check / check (push) Successful in 42s
loginCommand now takes its login function and prompts from CliContext; bin/quak.ts passes Client.login and the terminal prompts, so behaviour is unchanged. Tests cover a login from QUAK_EMAIL/QUAK_PASSWORD with no prompt, the TOTP prompt, a failed login, and the saved session's modes, and show that --exif and --all each turn on EXIF extraction. Also rewraps the header comment of src/cli-commands.ts. Model: opus-5-5
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
// 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 `run` in
|
|
// `cli-run.ts` prints a thrown error as one line and exits once output has
|
|
// drained. Output must stay byte-identical (see `cli-output.ts`).
|
|
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
type Client,
|
|
type ClientSnapshot,
|
|
type LoginOptions,
|
|
} from "./client.js";
|
|
import { init } from "./crypto/index.js";
|
|
import {
|
|
defaultCacheDirectory,
|
|
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;
|
|
// Used by `login` only. The CLI passes `Client.login` and terminal
|
|
// prompts; tests pass fakes.
|
|
login: (opts: LoginOptions) => Promise<Client>;
|
|
prompt: (message: string) => Promise<string>;
|
|
// Like `prompt`, but the answer is masked as it is typed.
|
|
promptSecret: (message: string) => Promise<string>;
|
|
}
|
|
|
|
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> =>
|
|
Library.open({
|
|
client: readLibraryClient(client),
|
|
cacheDirectory: ctx.cacheDir,
|
|
refreshIntervalSeconds: 3600,
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
});
|
|
|
|
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
|
await init();
|
|
const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
|
|
const password =
|
|
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
|
|
|
|
ctx.stderr.write("Authenticating...\n");
|
|
try {
|
|
const client = await ctx.login({
|
|
email,
|
|
password,
|
|
totp: async () => ctx.prompt("TOTP code: "),
|
|
emailOTP: async () => ctx.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<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const info = client.whoami();
|
|
ctx.stdout.write(JSON.stringify(info) + "\n");
|
|
return 0;
|
|
};
|
|
|
|
// Ends the session on the server, then deletes the session file even when that
|
|
// failed, and exits 1 if it did. The cache is left in place; the user is told
|
|
// where it is.
|
|
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
|
|
const path = sessionPath(ctx);
|
|
if (!existsSync(path)) {
|
|
ctx.stderr.write("No session found.\n");
|
|
return 0;
|
|
}
|
|
await init();
|
|
let cacheDir = ctx.cacheDir;
|
|
let failure: string | undefined;
|
|
try {
|
|
const client = ctx.loadSession(path);
|
|
if (client) {
|
|
cacheDir ??= defaultCacheDirectory(client.whoami().userID);
|
|
await client.logoutOnServer();
|
|
client.logout();
|
|
}
|
|
} catch (err) {
|
|
failure = err instanceof Error ? err.message : String(err);
|
|
}
|
|
unlinkSync(path);
|
|
if (failure === undefined) {
|
|
ctx.stderr.write("Session ended on the server.\n");
|
|
} else {
|
|
ctx.stderr.write(
|
|
`Could not end the session on the server: ${failure}\n`,
|
|
);
|
|
}
|
|
ctx.stderr.write("Session deleted.\n");
|
|
if (cacheDir !== undefined) {
|
|
ctx.stderr.write(
|
|
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`,
|
|
);
|
|
}
|
|
return failure === undefined ? 0 : 1;
|
|
};
|
|
|
|
export const collectionsCommand = async (
|
|
ctx: CliContext,
|
|
opts: { json?: boolean },
|
|
): Promise<number> => {
|
|
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 {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const filesCommand = async (
|
|
ctx: CliContext,
|
|
opts: { collection: string; json?: boolean },
|
|
): Promise<number> => {
|
|
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 {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const getCommand = async (
|
|
ctx: CliContext,
|
|
fileIDStr: string,
|
|
opts: { out?: string },
|
|
): Promise<number> => {
|
|
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 {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const getThumbCommand = async (
|
|
ctx: CliContext,
|
|
fileIDStr: string,
|
|
opts: { out?: string },
|
|
): Promise<number> => {
|
|
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_<file's own title>, 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 {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const backupMetadataCommand = async (
|
|
ctx: CliContext,
|
|
dir: string,
|
|
opts: { exif?: boolean; all?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
// Refresh first so the dump holds current account state, not what the
|
|
// cache last held; a failed refresh throws.
|
|
await lib.fresh();
|
|
const { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
|
|
exif: opts.exif || opts.all,
|
|
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
|
});
|
|
return failedMLBatches > 0 ? 1 : 0;
|
|
} finally {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const backupCommand = async (
|
|
ctx: CliContext,
|
|
dir: string,
|
|
opts: { json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
|
|
ctx.stderr.write("Starting backup...\n");
|
|
// The precache is off: the backup fetches what it needs, and must not
|
|
// also fill the cache with every thumbnail and the recent originals.
|
|
const lib = await Library.open({
|
|
client,
|
|
downloadDirectory: dir,
|
|
cacheDirectory: ctx.cacheDir,
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
});
|
|
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 {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const listMissingThumbnailsCommand = async (
|
|
ctx: CliContext,
|
|
opts: { json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
// Refresh first so files added since the cache was written are
|
|
// checked; a failed refresh throws.
|
|
await lib.fresh();
|
|
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 {
|
|
await lib.close();
|
|
}
|
|
};
|
|
|
|
export const fixMissingThumbnailsCommand = async (
|
|
ctx: CliContext,
|
|
opts: { file?: string[]; json?: boolean },
|
|
): Promise<number> => {
|
|
await init();
|
|
const client = requireSession(ctx);
|
|
if (!client) return 1;
|
|
const lib = await openReadLibrary(ctx, client);
|
|
try {
|
|
// Refresh first so files added since the cache was written are found;
|
|
// a failed refresh throws.
|
|
await lib.fresh();
|
|
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:\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 {
|
|
await lib.close();
|
|
}
|
|
};
|