codex-endpoint-switcher 1.3.2 → 1.4.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
@@ -49,6 +49,7 @@ codex-switcher start
49
49
  codex-switcher status
50
50
  codex-switcher restart
51
51
  codex-switcher stop
52
+ codex-switcher check-update
52
53
  codex-switcher sync-server
53
54
  codex-switcher install-access
54
55
  codex-switcher remove-access
@@ -61,6 +62,7 @@ codex-switcher remove-access
61
62
  - `status`:查看当前服务状态
62
63
  - `restart`:重启本地网页服务
63
64
  - `stop`:关闭本地网页服务
65
+ - `check-update`:检查 npm 是否有新版本
64
66
  - `sync-server`:启动账号同步服务,服务端使用 SQLite 单文件数据库
65
67
  - `install-access`:创建桌面快捷方式和开机启动项
66
68
  - `remove-access`:移除桌面快捷方式和开机启动项
@@ -75,7 +77,7 @@ http://localhost:3186
75
77
 
76
78
  ## 账号配置同步
77
79
 
78
- 网页控制台现在支持“服务器地址 + 账号 + 密码”的同步方式。
80
+ 网页控制台现在支持“账号 + 密码”的同步方式。
79
81
 
80
82
  ### 1. 启动同步服务
81
83
 
@@ -115,7 +117,6 @@ codex-switcher sync-server
115
117
 
116
118
  在客户端网页控制台里填写:
117
119
 
118
- - 同步服务器地址
119
120
  - 账号
120
121
  - 密码
121
122
 
@@ -135,6 +136,18 @@ codex-switcher sync-server
135
136
  - 拉取前会自动备份本机 `~/.codex/endpoint-presets.json` 和 `~/.codex/endpoint-switcher-state.json`
136
137
  - 服务端数据库是 SQLite,部署只需要一个 Node 进程和一个 `.db` 文件
137
138
 
139
+ ## 版本更新提示
140
+
141
+ - 控制台打开后会自动检查 npm 最新版本
142
+ - 如果你发布了新版本,其他用户打开控制台后会看到“发现新版本”的提示
143
+ - 用户可以直接复制升级命令执行
144
+
145
+ 也可以用 CLI 手动检查:
146
+
147
+ ```bash
148
+ codex-switcher check-update
149
+ ```
150
+
138
151
  ## 更新 npm 包
139
152
 
140
153
  先进入项目目录:
@@ -3,6 +3,7 @@
3
3
  const path = require("node:path");
4
4
  const { spawn } = require("node:child_process");
5
5
  const { handleCommand } = require("../src/web/launcher");
6
+ const updateService = require("../src/main/update-service");
6
7
 
7
8
  const projectRoot = path.resolve(__dirname, "..");
8
9
  const installScriptPath = path.join(projectRoot, "install-web-access.ps1");
@@ -17,6 +18,7 @@ function printUsage() {
17
18
  codex-switcher status
18
19
  codex-switcher restart
19
20
  codex-switcher stop
21
+ codex-switcher check-update
20
22
  codex-switcher sync-server
21
23
  codex-switcher install-access
22
24
  codex-switcher remove-access
@@ -27,6 +29,7 @@ function printUsage() {
27
29
  status 查看服务状态
28
30
  restart 重启本地网页服务
29
31
  stop 关闭本地网页服务
32
+ check-update 检查 npm 最新版本
30
33
  sync-server 启动账号同步服务(SQLite)
31
34
  install-access 创建桌面快捷方式与开机启动项
32
35
  remove-access 删除桌面快捷方式与开机启动项
@@ -89,6 +92,24 @@ async function main() {
89
92
  await handleCommand(command === "start" ? "ensure" : command);
90
93
  return;
91
94
  }
95
+ case "check-update": {
96
+ const status = await updateService.getUpdateStatus({ force: true });
97
+ if (status.hasUpdate) {
98
+ console.log(`发现新版本:${status.latestVersion}`);
99
+ console.log(`当前版本:${status.currentVersion}`);
100
+ console.log(`升级命令:${status.upgradeCommand}`);
101
+ return;
102
+ }
103
+
104
+ if (status.lastError) {
105
+ console.log(`当前版本:${status.currentVersion}`);
106
+ console.log(`暂时无法检查更新:${status.lastError}`);
107
+ return;
108
+ }
109
+
110
+ console.log(`当前已是最新版:${status.currentVersion}`);
111
+ return;
112
+ }
92
113
  case "sync-server": {
93
114
  await runNodeScript(cloudSyncServerPath, ["--experimental-sqlite"]);
94
115
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-endpoint-switcher",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "用于切换 Codex URL 和 Key 的本地网页控制台与 npm CLI",
5
5
  "main": "src/main/main.js",
6
6
  "bin": {
package/src/main/main.js CHANGED
@@ -2,6 +2,7 @@ const path = require("node:path");
2
2
  const { app, BrowserWindow, ipcMain, shell } = require("electron");
3
3
  const profileManager = require("./profile-manager");
4
4
  const cloudSyncClient = require("./cloud-sync-client");
5
+ const updateService = require("./update-service");
5
6
 
6
7
  function createWindow() {
7
8
  const mainWindow = new BrowserWindow({
@@ -101,6 +102,10 @@ function registerHandlers() {
101
102
  closing: true,
102
103
  };
103
104
  });
105
+
106
+ ipcMain.handle("app:updateStatus", async (_event, payload) => {
107
+ return updateService.getUpdateStatus(payload || {});
108
+ });
104
109
  }
105
110
 
106
111
  app.whenReady().then(() => {
@@ -55,4 +55,7 @@ contextBridge.exposeInMainWorld("codexDesktop", {
55
55
  closeApp() {
56
56
  return ipcRenderer.invoke("app:close");
57
57
  },
58
+ getUpdateStatus(payload) {
59
+ return ipcRenderer.invoke("app:updateStatus", payload);
60
+ },
58
61
  });
@@ -0,0 +1,122 @@
1
+ const packageInfo = require("../../package.json");
2
+
3
+ const REGISTRY_BASE_URL = "https://registry.npmjs.org";
4
+ const SUCCESS_CACHE_TTL_MS = 10 * 60 * 1000;
5
+ const ERROR_CACHE_TTL_MS = 30 * 1000;
6
+ const MAX_RETRY_TIMES = 3;
7
+
8
+ let cachedStatus = null;
9
+
10
+ function sleep(timeoutMs) {
11
+ return new Promise((resolve) => {
12
+ setTimeout(resolve, timeoutMs);
13
+ });
14
+ }
15
+
16
+ function parseVersion(version) {
17
+ return String(version || "")
18
+ .trim()
19
+ .split(".")
20
+ .map((part) => Number.parseInt(part, 10) || 0);
21
+ }
22
+
23
+ function compareVersions(left, right) {
24
+ const leftParts = parseVersion(left);
25
+ const rightParts = parseVersion(right);
26
+ const length = Math.max(leftParts.length, rightParts.length);
27
+
28
+ for (let index = 0; index < length; index += 1) {
29
+ const leftValue = leftParts[index] || 0;
30
+ const rightValue = rightParts[index] || 0;
31
+
32
+ if (leftValue > rightValue) {
33
+ return 1;
34
+ }
35
+
36
+ if (leftValue < rightValue) {
37
+ return -1;
38
+ }
39
+ }
40
+
41
+ return 0;
42
+ }
43
+
44
+ function buildStatus(payload = {}) {
45
+ const currentVersion = packageInfo.version;
46
+ const latestVersion = payload.latestVersion || currentVersion;
47
+ const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
48
+
49
+ return {
50
+ packageName: packageInfo.name,
51
+ currentVersion,
52
+ latestVersion,
53
+ hasUpdate,
54
+ checkedAt: new Date().toISOString(),
55
+ upgradeCommand: `npm install -g ${packageInfo.name}@latest`,
56
+ releaseUrl: `https://www.npmjs.com/package/${packageInfo.name}`,
57
+ lastError: payload.lastError || "",
58
+ };
59
+ }
60
+
61
+ async function fetchLatestPackageVersion() {
62
+ let lastError = null;
63
+
64
+ for (let attempt = 1; attempt <= MAX_RETRY_TIMES; attempt += 1) {
65
+ try {
66
+ const response = await fetch(`${REGISTRY_BASE_URL}/${packageInfo.name}/latest`, {
67
+ headers: {
68
+ Accept: "application/json",
69
+ "User-Agent": `${packageInfo.name}/${packageInfo.version}`,
70
+ },
71
+ });
72
+
73
+ if (!response.ok) {
74
+ throw new Error(`npm 仓库请求失败:${response.status}`);
75
+ }
76
+
77
+ const payload = await response.json();
78
+ const latestVersion = String(payload.version || "").trim();
79
+ if (!latestVersion) {
80
+ throw new Error("npm 仓库没有返回可用版本号。");
81
+ }
82
+
83
+ return latestVersion;
84
+ } catch (error) {
85
+ lastError = error;
86
+
87
+ if (attempt < MAX_RETRY_TIMES) {
88
+ await sleep(300 * attempt);
89
+ }
90
+ }
91
+ }
92
+
93
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
94
+ }
95
+
96
+ async function getUpdateStatus(options = {}) {
97
+ const force = Boolean(options.force);
98
+
99
+ if (
100
+ !force &&
101
+ cachedStatus &&
102
+ Date.now() - new Date(cachedStatus.checkedAt).getTime() <
103
+ (cachedStatus.lastError ? ERROR_CACHE_TTL_MS : SUCCESS_CACHE_TTL_MS)
104
+ ) {
105
+ return cachedStatus;
106
+ }
107
+
108
+ try {
109
+ const latestVersion = await fetchLatestPackageVersion();
110
+ cachedStatus = buildStatus({ latestVersion });
111
+ } catch (error) {
112
+ cachedStatus = buildStatus({
113
+ lastError: error instanceof Error ? error.message : String(error),
114
+ });
115
+ }
116
+
117
+ return cachedStatus;
118
+ }
119
+
120
+ module.exports = {
121
+ getUpdateStatus,
122
+ };
@@ -109,6 +109,24 @@
109
109
  <button id="openEndpointStoreButton" class="ghost-button">打开连接数据文件</button>
110
110
  <button id="closeAppButton" class="danger-button">关闭程序</button>
111
111
  </div>
112
+ <section class="update-strip" aria-label="版本更新状态">
113
+ <div class="update-copy">
114
+ <span class="metric-label">版本更新</span>
115
+ <strong id="updateSummary">正在检查版本...</strong>
116
+ <span id="updateDetail" class="update-detail">
117
+ 当前版本 -,正在连接 npm 仓库。
118
+ </span>
119
+ <code id="updateCommand" class="update-command" hidden>
120
+ npm install -g codex-endpoint-switcher@latest
121
+ </code>
122
+ </div>
123
+ <div class="update-actions">
124
+ <button id="checkUpdateButton" type="button" class="ghost-button">检查更新</button>
125
+ <button id="copyUpdateCommandButton" type="button" class="secondary-button" hidden>
126
+ 复制升级命令
127
+ </button>
128
+ </div>
129
+ </section>
112
130
  <div class="current-grid">
113
131
  <article class="metric-card">
114
132
  <span class="metric-label">当前备注</span>
@@ -3,6 +3,7 @@ const state = {
3
3
  endpoints: [],
4
4
  paths: null,
5
5
  cloud: null,
6
+ update: null,
6
7
  };
7
8
 
8
9
  function createWebBridge() {
@@ -105,6 +106,15 @@ function createWebBridge() {
105
106
  method: "POST",
106
107
  });
107
108
  },
109
+ getUpdateStatus(options = {}) {
110
+ const searchParams = new URLSearchParams();
111
+ if (options.force) {
112
+ searchParams.set("force", "1");
113
+ }
114
+
115
+ const query = searchParams.toString();
116
+ return request(`/api/app/update-status${query ? `?${query}` : ""}`);
117
+ },
108
118
  };
109
119
  }
110
120
 
@@ -286,6 +296,72 @@ function renderCloudStatus() {
286
296
  $("#cloudPullReplaceButton").disabled = !cloud.loggedIn;
287
297
  }
288
298
 
299
+ function renderUpdateStatus() {
300
+ const update = state.update;
301
+ if (!update) {
302
+ return;
303
+ }
304
+
305
+ const summary = $("#updateSummary");
306
+ const detail = $("#updateDetail");
307
+ const command = $("#updateCommand");
308
+ const copyButton = $("#copyUpdateCommandButton");
309
+
310
+ if (!summary || !detail || !command || !copyButton) {
311
+ return;
312
+ }
313
+
314
+ command.textContent = update.upgradeCommand;
315
+
316
+ if (update.hasUpdate) {
317
+ summary.textContent = `发现新版本 ${update.latestVersion}`;
318
+ detail.textContent = `当前版本 ${update.currentVersion},可以直接升级到最新版本。`;
319
+ command.hidden = false;
320
+ copyButton.hidden = false;
321
+ return;
322
+ }
323
+
324
+ if (update.lastError) {
325
+ summary.textContent = "暂时无法检查更新";
326
+ detail.textContent = `当前版本 ${update.currentVersion},原因:${update.lastError}`;
327
+ command.hidden = true;
328
+ copyButton.hidden = true;
329
+ return;
330
+ }
331
+
332
+ summary.textContent = `当前已是最新版 ${update.currentVersion}`;
333
+ detail.textContent = "npm 包没有检测到更新,可以继续直接使用。";
334
+ command.hidden = true;
335
+ copyButton.hidden = true;
336
+ }
337
+
338
+ async function refreshUpdateStatus(force = false) {
339
+ const summary = $("#updateSummary");
340
+ const detail = $("#updateDetail");
341
+
342
+ if (summary) {
343
+ summary.textContent = force ? "正在重新检查版本..." : "正在检查版本...";
344
+ }
345
+
346
+ if (detail) {
347
+ detail.textContent = "正在连接 npm 仓库,请稍候。";
348
+ }
349
+
350
+ try {
351
+ state.update = await bridge.getUpdateStatus({ force });
352
+ renderUpdateStatus();
353
+ } catch (error) {
354
+ state.update = {
355
+ currentVersion: "-",
356
+ latestVersion: "-",
357
+ hasUpdate: false,
358
+ upgradeCommand: "npm install -g codex-endpoint-switcher@latest",
359
+ lastError: error.message,
360
+ };
361
+ renderUpdateStatus();
362
+ }
363
+ }
364
+
289
365
  function createEndpointCard(endpoint) {
290
366
  const card = document.createElement("article");
291
367
  card.className = `profile-card ${endpoint.isActive ? "active" : ""}`;
@@ -348,9 +424,18 @@ function createEndpointCard(endpoint) {
348
424
  const meta = document.createElement("div");
349
425
  meta.className = "profile-meta";
350
426
  meta.innerHTML = `
351
- <span><strong>URL</strong>${endpoint.url || "-"}</span>
352
- <span><strong>Key</strong>${endpoint.maskedKey || "-"}</span>
353
- <span><strong>更新时间</strong>${formatDateTime(endpoint.updatedAt)}</span>
427
+ <div class="profile-meta-item">
428
+ <span class="profile-meta-label">URL</span>
429
+ <strong class="profile-meta-value">${endpoint.url || "-"}</strong>
430
+ </div>
431
+ <div class="profile-meta-item">
432
+ <span class="profile-meta-label">Key</span>
433
+ <strong class="profile-meta-value">${endpoint.maskedKey || "-"}</strong>
434
+ </div>
435
+ <div class="profile-meta-item">
436
+ <span class="profile-meta-label">更新时间</span>
437
+ <strong class="profile-meta-value">${formatDateTime(endpoint.updatedAt)}</strong>
438
+ </div>
354
439
  `;
355
440
 
356
441
  card.append(top, meta);
@@ -392,6 +477,7 @@ async function bootstrapConsoleAccess() {
392
477
  const cloud = await bridge.getCloudSyncStatus();
393
478
  state.cloud = cloud;
394
479
  renderCloudStatus();
480
+ void refreshUpdateStatus();
395
481
 
396
482
  if (!cloud.loggedIn) {
397
483
  applyAccessState(false);
@@ -408,6 +494,7 @@ async function bootstrapConsoleAccess() {
408
494
  setStatus("已通过账号校验,连接控制台已解锁。", "success");
409
495
  } catch (error) {
410
496
  applyAccessState(false);
497
+ void refreshUpdateStatus();
411
498
  setAuthStatus(`登录校验失败:${error.message}`, "error");
412
499
  }
413
500
  }
@@ -659,6 +746,20 @@ async function handleCloseApp() {
659
746
  }
660
747
  }
661
748
 
749
+ async function handleCopyUpdateCommand() {
750
+ const command = state.update?.upgradeCommand;
751
+ if (!command) {
752
+ return;
753
+ }
754
+
755
+ try {
756
+ await navigator.clipboard.writeText(command);
757
+ setStatus("升级命令已复制。", "success");
758
+ } catch (error) {
759
+ setStatus(`复制失败:${error.message}`, "error");
760
+ }
761
+ }
762
+
662
763
  function bindEvents() {
663
764
  $("#authForm").addEventListener("submit", (event) => {
664
765
  event.preventDefault();
@@ -706,6 +807,10 @@ function bindEvents() {
706
807
  handleOpenPath(state.paths.endpointStorePath, " 连接数据文件");
707
808
  }
708
809
  });
810
+ $("#checkUpdateButton").addEventListener("click", () => {
811
+ refreshUpdateStatus(true);
812
+ });
813
+ $("#copyUpdateCommandButton").addEventListener("click", handleCopyUpdateCommand);
709
814
  $("#closeAppButton").addEventListener("click", handleCloseApp);
710
815
  window.addEventListener("keydown", (event) => {
711
816
  if (event.key === "Escape" && !$("#endpointModal").hidden) {
@@ -278,6 +278,57 @@ code {
278
278
  margin-bottom: 6px;
279
279
  }
280
280
 
281
+ .update-strip {
282
+ display: grid;
283
+ grid-template-columns: minmax(0, 1fr) auto;
284
+ gap: 12px;
285
+ align-items: center;
286
+ margin-bottom: 6px;
287
+ padding: 10px 12px;
288
+ border-radius: var(--radius-md);
289
+ background: rgba(255, 255, 255, 0.56);
290
+ border: 1px solid rgba(47, 36, 30, 0.08);
291
+ }
292
+
293
+ .update-copy {
294
+ display: grid;
295
+ gap: 4px;
296
+ min-width: 0;
297
+ }
298
+
299
+ .update-copy strong {
300
+ display: block;
301
+ font-size: 0.92rem;
302
+ line-height: 1.25;
303
+ }
304
+
305
+ .update-detail {
306
+ color: var(--muted);
307
+ font-size: 0.8rem;
308
+ line-height: 1.35;
309
+ }
310
+
311
+ .update-command {
312
+ display: inline-flex;
313
+ width: fit-content;
314
+ max-width: 100%;
315
+ overflow: hidden;
316
+ text-overflow: ellipsis;
317
+ white-space: nowrap;
318
+ padding: 0.2rem 0.46rem;
319
+ }
320
+
321
+ .update-command[hidden] {
322
+ display: none !important;
323
+ }
324
+
325
+ .update-actions {
326
+ display: flex;
327
+ flex-wrap: wrap;
328
+ justify-content: flex-end;
329
+ gap: 8px;
330
+ }
331
+
281
332
  .metric-card {
282
333
  padding: 14px;
283
334
  border-radius: var(--radius-md);
@@ -552,9 +603,9 @@ input[readonly] {
552
603
  }
553
604
 
554
605
  .profiles-list {
555
- display: grid;
556
- grid-auto-rows: minmax(190px, auto);
557
- align-content: start;
606
+ display: flex;
607
+ flex-direction: column;
608
+ align-items: stretch;
558
609
  flex: 1 1 auto;
559
610
  gap: 14px;
560
611
  min-height: 0;
@@ -593,8 +644,9 @@ input[readonly] {
593
644
  display: grid;
594
645
  grid-template-rows: auto auto;
595
646
  gap: 14px;
596
- min-height: 100%;
647
+ min-height: 176px;
597
648
  padding: 18px;
649
+ box-sizing: border-box;
598
650
  border-radius: var(--radius-md);
599
651
  background: var(--panel-strong);
600
652
  border: 1px solid rgba(47, 36, 30, 0.08);
@@ -649,22 +701,27 @@ input[readonly] {
649
701
 
650
702
  .profile-meta {
651
703
  display: grid;
652
- grid-template-rows: repeat(3, auto);
653
- gap: 8px;
704
+ grid-template-columns: repeat(3, minmax(0, 1fr));
705
+ gap: 10px 14px;
654
706
  min-height: 0;
655
- align-content: start;
707
+ align-items: start;
656
708
  }
657
709
 
658
- .profile-meta span {
710
+ .profile-meta-item {
659
711
  display: grid;
660
712
  gap: 4px;
713
+ min-width: 0;
714
+ }
715
+
716
+ .profile-meta-label {
661
717
  color: var(--muted);
662
718
  font-size: 0.92rem;
663
- min-width: 0;
664
719
  }
665
720
 
666
- .profile-meta strong {
721
+ .profile-meta-value {
667
722
  color: var(--text);
723
+ display: block;
724
+ min-width: 0;
668
725
  white-space: nowrap;
669
726
  overflow: hidden;
670
727
  text-overflow: ellipsis;
@@ -906,6 +963,7 @@ input[readonly] {
906
963
  }
907
964
 
908
965
  .current-tools button,
966
+ .update-actions button,
909
967
  .action-row button,
910
968
  .account-actions-grid button,
911
969
  .card-actions button {
@@ -923,6 +981,15 @@ input[readonly] {
923
981
  gap: 8px;
924
982
  }
925
983
 
984
+ .update-strip {
985
+ grid-template-columns: 1fr;
986
+ gap: 10px;
987
+ }
988
+
989
+ .update-actions {
990
+ justify-content: flex-start;
991
+ }
992
+
926
993
  .current-panel,
927
994
  .account-panel,
928
995
  .profiles-panel {
@@ -991,8 +1058,14 @@ input[readonly] {
991
1058
  grid-template-columns: 1fr;
992
1059
  }
993
1060
 
1061
+ .update-command {
1062
+ white-space: normal;
1063
+ overflow: visible;
1064
+ text-overflow: clip;
1065
+ }
1066
+
994
1067
  .profiles-list {
995
- grid-auto-rows: auto;
1068
+ gap: 12px;
996
1069
  }
997
1070
 
998
1071
  .profile-card {
@@ -1000,10 +1073,10 @@ input[readonly] {
1000
1073
  }
1001
1074
 
1002
1075
  .profile-meta {
1003
- grid-template-rows: none;
1076
+ grid-template-columns: 1fr;
1004
1077
  }
1005
1078
 
1006
- .profile-meta strong {
1079
+ .profile-meta-value {
1007
1080
  white-space: normal;
1008
1081
  overflow: visible;
1009
1082
  text-overflow: clip;
package/src/web/server.js CHANGED
@@ -3,6 +3,7 @@ const { exec } = require("node:child_process");
3
3
  const express = require("express");
4
4
  const profileManager = require("../main/profile-manager");
5
5
  const cloudSyncClient = require("../main/cloud-sync-client");
6
+ const updateService = require("../main/update-service");
6
7
  const { createProxyServer, warmUpCurrentTarget } = require("./proxy-server");
7
8
 
8
9
  function wrapAsync(handler) {
@@ -179,6 +180,15 @@ function createApp() {
179
180
  }),
180
181
  );
181
182
 
183
+ app.get(
184
+ "/api/app/update-status",
185
+ wrapAsync(async (req) => {
186
+ return updateService.getUpdateStatus({
187
+ force: String(req.query.force || "").trim() === "1",
188
+ });
189
+ }),
190
+ );
191
+
182
192
  app.post(
183
193
  "/api/open-path",
184
194
  wrapAsync(async (req) => {