@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.
- package/README.md +6 -2
- package/cdx.mjs +171 -5
- 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.
|
|
9
|
+
### 1.7.3
|
|
10
10
|
|
|
11
11
|
#### Fixes
|
|
12
12
|
|
|
13
|
-
-
|
|
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.
|
|
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
|
-
|
|
1496
|
-
if (!server.ready) {
|
|
1497
|
-
p.log.
|
|
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
|
}
|