Complete CLI surface: quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak whoami print logged-in account quak logout delete session quak collections list all albums (--json) quak files list files in a collection (--json) quak get <id> download+decrypt a file (--out, --collection) quak get-thumb <id> download+decrypt a thumbnail quak backup <dir> full incremental backup get/get-thumb search all collections for the file ID when --collection is not specified. All listing commands support --json. Live-tested: collections list, file list, single file download (472 KB JPEG from the dev account, verified as valid JPEG with EXIF intact).
370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
#!/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("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")
|
|
.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();
|