diff --git a/src/bin-command-telemetry.integration.spec.ts b/src/bin-command-telemetry.integration.spec.ts index 585a0cf..a3600d2 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 76f0508..ecb7ddc 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -48,6 +48,8 @@ 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 { maybeShowTelemetryNotice } from './lib/telemetry-notice.js'; import { resolveCanonicalName, resolveCommandNameFromRawArgs, @@ -56,7 +58,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 +72,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 @@ -275,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; @@ -337,6 +354,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 +2625,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/debug.spec.ts b/src/commands/debug.spec.ts index 9a37b41..902e5bc 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 6d3f63b..e7f0da9 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/commands/telemetry.spec.ts b/src/commands/telemetry.spec.ts new file mode 100644 index 0000000..8a32dbd --- /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 0000000..5514853 --- /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 cc05e97..f16189f 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 0000000..f4a65bd --- /dev/null +++ b/src/lib/preferences.spec.ts @@ -0,0 +1,313 @@ +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, + isTelemetryOptedOut, + setTelemetryOptedOut, + isNoticeShown, + markNoticeShown, + envTelemetryOverride, + isTelemetryEnabled, + getTelemetrySource, + getPreferencesPath, + clearPreferences, + __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('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'; + 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 "default" when the flag is explicitly false (opted back in)', () => { + delete process.env.WORKOS_TELEMETRY; + setTelemetryOptedOut(false); + // 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', () => { + delete process.env.WORKOS_TELEMETRY; + 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 new file mode 100644 index 0000000..159df93 --- /dev/null +++ b/src/lib/preferences.ts @@ -0,0 +1,218 @@ +/** + * 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 { 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 } }); +} + +/** 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. + * + * 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. + * + * 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 (isTelemetryOptedOut()) return 'preference'; + 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; + pending = undefined; +} diff --git a/src/lib/telemetry-notice.spec.ts b/src/lib/telemetry-notice.spec.ts new file mode 100644 index 0000000..c37d76f --- /dev/null +++ b/src/lib/telemetry-notice.spec.ts @@ -0,0 +1,123 @@ +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 { formatWorkOSCommand } = await import('../utils/command-invocation.js'); +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('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); + + 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 0000000..17e44e4 --- /dev/null +++ b/src/lib/telemetry-notice.ts @@ -0,0 +1,55 @@ +/** + * 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 { formatWorkOSCommand } from '../utils/command-invocation.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 + + 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); + // 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. + } +} + +/** Reset session state (for testing). */ +export function resetTelemetryNoticeState(): void { + shownThisSession = false; +} diff --git a/src/utils/analytics.spec.ts b/src/utils/analytics.spec.ts index 277a35d..08e8ea8 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 e623349..5ee0663 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/box.spec.ts b/src/utils/box.spec.ts new file mode 100644 index 0000000..5fc3efb --- /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 f5f93bf..1b9cd09 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -1,15 +1,109 @@ 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. + * 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 + // 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(''); } diff --git a/src/utils/command-telemetry.ts b/src/utils/command-telemetry.ts index 6a28330..b54f74e 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 799f760..b85e54a 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)',