mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
feat(cli): add command + hyperframes.json (#256)
## What PR 5/17 of the catalog system rollout. Adds the `hyperframes add` verb for installing blocks and components from the registry into an existing project, plus the `hyperframes.json` project config that tells `add` which registry to use and where to drop files. Stacks on #255. - **`packages/cli/src/commands/add.ts`** — new `hyperframes add <name>` command. Resolves an item, validates target paths, installs files in parallel, builds an include snippet, copies it to the clipboard. Exposes a testable `runAdd(opts)` function; the citty default wraps it with console output + exit handling - **`packages/cli/src/utils/projectConfig.ts`** — read/write/normalize `hyperframes.json`. Tolerant to missing and partial configs - **`packages/cli/src/utils/clipboard.ts`** — minimal cross-platform clipboard (pbcopy / clip.exe / wl-copy / xclip / xsel). Zero deps. Gracefully no-ops in headless environments - **`packages/cli/src/commands/init.ts`** — write `hyperframes.json` during scaffold if not already present - **`packages/cli/src/cli.ts`** + **`help.ts`** — register `add` under Getting Started (directly below `init`) Design doc: [Hyperframes Catalog System](https://www.notion.so/heygen/Hyperframes-Catalog-System-Design-Plan-341449792c69813f899dcd53b4c0383a). ## UX ```bash # Scaffold a project (now writes hyperframes.json too) npx hyperframes init my-video --example blank cd my-video # Add a block — files land, snippet copied to clipboard npx hyperframes add claude-code-window # ✓ Added claude-code-window (hyperframes:block) # compositions/claude-code-window.html # # Include snippet: # <iframe src="compositions/claude-code-window.html" data-start="0" data-duration="6"></iframe> # # Copied to clipboard — paste into your host composition. # Add a component effect npx hyperframes add shader-wipe # Headless / CI — no clipboard, JSON output for tooling npx hyperframes add shader-wipe --no-clipboard --json ``` Running `hyperframes add warm-grain` (an example) errors clearly pointing to `init --example`. ## Docs (bundled in this PR per the tracker principle) - `docs/packages/cli.mdx` — new `add` subsection under Commands (flags, examples, trigger rules) + new `hyperframes.json` section describing the config file shape ## Tests - **`packages/cli/src/commands/add.test.ts`** — 11 tests: - `remapTarget` / `buildSnippet` pure helpers (5 tests) - `runAdd` integration against a mocked `fetch` registry: block install lands files + returns snippet, component install respects `paths.components` remap, example-typed names throw `AddError` with code `example-type`, unknown names throw `AddError` with code `unknown-item` (4 tests plus 2 covering block default path and non-default path preservation) - **`packages/cli/src/utils/projectConfig.test.ts`** — 9 tests: - Write/read round-trip, partial-config normalization, corrupt-file handling, absent-file fallback to defaults, custom paths preserved - **CLI suite:** 92 passed (was 72 on #255, **+20**). Same 4 pre-existing failures unchanged ## Scope decisions - **`init.ts` full port to new resolver deferred.** The original plan bundled a removal of the `packages/cli/src/templates/` compat shim. That's ~300 more lines and isn't required for `add` to work. The compat shim from #254 still functions; a separate cleanup PR handles it - **No ajv runtime schema validation.** Manifests are trusted as schema-valid. Full validation lands when third-party registries arrive (PR 14/15). Path safety is still enforced by the installer's `assertSafeTarget` guard - **Default project paths stay under `compositions/`.** Blocks → `compositions/<name>.html`; components → `compositions/components/<name>/<file>`. Users override via `hyperframes.json#paths` ## Breaking / migration **None.** Pure additive — new command, new file types, no existing commands or flags change. `init.ts` now writes `hyperframes.json` but that's a new additional file, not a modification of existing output. ## Stacks on #255 — base branch. When #255 merges, this rebases onto `main`. ## Next in stack PR 6 — `feat(registry): seed block — claude-code-window`. First real registry item. Exercises the full `hyperframes add <name>` flow end-to-end against a committed item on `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
|
||||
import { AddError, buildSnippet, remapTarget, runAdd } from "./add.js";
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MANIFEST: RegistryManifest = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/registry.json",
|
||||
name: "test",
|
||||
homepage: "https://example.com",
|
||||
items: [
|
||||
{ name: "my-block", type: "hyperframes:block" },
|
||||
{ name: "my-component", type: "hyperframes:component" },
|
||||
{ name: "my-example", type: "hyperframes:example" },
|
||||
],
|
||||
};
|
||||
|
||||
const BLOCK_ITEM: RegistryItem = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
name: "my-block",
|
||||
type: "hyperframes:block",
|
||||
title: "My Block",
|
||||
description: "Block for tests",
|
||||
dimensions: { width: 1080, height: 1350 },
|
||||
duration: 6,
|
||||
files: [
|
||||
{
|
||||
path: "my-block.html",
|
||||
target: "compositions/my-block.html",
|
||||
type: "hyperframes:composition",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const COMPONENT_ITEM: RegistryItem = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
name: "my-component",
|
||||
type: "hyperframes:component",
|
||||
title: "My Component",
|
||||
description: "Component for tests",
|
||||
files: [
|
||||
{
|
||||
path: "my-component.html",
|
||||
target: "compositions/components/my-component/my-component.html",
|
||||
type: "hyperframes:snippet",
|
||||
},
|
||||
{
|
||||
path: "my-component.css",
|
||||
target: "compositions/components/my-component/my-component.css",
|
||||
type: "hyperframes:style",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const EXAMPLE_ITEM: RegistryItem = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
name: "my-example",
|
||||
type: "hyperframes:example",
|
||||
title: "My Example",
|
||||
description: "Example for tests",
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
duration: 10,
|
||||
files: [{ path: "index.html", target: "index.html", type: "hyperframes:composition" }],
|
||||
};
|
||||
|
||||
const ITEM_BY_NAME: Record<string, RegistryItem> = {
|
||||
"my-block": BLOCK_ITEM,
|
||||
"my-component": COMPONENT_ITEM,
|
||||
"my-example": EXAMPLE_ITEM,
|
||||
};
|
||||
|
||||
function mockFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.endsWith("/registry.json")) {
|
||||
return new Response(JSON.stringify(MANIFEST), { status: 200 });
|
||||
}
|
||||
const m = /\/(examples|blocks|components)\/([^/]+)\/registry-item\.json$/.exec(url);
|
||||
if (m) {
|
||||
const item = ITEM_BY_NAME[m[2]!];
|
||||
if (item) return new Response(JSON.stringify(item), { status: 200 });
|
||||
}
|
||||
// File fetch — match `/<type-dir>/<name>/<rest>` and serve synthetic content.
|
||||
const f = /\/(examples|blocks|components)\/([^/]+)\/(.+)$/.exec(url);
|
||||
if (f) {
|
||||
return new Response(`/* ${f[3]} */\n`, { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function tmp(): string {
|
||||
return mkdtempSync(join(tmpdir(), "hf-add-test-"));
|
||||
}
|
||||
|
||||
function uniqueBase(): string {
|
||||
return `https://test.invalid/${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("add command pure helpers", () => {
|
||||
describe("remapTarget", () => {
|
||||
const PATHS = { blocks: "src/scenes", components: "src/fx" };
|
||||
|
||||
it("rewrites block default path to paths.blocks", () => {
|
||||
expect(remapTarget(BLOCK_ITEM, "compositions/my-block.html", PATHS)).toBe(
|
||||
"src/scenes/my-block.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites component default path to paths.components", () => {
|
||||
expect(
|
||||
remapTarget(
|
||||
COMPONENT_ITEM,
|
||||
"compositions/components/my-component/my-component.html",
|
||||
PATHS,
|
||||
),
|
||||
).toBe("src/fx/my-component/my-component.html");
|
||||
});
|
||||
|
||||
it("leaves example targets alone", () => {
|
||||
expect(remapTarget(EXAMPLE_ITEM, "index.html", PATHS)).toBe("index.html");
|
||||
});
|
||||
|
||||
it("leaves non-default block paths alone (no blind string replace)", () => {
|
||||
// A block's manifest could in future use a non-default target — make
|
||||
// sure the prefix match is anchored.
|
||||
expect(remapTarget(BLOCK_ITEM, "elsewhere/my-block.html", PATHS)).toBe(
|
||||
"elsewhere/my-block.html",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSnippet", () => {
|
||||
it("wraps blocks in an iframe with start/duration", () => {
|
||||
const snip = buildSnippet(BLOCK_ITEM, "src/scenes/my-block.html");
|
||||
expect(snip).toContain('src="src/scenes/my-block.html"');
|
||||
expect(snip).toContain('data-duration="6"');
|
||||
});
|
||||
|
||||
it("emits a paste hint for components", () => {
|
||||
const snip = buildSnippet(COMPONENT_ITEM, "src/fx/my-component/my-component.html");
|
||||
expect(snip).toContain("paste from");
|
||||
expect(snip).toContain("my-component.html");
|
||||
});
|
||||
|
||||
it("returns empty string for examples", () => {
|
||||
expect(buildSnippet(EXAMPLE_ITEM, "index.html")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAdd (integration, mocked registry)", () => {
|
||||
beforeEach(() => mockFetch());
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("installs a block into the default compositions/ path and returns the snippet", async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
// Write hyperframes.json so runAdd uses our unique baseUrl.
|
||||
const baseUrl = uniqueBase();
|
||||
const cfg = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
|
||||
registry: baseUrl,
|
||||
paths: { blocks: "compositions", components: "compositions/components", assets: "assets" },
|
||||
};
|
||||
writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(cfg), "utf-8");
|
||||
|
||||
const result = await runAdd({ name: "my-block", projectDir: dir, skipClipboard: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.name).toBe("my-block");
|
||||
expect(result.type).toBe("hyperframes:block");
|
||||
expect(result.written).toHaveLength(1);
|
||||
expect(existsSync(join(dir, "compositions/my-block.html"))).toBe(true);
|
||||
expect(readFileSync(join(dir, "compositions/my-block.html"), "utf-8")).toContain(
|
||||
"my-block.html",
|
||||
);
|
||||
expect(result.snippet).toContain("compositions/my-block.html");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("remaps component targets per hyperframes.json paths.components", async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const baseUrl = uniqueBase();
|
||||
const cfg = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
|
||||
registry: baseUrl,
|
||||
paths: { blocks: "compositions", components: "src/fx", assets: "assets" },
|
||||
};
|
||||
writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(cfg), "utf-8");
|
||||
|
||||
const result = await runAdd({
|
||||
name: "my-component",
|
||||
projectDir: dir,
|
||||
skipClipboard: true,
|
||||
});
|
||||
expect(result.written.length).toBe(2);
|
||||
expect(existsSync(join(dir, "src/fx/my-component/my-component.html"))).toBe(true);
|
||||
expect(existsSync(join(dir, "src/fx/my-component/my-component.css"))).toBe(true);
|
||||
expect(result.snippet).toContain("src/fx/my-component/my-component.html");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AddError with code 'example-type' when asked to add an example", async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const baseUrl = uniqueBase();
|
||||
writeFileSync(
|
||||
join(dir, "hyperframes.json"),
|
||||
JSON.stringify({
|
||||
registry: baseUrl,
|
||||
paths: {
|
||||
blocks: "compositions",
|
||||
components: "compositions/components",
|
||||
assets: "assets",
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
runAdd({ name: "my-example", projectDir: dir, skipClipboard: true }),
|
||||
).rejects.toMatchObject({
|
||||
code: "example-type",
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AddError with code 'unknown-item' for a missing name", async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const baseUrl = uniqueBase();
|
||||
writeFileSync(
|
||||
join(dir, "hyperframes.json"),
|
||||
JSON.stringify({
|
||||
registry: baseUrl,
|
||||
paths: {
|
||||
blocks: "compositions",
|
||||
components: "compositions/components",
|
||||
assets: "assets",
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
runAdd({ name: "nope", projectDir: dir, skipClipboard: true }),
|
||||
).rejects.toBeInstanceOf(AddError);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user