claude-nomad 0.17.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/.gitleaks.toml +16 -0
- package/CHANGELOG.md +293 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/package.json +79 -0
- package/shared/.gitignore +8 -0
- package/src/color.ts +81 -0
- package/src/commands.doctor.checks.ts +343 -0
- package/src/commands.doctor.format.ts +68 -0
- package/src/commands.doctor.ts +56 -0
- package/src/commands.doctor.version.ts +190 -0
- package/src/commands.drop-session.ts +173 -0
- package/src/commands.pull.ts +88 -0
- package/src/commands.push.ts +215 -0
- package/src/commands.update.ts +279 -0
- package/src/config.ts +149 -0
- package/src/diff.ts +49 -0
- package/src/init.snapshot.ts +53 -0
- package/src/init.ts +190 -0
- package/src/links.ts +123 -0
- package/src/nomad.ts +227 -0
- package/src/preview.ts +141 -0
- package/src/push-checks.ts +170 -0
- package/src/push-gitleaks.ts +217 -0
- package/src/remap.ts +158 -0
- package/src/resume.ts +159 -0
- package/src/summary.ts +43 -0
- package/src/update.topology.ts +118 -0
- package/src/utils.ts +368 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable helpers for push-boundary safety: gitlink walker, gitleaks
|
|
3
|
+
* presence probe, and rebase-before-push.
|
|
4
|
+
*
|
|
5
|
+
* The staged gitleaks scan lives in `./push-gitleaks.ts` so the
|
|
6
|
+
* session-aware FATAL builder has its own module under the 200-line cap.
|
|
7
|
+
* `gitleaksInstallHint` stays here because both `probeGitleaks`
|
|
8
|
+
* (top-of-flow) and `runGitleaksScan` (mid-flow) need the platform-aware
|
|
9
|
+
* install scaffold on ENOENT.
|
|
10
|
+
*
|
|
11
|
+
* All execFileSync-backed helpers use argv-array form with
|
|
12
|
+
* `stdio: ['ignore', 'pipe', 'pipe']` (no shell). Same shape as
|
|
13
|
+
* `gitStatusPorcelainZ` in src/utils.ts so the audit surface is uniform.
|
|
14
|
+
*
|
|
15
|
+
* Used by `cmdPush` for refuse-on-hit safety and by `cmdDoctor` for
|
|
16
|
+
* read-only diagnostics (doctor only consumes `findGitlinks` and
|
|
17
|
+
* `probeGitleaks`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, readdirSync, type Dirent } from 'node:fs';
|
|
22
|
+
import { homedir, platform } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { REPO_HOME } from './config.ts';
|
|
26
|
+
import { NomadFatal } from './utils.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Platform-aware "gitleaks not on PATH" hint, mirroring the scaffold
|
|
30
|
+
* `install.sh` prints during onboarding:
|
|
31
|
+
* - macOS: `brew install gitleaks`.
|
|
32
|
+
* - Linux: numbered steps (download arch-matched tarball, extract to
|
|
33
|
+
* `~/.local/bin`, optional PATH note when the dir isn't on PATH).
|
|
34
|
+
* - Other: just the release-page link.
|
|
35
|
+
* Evaluated at call time so the PATH-on-rc check reflects the runtime
|
|
36
|
+
* env, not the value at module load.
|
|
37
|
+
*/
|
|
38
|
+
export function gitleaksInstallHint(): string {
|
|
39
|
+
const head = 'gitleaks not on PATH (required for nomad push). Install:';
|
|
40
|
+
const plat = platform();
|
|
41
|
+
if (plat === 'darwin') {
|
|
42
|
+
return `${head}\n brew install gitleaks`;
|
|
43
|
+
}
|
|
44
|
+
if (plat === 'linux') {
|
|
45
|
+
const archMap: Record<string, string> = { x64: 'x64', arm64: 'arm64', arm: 'armv7' };
|
|
46
|
+
const arch = archMap[process.arch];
|
|
47
|
+
const lines = [
|
|
48
|
+
head,
|
|
49
|
+
arch
|
|
50
|
+
? ` 1. Download the linux_${arch} tarball: https://github.com/gitleaks/gitleaks/releases`
|
|
51
|
+
: ` 1. Download the linux artifact for arch=${process.arch}: https://github.com/gitleaks/gitleaks/releases`,
|
|
52
|
+
' 2. Install (replace TARBALL with the path to your download):',
|
|
53
|
+
' mkdir -p ~/.local/bin',
|
|
54
|
+
' tar -xzf TARBALL -C ~/.local/bin gitleaks',
|
|
55
|
+
' chmod +x ~/.local/bin/gitleaks',
|
|
56
|
+
' ~/.local/bin/gitleaks version # verify',
|
|
57
|
+
];
|
|
58
|
+
const localBin = `${homedir()}/.local/bin`;
|
|
59
|
+
const paths = (process.env.PATH ?? '').split(':');
|
|
60
|
+
if (!paths.includes(localBin)) {
|
|
61
|
+
lines.push(
|
|
62
|
+
' 3. ~/.local/bin is not on PATH; add to your shell rc:',
|
|
63
|
+
' export PATH="$HOME/.local/bin:$PATH"',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
return `${head}\n See https://github.com/gitleaks/gitleaks/releases`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Recursively find every entry whose basename is `.git` under `dir`. Returns
|
|
73
|
+
* absolute paths. Used by `cmdPush` (refuse-on-hit) and `cmdDoctor`
|
|
74
|
+
* (read-only diagnostic). Callers feed `REPO_HOME/shared/` only; the tool's
|
|
75
|
+
* own repo .git at `~/claude-nomad/.git/` is outside the walk root.
|
|
76
|
+
*
|
|
77
|
+
* Does NOT follow symlinks. `Dirent.isDirectory()` returns `false` for
|
|
78
|
+
* `S_IFLNK` entries even when the link target is a directory, so the
|
|
79
|
+
* recursion naturally short-circuits at any symlink. This is the
|
|
80
|
+
* load-bearing fix for a known hazard: `readdirSync` in recursive mode
|
|
81
|
+
* follows self-referential symlink cycles up to libuv's internal cap
|
|
82
|
+
* (empirically verified on Node 22.16: 82 entries at depth 83 before the
|
|
83
|
+
* cap fired). The hand-rolled walker below is cycle-safe by construction.
|
|
84
|
+
*
|
|
85
|
+
* Tolerates permission errors silently (returns whatever was collected before
|
|
86
|
+
* the error). Reports both file gitlinks (submodule pointer) and directory
|
|
87
|
+
* gitlinks (real nested repo); both push as gitlinks and both break clone.
|
|
88
|
+
*/
|
|
89
|
+
export function findGitlinks(dir: string): string[] {
|
|
90
|
+
const hits: string[] = [];
|
|
91
|
+
function walk(current: string): void {
|
|
92
|
+
let entries: Dirent[];
|
|
93
|
+
try {
|
|
94
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
const p = join(current, e.name);
|
|
100
|
+
if (e.name === '.git') {
|
|
101
|
+
hits.push(p);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (e.isDirectory()) walk(p);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
walk(dir);
|
|
108
|
+
return hits;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Probe for the gitleaks binary on PATH. Returns the trimmed `gitleaks
|
|
113
|
+
* version` stdout on success. Throws NomadFatal with the install hint on
|
|
114
|
+
* ENOENT; throws NomadFatal with the error message on any other failure.
|
|
115
|
+
* Used by `cmdPush` (top-of-flow probe) and `cmdDoctor` (read-only).
|
|
116
|
+
*
|
|
117
|
+
* Conditionally passes `--config <REPO_HOME>/.gitleaks.toml` when that file
|
|
118
|
+
* exists at call time. `gitleaks version` ignores the flag empirically on
|
|
119
|
+
* 8.30.1, so the wiring here is conservative: symmetric with
|
|
120
|
+
* `runGitleaksScan` and surfaces a malformed toml early if a future gitleaks
|
|
121
|
+
* version starts parsing the config on the `version` subcommand. When the
|
|
122
|
+
* toml is missing (e.g., fresh clone predating the allowlist) the flag is
|
|
123
|
+
* omitted entirely; behavior reverts silently to the default gitleaks ruleset.
|
|
124
|
+
*/
|
|
125
|
+
export function probeGitleaks(): string {
|
|
126
|
+
const tomlPath = join(REPO_HOME, '.gitleaks.toml');
|
|
127
|
+
const args: string[] = ['version'];
|
|
128
|
+
if (existsSync(tomlPath)) args.push('--config', tomlPath);
|
|
129
|
+
try {
|
|
130
|
+
return execFileSync('gitleaks', args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
131
|
+
.toString()
|
|
132
|
+
.trim();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const e = err as NodeJS.ErrnoException;
|
|
135
|
+
if (e.code === 'ENOENT') throw new NomadFatal(gitleaksInstallHint());
|
|
136
|
+
throw new NomadFatal(`gitleaks --version failed: ${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run `git pull --rebase --autostash` in REPO_HOME before push.
|
|
142
|
+
* The `--autostash` absorbs dirty trees (in-progress path-map.json edits,
|
|
143
|
+
* host overrides) so users do not need to commit-or-stash first.
|
|
144
|
+
*
|
|
145
|
+
* On failure, forwards git's stderr so the user sees the actual reason
|
|
146
|
+
* (conflict, no-upstream, unreachable remote, auth failure, etc.), then
|
|
147
|
+
* throws NomadFatal.
|
|
148
|
+
*
|
|
149
|
+
* FATAL wording references `git rebase --continue` / `--abort` (not the
|
|
150
|
+
* stash list): when `--autostash` is in flight, the stashed work lives in
|
|
151
|
+
* `.git/rebase-merge/autostash` mid-conflict and is reapplied by
|
|
152
|
+
* `--continue` / `--abort` automatically. Pointing the user at the stash
|
|
153
|
+
* list would mislead them; the recovery commands are the actual fix.
|
|
154
|
+
*
|
|
155
|
+
* `cmdPull` may adopt the same helper in a future refactor.
|
|
156
|
+
*/
|
|
157
|
+
export function rebaseBeforePush(): void {
|
|
158
|
+
try {
|
|
159
|
+
execFileSync('git', ['pull', '--rebase', '--autostash'], {
|
|
160
|
+
cwd: REPO_HOME,
|
|
161
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
|
165
|
+
if (e.stderr) process.stderr.write(e.stderr);
|
|
166
|
+
throw new NomadFatal(
|
|
167
|
+
'rebase failed; if a conflict was reported, resolve it in ~/claude-nomad/ and run "git rebase --continue" (or "git rebase --abort" to give up). Re-run nomad push after resolution.',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the staged gitleaks scan invoked at the end of `cmdPush`.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module (split from `push-checks.ts`) so the
|
|
5
|
+
* session-aware FATAL builder (gitleaks JSON parser + per-session message
|
|
6
|
+
* composer) has a clean home while keeping every file under the 200-line
|
|
7
|
+
* cap. `findGitlinks`, `probeGitleaks`, `gitleaksInstallHint`, and
|
|
8
|
+
* `rebaseBeforePush` stay in `push-checks.ts`.
|
|
9
|
+
*
|
|
10
|
+
* `gitleaksInstallHint` is imported from `./push-checks.ts` because the
|
|
11
|
+
* ENOENT branch surfaces the same platform-aware install scaffold whether
|
|
12
|
+
* the missing binary is detected by `probeGitleaks` (top-of-flow) or by
|
|
13
|
+
* this scan (defense-in-depth mid-flow).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { REPO_HOME } from './config.ts';
|
|
22
|
+
import { gitleaksInstallHint } from './push-checks.ts';
|
|
23
|
+
import { NomadFatal, nowTimestamp } from './utils.ts';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Subset of gitleaks 8.x JSON report fields the parser consumes. The
|
|
27
|
+
* report is an array of objects (one per finding) emitted to the
|
|
28
|
+
* `--report-path` file; on clean scans the array is empty.
|
|
29
|
+
*/
|
|
30
|
+
export type Finding = {
|
|
31
|
+
RuleID: string;
|
|
32
|
+
File: string;
|
|
33
|
+
StartLine: number;
|
|
34
|
+
Match: string;
|
|
35
|
+
Fingerprint: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Captures the session id from a repo-relative POSIX path of the form
|
|
40
|
+
* `shared/projects/<logical>/<sid>.jsonl`. gitleaks emits forward-slash
|
|
41
|
+
* paths regardless of host OS, so the literal works cross-platform.
|
|
42
|
+
* Anchored at both ends + depth-4 segments by construction so deeper
|
|
43
|
+
* paths (e.g., `shared/projects/<logical>/subagents/<id>.jsonl`) fall
|
|
44
|
+
* through to the non-session `other` bucket.
|
|
45
|
+
*/
|
|
46
|
+
const SESSION_PATH = /^shared\/projects\/[^/]+\/([^/]+)\.jsonl$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Legacy fallback FATAL emitted when no finding's File matches the session
|
|
50
|
+
* path pattern. Locked verbatim so existing tests covering the non-session
|
|
51
|
+
* path do not regress.
|
|
52
|
+
*/
|
|
53
|
+
const LEGACY_FATAL =
|
|
54
|
+
'gitleaks detected secrets; review staged changes with git diff --cached and unstage offending files before retry';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Group findings by extracted session id, counting per RuleID, with
|
|
58
|
+
* non-session paths routed to the `other` bucket. Pure: no side effects,
|
|
59
|
+
* no environment reads.
|
|
60
|
+
*/
|
|
61
|
+
export function partitionFindings(findings: Finding[]): {
|
|
62
|
+
bySession: Map<string, Map<string, number>>;
|
|
63
|
+
other: Finding[];
|
|
64
|
+
} {
|
|
65
|
+
const bySession = new Map<string, Map<string, number>>();
|
|
66
|
+
const other: Finding[] = [];
|
|
67
|
+
for (const f of findings) {
|
|
68
|
+
const m = SESSION_PATH.exec(f.File);
|
|
69
|
+
if (m === null) {
|
|
70
|
+
other.push(f);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const sid = m[1];
|
|
74
|
+
// Defensive type narrowing: the regex guarantees group 1 is captured
|
|
75
|
+
// when m !== null, so this branch is unreachable at runtime. Excluded
|
|
76
|
+
// from coverage rather than contorting tests to fake an impossible state.
|
|
77
|
+
/* c8 ignore next */
|
|
78
|
+
if (sid === undefined) continue;
|
|
79
|
+
let counts = bySession.get(sid);
|
|
80
|
+
if (counts === undefined) {
|
|
81
|
+
counts = new Map<string, number>();
|
|
82
|
+
bySession.set(sid, counts);
|
|
83
|
+
}
|
|
84
|
+
counts.set(f.RuleID, (counts.get(f.RuleID) ?? 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
return { bySession, other };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the FATAL message body. Returns the legacy fallback string when
|
|
91
|
+
* `bySession` is empty (no session matches); otherwise composes the
|
|
92
|
+
* multi-section message: `gitleaks detected secrets in N session
|
|
93
|
+
* transcript(s).` header, one block per affected session with a
|
|
94
|
+
* `Recover with: nomad drop-session <id>` line, an optional `Also found:`
|
|
95
|
+
* block for non-session paths, and a trailing `After recovery, re-run
|
|
96
|
+
* nomad push.` line. Pure.
|
|
97
|
+
*/
|
|
98
|
+
export function buildSessionAwareFatal(
|
|
99
|
+
bySession: Map<string, Map<string, number>>,
|
|
100
|
+
other: Finding[],
|
|
101
|
+
): string {
|
|
102
|
+
if (bySession.size === 0) return LEGACY_FATAL;
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
lines.push(`gitleaks detected secrets in ${bySession.size} session transcript(s).`);
|
|
105
|
+
for (const [sid, counts] of bySession) {
|
|
106
|
+
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(`Session ${sid}:`);
|
|
109
|
+
lines.push(` ${summary}`);
|
|
110
|
+
lines.push(` Recover with: nomad drop-session ${sid}`);
|
|
111
|
+
}
|
|
112
|
+
if (other.length > 0) {
|
|
113
|
+
lines.push('');
|
|
114
|
+
lines.push('Also found:');
|
|
115
|
+
for (const f of other) {
|
|
116
|
+
lines.push(` ${f.File} ${f.RuleID}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push(' Review with: git diff --cached, then unstage manually.');
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('After recovery, re-run nomad push.');
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read and parse the gitleaks JSON report at `reportPath`. Returns the
|
|
127
|
+
* findings array on success, or `null` when the file is missing or the
|
|
128
|
+
* JSON is malformed. Defense-in-depth: an unreadable/invalid report on
|
|
129
|
+
* the failure path must NOT cascade into a parse-error stack trace; the
|
|
130
|
+
* caller falls back to the legacy FATAL string in that case.
|
|
131
|
+
*/
|
|
132
|
+
function readGitleaksReport(reportPath: string): Finding[] | null {
|
|
133
|
+
try {
|
|
134
|
+
const raw = readFileSync(reportPath, 'utf8');
|
|
135
|
+
const parsed: unknown = JSON.parse(raw);
|
|
136
|
+
if (!Array.isArray(parsed)) return null;
|
|
137
|
+
return parsed as Finding[];
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run gitleaks against the staged index, writing the JSON report to a
|
|
145
|
+
* collision-resistant path under `~/.cache/claude-nomad/`. On non-zero
|
|
146
|
+
* exit, forwards gitleaks' redacted stderr/stdout so the user sees which
|
|
147
|
+
* file is dirty, reads the JSON report, classifies findings into session
|
|
148
|
+
* vs non-session paths, and throws a session-aware NomadFatal whose
|
|
149
|
+
* message names every affected session id with a `nomad drop-session`
|
|
150
|
+
* recovery hint. Non-session-only findings fall back to the legacy FATAL
|
|
151
|
+
* string. The temp report file is removed via `finally` on both success
|
|
152
|
+
* and failure paths.
|
|
153
|
+
*
|
|
154
|
+
* Conditionally passes `--config <REPO_HOME>/.gitleaks.toml` when that file
|
|
155
|
+
* exists at call time. The allowlist suppresses
|
|
156
|
+
* structurally-distinguishable tool-output noise (Sonar issue keys,
|
|
157
|
+
* gitleaks fingerprints, npm audit JSON id-field hashes, coverage line-keys)
|
|
158
|
+
* without weakening real-secret detection. Missing toml = silent fallback
|
|
159
|
+
* to the default gitleaks ruleset (e.g., fresh clones pre-allowlist).
|
|
160
|
+
*
|
|
161
|
+
* ENOENT branch is defense-in-depth: the presence probe at the top of
|
|
162
|
+
* `cmdPush` should have caught a missing binary, but if `cmdPush` ever
|
|
163
|
+
* bypasses the probe (or the user uninstalls gitleaks mid-flow) the same
|
|
164
|
+
* install-hint FATAL fires here.
|
|
165
|
+
*/
|
|
166
|
+
export function runGitleaksScan(): void {
|
|
167
|
+
const cacheDir = join(homedir(), '.cache', 'claude-nomad');
|
|
168
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
169
|
+
// Disambiguate with pid so the lockfile invariant (one concurrent push
|
|
170
|
+
// per host) is enough to keep the report path unique. The prior
|
|
171
|
+
// freshBackupTs() call checked for a sibling directory named exactly
|
|
172
|
+
// <ts>, which never collides with the file `gitleaks-<ts>.json` being
|
|
173
|
+
// written.
|
|
174
|
+
const reportPath = join(cacheDir, `gitleaks-${nowTimestamp()}-${process.pid}.json`);
|
|
175
|
+
const tomlPath = join(REPO_HOME, '.gitleaks.toml');
|
|
176
|
+
const args: string[] = [
|
|
177
|
+
'protect',
|
|
178
|
+
'--staged',
|
|
179
|
+
'--redact',
|
|
180
|
+
'-v',
|
|
181
|
+
'--report-format=json',
|
|
182
|
+
`--report-path=${reportPath}`,
|
|
183
|
+
];
|
|
184
|
+
if (existsSync(tomlPath)) args.push('--config', tomlPath);
|
|
185
|
+
try {
|
|
186
|
+
execFileSync('gitleaks', args, {
|
|
187
|
+
cwd: REPO_HOME,
|
|
188
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
189
|
+
});
|
|
190
|
+
} catch (err) {
|
|
191
|
+
const e = err as NodeJS.ErrnoException & {
|
|
192
|
+
status?: number;
|
|
193
|
+
stderr?: Buffer;
|
|
194
|
+
stdout?: Buffer;
|
|
195
|
+
};
|
|
196
|
+
if (e.code === 'ENOENT') throw new NomadFatal(gitleaksInstallHint());
|
|
197
|
+
if (e.stderr) process.stderr.write(e.stderr);
|
|
198
|
+
if (e.stdout) process.stdout.write(e.stdout);
|
|
199
|
+
const findings = readGitleaksReport(reportPath);
|
|
200
|
+
if (findings === null) {
|
|
201
|
+
// gitleaks exited non-zero but no parseable JSON report exists at the
|
|
202
|
+
// expected path. Could be a scanner crash, a malformed report, a
|
|
203
|
+
// missing/locked file, or a non-finding runtime failure (gitleaks v8.x
|
|
204
|
+
// returns exit 1 for both "leaks found" and runtime errors). Tell the
|
|
205
|
+
// operator the scan itself failed so they do not chase a phantom
|
|
206
|
+
// `nomad drop-session` rabbit hole. The stderr/stdout already
|
|
207
|
+
// forwarded above carries the underlying gitleaks output.
|
|
208
|
+
throw new NomadFatal(
|
|
209
|
+
`gitleaks scan failed: no parseable JSON report at ${reportPath} (${e.message}). Review the gitleaks output above.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const { bySession, other } = partitionFindings(findings);
|
|
213
|
+
throw new NomadFatal(buildSessionAwareFatal(bySession, other));
|
|
214
|
+
} finally {
|
|
215
|
+
rmSync(reportPath, { force: true });
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/remap.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
|
+
import { backupBeforeWrite, backupRepoWrite, encodePath, log, readJson } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recursive mirror copy: removes `dst` first, then copies `src` into it.
|
|
9
|
+
* `cpSync(force:true)` overwrites matching files but does not delete
|
|
10
|
+
* dst-only entries; the upfront `rmSync` makes the operation a true mirror
|
|
11
|
+
* so `dst` reflects `src` exactly rather than accumulating stale files.
|
|
12
|
+
*/
|
|
13
|
+
function copyDir(src: string, dst: string): void {
|
|
14
|
+
rmSync(dst, { recursive: true, force: true });
|
|
15
|
+
cpSync(src, dst, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Push-side mirror copy: identical to copyDir except a depth-0 extension
|
|
20
|
+
* filter restricts to *.jsonl files only. Subdirectory contents (subagents,
|
|
21
|
+
* memory, tool-results, etc.) copy recursively with no further filtering.
|
|
22
|
+
* Stray .bak / .tmp / .swp / editor backups at the source root are skipped
|
|
23
|
+
* and produce one `ℹ︎ skip <rel>: extension not in allowlist` log
|
|
24
|
+
* line each. The filter must allow the source root explicitly (Pitfall 1:
|
|
25
|
+
* cpSync invokes the filter on src === src first, and a false return
|
|
26
|
+
* there would abort the whole copy). Used by remapPush only; remapPull
|
|
27
|
+
* keeps the unfiltered copyDir because the repo side is already curated
|
|
28
|
+
* by the push gate.
|
|
29
|
+
*/
|
|
30
|
+
function copyDirJsonlOnly(src: string, dst: string): void {
|
|
31
|
+
rmSync(dst, { recursive: true, force: true });
|
|
32
|
+
cpSync(src, dst, {
|
|
33
|
+
recursive: true,
|
|
34
|
+
force: true,
|
|
35
|
+
filter: (srcPath) => {
|
|
36
|
+
const rel = relative(src, srcPath);
|
|
37
|
+
if (rel === '') return true;
|
|
38
|
+
if (rel.split(sep).length > 1) return true;
|
|
39
|
+
if (statSync(srcPath).isDirectory()) return true;
|
|
40
|
+
if (srcPath.endsWith('.jsonl')) return true;
|
|
41
|
+
log(`skip ${rel}: extension not in allowlist`);
|
|
42
|
+
return false;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pull: copy from repo's logical project names into local path-encoded dirs.
|
|
49
|
+
*
|
|
50
|
+
* Returns `{ unmapped: N }` where `N` counts path-map entries skipped for
|
|
51
|
+
* this host (either `'TBD'` placeholder or no entry for `HOST`). The count
|
|
52
|
+
* is consumed by `computePreview` and the future summary line.
|
|
53
|
+
*
|
|
54
|
+
* `opts.dryRun` (default `false`): when `true`, log `would overwrite:` lines
|
|
55
|
+
* instead of calling `backupBeforeWrite` + `copyDir`. The unmapped count is
|
|
56
|
+
* computed identically in both modes.
|
|
57
|
+
*/
|
|
58
|
+
export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapped: number } {
|
|
59
|
+
const dryRun = opts.dryRun === true;
|
|
60
|
+
let unmapped = 0;
|
|
61
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
62
|
+
const repoProjects = join(REPO_HOME, 'shared', 'projects');
|
|
63
|
+
if (!existsSync(mapPath) || !existsSync(repoProjects)) {
|
|
64
|
+
log('no path-map or repo projects dir; skipping session remap');
|
|
65
|
+
return { unmapped: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const map = readJson<PathMap>(mapPath);
|
|
69
|
+
const localProjects = join(CLAUDE_HOME, 'projects');
|
|
70
|
+
if (!dryRun) mkdirSync(localProjects, { recursive: true });
|
|
71
|
+
|
|
72
|
+
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
73
|
+
const localPath = hosts[HOST];
|
|
74
|
+
if (localPath === 'TBD') {
|
|
75
|
+
unmapped++;
|
|
76
|
+
log(`skip ${logical}: placeholder path for ${HOST}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!localPath) {
|
|
80
|
+
unmapped++;
|
|
81
|
+
log(`skip ${logical}: no path for ${HOST}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const src = join(repoProjects, logical);
|
|
85
|
+
if (!existsSync(src)) continue;
|
|
86
|
+
const dst = join(localProjects, encodePath(localPath));
|
|
87
|
+
if (dryRun) {
|
|
88
|
+
log(`would overwrite: ${dst} (from ${src})`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Snapshot prior encoded-path-dir state BEFORE copyDir overwrites it.
|
|
92
|
+
backupBeforeWrite(dst, ts);
|
|
93
|
+
copyDir(src, dst);
|
|
94
|
+
log(`pulled ${logical} -> ${encodePath(localPath)}`);
|
|
95
|
+
}
|
|
96
|
+
return { unmapped };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Push: copy local path-encoded dirs back to repo under logical names.
|
|
101
|
+
*
|
|
102
|
+
* Returns `{ unmapped: N, collisions: M }` where `unmapped` is the count of
|
|
103
|
+
* `~/.claude/projects/<dir>/` entries that have no path-map reverse-lookup
|
|
104
|
+
* for this host. `collisions` is reserved for a future slice's path-encoding
|
|
105
|
+
* collision detection and is always `0` here.
|
|
106
|
+
*
|
|
107
|
+
* `opts.dryRun` (default `false`): when `true`, log `would push:` lines
|
|
108
|
+
* instead of calling `backupRepoWrite` + `copyDir`. Counts are computed
|
|
109
|
+
* identically in both modes.
|
|
110
|
+
*/
|
|
111
|
+
export function remapPush(
|
|
112
|
+
ts: string,
|
|
113
|
+
opts: { dryRun?: boolean } = {},
|
|
114
|
+
): { unmapped: number; collisions: number } {
|
|
115
|
+
const dryRun = opts.dryRun === true;
|
|
116
|
+
let unmapped = 0;
|
|
117
|
+
const collisions = 0;
|
|
118
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
119
|
+
if (!existsSync(mapPath)) {
|
|
120
|
+
log('no path-map.json; skipping session export');
|
|
121
|
+
return { unmapped: 0, collisions: 0 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const map = readJson<PathMap>(mapPath);
|
|
125
|
+
const localProjects = join(CLAUDE_HOME, 'projects');
|
|
126
|
+
const repoProjects = join(REPO_HOME, 'shared', 'projects');
|
|
127
|
+
if (!dryRun) mkdirSync(repoProjects, { recursive: true });
|
|
128
|
+
|
|
129
|
+
const reverse = new Map<string, string>();
|
|
130
|
+
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
131
|
+
const p = hosts[HOST];
|
|
132
|
+
if (!p || p === 'TBD') continue;
|
|
133
|
+
reverse.set(encodePath(p), logical);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!existsSync(localProjects)) return { unmapped, collisions };
|
|
137
|
+
for (const dir of readdirSync(localProjects)) {
|
|
138
|
+
const logical = reverse.get(dir);
|
|
139
|
+
if (!logical) {
|
|
140
|
+
unmapped++;
|
|
141
|
+
log(`skip ${dir}: not in path-map for ${HOST}`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const repoDst = join(repoProjects, logical);
|
|
145
|
+
if (dryRun) {
|
|
146
|
+
log(`would push: ${dir} -> ${logical}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Snapshot repo-side destination before copyDir clobbers it. Git
|
|
150
|
+
// history exists only AFTER the commit step, so a corrupt or
|
|
151
|
+
// path-encoding-collided local dir would otherwise have no rollback
|
|
152
|
+
// path. Symmetric with remapPull's backupBeforeWrite on the local dst.
|
|
153
|
+
backupRepoWrite(repoDst, ts, REPO_HOME);
|
|
154
|
+
copyDirJsonlOnly(join(localProjects, dir), repoDst);
|
|
155
|
+
log(`pushed ${dir} -> ${logical}`);
|
|
156
|
+
}
|
|
157
|
+
return { unmapped, collisions };
|
|
158
|
+
}
|