Files
quak/bin/quak.ts
T
sneak c7cf3b77b5
check / check (push) Successful in 30s
Move CLI commands into testable functions and test them (closes #12)
The command bodies in bin/quak.ts become functions in
src/cli-commands.ts that take their options and a context (output
streams, session directory, cache directory, session loader) and return
an exit code. bin/quak.ts wires them to commander and exits with that
code once stdout and stderr have drained; nothing below it calls
process.exit. test/cli/commands.test.ts drives the commands with a fake
client and temp directories. Output is unchanged.

Model: opus-5-5
2026-09-23 00:24:59 +00:00

172 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
import { stdout, stderr } from "node:process";
import { Command } from "commander";
import envPaths from "env-paths";
import { init } from "../src/crypto/index.js";
import {
type CliContext,
loginCommand,
whoamiCommand,
logoutCommand,
collectionsCommand,
filesCommand,
getCommand,
getThumbCommand,
backupMetadataCommand,
backupCommand,
listMissingThumbnailsCommand,
fixMissingThumbnailsCommand,
} from "../src/cli-commands.js";
import { loadSession } from "../src/cli-session.js";
const paths = envPaths("quak", { suffix: "" });
const program = new Command();
program
.name("quak")
.description("CLI for the Ente end-to-end encrypted photo service")
.version("0.0.0")
.option(
"--cache-dir <path>",
"Directory for the local metadata/content cache " +
"(default: the per-user cache directory)",
);
const context = (): CliContext => ({
stdout,
stderr,
sessionDir: paths.data,
cacheDir: program.opts<{ cacheDir?: string }>().cacheDir,
loadSession,
});
// Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang.
const run = async (command: Promise<number>): Promise<void> => {
process.exitCode = await command;
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
if (pending.length === 0) {
process.exit();
return;
}
let remaining = pending.length;
for (const s of pending) {
s.once("drain", () => {
if (--remaining === 0) process.exit();
});
}
};
program
.command("login")
.description("Log in to an Ente account and save the session")
.action(() => run(loginCommand(context())));
program
.command("whoami")
.description("Print the logged-in account")
.action(() => run(whoamiCommand(context())));
program
.command("logout")
.description("Delete the saved session")
.action(() => run(logoutCommand(context())));
program
.command("collections")
.description("List all collections (albums)")
.option("--json", "Output as JSON array")
.action((opts: { json?: boolean }) =>
run(collectionsCommand(context(), opts)),
);
program
.command("files")
.description("List files in a collection")
.requiredOption(
"--collection <id>",
"Collection ID (from `quak collections`)",
)
.option("--json", "Output as JSON array")
.action((opts: { collection: string; json?: boolean }) =>
run(filesCommand(context(), opts)),
);
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>", "Accepted for compatibility; ignored")
.action((fileID: string, opts: { out?: string }) =>
run(getCommand(context(), fileID, opts)),
);
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>", "Accepted for compatibility; ignored")
.action((fileID: string, opts: { out?: string }) =>
run(getThumbCommand(context(), fileID, opts)),
);
program
.command("backup-metadata")
.description(
"Dump all decrypted account metadata to a directory of JSON files",
)
.argument("<dir>", "Output directory")
.option(
"--exif",
"Download each file and extract full EXIF/IPTC/XMP metadata (slow)",
)
.option("--all", "Alias for --exif")
.action((dir: string, opts: { exif?: boolean; all?: boolean }) =>
run(backupMetadataCommand(context(), dir, opts)),
);
program
.command("backup")
.description(
"Download all photos to a local directory, organized by collection",
)
.argument("<dir>", "Output directory")
.option("--json", "Print result as JSON instead of human-readable summary")
.action((dir: string, opts: { json?: boolean }) =>
run(backupCommand(context(), dir, opts)),
);
const helper = program
.command("helper")
.description("Maintenance and repair utilities");
helper
.command("list-missing-thumbnails")
.description("List files whose thumbnails are missing or empty")
.option("--json", "Output as JSON array")
.action((opts: { json?: boolean }) =>
run(listMissingThumbnailsCommand(context(), opts)),
);
helper
.command("fix-missing-thumbnails")
.description(
"Generate and upload thumbnails for files that are missing them",
)
.option(
"--file <ids...>",
"Specific file IDs to fix (default: fix all missing)",
)
.option("--json", "Output as JSON")
.action((opts: { file?: string[]; json?: boolean }) =>
run(fixMissingThumbnailsCommand(context(), opts)),
);
await init();
program.parse();