mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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
|
// `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.
|
// 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
|
// `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
|
// excluded from the self-update notice: a command that is itself actively
|
||||||
// checking/reconciling skills (`skills check`, `skills update`) must not also
|
// checking/reconciling skills (`skills check`, `skills update`) must not also
|
||||||
@@ -244,6 +246,7 @@ if (
|
|||||||
!hasJsonFlag &&
|
!hasJsonFlag &&
|
||||||
command !== "upgrade" &&
|
command !== "upgrade" &&
|
||||||
command !== "events" &&
|
command !== "events" &&
|
||||||
|
command !== "telemetry" &&
|
||||||
command !== "skills"
|
command !== "skills"
|
||||||
) {
|
) {
|
||||||
// Report any completed auto-install from the previous run first, before
|
// Report any completed auto-install from the previous run first, before
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,39 +1,67 @@
|
|||||||
import { failCommand } from "../utils/commandResult.js";
|
|
||||||
import { defineCommand } from "citty";
|
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 { c } from "../ui/colors.js";
|
||||||
|
import { failCommand } from "../utils/commandResult.js";
|
||||||
|
import type { Example } from "./_examples.js";
|
||||||
|
|
||||||
export const examples: Example[] = [
|
export const examples: Example[] = [
|
||||||
["Check current telemetry status", "hyperframes telemetry status"],
|
["Check current telemetry status", "hyperframes telemetry status"],
|
||||||
["Disable telemetry", "hyperframes telemetry disable"],
|
["Disable telemetry", "hyperframes telemetry disable"],
|
||||||
["Enable telemetry", "hyperframes telemetry enable"],
|
["Enable telemetry", "hyperframes telemetry enable"],
|
||||||
];
|
];
|
||||||
import { readConfig, writeConfig, CONFIG_PATH } from "../telemetry/config.js";
|
|
||||||
|
|
||||||
function runEnable(): void {
|
function describeOverride(source: Exclude<TelemetryStatusSource, "config">): string {
|
||||||
const config = readConfig();
|
switch (source) {
|
||||||
config.telemetryEnabled = true;
|
case "HYPERFRAMES_NO_TELEMETRY":
|
||||||
writeConfig(config);
|
case "DO_NOT_TRACK":
|
||||||
console.log(`\n ${c.success("\u2713")} Telemetry ${c.success("enabled")}\n`);
|
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 {
|
function setTelemetryEnabled(enabled: boolean): void {
|
||||||
const config = readConfig();
|
// Bypass the module cache so this privacy preference starts from the latest
|
||||||
config.telemetryEnabled = false;
|
// on-disk state rather than a snapshot held by another config consumer.
|
||||||
writeConfig(config);
|
const config = readConfigFresh();
|
||||||
console.log(`\n ${c.success("\u2713")} Telemetry ${c.bold("disabled")}\n`);
|
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 {
|
function runStatus(): void {
|
||||||
const config = readConfig();
|
const config = readConfigFresh();
|
||||||
const status = config.telemetryEnabled ? c.success("enabled") : c.dim("disabled");
|
const effective = effectiveTelemetryStatus(config.telemetryEnabled);
|
||||||
|
const status = effective.enabled ? c.success("enabled") : c.dim("disabled");
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` ${c.dim("Status:")} ${status}`);
|
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("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();
|
||||||
console.log(` ${c.dim("Disable:")} ${c.accent("hyperframes telemetry disable")}`);
|
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();
|
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")} IP addresses (discarded by our analytics provider)
|
||||||
${c.dim("\u2022")} Any personally identifiable information
|
${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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
case "enable":
|
case "enable":
|
||||||
return runEnable();
|
return setTelemetryEnabled(true);
|
||||||
case "disable":
|
case "disable":
|
||||||
return runDisable();
|
return setTelemetryEnabled(false);
|
||||||
case "status":
|
case "status":
|
||||||
return runStatus();
|
return runStatus();
|
||||||
default:
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,9 @@ import { readConfig, writeConfig } from "./config.js";
|
|||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
import { diag } from "../ui/diagnostics.js";
|
import { diag } from "../ui/diagnostics.js";
|
||||||
import { isDevMode } from "../utils/env.js";
|
|
||||||
import { getSystemMeta } from "./system.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
|
// 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.
|
* 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 {
|
export function shouldTrack(): boolean {
|
||||||
if (telemetryEnabled !== null) return telemetryEnabled;
|
if (telemetryEnabled !== null) return telemetryEnabled;
|
||||||
|
|
||||||
if (process.env["HYPERFRAMES_NO_TELEMETRY"] === "1" || process.env["DO_NOT_TRACK"] === "1") {
|
if (telemetryRuntimeOverride() !== null) {
|
||||||
telemetryEnabled = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDevMode()) {
|
|
||||||
telemetryEnabled = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safety check: ensure the API key has been configured (phc_ prefix = valid PostHog key)
|
|
||||||
if (!POSTHOG_API_KEY.startsWith("phc_")) {
|
|
||||||
telemetryEnabled = false;
|
telemetryEnabled = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module,
|
|||||||
let readConfig: typeof import("./config.js").readConfig;
|
let readConfig: typeof import("./config.js").readConfig;
|
||||||
let readConfigFresh: typeof import("./config.js").readConfigFresh;
|
let readConfigFresh: typeof import("./config.js").readConfigFresh;
|
||||||
let writeConfig: typeof import("./config.js").writeConfig;
|
let writeConfig: typeof import("./config.js").writeConfig;
|
||||||
|
let writeConfigWithResult: typeof import("./config.js").writeConfigWithResult;
|
||||||
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
|
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
|
||||||
|
|
||||||
beforeEach(async () => {
|
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
|
// module-scoped, so without this, a later test would silently inherit
|
||||||
// an earlier test's cached read.
|
// an earlier test's cached read.
|
||||||
vi.resetModules();
|
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", () => {
|
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();
|
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");
|
fsState.files.set(CONFIG_PATH, "{not valid json");
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
expect(config.telemetryEnabled).toBe(true);
|
expect(config.telemetryEnabled).toBe(false);
|
||||||
expect(config.anonymousId).toBeTruthy();
|
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 () => {
|
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");
|
throw new Error("EACCES: permission denied");
|
||||||
});
|
});
|
||||||
expect(writeConfig(config)).toBe(false);
|
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",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config directory: ~/.hyperframes/
|
// Config directory: ~/.hyperframes/
|
||||||
@@ -192,8 +193,16 @@ export function readConfig(): HyperframesConfig {
|
|||||||
cachedConfig = config;
|
cachedConfig = config;
|
||||||
return { ...config };
|
return { ...config };
|
||||||
} catch {
|
} catch {
|
||||||
// Corrupted config — reset
|
// A missing file is handled above. Any failure here means an existing
|
||||||
const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() };
|
// 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);
|
writeConfig(config);
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
@@ -229,16 +238,26 @@ export function readConfigFresh(): HyperframesConfig {
|
|||||||
* instead of re-implementing read-back verification.
|
* instead of re-implementing read-back verification.
|
||||||
*/
|
*/
|
||||||
export function writeConfig(config: HyperframesConfig): boolean {
|
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 {
|
try {
|
||||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||||
const tmpFile = `${CONFIG_FILE}.${process.pid}.tmp`;
|
const tmpFile = `${CONFIG_FILE}.${process.pid}.tmp`;
|
||||||
writeFileSync(tmpFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
writeFileSync(tmpFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
||||||
renameSync(tmpFile, CONFIG_FILE);
|
renameSync(tmpFile, CONFIG_FILE);
|
||||||
cachedConfig = { ...config };
|
cachedConfig = { ...config };
|
||||||
return true;
|
return { ok: true };
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Non-fatal — telemetry should never break the CLI
|
// Non-fatal — telemetry should never break the CLI
|
||||||
return false;
|
return { ok: false, error: normalizeErrorMessage(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>> = [];
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
vi.doMock("../telemetry/config.js", () => ({
|
vi.doMock("../telemetry/config.js", () => ({
|
||||||
readConfig: () => ({}),
|
readConfig: () => ({}),
|
||||||
|
readConfigFresh: () => ({}),
|
||||||
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
|
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
|
||||||
}));
|
}));
|
||||||
const origFetch = globalThis.fetch;
|
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
|
* U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's
|
||||||
* optional `{ deprecated: true }` is the single place that adds `_meta.deprecated`
|
* 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(typeof latest).toBe("string");
|
||||||
expect(wroteVersion).toBeUndefined();
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { compareVersions } from "compare-versions";
|
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 { VERSION } from "../version.js";
|
||||||
import { isDevMode } from "./env.js";
|
import { isDevMode } from "./env.js";
|
||||||
import { detectInstaller } from "./installerDetection.js";
|
import { detectInstaller } from "./installerDetection.js";
|
||||||
@@ -88,9 +88,14 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
|
|||||||
}
|
}
|
||||||
const latest = data.version;
|
const latest = data.version;
|
||||||
|
|
||||||
config.lastUpdateCheck = new Date().toISOString();
|
// The registry request can take seconds. Merge its two metadata fields
|
||||||
config.latestVersion = latest;
|
// into a fresh snapshot rather than writing the object captured before
|
||||||
writeConfig(config);
|
// 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) };
|
return { current: VERSION, latest, updateAvailable: isNewerSemver(latest, VERSION) };
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -56,10 +56,19 @@ describe("studio client shouldTrack", () => {
|
|||||||
expect(shouldTrack()).toBe(false);
|
expect(shouldTrack()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => {
|
it.each(["true", "TRUE", " yes ", "on"])(
|
||||||
setNoTelemetry("true");
|
"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();
|
const shouldTrack = await loadShouldTrack();
|
||||||
expect(shouldTrack()).toBe(false);
|
expect(shouldTrack()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false in vite dev mode", async () => {
|
it("returns false in vite dev mode", async () => {
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ function isApiKeyConfigured(): boolean {
|
|||||||
|
|
||||||
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
|
// 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
|
// 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).
|
// `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
|
||||||
function isBuildTimeOptOut(): boolean {
|
function isBuildTimeOptOut(): boolean {
|
||||||
try {
|
try {
|
||||||
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user