fix(cli): make telemetry opt-out durable (#2852)

* fix(cli): make telemetry opt-out durable

* fix(cli): make telemetry status trustworthy
This commit is contained in:
Miguel Ángel
2026-07-28 20:35:54 +02:00
committed by GitHub
parent c691869e22
commit 880021411d
13 changed files with 491 additions and 52 deletions
+3
View File
@@ -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
+146
View File
@@ -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<ReturnType<typeof loadTelemetryCommand>>["command"],
subcommand: string,
): Promise<void> {
await command.run?.({
args: { subcommand },
rawArgs: [subcommand],
cmd: command,
} as never);
}
async function runWithCapturedOutput(
command: Awaited<ReturnType<typeof loadTelemetryCommand>>["command"],
subcommand: string,
): Promise<string> {
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");
});
});
+48 -20
View File
@@ -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<TelemetryStatusSource, "config">): 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:
@@ -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);
});
});
+5 -15
View File
@@ -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;
}
+16 -3
View File
@@ -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",
});
});
});
+24 -5
View File
@@ -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) };
}
}
+80
View File
@@ -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" });
});
});
+56
View File
@@ -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<TelemetryStatusSource, "config"> | 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" };
}
@@ -130,6 +130,7 @@ async function checkWith(registryVersion: unknown): Promise<{
const writes: Array<Record<string, unknown>> = [];
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({}),
readConfigFresh: () => ({}),
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
}));
const origFetch = globalThis.fetch;
@@ -150,6 +151,49 @@ async function checkWith(registryVersion: unknown): Promise<{
}
}
async function checkAcrossConcurrentConfigWrite(): Promise<Record<string, unknown>> {
vi.resetModules();
const initial = {
telemetryEnabled: true,
anonymousId: "test-install",
telemetryNoticeShown: true,
commandCount: 0,
renderSuccessCount: 0,
lastFeedbackPromptAt: 0,
};
let persisted: Record<string, unknown> = { ...initial };
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ ...initial }),
readConfigFresh: () => ({ ...persisted }),
writeConfig: (config: Record<string, unknown>) => {
persisted = { ...config };
return true;
},
}));
const origFetch = globalThis.fetch;
let releaseResponse: (() => void) | undefined;
const responseReady = new Promise<void>((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",
});
});
});
+9 -4
View File
@@ -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<UpdateCheckResult
}
const latest = data.version;
config.lastUpdateCheck = new Date().toISOString();
config.latestVersion = latest;
writeConfig(config);
// The registry request can take seconds. Merge its two metadata fields
// into a fresh snapshot rather than writing the object captured before
// the network call: the stale object previously reverted a concurrent
// `telemetry disable` back to the default `true`.
const freshConfig = readConfigFresh();
freshConfig.lastUpdateCheck = new Date().toISOString();
freshConfig.latestVersion = latest;
writeConfig(freshConfig);
return { current: VERSION, latest, updateAvailable: isNewerSemver(latest, VERSION) };
} catch {
+11 -2
View File
@@ -56,10 +56,19 @@ describe("studio client shouldTrack", () => {
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(true);
});
it("returns false in vite dev mode", async () => {
+3 -2
View File
@@ -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;
}