fix(uninstall): remove the CLI binaries too, not just agent configs (#1254)

* fix(uninstall): remove the CLI binaries too, not just agent configs (#1071)

`codegraph uninstall` swept agent configurations and stopped — every
installed binary stayed behind, so `codegraph` still ran afterward. Three
disconnected paths each removed a fraction of an installation (uninstall:
configs; install.sh --uninstall: the bundle; npm preuninstall: configs +
npm's own package), and none cleared a shadowed second install — the
uninstall edition of the #1071 PATH shadow.

The uninstall now PLANS every install present on the machine — the bundle
layout(s) (running binary's own, the platform default, a custom
CODEGRAPH_INSTALL_DIR), the npm global package (found by asking
`npm root -g`, so nvm/fnm/volta prefixes resolve correctly), and the
bin-dir launcher link (only when it verifiably points into a detected
install) — confirms with the user, then removes them all. `--yes` skips
the prompt; the new `--keep-cli` flag keeps the old configs-only behavior.

Safety rules: a source checkout is reported, never deleted; a
project-local npm install is left to the project; on unix the default
install dir doubles as the machine state dir, so only the install
artifacts (versions/, current) are removed there — telemetry choice and
daemon records survive. Windows can't delete a running exe but can rename
it (the in-place upgrade's trick): a locked node.exe is renamed aside and
surfaced as a one-file leftover instead of failing the removal, and npm
is routed through cmd.exe (a direct .cmd spawn EINVALs on modern Node).

Planner/executor are split with injected side effects (the upgrade
orchestrator's convention) and unit-tested across the shadow case,
state-dir preservation, custom dirs, foreign-shim protection, and the
locked-exe dance; validated end-to-end on macOS against a fake HOME.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(uninstall): key path math on the target platform, not the host

Real-Windows validation caught it: the planner/executor used the host
path module, so win32 fixtures were meaningless on a POSIX host and
POSIX fixtures failed on the Windows VM. Same convention as
detectInstallMethod now — path.win32/path.posix chosen by the injected
platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(upgrade): route npm through cmd.exe on Windows — a direct npm.cmd spawn EINVALs on modern Node

Found while validating the uninstall change on the Windows VM: upgradeNpm
spawned npm.cmd without a shell, which every current Node rejects with
EINVAL (the CVE-2024-27980 hardening) — so `codegraph upgrade` on a
Windows npm install failed before doing anything. Verified live on the VM:
spawnSync('npm.cmd') → EINVAL; cmd.exe /d /s /c npm → works.

npmInvocation moves into the upgrade orchestrator (remove-binary imports
it from there — same direction as its existing imports, no cycle), and the
win32 test now pins the WORKING invocation instead of the broken one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-10 17:37:20 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 47823944a3
commit 40aa092f5b
8 changed files with 625 additions and 8 deletions
+18 -3
View File
@@ -610,17 +610,32 @@ function upgradeWindowsBundle(
return 0;
}
/**
* How to invoke npm. On Windows npm is a .cmd batch file, which Node refuses
* to spawn without a shell (EINVAL since the CVE-2024-27980 hardening) — a
* direct `npm.cmd` spawn fails on every current Node, so route it through
* cmd.exe, the same way the surface-refresh step invokes the .cmd launcher.
* (Verified live on the Windows VM: `spawnSync('npm.cmd')` → EINVAL;
* `cmd.exe /d /s /c npm …` → works.)
*/
export function npmInvocation(platform: NodeJS.Platform, npmArgs: string[]): { cmd: string; args: string[] } {
if (platform === 'win32') {
return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', ['npm', ...npmArgs].join(' ')] };
}
return { cmd: 'npm', args: npmArgs };
}
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);
deps.log(c.dim(`Running: npm ${args.join(' ')}`));
const inv = npmInvocation(deps.platform, args);
const code = deps.run(inv.cmd, inv.args, process.env);
if (code !== 0) {
deps.error(`npm exited with code ${code}.`);
if (method.scope === 'global') {
+305
View File
@@ -0,0 +1,305 @@
/**
* CLI binary removal for `codegraph uninstall` (the #1071 shadow, uninstall
* edition).
*
* Before this module, three disconnected paths each removed PART of an
* installation and none removed it all: `codegraph uninstall` swept agent
* configs only, `install.sh --uninstall` deleted the bundle only, and npm's
* `preuninstall` hook cleaned configs when npm removed its own package. A
* user with more than one install method (the common drift: npm first, the
* bundle later — or vice versa) ran `codegraph uninstall` and still had a
* working `codegraph` on PATH.
*
* This module makes `codegraph uninstall` complete: PLAN every binary
* install present on the machine (bundle layout(s), the npm global package,
* the bin-dir shim), then EXECUTE the removals. Split planner/executor with
* injected side effects, same convention as the upgrade orchestrator.
*
* Safety rules:
* - A source checkout is REPORTED, never deleted — a git repo is the
* user's working tree, not an "install".
* - A project-local npm install is left alone — the project's
* package.json owns it, not the machine-level uninstaller.
* - On unix the default install dir (`~/.codegraph`) doubles as the
* machine-level state dir (telemetry choice, daemon records, the
* update-check cache) — only the install ARTIFACTS (`versions/`,
* `current`) are removed there, never the whole dir. A dedicated
* install dir (Windows `%LOCALAPPDATA%\codegraph`, or a custom
* `CODEGRAPH_INSTALL_DIR`) is removed wholesale.
* - The bin-dir shim is removed only when it verifiably points into a
* detected install dir — a user's unrelated `codegraph` file survives.
* - Windows cannot DELETE a running exe but CAN rename it (the same
* trick the in-place upgrade uses): a locked `node.exe` is renamed
* aside and reported as a leftover for the user to delete after the
* window closes, instead of failing the whole removal.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { detectInstallMethod, npmInvocation, NPM_PACKAGE } from './index';
// ---------------------------------------------------------------------------
// Planner (pure — every probe injected)
// ---------------------------------------------------------------------------
export interface RemoveBinaryProbes {
/** `__filename` of the running CLI entry (dist/bin/codegraph.js). */
filename: string;
platform: NodeJS.Platform;
cwd: string;
env: NodeJS.ProcessEnv;
homedir: string;
exists: (p: string) => boolean;
/** Symlink target (raw link text), or null when not a symlink / unreadable. */
readlink: (p: string) => string | null;
/** Run a command capturing stdout; null = spawn failed. */
capture: (cmd: string, args: string[]) => { code: number; stdout: string } | null;
}
export interface BinaryRemovalPlan {
/** Filesystem paths to delete (bundle dirs / artifacts, shim links). */
paths: string[];
/** The npm global package is installed and should be `npm uninstall -g`ed. */
npmGlobal: boolean;
/**
* npm's global node_modules root (for the Windows locked-exe dance — the
* vendored node.exe lives in the SIBLING per-platform package, so the
* whole root is the lock surface, not just the meta package's dir).
*/
npmRoot: string | null;
/** Running from a git checkout — surfaced to the user, never deleted. */
sourceRoot: string | null;
/** One human line per planned removal, for the confirm prompt. */
summary: string[];
}
export function defaultProbes(filename: string): RemoveBinaryProbes {
return {
filename,
platform: process.platform,
cwd: process.cwd(),
env: process.env,
homedir: os.homedir(),
exists: fs.existsSync,
readlink: (p) => {
try { return fs.readlinkSync(p); } catch { return null; }
},
capture: (cmd, args) => {
const res = spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 30_000 });
if (res.error || typeof res.status !== 'number') return null;
return { code: res.status, stdout: res.stdout ?? '' };
},
};
}
/**
* Path math keyed on the TARGET platform (not the host) — same convention as
* `detectInstallMethod`, so the planner is deterministic when unit-tested with
* a win32 fixture on a POSIX host and vice versa. In production the probe's
* platform always matches the running host.
*/
function pathFor(platform: NodeJS.Platform): path.PlatformPath {
return platform === 'win32' ? path.win32 : path.posix;
}
/** The machine-level state dir that must survive an artifacts-only removal. */
function stateDir(p: RemoveBinaryProbes): string {
return pathFor(p.platform).join(p.homedir, '.codegraph');
}
/** Candidate bundle install dirs: the running binary's own, plus the defaults. */
function installDirCandidates(p: RemoveBinaryProbes): string[] {
const P = pathFor(p.platform);
const dirs: string[] = [];
const method = detectInstallMethod({
filename: p.filename,
platform: p.platform,
cwd: p.cwd,
exists: p.exists,
});
if (method.kind === 'bundle' && method.installDir) dirs.push(method.installDir);
if (p.env.CODEGRAPH_INSTALL_DIR) dirs.push(p.env.CODEGRAPH_INSTALL_DIR);
// Platform defaults — probed even when the RUNNING binary is npm/source,
// because the whole point is clearing installs the user forgot about.
if (p.platform === 'win32') {
if (p.env.LOCALAPPDATA) dirs.push(P.join(p.env.LOCALAPPDATA, 'codegraph'));
} else {
dirs.push(stateDir(p));
}
return [...new Set(dirs.map((d) => P.resolve(d)))];
}
export function planBinaryRemoval(p: RemoveBinaryProbes): BinaryRemovalPlan {
const P = pathFor(p.platform);
const plan: BinaryRemovalPlan = {
paths: [],
npmGlobal: false,
npmRoot: null,
sourceRoot: null,
summary: [],
};
const method = detectInstallMethod({
filename: p.filename,
platform: p.platform,
cwd: p.cwd,
exists: p.exists,
});
if (method.kind === 'source') {
plan.sourceRoot = method.root;
}
// --- Bundle install(s) ----------------------------------------------------
const installDirs: string[] = [];
for (const dir of installDirCandidates(p)) {
// A dir counts as a bundle install only when it carries install artifacts.
const artifacts = ['versions', 'current']
.map((a) => P.join(dir, a))
.filter((a) => p.exists(a) || p.readlink(a) !== null); // `current` is a symlink on unix
if (artifacts.length === 0) continue;
installDirs.push(dir);
if (P.resolve(dir) === P.resolve(stateDir(p))) {
// Shared with machine-level state: remove artifacts only.
plan.paths.push(...artifacts);
plan.summary.push(`bundle install at ${dir} (versions/ and current — state files kept)`);
} else {
plan.paths.push(dir);
plan.summary.push(`bundle install at ${dir}`);
}
}
// --- Bin-dir shim (unix installer's symlink) --------------------------------
const binDir = p.env.CODEGRAPH_BIN_DIR
?? (p.platform === 'win32' ? null : P.join(p.homedir, '.local', 'bin'));
if (binDir) {
const shim = P.join(binDir, 'codegraph');
const target = p.readlink(shim);
// Only when the link demonstrably points into a bundle install dir —
// resolved against the link's own directory, since install.sh links an
// absolute target but a hand-made relative link must still verify.
if (target !== null) {
const resolved = P.resolve(binDir, target);
const ours = installDirs.some((d) => resolved.startsWith(P.resolve(d) + P.sep))
|| resolved.includes(`${P.sep}.codegraph${P.sep}`);
if (ours) {
plan.paths.push(shim);
plan.summary.push(`launcher link at ${shim}`);
}
}
}
// --- npm global package -----------------------------------------------------
// Asking npm (not guessing prefixes) keeps this correct under nvm/fnm/volta.
// A LOCAL npm install (project dependency) is deliberately not offered.
const rootInv = npmInvocation(p.platform, ['root', '-g']);
const rootRes = p.capture(rootInv.cmd, rootInv.args);
if (rootRes && rootRes.code === 0) {
const pkgDir = P.join(rootRes.stdout.trim(), NPM_PACKAGE);
if (rootRes.stdout.trim() && p.exists(pkgDir)) {
plan.npmGlobal = true;
plan.npmRoot = rootRes.stdout.trim();
plan.summary.push(`npm global package (${NPM_PACKAGE})`);
}
}
return plan;
}
// ---------------------------------------------------------------------------
// Executor (injected side effects)
// ---------------------------------------------------------------------------
export interface RemoveBinaryDeps {
platform: NodeJS.Platform;
/** The running node binary — the file Windows will hold a lock on. */
execPath: string;
rm: (p: string) => void;
rename: (from: string, to: string) => void;
/** Run a command inheriting stdio; returns exit code (-1 = spawn failed). */
run: (cmd: string, args: string[]) => number;
}
export interface BinaryRemovalResult {
removed: string[];
/** Paths that could not be (fully) removed — surfaced with manual steps. */
leftovers: string[];
npm: 'removed' | 'failed' | 'skipped';
}
export function defaultRemoveDeps(): RemoveBinaryDeps {
return {
platform: process.platform,
execPath: process.execPath,
rm: (p) => fs.rmSync(p, { recursive: true, force: true }),
rename: (from, to) => fs.renameSync(from, to),
run: (cmd, args) => {
const res = spawnSync(cmd, args, { stdio: 'inherit', windowsHide: true, timeout: 120_000 });
return typeof res.status === 'number' ? res.status : -1;
},
};
}
/**
* Rename the running (locked) node.exe out of `dir` so the rest of the tree
* deletes cleanly — Windows allows renaming a mapped image, just not deleting
* it. Returns the leftover path, or null when nothing needed moving.
*/
function moveLockedExeAside(dir: string, deps: RemoveBinaryDeps): string | null {
if (deps.platform !== 'win32') return null;
const P = pathFor(deps.platform);
const resolvedDir = P.resolve(dir) + P.sep;
if (!P.resolve(deps.execPath).startsWith(resolvedDir)) return null;
const leftover = P.join(P.dirname(P.resolve(dir)), `codegraph-old-node-${process.pid}.exe`);
try {
deps.rename(deps.execPath, leftover);
return leftover;
} catch {
return null; // rename failed too — the rm error will surface the dir
}
}
export function executeBinaryRemoval(
plan: BinaryRemovalPlan,
deps: RemoveBinaryDeps = defaultRemoveDeps(),
): BinaryRemovalResult {
const result: BinaryRemovalResult = { removed: [], leftovers: [], npm: 'skipped' };
const P = pathFor(deps.platform);
for (const p of plan.paths) {
// Planner-emitted paths are always deep, specific artifacts; this guard
// exists so no future planner bug can ever hand the executor a root.
if (P.resolve(p) === P.parse(P.resolve(p)).root) {
result.leftovers.push(p);
continue;
}
try {
deps.rm(p);
result.removed.push(p);
} catch {
// Windows: the running exe inside this tree is deletable-after-rename.
const moved = moveLockedExeAside(p, deps);
try {
deps.rm(p);
result.removed.push(p);
if (moved) result.leftovers.push(moved);
} catch {
result.leftovers.push(p);
}
}
}
if (plan.npmGlobal) {
// If we are RUNNING from the npm install on Windows, npm's delete will
// hit the same lock — move the exe aside first.
let moved: string | null = null;
if (plan.npmRoot) moved = moveLockedExeAside(plan.npmRoot, deps);
const inv = npmInvocation(deps.platform, ['uninstall', '-g', NPM_PACKAGE]);
const code = deps.run(inv.cmd, inv.args);
result.npm = code === 0 ? 'removed' : 'failed';
if (moved) result.leftovers.push(moved);
}
return result;
}