mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* 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).
243 lines
8.1 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
});
|