@bjesuiter/codex-switcher 1.2.0 → 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 +136 -44
- package/cdx.mjs +857 -283
- package/package.json +4 -3
package/cdx.mjs
CHANGED
|
@@ -1,35 +1,101 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
4
5
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import os from "node:os";
|
|
7
|
-
import * as p from "@clack/prompts";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "cross-keychain";
|
|
10
|
+
import { createInterface } from "node:readline/promises";
|
|
9
11
|
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
10
12
|
import { randomBytes } from "node:crypto";
|
|
11
13
|
import http from "node:http";
|
|
12
14
|
|
|
13
15
|
//#region package.json
|
|
14
|
-
var version = "1.
|
|
16
|
+
var version = "1.4.0";
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region lib/commands/errors.ts
|
|
20
|
+
const exitWithCommandError = (error) => {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
process.stderr.write(`${message}\n`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region lib/platform/path-resolver.ts
|
|
28
|
+
const envValue = (env, key) => {
|
|
29
|
+
const value = env[key];
|
|
30
|
+
if (!value) return void 0;
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
33
|
+
};
|
|
34
|
+
const resolvePiAuthPath = (env, homeDir, platform) => {
|
|
35
|
+
const piAgentDir = envValue(env, "PI_CODING_AGENT_DIR");
|
|
36
|
+
if (piAgentDir) return platform === "win32" ? path.win32.join(piAgentDir, "auth.json") : path.join(piAgentDir, "auth.json");
|
|
37
|
+
return platform === "win32" ? path.win32.join(homeDir, ".pi", "agent", "auth.json") : path.join(homeDir, ".pi", "agent", "auth.json");
|
|
38
|
+
};
|
|
39
|
+
const resolveXdgPaths = (env, homeDir, platform) => {
|
|
40
|
+
const configHome = envValue(env, "XDG_CONFIG_HOME") ?? path.join(homeDir, ".config");
|
|
41
|
+
const dataHome = envValue(env, "XDG_DATA_HOME") ?? path.join(homeDir, ".local", "share");
|
|
42
|
+
const configDir = path.join(configHome, "cdx");
|
|
43
|
+
return {
|
|
44
|
+
profile: "xdg",
|
|
45
|
+
configDir,
|
|
46
|
+
configPath: path.join(configDir, "accounts.json"),
|
|
47
|
+
authPath: path.join(dataHome, "opencode", "auth.json"),
|
|
48
|
+
codexAuthPath: path.join(homeDir, ".codex", "auth.json"),
|
|
49
|
+
piAuthPath: resolvePiAuthPath(env, homeDir, platform)
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const resolveWindowsPaths = (env, homeDir) => {
|
|
53
|
+
const winPath = path.win32;
|
|
54
|
+
const appData = envValue(env, "APPDATA") ?? winPath.join(homeDir, "AppData", "Roaming");
|
|
55
|
+
const localAppData = envValue(env, "LOCALAPPDATA") ?? winPath.join(homeDir, "AppData", "Local");
|
|
56
|
+
const configDir = winPath.join(appData, "cdx");
|
|
57
|
+
return {
|
|
58
|
+
profile: "windows-appdata",
|
|
59
|
+
configDir,
|
|
60
|
+
configPath: winPath.join(configDir, "accounts.json"),
|
|
61
|
+
authPath: winPath.join(localAppData, "opencode", "auth.json"),
|
|
62
|
+
codexAuthPath: winPath.join(homeDir, ".codex", "auth.json"),
|
|
63
|
+
piAuthPath: resolvePiAuthPath(env, homeDir, "win32")
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const resolveRuntimePaths = (input) => {
|
|
67
|
+
if (input.platform === "win32") return resolveWindowsPaths(input.env, input.homeDir);
|
|
68
|
+
return resolveXdgPaths(input.env, input.homeDir, input.platform);
|
|
69
|
+
};
|
|
15
70
|
|
|
16
71
|
//#endregion
|
|
17
72
|
//#region lib/paths.ts
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const createDefaultPaths = () => ({
|
|
25
|
-
configDir: defaultConfigDir,
|
|
26
|
-
configPath: path.join(defaultConfigDir, "accounts.json"),
|
|
27
|
-
authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
|
|
28
|
-
codexAuthPath: path.join(os.homedir(), ".codex", "auth.json"),
|
|
29
|
-
piAuthPath: resolvePiAuthPath()
|
|
73
|
+
const toPathConfig = (paths) => ({
|
|
74
|
+
configDir: paths.configDir,
|
|
75
|
+
configPath: paths.configPath,
|
|
76
|
+
authPath: paths.authPath,
|
|
77
|
+
codexAuthPath: paths.codexAuthPath,
|
|
78
|
+
piAuthPath: paths.piAuthPath
|
|
30
79
|
});
|
|
31
|
-
|
|
80
|
+
const createDefaultPaths = () => {
|
|
81
|
+
const resolved = resolveRuntimePaths({
|
|
82
|
+
platform: process.platform,
|
|
83
|
+
env: process.env,
|
|
84
|
+
homeDir: os.homedir()
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
paths: toPathConfig(resolved),
|
|
88
|
+
resolution: {
|
|
89
|
+
platform: process.platform,
|
|
90
|
+
profile: resolved.profile
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const initial = createDefaultPaths();
|
|
95
|
+
let currentPaths = initial.paths;
|
|
96
|
+
let currentResolution = initial.resolution;
|
|
32
97
|
const getPaths = () => currentPaths;
|
|
98
|
+
const getPathResolutionInfo = () => currentResolution;
|
|
33
99
|
const setPaths = (paths) => {
|
|
34
100
|
currentPaths = {
|
|
35
101
|
...currentPaths,
|
|
@@ -38,7 +104,9 @@ const setPaths = (paths) => {
|
|
|
38
104
|
if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
|
|
39
105
|
};
|
|
40
106
|
const resetPaths = () => {
|
|
41
|
-
|
|
107
|
+
const next = createDefaultPaths();
|
|
108
|
+
currentPaths = next.paths;
|
|
109
|
+
currentResolution = next.resolution;
|
|
42
110
|
};
|
|
43
111
|
const createTestPaths = (testDir) => ({
|
|
44
112
|
configDir: path.join(testDir, "config"),
|
|
@@ -149,11 +217,71 @@ const configExists = () => {
|
|
|
149
217
|
return existsSync(configPath);
|
|
150
218
|
};
|
|
151
219
|
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region lib/platform/browser.ts
|
|
222
|
+
const getBrowserLauncher = (platform = process.platform, url) => {
|
|
223
|
+
if (platform === "darwin") return {
|
|
224
|
+
command: "open",
|
|
225
|
+
args: [url],
|
|
226
|
+
label: "open"
|
|
227
|
+
};
|
|
228
|
+
if (platform === "win32") return {
|
|
229
|
+
command: "cmd",
|
|
230
|
+
args: [
|
|
231
|
+
"/c",
|
|
232
|
+
"start",
|
|
233
|
+
"",
|
|
234
|
+
url
|
|
235
|
+
],
|
|
236
|
+
label: "cmd /c start"
|
|
237
|
+
};
|
|
238
|
+
return {
|
|
239
|
+
command: "xdg-open",
|
|
240
|
+
args: [url],
|
|
241
|
+
label: "xdg-open"
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
const isCommandAvailable = (command, platform = process.platform) => {
|
|
245
|
+
const probe = platform === "win32" ? "where" : "which";
|
|
246
|
+
return Bun.spawnSync({
|
|
247
|
+
cmd: [probe, command],
|
|
248
|
+
stdout: "pipe",
|
|
249
|
+
stderr: "pipe"
|
|
250
|
+
}).exitCode === 0;
|
|
251
|
+
};
|
|
252
|
+
const getBrowserLauncherCapability = (platform = process.platform) => {
|
|
253
|
+
const launcher = getBrowserLauncher(platform, "https://example.com");
|
|
254
|
+
return {
|
|
255
|
+
command: launcher.command,
|
|
256
|
+
label: launcher.label,
|
|
257
|
+
available: isCommandAvailable(launcher.command, platform)
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
const openBrowserUrl = (url, spawnImpl = spawn) => {
|
|
261
|
+
const launcher = getBrowserLauncher(process.platform, url);
|
|
262
|
+
try {
|
|
263
|
+
spawnImpl(launcher.command, launcher.args, {
|
|
264
|
+
detached: true,
|
|
265
|
+
stdio: "ignore"
|
|
266
|
+
}).unref();
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
launcher
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
launcher,
|
|
275
|
+
error: error instanceof Error ? error.message : String(error)
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
152
280
|
//#endregion
|
|
153
281
|
//#region lib/keychain.ts
|
|
154
|
-
const SERVICE_PREFIX = "cdx-openai-";
|
|
282
|
+
const SERVICE_PREFIX$2 = "cdx-openai-";
|
|
155
283
|
const getKeychainService = (accountId) => {
|
|
156
|
-
return `${SERVICE_PREFIX}${accountId}`;
|
|
284
|
+
return `${SERVICE_PREFIX$2}${accountId}`;
|
|
157
285
|
};
|
|
158
286
|
const runSecurity = (args) => {
|
|
159
287
|
const result = Bun.spawnSync({
|
|
@@ -225,12 +353,300 @@ const listKeychainAccounts = () => {
|
|
|
225
353
|
if (result.exitCode !== 0) return [];
|
|
226
354
|
const output = result.stdout.toString();
|
|
227
355
|
const accounts = [];
|
|
228
|
-
const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX}([^"]+)"`, "g");
|
|
356
|
+
const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$2}([^"]+)"`, "g");
|
|
229
357
|
let match;
|
|
230
358
|
while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
|
|
231
359
|
return [...new Set(accounts)];
|
|
232
360
|
};
|
|
233
361
|
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region lib/secrets/fallback-consent.ts
|
|
364
|
+
const CONSENT_FILE = "secure-store-fallback-consent.json";
|
|
365
|
+
const CONSENT_ENV_BYPASS = "CDX_ALLOW_SECURE_STORE_FALLBACK";
|
|
366
|
+
const isBypassEnabled = () => {
|
|
367
|
+
const value = process.env[CONSENT_ENV_BYPASS];
|
|
368
|
+
if (!value) return false;
|
|
369
|
+
return [
|
|
370
|
+
"1",
|
|
371
|
+
"true",
|
|
372
|
+
"yes",
|
|
373
|
+
"y"
|
|
374
|
+
].includes(value.trim().toLowerCase());
|
|
375
|
+
};
|
|
376
|
+
const consentFilePath = () => path.join(getPaths().configDir, CONSENT_FILE);
|
|
377
|
+
const loadConsentMap = async () => {
|
|
378
|
+
try {
|
|
379
|
+
const raw = await readFile(consentFilePath(), "utf8");
|
|
380
|
+
return JSON.parse(raw).accepted ?? {};
|
|
381
|
+
} catch {
|
|
382
|
+
return {};
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const saveConsentMap = async (accepted) => {
|
|
386
|
+
const { configDir } = getPaths();
|
|
387
|
+
await mkdir(configDir, { recursive: true });
|
|
388
|
+
const payload = { accepted };
|
|
389
|
+
await writeFile(consentFilePath(), JSON.stringify(payload, null, 2), "utf8");
|
|
390
|
+
};
|
|
391
|
+
const promptConsent = async (message) => {
|
|
392
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
393
|
+
process.stdout.write(`\n${message}\n\n`);
|
|
394
|
+
const rl = createInterface({
|
|
395
|
+
input: process.stdin,
|
|
396
|
+
output: process.stdout
|
|
397
|
+
});
|
|
398
|
+
try {
|
|
399
|
+
const normalized = (await rl.question("Do you want to continue with this fallback? [y/N]: ")).trim().toLowerCase();
|
|
400
|
+
return normalized === "y" || normalized === "yes";
|
|
401
|
+
} finally {
|
|
402
|
+
rl.close();
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
const ensureFallbackConsent = async (scope, warningMessage) => {
|
|
406
|
+
if (isBypassEnabled()) return;
|
|
407
|
+
const accepted = await loadConsentMap();
|
|
408
|
+
if (accepted[scope]) return;
|
|
409
|
+
if (!await promptConsent(warningMessage)) throw new Error(`Secure-store fallback usage was not approved for '${scope}'. Re-run in an interactive terminal to confirm, or set ${CONSENT_ENV_BYPASS}=1 if you accept the fallback risk.`);
|
|
410
|
+
accepted[scope] = { acceptedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
411
|
+
await saveConsentMap(accepted);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region lib/secrets/linux-cross-keychain.ts
|
|
416
|
+
const SERVICE_PREFIX$1 = "cdx-openai-";
|
|
417
|
+
const LINUX_FALLBACK_SCOPE = "linux:cross-keychain:secret-service";
|
|
418
|
+
let backendInitPromise$1 = null;
|
|
419
|
+
let selectedBackend$1 = null;
|
|
420
|
+
const tryUseBackend$1 = async (backendId) => {
|
|
421
|
+
try {
|
|
422
|
+
await useBackend(backendId);
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const selectBackend$1 = async () => {
|
|
429
|
+
const backends = await listBackends();
|
|
430
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
431
|
+
if (available.has("native-linux") && await tryUseBackend$1("native-linux")) return "native-linux";
|
|
432
|
+
if (available.has("secret-service") && await tryUseBackend$1("secret-service")) return "secret-service";
|
|
433
|
+
if (await tryUseBackend$1("native-linux")) return "native-linux";
|
|
434
|
+
if (await tryUseBackend$1("secret-service")) return "secret-service";
|
|
435
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
436
|
+
};
|
|
437
|
+
const ensureLinuxBackend = async (options = {}) => {
|
|
438
|
+
if (!backendInitPromise$1) backendInitPromise$1 = (async () => {
|
|
439
|
+
selectedBackend$1 = await selectBackend$1();
|
|
440
|
+
})();
|
|
441
|
+
try {
|
|
442
|
+
await backendInitPromise$1;
|
|
443
|
+
} catch {
|
|
444
|
+
backendInitPromise$1 = null;
|
|
445
|
+
selectedBackend$1 = null;
|
|
446
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
447
|
+
}
|
|
448
|
+
if (options.forWrite && selectedBackend$1 === "secret-service") await ensureFallbackConsent(LINUX_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Linux fallback backend is available.\nThis path relies on shell-based `secret-tool` operations for Secret Service access.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
449
|
+
};
|
|
450
|
+
const getLinuxCrossKeychainService = (accountId) => `${SERVICE_PREFIX$1}${accountId}`;
|
|
451
|
+
const parsePayload$1 = (accountId, raw) => {
|
|
452
|
+
let parsed;
|
|
453
|
+
try {
|
|
454
|
+
parsed = JSON.parse(raw);
|
|
455
|
+
} catch {
|
|
456
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
457
|
+
}
|
|
458
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
459
|
+
return parsed;
|
|
460
|
+
};
|
|
461
|
+
const withService$1 = async (accountId, run, options = {}) => {
|
|
462
|
+
await ensureLinuxBackend(options);
|
|
463
|
+
return run(getLinuxCrossKeychainService(accountId));
|
|
464
|
+
};
|
|
465
|
+
const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
466
|
+
const loadLinuxCrossKeychainPayload = async (accountId) => {
|
|
467
|
+
const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
|
|
468
|
+
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
469
|
+
return parsePayload$1(accountId, raw);
|
|
470
|
+
};
|
|
471
|
+
const deleteLinuxCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
|
|
472
|
+
const linuxCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
473
|
+
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region lib/secrets/windows-cross-keychain.ts
|
|
476
|
+
const SERVICE_PREFIX = "cdx-openai-";
|
|
477
|
+
const WINDOWS_FALLBACK_SCOPE = "win32:cross-keychain:windows";
|
|
478
|
+
let backendInitPromise = null;
|
|
479
|
+
let selectedBackend = null;
|
|
480
|
+
const tryUseBackend = async (backendId) => {
|
|
481
|
+
try {
|
|
482
|
+
await useBackend(backendId);
|
|
483
|
+
return true;
|
|
484
|
+
} catch {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const selectBackend = async () => {
|
|
489
|
+
const backends = await listBackends();
|
|
490
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
491
|
+
if (available.has("native-windows") && await tryUseBackend("native-windows")) return "native-windows";
|
|
492
|
+
if (available.has("windows") && await tryUseBackend("windows")) return "windows";
|
|
493
|
+
if (await tryUseBackend("native-windows")) return "native-windows";
|
|
494
|
+
if (await tryUseBackend("windows")) return "windows";
|
|
495
|
+
throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
|
|
496
|
+
};
|
|
497
|
+
const ensureWindowsBackend = async (options = {}) => {
|
|
498
|
+
if (!backendInitPromise) backendInitPromise = (async () => {
|
|
499
|
+
selectedBackend = await selectBackend();
|
|
500
|
+
})();
|
|
501
|
+
try {
|
|
502
|
+
await backendInitPromise;
|
|
503
|
+
} catch {
|
|
504
|
+
backendInitPromise = null;
|
|
505
|
+
selectedBackend = null;
|
|
506
|
+
throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
|
|
507
|
+
}
|
|
508
|
+
if (options.forWrite && selectedBackend === "windows") await ensureFallbackConsent(WINDOWS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Windows fallback backend is available.\nThis path runs a PowerShell helper to access Windows Credential Manager.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while the helper runs.");
|
|
509
|
+
};
|
|
510
|
+
const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
|
|
511
|
+
const parsePayload = (accountId, raw) => {
|
|
512
|
+
let parsed;
|
|
513
|
+
try {
|
|
514
|
+
parsed = JSON.parse(raw);
|
|
515
|
+
} catch {
|
|
516
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
517
|
+
}
|
|
518
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
519
|
+
return parsed;
|
|
520
|
+
};
|
|
521
|
+
const withService = async (accountId, run, options = {}) => {
|
|
522
|
+
await ensureWindowsBackend(options);
|
|
523
|
+
return run(getWindowsCrossKeychainService(accountId));
|
|
524
|
+
};
|
|
525
|
+
const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
526
|
+
const loadWindowsCrossKeychainPayload = async (accountId) => {
|
|
527
|
+
const raw = await withService(accountId, (service) => getPassword(service, accountId));
|
|
528
|
+
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
529
|
+
return parsePayload(accountId, raw);
|
|
530
|
+
};
|
|
531
|
+
const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, (service) => deletePassword(service, accountId));
|
|
532
|
+
const windowsCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
533
|
+
|
|
534
|
+
//#endregion
|
|
535
|
+
//#region lib/secrets/store.ts
|
|
536
|
+
const MAC_FALLBACK_SCOPE = "darwin:security-cli";
|
|
537
|
+
let macNativeStoreOptionPromise = null;
|
|
538
|
+
const unsupportedError = (platform) => /* @__PURE__ */ new Error(`No default secret store adapter configured for platform '${platform}'. Only macOS, Windows, and Linux adapters are wired by default right now.`);
|
|
539
|
+
const hasNativeMacStoreOption = async () => {
|
|
540
|
+
if (!macNativeStoreOptionPromise) macNativeStoreOptionPromise = (async () => {
|
|
541
|
+
try {
|
|
542
|
+
return (await listBackends()).some((backend) => backend.id === "native-macos");
|
|
543
|
+
} catch {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
})();
|
|
547
|
+
return macNativeStoreOptionPromise;
|
|
548
|
+
};
|
|
549
|
+
const ensureMacFallbackConsentIfNeeded = async () => {
|
|
550
|
+
if (await hasNativeMacStoreOption()) return;
|
|
551
|
+
await ensureFallbackConsent(MAC_FALLBACK_SCOPE, "⚠ Security warning: only the macOS CLI secure-store path is available.\nThis path uses the `security` command to access Keychain.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while CLI commands run.");
|
|
552
|
+
};
|
|
553
|
+
const createMacOSKeychainAdapter = () => ({
|
|
554
|
+
id: "macos-keychain",
|
|
555
|
+
label: "macOS Keychain",
|
|
556
|
+
getServiceName: getKeychainService,
|
|
557
|
+
save: async (accountId, payload) => {
|
|
558
|
+
await ensureMacFallbackConsentIfNeeded();
|
|
559
|
+
saveKeychainPayload(accountId, payload);
|
|
560
|
+
},
|
|
561
|
+
load: async (accountId) => loadKeychainPayload(accountId),
|
|
562
|
+
delete: async (accountId) => deleteKeychainPayload(accountId),
|
|
563
|
+
exists: async (accountId) => keychainPayloadExists(accountId),
|
|
564
|
+
listAccountIds: async () => listKeychainAccounts(),
|
|
565
|
+
getCapability: () => ({ available: true })
|
|
566
|
+
});
|
|
567
|
+
const loadConfiguredAccountIds = async () => {
|
|
568
|
+
if (!configExists()) return [];
|
|
569
|
+
return (await loadConfig()).accounts.map((account) => account.accountId);
|
|
570
|
+
};
|
|
571
|
+
const createWindowsCrossKeychainAdapter = () => ({
|
|
572
|
+
id: "windows-cross-keychain",
|
|
573
|
+
label: "Windows Credential Manager (cross-keychain)",
|
|
574
|
+
getServiceName: getWindowsCrossKeychainService,
|
|
575
|
+
save: saveWindowsCrossKeychainPayload,
|
|
576
|
+
load: loadWindowsCrossKeychainPayload,
|
|
577
|
+
delete: deleteWindowsCrossKeychainPayload,
|
|
578
|
+
exists: windowsCrossKeychainPayloadExists,
|
|
579
|
+
listAccountIds: async () => {
|
|
580
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
581
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
582
|
+
accountId,
|
|
583
|
+
exists: await windowsCrossKeychainPayloadExists(accountId)
|
|
584
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
585
|
+
},
|
|
586
|
+
getCapability: () => ({ available: true })
|
|
587
|
+
});
|
|
588
|
+
const createLinuxCrossKeychainAdapter = () => ({
|
|
589
|
+
id: "linux-cross-keychain",
|
|
590
|
+
label: "Linux Secret Service (cross-keychain)",
|
|
591
|
+
getServiceName: getLinuxCrossKeychainService,
|
|
592
|
+
save: saveLinuxCrossKeychainPayload,
|
|
593
|
+
load: loadLinuxCrossKeychainPayload,
|
|
594
|
+
delete: deleteLinuxCrossKeychainPayload,
|
|
595
|
+
exists: linuxCrossKeychainPayloadExists,
|
|
596
|
+
listAccountIds: async () => {
|
|
597
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
598
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
599
|
+
accountId,
|
|
600
|
+
exists: await linuxCrossKeychainPayloadExists(accountId)
|
|
601
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
602
|
+
},
|
|
603
|
+
getCapability: () => ({ available: true })
|
|
604
|
+
});
|
|
605
|
+
const createUnsupportedAdapter = (platform) => ({
|
|
606
|
+
id: "unsupported",
|
|
607
|
+
label: "Unsupported (no adapter configured)",
|
|
608
|
+
getServiceName: (accountId) => `cdx-openai-${accountId}`,
|
|
609
|
+
save: async () => {
|
|
610
|
+
throw unsupportedError(platform);
|
|
611
|
+
},
|
|
612
|
+
load: async () => {
|
|
613
|
+
throw unsupportedError(platform);
|
|
614
|
+
},
|
|
615
|
+
delete: async () => {
|
|
616
|
+
throw unsupportedError(platform);
|
|
617
|
+
},
|
|
618
|
+
exists: async () => false,
|
|
619
|
+
listAccountIds: async () => [],
|
|
620
|
+
getCapability: () => ({
|
|
621
|
+
available: false,
|
|
622
|
+
reason: "No default secure-store adapter available for this platform."
|
|
623
|
+
})
|
|
624
|
+
});
|
|
625
|
+
const createRuntimeSecretStoreAdapter = (platform = process.platform) => {
|
|
626
|
+
if (platform === "darwin") return createMacOSKeychainAdapter();
|
|
627
|
+
if (platform === "win32") return createWindowsCrossKeychainAdapter();
|
|
628
|
+
if (platform === "linux") return createLinuxCrossKeychainAdapter();
|
|
629
|
+
return createUnsupportedAdapter(platform);
|
|
630
|
+
};
|
|
631
|
+
let currentSecretStoreAdapter = createRuntimeSecretStoreAdapter();
|
|
632
|
+
const getSecretStoreAdapter = () => currentSecretStoreAdapter;
|
|
633
|
+
const setSecretStoreAdapter = (adapter) => {
|
|
634
|
+
currentSecretStoreAdapter = adapter;
|
|
635
|
+
};
|
|
636
|
+
const resetSecretStoreAdapter = () => {
|
|
637
|
+
currentSecretStoreAdapter = createRuntimeSecretStoreAdapter();
|
|
638
|
+
};
|
|
639
|
+
const getSecretStoreCapability = () => {
|
|
640
|
+
const adapter = getSecretStoreAdapter();
|
|
641
|
+
const capability = adapter.getCapability();
|
|
642
|
+
return {
|
|
643
|
+
id: adapter.id,
|
|
644
|
+
label: adapter.label,
|
|
645
|
+
available: capability.available,
|
|
646
|
+
...capability.reason ? { reason: capability.reason } : {}
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
|
|
234
650
|
//#endregion
|
|
235
651
|
//#region lib/oauth/constants.ts
|
|
236
652
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
@@ -429,83 +845,101 @@ const startOAuthServer = (state) => {
|
|
|
429
845
|
//#endregion
|
|
430
846
|
//#region lib/oauth/login.ts
|
|
431
847
|
const openBrowser = (url) => {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
848
|
+
const result = openBrowserUrl(url);
|
|
849
|
+
if (!result.ok) {
|
|
850
|
+
const msg = result.error ?? "unknown error";
|
|
851
|
+
p.log.warning(`Could not auto-open browser via ${result.launcher.label} (${msg}).`);
|
|
852
|
+
}
|
|
436
853
|
};
|
|
437
854
|
const addAccountToConfig = async (accountId, label) => {
|
|
438
855
|
let config;
|
|
856
|
+
const secretStore = getSecretStoreAdapter();
|
|
439
857
|
if (configExists()) {
|
|
440
858
|
config = await loadConfig();
|
|
441
859
|
if (!config.accounts.some((a) => a.accountId === accountId)) config.accounts.push({
|
|
442
860
|
accountId,
|
|
443
|
-
keychainService:
|
|
861
|
+
keychainService: secretStore.getServiceName(accountId),
|
|
444
862
|
...label ? { label } : {}
|
|
445
863
|
});
|
|
446
864
|
} else config = {
|
|
447
865
|
current: 0,
|
|
448
866
|
accounts: [{
|
|
449
867
|
accountId,
|
|
450
|
-
keychainService:
|
|
868
|
+
keychainService: secretStore.getServiceName(accountId),
|
|
451
869
|
...label ? { label } : {}
|
|
452
870
|
}]
|
|
453
871
|
};
|
|
454
872
|
await saveConfig(config);
|
|
455
873
|
};
|
|
456
|
-
const performRefresh = async (targetAccountId, label) => {
|
|
457
|
-
const
|
|
458
|
-
p.log.step(`Refreshing credentials for "${displayName}"...`);
|
|
459
|
-
let flow;
|
|
874
|
+
const performRefresh = async (targetAccountId, label, options = {}) => {
|
|
875
|
+
const keepAlive = setInterval(() => {}, 1e3);
|
|
460
876
|
try {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
spinner.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
877
|
+
const displayName = label ?? targetAccountId;
|
|
878
|
+
p.log.step(`Re-authenticating account "${displayName}"...`);
|
|
879
|
+
const useSpinner = options.useSpinner ?? true;
|
|
880
|
+
let flow;
|
|
881
|
+
try {
|
|
882
|
+
flow = await createAuthorizationFlow();
|
|
883
|
+
} catch (error) {
|
|
884
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
885
|
+
p.log.error(`Failed to create authorization flow: ${msg}`);
|
|
886
|
+
process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
const server = await startOAuthServer(flow.state);
|
|
890
|
+
if (!server.ready) {
|
|
891
|
+
p.log.error("Failed to start local server on port 1455.");
|
|
892
|
+
p.log.info("Please ensure the port is not in use.");
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
const spinner = useSpinner ? p.spinner() : null;
|
|
896
|
+
p.log.info("Opening browser for authentication...");
|
|
897
|
+
openBrowser(flow.url);
|
|
898
|
+
p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
|
|
899
|
+
if (spinner) spinner.start("Waiting for authentication...");
|
|
900
|
+
const result = await server.waitForCode();
|
|
901
|
+
server.close();
|
|
902
|
+
if (!result) {
|
|
903
|
+
if (spinner) spinner.stop("Authentication timed out or failed.");
|
|
904
|
+
else p.log.warning("Authentication timed out or failed.");
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
if (spinner) spinner.message("Exchanging authorization code...");
|
|
908
|
+
else p.log.message("Exchanging authorization code...");
|
|
909
|
+
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
910
|
+
if (tokenResult.type === "failed") {
|
|
911
|
+
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
912
|
+
else p.log.error("Failed to exchange authorization code.");
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
const newAccountId = extractAccountId(tokenResult.access);
|
|
916
|
+
if (!newAccountId) {
|
|
917
|
+
if (spinner) spinner.stop("Failed to extract account ID from token.");
|
|
918
|
+
else p.log.error("Failed to extract account ID from token.");
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
if (newAccountId !== targetAccountId) {
|
|
922
|
+
if (spinner) spinner.stop("Authentication completed for a different account.");
|
|
923
|
+
else p.log.error("Authentication completed for a different account.");
|
|
924
|
+
throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
|
|
925
|
+
}
|
|
926
|
+
if (spinner) spinner.message("Updating credentials...");
|
|
927
|
+
else p.log.message("Updating credentials...");
|
|
928
|
+
const payload = {
|
|
929
|
+
refresh: tokenResult.refresh,
|
|
930
|
+
access: tokenResult.access,
|
|
931
|
+
expires: tokenResult.expires,
|
|
932
|
+
accountId: newAccountId,
|
|
933
|
+
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
934
|
+
};
|
|
935
|
+
await getSecretStoreAdapter().save(newAccountId, payload);
|
|
936
|
+
if (spinner) spinner.stop("Credentials refreshed!");
|
|
937
|
+
else p.log.success("Credentials refreshed!");
|
|
938
|
+
p.log.success(`Account "${displayName}" credentials updated in secure store.`);
|
|
939
|
+
return { accountId: newAccountId };
|
|
940
|
+
} finally {
|
|
941
|
+
clearInterval(keepAlive);
|
|
497
942
|
}
|
|
498
|
-
spinner.message("Updating credentials...");
|
|
499
|
-
saveKeychainPayload(newAccountId, {
|
|
500
|
-
refresh: tokenResult.refresh,
|
|
501
|
-
access: tokenResult.access,
|
|
502
|
-
expires: tokenResult.expires,
|
|
503
|
-
accountId: newAccountId,
|
|
504
|
-
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
505
|
-
});
|
|
506
|
-
spinner.stop("Credentials refreshed!");
|
|
507
|
-
p.log.success(`Account "${displayName}" credentials updated in Keychain.`);
|
|
508
|
-
return { accountId: newAccountId };
|
|
509
943
|
};
|
|
510
944
|
const performLogin = async () => {
|
|
511
945
|
p.intro("cdx login - Add OpenAI account");
|
|
@@ -519,6 +953,7 @@ const performLogin = async () => {
|
|
|
519
953
|
const spinner = p.spinner();
|
|
520
954
|
p.log.info("Opening browser for authentication...");
|
|
521
955
|
openBrowser(flow.url);
|
|
956
|
+
p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
|
|
522
957
|
spinner.start("Waiting for authentication...");
|
|
523
958
|
const result = await server.waitForCode();
|
|
524
959
|
server.close();
|
|
@@ -538,13 +973,14 @@ const performLogin = async () => {
|
|
|
538
973
|
return null;
|
|
539
974
|
}
|
|
540
975
|
spinner.message("Saving credentials...");
|
|
541
|
-
|
|
976
|
+
const payload = {
|
|
542
977
|
refresh: tokenResult.refresh,
|
|
543
978
|
access: tokenResult.access,
|
|
544
979
|
expires: tokenResult.expires,
|
|
545
980
|
accountId,
|
|
546
981
|
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
547
|
-
}
|
|
982
|
+
};
|
|
983
|
+
await getSecretStoreAdapter().save(accountId, payload);
|
|
548
984
|
spinner.stop("Login successful!");
|
|
549
985
|
const labelInput = await p.text({
|
|
550
986
|
message: "Enter a label for this account (or press Enter to skip):",
|
|
@@ -553,7 +989,7 @@ const performLogin = async () => {
|
|
|
553
989
|
const label = !p.isCancel(labelInput) && labelInput?.trim() ? labelInput.trim() : void 0;
|
|
554
990
|
await addAccountToConfig(accountId, label);
|
|
555
991
|
const displayName = label ?? accountId;
|
|
556
|
-
p.log.success(`Account "${displayName}" saved to
|
|
992
|
+
p.log.success(`Account "${displayName}" saved to secure store and config.`);
|
|
557
993
|
p.outro("You can now use 'cdx switch' to activate this account.");
|
|
558
994
|
return { accountId };
|
|
559
995
|
};
|
|
@@ -565,7 +1001,19 @@ const writeActiveAuthFilesIfCurrent = async (accountId) => {
|
|
|
565
1001
|
const config = await loadConfig();
|
|
566
1002
|
const current = config.accounts[config.current];
|
|
567
1003
|
if (!current || current.accountId !== accountId) return null;
|
|
568
|
-
return writeAllAuthFiles(
|
|
1004
|
+
return writeAllAuthFiles(await getSecretStoreAdapter().load(accountId));
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
//#endregion
|
|
1008
|
+
//#region lib/platform/capabilities.ts
|
|
1009
|
+
const getRuntimeCapabilities = () => {
|
|
1010
|
+
const pathResolution = getPathResolutionInfo();
|
|
1011
|
+
return {
|
|
1012
|
+
platform: process.platform,
|
|
1013
|
+
pathProfile: pathResolution.profile,
|
|
1014
|
+
secretStore: getSecretStoreCapability(),
|
|
1015
|
+
browserLauncher: getBrowserLauncherCapability(process.platform)
|
|
1016
|
+
};
|
|
569
1017
|
};
|
|
570
1018
|
|
|
571
1019
|
//#endregion
|
|
@@ -650,12 +1098,13 @@ const readPiAuthAccount = async () => {
|
|
|
650
1098
|
};
|
|
651
1099
|
}
|
|
652
1100
|
};
|
|
653
|
-
const getAccountStatus = (accountId, isCurrent, label) => {
|
|
654
|
-
const
|
|
1101
|
+
const getAccountStatus = async (accountId, isCurrent, label) => {
|
|
1102
|
+
const secretStore = getSecretStoreAdapter();
|
|
1103
|
+
const secureStoreExists = await secretStore.exists(accountId);
|
|
655
1104
|
let expiresAt = null;
|
|
656
1105
|
let hasIdToken = false;
|
|
657
|
-
if (
|
|
658
|
-
const payload =
|
|
1106
|
+
if (secureStoreExists) try {
|
|
1107
|
+
const payload = await secretStore.load(accountId);
|
|
659
1108
|
expiresAt = payload.expires;
|
|
660
1109
|
hasIdToken = !!payload.idToken;
|
|
661
1110
|
} catch {}
|
|
@@ -663,7 +1112,7 @@ const getAccountStatus = (accountId, isCurrent, label) => {
|
|
|
663
1112
|
accountId,
|
|
664
1113
|
label,
|
|
665
1114
|
isCurrent,
|
|
666
|
-
|
|
1115
|
+
secureStoreExists,
|
|
667
1116
|
hasIdToken,
|
|
668
1117
|
expiresAt,
|
|
669
1118
|
expiresIn: formatExpiry(expiresAt)
|
|
@@ -675,7 +1124,7 @@ const getStatus = async () => {
|
|
|
675
1124
|
const config = await loadConfig();
|
|
676
1125
|
for (let i = 0; i < config.accounts.length; i++) {
|
|
677
1126
|
const account = config.accounts[i];
|
|
678
|
-
accounts.push(getAccountStatus(account.accountId, i === config.current, account.label));
|
|
1127
|
+
accounts.push(await getAccountStatus(account.accountId, i === config.current, account.label));
|
|
679
1128
|
}
|
|
680
1129
|
}
|
|
681
1130
|
const [opencodeAuth, codexAuth, piAuth] = await Promise.all([
|
|
@@ -687,7 +1136,8 @@ const getStatus = async () => {
|
|
|
687
1136
|
accounts,
|
|
688
1137
|
opencodeAuth,
|
|
689
1138
|
codexAuth,
|
|
690
|
-
piAuth
|
|
1139
|
+
piAuth,
|
|
1140
|
+
capabilities: getRuntimeCapabilities()
|
|
691
1141
|
};
|
|
692
1142
|
};
|
|
693
1143
|
|
|
@@ -697,6 +1147,20 @@ const getAccountDisplay = (accountId, isCurrent, label) => {
|
|
|
697
1147
|
const name = label ? `${label} (${accountId})` : accountId;
|
|
698
1148
|
return isCurrent ? `${name} (current)` : name;
|
|
699
1149
|
};
|
|
1150
|
+
const hasStoredCredentials = async (accountId) => getSecretStoreAdapter().exists(accountId);
|
|
1151
|
+
const loadStoredCredentials = async (accountId) => getSecretStoreAdapter().load(accountId);
|
|
1152
|
+
const getStoredAccountIds = async () => getSecretStoreAdapter().listAccountIds();
|
|
1153
|
+
const removeStoredCredentials = async (accountId) => {
|
|
1154
|
+
await getSecretStoreAdapter().delete(accountId);
|
|
1155
|
+
};
|
|
1156
|
+
const getRefreshExpiryState = async (accountId) => {
|
|
1157
|
+
if (!await hasStoredCredentials(accountId)) return "unknown [no secure store entry]";
|
|
1158
|
+
try {
|
|
1159
|
+
return formatExpiry((await loadStoredCredentials(accountId)).expires);
|
|
1160
|
+
} catch {
|
|
1161
|
+
return "unknown";
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
700
1164
|
const handleListAccounts = async () => {
|
|
701
1165
|
if (!configExists()) {
|
|
702
1166
|
p.log.warning("No accounts configured. Use 'Add account' to get started.");
|
|
@@ -708,7 +1172,7 @@ const handleListAccounts = async () => {
|
|
|
708
1172
|
for (const account of config.accounts) {
|
|
709
1173
|
const marker = account.accountId === currentAccountId ? "→ " : " ";
|
|
710
1174
|
const displayName = account.label ? `${account.label} (${account.accountId})` : account.accountId;
|
|
711
|
-
const status =
|
|
1175
|
+
const status = await hasStoredCredentials(account.accountId) ? "" : " (missing credentials)";
|
|
712
1176
|
p.log.message(`${marker}${displayName}${status}`);
|
|
713
1177
|
}
|
|
714
1178
|
};
|
|
@@ -746,7 +1210,7 @@ const handleSwitchAccount = async () => {
|
|
|
746
1210
|
}
|
|
747
1211
|
let payload;
|
|
748
1212
|
try {
|
|
749
|
-
payload =
|
|
1213
|
+
payload = await loadStoredCredentials(selectedAccount.accountId);
|
|
750
1214
|
} catch {
|
|
751
1215
|
p.log.error(`Missing credentials for account ${selectedAccount.label ?? selectedAccount.accountId}. Re-login with 'cdx login'.`);
|
|
752
1216
|
return;
|
|
@@ -766,23 +1230,23 @@ const handleSwitchAccount = async () => {
|
|
|
766
1230
|
const handleAddAccount = async () => {
|
|
767
1231
|
await performLogin();
|
|
768
1232
|
};
|
|
769
|
-
const
|
|
1233
|
+
const handleReloginAccount = async () => {
|
|
770
1234
|
if (!configExists()) {
|
|
771
1235
|
p.log.warning("No accounts configured. Use 'Add account' first.");
|
|
772
1236
|
return;
|
|
773
1237
|
}
|
|
774
1238
|
const config = await loadConfig();
|
|
775
1239
|
if (config.accounts.length === 0) {
|
|
776
|
-
p.log.warning("No accounts to
|
|
1240
|
+
p.log.warning("No accounts to re-login.");
|
|
777
1241
|
return;
|
|
778
1242
|
}
|
|
779
1243
|
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
780
|
-
const options = config.accounts.map((account) => ({
|
|
1244
|
+
const options = await Promise.all(config.accounts.map(async (account) => ({
|
|
781
1245
|
value: account.accountId,
|
|
782
|
-
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
783
|
-
}));
|
|
1246
|
+
label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${await getRefreshExpiryState(account.accountId)}`
|
|
1247
|
+
})));
|
|
784
1248
|
const selected = await p.select({
|
|
785
|
-
message: "Select account to
|
|
1249
|
+
message: "Select account to re-login:",
|
|
786
1250
|
options
|
|
787
1251
|
});
|
|
788
1252
|
if (p.isCancel(selected)) {
|
|
@@ -791,9 +1255,12 @@ const handleRefreshAccount = async () => {
|
|
|
791
1255
|
}
|
|
792
1256
|
const accountId = selected;
|
|
793
1257
|
const account = config.accounts.find((a) => a.accountId === accountId);
|
|
1258
|
+
const expiryState = await getRefreshExpiryState(accountId);
|
|
1259
|
+
const displayName = account?.label ?? accountId;
|
|
1260
|
+
p.log.info(`Current token status for ${displayName}: ${expiryState}`);
|
|
794
1261
|
try {
|
|
795
|
-
const result = await performRefresh(accountId, account?.label);
|
|
796
|
-
if (!result) p.log.warning("
|
|
1262
|
+
const result = await performRefresh(accountId, account?.label, { useSpinner: false });
|
|
1263
|
+
if (!result) p.log.warning("Re-login was not completed.");
|
|
797
1264
|
else {
|
|
798
1265
|
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
799
1266
|
if (authResult) {
|
|
@@ -807,7 +1274,7 @@ const handleRefreshAccount = async () => {
|
|
|
807
1274
|
}
|
|
808
1275
|
} catch (error) {
|
|
809
1276
|
const msg = error instanceof Error ? error.message : String(error);
|
|
810
|
-
p.log.error(`
|
|
1277
|
+
p.log.error(`Re-login failed: ${msg}`);
|
|
811
1278
|
}
|
|
812
1279
|
};
|
|
813
1280
|
const handleRemoveAccount = async () => {
|
|
@@ -843,7 +1310,7 @@ const handleRemoveAccount = async () => {
|
|
|
843
1310
|
return;
|
|
844
1311
|
}
|
|
845
1312
|
try {
|
|
846
|
-
|
|
1313
|
+
await removeStoredCredentials(accountId);
|
|
847
1314
|
} catch {}
|
|
848
1315
|
const previousAccountId = config.accounts[config.current]?.accountId;
|
|
849
1316
|
config.accounts = config.accounts.filter((a) => a.accountId !== accountId);
|
|
@@ -907,9 +1374,9 @@ const handleStatus = async () => {
|
|
|
907
1374
|
for (const account of status.accounts) {
|
|
908
1375
|
const marker = account.isCurrent ? "→ " : " ";
|
|
909
1376
|
const name = account.label ? `${account.label} (${account.accountId})` : account.accountId;
|
|
910
|
-
const
|
|
1377
|
+
const secureStore = account.secureStoreExists ? "" : " [no secure store entry]";
|
|
911
1378
|
const idToken = account.hasIdToken ? "" : " [no id_token]";
|
|
912
|
-
p.log.message(`${marker}${name} — ${account.expiresIn}${
|
|
1379
|
+
p.log.message(`${marker}${name} — ${account.expiresIn}${secureStore}${idToken}`);
|
|
913
1380
|
}
|
|
914
1381
|
const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
|
|
915
1382
|
const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
|
|
@@ -923,7 +1390,7 @@ const runInteractiveMode = async () => {
|
|
|
923
1390
|
p.intro("cdx - OpenAI Account Switcher");
|
|
924
1391
|
let running = true;
|
|
925
1392
|
while (running) {
|
|
926
|
-
const
|
|
1393
|
+
const storedAccounts = await getStoredAccountIds();
|
|
927
1394
|
let currentInfo = "";
|
|
928
1395
|
if (configExists()) try {
|
|
929
1396
|
const config = await loadConfig();
|
|
@@ -935,7 +1402,7 @@ const runInteractiveMode = async () => {
|
|
|
935
1402
|
options: [
|
|
936
1403
|
{
|
|
937
1404
|
value: "list",
|
|
938
|
-
label: `List accounts (${
|
|
1405
|
+
label: `List accounts (${storedAccounts.length} in secure store)`
|
|
939
1406
|
},
|
|
940
1407
|
{
|
|
941
1408
|
value: "switch",
|
|
@@ -946,8 +1413,8 @@ const runInteractiveMode = async () => {
|
|
|
946
1413
|
label: "Add account (OAuth login)"
|
|
947
1414
|
},
|
|
948
1415
|
{
|
|
949
|
-
value: "
|
|
950
|
-
label: "
|
|
1416
|
+
value: "relogin",
|
|
1417
|
+
label: "Re-login account"
|
|
951
1418
|
},
|
|
952
1419
|
{
|
|
953
1420
|
value: "remove",
|
|
@@ -981,8 +1448,8 @@ const runInteractiveMode = async () => {
|
|
|
981
1448
|
case "add":
|
|
982
1449
|
await handleAddAccount();
|
|
983
1450
|
break;
|
|
984
|
-
case "
|
|
985
|
-
await
|
|
1451
|
+
case "relogin":
|
|
1452
|
+
await handleReloginAccount();
|
|
986
1453
|
break;
|
|
987
1454
|
case "remove":
|
|
988
1455
|
await handleRemoveAccount();
|
|
@@ -1002,6 +1469,167 @@ const runInteractiveMode = async () => {
|
|
|
1002
1469
|
p.outro("Goodbye!");
|
|
1003
1470
|
};
|
|
1004
1471
|
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region lib/commands/interactive.ts
|
|
1474
|
+
const registerDefaultInteractiveAction = (program) => {
|
|
1475
|
+
program.action(async () => {
|
|
1476
|
+
try {
|
|
1477
|
+
await runInteractiveMode();
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
exitWithCommandError(error);
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
//#endregion
|
|
1485
|
+
//#region lib/commands/doctor.ts
|
|
1486
|
+
const registerDoctorCommand = (program) => {
|
|
1487
|
+
program.command("doctor").description("Show auth file paths and runtime capabilities").action(async () => {
|
|
1488
|
+
try {
|
|
1489
|
+
const status = await getStatus();
|
|
1490
|
+
const paths = getPaths();
|
|
1491
|
+
const resolveLabel = (accountId) => {
|
|
1492
|
+
if (!accountId) return "unknown";
|
|
1493
|
+
return status.accounts.find((account) => account.accountId === accountId)?.label ?? accountId;
|
|
1494
|
+
};
|
|
1495
|
+
process.stdout.write("\nAuth files:\n");
|
|
1496
|
+
const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
|
|
1497
|
+
process.stdout.write(` OpenCode: ${ocStatus}\n`);
|
|
1498
|
+
process.stdout.write(` Path: ${paths.authPath}\n`);
|
|
1499
|
+
const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
|
|
1500
|
+
process.stdout.write(` Codex CLI: ${cxStatus}\n`);
|
|
1501
|
+
process.stdout.write(` Path: ${paths.codexAuthPath}\n`);
|
|
1502
|
+
const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
|
|
1503
|
+
process.stdout.write(` Pi Agent: ${piStatus}\n`);
|
|
1504
|
+
process.stdout.write(` Path: ${paths.piAuthPath}\n`);
|
|
1505
|
+
process.stdout.write("\nCapabilities:\n");
|
|
1506
|
+
process.stdout.write(` Platform: ${status.capabilities.platform}\n`);
|
|
1507
|
+
process.stdout.write(` Path profile: ${status.capabilities.pathProfile}\n`);
|
|
1508
|
+
const secretStoreState = status.capabilities.secretStore.available ? "available" : `unavailable${status.capabilities.secretStore.reason ? ` (${status.capabilities.secretStore.reason})` : ""}`;
|
|
1509
|
+
process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
|
|
1510
|
+
const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
|
|
1511
|
+
process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
|
|
1512
|
+
process.stdout.write("\n");
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
exitWithCommandError(error);
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
//#endregion
|
|
1520
|
+
//#region lib/commands/help.ts
|
|
1521
|
+
const registerHelpCommand = (program) => {
|
|
1522
|
+
program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
|
|
1523
|
+
if (commandName) {
|
|
1524
|
+
const command = program.commands.find((entry) => entry.name() === commandName);
|
|
1525
|
+
if (command) {
|
|
1526
|
+
command.outputHelp();
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
process.stderr.write(`Unknown command: ${commandName}\n`);
|
|
1530
|
+
program.outputHelp();
|
|
1531
|
+
process.exit(1);
|
|
1532
|
+
}
|
|
1533
|
+
program.outputHelp();
|
|
1534
|
+
});
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
//#endregion
|
|
1538
|
+
//#region lib/commands/label.ts
|
|
1539
|
+
const registerLabelCommand = (program) => {
|
|
1540
|
+
program.command("label").description("Add or change label for an account").argument("[account]", "Account ID or current label to relabel").argument("[new-label]", "New label to assign").action(async (account, newLabel) => {
|
|
1541
|
+
try {
|
|
1542
|
+
if (account && newLabel) {
|
|
1543
|
+
const config = await loadConfig();
|
|
1544
|
+
const target = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
1545
|
+
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
1546
|
+
target.label = newLabel;
|
|
1547
|
+
await saveConfig(config);
|
|
1548
|
+
process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
await handleLabelAccount();
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
exitWithCommandError(error);
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
//#endregion
|
|
1559
|
+
//#region lib/commands/login.ts
|
|
1560
|
+
const registerLoginCommand = (program, deps = {}) => {
|
|
1561
|
+
const runLogin = deps.performLogin ?? performLogin;
|
|
1562
|
+
program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
|
|
1563
|
+
try {
|
|
1564
|
+
if (!await runLogin()) {
|
|
1565
|
+
process.stderr.write("Login failed.\n");
|
|
1566
|
+
process.exit(1);
|
|
1567
|
+
}
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
exitWithCommandError(error);
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
//#endregion
|
|
1575
|
+
//#region lib/commands/output.ts
|
|
1576
|
+
const formatCodexMark = (result) => {
|
|
1577
|
+
if (result.codexWritten) return "✓";
|
|
1578
|
+
if (result.codexCleared) return "⚠ missing id_token (cleared)";
|
|
1579
|
+
return "⚠ missing id_token";
|
|
1580
|
+
};
|
|
1581
|
+
const writeSwitchSummary = (displayName, result) => {
|
|
1582
|
+
const piMark = result.piWritten ? "✓" : "✗";
|
|
1583
|
+
const codexMark = formatCodexMark(result);
|
|
1584
|
+
process.stdout.write(`Switched to account ${displayName}\n`);
|
|
1585
|
+
process.stdout.write(" OpenCode: ✓\n");
|
|
1586
|
+
process.stdout.write(` Pi Agent: ${piMark}\n`);
|
|
1587
|
+
process.stdout.write(` Codex CLI: ${codexMark}\n`);
|
|
1588
|
+
};
|
|
1589
|
+
const writeUpdatedAuthSummary = (result) => {
|
|
1590
|
+
const piMark = result.piWritten ? "✓" : "✗";
|
|
1591
|
+
const codexMark = formatCodexMark(result);
|
|
1592
|
+
process.stdout.write("Updated active auth files:\n");
|
|
1593
|
+
process.stdout.write(" OpenCode: ✓\n");
|
|
1594
|
+
process.stdout.write(` Pi Agent: ${piMark}\n`);
|
|
1595
|
+
process.stdout.write(` Codex CLI: ${codexMark}\n`);
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1598
|
+
//#endregion
|
|
1599
|
+
//#region lib/commands/refresh.ts
|
|
1600
|
+
const registerReloginCommand = (program) => {
|
|
1601
|
+
program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").argument("[account]", "Account ID or label to re-login").action(async (account) => {
|
|
1602
|
+
try {
|
|
1603
|
+
if (account) {
|
|
1604
|
+
const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
1605
|
+
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
1606
|
+
const displayName = target.label ?? target.accountId;
|
|
1607
|
+
let expiryState = "unknown";
|
|
1608
|
+
let secureStoreState = "";
|
|
1609
|
+
const secretStore = getSecretStoreAdapter();
|
|
1610
|
+
if (await secretStore.exists(target.accountId)) try {
|
|
1611
|
+
expiryState = formatExpiry((await secretStore.load(target.accountId)).expires);
|
|
1612
|
+
} catch {
|
|
1613
|
+
expiryState = "unknown";
|
|
1614
|
+
}
|
|
1615
|
+
else secureStoreState = " [no secure store entry]";
|
|
1616
|
+
process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
|
|
1617
|
+
const result = await performRefresh(target.accountId, target.label);
|
|
1618
|
+
if (!result) {
|
|
1619
|
+
process.stderr.write("Re-login failed.\n");
|
|
1620
|
+
process.exit(1);
|
|
1621
|
+
}
|
|
1622
|
+
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
1623
|
+
if (authResult) writeUpdatedAuthSummary(authResult);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
await handleReloginAccount();
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
exitWithCommandError(error);
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1005
1633
|
//#endregion
|
|
1006
1634
|
//#region lib/usage.ts
|
|
1007
1635
|
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
@@ -1022,9 +1650,10 @@ const fetchUsageRaw = async (accessToken, accountId) => {
|
|
|
1022
1650
|
* Fetches usage for an account. On 401, refreshes the token and retries once.
|
|
1023
1651
|
*/
|
|
1024
1652
|
const fetchUsage = async (accountId) => {
|
|
1653
|
+
const secretStore = getSecretStoreAdapter();
|
|
1025
1654
|
let payload;
|
|
1026
1655
|
try {
|
|
1027
|
-
payload =
|
|
1656
|
+
payload = await secretStore.load(accountId);
|
|
1028
1657
|
} catch (err) {
|
|
1029
1658
|
return {
|
|
1030
1659
|
ok: false,
|
|
@@ -1052,7 +1681,7 @@ const fetchUsage = async (accountId) => {
|
|
|
1052
1681
|
expires: refreshResult.expires,
|
|
1053
1682
|
idToken: refreshResult.idToken ?? payload.idToken
|
|
1054
1683
|
};
|
|
1055
|
-
|
|
1684
|
+
await secretStore.save(accountId, updatedPayload);
|
|
1056
1685
|
response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
|
|
1057
1686
|
if (!response.ok) return {
|
|
1058
1687
|
ok: false,
|
|
@@ -1159,113 +1788,9 @@ const formatUsageOverview = (entries) => {
|
|
|
1159
1788
|
};
|
|
1160
1789
|
|
|
1161
1790
|
//#endregion
|
|
1162
|
-
//#region
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
const nextIndex = (config.current + 1) % config.accounts.length;
|
|
1166
|
-
const nextAccount = config.accounts[nextIndex];
|
|
1167
|
-
if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
|
|
1168
|
-
const payload = loadKeychainPayload(nextAccount.accountId);
|
|
1169
|
-
const result = await writeAllAuthFiles(payload);
|
|
1170
|
-
config.current = nextIndex;
|
|
1171
|
-
await saveConfig(config);
|
|
1172
|
-
const displayName = nextAccount.label ?? payload.accountId;
|
|
1173
|
-
const opencodeMark = "✓";
|
|
1174
|
-
const piMark = result.piWritten ? "✓" : "✗";
|
|
1175
|
-
const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
|
|
1176
|
-
process.stdout.write(`Switched to account ${displayName}\n`);
|
|
1177
|
-
process.stdout.write(` OpenCode: ${opencodeMark}\n`);
|
|
1178
|
-
process.stdout.write(` Pi Agent: ${piMark}\n`);
|
|
1179
|
-
process.stdout.write(` Codex CLI: ${codexMark}\n`);
|
|
1180
|
-
};
|
|
1181
|
-
const switchToAccount = async (identifier) => {
|
|
1182
|
-
const config = await loadConfig();
|
|
1183
|
-
const index = config.accounts.findIndex((a) => a.accountId === identifier || a.label === identifier);
|
|
1184
|
-
if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
|
|
1185
|
-
const account = config.accounts[index];
|
|
1186
|
-
const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
|
|
1187
|
-
config.current = index;
|
|
1188
|
-
await saveConfig(config);
|
|
1189
|
-
const displayName = account.label ?? account.accountId;
|
|
1190
|
-
const opencodeMark = "✓";
|
|
1191
|
-
const piMark = result.piWritten ? "✓" : "✗";
|
|
1192
|
-
const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
|
|
1193
|
-
process.stdout.write(`Switched to account ${displayName}\n`);
|
|
1194
|
-
process.stdout.write(` OpenCode: ${opencodeMark}\n`);
|
|
1195
|
-
process.stdout.write(` Pi Agent: ${piMark}\n`);
|
|
1196
|
-
process.stdout.write(` Codex CLI: ${codexMark}\n`);
|
|
1197
|
-
};
|
|
1198
|
-
const interactiveMode = runInteractiveMode;
|
|
1199
|
-
const createProgram = (deps = {}) => {
|
|
1200
|
-
const program = new Command();
|
|
1201
|
-
const runLogin = deps.performLogin ?? performLogin;
|
|
1202
|
-
program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
|
|
1203
|
-
program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
|
|
1204
|
-
try {
|
|
1205
|
-
if (!await runLogin()) {
|
|
1206
|
-
process.stderr.write("Login failed.\n");
|
|
1207
|
-
process.exit(1);
|
|
1208
|
-
}
|
|
1209
|
-
} catch (error) {
|
|
1210
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1211
|
-
process.stderr.write(`${message}\n`);
|
|
1212
|
-
process.exit(1);
|
|
1213
|
-
}
|
|
1214
|
-
});
|
|
1215
|
-
program.command("refresh").description("Re-authenticate an existing account (update tokens without creating a duplicate)").argument("[account]", "Account ID or label to refresh").action(async (account) => {
|
|
1216
|
-
try {
|
|
1217
|
-
if (account) {
|
|
1218
|
-
const target = (await loadConfig()).accounts.find((a) => a.accountId === account || a.label === account);
|
|
1219
|
-
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
1220
|
-
const result = await performRefresh(target.accountId, target.label);
|
|
1221
|
-
if (!result) {
|
|
1222
|
-
process.stderr.write("Refresh failed.\n");
|
|
1223
|
-
process.exit(1);
|
|
1224
|
-
}
|
|
1225
|
-
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
1226
|
-
if (authResult) {
|
|
1227
|
-
const piMark = authResult.piWritten ? "✓" : "✗";
|
|
1228
|
-
const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
|
|
1229
|
-
process.stdout.write("Updated active auth files:\n");
|
|
1230
|
-
process.stdout.write(" OpenCode: ✓\n");
|
|
1231
|
-
process.stdout.write(` Pi Agent: ${piMark}\n`);
|
|
1232
|
-
process.stdout.write(` Codex CLI: ${codexMark}\n`);
|
|
1233
|
-
}
|
|
1234
|
-
} else await handleRefreshAccount();
|
|
1235
|
-
} catch (error) {
|
|
1236
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1237
|
-
process.stderr.write(`${message}\n`);
|
|
1238
|
-
process.exit(1);
|
|
1239
|
-
}
|
|
1240
|
-
});
|
|
1241
|
-
program.command("switch").description("Switch OpenAI account (interactive picker, by name, or --next)").argument("[account-id]", "Account ID to switch to directly").option("-n, --next", "Cycle to the next configured account").action(async (accountId, options) => {
|
|
1242
|
-
try {
|
|
1243
|
-
if (options.next) await switchNext();
|
|
1244
|
-
else if (accountId) await switchToAccount(accountId);
|
|
1245
|
-
else await handleSwitchAccount();
|
|
1246
|
-
} catch (error) {
|
|
1247
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1248
|
-
process.stderr.write(`${message}\n`);
|
|
1249
|
-
process.exit(1);
|
|
1250
|
-
}
|
|
1251
|
-
});
|
|
1252
|
-
program.command("label").description("Add or change label for an account").argument("[account]", "Account ID or current label to relabel").argument("[new-label]", "New label to assign").action(async (account, newLabel) => {
|
|
1253
|
-
try {
|
|
1254
|
-
if (account && newLabel) {
|
|
1255
|
-
const config = await loadConfig();
|
|
1256
|
-
const target = config.accounts.find((a) => a.accountId === account || a.label === account);
|
|
1257
|
-
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
1258
|
-
target.label = newLabel;
|
|
1259
|
-
await saveConfig(config);
|
|
1260
|
-
process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
|
|
1261
|
-
} else await handleLabelAccount();
|
|
1262
|
-
} catch (error) {
|
|
1263
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1264
|
-
process.stderr.write(`${message}\n`);
|
|
1265
|
-
process.exit(1);
|
|
1266
|
-
}
|
|
1267
|
-
});
|
|
1268
|
-
program.command("status").description("Show account status, token expiry, and auth file state").action(async () => {
|
|
1791
|
+
//#region lib/commands/status.ts
|
|
1792
|
+
const registerStatusCommand = (program) => {
|
|
1793
|
+
program.command("status").description("Show account status, token expiry, and usage").action(async () => {
|
|
1269
1794
|
try {
|
|
1270
1795
|
const status = await getStatus();
|
|
1271
1796
|
if (status.accounts.length === 0) {
|
|
@@ -1277,108 +1802,157 @@ const createProgram = (deps = {}) => {
|
|
|
1277
1802
|
const account = status.accounts[i];
|
|
1278
1803
|
const marker = account.isCurrent ? "→ " : " ";
|
|
1279
1804
|
const warnings = [];
|
|
1280
|
-
if (!account.
|
|
1805
|
+
if (!account.secureStoreExists) warnings.push("[no secure store entry]");
|
|
1281
1806
|
if (!account.hasIdToken) warnings.push("[no id_token]");
|
|
1282
1807
|
const warnStr = warnings.length > 0 ? ` ${warnings.join(" ")}` : "";
|
|
1283
1808
|
const displayName = account.label ?? account.accountId;
|
|
1284
1809
|
process.stdout.write(`${marker}${displayName}${warnStr}\n`);
|
|
1285
1810
|
if (account.label) process.stdout.write(` ${account.accountId}\n`);
|
|
1286
1811
|
process.stdout.write(` ${account.expiresIn}\n`);
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1812
|
+
if (i < status.accounts.length - 1) process.stdout.write("\n");
|
|
1813
|
+
}
|
|
1814
|
+
const usageSpinner = p.spinner();
|
|
1815
|
+
const accountWord = status.accounts.length === 1 ? "account" : "accounts";
|
|
1816
|
+
usageSpinner.start(`Fetching usage for ${status.accounts.length} ${accountWord}...`);
|
|
1817
|
+
const usageResults = await Promise.allSettled(status.accounts.map((account) => fetchUsage(account.accountId)));
|
|
1818
|
+
const failedUsageCount = usageResults.filter((result) => result.status === "rejected" || result.status === "fulfilled" && !result.value.ok).length;
|
|
1819
|
+
if (failedUsageCount === 0) usageSpinner.stop("Usage loaded.");
|
|
1820
|
+
else {
|
|
1821
|
+
const failedWord = failedUsageCount === 1 ? "account" : "accounts";
|
|
1822
|
+
usageSpinner.stop(`Usage loaded (${failedUsageCount} ${failedWord} failed).`);
|
|
1823
|
+
}
|
|
1824
|
+
process.stdout.write("\nUsage:\n");
|
|
1825
|
+
for (let i = 0; i < status.accounts.length; i++) {
|
|
1826
|
+
const account = status.accounts[i];
|
|
1827
|
+
const marker = account.isCurrent ? "→ " : " ";
|
|
1828
|
+
const displayName = account.label ?? account.accountId;
|
|
1829
|
+
process.stdout.write(`${marker}${displayName}\n`);
|
|
1830
|
+
if (account.label) process.stdout.write(` ${account.accountId}\n`);
|
|
1831
|
+
const usageResult = usageResults[i];
|
|
1832
|
+
if (usageResult.status === "rejected") {
|
|
1833
|
+
const message = usageResult.reason instanceof Error ? usageResult.reason.message : "Fetch failed";
|
|
1834
|
+
process.stdout.write(` [usage unavailable] ${message}\n`);
|
|
1835
|
+
} else if (usageResult.value.ok) {
|
|
1836
|
+
const bars = formatUsageBars(usageResult.value.data);
|
|
1837
|
+
if (bars.length === 0) process.stdout.write(" Usage data unavailable\n");
|
|
1290
1838
|
for (const bar of bars) process.stdout.write(`${bar}\n`);
|
|
1291
|
-
}
|
|
1839
|
+
} else process.stdout.write(` [usage unavailable] ${usageResult.value.error.message}\n`);
|
|
1292
1840
|
if (i < status.accounts.length - 1) process.stdout.write("\n");
|
|
1293
1841
|
}
|
|
1294
|
-
const resolveLabel = (id) => {
|
|
1295
|
-
if (!id) return "unknown";
|
|
1296
|
-
return status.accounts.find((a) => a.accountId === id)?.label ?? id;
|
|
1297
|
-
};
|
|
1298
|
-
process.stdout.write("\nAuth files:\n");
|
|
1299
|
-
const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
|
|
1300
|
-
process.stdout.write(` OpenCode: ${ocStatus}\n`);
|
|
1301
|
-
const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
|
|
1302
|
-
process.stdout.write(` Codex CLI: ${cxStatus}\n`);
|
|
1303
|
-
const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
|
|
1304
|
-
process.stdout.write(` Pi Agent: ${piStatus}\n`);
|
|
1305
1842
|
process.stdout.write("\n");
|
|
1306
1843
|
} catch (error) {
|
|
1307
|
-
|
|
1308
|
-
process.stderr.write(`${message}\n`);
|
|
1309
|
-
process.exit(1);
|
|
1844
|
+
exitWithCommandError(error);
|
|
1310
1845
|
}
|
|
1311
1846
|
});
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
//#endregion
|
|
1850
|
+
//#region lib/commands/switch.ts
|
|
1851
|
+
const switchNext = async () => {
|
|
1852
|
+
const config = await loadConfig();
|
|
1853
|
+
const nextIndex = (config.current + 1) % config.accounts.length;
|
|
1854
|
+
const nextAccount = config.accounts[nextIndex];
|
|
1855
|
+
if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
|
|
1856
|
+
const payload = await getSecretStoreAdapter().load(nextAccount.accountId);
|
|
1857
|
+
const result = await writeAllAuthFiles(payload);
|
|
1858
|
+
config.current = nextIndex;
|
|
1859
|
+
await saveConfig(config);
|
|
1860
|
+
writeSwitchSummary(nextAccount.label ?? payload.accountId, result);
|
|
1861
|
+
};
|
|
1862
|
+
const switchToAccount = async (identifier) => {
|
|
1863
|
+
const config = await loadConfig();
|
|
1864
|
+
const index = config.accounts.findIndex((account) => account.accountId === identifier || account.label === identifier);
|
|
1865
|
+
if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
|
|
1866
|
+
const account = config.accounts[index];
|
|
1867
|
+
const result = await writeAllAuthFiles(await getSecretStoreAdapter().load(account.accountId));
|
|
1868
|
+
config.current = index;
|
|
1869
|
+
await saveConfig(config);
|
|
1870
|
+
writeSwitchSummary(account.label ?? account.accountId, result);
|
|
1871
|
+
};
|
|
1872
|
+
const registerSwitchCommand = (program) => {
|
|
1873
|
+
program.command("switch").description("Switch OpenAI account (interactive picker, by name, or --next)").argument("[account-id]", "Account ID to switch to directly").option("-n, --next", "Cycle to the next configured account").action(async (accountId, options) => {
|
|
1874
|
+
try {
|
|
1875
|
+
if (options.next) await switchNext();
|
|
1876
|
+
else if (accountId) await switchToAccount(accountId);
|
|
1877
|
+
else await handleSwitchAccount();
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
exitWithCommandError(error);
|
|
1880
|
+
}
|
|
1881
|
+
});
|
|
1882
|
+
};
|
|
1883
|
+
|
|
1884
|
+
//#endregion
|
|
1885
|
+
//#region lib/commands/usage.ts
|
|
1886
|
+
const registerUsageCommand = (program) => {
|
|
1312
1887
|
program.command("usage").description("Show OpenAI usage for all accounts (or detailed view for one)").argument("[account]", "Account ID or label (shows detailed single-account view)").action(async (account) => {
|
|
1313
1888
|
try {
|
|
1314
1889
|
const config = await loadConfig();
|
|
1315
1890
|
if (account) {
|
|
1316
|
-
const found = config.accounts.find((
|
|
1891
|
+
const found = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
1317
1892
|
if (!found) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
1318
1893
|
const result = await fetchUsage(found.accountId);
|
|
1319
1894
|
if (!result.ok) throw new Error(result.error.message);
|
|
1320
1895
|
const displayName = found.label ? `${found.label} (${found.accountId})` : found.accountId;
|
|
1321
1896
|
process.stdout.write(`\n${displayName}\n${formatUsage(result.data)}\n\n`);
|
|
1322
|
-
|
|
1323
|
-
if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
|
|
1324
|
-
const results = await Promise.allSettled(config.accounts.map((a) => fetchUsage(a.accountId)));
|
|
1325
|
-
const entries = config.accounts.map((a, i) => {
|
|
1326
|
-
const settled = results[i];
|
|
1327
|
-
const displayName = a.label ? `${a.label} (${a.accountId})` : a.accountId;
|
|
1328
|
-
const result = settled.status === "fulfilled" ? settled.value : {
|
|
1329
|
-
ok: false,
|
|
1330
|
-
error: {
|
|
1331
|
-
type: "network_error",
|
|
1332
|
-
message: settled.reason?.message ?? "Fetch failed"
|
|
1333
|
-
}
|
|
1334
|
-
};
|
|
1335
|
-
return {
|
|
1336
|
-
displayName,
|
|
1337
|
-
isCurrent: i === config.current,
|
|
1338
|
-
result
|
|
1339
|
-
};
|
|
1340
|
-
});
|
|
1341
|
-
process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
|
|
1897
|
+
return;
|
|
1342
1898
|
}
|
|
1899
|
+
if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
|
|
1900
|
+
const results = await Promise.allSettled(config.accounts.map((entry) => fetchUsage(entry.accountId)));
|
|
1901
|
+
const entries = config.accounts.map((entry, index) => {
|
|
1902
|
+
const settled = results[index];
|
|
1903
|
+
const displayName = entry.label ? `${entry.label} (${entry.accountId})` : entry.accountId;
|
|
1904
|
+
const result = settled.status === "fulfilled" ? settled.value : {
|
|
1905
|
+
ok: false,
|
|
1906
|
+
error: {
|
|
1907
|
+
type: "network_error",
|
|
1908
|
+
message: settled.reason?.message ?? "Fetch failed"
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
return {
|
|
1912
|
+
displayName,
|
|
1913
|
+
isCurrent: index === config.current,
|
|
1914
|
+
result
|
|
1915
|
+
};
|
|
1916
|
+
});
|
|
1917
|
+
process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
|
|
1343
1918
|
} catch (error) {
|
|
1344
|
-
|
|
1345
|
-
process.stderr.write(`${message}\n`);
|
|
1346
|
-
process.exit(1);
|
|
1919
|
+
exitWithCommandError(error);
|
|
1347
1920
|
}
|
|
1348
1921
|
});
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
process.stderr.write(`Unknown command: ${commandName}\n`);
|
|
1355
|
-
program.outputHelp();
|
|
1356
|
-
process.exit(1);
|
|
1357
|
-
}
|
|
1358
|
-
} else program.outputHelp();
|
|
1359
|
-
});
|
|
1922
|
+
};
|
|
1923
|
+
|
|
1924
|
+
//#endregion
|
|
1925
|
+
//#region lib/commands/version.ts
|
|
1926
|
+
const registerVersionCommand = (program, version) => {
|
|
1360
1927
|
program.command("version").description("Show CLI version").action(() => {
|
|
1361
1928
|
process.stdout.write(`${version}\n`);
|
|
1362
1929
|
});
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1930
|
+
};
|
|
1931
|
+
|
|
1932
|
+
//#endregion
|
|
1933
|
+
//#region cdx.ts
|
|
1934
|
+
const interactiveMode = runInteractiveMode;
|
|
1935
|
+
const createProgram = (deps = {}) => {
|
|
1936
|
+
const program = new Command();
|
|
1937
|
+
program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
|
|
1938
|
+
registerLoginCommand(program, deps);
|
|
1939
|
+
registerReloginCommand(program);
|
|
1940
|
+
registerSwitchCommand(program);
|
|
1941
|
+
registerLabelCommand(program);
|
|
1942
|
+
registerStatusCommand(program);
|
|
1943
|
+
registerDoctorCommand(program);
|
|
1944
|
+
registerUsageCommand(program);
|
|
1945
|
+
registerHelpCommand(program);
|
|
1946
|
+
registerVersionCommand(program, version);
|
|
1947
|
+
registerDefaultInteractiveAction(program);
|
|
1372
1948
|
return program;
|
|
1373
1949
|
};
|
|
1374
1950
|
const main = async () => {
|
|
1375
1951
|
await createProgram().parseAsync(process.argv);
|
|
1376
1952
|
};
|
|
1377
1953
|
if (import.meta.main) main().catch((error) => {
|
|
1378
|
-
|
|
1379
|
-
process.stderr.write(`${message}\n`);
|
|
1380
|
-
process.exit(1);
|
|
1954
|
+
exitWithCommandError(error);
|
|
1381
1955
|
});
|
|
1382
1956
|
|
|
1383
1957
|
//#endregion
|
|
1384
|
-
export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
|
|
1958
|
+
export { createProgram, createRuntimeSecretStoreAdapter, createTestPaths, getPaths, getSecretStoreAdapter, interactiveMode, loadConfig, resetPaths, resetSecretStoreAdapter, runInteractiveMode, saveConfig, setPaths, setSecretStoreAdapter, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
|