From 99a536286dc43ea53bfee319f74abef805f068bb Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 23 Sep 2026 00:12:59 +0000 Subject: [PATCH] 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 --- TODO.md | 9 + bin/quak.ts | 433 +++++---------------------------- src/cli-commands.ts | 486 ++++++++++++++++++++++++++++++++++++++ test/cli/commands.test.ts | 397 +++++++++++++++++++++++++++++++ 4 files changed, 945 insertions(+), 380 deletions(-) create mode 100644 src/cli-commands.ts create mode 100644 test/cli/commands.test.ts diff --git a/TODO.md b/TODO.md index aa8a1ee..70fbc6e 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,15 @@ Tag v1.0.0. # Completed Steps +- 2026-09-23: Made the CLI testable and tested it (issue 12). The command bodies + moved from `bin/quak.ts` into `src/cli-commands.ts` as functions that take + their options and a context (output streams, session directory, cache + directory, session loader) and return an exit code; `bin/quak.ts` only wires + them to commander and exits with the code once stdout and stderr have drained, + so nothing below it calls `process.exit`. `test/cli/commands.test.ts` drives + them with a fake client: session file modes, logout, the missing and corrupt + session paths, and the output and exit code of `whoami`, `collections`, + `files`, `get`, `get-thumb`, `backup` and `helper list-missing-thumbnails`. - 2026-09-23: Hardened the backup tree's atomic copy (issue 22). `copyAtomic` fsyncs its temp file before the rename and the directory after it, through the download writer's `fsyncPath`; each backup run deletes `.quak-backup-*.tmp` diff --git a/bin/quak.ts b/bin/quak.ts index 6760490..12b89a3 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -1,62 +1,26 @@ #!/usr/bin/env node -import { input, password as passwordPrompt } from "@inquirer/prompts"; import { stdout, stderr } from "node:process"; -import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { Command } from "commander"; import envPaths from "env-paths"; -import { Client, type ClientSnapshot } from "../src/client.js"; import { init } from "../src/crypto/index.js"; -import { Library, type LibraryClient } from "../src/library/index.js"; import { - fileListRow, - fileListLine, - originalName, - thumbnailName, -} from "../src/cli-output.js"; -import { freshCollections, freshFiles, freshFile } from "../src/cli-read.js"; + 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"; -import { runMetadataBackup } from "../src/metadata-backup.js"; -import { - listMissingThumbnails, - fixMissingThumbnails, -} from "../src/thumbnails.js"; const paths = envPaths("quak", { suffix: "" }); -const sessionPath = join(paths.data, "session.json"); - -const saveSession = (snapshot: ClientSnapshot): void => { - mkdirSync(paths.data, { recursive: true, mode: 0o700 }); - writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), { - mode: 0o600, - }); -}; - -const requireSession = (): Client => { - let client: Client | null; - try { - client = loadSession(sessionPath); - } catch (err) { - stderr.write( - `${err instanceof Error ? err.message : err}\n` + - `Run "quak logout" and then "quak login" to replace it.\n`, - ); - process.exit(1); - } - if (!client) { - stderr.write( - `Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`, - ); - process.exit(1); - } - return client; -}; - -const prompt = async (message: string): Promise => input({ message }); - -const promptSecret = async (message: string): Promise => - passwordPrompt({ message, mask: true }); const program = new Command(); @@ -70,51 +34,28 @@ program "(default: the per-user cache directory)", ); -// The `--cache-dir` global, or undefined to let the library pick its per-user -// default keyed by the account id. -const cacheDirOption = (): string | undefined => - program.opts<{ cacheDir?: string }>().cacheDir; - -// A library client that omits `fetchMLData`, so the point commands below do not -// kick the library's background ML backfill: they read metadata, or fetch one -// file's content, and exit. `backup` and `backup-metadata` handle ML on their -// own terms. The content source is kept so `get`/`get-thumb`/`--exif` can fetch -// originals through the on-disk cache. -const readLibraryClient = (client: Client): LibraryClient => ({ - whoami: () => client.whoami(), - collectionsSince: (args) => client.collectionsSince(args), - filesSince: (args) => client.filesSince(args), - contentSource: () => client.contentSource(), +const context = (): CliContext => ({ + stdout, + stderr, + sessionDir: paths.data, + cacheDir: program.opts<{ cacheDir?: string }>().cacheDir, + loadSession, }); -// Open a library for a single point command: the aggressive background precache -// (issue #48) is off — a one-shot `collections` or `get` must not start -// downloading the whole account — and the refresh interval is long so no second -// refresh fires mid-command. -const openReadLibrary = (client: Client): Promise => - Library.open({ - client: readLibraryClient(client), - cacheDirectory: cacheDirOption(), - refreshIntervalSeconds: 3600, - precacheThumbnails: false, - precacheOriginals: false, - }); - -// Close the library and exit once stdout/stderr have drained. `process.exit` -// alone can truncate buffered piped output, and the library keeps the event -// loop alive with a background refresh, so a plain return could hang; this does -// neither. -const finish = (lib: Library | undefined, code: number): void => { - lib?.close(); +// 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): Promise => { + process.exitCode = await command; const pending = [stdout, stderr].filter((s) => s.writableLength > 0); if (pending.length === 0) { - process.exit(code); + process.exit(); return; } let remaining = pending.length; for (const s of pending) { s.once("drain", () => { - if (--remaining === 0) process.exit(code); + if (--remaining === 0) process.exit(); }); } }; @@ -122,93 +63,25 @@ const finish = (lib: Library | undefined, code: number): void => { program .command("login") .description("Log in to an Ente account and save the session") - .action(async () => { - await init(); - const email = process.env.QUAK_EMAIL ?? (await prompt("Email")); - const password = - process.env.QUAK_PASSWORD ?? (await promptSecret("Password")); - - stderr.write("Authenticating...\n"); - try { - const client = await Client.login({ - email, - password, - totp: async () => prompt("TOTP code: "), - emailOTP: async () => prompt("Email verification code: "), - }); - - saveSession(client.toJSON()); - const info = client.whoami(); - stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`); - stderr.write(`Session saved to ${sessionPath}\n`); - } catch (err) { - stderr.write( - `Login failed: ${err instanceof Error ? err.message : err}\n`, - ); - process.exit(1); - } - }); + .action(() => run(loginCommand(context()))); program .command("whoami") .description("Print the logged-in account") - .action(async () => { - await init(); - const client = requireSession(); - const info = client.whoami(); - stdout.write(JSON.stringify(info) + "\n"); - }); + .action(() => run(whoamiCommand(context()))); program .command("logout") .description("Delete the saved session") - .action(async () => { - if (existsSync(sessionPath)) { - const { unlinkSync } = await import("node:fs"); - unlinkSync(sessionPath); - stderr.write("Session deleted.\n"); - } else { - stderr.write("No session found.\n"); - } - }); + .action(() => run(logoutCommand(context()))); 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 lib = await openReadLibrary(client); - // Force a server round-trip and list in enumeration order (issue #36 - // amendment, issue #52): the pre-library CLI printed current state in - // this order, not the albums projection's newest-first order. - const collections = await freshCollections(lib); - - 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`, - ); - } - } - finish(lib, 0); - }); + .action((opts: { json?: boolean }) => + run(collectionsCommand(context(), opts)), + ); program .command("files") @@ -218,39 +91,9 @@ program "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 lib = await openReadLibrary(client); - // Force a server round-trip and list in enumeration order (issue #36 - // amendment, issue #52). Each file prints from its own decrypted - // metadata (raw title, microsecond creationTime) via cli-output, and in - // the pre-library CLI's enumeration order, not the projection's - // newest-first order. - const files = await freshFiles(lib, collectionID); - if (!files) { - stderr.write(`Collection ${collectionID} not found\n`); - finish(lib, 1); - return; - } - - if (opts.json) { - stdout.write( - JSON.stringify(files.map(fileListRow), null, 2) + "\n", - ); - } else { - for (const file of files) { - stdout.write(fileListLine(file) + "\n"); - } - } - finish(lib, 0); - }); + .action((opts: { collection: string; json?: boolean }) => + run(filesCommand(context(), opts)), + ); program .command("get") @@ -258,34 +101,9 @@ program .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option("--collection ", "Accepted for compatibility; ignored") - .action(async (fileIDStr: string, opts: { out?: string }) => { - await init(); - const client = requireSession(); - const fileID = Number(fileIDStr); - if (!Number.isFinite(fileID)) { - stderr.write("Invalid file ID\n"); - process.exit(1); - } - - const lib = await openReadLibrary(client); - // Force a server round-trip so the file resolves against current state - // (issue #36 amendment, issue #52). - const resolved = await freshFile(lib, fileID); - if (!resolved) { - stderr.write(`File ${fileID} not found\n`); - finish(lib, 1); - return; - } - const { photo, file } = resolved; - - const result = await photo.original(); - // Default name is the file's own title, as the pre-library CLI used - // (not the editedName-preferring projection title) (issue #52). - const outPath = opts.out ?? originalName(file); - copyFileSync(result.path, outPath); - stderr.write(`${result.bytes} bytes -> ${outPath}\n`); - finish(lib, 0); - }); + .action((fileID: string, opts: { out?: string }) => + run(getCommand(context(), fileID, opts)), + ); program .command("get-thumb") @@ -293,34 +111,9 @@ program .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") .option("--collection ", "Accepted for compatibility; ignored") - .action(async (fileIDStr: string, opts: { out?: string }) => { - await init(); - const client = requireSession(); - const fileID = Number(fileIDStr); - if (!Number.isFinite(fileID)) { - stderr.write("Invalid file ID\n"); - process.exit(1); - } - - const lib = await openReadLibrary(client); - // Force a server round-trip so the file resolves against current state - // (issue #36 amendment, issue #52). - const resolved = await freshFile(lib, fileID); - if (!resolved) { - stderr.write(`File ${fileID} not found\n`); - finish(lib, 1); - return; - } - const { photo, file } = resolved; - - const result = await photo.thumbnail(); - // Default name is thumb_, as the pre-library CLI - // used (not the projection title) (issue #52). - const outPath = opts.out ?? thumbnailName(file); - copyFileSync(result.path, outPath); - stderr.write(`${result.bytes} bytes -> ${outPath}\n`); - finish(lib, 0); - }); + .action((fileID: string, opts: { out?: string }) => + run(getThumbCommand(context(), fileID, opts)), + ); program .command("backup-metadata") @@ -333,16 +126,9 @@ program "Download each file and extract full EXIF/IPTC/XMP metadata (slow)", ) .option("--all", "Alias for --exif") - .action(async (dir: string, opts: { exif?: boolean; all?: boolean }) => { - await init(); - const client = requireSession(); - const lib = await openReadLibrary(client); - await runMetadataBackup(lib, client, dir, { - exif: opts.exif || opts.all, - onProgress: (msg) => stderr.write(msg + "\n"), - }); - finish(lib, 0); - }); + .action((dir: string, opts: { exif?: boolean; all?: boolean }) => + run(backupMetadataCommand(context(), dir, opts)), + ); program .command("backup") @@ -351,43 +137,9 @@ program ) .argument("", "Output directory") .option("--json", "Print result as JSON instead of human-readable summary") - .action(async (dir: string, opts: { json?: boolean }) => { - await init(); - const client = requireSession(); - - stderr.write("Starting backup...\n"); - const lib = await Library.open({ - client, - downloadDirectory: dir, - cacheDirectory: cacheDirOption(), - }); - const result = await lib.backup({ - downloadDirectory: dir, - onProgress: (msg) => { - if (!opts.json) stderr.write(msg + "\n"); - }, - }); - - if (opts.json) { - stdout.write(JSON.stringify(result, null, 2) + "\n"); - } else { - stderr.write("\n--- Backup complete ---\n"); - stderr.write(` Total files: ${result.totalFiles}\n`); - stderr.write(` Downloaded: ${result.downloaded}\n`); - stderr.write(` Skipped: ${result.skipped}\n`); - stderr.write(` Failed: ${result.failed}\n`); - if (result.errors.length > 0) { - stderr.write("\nFailed files:\n"); - for (const e of result.errors) { - stderr.write( - ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, - ); - } - } - } - - finish(lib, result.failed > 0 ? 1 : 0); - }); + .action((dir: string, opts: { json?: boolean }) => + run(backupCommand(context(), dir, opts)), + ); const helper = program .command("helper") @@ -397,32 +149,9 @@ helper .command("list-missing-thumbnails") .description("List files whose thumbnails are missing or empty") .option("--json", "Output as JSON array") - .action(async (opts: { json?: boolean }) => { - await init(); - const client = requireSession(); - const lib = await openReadLibrary(client); - const missing = await listMissingThumbnails(lib, client, (msg) => { - if (!opts.json) stderr.write(msg + "\n"); - }); - - if (opts.json) { - stdout.write(JSON.stringify(missing, null, 2) + "\n"); - } else { - if (missing.length === 0) { - stderr.write("No missing thumbnails found.\n"); - } else { - stderr.write( - `\n${missing.length} file(s) with missing thumbnails:\n`, - ); - for (const m of missing) { - stdout.write( - `${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`, - ); - } - } - } - finish(lib, 0); - }); + .action((opts: { json?: boolean }) => + run(listMissingThumbnailsCommand(context(), opts)), + ); helper .command("fix-missing-thumbnails") @@ -434,65 +163,9 @@ helper "Specific file IDs to fix (default: fix all missing)", ) .option("--json", "Output as JSON") - .action(async (opts: { file?: string[]; json?: boolean }) => { - await init(); - const client = requireSession(); - const lib = await openReadLibrary(client); - - let fileIDs: number[]; - if (opts.file && opts.file.length > 0) { - fileIDs = opts.file.map(Number).filter(Number.isFinite); - } else { - stderr.write("Scanning for missing thumbnails...\n"); - const missing = await listMissingThumbnails(lib, client, (msg) => { - if (!opts.json) stderr.write(msg + "\n"); - }); - fileIDs = missing.map((m) => m.fileID); - if (fileIDs.length === 0) { - stderr.write("No missing thumbnails found.\n"); - finish(lib, 0); - return; - } - stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`); - } - - const results = await fixMissingThumbnails( - lib, - client, - fileIDs, - (msg) => { - if (!opts.json) stderr.write(msg + "\n"); - }, - ); - - if (opts.json) { - stdout.write(JSON.stringify(results, null, 2) + "\n"); - } else { - const fixed = results.filter((r) => r.status === "fixed").length; - const skipped = results.filter( - (r) => r.status === "skipped", - ).length; - const failed = results.filter((r) => r.status === "failed").length; - stderr.write(`\n--- Done ---\n`); - stderr.write(` Fixed: ${fixed}\n`); - stderr.write(` Skipped: ${skipped}\n`); - stderr.write(` Failed: ${failed}\n`); - if (skipped > 0) { - stderr.write("\nSkipped (unsupported format):\n"); - for (const r of results.filter((r) => r.status === "skipped")) { - stderr.write(` ${r.fileID}\t${r.title}\t${r.reason}\n`); - } - } - if (failed > 0) { - stderr.write("\nFailed files:\n"); - for (const r of results.filter((r) => r.status === "failed")) { - stderr.write(` ${r.fileID}\t${r.title}\t${r.reason}\n`); - } - } - } - - finish(lib, results.some((r) => r.status === "failed") ? 1 : 0); - }); + .action((opts: { file?: string[]; json?: boolean }) => + run(fixMissingThumbnailsCommand(context(), opts)), + ); await init(); program.parse(); diff --git a/src/cli-commands.ts b/src/cli-commands.ts new file mode 100644 index 0000000..fc02d40 --- /dev/null +++ b/src/cli-commands.ts @@ -0,0 +1,486 @@ +// The CLI's commands as plain functions. +// +// Each command takes its options and a `CliContext` and resolves to the exit +// code; a thrown error is left to the caller. Nothing here calls +// `process.exit`: `bin/quak.ts` wires these to the command line and exits with +// the returned code once output has drained. Output must stay byte-identical +// (see `cli-output.ts`). + +import { input, password as passwordPrompt } from "@inquirer/prompts"; +import { + copyFileSync, + existsSync, + mkdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { Client, type ClientSnapshot } from "./client.js"; +import { init } from "./crypto/index.js"; +import { Library, type LibraryClient } from "./library/index.js"; +import { + fileListRow, + fileListLine, + originalName, + thumbnailName, +} from "./cli-output.js"; +import { freshCollections, freshFiles, freshFile } from "./cli-read.js"; +import { runMetadataBackup } from "./metadata-backup.js"; +import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js"; + +export interface CliContext { + stdout: { write(text: string): unknown }; + stderr: { write(text: string): unknown }; + // Directory holding `session.json`. + sessionDir: string; + // The `--cache-dir` global, or undefined to let the library pick its + // per-user default keyed by the account id. + cacheDir?: string; + // Reads the session file into a client, or null when there is none. The + // CLI passes `loadSession` from `cli-session.ts`; tests pass a fake client. + loadSession: (path: string) => Client | null; +} + +const sessionPath = (ctx: CliContext): string => + join(ctx.sessionDir, "session.json"); + +// Write the session readable by its owner only, in a directory only its owner +// can enter. +export const saveSession = ( + sessionDir: string, + snapshot: ClientSnapshot, +): void => { + mkdirSync(sessionDir, { recursive: true, mode: 0o700 }); + writeFileSync( + join(sessionDir, "session.json"), + JSON.stringify(snapshot, null, 2), + { mode: 0o600 }, + ); +}; + +// The saved client, or undefined after telling the user why there is none. +const requireSession = (ctx: CliContext): Client | undefined => { + let client: Client | null; + try { + client = ctx.loadSession(sessionPath(ctx)); + } catch (err) { + ctx.stderr.write( + `${err instanceof Error ? err.message : err}\n` + + `Run "quak logout" and then "quak login" to replace it.\n`, + ); + return undefined; + } + if (!client) { + ctx.stderr.write( + `Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`, + ); + return undefined; + } + return client; +}; + +// A library client that omits `fetchMLData`, so the point commands below do not +// kick the library's background ML backfill: they read metadata, or fetch one +// file's content, and exit. `backup` and `backup-metadata` handle ML on their +// own terms. The content source is kept so `get`/`get-thumb`/`--exif` can fetch +// originals through the on-disk cache. +const readLibraryClient = (client: Client): LibraryClient => ({ + whoami: () => client.whoami(), + collectionsSince: (args) => client.collectionsSince(args), + filesSince: (args) => client.filesSince(args), + contentSource: () => client.contentSource(), +}); + +// Open a library for a single point command: the aggressive background precache +// (issue #48) is off — a one-shot `collections` or `get` must not start +// downloading the whole account — and the refresh interval is long so no second +// refresh fires mid-command. +const openReadLibrary = (ctx: CliContext, client: Client): Promise => + Library.open({ + client: readLibraryClient(client), + cacheDirectory: ctx.cacheDir, + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + }); + +const prompt = async (message: string): Promise => input({ message }); + +const promptSecret = async (message: string): Promise => + passwordPrompt({ message, mask: true }); + +export const loginCommand = async (ctx: CliContext): Promise => { + await init(); + const email = process.env.QUAK_EMAIL ?? (await prompt("Email")); + const password = + process.env.QUAK_PASSWORD ?? (await promptSecret("Password")); + + ctx.stderr.write("Authenticating...\n"); + try { + const client = await Client.login({ + email, + password, + totp: async () => prompt("TOTP code: "), + emailOTP: async () => prompt("Email verification code: "), + }); + + saveSession(ctx.sessionDir, client.toJSON()); + const info = client.whoami(); + ctx.stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`); + ctx.stderr.write(`Session saved to ${sessionPath(ctx)}\n`); + } catch (err) { + ctx.stderr.write( + `Login failed: ${err instanceof Error ? err.message : err}\n`, + ); + return 1; + } + return 0; +}; + +export const whoamiCommand = async (ctx: CliContext): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const info = client.whoami(); + ctx.stdout.write(JSON.stringify(info) + "\n"); + return 0; +}; + +export const logoutCommand = async (ctx: CliContext): Promise => { + if (existsSync(sessionPath(ctx))) { + unlinkSync(sessionPath(ctx)); + ctx.stderr.write("Session deleted.\n"); + } else { + ctx.stderr.write("No session found.\n"); + } + return 0; +}; + +export const collectionsCommand = async ( + ctx: CliContext, + opts: { json?: boolean }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const lib = await openReadLibrary(ctx, client); + try { + // Force a server round-trip and list in enumeration order (issue #36 + // amendment, issue #52): the pre-library CLI printed current state in + // this order, not the albums projection's newest-first order. + const collections = await freshCollections(lib); + + if (opts.json) { + ctx.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) { + ctx.stdout.write( + `${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`, + ); + } + } + return 0; + } finally { + lib.close(); + } +}; + +export const filesCommand = async ( + ctx: CliContext, + opts: { collection: string; json?: boolean }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const collectionID = Number(opts.collection); + if (!Number.isFinite(collectionID)) { + ctx.stderr.write("Invalid collection ID\n"); + return 1; + } + + const lib = await openReadLibrary(ctx, client); + try { + // Force a server round-trip and list in enumeration order (issue #36 + // amendment, issue #52). Each file prints from its own decrypted + // metadata (raw title, microsecond creationTime) via cli-output, and in + // the pre-library CLI's enumeration order, not the projection's + // newest-first order. + const files = await freshFiles(lib, collectionID); + if (!files) { + ctx.stderr.write(`Collection ${collectionID} not found\n`); + return 1; + } + + if (opts.json) { + ctx.stdout.write( + JSON.stringify(files.map(fileListRow), null, 2) + "\n", + ); + } else { + for (const file of files) { + ctx.stdout.write(fileListLine(file) + "\n"); + } + } + return 0; + } finally { + lib.close(); + } +}; + +export const getCommand = async ( + ctx: CliContext, + fileIDStr: string, + opts: { out?: string }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const fileID = Number(fileIDStr); + if (!Number.isFinite(fileID)) { + ctx.stderr.write("Invalid file ID\n"); + return 1; + } + + const lib = await openReadLibrary(ctx, client); + try { + // Force a server round-trip so the file resolves against current state + // (issue #36 amendment, issue #52). + const resolved = await freshFile(lib, fileID); + if (!resolved) { + ctx.stderr.write(`File ${fileID} not found\n`); + return 1; + } + const { photo, file } = resolved; + + const result = await photo.original(); + // Default name is the file's own title, as the pre-library CLI used + // (not the editedName-preferring projection title) (issue #52). + const outPath = opts.out ?? originalName(file); + copyFileSync(result.path, outPath); + ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); + return 0; + } finally { + lib.close(); + } +}; + +export const getThumbCommand = async ( + ctx: CliContext, + fileIDStr: string, + opts: { out?: string }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const fileID = Number(fileIDStr); + if (!Number.isFinite(fileID)) { + ctx.stderr.write("Invalid file ID\n"); + return 1; + } + + const lib = await openReadLibrary(ctx, client); + try { + // Force a server round-trip so the file resolves against current state + // (issue #36 amendment, issue #52). + const resolved = await freshFile(lib, fileID); + if (!resolved) { + ctx.stderr.write(`File ${fileID} not found\n`); + return 1; + } + const { photo, file } = resolved; + + const result = await photo.thumbnail(); + // Default name is thumb_, as the pre-library CLI + // used (not the projection title) (issue #52). + const outPath = opts.out ?? thumbnailName(file); + copyFileSync(result.path, outPath); + ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); + return 0; + } finally { + lib.close(); + } +}; + +export const backupMetadataCommand = async ( + ctx: CliContext, + dir: string, + opts: { exif?: boolean; all?: boolean }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const lib = await openReadLibrary(ctx, client); + try { + await runMetadataBackup(lib, client, dir, { + exif: opts.exif || opts.all, + onProgress: (msg) => ctx.stderr.write(msg + "\n"), + }); + return 0; + } finally { + lib.close(); + } +}; + +export const backupCommand = async ( + ctx: CliContext, + dir: string, + opts: { json?: boolean }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + + ctx.stderr.write("Starting backup...\n"); + const lib = await Library.open({ + client, + downloadDirectory: dir, + cacheDirectory: ctx.cacheDir, + }); + try { + const result = await lib.backup({ + downloadDirectory: dir, + onProgress: (msg) => { + if (!opts.json) ctx.stderr.write(msg + "\n"); + }, + }); + + if (opts.json) { + ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } else { + ctx.stderr.write("\n--- Backup complete ---\n"); + ctx.stderr.write(` Total files: ${result.totalFiles}\n`); + ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); + ctx.stderr.write(` Skipped: ${result.skipped}\n`); + ctx.stderr.write(` Failed: ${result.failed}\n`); + if (result.errors.length > 0) { + ctx.stderr.write("\nFailed files:\n"); + for (const e of result.errors) { + ctx.stderr.write( + ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, + ); + } + } + } + + return result.failed > 0 ? 1 : 0; + } finally { + lib.close(); + } +}; + +export const listMissingThumbnailsCommand = async ( + ctx: CliContext, + opts: { json?: boolean }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const lib = await openReadLibrary(ctx, client); + try { + const missing = await listMissingThumbnails(lib, client, (msg) => { + if (!opts.json) ctx.stderr.write(msg + "\n"); + }); + + if (opts.json) { + ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n"); + } else { + if (missing.length === 0) { + ctx.stderr.write("No missing thumbnails found.\n"); + } else { + ctx.stderr.write( + `\n${missing.length} file(s) with missing thumbnails:\n`, + ); + for (const m of missing) { + ctx.stdout.write( + `${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`, + ); + } + } + } + return 0; + } finally { + lib.close(); + } +}; + +export const fixMissingThumbnailsCommand = async ( + ctx: CliContext, + opts: { file?: string[]; json?: boolean }, +): Promise => { + await init(); + const client = requireSession(ctx); + if (!client) return 1; + const lib = await openReadLibrary(ctx, client); + try { + let fileIDs: number[]; + if (opts.file && opts.file.length > 0) { + fileIDs = opts.file.map(Number).filter(Number.isFinite); + } else { + ctx.stderr.write("Scanning for missing thumbnails...\n"); + const missing = await listMissingThumbnails(lib, client, (msg) => { + if (!opts.json) ctx.stderr.write(msg + "\n"); + }); + fileIDs = missing.map((m) => m.fileID); + if (fileIDs.length === 0) { + ctx.stderr.write("No missing thumbnails found.\n"); + return 0; + } + ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`); + } + + const results = await fixMissingThumbnails( + lib, + client, + fileIDs, + (msg) => { + if (!opts.json) ctx.stderr.write(msg + "\n"); + }, + ); + + if (opts.json) { + ctx.stdout.write(JSON.stringify(results, null, 2) + "\n"); + } else { + const fixed = results.filter((r) => r.status === "fixed").length; + const skipped = results.filter( + (r) => r.status === "skipped", + ).length; + const failed = results.filter((r) => r.status === "failed").length; + ctx.stderr.write(`\n--- Done ---\n`); + ctx.stderr.write(` Fixed: ${fixed}\n`); + ctx.stderr.write(` Skipped: ${skipped}\n`); + ctx.stderr.write(` Failed: ${failed}\n`); + if (skipped > 0) { + ctx.stderr.write("\nSkipped (unsupported format):\n"); + for (const r of results.filter((r) => r.status === "skipped")) { + ctx.stderr.write( + ` ${r.fileID}\t${r.title}\t${r.reason}\n`, + ); + } + } + if (failed > 0) { + ctx.stderr.write("\nFailed files:\n"); + for (const r of results.filter((r) => r.status === "failed")) { + ctx.stderr.write( + ` ${r.fileID}\t${r.title}\t${r.reason}\n`, + ); + } + } + } + + return results.some((r) => r.status === "failed") ? 1 : 0; + } finally { + lib.close(); + } +}; diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts new file mode 100644 index 0000000..9f100a1 --- /dev/null +++ b/test/cli/commands.test.ts @@ -0,0 +1,397 @@ +/** + * Tests for the CLI commands (`src/cli-commands.ts`, issue #12). + * + * Each command is called directly with a context whose output streams collect + * text, whose session directory is a fresh temp directory, and whose session + * loader hands back a fake client. The fake serves two albums and three files + * from memory, writes stand-in bytes for originals and thumbnails, and makes no + * network calls. The helpers the commands call (`cli-read`, `cli-output`, + * backup, thumbnails) have their own tests; these check what each command + * prints and the exit code it returns. + */ + +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; + +import { + type CliContext, + saveSession, + whoamiCommand, + logoutCommand, + collectionsCommand, + filesCommand, + getCommand, + getThumbCommand, + backupCommand, + listMissingThumbnailsCommand, +} from "../../src/cli-commands.js"; +import { loadSession } from "../../src/cli-session.js"; +import type { Client, ClientSnapshot } from "../../src/client.js"; +import type { ContentSource } from "../../src/library/content.js"; +import type { Collection, EnteFile } from "../../src/model/types.js"; +import { init } from "../../src/crypto/index.js"; + +const USER_ID = 42; + +const collection = ( + id: number, + name: string, + isShared = false, +): Collection => ({ + id, + ownerID: USER_ID, + key: new Uint8Array([id]), + name, + type: "album", + updationTime: 1, + isShared, +}); + +const file = (id: number, collectionID: number, title: string): EnteFile => ({ + id, + collectionID, + ownerID: USER_ID, + key: new Uint8Array([id & 0xff]), + metadata: { + title, + fileType: "image", + creationTime: 1000, + modificationTime: 1000, + }, + file: { decryptionHeader: "aGVhZGVy" }, + thumbnail: { decryptionHeader: "dGh1bWI=" }, + updationTime: 1, +}); + +const COLLECTIONS = [collection(1, "Vacation"), collection(2, "Work", true)]; + +const FILES: Record = { + 1: [file(100, 1, "beach.jpg"), file(101, 1, "sunset.jpg")], + 2: [file(200, 2, "diagram.png")], +}; + +// An original is 7 bytes and a thumbnail 3. `failID` makes that file's +// original fail; `emptyThumbID` makes the server report that file's +// thumbnail as empty. +const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => { + const source: ContentSource = { + original: async ({ file: f, destination }) => { + if (f.id === opts.failID) throw new Error("HTTP 500 from server"); + writeFileSync(destination, Buffer.alloc(7, f.id & 0xff)); + return { bytesWritten: 7 }; + }, + thumbnail: async ({ file: f, destination }) => { + writeFileSync(destination, Buffer.alloc(3, f.id & 0xff)); + return { bytesWritten: 3 }; + }, + }; + const fake = { + whoami: () => ({ email: "cli@example.com", userID: USER_ID }), + collectionsSince: async () => ({ + collections: COLLECTIONS, + deleted: [], + cursor: 1, + }), + filesSince: async (args: { collectionID: number }) => ({ + files: FILES[args.collectionID] ?? [], + deleted: [], + cursor: 1, + }), + contentSource: () => source, + getApiClient: () => ({ + getThumbnailStream: async (fileID: number) => + new ReadableStream({ + start(controller) { + if (fileID !== opts.emptyThumbID) { + controller.enqueue(new Uint8Array(3)); + } + controller.close(); + }, + }), + }), + }; + // The commands only call the methods above. + return fake as unknown as Client; +}; + +// Collects everything written to it. +class Output { + text = ""; + write(text: string): void { + this.text += text; + } +} + +let root: string; +let stdout: Output; +let stderr: Output; + +const context = (client: Client | null = fakeClient()): CliContext => ({ + stdout, + stderr, + sessionDir: join(root, "session"), + cacheDir: join(root, "cache"), + loadSession: () => client, +}); + +beforeAll(async () => { + await init(); +}); + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "quak-cli-test-")); + stdout = new Output(); + stderr = new Output(); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("session file", () => { + const snapshot: ClientSnapshot = { + email: "cli@example.com", + userID: USER_ID, + token: "token", + masterKey: "a", + secretKey: "b", + publicKey: "c", + }; + + it("is written with mode 0600 in a directory with mode 0700", () => { + const dir = join(root, "new", "session"); + saveSession(dir, snapshot); + expect(statSync(dir).mode & 0o777).toBe(0o700); + const path = join(dir, "session.json"); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot); + }); + + it("is removed by logout", async () => { + const ctx = context(); + saveSession(ctx.sessionDir, snapshot); + expect(await logoutCommand(ctx)).toBe(0); + expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); + expect(stderr.text).toBe("Session deleted.\n"); + }); + + it("logout without a session says so and exits 0", async () => { + expect(await logoutCommand(context())).toBe(0); + expect(stderr.text).toBe("No session found.\n"); + }); + + it("a missing session exits 1 with 'Not logged in'", async () => { + const ctx = { ...context(), loadSession }; + expect(await whoamiCommand(ctx)).toBe(1); + expect(stderr.text).toBe( + `Not logged in. Run "quak login" first.\n` + + `Session file: ${join(ctx.sessionDir, "session.json")}\n`, + ); + expect(stdout.text).toBe(""); + }); + + it("a corrupt session exits 1 and says it is corrupt", async () => { + const ctx = { ...context(), loadSession }; + saveSession(ctx.sessionDir, snapshot); + expect(await collectionsCommand(ctx, {})).toBe(1); + expect(stderr.text).toContain("is corrupt"); + expect(stderr.text).toContain( + `Run "quak logout" and then "quak login" to replace it.\n`, + ); + expect(stdout.text).toBe(""); + }); +}); + +describe("whoami", () => { + it("prints the account as one line of JSON", async () => { + expect(await whoamiCommand(context())).toBe(0); + expect(stdout.text).toBe( + `{"email":"cli@example.com","userID":${USER_ID}}\n`, + ); + }); +}); + +describe("collections", () => { + it("prints one tab-separated line per album", async () => { + expect(await collectionsCommand(context(), {})).toBe(0); + expect(stdout.text).toBe( + "1\talbum\tVacation\n" + "2\talbum\tWork (shared)\n", + ); + }); + + it("prints a JSON array with --json", async () => { + expect(await collectionsCommand(context(), { json: true })).toBe(0); + expect(JSON.parse(stdout.text)).toEqual([ + { + id: 1, + name: "Vacation", + type: "album", + ownerID: USER_ID, + isShared: false, + updationTime: 1, + }, + { + id: 2, + name: "Work", + type: "album", + ownerID: USER_ID, + isShared: true, + updationTime: 1, + }, + ]); + }); +}); + +describe("files", () => { + it("prints one tab-separated line per file", async () => { + expect(await filesCommand(context(), { collection: "1" })).toBe(0); + expect(stdout.text).toBe( + "100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n", + ); + }); + + it("prints a JSON array with --json", async () => { + const code = await filesCommand(context(), { + collection: "2", + json: true, + }); + expect(code).toBe(0); + expect(JSON.parse(stdout.text)).toEqual([ + { + id: 200, + title: "diagram.png", + fileType: "image", + creationTime: 1000, + collectionID: 2, + }, + ]); + }); + + it("exits 1 for an unknown collection", async () => { + expect(await filesCommand(context(), { collection: "9" })).toBe(1); + expect(stderr.text).toBe("Collection 9 not found\n"); + }); + + it("exits 1 for a collection ID that is not a number", async () => { + expect(await filesCommand(context(), { collection: "abc" })).toBe(1); + expect(stderr.text).toBe("Invalid collection ID\n"); + }); +}); + +describe("get and get-thumb", () => { + it("get finds a file in any album without --collection", async () => { + const out = join(root, "diagram.png"); + expect(await getCommand(context(), "200", { out })).toBe(0); + expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200)); + expect(stderr.text).toBe(`7 bytes -> ${out}\n`); + }); + + it("get-thumb finds a file in any album without --collection", async () => { + const out = join(root, "thumb.jpg"); + expect(await getThumbCommand(context(), "200", { out })).toBe(0); + expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200)); + expect(stderr.text).toBe(`3 bytes -> ${out}\n`); + }); + + it("get exits 1 when no album has the file", async () => { + const out = join(root, "x"); + expect(await getCommand(context(), "999", { out })).toBe(1); + expect(stderr.text).toBe("File 999 not found\n"); + expect(existsSync(out)).toBe(false); + }); + + it("get-thumb exits 1 when no album has the file", async () => { + const out = join(root, "x"); + expect(await getThumbCommand(context(), "999", { out })).toBe(1); + expect(stderr.text).toBe("File 999 not found\n"); + expect(existsSync(out)).toBe(false); + }); + + it("both exit 1 for a file ID that is not a number", async () => { + expect(await getCommand(context(), "abc", {})).toBe(1); + expect(await getThumbCommand(context(), "abc", {})).toBe(1); + expect(stderr.text).toBe("Invalid file ID\nInvalid file ID\n"); + }); +}); + +describe("backup", () => { + it("exits 0 and prints a summary when every file is saved", async () => { + const dir = join(root, "backup"); + expect(await backupCommand(context(), dir, {})).toBe(0); + expect(stderr.text).toContain( + "\n--- Backup complete ---\n" + + " Total files: 3\n" + + " Downloaded: 3\n" + + " Skipped: 0\n" + + " Failed: 0\n", + ); + expect(stdout.text).toBe(""); + }); + + it("exits 1 and lists the file when one download fails", async () => { + const ctx = context(fakeClient({ failID: 101 })); + expect(await backupCommand(ctx, join(root, "backup"), {})).toBe(1); + expect(stderr.text).toContain(" Failed: 1\n"); + expect(stderr.text).toContain( + "\nFailed files:\n" + + " [Vacation] sunset.jpg (id 101): HTTP 500 from server\n", + ); + }); + + it("prints the result as JSON with --json, still exiting 1 on a failure", async () => { + const ctx = context(fakeClient({ failID: 101 })); + const code = await backupCommand(ctx, join(root, "backup"), { + json: true, + }); + expect(code).toBe(1); + const result = JSON.parse(stdout.text); + expect(result).toMatchObject({ + totalFiles: 3, + downloaded: 2, + skipped: 0, + failed: 1, + }); + expect(result.errors[0].fileID).toBe(101); + expect(stderr.text).toBe("Starting backup...\n"); + }); +}); + +describe("helper list-missing-thumbnails", () => { + it("prints one line per file with an empty thumbnail", async () => { + const ctx = context(fakeClient({ emptyThumbID: 200 })); + expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0); + expect(stdout.text).toBe( + "200\tdiagram.png\tWork\tempty thumbnail (0 bytes)\n", + ); + expect(stderr.text).toContain("\n1 file(s) with missing thumbnails:\n"); + }); + + it("says so when nothing is missing", async () => { + expect(await listMissingThumbnailsCommand(context(), {})).toBe(0); + expect(stdout.text).toBe(""); + expect(stderr.text).toContain("No missing thumbnails found.\n"); + }); + + it("prints a JSON array with --json and no progress", async () => { + const ctx = context(fakeClient({ emptyThumbID: 200 })); + expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0); + expect(JSON.parse(stdout.text)).toEqual([ + { + fileID: 200, + title: "diagram.png", + collection: "Work", + reason: "empty thumbnail (0 bytes)", + }, + ]); + expect(stderr.text).toBe(""); + }); +});