@bjesuiter/codex-switcher 1.7.2 → 1.7.3

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 +6 -2
  2. package/cdx.mjs +171 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,11 +6,15 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
6
6
 
7
7
  ## Latest Changes
8
8
 
9
- ### 1.7.2
9
+ ### 1.7.3
10
10
 
11
11
  #### Fixes
12
12
 
13
- - Improve device OAuth failure diagnostics during login/relogin. When device flow startup or polling fails, `cdx` now prints technical details (HTTP status, OAuth error code, and response/body snippets where available) instead of only showing a generic "not available right now" message.
13
+ - Add recovery for OAuth callback port conflicts (`127.0.0.1:1455`) during login/relogin:
14
+ - detect `EADDRINUSE` as a port-in-use condition
15
+ - prompt to kill the existing listener and retry, switch to device flow, or cancel
16
+ - show detected PID/command details when available before confirmation
17
+ - Improve callback server startup diagnostics by exposing startup error reason/code details, making port/listen failures easier to troubleshoot.
14
18
 
15
19
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
16
20
 
package/cdx.mjs CHANGED
@@ -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.2";
18
+ var version = "1.7.3";
19
19
 
20
20
  //#endregion
21
21
  //#region lib/platform/path-resolver.ts
@@ -1310,10 +1310,14 @@ const startOAuthServer = (state) => {
1310
1310
  },
1311
1311
  waitForCode: () => codePromise
1312
1312
  });
1313
- }).on("error", () => {
1313
+ }).on("error", (error) => {
1314
+ const err = error;
1314
1315
  resolve({
1315
1316
  port: CALLBACK_PORT,
1316
1317
  ready: false,
1318
+ reason: err?.code === "EADDRINUSE" ? "port_in_use" : "listen_failed",
1319
+ ...typeof err?.message === "string" ? { error: err.message } : {},
1320
+ ...typeof err?.code === "string" ? { errorCode: err.code } : {},
1317
1321
  close: () => {
1318
1322
  try {
1319
1323
  server.close();
@@ -1333,6 +1337,101 @@ const isLikelyRemoteEnvironment = () => {
1333
1337
  if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
1334
1338
  return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
1335
1339
  };
1340
+ const parseLsofListeningProcess = (output) => {
1341
+ const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1342
+ let pid = null;
1343
+ let command;
1344
+ for (const line of lines) {
1345
+ if (line.startsWith("p") && pid === null) {
1346
+ const parsedPid = Number.parseInt(line.slice(1), 10);
1347
+ if (!Number.isNaN(parsedPid) && parsedPid > 0) pid = parsedPid;
1348
+ continue;
1349
+ }
1350
+ if (line.startsWith("c") && pid !== null && !command) {
1351
+ const parsedCommand = line.slice(1).trim();
1352
+ if (parsedCommand) command = parsedCommand;
1353
+ }
1354
+ if (pid !== null && command) break;
1355
+ }
1356
+ if (pid === null) return null;
1357
+ return {
1358
+ pid,
1359
+ ...command ? { command } : {}
1360
+ };
1361
+ };
1362
+ const parseWindowsNetstatListeningPid = (output, port) => {
1363
+ const lines = output.split(/\r?\n/);
1364
+ const portSuffix = `:${port}`;
1365
+ for (const rawLine of lines) {
1366
+ const line = rawLine.trim();
1367
+ if (!line || !/LISTENING/i.test(line) || !line.includes(portSuffix)) continue;
1368
+ const pidRaw = line.split(/\s+/).at(-1);
1369
+ const parsedPid = pidRaw ? Number.parseInt(pidRaw, 10) : NaN;
1370
+ if (!Number.isNaN(parsedPid) && parsedPid > 0) return parsedPid;
1371
+ }
1372
+ return null;
1373
+ };
1374
+ const findListeningProcessOnPort = (port, platform = process.platform) => {
1375
+ if (platform === "win32") {
1376
+ const netstat = Bun.spawnSync({
1377
+ cmd: [
1378
+ "netstat",
1379
+ "-ano",
1380
+ "-p",
1381
+ "tcp"
1382
+ ],
1383
+ stdout: "pipe",
1384
+ stderr: "pipe"
1385
+ });
1386
+ if (netstat.exitCode !== 0) return null;
1387
+ const pid = parseWindowsNetstatListeningPid(Buffer.from(netstat.stdout).toString("utf8"), port);
1388
+ if (!pid) return null;
1389
+ const tasklist = Bun.spawnSync({
1390
+ cmd: [
1391
+ "tasklist",
1392
+ "/FI",
1393
+ `PID eq ${pid}`,
1394
+ "/FO",
1395
+ "CSV",
1396
+ "/NH"
1397
+ ],
1398
+ stdout: "pipe",
1399
+ stderr: "pipe"
1400
+ });
1401
+ if (tasklist.exitCode !== 0) return { pid };
1402
+ const line = Buffer.from(tasklist.stdout).toString("utf8").trim().split(/\r?\n/)[0] ?? "";
1403
+ if (!line || /No tasks are running/i.test(line)) return { pid };
1404
+ const command = line.replace(/^"|"$/g, "").split("\",\"")[0]?.trim();
1405
+ return {
1406
+ pid,
1407
+ ...command ? { command } : {}
1408
+ };
1409
+ }
1410
+ const lsof = Bun.spawnSync({
1411
+ cmd: [
1412
+ "lsof",
1413
+ "-nP",
1414
+ `-iTCP:${port}`,
1415
+ "-sTCP:LISTEN",
1416
+ "-FpPc"
1417
+ ],
1418
+ stdout: "pipe",
1419
+ stderr: "pipe"
1420
+ });
1421
+ if (lsof.exitCode !== 0) return null;
1422
+ return parseLsofListeningProcess(Buffer.from(lsof.stdout).toString("utf8"));
1423
+ };
1424
+ const killProcessByPid = (pid) => {
1425
+ try {
1426
+ process.kill(pid, "SIGTERM");
1427
+ return { ok: true };
1428
+ } catch (error) {
1429
+ return {
1430
+ ok: false,
1431
+ error: error instanceof Error ? error.message : String(error)
1432
+ };
1433
+ }
1434
+ };
1336
1435
  const parseOAuthCallbackInput = (input) => {
1337
1436
  const trimmed = input.trim();
1338
1437
  if (!trimmed) return null;
@@ -1399,6 +1498,34 @@ const promptBrowserFallbackChoice = async () => {
1399
1498
  if (p.isCancel(selection)) return "cancel";
1400
1499
  return selection;
1401
1500
  };
1501
+ const promptPortConflictChoice = async (listeningProcess) => {
1502
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1503
+ p.log.info("Non-interactive terminal detected. Falling back to device OAuth flow.");
1504
+ return "device";
1505
+ }
1506
+ const killHint = listeningProcess ? `PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}` : "Attempt to free port and retry";
1507
+ const selection = await p.select({
1508
+ message: `Port ${CALLBACK_PORT} is already in use. How do you want to continue?`,
1509
+ options: [
1510
+ {
1511
+ value: "kill_and_retry",
1512
+ label: `Kill existing listener on port ${CALLBACK_PORT} and retry browser flow`,
1513
+ hint: killHint
1514
+ },
1515
+ {
1516
+ value: "device",
1517
+ label: "Continue with OAuth device flow",
1518
+ hint: "No local callback server required"
1519
+ },
1520
+ {
1521
+ value: "cancel",
1522
+ label: "Cancel login"
1523
+ }
1524
+ ]
1525
+ });
1526
+ if (p.isCancel(selection)) return "cancel";
1527
+ return selection;
1528
+ };
1402
1529
  const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
1403
1530
  p.log.info("Manual login selected.");
1404
1531
  p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
@@ -1492,9 +1619,48 @@ const runDeviceOAuthFlow = async (useSpinner) => {
1492
1619
  };
1493
1620
  const requestTokenViaOAuth = async (flow, options) => {
1494
1621
  if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
1495
- const server = await startOAuthServer(flow.state);
1496
- if (!server.ready) {
1497
- p.log.error("Failed to start local server on port 1455.");
1622
+ let server = await startOAuthServer(flow.state);
1623
+ if (!server.ready) if (server.reason === "port_in_use") {
1624
+ p.log.warning(`Local callback server port ${CALLBACK_PORT} is already in use.`);
1625
+ const listeningProcess = findListeningProcessOnPort(CALLBACK_PORT);
1626
+ if (listeningProcess) p.log.info(`Detected listener on port ${CALLBACK_PORT}: PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}`);
1627
+ const choice = await promptPortConflictChoice(listeningProcess);
1628
+ if (choice === "cancel") {
1629
+ p.log.info("Login cancelled.");
1630
+ return null;
1631
+ }
1632
+ if (choice === "device") return runDeviceOAuthFlow(options.useSpinner);
1633
+ if (listeningProcess) {
1634
+ const confirmed = await p.confirm({
1635
+ message: `Kill PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}?`,
1636
+ initialValue: false
1637
+ });
1638
+ if (p.isCancel(confirmed) || !confirmed) {
1639
+ p.log.info("Cancelled. No process was terminated.");
1640
+ return null;
1641
+ }
1642
+ const killResult = killProcessByPid(listeningProcess.pid);
1643
+ if (!killResult.ok) {
1644
+ p.log.error(`Failed to terminate PID ${listeningProcess.pid}.`);
1645
+ p.log.error(`Technical details: ${killResult.error ?? "unknown error"}`);
1646
+ p.log.info("Try device flow instead: cdx login --device-flow");
1647
+ return null;
1648
+ }
1649
+ p.log.success(`Sent SIGTERM to PID ${listeningProcess.pid}. Retrying local server...`);
1650
+ await sleep(250);
1651
+ } else p.log.warning(`Could not identify which process is listening on port ${CALLBACK_PORT}. Retrying once...`);
1652
+ server = await startOAuthServer(flow.state);
1653
+ if (!server.ready) {
1654
+ p.log.error(`Failed to start local server on port ${CALLBACK_PORT} after retry.`);
1655
+ if (server.error) p.log.error(`Technical details: ${server.error}`);
1656
+ if (server.errorCode) p.log.error(`Error code: ${server.errorCode}`);
1657
+ p.log.info("Try device flow instead: cdx login --device-flow");
1658
+ return null;
1659
+ }
1660
+ } else {
1661
+ p.log.error(`Failed to start local server on port ${CALLBACK_PORT}.`);
1662
+ if (server.error) p.log.error(`Technical details: ${server.error}`);
1663
+ if (server.errorCode) p.log.error(`Error code: ${server.errorCode}`);
1498
1664
  p.log.info("Please ensure the port is not in use.");
1499
1665
  return null;
1500
1666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {