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
+51 -31
View File
@@ -241,6 +241,39 @@ export async function loadPreviewServerBuildSignature(): Promise<string> {
]);
}
// 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(
/(<meta\s+name="viewport"\s+content="width=)\d+(,\s*height=)\d+/i,
`$1${hostW}$2${hostH}`,
);
content = content.replace(
/(\bwidth:\s*)\d+(px;\s*\n?\s*height:\s*)\d+(px;)/g,
(match, pre, mid, post) => {
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(
/(<meta\s+name="viewport"\s+content="width=)\d+(,\s*height=)\d+/i,
`$1${hostW}$2${hostH}`,
);
content = content.replace(
/(\bwidth:\s*)\d+(px;\s*\n?\s*height:\s*)\d+(px;)/g,
(match, pre, mid, post) => {
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;