diff --git a/cli/package.json b/cli/package.json index ebaab02b..f1d198cd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 601, + "bundleBudgetKB": 603, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index a93d7b8d..4a8f7c00 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -24,6 +24,11 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // display and the subcommand. It deletes 198 lines from a plugin, which the bundle does // not carry either way - the trade is a plugin script that had drifted for bytes that are // measured. Same 2.2 KB headroom as the three raises before it. +// 603 was set when a marketplace registry that cannot be read began saying so: measured +// 600.7 -> 601.2 KB, +0.5 KB for one guard and the sentence it prints. The smallest raise so +// far, for the smallest change - and the one that showed the budget had 0.3 KB of headroom +// left, which is less than a correctness fix costs. Same 2.2 KB headroom as the four raises +// before it. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/infrastructure/adapters/marketplace-registry-adapter.ts b/cli/src/infrastructure/adapters/marketplace-registry-adapter.ts index f5b89ec9..16ebf872 100644 --- a/cli/src/infrastructure/adapters/marketplace-registry-adapter.ts +++ b/cli/src/infrastructure/adapters/marketplace-registry-adapter.ts @@ -16,6 +16,31 @@ interface RegistryFile { marketplaces: MarketplaceData[]; } +/** A registry file that exists but cannot be read as one is never read as an empty + * registry. `save()` reads this same list, appends to it and writes the whole file back, so + * a silent empty read would not merely hide the marketplaces a person registered - it would + * delete them on the very next write. A file that is simply absent is a different answer and + * keeps its own: no file, no marketplaces, nothing to lose. */ +function unreadable(path: string, reason: string): Error { + return new Error( + `Cannot read the marketplace registry at ${path}: ${reason}. Repair the file, or ` + + `delete it to start from an empty registry.` + ); +} + +/** The registry's own list, or the reason the file cannot supply one. */ +function registryEntries(raw: string, path: string): MarketplaceData[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw unreadable(path, error instanceof Error ? error.message : "it is not valid JSON"); + } + const entries = (parsed as Partial | null)?.marketplaces; + if (!Array.isArray(entries)) throw unreadable(path, "it carries no \`marketplaces\` list"); + return entries; +} + export class MarketplaceRegistryAdapter implements MarketplaceRegistry { async list(projectRoot: string): Promise { const project = await this.read(this.projectPath(projectRoot), "project"); @@ -85,8 +110,7 @@ export class MarketplaceRegistryAdapter implements MarketplaceRegistry { } catch { return []; } - const parsed = JSON.parse(raw) as RegistryFile; - return parsed.marketplaces.map((m) => Marketplace.fromJSON({ ...m, scope })); + return registryEntries(raw, path).map((m) => Marketplace.fromJSON({ ...m, scope })); } private async write(path: string, entries: readonly Marketplace[]): Promise { diff --git a/cli/tests/infrastructure/adapters/marketplace-registry-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/marketplace-registry-adapter.integration.test.ts index cc676b12..49448fe7 100644 --- a/cli/tests/infrastructure/adapters/marketplace-registry-adapter.integration.test.ts +++ b/cli/tests/infrastructure/adapters/marketplace-registry-adapter.integration.test.ts @@ -43,6 +43,37 @@ describe("MarketplaceRegistryAdapter", () => { expect(result).toEqual([]); }); + // A registry file that exists but does not hold the list is not an empty registry, and + // reading it as one is how a person loses every marketplace they registered: `save()` + // reads this same list, appends to it and writes the whole file back, so one silent + // empty read turns into a file with one entry where there were five. Found on a real + // `~/.config/aidd/marketplaces.json` holding `{"version":1}`, which crashed + // `aidd marketplace list` with "Cannot read properties of undefined (reading 'map')" - + // a stack trace naming nothing a person can act on. + it("refuses a registry file that carries no list, naming the file rather than crashing", async () => { + const userFile = join(homeDir, ".config", "aidd", "marketplaces.json"); + await mkdir(join(homeDir, ".config", "aidd"), { recursive: true }); + await writeFile(userFile, '{"version":1}', "utf-8"); + + await expect(adapter.list(projectRoot)).rejects.toThrow(userFile); + }); + + it("refuses a registry file that is not JSON at all, naming the file", async () => { + const userFile = join(homeDir, ".config", "aidd", "marketplaces.json"); + await mkdir(join(homeDir, ".config", "aidd"), { recursive: true }); + await writeFile(userFile, "not json", "utf-8"); + + await expect(adapter.list(projectRoot)).rejects.toThrow(userFile); + }); + + it("reads a registry file whose list is present and empty as an empty registry", async () => { + const userFile = join(homeDir, ".config", "aidd", "marketplaces.json"); + await mkdir(join(homeDir, ".config", "aidd"), { recursive: true }); + await writeFile(userFile, '{"version":1,"marketplaces":[]}', "utf-8"); + + await expect(adapter.list(projectRoot)).resolves.toEqual([]); + }); + it("returns project entries first, user entries after", async () => { const project = Marketplace.fromJSON(baseData({ name: "p1" })); const user = Marketplace.fromJSON(baseData({ name: "u1", scope: "user" }));