// 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, };