Files
codegraph/src/upgrade/index.ts
T
4e5cf2de56 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>
2026-06-06 13:38:38 -04:00

516 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `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 versions 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 youre 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(`Youre 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(`Couldnt 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 dont 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;
}