diff --git a/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts index 87b82f525..5edafb55a 100644 --- a/packages/cli/src/commands/add.test.ts +++ b/packages/cli/src/commands/add.test.ts @@ -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" }, ], @@ -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", @@ -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", @@ -68,6 +85,7 @@ const EXAMPLE_ITEM: RegistryItem = { const ITEM_BY_NAME: Record = { "my-block": BLOCK_ITEM, + "base-component": BASE_COMPONENT_ITEM, "my-component": COMPONENT_ITEM, "my-example": EXAMPLE_ITEM, }; @@ -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"); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 8a5abe865..0a3e698a8 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -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, @@ -81,6 +81,7 @@ export interface RunAddResult { type: RegistryItem["type"]; typeDir: string; written: string[]; + installed: string[]; snippet: string; clipboardCopied: boolean; } @@ -106,13 +107,17 @@ export async function runAdd(opts: RunAddArgs): Promise { 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( @@ -122,20 +127,24 @@ export async function runAdd(opts: RunAddArgs): Promise { } // 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)}`, @@ -144,10 +153,11 @@ export async function runAdd(opts: RunAddArgs): Promise { } // 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; @@ -158,6 +168,7 @@ export async function runAdd(opts: RunAddArgs): Promise { type: item.type, typeDir: ITEM_TYPE_DIRS[item.type], written, + installed: installPlan.map((resolvedItem) => resolvedItem.name), snippet, clipboardCopied, }; diff --git a/packages/cli/src/registry/index.ts b/packages/cli/src/registry/index.ts index c3a702618..ff87f5686 100644 --- a/packages/cli/src/registry/index.ts +++ b/packages/cli/src/registry/index.ts @@ -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, diff --git a/packages/cli/src/registry/resolver.test.ts b/packages/cli/src/registry/resolver.test.ts index fec6f316b..105161a4c 100644 --- a/packages/cli/src/registry/resolver.test.ts +++ b/packages/cli/src/registry/resolver.test.ts @@ -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", @@ -13,6 +18,11 @@ const MANIFEST: RegistryManifest = { ], }; +const DEPENDENCIES: Record = { + beta: ["alpha"], + gamma: ["beta"], +}; + function buildItem(name: string, type: "hyperframes:example" | "hyperframes:block"): RegistryItem { if (type === "hyperframes:example") { return { @@ -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" }], }; } @@ -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`, @@ -42,7 +54,13 @@ function buildItem(name: string, type: "hyperframes:example" | "hyperframes:bloc }; } -function mockFetch(overrides: Record = {}): void { +function mockFetch( + overrides: { + registryFails?: boolean; + missing?: string[]; + dependencies?: Record; + } = {}, +): void { vi.stubGlobal( "fetch", vi.fn(async (urlInput: string | URL) => { @@ -51,9 +69,13 @@ function mockFetch(overrides: Record = {}): 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 }); }), @@ -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/, + ); + }); + }); }); diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index 9db4e04d9..44ab5d59c 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -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"; @@ -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 { + 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 { 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(); + const visited = new Set(); + const ordered: RegistryItem[] = []; + const itemCache = new Map>(); + + const getItem = (itemName: string): Promise => { + 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 => { + 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; }