claude-nomad 0.39.0 → 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,17 @@
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
+
3
15
  ## [0.39.0](https://github.com/funkadelic/claude-nomad/compare/v0.38.1...v0.39.0) (2026-06-02)
4
16
 
5
17
 
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
@@ -2956,8 +2956,8 @@ ${lines}`);
2956
2956
  }
2957
2957
 
2958
2958
  // src/commands.pull.ts
2959
- import { existsSync as existsSync28, mkdirSync as mkdirSync7 } from "node:fs";
2960
- 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";
2961
2961
 
2962
2962
  // src/commands.push.sections.ts
2963
2963
  init_color();
@@ -3633,156 +3633,23 @@ function computePreview(ts, map) {
3633
3633
  return { unmapped: remapResult.unmapped, collisions: 0 };
3634
3634
  }
3635
3635
 
3636
- // src/commands.pull.ts
3637
- init_utils();
3638
- init_utils_fs();
3639
- init_utils_json();
3640
- function applyWetPull(ts, map) {
3641
- applySharedLinks(ts, map);
3642
- const { label } = regenerateSettings(ts);
3643
- const remapResult = remapPull(ts);
3644
- const extrasResult = remapExtrasPull(ts);
3645
- const summary = section("Summary");
3646
- addItem(
3647
- summary,
3648
- summaryRow("pull", remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped)
3649
- );
3650
- renderTree([
3651
- buildSettingsSection(label),
3652
- buildSessionsSection(remapResult.pulled, remapResult.unmapped),
3653
- buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
3654
- summary
3655
- ]);
3656
- }
3657
- function cmdPull(opts = {}) {
3658
- const dryRun = opts.dryRun === true;
3659
- if (!existsSync28(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
3660
- if (!existsSync28(join33(REPO_HOME, "shared", "settings.base.json"))) {
3661
- die("repo not initialized; run 'nomad init' to scaffold");
3662
- }
3663
- const handle = acquireLock("pull");
3664
- if (handle === null) process.exit(0);
3665
- try {
3666
- const ts = freshBackupTs(BACKUP_BASE);
3667
- if (!dryRun) {
3668
- const backupRoot = join33(BACKUP_BASE, ts);
3669
- try {
3670
- mkdirSync7(backupRoot, { recursive: true });
3671
- } catch (err) {
3672
- die(`could not create backup dir: ${err.message}`);
3673
- }
3674
- }
3675
- log(
3676
- dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
3677
- );
3678
- gitOrFatal(["pull", "--rebase", "--autostash"], "git pull --rebase", REPO_HOME);
3679
- const mapPath = join33(REPO_HOME, "path-map.json");
3680
- const map = existsSync28(mapPath) ? readPathMap(mapPath) : { projects: {} };
3681
- divergenceCheckExtras(ts);
3682
- if (dryRun) {
3683
- const previewResult = computePreview(ts, map);
3684
- log("dry-run complete; no mutation");
3685
- emitSummary("pull", previewResult.unmapped);
3686
- } else {
3687
- applyWetPull(ts, map);
3688
- }
3689
- } catch (err) {
3690
- if (err instanceof NomadFatal) {
3691
- fail(err.message);
3692
- process.exitCode = 1;
3693
- } else {
3694
- throw err;
3695
- }
3696
- } finally {
3697
- releaseLock(handle);
3698
- }
3699
- }
3700
-
3701
- // src/commands.push.ts
3702
- init_config();
3703
- import { existsSync as existsSync31 } from "node:fs";
3704
- import { join as join38, relative as relative5 } from "node:path";
3705
-
3706
- // src/commands.push.allowlist.ts
3707
- init_config();
3708
- init_config_sharedDirs_guard();
3709
- init_utils();
3710
- function isAllowed(path, allowed) {
3711
- for (const entry of allowed) {
3712
- if (path === entry) return true;
3713
- if (entry === "hosts/") {
3714
- if (/^hosts\/[^/]+\.json$/.test(path)) return true;
3715
- continue;
3716
- }
3717
- if (entry.endsWith("/") && path.startsWith(entry)) return true;
3718
- }
3719
- return false;
3720
- }
3721
- function isNeverSync(path) {
3722
- const blockSet = path.startsWith("shared/extras/") ? ALWAYS_NEVER_SYNC : NEVER_SYNC;
3723
- for (const segment of path.split("/")) {
3724
- if (blockSet.has(segment)) return true;
3725
- }
3726
- return false;
3727
- }
3728
- function parsePorcelainZ(statusPorcelain) {
3729
- const records = statusPorcelain.split("\0");
3730
- const paths = [];
3731
- for (let i = 0; i < records.length; i++) {
3732
- const rec = records[i];
3733
- if (rec === void 0 || rec === "") continue;
3734
- if (rec.length < 4) continue;
3735
- const xy = rec.slice(0, 2);
3736
- const newPath = rec.slice(3);
3737
- paths.push(newPath);
3738
- if (/[RC]/.test(xy)) {
3739
- const oldPath = records[i + 1];
3740
- if (oldPath !== void 0 && oldPath !== "") paths.push(oldPath);
3741
- i++;
3742
- }
3743
- }
3744
- return paths;
3745
- }
3746
- function enforceAllowList(statusPorcelain, map) {
3747
- const extrasWhitelist = SUPPORTED_EXTRAS;
3748
- const allowed = [
3749
- ...PUSH_ALLOWED_STATIC,
3750
- ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
3751
- ...Object.entries(map.extras ?? {}).flatMap(
3752
- ([l, names]) => names.filter((n) => extrasWhitelist.includes(n)).flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`])
3753
- ),
3754
- ...(map.sharedDirs ?? []).filter((d) => isValidSharedDir(d)).map((d) => `shared/${d}/`)
3755
- ];
3756
- const neverSyncHits = [];
3757
- const violations = [];
3758
- for (const path of parsePorcelainZ(statusPorcelain)) {
3759
- if (isNeverSync(path)) {
3760
- neverSyncHits.push(path);
3761
- } else if (!isAllowed(path, allowed)) {
3762
- violations.push(path);
3763
- }
3764
- }
3765
- if (neverSyncHits.length === 0 && violations.length === 0) return;
3766
- for (const p of neverSyncHits) {
3767
- fail(`${p} is in NEVER_SYNC and must never be pushed`);
3768
- }
3769
- for (const p of violations) {
3770
- fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
3771
- }
3772
- throw new NomadFatal("push allow-list violations");
3773
- }
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";
3774
3641
 
3775
3642
  // src/commands.push.recovery.ts
3776
3643
  init_config();
3777
3644
  import { readFileSync as readFileSync10, rmSync as rmSync10, writeFileSync as writeFileSync5 } from "node:fs";
3778
- import { join as join36 } from "node:path";
3645
+ import { join as join35 } from "node:path";
3779
3646
  import { createInterface } from "node:readline/promises";
3780
3647
 
3781
3648
  // src/commands.push.recovery.redact.ts
3782
3649
  init_config();
3783
3650
  init_config_sharedDirs_guard();
3784
- import { cpSync as cpSync5, existsSync as existsSync29, mkdirSync as mkdirSync8, statSync as statSync8 } from "node:fs";
3785
- 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";
3786
3653
  init_push_gitleaks_scan();
3787
3654
  init_utils_json();
3788
3655
  init_utils();
@@ -3813,8 +3680,8 @@ function resolveStagedDir(localPath, map) {
3813
3680
  assertSafeLogical(logical);
3814
3681
  const abs = hostMap[HOST];
3815
3682
  if (abs === void 0) continue;
3816
- if (localPath.startsWith(join34(CLAUDE_HOME, "projects", encodePath(abs)) + sep2)) {
3817
- 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);
3818
3685
  }
3819
3686
  }
3820
3687
  return null;
@@ -3836,7 +3703,7 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
3836
3703
  `could not locate the local transcript for session ${sid}; choose Skip or Drop session.`
3837
3704
  );
3838
3705
  }
3839
- const sessionDir = join34(dirname5(localPath), sid);
3706
+ const sessionDir = join33(dirname5(localPath), sid);
3840
3707
  const subtreeFiles = listSubtreeFiles(sessionDir);
3841
3708
  const subtreeMtime = newestSubtreeMtimeMs(localPath, subtreeFiles, (p) => statSync8(p).mtimeMs);
3842
3709
  if (isRecentlyModified(subtreeMtime, nowMs())) {
@@ -3869,10 +3736,10 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
3869
3736
  `nothing to redact in the local transcript for session ${sid}; choose Skip or Drop session.`
3870
3737
  );
3871
3738
  }
3872
- mkdirSync8(stagedProjectDir, { recursive: true });
3873
- cpSync5(localPath, join34(stagedProjectDir, `${sid}.jsonl`), { force: true });
3874
- if (existsSync29(sessionDir)) {
3875
- 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 });
3876
3743
  }
3877
3744
  return true;
3878
3745
  }
@@ -3880,13 +3747,13 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
3880
3747
  // src/commands.push.recovery.drop.ts
3881
3748
  init_config();
3882
3749
  import { rmSync as rmSync9 } from "node:fs";
3883
- import { join as join35 } from "node:path";
3750
+ import { join as join34 } from "node:path";
3884
3751
  function dropSessionFromStaged(sid, map) {
3885
3752
  const logicals = Object.keys(map.projects);
3886
3753
  if (logicals.length === 0) return false;
3887
3754
  for (const logical of logicals) {
3888
- const jsonl = join35(REPO_HOME, "shared", "projects", logical, `${sid}.jsonl`);
3889
- 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);
3890
3757
  rmSync9(jsonl, { force: true });
3891
3758
  rmSync9(dir, { recursive: true, force: true });
3892
3759
  }
@@ -4004,7 +3871,7 @@ function applyThenRescan(scanVerdict, repoHome) {
4004
3871
  return next;
4005
3872
  }
4006
3873
  function allowThenRescan(append, scanVerdict, repoHome) {
4007
- const ignPath = join36(repoHome, ".gitleaksignore");
3874
+ const ignPath = join35(repoHome, ".gitleaksignore");
4008
3875
  let before;
4009
3876
  try {
4010
3877
  before = readFileSync10(ignPath, "utf8");
@@ -4083,6 +3950,225 @@ async function resolveLeakFindings(verdict, ts, map, deps = {}) {
4083
3950
  return current;
4084
3951
  }
4085
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
+
4086
4172
  // src/commands.push.ts
4087
4173
  init_push_leak_verdict();
4088
4174
  init_push_checks();
@@ -4092,7 +4178,7 @@ init_color();
4092
4178
  init_config();
4093
4179
  init_config_sharedDirs_guard();
4094
4180
  import { randomBytes as randomBytes2 } from "node:crypto";
4095
- 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";
4096
4182
  import { homedir as homedir5 } from "node:os";
4097
4183
  import { join as join37 } from "node:path";
4098
4184
  init_push_leak_verdict();
@@ -4110,7 +4196,7 @@ function stageSessions(tmpRoot, map) {
4110
4196
  reverse.set(encodePath(p), logical);
4111
4197
  }
4112
4198
  const localProjects = join37(CLAUDE_HOME, "projects");
4113
- if (!existsSync30(localProjects)) return 0;
4199
+ if (!existsSync31(localProjects)) return 0;
4114
4200
  let staged = 0;
4115
4201
  for (const dir of readdirSync10(localProjects)) {
4116
4202
  const logical = reverse.get(dir);
@@ -4132,7 +4218,7 @@ function stageExtras(tmpRoot, map) {
4132
4218
  for (const dirname6 of dirnames) {
4133
4219
  if (!whitelist.includes(dirname6)) continue;
4134
4220
  const src = join37(localRoot, dirname6);
4135
- if (!existsSync30(src)) continue;
4221
+ if (!existsSync31(src)) continue;
4136
4222
  const dst = join37(tmpRoot, "shared", "extras", logical, dirname6);
4137
4223
  copyExtras(src, dst);
4138
4224
  staged++;
@@ -4152,7 +4238,7 @@ function previewPushLeaks(map) {
4152
4238
  return { leak: false, verdictRow: NOTHING_TO_SCAN_ROW, recovery: null, findings: [] };
4153
4239
  }
4154
4240
  const ignoreFile = join37(REPO_HOME, ".gitleaksignore");
4155
- if (existsSync30(ignoreFile)) {
4241
+ if (existsSync31(ignoreFile)) {
4156
4242
  copyFileSync(ignoreFile, join37(tmpRoot, ".gitleaksignore"));
4157
4243
  }
4158
4244
  let findings;
@@ -4186,15 +4272,34 @@ function guardGitlinks() {
4186
4272
  `gitlink trap: ${gitlinks.length} nested .git ${noun} in shared/; remove before retry`
4187
4273
  );
4188
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
+ }
4189
4294
  async function commitAndPush(st, ts, map, redactAll, allowAll, allowRule) {
4190
4295
  gitOrFatal(["add", "-A"], "git add", REPO_HOME);
4191
- let verdict = scanPushVerdict();
4296
+ let verdict = runScan();
4192
4297
  if (verdict.leak) {
4193
4298
  renderPushTree(st, verdict);
4194
4299
  verdict = await resolveLeakFindings(verdict, ts, map, { redactAll, allowAll, allowRule });
4195
4300
  }
4196
4301
  gitOrFatal(["commit", "-m", `chore: sync from ${HOST}`], "git commit", REPO_HOME);
4197
- gitOrFatal(["push"], "git push", REPO_HOME);
4302
+ runPush();
4198
4303
  renderPushTree(st, verdict);
4199
4304
  }
4200
4305
  function runDryRunPreview(st, map) {
@@ -4227,13 +4332,19 @@ async function cmdPush(opts = {}) {
4227
4332
  const allowAll = opts.allowAll === true;
4228
4333
  const allowRule = opts.allowRule;
4229
4334
  guardResolutionModeConflicts(dryRun, redactAll, allowAll, allowRule);
4230
- if (!existsSync31(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4335
+ if (!existsSync32(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4231
4336
  const handle = acquireLock("push");
4232
4337
  if (handle === null) process.exit(0);
4233
4338
  try {
4234
4339
  console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
4235
4340
  probeGitleaks();
4236
- rebaseBeforePush();
4341
+ const rebaseSp = startSpinner("Rebasing onto origin");
4342
+ try {
4343
+ rebaseBeforePush();
4344
+ rebaseSp.succeed();
4345
+ } finally {
4346
+ rebaseSp.stop();
4347
+ }
4237
4348
  const ts = freshBackupTs(BACKUP_BASE);
4238
4349
  const remap = remapPush(ts, { dryRun });
4239
4350
  const extras = remapExtrasPush(ts, { dryRun });
@@ -4246,7 +4357,7 @@ async function cmdPush(opts = {}) {
4246
4357
  return;
4247
4358
  }
4248
4359
  const mapPath = join38(REPO_HOME, "path-map.json");
4249
- if (!existsSync31(mapPath)) {
4360
+ if (!existsSync32(mapPath)) {
4250
4361
  if (dryRun) return runDryRunPreview(st, null);
4251
4362
  die("path-map.json missing, cannot enforce push allow-list");
4252
4363
  }
@@ -4286,17 +4397,17 @@ init_config();
4286
4397
 
4287
4398
  // src/diff.ts
4288
4399
  init_config();
4289
- import { existsSync as existsSync32 } from "node:fs";
4400
+ import { existsSync as existsSync33 } from "node:fs";
4290
4401
  import { join as join39 } from "node:path";
4291
4402
  init_utils();
4292
4403
  init_utils_fs();
4293
4404
  init_utils_json();
4294
4405
  function cmdDiff() {
4295
4406
  try {
4296
- if (!existsSync32(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4407
+ if (!existsSync33(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
4297
4408
  const ts = freshBackupTs(BACKUP_BASE);
4298
4409
  const mapPath = join39(REPO_HOME, "path-map.json");
4299
- const map = existsSync32(mapPath) ? readPathMap(mapPath) : { projects: {} };
4410
+ const map = existsSync33(mapPath) ? readPathMap(mapPath) : { projects: {} };
4300
4411
  const result = computePreview(ts, map);
4301
4412
  emitSummary("diff", result.unmapped);
4302
4413
  } catch (err) {
@@ -4311,7 +4422,7 @@ function cmdDiff() {
4311
4422
 
4312
4423
  // src/init.ts
4313
4424
  init_config();
4314
- 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";
4315
4426
  import { join as join41 } from "node:path";
4316
4427
 
4317
4428
  // src/init.gh-onboard.ts
@@ -4393,16 +4504,16 @@ init_config();
4393
4504
  init_utils();
4394
4505
  init_utils_fs();
4395
4506
  init_utils_json();
4396
- 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";
4397
4508
  import { join as join40 } from "node:path";
4398
4509
  function snapshotIntoShared(map) {
4399
4510
  for (const name of allSharedLinks(map)) {
4400
4511
  const src = join40(CLAUDE_HOME, name);
4401
- if (!existsSync33(src)) continue;
4512
+ if (!existsSync34(src)) continue;
4402
4513
  const dst = join40(REPO_HOME, "shared", name);
4403
4514
  if (statSync9(src).isDirectory()) {
4404
4515
  const gk = join40(dst, ".gitkeep");
4405
- if (existsSync33(gk)) rmSync12(gk);
4516
+ if (existsSync34(gk)) rmSync12(gk);
4406
4517
  cpSync6(src, dst, { recursive: true, force: false, errorOnExist: true });
4407
4518
  } else {
4408
4519
  copyFileSync2(src, dst);
@@ -4410,7 +4521,7 @@ function snapshotIntoShared(map) {
4410
4521
  log(`snapshotted shared/${name} from ${src}`);
4411
4522
  }
4412
4523
  const userSettings = join40(CLAUDE_HOME, "settings.json");
4413
- if (existsSync33(userSettings)) {
4524
+ if (existsSync34(userSettings)) {
4414
4525
  let parsed;
4415
4526
  try {
4416
4527
  parsed = readJson(userSettings);
@@ -4437,7 +4548,7 @@ function preflightConflict(repoHome) {
4437
4548
  join41(repoHome, "shared")
4438
4549
  ];
4439
4550
  for (const c of candidates) {
4440
- if (existsSync34(c)) return c;
4551
+ if (existsSync35(c)) return c;
4441
4552
  }
4442
4553
  return null;
4443
4554
  }
@@ -4456,7 +4567,7 @@ function cmdInit(opts = {}) {
4456
4567
  mkdirSync10(join41(REPO_HOME, "shared", name), { recursive: true });
4457
4568
  }
4458
4569
  const userClaudeMd = join41(CLAUDE_HOME, "CLAUDE.md");
4459
- if (!snapshot || !existsSync34(userClaudeMd)) {
4570
+ if (!snapshot || !existsSync35(userClaudeMd)) {
4460
4571
  writeFileSync6(join41(REPO_HOME, "shared", "CLAUDE.md"), SHARED_CLAUDE_MD);
4461
4572
  log("created shared/CLAUDE.md");
4462
4573
  }
@@ -4730,7 +4841,7 @@ function parsePushArgs(argv) {
4730
4841
  // package.json
4731
4842
  var package_default = {
4732
4843
  name: "claude-nomad",
4733
- version: "0.39.0",
4844
+ version: "0.40.0",
4734
4845
  type: "module",
4735
4846
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
4736
4847
  keywords: [
@@ -4920,7 +5031,7 @@ var DEFAULT_HELP = [
4920
5031
  init_config();
4921
5032
  init_utils();
4922
5033
  init_utils_json();
4923
- 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";
4924
5035
  import { join as join42 } from "node:path";
4925
5036
  function resumeCmd(sessionId) {
4926
5037
  if (!/^[A-Za-z0-9_-]+$/.test(sessionId) || sessionId.length > 128) {
@@ -4928,7 +5039,7 @@ function resumeCmd(sessionId) {
4928
5039
  process.exit(1);
4929
5040
  }
4930
5041
  const projectsRoot = join42(CLAUDE_HOME, "projects");
4931
- if (!existsSync35(projectsRoot)) {
5042
+ if (!existsSync36(projectsRoot)) {
4932
5043
  fail(`${projectsRoot} does not exist`);
4933
5044
  process.exit(1);
4934
5045
  }
@@ -4943,7 +5054,7 @@ function resumeCmd(sessionId) {
4943
5054
  process.exit(1);
4944
5055
  }
4945
5056
  const mapPath = join42(REPO_HOME, "path-map.json");
4946
- if (!existsSync35(mapPath)) {
5057
+ if (!existsSync36(mapPath)) {
4947
5058
  fail("path-map.json missing");
4948
5059
  process.exit(1);
4949
5060
  }
@@ -4967,7 +5078,7 @@ function resumeCmd(sessionId) {
4967
5078
  function findTranscriptPath(projectsRoot, sessionId) {
4968
5079
  for (const dir of readdirSync11(projectsRoot)) {
4969
5080
  const candidate = join42(projectsRoot, dir, `${sessionId}.jsonl`);
4970
- if (existsSync35(candidate)) return candidate;
5081
+ if (existsSync36(candidate)) return candidate;
4971
5082
  }
4972
5083
  return null;
4973
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.39.0",
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": [