claude-nomad 0.26.2 → 0.28.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.
@@ -1,16 +1,57 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
+ import {
5
+ buildExtrasSection,
6
+ buildSessionsSection,
7
+ buildSettingsSection,
8
+ } from './commands.push.sections.ts';
4
9
  import { HOME, HOST, REPO_HOME } from './config.ts';
5
10
  import { divergenceCheckExtras, remapExtrasPull } from './extras-sync.ts';
6
11
  import { applySharedLinks, regenerateSettings } from './links.ts';
12
+ import { renderTree, section, addItem } from './output-tree.ts';
7
13
  import { computePreview } from './preview.ts';
8
14
  import { remapPull } from './remap.ts';
9
- import { emitSummary } from './summary.ts';
15
+ import { emitSummary, summaryRow } from './summary.ts';
10
16
  import { die, fail, gitOrFatal, log, NomadFatal } from './utils.ts';
11
17
  import { freshBackupTs } from './utils.fs.ts';
12
18
  import { acquireLock, releaseLock } from './utils.lockfile.ts';
13
19
 
20
+ /**
21
+ * Run the WET (non-dry-run) pull side effects in order and render the
22
+ * doctor-style grouped tree once at the end: a `pull on host=... (backup=<ts>)`
23
+ * header followed by `Settings` / `Sessions` / `Extras` / `Summary` sections.
24
+ * `applySharedLinks` stays silent (no Links group by design);
25
+ * `regenerateSettings` returns its override-source label so the Settings row
26
+ * surfaces what was written without logging inline. Sessions/Extras reuse the
27
+ * verb-agnostic builders shared with `cmdPush`, fed the pull-side `pulled`
28
+ * detail arrays. The combined session + extras unmapped count and the
29
+ * extras-skipped count drive the Summary row exactly as `emitSummary` did.
30
+ *
31
+ * @param ts - backup timestamp namespace shared by every WET side effect.
32
+ */
33
+ function applyWetPull(ts: string): void {
34
+ applySharedLinks(ts);
35
+ const { label } = regenerateSettings(ts);
36
+ const remapResult = remapPull(ts);
37
+ const extrasResult = remapExtrasPull(ts);
38
+ // Combine session-unmapped and extras-unmapped into one user-visible count;
39
+ // from the operator's perspective both mean "couldn't sync this for the
40
+ // host". extras-skipped (non-whitelisted dirname) stays separate because it
41
+ // signals config misuse, not a host-config gap.
42
+ const summary = section('Summary');
43
+ addItem(
44
+ summary,
45
+ summaryRow('pull', remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped),
46
+ );
47
+ renderTree([
48
+ buildSettingsSection(label),
49
+ buildSessionsSection(remapResult.pulled, remapResult.unmapped),
50
+ buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
51
+ summary,
52
+ ]);
53
+ }
54
+
14
55
  /**
15
56
  * `nomad pull` command. Acquires the push/pull lock, takes a backup
16
57
  * timestamp, runs `git pull --rebase --autostash` in `REPO_HOME`, then
@@ -23,6 +64,19 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
23
64
  * 5. `remapExtrasPull` (copy `shared/extras/<logical>/<dirname>/` back
24
65
  * into each project's localRoot; SKIPPED under dryRun)
25
66
  *
67
+ * WET output is a doctor-style grouped tree (`applyWetPull`): a `pull on
68
+ * host=... (backup=<ts>)` header, then `Settings` / `Sessions` / `Extras` /
69
+ * `Summary` sections rendered with `├`/`└` connectors. The Settings row shows
70
+ * `✓ settings.json (base + <label>)`; pulled sessions and extras list as `✓`
71
+ * rows; the per-project "not in path-map" skips collapse to one `ℹ︎` count
72
+ * row. There is no Links group (`applySharedLinks` stays silent by design).
73
+ *
74
+ * The WET-path Summary row (including the warn glyph case) renders to STDOUT as
75
+ * part of the grouped tree via `renderTree`, not to stderr via `warn` as in the
76
+ * pre-tree behavior. The dry-run path still routes its summary through
77
+ * `emitSummary` (stderr). This wet-stdout/dry-stderr stream split is
78
+ * intentional (the dry-run output is left byte-identical) and not a regression.
79
+ *
26
80
  * `opts.dryRun` (default `false`): when `true`, the lock IS still acquired
27
81
  * and `git pull --rebase` still runs (so concurrent invocations cannot race
28
82
  * and the user sees the same network round-trip as a real pull).
@@ -30,7 +84,10 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
30
84
  * `computePreview` runs in place of the four mutating steps. The per-run
31
85
  * backup directory under `~/.cache/claude-nomad/backup/<ts>/` is
32
86
  * intentionally NOT created (no backups are written under dryRun and an
33
- * empty dir would pollute the cache).
87
+ * empty dir would pollute the cache). The dry-run path is BYTE-IDENTICAL:
88
+ * it keeps the `pulling on host=... (backup=...; dry-run)` header, the
89
+ * `computePreview` adjacent diff, its standalone `emitSummary`, and the
90
+ * `dry-run complete; no mutation` line; it does NOT build the tree.
34
91
  *
35
92
  * Any `NomadFatal` thrown along the way is caught here so the `finally` block
36
93
  * releases the lock before exit (a raw `process.exit()` would skip `finally`
@@ -67,7 +124,14 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
67
124
  die(`could not create backup dir: ${(err as Error).message}`);
68
125
  }
69
126
  }
70
- log(`pulling on host=${HOST} (backup=${ts}${dryRun ? '; dry-run' : ''})`);
127
+ // WET header becomes the tree header (no `pulling`/`ℹ︎` prefix). The
128
+ // dry-run header phrasing is LEFT byte-identical so the readable diff path
129
+ // does not regress.
130
+ log(
131
+ dryRun
132
+ ? `pulling on host=${HOST} (backup=${ts}; dry-run)`
133
+ : `pull on host=${HOST} (backup=${ts})`,
134
+ );
71
135
  gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
72
136
  // Read-only pre-pull check: fires in BOTH wet and dry modes (D-08).
73
137
  // Runs AFTER the rebase (so origin content is fetched) and BEFORE any
@@ -78,19 +142,11 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
78
142
  const previewResult = computePreview(ts);
79
143
  // dryRun deliberately omits remapExtrasPull to preserve the
80
144
  // zero-mutation contract; users still see the divergence WARN above.
145
+ // BYTE-IDENTICAL dry-run output: standalone emitSummary, no tree.
81
146
  log('dry-run complete; no mutation');
82
147
  emitSummary('pull', previewResult.unmapped);
83
148
  } else {
84
- applySharedLinks(ts);
85
- regenerateSettings(ts);
86
- const remapResult = remapPull(ts);
87
- const extrasResult = remapExtrasPull(ts);
88
- log('pull complete');
89
- // Combine session-unmapped and extras-unmapped into one user-visible
90
- // count; from the operator's perspective both mean "couldn't sync this
91
- // for the host". extras-skipped (non-whitelisted dirname) stays
92
- // separate because it signals config misuse, not a host-config gap.
93
- emitSummary('pull', remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped);
149
+ applyWetPull(ts);
94
150
  }
95
151
  } catch (err) {
96
152
  // Catch fatal errors here so the finally block runs and releases the
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Pure section-builder helpers shared by `cmdPush` and `cmdPull` for the
3
+ * doctor-style grouped tree. The builders are verb-agnostic: they take the
4
+ * already-selected detail array (the wet `pushed`/`pulled` list or the dry-run
5
+ * `wouldPush`/`wouldPull` list) plus the relevant skip count, so the same code
6
+ * renders both push and pull rows. Row shapes mirror `nomad doctor`: one
7
+ * `${green(okGlyph)} <item>` row per synced item, then a single collapsed
8
+ * `${dim(infoGlyph)} <N> <noun>` count row instead of one row per skip.
9
+ *
10
+ * Sections returned here may be empty; `renderTree` (in `./output-tree.ts`)
11
+ * skips empty sections, so a Sessions/Extras group with zero items and a zero
12
+ * skip count never prints a header.
13
+ */
14
+
15
+ import { dim, green, infoGlyph, okGlyph } from './color.ts';
16
+ import type { remapExtrasPush } from './extras-sync.ts';
17
+ import { type DoctorSection, addItem, renderTree, section } from './output-tree.ts';
18
+ import type { LeakVerdict } from './push-leak-verdict.ts';
19
+ import type { remapPush } from './remap.ts';
20
+ import { summaryRow } from './summary.ts';
21
+
22
+ /**
23
+ * Build the single collapsed count row, or `null` when `n` is zero. Used for
24
+ * the "not in path-map" session skips and the "extras skipped" extras skips so
25
+ * the noisy per-project skip lines fold into one row that points at
26
+ * `nomad doctor` for the authoritative list.
27
+ *
28
+ * @param n - The skip count (no row is produced when `0`).
29
+ * @param noun - The collapsed-row phrasing after the count (e.g.
30
+ * `'not in path-map (run nomad doctor to list)'` or `'extras skipped'`).
31
+ * @returns The rendered ℹ︎ count row, or `null` when `n` is `0`.
32
+ */
33
+ export function collapsedSkipRow(n: number, noun: string): string | null {
34
+ if (n <= 0) return null;
35
+ return `${dim(infoGlyph)} ${n} ${noun}`;
36
+ }
37
+
38
+ /**
39
+ * Build the Settings section for `cmdPull`: a single
40
+ * `${green(okGlyph)} settings.json (base + <label>)` row. `label` is the
41
+ * override-source tag returned by `regenerateSettings` (`'<HOST>.json'` when a
42
+ * host override exists, else `'no host overrides'`), surfacing what was written
43
+ * without `regenerateSettings` logging the line inline. Push has no Settings
44
+ * section, so this helper is pull-only.
45
+ *
46
+ * @param label - The override-source tag from `regenerateSettings`.
47
+ * @returns A `Settings` `DoctorSection` holding the one settings row.
48
+ */
49
+ export function buildSettingsSection(label: string): DoctorSection {
50
+ const s = section('Settings');
51
+ addItem(s, `${green(okGlyph)} settings.json (base + ${label})`);
52
+ return s;
53
+ }
54
+
55
+ /**
56
+ * Build the Sessions section: one ✓ row per synced logical name plus, when
57
+ * `unmapped > 0`, a single collapsed `${unmapped} not in path-map` count row.
58
+ * Verb-agnostic: pass `remapResult.pushed` (or `wouldPush`, or the pull-side
59
+ * `pulled`/`wouldPull`) as `items`.
60
+ *
61
+ * @param items - The logical names synced this run.
62
+ * @param unmapped - Count of path-map entries skipped for this host.
63
+ * @returns A `Sessions` `DoctorSection` (possibly empty).
64
+ */
65
+ export function buildSessionsSection(items: string[], unmapped: number): DoctorSection {
66
+ const s = section('Sessions');
67
+ for (const logical of items) addItem(s, `${green(okGlyph)} ${logical}`);
68
+ const skip = collapsedSkipRow(unmapped, 'not in path-map (run nomad doctor to list)');
69
+ if (skip !== null) addItem(s, skip);
70
+ return s;
71
+ }
72
+
73
+ /**
74
+ * Build the Extras section: one ✓ row per synced `<logical>/<dirname>` entry
75
+ * plus, when `extrasSkipped > 0`, a single collapsed `${extrasSkipped} extras
76
+ * skipped` count row. Verb-agnostic: pass the wet or dry detail array as
77
+ * `items`.
78
+ *
79
+ * @param items - The `<logical>/<dirname>` entries synced this run.
80
+ * @param extrasSkipped - Count of dirnames the whitelist declined to sync.
81
+ * @returns An `Extras` `DoctorSection` (possibly empty).
82
+ */
83
+ export function buildExtrasSection(items: string[], extrasSkipped: number): DoctorSection {
84
+ const s = section('Extras');
85
+ for (const entry of items) addItem(s, `${green(okGlyph)} ${entry}`);
86
+ const skip = collapsedSkipRow(extrasSkipped, 'extras skipped');
87
+ if (skip !== null) addItem(s, skip);
88
+ return s;
89
+ }
90
+
91
+ /**
92
+ * Collected per-run push state threaded through `cmdPush` so the grouped tree
93
+ * can be assembled once at the end. `remap`/`extras` carry the detail arrays +
94
+ * counts; `dryRun` selects the wet (`pushed`) vs would-* (`wouldPush`) arrays.
95
+ */
96
+ export type PushState = {
97
+ dryRun: boolean;
98
+ remap: ReturnType<typeof remapPush>;
99
+ extras: ReturnType<typeof remapExtrasPush>;
100
+ };
101
+
102
+ /**
103
+ * Assemble the Sessions/Extras sections shared by the real and dry-run push
104
+ * paths, selecting the wet `pushed` detail arrays or the `wouldPush` arrays
105
+ * under `dryRun`. The Leak scan and Summary sections are appended by the caller
106
+ * in path-specific order.
107
+ *
108
+ * @param st - The collected push state.
109
+ * @returns The ordered `[Sessions, Extras]` sections (either may be empty).
110
+ */
111
+ function syncedSections(st: PushState): DoctorSection[] {
112
+ const sessions = st.dryRun ? st.remap.wouldPush : st.remap.pushed;
113
+ const extras = st.dryRun ? st.extras.wouldPush : st.extras.pushed;
114
+ return [
115
+ buildSessionsSection(sessions, st.remap.unmapped),
116
+ buildExtrasSection(extras, st.extras.skipped),
117
+ ];
118
+ }
119
+
120
+ /**
121
+ * Build the single-row Summary section from the combined unmapped count
122
+ * (sessions + extras), the collision count, and the extras-skipped count.
123
+ * Phrasing is delegated to `summaryRow` so it matches `emitSummary` exactly.
124
+ *
125
+ * @param st - The collected push state.
126
+ * @returns A `Summary` `DoctorSection` holding the one summary row.
127
+ */
128
+ function summarySection(st: PushState): DoctorSection {
129
+ const s = section('Summary');
130
+ const unmapped = st.remap.unmapped + st.extras.unmapped;
131
+ addItem(s, summaryRow('push', unmapped, st.remap.collisions, st.extras.skipped));
132
+ return s;
133
+ }
134
+
135
+ /**
136
+ * Render the grouped push tree with a Leak scan section (carrying `verdict`'s
137
+ * row) between Extras and Summary. The caller throws the recovery body as a
138
+ * `NomadFatal` AFTER this returns (real-push leak) or prints it via `fail`
139
+ * (dry-run) so the recovery block follows the tree.
140
+ *
141
+ * @param st - The collected push state.
142
+ * @param verdict - The leak verdict for the Leak scan section.
143
+ */
144
+ export function renderPushTree(st: PushState, verdict: LeakVerdict): void {
145
+ const leakScan = section('Leak scan');
146
+ addItem(leakScan, verdict.verdictRow);
147
+ renderTree([...syncedSections(st), leakScan, summarySection(st)]);
148
+ }
149
+
150
+ /**
151
+ * Render the no-Leak-scan push tree: the Sessions/Extras rows (if any) plus the
152
+ * Summary row. `renderTree` skips empty sections, so an empty push prints only
153
+ * the Summary. Two callers: the real-push nothing-to-commit early return
154
+ * (`noMapHint` omitted) and the dry-run no-`path-map.json` case
155
+ * (`noMapHint: true`), which prepends a `Path map` section carrying a single
156
+ * `${dim(infoGlyph)} no path-map.json (nothing to preview)` row so a dry-run
157
+ * user sees WHY no Leak scan section rendered (no map means nothing to stage).
158
+ *
159
+ * @param st - The collected push state.
160
+ * @param opts.noMapHint - When `true`, prepend the no-path-map hint section.
161
+ * @returns Nothing; renders to stdout.
162
+ */
163
+ export function renderNoScanTree(st: PushState, opts: { noMapHint?: boolean } = {}): void {
164
+ const sections: DoctorSection[] = [];
165
+ if (opts.noMapHint === true) {
166
+ const pathMap = section('Path map');
167
+ addItem(pathMap, `${dim(infoGlyph)} no path-map.json (nothing to preview)`);
168
+ sections.push(pathMap);
169
+ }
170
+ renderTree([...sections, ...syncedSections(st), summarySection(st)]);
171
+ }
@@ -1,18 +1,93 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
3
 
4
- import { HOME, HOST, REPO_HOME } from './config.ts';
4
+ import { HOME, HOST, type PathMap, REPO_HOME } from './config.ts';
5
5
  import { enforceAllowList } from './commands.push.allowlist.ts';
6
+ import { type PushState, renderNoScanTree, renderPushTree } from './commands.push.sections.ts';
6
7
  import { remapExtrasPush } from './extras-sync.ts';
8
+ import { scanPushVerdict } from './push-leak-verdict.ts';
7
9
  import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
8
- import { runGitleaksScan } from './push-gitleaks.ts';
10
+ import { previewPushLeaks } from './push-preview.ts';
9
11
  import { remapPush } from './remap.ts';
10
- import { emitSummary } from './summary.ts';
11
12
  import { die, fail, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal } from './utils.ts';
12
13
  import { freshBackupTs } from './utils.fs.ts';
13
14
  import { readPathMap } from './utils.json.ts';
14
15
  import { acquireLock, releaseLock } from './utils.lockfile.ts';
15
16
 
17
+ /**
18
+ * Walk `shared/` for nested `.git` entries copied in from a host's encoded
19
+ * session dir. A gitlink would otherwise push as a submodule via the
20
+ * `shared/projects/<logical>/` prefix. Emits a per-hit FATAL line on stderr and
21
+ * throws a summarizing `NomadFatal` (caught by `cmdPush` so the lock releases).
22
+ * Runs AFTER `remapPush` so it inspects the post-copy tree.
23
+ */
24
+ function guardGitlinks(): void {
25
+ const gitlinks = findGitlinks(join(REPO_HOME, 'shared'));
26
+ if (gitlinks.length === 0) return;
27
+ for (const p of gitlinks) {
28
+ const rel = relative(REPO_HOME, p);
29
+ fail(`gitlink: ${rel} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`);
30
+ }
31
+ const noun = gitlinks.length === 1 ? 'entry' : 'entries';
32
+ throw new NomadFatal(
33
+ `gitlink trap: ${gitlinks.length} nested .git ${noun} in shared/; remove before retry`,
34
+ );
35
+ }
36
+
37
+ /**
38
+ * The staged-tree leak gate + commit/push for the REAL push path. Runs
39
+ * `scanPushVerdict` AFTER `git add -A` (sees what would push) but BEFORE commit
40
+ * (a detection unwinds cleanly with no commit to revert). On a leak it renders
41
+ * the tree (with the ✗ Leak scan row + Summary) so the tree precedes the
42
+ * recovery block, then throws the recovery body as a `NomadFatal` (the catch
43
+ * prints it and sets a non-zero exit). On a clean scan it commits, pushes, and
44
+ * renders the tree with the `✓ no leaks` row.
45
+ *
46
+ * @param st - The collected push state for the final tree render.
47
+ */
48
+ function commitAndPush(st: PushState): void {
49
+ // gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
50
+ gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
51
+ const verdict = scanPushVerdict();
52
+ if (verdict.leak) {
53
+ renderPushTree(st, verdict);
54
+ // Every `leak: true` branch of scanPushVerdict sets a non-null recovery
55
+ // body, so the `?? fallback` is defensively unreachable (excluded from
56
+ // coverage rather than contorting a test to fake an impossible state).
57
+ /* c8 ignore next */
58
+ throw new NomadFatal(verdict.recovery ?? 'gitleaks detected secrets');
59
+ }
60
+ gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
61
+ gitOrFatal(['push'], 'git push', REPO_HOME);
62
+ renderPushTree(st, verdict);
63
+ }
64
+
65
+ /**
66
+ * Render the dry-run leak-scan tree. With `map === null` (a dry-run with no
67
+ * `path-map.json`) there is nothing to stage, so it renders the no-scan tree
68
+ * with the `noMapHint` row and returns. Otherwise it runs `previewPushLeaks`
69
+ * (which stages its OWN temp
70
+ * tree from the map, independent of `REPO_HOME` status, and sets
71
+ * `process.exitCode = 1` on findings), renders the push tree with the verdict
72
+ * row in the Leak scan section, and prints the recovery body BELOW the tree via
73
+ * `fail` (stderr) when one is present.
74
+ *
75
+ * Extracted from `cmdPush` so the command body and this helper each stay under
76
+ * the sonarjs cognitive-complexity threshold.
77
+ *
78
+ * @param st - The collected push state for the tree render.
79
+ * @param map - The parsed path-map, or `null` when a dry-run has no map.
80
+ */
81
+ function runDryRunPreview(st: PushState, map: PathMap | null): void {
82
+ if (map === null) {
83
+ renderNoScanTree(st, { noMapHint: true });
84
+ return;
85
+ }
86
+ const verdict = previewPushLeaks(map);
87
+ renderPushTree(st, verdict);
88
+ if (verdict.recovery !== null) fail(verdict.recovery);
89
+ }
90
+
16
91
  /**
17
92
  * `nomad push` command. Acquires the lock, runs the four pre-push safety
18
93
  * checks in the order from CONTEXT.md, stages, and pushes:
@@ -26,20 +101,48 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
26
101
  * 5. `findGitlinks` walk of `shared/` (refuse to push nested .git entries)
27
102
  * 6. allow-list enforcement on the resulting `git status` (runtime
28
103
  * `shared/extras/<logical>/` prefix per declared logical added)
29
- * 7. `git add -A` -> `runGitleaksScan` on staged tree -> `git commit` -> `git push`
104
+ * 7. `git add -A` -> `scanPushVerdict` on staged tree -> `git commit` -> `git push`
105
+ *
106
+ * Output is a doctor-style grouped tree: a `push on host=...` header, then
107
+ * `Sessions` / `Extras` / `Leak scan` / `Summary` sections rendered with
108
+ * `├`/`└` connectors. Pushed sessions and extras list as `✓` rows; the
109
+ * per-project "not in path-map" skips collapse to one `ℹ︎` count row. The Leak
110
+ * scan section shows `✓ no leaks` on a clean scan; on a leak it shows a `✗`
111
+ * one-line verdict row and the full `buildSessionAwareFatal` recovery block
112
+ * still prints BELOW the rendered tree.
113
+ *
114
+ * The WET-path Summary row (including the warn `⚠︎` case) renders to STDOUT as
115
+ * part of the grouped tree via `renderTree`, not to stderr via `warn` as in the
116
+ * pre-tree behavior. The dry-run preview likewise renders via `renderTree`
117
+ * (push has no dry-run `emitSummary` path; `cmdPull`'s dry-run does, see its
118
+ * JSDoc for the intentional wet-stdout/dry-pull-stderr stream split).
30
119
  *
31
120
  * The gitleaks scan runs AFTER staging so it sees what would actually be
32
121
  * pushed, but BEFORE commit so a detection unwinds cleanly without leaving a
33
122
  * commit to amend or revert. Any `NomadFatal` is caught here so `finally`
34
- * releases the lock.
123
+ * releases the lock; a real-push leak re-raises the recovery body as a
124
+ * `NomadFatal` AFTER the tree renders so the recovery block follows the tree.
35
125
  *
36
126
  * `opts.dryRun` (default `false`): when `true`, the network round-trip
37
127
  * (`rebaseBeforePush`) still runs so users see what a real push would see,
38
- * but `remapPush` runs with `dryRun: true` (no session copies into shared/),
39
- * and the `git add` / `runGitleaksScan` / `git commit` / `git push` quartet
40
- * is skipped. The allow-list check still classifies the existing `git
41
- * status` so a pre-existing violation surfaces before the user thinks
42
- * everything is fine. Mirrors `cmdPull`'s `dryRun` contract.
128
+ * and `remapPush` / `remapExtrasPush` run with `dryRun: true` (no copies
129
+ * into `shared/`). The `git add` / `git commit` / `git push` steps are
130
+ * skipped. Instead, `previewPushLeaks` runs a READ-ONLY gitleaks leak
131
+ * preview against a temp copy of the would-be-staged sessions AND extras
132
+ * (no `REPO_HOME/shared` mutation), returning a structured verdict whose
133
+ * `verdictRow` lands in the Leak scan section and whose `recovery` (if any)
134
+ * prints below the tree; `process.exitCode = 1` is set on findings.
135
+ *
136
+ * The dry-run preview runs REGARDLESS of `REPO_HOME` `git status`: in dry-run
137
+ * nothing is copied into `shared/`, so an empty status is the normal case for
138
+ * the headline target (a clean repo with new mapped sessions). `previewPushLeaks`
139
+ * stages its own temp tree from the path-map, so the empty-status
140
+ * `'nothing to commit'` early return is REAL-PUSH-ONLY. A dry-run with NO
141
+ * path-map renders the no-scan tree and returns without dying (a real push with
142
+ * a non-empty status and no map still dies on the allow-list check). The
143
+ * allow-list still classifies a non-empty `git status` (dry or wet) so a
144
+ * pre-existing violation surfaces; an empty status has nothing to classify.
145
+ * Mirrors `cmdPull`'s `dryRun` contract.
43
146
  */
44
147
  export function cmdPush(opts: { dryRun?: boolean } = {}): void {
45
148
  const dryRun = opts.dryRun === true;
@@ -47,7 +150,7 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
47
150
  const handle = acquireLock('push');
48
151
  if (handle === null) process.exit(0);
49
152
  try {
50
- log(dryRun ? `pushing on host=${HOST} (dry-run)` : `pushing on host=${HOST}`);
153
+ console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
51
154
  // Probe at top of flow: fail fast if gitleaks is missing, before any mutation.
52
155
  probeGitleaks();
53
156
  // Rebase BEFORE any local mutation: surfaces remote conflicts against the
@@ -59,29 +162,14 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
59
162
  const ts = freshBackupTs(backupBase);
60
163
  // remapPush runs BEFORE the empty-status check: it produces the diffs status
61
164
  // observes, so swapping the order would short-circuit before anything is staged.
62
- const remapResult = remapPush(ts, { dryRun });
165
+ const remap = remapPush(ts, { dryRun });
63
166
  // remapExtrasPush lands between remapPush and findGitlinks so the
64
167
  // produced `shared/extras/<logical>/<dirname>/` paths are visible to
65
168
  // both the gitlink walk and the downstream allow-list classification.
66
169
  // dryRun is forwarded so a preview push reports the same skipped count.
67
- const extrasResult = remapExtrasPush(ts, { dryRun });
68
- // Gitlink walk of shared/ AFTER remapPush so it inspects the post-copy tree.
69
- // A nested .git copied in from a host's encoded session dir would slip past a
70
- // pre-remap scan and reach the remote via the shared/projects/<logical>/ prefix.
71
- // Per-hit FATAL on stderr plus a summarizing throw, mirroring enforceAllowList.
72
- const sharedDir = join(REPO_HOME, 'shared');
73
- const gitlinks = findGitlinks(sharedDir);
74
- if (gitlinks.length > 0) {
75
- for (const p of gitlinks) {
76
- const rel = relative(REPO_HOME, p);
77
- fail(
78
- `gitlink: ${rel} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`,
79
- );
80
- }
81
- throw new NomadFatal(
82
- `gitlink trap: ${gitlinks.length} nested .git ${gitlinks.length === 1 ? 'entry' : 'entries'} in shared/; remove before retry`,
83
- );
84
- }
170
+ const extras = remapExtrasPush(ts, { dryRun });
171
+ const st: PushState = { dryRun, remap, extras };
172
+ guardGitlinks();
85
173
  // Routed through the shell-free, untrimmed helper because `sh` would .trim()
86
174
  // the leading status-space and shift parsePorcelainZ's offsets.
87
175
  // `untrackedAll` (issue #111): the allow-list runs on this snapshot BEFORE
@@ -91,53 +179,30 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
91
179
  // match, so the first extras push is rejected. Expanding to per-file paths
92
180
  // lets the existing allow-list accept them while keeping the gate order.
93
181
  const status = gitStatusPorcelainZ(REPO_HOME, { untrackedAll: true });
94
- if (!status) {
182
+ // REAL-PUSH-ONLY early return: a dry-run copies nothing into shared/, so an
183
+ // empty status is the normal headline case (clean repo, new mapped
184
+ // sessions) and must still reach the dry-run preview below.
185
+ if (!dryRun && !status) {
95
186
  log('nothing to commit');
96
- // Combine session-unmapped and extras-unmapped into one user-visible
97
- // count; both mean "couldn't sync this for the host". extras-skipped
98
- // (non-whitelisted dirname) stays separate because it signals config
99
- // misuse, not a host-config gap.
100
- emitSummary(
101
- 'push',
102
- remapResult.unmapped + extrasResult.unmapped,
103
- remapResult.collisions,
104
- extrasResult.skipped,
105
- );
187
+ renderNoScanTree(st);
106
188
  return;
107
189
  }
108
190
  const mapPath = join(REPO_HOME, 'path-map.json');
109
- if (!existsSync(mapPath)) die('path-map.json missing, cannot enforce push allow-list');
191
+ // A dry-run with no map cannot enforce nor scan: render the no-scan tree and
192
+ // return without dying. A real push with a non-empty status still dies.
193
+ if (!existsSync(mapPath)) {
194
+ if (dryRun) return runDryRunPreview(st, null);
195
+ die('path-map.json missing, cannot enforce push allow-list');
196
+ }
110
197
  // readPathMap routes parse failures through NomadFatal so finally releases the lock.
111
198
  const map = readPathMap(mapPath);
112
- enforceAllowList(status, map);
113
- if (dryRun) {
114
- // Skip the staging quartet so no commit lands and nothing is pushed.
115
- // The user has already seen probeGitleaks pass, the rebase result, the
116
- // remap preview, the gitlink scan, and the allow-list classification.
117
- log('push: dry-run; skipping git add, gitleaks scan, commit, and push');
118
- emitSummary(
119
- 'push',
120
- remapResult.unmapped + extrasResult.unmapped,
121
- remapResult.collisions,
122
- extrasResult.skipped,
123
- );
124
- return;
125
- }
126
- // gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
127
- gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
128
- // Gitleaks scan AFTER staging (sees what would push), BEFORE commit (no cleanup
129
- // needed on detection). The empty-status early return above guarantees the
130
- // index is non-empty here.
131
- runGitleaksScan();
132
- gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
133
- gitOrFatal(['push'], 'git push', REPO_HOME);
134
- log('push complete');
135
- emitSummary(
136
- 'push',
137
- remapResult.unmapped + extrasResult.unmapped,
138
- remapResult.collisions,
139
- extrasResult.skipped,
140
- );
199
+ // Classify only a non-empty status; an empty status (dry-run on a clean
200
+ // repo) has nothing to gate.
201
+ if (status) enforceAllowList(status, map);
202
+ // dryRun skips git add / commit / push: run the read-only leak preview,
203
+ // which prints any recovery below the rendered tree.
204
+ if (dryRun) return runDryRunPreview(st, map);
205
+ commitAndPush(st);
141
206
  } catch (err) {
142
207
  if (err instanceof NomadFatal) {
143
208
  fail(err.message);
package/src/config.ts CHANGED
@@ -36,6 +36,14 @@ export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad')
36
36
  */
37
37
  export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
38
38
 
39
+ /**
40
+ * The official Claude Code settings JSON schema. Source of truth for
41
+ * `SCHEMA_KEYS` (kept current by `scripts/sync-settings-keys.ts`) and the
42
+ * on-demand `nomad doctor --check-schema` reporter, which fetches it live to
43
+ * flag local `settings.json` keys absent from the published schema.
44
+ */
45
+ export const SETTINGS_SCHEMA_URL = 'https://json.schemastore.org/claude-code-settings.json';
46
+
39
47
  /**
40
48
  * Pinned gitleaks version. Single source of truth for the gitleaks pin used by
41
49
  * `nomad doctor`'s version-drift check (`reportGitleaksVersionCheck`), which
@@ -103,48 +111,11 @@ export const NEVER_SYNC = new Set([
103
111
  'ide',
104
112
  ]);
105
113
 
106
- /**
107
- * Schema-drift baseline for `~/.claude/settings.json`. Top-level keys not in
108
- * this set trigger a `nomad doctor` WARN line so we notice when Anthropic
109
- * adds new settings before they silently round-trip through pull. Update on
110
- * Anthropic settings.json schema changes.
111
- */
112
- export const KNOWN_SETTINGS_KEYS = new Set<string>([
113
- '$schema',
114
- 'agent',
115
- 'agents',
116
- 'agentPushNotifEnabled',
117
- 'allowedHttpHookUrls',
118
- 'apiKeyHelper',
119
- 'apiKeyHelperTimeoutMs',
120
- 'awsAuthRefresh',
121
- 'awsCredentialExport',
122
- 'awsLoginRefresh',
123
- 'awsRegion',
124
- 'awsRetryMode',
125
- 'cleanupPeriodDays',
126
- 'disableNonEssentialModelCalls',
127
- 'enabledExperimentalFeatures',
128
- 'enabledPlugins',
129
- 'env',
130
- 'forceLoginMethod',
131
- 'forceLoginOrgUUID',
132
- 'hooks',
133
- 'includeCoAuthoredBy',
134
- 'installMethod',
135
- 'model',
136
- 'outputStyle',
137
- 'permissions',
138
- 'pluginGroups',
139
- 'pluginRepositoryEnabled',
140
- 'pluginsLocalConfig',
141
- 'proxy',
142
- 'skipAutoPermissionPrompt',
143
- 'statsig',
144
- 'statusLine',
145
- 'subagents',
146
- 'theme',
147
- ]);
114
+ // Schema-drift baseline for `~/.claude/settings.json`; top-level keys not in
115
+ // this set trigger a `nomad doctor` WARN. Defined in ./settings-keys.ts so the
116
+ // schema-derived half can be re-synced mechanically; re-exported here so
117
+ // existing `from './config.ts'` imports keep resolving.
118
+ export { KNOWN_SETTINGS_KEYS } from './settings-keys.ts';
148
119
 
149
120
  /**
150
121
  * Static half of the push allow-list. Entries with trailing `/` are prefix