diff --git a/wasi/src/cli.ts b/wasi/src/cli.ts index 1719cf5..601918a 100644 --- a/wasi/src/cli.ts +++ b/wasi/src/cli.ts @@ -46,6 +46,13 @@ export interface CliOptions { cwd?: string; /** `get-stdin`'s buffer contents; default empty (matches contract: "stdin (empty)"). */ stdinBuffer?: Uint8Array; + /** + * Also `console.log`/`console.error` captured stdout/stderr writes as + * they happen. Default false. For embedders whose guest's stderr is its + * only diagnostic channel (lann/wosh): a buffer nobody reads is a black + * box exactly when something is going wrong. + */ + passthrough?: boolean; /** `exit()` throws `ExitError` instead of merely recording. Default false. */ throwOnExit?: boolean; } @@ -94,15 +101,18 @@ function concat(chunks: Uint8Array[]): Uint8Array { export function cli(options: CliOptions = {}): CliResult { const stdoutChunks: Uint8Array[] = []; const stderrChunks: Uint8Array[] = []; + const passthrough = options.passthrough ?? false; let exited = false; let exitOk: boolean | undefined; let exitCode: number | undefined; const stdout = new OutputStream((chunk) => { stdoutChunks.push(chunk); + if (passthrough) console.log(new TextDecoder().decode(chunk)); }); const stderr = new OutputStream((chunk) => { stderrChunks.push(chunk); + if (passthrough) console.error(new TextDecoder().decode(chunk)); }); const captured: CliCaptured = { @@ -118,11 +128,13 @@ export function cli(options: CliOptions = {}): CliResult { /** 0.3 write-via-stream into a capture buffer (the promise IS the future — embedder-api.md §"Streams and futures"). */ const captureViaStream = ( chunks: Uint8Array[], + mirror: ((text: string) => void) | undefined, ) => async (data: CliByteSource): Promise => { for await (const chunk of data as AsyncIterable) { const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk); chunks.push(bytes); + mirror?.(new TextDecoder().decode(bytes)); } return { kind: "ok" }; }; @@ -188,10 +200,16 @@ export function cli(options: CliOptions = {}): CliResult { ], }, "wasi:cli/stdout@0.3": { - writeViaStream: captureViaStream(stdoutChunks), + writeViaStream: captureViaStream( + stdoutChunks, + passthrough ? (t) => console.log(t) : undefined, + ), }, "wasi:cli/stderr@0.3": { - writeViaStream: captureViaStream(stderrChunks), + writeViaStream: captureViaStream( + stderrChunks, + passthrough ? (t) => console.error(t) : undefined, + ), }, "wasi:cli/terminal-input@0.3": { TerminalInput }, "wasi:cli/terminal-output@0.3": { TerminalOutput }, diff --git a/wasi/tests/cli_test.ts b/wasi/tests/cli_test.ts index f721c87..8c0903f 100644 --- a/wasi/tests/cli_test.ts +++ b/wasi/tests/cli_test.ts @@ -100,6 +100,42 @@ Deno.test("cli: no terminal is ever attached (option collapses to undefined)", ( assertEq(stdinTerm.getTerminalStdin(), undefined); }); +// `passthrough` mirrors writes to the console as they happen, on both +// tracks, while still capturing. A consumer (lann/wosh) depends on it: +// its guest's stderr is the only diagnostic channel it has. +Deno.test("cli: passthrough mirrors stdout/stderr to console on both tracks; off by default", async () => { + const logged: string[] = []; + const errored: string[] = []; + const origLog = console.log, origError = console.error; + console.log = (...a: unknown[]) => logged.push(a.join(" ")); + console.error = (...a: unknown[]) => errored.push(a.join(" ")); + try { + const quiet = cli(); + (quiet.imports["wasi:cli/stdout@0.2"] as { getStdout(): { write(c: Uint8Array): void } }) + .getStdout().write(new TextEncoder().encode("silent")); + assertEq(logged.length, 0); + assertEq(quiet.captured.stdoutText(), "silent"); + + const { imports, captured } = cli({ passthrough: true }); + (imports["wasi:cli/stdout@0.2"] as { getStdout(): { write(c: Uint8Array): void } }) + .getStdout().write(new TextEncoder().encode("out2")); + (imports["wasi:cli/stderr@0.2"] as { getStderr(): { write(c: Uint8Array): void } }) + .getStderr().write(new TextEncoder().encode("err2")); + await (imports["wasi:cli/stderr@0.3"] as { + writeViaStream(data: AsyncIterable): Promise<{ kind: string }>; + }).writeViaStream((async function* () { + yield new TextEncoder().encode("err3"); + })()); + assertEq(JSON.stringify(logged), JSON.stringify(["out2"])); + assertEq(JSON.stringify(errored), JSON.stringify(["err2", "err3"])); + assertEq(captured.stdoutText(), "out2"); + assertEq(captured.stderrText(), "err2err3"); + } finally { + console.log = origLog; + console.error = origError; + } +}); + // --- the @0.3 track (capture impl) --------------------------------------------- Deno.test("cli@0.3: write-via-stream captures; read-via-stream serves the buffer", async () => {