feat(cli): add opt-out anonymous telemetry via PostHog

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) <noreply@anthropic.com>
This commit is contained in:
James
2026-03-25 22:55:56 +00:00
co-authored by Claude Opus 4.6
parent f84df64e64
commit b7c75b814c
9 changed files with 461 additions and 0 deletions
+91
View File
@@ -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<HyperframesConfig>;
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;