diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts index c62df7233f..df78d430df 100644 --- a/apps/cli/src/__tests__/cli.test.ts +++ b/apps/cli/src/__tests__/cli.test.ts @@ -107,6 +107,31 @@ async function runCli(args: string[], stdinBytes?: Uint8Array): Promise { + try { + const { stdout, stderr } = await execFileAsync( + process.execPath, + ['run', join(REPO_ROOT, 'apps/cli/src/index.ts'), ...args], + { + env: { + ...process.env, + NODE_ENV: '', + }, + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }, + ); + + return { code: 0, stdout, stderr }; + } catch (error) { + const failed = error as { code?: number; stdout?: string; stderr?: string }; + return { + code: typeof failed.code === 'number' ? failed.code : 1, + stdout: failed.stdout ?? '', + stderr: failed.stderr ?? '', + }; + } +} + function parseJsonOutput(result: RunResult): T { const source = result.stdout.trim() || result.stderr.trim(); if (!source) { @@ -300,6 +325,22 @@ describe('superdoc CLI', () => { expect(envelope.meta.elapsedMs).toBeGreaterThanOrEqual(0); }); + test('json output is parseable from the CLI process when NODE_ENV is not test', async () => { + const result = await runCliSubprocess(['info', SAMPLE_DOC, '--json']); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout.trimStart().startsWith('{')).toBe(true); + expect(result.stdout).not.toContain('[super-editor] Telemetry: enabled'); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + counts: { words: number; paragraphs: number }; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.command).toBe('info'); + expect(envelope.data.counts.words).toBeGreaterThan(0); + expect(envelope.data.counts.paragraphs).toBeGreaterThan(0); + }); + test('info pretty includes revision summary and outline section when available', async () => { const jsonResult = await runCli(['info', SAMPLE_DOC]); expect(jsonResult.code).toBe(0); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5d5940fcb7..67028192ca 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -66,6 +66,9 @@ type ParsedInvocation = { rest: string[]; }; +type ConsoleMethod = 'debug' | 'info' | 'log' | 'warn' | 'error'; +type ConsoleSnapshot = Pick; + /** The result of a programmatic CLI invocation via {@link invokeCommand}. */ export type InvokeCommandResult = { globals: GlobalOptions; @@ -174,6 +177,39 @@ function applyDiagnosticPolicy(io: CliIO, globals: GlobalOptions): CliIO { }; } +async function withConsoleDiagnosticPolicy(globals: GlobalOptions, operation: () => Promise): Promise { + if (globals.output === 'pretty' && !globals.quiet) { + return operation(); + } + + const original: ConsoleSnapshot = { + debug: console.debug, + info: console.info, + log: console.log, + warn: console.warn, + error: console.error, + }; + const suppress = (..._values: unknown[]) => { + return; + }; + + console.debug = suppress; + console.info = suppress; + console.log = suppress; + console.warn = suppress; + console.error = suppress; + + try { + return await operation(); + } finally { + console.debug = original.debug; + console.info = original.info; + console.log = original.log; + console.warn = original.warn; + console.error = original.error; + } +} + function parseCommand(rest: string[]): { key: string; args: string[] } { if (rest.length === 0) { throw new CliError('MISSING_REQUIRED', 'Missing command.'); @@ -312,13 +348,15 @@ export async function invokeCommand(argv: string[], options: InvokeCommandOption const startedAt = io.now(); const { parsed, output } = await withStateDirOverride(options.stateDir, async () => { const parsedInvocation = parseInvocation(argv); - const runtimeIo = applyDiagnosticPolicy(io, parsedInvocation.globals); - const commandOutput = await executeParsedInvocation( - parsedInvocation, - runtimeIo, - options.executionMode ?? 'oneshot', - options.sessionPool, - ); + const commandOutput = await withConsoleDiagnosticPolicy(parsedInvocation.globals, async () => { + const runtimeIo = applyDiagnosticPolicy(io, parsedInvocation.globals); + return executeParsedInvocation( + parsedInvocation, + runtimeIo, + options.executionMode ?? 'oneshot', + options.sessionPool, + ); + }); return { parsed: parsedInvocation, output: commandOutput }; }); @@ -355,61 +393,72 @@ export async function run( let outputMode: OutputMode = 'json'; return withStateDirOverride(options.stateDir, async () => { + let parsedGlobals: GlobalOptions | null = null; try { const parsed = parseInvocation(argv); + parsedGlobals = parsed.globals; outputMode = parsed.globals.output; - const runtimeIo = applyDiagnosticPolicy(io, parsed.globals); - if (parsed.globals.version && !parsed.globals.help) { - io.stdout(`${resolveCliPackageVersion()}\n`); - return 0; - } + return await withConsoleDiagnosticPolicy(parsed.globals, async () => { + const runtimeIo = applyDiagnosticPolicy(io, parsed.globals); - if (parsed.rest[0] === 'host') { - const hostTokens = parsed.rest.slice(1); - if (parsed.globals.help) hostTokens.push('--help'); - return await runHostCommand(hostTokens, io); - } + if (parsed.globals.version && !parsed.globals.help) { + io.stdout(`${resolveCliPackageVersion()}\n`); + return 0; + } - if (parsed.rest[0] === 'install' && !parsed.globals.help) { - return await runInstall(parsed.rest.slice(1), io); - } + if (parsed.rest[0] === 'host') { + const hostTokens = parsed.rest.slice(1); + if (parsed.globals.help) hostTokens.push('--help'); + return await runHostCommand(hostTokens, io); + } - if (parsed.rest[0] === 'uninstall' && !parsed.globals.help) { - return await runUninstall(parsed.rest.slice(1), io); - } + if (parsed.rest[0] === 'install' && !parsed.globals.help) { + return await runInstall(parsed.rest.slice(1), io); + } - if (parsed.rest[0] === 'call' && outputMode !== 'json') { - throw new CliError('INVALID_ARGUMENT', 'call: only --output json is supported.'); - } + if (parsed.rest[0] === 'uninstall' && !parsed.globals.help) { + return await runUninstall(parsed.rest.slice(1), io); + } - if (!parsed.globals.help) { - const legacyCompat = await tryRunLegacyCompatCommand(argv, parsed.rest, io); - if (legacyCompat.handled) { - return legacyCompat.exitCode; + if (parsed.rest[0] === 'call' && outputMode !== 'json') { + throw new CliError('INVALID_ARGUMENT', 'call: only --output json is supported.'); } - } - const output = await executeParsedInvocation(parsed, runtimeIo, 'oneshot'); - if (output.helpText) { - io.stdout(output.helpText); - return 0; - } - if (output.versionText) { - io.stdout(`${output.versionText}\n`); - return 0; - } - if (!output.execution) { - throw new CliError('COMMAND_FAILED', 'Command produced no execution result, help text, or version text.'); - } + if (!parsed.globals.help) { + const legacyCompat = await tryRunLegacyCompatCommand(argv, parsed.rest, io); + if (legacyCompat.handled) { + return legacyCompat.exitCode; + } + } - const elapsedMs = io.now() - startedAt; - writeSuccess(io, outputMode, output.execution, elapsedMs); - return 0; + const output = await executeParsedInvocation(parsed, runtimeIo, 'oneshot'); + if (output.helpText) { + io.stdout(output.helpText); + return 0; + } + if (output.versionText) { + io.stdout(`${output.versionText}\n`); + return 0; + } + if (!output.execution) { + throw new CliError('COMMAND_FAILED', 'Command produced no execution result, help text, or version text.'); + } + + const elapsedMs = io.now() - startedAt; + writeSuccess(io, outputMode, output.execution, elapsedMs); + return 0; + }); } catch (error) { const cliError = toCliError(error); const elapsedMs = io.now() - startedAt; - writeFailure(io, outputMode, cliError, elapsedMs); + if (parsedGlobals) { + await withConsoleDiagnosticPolicy(parsedGlobals, async () => { + writeFailure(io, outputMode, cliError, elapsedMs); + }); + } else { + writeFailure(io, outputMode, cliError, elapsedMs); + } return cliError.exitCode; } });