claude-nomad 0.25.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/commands.doctor.check-shared.scan.ts +158 -0
  5. package/src/commands.doctor.check-shared.ts +58 -189
  6. package/src/commands.doctor.checks.pathmap.ts +101 -0
  7. package/src/commands.doctor.checks.repo.ts +133 -0
  8. package/src/commands.doctor.checks.repository.ts +105 -0
  9. package/src/commands.doctor.checks.settings.ts +88 -0
  10. package/src/commands.doctor.format.ts +18 -0
  11. package/src/commands.doctor.ts +10 -7
  12. package/src/commands.drop-session.git.ts +81 -0
  13. package/src/commands.drop-session.ts +79 -138
  14. package/src/commands.pull.ts +3 -2
  15. package/src/commands.push.allowlist.ts +119 -0
  16. package/src/commands.push.ts +6 -121
  17. package/src/commands.update.git.ts +90 -0
  18. package/src/commands.update.resolve.ts +138 -0
  19. package/src/commands.update.test-helpers.git.ts +107 -0
  20. package/src/commands.update.ts +4 -221
  21. package/src/diff.ts +2 -1
  22. package/src/extras-sync.diff.ts +40 -0
  23. package/src/extras-sync.guards.ts +52 -0
  24. package/src/extras-sync.ts +146 -236
  25. package/src/init.classify.ts +1 -1
  26. package/src/init.snapshot.ts +3 -1
  27. package/src/init.ts +2 -1
  28. package/src/links.ts +3 -10
  29. package/src/nomad.dispatch.ts +25 -0
  30. package/src/nomad.help.ts +43 -0
  31. package/src/nomad.ts +6 -68
  32. package/src/preview.ts +2 -1
  33. package/src/push-gitleaks.scan.ts +115 -0
  34. package/src/push-gitleaks.ts +50 -106
  35. package/src/remap.ts +3 -1
  36. package/src/resume.ts +2 -1
  37. package/src/update.fork-extras.ts +2 -1
  38. package/src/utils.fs.ts +152 -0
  39. package/src/utils.json.ts +55 -0
  40. package/src/utils.lockfile.ts +168 -0
  41. package/src/utils.ts +0 -327
  42. package/src/commands.doctor.checks.ts +0 -350
@@ -0,0 +1,138 @@
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
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Produce git `remote -v` formatted output from a map of remote names to URLs.
3
+ *
4
+ * Each entry produces two lines: one with `(fetch)` and one with `(push)`.
5
+ * `parseRemotes` only consumes `(fetch)`, but emitting production-shaped
6
+ * output keeps the test honest against the real git CLI format.
7
+ *
8
+ * @param remotes - Mapping of remote name to its URL
9
+ * @returns A `git remote -v`-style string where each remote has `(fetch)` and `(push)` lines; includes a trailing newline when there is at least one line
10
+ */
11
+ export function formatRemoteV(remotes: Record<string, string>): string {
12
+ const lines: string[] = [];
13
+ for (const [name, url] of Object.entries(remotes)) {
14
+ lines.push(`${name}\t${url} (fetch)`, `${name}\t${url} (push)`);
15
+ }
16
+ return lines.join('\n') + (lines.length > 0 ? '\n' : '');
17
+ }
18
+
19
+ /** Shape passed to `mockGit` so each test declares only the bits it cares
20
+ * about; defaults cover the "vanilla healthy" path. */
21
+ export type GitBehavior = {
22
+ remotes?: Record<string, string>;
23
+ branch?: string;
24
+ status?: string;
25
+ diffNames?: string;
26
+ pullThrows?: Error;
27
+ fetchThrows?: Error;
28
+ mergeThrows?: Error;
29
+ /** When set, `git rev-parse --abbrev-ref HEAD` throws this error. Used to
30
+ * exercise `currentBranch`'s NomadFatal-wrapping catch arm. */
31
+ branchThrows?: Error;
32
+ /** When set, `git rev-parse HEAD` throws this error. Used to exercise
33
+ * `headSha`'s NomadFatal-wrapping catch arm. */
34
+ headShaThrows?: Error;
35
+ /** Successive `git rev-parse HEAD` return values, consumed in order (one
36
+ * per call) and sticky on the last entry once exhausted. Lets a test model
37
+ * HEAD advancing across a merge (distinct pre/post SHAs) or staying put (a
38
+ * single entry, or unset for the constant default). Mutated in place. */
39
+ headShas?: string[];
40
+ /** When set, `git remote -v` throws this error. Used to exercise
41
+ * `loadTopology`'s NomadFatal-wrapping catch arm. */
42
+ remoteThrows?: Error;
43
+ /** Output for `git diff --name-only --diff-filter=U`: newline-separated
44
+ * unmerged paths after a failed merge. Empty/unset = no unmerged paths. */
45
+ unmergedPaths?: string;
46
+ /** When set, `git diff --name-only --diff-filter=U` throws this error.
47
+ * Used to verify the auto-resolve probe degrades gracefully and the
48
+ * original merge failure surfaces instead of a probe exception. */
49
+ diffThrows?: Error;
50
+ };
51
+
52
+ /** Per-command handler: returns the canned output (or throws the configured
53
+ * error). Each handler is keyed by `git ${args[0]}` or `npm ${args[0]}` for
54
+ * dispatch via a table, which keeps `mockGit`'s `execFileSync` body flat. */
55
+ type Handler = (behavior: GitBehavior, args: readonly string[]) => Buffer;
56
+
57
+ /** Dispatch table consumed by `mockGit`: maps `${bin} ${args[0]}` to the
58
+ * handler that returns the canned git/npm output (or throws). Extracted from
59
+ * the helper module so `mockGit` stays under the line cap. */
60
+ export const HANDLERS: Record<string, Handler> = {
61
+ 'git remote': (b, args) => {
62
+ if (args[1] !== '-v') throw new Error(`unhandled: git remote ${args.join(' ')}`);
63
+ if (b.remoteThrows !== undefined) throw b.remoteThrows;
64
+ return Buffer.from(formatRemoteV(b.remotes ?? {}));
65
+ },
66
+ 'git rev-parse': (b, args) => {
67
+ if (args[1] === '--abbrev-ref') {
68
+ if (b.branchThrows !== undefined) throw b.branchThrows;
69
+ return Buffer.from((b.branch ?? 'main') + '\n');
70
+ }
71
+ if (args[1] === 'HEAD') {
72
+ if (b.headShaThrows !== undefined) throw b.headShaThrows;
73
+ const seq = b.headShas;
74
+ if (seq !== undefined && seq.length > 0) {
75
+ const next = seq.length > 1 ? seq.shift()! : seq[0];
76
+ return Buffer.from(next + '\n');
77
+ }
78
+ return Buffer.from('0123456789abcdef0123456789abcdef01234567\n');
79
+ }
80
+ throw new Error(`unhandled: git rev-parse ${args.join(' ')}`);
81
+ },
82
+ 'git status': (b) => Buffer.from(b.status ?? ''),
83
+ 'git pull': (b) => {
84
+ if (b.pullThrows !== undefined) throw b.pullThrows;
85
+ return Buffer.from('');
86
+ },
87
+ 'git fetch': (b) => {
88
+ if (b.fetchThrows !== undefined) throw b.fetchThrows;
89
+ return Buffer.from('');
90
+ },
91
+ 'git merge': (b) => {
92
+ if (b.mergeThrows !== undefined) throw b.mergeThrows;
93
+ return Buffer.from('');
94
+ },
95
+ 'git push': () => Buffer.from(''),
96
+ 'git diff': (b, args) => {
97
+ if (args.includes('--diff-filter=U')) {
98
+ if (b.diffThrows !== undefined) throw b.diffThrows;
99
+ return Buffer.from(b.unmergedPaths ?? '');
100
+ }
101
+ return Buffer.from(b.diffNames ?? '');
102
+ },
103
+ 'git checkout': () => Buffer.from(''),
104
+ 'git add': () => Buffer.from(''),
105
+ 'git commit': () => Buffer.from(''),
106
+ 'npm install': () => Buffer.from(''),
107
+ };
@@ -1,11 +1,12 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { closeSync, existsSync, openSync, readSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
3
2
 
4
3
  import { cmdDoctor } from './commands.doctor.ts';
4
+ import { currentBranch, headSha, reinstallIfNeeded } from './commands.update.git.ts';
5
+ import { defaultPrompt, tryAutoResolveMergeConflict } from './commands.update.resolve.ts';
5
6
  import { REPO_HOME } from './config.ts';
6
7
  import { commitRegeneratedLockfile, precommitForkExtras } from './update.fork-extras.ts';
7
8
  import { loadTopology } from './update.topology.ts';
8
- import { die, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, warn } from './utils.ts';
9
+ import { die, gitOrFatal, gitStatusPorcelainZ, log, warn } from './utils.ts';
9
10
 
10
11
  /**
11
12
  * Caller-supplied options for `cmdUpdate`. All flags optional; defaults are
@@ -27,130 +28,6 @@ export type CmdUpdateOpts = {
27
28
  prompt?: (question: string) => string;
28
29
  };
29
30
 
30
- /**
31
- * Get the current Git branch name for the repository at REPO_HOME.
32
- *
33
- * Wraps the failure path so a corrupt or missing `.git` directory surfaces as
34
- * ``✗ ...`` via the top-level dispatcher's `NomadFatal` catch
35
- * rather than a raw `ExecException` stack trace.
36
- *
37
- * @returns The current branch name (trimmed).
38
- * @throws NomadFatal when the git command fails; if the command produced stderr, that stderr is written to process.stderr before the exception is thrown.
39
- */
40
- function currentBranch(): string {
41
- try {
42
- return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
43
- cwd: REPO_HOME,
44
- stdio: ['ignore', 'pipe', 'pipe'],
45
- })
46
- .toString()
47
- .trim();
48
- } catch (err) {
49
- const e = err as Error & { stderr?: Buffer };
50
- if (e.stderr) process.stderr.write(e.stderr);
51
- throw new NomadFatal('git rev-parse --abbrev-ref HEAD failed');
52
- }
53
- }
54
-
55
- /**
56
- * Default y/N prompt used when `opts.prompt` is not injected.
57
- *
58
- * Reads from `/dev/tty` byte-by-byte until newline so the call returns after
59
- * the user presses Enter (cooked-mode TTY line buffering). The naive
60
- * `readFileSync(0)` approach reads until EOF, which hangs interactive use
61
- * until Ctrl-D. Opening `/dev/tty` directly also means the prompt still
62
- * works when stdin is piped or redirected.
63
- *
64
- * @param question - Prompt text written to stdout before reading input.
65
- * @returns The user's trimmed answer; `''` on any failure (no controlling TTY, read error), which `runFork` treats as "no" and skips the push.
66
- */
67
- function defaultPrompt(question: string): string {
68
- process.stdout.write(question);
69
- let fd: number;
70
- try {
71
- fd = openSync('/dev/tty', 'r');
72
- } catch {
73
- return '';
74
- }
75
- try {
76
- const buf = Buffer.alloc(1);
77
- let answer = '';
78
- while (true) {
79
- const n = readSync(fd, buf, 0, 1, null);
80
- if (n === 0) break;
81
- const ch = buf.toString('utf8', 0, 1);
82
- if (ch === '\n' || ch === '\r') break;
83
- answer += ch;
84
- }
85
- return answer.trim();
86
- } catch {
87
- return '';
88
- } finally {
89
- closeSync(fd);
90
- }
91
- }
92
-
93
- /**
94
- * Read and return the current `HEAD` commit SHA from the repository.
95
- *
96
- * Used to pin the pre-update commit so the post-update lockfile diff is
97
- * exact regardless of whether the pull was a fast-forward, a no-op, or a
98
- * merge. `HEAD@{1}` is unreliable here: a no-op `git pull --ff-only` does
99
- * not always write a reflog entry, and a freshly cloned repo has no
100
- * `HEAD@{1}` at all.
101
- *
102
- * @returns The `HEAD` commit SHA as a trimmed string.
103
- * @throws NomadFatal if `git rev-parse HEAD` fails (stderr is written to stderr when present).
104
- */
105
- function headSha(): string {
106
- try {
107
- return execFileSync('git', ['rev-parse', 'HEAD'], {
108
- cwd: REPO_HOME,
109
- stdio: ['ignore', 'pipe', 'pipe'],
110
- })
111
- .toString()
112
- .trim();
113
- } catch (err) {
114
- const e = err as Error & { stderr?: Buffer };
115
- if (e.stderr) process.stderr.write(e.stderr);
116
- throw new NomadFatal('git rev-parse HEAD failed');
117
- }
118
- }
119
-
120
- /**
121
- * List files changed between the given commit and the current HEAD.
122
- *
123
- * @param beforeSha - Commit SHA to compare against HEAD
124
- * @returns An array of file paths changed between `beforeSha` and `HEAD`; an empty array if there are no changes
125
- */
126
- function changedFilesSince(beforeSha: string): string[] {
127
- const out = execFileSync('git', ['diff', '--name-only', `${beforeSha}..HEAD`], {
128
- cwd: REPO_HOME,
129
- stdio: ['ignore', 'pipe', 'pipe'],
130
- }).toString();
131
- return out.split('\n').filter((line) => line !== '');
132
- }
133
-
134
- /**
135
- * Run `npm install` in the repository only if `package-lock.json` changed since a given commit.
136
- *
137
- * If `package-lock.json` did not change between `beforeSha` and `HEAD`, logs
138
- * a skip message; otherwise runs `npm install` with working directory set to
139
- * `REPO_HOME`. Routing through `execFileSync` (no shell) keeps the call
140
- * mockable in tests and prevents any chance of argv injection.
141
- *
142
- * @param beforeSha - Commit SHA to compare against `HEAD` when determining whether the lockfile changed
143
- */
144
- function reinstallIfNeeded(beforeSha: string): void {
145
- const changed = changedFilesSince(beforeSha);
146
- if (!changed.includes('package-lock.json')) {
147
- log('skipping npm install (lockfile unchanged)');
148
- return;
149
- }
150
- log('package-lock.json changed, running npm install');
151
- execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
152
- }
153
-
154
31
  /**
155
32
  * Perform a vanilla update by fast-forward pulling `origin/main`.
156
33
  *
@@ -172,100 +49,6 @@ function runVanilla(opts: CmdUpdateOpts): boolean {
172
49
  return false;
173
50
  }
174
51
 
175
- /**
176
- * Files release-please touches as a set on every release commit. Multi-file
177
- * merge conflicts in `nomad update` that consist entirely of paths from this
178
- * set are diagnostic for a release landing upstream while the mirror has its
179
- * own local commits on these artifacts. Taking upstream is the canonical
180
- * resolution (these are all generated artifacts the user has no business
181
- * editing on a mirror), but multi-file is more aggressive than the lone
182
- * lockfile case so we prompt before mutating.
183
- */
184
- const RELEASE_PLEASE_ARTIFACTS: ReadonlySet<string> = new Set([
185
- 'package.json',
186
- 'package-lock.json',
187
- 'CHANGELOG.md',
188
- '.release-please-manifest.json',
189
- ]);
190
-
191
- /**
192
- * Resolve a merge conflict by taking upstream's version of every listed path,
193
- * regenerating the lockfile via `npm install`, and committing the merge.
194
- * Shared body for the lone-lockfile auto-resolve and the release-please
195
- * multi-file prompted auto-resolve.
196
- *
197
- * @param paths - Unmerged paths to resolve via `git checkout --theirs`.
198
- */
199
- function resolveByTakingTheirs(paths: readonly string[]): void {
200
- for (const p of paths) {
201
- gitOrFatal(['checkout', '--theirs', '--', p], `git checkout --theirs ${p}`, REPO_HOME);
202
- }
203
- gitOrFatal(['add', ...paths], `git add ${paths.join(' ')}`, REPO_HOME);
204
- execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
205
- gitOrFatal(['add', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
206
- gitOrFatal(['commit', '--no-edit'], 'git commit --no-edit', REPO_HOME);
207
- log(`auto-resolved merge conflict (took upstream for ${paths.join(', ')}, reinstalled)`);
208
- }
209
-
210
- /**
211
- * Auto-resolve a merge conflict in the two scenarios both caused by
212
- * release-please landing upstream while the mirror has local commits:
213
- *
214
- * 1. **Sole `package-lock.json`** (silent): the lone-lockfile case from PR
215
- * #96. Any host that has run `npm install` against the mirror will hit
216
- * this on the next `nomad update`; take upstream + reinstall is the
217
- * semantically-correct fix and surprise-free for a generated artifact.
218
- *
219
- * 2. **All paths in `RELEASE_PLEASE_ARTIFACTS` and more than one path**
220
- * (prompted): a release commit conflicting on `package.json`,
221
- * `CHANGELOG.md`, `.release-please-manifest.json` together with the
222
- * lockfile. Same semantic resolution, but more files are touched so we
223
- * require explicit y/N consent before mutating.
224
- *
225
- * Returns `false` for any other conflict shape (including probe failure);
226
- * the caller re-throws the original merge `NomadFatal` unchanged.
227
- *
228
- * @param opts - Update options; only `prompt` is consulted (used for the multi-file release-please consent prompt).
229
- * @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).
230
- */
231
- function tryAutoResolveMergeConflict(opts: CmdUpdateOpts): boolean {
232
- let unmerged: string[];
233
- try {
234
- unmerged = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
235
- cwd: REPO_HOME,
236
- stdio: ['ignore', 'pipe', 'pipe'],
237
- })
238
- .toString()
239
- .split('\n')
240
- .filter((line) => line !== '');
241
- } catch {
242
- // Probe failure must not mask the original merge NomadFatal. Returning
243
- // false lets the caller re-throw the merge error unchanged.
244
- return false;
245
- }
246
-
247
- if (unmerged.length === 1 && unmerged[0] === 'package-lock.json') {
248
- resolveByTakingTheirs(['package-lock.json']);
249
- return true;
250
- }
251
-
252
- if (unmerged.length > 1 && unmerged.every((p) => RELEASE_PLEASE_ARTIFACTS.has(p))) {
253
- const promptFn = opts.prompt ?? defaultPrompt;
254
- log(`merge conflict in release-please artifacts: ${unmerged.join(', ')}`);
255
- const answer = promptFn(
256
- 'Auto-resolve by taking upstream + `npm install` + commit? [y/N] ',
257
- ).toLowerCase();
258
- if (answer !== 'y' && answer !== 'yes') {
259
- log('skipping auto-resolve (resolve manually then re-run `nomad update`)');
260
- return false;
261
- }
262
- resolveByTakingTheirs(unmerged);
263
- return true;
264
- }
265
-
266
- return false;
267
- }
268
-
269
52
  /**
270
53
  * Perform a fork-style update by fetching from `upstream`, merging `upstream/main` into `main`, and optionally pushing the merge to `origin`.
271
54
  *
package/src/diff.ts CHANGED
@@ -4,7 +4,8 @@ import { join } from 'node:path';
4
4
  import { HOME, REPO_HOME } from './config.ts';
5
5
  import { computePreview } from './preview.ts';
6
6
  import { emitSummary } from './summary.ts';
7
- import { die, fail, freshBackupTs, NomadFatal } from './utils.ts';
7
+ import { die, fail, NomadFatal } from './utils.ts';
8
+ import { freshBackupTs } from './utils.fs.ts';
8
9
 
9
10
  /**
10
11
  * `nomad diff` command. Offline-safe, read-only preview surface that runs
@@ -0,0 +1,40 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { warn } from './utils.ts';
4
+
5
+ /**
6
+ * List files that differ between two paths via `git diff --no-index
7
+ * --name-only`. Exit 0 = identical, exit 1 = differences exist (read names
8
+ * from `e.stdout`, not an error). Missing-git (ENOENT) and other git failures
9
+ * each surface a WARN instead of collapsing to a silent empty list, so the
10
+ * operator can tell "no diff" (silent) apart from a skipped check (the
11
+ * loud-doctor contract). Argv-array `execFileSync` (no shell) so paths cannot
12
+ * inject.
13
+ *
14
+ * @param a - First path to compare (the local side, named in WARN messages).
15
+ * @param b - Second path to compare (the repo side).
16
+ * @returns Relative file paths that differ, or `[]` when identical or skipped.
17
+ */
18
+ export function listDivergingFiles(a: string, b: string): string[] {
19
+ try {
20
+ const stdout = execFileSync('git', ['diff', '--no-index', '--name-only', a, b], {
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ }).toString();
23
+ return stdout.split('\n').filter((line) => line.length > 0);
24
+ } catch (err) {
25
+ const e = err as NodeJS.ErrnoException & { status?: number; stdout?: Buffer };
26
+ if (e.status === 1 && e.stdout !== undefined) {
27
+ return e.stdout
28
+ .toString()
29
+ .split('\n')
30
+ .filter((line) => line.length > 0);
31
+ }
32
+ if (e.code === 'ENOENT') {
33
+ warn(`git not on PATH; divergence check skipped for ${a}`);
34
+ return [];
35
+ }
36
+ /* c8 ignore next -- e.message is set on any thrown Error; String(err) is a defensive fallback */
37
+ warn(`divergence check failed for ${a}: ${e.message ?? String(err)}`);
38
+ return [];
39
+ }
40
+ }
@@ -0,0 +1,52 @@
1
+ import { isAbsolute, normalize } from 'node:path';
2
+
3
+ import { NomadFatal } from './utils.ts';
4
+
5
+ /**
6
+ * `logical` keys in `path-map.json` are project identifiers (e.g. `ha-acwd`,
7
+ * `foo`), never path fragments. A crafted key like `../escape` or `foo/bar`
8
+ * would escape `shared/extras/` via `join()` (which normalizes `..`) and land
9
+ * content somewhere unexpected on the filesystem. The push allow-list catches
10
+ * such commits at the `git add` boundary, but the filesystem mutation has
11
+ * already happened by then. This check fails fast before any write. The
12
+ * pattern matches what every reasonable project name looks like and rejects
13
+ * everything else; tighten only if a real project needs broader characters.
14
+ */
15
+ const SAFE_LOGICAL = /^[A-Za-z0-9._-]+$/;
16
+
17
+ /**
18
+ * Throw `NomadFatal` unless `logical` is a path-separator-free project
19
+ * identifier (see `SAFE_LOGICAL`). Path-traversal defense-in-depth; called
20
+ * before any filesystem mutation by every extras op.
21
+ */
22
+ export function assertSafeLogical(logical: string): void {
23
+ if (!SAFE_LOGICAL.test(logical) || logical === '.' || logical === '..') {
24
+ throw new NomadFatal(
25
+ `invalid logical name in path-map.json extras: ${JSON.stringify(logical)} (must match [A-Za-z0-9._-]+; no path separators or '..')`,
26
+ );
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Reject `localRoot` values that contain unnormalized segments (`..`,
32
+ * redundant `/.`, trailing slashes that don't survive `normalize`). A
33
+ * poisoned `path-map.json` with `host: '/tmp/x/../escape'` would silently
34
+ * land writes at `/tmp/escape/.planning/` because `path.join` normalizes
35
+ * `..` before `cpSync` sees the destination. The user thinks they declared
36
+ * one path and got another. Requiring `localRoot === normalize(localRoot)`
37
+ * (and an absolute path on top) catches the obvious traversal trick and
38
+ * forces poisoned-map writes to surface as a FATAL before any filesystem
39
+ * mutation. Same defense-in-depth shape as `assertSafeLogical`.
40
+ */
41
+ export function assertSafeLocalRoot(localRoot: string, logical: string): void {
42
+ if (!isAbsolute(localRoot)) {
43
+ throw new NomadFatal(
44
+ `invalid localRoot for ${logical} in path-map.json: ${JSON.stringify(localRoot)} (must be absolute)`,
45
+ );
46
+ }
47
+ if (localRoot !== normalize(localRoot)) {
48
+ throw new NomadFatal(
49
+ `invalid localRoot for ${logical} in path-map.json: ${JSON.stringify(localRoot)} (must be already-normalized; no '..' or redundant segments)`,
50
+ );
51
+ }
52
+ }