From 5a07ea2e5faacbe74143f1fa1f465d6d05ff5589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 2 Nov 2026 14:47:04 +0000 Subject: [PATCH] fix(cli): catalog says the registry could not be read instead of listing nothing (#7015) --- packages/cli/src/commands/catalog.test.ts | 24 +++++++++++++++++++---- packages/cli/src/commands/catalog.ts | 11 +++++++---- packages/cli/src/registry/remote.test.ts | 16 +++++++++++++++ packages/cli/src/registry/remote.ts | 16 +++++++++++---- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/catalog.test.ts b/packages/cli/src/commands/catalog.test.ts index 39ca652c9d..d877a362fe 100644 --- a/packages/cli/src/commands/catalog.test.ts +++ b/packages/cli/src/commands/catalog.test.ts @@ -73,6 +73,7 @@ describe("countUnindexed", () => { const state = vi.hoisted(() => ({ registry: [] as Array<{ name: string; type: string; tags?: string[] }>, + registryUnreadable: "", artifactRevision: "revision-current", cachedVectorRevision: "revision-current", vectorFetches: 0, @@ -108,10 +109,10 @@ vi.mock("../registry/resolver.js", () => ({ })); vi.mock("../registry/remote.js", () => ({ - fetchRegistryManifest: async () => ({ - items: state.registry, - catalogArtifact: { revision: state.artifactRevision }, - }), + readRegistryManifest: async () => { + if (state.registryUnreadable) throw new Error(state.registryUnreadable); + return { items: state.registry, catalogArtifact: { revision: state.artifactRevision } }; + }, })); // The terminal and CI switches below decide attendance; the agent running the suite must not. @@ -276,6 +277,7 @@ async function runForExit( } beforeEach(() => { + state.registryUnreadable = ""; state.modelStatus = "ready"; state.artifactRevision = "revision-current"; state.cachedVectorRevision = "revision-current"; @@ -301,6 +303,20 @@ beforeEach(() => { ]; }); +describe("catalog when the registry cannot be read", () => { + it("exits non-zero with the reason instead of printing an empty list", async () => { + state.registryUnreadable = + "The registry could not be read: fetch failed (getaddrinfo ENOTFOUND raw.githubusercontent.com)"; + + const { exitCode, err } = await runForExit({ json: true }); + + expect(exitCode).toBe(1); + expect(err).toContain("The registry could not be read: fetch failed"); + expect(err).toContain("raw.githubusercontent.com"); + expect(err).not.toContain("[]"); + }); +}); + describe("catalog --json meaning search", () => { it("reports the registry moves meaning search cannot see", async () => { // Published after the user's artifact was fetched: in the registry, absent diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 7c7c112f4e..127f968167 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -14,7 +14,7 @@ import * as clack from "../ui/prompts.js"; import { realpath, type ItemType, type RegistryItem } from "@hyperframes/core"; import { c } from "../ui/colors.js"; import { loadAllItems } from "../registry/resolver.js"; -import { fetchRegistryManifest } from "../registry/remote.js"; +import { readRegistryManifest } from "../registry/remote.js"; import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js"; import { relative, resolve } from "node:path"; import { finishCommand } from "../utils/commandResult.js"; @@ -232,9 +232,12 @@ export default defineCommand({ // Asked for the whole manifest on purpose: its item list defines coverage, // and its artifact revision is the one owner of vector freshness. - const manifest = await fetchRegistryManifest(config.registry); - const entries = manifest?.items ?? []; - const artifactRevision = manifest?.catalogArtifact?.revision; + const manifest = await readRegistryManifest(config.registry).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + return finishCommand(1); + }); + const entries = manifest.items; + const artifactRevision = manifest.catalogArtifact?.revision; const catalog = entries.filter((e) => e.type !== "hyperframes:example"); const registryNames = new Set(catalog.map((e) => e.name)); const filtered = typeFilter ? catalog.filter((e) => e.type === typeFilter) : catalog; diff --git a/packages/cli/src/registry/remote.test.ts b/packages/cli/src/registry/remote.test.ts index 6a93ade2cd..5b8b941abd 100644 --- a/packages/cli/src/registry/remote.test.ts +++ b/packages/cli/src/registry/remote.test.ts @@ -18,6 +18,7 @@ const { fetchItemFile, fetchItemManifest, fetchRegistryManifest, + readRegistryManifest, DEFAULT_REGISTRY_URL, } = await import("./remote.js"); @@ -76,6 +77,21 @@ afterAll(() => { rmSync(scratchHome, { recursive: true, force: true }); }); +describe("readRegistryManifest", () => { + it("throws why when the registry host cannot be reached and nothing is cached", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("getaddrinfo ENOTFOUND raw.githubusercontent.com"), { + code: "ENOTFOUND", + }), + }), + ); + await expect(readRegistryManifest(DEFAULT_REGISTRY_URL)).rejects.toThrow( + "The registry could not be read: fetch failed (getaddrinfo ENOTFOUND raw.githubusercontent.com)", + ); + }); +}); + describe("fetchRegistryManifest", () => { it("rejects poisoned stale cache data instead of returning it on network failure", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(ok(MANIFEST)); diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts index ae3d4ff707..1c26e4ace4 100644 --- a/packages/cli/src/registry/remote.ts +++ b/packages/cli/src/registry/remote.ts @@ -123,10 +123,17 @@ async function fetchJson(url: string, validate: (value: unknown) => value is * because "check for something newer" and "rather have nothing than this" are * different requests and only the first one is ever made. */ -export async function fetchRegistryManifest( +export const fetchRegistryManifest = ( baseUrl: string = DEFAULT_REGISTRY_URL, options?: { skipCache?: boolean }, -): Promise { +): Promise => + readRegistryManifest(baseUrl, options).catch(() => undefined); + +/** The manifest as fetchRegistryManifest gives it, but unreachable and never cached throws why, never undefined. */ +export async function readRegistryManifest( + baseUrl: string = DEFAULT_REGISTRY_URL, + options?: { skipCache?: boolean }, +): Promise { const url = registryPathUrl(baseUrl, "registry.json"); const cacheFile = cachePath(baseUrl, "registry"); const cached = readCacheEntry(cacheFile, validRegistryManifest); @@ -136,8 +143,9 @@ export async function fetchRegistryManifest( const manifest = await fetchJson(url, validRegistryManifest); writeCache(cacheFile, manifest); return manifest; - } catch { - return cached?.data; + } catch (err) { + if (cached) return cached.data; + throw new Error(`The registry could not be read: ${describeCauseChain(err)}`, { cause: err }); } }