@bjesuiter/codex-switcher 1.7.4 → 1.8.1

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 +9 -7
  2. package/cdx.mjs +602 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,12 +6,12 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
6
6
 
7
7
  ## Latest Changes
8
8
 
9
- ### 1.7.4
9
+ ### 1.8.1
10
10
 
11
11
  #### Fixes
12
12
 
13
- - Detect Cloudflare/bot-protection HTML challenge responses during OAuth device flow startup and token polling, and report an explicit `cloudflare_challenge` reason instead of only a generic HTTP 403.
14
- - When that challenge is detected, show a clear workaround: retry without `--device-flow` to use browser/manual callback flow.
13
+ - Added platform-native secure-store write/read/delete probes to `cdx doctor` on Linux, macOS, and Windows, so runtime secure-store failures are detected directly instead of only reporting adapter capability.
14
+ - Linux secure-store error handling now treats Secret Service `no result found` responses as missing-entry cases and classifies generic `Couldn't access platform secure storage` failures as unavailable-store errors with actionable guidance.
15
15
 
16
16
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
17
17
 
@@ -153,12 +153,12 @@ cdx migrate-secrets
153
153
  | Command | Description |
154
154
  |---------|-------------|
155
155
  | `cdx` | Interactive mode |
156
- | `cdx login` | Add a new OpenAI account via OAuth |
157
- | `cdx login --device-flow` | Add account using OAuth device flow (no local browser callback needed) |
156
+ | `cdx login` | Add a new OpenAI account via OAuth (recommended default flow; supports manual URL copy/paste callback completion with optional clipboard copy assist) |
157
+ | `cdx login --device-flow` | Add account using OAuth device flow (may fail on some VPS/server IPs due to Cloudflare challenges) |
158
158
  | `cdx relogin` | Re-authenticate an existing account via OAuth |
159
- | `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow |
159
+ | `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow (may fail on some VPS/server IPs due to Cloudflare challenges) |
160
160
  | `cdx relogin <account>` | Re-authenticate a specific account by ID or label |
161
- | `cdx relogin <account> --device-flow` | Re-authenticate specific account using OAuth device flow |
161
+ | `cdx relogin <account> --device-flow` | Re-authenticate specific account using OAuth device flow (may fail on some VPS/server IPs due to Cloudflare challenges) |
162
162
  | `cdx switch` | Switch account (interactive picker) |
163
163
  | `cdx switch --next` | Cycle to next account |
164
164
  | `cdx switch <id>` | Switch to specific account |
@@ -177,6 +177,8 @@ cdx migrate-secrets
177
177
  | `cdx --version` | Show version |
178
178
  | `cdx --secret-store legacy-keychain <command>` | Override configured backend for this run (macOS legacy keychain) |
179
179
 
180
+ > Tip: On SSH/VPS, prefer `cdx login` (without `--device-flow`) and complete login via manual callback URL/code copy-paste. The manual flow can offer clipboard copy assist (including OSC52 on compatible terminals) to avoid selecting long URLs. In Mosh sessions, OSC52 clipboard updates may be unreliable; if needed, use the printed fallback copy command or manual copy. Device flow can be blocked by Cloudflare challenge pages.
181
+
180
182
  ### Shell completion
181
183
 
182
184
  Generate and source completion scripts:
package/cdx.mjs CHANGED
@@ -6,7 +6,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import * as p from "@clack/prompts";
9
- import { spawn } from "node:child_process";
9
+ import { spawn, spawnSync } from "node:child_process";
10
10
  import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import { randomBytes } from "node:crypto";
@@ -15,7 +15,7 @@ import { generatePKCE } from "@openauthjs/openauth/pkce";
15
15
  import http from "node:http";
16
16
 
17
17
  //#region package.json
18
- var version = "1.7.4";
18
+ var version = "1.8.1";
19
19
 
20
20
  //#endregion
21
21
  //#region lib/platform/path-resolver.ts
@@ -256,7 +256,7 @@ const getBrowserLauncher = (platform = process.platform, url) => {
256
256
  label: "xdg-open"
257
257
  };
258
258
  };
259
- const isCommandAvailable = (command, platform = process.platform) => {
259
+ const isCommandAvailable$1 = (command, platform = process.platform) => {
260
260
  const probe = platform === "win32" ? "where" : "which";
261
261
  return Bun.spawnSync({
262
262
  cmd: [probe, command],
@@ -269,13 +269,13 @@ const getBrowserLauncherCapability = (platform = process.platform) => {
269
269
  return {
270
270
  command: launcher.command,
271
271
  label: launcher.label,
272
- available: isCommandAvailable(launcher.command, platform)
272
+ available: isCommandAvailable$1(launcher.command, platform)
273
273
  };
274
274
  };
275
275
  const openBrowserUrl = (url, options = {}) => {
276
276
  const platform = options.platform ?? process.platform;
277
277
  const spawnImpl = options.spawnImpl ?? spawn;
278
- const commandAvailable = options.isCommandAvailableImpl ?? isCommandAvailable;
278
+ const commandAvailable = options.isCommandAvailableImpl ?? isCommandAvailable$1;
279
279
  const launcher = getBrowserLauncher(platform, url);
280
280
  if (!commandAvailable(launcher.command, platform)) return {
281
281
  ok: false,
@@ -304,6 +304,192 @@ const openBrowserUrl = (url, options = {}) => {
304
304
  }
305
305
  };
306
306
 
307
+ //#endregion
308
+ //#region lib/platform/clipboard.ts
309
+ const isCommandAvailable = (command, platform = process.platform) => {
310
+ const probe = platform === "win32" ? "where" : "which";
311
+ return Bun.spawnSync({
312
+ cmd: [probe, command],
313
+ stdout: "pipe",
314
+ stderr: "pipe"
315
+ }).exitCode === 0;
316
+ };
317
+ const isLikelyMoshSession = (env = process.env) => Boolean(env.MOSH_IP || env.MOSH_KEY || env.MOSH_PREDICTION_DISPLAY);
318
+ const isLikelyRemoteSession = (env = process.env) => {
319
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
320
+ return isLikelyMoshSession(env);
321
+ };
322
+ const hasDisplayServer = (env) => Boolean(env.WAYLAND_DISPLAY || env.DISPLAY);
323
+ const supportsOsc52 = (isTTY, env) => {
324
+ if (!isTTY) return false;
325
+ if ((env.TERM ?? "").toLowerCase() === "dumb") return false;
326
+ return true;
327
+ };
328
+ const getLocalClipboardTargets = (platform, env, commandExists) => {
329
+ if (platform === "darwin") return commandExists("pbcopy", platform) ? [{
330
+ kind: "command",
331
+ method: "pbcopy",
332
+ command: "pbcopy",
333
+ args: []
334
+ }] : [];
335
+ if (platform === "win32") {
336
+ const targets = [];
337
+ if (commandExists("clip", platform)) targets.push({
338
+ kind: "command",
339
+ method: "clip",
340
+ command: "cmd",
341
+ args: ["/c", "clip"]
342
+ });
343
+ if (commandExists("powershell", platform)) targets.push({
344
+ kind: "command",
345
+ method: "powershell",
346
+ command: "powershell",
347
+ args: [
348
+ "-NoProfile",
349
+ "-Command",
350
+ "$v=[Console]::In.ReadToEnd(); Set-Clipboard -Value $v"
351
+ ]
352
+ });
353
+ if (commandExists("pwsh", platform)) targets.push({
354
+ kind: "command",
355
+ method: "powershell",
356
+ command: "pwsh",
357
+ args: [
358
+ "-NoProfile",
359
+ "-Command",
360
+ "$v=[Console]::In.ReadToEnd(); Set-Clipboard -Value $v"
361
+ ]
362
+ });
363
+ return targets;
364
+ }
365
+ const targets = [];
366
+ const wayland = Boolean(env.WAYLAND_DISPLAY);
367
+ const x11 = Boolean(env.DISPLAY);
368
+ if (isLikelyRemoteSession(env) && !hasDisplayServer(env)) return targets;
369
+ if (wayland && commandExists("wl-copy", platform)) targets.push({
370
+ kind: "command",
371
+ method: "wl-copy",
372
+ command: "wl-copy",
373
+ args: []
374
+ });
375
+ if ((x11 || !wayland) && commandExists("xclip", platform)) targets.push({
376
+ kind: "command",
377
+ method: "xclip",
378
+ command: "xclip",
379
+ args: ["-selection", "clipboard"]
380
+ });
381
+ if ((x11 || !wayland) && commandExists("xsel", platform)) targets.push({
382
+ kind: "command",
383
+ method: "xsel",
384
+ command: "xsel",
385
+ args: ["--clipboard", "--input"]
386
+ });
387
+ if (!wayland && commandExists("wl-copy", platform)) targets.push({
388
+ kind: "command",
389
+ method: "wl-copy",
390
+ command: "wl-copy",
391
+ args: []
392
+ });
393
+ return targets;
394
+ };
395
+ const resolveClipboardTargets = (context = {}, commandExists = isCommandAvailable) => {
396
+ const platform = context.platform ?? process.platform;
397
+ const env = context.env ?? process.env;
398
+ const isTTY = context.isTTY ?? (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY));
399
+ const localTargets = getLocalClipboardTargets(platform, env, commandExists);
400
+ const remote = isLikelyRemoteSession(env);
401
+ const allowOsc52 = supportsOsc52(isTTY, env);
402
+ const result = [];
403
+ if (remote && allowOsc52) result.push({
404
+ kind: "osc52",
405
+ method: "osc52"
406
+ });
407
+ result.push(...localTargets);
408
+ if (!remote && allowOsc52) result.push({
409
+ kind: "osc52",
410
+ method: "osc52"
411
+ });
412
+ return result;
413
+ };
414
+ const buildOsc52Sequence = (text, env = process.env) => {
415
+ const osc = `\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`;
416
+ if (env.TMUX) return `\u001bPtmux;\u001b${osc}\u001b\\`;
417
+ const term = (env.TERM ?? "").toLowerCase();
418
+ if (env.STY || term.startsWith("screen")) return `\u001bP${osc}\u001b\\`;
419
+ return osc;
420
+ };
421
+ const defaultRunCommand = (command, args, input) => {
422
+ const result = spawnSync(command, args, {
423
+ input,
424
+ encoding: "utf8",
425
+ windowsHide: true
426
+ });
427
+ if (result.status === 0) return { ok: true };
428
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
429
+ const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
430
+ return {
431
+ ok: false,
432
+ error: stderr || stdout || `exit status ${result.status ?? "unknown"}`
433
+ };
434
+ };
435
+ const tryCopyToClipboard = (text, options = {}) => {
436
+ const commandExists = options.commandExistsImpl ?? isCommandAvailable;
437
+ const runCommand = options.runCommandImpl ?? defaultRunCommand;
438
+ const writeStdout = options.writeStdoutImpl ?? ((chunk) => {
439
+ process.stdout.write(chunk);
440
+ });
441
+ const targets = resolveClipboardTargets(options, commandExists);
442
+ if (targets.length === 0) return {
443
+ ok: false,
444
+ method: "none",
445
+ error: "No clipboard method available in this environment"
446
+ };
447
+ const errors = [];
448
+ for (const target of targets) {
449
+ if (target.kind === "osc52") {
450
+ const env = options.env ?? process.env;
451
+ try {
452
+ writeStdout(buildOsc52Sequence(text, env));
453
+ if (isLikelyMoshSession(env)) return {
454
+ ok: true,
455
+ method: "osc52",
456
+ warning: "Mosh session detected: OSC52 clipboard updates may not work reliably. If clipboard did not update, copy the URL manually from above."
457
+ };
458
+ return {
459
+ ok: true,
460
+ method: "osc52"
461
+ };
462
+ } catch (error) {
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ errors.push(`osc52: ${message}`);
465
+ continue;
466
+ }
467
+ }
468
+ const commandResult = runCommand(target.command, target.args, text);
469
+ if (commandResult.ok) return {
470
+ ok: true,
471
+ method: target.method
472
+ };
473
+ errors.push(`${target.method}: ${commandResult.error ?? "unknown error"}`);
474
+ }
475
+ return {
476
+ ok: false,
477
+ method: "none",
478
+ error: errors.join("; ")
479
+ };
480
+ };
481
+ const escapePosixSingleQuoted = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
482
+ const buildClipboardHelperCommand = (text, context = {}, commandExists = isCommandAvailable) => {
483
+ const commandTarget = resolveClipboardTargets(context, commandExists).find((target) => target.kind === "command");
484
+ if (!commandTarget) return null;
485
+ if (commandTarget.method === "powershell") {
486
+ const escaped = text.replace(/'/g, "''");
487
+ return `${commandTarget.command} -NoProfile -Command "Set-Clipboard -Value '${escaped}'"`;
488
+ }
489
+ if (commandTarget.method === "clip") return `printf '%s' ${escapePosixSingleQuoted(text)} | cmd /c clip`;
490
+ return `printf '%s' ${escapePosixSingleQuoted(text)} | ${commandTarget.command} ${commandTarget.args.join(" ")}`.trim();
491
+ };
492
+
307
493
  //#endregion
308
494
  //#region lib/keychain.ts
309
495
  const SERVICE_PREFIX$3 = "cdx-openai-";
@@ -473,8 +659,38 @@ const ensureFallbackConsent = async (scope, warningMessage) => {
473
659
  //#region lib/secrets/linux-cross-keychain.ts
474
660
  const SERVICE_PREFIX$2 = "cdx-openai-";
475
661
  const LINUX_FALLBACK_SCOPE = "linux:cross-keychain:secret-service";
662
+ const MISSING_ENTRY_MARKERS = [
663
+ "no matching entry found in secure storage",
664
+ "password not found",
665
+ "no stored credentials found",
666
+ "credential not found",
667
+ "no result found"
668
+ ];
669
+ const STORE_UNAVAILABLE_MARKERS = [
670
+ "unable to initialize linux secure-store backend",
671
+ "no keyring backend could be initialized",
672
+ "native keyring module not available",
673
+ "secret service operation failed",
674
+ "couldn't access platform secure storage",
675
+ "dbus",
676
+ "d-bus",
677
+ "org.freedesktop.secrets",
678
+ "service unavailable"
679
+ ];
476
680
  let backendInitPromise$2 = null;
477
681
  let selectedBackend$2 = null;
682
+ const getErrorMessage = (error) => error instanceof Error ? error.message : String(error);
683
+ const classifyLinuxSecureStoreError = (error) => {
684
+ const message = getErrorMessage(error).toLowerCase();
685
+ if (MISSING_ENTRY_MARKERS.some((marker) => message.includes(marker))) return "missing_entry";
686
+ if (STORE_UNAVAILABLE_MARKERS.some((marker) => message.includes(marker))) return "store_unavailable";
687
+ return "other";
688
+ };
689
+ const createLinuxSecureStoreUnavailableError = (details) => {
690
+ const guidance = "Linux secure store is unavailable. Ensure Secret Service is installed/running (for example gnome-keyring with secret-tool), then retry login.";
691
+ if (!details) return new Error(guidance);
692
+ return /* @__PURE__ */ new Error(`${guidance} Technical details: ${details}`);
693
+ };
478
694
  const tryUseBackend$2 = async (backendId) => {
479
695
  try {
480
696
  await useBackend(backendId, getCrossKeychainBackendOverrides());
@@ -492,6 +708,20 @@ const selectBackend$2 = async () => {
492
708
  if (await tryUseBackend$2("secret-service")) return "secret-service";
493
709
  throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
494
710
  };
711
+ const setActiveBackend = (backendId) => {
712
+ selectedBackend$2 = backendId;
713
+ backendInitPromise$2 = Promise.resolve();
714
+ };
715
+ const trySwitchBackend = async (backendId, options = {}) => {
716
+ if (options.forWrite && backendId === "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.");
717
+ try {
718
+ await useBackend(backendId, getCrossKeychainBackendOverrides());
719
+ setActiveBackend(backendId);
720
+ return true;
721
+ } catch {
722
+ return false;
723
+ }
724
+ };
495
725
  const ensureLinuxBackend = async (options = {}) => {
496
726
  if (!backendInitPromise$2) backendInitPromise$2 = (async () => {
497
727
  selectedBackend$2 = await selectBackend$2();
@@ -517,17 +747,75 @@ const parsePayload$2 = (accountId, raw) => {
517
747
  return parsed;
518
748
  };
519
749
  const withService$1 = async (accountId, run, options = {}) => {
520
- await ensureLinuxBackend(options);
521
- return run(getLinuxCrossKeychainService(accountId));
750
+ try {
751
+ await ensureLinuxBackend(options);
752
+ } catch (error) {
753
+ throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
754
+ }
755
+ try {
756
+ return await run(getLinuxCrossKeychainService(accountId));
757
+ } catch (error) {
758
+ if (classifyLinuxSecureStoreError(error) === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
759
+ throw error;
760
+ }
761
+ };
762
+ const trySaveWithSecretServiceFallback = async (accountId, serializedPayload) => {
763
+ if (!await trySwitchBackend("secret-service", { forWrite: true })) return {
764
+ ok: false,
765
+ error: /* @__PURE__ */ new Error("Unable to switch Linux secure-store backend to secret-service fallback.")
766
+ };
767
+ try {
768
+ await setPassword(getLinuxCrossKeychainService(accountId), accountId, serializedPayload);
769
+ return { ok: true };
770
+ } catch (error) {
771
+ return {
772
+ ok: false,
773
+ error
774
+ };
775
+ }
776
+ };
777
+ const saveLinuxCrossKeychainPayload = async (accountId, payload) => {
778
+ const serialized = JSON.stringify(payload);
779
+ try {
780
+ await withService$1(accountId, (service) => setPassword(service, accountId, serialized), { forWrite: true });
781
+ return;
782
+ } catch (error) {
783
+ const kind = classifyLinuxSecureStoreError(error);
784
+ if (kind === "missing_entry" && selectedBackend$2 === "native-linux") {
785
+ const fallbackResult = await trySaveWithSecretServiceFallback(accountId, serialized);
786
+ if (fallbackResult.ok) return;
787
+ throw createLinuxSecureStoreUnavailableError(`Native backend could not create the credential entry (${getErrorMessage(error)}). Fallback secret-service backend also failed (${getErrorMessage(fallbackResult.error)}).`);
788
+ }
789
+ if (kind === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
790
+ throw error;
791
+ }
522
792
  };
523
- const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
524
793
  const loadLinuxCrossKeychainPayload = async (accountId) => {
525
- const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
526
- if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
527
- return parsePayload$2(accountId, raw);
794
+ try {
795
+ const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
796
+ if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
797
+ return parsePayload$2(accountId, raw);
798
+ } catch (error) {
799
+ if (classifyLinuxSecureStoreError(error) === "missing_entry") throw new Error(`No stored credentials found for account ${accountId}.`);
800
+ throw error;
801
+ }
802
+ };
803
+ const deleteLinuxCrossKeychainPayload = async (accountId) => {
804
+ try {
805
+ await withService$1(accountId, (service) => deletePassword(service, accountId));
806
+ } catch (error) {
807
+ if (classifyLinuxSecureStoreError(error) === "missing_entry") return;
808
+ throw error;
809
+ }
810
+ };
811
+ const linuxCrossKeychainPayloadExists = async (accountId) => {
812
+ try {
813
+ return await withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
814
+ } catch (error) {
815
+ if (classifyLinuxSecureStoreError(error) === "missing_entry") return false;
816
+ throw error;
817
+ }
528
818
  };
529
- const deleteLinuxCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
530
- const linuxCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
531
819
 
532
820
  //#endregion
533
821
  //#region lib/secrets/macos-cross-keychain.ts
@@ -775,13 +1063,16 @@ const windowsCrossKeychainPayloadExists = async (accountId) => withWindowsBacken
775
1063
  //#endregion
776
1064
  //#region lib/secrets/store.ts
777
1065
  const MISSING_SECRET_STORE_ERROR_MARKERS = [
778
- "No stored credentials found",
779
- "No Keychain payload found",
780
- "Password not found"
1066
+ "no stored credentials found",
1067
+ "no keychain payload found",
1068
+ "password not found",
1069
+ "no matching entry found in secure storage",
1070
+ "no result found"
781
1071
  ];
782
1072
  const isMissingSecretStoreEntryError = (error) => {
783
1073
  if (!(error instanceof Error)) return false;
784
- return MISSING_SECRET_STORE_ERROR_MARKERS.some((marker) => error.message.includes(marker));
1074
+ const message = error.message.toLowerCase();
1075
+ return MISSING_SECRET_STORE_ERROR_MARKERS.some((marker) => message.includes(marker));
785
1076
  };
786
1077
  const createMissingSecretStoreEntryError = (accountId) => /* @__PURE__ */ new Error(`No stored credentials found for account ${accountId}.`);
787
1078
  const CACHED_ADAPTER_SYMBOL = Symbol.for("cdx.secretStore.cachedAdapter");
@@ -1467,33 +1758,19 @@ const promptBrowserFallbackChoice = async () => {
1467
1758
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1468
1759
  const selected = remoteHint ? "device" : "manual";
1469
1760
  p.log.info(`Non-interactive terminal detected. Falling back to ${selected === "device" ? "device OAuth flow" : "manual URL copy/paste flow"}.`);
1761
+ if (selected === "device") p.log.info("When interactive, manual URL copy/paste flow is recommended because device flow may be blocked by Cloudflare on some VPS/server IPs.");
1470
1762
  return selected;
1471
1763
  }
1472
- const options = remoteHint ? [
1473
- {
1474
- value: "device",
1475
- label: "Use device OAuth flow",
1476
- hint: "Recommended on SSH/remote servers"
1477
- },
1478
- {
1479
- value: "manual",
1480
- label: "Finish manually by copying URL",
1481
- hint: "Open URL on any machine and paste callback URL/code back here"
1482
- },
1483
- {
1484
- value: "cancel",
1485
- label: "Cancel login"
1486
- }
1487
- ] : [
1764
+ const options = [
1488
1765
  {
1489
1766
  value: "manual",
1490
- label: "Finish manually by copying URL",
1491
- hint: "Open URL on any machine and paste callback URL/code back here"
1767
+ label: "Finish manually by copying URL (recommended)",
1768
+ hint: remoteHint ? "Best on SSH/VPS: open URL on any machine and paste callback URL/code back here" : "Open URL on any machine and paste callback URL/code back here"
1492
1769
  },
1493
1770
  {
1494
1771
  value: "device",
1495
1772
  label: "Use device OAuth flow",
1496
- hint: "Best for headless/remote environments"
1773
+ hint: "May fail on some VPS/servers due to Cloudflare challenge"
1497
1774
  },
1498
1775
  {
1499
1776
  value: "cancel",
@@ -1535,10 +1812,33 @@ const promptPortConflictChoice = async (listeningProcess) => {
1535
1812
  if (p.isCancel(selection)) return "cancel";
1536
1813
  return selection;
1537
1814
  };
1815
+ const maybeCopyAuthorizationUrlToClipboard = async (authorizationUrl) => {
1816
+ if (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
1817
+ const shouldCopy = await p.confirm({
1818
+ message: "Copy login URL to clipboard now?",
1819
+ initialValue: true
1820
+ });
1821
+ if (p.isCancel(shouldCopy) || !shouldCopy) return;
1822
+ }
1823
+ const copyResult = tryCopyToClipboard(authorizationUrl);
1824
+ if (copyResult.ok) {
1825
+ p.log.success(`Copied login URL to clipboard via ${copyResult.method}.`);
1826
+ if (copyResult.warning) {
1827
+ p.log.warning(copyResult.warning);
1828
+ const helper = buildClipboardHelperCommand(authorizationUrl);
1829
+ if (helper) p.log.message(`If needed, run this copy command instead:\n${helper}`);
1830
+ }
1831
+ return;
1832
+ }
1833
+ p.log.warning(`Could not copy login URL automatically${copyResult.error ? ` (${copyResult.error})` : ""}.`);
1834
+ const helper = buildClipboardHelperCommand(authorizationUrl);
1835
+ if (helper) p.log.message(`Try this copy command:\n${helper}`);
1836
+ };
1538
1837
  const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
1539
1838
  p.log.info("Manual login selected.");
1540
1839
  p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
1541
1840
  p.log.message("After approving, copy the full callback URL (or just the 'code' value) and paste it below.");
1841
+ await maybeCopyAuthorizationUrlToClipboard(authorizationUrl);
1542
1842
  for (let attempt = 1; attempt <= 3; attempt += 1) {
1543
1843
  const response = await p.text({
1544
1844
  message: "Paste callback URL or authorization code:",
@@ -1563,6 +1863,8 @@ const promptManualAuthorizationCode = async (authorizationUrl, expectedState) =>
1563
1863
  return null;
1564
1864
  };
1565
1865
  const runDeviceOAuthFlow = async (useSpinner) => {
1866
+ p.log.info("Device OAuth flow may fail on some VPS/servers because auth.openai.com can return a Cloudflare challenge.");
1867
+ p.log.info("Recommended alternative: run login without --device-flow and use manual URL copy/paste callback completion.");
1566
1868
  const deviceFlowResult = await startDeviceAuthorizationFlow();
1567
1869
  if (deviceFlowResult.type !== "success") {
1568
1870
  p.log.error("Device OAuth flow is not available right now.");
@@ -2413,6 +2715,56 @@ const getKeychainDecryptAccessByServiceAsync = async (services) => {
2413
2715
  return parseKeychainDecryptAccessFromDump(dumpResult.output, dedupedServices);
2414
2716
  };
2415
2717
 
2718
+ //#endregion
2719
+ //#region lib/secrets/probe.ts
2720
+ const toError = (error) => error instanceof Error ? error : new Error(String(error));
2721
+ const createProbePayload = (accountId, now) => ({
2722
+ refresh: `probe-refresh-${accountId}`,
2723
+ access: `probe-access-${accountId}`,
2724
+ expires: now + 6e4,
2725
+ accountId
2726
+ });
2727
+ const payloadMatches = (expected, actual) => expected.accountId === actual.accountId && expected.refresh === actual.refresh && expected.access === actual.access && expected.expires === actual.expires;
2728
+ const runSecretStoreWriteReadProbe = async (secretStore, options = {}) => {
2729
+ const probeAccountId = options.probeAccountId ?? `cdx-doctor-probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2730
+ const payload = createProbePayload(probeAccountId, options.now ?? Date.now());
2731
+ let saveSucceeded = false;
2732
+ let result = { ok: true };
2733
+ try {
2734
+ await secretStore.save(probeAccountId, payload);
2735
+ saveSucceeded = true;
2736
+ } catch (error) {
2737
+ result = {
2738
+ ok: false,
2739
+ stage: "save",
2740
+ error: toError(error)
2741
+ };
2742
+ }
2743
+ if (result.ok) try {
2744
+ if (!payloadMatches(payload, await secretStore.load(probeAccountId))) result = {
2745
+ ok: false,
2746
+ stage: "verify",
2747
+ error: /* @__PURE__ */ new Error("Secure-store probe loaded payload does not match the saved payload.")
2748
+ };
2749
+ } catch (error) {
2750
+ result = {
2751
+ ok: false,
2752
+ stage: "load",
2753
+ error: toError(error)
2754
+ };
2755
+ }
2756
+ if (saveSucceeded) try {
2757
+ await secretStore.delete(probeAccountId);
2758
+ } catch (error) {
2759
+ if (result.ok) result = {
2760
+ ok: false,
2761
+ stage: "delete",
2762
+ error: toError(error)
2763
+ };
2764
+ }
2765
+ return result;
2766
+ };
2767
+
2416
2768
  //#endregion
2417
2769
  //#region lib/commands/doctor.ts
2418
2770
  const hasRuntimeTrustedApp = (trustedApplications, runtimeExecutablePath) => {
@@ -2422,6 +2774,23 @@ const hasRuntimeTrustedApp = (trustedApplications, runtimeExecutablePath) => {
2422
2774
  return path.basename(trustedApp).toLowerCase() === runtimeBaseName;
2423
2775
  });
2424
2776
  };
2777
+ const getSecretStoreProbeHeading = (platform) => {
2778
+ if (platform === "linux") return "Linux secure-store probe";
2779
+ if (platform === "darwin") return "macOS secure-store probe";
2780
+ if (platform === "win32") return "Windows secure-store probe";
2781
+ return null;
2782
+ };
2783
+ const createProbeAdapterForCurrentPlatform = () => {
2784
+ const currentAdapter = getSecretStoreAdapter();
2785
+ if (process.platform === "darwin" && currentAdapter.id === "macos-legacy-keychain") return createSecretStoreAdapterFromSelection("legacy-keychain", "darwin");
2786
+ return createRuntimeSecretStoreAdapter(process.platform);
2787
+ };
2788
+ const getSecretStoreProbeGuidance = (platform) => {
2789
+ if (platform === "linux") return "Suggested fix: ensure Secret Service is running/unlocked (for example gnome-keyring + secret-tool), then retry login.";
2790
+ if (platform === "darwin") return "Suggested fix: ensure Keychain Access is unlocked and allows this runtime/toolchain to store/read passwords, then retry login.";
2791
+ if (platform === "win32") return "Suggested fix: ensure Windows Credential Manager is available for this user session, then retry login.";
2792
+ return null;
2793
+ };
2425
2794
  const registerDoctorCommand = (program) => {
2426
2795
  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) => {
2427
2796
  try {
@@ -2472,6 +2841,17 @@ const registerDoctorCommand = (program) => {
2472
2841
  process.stdout.write(` Summary: ${okCount}/${status.accounts.length} configured account(s) passed secure-store load checks.\n`);
2473
2842
  }
2474
2843
  }
2844
+ const probeHeading = getSecretStoreProbeHeading(process.platform);
2845
+ if (probeHeading) {
2846
+ process.stdout.write(`\n${probeHeading}:\n`);
2847
+ const probeResult = await runSecretStoreWriteReadProbe(createProbeAdapterForCurrentPlatform());
2848
+ if (probeResult.ok) process.stdout.write(" write/read/delete probe: OK\n");
2849
+ else {
2850
+ process.stdout.write(` ⚠ ${probeResult.stage} failed: ${probeResult.error.message}\n`);
2851
+ const guidance = getSecretStoreProbeGuidance(process.platform);
2852
+ if (guidance) process.stdout.write(` ${guidance}\n`);
2853
+ }
2854
+ }
2475
2855
  if (process.platform === "darwin" && !options.checkKeychainAcl) {
2476
2856
  process.stdout.write(" ┌─ Optional keychain ACL check\n");
2477
2857
  process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
@@ -2567,7 +2947,7 @@ const registerLabelCommand = (program) => {
2567
2947
  //#region lib/commands/login.ts
2568
2948
  const registerLoginCommand = (program, deps = {}) => {
2569
2949
  const runLogin = deps.performLogin ?? performLogin;
2570
- program.command("login").description("Add a new OpenAI account via OAuth").option("--device-flow", "Use OAuth device flow instead of browser callback flow").action(async (options) => {
2950
+ program.command("login").description("Add a new OpenAI account via OAuth").option("--device-flow", "Use OAuth device flow instead of browser callback flow (manual/browser flow is recommended; device flow may fail on some VPS due to Cloudflare)").action(async (options) => {
2571
2951
  try {
2572
2952
  if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
2573
2953
  process.stderr.write("Login failed.\n");
@@ -2719,7 +3099,7 @@ const writeUpdatedAuthSummary = (result) => {
2719
3099
  //#endregion
2720
3100
  //#region lib/commands/refresh.ts
2721
3101
  const registerReloginCommand = (program) => {
2722
- program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").option("--device-flow", "Use OAuth device flow instead of browser callback flow").argument("[account]", "Account ID or label to re-login").action(async (account, options) => {
3102
+ program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").option("--device-flow", "Use OAuth device flow instead of browser callback flow (manual/browser flow is recommended; device flow may fail on some VPS due to Cloudflare)").argument("[account]", "Account ID or label to re-login").action(async (account, options) => {
2723
3103
  try {
2724
3104
  const authFlow = options.deviceFlow ? "device" : "auto";
2725
3105
  if (account) {
@@ -3043,6 +3423,189 @@ const registerUsageCommand = (program) => {
3043
3423
  });
3044
3424
  };
3045
3425
 
3426
+ //#endregion
3427
+ //#region lib/runtime/update-manager.ts
3428
+ const detectRuntime = (input = {}) => {
3429
+ const hasDenoGlobal = input.hasDenoGlobal ?? ("Deno" in globalThis && typeof globalThis.Deno !== "undefined");
3430
+ const versions = input.versions ?? process.versions;
3431
+ if (hasDenoGlobal) return "deno";
3432
+ if (typeof versions.bun === "string" && versions.bun.length > 0) return "bun";
3433
+ if (typeof versions.node === "string" && versions.node.length > 0) return "node";
3434
+ return "unknown";
3435
+ };
3436
+ const normalizePath = (value) => value.replaceAll("\\", "/").toLowerCase();
3437
+ const detectInstallManagerFromPath = (executablePath) => {
3438
+ if (!executablePath) return "unknown";
3439
+ const normalizedPath = normalizePath(executablePath);
3440
+ if (normalizedPath.includes("/.bun/install/global/node_modules/")) return "bun";
3441
+ if (normalizedPath.includes("/lib/node_modules/") || normalizedPath.includes("/npm/node_modules/")) return "npm";
3442
+ if (normalizedPath.includes("/.deno/") || normalizedPath.includes("/deno/bin/")) return "deno";
3443
+ return "unknown";
3444
+ };
3445
+ const classifyInstallContextFromPath = (executablePath) => {
3446
+ if (!executablePath) return "unknown";
3447
+ if (detectInstallManagerFromPath(executablePath) !== "unknown") return "global";
3448
+ const normalizedPath = normalizePath(executablePath);
3449
+ if (normalizedPath.endsWith("/cdx.ts") || normalizedPath.includes("/codex-switcher/") || normalizedPath.includes("/node_modules/.bin/")) return "local-or-dev";
3450
+ return "unknown";
3451
+ };
3452
+ const resolveFromRuntime = (runtime) => {
3453
+ if (runtime === "bun") return "bun";
3454
+ if (runtime === "node") return "npm";
3455
+ if (runtime === "deno") return "deno";
3456
+ return null;
3457
+ };
3458
+ const resolveUpdateManager = (input) => {
3459
+ if (input.requestedManager !== "auto") return {
3460
+ ok: true,
3461
+ manager: input.requestedManager,
3462
+ source: "explicit"
3463
+ };
3464
+ if (input.installManager !== "unknown") return {
3465
+ ok: true,
3466
+ manager: input.installManager,
3467
+ source: "install-manager"
3468
+ };
3469
+ const fromRuntime = resolveFromRuntime(input.runtime);
3470
+ if (fromRuntime) return {
3471
+ ok: true,
3472
+ manager: fromRuntime,
3473
+ source: "runtime"
3474
+ };
3475
+ return { ok: false };
3476
+ };
3477
+ const buildUpdateInstallCommand = (manager, packageName) => {
3478
+ if (manager === "bun") return {
3479
+ command: "bun",
3480
+ args: [
3481
+ "add",
3482
+ "-g",
3483
+ `${packageName}@latest`
3484
+ ]
3485
+ };
3486
+ if (manager === "npm") return {
3487
+ command: "npm",
3488
+ args: [
3489
+ "i",
3490
+ "-g",
3491
+ `${packageName}@latest`
3492
+ ]
3493
+ };
3494
+ return {
3495
+ command: "deno",
3496
+ args: [
3497
+ "install",
3498
+ "-g",
3499
+ "-f",
3500
+ "-A",
3501
+ "-n",
3502
+ "cdx",
3503
+ `npm:${packageName}@latest`
3504
+ ]
3505
+ };
3506
+ };
3507
+
3508
+ //#endregion
3509
+ //#region lib/commands/update-self.ts
3510
+ const PACKAGE_NAME = "@bjesuiter/codex-switcher";
3511
+ const quoteIfNeeded = (value) => {
3512
+ if (value.includes(" ")) return JSON.stringify(value);
3513
+ return value;
3514
+ };
3515
+ const formatShellCommand = (command, args) => [command, ...args].map(quoteIfNeeded).join(" ");
3516
+ const executeUpdate = async (command, args) => {
3517
+ await new Promise((resolve, reject) => {
3518
+ const child = spawn(command, args, { stdio: "inherit" });
3519
+ child.once("error", (error) => {
3520
+ reject(error);
3521
+ });
3522
+ child.once("close", (code) => {
3523
+ if (code === 0) {
3524
+ resolve();
3525
+ return;
3526
+ }
3527
+ reject(/* @__PURE__ */ new Error(`Update command failed with exit code ${code ?? "unknown"}.`));
3528
+ });
3529
+ });
3530
+ };
3531
+ const registerUpdateSelfCommand = (program) => {
3532
+ program.command("update-self").description("Update cdx to the latest version").option("--manager <manager>", "Select update manager (auto|bun|npm|deno)", "auto").option("--dry-run", "Print selected manager and update command without executing").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
3533
+ try {
3534
+ const requestedManager = options.manager ?? "auto";
3535
+ if (![
3536
+ "auto",
3537
+ "bun",
3538
+ "npm",
3539
+ "deno"
3540
+ ].includes(requestedManager)) {
3541
+ process.stderr.write(`Invalid value '${requestedManager}' for --manager. Allowed values: auto, bun, npm, deno.\n`);
3542
+ process.exit(1);
3543
+ }
3544
+ const runtime = detectRuntime();
3545
+ const executablePath = process.argv[1];
3546
+ const installManager = detectInstallManagerFromPath(executablePath);
3547
+ const installContext = classifyInstallContextFromPath(executablePath);
3548
+ if (requestedManager === "auto" && installManager === "unknown" && installContext === "local-or-dev") {
3549
+ process.stderr.write("Refusing to auto-update from a local/dev checkout. Re-run with --manager bun|npm|deno.\n");
3550
+ process.exit(1);
3551
+ }
3552
+ const resolvedManager = resolveUpdateManager({
3553
+ requestedManager,
3554
+ runtime,
3555
+ installManager
3556
+ });
3557
+ process.stdout.write(`Runtime: ${runtime}\n`);
3558
+ process.stdout.write(`Detected install manager: ${installManager}\n`);
3559
+ if (!resolvedManager.ok) {
3560
+ process.stderr.write("Could not determine update manager automatically. Re-run with --manager bun|npm|deno.\n");
3561
+ process.stderr.write("Manual update commands:\n");
3562
+ process.stderr.write(` bun: ${formatShellCommand("bun", [
3563
+ "add",
3564
+ "-g",
3565
+ `${PACKAGE_NAME}@latest`
3566
+ ])}\n`);
3567
+ process.stderr.write(` npm: ${formatShellCommand("npm", [
3568
+ "i",
3569
+ "-g",
3570
+ `${PACKAGE_NAME}@latest`
3571
+ ])}\n`);
3572
+ process.stderr.write(` deno: ${formatShellCommand("deno", [
3573
+ "install",
3574
+ "-g",
3575
+ "-f",
3576
+ "-A",
3577
+ "-n",
3578
+ "cdx",
3579
+ `npm:${PACKAGE_NAME}@latest`
3580
+ ])}\n`);
3581
+ process.exit(1);
3582
+ }
3583
+ const selectedManager = resolvedManager.manager;
3584
+ const command = buildUpdateInstallCommand(selectedManager, PACKAGE_NAME);
3585
+ const printableCommand = formatShellCommand(command.command, command.args);
3586
+ process.stdout.write(`Selected manager: ${selectedManager}\n`);
3587
+ process.stdout.write(`Source: ${resolvedManager.source}\n`);
3588
+ process.stdout.write(`Command: ${printableCommand}\n`);
3589
+ if (options.dryRun) return;
3590
+ if (!options.yes) {
3591
+ const confirmed = await p.confirm({
3592
+ message: `Run update command now?\n${printableCommand}`,
3593
+ initialValue: true
3594
+ });
3595
+ if (p.isCancel(confirmed) || !confirmed) {
3596
+ process.stderr.write("Update cancelled.\n");
3597
+ process.exit(1);
3598
+ }
3599
+ }
3600
+ await executeUpdate(command.command, command.args);
3601
+ process.stdout.write("Update completed.\n");
3602
+ process.stdout.write("Run `cdx version` to verify the installed version.\n");
3603
+ } catch (error) {
3604
+ exitWithCommandError(error);
3605
+ }
3606
+ });
3607
+ };
3608
+
3046
3609
  //#endregion
3047
3610
  //#region lib/commands/version.ts
3048
3611
  const registerVersionCommand = (program, version) => {
@@ -3162,6 +3725,7 @@ const createProgram = (deps = {}) => {
3162
3725
  registerStatusCommand(program);
3163
3726
  registerDoctorCommand(program);
3164
3727
  registerUsageCommand(program);
3728
+ registerUpdateSelfCommand(program);
3165
3729
  registerHelpCommand(program);
3166
3730
  registerVersionCommand(program, version);
3167
3731
  registerDefaultInteractiveAction(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.7.4",
3
+ "version": "1.8.1",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {