#!/usr/bin/env node import { createInterface } from "node:readline/promises"; import { stdin, stdout, stderr } from "node:process"; import { 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 { runBackup } from "../src/backup.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 => { const rl = createInterface({ input: stdin, output: stderr }); const answer = await rl.question(message); rl.close(); return answer; }; const promptPassword = async (message: string): Promise => { if (!stdin.isTTY) { return prompt(message); } stderr.write(message); stdin.setRawMode(true); stdin.resume(); const chars: string[] = []; return new Promise((resolve) => { const onData = (buf: Buffer) => { for (const byte of buf) { if (byte === 3) { stdin.setRawMode(false); process.exit(130); } if (byte === 13 || byte === 10) { stdin.setRawMode(false); stdin.removeListener("data", onData); stdin.pause(); stderr.write("\n"); resolve(chars.join("")); return; } if (byte === 127 || byte === 8) { chars.pop(); } else { chars.push(String.fromCharCode(byte)); } } }; stdin.on("data", onData); }); }; const program = new Command(); program .name("quak") .description("CLI for the Ente end-to-end encrypted photo service") .version("0.0.0"); program .command("login") .description("Log in to an Ente account and save the session") .action(async () => { await init(); const email = process.env.QUAK_EMAIL ?? (stdin.isTTY ? await prompt("Email: ") : null); const password = process.env.QUAK_PASSWORD ?? (stdin.isTTY ? await promptPassword("Password: ") : null); if (!email || !password) { stderr.write( "Set QUAK_EMAIL and QUAK_PASSWORD env vars for non-interactive use.\n", ); process.exit(1); } 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 collections = await client.listCollections(); if (opts.json) { 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) { stdout.write( `${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`, ); } } }); 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 collections = await client.listCollections(); const col = collections.find((c) => c.id === collectionID); if (!col) { stderr.write(`Collection ${collectionID} not found\n`); process.exit(1); } const files = await client.listFiles(col.id, col.key); if (opts.json) { stdout.write( JSON.stringify( files.map((f) => ({ id: f.id, title: f.metadata.title, fileType: f.metadata.fileType, creationTime: f.metadata.creationTime, collectionID: f.collectionID, })), null, 2, ) + "\n", ); } else { for (const f of files) { stdout.write( `${f.id}\t${f.metadata.fileType}\t${f.metadata.title}\n`, ); } } }); program .command("get") .description("Download and decrypt a single file") .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option( "--collection ", "Collection ID (required to look up the file key)", ) .action( async ( fileIDStr: string, opts: { out?: string; collection?: string }, ) => { await init(); const client = requireSession(); const fileID = Number(fileIDStr); if (!Number.isFinite(fileID)) { stderr.write("Invalid file ID\n"); process.exit(1); } const collections = await client.listCollections(); let targetCol; if (opts.collection) { targetCol = collections.find( (c) => c.id === Number(opts.collection), ); } // Search all collections (or the specified one) for the file const searchCols = targetCol ? [targetCol] : collections; for (const col of searchCols) { const files = await client.listFiles(col.id, col.key); const file = files.find((f) => f.id === fileID); if (file) { const result = await client.downloadFile(file, opts.out); stderr.write( `${result.bytesWritten} bytes -> ${result.path}\n`, ); return; } } stderr.write(`File ${fileID} not found\n`); process.exit(1); }, ); program .command("get-thumb") .description("Download and decrypt a thumbnail") .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option( "--collection ", "Collection ID (required to look up the file key)", ) .action( async ( fileIDStr: string, opts: { out?: string; collection?: string }, ) => { await init(); const client = requireSession(); const fileID = Number(fileIDStr); if (!Number.isFinite(fileID)) { stderr.write("Invalid file ID\n"); process.exit(1); } const collections = await client.listCollections(); let targetCol; if (opts.collection) { targetCol = collections.find( (c) => c.id === Number(opts.collection), ); } const searchCols = targetCol ? [targetCol] : collections; for (const col of searchCols) { const files = await client.listFiles(col.id, col.key); const file = files.find((f) => f.id === fileID); if (file) { const result = await client.downloadThumbnail( file, opts.out, ); stderr.write( `${result.bytesWritten} bytes -> ${result.path}\n`, ); return; } } stderr.write(`File ${fileID} not found\n`); process.exit(1); }, ); 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 result = await runBackup(client, dir, (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`, ); } } } process.exit(result.failed > 0 ? 1 : 0); }); program.parse();