@robinthues/rt-claude-coach 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/backup/dump.d.ts +17 -0
  2. package/dist/backup/dump.js +118 -0
  3. package/dist/backup/index.d.ts +11 -0
  4. package/dist/backup/index.js +202 -0
  5. package/dist/backup/lock.d.ts +25 -0
  6. package/dist/backup/lock.js +187 -0
  7. package/dist/backup/repo.d.ts +20 -0
  8. package/dist/backup/repo.js +70 -0
  9. package/dist/backup/restore.d.ts +33 -0
  10. package/dist/backup/restore.js +102 -0
  11. package/dist/calendar/dates.d.ts +24 -0
  12. package/dist/calendar/dates.js +57 -0
  13. package/dist/calendar/index.d.ts +3 -0
  14. package/dist/calendar/index.js +2 -0
  15. package/dist/calendar/parse.d.ts +19 -0
  16. package/dist/calendar/parse.js +74 -0
  17. package/dist/calendar/types.d.ts +70 -0
  18. package/dist/calendar/types.js +1 -0
  19. package/dist/cli.d.ts +9 -0
  20. package/dist/cli.js +272 -5
  21. package/dist/db/client.js +4 -0
  22. package/dist/db/schema.sql +64 -0
  23. package/dist/db/sql.d.ts +4 -0
  24. package/dist/db/sql.js +10 -0
  25. package/dist/lib/config.d.ts +1 -0
  26. package/dist/lib/config.js +23 -17
  27. package/dist/recovery/dates.d.ts +12 -0
  28. package/dist/recovery/dates.js +31 -0
  29. package/dist/recovery/index.d.ts +4 -0
  30. package/dist/recovery/index.js +3 -0
  31. package/dist/recovery/parse.d.ts +21 -0
  32. package/dist/recovery/parse.js +128 -0
  33. package/dist/recovery/store.d.ts +14 -0
  34. package/dist/recovery/store.js +108 -0
  35. package/dist/recovery/types.d.ts +50 -0
  36. package/dist/recovery/types.js +1 -0
  37. package/dist/strava/store.d.ts +2 -1
  38. package/dist/strava/store.js +2 -5
  39. package/dist/viewer/lib/completion.d.ts +5 -0
  40. package/dist/viewer/lib/completion.js +15 -0
  41. package/dist/viewer/stores/plan.d.ts +0 -1
  42. package/dist/viewer/stores/plan.js +6 -7
  43. package/package.json +1 -1
  44. package/templates/plan-viewer.html +20 -20
@@ -0,0 +1,17 @@
1
+ /** Every table carried by the backup. Views are derived and excluded. */
2
+ export declare const DUMP_TABLES: readonly ["activities", "streams", "athlete", "goals", "sync_log", "garmin_daily"];
3
+ /**
4
+ * Full SQL text dump, one INSERT per row, rows ordered by primary key.
5
+ *
6
+ * Determinism is the whole point: git only produces useful diffs if an
7
+ * unchanged database dumps to the identical bytes every time. Column order
8
+ * is sorted by NAME rather than taken from `pragma_table_info`'s physical
9
+ * order, because physical order depends on migration history (a column
10
+ * added later via ALTER TABLE lands at the end, even if two databases hold
11
+ * identical data). Every INSERT names its columns explicitly, so an
12
+ * alphabetical order reloads correctly regardless of the target table's own
13
+ * physical column layout.
14
+ */
15
+ export declare function dumpDatabase(dbPath: string): string;
16
+ /** Rows per table in a dump, used to summarise what changed in a commit message. */
17
+ export declare function countRowsPerTable(sql: string): Record<string, number>;
@@ -0,0 +1,118 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ /** Every table carried by the backup. Views are derived and excluded. */
3
+ export const DUMP_TABLES = [
4
+ "activities",
5
+ "streams",
6
+ "athlete",
7
+ "goals",
8
+ "sync_log",
9
+ "garmin_daily",
10
+ ];
11
+ function quoteLiteral(value) {
12
+ return "'" + value.replace(/'/g, "''") + "'";
13
+ }
14
+ /**
15
+ * Encodes a string containing newlines/carriage returns as a SQLite
16
+ * concatenation expression instead of a literal with raw line breaks, so
17
+ * that every INSERT statement stays on exactly one line in the dump. Each
18
+ * segment is quote-escaped individually and joined back together with
19
+ * char(10)/char(13), preserving which line-break character was which so
20
+ * the value round-trips byte-for-byte.
21
+ */
22
+ function encodeMultiline(value) {
23
+ const segments = [];
24
+ const separators = [];
25
+ let current = "";
26
+ for (const ch of value) {
27
+ if (ch === "\n" || ch === "\r") {
28
+ segments.push(current);
29
+ separators.push(ch === "\n" ? "char(10)" : "char(13)");
30
+ current = "";
31
+ }
32
+ else {
33
+ current += ch;
34
+ }
35
+ }
36
+ segments.push(current);
37
+ let out = quoteLiteral(segments[0]);
38
+ for (let i = 0; i < separators.length; i++) {
39
+ out += "||" + separators[i] + "||" + quoteLiteral(segments[i + 1]);
40
+ }
41
+ return out;
42
+ }
43
+ function escape(value) {
44
+ if (value === null || value === undefined)
45
+ return "NULL";
46
+ if (typeof value === "number")
47
+ return String(value);
48
+ if (typeof value === "bigint")
49
+ return value.toString();
50
+ if (value instanceof Uint8Array) {
51
+ return "X'" + Buffer.from(value).toString("hex") + "'";
52
+ }
53
+ const str = String(value);
54
+ if (str.includes("\n") || str.includes("\r")) {
55
+ return encodeMultiline(str);
56
+ }
57
+ return quoteLiteral(str);
58
+ }
59
+ /**
60
+ * Full SQL text dump, one INSERT per row, rows ordered by primary key.
61
+ *
62
+ * Determinism is the whole point: git only produces useful diffs if an
63
+ * unchanged database dumps to the identical bytes every time. Column order
64
+ * is sorted by NAME rather than taken from `pragma_table_info`'s physical
65
+ * order, because physical order depends on migration history (a column
66
+ * added later via ALTER TABLE lands at the end, even if two databases hold
67
+ * identical data). Every INSERT names its columns explicitly, so an
68
+ * alphabetical order reloads correctly regardless of the target table's own
69
+ * physical column layout.
70
+ */
71
+ export function dumpDatabase(dbPath) {
72
+ // Read-only: the dump must never create, migrate or touch the live file.
73
+ // One read transaction: every table comes from the same snapshot, even if
74
+ // a sync writes to the database while the dump runs.
75
+ const db = new DatabaseSync(dbPath, { readOnly: true });
76
+ try {
77
+ // Wait for a concurrent writer instead of failing the dump immediately
78
+ // with "database is locked".
79
+ db.exec("PRAGMA busy_timeout = 5000");
80
+ db.exec("BEGIN");
81
+ const out = [];
82
+ for (const table of DUMP_TABLES) {
83
+ out.push(`-- table: ${table}`);
84
+ const info = db.prepare(`SELECT name, pk FROM pragma_table_info('${table}')`).all();
85
+ if (info.length === 0)
86
+ continue;
87
+ const cols = info.map((c) => c.name).sort();
88
+ const pk = info
89
+ .filter((c) => c.pk > 0)
90
+ .sort((a, b) => a.pk - b.pk)
91
+ .map((c) => c.name);
92
+ const orderBy = (pk.length > 0 ? pk : cols).join(",");
93
+ const rows = db
94
+ .prepare(`SELECT ${cols.join(",")} FROM ${table} ORDER BY ${orderBy}`)
95
+ .all();
96
+ for (const row of rows) {
97
+ out.push(`INSERT INTO ${table} (${cols.join(",")}) VALUES (${cols.map((c) => escape(row[c])).join(",")});`);
98
+ }
99
+ }
100
+ db.exec("COMMIT");
101
+ return out.join("\n") + "\n";
102
+ }
103
+ finally {
104
+ db.close();
105
+ }
106
+ }
107
+ /** Rows per table in a dump, used to summarise what changed in a commit message. */
108
+ export function countRowsPerTable(sql) {
109
+ const counts = {};
110
+ for (const table of DUMP_TABLES)
111
+ counts[table] = 0;
112
+ for (const line of sql.split("\n")) {
113
+ const match = /^INSERT INTO (\w+) /.exec(line);
114
+ if (match && match[1] in counts)
115
+ counts[match[1]]++;
116
+ }
117
+ return counts;
118
+ }
@@ -0,0 +1,11 @@
1
+ /** Summarise a backup as "+3 activities, -1 goals, profile.md". */
2
+ export declare function buildCommitMessage(changed: string[], before: Record<string, number>, after: Record<string, number>): string;
3
+ export declare function runBackup(options: {
4
+ push: boolean;
5
+ quiet: boolean;
6
+ }): Promise<number>;
7
+ export declare const RESTORE_DOC = "# Restoring the coach data\n\nThis repo is the backup of `~/.claude-coach`. It deliberately does NOT contain\nyour Strava credentials, and it does not contain `coach.db` itself: the database\ntravels as `backup/coach-db.sql`, a text dump that git can diff.\n\n## Restore\n\n```bash\ngit clone git@github.com:RobinThues/claude-coach-data ~/.claude-coach\n\n# 1. Rebuild coach.db from the committed dump\nnpx @robinthues/rt-claude-coach restore-db\n\n# 2. Re-enter the Strava credentials from https://strava.com/settings/api,\n# then follow the printed URL and exchange the redirect for tokens\nnpx @robinthues/rt-claude-coach auth --client-id=ID --client-secret=SECRET\nnpx @robinthues/rt-claude-coach auth --code=\"FULL_REDIRECT_URL\"\n\n# 3. Rebuild the derived HTML for each plan\nnpx @robinthues/rt-claude-coach render ~/.claude-coach/plans/<slug>/plan.json\n```\n\n## Reinstall the backup hook\n\nThe hook script lives at `~/.claude-helpers/claude-coach-backup.sh` and is NOT\nsynced by env-and-agent-setup, so a fresh machine has to get it back from the\nrt-claude-coach repo (`scripts/claude-coach-backup.sh`), plus the `Stop` and\n`SessionEnd` entries in `~/.claude/settings.json`.\n\n## What is not here\n\n- `tokens.json`, `config.json` \u2014 secrets, restored by step 2 above\n- `coach.db` \u2014 restored by step 1\n- `plans/*/plan.html` \u2014 restored by step 3\n";
8
+ export declare function runInit(options: {
9
+ repo: string;
10
+ quiet: boolean;
11
+ }): Promise<number>;
@@ -0,0 +1,202 @@
1
+ import { existsSync, mkdirSync, readFileSync, utimesSync, writeFileSync } from "fs";
2
+ import { basename, join } from "path";
3
+ import { getDataDir, getDbPath } from "../lib/config.js";
4
+ import { log } from "../lib/logging.js";
5
+ import { countRowsPerTable, dumpDatabase } from "./dump.js";
6
+ import { acquireLock, releaseLock } from "./lock.js";
7
+ import { git, hasRemote, hasUnpushedCommits, initRepo, isRepo, stagedPaths } from "./repo.js";
8
+ const MAX_LISTED_FILES = 3;
9
+ /** Files that must never be committed, whatever the gitignore says. */
10
+ const SECRET_FILES = ["tokens.json", "config.json"];
11
+ /** Summarise a backup as "+3 activities, -1 goals, profile.md". */
12
+ export function buildCommitMessage(changed, before, after) {
13
+ const parts = [];
14
+ const tables = new Set([...Object.keys(before), ...Object.keys(after)]);
15
+ for (const table of [...tables].sort()) {
16
+ const delta = (after[table] ?? 0) - (before[table] ?? 0);
17
+ if (delta > 0)
18
+ parts.push(`+${delta} ${table}`);
19
+ if (delta < 0)
20
+ parts.push(`${delta} ${table}`);
21
+ }
22
+ const files = changed
23
+ .filter((path) => path !== "backup/coach-db.sql")
24
+ .map((path) => path.split("/").pop());
25
+ parts.push(...files.slice(0, MAX_LISTED_FILES));
26
+ if (files.length > MAX_LISTED_FILES) {
27
+ parts.push(`and ${files.length - MAX_LISTED_FILES} more`);
28
+ }
29
+ if (parts.length === 0)
30
+ return "backup: no visible changes";
31
+ const last = parts.pop();
32
+ return last.startsWith("and ")
33
+ ? `backup: ${parts.join(", ")} ${last}`
34
+ : `backup: ${[...parts, last].join(", ")}`;
35
+ }
36
+ /** The hook compares file mtimes against the marker's, so set it explicitly. */
37
+ function writeMarker(path, time) {
38
+ writeFileSync(path, time.toISOString());
39
+ utimesSync(path, time, time);
40
+ }
41
+ /**
42
+ * Push the current branch to origin and record it as the upstream, so a
43
+ * fresh repo with default git config (no push.autoSetupRemote) works on
44
+ * the first run.
45
+ *
46
+ * Not fatal on purpose: the commit is already safe locally and a later run
47
+ * pushes it (see the no-change path in runBackup). A rejected push means the
48
+ * remote moved on, which needs a human, not an automatic merge of training
49
+ * data.
50
+ */
51
+ function pushToRemote(dir) {
52
+ const push = git(dir, ["push", "--quiet", "-u", "origin", "HEAD"]);
53
+ if (push.code !== 0) {
54
+ log.warn(`Push failed, commit kept locally: ${push.stderr.trim()}`);
55
+ }
56
+ }
57
+ export async function runBackup(options) {
58
+ const dir = getDataDir();
59
+ const backupDir = join(dir, "backup");
60
+ const dumpPath = join(backupDir, "coach-db.sql");
61
+ const lockPath = join(backupDir, ".lock");
62
+ const markerPath = join(backupDir, ".last-backup");
63
+ // --quiet hides progress, never errors: the hook's log is the only place
64
+ // a detached run can report why it did nothing.
65
+ if (!existsSync(dir)) {
66
+ log.error(`No coach data directory at ${dir}`);
67
+ return 1;
68
+ }
69
+ if (!isRepo(dir)) {
70
+ log.error(`${dir} is not a git repo. Run 'backup --init' first.`);
71
+ return 1;
72
+ }
73
+ mkdirSync(backupDir, { recursive: true });
74
+ if (!acquireLock(lockPath)) {
75
+ if (!options.quiet)
76
+ log.info("Another backup is running; skipping.");
77
+ return 0;
78
+ }
79
+ try {
80
+ const before = existsSync(dumpPath) ? countRowsPerTable(readFileSync(dumpPath, "utf-8")) : {};
81
+ const dbPath = getDbPath();
82
+ // The marker's time is taken right after the dump returns (so the dump
83
+ // has closed the DB and the dump itself never makes coach.db look newer
84
+ // than the marker), not after the writing-to-disk and row-counting
85
+ // steps that follow it — the dump is read-only, so this is safe, and it
86
+ // keeps the marker from drifting later than it needs to be while still
87
+ // landing before git add (so a file the coach writes while this run
88
+ // commits and pushes is newer than the marker and triggers the next
89
+ // run).
90
+ let markerTime = new Date();
91
+ if (existsSync(dbPath)) {
92
+ const sql = dumpDatabase(dbPath);
93
+ markerTime = new Date();
94
+ writeFileSync(dumpPath, sql);
95
+ }
96
+ const after = existsSync(dumpPath) ? countRowsPerTable(readFileSync(dumpPath, "utf-8")) : {};
97
+ const add = git(dir, ["add", "-A"]);
98
+ if (add.code !== 0) {
99
+ log.error(`git add failed, nothing backed up: ${add.stderr.trim()}`);
100
+ return 1;
101
+ }
102
+ const changed = stagedPaths(dir);
103
+ // Second line of defence behind the gitignore: if a secret got staged
104
+ // anyway (an edited or lost .gitignore), unstage everything and stop.
105
+ const secrets = changed.filter((path) => SECRET_FILES.includes(basename(path)));
106
+ if (secrets.length > 0) {
107
+ const reset = git(dir, ["reset", "-q"]);
108
+ log.error(`Refusing to commit secrets: ${secrets.join(", ")} would be staged. ` +
109
+ `Check ${join(dir, ".gitignore")} ignores ${SECRET_FILES.join(" and ")}.`);
110
+ if (reset.code !== 0) {
111
+ log.error(`Could not unstage them: ${reset.stderr.trim()}. The secret may still be staged; run 'git -C ${dir} reset' yourself.`);
112
+ }
113
+ return 1;
114
+ }
115
+ if (changed.length === 0) {
116
+ // A commit whose push failed earlier still has to reach the remote,
117
+ // even when this run has nothing new to commit.
118
+ if (options.push && hasRemote(dir) && hasUnpushedCommits(dir))
119
+ pushToRemote(dir);
120
+ writeMarker(markerPath, markerTime);
121
+ if (!options.quiet)
122
+ log.info("Nothing changed; no commit.");
123
+ return 0;
124
+ }
125
+ const message = buildCommitMessage(changed, before, after);
126
+ const commit = git(dir, ["-c", "commit.gpgsign=false", "commit", "-m", message]);
127
+ if (commit.code !== 0) {
128
+ log.error(`Commit failed: ${commit.stderr.trim()}`);
129
+ return 1;
130
+ }
131
+ if (!options.quiet)
132
+ log.success(message);
133
+ if (options.push && hasRemote(dir))
134
+ pushToRemote(dir);
135
+ writeMarker(markerPath, markerTime);
136
+ return 0;
137
+ }
138
+ finally {
139
+ releaseLock(lockPath);
140
+ }
141
+ }
142
+ export const RESTORE_DOC = `# Restoring the coach data
143
+
144
+ This repo is the backup of \`~/.claude-coach\`. It deliberately does NOT contain
145
+ your Strava credentials, and it does not contain \`coach.db\` itself: the database
146
+ travels as \`backup/coach-db.sql\`, a text dump that git can diff.
147
+
148
+ ## Restore
149
+
150
+ \`\`\`bash
151
+ git clone git@github.com:RobinThues/claude-coach-data ~/.claude-coach
152
+
153
+ # 1. Rebuild coach.db from the committed dump
154
+ npx @robinthues/rt-claude-coach restore-db
155
+
156
+ # 2. Re-enter the Strava credentials from https://strava.com/settings/api,
157
+ # then follow the printed URL and exchange the redirect for tokens
158
+ npx @robinthues/rt-claude-coach auth --client-id=ID --client-secret=SECRET
159
+ npx @robinthues/rt-claude-coach auth --code="FULL_REDIRECT_URL"
160
+
161
+ # 3. Rebuild the derived HTML for each plan
162
+ npx @robinthues/rt-claude-coach render ~/.claude-coach/plans/<slug>/plan.json
163
+ \`\`\`
164
+
165
+ ## Reinstall the backup hook
166
+
167
+ The hook script lives at \`~/.claude-helpers/claude-coach-backup.sh\` and is NOT
168
+ synced by env-and-agent-setup, so a fresh machine has to get it back from the
169
+ rt-claude-coach repo (\`scripts/claude-coach-backup.sh\`), plus the \`Stop\` and
170
+ \`SessionEnd\` entries in \`~/.claude/settings.json\`.
171
+
172
+ ## What is not here
173
+
174
+ - \`tokens.json\`, \`config.json\` — secrets, restored by step 2 above
175
+ - \`coach.db\` — restored by step 1
176
+ - \`plans/*/plan.html\` — restored by step 3
177
+ `;
178
+ export async function runInit(options) {
179
+ const dir = getDataDir();
180
+ if (!existsSync(dir)) {
181
+ log.error(`No coach data directory at ${dir}. Nothing to back up.`);
182
+ return 1;
183
+ }
184
+ if (isRepo(dir)) {
185
+ log.error(`${dir} is already a git repo. Refusing to reinitialise it.`);
186
+ return 1;
187
+ }
188
+ initRepo(dir);
189
+ writeFileSync(join(dir, "RESTORE.md"), RESTORE_DOC);
190
+ if (options.repo) {
191
+ const create = git(dir, ["remote", "add", "origin", `git@github.com:${options.repo}.git`]);
192
+ if (create.code !== 0) {
193
+ log.warn(`Could not add the remote: ${create.stderr.trim()}`);
194
+ }
195
+ if (!options.quiet) {
196
+ log.info(`Create the private repo with:\n gh repo create ${options.repo} --private\nthen run 'backup' to push the first commit.`);
197
+ }
198
+ }
199
+ if (!options.quiet)
200
+ log.success(`Initialised the backup repo in ${dir}`);
201
+ return 0;
202
+ }
@@ -0,0 +1,25 @@
1
+ /** A lock older than this is assumed to belong to a crashed run. */
2
+ export declare const STALE_LOCK_MS: number;
3
+ /**
4
+ * A break marker older than this is assumed abandoned — the process that
5
+ * created it died inside `breakStaleLock` (SIGKILL, power loss, a WSL
6
+ * shutdown) before it could remove the marker in its `finally`. Breaking a
7
+ * lock takes milliseconds, so a minute is already enormously generous and
8
+ * unambiguously means "abandoned," not "still working."
9
+ *
10
+ * Without this, an abandoned marker gates the only path that can ever
11
+ * break the stale lock it belongs to, so it would disable backups for
12
+ * that lockPath forever, silently.
13
+ */
14
+ export declare const BREAK_MARKER_STALE_MS: number;
15
+ /**
16
+ * Exclusive-create lock. Returns false when another run holds it, so the
17
+ * caller can exit quietly rather than racing on the git index.
18
+ */
19
+ export declare function acquireLock(lockPath: string, now?: number): boolean;
20
+ /**
21
+ * Releases a lock this process holds. No-op if the lock is already gone,
22
+ * or if it was broken as stale and re-acquired by someone else: only the
23
+ * process whose token is still on disk may remove it.
24
+ */
25
+ export declare function releaseLock(lockPath: string): void;
@@ -0,0 +1,187 @@
1
+ import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from "fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { log } from "../lib/logging.js";
4
+ /** A lock older than this is assumed to belong to a crashed run. */
5
+ export const STALE_LOCK_MS = 10 * 60 * 1000;
6
+ /**
7
+ * A break marker older than this is assumed abandoned — the process that
8
+ * created it died inside `breakStaleLock` (SIGKILL, power loss, a WSL
9
+ * shutdown) before it could remove the marker in its `finally`. Breaking a
10
+ * lock takes milliseconds, so a minute is already enormously generous and
11
+ * unambiguously means "abandoned," not "still working."
12
+ *
13
+ * Without this, an abandoned marker gates the only path that can ever
14
+ * break the stale lock it belongs to, so it would disable backups for
15
+ * that lockPath forever, silently.
16
+ */
17
+ export const BREAK_MARKER_STALE_MS = 60 * 1000;
18
+ /** Do not retry acquisition forever when the lock file keeps vanishing under us. */
19
+ const MAX_ATTEMPTS = 3;
20
+ /**
21
+ * Tokens for locks this process currently holds, keyed by lock path.
22
+ * Lets `releaseLock` refuse to delete a lock that was broken as stale and
23
+ * re-acquired by someone else while we were still (wrongly) holding it.
24
+ */
25
+ const heldTokens = new Map();
26
+ function writeToken(path, token) {
27
+ const fd = openSync(path, "wx");
28
+ try {
29
+ writeSync(fd, token);
30
+ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ }
35
+ function errorCode(error) {
36
+ return error.code;
37
+ }
38
+ /**
39
+ * Exclusive-create lock. Returns false when another run holds it, so the
40
+ * caller can exit quietly rather than racing on the git index.
41
+ */
42
+ export function acquireLock(lockPath, now = Date.now()) {
43
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
44
+ const token = `${process.pid}:${randomUUID()}`;
45
+ try {
46
+ writeToken(lockPath, token);
47
+ heldTokens.set(lockPath, token);
48
+ return true;
49
+ }
50
+ catch (error) {
51
+ if (errorCode(error) !== "EEXIST")
52
+ throw error;
53
+ }
54
+ let age;
55
+ try {
56
+ age = now - statSync(lockPath).mtimeMs;
57
+ }
58
+ catch (error) {
59
+ if (errorCode(error) !== "ENOENT")
60
+ throw error;
61
+ // Vanished between the failed create and the stat: retry, bounded.
62
+ continue;
63
+ }
64
+ if (age <= STALE_LOCK_MS)
65
+ return false;
66
+ const breakResult = tryBreakStaleLock(lockPath, now);
67
+ if (breakResult === "lost")
68
+ return false;
69
+ if (breakResult === "retry")
70
+ continue; // abandoned marker cleared; restart from the top
71
+ const winToken = `${process.pid}:${randomUUID()}`;
72
+ try {
73
+ writeToken(lockPath, winToken);
74
+ heldTokens.set(lockPath, winToken);
75
+ return true;
76
+ }
77
+ catch (error) {
78
+ if (errorCode(error) !== "EEXIST")
79
+ throw error;
80
+ // A fresh process claimed the path in between. Correct behaviour.
81
+ return false;
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+ /**
87
+ * Removes the stale file currently at lockPath, but only after gaining
88
+ * sole authorization to do so and re-confirming it is still stale.
89
+ *
90
+ * A plain rename-based compare-and-swap (rename lockPath aside, ENOENT
91
+ * means you lost) is not enough on its own: it only proves *something*
92
+ * still sits at lockPath, not that it is the *same* stale file a racing
93
+ * process inspected earlier. Two processes can both see the old stale
94
+ * file, and then interleave so that by the time the second one acts, the
95
+ * first has already removed it and installed a brand-new, valid lock —
96
+ * the second then renames that fresh lock away and steals the slot,
97
+ * leaving the first process believing it still holds a lock that no
98
+ * longer exists on disk. This showed up as two winners under a real
99
+ * concurrent-process regression test.
100
+ *
101
+ * The fix is a dedicated marker file (`${lockPath}.break`) as the CAS
102
+ * gate, created exclusively so only one process at a time can be in this
103
+ * function for a given lockPath, combined with re-checking staleness
104
+ * with a fresh stat *after* winning that gate and *before* deleting
105
+ * anything. That closes the reordering gap: a process that wins the
106
+ * marker only after someone else already replaced the stale file with a
107
+ * fresh one will see the fresh mtime and back off instead of deleting it.
108
+ *
109
+ * The marker itself can also go stale — see BREAK_MARKER_STALE_MS — if
110
+ * whoever created it was killed before its `finally` could remove it.
111
+ * An abandoned marker is force-cleared and logged so that case self-heals
112
+ * instead of disabling backups on this lockPath forever. This reopens a
113
+ * narrow residual race (two processes could both force-clear the same
114
+ * abandoned marker and both proceed), accepted deliberately: it needs a
115
+ * hard kill in a microsecond window plus a second process racing the
116
+ * abandoned marker plus a specific ordering, and its worst case (two
117
+ * concurrent backups collide on git's own index.lock, one commit fails
118
+ * and logs an error) is far cheaper than a lock that never recovers.
119
+ */
120
+ function tryBreakStaleLock(lockPath, now) {
121
+ const breakMarker = `${lockPath}.break`;
122
+ try {
123
+ const fd = openSync(breakMarker, "wx");
124
+ closeSync(fd);
125
+ }
126
+ catch (error) {
127
+ if (errorCode(error) !== "EEXIST")
128
+ throw error;
129
+ let markerAge;
130
+ try {
131
+ markerAge = now - statSync(breakMarker).mtimeMs;
132
+ }
133
+ catch (error) {
134
+ if (errorCode(error) !== "ENOENT")
135
+ throw error;
136
+ // Vanished between our failed create and this stat (its holder
137
+ // finished): restart the acquisition from the top.
138
+ return "retry";
139
+ }
140
+ if (markerAge <= BREAK_MARKER_STALE_MS) {
141
+ // Someone else is genuinely mid-break. Let them.
142
+ return "lost";
143
+ }
144
+ rmSync(breakMarker, { force: true });
145
+ log.warn(`backup lock: break marker ${breakMarker} was abandoned (older than ${BREAK_MARKER_STALE_MS}ms) and has been cleared`);
146
+ return "retry";
147
+ }
148
+ try {
149
+ let age;
150
+ try {
151
+ age = now - statSync(lockPath).mtimeMs;
152
+ }
153
+ catch (error) {
154
+ if (errorCode(error) !== "ENOENT")
155
+ throw error;
156
+ // Already gone (another process finished breaking and/or
157
+ // re-acquired it): nothing left for us to do here.
158
+ return "lost";
159
+ }
160
+ if (age <= STALE_LOCK_MS)
161
+ return "lost"; // no longer stale: leave it
162
+ rmSync(lockPath, { force: true });
163
+ return "broke";
164
+ }
165
+ finally {
166
+ rmSync(breakMarker, { force: true });
167
+ }
168
+ }
169
+ /**
170
+ * Releases a lock this process holds. No-op if the lock is already gone,
171
+ * or if it was broken as stale and re-acquired by someone else: only the
172
+ * process whose token is still on disk may remove it.
173
+ */
174
+ export function releaseLock(lockPath) {
175
+ const token = heldTokens.get(lockPath);
176
+ heldTokens.delete(lockPath);
177
+ let current;
178
+ try {
179
+ current = readFileSync(lockPath, "utf8");
180
+ }
181
+ catch {
182
+ return;
183
+ }
184
+ if (token !== undefined && current === token) {
185
+ rmSync(lockPath, { force: true });
186
+ }
187
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Everything excluded is either a secret or reproducible:
3
+ * the database is carried as backup/coach-db.sql, plan.html comes from
4
+ * `render`, and the marker, log, lock, and break marker are local scratch.
5
+ */
6
+ export declare const GITIGNORE = "coach.db\ncoach.db-journal\ncoach.db-wal\ncoach.db-shm\ntokens.json\nconfig.json\nplans/*/plan.html\nbackup/.last-backup\nbackup/backup.log\nbackup/.lock\nbackup/.lock.break\n*.bak*\n";
7
+ export declare function git(dir: string, args: string[]): {
8
+ code: number;
9
+ stdout: string;
10
+ stderr: string;
11
+ };
12
+ export declare function isRepo(dir: string): boolean;
13
+ export declare function initRepo(dir: string): void;
14
+ export declare function stagedPaths(dir: string): string[];
15
+ export declare function hasRemote(dir: string, name?: string): boolean;
16
+ /**
17
+ * True when HEAD has commits the remote has not seen. With no upstream yet
18
+ * (the first push never succeeded) any commit counts as unpushed.
19
+ */
20
+ export declare function hasUnpushedCommits(dir: string): boolean;
@@ -0,0 +1,70 @@
1
+ import { spawnSync } from "child_process";
2
+ import { existsSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ /**
5
+ * Everything excluded is either a secret or reproducible:
6
+ * the database is carried as backup/coach-db.sql, plan.html comes from
7
+ * `render`, and the marker, log, lock, and break marker are local scratch.
8
+ */
9
+ export const GITIGNORE = `coach.db
10
+ coach.db-journal
11
+ coach.db-wal
12
+ coach.db-shm
13
+ tokens.json
14
+ config.json
15
+ plans/*/plan.html
16
+ backup/.last-backup
17
+ backup/backup.log
18
+ backup/.lock
19
+ backup/.lock.break
20
+ *.bak*
21
+ `;
22
+ /**
23
+ * The backup runs detached while holding its lock, so git must never wait
24
+ * for input: no terminal credential prompt, no ssh passphrase or host key
25
+ * question. A call that would need one fails instead, and the next run
26
+ * tries again.
27
+ */
28
+ const NON_INTERACTIVE_ENV = {
29
+ GIT_TERMINAL_PROMPT: "0",
30
+ GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
31
+ };
32
+ export function git(dir, args) {
33
+ const result = spawnSync("git", ["-C", dir, ...args], {
34
+ encoding: "utf-8",
35
+ env: { ...process.env, ...NON_INTERACTIVE_ENV },
36
+ });
37
+ return {
38
+ code: result.status ?? 1,
39
+ stdout: result.stdout ?? "",
40
+ stderr: result.stderr ?? "",
41
+ };
42
+ }
43
+ export function isRepo(dir) {
44
+ return existsSync(join(dir, ".git"));
45
+ }
46
+ export function initRepo(dir) {
47
+ git(dir, ["init", "--quiet"]);
48
+ writeFileSync(join(dir, ".gitignore"), GITIGNORE);
49
+ }
50
+ export function stagedPaths(dir) {
51
+ return git(dir, ["diff", "--cached", "--name-only"])
52
+ .stdout.split("\n")
53
+ .map((line) => line.trim())
54
+ .filter((line) => line.length > 0);
55
+ }
56
+ export function hasRemote(dir, name = "origin") {
57
+ return git(dir, ["remote", "get-url", name]).code === 0;
58
+ }
59
+ /**
60
+ * True when HEAD has commits the remote has not seen. With no upstream yet
61
+ * (the first push never succeeded) any commit counts as unpushed.
62
+ */
63
+ export function hasUnpushedCommits(dir) {
64
+ if (git(dir, ["rev-parse", "--verify", "--quiet", "HEAD"]).code !== 0)
65
+ return false;
66
+ const ahead = git(dir, ["rev-list", "--count", "@{upstream}..HEAD"]);
67
+ if (ahead.code !== 0)
68
+ return true;
69
+ return Number(ahead.stdout.trim()) > 0;
70
+ }