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:
19
README.md
19
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 <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
|
||||
|
||||
- [ ] 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)
|
||||
|
||||
180
bin/quak.ts
180
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 <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
|
||||
.command("backup")
|
||||
.description(
|
||||
|
||||
Reference in New Issue
Block a user