Files
codex-market-app/electron/lib/codex.js
T
2026-07-15 18:01:56 +08:00

369 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 CODEX_HOME = path.join(os.homedir(), ".codex");
const CONFIG_PATH = path.join(CODEX_HOME, "config.toml");
const ENABLED_FILE = "enabled-plugins.toml"; // 备份包里记录启用状态的文件名
// 打包时【绝对排除】的隐私 / 垃圾文件(就算勾了也不会被打包上传)
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 srcText = fs.existsSync(CONFIG_PATH)
? fs.readFileSync(CONFIG_PATH, "utf-8")
: "";
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;
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
let text = fs.existsSync(CONFIG_PATH)
? fs.readFileSync(CONFIG_PATH, "utf-8")
: "";
const existing = configPluginHeaders(text);
const toAdd = keys.filter((k) => !existing.has(k));
if (!toAdd.length) {
log(" 启用状态已是最新,config.toml 无需改动");
return;
}
// 备份
if (fs.existsSync(CONFIG_PATH)) {
const bak = CONFIG_PATH + ".bak-exporter"; // config.toml.bak-exporter
fs.copyFileSync(CONFIG_PATH, bak);
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(CONFIG_PATH, text + blocks.join("\n"), "utf-8");
log(` 已在 config.toml 启用 ${toAdd.length} 个插件:${toAdd.join(", ")}`);
}
// 把选中的(来自 clone 的)项拷回 ~/.codex,并可选地在 config.toml 里启用插件。
function restoreItems(selected, log, enable = true) {
for (const it of selected) {
const dst = path.join(CODEX_HOME, it.rel);
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 生效");
}
module.exports = {
CODEX_HOME,
CONFIG_PATH,
ENABLED_FILE,
humanSize,
dirSize,
scanBase,
copyClean,
pluginKey,
configPluginHeaders,
packageItems,
enablePluginsInConfig,
restoreItems,
};