From 969474e843a013d7c226d1f9127cc57cc61f456b Mon Sep 17 00:00:00 2001 From: James Russo Date: Mon, 13 Apr 2026 20:41:23 -0700 Subject: [PATCH] feat(cli): registry resolver + installer (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What PR 3/17 of the catalog system rollout. Introduces the registry resolver/installer abstraction. No UX change — `init --template` still works identically. Stacks on #253. **New module: `packages/cli/src/registry/`** - `remote.ts` — fetches manifests (`registry.json`, `registry-item.json`) and item files from a GitHub-hosted registry. 24h cache on manifests; item files stream straight to `destDir` - `resolver.ts` — `listRegistryItems`, `loadAllItems` (parallel fetch for picker UX), `resolveItem` (single-item fetch with `Available:` error) - `installer.ts` — `assertSafeTarget` (runtime path-traversal guard) + `installItem` (parallel file download with up-front validation; all-or-nothing semantics) - `index.ts` — barrel **Registry content:** - `registry/registry.json` — top-level manifest in PR 1's `RegistryManifest` shape. 8 examples - `registry/examples//registry-item.json` — per-item manifest for each existing example, generated from legacy `templates.json` + HTML data-attribute probing - `registry/examples/templates.json` — **deleted**, replaced by the above **Compat layer:** - `packages/cli/src/templates/{remote,generators}.ts` — thin shims that delegate to `../registry/`, keeping `init.ts`'s existing imports stable. `init.ts` doesn't move to the new API until PR 5 where it's part of a larger UX pass **Tooling:** - `scripts/generate-registry-items.ts` — idempotent one-off generator for this PR, kept in-repo for future example additions (`--only ` flag) Design doc: [Hyperframes Catalog System](https://www.notion.so/heygen/Hyperframes-Catalog-System-Design-Plan-341449792c69813f899dcd53b4c0383a). Tracker entry in local `hyperframes-catalog-plan.md`. ## Why Every future PR (`hyperframes add`, seed blocks, seed components, custom registries) otherwise has to keep piling onto the ad-hoc fetch + `cpSync` pattern in the old `fetchRemoteTemplate`. The new module is the single place that understands the registry wire format and file layout. **This is also where PR 1's schema comes alive.** ## How ### Scope-trimmed from the plan - **No transitive dependency resolution yet.** Examples have no deps today. `resolveItem` doesn't walk `registryDependencies`; PR 5 adds that when blocks/components need it. - **No ajv schema validation yet.** TS types + runtime path-traversal guard are the only safety nets. Full JSON-Schema validation lands when the registry starts accepting third-party content (PR 14 / custom registries). - **init.ts refactor deferred to PR 5.** Compat shims keep this PR small and reviewable. PR 5 rewrites init alongside adding the `add` command. ### Safety - `assertSafeTarget` rejects absolute paths, `..` segments, Windows drive letters, and any target that `path.resolve` shows to escape `destDir`. Mirrors the PR 1 schema `pattern`/`not.anyOf` on `target`, but runs at install-time so a registry that bypasses schema validation still can't write outside the project - Up-front validation in `installItem` means a malformed item fails **before** any file is written. Atomic-ish semantics: all files land or none do ### Caching - 24h manifest cache lives at `~/.hyperframes/cache/` per existing convention, but now keyed by `____.json` so PR 14 custom registries can coexist ## Test plan - [x] `bun run test` in `packages/cli`: **70 passed** (was 57 on #253, +13). Same 4 pre-existing failures (SRT/VTT whisper normalizer + `lintProject` clean-project test) — identical to main. No regressions - [x] **Resolver unit tests (8):** filter by type, parallel load with fail-safe, resolve-by-name with `Available:` error message, unreachable-registry handling - [x] **Installer unit tests (5):** accepts simple relative paths, rejects `..` segments, rejects Unix absolute paths, rejects Windows drive letters, permits `.` and dotfile-like names - [x] **Smoke test**: `hyperframes init /tmp/x --template blank` (bundled code path, unchanged) works end-to-end - [x] `bunx oxfmt --check` + `bunx oxlint`: clean - [x] Pre-commit typecheck (core + studio): clean. CLI typecheck has 2 pre-existing errors (`render.ts`, `studioServer.ts` — unrelated `"mov"` format issue on main) - [ ] **Smoke test remote fetch (`--template warm-grain`)** — verifiable only post-merge; registry paths live on `main` after this PR lands ## Breaking / migration **No end-user-visible UX change.** `init --template ` still works the same way. Internally, `templates.json` is gone and the CLI now reads `registry.json` + `registry-item.json` per example. Installed CLIs on old versions (`hyperframes@0.1.0`–`0.3.0`) already broke at PR 2 merge (see #253 rollout note). The next CLI release after this lands (`0.3.1`+) is the full fix. ## Commits 1. `generate-registry-items.ts` + generated manifests + deleted `templates.json` 2. Resolver + installer + compat shims 3. Unit tests (All squashed into one commit on this branch; see `git log feat/registry-resolver ^refactor/registry-examples-dir`.) ## Stacks on #253 — base branch. When #253 merges, this rebases onto `main`. ## Next in stack PR 4 — `feat(cli)!: rename --template to --example`. Single clean cut, no alias. Tiny PR (~150 lines) that mostly updates `init.ts`'s argument schema, help text, and docs. Depends on this PR so the new flag name can be applied against the refactored code path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/cli/src/registry/index.ts | 15 ++ packages/cli/src/registry/installer.test.ts | 34 ++++ packages/cli/src/registry/installer.ts | 73 +++++++ packages/cli/src/registry/remote.ts | 133 +++++++++++++ packages/cli/src/registry/resolver.test.ts | 143 ++++++++++++++ packages/cli/src/registry/resolver.ts | 82 ++++++++ packages/cli/src/templates/generators.ts | 33 ++-- packages/cli/src/templates/remote.test.ts | 2 +- packages/cli/src/templates/remote.ts | 104 +++------- packages/core/src/index.ts | 1 + packages/core/src/registry/index.ts | 9 +- packages/core/src/registry/types.ts | 12 ++ .../examples/decision-tree/registry-item.json | 24 +++ .../examples/kinetic-type/registry-item.json | 24 +++ .../examples/nyt-graph/registry-item.json | 24 +++ .../examples/play-mode/registry-item.json | 34 ++++ .../examples/product-promo/registry-item.json | 49 +++++ .../examples/swiss-grid/registry-item.json | 39 ++++ registry/examples/templates.json | 58 ------ registry/examples/vignelli/registry-item.json | 29 +++ .../examples/warm-grain/registry-item.json | 34 ++++ registry/registry.json | 39 ++++ scripts/generate-registry-items.ts | 182 ++++++++++++++++++ 23 files changed, 1023 insertions(+), 154 deletions(-) create mode 100644 packages/cli/src/registry/index.ts create mode 100644 packages/cli/src/registry/installer.test.ts create mode 100644 packages/cli/src/registry/installer.ts create mode 100644 packages/cli/src/registry/remote.ts create mode 100644 packages/cli/src/registry/resolver.test.ts create mode 100644 packages/cli/src/registry/resolver.ts create mode 100644 registry/examples/decision-tree/registry-item.json create mode 100644 registry/examples/kinetic-type/registry-item.json create mode 100644 registry/examples/nyt-graph/registry-item.json create mode 100644 registry/examples/play-mode/registry-item.json create mode 100644 registry/examples/product-promo/registry-item.json create mode 100644 registry/examples/swiss-grid/registry-item.json delete mode 100644 registry/examples/templates.json create mode 100644 registry/examples/vignelli/registry-item.json create mode 100644 registry/examples/warm-grain/registry-item.json create mode 100644 registry/registry.json create mode 100644 scripts/generate-registry-items.ts diff --git a/packages/cli/src/registry/index.ts b/packages/cli/src/registry/index.ts new file mode 100644 index 000000000..c3a702618 --- /dev/null +++ b/packages/cli/src/registry/index.ts @@ -0,0 +1,15 @@ +export { + DEFAULT_REGISTRY_URL, + fetchRegistryManifest, + fetchItemManifest, + fetchItemFile, +} from "./remote.js"; + +export { listRegistryItems, loadAllItems, resolveItem, type ResolveOptions } from "./resolver.js"; + +export { + installItem, + assertSafeTarget, + type InstallOptions, + type InstallResult, +} from "./installer.js"; diff --git a/packages/cli/src/registry/installer.test.ts b/packages/cli/src/registry/installer.test.ts new file mode 100644 index 000000000..a1541764e --- /dev/null +++ b/packages/cli/src/registry/installer.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { assertSafeTarget } from "./installer.js"; + +const DEST = "/tmp/hf-install-test"; + +describe("assertSafeTarget", () => { + it("allows simple relative paths", () => { + expect(() => assertSafeTarget(DEST, "index.html")).not.toThrow(); + expect(() => assertSafeTarget(DEST, "compositions/intro.html")).not.toThrow(); + expect(() => assertSafeTarget(DEST, "assets/nested/deep/file.svg")).not.toThrow(); + }); + + it("rejects `..` path segments", () => { + expect(() => assertSafeTarget(DEST, "../escape.html")).toThrow(/\.\./); + expect(() => assertSafeTarget(DEST, "compositions/../../escape.html")).toThrow(/\.\./); + expect(() => assertSafeTarget(DEST, "a/b/../../../escape.html")).toThrow(); + }); + + it("rejects Unix absolute paths", () => { + expect(() => assertSafeTarget(DEST, "/etc/passwd")).toThrow(/absolute/); + expect(() => assertSafeTarget(DEST, "/home/user/file.txt")).toThrow(); + }); + + it("rejects Windows drive-letter paths", () => { + expect(() => assertSafeTarget(DEST, "C:/Windows/System32")).toThrow(/Windows/); + expect(() => assertSafeTarget(DEST, "D:\\notes.txt")).toThrow(); + }); + + it("allows `.` segments (no-op) and dotfile-like names", () => { + expect(() => assertSafeTarget(DEST, ".hidden")).not.toThrow(); + expect(() => assertSafeTarget(DEST, "./file.html")).not.toThrow(); + expect(() => assertSafeTarget(DEST, "a..b/file.html")).not.toThrow(); + }); +}); diff --git a/packages/cli/src/registry/installer.ts b/packages/cli/src/registry/installer.ts new file mode 100644 index 000000000..5ba6d2cde --- /dev/null +++ b/packages/cli/src/registry/installer.ts @@ -0,0 +1,73 @@ +/** + * Registry installer — copies item files into a destination project. + * + * The top-level directory used under the source registry is determined by the + * item's `type` (examples/blocks/components). Target paths are validated at + * runtime to reject traversal even if the registry JSON schema was bypassed. + */ + +import { resolve, relative, isAbsolute } from "node:path"; +import type { FileTarget, RegistryItem } from "@hyperframes/core"; +import { fetchItemFile, DEFAULT_REGISTRY_URL } from "./remote.js"; + +export interface InstallOptions { + /** Project root where files land. Every target resolves relative to this. */ + destDir: string; + /** Base URL of the registry. Defaults to the official public registry. */ + baseUrl?: string; +} + +export interface InstallResult { + /** Absolute paths of files actually written. */ + written: string[]; +} + +/** + * Reject target paths that would escape `destDir`. Mirrors the pattern check + * in `packages/core/schemas/registry-item.json#files.items.target`, but runs at + * install time so a registry that bypasses schema validation still can't write + * outside the project. + */ +export function assertSafeTarget(destDir: string, target: string): void { + if (isAbsolute(target)) { + throw new Error(`Unsafe target "${target}": absolute paths are not allowed.`); + } + if (/(^|[/\\])\.\.([/\\]|$)/.test(target)) { + throw new Error(`Unsafe target "${target}": path segments may not contain "..".`); + } + if (/^[A-Za-z]:[/\\]/.test(target)) { + throw new Error(`Unsafe target "${target}": Windows drive letters are not allowed.`); + } + const resolved = resolve(destDir, target); + const rel = relative(resolve(destDir), resolved); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`Unsafe target "${target}": resolves outside destDir ${destDir}.`); + } +} + +/** + * Install a resolved `RegistryItem` into `destDir` by fetching each file in + * parallel and writing it to its validated target path. + */ +export async function installItem( + item: RegistryItem, + options: InstallOptions, +): Promise { + const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL; + const destDir = resolve(options.destDir); + + // Validate all targets up-front so a malformed item fails before any write. + for (const file of item.files) { + assertSafeTarget(destDir, file.target); + } + + const written = await Promise.all( + item.files.map(async (file: FileTarget) => { + const destPath = resolve(destDir, file.target); + await fetchItemFile(item, file, destPath, baseUrl); + return destPath; + }), + ); + + return { written }; +} diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts new file mode 100644 index 000000000..99a5e6afd --- /dev/null +++ b/packages/cli/src/registry/remote.ts @@ -0,0 +1,133 @@ +/** + * Remote Registry Fetching + * + * Fetches registry manifests and item files from a Hyperframes registry hosted + * on GitHub (or any HTTPS endpoint serving the same file layout). + * + * Base URL layout: + * /registry.json → top-level manifest + * ///registry-item.json + * /// → individual files referenced by the item + * + * `` comes from ITEM_TYPE_DIRS in @hyperframes/core. + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +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"; + +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 written straight to destDir on install. + +const CACHE_DIR = join(homedir(), ".hyperframes", "cache"); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +interface CacheEntry { + fetchedAt: number; + data: T; +} + +function cachePath(baseUrl: string, key: string): string { + const slug = baseUrl.replace(/[^a-zA-Z0-9]/g, "_"); + return join(CACHE_DIR, `${slug}__${key}.json`); +} + +function readCache(path: string): T | 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; + } +} + +function writeCache(path: string, data: T): void { + mkdirSync(dirname(path), { recursive: true }); + const entry: CacheEntry = { fetchedAt: Date.now(), data }; + writeFileSync(path, JSON.stringify(entry), "utf-8"); +} + +// ── Fetchers ──────────────────────────────────────────────────────────────── + +async function fetchJson(url: string): Promise { + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Registry fetch failed: ${url} — HTTP ${res.status}`); + } + return (await res.json()) as T; +} + +/** + * Fetch the top-level registry.json manifest. Cached for 24h. + * Returns undefined if the registry is unreachable (offline / 404). + */ +export async function fetchRegistryManifest( + baseUrl: string = DEFAULT_REGISTRY_URL, +): Promise { + const cacheFile = cachePath(baseUrl, "registry"); + const cached = readCache(cacheFile); + if (cached) return cached; + + try { + const manifest = await fetchJson(`${baseUrl}/registry.json`); + writeCache(cacheFile, manifest); + return manifest; + } catch { + return undefined; + } +} + +/** + * Fetch a single item's `registry-item.json` manifest. Cached for 24h. + * Throws on network failure (callers decide whether to degrade gracefully). + */ +export async function fetchItemManifest( + name: string, + type: ItemType, + baseUrl: string = DEFAULT_REGISTRY_URL, +): Promise { + const dir = ITEM_TYPE_DIRS[type]; + const cacheFile = cachePath(baseUrl, `${dir}__${name}`); + const cached = readCache(cacheFile); + if (cached) return cached; + + const url = `${baseUrl}/${dir}/${name}/registry-item.json`; + const item = await fetchJson(url); + writeCache(cacheFile, item); + return item; +} + +/** + * Download a single file referenced by an item to a local destination. + * Caller is responsible for target-path validation (see installer.ts). + */ +export async function fetchItemFile( + item: RegistryItem, + file: FileTarget, + destPath: string, + baseUrl: string = DEFAULT_REGISTRY_URL, +): Promise { + 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}`); + } + const buf = new Uint8Array(await res.arrayBuffer()); + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, buf); +} diff --git a/packages/cli/src/registry/resolver.test.ts b/packages/cli/src/registry/resolver.test.ts new file mode 100644 index 000000000..8792668fc --- /dev/null +++ b/packages/cli/src/registry/resolver.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { RegistryItem, RegistryManifest } from "@hyperframes/core"; +import { listRegistryItems, loadAllItems, resolveItem } from "./resolver.js"; + +const MANIFEST: RegistryManifest = { + $schema: "https://hyperframes.heygen.com/schema/registry.json", + name: "test", + homepage: "https://example.com", + items: [ + { name: "alpha", type: "hyperframes:example" }, + { name: "beta", type: "hyperframes:example" }, + { name: "gamma", type: "hyperframes:block" }, + ], +}; + +function buildItem(name: string, type: "hyperframes:example" | "hyperframes:block"): RegistryItem { + if (type === "hyperframes:example") { + return { + name, + type, + title: name.toUpperCase(), + description: `${name} desc`, + dimensions: { width: 1920, height: 1080 }, + duration: 10, + files: [{ path: "index.html", target: "index.html", type: "hyperframes:composition" }], + }; + } + return { + name, + type, + title: name.toUpperCase(), + description: `${name} desc`, + dimensions: { width: 1080, height: 1350 }, + duration: 6, + files: [ + { + path: `${name}.html`, + target: `compositions/${name}.html`, + type: "hyperframes:composition", + }, + ], + }; +} + +function mockFetch(overrides: Record = {}): void { + vi.stubGlobal( + "fetch", + vi.fn(async (urlInput: string | URL) => { + const url = typeof urlInput === "string" ? urlInput : urlInput.toString(); + if (url.endsWith("/registry.json") && !overrides.registryFails) { + return new Response(JSON.stringify(MANIFEST), { status: 200 }); + } + const m = /\/(examples|blocks|components)\/([^/]+)\/registry-item\.json$/.exec(url); + if (m && !(overrides.missing as string[] | undefined)?.includes(m[2]!)) { + const type = m[1] === "examples" ? "hyperframes:example" : "hyperframes:block"; + return new Response(JSON.stringify(buildItem(m[2]!, type)), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }), + ); +} + +function uniqueBaseUrl(): string { + // Unique per-test so the 24h on-disk cache doesn't pollute sibling tests. + return `https://test.invalid/${crypto.randomUUID()}`; +} + +describe("registry resolver", () => { + beforeEach(() => mockFetch()); + afterEach(() => vi.unstubAllGlobals()); + + describe("listRegistryItems", () => { + it("returns all items when no filter is given", async () => { + const items = await listRegistryItems(undefined, { baseUrl: uniqueBaseUrl() }); + expect(items.map((i) => i.name)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("filters by type", async () => { + const baseUrl = uniqueBaseUrl(); + const examples = await listRegistryItems({ type: "hyperframes:example" }, { baseUrl }); + expect(examples.map((i) => i.name)).toEqual(["alpha", "beta"]); + + const blocks = await listRegistryItems({ type: "hyperframes:block" }, { baseUrl }); + expect(blocks.map((i) => i.name)).toEqual(["gamma"]); + }); + + it("returns empty on unreachable registry", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("oops", { status: 500 })), + ); + const items = await listRegistryItems(undefined, { baseUrl: uniqueBaseUrl() }); + expect(items).toEqual([]); + }); + }); + + describe("loadAllItems", () => { + it("loads manifests in parallel", async () => { + const baseUrl = uniqueBaseUrl(); + const entries = await listRegistryItems(undefined, { baseUrl }); + const items = await loadAllItems(entries, { baseUrl }); + expect(items.map((i) => i.name).sort()).toEqual(["alpha", "beta", "gamma"]); + expect(items.find((i) => i.name === "alpha")?.title).toBe("ALPHA"); + }); + + it("skips items whose manifest fails to load (warning, not failure)", async () => { + mockFetch({ missing: ["beta"] }); + const baseUrl = uniqueBaseUrl(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const entries = await listRegistryItems(undefined, { baseUrl }); + const items = await loadAllItems(entries, { baseUrl }); + expect(items.map((i) => i.name).sort()).toEqual(["alpha", "gamma"]); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + }); + + describe("resolveItem", () => { + it("returns the full manifest for a known item", async () => { + const baseUrl = uniqueBaseUrl(); + const item = await resolveItem("alpha", { baseUrl }); + expect(item.name).toBe("alpha"); + expect(item.type).toBe("hyperframes:example"); + expect(item.files).toHaveLength(1); + }); + + it("throws with an `Available:` list when the name is unknown", async () => { + const baseUrl = uniqueBaseUrl(); + await expect(resolveItem("nonexistent", { baseUrl })).rejects.toThrow( + /Available: alpha, beta, gamma/, + ); + }); + + it("throws a clear message when the registry itself is unreachable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("down", { status: 500 })), + ); + const baseUrl = uniqueBaseUrl(); + await expect(resolveItem("alpha", { baseUrl })).rejects.toThrow(/unreachable/); + }); + }); +}); diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts new file mode 100644 index 000000000..e45b2ba2a --- /dev/null +++ b/packages/cli/src/registry/resolver.ts @@ -0,0 +1,82 @@ +/** + * Registry resolver — loads the top-level manifest and per-item manifests. + * 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"; +import { fetchItemManifest, fetchRegistryManifest, DEFAULT_REGISTRY_URL } from "./remote.js"; + +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`); +} + +/** + * List all items in the registry, optionally filtered by type. Returns empty + * if the registry is unreachable — callers should fall back to bundled items. + */ +export async function listRegistryItems( + filter?: { type?: ItemType }, + options: ResolveOptions = {}, +): Promise { + const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL; + const manifest = await fetchRegistryManifest(baseUrl); + if (!manifest) return []; + if (!filter?.type) return manifest.items; + return manifest.items.filter((item) => item.type === filter.type); +} + +/** + * Load every item's full manifest in parallel. Used by the interactive init + * picker to populate titles/descriptions for all examples at once. Items that + * fail to load are skipped with a warning so one missing manifest doesn't + * break the picker. + */ +export async function loadAllItems( + entries: RegistryManifestEntry[], + 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)), + ); + const items: RegistryItem[] = []; + results.forEach((r, i) => { + if (r.status === "fulfilled") { + items.push(r.value); + } else { + const name = entries[i]?.name ?? ""; + warn(`skipped item "${name}": ${String(r.reason)}`); + } + }); + return items; +} + +/** Resolve a single item by name. Throws if unknown or unreachable. */ +export async function resolveItem( + name: string, + options: ResolveOptions = {}, +): Promise { + const entries = await listRegistryItems(undefined, options); + const entry = entries.find((e) => e.name === name); + if (!entry) { + const available = entries.map((e) => e.name).join(", "); + throw new Error( + available.length > 0 + ? `Item "${name}" not found in registry. Available: ${available}` + : `Item "${name}" not found — registry unreachable or empty.`, + ); + } + return fetchItemManifest(entry.name, entry.type, options.baseUrl); +} diff --git a/packages/cli/src/templates/generators.ts b/packages/cli/src/templates/generators.ts index 8f00c8d50..b64ddb170 100644 --- a/packages/cli/src/templates/generators.ts +++ b/packages/cli/src/templates/generators.ts @@ -1,4 +1,10 @@ -import { listRemoteTemplates, type RemoteTemplateInfo } from "./remote.js"; +// Compat shim — the registry resolver (packages/cli/src/registry/) is the +// 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"; export type TemplateSource = "bundled" | "remote"; @@ -20,27 +26,22 @@ export const BUNDLED_TEMPLATES: TemplateOption[] = [ ]; /** - * Resolve the full template list by merging bundled and remote templates. - * Fetches `registry/examples/templates.json` from GitHub (cached 24h). No CLI release needed to add templates. - * If offline, returns only bundled templates. + * Resolve the full template list by merging bundled templates with remote + * examples fetched from the registry. Offline / unreachable → bundled only. */ export async function resolveTemplateList(): Promise { const bundled = [...BUNDLED_TEMPLATES]; const bundledIds = new Set(bundled.map((t) => t.id)); - let remote: RemoteTemplateInfo[] = []; - try { - remote = await listRemoteTemplates(); - } catch { - // Offline — return bundled only - } + const entries = await listRegistryItems({ type: "hyperframes:example" }); + const items = await loadAllItems(entries); - const remoteOptions: TemplateOption[] = remote - .filter((r) => !r.bundled && !bundledIds.has(r.id)) - .map((r) => ({ - id: r.id, - label: r.label, - hint: r.hint, + const remoteOptions: TemplateOption[] = items + .filter((item) => !bundledIds.has(item.name)) + .map((item) => ({ + id: item.name, + label: item.title, + hint: item.description, source: "remote" as const, })); diff --git a/packages/cli/src/templates/remote.test.ts b/packages/cli/src/templates/remote.test.ts index 9eed1d50b..18232c03b 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 278383899..7d5c17291 100644 --- a/packages/cli/src/templates/remote.ts +++ b/packages/cli/src/templates/remote.ts @@ -1,24 +1,18 @@ -/** - * Remote Template Fetching - * - * Downloads templates from the hyperframes GitHub repository using giget. - * Templates live in the `registry/examples/` directory of the repo. - */ +// 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. Deletable once init.ts is fully +// ported to call the resolver directly. -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { installItem, listRegistryItems, loadAllItems, resolveItem } from "../registry/index.js"; -const REPO = "heygen-com/hyperframes"; -// Exported for regression testing — see remote.test.ts. +// Re-exported for the existing remote.test.ts regression guard. These paths +// describe the repo layout under the default registry URL; updating them in +// sync with any future move prevents silent breakage of installed CLIs. export const TEMPLATES_DIR = "registry/examples"; export const MANIFEST_FILENAME = "templates.json"; -/** Cache directory for remote template metadata. */ -const CACHE_DIR = join(homedir(), ".hyperframes", "cache"); -const MANIFEST_CACHE_PATH = join(CACHE_DIR, "remote-templates.json"); -const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - export interface RemoteTemplateInfo { id: string; label: string; @@ -26,79 +20,33 @@ export interface RemoteTemplateInfo { bundled: boolean; } -interface ManifestCache { - fetchedAt: number; - templates: RemoteTemplateInfo[]; -} - /** - * Fetch the remote template manifest from GitHub. - * Caches the result for 24 hours to avoid rate limits. + * 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 { - // Check cache first - if (existsSync(MANIFEST_CACHE_PATH)) { - try { - const cached: ManifestCache = JSON.parse(readFileSync(MANIFEST_CACHE_PATH, "utf-8")); - if (Date.now() - cached.fetchedAt < CACHE_TTL_MS) { - return cached.templates; - } - } catch { - // Cache corrupt — refetch - } - } - - // Fetch from GitHub raw content - const url = `https://raw.githubusercontent.com/${REPO}/main/${TEMPLATES_DIR}/${MANIFEST_FILENAME}`; - try { - const res = await fetch(url, { signal: AbortSignal.timeout(5_000) }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const data = (await res.json()) as { templates: RemoteTemplateInfo[] }; - const templates = data.templates; - - // Write cache - mkdirSync(CACHE_DIR, { recursive: true }); - const cache: ManifestCache = { fetchedAt: Date.now(), templates }; - writeFileSync(MANIFEST_CACHE_PATH, JSON.stringify(cache), "utf-8"); - - return templates; - } catch { - // Offline or rate-limited — return empty (caller should fall back to bundled) - return []; - } + const entries = await listRegistryItems({ type: "hyperframes:example" }); + const items = await loadAllItems(entries); + return items.map((item) => ({ + id: item.name, + label: item.title, + hint: item.description, + bundled: false, + })); } /** - * Download a template from GitHub into destDir using giget. - * Fetches from `registry/examples/` in the hyperframes repo. + * Download a template into destDir. Delegates to the registry installer. */ -export async function fetchRemoteTemplate( - templateId: string, - destDir: string, - options?: { ref?: string }, -): Promise { - // Validate against manifest before downloading - const known = await listRemoteTemplates(); - if (known.length > 0 && !known.some((t) => t.id === templateId)) { - const available = known.map((t) => t.id).join(", "); - throw new Error(`Template "${templateId}" not found. Available: ${available}`); - } +export async function fetchRemoteTemplate(templateId: string, destDir: string): Promise { + const item = await resolveItem(templateId); + await installItem(item, { destDir }); - const { downloadTemplate } = await import("giget"); - const ref = options?.ref ?? "main"; - const source = `github:${REPO}/${TEMPLATES_DIR}/${templateId}#${ref}`; - - await downloadTemplate(source, { - dir: destDir, - force: true, - }); - - // Safety check — giget can succeed with empty dir if path doesn't exist + // Safety check — an item with no index.html isn't a valid example. if (!existsSync(join(destDir, "index.html"))) { throw new Error( - `Template "${templateId}" downloaded but missing index.html. The template may be malformed.`, + `Template "${templateId}" installed but missing index.html. The registry item may be malformed.`, ); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d69f93be6..490cafc4e 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 8b6c5b7f2..ff8b5890d 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 cf88957fa..6bec7053c 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/registry/examples/decision-tree/registry-item.json b/registry/examples/decision-tree/registry-item.json new file mode 100644 index 000000000..fe5b1757f --- /dev/null +++ b/registry/examples/decision-tree/registry-item.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "decision-tree", + "type": "hyperframes:example", + "title": "Decision Tree", + "description": "Animated flowchart with branching paths", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 15, + "files": [ + { + "path": "compositions/decision_tree.html", + "target": "compositions/decision_tree.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/examples/kinetic-type/registry-item.json b/registry/examples/kinetic-type/registry-item.json new file mode 100644 index 000000000..2bc365707 --- /dev/null +++ b/registry/examples/kinetic-type/registry-item.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "kinetic-type", + "type": "hyperframes:example", + "title": "Kinetic Type", + "description": "Bold kinetic typography promo", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 15, + "files": [ + { + "path": "compositions/main-graphics.html", + "target": "compositions/main-graphics.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/examples/nyt-graph/registry-item.json b/registry/examples/nyt-graph/registry-item.json new file mode 100644 index 000000000..a3d337d65 --- /dev/null +++ b/registry/examples/nyt-graph/registry-item.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "nyt-graph", + "type": "hyperframes:example", + "title": "NYT Graph", + "description": "Animated data chart in print editorial style", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 15, + "files": [ + { + "path": "compositions/nyt-chart.html", + "target": "compositions/nyt-chart.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/examples/play-mode/registry-item.json b/registry/examples/play-mode/registry-item.json new file mode 100644 index 000000000..657debefd --- /dev/null +++ b/registry/examples/play-mode/registry-item.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "play-mode", + "type": "hyperframes:example", + "title": "Play Mode", + "description": "Playful elastic animations", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 10, + "files": [ + { + "path": "compositions/captions.html", + "target": "compositions/captions.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/intro.html", + "target": "compositions/intro.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/stats.html", + "target": "compositions/stats.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/examples/product-promo/registry-item.json b/registry/examples/product-promo/registry-item.json new file mode 100644 index 000000000..2be30bfaa --- /dev/null +++ b/registry/examples/product-promo/registry-item.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "product-promo", + "type": "hyperframes:example", + "title": "Product Promo", + "description": "Multi-scene product showcase with SVG assets", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 20, + "files": [ + { + "path": "assets/figma-cursors.svg", + "target": "assets/figma-cursors.svg", + "type": "hyperframes:asset" + }, + { + "path": "assets/figma-logo-pieces.svg", + "target": "assets/figma-logo-pieces.svg", + "type": "hyperframes:asset" + }, + { + "path": "assets/figma-logo-pills.svg", + "target": "assets/figma-logo-pills.svg", + "type": "hyperframes:asset" + }, + { + "path": "compositions/scene1-logo-intro.html", + "target": "compositions/scene1-logo-intro.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/scene2-4-canvas.html", + "target": "compositions/scene2-4-canvas.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/scene5-logo-outro.html", + "target": "compositions/scene5-logo-outro.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/examples/swiss-grid/registry-item.json b/registry/examples/swiss-grid/registry-item.json new file mode 100644 index 000000000..04bfac79c --- /dev/null +++ b/registry/examples/swiss-grid/registry-item.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "swiss-grid", + "type": "hyperframes:example", + "title": "Swiss Grid", + "description": "Structured grid layout", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 10, + "files": [ + { + "path": "assets/swiss-grid.svg", + "target": "assets/swiss-grid.svg", + "type": "hyperframes:asset" + }, + { + "path": "compositions/captions.html", + "target": "compositions/captions.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/graphics.html", + "target": "compositions/graphics.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/intro.html", + "target": "compositions/intro.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/examples/templates.json b/registry/examples/templates.json deleted file mode 100644 index 9f32fb390..000000000 --- a/registry/examples/templates.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "templates": [ - { - "id": "blank", - "label": "Blank", - "hint": "Empty composition — just the scaffolding", - "bundled": true - }, - { - "id": "warm-grain", - "label": "Warm Grain", - "hint": "Cream aesthetic with grain texture", - "bundled": false - }, - { - "id": "play-mode", - "label": "Play Mode", - "hint": "Playful elastic animations", - "bundled": false - }, - { - "id": "swiss-grid", - "label": "Swiss Grid", - "hint": "Structured grid layout", - "bundled": false - }, - { - "id": "vignelli", - "label": "Vignelli", - "hint": "Bold typography with red accents", - "bundled": false - }, - { - "id": "decision-tree", - "label": "Decision Tree", - "hint": "Animated flowchart with branching paths", - "bundled": false - }, - { - "id": "kinetic-type", - "label": "Kinetic Type", - "hint": "Bold kinetic typography promo", - "bundled": false - }, - { - "id": "product-promo", - "label": "Product Promo", - "hint": "Multi-scene product showcase with SVG assets", - "bundled": false - }, - { - "id": "nyt-graph", - "label": "NYT Graph", - "hint": "Animated data chart in print editorial style", - "bundled": false - } - ] -} diff --git a/registry/examples/vignelli/registry-item.json b/registry/examples/vignelli/registry-item.json new file mode 100644 index 000000000..758999ce0 --- /dev/null +++ b/registry/examples/vignelli/registry-item.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "vignelli", + "type": "hyperframes:example", + "title": "Vignelli", + "description": "Bold typography with red accents", + "dimensions": { + "width": 1080, + "height": 1920 + }, + "duration": 10, + "files": [ + { + "path": "compositions/captions.html", + "target": "compositions/captions.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/overlays.html", + "target": "compositions/overlays.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/examples/warm-grain/registry-item.json b/registry/examples/warm-grain/registry-item.json new file mode 100644 index 000000000..f3cf8d94b --- /dev/null +++ b/registry/examples/warm-grain/registry-item.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "warm-grain", + "type": "hyperframes:example", + "title": "Warm Grain", + "description": "Cream aesthetic with grain texture", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 10, + "files": [ + { + "path": "compositions/captions.html", + "target": "compositions/captions.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/graphics.html", + "target": "compositions/graphics.html", + "type": "hyperframes:composition" + }, + { + "path": "compositions/intro.html", + "target": "compositions/intro.html", + "type": "hyperframes:composition" + }, + { + "path": "index.html", + "target": "index.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/registry.json b/registry/registry.json new file mode 100644 index 000000000..990035395 --- /dev/null +++ b/registry/registry.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry.json", + "name": "hyperframes", + "homepage": "https://hyperframes.heygen.com", + "items": [ + { + "name": "warm-grain", + "type": "hyperframes:example" + }, + { + "name": "play-mode", + "type": "hyperframes:example" + }, + { + "name": "swiss-grid", + "type": "hyperframes:example" + }, + { + "name": "vignelli", + "type": "hyperframes:example" + }, + { + "name": "decision-tree", + "type": "hyperframes:example" + }, + { + "name": "kinetic-type", + "type": "hyperframes:example" + }, + { + "name": "product-promo", + "type": "hyperframes:example" + }, + { + "name": "nyt-graph", + "type": "hyperframes:example" + } + ] +} diff --git a/scripts/generate-registry-items.ts b/scripts/generate-registry-items.ts new file mode 100644 index 000000000..ab33ff17e --- /dev/null +++ b/scripts/generate-registry-items.ts @@ -0,0 +1,182 @@ +#!/usr/bin/env tsx +/** + * Generate registry-item.json manifests for every example in registry/examples/, + * plus the top-level registry/registry.json manifest. + * + * Reads the legacy registry/examples/templates.json (label + hint) and probes + * each example's index.html for dimensions / duration data attributes. + * Placeholder `__VIDEO_DURATION__` falls back to 10 (the init-time default). + * + * Idempotent — safe to re-run, but will overwrite any hand-edits. Intended as + * one-shot scaffolding for PR 3. + * + * Usage: + * bun run scripts/generate-registry-items.ts + * bun run scripts/generate-registry-items.ts --only warm-grain + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +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", ITEM_TYPE_DIRS["hyperframes:example"]); +const registryManifestPath = resolve(repoRoot, "registry/registry.json"); +const legacyManifestPath = resolve(examplesDir, "templates.json"); + +const DEFAULT_DURATION_SECONDS = 10; +const PLACEHOLDER_DURATION = "__VIDEO_DURATION__"; + +interface LegacyTemplateEntry { + id: string; + label: string; + hint: string; + bundled: boolean; +} + +interface LegacyManifest { + templates: LegacyTemplateEntry[]; +} + +function readLegacyManifest(): LegacyTemplateEntry[] { + const raw = readFileSync(legacyManifestPath, "utf-8"); + const parsed = JSON.parse(raw) as LegacyManifest; + return parsed.templates; +} + +function extractAttr(html: string, attr: string): string | undefined { + const match = new RegExp(`data-${attr}="([^"]*)"`).exec(html); + return match?.[1]; +} + +interface CanvasMeta { + width: number; + height: number; + duration: number; +} + +function probeCanvas(exampleDir: string): CanvasMeta { + const html = readFileSync(join(exampleDir, "index.html"), "utf-8"); + const width = Number(extractAttr(html, "width") ?? 1920); + const height = Number(extractAttr(html, "height") ?? 1080); + const rawDuration = extractAttr(html, "duration"); + const duration = + rawDuration === undefined || rawDuration === PLACEHOLDER_DURATION + ? DEFAULT_DURATION_SECONDS + : Number(rawDuration); + return { width, height, duration }; +} + +function fileTypeFor(path: string): FileType { + if (path.endsWith(".html")) return "hyperframes:composition"; + return "hyperframes:asset"; +} + +/** Walk the example dir and collect every tracked file (HTML + assets). */ +function collectFiles(exampleDir: string): FileTarget[] { + const files: FileTarget[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile()) { + // Skip the registry-item.json itself if it already exists from a + // prior run; we're regenerating it. + if (entry.name === "registry-item.json") continue; + const rel = relative(exampleDir, full); + files.push({ path: rel, target: rel, type: fileTypeFor(rel) }); + } + } + }; + walk(exampleDir); + files.sort((a, b) => a.path.localeCompare(b.path)); + return files; +} + +function buildItem(entry: LegacyTemplateEntry): RegistryItem { + // The `blank` template is bundled inside the CLI package; don't generate a + // manifest in registry/examples/ for it. + const exampleDir = join(examplesDir, entry.id); + const canvas = probeCanvas(exampleDir); + const files = collectFiles(exampleDir); + + return { + $schema: "https://hyperframes.heygen.com/schema/registry-item.json", + name: entry.id, + type: "hyperframes:example", + title: entry.label, + description: entry.hint, + dimensions: { width: canvas.width, height: canvas.height }, + duration: canvas.duration, + files, + }; +} + +function writeItem(item: RegistryItem): void { + if (item.type !== "hyperframes:example") return; + const out = join(examplesDir, item.name, "registry-item.json"); + writeFileSync(out, JSON.stringify(item, null, 2) + "\n", "utf-8"); + console.log(`wrote ${relative(repoRoot, out)}`); +} + +function writeRegistryManifest(items: RegistryItem[]): void { + const manifest: RegistryManifest = { + $schema: "https://hyperframes.heygen.com/schema/registry.json", + name: "hyperframes", + homepage: "https://hyperframes.heygen.com", + items: items.map((item) => ({ name: item.name, type: item.type })), + }; + writeFileSync(registryManifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8"); + console.log(`wrote ${relative(repoRoot, registryManifestPath)}`); +} + +function main(): void { + const args = process.argv.slice(2); + const onlyIdx = args.indexOf("--only"); + const only = onlyIdx >= 0 ? args[onlyIdx + 1] : undefined; + + const legacy = readLegacyManifest(); + // Skip bundled templates (e.g. `blank`) — they live inside the CLI package, + // not under registry/examples/. + const onDisk = legacy.filter((t) => !t.bundled); + const filtered = only ? onDisk.filter((t) => t.id === only) : onDisk; + + if (filtered.length === 0) { + console.error( + only + ? `No example matches --only ${only}. Available: ${onDisk.map((t) => t.id).join(", ")}` + : "No examples found in registry/examples/templates.json", + ); + process.exit(1); + } + + const items: RegistryItem[] = []; + for (const entry of filtered) { + const exampleDir = join(examplesDir, entry.id); + try { + statSync(exampleDir); + } catch { + console.warn(`skip ${entry.id}: directory not found at ${relative(repoRoot, exampleDir)}`); + continue; + } + const item = buildItem(entry); + writeItem(item); + items.push(item); + } + + // Only rewrite the top-level manifest on a full-run (not --only). + if (!only) { + writeRegistryManifest(items); + } +} + +main();