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/src/nomad.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env -S npx tsx
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
- // Set-based parse so flag order does not matter and duplicates are
93
- // rejected. Unknown flags hit the same usage-error pattern as other
94
- // subcommands.
95
- const seen = parseFlags(process.argv, new Set(['--snapshot', '--keep-actions']));
96
- if (seen === null) {
97
- console.error('usage: nomad init [--snapshot] [--keep-actions]');
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({ snapshot: seen.has('--snapshot'), keepActions: seen.has('--keep-actions') });
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
- // Set-based parse so flag order does not matter and duplicates are
115
- // rejected (`--dry-run --dry-run` is a typo, not a no-op). Unknown
116
- // flags hit the same usage-error pattern as other subcommands.
117
- const seen = parseFlags(process.argv, new Set(['--dry-run', '--force', '--push-origin']));
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': {
@@ -18,11 +18,12 @@
18
18
  */
19
19
 
20
20
  import { execFileSync } from 'node:child_process';
21
- import { existsSync, readdirSync, type Dirent } from 'node:fs';
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
- * 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.
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 tomlPath = join(REPO_HOME, '.gitleaks.toml');
130
+ const { path: toml, tempPath } = resolveTomlConfig();
127
131
  const args: string[] = ['version'];
128
- if (existsSync(tomlPath)) args.push('--config', tomlPath);
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`. Conditionally passes `--config
78
- * <REPO_HOME>/.gitleaks.toml` when that file exists at call time (missing toml
79
- * = silent fallback to the default ruleset). Returns `[]` on a clean exit, the
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
- * report file is removed in a `finally` on every path. ENOENT (gitleaks or git
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 tomlPath = join(REPO_HOME, '.gitleaks.toml');
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 (existsSync(tomlPath)) args.push('--config', tomlPath);
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
- * Conditionally passes `--config <REPO_HOME>/.gitleaks.toml` when that file
158
- * exists, mirroring the `scanStagedTree` convention so allow-list entries apply
159
- * consistently across staged and non-staged scans.
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 tomlPath = join(REPO_HOME, '.gitleaks.toml');
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 (existsSync(tomlPath)) args.push('--config', tomlPath);
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
- }