@imdeadpool/codex-account-switcher 0.1.5 → 0.1.6

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.
Files changed (44) hide show
  1. package/README.md +68 -2
  2. package/dist/commands/config.d.ts +15 -0
  3. package/dist/commands/config.js +81 -0
  4. package/dist/commands/daemon.d.ts +9 -0
  5. package/dist/commands/daemon.js +39 -0
  6. package/dist/commands/list.d.ts +3 -0
  7. package/dist/commands/list.js +30 -5
  8. package/dist/commands/login.d.ts +15 -0
  9. package/dist/commands/login.js +97 -0
  10. package/dist/commands/remove.d.ts +14 -0
  11. package/dist/commands/remove.js +104 -0
  12. package/dist/commands/save.d.ts +4 -1
  13. package/dist/commands/save.js +24 -6
  14. package/dist/commands/status.d.ts +5 -0
  15. package/dist/commands/status.js +16 -0
  16. package/dist/lib/accounts/account-service.d.ts +59 -2
  17. package/dist/lib/accounts/account-service.js +551 -36
  18. package/dist/lib/accounts/auth-parser.d.ts +3 -0
  19. package/dist/lib/accounts/auth-parser.js +83 -0
  20. package/dist/lib/accounts/errors.d.ts +15 -0
  21. package/dist/lib/accounts/errors.js +34 -2
  22. package/dist/lib/accounts/index.d.ts +3 -1
  23. package/dist/lib/accounts/index.js +5 -1
  24. package/dist/lib/accounts/registry.d.ts +6 -0
  25. package/dist/lib/accounts/registry.js +166 -0
  26. package/dist/lib/accounts/service-manager.d.ts +4 -0
  27. package/dist/lib/accounts/service-manager.js +204 -0
  28. package/dist/lib/accounts/types.d.ts +71 -0
  29. package/dist/lib/accounts/types.js +5 -0
  30. package/dist/lib/accounts/usage.d.ts +10 -0
  31. package/dist/lib/accounts/usage.js +246 -0
  32. package/dist/lib/base-command.d.ts +1 -0
  33. package/dist/lib/base-command.js +4 -0
  34. package/dist/lib/config/paths.d.ts +6 -0
  35. package/dist/lib/config/paths.js +46 -5
  36. package/dist/tests/auth-parser.test.d.ts +1 -0
  37. package/dist/tests/auth-parser.test.js +65 -0
  38. package/dist/tests/registry.test.d.ts +1 -0
  39. package/dist/tests/registry.test.js +37 -0
  40. package/dist/tests/save-account-safety.test.d.ts +1 -0
  41. package/dist/tests/save-account-safety.test.js +399 -0
  42. package/dist/tests/usage.test.d.ts +1 -0
  43. package/dist/tests/usage.test.js +29 -0
  44. package/package.json +7 -6
@@ -0,0 +1,246 @@
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.resolveRateWindow = resolveRateWindow;
7
+ exports.remainingPercent = remainingPercent;
8
+ exports.usageScore = usageScore;
9
+ exports.shouldSwitchCurrent = shouldSwitchCurrent;
10
+ exports.fetchUsageFromApi = fetchUsageFromApi;
11
+ exports.fetchUsageFromLocal = fetchUsageFromLocal;
12
+ const promises_1 = __importDefault(require("node:fs/promises"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
15
+ const REQUEST_TIMEOUT_MS = 5000;
16
+ function coerceWindow(raw) {
17
+ if (!raw || typeof raw !== "object")
18
+ return undefined;
19
+ const value = raw;
20
+ const usedRaw = value.used_percent;
21
+ if (typeof usedRaw !== "number" || !Number.isFinite(usedRaw))
22
+ return undefined;
23
+ const windowMinutes = typeof value.window_minutes === "number"
24
+ ? Math.round(value.window_minutes)
25
+ : typeof value.limit_window_seconds === "number"
26
+ ? Math.ceil(value.limit_window_seconds / 60)
27
+ : undefined;
28
+ const resetsAt = typeof value.resets_at === "number"
29
+ ? Math.round(value.resets_at)
30
+ : typeof value.reset_at === "number"
31
+ ? Math.round(value.reset_at)
32
+ : undefined;
33
+ return {
34
+ usedPercent: Math.max(0, Math.min(100, usedRaw)),
35
+ windowMinutes,
36
+ resetsAt,
37
+ };
38
+ }
39
+ function buildSnapshotFromRateLimits(rateLimits, source) {
40
+ var _a, _b;
41
+ if (!rateLimits || typeof rateLimits !== "object")
42
+ return null;
43
+ const input = rateLimits;
44
+ const primary = coerceWindow((_a = input.primary_window) !== null && _a !== void 0 ? _a : input.primary);
45
+ const secondary = coerceWindow((_b = input.secondary_window) !== null && _b !== void 0 ? _b : input.secondary);
46
+ if (!primary && !secondary)
47
+ return null;
48
+ const planType = typeof input.plan_type === "string" ? input.plan_type : undefined;
49
+ return {
50
+ primary,
51
+ secondary,
52
+ planType,
53
+ fetchedAt: new Date().toISOString(),
54
+ source,
55
+ };
56
+ }
57
+ function findNestedRateLimits(input) {
58
+ if (!input || typeof input !== "object")
59
+ return null;
60
+ const root = input;
61
+ if (root.rate_limits)
62
+ return root.rate_limits;
63
+ if (root.payload && typeof root.payload === "object") {
64
+ const payload = root.payload;
65
+ if (payload.rate_limits)
66
+ return payload.rate_limits;
67
+ if (payload.event && typeof payload.event === "object") {
68
+ const event = payload.event;
69
+ if (event.rate_limits)
70
+ return event.rate_limits;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ function parseTimestampSeconds(input) {
76
+ if (typeof input === "number" && Number.isFinite(input)) {
77
+ if (input > 1000000000000) {
78
+ return Math.floor(input / 1000);
79
+ }
80
+ return Math.floor(input);
81
+ }
82
+ if (typeof input === "string") {
83
+ const parsed = Date.parse(input);
84
+ if (!Number.isNaN(parsed)) {
85
+ return Math.floor(parsed / 1000);
86
+ }
87
+ }
88
+ return Math.floor(Date.now() / 1000);
89
+ }
90
+ function resolveRateWindow(snapshot, minutes, fallbackPrimary) {
91
+ if (!snapshot)
92
+ return undefined;
93
+ if (snapshot.primary && snapshot.primary.windowMinutes === minutes) {
94
+ return snapshot.primary;
95
+ }
96
+ if (snapshot.secondary && snapshot.secondary.windowMinutes === minutes) {
97
+ return snapshot.secondary;
98
+ }
99
+ return fallbackPrimary ? snapshot.primary : snapshot.secondary;
100
+ }
101
+ function remainingPercent(window, nowSeconds) {
102
+ if (!window)
103
+ return undefined;
104
+ if (typeof window.resetsAt === "number" && window.resetsAt <= nowSeconds)
105
+ return 100;
106
+ const remaining = 100 - window.usedPercent;
107
+ if (remaining <= 0)
108
+ return 0;
109
+ if (remaining >= 100)
110
+ return 100;
111
+ return Math.trunc(remaining);
112
+ }
113
+ function usageScore(snapshot, nowSeconds) {
114
+ const fiveHour = remainingPercent(resolveRateWindow(snapshot, 300, true), nowSeconds);
115
+ const weekly = remainingPercent(resolveRateWindow(snapshot, 10080, false), nowSeconds);
116
+ if (typeof fiveHour === "number" && typeof weekly === "number")
117
+ return Math.min(fiveHour, weekly);
118
+ if (typeof fiveHour === "number")
119
+ return fiveHour;
120
+ if (typeof weekly === "number")
121
+ return weekly;
122
+ return undefined;
123
+ }
124
+ function shouldSwitchCurrent(snapshot, thresholds, nowSeconds) {
125
+ const remaining5h = remainingPercent(resolveRateWindow(snapshot, 300, true), nowSeconds);
126
+ const remainingWeekly = remainingPercent(resolveRateWindow(snapshot, 10080, false), nowSeconds);
127
+ return ((typeof remaining5h === "number" && remaining5h < thresholds.threshold5hPercent) ||
128
+ (typeof remainingWeekly === "number" && remainingWeekly < thresholds.thresholdWeeklyPercent));
129
+ }
130
+ async function fetchUsageFromApi(snapshotInfo) {
131
+ if (snapshotInfo.authMode !== "chatgpt" || !snapshotInfo.accessToken || !snapshotInfo.accountId) {
132
+ return null;
133
+ }
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
136
+ try {
137
+ const response = await fetch(USAGE_ENDPOINT, {
138
+ method: "GET",
139
+ headers: {
140
+ Authorization: `Bearer ${snapshotInfo.accessToken}`,
141
+ "ChatGPT-Account-Id": snapshotInfo.accountId,
142
+ "User-Agent": "codex-auth",
143
+ },
144
+ signal: controller.signal,
145
+ });
146
+ if (!response.ok)
147
+ return null;
148
+ const data = (await response.json());
149
+ const snapshot = buildSnapshotFromRateLimits(data.rate_limit, "api");
150
+ if (!snapshot)
151
+ return null;
152
+ if (!snapshot.planType && typeof data.plan_type === "string") {
153
+ snapshot.planType = data.plan_type;
154
+ }
155
+ return snapshot;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ finally {
161
+ clearTimeout(timer);
162
+ }
163
+ }
164
+ async function collectRolloutFiles(sessionsDir) {
165
+ const pending = [sessionsDir];
166
+ const rolloutFiles = [];
167
+ while (pending.length > 0) {
168
+ const current = pending.pop();
169
+ if (!current)
170
+ continue;
171
+ let entries;
172
+ try {
173
+ entries = await promises_1.default.readdir(current, { withFileTypes: true });
174
+ }
175
+ catch {
176
+ continue;
177
+ }
178
+ for (const entry of entries) {
179
+ const fullPath = node_path_1.default.join(current, entry.name);
180
+ if (entry.isDirectory()) {
181
+ pending.push(fullPath);
182
+ continue;
183
+ }
184
+ if (!entry.isFile())
185
+ continue;
186
+ if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl"))
187
+ continue;
188
+ try {
189
+ const stat = await promises_1.default.stat(fullPath);
190
+ rolloutFiles.push({ filePath: fullPath, mtimeMs: stat.mtimeMs });
191
+ }
192
+ catch {
193
+ // ignore unreadable files
194
+ }
195
+ }
196
+ }
197
+ rolloutFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
198
+ return rolloutFiles.slice(0, 5).map((entry) => entry.filePath);
199
+ }
200
+ async function parseRolloutForUsage(filePath) {
201
+ var _a, _b;
202
+ let raw;
203
+ try {
204
+ raw = await promises_1.default.readFile(filePath, "utf8");
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ let latest = null;
210
+ for (const line of raw.split(/\r?\n/)) {
211
+ const trimmed = line.trim();
212
+ if (!trimmed)
213
+ continue;
214
+ let record;
215
+ try {
216
+ record = JSON.parse(trimmed);
217
+ }
218
+ catch {
219
+ continue;
220
+ }
221
+ const rateLimits = findNestedRateLimits(record);
222
+ const snapshot = buildSnapshotFromRateLimits(rateLimits, "local");
223
+ if (!snapshot)
224
+ continue;
225
+ const row = record;
226
+ const timestampSeconds = parseTimestampSeconds((_b = (_a = row.event_timestamp_ms) !== null && _a !== void 0 ? _a : row.timestamp_ms) !== null && _b !== void 0 ? _b : row.timestamp);
227
+ if (!latest || timestampSeconds >= latest.timestampSeconds) {
228
+ latest = {
229
+ snapshot,
230
+ timestampSeconds,
231
+ };
232
+ }
233
+ }
234
+ return latest;
235
+ }
236
+ async function fetchUsageFromLocal(codexDir) {
237
+ const sessionsDir = node_path_1.default.join(codexDir, "sessions");
238
+ const files = await collectRolloutFiles(sessionsDir);
239
+ for (const filePath of files) {
240
+ const latest = await parseRolloutForUsage(filePath);
241
+ if (latest) {
242
+ return latest.snapshot;
243
+ }
244
+ }
245
+ return null;
246
+ }
@@ -1,6 +1,7 @@
1
1
  import { Command } from "@oclif/core";
2
2
  export declare abstract class BaseCommand extends Command {
3
3
  protected readonly accounts: import("./accounts").AccountService;
4
+ protected readonly syncExternalAuthBeforeRun: boolean;
4
5
  protected runSafe(action: () => Promise<void>): Promise<void>;
5
6
  private handleError;
6
7
  }
@@ -7,9 +7,13 @@ class BaseCommand extends core_1.Command {
7
7
  constructor() {
8
8
  super(...arguments);
9
9
  this.accounts = accounts_1.accountService;
10
+ this.syncExternalAuthBeforeRun = true;
10
11
  }
11
12
  async runSafe(action) {
12
13
  try {
14
+ if (this.syncExternalAuthBeforeRun) {
15
+ await this.accounts.syncExternalAuthSnapshotIfNeeded();
16
+ }
13
17
  await action();
14
18
  }
15
19
  catch (error) {
@@ -1,4 +1,10 @@
1
+ export declare function resolveCodexDir(): string;
2
+ export declare function resolveAccountsDir(): string;
3
+ export declare function resolveAuthPath(): string;
4
+ export declare function resolveCurrentNamePath(): string;
5
+ export declare function resolveRegistryPath(): string;
1
6
  export declare const codexDir: string;
2
7
  export declare const accountsDir: string;
3
8
  export declare const authPath: string;
4
9
  export declare const currentNamePath: string;
10
+ export declare const registryPath: string;
@@ -3,10 +3,51 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.currentNamePath = exports.authPath = exports.accountsDir = exports.codexDir = void 0;
6
+ exports.registryPath = exports.currentNamePath = exports.authPath = exports.accountsDir = exports.codexDir = void 0;
7
+ exports.resolveCodexDir = resolveCodexDir;
8
+ exports.resolveAccountsDir = resolveAccountsDir;
9
+ exports.resolveAuthPath = resolveAuthPath;
10
+ exports.resolveCurrentNamePath = resolveCurrentNamePath;
11
+ exports.resolveRegistryPath = resolveRegistryPath;
7
12
  const node_os_1 = __importDefault(require("node:os"));
8
13
  const node_path_1 = __importDefault(require("node:path"));
9
- exports.codexDir = node_path_1.default.join(node_os_1.default.homedir(), ".codex");
10
- exports.accountsDir = node_path_1.default.join(exports.codexDir, "accounts");
11
- exports.authPath = node_path_1.default.join(exports.codexDir, "auth.json");
12
- exports.currentNamePath = node_path_1.default.join(exports.codexDir, "current");
14
+ function resolvePath(raw) {
15
+ const expanded = raw.startsWith("~") ? node_path_1.default.join(node_os_1.default.homedir(), raw.slice(1)) : raw;
16
+ return node_path_1.default.resolve(expanded);
17
+ }
18
+ function resolveCodexDir() {
19
+ const envPath = process.env.CODEX_AUTH_CODEX_DIR;
20
+ if (envPath && envPath.trim().length > 0) {
21
+ return resolvePath(envPath.trim());
22
+ }
23
+ return node_path_1.default.join(node_os_1.default.homedir(), ".codex");
24
+ }
25
+ function resolveAccountsDir() {
26
+ const envPath = process.env.CODEX_AUTH_ACCOUNTS_DIR;
27
+ if (envPath && envPath.trim().length > 0) {
28
+ return resolvePath(envPath.trim());
29
+ }
30
+ return node_path_1.default.join(resolveCodexDir(), "accounts");
31
+ }
32
+ function resolveAuthPath() {
33
+ const envPath = process.env.CODEX_AUTH_JSON_PATH;
34
+ if (envPath && envPath.trim().length > 0) {
35
+ return resolvePath(envPath.trim());
36
+ }
37
+ return node_path_1.default.join(resolveCodexDir(), "auth.json");
38
+ }
39
+ function resolveCurrentNamePath() {
40
+ const envPath = process.env.CODEX_AUTH_CURRENT_PATH;
41
+ if (envPath && envPath.trim().length > 0) {
42
+ return resolvePath(envPath.trim());
43
+ }
44
+ return node_path_1.default.join(resolveCodexDir(), "current");
45
+ }
46
+ function resolveRegistryPath() {
47
+ return node_path_1.default.join(resolveAccountsDir(), "registry.json");
48
+ }
49
+ exports.codexDir = resolveCodexDir();
50
+ exports.accountsDir = resolveAccountsDir();
51
+ exports.authPath = resolveAuthPath();
52
+ exports.currentNamePath = resolveCurrentNamePath();
53
+ exports.registryPath = resolveRegistryPath();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
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
+ const node_test_1 = __importDefault(require("node:test"));
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ const auth_parser_1 = require("../lib/accounts/auth-parser");
9
+ function encodeBase64Url(input) {
10
+ return Buffer.from(input, "utf8")
11
+ .toString("base64")
12
+ .replace(/\+/g, "-")
13
+ .replace(/\//g, "_")
14
+ .replace(/=+$/g, "");
15
+ }
16
+ (0, node_test_1.default)("parseAuthSnapshotData extracts chatgpt metadata from id_token claims", () => {
17
+ const payload = {
18
+ email: "ADMIN@EDIXAI.COM",
19
+ "https://api.openai.com/auth": {
20
+ chatgpt_account_id: "acct-1",
21
+ chatgpt_user_id: "user-1",
22
+ chatgpt_plan_type: "team",
23
+ },
24
+ };
25
+ const idToken = `${encodeBase64Url(JSON.stringify({ alg: "none" }))}.${encodeBase64Url(JSON.stringify(payload))}.sig`;
26
+ const parsed = (0, auth_parser_1.parseAuthSnapshotData)({
27
+ tokens: {
28
+ access_token: "token-123",
29
+ id_token: idToken,
30
+ account_id: "acct-1",
31
+ },
32
+ });
33
+ strict_1.default.equal(parsed.authMode, "chatgpt");
34
+ strict_1.default.equal(parsed.email, "admin@edixai.com");
35
+ strict_1.default.equal(parsed.accountId, "acct-1");
36
+ strict_1.default.equal(parsed.userId, "user-1");
37
+ strict_1.default.equal(parsed.planType, "team");
38
+ strict_1.default.equal(parsed.accessToken, "token-123");
39
+ });
40
+ (0, node_test_1.default)("parseAuthSnapshotData detects API key mode", () => {
41
+ const parsed = (0, auth_parser_1.parseAuthSnapshotData)({ OPENAI_API_KEY: "sk-test" });
42
+ strict_1.default.equal(parsed.authMode, "apikey");
43
+ strict_1.default.equal(parsed.accessToken, undefined);
44
+ });
45
+ (0, node_test_1.default)("parseAuthSnapshotData falls back to root/sub/default_account_id metadata when auth claim is partial", () => {
46
+ const payload = {
47
+ sub: "user-from-sub",
48
+ };
49
+ const idToken = `${encodeBase64Url(JSON.stringify({ alg: "none" }))}.${encodeBase64Url(JSON.stringify(payload))}.sig`;
50
+ const parsed = (0, auth_parser_1.parseAuthSnapshotData)({
51
+ email: "Fallback@Example.com",
52
+ chatgpt_plan_type: "plus",
53
+ tokens: {
54
+ access_token: "token-xyz",
55
+ id_token: idToken,
56
+ default_account_id: "acct-default",
57
+ },
58
+ });
59
+ strict_1.default.equal(parsed.authMode, "chatgpt");
60
+ strict_1.default.equal(parsed.email, "fallback@example.com");
61
+ strict_1.default.equal(parsed.accountId, "acct-default");
62
+ strict_1.default.equal(parsed.userId, "user-from-sub");
63
+ strict_1.default.equal(parsed.planType, "plus");
64
+ strict_1.default.equal(parsed.accessToken, "token-xyz");
65
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
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
+ const node_test_1 = __importDefault(require("node:test"));
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ const registry_1 = require("../lib/accounts/registry");
9
+ (0, node_test_1.default)("sanitizeRegistry falls back to defaults for invalid thresholds", () => {
10
+ const registry = (0, registry_1.sanitizeRegistry)({
11
+ autoSwitch: {
12
+ enabled: true,
13
+ threshold5hPercent: 0,
14
+ thresholdWeeklyPercent: 101,
15
+ },
16
+ api: {
17
+ usage: false,
18
+ },
19
+ accounts: {},
20
+ });
21
+ strict_1.default.equal(registry.autoSwitch.enabled, true);
22
+ strict_1.default.equal(registry.autoSwitch.threshold5hPercent, 10);
23
+ strict_1.default.equal(registry.autoSwitch.thresholdWeeklyPercent, 5);
24
+ strict_1.default.equal(registry.api.usage, false);
25
+ });
26
+ (0, node_test_1.default)("reconcileRegistryWithAccounts drops missing account entries", () => {
27
+ const registry = (0, registry_1.sanitizeRegistry)({
28
+ accounts: {
29
+ keep: { name: "keep", createdAt: new Date().toISOString() },
30
+ remove: { name: "remove", createdAt: new Date().toISOString() },
31
+ },
32
+ activeAccountName: "remove",
33
+ });
34
+ const reconciled = (0, registry_1.reconcileRegistryWithAccounts)(registry, ["keep"]);
35
+ strict_1.default.deepEqual(Object.keys(reconciled.accounts), ["keep"]);
36
+ strict_1.default.equal(reconciled.activeAccountName, undefined);
37
+ });
@@ -0,0 +1 @@
1
+ export {};