diff --git a/packages/cli/src/media-use/lib/telemetry.mjs b/packages/cli/src/media-use/lib/telemetry.mjs index 8ceb7739637..182e3505b5f 100644 --- a/packages/cli/src/media-use/lib/telemetry.mjs +++ b/packages/cli/src/media-use/lib/telemetry.mjs @@ -35,9 +35,12 @@ function posthogHost() { return override || POSTHOG_HOST; } -/** True when telemetry must NOT be sent (opt-out envs, CI, dev). */ +/** True when telemetry must NOT be sent (opt-out envs, CI, dev, shared config). */ export function optedOut() { return ( + // `hyperframes telemetry disable` writes telemetryEnabled: false to the + // shared config; one opt-out must cover the CLI and the media-use scripts. + readSharedConfig().telemetryEnabled === false || process.env.HYPERFRAMES_NO_TELEMETRY === "1" || process.env.DO_NOT_TRACK === "1" || process.env.CI === "true" || diff --git a/packages/cli/src/media-use/lib/telemetry.test.mjs b/packages/cli/src/media-use/lib/telemetry.test.mjs index f534210ea11..19db85a4cb0 100644 --- a/packages/cli/src/media-use/lib/telemetry.test.mjs +++ b/packages/cli/src/media-use/lib/telemetry.test.mjs @@ -57,6 +57,26 @@ test("optedOut respects DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI", () => { } }); +test("optedOut honors telemetryEnabled: false from the shared config", () => { + const savedEnv = { ...process.env }; + const { root, home } = sandbox(); + try { + withoutTelemetryOptOut(); + const configPath = join(home, ".hyperframes", "config.json"); + assert.equal(optedOut(), false, "no config: tracking allowed"); + mkdirSync(join(home, ".hyperframes"), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ telemetryEnabled: false })); + assert.equal(optedOut(), true, "telemetryEnabled: false opts out"); + writeFileSync(configPath, JSON.stringify({ telemetryEnabled: true })); + assert.equal(optedOut(), false, "telemetryEnabled: true allows tracking"); + writeFileSync(configPath, "{not json"); + assert.equal(optedOut(), false, "unreadable config: tracking allowed"); + } finally { + restoreEnv(savedEnv); + rmSync(root, { recursive: true, force: true }); + } +}); + test("track is a no-op (no network, resolves) when opted out", async () => { const savedEnv = { ...process.env }; const originalFetch = globalThis.fetch; diff --git a/skills-manifest.json b/skills-manifest.json index 2e5c615a501..afc52f5b0dd 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,8 +54,8 @@ "files": 1 }, "media-use": { - "hash": "efb4902546396bfb", - "files": 111 + "hash": "0b6e01d1fecb39d6", + "files": 112 }, "motion-graphics": { "hash": "6db77031ad839f6a", diff --git a/skills/media-use/scripts/lib/telemetry.mjs b/skills/media-use/scripts/lib/telemetry.mjs index 8ceb7739637..182e3505b5f 100644 --- a/skills/media-use/scripts/lib/telemetry.mjs +++ b/skills/media-use/scripts/lib/telemetry.mjs @@ -35,9 +35,12 @@ function posthogHost() { return override || POSTHOG_HOST; } -/** True when telemetry must NOT be sent (opt-out envs, CI, dev). */ +/** True when telemetry must NOT be sent (opt-out envs, CI, dev, shared config). */ export function optedOut() { return ( + // `hyperframes telemetry disable` writes telemetryEnabled: false to the + // shared config; one opt-out must cover the CLI and the media-use scripts. + readSharedConfig().telemetryEnabled === false || process.env.HYPERFRAMES_NO_TELEMETRY === "1" || process.env.DO_NOT_TRACK === "1" || process.env.CI === "true" || diff --git a/skills/media-use/scripts/lib/telemetry.test.mjs b/skills/media-use/scripts/lib/telemetry.test.mjs new file mode 100644 index 00000000000..4a0c541ee1c --- /dev/null +++ b/skills/media-use/scripts/lib/telemetry.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { optedOut } from "./telemetry.mjs"; + +// optedOut() reads ~/.hyperframes/config.json and env vars. Point HOME at a temp +// dir so the run never touches the real config, and pin the env opt-outs to a +// neutral state; restore both afterwards. +const ENV_KEYS = [ + "HOME", + "USERPROFILE", + "HYPERFRAMES_NO_TELEMETRY", + "DO_NOT_TRACK", + "CI", + "NODE_ENV", +]; +const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); +const home = mkdtempSync(join(tmpdir(), "mu-telemetry-")); +const configPath = join(home, ".hyperframes", "config.json"); + +function writeConfig(contents) { + if (contents === null) { + rmSync(configPath, { force: true }); + } else { + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, contents); + } +} + +test.before(() => { + for (const key of ENV_KEYS) delete process.env[key]; + process.env.HOME = process.env.USERPROFILE = home; +}); + +test.after(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + rmSync(home, { recursive: true, force: true }); +}); + +test("optedOut is false with no config and no opt-out env", () => { + writeConfig(null); + assert.equal(optedOut(), false); +}); + +test("optedOut honors telemetryEnabled: false from the shared config", () => { + writeConfig(JSON.stringify({ telemetryEnabled: false })); + assert.equal(optedOut(), true); +}); + +test("optedOut is false when the shared config enables telemetry", () => { + writeConfig(JSON.stringify({ telemetryEnabled: true })); + assert.equal(optedOut(), false); +}); + +test("optedOut treats an unreadable shared config as no opt-out", () => { + writeConfig("{not json"); + assert.equal(optedOut(), false); +}); + +test("optedOut still honors the env opt-outs", () => { + writeConfig(JSON.stringify({ telemetryEnabled: true })); + process.env.DO_NOT_TRACK = "1"; + try { + assert.equal(optedOut(), true); + } finally { + delete process.env.DO_NOT_TRACK; + } +});