feat(cli): add codegraph upgrade self-update + stale-index re-index hint (#710)
`codegraph upgrade [version]` detects how the CLI was installed — the standalone install.sh/install.ps1 bundle, npm-global, npx, or a source checkout — and updates in place: re-running the canonical install.sh on macOS/Linux, an in-place rename-and-extract swap on Windows (a running node.exe can't be deleted, only renamed, so the detached-helper approach is avoided), and npm/npx/source-specific guidance otherwise. Flags: `--check` (report only), `--force`, and a positional version to pin. Each full index is now stamped with the engine's EXTRACTION_VERSION in project_metadata; `codegraph status` (and `--json`) flags an index built by an older engine and recommends re-indexing, and `upgrade` prints the same reminder. Gated on EXTRACTION_VERSION so it never nags on extraction-neutral releases. Validated end-to-end on macOS (real bundle upgrade), Linux (Docker, real curl|sh) and Windows (Parallels VM, real in-place swap). 32 new unit tests. Closes #679 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
07af3db6c7
commit
4e5cf2de56
@@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### New Features
|
### 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 <version>` 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.
|
- 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#)
|
- 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 `<MyComponent/>` 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)
|
- 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 `<MyComponent/>` 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)
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ npm i -g @colbymchenry/codegraph
|
|||||||
|
|
||||||
<sub>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.</sub>
|
<sub>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.</sub>
|
||||||
|
|
||||||
|
<sub>**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 <version>` to pin one.</sub>
|
||||||
|
|
||||||
### 2. Wire up your agent(s)
|
### 2. Wire up your agent(s)
|
||||||
|
|
||||||
In a **new terminal**, run the installer to connect CodeGraph to the agents you use:
|
In a **new terminal**, run the installer to connect CodeGraph to the agents you use:
|
||||||
@@ -465,6 +467,7 @@ codegraph callees <symbol> # Find what a function/method calls (--limit,
|
|||||||
codegraph impact <symbol> # Analyze what code is affected by changing a symbol (--depth, --json)
|
codegraph impact <symbol> # Analyze what code is affected by changing a symbol (--depth, --json)
|
||||||
codegraph affected [files...] # Find test files affected by changes (see below)
|
codegraph affected [files...] # Find test files affected by changes (see below)
|
||||||
codegraph serve --mcp # Start MCP server
|
codegraph serve --mcp # Start MCP server
|
||||||
|
codegraph upgrade [version] # Update to the latest release (--check, --force)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `codegraph affected`
|
### `codegraph affected`
|
||||||
|
|||||||
@@ -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<string>) {
|
||||||
|
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<InstallMethod, { kind: 'bundle' }>;
|
||||||
|
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<UpgradeDeps> & { 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 @<version>', 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
#
|
#
|
||||||
# irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
|
# irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
|
||||||
#
|
#
|
||||||
# Re-run to upgrade. To uninstall: remove $env:LOCALAPPDATA\codegraph and drop
|
# Upgrade with `codegraph upgrade` (or just re-run this). To uninstall: remove
|
||||||
# its \current\bin entry from your user PATH.
|
# $env:LOCALAPPDATA\codegraph and drop its \current\bin entry from your user PATH.
|
||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
# CODEGRAPH_VERSION release tag to install (default: latest)
|
# CODEGRAPH_VERSION release tag to install (default: latest)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
|
# 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
|
# Uninstall: curl -fsSL .../install.sh | sh -s -- --uninstall
|
||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
* codegraph callees <symbol> Find what a function/method calls
|
* codegraph callees <symbol> Find what a function/method calls
|
||||||
* codegraph impact <symbol> Analyze what code is affected by changing a symbol
|
* codegraph impact <symbol> Analyze what code is affected by changing a symbol
|
||||||
* codegraph affected [files] Find test files affected by changes
|
* codegraph affected [files] Find test files affected by changes
|
||||||
|
* codegraph upgrade [version] Update CodeGraph to the latest release
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
@@ -32,6 +33,7 @@ import { getGlyphs } from '../ui/glyphs';
|
|||||||
|
|
||||||
import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
|
import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
|
||||||
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
|
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.
|
// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
|
||||||
async function loadCodeGraph(): Promise<typeof import('../index')> {
|
async function loadCodeGraph(): Promise<typeof import('../index')> {
|
||||||
@@ -699,6 +701,9 @@ program
|
|||||||
const backend = cg.getBackend();
|
const backend = cg.getBackend();
|
||||||
const journalMode = cg.getJournalMode();
|
const journalMode = cg.getJournalMode();
|
||||||
|
|
||||||
|
const buildInfo = cg.getIndexBuildInfo();
|
||||||
|
const reindexRecommended = cg.isIndexStale();
|
||||||
|
|
||||||
// JSON output mode
|
// JSON output mode
|
||||||
if (options.json) {
|
if (options.json) {
|
||||||
const lastIndexedMs = cg.getLastIndexedAt();
|
const lastIndexedMs = cg.getLastIndexedAt();
|
||||||
@@ -724,6 +729,12 @@ program
|
|||||||
worktreeMismatch: worktreeMismatch
|
worktreeMismatch: worktreeMismatch
|
||||||
? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot }
|
? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot }
|
||||||
: null,
|
: null,
|
||||||
|
index: {
|
||||||
|
builtWithVersion: buildInfo.version,
|
||||||
|
builtWithExtractionVersion: buildInfo.extractionVersion,
|
||||||
|
currentExtractionVersion: EXTRACTION_VERSION,
|
||||||
|
reindexRecommended,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
cg.destroy();
|
cg.destroy();
|
||||||
return;
|
return;
|
||||||
@@ -797,6 +808,15 @@ program
|
|||||||
}
|
}
|
||||||
console.log();
|
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();
|
cg.destroy();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(`Failed to get status: ${err instanceof Error ? err.message : String(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
|
// Parse and run
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -47,6 +47,8 @@ import { GraphTraverser, GraphQueryManager } from './graph';
|
|||||||
import { ContextBuilder, createContextBuilder } from './context';
|
import { ContextBuilder, createContextBuilder } from './context';
|
||||||
import { Mutex, FileLock } from './utils';
|
import { Mutex, FileLock } from './utils';
|
||||||
import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
|
import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
|
||||||
|
import { EXTRACTION_VERSION } from './extraction/extraction-version';
|
||||||
|
import { CodeGraphPackageVersion } from './mcp/version';
|
||||||
|
|
||||||
// Re-export types for consumers
|
// Re-export types for consumers
|
||||||
export * from './types';
|
export * from './types';
|
||||||
@@ -382,6 +384,18 @@ export class CodeGraph {
|
|||||||
result.edgesCreated = after.edges - before.edges;
|
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;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
this.fileLock.release();
|
this.fileLock.release();
|
||||||
@@ -585,6 +599,32 @@ export class CodeGraph {
|
|||||||
return this.queries.getLastIndexedAt();
|
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)
|
* Extract nodes and edges from source code (without storing)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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: <installDir>/versions/<vX.Y.Z> (bundleRoot) → <installDir>
|
||||||
|
* windows: <installDir>\current (bundleRoot) → <installDir>
|
||||||
|
*/
|
||||||
|
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 <installDir>/versions/<version>
|
||||||
|
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: <root>/lib/dist/bin/codegraph.js → <root> 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/<hash>/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 <repo>/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<b, 0 if equal. Throws on unparseable input. */
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const sa = parseSemver(a);
|
||||||
|
const sb = parseSemver(b);
|
||||||
|
if (!sa || !sb) throw new Error(`cannot compare versions: "${a}" vs "${b}"`);
|
||||||
|
if (sa.major !== sb.major) return sa.major - sb.major;
|
||||||
|
if (sa.minor !== sb.minor) return sa.minor - sb.minor;
|
||||||
|
if (sa.patch !== sb.patch) return sa.patch - sb.patch;
|
||||||
|
// A prerelease is "less than" its release (1.0.0-rc < 1.0.0).
|
||||||
|
if (sa.pre && !sb.pre) return -1;
|
||||||
|
if (!sa.pre && sb.pre) return 1;
|
||||||
|
if (sa.pre && sb.pre) return sa.pre < sb.pre ? -1 : sa.pre > 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<string, string>,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<{ status: number; headers: Record<string, string | string[] | undefined>; 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/<repo>/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<string> {
|
||||||
|
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 <version>`.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<string>;
|
||||||
|
/** 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<number> {
|
||||||
|
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 <version>` 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<InstallMethod, { kind: 'bundle' }>,
|
||||||
|
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<InstallMethod, { kind: 'bundle' }>,
|
||||||
|
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<InstallMethod, { kind: 'npm' }>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user