/** * `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; }