@bjesuiter/codex-switcher 1.7.4 → 1.8.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 +16 -7
  2. package/cdx.mjs +517 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,12 +6,19 @@ 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.0
10
+
11
+ #### Features
12
+
13
+ - Added manual OAuth URL clipboard assist for login/relogin fallback flow, including an opt-in copy prompt and non-blocking behavior.
14
+ - Added cross-platform clipboard copy strategies for auth URLs (local clipboard commands + OSC52 terminal fallback).
15
+ - Added tmux/screen OSC52 framing support and fallback copy-command hints when auto-copy is unavailable.
10
16
 
11
17
  #### Fixes
12
18
 
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.
19
+ - Clarified fallback guidance: manual URL copy/paste flow is now the recommended browser-launch fallback, while `--device-flow` may fail on some VPS/server IPs due to Cloudflare challenges.
20
+ - Linux secure-store login reliability: treat native Secret Service "no matching entry" responses as missing-entry cases and improve unavailable-store error guidance.
21
+ - Added Mosh-specific clipboard heuristics/warnings so OSC52 copy reports are less misleading when clipboard updates are unreliable.
15
22
 
16
23
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
17
24
 
@@ -153,12 +160,12 @@ cdx migrate-secrets
153
160
  | Command | Description |
154
161
  |---------|-------------|
155
162
  | `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) |
163
+ | `cdx login` | Add a new OpenAI account via OAuth (recommended default flow; supports manual URL copy/paste callback completion with optional clipboard copy assist) |
164
+ | `cdx login --device-flow` | Add account using OAuth device flow (may fail on some VPS/server IPs due to Cloudflare challenges) |
158
165
  | `cdx relogin` | Re-authenticate an existing account via OAuth |
159
- | `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow |
166
+ | `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow (may fail on some VPS/server IPs due to Cloudflare challenges) |
160
167
  | `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 |
168
+ | `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
169
  | `cdx switch` | Switch account (interactive picker) |
163
170
  | `cdx switch --next` | Cycle to next account |
164
171
  | `cdx switch <id>` | Switch to specific account |
@@ -177,6 +184,8 @@ cdx migrate-secrets
177
184
  | `cdx --version` | Show version |
178
185
  | `cdx --secret-store legacy-keychain <command>` | Override configured backend for this run (macOS legacy keychain) |
179
186
 
187
+ > 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.
188
+
180
189
  ### Shell completion
181
190
 
182
191
  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.0";
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,36 @@ 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
+ ];
668
+ const STORE_UNAVAILABLE_MARKERS = [
669
+ "unable to initialize linux secure-store backend",
670
+ "no keyring backend could be initialized",
671
+ "native keyring module not available",
672
+ "secret service operation failed",
673
+ "dbus",
674
+ "d-bus",
675
+ "org.freedesktop.secrets",
676
+ "service unavailable"
677
+ ];
476
678
  let backendInitPromise$2 = null;
477
679
  let selectedBackend$2 = null;
680
+ const getErrorMessage = (error) => error instanceof Error ? error.message : String(error);
681
+ const classifyLinuxSecureStoreError = (error) => {
682
+ const message = getErrorMessage(error).toLowerCase();
683
+ if (MISSING_ENTRY_MARKERS.some((marker) => message.includes(marker))) return "missing_entry";
684
+ if (STORE_UNAVAILABLE_MARKERS.some((marker) => message.includes(marker))) return "store_unavailable";
685
+ return "other";
686
+ };
687
+ const createLinuxSecureStoreUnavailableError = (details) => {
688
+ const guidance = "Linux secure store is unavailable. Ensure Secret Service is installed/running (for example gnome-keyring with secret-tool), then retry login.";
689
+ if (!details) return new Error(guidance);
690
+ return /* @__PURE__ */ new Error(`${guidance} Technical details: ${details}`);
691
+ };
478
692
  const tryUseBackend$2 = async (backendId) => {
479
693
  try {
480
694
  await useBackend(backendId, getCrossKeychainBackendOverrides());
@@ -492,6 +706,20 @@ const selectBackend$2 = async () => {
492
706
  if (await tryUseBackend$2("secret-service")) return "secret-service";
493
707
  throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
494
708
  };
709
+ const setActiveBackend = (backendId) => {
710
+ selectedBackend$2 = backendId;
711
+ backendInitPromise$2 = Promise.resolve();
712
+ };
713
+ const trySwitchBackend = async (backendId, options = {}) => {
714
+ 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.");
715
+ try {
716
+ await useBackend(backendId, getCrossKeychainBackendOverrides());
717
+ setActiveBackend(backendId);
718
+ return true;
719
+ } catch {
720
+ return false;
721
+ }
722
+ };
495
723
  const ensureLinuxBackend = async (options = {}) => {
496
724
  if (!backendInitPromise$2) backendInitPromise$2 = (async () => {
497
725
  selectedBackend$2 = await selectBackend$2();
@@ -517,17 +745,75 @@ const parsePayload$2 = (accountId, raw) => {
517
745
  return parsed;
518
746
  };
519
747
  const withService$1 = async (accountId, run, options = {}) => {
520
- await ensureLinuxBackend(options);
521
- return run(getLinuxCrossKeychainService(accountId));
748
+ try {
749
+ await ensureLinuxBackend(options);
750
+ } catch (error) {
751
+ throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
752
+ }
753
+ try {
754
+ return await run(getLinuxCrossKeychainService(accountId));
755
+ } catch (error) {
756
+ if (classifyLinuxSecureStoreError(error) === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
757
+ throw error;
758
+ }
759
+ };
760
+ const trySaveWithSecretServiceFallback = async (accountId, serializedPayload) => {
761
+ if (!await trySwitchBackend("secret-service", { forWrite: true })) return {
762
+ ok: false,
763
+ error: /* @__PURE__ */ new Error("Unable to switch Linux secure-store backend to secret-service fallback.")
764
+ };
765
+ try {
766
+ await setPassword(getLinuxCrossKeychainService(accountId), accountId, serializedPayload);
767
+ return { ok: true };
768
+ } catch (error) {
769
+ return {
770
+ ok: false,
771
+ error
772
+ };
773
+ }
774
+ };
775
+ const saveLinuxCrossKeychainPayload = async (accountId, payload) => {
776
+ const serialized = JSON.stringify(payload);
777
+ try {
778
+ await withService$1(accountId, (service) => setPassword(service, accountId, serialized), { forWrite: true });
779
+ return;
780
+ } catch (error) {
781
+ const kind = classifyLinuxSecureStoreError(error);
782
+ if (kind === "missing_entry" && selectedBackend$2 === "native-linux") {
783
+ const fallbackResult = await trySaveWithSecretServiceFallback(accountId, serialized);
784
+ if (fallbackResult.ok) return;
785
+ throw createLinuxSecureStoreUnavailableError(`Native backend could not create the credential entry (${getErrorMessage(error)}). Fallback secret-service backend also failed (${getErrorMessage(fallbackResult.error)}).`);
786
+ }
787
+ if (kind === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
788
+ throw error;
789
+ }
522
790
  };
523
- const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
524
791
  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);
792
+ try {
793
+ const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
794
+ if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
795
+ return parsePayload$2(accountId, raw);
796
+ } catch (error) {
797
+ if (classifyLinuxSecureStoreError(error) === "missing_entry") throw new Error(`No stored credentials found for account ${accountId}.`);
798
+ throw error;
799
+ }
800
+ };
801
+ const deleteLinuxCrossKeychainPayload = async (accountId) => {
802
+ try {
803
+ await withService$1(accountId, (service) => deletePassword(service, accountId));
804
+ } catch (error) {
805
+ if (classifyLinuxSecureStoreError(error) === "missing_entry") return;
806
+ throw error;
807
+ }
808
+ };
809
+ const linuxCrossKeychainPayloadExists = async (accountId) => {
810
+ try {
811
+ return await withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
812
+ } catch (error) {
813
+ if (classifyLinuxSecureStoreError(error) === "missing_entry") return false;
814
+ throw error;
815
+ }
528
816
  };
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
817
 
532
818
  //#endregion
533
819
  //#region lib/secrets/macos-cross-keychain.ts
@@ -777,7 +1063,8 @@ const windowsCrossKeychainPayloadExists = async (accountId) => withWindowsBacken
777
1063
  const MISSING_SECRET_STORE_ERROR_MARKERS = [
778
1064
  "No stored credentials found",
779
1065
  "No Keychain payload found",
780
- "Password not found"
1066
+ "Password not found",
1067
+ "no matching entry found in secure storage"
781
1068
  ];
782
1069
  const isMissingSecretStoreEntryError = (error) => {
783
1070
  if (!(error instanceof Error)) return false;
@@ -1467,33 +1754,19 @@ const promptBrowserFallbackChoice = async () => {
1467
1754
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1468
1755
  const selected = remoteHint ? "device" : "manual";
1469
1756
  p.log.info(`Non-interactive terminal detected. Falling back to ${selected === "device" ? "device OAuth flow" : "manual URL copy/paste flow"}.`);
1757
+ 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
1758
  return selected;
1471
1759
  }
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
- ] : [
1760
+ const options = [
1488
1761
  {
1489
1762
  value: "manual",
1490
- label: "Finish manually by copying URL",
1491
- hint: "Open URL on any machine and paste callback URL/code back here"
1763
+ label: "Finish manually by copying URL (recommended)",
1764
+ 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
1765
  },
1493
1766
  {
1494
1767
  value: "device",
1495
1768
  label: "Use device OAuth flow",
1496
- hint: "Best for headless/remote environments"
1769
+ hint: "May fail on some VPS/servers due to Cloudflare challenge"
1497
1770
  },
1498
1771
  {
1499
1772
  value: "cancel",
@@ -1535,10 +1808,33 @@ const promptPortConflictChoice = async (listeningProcess) => {
1535
1808
  if (p.isCancel(selection)) return "cancel";
1536
1809
  return selection;
1537
1810
  };
1811
+ const maybeCopyAuthorizationUrlToClipboard = async (authorizationUrl) => {
1812
+ if (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
1813
+ const shouldCopy = await p.confirm({
1814
+ message: "Copy login URL to clipboard now?",
1815
+ initialValue: true
1816
+ });
1817
+ if (p.isCancel(shouldCopy) || !shouldCopy) return;
1818
+ }
1819
+ const copyResult = tryCopyToClipboard(authorizationUrl);
1820
+ if (copyResult.ok) {
1821
+ p.log.success(`Copied login URL to clipboard via ${copyResult.method}.`);
1822
+ if (copyResult.warning) {
1823
+ p.log.warning(copyResult.warning);
1824
+ const helper = buildClipboardHelperCommand(authorizationUrl);
1825
+ if (helper) p.log.message(`If needed, run this copy command instead:\n${helper}`);
1826
+ }
1827
+ return;
1828
+ }
1829
+ p.log.warning(`Could not copy login URL automatically${copyResult.error ? ` (${copyResult.error})` : ""}.`);
1830
+ const helper = buildClipboardHelperCommand(authorizationUrl);
1831
+ if (helper) p.log.message(`Try this copy command:\n${helper}`);
1832
+ };
1538
1833
  const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
1539
1834
  p.log.info("Manual login selected.");
1540
1835
  p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
1541
1836
  p.log.message("After approving, copy the full callback URL (or just the 'code' value) and paste it below.");
1837
+ await maybeCopyAuthorizationUrlToClipboard(authorizationUrl);
1542
1838
  for (let attempt = 1; attempt <= 3; attempt += 1) {
1543
1839
  const response = await p.text({
1544
1840
  message: "Paste callback URL or authorization code:",
@@ -1563,6 +1859,8 @@ const promptManualAuthorizationCode = async (authorizationUrl, expectedState) =>
1563
1859
  return null;
1564
1860
  };
1565
1861
  const runDeviceOAuthFlow = async (useSpinner) => {
1862
+ p.log.info("Device OAuth flow may fail on some VPS/servers because auth.openai.com can return a Cloudflare challenge.");
1863
+ p.log.info("Recommended alternative: run login without --device-flow and use manual URL copy/paste callback completion.");
1566
1864
  const deviceFlowResult = await startDeviceAuthorizationFlow();
1567
1865
  if (deviceFlowResult.type !== "success") {
1568
1866
  p.log.error("Device OAuth flow is not available right now.");
@@ -2567,7 +2865,7 @@ const registerLabelCommand = (program) => {
2567
2865
  //#region lib/commands/login.ts
2568
2866
  const registerLoginCommand = (program, deps = {}) => {
2569
2867
  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) => {
2868
+ 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
2869
  try {
2572
2870
  if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
2573
2871
  process.stderr.write("Login failed.\n");
@@ -2719,7 +3017,7 @@ const writeUpdatedAuthSummary = (result) => {
2719
3017
  //#endregion
2720
3018
  //#region lib/commands/refresh.ts
2721
3019
  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) => {
3020
+ 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
3021
  try {
2724
3022
  const authFlow = options.deviceFlow ? "device" : "auto";
2725
3023
  if (account) {
@@ -3043,6 +3341,189 @@ const registerUsageCommand = (program) => {
3043
3341
  });
3044
3342
  };
3045
3343
 
3344
+ //#endregion
3345
+ //#region lib/runtime/update-manager.ts
3346
+ const detectRuntime = (input = {}) => {
3347
+ const hasDenoGlobal = input.hasDenoGlobal ?? ("Deno" in globalThis && typeof globalThis.Deno !== "undefined");
3348
+ const versions = input.versions ?? process.versions;
3349
+ if (hasDenoGlobal) return "deno";
3350
+ if (typeof versions.bun === "string" && versions.bun.length > 0) return "bun";
3351
+ if (typeof versions.node === "string" && versions.node.length > 0) return "node";
3352
+ return "unknown";
3353
+ };
3354
+ const normalizePath = (value) => value.replaceAll("\\", "/").toLowerCase();
3355
+ const detectInstallManagerFromPath = (executablePath) => {
3356
+ if (!executablePath) return "unknown";
3357
+ const normalizedPath = normalizePath(executablePath);
3358
+ if (normalizedPath.includes("/.bun/install/global/node_modules/")) return "bun";
3359
+ if (normalizedPath.includes("/lib/node_modules/") || normalizedPath.includes("/npm/node_modules/")) return "npm";
3360
+ if (normalizedPath.includes("/.deno/") || normalizedPath.includes("/deno/bin/")) return "deno";
3361
+ return "unknown";
3362
+ };
3363
+ const classifyInstallContextFromPath = (executablePath) => {
3364
+ if (!executablePath) return "unknown";
3365
+ if (detectInstallManagerFromPath(executablePath) !== "unknown") return "global";
3366
+ const normalizedPath = normalizePath(executablePath);
3367
+ if (normalizedPath.endsWith("/cdx.ts") || normalizedPath.includes("/codex-switcher/") || normalizedPath.includes("/node_modules/.bin/")) return "local-or-dev";
3368
+ return "unknown";
3369
+ };
3370
+ const resolveFromRuntime = (runtime) => {
3371
+ if (runtime === "bun") return "bun";
3372
+ if (runtime === "node") return "npm";
3373
+ if (runtime === "deno") return "deno";
3374
+ return null;
3375
+ };
3376
+ const resolveUpdateManager = (input) => {
3377
+ if (input.requestedManager !== "auto") return {
3378
+ ok: true,
3379
+ manager: input.requestedManager,
3380
+ source: "explicit"
3381
+ };
3382
+ if (input.installManager !== "unknown") return {
3383
+ ok: true,
3384
+ manager: input.installManager,
3385
+ source: "install-manager"
3386
+ };
3387
+ const fromRuntime = resolveFromRuntime(input.runtime);
3388
+ if (fromRuntime) return {
3389
+ ok: true,
3390
+ manager: fromRuntime,
3391
+ source: "runtime"
3392
+ };
3393
+ return { ok: false };
3394
+ };
3395
+ const buildUpdateInstallCommand = (manager, packageName) => {
3396
+ if (manager === "bun") return {
3397
+ command: "bun",
3398
+ args: [
3399
+ "add",
3400
+ "-g",
3401
+ `${packageName}@latest`
3402
+ ]
3403
+ };
3404
+ if (manager === "npm") return {
3405
+ command: "npm",
3406
+ args: [
3407
+ "i",
3408
+ "-g",
3409
+ `${packageName}@latest`
3410
+ ]
3411
+ };
3412
+ return {
3413
+ command: "deno",
3414
+ args: [
3415
+ "install",
3416
+ "-g",
3417
+ "-f",
3418
+ "-A",
3419
+ "-n",
3420
+ "cdx",
3421
+ `npm:${packageName}@latest`
3422
+ ]
3423
+ };
3424
+ };
3425
+
3426
+ //#endregion
3427
+ //#region lib/commands/update-self.ts
3428
+ const PACKAGE_NAME = "@bjesuiter/codex-switcher";
3429
+ const quoteIfNeeded = (value) => {
3430
+ if (value.includes(" ")) return JSON.stringify(value);
3431
+ return value;
3432
+ };
3433
+ const formatShellCommand = (command, args) => [command, ...args].map(quoteIfNeeded).join(" ");
3434
+ const executeUpdate = async (command, args) => {
3435
+ await new Promise((resolve, reject) => {
3436
+ const child = spawn(command, args, { stdio: "inherit" });
3437
+ child.once("error", (error) => {
3438
+ reject(error);
3439
+ });
3440
+ child.once("close", (code) => {
3441
+ if (code === 0) {
3442
+ resolve();
3443
+ return;
3444
+ }
3445
+ reject(/* @__PURE__ */ new Error(`Update command failed with exit code ${code ?? "unknown"}.`));
3446
+ });
3447
+ });
3448
+ };
3449
+ const registerUpdateSelfCommand = (program) => {
3450
+ 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) => {
3451
+ try {
3452
+ const requestedManager = options.manager ?? "auto";
3453
+ if (![
3454
+ "auto",
3455
+ "bun",
3456
+ "npm",
3457
+ "deno"
3458
+ ].includes(requestedManager)) {
3459
+ process.stderr.write(`Invalid value '${requestedManager}' for --manager. Allowed values: auto, bun, npm, deno.\n`);
3460
+ process.exit(1);
3461
+ }
3462
+ const runtime = detectRuntime();
3463
+ const executablePath = process.argv[1];
3464
+ const installManager = detectInstallManagerFromPath(executablePath);
3465
+ const installContext = classifyInstallContextFromPath(executablePath);
3466
+ if (requestedManager === "auto" && installManager === "unknown" && installContext === "local-or-dev") {
3467
+ process.stderr.write("Refusing to auto-update from a local/dev checkout. Re-run with --manager bun|npm|deno.\n");
3468
+ process.exit(1);
3469
+ }
3470
+ const resolvedManager = resolveUpdateManager({
3471
+ requestedManager,
3472
+ runtime,
3473
+ installManager
3474
+ });
3475
+ process.stdout.write(`Runtime: ${runtime}\n`);
3476
+ process.stdout.write(`Detected install manager: ${installManager}\n`);
3477
+ if (!resolvedManager.ok) {
3478
+ process.stderr.write("Could not determine update manager automatically. Re-run with --manager bun|npm|deno.\n");
3479
+ process.stderr.write("Manual update commands:\n");
3480
+ process.stderr.write(` bun: ${formatShellCommand("bun", [
3481
+ "add",
3482
+ "-g",
3483
+ `${PACKAGE_NAME}@latest`
3484
+ ])}\n`);
3485
+ process.stderr.write(` npm: ${formatShellCommand("npm", [
3486
+ "i",
3487
+ "-g",
3488
+ `${PACKAGE_NAME}@latest`
3489
+ ])}\n`);
3490
+ process.stderr.write(` deno: ${formatShellCommand("deno", [
3491
+ "install",
3492
+ "-g",
3493
+ "-f",
3494
+ "-A",
3495
+ "-n",
3496
+ "cdx",
3497
+ `npm:${PACKAGE_NAME}@latest`
3498
+ ])}\n`);
3499
+ process.exit(1);
3500
+ }
3501
+ const selectedManager = resolvedManager.manager;
3502
+ const command = buildUpdateInstallCommand(selectedManager, PACKAGE_NAME);
3503
+ const printableCommand = formatShellCommand(command.command, command.args);
3504
+ process.stdout.write(`Selected manager: ${selectedManager}\n`);
3505
+ process.stdout.write(`Source: ${resolvedManager.source}\n`);
3506
+ process.stdout.write(`Command: ${printableCommand}\n`);
3507
+ if (options.dryRun) return;
3508
+ if (!options.yes) {
3509
+ const confirmed = await p.confirm({
3510
+ message: `Run update command now?\n${printableCommand}`,
3511
+ initialValue: true
3512
+ });
3513
+ if (p.isCancel(confirmed) || !confirmed) {
3514
+ process.stderr.write("Update cancelled.\n");
3515
+ process.exit(1);
3516
+ }
3517
+ }
3518
+ await executeUpdate(command.command, command.args);
3519
+ process.stdout.write("Update completed.\n");
3520
+ process.stdout.write("Run `cdx version` to verify the installed version.\n");
3521
+ } catch (error) {
3522
+ exitWithCommandError(error);
3523
+ }
3524
+ });
3525
+ };
3526
+
3046
3527
  //#endregion
3047
3528
  //#region lib/commands/version.ts
3048
3529
  const registerVersionCommand = (program, version) => {
@@ -3162,6 +3643,7 @@ const createProgram = (deps = {}) => {
3162
3643
  registerStatusCommand(program);
3163
3644
  registerDoctorCommand(program);
3164
3645
  registerUsageCommand(program);
3646
+ registerUpdateSelfCommand(program);
3165
3647
  registerHelpCommand(program);
3166
3648
  registerVersionCommand(program, version);
3167
3649
  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.0",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {