claude-nomad 0.32.4 → 0.34.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 +20 -0
- package/README.md +180 -200
- package/package.json +5 -6
- package/src/commands.doctor.version.ts +19 -22
- package/src/commands.update.ts +18 -179
- package/src/config.ts +1 -8
- package/src/init.gh-onboard.ts +139 -0
- package/src/init.ts +19 -1
- package/src/nomad.dispatch.ts +95 -0
- package/src/nomad.help.ts +13 -8
- package/src/nomad.ts +19 -20
- package/src/push-checks.ts +16 -10
- package/src/push-gitleaks.config.ts +161 -0
- package/src/push-gitleaks.scan.ts +35 -12
- package/src/commands.update.git.ts +0 -90
- package/src/commands.update.resolve.ts +0 -138
- package/src/commands.update.test-helpers.git.ts +0 -107
- package/src/update.fork-extras.ts +0 -102
- package/src/update.topology.ts +0 -118
package/src/nomad.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env -S
|
|
1
|
+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
|
|
2
2
|
/**
|
|
3
3
|
* claude-nomad: Claude Code config sync wrapper over a private Git repo.
|
|
4
4
|
*
|
|
@@ -25,7 +25,7 @@ import { cmdUpdate } from './commands.update.ts';
|
|
|
25
25
|
import { HOME } from './config.ts';
|
|
26
26
|
import { cmdDiff } from './diff.ts';
|
|
27
27
|
import { cmdInit } from './init.ts';
|
|
28
|
-
import { parseFlags, parseRedactArgs } from './nomad.dispatch.ts';
|
|
28
|
+
import { parseFlags, parseInitArgs, parseRedactArgs } from './nomad.dispatch.ts';
|
|
29
29
|
import { DEFAULT_HELP } from './nomad.help.ts';
|
|
30
30
|
import { resumeCmd } from './resume.ts';
|
|
31
31
|
import { fail, NomadFatal } from './utils.ts';
|
|
@@ -89,15 +89,20 @@ try {
|
|
|
89
89
|
break;
|
|
90
90
|
}
|
|
91
91
|
case 'init': {
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
// parseInitArgs handles boolean flags (--snapshot, --keep-actions) and
|
|
93
|
+
// the value-bearing --repo <name>. Returns null on any parse error:
|
|
94
|
+
// unknown flag, duplicate, --repo with no value or a value starting with
|
|
95
|
+
// '--'.
|
|
96
|
+
const initArgs = parseInitArgs(process.argv);
|
|
97
|
+
if (initArgs === null) {
|
|
98
|
+
console.error('usage: nomad init [--snapshot] [--keep-actions] [--repo <name>]');
|
|
98
99
|
process.exit(1);
|
|
99
100
|
}
|
|
100
|
-
cmdInit({
|
|
101
|
+
cmdInit({
|
|
102
|
+
snapshot: initArgs.snapshot,
|
|
103
|
+
keepActions: initArgs.keepActions,
|
|
104
|
+
repoName: initArgs.repoName,
|
|
105
|
+
});
|
|
101
106
|
break;
|
|
102
107
|
}
|
|
103
108
|
case 'diff':
|
|
@@ -111,19 +116,13 @@ try {
|
|
|
111
116
|
cmdDiff();
|
|
112
117
|
break;
|
|
113
118
|
case 'update': {
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (seen === null) {
|
|
119
|
-
console.error('usage: nomad update [--dry-run] [--force] [--push-origin]');
|
|
119
|
+
// No flags accepted; any extra argv is a usage error (same length-check
|
|
120
|
+
// pattern as the --version arm).
|
|
121
|
+
if (process.argv.length !== 3) {
|
|
122
|
+
console.error('usage: nomad update');
|
|
120
123
|
process.exit(1);
|
|
121
124
|
}
|
|
122
|
-
cmdUpdate(
|
|
123
|
-
dryRun: seen.has('--dry-run'),
|
|
124
|
-
force: seen.has('--force'),
|
|
125
|
-
pushOrigin: seen.has('--push-origin'),
|
|
126
|
-
});
|
|
125
|
+
cmdUpdate();
|
|
127
126
|
break;
|
|
128
127
|
}
|
|
129
128
|
case 'adopt': {
|
package/src/push-checks.ts
CHANGED
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
|
-
import {
|
|
21
|
+
import { readdirSync, rmSync, type Dirent } from 'node:fs';
|
|
22
22
|
import { homedir, platform } from 'node:os';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { REPO_HOME } from './config.ts';
|
|
26
|
+
import { resolveTomlConfig } from './push-gitleaks.config.ts';
|
|
26
27
|
import { NomadFatal } from './utils.ts';
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -114,18 +115,21 @@ export function findGitlinks(dir: string): string[] {
|
|
|
114
115
|
* ENOENT; throws NomadFatal with the error message on any other failure.
|
|
115
116
|
* Used by `cmdPush` (top-of-flow probe) and `cmdDoctor` (read-only).
|
|
116
117
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* `
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
118
|
+
* Passes `--config <toml>` resolved via `resolveTomlConfig`, which applies the
|
|
119
|
+
* user-owned `.gitleaks.overlay.toml` allowlist on top of the bundled base (or
|
|
120
|
+
* delegates to the two-tier `resolveTomlPath` lookup when no overlay exists).
|
|
121
|
+
* `gitleaks version` ignores the flag empirically on 8.30.1, so the wiring is
|
|
122
|
+
* conservative: symmetric with `runGitleaksScan` and surfaces a malformed toml
|
|
123
|
+
* early if a future gitleaks version starts parsing the config on the `version`
|
|
124
|
+
* subcommand. Omits the flag when no config resolves; behavior reverts to the
|
|
125
|
+
* default ruleset. When the overlay merge generates a temp config its `tempPath`
|
|
126
|
+
* is removed in the `finally` on every path. Throws NomadFatal with the install
|
|
127
|
+
* hint on ENOENT; throws NomadFatal with the error message on any other failure.
|
|
124
128
|
*/
|
|
125
129
|
export function probeGitleaks(): string {
|
|
126
|
-
const
|
|
130
|
+
const { path: toml, tempPath } = resolveTomlConfig();
|
|
127
131
|
const args: string[] = ['version'];
|
|
128
|
-
if (
|
|
132
|
+
if (toml !== null) args.push('--config', toml);
|
|
129
133
|
try {
|
|
130
134
|
return execFileSync('gitleaks', args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
131
135
|
.toString()
|
|
@@ -134,6 +138,8 @@ export function probeGitleaks(): string {
|
|
|
134
138
|
const e = err as NodeJS.ErrnoException;
|
|
135
139
|
if (e.code === 'ENOENT') throw new NomadFatal(gitleaksInstallHint());
|
|
136
140
|
throw new NomadFatal(`gitleaks --version failed: ${e.message}`);
|
|
141
|
+
} finally {
|
|
142
|
+
if (tempPath !== null) rmSync(tempPath, { recursive: true, force: true });
|
|
137
143
|
}
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the gitleaks `--config` path for every scan site, layering a
|
|
3
|
+
* user-owned `REPO_HOME/.gitleaks.overlay.toml` allowlist ON TOP of the
|
|
4
|
+
* package-bundled `.gitleaks.toml` via a generated temp `[extend]` chain.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `push-gitleaks.scan.ts` (which keeps the two-tier
|
|
7
|
+
* `resolveTomlPath` lookup) so the overlay-merge logic, its `[extend]` guard,
|
|
8
|
+
* and the D-04 generation-failure fallback live under the source-line cap
|
|
9
|
+
* without crowding the scan primitives. Dependency flows one way
|
|
10
|
+
* (`push-gitleaks.scan.ts` + `push-checks.ts` -> this module); this module
|
|
11
|
+
* imports only `config.ts`, `push-gitleaks.scan.ts` (for `resolveTomlPath`),
|
|
12
|
+
* `utils.fs.ts`, and `utils.ts`, so there is no cycle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { REPO_HOME } from './config.ts';
|
|
20
|
+
import { resolveTomlPath } from './push-gitleaks.scan.ts';
|
|
21
|
+
import { NomadFatal, warn } from './utils.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result of `resolveTomlConfig`. A discriminated union (TYPE, not enum or
|
|
25
|
+
* class, to satisfy `erasableSyntaxOnly`):
|
|
26
|
+
* - `tempPath: null` -> no temp config was generated (no overlay, S-01
|
|
27
|
+
* precedence, bundled-base absent, or the D-04 generation-failure fallback);
|
|
28
|
+
* `path` is whatever `resolveTomlPath` would return (possibly `null`).
|
|
29
|
+
* - `tempPath: string` -> an overlay was merged into a generated temp config;
|
|
30
|
+
* `tempPath` is the private temp DIRECTORY holding it, `path` is the config
|
|
31
|
+
* file inside that directory, and the caller MUST remove `tempPath`
|
|
32
|
+
* recursively (`rmSync(tempPath, { recursive: true, force: true })`) in a
|
|
33
|
+
* `finally` once gitleaks has run.
|
|
34
|
+
*/
|
|
35
|
+
export type TomlConfigResult =
|
|
36
|
+
| { path: string | null; tempPath: null }
|
|
37
|
+
| { path: string; tempPath: string };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Regex matching an `[extend]` definition at the start of a line (D-05). Covers
|
|
41
|
+
* every TOML-equivalent form so the guard cannot be bypassed by whitespace or
|
|
42
|
+
* key syntax: the table header `[extend]` with optional inner whitespace
|
|
43
|
+
* (`[ extend ]`), the dotted-key form (`extend.path = ...`), and the inline-table
|
|
44
|
+
* form (`extend = { ... }`). All three load the `extend` table in gitleaks' TOML
|
|
45
|
+
* parser and would build the depth-3 chain that silently drops the default
|
|
46
|
+
* ruleset, so all three must fail LOUD.
|
|
47
|
+
*/
|
|
48
|
+
const OVERLAY_EXTEND_RE = /^\s*(?:\[\s*extend\s*\]|extend\s*[.=])/m;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the overlay body and write the generated temp config that chains it onto
|
|
52
|
+
* the bundled base. Separated from `resolveTomlConfig` so the I/O try/catch
|
|
53
|
+
* (D-04 fallback) is a tight seam and the caller stays under the
|
|
54
|
+
* cognitive-complexity gate. The `[extend]` guard is intentionally NOT here: it
|
|
55
|
+
* runs in `resolveTomlConfig` BEFORE this call so its `NomadFatal` (D-05) is
|
|
56
|
+
* never swallowed by the D-04 fallback catch.
|
|
57
|
+
*
|
|
58
|
+
* The temp body is `[extend]\npath = <bundled abs path JSON>\n\n<overlay body>`,
|
|
59
|
+
* written as `config.toml` inside a private directory created atomically with
|
|
60
|
+
* `mkdtempSync` (mode 0o700) under `tmpdir()` with a `nomad-gitleaks-cfg-` prefix
|
|
61
|
+
* (CWE-377: the private dir plus the `wx` exclusive-create flag defeat a
|
|
62
|
+
* symlink-redirect in the world-writable temp dir). The config file is written
|
|
63
|
+
* mode 0o600. The `[extend] path` is the ABSOLUTE bundled path (D-02, Pitfall 1)
|
|
64
|
+
* because the scan CWD is uncontrolled at the `scanFile` and `probeGitleaks` sites.
|
|
65
|
+
*
|
|
66
|
+
* @param overlayBody The already-read overlay body (read once by the caller so a
|
|
67
|
+
* single buffer is both guarded and spliced, closing the guard/build TOCTOU).
|
|
68
|
+
* @param bundled Absolute path to the bundled `.gitleaks.toml` (from `resolveTomlPath`).
|
|
69
|
+
* @returns The generated temp directory and the config file path inside it.
|
|
70
|
+
*/
|
|
71
|
+
function buildOverlayTempConfig(
|
|
72
|
+
overlayBody: string,
|
|
73
|
+
bundled: string,
|
|
74
|
+
): { configPath: string; tempPath: string } {
|
|
75
|
+
const tempBody = `[extend]\npath = ${JSON.stringify(bundled)}\n\n${overlayBody}`;
|
|
76
|
+
const tempPath = mkdtempSync(join(tmpdir(), 'nomad-gitleaks-cfg-'));
|
|
77
|
+
const configPath = join(tempPath, 'config.toml');
|
|
78
|
+
writeFileSync(configPath, tempBody, { mode: 0o600, flag: 'wx' });
|
|
79
|
+
return { configPath, tempPath };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the gitleaks `--config` path, applying a user-owned
|
|
84
|
+
* `REPO_HOME/.gitleaks.overlay.toml` allowlist ON TOP of the package-bundled
|
|
85
|
+
* `.gitleaks.toml` (which itself `[extend] useDefault = true` chains the gitleaks
|
|
86
|
+
* default ruleset). Implements Approach A (chain), empirically verified at
|
|
87
|
+
* gitleaks 8.30.1: the generated temp config `[extend] path = <bundled abs>` plus
|
|
88
|
+
* the overlay body loads as temp -> bundled(useDefault) -> default, exactly the
|
|
89
|
+
* depth-2 `maxExtendDepth` limit (depth 3 SILENTLY drops the default ruleset).
|
|
90
|
+
* The caller owns cleanup: when `tempPath` is non-null it MUST be removed in a
|
|
91
|
+
* `finally`.
|
|
92
|
+
*
|
|
93
|
+
* Branches:
|
|
94
|
+
* - No overlay file: delegates to `resolveTomlPath()`, `tempPath: null`. Byte
|
|
95
|
+
* identical to the pre-overlay behavior.
|
|
96
|
+
* - S-01 precedence: if a full `REPO_HOME/.gitleaks.toml` exists,
|
|
97
|
+
* `resolveTomlPath()` returns IT first; the overlay is ignored with a single
|
|
98
|
+
* `warn`, `tempPath: null`. A full repo toml signals manual control and may
|
|
99
|
+
* itself `[extend]`, so interposing the overlay would risk the depth-3 silent
|
|
100
|
+
* drop; the repo toml wins outright to keep the chain at the safe depth-2 max.
|
|
101
|
+
* - Overlay present, no full repo toml, bundled base absent: D-04 fallback,
|
|
102
|
+
* `{ path: null, tempPath: null }` so gitleaks still runs with its default
|
|
103
|
+
* ruleset (never no-scan, never skipped).
|
|
104
|
+
* - Overlay present with its own `[extend]` block: throws `NomadFatal` (D-05)
|
|
105
|
+
* BEFORE generating the temp, so a dangerous/malformed overlay fails LOUD and
|
|
106
|
+
* aborts the push rather than silently weakening the scan.
|
|
107
|
+
* - Overlay present, bundled base resolvable: generates the temp config and
|
|
108
|
+
* returns `{ path: tempPath, tempPath }`.
|
|
109
|
+
* - D-04 "for ANY reason" generation failure: if reading the overlay or writing
|
|
110
|
+
* the temp throws (ENOSPC, EACCES, EROFS, missing tmpdir, unreadable overlay,
|
|
111
|
+
* etc.), `warn` once and fall back to the BUNDLED base path so the scan STILL
|
|
112
|
+
* runs with the full bundled allowlist. Never returns `path: null` here, never
|
|
113
|
+
* throws, never skips. The `[extend]` `NomadFatal` (D-05) is thrown outside the
|
|
114
|
+
* fallback try, so it is never swallowed by this catch.
|
|
115
|
+
*
|
|
116
|
+
* @returns A `TomlConfigResult`; the caller passes `path` to `--config` (omitting
|
|
117
|
+
* the flag on `null`) and removes a non-null `tempPath` in a `finally`.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveTomlConfig(): TomlConfigResult {
|
|
120
|
+
const overlayPath = join(REPO_HOME, '.gitleaks.overlay.toml');
|
|
121
|
+
const repoToml = join(REPO_HOME, '.gitleaks.toml');
|
|
122
|
+
const bundled = resolveTomlPath();
|
|
123
|
+
if (!existsSync(overlayPath)) {
|
|
124
|
+
return { path: bundled, tempPath: null };
|
|
125
|
+
}
|
|
126
|
+
// S-01: a full REPO_HOME/.gitleaks.toml wins outright; resolveTomlPath returns
|
|
127
|
+
// it before the bundled copy, so compare to detect that case and short-circuit
|
|
128
|
+
// before generating a temp (keeps the chain at the safe depth-2 max).
|
|
129
|
+
if (bundled === repoToml) {
|
|
130
|
+
warn(
|
|
131
|
+
'.gitleaks.overlay.toml ignored: REPO_HOME/.gitleaks.toml takes precedence (full manual control)',
|
|
132
|
+
);
|
|
133
|
+
return { path: bundled, tempPath: null };
|
|
134
|
+
}
|
|
135
|
+
// D-04: no bundled base to chain onto; run with the default ruleset, never skip.
|
|
136
|
+
if (bundled === null) {
|
|
137
|
+
return { path: null, tempPath: null };
|
|
138
|
+
}
|
|
139
|
+
// D-04: any overlay-read or temp-generation I/O failure falls back to the
|
|
140
|
+
// bundled base so the scan still runs with the full bundled allowlist (never
|
|
141
|
+
// null, never thrown). The overlay is read ONCE here and the same buffer is
|
|
142
|
+
// both guarded and spliced, closing the guard-vs-build TOCTOU. The D-05
|
|
143
|
+
// [extend] NomadFatal is re-thrown from the catch so it is never swallowed by
|
|
144
|
+
// this fallback.
|
|
145
|
+
try {
|
|
146
|
+
const overlayBody = readFileSync(overlayPath, 'utf8');
|
|
147
|
+
if (OVERLAY_EXTEND_RE.test(overlayBody)) {
|
|
148
|
+
throw new NomadFatal(
|
|
149
|
+
'.gitleaks.overlay.toml must not contain an [extend] block; it is generated automatically. Remove the [extend] section and retry.',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const { configPath, tempPath } = buildOverlayTempConfig(overlayBody, bundled);
|
|
153
|
+
return { path: configPath, tempPath };
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err instanceof NomadFatal) throw err;
|
|
156
|
+
warn(
|
|
157
|
+
`.gitleaks.overlay.toml merge failed (${(err as Error).message}); falling back to the bundled allowlist`,
|
|
158
|
+
);
|
|
159
|
+
return { path: bundled, tempPath: null };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -16,10 +16,27 @@ import { execFileSync, type ExecFileSyncOptions } from 'node:child_process';
|
|
|
16
16
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
19
20
|
|
|
20
21
|
import { REPO_HOME } from './config.ts';
|
|
22
|
+
import { resolveTomlConfig } from './push-gitleaks.config.ts';
|
|
21
23
|
import { nowTimestamp } from './utils.fs.ts';
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Two-tier `.gitleaks.toml` lookup: returns `REPO_HOME/.gitleaks.toml` when
|
|
27
|
+
* present, else the package-bundled copy resolved via `import.meta.url`
|
|
28
|
+
* (always current with the installed binary, critical for standalone repos
|
|
29
|
+
* that have no git update path for the allowlist), else `null`. Callers omit
|
|
30
|
+
* `--config` on a `null` return so gitleaks uses its default ruleset; scanning
|
|
31
|
+
* is never disabled. Exported for reuse in `push-checks.ts` `probeGitleaks`.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveTomlPath(): string | null {
|
|
34
|
+
const repoToml = join(REPO_HOME, '.gitleaks.toml');
|
|
35
|
+
if (existsSync(repoToml)) return repoToml;
|
|
36
|
+
const bundled = fileURLToPath(new URL('../.gitleaks.toml', import.meta.url));
|
|
37
|
+
return existsSync(bundled) ? bundled : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
/**
|
|
24
41
|
* Subset of gitleaks 8.x JSON report fields the parser consumes. The
|
|
25
42
|
* report is an array of objects (one per finding) emitted to the
|
|
@@ -74,12 +91,14 @@ export function readGitleaksReport(reportPath: string): Finding[] | null {
|
|
|
74
91
|
* In `repoDir`, runs `git init` then `git add -A` (no commit, no user identity:
|
|
75
92
|
* `git add` does not require one), writes the gitleaks JSON report to a
|
|
76
93
|
* collision-resistant path under `~/.cache/claude-nomad/`, and invokes
|
|
77
|
-
* `gitleaks protect --staged`.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
94
|
+
* `gitleaks protect --staged`. Passes `--config <toml>` resolved via
|
|
95
|
+
* `resolveTomlConfig`, which layers a user-owned `.gitleaks.overlay.toml` on the
|
|
96
|
+
* two-tier `resolveTomlPath` base by generating a temp `[extend]` config (removed
|
|
97
|
+
* in the `finally`); omits the flag when no base exists so gitleaks uses its
|
|
98
|
+
* default ruleset. Returns `[]` on a clean exit, the
|
|
80
99
|
* parsed `Finding[]` on a non-zero exit with a readable report, or `null` when
|
|
81
|
-
* the report is missing or unparseable (the scan-failed signal). The temp
|
|
82
|
-
*
|
|
100
|
+
* the report is missing or unparseable (the scan-failed signal). The temp report
|
|
101
|
+
* file and any generated overlay temp-config are removed in a `finally` on every path. ENOENT (gitleaks or git
|
|
83
102
|
* absent) is re-thrown, not swallowed, so each caller keeps its own
|
|
84
103
|
* missing-binary handling (push -> install-hint FATAL; doctor -> scan-failed
|
|
85
104
|
* FAIL row). All calls use `execFileSync` argv-array form (no shell), the
|
|
@@ -98,7 +117,7 @@ export function scanStagedTree(repoDir: string, forwardStreams = false): Finding
|
|
|
98
117
|
const cacheDir = join(homedir(), '.cache', 'claude-nomad');
|
|
99
118
|
mkdirSync(cacheDir, { recursive: true });
|
|
100
119
|
const reportPath = join(cacheDir, `gitleaks-${nowTimestamp()}-${process.pid}.json`);
|
|
101
|
-
const
|
|
120
|
+
const { path: toml, tempPath } = resolveTomlConfig();
|
|
102
121
|
const args: string[] = [
|
|
103
122
|
'protect',
|
|
104
123
|
'--staged',
|
|
@@ -107,7 +126,7 @@ export function scanStagedTree(repoDir: string, forwardStreams = false): Finding
|
|
|
107
126
|
'--report-format=json',
|
|
108
127
|
`--report-path=${reportPath}`,
|
|
109
128
|
];
|
|
110
|
-
if (
|
|
129
|
+
if (toml !== null) args.push('--config', toml);
|
|
111
130
|
const opts: ExecFileSyncOptions = { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] };
|
|
112
131
|
try {
|
|
113
132
|
execFileSync('git', ['init', '-q'], opts);
|
|
@@ -124,6 +143,7 @@ export function scanStagedTree(repoDir: string, forwardStreams = false): Finding
|
|
|
124
143
|
}
|
|
125
144
|
return report;
|
|
126
145
|
} finally {
|
|
146
|
+
if (tempPath !== null) rmSync(tempPath, { recursive: true, force: true });
|
|
127
147
|
rmSync(reportPath, { force: true });
|
|
128
148
|
}
|
|
129
149
|
}
|
|
@@ -154,9 +174,11 @@ export function scanStagedTree(repoDir: string, forwardStreams = false): Finding
|
|
|
154
174
|
* streams so the caller can surface it. On the findings path the streams are
|
|
155
175
|
* suppressed; the structured `Finding[]` fully describes the result.
|
|
156
176
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
177
|
+
* Passes `--config <toml>` resolved via `resolveTomlConfig` (the
|
|
178
|
+
* `.gitleaks.overlay.toml` merge over the two-tier `resolveTomlPath` base, with a
|
|
179
|
+
* generated temp config cleaned up in the `finally`), mirroring the
|
|
180
|
+
* `scanStagedTree` convention so allow-list entries apply consistently across
|
|
181
|
+
* staged and non-staged scans. Omits the flag when no base config exists.
|
|
160
182
|
*
|
|
161
183
|
* @param filePath Absolute path to the file to scan.
|
|
162
184
|
* @param forwardStreams Forward gitleaks stderr/stdout to process streams on
|
|
@@ -167,7 +189,7 @@ export function scanFile(filePath: string, forwardStreams = false): Finding[] |
|
|
|
167
189
|
const cacheDir = join(homedir(), '.cache', 'claude-nomad');
|
|
168
190
|
mkdirSync(cacheDir, { recursive: true });
|
|
169
191
|
const reportPath = join(cacheDir, `gitleaks-file-${nowTimestamp()}-${process.pid}.json`);
|
|
170
|
-
const
|
|
192
|
+
const { path: toml, tempPath } = resolveTomlConfig();
|
|
171
193
|
const args: string[] = [
|
|
172
194
|
'detect',
|
|
173
195
|
'--no-git',
|
|
@@ -176,7 +198,7 @@ export function scanFile(filePath: string, forwardStreams = false): Finding[] |
|
|
|
176
198
|
'--report-format=json',
|
|
177
199
|
`--report-path=${reportPath}`,
|
|
178
200
|
];
|
|
179
|
-
if (
|
|
201
|
+
if (toml !== null) args.push('--config', toml);
|
|
180
202
|
const opts: ExecFileSyncOptions = { stdio: ['ignore', 'pipe', 'pipe'] };
|
|
181
203
|
try {
|
|
182
204
|
execFileSync('gitleaks', args, opts);
|
|
@@ -191,6 +213,7 @@ export function scanFile(filePath: string, forwardStreams = false): Finding[] |
|
|
|
191
213
|
}
|
|
192
214
|
return report;
|
|
193
215
|
} finally {
|
|
216
|
+
if (tempPath !== null) rmSync(tempPath, { recursive: true, force: true });
|
|
194
217
|
rmSync(reportPath, { force: true });
|
|
195
218
|
}
|
|
196
219
|
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
import { REPO_HOME } from './config.ts';
|
|
4
|
-
import { log, NomadFatal } from './utils.ts';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Get the current Git branch name for the repository at REPO_HOME.
|
|
8
|
-
*
|
|
9
|
-
* Wraps the failure path so a corrupt or missing `.git` directory surfaces as
|
|
10
|
-
* ``✗ ...`` via the top-level dispatcher's `NomadFatal` catch
|
|
11
|
-
* rather than a raw `ExecException` stack trace.
|
|
12
|
-
*
|
|
13
|
-
* @returns The current branch name (trimmed).
|
|
14
|
-
* @throws NomadFatal when the git command fails; if the command produced stderr, that stderr is written to process.stderr before the exception is thrown.
|
|
15
|
-
*/
|
|
16
|
-
export function currentBranch(): string {
|
|
17
|
-
try {
|
|
18
|
-
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
19
|
-
cwd: REPO_HOME,
|
|
20
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
21
|
-
})
|
|
22
|
-
.toString()
|
|
23
|
-
.trim();
|
|
24
|
-
} catch (err) {
|
|
25
|
-
const e = err as Error & { stderr?: Buffer };
|
|
26
|
-
if (e.stderr) process.stderr.write(e.stderr);
|
|
27
|
-
throw new NomadFatal('git rev-parse --abbrev-ref HEAD failed');
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Read and return the current `HEAD` commit SHA from the repository.
|
|
33
|
-
*
|
|
34
|
-
* Used to pin the pre-update commit so the post-update lockfile diff is
|
|
35
|
-
* exact regardless of whether the pull was a fast-forward, a no-op, or a
|
|
36
|
-
* merge. `HEAD@{1}` is unreliable here: a no-op `git pull --ff-only` does
|
|
37
|
-
* not always write a reflog entry, and a freshly cloned repo has no
|
|
38
|
-
* `HEAD@{1}` at all.
|
|
39
|
-
*
|
|
40
|
-
* @returns The `HEAD` commit SHA as a trimmed string.
|
|
41
|
-
* @throws NomadFatal if `git rev-parse HEAD` fails (stderr is written to stderr when present).
|
|
42
|
-
*/
|
|
43
|
-
export function headSha(): string {
|
|
44
|
-
try {
|
|
45
|
-
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
46
|
-
cwd: REPO_HOME,
|
|
47
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
-
})
|
|
49
|
-
.toString()
|
|
50
|
-
.trim();
|
|
51
|
-
} catch (err) {
|
|
52
|
-
const e = err as Error & { stderr?: Buffer };
|
|
53
|
-
if (e.stderr) process.stderr.write(e.stderr);
|
|
54
|
-
throw new NomadFatal('git rev-parse HEAD failed');
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* List files changed between the given commit and the current HEAD.
|
|
60
|
-
*
|
|
61
|
-
* @param beforeSha - Commit SHA to compare against HEAD
|
|
62
|
-
* @returns An array of file paths changed between `beforeSha` and `HEAD`; an empty array if there are no changes
|
|
63
|
-
*/
|
|
64
|
-
export function changedFilesSince(beforeSha: string): string[] {
|
|
65
|
-
const out = execFileSync('git', ['diff', '--name-only', `${beforeSha}..HEAD`], {
|
|
66
|
-
cwd: REPO_HOME,
|
|
67
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
-
}).toString();
|
|
69
|
-
return out.split('\n').filter((line) => line !== '');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Run `npm install` in the repository only if `package-lock.json` changed since a given commit.
|
|
74
|
-
*
|
|
75
|
-
* If `package-lock.json` did not change between `beforeSha` and `HEAD`, logs
|
|
76
|
-
* a skip message; otherwise runs `npm install` with working directory set to
|
|
77
|
-
* `REPO_HOME`. Routing through `execFileSync` (no shell) keeps the call
|
|
78
|
-
* mockable in tests and prevents any chance of argv injection.
|
|
79
|
-
*
|
|
80
|
-
* @param beforeSha - Commit SHA to compare against `HEAD` when determining whether the lockfile changed
|
|
81
|
-
*/
|
|
82
|
-
export function reinstallIfNeeded(beforeSha: string): void {
|
|
83
|
-
const changed = changedFilesSince(beforeSha);
|
|
84
|
-
if (!changed.includes('package-lock.json')) {
|
|
85
|
-
log('skipping npm install (lockfile unchanged)');
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
log('package-lock.json changed, running npm install');
|
|
89
|
-
execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
|
|
90
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { closeSync, openSync, readSync } from 'node:fs';
|
|
3
|
-
|
|
4
|
-
import type { CmdUpdateOpts } from './commands.update.ts';
|
|
5
|
-
import { REPO_HOME } from './config.ts';
|
|
6
|
-
import { gitOrFatal, log } from './utils.ts';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Default y/N prompt used when `opts.prompt` is not injected.
|
|
10
|
-
*
|
|
11
|
-
* Reads from `/dev/tty` byte-by-byte until newline so the call returns after
|
|
12
|
-
* the user presses Enter (cooked-mode TTY line buffering). The naive
|
|
13
|
-
* `readFileSync(0)` approach reads until EOF, which hangs interactive use
|
|
14
|
-
* until Ctrl-D. Opening `/dev/tty` directly also means the prompt still
|
|
15
|
-
* works when stdin is piped or redirected.
|
|
16
|
-
*
|
|
17
|
-
* @param question - Prompt text written to stdout before reading input.
|
|
18
|
-
* @returns The user's trimmed answer; `''` on any failure (no controlling TTY, read error), which `runFork` treats as "no" and skips the push.
|
|
19
|
-
*/
|
|
20
|
-
export function defaultPrompt(question: string): string {
|
|
21
|
-
process.stdout.write(question);
|
|
22
|
-
let fd: number;
|
|
23
|
-
try {
|
|
24
|
-
fd = openSync('/dev/tty', 'r');
|
|
25
|
-
} catch {
|
|
26
|
-
return '';
|
|
27
|
-
}
|
|
28
|
-
try {
|
|
29
|
-
const buf = Buffer.alloc(1);
|
|
30
|
-
let answer = '';
|
|
31
|
-
while (true) {
|
|
32
|
-
const n = readSync(fd, buf, 0, 1, null);
|
|
33
|
-
if (n === 0) break;
|
|
34
|
-
const ch = buf.toString('utf8', 0, 1);
|
|
35
|
-
if (ch === '\n' || ch === '\r') break;
|
|
36
|
-
answer += ch;
|
|
37
|
-
}
|
|
38
|
-
return answer.trim();
|
|
39
|
-
} catch {
|
|
40
|
-
return '';
|
|
41
|
-
} finally {
|
|
42
|
-
closeSync(fd);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Files release-please touches as a set on every release commit. Multi-file
|
|
48
|
-
* merge conflicts in `nomad update` that consist entirely of paths from this
|
|
49
|
-
* set are diagnostic for a release landing upstream while the mirror has its
|
|
50
|
-
* own local commits on these artifacts. Taking upstream is the canonical
|
|
51
|
-
* resolution (these are all generated artifacts the user has no business
|
|
52
|
-
* editing on a mirror), but multi-file is more aggressive than the lone
|
|
53
|
-
* lockfile case so we prompt before mutating.
|
|
54
|
-
*/
|
|
55
|
-
const RELEASE_PLEASE_ARTIFACTS: ReadonlySet<string> = new Set([
|
|
56
|
-
'package.json',
|
|
57
|
-
'package-lock.json',
|
|
58
|
-
'CHANGELOG.md',
|
|
59
|
-
'.release-please-manifest.json',
|
|
60
|
-
]);
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Resolve a merge conflict by taking upstream's version of every listed path,
|
|
64
|
-
* regenerating the lockfile via `npm install`, and committing the merge.
|
|
65
|
-
* Shared body for the lone-lockfile auto-resolve and the release-please
|
|
66
|
-
* multi-file prompted auto-resolve.
|
|
67
|
-
*
|
|
68
|
-
* @param paths - Unmerged paths to resolve via `git checkout --theirs`.
|
|
69
|
-
*/
|
|
70
|
-
export function resolveByTakingTheirs(paths: readonly string[]): void {
|
|
71
|
-
for (const p of paths) {
|
|
72
|
-
gitOrFatal(['checkout', '--theirs', '--', p], `git checkout --theirs ${p}`, REPO_HOME);
|
|
73
|
-
}
|
|
74
|
-
gitOrFatal(['add', ...paths], `git add ${paths.join(' ')}`, REPO_HOME);
|
|
75
|
-
execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
|
|
76
|
-
gitOrFatal(['add', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
|
|
77
|
-
gitOrFatal(['commit', '--no-edit'], 'git commit --no-edit', REPO_HOME);
|
|
78
|
-
log(`auto-resolved merge conflict (took upstream for ${paths.join(', ')}, reinstalled)`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Auto-resolve a merge conflict in the two scenarios both caused by
|
|
83
|
-
* release-please landing upstream while the mirror has local commits:
|
|
84
|
-
*
|
|
85
|
-
* 1. **Sole `package-lock.json`** (silent): the lone-lockfile case from PR
|
|
86
|
-
* #96. Any host that has run `npm install` against the mirror will hit
|
|
87
|
-
* this on the next `nomad update`; take upstream + reinstall is the
|
|
88
|
-
* semantically-correct fix and surprise-free for a generated artifact.
|
|
89
|
-
*
|
|
90
|
-
* 2. **All paths in `RELEASE_PLEASE_ARTIFACTS` and more than one path**
|
|
91
|
-
* (prompted): a release commit conflicting on `package.json`,
|
|
92
|
-
* `CHANGELOG.md`, `.release-please-manifest.json` together with the
|
|
93
|
-
* lockfile. Same semantic resolution, but more files are touched so we
|
|
94
|
-
* require explicit y/N consent before mutating.
|
|
95
|
-
*
|
|
96
|
-
* Returns `false` for any other conflict shape (including probe failure);
|
|
97
|
-
* the caller re-throws the original merge `NomadFatal` unchanged.
|
|
98
|
-
*
|
|
99
|
-
* @param opts - Update options; only `prompt` is consulted (used for the multi-file release-please consent prompt).
|
|
100
|
-
* @returns `true` when the conflict was auto-resolved and the merge committed; `false` when the conflict shape does not match either auto-resolve case (caller should re-throw the original failure).
|
|
101
|
-
*/
|
|
102
|
-
export function tryAutoResolveMergeConflict(opts: CmdUpdateOpts): boolean {
|
|
103
|
-
let unmerged: string[];
|
|
104
|
-
try {
|
|
105
|
-
unmerged = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
106
|
-
cwd: REPO_HOME,
|
|
107
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
108
|
-
})
|
|
109
|
-
.toString()
|
|
110
|
-
.split('\n')
|
|
111
|
-
.filter((line) => line !== '');
|
|
112
|
-
} catch {
|
|
113
|
-
// Probe failure must not mask the original merge NomadFatal. Returning
|
|
114
|
-
// false lets the caller re-throw the merge error unchanged.
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (unmerged.length === 1 && unmerged[0] === 'package-lock.json') {
|
|
119
|
-
resolveByTakingTheirs(['package-lock.json']);
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (unmerged.length > 1 && unmerged.every((p) => RELEASE_PLEASE_ARTIFACTS.has(p))) {
|
|
124
|
-
const promptFn = opts.prompt ?? defaultPrompt;
|
|
125
|
-
log(`merge conflict in release-please artifacts: ${unmerged.join(', ')}`);
|
|
126
|
-
const answer = promptFn(
|
|
127
|
-
'Auto-resolve by taking upstream + `npm install` + commit? [y/N] ',
|
|
128
|
-
).toLowerCase();
|
|
129
|
-
if (answer !== 'y' && answer !== 'yes') {
|
|
130
|
-
log('skipping auto-resolve (resolve manually then re-run `nomad update`)');
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
resolveByTakingTheirs(unmerged);
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return false;
|
|
138
|
-
}
|