From f24c54f0361cb2a68bef7c9fd465a4f7fc8b86ab Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Sun, 7 Jun 2026 13:48:24 +0900 Subject: [PATCH] fix(cli): respect registry compatibility metadata --- packages/cli/src/commands/add.test.ts | 138 +++++++++++++----- packages/cli/src/commands/add.ts | 23 ++- packages/cli/src/commands/catalog.ts | 3 + .../cli/src/registry/compatibility.test.ts | 57 ++++++++ packages/cli/src/registry/compatibility.ts | 44 ++++++ 5 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 packages/cli/src/registry/compatibility.test.ts create mode 100644 packages/cli/src/registry/compatibility.ts diff --git a/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts index 7237d7b25..7b3c05eec 100644 --- a/packages/cli/src/commands/add.test.ts +++ b/packages/cli/src/commands/add.test.ts @@ -13,6 +13,8 @@ const MANIFEST: RegistryManifest = { homepage: "https://example.com", items: [ { name: "my-block", type: "hyperframes:block" }, + { name: "deprecated-block", type: "hyperframes:block" }, + { name: "future-block", type: "hyperframes:block" }, { name: "my-component", type: "hyperframes:component" }, { name: "my-example", type: "hyperframes:example" }, ], @@ -60,6 +62,34 @@ const COMPONENT_ITEM: RegistryItem = { ], }; +const DEPRECATED_BLOCK_ITEM: RegistryItem = { + ...BLOCK_ITEM, + name: "deprecated-block", + title: "Deprecated Block", + deprecated: "Use `my-block` instead.", + files: [ + { + path: "deprecated-block.html", + target: "compositions/deprecated-block.html", + type: "hyperframes:composition", + }, + ], +}; + +const FUTURE_BLOCK_ITEM: RegistryItem = { + ...BLOCK_ITEM, + name: "future-block", + title: "Future Block", + minCliVersion: "999.0.0", + files: [ + { + path: "future-block.html", + target: "compositions/future-block.html", + type: "hyperframes:composition", + }, + ], +}; + const EXAMPLE_ITEM: RegistryItem = { $schema: "https://hyperframes.heygen.com/schema/registry-item.json", name: "my-example", @@ -73,6 +103,8 @@ const EXAMPLE_ITEM: RegistryItem = { const ITEM_BY_NAME: Record = { "my-block": BLOCK_ITEM, + "deprecated-block": DEPRECATED_BLOCK_ITEM, + "future-block": FUTURE_BLOCK_ITEM, "my-component": COMPONENT_ITEM, "my-example": EXAMPLE_ITEM, }; @@ -108,6 +140,27 @@ function uniqueBase(): string { return `https://test.invalid/${crypto.randomUUID()}`; } +const DEFAULT_TEST_PATHS = { + blocks: "compositions", + components: "compositions/components", + assets: "assets", +}; + +function writeRegistryConfig( + dir: string, + paths: typeof DEFAULT_TEST_PATHS = DEFAULT_TEST_PATHS, +): void { + writeFileSync( + join(dir, "hyperframes.json"), + JSON.stringify({ + $schema: "https://hyperframes.heygen.com/schema/hyperframes.json", + registry: uniqueBase(), + paths, + }), + "utf-8", + ); +} + // ── Tests ─────────────────────────────────────────────────────────────────── describe("add command pure helpers", () => { @@ -172,19 +225,14 @@ describe("runAdd (integration, mocked registry)", () => { 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"); + writeRegistryConfig(dir); 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(result.warnings).toEqual([]); expect(existsSync(join(dir, "compositions/my-block.html"))).toBe(true); const installed = readFileSync(join(dir, "compositions/my-block.html"), "utf-8"); expect(installed).toContain(""); @@ -195,16 +243,50 @@ describe("runAdd (integration, mocked registry)", () => { } }); + it("returns a warning for deprecated registry items while still installing", async () => { + const dir = tmp(); + try { + writeRegistryConfig(dir); + + const result = await runAdd({ + name: "deprecated-block", + projectDir: dir, + skipClipboard: true, + }); + expect(result.warnings).toEqual([ + 'Registry item "deprecated-block" is deprecated: Use `my-block` instead.', + ]); + expect(existsSync(join(dir, "compositions/deprecated-block.html"))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("blocks registry items that require a newer CLI before writing files", async () => { + const dir = tmp(); + try { + writeRegistryConfig(dir); + + await expect( + runAdd({ + name: "future-block", + projectDir: dir, + skipClipboard: true, + cliVersion: "0.6.79", + }), + ).rejects.toMatchObject({ + code: "incompatible-cli", + }); + expect(existsSync(join(dir, "compositions/future-block.html"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("remaps component snippet/style targets while leaving asset targets stable", 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"); + writeRegistryConfig(dir, { blocks: "compositions", components: "src/fx", assets: "assets" }); const result = await runAdd({ name: "my-component", @@ -224,19 +306,7 @@ describe("runAdd (integration, mocked registry)", () => { 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", - ); + writeRegistryConfig(dir); await expect( runAdd({ name: "my-example", projectDir: dir, skipClipboard: true }), @@ -251,19 +321,7 @@ describe("runAdd (integration, mocked registry)", () => { 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", - ); + writeRegistryConfig(dir); await expect( runAdd({ name: "nope", projectDir: dir, skipClipboard: true }), diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 79d138b63..6321fee80 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -15,6 +15,7 @@ import { resolve, relative } from "node:path"; import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core"; import { c } from "../ui/colors.js"; import { installItem, resolveItem, resolveItemsByTag } from "../registry/index.js"; +import { checkRegistryItemCompatibility } from "../registry/compatibility.js"; import { DEFAULT_PROJECT_CONFIG, loadProjectConfig, @@ -75,6 +76,8 @@ export interface RunAddArgs { name: string; projectDir: string; skipClipboard?: boolean; + /** Current CLI version used for registry metadata compatibility checks. */ + cliVersion?: string; } export interface RunAddResult { @@ -85,12 +88,18 @@ export interface RunAddResult { written: string[]; snippet: string; clipboardCopied: boolean; + warnings: string[]; } export class AddError extends Error { constructor( message: string, - public readonly code: "unknown-item" | "wrong-type" | "install-failed" | "example-type", + public readonly code: + | "unknown-item" + | "wrong-type" + | "install-failed" + | "example-type" + | "incompatible-cli", ) { super(message); this.name = "AddError"; @@ -123,6 +132,11 @@ export async function runAdd(opts: RunAddArgs): Promise { ); } + const compatibility = checkRegistryItemCompatibility(item, opts.cliVersion); + if (compatibility.error) { + throw new AddError(compatibility.error, "incompatible-cli"); + } + // 3. Remap targets per project config. const remappedFiles = item.files.map((f) => ({ ...f, @@ -162,6 +176,7 @@ export async function runAdd(opts: RunAddArgs): Promise { written, snippet, clipboardCopied, + warnings: compatibility.warnings, }; } @@ -212,6 +227,9 @@ export default defineCommand({ if (wroteConfig) { console.log(c.dim(`Wrote default ${projectConfigPath(projectDir)}`)); } + for (const warning of result.warnings) { + console.warn(c.warn(`Warning: ${warning}`)); + } console.log(""); console.log(`${c.success("✓")} Added ${c.accent(result.name)} (${result.type})`); for (const file of result.written) { @@ -272,6 +290,9 @@ export default defineCommand({ try { const result = await runAdd({ name: item.name, projectDir, skipClipboard: true }); results.push(result); + for (const warning of result.warnings) { + if (!json) console.log(` ${c.warn("Warning:")} ${warning}`); + } if (!json) console.log(` ${c.success("✓")} ${result.name}`); } catch { if (!json) console.log(` ${c.error("✗")} ${item.name} (skipped)`); diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 43d3c949c..7cce80a11 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -115,6 +115,9 @@ export default defineCommand({ skipClipboard: false, }); + for (const warning of result.warnings) { + console.warn(c.warn(`Warning: ${warning}`)); + } console.log(""); console.log(`${c.success("✓")} Installed ${c.accent(result.name)} (${result.type})`); for (const file of result.written) { diff --git a/packages/cli/src/registry/compatibility.test.ts b/packages/cli/src/registry/compatibility.test.ts new file mode 100644 index 000000000..41e901eb8 --- /dev/null +++ b/packages/cli/src/registry/compatibility.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { RegistryItem } from "@hyperframes/core"; +import { checkRegistryItemCompatibility } from "./compatibility.js"; + +const BASE_ITEM: RegistryItem = { + name: "demo-block", + type: "hyperframes:block", + title: "Demo Block", + description: "Block for tests", + dimensions: { width: 1080, height: 1350 }, + duration: 6, + files: [ + { + path: "demo-block.html", + target: "compositions/demo-block.html", + type: "hyperframes:composition", + }, + ], +}; + +describe("checkRegistryItemCompatibility", () => { + it("returns no warning or error for compatible items", () => { + expect(checkRegistryItemCompatibility(BASE_ITEM, "0.6.79")).toEqual({ warnings: [] }); + }); + + it("returns a warning for deprecated items", () => { + const result = checkRegistryItemCompatibility( + { ...BASE_ITEM, deprecated: "Use `demo-block-v2` instead." }, + "0.6.79", + ); + expect(result).toEqual({ + warnings: ['Registry item "demo-block" is deprecated: Use `demo-block-v2` instead.'], + }); + }); + + it("returns an error when the current CLI is below minCliVersion", () => { + const result = checkRegistryItemCompatibility( + { ...BASE_ITEM, minCliVersion: "0.6.80" }, + "0.6.79", + ); + expect(result.error).toContain('Registry item "demo-block" requires hyperframes >= 0.6.80'); + }); + + it("allows source/dev CLI builds to install future-gated registry items", () => { + expect( + checkRegistryItemCompatibility({ ...BASE_ITEM, minCliVersion: "999.0.0" }, "0.0.0-dev"), + ).toEqual({ warnings: [] }); + }); + + it("returns a clear error for malformed minCliVersion metadata", () => { + const result = checkRegistryItemCompatibility( + { ...BASE_ITEM, minCliVersion: "next" }, + "0.6.79", + ); + expect(result.error).toContain('declares invalid minCliVersion "next"'); + }); +}); diff --git a/packages/cli/src/registry/compatibility.ts b/packages/cli/src/registry/compatibility.ts new file mode 100644 index 000000000..36cedff75 --- /dev/null +++ b/packages/cli/src/registry/compatibility.ts @@ -0,0 +1,44 @@ +import type { RegistryItem } from "@hyperframes/core"; +import { compareVersions } from "compare-versions"; +import { VERSION } from "../version.js"; + +export interface RegistryCompatibilityResult { + warnings: string[]; + error?: string; +} + +const DEV_VERSION = "0.0.0-dev"; + +export function checkRegistryItemCompatibility( + item: RegistryItem, + currentCliVersion = VERSION, +): RegistryCompatibilityResult { + const warnings: string[] = []; + if (item.deprecated) { + warnings.push(`Registry item "${item.name}" is deprecated: ${item.deprecated}`); + } + + const minCliVersion = item.minCliVersion?.trim(); + if (!minCliVersion || currentCliVersion === DEV_VERSION) { + return { warnings }; + } + + try { + if (compareVersions(currentCliVersion, minCliVersion) >= 0) { + return { warnings }; + } + } catch { + return { + warnings, + error: `Registry item "${item.name}" declares invalid minCliVersion "${minCliVersion}".`, + }; + } + + return { + warnings, + error: + `Registry item "${item.name}" requires hyperframes >= ${minCliVersion} ` + + `(current: ${currentCliVersion}). Run \`npx hyperframes@latest add ${item.name}\` ` + + "or upgrade your installed hyperframes CLI.", + }; +}