Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 57 additions & 13 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,70 @@

import { defineCommand, runMain } from "citty";
import { VERSION } from "./version.js";
import {
showTelemetryNotice,
flush,
flushSync,
shouldTrack,
trackCommand,
incrementCommandCount,
} from "./telemetry/index.js";

// ---------------------------------------------------------------------------
// CLI definition
// ---------------------------------------------------------------------------

const subCommands = {
init: () => import("./commands/init.js").then((m) => m.default),
dev: () => import("./commands/dev.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
compositions: () => import("./commands/compositions.js").then((m) => m.default),
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default),
docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
telemetry: () => import("./commands/telemetry.js").then((m) => m.default),
};

const main = defineCommand({
meta: {
name: "hyperframes",
version: VERSION,
description: "Create and render HTML video compositions",
},
subCommands: {
init: () => import("./commands/init.js").then((m) => m.default),
dev: () => import("./commands/dev.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
compositions: () => import("./commands/compositions.js").then((m) => m.default),
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default),
docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
},
subCommands,
});

// ---------------------------------------------------------------------------
// Telemetry — detect command from argv, track it, flush on exit
// ---------------------------------------------------------------------------

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";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: trackCommand fires before the command actually runs. If the command fails to import (e.g., missing dependency), we still track it as invoked. This inflates usage numbers. Consider moving the track call to after the command completes, or track it as "command_started" and add a separate "command_completed" event.


if (command !== "telemetry" && command !== "unknown" && !isHelpOrVersion) {
showTelemetryNotice();
trackCommand(command);
if (shouldTrack()) {
incrementCommandCount();
}
}

// Async flush for normal exit (beforeExit fires when the event loop drains)
process.on("beforeExit", () => {
flush().catch(() => {});
});

// Sync flush for process.exit() calls (exit event only allows synchronous code)
process.on("exit", () => {
flushSync();
});

runMain(main);
2 changes: 2 additions & 0 deletions packages/cli/src/commands/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CHROME_VERSION,
CACHE_DIR,
} from "../browser/manager.js";
import { trackBrowserInstall } from "../telemetry/events.js";

async function runEnsure(): Promise<void> {
clack.intro(c.bold("hyperframes browser ensure"));
Expand Down Expand Up @@ -47,6 +48,7 @@ async function runEnsure(): Promise<void> {
});

downloadSpinner.stop(c.success("Download complete"));
trackBrowserInstall();

console.log();
console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`);
Expand Down
14 changes: 1 addition & 13 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";

/**
* Check if a port is available by trying to listen on it briefly.
Expand All @@ -31,19 +32,6 @@ async function findAvailablePort(startPort: number): Promise<number> {
return startPort; // fallback — let the server fail with a clear error
}

/**
* Detect whether we're running from source (monorepo dev) or from the built bundle.
* When running via tsx from source, the file is at cli/src/commands/dev.ts.
* When running from the built bundle, the file is at cli/dist/cli.js.
* We check the filename portion of the URL to avoid false positives from
* directory names (e.g., /Users/someone/src/...).
*/
function isDevMode(): boolean {
const url = new URL(import.meta.url);
// In dev mode the file is a .ts source file; in production it's a bundled .js
return url.pathname.endsWith(".ts");
}

export default defineCommand({
meta: { name: "dev", description: "Start the studio for local development" },
args: {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { execSync, execFileSync, spawn } from "node:child_process";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { TEMPLATES, type TemplateId } from "../templates/generators.js";
import { trackInitTemplate } from "../telemetry/events.js";

const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);

Expand Down Expand Up @@ -380,6 +381,7 @@ export default defineCommand({
}

scaffoldProject(destDir, basename(destDir), templateId, localVideoName);
trackInitTemplate(templateId);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: trackInitTemplate is called in two places (line 384 and line 503) for the two code paths (direct template arg vs interactive prompt). If both paths somehow execute (bug), you'd double-track. Consider moving the track call to a single place after scaffoldProject — or dedup by tracking only once per invocation.


console.log(c.success(`\nCreated ${c.accent(name + "/")}`));
for (const f of readdirSync(destDir)) {
Expand Down Expand Up @@ -499,6 +501,7 @@ export default defineCommand({

// 4. Copy template and patch
scaffoldProject(destDir, name, templateId, localVideoName);
trackInitTemplate(templateId);

const files = readdirSync(destDir);
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
import { renderProgress } from "../ui/progress.js";
import { trackRenderComplete, trackRenderError } from "../telemetry/events.js";

const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
Expand Down Expand Up @@ -158,12 +159,21 @@ async function renderDocker(
});
await producer.executeRenderJob(job, projectDir, outputPath);
} catch (error: unknown) {
trackRenderError({ fps: options.fps, quality: options.quality, docker: true });
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: The error tracking fires before process.exit(1). Since process.exit triggers the synchronous flushSync in cli.ts, the error event will be flushed via execFileSync — a blocking child process spawn during an error path. If the network is down (which might be why the render failed), this adds a 5-second delay before the user sees the error exit. Consider making the error track fully fire-and-forget.

const message = error instanceof Error ? error.message : String(error);
errorBox("Render failed", message, "Try --docker for containerized rendering");
process.exit(1);
}

const elapsed = Date.now() - startTime;
trackRenderComplete({
durationMs: elapsed,
fps: options.fps,
quality: options.quality,
workers: options.workers ?? 4,
docker: true,
gpu: options.gpu,
});
printRenderComplete(outputPath, elapsed, options.quiet);
}

Expand Down Expand Up @@ -191,12 +201,21 @@ async function renderLocal(
try {
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
} catch (error: unknown) {
trackRenderError({ fps: options.fps, quality: options.quality, docker: false });
const message = error instanceof Error ? error.message : String(error);
errorBox("Render failed", message, "Try --docker for containerized rendering");
process.exit(1);
}

const elapsed = Date.now() - startTime;
trackRenderComplete({
durationMs: elapsed,
fps: options.fps,
quality: options.quality,
workers: options.workers ?? 4,
docker: false,
gpu: options.gpu,
});
printRenderComplete(outputPath, elapsed, options.quiet);
}

Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/commands/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { defineCommand } from "citty";
import { c } from "../ui/colors.js";
import { readConfig, writeConfig, CONFIG_PATH } from "../telemetry/config.js";

function runEnable(): void {
const config = readConfig();
config.telemetryEnabled = true;
writeConfig(config);
console.log(`\n ${c.success("\u2713")} Telemetry ${c.success("enabled")}\n`);
}

function runDisable(): void {
const config = readConfig();
config.telemetryEnabled = false;
writeConfig(config);
console.log(`\n ${c.success("\u2713")} Telemetry ${c.bold("disabled")}\n`);
}

function runStatus(): void {
const config = readConfig();
const status = config.telemetryEnabled ? c.success("enabled") : c.dim("disabled");
console.log();
console.log(` ${c.dim("Status:")} ${status}`);
console.log(` ${c.dim("Config:")} ${c.accent(CONFIG_PATH)}`);
console.log(` ${c.dim("Commands:")} ${c.bold(String(config.commandCount))}`);
console.log();
console.log(` ${c.dim("Disable:")} ${c.accent("hyperframes telemetry disable")}`);
console.log(` ${c.dim("Env var:")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")}`);
console.log();
}

export default defineCommand({
meta: { name: "telemetry", description: "Manage anonymous usage telemetry" },
args: {
subcommand: {
type: "positional",
description: "Subcommand: enable, disable, status",
required: false,
},
},
async run({ args }) {
const subcommand = args.subcommand;

if (!subcommand || subcommand === "") {
console.log(`
${c.bold("hyperframes telemetry")} ${c.dim("<subcommand>")}

Manage anonymous usage data collection.

${c.bold("SUBCOMMANDS:")}
${c.accent("status")} ${c.dim("Show current telemetry status")}
${c.accent("enable")} ${c.dim("Enable anonymous telemetry")}
${c.accent("disable")} ${c.dim("Disable anonymous telemetry")}

${c.bold("WHAT WE COLLECT:")}
${c.dim("\u2022")} Command names (init, render, dev, etc.)
${c.dim("\u2022")} Render performance (duration, fps, quality)
${c.dim("\u2022")} Template choices
${c.dim("\u2022")} OS, architecture, Node.js version, CLI version

${c.bold("WHAT WE DON'T COLLECT:")}
${c.dim("\u2022")} File paths, project names, or video content
${c.dim("\u2022")} IP addresses (discarded by our analytics provider)
${c.dim("\u2022")} Any personally identifiable information

${c.dim("You can also set")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("to disable.")}
`);
return;
}

switch (subcommand) {
case "enable":
return runEnable();
case "disable":
return runDisable();
case "status":
return runStatus();
default:
console.error(
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes telemetry --help")} for usage.`,
);
process.exit(1);
}
},
});
Loading
Loading