-v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212)
check / check (push) Waiting to run

Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks.

Model: opus-5-5
This commit was merged in pull request #216.
This commit is contained in:
2026-10-18 21:46:59 +02:00
parent 92e0c6c791
commit 68d22b9927
21 changed files with 1099 additions and 188 deletions
+22 -8
View File
@@ -47,6 +47,7 @@ import {
type MetadataBackupOptions,
} from "../../src/metadata-backup.js";
import { backupMetadataCommand } from "../../src/cli-commands.js";
import { stderrLog, type LogLevel } from "../../src/cli-log.js";
import {
buildAccount,
buildMetaFetch,
@@ -490,9 +491,12 @@ describe("quak backup-metadata", () => {
});
describe("quak backup-metadata when a file's ML data cannot be read", () => {
// Run the CLI command against `fetch`, the mock unless given, and return
// its exit code, stderr and output directory.
const runCommand = async (fetch = buildMetaFetch(mock)) => {
// Run the CLI command against `fetch`, the mock unless given, logging at
// `level`, and return its exit code, stderr and output directory.
const runCommand = async (
fetch = buildMetaFetch(mock),
level: LogLevel = "info",
) => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
@@ -506,7 +510,10 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
const code = await backupMetadataCommand(
{
stdout: { write: () => true },
stderr: { write: (text: string) => (stderr += text) },
log: stderrLog(
{ write: (text: string) => (stderr += text) },
level,
),
sessionDir: testDir,
cacheDir: mkdtempSync(join(testDir, "cache-")),
loadSession: () => client,
@@ -517,13 +524,16 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
return { code, stderr, outDir };
};
// The failed request is logged at error, so it is the first line printed
// at that level.
it("writes every file, marks the failed batch's files, and exits 1", async () => {
const { code, stderr, outDir } = await runCommand(
buildMetaFetch(mock, 200),
"error",
);
expect(code).toBe(1);
expect(stderr).toContain("ML data request for 1 file(s) failed");
expect(stderr).toMatch(/^ML data request for 1 file\(s\) failed: /);
const ok = JSON.parse(
readFileSync(
@@ -546,7 +556,9 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
});
// The server sends ML data for diagram.png encrypted under a key that is
// not the file's, so quak cannot decrypt it.
// not the file's, so quak cannot decrypt it. That is logged at error, and
// the last line is fatal, so `-q`, which prints fatal lines only, still
// says why the command exits 1.
it("writes the reason for ML data that does not decrypt, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob(
@@ -564,12 +576,14 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
},
},
}),
"error",
);
expect(code).toBe(1);
const reason = "secretstream chunk authentication failed";
expect(stderr).toContain(
`ML data of file 200 could not be read: ${reason}\n`,
expect(stderr).toBe(
`ML data of file 200 could not be read: ${reason}\n` +
`quak: the ML data of 1 file(s) could not be read; see mlDataError in their JSON in ${outDir}\n`,
);
const failed = JSON.parse(
readFileSync(