Files
hyperframes/packages/cli/src/commands/capture.ts
T
Miguel Ángel a5c2636e8c fix(cli): never print "[object Object]" from validate/inspect errors (#1810)
* fix(cli): use normalizeErrorMessage so validate/inspect never print "[object Object]"

The validate and inspect (layout) commands formatted thrown values with
`err instanceof Error ? err.message : String(err)`. When a browser/CDP/
Puppeteer protocol error or a structured page error reaches the formatter
as a plain object without a string `message`, `String(obj)` yields the
useless literal "[object Object]", hiding the real cause.

Route those paths through the existing shared `normalizeErrorMessage`
helper, which returns an Error's message, a string as-is, an object's
`.message` when present, or a compact JSON serialization otherwise (with
a key-list and String fallback for circular/opaque objects). Also fold
the duplicated local `errorMessage` helpers in batchRender and preview
into the same shared helper.

Covered by added assertions in errorMessage.test.ts for the no-message
object and Puppeteer-style protocol-error object cases.

* fix(cli): route remaining browser/process error sites through normalizeErrorMessage

The validate/inspect fix routed only those two commands through the shared
normalizeErrorMessage helper. The same err instanceof Error ? err.message :
String(err) pattern survived in the other commands that drive a headless
browser or an external process (ffmpeg, Docker, CDP) or surface a network
API error, so a thrown structured object without a string message would
still render as the useless literal [object Object].

Route those sites through the shared helper:
  snapshot.ts (the closest sibling to validate/inspect, same bug class),
  render.ts (Chrome launch + Docker build), capture/index.ts and
  commands/capture.ts (page-driven extraction), auth/browser.ts,
  browser/manager.ts (Puppeteer browser resolution), and the cloud/lambda
  paths (cloud/render.ts, cloudrun.ts, lambda/render-batch.ts,
  lambda/policies.ts, cloud/detectAspectRatio.ts) that surface API/network
  error objects.

Only the message-deriving expression changes; control flow and error
propagation are untouched. capture/index.ts keeps appending the stack for
real Errors and only routes the non-Error branch. Adds a helper test for a
structured CDP-style error object (code + nested data, no message).
2026-06-30 10:47:54 -07:00

243 lines
8.1 KiB
TypeScript

import { defineCommand } from "citty";
import { resolve } from "node:path";
import type { Example } from "./_examples.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export const examples: Example[] = [
["Capture a website into ./capture/", "hyperframes capture https://stripe.com"],
["Capture to a different directory", "hyperframes capture https://linear.app -o linear-video"],
["JSON output for AI agents", "hyperframes capture https://example.com --json"],
[
"Pull a video from the captured manifest by index",
"hyperframes capture --video ./linear-video --index 0",
],
[
"List videos referenced in the captured manifest",
"hyperframes capture --video ./linear-video --list",
],
];
export default defineCommand({
meta: {
name: "capture",
description: "Capture a website as editable HyperFrames components",
},
args: {
url: {
type: "positional",
description: "Website URL to capture (omit when using --video)",
required: false,
},
output: {
type: "string",
description: "Output directory name (default: ./capture, then ./capture-2/, ./capture-3/, …)",
alias: "o",
},
"skip-assets": {
type: "boolean",
description: "Skip downloading assets (images, SVGs)",
default: false,
},
"max-screenshots": {
type: "string",
description: "Maximum screenshots to capture (default: 24)",
},
timeout: {
type: "string",
description: "Page load timeout in ms (default: 120000)",
},
json: {
type: "boolean",
description: "Output JSON (for AI agents / programmatic use)",
default: false,
},
video: {
type: "string",
description:
"Switch to video-download mode: path to a captured project directory whose video-manifest.json should be read. Pair with --index, --video-url, or --list.",
},
index: {
type: "string",
description: "(--video mode) Manifest entry index to download (0-based)",
},
"video-url": {
type: "string",
description: "(--video mode) Exact video URL to download (must match a manifest entry)",
},
list: {
type: "boolean",
description: "(--video mode) List manifest entries and exit",
default: false,
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
if (args.video) {
const { runVideoMode } = await import("./capture/video.js");
await runVideoMode({
project: args.video as string,
index: (args.index as string | undefined) ?? null,
url: (args["video-url"] as string | undefined) ?? null,
list: args.list as boolean,
});
return;
}
const url = args.url as string | undefined;
if (!url) {
console.error(
"Missing URL. Pass a website URL, or use --video <project> for video download.",
);
process.exit(1);
}
try {
new URL(url);
} catch {
console.error(`Invalid URL: ${url}`);
process.exit(1);
}
const isDefaultOutput = !args.output;
let outputName = (args.output as string | undefined) ?? "capture";
let outputDir = resolve(outputName);
if (isDefaultOutput) {
const { existsSync } = await import("node:fs");
// Auto-suffix when ./capture/ is taken: capture-2, capture-3, … so re-runs
// never silently merge into a previous capture's artifacts.
let n = 2;
while (existsSync(outputDir) && n < 100) {
outputName = `capture-${n}`;
outputDir = resolve(outputName);
n++;
}
if (existsSync(outputDir)) {
console.error(`./capture-{2..99} are all taken. Pass -o <name> to pick a directory.`);
process.exit(1);
}
}
const isJson = args.json as boolean;
if (!isJson) {
const { c } = await import("../ui/colors.js");
console.log();
console.log(c.dim("◆") + " Capturing " + c.bold(url));
if (isDefaultOutput && outputName !== "capture") {
console.log(` ${c.dim(`(./capture/ exists; writing to ./${outputName}/)`)}`);
}
console.log();
}
const { captureWebsite } = await import("../capture/index.js");
try {
const result = await captureWebsite(
{
url,
outputDir,
skipAssets: args["skip-assets"] as boolean,
maxScreenshots: args["max-screenshots"]
? parseInt(args["max-screenshots"] as string)
: undefined,
timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
json: isJson,
},
isJson
? undefined
: (stage: string, detail?: string) => {
const stages: Record<string, string> = {
browser: " Launching browser...",
navigate: " Loading page...",
extract: " Extracting HTML & CSS...",
tokens: " Extracting design tokens...",
screenshots: " Capturing screenshots...",
assets: " Downloading assets...",
style: " Generating visual style...",
done: " Done",
};
const label = stages[stage] || ` ${stage}`;
console.log(detail ? `${label} ${detail}` : label);
},
);
if (isJson) {
// Output structured JSON for Claude Code / programmatic use
console.log(
JSON.stringify(
{
ok: result.ok,
projectDir: result.projectDir,
url: result.url,
title: result.title,
screenshots: result.screenshots.length,
assets: result.assets.length,
detectedSections: result.tokens.sections.length,
fonts: result.tokens.fonts.map((f) => f.family),
fontsDetailed: result.tokens.fonts,
animations: result.animationCatalog?.summary,
warnings: result.warnings,
},
null,
2,
),
);
} else {
const { c } = await import("../ui/colors.js");
console.log();
console.log(c.success("◇") + ` Captured ${c.bold(result.title)}${c.dim(outputDir)}`);
console.log();
console.log(` ${c.dim("Screenshots:")} ${result.screenshots.length}`);
console.log(` ${c.dim("Assets:")} ${result.assets.length}`);
console.log(` ${c.dim("Sections:")} ${result.tokens.sections.length}`);
console.log(
` ${c.dim("Fonts:")} ${result.tokens.fonts
.map(function (f) {
return (
f.family +
" (" +
(f.variable && f.weightRange
? f.weightRange[0] + "-" + f.weightRange[1] + " variable"
: f.weights.join(",")) +
")"
);
})
.join(", ")}`,
);
if (result.warnings.length > 0) {
console.log();
for (const w of result.warnings) {
console.log(` ${c.warn("⚠")} ${w}`);
}
}
console.log();
}
} catch (err) {
const errMsg = normalizeErrorMessage(err);
// Write BLOCKED.md so the user/agent knows the capture failed
try {
const { mkdirSync, writeFileSync } = await import("node:fs");
mkdirSync(outputDir, { recursive: true });
const isTimeout = /timeout|timed out/i.test(errMsg);
const reason = isTimeout
? "Page navigation timed out — the site may be blocking headless browsers or requires authentication."
: `Capture failed: ${errMsg}`;
writeFileSync(
`${outputDir}/BLOCKED.md`,
`# Capture Failed\n\n${reason}\n\nURL: ${url}\n\n## What to try\n\n- Re-run with a longer timeout: \`--timeout 60000\`\n- The site may block headless browsers (anti-bot protection)\n- Try capturing a different page on the same domain\n`,
"utf-8",
);
} catch {
/* best-effort */
}
if (isJson) {
console.log(JSON.stringify({ ok: false, error: errMsg }));
} else {
console.error(`\n ✗ Capture failed: ${errMsg}\n`);
}
process.exit(1);
}
},
});