mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
## What PR 4/17 of the catalog system rollout. **Single clean cut** — the old flag is gone, replaced by `--example`. Alias changes from `-t` to `-e`. Stacks on #254. - Rename `--template` → `--example` (alias `-e`) on `hyperframes init` - Accept `--template` as a recognized-but-errored flag so users get a clear rename hint instead of citty silently ignoring the flag and producing a blank project - Update all user-visible strings that referenced "template" as a user-facing concept in the init flow (picker prompt, step comments, offline-fallback suggestion) - New `init.test.ts` covering both the success case (`--example` scaffolds) and the error case (`--template` exits 1 with rename hint) Design doc: [Hyperframes Catalog System](https://www.notion.so/heygen/Hyperframes-Catalog-System-Design-Plan-341449792c69813f899dcd53b4c0383a). ## ⚠️ Breaking change `--template` is no longer accepted. Example: ```bash # before npx hyperframes init my-video --template warm-grain # after npx hyperframes init my-video --example warm-grain ``` Users who still type the old flag will see: ``` The --template flag was renamed to --example. Example: npx hyperframes init my-video --example warm-grain ``` and the command exits with code 1. This is **user guidance, not backwards compat** — the old flag's behavior is fully gone. ## Docs (bundled per the tracker principle) - `docs/templates.mdx` — every `--template` reference - `docs/quickstart.mdx` — agent-mode and video-mode examples - `docs/packages/cli.mdx` — prose, `--help` flag table, `-e` alias - `packages/cli/src/docs/templates.md` — CLI-embedded help topic - `README.md` and `CONTRIBUTING.md` — not affected (no flag references) User-facing renames of the `templates.mdx` page title, nav entry, and URL route are deferred to PR 11 (catalog discoverability UX) as planned. ## Why 1. **"examples"** matches shadcn + Remotion convention for full-project scaffolds and frees the word "template" for future parameterization work (string templating, placeholder substitution) 2. Once `hyperframes add` lands in PR 5, "template" vs "block" vs "component" would be three subtly different concepts sharing one word — renaming the old one to "example" makes the taxonomy self-explaining ## How - **citty silently ignores unknown flags.** Naively removing `--template` would cause `hyperframes init my-video --template warm-grain` to silently fall through and scaffold a blank project. So `--template` stays declared in the args schema, but its run handler immediately errors with a rename hint and exits 1 - **Internal names unchanged** — `templateId` local variables, `getStaticTemplateDir` function, `BUNDLED_TEMPLATES` constant. They're implementation details; their rename is scheduled for PR 5 when the compat shims in `packages/cli/src/templates/` are fully removed alongside the `init` refactor ## Test plan - [x] `bun run test` in `packages/cli`: **72 passed** (was 70 on #254, +2 new `init.test.ts` cases). Same 4 pre-existing failures unchanged - [x] **New unit tests** in `init.test.ts`: - `--example blank` non-interactive: exits 0, writes `index.html` to the target dir - `--template blank` non-interactive: exits non-zero, stderr contains the rename hint + corrected command line, target dir is **not** created - [x] **Manual smoke:** - `npx hyperframes init /tmp/x --example blank` → "Created /tmp/x/" - `npx hyperframes init /tmp/y --template blank` → "The --template flag was renamed to --example..." exit=1 - [x] `bunx oxfmt --check` + `bunx oxlint` on changed files: clean - [x] Pre-commit typecheck (core + studio): clean ## Incidental fix Resolver test regression from PR 3's simplify follow-up: `loadAllItems`' warning-path test was still spying on `console.warn` after the `onWarn` callback refactor. Now uses the callback directly. ## Stacks on #254 — base branch. When #254 merges, this rebases onto `main`. ## Next in stack PR 5 — `feat(cli): add command + hyperframes.json`. The big UX PR where: - `init.ts` gets fully ported to the new registry resolver - Compat shims in `packages/cli/src/templates/` are removed - Users gain the `add` verb for installing blocks and components into existing projects - `hyperframes.json` project-config file lands 🤖 Generated with [Claude Code](https://claude.com/claude-code)
144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
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 warnings: string[] = [];
|
|
const entries = await listRegistryItems(undefined, { baseUrl });
|
|
const items = await loadAllItems(entries, { baseUrl, onWarn: (m) => warnings.push(m) });
|
|
expect(items.map((i) => i.name).sort()).toEqual(["alpha", "gamma"]);
|
|
expect(warnings.length).toBeGreaterThan(0);
|
|
expect(warnings.some((w) => w.includes("beta"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|
|
});
|