remove-binary.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. /**
  2. * CLI binary removal for `codegraph uninstall` (the #1071 shadow, uninstall
  3. * edition).
  4. *
  5. * Before this module, three disconnected paths each removed PART of an
  6. * installation and none removed it all: `codegraph uninstall` swept agent
  7. * configs only, `install.sh --uninstall` deleted the bundle only, and npm's
  8. * `preuninstall` hook cleaned configs when npm removed its own package. A
  9. * user with more than one install method (the common drift: npm first, the
  10. * bundle later — or vice versa) ran `codegraph uninstall` and still had a
  11. * working `codegraph` on PATH.
  12. *
  13. * This module makes `codegraph uninstall` complete: PLAN every binary
  14. * install present on the machine (bundle layout(s), the npm global package,
  15. * the bin-dir shim), then EXECUTE the removals. Split planner/executor with
  16. * injected side effects, same convention as the upgrade orchestrator.
  17. *
  18. * Safety rules:
  19. * - A source checkout is REPORTED, never deleted — a git repo is the
  20. * user's working tree, not an "install".
  21. * - A project-local npm install is left alone — the project's
  22. * package.json owns it, not the machine-level uninstaller.
  23. * - On unix the default install dir (`~/.codegraph`) doubles as the
  24. * machine-level state dir (telemetry choice, daemon records, the
  25. * update-check cache) — only the install ARTIFACTS (`versions/`,
  26. * `current`) are removed there, never the whole dir. A dedicated
  27. * install dir (Windows `%LOCALAPPDATA%\codegraph`, or a custom
  28. * `CODEGRAPH_INSTALL_DIR`) is removed wholesale.
  29. * - The bin-dir shim is removed only when it verifiably points into a
  30. * detected install dir — a user's unrelated `codegraph` file survives.
  31. * - Windows cannot DELETE a running exe but CAN rename it (the same
  32. * trick the in-place upgrade uses): a locked `node.exe` is renamed
  33. * aside and reported as a leftover for the user to delete after the
  34. * window closes, instead of failing the whole removal.
  35. */
  36. import * as fs from 'fs';
  37. import * as path from 'path';
  38. import * as os from 'os';
  39. import { spawnSync } from 'child_process';
  40. import { detectInstallMethod, npmInvocation, NPM_PACKAGE } from './index';
  41. // ---------------------------------------------------------------------------
  42. // Planner (pure — every probe injected)
  43. // ---------------------------------------------------------------------------
  44. export interface RemoveBinaryProbes {
  45. /** `__filename` of the running CLI entry (dist/bin/codegraph.js). */
  46. filename: string;
  47. platform: NodeJS.Platform;
  48. cwd: string;
  49. env: NodeJS.ProcessEnv;
  50. homedir: string;
  51. exists: (p: string) => boolean;
  52. /** Symlink target (raw link text), or null when not a symlink / unreadable. */
  53. readlink: (p: string) => string | null;
  54. /** Run a command capturing stdout; null = spawn failed. */
  55. capture: (cmd: string, args: string[]) => { code: number; stdout: string } | null;
  56. }
  57. export interface BinaryRemovalPlan {
  58. /** Filesystem paths to delete (bundle dirs / artifacts, shim links). */
  59. paths: string[];
  60. /** The npm global package is installed and should be `npm uninstall -g`ed. */
  61. npmGlobal: boolean;
  62. /**
  63. * npm's global node_modules root (for the Windows locked-exe dance — the
  64. * vendored node.exe lives in the SIBLING per-platform package, so the
  65. * whole root is the lock surface, not just the meta package's dir).
  66. */
  67. npmRoot: string | null;
  68. /** Running from a git checkout — surfaced to the user, never deleted. */
  69. sourceRoot: string | null;
  70. /** One human line per planned removal, for the confirm prompt. */
  71. summary: string[];
  72. }
  73. export function defaultProbes(filename: string): RemoveBinaryProbes {
  74. return {
  75. filename,
  76. platform: process.platform,
  77. cwd: process.cwd(),
  78. env: process.env,
  79. homedir: os.homedir(),
  80. exists: fs.existsSync,
  81. readlink: (p) => {
  82. try { return fs.readlinkSync(p); } catch { return null; }
  83. },
  84. capture: (cmd, args) => {
  85. const res = spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 30_000 });
  86. if (res.error || typeof res.status !== 'number') return null;
  87. return { code: res.status, stdout: res.stdout ?? '' };
  88. },
  89. };
  90. }
  91. /**
  92. * Path math keyed on the TARGET platform (not the host) — same convention as
  93. * `detectInstallMethod`, so the planner is deterministic when unit-tested with
  94. * a win32 fixture on a POSIX host and vice versa. In production the probe's
  95. * platform always matches the running host.
  96. */
  97. function pathFor(platform: NodeJS.Platform): path.PlatformPath {
  98. return platform === 'win32' ? path.win32 : path.posix;
  99. }
  100. /** The machine-level state dir that must survive an artifacts-only removal. */
  101. function stateDir(p: RemoveBinaryProbes): string {
  102. return pathFor(p.platform).join(p.homedir, '.codegraph');
  103. }
  104. /** Candidate bundle install dirs: the running binary's own, plus the defaults. */
  105. function installDirCandidates(p: RemoveBinaryProbes): string[] {
  106. const P = pathFor(p.platform);
  107. const dirs: string[] = [];
  108. const method = detectInstallMethod({
  109. filename: p.filename,
  110. platform: p.platform,
  111. cwd: p.cwd,
  112. exists: p.exists,
  113. });
  114. if (method.kind === 'bundle' && method.installDir) dirs.push(method.installDir);
  115. if (p.env.CODEGRAPH_INSTALL_DIR) dirs.push(p.env.CODEGRAPH_INSTALL_DIR);
  116. // Platform defaults — probed even when the RUNNING binary is npm/source,
  117. // because the whole point is clearing installs the user forgot about.
  118. if (p.platform === 'win32') {
  119. if (p.env.LOCALAPPDATA) dirs.push(P.join(p.env.LOCALAPPDATA, 'codegraph'));
  120. } else {
  121. dirs.push(stateDir(p));
  122. }
  123. return [...new Set(dirs.map((d) => P.resolve(d)))];
  124. }
  125. export function planBinaryRemoval(p: RemoveBinaryProbes): BinaryRemovalPlan {
  126. const P = pathFor(p.platform);
  127. const plan: BinaryRemovalPlan = {
  128. paths: [],
  129. npmGlobal: false,
  130. npmRoot: null,
  131. sourceRoot: null,
  132. summary: [],
  133. };
  134. const method = detectInstallMethod({
  135. filename: p.filename,
  136. platform: p.platform,
  137. cwd: p.cwd,
  138. exists: p.exists,
  139. });
  140. if (method.kind === 'source') {
  141. plan.sourceRoot = method.root;
  142. }
  143. // --- Bundle install(s) ----------------------------------------------------
  144. const installDirs: string[] = [];
  145. for (const dir of installDirCandidates(p)) {
  146. // A dir counts as a bundle install only when it carries install artifacts.
  147. const artifacts = ['versions', 'current']
  148. .map((a) => P.join(dir, a))
  149. .filter((a) => p.exists(a) || p.readlink(a) !== null); // `current` is a symlink on unix
  150. if (artifacts.length === 0) continue;
  151. installDirs.push(dir);
  152. if (P.resolve(dir) === P.resolve(stateDir(p))) {
  153. // Shared with machine-level state: remove artifacts only.
  154. plan.paths.push(...artifacts);
  155. plan.summary.push(`bundle install at ${dir} (versions/ and current — state files kept)`);
  156. } else {
  157. plan.paths.push(dir);
  158. plan.summary.push(`bundle install at ${dir}`);
  159. }
  160. }
  161. // --- Bin-dir shim (unix installer's symlink) --------------------------------
  162. const binDir = p.env.CODEGRAPH_BIN_DIR
  163. ?? (p.platform === 'win32' ? null : P.join(p.homedir, '.local', 'bin'));
  164. if (binDir) {
  165. const shim = P.join(binDir, 'codegraph');
  166. const target = p.readlink(shim);
  167. // Only when the link demonstrably points into a bundle install dir —
  168. // resolved against the link's own directory, since install.sh links an
  169. // absolute target but a hand-made relative link must still verify.
  170. if (target !== null) {
  171. const resolved = P.resolve(binDir, target);
  172. const ours = installDirs.some((d) => resolved.startsWith(P.resolve(d) + P.sep))
  173. || resolved.includes(`${P.sep}.codegraph${P.sep}`);
  174. if (ours) {
  175. plan.paths.push(shim);
  176. plan.summary.push(`launcher link at ${shim}`);
  177. }
  178. }
  179. }
  180. // --- npm global package -----------------------------------------------------
  181. // Asking npm (not guessing prefixes) keeps this correct under nvm/fnm/volta.
  182. // A LOCAL npm install (project dependency) is deliberately not offered.
  183. const rootInv = npmInvocation(p.platform, ['root', '-g']);
  184. const rootRes = p.capture(rootInv.cmd, rootInv.args);
  185. if (rootRes && rootRes.code === 0) {
  186. const pkgDir = P.join(rootRes.stdout.trim(), NPM_PACKAGE);
  187. if (rootRes.stdout.trim() && p.exists(pkgDir)) {
  188. plan.npmGlobal = true;
  189. plan.npmRoot = rootRes.stdout.trim();
  190. plan.summary.push(`npm global package (${NPM_PACKAGE})`);
  191. }
  192. }
  193. return plan;
  194. }
  195. // ---------------------------------------------------------------------------
  196. // Executor (injected side effects)
  197. // ---------------------------------------------------------------------------
  198. export interface RemoveBinaryDeps {
  199. platform: NodeJS.Platform;
  200. /** The running node binary — the file Windows will hold a lock on. */
  201. execPath: string;
  202. rm: (p: string) => void;
  203. rename: (from: string, to: string) => void;
  204. /** Run a command inheriting stdio; returns exit code (-1 = spawn failed). */
  205. run: (cmd: string, args: string[]) => number;
  206. }
  207. export interface BinaryRemovalResult {
  208. removed: string[];
  209. /** Paths that could not be (fully) removed — surfaced with manual steps. */
  210. leftovers: string[];
  211. npm: 'removed' | 'failed' | 'skipped';
  212. }
  213. export function defaultRemoveDeps(): RemoveBinaryDeps {
  214. return {
  215. platform: process.platform,
  216. execPath: process.execPath,
  217. rm: (p) => fs.rmSync(p, { recursive: true, force: true }),
  218. rename: (from, to) => fs.renameSync(from, to),
  219. run: (cmd, args) => {
  220. const res = spawnSync(cmd, args, { stdio: 'inherit', windowsHide: true, timeout: 120_000 });
  221. return typeof res.status === 'number' ? res.status : -1;
  222. },
  223. };
  224. }
  225. /**
  226. * Rename the running (locked) node.exe out of `dir` so the rest of the tree
  227. * deletes cleanly — Windows allows renaming a mapped image, just not deleting
  228. * it. Returns the leftover path, or null when nothing needed moving.
  229. */
  230. function moveLockedExeAside(dir: string, deps: RemoveBinaryDeps): string | null {
  231. if (deps.platform !== 'win32') return null;
  232. const P = pathFor(deps.platform);
  233. const resolvedDir = P.resolve(dir) + P.sep;
  234. if (!P.resolve(deps.execPath).startsWith(resolvedDir)) return null;
  235. const leftover = P.join(P.dirname(P.resolve(dir)), `codegraph-old-node-${process.pid}.exe`);
  236. try {
  237. deps.rename(deps.execPath, leftover);
  238. return leftover;
  239. } catch {
  240. return null; // rename failed too — the rm error will surface the dir
  241. }
  242. }
  243. export function executeBinaryRemoval(
  244. plan: BinaryRemovalPlan,
  245. deps: RemoveBinaryDeps = defaultRemoveDeps(),
  246. ): BinaryRemovalResult {
  247. const result: BinaryRemovalResult = { removed: [], leftovers: [], npm: 'skipped' };
  248. const P = pathFor(deps.platform);
  249. for (const p of plan.paths) {
  250. // Planner-emitted paths are always deep, specific artifacts; this guard
  251. // exists so no future planner bug can ever hand the executor a root.
  252. if (P.resolve(p) === P.parse(P.resolve(p)).root) {
  253. result.leftovers.push(p);
  254. continue;
  255. }
  256. try {
  257. deps.rm(p);
  258. result.removed.push(p);
  259. } catch {
  260. // Windows: the running exe inside this tree is deletable-after-rename.
  261. const moved = moveLockedExeAside(p, deps);
  262. try {
  263. deps.rm(p);
  264. result.removed.push(p);
  265. if (moved) result.leftovers.push(moved);
  266. } catch {
  267. result.leftovers.push(p);
  268. }
  269. }
  270. }
  271. if (plan.npmGlobal) {
  272. // If we are RUNNING from the npm install on Windows, npm's delete will
  273. // hit the same lock — move the exe aside first.
  274. let moved: string | null = null;
  275. if (plan.npmRoot) moved = moveLockedExeAside(plan.npmRoot, deps);
  276. const inv = npmInvocation(deps.platform, ['uninstall', '-g', NPM_PACKAGE]);
  277. const code = deps.run(inv.cmd, inv.args);
  278. result.npm = code === 0 ? 'removed' : 'failed';
  279. if (moved) result.leftovers.push(moved);
  280. }
  281. return result;
  282. }