claude-nomad 0.26.2 → 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.
@@ -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
+ }