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.
- package/CHANGELOG.md +7 -0
- package/README.md +80 -12
- package/package.json +1 -1
- package/src/commands.doctor.format.ts +2 -81
- package/src/commands.pull.ts +69 -13
- package/src/commands.push.sections.ts +171 -0
- package/src/commands.push.ts +136 -71
- package/src/extras-sync.core.ts +96 -0
- package/src/extras-sync.remap.ts +138 -0
- package/src/extras-sync.ts +22 -168
- package/src/links.ts +14 -3
- package/src/output-tree.ts +91 -0
- package/src/push-leak-verdict.ts +154 -0
- package/src/push-preview.ts +160 -0
- package/src/remap.ts +46 -27
- package/src/summary.ts +75 -27
package/src/extras-sync.ts
CHANGED
|
@@ -1,102 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { HOME,
|
|
4
|
+
import { HOME, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
5
5
|
import { listDivergingFiles } from './extras-sync.diff.ts';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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 } = {}):
|
|
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
|
-
|
|
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
|
+
}
|