feat(cli): registry resolver + installer (#254)

## 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/<id>/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 <name>` 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 `<baseUrl>__<kind>__<name>.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 <name>` 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)
This commit is contained in:
James Russo
2026-04-13 20:41:23 -07:00
committed by GitHub
parent 69d9f08061
commit 969474e843
23 changed files with 1023 additions and 154 deletions
+15
View File
@@ -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";
@@ -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();
});
});
+73
View File
@@ -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<InstallResult> {
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 };
}
+133
View File
@@ -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:
* <base>/registry.json → top-level manifest
* <base>/<type-dir>/<name>/registry-item.json
* <base>/<type-dir>/<name>/<file.path> → individual files referenced by the item
*
* `<type-dir>` 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<T> {
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<T>(path: string): T | undefined {
try {
const entry = JSON.parse(readFileSync(path, "utf-8")) as CacheEntry<T>;
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<T>(path: string, data: T): void {
mkdirSync(dirname(path), { recursive: true });
const entry: CacheEntry<T> = { fetchedAt: Date.now(), data };
writeFileSync(path, JSON.stringify(entry), "utf-8");
}
// ── Fetchers ────────────────────────────────────────────────────────────────
async function fetchJson<T>(url: string): Promise<T> {
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<RegistryManifest | undefined> {
const cacheFile = cachePath(baseUrl, "registry");
const cached = readCache<RegistryManifest>(cacheFile);
if (cached) return cached;
try {
const manifest = await fetchJson<RegistryManifest>(`${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<RegistryItem> {
const dir = ITEM_TYPE_DIRS[type];
const cacheFile = cachePath(baseUrl, `${dir}__${name}`);
const cached = readCache<RegistryItem>(cacheFile);
if (cached) return cached;
const url = `${baseUrl}/${dir}/${name}/registry-item.json`;
const item = await fetchJson<RegistryItem>(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<void> {
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);
}
+143
View File
@@ -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<string, unknown> = {}): 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/);
});
});
});
+82
View File
@@ -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<RegistryManifestEntry[]> {
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<RegistryItem[]> {
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 ?? "<unknown>";
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<RegistryItem> {
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);
}
+17 -16
View File
@@ -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<TemplateOption[]> {
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,
}));
+1 -1
View File
@@ -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");
});
});
+26 -78
View File
@@ -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<RemoteTemplateInfo[]> {
// 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/<templateId>` 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<void> {
// 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<void> {
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.`,
);
}
}
+1
View File
@@ -182,6 +182,7 @@ export type {
export {
ITEM_TYPES,
FILE_TYPES,
ITEM_TYPE_DIRS,
isExampleItem,
isBlockItem,
isComponentItem,
+8 -1
View File
@@ -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";
+12
View File
@@ -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
* (`<baseUrl>/examples/<name>/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<ItemType, string>;
// 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.)
@@ -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"
}
]
}
@@ -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"
}
]
}
@@ -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"
}
]
}
@@ -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"
}
]
}
@@ -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"
}
]
}
@@ -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"
}
]
}
-58
View File
@@ -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
}
]
}
@@ -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"
}
]
}
@@ -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"
}
]
}
+39
View File
@@ -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"
}
]
}
+182
View File
@@ -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();