GitNexus/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs
drdave 021ac30376
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
feat(cli): add a bunx lane so bun-only machines can run gitnexus (#2765)
* feat(cli): add a bunx lane to the runner ladder

The ladder assumed a Node toolchain: global gitnexus, then pnpm dlx or
npx in some order, with npx as the last resort. On a bun-only machine
npm, npx and pnpm are all absent, so every rung fell through to npx and
both the emitted hint and the generated .gitnexus/run.cjs produced a
command the machine could not run at all.

Add bun as a fourth mode, invoked as an install-free bunx one-shot, on
two rungs:

  - npm 11+ with no pnpm to fall back on — bunx dodges the same arborist
    install crash the pnpm rung exists for (#1939);
  - npm and pnpm both absent — previously the dead end described above.

Every pre-existing outcome is preserved: pnpm still wins on npm 11+, npx
still wins on npm < 11, and pnpm still wins over bunx when npm is absent.
Regression tests pin each of those. The bun PATH probe is lazy, so a
machine with a Node toolchain pays no extra scan and the stale-index hook
budget is unchanged.

bunx takes no allow-build equivalent: bun's --trust is a bun add/install
flag that writes trustedDependencies into a project package.json, which a
one-shot has none of, so the argv stays flag-free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R9psS9gJ73MRyquoBoPKg

* fix(lbug): restore the prebuilt native binary when install scripts were skipped

Without this the new bunx lane resolves to a command that still fails:
bun skips lifecycle scripts for a bunx fetch, so @ladybugdb/core's
install script never copies lbugjs.node up from its per-platform
sub-package and every native command dead-ends on 'LadybugDB native
binary (lbugjs.node) is missing'.

The existing guidance cannot rescue that case. It offers pnpm
--allow-build, a global install, or adding trustedDependencies to a
project package.json — bunx has no project package.json to add to, no
per-invocation opt-in, and re-extracts the package on every run, so an
out-of-band repair is wiped before the next invocation. In-process
recovery is the only thing that can work.

Recovery is cheap because nothing is actually absent: the binary is
already on disk in @ladybugdb/core-<platform>-<arch>, and the skipped
script only copied it up. Redo that copy (prebuilt only — never a source
build, never a network fetch) before reporting failure. Best-effort by
construction: read-only node_modules, an absent sub-package or an
unsupported platform all fall through to the existing diagnostics
unchanged, which a test pins.

Also covers pnpm dlx without --allow-build and npm --ignore-scripts.

Declare trustedDependencies so a plain `bun install` in this repo
produces a working native binary too — the remedy the error message
already prescribes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R9psS9gJ73MRyquoBoPKg

* fix(ai-context): name every install-free runner in the generated bootstrap note

The emitted gitnexus:start block told a reader with no runner yet to run
`npx gitnexus analyze`, falling back to a global npm install. Both name
binaries a bun-only machine does not have, so the generated AGENTS.md and
CLAUDE.md offered it no reachable bootstrap path.

List npx, bunx and pnpm dlx instead of resolving one. The block is
committed, so emitting the command this machine happens to resolve would
make two contributors on different package managers rewrite it at each
other on every analyze — the per-machine churn #1706 removed. Naming all
three keeps the note machine-independent and correct everywhere.

Regenerates this repo's own committed block to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R9psS9gJ73MRyquoBoPKg

* fix(cli): address PR #2765 review — bunx liveness, restore diagnostics, docs

Addresses all five review comments on #2765.

P1 — `hasBun()` was a PATH-existence check only, so a present-but-broken
`bunx` shim (partial uninstall, failed `bun upgrade`) was selected with no
functional validation. Because selecting `bun` also suppresses the npm-11
npx-crash warning, the result was a silent dead end: no diagnostic, and a
`bunx gitnexus@latest analyze` command that only fails at execution time.
Add `probeRuns()` — a real `bunx --version` liveness probe, gated behind the
cheap spawn-free PATH scan so machines with npm/pnpm still pay nothing. It
ignores the output on purpose (a banner or unparseable version still counts
as alive); only a spawn failure, non-zero exit, or timeout rejects. Injectable
via a new `bunRuns` dep so the mode tests stay host-independent.

P2 — the `gitnexus-cli` skill (and both shipped mirrors) still described the
pre-bunx ladder, stranding exactly this PR's audience: a bun-only machine
whose agent bootstraps from that file was told to use npx/npm/pnpm, none of
which exist there. All three copies now name `bunx` in the ladder and the
bootstrap fallback, with a `shipped-skills-sync` fragment assertion so the
gap is CI-caught (these copies are not byte-compared, only the engineering
family is).

P2 — `restorePrebuiltNativeBinary` collapsed every failure into `false`, so an
EACCES/EROFS from `copyFileSync` was indistinguishable from "no prebuilt
sub-package exists". Users on a read-only `node_modules` layer (a baked
container image mounted read-only — a common CI pattern) got the generic
lifecycle-script advice, which cannot fix a non-writable filesystem. Return a
`RestoreOutcome` instead and route `copy-failed` to its own message.

P2 — document that `trustedDependencies` only takes effect for `bun install` /
`pnpm install` run inside this repo: it does nothing for a `bunx` one-shot or
for a consumer's `bun add gitnexus`. The note sits on
`restorePrebuiltNativeBinary` so a future maintainer cannot mistake that
function for redundant and delete the thing the bunx path actually relies on.

P3 — the `binary_missing` bun advice told `bunx` one-shot users to edit a
package.json they do not have, and listed 1 of the 3 packages this package
now trusts. Both repair messages now share one `BUN_REPAIR_LINES` const with
the full package list and a `bun install -g gitnexus` alternative.

Also: shortened the bootstrap note and raised the CLAUDE.md block budget
2900 -> 2950. The note has to name every install-free runner (that is the
point of the bun lane), and main's own growth since this PR's last green CI
had already pushed the generated block over the old ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015epfxkEMsmHFNVSFQqAkB4

* refactor(cli): simplify the #2765 review fixes

Cleanup pass over the previous commit — no intended behavior change except
the doctor status line noted below.

Reuse: `probeRuns()` duplicated `probeVersion()`'s entire spawn setup — same
argv, timeout, `windowsHide`, and the CVE-2024-27980 Windows-shim workaround —
in a file with two byte-identical committed copies, so the shim rule lived at
four sites. Its docstring's own objection was to the RETURN SHAPE, not to
reuse, so `probeVersion` now returns `{ ran, major, minor }` and `hasBun` reads
`.ran`. Existing callers only read `major`/`minor`, so nothing else changes.

Also dropped a pointless `const runs = () => …` thunk (`&&` already
short-circuits), and deleted a new test that was a character-for-character
duplicate of `falls back to npx when npm is null-absent and pnpm is also
absent` — its cheapest-first-gate rationale moved into that test's comment.

Correctness in the budget comment: the claim that the bun rung is free because
"pnpm is absent there, so its probe never ran" was wrong. `formatAnalyzeCommand`
spawns `pnpm --version` unconditionally when no global `gitnexus` is on PATH —
that spawn IS how pnpm presence is discovered. Real worst case is 5 subprocesses
/ ~8s, and the 8s needs Windows (`shell: true` spawns cmd.exe for an absent
pnpm); on POSIX an absent pnpm ENOENTs in ~1ms. Comment now says that. Likewise
"a machine with npm or pnpm never pays" was wrong for npm 11+ without pnpm —
that IS the rung that pays.

Altitude: `copy-failed` changed only the message text while still returning
`kind: 'binary_missing'`, so `doctor` would have printed "✗ lbugjs.node missing"
directly above a message saying the binary IS present — exactly the
contradiction #2672 removed. Added a `binary_unwritable` kind, a doctor case,
and a `nativeStatusCases` row. The binary-missing message construction moved
out of `checkLbugNative` into `unrestorableBinaryFailure`, typed
`Exclude<RestoreOutcome, 'restored'>` so a new outcome forces a decision
instead of silently inheriting the lifecycle-script advice.

Drift: the trusted-package list was hand-spelled in five places in
native-check.ts, with "matches gitnexus/package.json" asserted only in a
comment. All five now render from one `NATIVE_BUILD_PACKAGES` const (rendered
output is byte-identical), and the test reads the list out of package.json
instead of restating it, so a fourth native package fails the test rather than
silently shipping stale advice.

Finally, replaced the absolute CLAUDE.md block cap with the ratio the two prior
justifications actually appealed to (`< 5465 * 0.55`). Raising 2700 -> 2900 ->
2950 was a ratchet with no ratchet: an absolute cap can only fail on the PR
that adds the character, and the fix is always to nudge the number. Also fixed
a stale runner ladder in skills-steering.test.ts that still omitted bunx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015epfxkEMsmHFNVSFQqAkB4

---------

Co-authored-by: drdave-flexnteos <revenaugh.david@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-06 08:40:36 +00:00

384 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Single source of truth for how docs, hooks, and warnings invoke gitnexus.
*
* Automatically selects a working invocation path:
* 1. Global `gitnexus` on PATH (best — no install step)
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
* 3. npm 11+ without pnpm but with bunx → `bunx` (dodges the same crash)
* 4. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
* 5. pnpm-only → `pnpm --allow-build=… dlx`
* 6. bun-only → `bunx`
* 7. Last resort → `npx` (warned on npm 11+ from analyze.ts)
*
* The bun branches exist because a Node toolchain is no longer implied: on a
* bun-only machine npm, npx and pnpm are all absent, so every rung above
* resolved to `npx` and the emitted command could not run at all. `bunx` is
* bun's install-free one-shot runner and needs no allow-build equivalent — bun
* skips lifecycle scripts unconditionally for a `bunx` fetch, which the native
* loader recovers from directly (see core/lbug/native-check.ts). Both bun rungs
* gate on `bunx` actually running, not merely existing on PATH — see `hasBun`.
*
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
* package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses
* into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939).
*
* This stays self-contained CJS because the Claude/Antigravity hooks run as
* standalone files copied into the user's hook dir, where no package import is
* available. The CLI reuses this module from src/cli/resolve-invocation.ts via
* createRequire rather than re-implementing it. Two committed copies must stay
* byte-identical (enforced by resolve-invocation.test.ts) — edit both together:
* gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read)
* and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to
* `<repo>/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can
* reference it directly via the `require.main === module` exec tail below; that
* copy is gitignored and refreshed on every analyze, so it cannot drift for long.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const NPX_REF = 'gitnexus@latest';
// Native packages whose postinstall must run under pnpm 10+ (blocked by default).
const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter'];
const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
// Version-probe timeout, kept under Claude Code's 10s hook budget. PATH presence
// detection is now spawn-free (resolveOnPath scans PATH directly), so the only
// subprocesses left are the version probes: in a linked worktree the stale-index
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
// the worst case is ~7s — within budget. A healthy `--version` returns in well
// under a second, so the realistic cost is far lower. The bun rungs add at most
// one more 1s probe (`bunx --version`), reached only when pnpm is unusable and
// npm is 11+ or unreadable, for a ~8s theoretical cap. That cap needs an absent
// pnpm to burn its full second, which only Windows can do (`shell: true` spawns
// cmd.exe); on POSIX an absent pnpm ENOENTs in ~1ms, so the real ceiling is
// unmoved.
const PROBE_TIMEOUT_MS = 1000;
/**
* Absolute path to `command` on PATH, or null — a pure-Node, spawn-free lookup
* that mirrors how a shell resolves a bare command name: each PATH dir × the
* platform's executable extensions (PATHEXT on Windows; the bare name + X_OK on
* POSIX). This replaces the former `where`/`which` subprocess (#1938 "Option A"):
* it is byte-for-byte identical on every OS, with no dependency on the probe
* binary being reachable (a sanitized PATH that drops System32 / `/usr/bin` no
* longer defeats detection), no shell-spawn surface (CVE-2024-27980), and no
* spawn timeout to tune. On Windows it matches PATHEXT extensions ONLY — exactly
* what `where`/cmd.exe resolve — so neither an un-spawnable `.ps1`-only shim (not
* in default PATHEXT) nor a bare extensionless file (which the shell cannot launch
* as `command`) is a false positive. `preferExecExt` returns a recognized
* `.cmd`/`.bat`/`.exe` shim ahead of an exotic PATHEXT hit (e.g. `.COM`) when both
* match, matching what a user would actually launch. Pure (platform/env injectable)
* so it is unit-testable without touching the host PATH.
*/
function resolveOnPath(
command,
preferExecExt = false,
{ platform = process.platform, env = process.env } = {},
) {
const pathValue = env.PATH || env.Path || env.path || '';
if (!pathValue) return null;
const isWin = platform === 'win32';
const exts = isWin
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
.split(';')
.map((e) => e.trim())
.filter(Boolean)
.map((e) => (e.startsWith('.') ? e : `.${e}`))
: [''];
let weakHit = null;
// Split on the host's PATH delimiter. `platform` is injected only to choose the
// extension/exec-bit rules; the PATH string is always host-format, so it must
// split on the host delimiter (`path.delimiter`) — in production `platform` IS
// the host, so they coincide. (Deriving the delimiter from an injected platform
// would split a Windows drive-letter path `C:\…` at its colon under a POSIX
// injection.)
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
for (const ext of exts) {
const candidate = path.join(dir, `${command}${ext}`);
try {
if (!fs.statSync(candidate).isFile()) continue;
if (!isWin) fs.accessSync(candidate, fs.constants.X_OK);
// Prefer a runnable .cmd/.bat/.exe shim; remember an exotic PATHEXT hit
// (e.g. .COM) only as a last resort if nothing better turns up.
if (isWin && preferExecExt && !/\.(cmd|bat|exe)$/i.test(ext)) {
weakHit = weakHit || candidate;
continue;
}
return candidate;
} catch {
/* not a runnable file here — try the next candidate */
}
}
}
return weakHit;
}
// One spawn of `<command> --version` → { ran, major, minor } (versions null when
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
//
// `ran` is liveness, kept separate from the version because a PATH hit proves a
// file exists, not that it works, and the two answers differ: a banner-printing
// or oddly-versioned tool is alive with `major: null`, while a stale shim left by
// a partial uninstall is neither. Only the bun rung consults `ran` today (see
// hasBun) — it is the one runner with no version to read, so a dedicated probe is
// its only liveness signal; pnpm gets the same evidence for free from the version
// spawn it must make anyway, and deliberately forgives an unreadable one (#1939).
function probeVersion(command) {
try {
const output = execFileSync(command, ['--version'], {
encoding: 'utf-8',
timeout: PROBE_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
// On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no
// PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a
// shell (CVE-2024-27980), so a bare `<command> --version` ENOENTs and the
// probe would wrongly report a present tool as absent. A shell lets the OS
// resolve the shim. POSIX needs no shell (direct PATH lookup works).
shell: process.platform === 'win32',
});
// Find the first line that starts with a version token (`MAJOR.MINOR`,
// optional `v` prefix) rather than splitting the whole output — pnpm/npm
// under Corepack or with an update notice can print a banner line on stdout
// before the version (stderr is already dropped via the stdio config).
const versionLine = output
.split('\n')
.map((l) => l.trim())
.find((l) => /^v?\d+\.\d+/.test(l));
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
return {
ran: true,
major: match ? Number(match[1]) : null,
minor: match ? Number(match[2]) : null,
};
} catch {
// Spawn failure, non-zero exit, or the timeout — the command did not run.
return { ran: false, major: null, minor: null };
}
}
// `deps` is the single injection seam: an explicitly provided key — including a
// `null` value, detected via `in` — is honored as-is so tests can simulate an
// absent tool without spawning; an absent key falls through to the real probe.
function getNpmMajorVersion(deps = {}) {
return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major;
}
/**
* `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2
* (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted
* below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the
* flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the
* flags for committed documentation, which cannot probe the reader's pnpm.
*/
function formatPnpmAllowBuildArgs(options = {}, deps = {}) {
if (!options.alwaysAllowBuild) {
const { major, minor } =
'pnpmMajor' in deps
? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null }
: probeVersion('pnpm');
const lacksAllowBuild =
major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2));
if (lacksAllowBuild) return [];
}
const pkgs = [...PNPM_ALLOW_BUILD_BASE];
if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS);
return pkgs.map((p) => `--allow-build=${p}`);
}
/** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */
function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' ');
const prefix = flags ? `${flags} ` : '';
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
}
/**
* Resolve `gitnexus` | `pnpm` | `bun` | `npx`. `GITNEXUS_INVOCATION` forces a
* mode (test/escape hatch). `probe` is injectable so the preference order can be
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
* inject `{ npmMajor, pnpmMajor, bunPresent, bunRuns }` for tests.
*/
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx' || forced === 'bun') {
return forced;
}
if (probe('gitnexus', true)) return 'gitnexus';
const npmMajor = getNpmMajorVersion(deps);
// pnpm presence: prefer an explicit `pnpmPresent` flag (set by
// formatAnalyzeCommand, which falls back to a PATH probe when the version is
// unreadable) so a present-but-unparseable pnpm — slow probe, Corepack
// banner — still selects pnpm instead of the npx crash path. Otherwise an
// injected version (a successful `pnpm --version` proves presence)
// short-circuits the `which pnpm` probe; failing both, fall back to PATH.
const hasPnpm =
'pnpmPresent' in deps
? deps.pnpmPresent
: 'pnpmMajor' in deps
? deps.pnpmMajor !== null
: Boolean(probe('pnpm'));
// bun usability is resolved lazily: only the two branches below can select it,
// so a machine with pnpm, or with npm < 11, never pays the PATH scan or the
// spawn. `bunx` (not `bun`) is probed because `bunx` is what the resolved
// command actually runs. Two gates, `&&`-ordered cheapest first: a spawn-free
// PATH scan, then liveness — a PATH hit alone would route a present-but-broken
// shim to a command that can only fail at execution time.
let bunCache;
const hasBun = () => {
if (bunCache === undefined) {
const present = 'bunPresent' in deps ? Boolean(deps.bunPresent) : Boolean(probe('bunx'));
bunCache = present && ('bunRuns' in deps ? Boolean(deps.bunRuns) : probeVersion('bunx').ran);
}
return bunCache;
};
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
// Same crash, no pnpm to fall back on: bunx is install-free and unaffected.
if (npmMajor !== null && npmMajor >= 11 && hasBun()) return 'bun';
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
if (npmMajor !== null && npmMajor < 11) return 'npx';
// npm absent or unreadable — use pnpm if present (with allow-build flags).
if (hasPnpm) return 'pnpm';
// Neither npm nor pnpm — bunx is the only install-free runner left. Without
// this rung a bun-only machine fell through to `npx`, which is not installed
// there, so the emitted command failed with "npx: command not found".
if (hasBun()) return 'bun';
return 'npx';
}
function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
const flags = formatPnpmAllowBuildArgs(options, deps).join(' ');
const prefix = flags ? `${flags} ` : '';
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
}
/**
* bun's install-free one-shot runner. Deliberately flag-free: bun has no
* per-invocation `--allow-build` equivalent (`--trust` is a `bun add`/`bun
* install` flag that writes trustedDependencies into a project package.json,
* which a one-shot `bunx` has nowhere to put), so the skipped lifecycle copy is
* recovered by the native loader instead of by the invocation.
*/
function formatBunxCommand(gitnexusArgs) {
return `bunx ${NPX_REF} ${gitnexusArgs}`;
}
function formatAnalyzeCommand(options = {}, deps = {}) {
const suffix = options.embeddings ? ' --embeddings' : '';
// Keep the stale-index hook budget tight by querying each tool at most once.
// The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with
// resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess
// is spawned for presence. pnpm's *version* is still captured by a single
// `pnpm --version` (the allow-build gate needs the number), which also proves
// presence; the memoized scan only re-checks pnpm when that version is
// unreadable. Injected deps (tests) and forced/global modes skip the pnpm probe.
const cache = new Map();
const probe = (command, gitnexusWrapper) => {
const key = `${command}:${gitnexusWrapper ? 1 : 0}`;
if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper));
return cache.get(key);
};
let resolved = deps;
if (!('pnpmMajor' in deps)) {
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
// pnpm is only consulted when no non-pnpm mode is already certain: forced
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
const mightUsePnpm =
forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx' && forced !== 'bun');
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
const { major, minor } = probeVersion('pnpm');
// Carry presence separately from version: when the version probe fails
// (timeout, Corepack banner) but pnpm is on PATH, still treat it as
// present so mode resolution picks pnpm over the npx crash path. The
// PATH probe is memoized and only runs when the version is unreadable.
const pnpmPresent = major !== null || Boolean(probe('pnpm'));
resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent };
}
}
const mode = resolveInvocationMode(probe, resolved);
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
if (mode === 'bun') return formatBunxCommand(`analyze${suffix}`);
return `npx ${NPX_REF} analyze${suffix}`;
}
/**
* Resolve `mode` into a concrete { program, args } pair for a set of gitnexus
* subcommand arguments. Shared by the direct-exec entrypoint below; pure (no
* spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set.
*/
function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
// Match both the space form (`--embeddings`) and the equals form
// (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still
// widens to onnxruntime-node when a user hand-types the equals form.
const embeddings = gitnexusArgs.some(
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
);
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
if (mode === 'bun') return { program: 'bunx', args: [NPX_REF, ...gitnexusArgs] };
if (mode === 'pnpm') {
return {
program: 'pnpm',
args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs],
};
}
return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] };
}
module.exports = {
formatAnalyzeCommand,
formatBunxCommand,
formatDocumentationDlxCommand,
formatPnpmAllowBuildArgs,
formatPnpmDlxCommand,
resolveInvocationMode,
buildRunnerArgv,
resolveOnPath,
getNpmMajorVersion,
NPX_REF,
PNPM_ALLOW_BUILD_BASE,
};
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
// best available runner (global `gitnexus` → `pnpm dlx` → `bunx` → `npx`) at call time and
// runs it, inheriting stdio and propagating the child's exit code. This lets the
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
// analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require()
// (the CLI and tests reuse the exports above), so it runs only when invoked as a
// script.
if (require.main === module) {
const gitnexusArgs = process.argv.slice(2);
const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs);
try {
execFileSync(program, args, {
stdio: 'inherit',
windowsHide: true,
// On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe`
// shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT
// resolution and Node refuses to spawn `.cmd`/`.bat` without a shell
// (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS
// resolve the shim; POSIX needs no shell (direct PATH lookup works).
shell: process.platform === 'win32',
});
} catch (err) {
// Make spawn failures (resolved program absent from PATH) self-explanatory
// instead of a silent exit 1, then propagate the runner's own exit code.
if (typeof err.status !== 'number') {
process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`);
}
process.exit(typeof err.status === 'number' ? err.status : 1);
}
}