claude-nomad 0.19.0 → 0.21.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/CHANGELOG.md +19 -0
- package/README.md +8 -8
- package/package.json +1 -1
- package/shared/.gitignore +1 -0
- package/src/commands.update.ts +87 -29
- package/src/config.ts +1 -0
- package/src/utils.ts +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.21.0](https://github.com/funkadelic/claude-nomad/compare/v0.20.0...v0.21.0) (2026-05-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **config:** add settings.local.json to NEVER_SYNC ([#100](https://github.com/funkadelic/claude-nomad/issues/100)) ([26c7dc1](https://github.com/funkadelic/claude-nomad/commit/26c7dc1ef15206056349d117395cd22a4ee5bd84))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
* **utils:** drop unused writeJson helper ([#101](https://github.com/funkadelic/claude-nomad/issues/101)) ([168a9d7](https://github.com/funkadelic/claude-nomad/commit/168a9d7a582e3cd8ff97e26ac32c9ee4adad1a0d))
|
|
14
|
+
|
|
15
|
+
## [0.20.0](https://github.com/funkadelic/claude-nomad/compare/v0.19.0...v0.20.0) (2026-05-22)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
* **update:** prompted auto-resolve for release-please artifact conflicts ([#98](https://github.com/funkadelic/claude-nomad/issues/98)) ([958cbf1](https://github.com/funkadelic/claude-nomad/commit/958cbf19839f1bda8ad0e25db0bb8495a88c2ee9))
|
|
21
|
+
|
|
3
22
|
## [0.19.0](https://github.com/funkadelic/claude-nomad/compare/v0.18.0...v0.19.0) (2026-05-22)
|
|
4
23
|
|
|
5
24
|
|
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
113
113
|
│ ├── commands/
|
|
114
114
|
│ ├── rules/
|
|
115
115
|
│ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
|
|
116
|
-
│ ├── .gitignore # defense-in-depth: blocks .claude.json, *.token, *.key, .env
|
|
116
|
+
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
|
|
117
117
|
│ └── projects/ # session transcripts under logical names
|
|
118
118
|
├── hosts/
|
|
119
119
|
│ ├── <your-mac>.json # patches merged over settings.base.json
|
|
@@ -125,13 +125,13 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
125
125
|
|
|
126
126
|
## What gets synced vs. not
|
|
127
127
|
|
|
128
|
-
| Category | Items
|
|
129
|
-
| ------------------- |
|
|
130
|
-
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs`
|
|
131
|
-
| **Generated** | `settings.json`
|
|
132
|
-
| **Remapped** | `projects/` session transcripts
|
|
133
|
-
| **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
|
|
134
|
-
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...`
|
|
128
|
+
| Category | Items | Behavior |
|
|
129
|
+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
130
|
+
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
|
|
131
|
+
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
|
|
132
|
+
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
|
|
133
|
+
| **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
|
|
134
|
+
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host. |
|
|
135
135
|
|
|
136
136
|
> [!NOTE]
|
|
137
137
|
> Plugins that depend on host-specific state (external binaries, API keys in env, MCP server URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's own per-host config.
|
package/package.json
CHANGED
package/shared/.gitignore
CHANGED
package/src/commands.update.ts
CHANGED
|
@@ -160,26 +160,74 @@ function reinstallIfNeeded(beforeSha: string): void {
|
|
|
160
160
|
* today.
|
|
161
161
|
*
|
|
162
162
|
* @param opts - Update options; only `dryRun` is observed for this topology.
|
|
163
|
+
* @returns `true` when this path already ran `npm install` and committed the merged lockfile (so the caller should skip `reinstallIfNeeded`). Vanilla `--ff-only` pulls never conflict, so this is always `false`.
|
|
163
164
|
*/
|
|
164
|
-
function runVanilla(opts: CmdUpdateOpts):
|
|
165
|
+
function runVanilla(opts: CmdUpdateOpts): boolean {
|
|
165
166
|
if (opts.dryRun === true) {
|
|
166
167
|
log('DRY-RUN: would run `git pull --ff-only origin main`');
|
|
167
|
-
return;
|
|
168
|
+
return false;
|
|
168
169
|
}
|
|
169
170
|
gitOrFatal(['pull', '--ff-only', 'origin', 'main'], 'git pull', REPO_HOME);
|
|
171
|
+
return false;
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
175
|
+
* Files release-please touches as a set on every release commit. Multi-file
|
|
176
|
+
* merge conflicts in `nomad update` that consist entirely of paths from this
|
|
177
|
+
* set are diagnostic for a release landing upstream while the mirror has its
|
|
178
|
+
* own local commits on these artifacts. Taking upstream is the canonical
|
|
179
|
+
* resolution (these are all generated artifacts the user has no business
|
|
180
|
+
* editing on a mirror), but multi-file is more aggressive than the lone
|
|
181
|
+
* lockfile case so we prompt before mutating.
|
|
182
|
+
*/
|
|
183
|
+
const RELEASE_PLEASE_ARTIFACTS: ReadonlySet<string> = new Set([
|
|
184
|
+
'package.json',
|
|
185
|
+
'package-lock.json',
|
|
186
|
+
'CHANGELOG.md',
|
|
187
|
+
'.release-please-manifest.json',
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve a merge conflict by taking upstream's version of every listed path,
|
|
192
|
+
* regenerating the lockfile via `npm install`, and committing the merge.
|
|
193
|
+
* Shared body for the lone-lockfile auto-resolve and the release-please
|
|
194
|
+
* multi-file prompted auto-resolve.
|
|
179
195
|
*
|
|
180
|
-
* @
|
|
196
|
+
* @param paths - Unmerged paths to resolve via `git checkout --theirs`.
|
|
181
197
|
*/
|
|
182
|
-
function
|
|
198
|
+
function resolveByTakingTheirs(paths: readonly string[]): void {
|
|
199
|
+
for (const p of paths) {
|
|
200
|
+
gitOrFatal(['checkout', '--theirs', '--', p], `git checkout --theirs ${p}`, REPO_HOME);
|
|
201
|
+
}
|
|
202
|
+
gitOrFatal(['add', ...paths], `git add ${paths.join(' ')}`, REPO_HOME);
|
|
203
|
+
execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
|
|
204
|
+
gitOrFatal(['add', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
|
|
205
|
+
gitOrFatal(['commit', '--no-edit'], 'git commit --no-edit', REPO_HOME);
|
|
206
|
+
log(`auto-resolved merge conflict (took upstream for ${paths.join(', ')}, reinstalled)`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Auto-resolve a merge conflict in the two scenarios both caused by
|
|
211
|
+
* release-please landing upstream while the mirror has local commits:
|
|
212
|
+
*
|
|
213
|
+
* 1. **Sole `package-lock.json`** (silent): the lone-lockfile case from PR
|
|
214
|
+
* #96. Any host that has run `npm install` against the mirror will hit
|
|
215
|
+
* this on the next `nomad update`; take upstream + reinstall is the
|
|
216
|
+
* semantically-correct fix and surprise-free for a generated artifact.
|
|
217
|
+
*
|
|
218
|
+
* 2. **All paths in `RELEASE_PLEASE_ARTIFACTS` and more than one path**
|
|
219
|
+
* (prompted): a release commit conflicting on `package.json`,
|
|
220
|
+
* `CHANGELOG.md`, `.release-please-manifest.json` together with the
|
|
221
|
+
* lockfile. Same semantic resolution, but more files are touched so we
|
|
222
|
+
* require explicit y/N consent before mutating.
|
|
223
|
+
*
|
|
224
|
+
* Returns `false` for any other conflict shape (including probe failure);
|
|
225
|
+
* the caller re-throws the original merge `NomadFatal` unchanged.
|
|
226
|
+
*
|
|
227
|
+
* @param opts - Update options; only `prompt` is consulted (used for the multi-file release-please consent prompt).
|
|
228
|
+
* @returns `true` when the conflict was auto-resolved and the merge committed; `false` when the conflict shape does not match either auto-resolve case (caller should re-throw the original failure).
|
|
229
|
+
*/
|
|
230
|
+
function tryAutoResolveMergeConflict(opts: CmdUpdateOpts): boolean {
|
|
183
231
|
let unmerged: string[];
|
|
184
232
|
try {
|
|
185
233
|
unmerged = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
@@ -194,19 +242,27 @@ function tryAutoResolveLockfileConflict(): boolean {
|
|
|
194
242
|
// false lets the caller re-throw the merge error unchanged.
|
|
195
243
|
return false;
|
|
196
244
|
}
|
|
197
|
-
if (unmerged.length !== 1 || unmerged[0] !== 'package-lock.json') return false;
|
|
198
245
|
|
|
199
|
-
|
|
200
|
-
['
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
246
|
+
if (unmerged.length === 1 && unmerged[0] === 'package-lock.json') {
|
|
247
|
+
resolveByTakingTheirs(['package-lock.json']);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (unmerged.length > 1 && unmerged.every((p) => RELEASE_PLEASE_ARTIFACTS.has(p))) {
|
|
252
|
+
const promptFn = opts.prompt ?? defaultPrompt;
|
|
253
|
+
log(`merge conflict in release-please artifacts: ${unmerged.join(', ')}`);
|
|
254
|
+
const answer = promptFn(
|
|
255
|
+
'Auto-resolve by taking upstream + `npm install` + commit? [y/N] ',
|
|
256
|
+
).toLowerCase();
|
|
257
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
258
|
+
log('skipping auto-resolve (resolve manually then re-run `nomad update`)');
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
resolveByTakingTheirs(unmerged);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return false;
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
/**
|
|
@@ -226,7 +282,7 @@ function tryAutoResolveLockfileConflict(): boolean {
|
|
|
226
282
|
* - `pushOrigin`: when true, push to `origin/main` without prompting
|
|
227
283
|
* - `prompt`: optional prompt function used for interactive confirmation
|
|
228
284
|
*/
|
|
229
|
-
function runFork(opts: CmdUpdateOpts):
|
|
285
|
+
function runFork(opts: CmdUpdateOpts): boolean {
|
|
230
286
|
const promptFn = opts.prompt ?? defaultPrompt;
|
|
231
287
|
if (opts.dryRun === true) {
|
|
232
288
|
log('DRY-RUN: would run `git fetch upstream`');
|
|
@@ -236,17 +292,19 @@ function runFork(opts: CmdUpdateOpts): void {
|
|
|
236
292
|
} else {
|
|
237
293
|
log('DRY-RUN: would prompt before pushing to origin/main');
|
|
238
294
|
}
|
|
239
|
-
return;
|
|
295
|
+
return false;
|
|
240
296
|
}
|
|
241
297
|
gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
|
|
298
|
+
let autoResolved = false;
|
|
242
299
|
try {
|
|
243
300
|
gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
|
|
244
301
|
} catch (err) {
|
|
245
|
-
if (!
|
|
302
|
+
if (!tryAutoResolveMergeConflict(opts)) throw err;
|
|
303
|
+
autoResolved = true;
|
|
246
304
|
}
|
|
247
305
|
if (opts.pushOrigin === true) {
|
|
248
306
|
gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
|
|
249
|
-
return;
|
|
307
|
+
return autoResolved;
|
|
250
308
|
}
|
|
251
309
|
const answer = promptFn(
|
|
252
310
|
'Push merge to origin/main? (y publishes to your private mirror so other hosts see it; N keeps it local) [y/N] ',
|
|
@@ -256,6 +314,7 @@ function runFork(opts: CmdUpdateOpts): void {
|
|
|
256
314
|
} else {
|
|
257
315
|
log('skipping push to origin (run `git push origin main` later)');
|
|
258
316
|
}
|
|
317
|
+
return autoResolved;
|
|
259
318
|
}
|
|
260
319
|
|
|
261
320
|
/**
|
|
@@ -317,9 +376,8 @@ export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
|
|
|
317
376
|
}
|
|
318
377
|
|
|
319
378
|
const beforeSha = headSha();
|
|
320
|
-
|
|
321
|
-
else runFork(opts);
|
|
379
|
+
const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts);
|
|
322
380
|
|
|
323
|
-
reinstallIfNeeded(beforeSha);
|
|
381
|
+
if (!installAlreadyRan) reinstallIfNeeded(beforeSha);
|
|
324
382
|
cmdDoctor();
|
|
325
383
|
}
|
package/src/config.ts
CHANGED
package/src/utils.ts
CHANGED
|
@@ -117,11 +117,6 @@ export function readJson<T>(path: string): T {
|
|
|
117
117
|
return data as T;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/** Write `data` as pretty-printed JSON (2-space indent, trailing newline). Non-atomic. */
|
|
121
|
-
export function writeJson(path: string, data: unknown): void {
|
|
122
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
120
|
/**
|
|
126
121
|
* Atomic write: temp + fsync + rename + parent-dir fsync. Survives
|
|
127
122
|
* interrupted pulls. Preserves the destination file's existing mode when it
|