check / check (push) Successful in 27s
Library.close() now returns a promise that resolves once the work it started has finished: an in-flight refresh with its cache write, the ML data fetch, and running precache sweeps. The interval test could see a refresh's new state, close, and remove the directory while the write was still running. Every library test and CLI command now awaits close(), and tests hold each of the three writes open to prove close() waits for it. The precache test waited for its stub source to be called, but the cache records a file only after checking it on disk, so status() could lag. It now waits for both fills to report "done". Model: opus-5-5
487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
// The CLI's commands as plain functions.
|
|
//
|
|
// 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
|
|
// (see `cli-output.ts`).
|
|
|
|
import { input, password as passwordPrompt } from "@inquirer/prompts";
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import { Client, type ClientSnapshot } from "./client.js";
|
|
import { init } from "./crypto/index.js";
|
|
import { Library, type LibraryClient } from "./library/index.js";
|
|
import {
|
|
fileListRow,
|
|
fileListLine,
|
|
originalName,
|
|
thumbnailName,
|
|
} from "./cli-output.js";
|
|
import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
|
|
import { runMetadataBackup } from "./metadata-backup.js";
|
|
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
|
|
|
|
export interface CliContext {
|
|
stdout: { write(text: string): unknown };
|
|
stderr: { write(text: string): unknown };
|
|
// Directory holding `session.json`.
|
|
sessionDir: string;
|
|
// The `--cache-dir` global, or undefined to let the library pick its
|
|
// per-user default keyed by the account id.
|
|
cacheDir?: string;
|
|
// Reads the session file into a client, or null when there is none. The
|
|
// CLI passes `loadSession` from `cli-session.ts`; tests pass a fake client.
|
|
loadSession: (path: string) => Client | null;
|
|
}
|
|
|
|
const sessionPath = (ctx: CliContext): string =>
|
|
join(ctx.sessionDir, "session.json");
|
|
|
|
// Write the session readable by its owner only, in a directory only its owner
|
|
// can enter.
|
|
export const saveSession = (
|
|
sessionDir: string,
|
|
snapshot: ClientSnapshot,
|
|
): void => {
|
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
writeFileSync(
|
|
join(sessionDir, "session.json"),
|
|
JSON.stringify(snapshot, null, 2),
|
|
{ mode: 0o600 },
|
|
);
|
|
};
|
|
|
|
// The saved client, or undefined after telling the user why there is none.
|
|
const requireSession = (ctx: CliContext): Client | undefined => {
|
|
let client: Client | null;
|
|
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`,
|
|
);
|
|
return undefined;
|
|
}
|
|
if (!client) {
|
|
ctx.stderr.write(
|
|
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`,
|
|
);
|
|
return undefined;
|
|
}
|
|
return client;
|
|
};
|
|
|
|
// A library client that omits `fetchMLData`, so the point commands below do not
|
|
// kick the library's background ML backfill: they read metadata, or fetch one
|
|
// file's content, and exit. `backup` and `backup-metadata` handle ML on their
|
|
// own terms. The content source is kept so `get`/`get-thumb`/`--exif` can fetch
|
|
// originals through the on-disk cache.
|
|
const readLibraryClient = (client: Client): LibraryClient => ({
|
|
whoami: () => client.whoami(),
|
|
collectionsSince: (args) => client.collectionsSince(args),
|
|
filesSince: (args) => client.filesSince(args),
|
|
contentSource: () => client.contentSource(),
|
|
});
|
|
|
|
// Open a library for a single point command: the aggressive background precache
|
|
// (issue #48) is off — a one-shot `collections` or `get` must not start
|
|
// downloading the whole account — and the refresh interval is long so no second
|
|
// refresh fires mid-command.
|
|
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
|
|
Library.open({
|
|
client: readLibraryClient(client),
|
|
cacheDirectory: ctx.cacheDir,
|
|
refreshIntervalSeconds: 3600,
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
});
|
|
|
|
const prompt = async (message: string): Promise<string> => input({ message });
|
|
|
|
const promptSecret = async (message: string): Promise<string> =>
|
|
passwordPrompt({ message, mask: true });
|
|
|
|
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
|
await init();
|
|
const email = process.env.QUAK_EMAIL ?? (await prompt("Email"));
|
|
const password =
|
|
process.env.QUAK_PASSWORD ?? (await promptSecret("Password"));
|
|
|
|
ctx.stderr.write("Authenticating...\n");
|
|
try {
|
|
const client = await Client.login({
|
|
email,
|
|
password,
|
|
totp: async () => prompt("TOTP code: "),
|
|
emailOTP: async () => prompt("Email verification code: "),
|
|
});
|
|
|
|
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`);
|
|
} catch (err) {
|
|
ctx.stderr.write(
|
|
`Login failed: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const info = client.whoami();
|
|
ctx.stdout.write(JSON.stringify(info) + "\n");
|
|
return 0;
|
|
};
|
|
|
|
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
|
|
if (existsSync(sessionPath(ctx))) {
|
|
unlinkSync(sessionPath(ctx));
|
|
ctx.stderr.write("Session deleted.\n");
|
|
} else {
|
|
ctx.stderr.write("No session found.\n");
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
export const collectionsCommand = async (
|
|
ctx: CliContext,
|
|
opts: { json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
// Force a server round-trip and list in enumeration order (issue #36
|
|
// amendment, issue #52): the pre-library CLI printed current state in
|
|
// this order, not the albums projection's newest-first order.
|
|
const collections = await freshCollections(lib);
|
|
|
|
if (opts.json) {
|
|
ctx.stdout.write(
|
|
JSON.stringify(
|
|
collections.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
type: c.type,
|
|
ownerID: c.ownerID,
|
|
isShared: c.isShared,
|
|
updationTime: c.updationTime,
|
|
})),
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
} else {
|
|
for (const c of collections) {
|
|
ctx.stdout.write(
|
|
`${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`,
|
|
);
|
|
}
|
|
}
|
|
return 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const filesCommand = async (
|
|
ctx: CliContext,
|
|
opts: { collection: string; json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const collectionID = Number(opts.collection);
|
|
if (!Number.isFinite(collectionID)) {
|
|
ctx.stderr.write("Invalid collection ID\n");
|
|
return 1;
|
|
}
|
|
|
|
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
|
|
// metadata (raw title, microsecond creationTime) via cli-output, and in
|
|
// the pre-library CLI's enumeration order, not the projection's
|
|
// newest-first order.
|
|
const files = await freshFiles(lib, collectionID);
|
|
if (!files) {
|
|
ctx.stderr.write(`Collection ${collectionID} not found\n`);
|
|
return 1;
|
|
}
|
|
|
|
if (opts.json) {
|
|
ctx.stdout.write(
|
|
JSON.stringify(files.map(fileListRow), null, 2) + "\n",
|
|
);
|
|
} else {
|
|
for (const file of files) {
|
|
ctx.stdout.write(fileListLine(file) + "\n");
|
|
}
|
|
}
|
|
return 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const getCommand = async (
|
|
ctx: CliContext,
|
|
fileIDStr: string,
|
|
opts: { out?: string },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const fileID = Number(fileIDStr);
|
|
if (!Number.isFinite(fileID)) {
|
|
ctx.stderr.write("Invalid file ID\n");
|
|
return 1;
|
|
}
|
|
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
// Force a server round-trip so the file resolves against current state
|
|
// (issue #36 amendment, issue #52).
|
|
const resolved = await freshFile(lib, fileID);
|
|
if (!resolved) {
|
|
ctx.stderr.write(`File ${fileID} not found\n`);
|
|
return 1;
|
|
}
|
|
const { photo, file } = resolved;
|
|
|
|
const result = await photo.original();
|
|
// Default name is the file's own title, as the pre-library CLI used
|
|
// (not the editedName-preferring projection title) (issue #52).
|
|
const outPath = opts.out ?? originalName(file);
|
|
copyFileSync(result.path, outPath);
|
|
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
|
return 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const getThumbCommand = async (
|
|
ctx: CliContext,
|
|
fileIDStr: string,
|
|
opts: { out?: string },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const fileID = Number(fileIDStr);
|
|
if (!Number.isFinite(fileID)) {
|
|
ctx.stderr.write("Invalid file ID\n");
|
|
return 1;
|
|
}
|
|
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
// Force a server round-trip so the file resolves against current state
|
|
// (issue #36 amendment, issue #52).
|
|
const resolved = await freshFile(lib, fileID);
|
|
if (!resolved) {
|
|
ctx.stderr.write(`File ${fileID} not found\n`);
|
|
return 1;
|
|
}
|
|
const { photo, file } = resolved;
|
|
|
|
const result = await photo.thumbnail();
|
|
// Default name is thumb_<file's own title>, as the pre-library CLI
|
|
// 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`);
|
|
return 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const backupMetadataCommand = async (
|
|
ctx: CliContext,
|
|
dir: string,
|
|
opts: { exif?: boolean; all?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
await runMetadataBackup(lib, client, dir, {
|
|
exif: opts.exif || opts.all,
|
|
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
|
});
|
|
return 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const backupCommand = async (
|
|
ctx: CliContext,
|
|
dir: string,
|
|
opts: { json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
|
|
ctx.stderr.write("Starting backup...\n");
|
|
const lib = await Library.open({
|
|
client,
|
|
downloadDirectory: dir,
|
|
cacheDirectory: ctx.cacheDir,
|
|
});
|
|
try {
|
|
const result = await lib.backup({
|
|
downloadDirectory: dir,
|
|
onProgress: (msg) => {
|
|
if (!opts.json) ctx.stderr.write(msg + "\n");
|
|
},
|
|
});
|
|
|
|
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.stderr.write(` Failed: ${result.failed}\n`);
|
|
if (result.errors.length > 0) {
|
|
ctx.stderr.write("\nFailed files:\n");
|
|
for (const e of result.errors) {
|
|
ctx.stderr.write(
|
|
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result.failed > 0 ? 1 : 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const listMissingThumbnailsCommand = async (
|
|
ctx: CliContext,
|
|
opts: { json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
const missing = await listMissingThumbnails(lib, client, (msg) => {
|
|
if (!opts.json) ctx.stderr.write(msg + "\n");
|
|
});
|
|
|
|
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");
|
|
} else {
|
|
ctx.stderr.write(
|
|
`\n${missing.length} file(s) with missing thumbnails:\n`,
|
|
);
|
|
for (const m of missing) {
|
|
ctx.stdout.write(
|
|
`${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const fixMissingThumbnailsCommand = async (
|
|
ctx: CliContext,
|
|
opts: { file?: string[]; json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
let fileIDs: number[];
|
|
if (opts.file && opts.file.length > 0) {
|
|
fileIDs = opts.file.map(Number).filter(Number.isFinite);
|
|
} else {
|
|
ctx.stderr.write("Scanning for missing thumbnails...\n");
|
|
const missing = await listMissingThumbnails(lib, client, (msg) => {
|
|
if (!opts.json) ctx.stderr.write(msg + "\n");
|
|
});
|
|
fileIDs = missing.map((m) => m.fileID);
|
|
if (fileIDs.length === 0) {
|
|
ctx.stderr.write("No missing thumbnails found.\n");
|
|
return 0;
|
|
}
|
|
ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
|
|
}
|
|
|
|
const results = await fixMissingThumbnails(
|
|
lib,
|
|
client,
|
|
fileIDs,
|
|
(msg) => {
|
|
if (!opts.json) ctx.stderr.write(msg + "\n");
|
|
},
|
|
);
|
|
|
|
if (opts.json) {
|
|
ctx.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
} else {
|
|
const fixed = results.filter((r) => r.status === "fixed").length;
|
|
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`);
|
|
if (skipped > 0) {
|
|
ctx.stderr.write("\nSkipped (unsupported format):\n");
|
|
for (const r of results.filter((r) => r.status === "skipped")) {
|
|
ctx.stderr.write(
|
|
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
if (failed > 0) {
|
|
ctx.stderr.write("\nFailed files:\n");
|
|
for (const r of results.filter((r) => r.status === "failed")) {
|
|
ctx.stderr.write(
|
|
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return results.some((r) => r.status === "failed") ? 1 : 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|