diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2c66c6f1..9bf182ae 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,6 +2,33 @@ 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: { @@ -9,19 +36,36 @@ const main = defineCommand({ 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"; + +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); diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index 7eaade68..3144abfb 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -9,6 +9,7 @@ import { CHROME_VERSION, CACHE_DIR, } from "../browser/manager.js"; +import { trackBrowserInstall } from "../telemetry/events.js"; async function runEnsure(): Promise { clack.intro(c.bold("hyperframes browser ensure")); @@ -47,6 +48,7 @@ async function runEnsure(): Promise { }); downloadSpinner.stop(c.success("Download complete")); + trackBrowserInstall(); console.log(); console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index a5f32e5f..dd7789b2 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -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. @@ -31,19 +32,6 @@ async function findAvailablePort(startPort: number): Promise { 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: { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e28d6604..0024c6be 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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); @@ -380,6 +381,7 @@ export default defineCommand({ } scaffoldProject(destDir, basename(destDir), templateId, localVideoName); + trackInitTemplate(templateId); console.log(c.success(`\nCreated ${c.accent(name + "/")}`)); for (const f of readdirSync(destDir)) { @@ -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}/`)); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 46aa3364..8dde1378 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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"]); @@ -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 }); 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); } @@ -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); } diff --git a/packages/cli/src/commands/telemetry.ts b/packages/cli/src/commands/telemetry.ts new file mode 100644 index 00000000..c377437e --- /dev/null +++ b/packages/cli/src/commands/telemetry.ts @@ -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("")} + +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); + } + }, +}); diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts new file mode 100644 index 00000000..35771907 --- /dev/null +++ b/packages/cli/src/telemetry/client.ts @@ -0,0 +1,181 @@ +import { readConfig, writeConfig } from "./config.js"; +import { VERSION } from "../version.js"; +import { c } from "../ui/colors.js"; +import { isDevMode } from "../utils/env.js"; + +// This is a public project API key — safe to embed in client-side code. +// It only allows writing events, not reading data. +const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx"; +const POSTHOG_HOST = "https://us.i.posthog.com"; +const FLUSH_TIMEOUT_MS = 5_000; + +// --------------------------------------------------------------------------- +// Lightweight PostHog client — uses the HTTP batch API directly to avoid +// pulling in the full posthog-node SDK and its dependencies. +// All calls are fire-and-forget with a hard timeout. +// --------------------------------------------------------------------------- + +interface EventProperties { + [key: string]: string | number | boolean | undefined; +} + +let eventQueue: Array<{ + event: string; + properties: EventProperties; + timestamp: string; +}> = []; + +let telemetryEnabled: boolean | null = null; + +/** + * Check if telemetry should be active. + * Disabled when: dev mode, user opted out, CI environment, or HYPERFRAMES_NO_TELEMETRY set. + */ +export function shouldTrack(): boolean { + if (telemetryEnabled !== null) return telemetryEnabled; + + if (process.env["HYPERFRAMES_NO_TELEMETRY"] === "1" || process.env["DO_NOT_TRACK"] === "1") { + telemetryEnabled = false; + return false; + } + + if (process.env["CI"] === "true" || process.env["CI"] === "1") { + telemetryEnabled = false; + return false; + } + + if (isDevMode()) { + telemetryEnabled = false; + return false; + } + + // Safety check: ensure the API key has been configured (phc_ prefix = valid PostHog key) + if (!POSTHOG_API_KEY.startsWith("phc_")) { + telemetryEnabled = false; + return false; + } + + const config = readConfig(); + telemetryEnabled = config.telemetryEnabled; + return telemetryEnabled; +} + +/** + * Queue a telemetry event. Non-blocking, fail-silent. + */ +export function trackEvent(event: string, properties: EventProperties = {}): void { + if (!shouldTrack()) return; + + eventQueue.push({ + event, + properties: { + ...properties, + cli_version: VERSION, + os: process.platform, + arch: process.arch, + node_version: process.version, + }, + timestamp: new Date().toISOString(), + }); +} + +/** + * Flush all queued events to PostHog via async HTTP POST. + * Called before normal process exit via `beforeExit`. + */ +export async function flush(): Promise { + if (eventQueue.length === 0) { + return; + } + + const config = readConfig(); + const batch = eventQueue.map((e) => ({ + event: e.event, + // $ip: null tells PostHog to not record the request IP for this event. + // Server-side "Discard client IP data" is also enabled in project settings. + properties: { ...e.properties, $ip: null }, + distinct_id: config.anonymousId, + timestamp: e.timestamp, + })); + eventQueue = []; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); + + try { + await fetch(`${POSTHOG_HOST}/batch/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }), + signal: controller.signal, + }); + } catch { + // Silently ignore — telemetry must never break the CLI + } finally { + clearTimeout(timeout); + } +} + +/** + * Fire-and-forget flush for use in the `exit` event handler. + * Spawns a detached child process that sends the HTTP request independently, + * so the parent process exits immediately without waiting. + */ +export function flushSync(): void { + if (eventQueue.length === 0) { + return; + } + + const config = readConfig(); + const batch = eventQueue.map((e) => ({ + event: e.event, + properties: { ...e.properties, $ip: null }, + distinct_id: config.anonymousId, + timestamp: e.timestamp, + })); + eventQueue = []; + + const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); + + try { + const { spawn } = require("node:child_process") as typeof import("node:child_process"); + const child = spawn( + process.execPath, + [ + "-e", + `fetch(${JSON.stringify(`${POSTHOG_HOST}/batch/`)},{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify(payload)},signal:AbortSignal.timeout(${FLUSH_TIMEOUT_MS})}).catch(()=>{})`, + ], + { detached: true, stdio: "ignore" }, + ); + // Let the parent exit without waiting for the child + child.unref(); + } catch { + // Silently ignore + } +} + +/** + * Show the first-run telemetry notice if it hasn't been shown yet. + * Must be called BEFORE any tracking calls so the user sees the disclosure + * before any data is sent. + */ +export function showTelemetryNotice(): boolean { + if (!shouldTrack()) return false; + + const config = readConfig(); + if (config.telemetryNoticeShown) return false; + + // Persist the notice flag first, before any tracking occurs, + // so the user is never tracked without having seen the disclosure. + config.telemetryNoticeShown = true; + writeConfig(config); + + console.log(); + console.log(` ${c.dim("Hyperframes collects anonymous usage data to improve the tool.")}`); + console.log(` ${c.dim("No personal info, file paths, or content is collected.")}`); + console.log(); + console.log(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`); + console.log(); + + return true; +} diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts new file mode 100644 index 00000000..26d8594b --- /dev/null +++ b/packages/cli/src/telemetry/config.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { randomUUID } from "node:crypto"; + +// --------------------------------------------------------------------------- +// Config directory: ~/.hyperframes/ +// --------------------------------------------------------------------------- + +const CONFIG_DIR = join(homedir(), ".hyperframes"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +export interface HyperframesConfig { + /** Whether anonymous telemetry is enabled (default: true in production) */ + telemetryEnabled: boolean; + /** Stable anonymous identifier — no PII, just a random UUID */ + anonymousId: string; + /** Whether the first-run telemetry notice has been shown */ + telemetryNoticeShown: boolean; + /** Total CLI command invocations (for engagement prompts) */ + commandCount: number; +} + +const DEFAULT_CONFIG: HyperframesConfig = { + telemetryEnabled: true, + anonymousId: "", + telemetryNoticeShown: false, + commandCount: 0, +}; + +let cachedConfig: HyperframesConfig | null = null; + +/** + * Read the config file, creating it with defaults if it doesn't exist. + * Returns a mutable copy — call `writeConfig()` to persist changes. + */ +export function readConfig(): HyperframesConfig { + if (cachedConfig) return { ...cachedConfig }; + + if (!existsSync(CONFIG_FILE)) { + const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() }; + writeConfig(config); + return config; + } + + try { + const raw = readFileSync(CONFIG_FILE, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + + const config: HyperframesConfig = { + telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled, + anonymousId: parsed.anonymousId || randomUUID(), + telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown, + commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount, + }; + + cachedConfig = config; + return { ...config }; + } catch { + // Corrupted config — reset + const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() }; + writeConfig(config); + return config; + } +} + +/** + * Persist config to disk. Updates the in-memory cache. + */ +export function writeConfig(config: HyperframesConfig): void { + try { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); + cachedConfig = { ...config }; + } catch { + // Non-fatal — telemetry should never break the CLI + } +} + +/** + * Increment the command counter and persist. + */ +export function incrementCommandCount(): number { + const config = readConfig(); + config.commandCount++; + writeConfig(config); + return config.commandCount; +} + +/** Expose the config directory path for the telemetry command output */ +export const CONFIG_PATH = CONFIG_FILE; diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts new file mode 100644 index 00000000..2bf498c8 --- /dev/null +++ b/packages/cli/src/telemetry/events.ts @@ -0,0 +1,39 @@ +import { trackEvent } from "./client.js"; + +export function trackCommand(command: string): void { + trackEvent("cli_command", { command }); +} + +export function trackRenderComplete(props: { + durationMs: number; + fps: number; + quality: string; + workers: number; + docker: boolean; + gpu: boolean; +}): void { + trackEvent("render_complete", { + duration_ms: props.durationMs, + fps: props.fps, + quality: props.quality, + workers: props.workers, + docker: props.docker, + gpu: props.gpu, + }); +} + +export function trackRenderError(props: { fps: number; quality: string; docker: boolean }): void { + trackEvent("render_error", { + fps: props.fps, + quality: props.quality, + docker: props.docker, + }); +} + +export function trackInitTemplate(templateId: string): void { + trackEvent("init_template", { template: templateId }); +} + +export function trackBrowserInstall(): void { + trackEvent("browser_install", {}); +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts new file mode 100644 index 00000000..f1c00b71 --- /dev/null +++ b/packages/cli/src/telemetry/index.ts @@ -0,0 +1,9 @@ +export { readConfig, writeConfig, incrementCommandCount, CONFIG_PATH } from "./config.js"; +export { trackEvent, flush, flushSync, shouldTrack, showTelemetryNotice } from "./client.js"; +export { + trackCommand, + trackRenderComplete, + trackRenderError, + trackInitTemplate, + trackBrowserInstall, +} from "./events.js"; diff --git a/packages/cli/src/utils/env.ts b/packages/cli/src/utils/env.ts new file mode 100644 index 00000000..c6d22250 --- /dev/null +++ b/packages/cli/src/utils/env.ts @@ -0,0 +1,14 @@ +/** + * Detect whether we're running from source (monorepo dev) or from the built bundle. + * In dev: files are .ts (running via tsx). In production: bundled into .js by tsup. + */ +export function isDevMode(): boolean { + try { + const url = new URL(import.meta.url); + return url.pathname.endsWith(".ts"); + } catch { + // Fail-safe: if URL parsing fails for any reason, assume production. + // This ensures telemetry is never accidentally disabled in production builds. + return false; + } +}