From f738c8cb289addda616ea7ba8d769667d4ba6aa3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 16:48:35 -0500 Subject: [PATCH 1/6] feat: telemetry opt-out command with persisted preference Add `workos telemetry opt-out / opt-in / status` backed by a plain ~/.workos/preferences.json store. The opt-out flag folds into the single Analytics.isEnabled() gate, suppressing all telemetry events when set. WORKOS_TELEMETRY now resolves at call time as a tri-state override: only 'true'/'false' override the saved preference (in both directions); any other value falls through to the preference. Replaces the import-time WORKOS_TELEMETRY_ENABLED const with isTelemetryEnabled(). - New preferences store (sync accessor + async prewarm, never-throws read path, write path surfaces failures so opt-out/opt-in report non-persist) - telemetry command registered in bin.ts + help-json registry; added to SKIP_TELEMETRY_COMMANDS so managing the preference emits no telemetry - Prewarm loadPreferences() before analytics.initForNonInstaller() - Unit specs (store + handlers) and integration coverage proving opt-out emits zero events and env precedence holds in both directions --- src/bin-command-telemetry.integration.spec.ts | 42 ++- src/bin.ts | 43 +++- src/commands/telemetry.spec.ts | 151 +++++++++++ src/commands/telemetry.ts | 102 ++++++++ src/lib/constants.ts | 1 - src/lib/preferences.spec.ts | 243 ++++++++++++++++++ src/lib/preferences.ts | 186 ++++++++++++++ src/utils/analytics.spec.ts | 23 +- src/utils/analytics.ts | 4 +- src/utils/command-telemetry.ts | 2 +- src/utils/help-json.ts | 9 + 11 files changed, 795 insertions(+), 11 deletions(-) create mode 100644 src/commands/telemetry.spec.ts create mode 100644 src/commands/telemetry.ts create mode 100644 src/lib/preferences.spec.ts create mode 100644 src/lib/preferences.ts diff --git a/src/bin-command-telemetry.integration.spec.ts b/src/bin-command-telemetry.integration.spec.ts index 585a0cf8..a3600d2e 100644 --- a/src/bin-command-telemetry.integration.spec.ts +++ b/src/bin-command-telemetry.integration.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -38,7 +38,14 @@ afterEach(() => { rmSync(sandboxTmp, { recursive: true, force: true }); }); -function runCli(args: string[]) { +/** Seed ~/.workos/preferences.json inside the sandboxed HOME before a run. */ +function seedPreferences(prefs: unknown): void { + const workosDir = join(sandboxTmp, '.workos'); + mkdirSync(workosDir, { recursive: true }); + writeFileSync(join(workosDir, 'preferences.json'), JSON.stringify(prefs), 'utf-8'); +} + +function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) { const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: sandboxTmp, @@ -49,12 +56,14 @@ function runCli(args: string[]) { // Keep prompts/update checks disabled without inheriting host agent/CI env. WORKOS_MODE: 'agent', // Force telemetry on so a host WORKOS_TELEMETRY=false can't make the test - // silently produce no event and fail. + // silently produce no event and fail. Tests that exercise env precedence + // override this explicitly via envOverrides. WORKOS_TELEMETRY: 'true', // Unroutable URL: the flush fails, so the queued events are persisted to // the pending file on exit where we can inspect the real payload. WORKOS_TELEMETRY_URL: 'http://127.0.0.1:59999/cli', WORKOS_API_KEY: 'sk_dummy_for_test', + ...envOverrides, }; const result = spawnSync( @@ -69,7 +78,13 @@ function runCli(args: string[]) { const events: Array<{ type: string; attributes?: Record }> = []; const pendingDir = join(sandboxTmp, 'workos-cli-telemetry'); - for (const file of readdirSync(pendingDir, { withFileTypes: true })) { + let entries: ReturnType = []; + try { + entries = readdirSync(pendingDir, { withFileTypes: true }); + } catch { + // No pending dir => no events were ever queued (e.g. opted out). + } + for (const file of entries) { if (file.isFile() && file.name.startsWith('pending-') && file.name.endsWith('.json')) { events.push(...JSON.parse(readFileSync(join(pendingDir, file.name), 'utf-8'))); } @@ -119,4 +134,23 @@ describe('command telemetry lifecycle', () => { expect(stack).not.toMatch(/\/Users\/[^/]+\//); // POSIX home dir collapsed to ~ expect(stack).not.toContain(repoRoot); }, 20_000); + + it('emits zero events when the saved preference is opted out', () => { + seedPreferences({ telemetry: { optedOut: true } }); + // Clear the forced WORKOS_TELEMETRY so the saved preference is honored + // (an empty string is not the tri-state 'true'/'false', so it falls through). + const { events } = runCli(['organization', 'create'], { WORKOS_TELEMETRY: '' }); + expect(events).toHaveLength(0); + }, 20_000); + + it('env WORKOS_TELEMETRY=true overrides an opted-out preference (event IS emitted)', () => { + seedPreferences({ telemetry: { optedOut: true } }); + const { events } = runCli(['organization', 'create'], { WORKOS_TELEMETRY: 'true' }); + expect(events.find((e) => e.type === 'command')).toBeDefined(); + }, 20_000); + + it('env WORKOS_TELEMETRY=false suppresses events even when not opted out', () => { + const { events } = runCli(['organization', 'create'], { WORKOS_TELEMETRY: 'false' }); + expect(events).toHaveLength(0); + }, 20_000); }); diff --git a/src/bin.ts b/src/bin.ts index 76f0508f..e2678ed0 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -48,6 +48,7 @@ import { registerSubcommand } from './utils/register-subcommand.js'; import { installCrashReporter, sanitizeMessage } from './utils/crash-reporter.js'; import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js'; import { loadDeviceId } from './lib/device-id.js'; +import { loadPreferences, isTelemetryEnabled } from './lib/preferences.js'; import { resolveCanonicalName, resolveCommandNameFromRawArgs, @@ -56,7 +57,6 @@ import { } from './utils/command-telemetry.js'; import { CliExit } from './utils/cli-exit.js'; import { telemetryClient } from './utils/telemetry-client.js'; -import { WORKOS_TELEMETRY_ENABLED } from './lib/constants.js'; import { ExitCode } from './utils/exit-codes.js'; import { analytics } from './utils/analytics.js'; @@ -71,6 +71,12 @@ if (process.env.WORKOS_DEBUG === '1') { // Must be before yargs so crashes during startup are captured. installCrashReporter(); installStoreForward(); +// Prewarm the telemetry opt-out preference before init: initForNonInstaller() +// checks isEnabled() (which reads the preference), and session/command events +// may fire shortly after. The sync getPreferences() fallback makes correctness +// ordering-independent, but prewarming keeps the synchronous event path off +// blocking fs IO (same rationale as the device-id prewarm). +await loadPreferences(); analytics.initForNonInstaller(); // Prewarm the device id off the blocking-fs path so the synchronous telemetry // event path reads it from cache. Cheap (a tiny file read); awaited so it is @@ -337,6 +343,39 @@ async function runCli(): Promise { ); return yargs.demandCommand(1, 'Please specify an auth subcommand').strict(); }) + .command('telemetry', 'Manage telemetry collection (opt-out, opt-in, status)', (yargs) => { + registerSubcommand( + yargs, + 'opt-out', + 'Disable telemetry collection (persists across runs)', + (y) => y, + async () => { + const { runTelemetryOptOut } = await import('./commands/telemetry.js'); + await runTelemetryOptOut(); + }, + ); + registerSubcommand( + yargs, + 'opt-in', + 'Re-enable telemetry collection', + (y) => y, + async () => { + const { runTelemetryOptIn } = await import('./commands/telemetry.js'); + await runTelemetryOptIn(); + }, + ); + registerSubcommand( + yargs, + 'status', + 'Show whether telemetry is enabled and why', + (y) => y, + async () => { + const { runTelemetryStatus } = await import('./commands/telemetry.js'); + await runTelemetryStatus(); + }, + ); + return yargs.demandCommand(1, 'Please specify a telemetry subcommand').strict(); + }) .command('skills', 'Manage WorkOS skills for coding agents (Claude Code, Codex, Cursor, Goose)', (yargs) => { registerSubcommand( yargs, @@ -2575,7 +2614,7 @@ async function runCli(): Promise { .alias('version', 'v') .wrap(process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : 80); - const shouldSkipTelemetry = () => !WORKOS_TELEMETRY_ENABLED || SKIP_TELEMETRY_COMMANDS.has(commandName.split('.')[0]); + const shouldSkipTelemetry = () => !isTelemetryEnabled() || SKIP_TELEMETRY_COMMANDS.has(commandName.split('.')[0]); let commandOutcome: | { success: boolean; diff --git a/src/commands/telemetry.spec.ts b/src/commands/telemetry.spec.ts new file mode 100644 index 00000000..8a32dbde --- /dev/null +++ b/src/commands/telemetry.spec.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSetTelemetryOptedOut = vi.fn(); +const mockIsTelemetryOptedOut = vi.fn(); +const mockIsTelemetryEnabled = vi.fn(); +const mockGetTelemetrySource = vi.fn(); +const mockEnvTelemetryOverride = vi.fn(); + +vi.mock('../lib/preferences.js', () => ({ + setTelemetryOptedOut: (v: boolean) => mockSetTelemetryOptedOut(v), + isTelemetryOptedOut: () => mockIsTelemetryOptedOut(), + isTelemetryEnabled: () => mockIsTelemetryEnabled(), + getTelemetrySource: () => mockGetTelemetrySource(), + envTelemetryOverride: () => mockEnvTelemetryOverride(), +})); + +// Keep human-mode confirmation lines stable regardless of host env. +vi.mock('../utils/command-invocation.js', () => ({ + formatWorkOSCommand: (args: string) => `workos ${args}`, +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runTelemetryOptOut, runTelemetryOptIn, runTelemetryStatus } = await import('./telemetry.js'); + +describe('telemetry commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + // clearAllMocks wipes call history but not implementations set with + // mockImplementation, so reset the write mock to a no-op each test. + mockSetTelemetryOptedOut.mockReset(); + // Sensible defaults; individual tests override. + mockIsTelemetryOptedOut.mockReturnValue(false); + mockIsTelemetryEnabled.mockReturnValue(true); + mockGetTelemetrySource.mockReturnValue('default'); + mockEnvTelemetryOverride.mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + describe('runTelemetryOptOut', () => { + it('persists optedOut=true and confirms', async () => { + mockIsTelemetryOptedOut.mockReturnValue(false); + await runTelemetryOptOut(); + expect(mockSetTelemetryOptedOut).toHaveBeenCalledWith(true); + expect(consoleOutput.some((l) => l.includes('disabled'))).toBe(true); + }); + + it('is idempotent and honest when already opted out', async () => { + mockIsTelemetryOptedOut.mockReturnValue(true); + await runTelemetryOptOut(); + expect(mockSetTelemetryOptedOut).toHaveBeenCalledWith(true); + expect(consoleOutput.some((l) => l.includes('already opted out'))).toBe(true); + }); + + it('surfaces a CliExit error when the write fails', async () => { + mockIsTelemetryOptedOut.mockReturnValue(false); + mockSetTelemetryOptedOut.mockImplementation(() => { + throw new Error('EROFS'); + }); + await expect(runTelemetryOptOut()).rejects.toBeInstanceOf(CliExit); + }); + + it('outputs JSON in json mode', async () => { + setOutputMode('json'); + mockIsTelemetryOptedOut.mockReturnValue(false); + await runTelemetryOptOut(); + const out = JSON.parse(consoleOutput[0]); + expect(out).toEqual({ status: 'ok', optedOut: true, alreadyOptedOut: false }); + }); + }); + + describe('runTelemetryOptIn', () => { + it('persists optedOut=false and confirms re-enable', async () => { + mockIsTelemetryOptedOut.mockReturnValue(true); + await runTelemetryOptIn(); + expect(mockSetTelemetryOptedOut).toHaveBeenCalledWith(false); + expect(consoleOutput.some((l) => l.includes('re-enabled'))).toBe(true); + }); + + it('is honest when already opted in', async () => { + mockIsTelemetryOptedOut.mockReturnValue(false); + await runTelemetryOptIn(); + expect(mockSetTelemetryOptedOut).toHaveBeenCalledWith(false); + expect(consoleOutput.some((l) => l.includes('already enabled'))).toBe(true); + }); + + it('outputs JSON in json mode', async () => { + setOutputMode('json'); + mockIsTelemetryOptedOut.mockReturnValue(true); + await runTelemetryOptIn(); + const out = JSON.parse(consoleOutput[0]); + expect(out).toEqual({ status: 'ok', optedOut: false, alreadyOptedIn: false }); + }); + }); + + describe('runTelemetryStatus', () => { + it('reports source "preference" / disabled when opted out', async () => { + mockIsTelemetryEnabled.mockReturnValue(false); + mockIsTelemetryOptedOut.mockReturnValue(true); + mockGetTelemetrySource.mockReturnValue('preference'); + mockEnvTelemetryOverride.mockReturnValue(undefined); + await runTelemetryStatus(); + expect(consoleOutput.some((l) => l.includes('disabled'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('saved preference'))).toBe(true); + }); + + it('reports source "env" / enabled when env overrides an opt-out', async () => { + mockIsTelemetryEnabled.mockReturnValue(true); + mockIsTelemetryOptedOut.mockReturnValue(true); + mockGetTelemetrySource.mockReturnValue('env'); + mockEnvTelemetryOverride.mockReturnValue(true); + await runTelemetryStatus(); + expect(consoleOutput.some((l) => l.includes('enabled'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('environment variable'))).toBe(true); + }); + + it('emits the documented JSON shape', async () => { + setOutputMode('json'); + mockIsTelemetryEnabled.mockReturnValue(false); + mockIsTelemetryOptedOut.mockReturnValue(true); + mockGetTelemetrySource.mockReturnValue('preference'); + mockEnvTelemetryOverride.mockReturnValue(undefined); + await runTelemetryStatus(); + const out = JSON.parse(consoleOutput[0]); + expect(out).toEqual({ enabled: false, optedOut: true, source: 'preference', envOverride: null }); + }); + + it('serializes a boolean envOverride in JSON', async () => { + setOutputMode('json'); + mockIsTelemetryEnabled.mockReturnValue(true); + mockIsTelemetryOptedOut.mockReturnValue(true); + mockGetTelemetrySource.mockReturnValue('env'); + mockEnvTelemetryOverride.mockReturnValue(true); + await runTelemetryStatus(); + const out = JSON.parse(consoleOutput[0]); + expect(out.source).toBe('env'); + expect(out.envOverride).toBe(true); + }); + }); +}); diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts new file mode 100644 index 00000000..5514853e --- /dev/null +++ b/src/commands/telemetry.ts @@ -0,0 +1,102 @@ +import chalk from 'chalk'; +import { + envTelemetryOverride, + getTelemetrySource, + isTelemetryEnabled, + isTelemetryOptedOut, + setTelemetryOptedOut, + type TelemetrySource, +} from '../lib/preferences.js'; +import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +/** + * Persist an opt-out/opt-in change, surfacing a clear error if the write fails + * (read-only FS, permission denied). Unlike the read path, the command path + * must NOT swallow a write failure — otherwise the user believes their + * preference persisted when it did not. + */ +function persistOptedOut(value: boolean): void { + try { + setTelemetryOptedOut(value); + } catch { + exitWithError({ + code: 'internal_error', + message: `Could not save telemetry preference to disk. Your preference was NOT persisted.`, + }); + } +} + +export async function runTelemetryOptOut(): Promise { + const alreadyOptedOut = isTelemetryOptedOut(); + persistOptedOut(true); + + if (isJsonMode()) { + outputJson({ status: 'ok', optedOut: true, alreadyOptedOut }); + return; + } + + if (alreadyOptedOut) { + console.log(chalk.green('Telemetry collection is already opted out.')); + } else { + console.log(chalk.green('Telemetry collection disabled. No further events will be sent.')); + } + console.log(chalk.dim(`Re-enable any time with \`${formatWorkOSCommand('telemetry opt-in')}\`.`)); +} + +export async function runTelemetryOptIn(): Promise { + const wasOptedOut = isTelemetryOptedOut(); + persistOptedOut(false); + + if (isJsonMode()) { + outputJson({ status: 'ok', optedOut: false, alreadyOptedIn: !wasOptedOut }); + return; + } + + if (wasOptedOut) { + console.log(chalk.green('Telemetry collection re-enabled.')); + } else { + console.log(chalk.green('Telemetry collection is already enabled.')); + } + console.log(chalk.dim(`Opt out any time with \`${formatWorkOSCommand('telemetry opt-out')}\`.`)); +} + +function describeSource(source: TelemetrySource): string { + switch (source) { + case 'env': + return 'WORKOS_TELEMETRY environment variable'; + case 'preference': + return 'saved preference'; + case 'default': + return 'default'; + } +} + +export async function runTelemetryStatus(): Promise { + const enabled = isTelemetryEnabled(); + const optedOut = isTelemetryOptedOut(); + const source = getTelemetrySource(); + const override = envTelemetryOverride(); + + if (isJsonMode()) { + outputJson({ + enabled, + optedOut, + source, + envOverride: override ?? null, + }); + return; + } + + const stateLine = enabled + ? chalk.green('Telemetry collection is enabled.') + : chalk.yellow('Telemetry collection is disabled.'); + console.log(stateLine); + console.log(chalk.dim(`Source: ${describeSource(source)}.`)); + + if (enabled) { + console.log(chalk.dim(`Opt out with \`${formatWorkOSCommand('telemetry opt-out')}\`.`)); + } else { + console.log(chalk.dim(`Re-enable with \`${formatWorkOSCommand('telemetry opt-in')}\`.`)); + } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cc05e970..f16189f8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -34,7 +34,6 @@ export const WORKOS_DASHBOARD_URL = settings.documentation.dashboardUrl; export const ISSUES_URL = settings.documentation.issuesUrl; export const ANALYTICS_ENABLED = settings.telemetry.enabled; export const INSTALLER_INTERACTION_EVENT_NAME = settings.telemetry.eventName; -export const WORKOS_TELEMETRY_ENABLED = process.env.WORKOS_TELEMETRY !== 'false'; export const OAUTH_PORT = settings.legacy.oauthPort; /** diff --git a/src/lib/preferences.spec.ts b/src/lib/preferences.spec.ts new file mode 100644 index 00000000..131a9d1d --- /dev/null +++ b/src/lib/preferences.spec.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync, chmodSync, mkdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mutable testDir rebound in beforeEach; mock closes over it. +let testDir: string; + +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + default: { + ...original, + homedir: () => testDir, + }, + homedir: () => testDir, + }; +}); + +const { + getPreferences, + loadPreferences, + savePreferences, + isTelemetryOptedOut, + setTelemetryOptedOut, + envTelemetryOverride, + isTelemetryEnabled, + getTelemetrySource, + getPreferencesPath, + __resetPreferencesCache, +} = await import('./preferences.js'); + +const originalTelemetryEnv = process.env.WORKOS_TELEMETRY; + +function writePrefs(value: unknown): void { + const workosDir = join(testDir, '.workos'); + mkdirSync(workosDir, { recursive: true }); + writeFileSync(join(workosDir, 'preferences.json'), JSON.stringify(value), 'utf8'); +} + +function writeRawPrefs(raw: string): void { + const workosDir = join(testDir, '.workos'); + mkdirSync(workosDir, { recursive: true }); + writeFileSync(join(workosDir, 'preferences.json'), raw, 'utf8'); +} + +describe('preferences', () => { + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'preferences-test-')); + __resetPreferencesCache(); + delete process.env.WORKOS_TELEMETRY; + }); + + afterEach(() => { + try { + chmodSync(join(testDir, '.workos'), 0o700); + } catch { + // ignore — dir may not exist or already writable + } + try { + chmodSync(testDir, 0o700); + } catch { + // ignore + } + rmSync(testDir, { recursive: true, force: true }); + if (originalTelemetryEnv !== undefined) { + process.env.WORKOS_TELEMETRY = originalTelemetryEnv; + } else { + delete process.env.WORKOS_TELEMETRY; + } + }); + + describe('getPreferences', () => { + it('returns {} when no file exists', () => { + expect(getPreferences()).toEqual({}); + }); + + it('reads a saved preferences object', () => { + writePrefs({ telemetry: { optedOut: true } }); + expect(getPreferences()).toEqual({ telemetry: { optedOut: true } }); + }); + + it('returns {} on corrupt JSON (does not throw, does not delete)', () => { + writeRawPrefs('{ this is not json'); + expect(getPreferences()).toEqual({}); + // File is left intact for a later clean overwrite. + expect(existsSync(getPreferencesPath())).toBe(true); + }); + + it('returns {} when the file parses to a non-object', () => { + writeRawPrefs('"true"'); + expect(getPreferences()).toEqual({}); + }); + + it('caches after the first read', () => { + writePrefs({ telemetry: { optedOut: true } }); + expect(getPreferences()).toEqual({ telemetry: { optedOut: true } }); + // Mutate the file on disk; the cached value should NOT change in-process. + writePrefs({ telemetry: { optedOut: false } }); + expect(getPreferences()).toEqual({ telemetry: { optedOut: true } }); + }); + }); + + describe('loadPreferences (async prewarm)', () => { + it('resolves to {} when no file exists', async () => { + await expect(loadPreferences()).resolves.toEqual({}); + }); + + it('warms the cache the synchronous getPreferences() reads', async () => { + writePrefs({ telemetry: { optedOut: true } }); + const loaded = await loadPreferences(); + expect(loaded).toEqual({ telemetry: { optedOut: true } }); + expect(getPreferences()).toEqual({ telemetry: { optedOut: true } }); + }); + + it('memoizes concurrent callers to a single value', async () => { + writePrefs({ telemetry: { optedOut: true } }); + const [a, b] = await Promise.all([loadPreferences(), loadPreferences()]); + expect(a).toBe(b); + }); + + it('never rejects on corrupt JSON', async () => { + writeRawPrefs('not json'); + await expect(loadPreferences()).resolves.toEqual({}); + }); + }); + + describe('savePreferences / setTelemetryOptedOut', () => { + it('round-trips opt-out true then opt-in false', () => { + setTelemetryOptedOut(true); + expect(isTelemetryOptedOut()).toBe(true); + expect(isTelemetryEnabled()).toBe(false); + + setTelemetryOptedOut(false); + expect(isTelemetryOptedOut()).toBe(false); + expect(isTelemetryEnabled()).toBe(true); + }); + + it('writes the file with mode 0o600', () => { + setTelemetryOptedOut(true); + const mode = statSync(getPreferencesPath()).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('overwrites a corrupt file cleanly', () => { + writeRawPrefs('garbage'); + setTelemetryOptedOut(true); + expect(JSON.parse(readFileSync(getPreferencesPath(), 'utf8'))).toEqual({ telemetry: { optedOut: true } }); + }); + + it('preserves unrelated existing fields (read-modify-write)', () => { + // Simulate a Phase 2 field already on disk. + writePrefs({ telemetry: { noticeShownAt: '2026-01-01T00:00:00.000Z' } }); + __resetPreferencesCache(); + setTelemetryOptedOut(true); + const onDisk = JSON.parse(readFileSync(getPreferencesPath(), 'utf8')); + expect(onDisk.telemetry.optedOut).toBe(true); + expect(onDisk.telemetry.noticeShownAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('throws when the filesystem is read-only', () => { + chmodSync(testDir, 0o500); + expect(() => setTelemetryOptedOut(true)).toThrow(); + }); + + it('updates the in-memory cache after a write', () => { + setTelemetryOptedOut(true); + // No reset — the cache should reflect the write without re-reading disk. + expect(getPreferences().telemetry?.optedOut).toBe(true); + }); + }); + + describe('envTelemetryOverride (tri-state)', () => { + it('returns true for "true"', () => { + process.env.WORKOS_TELEMETRY = 'true'; + expect(envTelemetryOverride()).toBe(true); + }); + + it('returns false for "false"', () => { + process.env.WORKOS_TELEMETRY = 'false'; + expect(envTelemetryOverride()).toBe(false); + }); + + it('returns undefined when unset', () => { + delete process.env.WORKOS_TELEMETRY; + expect(envTelemetryOverride()).toBeUndefined(); + }); + + it('returns undefined for garbage like "1" (falls through to preference)', () => { + process.env.WORKOS_TELEMETRY = '1'; + expect(envTelemetryOverride()).toBeUndefined(); + }); + }); + + describe('isTelemetryEnabled — env overrides preference in both directions', () => { + // prefs ∈ {opted-out, not} × env ∈ {unset, 'true', 'false', '1'} + const cases: Array<{ optedOut: boolean; env: string | undefined; expected: boolean }> = [ + { optedOut: false, env: undefined, expected: true }, + { optedOut: false, env: 'true', expected: true }, + { optedOut: false, env: 'false', expected: false }, // env disables even when opted in + { optedOut: false, env: '1', expected: true }, + { optedOut: true, env: undefined, expected: false }, + { optedOut: true, env: 'true', expected: true }, // env enables even when opted out + { optedOut: true, env: 'false', expected: false }, + { optedOut: true, env: '1', expected: false }, // garbage falls through to opt-out + ]; + + for (const { optedOut, env, expected } of cases) { + it(`optedOut=${optedOut}, WORKOS_TELEMETRY=${env ?? 'unset'} => ${expected}`, () => { + if (optedOut) setTelemetryOptedOut(true); + if (env === undefined) delete process.env.WORKOS_TELEMETRY; + else process.env.WORKOS_TELEMETRY = env; + expect(isTelemetryEnabled()).toBe(expected); + }); + } + }); + + describe('getTelemetrySource', () => { + it('is "env" when WORKOS_TELEMETRY is explicitly set', () => { + process.env.WORKOS_TELEMETRY = 'true'; + setTelemetryOptedOut(true); + expect(getTelemetrySource()).toBe('env'); + }); + + it('is "preference" when only the opt-out flag is set', () => { + delete process.env.WORKOS_TELEMETRY; + setTelemetryOptedOut(true); + expect(getTelemetrySource()).toBe('preference'); + }); + + it('is "preference" when the flag is explicitly false', () => { + delete process.env.WORKOS_TELEMETRY; + setTelemetryOptedOut(false); + expect(getTelemetrySource()).toBe('preference'); + }); + + it('is "default" when nothing is set', () => { + delete process.env.WORKOS_TELEMETRY; + expect(getTelemetrySource()).toBe('default'); + }); + }); +}); diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts new file mode 100644 index 00000000..1abe3883 --- /dev/null +++ b/src/lib/preferences.ts @@ -0,0 +1,186 @@ +/** + * Plain CLI preferences store. + * + * Stored at ~/.workos/preferences.json as plain JSON. These are NOT secrets — + * knowing that someone opted out of telemetry leaks nothing — so this + * deliberately avoids the keyring abstraction (config-store.ts) to prevent a + * non-secret write from ever triggering the insecure-fallback security warning. + * + * Mirrors the structural pattern of device-id.ts: a synchronous accessor backed + * by a cache, an async prewarm that populates that cache off the blocking-fs + * path, and a never-throws contract on every read/parse so a corrupt file can + * never break a command. + */ + +import fs from 'node:fs'; +import { mkdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +export interface CliPreferences { + telemetry?: { + /** true => the user explicitly opted out of telemetry. */ + optedOut?: boolean; + /** ISO timestamp the first-run notice was shown — written in Phase 2 only. */ + noticeShownAt?: string; + }; +} + +/** Effective source of the resolved telemetry-enabled decision. */ +export type TelemetrySource = 'env' | 'preference' | 'default'; + +let cached: CliPreferences | undefined; +let pending: Promise | undefined; + +export function getPreferencesPath(): string { + return path.join(os.homedir(), '.workos', 'preferences.json'); +} + +function parsePreferences(raw: string): CliPreferences { + try { + const value = JSON.parse(raw); + // Defend against a file that parses to a non-object (e.g. `"true"`, `42`). + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as CliPreferences; + } + } catch { + // Corrupt JSON — fall through to empty preferences. A later savePreferences + // overwrites it cleanly; we never auto-delete. + } + return {}; +} + +/** + * Asynchronously load preferences and warm the cache the synchronous + * getPreferences() reads. Memoized: the first call performs the IO, concurrent + * and later callers await the same promise. Prewarming this at startup keeps + * the synchronous telemetry path off blocking fs IO. Never rejects. + */ +export function loadPreferences(): Promise { + if (cached) return Promise.resolve(cached); + if (pending) return pending; + + pending = (async () => { + try { + const raw = await readFile(getPreferencesPath(), 'utf8'); + cached = parsePreferences(raw); + } catch { + // Missing/unreadable file — treat as no preferences set. + cached = {}; + } finally { + pending = undefined; + } + return cached; + })(); + + return pending; +} + +/** + * Synchronous accessor. Returns the prewarmed value when loadPreferences() has + * run; otherwise performs a one-time synchronous read of the same file. On any + * IO/parse failure returns {} (telemetry stays at its default-on state). Never + * throws. + */ +export function getPreferences(): CliPreferences { + if (cached) return cached; + + try { + const raw = fs.readFileSync(getPreferencesPath(), 'utf8'); + cached = parsePreferences(raw); + } catch { + // Missing/unreadable/corrupt — telemetry stays at its default-on state. + cached = {}; + } + return cached; +} + +/** + * Read-modify-write the on-disk preferences, merging `next` over the current + * persisted value so future fields are never clobbered, then update the cache. + * Throws on write failure so callers on the command path (opt-out/opt-in) can + * surface a clear error — the read path swallows, the write path does not. + */ +export function savePreferences(next: CliPreferences): void { + const filePath = getPreferencesPath(); + + // Read-modify-write over the CURRENT on-disk value (not the cache) so a field + // written by another process / a future phase is preserved. + let current: CliPreferences = {}; + try { + current = parsePreferences(fs.readFileSync(filePath, 'utf8')); + } catch { + // No existing file (or unreadable) — start from empty. + } + + const merged: CliPreferences = { + ...current, + ...next, + ...(current.telemetry || next.telemetry + ? { telemetry: { ...current.telemetry, ...next.telemetry } } + : {}), + }; + + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, JSON.stringify(merged), { encoding: 'utf8', mode: 0o600 }); + cached = merged; +} + +/** Whether the user has explicitly opted out via the saved preference. */ +export function isTelemetryOptedOut(): boolean { + return getPreferences().telemetry?.optedOut === true; +} + +/** Persist the opt-out flag. Throws on write failure (see savePreferences). */ +export function setTelemetryOptedOut(value: boolean): void { + savePreferences({ telemetry: { optedOut: value } }); +} + +/** + * Tri-state env override for telemetry. + * + * Only the explicit strings 'true' / 'false' count as an override. Any other + * value — including unset or garbage like '1' — returns undefined and falls + * through to the saved preference. + * + * This is a deliberate, documented change from the old `WORKOS_TELEMETRY !== + * 'false'` behaviour: previously `WORKOS_TELEMETRY=1` forced telemetry on even + * for opted-out users; now an opt-out is respected unless the env var + * explicitly says 'true'. + */ +export function envTelemetryOverride(): boolean | undefined { + const value = process.env.WORKOS_TELEMETRY; + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; +} + +/** + * Effective telemetry-enabled decision. + * + * Resolution order: + * 1. envTelemetryOverride() if defined — env wins in BOTH directions. + * 2. otherwise !isTelemetryOptedOut() — default-on unless explicitly opted out. + */ +export function isTelemetryEnabled(): boolean { + const override = envTelemetryOverride(); + if (override !== undefined) return override; + return !isTelemetryOptedOut(); +} + +/** + * Which signal produced the effective telemetry decision. Mirrors the + * precedence in isTelemetryEnabled() so the `telemetry status` command and the + * resolver can never drift. + */ +export function getTelemetrySource(): TelemetrySource { + if (envTelemetryOverride() !== undefined) return 'env'; + if (getPreferences().telemetry?.optedOut !== undefined) return 'preference'; + return 'default'; +} + +/** Test seam — resets the in-memory cache between test cases. */ +export function __resetPreferencesCache(): void { + cached = undefined; + pending = undefined; +} diff --git a/src/utils/analytics.spec.ts b/src/utils/analytics.spec.ts index 277a35d1..08e8ea88 100644 --- a/src/utils/analytics.spec.ts +++ b/src/utils/analytics.spec.ts @@ -35,6 +35,20 @@ vi.mock('../lib/device-id.js', () => ({ getDeviceId: () => TEST_DEVICE_ID, })); +// Resolve the telemetry kill switch from the env var alone so tests stay +// deterministic regardless of any preferences.json on the dev machine. +// Mirrors the real tri-state precedence: explicit 'true'/'false' override, +// otherwise default-on (no opt-out preference in tests). +const isTelemetryEnabledFromEnv = () => { + const value = process.env.WORKOS_TELEMETRY; + if (value === 'true') return true; + if (value === 'false') return false; + return true; +}; +vi.mock('../lib/preferences.js', () => ({ + isTelemetryEnabled: () => isTelemetryEnabledFromEnv(), +})); + // Mock settings for initForNonInstaller const mockGetTelemetryUrl = vi.fn(() => 'https://api.workos.com/cli'); const mockSettingsConfig = { @@ -69,7 +83,8 @@ vi.mock('../lib/config-store.js', () => ({ })); describe('Analytics', () => { - // Need to handle WORKOS_TELEMETRY_ENABLED which is evaluated at import time + // isEnabled() now resolves via isTelemetryEnabled() (mocked above to read the + // WORKOS_TELEMETRY env var at call time), so toggling the var per-test works. const originalEnv = process.env.WORKOS_TELEMETRY; beforeEach(() => { @@ -115,6 +130,9 @@ describe('Analytics', () => { vi.doMock('../lib/device-id.js', () => ({ getDeviceId: () => TEST_DEVICE_ID, })); + vi.doMock('../lib/preferences.js', () => ({ + isTelemetryEnabled: () => isTelemetryEnabledFromEnv(), + })); vi.doMock('../lib/config-store.js', () => ({ getActiveEnvironment: () => mockGetActiveEnvironment(), isUnclaimedEnvironment: (env: { type: string }) => env?.type === 'unclaimed', @@ -819,6 +837,9 @@ describe('Analytics', () => { vi.doMock('../lib/device-id.js', () => ({ getDeviceId: () => TEST_DEVICE_ID, })); + vi.doMock('../lib/preferences.js', () => ({ + isTelemetryEnabled: () => isTelemetryEnabledFromEnv(), + })); vi.doMock('../lib/config-store.js', () => ({ getActiveEnvironment: () => mockGetActiveEnvironment(), isUnclaimedEnvironment: (env: { type: string }) => env?.type === 'unclaimed', diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index e6233496..5ee06637 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -15,7 +15,7 @@ import type { TerminationReason, EnvFingerprint, } from './telemetry-types.js'; -import { WORKOS_TELEMETRY_ENABLED } from '../lib/constants.js'; +import { isTelemetryEnabled } from '../lib/preferences.js'; import { getTelemetryUrl, getVersion } from '../lib/settings.js'; import { getCredentials, isTokenExpired } from '../lib/credentials.js'; import { getActiveEnvironment, isUnclaimedEnvironment } from '../lib/config-store.js'; @@ -70,7 +70,7 @@ export class Analytics { } private isEnabled(): boolean { - return WORKOS_TELEMETRY_ENABLED; + return isTelemetryEnabled(); } /** diff --git a/src/utils/command-telemetry.ts b/src/utils/command-telemetry.ts index 6a283303..b54f74ec 100644 --- a/src/utils/command-telemetry.ts +++ b/src/utils/command-telemetry.ts @@ -16,7 +16,7 @@ function topLevelCommands(): Set { return knownTopLevelCommands; } -export const SKIP_TELEMETRY_COMMANDS = new Set(['install', 'dashboard', 'root']); +export const SKIP_TELEMETRY_COMMANDS = new Set(['install', 'dashboard', 'root', 'telemetry']); export function resolveCanonicalName(parts: string[]): string { if (parts.length === 0) return 'root'; diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 799f760c..b85e54a2 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -111,6 +111,15 @@ const commands: CommandSchema[] = [ description: 'Show current authentication status', options: [insecureStorageOpt], }, + { + name: 'telemetry', + description: 'Manage telemetry collection (opt-out, opt-in, status)', + commands: [ + { name: 'opt-out', description: 'Disable telemetry collection (persists across runs)' }, + { name: 'opt-in', description: 'Re-enable telemetry collection' }, + { name: 'status', description: 'Show whether telemetry is enabled and why' }, + ], + }, { name: 'skills', description: 'Manage WorkOS skills for coding agents (Claude Code, Codex, Cursor, Goose)', From 9991c096ea2f2f6d9d19a0c134cd39f19141140c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 16:59:13 -0500 Subject: [PATCH 2/6] feat: first-run telemetry notice (Phase 2) Add a one-time, stderr-only notice that tells users the WorkOS CLI collects anonymous usage telemetry and how to opt out. Shown at most once ever, only in interactive human mode, and never on the machine-readable path. - preferences.ts: add isNoticeShown()/markNoticeShown() (persists telemetry.noticeShownAt, read-modify-write preserves optedOut) - telemetry-notice.ts: maybeShowTelemetryNotice() with per-session guard, json-mode/opted-out/already-shown gates, never-throws; marks shown ONLY after actually rendering so non-human first runs never consume the one-time display - bin.ts: wire into middleware, skipping the telemetry command and the empty/root command (bare --help/--version) - unit specs for every gate and the mark-only-on-display rule --- src/bin.ts | 11 +++ src/lib/preferences.spec.ts | 41 +++++++++++ src/lib/preferences.ts | 19 ++++- src/lib/telemetry-notice.spec.ts | 115 +++++++++++++++++++++++++++++++ src/lib/telemetry-notice.ts | 50 ++++++++++++++ 5 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 src/lib/telemetry-notice.spec.ts create mode 100644 src/lib/telemetry-notice.ts diff --git a/src/bin.ts b/src/bin.ts index e2678ed0..ecb7ddc1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -49,6 +49,7 @@ import { installCrashReporter, sanitizeMessage } from './utils/crash-reporter.js import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js'; import { loadDeviceId } from './lib/device-id.js'; import { loadPreferences, isTelemetryEnabled } from './lib/preferences.js'; +import { maybeShowTelemetryNotice } from './lib/telemetry-notice.js'; import { resolveCanonicalName, resolveCommandNameFromRawArgs, @@ -281,6 +282,16 @@ async function runCli(): Promise { const commandParts = (argv._ as string[]) || []; commandName = resolveCanonicalName(commandParts); }) + .middleware((argv) => { + // First-run, stderr-only notice that telemetry is being collected. + // Skip while the user is actively managing telemetry, and on the + // empty/root command (bare `--help` / `--version` / `$0`). The notice + // is self-guarded — it no-ops in json mode, when already shown, when + // opted out, and after the first display this session. + const command = String(argv._?.[0] ?? ''); + if (command === 'telemetry' || command === '') return; + maybeShowTelemetryNotice(); + }) .middleware(async (argv) => { // Warn about unclaimed environments before management commands. // Excluded: auth/claim/install/dashboard handle their own credential flows; diff --git a/src/lib/preferences.spec.ts b/src/lib/preferences.spec.ts index 131a9d1d..c8a9ddf8 100644 --- a/src/lib/preferences.spec.ts +++ b/src/lib/preferences.spec.ts @@ -24,6 +24,8 @@ const { savePreferences, isTelemetryOptedOut, setTelemetryOptedOut, + isNoticeShown, + markNoticeShown, envTelemetryOverride, isTelemetryEnabled, getTelemetrySource, @@ -171,6 +173,45 @@ describe('preferences', () => { }); }); + describe('isNoticeShown / markNoticeShown', () => { + it('isNoticeShown is false when nothing is persisted', () => { + expect(isNoticeShown()).toBe(false); + }); + + it('markNoticeShown persists a timestamp that isNoticeShown reads back', () => { + markNoticeShown(); + expect(isNoticeShown()).toBe(true); + + const onDisk = JSON.parse(readFileSync(getPreferencesPath(), 'utf8')); + expect(typeof onDisk.telemetry.noticeShownAt).toBe('string'); + // Round-trips as a valid ISO timestamp. + expect(Number.isNaN(Date.parse(onDisk.telemetry.noticeShownAt))).toBe(false); + }); + + it('isNoticeShown is true for an existing noticeShownAt on disk', () => { + writePrefs({ telemetry: { noticeShownAt: '2026-01-01T00:00:00.000Z' } }); + expect(isNoticeShown()).toBe(true); + }); + + it('markNoticeShown preserves an existing optedOut flag (no clobber)', () => { + setTelemetryOptedOut(true); + markNoticeShown(); + + const onDisk = JSON.parse(readFileSync(getPreferencesPath(), 'utf8')); + expect(onDisk.telemetry.optedOut).toBe(true); + expect(typeof onDisk.telemetry.noticeShownAt).toBe('string'); + }); + + it('setTelemetryOptedOut preserves an existing noticeShownAt (no clobber)', () => { + markNoticeShown(); + setTelemetryOptedOut(true); + + const onDisk = JSON.parse(readFileSync(getPreferencesPath(), 'utf8')); + expect(onDisk.telemetry.optedOut).toBe(true); + expect(typeof onDisk.telemetry.noticeShownAt).toBe('string'); + }); + }); + describe('envTelemetryOverride (tri-state)', () => { it('returns true for "true"', () => { process.env.WORKOS_TELEMETRY = 'true'; diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index 1abe3883..1d360d66 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -116,9 +116,7 @@ export function savePreferences(next: CliPreferences): void { const merged: CliPreferences = { ...current, ...next, - ...(current.telemetry || next.telemetry - ? { telemetry: { ...current.telemetry, ...next.telemetry } } - : {}), + ...(current.telemetry || next.telemetry ? { telemetry: { ...current.telemetry, ...next.telemetry } } : {}), }; fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); @@ -136,6 +134,21 @@ export function setTelemetryOptedOut(value: boolean): void { savePreferences({ telemetry: { optedOut: value } }); } +/** Whether the first-run telemetry notice has already been shown (ever). */ +export function isNoticeShown(): boolean { + return !!getPreferences().telemetry?.noticeShownAt; +} + +/** + * Persist the first-run notice as shown, stamping the current time. Uses the + * read-modify-write savePreferences so it never clobbers the optedOut flag. + * Throws on write failure (see savePreferences) — the caller in + * telemetry-notice.ts swallows it so a read-only FS never blocks a command. + */ +export function markNoticeShown(): void { + savePreferences({ telemetry: { noticeShownAt: new Date().toISOString() } }); +} + /** * Tri-state env override for telemetry. * diff --git a/src/lib/telemetry-notice.spec.ts b/src/lib/telemetry-notice.spec.ts new file mode 100644 index 00000000..ae32085a --- /dev/null +++ b/src/lib/telemetry-notice.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Toggleable output mode. +let jsonMode = false; +vi.mock('../utils/output.js', () => ({ + isJsonMode: () => jsonMode, +})); + +// Spy on the box renderer instead of writing to stderr. +const mockRenderStderrBox = vi.fn(); +vi.mock('../utils/box.js', () => ({ + renderStderrBox: (...args: unknown[]) => mockRenderStderrBox(...args), +})); + +// Control the persisted-state gates and spy on the mark. +let noticeShown = false; +let optedOut = false; +const mockMarkNoticeShown = vi.fn(() => { + noticeShown = true; +}); +vi.mock('./preferences.js', () => ({ + isNoticeShown: () => noticeShown, + isTelemetryOptedOut: () => optedOut, + markNoticeShown: (...args: unknown[]) => mockMarkNoticeShown(...args), +})); + +const { maybeShowTelemetryNotice, resetTelemetryNoticeState } = await import('./telemetry-notice.js'); + +describe('telemetry-notice', () => { + beforeEach(() => { + vi.clearAllMocks(); + jsonMode = false; + noticeShown = false; + optedOut = false; + resetTelemetryNoticeState(); + }); + + it('human + unshown + not-opted-out → renders once and marks shown', () => { + maybeShowTelemetryNotice(); + + expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockMarkNoticeShown).toHaveBeenCalledTimes(1); + }); + + it('second call in the same session → no second render (per-session guard)', () => { + maybeShowTelemetryNotice(); + expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + + maybeShowTelemetryNotice(); + expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockMarkNoticeShown).toHaveBeenCalledTimes(1); + }); + + it('json mode → no render and never marks (mark-only-on-display)', () => { + jsonMode = true; + + maybeShowTelemetryNotice(); + + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockMarkNoticeShown).not.toHaveBeenCalled(); + // The flag must stay unset so a real human still sees it later. + expect(noticeShown).toBe(false); + }); + + it('already shown (noticeShownAt present) → no render, no mark', () => { + noticeShown = true; + + maybeShowTelemetryNotice(); + + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockMarkNoticeShown).not.toHaveBeenCalled(); + }); + + it('opted out → no render, no mark', () => { + optedOut = true; + + maybeShowTelemetryNotice(); + + expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockMarkNoticeShown).not.toHaveBeenCalled(); + }); + + it('marks shown only AFTER rendering (display-then-persist order)', () => { + const calls: string[] = []; + mockRenderStderrBox.mockImplementation(() => calls.push('render')); + mockMarkNoticeShown.mockImplementation(() => { + calls.push('mark'); + noticeShown = true; + }); + + maybeShowTelemetryNotice(); + + expect(calls).toEqual(['render', 'mark']); + }); + + it('never throws if rendering fails; does not mark on failure', () => { + mockRenderStderrBox.mockImplementation(() => { + throw new Error('render boom'); + }); + + expect(() => maybeShowTelemetryNotice()).not.toThrow(); + expect(mockMarkNoticeShown).not.toHaveBeenCalled(); + }); + + it('resetTelemetryNoticeState allows the notice to render again', () => { + maybeShowTelemetryNotice(); + expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + + // Simulate a fresh process where the flag was not persisted. + noticeShown = false; + resetTelemetryNoticeState(); + maybeShowTelemetryNotice(); + expect(mockRenderStderrBox).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/telemetry-notice.ts b/src/lib/telemetry-notice.ts new file mode 100644 index 00000000..9c3c2180 --- /dev/null +++ b/src/lib/telemetry-notice.ts @@ -0,0 +1,50 @@ +/** + * First-run telemetry notice. + * + * Prints a one-time, stderr-only box telling the user that anonymous CLI usage + * telemetry is being collected and how to turn it off. Shown at most once ever + * (backed by the persisted `noticeShownAt` timestamp in preferences.json), only + * in interactive human mode, and never on the machine-readable path. + * + * Mirrors the structural pattern of unclaimed-warning.ts: a per-session guard, + * a `!isJsonMode()` gate, the shared renderStderrBox helper, and a never-throws + * contract so it can never block command execution. The one structural + * difference is persistence — this notice writes `noticeShownAt` the first time + * it actually displays so it never re-shows across runs. + */ + +import chalk from 'chalk'; +import { isJsonMode } from '../utils/output.js'; +import { renderStderrBox } from '../utils/box.js'; +import { isNoticeShown, markNoticeShown, isTelemetryOptedOut } from './preferences.js'; + +let shownThisSession = false; + +/** + * Show the first-run telemetry notice if it has never been displayed. + * + * Gate order is load-bearing: every suppression check runs BEFORE + * markNoticeShown(), so a non-human first run (--json / piped / CI) never + * consumes the one-time display. The flag is set only when the box is actually + * rendered, so a real human eventually sees it. Never throws. + */ +export function maybeShowTelemetryNotice(): void { + try { + if (shownThisSession) return; + if (isJsonMode()) return; // suppress in --json / non-TTY / CI (output auto-switches to json) + if (isTelemetryOptedOut()) return; // already opted out — nothing to inform + if (isNoticeShown()) return; // already shown once, ever + + shownThisSession = true; + const inner = ` ${chalk.cyan('ℹ')} WorkOS collects anonymous CLI usage telemetry. Run ${chalk.cyan('workos telemetry opt-out')} to disable it. `; + renderStderrBox(inner, chalk.cyan); + markNoticeShown(); // mark ONLY after actually displaying + } catch { + // Never block command execution. + } +} + +/** Reset session state (for testing). */ +export function resetTelemetryNoticeState(): void { + shownThisSession = false; +} From 1243b4afed0ae59cf2c8e1bcb173f4ccd136b8ce Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 20:15:49 -0500 Subject: [PATCH 3/6] fix(telemetry): wrap first-run notice box to terminal width renderStderrBox drew a single-line border sized to the message's visible length, so any message wider than the terminal soft-wrapped and broke the border. The first-run telemetry notice (~90 chars) hit this on normal and narrow terminals. Make the box width-aware: an ANSI-aware word wrap (wrapAnsiAware) tokenizes into atomic self-closed color spans plus plain words and packs them by visible width, so color never bleeds onto the border. A backward-compatible single-line fast path keeps wide-terminal output byte-for-byte identical; only overflow triggers the multi-line box, snugged to the longest wrapped line. Fixes all three renderStderrBox callers (telemetry notice, unclaimed-env warning/provision). Adds box.spec.ts covering wrapping, atomic color spans, visible-width measurement, and border alignment at narrow widths. --- src/utils/box.spec.ts | 123 ++++++++++++++++++++++++++++++++++++++++++ src/utils/box.ts | 92 +++++++++++++++++++++++++++++-- 2 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 src/utils/box.spec.ts diff --git a/src/utils/box.spec.ts b/src/utils/box.spec.ts new file mode 100644 index 00000000..5fc3efb6 --- /dev/null +++ b/src/utils/box.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type chalk from 'chalk'; +import { renderStderrBox, wrapAnsiAware } from './box.js'; +import { stripAnsii } from './string.js'; + +// Identity "color" so border/structure assertions read cleanly. +const noColor = ((s: string) => s) as unknown as typeof chalk.yellow; + +// Build a self-closing SGR span explicitly. chalk auto-disables color in a +// non-TTY test env, so we synthesize the escapes the way chalk would on a real +// terminal — this keeps the ANSI-handling assertions deterministic. +const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`; +const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`; +const green = (s: string) => `\x1b[32m${s}\x1b[39m`; + +function withColumns(cols: number, fn: () => void): void { + const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, 'columns'); + const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); + Object.defineProperty(process.stderr, 'columns', { value: cols, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { value: cols, configurable: true }); + try { + fn(); + } finally { + if (stderrDesc) Object.defineProperty(process.stderr, 'columns', stderrDesc); + else delete (process.stderr as { columns?: number }).columns; + if (stdoutDesc) Object.defineProperty(process.stdout, 'columns', stdoutDesc); + else delete (process.stdout as { columns?: number }).columns; + } +} + +describe('wrapAnsiAware', () => { + it('keeps short text on a single line', () => { + expect(wrapAnsiAware('hello world', 80)).toEqual(['hello world']); + }); + + it('wraps plain text to the visible width', () => { + const lines = wrapAnsiAware('one two three four five', 9); + for (const line of lines) { + expect(stripAnsii(line).length).toBeLessThanOrEqual(9); + } + expect(lines.join(' ')).toBe('one two three four five'); + }); + + it('treats a colored span with internal spaces as one atomic token', () => { + const span = cyan('keep me together'); + const lines = wrapAnsiAware(`run ${span} now please`, 20); + // The colored span (16 visible chars, with internal spaces) must land on a + // single line, never split across the wrap boundary. + const onSameLine = lines.some((l) => l.includes(span)); + expect(onSameLine).toBe(true); + }); + + it('measures width by visible characters, not ANSI bytes', () => { + const colored = `${cyan('aaa')} ${yellow('bbb')} ${green('ccc')}`; + const lines = wrapAnsiAware(colored, 7); // "aaa bbb" = 7 visible + for (const line of lines) { + expect(stripAnsii(line).length).toBeLessThanOrEqual(7); + } + // The ANSI bytes are far longer than 7; proves we wrapped on visible width. + expect(colored.length).toBeGreaterThan(7); + }); + + it('never returns an empty array', () => { + expect(wrapAnsiAware('', 10)).toEqual(['']); + }); +}); + +describe('renderStderrBox', () => { + let errors: string[]; + + beforeEach(() => { + errors = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders a single-line box when content fits (historical layout)', () => { + withColumns(80, () => renderStderrBox(' hi ', noColor)); + + // blank, top, middle, bottom, blank + expect(errors).toHaveLength(5); + expect(errors[1]).toBe(' ┌────┐'); // border = visible length of " hi " (4) + expect(errors[2]).toBe(' │ hi │'); + expect(errors[3]).toBe(' └────┘'); + }); + + it('wraps to multiple lines on a narrow terminal without breaking the border', () => { + const msg = ' WorkOS collects anonymous CLI usage telemetry. Run workos telemetry opt-out to disable it. '; + withColumns(40, () => renderStderrBox(msg, noColor)); + + // No rendered line may exceed the terminal width. + for (const line of errors) { + expect(stripAnsii(line).length).toBeLessThanOrEqual(40); + } + + const top = errors.find((l) => l.includes('┌'))!; + const bottom = errors.find((l) => l.includes('└'))!; + const body = errors.filter((l) => l.includes('│')); + + // More than one body line proves it wrapped. + expect(body.length).toBeGreaterThan(1); + + // Top and bottom borders are the same width, and every body line matches it. + expect(stripAnsii(top).length).toBe(stripAnsii(bottom).length); + for (const line of body) { + expect(stripAnsii(line).length).toBe(stripAnsii(top).length); + } + }); + + it('preserves the colored command span when wrapping', () => { + const cmd = cyan('workos telemetry opt-out'); + const msg = ` WorkOS collects anonymous CLI usage telemetry. Run ${cmd} to disable it. `; + withColumns(44, () => renderStderrBox(msg, noColor)); + + const body = errors.filter((l) => l.includes('│')).join('\n'); + expect(body).toContain(cmd); // intact, not split mid-span + }); +}); diff --git a/src/utils/box.ts b/src/utils/box.ts index f5f93bf0..53e85920 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -1,15 +1,99 @@ import type chalk from 'chalk'; import { stripAnsii } from './string.js'; +/** Visible (printable) width of a string, ignoring ANSI escape sequences. */ +function visibleWidth(str: string): number { + return stripAnsii(str).length; +} + +/** Terminal width for stderr output, falling back to stdout then 80 columns. */ +function terminalWidth(): number { + return process.stderr.columns || process.stdout.columns || 80; +} + +/** + * Word-wrap a string to a maximum visible width, preserving ANSI color. + * + * chalk emits self-closing color spans (e.g. `\x1b[36m…\x1b[39m`), so each + * colored fragment is atomic: we tokenize the input into whole colored spans + * and plain words, then greedily pack tokens into lines measured by their + * VISIBLE width. Because every token carries its own open+close codes, color + * never bleeds across a line break onto the border or padding. + * + * A single token wider than `maxWidth` (rare — only a very narrow terminal vs. + * a long unbroken word) overflows its own line rather than being split mid-span. + */ +export function wrapAnsiAware(input: string, maxWidth: number): string[] { + // A token is either: a full SGR-wrapped span (open code, content that may + // contain spaces, close code), a run of non-space/non-escape characters + // (a plain word), or a lone escape. Whitespace between tokens is dropped and + // re-inserted as single separating spaces. + const tokenRe = /\x1b\[[0-9;]*m[^\x1b]*?\x1b\[[0-9;]*m|[^\s\x1b]+|\x1b\[[0-9;]*m/g; + const tokens = input.match(tokenRe) ?? []; + + const lines: string[] = []; + let line = ''; + let lineWidth = 0; + + for (const token of tokens) { + const tokenWidth = visibleWidth(token); + if (lineWidth === 0) { + line = token; + lineWidth = tokenWidth; + } else if (lineWidth + 1 + tokenWidth <= maxWidth) { + line += ` ${token}`; + lineWidth += 1 + tokenWidth; + } else { + lines.push(line); + line = token; + lineWidth = tokenWidth; + } + } + if (line) lines.push(line); + return lines.length > 0 ? lines : ['']; +} + /** - * Render a one-line bordered box to stderr. + * Render a bordered box to stderr, wrapping to the terminal width. + * + * When the content fits on one line it renders exactly as a single-line box + * (the historical behavior). When it would overflow the terminal, the content + * is word-wrapped (ANSI-aware) and the box grows to multiple lines so the + * border never breaks on a narrow terminal. */ export function renderStderrBox(inner: string, color: typeof chalk.yellow | typeof chalk.green): void { - const plainLen = stripAnsii(inner).length; - const border = '─'.repeat(plainLen); + const cols = terminalWidth(); + const plainLen = visibleWidth(inner); + + // Fast path: content (including its own padding spaces) fits within the + // terminal. Render the single-line box byte-for-byte as before. + if (plainLen <= cols - 4) { + const border = '─'.repeat(plainLen); + console.error(''); + console.error(color(` ┌${border}┐`)); + console.error(color(' │') + inner + color('│')); + console.error(color(` └${border}┘`)); + console.error(''); + return; + } + + // Wrap path: trim the caller's outer padding, wrap to the available width, + // then re-pad each line to a uniform inner width with one space of padding + // on each side. Layout per line: " │ " + text + " │" = text + 6 columns. + const content = inner.replace(/^[ \t]+/, '').replace(/[ \t]+$/, ''); + const maxTextWidth = Math.max(1, cols - 6); + const wrapped = wrapAnsiAware(content, maxTextWidth); + + // Snug the box to the longest wrapped line rather than the full terminal. + const textWidth = Math.max(...wrapped.map(visibleWidth)); + const border = '─'.repeat(textWidth + 2); + console.error(''); console.error(color(` ┌${border}┐`)); - console.error(color(' │') + inner + color('│')); + for (const ln of wrapped) { + const pad = ' '.repeat(Math.max(0, textWidth - visibleWidth(ln))); + console.error(`${color(' │')} ${ln}${pad} ${color('│')}`); + } console.error(color(` └${border}┘`)); console.error(''); } From 6935bf9342b4ffd5cf99c04a39d3f381c3af882d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 20:15:58 -0500 Subject: [PATCH 4/6] feat(telemetry): surface telemetry state in debug, clear preferences on reset debug state now reports telemetry enabled/optedOut/source/noticeShown and the preferences file path, so users and support can see the effective state and where it lives. debug reset clears ~/.workos/preferences.json alongside config (non-secret local CLI state rides with the config target), returning telemetry to its fresh-install state. Adds clearPreferences() to the preferences store. --- src/commands/debug.spec.ts | 67 ++++++++++++++++++++++++++++++++++--- src/commands/debug.ts | 46 +++++++++++++++++++++++-- src/lib/preferences.spec.ts | 28 ++++++++++++++++ src/lib/preferences.ts | 14 ++++++++ 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/src/commands/debug.spec.ts b/src/commands/debug.spec.ts index 9a37b41a..902e5bca 100644 --- a/src/commands/debug.spec.ts +++ b/src/commands/debug.spec.ts @@ -38,6 +38,23 @@ vi.mock('../lib/config-store.js', () => ({ diagnoseConfig: (...args: unknown[]) => mockDiagnoseConfig(...args), })); +// Mock preferences store +const mockClearPreferences = vi.fn(); +const mockGetPreferencesPath = vi.fn(() => '/home/user/.workos/preferences.json'); +const mockGetTelemetrySource = vi.fn(() => 'default'); +const mockIsNoticeShown = vi.fn(() => false); +const mockIsTelemetryEnabled = vi.fn(() => true); +const mockIsTelemetryOptedOut = vi.fn(() => false); + +vi.mock('../lib/preferences.js', () => ({ + clearPreferences: (...args: unknown[]) => mockClearPreferences(...args), + getPreferencesPath: (...args: unknown[]) => mockGetPreferencesPath(...args), + getTelemetrySource: (...args: unknown[]) => mockGetTelemetrySource(...args), + isNoticeShown: (...args: unknown[]) => mockIsNoticeShown(...args), + isTelemetryEnabled: (...args: unknown[]) => mockIsTelemetryEnabled(...args), + isTelemetryOptedOut: (...args: unknown[]) => mockIsTelemetryOptedOut(...args), +})); + // Mock output let jsonMode = false; vi.mock('../utils/output.js', () => ({ @@ -212,6 +229,31 @@ describe('debug commands', () => { expect(output).toContain('keyring'); }); + it('reports telemetry status (human + JSON)', async () => { + mockGetCredentials.mockReturnValue(makeCreds()); + mockGetConfig.mockReturnValue(makeConfig()); + mockIsTokenExpired.mockReturnValue(false); + mockIsTelemetryEnabled.mockReturnValue(false); + mockIsTelemetryOptedOut.mockReturnValue(true); + mockGetTelemetrySource.mockReturnValue('preference'); + mockIsNoticeShown.mockReturnValue(true); + + await runDebugState({ showSecrets: false }); + expect(consoleOutput.join('\n')).toContain('Telemetry'); + + consoleOutput.length = 0; + jsonMode = true; + await runDebugState({ showSecrets: false }); + const parsed = JSON.parse(consoleOutput[0]); + expect(parsed.telemetry).toEqual({ + enabled: false, + optedOut: true, + source: 'preference', + noticeShown: true, + }); + expect(parsed.storage.preferencesPath).toBeDefined(); + }); + it('shows file source when insecure storage', async () => { mockGetCredentials.mockReturnValue(makeCreds()); mockGetConfig.mockReturnValue(makeConfig()); @@ -234,48 +276,62 @@ describe('debug commands', () => { }); describe('debug reset', () => { - it('clears both credentials and config by default', async () => { + it('clears credentials, config, and preferences by default', async () => { mockConfirm.mockResolvedValue(true); await runDebugReset({ force: false, credentialsOnly: false, configOnly: false }); expect(mockClearCredentials).toHaveBeenCalled(); expect(mockClearConfig).toHaveBeenCalled(); + expect(mockClearPreferences).toHaveBeenCalled(); }); - it('--credentials-only clears only credentials', async () => { + it('--credentials-only clears only credentials, leaving preferences intact', async () => { mockConfirm.mockResolvedValue(true); await runDebugReset({ force: false, credentialsOnly: true, configOnly: false }); expect(mockClearCredentials).toHaveBeenCalled(); expect(mockClearConfig).not.toHaveBeenCalled(); + expect(mockClearPreferences).not.toHaveBeenCalled(); }); - it('--config-only clears only config', async () => { + it('--config-only clears config and preferences, leaving credentials intact', async () => { mockConfirm.mockResolvedValue(true); await runDebugReset({ force: false, credentialsOnly: false, configOnly: true }); expect(mockClearConfig).toHaveBeenCalled(); + expect(mockClearPreferences).toHaveBeenCalled(); expect(mockClearCredentials).not.toHaveBeenCalled(); }); - it('--force skips confirmation', async () => { + it('--force skips confirmation and clears all three', async () => { await runDebugReset({ force: true, credentialsOnly: false, configOnly: false }); expect(mockConfirm).not.toHaveBeenCalled(); expect(mockClearCredentials).toHaveBeenCalled(); expect(mockClearConfig).toHaveBeenCalled(); + expect(mockClearPreferences).toHaveBeenCalled(); }); - it('both --credentials-only and --config-only clears both', async () => { + it('both --credentials-only and --config-only clears everything', async () => { mockConfirm.mockResolvedValue(true); await runDebugReset({ force: false, credentialsOnly: true, configOnly: true }); expect(mockClearCredentials).toHaveBeenCalled(); expect(mockClearConfig).toHaveBeenCalled(); + expect(mockClearPreferences).toHaveBeenCalled(); + }); + + it('lists preferences as a cleared target in the confirmation prompt', async () => { + mockConfirm.mockResolvedValue(true); + + await runDebugReset({ force: false, credentialsOnly: false, configOnly: false }); + + const promptArg = mockConfirm.mock.calls[0][0] as { message: string }; + expect(promptArg.message).toContain('preferences'); }); it('errors in agent/CI mode without --force', async () => { @@ -295,6 +351,7 @@ describe('debug commands', () => { expect(parsed.cleared).toBe(true); expect(parsed.credentials).toBe(true); expect(parsed.config).toBe(true); + expect(parsed.preferences).toBe(true); }); }); diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 6d3f63b2..e7f0da9d 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -17,6 +17,14 @@ import { setInsecureConfigStorage, diagnoseConfig, } from '../lib/config-store.js'; +import { + clearPreferences, + getPreferencesPath, + getTelemetrySource, + isNoticeShown, + isTelemetryEnabled, + isTelemetryOptedOut, +} from '../lib/preferences.js'; import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; import { isPromptAllowed } from '../utils/interaction-mode.js'; @@ -114,12 +122,21 @@ export async function runDebugState({ showSecrets }: { showSecrets: boolean }): const configSource = determineCredentialSource(configDiagnostics); configOutput.source = configSource; + const telemetryOutput = { + enabled: isTelemetryEnabled(), + optedOut: isTelemetryOptedOut(), + source: getTelemetrySource(), + noticeShown: isNoticeShown(), + }; + const result = { credentials: credentialsOutput, config: configOutput, + telemetry: telemetryOutput, storage: { credentialsPath: getCredentialsPath(), configPath: getConfigPath(), + preferencesPath: getPreferencesPath(), credentialDiagnostics: diagnostics, configDiagnostics, }, @@ -160,6 +177,13 @@ export async function runDebugState({ showSecrets }: { showSecrets: boolean }): } } + console.log(); + console.log(chalk.bold('Telemetry')); + console.log(` enabled: ${telemetryOutput.enabled ? chalk.green('true') : chalk.yellow('false')}`); + console.log(` optedOut: ${telemetryOutput.optedOut ? chalk.yellow('true') : 'false'}`); + console.log(` source: ${telemetryOutput.source}`); + console.log(` notice: ${telemetryOutput.noticeShown ? 'shown' : chalk.dim('not shown')}`); + console.log(); console.log(chalk.bold('Storage — Credentials')); console.log(` path: ${getCredentialsPath()}`); @@ -173,10 +197,21 @@ export async function runDebugState({ showSecrets }: { showSecrets: boolean }): for (const line of configDiagnostics) { console.log(` ${chalk.dim(line)}`); } + + console.log(); + console.log(chalk.bold('Storage — Preferences')); + console.log(` path: ${getPreferencesPath()}`); } // --- debug reset --- +/** Join names with an Oxford comma: ["a","b","c"] => "a, b, and c". */ +function formatList(items: string[]): string { + if (items.length <= 1) return items.join(''); + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; +} + export async function runDebugReset({ force, credentialsOnly, @@ -189,8 +224,14 @@ export async function runDebugReset({ // Both flags = clear both (same as neither) const clearCreds = !configOnly || credentialsOnly; const clearConf = !credentialsOnly || configOnly; + // Preferences (~/.workos/preferences.json) are non-secret local CLI state, so + // they ride with the config target. Clearing config returns the CLI to a + // fresh-install state, which includes resetting the telemetry preference. + const clearPrefs = clearConf; - const targets = [clearCreds && 'credentials', clearConf && 'config'].filter(Boolean).join(' and '); + const targets = formatList( + [clearCreds && 'credentials', clearConf && 'config', clearPrefs && 'preferences'].filter(Boolean) as string[], + ); if (!force) { if (!isPromptAllowed()) { @@ -216,9 +257,10 @@ export async function runDebugReset({ if (clearCreds) clearCredentials(); if (clearConf) clearConfig(); + if (clearPrefs) clearPreferences(); if (isJsonMode()) { - outputJson({ cleared: true, credentials: clearCreds, config: clearConf }); + outputJson({ cleared: true, credentials: clearCreds, config: clearConf, preferences: clearPrefs }); } else { clack.log.success(`Cleared ${targets}`); } diff --git a/src/lib/preferences.spec.ts b/src/lib/preferences.spec.ts index c8a9ddf8..72b2e27a 100644 --- a/src/lib/preferences.spec.ts +++ b/src/lib/preferences.spec.ts @@ -30,6 +30,7 @@ const { isTelemetryEnabled, getTelemetrySource, getPreferencesPath, + clearPreferences, __resetPreferencesCache, } = await import('./preferences.js'); @@ -281,4 +282,31 @@ describe('preferences', () => { expect(getTelemetrySource()).toBe('default'); }); }); + + describe('clearPreferences', () => { + it('deletes the file and returns telemetry to its default state', () => { + setTelemetryOptedOut(true); + markNoticeShown(); + expect(existsSync(getPreferencesPath())).toBe(true); + + clearPreferences(); + + expect(existsSync(getPreferencesPath())).toBe(false); + // In-process cache reflects the cleared state immediately. + expect(isTelemetryOptedOut()).toBe(false); + expect(isNoticeShown()).toBe(false); + }); + + it('is a no-op when the file does not exist', () => { + expect(existsSync(getPreferencesPath())).toBe(false); + expect(() => clearPreferences()).not.toThrow(); + }); + + it('reads as empty preferences after a fresh process (cache reset)', () => { + setTelemetryOptedOut(true); + clearPreferences(); + __resetPreferencesCache(); // simulate a new process + expect(getPreferences()).toEqual({}); + }); + }); }); diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index 1d360d66..608e3602 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -192,6 +192,20 @@ export function getTelemetrySource(): TelemetrySource { return 'default'; } +/** + * Delete the preferences file, returning telemetry to its fresh-install state + * (opted-in, first-run notice unseen). Used by `debug reset` to wipe stored CLI + * state alongside credentials and config. No-op if the file does not exist; + * throws on a real delete failure (e.g. permission denied) so the caller can + * surface it, mirroring clearConfig/clearCredentials. Resets the in-memory + * cache so subsequent reads in this process reflect the cleared state. + */ +export function clearPreferences(): void { + fs.rmSync(getPreferencesPath(), { force: true }); + cached = {}; + pending = undefined; +} + /** Test seam — resets the in-memory cache between test cases. */ export function __resetPreferencesCache(): void { cached = undefined; From 9cf0ce24726b0e8d75a3b0f8cb8caaa35e8fcc4e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 21:13:04 -0500 Subject: [PATCH 5/6] fix(telemetry): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - telemetry-notice: render the opt-out command via formatWorkOSCommand so npx users see the working invocation (e.g. 'npx workos@latest telemetry opt-out') instead of a hardcoded 'workos ...'; add a regression test. - telemetry-notice: set the per-session guard and persist 'shown' only AFTER a successful render, so a render failure lets a later command retry instead of silently suppressing the notice for the rest of the session. - preferences: getTelemetrySource now returns 'default' (not 'preference') when optedOut is explicitly false, matching isTelemetryEnabled()'s precedence — an opted-in state is indistinguishable from a fresh install. - preferences: drop the unused 'mkdir' import. - box: document the single-level-SGR assumption and the long atomic-token overflow caveat (e.g. the npx command form on very narrow terminals). --- src/lib/preferences.spec.ts | 6 ++++-- src/lib/preferences.ts | 9 +++++++-- src/lib/telemetry-notice.spec.ts | 8 ++++++++ src/lib/telemetry-notice.ts | 11 ++++++++--- src/utils/box.ts | 10 ++++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/lib/preferences.spec.ts b/src/lib/preferences.spec.ts index 72b2e27a..268dd02b 100644 --- a/src/lib/preferences.spec.ts +++ b/src/lib/preferences.spec.ts @@ -271,10 +271,12 @@ describe('preferences', () => { expect(getTelemetrySource()).toBe('preference'); }); - it('is "preference" when the flag is explicitly false', () => { + it('is "default" when the flag is explicitly false (opted back in)', () => { delete process.env.WORKOS_TELEMETRY; setTelemetryOptedOut(false); - expect(getTelemetrySource()).toBe('preference'); + // opted-in matches the fresh-install outcome, so the source is 'default', + // not 'preference' — consistent with isTelemetryEnabled()'s precedence. + expect(getTelemetrySource()).toBe('default'); }); it('is "default" when nothing is set', () => { diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index 608e3602..159df931 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -13,7 +13,7 @@ */ import fs from 'node:fs'; -import { mkdir, readFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; @@ -185,10 +185,15 @@ export function isTelemetryEnabled(): boolean { * Which signal produced the effective telemetry decision. Mirrors the * precedence in isTelemetryEnabled() so the `telemetry status` command and the * resolver can never drift. + * + * Note: an explicit opt-in (optedOut === false) reads as 'default', not + * 'preference' — its outcome is identical to a fresh install, and the resolver + * only treats optedOut === true as a non-default signal, so reporting + * 'preference' here would imply a behavioral difference that does not exist. */ export function getTelemetrySource(): TelemetrySource { if (envTelemetryOverride() !== undefined) return 'env'; - if (getPreferences().telemetry?.optedOut !== undefined) return 'preference'; + if (isTelemetryOptedOut()) return 'preference'; return 'default'; } diff --git a/src/lib/telemetry-notice.spec.ts b/src/lib/telemetry-notice.spec.ts index ae32085a..c37d76f3 100644 --- a/src/lib/telemetry-notice.spec.ts +++ b/src/lib/telemetry-notice.spec.ts @@ -24,6 +24,7 @@ vi.mock('./preferences.js', () => ({ markNoticeShown: (...args: unknown[]) => mockMarkNoticeShown(...args), })); +const { formatWorkOSCommand } = await import('../utils/command-invocation.js'); const { maybeShowTelemetryNotice, resetTelemetryNoticeState } = await import('./telemetry-notice.js'); describe('telemetry-notice', () => { @@ -42,6 +43,13 @@ describe('telemetry-notice', () => { expect(mockMarkNoticeShown).toHaveBeenCalledTimes(1); }); + it('renders the opt-out command via formatWorkOSCommand (npx-safe, not hardcoded)', () => { + maybeShowTelemetryNotice(); + + const inner = mockRenderStderrBox.mock.calls[0]?.[0] as string; + expect(inner).toContain(formatWorkOSCommand('telemetry opt-out')); + }); + it('second call in the same session → no second render (per-session guard)', () => { maybeShowTelemetryNotice(); expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); diff --git a/src/lib/telemetry-notice.ts b/src/lib/telemetry-notice.ts index 9c3c2180..17e44e45 100644 --- a/src/lib/telemetry-notice.ts +++ b/src/lib/telemetry-notice.ts @@ -16,6 +16,7 @@ import chalk from 'chalk'; import { isJsonMode } from '../utils/output.js'; import { renderStderrBox } from '../utils/box.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { isNoticeShown, markNoticeShown, isTelemetryOptedOut } from './preferences.js'; let shownThisSession = false; @@ -35,10 +36,14 @@ export function maybeShowTelemetryNotice(): void { if (isTelemetryOptedOut()) return; // already opted out — nothing to inform if (isNoticeShown()) return; // already shown once, ever - shownThisSession = true; - const inner = ` ${chalk.cyan('ℹ')} WorkOS collects anonymous CLI usage telemetry. Run ${chalk.cyan('workos telemetry opt-out')} to disable it. `; + const optOut = chalk.cyan(formatWorkOSCommand('telemetry opt-out')); + const inner = ` ${chalk.cyan('ℹ')} WorkOS collects anonymous CLI usage telemetry. Run ${optOut} to disable it. `; renderStderrBox(inner, chalk.cyan); - markNoticeShown(); // mark ONLY after actually displaying + // Set the per-session guard and persist ONLY after a successful render, so a + // render failure (caught below) lets a later command in this process retry + // rather than silently suppressing the notice for the rest of the session. + shownThisSession = true; + markNoticeShown(); } catch { // Never block command execution. } diff --git a/src/utils/box.ts b/src/utils/box.ts index 53e85920..1b9cd093 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -22,6 +22,16 @@ function terminalWidth(): number { * * A single token wider than `maxWidth` (rare — only a very narrow terminal vs. * a long unbroken word) overflows its own line rather than being split mid-span. + * Such an overflow can push the rendered box border past the terminal width; + * acceptable at standard widths. Note that a colored command produced by + * `formatWorkOSCommand` can be long (e.g. `npx workos@latest telemetry opt-out`) + * and stays a single unbreakable token by design. + * + * Limitation: a colored span is grouped atomically only when it is a single SGR + * layer (one open code + one close code, as `chalk.cyan('…')` emits). Stacked + * styles such as bold+color (`\x1b[1m\x1b[36m…\x1b[39m\x1b[22m`) or two adjacent + * spans with no separating space are not guaranteed to stay on one line and may + * leave a reset code mid-line. All current callers use single-color spans only. */ export function wrapAnsiAware(input: string, maxWidth: number): string[] { // A token is either: a full SGR-wrapped span (open code, content that may From 59a06ceb926e3fd3b28b3590c1bd7a134a655639 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 1 Jun 2026 21:14:42 -0500 Subject: [PATCH 6/6] chore(telemetry): drop unused savePreferences import in spec --- src/lib/preferences.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/preferences.spec.ts b/src/lib/preferences.spec.ts index 268dd02b..f4a65bdd 100644 --- a/src/lib/preferences.spec.ts +++ b/src/lib/preferences.spec.ts @@ -21,7 +21,6 @@ vi.mock('node:os', async (importOriginal) => { const { getPreferences, loadPreferences, - savePreferences, isTelemetryOptedOut, setTelemetryOptedOut, isNoticeShown,