diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6d6ed2fd2..ba2a8ee2d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,19 +1,25 @@ #!/usr/bin/env node -import { defineCommand, runMain } from "citty"; +// ── Fast-path exits ───────────────────────────────────────────────────────── +// Check --version before importing anything heavy. This makes +// `hyperframes --version` near-instant (~10ms vs ~80ms). import { VERSION } from "./version.js"; -import { - showTelemetryNotice, - flush, - flushSync, - shouldTrack, - trackCommand, - incrementCommandCount, -} from "./telemetry/index.js"; -import { checkForUpdate, printUpdateNotice } from "./utils/updateCheck.js"; + +if (process.argv.includes("--version") || process.argv.includes("-V")) { + console.log(VERSION); + process.exit(0); +} + +// ── Lazy imports ──────────────────────────────────────────────────────────── +// Telemetry, update checks, and heavy modules are imported only when needed. +// For --help we skip telemetry entirely. + +import { defineCommand, runMain } from "citty"; + +const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); // --------------------------------------------------------------------------- -// CLI definition +// CLI definition — all commands are lazy-loaded via dynamic import() // --------------------------------------------------------------------------- const subCommands = { @@ -43,42 +49,46 @@ const main = defineCommand({ }); // --------------------------------------------------------------------------- -// Telemetry — detect command from argv, track it, flush on exit +// Telemetry — lazy-loaded, captured references for exit handlers // --------------------------------------------------------------------------- const commandArg = process.argv[2]; -const isHelpOrVersion = - process.argv.includes("--help") || - process.argv.includes("--version") || - process.argv.includes("-h"); const command = commandArg && commandArg in subCommands ? commandArg : "unknown"; +const hasJsonFlag = process.argv.includes("--json"); -if (command !== "telemetry" && command !== "unknown" && !isHelpOrVersion) { - showTelemetryNotice(); - trackCommand(command); - if (shouldTrack()) { - incrementCommandCount(); - } +// Captured references — populated when the lazy imports resolve. +// Used in exit handlers where dynamic import() is unsafe (beforeExit loops, +// exit handler is synchronous-only). +let _flush: (() => Promise) | undefined; +let _flushSync: (() => void) | undefined; +let _printUpdateNotice: (() => void) | undefined; + +if (!isHelp && command !== "telemetry" && command !== "unknown") { + import("./telemetry/index.js").then((mod) => { + _flush = mod.flush; + _flushSync = mod.flushSync; + mod.showTelemetryNotice(); + mod.trackCommand(command); + if (mod.shouldTrack()) mod.incrementCommandCount(); + }); } -// Fire background update check (non-blocking, populates cache for printUpdateNotice) -const hasJsonFlag = process.argv.includes("--json"); -if (!isHelpOrVersion && !hasJsonFlag && command !== "upgrade") { - checkForUpdate().catch(() => {}); +if (!isHelp && !hasJsonFlag && command !== "upgrade") { + import("./utils/updateCheck.js").then((mod) => { + _printUpdateNotice = mod.printUpdateNotice; + mod.checkForUpdate().catch(() => {}); + }); } // Async flush for normal exit (beforeExit fires when the event loop drains) process.on("beforeExit", () => { - flush().catch(() => {}); - // Print update notice after command output (stderr, skipped in CI/non-TTY) - if (!hasJsonFlag) { - printUpdateNotice(); - } + _flush?.().catch(() => {}); + if (!hasJsonFlag) _printUpdateNotice?.(); }); // Sync flush for process.exit() calls (exit event only allows synchronous code) process.on("exit", () => { - flushSync(); + _flushSync?.(); }); runMain(main); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 2b3cf6838..a55aa6f86 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -411,50 +411,6 @@ function scaffoldProject( } } -// --------------------------------------------------------------------------- -// nextStepLoop — "What do you want to do?" loop after scaffolding -// --------------------------------------------------------------------------- - -async function nextStepLoop(destDir: string): Promise { - while (true) { - const next = await clack.select({ - message: "What do you want to do?", - options: [ - { - value: "preview", - label: "Open in studio", - hint: "full editor with timeline", - }, - { value: "render", label: "Render to MP4", hint: "export video now" }, - { value: "done", label: "Done for now" }, - ], - }); - - if (clack.isCancel(next) || next === "done") { - clack.outro(c.success("Happy editing!")); - return; - } - - // Hand off to the selected command — use explicit imports so the - // bundler can resolve them (dynamic import with a variable fails in bundles) - try { - if (next === "preview") { - const previewCmd = await import("./preview.js").then((m) => m.default); - await runCommand(previewCmd, { rawArgs: [destDir] }); - } else if (next === "render") { - const renderCmd = await import("./render.js").then((m) => m.default); - await runCommand(renderCmd, { rawArgs: [destDir] }); - } - } catch { - // Command may throw on Ctrl+C — that's fine, loop back - } - - // Wait a tick so any lingering SIGINT state clears before Clack prompts again - await new Promise((r) => setTimeout(r, 100)); - console.log(); - } -} - // --------------------------------------------------------------------------- // Exported command // --------------------------------------------------------------------------- @@ -546,6 +502,11 @@ Examples: let videoDuration: number | undefined; let sourceFilePath: string | undefined; + if (videoFlag && audioFlag) { + console.error(c.error("Cannot use --video and --audio together")); + process.exit(1); + } + // Handle video if (videoFlag) { const videoPath = resolve(videoFlag); @@ -670,11 +631,10 @@ Examples: } } - // 2. Got a video or audio file? + // 2. Video/audio file handling (only via --video/--audio flags, no interactive prompt) let localVideoName: string | undefined; let sourceFilePath: string | undefined; let videoDuration: number | undefined; - let isAudioOnly = false; if (videoFlag) { const videoPath = resolve(videoFlag); @@ -688,59 +648,20 @@ Examples: const result = await handleVideoFile(videoPath, destDir, true); localVideoName = result.localVideoName; videoDuration = result.meta.durationSeconds; - } else { - const mediaChoice = await clack.select({ - message: "Got a video or audio file?", - options: [ - { value: "video", label: "Video", hint: "MP4, WebM, MOV" }, - { value: "audio", label: "Audio only", hint: "MP3, WAV, M4A" }, - { - value: "no", - label: "No", - hint: "Start with motion graphics or text", - }, - ], - initialValue: "no" as "video" | "audio" | "no", - }); - if (clack.isCancel(mediaChoice)) { + } else if (audioFlag) { + const audioPath = resolve(audioFlag); + if (!existsSync(audioPath)) { + clack.log.error(`File not found: ${audioFlag}`); clack.cancel("Setup cancelled."); - process.exit(0); - } - - if (mediaChoice === "video" || mediaChoice === "audio") { - const pathResult = await clack.text({ - message: `Path to your ${mediaChoice} file (drag and drop or paste)`, - placeholder: mediaChoice === "video" ? "/path/to/video.mp4" : "/path/to/audio.mp3", - validate(val) { - const trimmed = val?.trim(); - if (!trimmed) return "Please enter a file path"; - if (!existsSync(resolve(trimmed))) return "File not found"; - return undefined; - }, - }); - if (clack.isCancel(pathResult)) { - clack.cancel("Setup cancelled."); - process.exit(0); - } - - const filePath = resolve(String(pathResult).trim()); - sourceFilePath = filePath; - mkdirSync(destDir, { recursive: true }); - - if (mediaChoice === "video") { - const result = await handleVideoFile(filePath, destDir, true); - localVideoName = result.localVideoName; - videoDuration = result.meta.durationSeconds; - } else { - // Audio file — copy to project root - isAudioOnly = true; - copyFileSync(filePath, resolve(destDir, basename(filePath))); - clack.log.info(`Audio copied to ${c.accent(basename(filePath))}`); - } + process.exit(1); } + mkdirSync(destDir, { recursive: true }); + sourceFilePath = audioPath; + copyFileSync(audioPath, resolve(destDir, basename(audioPath))); + clack.log.info(`Audio copied to ${c.accent(basename(audioPath))}`); } - // 2b. Transcribe if we have a source file with audio + // 2b. Transcribe if we have a source file with audio (via flags) if (sourceFilePath) { const transcribeChoice = await clack.confirm({ message: "Generate captions from audio?", @@ -794,7 +715,6 @@ Examples: if (templateFlag) { clack.log.warn(`Unknown template "${templateFlag}" — pick from the list below`); } - const defaultTemplate = isAudioOnly ? "warm-grain" : "blank"; const templateResult = await clack.select({ message: "Pick a template", options: TEMPLATES.map((t) => ({ @@ -802,7 +722,7 @@ Examples: label: t.label, hint: t.hint, })), - initialValue: defaultTemplate as TemplateId, + initialValue: "blank" as TemplateId, }); if (clack.isCancel(templateResult)) { clack.cancel("Setup cancelled."); @@ -834,6 +754,13 @@ Examples: `${c.dim(" AI skills are installed — your agent knows how to create and edit compositions.")}`, ); - await nextStepLoop(destDir); + // Auto-launch studio preview + clack.log.info("Opening studio preview..."); + try { + const previewCmd = await import("./preview.js").then((m) => m.default); + await runCommand(previewCmd, { rawArgs: [destDir] }); + } catch { + // Ctrl+C or error — that's fine + } }, }); diff --git a/packages/cli/src/ui/colors.ts b/packages/cli/src/ui/colors.ts index 5796932de..7e1bdc870 100644 --- a/packages/cli/src/ui/colors.ts +++ b/packages/cli/src/ui/colors.ts @@ -7,13 +7,16 @@ function wrap(fn: (s: string) => string): (s: string) => string { return isColorSupported ? fn : (s: string) => s; } +// Brand teal (#3CE6AC) via ANSI 24-bit true color +const teal = (s: string) => `\x1b[38;2;60;230;172m${s}\x1b[39m`; + export const c = { success: wrap(pc.green), error: wrap(pc.red), warn: wrap(pc.yellow), dim: wrap(pc.dim), bold: wrap(pc.bold), - accent: wrap(pc.cyan), + accent: wrap(teal), progress: wrap(pc.magenta), reset: isColorSupported ? pc.reset : (s: string) => s, }; diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 03b62f071..66568fd98 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -9,6 +9,7 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; +import type { IncomingMessage } from "node:http"; import { readFileSync, existsSync, statSync } from "node:fs"; import { join, extname } from "node:path"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; @@ -323,15 +324,29 @@ export function createFileServer(options: FileServerOptions): Promise { - const server = serve({ fetch: app.fetch, port }, (info) => { - const actualPort = info.port; - const url = `http://localhost:${actualPort}`; + // Track open connections so we can force-destroy them on close. + // Without this, server.close() waits for keep-alive connections to + // drain, holding the Node.js event loop open indefinitely. + const connections = new Set(); + // @hono/node-server serve() returns the http.Server directly. + // Register the connection tracker before the listen callback fires + // to avoid missing early connections. + const server = serve({ fetch: app.fetch, port }, (info) => { resolve({ - url, - port: actualPort, - close: () => server.close(), + url: `http://localhost:${info.port}`, + port: info.port, + close: () => { + for (const socket of connections) socket.destroy(); + connections.clear(); + server.close(); + }, }); }); + + server.on("connection", (socket: IncomingMessage["socket"]) => { + connections.add(socket); + socket.on("close", () => connections.delete(socket)); + }); }); }