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

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();
});
});