claude-nomad 0.18.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,19 @@
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
+
10
+ ## [0.19.0](https://github.com/funkadelic/claude-nomad/compare/v0.18.0...v0.19.0) (2026-05-22)
11
+
12
+
13
+ ### Added
14
+
15
+ * **update:** auto-resolve sole package-lock.json merge conflict ([#96](https://github.com/funkadelic/claude-nomad/issues/96)) ([8d76be9](https://github.com/funkadelic/claude-nomad/commit/8d76be9791b427484d709d97a5d077539c0f9f1a))
16
+
3
17
  ## [0.18.0](https://github.com/funkadelic/claude-nomad/compare/v0.17.2...v0.18.0) (2026-05-22)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.18.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,13 +160,109 @@ 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;
172
+ }
173
+
174
+ /**
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.
195
+ *
196
+ * @param paths - Unmerged paths to resolve via `git checkout --theirs`.
197
+ */
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 {
231
+ let unmerged: string[];
232
+ try {
233
+ unmerged = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
234
+ cwd: REPO_HOME,
235
+ stdio: ['ignore', 'pipe', 'pipe'],
236
+ })
237
+ .toString()
238
+ .split('\n')
239
+ .filter((line) => line !== '');
240
+ } catch {
241
+ // Probe failure must not mask the original merge NomadFatal. Returning
242
+ // false lets the caller re-throw the merge error unchanged.
243
+ return false;
244
+ }
245
+
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;
170
266
  }
171
267
 
172
268
  /**
@@ -186,7 +282,7 @@ function runVanilla(opts: CmdUpdateOpts): void {
186
282
  * - `pushOrigin`: when true, push to `origin/main` without prompting
187
283
  * - `prompt`: optional prompt function used for interactive confirmation
188
284
  */
189
- function runFork(opts: CmdUpdateOpts): void {
285
+ function runFork(opts: CmdUpdateOpts): boolean {
190
286
  const promptFn = opts.prompt ?? defaultPrompt;
191
287
  if (opts.dryRun === true) {
192
288
  log('DRY-RUN: would run `git fetch upstream`');
@@ -196,13 +292,19 @@ function runFork(opts: CmdUpdateOpts): void {
196
292
  } else {
197
293
  log('DRY-RUN: would prompt before pushing to origin/main');
198
294
  }
199
- return;
295
+ return false;
200
296
  }
201
297
  gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
202
- gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
298
+ let autoResolved = false;
299
+ try {
300
+ gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
301
+ } catch (err) {
302
+ if (!tryAutoResolveMergeConflict(opts)) throw err;
303
+ autoResolved = true;
304
+ }
203
305
  if (opts.pushOrigin === true) {
204
306
  gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
205
- return;
307
+ return autoResolved;
206
308
  }
207
309
  const answer = promptFn(
208
310
  'Push merge to origin/main? (y publishes to your private mirror so other hosts see it; N keeps it local) [y/N] ',
@@ -212,6 +314,7 @@ function runFork(opts: CmdUpdateOpts): void {
212
314
  } else {
213
315
  log('skipping push to origin (run `git push origin main` later)');
214
316
  }
317
+ return autoResolved;
215
318
  }
216
319
 
217
320
  /**
@@ -273,9 +376,8 @@ export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
273
376
  }
274
377
 
275
378
  const beforeSha = headSha();
276
- if (topology === 'vanilla') runVanilla(opts);
277
- else runFork(opts);
379
+ const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts);
278
380
 
279
- reinstallIfNeeded(beforeSha);
381
+ if (!installAlreadyRan) reinstallIfNeeded(beforeSha);
280
382
  cmdDoctor();
281
383
  }