claude-nomad 0.26.1 → 0.26.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.2](https://github.com/funkadelic/claude-nomad/compare/v0.26.1...v0.26.2) (2026-05-28)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **gitleaks:** condense push output and group doctor check-shared layout ([#161](https://github.com/funkadelic/claude-nomad/issues/161)) ([d9e5758](https://github.com/funkadelic/claude-nomad/commit/d9e57589f616e70b8e2d6249c957af55a25a578b))
9
+
3
10
  ## [0.26.1](https://github.com/funkadelic/claude-nomad/compare/v0.26.0...v0.26.1) (2026-05-27)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.26.1",
3
+ "version": "0.26.2",
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": [
@@ -7,14 +7,14 @@
7
7
  * Owns the post-stage block: run the shared `scanStagedTree` (the same git
8
8
  * init + add + `gitleaks protect --staged` mechanism push uses), classify the
9
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
10
+ * per-session leak rows, then a Remediation block, then a Finding types legend).
11
+ * All external work flows through `scanStagedTree`; this module spawns
12
12
  * nothing itself.
13
13
  */
14
14
 
15
15
  import { join } from 'node:path';
16
16
 
17
- import { green, red, dim, okGlyph, failGlyph } from './color.ts';
17
+ import { green, red, dim, bold, okGlyph, failGlyph } from './color.ts';
18
18
  import { addItem, type DoctorSection } from './commands.doctor.format.ts';
19
19
  import { CLAUDE_HOME } from './config.ts';
20
20
  import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
@@ -32,31 +32,18 @@ function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, s
32
32
  }
33
33
 
34
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.
35
+ * Emit one fail row per affected session (the `✗ ... in session` rows only)
36
+ * and set `process.exitCode = 1`. Rotate-and-scrub guidance and the
37
+ * false-positive hint are NOT emitted here; they belong to `reportRemediation`
38
+ * which is called after all finding rows.
41
39
  */
42
40
  function reportSessionFindings(
43
41
  section: DoctorSection,
44
42
  bySession: Map<string, Map<string, number>>,
45
- logicalBySession: Map<string, string>,
46
- logicalToEncoded: Map<string, string>,
47
43
  ): void {
48
44
  for (const [sid, counts] of bySession) {
49
45
  const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
50
46
  addItem(section, `${red(failGlyph)} ${red(summary)} in session ${sid}`);
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
- ` ${dim(`rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
57
- );
58
- }
59
- addItem(section, ` ${dim('false positive? add a pattern to .gitleaks.toml')}`);
60
47
  }
61
48
  process.exitCode = 1;
62
49
  }
@@ -75,6 +62,36 @@ function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
75
62
  process.exitCode = 1;
76
63
  }
77
64
 
65
+ /**
66
+ * Emit the remediation block after all finding rows. Findings-first ordering
67
+ * means guidance is grouped at the end, not interleaved between session rows.
68
+ * Emits a leading blank, a bold `Remediation` header, one rotate-and-scrub
69
+ * line per session (using `logicalBySession` to build the scrub path), and
70
+ * exactly ONE false-positive hint after the loop (deduped, not once per session).
71
+ * The hint guard omits a rotate row rather than print a wrong path if the
72
+ * invariant ever breaks; the false-positive line is always appended.
73
+ */
74
+ function reportRemediation(
75
+ section: DoctorSection,
76
+ bySession: Map<string, Map<string, number>>,
77
+ logicalBySession: Map<string, string>,
78
+ logicalToEncoded: Map<string, string>,
79
+ ): void {
80
+ addItem(section, '');
81
+ addItem(section, bold('Remediation'));
82
+ for (const [sid] of bySession) {
83
+ const logical = logicalBySession.get(sid);
84
+ /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
85
+ if (logical !== undefined) {
86
+ addItem(
87
+ section,
88
+ ` ${dim(`- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
89
+ );
90
+ }
91
+ }
92
+ addItem(section, ` ${dim('- false positive? add a pattern to .gitleaks.toml')}`);
93
+ }
94
+
78
95
  /**
79
96
  * Captures both the `<logical>` segment and the `<sid>` from a repo-relative
80
97
  * `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
@@ -112,14 +129,14 @@ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
112
129
  }
113
130
 
114
131
  /**
115
- * Emit a deduplicated description legend in the footer: one `[rule-id]:
116
- * description` row per distinct RuleID across all findings, set off by a blank
117
- * line before and after. Sourced from the `Description` gitleaks bakes into
118
- * each finding, so it needs no network; rules whose description is absent
119
- * (older gitleaks, custom rules) are skipped, and the whole block (including
120
- * the surrounding blanks) is omitted when no descriptions are available. The
121
- * legend lives in the footer so a rule hit across many files or sessions
122
- * (e.g. `sonar-api-token`) is explained once, not per occurrence.
132
+ * Emit a deduplicated description legend in the footer: one `- [rule-id]:
133
+ * description` row per distinct RuleID across all findings, headed by a bold
134
+ * `Finding types` header with a leading blank but no trailing blank (renderSection
135
+ * attaches the `└` elbow to the last non-empty item). Rules whose description
136
+ * is absent (older gitleaks, custom rules) are skipped, and the whole block
137
+ * (including the surrounding blank and header) is omitted when no descriptions
138
+ * are available. The legend lives in the footer so a rule hit across many files
139
+ * or sessions (e.g. `sonar-api-token`) is explained once, not per occurrence.
123
140
  */
124
141
  function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): void {
125
142
  const descByRule = new Map<string, string>();
@@ -128,16 +145,19 @@ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): voi
128
145
  }
129
146
  if (descByRule.size === 0) return;
130
147
  addItem(section, '');
148
+ addItem(section, bold('Finding types'));
131
149
  for (const [rule, desc] of descByRule) {
132
- addItem(section, ` ${red(`[${rule}]`)}: ${dim(desc)}`);
150
+ addItem(section, ` ${red(`- [${rule}]`)}: ${dim(desc)}`);
133
151
  }
134
- addItem(section, '');
135
152
  }
136
153
 
137
154
  /**
138
155
  * Scan the staged temp tree through the shared `scanStagedTree` and emit the
139
- * result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
140
- * try/catch (failure -> fail row + exit 1, carrying `err.message` only, never
156
+ * result rows in findings-first order: other-bucket leak rows, then per-session
157
+ * leak rows, then a `Remediation` block (gated on bySession.size > 0), then a
158
+ * `Finding types` legend (gated on at least one finding carrying a Description).
159
+ * Isolates the deepest nesting from `reportCheckShared`: the scan try/catch
160
+ * (failure -> fail row + exit 1, carrying `err.message` only, never
141
161
  * stderr/stdout), the unparseable `findings === null` branch, `partitionFindings`,
142
162
  * and the clean / `other` / `bySession` rows. BOTH buckets gate the clean row: a
143
163
  * finding in `other` (nested transcripts matching neither the flat `SESSION_PATH`
@@ -175,8 +195,9 @@ export function scanAndReport(
175
195
  return;
176
196
  }
177
197
  if (other.length > 0) reportOtherFindings(section, other);
198
+ if (bySession.size > 0) reportSessionFindings(section, bySession);
178
199
  if (bySession.size > 0) {
179
- reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
200
+ reportRemediation(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
180
201
  }
181
202
  emitDescriptionLegend(section, findings);
182
203
  }
@@ -83,10 +83,12 @@ export function readGitleaksReport(reportPath: string): Finding[] | null {
83
83
  *
84
84
  * `forwardStreams` (default `false`): when `true`, the gitleaks redacted
85
85
  * stderr/stdout captured on a non-zero exit is written to the process streams
86
- * so the operator sees which file is dirty. `runGitleaksScan` passes `true`
87
- * (byte-identical push behavior); the read-only `--check-shared` preflight
88
- * leaves it `false` so it never writes to stderr (its scan-failed row carries
89
- * the error message only, never the streams).
86
+ * ONLY on the scan-crash path (when the report is unparseable or missing, i.e.
87
+ * `readGitleaksReport` returns `null`). On the leaks-found path the report
88
+ * parses to a findings array, the structured caller FATAL fully describes the
89
+ * findings, and the raw streams are suppressed to avoid printing them twice.
90
+ * `runGitleaksScan` passes `true`; the read-only `--check-shared` preflight
91
+ * leaves it `false` so it never writes to streams on any path.
90
92
  */
91
93
  export function scanStagedTree(repoDir: string, forwardStreams = false): Finding[] | null {
92
94
  const cacheDir = join(homedir(), '.cache', 'claude-nomad');
@@ -111,11 +113,12 @@ export function scanStagedTree(repoDir: string, forwardStreams = false): Finding
111
113
  } catch (err) {
112
114
  const e = err as NodeJS.ErrnoException & { stderr?: Buffer; stdout?: Buffer };
113
115
  if (e.code === 'ENOENT') throw err;
114
- if (forwardStreams) {
116
+ const report = readGitleaksReport(reportPath);
117
+ if (forwardStreams && report === null) {
115
118
  if (e.stderr) process.stderr.write(e.stderr);
116
119
  if (e.stdout) process.stdout.write(e.stdout);
117
120
  }
118
- return readGitleaksReport(reportPath);
121
+ return report;
119
122
  } finally {
120
123
  rmSync(reportPath, { force: true });
121
124
  }
@@ -92,6 +92,20 @@ export function partitionFindings(findings: Finding[]): {
92
92
  * session, since those nested paths route to the `other` bucket and are
93
93
  * not listed per-session. Pure.
94
94
  */
95
+ /**
96
+ * Render one `Also found:` row for a non-session ("other"-bucket) finding as
97
+ * ` <File>:<StartLine> <RuleID>`, where the line number is the manual-scrub
98
+ * locator for the nested transcript. `StartLine` is typed `number` but comes
99
+ * from an unvalidated `parsed as Finding[]` cast over gitleaks subprocess
100
+ * output, so a missing or non-positive value (gitleaks line numbers are
101
+ * 1-indexed) drops the `:<line>` suffix rather than emit a confusing
102
+ * `:undefined` / `:0`.
103
+ */
104
+ export function formatOtherFinding(f: Finding): string {
105
+ const loc = Number.isInteger(f.StartLine) && f.StartLine > 0 ? `:${f.StartLine}` : '';
106
+ return ` ${f.File}${loc} ${f.RuleID}`;
107
+ }
108
+
95
109
  export function buildSessionAwareFatal(
96
110
  bySession: Map<string, Map<string, number>>,
97
111
  other: Finding[],
@@ -110,7 +124,7 @@ export function buildSessionAwareFatal(
110
124
  lines.push(
111
125
  '',
112
126
  'Also found:',
113
- ...other.map((f) => ` ${f.File} ${f.RuleID}`),
127
+ ...other.map(formatOtherFinding),
114
128
  ' Review with: git diff --cached, then unstage manually.',
115
129
  );
116
130
  }
@@ -133,8 +147,10 @@ export function buildSessionAwareFatal(
133
147
  * missing/locked file, or a non-finding runtime failure, since gitleaks v8.x
134
148
  * returns exit 1 for both "leaks found" and runtime errors) it throws a
135
149
  * distinct scan-failed FATAL so the operator does not chase a phantom
136
- * `nomad drop-session` recovery; the forwarded stderr/stdout above carries the
137
- * underlying gitleaks output.
150
+ * `nomad drop-session` recovery. On the leaks-found path the raw gitleaks
151
+ * streams are suppressed (the session-aware FATAL fully describes the findings).
152
+ * On the scan-failed/null-report path the raw stderr/stdout is forwarded so
153
+ * "Review the gitleaks output above." has something to point at.
138
154
  *
139
155
  * ENOENT (gitleaks or git absent) propagates from the helper and is mapped to
140
156
  * the platform-aware install-hint FATAL. Defense-in-depth: the presence probe