diff --git a/CHANGELOG.md b/CHANGELOG.md index 57adbe59f..9cff5ed7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- New `codegraph upgrade` command updates CodeGraph to the latest release in place — it detects how you installed (the standalone `install.sh` / `install.ps1` bundle, npm, or npx) and does the right thing for each, on macOS, Linux, and Windows. Use `codegraph upgrade --check` to see whether an update is available without installing, or `codegraph upgrade ` to move to a specific version. After upgrading it reminds you to re-index your projects so they pick up the newer engine's improvements. (#679) +- `codegraph status` now flags when a project's index was built by an older engine than the one you're running and recommends re-indexing (also surfaced in `codegraph status --json`), so you know when a `codegraph index -f` or `codegraph sync` will add coverage a newer release introduced. - Cross-file impact and blast-radius coverage now spans **all 22 supported languages and 14 web frameworks**, each validated on a real-world repo — see the new coverage table in the README. This release ships the cross-file resolution behind it, including Lua and Luau `require`, Shopify OS 2.0 Liquid section templates, Delphi form code-behind, Rust cross-module calls and Rocket route macros, Swift Fluent relationships, and the SvelteKit / Nuxt / Vapor / Axum route conventions. The residual everywhere is genuine static-analysis frontiers (runtime dispatch, reflection / DI, framework-convention entry points), never hidden. - C# types are now tracked by their namespace-qualified name. Same-named types in different namespaces — a domain entity and a DTO both called `CatalogBrand`, say — are told apart instead of collapsing into one arbitrary match, so a reference resolves to the right one and impact no longer conflates them. (C#) - ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class; and the C# inside `@code { }` / `@functions { }` / `@{ }` blocks is analyzed too, so services and types used in component logic are linked. A view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor) diff --git a/README.md b/README.md index c5bb20f84..7c7b84a9e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ npm i -g @colbymchenry/codegraph CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The installer puts `codegraph` on your PATH but **doesn't change your current shell** — open a new terminal before the next step so the command resolves. +**Upgrade any time** with `codegraph upgrade` — it detects how you installed (bundle, npm, or npx) and updates in place. Add `--check` to see if an update is available, or `codegraph upgrade ` to pin one. + ### 2. Wire up your agent(s) In a **new terminal**, run the installer to connect CodeGraph to the agents you use: @@ -465,6 +467,7 @@ codegraph callees # Find what a function/method calls (--limit, codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) codegraph affected [files...] # Find test files affected by changes (see below) codegraph serve --mcp # Start MCP server +codegraph upgrade [version] # Update to the latest release (--check, --force) ``` ### `codegraph affected` diff --git a/__tests__/upgrade.test.ts b/__tests__/upgrade.test.ts new file mode 100644 index 000000000..ceef23280 --- /dev/null +++ b/__tests__/upgrade.test.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + detectInstallMethod, + deriveInstallDir, + parseSemver, + compareVersions, + isUpdateAvailable, + normalizeVersion, + stripV, + parseLatestTagFromLocation, + reindexAdvisory, + runUpgrade, + buildWindowsUpgradeScript, + NPM_PACKAGE, + type InstallMethod, + type UpgradeDeps, +} from '../src/upgrade'; +import { EXTRACTION_VERSION } from '../src/extraction/extraction-version'; +import { CodeGraph } from '../src'; + +// --------------------------------------------------------------------------- +// detectInstallMethod — structural detection from the running file's path +// --------------------------------------------------------------------------- + +describe('detectInstallMethod', () => { + // A bundle exists if a vendored node + launcher sit next to lib/. + function bundleExists(present: Set) { + return (p: string) => present.has(p.replace(/\\/g, '/')); + } + + it('detects a unix bundle and derives the install dir from the versions/ layout', () => { + const root = '/home/u/.codegraph/versions/v0.9.9'; + const filename = `${root}/lib/dist/bin/codegraph.js`; + const present = new Set([`${root}/node`, `${root}/bin/codegraph`, '/home/u/.codegraph']); + const m = detectInstallMethod({ + filename, + platform: 'linux', + cwd: '/home/u/project', + exists: bundleExists(present), + }); + expect(m).toEqual({ + kind: 'bundle', + os: 'unix', + bundleRoot: root, + installDir: '/home/u/.codegraph', + }); + }); + + it('detects a windows bundle and derives the install dir from current\\', () => { + const root = 'C:/Users/u/AppData/Local/codegraph/current'; + const filename = `${root}/lib/dist/bin/codegraph.js`; + const present = new Set([`${root}/node.exe`, `${root}/bin/codegraph.cmd`]); + const m = detectInstallMethod({ + filename, + platform: 'win32', + cwd: 'C:/Users/u/project', + exists: bundleExists(present), + }) as Extract; + expect(m.kind).toBe('bundle'); + expect(m.os).toBe('windows'); + // win32 path math emits backslashes; compare separator-independently. + expect(m.installDir?.replace(/\\/g, '/')).toBe('C:/Users/u/AppData/Local/codegraph'); + }); + + it('detects a global npm install', () => { + const filename = '/usr/local/lib/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js'; + const m = detectInstallMethod({ + filename, + platform: 'linux', + cwd: '/home/u/project', + exists: () => false, + }); + expect(m).toEqual({ kind: 'npm', scope: 'global' }); + }); + + it('detects a local (project) npm install as local', () => { + const cwd = '/home/u/project'; + const filename = `${cwd}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`; + const m = detectInstallMethod({ filename, platform: 'linux', cwd, exists: () => false }); + expect(m).toEqual({ kind: 'npm', scope: 'local' }); + }); + + it('detects an npx run from the _npx cache', () => { + const filename = '/home/u/.npm/_npx/abc123/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js'; + const m = detectInstallMethod({ filename, platform: 'linux', cwd: '/home/u', exists: () => false }); + expect(m).toEqual({ kind: 'npx' }); + }); + + it('detects a source checkout via sibling package.json + .git', () => { + const repo = '/home/u/dev/codegraph'; + const filename = `${repo}/dist/bin/codegraph.js`; + const present = new Set([`${repo}/package.json`, `${repo}/.git`]); + const m = detectInstallMethod({ + filename, + platform: 'darwin', + cwd: repo, + exists: bundleExists(present), + }); + expect(m).toEqual({ kind: 'source', root: repo }); + }); + + it('returns unknown for an unrecognized layout', () => { + const m = detectInstallMethod({ + filename: '/opt/weird/place/codegraph.js', + platform: 'linux', + cwd: '/tmp', + exists: () => false, + }); + expect(m.kind).toBe('unknown'); + }); +}); + +describe('deriveInstallDir', () => { + it('unix: returns the dir above versions/', () => { + expect(deriveInstallDir('/a/b/.codegraph/versions/v1.2.3', 'unix', () => true)).toBe('/a/b/.codegraph'); + }); + it('unix: null when not under versions/', () => { + expect(deriveInstallDir('/a/b/somewhere', 'unix', () => true)).toBeNull(); + }); + it('windows: returns the parent of current\\', () => { + expect(deriveInstallDir('C:/x/codegraph/current', 'windows', () => true)?.replace(/\\/g, '/')).toBe('C:/x/codegraph'); + }); + it('windows: null when basename is not current', () => { + expect(deriveInstallDir('C:/x/codegraph/v1', 'windows', () => true)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// version helpers +// --------------------------------------------------------------------------- + +describe('version helpers', () => { + it('parseSemver handles v-prefix and prerelease', () => { + expect(parseSemver('v1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, pre: null }); + expect(parseSemver('1.2.3-rc.1')).toEqual({ major: 1, minor: 2, patch: 3, pre: 'rc.1' }); + expect(parseSemver('not-a-version')).toBeNull(); + }); + + it('compareVersions orders correctly incl. prerelease < release', () => { + expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0); + expect(compareVersions('1.0.0', '1.1.0')).toBeLessThan(0); + expect(compareVersions('v2.0.0', '2.0.0')).toBe(0); + expect(compareVersions('1.0.0-rc.1', '1.0.0')).toBeLessThan(0); + }); + + it('isUpdateAvailable compares, and falls back to string-inequality for unparseable', () => { + expect(isUpdateAvailable('0.9.8', '0.9.9')).toBe(true); + expect(isUpdateAvailable('0.9.9', '0.9.9')).toBe(false); + expect(isUpdateAvailable('0.9.9', '0.9.8')).toBe(false); + // dev sentinel can't parse → any difference means "update available" + expect(isUpdateAvailable('0.0.0-unknown', '0.9.9')).toBe(true); + }); + + it('normalizeVersion / stripV round-trip', () => { + expect(normalizeVersion('0.9.9')).toBe('v0.9.9'); + expect(normalizeVersion('v0.9.9')).toBe('v0.9.9'); + expect(stripV('v0.9.9')).toBe('0.9.9'); + expect(stripV('0.9.9')).toBe('0.9.9'); + }); + + it('parseLatestTagFromLocation extracts the tag from a releases redirect', () => { + expect(parseLatestTagFromLocation('https://github.com/colbymchenry/codegraph/releases/tag/v0.9.9')).toBe('v0.9.9'); + expect(parseLatestTagFromLocation('https://github.com/o/r/releases/tag/v1.2.3?foo=bar')).toBe('v1.2.3'); + expect(parseLatestTagFromLocation(undefined)).toBeNull(); + expect(parseLatestTagFromLocation('https://github.com/o/r/releases')).toBeNull(); + }); + + it('reindexAdvisory mentions the refresh commands', () => { + const a = reindexAdvisory(); + expect(a).toContain('codegraph sync'); + expect(a).toContain('codegraph index -f'); + }); + + it('buildWindowsUpgradeScript targets the right asset per arch and renames-not-deletes the exe', () => { + const arm = buildWindowsUpgradeScript('C:\\cg\\current', 'v1.2.3', 'arm64'); + expect(arm).toContain('releases/download/v1.2.3/codegraph-win32-arm64.zip'); + expect(arm).toContain("$dest='C:\\cg\\current'"); + expect(arm).toContain('Rename-Item'); // never Remove-Item on the locked exe + expect(arm).not.toMatch(/Remove-Item[^;]*\$dest'?\s*;/); // doesn't delete current\ + const x64 = buildWindowsUpgradeScript('C:\\cg\\current', 'v1.2.3', 'x64'); + expect(x64).toContain('codegraph-win32-x64.zip'); + }); +}); + +// --------------------------------------------------------------------------- +// runUpgrade orchestration — mocked side-effects +// --------------------------------------------------------------------------- + +interface Calls { + runs: Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv }>; + logs: string[]; + errors: string[]; +} + +function makeDeps( + overrides: Partial & { method: InstallMethod; currentVersion: string }, + runExit = 0 +): { deps: UpgradeDeps; calls: Calls } { + const calls: Calls = { runs: [], logs: [], errors: [] }; + const deps: UpgradeDeps = { + currentVersion: overrides.currentVersion, + method: overrides.method, + resolveLatest: overrides.resolveLatest ?? (async () => 'v0.9.9'), + run: (cmd, args, env) => { + calls.runs.push({ cmd, args, env }); + return runExit; + }, + hasCommand: overrides.hasCommand ?? ((c) => c === 'curl'), + log: (m) => calls.logs.push(m), + warn: (m) => calls.logs.push(m), + error: (m) => calls.errors.push(m), + platform: overrides.platform ?? 'linux', + }; + return { deps, calls }; +} + +/** Decode a `-EncodedCommand` base64 (UTF-16LE) payload back to its script. */ +function decodeEncodedCommand(args: string[]): string { + const i = args.indexOf('-EncodedCommand'); + if (i < 0) throw new Error('no -EncodedCommand in args'); + return Buffer.from(args[i + 1]!, 'base64').toString('utf16le'); +} + +describe('runUpgrade', () => { + it('does nothing when already up to date', async () => { + const { deps, calls } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.9' }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs).toHaveLength(0); + expect(calls.logs.join('\n')).toMatch(/up to date/i); + }); + + it('--check reports an available update without running anything', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + }); + const code = await runUpgrade({ check: true }, deps); + expect(code).toBe(0); + expect(calls.runs).toHaveLength(0); + expect(calls.logs.join('\n')).toMatch(/update is available/i); + }); + + it('unix bundle: runs the installer via sh with the derived install dir', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'bundle', os: 'unix', bundleRoot: '/h/.codegraph/versions/v0.9.8', installDir: '/h/.codegraph' }, + currentVersion: '0.9.8', + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs).toHaveLength(1); + expect(calls.runs[0].cmd).toBe('sh'); + expect(calls.runs[0].args[0]).toBe('-c'); + expect(calls.runs[0].args[1]).toContain('curl -fsSL'); + expect(calls.runs[0].args[1]).toContain('| sh'); + expect(calls.runs[0].env?.CODEGRAPH_INSTALL_DIR).toBe('/h/.codegraph'); + expect(calls.logs.join('\n')).toMatch(/codegraph sync/); // re-index advisory printed + }); + + it('unix bundle: falls back to wget, and errors when neither downloader exists', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'bundle', os: 'unix', bundleRoot: '/h/.codegraph/versions/v0.9.8', installDir: null }, + currentVersion: '0.9.8', + hasCommand: () => false, + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(1); + expect(calls.runs).toHaveLength(0); + expect(calls.errors.join('\n')).toMatch(/curl nor wget/i); + }); + + it('windows bundle: runs a synchronous in-place (rename + extract) powershell upgrade', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'bundle', os: 'windows', bundleRoot: 'C:/x/codegraph/current', installDir: 'C:/x/codegraph' }, + currentVersion: '0.9.8', + platform: 'win32', + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs).toHaveLength(1); + expect(calls.runs[0].cmd).toBe('powershell.exe'); + const decoded = decodeEncodedCommand(calls.runs[0].args); + // Downloads the right asset, renames the locked exe aside, copies over current\. + expect(decoded).toContain('releases/download/v0.9.9/codegraph-win32-'); + expect(decoded).toContain('Rename-Item'); + expect(decoded).toContain('node.exe.old-'); + expect(decoded).toContain('Copy-Item'); + }); + + it('windows bundle: a non-zero installer exit is a failure', async () => { + const { deps, calls } = makeDeps( + { + method: { kind: 'bundle', os: 'windows', bundleRoot: 'C:/x/codegraph/current', installDir: 'C:/x/codegraph' }, + currentVersion: '0.9.8', + platform: 'win32', + }, + 1 + ); + const code = await runUpgrade({}, deps); + expect(code).toBe(1); + expect(calls.errors.join('\n')).toMatch(/exited with code/i); + }); + + it('npm global: shells out to npm install -g @pkg@latest', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs[0].cmd).toBe('npm'); + expect(calls.runs[0].args).toEqual(['install', '-g', `${NPM_PACKAGE}@latest`]); + }); + + it('npm on win32 uses npm.cmd', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + platform: 'win32', + }); + await runUpgrade({}, deps); + expect(calls.runs[0].cmd).toBe('npm.cmd'); + }); + + it('npm: a pinned version is passed through as @', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.9', + }); + await runUpgrade({ version: '0.9.8' }, deps); + // npm spec carries no leading "v". + expect(calls.runs[0].args).toEqual(['install', '-g', `${NPM_PACKAGE}@0.9.8`]); + }); + + it('npm: surfaces a non-zero exit as failure', async () => { + const { deps, calls } = makeDeps( + { method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' }, + 1 + ); + const code = await runUpgrade({}, deps); + expect(code).toBe(1); + expect(calls.errors.join('\n')).toMatch(/npm exited/i); + }); + + it('npx: nothing to upgrade', async () => { + const { deps, calls } = makeDeps({ method: { kind: 'npx' }, currentVersion: '0.9.8' }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs).toHaveLength(0); + expect(calls.logs.join('\n')).toMatch(/nothing to upgrade/i); + }); + + it('source: tells the user to git pull, runs nothing', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'source', root: '/dev/codegraph' }, + currentVersion: '0.9.8', + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs).toHaveLength(0); + expect(calls.logs.join('\n')).toMatch(/git pull/); + }); +}); + +// --------------------------------------------------------------------------- +// Re-index staleness — real index, real metadata stamp +// --------------------------------------------------------------------------- + +describe('index extraction-version stamp / isIndexStale', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-upgrade-stamp-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('stamps the current extraction version on full index and is not stale', async () => { + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function hello() { return 1; }\n'); + const cg = await CodeGraph.init(dir, { index: false }); + // No index yet → not stale (nothing to refresh). + expect(cg.isIndexStale()).toBe(false); + + await cg.indexAll(); + const info = cg.getIndexBuildInfo(); + expect(info.extractionVersion).toBe(EXTRACTION_VERSION); + expect(typeof info.version).toBe('string'); + expect(cg.isIndexStale()).toBe(false); + cg.destroy(); + }); + + it('flags an index stamped by an older extraction version as stale', async () => { + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function hello() { return 1; }\n'); + const cg = await CodeGraph.init(dir, { index: false }); + await cg.indexAll(); + + // Simulate an index built by an older engine. + (cg as unknown as { queries: { setMetadata(k: string, v: string): void } }).queries.setMetadata( + 'indexed_with_extraction_version', + String(EXTRACTION_VERSION - 1) + ); + expect(cg.isIndexStale()).toBe(true); + cg.destroy(); + }); +}); diff --git a/install.ps1 b/install.ps1 index d12fb98a6..564a747e1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,8 +5,8 @@ # # irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex # -# Re-run to upgrade. To uninstall: remove $env:LOCALAPPDATA\codegraph and drop -# its \current\bin entry from your user PATH. +# Upgrade with `codegraph upgrade` (or just re-run this). To uninstall: remove +# $env:LOCALAPPDATA\codegraph and drop its \current\bin entry from your user PATH. # # Environment: # CODEGRAPH_VERSION release tag to install (default: latest) diff --git a/install.sh b/install.sh index b4004fb1b..3e903db81 100755 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ # # curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh # -# Upgrade: re-run the same command. +# Upgrade: run `codegraph upgrade` (or just re-run the same command). # Uninstall: curl -fsSL .../install.sh | sh -s -- --uninstall # # Environment: diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 0acc70097..bd667738b 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -20,6 +20,7 @@ * codegraph callees Find what a function/method calls * codegraph impact Analyze what code is affected by changing a symbol * codegraph affected [files] Find test files affected by changes + * codegraph upgrade [version] Update CodeGraph to the latest release */ import { Command } from 'commander'; @@ -32,6 +33,7 @@ import { getGlyphs } from '../ui/glyphs'; import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check'; import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags'; +import { EXTRACTION_VERSION } from '../extraction/extraction-version'; // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast. async function loadCodeGraph(): Promise { @@ -699,6 +701,9 @@ program const backend = cg.getBackend(); const journalMode = cg.getJournalMode(); + const buildInfo = cg.getIndexBuildInfo(); + const reindexRecommended = cg.isIndexStale(); + // JSON output mode if (options.json) { const lastIndexedMs = cg.getLastIndexedAt(); @@ -724,6 +729,12 @@ program worktreeMismatch: worktreeMismatch ? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot } : null, + index: { + builtWithVersion: buildInfo.version, + builtWithExtractionVersion: buildInfo.extractionVersion, + currentExtractionVersion: EXTRACTION_VERSION, + reindexRecommended, + }, })); cg.destroy(); return; @@ -797,6 +808,15 @@ program } console.log(); + // Re-index hint: the index was built by an older engine than the one now + // running, so a rebuild would add data a migration can't backfill. + if (reindexRecommended) { + const builtWith = buildInfo.version ? `v${buildInfo.version.replace(/^v/, '')}` : 'an earlier version'; + warn(`Index was built by ${builtWith}; re-index to pick up this engine's improvements.`); + info('Run "codegraph index -f" (full rebuild) or "codegraph sync"'); + console.log(); + } + cg.destroy(); } catch (err) { error(`Failed to get status: ${err instanceof Error ? err.message : String(err)}`); @@ -1664,6 +1684,43 @@ program } }); +/** + * codegraph upgrade [version] + * + * Self-update, however CodeGraph was installed (bundle via install.sh/.ps1, + * npm-global, npx, or a source checkout). See ../upgrade for the detection and + * per-method upgrade logic. + */ +program + .command('upgrade [version]') + .description('Update CodeGraph to the latest release (or a specific version)') + .option('--check', 'Check whether an update is available without installing') + .option('-f, --force', 'Reinstall even if already on the target version') + .action(async (versionArg: string | undefined, options: { check?: boolean; force?: boolean }) => { + const up = await import('../upgrade'); + const method = up.detectInstallMethod({ + filename: __filename, + platform: process.platform, + cwd: process.cwd(), + }); + const pin = versionArg || process.env.CODEGRAPH_VERSION || undefined; + const code = await up.runUpgrade( + { version: pin, check: options.check, force: options.force }, + { + currentVersion: packageJson.version, + method, + resolveLatest: () => up.resolveLatestVersion(), + run: up.defaultRun, + hasCommand: up.hasCommand, + log: (m: string) => console.log(m), + warn: (m: string) => warn(m), + error: (m: string) => error(m), + platform: process.platform, + } + ); + process.exit(code); + }); + // Parse and run program.parse(); diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts new file mode 100644 index 000000000..5ca8f2d24 --- /dev/null +++ b/src/extraction/extraction-version.ts @@ -0,0 +1,24 @@ +/** + * Extraction version + * + * A monotonically-increasing integer that identifies the *shape and depth* of + * what the extractor writes into the graph. Unlike `CURRENT_SCHEMA_VERSION` + * (which tracks the SQLite table layout and is migrated in place), this tracks + * the EXTRACTED CONTENT — node kinds, edges, synthesizers, resolver coverage. + * + * When an index was built by an older engine whose `EXTRACTION_VERSION` is + * below the running engine's, the data on disk is structurally fine but + * *stale*: it's missing whatever a newer extractor would now produce. A schema + * migration can't backfill that — only a re-index can. So this is the signal + * `codegraph status` uses to recommend a re-index, and the reason `codegraph + * upgrade` reminds users to refresh their projects. + * + * BUMP THIS when a release changes extraction output enough that existing + * indexes should be rebuilt to benefit — e.g. a new language/framework + * extractor, a new dynamic-dispatch synthesizer, a new node/edge kind, or a + * resolver fix that materially changes which edges exist. Do NOT bump for + * pure bug fixes, CLI/UX changes, or schema-only migrations. Over-bumping + * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty + * in the product is load-bearing"). + */ +export const EXTRACTION_VERSION = 1; diff --git a/src/index.ts b/src/index.ts index fc8b3dedf..55ef12e64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,8 @@ import { GraphTraverser, GraphQueryManager } from './graph'; import { ContextBuilder, createContextBuilder } from './context'; import { Mutex, FileLock } from './utils'; import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync'; +import { EXTRACTION_VERSION } from './extraction/extraction-version'; +import { CodeGraphPackageVersion } from './mcp/version'; // Re-export types for consumers export * from './types'; @@ -382,6 +384,18 @@ export class CodeGraph { result.edgesCreated = after.edges - before.edges; } + // Stamp the index with the engine that built it, so `codegraph status` + // and `codegraph upgrade` can recommend a re-index when the running + // engine produces richer extraction than the one on disk. Only on a + // real full index — a sync touches a subset, so it must NOT advance the + // extraction stamp (the bulk would still be stale). See extraction-version.ts. + if (result.success && result.filesIndexed > 0) { + try { + this.queries.setMetadata('indexed_with_version', CodeGraphPackageVersion); + this.queries.setMetadata('indexed_with_extraction_version', String(EXTRACTION_VERSION)); + } catch { /* metadata is advisory — never fail an index over it */ } + } + return result; } finally { this.fileLock.release(); @@ -585,6 +599,32 @@ export class CodeGraph { return this.queries.getLastIndexedAt(); } + /** + * Which engine built the current index: the package version + extraction + * version stamped at the last full `indexAll`. Either field is null for an + * index built before stamping existed (treated as stale). See + * `extraction-version.ts` and `isIndexStale()`. + */ + getIndexBuildInfo(): { version: string | null; extractionVersion: number | null } { + const version = this.queries.getMetadata('indexed_with_version'); + const ev = this.queries.getMetadata('indexed_with_extraction_version'); + const parsed = ev != null ? parseInt(ev, 10) : NaN; + return { version, extractionVersion: Number.isFinite(parsed) ? parsed : null }; + } + + /** + * True when the on-disk index was built by an engine whose extraction is + * older than the one now running — i.e. a re-index would add data a migration + * can't backfill. False when there's no index yet (nothing to refresh) or the + * stamp is current. This is the signal behind `codegraph status`'s re-index + * hint and `codegraph upgrade`'s reminder. + */ + isIndexStale(): boolean { + if (this.queries.getLastIndexedAt() == null) return false; + const { extractionVersion } = this.getIndexBuildInfo(); + return extractionVersion == null || extractionVersion < EXTRACTION_VERSION; + } + /** * Extract nodes and edges from source code (without storing) */ diff --git a/src/upgrade/index.ts b/src/upgrade/index.ts new file mode 100644 index 000000000..bf9d5c303 --- /dev/null +++ b/src/upgrade/index.ts @@ -0,0 +1,515 @@ +/** + * `codegraph upgrade` + * + * Self-update for the CLI, whatever way it was installed: + * + * - **bundle** — the self-contained runtime+app installed by `install.sh` + * (Linux/macOS) or `install.ps1` (Windows). Upgrading re-runs the SAME + * canonical installer script (single source of truth) so the download / + * version-resolution / PATH logic never drifts between first-install and + * upgrade. + * - **npm** — installed via `npm i -g @colbymchenry/codegraph`. Upgrading + * shells out to npm. + * - **npx** — ephemeral; nothing to upgrade (next `npx` fetches latest). + * - **source** — a git checkout running its own `dist/`; `git pull` + rebuild. + * + * Detection is structural (see `detectInstallMethod`): a bundle carries a + * vendored `node` binary and a `bin/codegraph` launcher next to its `lib/`, so + * we can recognize it from the running file's path without a marker file. + * + * Windows wrinkle: a running `node.exe` is locked and can't be deleted, so the + * bundle's `current\` dir can't be overwritten in place by the process doing + * the upgrade. We therefore spawn a DETACHED helper that waits for this + * process to exit (releasing the lock), then runs `install.ps1`. This is the + * conventional Windows self-update dance (rustup/nvm-windows do the same). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; +import { spawnSync } from 'child_process'; + +export const REPO = 'colbymchenry/codegraph'; +export const NPM_PACKAGE = '@colbymchenry/codegraph'; +const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`; +export const INSTALL_SH_URL = `${RAW_BASE}/install.sh`; + +// --------------------------------------------------------------------------- +// Install-method detection (pure — fully unit-testable via injected probes) +// --------------------------------------------------------------------------- + +export type InstallMethod = + | { kind: 'bundle'; os: 'unix' | 'windows'; bundleRoot: string; installDir: string | null } + | { kind: 'npm'; scope: 'global' | 'local' } + | { kind: 'npx' } + | { kind: 'source'; root: string } + | { kind: 'unknown'; reason: string }; + +export interface DetectInput { + /** `__filename` of the running CLI module — `<…>/dist/bin/codegraph.js`. */ + filename: string; + platform: NodeJS.Platform; + cwd: string; + /** Injectable existence probe (defaults to fs.existsSync) — for tests. */ + exists?: (p: string) => boolean; +} + +function toPosix(p: string): string { + return p.replace(/\\/g, '/'); +} + +/** + * Where the bundle installer keeps its install root, derived from the bundle + * dir so an upgrade reuses a custom `CODEGRAPH_INSTALL_DIR`. Returns null when + * the layout isn't the one the installer creates (then the installer falls + * back to its own default). + * + * unix: /versions/ (bundleRoot) → + * windows: \current (bundleRoot) → + */ +export function deriveInstallDir( + bundleRoot: string, + os: 'unix' | 'windows', + exists: (p: string) => boolean +): string | null { + // Use the TARGET platform's path semantics (not the host's), so this is + // deterministic when reasoning about a Windows layout from a POSIX host (CI) + // and vice-versa. In production `os` always matches the running platform. + const P = os === 'windows' ? path.win32 : path.posix; + if (os === 'windows') { + if (P.basename(bundleRoot).toLowerCase() === 'current') { + return P.dirname(bundleRoot); + } + return null; + } + // unix: bundleRoot is /versions/ + const parent = P.dirname(bundleRoot); + if (P.basename(parent) === 'versions') { + const installDir = P.dirname(parent); + return exists(installDir) ? installDir : P.dirname(parent); + } + return null; +} + +export function detectInstallMethod(input: DetectInput): InstallMethod { + const exists = input.exists ?? fs.existsSync; + const isWin = input.platform === 'win32'; + // Path math keyed on the TARGET platform so detection is host-independent + // (a Windows layout resolves correctly even when unit-tested on macOS/Linux). + const P = isWin ? path.win32 : path.posix; + const binDir = P.dirname(input.filename); // <…>/bin + + // Bundle: /lib/dist/bin/codegraph.js → is up 3 from bin/. + // A bundle has a vendored node + a launcher script as siblings of lib/. + const bundleRoot = P.resolve(binDir, '..', '..', '..'); + const vendoredNode = P.join(bundleRoot, isWin ? 'node.exe' : 'node'); + const launcher = P.join(bundleRoot, 'bin', isWin ? 'codegraph.cmd' : 'codegraph'); + if (exists(vendoredNode) && exists(launcher)) { + const os = isWin ? 'windows' : 'unix'; + return { kind: 'bundle', os, bundleRoot, installDir: deriveInstallDir(bundleRoot, os, exists) }; + } + + const norm = toPosix(input.filename); + + // npx cache: <…>/_npx//node_modules/@colbymchenry/codegraph/… + if (norm.includes('/_npx/')) { + return { kind: 'npx' }; + } + + // npm install (global or local): lives under a node_modules tree. + if (norm.includes('/node_modules/')) { + const underCwd = norm.startsWith(toPosix(P.resolve(input.cwd)) + '/'); + return { kind: 'npm', scope: underCwd ? 'local' : 'global' }; + } + + // Source checkout: running /dist/bin/codegraph.js with a sibling .git. + const repoRoot = P.resolve(binDir, '..', '..'); + if (exists(P.join(repoRoot, 'package.json')) && exists(P.join(repoRoot, '.git'))) { + return { kind: 'source', root: repoRoot }; + } + + return { kind: 'unknown', reason: `unrecognized install layout at ${input.filename}` }; +} + +// --------------------------------------------------------------------------- +// Version helpers (pure) +// --------------------------------------------------------------------------- + +export interface Semver { + major: number; + minor: number; + patch: number; + pre: string | null; +} + +export function parseSemver(version: string): Semver | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim()); + if (!m) return null; + return { + major: parseInt(m[1]!, 10), + minor: parseInt(m[2]!, 10), + patch: parseInt(m[3]!, 10), + pre: m[4] ?? null, + }; +} + +/** Returns >0 if a>b, <0 if a sb.pre ? 1 : 0; + return 0; +} + +export function isUpdateAvailable(current: string, latest: string): boolean { + try { + return compareVersions(latest, current) > 0; + } catch { + // If either is unparseable (e.g. a dev "0.0.0-unknown"), treat differing + // strings as "update available" so the user isn't stuck. + return normalizeVersion(current) !== normalizeVersion(latest); + } +} + +/** `0.9.9` / `v0.9.9` → `v0.9.9` (release tags are v-prefixed). */ +export function normalizeVersion(v: string): string { + const t = v.trim(); + return t.startsWith('v') ? t : `v${t}`; +} + +/** Strip a leading `v`: `v0.9.9` → `0.9.9`. */ +export function stripV(v: string): string { + const t = v.trim(); + return t.startsWith('v') ? t.slice(1) : t; +} + +/** + * Parse the release tag out of the `Location` header GitHub returns for + * `/releases/latest` → `…/releases/tag/v0.9.9`. Pure so it's unit-tested. + */ +export function parseLatestTagFromLocation(location: string | undefined): string | null { + if (!location) return null; + const m = /\/releases\/tag\/([^/?#]+)/.exec(location); + return m ? decodeURIComponent(m[1]!) : null; +} + +// --------------------------------------------------------------------------- +// Latest-version resolution (network) +// --------------------------------------------------------------------------- + +function httpsGet( + url: string, + headers: Record, + timeoutMs: number +): Promise<{ status: number; headers: Record; body: string }> { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers }, (res) => { + let body = ''; + res.on('data', (c) => (body += c)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body })); + }); + req.on('error', reject); + req.setTimeout(timeoutMs, () => req.destroy(new Error(`request timed out after ${timeoutMs}ms`))); + }); +} + +/** + * Resolve the latest release tag (e.g. `v0.9.9`). + * + * Primary: read the redirect `Location` from `github.com//releases/latest` + * — same trick install.sh uses, because the unauthenticated GitHub API is + * rate-limited to 60 req/h/IP and 403s on shared/cloud hosts (issue #325). The + * redirect has no such limit. Fall back to the API only if the redirect can't + * be read. + */ +export async function resolveLatestVersion(repo = REPO, timeoutMs = 12000): Promise { + try { + const res = await httpsGet( + `https://github.com/${repo}/releases/latest`, + { 'User-Agent': 'codegraph-upgrade' }, + timeoutMs + ); + const loc = res.headers.location; + const tag = parseLatestTagFromLocation(Array.isArray(loc) ? loc[0] : loc); + if (tag) return normalizeVersion(tag); + } catch { + /* fall through to API */ + } + try { + const res = await httpsGet( + `https://api.github.com/repos/${repo}/releases/latest`, + { 'User-Agent': 'codegraph-upgrade', Accept: 'application/vnd.github+json' }, + timeoutMs + ); + const tag = JSON.parse(res.body)?.tag_name; + if (typeof tag === 'string' && tag) return normalizeVersion(tag); + } catch { + /* fall through to error */ + } + throw new Error( + 'could not resolve the latest version from GitHub. Check your network, or pin a version: `codegraph upgrade `.' + ); +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +export interface UpgradeOptions { + /** Pin a specific version (positional arg or CODEGRAPH_VERSION). */ + version?: string; + /** Report current vs latest, don't change anything. */ + check?: boolean; + /** Reinstall even if already on the resolved version. */ + force?: boolean; +} + +/** Injectable side-effects so the orchestrator stays unit-testable. */ +export interface UpgradeDeps { + currentVersion: string; + method: InstallMethod; + resolveLatest: (pin?: string) => Promise; + /** Run a command inheriting stdio; returns its exit code (-1 = spawn failed). */ + run: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => number; + hasCommand: (cmd: string) => boolean; + log: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + platform: NodeJS.Platform; +} + +const c = { + bold: (s: string) => `\x1b[1m${s}\x1b[0m`, + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, +}; + +/** The honest, additive re-index reminder shown after a successful upgrade. */ +export function reindexAdvisory(): string { + return [ + c.dim('Your existing project indexes keep working, but were built by the previous version.'), + c.dim('To pick up this version’s extraction improvements, refresh each project:'), + ` ${c.cyan('codegraph sync')} ${c.dim('# incremental, fast')}`, + ` ${c.cyan('codegraph index -f')} ${c.dim('# full rebuild')}`, + c.dim('(`codegraph status` flags any index that predates the engine you’re running.)'), + ].join('\n'); +} + +/** + * Returns the process exit code (0 = success / nothing to do, 1 = failure). + */ +export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promise { + const { currentVersion, method } = deps; + + // Resolve the target version (pinned or latest). + let latest: string; + try { + latest = normalizeVersion(opts.version || (await deps.resolveLatest())); + } catch (err) { + deps.error(err instanceof Error ? err.message : String(err)); + return 1; + } + + const currentDisplay = normalizeVersion(currentVersion); + deps.log(`${c.bold('CodeGraph')} current ${c.cyan(currentDisplay)} ${opts.version ? 'target' : 'latest'} ${c.cyan(latest)}`); + + const updateAvailable = isUpdateAvailable(currentVersion, latest); + + if (opts.check) { + if (updateAvailable) { + deps.log(c.yellow(`An update is available: ${currentDisplay} → ${latest}`)); + deps.log(c.dim('Run `codegraph upgrade` to install it.')); + } else { + deps.log(c.green(`You’re on the latest version (${currentDisplay}).`)); + } + return 0; + } + + if (!updateAvailable && !opts.force && !opts.version) { + deps.log(c.green(`Already up to date (${currentDisplay}).`)); + deps.log(c.dim('Use `--force` to reinstall, or `codegraph upgrade ` to change versions.')); + return 0; + } + + // Dispatch by install method. + switch (method.kind) { + case 'bundle': + return method.os === 'windows' + ? upgradeWindowsBundle(method, latest, deps) + : upgradeUnixBundle(method, opts.version ? latest : undefined, deps); + case 'npm': + // npm version specs have no leading "v" (`@0.9.8`, not `@v0.9.8` — the + // latter resolves as a nonexistent dist-tag). + return upgradeNpm(method, opts.version ? stripV(latest) : 'latest', deps); + case 'npx': + deps.log(c.green('npx always runs the latest version on demand — nothing to upgrade.')); + deps.log(c.dim(`Force a fresh fetch with: npx ${NPM_PACKAGE}@latest`)); + return 0; + case 'source': + deps.warn(`Running from a source checkout at ${method.root}.`); + deps.log(c.dim('Upgrade it with: git pull && npm run build')); + return 0; + default: + deps.error(`Couldn’t determine how CodeGraph was installed (${method.reason}).`); + deps.log(c.dim(`Reinstall manually — see https://github.com/${REPO}#install`)); + return 1; + } +} + +function upgradeUnixBundle( + method: Extract, + pinned: string | undefined, + deps: UpgradeDeps +): number { + const downloader = deps.hasCommand('curl') + ? `curl -fsSL ${INSTALL_SH_URL}` + : deps.hasCommand('wget') + ? `wget -qO- ${INSTALL_SH_URL}` + : null; + if (!downloader) { + deps.error('Neither curl nor wget is available to download the installer.'); + deps.log(c.dim(`Install curl, or run manually: ${INSTALL_SH_URL} | sh`)); + return 1; + } + + const env: NodeJS.ProcessEnv = { ...process.env }; + if (method.installDir) env.CODEGRAPH_INSTALL_DIR = method.installDir; + if (pinned) env.CODEGRAPH_VERSION = pinned; + + deps.log(c.dim(`Running the installer (${downloader} | sh)…`)); + const code = deps.run('sh', ['-c', `${downloader} | sh`], env); + if (code !== 0) { + deps.error(`Installer exited with code ${code}.`); + return 1; + } + deps.log(''); + deps.log(c.green('✓ Upgrade complete.') + c.dim(' Open a new terminal if the version looks unchanged (PATH cache).')); + deps.log(reindexAdvisory()); + return 0; +} + +/** Build the in-place Windows upgrade script (exported for unit-testing). */ +export function buildWindowsUpgradeScript(bundleRoot: string, version: string, arch: string): string { + const target = `win32-${arch}`; + const url = `https://github.com/${REPO}/releases/download/${version}/codegraph-${target}.zip`; + // Windows can't DELETE a running exe but CAN rename it, so we upgrade IN + // PLACE: download → rename the locked node.exe aside → extract the new bundle + // over current\. Synchronous, no detached helper (which dies under SSH/job + // objects and has worse UX). The running process keeps its renamed node.exe + // mapped; the NEXT `codegraph` invocation uses the new one. We can't reuse + // install.ps1 here — it `Remove-Item`s current\, which fails on the locked exe. + return [ + `$ErrorActionPreference='Stop'`, + `$dest='${bundleRoot}'`, + `$url='${url}'`, + `Write-Host "Downloading $url"`, + `$tmp=Join-Path $env:TEMP ('cg-up-'+[guid]::NewGuid().ToString('N'))`, + `New-Item -ItemType Directory -Force -Path $tmp | Out-Null`, + `$zip=Join-Path $tmp 'cg.zip'`, + `Invoke-WebRequest -Uri $url -OutFile $zip`, + `$stage=Join-Path $tmp 'stage'`, + `Expand-Archive -Path $zip -DestinationPath $stage -Force`, + `$inner=Join-Path $stage 'codegraph-${target}'`, + `$src=if(Test-Path $inner){$inner}else{$stage}`, + `$node=Join-Path $dest 'node.exe'`, + `if(Test-Path $node){Rename-Item -Path $node -NewName ('node.exe.old-'+[guid]::NewGuid().ToString('N')) -Force}`, + `Copy-Item -Path (Join-Path $src '*') -Destination $dest -Recurse -Force`, + `Get-ChildItem -Path $dest -Filter 'node.exe.old-*' -ErrorAction SilentlyContinue | ForEach-Object { try { Remove-Item $_.FullName -Force -ErrorAction Stop } catch {} }`, + `Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue`, + `Write-Host "Installed CodeGraph ${version} to $dest"`, + ].join(';'); +} + +function upgradeWindowsBundle( + method: Extract, + latest: string, + deps: UpgradeDeps +): number { + const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; + const script = buildWindowsUpgradeScript(method.bundleRoot, latest, arch); + // -EncodedCommand (base64 UTF-16LE), NOT -Command: Node's Windows argv→command + // -line quoting mangles a long multi-statement script, so PowerShell never + // parses it. Encoding sidesteps all shell quoting — the canonical approach. + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + deps.log(c.dim(`Downloading and installing ${latest}…`)); + const code = deps.run('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded]); + if (code !== 0) { + deps.error(`Installer exited with code ${code}.`); + return 1; + } + deps.log(''); + deps.log(c.green('✓ Upgrade complete.') + c.dim(' Open a new terminal to be safe (PATH/version cache).')); + deps.log(reindexAdvisory()); + return 0; +} + +function upgradeNpm( + method: Extract, + versionSpec: string, + deps: UpgradeDeps +): number { + const npm = deps.platform === 'win32' ? 'npm.cmd' : 'npm'; + const args = method.scope === 'global' + ? ['install', '-g', `${NPM_PACKAGE}@${versionSpec}`] + : ['install', `${NPM_PACKAGE}@${versionSpec}`]; + deps.log(c.dim(`Running: ${npm} ${args.join(' ')}`)); + const code = deps.run(npm, args, process.env); + if (code !== 0) { + deps.error(`npm exited with code ${code}.`); + if (method.scope === 'global') { + deps.log(c.dim('If this is a permissions error (EACCES), your global prefix needs sudo, or use a')); + deps.log(c.dim('Node version manager (nvm/fnm) so global installs don’t require root.')); + } + return 1; + } + deps.log(''); + deps.log(c.green('✓ Upgrade complete.')); + deps.log(reindexAdvisory()); + return 0; +} + +// --------------------------------------------------------------------------- +// Production deps wiring (used by the CLI) +// --------------------------------------------------------------------------- + +/** + * True if `cmd` resolves to an executable on PATH. A pure-Node PATH scan — NOT + * a spawned `command -v`/`which`: `command` is a shell builtin (no standalone + * binary on Debian, though macOS ships one), and `which` isn't guaranteed + * present on minimal images, so spawning either is unreliable. Scanning PATH + * ourselves behaves identically on every platform. + */ +export function hasCommand(cmd: string): boolean { + const isWin = process.platform === 'win32'; + const dirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean); + const exts = isWin ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']; + for (const dir of dirs) { + for (const ext of exts) { + const candidate = path.join(dir, cmd + ext); + try { + if (!fs.statSync(candidate).isFile()) continue; + if (isWin) return true; + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + /* not here / not executable — keep scanning */ + } + } + } + return false; +} + +export function defaultRun(cmd: string, args: string[], env?: NodeJS.ProcessEnv): number { + const r = spawnSync(cmd, args, { stdio: 'inherit', env: env ?? process.env }); + if (r.error) return -1; + return r.status ?? -1; +}