claude-nomad 0.26.1 → 0.27.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.
@@ -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);
@@ -0,0 +1,96 @@
1
+ import { cpSync, existsSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
5
+ import { assertSafeLocalRoot, assertSafeLogical } from './extras-sync.guards.ts';
6
+ import { log } from './utils.ts';
7
+ import { readPathMap } from './utils.json.ts';
8
+
9
+ /** Parsed `path-map.json` plus its validated `extras` block. */
10
+ export type ValidatedExtras = { map: PathMap; extrasMap: Record<string, string[]> };
11
+
12
+ /** Skip counts: `unmapped` per-project (no host path / `'TBD'`), `skipped` per-dirname (not whitelisted). */
13
+ export type ExtrasCounts = { unmapped: number; skipped: number };
14
+
15
+ /**
16
+ * Load and validate `path-map.json` for an extras op, owning the guard order
17
+ * so the "FATAL before any filesystem mutation" contract holds for every
18
+ * caller. Returns the parsed map plus its `extras` block, or `null` on a clean
19
+ * early-exit (missing `path-map.json`, a missing repo extras dir when
20
+ * `requireRepoExtras`, or an empty/absent `extras` key). THE VALIDATION PASS
21
+ * runs here, up-front over the whole map (`assertSafeLogical` per logical,
22
+ * `assertSafeLocalRoot` per mapped non-`'TBD'` path) so a clean entry ahead of
23
+ * a poisoned one cannot let a `mkdirSync`/`cpSync` land before the FATAL fires.
24
+ *
25
+ * @param opts.requireRepoExtras - Also require `shared/extras/` (pull side).
26
+ * @param opts.missingMsg - `log()` line on the missing-prereq exit (omitted for
27
+ * the divergence check, which skips silently).
28
+ */
29
+ export function loadValidatedExtras(opts: {
30
+ requireRepoExtras?: boolean;
31
+ missingMsg?: string;
32
+ }): ValidatedExtras | null {
33
+ const mapPath = join(REPO_HOME, 'path-map.json');
34
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
35
+ if (!existsSync(mapPath) || (opts.requireRepoExtras === true && !existsSync(repoExtras))) {
36
+ if (opts.missingMsg !== undefined) log(opts.missingMsg);
37
+ return null;
38
+ }
39
+
40
+ const map = readPathMap(mapPath);
41
+ const extrasMap = map.extras ?? {};
42
+ if (Object.keys(extrasMap).length === 0) return null;
43
+
44
+ for (const logical of Object.keys(extrasMap)) {
45
+ assertSafeLogical(logical);
46
+ const localRoot = map.projects[logical]?.[HOST];
47
+ if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
48
+ }
49
+ return { map, extrasMap };
50
+ }
51
+
52
+ /**
53
+ * Yield every surviving `{ logical, localRoot, dirname }` extras target after
54
+ * the per-project and per-dirname skip filters, mutating `counts` as it goes
55
+ * (`unmapped++` for a project with no host path / `'TBD'`, then skip it;
56
+ * `skipped++` for a dirname outside `SUPPORTED_EXTRAS`). Shared by push, pull,
57
+ * and the divergence check so all three walk identical skip/count semantics;
58
+ * the caller builds src/dst from the yielded triple.
59
+ *
60
+ * @param v - validated path-map plus its extras block.
61
+ * @param counts - mutated in place as targets are skipped or yielded. Skips are
62
+ * counted silently (no per-skip log line); the caller's detail arrays and the
63
+ * collapsed count row carry that information to the tree renderer.
64
+ */
65
+ export function* eachExtrasTarget(
66
+ v: ValidatedExtras,
67
+ counts: ExtrasCounts,
68
+ ): Generator<{ logical: string; localRoot: string; dirname: string }> {
69
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
70
+ for (const [logical, dirnames] of Object.entries(v.extrasMap)) {
71
+ const localRoot = v.map.projects[logical]?.[HOST];
72
+ if (!localRoot || localRoot === 'TBD') {
73
+ counts.unmapped++;
74
+ continue;
75
+ }
76
+ for (const dirname of dirnames) {
77
+ if (!whitelist.includes(dirname)) {
78
+ counts.skipped++;
79
+ continue;
80
+ }
81
+ yield { logical, localRoot, dirname };
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Recursive mirror copy: `rmSync` then `cpSync` so dst-only entries are
88
+ * removed (true mirror, not just overwrite). Passes `verbatimSymlinks: true`
89
+ * to keep relative symlink targets unrewritten across hosts (Pitfall 1;
90
+ * nodejs/node issue 41693). Exported so the test file can call it directly;
91
+ * `remapExtrasPush` and `remapExtrasPull` are the primary public API.
92
+ */
93
+ export function copyExtras(src: string, dst: string): void {
94
+ rmSync(dst, { recursive: true, force: true });
95
+ cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
96
+ }
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { REPO_HOME } from './config.ts';
5
+ import {
6
+ copyExtras,
7
+ eachExtrasTarget,
8
+ loadValidatedExtras,
9
+ type ExtrasCounts,
10
+ type ValidatedExtras,
11
+ } from './extras-sync.core.ts';
12
+ import { backupExtrasWrite, backupRepoWrite } from './utils.fs.ts';
13
+
14
+ /** Detail lists returned by an extras op: items copied (wet) and would-copy (dry). */
15
+ type ExtrasDetail = ExtrasCounts & { done: string[]; would: string[] };
16
+
17
+ /** One yielded extras target: a (logical, host localRoot, dirname) triple. */
18
+ type ExtrasTarget = { logical: string; localRoot: string; dirname: string };
19
+
20
+ /**
21
+ * Shared copy loop for `remapExtrasPush` / `remapExtrasPull`. Walks every
22
+ * surviving extras target (counts mutated via `eachExtrasTarget`; skips are
23
+ * counted silently, no per-skip log line), resolves src/dst through the
24
+ * side-specific `paths(...)`, and either records the would-copy item under
25
+ * `dryRun` or backs up + copies and records the done item. Returns
26
+ * `{ unmapped, skipped, done, would }`; the public wrappers rename
27
+ * `done`/`would` to push/pull-specific field names. No per-item log lines: the
28
+ * detail arrays carry that information to the tree renderer.
29
+ *
30
+ * @param v - validated path-map plus its extras block.
31
+ * @param dryRun - when `true`, collect `would` without mutating.
32
+ * @param paths - resolves `{ src, dst }` for one target (side-specific).
33
+ * @param backup - snapshots the dst before clobber (side-specific).
34
+ * @returns the counts plus the done/would detail lists.
35
+ */
36
+ function runExtrasOp(
37
+ v: ValidatedExtras,
38
+ dryRun: boolean,
39
+ paths: (t: ExtrasTarget) => { src: string; dst: string },
40
+ backup: (dst: string, localRoot: string) => void,
41
+ ): ExtrasDetail {
42
+ const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
43
+ const done: string[] = [];
44
+ const would: string[] = [];
45
+ for (const t of eachExtrasTarget(v, counts)) {
46
+ const { src, dst } = paths(t);
47
+ if (!existsSync(src)) continue;
48
+ const item = `${t.logical}/${t.dirname}`;
49
+ if (dryRun) {
50
+ would.push(item);
51
+ continue;
52
+ }
53
+ backup(dst, t.localRoot);
54
+ copyExtras(src, dst);
55
+ done.push(item);
56
+ }
57
+ return { ...counts, done, would };
58
+ }
59
+
60
+ /**
61
+ * Push: copy whitelisted extras directories under each project's localRoot
62
+ * into the repo at `shared/extras/<logical>/<dirname>/`. Returns
63
+ * `{ unmapped, skipped, pushed, wouldPush }` with intentionally asymmetric
64
+ * count granularity (see `eachExtrasTarget`): `unmapped` per-project, `skipped`
65
+ * per-dirname; both feed the summary row. `pushed` / `wouldPush` hold
66
+ * `<logical>/<dirname>` strings copied (wet) or that would copy under
67
+ * `opts.dryRun` so cmdPush can render a grouped tree. Skips are counted
68
+ * silently and per-item log lines are dropped; counts are unchanged. Legacy
69
+ * `path-map.json` without an `extras` key returns empty arrays and zero counts
70
+ * cleanly.
71
+ *
72
+ * @param ts - backup timestamp namespace.
73
+ * @param opts.dryRun - when `true`, collect `wouldPush` without mutating.
74
+ */
75
+ export function remapExtrasPush(
76
+ ts: string,
77
+ opts: { dryRun?: boolean } = {},
78
+ ): ExtrasCounts & { pushed: string[]; wouldPush: string[] } {
79
+ const dryRun = opts.dryRun === true;
80
+ const v = loadValidatedExtras({ missingMsg: 'no path-map.json; skipping extras push' });
81
+ if (v === null) return { unmapped: 0, skipped: 0, pushed: [], wouldPush: [] };
82
+
83
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
84
+ if (!dryRun) mkdirSync(repoExtras, { recursive: true });
85
+
86
+ const { unmapped, skipped, done, would } = runExtrasOp(
87
+ v,
88
+ dryRun,
89
+ ({ localRoot, logical, dirname }) => ({
90
+ src: join(localRoot, dirname),
91
+ dst: join(repoExtras, logical, dirname),
92
+ }),
93
+ (dst) => backupRepoWrite(dst, ts, REPO_HOME),
94
+ );
95
+ return { unmapped, skipped, pushed: done, wouldPush: would };
96
+ }
97
+
98
+ /**
99
+ * Pull: copy whitelisted extras from `shared/extras/<logical>/<dirname>/`
100
+ * back into each project's localRoot on this host. Returns
101
+ * `{ unmapped, skipped, pulled, wouldPull }` with the same asymmetric count
102
+ * granularity as `remapExtrasPush`; `pulled` / `wouldPull` hold
103
+ * `<logical>/<dirname>` strings for the grouped tree. Skips are counted
104
+ * silently and per-item log lines are dropped; counts are unchanged. Uses
105
+ * `backupExtrasWrite` (not `backupBeforeWrite`) because
106
+ * `<localRoot>/<dirname>` lives outside `CLAUDE_HOME` and the standard helper's
107
+ * relative-path guard would no-op and lose prior content. Legacy
108
+ * `path-map.json` without an `extras` key, or a missing `shared/extras/`, both
109
+ * produce a clean no-op.
110
+ *
111
+ * @param ts - backup timestamp namespace.
112
+ * @param opts.dryRun - when `true`, collect `wouldPull` without mutating.
113
+ */
114
+ export function remapExtrasPull(
115
+ ts: string,
116
+ opts: { dryRun?: boolean } = {},
117
+ ): ExtrasCounts & { pulled: string[]; wouldPull: string[] } {
118
+ const dryRun = opts.dryRun === true;
119
+ const v = loadValidatedExtras({
120
+ requireRepoExtras: true,
121
+ missingMsg: 'no path-map or repo extras dir; skipping extras remap',
122
+ });
123
+ if (v === null) return { unmapped: 0, skipped: 0, pulled: [], wouldPull: [] };
124
+
125
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
126
+ const { unmapped, skipped, done, would } = runExtrasOp(
127
+ v,
128
+ dryRun,
129
+ ({ localRoot, logical, dirname }) => ({
130
+ src: join(repoExtras, logical, dirname),
131
+ dst: join(localRoot, dirname),
132
+ }),
133
+ // Snapshot the host-side dst BEFORE copyExtras clobbers it. Anchor on
134
+ // localRoot so the backup tree mirrors the project layout.
135
+ (dst, localRoot) => backupExtrasWrite(dst, ts, localRoot),
136
+ );
137
+ return { unmapped, skipped, pulled: done, wouldPull: would };
138
+ }