codex-slot 0.1.8 → 0.1.17
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/dist/app/service-lifecycle-service.js +42 -0
- package/dist/codex-auth.js +134 -0
- package/dist/codex-config.js +314 -66
- package/dist/state.js +77 -0
- package/dist/status-command.js +96 -2
- package/dist/status.js +195 -49
- package/dist/usage-sync.js +28 -2
- package/package.json +1 -1
|
@@ -11,9 +11,13 @@ const node_net_1 = __importDefault(require("node:net"));
|
|
|
11
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
12
12
|
const node_child_process_1 = require("node:child_process");
|
|
13
13
|
const undici_1 = require("undici");
|
|
14
|
+
const account_service_1 = require("./account-service");
|
|
15
|
+
const account_store_1 = require("../account-store");
|
|
14
16
|
const codex_config_1 = require("../codex-config");
|
|
17
|
+
const codex_auth_1 = require("../codex-auth");
|
|
15
18
|
const cli_helpers_1 = require("../cli-helpers");
|
|
16
19
|
const config_1 = require("../config");
|
|
20
|
+
const scheduler_1 = require("../scheduler");
|
|
17
21
|
const STARTUP_POLL_INTERVAL_MS = 100;
|
|
18
22
|
const STARTUP_TIMEOUT_MS = 5000;
|
|
19
23
|
/**
|
|
@@ -163,6 +167,41 @@ function rollbackFailedStart(pid, previousConfig) {
|
|
|
163
167
|
node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
|
|
164
168
|
(0, config_1.saveConfig)(previousConfig);
|
|
165
169
|
(0, codex_config_1.deactivateManagedCodexConfig)();
|
|
170
|
+
(0, codex_auth_1.deactivateManagedCodexAuth)();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 选择一个可用于接管主 `~/.codex` 登录态的账号。
|
|
174
|
+
*
|
|
175
|
+
* 业务规则:
|
|
176
|
+
* 1. 优先复用当前调度器已经选中的最佳账号,保证 CLI 与代理请求走同一身份。
|
|
177
|
+
* 2. 若当前没有可调度账号,则回退到首个启用且本地工作空间仍存在的账号。
|
|
178
|
+
* 3. 若仍无可用账号,则返回 `null`,此时仅接管 provider 配置,不强行覆盖主登录态。
|
|
179
|
+
*
|
|
180
|
+
* @returns 选中的受管账号;若不存在可接管账号则返回 `null`。
|
|
181
|
+
* @throws 无显式抛出。
|
|
182
|
+
*/
|
|
183
|
+
function resolveManagedAuthAccount() {
|
|
184
|
+
const selected = (0, scheduler_1.pickBestAccount)()?.account;
|
|
185
|
+
if (selected && (0, account_store_1.hasCompleteCodexAuthState)(selected.codex_home)) {
|
|
186
|
+
return selected;
|
|
187
|
+
}
|
|
188
|
+
return ((0, account_service_1.listAccounts)().find((account) => account.enabled &&
|
|
189
|
+
node_fs_1.default.existsSync(account.codex_home) &&
|
|
190
|
+
(0, account_store_1.hasCompleteCodexAuthState)(account.codex_home)) ?? null);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 将主 `~/.codex` 登录态切换到当前受管账号,供 `codex_apps` 等依赖主登录态的链路复用。
|
|
194
|
+
*
|
|
195
|
+
* @returns 实际接管的账号;若没有合适账号则返回 `null`。
|
|
196
|
+
* @throws 当目标账号目录缺少完整登录态时抛出异常。
|
|
197
|
+
*/
|
|
198
|
+
function applyManagedAuthIfPossible() {
|
|
199
|
+
const account = resolveManagedAuthAccount();
|
|
200
|
+
if (!account) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
(0, codex_auth_1.applyManagedCodexAuth)(account.codex_home, { sourceAccountId: account.id });
|
|
204
|
+
return account;
|
|
166
205
|
}
|
|
167
206
|
/**
|
|
168
207
|
* 为后台服务挑选最终启动端口。
|
|
@@ -225,6 +264,7 @@ async function startManagedService(portOverride) {
|
|
|
225
264
|
// 每次真正启动服务前都轮换一次本地 api_key,并让受管 config.toml 使用同一新值。
|
|
226
265
|
const persistedConfig = (0, config_1.rotateServerApiKey)(config);
|
|
227
266
|
(0, codex_config_1.applyManagedCodexConfig)(undefined, { config: persistedConfig });
|
|
267
|
+
applyManagedAuthIfPossible();
|
|
228
268
|
const logPath = (0, config_1.getServiceLogPath)();
|
|
229
269
|
const logFd = node_fs_1.default.openSync(logPath, "a");
|
|
230
270
|
const serveEntrypoint = resolveServeEntrypoint();
|
|
@@ -267,10 +307,12 @@ function stopManagedService() {
|
|
|
267
307
|
const pid = getRunningPid();
|
|
268
308
|
if (!pid) {
|
|
269
309
|
(0, codex_config_1.deactivateManagedCodexConfig)();
|
|
310
|
+
(0, codex_auth_1.deactivateManagedCodexAuth)();
|
|
270
311
|
return { stoppedPid: null };
|
|
271
312
|
}
|
|
272
313
|
process.kill(pid, "SIGTERM");
|
|
273
314
|
node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
|
|
274
315
|
(0, codex_config_1.deactivateManagedCodexConfig)();
|
|
316
|
+
(0, codex_auth_1.deactivateManagedCodexAuth)();
|
|
275
317
|
return { stoppedPid: pid };
|
|
276
318
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getDefaultCodexHome = getDefaultCodexHome;
|
|
7
|
+
exports.applyManagedCodexAuth = applyManagedCodexAuth;
|
|
8
|
+
exports.deactivateManagedCodexAuth = deactivateManagedCodexAuth;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const account_store_1 = require("./account-store");
|
|
12
|
+
const state_1 = require("./state");
|
|
13
|
+
/**
|
|
14
|
+
* 返回默认的 Codex HOME 目录。
|
|
15
|
+
*
|
|
16
|
+
* @returns 当前进程 HOME;未设置时返回空字符串。
|
|
17
|
+
*/
|
|
18
|
+
function getDefaultCodexHome() {
|
|
19
|
+
return process.env.HOME ?? "";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 读取目标 HOME 下 `.codex/accounts` 目录中的所有 `.auth.json` 文件内容。
|
|
23
|
+
*
|
|
24
|
+
* @param codexHome 目标 HOME 目录。
|
|
25
|
+
* @returns 文件名到原始文本的映射;目录不存在时返回空对象。
|
|
26
|
+
*/
|
|
27
|
+
function snapshotAccountAuthFiles(codexHome) {
|
|
28
|
+
const accountsDir = node_path_1.default.join((0, account_store_1.getCodexDataDir)(codexHome), "accounts");
|
|
29
|
+
if (!node_fs_1.default.existsSync(accountsDir)) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
const snapshots = {};
|
|
33
|
+
for (const entry of node_fs_1.default.readdirSync(accountsDir, { withFileTypes: true })) {
|
|
34
|
+
if (!entry.isFile() || !entry.name.endsWith(".auth.json")) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
snapshots[entry.name] = node_fs_1.default.readFileSync(node_path_1.default.join(accountsDir, entry.name), "utf8");
|
|
38
|
+
}
|
|
39
|
+
return snapshots;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 基于当前目标 HOME 生成登录态恢复快照。
|
|
43
|
+
*
|
|
44
|
+
* @param targetHome 需要接管的主 HOME 目录。
|
|
45
|
+
* @param sourceAccountId 可选的来源账号标识,仅用于状态记录。
|
|
46
|
+
* @returns 用于 stop 恢复的快照。
|
|
47
|
+
*/
|
|
48
|
+
function buildManagedAuthSnapshot(targetHome, sourceAccountId) {
|
|
49
|
+
const codexDir = (0, account_store_1.getCodexDataDir)(targetHome);
|
|
50
|
+
const authPath = node_path_1.default.join(codexDir, "auth.json");
|
|
51
|
+
const registryPath = node_path_1.default.join(codexDir, "accounts", "registry.json");
|
|
52
|
+
return {
|
|
53
|
+
target_home: targetHome,
|
|
54
|
+
source_account_id: sourceAccountId ?? null,
|
|
55
|
+
original_auth_file: node_fs_1.default.existsSync(authPath) ? node_fs_1.default.readFileSync(authPath, "utf8") : null,
|
|
56
|
+
original_registry_file: node_fs_1.default.existsSync(registryPath) ? node_fs_1.default.readFileSync(registryPath, "utf8") : null,
|
|
57
|
+
original_account_auth_files: snapshotAccountAuthFiles(targetHome)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 将指定文件恢复为快照内容;快照为空时删除目标文件。
|
|
62
|
+
*
|
|
63
|
+
* @param targetFile 目标文件路径。
|
|
64
|
+
* @param content 原始文件内容;为 `null` 时表示恢复为不存在。
|
|
65
|
+
* @returns 无返回值。
|
|
66
|
+
*/
|
|
67
|
+
function restoreSnapshotFile(targetFile, content) {
|
|
68
|
+
if (content === null) {
|
|
69
|
+
node_fs_1.default.rmSync(targetFile, { force: true });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(targetFile), { recursive: true });
|
|
73
|
+
node_fs_1.default.writeFileSync(targetFile, content, "utf8");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 将主 HOME 中多余的 `.auth.json` 文件删除,再恢复快照中记录的文件集合。
|
|
77
|
+
*
|
|
78
|
+
* @param targetHome 目标 HOME 目录。
|
|
79
|
+
* @param snapshot 登录态快照。
|
|
80
|
+
* @returns 无返回值。
|
|
81
|
+
*/
|
|
82
|
+
function restoreAccountAuthFiles(targetHome, snapshot) {
|
|
83
|
+
const accountsDir = node_path_1.default.join((0, account_store_1.getCodexDataDir)(targetHome), "accounts");
|
|
84
|
+
node_fs_1.default.mkdirSync(accountsDir, { recursive: true });
|
|
85
|
+
for (const entry of node_fs_1.default.readdirSync(accountsDir, { withFileTypes: true })) {
|
|
86
|
+
if (!entry.isFile() || !entry.name.endsWith(".auth.json")) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!(entry.name in snapshot.original_account_auth_files)) {
|
|
90
|
+
node_fs_1.default.rmSync(node_path_1.default.join(accountsDir, entry.name), { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [fileName, content] of Object.entries(snapshot.original_account_auth_files)) {
|
|
94
|
+
restoreSnapshotFile(node_path_1.default.join(accountsDir, fileName), content);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 将主 `~/.codex` 登录态切换到指定受管账号,供 `codex_apps` 等依赖主登录态的能力复用。
|
|
99
|
+
*
|
|
100
|
+
* @param sourceHome 来源账号 HOME。
|
|
101
|
+
* @param options 可选控制项;可指定目标 HOME 与来源账号标识。
|
|
102
|
+
* @returns 实际接管的目标 HOME 路径。
|
|
103
|
+
*/
|
|
104
|
+
function applyManagedCodexAuth(sourceHome, options) {
|
|
105
|
+
const targetHome = options?.targetHome ?? getDefaultCodexHome();
|
|
106
|
+
const previousState = (0, state_1.getManagedCodexAuthState)();
|
|
107
|
+
const snapshot = previousState && previousState.target_home === targetHome
|
|
108
|
+
? previousState
|
|
109
|
+
: buildManagedAuthSnapshot(targetHome, options?.sourceAccountId);
|
|
110
|
+
(0, account_store_1.cloneCodexAuthState)(sourceHome, targetHome);
|
|
111
|
+
(0, state_1.setManagedCodexAuthState)({
|
|
112
|
+
...snapshot,
|
|
113
|
+
source_account_id: options?.sourceAccountId ?? snapshot.source_account_id ?? null
|
|
114
|
+
});
|
|
115
|
+
return targetHome;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 恢复主 `~/.codex` 登录态到 cslot 接管前的原始状态。
|
|
119
|
+
*
|
|
120
|
+
* @returns 恢复的目标 HOME 路径;若没有接管快照则返回 `null`。
|
|
121
|
+
*/
|
|
122
|
+
function deactivateManagedCodexAuth() {
|
|
123
|
+
const snapshot = (0, state_1.getManagedCodexAuthState)();
|
|
124
|
+
if (!snapshot) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const targetHome = snapshot.target_home;
|
|
128
|
+
const codexDir = (0, account_store_1.getCodexDataDir)(targetHome);
|
|
129
|
+
restoreSnapshotFile(node_path_1.default.join(codexDir, "auth.json"), snapshot.original_auth_file);
|
|
130
|
+
restoreSnapshotFile(node_path_1.default.join(codexDir, "accounts", "registry.json"), snapshot.original_registry_file);
|
|
131
|
+
restoreAccountAuthFiles(targetHome, snapshot);
|
|
132
|
+
(0, state_1.clearManagedCodexAuthState)();
|
|
133
|
+
return targetHome;
|
|
134
|
+
}
|
package/dist/codex-config.js
CHANGED
|
@@ -73,9 +73,31 @@ function buildManagedProviderBlock(eol, config) {
|
|
|
73
73
|
`base_url = "http://${config.server.host}:${config.server.port}/v1"`,
|
|
74
74
|
'wire_api = "responses"',
|
|
75
75
|
`experimental_bearer_token = "${config.server.api_key}"`,
|
|
76
|
+
"[model_providers.cslot.http_headers]",
|
|
77
|
+
`Authorization = "Bearer ${config.server.api_key}"`,
|
|
76
78
|
PROVIDER_BLOCK_END_MARKER
|
|
77
79
|
].join(eol);
|
|
78
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* 判断候选表头是否属于指定父表的子表。
|
|
83
|
+
*
|
|
84
|
+
* @param parentHeader 父表表头,例如 `[model_providers.cslot]`。
|
|
85
|
+
* @param candidateHeader 当前扫描到的候选表头。
|
|
86
|
+
* @returns `true` 表示候选表头属于父表子表;否则返回 `false`。
|
|
87
|
+
*/
|
|
88
|
+
function isChildTableHeader(parentHeader, candidateHeader) {
|
|
89
|
+
const normalizedParent = parentHeader.trim();
|
|
90
|
+
const normalizedCandidate = candidateHeader.trim();
|
|
91
|
+
if (!normalizedParent.startsWith("[") || !normalizedParent.endsWith("]")) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (!normalizedCandidate.startsWith("[") || !normalizedCandidate.endsWith("]")) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const parentName = normalizedParent.slice(1, -1);
|
|
98
|
+
const candidateName = normalizedCandidate.slice(1, -1);
|
|
99
|
+
return candidateName.startsWith(`${parentName}.`);
|
|
100
|
+
}
|
|
79
101
|
/**
|
|
80
102
|
* 在文本中定位受 cslot 接管的块范围。
|
|
81
103
|
*
|
|
@@ -103,29 +125,22 @@ function findMarkedBlockRange(content, startMarker, endMarker) {
|
|
|
103
125
|
return { start, end };
|
|
104
126
|
}
|
|
105
127
|
/**
|
|
106
|
-
*
|
|
128
|
+
* 反复移除文本中所有带指定标记的受管块,避免异常退出后残留旧块导致后续写入出现重复或串位。
|
|
107
129
|
*
|
|
108
130
|
* @param content 当前 `config.toml` 内容。
|
|
109
|
-
* @param
|
|
110
|
-
* @
|
|
131
|
+
* @param startMarker 块起始标记。
|
|
132
|
+
* @param endMarker 块结束标记。
|
|
133
|
+
* @returns 清理后的文本内容。
|
|
111
134
|
*/
|
|
112
|
-
function
|
|
113
|
-
let
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
const modelProviderRange = findMarkedBlockRange(restored, MODEL_PROVIDER_START_MARKER, MODEL_PROVIDER_END_MARKER);
|
|
122
|
-
if (modelProviderRange) {
|
|
123
|
-
restored =
|
|
124
|
-
restored.slice(0, modelProviderRange.start) +
|
|
125
|
-
(managedState.original_model_provider_block ?? "") +
|
|
126
|
-
restored.slice(modelProviderRange.end);
|
|
135
|
+
function stripMarkedBlocks(content, startMarker, endMarker) {
|
|
136
|
+
let stripped = content;
|
|
137
|
+
while (true) {
|
|
138
|
+
const range = findMarkedBlockRange(stripped, startMarker, endMarker);
|
|
139
|
+
if (!range) {
|
|
140
|
+
return stripped;
|
|
141
|
+
}
|
|
142
|
+
stripped = stripped.slice(0, range.start) + stripped.slice(range.end);
|
|
127
143
|
}
|
|
128
|
-
return restored;
|
|
129
144
|
}
|
|
130
145
|
/**
|
|
131
146
|
* 查找首个 `model_provider` 配置块,兼容已启用与注释掉的场景。
|
|
@@ -183,12 +198,13 @@ function findModelProviderLine(content) {
|
|
|
183
198
|
return null;
|
|
184
199
|
}
|
|
185
200
|
/**
|
|
186
|
-
*
|
|
201
|
+
* 查找指定表块的文本范围。
|
|
187
202
|
*
|
|
188
203
|
* @param content 当前 `config.toml` 内容。
|
|
204
|
+
* @param header 目标表头,例如 `[model_providers.cslot]`。
|
|
189
205
|
* @returns 命中时返回完整表块范围;未命中返回 `null`。
|
|
190
206
|
*/
|
|
191
|
-
function
|
|
207
|
+
function findTableSectionRange(content, header) {
|
|
192
208
|
const lines = content.split(/\r?\n/);
|
|
193
209
|
let offset = 0;
|
|
194
210
|
let startLineIndex = -1;
|
|
@@ -196,7 +212,7 @@ function findProviderSectionRange(content) {
|
|
|
196
212
|
for (let i = 0; i < lines.length; i += 1) {
|
|
197
213
|
const line = lines[i];
|
|
198
214
|
const lineEnd = offset + line.length;
|
|
199
|
-
if (line.trim() ===
|
|
215
|
+
if (line.trim() === header) {
|
|
200
216
|
startLineIndex = i;
|
|
201
217
|
startOffset = offset;
|
|
202
218
|
break;
|
|
@@ -212,7 +228,10 @@ function findProviderSectionRange(content) {
|
|
|
212
228
|
const line = lines[i];
|
|
213
229
|
const lineEnd = offset + line.length;
|
|
214
230
|
const trimmed = line.trim();
|
|
215
|
-
if (i > startLineIndex &&
|
|
231
|
+
if (i > startLineIndex &&
|
|
232
|
+
trimmed.startsWith("[") &&
|
|
233
|
+
!trimmed.startsWith("[[") &&
|
|
234
|
+
!isChildTableHeader(header, trimmed)) {
|
|
216
235
|
break;
|
|
217
236
|
}
|
|
218
237
|
endOffset = lineEnd;
|
|
@@ -234,6 +253,164 @@ function findProviderSectionRange(content) {
|
|
|
234
253
|
value: content.slice(startOffset, endOffset)
|
|
235
254
|
};
|
|
236
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* 查找 `[model_providers.cslot]` 表块的文本范围。
|
|
258
|
+
*
|
|
259
|
+
* @param content 当前 `config.toml` 内容。
|
|
260
|
+
* @returns 命中时返回完整表块范围;未命中返回 `null`。
|
|
261
|
+
*/
|
|
262
|
+
function findProviderSectionRange(content) {
|
|
263
|
+
return findTableSectionRange(content, "[model_providers.cslot]");
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 查找指定表头所在的行起始偏移。
|
|
267
|
+
*
|
|
268
|
+
* @param content 当前 `config.toml` 内容。
|
|
269
|
+
* @param header 目标表头。
|
|
270
|
+
* @returns 命中时返回表头行起始偏移;未命中返回 `null`。
|
|
271
|
+
*/
|
|
272
|
+
function findTableHeaderOffset(content, header) {
|
|
273
|
+
const lines = content.split(/\r?\n/);
|
|
274
|
+
let offset = 0;
|
|
275
|
+
for (const line of lines) {
|
|
276
|
+
const lineEnd = offset + line.length;
|
|
277
|
+
if (line.trim() === header) {
|
|
278
|
+
return offset;
|
|
279
|
+
}
|
|
280
|
+
offset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 查找指定偏移之前最近的表头,供恢复原有表块位置时作为后备锚点。
|
|
286
|
+
*
|
|
287
|
+
* @param content 当前 `config.toml` 内容。
|
|
288
|
+
* @param offset 截止偏移。
|
|
289
|
+
* @returns 最近的表头文本;未命中返回 `null`。
|
|
290
|
+
*/
|
|
291
|
+
function findPreviousTableHeaderBeforeOffset(content, offset) {
|
|
292
|
+
const lines = content.split(/\r?\n/);
|
|
293
|
+
let currentOffset = 0;
|
|
294
|
+
let previousHeader = null;
|
|
295
|
+
for (const line of lines) {
|
|
296
|
+
const lineEnd = currentOffset + line.length;
|
|
297
|
+
const trimmed = line.trim();
|
|
298
|
+
if (currentOffset >= offset) {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
if (trimmed.startsWith("[") && !trimmed.startsWith("[[") && !trimmed.startsWith("#")) {
|
|
302
|
+
previousHeader = trimmed;
|
|
303
|
+
}
|
|
304
|
+
currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
|
|
305
|
+
}
|
|
306
|
+
return previousHeader;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* 查找指定偏移之后的首个表头,供恢复原有表块位置时作为优先锚点。
|
|
310
|
+
*
|
|
311
|
+
* @param content 当前 `config.toml` 内容。
|
|
312
|
+
* @param offset 起始偏移。
|
|
313
|
+
* @returns 首个后续表头文本;未命中返回 `null`。
|
|
314
|
+
*/
|
|
315
|
+
function findNextTableHeaderAfterOffset(content, offset) {
|
|
316
|
+
const lines = content.split(/\r?\n/);
|
|
317
|
+
let currentOffset = 0;
|
|
318
|
+
for (const line of lines) {
|
|
319
|
+
const lineEnd = currentOffset + line.length;
|
|
320
|
+
const trimmed = line.trim();
|
|
321
|
+
if (currentOffset >= offset &&
|
|
322
|
+
trimmed.startsWith("[") &&
|
|
323
|
+
!trimmed.startsWith("[[") &&
|
|
324
|
+
!trimmed.startsWith("#")) {
|
|
325
|
+
return trimmed;
|
|
326
|
+
}
|
|
327
|
+
currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 清理文本中的所有 `model_provider` 配置块,确保每次接管都以单一稳定块重新写入。
|
|
333
|
+
*
|
|
334
|
+
* @param content 当前 `config.toml` 内容。
|
|
335
|
+
* @returns 移除后的文本内容。
|
|
336
|
+
*/
|
|
337
|
+
function removeAllModelProviderLines(content) {
|
|
338
|
+
let nextContent = content;
|
|
339
|
+
while (true) {
|
|
340
|
+
const range = findModelProviderLine(nextContent);
|
|
341
|
+
if (!range) {
|
|
342
|
+
return nextContent;
|
|
343
|
+
}
|
|
344
|
+
nextContent = nextContent.slice(0, range.start) + nextContent.slice(range.end);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 清理文本中的所有 `[model_providers.cslot]` 表块,避免残留旧块影响下一段配置。
|
|
349
|
+
*
|
|
350
|
+
* @param content 当前 `config.toml` 内容。
|
|
351
|
+
* @returns 移除后的文本内容。
|
|
352
|
+
*/
|
|
353
|
+
function removeAllProviderSections(content) {
|
|
354
|
+
let nextContent = content;
|
|
355
|
+
while (true) {
|
|
356
|
+
const range = findProviderSectionRange(nextContent);
|
|
357
|
+
if (!range) {
|
|
358
|
+
return nextContent;
|
|
359
|
+
}
|
|
360
|
+
nextContent = nextContent.slice(0, range.start) + nextContent.slice(range.end);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 移除所有 cslot 受管标记块,得到不包含历史残留接管片段的基线内容。
|
|
365
|
+
*
|
|
366
|
+
* @param content 当前 `config.toml` 内容。
|
|
367
|
+
* @returns 清理后的基线内容。
|
|
368
|
+
*/
|
|
369
|
+
function stripAllManagedBlocks(content) {
|
|
370
|
+
const withoutProviderBlock = stripMarkedBlocks(content, PROVIDER_BLOCK_START_MARKER, PROVIDER_BLOCK_END_MARKER);
|
|
371
|
+
return stripMarkedBlocks(withoutProviderBlock, MODEL_PROVIDER_START_MARKER, MODEL_PROVIDER_END_MARKER);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* 查找根级配置区的尾部插入点。
|
|
375
|
+
*
|
|
376
|
+
* 业务规则:
|
|
377
|
+
* 1. 若文件中存在 table header,则插入到首个 table 之前,保证 `model_provider` 仍处于根级作用域。
|
|
378
|
+
* 2. 若文件不存在任何 table,则允许直接追加到文件尾部。
|
|
379
|
+
*
|
|
380
|
+
* @param content 当前 `config.toml` 内容。
|
|
381
|
+
* @returns 可用于插入根级配置块的偏移位置。
|
|
382
|
+
*/
|
|
383
|
+
function findRootSectionInsertOffset(content) {
|
|
384
|
+
const lines = content.split(/\r?\n/);
|
|
385
|
+
let offset = 0;
|
|
386
|
+
for (const line of lines) {
|
|
387
|
+
const lineEnd = offset + line.length;
|
|
388
|
+
const trimmed = line.trim();
|
|
389
|
+
if (trimmed.startsWith("[") && !trimmed.startsWith("#")) {
|
|
390
|
+
return offset;
|
|
391
|
+
}
|
|
392
|
+
offset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
|
|
393
|
+
}
|
|
394
|
+
return content.length;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* 将根级配置块插回根级区域。
|
|
398
|
+
*
|
|
399
|
+
* 若能命中原始记录的后续表头,则优先插回该表头前;否则回退到首个表头前。
|
|
400
|
+
*
|
|
401
|
+
* @param content 当前 `config.toml` 内容。
|
|
402
|
+
* @param block 待插入的根级配置块。
|
|
403
|
+
* @param eol 目标换行符。
|
|
404
|
+
* @param preferredNextTableHeader 原始记录的后续表头锚点。
|
|
405
|
+
* @returns 插入后的完整文本。
|
|
406
|
+
*/
|
|
407
|
+
function insertRootBlock(content, block, eol, preferredNextTableHeader) {
|
|
408
|
+
const preferredOffset = preferredNextTableHeader
|
|
409
|
+
? findTableHeaderOffset(content, preferredNextTableHeader)
|
|
410
|
+
: null;
|
|
411
|
+
const insertOffset = preferredOffset ?? findRootSectionInsertOffset(content);
|
|
412
|
+
return insertBlockBetween(content.slice(0, insertOffset), block, content.slice(insertOffset), eol);
|
|
413
|
+
}
|
|
237
414
|
/**
|
|
238
415
|
* 将指定文本规范为单个块插入形式,避免在块两侧不断叠加多余空行。
|
|
239
416
|
*
|
|
@@ -248,6 +425,103 @@ function insertBlockBetween(before, block, after, eol) {
|
|
|
248
425
|
const normalizedAfter = after.startsWith(eol) || after.length === 0 ? after : `${eol}${after}`;
|
|
249
426
|
return `${normalizedBefore}${block}${normalizedAfter}`;
|
|
250
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* 将配置块稳定追加到文件尾部,统一清理尾部多余空行,避免多次接管后空行不断累积。
|
|
430
|
+
*
|
|
431
|
+
* @param content 当前 `config.toml` 内容。
|
|
432
|
+
* @param block 待追加配置块。
|
|
433
|
+
* @param eol 目标换行符。
|
|
434
|
+
* @returns 追加后的完整文本。
|
|
435
|
+
*/
|
|
436
|
+
function appendBlockToEnd(content, block, eol) {
|
|
437
|
+
let trimmed = content;
|
|
438
|
+
while (trimmed.endsWith(eol)) {
|
|
439
|
+
trimmed = trimmed.slice(0, -eol.length);
|
|
440
|
+
}
|
|
441
|
+
if (trimmed.length === 0) {
|
|
442
|
+
return `${block}${eol}`;
|
|
443
|
+
}
|
|
444
|
+
return `${trimmed}${eol}${eol}${block}${eol}`;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* 将表块尽量插回原有相邻表头附近;若锚点已不存在,则退回文件尾部追加。
|
|
448
|
+
*
|
|
449
|
+
* @param content 当前 `config.toml` 内容。
|
|
450
|
+
* @param block 待插入的表块。
|
|
451
|
+
* @param eol 目标换行符。
|
|
452
|
+
* @param preferredNextTableHeader 原始后续表头锚点,命中时优先插到该表之前。
|
|
453
|
+
* @param preferredPreviousTableHeader 原始前驱表头锚点,当前者失效时插到该表之后。
|
|
454
|
+
* @returns 插入后的完整文本。
|
|
455
|
+
*/
|
|
456
|
+
function insertTableBlock(content, block, eol, preferredNextTableHeader, preferredPreviousTableHeader) {
|
|
457
|
+
if (preferredNextTableHeader) {
|
|
458
|
+
const nextOffset = findTableHeaderOffset(content, preferredNextTableHeader);
|
|
459
|
+
if (nextOffset !== null) {
|
|
460
|
+
return insertBlockBetween(content.slice(0, nextOffset), block, content.slice(nextOffset), eol);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (preferredPreviousTableHeader) {
|
|
464
|
+
const previousRange = findTableSectionRange(content, preferredPreviousTableHeader);
|
|
465
|
+
if (previousRange) {
|
|
466
|
+
return insertBlockBetween(content.slice(0, previousRange.end), block, content.slice(previousRange.end), eol);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return appendBlockToEnd(content, block, eol);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* 解析当前目标文件对应的上一轮接管快照。
|
|
473
|
+
*
|
|
474
|
+
* @param targetFile 当前准备接管或恢复的 `config.toml` 路径。
|
|
475
|
+
* @returns 命中同一目标文件时返回上一轮快照;否则返回 `null`。
|
|
476
|
+
*/
|
|
477
|
+
function resolveManagedStateForTarget(targetFile) {
|
|
478
|
+
const managedState = (0, state_1.getManagedCodexConfigState)();
|
|
479
|
+
if (!managedState || managedState.target_file !== targetFile) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return managedState;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* 基于当前未受管的配置文本与上一轮快照,生成本轮接管所需的最小恢复快照。
|
|
486
|
+
*
|
|
487
|
+
* 业务规则:
|
|
488
|
+
* 1. 优先记录当前文件里实际存在的原始 `model_provider` 与 `[model_providers.cslot]`。
|
|
489
|
+
* 2. 若当前文件只剩残留受管块,允许继承上一轮快照中的原始片段。
|
|
490
|
+
* 3. 仅保存 cslot 自己声明所有权的两块配置及其锚点,不保存整文件内容。
|
|
491
|
+
*
|
|
492
|
+
* @param targetFile 当前准备接管的 `config.toml` 路径。
|
|
493
|
+
* @param strippedCurrent 已移除受管标记块后的配置文本。
|
|
494
|
+
* @param previousManagedState 同一目标文件的上一轮快照;不存在时传 `null`。
|
|
495
|
+
* @returns 本轮接管后用于 stop 恢复的快照。
|
|
496
|
+
*/
|
|
497
|
+
function buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState) {
|
|
498
|
+
const originalModelProviderLine = findModelProviderLine(strippedCurrent);
|
|
499
|
+
const originalProviderSection = findProviderSectionRange(strippedCurrent);
|
|
500
|
+
return {
|
|
501
|
+
target_file: targetFile,
|
|
502
|
+
original_model_provider_block: originalModelProviderLine?.value ??
|
|
503
|
+
previousManagedState?.original_model_provider_block ??
|
|
504
|
+
null,
|
|
505
|
+
original_model_provider_next_table_header: (originalModelProviderLine
|
|
506
|
+
? findNextTableHeaderAfterOffset(strippedCurrent, originalModelProviderLine.end)
|
|
507
|
+
: null) ??
|
|
508
|
+
previousManagedState?.original_model_provider_next_table_header ??
|
|
509
|
+
null,
|
|
510
|
+
original_cslot_provider_block: originalProviderSection?.value ??
|
|
511
|
+
previousManagedState?.original_cslot_provider_block ??
|
|
512
|
+
null,
|
|
513
|
+
original_cslot_provider_previous_table_header: (originalProviderSection
|
|
514
|
+
? findPreviousTableHeaderBeforeOffset(strippedCurrent, originalProviderSection.start)
|
|
515
|
+
: null) ??
|
|
516
|
+
previousManagedState?.original_cslot_provider_previous_table_header ??
|
|
517
|
+
null,
|
|
518
|
+
original_cslot_provider_next_table_header: (originalProviderSection
|
|
519
|
+
? findNextTableHeaderAfterOffset(strippedCurrent, originalProviderSection.end)
|
|
520
|
+
: null) ??
|
|
521
|
+
previousManagedState?.original_cslot_provider_next_table_header ??
|
|
522
|
+
null
|
|
523
|
+
};
|
|
524
|
+
}
|
|
251
525
|
/**
|
|
252
526
|
* 将 cslot 需要的 provider 配置写入指定 `config.toml`,并保存恢复快照。
|
|
253
527
|
*
|
|
@@ -260,51 +534,16 @@ function applyManagedCodexConfig(targetPathOrDir, options) {
|
|
|
260
534
|
const rawTarget = targetPathOrDir ? (0, config_1.expandHome)(targetPathOrDir) : getDefaultCodexConfigPath();
|
|
261
535
|
const targetFile = rawTarget.endsWith(".toml") ? rawTarget : node_path_1.default.join(rawTarget, "config.toml");
|
|
262
536
|
const current = node_fs_1.default.existsSync(targetFile) ? node_fs_1.default.readFileSync(targetFile, "utf8") : "";
|
|
263
|
-
const previousManagedState = (
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const eol = detectEol(baseContent);
|
|
268
|
-
const originalModelProviderLine = findModelProviderLine(baseContent);
|
|
269
|
-
const originalProviderSection = findProviderSectionRange(baseContent);
|
|
270
|
-
const snapshot = {
|
|
271
|
-
target_file: targetFile,
|
|
272
|
-
original_model_provider_block: originalModelProviderLine?.value ?? null,
|
|
273
|
-
original_cslot_provider_block: originalProviderSection?.value ?? null
|
|
274
|
-
};
|
|
537
|
+
const previousManagedState = resolveManagedStateForTarget(targetFile);
|
|
538
|
+
const strippedCurrent = stripAllManagedBlocks(current);
|
|
539
|
+
const eol = detectEol(strippedCurrent);
|
|
540
|
+
const snapshot = buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState);
|
|
275
541
|
const config = options?.config ?? (0, config_1.loadConfig)();
|
|
276
|
-
let nextContent = baseContent;
|
|
277
542
|
const managedModelProviderBlock = buildManagedModelProviderBlock(eol);
|
|
278
543
|
const managedProviderBlock = buildManagedProviderBlock(eol, config);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
nextContent.slice(0, originalProviderSection.start) +
|
|
283
|
-
managedProviderBlock +
|
|
284
|
-
nextContent.slice(originalProviderSection.end);
|
|
285
|
-
}
|
|
286
|
-
else if (nextContent.length > 0) {
|
|
287
|
-
nextContent = insertBlockBetween(nextContent, managedProviderBlock, "", eol);
|
|
288
|
-
}
|
|
289
|
-
else {
|
|
290
|
-
nextContent = `${managedProviderBlock}${eol}`;
|
|
291
|
-
}
|
|
292
|
-
const modelProviderLine = findModelProviderLine(nextContent);
|
|
293
|
-
if (modelProviderLine) {
|
|
294
|
-
nextContent =
|
|
295
|
-
nextContent.slice(0, modelProviderLine.start) +
|
|
296
|
-
managedModelProviderBlock +
|
|
297
|
-
nextContent.slice(modelProviderLine.end);
|
|
298
|
-
}
|
|
299
|
-
else {
|
|
300
|
-
const firstNonWhitespaceMatch = nextContent.match(/\S/);
|
|
301
|
-
if (firstNonWhitespaceMatch && firstNonWhitespaceMatch.index !== undefined) {
|
|
302
|
-
nextContent = insertBlockBetween(nextContent.slice(0, firstNonWhitespaceMatch.index), managedModelProviderBlock, nextContent.slice(firstNonWhitespaceMatch.index), eol);
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
nextContent = `${managedModelProviderBlock}${eol}`;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
544
|
+
const cleanedBaseContent = removeAllProviderSections(removeAllModelProviderLines(strippedCurrent));
|
|
545
|
+
let nextContent = insertRootBlock(cleanedBaseContent, managedModelProviderBlock, eol, snapshot.original_model_provider_next_table_header);
|
|
546
|
+
nextContent = appendBlockToEnd(nextContent, managedProviderBlock, eol);
|
|
308
547
|
if (!nextContent.endsWith(eol)) {
|
|
309
548
|
nextContent = `${nextContent}${eol}`;
|
|
310
549
|
}
|
|
@@ -335,7 +574,16 @@ function deactivateManagedCodexConfig() {
|
|
|
335
574
|
return null;
|
|
336
575
|
}
|
|
337
576
|
const current = node_fs_1.default.readFileSync(targetFile, "utf8");
|
|
338
|
-
const
|
|
577
|
+
const eol = detectEol(current);
|
|
578
|
+
let restored = stripAllManagedBlocks(current);
|
|
579
|
+
const existingModelProviderLine = findModelProviderLine(restored);
|
|
580
|
+
if (!existingModelProviderLine && managedState.original_model_provider_block) {
|
|
581
|
+
restored = insertRootBlock(restored, managedState.original_model_provider_block, eol, managedState.original_model_provider_next_table_header);
|
|
582
|
+
}
|
|
583
|
+
const existingProviderSection = findProviderSectionRange(restored);
|
|
584
|
+
if (!existingProviderSection && managedState.original_cslot_provider_block) {
|
|
585
|
+
restored = insertTableBlock(restored, managedState.original_cslot_provider_block, eol, managedState.original_cslot_provider_next_table_header, managedState.original_cslot_provider_previous_table_header);
|
|
586
|
+
}
|
|
339
587
|
writeFileAtomic(targetFile, restored);
|
|
340
588
|
(0, state_1.clearManagedCodexConfigState)();
|
|
341
589
|
return targetFile;
|
package/dist/state.js
CHANGED
|
@@ -10,9 +10,15 @@ exports.pruneExpiredBlocks = pruneExpiredBlocks;
|
|
|
10
10
|
exports.getAccountBlock = getAccountBlock;
|
|
11
11
|
exports.setUsageCache = setUsageCache;
|
|
12
12
|
exports.getUsageCache = getUsageCache;
|
|
13
|
+
exports.setUsageRefreshError = setUsageRefreshError;
|
|
14
|
+
exports.clearUsageRefreshError = clearUsageRefreshError;
|
|
15
|
+
exports.getUsageRefreshError = getUsageRefreshError;
|
|
13
16
|
exports.getManagedCodexConfigState = getManagedCodexConfigState;
|
|
17
|
+
exports.getManagedCodexAuthState = getManagedCodexAuthState;
|
|
14
18
|
exports.setManagedCodexConfigState = setManagedCodexConfigState;
|
|
19
|
+
exports.setManagedCodexAuthState = setManagedCodexAuthState;
|
|
15
20
|
exports.clearManagedCodexConfigState = clearManagedCodexConfigState;
|
|
21
|
+
exports.clearManagedCodexAuthState = clearManagedCodexAuthState;
|
|
16
22
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
23
|
const node_path_1 = __importDefault(require("node:path"));
|
|
18
24
|
const config_1 = require("./config");
|
|
@@ -30,6 +36,8 @@ function loadState() {
|
|
|
30
36
|
return {
|
|
31
37
|
account_blocks: {},
|
|
32
38
|
usage_cache: {},
|
|
39
|
+
usage_refresh_errors: {},
|
|
40
|
+
managed_codex_auth: null,
|
|
33
41
|
managed_codex_config: null
|
|
34
42
|
};
|
|
35
43
|
}
|
|
@@ -39,11 +47,15 @@ function loadState() {
|
|
|
39
47
|
: {
|
|
40
48
|
account_blocks: {},
|
|
41
49
|
usage_cache: {},
|
|
50
|
+
usage_refresh_errors: {},
|
|
51
|
+
managed_codex_auth: null,
|
|
42
52
|
managed_codex_config: null
|
|
43
53
|
};
|
|
44
54
|
return {
|
|
45
55
|
account_blocks: parsed.account_blocks ?? {},
|
|
46
56
|
usage_cache: parsed.usage_cache ?? {},
|
|
57
|
+
usage_refresh_errors: parsed.usage_refresh_errors ?? {},
|
|
58
|
+
managed_codex_auth: parsed.managed_codex_auth ?? null,
|
|
47
59
|
managed_codex_config: parsed.managed_codex_config ?? null
|
|
48
60
|
};
|
|
49
61
|
}
|
|
@@ -125,6 +137,41 @@ function getUsageCache(accountId) {
|
|
|
125
137
|
const state = loadState();
|
|
126
138
|
return state.usage_cache[accountId] ?? null;
|
|
127
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* 记录指定账号最近一次额度刷新失败的状态,供 `status` 命令渲染为账号状态而不是直接打印异常。
|
|
142
|
+
*
|
|
143
|
+
* @param usageError 刷新失败信息,包含账号、状态码与原始错误摘要。
|
|
144
|
+
* @returns 无返回值。
|
|
145
|
+
*/
|
|
146
|
+
function setUsageRefreshError(usageError) {
|
|
147
|
+
const state = loadState();
|
|
148
|
+
state.usage_refresh_errors[usageError.accountId] = usageError;
|
|
149
|
+
saveState(state);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 清理指定账号最近一次记录的额度刷新失败状态,避免后续成功刷新后继续展示旧错误。
|
|
153
|
+
*
|
|
154
|
+
* @param accountId 账号标识。
|
|
155
|
+
* @returns 无返回值。
|
|
156
|
+
*/
|
|
157
|
+
function clearUsageRefreshError(accountId) {
|
|
158
|
+
const state = loadState();
|
|
159
|
+
if (!(accountId in state.usage_refresh_errors)) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
delete state.usage_refresh_errors[accountId];
|
|
163
|
+
saveState(state);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 读取指定账号最近一次记录的额度刷新失败状态。
|
|
167
|
+
*
|
|
168
|
+
* @param accountId 账号标识。
|
|
169
|
+
* @returns 刷新失败信息;若不存在则返回 `null`。
|
|
170
|
+
*/
|
|
171
|
+
function getUsageRefreshError(accountId) {
|
|
172
|
+
const state = loadState();
|
|
173
|
+
return state.usage_refresh_errors[accountId] ?? null;
|
|
174
|
+
}
|
|
128
175
|
/**
|
|
129
176
|
* 读取当前记录的 Codex `config.toml` 接管快照。
|
|
130
177
|
*
|
|
@@ -134,6 +181,15 @@ function getManagedCodexConfigState() {
|
|
|
134
181
|
const state = loadState();
|
|
135
182
|
return state.managed_codex_config ?? null;
|
|
136
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* 读取当前记录的 Codex 主 HOME 登录态接管快照。
|
|
186
|
+
*
|
|
187
|
+
* @returns 最近一次接管时保存的登录态快照;不存在时返回 `null`。
|
|
188
|
+
*/
|
|
189
|
+
function getManagedCodexAuthState() {
|
|
190
|
+
const state = loadState();
|
|
191
|
+
return state.managed_codex_auth ?? null;
|
|
192
|
+
}
|
|
137
193
|
/**
|
|
138
194
|
* 保存 Codex `config.toml` 接管快照,用于后续停止服务时精确恢复。
|
|
139
195
|
*
|
|
@@ -145,6 +201,17 @@ function setManagedCodexConfigState(managedState) {
|
|
|
145
201
|
state.managed_codex_config = managedState;
|
|
146
202
|
saveState(state);
|
|
147
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* 保存 Codex 主 HOME 登录态接管快照,用于 stop 时恢复原始登录态文件。
|
|
206
|
+
*
|
|
207
|
+
* @param managedState 接管前保存的原始登录态快照。
|
|
208
|
+
* @returns 无返回值。
|
|
209
|
+
*/
|
|
210
|
+
function setManagedCodexAuthState(managedState) {
|
|
211
|
+
const state = loadState();
|
|
212
|
+
state.managed_codex_auth = managedState;
|
|
213
|
+
saveState(state);
|
|
214
|
+
}
|
|
148
215
|
/**
|
|
149
216
|
* 清理 Codex `config.toml` 接管快照。
|
|
150
217
|
*
|
|
@@ -155,3 +222,13 @@ function clearManagedCodexConfigState() {
|
|
|
155
222
|
state.managed_codex_config = null;
|
|
156
223
|
saveState(state);
|
|
157
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* 清理 Codex 主 HOME 登录态接管快照。
|
|
227
|
+
*
|
|
228
|
+
* @returns 无返回值。
|
|
229
|
+
*/
|
|
230
|
+
function clearManagedCodexAuthState() {
|
|
231
|
+
const state = loadState();
|
|
232
|
+
state.managed_codex_auth = null;
|
|
233
|
+
saveState(state);
|
|
234
|
+
}
|
package/dist/status-command.js
CHANGED
|
@@ -10,6 +10,87 @@ const status_service_1 = require("./app/status-service");
|
|
|
10
10
|
const scheduler_1 = require("./scheduler");
|
|
11
11
|
const status_1 = require("./status");
|
|
12
12
|
const text_1 = require("./text");
|
|
13
|
+
const ANSI = {
|
|
14
|
+
reset: "\x1b[0m",
|
|
15
|
+
bold: "\x1b[1m",
|
|
16
|
+
dim: "\x1b[2m",
|
|
17
|
+
cyan: "\x1b[36m",
|
|
18
|
+
green: "\x1b[32m",
|
|
19
|
+
yellow: "\x1b[33m"
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* 判断当前终端是否适合启用 ANSI 样式,避免在 dumb/no-color 环境输出控制字符。
|
|
23
|
+
*
|
|
24
|
+
* @returns 可安全启用样式时返回 `true`,否则返回 `false`。
|
|
25
|
+
* @throws 无显式抛出。
|
|
26
|
+
*/
|
|
27
|
+
function shouldUseAnsiStyle() {
|
|
28
|
+
return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 对文本应用 ANSI 样式;当样式关闭时原样返回。
|
|
32
|
+
*
|
|
33
|
+
* @param text 原始文本。
|
|
34
|
+
* @param color ANSI 颜色码。
|
|
35
|
+
* @param enabled 是否启用 ANSI 样式。
|
|
36
|
+
* @returns 样式化后的文本或原文。
|
|
37
|
+
* @throws 无显式抛出。
|
|
38
|
+
*/
|
|
39
|
+
function paint(text, color, enabled) {
|
|
40
|
+
if (!enabled) {
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
return `${color}${text}${ANSI.reset}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 渲染分区标题行,兼容窄终端与普通宽度终端。
|
|
47
|
+
*
|
|
48
|
+
* @param title 分区标题文本。
|
|
49
|
+
* @param width 当前终端宽度。
|
|
50
|
+
* @param styled 是否启用 ANSI 样式。
|
|
51
|
+
* @returns 可直接打印的单行分区标题。
|
|
52
|
+
* @throws 无显式抛出。
|
|
53
|
+
*/
|
|
54
|
+
function renderSectionHeader(title, width, styled) {
|
|
55
|
+
if (width < 44) {
|
|
56
|
+
return paint(`[ ${title} ]`, ANSI.cyan, styled);
|
|
57
|
+
}
|
|
58
|
+
const plainLabel = ` ${title} `;
|
|
59
|
+
const targetWidth = Math.max(plainLabel.length + 2, Math.min(width, 96));
|
|
60
|
+
const side = Math.max(1, Math.floor((targetWidth - plainLabel.length) / 2));
|
|
61
|
+
const line = `${"-".repeat(side)}${plainLabel}${"-".repeat(side)}`;
|
|
62
|
+
return paint(line.slice(0, targetWidth), ANSI.cyan, styled);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 渲染轻量分隔线,用于账号主表与当前账号明细之间建立清晰层次。
|
|
66
|
+
*
|
|
67
|
+
* @param width 当前终端宽度。
|
|
68
|
+
* @param styled 是否启用 ANSI 样式。
|
|
69
|
+
* @returns 可直接打印的分隔线文本。
|
|
70
|
+
* @throws 无显式抛出。
|
|
71
|
+
*/
|
|
72
|
+
function renderDivider(width, styled) {
|
|
73
|
+
const dividerWidth = Math.max(24, Math.min(width, 96));
|
|
74
|
+
return paint("-".repeat(dividerWidth), ANSI.dim, styled);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 渲染摘要区可读性更高的计数文本,并对关键指标做轻量着色。
|
|
78
|
+
*
|
|
79
|
+
* @param summary 状态摘要计数。
|
|
80
|
+
* @param narrowScreen 是否窄屏布局。
|
|
81
|
+
* @param styled 是否启用 ANSI 样式。
|
|
82
|
+
* @returns 摘要展示文本。
|
|
83
|
+
* @throws 无显式抛出。
|
|
84
|
+
*/
|
|
85
|
+
function renderSummaryLine(summary, narrowScreen, styled) {
|
|
86
|
+
const available = paint(String(summary.available), ANSI.green, styled);
|
|
87
|
+
const fiveHourLimited = paint(String(summary.fiveHourLimited), ANSI.yellow, styled);
|
|
88
|
+
const weeklyLimited = paint(String(summary.weeklyLimited), ANSI.yellow, styled);
|
|
89
|
+
if (narrowScreen) {
|
|
90
|
+
return `ok=${available} 5h=${fiveHourLimited} wk=${weeklyLimited}`;
|
|
91
|
+
}
|
|
92
|
+
return `available=${available} 5h_limited=${fiveHourLimited} weekly_limited=${weeklyLimited}`;
|
|
93
|
+
}
|
|
13
94
|
/**
|
|
14
95
|
* 进入交互式全屏缓冲区,并隐藏光标,确保后续重绘始终基于固定画布。
|
|
15
96
|
*
|
|
@@ -103,6 +184,9 @@ async function handleInteractiveToggle(initialStatuses) {
|
|
|
103
184
|
return await new Promise((resolve) => {
|
|
104
185
|
let closed = false;
|
|
105
186
|
const render = () => {
|
|
187
|
+
const screenWidth = process.stdout.columns ?? 80;
|
|
188
|
+
const narrowScreen = screenWidth < 72;
|
|
189
|
+
const styled = shouldUseAnsiStyle();
|
|
106
190
|
const latestSnapshot = (0, status_service_1.getStatusSnapshot)();
|
|
107
191
|
const statusSource = changed ? latestSnapshot.statuses : (initialStatuses ?? latestSnapshot.statuses);
|
|
108
192
|
const statusById = new Map(statusSource.map((item) => [item.id, item]));
|
|
@@ -120,18 +204,28 @@ async function handleInteractiveToggle(initialStatuses) {
|
|
|
120
204
|
};
|
|
121
205
|
})
|
|
122
206
|
.filter((item) => item !== null);
|
|
207
|
+
const currentItem = displayStatuses.find((item) => item.id === accounts[cursor]?.id) ?? null;
|
|
123
208
|
renderInteractiveScreen([
|
|
209
|
+
renderSectionHeader("accounts", screenWidth, styled),
|
|
124
210
|
(0, status_1.renderStatusTable)(displayStatuses, {
|
|
211
|
+
compact: true,
|
|
212
|
+
maxWidth: screenWidth,
|
|
125
213
|
selectorColumn: {
|
|
126
214
|
enabledById: Object.fromEntries(accounts.map((account) => [account.id, account.enabled])),
|
|
127
215
|
cursorAccountId: accounts[cursor]?.id ?? null
|
|
128
216
|
}
|
|
129
217
|
}),
|
|
130
218
|
"",
|
|
131
|
-
|
|
219
|
+
renderDivider(screenWidth, styled),
|
|
220
|
+
renderSectionHeader("current", screenWidth, styled),
|
|
221
|
+
(0, status_1.renderStatusDetails)(currentItem, { maxWidth: screenWidth, header: false }),
|
|
222
|
+
"",
|
|
223
|
+
renderSectionHeader("summary", screenWidth, styled),
|
|
224
|
+
renderSummaryLine(summary, narrowScreen, styled),
|
|
132
225
|
`selected=${latestSnapshot.selectedName ?? "none"}`,
|
|
133
226
|
"",
|
|
134
|
-
(
|
|
227
|
+
renderSectionHeader("help", screenWidth, styled),
|
|
228
|
+
(0, text_1.bi)(narrowScreen ? "Space 切换,Enter / q 退出。" : "Space 切换启用状态,Enter / q 退出。", narrowScreen ? "Space toggles, Enter or q exits." : "Space toggles enabled state, Enter or q exits.")
|
|
135
229
|
]);
|
|
136
230
|
};
|
|
137
231
|
const applyChanges = () => {
|
package/dist/status.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.collectAccountStatuses = collectAccountStatuses;
|
|
4
4
|
exports.summarizeAccountStatuses = summarizeAccountStatuses;
|
|
5
5
|
exports.renderStatusTable = renderStatusTable;
|
|
6
|
+
exports.renderStatusDetails = renderStatusDetails;
|
|
6
7
|
const config_1 = require("./config");
|
|
7
8
|
const account_store_1 = require("./account-store");
|
|
8
9
|
const state_1 = require("./state");
|
|
@@ -28,6 +29,69 @@ function formatPercent(value) {
|
|
|
28
29
|
function formatReset(unixSeconds) {
|
|
29
30
|
return (0, text_1.formatLocalDateTime)(unixSeconds);
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* 按给定最大宽度截断单元格文本,优先保证表格整体不换行。
|
|
34
|
+
*
|
|
35
|
+
* @param value 原始文本。
|
|
36
|
+
* @param maxWidth 最大宽度。
|
|
37
|
+
* @returns 截断后的文本;宽度过小时退化为最短可读形式。
|
|
38
|
+
*/
|
|
39
|
+
function truncateCell(value, maxWidth) {
|
|
40
|
+
if (maxWidth <= 0) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
if (value.length <= maxWidth) {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
if (maxWidth <= 2) {
|
|
47
|
+
return value.slice(0, maxWidth);
|
|
48
|
+
}
|
|
49
|
+
return `${value.slice(0, maxWidth - 1)}…`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 生成固定标签宽度的详情行,超出终端宽度时自动截断值部分。
|
|
53
|
+
*
|
|
54
|
+
* @param label 字段标签。
|
|
55
|
+
* @param value 字段值。
|
|
56
|
+
* @param maxWidth 当前可用最大宽度。
|
|
57
|
+
* @returns 单行详情文本。
|
|
58
|
+
*/
|
|
59
|
+
function formatDetailLine(label, value, maxWidth) {
|
|
60
|
+
const prefix = `${label.padEnd(6)} `;
|
|
61
|
+
const safeWidth = Number.isFinite(maxWidth) ? maxWidth : prefix.length + value.length;
|
|
62
|
+
const valueWidth = Math.max(8, safeWidth - prefix.length);
|
|
63
|
+
return `${prefix}${truncateCell(value, valueWidth)}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 将状态对象归一化为单个紧凑状态标签,供表格与详情面板复用。
|
|
67
|
+
*
|
|
68
|
+
* @param item 单个账号状态。
|
|
69
|
+
* @returns 适合在终端展示的状态标签。
|
|
70
|
+
*/
|
|
71
|
+
function resolveStatusLabel(item) {
|
|
72
|
+
if (item.refreshErrorCode) {
|
|
73
|
+
return item.refreshErrorCode;
|
|
74
|
+
}
|
|
75
|
+
if (!item.exists) {
|
|
76
|
+
return "missing";
|
|
77
|
+
}
|
|
78
|
+
if (!item.enabled) {
|
|
79
|
+
return "disabled";
|
|
80
|
+
}
|
|
81
|
+
if (item.localBlockUntil && item.localBlockUntil * 1000 > Date.now()) {
|
|
82
|
+
return formatBlockedStatus(item.localBlockReason, item.localBlockUntil);
|
|
83
|
+
}
|
|
84
|
+
if (item.isWeeklyLimited) {
|
|
85
|
+
return formatLimitStatus("weekly_limited", item.weeklyResetsAt);
|
|
86
|
+
}
|
|
87
|
+
if (item.isFiveHourLimited) {
|
|
88
|
+
return formatLimitStatus("5h_limited", item.fiveHourResetsAt);
|
|
89
|
+
}
|
|
90
|
+
if (item.isAvailable) {
|
|
91
|
+
return "available";
|
|
92
|
+
}
|
|
93
|
+
return "unknown";
|
|
94
|
+
}
|
|
31
95
|
function formatLimitStatus(label, resetAt) {
|
|
32
96
|
const remaining = formatRemainingDuration(resetAt);
|
|
33
97
|
if (!remaining) {
|
|
@@ -44,6 +108,46 @@ function normalizeBlockReason(reason) {
|
|
|
44
108
|
}
|
|
45
109
|
return reason;
|
|
46
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* 将账号工作空间读取异常统一归类为状态码,避免状态汇总阶段直接抛出异常中断整个命令。
|
|
113
|
+
*
|
|
114
|
+
* @param error 工作空间读取过程中抛出的异常。
|
|
115
|
+
* @returns 归一化后的状态码与错误摘要。
|
|
116
|
+
*/
|
|
117
|
+
function classifyWorkspaceStatusError(error) {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
119
|
+
return {
|
|
120
|
+
code: "workspace_invalid",
|
|
121
|
+
message
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 读取账号工作空间的本地登录态摘要;若目录损坏或 JSON 非法,则降级为工作空间不可用状态。
|
|
126
|
+
*
|
|
127
|
+
* @param codexHome 账号隔离 HOME 目录。
|
|
128
|
+
* @returns 是否存在完整登录态、主账号信息以及可选的工作空间错误状态。
|
|
129
|
+
*/
|
|
130
|
+
function readWorkspaceSnapshot(codexHome) {
|
|
131
|
+
try {
|
|
132
|
+
const exists = (0, account_store_1.hasCompleteCodexAuthState)(codexHome);
|
|
133
|
+
const primary = exists ? (0, account_store_1.resolvePrimaryRegistryAccount)(codexHome) : null;
|
|
134
|
+
return {
|
|
135
|
+
exists,
|
|
136
|
+
primary,
|
|
137
|
+
workspaceErrorCode: null,
|
|
138
|
+
workspaceErrorMessage: null
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const workspaceError = classifyWorkspaceStatusError(error);
|
|
143
|
+
return {
|
|
144
|
+
exists: false,
|
|
145
|
+
primary: null,
|
|
146
|
+
workspaceErrorCode: workspaceError.code,
|
|
147
|
+
workspaceErrorMessage: workspaceError.message
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
47
151
|
/**
|
|
48
152
|
* 将剩余秒数格式化为紧凑的人类可读文本,便于在状态列中展示熔断剩余时间。
|
|
49
153
|
*
|
|
@@ -92,10 +196,10 @@ function formatBlockedStatus(reason, until) {
|
|
|
92
196
|
function collectAccountStatuses() {
|
|
93
197
|
const config = (0, config_1.loadConfig)();
|
|
94
198
|
return config.accounts.map((account) => {
|
|
95
|
-
const
|
|
96
|
-
const primary = exists ? (0, account_store_1.resolvePrimaryRegistryAccount)(account.codex_home) : null;
|
|
199
|
+
const workspace = readWorkspaceSnapshot(account.codex_home);
|
|
97
200
|
const usageCache = (0, state_1.getUsageCache)(account.id);
|
|
98
|
-
const
|
|
201
|
+
const refreshError = (0, state_1.getUsageRefreshError)(account.id);
|
|
202
|
+
const activeEmail = usageCache?.email ?? workspace.primary?.email ?? account.email;
|
|
99
203
|
const fiveHourUsed = usageCache?.fiveHourUsedPercent ?? null;
|
|
100
204
|
const fiveHourReset = usageCache?.fiveHourResetAt ?? null;
|
|
101
205
|
const weeklyUsed = usageCache?.weeklyUsedPercent ?? null;
|
|
@@ -106,13 +210,15 @@ function collectAccountStatuses() {
|
|
|
106
210
|
const isWeeklyLimited = isLimited(weeklyUsed, weeklyReset);
|
|
107
211
|
const localBlock = (0, state_1.getAccountBlock)(account.id);
|
|
108
212
|
const localBlocked = localBlock?.until != null ? localBlock.until * 1000 > Date.now() : false;
|
|
213
|
+
const refreshErrorCode = workspace.workspaceErrorCode ?? refreshError?.code ?? null;
|
|
214
|
+
const refreshErrorMessage = workspace.workspaceErrorMessage ?? refreshError?.message ?? null;
|
|
109
215
|
return {
|
|
110
216
|
id: account.id,
|
|
111
217
|
name: account.name,
|
|
112
218
|
email: activeEmail,
|
|
113
219
|
enabled: account.enabled,
|
|
114
|
-
exists,
|
|
115
|
-
plan: usageCache?.plan ?? primary?.plan ?? "-",
|
|
220
|
+
exists: workspace.exists,
|
|
221
|
+
plan: usageCache?.plan ?? workspace.primary?.plan ?? "-",
|
|
116
222
|
fiveHourLeftPercent,
|
|
117
223
|
fiveHourResetsAt: fiveHourReset,
|
|
118
224
|
weeklyLeftPercent,
|
|
@@ -121,8 +227,11 @@ function collectAccountStatuses() {
|
|
|
121
227
|
isWeeklyLimited,
|
|
122
228
|
localBlockReason: localBlock?.reason,
|
|
123
229
|
localBlockUntil: localBlock?.until ?? null,
|
|
230
|
+
refreshErrorCode,
|
|
231
|
+
refreshErrorMessage,
|
|
124
232
|
isAvailable: account.enabled &&
|
|
125
|
-
exists &&
|
|
233
|
+
workspace.exists &&
|
|
234
|
+
!refreshErrorCode &&
|
|
126
235
|
!isFiveHourLimited &&
|
|
127
236
|
!isWeeklyLimited &&
|
|
128
237
|
!localBlocked,
|
|
@@ -152,58 +261,95 @@ function summarizeAccountStatuses(statuses) {
|
|
|
152
261
|
*/
|
|
153
262
|
function renderStatusTable(statuses, options) {
|
|
154
263
|
const selectorColumn = options?.selectorColumn;
|
|
264
|
+
const compact = options?.compact ?? false;
|
|
265
|
+
const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
|
|
266
|
+
const compactHeader = maxWidth < 68;
|
|
267
|
+
const compactSlotWidth = maxWidth < 56 ? 8 : 12;
|
|
268
|
+
const compactPlanWidth = maxWidth < 56 ? 4 : 6;
|
|
269
|
+
const compactStatusWidth = maxWidth < 56 ? 12 : 18;
|
|
155
270
|
const rows = [
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
271
|
+
compact
|
|
272
|
+
? [
|
|
273
|
+
...(selectorColumn ? [" "] : []),
|
|
274
|
+
compactHeader ? "ID" : "SLOT",
|
|
275
|
+
compactHeader ? "P" : "PLAN",
|
|
276
|
+
"5H",
|
|
277
|
+
compactHeader ? "WK" : "WEEK",
|
|
278
|
+
compactHeader ? "ST" : "STATUS"
|
|
279
|
+
]
|
|
280
|
+
: [
|
|
281
|
+
...(selectorColumn ? [" "] : []),
|
|
282
|
+
"NAME",
|
|
283
|
+
"EMAIL",
|
|
284
|
+
"PLAN",
|
|
285
|
+
"5H_LEFT",
|
|
286
|
+
"5H_RESET",
|
|
287
|
+
"WEEK_LEFT",
|
|
288
|
+
"WEEK_RESET",
|
|
289
|
+
"STATUS"
|
|
290
|
+
]
|
|
167
291
|
];
|
|
168
292
|
for (const item of statuses) {
|
|
169
|
-
|
|
170
|
-
if (item.exists) {
|
|
171
|
-
if (!item.enabled) {
|
|
172
|
-
status = "disabled";
|
|
173
|
-
}
|
|
174
|
-
else if (item.localBlockUntil && item.localBlockUntil * 1000 > Date.now()) {
|
|
175
|
-
status = formatBlockedStatus(item.localBlockReason, item.localBlockUntil);
|
|
176
|
-
}
|
|
177
|
-
else if (item.isWeeklyLimited) {
|
|
178
|
-
status = formatLimitStatus("weekly_limited", item.weeklyResetsAt);
|
|
179
|
-
}
|
|
180
|
-
else if (item.isFiveHourLimited) {
|
|
181
|
-
status = formatLimitStatus("5h_limited", item.fiveHourResetsAt);
|
|
182
|
-
}
|
|
183
|
-
else if (item.isAvailable) {
|
|
184
|
-
status = "available";
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
status = "unknown";
|
|
188
|
-
}
|
|
189
|
-
}
|
|
293
|
+
const status = resolveStatusLabel(item);
|
|
190
294
|
const selectorCell = selectorColumn
|
|
191
295
|
? `${selectorColumn.cursorAccountId === item.id ? ">" : " "}[${selectorColumn.enabledById[item.id] ? "x" : " "}]`
|
|
192
296
|
: null;
|
|
193
|
-
rows.push(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
297
|
+
rows.push(compact
|
|
298
|
+
? [
|
|
299
|
+
...(selectorCell ? [selectorCell] : []),
|
|
300
|
+
truncateCell(item.name, compactSlotWidth),
|
|
301
|
+
truncateCell(item.plan, compactPlanWidth),
|
|
302
|
+
formatPercent(item.fiveHourLeftPercent),
|
|
303
|
+
formatPercent(item.weeklyLeftPercent),
|
|
304
|
+
truncateCell(status, compactStatusWidth)
|
|
305
|
+
]
|
|
306
|
+
: [
|
|
307
|
+
...(selectorCell ? [selectorCell] : []),
|
|
308
|
+
item.name,
|
|
309
|
+
item.email ?? "-",
|
|
310
|
+
item.plan,
|
|
311
|
+
formatPercent(item.fiveHourLeftPercent),
|
|
312
|
+
formatReset(item.fiveHourResetsAt),
|
|
313
|
+
formatPercent(item.weeklyLeftPercent),
|
|
314
|
+
formatReset(item.weeklyResetsAt),
|
|
315
|
+
status
|
|
316
|
+
]);
|
|
204
317
|
}
|
|
205
318
|
const widths = rows[0].map((_, columnIndex) => Math.max(...rows.map((row) => row[columnIndex].length)));
|
|
206
319
|
return rows
|
|
207
320
|
.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" "))
|
|
208
321
|
.join("\n");
|
|
209
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* 将当前选中账号渲染为紧凑详情区,补充主表中省略的邮箱、重置时间与错误摘要。
|
|
325
|
+
*
|
|
326
|
+
* @param item 当前选中的账号状态;为空时返回占位提示。
|
|
327
|
+
* @param options 详情区渲染选项。
|
|
328
|
+
* @returns 适合直接打印的详情区文本。
|
|
329
|
+
*/
|
|
330
|
+
function renderStatusDetails(item, options) {
|
|
331
|
+
const includeHeader = options?.header ?? true;
|
|
332
|
+
if (!item) {
|
|
333
|
+
return [includeHeader ? "[ current ]" : "slot -", includeHeader ? "slot -" : ""]
|
|
334
|
+
.filter((line) => line.length > 0)
|
|
335
|
+
.join("\n");
|
|
336
|
+
}
|
|
337
|
+
const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
|
|
338
|
+
const narrow = maxWidth < 72;
|
|
339
|
+
const lines = [
|
|
340
|
+
...(includeHeader ? ["[ current ]"] : []),
|
|
341
|
+
formatDetailLine("slot", `${item.name} plan=${item.plan}`, maxWidth),
|
|
342
|
+
formatDetailLine("email", item.email ?? "-", maxWidth),
|
|
343
|
+
formatDetailLine("status", resolveStatusLabel(item), maxWidth),
|
|
344
|
+
narrow
|
|
345
|
+
? formatDetailLine("5h", `${formatPercent(item.fiveHourLeftPercent)} reset=${formatReset(item.fiveHourResetsAt)}`, maxWidth)
|
|
346
|
+
: formatDetailLine("5h", `${formatPercent(item.fiveHourLeftPercent)} reset=${formatReset(item.fiveHourResetsAt)}`, maxWidth),
|
|
347
|
+
narrow
|
|
348
|
+
? formatDetailLine("week", `${formatPercent(item.weeklyLeftPercent)} reset=${formatReset(item.weeklyResetsAt)}`, maxWidth)
|
|
349
|
+
: formatDetailLine("week", `${formatPercent(item.weeklyLeftPercent)} reset=${formatReset(item.weeklyResetsAt)}`, maxWidth)
|
|
350
|
+
];
|
|
351
|
+
if (item.refreshErrorMessage) {
|
|
352
|
+
lines.push(formatDetailLine("error", item.refreshErrorMessage, narrow ? Math.max(28, maxWidth) : Math.min(maxWidth, 96)));
|
|
353
|
+
}
|
|
354
|
+
return lines.join("\n");
|
|
355
|
+
}
|
package/dist/usage-sync.js
CHANGED
|
@@ -21,6 +21,32 @@ function normalizeResetAt(value, resetAfterSeconds) {
|
|
|
21
21
|
}
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* 将额度刷新异常归类为可直接展示在 `status` 表格中的状态码。
|
|
26
|
+
*
|
|
27
|
+
* @param accountId 刷新失败的账号标识。
|
|
28
|
+
* @param error 刷新流程抛出的原始异常。
|
|
29
|
+
* @returns 归一化后的刷新失败状态。
|
|
30
|
+
*/
|
|
31
|
+
function classifyUsageRefreshError(accountId, error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
const workspaceInvalidPatterns = [
|
|
34
|
+
"未找到账号",
|
|
35
|
+
"缺少 access_token",
|
|
36
|
+
"缺少 refresh_token",
|
|
37
|
+
"Unexpected end of JSON input",
|
|
38
|
+
"Unexpected token"
|
|
39
|
+
];
|
|
40
|
+
const code = workspaceInvalidPatterns.some((pattern) => message.includes(pattern))
|
|
41
|
+
? "workspace_invalid"
|
|
42
|
+
: "refresh_failed";
|
|
43
|
+
return {
|
|
44
|
+
accountId,
|
|
45
|
+
code,
|
|
46
|
+
message,
|
|
47
|
+
updatedAt: new Date().toISOString()
|
|
48
|
+
};
|
|
49
|
+
}
|
|
24
50
|
/**
|
|
25
51
|
* 使用 refresh token 刷新指定账号的 access token,并回写到账号目录。
|
|
26
52
|
*
|
|
@@ -122,6 +148,7 @@ async function refreshAccountUsage(accountId) {
|
|
|
122
148
|
refreshedAt: new Date().toISOString()
|
|
123
149
|
};
|
|
124
150
|
(0, state_1.setUsageCache)(result);
|
|
151
|
+
(0, state_1.clearUsageRefreshError)(accountId);
|
|
125
152
|
return result;
|
|
126
153
|
}
|
|
127
154
|
/**
|
|
@@ -182,8 +209,7 @@ async function refreshAllAccountUsage() {
|
|
|
182
209
|
results.push(result);
|
|
183
210
|
}
|
|
184
211
|
catch (error) {
|
|
185
|
-
|
|
186
|
-
console.error((0, text_1.bi)(`[refresh] ${account.id} 失败: ${message}`, `[refresh] ${account.id} failed: ${message}`));
|
|
212
|
+
(0, state_1.setUsageRefreshError)(classifyUsageRefreshError(account.id, error));
|
|
187
213
|
}
|
|
188
214
|
}
|
|
189
215
|
return results;
|