mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
feat(cli): simplify init flow (#163)
## Summary - **Streamline init from 7 steps to 3**: name → template → auto-opens studio preview - **Remove** interactive "Got a video or audio file?" prompt (`--video`/`--audio` flags still work) - **Remove** "What do you want to do?" post-scaffold menu (auto-launches preview) - **Fast startup**: `--version` exits in ~10ms (lazy-load telemetry + update checker) - **Brand teal**: CLI accent color is now `#3CE6AC` instead of generic cyan ### Before ``` name → overwrite? → media file? → [drag file] → transcribe? → pick template (9) → what next? ``` ### After ``` name → pick template → auto-opens studio ``` ## Test plan - [x] `hyperframes init my-project` — should prompt name + template, then auto-open preview - [x] `hyperframes --version` — should print version instantly (~10ms) - [x] `hyperframes init --video path.mp4 my-project` — video flag still works - [x] `hyperframes init --template blank my-project` — template flag still works - [x] Verify teal accent color in terminal output
This commit is contained in:
+42
-32
@@ -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<void>) | 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);
|
||||
|
||||
@@ -411,50 +411,6 @@ function scaffoldProject(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nextStepLoop — "What do you want to do?" loop after scaffolding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function nextStepLoop(destDir: string): Promise<void> {
|
||||
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
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<FileServer
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
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<IncomingMessage["socket"]>();
|
||||
|
||||
// @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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user