369 lines
11 KiB
JavaScript
369 lines
11 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 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,
|
|||
|
|
};
|