claude-nomad 0.38.1 → 0.40.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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.40.0](https://github.com/funkadelic/claude-nomad/compare/v0.39.0...v0.40.0) (2026-06-03)
4
+
5
+
6
+ ### Added
7
+
8
+ * **spinner:** animate long-running operations ([#233](https://github.com/funkadelic/claude-nomad/issues/233)) ([e6755c3](https://github.com/funkadelic/claude-nomad/commit/e6755c3e5e86b52cbc5f996e9c5aee9d79da4574))
9
+
10
+
11
+ ### Changed
12
+
13
+ * **docs-site:** build gate, link validation, and page metadata ([#231](https://github.com/funkadelic/claude-nomad/issues/231)) ([d4ad8ec](https://github.com/funkadelic/claude-nomad/commit/d4ad8ec0c414a91153a584156ccee2b32d824360))
14
+
15
+ ## [0.39.0](https://github.com/funkadelic/claude-nomad/compare/v0.38.1...v0.39.0) (2026-06-02)
16
+
17
+
18
+ ### Added
19
+
20
+ * **doctor:** group version checks into Nomad + Dependency sections ([#228](https://github.com/funkadelic/claude-nomad/issues/228)) ([08e8bf7](https://github.com/funkadelic/claude-nomad/commit/08e8bf79a730f1e7f025a1c2ad9dd406f586b05e))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * **how-it-works:** render repo-layout trees with FileTree ([#229](https://github.com/funkadelic/claude-nomad/issues/229)) ([b52de15](https://github.com/funkadelic/claude-nomad/commit/b52de152416ac5dabb650a1970b53a3b45262396))
26
+
3
27
  ## [0.38.1](https://github.com/funkadelic/claude-nomad/compare/v0.38.0...v0.38.1) (2026-06-02)
4
28
 
5
29
 
package/README.md CHANGED
@@ -57,6 +57,11 @@ $ nomad pull # apply config to ~/.claude/
57
57
  $ nomad push # publish local changes (sessions, settings)
58
58
  ```
59
59
 
60
+ During `nomad push` and `nomad pull`, long-running steps (rebase, secret scan, git push, session
61
+ sync) show an animated progress indicator on an interactive terminal so the CLI does not look hung.
62
+ In CI and when output is piped, only plain text lines are printed, with no ANSI control codes, so
63
+ log output remains grep-stable.
64
+
60
65
  When `nomad push` detects a potential secret, it drops into an interactive menu (TTY) or aborts with
61
66
  a recovery hint (non-TTY/CI). Three non-interactive recovery paths are available without the menu:
62
67
 
package/dist/nomad.mjs CHANGED
@@ -2342,7 +2342,7 @@ init_color();
2342
2342
  import { execFileSync as execFileSync8 } from "node:child_process";
2343
2343
  var VERSION_TOKEN = /(\d{1,9}\.\d{1,9}\.\d{1,9})/;
2344
2344
  var PROBE_TIMEOUT_MS = 3e3;
2345
- var FETCHER_LABEL = "HTTP fetcher (curl or wget)";
2345
+ var FETCHER_BASE = "HTTP fetcher";
2346
2346
  function parseFirstVersion(line) {
2347
2347
  const m = VERSION_TOKEN.exec(line);
2348
2348
  return m ? m[1] : null;
@@ -2366,13 +2366,13 @@ function reportFetcherRow(section2, run) {
2366
2366
  const curl = probeOptionalDep("curl", run);
2367
2367
  const wget = probeOptionalDep("wget", run);
2368
2368
  if (curl.status === "present") {
2369
- addItem(section2, `${green(okGlyph)} ${FETCHER_LABEL}: ${curl.version ?? "present"}`);
2369
+ addItem(section2, `${green(okGlyph)} ${FETCHER_BASE}: curl ${curl.version ?? "(present)"}`);
2370
2370
  } else if (wget.status === "present") {
2371
- addItem(section2, `${green(okGlyph)} ${FETCHER_LABEL}: ${wget.version ?? "present"}`);
2371
+ addItem(section2, `${green(okGlyph)} ${FETCHER_BASE}: wget ${wget.version ?? "(present)"}`);
2372
2372
  } else {
2373
2373
  addItem(
2374
2374
  section2,
2375
- `${yellow(warnGlyph)} ${FETCHER_LABEL}: not installed (optional; needed for release-version staleness check + nomad doctor --check-schema)`
2375
+ `${yellow(warnGlyph)} ${FETCHER_BASE} (curl or wget): not installed (optional; needed for release-version staleness check + nomad doctor --check-schema)`
2376
2376
  );
2377
2377
  }
2378
2378
  }
@@ -2513,18 +2513,20 @@ function cmdDoctor(opts = {}) {
2513
2513
  reportRemote(repository);
2514
2514
  reportRebaseClean(repository);
2515
2515
  reportActionsDrift(repository);
2516
- const version = section("Version Checks");
2517
- reportVersionCheck(version);
2518
- reportNodeEngineCheck(version);
2519
- reportGitleaksVersionCheck(version);
2520
- reportOptionalDeps(version);
2521
- reportBackupsCheck(version);
2516
+ const nomadVersion = section("Nomad Version");
2517
+ reportVersionCheck(nomadVersion);
2518
+ reportBackupsCheck(nomadVersion);
2519
+ const depVersions = section("Dependency Versions");
2520
+ reportNodeEngineCheck(depVersions);
2521
+ reportGitleaksVersionCheck(depVersions);
2522
+ reportOptionalDeps(depVersions);
2522
2523
  const sharedScan = section("Shared scan");
2523
2524
  if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
2524
2525
  const schemaScan = section("Schema scan");
2525
2526
  if (opts.checkSchema === true) reportCheckSchema(schemaScan);
2526
2527
  renderDoctor([
2527
- version,
2528
+ nomadVersion,
2529
+ depVersions,
2528
2530
  host,
2529
2531
  links,
2530
2532
  hooksScan,
@@ -2954,8 +2956,8 @@ ${lines}`);
2954
2956
  }
2955
2957
 
2956
2958
  // src/commands.pull.ts
2957
- import { existsSync as existsSync28, mkdirSync as mkdirSync7 } from "node:fs";
2958
- import { join as join33 } from "node:path";
2959
+ import { existsSync as existsSync30, mkdirSync as mkdirSync8 } from "node:fs";
2960
+ import { join as join36 } from "node:path";
2959
2961
 
2960
2962
  // src/commands.push.sections.ts
2961
2963
  init_color();
@@ -3631,156 +3633,23 @@ function computePreview(ts, map) {
3631
3633
  return { unmapped: remapResult.unmapped, collisions: 0 };
3632
3634
  }
3633
3635
 
3634
- // src/commands.pull.ts
3635
- init_utils();
3636
- init_utils_fs();
3637
- init_utils_json();
3638
- function applyWetPull(ts, map) {
3639
- applySharedLinks(ts, map);
3640
- const { label } = regenerateSettings(ts);
3641
- const remapResult = remapPull(ts);
3642
- const extrasResult = remapExtrasPull(ts);
3643
- const summary = section("Summary");
3644
- addItem(
3645
- summary,
3646
- summaryRow("pull", remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped)
3647
- );
3648
- renderTree([
3649
- buildSettingsSection(label),
3650
- buildSessionsSection(remapResult.pulled, remapResult.unmapped),
3651
- buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
3652
- summary
3653
- ]);
3654
- }
3655
- function cmdPull(opts = {}) {
3656
- const dryRun = opts.dryRun === true;
3657
- if (!existsSync28(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
3658
- if (!existsSync28(join33(REPO_HOME, "shared", "settings.base.json"))) {
3659
- die("repo not initialized; run 'nomad init' to scaffold");
3660
- }
3661
- const handle = acquireLock("pull");
3662
- if (handle === null) process.exit(0);
3663
- try {
3664
- const ts = freshBackupTs(BACKUP_BASE);
3665
- if (!dryRun) {
3666
- const backupRoot = join33(BACKUP_BASE, ts);
3667
- try {
3668
- mkdirSync7(backupRoot, { recursive: true });
3669
- } catch (err) {
3670
- die(`could not create backup dir: ${err.message}`);
3671
- }
3672
- }
3673
- log(
3674
- dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
3675
- );
3676
- gitOrFatal(["pull", "--rebase", "--autostash"], "git pull --rebase", REPO_HOME);
3677
- const mapPath = join33(REPO_HOME, "path-map.json");
3678
- const map = existsSync28(mapPath) ? readPathMap(mapPath) : { projects: {} };
3679
- divergenceCheckExtras(ts);
3680
- if (dryRun) {
3681
- const previewResult = computePreview(ts, map);
3682
- log("dry-run complete; no mutation");
3683
- emitSummary("pull", previewResult.unmapped);
3684
- } else {
3685
- applyWetPull(ts, map);
3686
- }
3687
- } catch (err) {
3688
- if (err instanceof NomadFatal) {
3689
- fail(err.message);
3690
- process.exitCode = 1;
3691
- } else {
3692
- throw err;
3693
- }
3694
- } finally {
3695
- releaseLock(handle);
3696
- }
3697
- }
3698
-
3699
- // src/commands.push.ts
3700
- init_config();
3701
- import { existsSync as existsSync31 } from "node:fs";
3702
- import { join as join38, relative as relative5 } from "node:path";
3703
-
3704
- // src/commands.push.allowlist.ts
3705
- init_config();
3706
- init_config_sharedDirs_guard();
3707
- init_utils();
3708
- function isAllowed(path, allowed) {
3709
- for (const entry of allowed) {
3710
- if (path === entry) return true;
3711
- if (entry === "hosts/") {
3712
- if (/^hosts\/[^/]+\.json$/.test(path)) return true;
3713
- continue;
3714
- }
3715
- if (entry.endsWith("/") && path.startsWith(entry)) return true;
3716
- }
3717
- return false;
3718
- }
3719
- function isNeverSync(path) {
3720
- const blockSet = path.startsWith("shared/extras/") ? ALWAYS_NEVER_SYNC : NEVER_SYNC;
3721
- for (const segment of path.split("/")) {
3722
- if (blockSet.has(segment)) return true;
3723
- }
3724
- return false;
3725
- }
3726
- function parsePorcelainZ(statusPorcelain) {
3727
- const records = statusPorcelain.split("\0");
3728
- const paths = [];
3729
- for (let i = 0; i < records.length; i++) {
3730
- const rec = records[i];
3731
- if (rec === void 0 || rec === "") continue;
3732
- if (rec.length < 4) continue;
3733
- const xy = rec.slice(0, 2);
3734
- const newPath = rec.slice(3);
3735
- paths.push(newPath);
3736
- if (/[RC]/.test(xy)) {
3737
- const oldPath = records[i + 1];
3738
- if (oldPath !== void 0 && oldPath !== "") paths.push(oldPath);
3739
- i++;
3740
- }
3741
- }
3742
- return paths;
3743
- }
3744
- function enforceAllowList(statusPorcelain, map) {
3745
- const extrasWhitelist = SUPPORTED_EXTRAS;
3746
- const allowed = [
3747
- ...PUSH_ALLOWED_STATIC,
3748
- ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
3749
- ...Object.entries(map.extras ?? {}).flatMap(
3750
- ([l, names]) => names.filter((n) => extrasWhitelist.includes(n)).flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`])
3751
- ),
3752
- ...(map.sharedDirs ?? []).filter((d) => isValidSharedDir(d)).map((d) => `shared/${d}/`)
3753
- ];
3754
- const neverSyncHits = [];
3755
- const violations = [];
3756
- for (const path of parsePorcelainZ(statusPorcelain)) {
3757
- if (isNeverSync(path)) {
3758
- neverSyncHits.push(path);
3759
- } else if (!isAllowed(path, allowed)) {
3760
- violations.push(path);
3761
- }
3762
- }
3763
- if (neverSyncHits.length === 0 && violations.length === 0) return;
3764
- for (const p of neverSyncHits) {
3765
- fail(`${p} is in NEVER_SYNC and must never be pushed`);
3766
- }
3767
- for (const p of violations) {
3768
- fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
3769
- }
3770
- throw new NomadFatal("push allow-list violations");
3771
- }
3636
+ // src/spinner.ts
3637
+ init_color();
3638
+ import { existsSync as existsSync29 } from "node:fs";
3639
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
3640
+ import { Worker } from "node:worker_threads";
3772
3641
 
3773
3642
  // src/commands.push.recovery.ts
3774
3643
  init_config();
3775
3644
  import { readFileSync as readFileSync10, rmSync as rmSync10, writeFileSync as writeFileSync5 } from "node:fs";
3776
- import { join as join36 } from "node:path";
3645
+ import { join as join35 } from "node:path";
3777
3646
  import { createInterface } from "node:readline/promises";
3778
3647
 
3779
3648
  // src/commands.push.recovery.redact.ts
3780
3649
  init_config();
3781
3650
  init_config_sharedDirs_guard();
3782
- import { cpSync as cpSync5, existsSync as existsSync29, mkdirSync as mkdirSync8, statSync as statSync8 } from "node:fs";
3783
- import { dirname as dirname5, join as join34, sep as sep2 } from "node:path";
3651
+ import { cpSync as cpSync5, existsSync as existsSync28, mkdirSync as mkdirSync7, statSync as statSync8 } from "node:fs";
3652
+ import { dirname as dirname5, join as join33, sep as sep2 } from "node:path";
3784
3653
  init_push_gitleaks_scan();
3785
3654
  init_utils_json();
3786
3655
  init_utils();
@@ -3811,8 +3680,8 @@ function resolveStagedDir(localPath, map) {
3811
3680
  assertSafeLogical(logical);
3812
3681
  const abs = hostMap[HOST];
3813
3682
  if (abs === void 0) continue;
3814
- if (localPath.startsWith(join34(CLAUDE_HOME, "projects", encodePath(abs)) + sep2)) {
3815
- return join34(REPO_HOME, "shared", "projects", logical);
3683
+ if (localPath.startsWith(join33(CLAUDE_HOME, "projects", encodePath(abs)) + sep2)) {
3684
+ return join33(REPO_HOME, "shared", "projects", logical);
3816
3685
  }
3817
3686
  }
3818
3687
  return null;
@@ -3834,7 +3703,7 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
3834
3703
  `could not locate the local transcript for session ${sid}; choose Skip or Drop session.`
3835
3704
  );
3836
3705
  }
3837
- const sessionDir = join34(dirname5(localPath), sid);
3706
+ const sessionDir = join33(dirname5(localPath), sid);
3838
3707
  const subtreeFiles = listSubtreeFiles(sessionDir);
3839
3708
  const subtreeMtime = newestSubtreeMtimeMs(localPath, subtreeFiles, (p) => statSync8(p).mtimeMs);
3840
3709
  if (isRecentlyModified(subtreeMtime, nowMs())) {
@@ -3867,10 +3736,10 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
3867
3736
  `nothing to redact in the local transcript for session ${sid}; choose Skip or Drop session.`
3868
3737
  );
3869
3738
  }
3870
- mkdirSync8(stagedProjectDir, { recursive: true });
3871
- cpSync5(localPath, join34(stagedProjectDir, `${sid}.jsonl`), { force: true });
3872
- if (existsSync29(sessionDir)) {
3873
- cpSync5(sessionDir, join34(stagedProjectDir, sid), { force: true, recursive: true });
3739
+ mkdirSync7(stagedProjectDir, { recursive: true });
3740
+ cpSync5(localPath, join33(stagedProjectDir, `${sid}.jsonl`), { force: true });
3741
+ if (existsSync28(sessionDir)) {
3742
+ cpSync5(sessionDir, join33(stagedProjectDir, sid), { force: true, recursive: true });
3874
3743
  }
3875
3744
  return true;
3876
3745
  }
@@ -3878,13 +3747,13 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
3878
3747
  // src/commands.push.recovery.drop.ts
3879
3748
  init_config();
3880
3749
  import { rmSync as rmSync9 } from "node:fs";
3881
- import { join as join35 } from "node:path";
3750
+ import { join as join34 } from "node:path";
3882
3751
  function dropSessionFromStaged(sid, map) {
3883
3752
  const logicals = Object.keys(map.projects);
3884
3753
  if (logicals.length === 0) return false;
3885
3754
  for (const logical of logicals) {
3886
- const jsonl = join35(REPO_HOME, "shared", "projects", logical, `${sid}.jsonl`);
3887
- const dir = join35(REPO_HOME, "shared", "projects", logical, sid);
3755
+ const jsonl = join34(REPO_HOME, "shared", "projects", logical, `${sid}.jsonl`);
3756
+ const dir = join34(REPO_HOME, "shared", "projects", logical, sid);
3888
3757
  rmSync9(jsonl, { force: true });
3889
3758
  rmSync9(dir, { recursive: true, force: true });
3890
3759
  }
@@ -4002,7 +3871,7 @@ function applyThenRescan(scanVerdict, repoHome) {
4002
3871
  return next;
4003
3872
  }
4004
3873
  function allowThenRescan(append, scanVerdict, repoHome) {
4005
- const ignPath = join36(repoHome, ".gitleaksignore");
3874
+ const ignPath = join35(repoHome, ".gitleaksignore");
4006
3875
  let before;
4007
3876
  try {
4008
3877
  before = readFileSync10(ignPath, "utf8");
@@ -4081,6 +3950,225 @@ async function resolveLeakFindings(verdict, ts, map, deps = {}) {
4081
3950
  return current;
4082
3951
  }
4083
3952
 
3953
+ // src/spinner.ts
3954
+ function formatElapsed(ms) {
3955
+ return `${(ms / 1e3).toFixed(1)}s`;
3956
+ }
3957
+ function writePlainStart(out, label) {
3958
+ out.write(`${label}...
3959
+ `);
3960
+ }
3961
+ function writePlainDone(out, label, ms) {
3962
+ out.write(`${label} done (${formatElapsed(ms)})
3963
+ `);
3964
+ }
3965
+ function writeAnimatedDone(out, label, ms, useTTY) {
3966
+ out.write("\r\x1B[K");
3967
+ const glyph = useTTY ? green(okGlyph) : okGlyph;
3968
+ out.write(`${glyph} ${label} (${formatElapsed(ms)})
3969
+ `);
3970
+ }
3971
+ function resolveWorkerPath(deps = {}) {
3972
+ const check = deps.existsSyncFn ?? existsSync29;
3973
+ const base = deps.baseUrl ?? import.meta.url;
3974
+ const mjs = fileURLToPath4(new URL("./nomad.worker.mjs", base));
3975
+ if (check(mjs)) return mjs;
3976
+ return fileURLToPath4(new URL("./spinner.worker.ts", base));
3977
+ }
3978
+ function makeRealWorker() {
3979
+ return new Worker(resolveWorkerPath());
3980
+ }
3981
+ function startSpinner(label, deps = {}) {
3982
+ const ttyCheck = deps.isTTYCheck ?? (() => isTTY());
3983
+ const env = deps.env ?? process.env;
3984
+ const out = deps.out ?? process.stderr;
3985
+ const now = deps.now ?? Date.now;
3986
+ const startMs = now();
3987
+ const animate = ttyCheck() && !env.CI;
3988
+ let worker = null;
3989
+ let degraded = false;
3990
+ let finalized = false;
3991
+ if (animate) {
3992
+ const factory = deps.makeWorker ?? makeRealWorker;
3993
+ try {
3994
+ worker = factory();
3995
+ worker.unref?.();
3996
+ worker.postMessage({ type: "start", label });
3997
+ } catch {
3998
+ degraded = true;
3999
+ worker = null;
4000
+ writePlainStart(out, label);
4001
+ }
4002
+ } else {
4003
+ writePlainStart(out, label);
4004
+ }
4005
+ function finalize(success, doneLabel) {
4006
+ if (finalized) return;
4007
+ finalized = true;
4008
+ const dl = doneLabel ?? label;
4009
+ const elapsed = now() - startMs;
4010
+ if (animate && !degraded && worker !== null) {
4011
+ worker.postMessage({ type: "pause" });
4012
+ if (success) writeAnimatedDone(out, dl, elapsed, ttyCheck());
4013
+ else out.write("\r\x1B[K");
4014
+ worker.terminate();
4015
+ worker = null;
4016
+ } else if (success) {
4017
+ writePlainDone(out, dl, elapsed);
4018
+ }
4019
+ }
4020
+ return {
4021
+ succeed: (doneLabel) => finalize(true, doneLabel),
4022
+ stop: () => finalize(false)
4023
+ };
4024
+ }
4025
+
4026
+ // src/commands.pull.ts
4027
+ init_utils();
4028
+ init_utils_fs();
4029
+ init_utils_json();
4030
+ function applyWetPull(ts, map) {
4031
+ applySharedLinks(ts, map);
4032
+ const { label } = regenerateSettings(ts);
4033
+ const syncSp = startSpinner("Syncing sessions");
4034
+ let remapResult;
4035
+ try {
4036
+ remapResult = remapPull(ts);
4037
+ syncSp.succeed();
4038
+ } finally {
4039
+ syncSp.stop();
4040
+ }
4041
+ const extrasResult = remapExtrasPull(ts);
4042
+ const summary = section("Summary");
4043
+ addItem(
4044
+ summary,
4045
+ summaryRow("pull", remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped)
4046
+ );
4047
+ renderTree([
4048
+ buildSettingsSection(label),
4049
+ buildSessionsSection(remapResult.pulled, remapResult.unmapped),
4050
+ buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
4051
+ summary
4052
+ ]);
4053
+ }
4054
+ function cmdPull(opts = {}) {
4055
+ const dryRun = opts.dryRun === true;
4056
+ if (!existsSync30(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4057
+ if (!existsSync30(join36(REPO_HOME, "shared", "settings.base.json"))) {
4058
+ die("repo not initialized; run 'nomad init' to scaffold");
4059
+ }
4060
+ const handle = acquireLock("pull");
4061
+ if (handle === null) process.exit(0);
4062
+ try {
4063
+ const ts = freshBackupTs(BACKUP_BASE);
4064
+ if (!dryRun) {
4065
+ const backupRoot = join36(BACKUP_BASE, ts);
4066
+ try {
4067
+ mkdirSync8(backupRoot, { recursive: true });
4068
+ } catch (err) {
4069
+ die(`could not create backup dir: ${err.message}`);
4070
+ }
4071
+ }
4072
+ log(
4073
+ dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
4074
+ );
4075
+ gitOrFatal(["pull", "--rebase", "--autostash"], "git pull --rebase", REPO_HOME);
4076
+ const mapPath = join36(REPO_HOME, "path-map.json");
4077
+ const map = existsSync30(mapPath) ? readPathMap(mapPath) : { projects: {} };
4078
+ divergenceCheckExtras(ts);
4079
+ if (dryRun) {
4080
+ const previewResult = computePreview(ts, map);
4081
+ log("dry-run complete; no mutation");
4082
+ emitSummary("pull", previewResult.unmapped);
4083
+ } else {
4084
+ applyWetPull(ts, map);
4085
+ }
4086
+ } catch (err) {
4087
+ if (err instanceof NomadFatal) {
4088
+ fail(err.message);
4089
+ process.exitCode = 1;
4090
+ } else {
4091
+ throw err;
4092
+ }
4093
+ } finally {
4094
+ releaseLock(handle);
4095
+ }
4096
+ }
4097
+
4098
+ // src/commands.push.ts
4099
+ init_config();
4100
+ import { existsSync as existsSync32 } from "node:fs";
4101
+ import { join as join38, relative as relative5 } from "node:path";
4102
+
4103
+ // src/commands.push.allowlist.ts
4104
+ init_config();
4105
+ init_config_sharedDirs_guard();
4106
+ init_utils();
4107
+ function isAllowed(path, allowed) {
4108
+ for (const entry of allowed) {
4109
+ if (path === entry) return true;
4110
+ if (entry === "hosts/") {
4111
+ if (/^hosts\/[^/]+\.json$/.test(path)) return true;
4112
+ continue;
4113
+ }
4114
+ if (entry.endsWith("/") && path.startsWith(entry)) return true;
4115
+ }
4116
+ return false;
4117
+ }
4118
+ function isNeverSync(path) {
4119
+ const blockSet = path.startsWith("shared/extras/") ? ALWAYS_NEVER_SYNC : NEVER_SYNC;
4120
+ for (const segment of path.split("/")) {
4121
+ if (blockSet.has(segment)) return true;
4122
+ }
4123
+ return false;
4124
+ }
4125
+ function parsePorcelainZ(statusPorcelain) {
4126
+ const records = statusPorcelain.split("\0");
4127
+ const paths = [];
4128
+ for (let i = 0; i < records.length; i++) {
4129
+ const rec = records[i];
4130
+ if (rec === void 0 || rec === "") continue;
4131
+ if (rec.length < 4) continue;
4132
+ const xy = rec.slice(0, 2);
4133
+ const newPath = rec.slice(3);
4134
+ paths.push(newPath);
4135
+ if (/[RC]/.test(xy)) {
4136
+ const oldPath = records[i + 1];
4137
+ if (oldPath !== void 0 && oldPath !== "") paths.push(oldPath);
4138
+ i++;
4139
+ }
4140
+ }
4141
+ return paths;
4142
+ }
4143
+ function enforceAllowList(statusPorcelain, map) {
4144
+ const extrasWhitelist = SUPPORTED_EXTRAS;
4145
+ const allowed = [
4146
+ ...PUSH_ALLOWED_STATIC,
4147
+ ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
4148
+ ...Object.entries(map.extras ?? {}).flatMap(
4149
+ ([l, names]) => names.filter((n) => extrasWhitelist.includes(n)).flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`])
4150
+ ),
4151
+ ...(map.sharedDirs ?? []).filter((d) => isValidSharedDir(d)).map((d) => `shared/${d}/`)
4152
+ ];
4153
+ const neverSyncHits = [];
4154
+ const violations = [];
4155
+ for (const path of parsePorcelainZ(statusPorcelain)) {
4156
+ if (isNeverSync(path)) {
4157
+ neverSyncHits.push(path);
4158
+ } else if (!isAllowed(path, allowed)) {
4159
+ violations.push(path);
4160
+ }
4161
+ }
4162
+ if (neverSyncHits.length === 0 && violations.length === 0) return;
4163
+ for (const p of neverSyncHits) {
4164
+ fail(`${p} is in NEVER_SYNC and must never be pushed`);
4165
+ }
4166
+ for (const p of violations) {
4167
+ fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
4168
+ }
4169
+ throw new NomadFatal("push allow-list violations");
4170
+ }
4171
+
4084
4172
  // src/commands.push.ts
4085
4173
  init_push_leak_verdict();
4086
4174
  init_push_checks();
@@ -4090,7 +4178,7 @@ init_color();
4090
4178
  init_config();
4091
4179
  init_config_sharedDirs_guard();
4092
4180
  import { randomBytes as randomBytes2 } from "node:crypto";
4093
- import { copyFileSync, existsSync as existsSync30, mkdirSync as mkdirSync9, readdirSync as readdirSync10, rmSync as rmSync11 } from "node:fs";
4181
+ import { copyFileSync, existsSync as existsSync31, mkdirSync as mkdirSync9, readdirSync as readdirSync10, rmSync as rmSync11 } from "node:fs";
4094
4182
  import { homedir as homedir5 } from "node:os";
4095
4183
  import { join as join37 } from "node:path";
4096
4184
  init_push_leak_verdict();
@@ -4108,7 +4196,7 @@ function stageSessions(tmpRoot, map) {
4108
4196
  reverse.set(encodePath(p), logical);
4109
4197
  }
4110
4198
  const localProjects = join37(CLAUDE_HOME, "projects");
4111
- if (!existsSync30(localProjects)) return 0;
4199
+ if (!existsSync31(localProjects)) return 0;
4112
4200
  let staged = 0;
4113
4201
  for (const dir of readdirSync10(localProjects)) {
4114
4202
  const logical = reverse.get(dir);
@@ -4130,7 +4218,7 @@ function stageExtras(tmpRoot, map) {
4130
4218
  for (const dirname6 of dirnames) {
4131
4219
  if (!whitelist.includes(dirname6)) continue;
4132
4220
  const src = join37(localRoot, dirname6);
4133
- if (!existsSync30(src)) continue;
4221
+ if (!existsSync31(src)) continue;
4134
4222
  const dst = join37(tmpRoot, "shared", "extras", logical, dirname6);
4135
4223
  copyExtras(src, dst);
4136
4224
  staged++;
@@ -4150,7 +4238,7 @@ function previewPushLeaks(map) {
4150
4238
  return { leak: false, verdictRow: NOTHING_TO_SCAN_ROW, recovery: null, findings: [] };
4151
4239
  }
4152
4240
  const ignoreFile = join37(REPO_HOME, ".gitleaksignore");
4153
- if (existsSync30(ignoreFile)) {
4241
+ if (existsSync31(ignoreFile)) {
4154
4242
  copyFileSync(ignoreFile, join37(tmpRoot, ".gitleaksignore"));
4155
4243
  }
4156
4244
  let findings;
@@ -4184,15 +4272,34 @@ function guardGitlinks() {
4184
4272
  `gitlink trap: ${gitlinks.length} nested .git ${noun} in shared/; remove before retry`
4185
4273
  );
4186
4274
  }
4275
+ function runScan() {
4276
+ const sp = startSpinner("Scanning for secrets");
4277
+ try {
4278
+ const verdict = scanPushVerdict();
4279
+ sp.succeed();
4280
+ return verdict;
4281
+ } finally {
4282
+ sp.stop();
4283
+ }
4284
+ }
4285
+ function runPush() {
4286
+ const sp = startSpinner("Pushing");
4287
+ try {
4288
+ gitOrFatal(["push"], "git push", REPO_HOME);
4289
+ sp.succeed();
4290
+ } finally {
4291
+ sp.stop();
4292
+ }
4293
+ }
4187
4294
  async function commitAndPush(st, ts, map, redactAll, allowAll, allowRule) {
4188
4295
  gitOrFatal(["add", "-A"], "git add", REPO_HOME);
4189
- let verdict = scanPushVerdict();
4296
+ let verdict = runScan();
4190
4297
  if (verdict.leak) {
4191
4298
  renderPushTree(st, verdict);
4192
4299
  verdict = await resolveLeakFindings(verdict, ts, map, { redactAll, allowAll, allowRule });
4193
4300
  }
4194
4301
  gitOrFatal(["commit", "-m", `chore: sync from ${HOST}`], "git commit", REPO_HOME);
4195
- gitOrFatal(["push"], "git push", REPO_HOME);
4302
+ runPush();
4196
4303
  renderPushTree(st, verdict);
4197
4304
  }
4198
4305
  function runDryRunPreview(st, map) {
@@ -4225,13 +4332,19 @@ async function cmdPush(opts = {}) {
4225
4332
  const allowAll = opts.allowAll === true;
4226
4333
  const allowRule = opts.allowRule;
4227
4334
  guardResolutionModeConflicts(dryRun, redactAll, allowAll, allowRule);
4228
- if (!existsSync31(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4335
+ if (!existsSync32(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4229
4336
  const handle = acquireLock("push");
4230
4337
  if (handle === null) process.exit(0);
4231
4338
  try {
4232
4339
  console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
4233
4340
  probeGitleaks();
4234
- rebaseBeforePush();
4341
+ const rebaseSp = startSpinner("Rebasing onto origin");
4342
+ try {
4343
+ rebaseBeforePush();
4344
+ rebaseSp.succeed();
4345
+ } finally {
4346
+ rebaseSp.stop();
4347
+ }
4235
4348
  const ts = freshBackupTs(BACKUP_BASE);
4236
4349
  const remap = remapPush(ts, { dryRun });
4237
4350
  const extras = remapExtrasPush(ts, { dryRun });
@@ -4244,7 +4357,7 @@ async function cmdPush(opts = {}) {
4244
4357
  return;
4245
4358
  }
4246
4359
  const mapPath = join38(REPO_HOME, "path-map.json");
4247
- if (!existsSync31(mapPath)) {
4360
+ if (!existsSync32(mapPath)) {
4248
4361
  if (dryRun) return runDryRunPreview(st, null);
4249
4362
  die("path-map.json missing, cannot enforce push allow-list");
4250
4363
  }
@@ -4284,17 +4397,17 @@ init_config();
4284
4397
 
4285
4398
  // src/diff.ts
4286
4399
  init_config();
4287
- import { existsSync as existsSync32 } from "node:fs";
4400
+ import { existsSync as existsSync33 } from "node:fs";
4288
4401
  import { join as join39 } from "node:path";
4289
4402
  init_utils();
4290
4403
  init_utils_fs();
4291
4404
  init_utils_json();
4292
4405
  function cmdDiff() {
4293
4406
  try {
4294
- if (!existsSync32(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4407
+ if (!existsSync33(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4295
4408
  const ts = freshBackupTs(BACKUP_BASE);
4296
4409
  const mapPath = join39(REPO_HOME, "path-map.json");
4297
- const map = existsSync32(mapPath) ? readPathMap(mapPath) : { projects: {} };
4410
+ const map = existsSync33(mapPath) ? readPathMap(mapPath) : { projects: {} };
4298
4411
  const result = computePreview(ts, map);
4299
4412
  emitSummary("diff", result.unmapped);
4300
4413
  } catch (err) {
@@ -4309,7 +4422,7 @@ function cmdDiff() {
4309
4422
 
4310
4423
  // src/init.ts
4311
4424
  init_config();
4312
- import { existsSync as existsSync34, mkdirSync as mkdirSync10, writeFileSync as writeFileSync6 } from "node:fs";
4425
+ import { existsSync as existsSync35, mkdirSync as mkdirSync10, writeFileSync as writeFileSync6 } from "node:fs";
4313
4426
  import { join as join41 } from "node:path";
4314
4427
 
4315
4428
  // src/init.gh-onboard.ts
@@ -4391,16 +4504,16 @@ init_config();
4391
4504
  init_utils();
4392
4505
  init_utils_fs();
4393
4506
  init_utils_json();
4394
- import { copyFileSync as copyFileSync2, cpSync as cpSync6, existsSync as existsSync33, rmSync as rmSync12, statSync as statSync9 } from "node:fs";
4507
+ import { copyFileSync as copyFileSync2, cpSync as cpSync6, existsSync as existsSync34, rmSync as rmSync12, statSync as statSync9 } from "node:fs";
4395
4508
  import { join as join40 } from "node:path";
4396
4509
  function snapshotIntoShared(map) {
4397
4510
  for (const name of allSharedLinks(map)) {
4398
4511
  const src = join40(CLAUDE_HOME, name);
4399
- if (!existsSync33(src)) continue;
4512
+ if (!existsSync34(src)) continue;
4400
4513
  const dst = join40(REPO_HOME, "shared", name);
4401
4514
  if (statSync9(src).isDirectory()) {
4402
4515
  const gk = join40(dst, ".gitkeep");
4403
- if (existsSync33(gk)) rmSync12(gk);
4516
+ if (existsSync34(gk)) rmSync12(gk);
4404
4517
  cpSync6(src, dst, { recursive: true, force: false, errorOnExist: true });
4405
4518
  } else {
4406
4519
  copyFileSync2(src, dst);
@@ -4408,7 +4521,7 @@ function snapshotIntoShared(map) {
4408
4521
  log(`snapshotted shared/${name} from ${src}`);
4409
4522
  }
4410
4523
  const userSettings = join40(CLAUDE_HOME, "settings.json");
4411
- if (existsSync33(userSettings)) {
4524
+ if (existsSync34(userSettings)) {
4412
4525
  let parsed;
4413
4526
  try {
4414
4527
  parsed = readJson(userSettings);
@@ -4435,7 +4548,7 @@ function preflightConflict(repoHome) {
4435
4548
  join41(repoHome, "shared")
4436
4549
  ];
4437
4550
  for (const c of candidates) {
4438
- if (existsSync34(c)) return c;
4551
+ if (existsSync35(c)) return c;
4439
4552
  }
4440
4553
  return null;
4441
4554
  }
@@ -4454,7 +4567,7 @@ function cmdInit(opts = {}) {
4454
4567
  mkdirSync10(join41(REPO_HOME, "shared", name), { recursive: true });
4455
4568
  }
4456
4569
  const userClaudeMd = join41(CLAUDE_HOME, "CLAUDE.md");
4457
- if (!snapshot || !existsSync34(userClaudeMd)) {
4570
+ if (!snapshot || !existsSync35(userClaudeMd)) {
4458
4571
  writeFileSync6(join41(REPO_HOME, "shared", "CLAUDE.md"), SHARED_CLAUDE_MD);
4459
4572
  log("created shared/CLAUDE.md");
4460
4573
  }
@@ -4728,7 +4841,7 @@ function parsePushArgs(argv) {
4728
4841
  // package.json
4729
4842
  var package_default = {
4730
4843
  name: "claude-nomad",
4731
- version: "0.38.1",
4844
+ version: "0.40.0",
4732
4845
  type: "module",
4733
4846
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
4734
4847
  keywords: [
@@ -4918,7 +5031,7 @@ var DEFAULT_HELP = [
4918
5031
  init_config();
4919
5032
  init_utils();
4920
5033
  init_utils_json();
4921
- import { existsSync as existsSync35, readFileSync as readFileSync11, readdirSync as readdirSync11 } from "node:fs";
5034
+ import { existsSync as existsSync36, readFileSync as readFileSync11, readdirSync as readdirSync11 } from "node:fs";
4922
5035
  import { join as join42 } from "node:path";
4923
5036
  function resumeCmd(sessionId) {
4924
5037
  if (!/^[A-Za-z0-9_-]+$/.test(sessionId) || sessionId.length > 128) {
@@ -4926,7 +5039,7 @@ function resumeCmd(sessionId) {
4926
5039
  process.exit(1);
4927
5040
  }
4928
5041
  const projectsRoot = join42(CLAUDE_HOME, "projects");
4929
- if (!existsSync35(projectsRoot)) {
5042
+ if (!existsSync36(projectsRoot)) {
4930
5043
  fail(`${projectsRoot} does not exist`);
4931
5044
  process.exit(1);
4932
5045
  }
@@ -4941,7 +5054,7 @@ function resumeCmd(sessionId) {
4941
5054
  process.exit(1);
4942
5055
  }
4943
5056
  const mapPath = join42(REPO_HOME, "path-map.json");
4944
- if (!existsSync35(mapPath)) {
5057
+ if (!existsSync36(mapPath)) {
4945
5058
  fail("path-map.json missing");
4946
5059
  process.exit(1);
4947
5060
  }
@@ -4965,7 +5078,7 @@ function resumeCmd(sessionId) {
4965
5078
  function findTranscriptPath(projectsRoot, sessionId) {
4966
5079
  for (const dir of readdirSync11(projectsRoot)) {
4967
5080
  const candidate = join42(projectsRoot, dir, `${sessionId}.jsonl`);
4968
- if (existsSync35(candidate)) return candidate;
5081
+ if (existsSync36(candidate)) return candidate;
4969
5082
  }
4970
5083
  return null;
4971
5084
  }
@@ -0,0 +1,24 @@
1
+ // src/spinner.worker.ts
2
+ import { parentPort } from "node:worker_threads";
3
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
4
+ var INTERVAL_MS = 80;
5
+ var frame = 0;
6
+ var timer = null;
7
+ if (parentPort !== null) {
8
+ parentPort.on("message", (msg) => {
9
+ if (msg.type === "start" && msg.label !== void 0) {
10
+ const label = msg.label;
11
+ frame = 0;
12
+ if (timer !== null) clearInterval(timer);
13
+ timer = setInterval(() => {
14
+ process.stderr.write(`${FRAMES[frame % FRAMES.length]} ${label}\r`);
15
+ frame++;
16
+ }, INTERVAL_MS);
17
+ } else if (msg.type === "pause") {
18
+ if (timer !== null) {
19
+ clearInterval(timer);
20
+ timer = null;
21
+ }
22
+ }
23
+ });
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.38.1",
3
+ "version": "0.40.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": [