check / check (push) Successful in 4m25s
`quak login` takes the server's API address from `--server` or `QUAK_SERVER`, refusing anything that is not an http or https URL. The client's snapshot now keeps a non-default `apiOrigin`, so the session file names the server and every later command uses it. A snapshot's `apiOrigin` wins over the one in `fromJSON`'s `apiOptions`, since the token belongs to that server. Model: opus-5-5
221 lines
6.9 KiB
JavaScript
221 lines
6.9 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: { ...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")
|
|
.option(
|
|
"--server <url>",
|
|
"Address of a self-hosted Ente server's API, saved with the session " +
|
|
"(default: QUAK_SERVER, then Ente's hosted service)",
|
|
)
|
|
.action((opts: { server?: string }) => run(loginCommand(context(), opts)));
|
|
|
|
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")
|
|
.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")
|
|
.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();
|