13 Commits
Author SHA1 Message Date
christian 14a5774d32 Merge pull request 'Fix duplicate ids, validate CLI input, harden load/save' (#2) from fix/review-findings into main
Reviewed-on: #2
2026-10-21 00:58:54 +02:00
christian 696049b3b1 Merge pull request 'Document branch-and-PR workflow; never write to main' (#1) from docs/branch-pr-workflow into main
Reviewed-on: #1
2026-10-21 00:58:40 +02:00
christianandClaude Opus 5.5 f666bdf6b3 Give the PR workflow a hard stop
Only confirmed correctness bugs block approval; everything else is a note.
At most one fix round, then hand off. Don't widen scope to satisfy a
review. Merge main only on conflict or request. The token goes in a
private header file, and PR responses are checked for a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:56:51 +02:00
christianandClaude Opus 5.5 130ffe0d13 Drop the lock file: too much machinery for a personal CLI
Concurrent runs of the CLI can again lose an update; saves stay atomic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:56:16 +02:00
christianandClaude Opus 5.5 ed197eeeb1 Address review round 3: safe API snippets, PR opened once, pinned review
- Titles and bodies go through --rawfile files, never inline shell quotes
- pipefail + curl -f + jq -e so a failed call can't look successful
- Fix rounds merge latest main and skip re-opening the PR
- Check the branch isn't main before pushing; never bypass the hooks
- Review at a pinned level; only correctness findings checked against the
  code block approval

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:50:38 +02:00
christianandClaude Opus 5.5 dab492da1e Lock during updates, repair duplicate ids, keep file identity on save
- CLI holds a lock file from load to save, so concurrent commands can't
  lose an update; stale locks from crashed runs are taken over
- load() renumbers duplicate ids from the old add() bug instead of
  refusing the file
- save() keeps owner, group and setgid on the temp file, and falls back to
  an in-place write for hard-linked files, files whose owner/group can't
  be kept, and directories that aren't writable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:49:47 +02:00
christianandClaude Opus 5.5 f09d20cb84 Address review round 3: id limit, private temp files, canonical ids
- add() refuses to create an id that load() would reject; the CLI reports it
- save() creates the temp file with the real file's mode (and O_EXCL), so a
  private list is never briefly world-readable; one stat call; symlink
  resolution inlined; comment states the rename trade-offs
- CLI rejects ids with leading zeros and id 0
- Tests for trimming, the bare 'todo' default, and the id limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:41:37 +02:00
christianandClaude Opus 5.5 089fc94e16 Address review round 2: duplicate ids, read-only files, dangling links, safe ids
- load() rejects duplicate ids (left by the old add bug) and ids that aren't safe positive integers
- save() refuses files the user can't write, and follows dangling symlinks to their target
- CLI rejects ids beyond Number.MAX_SAFE_INTEGER
- Skip permission-based tests when running as root

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:33:46 +02:00
christianandClaude Opus 5.5 fa45c58c0c Address review round 2: no-track branches, honest enforcement, safer API calls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:32:15 +02:00
christianandClaude Opus 5.5 8dc5194438 Address review: safer save, stricter load and id parsing
- save() follows symlinks, keeps permissions, uses a unique temp file and cleans it up on failure
- load() rejects entries that aren't {id, text, done}
- add() finds the max id with reduce (no spread limit)
- CLI accepts only plain digits as ids and reports save errors readably
- Ignore *.tmp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:26:22 +02:00
christianandClaude Opus 5.5 962c1aa9a1 Address review: fresh base, named review target, round cap, Gitea API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:22:48 +02:00
christianandClaude Opus 5.5 ba17376132 Fix duplicate ids, validate CLI input, harden load/save
- add() uses max id + 1 so ids are never reused after rm
- done/rm reject missing, non-numeric and unknown ids; add rejects empty text
- load() gives a readable error on bad JSON or non-list data
- save() writes a temp file and renames it
- CLI checks the command before loading and no longer relies on process.exit for ls
- Add regression tests for all of the above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:51 +02:00
christianandClaude Opus 5.5 82ba704957 Document branch-and-PR workflow; never write to main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:12:37 +02:00
6 changed files with 453 additions and 25 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
node_modules/
todos.json
*.tmp
.DS_Store
# Personal Claude Code overrides (not shared)
+54
View File
@@ -13,5 +13,59 @@ A tiny zero-dependency Node.js todo CLI, used as a sandbox for trying Claude Cod
- Every behavior change gets a test in `test/`.
- Commit messages: short imperative subject line.
## Workflow (required)
Never commit, merge, or push to `main`. Only a human (Christian) merges into `main`, in Gitea.
The git hooks installed by `npm install` and Gitea branch protection enforce this (see "Protecting `main`" in the README). Never get around them: no `--no-verify`, no changing `core.hooksPath`, no editing `.git/`.
1. **Branch.** Run `git fetch origin` on its own, then `git switch --no-track -c <type>/<short-name> origin/main` (e.g. `fix/duplicate-ids`).
2. **Commit, test, push.** `npm test` must pass. Check that `git branch --show-current` isn't `main`, then run `git push -u origin HEAD`.
3. **Open the PR**, once per branch (see "Gitea API").
4. **Review** with `/code-review <branch> medium`.
5. **Triage.** The review lists unverified candidates, and it will always find some. Check each one against the code. A finding **blocks** only if it is a correctness bug in this PR's change that you have confirmed, ideally with a test that fails. Everything else is a **note**: cleanups, judgement calls, hardening ideas and unconfirmed findings.
6. **Comment** on the PR:
- First line: **Needs fixes** if anything blocks, otherwise **Approved**.
- Second line: `_Automated self-review by Claude; not an independent review._`
- Then the blocking findings, then the notes, then the findings you rejected, each with the reason.
7. **At most one fix round.** If the PR needs fixes, fix only the blocking findings, each with a test. Push, review once more, and post the verdict from steps 5–6. Then stop, whatever that second review says. Findings about the fixes themselves go to the human as notes; don't fix them.
8. **Hand off.** Never merge, even if the PR is approved. Tell the human it's ready, with the verdict and any notes.
Don't widen scope beyond the PR's purpose to satisfy a review. If a fix would add significant new code (a new mechanism, not a correction), list it as a note for the human instead.
Bring in the latest `main` only when the PR conflicts or the human asks:
1. Run `git fetch origin`.
2. Run `git merge --no-ff --no-commit origin/main`.
3. If it says "Already up to date", stop here. Otherwise resolve any conflicts and run `npm test`.
4. Run `git commit --no-edit`.
Run each as its own command, and never rebase or force-push.
### Gitea API
No `gh`/`tea`: use `curl` with `$GITEA_TOKEN`.
One-time human setup in the git-ignored `.claude/settings.local.json`: set `env.GITEA_TOKEN`, and add `code.westerkam.org` to `sandbox.network.allowedDomains`. Then restart the session (`claude --continue`); settings load only at start. If the token is unset, stop and ask the human.
Rules for every API call:
- Make each call one self-contained command, because shell state doesn't carry over between commands.
- Put titles and bodies in files in `$TMPDIR` (never in the repo) and pass them with `--rawfile`, never inline in double quotes, where backticks and `$(…)` would run.
- The token goes in a private header file, so it doesn't show up in the process list.
- `pipefail`, `curl -f` and the `jq` checks make every failure exit non-zero.
```sh
# Open a PR for the current branch (once). Write $TMPDIR/pr-title.txt and $TMPDIR/pr-body.md first.
set -o pipefail; API=https://code.westerkam.org/api/v1/repos/christian/claude-playground
(umask 077; printf 'Authorization: token %s\n' "$GITEA_TOKEN" > "$TMPDIR/gitea-auth")
jq -n --arg head "$(git branch --show-current)" --rawfile title "$TMPDIR/pr-title.txt" --rawfile body "$TMPDIR/pr-body.md" \
'{head:$head, base:"main", title:($title|rtrimstr("\n")), body:$body}' \
| curl -fsS -X POST -H @"$TMPDIR/gitea-auth" -H "Content-Type: application/json" -d @- "$API/pulls" \
| jq -er 'if .number then "#\(.number) \(.html_url)" else error("no PR number in response") end'
# Comment on PR <number>. Write $TMPDIR/comment.md first.
set -o pipefail; API=https://code.westerkam.org/api/v1/repos/christian/claude-playground
(umask 077; printf 'Authorization: token %s\n' "$GITEA_TOKEN" > "$TMPDIR/gitea-auth")
jq -n --rawfile body "$TMPDIR/comment.md" '{body:$body}' \
| curl -fsS -X POST -H @"$TMPDIR/gitea-auth" -H "Content-Type: application/json" -d @- "$API/issues/<number>/comments" \
| jq -er .html_url
```
Commands listed under `sandbox.excludedCommands` in `.claude/settings.json` (`git fetch`, `git commit`, `git push` and others) run outside the sandbox only when they are run standalone: no `cd … &&` before them and nothing chained after. They need that to reach the remote and to sign commits.
## Remote
Gitea at code.westerkam.org — `origin` = `git@code.westerkam.org:christian/claude-playground.git`.
+47 -20
View File
@@ -1,28 +1,55 @@
#!/usr/bin/env node
import { load, save, add, complete, remove, format } from "./todo.js";
import { load, save, add, has, complete, remove, format } from "./todo.js";
const FILE = process.env.TODO_FILE ?? "todos.json";
const [cmd, ...args] = process.argv.slice(2);
let todos = load(FILE);
const USAGE = "Usage: todo [ls | add <text> | done <id> | rm <id>]";
const [cmd = "ls", ...args] = process.argv.slice(2);
switch (cmd) {
case "add":
todos = add(todos, args.join(" "));
break;
case "done":
todos = complete(todos, Number(args[0]));
break;
case "rm":
todos = remove(todos, Number(args[0]));
break;
case "ls":
case undefined:
console.log(format(todos));
process.exit(0);
default:
console.error(`Unknown command: ${cmd}\nUsage: todo [ls | add <text> | done <id> | rm <id>]`);
function fail(message) {
console.error(message);
process.exit(1);
}
save(FILE, todos);
function parseId(todos, arg) {
const id = Number(arg);
if (!/^[1-9]\d*$/.test(arg ?? "") || !Number.isSafeInteger(id)) fail(`Invalid id: ${arg ?? "(missing)"}\n${USAGE}`);
if (!has(todos, id)) fail(`No todo with id ${id}`);
return id;
}
if (!["ls", "add", "done", "rm"].includes(cmd)) fail(`Unknown command: ${cmd}\n${USAGE}`);
let todos;
try {
todos = load(FILE);
} catch (err) {
fail(err.message);
}
switch (cmd) {
case "add": {
const text = args.join(" ").trim();
if (!text) fail(`Missing todo text\n${USAGE}`);
try {
todos = add(todos, text);
} catch (err) {
fail(err.message);
}
break;
}
case "done":
todos = complete(todos, parseId(todos, args[0]));
break;
case "rm":
todos = remove(todos, parseId(todos, args[0]));
break;
}
if (cmd !== "ls") {
try {
save(FILE, todos);
} catch (err) {
fail(err.message);
}
}
console.log(format(todos));
+84 -4
View File
@@ -1,19 +1,99 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import {
readFileSync, writeFileSync, renameSync, existsSync, lstatSync, readlinkSync, statSync, chmodSync, chownSync, accessSync, rmSync,
constants,
} from "node:fs";
import { dirname, resolve } from "node:path";
import { randomBytes } from "node:crypto";
// Ids are positive and below MAX_SAFE_INTEGER, so max + 1 in add() is always exact.
const isId = (id) => Number.isSafeInteger(id) && id > 0 && id < Number.MAX_SAFE_INTEGER;
const isTodo = (t) =>
t !== null && typeof t === "object" && isId(t.id) && typeof t.text === "string" && typeof t.done === "boolean";
export function load(path) {
if (!existsSync(path)) return [];
return JSON.parse(readFileSync(path, "utf8"));
let todos;
try {
todos = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new Error(`Could not read ${path}: ${err.message}`);
}
if (!Array.isArray(todos)) throw new Error(`Could not read ${path}: expected a list of todos`);
const bad = todos.findIndex((t) => !isTodo(t));
if (bad !== -1) throw new Error(`Could not read ${path}: entry ${bad + 1} is not a valid todo`);
// Older versions could give two todos the same id. Keep the first and renumber the rest
// after the current highest id, so every todo can be targeted on its own again.
const seen = new Set();
let next = todos.reduce((max, t) => Math.max(max, t.id), 0);
return todos.map((t) => {
if (!seen.has(t.id)) {
seen.add(t.id);
return t;
}
if (!isId(++next)) throw new Error(`Could not read ${path}: no ids left to renumber duplicate id ${t.id}`);
return { ...t, id: next };
});
}
// Atomic save: write a uniquely named temp file next to the real file, then rename it into
// place, so an interrupted write never truncates the list.
// - Symlinks are followed by hand, so a dangling link gets its target created, not replaced.
// - The temp file gets the real file's permissions from the start (so a private list is never
// readable by others mid-write) and its owner and group.
// - A file the user can't write (chmod 444) is refused, as a plain write would be.
// - Where a rename can't keep the file's identity (other hard links, an owner or group we
// can't assign, a directory we can't write), it falls back to a plain in-place write.
export function save(path, todos) {
writeFileSync(path, JSON.stringify(todos, null, 2) + "\n");
const data = JSON.stringify(todos, null, 2) + "\n";
let tmp;
try {
let target = path;
for (let hops = 0; lstatSync(target, { throwIfNoEntry: false })?.isSymbolicLink(); hops++) {
if (hops === 40) throw new Error("too many levels of symbolic links");
target = resolve(dirname(target), readlinkSync(target));
}
const stat = statSync(target, { throwIfNoEntry: false });
if (stat) accessSync(target, constants.W_OK);
const writeInPlace = () => {
if (tmp) rmSync(tmp, { force: true });
tmp = undefined;
writeFileSync(target, data);
};
if (stat?.nlink > 1) return writeInPlace();
const mode = stat ? stat.mode & 0o7777 : 0o666;
tmp = `${target}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
try {
writeFileSync(tmp, data, { mode, flag: "wx" });
} catch (err) {
if (stat && (err.code === "EACCES" || err.code === "EPERM")) return writeInPlace();
throw err;
}
if (stat) {
try {
chownSync(tmp, stat.uid, stat.gid);
} catch {
return writeInPlace();
}
chmodSync(tmp, mode); // after chown, which can clear setgid; also undoes the umask
}
renameSync(tmp, target);
} catch (err) {
if (tmp) rmSync(tmp, { force: true });
throw new Error(`Could not save ${path}: ${err.message}`);
}
}
export function add(todos, text) {
const id = todos.length + 1;
const id = todos.reduce((max, t) => Math.max(max, t.id), 0) + 1;
if (!isId(id)) throw new Error("No ids left: the highest id is already the largest allowed");
return [...todos, { id, text, done: false }];
}
export function has(todos, id) {
return todos.some((t) => t.id === id);
}
export function complete(todos, id) {
return todos.map((t) => (t.id === id ? { ...t, done: true } : t));
}
+122
View File
@@ -0,0 +1,122 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url));
function run(file, ...args) {
return spawnSync(process.execPath, [CLI, ...args], {
env: { ...process.env, TODO_FILE: file },
encoding: "utf8",
});
}
const tempFile = () => join(mkdtempSync(join(tmpdir(), "todo-")), "todos.json");
test("ls with no file prints the empty message and writes nothing", () => {
const file = tempFile();
const res = run(file, "ls");
assert.equal(res.status, 0);
assert.equal(res.stdout.trim(), "Nothing to do.");
assert.equal(existsSync(file), false);
});
test("no command lists the todos", () => {
const file = tempFile();
run(file, "add", "a");
const res = run(file);
assert.equal(res.status, 0);
assert.equal(res.stdout.trim(), "[ ] 1. a");
});
test("add trims surrounding whitespace from the text", () => {
const file = tempFile();
run(file, "add", " buy", "milk ");
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), [{ id: 1, text: "buy milk", done: false }]);
});
test("add fails readably when no ids are left, without saving", () => {
const file = tempFile();
const before = JSON.stringify([{ id: Number.MAX_SAFE_INTEGER - 1, text: "a", done: false }]);
writeFileSync(file, before);
const res = run(file, "add", "b");
assert.equal(res.status, 1);
assert.match(res.stderr, /^No ids left/);
assert.equal(readFileSync(file, "utf8"), before);
});
test("rm on a file with duplicate ids removes only the first of them", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify([{ id: 2, text: "b", done: false }, { id: 2, text: "c", done: false }]));
const res = run(file, "rm", "2");
assert.equal(res.status, 0, res.stderr);
assert.equal(res.stdout.trim(), "[ ] 3. c");
});
test("add with no text fails without saving", () => {
const file = tempFile();
const res = run(file, "add", " ");
assert.equal(res.status, 1);
assert.match(res.stderr, /Missing todo text/);
assert.equal(existsSync(file), false);
});
for (const [label, args, message] of [
["missing id", [], /Invalid id: \(missing\)/],
["non-numeric id", ["abc"], /Invalid id: abc/],
["hex id", ["0x1"], /Invalid id: 0x1/],
["exponent id", ["1e0"], /Invalid id: 1e0/],
["decimal id", ["1.0"], /Invalid id: 1\.0/],
["padded id", [" 1 "], /Invalid id: 1 /],
["empty id", [""], /Invalid id: \n/],
["unsafe id", ["99999999999999999999"], /Invalid id: 99999999999999999999/],
["leading-zero id", ["01"], /Invalid id: 01/],
["zero id", ["0"], /Invalid id: 0/],
["unknown id", ["99"], /No todo with id 99/],
]) {
for (const cmd of ["done", "rm"]) {
test(`${cmd} with ${label} fails and leaves the file unchanged`, () => {
const file = tempFile();
run(file, "add", "a");
const before = readFileSync(file, "utf8");
const res = run(file, cmd, ...args);
assert.equal(res.status, 1);
assert.match(res.stderr, message);
assert.equal(readFileSync(file, "utf8"), before);
});
}
}
test("unknown command prints usage even when the file is corrupt", () => {
const file = tempFile();
writeFileSync(file, "{ not json");
const res = run(file, "--help");
assert.equal(res.status, 1);
assert.match(res.stderr, /Unknown command: --help\nUsage:/);
});
test("a failed save gives a readable error instead of a stack trace", { skip: process.getuid?.() === 0 && "root ignores file permissions" }, () => {
const file = tempFile();
chmodSync(dirname(file), 0o500);
try {
const res = run(file, "add", "a");
assert.equal(res.status, 1);
assert.match(res.stderr, /^Could not save /);
assert.doesNotMatch(res.stderr, /at .*\.js:\d+/);
} finally {
chmodSync(dirname(file), 0o700);
}
});
test("a corrupt file gives a readable error instead of a stack trace", () => {
const file = tempFile();
writeFileSync(file, "{ not json");
const res = run(file, "ls");
assert.equal(res.status, 1);
assert.match(res.stderr, /^Could not read /);
assert.doesNotMatch(res.stderr, /at .*\.js:\d+/);
});
+145 -1
View File
@@ -1,12 +1,29 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { add, complete, remove, format } from "../src/todo.js";
import { mkdtempSync, writeFileSync, readdirSync, symlinkSync, linkSync, lstatSync, statSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { load, save, add, has, complete, remove, format } from "../src/todo.js";
const tempFile = () => join(mkdtempSync(join(tmpdir(), "todo-")), "todos.json");
const isRoot = process.getuid?.() === 0;
test("add appends an open todo", () => {
const todos = add([], "buy milk");
assert.deepEqual(todos, [{ id: 1, text: "buy milk", done: false }]);
});
test("add after a remove does not reuse an existing id", () => {
const todos = add(remove(add(add([], "a"), "b"), 1), "c");
assert.deepEqual(todos.map((t) => t.id), [2, 3]);
});
test("has reports whether an id exists", () => {
const todos = add([], "a");
assert.equal(has(todos, 1), true);
assert.equal(has(todos, 2), false);
});
test("complete marks only the matching todo", () => {
const todos = complete(add(add([], "a"), "b"), 2);
assert.equal(todos[0].done, false);
@@ -21,3 +38,130 @@ test("remove drops the matching todo", () => {
test("format renders an empty list", () => {
assert.equal(format([]), "Nothing to do.");
});
test("load returns an empty list for a missing file", () => {
assert.deepEqual(load(tempFile()), []);
});
test("add refuses an id that load() would reject", () => {
assert.throws(() => add([{ id: Number.MAX_SAFE_INTEGER - 1, text: "a", done: false }], "b"), /No ids left/);
});
test("add handles a very large list", () => {
const todos = Array.from({ length: 200_000 }, (_, i) => ({ id: i + 1, text: "x", done: false }));
assert.equal(add(todos, "y").at(-1).id, 200_001);
});
test("save then load round-trips and leaves no temp file", () => {
const file = tempFile();
const todos = add([], "a");
save(file, todos);
assert.deepEqual(load(file), todos);
assert.deepEqual(readdirSync(dirname(file)), ["todos.json"]);
});
test("save writes through a symlink instead of replacing it", () => {
const real = tempFile();
const link = join(dirname(tempFile()), "link.json");
save(real, []);
symlinkSync(real, link);
save(link, add([], "a"));
assert.equal(lstatSync(link).isSymbolicLink(), true);
assert.deepEqual(load(real).map((t) => t.text), ["a"]);
});
test("load renumbers duplicate ids left by the old add() bug", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify([
{ id: 2, text: "b", done: false },
{ id: 2, text: "c", done: true },
{ id: 3, text: "d", done: false },
]));
assert.deepEqual(load(file).map((t) => [t.id, t.text, t.done]), [[2, "b", false], [4, "c", true], [3, "d", false]]);
});
test("save writes in place when the file has other hard links", () => {
const file = tempFile();
const other = join(dirname(file), "other.json");
save(file, []);
linkSync(file, other);
save(file, add([], "a"));
assert.deepEqual(load(other).map((t) => t.text), ["a"]);
});
test("save writes in place when only the file, not its directory, is writable", { skip: isRoot && "root ignores file permissions" }, () => {
const file = tempFile();
save(file, []);
chmodSync(dirname(file), 0o500);
try {
save(file, add([], "a"));
assert.deepEqual(load(file).map((t) => t.text), ["a"]);
assert.deepEqual(readdirSync(dirname(file)), ["todos.json"]);
} finally {
chmodSync(dirname(file), 0o700);
}
});
test("save through a dangling symlink creates the target and keeps the link", () => {
const dir = dirname(tempFile());
const link = join(dir, "link.json");
symlinkSync("real.json", link);
save(link, add([], "a"));
assert.equal(lstatSync(link).isSymbolicLink(), true);
assert.deepEqual(load(join(dir, "real.json")).map((t) => t.text), ["a"]);
});
test("save refuses a read-only file", { skip: isRoot && "root ignores file permissions" }, () => {
const file = tempFile();
save(file, []);
chmodSync(file, 0o444);
assert.throws(() => save(file, add([], "a")), /Could not save .*todos\.json/);
assert.deepEqual(load(file), []);
});
test("save keeps the file's permissions", () => {
const file = tempFile();
save(file, []);
chmodSync(file, 0o600);
save(file, add([], "a"));
assert.equal(statSync(file).mode & 0o777, 0o600);
});
test("save reports a readable error and cleans up when it can't write", { skip: isRoot && "root ignores file permissions" }, () => {
const file = tempFile();
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => save(file, []), /Could not save .*todos\.json/);
assert.deepEqual(readdirSync(dirname(file)), []);
} finally {
chmodSync(dirname(file), 0o700);
}
});
test("load rejects invalid JSON with a readable error", () => {
const file = tempFile();
writeFileSync(file, "{ not json");
assert.throws(() => load(file), /Could not read .*todos\.json/);
});
test("load rejects JSON that is not a list", () => {
const file = tempFile();
writeFileSync(file, "{}");
assert.throws(() => load(file), /expected a list of todos/);
});
for (const entry of [
"null",
"1",
'{"text":"a","done":false}',
'{"id":"1","text":"a","done":false}',
'{"id":0,"text":"a","done":false}',
'{"id":-1,"text":"a","done":false}',
`{"id":${Number.MAX_SAFE_INTEGER},"text":"a","done":false}`,
]) {
test(`load rejects an invalid entry: ${entry}`, () => {
const file = tempFile();
writeFileSync(file, `[${entry}]`);
assert.throws(() => load(file), /entry 1 is not a valid todo/);
});
}