check / check (push) Successful in 25s
Rewrites backup on the library API. backup() refreshes, fetches pending originals (and optionally thumbnails) through the pools reusing the content cache, then materialises the unchanged collections/ symlink views + per-collection JSON + sidecars from the model. A symlink failure no longer aborts the run (closes #8). failures.json reconciles against each run's attempted set — since-deleted/out-of-scope/resolved entries clear, still-failing retained, one attempt per file per run — and the exit-code contract is preserved. Model: opus-4-8
442 lines
14 KiB
JavaScript
442 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { input, password as passwordPrompt } from "@inquirer/prompts";
|
|
import { stdout, stderr } from "node:process";
|
|
import { existsSync, mkdirSync, readFileSync, 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 } from "../src/library/index.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 loadSession = (): ClientSnapshot | null => {
|
|
if (!existsSync(sessionPath)) return null;
|
|
try {
|
|
return JSON.parse(readFileSync(sessionPath, "utf-8")) as ClientSnapshot;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const saveSession = (snapshot: ClientSnapshot): void => {
|
|
mkdirSync(paths.data, { recursive: true, mode: 0o700 });
|
|
writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), {
|
|
mode: 0o600,
|
|
});
|
|
};
|
|
|
|
const requireSession = (): Client => {
|
|
const snapshot = loadSession();
|
|
if (!snapshot) {
|
|
stderr.write(
|
|
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
return Client.fromJSON(snapshot);
|
|
};
|
|
|
|
const prompt = async (message: string): Promise<string> => input({ message });
|
|
|
|
const promptSecret = async (message: string): Promise<string> =>
|
|
passwordPrompt({ message, mask: true });
|
|
|
|
const program = new Command();
|
|
|
|
program
|
|
.name("quak")
|
|
.description("CLI for the Ente end-to-end encrypted photo service")
|
|
.version("0.0.0");
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("whoami")
|
|
.description("Print the logged-in account")
|
|
.action(() => {
|
|
const client = requireSession();
|
|
const info = client.whoami();
|
|
stdout.write(JSON.stringify(info) + "\n");
|
|
});
|
|
|
|
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");
|
|
}
|
|
});
|
|
|
|
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 collections = await client.listCollections();
|
|
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("files")
|
|
.description("List files in a collection")
|
|
.requiredOption(
|
|
"--collection <id>",
|
|
"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 collections = await client.listCollections();
|
|
const col = collections.find((c) => c.id === collectionID);
|
|
if (!col) {
|
|
stderr.write(`Collection ${collectionID} not found\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const files = await client.listFiles(col.id, col.key);
|
|
|
|
if (opts.json) {
|
|
stdout.write(
|
|
JSON.stringify(
|
|
files.map((f) => ({
|
|
id: f.id,
|
|
title: f.metadata.title,
|
|
fileType: f.metadata.fileType,
|
|
creationTime: f.metadata.creationTime,
|
|
collectionID: f.collectionID,
|
|
})),
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
} else {
|
|
for (const f of files) {
|
|
stdout.write(
|
|
`${f.id}\t${f.metadata.fileType}\t${f.metadata.title}\n`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
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>",
|
|
"Collection ID (required to look up the file key)",
|
|
)
|
|
.action(
|
|
async (
|
|
fileIDStr: string,
|
|
opts: { out?: string; collection?: string },
|
|
) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const fileID = Number(fileIDStr);
|
|
if (!Number.isFinite(fileID)) {
|
|
stderr.write("Invalid file ID\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const collections = await client.listCollections();
|
|
let targetCol;
|
|
if (opts.collection) {
|
|
targetCol = collections.find(
|
|
(c) => c.id === Number(opts.collection),
|
|
);
|
|
}
|
|
|
|
// Search all collections (or the specified one) for the file
|
|
const searchCols = targetCol ? [targetCol] : collections;
|
|
for (const col of searchCols) {
|
|
const files = await client.listFiles(col.id, col.key);
|
|
const file = files.find((f) => f.id === fileID);
|
|
if (file) {
|
|
const result = await client.downloadFile(file, opts.out);
|
|
stderr.write(
|
|
`${result.bytesWritten} bytes -> ${result.path}\n`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
stderr.write(`File ${fileID} not found\n`);
|
|
process.exit(1);
|
|
},
|
|
);
|
|
|
|
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>",
|
|
"Collection ID (required to look up the file key)",
|
|
)
|
|
.action(
|
|
async (
|
|
fileIDStr: string,
|
|
opts: { out?: string; collection?: string },
|
|
) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const fileID = Number(fileIDStr);
|
|
if (!Number.isFinite(fileID)) {
|
|
stderr.write("Invalid file ID\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const collections = await client.listCollections();
|
|
let targetCol;
|
|
if (opts.collection) {
|
|
targetCol = collections.find(
|
|
(c) => c.id === Number(opts.collection),
|
|
);
|
|
}
|
|
|
|
const searchCols = targetCol ? [targetCol] : collections;
|
|
for (const col of searchCols) {
|
|
const files = await client.listFiles(col.id, col.key);
|
|
const file = files.find((f) => f.id === fileID);
|
|
if (file) {
|
|
const result = await client.downloadThumbnail(
|
|
file,
|
|
opts.out,
|
|
);
|
|
stderr.write(
|
|
`${result.bytesWritten} bytes -> ${result.path}\n`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
stderr.write(`File ${fileID} not found\n`);
|
|
process.exit(1);
|
|
},
|
|
);
|
|
|
|
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(async (dir: string, opts: { exif?: boolean; all?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
await runMetadataBackup(client, dir, {
|
|
exif: opts.exif || opts.all,
|
|
onProgress: (msg) => stderr.write(msg + "\n"),
|
|
});
|
|
});
|
|
|
|
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")
|
|
.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 });
|
|
const result = await lib.backup({
|
|
downloadDirectory: dir,
|
|
onProgress: (msg) => {
|
|
if (!opts.json) stderr.write(msg + "\n");
|
|
},
|
|
});
|
|
lib.close();
|
|
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
process.exit(result.failed > 0 ? 1 : 0);
|
|
});
|
|
|
|
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(async (opts: { json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const missing = await listMissingThumbnails(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`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
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(async (opts: { file?: string[]; json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
|
|
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(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");
|
|
return;
|
|
}
|
|
stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
|
|
}
|
|
|
|
const results = await fixMissingThumbnails(client, fileIDs, (msg) => {
|
|
if (!opts.json) stderr.write(msg + "\n");
|
|
});
|
|
|
|
if (opts.json) {
|
|
stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
} else {
|
|
const ok = results.filter((r) => r.success).length;
|
|
const fail = results.filter((r) => !r.success).length;
|
|
stderr.write(`\n--- Done ---\n`);
|
|
stderr.write(` Fixed: ${ok}\n`);
|
|
stderr.write(` Failed: ${fail}\n`);
|
|
if (fail > 0) {
|
|
stderr.write("\nFailed files:\n");
|
|
for (const r of results.filter((r) => !r.success)) {
|
|
stderr.write(` ${r.fileID}\t${r.title}\t${r.error}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
process.exit(results.some((r) => !r.success) ? 1 : 0);
|
|
});
|
|
|
|
await init();
|
|
program.parse();
|