feat: add desktop app hot update channel

This commit is contained in:
mac
2026-07-15 18:01:56 +08:00
parent 2dc15d99ad
commit 322807c68d
45 changed files with 292746 additions and 10 deletions
+176
View File
@@ -0,0 +1,176 @@
// 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 };