claude-nomad 0.26.0 → 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 +21 -0
- package/package.json +2 -1
- package/src/commands.doctor.check-shared.scan.ts +54 -33
- package/src/diff-lines.ts +43 -0
- package/src/preview.ts +17 -36
- package/src/push-gitleaks.scan.ts +9 -6
- package/src/push-gitleaks.ts +19 -3
- package/src/remap.ts +62 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.26.1](https://github.com/funkadelic/claude-nomad/compare/v0.26.0...v0.26.1) (2026-05-27)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **preview:** replace diffJsonStrings parallel walk with jsdiff LCS line diff ([#159](https://github.com/funkadelic/claude-nomad/issues/159)) ([d9c0dca](https://github.com/funkadelic/claude-nomad/commit/d9c0dcae7b417d069e826ce3b825e9f23115ba2d))
|
|
16
|
+
* **remap:** fail closed on path-map collisions in remapPush before any write ([#156](https://github.com/funkadelic/claude-nomad/issues/156)) ([db33157](https://github.com/funkadelic/claude-nomad/commit/db33157faa02b5439278ae4e92a02bd671faee72))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
* **codeql:** skip analysis on release-please PR branches ([#160](https://github.com/funkadelic/claude-nomad/issues/160)) ([2bfed46](https://github.com/funkadelic/claude-nomad/commit/2bfed463ccd76f6331e5ab775bbd5d19df2898e9))
|
|
22
|
+
* **tests:** skip PR-time test matrix on release-please PR branches ([#158](https://github.com/funkadelic/claude-nomad/issues/158)) ([222e27b](https://github.com/funkadelic/claude-nomad/commit/222e27b2643382136505f72e148c17c7ecb7a08f))
|
|
23
|
+
|
|
3
24
|
## [0.26.0](https://github.com/funkadelic/claude-nomad/compare/v0.25.5...v0.26.0) (2026-05-27)
|
|
4
25
|
|
|
5
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-nomad",
|
|
3
|
-
"version": "0.26.
|
|
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": [
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"vitest": "^4.1.6"
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
+
"diff": "^9.0.0",
|
|
83
84
|
"picocolors": "^1.1.1",
|
|
84
85
|
"tsx": "^4.22.2"
|
|
85
86
|
}
|
|
@@ -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
|
|
11
|
-
*
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
116
|
-
* description` row per distinct RuleID across all findings,
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* (older gitleaks, custom rules) are skipped, and the whole block
|
|
120
|
-
* the surrounding
|
|
121
|
-
* legend lives in the footer so a rule hit across many files
|
|
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(
|
|
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
|
|
140
|
-
*
|
|
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
|
-
|
|
200
|
+
reportRemediation(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
|
|
180
201
|
}
|
|
181
202
|
emitDescriptionLegend(section, findings);
|
|
182
203
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { diffLines } from 'diff';
|
|
2
|
+
|
|
3
|
+
import { green, red } from './color.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map a jsdiff `diffLines` result for two pre-stringified JSON strings into
|
|
7
|
+
* an array of unified-diff body lines (the two-line header is the caller's
|
|
8
|
+
* responsibility).
|
|
9
|
+
*
|
|
10
|
+
* Each jsdiff part has a `value` that may span multiple lines and may carry a
|
|
11
|
+
* trailing `\n`. The value is split on `\n` and any trailing empty element
|
|
12
|
+
* (produced by the trailing newline) is dropped so that it does not become a
|
|
13
|
+
* spurious blank output line.
|
|
14
|
+
*
|
|
15
|
+
* Line prefix mapping per part type:
|
|
16
|
+
* - context (neither added nor removed): a single leading space then the line
|
|
17
|
+
* - removed (`part.removed === true`): `red('-' + line)`
|
|
18
|
+
* - added (`part.added === true`): `green('+' + line)`
|
|
19
|
+
*
|
|
20
|
+
* Coloring routes through `color.ts` `red`/`green` helpers, so `NO_COLOR` /
|
|
21
|
+
* non-TTY environments degrade to literal `-` / `+` prefixed lines with no
|
|
22
|
+
* ANSI escape sequences. Picocolors owns the detection logic.
|
|
23
|
+
*/
|
|
24
|
+
export function diffLinesToUnified(oldStr: string, newStr: string): string[] {
|
|
25
|
+
const parts = diffLines(oldStr, newStr);
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
const partLines = part.value.split('\n');
|
|
29
|
+
// A part value ending in '\n' yields a trailing '' after split; drop it.
|
|
30
|
+
if (partLines[partLines.length - 1] === '') {
|
|
31
|
+
partLines.pop();
|
|
32
|
+
}
|
|
33
|
+
const prefix = part.removed
|
|
34
|
+
? (line: string) => red(`-${line}`)
|
|
35
|
+
: part.added
|
|
36
|
+
? (line: string) => green(`+${line}`)
|
|
37
|
+
: (line: string) => ` ${line}`;
|
|
38
|
+
for (const line of partLines) {
|
|
39
|
+
lines.push(prefix(line));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -1,53 +1,34 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { green, red } from './color.ts';
|
|
5
4
|
import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
|
|
5
|
+
import { diffLinesToUnified } from './diff-lines.ts';
|
|
6
6
|
import { applySharedLinks } from './links.ts';
|
|
7
7
|
import { remapPull } from './remap.ts';
|
|
8
8
|
import { log } from './utils.ts';
|
|
9
9
|
import { deepMerge, readJson } from './utils.json.ts';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* LCS line diff for two pre-stringified JSON documents via jsdiff. Returns a
|
|
13
|
+
* unified-diff style string: the two literal header lines
|
|
14
|
+
* `--- ~/.claude/settings.json` and `+++ would write`, followed by body lines
|
|
15
|
+
* where unchanged lines are prefixed with a space, removed lines with `-`
|
|
16
|
+
* (red), and added lines with `+` (green). Coloring routes through `color.ts`
|
|
17
|
+
* so `NO_COLOR` / non-TTY environments degrade to literal prefixes with no
|
|
18
|
+
* ANSI escape sequences.
|
|
19
19
|
*
|
|
20
|
-
* Returns the empty string when
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* The header line `--- ~/.claude/settings.json` / `+++ would write` is
|
|
26
|
-
* literal; callers that want a different header can prepend their own.
|
|
20
|
+
* Returns the empty string when inputs are byte-identical so the caller can
|
|
21
|
+
* suppress the section. jsdiff `diffLines` aligns on the longest common
|
|
22
|
+
* subsequence, so a mid-document insertion does not cascade false `-`/`+`
|
|
23
|
+
* pairs for the unchanged tail.
|
|
27
24
|
*/
|
|
28
25
|
export function diffJsonStrings(currentJsonText: string, newJsonText: string): string {
|
|
29
26
|
if (currentJsonText === newJsonText) return '';
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// Walk both arrays in parallel. Lines that match index-wise are context;
|
|
37
|
-
// others are emitted as -a / +b. A real unified-diff would compute the
|
|
38
|
-
// longest common subsequence; this naive walk is good enough for two JSON
|
|
39
|
-
// documents pretty-printed at the same indentation level.
|
|
40
|
-
const maxLen = Math.max(a.length, b.length);
|
|
41
|
-
for (let i = 0; i < maxLen; i++) {
|
|
42
|
-
const av = a[i];
|
|
43
|
-
const bv = b[i];
|
|
44
|
-
if (av === bv) {
|
|
45
|
-
if (av !== undefined) lines.push(` ${av}`);
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (av !== undefined) lines.push(red(`-${av}`));
|
|
49
|
-
if (bv !== undefined) lines.push(green(`+${bv}`));
|
|
50
|
-
}
|
|
27
|
+
const lines: string[] = [
|
|
28
|
+
'--- ~/.claude/settings.json',
|
|
29
|
+
'+++ would write',
|
|
30
|
+
...diffLinesToUnified(currentJsonText, newJsonText),
|
|
31
|
+
];
|
|
51
32
|
return lines.join('\n');
|
|
52
33
|
}
|
|
53
34
|
|
|
@@ -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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* the
|
|
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
|
-
|
|
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
|
|
121
|
+
return report;
|
|
119
122
|
} finally {
|
|
120
123
|
rmSync(reportPath, { force: true });
|
|
121
124
|
}
|
package/src/push-gitleaks.ts
CHANGED
|
@@ -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(
|
|
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
|
|
137
|
-
*
|
|
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
|
package/src/remap.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'no
|
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
|
-
import { log } from './utils.ts';
|
|
5
|
+
import { die, log } from './utils.ts';
|
|
6
6
|
import { backupBeforeWrite, backupRepoWrite } from './utils.fs.ts';
|
|
7
7
|
import { encodePath, readJson } from './utils.json.ts';
|
|
8
8
|
|
|
@@ -98,17 +98,67 @@ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapp
|
|
|
98
98
|
return { unmapped };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Build the encoded-key to logical-name reverse map for the current host,
|
|
103
|
+
* failing closed on any `path-map.json` shape that would silently lose session
|
|
104
|
+
* data on push. Both failure modes `die()` (throw `NomadFatal`) before the
|
|
105
|
+
* caller writes anything:
|
|
106
|
+
*
|
|
107
|
+
* - Encoded-path collision: two distinct host paths that `encodePath` maps to
|
|
108
|
+
* the same key (every `/` becomes `-`), which would clobber each other under
|
|
109
|
+
* one repo directory.
|
|
110
|
+
* - Duplicate path: two logical names mapping to the same host path, where only
|
|
111
|
+
* one logical could be pushed and the other's `shared/projects/` copy would
|
|
112
|
+
* be orphaned.
|
|
113
|
+
*
|
|
114
|
+
* @param map - the parsed `path-map.json`
|
|
115
|
+
* @returns reverse lookup from encoded local dir name to logical project name
|
|
116
|
+
*/
|
|
117
|
+
function buildReverseMap(map: PathMap): Map<string, string> {
|
|
118
|
+
const reverse = new Map<string, string>();
|
|
119
|
+
const encodedPaths = new Map<string, string>();
|
|
120
|
+
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
121
|
+
const p = hosts[HOST];
|
|
122
|
+
if (!p || p === 'TBD') continue;
|
|
123
|
+
const encoded = encodePath(p);
|
|
124
|
+
const prior = encodedPaths.get(encoded);
|
|
125
|
+
if (prior !== undefined) {
|
|
126
|
+
if (prior !== p) {
|
|
127
|
+
die(
|
|
128
|
+
`encoded-path collision in path-map.json: "${prior}" and "${p}" both encode to` +
|
|
129
|
+
` "${encoded}" (encodePath replaces every / with -).` +
|
|
130
|
+
` Edit path-map.json so the two paths do not encode identically.` +
|
|
131
|
+
` Run nomad doctor for the full list of collisions.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
die(
|
|
135
|
+
`duplicate path in path-map.json: logical names "${reverse.get(encoded)}" and "${logical}"` +
|
|
136
|
+
` both map to "${p}" for ${HOST}, so only one could be pushed and the other's` +
|
|
137
|
+
` shared/projects/ copy would be orphaned.` +
|
|
138
|
+
` Edit path-map.json so each host path maps to a single logical name.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
encodedPaths.set(encoded, p);
|
|
142
|
+
reverse.set(encoded, logical);
|
|
143
|
+
}
|
|
144
|
+
return reverse;
|
|
145
|
+
}
|
|
146
|
+
|
|
101
147
|
/**
|
|
102
148
|
* Push: copy local path-encoded dirs back to repo under logical names.
|
|
103
149
|
*
|
|
104
150
|
* Returns `{ unmapped: N, collisions: M }` where `unmapped` is the count of
|
|
105
151
|
* `~/.claude/projects/<dir>/` entries that have no path-map reverse-lookup
|
|
106
|
-
* for this host. `collisions` is
|
|
107
|
-
*
|
|
152
|
+
* for this host. `collisions` is always `0` on the success path: any
|
|
153
|
+
* `path-map.json` shape that would silently lose data (an encoded-path
|
|
154
|
+
* collision between two distinct host paths, or two logical names mapping to
|
|
155
|
+
* the same host path) makes `buildReverseMap` `die()` (throw `NomadFatal`) to
|
|
156
|
+
* refuse the push before any `shared/projects/` content is written. Detection
|
|
157
|
+
* runs during the reverse-map build, so it fires under `dryRun` too.
|
|
108
158
|
*
|
|
109
159
|
* `opts.dryRun` (default `false`): when `true`, log `would push:` lines
|
|
110
|
-
* instead of calling `backupRepoWrite` + `copyDir`.
|
|
111
|
-
* identically in both modes.
|
|
160
|
+
* instead of calling `backupRepoWrite` + `copyDir`. Collision detection
|
|
161
|
+
* runs identically in both modes.
|
|
112
162
|
*/
|
|
113
163
|
export function remapPush(
|
|
114
164
|
ts: string,
|
|
@@ -116,7 +166,6 @@ export function remapPush(
|
|
|
116
166
|
): { unmapped: number; collisions: number } {
|
|
117
167
|
const dryRun = opts.dryRun === true;
|
|
118
168
|
let unmapped = 0;
|
|
119
|
-
const collisions = 0;
|
|
120
169
|
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
121
170
|
if (!existsSync(mapPath)) {
|
|
122
171
|
log('no path-map.json; skipping session export');
|
|
@@ -126,16 +175,14 @@ export function remapPush(
|
|
|
126
175
|
const map = readJson<PathMap>(mapPath);
|
|
127
176
|
const localProjects = join(CLAUDE_HOME, 'projects');
|
|
128
177
|
const repoProjects = join(REPO_HOME, 'shared', 'projects');
|
|
129
|
-
if (!dryRun) mkdirSync(repoProjects, { recursive: true });
|
|
130
178
|
|
|
131
|
-
const reverse =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
179
|
+
const reverse = buildReverseMap(map);
|
|
180
|
+
if (!existsSync(localProjects)) return { unmapped, collisions: 0 };
|
|
181
|
+
// Create the repo destination only after collision detection passes and we
|
|
182
|
+
// know there is something to push, so a failing or no-op push is fully
|
|
183
|
+
// side-effect-free (no empty shared/projects/ left behind).
|
|
184
|
+
if (!dryRun) mkdirSync(repoProjects, { recursive: true });
|
|
137
185
|
|
|
138
|
-
if (!existsSync(localProjects)) return { unmapped, collisions };
|
|
139
186
|
for (const dir of readdirSync(localProjects)) {
|
|
140
187
|
const logical = reverse.get(dir);
|
|
141
188
|
if (!logical) {
|
|
@@ -156,5 +203,5 @@ export function remapPush(
|
|
|
156
203
|
copyDirJsonlOnly(join(localProjects, dir), repoDst);
|
|
157
204
|
log(`pushed ${dir} -> ${logical}`);
|
|
158
205
|
}
|
|
159
|
-
return { unmapped, collisions };
|
|
206
|
+
return { unmapped, collisions: 0 };
|
|
160
207
|
}
|