* 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>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|