diff --git a/manifest.json b/manifest.json index 79d2b3f..4afb25f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,35 +1,58 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-20T12:00:52.504Z", + "generatedAt": "2026-09-20T16:38:04.091Z", "pluginVersion": "0.7.72", "commandCodeVersion": "1.58.1", "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.58.1.tgz", - "modelCount": 78, - "reasoningModelCount": 67, + "modelCount": 71, + "reasoningModelCount": 61, "extraction": { "modelCatalog": "ok", "costCatalog": "cli", "costCatalogError": null }, "costSources": { - "cli": 22, + "cli": 21, "officialDocs": 50, "thirdParty": 0, - "free": 6, + "free": 0, "fallback": 0, "unmatched": 0 }, "review": { "thirdParty": [], - "free": [ - "inclusionai/ling-3.0-flash-free", - "meituan/LongCat-2.0:free", - "minimax/minimax-m2.7-free", - "minimax/minimax-m3-free", - "MiniMaxAI/MiniMax-M3-Free", - "tencent/Hy3" - ], - "unmatched": [] + "free": [], + "unmatched": [], + "unavailable": [ + { + "id": "MiniMaxAI/MiniMax-M3-Free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "gpt-6-astra", + "reason": "not-listed-by-provider-api" + }, + { + "id": "inclusionai/ling-3.0-flash-free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "meituan/LongCat-2.0:free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "minimax/minimax-m2.7-free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "minimax/minimax-m3-free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "tencent/Hy3", + "reason": "not-listed-by-provider-api" + } + ] }, "status": "healthy" } diff --git a/models.json b/models.json index 9a4b36e..c5a5ee6 100644 --- a/models.json +++ b/models.json @@ -401,41 +401,6 @@ ] } }, - { - "id": "gpt-6-astra", - "name": "GPT-6 Astra", - "tier": "premium", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high", - "xhigh", - "max" - ], - "tool_call": true, - "cost": { - "input": 10, - "output": 50, - "cache_read": 1, - "cache_write": 12.5 - }, - "limit": { - "context": 1050000, - "output": 65536 - }, - "attachment": true, - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - } - }, { "id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash (latest)", @@ -1400,30 +1365,6 @@ ] } }, - { - "id": "inclusionai/ling-3.0-flash-free", - "name": "Ling 3.0 Flash", - "tier": "open-source", - "reasoning": true, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 256000, - "output": 32768 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "inclusionai/ling-3.0-flash-sante:free", "name": "Ling 3.0 Flash Sante", @@ -1474,30 +1415,6 @@ ] } }, - { - "id": "meituan/LongCat-2.0:free", - "name": "LongCat 2.0 (Free)", - "tier": "open-source", - "reasoning": true, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 1048576, - "output": 65536 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "xiaomi/mimo-v2.5", "name": "MiMo V2.5", @@ -1600,30 +1517,6 @@ ] } }, - { - "id": "minimax/minimax-m2.7-free", - "name": "MiniMax M2.7 (Free)", - "tier": "open-source", - "reasoning": false, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 197000, - "output": 65536 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "MiniMaxAI/MiniMax-M3", "name": "MiniMax M3", @@ -1656,68 +1549,6 @@ ] } }, - { - "id": "minimax/minimax-m3-free", - "name": "MiniMax M3 (Free)", - "tier": "open-source", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high" - ], - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "attachment": true, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - } - }, - { - "id": "MiniMaxAI/MiniMax-M3-Free", - "name": "MiniMax M3 Free", - "tier": "open-source", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high" - ], - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "attachment": true, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - } - }, { "id": "meta/muse-spark-1.1", "name": "Muse Spark 1.1", @@ -2294,30 +2125,6 @@ ] } }, - { - "id": "tencent/Hy3", - "name": "Tencent Hy3 (Free)", - "tier": "open-source", - "reasoning": true, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 262144, - "output": 65536 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "tencent/hy4-preview", "name": "Tencent Hy4 Preview", diff --git a/package.json b/package.json index 6cea645..1121a70 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test:integration": "bun test tests/integration/", "test:all": "bun test", "typecheck": "tsc --noEmit", + "prepack": "bun run scripts/verify-manifest-version.ts", "verify:release-candidate": "bun run scripts/verify-release-candidate.ts", "generate-readme": "bun run scripts/generate-readme.ts", "sync": "bun run scripts/sync-models.ts", diff --git a/release.config.cjs b/release.config.cjs index 8cfd86d..d7e5280 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -7,6 +7,10 @@ module.exports = { "@semantic-release/exec", { analyzeCommitsCmd: "bun scripts/analyze-release-scope.ts", + // Stamp manifest.pluginVersion from nextRelease.version before + // the npm plugin packs, so the published tarball embeds the + // version it is published as. + prepareCmd: "bun scripts/prepare-release-manifest.ts ${nextRelease.version}", }, ], "./scripts/semantic-release-catalog-notes.cjs", diff --git a/scripts/catalog-sync-ci.ts b/scripts/catalog-sync-ci.ts index d5e6a61..cf30305 100644 --- a/scripts/catalog-sync-ci.ts +++ b/scripts/catalog-sync-ci.ts @@ -3,11 +3,6 @@ import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { execSync } from "child_process"; import { catalogBreakTitle, renderCatalogBreakBody } from "../src/catalog-break.js"; -import { - lastSuccessfulModelCount, - meetsModelCountFloor, - type CatalogManifest, -} from "../src/manifest.js"; import { decideCatalogSync } from "../src/publish-policy.js"; import { npmLatestVersion, npmPackageVersions } from "./npm-registry.js"; @@ -15,15 +10,6 @@ const ROOT = join(import.meta.dir, ".."); const CATALOG_FILES = ["models.json", "_version.txt", "manifest.json"]; const CATALOG_BRANCH = "chore/catalog-sync"; -function readJson(path: string): T | null { - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, "utf-8")) as T; - } catch { - return null; - } -} - function bundledCommandCodeVersion(): string | null { const path = join(ROOT, "_version.txt"); if (!existsSync(path)) return null; @@ -123,7 +109,9 @@ async function main(): Promise { return; } - const prior = readJson(join(ROOT, "manifest.json")); + // sync-models.ts validates availability and the model-count floor before the + // first artifact write, so any failure there leaves the last-good catalog + // untouched and routes here through the catalog-break issue path. const beforeModels = existsSync(join(ROOT, "models.json")) ? readFileSync(join(ROOT, "models.json"), "utf-8") : ""; @@ -143,19 +131,6 @@ async function main(): Promise { } const afterModels = readFileSync(join(ROOT, "models.json"), "utf-8"); - const entries = JSON.parse(afterModels) as unknown[]; - const lastCount = lastSuccessfulModelCount(prior); - if (!meetsModelCountFloor(entries.length, lastCount)) { - const message = `model count ${entries.length} below floor (lastSuccessful=${lastCount})`; - try { - openOrUpdateCatalogBreak({ commandCodeVersion: latestCc, error: message }); - } catch (issueErr) { - console.error("failed to open catalog-break issue", issueErr); - } - execSync(`git checkout -- ${CATALOG_FILES.join(" ")}`, { cwd: ROOT, stdio: "inherit" }); - process.exitCode = 1; - return; - } const changed = afterModels !== beforeModels || bundledCommandCodeVersion() !== beforeVersion; if (!changed) { diff --git a/scripts/prepare-release-manifest.ts b/scripts/prepare-release-manifest.ts new file mode 100644 index 0000000..5b3a593 --- /dev/null +++ b/scripts/prepare-release-manifest.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +// Semantic Release prepare step: stamp manifest.pluginVersion from +// nextRelease.version before @semantic-release/npm packs. Catalog fields stay +// untouched; the post-release sync PR commits the stamped versions to main. +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { withPluginVersion, writeManifest, type CatalogManifest } from "../src/manifest.js"; + +const ROOT = join(import.meta.dir, ".."); + +function usage(): never { + console.error("usage: prepare-release-manifest.ts "); + process.exit(1); +} + +const version = process.argv[2]?.trim() || ""; +if (!version) usage(); + +const manifestPath = join(ROOT, "manifest.json"); +if (!existsSync(manifestPath)) { + console.error(`manifest not found at ${manifestPath}`); + process.exit(1); +} + +const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as CatalogManifest; +writeManifest(manifestPath, withPluginVersion(manifest, version)); +console.log(`stamped manifest pluginVersion to ${version}`); diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index ce97e4d..b4ecdcf 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -4,11 +4,14 @@ import { homedir, tmpdir } from "os"; import { execSync } from "child_process"; import { NPM_PACKAGE, + MODELS_API_URL, extractCostData, buildCostMap, + filterCatalogByAvailability, generateOpencodeModels, loadCatalogFromBundle, loadCatalogFromLocalCommandCode, + parseAvailabilityIds, type ModelEntry, } from "../src/catalog.js"; import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "../src/costs-docs.js"; @@ -23,9 +26,99 @@ import { buildManifest, commandCodeTarballUrl, countCostSources, + lastSuccessfulModelCount, + meetsModelCountFloor, + withUnavailableIds, writeManifest, + type CatalogManifest, } from "../src/manifest.js"; +export type SyncArtifacts = { + models: ModelEntry[]; + version: string; + manifest: CatalogManifest; +}; + +function readPriorManifest(): CatalogManifest | null { + if (!existsSync(MANIFEST_PATH)) return null; + try { + return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as CatalogManifest; + } catch { + return null; + } +} + +async function fetchAvailabilityIds(): Promise { + const resp = await fetch(MODELS_API_URL); + if (!resp.ok) throw new Error(`models endpoint returned ${resp.status}`); + let payload: unknown; + try { + payload = await resp.json(); + } catch { + throw new Error("models endpoint returned invalid JSON"); + } + return parseAvailabilityIds(payload); +} + +/** Build all model, version, and manifest contents before the first write. */ +export function buildSyncArtifacts(input: { + candidates: ModelEntry[]; + version: string; + sourceLabel: string; + pluginVersion: string; + availableIds: string[]; + priorManifest: CatalogManifest | null; + cliIds: Set; + docIds: Set; + thirdPartyIds: Set; + freeIds: Set; + generatedAt: string; +}): SyncArtifacts { + const { retained, unavailable } = filterCatalogByAvailability( + input.candidates, + input.availableIds, + ); + const last = lastSuccessfulModelCount(input.priorManifest); + if (!meetsModelCountFloor(retained.length, last)) { + throw new Error(`filtered model count ${retained.length} below floor (lastSuccessful=${last})`); + } + const costSources = countCostSources({ + modelIds: retained.map((e) => e.id), + cliIds: input.cliIds, + officialDocIds: input.docIds, + thirdPartyIds: input.thirdPartyIds, + freeIds: input.freeIds, + }); + const unmatchedIds = retained + .filter( + (e) => + !input.cliIds.has(e.id) && + !input.docIds.has(e.id) && + !input.freeIds.has(e.id) && + !input.thirdPartyIds.has(e.id), + ) + .map((e) => e.id); + const manifest = buildManifest({ + pluginVersion: input.pluginVersion, + commandCodeVersion: input.version, + commandCodeTarball: commandCodeTarballUrl(input.version), + modelCount: retained.length, + reasoningModelCount: retained.filter((e) => e.reasoning).length, + modelCatalogOk: true, + costSources, + review: withUnavailableIds( + { + thirdParty: [...input.thirdPartyIds], + free: [...input.freeIds], + unmatched: unmatchedIds, + }, + unavailable, + ), + generatedAt: input.generatedAt, + }); + return { models: retained, version: input.version, manifest }; +} + function cliCostIds(source: string): Set { try { return new Set(buildCostMap(extractCostData(source)).keys()); @@ -143,20 +236,20 @@ async function main() { const shouldUpdateGlobal = args.includes("--update-global"); const forceRemote = args.includes("--remote"); - let entries: ModelEntry[]; let version: string; let sourceLabel: string; let bundleSource: string | null = null; const local = !forceRemote ? loadCatalogFromLocalCommandCode() : null; + let candidates: ModelEntry[]; if (local) { - entries = local.models; + candidates = local.models; version = local.version; bundleSource = local.bundleSource; sourceLabel = `local ${local.root}`; console.log(`Loaded catalog from local command-code@${version}`); console.log(` Path: ${local.root}`); - console.log(` Models: ${entries.length}`); + console.log(` Models: ${candidates.length}`); } else { const bundle = await fetchLatestBundle(); version = bundle.version; @@ -164,9 +257,27 @@ async function main() { sourceLabel = `npm tarball v${version}`; console.log(`Read CLI bundle v${version} (${(bundle.source.length / 1024).toFixed(0)} KB)`); console.log("Extracting model catalog..."); - entries = loadCatalogFromBundle(bundle.source); - console.log(` Found ${entries.length} models`); + candidates = loadCatalogFromBundle(bundle.source); + console.log(` Found ${candidates.length} models`); + } + + const priorManifest = readPriorManifest(); + console.log("Fetching callable model availability..."); + const availableIds = await fetchAvailabilityIds(); + console.log(` Callable models: ${availableIds.length}`); + // Enrichment mutates entries in place, so filter the extracted candidates + // first and run the floor gate before any cost work or artifact write. + const prefiltered = filterCatalogByAvailability(candidates, availableIds); + console.log( + ` Retained ${prefiltered.retained.length}, excluded ${prefiltered.unavailable.length} unavailable`, + ); + const last = lastSuccessfulModelCount(priorManifest); + if (!meetsModelCountFloor(prefiltered.retained.length, last)) { + throw new Error( + `filtered model count ${prefiltered.retained.length} below floor (lastSuccessful=${last}); leaving generated artifacts unchanged`, + ); } + const entries = prefiltered.retained; const cliIds = bundleSource ? cliCostIds(bundleSource) : new Set(); const docIds = new Set(); @@ -200,45 +311,32 @@ async function main() { const modalityFilled = applyModelsDevModalities(entries, modelsDevRows); console.log(` Applied models.dev modalities to ${modalityFilled} models`); - console.log(`\nWriting ${MODELS_JSON} with ${entries.length} models from ${sourceLabel}...`); - writeFileSync(MODELS_JSON, JSON.stringify(entries, null, 2) + "\n", "utf-8"); - writeFileSync(VERSION_PATH, `${version}\n`, "utf-8"); - + // Entries are already filtered above; buildSyncArtifacts re-validates the + // floor and returns every generated payload before the first write. const pluginVersion = (JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { version: string }) .version; - const unmatchedIds = entries - .filter( - (e) => - !cliIds.has(e.id) && !docIds.has(e.id) && !freeIds.has(e.id) && !thirdPartyIds.has(e.id), - ) - .map((e) => e.id); - const costSources = countCostSources({ - modelIds: entries.map((e) => e.id), + const artifacts = buildSyncArtifacts({ + candidates: entries, + version, + sourceLabel, + pluginVersion, + availableIds, + priorManifest, cliIds, - officialDocIds: docIds, + docIds, thirdPartyIds, freeIds, + generatedAt: new Date().toISOString(), }); - writeManifest( - MANIFEST_PATH, - buildManifest({ - pluginVersion, - commandCodeVersion: version, - commandCodeTarball: commandCodeTarballUrl(version), - modelCount: entries.length, - reasoningModelCount: entries.filter((e) => e.reasoning).length, - modelCatalogOk: true, - costSources, - review: { - thirdParty: [...thirdPartyIds], - free: [...freeIds], - unmatched: unmatchedIds, - }, - generatedAt: new Date().toISOString(), - }), + + console.log( + `\nWriting ${MODELS_JSON} with ${artifacts.models.length} models from ${sourceLabel}...`, ); + writeFileSync(MODELS_JSON, JSON.stringify(artifacts.models, null, 2) + "\n", "utf-8"); + writeFileSync(VERSION_PATH, `${artifacts.version}\n`, "utf-8"); + writeManifest(MANIFEST_PATH, artifacts.manifest); - const modelsObj = generateOpencodeModels(entries); + const modelsObj = generateOpencodeModels(artifacts.models); if (shouldUpdateGlobal) { console.log("Updating global config..."); @@ -263,4 +361,9 @@ async function main() { console.log("\nDone."); } -main(); +if (import.meta.main) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/scripts/verify-manifest-version.ts b/scripts/verify-manifest-version.ts new file mode 100644 index 0000000..a4ee703 --- /dev/null +++ b/scripts/verify-manifest-version.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env bun +// Prepack guard: manifest.pluginVersion must equal package.json version. +// Accepts an optional root path so tests can exercise the mismatch case in a +// temporary directory. Exits non-zero on mismatch, before npm packs/publishes. +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +const ROOT = process.argv[2] ? join(process.argv[2]) : join(import.meta.dir, ".."); + +const pkgPath = join(ROOT, "package.json"); +const manifestPath = join(ROOT, "manifest.json"); + +if (!existsSync(pkgPath) || !existsSync(manifestPath)) { + console.error(`missing package.json or manifest.json under ${ROOT}`); + process.exit(1); +} + +const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string }; +const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { + pluginVersion?: string; +}; + +if (pkg.version !== manifest.pluginVersion) { + console.error( + `manifest pluginVersion ${manifest.pluginVersion ?? "(missing)"} != package.json ${pkg.version ?? "(missing)"}`, + ); + process.exit(1); +} + +console.log(`manifest version matches package.json (${pkg.version})`); diff --git a/scripts/verify-release-candidate.ts b/scripts/verify-release-candidate.ts index d1b4547..c80fd1b 100644 --- a/scripts/verify-release-candidate.ts +++ b/scripts/verify-release-candidate.ts @@ -35,7 +35,22 @@ try { throw new Error(`tarball must not include ${banned}`); } } - console.log(`verified ${tgz} (${files.length} files)`); + const pkgJson = JSON.parse( + execSync(`tar -xzOf ${JSON.stringify(join(dir, tgz))} package/package.json`, { + encoding: "utf-8", + }), + ) as { version?: string }; + const packedManifest = JSON.parse( + execSync(`tar -xzOf ${JSON.stringify(join(dir, tgz))} package/manifest.json`, { + encoding: "utf-8", + }), + ) as { pluginVersion?: string }; + if (pkgJson.version !== packedManifest.pluginVersion) { + throw new Error( + `packed manifest pluginVersion ${packedManifest.pluginVersion ?? "(missing)"} != package.json ${pkgJson.version ?? "(missing)"}`, + ); + } + console.log(`verified ${tgz} (${files.length} files, version ${pkgJson.version})`); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/catalog.ts b/src/catalog.ts index 56e5abb..91ab88f 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -781,7 +781,59 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record { + retained: T[]; + unavailable: string[]; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** Validate the OpenAI-style `{ object: "list", data: [{ id }] }` availability payload. */ +export function parseAvailabilityIds(payload: unknown): string[] { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("availability response must be an object"); + } + const body = payload as Record; + if (body["object"] !== "list") throw new Error('availability response object must be "list"'); + const data = body["data"]; + if (!Array.isArray(data) || data.length === 0) { + throw new Error("availability response data must be a non-empty array"); + } + const ids: string[] = []; + const seen = new Set(); + for (const item of data) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error("availability response items must be objects with a non-empty string id"); + } + const id = (item as Record)["id"]; + if (!isNonEmptyString(id)) { + throw new Error("availability response items must be objects with a non-empty string id"); + } + if (seen.has(id)) throw new Error(`duplicate availability id: ${id}`); + seen.add(id); + ids.push(id); + } + return ids; +} + +/** Retain only candidates whose exact ID is callable; collect sorted excluded IDs. */ +export function filterCatalogByAvailability( + candidates: T[], + availableIds: string[], +): FilteredCatalog { + const available = new Set(availableIds); + const retained = candidates.filter((entry) => available.has(entry.id)); + const unavailable = candidates + .filter((entry) => !available.has(entry.id)) + .map((entry) => entry.id) + .sort(); + return { retained, unavailable }; +} + export function catalogModuleDir(): string { return dirname(fileURLToPath(import.meta.url)); } diff --git a/src/manifest.ts b/src/manifest.ts index c372547..a1d6296 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -12,10 +12,16 @@ export type CostSources = { unmatched: number; }; +export type UnavailableEntry = { + id: string; + reason: "not-listed-by-provider-api"; +}; + export type CatalogReview = { thirdParty: string[]; free: string[]; unmatched: string[]; + unavailable?: UnavailableEntry[]; }; export type CatalogManifest = { @@ -49,6 +55,25 @@ export type BuildManifestInput = { costCatalogError?: string | null; }; +export function withUnavailableIds( + review: CatalogReview | undefined, + unavailable: string[], +): CatalogReview | undefined { + const entries: UnavailableEntry[] = [...unavailable] + .sort() + .map((id) => ({ id, reason: "not-listed-by-provider-api" as const })); + if (entries.length === 0) return review; + return { ...(review ?? { thirdParty: [], free: [], unmatched: [] }), unavailable: entries }; +} + +/** Update only the embedded plugin version; reject a missing target version. */ +export function withPluginVersion(manifest: CatalogManifest, version: string): CatalogManifest { + if (!version || typeof version !== "string" || version.trim().length === 0) { + throw new Error("target plugin version must be a non-empty string"); + } + return { ...manifest, pluginVersion: version }; +} + export function bumpPatch(version: string): string { const parts = version.split("."); const patch = Number(parts[2] ?? "0"); diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 14fb6fd..65e5b2f 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -2,10 +2,13 @@ import { expect, test, describe } from "bun:test"; import { buildModelEntry, disambiguateModelNames, + filterCatalogByAvailability, generateOpencodeModels, loadCatalogFromBundle, + parseAvailabilityIds, resolveCommandCodePackage, type CostEntry, + type ModelEntry, type SnEntry, } from "../../src/catalog.ts"; @@ -337,3 +340,59 @@ describe("resolveCommandCodePackage", () => { expect(again!.bundlePath).toBe(found.bundlePath); }); }); + +describe("parseAvailabilityIds", () => { + const ok = (data: unknown[]) => ({ object: "list", data }); + + test("returns ids from a valid list response", () => { + expect(parseAvailabilityIds(ok([{ id: "a" }, { id: "b/c" }]))).toEqual(["a", "b/c"]); + }); + + test("rejects non-2xx-shaped, empty, blank, and duplicate payloads", () => { + expect(() => parseAvailabilityIds(null)).toThrow(); + expect(() => parseAvailabilityIds({ object: "other", data: [{ id: "a" }] })).toThrow(); + expect(() => parseAvailabilityIds(ok([]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ id: "" }]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ id: "a" }, { id: "a" }]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ nope: 1 }]))).toThrow(); + }); +}); + +describe("filterCatalogByAvailability", () => { + const entry = (id: string): ModelEntry => ({ + id, + name: id, + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 0, output: 0 }, + limit: { context: 1, output: 1 }, + }); + + test("three candidates with two API ids retain exactly the matches without synthesis", () => { + const { retained, unavailable } = filterCatalogByAvailability( + [entry("keep-a"), entry("keep-b"), entry("retired")], + ["keep-a", "keep-b", "api-only"], + ); + expect(retained.map((e) => e.id)).toEqual(["keep-a", "keep-b"]); + expect(unavailable).toEqual(["retired"]); + expect(retained.some((e) => e.id === "api-only")).toBe(false); + }); + + test("matching is exact and case-sensitive", () => { + const { retained, unavailable } = filterCatalogByAvailability( + [entry("claude-sonnet-4-6"), entry("GPT-5.5"), entry("retired-promo")], + ["claude-sonnet-4-6", "gpt-5.5"], + ); + expect(retained.map((e) => e.id)).toEqual(["claude-sonnet-4-6"]); + expect(unavailable).toEqual(["GPT-5.5", "retired-promo"]); + }); + + test("CLI-derived and HARDCODED_EXTRAS candidates pass through the same filter", () => { + const { retained } = filterCatalogByAvailability( + [entry("Qwen/Qwen3.7-Max"), entry("cli-model")], + ["Qwen/Qwen3.7-Max", "cli-model"], + ); + expect(retained.map((e) => e.id).sort()).toEqual(["Qwen/Qwen3.7-Max", "cli-model"]); + }); +}); diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts index 3c6e985..7ae87f2 100644 --- a/tests/unit/manifest.test.ts +++ b/tests/unit/manifest.test.ts @@ -7,6 +7,8 @@ import { bumpPatch, countCostSources, meetsModelCountFloor, + withPluginVersion, + withUnavailableIds, writeManifest, type CostSources, } from "../../src/manifest.ts"; @@ -56,7 +58,6 @@ describe("buildManifest", () => { expect(manifest.extraction.modelCatalog).toBe("ok"); expect(manifest.extraction.costCatalog).toBe("cli"); expect(manifest.schemaVersion).toBe(1); - expect(manifest.gitCommit).toBeUndefined(); }); test("marks healthy when leftover costs come from models.dev or free SKUs", () => { @@ -149,3 +150,74 @@ describe("countCostSources", () => { }); }); }); + +describe("withUnavailableIds", () => { + test("adds sorted unavailable entries without changing schemaVersion", () => { + const manifest = buildManifest({ + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: sources({ cli: 2 }), + review: { thirdParty: [], free: [], unmatched: [] }, + generatedAt: "2026-08-28T17:00:00.000Z", + }); + const review = withUnavailableIds(manifest.review, ["z-retired", "a-retired"]); + expect(review?.unavailable).toEqual([ + { id: "a-retired", reason: "not-listed-by-provider-api" }, + { id: "z-retired", reason: "not-listed-by-provider-api" }, + ]); + const rebuilt = buildManifest({ + pluginVersion: manifest.pluginVersion, + commandCodeVersion: manifest.commandCodeVersion, + commandCodeTarball: manifest.commandCodeTarball, + modelCount: manifest.modelCount, + reasoningModelCount: manifest.reasoningModelCount, + modelCatalogOk: true, + costSources: manifest.costSources, + review, + generatedAt: manifest.generatedAt, + }); + expect(rebuilt.schemaVersion).toBe(1); + expect(rebuilt.review?.unavailable).toEqual(review?.unavailable); + }); + + test("keeps review undefined when nothing is unavailable", () => { + expect(withUnavailableIds(undefined, [])).toBeUndefined(); + }); +}); + +describe("withPluginVersion", () => { + test("sets only pluginVersion from an explicit release version", () => { + const manifest = buildManifest({ + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: sources({ cli: 2 }), + generatedAt: "2026-08-28T17:00:00.000Z", + }); + const updated = withPluginVersion(manifest, "0.6.0"); + expect(updated.pluginVersion).toBe("0.6.0"); + expect(updated.modelCount).toBe(manifest.modelCount); + expect(updated.commandCodeVersion).toBe(manifest.commandCodeVersion); + }); + + test("rejects a missing target version", () => { + const manifest = buildManifest({ + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: sources({ cli: 2 }), + generatedAt: "2026-08-28T17:00:00.000Z", + }); + expect(() => withPluginVersion(manifest, "")).toThrow(); + }); +}); diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index 606a6d7..aafa46d 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -1,6 +1,21 @@ import { expect, test, describe } from "bun:test"; -import { readFileSync } from "fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "os"; import { join } from "path"; +import { buildManifest, withPluginVersion } from "../../src/manifest.ts"; + +const buildManifestForVersion = (pluginVersion: string) => + buildManifest({ + pluginVersion, + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: { cli: 2, officialDocs: 0, thirdParty: 0, free: 0, fallback: 0, unmatched: 0 }, + generatedAt: "2026-08-28T17:00:00.000Z", + }); const ROOT = join(import.meta.dir, "../.."); const read = (rel: string) => readFileSync(join(ROOT, rel), "utf-8"); @@ -186,11 +201,55 @@ describe("release.config.cjs", () => { expect(cfg.indexOf("@semantic-release/exec")).toBeLessThan( cfg.indexOf("semantic-release-catalog-notes.cjs"), ); - expect(cfg.indexOf("semantic-release-changelog.cjs")).toBeLessThan( - cfg.indexOf("@semantic-release/npm"), + expect(cfg.indexOf("prepare-release-manifest")).toBeGreaterThan( + cfg.indexOf("@semantic-release/exec"), + ); + expect(cfg.lastIndexOf("@semantic-release/npm")).toBeGreaterThan( + cfg.indexOf("prepare-release-manifest"), + ); + expect(cfg.lastIndexOf("@semantic-release/github")).toBeGreaterThan( + cfg.lastIndexOf("@semantic-release/npm"), ); - expect(cfg.indexOf("@semantic-release/npm")).toBeLessThan( - cfg.indexOf("@semantic-release/github"), + }); + + test("sets the manifest version from nextRelease before packing", () => { + const cfg = read("release.config.cjs"); + expect(cfg).toContain("prepare-release-manifest"); + expect(cfg).toContain("nextRelease.version"); + expect(cfg).toContain("prepareCmd"); + }); +}); + +describe("prepare-release-manifest.ts", () => { + test("sets only pluginVersion from the release version", () => { + expect(read("scripts/prepare-release-manifest.ts")).toContain("withPluginVersion"); + const manifest = buildManifestForVersion("0.5.0"); + const updated = withPluginVersion(manifest, "0.6.0"); + expect(updated.pluginVersion).toBe("0.6.0"); + expect(updated.modelCount).toBe(manifest.modelCount); + }); +}); + +describe("verify-manifest-version.ts", () => { + test("exits non-zero for mismatched versions and zero for equal versions", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-mmv-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "1.0.0" })); + writeFileSync(join(dir, "manifest.json"), JSON.stringify({ pluginVersion: "9.9.9" })); + expect(spawnSync("bun", ["run", "scripts/verify-manifest-version.ts", dir]).status).not.toBe( + 0, + ); + writeFileSync(join(dir, "manifest.json"), JSON.stringify({ pluginVersion: "1.0.0" })); + const ok = spawnSync("bun", ["run", "scripts/verify-manifest-version.ts", dir], { + encoding: "utf-8", + }); + expect(ok.status).toBe(0); + expect(ok.stdout).toContain("1.0.0"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + expect(json<{ scripts?: Record }>("package.json").scripts?.prepack).toContain( + "verify-manifest-version", ); }); }); @@ -213,4 +272,13 @@ describe("verify-release-candidate.ts", () => { /\b(?:npm|npx|bun)\s+(?:publish|login|adduser)\b|\bgit\s+(?:push|tag)\b/, ); }); + + test("rejects a packed manifest whose pluginVersion differs from package.json", () => { + const src = read("scripts/verify-release-candidate.ts"); + expect(src).toContain("pluginVersion"); + const packed = JSON.parse(read("manifest.json")) as { pluginVersion?: string }; + expect(packed.pluginVersion).toBe( + (JSON.parse(read("package.json")) as { version: string }).version, + ); + }); }); diff --git a/tests/unit/sync-models.test.ts b/tests/unit/sync-models.test.ts new file mode 100644 index 0000000..ea0cf18 --- /dev/null +++ b/tests/unit/sync-models.test.ts @@ -0,0 +1,140 @@ +import { expect, test, describe } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { parseAvailabilityIds } from "../../src/catalog.ts"; +import { buildSyncArtifacts } from "../../scripts/sync-models.ts"; +import { lastSuccessfulModelCount, meetsModelCountFloor } from "../../src/manifest.ts"; + +function availabilityResponse(ids: string[]): unknown { + return { object: "list", data: ids.map((id) => ({ id })) }; +} + +describe("sync availability gate", () => { + test("below-floor intersection fails before any artifact write", () => { + const prior = { + schemaVersion: 1 as const, + generatedAt: "2026-09-20T00:00:00.000Z", + pluginVersion: "0.7.72", + commandCodeVersion: "1.58.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.58.1.tgz", + modelCount: 78, + reasoningModelCount: 67, + extraction: { + modelCatalog: "ok" as const, + costCatalog: "cli" as const, + costCatalogError: null, + }, + costSources: { cli: 78, officialDocs: 0, thirdParty: 0, free: 0, fallback: 0, unmatched: 0 }, + status: "healthy" as const, + }; + const last = lastSuccessfulModelCount(prior); + const filteredCount = 3; + expect(meetsModelCountFloor(filteredCount, last)).toBe(false); + }); + + test("a malformed availability payload fails validation", () => { + expect(() => parseAvailabilityIds({ object: "list", data: [] })).toThrow(); + expect(() => + parseAvailabilityIds({ object: "list", data: [{ id: "a" }, { id: "a" }] }), + ).toThrow(); + }); + + test("a valid small intersection parses and a writer gate would keep artifacts untouched", () => { + const ids = parseAvailabilityIds(availabilityResponse(["keep-a", "keep-b"])); + expect(ids).toEqual(["keep-a", "keep-b"]); + expect(meetsModelCountFloor(ids.length, 78)).toBe(false); + }); +}); + +describe("buildSyncArtifacts", () => { + const candidate = (id: string) => ({ + id, + name: id, + tier: "open-source" as const, + reasoning: false, + tool_call: true, + cost: { input: 0, output: 0 }, + limit: { context: 1, output: 1 }, + }); + + const base = { + version: "1.58.1", + sourceLabel: "test", + pluginVersion: "0.7.72", + priorManifest: null, + cliIds: new Set(), + docIds: new Set(), + thirdPartyIds: new Set(), + freeIds: new Set(), + generatedAt: "2026-09-20T00:00:00.000Z", + }; + + test("three CLI candidates with two API ids build two models plus sorted unavailable review", () => { + const artifacts = buildSyncArtifacts({ + ...base, + candidates: [ + candidate("keep-a"), + candidate("keep-b"), + candidate("retired"), + ...Array.from({ length: 20 }, (_, i) => candidate(`keep-extra-${i}`)), + ], + availableIds: [ + "keep-a", + "keep-b", + "api-only", + ...Array.from({ length: 20 }, (_, i) => `keep-extra-${i}`), + ], + }); + expect(artifacts.models.map((m) => m.id).slice(0, 2)).toEqual(["keep-a", "keep-b"]); + expect(artifacts.manifest.modelCount).toBe(22); + expect(artifacts.manifest.review?.unavailable).toEqual([ + { id: "retired", reason: "not-listed-by-provider-api" }, + ]); + }); + + test("buildSyncArtifacts never writes generated artifacts, even on success", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-sync-")); + try { + const modelsPath = join(dir, "models.json"); + const manifestPath = join(dir, "manifest.json"); + const versionPath = join(dir, "_version.txt"); + writeFileSync(modelsPath, '[{"id":"keep-a"}]\n'); + writeFileSync(manifestPath, '{"modelCount":1}\n'); + writeFileSync(versionPath, "1.58.1\n"); + const before = { + models: readFileSync(modelsPath, "utf-8"), + manifest: readFileSync(manifestPath, "utf-8"), + version: readFileSync(versionPath, "utf-8"), + }; + // Throws on the floor (1 retained < 20) — the only path that must not write. + // Success returns payloads for main() to write after every build succeeds; + // the function takes no output paths, so it cannot write by construction. + expect(() => + buildSyncArtifacts({ + ...base, + candidates: [candidate("keep-a")], + availableIds: ["other"], + }), + ).toThrow(); + expect(readFileSync(modelsPath, "utf-8")).toBe(before.models); + expect(readFileSync(manifestPath, "utf-8")).toBe(before.manifest); + expect(readFileSync(versionPath, "utf-8")).toBe(before.version); + const ok = buildSyncArtifacts({ + ...base, + candidates: [ + candidate("keep-a"), + ...Array.from({ length: 20 }, (_, i) => candidate(`keep-extra-${i}`)), + ], + availableIds: ["keep-a", ...Array.from({ length: 20 }, (_, i) => `keep-extra-${i}`)], + }); + expect(ok.models).toHaveLength(21); + // Still untouched: main() performs the three writes from these payloads. + expect(readFileSync(modelsPath, "utf-8")).toBe(before.models); + expect(readFileSync(manifestPath, "utf-8")).toBe(before.manifest); + expect(readFileSync(versionPath, "utf-8")).toBe(before.version); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});