#!/usr/bin/env node import { input, password as passwordPrompt } from "@inquirer/prompts"; import { stdout, stderr } from "node:process"; import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; import { Command } from "commander"; import envPaths from "env-paths"; import { Client, type ClientSnapshot } from "../src/client.js"; import { init } from "../src/crypto/index.js"; import { Library, type LibraryClient } from "../src/library/index.js"; import { fileListRow, fileListLine, originalName, thumbnailName, } from "../src/cli-output.js"; import { runMetadataBackup } from "../src/metadata-backup.js"; import { listMissingThumbnails, fixMissingThumbnails, } from "../src/thumbnails.js"; const paths = envPaths("quak", { suffix: "" }); const sessionPath = join(paths.data, "session.json"); const loadSession = (): ClientSnapshot | null => { if (!existsSync(sessionPath)) return null; try { return JSON.parse(readFileSync(sessionPath, "utf-8")) as ClientSnapshot; } catch { return null; } }; const saveSession = (snapshot: ClientSnapshot): void => { mkdirSync(paths.data, { recursive: true, mode: 0o700 }); writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), { mode: 0o600, }); }; const requireSession = (): Client => { const snapshot = loadSession(); if (!snapshot) { stderr.write( `Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`, ); process.exit(1); } return Client.fromJSON(snapshot); }; const prompt = async (message: string): Promise => input({ message }); const promptSecret = async (message: string): Promise => passwordPrompt({ message, mask: true }); const program = new Command(); program .name("quak") .description("CLI for the Ente end-to-end encrypted photo service") .version("0.0.0") .option( "--cache-dir ", "Directory for the local metadata/content cache " + "(default: the per-user cache directory)", ); // The `--cache-dir` global, or undefined to let the library pick its per-user // default keyed by the account id. const cacheDirOption = (): string | undefined => program.opts<{ cacheDir?: string }>().cacheDir; // 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 = (client: Client): Promise => Library.open({ client: readLibraryClient(client), cacheDirectory: cacheDirOption(), refreshIntervalSeconds: 3600, precacheThumbnails: false, precacheOriginals: false, }); // Close the library and exit once stdout/stderr have drained. `process.exit` // alone can truncate buffered piped output, and the library keeps the event // loop alive with a background refresh, so a plain return could hang; this does // neither. const finish = (lib: Library | undefined, code: number): void => { lib?.close(); const pending = [stdout, stderr].filter((s) => s.writableLength > 0); if (pending.length === 0) { process.exit(code); return; } let remaining = pending.length; for (const s of pending) { s.once("drain", () => { if (--remaining === 0) process.exit(code); }); } }; program .command("login") .description("Log in to an Ente account and save the session") .action(async () => { await init(); const email = process.env.QUAK_EMAIL ?? (await prompt("Email")); const password = process.env.QUAK_PASSWORD ?? (await promptSecret("Password")); stderr.write("Authenticating...\n"); try { const client = await Client.login({ email, password, totp: async () => prompt("TOTP code: "), emailOTP: async () => prompt("Email verification code: "), }); saveSession(client.toJSON()); const info = client.whoami(); stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`); stderr.write(`Session saved to ${sessionPath}\n`); } catch (err) { stderr.write( `Login failed: ${err instanceof Error ? err.message : err}\n`, ); process.exit(1); } }); program .command("whoami") .description("Print the logged-in account") .action(() => { const client = requireSession(); const info = client.whoami(); stdout.write(JSON.stringify(info) + "\n"); }); program .command("logout") .description("Delete the saved session") .action(async () => { if (existsSync(sessionPath)) { const { unlinkSync } = await import("node:fs"); unlinkSync(sessionPath); stderr.write("Session deleted.\n"); } else { stderr.write("No session found.\n"); } }); program .command("collections") .description("List all collections (albums)") .option("--json", "Output as JSON array") .action(async (opts: { json?: boolean }) => { await init(); const client = requireSession(); const lib = await openReadLibrary(client); const albums = lib.albums.list(); if (opts.json) { stdout.write( JSON.stringify( albums.map((a) => ({ id: a.collectionID, name: a.name, type: a.type, ownerID: lib.getCollection(a.collectionID)?.ownerID, isShared: a.isShared, updationTime: a.updationTime, })), null, 2, ) + "\n", ); } else { for (const a of albums) { stdout.write( `${a.collectionID}\t${a.type}\t${a.name}${a.isShared ? " (shared)" : ""}\n`, ); } } finish(lib, 0); }); program .command("files") .description("List files in a collection") .requiredOption( "--collection ", "Collection ID (from `quak collections`)", ) .option("--json", "Output as JSON array") .action(async (opts: { collection: string; json?: boolean }) => { await init(); const client = requireSession(); const collectionID = Number(opts.collection); if (!Number.isFinite(collectionID)) { stderr.write("Invalid collection ID\n"); process.exit(1); } const lib = await openReadLibrary(client); const album = lib.albums.byID({ collectionID }); if (!album) { stderr.write(`Collection ${collectionID} not found\n`); finish(lib, 1); return; } // Present each file from its own decrypted metadata, not the // PhotoRecord projection, so the raw title and the microsecond // creationTime print as the pre-library CLI did (issue #52). The // album's photo order is kept; only the field source changes. const files = album.photos.list().flatMap((p) => { const file = lib.getFile(collectionID, p.fileID); return file ? [file] : []; }); if (opts.json) { stdout.write( JSON.stringify(files.map(fileListRow), null, 2) + "\n", ); } else { for (const file of files) { stdout.write(fileListLine(file) + "\n"); } } finish(lib, 0); }); program .command("get") .description("Download and decrypt a single file") .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option("--collection ", "Accepted for compatibility; ignored") .action(async (fileIDStr: string, opts: { out?: string }) => { await init(); const client = requireSession(); const fileID = Number(fileIDStr); if (!Number.isFinite(fileID)) { stderr.write("Invalid file ID\n"); process.exit(1); } const lib = await openReadLibrary(client); const photo = lib.photos.byID({ fileID }); const file = lib.getFileByID(fileID); if (!photo || !file) { stderr.write(`File ${fileID} not found\n`); finish(lib, 1); return; } 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); stderr.write(`${result.bytes} bytes -> ${outPath}\n`); finish(lib, 0); }); program .command("get-thumb") .description("Download and decrypt a thumbnail") .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option("--collection ", "Accepted for compatibility; ignored") .action(async (fileIDStr: string, opts: { out?: string }) => { await init(); const client = requireSession(); const fileID = Number(fileIDStr); if (!Number.isFinite(fileID)) { stderr.write("Invalid file ID\n"); process.exit(1); } const lib = await openReadLibrary(client); const photo = lib.photos.byID({ fileID }); const file = lib.getFileByID(fileID); if (!photo || !file) { stderr.write(`File ${fileID} not found\n`); finish(lib, 1); return; } 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); stderr.write(`${result.bytes} bytes -> ${outPath}\n`); finish(lib, 0); }); program .command("backup-metadata") .description( "Dump all decrypted account metadata to a directory of JSON files", ) .argument("", "Output directory") .option( "--exif", "Download each file and extract full EXIF/IPTC/XMP metadata (slow)", ) .option("--all", "Alias for --exif") .action(async (dir: string, opts: { exif?: boolean; all?: boolean }) => { await init(); const client = requireSession(); const lib = await openReadLibrary(client); await runMetadataBackup(lib, client, dir, { exif: opts.exif || opts.all, onProgress: (msg) => stderr.write(msg + "\n"), }); finish(lib, 0); }); program .command("backup") .description( "Download all photos to a local directory, organized by collection", ) .argument("", "Output directory") .option("--json", "Print result as JSON instead of human-readable summary") .action(async (dir: string, opts: { json?: boolean }) => { await init(); const client = requireSession(); stderr.write("Starting backup...\n"); const lib = await Library.open({ client, downloadDirectory: dir, cacheDirectory: cacheDirOption(), }); const result = await lib.backup({ downloadDirectory: dir, onProgress: (msg) => { if (!opts.json) stderr.write(msg + "\n"); }, }); if (opts.json) { stdout.write(JSON.stringify(result, null, 2) + "\n"); } else { stderr.write("\n--- Backup complete ---\n"); stderr.write(` Total files: ${result.totalFiles}\n`); stderr.write(` Downloaded: ${result.downloaded}\n`); stderr.write(` Skipped: ${result.skipped}\n`); stderr.write(` Failed: ${result.failed}\n`); if (result.errors.length > 0) { stderr.write("\nFailed files:\n"); for (const e of result.errors) { stderr.write( ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, ); } } } finish(lib, result.failed > 0 ? 1 : 0); }); const helper = program .command("helper") .description("Maintenance and repair utilities"); helper .command("list-missing-thumbnails") .description("List files whose thumbnails are missing or empty") .option("--json", "Output as JSON array") .action(async (opts: { json?: boolean }) => { await init(); const client = requireSession(); const lib = await openReadLibrary(client); const missing = await listMissingThumbnails(lib, client, (msg) => { if (!opts.json) stderr.write(msg + "\n"); }); if (opts.json) { stdout.write(JSON.stringify(missing, null, 2) + "\n"); } else { if (missing.length === 0) { stderr.write("No missing thumbnails found.\n"); } else { stderr.write( `\n${missing.length} file(s) with missing thumbnails:\n`, ); for (const m of missing) { stdout.write( `${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`, ); } } } finish(lib, 0); }); helper .command("fix-missing-thumbnails") .description( "Generate and upload thumbnails for files that are missing them", ) .option( "--file ", "Specific file IDs to fix (default: fix all missing)", ) .option("--json", "Output as JSON") .action(async (opts: { file?: string[]; json?: boolean }) => { await init(); const client = requireSession(); const lib = await openReadLibrary(client); let fileIDs: number[]; if (opts.file && opts.file.length > 0) { fileIDs = opts.file.map(Number).filter(Number.isFinite); } else { stderr.write("Scanning for missing thumbnails...\n"); const missing = await listMissingThumbnails(lib, client, (msg) => { if (!opts.json) stderr.write(msg + "\n"); }); fileIDs = missing.map((m) => m.fileID); if (fileIDs.length === 0) { stderr.write("No missing thumbnails found.\n"); finish(lib, 0); return; } stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`); } const results = await fixMissingThumbnails( lib, client, fileIDs, (msg) => { if (!opts.json) stderr.write(msg + "\n"); }, ); if (opts.json) { 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; stderr.write(`\n--- Done ---\n`); stderr.write(` Fixed: ${fixed}\n`); stderr.write(` Skipped: ${skipped}\n`); stderr.write(` Failed: ${failed}\n`); if (skipped > 0) { stderr.write("\nSkipped (unsupported format):\n"); for (const r of results.filter((r) => r.status === "skipped")) { stderr.write(` ${r.fileID}\t${r.title}\t${r.reason}\n`); } } if (failed > 0) { stderr.write("\nFailed files:\n"); for (const r of results.filter((r) => r.status === "failed")) { stderr.write(` ${r.fileID}\t${r.title}\t${r.reason}\n`); } } } finish(lib, results.some((r) => r.status === "failed") ? 1 : 0); }); await init(); program.parse();