CLI: quak login + quak backup with dedup symlink layout

bin/quak.ts: commander-based CLI with login (interactive + QUAK_EMAIL/
QUAK_PASSWORD env vars), whoami, logout, backup commands. Session
stored at env-paths('quak').data/session.json (~/Library/Application
Support/quak/ on macOS, XDG on Linux).

src/backup.ts: runBackup downloads all files into originals/<id>.<ext>,
symlinks into collections/<name>/<title>, writes per-collection JSON
metadata at collections/<name>.json. Deduplicates across collections
(each file downloaded once). Skips existing originals on incremental
runs. Never crashes on single-file failure.

4 backup tests + live-tested against real Ente account.
This commit is contained in:
2026-05-13 18:47:06 -07:00
parent 30a13eeeaf
commit 8ee1be1cc2
3 changed files with 348 additions and 27 deletions

189
bin/quak.ts Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
import { createInterface } from "node:readline/promises";
import { stdin, 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 { runBackup } from "../src/backup.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> => {
const rl = createInterface({ input: stdin, output: stderr });
const answer = await rl.question(message);
rl.close();
return answer;
};
const promptPassword = async (message: string): Promise<string> => {
if (!stdin.isTTY) {
return prompt(message);
}
stderr.write(message);
stdin.setRawMode(true);
stdin.resume();
const chars: string[] = [];
return new Promise((resolve) => {
const onData = (buf: Buffer) => {
for (const byte of buf) {
if (byte === 3) {
stdin.setRawMode(false);
process.exit(130);
}
if (byte === 13 || byte === 10) {
stdin.setRawMode(false);
stdin.removeListener("data", onData);
stdin.pause();
stderr.write("\n");
resolve(chars.join(""));
return;
}
if (byte === 127 || byte === 8) {
chars.pop();
} else {
chars.push(String.fromCharCode(byte));
}
}
};
stdin.on("data", onData);
});
};
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 ??
(stdin.isTTY ? await prompt("Email: ") : null);
const password =
process.env.QUAK_PASSWORD ??
(stdin.isTTY ? await promptPassword("Password: ") : null);
if (!email || !password) {
stderr.write(
"Set QUAK_EMAIL and QUAK_PASSWORD env vars for non-interactive use.\n",
);
process.exit(1);
}
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("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 result = await runBackup(client, dir, (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`,
);
}
}
}
process.exit(result.failed > 0 ? 1 : 0);
});
program.parse();

View File

@@ -1,6 +1,13 @@
// Stub: see the README "Development workflow" section for TDD policy.
import {
existsSync,
mkdirSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join, relative, extname } from "node:path";
import type { Client } from "./client.js";
import type { EnteFile } from "./model/types.js";
export interface BackupError {
fileID: number;
@@ -17,9 +24,114 @@ export interface BackupResult {
errors: BackupError[];
}
export const runBackup = async (
_client: Client,
_outDir: string,
): Promise<BackupResult> => {
throw new Error("backup.runBackup not implemented");
export type ProgressCallback = (message: string) => void;
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
const originalFileName = (file: EnteFile): string => {
const ext = extname(file.metadata.title || "") || ".bin";
return `${file.id}${ext}`;
};
export const runBackup = async (
client: Client,
outDir: string,
onProgress?: ProgressCallback,
): Promise<BackupResult> => {
const log = onProgress ?? (() => {});
mkdirSync(outDir, { recursive: true });
const originalsDir = join(outDir, "originals");
mkdirSync(originalsDir, { recursive: true });
const collectionsDir = join(outDir, "collections");
mkdirSync(collectionsDir, { recursive: true });
log("Fetching collections...");
const collections = await client.listCollections();
const downloadedIDs = new Set<number>();
let totalFiles = 0;
let downloaded = 0;
let skipped = 0;
let failed = 0;
const errors: BackupError[] = [];
for (const col of collections) {
const colDirName = sanitizePath(col.name || `collection-${col.id}`);
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
log(`[${col.name}] Fetching file list...`);
const files = await client.listFiles(col.id, col.key);
log(`[${col.name}] ${files.length} file(s)`);
const collectionMeta: {
id: number;
name: string;
type: string;
files: { id: number; metadata: EnteFile["metadata"] }[];
} = {
id: col.id,
name: col.name,
type: col.type,
files: [],
};
for (const file of files) {
totalFiles++;
const origName = originalFileName(file);
const origPath = join(originalsDir, origName);
const linkName = sanitizePath(
file.metadata.title || `file-${file.id}`,
);
const linkPath = join(colDir, linkName);
if (!downloadedIDs.has(file.id)) {
if (existsSync(origPath) && statSync(origPath).size > 0) {
skipped++;
downloadedIDs.add(file.id);
} else {
try {
log(`[${col.name}] Downloading ${linkName}...`);
await client.downloadFile(file, origPath);
downloaded++;
downloadedIDs.add(file.id);
} catch (err) {
log(
`[${col.name}] FAILED ${linkName}: ${err instanceof Error ? err.message : err}`,
);
failed++;
errors.push({
fileID: file.id,
title: file.metadata.title,
collection: col.name,
error:
err instanceof Error
? err.message
: String(err),
});
continue;
}
}
}
if (!existsSync(linkPath) && existsSync(origPath)) {
const target = relative(colDir, origPath);
symlinkSync(target, linkPath);
}
collectionMeta.files.push({
id: file.id,
metadata: file.metadata,
});
}
writeFileSync(
join(collectionsDir, `${colDirName}.json`),
JSON.stringify(collectionMeta, null, 2),
);
}
return { totalFiles, downloaded, skipped, failed, errors };
};

View File

@@ -33,7 +33,14 @@
* is a thin wrapper around this module.
*/
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
existsSync,
lstatSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
@@ -363,13 +370,22 @@ describe("quak backup", () => {
expect(result.failed).toBe(0);
expect(result.errors).toEqual([]);
// Files are under <outDir>/<collection-name>/<title>
const beach = readFileSync(join(outDir, "Vacation", "beach.jpg"));
const sunset = readFileSync(join(outDir, "Vacation", "sunset.jpg"));
const diagram = readFileSync(join(outDir, "Work", "diagram.png"));
expect(beach.length).toBe(3000);
expect(sunset.length).toBe(2000);
expect(diagram.length).toBe(1500);
// Originals are under <outDir>/originals/<fileID>.<ext>
expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe(
3000,
);
expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe(
2000,
);
expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe(
1500,
);
// Collection dirs under collections/ contain symlinks to originals
const beachLink = join(outDir, "collections", "Vacation", "beach.jpg");
expect(lstatSync(beachLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(beachLink)).toContain("originals");
expect(readFileSync(beachLink).length).toBe(3000);
});
it("skips files that already exist on disk with matching size", async () => {
@@ -411,14 +427,17 @@ describe("quak backup", () => {
expect(result.errors[0]!.fileID).toBe(101);
expect(result.errors[0]!.title).toBe("sunset.jpg");
// The two successful files are on disk
expect(existsSync(join(outDir, "Vacation", "beach.jpg"))).toBe(true);
expect(existsSync(join(outDir, "Work", "diagram.png"))).toBe(true);
// The failed file is NOT on disk (no partial write)
expect(existsSync(join(outDir, "Vacation", "sunset.jpg"))).toBe(false);
// The two successful originals are on disk
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
// The failed file has no original and no symlink
expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false);
expect(
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
).toBe(false);
});
it("writes metadata.json with collection and file info", async () => {
it("writes per-collection JSON metadata", async () => {
const outDir = join(testDir, "metadata-check");
const client = await Client.login({
email: TEST_EMAIL,
@@ -428,11 +447,12 @@ describe("quak backup", () => {
await runBackup(client, outDir);
const metaPath = join(outDir, "metadata.json");
expect(existsSync(metaPath)).toBe(true);
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
expect(meta.collections.length).toBe(2);
expect(meta.collections[0].files.length).toBeGreaterThan(0);
expect(meta.collections[0].files[0].metadata.title).toBeDefined();
// Each collection gets a <name>.json next to its image dir
const vacationMeta = join(outDir, "collections", "Vacation.json");
expect(existsSync(vacationMeta)).toBe(true);
const meta = JSON.parse(readFileSync(vacationMeta, "utf-8"));
expect(meta.name).toBe("Vacation");
expect(meta.files.length).toBeGreaterThan(0);
expect(meta.files[0].metadata.title).toBeDefined();
});
});