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,102 +1,27 @@
1
- import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
4
+ import { HOME, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
5
5
  import { listDivergingFiles } from './extras-sync.diff.ts';
6
- import { assertSafeLocalRoot, assertSafeLogical } from './extras-sync.guards.ts';
7
- import { log, warn } from './utils.ts';
8
- import { backupExtrasWrite, backupRepoWrite } from './utils.fs.ts';
9
- import { encodePath, readPathMap } from './utils.json.ts';
10
-
11
- /** Parsed `path-map.json` plus its validated `extras` block. */
12
- type ValidatedExtras = { map: PathMap; extrasMap: Record<string, string[]> };
13
-
14
- /** Skip counts: `unmapped` per-project (no host path / `'TBD'`), `skipped` per-dirname (not whitelisted). */
15
- type ExtrasCounts = { unmapped: number; skipped: number };
16
-
17
- /**
18
- * Load and validate `path-map.json` for an extras op, owning the guard order
19
- * so the "FATAL before any filesystem mutation" contract holds for every
20
- * caller. Returns the parsed map plus its `extras` block, or `null` on a clean
21
- * early-exit (missing `path-map.json`, a missing repo extras dir when
22
- * `requireRepoExtras`, or an empty/absent `extras` key). THE VALIDATION PASS
23
- * runs here, up-front over the whole map (`assertSafeLogical` per logical,
24
- * `assertSafeLocalRoot` per mapped non-`'TBD'` path) so a clean entry ahead of
25
- * a poisoned one cannot let a `mkdirSync`/`cpSync` land before the FATAL fires.
26
- *
27
- * @param opts.requireRepoExtras - Also require `shared/extras/` (pull side).
28
- * @param opts.missingMsg - `log()` line on the missing-prereq exit (omitted for
29
- * the divergence check, which skips silently).
30
- */
31
- function loadValidatedExtras(opts: {
32
- requireRepoExtras?: boolean;
33
- missingMsg?: string;
34
- }): ValidatedExtras | null {
35
- const mapPath = join(REPO_HOME, 'path-map.json');
36
- const repoExtras = join(REPO_HOME, 'shared', 'extras');
37
- if (!existsSync(mapPath) || (opts.requireRepoExtras === true && !existsSync(repoExtras))) {
38
- if (opts.missingMsg !== undefined) log(opts.missingMsg);
39
- return null;
40
- }
41
-
42
- const map = readPathMap(mapPath);
43
- const extrasMap = map.extras ?? {};
44
- if (Object.keys(extrasMap).length === 0) return null;
45
-
46
- for (const logical of Object.keys(extrasMap)) {
47
- assertSafeLogical(logical);
48
- const localRoot = map.projects[logical]?.[HOST];
49
- if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
50
- }
51
- return { map, extrasMap };
52
- }
53
-
54
- /**
55
- * Yield every surviving `{ logical, localRoot, dirname }` extras target after
56
- * the per-project and per-dirname skip filters, mutating `counts` as it goes
57
- * (`unmapped++` for a project with no host path / `'TBD'`, then skip it;
58
- * `skipped++` for a dirname outside `SUPPORTED_EXTRAS`). Shared by push, pull,
59
- * and the divergence check so all three walk identical skip/count semantics;
60
- * the caller builds src/dst from the yielded triple.
61
- *
62
- * @param quiet - Suppress the per-skip `log()` lines (the read-only divergence
63
- * check skips silently; push/pull narrate). Counts increment either way.
64
- */
65
- function* eachExtrasTarget(
66
- v: ValidatedExtras,
67
- counts: ExtrasCounts,
68
- quiet = false,
69
- ): Generator<{ logical: string; localRoot: string; dirname: string }> {
70
- const whitelist: readonly string[] = SUPPORTED_EXTRAS;
71
- for (const [logical, dirnames] of Object.entries(v.extrasMap)) {
72
- const localRoot = v.map.projects[logical]?.[HOST];
73
- if (!localRoot || localRoot === 'TBD') {
74
- counts.unmapped++;
75
- if (!quiet) log(`skip ${logical}: no path for ${HOST}`);
76
- continue;
77
- }
78
- for (const dirname of dirnames) {
79
- if (!whitelist.includes(dirname)) {
80
- counts.skipped++;
81
- if (!quiet) log(`skip ${dirname} for ${logical}: not in SUPPORTED_EXTRAS`);
82
- continue;
83
- }
84
- yield { logical, localRoot, dirname };
85
- }
86
- }
87
- }
88
-
89
- /**
90
- * Recursive mirror copy: `rmSync` then `cpSync` so dst-only entries are
91
- * removed (true mirror, not just overwrite). Passes `verbatimSymlinks: true`
92
- * to keep relative symlink targets unrewritten across hosts (Pitfall 1;
93
- * nodejs/node issue 41693). Exported so the test file can call it directly;
94
- * `remapExtrasPush` and `remapExtrasPull` are the primary public API.
95
- */
96
- export function copyExtras(src: string, dst: string): void {
97
- rmSync(dst, { recursive: true, force: true });
98
- cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
99
- }
6
+ import {
7
+ copyExtras,
8
+ eachExtrasTarget,
9
+ loadValidatedExtras,
10
+ type ExtrasCounts,
11
+ type ValidatedExtras,
12
+ } from './extras-sync.core.ts';
13
+ import { assertSafeLogical } from './extras-sync.guards.ts';
14
+ import { warn } from './utils.ts';
15
+ import { encodePath } from './utils.json.ts';
16
+
17
+ // Re-export the shared primitives so existing import sites that pull them from
18
+ // `./extras-sync.ts` (tests call `copyExtras` directly) keep working unchanged.
19
+ export { copyExtras, eachExtrasTarget, loadValidatedExtras };
20
+ export type { ExtrasCounts, ValidatedExtras };
21
+
22
+ // The two public remap ops live in the sibling module to hold the soft
23
+ // line-cap; re-exported here so `./extras-sync.ts` stays the public surface.
24
+ export { remapExtrasPull, remapExtrasPush } from './extras-sync.remap.ts';
100
25
 
101
26
  /**
102
27
  * Repo-relative `shared/extras/<logical>/<dirname>` paths for every (logical,
@@ -125,77 +50,6 @@ export function whitelistedExtrasPaths(map: PathMap): string[] {
125
50
  return [...paths].sort((a, b) => a.localeCompare(b));
126
51
  }
127
52
 
128
- /**
129
- * Push: copy whitelisted extras directories under each project's localRoot
130
- * into the repo at `shared/extras/<logical>/<dirname>/`. Returns
131
- * `{ unmapped, skipped }` with intentionally asymmetric granularity (see
132
- * `eachExtrasTarget`): `unmapped` per-project, `skipped` per-dirname; both feed
133
- * `emitSummary`. `opts.dryRun` logs `would push extras:` lines without writing,
134
- * with identical count semantics. Legacy `path-map.json` without an `extras`
135
- * key returns `{ unmapped: 0, skipped: 0 }` cleanly.
136
- */
137
- export function remapExtrasPush(ts: string, opts: { dryRun?: boolean } = {}): ExtrasCounts {
138
- const dryRun = opts.dryRun === true;
139
- const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
140
- const v = loadValidatedExtras({ missingMsg: 'no path-map.json; skipping extras push' });
141
- if (v === null) return counts;
142
-
143
- const repoExtras = join(REPO_HOME, 'shared', 'extras');
144
- if (!dryRun) mkdirSync(repoExtras, { recursive: true });
145
-
146
- for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts)) {
147
- const src = join(localRoot, dirname);
148
- if (!existsSync(src)) continue;
149
- const dst = join(repoExtras, logical, dirname);
150
- if (dryRun) {
151
- log(`would push extras: ${src} -> ${dst}`);
152
- continue;
153
- }
154
- backupRepoWrite(dst, ts, REPO_HOME);
155
- copyExtras(src, dst);
156
- log(`pushed extras ${logical}/${dirname} -> shared/extras/${logical}/${dirname}`);
157
- }
158
- return counts;
159
- }
160
-
161
- /**
162
- * Pull: copy whitelisted extras from `shared/extras/<logical>/<dirname>/`
163
- * back into each project's localRoot on this host. Returns `{ unmapped,
164
- * skipped }` with the same asymmetric granularity as `remapExtrasPush`.
165
- * `opts.dryRun` logs `would overwrite extras:` lines without writing. Uses
166
- * `backupExtrasWrite` (not `backupBeforeWrite`) because `<localRoot>/<dirname>`
167
- * lives outside `CLAUDE_HOME` and the standard helper's relative-path guard
168
- * would no-op and lose prior content. Legacy `path-map.json` without an
169
- * `extras` key, or a missing `shared/extras/`, both produce a clean no-op.
170
- */
171
- export function remapExtrasPull(ts: string, opts: { dryRun?: boolean } = {}): ExtrasCounts {
172
- const dryRun = opts.dryRun === true;
173
- const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
174
- const v = loadValidatedExtras({
175
- requireRepoExtras: true,
176
- missingMsg: 'no path-map or repo extras dir; skipping extras remap',
177
- });
178
- if (v === null) return counts;
179
-
180
- const repoExtras = join(REPO_HOME, 'shared', 'extras');
181
-
182
- for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts)) {
183
- const src = join(repoExtras, logical, dirname);
184
- if (!existsSync(src)) continue;
185
- const dst = join(localRoot, dirname);
186
- if (dryRun) {
187
- log(`would overwrite extras: ${dst} (from ${src})`);
188
- continue;
189
- }
190
- // Snapshot the host-side dst BEFORE copyExtras clobbers it. Anchor
191
- // on localRoot so the backup tree mirrors the project layout.
192
- backupExtrasWrite(dst, ts, localRoot);
193
- copyExtras(src, dst);
194
- log(`pulled extras ${logical}/${dirname} -> ${dst}`);
195
- }
196
- return counts;
197
- }
198
-
199
53
  /**
200
54
  * Read-only pre-pull check: compare local `<localRoot>/<dirname>/` against
201
55
  * the just-pulled `shared/extras/<logical>/<dirname>/` and emit a WARN per
@@ -214,7 +68,7 @@ export function divergenceCheckExtras(ts: string): void {
214
68
 
215
69
  const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
216
70
  const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'extras');
217
- for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts, true)) {
71
+ for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts)) {
218
72
  const local = join(localRoot, dirname);
219
73
  const repo = join(REPO_HOME, 'shared', 'extras', logical, dirname);
220
74
  if (!existsSync(local) || !existsSync(repo)) continue;
package/src/links.ts CHANGED
@@ -64,8 +64,19 @@ export function applySharedLinks(ts: string, opts: { dryRun?: boolean } = {}): v
64
64
  * would produce. The unified textual diff of the would-be-written content
65
65
  * is produced by `computePreview` in `src/preview.ts`, not here, to keep
66
66
  * this function's contract simple (mutation or log-only).
67
+ *
68
+ * Returns `{ label }` where `label` is the override-source tag
69
+ * (`'<HOST>.json'` when a host override exists, else `'no host overrides'`).
70
+ * The WET path no longer logs `wrote settings.json (base + <label>)` inline;
71
+ * `cmdPull` consumes the returned label to render the Settings row of its
72
+ * grouped tree. The dry-run `would write settings.json ...` log and the
73
+ * drift WARN are unchanged (the WET success log is the only thing that moved).
74
+ *
75
+ * @param ts - backup timestamp namespace for `backupBeforeWrite`.
76
+ * @param opts.dryRun - when `true`, log the would-write line and skip mutation.
77
+ * @returns `{ label }` describing the override source for the Settings row.
67
78
  */
68
- export function regenerateSettings(ts: string, opts: { dryRun?: boolean } = {}): void {
79
+ export function regenerateSettings(ts: string, opts: { dryRun?: boolean } = {}): { label: string } {
69
80
  const dryRun = opts.dryRun === true;
70
81
  const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
71
82
  const hostPath = join(REPO_HOME, 'hosts', `${HOST}.json`);
@@ -107,10 +118,10 @@ export function regenerateSettings(ts: string, opts: { dryRun?: boolean } = {}):
107
118
 
108
119
  if (dryRun) {
109
120
  log(`would write settings.json (base + ${overrideLabel})`);
110
- return;
121
+ return { label: overrideLabel };
111
122
  }
112
123
 
113
124
  backupBeforeWrite(settingsPath, ts);
114
125
  writeJsonAtomic(settingsPath, merged);
115
- log(`wrote settings.json (base + ${overrideLabel})`);
126
+ return { label: overrideLabel };
116
127
  }
@@ -0,0 +1,91 @@
1
+ import { failGlyph, red } from './color.ts';
2
+
3
+ /**
4
+ * Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
5
+ * `failGlyph` constant may carry. Header rendering composes its own
6
+ * spacing (`${red(failGlyph)} ${header}`), so the section-header path
7
+ * must use the unpadded codepoint to avoid a double space on WSL.
8
+ */
9
+ const FAIL_GLYPH_BARE = '✗';
10
+
11
+ /**
12
+ * Tree-style output builder shared by `cmdDoctor`, `cmdPush`, and `cmdPull`.
13
+ * Callers build an ordered list of `DoctorSection`s, push pre-rendered
14
+ * plain-text items into the relevant section, then call `renderTree`
15
+ * (aliased `renderDoctor` for doctor's call site) to emit a Claude Code
16
+ * `/doctor`-style tree (`Header` / ` ├ item` / ` └ last`) on stdout.
17
+ *
18
+ * Color and status glyphs (okGlyph/warnGlyph/failGlyph/infoGlyph) already
19
+ * live inside the item text; this module never re-colors or re-tokenizes.
20
+ * Sections with zero items are skipped at render time (no empty headers).
21
+ *
22
+ * Output goes directly through `console.log` rather than `utils.log` so the
23
+ * dim `ℹ︎` info glyph used by `pull` / `push` / `init` does NOT appear in
24
+ * doctor output (doctor has its own glyphs per row). Test assertions continue
25
+ * to spy on `console.log`.
26
+ */
27
+ export type DoctorSection = {
28
+ header: string;
29
+ items: string[];
30
+ };
31
+
32
+ /** Construct an empty section with the given header. */
33
+ export function section(header: string): DoctorSection {
34
+ return { header, items: [] };
35
+ }
36
+
37
+ /** Append one rendered line to a section. */
38
+ export function addItem(s: DoctorSection, text: string): void {
39
+ s.items.push(text);
40
+ }
41
+
42
+ /**
43
+ * True when any item in the section contains the FAIL glyph.
44
+ * Color-wrapped failGlyph (`[31m✗[39m`) still contains the
45
+ * glyph as a substring, so this works for both color-on and color-off output.
46
+ */
47
+ function sectionFailed(s: DoctorSection): boolean {
48
+ return s.items.some((line) => line.includes(failGlyph));
49
+ }
50
+
51
+ /**
52
+ * Render one section: a (possibly fail-glyph-prefixed) header followed by its
53
+ * items as a tree. Empty-string items print as true blank lines; the `└` elbow
54
+ * attaches to the last non-empty item so a trailing blank cannot strand it.
55
+ */
56
+ function renderSection(s: DoctorSection): void {
57
+ const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
58
+ console.log(header);
59
+ const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
60
+ for (let j = 0; j < s.items.length; j++) {
61
+ if (s.items[j] === '') console.log('');
62
+ else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Emit the full grouped tree. Skips empty sections, prefixes failed-section
68
+ * headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
69
+ * `grep -F '✗'` catches both row and header failures), and writes one blank
70
+ * line between rendered sections (no leading or trailing blank).
71
+ *
72
+ * An empty-string item renders as a true blank line (no tree connector), which
73
+ * lets a reporter set off a footer block (e.g. the `--check-shared` description
74
+ * legend) with vertical whitespace. The `└` connector attaches to the last
75
+ * non-empty item rather than the last array slot so a trailing blank does not
76
+ * strand the elbow on an empty line.
77
+ */
78
+ export function renderTree(sections: DoctorSection[]): void {
79
+ const visible = sections.filter((s) => s.items.length > 0);
80
+ for (let i = 0; i < visible.length; i++) {
81
+ if (i > 0) console.log('');
82
+ renderSection(visible[i]);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Back-compat alias for `renderTree`. Doctor's call site imports
88
+ * `renderDoctor`; push/pull import `renderTree`. Both point at the same
89
+ * implementation so doctor output stays byte-identical.
90
+ */
91
+ export const renderDoctor = renderTree;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shared leak-scan verdict vocabulary for `cmdPush`. Both the dry-run preview
3
+ * (`previewPushLeaks` in `./push-preview.ts`) and the real-push scan
4
+ * (`scanPushVerdict` here) produce the same structured `LeakVerdict` so the
5
+ * one-line Leak scan row rendered inside the grouped tree cannot drift between
6
+ * the two paths. The multi-line `recovery` block (the `buildSessionAwareFatal`
7
+ * body) is printed by `cmdPush` BELOW the rendered tree on a leak.
8
+ *
9
+ * On a real push the scan still aborts the run: `cmdPush` renders the tree with
10
+ * the ✗ verdict row, then throws a `NomadFatal` carrying `recovery` so the
11
+ * existing catch prints the recovery block and sets a non-zero exit. The
12
+ * dry-run path never throws; it only sets `process.exitCode = 1`.
13
+ */
14
+
15
+ import { failGlyph, green, okGlyph, red } from './color.ts';
16
+ import { REPO_HOME } from './config.ts';
17
+ import { gitleaksInstallHint } from './push-checks.ts';
18
+ import {
19
+ type Finding,
20
+ buildSessionAwareFatal,
21
+ partitionFindings,
22
+ scanStagedTree,
23
+ } from './push-gitleaks.ts';
24
+
25
+ /**
26
+ * Structured leak-scan verdict.
27
+ *
28
+ * - `leak`: `true` only when findings were present (a scan crash is surfaced
29
+ * via a ✗ `verdictRow` but is NOT a leak, so the dry-run path does not throw).
30
+ * - `verdictRow`: the rendered one-line Leak scan row (glyph embedded).
31
+ * - `recovery`: the `buildSessionAwareFatal` body on a leak, else `null`.
32
+ */
33
+ export type LeakVerdict = {
34
+ leak: boolean;
35
+ verdictRow: string;
36
+ recovery: string | null;
37
+ };
38
+
39
+ /** Rendered clean Leak scan row (no findings). */
40
+ export const noLeaksRow = (): string => `${green(okGlyph)} no leaks`;
41
+
42
+ /** Rendered ✗ Leak scan row (caller supplies the message text). */
43
+ export const failRow = (text: string): string => `${red(failGlyph)} ${text}`;
44
+
45
+ /**
46
+ * Build the one-line ✗ Leak scan verdict row for a non-empty findings set,
47
+ * naming the affected session count. Falls back to the raw finding count when
48
+ * no finding matches the session-path pattern. Pure.
49
+ *
50
+ * @param findings - The non-empty findings array.
51
+ * @returns The rendered ✗ verdict row.
52
+ */
53
+ export function leakVerdictRow(findings: Finding[]): string {
54
+ const { bySession } = partitionFindings(findings);
55
+ const n = bySession.size > 0 ? bySession.size : findings.length;
56
+ return failRow(`gitleaks detected secrets in ${n} session transcript(s)`);
57
+ }
58
+
59
+ /**
60
+ * Build the leak verdict for a non-empty findings set: the ✗ verdict row plus
61
+ * the `buildSessionAwareFatal` recovery body. Pure (no `process.exitCode`
62
+ * side effect; callers own that). Shared by the dry-run and real-push paths so
63
+ * the verdict row and recovery body cannot diverge.
64
+ *
65
+ * @param findings - The non-empty findings array.
66
+ * @returns A `leak=true` verdict carrying the ✗ row and recovery body.
67
+ */
68
+ function leakFound(findings: Finding[]): LeakVerdict {
69
+ const { bySession, other } = partitionFindings(findings);
70
+ return {
71
+ leak: true,
72
+ verdictRow: leakVerdictRow(findings),
73
+ recovery: buildSessionAwareFatal(bySession, other),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Map a `scanStagedTree` result to a structured `LeakVerdict`, applying the
79
+ * shared side effect (`process.exitCode = 1` on findings or a scan crash). A
80
+ * `null` report (scan crash) yields a ✗ scan-failed row with `recovery=null`
81
+ * and is NOT classified as a `leak` (so the dry-run path neither throws nor
82
+ * offers a phantom drop-session hint). An empty array yields the clean
83
+ * `✓ no leaks` row. Non-empty findings yield the ✗ verdict row plus the
84
+ * `buildSessionAwareFatal` recovery body.
85
+ *
86
+ * @param findings - Output of `scanStagedTree`, or `null` on scan crash.
87
+ * @returns The structured verdict for the Leak scan section.
88
+ */
89
+ export function verdictFromFindings(findings: Finding[] | null): LeakVerdict {
90
+ if (findings === null) {
91
+ process.exitCode = 1;
92
+ return {
93
+ leak: false,
94
+ verdictRow: failRow('scan failed, no parseable report'),
95
+ recovery: null,
96
+ };
97
+ }
98
+ if (findings.length === 0) return { leak: false, verdictRow: noLeaksRow(), recovery: null };
99
+ process.exitCode = 1;
100
+ return leakFound(findings);
101
+ }
102
+
103
+ /**
104
+ * Verdict for a scan that threw before producing a report (e.g. gitleaks/git
105
+ * absent on the dry-run path). Sets `process.exitCode = 1` and yields a ✗ row
106
+ * with `recovery=null`. Does not mark `leak` so the caller never throws.
107
+ *
108
+ * @param text - The ✗ row message text.
109
+ * @returns The structured scan-error verdict.
110
+ */
111
+ export function verdictScanError(text: string): LeakVerdict {
112
+ process.exitCode = 1;
113
+ return { leak: false, verdictRow: failRow(text), recovery: null };
114
+ }
115
+
116
+ /**
117
+ * Run the real-push staged gitleaks scan (the same `scanStagedTree(REPO_HOME,
118
+ * true)` the push gate uses) and RETURN a structured `LeakVerdict` instead of
119
+ * throwing. This lets `cmdPush` render the grouped tree with the ✗ Leak scan
120
+ * row BEFORE re-raising the FATAL so the recovery block prints below the tree.
121
+ *
122
+ * On findings: `leak=true`, `verdictRow` is the ✗ row, `recovery` is the
123
+ * `buildSessionAwareFatal` body. On a clean scan: `✓ no leaks`. On a null
124
+ * report (scanner crash, malformed JSON): a ✗ scan-failed verdict with
125
+ * `recovery` set to the same scan-failed FATAL string `runGitleaksScan` would
126
+ * have thrown, so `cmdPush` still aborts. ENOENT (gitleaks/git absent) maps to
127
+ * the platform-aware install-hint FATAL as `recovery` with a ✗ row.
128
+ *
129
+ * @returns The structured verdict for the real-push Leak scan section.
130
+ */
131
+ export function scanPushVerdict(): LeakVerdict {
132
+ let findings: Finding[] | null;
133
+ try {
134
+ findings = scanStagedTree(REPO_HOME, true);
135
+ } catch (err) {
136
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
137
+ return {
138
+ leak: true,
139
+ verdictRow: failRow('gitleaks not found'),
140
+ recovery: gitleaksInstallHint(),
141
+ };
142
+ }
143
+ throw err;
144
+ }
145
+ if (findings === null) {
146
+ return {
147
+ leak: true,
148
+ verdictRow: failRow('scan failed, no parseable report'),
149
+ recovery: 'gitleaks scan failed: no parseable JSON report. Review the gitleaks output above.',
150
+ };
151
+ }
152
+ if (findings.length === 0) return { leak: false, verdictRow: noLeaksRow(), recovery: null };
153
+ return leakFound(findings);
154
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Push dry-run gitleaks leak preview.
3
+ *
4
+ * Stages a read-only copy of the session transcripts and extras that a real
5
+ * `nomad push` would send for this host, then runs `scanStagedTree` against
6
+ * that temp tree. The verdict is RETURNED as a structured
7
+ * `{ leak, verdictRow, recovery }` (rather than logged) so `cmdPush` can place
8
+ * `verdictRow` in the grouped tree's Leak scan section and print `recovery`
9
+ * (the `buildSessionAwareFatal` body) below the tree. On findings it still sets
10
+ * `process.exitCode = 1`.
11
+ *
12
+ * This module is the push-dry-run-only path. The `nomad doctor --check-shared`
13
+ * preflight (session-only scan, no extras) is unchanged and lives in
14
+ * `./commands.doctor.check-shared.ts`. Extras-in-doctor is a deferred
15
+ * follow-up (out of scope here).
16
+ */
17
+
18
+ import { randomBytes } from 'node:crypto';
19
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { dim, infoGlyph } from './color.ts';
24
+ import { CLAUDE_HOME, HOST, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
25
+ import { assertSafeLogical } from './extras-sync.guards.ts';
26
+ import { copyExtras } from './extras-sync.ts';
27
+ import { copyDirJsonlOnly } from './remap.ts';
28
+ import { type LeakVerdict, verdictFromFindings, verdictScanError } from './push-leak-verdict.ts';
29
+ import { scanStagedTree } from './push-gitleaks.ts';
30
+ import { nowTimestamp } from './utils.fs.ts';
31
+ import { encodePath } from './utils.json.ts';
32
+
33
+ /** Rendered neutral Leak scan row when there was nothing to scan. */
34
+ const NOTHING_TO_SCAN_ROW = `${dim(infoGlyph)} nothing to scan, no leaks`;
35
+
36
+ /**
37
+ * Stage local session transcripts for HOST into `<tmpRoot>/shared/projects/<logical>/`
38
+ * using the same depth-0 `*.jsonl` filter as a real push. Builds the
39
+ * encoded-dir-to-logical reverse map from `map.projects` (skipping TBD or
40
+ * missing entries), then copies each matching `~/.claude/projects/<dir>/`.
41
+ *
42
+ * @param tmpRoot - Root of the throwaway staging tree.
43
+ * @param map - Parsed `path-map.json`.
44
+ * @returns Number of session directories staged.
45
+ */
46
+ function stageSessions(tmpRoot: string, map: PathMap): number {
47
+ if (typeof map.projects !== 'object' || map.projects === null) return 0;
48
+
49
+ const reverse = new Map<string, string>();
50
+ for (const [logical, hosts] of Object.entries(map.projects)) {
51
+ assertSafeLogical(logical);
52
+ const p = hosts[HOST];
53
+ if (!p || p === 'TBD') continue;
54
+ reverse.set(encodePath(p), logical);
55
+ }
56
+
57
+ const localProjects = join(CLAUDE_HOME, 'projects');
58
+ if (!existsSync(localProjects)) return 0;
59
+
60
+ let staged = 0;
61
+ for (const dir of readdirSync(localProjects)) {
62
+ const logical = reverse.get(dir);
63
+ if (!logical) continue;
64
+ copyDirJsonlOnly(join(localProjects, dir), join(tmpRoot, 'shared', 'projects', logical));
65
+ staged++;
66
+ }
67
+ return staged;
68
+ }
69
+
70
+ /**
71
+ * Stage whitelisted extras for HOST into
72
+ * `<tmpRoot>/shared/extras/<logical>/<dirname>/`. Mirrors the skip semantics of
73
+ * `remapExtrasPush`: skips logicals with no host path or `'TBD'`, skips
74
+ * dirnames not in `SUPPORTED_EXTRAS`, and skips when the source path does not
75
+ * exist locally.
76
+ *
77
+ * Guards a non-object or missing `map.projects` defensively (mirroring
78
+ * `stageSessions`): a malformed map with an `extras` block but no usable
79
+ * `projects` stages nothing rather than throwing on the `map.projects[logical]`
80
+ * read.
81
+ *
82
+ * @param tmpRoot - Root of the throwaway staging tree.
83
+ * @param map - Parsed `path-map.json`.
84
+ * @returns Number of extras entries staged.
85
+ */
86
+ function stageExtras(tmpRoot: string, map: PathMap): number {
87
+ if (typeof map.projects !== 'object' || map.projects === null) return 0;
88
+ const extrasMap = map.extras ?? {};
89
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
90
+ let staged = 0;
91
+ for (const [logical, dirnames] of Object.entries(extrasMap)) {
92
+ assertSafeLogical(logical);
93
+ const localRoot = map.projects[logical]?.[HOST];
94
+ if (!localRoot || localRoot === 'TBD') continue;
95
+ for (const dirname of dirnames) {
96
+ if (!whitelist.includes(dirname)) continue;
97
+ const src = join(localRoot, dirname);
98
+ if (!existsSync(src)) continue;
99
+ const dst = join(tmpRoot, 'shared', 'extras', logical, dirname);
100
+ copyExtras(src, dst);
101
+ staged++;
102
+ }
103
+ }
104
+ return staged;
105
+ }
106
+
107
+ /**
108
+ * Run a read-only gitleaks leak preview of what `nomad push` would stage for
109
+ * this host: both mapped session transcripts
110
+ * (`shared/projects/<logical>/*.jsonl`) and opted-in extras
111
+ * (`shared/extras/<logical>/<dirname>`).
112
+ *
113
+ * Stages the content into a throwaway tree under
114
+ * `~/.cache/claude-nomad/push-preview-tree-<stamp>` and runs `scanStagedTree`
115
+ * with `forwardStreams=false` (read-only: no gitleaks stderr/stdout leak to the
116
+ * terminal). The temp tree is always removed in a `finally`, regardless of
117
+ * whether the scan found leaks, crashed, or returned clean. `REPO_HOME/shared`
118
+ * is never written.
119
+ *
120
+ * Returns a structured `LeakVerdict` rather than logging the verdict line so
121
+ * `cmdPush` can render `verdictRow` in the Leak scan section and print
122
+ * `recovery` below the tree. Side effects preserved: `process.exitCode = 1` on
123
+ * findings AND on a scan crash. A scan that throws maps to a ✗ scan-error row
124
+ * with `exitCode = 1`: ENOENT (gitleaks/git absent) keeps the "not on PATH"
125
+ * wording, any other error (e.g. EACCES) surfaces its real message so the
126
+ * cause is not mislabeled. Nothing-to-scan maps to a neutral ℹ︎ row.
127
+ *
128
+ * Fails closed before any copy: an unsafe `logical` key (path separator or
129
+ * `..`) raised by `assertSafeLogical` in the staging step propagates out as a
130
+ * `NomadFatal` to `cmdPush`, and the `finally` still removes the temp tree.
131
+ *
132
+ * @param map - Parsed `path-map.json` (already in scope from `cmdPush`).
133
+ * @returns The structured verdict for the Leak scan section.
134
+ */
135
+ export function previewPushLeaks(map: PathMap): LeakVerdict {
136
+ const cacheDir = join(homedir(), '.cache', 'claude-nomad');
137
+ mkdirSync(cacheDir, { recursive: true });
138
+ const stamp = `${nowTimestamp()}-${process.pid}-${randomBytes(4).toString('hex')}`;
139
+ const tmpRoot = join(cacheDir, `push-preview-tree-${stamp}`);
140
+
141
+ try {
142
+ const sessionCount = stageSessions(tmpRoot, map);
143
+ const extrasCount = stageExtras(tmpRoot, map);
144
+ if (sessionCount + extrasCount === 0) {
145
+ return { leak: false, verdictRow: NOTHING_TO_SCAN_ROW, recovery: null };
146
+ }
147
+ let findings: ReturnType<typeof scanStagedTree>;
148
+ try {
149
+ findings = scanStagedTree(tmpRoot);
150
+ } catch (err) {
151
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
152
+ return verdictScanError('scan error (git or gitleaks not on PATH)');
153
+ }
154
+ return verdictScanError(`scan error: ${(err as Error).message}`);
155
+ }
156
+ return verdictFromFindings(findings);
157
+ } finally {
158
+ rmSync(tmpRoot, { recursive: true, force: true });
159
+ }
160
+ }