Files
quak/bin/quak.ts
T
clawbot 68d22b9927
check / check (push) Waiting to run
-v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212)
Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks.

Model: opus-5-5
2026-10-18 21:46:59 +02:00

214 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
import { stdout, stderr } from "node:process";
import { input, password } from "@inquirer/prompts";
import { Command, Option } from "commander";
import envPaths from "env-paths";
import { logLevel, logRequests, stderrLog, type Log } from "../src/cli-log.js";
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 { run as runCommand } from "../src/cli-run.js";
import { loadSession } from "../src/cli-session.js";
import { Client } from "../src/client.js";
import { VERSION } from "../src/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../src/retry.js";
const paths = envPaths("quak", { suffix: "" });
const program = new Command();
// Program options, so they are taken before or after any command's name.
// `-v` counts its repeats, the way commander's documentation counts a repeated
// option.
program
.name("quak")
.description("CLI for the Ente end-to-end encrypted photo service")
.version(VERSION)
.option(
"--cache-dir <path>",
"Directory for the local metadata/content cache " +
"(default: the per-user cache directory)",
)
.option(
"-v, --verbose",
"Log more on stderr: debug lines with -v, trace lines too with -vv",
(_value: string, previous: number) => previous + 1,
0,
)
.addOption(
new Option(
"-q, --quiet",
"Print nothing on stderr but the error a failing command exits with",
).conflicts("verbose"),
)
.configureHelp({ showGlobalOptions: true });
interface ProgramOptions {
cacheDir?: string;
verbose: number;
quiet?: boolean;
}
// The log for this run, at the level `-v` and `-q` ask for.
const log = (): Log => {
const { verbose, quiet } = program.opts<ProgramOptions>();
return stderrLog(stderr, logLevel(verbose, quiet ?? false));
};
const context = (): CliContext => ({
stdout,
log: log(),
sessionDir: paths.data,
cacheDir: program.opts<ProgramOptions>().cacheDir,
loadSession: (path) => loadSession(path, logRequests(log())),
login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }),
prompt: (message) => input({ message }),
promptSecret: (message) => password({ message, mask: true }),
});
const run = (command: Promise<number>): Promise<void> =>
runCommand(command, stdout, stderr, log(), (code) => process.exit(code));
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("End the session on the server and 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 the files in a collection, or every file in the account")
.argument(
"[collectionID]",
"Collection ID (from `quak collections`); without it, every file " +
"is listed once",
)
.option("--json", "Output as JSON array")
.action((collectionID: string | undefined, opts: { json?: boolean }) =>
run(filesCommand(context(), collectionID, 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")
.option(
"--verify",
"Re-hash stored originals and download again any that do not match",
)
// A backup usually runs from cron with nobody watching, so every request
// it makes retries for longer than the other commands' requests do.
.action((dir: string, opts: { json?: boolean; verify?: boolean }) =>
run(
backupCommand(
{
...context(),
loadSession: (path) =>
loadSession(
path,
logRequests(log(), UNATTENDED_RETRY_OPTIONS),
),
},
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();
await program.parseAsync();