@bjesuiter/codex-switcher 1.3.0 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +146 -44
  2. package/cdx.mjs +1094 -111
  3. package/package.json +3 -1
package/cdx.mjs CHANGED
@@ -1,43 +1,94 @@
1
1
  #!/usr/bin/env bun
2
- import { Command } from "commander";
3
- import * as p from "@clack/prompts";
2
+ import tab from "@bomb.sh/tab/commander";
3
+ import { Command, InvalidArgumentError } from "commander";
4
4
  import { existsSync } from "node:fs";
5
5
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
- import path from "node:path";
7
6
  import os from "node:os";
7
+ import path from "node:path";
8
+ import * as p from "@clack/prompts";
8
9
  import { spawn } from "node:child_process";
10
+ import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
11
+ import { createInterface } from "node:readline/promises";
9
12
  import { generatePKCE } from "@openauthjs/openauth/pkce";
10
13
  import { randomBytes } from "node:crypto";
11
14
  import http from "node:http";
12
15
 
13
16
  //#region package.json
14
- var version = "1.3.0";
17
+ var version = "1.5.0";
15
18
 
16
19
  //#endregion
17
- //#region lib/commands/errors.ts
18
- const exitWithCommandError = (error) => {
19
- const message = error instanceof Error ? error.message : String(error);
20
- process.stderr.write(`${message}\n`);
21
- process.exit(1);
20
+ //#region lib/platform/path-resolver.ts
21
+ const envValue = (env, key) => {
22
+ const value = env[key];
23
+ if (!value) return void 0;
24
+ const trimmed = value.trim();
25
+ return trimmed.length > 0 ? trimmed : void 0;
26
+ };
27
+ const resolvePiAuthPath = (env, homeDir, platform) => {
28
+ const piAgentDir = envValue(env, "PI_CODING_AGENT_DIR");
29
+ if (piAgentDir) return platform === "win32" ? path.win32.join(piAgentDir, "auth.json") : path.join(piAgentDir, "auth.json");
30
+ return platform === "win32" ? path.win32.join(homeDir, ".pi", "agent", "auth.json") : path.join(homeDir, ".pi", "agent", "auth.json");
31
+ };
32
+ const resolveXdgPaths = (env, homeDir, platform) => {
33
+ const configHome = envValue(env, "XDG_CONFIG_HOME") ?? path.join(homeDir, ".config");
34
+ const dataHome = envValue(env, "XDG_DATA_HOME") ?? path.join(homeDir, ".local", "share");
35
+ const configDir = path.join(configHome, "cdx");
36
+ return {
37
+ profile: "xdg",
38
+ configDir,
39
+ configPath: path.join(configDir, "accounts.json"),
40
+ authPath: path.join(dataHome, "opencode", "auth.json"),
41
+ codexAuthPath: path.join(homeDir, ".codex", "auth.json"),
42
+ piAuthPath: resolvePiAuthPath(env, homeDir, platform)
43
+ };
44
+ };
45
+ const resolveWindowsPaths = (env, homeDir) => {
46
+ const winPath = path.win32;
47
+ const appData = envValue(env, "APPDATA") ?? winPath.join(homeDir, "AppData", "Roaming");
48
+ const localAppData = envValue(env, "LOCALAPPDATA") ?? winPath.join(homeDir, "AppData", "Local");
49
+ const configDir = winPath.join(appData, "cdx");
50
+ return {
51
+ profile: "windows-appdata",
52
+ configDir,
53
+ configPath: winPath.join(configDir, "accounts.json"),
54
+ authPath: winPath.join(localAppData, "opencode", "auth.json"),
55
+ codexAuthPath: winPath.join(homeDir, ".codex", "auth.json"),
56
+ piAuthPath: resolvePiAuthPath(env, homeDir, "win32")
57
+ };
58
+ };
59
+ const resolveRuntimePaths = (input) => {
60
+ if (input.platform === "win32") return resolveWindowsPaths(input.env, input.homeDir);
61
+ return resolveXdgPaths(input.env, input.homeDir, input.platform);
22
62
  };
23
63
 
24
64
  //#endregion
25
65
  //#region lib/paths.ts
26
- const defaultConfigDir = path.join(os.homedir(), ".config", "cdx");
27
- const resolvePiAuthPath = () => {
28
- const piAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
29
- if (piAgentDir) return path.join(piAgentDir, "auth.json");
30
- return path.join(os.homedir(), ".pi", "agent", "auth.json");
31
- };
32
- const createDefaultPaths = () => ({
33
- configDir: defaultConfigDir,
34
- configPath: path.join(defaultConfigDir, "accounts.json"),
35
- authPath: path.join(os.homedir(), ".local", "share", "opencode", "auth.json"),
36
- codexAuthPath: path.join(os.homedir(), ".codex", "auth.json"),
37
- piAuthPath: resolvePiAuthPath()
66
+ const toPathConfig = (paths) => ({
67
+ configDir: paths.configDir,
68
+ configPath: paths.configPath,
69
+ authPath: paths.authPath,
70
+ codexAuthPath: paths.codexAuthPath,
71
+ piAuthPath: paths.piAuthPath
38
72
  });
39
- let currentPaths = createDefaultPaths();
73
+ const createDefaultPaths = () => {
74
+ const resolved = resolveRuntimePaths({
75
+ platform: process.platform,
76
+ env: process.env,
77
+ homeDir: os.homedir()
78
+ });
79
+ return {
80
+ paths: toPathConfig(resolved),
81
+ resolution: {
82
+ platform: process.platform,
83
+ profile: resolved.profile
84
+ }
85
+ };
86
+ };
87
+ const initial = createDefaultPaths();
88
+ let currentPaths = initial.paths;
89
+ let currentResolution = initial.resolution;
40
90
  const getPaths = () => currentPaths;
91
+ const getPathResolutionInfo = () => currentResolution;
41
92
  const setPaths = (paths) => {
42
93
  currentPaths = {
43
94
  ...currentPaths,
@@ -46,7 +97,9 @@ const setPaths = (paths) => {
46
97
  if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
47
98
  };
48
99
  const resetPaths = () => {
49
- currentPaths = createDefaultPaths();
100
+ const next = createDefaultPaths();
101
+ currentPaths = next.paths;
102
+ currentResolution = next.resolution;
50
103
  };
51
104
  const createTestPaths = (testDir) => ({
52
105
  configDir: path.join(testDir, "config"),
@@ -56,6 +109,48 @@ const createTestPaths = (testDir) => ({
56
109
  piAuthPath: path.join(testDir, "pi", "auth.json")
57
110
  });
58
111
 
112
+ //#endregion
113
+ //#region lib/config.ts
114
+ const isSecretStoreSelection = (value) => value === "auto" || value === "legacy-keychain";
115
+ const loadConfiguredSecretStoreSelection = async () => {
116
+ const { configPath } = getPaths();
117
+ if (!existsSync(configPath)) return;
118
+ try {
119
+ const raw = await readFile(configPath, "utf8");
120
+ const parsed = JSON.parse(raw);
121
+ return isSecretStoreSelection(parsed.secretStore) ? parsed.secretStore : void 0;
122
+ } catch {
123
+ return;
124
+ }
125
+ };
126
+ const loadConfig = async () => {
127
+ const { configPath } = getPaths();
128
+ if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
129
+ const raw = await readFile(configPath, "utf8");
130
+ const parsed = JSON.parse(raw);
131
+ if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
132
+ if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
133
+ if (!isSecretStoreSelection(parsed.secretStore)) delete parsed.secretStore;
134
+ return parsed;
135
+ };
136
+ const saveConfig = async (config) => {
137
+ const { configDir, configPath } = getPaths();
138
+ await mkdir(configDir, { recursive: true });
139
+ await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
140
+ };
141
+ const configExists = () => {
142
+ const { configPath } = getPaths();
143
+ return existsSync(configPath);
144
+ };
145
+
146
+ //#endregion
147
+ //#region lib/commands/errors.ts
148
+ const exitWithCommandError = (error) => {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ process.stderr.write(`${message}\n`);
151
+ process.exit(1);
152
+ };
153
+
59
154
  //#endregion
60
155
  //#region lib/auth.ts
61
156
  const readExistingJson = async (filePath) => {
@@ -137,31 +232,70 @@ const writeAllAuthFiles = async (payload) => {
137
232
  };
138
233
 
139
234
  //#endregion
140
- //#region lib/config.ts
141
- const loadConfig = async () => {
142
- const { configPath } = getPaths();
143
- if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
144
- const raw = await readFile(configPath, "utf8");
145
- const parsed = JSON.parse(raw);
146
- if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
147
- if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
148
- return parsed;
235
+ //#region lib/platform/browser.ts
236
+ const getBrowserLauncher = (platform = process.platform, url) => {
237
+ if (platform === "darwin") return {
238
+ command: "open",
239
+ args: [url],
240
+ label: "open"
241
+ };
242
+ if (platform === "win32") return {
243
+ command: "cmd",
244
+ args: [
245
+ "/c",
246
+ "start",
247
+ "",
248
+ url
249
+ ],
250
+ label: "cmd /c start"
251
+ };
252
+ return {
253
+ command: "xdg-open",
254
+ args: [url],
255
+ label: "xdg-open"
256
+ };
149
257
  };
150
- const saveConfig = async (config) => {
151
- const { configDir, configPath } = getPaths();
152
- await mkdir(configDir, { recursive: true });
153
- await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
258
+ const isCommandAvailable = (command, platform = process.platform) => {
259
+ const probe = platform === "win32" ? "where" : "which";
260
+ return Bun.spawnSync({
261
+ cmd: [probe, command],
262
+ stdout: "pipe",
263
+ stderr: "pipe"
264
+ }).exitCode === 0;
154
265
  };
155
- const configExists = () => {
156
- const { configPath } = getPaths();
157
- return existsSync(configPath);
266
+ const getBrowserLauncherCapability = (platform = process.platform) => {
267
+ const launcher = getBrowserLauncher(platform, "https://example.com");
268
+ return {
269
+ command: launcher.command,
270
+ label: launcher.label,
271
+ available: isCommandAvailable(launcher.command, platform)
272
+ };
273
+ };
274
+ const openBrowserUrl = (url, spawnImpl = spawn) => {
275
+ const launcher = getBrowserLauncher(process.platform, url);
276
+ try {
277
+ spawnImpl(launcher.command, launcher.args, {
278
+ detached: true,
279
+ stdio: "ignore"
280
+ }).unref();
281
+ return {
282
+ ok: true,
283
+ launcher
284
+ };
285
+ } catch (error) {
286
+ return {
287
+ ok: false,
288
+ launcher,
289
+ error: error instanceof Error ? error.message : String(error)
290
+ };
291
+ }
158
292
  };
159
293
 
160
294
  //#endregion
161
295
  //#region lib/keychain.ts
162
- const SERVICE_PREFIX = "cdx-openai-";
296
+ const SERVICE_PREFIX$3 = "cdx-openai-";
163
297
  const getKeychainService = (accountId) => {
164
- return `${SERVICE_PREFIX}${accountId}`;
298
+ return `${SERVICE_PREFIX$3}${accountId}`;
165
299
  };
166
300
  const runSecurity = (args) => {
167
301
  const result = Bun.spawnSync({
@@ -186,6 +320,23 @@ const runSecuritySafe = (args) => {
186
320
  output: result.exitCode === 0 ? result.stdout.toString() : result.stderr.toString()
187
321
  };
188
322
  };
323
+ const runSecuritySafeAsync = async (args) => {
324
+ const childProcess = Bun.spawn(["security", ...args], {
325
+ stderr: "pipe",
326
+ stdout: "pipe"
327
+ });
328
+ const stdoutPromise = childProcess.stdout ? new Response(childProcess.stdout).text() : Promise.resolve("");
329
+ const stderrPromise = childProcess.stderr ? new Response(childProcess.stderr).text() : Promise.resolve("");
330
+ const [exitCode, stdout, stderr] = await Promise.all([
331
+ childProcess.exited,
332
+ stdoutPromise,
333
+ stderrPromise
334
+ ]);
335
+ return {
336
+ success: exitCode === 0,
337
+ output: exitCode === 0 ? stdout : stderr
338
+ };
339
+ };
189
340
  const saveKeychainPayload = (accountId, payload) => {
190
341
  runSecurity([
191
342
  "add-generic-password",
@@ -233,12 +384,492 @@ const listKeychainAccounts = () => {
233
384
  if (result.exitCode !== 0) return [];
234
385
  const output = result.stdout.toString();
235
386
  const accounts = [];
236
- const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX}([^"]+)"`, "g");
387
+ const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$3}([^"]+)"`, "g");
237
388
  let match;
238
389
  while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
239
390
  return [...new Set(accounts)];
240
391
  };
241
392
 
393
+ //#endregion
394
+ //#region lib/secrets/cross-keychain-overrides.ts
395
+ const LEGACY_MAX_PASSWORD_LENGTH = 4096;
396
+ const DEFAULT_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH = 16384;
397
+ const parseMaxPasswordLength = (value) => {
398
+ if (!value) return null;
399
+ const parsed = Number.parseInt(value, 10);
400
+ if (!Number.isInteger(parsed) || parsed <= LEGACY_MAX_PASSWORD_LENGTH) return null;
401
+ return parsed;
402
+ };
403
+ const getCrossKeychainBackendOverrides = () => {
404
+ return { max_password_length: parseMaxPasswordLength(process.env.CDX_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH) ?? DEFAULT_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH };
405
+ };
406
+
407
+ //#endregion
408
+ //#region lib/secrets/fallback-consent.ts
409
+ const CONSENT_FILE = "secure-store-fallback-consent.json";
410
+ const CONSENT_ENV_BYPASS = "CDX_ALLOW_SECURE_STORE_FALLBACK";
411
+ const isBypassEnabled = () => {
412
+ const value = process.env[CONSENT_ENV_BYPASS];
413
+ if (!value) return false;
414
+ return [
415
+ "1",
416
+ "true",
417
+ "yes",
418
+ "y"
419
+ ].includes(value.trim().toLowerCase());
420
+ };
421
+ const consentFilePath = () => path.join(getPaths().configDir, CONSENT_FILE);
422
+ const loadConsentMap = async () => {
423
+ try {
424
+ const raw = await readFile(consentFilePath(), "utf8");
425
+ return JSON.parse(raw).accepted ?? {};
426
+ } catch {
427
+ return {};
428
+ }
429
+ };
430
+ const saveConsentMap = async (accepted) => {
431
+ const { configDir } = getPaths();
432
+ await mkdir(configDir, { recursive: true });
433
+ const payload = { accepted };
434
+ await writeFile(consentFilePath(), JSON.stringify(payload, null, 2), "utf8");
435
+ };
436
+ const promptConsent = async (message) => {
437
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
438
+ process.stdout.write(`\n${message}\n\n`);
439
+ const rl = createInterface({
440
+ input: process.stdin,
441
+ output: process.stdout
442
+ });
443
+ try {
444
+ const normalized = (await rl.question("Do you want to continue with this fallback? [y/N]: ")).trim().toLowerCase();
445
+ return normalized === "y" || normalized === "yes";
446
+ } finally {
447
+ rl.close();
448
+ }
449
+ };
450
+ const ensureFallbackConsent = async (scope, warningMessage) => {
451
+ if (isBypassEnabled()) return;
452
+ const accepted = await loadConsentMap();
453
+ if (accepted[scope]) return;
454
+ 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.`);
455
+ accepted[scope] = { acceptedAt: (/* @__PURE__ */ new Date()).toISOString() };
456
+ await saveConsentMap(accepted);
457
+ };
458
+
459
+ //#endregion
460
+ //#region lib/secrets/linux-cross-keychain.ts
461
+ const SERVICE_PREFIX$2 = "cdx-openai-";
462
+ const LINUX_FALLBACK_SCOPE = "linux:cross-keychain:secret-service";
463
+ let backendInitPromise$2 = null;
464
+ let selectedBackend$2 = null;
465
+ const tryUseBackend$2 = async (backendId) => {
466
+ try {
467
+ await useBackend(backendId, getCrossKeychainBackendOverrides());
468
+ return true;
469
+ } catch {
470
+ return false;
471
+ }
472
+ };
473
+ const selectBackend$2 = async () => {
474
+ const backends = await listBackends();
475
+ const available = new Set(backends.map((backend) => backend.id));
476
+ if (available.has("native-linux") && await tryUseBackend$2("native-linux")) return "native-linux";
477
+ if (available.has("secret-service") && await tryUseBackend$2("secret-service")) return "secret-service";
478
+ if (await tryUseBackend$2("native-linux")) return "native-linux";
479
+ if (await tryUseBackend$2("secret-service")) return "secret-service";
480
+ throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
481
+ };
482
+ const ensureLinuxBackend = async (options = {}) => {
483
+ if (!backendInitPromise$2) backendInitPromise$2 = (async () => {
484
+ selectedBackend$2 = await selectBackend$2();
485
+ })();
486
+ try {
487
+ await backendInitPromise$2;
488
+ } catch {
489
+ backendInitPromise$2 = null;
490
+ selectedBackend$2 = null;
491
+ throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
492
+ }
493
+ if (options.forWrite && selectedBackend$2 === "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.");
494
+ };
495
+ const getLinuxCrossKeychainService = (accountId) => `${SERVICE_PREFIX$2}${accountId}`;
496
+ const parsePayload$2 = (accountId, raw) => {
497
+ let parsed;
498
+ try {
499
+ parsed = JSON.parse(raw);
500
+ } catch {
501
+ throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
502
+ }
503
+ if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
504
+ return parsed;
505
+ };
506
+ const withService$2 = async (accountId, run, options = {}) => {
507
+ await ensureLinuxBackend(options);
508
+ return run(getLinuxCrossKeychainService(accountId));
509
+ };
510
+ const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$2(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
511
+ const loadLinuxCrossKeychainPayload = async (accountId) => {
512
+ const raw = await withService$2(accountId, (service) => getPassword(service, accountId));
513
+ if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
514
+ return parsePayload$2(accountId, raw);
515
+ };
516
+ const deleteLinuxCrossKeychainPayload = async (accountId) => withService$2(accountId, (service) => deletePassword(service, accountId));
517
+ const linuxCrossKeychainPayloadExists = async (accountId) => withService$2(accountId, async (service) => await getPassword(service, accountId) !== null);
518
+
519
+ //#endregion
520
+ //#region lib/secrets/macos-cross-keychain.ts
521
+ const SERVICE_PREFIX$1 = "cdx-openai-";
522
+ const MACOS_FALLBACK_SCOPE = "darwin:cross-keychain:macos";
523
+ let backendInitPromise$1 = null;
524
+ let selectedBackend$1 = null;
525
+ const tryUseBackend$1 = async (backendId) => {
526
+ try {
527
+ await useBackend(backendId, getCrossKeychainBackendOverrides());
528
+ return true;
529
+ } catch {
530
+ return false;
531
+ }
532
+ };
533
+ const selectBackend$1 = async () => {
534
+ const backends = await listBackends();
535
+ const available = new Set(backends.map((backend) => backend.id));
536
+ if (available.has("native-macos") && await tryUseBackend$1("native-macos")) return "native-macos";
537
+ if (available.has("macos") && await tryUseBackend$1("macos")) return "macos";
538
+ if (await tryUseBackend$1("native-macos")) return "native-macos";
539
+ if (await tryUseBackend$1("macos")) return "macos";
540
+ throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
541
+ };
542
+ const ensureMacOSBackend = async (options = {}) => {
543
+ if (!backendInitPromise$1) backendInitPromise$1 = (async () => {
544
+ selectedBackend$1 = await selectBackend$1();
545
+ })();
546
+ try {
547
+ await backendInitPromise$1;
548
+ } catch {
549
+ backendInitPromise$1 = null;
550
+ selectedBackend$1 = null;
551
+ throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
552
+ }
553
+ if (options.forWrite && selectedBackend$1 === "macos") await ensureFallbackConsent(MACOS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain macOS fallback backend 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 helper commands run.");
554
+ };
555
+ const resolveMacOSCrossKeychainBackendId$1 = async () => {
556
+ await ensureMacOSBackend();
557
+ if (!selectedBackend$1) throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
558
+ return selectedBackend$1;
559
+ };
560
+ const getMacOSCrossKeychainService = (accountId) => `${SERVICE_PREFIX$1}${accountId}`;
561
+ const parsePayload$1 = (accountId, raw) => {
562
+ let parsed;
563
+ try {
564
+ parsed = JSON.parse(raw);
565
+ } catch {
566
+ throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
567
+ }
568
+ if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
569
+ return parsed;
570
+ };
571
+ const withService$1 = async (accountId, run, options = {}) => {
572
+ await ensureMacOSBackend(options);
573
+ return run(getMacOSCrossKeychainService(accountId));
574
+ };
575
+ const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
576
+ const loadMacOSCrossKeychainPayload = async (accountId) => {
577
+ const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
578
+ if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
579
+ return parsePayload$1(accountId, raw);
580
+ };
581
+ const deleteMacOSCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
582
+ const macosCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
583
+
584
+ //#endregion
585
+ //#region lib/secrets/windows-cross-keychain.ts
586
+ const SERVICE_PREFIX = "cdx-openai-";
587
+ const WINDOWS_FALLBACK_SCOPE = "win32:cross-keychain:windows";
588
+ let backendInitPromise = null;
589
+ let selectedBackend = null;
590
+ const tryUseBackend = async (backendId) => {
591
+ try {
592
+ await useBackend(backendId, getCrossKeychainBackendOverrides());
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ };
598
+ const selectBackend = async () => {
599
+ const backends = await listBackends();
600
+ const available = new Set(backends.map((backend) => backend.id));
601
+ if (available.has("native-windows") && await tryUseBackend("native-windows")) return "native-windows";
602
+ if (available.has("windows") && await tryUseBackend("windows")) return "windows";
603
+ if (await tryUseBackend("native-windows")) return "native-windows";
604
+ if (await tryUseBackend("windows")) return "windows";
605
+ throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
606
+ };
607
+ const ensureWindowsBackend = async (options = {}) => {
608
+ if (!backendInitPromise) backendInitPromise = (async () => {
609
+ selectedBackend = await selectBackend();
610
+ })();
611
+ try {
612
+ await backendInitPromise;
613
+ } catch {
614
+ backendInitPromise = null;
615
+ selectedBackend = null;
616
+ throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
617
+ }
618
+ 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.");
619
+ };
620
+ const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
621
+ const parsePayload = (accountId, raw) => {
622
+ let parsed;
623
+ try {
624
+ parsed = JSON.parse(raw);
625
+ } catch {
626
+ throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
627
+ }
628
+ if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
629
+ return parsed;
630
+ };
631
+ const withService = async (accountId, run, options = {}) => {
632
+ await ensureWindowsBackend(options);
633
+ return run(getWindowsCrossKeychainService(accountId));
634
+ };
635
+ const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, async (service) => {
636
+ await setPassword(service, accountId, JSON.stringify(payload));
637
+ }, { forWrite: true });
638
+ const loadWindowsCrossKeychainPayload = async (accountId) => {
639
+ const raw = await withService(accountId, (service) => getPassword(service, accountId));
640
+ if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
641
+ return parsePayload(accountId, raw);
642
+ };
643
+ const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, async (service) => {
644
+ await deletePassword(service, accountId);
645
+ });
646
+ const windowsCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
647
+
648
+ //#endregion
649
+ //#region lib/secrets/store.ts
650
+ const MISSING_SECRET_STORE_ERROR_MARKERS = [
651
+ "No stored credentials found",
652
+ "No Keychain payload found",
653
+ "Password not found"
654
+ ];
655
+ const isMissingSecretStoreEntryError = (error) => {
656
+ if (!(error instanceof Error)) return false;
657
+ return MISSING_SECRET_STORE_ERROR_MARKERS.some((marker) => error.message.includes(marker));
658
+ };
659
+ const createMissingSecretStoreEntryError = (accountId) => /* @__PURE__ */ new Error(`No stored credentials found for account ${accountId}.`);
660
+ const CACHED_ADAPTER_SYMBOL = Symbol.for("cdx.secretStore.cachedAdapter");
661
+ const withSecretStoreCache = (adapter) => {
662
+ if (adapter[CACHED_ADAPTER_SYMBOL]) return adapter;
663
+ const payloadCache = /* @__PURE__ */ new Map();
664
+ const existsCache = /* @__PURE__ */ new Map();
665
+ const missingAccounts = /* @__PURE__ */ new Set();
666
+ const inFlightLoads = /* @__PURE__ */ new Map();
667
+ const markPresent = (accountId, payload) => {
668
+ payloadCache.set(accountId, payload);
669
+ existsCache.set(accountId, true);
670
+ missingAccounts.delete(accountId);
671
+ };
672
+ const markMissing = (accountId) => {
673
+ payloadCache.delete(accountId);
674
+ existsCache.set(accountId, false);
675
+ missingAccounts.add(accountId);
676
+ };
677
+ const loadAndCache = async (accountId) => {
678
+ const existingPromise = inFlightLoads.get(accountId);
679
+ if (existingPromise) return existingPromise;
680
+ const promise = (async () => {
681
+ try {
682
+ const payload = await adapter.load(accountId);
683
+ markPresent(accountId, payload);
684
+ return payload;
685
+ } catch (error) {
686
+ if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
687
+ throw error;
688
+ } finally {
689
+ inFlightLoads.delete(accountId);
690
+ }
691
+ })();
692
+ inFlightLoads.set(accountId, promise);
693
+ return promise;
694
+ };
695
+ return {
696
+ id: adapter.id,
697
+ label: adapter.label,
698
+ getServiceName: (accountId) => adapter.getServiceName(accountId),
699
+ save: async (accountId, payload) => {
700
+ await adapter.save(accountId, payload);
701
+ markPresent(accountId, payload);
702
+ },
703
+ load: async (accountId) => {
704
+ const cachedPayload = payloadCache.get(accountId);
705
+ if (cachedPayload) return cachedPayload;
706
+ if (missingAccounts.has(accountId)) throw createMissingSecretStoreEntryError(accountId);
707
+ return loadAndCache(accountId);
708
+ },
709
+ delete: async (accountId) => {
710
+ try {
711
+ await adapter.delete(accountId);
712
+ } catch (error) {
713
+ if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
714
+ throw error;
715
+ }
716
+ markMissing(accountId);
717
+ },
718
+ exists: async (accountId) => {
719
+ if (payloadCache.has(accountId)) return true;
720
+ if (missingAccounts.has(accountId)) return false;
721
+ const cachedExists = existsCache.get(accountId);
722
+ if (cachedExists !== void 0) return cachedExists;
723
+ try {
724
+ await loadAndCache(accountId);
725
+ return true;
726
+ } catch (error) {
727
+ if (isMissingSecretStoreEntryError(error)) return false;
728
+ }
729
+ const exists = await adapter.exists(accountId);
730
+ existsCache.set(accountId, exists);
731
+ if (!exists) missingAccounts.add(accountId);
732
+ return exists;
733
+ },
734
+ listAccountIds: async () => {
735
+ const accountIds = await adapter.listAccountIds();
736
+ for (const accountId of accountIds) {
737
+ existsCache.set(accountId, true);
738
+ missingAccounts.delete(accountId);
739
+ }
740
+ return accountIds;
741
+ },
742
+ getCapability: () => adapter.getCapability(),
743
+ [CACHED_ADAPTER_SYMBOL]: true
744
+ };
745
+ };
746
+ 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.`);
747
+ const loadConfiguredAccountIds = async () => {
748
+ if (!configExists()) return [];
749
+ return (await loadConfig()).accounts.map((account) => account.accountId);
750
+ };
751
+ const createMacOSCrossKeychainAdapter = () => ({
752
+ id: "macos-cross-keychain",
753
+ label: "macOS Keychain (cross-keychain)",
754
+ getServiceName: getMacOSCrossKeychainService,
755
+ save: saveMacOSCrossKeychainPayload,
756
+ load: loadMacOSCrossKeychainPayload,
757
+ delete: deleteMacOSCrossKeychainPayload,
758
+ exists: macosCrossKeychainPayloadExists,
759
+ listAccountIds: async () => {
760
+ const accountIds = await loadConfiguredAccountIds();
761
+ return (await Promise.all(accountIds.map(async (accountId) => ({
762
+ accountId,
763
+ exists: await macosCrossKeychainPayloadExists(accountId)
764
+ })))).filter((item) => item.exists).map((item) => item.accountId);
765
+ },
766
+ getCapability: () => ({ available: true })
767
+ });
768
+ const createMacOSLegacyKeychainAdapter = () => ({
769
+ id: "macos-legacy-keychain",
770
+ label: "macOS Keychain (legacy security CLI)",
771
+ getServiceName: getKeychainService,
772
+ save: async (accountId, payload) => {
773
+ saveKeychainPayload(accountId, payload);
774
+ },
775
+ load: async (accountId) => loadKeychainPayload(accountId),
776
+ delete: async (accountId) => {
777
+ deleteKeychainPayload(accountId);
778
+ },
779
+ exists: async (accountId) => keychainPayloadExists(accountId),
780
+ listAccountIds: async () => listKeychainAccounts(),
781
+ getCapability: () => ({ available: true })
782
+ });
783
+ const createWindowsCrossKeychainAdapter = () => ({
784
+ id: "windows-cross-keychain",
785
+ label: "Windows Credential Manager (cross-keychain)",
786
+ getServiceName: getWindowsCrossKeychainService,
787
+ save: saveWindowsCrossKeychainPayload,
788
+ load: loadWindowsCrossKeychainPayload,
789
+ delete: deleteWindowsCrossKeychainPayload,
790
+ exists: windowsCrossKeychainPayloadExists,
791
+ listAccountIds: async () => {
792
+ const accountIds = await loadConfiguredAccountIds();
793
+ return (await Promise.all(accountIds.map(async (accountId) => ({
794
+ accountId,
795
+ exists: await windowsCrossKeychainPayloadExists(accountId)
796
+ })))).filter((item) => item.exists).map((item) => item.accountId);
797
+ },
798
+ getCapability: () => ({ available: true })
799
+ });
800
+ const createLinuxCrossKeychainAdapter = () => ({
801
+ id: "linux-cross-keychain",
802
+ label: "Linux Secret Service (cross-keychain)",
803
+ getServiceName: getLinuxCrossKeychainService,
804
+ save: saveLinuxCrossKeychainPayload,
805
+ load: loadLinuxCrossKeychainPayload,
806
+ delete: deleteLinuxCrossKeychainPayload,
807
+ exists: linuxCrossKeychainPayloadExists,
808
+ listAccountIds: async () => {
809
+ const accountIds = await loadConfiguredAccountIds();
810
+ return (await Promise.all(accountIds.map(async (accountId) => ({
811
+ accountId,
812
+ exists: await linuxCrossKeychainPayloadExists(accountId)
813
+ })))).filter((item) => item.exists).map((item) => item.accountId);
814
+ },
815
+ getCapability: () => ({ available: true })
816
+ });
817
+ const createUnsupportedAdapter = (platform) => ({
818
+ id: "unsupported",
819
+ label: "Unsupported (no adapter configured)",
820
+ getServiceName: (accountId) => `cdx-openai-${accountId}`,
821
+ save: async () => {
822
+ throw unsupportedError(platform);
823
+ },
824
+ load: async () => {
825
+ throw unsupportedError(platform);
826
+ },
827
+ delete: async () => {
828
+ throw unsupportedError(platform);
829
+ },
830
+ exists: async () => false,
831
+ listAccountIds: async () => [],
832
+ getCapability: () => ({
833
+ available: false,
834
+ reason: "No default secure-store adapter available for this platform."
835
+ })
836
+ });
837
+ const createRuntimeSecretStoreAdapter = (platform = process.platform) => {
838
+ if (platform === "darwin") return createMacOSCrossKeychainAdapter();
839
+ if (platform === "win32") return createWindowsCrossKeychainAdapter();
840
+ if (platform === "linux") return createLinuxCrossKeychainAdapter();
841
+ return createUnsupportedAdapter(platform);
842
+ };
843
+ const createSecretStoreAdapterFromSelection = (selection = "auto", platform = process.platform) => {
844
+ if (selection === "legacy-keychain") {
845
+ if (platform !== "darwin") throw new Error("The legacy keychain adapter is only available on macOS (darwin).");
846
+ return createMacOSLegacyKeychainAdapter();
847
+ }
848
+ return createRuntimeSecretStoreAdapter(platform);
849
+ };
850
+ const resolveMacOSCrossKeychainBackendId = async (platform = process.platform) => {
851
+ if (platform !== "darwin") return null;
852
+ return resolveMacOSCrossKeychainBackendId$1();
853
+ };
854
+ let currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
855
+ const getSecretStoreAdapter = () => currentSecretStoreAdapter;
856
+ const setSecretStoreAdapter = (adapter) => {
857
+ currentSecretStoreAdapter = withSecretStoreCache(adapter);
858
+ };
859
+ const resetSecretStoreAdapter = () => {
860
+ currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
861
+ };
862
+ const getSecretStoreCapability = () => {
863
+ const adapter = getSecretStoreAdapter();
864
+ const capability = adapter.getCapability();
865
+ return {
866
+ id: adapter.id,
867
+ label: adapter.label,
868
+ available: capability.available,
869
+ ...capability.reason ? { reason: capability.reason } : {}
870
+ };
871
+ };
872
+
242
873
  //#endregion
243
874
  //#region lib/oauth/constants.ts
244
875
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -437,31 +1068,27 @@ const startOAuthServer = (state) => {
437
1068
  //#endregion
438
1069
  //#region lib/oauth/login.ts
439
1070
  const openBrowser = (url) => {
440
- const cmd = process.platform === "darwin" ? "open" : "xdg-open";
441
- try {
442
- spawn(cmd, [url], {
443
- detached: true,
444
- stdio: "ignore"
445
- }).unref();
446
- } catch (error) {
447
- const msg = error instanceof Error ? error.message : String(error);
448
- p.log.warning(`Could not auto-open browser (${msg}).`);
1071
+ const result = openBrowserUrl(url);
1072
+ if (!result.ok) {
1073
+ const msg = result.error ?? "unknown error";
1074
+ p.log.warning(`Could not auto-open browser via ${result.launcher.label} (${msg}).`);
449
1075
  }
450
1076
  };
451
1077
  const addAccountToConfig = async (accountId, label) => {
452
1078
  let config;
1079
+ const secretStore = getSecretStoreAdapter();
453
1080
  if (configExists()) {
454
1081
  config = await loadConfig();
455
1082
  if (!config.accounts.some((a) => a.accountId === accountId)) config.accounts.push({
456
1083
  accountId,
457
- keychainService: getKeychainService(accountId),
1084
+ keychainService: secretStore.getServiceName(accountId),
458
1085
  ...label ? { label } : {}
459
1086
  });
460
1087
  } else config = {
461
1088
  current: 0,
462
1089
  accounts: [{
463
1090
  accountId,
464
- keychainService: getKeychainService(accountId),
1091
+ keychainService: secretStore.getServiceName(accountId),
465
1092
  ...label ? { label } : {}
466
1093
  }]
467
1094
  };
@@ -521,16 +1148,17 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
521
1148
  }
522
1149
  if (spinner) spinner.message("Updating credentials...");
523
1150
  else p.log.message("Updating credentials...");
524
- saveKeychainPayload(newAccountId, {
1151
+ const payload = {
525
1152
  refresh: tokenResult.refresh,
526
1153
  access: tokenResult.access,
527
1154
  expires: tokenResult.expires,
528
1155
  accountId: newAccountId,
529
1156
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
530
- });
1157
+ };
1158
+ await getSecretStoreAdapter().save(newAccountId, payload);
531
1159
  if (spinner) spinner.stop("Credentials refreshed!");
532
1160
  else p.log.success("Credentials refreshed!");
533
- p.log.success(`Account "${displayName}" credentials updated in Keychain.`);
1161
+ p.log.success(`Account "${displayName}" credentials updated in secure store.`);
534
1162
  return { accountId: newAccountId };
535
1163
  } finally {
536
1164
  clearInterval(keepAlive);
@@ -568,13 +1196,14 @@ const performLogin = async () => {
568
1196
  return null;
569
1197
  }
570
1198
  spinner.message("Saving credentials...");
571
- saveKeychainPayload(accountId, {
1199
+ const payload = {
572
1200
  refresh: tokenResult.refresh,
573
1201
  access: tokenResult.access,
574
1202
  expires: tokenResult.expires,
575
1203
  accountId,
576
1204
  ...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
577
- });
1205
+ };
1206
+ await getSecretStoreAdapter().save(accountId, payload);
578
1207
  spinner.stop("Login successful!");
579
1208
  const labelInput = await p.text({
580
1209
  message: "Enter a label for this account (or press Enter to skip):",
@@ -583,7 +1212,7 @@ const performLogin = async () => {
583
1212
  const label = !p.isCancel(labelInput) && labelInput?.trim() ? labelInput.trim() : void 0;
584
1213
  await addAccountToConfig(accountId, label);
585
1214
  const displayName = label ?? accountId;
586
- p.log.success(`Account "${displayName}" saved to Keychain and config.`);
1215
+ p.log.success(`Account "${displayName}" saved to secure store and config.`);
587
1216
  p.outro("You can now use 'cdx switch' to activate this account.");
588
1217
  return { accountId };
589
1218
  };
@@ -595,7 +1224,19 @@ const writeActiveAuthFilesIfCurrent = async (accountId) => {
595
1224
  const config = await loadConfig();
596
1225
  const current = config.accounts[config.current];
597
1226
  if (!current || current.accountId !== accountId) return null;
598
- return writeAllAuthFiles(loadKeychainPayload(accountId));
1227
+ return writeAllAuthFiles(await getSecretStoreAdapter().load(accountId));
1228
+ };
1229
+
1230
+ //#endregion
1231
+ //#region lib/platform/capabilities.ts
1232
+ const getRuntimeCapabilities = () => {
1233
+ const pathResolution = getPathResolutionInfo();
1234
+ return {
1235
+ platform: process.platform,
1236
+ pathProfile: pathResolution.profile,
1237
+ secretStore: getSecretStoreCapability(),
1238
+ browserLauncher: getBrowserLauncherCapability(process.platform)
1239
+ };
599
1240
  };
600
1241
 
601
1242
  //#endregion
@@ -680,20 +1321,24 @@ const readPiAuthAccount = async () => {
680
1321
  };
681
1322
  }
682
1323
  };
683
- const getAccountStatus = (accountId, isCurrent, label) => {
684
- const keychainExists = keychainPayloadExists(accountId);
1324
+ const getAccountStatus = async (accountId, isCurrent, label) => {
1325
+ const secretStore = getSecretStoreAdapter();
1326
+ let secureStoreExists = false;
685
1327
  let expiresAt = null;
686
1328
  let hasIdToken = false;
687
- if (keychainExists) try {
688
- const payload = loadKeychainPayload(accountId);
1329
+ try {
1330
+ const payload = await secretStore.load(accountId);
1331
+ secureStoreExists = true;
689
1332
  expiresAt = payload.expires;
690
1333
  hasIdToken = !!payload.idToken;
691
- } catch {}
1334
+ } catch (error) {
1335
+ secureStoreExists = !isMissingSecretStoreEntryError(error);
1336
+ }
692
1337
  return {
693
1338
  accountId,
694
1339
  label,
695
1340
  isCurrent,
696
- keychainExists,
1341
+ secureStoreExists,
697
1342
  hasIdToken,
698
1343
  expiresAt,
699
1344
  expiresIn: formatExpiry(expiresAt)
@@ -705,7 +1350,7 @@ const getStatus = async () => {
705
1350
  const config = await loadConfig();
706
1351
  for (let i = 0; i < config.accounts.length; i++) {
707
1352
  const account = config.accounts[i];
708
- accounts.push(getAccountStatus(account.accountId, i === config.current, account.label));
1353
+ accounts.push(await getAccountStatus(account.accountId, i === config.current, account.label));
709
1354
  }
710
1355
  }
711
1356
  const [opencodeAuth, codexAuth, piAuth] = await Promise.all([
@@ -717,7 +1362,8 @@ const getStatus = async () => {
717
1362
  accounts,
718
1363
  opencodeAuth,
719
1364
  codexAuth,
720
- piAuth
1365
+ piAuth,
1366
+ capabilities: getRuntimeCapabilities()
721
1367
  };
722
1368
  };
723
1369
 
@@ -727,10 +1373,16 @@ const getAccountDisplay = (accountId, isCurrent, label) => {
727
1373
  const name = label ? `${label} (${accountId})` : accountId;
728
1374
  return isCurrent ? `${name} (current)` : name;
729
1375
  };
730
- const getRefreshExpiryState = (accountId) => {
731
- if (!keychainPayloadExists(accountId)) return "unknown [no keychain]";
1376
+ const hasStoredCredentials = async (accountId) => getSecretStoreAdapter().exists(accountId);
1377
+ const loadStoredCredentials = async (accountId) => getSecretStoreAdapter().load(accountId);
1378
+ const getStoredAccountIds = async () => getSecretStoreAdapter().listAccountIds();
1379
+ const removeStoredCredentials = async (accountId) => {
1380
+ await getSecretStoreAdapter().delete(accountId);
1381
+ };
1382
+ const getRefreshExpiryState = async (accountId) => {
1383
+ if (!await hasStoredCredentials(accountId)) return "unknown [no secure store entry]";
732
1384
  try {
733
- return formatExpiry(loadKeychainPayload(accountId).expires);
1385
+ return formatExpiry((await loadStoredCredentials(accountId)).expires);
734
1386
  } catch {
735
1387
  return "unknown";
736
1388
  }
@@ -746,7 +1398,7 @@ const handleListAccounts = async () => {
746
1398
  for (const account of config.accounts) {
747
1399
  const marker = account.accountId === currentAccountId ? "→ " : " ";
748
1400
  const displayName = account.label ? `${account.label} (${account.accountId})` : account.accountId;
749
- const status = keychainPayloadExists(account.accountId) ? "" : " (missing credentials)";
1401
+ const status = await hasStoredCredentials(account.accountId) ? "" : " (missing credentials)";
750
1402
  p.log.message(`${marker}${displayName}${status}`);
751
1403
  }
752
1404
  };
@@ -784,7 +1436,7 @@ const handleSwitchAccount = async () => {
784
1436
  }
785
1437
  let payload;
786
1438
  try {
787
- payload = loadKeychainPayload(selectedAccount.accountId);
1439
+ payload = await loadStoredCredentials(selectedAccount.accountId);
788
1440
  } catch {
789
1441
  p.log.error(`Missing credentials for account ${selectedAccount.label ?? selectedAccount.accountId}. Re-login with 'cdx login'.`);
790
1442
  return;
@@ -815,10 +1467,10 @@ const handleReloginAccount = async () => {
815
1467
  return;
816
1468
  }
817
1469
  const currentAccountId = config.accounts[config.current]?.accountId;
818
- const options = config.accounts.map((account) => ({
1470
+ const options = await Promise.all(config.accounts.map(async (account) => ({
819
1471
  value: account.accountId,
820
- label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${getRefreshExpiryState(account.accountId)}`
821
- }));
1472
+ label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${await getRefreshExpiryState(account.accountId)}`
1473
+ })));
822
1474
  const selected = await p.select({
823
1475
  message: "Select account to re-login:",
824
1476
  options
@@ -829,7 +1481,7 @@ const handleReloginAccount = async () => {
829
1481
  }
830
1482
  const accountId = selected;
831
1483
  const account = config.accounts.find((a) => a.accountId === accountId);
832
- const expiryState = getRefreshExpiryState(accountId);
1484
+ const expiryState = await getRefreshExpiryState(accountId);
833
1485
  const displayName = account?.label ?? accountId;
834
1486
  p.log.info(`Current token status for ${displayName}: ${expiryState}`);
835
1487
  try {
@@ -884,7 +1536,7 @@ const handleRemoveAccount = async () => {
884
1536
  return;
885
1537
  }
886
1538
  try {
887
- deleteKeychainPayload(accountId);
1539
+ await removeStoredCredentials(accountId);
888
1540
  } catch {}
889
1541
  const previousAccountId = config.accounts[config.current]?.accountId;
890
1542
  config.accounts = config.accounts.filter((a) => a.accountId !== accountId);
@@ -948,9 +1600,9 @@ const handleStatus = async () => {
948
1600
  for (const account of status.accounts) {
949
1601
  const marker = account.isCurrent ? "→ " : " ";
950
1602
  const name = account.label ? `${account.label} (${account.accountId})` : account.accountId;
951
- const keychain = account.keychainExists ? "" : " [no keychain]";
1603
+ const secureStore = account.secureStoreExists ? "" : " [no secure store entry]";
952
1604
  const idToken = account.hasIdToken ? "" : " [no id_token]";
953
- p.log.message(`${marker}${name} — ${account.expiresIn}${keychain}${idToken}`);
1605
+ p.log.message(`${marker}${name} — ${account.expiresIn}${secureStore}${idToken}`);
954
1606
  }
955
1607
  const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
956
1608
  const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
@@ -964,7 +1616,7 @@ const runInteractiveMode = async () => {
964
1616
  p.intro("cdx - OpenAI Account Switcher");
965
1617
  let running = true;
966
1618
  while (running) {
967
- const keychainAccounts = listKeychainAccounts();
1619
+ const storedAccounts = await getStoredAccountIds();
968
1620
  let currentInfo = "";
969
1621
  if (configExists()) try {
970
1622
  const config = await loadConfig();
@@ -976,7 +1628,7 @@ const runInteractiveMode = async () => {
976
1628
  options: [
977
1629
  {
978
1630
  value: "list",
979
- label: `List accounts (${keychainAccounts.length} in Keychain)`
1631
+ label: `List accounts (${storedAccounts.length} in secure store)`
980
1632
  },
981
1633
  {
982
1634
  value: "switch",
@@ -1055,6 +1707,164 @@ const registerDefaultInteractiveAction = (program) => {
1055
1707
  });
1056
1708
  };
1057
1709
 
1710
+ //#endregion
1711
+ //#region lib/keychain-acl.ts
1712
+ const getDefaultMap = (services) => {
1713
+ const map = /* @__PURE__ */ new Map();
1714
+ for (const service of services) map.set(service, {
1715
+ service,
1716
+ mode: "missing",
1717
+ applications: []
1718
+ });
1719
+ return map;
1720
+ };
1721
+ const parseItemEntries = (block) => {
1722
+ const entries = [];
1723
+ const entryRegex = /entry\s+\d+:\n([\s\S]*?)(?=\n\s*entry\s+\d+:|$)/g;
1724
+ let match;
1725
+ while ((match = entryRegex.exec(block)) !== null) if (match[1]) entries.push(match[1]);
1726
+ return entries;
1727
+ };
1728
+ const parseApplicationsFromEntry = (entry) => {
1729
+ const applications = [];
1730
+ const appRegex = /^\s*\d+:\s+(.+?)(?:\s+\([^\n]*\))?\s*$/gm;
1731
+ let match;
1732
+ while ((match = appRegex.exec(entry)) !== null) {
1733
+ const app = match[1]?.trim();
1734
+ if (app) applications.push(app);
1735
+ }
1736
+ return applications;
1737
+ };
1738
+ const parseKeychainDecryptAccessFromDump = (dumpOutput, services) => {
1739
+ const dedupedServices = [...new Set(services.filter((service) => service.length > 0))];
1740
+ const result = getDefaultMap(dedupedServices);
1741
+ if (dedupedServices.length === 0 || !dumpOutput.trim()) return result;
1742
+ const targetServices = new Set(dedupedServices);
1743
+ const blocks = dumpOutput.split(/\n(?=keychain:\s+")/g);
1744
+ for (const block of blocks) {
1745
+ if (!block.startsWith("keychain:")) continue;
1746
+ const service = block.match(/"svce"<blob>="([^"]+)"/)?.[1];
1747
+ if (!service || !targetServices.has(service)) continue;
1748
+ const entries = parseItemEntries(block);
1749
+ let mode = "missing";
1750
+ const applications = [];
1751
+ for (const entry of entries) {
1752
+ const authorizationsLine = entry.match(/authorizations\s*\(\d+\):\s*([^\n]+)/)?.[1] ?? "";
1753
+ if (!/\bdecrypt\b/.test(authorizationsLine)) continue;
1754
+ if (/applications:\s*<null>/.test(entry)) {
1755
+ mode = "all-apps";
1756
+ applications.length = 0;
1757
+ break;
1758
+ }
1759
+ const entryApplications = parseApplicationsFromEntry(entry);
1760
+ if (entryApplications.length > 0) {
1761
+ mode = "explicit-list";
1762
+ for (const app of entryApplications) if (!applications.includes(app)) applications.push(app);
1763
+ }
1764
+ }
1765
+ result.set(service, {
1766
+ service,
1767
+ mode,
1768
+ applications
1769
+ });
1770
+ }
1771
+ return result;
1772
+ };
1773
+ const getKeychainDecryptAccessByServiceAsync = async (services) => {
1774
+ const dedupedServices = [...new Set(services.filter((service) => service.length > 0))];
1775
+ const defaultResult = getDefaultMap(dedupedServices);
1776
+ if (process.platform !== "darwin" || dedupedServices.length === 0) return defaultResult;
1777
+ const dumpResult = await runSecuritySafeAsync(["dump-keychain", "-a"]);
1778
+ if (!dumpResult.success) return defaultResult;
1779
+ return parseKeychainDecryptAccessFromDump(dumpResult.output, dedupedServices);
1780
+ };
1781
+
1782
+ //#endregion
1783
+ //#region lib/commands/doctor.ts
1784
+ const hasRuntimeTrustedApp = (trustedApplications, runtimeExecutablePath) => {
1785
+ const runtimeBaseName = path.basename(runtimeExecutablePath).toLowerCase();
1786
+ return trustedApplications.some((trustedApp) => {
1787
+ if (trustedApp === runtimeExecutablePath) return true;
1788
+ return path.basename(trustedApp).toLowerCase() === runtimeBaseName;
1789
+ });
1790
+ };
1791
+ const registerDoctorCommand = (program) => {
1792
+ program.command("doctor").description("Show auth file paths and runtime capabilities").option("--check-keychain-acl", "Run keychain trusted-app/ACL checks on macOS (can be slow)").action(async (options) => {
1793
+ try {
1794
+ const status = await getStatus();
1795
+ const paths = getPaths();
1796
+ const resolveLabel = (accountId) => {
1797
+ if (!accountId) return "unknown";
1798
+ return status.accounts.find((account) => account.accountId === accountId)?.label ?? accountId;
1799
+ };
1800
+ process.stdout.write("\nAuth files:\n");
1801
+ const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
1802
+ process.stdout.write(` OpenCode: ${ocStatus}\n`);
1803
+ process.stdout.write(` Path: ${paths.authPath}\n`);
1804
+ const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
1805
+ process.stdout.write(` Codex CLI: ${cxStatus}\n`);
1806
+ process.stdout.write(` Path: ${paths.codexAuthPath}\n`);
1807
+ const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
1808
+ process.stdout.write(` Pi Agent: ${piStatus}\n`);
1809
+ process.stdout.write(` Path: ${paths.piAuthPath}\n`);
1810
+ process.stdout.write("\nCapabilities:\n");
1811
+ process.stdout.write(` Platform: ${status.capabilities.platform}\n`);
1812
+ process.stdout.write(` Path profile: ${status.capabilities.pathProfile}\n`);
1813
+ const secretStoreState = status.capabilities.secretStore.available ? "available" : `unavailable${status.capabilities.secretStore.reason ? ` (${status.capabilities.secretStore.reason})` : ""}`;
1814
+ process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
1815
+ const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
1816
+ process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
1817
+ if (process.platform === "darwin" && !options.checkKeychainAcl) {
1818
+ process.stdout.write(" ┌─ Optional keychain ACL check\n");
1819
+ process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
1820
+ process.stdout.write(" │ Verifies whether your current runtime is trusted by Keychain.\n");
1821
+ process.stdout.write(" └─ Expected duration: ~30-60 seconds\n");
1822
+ }
1823
+ if (process.platform === "darwin" && options.checkKeychainAcl) {
1824
+ const secretStore = getSecretStoreAdapter();
1825
+ const accountsWithSecrets = status.accounts.filter((account) => account.secureStoreExists);
1826
+ if (accountsWithSecrets.length > 0) {
1827
+ const runtimeExecutablePath = process.execPath;
1828
+ const services = accountsWithSecrets.map((account) => secretStore.getServiceName(account.accountId));
1829
+ process.stdout.write("\nKeychain ACL checks:\n");
1830
+ process.stdout.write(` Runtime executable: ${runtimeExecutablePath}\n`);
1831
+ const aclSpinner = p.spinner();
1832
+ const accountWord = accountsWithSecrets.length === 1 ? "account" : "accounts";
1833
+ aclSpinner.start(`Checking keychain ACLs for ${accountsWithSecrets.length} ${accountWord}...`);
1834
+ const decryptAccessByService = await getKeychainDecryptAccessByServiceAsync(services);
1835
+ aclSpinner.stop("Keychain ACL checks complete.");
1836
+ for (const account of accountsWithSecrets) {
1837
+ const service = secretStore.getServiceName(account.accountId);
1838
+ const decryptAccess = decryptAccessByService.get(service);
1839
+ const accountLabel = resolveLabel(account.accountId);
1840
+ if (!decryptAccess || decryptAccess.mode === "missing") {
1841
+ process.stdout.write(` ${accountLabel}: unable to read decrypt trusted apps (service: ${service})\n`);
1842
+ continue;
1843
+ }
1844
+ if (decryptAccess.mode === "all-apps") {
1845
+ process.stdout.write(` ${accountLabel}: decrypt access allows all apps (<null>)\n`);
1846
+ continue;
1847
+ }
1848
+ const runtimeTrusted = hasRuntimeTrustedApp(decryptAccess.applications, runtimeExecutablePath);
1849
+ const trustedAppsList = decryptAccess.applications.join(", ");
1850
+ if (runtimeTrusted) {
1851
+ process.stdout.write(` ${accountLabel}: runtime is in trusted apps\n`);
1852
+ continue;
1853
+ }
1854
+ process.stdout.write(` ⚠ ${accountLabel}: runtime not found in trusted apps\n`);
1855
+ process.stdout.write(` Service: ${service}\n`);
1856
+ process.stdout.write(` Trusted apps: ${trustedAppsList || "(none)"}\n`);
1857
+ process.stdout.write(" This secret may have been created with a different runtime/toolchain (for example node vs bun).\n");
1858
+ }
1859
+ }
1860
+ }
1861
+ process.stdout.write("\n");
1862
+ } catch (error) {
1863
+ exitWithCommandError(error);
1864
+ }
1865
+ });
1866
+ };
1867
+
1058
1868
  //#endregion
1059
1869
  //#region lib/commands/help.ts
1060
1870
  const registerHelpCommand = (program) => {
@@ -1110,6 +1920,119 @@ const registerLoginCommand = (program, deps = {}) => {
1110
1920
  });
1111
1921
  };
1112
1922
 
1923
+ //#endregion
1924
+ //#region lib/secrets/migrate.ts
1925
+ const asErrorMessage = (error) => error instanceof Error ? error.message : String(error);
1926
+ const migrateLegacyMacOSSecrets = async (options = {}) => {
1927
+ if ((options.platform ?? process.platform) !== "darwin") throw new Error("'migrate-secrets' is only available on macOS (darwin).");
1928
+ const loadConfigFn = options.loadConfigFn ?? loadConfig;
1929
+ const saveConfigFn = options.saveConfigFn ?? saveConfig;
1930
+ const sourceAdapter = options.sourceAdapter ?? createMacOSLegacyKeychainAdapter();
1931
+ const targetAdapter = options.targetAdapter ?? createRuntimeSecretStoreAdapter("darwin");
1932
+ const config = await loadConfigFn();
1933
+ const accountResults = [];
1934
+ for (const account of config.accounts) {
1935
+ const accountId = account.accountId;
1936
+ try {
1937
+ if (!await sourceAdapter.exists(accountId)) {
1938
+ accountResults.push({
1939
+ accountId,
1940
+ label: account.label,
1941
+ status: "skipped",
1942
+ message: "No legacy keychain entry found."
1943
+ });
1944
+ continue;
1945
+ }
1946
+ const loaded = await sourceAdapter.load(accountId);
1947
+ const payload = loaded.accountId === accountId ? loaded : {
1948
+ ...loaded,
1949
+ accountId
1950
+ };
1951
+ await sourceAdapter.delete(accountId);
1952
+ try {
1953
+ await targetAdapter.save(accountId, payload);
1954
+ } catch (error) {
1955
+ try {
1956
+ await sourceAdapter.save(accountId, payload);
1957
+ } catch {}
1958
+ throw error;
1959
+ }
1960
+ accountResults.push({
1961
+ accountId,
1962
+ label: account.label,
1963
+ status: "migrated",
1964
+ message: "Legacy entry migrated to cross-keychain backend."
1965
+ });
1966
+ } catch (error) {
1967
+ accountResults.push({
1968
+ accountId,
1969
+ label: account.label,
1970
+ status: "failed",
1971
+ message: asErrorMessage(error)
1972
+ });
1973
+ }
1974
+ }
1975
+ const failed = accountResults.filter((entry) => entry.status === "failed").length;
1976
+ const skipped = accountResults.filter((entry) => entry.status === "skipped").length;
1977
+ const migrated = accountResults.filter((entry) => entry.status === "migrated").length;
1978
+ let configUpdated = false;
1979
+ if (failed === 0) {
1980
+ let changed = false;
1981
+ for (const account of config.accounts) {
1982
+ const expectedService = targetAdapter.getServiceName(account.accountId);
1983
+ if (account.keychainService !== expectedService) {
1984
+ account.keychainService = expectedService;
1985
+ changed = true;
1986
+ }
1987
+ }
1988
+ if (config.secretStore !== "auto") {
1989
+ config.secretStore = "auto";
1990
+ changed = true;
1991
+ }
1992
+ if (changed) {
1993
+ await saveConfigFn(config);
1994
+ configUpdated = true;
1995
+ }
1996
+ }
1997
+ return {
1998
+ migrated,
1999
+ skipped,
2000
+ failed,
2001
+ configUpdated,
2002
+ accountResults
2003
+ };
2004
+ };
2005
+
2006
+ //#endregion
2007
+ //#region lib/commands/migrate-secrets.ts
2008
+ const statusPrefix = (result) => {
2009
+ if (result.status === "migrated") return "✓";
2010
+ if (result.status === "skipped") return "-";
2011
+ return "✗";
2012
+ };
2013
+ const formatName = (result) => result.label ? `${result.label} (${result.accountId})` : result.accountId;
2014
+ const registerMigrateSecretsCommand = (program) => {
2015
+ program.command("migrate-secrets").description("Migrate macOS legacy keychain entries to cross-keychain and update config").action(async () => {
2016
+ try {
2017
+ const result = await migrateLegacyMacOSSecrets();
2018
+ process.stdout.write("\nSecret migration results:\n");
2019
+ for (const accountResult of result.accountResults) process.stdout.write(` ${statusPrefix(accountResult)} ${formatName(accountResult)}: ${accountResult.message}\n`);
2020
+ process.stdout.write("\nSummary:\n");
2021
+ process.stdout.write(` Migrated: ${result.migrated}\n`);
2022
+ process.stdout.write(` Skipped: ${result.skipped}\n`);
2023
+ process.stdout.write(` Failed: ${result.failed}\n`);
2024
+ if (result.failed === 0) {
2025
+ process.stdout.write(result.configUpdated ? " Config: updated (secretStore=auto, service names normalized)\n\n" : " Config: already up to date\n\n");
2026
+ return;
2027
+ }
2028
+ process.stdout.write(" Config: not updated because at least one account failed\n\n");
2029
+ throw new Error(`Migration finished with ${result.failed} failed account(s). Resolve them and run 'cdx migrate-secrets' again.`);
2030
+ } catch (error) {
2031
+ exitWithCommandError(error);
2032
+ }
2033
+ });
2034
+ };
2035
+
1113
2036
  //#endregion
1114
2037
  //#region lib/commands/output.ts
1115
2038
  const formatCodexMark = (result) => {
@@ -1144,14 +2067,15 @@ const registerReloginCommand = (program) => {
1144
2067
  if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
1145
2068
  const displayName = target.label ?? target.accountId;
1146
2069
  let expiryState = "unknown";
1147
- let keychainState = "";
1148
- if (keychainPayloadExists(target.accountId)) try {
1149
- expiryState = formatExpiry(loadKeychainPayload(target.accountId).expires);
2070
+ let secureStoreState = "";
2071
+ const secretStore = getSecretStoreAdapter();
2072
+ if (await secretStore.exists(target.accountId)) try {
2073
+ expiryState = formatExpiry((await secretStore.load(target.accountId)).expires);
1150
2074
  } catch {
1151
2075
  expiryState = "unknown";
1152
2076
  }
1153
- else keychainState = " [no keychain]";
1154
- process.stdout.write(`Current token status for ${displayName}: ${expiryState}${keychainState}\n`);
2077
+ else secureStoreState = " [no secure store entry]";
2078
+ process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
1155
2079
  const result = await performRefresh(target.accountId, target.label);
1156
2080
  if (!result) {
1157
2081
  process.stderr.write("Re-login failed.\n");
@@ -1188,9 +2112,10 @@ const fetchUsageRaw = async (accessToken, accountId) => {
1188
2112
  * Fetches usage for an account. On 401, refreshes the token and retries once.
1189
2113
  */
1190
2114
  const fetchUsage = async (accountId) => {
2115
+ const secretStore = getSecretStoreAdapter();
1191
2116
  let payload;
1192
2117
  try {
1193
- payload = loadKeychainPayload(accountId);
2118
+ payload = await secretStore.load(accountId);
1194
2119
  } catch (err) {
1195
2120
  return {
1196
2121
  ok: false,
@@ -1218,7 +2143,7 @@ const fetchUsage = async (accountId) => {
1218
2143
  expires: refreshResult.expires,
1219
2144
  idToken: refreshResult.idToken ?? payload.idToken
1220
2145
  };
1221
- saveKeychainPayload(accountId, updatedPayload);
2146
+ await secretStore.save(accountId, updatedPayload);
1222
2147
  response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
1223
2148
  if (!response.ok) return {
1224
2149
  ok: false,
@@ -1327,7 +2252,7 @@ const formatUsageOverview = (entries) => {
1327
2252
  //#endregion
1328
2253
  //#region lib/commands/status.ts
1329
2254
  const registerStatusCommand = (program) => {
1330
- program.command("status").description("Show account status, token expiry, usage, and auth file state").action(async () => {
2255
+ program.command("status").description("Show account status, token expiry, and usage").action(async () => {
1331
2256
  try {
1332
2257
  const status = await getStatus();
1333
2258
  if (status.accounts.length === 0) {
@@ -1339,31 +2264,43 @@ const registerStatusCommand = (program) => {
1339
2264
  const account = status.accounts[i];
1340
2265
  const marker = account.isCurrent ? "→ " : " ";
1341
2266
  const warnings = [];
1342
- if (!account.keychainExists) warnings.push("[no keychain]");
2267
+ if (!account.secureStoreExists) warnings.push("[no secure store entry]");
1343
2268
  if (!account.hasIdToken) warnings.push("[no id_token]");
1344
2269
  const warnStr = warnings.length > 0 ? ` ${warnings.join(" ")}` : "";
1345
2270
  const displayName = account.label ?? account.accountId;
1346
2271
  process.stdout.write(`${marker}${displayName}${warnStr}\n`);
1347
2272
  if (account.label) process.stdout.write(` ${account.accountId}\n`);
1348
2273
  process.stdout.write(` ${account.expiresIn}\n`);
1349
- const usageResult = await fetchUsage(account.accountId);
1350
- if (usageResult.ok) {
1351
- const bars = formatUsageBars(usageResult.data);
2274
+ if (i < status.accounts.length - 1) process.stdout.write("\n");
2275
+ }
2276
+ const usageSpinner = p.spinner();
2277
+ const accountWord = status.accounts.length === 1 ? "account" : "accounts";
2278
+ usageSpinner.start(`Fetching usage for ${status.accounts.length} ${accountWord}...`);
2279
+ const usageResults = await Promise.allSettled(status.accounts.map((account) => fetchUsage(account.accountId)));
2280
+ const failedUsageCount = usageResults.filter((result) => result.status === "rejected" || result.status === "fulfilled" && !result.value.ok).length;
2281
+ if (failedUsageCount === 0) usageSpinner.stop("Usage loaded.");
2282
+ else {
2283
+ const failedWord = failedUsageCount === 1 ? "account" : "accounts";
2284
+ usageSpinner.stop(`Usage loaded (${failedUsageCount} ${failedWord} failed).`);
2285
+ }
2286
+ process.stdout.write("\nUsage:\n");
2287
+ for (let i = 0; i < status.accounts.length; i++) {
2288
+ const account = status.accounts[i];
2289
+ const marker = account.isCurrent ? "→ " : " ";
2290
+ const displayName = account.label ?? account.accountId;
2291
+ process.stdout.write(`${marker}${displayName}\n`);
2292
+ if (account.label) process.stdout.write(` ${account.accountId}\n`);
2293
+ const usageResult = usageResults[i];
2294
+ if (usageResult.status === "rejected") {
2295
+ const message = usageResult.reason instanceof Error ? usageResult.reason.message : "Fetch failed";
2296
+ process.stdout.write(` [usage unavailable] ${message}\n`);
2297
+ } else if (usageResult.value.ok) {
2298
+ const bars = formatUsageBars(usageResult.value.data);
2299
+ if (bars.length === 0) process.stdout.write(" Usage data unavailable\n");
1352
2300
  for (const bar of bars) process.stdout.write(`${bar}\n`);
1353
- }
2301
+ } else process.stdout.write(` [usage unavailable] ${usageResult.value.error.message}\n`);
1354
2302
  if (i < status.accounts.length - 1) process.stdout.write("\n");
1355
2303
  }
1356
- const resolveLabel = (accountId) => {
1357
- if (!accountId) return "unknown";
1358
- return status.accounts.find((account) => account.accountId === accountId)?.label ?? accountId;
1359
- };
1360
- process.stdout.write("\nAuth files:\n");
1361
- const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
1362
- process.stdout.write(` OpenCode: ${ocStatus}\n`);
1363
- const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
1364
- process.stdout.write(` Codex CLI: ${cxStatus}\n`);
1365
- const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
1366
- process.stdout.write(` Pi Agent: ${piStatus}\n`);
1367
2304
  process.stdout.write("\n");
1368
2305
  } catch (error) {
1369
2306
  exitWithCommandError(error);
@@ -1378,7 +2315,7 @@ const switchNext = async () => {
1378
2315
  const nextIndex = (config.current + 1) % config.accounts.length;
1379
2316
  const nextAccount = config.accounts[nextIndex];
1380
2317
  if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
1381
- const payload = loadKeychainPayload(nextAccount.accountId);
2318
+ const payload = await getSecretStoreAdapter().load(nextAccount.accountId);
1382
2319
  const result = await writeAllAuthFiles(payload);
1383
2320
  config.current = nextIndex;
1384
2321
  await saveConfig(config);
@@ -1389,7 +2326,7 @@ const switchToAccount = async (identifier) => {
1389
2326
  const index = config.accounts.findIndex((account) => account.accountId === identifier || account.label === identifier);
1390
2327
  if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
1391
2328
  const account = config.accounts[index];
1392
- const result = await writeAllAuthFiles(loadKeychainPayload(account.accountId));
2329
+ const result = await writeAllAuthFiles(await getSecretStoreAdapter().load(account.accountId));
1393
2330
  config.current = index;
1394
2331
  await saveConfig(config);
1395
2332
  writeSwitchSummary(account.label ?? account.accountId, result);
@@ -1457,14 +2394,53 @@ const registerVersionCommand = (program, version) => {
1457
2394
  //#endregion
1458
2395
  //#region cdx.ts
1459
2396
  const interactiveMode = runInteractiveMode;
2397
+ const parseSecretStoreSelection = (value) => {
2398
+ if (value === "auto" || value === "legacy-keychain") return value;
2399
+ throw new InvalidArgumentError(`Invalid value '${value}' for --secret-store. Allowed values: auto, legacy-keychain.`);
2400
+ };
2401
+ const getCompletionParseArgs = (argv) => {
2402
+ const completeIndex = argv.findIndex((arg) => arg === "complete");
2403
+ if (completeIndex === -1) return null;
2404
+ const separatorIndex = argv.findIndex((arg) => arg === "--");
2405
+ if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
2406
+ return argv.slice(separatorIndex + 1);
2407
+ };
2408
+ const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
2409
+ if (platform !== "darwin") return null;
2410
+ if (selection === "legacy-keychain") return "⚠ macOS keychain is using the legacy security CLI backend. Touch ID may not be offered for keychain prompts.";
2411
+ if (selection === "auto" && backendId === "macos") return "⚠ macOS keychain is using the cross-keychain CLI fallback (`security`). Touch ID may not be offered for keychain prompts.";
2412
+ return null;
2413
+ };
2414
+ const maybeWarnAboutMacOSKeychainPromptMode = async (selection) => {
2415
+ let backendId = null;
2416
+ if (selection === "auto" && process.platform === "darwin") try {
2417
+ backendId = await resolveMacOSCrossKeychainBackendId();
2418
+ } catch {
2419
+ backendId = null;
2420
+ }
2421
+ const warning = getMacOSKeychainPromptWarning(selection, process.platform, backendId);
2422
+ if (warning) process.stderr.write(`${warning}\n`);
2423
+ };
1460
2424
  const createProgram = (deps = {}) => {
1461
2425
  const program = new Command();
1462
- program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
2426
+ program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version").option("--secret-store <mode>", "Select secret-store backend (auto|legacy-keychain)", parseSecretStoreSelection);
2427
+ program.hook("preAction", async (_thisCommand, actionCommand) => {
2428
+ const options = actionCommand.optsWithGlobals();
2429
+ const configuredSelection = options.secretStore ? void 0 : await loadConfiguredSecretStoreSelection();
2430
+ const selection = options.secretStore ?? configuredSelection ?? "auto";
2431
+ setSecretStoreAdapter(createSecretStoreAdapterFromSelection(selection));
2432
+ await maybeWarnAboutMacOSKeychainPromptMode(selection);
2433
+ });
2434
+ program.hook("postAction", () => {
2435
+ resetSecretStoreAdapter();
2436
+ });
1463
2437
  registerLoginCommand(program, deps);
1464
2438
  registerReloginCommand(program);
1465
2439
  registerSwitchCommand(program);
1466
2440
  registerLabelCommand(program);
2441
+ registerMigrateSecretsCommand(program);
1467
2442
  registerStatusCommand(program);
2443
+ registerDoctorCommand(program);
1468
2444
  registerUsageCommand(program);
1469
2445
  registerHelpCommand(program);
1470
2446
  registerVersionCommand(program, version);
@@ -1472,11 +2448,18 @@ const createProgram = (deps = {}) => {
1472
2448
  return program;
1473
2449
  };
1474
2450
  const main = async () => {
1475
- await createProgram().parseAsync(process.argv);
2451
+ const program = createProgram();
2452
+ const completion = tab(program);
2453
+ const completionArgs = getCompletionParseArgs(process.argv);
2454
+ if (completionArgs) {
2455
+ completion.parse(completionArgs);
2456
+ return;
2457
+ }
2458
+ await program.parseAsync(process.argv);
1476
2459
  };
1477
2460
  if (import.meta.main) main().catch((error) => {
1478
2461
  exitWithCommandError(error);
1479
2462
  });
1480
2463
 
1481
2464
  //#endregion
1482
- export { createProgram, createTestPaths, getPaths, interactiveMode, loadConfig, resetPaths, runInteractiveMode, saveConfig, setPaths, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
2465
+ export { createProgram, createRuntimeSecretStoreAdapter, createSecretStoreAdapterFromSelection, createTestPaths, getMacOSKeychainPromptWarning, getPaths, getSecretStoreAdapter, interactiveMode, loadConfig, resetPaths, resetSecretStoreAdapter, resolveMacOSCrossKeychainBackendId, runInteractiveMode, saveConfig, setPaths, setSecretStoreAdapter, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };