From b7c75b814c973bc6ca69bc47a25dad511495a684 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Mar 2026 22:55:56 +0000 Subject: [PATCH 1/6] feat(cli): add opt-out anonymous telemetry via PostHog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add anonymous usage telemetry to help improve the CLI. Uses PostHog's HTTP batch API directly (zero new dependencies) with a 5-second timeout and fail-silent behavior — telemetry never breaks the CLI. What's collected: command names, render performance (duration, fps, quality), template choices, OS/arch/Node version/CLI version. What's NOT collected: file paths, project names, video content, or any personally identifiable information. Telemetry is: - Disabled in dev mode (running via tsx) - Disabled in CI (CI=true) or via HYPERFRAMES_NO_TELEMETRY=1 - Disabled when API key is placeholder (safe to merge before key is set) - Controllable via `hyperframes telemetry [enable|disable|status]` - Disclosed on first run with clear opt-out instructions Config stored at ~/.hyperframes/config.json (0600 permissions). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/cli.ts | 30 +++++ packages/cli/src/commands/browser.ts | 2 + packages/cli/src/commands/init.ts | 3 + packages/cli/src/commands/render.ts | 19 +++ packages/cli/src/commands/telemetry.ts | 85 +++++++++++++ packages/cli/src/telemetry/client.ts | 167 +++++++++++++++++++++++++ packages/cli/src/telemetry/config.ts | 91 ++++++++++++++ packages/cli/src/telemetry/events.ts | 55 ++++++++ packages/cli/src/telemetry/index.ts | 9 ++ 9 files changed, 461 insertions(+) create mode 100644 packages/cli/src/commands/telemetry.ts create mode 100644 packages/cli/src/telemetry/client.ts create mode 100644 packages/cli/src/telemetry/config.ts create mode 100644 packages/cli/src/telemetry/events.ts create mode 100644 packages/cli/src/telemetry/index.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2c66c6f1..95979c4e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,6 +2,35 @@ import { defineCommand, runMain } from "citty"; import { VERSION } from "./version.js"; +import { showTelemetryNotice, flush, trackCommand, incrementCommandCount } from "./telemetry/index.js"; + +// --------------------------------------------------------------------------- +// Telemetry — detect command from argv, track it, flush on exit +// --------------------------------------------------------------------------- + +const KNOWN_COMMANDS = new Set([ + "init", "dev", "render", "lint", "info", "compositions", + "benchmark", "browser", "docs", "doctor", "upgrade", "telemetry", +]); + +const commandArg = process.argv[2]; +const command = commandArg && KNOWN_COMMANDS.has(commandArg) ? commandArg : "unknown"; + +// Show first-run notice (no-ops if already shown or telemetry disabled) +if (command !== "telemetry") { + showTelemetryNotice(); + trackCommand(command); + incrementCommandCount(); +} + +// Flush telemetry events before exit — non-blocking, 5s timeout +process.on("beforeExit", () => { + flush().catch(() => {}); +}); + +// --------------------------------------------------------------------------- +// CLI definition +// --------------------------------------------------------------------------- const main = defineCommand({ meta: { @@ -21,6 +50,7 @@ const main = defineCommand({ 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), }, }); diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index 7eaade68..66603c8d 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(true); console.log(); console.log(` ${c.dim("Source:")} ${c.bold(result.source)}`); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e28d6604..1aae6651 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)) { @@ -498,6 +500,7 @@ export default defineCommand({ const templateId: TemplateId = templateResult; // 4. Copy template and patch + trackInitTemplate(templateId); scaffoldProject(destDir, name, templateId, localVideoName); const files = readdirSync(destDir); 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..853db9b3 --- /dev/null +++ b/packages/cli/src/telemetry/client.ts @@ -0,0 +1,167 @@ +import { readConfig, writeConfig } from "./config.js"; +import { VERSION } from "../version.js"; + +// --------------------------------------------------------------------------- +// PostHog configuration +// --------------------------------------------------------------------------- + +// 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 = "__POSTHOG_API_KEY__"; +const POSTHOG_HOST = "https://us.i.posthog.com"; +const FLUSH_TIMEOUT_MS = 5_000; + +// --------------------------------------------------------------------------- +// Dev mode detection — telemetry is disabled when running from source (tsx) +// --------------------------------------------------------------------------- + +function isDevMode(): boolean { + // In dev: files are .ts (running via tsx). In production: bundled .js + try { + const url = new URL(import.meta.url); + return url.pathname.endsWith(".ts"); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Lightweight PostHog client — we use the HTTP 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 isEnabled: boolean | null = null; +let anonymousId: string | null = null; + +/** + * Check if telemetry should be active. + * Disabled when: dev mode, user opted out, CI environment, or HYPERFRAMES_NO_TELEMETRY set. + */ +function shouldTrack(): boolean { + if (isEnabled !== null) return isEnabled; + + // Environment overrides + if (process.env["HYPERFRAMES_NO_TELEMETRY"] === "1" || process.env["DO_NOT_TRACK"] === "1") { + isEnabled = false; + return false; + } + + // CI detection + if (process.env["CI"] === "true" || process.env["CI"] === "1") { + isEnabled = false; + return false; + } + + // Dev mode — never phone home during development + if (isDevMode()) { + isEnabled = false; + return false; + } + + // Placeholder API key means it hasn't been configured yet + if (POSTHOG_API_KEY === "__POSTHOG_API_KEY__") { + isEnabled = false; + return false; + } + + const config = readConfig(); + isEnabled = config.telemetryEnabled; + anonymousId = config.anonymousId; + return isEnabled; +} + +/** + * Queue a telemetry event. Non-blocking, fail-silent. + */ +export function trackEvent(event: string, properties: EventProperties = {}): void { + if (!shouldTrack()) return; + + if (!anonymousId) { + const config = readConfig(); + anonymousId = config.anonymousId; + } + + 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. Called on process exit. + * Uses the /batch endpoint for efficiency. + */ +export async function flush(): Promise { + if (eventQueue.length === 0 || !shouldTrack()) { + eventQueue = []; + return; + } + + const batch = eventQueue.map((e) => ({ + event: e.event, + properties: e.properties, + distinct_id: 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); + } +} + +/** + * Show the first-run telemetry notice if it hasn't been shown yet. + * Returns true if the notice was shown (so callers can add spacing). + */ +export function showTelemetryNotice(): boolean { + if (!shouldTrack()) return false; + + const config = readConfig(); + if (config.telemetryNoticeShown) return false; + + // Dynamic import to avoid pulling colors into the check path + const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; + const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; + + console.log(); + console.log(` ${dim("Hyperframes collects anonymous usage data to improve the tool.")}`); + console.log(` ${dim("No personal info, file paths, or content is collected.")}`); + console.log(); + console.log(` ${dim("Disable anytime:")} ${cyan("hyperframes telemetry disable")}`); + console.log(); + + config.telemetryNoticeShown = true; + writeConfig(config); + return true; +} diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts new file mode 100644 index 00000000..c2981d48 --- /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 }); + 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..c509808c --- /dev/null +++ b/packages/cli/src/telemetry/events.ts @@ -0,0 +1,55 @@ +import { trackEvent } from "./client.js"; + +/** + * Track a CLI command invocation. + * This is the primary event — fired for every command. + */ +export function trackCommand(command: string): void { + trackEvent("cli_command", { command }); +} + +/** + * Track a successful render completion with performance metrics. + */ +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, + }); +} + +/** + * Track a render failure (error type only, no message/stack). + */ +export function trackRenderError(props: { fps: number; quality: string; docker: boolean }): void { + trackEvent("render_error", { + fps: props.fps, + quality: props.quality, + docker: props.docker, + }); +} + +/** + * Track which template was chosen during init. + */ +export function trackInitTemplate(templateId: string): void { + trackEvent("init_template", { template: templateId }); +} + +/** + * Track browser download/ensure events. + */ +export function trackBrowserInstall(success: boolean): void { + trackEvent("browser_install", { success }); +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts new file mode 100644 index 00000000..0caaff3d --- /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, showTelemetryNotice } from "./client.js"; +export { + trackCommand, + trackRenderComplete, + trackRenderError, + trackInitTemplate, + trackBrowserInstall, +} from "./events.js"; From 6e530bc2dd8ff5a53338dd8a90fe5d5dcc907b11 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Mar 2026 23:02:33 +0000 Subject: [PATCH 2/6] refactor(cli): address review findings in telemetry code - Extract shared isDevMode() to utils/env.ts (was duplicated in dev.ts and client.ts) - Use ui/colors.ts instead of raw ANSI escapes in telemetry notice (respects NO_COLOR) - Derive known commands from subCommands object instead of maintaining duplicate set - Skip telemetry on --help/--version and unknown commands - Gate incrementCommandCount() behind shouldTrack() (no disk writes in CI) - Add flushSync() for process.exit() paths (beforeExit doesn't fire on explicit exit) - Remove dead trackBrowserInstall(success) param (failure path never called it) - Remove redundant isEnabled/anonymousId caching in client.ts (config.ts cache suffices) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/cli.ts | 81 ++++++++++++--------- packages/cli/src/commands/browser.ts | 2 +- packages/cli/src/commands/dev.ts | 14 +--- packages/cli/src/telemetry/client.ts | 104 ++++++++++++++------------- packages/cli/src/telemetry/events.ts | 20 +----- packages/cli/src/telemetry/index.ts | 2 +- packages/cli/src/utils/env.ts | 12 ++++ 7 files changed, 117 insertions(+), 118 deletions(-) create mode 100644 packages/cli/src/utils/env.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 95979c4e..2364038c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,56 +2,67 @@ import { defineCommand, runMain } from "citty"; import { VERSION } from "./version.js"; -import { showTelemetryNotice, flush, trackCommand, incrementCommandCount } from "./telemetry/index.js"; +import { + showTelemetryNotice, + flush, + flushSync, + shouldTrack, + trackCommand, + incrementCommandCount, +} from "./telemetry/index.js"; // --------------------------------------------------------------------------- -// Telemetry — detect command from argv, track it, flush on exit +// CLI definition // --------------------------------------------------------------------------- -const KNOWN_COMMANDS = new Set([ - "init", "dev", "render", "lint", "info", "compositions", - "benchmark", "browser", "docs", "doctor", "upgrade", "telemetry", -]); +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, +}); + +// --------------------------------------------------------------------------- +// Telemetry — detect command from argv, track it, flush on exit +// --------------------------------------------------------------------------- const commandArg = process.argv[2]; -const command = commandArg && KNOWN_COMMANDS.has(commandArg) ? commandArg : "unknown"; +const isHelpOrVersion = process.argv.includes("--help") || process.argv.includes("--version") || process.argv.includes("-h"); +const command = commandArg && commandArg in subCommands ? commandArg : "unknown"; -// Show first-run notice (no-ops if already shown or telemetry disabled) -if (command !== "telemetry") { +if (command !== "telemetry" && command !== "unknown" && !isHelpOrVersion) { showTelemetryNotice(); trackCommand(command); - incrementCommandCount(); + if (shouldTrack()) { + incrementCommandCount(); + } } -// Flush telemetry events before exit — non-blocking, 5s timeout +// Async flush for normal exit (beforeExit fires when the event loop drains) process.on("beforeExit", () => { flush().catch(() => {}); }); -// --------------------------------------------------------------------------- -// CLI definition -// --------------------------------------------------------------------------- - -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), - telemetry: () => import("./commands/telemetry.js").then((m) => m.default), - }, +// 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 66603c8d..3144abfb 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -48,7 +48,7 @@ async function runEnsure(): Promise { }); downloadSpinner.stop(c.success("Download complete")); - trackBrowserInstall(true); + 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/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 853db9b3..efce7813 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -1,9 +1,7 @@ import { readConfig, writeConfig } from "./config.js"; import { VERSION } from "../version.js"; - -// --------------------------------------------------------------------------- -// PostHog configuration -// --------------------------------------------------------------------------- +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. @@ -12,21 +10,7 @@ const POSTHOG_HOST = "https://us.i.posthog.com"; const FLUSH_TIMEOUT_MS = 5_000; // --------------------------------------------------------------------------- -// Dev mode detection — telemetry is disabled when running from source (tsx) -// --------------------------------------------------------------------------- - -function isDevMode(): boolean { - // In dev: files are .ts (running via tsx). In production: bundled .js - try { - const url = new URL(import.meta.url); - return url.pathname.endsWith(".ts"); - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// Lightweight PostHog client — we use the HTTP API directly to avoid +// 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. // --------------------------------------------------------------------------- @@ -41,44 +25,39 @@ let eventQueue: Array<{ timestamp: string; }> = []; -let isEnabled: boolean | null = null; -let anonymousId: string | null = null; +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. */ -function shouldTrack(): boolean { - if (isEnabled !== null) return isEnabled; +export function shouldTrack(): boolean { + if (telemetryEnabled !== null) return telemetryEnabled; - // Environment overrides if (process.env["HYPERFRAMES_NO_TELEMETRY"] === "1" || process.env["DO_NOT_TRACK"] === "1") { - isEnabled = false; + telemetryEnabled = false; return false; } - // CI detection if (process.env["CI"] === "true" || process.env["CI"] === "1") { - isEnabled = false; + telemetryEnabled = false; return false; } - // Dev mode — never phone home during development if (isDevMode()) { - isEnabled = false; + telemetryEnabled = false; return false; } // Placeholder API key means it hasn't been configured yet if (POSTHOG_API_KEY === "__POSTHOG_API_KEY__") { - isEnabled = false; + telemetryEnabled = false; return false; } const config = readConfig(); - isEnabled = config.telemetryEnabled; - anonymousId = config.anonymousId; - return isEnabled; + telemetryEnabled = config.telemetryEnabled; + return telemetryEnabled; } /** @@ -87,11 +66,6 @@ function shouldTrack(): boolean { export function trackEvent(event: string, properties: EventProperties = {}): void { if (!shouldTrack()) return; - if (!anonymousId) { - const config = readConfig(); - anonymousId = config.anonymousId; - } - eventQueue.push({ event, properties: { @@ -106,19 +80,19 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi } /** - * Flush all queued events to PostHog. Called on process exit. - * Uses the /batch endpoint for efficiency. + * 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 || !shouldTrack()) { - eventQueue = []; + if (eventQueue.length === 0) { return; } + const config = readConfig(); const batch = eventQueue.map((e) => ({ event: e.event, properties: e.properties, - distinct_id: anonymousId, + distinct_id: config.anonymousId, timestamp: e.timestamp, })); eventQueue = []; @@ -140,6 +114,40 @@ export async function flush(): Promise { } } +/** + * Synchronous flush for use in the `exit` event handler (which doesn't support async). + * Uses a synchronous XMLHttpRequest-style approach via child_process to ensure + * events are sent even when process.exit() is called. + */ +export function flushSync(): void { + if (eventQueue.length === 0) { + return; + } + + const config = readConfig(); + const batch = eventQueue.map((e) => ({ + event: e.event, + properties: e.properties, + distinct_id: config.anonymousId, + timestamp: e.timestamp, + })); + eventQueue = []; + + const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); + + try { + // Spawn a detached process to send the request so we don't block exit. + // The subprocess inherits nothing and runs independently. + const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); + execFileSync(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(()=>{})`, + ], { stdio: "ignore", timeout: FLUSH_TIMEOUT_MS }); + } catch { + // Silently ignore + } +} + /** * Show the first-run telemetry notice if it hasn't been shown yet. * Returns true if the notice was shown (so callers can add spacing). @@ -150,15 +158,11 @@ export function showTelemetryNotice(): boolean { const config = readConfig(); if (config.telemetryNoticeShown) return false; - // Dynamic import to avoid pulling colors into the check path - const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; - const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; - console.log(); - console.log(` ${dim("Hyperframes collects anonymous usage data to improve the tool.")}`); - console.log(` ${dim("No personal info, file paths, or content is collected.")}`); + 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(` ${dim("Disable anytime:")} ${cyan("hyperframes telemetry disable")}`); + console.log(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`); console.log(); config.telemetryNoticeShown = true; diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index c509808c..2bf498c8 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -1,16 +1,9 @@ import { trackEvent } from "./client.js"; -/** - * Track a CLI command invocation. - * This is the primary event — fired for every command. - */ export function trackCommand(command: string): void { trackEvent("cli_command", { command }); } -/** - * Track a successful render completion with performance metrics. - */ export function trackRenderComplete(props: { durationMs: number; fps: number; @@ -29,9 +22,6 @@ export function trackRenderComplete(props: { }); } -/** - * Track a render failure (error type only, no message/stack). - */ export function trackRenderError(props: { fps: number; quality: string; docker: boolean }): void { trackEvent("render_error", { fps: props.fps, @@ -40,16 +30,10 @@ export function trackRenderError(props: { fps: number; quality: string; docker: }); } -/** - * Track which template was chosen during init. - */ export function trackInitTemplate(templateId: string): void { trackEvent("init_template", { template: templateId }); } -/** - * Track browser download/ensure events. - */ -export function trackBrowserInstall(success: boolean): void { - trackEvent("browser_install", { success }); +export function trackBrowserInstall(): void { + trackEvent("browser_install", {}); } diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 0caaff3d..f1c00b71 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -1,5 +1,5 @@ export { readConfig, writeConfig, incrementCommandCount, CONFIG_PATH } from "./config.js"; -export { trackEvent, flush, showTelemetryNotice } from "./client.js"; +export { trackEvent, flush, flushSync, shouldTrack, showTelemetryNotice } from "./client.js"; export { trackCommand, trackRenderComplete, diff --git a/packages/cli/src/utils/env.ts b/packages/cli/src/utils/env.ts new file mode 100644 index 00000000..d4a92222 --- /dev/null +++ b/packages/cli/src/utils/env.ts @@ -0,0 +1,12 @@ +/** + * 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 { + return false; + } +} From c6e124392d97d6008042a78522464fc6536b180d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Mar 2026 23:02:48 +0000 Subject: [PATCH 3/6] chore(cli): add PostHog project API key for telemetry Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/telemetry/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index efce7813..3e9a2eca 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -5,7 +5,7 @@ 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 = "__POSTHOG_API_KEY__"; +const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx"; const POSTHOG_HOST = "https://us.i.posthog.com"; const FLUSH_TIMEOUT_MS = 5_000; From dd586f4705de0dc4a1391f885058419d4c7db88e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Mar 2026 23:06:57 +0000 Subject: [PATCH 4/6] chore(cli): set $ip: null on telemetry events to discard IP data PostHog's $ip: null property tells the server to not associate the request IP with the event. Combined with the "Discard client IP data" project setting for server-side enforcement. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/telemetry/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 3e9a2eca..8a5d1e97 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -91,7 +91,7 @@ export async function flush(): Promise { const config = readConfig(); const batch = eventQueue.map((e) => ({ event: e.event, - properties: e.properties, + properties: { ...e.properties, $ip: null }, distinct_id: config.anonymousId, timestamp: e.timestamp, })); @@ -127,7 +127,7 @@ export function flushSync(): void { const config = readConfig(); const batch = eventQueue.map((e) => ({ event: e.event, - properties: e.properties, + properties: { ...e.properties, $ip: null }, distinct_id: config.anonymousId, timestamp: e.timestamp, })); From 6c88c56cd2f244b9b7837d534a4ceee689d6716f Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Mar 2026 23:13:12 +0000 Subject: [PATCH 5/6] fix(cli): fix CI format and typecheck failures - Run oxfmt on cli.ts and client.ts - Replace literal placeholder comparison with phc_ prefix check (TS2367: comparing two different string literals has no overlap) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/cli.ts | 5 ++++- packages/cli/src/telemetry/client.ts | 14 +++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2364038c..9bf182ae 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -44,7 +44,10 @@ const main = defineCommand({ // --------------------------------------------------------------------------- const commandArg = process.argv[2]; -const isHelpOrVersion = process.argv.includes("--help") || process.argv.includes("--version") || process.argv.includes("-h"); +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) { diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 8a5d1e97..370b35d6 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -50,7 +50,7 @@ export function shouldTrack(): boolean { } // Placeholder API key means it hasn't been configured yet - if (POSTHOG_API_KEY === "__POSTHOG_API_KEY__") { + if (!POSTHOG_API_KEY.startsWith("phc_")) { telemetryEnabled = false; return false; } @@ -139,10 +139,14 @@ export function flushSync(): void { // Spawn a detached process to send the request so we don't block exit. // The subprocess inherits nothing and runs independently. const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); - execFileSync(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(()=>{})`, - ], { stdio: "ignore", timeout: FLUSH_TIMEOUT_MS }); + execFileSync( + 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(()=>{})`, + ], + { stdio: "ignore", timeout: FLUSH_TIMEOUT_MS }, + ); } catch { // Silently ignore } From e52a19b8b4f05d5cd1151cbbcb07e598d0f5ee17 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Mar 2026 23:15:34 +0000 Subject: [PATCH 6/6] fix(cli): address PR review feedback from miguel-heygen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flushSync: use detached spawn + unref() instead of execFileSync, so process.exit() paths don't block up to 5s on slow networks - showTelemetryNotice: persist notice flag BEFORE printing/tracking, so users are never tracked without having seen the disclosure - Config dir: set mode 0o700 on ~/.hyperframes/ directory (was umask default) - $ip: null comment: clarify this is belt-and-suspenders with server-side discard - shouldTrack: update comment — phc_ prefix check is a safety net, not dead code - env.ts: add comment explaining try/catch fail-safe defaults to production - init.ts: consistently call trackInitTemplate after scaffoldProject in both paths Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/telemetry/client.ts | 30 +++++++++++++++++----------- packages/cli/src/telemetry/config.ts | 2 +- packages/cli/src/utils/env.ts | 2 ++ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1aae6651..0024c6be 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -500,8 +500,8 @@ export default defineCommand({ const templateId: TemplateId = templateResult; // 4. Copy template and patch - trackInitTemplate(templateId); 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/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 370b35d6..35771907 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -49,7 +49,7 @@ export function shouldTrack(): boolean { return false; } - // Placeholder API key means it hasn't been configured yet + // Safety check: ensure the API key has been configured (phc_ prefix = valid PostHog key) if (!POSTHOG_API_KEY.startsWith("phc_")) { telemetryEnabled = false; return false; @@ -91,6 +91,8 @@ export async function flush(): Promise { 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, @@ -115,9 +117,9 @@ export async function flush(): Promise { } /** - * Synchronous flush for use in the `exit` event handler (which doesn't support async). - * Uses a synchronous XMLHttpRequest-style approach via child_process to ensure - * events are sent even when process.exit() is called. + * 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) { @@ -136,17 +138,17 @@ export function flushSync(): void { const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); try { - // Spawn a detached process to send the request so we don't block exit. - // The subprocess inherits nothing and runs independently. - const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); - execFileSync( + 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(()=>{})`, ], - { stdio: "ignore", timeout: FLUSH_TIMEOUT_MS }, + { detached: true, stdio: "ignore" }, ); + // Let the parent exit without waiting for the child + child.unref(); } catch { // Silently ignore } @@ -154,7 +156,8 @@ export function flushSync(): void { /** * Show the first-run telemetry notice if it hasn't been shown yet. - * Returns true if the notice was shown (so callers can add spacing). + * 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; @@ -162,6 +165,11 @@ export function showTelemetryNotice(): boolean { 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.")}`); @@ -169,7 +177,5 @@ export function showTelemetryNotice(): boolean { console.log(` ${c.dim("Disable anytime:")} ${c.accent("hyperframes telemetry disable")}`); console.log(); - config.telemetryNoticeShown = true; - writeConfig(config); return true; } diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index c2981d48..26d8594b 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -69,7 +69,7 @@ export function readConfig(): HyperframesConfig { */ export function writeConfig(config: HyperframesConfig): void { try { - mkdirSync(CONFIG_DIR, { recursive: true }); + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); cachedConfig = { ...config }; } catch { diff --git a/packages/cli/src/utils/env.ts b/packages/cli/src/utils/env.ts index d4a92222..c6d22250 100644 --- a/packages/cli/src/utils/env.ts +++ b/packages/cli/src/utils/env.ts @@ -7,6 +7,8 @@ export function isDevMode(): boolean { 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; } }