Merge: complete CLI command surface
Adds collections, files, get, get-thumb commands. Full CLI: quak login / whoami / logout quak collections [--json] quak files --collection <id> [--json] quak get <fileID> [--out <path>] [--collection <id>] quak get-thumb <fileID> [--out <path>] [--collection <id>] quak backup <dir> [--json] get/get-thumb search all collections when --collection is omitted. All listing commands support --json. Live-tested against dev account.
This commit is contained in:
19
README.md
19
README.md
@@ -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)
|
||||||
|
|||||||
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
|
program
|
||||||
.command("backup")
|
.command("backup")
|
||||||
.description(
|
.description(
|
||||||
|
|||||||
Reference in New Issue
Block a user