claude-nomad 0.19.0 → 0.20.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.20.0](https://github.com/funkadelic/claude-nomad/compare/v0.19.0...v0.20.0) (2026-05-22)
4
+
5
+
6
+ ### Added
7
+
8
+ * **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))
9
+
3
10
  ## [0.19.0](https://github.com/funkadelic/claude-nomad/compare/v0.18.0...v0.19.0) (2026-05-22)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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": [
@@ -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): void {
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
- * Auto-resolve a merge conflict when `package-lock.json` is the only unmerged
174
- * path. Release-please bumps the lockfile on every release, so any host that
175
- * has run `npm install` against its mirror will hit this conflict on the next
176
- * `nomad update`. The semantically-correct resolution is "take upstream's
177
- * lockfile, then regenerate locally", so do that automatically when no other
178
- * files are unmerged. Multi-file conflicts still fail loud for human review.
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
- * @returns `true` when the conflict was auto-resolved and the merge committed; `false` when other files are also unmerged (caller should re-throw the original failure).
196
+ * @param paths - Unmerged paths to resolve via `git checkout --theirs`.
181
197
  */
182
- function tryAutoResolveLockfileConflict(): boolean {
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
- gitOrFatal(
200
- ['checkout', '--theirs', '--', 'package-lock.json'],
201
- 'git checkout --theirs package-lock.json',
202
- REPO_HOME,
203
- );
204
- gitOrFatal(['add', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
205
- execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
206
- gitOrFatal(['add', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
207
- gitOrFatal(['commit', '--no-edit'], 'git commit --no-edit', REPO_HOME);
208
- log('auto-resolved package-lock.json conflict (took upstream, reinstalled, committed merge)');
209
- return true;
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): void {
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 (!tryAutoResolveLockfileConflict()) throw err;
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
- if (topology === 'vanilla') runVanilla(opts);
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
  }