Skip to content
22 changes: 21 additions & 1 deletion packages/cli/src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const MANIFEST: RegistryManifest = {
homepage: "https://example.com",
items: [
{ name: "my-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 @@ -41,6 +42,7 @@ const COMPONENT_ITEM: RegistryItem = {
type: "hyperframes:component",
title: "My Component",
description: "Component for tests",
registryDependencies: ["base-component"],
files: [
{
path: "my-component.html",
Expand All @@ -55,6 +57,21 @@ const COMPONENT_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",
},
],
};

const EXAMPLE_ITEM: RegistryItem = {
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
name: "my-example",
Expand All @@ -68,6 +85,7 @@ const EXAMPLE_ITEM: RegistryItem = {

const ITEM_BY_NAME: Record<string, RegistryItem> = {
"my-block": BLOCK_ITEM,
"base-component": BASE_COMPONENT_ITEM,
"my-component": COMPONENT_ITEM,
"my-example": EXAMPLE_ITEM,
};
Expand Down Expand Up @@ -206,7 +224,9 @@ describe("runAdd (integration, mocked registry)", () => {
projectDir: dir,
skipClipboard: true,
});
expect(result.written.length).toBe(2);
expect(result.written.length).toBe(3);
expect(result.installed).toEqual(["base-component", "my-component"]);
expect(existsSync(join(dir, "src/fx/base-component/base-component.css"))).toBe(true);
expect(existsSync(join(dir, "src/fx/my-component/my-component.html"))).toBe(true);
expect(existsSync(join(dir, "src/fx/my-component/my-component.css"))).toBe(true);
expect(result.snippet).toContain("src/fx/my-component/my-component.html");
Expand Down
45 changes: 28 additions & 17 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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 } from "../registry/index.js";
import { installItem, resolveItemWithDependencies } from "../registry/index.js";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
Expand Down Expand Up @@ -81,6 +81,7 @@ export interface RunAddResult {
type: RegistryItem["type"];
typeDir: string;
written: string[];
installed: string[];
snippet: string;
clipboardCopied: boolean;
}
Expand All @@ -106,13 +107,17 @@ 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 item (and transitive dependencies) from the registry.
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");
}
const item = resolved[resolved.length - 1];
if (!item) {
throw new AddError(`Item "${opts.name}" not found — registry unreachable or empty.`, "unknown-item");
}

if (item.type === "hyperframes:example") {
throw new AddError(
Expand All @@ -122,20 +127,24 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
}

// 3. Remap targets per project config.
const remappedFiles = item.files.map((f) => ({
...f,
target: remapTarget(item, f.target, config.paths),
const installPlan = 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[];
let written: string[] = [];
try {
const result = await installItem(itemForInstall, {
destDir: projectDir,
baseUrl: config.registry,
});
written = result.written;
for (const resolvedItem of installPlan) {
const result = await installItem(resolvedItem, {
destDir: projectDir,
baseUrl: config.registry,
});
written.push(...result.written);
}
} catch (err) {
throw new AddError(
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
Expand All @@ -144,10 +153,11 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
}

// 5. Build include snippet + clipboard copy.
const installTarget = installPlan[installPlan.length - 1] ?? item;
const primaryFile =
itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
itemForInstall.files[0];
installTarget.files.find((f) => f.type === "hyperframes:snippet") ??
installTarget.files.find((f) => f.type === "hyperframes:composition") ??
installTarget.files[0];
const snippetTargetRel = primaryFile?.target ?? "";
const snippet = buildSnippet(item, snippetTargetRel);
const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false;
Expand All @@ -158,6 +168,7 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
type: item.type,
typeDir: ITEM_TYPE_DIRS[item.type],
written,
installed: installPlan.map((resolvedItem) => resolvedItem.name),
snippet,
clipboardCopied,
};
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ export {
fetchItemFile,
} from "./remote.js";

export { listRegistryItems, loadAllItems, resolveItem, type ResolveOptions } from "./resolver.js";
export {
listRegistryItems,
loadAllItems,
resolveItem,
resolveItemWithDependencies,
type ResolveOptions,
} from "./resolver.js";

export {
installItem,
Expand Down
54 changes: 50 additions & 4 deletions packages/cli/src/registry/resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
import { listRegistryItems, loadAllItems, resolveItem } from "./resolver.js";
import {
listRegistryItems,
loadAllItems,
resolveItem,
resolveItemWithDependencies,
} from "./resolver.js";

const MANIFEST: RegistryManifest = {
$schema: "https://hyperframes.heygen.com/schema/registry.json",
Expand All @@ -13,6 +18,11 @@ const MANIFEST: RegistryManifest = {
],
};

const DEPENDENCIES: Record<string, string[] | undefined> = {
beta: ["alpha"],
gamma: ["beta"],
};

function buildItem(name: string, type: "hyperframes:example" | "hyperframes:block"): RegistryItem {
if (type === "hyperframes:example") {
return {
Expand All @@ -22,6 +32,7 @@ function buildItem(name: string, type: "hyperframes:example" | "hyperframes:bloc
description: `${name} desc`,
dimensions: { width: 1920, height: 1080 },
duration: 10,
registryDependencies: DEPENDENCIES[name],
files: [{ path: "index.html", target: "index.html", type: "hyperframes:composition" }],
};
}
Expand All @@ -32,6 +43,7 @@ function buildItem(name: string, type: "hyperframes:example" | "hyperframes:bloc
description: `${name} desc`,
dimensions: { width: 1080, height: 1350 },
duration: 6,
registryDependencies: DEPENDENCIES[name],
files: [
{
path: `${name}.html`,
Expand All @@ -42,7 +54,13 @@ function buildItem(name: string, type: "hyperframes:example" | "hyperframes:bloc
};
}

function mockFetch(overrides: Record<string, unknown> = {}): void {
function mockFetch(
overrides: {
registryFails?: boolean;
missing?: string[];
dependencies?: Record<string, string[] | undefined>;
} = {},
): void {
vi.stubGlobal(
"fetch",
vi.fn(async (urlInput: string | URL) => {
Expand All @@ -51,9 +69,13 @@ function mockFetch(overrides: Record<string, unknown> = {}): void {
return new Response(JSON.stringify(MANIFEST), { status: 200 });
}
const m = /\/(examples|blocks|components)\/([^/]+)\/registry-item\.json$/.exec(url);
if (m && !(overrides.missing as string[] | undefined)?.includes(m[2]!)) {
if (m && !overrides.missing?.includes(m[2]!)) {
const type = m[1] === "examples" ? "hyperframes:example" : "hyperframes:block";
return new Response(JSON.stringify(buildItem(m[2]!, type)), { status: 200 });
const item = buildItem(m[2]!, type);
if (overrides.dependencies && item.name in overrides.dependencies) {
item.registryDependencies = overrides.dependencies[item.name];
}
return new Response(JSON.stringify(item), { status: 200 });
}
return new Response("not found", { status: 404 });
}),
Expand Down Expand Up @@ -140,4 +162,28 @@ describe("registry resolver", () => {
await expect(resolveItem("alpha", { baseUrl })).rejects.toThrow(/unreachable/);
});
});

describe("resolveItemWithDependencies", () => {
it("returns dependencies first, then the requested item", async () => {
const baseUrl = uniqueBaseUrl();
const items = await resolveItemWithDependencies("gamma", { baseUrl });
expect(items.map((item) => item.name)).toEqual(["alpha", "beta", "gamma"]);
});

it("throws when a transitive dependency is missing from the registry", async () => {
mockFetch({ dependencies: { beta: ["does-not-exist"], gamma: ["beta"] } });
const baseUrl = uniqueBaseUrl();
await expect(resolveItemWithDependencies("gamma", { baseUrl })).rejects.toThrow(
/Dependency "does-not-exist" not found in registry/,
);
});

it("throws a clear cycle error for circular dependencies", async () => {
mockFetch({ dependencies: { alpha: ["gamma"], beta: ["alpha"], gamma: ["beta"] } });
const baseUrl = uniqueBaseUrl();
await expect(resolveItemWithDependencies("gamma", { baseUrl })).rejects.toThrow(
/Circular registryDependencies detected/,
);
});
});
});
77 changes: 68 additions & 9 deletions packages/cli/src/registry/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Registry resolver — loads the top-level manifest and per-item manifests.
* No transitive dependency resolution yet (examples don't have any); added
* when blocks/components need it for the `add` command.
* Supports transitive `registryDependencies` resolution for install flows.
*/

import type { ItemType, RegistryItem, RegistryManifestEntry } from "@hyperframes/core";
Expand Down Expand Up @@ -63,27 +62,87 @@ export async function loadAllItems(
return items;
}

function formatAvailable(entries: RegistryManifestEntry[]): string {
return entries.map((e) => e.name).join(", ");
}

/**
* Resolve a single item by name. Throws if unknown or unreachable.
*
* TODO: walk registryDependencies transitively and return a topo-sorted
* list of items. Today examples have no deps so this returns a single item.
* Blocks and components will need transitive resolution once they ship with
* deps (seed items in Phase B).
*/
export async function resolveItem(
name: string,
options: ResolveOptions = {},
): Promise<RegistryItem> {
const items = await resolveItemWithDependencies(name, options);
const item = items[items.length - 1];
if (!item) {
throw new Error(`Item "${name}" not found — registry unreachable or empty.`);
}
return item;
}

/**
* Resolve one item and all of its transitive `registryDependencies` in
* topological order (dependencies first, requested item last).
*/
export async function resolveItemWithDependencies(
name: string,
options: ResolveOptions = {},
): Promise<RegistryItem[]> {
const entries = await listRegistryItems(undefined, options);
const entry = entries.find((e) => e.name === name);
if (!entry) {
const available = entries.map((e) => e.name).join(", ");
const available = formatAvailable(entries);
throw new Error(
available.length > 0
? `Item "${name}" not found in registry. Available: ${available}`
: `Item "${name}" not found — registry unreachable or empty.`,
);
}
return fetchItemManifest(entry.name, entry.type, options.baseUrl);

const entryByName = new Map(entries.map((e) => [e.name, e]));
const visiting = new Set<string>();
const visited = new Set<string>();
const ordered: RegistryItem[] = [];
const itemCache = new Map<string, Promise<RegistryItem>>();

const getItem = (itemName: string): Promise<RegistryItem> => {
const existing = itemCache.get(itemName);
if (existing) return existing;

const registryEntry = entryByName.get(itemName);
if (!registryEntry) {
const available = formatAvailable(entries);
throw new Error(
available.length > 0
? `Dependency "${itemName}" not found in registry. Available: ${available}`
: `Dependency "${itemName}" not found — registry unreachable or empty.`,
);
}

const pending = fetchItemManifest(registryEntry.name, registryEntry.type, options.baseUrl);
itemCache.set(itemName, pending);
return pending;
};

const visit = async (itemName: string, path: string[]): Promise<void> => {
if (visited.has(itemName)) return;
if (visiting.has(itemName)) {
const cycleStart = path.indexOf(itemName);
const cyclePath = [...path.slice(cycleStart), itemName].join(" -> ");
throw new Error(`Circular registryDependencies detected: ${cyclePath}`);
}

visiting.add(itemName);
const item = await getItem(itemName);
for (const dep of item.registryDependencies ?? []) {
await visit(dep, [...path, itemName]);
}
visiting.delete(itemName);
visited.add(itemName);
ordered.push(item);
};

await visit(name, []);
return ordered;
}