claude-nomad 0.66.0 → 0.67.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/nomad.mjs +197 -129
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.67.0](https://github.com/funkadelic/claude-nomad/compare/v0.66.0...v0.67.0) (2026-08-18)
4
+
5
+ ### What's new
6
+
7
+ **On all supported platforms, i.e. \*nix, Mac, WSL2, and native Windows.**
8
+
9
+ - **Your backup cache no longer collects empty folders.** Every sync saves a copy of anything it is
10
+ about to overwrite into a dated folder under `~/.cache/claude-nomad/backup/`, and it creates that
11
+ folder before it touches your files so a full disk or a permissions problem stops the sync before
12
+ it has changed anything. A sync that turns out to have nothing to save now clears its own folder
13
+ instead of leaving an empty one behind, and so does one that stops early.
14
+ - **`nomad clean --backups` removes empty backup folders whatever their age.** They hold nothing to
15
+ recover, so neither the 14-day default nor `--keep <N>` keeps one alive, and they no longer use up
16
+ the slots `--keep <N>` reserves: ask for the five newest and you get the five newest that actually
17
+ hold something. Backups holding anything at all, a file or even a link, are left exactly as
18
+ before. `nomad clean --backups --dry-run` always prints its preview, since it changes nothing and
19
+ never waits on another command.
20
+ - **`nomad adopt` no longer runs at the same time as a pull, push, or sync.** It waits its turn the
21
+ way those commands already wait for each other, so a cleanup or a sync running in another terminal
22
+ cannot remove or overwrite a backup that adopt is still writing. If another one is already
23
+ running, adopt says so and does nothing rather than starting a move it cannot finish safely.
24
+ Previewing with `nomad adopt <name> --dry-run` never waits, since it only reads and prints.
25
+
26
+
27
+ ### Added
28
+
29
+ * clean up the empty folders in your backup cache ([#525](https://github.com/funkadelic/claude-nomad/issues/525)) ([3f5ae92](https://github.com/funkadelic/claude-nomad/commit/3f5ae92e46173364f5e9c66e4963bcc716c813e6))
30
+
31
+
32
+ ### Fixed
33
+
34
+ * **ci:** base the drift branch on main on a dispatch run ([#524](https://github.com/funkadelic/claude-nomad/issues/524)) ([3e94c1e](https://github.com/funkadelic/claude-nomad/commit/3e94c1e2df9296a539c71eac873058cb69225d78))
35
+
3
36
  ## [0.66.0](https://github.com/funkadelic/claude-nomad/compare/v0.65.0...v0.66.0) (2026-08-17)
4
37
 
5
38
  ### What's new
package/dist/nomad.mjs CHANGED
@@ -807,6 +807,7 @@ import {
807
807
  mkdirSync,
808
808
  openSync,
809
809
  renameSync,
810
+ rmdirSync,
810
811
  statSync,
811
812
  symlinkSync,
812
813
  writeFileSync
@@ -867,6 +868,12 @@ function freshBackupTs(backupRoot) {
867
868
  while (existsSync3(join5(backupRoot, `${base}-${n}`))) n++;
868
869
  return `${base}-${n}`;
869
870
  }
871
+ function discardEmptyBackupDir(backupRoot) {
872
+ try {
873
+ rmdirSync(backupRoot);
874
+ } catch {
875
+ }
876
+ }
870
877
  function ensureSymlink(linkPath, target) {
871
878
  if (existsSync3(linkPath)) {
872
879
  if (lstatSync4(linkPath).isSymbolicLink()) return;
@@ -1549,7 +1556,7 @@ var init_push_leak_verdict = __esm({
1549
1556
 
1550
1557
  // src/commands.adopt.ts
1551
1558
  import { existsSync as existsSync5, lstatSync as lstatSync7 } from "node:fs";
1552
- import { join as join8 } from "node:path";
1559
+ import { join as join9 } from "node:path";
1553
1560
 
1554
1561
  // src/commands.adopt.recover.ts
1555
1562
  import { lstatSync as lstatSync6, rmSync as rmSync4 } from "node:fs";
@@ -2460,10 +2467,125 @@ init_config_sharedDirs_guard();
2460
2467
  init_exit_codes();
2461
2468
  init_utils();
2462
2469
  init_utils_fs();
2470
+
2471
+ // src/utils.lockfile.ts
2472
+ init_config();
2473
+ init_utils();
2474
+ import { closeSync as closeSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
2475
+ import { dirname as dirname3, join as join8 } from "node:path";
2476
+ function lockFilePath() {
2477
+ return join8(home(), ".cache", "claude-nomad", "nomad.lock");
2478
+ }
2479
+ function acquireLock(verb) {
2480
+ const lp = lockFilePath();
2481
+ mkdirSync2(dirname3(lp), { recursive: true });
2482
+ try {
2483
+ const fd = openSync2(lp, "wx");
2484
+ try {
2485
+ writeFileSync2(fd, String(process.pid));
2486
+ } catch (writeErr) {
2487
+ try {
2488
+ closeSync2(fd);
2489
+ } catch {
2490
+ }
2491
+ try {
2492
+ unlinkSync(lp);
2493
+ } catch {
2494
+ }
2495
+ throw writeErr;
2496
+ }
2497
+ return { fd, path: lp };
2498
+ } catch (err) {
2499
+ const code = err.code;
2500
+ if (code !== "EEXIST") throw err;
2501
+ return checkStaleAndRetry(verb, lp);
2502
+ }
2503
+ }
2504
+ function releaseLock(handle) {
2505
+ if (handle === null) return;
2506
+ const lp = handle.path;
2507
+ try {
2508
+ closeSync2(handle.fd);
2509
+ } catch {
2510
+ }
2511
+ try {
2512
+ unlinkSync(lp);
2513
+ } catch (err) {
2514
+ if (err.code !== "ENOENT") throw err;
2515
+ }
2516
+ }
2517
+ function unlinkIfSamePid(expectedPidStr, lp) {
2518
+ let current;
2519
+ try {
2520
+ current = readFileSync3(lp, "utf8").trim();
2521
+ } catch {
2522
+ return false;
2523
+ }
2524
+ if (current !== expectedPidStr) return false;
2525
+ try {
2526
+ unlinkSync(lp);
2527
+ return true;
2528
+ } catch {
2529
+ return false;
2530
+ }
2531
+ }
2532
+ function checkStaleAndRetry(verb, lp) {
2533
+ let pidStr;
2534
+ try {
2535
+ pidStr = readFileSync3(lp, "utf8").trim();
2536
+ } catch {
2537
+ pidStr = "";
2538
+ }
2539
+ const pid = Number.parseInt(pidStr, 10);
2540
+ if (!Number.isFinite(pid) || pid <= 0) {
2541
+ if (unlinkIfSamePid(pidStr, lp)) return retryOnce(verb, lp);
2542
+ warn(`another nomad ${verb} running, skipping`);
2543
+ return null;
2544
+ }
2545
+ try {
2546
+ process.kill(pid, 0);
2547
+ warn(`another nomad ${verb} running, skipping`);
2548
+ return null;
2549
+ } catch (err) {
2550
+ const code = err.code;
2551
+ if (code === "ESRCH") {
2552
+ if (unlinkIfSamePid(pidStr, lp)) return retryOnce(verb, lp);
2553
+ warn(`another nomad ${verb} running, skipping`);
2554
+ return null;
2555
+ }
2556
+ warn(`another nomad ${verb} running, skipping`);
2557
+ return null;
2558
+ }
2559
+ }
2560
+ function retryOnce(verb, lp) {
2561
+ try {
2562
+ const fd = openSync2(lp, "wx");
2563
+ try {
2564
+ writeFileSync2(fd, String(process.pid));
2565
+ } catch {
2566
+ try {
2567
+ closeSync2(fd);
2568
+ } catch {
2569
+ }
2570
+ try {
2571
+ unlinkSync(lp);
2572
+ } catch {
2573
+ }
2574
+ warn(`another nomad ${verb} running, skipping`);
2575
+ return null;
2576
+ }
2577
+ return { fd, path: lp };
2578
+ } catch {
2579
+ warn(`another nomad ${verb} running, skipping`);
2580
+ return null;
2581
+ }
2582
+ }
2583
+
2584
+ // src/commands.adopt.ts
2463
2585
  init_utils_json();
2464
2586
  var ADOPT_PUSH_HINT = "run `nomad push` to share with other hosts";
2465
2587
  function readMapIfPresent(repoHome2) {
2466
- const mapPath = join8(repoHome2, "path-map.json");
2588
+ const mapPath = join9(repoHome2, "path-map.json");
2467
2589
  return existsSync5(mapPath) ? readPathMap(mapPath) : { projects: {} };
2468
2590
  }
2469
2591
  function isConfiguredTarget(name, map) {
@@ -2498,7 +2620,7 @@ function adoptStopsEarly(name, linkPath, sharedTarget) {
2498
2620
  function performAdoptMove(name, linkPath, sharedTarget, repo, backup) {
2499
2621
  const ts = freshBackupTs(backup);
2500
2622
  const snapshotted = backupBeforeWrite(linkPath, ts);
2501
- const rel = join8("shared", name);
2623
+ const rel = join9("shared", name);
2502
2624
  const stage = () => gitOrFatal(["add", "--", rel], `git add shared/${name}`, repo);
2503
2625
  copyIntoSharedOrFatal(name, linkPath, sharedTarget, repo);
2504
2626
  refuseLateDeniedEntries(name, linkPath, sharedTarget, repo, { snapshotted, ts });
@@ -2538,8 +2660,21 @@ function cmdAdopt(name, opts = {}) {
2538
2660
  );
2539
2661
  process.exit(1);
2540
2662
  }
2541
- const linkPath = join8(claude, name);
2542
- const sharedTarget = join8(repo, "shared", name);
2663
+ const linkPath = join9(claude, name);
2664
+ const sharedTarget = join9(repo, "shared", name);
2665
+ if (dryRun) {
2666
+ adoptPreflightAndMove(name, linkPath, sharedTarget, repo, backup, true);
2667
+ return;
2668
+ }
2669
+ const handle = acquireLock("adopt");
2670
+ if (handle === null) process.exit(EXIT.SUCCESS);
2671
+ try {
2672
+ adoptPreflightAndMove(name, linkPath, sharedTarget, repo, backup, false);
2673
+ } finally {
2674
+ releaseLock(handle);
2675
+ }
2676
+ }
2677
+ function adoptPreflightAndMove(name, linkPath, sharedTarget, repo, backup, dryRun) {
2543
2678
  if (adoptStopsEarly(name, linkPath, sharedTarget)) return;
2544
2679
  refuseDeniedEntries(name, linkPath);
2545
2680
  if (dryRun) {
@@ -2566,8 +2701,8 @@ init_config();
2566
2701
  import { existsSync as existsSync6 } from "node:fs";
2567
2702
 
2568
2703
  // src/commands.redact.core.ts
2569
- import { appendFileSync, readFileSync as readFileSync3 } from "node:fs";
2570
- import { join as join9 } from "node:path";
2704
+ import { appendFileSync, readFileSync as readFileSync4 } from "node:fs";
2705
+ import { join as join10 } from "node:path";
2571
2706
  function collectMatchIntervals(content, findings) {
2572
2707
  const intervals = [];
2573
2708
  for (const f of findings) {
@@ -2625,10 +2760,10 @@ function isAlreadyPresent(line, lines) {
2625
2760
  function appendGitleaksIgnore(fingerprint, repo) {
2626
2761
  const sanitized = fingerprint.replace(/[\r\n]/g, "").trim();
2627
2762
  if (sanitized.length === 0) return;
2628
- const ignPath = join9(repo, ".gitleaksignore");
2763
+ const ignPath = join10(repo, ".gitleaksignore");
2629
2764
  let raw;
2630
2765
  try {
2631
- raw = readFileSync3(ignPath, "utf8");
2766
+ raw = readFileSync4(ignPath, "utf8");
2632
2767
  } catch {
2633
2768
  raw = "";
2634
2769
  }
@@ -2666,121 +2801,6 @@ import { join as join11 } from "node:path";
2666
2801
  import { createInterface } from "node:readline/promises";
2667
2802
  init_utils_fs();
2668
2803
  init_utils_json();
2669
-
2670
- // src/utils.lockfile.ts
2671
- init_config();
2672
- init_utils();
2673
- import { closeSync as closeSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
2674
- import { dirname as dirname3, join as join10 } from "node:path";
2675
- function lockFilePath() {
2676
- return join10(home(), ".cache", "claude-nomad", "nomad.lock");
2677
- }
2678
- function acquireLock(verb) {
2679
- const lp = lockFilePath();
2680
- mkdirSync2(dirname3(lp), { recursive: true });
2681
- try {
2682
- const fd = openSync2(lp, "wx");
2683
- try {
2684
- writeFileSync2(fd, String(process.pid));
2685
- } catch (writeErr) {
2686
- try {
2687
- closeSync2(fd);
2688
- } catch {
2689
- }
2690
- try {
2691
- unlinkSync(lp);
2692
- } catch {
2693
- }
2694
- throw writeErr;
2695
- }
2696
- return { fd, path: lp };
2697
- } catch (err) {
2698
- const code = err.code;
2699
- if (code !== "EEXIST") throw err;
2700
- return checkStaleAndRetry(verb, lp);
2701
- }
2702
- }
2703
- function releaseLock(handle) {
2704
- if (handle === null) return;
2705
- const lp = handle.path;
2706
- try {
2707
- closeSync2(handle.fd);
2708
- } catch {
2709
- }
2710
- try {
2711
- unlinkSync(lp);
2712
- } catch (err) {
2713
- if (err.code !== "ENOENT") throw err;
2714
- }
2715
- }
2716
- function unlinkIfSamePid(expectedPidStr, lp) {
2717
- let current;
2718
- try {
2719
- current = readFileSync4(lp, "utf8").trim();
2720
- } catch {
2721
- return false;
2722
- }
2723
- if (current !== expectedPidStr) return false;
2724
- try {
2725
- unlinkSync(lp);
2726
- return true;
2727
- } catch {
2728
- return false;
2729
- }
2730
- }
2731
- function checkStaleAndRetry(verb, lp) {
2732
- let pidStr;
2733
- try {
2734
- pidStr = readFileSync4(lp, "utf8").trim();
2735
- } catch {
2736
- pidStr = "";
2737
- }
2738
- const pid = Number.parseInt(pidStr, 10);
2739
- if (!Number.isFinite(pid) || pid <= 0) {
2740
- if (unlinkIfSamePid(pidStr, lp)) return retryOnce(verb, lp);
2741
- warn(`another nomad ${verb} running, skipping`);
2742
- return null;
2743
- }
2744
- try {
2745
- process.kill(pid, 0);
2746
- warn(`another nomad ${verb} running, skipping`);
2747
- return null;
2748
- } catch (err) {
2749
- const code = err.code;
2750
- if (code === "ESRCH") {
2751
- if (unlinkIfSamePid(pidStr, lp)) return retryOnce(verb, lp);
2752
- warn(`another nomad ${verb} running, skipping`);
2753
- return null;
2754
- }
2755
- warn(`another nomad ${verb} running, skipping`);
2756
- return null;
2757
- }
2758
- }
2759
- function retryOnce(verb, lp) {
2760
- try {
2761
- const fd = openSync2(lp, "wx");
2762
- try {
2763
- writeFileSync2(fd, String(process.pid));
2764
- } catch {
2765
- try {
2766
- closeSync2(fd);
2767
- } catch {
2768
- }
2769
- try {
2770
- unlinkSync(lp);
2771
- } catch {
2772
- }
2773
- warn(`another nomad ${verb} running, skipping`);
2774
- return null;
2775
- }
2776
- return { fd, path: lp };
2777
- } catch {
2778
- warn(`another nomad ${verb} running, skipping`);
2779
- return null;
2780
- }
2781
- }
2782
-
2783
- // src/commands.capture-settings.ts
2784
2804
  init_utils();
2785
2805
  async function confirmCapture(destLabel, keys) {
2786
2806
  if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
@@ -2866,6 +2886,9 @@ var TS_SHAPE = /^\d{8}-\d{6}(-\d+)?$/;
2866
2886
  var DURATION_RE = /^(\d+)([dhm])$/;
2867
2887
  var UNIT_MS = { d: 864e5, h: 36e5, m: 6e4 };
2868
2888
  var CLEAN_DEFAULT_OLDER_THAN_MS = 14 * 24 * 60 * 60 * 1e3;
2889
+ function nameOf(d) {
2890
+ return d.name;
2891
+ }
2869
2892
  function isTsDir(name) {
2870
2893
  return TS_SHAPE.test(name);
2871
2894
  }
@@ -2878,6 +2901,19 @@ function listBackupDirs(backupBase2) {
2878
2901
  if (!existsSync8(backupBase2)) return [];
2879
2902
  return readdirSync4(backupBase2).filter(isTsDir).map((name) => ({ name, mtimeMs: statSync2(join12(backupBase2, name)).mtimeMs })).sort((a, b) => b.mtimeMs - a.mtimeMs);
2880
2903
  }
2904
+ function holdsNoContent(dir) {
2905
+ let entries;
2906
+ try {
2907
+ entries = readdirSync4(dir, { withFileTypes: true });
2908
+ } catch {
2909
+ return false;
2910
+ }
2911
+ for (const entry of entries) {
2912
+ if (!entry.isDirectory()) return false;
2913
+ if (!holdsNoContent(join12(dir, entry.name))) return false;
2914
+ }
2915
+ return true;
2916
+ }
2881
2917
  function prunableByAge(dirs, olderThanMs, nowMs) {
2882
2918
  return dirs.filter((d) => nowMs - d.mtimeMs > olderThanMs).map((d) => d.name);
2883
2919
  }
@@ -2910,8 +2946,30 @@ function cmdClean(opts, backupBase2 = backupBase()) {
2910
2946
  }
2911
2947
  olderThanMs = parsed;
2912
2948
  }
2949
+ if (dryRun) {
2950
+ pruneBackups({ dryRun, olderThanMs, keep }, backupBase2);
2951
+ return;
2952
+ }
2953
+ const handle = acquireLock("clean");
2954
+ if (handle === null) process.exit(EXIT.SUCCESS);
2955
+ try {
2956
+ pruneBackups({ dryRun, olderThanMs, keep }, backupBase2);
2957
+ } finally {
2958
+ releaseLock(handle);
2959
+ }
2960
+ }
2961
+ function pruneBackups(opts, backupBase2) {
2962
+ const { dryRun, olderThanMs, keep } = opts;
2913
2963
  const dirs = listBackupDirs(backupBase2);
2914
- const targets = resolveTargets(dirs, olderThanMs, keep);
2964
+ const empty = new Set(dirs.filter((d) => holdsNoContent(join12(backupBase2, d.name))).map(nameOf));
2965
+ const aged = new Set(
2966
+ resolveTargets(
2967
+ dirs.filter((d) => !empty.has(d.name)),
2968
+ olderThanMs,
2969
+ keep
2970
+ )
2971
+ );
2972
+ const targets = dirs.filter((d) => empty.has(d.name) || aged.has(d.name)).map(nameOf);
2915
2973
  if (dryRun) {
2916
2974
  for (const name of targets) item(name);
2917
2975
  log(`dry-run: ${targets.length} backup(s) would be removed`);
@@ -3964,7 +4022,7 @@ function reportBackupsCheck(section2, backupBase2 = backupBase()) {
3964
4022
  if (count > DOCTOR_BACKUP_COUNT_WARN || sizeMb > DOCTOR_BACKUP_SIZE_WARN_MB) {
3965
4023
  addItem(
3966
4024
  section2,
3967
- `${yellow(warnGlyph)} backups: ${count} dirs / ${sizeMb.toFixed(1)} MB (run 'nomad clean --backups --keep <N>'; bare --backups only prunes dirs older than 14d)`
4025
+ `${yellow(warnGlyph)} backups: ${count} dirs / ${sizeMb.toFixed(1)} MB (run 'nomad clean --backups --keep <N>'; bare --backups prunes empty dirs plus those older than 14d)`
3968
4026
  );
3969
4027
  }
3970
4028
  }
@@ -9001,12 +9059,20 @@ function handleWedge(repo, forceRemote) {
9001
9059
  return die(wedgeMarkerRunbookText(state), { code: EXIT.CONFLICT });
9002
9060
  }
9003
9061
  function runPullCore(opts = {}) {
9062
+ const backup = backupBase();
9063
+ const ts = freshBackupTs(backup);
9064
+ try {
9065
+ return runPullWithBackupTs(ts, opts);
9066
+ } finally {
9067
+ discardEmptyBackupDir(join61(backup, ts));
9068
+ }
9069
+ }
9070
+ function runPullWithBackupTs(ts, opts) {
9004
9071
  const dryRun = opts.dryRun === true;
9005
9072
  const forceRemote = opts.forceRemote === true;
9006
9073
  const compose = opts.compose === true;
9007
9074
  const repo = repoHome();
9008
9075
  const backup = backupBase();
9009
- const ts = freshBackupTs(backup);
9010
9076
  const recovered = handleWedge(repo, forceRemote);
9011
9077
  if (!dryRun) {
9012
9078
  const backupRoot = join61(backup, ts);
@@ -10638,7 +10704,7 @@ function parseSyncArgs(argv) {
10638
10704
  // package.json
10639
10705
  var package_default = {
10640
10706
  name: "claude-nomad",
10641
- version: "0.66.0",
10707
+ version: "0.67.0",
10642
10708
  type: "module",
10643
10709
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
10644
10710
  keywords: [
@@ -10867,7 +10933,9 @@ var DEFAULT_HELP = [
10867
10933
  row(" --backups", "Required: confirm backup pruning is the intended target."),
10868
10934
  row(" --dry-run", "List the snapshots that would be removed without deleting."),
10869
10935
  row(" --older-than <dur>", "Delete snapshots older than a duration (e.g. 14d, 24h, 30m)."),
10870
- cont("Default when no retention flag is given: 14d."),
10936
+ cont("Default when no retention flag is given: 14d. A snapshot holding"),
10937
+ cont("nothing is removed in every mode, whatever its age, and never"),
10938
+ cont("uses up one of the --keep slots."),
10871
10939
  row(" --keep <N>", "Keep the N most-recent snapshots, delete the rest. Mutually"),
10872
10940
  cont("exclusive with --older-than."),
10873
10941
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.66.0",
3
+ "version": "0.67.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": [