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
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]));
Comment thread
rakibulism marked this conversation as resolved.
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;
}