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.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/commands.doctor.check-shared.scan.ts +158 -0
  5. package/src/commands.doctor.check-shared.ts +58 -189
  6. package/src/commands.doctor.checks.pathmap.ts +101 -0
  7. package/src/commands.doctor.checks.repo.ts +133 -0
  8. package/src/commands.doctor.checks.repository.ts +105 -0
  9. package/src/commands.doctor.checks.settings.ts +88 -0
  10. package/src/commands.doctor.format.ts +18 -0
  11. package/src/commands.doctor.ts +10 -7
  12. package/src/commands.drop-session.git.ts +81 -0
  13. package/src/commands.drop-session.ts +79 -138
  14. package/src/commands.pull.ts +3 -2
  15. package/src/commands.push.allowlist.ts +119 -0
  16. package/src/commands.push.ts +6 -121
  17. package/src/commands.update.git.ts +90 -0
  18. package/src/commands.update.resolve.ts +138 -0
  19. package/src/commands.update.test-helpers.git.ts +107 -0
  20. package/src/commands.update.ts +4 -221
  21. package/src/diff.ts +2 -1
  22. package/src/extras-sync.diff.ts +40 -0
  23. package/src/extras-sync.guards.ts +52 -0
  24. package/src/extras-sync.ts +146 -236
  25. package/src/init.classify.ts +1 -1
  26. package/src/init.snapshot.ts +3 -1
  27. package/src/init.ts +2 -1
  28. package/src/links.ts +3 -10
  29. package/src/nomad.dispatch.ts +25 -0
  30. package/src/nomad.help.ts +43 -0
  31. package/src/nomad.ts +6 -68
  32. package/src/preview.ts +2 -1
  33. package/src/push-gitleaks.scan.ts +115 -0
  34. package/src/push-gitleaks.ts +50 -106
  35. package/src/remap.ts +3 -1
  36. package/src/resume.ts +2 -1
  37. package/src/update.fork-extras.ts +2 -1
  38. package/src/utils.fs.ts +152 -0
  39. package/src/utils.json.ts +55 -0
  40. package/src/utils.lockfile.ts +168 -0
  41. package/src/utils.ts +0 -327
  42. 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
- }