Files
hyperframes/packages/cli/src/utils/projectConfig.test.ts
T
James Russo 08fb1de61f 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)
2026-04-13 21:04:59 -07:00

127 lines
4.0 KiB
TypeScript

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 });
}
});
});
});