claude-nomad 0.25.1 → 0.25.3

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.3](https://github.com/funkadelic/claude-nomad/compare/v0.25.2...v0.25.3) (2026-05-26)
4
+
5
+
6
+ ### Changed
7
+
8
+ * **config:** add skipAutoPermissionPrompt to KNOWN_SETTINGS_KEYS ([#142](https://github.com/funkadelic/claude-nomad/issues/142)) ([09b4d7c](https://github.com/funkadelic/claude-nomad/commit/09b4d7c27d74cc922f9c516296ca6123e1ee84f5))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * document runtime deps and foreground the security posture ([#145](https://github.com/funkadelic/claude-nomad/issues/145)) ([fb029ee](https://github.com/funkadelic/claude-nomad/commit/fb029eec1a6d915224a39cc2755ff76e42618365))
14
+ * refresh hero.svg tagline and doctor mockup ([#144](https://github.com/funkadelic/claude-nomad/issues/144)) ([58a0176](https://github.com/funkadelic/claude-nomad/commit/58a01760e8d977ce4cf46dcd3137c02f3bc6541c))
15
+
16
+ ## [0.25.2](https://github.com/funkadelic/claude-nomad/compare/v0.25.1...v0.25.2) (2026-05-26)
17
+
18
+
19
+ ### Fixed
20
+
21
+ * 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))
22
+
3
23
  ## [0.25.1](https://github.com/funkadelic/claude-nomad/compare/v0.25.0...v0.25.1) (2026-05-26)
4
24
 
5
25
 
package/README.md CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  ![claude-nomad - Sync your Claude Code setup. Same environment. Any machine.](docs/hero.svg)
8
8
 
9
- **Your entire Claude Code setup, on every machine. History included, secrets excluded.**
9
+ **Your entire Claude Code setup, on every machine. History included, every push secret-scanned.**
10
10
 
11
11
  Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything is there, conversations included.
12
12
 
13
13
  - **Resume your sessions on any machine.** Start a conversation on your desktop and pick it up on your laptop. claude-nomad remaps the file paths Claude Code embeds in every transcript, so your history follows you instead of getting stranded on the box where it started.
14
- - **Private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation. Every push is secret-scanned before it leaves your machine, credentials and ephemeral state never sync, and `nomad init` disables CI on your private mirror by default, so transcripts can't leak through Actions logs.
14
+ - **Secret-scanned, private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation, so claude-nomad is deliberate about what leaves your machine: credentials and ephemeral state never sync, only an explicit allow-list of paths is pushed, and everything that does go up is scanned by [gitleaks](https://github.com/gitleaks/gitleaks) before it leaves your machine; the push aborts on any hit. `nomad init` also disables Actions on your private mirror by default, so transcripts can't leak through CI logs.
15
15
  - **One setup, every machine.** Your agents, skills, slash commands, and settings live in one place and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on top instead of clobbering your shared defaults.
16
16
 
17
17
  Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history survives different file paths and your secrets never ride along.
@@ -215,8 +215,14 @@ Read these before adopting so you opt in with eyes open.
215
215
  - Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes - npm only blocks the install when `engine-strict=true` is configured)
216
216
  - `tsx` (ships as a runtime dependency of the published package; no separate global install required)
217
217
  - Git
218
+ - [`gitleaks`](https://github.com/gitleaks/gitleaks) (required for `nomad push`, which fail-fasts if it is not on PATH; `nomad doctor` also checks it against the pinned 8.30.x and warns when it is absent or mismatched)
218
219
  - A **private** GitHub repo (or any Git remote you control)
219
220
 
221
+ **Optional:**
222
+
223
+ - `gh` (GitHub CLI), used only by `nomad init` to auto-disable Actions on the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and continues
224
+ - `curl`, used only by the version/update check (the `nomad doctor` latest-release line and the post-`nomad update` check); it degrades silently when curl is absent or offline, so the rest of the CLI works without it
225
+
220
226
  ## Setup
221
227
 
222
228
  **Why not just fork?** GitHub doesn't let you flip a public fork to private, and your config (especially session transcripts) must stay private. So the bootstrap is a one-time mirror-push into a fresh private repo, not a fork.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.25.1",
3
+ "version": "0.25.3",
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;
@@ -123,13 +123,13 @@ function saveCache(latest: string): void {
123
123
  /**
124
124
  * Fetch the latest release tag from the upstream GitHub releases API. Uses
125
125
  * `execFileSync('curl', ...)` rather than `node:https` because curl honors
126
- * system proxies, respects the `-m` timeout reliably, and is already a
127
- * required dependency on every supported host (push uses gitleaks; pull uses
128
- * git). 3-second timeout, fail-fast on non-2xx (`-f`), silent (`-s`), follow
129
- * redirects (`-L`). Returns `null` on ANY failure path including a missing
130
- * `tag_name` field or a tag that fails strict-semver validation after the
131
- * leading `v` strip. Release tags ship as `v<semver>` per
132
- * `release-please-config.json`'s `include-v-in-tag: true`.
126
+ * system proxies and respects the `-m` timeout reliably. curl is optional, not
127
+ * a hard dependency: this is its only consumer, so a host without curl simply
128
+ * skips the version line. 3-second timeout, fail-fast on non-2xx (`-f`), silent
129
+ * (`-s`), follow redirects (`-L`). Returns `null` on ANY failure path (curl
130
+ * missing from PATH, a missing `tag_name` field, or a tag that fails
131
+ * strict-semver validation after the leading `v` strip). Release tags ship as
132
+ * `v<semver>` per `release-please-config.json`'s `include-v-in-tag: true`.
133
133
  */
134
134
  function fetchLatestTag(): string | null {
135
135
  try {
package/src/config.ts CHANGED
@@ -139,6 +139,7 @@ export const KNOWN_SETTINGS_KEYS = new Set<string>([
139
139
  'pluginRepositoryEnabled',
140
140
  'pluginsLocalConfig',
141
141
  'proxy',
142
+ 'skipAutoPermissionPrompt',
142
143
  'statsig',
143
144
  'statusLine',
144
145
  'subagents',
@@ -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`);