@robinthues/rt-claude-coach 0.2.0 → 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.
- package/dist/backup/dump.d.ts +17 -0
- package/dist/backup/dump.js +118 -0
- package/dist/backup/index.d.ts +11 -0
- package/dist/backup/index.js +202 -0
- package/dist/backup/lock.d.ts +25 -0
- package/dist/backup/lock.js +187 -0
- package/dist/backup/repo.d.ts +20 -0
- package/dist/backup/repo.js +70 -0
- package/dist/backup/restore.d.ts +33 -0
- package/dist/backup/restore.js +102 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +84 -5
- package/dist/db/client.js +4 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +23 -17
- package/dist/viewer/lib/completion.d.ts +5 -0
- package/dist/viewer/lib/completion.js +15 -0
- package/dist/viewer/stores/plan.d.ts +0 -1
- package/dist/viewer/stores/plan.js +6 -7
- package/package.json +1 -1
- 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
|
+
}
|