Skip to content
Merged
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
22 changes: 20 additions & 2 deletions wasi/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = {
Expand All @@ -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<CliIoResult> => {
for await (const chunk of data as AsyncIterable<Uint8Array | number[]>) {
const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk);
chunks.push(bytes);
mirror?.(new TextDecoder().decode(bytes));
}
return { kind: "ok" };
};
Expand Down Expand Up @@ -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 },
Expand Down
36 changes: 36 additions & 0 deletions wasi/tests/cli_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>): 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 () => {
Expand Down
Loading