diff --git a/README.md b/README.md index efb2f08..c910613 100644 --- a/README.md +++ b/README.md @@ -663,14 +663,25 @@ Phase 7: Client class Phase 8: CLI -- [ ] `commander`-based CLI that matches the surface in the Design section -- [ ] `--json` output for every command -- [ ] Reasonable progress output for long downloads (only when stdout is a TTY) +- [x] `quak login` (interactive TTY prompts or QUAK_EMAIL/QUAK_PASSWORD env vars + for non-interactive use) +- [x] `quak whoami`, `quak logout` +- [x] `quak collections` and `quak files --collection ` +- [x] `quak get ` and `quak get-thumb ` with --out and + --collection options; searches all collections when --collection omitted +- [x] `quak backup ` with originals/ dedup, collections/ symlinks, + per-collection and per-file JSON metadata, incremental skip, per-file + error resilience, --json flag +- [x] `--json` output on every listing/backup command +- [x] Progress output to stderr for backup +- [x] Session persistence via env-paths (~/Library/Application Support/quak/ on + macOS, XDG_DATA_HOME/quak/ on Linux) Phase 9: docs and 1.0 -- [ ] README usage examples for both library and CLI verified by hand +- [ ] Update README Getting Started and Design sections to match current state - [ ] All TODO items above checked +- [ ] `make docker` green - [ ] Tag `v1.0.0` Phase 10 and beyond: desktop client (separate repo) diff --git a/bin/quak.ts b/bin/quak.ts index dc0872d..c89092e 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -149,6 +149,186 @@ program } }); +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(