Skip to content

Commit 583b47b

Browse files
authored
fix(cli): respect registry compatibility metadata (#1251)
1 parent 28e2ab9 commit 583b47b

5 files changed

Lines changed: 224 additions & 41 deletions

File tree

packages/cli/src/commands/add.test.ts

Lines changed: 98 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const MANIFEST: RegistryManifest = {
1313
homepage: "https://example.com",
1414
items: [
1515
{ name: "my-block", type: "hyperframes:block" },
16+
{ name: "deprecated-block", type: "hyperframes:block" },
17+
{ name: "future-block", type: "hyperframes:block" },
1618
{ name: "my-component", type: "hyperframes:component" },
1719
{ name: "my-example", type: "hyperframes:example" },
1820
],
@@ -60,6 +62,34 @@ const COMPONENT_ITEM: RegistryItem = {
6062
],
6163
};
6264

65+
const DEPRECATED_BLOCK_ITEM: RegistryItem = {
66+
...BLOCK_ITEM,
67+
name: "deprecated-block",
68+
title: "Deprecated Block",
69+
deprecated: "Use `my-block` instead.",
70+
files: [
71+
{
72+
path: "deprecated-block.html",
73+
target: "compositions/deprecated-block.html",
74+
type: "hyperframes:composition",
75+
},
76+
],
77+
};
78+
79+
const FUTURE_BLOCK_ITEM: RegistryItem = {
80+
...BLOCK_ITEM,
81+
name: "future-block",
82+
title: "Future Block",
83+
minCliVersion: "999.0.0",
84+
files: [
85+
{
86+
path: "future-block.html",
87+
target: "compositions/future-block.html",
88+
type: "hyperframes:composition",
89+
},
90+
],
91+
};
92+
6393
const EXAMPLE_ITEM: RegistryItem = {
6494
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
6595
name: "my-example",
@@ -73,6 +103,8 @@ const EXAMPLE_ITEM: RegistryItem = {
73103

74104
const ITEM_BY_NAME: Record<string, RegistryItem> = {
75105
"my-block": BLOCK_ITEM,
106+
"deprecated-block": DEPRECATED_BLOCK_ITEM,
107+
"future-block": FUTURE_BLOCK_ITEM,
76108
"my-component": COMPONENT_ITEM,
77109
"my-example": EXAMPLE_ITEM,
78110
};
@@ -108,6 +140,27 @@ function uniqueBase(): string {
108140
return `https://test.invalid/${crypto.randomUUID()}`;
109141
}
110142

143+
const DEFAULT_TEST_PATHS = {
144+
blocks: "compositions",
145+
components: "compositions/components",
146+
assets: "assets",
147+
};
148+
149+
function writeRegistryConfig(
150+
dir: string,
151+
paths: typeof DEFAULT_TEST_PATHS = DEFAULT_TEST_PATHS,
152+
): void {
153+
writeFileSync(
154+
join(dir, "hyperframes.json"),
155+
JSON.stringify({
156+
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
157+
registry: uniqueBase(),
158+
paths,
159+
}),
160+
"utf-8",
161+
);
162+
}
163+
111164
// ── Tests ───────────────────────────────────────────────────────────────────
112165

113166
describe("add command pure helpers", () => {
@@ -172,19 +225,14 @@ describe("runAdd (integration, mocked registry)", () => {
172225
const dir = tmp();
173226
try {
174227
// Write hyperframes.json so runAdd uses our unique baseUrl.
175-
const baseUrl = uniqueBase();
176-
const cfg = {
177-
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
178-
registry: baseUrl,
179-
paths: { blocks: "compositions", components: "compositions/components", assets: "assets" },
180-
};
181-
writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(cfg), "utf-8");
228+
writeRegistryConfig(dir);
182229

183230
const result = await runAdd({ name: "my-block", projectDir: dir, skipClipboard: true });
184231
expect(result.ok).toBe(true);
185232
expect(result.name).toBe("my-block");
186233
expect(result.type).toBe("hyperframes:block");
187234
expect(result.written).toHaveLength(1);
235+
expect(result.warnings).toEqual([]);
188236
expect(existsSync(join(dir, "compositions/my-block.html"))).toBe(true);
189237
const installed = readFileSync(join(dir, "compositions/my-block.html"), "utf-8");
190238
expect(installed).toContain("<!-- hyperframes-registry-item: my-block -->");
@@ -195,16 +243,50 @@ describe("runAdd (integration, mocked registry)", () => {
195243
}
196244
});
197245

246+
it("returns a warning for deprecated registry items while still installing", async () => {
247+
const dir = tmp();
248+
try {
249+
writeRegistryConfig(dir);
250+
251+
const result = await runAdd({
252+
name: "deprecated-block",
253+
projectDir: dir,
254+
skipClipboard: true,
255+
});
256+
expect(result.warnings).toEqual([
257+
'Registry item "deprecated-block" is deprecated: Use `my-block` instead.',
258+
]);
259+
expect(existsSync(join(dir, "compositions/deprecated-block.html"))).toBe(true);
260+
} finally {
261+
rmSync(dir, { recursive: true, force: true });
262+
}
263+
});
264+
265+
it("blocks registry items that require a newer CLI before writing files", async () => {
266+
const dir = tmp();
267+
try {
268+
writeRegistryConfig(dir);
269+
270+
await expect(
271+
runAdd({
272+
name: "future-block",
273+
projectDir: dir,
274+
skipClipboard: true,
275+
cliVersion: "0.6.79",
276+
}),
277+
).rejects.toMatchObject({
278+
code: "incompatible-cli",
279+
});
280+
expect(existsSync(join(dir, "compositions/future-block.html"))).toBe(false);
281+
} finally {
282+
rmSync(dir, { recursive: true, force: true });
283+
}
284+
});
285+
198286
it("remaps component snippet/style targets while leaving asset targets stable", async () => {
199287
const dir = tmp();
200288
try {
201-
const baseUrl = uniqueBase();
202-
const cfg = {
203-
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
204-
registry: baseUrl,
205-
paths: { blocks: "compositions", components: "src/fx", assets: "assets" },
206-
};
207-
writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(cfg), "utf-8");
289+
writeRegistryConfig(dir, { blocks: "compositions", components: "src/fx", assets: "assets" });
208290

209291
const result = await runAdd({
210292
name: "my-component",
@@ -224,19 +306,7 @@ describe("runAdd (integration, mocked registry)", () => {
224306
it("throws AddError with code 'example-type' when asked to add an example", async () => {
225307
const dir = tmp();
226308
try {
227-
const baseUrl = uniqueBase();
228-
writeFileSync(
229-
join(dir, "hyperframes.json"),
230-
JSON.stringify({
231-
registry: baseUrl,
232-
paths: {
233-
blocks: "compositions",
234-
components: "compositions/components",
235-
assets: "assets",
236-
},
237-
}),
238-
"utf-8",
239-
);
309+
writeRegistryConfig(dir);
240310

241311
await expect(
242312
runAdd({ name: "my-example", projectDir: dir, skipClipboard: true }),
@@ -251,19 +321,7 @@ describe("runAdd (integration, mocked registry)", () => {
251321
it("throws AddError with code 'unknown-item' for a missing name", async () => {
252322
const dir = tmp();
253323
try {
254-
const baseUrl = uniqueBase();
255-
writeFileSync(
256-
join(dir, "hyperframes.json"),
257-
JSON.stringify({
258-
registry: baseUrl,
259-
paths: {
260-
blocks: "compositions",
261-
components: "compositions/components",
262-
assets: "assets",
263-
},
264-
}),
265-
"utf-8",
266-
);
324+
writeRegistryConfig(dir);
267325

268326
await expect(
269327
runAdd({ name: "nope", projectDir: dir, skipClipboard: true }),

packages/cli/src/commands/add.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { resolve, relative } from "node:path";
1515
import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core";
1616
import { c } from "../ui/colors.js";
1717
import { installItem, resolveItem, resolveItemsByTag } from "../registry/index.js";
18+
import { checkRegistryItemCompatibility } from "../registry/compatibility.js";
1819
import {
1920
DEFAULT_PROJECT_CONFIG,
2021
loadProjectConfig,
@@ -75,6 +76,8 @@ export interface RunAddArgs {
7576
name: string;
7677
projectDir: string;
7778
skipClipboard?: boolean;
79+
/** Current CLI version used for registry metadata compatibility checks. */
80+
cliVersion?: string;
7881
}
7982

8083
export interface RunAddResult {
@@ -85,12 +88,18 @@ export interface RunAddResult {
8588
written: string[];
8689
snippet: string;
8790
clipboardCopied: boolean;
91+
warnings: string[];
8892
}
8993

9094
export class AddError extends Error {
9195
constructor(
9296
message: string,
93-
public readonly code: "unknown-item" | "wrong-type" | "install-failed" | "example-type",
97+
public readonly code:
98+
| "unknown-item"
99+
| "wrong-type"
100+
| "install-failed"
101+
| "example-type"
102+
| "incompatible-cli",
94103
) {
95104
super(message);
96105
this.name = "AddError";
@@ -123,6 +132,11 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
123132
);
124133
}
125134

135+
const compatibility = checkRegistryItemCompatibility(item, opts.cliVersion);
136+
if (compatibility.error) {
137+
throw new AddError(compatibility.error, "incompatible-cli");
138+
}
139+
126140
// 3. Remap targets per project config.
127141
const remappedFiles = item.files.map((f) => ({
128142
...f,
@@ -162,6 +176,7 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
162176
written,
163177
snippet,
164178
clipboardCopied,
179+
warnings: compatibility.warnings,
165180
};
166181
}
167182

@@ -212,6 +227,9 @@ export default defineCommand({
212227
if (wroteConfig) {
213228
console.log(c.dim(`Wrote default ${projectConfigPath(projectDir)}`));
214229
}
230+
for (const warning of result.warnings) {
231+
console.warn(c.warn(`Warning: ${warning}`));
232+
}
215233
console.log("");
216234
console.log(`${c.success("✓")} Added ${c.accent(result.name)} (${result.type})`);
217235
for (const file of result.written) {
@@ -272,6 +290,9 @@ export default defineCommand({
272290
try {
273291
const result = await runAdd({ name: item.name, projectDir, skipClipboard: true });
274292
results.push(result);
293+
for (const warning of result.warnings) {
294+
if (!json) console.log(` ${c.warn("Warning:")} ${warning}`);
295+
}
275296
if (!json) console.log(` ${c.success("✓")} ${result.name}`);
276297
} catch {
277298
if (!json) console.log(` ${c.error("✗")} ${item.name} (skipped)`);

packages/cli/src/commands/catalog.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ export default defineCommand({
115115
skipClipboard: false,
116116
});
117117

118+
for (const warning of result.warnings) {
119+
console.warn(c.warn(`Warning: ${warning}`));
120+
}
118121
console.log("");
119122
console.log(`${c.success("✓")} Installed ${c.accent(result.name)} (${result.type})`);
120123
for (const file of result.written) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { RegistryItem } from "@hyperframes/core";
3+
import { checkRegistryItemCompatibility } from "./compatibility.js";
4+
5+
const BASE_ITEM: RegistryItem = {
6+
name: "demo-block",
7+
type: "hyperframes:block",
8+
title: "Demo Block",
9+
description: "Block for tests",
10+
dimensions: { width: 1080, height: 1350 },
11+
duration: 6,
12+
files: [
13+
{
14+
path: "demo-block.html",
15+
target: "compositions/demo-block.html",
16+
type: "hyperframes:composition",
17+
},
18+
],
19+
};
20+
21+
describe("checkRegistryItemCompatibility", () => {
22+
it("returns no warning or error for compatible items", () => {
23+
expect(checkRegistryItemCompatibility(BASE_ITEM, "0.6.79")).toEqual({ warnings: [] });
24+
});
25+
26+
it("returns a warning for deprecated items", () => {
27+
const result = checkRegistryItemCompatibility(
28+
{ ...BASE_ITEM, deprecated: "Use `demo-block-v2` instead." },
29+
"0.6.79",
30+
);
31+
expect(result).toEqual({
32+
warnings: ['Registry item "demo-block" is deprecated: Use `demo-block-v2` instead.'],
33+
});
34+
});
35+
36+
it("returns an error when the current CLI is below minCliVersion", () => {
37+
const result = checkRegistryItemCompatibility(
38+
{ ...BASE_ITEM, minCliVersion: "0.6.80" },
39+
"0.6.79",
40+
);
41+
expect(result.error).toContain('Registry item "demo-block" requires hyperframes >= 0.6.80');
42+
});
43+
44+
it("allows source/dev CLI builds to install future-gated registry items", () => {
45+
expect(
46+
checkRegistryItemCompatibility({ ...BASE_ITEM, minCliVersion: "999.0.0" }, "0.0.0-dev"),
47+
).toEqual({ warnings: [] });
48+
});
49+
50+
it("returns a clear error for malformed minCliVersion metadata", () => {
51+
const result = checkRegistryItemCompatibility(
52+
{ ...BASE_ITEM, minCliVersion: "next" },
53+
"0.6.79",
54+
);
55+
expect(result.error).toContain('declares invalid minCliVersion "next"');
56+
});
57+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { RegistryItem } from "@hyperframes/core";
2+
import { compareVersions } from "compare-versions";
3+
import { VERSION } from "../version.js";
4+
5+
export interface RegistryCompatibilityResult {
6+
warnings: string[];
7+
error?: string;
8+
}
9+
10+
const DEV_VERSION = "0.0.0-dev";
11+
12+
export function checkRegistryItemCompatibility(
13+
item: RegistryItem,
14+
currentCliVersion = VERSION,
15+
): RegistryCompatibilityResult {
16+
const warnings: string[] = [];
17+
if (item.deprecated) {
18+
warnings.push(`Registry item "${item.name}" is deprecated: ${item.deprecated}`);
19+
}
20+
21+
const minCliVersion = item.minCliVersion?.trim();
22+
if (!minCliVersion || currentCliVersion === DEV_VERSION) {
23+
return { warnings };
24+
}
25+
26+
try {
27+
if (compareVersions(currentCliVersion, minCliVersion) >= 0) {
28+
return { warnings };
29+
}
30+
} catch {
31+
return {
32+
warnings,
33+
error: `Registry item "${item.name}" declares invalid minCliVersion "${minCliVersion}".`,
34+
};
35+
}
36+
37+
return {
38+
warnings,
39+
error:
40+
`Registry item "${item.name}" requires hyperframes >= ${minCliVersion} ` +
41+
`(current: ${currentCliVersion}). Run \`npx hyperframes@latest add ${item.name}\` ` +
42+
"or upgrade your installed hyperframes CLI.",
43+
};
44+
}

0 commit comments

Comments
 (0)