index.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. /**
  2. * `codegraph upgrade`
  3. *
  4. * Self-update for the CLI, whatever way it was installed:
  5. *
  6. * - **bundle** — the self-contained runtime+app installed by `install.sh`
  7. * (Linux/macOS) or `install.ps1` (Windows). Upgrading re-runs the SAME
  8. * canonical installer script (single source of truth) so the download /
  9. * version-resolution / PATH logic never drifts between first-install and
  10. * upgrade.
  11. * - **npm** — installed via `npm i -g @colbymchenry/codegraph`. Upgrading
  12. * shells out to npm.
  13. * - **npx** — ephemeral; nothing to upgrade (next `npx` fetches latest).
  14. * - **source** — a git checkout running its own `dist/`; `git pull` + rebuild.
  15. *
  16. * Detection is structural (see `detectInstallMethod`): a bundle carries a
  17. * vendored `node` binary and a `bin/codegraph` launcher next to its `lib/`, so
  18. * we can recognize it from the running file's path without a marker file.
  19. *
  20. * Windows wrinkle: a running `node.exe` is locked and can't be deleted, so the
  21. * bundle's `current\` dir can't be overwritten in place by the process doing
  22. * the upgrade. We therefore spawn a DETACHED helper that waits for this
  23. * process to exit (releasing the lock), then runs `install.ps1`. This is the
  24. * conventional Windows self-update dance (rustup/nvm-windows do the same).
  25. */
  26. import * as fs from 'fs';
  27. import * as path from 'path';
  28. import * as https from 'https';
  29. import { spawnSync } from 'child_process';
  30. import { ansiColorsEnabled } from '../ui/color';
  31. export const REPO = 'colbymchenry/codegraph';
  32. export const NPM_PACKAGE = '@colbymchenry/codegraph';
  33. const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`;
  34. export const INSTALL_SH_URL = `${RAW_BASE}/install.sh`;
  35. // ---------------------------------------------------------------------------
  36. // Install-method detection (pure — fully unit-testable via injected probes)
  37. // ---------------------------------------------------------------------------
  38. export type InstallMethod =
  39. | { kind: 'bundle'; os: 'unix' | 'windows'; bundleRoot: string; installDir: string | null }
  40. | { kind: 'npm'; scope: 'global' | 'local' }
  41. | { kind: 'npx' }
  42. | { kind: 'source'; root: string }
  43. | { kind: 'unknown'; reason: string };
  44. export interface DetectInput {
  45. /** `__filename` of the running CLI module — `<…>/dist/bin/codegraph.js`. */
  46. filename: string;
  47. platform: NodeJS.Platform;
  48. cwd: string;
  49. /** Injectable existence probe (defaults to fs.existsSync) — for tests. */
  50. exists?: (p: string) => boolean;
  51. }
  52. function toPosix(p: string): string {
  53. return p.replace(/\\/g, '/');
  54. }
  55. /**
  56. * Where the bundle installer keeps its install root, derived from the bundle
  57. * dir so an upgrade reuses a custom `CODEGRAPH_INSTALL_DIR`. Returns null when
  58. * the layout isn't the one the installer creates (then the installer falls
  59. * back to its own default).
  60. *
  61. * unix: <installDir>/versions/<vX.Y.Z> (bundleRoot) → <installDir>
  62. * windows: <installDir>\current (bundleRoot) → <installDir>
  63. */
  64. export function deriveInstallDir(
  65. bundleRoot: string,
  66. os: 'unix' | 'windows',
  67. exists: (p: string) => boolean
  68. ): string | null {
  69. // Use the TARGET platform's path semantics (not the host's), so this is
  70. // deterministic when reasoning about a Windows layout from a POSIX host (CI)
  71. // and vice-versa. In production `os` always matches the running platform.
  72. const P = os === 'windows' ? path.win32 : path.posix;
  73. if (os === 'windows') {
  74. if (P.basename(bundleRoot).toLowerCase() === 'current') {
  75. return P.dirname(bundleRoot);
  76. }
  77. return null;
  78. }
  79. // unix: bundleRoot is <installDir>/versions/<version>
  80. const parent = P.dirname(bundleRoot);
  81. if (P.basename(parent) === 'versions') {
  82. const installDir = P.dirname(parent);
  83. return exists(installDir) ? installDir : P.dirname(parent);
  84. }
  85. return null;
  86. }
  87. export function detectInstallMethod(input: DetectInput): InstallMethod {
  88. const exists = input.exists ?? fs.existsSync;
  89. const isWin = input.platform === 'win32';
  90. // Path math keyed on the TARGET platform so detection is host-independent
  91. // (a Windows layout resolves correctly even when unit-tested on macOS/Linux).
  92. const P = isWin ? path.win32 : path.posix;
  93. const binDir = P.dirname(input.filename); // <…>/bin
  94. const norm = toPosix(input.filename);
  95. // Path-based checks come FIRST. The npm thin-installer's per-platform
  96. // package (@colbymchenry/codegraph-<platform>-<arch>) is itself a complete
  97. // bundle — vendored node + bin/ launcher — living inside node_modules, so
  98. // the layout sniff below would misread every npm install as a standalone
  99. // bundle. `upgrade` would then curl install.sh into ~/.codegraph: a SECOND
  100. // install that never wins the PATH race against npm's shim, leaving
  101. // `codegraph -v` permanently on the old version (the #1071 shadow,
  102. // self-inflicted). A path under node_modules is authoritative about HOW the
  103. // user installed, whatever the artifact inside looks like.
  104. // npx cache: <…>/_npx/<hash>/node_modules/@colbymchenry/codegraph/…
  105. // (checked before npm — the npx cache path also contains /node_modules/).
  106. if (norm.includes('/_npx/')) {
  107. return { kind: 'npx' };
  108. }
  109. // npm install (global or local): lives under a node_modules tree.
  110. if (norm.includes('/node_modules/')) {
  111. const underCwd = norm.startsWith(toPosix(P.resolve(input.cwd)) + '/');
  112. return { kind: 'npm', scope: underCwd ? 'local' : 'global' };
  113. }
  114. // Bundle: <root>/lib/dist/bin/codegraph.js → <root> is up 3 from bin/.
  115. // A bundle has a vendored node + a launcher script as siblings of lib/.
  116. const bundleRoot = P.resolve(binDir, '..', '..', '..');
  117. const vendoredNode = P.join(bundleRoot, isWin ? 'node.exe' : 'node');
  118. const launcher = P.join(bundleRoot, 'bin', isWin ? 'codegraph.cmd' : 'codegraph');
  119. if (exists(vendoredNode) && exists(launcher)) {
  120. const os = isWin ? 'windows' : 'unix';
  121. return { kind: 'bundle', os, bundleRoot, installDir: deriveInstallDir(bundleRoot, os, exists) };
  122. }
  123. // Source checkout: running <repo>/dist/bin/codegraph.js with a sibling .git.
  124. const repoRoot = P.resolve(binDir, '..', '..');
  125. if (exists(P.join(repoRoot, 'package.json')) && exists(P.join(repoRoot, '.git'))) {
  126. return { kind: 'source', root: repoRoot };
  127. }
  128. return { kind: 'unknown', reason: `unrecognized install layout at ${input.filename}` };
  129. }
  130. // ---------------------------------------------------------------------------
  131. // Version helpers (pure)
  132. // ---------------------------------------------------------------------------
  133. export interface Semver {
  134. major: number;
  135. minor: number;
  136. patch: number;
  137. pre: string | null;
  138. }
  139. export function parseSemver(version: string): Semver | null {
  140. const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim());
  141. if (!m) return null;
  142. return {
  143. major: parseInt(m[1]!, 10),
  144. minor: parseInt(m[2]!, 10),
  145. patch: parseInt(m[3]!, 10),
  146. pre: m[4] ?? null,
  147. };
  148. }
  149. /** Returns >0 if a>b, <0 if a<b, 0 if equal. Throws on unparseable input. */
  150. export function compareVersions(a: string, b: string): number {
  151. const sa = parseSemver(a);
  152. const sb = parseSemver(b);
  153. if (!sa || !sb) throw new Error(`cannot compare versions: "${a}" vs "${b}"`);
  154. if (sa.major !== sb.major) return sa.major - sb.major;
  155. if (sa.minor !== sb.minor) return sa.minor - sb.minor;
  156. if (sa.patch !== sb.patch) return sa.patch - sb.patch;
  157. // A prerelease is "less than" its release (1.0.0-rc < 1.0.0).
  158. if (sa.pre && !sb.pre) return -1;
  159. if (!sa.pre && sb.pre) return 1;
  160. if (sa.pre && sb.pre) return sa.pre < sb.pre ? -1 : sa.pre > sb.pre ? 1 : 0;
  161. return 0;
  162. }
  163. export function isUpdateAvailable(current: string, latest: string): boolean {
  164. try {
  165. return compareVersions(latest, current) > 0;
  166. } catch {
  167. // If either is unparseable (e.g. a dev "0.0.0-unknown"), treat differing
  168. // strings as "update available" so the user isn't stuck.
  169. return normalizeVersion(current) !== normalizeVersion(latest);
  170. }
  171. }
  172. /** `0.9.9` / `v0.9.9` → `v0.9.9` (release tags are v-prefixed). */
  173. export function normalizeVersion(v: string): string {
  174. const t = v.trim();
  175. return t.startsWith('v') ? t : `v${t}`;
  176. }
  177. /** Strip a leading `v`: `v0.9.9` → `0.9.9`. */
  178. export function stripV(v: string): string {
  179. const t = v.trim();
  180. return t.startsWith('v') ? t.slice(1) : t;
  181. }
  182. /**
  183. * Parse the release tag out of the `Location` header GitHub returns for
  184. * `/releases/latest` → `…/releases/tag/v0.9.9`. Pure so it's unit-tested.
  185. */
  186. export function parseLatestTagFromLocation(location: string | undefined): string | null {
  187. if (!location) return null;
  188. const m = /\/releases\/tag\/([^/?#]+)/.exec(location);
  189. return m ? decodeURIComponent(m[1]!) : null;
  190. }
  191. // ---------------------------------------------------------------------------
  192. // Latest-version resolution (network)
  193. // ---------------------------------------------------------------------------
  194. function httpsGet(
  195. url: string,
  196. headers: Record<string, string>,
  197. timeoutMs: number
  198. ): Promise<{ status: number; headers: Record<string, string | string[] | undefined>; body: string }> {
  199. return new Promise((resolve, reject) => {
  200. const req = https.get(url, { headers }, (res) => {
  201. let body = '';
  202. res.on('data', (c) => (body += c));
  203. res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body }));
  204. });
  205. req.on('error', reject);
  206. req.setTimeout(timeoutMs, () => req.destroy(new Error(`request timed out after ${timeoutMs}ms`)));
  207. });
  208. }
  209. /**
  210. * Resolve the latest release tag (e.g. `v0.9.9`).
  211. *
  212. * Primary: read the redirect `Location` from `github.com/<repo>/releases/latest`
  213. * — same trick install.sh uses, because the unauthenticated GitHub API is
  214. * rate-limited to 60 req/h/IP and 403s on shared/cloud hosts (issue #325). The
  215. * redirect has no such limit. Fall back to the API only if the redirect can't
  216. * be read.
  217. */
  218. export async function resolveLatestVersion(repo = REPO, timeoutMs = 12000): Promise<string> {
  219. try {
  220. const res = await httpsGet(
  221. `https://github.com/${repo}/releases/latest`,
  222. { 'User-Agent': 'codegraph-upgrade' },
  223. timeoutMs
  224. );
  225. const loc = res.headers.location;
  226. const tag = parseLatestTagFromLocation(Array.isArray(loc) ? loc[0] : loc);
  227. if (tag) return normalizeVersion(tag);
  228. } catch {
  229. /* fall through to API */
  230. }
  231. try {
  232. const res = await httpsGet(
  233. `https://api.github.com/repos/${repo}/releases/latest`,
  234. { 'User-Agent': 'codegraph-upgrade', Accept: 'application/vnd.github+json' },
  235. timeoutMs
  236. );
  237. const tag = JSON.parse(res.body)?.tag_name;
  238. if (typeof tag === 'string' && tag) return normalizeVersion(tag);
  239. } catch {
  240. /* fall through to error */
  241. }
  242. throw new Error(
  243. 'could not resolve the latest version from GitHub. Check your network, or pin a version: `codegraph upgrade <version>`.'
  244. );
  245. }
  246. // ---------------------------------------------------------------------------
  247. // Orchestrator
  248. // ---------------------------------------------------------------------------
  249. export interface UpgradeOptions {
  250. /** Pin a specific version (positional arg or CODEGRAPH_VERSION). */
  251. version?: string;
  252. /** Report current vs latest, don't change anything. */
  253. check?: boolean;
  254. /** Reinstall even if already on the resolved version. */
  255. force?: boolean;
  256. }
  257. /** Injectable side-effects so the orchestrator stays unit-testable. */
  258. export interface UpgradeDeps {
  259. currentVersion: string;
  260. method: InstallMethod;
  261. resolveLatest: (pin?: string) => Promise<string>;
  262. /** Run a command inheriting stdio; returns its exit code (-1 = spawn failed). */
  263. run: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => number;
  264. /** Run a command capturing stdout (nothing reaches the terminal); null = spawn failed. */
  265. capture: (cmd: string, args: string[]) => { code: number; stdout: string } | null;
  266. hasCommand: (cmd: string) => boolean;
  267. log: (msg: string) => void;
  268. warn: (msg: string) => void;
  269. error: (msg: string) => void;
  270. platform: NodeJS.Platform;
  271. /**
  272. * Offer the one-time CodeGraph Pro beta opt-in after a successful update
  273. * (see installer/beta-signup — self-gating: TTY only, and silent forever
  274. * once any install/upgrade ask was answered). Optional so unit tests and
  275. * embedded callers stay prompt-free; never fatal to the upgrade.
  276. */
  277. offerBetaSignup?: () => Promise<void>;
  278. }
  279. // Colors off when piped / NO_COLOR / --no-color (#1281).
  280. const useColor = ansiColorsEnabled();
  281. const c = {
  282. bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
  283. dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
  284. green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
  285. yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
  286. cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
  287. };
  288. /** The honest, additive re-index reminder shown after a successful upgrade. */
  289. export function reindexAdvisory(): string {
  290. return [
  291. c.dim('Your existing project indexes keep working, but were built by the previous version.'),
  292. c.dim('To pick up this version’s extraction improvements, refresh each project:'),
  293. ` ${c.cyan('codegraph sync')} ${c.dim('# incremental, fast')}`,
  294. ` ${c.cyan('codegraph index -f')} ${c.dim('# full rebuild')}`,
  295. c.dim('(`codegraph status` flags any index that predates the engine you’re running.)'),
  296. ].join('\n');
  297. }
  298. /**
  299. * Returns the process exit code (0 = success / nothing to do, 1 = failure).
  300. */
  301. export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promise<number> {
  302. const { currentVersion, method } = deps;
  303. // Resolve the target version (pinned or latest).
  304. let latest: string;
  305. try {
  306. latest = normalizeVersion(opts.version || (await deps.resolveLatest()));
  307. } catch (err) {
  308. deps.error(err instanceof Error ? err.message : String(err));
  309. return 1;
  310. }
  311. const currentDisplay = normalizeVersion(currentVersion);
  312. deps.log(`${c.bold('CodeGraph')} current ${c.cyan(currentDisplay)} ${opts.version ? 'target' : 'latest'} ${c.cyan(latest)}`);
  313. const updateAvailable = isUpdateAvailable(currentVersion, latest);
  314. if (opts.check) {
  315. if (updateAvailable) {
  316. deps.log(c.yellow(`An update is available: ${currentDisplay} → ${latest}`));
  317. deps.log(c.dim('Run `codegraph upgrade` to install it.'));
  318. } else {
  319. deps.log(c.green(`You’re on the latest version (${currentDisplay}).`));
  320. }
  321. return 0;
  322. }
  323. if (!updateAvailable && !opts.force && !opts.version) {
  324. deps.log(c.green(`Already up to date (${currentDisplay}).`));
  325. deps.log(c.dim('Use `--force` to reinstall, or `codegraph upgrade <version>` to change versions.'));
  326. return 0;
  327. }
  328. // Dispatch by install method. bundle/npm perform a real binary update, so
  329. // after they succeed we self-heal the front-load hook (below); npx/source/
  330. // unknown don't update anything here, so they return directly.
  331. let code: number;
  332. switch (method.kind) {
  333. case 'bundle':
  334. code = await (method.os === 'windows'
  335. ? upgradeWindowsBundle(method, latest, deps)
  336. : upgradeUnixBundle(method, opts.version ? latest : undefined, deps));
  337. break;
  338. case 'npm':
  339. // npm version specs have no leading "v" (`@0.9.8`, not `@v0.9.8` — the
  340. // latter resolves as a nonexistent dist-tag).
  341. code = await upgradeNpm(method, opts.version ? stripV(latest) : 'latest', deps);
  342. break;
  343. case 'npx':
  344. deps.log(c.green('npx always runs the latest version on demand — nothing to upgrade.'));
  345. deps.log(c.dim(`Force a fresh fetch with: npx ${NPM_PACKAGE}@latest`));
  346. return 0;
  347. case 'source':
  348. deps.warn(`Running from a source checkout at ${method.root}.`);
  349. deps.log(c.dim('Upgrade it with: git pull && npm run build'));
  350. return 0;
  351. default:
  352. deps.error(`Couldn’t determine how CodeGraph was installed (${method.reason}).`);
  353. deps.log(c.dim(`Reinstall manually — see https://github.com/${REPO}#install`));
  354. return 1;
  355. }
  356. // After a successful update, ensure the front-load prompt hook is wired for an
  357. // already-configured global Claude install — so existing users pick it up on
  358. // upgrade, not only on a fresh `install` (the hook config is version-agnostic,
  359. // so the still-running old binary can write it safely). Idempotent + gated on
  360. // an existing Claude config, and skipped entirely by the kill-switch. Never
  361. // fatal to the upgrade.
  362. if (code === 0) {
  363. let probe: VersionProbe = 'inconclusive';
  364. try {
  365. probe = reportResolvedVersion(latest, deps);
  366. } catch {
  367. /* an inconclusive probe must not fail the upgrade */
  368. }
  369. try {
  370. await selfHealPromptHook(deps);
  371. } catch {
  372. /* a hook-wiring hiccup must not fail the upgrade */
  373. }
  374. // The refresh executes whatever `codegraph` PATH resolves. If the probe
  375. // just proved that's a stale shadowed install, spawning it would rewrite
  376. // the agent surfaces with the very templates the refresh exists to heal —
  377. // skip, and point at the manual command for after the PATH is fixed.
  378. if (probe !== 'mismatch') {
  379. try {
  380. selfHealInstalledSurfaces(deps);
  381. } catch {
  382. /* a refresh hiccup must not fail the upgrade */
  383. }
  384. } else {
  385. deps.log(c.dim('Skipped refreshing agent instructions/config — run `codegraph install --refresh` once the PATH is fixed.'));
  386. }
  387. // Reached only after a real binary update (check/up-to-date/npx/source
  388. // all returned earlier) — the one place the upgrade path may offer the
  389. // beta opt-in. The hook self-gates on TTY + the stored once-per-machine
  390. // choice, so an already-answered user never sees it again.
  391. try {
  392. await deps.offerBetaSignup?.();
  393. } catch {
  394. /* a marketing question must never fail the upgrade */
  395. }
  396. }
  397. return code;
  398. }
  399. type VersionProbe = 'match' | 'mismatch' | 'inconclusive';
  400. /**
  401. * Prove the upgrade actually took: spawn the `codegraph` this terminal's PATH
  402. * resolves and compare its reported version to the target. Catches the silent
  403. * failure mode where ANOTHER install shadows the one we just upgraded (issue
  404. * #1071 — e.g. a stale `npm i -g` copy earlier on PATH than the bundle
  405. * launcher): the upgrade "succeeds" but `codegraph -v` — in this terminal and
  406. * every future one — keeps serving the old version. Exported for unit tests.
  407. */
  408. export function verifyResolvedVersion(latest: string, deps: UpgradeDeps): VersionProbe {
  409. if (!deps.hasCommand('codegraph')) return 'inconclusive';
  410. // Windows installs expose codegraph through a .cmd launcher; Node can't
  411. // spawn .cmd files without a shell, so route through cmd.exe there.
  412. const probe = deps.platform === 'win32'
  413. ? deps.capture('cmd.exe', ['/d', '/s', '/c', 'codegraph --version'])
  414. : deps.capture('codegraph', ['--version']);
  415. if (!probe || probe.code !== 0) return 'inconclusive';
  416. // `codegraph --version` prints the bare version; take the last non-empty
  417. // line so a stray runtime warning above it can't spoil the parse.
  418. const reported = probe.stdout.trim().split(/\r?\n/).pop()?.trim() ?? '';
  419. if (!parseSemver(reported)) return 'inconclusive';
  420. return compareVersions(reported, latest) === 0 ? 'match' : 'mismatch';
  421. }
  422. /**
  423. * Log the outcome of the post-upgrade version probe. On a match the user
  424. * knows the current terminal is already serving the new version; on a
  425. * mismatch they get told exactly which stale install is hijacking their PATH
  426. * instead of discovering it via a mysteriously unchanged `codegraph -v`.
  427. * Inconclusive probes fall back to the old soft hint — never a scare on
  428. * setups we can't inspect (no `codegraph` on PATH yet, exotic wrappers).
  429. * Returns the probe result so the caller can gate the post-upgrade refresh
  430. * (which spawns the PATH-resolved binary) on it.
  431. */
  432. function reportResolvedVersion(latest: string, deps: UpgradeDeps): VersionProbe {
  433. const { method } = deps;
  434. // A project-local npm install isn't served by PATH's `codegraph` (that
  435. // would be some other install) — a probe could only false-alarm.
  436. if (method.kind === 'npm' && method.scope === 'local') return 'inconclusive';
  437. const probe = verifyResolvedVersion(latest, deps);
  438. switch (probe) {
  439. case 'match':
  440. deps.log(c.green(`✓ \`codegraph\` on your PATH now reports ${latest} — this terminal is already using it.`));
  441. break;
  442. case 'mismatch':
  443. deps.warn(`Installed ${latest}, but the \`codegraph\` this terminal resolves still reports an older version.`);
  444. deps.log(c.dim('Another CodeGraph install earlier on your PATH is shadowing the one just upgraded.'));
  445. deps.log(c.dim('Find every copy with `which -a codegraph` (Windows: `where codegraph`) and remove or upgrade the stale one.'));
  446. break;
  447. case 'inconclusive':
  448. deps.log(c.dim('Open a new terminal if `codegraph --version` looks unchanged (PATH cache).'));
  449. break;
  450. }
  451. return probe;
  452. }
  453. /**
  454. * Refresh the agent surfaces previous installs wrote — the marker-fenced
  455. * instructions sections (CLAUDE.md / AGENTS.md / GEMINI.md), MCP entries,
  456. * legacy-hook cleanups — so they match the version that will serve them.
  457. * Unlike the prompt hook above, this content is NOT version-agnostic: the
  458. * templates are baked into the binary, so the still-running old process
  459. * would only rewrite its own stale copy — the exact staleness this heals.
  460. * We therefore spawn the freshly-installed binary (`codegraph install
  461. * --refresh`), which is refresh-only: agents never configured stay
  462. * untouched, and permission / prompt-hook choices are preserved. Gated on
  463. * `codegraph` being resolvable on PATH (an npm-local install isn't) and on
  464. * the kill-switch; never fatal to the upgrade.
  465. */
  466. function selfHealInstalledSurfaces(deps: UpgradeDeps): void {
  467. if (process.env.CODEGRAPH_NO_INSTALL_REFRESH === '1') return;
  468. if (!deps.hasCommand('codegraph')) return;
  469. deps.log(c.dim('Refreshing agent instruction sections and config written by previous versions…'));
  470. // Windows installs expose codegraph through a .cmd launcher. Node cannot
  471. // spawn .cmd files directly without a shell, so route the constant command
  472. // through cmd.exe there (the same launcher a terminal would resolve).
  473. const code = deps.platform === 'win32'
  474. ? deps.run('cmd.exe', ['/d', '/s', '/c', 'codegraph install --refresh'])
  475. : deps.run('codegraph', ['install', '--refresh']);
  476. if (code !== 0) {
  477. deps.warn('Could not refresh the installed agent surfaces — run `codegraph install --refresh` manually.');
  478. }
  479. }
  480. /**
  481. * Wire the Claude `UserPromptSubmit` front-load hook on upgrade for an
  482. * already-configured global Claude install. No-op when Claude isn't configured,
  483. * when the hook is already present, or when the kill-switch is set.
  484. */
  485. async function selfHealPromptHook(deps: UpgradeDeps): Promise<void> {
  486. if (process.env.CODEGRAPH_NO_PROMPT_HOOK === '1' || process.env.CODEGRAPH_PROMPT_HOOK === '0') return;
  487. const { claudeTarget, writePromptHookEntry } = await import('../installer/targets/claude');
  488. if (!claudeTarget.detect('global').alreadyConfigured) return;
  489. const res = writePromptHookEntry('global');
  490. if (res.action === 'created' || res.action === 'updated') {
  491. deps.log(
  492. c.dim('Enabled the CodeGraph front-load hook for Claude Code (structural prompts). Disable any time: CODEGRAPH_NO_PROMPT_HOOK=1'),
  493. );
  494. }
  495. }
  496. function upgradeUnixBundle(
  497. method: Extract<InstallMethod, { kind: 'bundle' }>,
  498. pinned: string | undefined,
  499. deps: UpgradeDeps
  500. ): number {
  501. const downloader = deps.hasCommand('curl')
  502. ? `curl -fsSL ${INSTALL_SH_URL}`
  503. : deps.hasCommand('wget')
  504. ? `wget -qO- ${INSTALL_SH_URL}`
  505. : null;
  506. if (!downloader) {
  507. deps.error('Neither curl nor wget is available to download the installer.');
  508. deps.log(c.dim(`Install curl, or run manually: ${INSTALL_SH_URL} | sh`));
  509. return 1;
  510. }
  511. const env: NodeJS.ProcessEnv = { ...process.env };
  512. if (method.installDir) env.CODEGRAPH_INSTALL_DIR = method.installDir;
  513. if (pinned) env.CODEGRAPH_VERSION = pinned;
  514. deps.log(c.dim(`Running the installer (${downloader} | sh)…`));
  515. const code = deps.run('sh', ['-c', `${downloader} | sh`], env);
  516. if (code !== 0) {
  517. deps.error(`Installer exited with code ${code}.`);
  518. return 1;
  519. }
  520. deps.log('');
  521. // No "open a new terminal" hedge here — after the swap, runUpgrade probes
  522. // the PATH-resolved `codegraph --version` and reports the real outcome.
  523. deps.log(c.green('✓ Upgrade complete.'));
  524. deps.log(reindexAdvisory());
  525. return 0;
  526. }
  527. /** Build the in-place Windows upgrade script (exported for unit-testing). */
  528. export function buildWindowsUpgradeScript(bundleRoot: string, version: string, arch: string): string {
  529. const target = `win32-${arch}`;
  530. const url = `https://github.com/${REPO}/releases/download/${version}/codegraph-${target}.zip`;
  531. // Windows can't DELETE a running exe but CAN rename it, so we upgrade IN
  532. // PLACE: download → rename the locked node.exe aside → extract the new bundle
  533. // over current\. Synchronous, no detached helper (which dies under SSH/job
  534. // objects and has worse UX). The running process keeps its renamed node.exe
  535. // mapped; the NEXT `codegraph` invocation uses the new one. We can't reuse
  536. // install.ps1 here — it `Remove-Item`s current\, which fails on the locked exe.
  537. return [
  538. `$ErrorActionPreference='Stop'`,
  539. `$dest='${bundleRoot}'`,
  540. `$url='${url}'`,
  541. `Write-Host "Downloading $url"`,
  542. `$tmp=Join-Path $env:TEMP ('cg-up-'+[guid]::NewGuid().ToString('N'))`,
  543. `New-Item -ItemType Directory -Force -Path $tmp | Out-Null`,
  544. `$zip=Join-Path $tmp 'cg.zip'`,
  545. `Invoke-WebRequest -Uri $url -OutFile $zip`,
  546. `$stage=Join-Path $tmp 'stage'`,
  547. `Expand-Archive -Path $zip -DestinationPath $stage -Force`,
  548. `$inner=Join-Path $stage 'codegraph-${target}'`,
  549. `$src=if(Test-Path $inner){$inner}else{$stage}`,
  550. `$node=Join-Path $dest 'node.exe'`,
  551. `if(Test-Path $node){Rename-Item -Path $node -NewName ('node.exe.old-'+[guid]::NewGuid().ToString('N')) -Force}`,
  552. `Copy-Item -Path (Join-Path $src '*') -Destination $dest -Recurse -Force`,
  553. `Get-ChildItem -Path $dest -Filter 'node.exe.old-*' -ErrorAction SilentlyContinue | ForEach-Object { try { Remove-Item $_.FullName -Force -ErrorAction Stop } catch {} }`,
  554. `Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue`,
  555. `Write-Host "Installed CodeGraph ${version} to $dest"`,
  556. ].join(';');
  557. }
  558. function upgradeWindowsBundle(
  559. method: Extract<InstallMethod, { kind: 'bundle' }>,
  560. latest: string,
  561. deps: UpgradeDeps
  562. ): number {
  563. const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
  564. const script = buildWindowsUpgradeScript(method.bundleRoot, latest, arch);
  565. // -EncodedCommand (base64 UTF-16LE), NOT -Command: Node's Windows argv→command
  566. // -line quoting mangles a long multi-statement script, so PowerShell never
  567. // parses it. Encoding sidesteps all shell quoting — the canonical approach.
  568. const encoded = Buffer.from(script, 'utf16le').toString('base64');
  569. deps.log(c.dim(`Downloading and installing ${latest}…`));
  570. const code = deps.run('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded]);
  571. if (code !== 0) {
  572. deps.error(`Installer exited with code ${code}.`);
  573. return 1;
  574. }
  575. deps.log('');
  576. // The running node.exe was renamed aside, so the version probe in
  577. // runUpgrade already exercises the NEW binary — no terminal hedge needed.
  578. deps.log(c.green('✓ Upgrade complete.'));
  579. deps.log(reindexAdvisory());
  580. return 0;
  581. }
  582. /**
  583. * How to invoke npm. On Windows npm is a .cmd batch file, which Node refuses
  584. * to spawn without a shell (EINVAL since the CVE-2024-27980 hardening) — a
  585. * direct `npm.cmd` spawn fails on every current Node, so route it through
  586. * cmd.exe, the same way the surface-refresh step invokes the .cmd launcher.
  587. * (Verified live on the Windows VM: `spawnSync('npm.cmd')` → EINVAL;
  588. * `cmd.exe /d /s /c npm …` → works.)
  589. */
  590. export function npmInvocation(platform: NodeJS.Platform, npmArgs: string[]): { cmd: string; args: string[] } {
  591. if (platform === 'win32') {
  592. return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', ['npm', ...npmArgs].join(' ')] };
  593. }
  594. return { cmd: 'npm', args: npmArgs };
  595. }
  596. function upgradeNpm(
  597. method: Extract<InstallMethod, { kind: 'npm' }>,
  598. versionSpec: string,
  599. deps: UpgradeDeps
  600. ): number {
  601. const args = method.scope === 'global'
  602. ? ['install', '-g', `${NPM_PACKAGE}@${versionSpec}`]
  603. : ['install', `${NPM_PACKAGE}@${versionSpec}`];
  604. deps.log(c.dim(`Running: npm ${args.join(' ')}`));
  605. const inv = npmInvocation(deps.platform, args);
  606. const code = deps.run(inv.cmd, inv.args, process.env);
  607. if (code !== 0) {
  608. deps.error(`npm exited with code ${code}.`);
  609. if (method.scope === 'global') {
  610. deps.log(c.dim('If this is a permissions error (EACCES), your global prefix needs sudo, or use a'));
  611. deps.log(c.dim('Node version manager (nvm/fnm) so global installs don’t require root.'));
  612. }
  613. return 1;
  614. }
  615. deps.log('');
  616. deps.log(c.green('✓ Upgrade complete.'));
  617. deps.log(reindexAdvisory());
  618. return 0;
  619. }
  620. // ---------------------------------------------------------------------------
  621. // Production deps wiring (used by the CLI)
  622. // ---------------------------------------------------------------------------
  623. /**
  624. * True if `cmd` resolves to an executable on PATH. A pure-Node PATH scan — NOT
  625. * a spawned `command -v`/`which`: `command` is a shell builtin (no standalone
  626. * binary on Debian, though macOS ships one), and `which` isn't guaranteed
  627. * present on minimal images, so spawning either is unreliable. Scanning PATH
  628. * ourselves behaves identically on every platform.
  629. */
  630. export function hasCommand(cmd: string): boolean {
  631. const isWin = process.platform === 'win32';
  632. const dirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean);
  633. const exts = isWin ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : [''];
  634. for (const dir of dirs) {
  635. for (const ext of exts) {
  636. const candidate = path.join(dir, cmd + ext);
  637. try {
  638. if (!fs.statSync(candidate).isFile()) continue;
  639. if (isWin) return true;
  640. fs.accessSync(candidate, fs.constants.X_OK);
  641. return true;
  642. } catch {
  643. /* not here / not executable — keep scanning */
  644. }
  645. }
  646. }
  647. return false;
  648. }
  649. export function defaultRun(cmd: string, args: string[], env?: NodeJS.ProcessEnv): number {
  650. const r = spawnSync(cmd, args, { stdio: 'inherit', env: env ?? process.env, windowsHide: true });
  651. if (r.error) return -1;
  652. return r.status ?? -1;
  653. }
  654. export function defaultCapture(cmd: string, args: string[]): { code: number; stdout: string } | null {
  655. // stdio is piped (the default with `encoding`), so nothing the probed
  656. // command prints reaches the user's terminal. The timeout keeps a wedged
  657. // probe from hanging the upgrade's last step.
  658. const r = spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true, timeout: 30_000 });
  659. if (r.error) return null;
  660. return { code: r.status ?? -1, stdout: r.stdout ?? '' };
  661. }