Add collections, files, get, get-thumb CLI commands

Complete CLI surface:
  quak login          interactive or QUAK_EMAIL/QUAK_PASSWORD
  quak whoami         print logged-in account
  quak logout         delete session
  quak collections    list all albums (--json)
  quak files          list files in a collection (--json)
  quak get <id>       download+decrypt a file (--out, --collection)
  quak get-thumb <id> download+decrypt a thumbnail
  quak backup <dir>   full incremental backup

get/get-thumb search all collections for the file ID when --collection
is not specified. All listing commands support --json.

Live-tested: collections list, file list, single file download (472 KB
JPEG from the dev account, verified as valid JPEG with EXIF intact).
This commit is contained in:
2026-05-13 20:49:13 -07:00
parent 5499effa91
commit ec2d12b986
2 changed files with 195 additions and 4 deletions

View File

@@ -663,14 +663,25 @@ Phase 7: Client class
Phase 8: CLI Phase 8: CLI
- [ ] `commander`-based CLI that matches the surface in the Design section - [x] `quak login` (interactive TTY prompts or QUAK_EMAIL/QUAK_PASSWORD env vars
- [ ] `--json` output for every command for non-interactive use)
- [ ] Reasonable progress output for long downloads (only when stdout is a TTY) - [x] `quak whoami`, `quak logout`
- [x] `quak collections` and `quak files --collection <id>`
- [x] `quak get <fileID>` and `quak get-thumb <fileID>` with --out and
--collection options; searches all collections when --collection omitted
- [x] `quak backup <dir>` 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 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 - [ ] All TODO items above checked
- [ ] `make docker` green
- [ ] Tag `v1.0.0` - [ ] Tag `v1.0.0`
Phase 10 and beyond: desktop client (separate repo) Phase 10 and beyond: desktop client (separate repo)

View File

@@ -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 <id>",
"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("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path")
.option(
"--collection <id>",
"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("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path")
.option(
"--collection <id>",
"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 program
.command("backup") .command("backup")
.description( .description(