Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df34735989 |
@@ -517,17 +517,8 @@ The CLI stores the snapshot at the platform-appropriate data directory via
|
|||||||
`0600`. The key material is stored in cleartext in the JSON; treat this file as
|
`0600`. The key material is stored in cleartext in the JSON; treat this file as
|
||||||
you would treat the password itself. A missing file is reported as "not logged
|
you would treat the password itself. A missing file is reported as "not logged
|
||||||
in"; a file that exists but is corrupt is reported as such, naming the bad
|
in"; a file that exists but is corrupt is reported as such, naming the bad
|
||||||
field. When the refresh a command starts with gets HTTP 401 from the server,
|
field. Both exit with status 1, except that `quak logout` with no file says
|
||||||
because it no longer accepts the saved session's token, the command prints one
|
there is no session and exits 0.
|
||||||
line, `quak: the saved session is no longer valid; run "quak login"`, with no
|
|
||||||
stack trace. All three exit with status 3, which means the user must run
|
|
||||||
`quak login` again. `quak logout` is the exception: with no file it says there
|
|
||||||
is no session and exits 0, and it handles a corrupt file or a failed server call
|
|
||||||
as described below. `quak backup` meets an expired session on the refresh that
|
|
||||||
starts every run, before it touches any file. A session that stops working
|
|
||||||
partway through a backup instead fails each remaining file into `failures.json`,
|
|
||||||
so that run exits 1 and the next one stops at its refresh with status 3. No
|
|
||||||
command but `quak login` ever prompts.
|
|
||||||
|
|
||||||
`quak logout` ends the session on the server, so the token in `session.json`
|
`quak logout` ends the session on the server, so the token in `session.json`
|
||||||
stops working even in a copy of the file, and then deletes the file. If the
|
stops working even in a copy of the file, and then deletes the file. If the
|
||||||
@@ -561,9 +552,8 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
|
|||||||
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
||||||
answer, so they report current account state rather than whatever the cache last
|
answer, so they report current account state rather than whatever the cache last
|
||||||
held. If that round-trip fails, the command prints the error on one line and
|
held. If that round-trip fails, the command prints the error on one line and
|
||||||
exits 1, or 3 when the server no longer accepts the saved session (see "Session
|
exits 1. `--cache-dir` overrides where the cache lives; without it each account
|
||||||
handling"). `--cache-dir` overrides where the cache lives; without it each
|
gets its own directory under the per-user cache path.
|
||||||
account gets its own directory under the per-user cache path.
|
|
||||||
|
|
||||||
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
||||||
accepted for backward compatibility but ignored. For a live photo, `get` writes
|
accepted for backward compatibility but ignored. For a live photo, `get` writes
|
||||||
|
|||||||
@@ -34,16 +34,6 @@ declares one.
|
|||||||
field is taken from that JSON. The hand-built JPEGs moved to
|
field is taken from that JSON. The hand-built JPEGs moved to
|
||||||
`test/exif-jpeg.ts`, beside the HEIC.
|
`test/exif-jpeg.ts`, beside the HEIC.
|
||||||
|
|
||||||
- 2026-10-06: When the server answers HTTP 401 and that ends a command that
|
|
||||||
loads the saved session, because the server no longer accepts its token, the
|
|
||||||
command prints one line,
|
|
||||||
`quak: the saved session is no longer valid; run "quak login"`, and exits 3
|
|
||||||
(issue 164). `quak backup` meets that 401 on the refresh it starts with,
|
|
||||||
before it touches any file. For the same commands, a missing or corrupt
|
|
||||||
session file keeps its message and also exits 3, so a cron job can tell that
|
|
||||||
the user must log in again. `quak logout` is unchanged. `run` in
|
|
||||||
`src/cli-run.ts` recognises the 401, which reaches it unchanged.
|
|
||||||
|
|
||||||
- 2026-10-06: `quak backup` retries a failed request for longer than the other
|
- 2026-10-06: `quak backup` retries a failed request for longer than the other
|
||||||
commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS`
|
commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS`
|
||||||
beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so
|
beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so
|
||||||
|
|||||||
+10
-12
@@ -73,9 +73,7 @@ export const saveSession = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The saved client, or undefined after telling the user why there is none. The
|
// The saved client, or undefined after telling the user why there is none.
|
||||||
// command then exits 3, the code for "log in again", as `run` in `cli-run.ts`
|
|
||||||
// does when the server no longer accepts the saved session.
|
|
||||||
const requireSession = (ctx: CliContext): Client | undefined => {
|
const requireSession = (ctx: CliContext): Client | undefined => {
|
||||||
let client: Client | null;
|
let client: Client | null;
|
||||||
try {
|
try {
|
||||||
@@ -152,7 +150,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
|||||||
export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
|
export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const info = client.whoami();
|
const info = client.whoami();
|
||||||
ctx.stdout.write(JSON.stringify(info) + "\n");
|
ctx.stdout.write(JSON.stringify(info) + "\n");
|
||||||
return 0;
|
return 0;
|
||||||
@@ -203,7 +201,7 @@ export const collectionsCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
try {
|
||||||
// Force a server round-trip and list in enumeration order (issue #36
|
// Force a server round-trip and list in enumeration order (issue #36
|
||||||
@@ -245,7 +243,7 @@ export const filesCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const collectionID = Number(opts.collection);
|
const collectionID = Number(opts.collection);
|
||||||
if (!Number.isFinite(collectionID)) {
|
if (!Number.isFinite(collectionID)) {
|
||||||
ctx.stderr.write("Invalid collection ID\n");
|
ctx.stderr.write("Invalid collection ID\n");
|
||||||
@@ -287,7 +285,7 @@ export const getCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const fileID = Number(fileIDStr);
|
const fileID = Number(fileIDStr);
|
||||||
if (!Number.isFinite(fileID)) {
|
if (!Number.isFinite(fileID)) {
|
||||||
ctx.stderr.write("Invalid file ID\n");
|
ctx.stderr.write("Invalid file ID\n");
|
||||||
@@ -345,7 +343,7 @@ export const getThumbCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const fileID = Number(fileIDStr);
|
const fileID = Number(fileIDStr);
|
||||||
if (!Number.isFinite(fileID)) {
|
if (!Number.isFinite(fileID)) {
|
||||||
ctx.stderr.write("Invalid file ID\n");
|
ctx.stderr.write("Invalid file ID\n");
|
||||||
@@ -382,7 +380,7 @@ export const backupMetadataCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
try {
|
||||||
// Refresh first so the dump holds current account state, not what the
|
// Refresh first so the dump holds current account state, not what the
|
||||||
@@ -405,7 +403,7 @@ export const backupCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
|
|
||||||
ctx.stderr.write("Starting backup...\n");
|
ctx.stderr.write("Starting backup...\n");
|
||||||
// The precache is off: the backup fetches what it needs, and must not
|
// The precache is off: the backup fetches what it needs, and must not
|
||||||
@@ -455,7 +453,7 @@ export const listMissingThumbnailsCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
try {
|
||||||
// Refresh first so files added since the cache was written are
|
// Refresh first so files added since the cache was written are
|
||||||
@@ -493,7 +491,7 @@ export const fixMissingThumbnailsCommand = async (
|
|||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 1;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
try {
|
||||||
// Refresh first so files added since the cache was written are found;
|
// Refresh first so files added since the cache was written are found;
|
||||||
|
|||||||
+5
-15
@@ -1,15 +1,12 @@
|
|||||||
// Runs one CLI command for `bin/quak.ts` and exits with its code.
|
// Runs one CLI command for `bin/quak.ts` and exits with its code.
|
||||||
|
|
||||||
import type { Writable } from "node:stream";
|
import type { Writable } from "node:stream";
|
||||||
import { ApiError } from "./api/client.js";
|
|
||||||
|
|
||||||
// Run a command and exit with its code once stdout/stderr have drained.
|
// Run a command and exit with its code once stdout/stderr have drained.
|
||||||
// Exiting before the drain can truncate piped output, and the library can keep
|
// Exiting before the drain can truncate piped output, and the library can keep
|
||||||
// the event loop alive after a command returns, so a plain return could hang.
|
// the event loop alive after a command returns, so a plain return could hang.
|
||||||
// An error the command throws is printed as one `quak: MESSAGE` line, without
|
// An error the command throws is printed as one `quak: MESSAGE` line, without
|
||||||
// the stack trace, and exits 1. A 401 from the server means it no longer
|
// the stack trace, and exits 1.
|
||||||
// accepts the saved session: that prints one line saying to log in again and
|
|
||||||
// exits 3, as a missing or corrupt session file does.
|
|
||||||
export const run = async (
|
export const run = async (
|
||||||
command: Promise<number>,
|
command: Promise<number>,
|
||||||
stdout: Writable,
|
stdout: Writable,
|
||||||
@@ -20,17 +17,10 @@ export const run = async (
|
|||||||
try {
|
try {
|
||||||
code = await command;
|
code = await command;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 401) {
|
stderr.write(
|
||||||
stderr.write(
|
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
`quak: the saved session is no longer valid; run "quak login"\n`,
|
);
|
||||||
);
|
code = 1;
|
||||||
code = 3;
|
|
||||||
} else {
|
|
||||||
stderr.write(
|
|
||||||
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
||||||
);
|
|
||||||
code = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
|
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
|
|||||||
+18
-68
@@ -230,30 +230,20 @@ describe("session file", () => {
|
|||||||
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
|
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a missing session exits 3 with 'Not logged in' from every command that needs one", async () => {
|
it("a missing session exits 1 with 'Not logged in'", async () => {
|
||||||
const ctx = { ...context(), loadSession };
|
const ctx = { ...context(), loadSession };
|
||||||
const dir = join(root, "backup");
|
expect(await whoamiCommand(ctx)).toBe(1);
|
||||||
expect(await whoamiCommand(ctx)).toBe(3);
|
expect(stderr.text).toBe(
|
||||||
expect(await collectionsCommand(ctx, {})).toBe(3);
|
|
||||||
expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
|
|
||||||
expect(await getCommand(ctx, "100", {})).toBe(3);
|
|
||||||
expect(await getThumbCommand(ctx, "100", {})).toBe(3);
|
|
||||||
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
|
|
||||||
expect(await backupCommand(ctx, dir, {})).toBe(3);
|
|
||||||
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(3);
|
|
||||||
expect(await fixMissingThumbnailsCommand(ctx, {})).toBe(3);
|
|
||||||
const notLoggedIn =
|
|
||||||
`Not logged in. Run "quak login" first.\n` +
|
`Not logged in. Run "quak login" first.\n` +
|
||||||
`Session file: ${join(ctx.sessionDir, "session.json")}\n`;
|
`Session file: ${join(ctx.sessionDir, "session.json")}\n`,
|
||||||
expect(stderr.text).toBe(notLoggedIn.repeat(9));
|
);
|
||||||
expect(stdout.text).toBe("");
|
expect(stdout.text).toBe("");
|
||||||
expect(existsSync(dir)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a corrupt session exits 3 and says it is corrupt", async () => {
|
it("a corrupt session exits 1 and says it is corrupt", async () => {
|
||||||
const ctx = { ...context(), loadSession };
|
const ctx = { ...context(), loadSession };
|
||||||
saveSession(ctx.sessionDir, snapshot);
|
saveSession(ctx.sessionDir, snapshot);
|
||||||
expect(await collectionsCommand(ctx, {})).toBe(3);
|
expect(await collectionsCommand(ctx, {})).toBe(1);
|
||||||
expect(stderr.text).toContain("is corrupt");
|
expect(stderr.text).toContain("is corrupt");
|
||||||
expect(stderr.text).toContain(
|
expect(stderr.text).toContain(
|
||||||
`Run "quak logout" and then "quak login" to replace it.\n`,
|
`Run "quak logout" and then "quak login" to replace it.\n`,
|
||||||
@@ -758,12 +748,15 @@ describe("backup", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Runs `backup` through `run`, as `bin/quak.ts` does, which prints a thrown
|
it("exits 1 with the error on one line when the refresh fails", async () => {
|
||||||
// error; returns the exit code and what `run` printed.
|
const client = {
|
||||||
const backupThroughRun = async (
|
...fakeClient(),
|
||||||
ctx: CliContext,
|
collectionsSince: async () => {
|
||||||
dir: string,
|
throw new Error("HTTP 401 from server");
|
||||||
): Promise<{ code: number; runText: string }> => {
|
},
|
||||||
|
} as unknown as Client;
|
||||||
|
const dir = join(root, "backup");
|
||||||
|
// Through `run`, as `bin/quak.ts` does, which prints a thrown error.
|
||||||
const runStderr = new PassThrough();
|
const runStderr = new PassThrough();
|
||||||
let runText = "";
|
let runText = "";
|
||||||
runStderr.on("data", (chunk: Buffer) => {
|
runStderr.on("data", (chunk: Buffer) => {
|
||||||
@@ -771,57 +764,14 @@ describe("backup", () => {
|
|||||||
});
|
});
|
||||||
const code = await new Promise<number>((resolve) => {
|
const code = await new Promise<number>((resolve) => {
|
||||||
void run(
|
void run(
|
||||||
backupCommand(ctx, dir, {}),
|
backupCommand(context(client), dir, {}),
|
||||||
new PassThrough(),
|
new PassThrough(),
|
||||||
runStderr,
|
runStderr,
|
||||||
resolve,
|
resolve,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return { code, runText };
|
|
||||||
};
|
|
||||||
|
|
||||||
it("exits 1 with the error on one line when the refresh fails", async () => {
|
|
||||||
const client = {
|
|
||||||
...fakeClient(),
|
|
||||||
collectionsSince: async () => {
|
|
||||||
throw new Error("HTTP 503 from server");
|
|
||||||
},
|
|
||||||
} as unknown as Client;
|
|
||||||
const dir = join(root, "backup");
|
|
||||||
const { code, runText } = await backupThroughRun(context(client), dir);
|
|
||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(runText).toBe("quak: HTTP 503 from server\n");
|
expect(runText).toBe("quak: HTTP 401 from server\n");
|
||||||
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
|
||||||
expect(existsSync(dir)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A real saved session, read back by `loadSession`, whose server answers
|
|
||||||
// every request with 401, as it does once it no longer accepts the token.
|
|
||||||
// The context's prompts throw, so a prompt would end the run with another
|
|
||||||
// line and exit 1.
|
|
||||||
it("exits 3 with one line saying to log in again when the server answers 401", async () => {
|
|
||||||
const key = toBase64(new Uint8Array(32));
|
|
||||||
saveSession(join(root, "session"), {
|
|
||||||
email: "cli@example.com",
|
|
||||||
userID: USER_ID,
|
|
||||||
token: "expired",
|
|
||||||
masterKey: key,
|
|
||||||
secretKey: key,
|
|
||||||
publicKey: key,
|
|
||||||
});
|
|
||||||
const unauthorized = async (): Promise<Response> =>
|
|
||||||
new Response(null, { status: 401 });
|
|
||||||
const ctx = {
|
|
||||||
...context(),
|
|
||||||
loadSession: (path: string) =>
|
|
||||||
loadSession(path, { fetch: unauthorized }),
|
|
||||||
};
|
|
||||||
const dir = join(root, "backup");
|
|
||||||
const { code, runText } = await backupThroughRun(ctx, dir);
|
|
||||||
expect(code).toBe(3);
|
|
||||||
expect(runText).toBe(
|
|
||||||
`quak: the saved session is no longer valid; run "quak login"\n`,
|
|
||||||
);
|
|
||||||
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
||||||
expect(existsSync(dir)).toBe(false);
|
expect(existsSync(dir)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
import { ApiError } from "../../src/api/client.js";
|
|
||||||
import { run } from "../../src/cli-run.js";
|
import { run } from "../../src/cli-run.js";
|
||||||
|
|
||||||
// A stream whose written text is kept in `text`; writes finish at once, so
|
// A stream whose written text is kept in `text`; writes finish at once, so
|
||||||
@@ -56,26 +55,4 @@ describe("run", () => {
|
|||||||
stderr: "quak: offline\n",
|
stderr: "quak: offline\n",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => {
|
|
||||||
const result = await runToExit(
|
|
||||||
Promise.reject(new ApiError("unauthorized", 401)),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({
|
|
||||||
code: 3,
|
|
||||||
stdout: "",
|
|
||||||
stderr: `quak: the saved session is no longer valid; run "quak login"\n`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prints another HTTP error as it is and exits 1", async () => {
|
|
||||||
const result = await runToExit(
|
|
||||||
Promise.reject(new ApiError("forbidden", 403)),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({
|
|
||||||
code: 1,
|
|
||||||
stdout: "",
|
|
||||||
stderr: "quak: forbidden\n",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user