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.
- package/CHANGELOG.md +19 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +158 -0
- package/src/commands.doctor.check-shared.ts +58 -189
- package/src/commands.doctor.checks.pathmap.ts +101 -0
- package/src/commands.doctor.checks.repo.ts +133 -0
- package/src/commands.doctor.checks.repository.ts +105 -0
- package/src/commands.doctor.checks.settings.ts +88 -0
- package/src/commands.doctor.format.ts +18 -0
- package/src/commands.doctor.ts +10 -7
- package/src/commands.drop-session.git.ts +81 -0
- package/src/commands.drop-session.ts +79 -138
- package/src/commands.pull.ts +3 -2
- package/src/commands.push.allowlist.ts +119 -0
- package/src/commands.push.ts +6 -121
- package/src/commands.update.git.ts +90 -0
- package/src/commands.update.resolve.ts +138 -0
- package/src/commands.update.test-helpers.git.ts +107 -0
- package/src/commands.update.ts +4 -221
- package/src/diff.ts +2 -1
- package/src/extras-sync.diff.ts +40 -0
- package/src/extras-sync.guards.ts +52 -0
- package/src/extras-sync.ts +146 -236
- package/src/init.classify.ts +1 -1
- package/src/init.snapshot.ts +3 -1
- package/src/init.ts +2 -1
- package/src/links.ts +3 -10
- package/src/nomad.dispatch.ts +25 -0
- package/src/nomad.help.ts +43 -0
- package/src/nomad.ts +6 -68
- package/src/preview.ts +2 -1
- package/src/push-gitleaks.scan.ts +115 -0
- package/src/push-gitleaks.ts +50 -106
- package/src/remap.ts +3 -1
- package/src/resume.ts +2 -1
- package/src/update.fork-extras.ts +2 -1
- package/src/utils.fs.ts +152 -0
- package/src/utils.json.ts +55 -0
- package/src/utils.lockfile.ts +168 -0
- package/src/utils.ts +0 -327
- 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
|
+
};
|
package/src/commands.update.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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,
|
|
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
|
+
}
|