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>
This commit is contained in:
James Russo
2026-06-12 17:18:15 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Rakibul Islam
parent 583b47b039
commit 8eac7e1cda
8 changed files with 429 additions and 74 deletions
+69 -30
View File
@@ -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<string[]> {
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<RunAddResult> {
const projectDir = resolve(opts.projectDir);
@@ -117,13 +160,18 @@ 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 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<RunAddResult> {
);
}
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<RunAddResult> {
type: item.type,
typeDir: ITEM_TYPE_DIRS[item.type],
written,
installed: installPlan.map((planItem) => planItem.name),
snippet,
clipboardCopied,
warnings: compatibility.warnings,
warnings,
};
}