remove-binary.test.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. /**
  2. * `codegraph uninstall` — CLI binary removal (the #1071 shadow, uninstall
  3. * edition).
  4. *
  5. * Before this feature, `codegraph uninstall` removed agent configs only:
  6. * a user with both a bundle install and an npm global install (the shadow
  7. * scenario) still had a working `codegraph` on PATH afterward. The planner
  8. * must find EVERY install present on the machine — not just the one the
  9. * running binary belongs to — and the executor must remove them all, with
  10. * the Windows locked-exe rename dance instead of a hard failure.
  11. */
  12. import { describe, it, expect } from 'vitest';
  13. import * as path from 'path';
  14. import {
  15. planBinaryRemoval,
  16. executeBinaryRemoval,
  17. type RemoveBinaryProbes,
  18. type BinaryRemovalPlan,
  19. type RemoveBinaryDeps,
  20. } from '../src/upgrade/remove-binary';
  21. import { NPM_PACKAGE, npmInvocation } from '../src/upgrade';
  22. const HOME = '/home/u';
  23. const STATE = `${HOME}/.codegraph`;
  24. function probes(over: Partial<RemoveBinaryProbes> & { present?: Set<string>; links?: Map<string, string> }): RemoveBinaryProbes {
  25. const present = over.present ?? new Set<string>();
  26. const links = over.links ?? new Map<string, string>();
  27. return {
  28. filename: `${STATE}/versions/v1.4.0/lib/dist/bin/codegraph.js`,
  29. platform: 'linux',
  30. cwd: `${HOME}/project`,
  31. env: {},
  32. homedir: HOME,
  33. exists: (p) => present.has(p) || links.has(p),
  34. readlink: (p) => links.get(p) ?? null,
  35. capture: () => null, // no npm unless a test injects one
  36. ...over,
  37. };
  38. }
  39. /** A standard unix bundle install under ~/.codegraph, running from it. */
  40. function bundlePresent(): Set<string> {
  41. const root = `${STATE}/versions/v1.4.0`;
  42. return new Set([
  43. STATE,
  44. `${STATE}/versions`,
  45. `${root}/node`,
  46. `${root}/bin/codegraph`,
  47. ]);
  48. }
  49. describe('planBinaryRemoval', () => {
  50. it('unix bundle at ~/.codegraph: removes artifacts only, never the state dir itself', () => {
  51. const links = new Map([
  52. [`${STATE}/current`, `${STATE}/versions/v1.4.0`],
  53. [`${HOME}/.local/bin/codegraph`, `${STATE}/versions/v1.4.0/bin/codegraph`],
  54. ]);
  55. const plan = planBinaryRemoval(probes({ present: bundlePresent(), links }));
  56. expect(plan.paths).toContain(`${STATE}/versions`);
  57. expect(plan.paths).toContain(`${STATE}/current`);
  58. expect(plan.paths).toContain(`${HOME}/.local/bin/codegraph`);
  59. // The state dir (telemetry choice, daemon records) must survive.
  60. expect(plan.paths).not.toContain(STATE);
  61. expect(plan.npmGlobal).toBe(false);
  62. expect(plan.sourceRoot).toBeNull();
  63. });
  64. it('a custom CODEGRAPH_INSTALL_DIR is removed wholesale (it is not the state dir)', () => {
  65. const dir = '/opt/cg';
  66. const present = new Set([dir, `${dir}/versions`, `${dir}/versions/v1.4.0/node`, `${dir}/versions/v1.4.0/bin/codegraph`]);
  67. const plan = planBinaryRemoval(probes({
  68. filename: `${dir}/versions/v1.4.0/lib/dist/bin/codegraph.js`,
  69. env: { CODEGRAPH_INSTALL_DIR: dir },
  70. present,
  71. }));
  72. expect(plan.paths).toContain(dir);
  73. expect(plan.paths).not.toContain(`${dir}/versions`); // covered by the whole dir
  74. });
  75. it('npm global install is found by asking npm, even when running from a bundle (the shadow case)', () => {
  76. const npmRoot = '/usr/local/lib/node_modules';
  77. const present = bundlePresent();
  78. present.add(`${npmRoot}/${NPM_PACKAGE}`);
  79. const plan = planBinaryRemoval(probes({
  80. present,
  81. capture: (cmd, args) =>
  82. cmd === 'npm' && args.join(' ') === 'root -g' ? { code: 0, stdout: `${npmRoot}\n` } : null,
  83. }));
  84. expect(plan.npmGlobal).toBe(true);
  85. expect(plan.npmRoot).toBe(npmRoot);
  86. expect(plan.paths).toContain(`${STATE}/versions`); // both installs planned
  87. expect(plan.summary.some((s) => s.includes(NPM_PACKAGE))).toBe(true);
  88. });
  89. it('npm not installed / no global package → npmGlobal false', () => {
  90. const plan = planBinaryRemoval(probes({
  91. present: bundlePresent(),
  92. capture: () => ({ code: 0, stdout: '/usr/local/lib/node_modules\n' }), // root exists, package doesn't
  93. }));
  94. expect(plan.npmGlobal).toBe(false);
  95. });
  96. it('a source checkout is reported and never listed for deletion', () => {
  97. const repo = `${HOME}/dev/codegraph`;
  98. const present = new Set([`${repo}/package.json`, `${repo}/.git`]);
  99. const plan = planBinaryRemoval(probes({
  100. filename: `${repo}/dist/bin/codegraph.js`,
  101. present,
  102. }));
  103. expect(plan.sourceRoot).toBe(repo);
  104. expect(plan.paths).toHaveLength(0);
  105. });
  106. it('a bin-dir shim pointing somewhere ELSE is left alone', () => {
  107. const links = new Map([
  108. [`${STATE}/current`, `${STATE}/versions/v1.4.0`],
  109. [`${HOME}/.local/bin/codegraph`, '/usr/local/other-tool/bin/codegraph'],
  110. ]);
  111. const plan = planBinaryRemoval(probes({ present: bundlePresent(), links }));
  112. expect(plan.paths).not.toContain(`${HOME}/.local/bin/codegraph`);
  113. });
  114. it('CODEGRAPH_BIN_DIR override is honored for the shim', () => {
  115. const links = new Map([
  116. [`${STATE}/current`, `${STATE}/versions/v1.4.0`],
  117. ['/opt/bin/codegraph', `${STATE}/versions/v1.4.0/bin/codegraph`],
  118. ]);
  119. const plan = planBinaryRemoval(probes({
  120. env: { CODEGRAPH_BIN_DIR: '/opt/bin' },
  121. present: bundlePresent(),
  122. links,
  123. }));
  124. expect(plan.paths).toContain('/opt/bin/codegraph');
  125. });
  126. it('nothing installed → empty plan', () => {
  127. const plan = planBinaryRemoval(probes({ filename: '/somewhere/odd/codegraph.js' }));
  128. expect(plan.paths).toHaveLength(0);
  129. expect(plan.npmGlobal).toBe(false);
  130. expect(plan.summary).toHaveLength(0);
  131. });
  132. });
  133. describe('executeBinaryRemoval', () => {
  134. function deps(over: Partial<RemoveBinaryDeps> & { rmFails?: Set<string>; calls?: string[] }): RemoveBinaryDeps & { calls: string[] } {
  135. const calls = over.calls ?? [];
  136. const rmFails = over.rmFails ?? new Set<string>();
  137. return {
  138. platform: 'linux',
  139. execPath: '/usr/bin/node',
  140. rm: (p) => {
  141. if (rmFails.has(p)) { rmFails.delete(p); throw new Error('EBUSY'); }
  142. calls.push(`rm ${p}`);
  143. },
  144. rename: (from, to) => calls.push(`mv ${from} ${to}`),
  145. run: (cmd, args) => { calls.push(`run ${cmd} ${args.join(' ')}`); return 0; },
  146. calls,
  147. ...over,
  148. };
  149. }
  150. const plan = (over: Partial<BinaryRemovalPlan> = {}): BinaryRemovalPlan => ({
  151. paths: [],
  152. npmGlobal: false,
  153. npmRoot: null,
  154. sourceRoot: null,
  155. summary: [],
  156. ...over,
  157. });
  158. it('removes planned paths and runs npm uninstall -g', () => {
  159. const d = deps({});
  160. const result = executeBinaryRemoval(
  161. plan({ paths: [`${STATE}/versions`, `${STATE}/current`], npmGlobal: true, npmRoot: '/usr/local/lib/node_modules' }),
  162. d,
  163. );
  164. expect(result.removed).toEqual([`${STATE}/versions`, `${STATE}/current`]);
  165. expect(result.npm).toBe('removed');
  166. expect(d.calls).toContain(`run npm uninstall -g ${NPM_PACKAGE}`);
  167. expect(result.leftovers).toHaveLength(0);
  168. });
  169. it('npm failure is reported, not thrown', () => {
  170. const d = deps({ run: () => 1 });
  171. const result = executeBinaryRemoval(plan({ npmGlobal: true }), d);
  172. expect(result.npm).toBe('failed');
  173. });
  174. it('windows: a locked exe inside the tree is renamed aside, then the tree deletes', () => {
  175. const dir = 'C:\\Users\\u\\AppData\\Local\\codegraph';
  176. const exe = path.join(dir, 'current', 'node.exe');
  177. const d = deps({ platform: 'win32', execPath: exe, rmFails: new Set([dir]) });
  178. const result = executeBinaryRemoval(plan({ paths: [dir] }), d);
  179. expect(result.removed).toEqual([dir]);
  180. // The renamed exe is surfaced as a leftover for the user to delete.
  181. expect(result.leftovers).toHaveLength(1);
  182. expect(result.leftovers[0]).toContain('codegraph-old-node-');
  183. expect(d.calls.some((c) => c.startsWith(`mv ${exe} `))).toBe(true);
  184. });
  185. it('an unremovable path becomes a leftover, never an exception', () => {
  186. const d = deps({ rm: () => { throw new Error('EPERM'); } });
  187. const result = executeBinaryRemoval(plan({ paths: ['/opt/cg'] }), d);
  188. expect(result.removed).toHaveLength(0);
  189. expect(result.leftovers).toEqual(['/opt/cg']);
  190. });
  191. it('refuses a filesystem root even if a planner bug ever emitted one', () => {
  192. const d = deps({});
  193. const result = executeBinaryRemoval(plan({ paths: ['/'] }), d);
  194. expect(result.removed).toHaveLength(0);
  195. expect(result.leftovers).toEqual(['/']);
  196. expect(d.calls).toHaveLength(0);
  197. });
  198. });
  199. describe('npmInvocation', () => {
  200. it('unix: plain npm', () => {
  201. expect(npmInvocation('linux', ['root', '-g'])).toEqual({ cmd: 'npm', args: ['root', '-g'] });
  202. });
  203. it('windows: routed through cmd.exe (npm is a .cmd — direct spawn EINVALs on modern Node)', () => {
  204. const inv = npmInvocation('win32', ['uninstall', '-g', NPM_PACKAGE]);
  205. expect(inv.cmd).toBe('cmd.exe');
  206. expect(inv.args.slice(0, 3)).toEqual(['/d', '/s', '/c']);
  207. expect(inv.args[3]).toBe(`npm uninstall -g ${NPM_PACKAGE}`);
  208. });
  209. });