@openxiaobu/codexl 0.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/LICENSE +21 -0
- package/README.md +164 -0
- package/dist/account-store.js +131 -0
- package/dist/cli.js +304 -0
- package/dist/config.js +202 -0
- package/dist/login.js +37 -0
- package/dist/scheduler.js +62 -0
- package/dist/serve.js +24 -0
- package/dist/server.js +235 -0
- package/dist/state.js +121 -0
- package/dist/status.js +134 -0
- package/dist/types.js +2 -0
- package/dist/usage-sync.js +143 -0
- package/package.json +47 -0
package/dist/status.js
ADDED
|
@@ -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.collectAccountStatuses = collectAccountStatuses;
|
|
7
|
+
exports.renderStatusTable = renderStatusTable;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const config_1 = require("./config");
|
|
11
|
+
const account_store_1 = require("./account-store");
|
|
12
|
+
const state_1 = require("./state");
|
|
13
|
+
function computeLeftPercent(usedPercent) {
|
|
14
|
+
if (usedPercent === null || usedPercent === undefined || Number.isNaN(usedPercent)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return Math.max(0, Math.min(100, 100 - usedPercent));
|
|
18
|
+
}
|
|
19
|
+
function isLimited(usedPercent, resetsAt) {
|
|
20
|
+
if (usedPercent === null || usedPercent < 100) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (!resetsAt) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return resetsAt * 1000 > Date.now();
|
|
27
|
+
}
|
|
28
|
+
function formatPercent(value) {
|
|
29
|
+
return value === null ? "-" : `${value}%`;
|
|
30
|
+
}
|
|
31
|
+
function formatReset(unixSeconds) {
|
|
32
|
+
if (!unixSeconds) {
|
|
33
|
+
return "-";
|
|
34
|
+
}
|
|
35
|
+
return new Date(unixSeconds * 1000).toLocaleString("zh-CN", {
|
|
36
|
+
hour12: false
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 汇总所有受管账号的运行状态,供状态展示与调度复用。
|
|
41
|
+
*
|
|
42
|
+
* @returns 所有账号的运行时状态列表。
|
|
43
|
+
*/
|
|
44
|
+
function collectAccountStatuses() {
|
|
45
|
+
const config = (0, config_1.loadConfig)();
|
|
46
|
+
return config.accounts.map((account) => {
|
|
47
|
+
const codexDir = (0, account_store_1.getCodexDataDir)(account.codex_home);
|
|
48
|
+
const registryPath = node_path_1.default.join(codexDir, "accounts", "registry.json");
|
|
49
|
+
const exists = node_fs_1.default.existsSync(registryPath);
|
|
50
|
+
const primary = exists ? (0, account_store_1.resolvePrimaryRegistryAccount)(account.codex_home) : null;
|
|
51
|
+
const usageCache = (0, state_1.getUsageCache)(account.id);
|
|
52
|
+
const activeEmail = usageCache?.email ?? primary?.email ?? account.email;
|
|
53
|
+
const fiveHourUsed = usageCache?.fiveHourUsedPercent ?? null;
|
|
54
|
+
const fiveHourReset = usageCache?.fiveHourResetAt ?? null;
|
|
55
|
+
const weeklyUsed = usageCache?.weeklyUsedPercent ?? null;
|
|
56
|
+
const weeklyReset = usageCache?.weeklyResetAt ?? null;
|
|
57
|
+
const fiveHourLeftPercent = computeLeftPercent(fiveHourUsed);
|
|
58
|
+
const weeklyLeftPercent = computeLeftPercent(weeklyUsed);
|
|
59
|
+
const isFiveHourLimited = isLimited(fiveHourUsed, fiveHourReset);
|
|
60
|
+
const isWeeklyLimited = isLimited(weeklyUsed, weeklyReset);
|
|
61
|
+
const localBlock = (0, state_1.getAccountBlock)(account.id);
|
|
62
|
+
const localBlocked = localBlock?.until != null ? localBlock.until * 1000 > Date.now() : false;
|
|
63
|
+
return {
|
|
64
|
+
id: account.id,
|
|
65
|
+
name: account.name,
|
|
66
|
+
email: activeEmail,
|
|
67
|
+
enabled: account.enabled,
|
|
68
|
+
exists,
|
|
69
|
+
plan: usageCache?.plan ?? primary?.plan ?? "-",
|
|
70
|
+
fiveHourLeftPercent,
|
|
71
|
+
fiveHourResetsAt: fiveHourReset,
|
|
72
|
+
weeklyLeftPercent,
|
|
73
|
+
weeklyResetsAt: weeklyReset,
|
|
74
|
+
isFiveHourLimited,
|
|
75
|
+
isWeeklyLimited,
|
|
76
|
+
localBlockReason: localBlock?.reason,
|
|
77
|
+
localBlockUntil: localBlock?.until ?? null,
|
|
78
|
+
isAvailable: account.enabled &&
|
|
79
|
+
exists &&
|
|
80
|
+
!isFiveHourLimited &&
|
|
81
|
+
!isWeeklyLimited &&
|
|
82
|
+
!localBlocked,
|
|
83
|
+
sourcePath: codexDir
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 将账号状态渲染为适合终端输出的表格文本。
|
|
89
|
+
*
|
|
90
|
+
* @param statuses 待展示的账号状态列表。
|
|
91
|
+
* @returns 可直接打印到终端的表格字符串。
|
|
92
|
+
*/
|
|
93
|
+
function renderStatusTable(statuses) {
|
|
94
|
+
const rows = [
|
|
95
|
+
["NAME", "EMAIL", "PLAN", "5H_LEFT", "5H_RESET", "WEEK_LEFT", "WEEK_RESET", "STATUS"]
|
|
96
|
+
];
|
|
97
|
+
for (const item of statuses) {
|
|
98
|
+
let status = "missing";
|
|
99
|
+
if (item.exists) {
|
|
100
|
+
if (!item.enabled) {
|
|
101
|
+
status = "disabled";
|
|
102
|
+
}
|
|
103
|
+
else if (item.localBlockUntil && item.localBlockUntil * 1000 > Date.now()) {
|
|
104
|
+
status = item.localBlockReason ?? "blocked";
|
|
105
|
+
}
|
|
106
|
+
else if (item.isWeeklyLimited) {
|
|
107
|
+
status = "weekly_limited";
|
|
108
|
+
}
|
|
109
|
+
else if (item.isFiveHourLimited) {
|
|
110
|
+
status = "cooldown";
|
|
111
|
+
}
|
|
112
|
+
else if (item.isAvailable) {
|
|
113
|
+
status = "available";
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
status = "unknown";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
rows.push([
|
|
120
|
+
item.name,
|
|
121
|
+
item.email ?? "-",
|
|
122
|
+
item.plan,
|
|
123
|
+
formatPercent(item.fiveHourLeftPercent),
|
|
124
|
+
formatReset(item.fiveHourResetsAt),
|
|
125
|
+
formatPercent(item.weeklyLeftPercent),
|
|
126
|
+
formatReset(item.weeklyResetsAt),
|
|
127
|
+
status
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
const widths = rows[0].map((_, columnIndex) => Math.max(...rows.map((row) => row[columnIndex].length)));
|
|
131
|
+
return rows
|
|
132
|
+
.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" "))
|
|
133
|
+
.join("\n");
|
|
134
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.refreshAccountTokens = refreshAccountTokens;
|
|
4
|
+
exports.refreshAccountUsage = refreshAccountUsage;
|
|
5
|
+
exports.refreshAllAccountUsage = refreshAllAccountUsage;
|
|
6
|
+
const undici_1 = require("undici");
|
|
7
|
+
const account_store_1 = require("./account-store");
|
|
8
|
+
const config_1 = require("./config");
|
|
9
|
+
const state_1 = require("./state");
|
|
10
|
+
function normalizeResetAt(value, resetAfterSeconds) {
|
|
11
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
if (typeof resetAfterSeconds === "number" && Number.isFinite(resetAfterSeconds)) {
|
|
15
|
+
return Math.floor(Date.now() / 1000) + resetAfterSeconds;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 使用 refresh token 刷新指定账号的 access token,并回写到账号目录。
|
|
21
|
+
*
|
|
22
|
+
* @param accountId 账号标识。
|
|
23
|
+
* @returns 最新认证信息。
|
|
24
|
+
* @throws 当账号不存在、缺少 refresh_token 或刷新失败时抛出错误。
|
|
25
|
+
*/
|
|
26
|
+
async function refreshAccountTokens(accountId) {
|
|
27
|
+
const config = (0, config_1.loadConfig)();
|
|
28
|
+
const account = (0, account_store_1.findManagedAccount)(accountId);
|
|
29
|
+
if (!account) {
|
|
30
|
+
throw new Error(`未找到账号 ${accountId}`);
|
|
31
|
+
}
|
|
32
|
+
const auth = (0, account_store_1.readAuthFile)(account.codex_home);
|
|
33
|
+
const refreshToken = auth?.tokens?.refresh_token;
|
|
34
|
+
if (!refreshToken) {
|
|
35
|
+
throw new Error(`账号 ${accountId} 缺少 refresh_token`);
|
|
36
|
+
}
|
|
37
|
+
const response = await (0, undici_1.request)(`${config.upstream.auth_base_url}/oauth/token`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
41
|
+
accept: "application/json"
|
|
42
|
+
},
|
|
43
|
+
body: new URLSearchParams({
|
|
44
|
+
grant_type: "refresh_token",
|
|
45
|
+
refresh_token: refreshToken,
|
|
46
|
+
client_id: config.upstream.oauth_client_id
|
|
47
|
+
}).toString()
|
|
48
|
+
});
|
|
49
|
+
if (response.statusCode >= 400) {
|
|
50
|
+
const errorText = await response.body.text();
|
|
51
|
+
throw new Error(`刷新 token 失败: HTTP ${response.statusCode} ${errorText}`);
|
|
52
|
+
}
|
|
53
|
+
const payload = (await response.body.json());
|
|
54
|
+
const nextAuth = {
|
|
55
|
+
...(auth ?? {}),
|
|
56
|
+
auth_mode: "chatgpt",
|
|
57
|
+
OPENAI_API_KEY: null,
|
|
58
|
+
tokens: {
|
|
59
|
+
...(auth?.tokens ?? {}),
|
|
60
|
+
access_token: payload.access_token ?? auth?.tokens?.access_token,
|
|
61
|
+
refresh_token: payload.refresh_token ?? auth?.tokens?.refresh_token,
|
|
62
|
+
id_token: payload.id_token ?? auth?.tokens?.id_token,
|
|
63
|
+
account_id: auth?.tokens?.account_id
|
|
64
|
+
},
|
|
65
|
+
last_refresh: new Date().toISOString()
|
|
66
|
+
};
|
|
67
|
+
(0, account_store_1.writeAuthFile)(account.codex_home, nextAuth);
|
|
68
|
+
return nextAuth;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 查询单个账号的最新额度信息,并写入 codexl 自己的 usage 缓存。
|
|
72
|
+
*
|
|
73
|
+
* @param accountId 账号标识。
|
|
74
|
+
* @returns 刷新后的额度摘要。
|
|
75
|
+
* @throws 当账号不存在、未登录或远端请求失败时抛出错误。
|
|
76
|
+
*/
|
|
77
|
+
async function refreshAccountUsage(accountId) {
|
|
78
|
+
const account = (0, account_store_1.findManagedAccount)(accountId);
|
|
79
|
+
if (!account) {
|
|
80
|
+
throw new Error(`未找到账号 ${accountId}`);
|
|
81
|
+
}
|
|
82
|
+
const auth = (0, account_store_1.readAuthFile)(account.codex_home);
|
|
83
|
+
const accessToken = auth?.tokens?.access_token;
|
|
84
|
+
const accountIdHeader = auth?.tokens?.account_id;
|
|
85
|
+
if (!accessToken) {
|
|
86
|
+
throw new Error(`账号 ${accountId} 缺少 access_token`);
|
|
87
|
+
}
|
|
88
|
+
const response = await (0, undici_1.request)("https://chatgpt.com/backend-api/wham/usage", {
|
|
89
|
+
method: "GET",
|
|
90
|
+
headers: {
|
|
91
|
+
authorization: `Bearer ${accessToken}`,
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
"user-agent": "codexl/0.1.0",
|
|
94
|
+
...(accountIdHeader ? { "chatgpt-account-id": accountIdHeader } : {})
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (response.statusCode === 401) {
|
|
98
|
+
await refreshAccountTokens(accountId);
|
|
99
|
+
return await refreshAccountUsage(accountId);
|
|
100
|
+
}
|
|
101
|
+
if (response.statusCode >= 400) {
|
|
102
|
+
const errorText = await response.body.text();
|
|
103
|
+
throw new Error(`刷新额度失败: HTTP ${response.statusCode} ${errorText}`);
|
|
104
|
+
}
|
|
105
|
+
const payload = (await response.body.json());
|
|
106
|
+
const primary = (0, account_store_1.resolvePrimaryRegistryAccount)(account.codex_home);
|
|
107
|
+
const email = primary?.email ?? account.email ?? undefined;
|
|
108
|
+
const plan = payload.plan_type ?? primary?.plan ?? "-";
|
|
109
|
+
const result = {
|
|
110
|
+
accountId: account.id,
|
|
111
|
+
email,
|
|
112
|
+
plan,
|
|
113
|
+
fiveHourUsedPercent: payload.rate_limit?.primary_window?.used_percent ?? null,
|
|
114
|
+
fiveHourResetAt: normalizeResetAt(payload.rate_limit?.primary_window?.reset_at, payload.rate_limit?.primary_window?.reset_after_seconds),
|
|
115
|
+
weeklyUsedPercent: payload.rate_limit?.secondary_window?.used_percent ?? null,
|
|
116
|
+
weeklyResetAt: normalizeResetAt(payload.rate_limit?.secondary_window?.reset_at, payload.rate_limit?.secondary_window?.reset_after_seconds)
|
|
117
|
+
};
|
|
118
|
+
(0, state_1.setUsageCache)(result);
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 批量刷新所有受管账号的额度信息。
|
|
123
|
+
*
|
|
124
|
+
* @returns 每个账号对应的刷新结果列表。
|
|
125
|
+
*/
|
|
126
|
+
async function refreshAllAccountUsage() {
|
|
127
|
+
const config = (0, config_1.loadConfig)();
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const account of config.accounts) {
|
|
130
|
+
if (!account.enabled) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const result = await refreshAccountUsage(account.id);
|
|
135
|
+
results.push(result);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
console.error(`[refresh] ${account.id} 失败: ${message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return results;
|
|
143
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openxiaobu/codexl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "本地 Codex 多账号切换与状态管理工具",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "dist/cli.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"codexl": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"clean": "rm -rf dist",
|
|
15
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/serve.js",
|
|
16
|
+
"dev": "tsx src/cli.ts",
|
|
17
|
+
"check": "tsc --noEmit -p tsconfig.json"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"codex",
|
|
21
|
+
"chatgpt",
|
|
22
|
+
"switcher",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"author": "bk",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/openxiaobu/codexl.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/openxiaobu/codexl#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/openxiaobu/codexl/issues"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"commander": "^14.0.3",
|
|
37
|
+
"fastify": "^5.8.2",
|
|
38
|
+
"undici": "^7.22.0",
|
|
39
|
+
"yaml": "^2.8.2",
|
|
40
|
+
"zod": "^4.3.6"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^25.4.0",
|
|
44
|
+
"tsx": "^4.21.0",
|
|
45
|
+
"typescript": "^5.9.3"
|
|
46
|
+
}
|
|
47
|
+
}
|