-v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212)
check / check (push) Canceled after 0s
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:
+105
-101
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user