@bjesuiter/codex-switcher 1.7.1 → 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 -6
  2. package/cdx.mjs +262 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,15 +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.1
10
-
11
- #### Features
12
-
13
- - Add a release helper script (`scripts/wait-for-npm-latest.ts`) plus `bun run wait-npm-latest` to poll npm until the package `latest` tag matches the target version.
9
+ ### 1.7.3
14
10
 
15
11
  #### Fixes
16
12
 
17
- - Fix Windows CI completion test behavior by providing `APPDATA` in the account-completion test environment, so account suggestions are resolved correctly on `windows-latest`.
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.
18
18
 
19
19
  see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
20
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.1";
18
+ var version = "1.7.3";
19
19
 
20
20
  //#endregion
21
21
  //#region lib/platform/path-resolver.ts
@@ -1032,6 +1032,10 @@ const createAuthorizationFlow = async () => {
1032
1032
  url: url.toString()
1033
1033
  };
1034
1034
  };
1035
+ const truncateForLog = (value, maxLength = 300) => {
1036
+ if (value.length <= maxLength) return value;
1037
+ return `${value.slice(0, maxLength)}…`;
1038
+ };
1035
1039
  const startDeviceAuthorizationFlow = async () => {
1036
1040
  try {
1037
1041
  const res = await fetch(DEVICE_CODE_URL, {
@@ -1042,19 +1046,53 @@ const startDeviceAuthorizationFlow = async () => {
1042
1046
  scope: SCOPE
1043
1047
  })
1044
1048
  });
1045
- if (!res.ok) return null;
1049
+ if (!res.ok) {
1050
+ let oauthError;
1051
+ let responseBody;
1052
+ try {
1053
+ const json = await res.json();
1054
+ oauthError = json.error;
1055
+ responseBody = truncateForLog(JSON.stringify({
1056
+ ...json.error ? { error: json.error } : {},
1057
+ ...json.error_description ? { error_description: json.error_description } : {}
1058
+ }));
1059
+ } catch {
1060
+ try {
1061
+ responseBody = truncateForLog(await res.text());
1062
+ } catch {
1063
+ responseBody = void 0;
1064
+ }
1065
+ }
1066
+ return {
1067
+ type: "failed",
1068
+ error: `Device code request failed with HTTP ${res.status} ${res.statusText}`,
1069
+ status: res.status,
1070
+ ...oauthError ? { oauthError } : {},
1071
+ ...responseBody ? { responseBody } : {}
1072
+ };
1073
+ }
1046
1074
  const json = await res.json();
1047
- if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return null;
1075
+ if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return {
1076
+ type: "failed",
1077
+ error: "Device code response is missing required fields.",
1078
+ responseBody: truncateForLog(JSON.stringify(json))
1079
+ };
1048
1080
  return {
1049
- deviceCode: json.device_code,
1050
- userCode: json.user_code,
1051
- verificationUri: json.verification_uri,
1052
- verificationUriComplete: json.verification_uri_complete,
1053
- expiresIn: json.expires_in,
1054
- interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
1081
+ type: "success",
1082
+ flow: {
1083
+ deviceCode: json.device_code,
1084
+ userCode: json.user_code,
1085
+ verificationUri: json.verification_uri,
1086
+ verificationUriComplete: json.verification_uri_complete,
1087
+ expiresIn: json.expires_in,
1088
+ interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
1089
+ }
1090
+ };
1091
+ } catch (error) {
1092
+ return {
1093
+ type: "failed",
1094
+ error: `Device code request failed: ${error instanceof Error ? error.message : String(error)}`
1055
1095
  };
1056
- } catch {
1057
- return null;
1058
1096
  }
1059
1097
  };
1060
1098
  const pollDeviceAuthorizationToken = async (deviceCode) => {
@@ -1070,7 +1108,11 @@ const pollDeviceAuthorizationToken = async (deviceCode) => {
1070
1108
  });
1071
1109
  if (res.ok) {
1072
1110
  const json = await res.json();
1073
- if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
1111
+ if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return {
1112
+ type: "failed",
1113
+ error: "Device token response is missing access_token/refresh_token/expires_in.",
1114
+ responseBody: truncateForLog(JSON.stringify(json))
1115
+ };
1074
1116
  return {
1075
1117
  type: "success",
1076
1118
  access: json.access_token,
@@ -1081,11 +1123,23 @@ const pollDeviceAuthorizationToken = async (deviceCode) => {
1081
1123
  }
1082
1124
  let errorCode;
1083
1125
  let interval;
1126
+ let responseBody;
1084
1127
  try {
1085
1128
  const json = await res.json();
1086
1129
  errorCode = json.error;
1087
1130
  interval = json.interval;
1088
- } catch {}
1131
+ responseBody = truncateForLog(JSON.stringify({
1132
+ ...json.error ? { error: json.error } : {},
1133
+ ...json.error_description ? { error_description: json.error_description } : {},
1134
+ ...typeof json.interval === "number" ? { interval: json.interval } : {}
1135
+ }));
1136
+ } catch {
1137
+ try {
1138
+ responseBody = truncateForLog(await res.text());
1139
+ } catch {
1140
+ responseBody = void 0;
1141
+ }
1142
+ }
1089
1143
  if (errorCode === "authorization_pending") return {
1090
1144
  type: "pending",
1091
1145
  interval: typeof interval === "number" && interval > 0 ? interval : 5
@@ -1096,9 +1150,18 @@ const pollDeviceAuthorizationToken = async (deviceCode) => {
1096
1150
  };
1097
1151
  if (errorCode === "access_denied") return { type: "access_denied" };
1098
1152
  if (errorCode === "expired_token") return { type: "expired" };
1099
- return { type: "failed" };
1100
- } catch {
1101
- return { type: "failed" };
1153
+ return {
1154
+ type: "failed",
1155
+ error: `Device token polling failed with HTTP ${res.status} ${res.statusText}`,
1156
+ status: res.status,
1157
+ ...errorCode ? { oauthError: errorCode } : {},
1158
+ ...responseBody ? { responseBody } : {}
1159
+ };
1160
+ } catch (error) {
1161
+ return {
1162
+ type: "failed",
1163
+ error: `Device token polling request failed: ${error instanceof Error ? error.message : String(error)}`
1164
+ };
1102
1165
  }
1103
1166
  };
1104
1167
  const exchangeAuthorizationCode = async (code, verifier) => {
@@ -1247,10 +1310,14 @@ const startOAuthServer = (state) => {
1247
1310
  },
1248
1311
  waitForCode: () => codePromise
1249
1312
  });
1250
- }).on("error", () => {
1313
+ }).on("error", (error) => {
1314
+ const err = error;
1251
1315
  resolve({
1252
1316
  port: CALLBACK_PORT,
1253
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 } : {},
1254
1321
  close: () => {
1255
1322
  try {
1256
1323
  server.close();
@@ -1270,6 +1337,101 @@ const isLikelyRemoteEnvironment = () => {
1270
1337
  if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
1271
1338
  return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
1272
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
+ };
1273
1435
  const parseOAuthCallbackInput = (input) => {
1274
1436
  const trimmed = input.trim();
1275
1437
  if (!trimmed) return null;
@@ -1336,6 +1498,34 @@ const promptBrowserFallbackChoice = async () => {
1336
1498
  if (p.isCancel(selection)) return "cancel";
1337
1499
  return selection;
1338
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
+ };
1339
1529
  const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
1340
1530
  p.log.info("Manual login selected.");
1341
1531
  p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
@@ -1364,11 +1554,16 @@ const promptManualAuthorizationCode = async (authorizationUrl, expectedState) =>
1364
1554
  return null;
1365
1555
  };
1366
1556
  const runDeviceOAuthFlow = async (useSpinner) => {
1367
- const deviceFlow = await startDeviceAuthorizationFlow();
1368
- if (!deviceFlow) {
1557
+ const deviceFlowResult = await startDeviceAuthorizationFlow();
1558
+ if (deviceFlowResult.type !== "success") {
1369
1559
  p.log.error("Device OAuth flow is not available right now.");
1560
+ p.log.error(`Technical details: ${deviceFlowResult.error}`);
1561
+ if (typeof deviceFlowResult.status === "number") p.log.error(`HTTP status: ${deviceFlowResult.status}`);
1562
+ if (deviceFlowResult.oauthError) p.log.error(`OAuth error: ${deviceFlowResult.oauthError}`);
1563
+ if (deviceFlowResult.responseBody) p.log.error(`Response: ${deviceFlowResult.responseBody}`);
1370
1564
  return null;
1371
1565
  }
1566
+ const deviceFlow = deviceFlowResult.flow;
1372
1567
  p.log.info("Using device OAuth flow.");
1373
1568
  p.log.message(`Verification URL: ${deviceFlow.verificationUri}`);
1374
1569
  p.log.message(`User code: ${deviceFlow.userCode}`);
@@ -1410,6 +1605,12 @@ const runDeviceOAuthFlow = async (useSpinner) => {
1410
1605
  }
1411
1606
  if (spinner) spinner.stop("Device authorization failed.");
1412
1607
  else p.log.error("Device authorization failed.");
1608
+ if (pollResult.type === "failed") {
1609
+ if (pollResult.error) p.log.error(`Technical details: ${pollResult.error}`);
1610
+ if (typeof pollResult.status === "number") p.log.error(`HTTP status: ${pollResult.status}`);
1611
+ if (pollResult.oauthError) p.log.error(`OAuth error: ${pollResult.oauthError}`);
1612
+ if (pollResult.responseBody) p.log.error(`Response: ${pollResult.responseBody}`);
1613
+ }
1413
1614
  return null;
1414
1615
  }
1415
1616
  if (spinner) spinner.stop("Device authorization timed out.");
@@ -1418,9 +1619,48 @@ const runDeviceOAuthFlow = async (useSpinner) => {
1418
1619
  };
1419
1620
  const requestTokenViaOAuth = async (flow, options) => {
1420
1621
  if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
1421
- const server = await startOAuthServer(flow.state);
1422
- if (!server.ready) {
1423
- 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}`);
1424
1664
  p.log.info("Please ensure the port is not in use.");
1425
1665
  return null;
1426
1666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.7.1",
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": {