claude-nomad 0.25.0 → 0.25.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/commands.doctor.check-shared.scan.ts +158 -0
  5. package/src/commands.doctor.check-shared.ts +58 -189
  6. package/src/commands.doctor.checks.pathmap.ts +101 -0
  7. package/src/commands.doctor.checks.repo.ts +101 -0
  8. package/src/commands.doctor.checks.repository.ts +105 -0
  9. package/src/commands.doctor.checks.settings.ts +88 -0
  10. package/src/commands.doctor.format.ts +18 -0
  11. package/src/commands.doctor.ts +10 -7
  12. package/src/commands.drop-session.git.ts +81 -0
  13. package/src/commands.drop-session.ts +79 -138
  14. package/src/commands.pull.ts +3 -2
  15. package/src/commands.push.allowlist.ts +119 -0
  16. package/src/commands.push.ts +6 -121
  17. package/src/commands.update.git.ts +90 -0
  18. package/src/commands.update.resolve.ts +138 -0
  19. package/src/commands.update.test-helpers.git.ts +107 -0
  20. package/src/commands.update.ts +4 -221
  21. package/src/diff.ts +2 -1
  22. package/src/extras-sync.diff.ts +40 -0
  23. package/src/extras-sync.guards.ts +52 -0
  24. package/src/extras-sync.ts +146 -236
  25. package/src/init.classify.ts +1 -1
  26. package/src/init.snapshot.ts +3 -1
  27. package/src/init.ts +2 -1
  28. package/src/links.ts +3 -10
  29. package/src/nomad.dispatch.ts +25 -0
  30. package/src/nomad.help.ts +43 -0
  31. package/src/nomad.ts +6 -68
  32. package/src/preview.ts +2 -1
  33. package/src/push-gitleaks.scan.ts +115 -0
  34. package/src/push-gitleaks.ts +50 -106
  35. package/src/remap.ts +3 -1
  36. package/src/resume.ts +2 -1
  37. package/src/update.fork-extras.ts +2 -1
  38. package/src/utils.fs.ts +152 -0
  39. package/src/utils.json.ts +55 -0
  40. package/src/utils.lockfile.ts +131 -0
  41. package/src/utils.ts +0 -327
  42. package/src/commands.doctor.checks.ts +0 -350
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.1](https://github.com/funkadelic/claude-nomad/compare/v0.25.0...v0.25.1) (2026-05-26)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **doctor:** scan --check-shared like push to fix false negatives ([#134](https://github.com/funkadelic/claude-nomad/issues/134)) ([5063028](https://github.com/funkadelic/claude-nomad/commit/5063028c6a695709f7f2d2dfe3cceabedecc17f9))
9
+
10
+
11
+ ### Changed
12
+
13
+ * split over-cap source and test files under the ~200-line cap ([#136](https://github.com/funkadelic/claude-nomad/issues/136)) ([0cc7eed](https://github.com/funkadelic/claude-nomad/commit/0cc7eed13abbc85084b04cfc8b686e53b26133c6))
14
+
3
15
  ## [0.25.0](https://github.com/funkadelic/claude-nomad/compare/v0.24.0...v0.25.0) (2026-05-25)
4
16
 
5
17
 
package/README.md CHANGED
@@ -409,7 +409,7 @@ Exit codes:
409
409
  - `0` on any drop, including an idempotent re-run.
410
410
  - `1` with `✗ no staged session matches <id>` on stderr when neither a `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries matches.
411
411
 
412
- What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrub the local files separately.
412
+ What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrubbing or removing the local files is REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.ts`) re-mirrors the local content into the staged tree on the next push, so a drop without a local scrub re-stages the same secret.
413
413
 
414
414
  ### Recovery flow: gitleaks FATAL on a session JSONL
415
415
 
@@ -427,7 +427,7 @@ After recovery, re-run nomad push.
427
427
 
428
428
  Two branches from here:
429
429
 
430
- 1. **Real secret.** Rotate the credential at its provider (revoke in dashboard, issue replacement), then run `nomad drop-session <sid-aaaa>` to remove the contaminated staged copy, then re-run `nomad push`. To clear the secret from the local transcript as well, edit `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` to scrub the offending lines; the next `remapPush` copies the cleaned version forward. If the local file is not important to you, leave it alone, the staged-tree drop is enough to publish the push.
430
+ 1. **Real secret.** Rotate the credential at its provider first (revoke in dashboard, issue replacement) before touching anything else. Running `nomad drop-session <sid-aaaa>` clears the contaminated copy from the current staged tree, but that alone is NOT durable: `remapPush` (in `src/remap.ts`) does a full rm-and-copy mirror of your LOCAL transcripts into `shared/projects/` on every push, so the next `nomad push` re-copies the un-scrubbed local file forward and re-stages the same secret. The durable fix is to rotate AND scrub or remove the local transcript at `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent directory under that encoded dir, if present) so the next `remapPush` carries clean content forward. Do not leave the local file un-scrubbed and expect the staged-tree drop to hold.
431
431
 
432
432
  2. **False positive.** Add an allowlist regex to `.gitleaks.toml` at the repo root that matches the noise pattern but not real-secret formats, commit it, then re-run `nomad push`. The new allowlist propagates to deploy hosts via `nomad update`.
433
433
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Scan-result classification and row emission for the `nomad doctor
3
+ * --check-shared` preflight. Split out of `commands.doctor.check-shared.ts` to
4
+ * keep both files under the line cap; `reportCheckShared` (the public reporter)
5
+ * stays in the sibling and calls `scanAndReport` after staging the temp tree.
6
+ *
7
+ * Owns the post-stage block: run the shared `scanStagedTree` (the same git
8
+ * init + add + `gitleaks protect --staged` mechanism push uses), classify the
9
+ * findings via `partitionFindings`, and emit the doctor glyph rows (clean,
10
+ * per-session leak with rotate-and-scrub guidance, and the nested "other"
11
+ * bucket). All external work flows through `scanStagedTree`; this module spawns
12
+ * nothing itself.
13
+ */
14
+
15
+ import { join } from 'node:path';
16
+
17
+ import { green, red, okGlyph, failGlyph } from './color.ts';
18
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
19
+ import { CLAUDE_HOME } from './config.ts';
20
+ import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
21
+
22
+ /**
23
+ * Recover the absolute live transcript path
24
+ * `~/.claude/projects/<encoded>/<sid>.jsonl` by mapping the finding's
25
+ * `<logical>` through the staging association, falling back to the logical name
26
+ * when the association is missing (defensive; the temp-tree build guarantees a hit).
27
+ */
28
+ function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
29
+ /* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
30
+ const encoded = logicalToEncoded.get(logical) ?? logical;
31
+ return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
32
+ }
33
+
34
+ /**
35
+ * Emit one fail row per affected session plus rotate-and-scrub + allowlist
36
+ * guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
37
+ * `<logical>` captured from the same match that keyed `bySession`, so the
38
+ * scrub-path hint reuses the authoritative parse. The hint guard omits a row
39
+ * rather than print a wrong path if the invariant ever breaks; the leak row is
40
+ * always emitted.
41
+ */
42
+ function reportSessionFindings(
43
+ section: DoctorSection,
44
+ bySession: Map<string, Map<string, number>>,
45
+ logicalBySession: Map<string, string>,
46
+ logicalToEncoded: Map<string, string>,
47
+ ): void {
48
+ for (const [sid, counts] of bySession) {
49
+ const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
50
+ addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
51
+ const logical = logicalBySession.get(sid);
52
+ /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
53
+ if (logical !== undefined) {
54
+ addItem(
55
+ section,
56
+ ` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
57
+ );
58
+ }
59
+ addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
60
+ }
61
+ process.exitCode = 1;
62
+ }
63
+
64
+ /**
65
+ * Emit one fail row per non-session ("other"-bucket) finding and set
66
+ * `process.exitCode = 1`. These are findings whose `File` did not match the
67
+ * flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
68
+ * which `nomad push` would still stage). Names the repo-relative path and
69
+ * RuleID only, never the matched secret.
70
+ */
71
+ function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
72
+ for (const f of other) {
73
+ addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
74
+ }
75
+ process.exitCode = 1;
76
+ }
77
+
78
+ /**
79
+ * Captures both the `<logical>` segment and the `<sid>` from a repo-relative
80
+ * `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
81
+ * the exported `SESSION_PATH` shape; the `<logical>` group lets the scrub-path
82
+ * hint reuse this single authoritative parse.
83
+ */
84
+ const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
85
+
86
+ /**
87
+ * Emit the single canonical clean row reporting the scanned-project count
88
+ * (`staged` is the number of mapped project dirs staged, not a transcript
89
+ * total). Centralizing the literal keeps every clean path (zero-staged,
90
+ * scanned-clean, findings-but-no-`other`) phrased consistently.
91
+ */
92
+ export function emitClean(section: DoctorSection, staged: number): void {
93
+ addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
94
+ }
95
+
96
+ /**
97
+ * Build the `sid -> <logical>` association from the findings, capturing both
98
+ * groups from the same `SESSION_PATH_LOGICAL` match so the scrub-path hint
99
+ * never re-derives the logical name. First match per sid wins (the scrub path
100
+ * is per session, not per finding).
101
+ */
102
+ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
103
+ const logicalBySession = new Map<string, string>();
104
+ for (const f of findings) {
105
+ const m = SESSION_PATH_LOGICAL.exec(f.File);
106
+ if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
107
+ /* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
108
+ logicalBySession.set(m[2], m[1] ?? '');
109
+ }
110
+ }
111
+ return logicalBySession;
112
+ }
113
+
114
+ /**
115
+ * Scan the staged temp tree through the shared `scanStagedTree` and emit the
116
+ * result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
117
+ * try/catch (failure -> fail row + exit 1, carrying `err.message` only, never
118
+ * stderr/stdout), the unparseable `findings === null` branch, `partitionFindings`,
119
+ * and the clean / `other` / `bySession` rows. BOTH buckets gate the clean row: a
120
+ * finding in `other` (nested transcripts matching neither the flat `SESSION_PATH`
121
+ * nor any session) is still a stageable secret push would catch, so a
122
+ * `bySession`-only gate would make the preflight weaker than the push scan.
123
+ */
124
+ export function scanAndReport(
125
+ section: DoctorSection,
126
+ tmpRoot: string,
127
+ staged: number,
128
+ logicalToEncoded: Map<string, string>,
129
+ ): void {
130
+ let findings: Finding[] | null;
131
+ try {
132
+ findings = scanStagedTree(tmpRoot);
133
+ } catch (err) {
134
+ // ENOENT (binary vanished mid-flow) or a git failure. The top-of-flow probe
135
+ // WARN-skips a truly missing gitleaks; this catch reports a scan-failed FAIL
136
+ // row with err.message only (never stderr/stdout, which can echo
137
+ // redacted-but-sensitive scan output).
138
+ addItem(section, `${red(failGlyph)} scan failed: ${(err as Error).message}`);
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ if (findings === null) {
143
+ // Non-zero gitleaks exit with no parseable report. Carry no stream output,
144
+ // matching runGitleaksScan on the push side.
145
+ addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report`);
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ const { bySession, other } = partitionFindings(findings);
150
+ if (bySession.size === 0 && other.length === 0) {
151
+ emitClean(section, staged);
152
+ return;
153
+ }
154
+ if (other.length > 0) reportOtherFindings(section, other);
155
+ if (bySession.size > 0) {
156
+ reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
157
+ }
158
+ }
@@ -1,23 +1,18 @@
1
1
  /**
2
2
  * Owns the `nomad doctor --check-shared` preflight reporter.
3
3
  *
4
- * Read-only diagnostic that runs gitleaks against the LOCAL session
5
- * transcripts `nomad push` would stage (each path-map entry mapped to this
6
- * host), surfacing secret leaks BEFORE the push pipeline fires. Mirrors the
7
- * push-time scan (`runGitleaksScan` in `./push-gitleaks.ts`) but: scans a
8
- * temp COPY of the live transcripts (never the live dir), uses the
9
- * purpose-built `gitleaks dir` subcommand, and emits doctor-flavored glyph
10
- * rows + `process.exitCode` instead of throwing a push-flavored FATAL.
4
+ * Read-only diagnostic that runs gitleaks against the LOCAL session transcripts
5
+ * `nomad push` would stage (each path-map entry mapped to this host), surfacing
6
+ * leaks BEFORE the push pipeline fires. Stages a temp COPY of the live
7
+ * transcripts into a throwaway git repo and delegates the scan + row emission
8
+ * to `scanAndReport` (`./commands.doctor.check-shared.scan.ts`), which runs the
9
+ * shared `scanStagedTree` (`gitleaks protect --staged`, the same mechanism push
10
+ * uses), so the preflight cannot miss a secret the push gate would catch. Emits
11
+ * doctor glyph rows + `process.exitCode` instead of throwing a FATAL.
11
12
  *
12
- * Composition only: reuses `partitionFindings` / `readGitleaksReport` /
13
- * `SESSION_PATH` (the gitleaks JSON parser) and `copyDirJsonlOnly` (the
14
- * push-fidelity source filter) verbatim. The doctor-flavored guidance
15
- * composer is new (push's `buildSessionAwareFatal` is wrong at doctor time:
16
- * `nomad drop-session` operates on the staged tree, and nothing is staged
17
- * during a preflight).
18
- *
19
- * All external calls use `execFileSync` argv-array form (no shell), the
20
- * codebase PUSH-04 invariant.
13
+ * This file owns probe-readiness, temp-tree staging, and orchestration; the
14
+ * findings classification + guidance composer live in the `.scan.ts` sibling.
15
+ * All external calls use `execFileSync` argv-array form (PUSH-04).
21
16
  */
22
17
 
23
18
  import { randomBytes } from 'node:crypto';
@@ -26,19 +21,18 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
26
21
  import { homedir } from 'node:os';
27
22
  import { join } from 'node:path';
28
23
 
29
- import { green, red, yellow, okGlyph, failGlyph, warnGlyph } from './color.ts';
24
+ import { red, yellow, failGlyph, warnGlyph } from './color.ts';
25
+ import { emitClean, scanAndReport } from './commands.doctor.check-shared.scan.ts';
30
26
  import { addItem, type DoctorSection } from './commands.doctor.format.ts';
31
27
  import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
32
- import { type Finding, partitionFindings, readGitleaksReport } from './push-gitleaks.ts';
33
28
  import { copyDirJsonlOnly } from './remap.ts';
34
- import { encodePath, nowTimestamp, readJson } from './utils.ts';
29
+ import { nowTimestamp } from './utils.fs.ts';
30
+ import { encodePath, readJson } from './utils.json.ts';
35
31
 
36
32
  /**
37
33
  * Result of staging the scan tree. `malformed` is true when `path-map.json`
38
34
  * exists but does not parse as JSON; the caller emits a FAIL row and stops
39
- * (mirroring `reportPathMap`'s `readJsonSafe` degradation) rather than letting
40
- * the `SyntaxError` propagate past `nomad.ts`'s `NomadFatal`-only handler and
41
- * abort the whole doctor run with a stack trace.
35
+ * rather than letting the `SyntaxError` abort the whole doctor run.
42
36
  */
43
37
  type ScanTree = {
44
38
  logicalToEncoded: Map<string, string>;
@@ -48,12 +42,10 @@ type ScanTree = {
48
42
 
49
43
  /**
50
44
  * Build the temp staging tree under `tmpRoot/shared/projects/<logical>/` by
51
- * copying each local encoded session dir that resolves to a path-map logical
52
- * for this host. Returns the `logical -> encoded-dir` association so the
53
- * scrub-path hint can name the live `~/.claude/projects/<encoded>/<sid>.jsonl`
54
- * file, plus the count of session dirs staged. Skips `TBD`/unmapped entries
55
- * (the D-03 scope: exactly what `remapPush` would stage). Uses the same
56
- * depth-0 `*.jsonl` filter as push via `copyDirJsonlOnly`. A malformed
45
+ * copying each local encoded session dir that maps to a path-map logical for
46
+ * this host (exactly what `remapPush` would stage; same depth-0 `*.jsonl`
47
+ * filter via `copyDirJsonlOnly`). Returns the `logical -> encoded-dir`
48
+ * association (for the scrub-path hint) plus the count staged. A malformed
57
49
  * `path-map.json` sets `malformed: true` rather than throwing.
58
50
  */
59
51
  function buildScanTree(tmpRoot: string): ScanTree {
@@ -93,11 +85,9 @@ function buildScanTree(tmpRoot: string): ScanTree {
93
85
 
94
86
  /**
95
87
  * Probe for the gitleaks binary on PATH, distinguishing the not-installed case
96
- * (ENOENT -> `'missing'`, a WARN skip per the read-only doctor contract) from a
97
- * real probe failure (EACCES, corrupt binary -> `{ fail: message }`, a FAIL).
98
- * Mirrors `reportGitleaksProbe`'s ENOENT-vs-other split rather than collapsing
99
- * every failure into "not on PATH". Probes directly (not via `probeGitleaks`)
100
- * so the doctor flavor stays read-only and need not unwrap a `NomadFatal`.
88
+ * (ENOENT -> `'missing'`, a WARN skip) from a real probe failure (EACCES,
89
+ * corrupt binary -> `{ fail: message }`, a FAIL). Mirrors `reportGitleaksProbe`'s
90
+ * ENOENT-vs-other split; probes directly so the doctor flavor stays read-only.
101
91
  */
102
92
  function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
103
93
  try {
@@ -110,123 +100,46 @@ function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
110
100
  }
111
101
 
112
102
  /**
113
- * Recover the live encoded-dir for a finding by mapping its `<logical>`
114
- * segment through the staging association. Returns the absolute live
115
- * transcript path `~/.claude/projects/<encoded>/<sid>.jsonl`, falling back to
116
- * the logical name when the association is missing (defensive; the temp-tree
117
- * model guarantees a hit).
103
+ * Probe-readiness guard ladder. Returns true to proceed to the scan, false to
104
+ * stop after emitting an early row. When the orchestrator already probed
105
+ * (`gitleaksReady === true`) the subcommand is not re-invoked; otherwise this
106
+ * probes for itself, mapping `missing` to a WARN skip (exit untouched) and a
107
+ * non-ENOENT failure to a FAIL row + `process.exitCode = 1`.
118
108
  */
119
- function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
120
- /* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
121
- const encoded = logicalToEncoded.get(logical) ?? logical;
122
- return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
123
- }
124
-
125
- /**
126
- * Emit one fail row per affected session plus rotate-and-scrub + allowlist
127
- * guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
128
- * `<logical>` segment captured from the same `SESSION_PATH` match that keyed
129
- * `bySession`, so the scrub-path hint reuses the authoritative parse rather
130
- * than re-deriving the logical name from the finding `File`. Every `bySession`
131
- * sid is keyed in `logicalBySession` (both come from the identical sid capture),
132
- * so the scrub hint always renders; the guard omits the hint rather than
133
- * printing a wrong path if that invariant ever breaks, and the leak row itself
134
- * is always emitted.
135
- */
136
- function reportSessionFindings(
137
- section: DoctorSection,
138
- bySession: Map<string, Map<string, number>>,
139
- logicalBySession: Map<string, string>,
140
- logicalToEncoded: Map<string, string>,
141
- ): void {
142
- for (const [sid, counts] of bySession) {
143
- const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
144
- addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
145
- const logical = logicalBySession.get(sid);
146
- /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
147
- if (logical !== undefined) {
148
- addItem(
149
- section,
150
- ` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
151
- );
152
- }
153
- addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
109
+ function ensureGitleaksReady(section: DoctorSection, gitleaksReady?: boolean): boolean {
110
+ if (gitleaksReady === true) return true;
111
+ const probe = probeGitleaksForScan();
112
+ if (probe === 'missing') {
113
+ addItem(section, `${yellow(warnGlyph)} gitleaks not on PATH; shared scan skipped`);
114
+ return false;
154
115
  }
155
- process.exitCode = 1;
156
- }
157
-
158
- /**
159
- * Emit one fail row per non-session ("other"-bucket) finding and set
160
- * `process.exitCode = 1`. These are findings whose `File` did not match the
161
- * flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
162
- * etc., which `copyDirJsonlOnly` copies recursively and `nomad push` would
163
- * stage). Names the repo-relative path and RuleID only, never the matched
164
- * secret. Mirrors the push-side guarantee that any finding outside `bySession`
165
- * still fails the scan (`buildSessionAwareFatal`'s `LEGACY_FATAL` fallback).
166
- */
167
- function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
168
- for (const f of other) {
169
- addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
116
+ if (probe !== 'ok') {
117
+ addItem(section, `${red(failGlyph)} gitleaks probe failed: ${probe.fail}`);
118
+ process.exitCode = 1;
119
+ return false;
170
120
  }
171
- process.exitCode = 1;
172
- }
173
-
174
- /**
175
- * Captures both the `<logical>` segment and the `<sid>` from a repo-relative
176
- * `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
177
- * the exported `SESSION_PATH` shape; the extra `<logical>` group lets the
178
- * scrub-path hint reuse this single authoritative parse.
179
- */
180
- const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
181
-
182
- /**
183
- * Emit the single canonical clean row reporting the scanned-project count
184
- * (`staged` is the number of mapped project directories whose transcripts were
185
- * staged, not a transcript total). Centralizing the literal (zero-staged,
186
- * scanned-clean, and the findings-but-no-`other` paths all route through here)
187
- * keeps the phrasing consistent and prevents one copy drifting from another,
188
- * which is what let a "no session findings == clean" path slip past the
189
- * `other`-bucket gate.
190
- */
191
- function emitClean(section: DoctorSection, staged: number): void {
192
- addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
121
+ return true;
193
122
  }
194
123
 
195
124
  /**
196
125
  * Run the `--check-shared` preflight and append its rows to `section`.
197
126
  *
198
- * Flow (D-01..D-10): probe gitleaks (missing -> one WARN row, exit untouched;
199
- * a non-ENOENT probe failure -> FAIL row + exit 1, mirroring
200
- * `reportGitleaksProbe`); stage a temp copy of this-host mapped session dirs
201
- * (a malformed `path-map.json` -> FAIL row + exit 1, no crash); scan with the
202
- * positional `gitleaks dir shared/projects` invocation (NOT `--source`, which
203
- * `gitleaks dir` rejects with exit 126); on a clean scan emit one ok row
204
- * reporting the scanned-project count; on findings emit per-session fail rows
205
- * with rotate-and-scrub guidance and set `process.exitCode = 1`; on a non-zero
206
- * exit with no parseable report emit a scan-failed fail row carrying the
207
- * gitleaks error message (never its stderr/stdout, which may hold secrets) +
208
- * exit 1 (do not chase phantom sessions). Removes both the temp report and the
209
- * temp tree in `finally` on success and failure. Never writes to stderr
210
- * (read-only doctor contract).
127
+ * Thin orchestrator (D-01..D-10): `ensureGitleaksReady` gates entry (a missing
128
+ * binary WARN-skips, a probe failure FAILs); `buildScanTree` stages a temp copy
129
+ * of this-host mapped session dirs (a malformed `path-map.json` -> FAIL row,
130
+ * no crash); `scanAndReport` runs the shared `scanStagedTree` (the same
131
+ * mechanism push uses, so the preflight cannot miss what push catches) and
132
+ * emits the clean / leak / scan-failed rows, setting `process.exitCode = 1` on
133
+ * any failure. The temp report + tree (including the injected throwaway `.git`)
134
+ * are removed in `finally` on every path. Never writes to stderr (read-only
135
+ * doctor contract: `scanStagedTree` runs with `forwardStreams` left false).
211
136
  *
212
- * `gitleaksReady` lets the doctor orchestrator pass the result of the
213
- * Repository section's gitleaks probe so the `version` subcommand is not
214
- * invoked a second time on a `--check-shared` run. When omitted (the module's
215
- * standalone contract) this reporter probes for itself.
137
+ * `gitleaksReady` lets the doctor orchestrator pass the Repository section's
138
+ * probe result so `version` is not invoked twice on a `--check-shared` run;
139
+ * when omitted (the standalone contract) this reporter probes for itself.
216
140
  */
217
141
  export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolean): void {
218
- if (gitleaksReady !== true) {
219
- const probe = probeGitleaksForScan();
220
- if (probe === 'missing') {
221
- addItem(section, `${yellow(warnGlyph)} gitleaks not on PATH; shared scan skipped`);
222
- return;
223
- }
224
- if (probe !== 'ok') {
225
- addItem(section, `${red(failGlyph)} gitleaks probe failed: ${probe.fail}`);
226
- process.exitCode = 1;
227
- return;
228
- }
229
- }
142
+ if (!ensureGitleaksReady(section, gitleaksReady)) return;
230
143
 
231
144
  const cacheDir = join(homedir(), '.cache', 'claude-nomad');
232
145
  mkdirSync(cacheDir, { recursive: true });
@@ -252,56 +165,12 @@ export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolea
252
165
  emitClean(section, 0);
253
166
  return;
254
167
  }
255
- const tomlPath = join(REPO_HOME, '.gitleaks.toml');
256
- const args: string[] = [
257
- 'dir',
258
- 'shared/projects',
259
- '--redact',
260
- '-v',
261
- '--report-format=json',
262
- `--report-path=${reportPath}`,
263
- ];
264
- if (existsSync(tomlPath)) args.push('--config', tomlPath);
265
-
266
- try {
267
- execFileSync('gitleaks', args, { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] });
268
- emitClean(section, staged);
269
- } catch (err) {
270
- const findings = readGitleaksReport(reportPath);
271
- if (findings === null) {
272
- // Carry the gitleaks error message only (never e.stderr/e.stdout, which
273
- // can echo the redacted-but-still-sensitive scan output), matching
274
- // runGitleaksScan on the push side.
275
- const msg = (err as Error).message;
276
- addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report (${msg})`);
277
- process.exitCode = 1;
278
- return;
279
- }
280
- const { bySession, other } = partitionFindings(findings);
281
- // Both buckets must gate the clean row. A finding routed to `other`
282
- // (nested transcripts that match neither the flat SESSION_PATH nor any
283
- // session) is still a stageable secret push would catch, so reporting
284
- // clean on `bySession.size === 0` alone would make the preflight weaker
285
- // than the push scan it stands in for.
286
- if (bySession.size === 0 && other.length === 0) {
287
- emitClean(section, staged);
288
- return;
289
- }
290
- if (other.length > 0) reportOtherFindings(section, other);
291
- if (bySession.size > 0) {
292
- // Capture <logical> alongside <sid> from the same authoritative match
293
- // so the scrub hint never re-derives the logical name independently.
294
- const logicalBySession = new Map<string, string>();
295
- for (const f of findings) {
296
- const m = SESSION_PATH_LOGICAL.exec(f.File);
297
- if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
298
- /* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
299
- logicalBySession.set(m[2], m[1] ?? '');
300
- }
301
- }
302
- reportSessionFindings(section, bySession, logicalBySession, logicalToEncoded);
303
- }
304
- }
168
+ // Scan the temp tree through the SAME mechanism push uses (scanStagedTree:
169
+ // git init + add + gitleaks protect --staged), so the preflight cannot miss
170
+ // a secret the push gate would catch. forwardStreams stays false so the
171
+ // read-only doctor never writes gitleaks output to stderr; the injected
172
+ // throwaway .git under tmpRoot is removed by the finally below.
173
+ scanAndReport(section, tmpRoot, staged, logicalToEncoded);
305
174
  } finally {
306
175
  rmSync(reportPath, { force: true });
307
176
  rmSync(tmpRoot, { recursive: true, force: true });
@@ -0,0 +1,101 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { blue, cyan, dim, failGlyph, green, infoGlyph, okGlyph, red } from './color.ts';
5
+ import { HOST, NEVER_SYNC, REPO_HOME, type PathMap } from './config.ts';
6
+ import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
7
+ import { encodePath } from './utils.json.ts';
8
+
9
+ /**
10
+ * Path-map reporters for `cmdDoctor`: the mapped-projects listing, the
11
+ * path-encoding collision scan, and the never-sync visibility line. Each helper
12
+ * appends items to its target `DoctorSection` and signals failure by setting
13
+ * `process.exitCode = 1`. Read-only: FAIL lines stay on stdout.
14
+ */
15
+
16
+ /** Emits the mapped-projects header for the current host and one line per mapped project. */
17
+ function reportMappedProjects(section: DoctorSection, map: PathMap): void {
18
+ const mapped = Object.entries(map.projects).filter(([, hosts]) => hosts[HOST]);
19
+ addItem(
20
+ section,
21
+ `${dim(infoGlyph)} mapped projects for ${cyan(HOST)}: ${dim(String(mapped.length))}`,
22
+ );
23
+ for (const [name, hosts] of mapped) {
24
+ addItem(section, `${dim(infoGlyph)} ${name} -> ${blue(hosts[HOST])}`);
25
+ }
26
+ }
27
+
28
+ /** Scans every host of every project for encodePath collisions; emits failGlyph per collision (sets exitCode=1), okGlyph when clean. */
29
+ function reportPathCollisions(section: DoctorSection, map: PathMap): void {
30
+ const seen = new Map<string, string>();
31
+ let collisionCount = 0;
32
+ for (const hosts of Object.values(map.projects)) {
33
+ for (const abspath of Object.values(hosts)) {
34
+ if (!abspath || abspath === 'TBD') continue;
35
+ const encoded = encodePath(abspath);
36
+ const prior = seen.get(encoded);
37
+ if (prior !== undefined && prior !== abspath) {
38
+ addItem(
39
+ section,
40
+ `${red(failGlyph)} path-encoding collision: ${prior} and ${abspath} both encode to ${encoded}`,
41
+ );
42
+ collisionCount++;
43
+ } else {
44
+ seen.set(encoded, abspath);
45
+ }
46
+ }
47
+ }
48
+ if (collisionCount > 0) process.exitCode = 1;
49
+ else addItem(section, `${green(okGlyph)} path-encoding: no collisions`);
50
+ }
51
+
52
+ /** Pushes mapped projects for the current host and FAILs on path-encoding collisions across hosts; FAILs when path-map.json is missing. */
53
+ export function reportPathMap(section: DoctorSection): void {
54
+ const mapPath = join(REPO_HOME, 'path-map.json');
55
+ if (!existsSync(mapPath)) {
56
+ addItem(section, `${red(failGlyph)} path-map.json missing at ${blue(mapPath)}`);
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+ const map = readJsonSafe<PathMap>(mapPath, mapPath, section);
61
+ if (map === null) return;
62
+ // Guard non-object `projects` and per-project non-object `hosts` so the
63
+ // helpers' `hosts[HOST]` / `Object.values(hosts)` cannot throw mid-output
64
+ // and break the tolerant-doctor contract.
65
+ const projects: unknown = (map as { projects?: unknown }).projects;
66
+ if (projects === null || typeof projects !== 'object' || Array.isArray(projects)) {
67
+ addItem(
68
+ section,
69
+ `${red(failGlyph)} path-map.json invalid schema: "projects" must be an object`,
70
+ );
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ for (const [name, hosts] of Object.entries(projects as Record<string, unknown>)) {
75
+ if (hosts === null || typeof hosts !== 'object' || Array.isArray(hosts)) {
76
+ addItem(
77
+ section,
78
+ `${red(failGlyph)} path-map.json invalid schema: project "${name}" hosts must be an object`,
79
+ );
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ for (const [hostName, mappedPath] of Object.entries(hosts as Record<string, unknown>)) {
84
+ if (typeof mappedPath !== 'string') {
85
+ addItem(
86
+ section,
87
+ `${red(failGlyph)} path-map.json invalid schema: project "${name}" host "${hostName}" path must be a string`,
88
+ );
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ }
93
+ }
94
+ reportMappedProjects(section, map);
95
+ reportPathCollisions(section, map);
96
+ }
97
+
98
+ /** Pushes the comma-joined NEVER_SYNC set for informational visibility. */
99
+ export function reportNeverSync(section: DoctorSection): void {
100
+ addItem(section, `${dim(infoGlyph)} never-sync items: ${[...NEVER_SYNC].join(', ')}`);
101
+ }