Skip to content

Commit 8eac7e1

Browse files
jrusso1020rakibulismclaude
authored
fix(cli): resolve and install transitive registry dependencies (#1396)
* fix(cli): resolve and install transitive registry dependencies `hyperframes add`, `hyperframes new` (fetchRemoteTemplate), and the studio "add block" path each resolved a single registry item and silently dropped any `registryDependencies` it declared. Add `resolveItemWithDependencies` (DFS topological sort, cycle detection, missing-dependency errors, and dedup of shared/diamond deps) and route all three install paths through it so dependencies are installed before the item that needs them. `resolveItem` becomes a thin guard that throws on dep-bearing items, so no future caller can silently reintroduce the drop. `runAdd` now returns the ordered `installed` list and compatibility-gates every dependency before any write. Reworks the stale PR #414 onto current main and addresses its review feedback: fetchRemoteTemplate installs deps, no out-of-scope files, dead null-checks dropped, diamond test added, and the deliberate serial-fetch tradeoff is noted. Co-authored-by: Rakibul Islam <40rakib70@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): make getItem async so missing-dep surfaces as rejection Addresses review nit on #1396: getItem was typed Promise<RegistryItem> but threw synchronously on a missing dependency. Marking it async keeps the control flow consistent with the return type — the throw now becomes a rejection. The body has no await, so the item cache is still populated synchronously on first request and dedup is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): compatibility-gate transitive deps in all install paths Addresses Via's review on #1396: `assertCompatibleOrThrow` only ran inside `runAdd`, so `fetchRemoteTemplate` (hyperframes new) and the Studio "add block" action installed resolved items — now including transitive dependencies — with no minCliVersion enforcement or deprecation warnings. A pre-existing single-item asymmetry that this PR's dep loops amplify across N items. - Add shared `gateRegistryItemsCompatibility` + `RegistryCompatibilityError` to compatibility.ts; all three install paths now gate the full resolved set before any write. `runAdd` keeps its AddError mapping by wrapping the shared gate. - Surface deprecation warnings from the template/studio paths to stderr. - Extract the studio viewport rewrite into `rewriteWrittenToHostViewport` (also drops redundant dynamic node:fs imports) and document that it intentionally rewrites dep-shipped .html too (Via item 3). - Unit-test the shared gate directly (no fetch/cache flakiness): compatible set, accumulated deprecation warnings, and throw-on-incompatible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Rakibul Islam <40rakib70@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 583b47b commit 8eac7e1

8 files changed

Lines changed: 429 additions & 74 deletions

File tree

packages/cli/src/commands/add.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const MANIFEST: RegistryManifest = {
1515
{ name: "my-block", type: "hyperframes:block" },
1616
{ name: "deprecated-block", type: "hyperframes:block" },
1717
{ name: "future-block", type: "hyperframes:block" },
18+
{ name: "dep-block", type: "hyperframes:block" },
19+
{ name: "base-component", type: "hyperframes:component" },
1820
{ name: "my-component", type: "hyperframes:component" },
1921
{ name: "my-example", type: "hyperframes:example" },
2022
],
@@ -90,6 +92,36 @@ const FUTURE_BLOCK_ITEM: RegistryItem = {
9092
],
9193
};
9294

95+
const BASE_COMPONENT_ITEM: RegistryItem = {
96+
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
97+
name: "base-component",
98+
type: "hyperframes:component",
99+
title: "Base Component",
100+
description: "Base component dependency for tests",
101+
files: [
102+
{
103+
path: "base-component.css",
104+
target: "compositions/components/base-component/base-component.css",
105+
type: "hyperframes:style",
106+
},
107+
],
108+
};
109+
110+
// A block that declares a transitive registryDependency on base-component.
111+
const DEP_BLOCK_ITEM: RegistryItem = {
112+
...BLOCK_ITEM,
113+
name: "dep-block",
114+
title: "Dependent Block",
115+
registryDependencies: ["base-component"],
116+
files: [
117+
{
118+
path: "dep-block.html",
119+
target: "compositions/dep-block.html",
120+
type: "hyperframes:composition",
121+
},
122+
],
123+
};
124+
93125
const EXAMPLE_ITEM: RegistryItem = {
94126
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
95127
name: "my-example",
@@ -105,6 +137,8 @@ const ITEM_BY_NAME: Record<string, RegistryItem> = {
105137
"my-block": BLOCK_ITEM,
106138
"deprecated-block": DEPRECATED_BLOCK_ITEM,
107139
"future-block": FUTURE_BLOCK_ITEM,
140+
"dep-block": DEP_BLOCK_ITEM,
141+
"base-component": BASE_COMPONENT_ITEM,
108142
"my-component": COMPONENT_ITEM,
109143
"my-example": EXAMPLE_ITEM,
110144
};
@@ -232,6 +266,7 @@ describe("runAdd (integration, mocked registry)", () => {
232266
expect(result.name).toBe("my-block");
233267
expect(result.type).toBe("hyperframes:block");
234268
expect(result.written).toHaveLength(1);
269+
expect(result.installed).toEqual(["my-block"]);
235270
expect(result.warnings).toEqual([]);
236271
expect(existsSync(join(dir, "compositions/my-block.html"))).toBe(true);
237272
const installed = readFileSync(join(dir, "compositions/my-block.html"), "utf-8");
@@ -303,6 +338,27 @@ describe("runAdd (integration, mocked registry)", () => {
303338
}
304339
});
305340

341+
it("installs transitive registryDependencies before the requested item", async () => {
342+
const dir = tmp();
343+
try {
344+
writeRegistryConfig(dir);
345+
346+
const result = await runAdd({ name: "dep-block", projectDir: dir, skipClipboard: true });
347+
expect(result.name).toBe("dep-block");
348+
// Dependency first, requested item last.
349+
expect(result.installed).toEqual(["base-component", "dep-block"]);
350+
expect(result.written).toHaveLength(2);
351+
expect(
352+
existsSync(join(dir, "compositions/components/base-component/base-component.css")),
353+
).toBe(true);
354+
expect(existsSync(join(dir, "compositions/dep-block.html"))).toBe(true);
355+
// Snippet points at the requested block, not the dependency.
356+
expect(result.snippet).toContain("compositions/dep-block.html");
357+
} finally {
358+
rmSync(dir, { recursive: true, force: true });
359+
}
360+
});
361+
306362
it("throws AddError with code 'example-type' when asked to add an example", async () => {
307363
const dir = tmp();
308364
try {

packages/cli/src/commands/add.ts

Lines changed: 69 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import { existsSync } from "node:fs";
1414
import { resolve, relative } from "node:path";
1515
import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core";
1616
import { c } from "../ui/colors.js";
17-
import { installItem, resolveItem, resolveItemsByTag } from "../registry/index.js";
18-
import { checkRegistryItemCompatibility } from "../registry/compatibility.js";
17+
import { installItem, resolveItemsByTag } from "../registry/index.js";
18+
import { resolveItemWithDependencies } from "../registry/resolver.js";
19+
import {
20+
gateRegistryItemsCompatibility,
21+
RegistryCompatibilityError,
22+
} from "../registry/compatibility.js";
1923
import {
2024
DEFAULT_PROJECT_CONFIG,
2125
loadProjectConfig,
@@ -86,6 +90,8 @@ export interface RunAddResult {
8690
type: RegistryItem["type"];
8791
typeDir: string;
8892
written: string[];
93+
/** Names of every item installed, in order — dependencies first, then `name`. */
94+
installed: string[];
8995
snippet: string;
9096
clipboardCopied: boolean;
9197
warnings: string[];
@@ -106,6 +112,43 @@ export class AddError extends Error {
106112
}
107113
}
108114

115+
// Compatibility-gate a set of resolved items before any install runs, mapping
116+
// the shared gate's error into an AddError so the command surfaces the right
117+
// exit code. Returns the accumulated (non-fatal) warnings from every item.
118+
function assertCompatibleOrThrow(items: RegistryItem[], cliVersion?: string): string[] {
119+
try {
120+
return gateRegistryItemsCompatibility(items, cliVersion);
121+
} catch (err) {
122+
if (err instanceof RegistryCompatibilityError) {
123+
throw new AddError(err.message, "incompatible-cli");
124+
}
125+
throw err;
126+
}
127+
}
128+
129+
// Install a topologically-ordered plan (dependencies first, requested item
130+
// last). The installer validates every target before any write; a failure on
131+
// any item surfaces as an install-failed AddError. Returns all written paths.
132+
async function installAll(
133+
installPlan: RegistryItem[],
134+
destDir: string,
135+
baseUrl: string | undefined,
136+
): Promise<string[]> {
137+
const written: string[] = [];
138+
try {
139+
for (const planItem of installPlan) {
140+
const result = await installItem(planItem, { destDir, baseUrl });
141+
written.push(...result.written);
142+
}
143+
} catch (err) {
144+
throw new AddError(
145+
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
146+
"install-failed",
147+
);
148+
}
149+
return written;
150+
}
151+
109152
export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
110153
const projectDir = resolve(opts.projectDir);
111154

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

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

128176
if (item.type === "hyperframes:example") {
129177
throw new AddError(
@@ -132,34 +180,24 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
132180
);
133181
}
134182

135-
const compatibility = checkRegistryItemCompatibility(item, opts.cliVersion);
136-
if (compatibility.error) {
137-
throw new AddError(compatibility.error, "incompatible-cli");
138-
}
183+
// 3. Compatibility-gate every item we're about to install (dependencies
184+
// included) before writing anything.
185+
const warnings = assertCompatibleOrThrow(resolved, opts.cliVersion);
139186

140-
// 3. Remap targets per project config.
141-
const remappedFiles = item.files.map((f) => ({
142-
...f,
143-
target: remapTarget(item, f.target, config.paths),
187+
// 4. Remap targets per project config — each item by its own type.
188+
const installPlan: RegistryItem[] = resolved.map((resolvedItem) => ({
189+
...resolvedItem,
190+
files: resolvedItem.files.map((f) => ({
191+
...f,
192+
target: remapTarget(resolvedItem, f.target, config.paths),
193+
})),
144194
}));
145-
const itemForInstall: RegistryItem = { ...item, files: remappedFiles };
146195

147-
// 4. Install — the installer validates every target before any write.
148-
let written: string[];
149-
try {
150-
const result = await installItem(itemForInstall, {
151-
destDir: projectDir,
152-
baseUrl: config.registry,
153-
});
154-
written = result.written;
155-
} catch (err) {
156-
throw new AddError(
157-
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
158-
"install-failed",
159-
);
160-
}
196+
// 5. Install — dependencies first, requested item last.
197+
const written = await installAll(installPlan, projectDir, config.registry);
161198

162-
// 5. Build include snippet + clipboard copy.
199+
// 6. Build include snippet + clipboard copy for the requested item.
200+
const itemForInstall = installPlan[installPlan.length - 1]!;
163201
const primaryFile =
164202
itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
165203
itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
@@ -174,9 +212,10 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
174212
type: item.type,
175213
typeDir: ITEM_TYPE_DIRS[item.type],
176214
written,
215+
installed: installPlan.map((planItem) => planItem.name),
177216
snippet,
178217
clipboardCopied,
179-
warnings: compatibility.warnings,
218+
warnings,
180219
};
181220
}
182221

packages/cli/src/registry/compatibility.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest";
22
import type { RegistryItem } from "@hyperframes/core";
3-
import { checkRegistryItemCompatibility } from "./compatibility.js";
3+
import {
4+
checkRegistryItemCompatibility,
5+
gateRegistryItemsCompatibility,
6+
RegistryCompatibilityError,
7+
} from "./compatibility.js";
48

59
const BASE_ITEM: RegistryItem = {
610
name: "demo-block",
@@ -55,3 +59,38 @@ describe("checkRegistryItemCompatibility", () => {
5559
expect(result.error).toContain('declares invalid minCliVersion "next"');
5660
});
5761
});
62+
63+
describe("gateRegistryItemsCompatibility", () => {
64+
const dep = (
65+
name: string,
66+
extra: { deprecated?: string; minCliVersion?: string } = {},
67+
): RegistryItem => ({
68+
...BASE_ITEM,
69+
name,
70+
...extra,
71+
});
72+
73+
it("returns no warnings when every item is compatible", () => {
74+
expect(gateRegistryItemsCompatibility([dep("a"), dep("b")], "0.6.79")).toEqual([]);
75+
});
76+
77+
it("accumulates deprecation warnings across all items", () => {
78+
const warnings = gateRegistryItemsCompatibility(
79+
[dep("a", { deprecated: "gone" }), dep("b"), dep("c", { deprecated: "also gone" })],
80+
"0.6.79",
81+
);
82+
expect(warnings).toEqual([
83+
'Registry item "a" is deprecated: gone',
84+
'Registry item "c" is deprecated: also gone',
85+
]);
86+
});
87+
88+
it("throws RegistryCompatibilityError on the first item requiring a newer CLI", () => {
89+
expect(() =>
90+
gateRegistryItemsCompatibility([dep("a"), dep("b", { minCliVersion: "999.0.0" })], "0.6.79"),
91+
).toThrow(RegistryCompatibilityError);
92+
expect(() =>
93+
gateRegistryItemsCompatibility([dep("a"), dep("b", { minCliVersion: "999.0.0" })], "0.6.79"),
94+
).toThrow(/requires hyperframes >= 999\.0\.0/);
95+
});
96+
});

packages/cli/src/registry/compatibility.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,37 @@ export function checkRegistryItemCompatibility(
4242
"or upgrade your installed hyperframes CLI.",
4343
};
4444
}
45+
46+
/** Thrown by `gateRegistryItemsCompatibility` when an item requires a newer CLI. */
47+
export class RegistryCompatibilityError extends Error {
48+
constructor(message: string) {
49+
super(message);
50+
this.name = "RegistryCompatibilityError";
51+
}
52+
}
53+
54+
/**
55+
* Compatibility-gate a set of resolved items (e.g. an item plus its transitive
56+
* `registryDependencies`) before any of them are installed. Throws a
57+
* `RegistryCompatibilityError` on the first item that requires a newer CLI, so
58+
* a partial install never happens; returns the accumulated (non-fatal)
59+
* deprecation warnings from every item.
60+
*
61+
* Every install path — `add`, template fetch, and the Studio "add block"
62+
* action — funnels through this so a dependency that ships `minCliVersion` is
63+
* rejected uniformly, not just by `hyperframes add`.
64+
*/
65+
export function gateRegistryItemsCompatibility(
66+
items: RegistryItem[],
67+
currentCliVersion = VERSION,
68+
): string[] {
69+
const warnings: string[] = [];
70+
for (const item of items) {
71+
const result = checkRegistryItemCompatibility(item, currentCliVersion);
72+
if (result.error) {
73+
throw new RegistryCompatibilityError(result.error);
74+
}
75+
warnings.push(...result.warnings);
76+
}
77+
return warnings;
78+
}

0 commit comments

Comments
 (0)