#!/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 ", "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(); return stderrLog(stderr, logLevel(verbose, quiet ?? false)); }; const context = (): CliContext => ({ stdout, log: log(), sessionDir: paths.data, cacheDir: program.opts().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): Promise => 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("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option("--collection ", "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("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option("--collection ", "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("", "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("", "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 ", "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();