384 lines
12 KiB
JavaScript
384 lines
12 KiB
JavaScript
// 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");
|
||
|
||
const DEFAULT_CODEX_HOME = path.join(os.homedir(), ".codex");
|
||
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");
|
||
}
|
||
|
||
// 打包时【绝对排除】的隐私 / 垃圾文件(就算勾了也不会被打包上传)
|
||
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")
|
||
: "";
|
||
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")
|
||
: "";
|
||
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);
|
||
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");
|
||
log(` 已在 config.toml 启用 ${toAdd.length} 个插件:${toAdd.join(", ")}`);
|
||
}
|
||
|
||
// 把选中的(来自 clone 的)项拷回 ~/.codex,并可选地在 config.toml 里启用插件。
|
||
function restoreItems(selected, log, enable = true) {
|
||
const codexHome = getCodexHome();
|
||
for (const it of selected) {
|
||
const dst = path.join(codexHome, 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 生效");
|
||
}
|
||
|
||
const api = {
|
||
getCodexHome,
|
||
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;
|