Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5b90104f |
@@ -18,6 +18,12 @@ Tag v1.0.0.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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: Stopped `helper fix-missing-thumbnails` retrying files the server
|
- 2026-09-23: Stopped `helper fix-missing-thumbnails` retrying files the server
|
||||||
always refuses (issue 109). Both thumbnail helpers skip a file another account
|
always refuses (issue 109). Both thumbnail helpers skip a file another account
|
||||||
owns without fetching it. The fixer skips a file whose recorded thumbnail size
|
owns without fetching it. The fixer skips a file whose recorded thumbnail size
|
||||||
|
|||||||
+4
-18
@@ -18,6 +18,7 @@ import {
|
|||||||
listMissingThumbnailsCommand,
|
listMissingThumbnailsCommand,
|
||||||
fixMissingThumbnailsCommand,
|
fixMissingThumbnailsCommand,
|
||||||
} from "../src/cli-commands.js";
|
} from "../src/cli-commands.js";
|
||||||
|
import { run as runCommand } from "../src/cli-run.js";
|
||||||
import { loadSession } from "../src/cli-session.js";
|
import { loadSession } from "../src/cli-session.js";
|
||||||
import { VERSION } from "../src/index.js";
|
import { VERSION } from "../src/index.js";
|
||||||
|
|
||||||
@@ -43,23 +44,8 @@ const context = (): CliContext => ({
|
|||||||
loadSession,
|
loadSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run a command and exit with its code once stdout/stderr have drained.
|
const run = (command: Promise<number>): Promise<void> =>
|
||||||
// Exiting before the drain can truncate piped output, and the library can keep
|
runCommand(command, stdout, stderr, (code) => process.exit(code));
|
||||||
// the event loop alive after a command returns, so a plain return could hang.
|
|
||||||
const run = async (command: Promise<number>): Promise<void> => {
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("login")
|
.command("login")
|
||||||
@@ -169,4 +155,4 @@ helper
|
|||||||
);
|
);
|
||||||
|
|
||||||
await init();
|
await init();
|
||||||
program.parse();
|
await program.parseAsync();
|
||||||
|
|||||||
+3
-2
@@ -2,8 +2,9 @@
|
|||||||
//
|
//
|
||||||
// Each command takes its options and a `CliContext` and resolves to the exit
|
// 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
|
// 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
|
// `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in
|
||||||
// the returned code once output has drained. Output must stay byte-identical
|
// `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`).
|
// (see `cli-output.ts`).
|
||||||
|
|
||||||
import { input, password as passwordPrompt } from "@inquirer/prompts";
|
import { input, password as passwordPrompt } from "@inquirer/prompts";
|
||||||
|
|||||||
@@ -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<number>,
|
||||||
|
stdout: Writable,
|
||||||
|
stderr: Writable,
|
||||||
|
exit: (code: number) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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<number>,
|
||||||
|
): Promise<{ code: number; stdout: string; stderr: string }> => {
|
||||||
|
const stdout = collector();
|
||||||
|
const stderr = collector();
|
||||||
|
const code = await new Promise<number>((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<number> => {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user