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");