diff --git a/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts index 7b3c05eec..f54b40c91 100644 --- a/packages/cli/src/commands/add.test.ts +++ b/packages/cli/src/commands/add.test.ts @@ -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" }, ], @@ -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", @@ -105,6 +137,8 @@ const ITEM_BY_NAME: Record = { "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, }; @@ -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"); @@ -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 { diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 6321fee80..fd3455b9a 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -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, @@ -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[]; @@ -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 { + 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 { const projectDir = resolve(opts.projectDir); @@ -117,13 +160,18 @@ export async function runAdd(opts: RunAddArgs): Promise { 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( @@ -132,34 +180,24 @@ export async function runAdd(opts: RunAddArgs): Promise { ); } - 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") ?? @@ -174,9 +212,10 @@ export async function runAdd(opts: RunAddArgs): Promise { type: item.type, typeDir: ITEM_TYPE_DIRS[item.type], written, + installed: installPlan.map((planItem) => planItem.name), snippet, clipboardCopied, - warnings: compatibility.warnings, + warnings, }; } diff --git a/packages/cli/src/registry/compatibility.test.ts b/packages/cli/src/registry/compatibility.test.ts index 41e901eb8..26edaf9ee 100644 --- a/packages/cli/src/registry/compatibility.test.ts +++ b/packages/cli/src/registry/compatibility.test.ts @@ -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", @@ -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/); + }); +}); diff --git a/packages/cli/src/registry/compatibility.ts b/packages/cli/src/registry/compatibility.ts index 36cedff75..c935d83ff 100644 --- a/packages/cli/src/registry/compatibility.ts +++ b/packages/cli/src/registry/compatibility.ts @@ -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; +} diff --git a/packages/cli/src/registry/resolver.test.ts b/packages/cli/src/registry/resolver.test.ts index fec6f316b..e9b7d46a7 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", @@ -42,7 +47,14 @@ function buildItem(name: string, type: "hyperframes:example" | "hyperframes:bloc }; } -function mockFetch(overrides: Record = {}): void { +function mockFetch( + overrides: { + registryFails?: boolean; + missing?: string[]; + /** Inject `registryDependencies` into served items, keyed by item name. */ + dependencies?: Record; + } = {}, +): void { vi.stubGlobal( "fetch", vi.fn(async (urlInput: string | URL) => { @@ -51,9 +63,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 }); }), @@ -139,5 +155,53 @@ describe("registry resolver", () => { const baseUrl = uniqueBaseUrl(); await expect(resolveItem("alpha", { baseUrl })).rejects.toThrow(/unreachable/); }); + + it("refuses an item that declares registryDependencies", async () => { + mockFetch({ dependencies: { beta: ["alpha"] } }); + const baseUrl = uniqueBaseUrl(); + await expect(resolveItem("beta", { baseUrl })).rejects.toThrow( + /declares registryDependencies \(alpha\); use resolveItemWithDependencies/, + ); + }); + }); + + describe("resolveItemWithDependencies", () => { + it("returns dependencies first, then the requested item (linear chain)", async () => { + mockFetch({ dependencies: { beta: ["alpha"], gamma: ["beta"] } }); + const baseUrl = uniqueBaseUrl(); + const items = await resolveItemWithDependencies("gamma", { baseUrl }); + expect(items.map((item) => item.name)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("returns a single item when there are no dependencies", async () => { + const baseUrl = uniqueBaseUrl(); + const items = await resolveItemWithDependencies("alpha", { baseUrl }); + expect(items.map((item) => item.name)).toEqual(["alpha"]); + }); + + it("installs a shared transitive dependency exactly once (diamond)", async () => { + // gamma depends on both alpha and beta; beta also depends on alpha. + mockFetch({ dependencies: { gamma: ["alpha", "beta"], beta: ["alpha"] } }); + const baseUrl = uniqueBaseUrl(); + const items = await resolveItemWithDependencies("gamma", { baseUrl }); + expect(items.map((item) => item.name)).toEqual(["alpha", "beta", "gamma"]); + expect(items.filter((item) => item.name === "alpha")).toHaveLength(1); + }); + + 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: gamma -> beta -> alpha -> gamma/, + ); + }); }); }); diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index eb2f8fb3f..8ba98749a 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -68,15 +68,53 @@ export async function loadAllItems( /** * 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). + * This is a thin guard around `resolveItemWithDependencies`: it refuses items + * that declare `registryDependencies`, throwing a clear error that points the + * caller at the dependency-aware API. That keeps single-item install paths + * from silently dropping a transitive dependency — any caller that wants deps + * installed must opt in via `resolveItemWithDependencies`. */ export async function resolveItem( name: string, options: ResolveOptions = {}, ): Promise { + const items = await resolveItemWithDependencies(name, options); + if (items.length > 1) { + const deps = items + .slice(0, -1) + .map((i) => i.name) + .join(", "); + throw new Error( + `Item "${name}" declares registryDependencies (${deps}); use resolveItemWithDependencies ` + + `to resolve and install them in order.`, + ); + } + const item = items[items.length - 1]; + if (!item) { + throw new Error(`Item "${name}" not found — registry unreachable or empty.`); + } + return item; +} + +/** + * Resolve an item and all of its transitive `registryDependencies` in + * topological order — dependencies first, the requested item last — so callers + * can install the returned list front-to-back and have every prerequisite on + * disk before the item that needs it. + * + * Detects cycles (throws with the offending path) and missing dependencies + * (throws naming the absent item). Shared dependencies in a diamond graph are + * resolved and returned exactly once. + * + * Note: dependencies are fetched serially during the DFS walk. This keeps the + * cycle-detection bookkeeping (the `visiting` set) simple and correct; the + * registry is small enough that the extra round-trips don't matter. Switch to + * parallel sibling fetches only if graph depth ever becomes a real cost. + */ +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) { @@ -87,7 +125,56 @@ export async function resolveItem( : `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>(); + + // `async` so the missing-dependency path surfaces as a promise rejection + // rather than a synchronous throw, keeping the control flow consistent with + // the `Promise` return type. The body has no `await`, so the + // cache is still populated synchronously on first request (dedup intact). + const getItem = async (itemName: string): Promise => { + const existing = itemCache.get(itemName); + if (existing) return existing; + + const registryEntry = entryByName.get(itemName); + if (!registryEntry) { + const available = entries.map((e) => e.name).join(", "); + 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; } /** diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index f275e8ddc..f25228b56 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -241,6 +241,39 @@ export async function loadPreviewServerBuildSignature(): Promise { ]); } +// Rewrite the viewport meta + inline width/height in every written .html to the +// host composition's dimensions, so an installed fragment matches the host +// canvas. Applies to ALL written files — including any .html a dependency ships, +// not just the requested block's — which is intentional. No-op when the host +// index.html is absent or carries no dimensions. +function rewriteWrittenToHostViewport(projectDir: string, written: string[]): void { + const indexPath = join(projectDir, "index.html"); + if (!existsSync(indexPath)) return; + const indexHtml = readFileSync(indexPath, "utf-8"); + const hostW = indexHtml.match(/data-width="(\d+)"/)?.[1]; + const hostH = indexHtml.match(/data-height="(\d+)"/)?.[1]; + if (!hostW || !hostH) return; + + for (const absPath of written) { + if (!absPath.endsWith(".html")) continue; + let content = readFileSync(absPath, "utf-8"); + content = content.replace( + /( { + if (match.includes("1920") || match.includes("1080")) { + return `${pre}${hostW}${mid}${hostH}${post}`; + } + return match; + }, + ); + writeFileSync(absPath, content, "utf-8"); + } +} + export function createStudioServer(options: StudioServerOptions): StudioServer { const { projectDir, projectName } = options; const projectId = projectName || basename(projectDir); @@ -468,39 +501,26 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }, async installRegistryBlock(opts) { - const { resolveItem } = await import("../registry/resolver.js"); + const { resolveItemWithDependencies } = await import("../registry/resolver.js"); const { installItem } = await import("../registry/installer.js"); - const { readFileSync, writeFileSync, existsSync } = await import("node:fs"); - const { join } = await import("node:path"); - const item = await resolveItem(opts.blockName); - const { written } = await installItem(item, { destDir: opts.project.dir }); - - const indexPath = join(opts.project.dir, "index.html"); - if (existsSync(indexPath)) { - const indexHtml = readFileSync(indexPath, "utf-8"); - const hostW = indexHtml.match(/data-width="(\d+)"/)?.[1]; - const hostH = indexHtml.match(/data-height="(\d+)"/)?.[1]; - if (hostW && hostH) { - for (const absPath of written) { - if (!absPath.endsWith(".html")) continue; - let content = readFileSync(absPath, "utf-8"); - content = content.replace( - /( { - if (match.includes("1920") || match.includes("1080")) { - return `${pre}${hostW}${mid}${hostH}${post}`; - } - return match; - }, - ); - writeFileSync(absPath, content, "utf-8"); - } - } + const { gateRegistryItemsCompatibility } = await import("../registry/compatibility.js"); + // Resolve transitive registryDependencies and install them first so a + // block that depends on other registry items installs completely. + const items = await resolveItemWithDependencies(opts.blockName); + // Compatibility-gate the whole set before writing anything (same gate as + // `hyperframes add`), so an incompatible block or dep aborts cleanly. + const warnings = gateRegistryItemsCompatibility(items); + for (const warning of warnings) { + process.stderr.write(`hyperframes:registry ${warning}\n`); } + const written: string[] = []; + for (const dep of items) { + const result = await installItem(dep, { destDir: opts.project.dir }); + written.push(...result.written); + } + const item = items[items.length - 1]!; + + rewriteWrittenToHostViewport(opts.project.dir, written); const relativePaths = written.map((abs) => { const rel = abs.startsWith(opts.project.dir) ? abs.slice(opts.project.dir.length + 1) : abs; diff --git a/packages/cli/src/templates/remote.ts b/packages/cli/src/templates/remote.ts index ec1655a18..5bd3bd64c 100644 --- a/packages/cli/src/templates/remote.ts +++ b/packages/cli/src/templates/remote.ts @@ -5,7 +5,9 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { installItem, listRegistryItems, loadAllItems, resolveItem } from "../registry/index.js"; +import { installItem, listRegistryItems, loadAllItems } from "../registry/index.js"; +import { resolveItemWithDependencies } from "../registry/resolver.js"; +import { gateRegistryItemsCompatibility } from "../registry/compatibility.js"; // Re-exported for the existing remote.test.ts regression guard. These paths // describe the repo layout under the default registry URL; updating them in @@ -38,10 +40,24 @@ export async function listRemoteTemplates(): Promise { /** * Download a template into destDir. Delegates to the registry installer. + * + * Resolves the template's transitive `registryDependencies` and installs them + * before the template itself, so a template that depends on other registry + * items gets a complete install rather than silently dropping its deps. + * + * Every resolved item is compatibility-gated up front (same gate as + * `hyperframes add`), so an incompatible template — or any of its deps — + * aborts before a single file is written. */ export async function fetchRemoteTemplate(templateId: string, destDir: string): Promise { - const item = await resolveItem(templateId); - await installItem(item, { destDir }); + const items = await resolveItemWithDependencies(templateId); + const warnings = gateRegistryItemsCompatibility(items); + for (const warning of warnings) { + process.stderr.write(`hyperframes:registry ${warning}\n`); + } + for (const item of items) { + await installItem(item, { destDir }); + } // Safety check — an item with no index.html isn't a valid example. if (!existsSync(join(destDir, "index.html"))) {