// 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 `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`). 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 { defaultCacheDirectory, 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.open({ client: readLibraryClient(client), cacheDirectory: ctx.cacheDir, refreshIntervalSeconds: 3600, precacheThumbnails: false, precacheOriginals: false, }); const prompt = async (message: string): Promise => input({ message }); const promptSecret = async (message: string): Promise => passwordPrompt({ message, mask: true }); export const loginCommand = async (ctx: CliContext): Promise => { 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 => { await init(); const client = requireSession(ctx); if (!client) return 1; const info = client.whoami(); ctx.stdout.write(JSON.stringify(info) + "\n"); return 0; }; // Ends the session on the server, then deletes the session file even when that // failed, and exits 1 if it did. The cache is left in place; the user is told // where it is. export const logoutCommand = async (ctx: CliContext): Promise => { const path = sessionPath(ctx); if (!existsSync(path)) { ctx.stderr.write("No session found.\n"); return 0; } await init(); let cacheDir = ctx.cacheDir; let failure: string | undefined; try { const client = ctx.loadSession(path); if (client) { cacheDir ??= defaultCacheDirectory(client.whoami().userID); await client.logoutOnServer(); client.logout(); } } catch (err) { failure = err instanceof Error ? err.message : String(err); } unlinkSync(path); if (failure === undefined) { ctx.stderr.write("Session ended on the server.\n"); } else { ctx.stderr.write( `Could not end the session on the server: ${failure}\n`, ); } ctx.stderr.write("Session deleted.\n"); if (cacheDir !== undefined) { ctx.stderr.write( `Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`, ); } return failure === undefined ? 0 : 1; }; export const collectionsCommand = async ( ctx: CliContext, opts: { json?: boolean }, ): Promise => { 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 => { 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 => { 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 => { 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_, 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 => { await init(); const client = requireSession(ctx); if (!client) return 1; const lib = await openReadLibrary(ctx, client); try { const { failedMLBatches } = await runMetadataBackup(lib, client, dir, { exif: opts.exif || opts.all, onProgress: (msg) => ctx.stderr.write(msg + "\n"), }); return failedMLBatches > 0 ? 1 : 0; } finally { await lib.close(); } }; export const backupCommand = async ( ctx: CliContext, dir: string, opts: { json?: boolean }, ): Promise => { 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 => { 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 => { 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:\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(); } };