177 lines
6.1 KiB
JavaScript
177 lines
6.1 KiB
JavaScript
// purge.js —— 检测本机 Codex 的所有安装位置,并「强制清除」。
|
||
// 跨平台(macOS / Windows / Linux)自动识别:数据目录、PATH 里的可执行文件、
|
||
// npm 全局包、Homebrew 安装等。用于「更新后中转失效 → 彻底清干净再重装」。
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const os = require("os");
|
||
const { exec } = require("child_process");
|
||
|
||
const codex = require("./codex");
|
||
|
||
const IS_WIN = process.platform === "win32";
|
||
|
||
// 跑一条 shell 命令,带超时;失败返回 null(用于探测,绝不抛错)。
|
||
function run(command) {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
exec(command, { timeout: 8000, windowsHide: true }, (err, stdout) => {
|
||
if (err) return resolve(null);
|
||
resolve(String(stdout || "").trim() || null);
|
||
});
|
||
} catch (e) {
|
||
resolve(null);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 某个路径存在吗?(含符号链接本身)
|
||
function lstat(p) {
|
||
try {
|
||
return fs.lstatSync(p);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 求路径体积:目录递归、文件取 size、符号链接算 0。
|
||
function sizeOf(p, st) {
|
||
if (!st) return 0;
|
||
if (st.isSymbolicLink()) return 0;
|
||
if (st.isDirectory()) return codex.dirSize(p);
|
||
try {
|
||
return fs.statSync(p).size;
|
||
} catch (e) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// 从可执行文件的真实路径回溯出 @openai/codex 包根目录。
|
||
// 例:.../@openai/codex/bin/codex.js -> .../@openai/codex
|
||
function pkgRootFromBin(realBin) {
|
||
let dir = path.dirname(realBin);
|
||
for (let i = 0; i < 6; i++) {
|
||
if (
|
||
path.basename(dir) === "codex" &&
|
||
path.basename(path.dirname(dir)) === "@openai"
|
||
) {
|
||
return dir;
|
||
}
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 探测所有可能的 Codex 安装位置。返回去重后的候选清单(含 exists / size)。
|
||
async function detectInstalls() {
|
||
const home = os.homedir();
|
||
// key(绝对路径)-> 候选
|
||
const found = new Map();
|
||
|
||
const add = (label, kind, p) => {
|
||
if (!p) return;
|
||
let abs;
|
||
try {
|
||
abs = path.resolve(p);
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
if (found.has(abs)) return;
|
||
found.set(abs, { label, kind, path: abs });
|
||
};
|
||
|
||
// 1) 数据目录(配置 / 登录凭证 / 会话 / 插件 / 技能)——始终列出
|
||
add("Codex 数据目录(配置 · 登录 · 会话 · 插件)", "data", path.join(home, ".codex"));
|
||
|
||
// 2) PATH 里的可执行文件(which / where 可能返回多行)
|
||
const whichOut = IS_WIN ? await run("where codex") : await run("which -a codex");
|
||
if (whichOut) {
|
||
for (const line of whichOut.split(/\r?\n/)) {
|
||
const bin = line.trim();
|
||
if (!bin) continue;
|
||
add("Codex 可执行文件(PATH)", "bin", bin);
|
||
// 顺着符号链接找到真实文件,再回溯 npm 包根
|
||
try {
|
||
const real = fs.realpathSync(bin);
|
||
if (real !== bin) add("Codex 可执行文件(真实位置)", "bin", real);
|
||
const root = pkgRootFromBin(real);
|
||
if (root) add("@openai/codex 安装包", "npm-package", root);
|
||
} catch (e) {
|
||
/* 断链/权限:忽略 */
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3) npm 全局包与 npm 全局 bin
|
||
const npmRoot = await run(IS_WIN ? "npm.cmd root -g" : "npm root -g");
|
||
if (npmRoot) add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmRoot, "@openai", "codex"));
|
||
|
||
const npmPrefix = await run(IS_WIN ? "npm.cmd prefix -g" : "npm prefix -g");
|
||
if (npmPrefix) {
|
||
const binDir = IS_WIN ? npmPrefix : path.join(npmPrefix, "bin");
|
||
for (const n of ["codex", "codex.cmd", "codex.ps1", "codex.exe"]) {
|
||
add("Codex 启动脚本(npm bin)", "bin", path.join(binDir, n));
|
||
}
|
||
add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmPrefix, IS_WIN ? "node_modules" : "lib/node_modules", "@openai", "codex"));
|
||
}
|
||
|
||
// 4) 各平台常见固定位置兜底
|
||
if (IS_WIN) {
|
||
const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
||
for (const n of ["codex", "codex.cmd", "codex.ps1"]) {
|
||
add("Codex 启动脚本(%APPDATA%\\npm)", "bin", path.join(appdata, "npm", n));
|
||
}
|
||
add("@openai/codex 安装包(%APPDATA%\\npm)", "npm-package", path.join(appdata, "npm", "node_modules", "@openai", "codex"));
|
||
} else {
|
||
// macOS / Linux:Homebrew 与常见全局前缀
|
||
for (const prefix of ["/opt/homebrew", "/usr/local", path.join(home, ".npm-global")]) {
|
||
add("Codex 可执行文件(" + prefix + ")", "bin", path.join(prefix, "bin", "codex"));
|
||
add("@openai/codex 安装包(" + prefix + ")", "npm-package", path.join(prefix, "lib", "node_modules", "@openai", "codex"));
|
||
}
|
||
}
|
||
|
||
// 收尾:补 exists / size,只保留真实存在的项
|
||
const items = [];
|
||
for (const c of found.values()) {
|
||
const st = lstat(c.path);
|
||
if (!st) continue; // 不存在的不展示
|
||
items.push({
|
||
...c,
|
||
exists: true,
|
||
isSymlink: st.isSymbolicLink(),
|
||
isDir: st.isDirectory() && !st.isSymbolicLink(),
|
||
size: sizeOf(c.path, st),
|
||
sizeText: codex.humanSize(sizeOf(c.path, st)),
|
||
});
|
||
}
|
||
// 数据目录排最前,其余按体积降序
|
||
items.sort((a, b) => {
|
||
if (a.kind === "data") return -1;
|
||
if (b.kind === "data") return 1;
|
||
return b.size - a.size;
|
||
});
|
||
return items;
|
||
}
|
||
|
||
// 强制删除给定路径。为安全起见,只删除「当前探测得到」的路径,
|
||
// 防止渲染进程传入任意路径。返回每一项的删除结果。
|
||
async function purge(requestedPaths) {
|
||
const wanted = new Set((requestedPaths || []).map((p) => path.resolve(p)));
|
||
const detected = await detectInstalls();
|
||
const allowed = detected.filter((d) => wanted.has(d.path));
|
||
|
||
const results = [];
|
||
for (const item of allowed) {
|
||
try {
|
||
fs.rmSync(item.path, { recursive: true, force: true });
|
||
results.push({ path: item.path, label: item.label, ok: true, stillExists: !!lstat(item.path) });
|
||
} catch (e) {
|
||
results.push({ path: item.path, label: item.label, ok: false, error: String(e && e.message || e) });
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
module.exports = { detectInstalls, purge };
|