Files
codex-market-app/electron/lib/codex.js
T

384 lines
12 KiB
JavaScript
Raw Normal View History

2026-07-15 18:01:56 +08:00
// codex.js —— 扫描 ~/.codex、打包(排除隐私文件 + 生成辅助文件)、还原、config.toml 启用
// 忠实移植自 codex_exporter.py,纯 Node(fs / path / os)。
const fs = require("fs");
const path = require("path");
const os = require("os");
const codexDataDir = require("./codex_data_dir");
2026-07-15 18:01:56 +08:00
const DEFAULT_CODEX_HOME = path.join(os.homedir(), ".codex");
2026-07-15 18:01:56 +08:00
const ENABLED_FILE = "enabled-plugins.toml"; // 备份包里记录启用状态的文件名
function getCodexHome() {
try { return codexDataDir.get(); } catch { return DEFAULT_CODEX_HOME; }
}
function getConfigPath() {
return path.join(getCodexHome(), "config.toml");
}
2026-07-15 18:01:56 +08:00
// 打包时【绝对排除】的隐私 / 垃圾文件(就算勾了也不会被打包上传)
const BLOCKLIST_NAMES = new Set([
"auth.json",
"config.toml",
"history.jsonl",
".DS_Store",
"installation_id",
".codex-global-state.json",
".codex-global-state.json.bak",
]);
const BLOCKLIST_SUFFIX = [".sqlite", ".sqlite-shm", ".sqlite-wal", ".log", ".bak"];
function isBlocked(name) {
if (BLOCKLIST_NAMES.has(name)) return true;
return BLOCKLIST_SUFFIX.some((s) => name.endsWith(s));
}
function humanSize(num) {
const units = ["B", "K", "M", "G"];
let n = num;
for (const unit of units) {
if (n < 1024) {
return unit === "B" ? `${Math.round(n)}${unit}` : `${n.toFixed(1)}${unit}`;
}
n /= 1024;
}
return `${n.toFixed(1)}T`;
}
// 递归求目录大小
function dirSize(dir) {
let total = 0;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return 0;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
try {
if (ent.isDirectory()) {
total += dirSize(full);
} else if (ent.isFile()) {
total += fs.statSync(full).size;
}
} catch (e) {
/* 忽略无法访问的文件 */
}
}
return total;
}
// 扫描一个 codex 结构目录(可以是 ~/.codex,也可以是 clone 下来的备份)。
// 返回 [{category, source, name, path, size, rel}],rel 相对 base(用 "/" 分隔)。
function scanBase(base) {
const items = [];
const pluginsCache = path.join(base, "plugins", "cache");
const skillsDir = path.join(base, "skills");
if (isDir(pluginsCache)) {
for (const source of sortedDirNames(pluginsCache)) {
if (source.startsWith(".")) continue;
const sourcePath = path.join(pluginsCache, source);
if (!isDir(sourcePath)) continue;
for (const plugin of sortedDirNames(sourcePath)) {
if (plugin.startsWith(".")) continue;
const pluginPath = path.join(sourcePath, plugin);
if (!isDir(pluginPath)) continue;
items.push({
category: "插件 Plugin",
source,
name: plugin,
path: pluginPath,
size: dirSize(pluginPath),
rel: `plugins/cache/${source}/${plugin}`,
});
}
}
}
if (isDir(skillsDir)) {
for (const skill of sortedDirNames(skillsDir)) {
if (skill.startsWith(".")) continue;
const skillPath = path.join(skillsDir, skill);
if (!isDir(skillPath)) continue;
items.push({
category: "技能 Skill",
source: "skills",
name: skill,
path: skillPath,
size: dirSize(skillPath),
rel: `skills/${skill}`,
});
}
}
return items;
}
function isDir(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function sortedDirNames(dir) {
try {
return fs.readdirSync(dir).sort();
} catch (e) {
return [];
}
}
// 拷贝目录,跳过隐私 / 垃圾文件(对齐 python copy_clean)。
function copyClean(src, dst) {
fs.mkdirSync(dst, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const ent of entries) {
if (isBlocked(ent.name)) continue;
const srcPath = path.join(src, ent.name);
const dstPath = path.join(dst, ent.name);
if (ent.isDirectory()) {
copyClean(srcPath, dstPath);
} else if (ent.isSymbolicLink()) {
try {
const link = fs.readlinkSync(srcPath);
try {
fs.unlinkSync(dstPath);
} catch (e) {
/* 目标不存在 */
}
fs.symlinkSync(link, dstPath);
} catch (e) {
/* 忽略坏链接 */
}
} else if (ent.isFile()) {
fs.copyFileSync(srcPath, dstPath);
}
}
}
// 插件在 config.toml 里的登记键: name@source 。技能返回 null。
function pluginKey(item) {
if (item.category.startsWith("插件")) {
return `${item.name}@${item.source}`;
}
return null;
}
// 从 config.toml 文本里取出所有已登记的 [plugins."KEY"] 键。
function configPluginHeaders(text) {
const set = new Set();
const re = /\[plugins\."([^"]+)"\]/g;
let m;
while ((m = re.exec(text)) !== null) {
set.add(m[1]);
}
return set;
}
// 把选中插件在本机的启用状态,写进备份包的 enabled-plugins.toml。返回记录的 keys。
function writeEnabledFile(selected, staging) {
const configPath = getConfigPath();
const srcText = fs.existsSync(configPath)
? fs.readFileSync(configPath, "utf-8")
2026-07-15 18:01:56 +08:00
: "";
const enabledNow = configPluginHeaders(srcText);
const keys = selected
.map((it) => pluginKey(it))
.filter((k) => k && enabledNow.has(k));
const lines = [
"# 由 Codex 备份工具生成:仅记录插件启用状态,不含任何敏感配置",
"# 还原时会把这些插件在新机器的 config.toml 里标记为 enabled\n",
];
for (const k of keys) {
lines.push(`[plugins."${k}"]`);
lines.push("enabled = true\n");
}
fs.writeFileSync(path.join(staging, ENABLED_FILE), lines.join("\n"), "utf-8");
return keys;
}
// 把选中的项拷进 staging,保持相对结构,并生成辅助文件。
function packageItems(selected, staging, log) {
if (fs.existsSync(staging)) {
fs.rmSync(staging, { recursive: true, force: true });
}
fs.mkdirSync(staging, { recursive: true });
for (const it of selected) {
const dst = path.join(staging, it.rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
log(` 打包 ${it.category}: ${it.name} (${humanSize(it.size)})`);
copyClean(it.path, dst);
}
fs.writeFileSync(
path.join(staging, ".gitignore"),
[
"# 隐私 / 凭证,绝对不上传",
"auth.json",
"config.toml",
"history.jsonl",
"*.sqlite",
"*.sqlite-*",
"*.log",
"*.bak",
".DS_Store",
"installation_id",
].join("\n") + "\n",
"utf-8"
);
const restorePath = path.join(staging, "restore.sh");
fs.writeFileSync(
restorePath,
"#!/bin/bash\n" +
"# 在新电脑上运行本脚本,把插件/技能还原到 ~/.codex\n" +
"set -e\n" +
'DIR="$(cd "$(dirname "$0")" && pwd)"\n' +
'mkdir -p "$HOME/.codex/plugins/cache" "$HOME/.codex/skills"\n' +
'[ -d "$DIR/plugins/cache" ] && cp -R "$DIR/plugins/cache/." "$HOME/.codex/plugins/cache/" && echo "已还原插件"\n' +
'[ -d "$DIR/skills" ] && cp -R "$DIR/skills/." "$HOME/.codex/skills/" && echo "已还原技能"\n' +
'echo "完成 ✅ 重启 codex 生效"\n',
"utf-8"
);
try {
fs.chmodSync(restorePath, 0o755);
} catch (e) {
/* 忽略 */
}
// Windows 还原:restore.ps1(PowerShell 逻辑,用正斜杠,Windows 也接受)
fs.writeFileSync(
path.join(staging, "restore.ps1"),
[
'# 在 Windows 上运行:把插件/技能还原到 %USERPROFILE%\\.codex',
'$ErrorActionPreference = "Stop"',
'$dir = Split-Path -Parent $MyInvocation.MyCommand.Definition',
'$codex = "$env:USERPROFILE/.codex"',
'New-Item -ItemType Directory -Force -Path "$codex/plugins/cache" | Out-Null',
'New-Item -ItemType Directory -Force -Path "$codex/skills" | Out-Null',
'if (Test-Path "$dir/plugins/cache") {',
' Copy-Item -Recurse -Force -Path "$dir/plugins/cache/*" -Destination "$codex/plugins/cache"',
' Write-Host "已还原插件"',
'}',
'if (Test-Path "$dir/skills") {',
' Copy-Item -Recurse -Force -Path "$dir/skills/*" -Destination "$codex/skills"',
' Write-Host "已还原技能"',
'}',
'Write-Host "完成,重启 codex 生效"',
].join("\r\n") + "\r\n",
"utf-8"
);
// Windows 还原:restore.bat(双击即用,内部以 Bypass 策略调用 restore.ps1)
fs.writeFileSync(
path.join(staging, "restore.bat"),
[
"@echo off",
"chcp 65001 >nul",
'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0restore.ps1"',
"echo.",
"pause",
].join("\r\n") + "\r\n",
"utf-8"
);
const lines = [
"# Codex 插件 / 技能 备份\n",
"由 Codex 备份工具生成。**已自动排除凭证文件**。\n",
"## 包含内容\n",
];
for (const it of selected) {
lines.push(`- \`${it.rel}\` (${humanSize(it.size)})`);
}
lines.push(
"\n## 在新电脑上还原",
"推荐用本工具的「从 GitHub 拉取还原」标签页;也可手动:\n",
"**macOS / Linux:**",
"```bash",
"bash restore.sh",
"```",
"**Windows:** 双击 `restore.bat`(或在 PowerShell 里运行 `restore.ps1`)\n",
"还原后重启 codex 即可。\n"
);
fs.writeFileSync(path.join(staging, "README.md"), lines.join("\n"), "utf-8");
const enabledKeys = writeEnabledFile(selected, staging);
if (enabledKeys.length) {
log(` 已记录 ${enabledKeys.length} 个插件的启用状态`);
}
log("打包完成 ✅");
}
// 把 keys 里缺失的插件登记项【追加】进本机 config.toml(带备份,不覆盖已有项)。
function enablePluginsInConfig(keys, log) {
keys = keys.filter((k) => k);
if (!keys.length) return;
const configPath = getConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
let text = fs.existsSync(configPath)
? fs.readFileSync(configPath, "utf-8")
2026-07-15 18:01:56 +08:00
: "";
const existing = configPluginHeaders(text);
const toAdd = keys.filter((k) => !existing.has(k));
if (!toAdd.length) {
log(" 启用状态已是最新,config.toml 无需改动");
return;
}
// 备份
if (fs.existsSync(configPath)) {
const bak = configPath + ".bak-exporter"; // config.toml.bak-exporter
fs.copyFileSync(configPath, bak);
2026-07-15 18:01:56 +08:00
log(` 已备份 config.toml -> ${path.basename(bak)}`);
}
if (text && !text.endsWith("\n")) {
text += "\n";
}
const blocks = ["", "# --- 由 Codex 备份工具还原的插件启用项 ---"];
for (const k of toAdd) {
blocks.push(`[plugins."${k}"]`);
blocks.push("enabled = true");
blocks.push("");
}
fs.writeFileSync(configPath, text + blocks.join("\n"), "utf-8");
2026-07-15 18:01:56 +08:00
log(` 已在 config.toml 启用 ${toAdd.length} 个插件:${toAdd.join(", ")}`);
}
// 把选中的(来自 clone 的)项拷回 ~/.codex,并可选地在 config.toml 里启用插件。
function restoreItems(selected, log, enable = true) {
const codexHome = getCodexHome();
2026-07-15 18:01:56 +08:00
for (const it of selected) {
const dst = path.join(codexHome, it.rel);
2026-07-15 18:01:56 +08:00
fs.mkdirSync(path.dirname(dst), { recursive: true });
log(` 还原 ${it.category}: ${it.name} -> ${dst}`);
copyClean(it.path, dst);
}
if (enable) {
const keys = selected.map((it) => pluginKey(it));
enablePluginsInConfig(keys, log);
}
log("还原完成 ✅ 重启 codex 生效");
}
const api = {
getCodexHome,
2026-07-15 18:01:56 +08:00
ENABLED_FILE,
humanSize,
dirSize,
scanBase,
copyClean,
pluginKey,
configPluginHeaders,
packageItems,
enablePluginsInConfig,
restoreItems,
};
Object.defineProperty(api, "CODEX_HOME", { enumerable: true, get: getCodexHome });
Object.defineProperty(api, "CONFIG_PATH", { enumerable: true, get: getConfigPath });
module.exports = api;