claude-nomad 0.25.1 → 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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.2](https://github.com/funkadelic/claude-nomad/compare/v0.25.1...v0.25.2) (2026-05-26)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * pre-existing robustness fixes from PR [#136](https://github.com/funkadelic/claude-nomad/issues/136) review ([#140](https://github.com/funkadelic/claude-nomad/issues/140)) ([bf4f443](https://github.com/funkadelic/claude-nomad/commit/bf4f443c50a47efafbb350d45d0e5d6883374f8d))
9
+
3
10
  ## [0.25.1](https://github.com/funkadelic/claude-nomad/compare/v0.25.0...v0.25.1) (2026-05-26)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.25.1",
3
+ "version": "0.25.2",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync } from 'node:fs';
1
+ import { existsSync, lstatSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import {
@@ -83,16 +83,48 @@ export function reportRepoState(section: DoctorSection): void {
83
83
  }
84
84
  }
85
85
 
86
- /** Emits a per-entry status line for each name in SHARED_LINKS (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via process.exitCode. */
86
+ /**
87
+ * Emits a per-entry status line for each name in SHARED_LINKS
88
+ * (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via
89
+ * process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
90
+ * that vanishes or becomes unreadable between the probe and the stat yields a
91
+ * row instead of an unhandled throw that aborts the whole doctor run. A symlink
92
+ * whose target cannot be resolved is a WARN (broken-symlink for a missing
93
+ * target, target-unreadable otherwise), never a healthy OK, so a dangling or
94
+ * unreadable link is not masked.
95
+ */
87
96
  export function reportSharedLinks(section: DoctorSection): void {
88
97
  for (const name of SHARED_LINKS) {
89
98
  const p = join(CLAUDE_HOME, name);
90
- if (!existsSync(p)) {
91
- addItem(section, `${yellow(warnGlyph)} ${name}: missing`);
99
+ let stat;
100
+ try {
101
+ stat = lstatSync(p);
102
+ } catch (err) {
103
+ const code = (err as NodeJS.ErrnoException).code;
104
+ if (code === 'ENOENT') {
105
+ addItem(section, `${yellow(warnGlyph)} ${name}: missing`);
106
+ } else {
107
+ addItem(section, `${red(failGlyph)} ${name}: could not stat (${String(code)})`);
108
+ process.exitCode = 1;
109
+ }
92
110
  continue;
93
111
  }
94
- if (lstatSync(p).isSymbolicLink()) {
95
- addItem(section, `${green(okGlyph)} ${name}: symlink`);
112
+ if (stat.isSymbolicLink()) {
113
+ try {
114
+ // statSync follows the link; a throw means the target does not resolve.
115
+ statSync(p);
116
+ addItem(section, `${green(okGlyph)} ${name}: symlink`);
117
+ } catch (err) {
118
+ const code = (err as NodeJS.ErrnoException).code;
119
+ if (code === 'ENOENT') {
120
+ addItem(section, `${yellow(warnGlyph)} ${name}: broken symlink (target missing)`);
121
+ } else {
122
+ addItem(
123
+ section,
124
+ `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
125
+ );
126
+ }
127
+ }
96
128
  } else {
97
129
  addItem(section, `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`);
98
130
  process.exitCode = 1;
@@ -22,7 +22,26 @@ export function acquireLock(verb: string): LockHandle | null {
22
22
  mkdirSync(dirname(LOCK_PATH), { recursive: true });
23
23
  try {
24
24
  const fd = openSync(LOCK_PATH, 'wx');
25
- writeFileSync(fd, String(process.pid));
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
+ }
26
45
  return { fd };
27
46
  } catch (err) {
28
47
  const code = (err as NodeJS.ErrnoException).code;
@@ -122,7 +141,25 @@ function checkStaleAndRetry(verb: string): LockHandle | null {
122
141
  function retryOnce(verb: string): LockHandle | null {
123
142
  try {
124
143
  const fd = openSync(LOCK_PATH, 'wx');
125
- writeFileSync(fd, String(process.pid));
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
+ }
126
163
  return { fd };
127
164
  } catch {
128
165
  warn(`another nomad ${verb} running, skipping`);