mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): respect registry compatibility metadata (#1251)
This commit is contained in:
@@ -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<string, RegistryItem> = {
|
||||
"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("<!-- hyperframes-registry-item: my-block -->");
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<RunAddResult> {
|
||||
);
|
||||
}
|
||||
|
||||
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<RunAddResult> {
|
||||
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)`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user