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.
@@ -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
- }
@@ -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
- }