diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts index 531a7f009a..99a5e6afda 100644 --- a/packages/cli/src/registry/remote.ts +++ b/packages/cli/src/registry/remote.ts @@ -6,16 +6,22 @@ * * Base URL layout: * /registry.json → top-level manifest - * /examples//registry-item.json - * /examples// → individual files referenced by the item - * /blocks//... (PR 5+) - * /components//... (PR 5+) + * ///registry-item.json + * /// → individual files referenced by the item + * + * `` comes from ITEM_TYPE_DIRS in @hyperframes/core. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; -import type { FileTarget, ItemType, RegistryItem, RegistryManifest } from "@hyperframes/core"; +import { + ITEM_TYPE_DIRS, + type FileTarget, + type ItemType, + type RegistryItem, + type RegistryManifest, +} from "@hyperframes/core"; export const DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry"; @@ -24,7 +30,7 @@ const FETCH_TIMEOUT_MS = 10_000; // ── Caching ───────────────────────────────────────────────────────────────── // 24h TTL on manifest fetches so the interactive picker stays snappy offline. -// Item files aren't cached (they're downloaded straight to destDir on install). +// Item files aren't cached — they're written straight to destDir on install. const CACHE_DIR = join(homedir(), ".hyperframes", "cache"); const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -40,12 +46,12 @@ function cachePath(baseUrl: string, key: string): string { } function readCache(path: string): T | undefined { - if (!existsSync(path)) return undefined; try { const entry = JSON.parse(readFileSync(path, "utf-8")) as CacheEntry; if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) return undefined; return entry.data; } catch { + // Missing file or corrupt JSON → cache miss. return undefined; } } @@ -86,18 +92,6 @@ export async function fetchRegistryManifest( } } -/** Directory segment for an item type under the registry base URL. */ -function typeDir(type: ItemType): string { - switch (type) { - case "hyperframes:example": - return "examples"; - case "hyperframes:block": - return "blocks"; - case "hyperframes:component": - return "components"; - } -} - /** * Fetch a single item's `registry-item.json` manifest. Cached for 24h. * Throws on network failure (callers decide whether to degrade gracefully). @@ -107,11 +101,12 @@ export async function fetchItemManifest( type: ItemType, baseUrl: string = DEFAULT_REGISTRY_URL, ): Promise { - const cacheFile = cachePath(baseUrl, `${typeDir(type)}__${name}`); + const dir = ITEM_TYPE_DIRS[type]; + const cacheFile = cachePath(baseUrl, `${dir}__${name}`); const cached = readCache(cacheFile); if (cached) return cached; - const url = `${baseUrl}/${typeDir(type)}/${name}/registry-item.json`; + const url = `${baseUrl}/${dir}/${name}/registry-item.json`; const item = await fetchJson(url); writeCache(cacheFile, item); return item; @@ -127,7 +122,7 @@ export async function fetchItemFile( destPath: string, baseUrl: string = DEFAULT_REGISTRY_URL, ): Promise { - const url = `${baseUrl}/${typeDir(item.type)}/${item.name}/${file.path}`; + const url = `${baseUrl}/${ITEM_TYPE_DIRS[item.type]}/${item.name}/${file.path}`; const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) { throw new Error(`File fetch failed: ${url} — HTTP ${res.status}`); diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index e91f919d70..e45b2ba2a4 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -1,7 +1,7 @@ /** * Registry resolver — loads the top-level manifest and per-item manifests. - * No transitive dependency resolution yet (examples don't have any); that - * lands when `hyperframes add` (PR 5) needs it for blocks/components. + * No transitive dependency resolution yet (examples don't have any); added + * when blocks/components need it for the `add` command. */ import type { ItemType, RegistryItem, RegistryManifestEntry } from "@hyperframes/core"; @@ -9,6 +9,16 @@ import { fetchItemManifest, fetchRegistryManifest, DEFAULT_REGISTRY_URL } from " export interface ResolveOptions { baseUrl?: string; + /** + * Called once per item that fails to load inside `loadAllItems`. Defaults + * to writing a diagnostic line to stderr. Pass a quieter implementation + * when rendering structured output (clack prompts, JSON, etc.). + */ + onWarn?: (message: string) => void; +} + +function defaultWarn(message: string): void { + process.stderr.write(`hyperframes:registry ${message}\n`); } /** @@ -37,6 +47,7 @@ export async function loadAllItems( options: ResolveOptions = {}, ): Promise { const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL; + const warn = options.onWarn ?? defaultWarn; const results = await Promise.allSettled( entries.map((e) => fetchItemManifest(e.name, e.type, baseUrl)), ); @@ -45,9 +56,8 @@ export async function loadAllItems( if (r.status === "fulfilled") { items.push(r.value); } else { - const entry = entries[i]; - const name = entry?.name ?? ""; - console.warn(`Skipped registry item "${name}": ${String(r.reason)}`); + const name = entries[i]?.name ?? ""; + warn(`skipped item "${name}": ${String(r.reason)}`); } }); return items; diff --git a/packages/cli/src/templates/generators.ts b/packages/cli/src/templates/generators.ts index 92b5eff863..b64ddb170a 100644 --- a/packages/cli/src/templates/generators.ts +++ b/packages/cli/src/templates/generators.ts @@ -1,10 +1,8 @@ // Compat shim — the registry resolver (packages/cli/src/registry/) is the -// canonical implementation. Kept here so init.ts and any external imports that -// still reference this path keep working through the PR 3 rollout. -// -// The shim converts new RegistryItem manifests back into the TemplateOption / -// RemoteTemplateInfo shape the init wizard still uses. When init.ts is fully -// ported to call the resolver directly, this file can be deleted. +// canonical implementation. Kept so init.ts and any external imports that +// reference this path keep working. Converts new RegistryItem manifests back +// into the TemplateOption shape the init wizard still uses. Deletable once +// init.ts is fully ported to call the resolver directly. import { listRegistryItems, loadAllItems } from "../registry/index.js"; diff --git a/packages/cli/src/templates/remote.test.ts b/packages/cli/src/templates/remote.test.ts index 9eed1d50bb..18232c03b5 100644 --- a/packages/cli/src/templates/remote.test.ts +++ b/packages/cli/src/templates/remote.test.ts @@ -9,7 +9,7 @@ describe("remote template path constants", () => { expect(TEMPLATES_DIR).toBe("registry/examples"); }); - it("MANIFEST_FILENAME is templates.json (renamed to registry.json in PR 3)", () => { + it("MANIFEST_FILENAME is retained for backwards-compat with any external consumers", () => { expect(MANIFEST_FILENAME).toBe("templates.json"); }); }); diff --git a/packages/cli/src/templates/remote.ts b/packages/cli/src/templates/remote.ts index eda3d947f6..7d5c172917 100644 --- a/packages/cli/src/templates/remote.ts +++ b/packages/cli/src/templates/remote.ts @@ -1,11 +1,11 @@ -// Compat shim — fetchRemoteTemplate delegates to the new registry resolver + +// Compat shim — fetchRemoteTemplate delegates to the registry resolver + // installer (packages/cli/src/registry/). Kept so init.ts and external imports -// that reference this path keep working through the PR 3 rollout. Delete when -// init.ts is fully ported. +// that reference this path keep working. Deletable once init.ts is fully +// ported to call the resolver directly. import { existsSync } from "node:fs"; import { join } from "node:path"; -import { installItem, resolveItem } from "../registry/index.js"; +import { installItem, listRegistryItems, loadAllItems, resolveItem } from "../registry/index.js"; // Re-exported for the existing remote.test.ts regression guard. These paths // describe the repo layout under the default registry URL; updating them in @@ -21,14 +21,11 @@ export interface RemoteTemplateInfo { } /** - * List available remote templates. - * - * Kept only for backwards compat with any third-party code that imported this - * function. Internally, init.ts reaches for `resolveTemplateList` in - * `generators.ts`, which goes through the new registry resolver. + * List available remote templates — kept for backwards compat with external + * imports. Internally, `resolveTemplateList` in generators.ts is what init.ts + * uses, and it goes through the registry resolver directly. */ export async function listRemoteTemplates(): Promise { - const { listRegistryItems, loadAllItems } = await import("../registry/index.js"); const entries = await listRegistryItems({ type: "hyperframes:example" }); const items = await loadAllItems(entries); return items.map((item) => ({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d69f93be6c..490cafc4e6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -182,6 +182,7 @@ export type { export { ITEM_TYPES, FILE_TYPES, + ITEM_TYPE_DIRS, isExampleItem, isBlockItem, isComponentItem, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 8b6c5b7f29..ff8b5890dd 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -12,4 +12,11 @@ export type { RegistryManifest, } from "./types.js"; -export { ITEM_TYPES, FILE_TYPES, isExampleItem, isBlockItem, isComponentItem } from "./types.js"; +export { + ITEM_TYPES, + FILE_TYPES, + ITEM_TYPE_DIRS, + isExampleItem, + isBlockItem, + isComponentItem, +} from "./types.js"; diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index cf88957fab..6bec7053c5 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -131,6 +131,18 @@ export const FILE_TYPES = [ "hyperframes:timeline", ] as const satisfies readonly FileType[]; +/** + * Directory segment where each item type lives under a registry root — both + * on disk (`registry/examples/…`) and in URL construction + * (`/examples//registry-item.json`). Shared so CLIs, docs + * tooling, and codegen scripts all agree. + */ +export const ITEM_TYPE_DIRS = { + "hyperframes:example": "examples", + "hyperframes:block": "blocks", + "hyperframes:component": "components", +} as const satisfies Record; + // Compile-time exhaustiveness: every member of the TS union appears in the constant. // If someone adds to `ItemType`/`FileType` without updating `ITEM_TYPES`/`FILE_TYPES`, // these lines stop compiling. (The `satisfies` above covers the other direction.) diff --git a/scripts/generate-registry-items.ts b/scripts/generate-registry-items.ts index 9235cccdbc..ab33ff17e1 100644 --- a/scripts/generate-registry-items.ts +++ b/scripts/generate-registry-items.ts @@ -18,11 +18,17 @@ import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; import { join, relative, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import type { FileTarget, FileType, RegistryItem, RegistryManifest } from "@hyperframes/core"; +import { + ITEM_TYPE_DIRS, + type FileTarget, + type FileType, + type RegistryItem, + type RegistryManifest, +} from "@hyperframes/core"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); -const examplesDir = resolve(repoRoot, "registry/examples"); +const examplesDir = resolve(repoRoot, "registry", ITEM_TYPE_DIRS["hyperframes:example"]); const registryManifestPath = resolve(repoRoot, "registry/registry.json"); const legacyManifestPath = resolve(examplesDir, "templates.json");