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:
189
bin/quak.ts
Normal file
189
bin/quak.ts
Normal 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();
|
||||
Reference in New Issue
Block a user