Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 98 additions & 40 deletions packages/cli/src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand Down Expand Up @@ -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",
Expand All @@ -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,
};
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 -->");
Expand All @@ -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",
Expand All @@ -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 }),
Expand All @@ -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 }),
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -162,6 +176,7 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
written,
snippet,
clipboardCopied,
warnings: compatibility.warnings,
};
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)`);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/registry/compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
44 changes: 44 additions & 0 deletions packages/cli/src/registry/compatibility.ts
Original file line number Diff line number Diff line change
@@ -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.",
};
}
Loading