import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { existsSync, readFileSync } from "node:fs"; export const examples: Example[] = [ ["Play the current project", "hyperframes play"], ["Play a specific project directory", "hyperframes play ./my-video"], ["Use a custom port", "hyperframes play --port 8080"], ["Start without opening the browser", "hyperframes play --no-open"], ["Open with a specific browser", "hyperframes play --browser-path /usr/bin/chromium"], [ "Open with CDP enabled (requires browser path + isolated profile)", "hyperframes play --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222", ], ]; import { resolve } from "node:path"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; import { openBrowser, parseRemoteDebuggingPort, validateRemoteDebuggingPortDeps, } from "../utils/openBrowser.js"; import { resolveRuntimePath, resolvePlayerPath, listenOnFreePort, injectRuntime, assetContentType, } from "../utils/compositionServer.js"; export default defineCommand({ meta: { name: "play", description: "Play a composition in a lightweight browser player" }, args: { dir: { type: "positional", description: "Project directory", required: false }, port: { type: "string", description: "Port to run the player server on", default: "3003" }, open: { type: "boolean", default: true, description: "Open browser automatically", }, "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)", }, }, async run({ args }) { const project = resolveProject(args.dir); const startPort = parseInt(args.port ?? "3003", 10); // 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; } // Parse --remote-debugging-port before any server setup so an invalid value // exits cleanly instead of leaving an orphan listening socket behind. 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; } // Resolve runtime path — same logic as studioServer.ts const runtimePath = resolveRuntimePath(); if (!runtimePath) { clack.log.error("HyperFrames runtime not found. Run `bun run build` first."); process.exitCode = 1; return; } // Resolve player path const playerPath = resolvePlayerPath(); if (!playerPath) { clack.log.error( "@hyperframes/player not found. Run `bun run --cwd packages/player build` first.", ); process.exitCode = 1; return; } const { Hono } = await import("hono"); const { createAdaptorServer } = await import("@hono/node-server"); const { isSafePath } = await import("@hyperframes/core/studio-api"); const app = new Hono(); // Serve the player JS app.get("/player.js", (ctx) => { return ctx.body(readFileSync(playerPath, "utf-8"), 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache", }); }); // Serve the runtime JS app.get("/runtime.js", (ctx) => { return ctx.body(readFileSync(runtimePath, "utf-8"), 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache", }); }); // Serve composition files (HTML + assets) app.get("/composition/*", (ctx) => { const reqPath = ctx.req.path.replace("/composition/", ""); const filePath = resolve(project.dir, reqPath); // Security: don't allow path traversal outside project dir. isSafePath // canonicalizes symlinks and applies a trailing-separator guard, so neither // an in-project symlink to an external target nor a sibling dir whose name // shares the project-dir prefix (e.g. `