@bjesuiter/codex-switcher 1.4.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 +52 -10
  2. package/cdx.mjs +599 -92
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -6,25 +6,27 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
6
6
 
7
7
  ## Latest Changes
8
8
 
9
- ### 1.4.0
9
+ ### 1.5.0
10
10
 
11
11
  #### Features
12
12
 
13
- - Add **beta Windows/Linux support** with platform-specific defaults for config/auth paths.
14
- - Add secure-store adapters via `cross-keychain` for Windows Credential Manager and Linux Secret Service/keyring.
15
- - Add `cdx doctor` to display auth file state with explicit paths plus runtime capability diagnostics.
16
- - Improve `cdx status` output flow: account/token details first, usage fetch with spinner after.
17
- - Require explicit one-time consent before using secure-store fallback backends (`CDX_ALLOW_SECURE_STORE_FALLBACK=1` override).
13
+ - Add shell completion support via `cdx complete <shell>` (with parse-completion handling for shell integrations).
14
+ - Add configurable secret-store selection with `--secret-store <mode>` (`auto` or `legacy-keychain`) plus persisted config support.
15
+ - Switch macOS `auto` secret storage to cross-keychain backend selection (prefers native backend, falls back when needed).
16
+ - Add `cdx migrate-secrets` to migrate legacy macOS keychain entries to cross-keychain and update config.
17
+ - Add optional macOS keychain ACL diagnostics in `cdx doctor --check-keychain-acl` to verify trusted runtime access.
18
+ - Add doctor/runtime warnings when macOS keychain access is using legacy/CLI fallback paths where Touch ID prompts may not be offered.
19
+ - Increase cross-keychain max password length handling (default `16384`) to support larger stored credential payloads.
18
20
 
19
21
  #### Fixes
20
22
 
21
- - Use platform-neutral secure-store wording in output where macOS-specific keychain wording was misleading.
23
+ - Keep `cdx doctor` fast by making keychain ACL checks opt-in and improving output with clearer guidance and progress feedback.
24
+ - Remove Windows credential payload chunking now that larger payloads are supported directly in the secure store backend.
22
25
 
23
26
  #### Internal
24
27
 
25
- - Add shared platform abstraction layer (`lib/platform/*`) for paths, browser launcher, and runtime capability detection.
26
- - Expand platform/path/browser/secret-store test coverage.
27
- - Update dependencies and lockfile for `cross-keychain`.
28
+ - Temporarily switch keyring dependency from `cross-keychain` to `@bjesuiter/cross-keychain@1.1.0-jb.0` until upstream support is available.
29
+ - Add Windows CI coverage including shell smoke checks and expanded secure-store integration tests (including Windows CRUD coverage).
28
30
 
29
31
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
30
32
 
@@ -148,6 +150,19 @@ Interactive mode:
148
150
  cdx
149
151
  ```
150
152
 
153
+ Use the legacy macOS keychain implementation (if needed):
154
+
155
+ ```bash
156
+ cdx --secret-store legacy-keychain switch
157
+ cdx --secret-store legacy-keychain status
158
+ ```
159
+
160
+ Migrate legacy macOS keychain entries to cross-keychain (`auto`) and update config:
161
+
162
+ ```bash
163
+ cdx migrate-secrets
164
+ ```
165
+
151
166
  ## Commands
152
167
 
153
168
  | Command | Description |
@@ -162,13 +177,31 @@ cdx
162
177
  | `cdx label` | Label an account (interactive) |
163
178
  | `cdx label <account> <label>` | Assign label directly |
164
179
  | `cdx status` | Show account status, token expiry, and usage |
180
+ | `cdx migrate-secrets` | Migrate macOS legacy keychain entries to cross-keychain and switch config to `auto` |
165
181
  | `cdx doctor` | Show auth file paths/state and runtime capabilities |
182
+ | `cdx doctor --check-keychain-acl` | Run additional macOS keychain trusted-app/ACL checks (slow) |
166
183
  | `cdx usage` | Show usage overview for all accounts |
167
184
  | `cdx usage <account>` | Show detailed usage for a specific account |
168
185
  | `cdx help [command]` | Show help for all commands or one command |
186
+ | `cdx complete <shell>` | Generate shell completion script (`zsh`, `bash`, `fish`, `powershell`) |
169
187
  | `cdx version` | Show CLI version |
170
188
  | `cdx --help` | Show help |
171
189
  | `cdx --version` | Show version |
190
+ | `cdx --secret-store legacy-keychain <command>` | Override configured backend for this run (macOS legacy keychain) |
191
+
192
+ ### Shell completion
193
+
194
+ Generate and source completion scripts:
195
+
196
+ ```bash
197
+ # zsh
198
+ source <(cdx complete zsh)
199
+
200
+ # bash
201
+ source <(cdx complete bash)
202
+ ```
203
+
204
+ `cdx` also supports shell parse completion requests via `cdx complete -- ...`.
172
205
 
173
206
  ## How It Works
174
207
 
@@ -177,8 +210,16 @@ cdx
177
210
  - **macOS:** macOS Keychain
178
211
  - **Windows:** Windows Credential Manager
179
212
  - **Linux:** Secret Service/keyring
213
+ - Default backend selection is automatic (`auto`).
214
+ - You can persist a preferred backend in `accounts.json` via optional `"secretStore"` (`"auto"` or `"legacy-keychain"`).
215
+ - `--secret-store <mode>` always overrides config for the current run.
180
216
  - If only a fallback secure-store backend is available on your platform, `cdx` asks for one-time explicit consent before the first credential write and explains the security trade-off.
181
217
  - Non-interactive override (if you accept the risk): set `CDX_ALLOW_SECURE_STORE_FALLBACK=1`
218
+ - On macOS, `cdx doctor --check-keychain-acl` performs an additional trusted-app/ACL check for configured account secrets. This check can be slow.
219
+ - Cross-keychain payload size policy:
220
+ - Default max password length override is `16384`.
221
+ - Optional override: set `CDX_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH=<integer-above-4096>`.
222
+ - This currently relies on `@bjesuiter/cross-keychain@1.1.0-jb.0` until upstream support is released.
182
223
 
183
224
  ### Account list path
184
225
 
@@ -233,6 +274,7 @@ And create the accounts list manually:
233
274
  ```json
234
275
  {
235
276
  "current": 0,
277
+ "secretStore": "auto",
236
278
  "accounts": [
237
279
  { "accountId": "ACCOUNT_ID", "keychainService": "cdx-openai-ACCOUNT_ID" }
238
280
  ]
package/cdx.mjs CHANGED
@@ -1,27 +1,20 @@
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";
9
- import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "cross-keychain";
10
+ import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
10
11
  import { createInterface } from "node:readline/promises";
11
12
  import { generatePKCE } from "@openauthjs/openauth/pkce";
12
13
  import { randomBytes } from "node:crypto";
13
14
  import http from "node:http";
14
15
 
15
16
  //#region package.json
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
- };
17
+ var version = "1.5.0";
25
18
 
26
19
  //#endregion
27
20
  //#region lib/platform/path-resolver.ts
@@ -116,6 +109,48 @@ const createTestPaths = (testDir) => ({
116
109
  piAuthPath: path.join(testDir, "pi", "auth.json")
117
110
  });
118
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
+
119
154
  //#endregion
120
155
  //#region lib/auth.ts
121
156
  const readExistingJson = async (filePath) => {
@@ -196,27 +231,6 @@ const writeAllAuthFiles = async (payload) => {
196
231
  };
197
232
  };
198
233
 
199
- //#endregion
200
- //#region lib/config.ts
201
- const loadConfig = async () => {
202
- const { configPath } = getPaths();
203
- if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
204
- const raw = await readFile(configPath, "utf8");
205
- const parsed = JSON.parse(raw);
206
- if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
207
- if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
208
- return parsed;
209
- };
210
- const saveConfig = async (config) => {
211
- const { configDir, configPath } = getPaths();
212
- await mkdir(configDir, { recursive: true });
213
- await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
214
- };
215
- const configExists = () => {
216
- const { configPath } = getPaths();
217
- return existsSync(configPath);
218
- };
219
-
220
234
  //#endregion
221
235
  //#region lib/platform/browser.ts
222
236
  const getBrowserLauncher = (platform = process.platform, url) => {
@@ -279,9 +293,9 @@ const openBrowserUrl = (url, spawnImpl = spawn) => {
279
293
 
280
294
  //#endregion
281
295
  //#region lib/keychain.ts
282
- const SERVICE_PREFIX$2 = "cdx-openai-";
296
+ const SERVICE_PREFIX$3 = "cdx-openai-";
283
297
  const getKeychainService = (accountId) => {
284
- return `${SERVICE_PREFIX$2}${accountId}`;
298
+ return `${SERVICE_PREFIX$3}${accountId}`;
285
299
  };
286
300
  const runSecurity = (args) => {
287
301
  const result = Bun.spawnSync({
@@ -306,6 +320,23 @@ const runSecuritySafe = (args) => {
306
320
  output: result.exitCode === 0 ? result.stdout.toString() : result.stderr.toString()
307
321
  };
308
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
+ };
309
340
  const saveKeychainPayload = (accountId, payload) => {
310
341
  runSecurity([
311
342
  "add-generic-password",
@@ -353,12 +384,26 @@ const listKeychainAccounts = () => {
353
384
  if (result.exitCode !== 0) return [];
354
385
  const output = result.stdout.toString();
355
386
  const accounts = [];
356
- const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$2}([^"]+)"`, "g");
387
+ const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$3}([^"]+)"`, "g");
357
388
  let match;
358
389
  while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
359
390
  return [...new Set(accounts)];
360
391
  };
361
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
+
362
407
  //#endregion
363
408
  //#region lib/secrets/fallback-consent.ts
364
409
  const CONSENT_FILE = "secure-store-fallback-consent.json";
@@ -413,13 +458,73 @@ const ensureFallbackConsent = async (scope, warningMessage) => {
413
458
 
414
459
  //#endregion
415
460
  //#region lib/secrets/linux-cross-keychain.ts
416
- const SERVICE_PREFIX$1 = "cdx-openai-";
461
+ const SERVICE_PREFIX$2 = "cdx-openai-";
417
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";
418
523
  let backendInitPromise$1 = null;
419
524
  let selectedBackend$1 = null;
420
525
  const tryUseBackend$1 = async (backendId) => {
421
526
  try {
422
- await useBackend(backendId);
527
+ await useBackend(backendId, getCrossKeychainBackendOverrides());
423
528
  return true;
424
529
  } catch {
425
530
  return false;
@@ -428,13 +533,13 @@ const tryUseBackend$1 = async (backendId) => {
428
533
  const selectBackend$1 = async () => {
429
534
  const backends = await listBackends();
430
535
  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.");
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.");
436
541
  };
437
- const ensureLinuxBackend = async (options = {}) => {
542
+ const ensureMacOSBackend = async (options = {}) => {
438
543
  if (!backendInitPromise$1) backendInitPromise$1 = (async () => {
439
544
  selectedBackend$1 = await selectBackend$1();
440
545
  })();
@@ -443,11 +548,16 @@ const ensureLinuxBackend = async (options = {}) => {
443
548
  } catch {
444
549
  backendInitPromise$1 = null;
445
550
  selectedBackend$1 = null;
446
- throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
551
+ throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
447
552
  }
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.");
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.");
449
554
  };
450
- const getLinuxCrossKeychainService = (accountId) => `${SERVICE_PREFIX$1}${accountId}`;
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}`;
451
561
  const parsePayload$1 = (accountId, raw) => {
452
562
  let parsed;
453
563
  try {
@@ -459,17 +569,17 @@ const parsePayload$1 = (accountId, raw) => {
459
569
  return parsed;
460
570
  };
461
571
  const withService$1 = async (accountId, run, options = {}) => {
462
- await ensureLinuxBackend(options);
463
- return run(getLinuxCrossKeychainService(accountId));
572
+ await ensureMacOSBackend(options);
573
+ return run(getMacOSCrossKeychainService(accountId));
464
574
  };
465
- const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
466
- const loadLinuxCrossKeychainPayload = async (accountId) => {
575
+ const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
576
+ const loadMacOSCrossKeychainPayload = async (accountId) => {
467
577
  const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
468
578
  if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
469
579
  return parsePayload$1(accountId, raw);
470
580
  };
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);
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);
473
583
 
474
584
  //#endregion
475
585
  //#region lib/secrets/windows-cross-keychain.ts
@@ -479,7 +589,7 @@ let backendInitPromise = null;
479
589
  let selectedBackend = null;
480
590
  const tryUseBackend = async (backendId) => {
481
591
  try {
482
- await useBackend(backendId);
592
+ await useBackend(backendId, getCrossKeychainBackendOverrides());
483
593
  return true;
484
594
  } catch {
485
595
  return false;
@@ -522,52 +632,154 @@ const withService = async (accountId, run, options = {}) => {
522
632
  await ensureWindowsBackend(options);
523
633
  return run(getWindowsCrossKeychainService(accountId));
524
634
  };
525
- const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
635
+ const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, async (service) => {
636
+ await setPassword(service, accountId, JSON.stringify(payload));
637
+ }, { forWrite: true });
526
638
  const loadWindowsCrossKeychainPayload = async (accountId) => {
527
639
  const raw = await withService(accountId, (service) => getPassword(service, accountId));
528
640
  if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
529
641
  return parsePayload(accountId, raw);
530
642
  };
531
- const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, (service) => deletePassword(service, accountId));
643
+ const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, async (service) => {
644
+ await deletePassword(service, accountId);
645
+ });
532
646
  const windowsCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
533
647
 
534
648
  //#endregion
535
649
  //#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;
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
+ };
548
745
  };
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.");
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);
552
750
  };
553
- const createMacOSKeychainAdapter = () => ({
554
- id: "macos-keychain",
555
- label: "macOS Keychain",
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)",
556
771
  getServiceName: getKeychainService,
557
772
  save: async (accountId, payload) => {
558
- await ensureMacFallbackConsentIfNeeded();
559
773
  saveKeychainPayload(accountId, payload);
560
774
  },
561
775
  load: async (accountId) => loadKeychainPayload(accountId),
562
- delete: async (accountId) => deleteKeychainPayload(accountId),
776
+ delete: async (accountId) => {
777
+ deleteKeychainPayload(accountId);
778
+ },
563
779
  exists: async (accountId) => keychainPayloadExists(accountId),
564
780
  listAccountIds: async () => listKeychainAccounts(),
565
781
  getCapability: () => ({ available: true })
566
782
  });
567
- const loadConfiguredAccountIds = async () => {
568
- if (!configExists()) return [];
569
- return (await loadConfig()).accounts.map((account) => account.accountId);
570
- };
571
783
  const createWindowsCrossKeychainAdapter = () => ({
572
784
  id: "windows-cross-keychain",
573
785
  label: "Windows Credential Manager (cross-keychain)",
@@ -623,18 +835,29 @@ const createUnsupportedAdapter = (platform) => ({
623
835
  })
624
836
  });
625
837
  const createRuntimeSecretStoreAdapter = (platform = process.platform) => {
626
- if (platform === "darwin") return createMacOSKeychainAdapter();
838
+ if (platform === "darwin") return createMacOSCrossKeychainAdapter();
627
839
  if (platform === "win32") return createWindowsCrossKeychainAdapter();
628
840
  if (platform === "linux") return createLinuxCrossKeychainAdapter();
629
841
  return createUnsupportedAdapter(platform);
630
842
  };
631
- let currentSecretStoreAdapter = createRuntimeSecretStoreAdapter();
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());
632
855
  const getSecretStoreAdapter = () => currentSecretStoreAdapter;
633
856
  const setSecretStoreAdapter = (adapter) => {
634
- currentSecretStoreAdapter = adapter;
857
+ currentSecretStoreAdapter = withSecretStoreCache(adapter);
635
858
  };
636
859
  const resetSecretStoreAdapter = () => {
637
- currentSecretStoreAdapter = createRuntimeSecretStoreAdapter();
860
+ currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
638
861
  };
639
862
  const getSecretStoreCapability = () => {
640
863
  const adapter = getSecretStoreAdapter();
@@ -1100,14 +1323,17 @@ const readPiAuthAccount = async () => {
1100
1323
  };
1101
1324
  const getAccountStatus = async (accountId, isCurrent, label) => {
1102
1325
  const secretStore = getSecretStoreAdapter();
1103
- const secureStoreExists = await secretStore.exists(accountId);
1326
+ let secureStoreExists = false;
1104
1327
  let expiresAt = null;
1105
1328
  let hasIdToken = false;
1106
- if (secureStoreExists) try {
1329
+ try {
1107
1330
  const payload = await secretStore.load(accountId);
1331
+ secureStoreExists = true;
1108
1332
  expiresAt = payload.expires;
1109
1333
  hasIdToken = !!payload.idToken;
1110
- } catch {}
1334
+ } catch (error) {
1335
+ secureStoreExists = !isMissingSecretStoreEntryError(error);
1336
+ }
1111
1337
  return {
1112
1338
  accountId,
1113
1339
  label,
@@ -1481,10 +1707,89 @@ const registerDefaultInteractiveAction = (program) => {
1481
1707
  });
1482
1708
  };
1483
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
+
1484
1782
  //#endregion
1485
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
+ };
1486
1791
  const registerDoctorCommand = (program) => {
1487
- program.command("doctor").description("Show auth file paths and runtime capabilities").action(async () => {
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) => {
1488
1793
  try {
1489
1794
  const status = await getStatus();
1490
1795
  const paths = getPaths();
@@ -1509,6 +1814,50 @@ const registerDoctorCommand = (program) => {
1509
1814
  process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
1510
1815
  const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
1511
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
+ }
1512
1861
  process.stdout.write("\n");
1513
1862
  } catch (error) {
1514
1863
  exitWithCommandError(error);
@@ -1571,6 +1920,119 @@ const registerLoginCommand = (program, deps = {}) => {
1571
1920
  });
1572
1921
  };
1573
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
+
1574
2036
  //#endregion
1575
2037
  //#region lib/commands/output.ts
1576
2038
  const formatCodexMark = (result) => {
@@ -1932,13 +2394,51 @@ const registerVersionCommand = (program, version) => {
1932
2394
  //#endregion
1933
2395
  //#region cdx.ts
1934
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
+ };
1935
2424
  const createProgram = (deps = {}) => {
1936
2425
  const program = new Command();
1937
- 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
+ });
1938
2437
  registerLoginCommand(program, deps);
1939
2438
  registerReloginCommand(program);
1940
2439
  registerSwitchCommand(program);
1941
2440
  registerLabelCommand(program);
2441
+ registerMigrateSecretsCommand(program);
1942
2442
  registerStatusCommand(program);
1943
2443
  registerDoctorCommand(program);
1944
2444
  registerUsageCommand(program);
@@ -1948,11 +2448,18 @@ const createProgram = (deps = {}) => {
1948
2448
  return program;
1949
2449
  };
1950
2450
  const main = async () => {
1951
- 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);
1952
2459
  };
1953
2460
  if (import.meta.main) main().catch((error) => {
1954
2461
  exitWithCommandError(error);
1955
2462
  });
1956
2463
 
1957
2464
  //#endregion
1958
- export { createProgram, createRuntimeSecretStoreAdapter, createTestPaths, getPaths, getSecretStoreAdapter, interactiveMode, loadConfig, resetPaths, resetSecretStoreAdapter, runInteractiveMode, saveConfig, setPaths, setSecretStoreAdapter, 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {
@@ -20,9 +20,10 @@
20
20
  "license": "MIT",
21
21
  "author": "bjesuiter",
22
22
  "dependencies": {
23
+ "@bjesuiter/cross-keychain": "1.1.0-jb.0",
24
+ "@bomb.sh/tab": "^0.0.13",
23
25
  "@clack/prompts": "^1.0.0",
24
26
  "@openauthjs/openauth": "^0.4.3",
25
- "commander": "^14.0.3",
26
- "cross-keychain": "^1.1.0"
27
+ "commander": "^14.0.3"
27
28
  }
28
29
  }