Spaces:
Sleeping
Sleeping
| import crypto from "node:crypto"; | |
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| function nowIso() { | |
| return new Date().toISOString(); | |
| } | |
| function validationError(message, statusCode = 400) { | |
| const error = new Error(message); | |
| error.statusCode = statusCode; | |
| return error; | |
| } | |
| function ensureTrailingNewline(text) { | |
| const normalized = String(text ?? "").replace(/\r\n/g, "\n"); | |
| return normalized.endsWith("\n") ? normalized : `${normalized}\n`; | |
| } | |
| function normalizeTomlText(text) { | |
| return String(text ?? "").replace(/\r\n/g, "\n").trimEnd(); | |
| } | |
| function normalizeJsonText(text) { | |
| return `${JSON.stringify(JSON.parse(String(text ?? "")), null, 2)}\n`; | |
| } | |
| async function fileExists(filePath) { | |
| try { | |
| await fs.access(filePath); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function defaultProviderDefinitions(homeDir = os.homedir()) { | |
| return { | |
| codex: { | |
| provider: "codex", | |
| label: "Codex 配置", | |
| targetPaths: [ | |
| { | |
| key: "authJsonText", | |
| label: "auth.json", | |
| path: path.join(homeDir, ".codex", "auth.json"), | |
| kind: "json", | |
| }, | |
| { | |
| key: "configTomlText", | |
| label: "config.toml", | |
| path: path.join(homeDir, ".codex", "config.toml"), | |
| kind: "toml", | |
| }, | |
| ], | |
| }, | |
| "claude-code": { | |
| provider: "claude-code", | |
| label: "Claude Code 配置", | |
| targetPaths: [ | |
| { | |
| key: "settingsJsonText", | |
| label: "settings.json", | |
| path: path.join(homeDir, ".claude", "settings.json"), | |
| kind: "json", | |
| }, | |
| ], | |
| }, | |
| }; | |
| } | |
| function makeEmptyState() { | |
| return { | |
| version: 1, | |
| presetsByProvider: { | |
| codex: [], | |
| "claude-code": [], | |
| }, | |
| lastAppliedPresetIdByProvider: { | |
| codex: null, | |
| "claude-code": null, | |
| }, | |
| lastAppliedAtByProvider: { | |
| codex: null, | |
| "claude-code": null, | |
| }, | |
| }; | |
| } | |
| async function readOptionalText(filePath) { | |
| try { | |
| return await fs.readFile(filePath, "utf8"); | |
| } catch (error) { | |
| if (error?.code === "ENOENT") return ""; | |
| throw error; | |
| } | |
| } | |
| function normalizePresetInput(provider, input = {}) { | |
| return { | |
| id: input.id ? String(input.id) : "", | |
| name: String(input.name ?? "").trim(), | |
| provider, | |
| payload: | |
| provider === "codex" | |
| ? { | |
| authJsonText: String(input.payload?.authJsonText ?? ""), | |
| configTomlText: String(input.payload?.configTomlText ?? ""), | |
| } | |
| : { | |
| settingsJsonText: String(input.payload?.settingsJsonText ?? ""), | |
| }, | |
| }; | |
| } | |
| function validatePreset(provider, preset) { | |
| if (!preset.name) { | |
| throw validationError("name 不能为空"); | |
| } | |
| if (provider === "codex") { | |
| if (!preset.payload.configTomlText.trim()) { | |
| throw validationError("configTomlText 不能为空"); | |
| } | |
| try { | |
| normalizeJsonText(preset.payload.authJsonText); | |
| } catch { | |
| throw validationError("authJsonText 不是合法 JSON"); | |
| } | |
| return; | |
| } | |
| try { | |
| normalizeJsonText(preset.payload.settingsJsonText); | |
| } catch { | |
| throw validationError("settingsJsonText 不是合法 JSON"); | |
| } | |
| } | |
| function compareContent(kind, leftText, rightText) { | |
| if (kind === "json") { | |
| try { | |
| return normalizeJsonText(leftText) === normalizeJsonText(rightText); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| return normalizeTomlText(leftText) === normalizeTomlText(rightText); | |
| } | |
| function prepareWrittenText(kind, text) { | |
| if (kind === "json") return normalizeJsonText(text); | |
| return ensureTrailingNewline(text); | |
| } | |
| export class ConfigSwitchStore { | |
| constructor(dataDir, options = {}) { | |
| this.dataDir = dataDir; | |
| this.filePath = path.join(dataDir, "config-switch.json"); | |
| this.providerDefinitions = options.providerDefinitions || defaultProviderDefinitions(); | |
| this.state = makeEmptyState(); | |
| this.loaded = false; | |
| } | |
| requireProvider(provider) { | |
| const normalized = String(provider || ""); | |
| if (!this.providerDefinitions[normalized]) { | |
| throw validationError(`Unknown config-switch provider: ${provider}`, 404); | |
| } | |
| return normalized; | |
| } | |
| async load() { | |
| if (this.loaded) return; | |
| await fs.mkdir(this.dataDir, { recursive: true }); | |
| try { | |
| const raw = JSON.parse(await fs.readFile(this.filePath, "utf8")); | |
| this.state = { | |
| ...makeEmptyState(), | |
| ...raw, | |
| presetsByProvider: { | |
| ...makeEmptyState().presetsByProvider, | |
| ...(raw.presetsByProvider || {}), | |
| }, | |
| lastAppliedPresetIdByProvider: { | |
| ...makeEmptyState().lastAppliedPresetIdByProvider, | |
| ...(raw.lastAppliedPresetIdByProvider || {}), | |
| }, | |
| lastAppliedAtByProvider: { | |
| ...makeEmptyState().lastAppliedAtByProvider, | |
| ...(raw.lastAppliedAtByProvider || {}), | |
| }, | |
| }; | |
| } catch { | |
| this.state = makeEmptyState(); | |
| await this.persist(); | |
| } | |
| this.loaded = true; | |
| } | |
| async persist() { | |
| await fs.mkdir(this.dataDir, { recursive: true }); | |
| await fs.writeFile( | |
| this.filePath, | |
| `${JSON.stringify(this.state, null, 2)}\n`, | |
| "utf8", | |
| ); | |
| } | |
| findPreset(provider, presetId) { | |
| const items = this.state.presetsByProvider[provider] || []; | |
| const preset = items.find((item) => item.id === presetId) || null; | |
| if (!preset) { | |
| throw validationError(`Preset not found: ${presetId}`, 404); | |
| } | |
| return preset; | |
| } | |
| async serializeProvider(provider) { | |
| const definition = this.providerDefinitions[provider]; | |
| const items = this.state.presetsByProvider[provider] || []; | |
| const lastAppliedPresetId = this.state.lastAppliedPresetIdByProvider[provider] || null; | |
| const currentFiles = {}; | |
| for (const target of definition.targetPaths) { | |
| currentFiles[target.key] = await readOptionalText(target.path); | |
| } | |
| return { | |
| provider, | |
| label: definition.label, | |
| targetPaths: definition.targetPaths.map((target) => ({ | |
| key: target.key, | |
| label: target.label, | |
| path: target.path, | |
| })), | |
| lastAppliedPresetId, | |
| lastAppliedAt: this.state.lastAppliedAtByProvider[provider] || null, | |
| presets: items | |
| .map((item) => { | |
| const matchesCurrent = definition.targetPaths.every((target) => | |
| compareContent( | |
| target.kind, | |
| item.payload?.[target.key] || "", | |
| currentFiles[target.key] || "", | |
| ), | |
| ); | |
| const status = | |
| item.id === lastAppliedPresetId | |
| ? matchesCurrent | |
| ? "active" | |
| : "drifted" | |
| : "idle"; | |
| return { | |
| ...item, | |
| status, | |
| statusText: | |
| status === "active" | |
| ? "已启用" | |
| : status === "drifted" | |
| ? "已偏离" | |
| : "未启用", | |
| currentPayload: | |
| status === "drifted" | |
| ? Object.fromEntries( | |
| definition.targetPaths.map((target) => [ | |
| target.key, | |
| currentFiles[target.key] || "", | |
| ]), | |
| ) | |
| : null, | |
| }; | |
| }) | |
| .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), | |
| }; | |
| } | |
| async getConfigSwitchData() { | |
| await this.load(); | |
| return { | |
| providers: { | |
| codex: await this.serializeProvider("codex"), | |
| "claude-code": await this.serializeProvider("claude-code"), | |
| }, | |
| }; | |
| } | |
| async upsertPreset(provider, input) { | |
| await this.load(); | |
| const normalizedProvider = this.requireProvider(provider); | |
| const preset = normalizePresetInput(normalizedProvider, input); | |
| validatePreset(normalizedProvider, preset); | |
| const now = nowIso(); | |
| const items = [...(this.state.presetsByProvider[normalizedProvider] || [])]; | |
| if (preset.id) { | |
| const index = items.findIndex((item) => item.id === preset.id); | |
| if (index < 0) { | |
| throw validationError(`Preset not found: ${preset.id}`, 404); | |
| } | |
| items[index] = { | |
| ...items[index], | |
| name: preset.name, | |
| payload: preset.payload, | |
| updatedAt: now, | |
| }; | |
| } else { | |
| items.unshift({ | |
| id: crypto.randomUUID(), | |
| name: preset.name, | |
| provider: normalizedProvider, | |
| payload: preset.payload, | |
| createdAt: now, | |
| updatedAt: now, | |
| }); | |
| } | |
| this.state.presetsByProvider[normalizedProvider] = items; | |
| await this.persist(); | |
| return this.getConfigSwitchData(); | |
| } | |
| async deletePreset(provider, presetId) { | |
| await this.load(); | |
| const normalizedProvider = this.requireProvider(provider); | |
| const items = [...(this.state.presetsByProvider[normalizedProvider] || [])]; | |
| const index = items.findIndex((item) => item.id === presetId); | |
| if (index < 0) { | |
| throw validationError(`Preset not found: ${presetId}`, 404); | |
| } | |
| items.splice(index, 1); | |
| this.state.presetsByProvider[normalizedProvider] = items; | |
| if (this.state.lastAppliedPresetIdByProvider[normalizedProvider] === presetId) { | |
| this.state.lastAppliedPresetIdByProvider[normalizedProvider] = null; | |
| this.state.lastAppliedAtByProvider[normalizedProvider] = null; | |
| } | |
| await this.persist(); | |
| return this.getConfigSwitchData(); | |
| } | |
| async copyPreset(provider, presetId) { | |
| await this.load(); | |
| const normalizedProvider = this.requireProvider(provider); | |
| const source = this.findPreset(normalizedProvider, presetId); | |
| const now = nowIso(); | |
| const copied = { | |
| ...source, | |
| id: crypto.randomUUID(), | |
| name: `${source.name} - 副本`, | |
| createdAt: now, | |
| updatedAt: now, | |
| }; | |
| this.state.presetsByProvider[normalizedProvider] = [ | |
| copied, | |
| ...(this.state.presetsByProvider[normalizedProvider] || []), | |
| ]; | |
| await this.persist(); | |
| return this.getConfigSwitchData(); | |
| } | |
| async activatePreset(provider, presetId, options = {}) { | |
| await this.load(); | |
| const normalizedProvider = this.requireProvider(provider); | |
| if (options.confirmed !== true) { | |
| throw validationError("Confirmation required before activating this preset.", 409); | |
| } | |
| const definition = this.providerDefinitions[normalizedProvider]; | |
| const preset = this.findPreset(normalizedProvider, presetId); | |
| validatePreset(normalizedProvider, preset); | |
| for (const target of definition.targetPaths) { | |
| await fs.mkdir(path.dirname(target.path), { recursive: true }); | |
| await fs.writeFile( | |
| target.path, | |
| prepareWrittenText(target.kind, preset.payload[target.key] || ""), | |
| "utf8", | |
| ); | |
| } | |
| this.state.lastAppliedPresetIdByProvider[normalizedProvider] = presetId; | |
| this.state.lastAppliedAtByProvider[normalizedProvider] = nowIso(); | |
| await this.persist(); | |
| return { | |
| ...(await this.getConfigSwitchData()), | |
| activation: { | |
| provider: normalizedProvider, | |
| presetId, | |
| targetPaths: definition.targetPaths.map((target) => target.path), | |
| }, | |
| }; | |
| } | |
| } | |