claude-nomad 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +180 -200
- package/package.json +5 -6
- package/src/commands.doctor.version.ts +19 -22
- package/src/commands.update.ts +18 -179
- package/src/config.ts +1 -8
- package/src/init.gh-onboard.ts +139 -0
- package/src/init.ts +19 -1
- package/src/nomad.dispatch.ts +95 -0
- package/src/nomad.help.ts +13 -8
- package/src/nomad.ts +19 -20
- package/src/push-checks.ts +16 -10
- package/src/push-gitleaks.config.ts +161 -0
- package/src/push-gitleaks.scan.ts +35 -12
- package/src/commands.update.git.ts +0 -90
- package/src/commands.update.resolve.ts +0 -138
- package/src/commands.update.test-helpers.git.ts +0 -107
- package/src/update.fork-extras.ts +0 -102
- package/src/update.topology.ts +0 -118
|
@@ -1,107 +0,0 @@
|
|
|
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,102 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
import { REPO_HOME } from './config.ts';
|
|
6
|
-
import { whitelistedExtrasPaths } from './extras-sync.ts';
|
|
7
|
-
import { gitOrFatal, log } from './utils.ts';
|
|
8
|
-
import { readPathMap } from './utils.json.ts';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Pre-commit whitelisted extras before a fork merge so an untracked-overwrite
|
|
12
|
-
* abort becomes a tracked-file merge (issue #112).
|
|
13
|
-
*
|
|
14
|
-
* When a fork host has untracked `shared/extras/<logical>/<dirname>/` content
|
|
15
|
-
* that `upstream/main` also introduces, `git merge upstream/main` aborts
|
|
16
|
-
* before creating any merge state ("untracked working tree files would be
|
|
17
|
-
* overwritten by merge"). No `UU` is recorded, so the lone-lockfile
|
|
18
|
-
* auto-resolve never fires and the merge surfaces as an opaque failure.
|
|
19
|
-
* Staging alone is insufficient (git still refuses to overwrite staged-but-
|
|
20
|
-
* uncommitted local changes); the overlap must be a committed tracked path so
|
|
21
|
-
* the merge engine treats it as a content merge. After this commit, identical
|
|
22
|
-
* extras merge cleanly (the normal sync case, leaving the lone `UU
|
|
23
|
-
* package-lock.json` the existing auto-resolve handles) and divergent extras
|
|
24
|
-
* surface a real, resolvable conflict instead of the abort.
|
|
25
|
-
*
|
|
26
|
-
* Scoped strictly to the whitelisted `shared/extras/` paths declared in
|
|
27
|
-
* `path-map.json`; never a blanket `git add -A`. No-op when there is no
|
|
28
|
-
* `path-map.json`, no declared extras, or none of the declared extras paths
|
|
29
|
-
* exist on disk, and when staging produces no index change (nothing dirty).
|
|
30
|
-
*/
|
|
31
|
-
export function precommitForkExtras(): void {
|
|
32
|
-
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
33
|
-
if (!existsSync(mapPath)) return;
|
|
34
|
-
const map = readPathMap(mapPath);
|
|
35
|
-
const candidates = whitelistedExtrasPaths(map).filter((p) => existsSync(join(REPO_HOME, p)));
|
|
36
|
-
if (candidates.length === 0) return;
|
|
37
|
-
|
|
38
|
-
gitOrFatal(['add', '--', ...candidates], 'git add extras', REPO_HOME);
|
|
39
|
-
// Only commit when staging actually changed the index. The probe and commit
|
|
40
|
-
// are BOTH path-scoped to the extras candidates so an unrelated staged change
|
|
41
|
-
// present before update neither flips the dirty probe nor rides along in the
|
|
42
|
-
// commit. `git diff --cached --quiet -- <candidates>` exits 0 when those paths
|
|
43
|
-
// match HEAD (nothing to commit), 1 when they differ; avoids an empty-commit
|
|
44
|
-
// failure when the extras were already tracked and unmodified.
|
|
45
|
-
let dirty = false;
|
|
46
|
-
try {
|
|
47
|
-
execFileSync('git', ['diff', '--cached', '--quiet', '--', ...candidates], {
|
|
48
|
-
cwd: REPO_HOME,
|
|
49
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
-
});
|
|
51
|
-
} catch {
|
|
52
|
-
dirty = true;
|
|
53
|
-
}
|
|
54
|
-
if (!dirty) return;
|
|
55
|
-
gitOrFatal(
|
|
56
|
-
['commit', '-m', 'chore: stage local extras before upstream merge', '--', ...candidates],
|
|
57
|
-
'git commit extras',
|
|
58
|
-
REPO_HOME,
|
|
59
|
-
);
|
|
60
|
-
log(`staged local extras before merge: ${candidates.join(', ')}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* After a successful fork merge, commit a `package-lock.json` that `npm
|
|
65
|
-
* install` regenerated and left uncommitted (secondary item of issue #112).
|
|
66
|
-
*
|
|
67
|
-
* The post-merge reinstall (`reinstallIfNeeded`) can rewrite the lockfile
|
|
68
|
-
* when the merge changed dependencies, leaving working-tree drift that the
|
|
69
|
-
* trailing `nomad doctor` reports as "uncommitted changes". This stages and
|
|
70
|
-
* commits ONLY `package-lock.json` so the repo is clean after update. No-op
|
|
71
|
-
* when the lockfile is absent or unchanged (the `git diff --quiet` probe
|
|
72
|
-
* exits 0). Tightly scoped: never touches any other path.
|
|
73
|
-
*/
|
|
74
|
-
export function commitRegeneratedLockfile(): void {
|
|
75
|
-
const lockfile = join(REPO_HOME, 'package-lock.json');
|
|
76
|
-
if (!existsSync(lockfile)) return;
|
|
77
|
-
// `git diff --quiet -- package-lock.json` exits 0 when the working tree
|
|
78
|
-
// matches HEAD (no drift to commit), 1 when it differs.
|
|
79
|
-
let drifted = false;
|
|
80
|
-
try {
|
|
81
|
-
execFileSync('git', ['diff', '--quiet', '--', 'package-lock.json'], {
|
|
82
|
-
cwd: REPO_HOME,
|
|
83
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
-
});
|
|
85
|
-
} catch {
|
|
86
|
-
drifted = true;
|
|
87
|
-
}
|
|
88
|
-
if (!drifted) return;
|
|
89
|
-
gitOrFatal(['add', '--', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
|
|
90
|
-
gitOrFatal(
|
|
91
|
-
[
|
|
92
|
-
'commit',
|
|
93
|
-
'-m',
|
|
94
|
-
'chore: commit regenerated package-lock.json after update',
|
|
95
|
-
'--',
|
|
96
|
-
'package-lock.json',
|
|
97
|
-
],
|
|
98
|
-
'git commit package-lock.json',
|
|
99
|
-
REPO_HOME,
|
|
100
|
-
);
|
|
101
|
-
log('committed regenerated package-lock.json after update');
|
|
102
|
-
}
|
package/src/update.topology.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
import { REPO_HOME, UPSTREAM_REPO_SLUG } from './config.ts';
|
|
4
|
-
import { NomadFatal } from './utils.ts';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Topology label resolved by `detectTopology`. `vanilla` is a single `origin`
|
|
8
|
-
* remote pointing at the public repo (read-only consumer). `fork` is the
|
|
9
|
-
* private-config layout: `upstream` is the public repo and `origin` is the
|
|
10
|
-
* user's private mirror. `unknown` is anything else: no `upstream`, an
|
|
11
|
-
* `origin` that does not match the public repo, etc. Unknown topologies
|
|
12
|
-
* surface a fatal so the user can run the two-command manual fallback
|
|
13
|
-
* without nomad guessing at intent.
|
|
14
|
-
*/
|
|
15
|
-
export type Topology = 'vanilla' | 'fork' | 'unknown';
|
|
16
|
-
|
|
17
|
-
/** Escape regex metacharacters so a config value with `.` or `+` interpolated
|
|
18
|
-
* into a `new RegExp(...)` does not silently broaden the match. The current
|
|
19
|
-
* slug has no metachars, but this keeps the call defensible if it changes. */
|
|
20
|
-
function escapeRe(s: string): string {
|
|
21
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Strict patterns matching the public repo's SSH and HTTPS remote URL forms,
|
|
26
|
-
* with and without a `.git` suffix. Both `git remote add upstream ...` styles
|
|
27
|
-
* (set-url ssh, gh-clone https) must round-trip through `detectTopology`.
|
|
28
|
-
*/
|
|
29
|
-
const SLUG_RE = escapeRe(UPSTREAM_REPO_SLUG);
|
|
30
|
-
const SSH_REGEX = new RegExp(String.raw`^git@github\.com:${SLUG_RE}(\.git)?$`);
|
|
31
|
-
const HTTPS_REGEX = new RegExp(String.raw`^https://github\.com/${SLUG_RE}(\.git)?$`);
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Determines whether a remote URL matches the canonical upstream repository forms (SSH or HTTPS).
|
|
35
|
-
*
|
|
36
|
-
* @param url - The remote URL to test
|
|
37
|
-
* @returns `true` if `url` matches the canonical upstream SSH or HTTPS form, `false` otherwise.
|
|
38
|
-
*/
|
|
39
|
-
function matchesUpstream(url: string): boolean {
|
|
40
|
-
return SSH_REGEX.test(url) || HTTPS_REGEX.test(url);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Parse the output of `git remote -v` into a mapping of remote names to their fetch URLs.
|
|
45
|
-
*
|
|
46
|
-
* Ignores `(push)` entries because topology detection drives the
|
|
47
|
-
* `git pull`/`fetch`/`merge` invocations, which all use the fetch URL.
|
|
48
|
-
* Lines that do not match the expected `<name> <url> (fetch)` format are
|
|
49
|
-
* skipped.
|
|
50
|
-
*
|
|
51
|
-
* @param out - Raw stdout from `git remote -v`
|
|
52
|
-
* @returns A record mapping each remote name to its fetch URL
|
|
53
|
-
*/
|
|
54
|
-
export function parseRemotes(out: string): Record<string, string> {
|
|
55
|
-
const remotes: Record<string, string> = {};
|
|
56
|
-
for (const line of out.split('\n')) {
|
|
57
|
-
const trimmed = line.trim();
|
|
58
|
-
if (trimmed === '') continue;
|
|
59
|
-
const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(trimmed);
|
|
60
|
-
if (match === null) continue;
|
|
61
|
-
remotes[match[1]] = match[2];
|
|
62
|
-
}
|
|
63
|
-
return remotes;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Classify a parsed `{ name: fetchUrl }` remote map into a topology label.
|
|
68
|
-
*
|
|
69
|
-
* Classification rules:
|
|
70
|
-
* - `vanilla` — exactly one remote named `origin` matching the public repo.
|
|
71
|
-
* - `fork` — an `upstream` matching the public repo and a separate `origin`
|
|
72
|
-
* (any URL).
|
|
73
|
-
* - `unknown` — anything else (no `upstream`, an `origin` that does not
|
|
74
|
-
* match the public repo, etc.).
|
|
75
|
-
*
|
|
76
|
-
* Pure and side-effect-free; tests drive it directly with hand-built maps.
|
|
77
|
-
*
|
|
78
|
-
* @param remotes - Map of remote name -> fetch URL produced by `parseRemotes`.
|
|
79
|
-
* @returns The resolved `Topology` label.
|
|
80
|
-
*/
|
|
81
|
-
export function detectTopology(remotes: Record<string, string>): Topology {
|
|
82
|
-
const origin = remotes.origin;
|
|
83
|
-
const upstream = remotes.upstream;
|
|
84
|
-
if (typeof upstream === 'string' && matchesUpstream(upstream) && typeof origin === 'string') {
|
|
85
|
-
return 'fork';
|
|
86
|
-
}
|
|
87
|
-
const names = Object.keys(remotes);
|
|
88
|
-
if (names.length === 1 && names[0] === 'origin' && matchesUpstream(origin ?? '')) {
|
|
89
|
-
return 'vanilla';
|
|
90
|
-
}
|
|
91
|
-
return 'unknown';
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Detect the repository remote topology by running `git remote -v` in REPO_HOME
|
|
96
|
-
* and routing the output through `parseRemotes` + `detectTopology`.
|
|
97
|
-
*
|
|
98
|
-
* `git remote -v` is read-only so failures here are unexpected; we still
|
|
99
|
-
* route through `NomadFatal` so the dispatcher prints ``✗ ...``
|
|
100
|
-
* rather than dumping a stack trace.
|
|
101
|
-
*
|
|
102
|
-
* @returns The detected topology label: `'vanilla'`, `'fork'`, or `'unknown'`.
|
|
103
|
-
* @throws NomadFatal when `git remote -v` fails; any captured git stderr is written to `process.stderr` before the error is thrown.
|
|
104
|
-
*/
|
|
105
|
-
export function loadTopology(): Topology {
|
|
106
|
-
let out: string;
|
|
107
|
-
try {
|
|
108
|
-
out = execFileSync('git', ['remote', '-v'], {
|
|
109
|
-
cwd: REPO_HOME,
|
|
110
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
|
-
}).toString();
|
|
112
|
-
} catch (err) {
|
|
113
|
-
const e = err as Error & { stderr?: Buffer };
|
|
114
|
-
if (e.stderr) process.stderr.write(e.stderr);
|
|
115
|
-
throw new NomadFatal('git remote -v failed');
|
|
116
|
-
}
|
|
117
|
-
return detectTopology(parseRemotes(out));
|
|
118
|
-
}
|