mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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:
co-authored by
Claude Opus 4.8
Rakibul Islam
parent
583b47b039
commit
8eac7e1cda
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user