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

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
+105 -101
View File
@@ -4,7 +4,10 @@
// code; a thrown error is left to the caller. Nothing here calls
// `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`).
// drained. Output must stay byte-identical (see `cli-output.ts`). Each line on
// stderr goes through `ctx.log` at its level (see `cli-log.ts`). Every command
// that exits non-zero logs a fatal line saying why; one that exits 1 because
// files or albums failed logs it last, saying how many and where to look.
import {
copyFileSync,
@@ -33,6 +36,7 @@ import {
originalName,
thumbnailName,
} from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js";
import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js";
@@ -40,7 +44,8 @@ import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext {
stdout: { write(text: string): unknown };
stderr: { write(text: string): unknown };
// Writes a line on stderr at a log level, if the run's level prints it.
log: Log;
// Directory holding `session.json`.
sessionDir: string;
// The `--cache-dir` global, or undefined to let the library pick its
@@ -82,16 +87,16 @@ const requireSession = (ctx: CliContext): Client | undefined => {
try {
client = ctx.loadSession(sessionPath(ctx));
} catch (err) {
ctx.stderr.write(
`${err instanceof Error ? err.message : err}\n` +
`Run "quak logout" and then "quak login" to replace it.\n`,
ctx.log("fatal", err instanceof Error ? err.message : String(err));
ctx.log(
"fatal",
`Run "quak logout" and then "quak login" to replace it.`,
);
return undefined;
}
if (!client) {
ctx.stderr.write(
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`,
);
ctx.log("fatal", `Not logged in. Run "quak login" first.`);
ctx.log("fatal", `Session file: ${sessionPath(ctx)}`);
return undefined;
}
return client;
@@ -107,7 +112,7 @@ const parseID = (
): number | undefined => {
const id = Number(text);
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`);
ctx.log("fatal", `Invalid ${kind} ID: ${text}`);
return undefined;
};
@@ -128,13 +133,8 @@ const readLibraryClient = (client: Client): LibraryClient => ({
// downloading the whole account — and no refresh starts as it opens or on a
// timer: each command asks for the one it needs, and `collections` needs none
// (issue #210). While a refresh runs, each collection whose files it has
// fetched is counted on stderr, unless `json` is set, so a large account shows
// progress rather than nothing.
const openReadLibrary = (
ctx: CliContext,
client: Client,
opts: { json?: boolean } = {},
): Promise<Library> =>
// fetched is logged, so a large account shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({
client: readLibraryClient(client),
cacheDirectory: ctx.cacheDir,
@@ -142,12 +142,7 @@ const openReadLibrary = (
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
onProgress: (event) => {
if (opts.json || event.status !== "progress") return;
ctx.stderr.write(
`Fetched the files of ${event.fetched} of ${event.total} collections\n`,
);
},
onProgress: logRefresh(ctx.log),
});
export const loginCommand = async (ctx: CliContext): Promise<number> => {
@@ -156,7 +151,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
const password =
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
ctx.stderr.write("Authenticating...\n");
ctx.log("info", "Authenticating...");
try {
const client = await ctx.login({
email,
@@ -167,11 +162,12 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
saveSession(ctx.sessionDir, client.toJSON());
const info = client.whoami();
ctx.stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`);
ctx.stderr.write(`Session saved to ${sessionPath(ctx)}\n`);
ctx.log("info", `Logged in as ${info.email} (user ${info.userID})`);
ctx.log("info", `Session saved to ${sessionPath(ctx)}`);
} catch (err) {
ctx.stderr.write(
`Login failed: ${err instanceof Error ? err.message : err}\n`,
ctx.log(
"fatal",
`Login failed: ${err instanceof Error ? err.message : err}`,
);
return 1;
}
@@ -193,7 +189,7 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
const path = sessionPath(ctx);
if (!existsSync(path)) {
ctx.stderr.write("No session found.\n");
ctx.log("info", "No session found.");
return 0;
}
await init();
@@ -211,16 +207,15 @@ export const logoutCommand = async (ctx: CliContext): Promise<number> => {
}
unlinkSync(path);
if (failure === undefined) {
ctx.stderr.write("Session ended on the server.\n");
ctx.log("info", "Session ended on the server.");
} else {
ctx.stderr.write(
`Could not end the session on the server: ${failure}\n`,
);
ctx.log("fatal", `Could not end the session on the server: ${failure}`);
}
ctx.stderr.write("Session deleted.\n");
ctx.log("info", "Session deleted.");
if (cacheDir !== undefined) {
ctx.stderr.write(
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`,
ctx.log(
"warning",
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.`,
);
}
return failure === undefined ? 0 : 1;
@@ -285,7 +280,7 @@ export const filesCommand = async (
: parseID(ctx, "collection", collectionIDStr);
if (collectionIDStr !== undefined && collectionID === undefined) return 1;
const lib = await openReadLibrary(ctx, client, opts);
const lib = await openReadLibrary(ctx, client);
try {
// Force a server round-trip and list in enumeration order (issue #36
// amendment, issue #52). Each file prints from its own decrypted
@@ -294,7 +289,7 @@ export const filesCommand = async (
// newest-first order.
const files = await freshFiles(lib, collectionID);
if (!files) {
ctx.stderr.write(`Collection ${collectionID} not found\n`);
ctx.log("fatal", `Collection ${collectionID} not found`);
return 1;
}
@@ -330,7 +325,7 @@ export const getCommand = async (
// (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID);
if (!resolved) {
ctx.stderr.write(`File ${fileID} not found\n`);
ctx.log("fatal", `File ${fileID} not found`);
return 1;
}
const { photo, file } = resolved;
@@ -341,7 +336,7 @@ export const getCommand = async (
if (result.videoPath === undefined) {
const outPath = opts.out ?? originalName(file);
copyFileSync(result.path, outPath);
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
ctx.log("info", `${result.bytes} bytes -> ${outPath}`);
return 0;
}
// A live photo is written as its image and its video, each named after
@@ -355,17 +350,16 @@ export const getCommand = async (
? originalName(file, videoExt)
: withExtension(opts.out, videoExt);
if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
ctx.stderr.write(
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
ctx.log(
"fatal",
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}`,
);
return 1;
}
copyFileSync(result.path, imageOut);
copyFileSync(result.videoPath, videoOut);
ctx.stderr.write(
`${result.bytes} bytes -> ${imageOut}\n` +
`${statSync(videoOut).size} bytes -> ${videoOut}\n`,
);
ctx.log("info", `${result.bytes} bytes -> ${imageOut}`);
ctx.log("info", `${statSync(videoOut).size} bytes -> ${videoOut}`);
return 0;
} finally {
await lib.close();
@@ -389,7 +383,7 @@ export const getThumbCommand = async (
// (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID);
if (!resolved) {
ctx.stderr.write(`File ${fileID} not found\n`);
ctx.log("fatal", `File ${fileID} not found`);
return 1;
}
const { photo, file } = resolved;
@@ -399,7 +393,7 @@ export const getThumbCommand = async (
// used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file);
copyFileSync(result.path, outPath);
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
ctx.log("info", `${result.bytes} bytes -> ${outPath}`);
return 0;
} finally {
await lib.close();
@@ -421,9 +415,14 @@ export const backupMetadataCommand = async (
await lib.fresh();
const result = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all,
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
onProgress: (msg, level) => ctx.log(level ?? "info", msg),
});
return result.filesWithMLDataError > 0 ? 1 : 0;
if (result.filesWithMLDataError === 0) return 0;
ctx.log(
"fatal",
`quak: the ML data of ${result.filesWithMLDataError} file(s) could not be read; see mlDataError in their JSON in ${dir}`,
);
return 1;
} finally {
await lib.close();
}
@@ -438,7 +437,7 @@ export const backupCommand = async (
const client = requireSession(ctx);
if (!client) return 3;
ctx.stderr.write("Starting backup...\n");
ctx.log("info", "Starting backup...");
// The lock is taken before the library opens and starts its refresh, so a
// run refused while another backup of `dir` runs sends no request.
let lock: BackupLock;
@@ -446,7 +445,7 @@ export const backupCommand = async (
lock = await lockBackupDirectory(dir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
ctx.stderr.write(`quak: ${(err as Error).message}\n`);
ctx.log("fatal", `quak: ${(err as Error).message}`);
return 2;
}
try {
@@ -461,45 +460,48 @@ export const backupCommand = async (
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
onProgress: logRefresh(ctx.log),
});
try {
const result = await lib.backup({
downloadDirectory: dir,
lock,
verify: opts.verify,
onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
onProgress: (msg, level) => ctx.log(level ?? "info", msg),
});
if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else {
ctx.stderr.write("\n--- Backup complete ---\n");
ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
ctx.stderr.write(` Skipped: ${result.skipped}\n`);
ctx.log("info", "\n--- Backup complete ---");
ctx.log("info", ` Total files: ${result.totalFiles}`);
ctx.log("info", ` Downloaded: ${result.downloaded}`);
ctx.log("info", ` Skipped: ${result.skipped}`);
if (opts.verify) {
ctx.stderr.write(` Verified: ${result.verified}\n`);
ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
ctx.log("info", ` Verified: ${result.verified}`);
ctx.log("info", ` Mismatched: ${result.mismatched}`);
ctx.log("info", ` Unchecked: ${result.unchecked}`);
}
ctx.stderr.write(` Failed: ${result.failed}\n`);
ctx.log("info", ` Failed: ${result.failed}`);
if (result.errors.length > 0) {
ctx.stderr.write("\nFailed files:\n");
ctx.log("error", "\nFailed files:");
for (const e of result.errors) {
ctx.stderr.write(
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
ctx.log(
"error",
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}`,
);
}
}
}
return result.failed > 0 ||
result.undecryptableAlbums.length > 0 ||
result.failedAlbums.length > 0
? 1
: 0;
const albums =
result.undecryptableAlbums.length + result.failedAlbums.length;
if (result.failed === 0 && albums === 0) return 0;
ctx.log(
"fatal",
`quak: ${result.failed} file(s) and ${albums} album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines`,
);
return 1;
} finally {
await lib.close();
}
@@ -515,23 +517,24 @@ export const listMissingThumbnailsCommand = async (
await init();
const client = requireSession(ctx);
if (!client) return 3;
const lib = await openReadLibrary(ctx, client, opts);
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so files added since the cache was written are
// checked; a failed refresh throws.
await lib.fresh();
const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
});
const missing = await listMissingThumbnails(lib, client, (msg, level) =>
ctx.log(level ?? "info", msg),
);
if (opts.json) {
ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n");
} else {
if (missing.length === 0) {
ctx.stderr.write("No missing thumbnails found.\n");
ctx.log("info", "No missing thumbnails found.");
} else {
ctx.stderr.write(
`\n${missing.length} file(s) with missing thumbnails:\n`,
ctx.log(
"info",
`\n${missing.length} file(s) with missing thumbnails:`,
);
for (const m of missing) {
ctx.stdout.write(
@@ -560,33 +563,34 @@ export const fixMissingThumbnailsCommand = async (
if (fileID === undefined) return 1;
fileIDs.push(fileID);
}
const lib = await openReadLibrary(ctx, client, opts);
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so files added since the cache was written are found;
// a failed refresh throws.
await lib.fresh();
if (fileIDs.length === 0) {
ctx.stderr.write("Scanning for missing thumbnails...\n");
const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
});
ctx.log("info", "Scanning for missing thumbnails...");
const missing = await listMissingThumbnails(
lib,
client,
(msg, level) => ctx.log(level ?? "info", msg),
);
fileIDs = missing.map((m) => m.fileID);
if (fileIDs.length === 0) {
ctx.stderr.write("No missing thumbnails found.\n");
ctx.log("info", "No missing thumbnails found.");
return 0;
}
ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`);
}
const results = await fixMissingThumbnails(
lib,
client,
fileIDs,
(msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
(msg, level) => ctx.log(level ?? "info", msg),
);
const failed = results.filter((r) => r.status === "failed").length;
if (opts.json) {
ctx.stdout.write(JSON.stringify(results, null, 2) + "\n");
} else {
@@ -594,30 +598,30 @@ export const fixMissingThumbnailsCommand = async (
const skipped = results.filter(
(r) => r.status === "skipped",
).length;
const failed = results.filter((r) => r.status === "failed").length;
ctx.stderr.write(`\n--- Done ---\n`);
ctx.stderr.write(` Fixed: ${fixed}\n`);
ctx.stderr.write(` Skipped: ${skipped}\n`);
ctx.stderr.write(` Failed: ${failed}\n`);
ctx.log("info", `\n--- Done ---`);
ctx.log("info", ` Fixed: ${fixed}`);
ctx.log("info", ` Skipped: ${skipped}`);
ctx.log("info", ` Failed: ${failed}`);
if (skipped > 0) {
ctx.stderr.write("\nSkipped:\n");
ctx.log("info", "\nSkipped:");
for (const r of results.filter((r) => r.status === "skipped")) {
ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
ctx.log("info", ` ${r.fileID}\t${r.title}\t${r.reason}`);
}
}
if (failed > 0) {
ctx.stderr.write("\nFailed files:\n");
ctx.log("error", "\nFailed files:");
for (const r of results.filter((r) => r.status === "failed")) {
ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
ctx.log("error", ` ${r.fileID}\t${r.title}\t${r.reason}`);
}
}
}
return results.some((r) => r.status === "failed") ? 1 : 0;
if (failed === 0) return 0;
ctx.log(
"fatal",
`quak: ${failed} file(s) failed; see the "Failed files:" list, or the JSON with --json`,
);
return 1;
} finally {
await lib.close();
}