diff --git a/TODO.md b/TODO.md index 739387d..04d5c47 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,12 @@ Tag v1.0.0. # Completed Steps +- 2026-09-23: CLI errors print a message instead of a stack trace (issue 102). + An error a command throws is printed as one `quak: MESSAGE` line on stderr and + the CLI exits 1 once output has drained. The wrapper that does this moved from + `bin/quak.ts` to `src/cli-run.ts`, and `bin/quak.ts` now awaits + `program.parseAsync()`. + - 2026-09-23: Opening a library no longer deletes another process's download in progress (issue 105). The download writer's temp files are named `.quak--.tmp`, and `removeLeftoverTempFiles`, moved from the diff --git a/bin/quak.ts b/bin/quak.ts index 75ecb8c..315d82b 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -18,6 +18,7 @@ import { listMissingThumbnailsCommand, fixMissingThumbnailsCommand, } from "../src/cli-commands.js"; +import { run as runCommand } from "../src/cli-run.js"; import { loadSession } from "../src/cli-session.js"; import { VERSION } from "../src/index.js"; @@ -43,23 +44,8 @@ const context = (): CliContext => ({ loadSession, }); -// Run a command and exit with its code once stdout/stderr have drained. -// Exiting before the drain can truncate piped output, and the library can keep -// the event loop alive after a command returns, so a plain return could hang. -const run = async (command: Promise): Promise => { - process.exitCode = await command; - const pending = [stdout, stderr].filter((s) => s.writableLength > 0); - if (pending.length === 0) { - process.exit(); - return; - } - let remaining = pending.length; - for (const s of pending) { - s.once("drain", () => { - if (--remaining === 0) process.exit(); - }); - } -}; +const run = (command: Promise): Promise => + runCommand(command, stdout, stderr, (code) => process.exit(code)); program .command("login") @@ -169,4 +155,4 @@ helper ); await init(); -program.parse(); +await program.parseAsync(); diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 4a16798..67a40d0 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -2,8 +2,9 @@ // // Each command takes its options and a `CliContext` and resolves to the exit // code; a thrown error is left to the caller. Nothing here calls -// `process.exit`: `bin/quak.ts` wires these to the command line and exits with -// the returned code once output has drained. Output must stay byte-identical +// `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in +// `cli-run.ts` prints a thrown error as one line and exits once output has +// drained. Output must stay byte-identical // (see `cli-output.ts`). import { input, password as passwordPrompt } from "@inquirer/prompts"; diff --git a/src/cli-run.ts b/src/cli-run.ts new file mode 100644 index 0000000..c668b49 --- /dev/null +++ b/src/cli-run.ts @@ -0,0 +1,36 @@ +// Runs one CLI command for `bin/quak.ts` and exits with its code. + +import type { Writable } from "node:stream"; + +// Run a command and exit with its code once stdout/stderr have drained. +// Exiting before the drain can truncate piped output, and the library can keep +// the event loop alive after a command returns, so a plain return could hang. +// An error the command throws is printed as one `quak: MESSAGE` line, without +// the stack trace, and exits 1. +export const run = async ( + command: Promise, + stdout: Writable, + stderr: Writable, + exit: (code: number) => void, +): Promise => { + let code: number; + try { + code = await command; + } catch (err) { + stderr.write( + `quak: ${err instanceof Error ? err.message : String(err)}\n`, + ); + code = 1; + } + const pending = [stdout, stderr].filter((s) => s.writableLength > 0); + if (pending.length === 0) { + exit(code); + return; + } + let remaining = pending.length; + for (const s of pending) { + s.once("drain", () => { + if (--remaining === 0) exit(code); + }); + } +}; diff --git a/test/cli/run.test.ts b/test/cli/run.test.ts new file mode 100644 index 0000000..29d0a92 --- /dev/null +++ b/test/cli/run.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for `run` in `src/cli-run.ts`, which every CLI command goes through. + */ + +import { PassThrough } from "node:stream"; +import { describe, it, expect } from "vitest"; + +import { run } from "../../src/cli-run.js"; + +// A stream whose written text is kept in `text`; writes finish at once, so +// nothing is left waiting to drain. +const collector = (): { stream: PassThrough; text: () => string } => { + const stream = new PassThrough(); + const chunks: string[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk.toString())); + return { stream, text: () => chunks.join("") }; +}; + +const runToExit = async ( + command: Promise, +): Promise<{ code: number; stdout: string; stderr: string }> => { + const stdout = collector(); + const stderr = collector(); + const code = await new Promise((resolve) => { + void run(command, stdout.stream, stderr.stream, resolve); + }); + return { code, stdout: stdout.text(), stderr: stderr.text() }; +}; + +describe("run", () => { + it("exits with the code the command returns", async () => { + const result = await runToExit(Promise.resolve(3)); + expect(result).toEqual({ code: 3, stdout: "", stderr: "" }); + }); + + it("prints a thrown error as one line without a stack trace and exits 1", async () => { + const failing = async (): Promise => { + throw new Error( + "ENOTDIR: not a directory, mkdir '/dev/null/x/originals'", + ); + }; + const result = await runToExit(failing()); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "quak: ENOTDIR: not a directory, mkdir '/dev/null/x/originals'\n", + ); + }); + + it("prints a thrown value that is not an Error", async () => { + const result = await runToExit(Promise.reject("offline")); + expect(result).toEqual({ + code: 1, + stdout: "", + stderr: "quak: offline\n", + }); + }); +});