diff --git a/src/cli/index.ts b/src/cli/index.ts index c51e490a8d..1143866882 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -154,8 +154,9 @@ program.hook('preAction', async (thisCommand, actionCommand) => { process.env.NO_COLOR = '1'; } - // Show first-run telemetry notice (if not seen). Suppress it whenever the run - // asked for JSON so stdout stays a single valid JSON document (see isJsonRun). + // Show first-run telemetry notice (if not seen). It's written to stderr, so it + // never pollutes stdout — but --json runs still defer it (see isJsonRun) so the + // very first invocation stays free of any incidental output on either stream. await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) }); // Track command execution (use actionCommand to get the actual subcommand) diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index 3eb1978e3f..0e4d6290eb 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -197,8 +197,9 @@ export async function maybeShowTelemetryNotice( return; } - // Display notice - console.log( + // Display notice on stderr, not stdout: stdout is reserved for command + // output (raw passthrough text, JSON, etc.) and must stay parser/pipe-safe. + console.error( 'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false' ); diff --git a/test/telemetry/index.test.ts b/test/telemetry/index.test.ts index 3d2afe9151..7db56ddeed 100644 --- a/test/telemetry/index.test.ts +++ b/test/telemetry/index.test.ts @@ -9,7 +9,7 @@ import { getTelemetryConfig } from '../../src/telemetry/config.js'; describe('telemetry/index', () => { let tempDir: string; let originalEnv: NodeJS.ProcessEnv; - let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; let fetchSpy: ReturnType>; beforeEach(() => { @@ -28,8 +28,8 @@ describe('telemetry/index', () => { // Clear all mocks vi.clearAllMocks(); - // Spy on console.log for notice tests - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + // Notice is written to stderr so it never pollutes stdout (raw/JSON output) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // Telemetry must never reach the real network in tests fetchSpy = vi .spyOn(globalThis, 'fetch') @@ -167,7 +167,7 @@ describe('telemetry/index', () => { await maybeShowTelemetryNotice(); - expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); it('should not show notice when telemetry.enabled is false', async () => { @@ -176,21 +176,21 @@ describe('telemetry/index', () => { await maybeShowTelemetryNotice(); - expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); it('should show notice on the first non-silent run, then never repeat it', async () => { enableTelemetry(); await maybeShowTelemetryNotice(); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('OpenSpec collects anonymous usage stats') ); // noticeSeen is now persisted: a second run stays quiet. await maybeShowTelemetryNotice(); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); it('should suppress the notice in silent (--json) mode and defer the disclosure', async () => { @@ -198,15 +198,15 @@ describe('telemetry/index', () => { // A first-ever run in --json mode must not pollute stdout. await maybeShowTelemetryNotice({ silent: true }); - expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); // The disclosure must be deferred, not consumed: noticeSeen stays unset. expect((await getTelemetryConfig()).noticeSeen).toBeFalsy(); // Disclosure is only deferred, not skipped: the next non-JSON run shows it. await maybeShowTelemetryNotice(); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('OpenSpec collects anonymous usage stats') ); });