diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index fee96cf13..83765bebc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -233,6 +233,8 @@ if (!isHelp && command !== "telemetry" && command !== "events" && command !== "u // `events` skips the update check too — a skill-usage beacon must not add // network latency or trigger a background self-upgrade on the calling skill. +// `telemetry` skips it because update metadata must never race the command +// that changes the user's telemetry preference. // `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is // excluded from the self-update notice: a command that is itself actively // checking/reconciling skills (`skills check`, `skills update`) must not also @@ -244,6 +246,7 @@ if ( !hasJsonFlag && command !== "upgrade" && command !== "events" && + command !== "telemetry" && command !== "skills" ) { // Report any completed auto-install from the previous run first, before diff --git a/packages/cli/src/commands/telemetry.test.ts b/packages/cli/src/commands/telemetry.test.ts new file mode 100644 index 000000000..93f39e7f1 --- /dev/null +++ b/packages/cli/src/commands/telemetry.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const baseConfig = { + telemetryEnabled: true, + anonymousId: "test-install", + telemetryNoticeShown: true, + commandCount: 7, + renderSuccessCount: 0, + lastFeedbackPromptAt: 0, +}; + +async function loadTelemetryCommand(options?: { + writeSucceeds?: boolean; + configEnabled?: boolean; + devMode?: boolean; + apiKey?: string; +}) { + const config = { + ...baseConfig, + telemetryEnabled: options?.configEnabled ?? true, + }; + const writeConfigWithResult = vi.fn(() => + options?.writeSucceeds === false + ? { ok: false as const, error: "EACCES: permission denied" } + : { ok: true as const }, + ); + vi.resetModules(); + vi.doMock("../telemetry/config.js", () => ({ + CONFIG_PATH: "/test/.hyperframes/config.json", + readConfig: () => { + throw new Error("telemetry commands must bypass stale cached config"); + }, + readConfigFresh: () => ({ ...config }), + writeConfigWithResult, + })); + vi.doMock("../utils/env.js", () => ({ + isDevMode: () => options?.devMode ?? false, + })); + vi.doMock("../telemetry/transport.js", () => ({ + POSTHOG_API_KEY: options?.apiKey ?? "phc_test", + })); + const module = await import("./telemetry.js"); + return { command: module.default, writeConfigWithResult }; +} + +async function runSubcommand( + command: Awaited>["command"], + subcommand: string, +): Promise { + await command.run?.({ + args: { subcommand }, + rawArgs: [subcommand], + cmd: command, + } as never); +} + +async function runWithCapturedOutput( + command: Awaited>["command"], + subcommand: string, +): Promise { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + await runSubcommand(command, subcommand); + return lines.join("\n"); +} + +describe("telemetry command", () => { + afterEach(() => { + vi.doUnmock("../telemetry/config.js"); + vi.doUnmock("../utils/env.js"); + vi.doUnmock("../telemetry/transport.js"); + vi.restoreAllMocks(); + vi.resetModules(); + delete process.env["HYPERFRAMES_NO_TELEMETRY"]; + delete process.env["DO_NOT_TRACK"]; + }); + + it("persists disable from a fresh config snapshot", async () => { + const { command, writeConfigWithResult } = await loadTelemetryCommand(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + await runSubcommand(command, "disable"); + + expect(writeConfigWithResult).toHaveBeenCalledWith( + expect.objectContaining({ telemetryEnabled: false }), + ); + }); + + it("fails instead of claiming success when the preference cannot be persisted", async () => { + const { command } = await loadTelemetryCommand({ writeSucceeds: false }); + const stdout = vi.spyOn(console, "log").mockImplementation(() => undefined); + const stderr = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(runSubcommand(command, "disable")).rejects.toMatchObject({ + name: "CliRuntimeError", + }); + + expect(stderr).toHaveBeenCalledWith(expect.stringContaining("Could not persist")); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining("EACCES: permission denied")); + expect(stdout).not.toHaveBeenCalledWith(expect.stringContaining("Telemetry disabled")); + }); + + it("reports the effective env-var opt-out instead of the stored preference", async () => { + process.env["HYPERFRAMES_NO_TELEMETRY"] = "1"; + const { command } = await loadTelemetryCommand({ configEnabled: true }); + const output = await runWithCapturedOutput(command, "status"); + expect(output).toContain("disabled"); + expect(output).toContain("HYPERFRAMES_NO_TELEMETRY"); + expect(output).toContain("Tracked commands:"); + }); + + it("reports DO_NOT_TRACK as the effective opt-out source", async () => { + process.env["DO_NOT_TRACK"] = "1"; + const { command } = await loadTelemetryCommand({ configEnabled: true }); + const output = await runWithCapturedOutput(command, "status"); + expect(output).toContain("DO_NOT_TRACK"); + }); + + it.each([ + ["enable", "Telemetry preference"], + ["disable", "Telemetry disabled"], + ])("explains the effective override after telemetry %s", async (subcommand, expectedSuccess) => { + process.env["HYPERFRAMES_NO_TELEMETRY"] = "true"; + const { command } = await loadTelemetryCommand({ configEnabled: subcommand === "disable" }); + const output = await runWithCapturedOutput(command, subcommand); + expect(output).toContain(expectedSuccess); + expect(output).toContain("remains disabled"); + expect(output).toContain("HYPERFRAMES_NO_TELEMETRY"); + }); + + it("reports dev mode as the effective source", async () => { + const { command } = await loadTelemetryCommand({ devMode: true }); + const output = await runWithCapturedOutput(command, "status"); + expect(output).toContain("disabled"); + expect(output).toContain("dev_mode"); + }); + + it("reports a telemetry-disabled build as the effective source", async () => { + const { command } = await loadTelemetryCommand({ apiKey: "disabled" }); + const output = await runWithCapturedOutput(command, "status"); + expect(output).toContain("disabled"); + expect(output).toContain("telemetry_disabled_build"); + }); +}); diff --git a/packages/cli/src/commands/telemetry.ts b/packages/cli/src/commands/telemetry.ts index 15f033679..8ffb4b119 100644 --- a/packages/cli/src/commands/telemetry.ts +++ b/packages/cli/src/commands/telemetry.ts @@ -1,39 +1,67 @@ -import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "citty"; -import type { Example } from "./_examples.js"; +import { writeConfigWithResult, readConfigFresh, CONFIG_PATH } from "../telemetry/config.js"; +import { effectiveTelemetryStatus, type TelemetryStatusSource } from "../telemetry/policy.js"; import { c } from "../ui/colors.js"; +import { failCommand } from "../utils/commandResult.js"; +import type { Example } from "./_examples.js"; export const examples: Example[] = [ ["Check current telemetry status", "hyperframes telemetry status"], ["Disable telemetry", "hyperframes telemetry disable"], ["Enable telemetry", "hyperframes telemetry enable"], ]; -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 describeOverride(source: Exclude): string { + switch (source) { + case "HYPERFRAMES_NO_TELEMETRY": + case "DO_NOT_TRACK": + return `${source} is set`; + case "dev_mode": + return "this is a development build"; + case "telemetry_disabled_build": + return "this build has no telemetry key"; + } } -function runDisable(): void { - const config = readConfig(); - config.telemetryEnabled = false; - writeConfig(config); - console.log(`\n ${c.success("\u2713")} Telemetry ${c.bold("disabled")}\n`); +function setTelemetryEnabled(enabled: boolean): void { + // Bypass the module cache so this privacy preference starts from the latest + // on-disk state rather than a snapshot held by another config consumer. + const config = readConfigFresh(); + config.telemetryEnabled = enabled; + const result = writeConfigWithResult(config); + if (!result.ok) { + console.error( + `\n ${c.error("\u2717")} Could not persist telemetry preference to ${c.accent(CONFIG_PATH)}\n` + + ` ${c.dim("Reason:")} ${result.error}\n`, + ); + failCommand(); + } + const effective = effectiveTelemetryStatus(enabled); + const preference = enabled ? c.success("enabled") : c.bold("disabled"); + const noun = enabled && !effective.enabled ? "Telemetry preference" : "Telemetry"; + console.log(`\n ${c.success("\u2713")} ${noun} ${preference}`); + if (effective.source !== "config") { + console.log( + ` ${c.dim("Note:")} Telemetry remains disabled because ${describeOverride(effective.source)}.`, + ); + } + console.log(); } function runStatus(): void { - const config = readConfig(); - const status = config.telemetryEnabled ? c.success("enabled") : c.dim("disabled"); + const config = readConfigFresh(); + const effective = effectiveTelemetryStatus(config.telemetryEnabled); + const status = effective.enabled ? c.success("enabled") : c.dim("disabled"); console.log(); console.log(` ${c.dim("Status:")} ${status}`); + console.log(` ${c.dim("Source:")} ${effective.source}`); console.log(` ${c.dim("Config:")} ${c.accent(CONFIG_PATH)}`); - console.log(` ${c.dim("Commands:")} ${c.bold(String(config.commandCount))}`); + console.log(` ${c.dim("Tracked 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( + ` ${c.dim("Env var:")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("or")} ${c.accent("DO_NOT_TRACK=1")}`, + ); console.log(); } @@ -71,16 +99,16 @@ ${c.bold("WHAT WE DON'T COLLECT:")} ${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.")} +${c.dim("You can also set")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("or")} ${c.accent("DO_NOT_TRACK=1")} ${c.dim("to disable.")} `); return; } switch (subcommand) { case "enable": - return runEnable(); + return setTelemetryEnabled(true); case "disable": - return runDisable(); + return setTelemetryEnabled(false); case "status": return runStatus(); default: diff --git a/packages/cli/src/telemetry/client.policy.test.ts b/packages/cli/src/telemetry/client.policy.test.ts new file mode 100644 index 000000000..5e71e6e47 --- /dev/null +++ b/packages/cli/src/telemetry/client.policy.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadShouldTrack(env: { HYPERFRAMES_NO_TELEMETRY?: string; DO_NOT_TRACK?: string }) { + vi.resetModules(); + vi.doMock("../utils/env.js", () => ({ isDevMode: () => false })); + vi.doMock("./config.js", () => ({ + readConfig: () => ({ telemetryEnabled: true }), + writeConfig: () => true, + })); + vi.stubEnv("HYPERFRAMES_NO_TELEMETRY", env.HYPERFRAMES_NO_TELEMETRY ?? ""); + vi.stubEnv("DO_NOT_TRACK", env.DO_NOT_TRACK ?? ""); + return (await import("./client.js")).shouldTrack; +} + +describe("telemetry client policy", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.doUnmock("../utils/env.js"); + vi.doUnmock("./config.js"); + vi.resetModules(); + }); + + it.each([ + ["HYPERFRAMES_NO_TELEMETRY", "true"], + ["HYPERFRAMES_NO_TELEMETRY", " yes "], + ["DO_NOT_TRACK", "TRUE"], + ["DO_NOT_TRACK", "on"], + ] as const)("does not track when %s=%j", async (name, value) => { + const shouldTrack = await loadShouldTrack({ [name]: value }); + expect(shouldTrack()).toBe(false); + }); + + it("tracks when the config is enabled and no runtime override applies", async () => { + const shouldTrack = await loadShouldTrack({}); + expect(shouldTrack()).toBe(true); + }); +}); diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts index 4fffa4dea..2e6f085a7 100644 --- a/packages/cli/src/telemetry/client.ts +++ b/packages/cli/src/telemetry/client.ts @@ -2,9 +2,9 @@ import { readConfig, writeConfig } from "./config.js"; import { VERSION } from "../version.js"; import { c } from "../ui/colors.js"; import { diag } from "../ui/diagnostics.js"; -import { isDevMode } from "../utils/env.js"; import { getSystemMeta } from "./system.js"; -import { enqueue, POSTHOG_API_KEY, type EventProperties } from "./transport.js"; +import { enqueue, type EventProperties } from "./transport.js"; +import { telemetryRuntimeOverride } from "./policy.js"; // --------------------------------------------------------------------------- // CLI-facing telemetry policy: opt-out checks, system-metadata enrichment, and @@ -21,23 +21,13 @@ 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. + * Disabled when: a privacy env var is set, this is a development or + * telemetry-disabled build, or the persisted preference is off. */ 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 (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_")) { + if (telemetryRuntimeOverride() !== null) { telemetryEnabled = false; return false; } diff --git a/packages/cli/src/telemetry/config.test.ts b/packages/cli/src/telemetry/config.test.ts index 47411d538..d24a228f1 100644 --- a/packages/cli/src/telemetry/config.test.ts +++ b/packages/cli/src/telemetry/config.test.ts @@ -34,6 +34,7 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module, let readConfig: typeof import("./config.js").readConfig; let readConfigFresh: typeof import("./config.js").readConfigFresh; let writeConfig: typeof import("./config.js").writeConfig; + let writeConfigWithResult: typeof import("./config.js").writeConfigWithResult; let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH; beforeEach(async () => { @@ -42,7 +43,8 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module, // module-scoped, so without this, a later test would silently inherit // an earlier test's cached read. vi.resetModules(); - ({ readConfig, readConfigFresh, writeConfig, CONFIG_PATH } = await import("./config.js")); + ({ readConfig, readConfigFresh, writeConfig, writeConfigWithResult, CONFIG_PATH } = + await import("./config.js")); }); it("creates a default config with a fresh anonymousId when no file exists", () => { @@ -94,11 +96,14 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module, expect(fresh.deParallelRouterTrialRenderCount).toBeUndefined(); }); - it("resets to defaults with a fresh anonymousId when the file is corrupted JSON", () => { + it("fails closed when the file is corrupted instead of silently re-enabling telemetry", () => { fsState.files.set(CONFIG_PATH, "{not valid json"); const config = readConfig(); - expect(config.telemetryEnabled).toBe(true); + expect(config.telemetryEnabled).toBe(false); expect(config.anonymousId).toBeTruthy(); + expect(JSON.parse(fsState.files.get(CONFIG_PATH) ?? "{}")).toMatchObject({ + telemetryEnabled: false, + }); }); it("writeConfig reports success, leaves no temp file behind, and reports failure when the fs throws", async () => { @@ -114,5 +119,13 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module, throw new Error("EACCES: permission denied"); }); expect(writeConfig(config)).toBe(false); + + vi.mocked(fs.writeFileSync).mockImplementationOnce(() => { + throw new Error("ENOSPC: disk full"); + }); + expect(writeConfigWithResult(config)).toEqual({ + ok: false, + error: "ENOSPC: disk full", + }); }); }); diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index cd41a4d50..48ded16c5 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from " import { join } from "node:path"; import { homedir } from "node:os"; import { randomUUID } from "node:crypto"; +import { normalizeErrorMessage } from "../utils/errorMessage.js"; // --------------------------------------------------------------------------- // Config directory: ~/.hyperframes/ @@ -192,8 +193,16 @@ export function readConfig(): HyperframesConfig { cachedConfig = config; return { ...config }; } catch { - // Corrupted config — reset - const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() }; + // A missing file is handled above. Any failure here means an existing + // preference could not be read safely (corrupt JSON, permissions, I/O). + // Preserve the historical recovery behavior for the rest of the config, + // but fail closed for the privacy control: recovery must never silently + // turn telemetry back on. + const config = { + ...DEFAULT_CONFIG, + telemetryEnabled: false, + anonymousId: randomUUID(), + }; writeConfig(config); return config; } @@ -229,16 +238,26 @@ export function readConfigFresh(): HyperframesConfig { * instead of re-implementing read-back verification. */ export function writeConfig(config: HyperframesConfig): boolean { + return writeConfigWithResult(config).ok; +} + +export type ConfigWriteResult = { ok: true } | { ok: false; error: string }; + +/** + * Persist config and retain the failure reason for user-facing commands that + * must distinguish a durable preference write from a best-effort update. + */ +export function writeConfigWithResult(config: HyperframesConfig): ConfigWriteResult { try { mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); const tmpFile = `${CONFIG_FILE}.${process.pid}.tmp`; writeFileSync(tmpFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); renameSync(tmpFile, CONFIG_FILE); cachedConfig = { ...config }; - return true; - } catch { + return { ok: true }; + } catch (error) { // Non-fatal — telemetry should never break the CLI - return false; + return { ok: false, error: normalizeErrorMessage(error) }; } } diff --git a/packages/cli/src/telemetry/policy.test.ts b/packages/cli/src/telemetry/policy.test.ts new file mode 100644 index 000000000..4894f29fc --- /dev/null +++ b/packages/cli/src/telemetry/policy.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadPolicy(options?: { devMode?: boolean; apiKey?: string }) { + vi.resetModules(); + vi.doMock("../utils/env.js", () => ({ + isDevMode: () => options?.devMode ?? false, + })); + vi.doMock("./transport.js", () => ({ + POSTHOG_API_KEY: options?.apiKey ?? "phc_test", + })); + return import("./policy.js"); +} + +describe("telemetry policy", () => { + afterEach(() => { + vi.doUnmock("../utils/env.js"); + vi.doUnmock("./transport.js"); + vi.resetModules(); + delete process.env["HYPERFRAMES_NO_TELEMETRY"]; + delete process.env["DO_NOT_TRACK"]; + }); + + it.each(["1", "true", "TRUE", " yes ", "on"])( + "treats %j as an explicit environment opt-out", + async (value) => { + process.env["HYPERFRAMES_NO_TELEMETRY"] = value; + const { effectiveTelemetryStatus } = await loadPolicy(); + expect(effectiveTelemetryStatus(true).source).toBe("HYPERFRAMES_NO_TELEMETRY"); + }, + ); + + it.each(["", "0", "false", "no", "off", "anything"])( + "does not treat %j as an affirmative opt-out value", + async (value) => { + process.env["HYPERFRAMES_NO_TELEMETRY"] = value; + const { effectiveTelemetryStatus } = await loadPolicy(); + expect(effectiveTelemetryStatus(true)).toEqual({ enabled: true, source: "config" }); + }, + ); + + it("reports HYPERFRAMES_NO_TELEMETRY as the effective source", async () => { + process.env["HYPERFRAMES_NO_TELEMETRY"] = "true"; + const { effectiveTelemetryStatus } = await loadPolicy(); + expect(effectiveTelemetryStatus(true)).toEqual({ + enabled: false, + source: "HYPERFRAMES_NO_TELEMETRY", + }); + }); + + it("reports DO_NOT_TRACK as the effective source", async () => { + process.env["DO_NOT_TRACK"] = "yes"; + const { effectiveTelemetryStatus } = await loadPolicy(); + expect(effectiveTelemetryStatus(true)).toEqual({ + enabled: false, + source: "DO_NOT_TRACK", + }); + }); + + it("reports dev mode instead of claiming telemetry is active", async () => { + const { effectiveTelemetryStatus } = await loadPolicy({ devMode: true }); + expect(effectiveTelemetryStatus(true)).toEqual({ + enabled: false, + source: "dev_mode", + }); + }); + + it("reports a telemetry-disabled build instead of claiming telemetry is active", async () => { + const { effectiveTelemetryStatus } = await loadPolicy({ apiKey: "disabled" }); + expect(effectiveTelemetryStatus(true)).toEqual({ + enabled: false, + source: "telemetry_disabled_build", + }); + }); + + it("falls back to the persisted preference when no runtime override applies", async () => { + const { effectiveTelemetryStatus } = await loadPolicy(); + expect(effectiveTelemetryStatus(false)).toEqual({ enabled: false, source: "config" }); + expect(effectiveTelemetryStatus(true)).toEqual({ enabled: true, source: "config" }); + }); +}); diff --git a/packages/cli/src/telemetry/policy.ts b/packages/cli/src/telemetry/policy.ts new file mode 100644 index 000000000..78e97df84 --- /dev/null +++ b/packages/cli/src/telemetry/policy.ts @@ -0,0 +1,56 @@ +import { isDevMode } from "../utils/env.js"; +import { POSTHOG_API_KEY } from "./transport.js"; + +export type TelemetryStatusSource = + | "config" + | "HYPERFRAMES_NO_TELEMETRY" + | "DO_NOT_TRACK" + | "dev_mode" + | "telemetry_disabled_build"; + +export type TelemetryStatus = { + enabled: boolean; + source: TelemetryStatusSource; +}; + +const ENV_OPT_OUT_VALUES = new Set(["1", "true", "yes", "on"]); + +/** + * Parse privacy-control environment variables consistently at every CLI + * surface. Be liberal about common affirmative spellings so an explicit + * opt-out never silently becomes an opt-in. + */ +function isEnvOptOutValue(value: string | undefined): boolean { + return value !== undefined && ENV_OPT_OUT_VALUES.has(value.trim().toLowerCase()); +} + +/** + * Return the runtime/build override that suppresses telemetry before the + * persisted preference is considered. + */ +export function telemetryRuntimeOverride(): Exclude | null { + if (isEnvOptOutValue(process.env["HYPERFRAMES_NO_TELEMETRY"])) { + return "HYPERFRAMES_NO_TELEMETRY"; + } + if (isEnvOptOutValue(process.env["DO_NOT_TRACK"])) { + return "DO_NOT_TRACK"; + } + if (isDevMode()) { + return "dev_mode"; + } + if (!POSTHOG_API_KEY.startsWith("phc_")) { + return "telemetry_disabled_build"; + } + return null; +} + +/** + * The single effective-status policy shared by the user-facing command and + * the event emitter. This prevents `telemetry status` from disagreeing with + * whether events can actually be sent. + */ +export function effectiveTelemetryStatus(configEnabled: boolean): TelemetryStatus { + const override = telemetryRuntimeOverride(); + if (override !== null) return { enabled: false, source: override }; + return { enabled: configEnabled, source: "config" }; +} diff --git a/packages/cli/src/utils/updateCheck.test.ts b/packages/cli/src/utils/updateCheck.test.ts index 9a9daf28c..4ef2dd2c4 100644 --- a/packages/cli/src/utils/updateCheck.test.ts +++ b/packages/cli/src/utils/updateCheck.test.ts @@ -130,6 +130,7 @@ async function checkWith(registryVersion: unknown): Promise<{ const writes: Array> = []; vi.doMock("../telemetry/config.js", () => ({ readConfig: () => ({}), + readConfigFresh: () => ({}), writeConfig: (c: Record) => writes.push({ ...c }), })); const origFetch = globalThis.fetch; @@ -150,6 +151,49 @@ async function checkWith(registryVersion: unknown): Promise<{ } } +async function checkAcrossConcurrentConfigWrite(): Promise> { + vi.resetModules(); + const initial = { + telemetryEnabled: true, + anonymousId: "test-install", + telemetryNoticeShown: true, + commandCount: 0, + renderSuccessCount: 0, + lastFeedbackPromptAt: 0, + }; + let persisted: Record = { ...initial }; + vi.doMock("../telemetry/config.js", () => ({ + readConfig: () => ({ ...initial }), + readConfigFresh: () => ({ ...persisted }), + writeConfig: (config: Record) => { + persisted = { ...config }; + return true; + }, + })); + const origFetch = globalThis.fetch; + let releaseResponse: (() => void) | undefined; + const responseReady = new Promise((resolve) => { + releaseResponse = resolve; + }); + globalThis.fetch = (async () => { + await responseReady; + return { + ok: true, + json: async () => ({ version: "9.9.9" }), + }; + }) as unknown as typeof fetch; + try { + const mod = await import("./updateCheck.js"); + const check = mod.checkForUpdate(true); + persisted = { ...persisted, telemetryEnabled: false }; + releaseResponse?.(); + await check; + return persisted; + } finally { + globalThis.fetch = origFetch; + } +} + /** * U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's * optional `{ deprecated: true }` is the single place that adds `_meta.deprecated` @@ -236,4 +280,12 @@ describe("checkForUpdate — registry boundary guard", () => { expect(typeof latest).toBe("string"); expect(wroteVersion).toBeUndefined(); }); + + it("merges update metadata into a fresh snapshot without re-enabling telemetry", async () => { + const persisted = await checkAcrossConcurrentConfigWrite(); + expect(persisted).toMatchObject({ + telemetryEnabled: false, + latestVersion: "9.9.9", + }); + }); }); diff --git a/packages/cli/src/utils/updateCheck.ts b/packages/cli/src/utils/updateCheck.ts index 0dddbd3fd..b0e049435 100644 --- a/packages/cli/src/utils/updateCheck.ts +++ b/packages/cli/src/utils/updateCheck.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { compareVersions } from "compare-versions"; -import { readConfig, writeConfig } from "../telemetry/config.js"; +import { readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js"; import { VERSION } from "../version.js"; import { isDevMode } from "./env.js"; import { detectInstaller } from "./installerDetection.js"; @@ -88,9 +88,14 @@ export async function checkForUpdate(force?: boolean): Promise { expect(shouldTrack()).toBe(false); }); - it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => { - setNoTelemetry("true"); + it.each(["true", "TRUE", " yes ", "on"])( + "returns false when VITE_HYPERFRAMES_NO_TELEMETRY=%j", + async (value) => { + setNoTelemetry(value); + const shouldTrack = await loadShouldTrack(); + expect(shouldTrack()).toBe(false); + }, + ); + + it("does not opt out for an explicit false value", async () => { + setNoTelemetry("false"); const shouldTrack = await loadShouldTrack(); - expect(shouldTrack()).toBe(false); + expect(shouldTrack()).toBe(true); }); it("returns false in vite dev mode", async () => { diff --git a/packages/studio/src/telemetry/client.ts b/packages/studio/src/telemetry/client.ts index 96b6d05f4..cdeccfb45 100644 --- a/packages/studio/src/telemetry/client.ts +++ b/packages/studio/src/telemetry/client.ts @@ -34,12 +34,13 @@ function isApiKeyConfigured(): boolean { // VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1 // opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio -// bundle the same way. Vite injects it at build time. Accepts "1" or "true". +// bundle the same way. Vite injects it at build time. Match the CLI's +// affirmative privacy-control spellings. // `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack). function isBuildTimeOptOut(): boolean { try { const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined; - return v === "1" || v === "true"; + return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase()); } catch { return false; }