Skip to content
Merged
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
56 changes: 56 additions & 0 deletions packages/cli/src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const MANIFEST: RegistryManifest = {
{ name: "my-block", type: "hyperframes:block" },
{ name: "deprecated-block", type: "hyperframes:block" },
{ name: "future-block", type: "hyperframes:block" },
{ name: "dep-block", type: "hyperframes:block" },
{ name: "base-component", type: "hyperframes:component" },
{ name: "my-component", type: "hyperframes:component" },
{ name: "my-example", type: "hyperframes:example" },
],
Expand Down Expand Up @@ -90,6 +92,36 @@ const FUTURE_BLOCK_ITEM: RegistryItem = {
],
};

const BASE_COMPONENT_ITEM: RegistryItem = {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: "base-component",
type: "hyperframes:component",
title: "Base Component",
description: "Base component dependency for tests",
files: [
{
path: "base-component.css",
target: "compositions/components/base-component/base-component.css",
type: "hyperframes:style",
},
],
};

// A block that declares a transitive registryDependency on base-component.
const DEP_BLOCK_ITEM: RegistryItem = {
...BLOCK_ITEM,
name: "dep-block",
title: "Dependent Block",
registryDependencies: ["base-component"],
files: [
{
path: "dep-block.html",
target: "compositions/dep-block.html",
type: "hyperframes:composition",
},
],
};

const EXAMPLE_ITEM: RegistryItem = {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: "my-example",
Expand All @@ -105,6 +137,8 @@ const ITEM_BY_NAME: Record<string, RegistryItem> = {
"my-block": BLOCK_ITEM,
"deprecated-block": DEPRECATED_BLOCK_ITEM,
"future-block": FUTURE_BLOCK_ITEM,
"dep-block": DEP_BLOCK_ITEM,
"base-component": BASE_COMPONENT_ITEM,
"my-component": COMPONENT_ITEM,
"my-example": EXAMPLE_ITEM,
};
Expand Down Expand Up @@ -232,6 +266,7 @@ describe("runAdd (integration, mocked registry)", () => {
expect(result.name).toBe("my-block");
expect(result.type).toBe("hyperframes:block");
expect(result.written).toHaveLength(1);
expect(result.installed).toEqual(["my-block"]);
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");
Expand Down Expand Up @@ -303,6 +338,27 @@ describe("runAdd (integration, mocked registry)", () => {
}
});

it("installs transitive registryDependencies before the requested item", async () => {
const dir = tmp();
try {
writeRegistryConfig(dir);

const result = await runAdd({ name: "dep-block", projectDir: dir, skipClipboard: true });
expect(result.name).toBe("dep-block");
// Dependency first, requested item last.
expect(result.installed).toEqual(["base-component", "dep-block"]);
expect(result.written).toHaveLength(2);
expect(
existsSync(join(dir, "compositions/components/base-component/base-component.css")),
).toBe(true);
expect(existsSync(join(dir, "compositions/dep-block.html"))).toBe(true);
// Snippet points at the requested block, not the dependency.
expect(result.snippet).toContain("compositions/dep-block.html");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("throws AddError with code 'example-type' when asked to add an example", async () => {
const dir = tmp();
try {
Expand Down
99 changes: 69 additions & 30 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ import { existsSync } from "node:fs";
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 { installItem, resolveItemsByTag } from "../registry/index.js";
import { resolveItemWithDependencies } from "../registry/resolver.js";
import {
gateRegistryItemsCompatibility,
RegistryCompatibilityError,
} from "../registry/compatibility.js";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
Expand Down Expand Up @@ -86,6 +90,8 @@ export interface RunAddResult {
type: RegistryItem["type"];
typeDir: string;
written: string[];
/** Names of every item installed, in order — dependencies first, then `name`. */
installed: string[];
snippet: string;
clipboardCopied: boolean;
warnings: string[];
Expand All @@ -106,6 +112,43 @@ export class AddError extends Error {
}
}

// Compatibility-gate a set of resolved items before any install runs, mapping
// the shared gate's error into an AddError so the command surfaces the right
// exit code. Returns the accumulated (non-fatal) warnings from every item.
function assertCompatibleOrThrow(items: RegistryItem[], cliVersion?: string): string[] {
try {
return gateRegistryItemsCompatibility(items, cliVersion);
} catch (err) {
if (err instanceof RegistryCompatibilityError) {
throw new AddError(err.message, "incompatible-cli");
}
throw err;
}
}

// Install a topologically-ordered plan (dependencies first, requested item
// last). The installer validates every target before any write; a failure on
// any item surfaces as an install-failed AddError. Returns all written paths.
async function installAll(
installPlan: RegistryItem[],
destDir: string,
baseUrl: string | undefined,
): Promise<string[]> {
const written: string[] = [];
try {
for (const planItem of installPlan) {
const result = await installItem(planItem, { destDir, baseUrl });
written.push(...result.written);
}
} catch (err) {
throw new AddError(
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
"install-failed",
);
}
return written;
}

export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
const projectDir = resolve(opts.projectDir);

Expand All @@ -117,13 +160,18 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
config = DEFAULT_PROJECT_CONFIG;
}

// 2. Resolve the item from the registry.
let item: RegistryItem;
// 2. Resolve the requested item and its transitive registryDependencies.
// The list comes back topologically sorted: dependencies first, the
// requested item last.
let resolved: RegistryItem[];
try {
item = await resolveItem(opts.name, { baseUrl: config.registry });
resolved = await resolveItemWithDependencies(opts.name, { baseUrl: config.registry });
} catch (err) {
throw new AddError(err instanceof Error ? err.message : String(err), "unknown-item");
}
// `resolveItemWithDependencies` always pushes the requested item last (or throws),
// so the final element is the item the user asked for.
const item = resolved[resolved.length - 1]!;

if (item.type === "hyperframes:example") {
throw new AddError(
Expand All @@ -132,34 +180,24 @@ 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. Compatibility-gate every item we're about to install (dependencies
// included) before writing anything.
const warnings = assertCompatibleOrThrow(resolved, opts.cliVersion);

// 3. Remap targets per project config.
const remappedFiles = item.files.map((f) => ({
...f,
target: remapTarget(item, f.target, config.paths),
// 4. Remap targets per project config — each item by its own type.
const installPlan: RegistryItem[] = resolved.map((resolvedItem) => ({
...resolvedItem,
files: resolvedItem.files.map((f) => ({
...f,
target: remapTarget(resolvedItem, f.target, config.paths),
})),
}));
const itemForInstall: RegistryItem = { ...item, files: remappedFiles };

// 4. Install — the installer validates every target before any write.
let written: string[];
try {
const result = await installItem(itemForInstall, {
destDir: projectDir,
baseUrl: config.registry,
});
written = result.written;
} catch (err) {
throw new AddError(
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
"install-failed",
);
}
// 5. Install — dependencies first, requested item last.
const written = await installAll(installPlan, projectDir, config.registry);

// 5. Build include snippet + clipboard copy.
// 6. Build include snippet + clipboard copy for the requested item.
const itemForInstall = installPlan[installPlan.length - 1]!;
const primaryFile =
itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
Expand All @@ -174,9 +212,10 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
type: item.type,
typeDir: ITEM_TYPE_DIRS[item.type],
written,
installed: installPlan.map((planItem) => planItem.name),
snippet,
clipboardCopied,
warnings: compatibility.warnings,
warnings,
};
}

Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/registry/compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { RegistryItem } from "@hyperframes/core";
import { checkRegistryItemCompatibility } from "./compatibility.js";
import {
checkRegistryItemCompatibility,
gateRegistryItemsCompatibility,
RegistryCompatibilityError,
} from "./compatibility.js";

const BASE_ITEM: RegistryItem = {
name: "demo-block",
Expand Down Expand Up @@ -55,3 +59,38 @@ describe("checkRegistryItemCompatibility", () => {
expect(result.error).toContain('declares invalid minCliVersion "next"');
});
});

describe("gateRegistryItemsCompatibility", () => {
const dep = (
name: string,
extra: { deprecated?: string; minCliVersion?: string } = {},
): RegistryItem => ({
...BASE_ITEM,
name,
...extra,
});

it("returns no warnings when every item is compatible", () => {
expect(gateRegistryItemsCompatibility([dep("a"), dep("b")], "0.6.79")).toEqual([]);
});

it("accumulates deprecation warnings across all items", () => {
const warnings = gateRegistryItemsCompatibility(
[dep("a", { deprecated: "gone" }), dep("b"), dep("c", { deprecated: "also gone" })],
"0.6.79",
);
expect(warnings).toEqual([
'Registry item "a" is deprecated: gone',
'Registry item "c" is deprecated: also gone',
]);
});

it("throws RegistryCompatibilityError on the first item requiring a newer CLI", () => {
expect(() =>
gateRegistryItemsCompatibility([dep("a"), dep("b", { minCliVersion: "999.0.0" })], "0.6.79"),
).toThrow(RegistryCompatibilityError);
expect(() =>
gateRegistryItemsCompatibility([dep("a"), dep("b", { minCliVersion: "999.0.0" })], "0.6.79"),
).toThrow(/requires hyperframes >= 999\.0\.0/);
});
});
34 changes: 34 additions & 0 deletions packages/cli/src/registry/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,37 @@ export function checkRegistryItemCompatibility(
"or upgrade your installed hyperframes CLI.",
};
}

/** Thrown by `gateRegistryItemsCompatibility` when an item requires a newer CLI. */
export class RegistryCompatibilityError extends Error {
constructor(message: string) {
super(message);
this.name = "RegistryCompatibilityError";
}
}

/**
* Compatibility-gate a set of resolved items (e.g. an item plus its transitive
* `registryDependencies`) before any of them are installed. Throws a
* `RegistryCompatibilityError` on the first item that requires a newer CLI, so
* a partial install never happens; returns the accumulated (non-fatal)
* deprecation warnings from every item.
*
* Every install path — `add`, template fetch, and the Studio "add block"
* action — funnels through this so a dependency that ships `minCliVersion` is
* rejected uniformly, not just by `hyperframes add`.
*/
export function gateRegistryItemsCompatibility(
items: RegistryItem[],
currentCliVersion = VERSION,
): string[] {
const warnings: string[] = [];
for (const item of items) {
const result = checkRegistryItemCompatibility(item, currentCliVersion);
if (result.error) {
throw new RegistryCompatibilityError(result.error);
}
warnings.push(...result.warnings);
}
return warnings;
}
Loading
Loading