claude-nomad 0.25.0 → 0.25.2
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/CHANGELOG.md +19 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +158 -0
- package/src/commands.doctor.check-shared.ts +58 -189
- package/src/commands.doctor.checks.pathmap.ts +101 -0
- package/src/commands.doctor.checks.repo.ts +133 -0
- package/src/commands.doctor.checks.repository.ts +105 -0
- package/src/commands.doctor.checks.settings.ts +88 -0
- package/src/commands.doctor.format.ts +18 -0
- package/src/commands.doctor.ts +10 -7
- package/src/commands.drop-session.git.ts +81 -0
- package/src/commands.drop-session.ts +79 -138
- package/src/commands.pull.ts +3 -2
- package/src/commands.push.allowlist.ts +119 -0
- package/src/commands.push.ts +6 -121
- package/src/commands.update.git.ts +90 -0
- package/src/commands.update.resolve.ts +138 -0
- package/src/commands.update.test-helpers.git.ts +107 -0
- package/src/commands.update.ts +4 -221
- package/src/diff.ts +2 -1
- package/src/extras-sync.diff.ts +40 -0
- package/src/extras-sync.guards.ts +52 -0
- package/src/extras-sync.ts +146 -236
- package/src/init.classify.ts +1 -1
- package/src/init.snapshot.ts +3 -1
- package/src/init.ts +2 -1
- package/src/links.ts +3 -10
- package/src/nomad.dispatch.ts +25 -0
- package/src/nomad.help.ts +43 -0
- package/src/nomad.ts +6 -68
- package/src/preview.ts +2 -1
- package/src/push-gitleaks.scan.ts +115 -0
- package/src/push-gitleaks.ts +50 -106
- package/src/remap.ts +3 -1
- package/src/resume.ts +2 -1
- package/src/update.fork-extras.ts +2 -1
- package/src/utils.fs.ts +152 -0
- package/src/utils.json.ts +55 -0
- package/src/utils.lockfile.ts +168 -0
- package/src/utils.ts +0 -327
- package/src/commands.doctor.checks.ts +0 -350
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { HOME } from './config.ts';
|
|
5
|
+
import { warn } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
const LOCK_PATH = join(HOME, '.cache', 'claude-nomad', 'nomad.lock');
|
|
8
|
+
|
|
9
|
+
/** Opaque handle for an acquired lockfile. Pass to `releaseLock` in a `finally`. */
|
|
10
|
+
export type LockHandle = { fd: number };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Acquire the exclusive nomad lockfile so two pulls/pushes cannot mutate
|
|
14
|
+
* `~/.claude/` concurrently. Returns the handle on success, or `null` on
|
|
15
|
+
* contention (caller should `process.exit(0)`; skip-on-contention is the
|
|
16
|
+
* intended UX for backgrounded shell-rc invocations). Detects stale locks by
|
|
17
|
+
* probing the recorded pid with `kill(pid, 0)` and recovers via
|
|
18
|
+
* `unlinkIfSamePid` + `retryOnce`. `verb` is `'pull'` or `'push'`; surfaces
|
|
19
|
+
* in the contention-skip message.
|
|
20
|
+
*/
|
|
21
|
+
export function acquireLock(verb: string): LockHandle | null {
|
|
22
|
+
mkdirSync(dirname(LOCK_PATH), { recursive: true });
|
|
23
|
+
try {
|
|
24
|
+
const fd = openSync(LOCK_PATH, 'wx');
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(fd, String(process.pid));
|
|
27
|
+
} catch (writeErr) {
|
|
28
|
+
// PID-write failed after the lock file was created. Best-effort cleanup:
|
|
29
|
+
// close the fd (ignore errors), then unlink the orphaned lock file
|
|
30
|
+
// (ignore ANY unlink failure). The original write error is rethrown
|
|
31
|
+
// unconditionally, so a cleanup failure can never mask it and non-EEXIST
|
|
32
|
+
// throw semantics are preserved.
|
|
33
|
+
try {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
} catch {
|
|
36
|
+
/* already closed; ignore */
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(LOCK_PATH);
|
|
40
|
+
} catch {
|
|
41
|
+
/* best-effort cleanup; the original write failure takes precedence */
|
|
42
|
+
}
|
|
43
|
+
throw writeErr;
|
|
44
|
+
}
|
|
45
|
+
return { fd };
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
48
|
+
if (code !== 'EEXIST') throw err;
|
|
49
|
+
return checkStaleAndRetry(verb);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Release a previously-acquired lock handle. No-op when `handle` is null
|
|
55
|
+
* (matches `acquireLock`'s contention return). Tolerates the lockfile having
|
|
56
|
+
* already been unlinked. MUST be called from a `finally` so it runs even when
|
|
57
|
+
* the wrapped command throws.
|
|
58
|
+
*/
|
|
59
|
+
export function releaseLock(handle: LockHandle | null): void {
|
|
60
|
+
if (handle === null) return;
|
|
61
|
+
try {
|
|
62
|
+
closeSync(handle.fd);
|
|
63
|
+
} catch {
|
|
64
|
+
/* already closed; ignore */
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
unlinkSync(LOCK_PATH);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compare-and-delete helper that closes most of the TOCTOU window between
|
|
75
|
+
* reading a stale lock's pid and removing it: another process could
|
|
76
|
+
* legitimately acquire the lock between those steps, and a naive unlink
|
|
77
|
+
* would clobber it. Re-reads the file and only unlinks if the contents
|
|
78
|
+
* still equal `expectedPidStr`. Returns `true` if the lock was unlinked,
|
|
79
|
+
* `false` if the content drifted or the file already vanished. A microsecond
|
|
80
|
+
* window between the re-read and the unlink remains; the residual race is
|
|
81
|
+
* documented as a backlog item rather than fully closed here.
|
|
82
|
+
*/
|
|
83
|
+
function unlinkIfSamePid(expectedPidStr: string): boolean {
|
|
84
|
+
let current: string;
|
|
85
|
+
try {
|
|
86
|
+
current = readFileSync(LOCK_PATH, 'utf8').trim();
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
/* c8 ignore next -- TOCTOU drift between the two reads is a documented residual race, hard to exercise deterministically */
|
|
91
|
+
if (current !== expectedPidStr) return false;
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(LOCK_PATH);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* EEXIST recovery path for `acquireLock`. Reads the lockfile pid, probes
|
|
102
|
+
* liveness with `kill(pid, 0)`, and tries one retry only when the pid is
|
|
103
|
+
* dead AND the compare-and-delete in `unlinkIfSamePid` confirms the file
|
|
104
|
+
* has not been replaced under us. Returns `null` (contention skip) in any
|
|
105
|
+
* other case.
|
|
106
|
+
*/
|
|
107
|
+
function checkStaleAndRetry(verb: string): LockHandle | null {
|
|
108
|
+
let pidStr: string;
|
|
109
|
+
try {
|
|
110
|
+
pidStr = readFileSync(LOCK_PATH, 'utf8').trim();
|
|
111
|
+
} catch {
|
|
112
|
+
pidStr = '';
|
|
113
|
+
}
|
|
114
|
+
const pid = Number.parseInt(pidStr, 10);
|
|
115
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
116
|
+
if (unlinkIfSamePid(pidStr)) return retryOnce(verb);
|
|
117
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
process.kill(pid, 0);
|
|
122
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
123
|
+
return null;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
126
|
+
if (code === 'ESRCH') {
|
|
127
|
+
if (unlinkIfSamePid(pidStr)) return retryOnce(verb);
|
|
128
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Single retry of `openSync(..., 'wx')` after `unlinkIfSamePid` cleared a
|
|
138
|
+
* confirmed-stale lock. Bounded to one attempt to avoid spin loops if the
|
|
139
|
+
* lock is being rapidly recreated by another live process.
|
|
140
|
+
*/
|
|
141
|
+
function retryOnce(verb: string): LockHandle | null {
|
|
142
|
+
try {
|
|
143
|
+
const fd = openSync(LOCK_PATH, 'wx');
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(fd, String(process.pid));
|
|
146
|
+
} catch {
|
|
147
|
+
// Twin of the acquireLock write-failure guard. Best-effort cleanup: close
|
|
148
|
+
// the fd (ignore errors) and unlink the orphaned lock file (ignore ANY
|
|
149
|
+
// unlink failure). retryOnce's null-on-failure contract is preserved.
|
|
150
|
+
try {
|
|
151
|
+
closeSync(fd);
|
|
152
|
+
} catch {
|
|
153
|
+
/* already closed; ignore */
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(LOCK_PATH);
|
|
157
|
+
} catch {
|
|
158
|
+
/* best-effort cleanup; the null return below is the contract */
|
|
159
|
+
}
|
|
160
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return { fd };
|
|
164
|
+
} catch {
|
|
165
|
+
warn(`another nomad ${verb} running, skipping`);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -1,28 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
closeSync,
|
|
4
|
-
cpSync,
|
|
5
|
-
existsSync,
|
|
6
|
-
fsyncSync,
|
|
7
|
-
lstatSync,
|
|
8
|
-
mkdirSync,
|
|
9
|
-
openSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
renameSync,
|
|
12
|
-
statSync,
|
|
13
|
-
symlinkSync,
|
|
14
|
-
unlinkSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
} from 'node:fs';
|
|
17
|
-
import { dirname, join, relative } from 'node:path';
|
|
18
2
|
|
|
19
3
|
import { dim, failGlyph, green, infoGlyph, okGlyph, red, warnGlyph, yellow } from './color.ts';
|
|
20
|
-
import { CLAUDE_HOME, HOME, type PathMap } from './config.ts';
|
|
21
|
-
|
|
22
|
-
const LOCK_PATH = join(HOME, '.cache', 'claude-nomad', 'nomad.lock');
|
|
23
|
-
|
|
24
|
-
/** Opaque handle for an acquired lockfile. Pass to `releaseLock` in a `finally`. */
|
|
25
|
-
export type LockHandle = { fd: number };
|
|
26
4
|
|
|
27
5
|
/**
|
|
28
6
|
* Print an informational line prefixed with the dim `ℹ︎` glyph (U+2139+VS15)
|
|
@@ -130,308 +108,3 @@ export function gitOrFatal(args: readonly string[], context: string, cwd?: strin
|
|
|
130
108
|
throw new NomadFatal(`${context} failed`);
|
|
131
109
|
}
|
|
132
110
|
}
|
|
133
|
-
|
|
134
|
-
/** Read and JSON-parse `path`. Throws `SyntaxError` on malformed content. */
|
|
135
|
-
export function readJson<T>(path: string): T {
|
|
136
|
-
const data: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
137
|
-
return data as T;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Read `path-map.json` and wrap failures as `NomadFatal` so callers route the
|
|
142
|
-
* failure through their `try/finally` lock-release path instead of exposing a
|
|
143
|
-
* raw `SyntaxError` (or `ENOENT`/`EACCES`) past `NomadFatal`-only catch
|
|
144
|
-
* blocks. Equivalent to the inline `try { readJson } catch { throw NomadFatal }`
|
|
145
|
-
* pattern in `cmdPush`; use this helper at every other read site so the
|
|
146
|
-
* lock-release contract holds uniformly across the pipeline.
|
|
147
|
-
*
|
|
148
|
-
* Error verb is conditioned on the cause so ops can distinguish parse
|
|
149
|
-
* failures (malformed JSON) from IO failures (permission denied, file
|
|
150
|
-
* removed mid-run) without scraping the wrapped message. Callers gate on
|
|
151
|
-
* `existsSync(mapPath)` first in the happy path, so an `ENOENT` here means
|
|
152
|
-
* a TOCTOU race rather than the expected absent-file case.
|
|
153
|
-
*/
|
|
154
|
-
export function readPathMap(mapPath: string): PathMap {
|
|
155
|
-
try {
|
|
156
|
-
return readJson<PathMap>(mapPath);
|
|
157
|
-
} catch (err) {
|
|
158
|
-
const verb = err instanceof SyntaxError ? 'parse' : 'read';
|
|
159
|
-
throw new NomadFatal(`could not ${verb} path-map.json: ${(err as Error).message}`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Atomic write: temp + fsync + rename + parent-dir fsync. Survives
|
|
165
|
-
* interrupted pulls. Preserves the destination file's existing mode when it
|
|
166
|
-
* exists, defaults to 0o600 otherwise so credentials in `settings.json` are
|
|
167
|
-
* not widened by the process umask on every regenerate.
|
|
168
|
-
*/
|
|
169
|
-
export function writeJsonAtomic(path: string, data: unknown): void {
|
|
170
|
-
const mode = existsSync(path) ? statSync(path).mode & 0o777 : 0o600;
|
|
171
|
-
const tmp = `${path}.tmp.${process.pid}`;
|
|
172
|
-
const fd = openSync(tmp, 'w', mode);
|
|
173
|
-
try {
|
|
174
|
-
writeFileSync(fd, JSON.stringify(data, null, 2) + '\n');
|
|
175
|
-
fsyncSync(fd);
|
|
176
|
-
} finally {
|
|
177
|
-
closeSync(fd);
|
|
178
|
-
}
|
|
179
|
-
renameSync(tmp, path);
|
|
180
|
-
// Fsync the parent directory so the rename itself is durable across a crash;
|
|
181
|
-
// otherwise the file contents are persisted but the directory entry can be
|
|
182
|
-
// lost. Linux/macOS support this on a read-only fd to the dir.
|
|
183
|
-
const dirFd = openSync(dirname(path), 'r');
|
|
184
|
-
try {
|
|
185
|
-
fsyncSync(dirFd);
|
|
186
|
-
} finally {
|
|
187
|
-
closeSync(dirFd);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Deep merge: source overrides target. Arrays replace, objects merge recursively. */
|
|
192
|
-
export function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
|
|
193
|
-
const out: Record<string, unknown> = { ...target };
|
|
194
|
-
for (const [key, value] of Object.entries(source)) {
|
|
195
|
-
const existing = out[key];
|
|
196
|
-
const bothObjects =
|
|
197
|
-
value !== null &&
|
|
198
|
-
typeof value === 'object' &&
|
|
199
|
-
!Array.isArray(value) &&
|
|
200
|
-
existing !== null &&
|
|
201
|
-
typeof existing === 'object' &&
|
|
202
|
-
!Array.isArray(existing);
|
|
203
|
-
out[key] = bothObjects
|
|
204
|
-
? deepMerge(existing as Record<string, unknown>, value as Record<string, unknown>)
|
|
205
|
-
: value;
|
|
206
|
-
}
|
|
207
|
-
return out as T;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Claude Code encodes absolute project paths by replacing `/` with `-`. */
|
|
211
|
-
export const encodePath = (absPath: string): string => absPath.replaceAll('/', '-');
|
|
212
|
-
|
|
213
|
-
/** Local-time YYYYMMDD-HHMMSS timestamp; lexicographically sortable. Pure. */
|
|
214
|
-
export function nowTimestamp(): string {
|
|
215
|
-
const d = new Date();
|
|
216
|
-
const pad = (n: number): string => n.toString().padStart(2, '0');
|
|
217
|
-
return (
|
|
218
|
-
d.getFullYear().toString() +
|
|
219
|
-
pad(d.getMonth() + 1) +
|
|
220
|
-
pad(d.getDate()) +
|
|
221
|
-
'-' +
|
|
222
|
-
pad(d.getHours()) +
|
|
223
|
-
pad(d.getMinutes()) +
|
|
224
|
-
pad(d.getSeconds())
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Collision-resistant backup timestamp. `nowTimestamp()` is second-resolution,
|
|
230
|
-
* so two pulls in the same wall-clock second would share `ts`, and the
|
|
231
|
-
* second's `backupBeforeWrite` calls (which use `cpSync` with `force:false`)
|
|
232
|
-
* would silently no-op against the existing first snapshot. Append a `-N`
|
|
233
|
-
* suffix until the backup dir is unique.
|
|
234
|
-
*/
|
|
235
|
-
export function freshBackupTs(backupRoot: string): string {
|
|
236
|
-
const base = nowTimestamp();
|
|
237
|
-
if (!existsSync(join(backupRoot, base))) return base;
|
|
238
|
-
let n = 1;
|
|
239
|
-
while (existsSync(join(backupRoot, `${base}-${n}`))) n++;
|
|
240
|
-
return `${base}-${n}`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Create a symlink at `linkPath` pointing to `target`, idempotently. No-op if
|
|
245
|
-
* a symlink already exists at `linkPath`; dies if a non-symlink exists there
|
|
246
|
-
* (caller should pre-scan and back up first; see `applySharedLinks`).
|
|
247
|
-
*/
|
|
248
|
-
export function ensureSymlink(linkPath: string, target: string): void {
|
|
249
|
-
if (existsSync(linkPath)) {
|
|
250
|
-
if (lstatSync(linkPath).isSymbolicLink()) return;
|
|
251
|
-
die(`${linkPath} exists and is not a symlink. Move it aside first.`);
|
|
252
|
-
}
|
|
253
|
-
mkdirSync(dirname(linkPath), { recursive: true });
|
|
254
|
-
symlinkSync(target, linkPath);
|
|
255
|
-
log(`linked ${linkPath} -> ${target}`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Snapshot `absPath` into `~/.cache/claude-nomad/backup/<ts>/<rel>` before destructive write.
|
|
260
|
-
* No-op if source missing or outside CLAUDE_HOME. Recursive for directories.
|
|
261
|
-
*/
|
|
262
|
-
export function backupBeforeWrite(absPath: string, ts: string): void {
|
|
263
|
-
if (!existsSync(absPath)) return;
|
|
264
|
-
const rel = relative(CLAUDE_HOME, absPath);
|
|
265
|
-
if (rel.startsWith('..') || rel === '') return;
|
|
266
|
-
const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts);
|
|
267
|
-
const dst = join(backupRoot, rel);
|
|
268
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
269
|
-
cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Parallel of `backupBeforeWrite`, but scoped to `REPO_HOME` instead of
|
|
274
|
-
* `CLAUDE_HOME`. Used by `remapPush` to snapshot repo-side encoded-dir
|
|
275
|
-
* state before `copyDir` clobbers it. Backup root is repo-prefixed so the
|
|
276
|
-
* dump is distinguishable from `CLAUDE_HOME` backups in the same `ts` dir.
|
|
277
|
-
*/
|
|
278
|
-
export function backupRepoWrite(absPath: string, ts: string, repoHome: string): void {
|
|
279
|
-
if (!existsSync(absPath)) return;
|
|
280
|
-
const rel = relative(repoHome, absPath);
|
|
281
|
-
if (rel.startsWith('..') || rel === '') return;
|
|
282
|
-
const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'repo');
|
|
283
|
-
const dst = join(backupRoot, rel);
|
|
284
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
285
|
-
cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Parallel of `backupBeforeWrite` and `backupRepoWrite`, scoped to an
|
|
290
|
-
* explicit `projectRoot` instead of `CLAUDE_HOME` or `REPO_HOME`. Used by
|
|
291
|
-
* `remapExtrasPull` to snapshot host-side extras content (e.g.
|
|
292
|
-
* `<localRoot>/.planning/`) before `copyExtras` clobbers it. The existing
|
|
293
|
-
* helpers cannot serve this case: their `relative(CLAUDE_HOME, absPath)` and
|
|
294
|
-
* `relative(repoHome, absPath)` guards return a `..`-prefixed string for any
|
|
295
|
-
* path outside their anchor and silently no-op, so a pull-side
|
|
296
|
-
* `<localRoot>/.planning/` would never be backed up.
|
|
297
|
-
*
|
|
298
|
-
* Backup root is `extras/`-prefixed inside the same `<ts>` dir so the
|
|
299
|
-
* snapshot is distinguishable from `CLAUDE_HOME` dumps (no prefix) and
|
|
300
|
-
* `repo/` dumps. Layout:
|
|
301
|
-
* `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-projectRoot>/<rel>/`
|
|
302
|
-
* where `<rel>` is `relative(projectRoot, absPath)` and
|
|
303
|
-
* `<encoded-projectRoot>` is `encodePath(projectRoot)`. The encoded prefix
|
|
304
|
-
* namespaces snapshots by project so two opted-in projects with the same
|
|
305
|
-
* relative extras path (e.g. both with `.planning/PLAN.md`) cannot collide
|
|
306
|
-
* inside the same `<ts>` directory (`cpSync` runs with `force: false`, so a
|
|
307
|
-
* collision would silently drop the second snapshot).
|
|
308
|
-
*/
|
|
309
|
-
export function backupExtrasWrite(absPath: string, ts: string, projectRoot: string): void {
|
|
310
|
-
if (!existsSync(absPath)) return;
|
|
311
|
-
const rel = relative(projectRoot, absPath);
|
|
312
|
-
if (rel.startsWith('..') || rel === '') return;
|
|
313
|
-
const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'extras');
|
|
314
|
-
const dst = join(backupRoot, encodePath(projectRoot), rel);
|
|
315
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
316
|
-
cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Acquire the exclusive nomad lockfile so two pulls/pushes cannot mutate
|
|
321
|
-
* `~/.claude/` concurrently. Returns the handle on success, or `null` on
|
|
322
|
-
* contention (caller should `process.exit(0)`; skip-on-contention is the
|
|
323
|
-
* intended UX for backgrounded shell-rc invocations). Detects stale locks by
|
|
324
|
-
* probing the recorded pid with `kill(pid, 0)` and recovers via
|
|
325
|
-
* `unlinkIfSamePid` + `retryOnce`. `verb` is `'pull'` or `'push'`; surfaces
|
|
326
|
-
* in the contention-skip message.
|
|
327
|
-
*/
|
|
328
|
-
export function acquireLock(verb: string): LockHandle | null {
|
|
329
|
-
mkdirSync(dirname(LOCK_PATH), { recursive: true });
|
|
330
|
-
try {
|
|
331
|
-
const fd = openSync(LOCK_PATH, 'wx');
|
|
332
|
-
writeFileSync(fd, String(process.pid));
|
|
333
|
-
return { fd };
|
|
334
|
-
} catch (err) {
|
|
335
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
336
|
-
if (code !== 'EEXIST') throw err;
|
|
337
|
-
return checkStaleAndRetry(verb);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Release a previously-acquired lock handle. No-op when `handle` is null
|
|
343
|
-
* (matches `acquireLock`'s contention return). Tolerates the lockfile having
|
|
344
|
-
* already been unlinked. MUST be called from a `finally` so it runs even when
|
|
345
|
-
* the wrapped command throws.
|
|
346
|
-
*/
|
|
347
|
-
export function releaseLock(handle: LockHandle | null): void {
|
|
348
|
-
if (handle === null) return;
|
|
349
|
-
try {
|
|
350
|
-
closeSync(handle.fd);
|
|
351
|
-
} catch {
|
|
352
|
-
/* already closed; ignore */
|
|
353
|
-
}
|
|
354
|
-
try {
|
|
355
|
-
unlinkSync(LOCK_PATH);
|
|
356
|
-
} catch (err) {
|
|
357
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Compare-and-delete helper that closes most of the TOCTOU window between
|
|
363
|
-
* reading a stale lock's pid and removing it: another process could
|
|
364
|
-
* legitimately acquire the lock between those steps, and a naive unlink
|
|
365
|
-
* would clobber it. Re-reads the file and only unlinks if the contents
|
|
366
|
-
* still equal `expectedPidStr`. Returns `true` if the lock was unlinked,
|
|
367
|
-
* `false` if the content drifted or the file already vanished. A microsecond
|
|
368
|
-
* window between the re-read and the unlink remains; the residual race is
|
|
369
|
-
* documented as a backlog item rather than fully closed here.
|
|
370
|
-
*/
|
|
371
|
-
function unlinkIfSamePid(expectedPidStr: string): boolean {
|
|
372
|
-
let current: string;
|
|
373
|
-
try {
|
|
374
|
-
current = readFileSync(LOCK_PATH, 'utf8').trim();
|
|
375
|
-
} catch {
|
|
376
|
-
return false;
|
|
377
|
-
}
|
|
378
|
-
if (current !== expectedPidStr) return false;
|
|
379
|
-
try {
|
|
380
|
-
unlinkSync(LOCK_PATH);
|
|
381
|
-
return true;
|
|
382
|
-
} catch {
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* EEXIST recovery path for `acquireLock`. Reads the lockfile pid, probes
|
|
389
|
-
* liveness with `kill(pid, 0)`, and tries one retry only when the pid is
|
|
390
|
-
* dead AND the compare-and-delete in `unlinkIfSamePid` confirms the file
|
|
391
|
-
* has not been replaced under us. Returns `null` (contention skip) in any
|
|
392
|
-
* other case.
|
|
393
|
-
*/
|
|
394
|
-
function checkStaleAndRetry(verb: string): LockHandle | null {
|
|
395
|
-
let pidStr: string;
|
|
396
|
-
try {
|
|
397
|
-
pidStr = readFileSync(LOCK_PATH, 'utf8').trim();
|
|
398
|
-
} catch {
|
|
399
|
-
pidStr = '';
|
|
400
|
-
}
|
|
401
|
-
const pid = Number.parseInt(pidStr, 10);
|
|
402
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
403
|
-
if (unlinkIfSamePid(pidStr)) return retryOnce(verb);
|
|
404
|
-
warn(`another nomad ${verb} running, skipping`);
|
|
405
|
-
return null;
|
|
406
|
-
}
|
|
407
|
-
try {
|
|
408
|
-
process.kill(pid, 0);
|
|
409
|
-
warn(`another nomad ${verb} running, skipping`);
|
|
410
|
-
return null;
|
|
411
|
-
} catch (err) {
|
|
412
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
413
|
-
if (code === 'ESRCH') {
|
|
414
|
-
if (unlinkIfSamePid(pidStr)) return retryOnce(verb);
|
|
415
|
-
warn(`another nomad ${verb} running, skipping`);
|
|
416
|
-
return null;
|
|
417
|
-
}
|
|
418
|
-
warn(`another nomad ${verb} running, skipping`);
|
|
419
|
-
return null;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Single retry of `openSync(..., 'wx')` after `unlinkIfSamePid` cleared a
|
|
425
|
-
* confirmed-stale lock. Bounded to one attempt to avoid spin loops if the
|
|
426
|
-
* lock is being rapidly recreated by another live process.
|
|
427
|
-
*/
|
|
428
|
-
function retryOnce(verb: string): LockHandle | null {
|
|
429
|
-
try {
|
|
430
|
-
const fd = openSync(LOCK_PATH, 'wx');
|
|
431
|
-
writeFileSync(fd, String(process.pid));
|
|
432
|
-
return { fd };
|
|
433
|
-
} catch {
|
|
434
|
-
warn(`another nomad ${verb} running, skipping`);
|
|
435
|
-
return null;
|
|
436
|
-
}
|
|
437
|
-
}
|