codex-endpoint-switcher 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,7 @@ codex-switcher open
48
48
  codex-switcher start
49
49
  codex-switcher status
50
50
  codex-switcher restart
51
+ codex-switcher sync-server
51
52
  codex-switcher install-access
52
53
  codex-switcher remove-access
53
54
  ```
@@ -58,6 +59,7 @@ codex-switcher remove-access
58
59
  - `start`:仅在后台启动本地网页服务
59
60
  - `status`:查看当前服务状态
60
61
  - `restart`:重启本地网页服务
62
+ - `sync-server`:启动账号同步服务,服务端使用 SQLite 单文件数据库
61
63
  - `install-access`:创建桌面快捷方式和开机启动项
62
64
  - `remove-access`:移除桌面快捷方式和开机启动项
63
65
 
@@ -69,6 +71,68 @@ codex-switcher remove-access
69
71
  http://localhost:3186
70
72
  ```
71
73
 
74
+ ## 账号配置同步
75
+
76
+ 网页控制台现在支持“服务器地址 + 账号 + 密码”的同步方式。
77
+
78
+ ### 1. 启动同步服务
79
+
80
+ 在准备当服务端的机器上执行:
81
+
82
+ ```bash
83
+ codex-switcher sync-server
84
+ ```
85
+
86
+ 或者:
87
+
88
+ ```bash
89
+ npm run start:sync-server
90
+ ```
91
+
92
+ 默认会监听:
93
+
94
+ ```text
95
+ http://127.0.0.1:3190
96
+ ```
97
+
98
+ 默认数据库文件:
99
+
100
+ ```text
101
+ ~/.codex-cloud-sync/cloud-sync.db
102
+ ```
103
+
104
+ 如果你要让局域网或公网机器访问,可以先设置:
105
+
106
+ ```bash
107
+ set SYNC_SERVER_HOST=0.0.0.0
108
+ set SYNC_SERVER_PORT=3190
109
+ codex-switcher sync-server
110
+ ```
111
+
112
+ ### 2. 在网页控制台登录同步账号
113
+
114
+ 在客户端网页控制台里填写:
115
+
116
+ - 同步服务器地址
117
+ - 账号
118
+ - 密码
119
+
120
+ 然后:
121
+
122
+ - 第一次使用点 `注册账号`
123
+ - 后续使用点 `登录账号`
124
+
125
+ ### 3. 推送和拉取
126
+
127
+ - `推送当前连接`:把本机保存的 URL / Key / 备注 上传到账号空间
128
+ - `合并拉取`:把账号空间里的连接合并到本机
129
+ - `覆盖拉取`:直接用账号空间里的连接替换本机连接列表
130
+
131
+ 说明:
132
+
133
+ - 拉取前会自动备份本机 `~/.codex/endpoint-presets.json` 和 `~/.codex/endpoint-switcher-state.json`
134
+ - 服务端数据库是 SQLite,部署只需要一个 Node 进程和一个 `.db` 文件
135
+
72
136
  ## 更新 npm 包
73
137
 
74
138
  先进入项目目录:
@@ -7,6 +7,7 @@ const { handleCommand } = require("../src/web/launcher");
7
7
  const projectRoot = path.resolve(__dirname, "..");
8
8
  const installScriptPath = path.join(projectRoot, "install-web-access.ps1");
9
9
  const removeScriptPath = path.join(projectRoot, "remove-web-access.ps1");
10
+ const cloudSyncServerPath = path.join(projectRoot, "src/web/cloud-sync-server.js");
10
11
 
11
12
  function printUsage() {
12
13
  console.log(`
@@ -15,6 +16,7 @@ function printUsage() {
15
16
  codex-switcher start
16
17
  codex-switcher status
17
18
  codex-switcher restart
19
+ codex-switcher sync-server
18
20
  codex-switcher install-access
19
21
  codex-switcher remove-access
20
22
 
@@ -23,6 +25,7 @@ function printUsage() {
23
25
  start 仅在后台启动本地网页服务
24
26
  status 查看服务状态
25
27
  restart 重启本地网页服务
28
+ sync-server 启动账号同步服务(SQLite)
26
29
  install-access 创建桌面快捷方式与开机启动项
27
30
  remove-access 删除桌面快捷方式与开机启动项
28
31
  `);
@@ -52,6 +55,26 @@ function runPowerShellScript(scriptPath) {
52
55
  });
53
56
  }
54
57
 
58
+ function runNodeScript(scriptPath, extraArgs = []) {
59
+ return new Promise((resolve, reject) => {
60
+ const child = spawn(process.execPath, [...extraArgs, scriptPath], {
61
+ cwd: projectRoot,
62
+ stdio: "inherit",
63
+ });
64
+
65
+ child.on("exit", (code) => {
66
+ if (code === 0) {
67
+ resolve();
68
+ return;
69
+ }
70
+
71
+ reject(new Error(`脚本执行失败,退出码:${code}`));
72
+ });
73
+
74
+ child.on("error", reject);
75
+ });
76
+ }
77
+
55
78
  async function main() {
56
79
  const command = process.argv[2] || "open";
57
80
 
@@ -63,6 +86,10 @@ async function main() {
63
86
  await handleCommand(command === "start" ? "ensure" : command);
64
87
  return;
65
88
  }
89
+ case "sync-server": {
90
+ await runNodeScript(cloudSyncServerPath, ["--experimental-sqlite"]);
91
+ return;
92
+ }
66
93
  case "install-access": {
67
94
  await runPowerShellScript(installScriptPath);
68
95
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-endpoint-switcher",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "用于切换 Codex URL 和 Key 的本地网页控制台与 npm CLI",
5
5
  "main": "src/main/main.js",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "dev": "electron .",
22
22
  "start:web": "node src/web/server.js",
23
23
  "dev:web": "node src/web/server.js",
24
+ "start:sync-server": "node --experimental-sqlite src/web/cloud-sync-server.js",
24
25
  "cli": "node bin/codex-switcher.js",
25
26
  "pack:npm": "npm pack",
26
27
  "release:check": "npm pack --dry-run",
@@ -0,0 +1,328 @@
1
+ const fs = require("node:fs/promises");
2
+ const path = require("node:path");
3
+ const os = require("node:os");
4
+ const profileManager = require("./profile-manager");
5
+
6
+ const codexRoot = path.join(os.homedir(), ".codex");
7
+ const cloudSyncConfigPath = path.join(codexRoot, "cloud-sync-config.json");
8
+
9
+ function normalizeServerUrl(value) {
10
+ const raw = String(value || "").trim();
11
+ if (!raw) {
12
+ throw new Error("同步服务器地址不能为空。");
13
+ }
14
+
15
+ let parsedUrl;
16
+ try {
17
+ parsedUrl = new URL(raw);
18
+ } catch {
19
+ throw new Error("同步服务器地址格式不正确。");
20
+ }
21
+
22
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
23
+ throw new Error("同步服务器地址仅支持 http 或 https。");
24
+ }
25
+
26
+ parsedUrl.hash = "";
27
+ parsedUrl.search = "";
28
+
29
+ return parsedUrl.toString().replace(/\/+$/, "");
30
+ }
31
+
32
+ function normalizeUsername(value) {
33
+ const username = String(value || "").trim();
34
+ if (!username) {
35
+ throw new Error("账号不能为空。");
36
+ }
37
+
38
+ if (username.length < 3 || username.length > 64) {
39
+ throw new Error("账号长度需要在 3 到 64 个字符之间。");
40
+ }
41
+
42
+ if (/\s/.test(username)) {
43
+ throw new Error("账号不能包含空白字符。");
44
+ }
45
+
46
+ return username;
47
+ }
48
+
49
+ function normalizePassword(value) {
50
+ const password = String(value || "");
51
+ if (!password.trim()) {
52
+ throw new Error("密码不能为空。");
53
+ }
54
+
55
+ if (password.length < 6) {
56
+ throw new Error("密码至少需要 6 位。");
57
+ }
58
+
59
+ return password;
60
+ }
61
+
62
+ async function ensureStorage() {
63
+ await fs.mkdir(codexRoot, { recursive: true });
64
+
65
+ try {
66
+ await fs.access(cloudSyncConfigPath);
67
+ } catch {
68
+ await fs.writeFile(
69
+ cloudSyncConfigPath,
70
+ `${JSON.stringify(
71
+ {
72
+ serverUrl: "",
73
+ username: "",
74
+ authToken: "",
75
+ tokenExpiresAt: "",
76
+ lastPushAt: "",
77
+ lastPullAt: "",
78
+ },
79
+ null,
80
+ 2,
81
+ )}\n`,
82
+ "utf8",
83
+ );
84
+ }
85
+ }
86
+
87
+ async function readConfig() {
88
+ await ensureStorage();
89
+ const content = await fs.readFile(cloudSyncConfigPath, "utf8");
90
+ const config = JSON.parse(content);
91
+
92
+ return {
93
+ serverUrl: String(config.serverUrl || "").trim(),
94
+ username: String(config.username || "").trim(),
95
+ authToken: String(config.authToken || "").trim(),
96
+ tokenExpiresAt: String(config.tokenExpiresAt || "").trim(),
97
+ lastPushAt: String(config.lastPushAt || "").trim(),
98
+ lastPullAt: String(config.lastPullAt || "").trim(),
99
+ };
100
+ }
101
+
102
+ async function writeConfig(config) {
103
+ await ensureStorage();
104
+ await fs.writeFile(cloudSyncConfigPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
105
+ }
106
+
107
+ async function requestJson(serverUrl, pathname, options = {}) {
108
+ const targetUrl = new URL(pathname, `${serverUrl}/`);
109
+ const response = await fetch(targetUrl, {
110
+ method: options.method || "GET",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ ...(options.authToken
114
+ ? {
115
+ Authorization: `Bearer ${options.authToken}`,
116
+ }
117
+ : {}),
118
+ },
119
+ body: options.body ? JSON.stringify(options.body) : undefined,
120
+ signal: AbortSignal.timeout(options.timeoutMs || 5000),
121
+ });
122
+
123
+ const rawText = await response.text();
124
+ let payload = {};
125
+
126
+ try {
127
+ payload = rawText ? JSON.parse(rawText) : {};
128
+ } catch {
129
+ payload = {};
130
+ }
131
+
132
+ if (!response.ok || payload.ok === false) {
133
+ throw new Error(payload.error || `请求同步服务失败:${response.status}`);
134
+ }
135
+
136
+ return payload.data;
137
+ }
138
+
139
+ async function getCloudSyncStatus() {
140
+ const config = await readConfig();
141
+ const result = {
142
+ configPath: cloudSyncConfigPath,
143
+ serverUrl: config.serverUrl,
144
+ username: config.username,
145
+ hasToken: Boolean(config.authToken),
146
+ loggedIn: false,
147
+ tokenExpiresAt: config.tokenExpiresAt,
148
+ lastPushAt: config.lastPushAt,
149
+ lastPullAt: config.lastPullAt,
150
+ remoteUser: "",
151
+ remoteProfileUpdatedAt: "",
152
+ lastError: "",
153
+ };
154
+
155
+ if (!config.serverUrl || !config.authToken) {
156
+ return result;
157
+ }
158
+
159
+ try {
160
+ const data = await requestJson(config.serverUrl, "/api/auth/me", {
161
+ authToken: config.authToken,
162
+ timeoutMs: 3000,
163
+ });
164
+
165
+ return {
166
+ ...result,
167
+ loggedIn: true,
168
+ username: data.user.username,
169
+ remoteUser: data.user.username,
170
+ tokenExpiresAt: data.session.expiresAt,
171
+ remoteProfileUpdatedAt: data.syncProfileUpdatedAt || "",
172
+ lastError: "",
173
+ };
174
+ } catch (error) {
175
+ return {
176
+ ...result,
177
+ lastError: error instanceof Error ? error.message : String(error),
178
+ };
179
+ }
180
+ }
181
+
182
+ async function authenticate(mode, payload) {
183
+ const serverUrl = normalizeServerUrl(payload.serverUrl);
184
+ const username = normalizeUsername(payload.username);
185
+ const password = normalizePassword(payload.password);
186
+ const endpointPath = mode === "register" ? "/api/auth/register" : "/api/auth/login";
187
+ const response = await requestJson(serverUrl, endpointPath, {
188
+ method: "POST",
189
+ body: {
190
+ username,
191
+ password,
192
+ },
193
+ });
194
+
195
+ await writeConfig({
196
+ serverUrl,
197
+ username: response.user.username,
198
+ authToken: String(response.token || "").trim(),
199
+ tokenExpiresAt: String(response.expiresAt || "").trim(),
200
+ lastPushAt: "",
201
+ lastPullAt: "",
202
+ });
203
+
204
+ return {
205
+ ...(await getCloudSyncStatus()),
206
+ message: mode === "register" ? "账号注册并登录成功。" : "账号登录成功。",
207
+ };
208
+ }
209
+
210
+ async function registerAccount(payload) {
211
+ return authenticate("register", payload);
212
+ }
213
+
214
+ async function loginAccount(payload) {
215
+ return authenticate("login", payload);
216
+ }
217
+
218
+ async function logoutAccount() {
219
+ const config = await readConfig();
220
+
221
+ if (config.serverUrl && config.authToken) {
222
+ try {
223
+ await requestJson(config.serverUrl, "/api/auth/logout", {
224
+ method: "POST",
225
+ authToken: config.authToken,
226
+ timeoutMs: 3000,
227
+ });
228
+ } catch {
229
+ // 服务端不可达时,本地仍然允许清理登录态,避免用户被卡住。
230
+ }
231
+ }
232
+
233
+ await writeConfig({
234
+ ...config,
235
+ authToken: "",
236
+ tokenExpiresAt: "",
237
+ });
238
+
239
+ return {
240
+ ...(await getCloudSyncStatus()),
241
+ message: "已退出账号同步登录。",
242
+ };
243
+ }
244
+
245
+ async function requireLoggedInConfig() {
246
+ const config = await readConfig();
247
+ if (!config.serverUrl) {
248
+ throw new Error("请先填写并登录同步服务器。");
249
+ }
250
+
251
+ if (!config.authToken) {
252
+ throw new Error("当前还没有登录同步账号。");
253
+ }
254
+
255
+ return config;
256
+ }
257
+
258
+ /**
259
+ * 把当前本机保存的连接配置推送到远端账号空间。
260
+ */
261
+ async function pushCurrentConfig() {
262
+ const config = await requireLoggedInConfig();
263
+ const exported = await profileManager.exportSyncPackage();
264
+ const response = await requestJson(config.serverUrl, "/api/sync/config", {
265
+ method: "PUT",
266
+ authToken: config.authToken,
267
+ body: {
268
+ syncCode: exported.syncCode,
269
+ endpointCount: exported.endpointCount,
270
+ },
271
+ });
272
+
273
+ const nextConfig = {
274
+ ...config,
275
+ lastPushAt: String(response.updatedAt || new Date().toISOString()).trim(),
276
+ };
277
+ await writeConfig(nextConfig);
278
+
279
+ return {
280
+ ...(await getCloudSyncStatus()),
281
+ endpointCount: exported.endpointCount,
282
+ remoteUpdatedAt: response.updatedAt,
283
+ message: `已把 ${exported.endpointCount} 条连接推送到账号空间。`,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 从远端账号空间拉取连接配置,再按指定策略导入到本机。
289
+ */
290
+ async function pullRemoteConfig(mode = "merge") {
291
+ const safeMode = String(mode || "merge").trim().toLowerCase();
292
+ if (!["merge", "replace"].includes(safeMode)) {
293
+ throw new Error("拉取模式不正确,只支持 merge 或 replace。");
294
+ }
295
+
296
+ const config = await requireLoggedInConfig();
297
+ const response = await requestJson(config.serverUrl, "/api/sync/config", {
298
+ method: "GET",
299
+ authToken: config.authToken,
300
+ });
301
+ const importResult = await profileManager.importSyncPackage(response.syncCode, safeMode);
302
+ const nextConfig = {
303
+ ...config,
304
+ lastPullAt: new Date().toISOString(),
305
+ };
306
+ await writeConfig(nextConfig);
307
+
308
+ return {
309
+ ...(await getCloudSyncStatus()),
310
+ ...importResult,
311
+ remoteUpdatedAt: response.updatedAt,
312
+ endpointCount: response.endpointCount,
313
+ message:
314
+ safeMode === "merge"
315
+ ? "已从账号空间拉取并合并到本机。"
316
+ : "已从账号空间拉取并覆盖本机连接列表。",
317
+ };
318
+ }
319
+
320
+ module.exports = {
321
+ cloudSyncConfigPath,
322
+ getCloudSyncStatus,
323
+ loginAccount,
324
+ logoutAccount,
325
+ pullRemoteConfig,
326
+ pushCurrentConfig,
327
+ registerAccount,
328
+ };
package/src/main/main.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const path = require("node:path");
2
2
  const { app, BrowserWindow, ipcMain, shell } = require("electron");
3
3
  const profileManager = require("./profile-manager");
4
+ const cloudSyncClient = require("./cloud-sync-client");
4
5
 
5
6
  function createWindow() {
6
7
  const mainWindow = new BrowserWindow({
@@ -50,6 +51,38 @@ function registerHandlers() {
50
51
  return profileManager.enableProxyMode();
51
52
  });
52
53
 
54
+ ipcMain.handle("sync:export", async () => {
55
+ return profileManager.exportSyncPackage();
56
+ });
57
+
58
+ ipcMain.handle("sync:import", async (_event, payload) => {
59
+ return profileManager.importSyncPackage(payload.syncCode, payload.mode);
60
+ });
61
+
62
+ ipcMain.handle("cloud:status", async () => {
63
+ return cloudSyncClient.getCloudSyncStatus();
64
+ });
65
+
66
+ ipcMain.handle("cloud:register", async (_event, payload) => {
67
+ return cloudSyncClient.registerAccount(payload);
68
+ });
69
+
70
+ ipcMain.handle("cloud:login", async (_event, payload) => {
71
+ return cloudSyncClient.loginAccount(payload);
72
+ });
73
+
74
+ ipcMain.handle("cloud:logout", async () => {
75
+ return cloudSyncClient.logoutAccount();
76
+ });
77
+
78
+ ipcMain.handle("cloud:push", async () => {
79
+ return cloudSyncClient.pushCurrentConfig();
80
+ });
81
+
82
+ ipcMain.handle("cloud:pull", async (_event, payload) => {
83
+ return cloudSyncClient.pullRemoteConfig(payload.mode);
84
+ });
85
+
53
86
  ipcMain.handle("paths:get", async () => {
54
87
  return profileManager.getManagedPaths();
55
88
  });
@@ -22,6 +22,30 @@ contextBridge.exposeInMainWorld("codexDesktop", {
22
22
  enableProxyMode() {
23
23
  return ipcRenderer.invoke("proxy:enable");
24
24
  },
25
+ exportSyncCode() {
26
+ return ipcRenderer.invoke("sync:export");
27
+ },
28
+ importSyncCode(payload) {
29
+ return ipcRenderer.invoke("sync:import", payload);
30
+ },
31
+ getCloudSyncStatus() {
32
+ return ipcRenderer.invoke("cloud:status");
33
+ },
34
+ registerCloudAccount(payload) {
35
+ return ipcRenderer.invoke("cloud:register", payload);
36
+ },
37
+ loginCloudAccount(payload) {
38
+ return ipcRenderer.invoke("cloud:login", payload);
39
+ },
40
+ logoutCloudAccount() {
41
+ return ipcRenderer.invoke("cloud:logout");
42
+ },
43
+ pushCloudConfig() {
44
+ return ipcRenderer.invoke("cloud:push");
45
+ },
46
+ pullCloudConfig(payload) {
47
+ return ipcRenderer.invoke("cloud:pull", payload);
48
+ },
25
49
  getPaths() {
26
50
  return ipcRenderer.invoke("paths:get");
27
51
  },