mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
997 lines
33 KiB
TypeScript
997 lines
33 KiB
TypeScript
import { defineCommand } from "citty";
|
|
import type { Example } from "./_examples.js";
|
|
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
import type { Readable } from "node:stream";
|
|
|
|
export const examples: Example[] = [
|
|
["Preview the current project", "hyperframes preview"],
|
|
["Print the current Studio selection as JSON", "hyperframes preview --selection --json"],
|
|
["Print current Studio context as JSON", "hyperframes preview --context --json"],
|
|
["Preview a specific project directory", "hyperframes preview ./my-video"],
|
|
["Use a custom port", "hyperframes preview --port 8080"],
|
|
["Force a new server even if one is already running", "hyperframes preview --force-new"],
|
|
["Start without opening the browser", "hyperframes preview --no-open"],
|
|
["Open with a specific browser", "hyperframes preview --browser-path /usr/bin/chromium"],
|
|
[
|
|
"Open with CDP enabled (requires browser path + isolated profile)",
|
|
"hyperframes preview --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222",
|
|
],
|
|
["List all active preview servers", "hyperframes preview --list"],
|
|
["Kill all active preview servers", "hyperframes preview --kill-all"],
|
|
];
|
|
import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
|
|
import { resolve, dirname, basename, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createRequire } from "node:module";
|
|
import * as clack from "@clack/prompts";
|
|
import { c } from "../ui/colors.js";
|
|
import { isDevMode } from "../utils/env.js";
|
|
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
|
|
import { buildNpxCommand } from "../utils/npxCommand.js";
|
|
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
|
|
import {
|
|
openBrowser,
|
|
parseRemoteDebuggingPort,
|
|
validateRemoteDebuggingPortDeps,
|
|
} from "../utils/openBrowser.js";
|
|
import { lintProject } from "../utils/lintProject.js";
|
|
import { formatLintFindings } from "../utils/lintFormat.js";
|
|
import {
|
|
findPortAndServe,
|
|
scanActiveServers,
|
|
killActiveServers,
|
|
type FindPortResult,
|
|
} from "../server/portUtils.js";
|
|
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
|
|
import { resolveProject } from "../utils/project.js";
|
|
|
|
interface BrowserLaunchOptions {
|
|
noOpen?: boolean;
|
|
browserPath?: string;
|
|
userDataDir?: string;
|
|
remoteDebuggingPort?: number;
|
|
browserNoGpu?: boolean;
|
|
}
|
|
|
|
interface StudioLaunchOptions extends BrowserLaunchOptions {
|
|
projectName?: string;
|
|
}
|
|
|
|
interface EmbeddedStudioOptions extends StudioLaunchOptions {
|
|
forceNew?: boolean;
|
|
}
|
|
|
|
type StudioChildProcess = ChildProcessByStdio<null, Readable, Readable>;
|
|
type ContextField = "server" | "selection" | "lint" | "capabilities";
|
|
type CompactSelectionPayload = Pick<
|
|
StudioSelectionSnapshot,
|
|
| "schemaVersion"
|
|
| "projectId"
|
|
| "compositionPath"
|
|
| "sourceFile"
|
|
| "currentTime"
|
|
| "target"
|
|
| "label"
|
|
| "tagName"
|
|
| "boundingBox"
|
|
| "textContent"
|
|
| "thumbnailUrl"
|
|
>;
|
|
|
|
const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"];
|
|
|
|
export default defineCommand({
|
|
meta: { name: "preview", description: "Start the studio for previewing compositions" },
|
|
args: {
|
|
dir: { type: "positional", description: "Project directory", required: false },
|
|
port: { type: "string", description: "Port to run the preview server on", default: "3002" },
|
|
"force-new": {
|
|
type: "boolean",
|
|
description: "Start a new server even if one is already running for this project",
|
|
default: false,
|
|
},
|
|
list: {
|
|
type: "boolean",
|
|
description: "List all active preview servers and exit",
|
|
default: false,
|
|
},
|
|
"kill-all": {
|
|
type: "boolean",
|
|
description: "Kill all active preview servers and exit",
|
|
default: false,
|
|
},
|
|
open: {
|
|
type: "boolean",
|
|
default: true,
|
|
description: "Open browser automatically",
|
|
},
|
|
selection: {
|
|
type: "boolean",
|
|
description: "Print the current element selected in a running Studio preview and exit",
|
|
default: false,
|
|
},
|
|
json: {
|
|
type: "boolean",
|
|
description: "Output preview selection/context as JSON (only with --selection or --context)",
|
|
default: false,
|
|
},
|
|
context: {
|
|
type: "boolean",
|
|
description:
|
|
"Print the current agent-readable context from a running Studio preview and exit",
|
|
default: false,
|
|
},
|
|
"context-fields": {
|
|
type: "string",
|
|
description:
|
|
"Comma-separated context fields to include: server,selection,lint,capabilities (only with --context)",
|
|
},
|
|
"context-detail": {
|
|
type: "string",
|
|
description: "Context payload detail: compact or full (only with --context)",
|
|
default: "compact",
|
|
},
|
|
"browser-path": {
|
|
type: "string",
|
|
description: "Path to the browser executable to open",
|
|
},
|
|
"user-data-dir": {
|
|
type: "string",
|
|
description: "Chromium-compatible user data directory (requires --browser-path)",
|
|
},
|
|
"remote-debugging-port": {
|
|
type: "string",
|
|
description: "Chromium remote debugging port (requires --browser-path and --user-data-dir)",
|
|
},
|
|
"browser-no-gpu": {
|
|
type: "boolean",
|
|
default: false,
|
|
description:
|
|
"Launch the opened browser with --disable-gpu (requires --browser-path). For hosts where hardware acceleration crashes the graphics driver (e.g. NVIDIA Xid resets); with the system default browser use --no-open instead.",
|
|
},
|
|
},
|
|
async run({ args }) {
|
|
const startPort = parseInt(args.port ?? "3002", 10);
|
|
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
|
|
|
|
// --list: scan and display active servers
|
|
if (args.list) {
|
|
const servers = await scanActiveServers(startPort);
|
|
if (servers.length === 0) {
|
|
console.log("\n No active preview servers found.\n");
|
|
return;
|
|
}
|
|
console.log(`\n ${c.bold("Active preview servers:")}\n`);
|
|
for (const s of servers) {
|
|
const pidStr = s.pid ? c.dim(` (PID ${s.pid})`) : "";
|
|
console.log(
|
|
` ${c.accent(`Port ${s.port}`)} ${s.projectName} ${c.dim(s.projectDir)}${pidStr}`,
|
|
);
|
|
}
|
|
console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`);
|
|
return;
|
|
}
|
|
|
|
// --kill-all: kill all active servers
|
|
if (args["kill-all"]) {
|
|
const servers = await scanActiveServers(startPort);
|
|
if (servers.length === 0) {
|
|
console.log("\n No active preview servers to kill.\n");
|
|
return;
|
|
}
|
|
const killed = await killActiveServers(startPort);
|
|
console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`);
|
|
return;
|
|
}
|
|
|
|
if (args.context) {
|
|
const project = resolveProject(args.dir);
|
|
return printCurrentContext(project.dir, startPort, {
|
|
json: Boolean(args.json),
|
|
fields: args["context-fields"] as string | undefined,
|
|
detail: args["context-detail"] as string | undefined,
|
|
...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }),
|
|
});
|
|
}
|
|
|
|
if (args.selection) {
|
|
const project = resolveProject(args.dir);
|
|
return printCurrentSelection(
|
|
project.dir,
|
|
startPort,
|
|
Boolean(args.json),
|
|
preferredContextPort,
|
|
);
|
|
}
|
|
|
|
// Kill orphaned chrome-headless-shell processes from previous crashed sessions.
|
|
const orphansKilled = killOrphanedProcesses();
|
|
if (orphansKilled > 0) {
|
|
console.log(
|
|
` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`,
|
|
);
|
|
}
|
|
|
|
const rawArg = args.dir;
|
|
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
|
|
const project = resolveProject(rawArg);
|
|
const dir = project.dir;
|
|
const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name;
|
|
|
|
// Lint before starting — surface issues for the agent to fix.
|
|
const lintResult = await lintProject(dir);
|
|
if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
|
|
console.log();
|
|
for (const line of formatLintFindings(lintResult)) console.log(line);
|
|
console.log();
|
|
}
|
|
|
|
// Validation: --user-data-dir requires --browser-path
|
|
if (args["user-data-dir"] && !args["browser-path"]) {
|
|
clack.log.error("--user-data-dir requires --browser-path");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// Validation: --remote-debugging-port deps
|
|
const depsError = validateRemoteDebuggingPortDeps({
|
|
browserPath: args["browser-path"] as string | undefined,
|
|
userDataDir: args["user-data-dir"] as string | undefined,
|
|
remoteDebuggingPort: args["remote-debugging-port"] as string | undefined,
|
|
});
|
|
if (depsError) {
|
|
clack.log.error(depsError);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const noOpen = !args.open;
|
|
const browserPath = args["browser-path"] as string | undefined;
|
|
const browserNoGpu = !!args["browser-no-gpu"];
|
|
if (browserNoGpu && !browserPath) {
|
|
clack.log.error(
|
|
"--browser-no-gpu requires --browser-path (the system default browser cannot receive Chromium flags — use --no-open on GPU-unstable hosts)",
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const userDataDir = args["user-data-dir"] as string | undefined;
|
|
let remoteDebuggingPort: number | undefined;
|
|
try {
|
|
remoteDebuggingPort = parseRemoteDebuggingPort(
|
|
args["remote-debugging-port"] as string | undefined,
|
|
);
|
|
} catch (err) {
|
|
clack.log.error((err as Error).message);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (isDevMode()) {
|
|
return runDevMode(dir, {
|
|
projectName,
|
|
noOpen,
|
|
browserPath,
|
|
userDataDir,
|
|
remoteDebuggingPort,
|
|
browserNoGpu,
|
|
});
|
|
}
|
|
|
|
// If @hyperframes/studio is installed locally, use Vite for full HMR
|
|
if (hasLocalStudio(dir)) {
|
|
return runLocalStudioMode(dir, {
|
|
projectName,
|
|
noOpen,
|
|
browserPath,
|
|
userDataDir,
|
|
remoteDebuggingPort,
|
|
browserNoGpu,
|
|
});
|
|
}
|
|
|
|
const forceNew = !!args["force-new"];
|
|
return runEmbeddedMode(dir, startPort, {
|
|
projectName,
|
|
forceNew,
|
|
noOpen,
|
|
browserPath,
|
|
userDataDir,
|
|
remoteDebuggingPort,
|
|
browserNoGpu,
|
|
});
|
|
},
|
|
});
|
|
|
|
// `host` is the loopback the server actually bound (Vite binds `[::1]`, embedded
|
|
// binds `127.0.0.1`); default to IPv4 for the embedded/legacy callers.
|
|
function previewBaseUrl(port: number, host = "127.0.0.1"): string {
|
|
return `http://${host}:${port}`;
|
|
}
|
|
|
|
function absolutePreviewUrl(port: number, path: string, host = "127.0.0.1"): string {
|
|
if (/^https?:\/\//.test(path)) return path;
|
|
return `${previewBaseUrl(port, host)}${path.startsWith("/") ? path : `/${path}`}`;
|
|
}
|
|
|
|
function hasExplicitPreviewPort(argv: string[]): boolean {
|
|
return argv.some((arg) => arg === "--port" || arg.startsWith("--port="));
|
|
}
|
|
|
|
function printSelectionFailure(code: string, message: string, json: boolean): void {
|
|
if (json) {
|
|
console.log(JSON.stringify({ ok: false, error: { code, message } }, null, 2));
|
|
} else {
|
|
clack.log.error(message);
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function previewServerPayload(server: {
|
|
port: number;
|
|
host?: string;
|
|
projectName: string;
|
|
projectDir: string;
|
|
}): {
|
|
port: number;
|
|
projectName: string;
|
|
projectDir: string;
|
|
url: string;
|
|
} {
|
|
return {
|
|
port: server.port,
|
|
projectName: server.projectName,
|
|
projectDir: server.projectDir,
|
|
url: previewBaseUrl(server.port, server.host),
|
|
};
|
|
}
|
|
|
|
function parseContextFields(value: string | undefined): ContextField[] {
|
|
if (value === undefined) return DEFAULT_CONTEXT_FIELDS;
|
|
if (!value.trim()) throw new Error("--context-fields cannot be empty");
|
|
const allowed = new Set<ContextField>(DEFAULT_CONTEXT_FIELDS);
|
|
const fields = value
|
|
.split(",")
|
|
.map((field) => field.trim())
|
|
.filter(Boolean);
|
|
const invalid = fields.filter((field) => !allowed.has(field as ContextField));
|
|
if (invalid.length > 0) {
|
|
throw new Error(
|
|
`Unknown context field${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
|
|
);
|
|
}
|
|
return [...new Set(fields)] as ContextField[];
|
|
}
|
|
|
|
function contextIncludes(fields: ContextField[], field: ContextField): boolean {
|
|
return fields.includes(field);
|
|
}
|
|
|
|
function addContextError(
|
|
payload: Record<string, unknown>,
|
|
field: ContextField,
|
|
error: { code: string; message: string },
|
|
): void {
|
|
payload.errors = {
|
|
...((payload.errors as Record<string, unknown> | undefined) ?? {}),
|
|
[field]: error,
|
|
};
|
|
}
|
|
|
|
async function printCurrentSelection(
|
|
projectDir: string,
|
|
startPort: number,
|
|
json: boolean,
|
|
preferredPort?: number,
|
|
): Promise<void> {
|
|
const {
|
|
AmbiguousPreviewServerError,
|
|
PreviewServerPortMismatchError,
|
|
fetchStudioSelection,
|
|
findPreviewServerForProject,
|
|
} = await import("../utils/studioSelectionClient.js");
|
|
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
|
|
try {
|
|
server = await findPreviewServerForProject(
|
|
projectDir,
|
|
startPort,
|
|
undefined,
|
|
undefined,
|
|
preferredPort === undefined ? undefined : { preferredPort },
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof AmbiguousPreviewServerError) {
|
|
printSelectionFailure("ambiguous-preview-server", err.message, json);
|
|
return;
|
|
}
|
|
if (err instanceof PreviewServerPortMismatchError) {
|
|
printSelectionFailure("preview-port-mismatch", err.message, json);
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
if (!server) {
|
|
printSelectionFailure(
|
|
"preview-not-running",
|
|
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
|
|
json,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let response: Awaited<ReturnType<typeof fetchStudioSelection>>;
|
|
try {
|
|
response = await fetchStudioSelection(server);
|
|
} catch (err) {
|
|
printSelectionFailure("selection-unavailable", errorMessage(err), json);
|
|
return;
|
|
}
|
|
|
|
if (!response.selection) {
|
|
printSelectionFailure(
|
|
"no-selection",
|
|
"Studio is running, but no element is selected. Select an element in Studio and rerun this command.",
|
|
json,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const selection = {
|
|
...response.selection,
|
|
thumbnailUrl: absolutePreviewUrl(server.port, response.selection.thumbnailUrl, server.host),
|
|
};
|
|
|
|
if (json) {
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
ok: true,
|
|
server: previewServerPayload(server),
|
|
selection,
|
|
updatedAt: response.updatedAt,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`${c.success("◇")} ${c.accent(selection.label)} selected in Studio`);
|
|
console.log(` ${c.dim("Source")} ${selection.sourceFile}`);
|
|
console.log(
|
|
` ${c.dim("Target")} ${selection.target.hfId ?? selection.target.id ?? selection.target.selector ?? "(none)"}`,
|
|
);
|
|
console.log(` ${c.dim("Time")} ${selection.currentTime.toFixed(3)}s`);
|
|
console.log(` ${c.dim("Thumbnail")} ${selection.thumbnailUrl}`);
|
|
console.log();
|
|
console.log(c.dim("Use --json for the full agent-readable selection payload."));
|
|
}
|
|
|
|
function countLintFindings(findings: Array<{ severity: string }>): {
|
|
errors: number;
|
|
warnings: number;
|
|
} {
|
|
return {
|
|
errors: findings.filter((finding) => finding.severity === "error").length,
|
|
warnings: findings.filter((finding) => finding.severity === "warning").length,
|
|
};
|
|
}
|
|
|
|
async function printCurrentContext(
|
|
projectDir: string,
|
|
startPort: number,
|
|
options: { json: boolean; fields?: string; detail?: string; preferredPort?: number },
|
|
): Promise<void> {
|
|
let fields: ContextField[];
|
|
try {
|
|
fields = parseContextFields(options.fields);
|
|
} catch (err) {
|
|
printSelectionFailure("invalid-context-fields", errorMessage(err), options.json);
|
|
return;
|
|
}
|
|
const fullDetail = options.detail === "full";
|
|
if (options.detail !== undefined && !["compact", "full"].includes(options.detail)) {
|
|
printSelectionFailure(
|
|
"invalid-context-detail",
|
|
"--context-detail must be compact or full",
|
|
options.json,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const {
|
|
AmbiguousPreviewServerError,
|
|
PreviewServerPortMismatchError,
|
|
fetchStudioLint,
|
|
fetchStudioSelection,
|
|
findPreviewServerForProject,
|
|
} = await import("../utils/studioSelectionClient.js");
|
|
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
|
|
try {
|
|
server = await findPreviewServerForProject(
|
|
projectDir,
|
|
startPort,
|
|
undefined,
|
|
undefined,
|
|
options.preferredPort === undefined ? undefined : { preferredPort: options.preferredPort },
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof AmbiguousPreviewServerError) {
|
|
printSelectionFailure("ambiguous-preview-server", err.message, options.json);
|
|
return;
|
|
}
|
|
if (err instanceof PreviewServerPortMismatchError) {
|
|
printSelectionFailure("preview-port-mismatch", err.message, options.json);
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
if (!server) {
|
|
printSelectionFailure(
|
|
"preview-not-running",
|
|
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
|
|
options.json,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const wantsSelection = contextIncludes(fields, "selection");
|
|
const wantsLint = contextIncludes(fields, "lint");
|
|
const [selectionResult, lintResult] = await Promise.allSettled([
|
|
wantsSelection ? fetchStudioSelection(server) : Promise.resolve(null),
|
|
wantsLint ? fetchStudioLint(server) : Promise.resolve(null),
|
|
]);
|
|
|
|
const selection =
|
|
selectionResult.status === "fulfilled" && selectionResult.value?.selection
|
|
? {
|
|
ok: true as const,
|
|
value: fullDetail
|
|
? {
|
|
...selectionResult.value.selection,
|
|
thumbnailUrl: absolutePreviewUrl(
|
|
server.port,
|
|
selectionResult.value.selection.thumbnailUrl,
|
|
),
|
|
}
|
|
: compactSelectionPayload({
|
|
...selectionResult.value.selection,
|
|
thumbnailUrl: absolutePreviewUrl(
|
|
server.port,
|
|
selectionResult.value.selection.thumbnailUrl,
|
|
),
|
|
}),
|
|
updatedAt: selectionResult.value.updatedAt,
|
|
}
|
|
: {
|
|
ok: false as const,
|
|
error:
|
|
selectionResult.status === "rejected"
|
|
? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) }
|
|
: {
|
|
code: "no-selection",
|
|
message: "Studio is running, but no element is selected.",
|
|
},
|
|
};
|
|
|
|
const lint =
|
|
lintResult.status === "fulfilled" && lintResult.value
|
|
? {
|
|
ok: true as const,
|
|
summary: countLintFindings(lintResult.value.findings),
|
|
findings: lintResult.value.findings,
|
|
}
|
|
: {
|
|
ok: false as const,
|
|
error:
|
|
lintResult.status === "rejected"
|
|
? { code: "lint-unavailable", message: errorMessage(lintResult.reason) }
|
|
: { code: "lint-not-requested", message: "Lint was not requested." },
|
|
};
|
|
|
|
const payload: Record<string, unknown> = { ok: true };
|
|
if (contextIncludes(fields, "server")) payload.server = previewServerPayload(server);
|
|
if (contextIncludes(fields, "selection")) {
|
|
payload.selection = selection.ok ? selection.value : null;
|
|
payload.selectionUpdatedAt = selection.ok ? selection.updatedAt : null;
|
|
if (!selection.ok) addContextError(payload, "selection", selection.error);
|
|
}
|
|
if (contextIncludes(fields, "lint")) payload.lint = lint;
|
|
if (contextIncludes(fields, "capabilities")) {
|
|
payload.capabilities = {
|
|
selection: true,
|
|
lint: true,
|
|
frame: false,
|
|
visibleElements: false,
|
|
lastAction: false,
|
|
};
|
|
}
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(payload, null, 2));
|
|
return;
|
|
}
|
|
|
|
console.log(`${c.success("◇")} Studio context`);
|
|
if (contextIncludes(fields, "server")) {
|
|
console.log(` ${c.dim("Project")} ${server.projectName}`);
|
|
console.log(` ${c.dim("Studio")} ${previewBaseUrl(server.port, server.host)}`);
|
|
}
|
|
if (contextIncludes(fields, "selection")) {
|
|
if (selection.ok) {
|
|
console.log(` ${c.dim("Selection")} ${selection.value.label}`);
|
|
} else {
|
|
console.log(` ${c.dim("Selection")} ${selection.error.message}`);
|
|
}
|
|
}
|
|
if (contextIncludes(fields, "lint")) {
|
|
if (lint.ok) {
|
|
console.log(
|
|
` ${c.dim("Lint")} ${lint.summary.errors} error(s), ${lint.summary.warnings} warning(s)`,
|
|
);
|
|
} else {
|
|
console.log(` ${c.dim("Lint")} ${lint.error.message}`);
|
|
}
|
|
}
|
|
console.log();
|
|
console.log(c.dim("Use --json for the full agent-readable context payload."));
|
|
}
|
|
|
|
function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSelectionPayload {
|
|
return {
|
|
schemaVersion: selection.schemaVersion,
|
|
projectId: selection.projectId,
|
|
compositionPath: selection.compositionPath,
|
|
sourceFile: selection.sourceFile,
|
|
currentTime: selection.currentTime,
|
|
target: selection.target,
|
|
label: selection.label,
|
|
tagName: selection.tagName,
|
|
boundingBox: selection.boundingBox,
|
|
textContent: selection.textContent,
|
|
thumbnailUrl: selection.thumbnailUrl,
|
|
};
|
|
}
|
|
|
|
function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void {
|
|
if (options?.noOpen) return;
|
|
openBrowser(`${url}#project/${projectName}`, {
|
|
browserPath: options?.browserPath,
|
|
userDataDir: options?.userDataDir,
|
|
remoteDebuggingPort: options?.remoteDebuggingPort,
|
|
disableGpu: options?.browserNoGpu,
|
|
});
|
|
}
|
|
|
|
function printStudioSummary(
|
|
projectName: string,
|
|
url: string,
|
|
opts: { details?: string[]; footer?: string } = {},
|
|
): void {
|
|
console.log();
|
|
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
|
|
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
|
|
console.log();
|
|
for (const detail of opts.details ?? []) {
|
|
console.log(` ${c.dim(detail)}`);
|
|
}
|
|
if (opts.details?.length && opts.footer) console.log();
|
|
if (opts.footer) console.log(` ${c.dim(opts.footer)}`);
|
|
console.log();
|
|
}
|
|
|
|
function linkProjectIntoStudioData(
|
|
dir: string,
|
|
projectsDir: string,
|
|
projectName: string,
|
|
): { symlinkPath: string; createdSymlink: boolean } {
|
|
const symlinkPath = join(projectsDir, projectName);
|
|
mkdirSync(projectsDir, { recursive: true });
|
|
|
|
let createdSymlink = false;
|
|
if (dir !== symlinkPath) {
|
|
if (existsSync(symlinkPath)) {
|
|
try {
|
|
const stat = lstatSync(symlinkPath);
|
|
if (stat.isSymbolicLink() && resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
|
|
unlinkSync(symlinkPath);
|
|
}
|
|
} catch {
|
|
// Real directories or unreadable paths are left untouched.
|
|
}
|
|
}
|
|
if (!existsSync(symlinkPath)) {
|
|
// Windows: "dir" symlinks need Developer Mode or elevation (EPERM otherwise);
|
|
// NTFS junctions are unprivileged and keep the live write-back the studio needs.
|
|
symlinkSync(dir, symlinkPath, process.platform === "win32" ? "junction" : "dir");
|
|
createdSymlink = true;
|
|
}
|
|
}
|
|
|
|
return { symlinkPath, createdSymlink };
|
|
}
|
|
|
|
function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void {
|
|
if (!createdSymlink) return;
|
|
process.on("exit", () => {
|
|
try {
|
|
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
function registerChildTreeShutdown(child: StudioChildProcess): void {
|
|
const shutdown = (): void => {
|
|
if (child.pid) killProcessTree(child.pid);
|
|
};
|
|
process.once("SIGINT", shutdown);
|
|
process.once("SIGTERM", shutdown);
|
|
}
|
|
|
|
function waitForChildClose(child: StudioChildProcess): Promise<void> {
|
|
return new Promise<void>((resolveClose) => {
|
|
child.on("close", () => resolveClose());
|
|
});
|
|
}
|
|
|
|
function attachStudioReadyHandler(
|
|
child: StudioChildProcess,
|
|
spinner: ReturnType<typeof clack.spinner>,
|
|
projectName: string,
|
|
options?: BrowserLaunchOptions,
|
|
): void {
|
|
let detected = false;
|
|
|
|
function handleOutput(data: Buffer): void {
|
|
const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1];
|
|
if (!url || detected) return;
|
|
|
|
detected = true;
|
|
spinner.stop(c.success("Studio running"));
|
|
printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" });
|
|
openStudioBrowser(url, projectName, options);
|
|
child.stdout.removeListener("data", handleOutput);
|
|
child.stderr.removeListener("data", handleOutput);
|
|
}
|
|
|
|
child.stdout.on("data", handleOutput);
|
|
child.stderr.on("data", handleOutput);
|
|
child.on("error", (err) => {
|
|
spinner.stop(c.error("Failed to start studio"));
|
|
console.error(c.dim(err.message));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dev mode: spawn the studio dev server from the monorepo.
|
|
*/
|
|
async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
|
|
// Find monorepo root by navigating from packages/cli/src/commands/
|
|
const thisFile = fileURLToPath(import.meta.url);
|
|
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
|
|
|
|
// Symlink project into the studio's data directory
|
|
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
|
|
const pName = options?.projectName ?? basename(dir);
|
|
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
|
|
|
|
clack.intro(c.bold("hyperframes preview"));
|
|
|
|
const s = clack.spinner();
|
|
s.start("Starting studio...");
|
|
|
|
// Run the new consolidated studio (single Vite dev server with API plugin)
|
|
const studioPkgDir = join(repoRoot, "packages", "studio");
|
|
const child = spawn("bun", ["run", "dev"], {
|
|
cwd: studioPkgDir,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
attachStudioReadyHandler(child, s, pName, options);
|
|
removeSymlinkOnExit(createdSymlink, symlinkPath);
|
|
|
|
// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
|
|
// SIGINT to the foreground process group (covers the common case), but
|
|
// `kill <pid>` only targets this process — the child tree (Vite + Chrome)
|
|
// would survive without explicit cleanup.
|
|
// On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
|
|
// propagates via the console process group instead.
|
|
registerChildTreeShutdown(child);
|
|
return waitForChildClose(child);
|
|
}
|
|
|
|
/**
|
|
* Check if @hyperframes/studio is installed locally in the project's node_modules.
|
|
*/
|
|
function hasLocalStudio(dir: string): boolean {
|
|
try {
|
|
const req = createRequire(join(dir, "package.json"));
|
|
req.resolve("@hyperframes/studio/package.json");
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Local studio mode: spawn Vite using a locally installed @hyperframes/studio.
|
|
* Provides full Vite HMR and the complete studio experience.
|
|
*/
|
|
async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
|
|
const req = createRequire(join(dir, "package.json"));
|
|
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
|
|
const pName = options?.projectName ?? basename(dir);
|
|
|
|
// Symlink project into studio's data directory
|
|
const projectsDir = join(studioPkgPath, "data", "projects");
|
|
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
|
|
|
|
clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)"));
|
|
const s = clack.spinner();
|
|
s.start("Starting studio...");
|
|
|
|
const viteCommand = buildNpxCommand(["vite"]);
|
|
const child = spawn(viteCommand.command, viteCommand.args, {
|
|
cwd: studioPkgPath,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
attachStudioReadyHandler(child, s, pName, options);
|
|
removeSymlinkOnExit(createdSymlink, symlinkPath);
|
|
|
|
// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
|
|
registerChildTreeShutdown(child);
|
|
return waitForChildClose(child);
|
|
}
|
|
|
|
/**
|
|
* Embedded mode: serve the pre-built studio SPA with a standalone Hono server.
|
|
* Works without any additional dependencies — the studio is bundled in dist/.
|
|
*
|
|
* If an existing HyperFrames server for the same project is detected,
|
|
* reuses it instead of starting a new one (unless --force-new is set).
|
|
*/
|
|
async function runEmbeddedMode(
|
|
dir: string,
|
|
startPort: number,
|
|
options?: EmbeddedStudioOptions,
|
|
): Promise<void> {
|
|
const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } =
|
|
await import("../server/studioServer.js");
|
|
|
|
const pName = options?.projectName ?? basename(dir);
|
|
const studioBundle = resolveStudioBundle();
|
|
|
|
clack.intro(c.bold("hyperframes preview"));
|
|
const s = clack.spinner();
|
|
s.start("Starting studio...");
|
|
|
|
if (!studioBundle.available) {
|
|
s.stop(c.error("Studio build missing"));
|
|
console.error();
|
|
console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`);
|
|
for (const checkedPath of studioBundle.checkedPaths) {
|
|
console.error(` ${c.dim("-")} ${checkedPath}`);
|
|
}
|
|
console.error();
|
|
console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("bun run build")}`);
|
|
console.error();
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const { app } = createStudioServer({ projectDir: dir, projectName: pName });
|
|
const serverBuildSignature = await loadPreviewServerBuildSignature();
|
|
|
|
let result: FindPortResult;
|
|
try {
|
|
result = await findPortAndServe(
|
|
app.fetch,
|
|
startPort,
|
|
dir,
|
|
!!options?.forceNew,
|
|
serverBuildSignature,
|
|
);
|
|
} catch (err: unknown) {
|
|
s.stop(c.error("Failed to start studio"));
|
|
console.error();
|
|
console.error(` ${(err as Error).message}`);
|
|
console.error();
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (result.type === "already-running") {
|
|
const url = `http://localhost:${result.port}`;
|
|
s.stop(c.success("Already running"));
|
|
printStudioSummary(pName, url, {
|
|
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
|
|
});
|
|
openStudioBrowser(url, pName, options);
|
|
return;
|
|
}
|
|
|
|
const url = `http://localhost:${result.port}`;
|
|
s.stop(c.success("Studio running"));
|
|
console.log();
|
|
if (result.port !== startPort) {
|
|
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
|
|
console.log();
|
|
}
|
|
printStudioSummary(pName, url, {
|
|
details: [
|
|
"Edit with your AI agent — it has HyperFrames skills installed.",
|
|
"Changes reload automatically in the studio.",
|
|
],
|
|
footer: "Press Ctrl+C to stop",
|
|
});
|
|
openStudioBrowser(url, pName, options);
|
|
|
|
// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
|
|
// HTTP server keeps handles open, so the event loop stays alive after the
|
|
// signal handler fires. Close the server explicitly and resolve the promise
|
|
// so `run()` returns cleanly instead of requiring a second Ctrl+C (or,
|
|
// worse, the user force-killing the terminal).
|
|
//
|
|
// Windows wrinkle: Ctrl+C in some terminals (Git Bash / MSYS) doesn't reach
|
|
// Node as a SIGINT at all — the process just sits there. Run a readline
|
|
// interface on stdin so the keystroke is observed at the TTY layer and
|
|
// re-emit it as SIGINT. No-op on platforms where the signal already arrives.
|
|
let rl: import("node:readline").Interface | undefined;
|
|
if (process.platform === "win32") {
|
|
const readline = await import("node:readline");
|
|
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
rl.on("SIGINT", () => {
|
|
process.emit("SIGINT", "SIGINT");
|
|
});
|
|
}
|
|
|
|
return new Promise<void>((resolveRun) => {
|
|
let shuttingDown = false;
|
|
const shutdown = (): void => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
process.off("SIGINT", shutdown);
|
|
process.off("SIGTERM", shutdown);
|
|
rl?.close();
|
|
console.log();
|
|
console.log(` ${c.dim("Shutting down studio...")}`);
|
|
|
|
// Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to
|
|
// browser.close()), force exit. Armed before awaiting cleanup so it
|
|
// can't be blocked by a stuck drainBrowserPool().
|
|
setTimeout(() => process.exit(0), 3000).unref();
|
|
|
|
// Kill ffmpeg first (sync, fast), then drain browsers (async, slower).
|
|
const cleanup = async () => {
|
|
const { closeThumbnailBrowser } = await import("../server/studioServer.js");
|
|
const { drainBrowserPool, killTrackedProcesses } = await import("@hyperframes/engine");
|
|
killTrackedProcesses();
|
|
await closeThumbnailBrowser().catch(() => {});
|
|
await drainBrowserPool().catch(() => {});
|
|
};
|
|
|
|
cleanup()
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
result.server.close(() => resolveRun());
|
|
});
|
|
};
|
|
process.once("SIGINT", shutdown);
|
|
process.once("SIGTERM", shutdown);
|
|
|
|
// Last-resort cleanup for crash paths (unhandled exceptions/rejections)
|
|
// that bypass the signal handlers. Eagerly resolve the sync killer so
|
|
// the 'exit' handler (which is synchronous) can call it directly.
|
|
import("@hyperframes/engine")
|
|
.then(({ killTrackedProcesses }) => {
|
|
process.once("exit", () => {
|
|
if (!shuttingDown) killTrackedProcesses();
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
}
|