Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ async function runCli(args: string[], stdinBytes?: Uint8Array): Promise<RunResul
return { code, stdout, stderr };
}

async function runCliSubprocess(args: string[]): Promise<RunResult> {
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<T>(result: RunResult): T {
const source = result.stdout.trim() || result.stderr.trim();
if (!source) {
Expand Down Expand Up @@ -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);
Expand Down
143 changes: 96 additions & 47 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ type ParsedInvocation = {
rest: string[];
};

type ConsoleMethod = 'debug' | 'info' | 'log' | 'warn' | 'error';
type ConsoleSnapshot = Pick<typeof console, ConsoleMethod>;

/** The result of a programmatic CLI invocation via {@link invokeCommand}. */
export type InvokeCommandResult = {
globals: GlobalOptions;
Expand Down Expand Up @@ -174,6 +177,39 @@ function applyDiagnosticPolicy(io: CliIO, globals: GlobalOptions): CliIO {
};
}

async function withConsoleDiagnosticPolicy<T>(globals: GlobalOptions, operation: () => Promise<T>): Promise<T> {
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.');
Expand Down Expand Up @@ -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 };
});

Expand Down Expand Up @@ -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;
}
});
Expand Down
Loading