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:
James Russo
2026-04-13 21:04:59 -07:00
committed by GitHub
parent c8acd8abd8
commit 08fb1de61f
28 changed files with 923 additions and 46 deletions
+1
View File
@@ -25,6 +25,7 @@ const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
const subCommands = {
init: () => import("./commands/init.js").then((m) => m.default),
add: () => import("./commands/add.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
preview: () => import("./commands/preview.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
+269
View File
@@ -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 });
}
});
});
+232
View File
@@ -0,0 +1,232 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
["Add a block to the current project", "hyperframes add claude-code-window"],
["Add a component effect", "hyperframes add shader-wipe"],
["Target a specific project directory", "hyperframes add shader-wipe --dir ./my-video"],
["Skip the clipboard copy (CI/headless)", "hyperframes add shader-wipe --no-clipboard"],
];
import { existsSync } from "node:fs";
import { resolve, relative } from "node:path";
import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core";
import { c } from "../ui/colors.js";
import { installItem, resolveItem } from "../registry/index.js";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
projectConfigPath,
writeProjectConfig,
} from "../utils/projectConfig.js";
import { copyToClipboard } from "../utils/clipboard.js";
// ── Target-path resolution ──────────────────────────────────────────────────
// `registry-item.json` files specify `target` paths relative to the project
// root. For blocks and components we override the default path with the
// user's `hyperframes.json#paths` so a project can reshape its layout
// without editing every item's manifest.
export function remapTarget(
item: RegistryItem,
originalTarget: string,
paths: { blocks: string; components: string },
): string {
if (item.type === "hyperframes:block") {
// Anchored to the default target prefix from DEFAULT_PROJECT_CONFIG.paths.blocks.
// Targets that don't start with "compositions/" pass through unchanged.
// Strip trailing slashes to prevent double-slash in output.
const blocksDir = paths.blocks.replace(/\/+$/, "");
return originalTarget.replace(/^compositions\//, `${blocksDir}/`);
}
if (item.type === "hyperframes:component") {
// Anchored to the default target prefix from DEFAULT_PROJECT_CONFIG.paths.components.
const componentsDir = paths.components.replace(/\/+$/, "");
return originalTarget.replace(/^compositions\/components\//, `${componentsDir}/`);
}
// Examples are installed by `init`, not `add` — no remapping.
return originalTarget;
}
// ── Include-snippet builders ────────────────────────────────────────────────
// Shown to the user after install so they know how to wire the item into
// their host composition. Copied to clipboard by default.
export function buildSnippet(item: RegistryItem, relativeTarget: string): string {
if (item.type === "hyperframes:block") {
// data-start omitted — adjust to your timeline position after pasting.
return `<iframe src="${relativeTarget}" data-duration="${item.duration}"></iframe>`;
}
if (item.type === "hyperframes:component") {
return `<!-- paste from ${relativeTarget} into your composition -->`;
}
return "";
}
// ── Core runner (tested) ────────────────────────────────────────────────────
export interface RunAddArgs {
name: string;
projectDir: string;
skipClipboard?: boolean;
}
export interface RunAddResult {
ok: true;
name: string;
type: RegistryItem["type"];
typeDir: string;
written: string[];
snippet: string;
clipboardCopied: boolean;
}
export class AddError extends Error {
constructor(
message: string,
public readonly code: "unknown-item" | "wrong-type" | "install-failed" | "example-type",
) {
super(message);
this.name = "AddError";
}
}
export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
const projectDir = resolve(opts.projectDir);
// 1. Load (or write default) project config.
let config = loadProjectConfig(projectDir);
const hasConfig = existsSync(projectConfigPath(projectDir));
if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) {
writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG);
config = DEFAULT_PROJECT_CONFIG;
}
// 2. Resolve the item from the registry.
let item: RegistryItem;
try {
item = await resolveItem(opts.name, { baseUrl: config.registry });
} catch (err) {
throw new AddError(err instanceof Error ? err.message : String(err), "unknown-item");
}
if (item.type === "hyperframes:example") {
throw new AddError(
`"${item.name}" is an example — use \`hyperframes init <dir> --example ${item.name}\` instead.`,
"example-type",
);
}
// 3. Remap targets per project config.
const remappedFiles = item.files.map((f) => ({
...f,
target: remapTarget(item, f.target, config.paths),
}));
const itemForInstall: RegistryItem = { ...item, files: remappedFiles };
// 4. Install — the installer validates every target before any write.
let written: string[];
try {
const result = await installItem(itemForInstall, {
destDir: projectDir,
baseUrl: config.registry,
});
written = result.written;
} catch (err) {
throw new AddError(
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
"install-failed",
);
}
// 5. Build include snippet + clipboard copy.
const primaryFile =
itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
itemForInstall.files[0];
const snippetTargetRel = primaryFile?.target ?? "";
const snippet = buildSnippet(item, snippetTargetRel);
const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false;
return {
ok: true,
name: item.name,
type: item.type,
typeDir: ITEM_TYPE_DIRS[item.type],
written,
snippet,
clipboardCopied,
};
}
// ── Command ─────────────────────────────────────────────────────────────────
export default defineCommand({
meta: {
name: "add",
description: "Install a block or component from the registry into this project",
},
args: {
name: {
type: "positional",
description: "Registry item name (e.g. claude-code-window, shader-wipe)",
required: true,
},
dir: {
type: "string",
description: "Project directory (defaults to the current working directory)",
},
"no-clipboard": {
type: "boolean",
description: "Skip copying the include snippet to the clipboard",
},
json: {
type: "boolean",
description: "Print a machine-readable summary (written files + snippet) to stdout",
},
},
async run({ args }) {
const projectDir = resolve(args.dir ?? process.cwd());
const json = args.json === true;
const skipClipboard = args["no-clipboard"] === true;
const hasConfigBefore = existsSync(projectConfigPath(projectDir));
try {
const result = await runAdd({ name: args.name, projectDir, skipClipboard });
const wroteConfig = !hasConfigBefore && existsSync(projectConfigPath(projectDir));
if (json) {
console.log(JSON.stringify(result));
return;
}
if (wroteConfig) {
console.log(c.dim(`Wrote default ${projectConfigPath(projectDir)}`));
}
console.log("");
console.log(`${c.success("✓")} Added ${c.accent(result.name)} (${result.type})`);
for (const file of result.written) {
console.log(` ${c.dim(relative(projectDir, file))}`);
}
if (result.snippet) {
console.log("");
console.log(c.dim("Include snippet:"));
console.log(` ${result.snippet}`);
console.log("");
console.log(
result.clipboardCopied
? c.dim("Copied to clipboard — paste into your host composition.")
: c.dim("Paste the snippet above into your host composition."),
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (json) {
console.log(JSON.stringify({ ok: false, error: msg }));
} else {
console.error(c.error(msg));
}
process.exit(1);
}
},
});
+3 -3
View File
@@ -22,9 +22,9 @@ const TOPICS: Record<string, TopicEntry> = {
file: "data-attributes.md",
description: "Timing, media, and composition attributes",
},
templates: {
file: "templates.md",
description: "Built-in project templates for init",
examples: {
file: "examples.md",
description: "Built-in project examples for init",
},
rendering: {
file: "rendering.md",
+1 -1
View File
@@ -41,7 +41,7 @@ describe("hyperframes init flag rename", () => {
const target = join(dir, "proj");
try {
const res = runInit([target, "--template", "blank", "--non-interactive", "--skip-skills"]);
expect(res.status).not.toBe(0);
expect(res.status).toBe(1);
expect(res.stderr).toContain("--template flag was renamed to --example");
expect(res.stderr).toContain(`--example "blank"`);
expect(existsSync(target)).toBe(false);
+9
View File
@@ -338,6 +338,14 @@ async function scaffoldProject(
"utf-8",
);
// Write hyperframes.json so `hyperframes add` knows which registry to use
// and where to drop block/component files. Overwritten only if absent.
if (!existsSync(resolve(destDir, "hyperframes.json"))) {
const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
await import("../utils/projectConfig.js");
writeProjectConfig(destDir, DEFAULT_PROJECT_CONFIG);
}
// Copy shared files (CLAUDE.md, AGENTS.md) for AI agent context
const sharedDir = getSharedTemplateDir();
if (existsSync(sharedDir)) {
@@ -375,6 +383,7 @@ export default defineCommand({
template: {
type: "string",
description: "[renamed] Use --example instead.",
alias: "t",
hidden: true,
},
video: {
@@ -16,4 +16,4 @@ Video element with trimming, audio, and track controls. Starting point for video
## Custom Templates
Any directory with an `index.html` can serve as a template. Copy it manually or build your own init workflow.
Any directory with an `index.html` can serve as an example. Copy it manually or build your own init workflow.
+1
View File
@@ -20,6 +20,7 @@ const GROUPS: Group[] = [
title: "Getting Started",
commands: [
["init", "Scaffold a new composition project"],
["add", "Install a block or component from the registry"],
["preview", "Start the studio for previewing compositions"],
["render", "Render a composition to MP4 or WebM"],
],
+5
View File
@@ -48,6 +48,7 @@ function cachePath(baseUrl: string, key: string): string {
function readCache<T>(path: string): T | undefined {
try {
const entry = JSON.parse(readFileSync(path, "utf-8")) as CacheEntry<T>;
if (typeof entry.fetchedAt !== "number") return undefined;
if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) return undefined;
return entry.data;
} catch {
@@ -122,6 +123,10 @@ export async function fetchItemFile(
destPath: string,
baseUrl: string = DEFAULT_REGISTRY_URL,
): Promise<void> {
// Reject path-traversal in file.path (mirrors assertSafeTarget for file.target).
if (/(^|[/\\])\.\.([/\\]|$)/.test(file.path)) {
throw new Error(`Unsafe file.path "${file.path}": path segments may not contain "..".`);
}
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) {
+8 -1
View File
@@ -63,7 +63,14 @@ export async function loadAllItems(
return items;
}
/** Resolve a single item by name. Throws if unknown or unreachable. */
/**
* Resolve a single item by name. Throws if unknown or unreachable.
*
* TODO: walk registryDependencies transitively and return a topo-sorted
* list of items. Today examples have no deps so this returns a single item.
* Blocks and components will need transitive resolution once they ship with
* deps (seed items in Phase B).
*/
export async function resolveItem(
name: string,
options: ResolveOptions = {},
+1 -1
View File
@@ -33,7 +33,7 @@ npx hyperframes docs <topic> # reference docs in terminal
npx hyperframes docs <topic>
```
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `templates`, `troubleshooting`
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
+1 -1
View File
@@ -46,7 +46,7 @@ export async function fetchRemoteTemplate(templateId: string, destDir: string):
// Safety check — an item with no index.html isn't a valid example.
if (!existsSync(join(destDir, "index.html"))) {
throw new Error(
`Template "${templateId}" installed but missing index.html. The registry item may be malformed.`,
`Example "${templateId}" installed but missing index.html. The registry item may be malformed.`,
);
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Minimal cross-platform clipboard copy. Shells out to the OS tool; gracefully
* no-ops when no tool is available (CI, headless SSH, etc.) so callers can
* always invoke it without guarding.
*
* Returns true if the copy succeeded, false otherwise.
*/
import { spawnSync } from "node:child_process";
import { platform } from "node:os";
interface ClipboardProvider {
cmd: string;
args: string[];
}
function detectProvider(): ClipboardProvider | undefined {
const os = platform();
if (os === "darwin") {
return { cmd: "pbcopy", args: [] };
}
if (os === "win32") {
return { cmd: "clip.exe", args: [] };
}
// Linux / BSD — pick the first tool that's on PATH.
// WSL exposes clip.exe too; prefer it so copies land in the Windows
// clipboard where the user actually sees them.
const candidates: ClipboardProvider[] = [
{ cmd: "clip.exe", args: [] },
{ cmd: "wl-copy", args: [] },
{ cmd: "xclip", args: ["-selection", "clipboard"] },
{ cmd: "xsel", args: ["--clipboard", "--input"] },
];
for (const p of candidates) {
const which = spawnSync("which", [p.cmd], { stdio: "ignore" });
if (which.status === 0) return p;
}
return undefined;
}
let cachedProvider: ClipboardProvider | undefined | null = null;
export function copyToClipboard(text: string): boolean {
if (cachedProvider === null) cachedProvider = detectProvider();
const provider = cachedProvider;
if (!provider) return false;
try {
const res = spawnSync(provider.cmd, provider.args, {
input: text,
encoding: "utf-8",
});
return res.status === 0;
} catch {
return false;
}
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
normalizeConfig,
projectConfigPath,
readProjectConfig,
writeProjectConfig,
PROJECT_CONFIG_FILENAME,
} from "./projectConfig.js";
function tmp(): string {
return mkdtempSync(join(tmpdir(), "hf-cfg-test-"));
}
describe("projectConfig", () => {
describe("write + read round-trip", () => {
it("writes the default config and reads it back", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
const read = readProjectConfig(dir);
expect(read).toEqual(DEFAULT_PROJECT_CONFIG);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("writes a custom config and reads it back verbatim", () => {
const dir = tmp();
try {
const custom = {
$schema: DEFAULT_PROJECT_CONFIG.$schema,
registry: "https://example.com/my-registry",
paths: { blocks: "src/blocks", components: "src/fx", assets: "media" },
};
writeProjectConfig(dir, custom);
const read = readProjectConfig(dir);
expect(read).toEqual(custom);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("normalizeConfig", () => {
it("fills in defaults for missing fields", () => {
const result = normalizeConfig({ registry: "https://alt.example.com" });
expect(result.registry).toBe("https://alt.example.com");
expect(result.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
expect(result.$schema).toBe(DEFAULT_PROJECT_CONFIG.$schema);
});
it("preserves partial paths objects", () => {
const result = normalizeConfig({ paths: { blocks: "x" } as unknown as never });
expect(result.paths.blocks).toBe("x");
expect(result.paths.components).toBe(DEFAULT_PROJECT_CONFIG.paths.components);
expect(result.paths.assets).toBe(DEFAULT_PROJECT_CONFIG.paths.assets);
});
});
describe("readProjectConfig", () => {
it("returns undefined when the file is absent", () => {
const dir = tmp();
try {
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns undefined when the file is corrupt", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("normalizes a partial on-disk config", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://only-this.example.com" }),
"utf-8",
);
const read = readProjectConfig(dir);
expect(read?.registry).toBe("https://only-this.example.com");
expect(read?.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("loadProjectConfig", () => {
it("returns defaults when no config file exists", () => {
const dir = tmp();
try {
expect(loadProjectConfig(dir)).toEqual(DEFAULT_PROJECT_CONFIG);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("writeProjectConfig", () => {
it("writes to hyperframes.json at the project root", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
const path = join(dir, PROJECT_CONFIG_FILENAME);
const parsed = JSON.parse(readFileSync(path, "utf-8"));
expect(parsed.registry).toBe(DEFAULT_PROJECT_CONFIG.registry);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* Read and write `hyperframes.json` the per-project config that tells
* `hyperframes add` which registry to pull items from and where to drop them
* in the user's project tree.
*
* The file is created by `hyperframes init` and optionally edited by users to
* point at custom registries or reshape their project layout.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DEFAULT_REGISTRY_URL } from "../registry/index.js";
export const PROJECT_CONFIG_FILENAME = "hyperframes.json";
export const PROJECT_CONFIG_SCHEMA_URL = "https://hyperframes.heygen.com/schema/hyperframes.json";
export interface ProjectConfigPaths {
/** Where `hyperframes:block` items land, relative to project root. */
blocks: string;
/** Where `hyperframes:component` items land, relative to project root. */
components: string;
/** Where asset files (images, fonts, videos) land, relative to project root. */
assets: string;
}
export interface ProjectConfig {
$schema?: string;
/** Base URL of the registry to pull items from. */
registry: string;
/** Target paths for each item type. */
paths: ProjectConfigPaths;
}
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
$schema: PROJECT_CONFIG_SCHEMA_URL,
registry: DEFAULT_REGISTRY_URL,
paths: {
blocks: "compositions",
components: "compositions/components",
assets: "assets",
},
};
/** Path to the config file for a project rooted at `projectDir`. */
export function projectConfigPath(projectDir: string): string {
return join(resolve(projectDir), PROJECT_CONFIG_FILENAME);
}
/** Read `hyperframes.json` from a project directory. */
export function readProjectConfig(projectDir: string): ProjectConfig | undefined {
const path = projectConfigPath(projectDir);
try {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<ProjectConfig>;
return normalizeConfig(parsed);
} catch {
// Missing file or corrupt JSON → no config.
return undefined;
}
}
/**
* Return a valid config fills in any missing fields with defaults. Used
* when a user's config file is present but partial (e.g. they only set
* `registry` and rely on default paths).
*/
export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig {
return {
$schema: partial.$schema ?? DEFAULT_PROJECT_CONFIG.$schema,
registry: partial.registry ?? DEFAULT_PROJECT_CONFIG.registry,
paths: {
blocks: partial.paths?.blocks ?? DEFAULT_PROJECT_CONFIG.paths.blocks,
components: partial.paths?.components ?? DEFAULT_PROJECT_CONFIG.paths.components,
assets: partial.paths?.assets ?? DEFAULT_PROJECT_CONFIG.paths.assets,
},
};
}
/** Write `hyperframes.json` to a project directory. Overwrites if present. */
export function writeProjectConfig(
projectDir: string,
config: ProjectConfig = DEFAULT_PROJECT_CONFIG,
): void {
const path = projectConfigPath(projectDir);
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
}
/**
* Load the project config for the given directory, falling back to defaults
* if missing. Mutates nothing on disk. Used by commands that want to operate
* with or without an explicit config.
*/
export function loadProjectConfig(projectDir: string): ProjectConfig {
return readProjectConfig(projectDir) ?? DEFAULT_PROJECT_CONFIG;
}