@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.
- package/README.md +6 -6
- package/cdx.mjs +262 -22
- 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.
|
|
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
|
-
-
|
|
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.
|
|
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)
|
|
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
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
|
1368
|
-
if (
|
|
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
|
-
|
|
1422
|
-
if (!server.ready) {
|
|
1423
|
-
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}`);
|
|
1424
1664
|
p.log.info("Please ensure the port is not in use.");
|
|
1425
1665
|
return null;
|
|
1426
1666
|
}
|