portless 0.13.1 → 0.15.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.
- package/README.md +24 -1
- package/dist/{chunk-3WLVQXFE.js → chunk-PCBKLZK2.js} +53 -7
- package/dist/cli.js +744 -44
- package/dist/index.d.ts +20 -3
- package/dist/index.js +3 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,16 +4,19 @@ import {
|
|
|
4
4
|
PORTLESS_HEADER,
|
|
5
5
|
RouteConflictError,
|
|
6
6
|
RouteStore,
|
|
7
|
+
checkHostResolution,
|
|
7
8
|
cleanHostsFile,
|
|
8
9
|
createHttpRedirectServer,
|
|
9
10
|
createProxyServer,
|
|
10
11
|
fixOwnership,
|
|
11
12
|
formatUrl,
|
|
13
|
+
getManagedHostnames,
|
|
12
14
|
isErrnoException,
|
|
15
|
+
isProcessAlive,
|
|
13
16
|
parseHostname,
|
|
14
17
|
shouldAutoSyncHosts,
|
|
15
18
|
syncHostsFile
|
|
16
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-PCBKLZK2.js";
|
|
17
20
|
|
|
18
21
|
// src/colors.ts
|
|
19
22
|
function supportsColor() {
|
|
@@ -41,7 +44,7 @@ var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
|
|
|
41
44
|
// src/cli.ts
|
|
42
45
|
import * as fs9 from "fs";
|
|
43
46
|
import * as path9 from "path";
|
|
44
|
-
import { spawn as
|
|
47
|
+
import { spawn as spawn4, spawnSync as spawnSync5 } from "child_process";
|
|
45
48
|
import { StringDecoder } from "string_decoder";
|
|
46
49
|
|
|
47
50
|
// src/certs.ts
|
|
@@ -999,6 +1002,178 @@ function formatTailscaleUrl(baseUrl, httpsPort) {
|
|
|
999
1002
|
return `${trimmed}:${httpsPort}`;
|
|
1000
1003
|
}
|
|
1001
1004
|
|
|
1005
|
+
// src/ngrok.ts
|
|
1006
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1007
|
+
var NGROK_BINARY = "ngrok";
|
|
1008
|
+
var NGROK_START_TIMEOUT_MS = 3e4;
|
|
1009
|
+
var NGROK_COMMAND_TIMEOUT_MS = 1e4;
|
|
1010
|
+
var OUTPUT_BUFFER_LIMIT = 16384;
|
|
1011
|
+
function defaultSpawner(args) {
|
|
1012
|
+
return spawn(NGROK_BINARY, args, {
|
|
1013
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1014
|
+
windowsHide: true
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
function defaultRunner2(args) {
|
|
1018
|
+
const result = spawnSync2(NGROK_BINARY, args, {
|
|
1019
|
+
encoding: "utf-8",
|
|
1020
|
+
killSignal: "SIGKILL",
|
|
1021
|
+
timeout: NGROK_COMMAND_TIMEOUT_MS
|
|
1022
|
+
});
|
|
1023
|
+
return {
|
|
1024
|
+
status: result.status,
|
|
1025
|
+
stdout: result.stdout ?? "",
|
|
1026
|
+
stderr: result.stderr ?? "",
|
|
1027
|
+
...result.error ? { error: result.error } : {}
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function normalizeSpace2(value) {
|
|
1031
|
+
return value.trim().replace(/\s+/g, " ");
|
|
1032
|
+
}
|
|
1033
|
+
function formatSpawnError(error) {
|
|
1034
|
+
const errno = error;
|
|
1035
|
+
if (errno.code === "ENOENT") {
|
|
1036
|
+
return new Error(
|
|
1037
|
+
"ngrok CLI not found. Install ngrok (https://ngrok.com/download) and ensure `ngrok` is on PATH."
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
return new Error(`Failed to start ngrok: ${error.message}`);
|
|
1041
|
+
}
|
|
1042
|
+
function formatOutputError(output) {
|
|
1043
|
+
const details = normalizeSpace2(output);
|
|
1044
|
+
const lower = details.toLowerCase();
|
|
1045
|
+
if (lower.includes("authtoken") || lower.includes("authentication") || lower.includes("not logged in")) {
|
|
1046
|
+
return new Error(
|
|
1047
|
+
"ngrok could not start because authentication is not configured. Run `ngrok config add-authtoken <token>`, then run portless again."
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
return new Error(
|
|
1051
|
+
`Failed to start ngrok tunnel: ${details || "ngrok exited before printing a public URL"}`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
function ensureNgrokAvailable(runner = defaultRunner2) {
|
|
1055
|
+
const result = runner(["version"]);
|
|
1056
|
+
if (result.error) {
|
|
1057
|
+
throw formatSpawnError(result.error);
|
|
1058
|
+
}
|
|
1059
|
+
if (result.status !== 0) {
|
|
1060
|
+
const details = normalizeSpace2(result.stderr || result.stdout);
|
|
1061
|
+
throw new Error(`Failed to check ngrok version: ${details || "unknown ngrok error"}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
function cleanUrl(value) {
|
|
1065
|
+
return value.replace(/[),.]+$/g, "");
|
|
1066
|
+
}
|
|
1067
|
+
function extractNgrokUrl(output) {
|
|
1068
|
+
const urlMatches = output.matchAll(/https:\/\/[^\s"'<>]+/g);
|
|
1069
|
+
for (const match of urlMatches) {
|
|
1070
|
+
const raw = match[0];
|
|
1071
|
+
const matchIndex = match.index ?? 0;
|
|
1072
|
+
const before = output.slice(Math.max(0, matchIndex - 80), matchIndex).toLowerCase();
|
|
1073
|
+
const looksLikeTunnel = before.includes("forwarding") || before.includes("url=") || before.includes('"url"') || before.includes("started tunnel");
|
|
1074
|
+
if (!looksLikeTunnel) continue;
|
|
1075
|
+
const candidate = cleanUrl(raw);
|
|
1076
|
+
try {
|
|
1077
|
+
const parsed = new URL(candidate);
|
|
1078
|
+
if (parsed.hostname === "ngrok.com" || parsed.hostname.endsWith(".ngrok.com")) {
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
return parsed.toString().replace(/\/$/, "");
|
|
1082
|
+
} catch {
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
function buildNgrokArgs(localPort, hostHeader = "rewrite") {
|
|
1089
|
+
return [
|
|
1090
|
+
"http",
|
|
1091
|
+
"--log=stdout",
|
|
1092
|
+
"--log-format=logfmt",
|
|
1093
|
+
`--host-header=${hostHeader}`,
|
|
1094
|
+
`http://127.0.0.1:${localPort}`
|
|
1095
|
+
];
|
|
1096
|
+
}
|
|
1097
|
+
function startNgrok(localPort, options = {}) {
|
|
1098
|
+
const spawner = options.spawner ?? defaultSpawner;
|
|
1099
|
+
const timeoutMs = options.timeoutMs ?? NGROK_START_TIMEOUT_MS;
|
|
1100
|
+
const args = buildNgrokArgs(localPort, options.hostHeader);
|
|
1101
|
+
let child;
|
|
1102
|
+
try {
|
|
1103
|
+
child = spawner(args);
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
return Promise.reject(formatSpawnError(err instanceof Error ? err : new Error(String(err))));
|
|
1106
|
+
}
|
|
1107
|
+
return new Promise((resolve4, reject) => {
|
|
1108
|
+
let settled = false;
|
|
1109
|
+
let started = false;
|
|
1110
|
+
let output = "";
|
|
1111
|
+
const settle = (fn) => {
|
|
1112
|
+
if (settled) return;
|
|
1113
|
+
settled = true;
|
|
1114
|
+
clearTimeout(timer);
|
|
1115
|
+
fn();
|
|
1116
|
+
};
|
|
1117
|
+
const appendOutput = (chunk) => {
|
|
1118
|
+
if (settled) return;
|
|
1119
|
+
output += chunk.toString();
|
|
1120
|
+
if (output.length > OUTPUT_BUFFER_LIMIT) {
|
|
1121
|
+
output = output.slice(-OUTPUT_BUFFER_LIMIT);
|
|
1122
|
+
}
|
|
1123
|
+
const url = extractNgrokUrl(output);
|
|
1124
|
+
if (url) {
|
|
1125
|
+
settle(() => {
|
|
1126
|
+
started = true;
|
|
1127
|
+
resolve4({ url, pid: child.pid, child });
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
const timer = setTimeout(() => {
|
|
1132
|
+
try {
|
|
1133
|
+
child.kill("SIGTERM");
|
|
1134
|
+
} catch {
|
|
1135
|
+
}
|
|
1136
|
+
settle(
|
|
1137
|
+
() => reject(
|
|
1138
|
+
new Error(
|
|
1139
|
+
"Timed out waiting for ngrok to print a public URL. Check that ngrok is authenticated and can connect."
|
|
1140
|
+
)
|
|
1141
|
+
)
|
|
1142
|
+
);
|
|
1143
|
+
}, timeoutMs);
|
|
1144
|
+
child.stdout?.on("data", appendOutput);
|
|
1145
|
+
child.stderr?.on("data", appendOutput);
|
|
1146
|
+
child.on("error", (err) => {
|
|
1147
|
+
settle(() => reject(formatSpawnError(err)));
|
|
1148
|
+
});
|
|
1149
|
+
child.on("exit", (code, signal) => {
|
|
1150
|
+
if (settled) {
|
|
1151
|
+
if (started) options.onExit?.(code, signal);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
settle(() => {
|
|
1155
|
+
const suffix = signal ? ` (signal ${signal})` : code !== null ? ` (exit ${code})` : "";
|
|
1156
|
+
const error = formatOutputError(output);
|
|
1157
|
+
reject(new Error(`${error.message}${suffix}`));
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
function stopNgrokProcess(child) {
|
|
1163
|
+
if (!child) return;
|
|
1164
|
+
try {
|
|
1165
|
+
child.kill("SIGTERM");
|
|
1166
|
+
} catch {
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
function stopNgrok(route) {
|
|
1170
|
+
if (!route.ngrokPid) return;
|
|
1171
|
+
try {
|
|
1172
|
+
process.kill(route.ngrokPid, "SIGTERM");
|
|
1173
|
+
} catch {
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1002
1177
|
// src/auto.ts
|
|
1003
1178
|
import { createHash as createHash2 } from "crypto";
|
|
1004
1179
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
@@ -1181,7 +1356,7 @@ import * as net from "net";
|
|
|
1181
1356
|
import * as os from "os";
|
|
1182
1357
|
import * as path3 from "path";
|
|
1183
1358
|
import * as readline from "readline";
|
|
1184
|
-
import { execSync, spawn } from "child_process";
|
|
1359
|
+
import { execSync, spawn as spawn2 } from "child_process";
|
|
1185
1360
|
var isWindows = process.platform === "win32";
|
|
1186
1361
|
var FALLBACK_PROXY_PORT = 1355;
|
|
1187
1362
|
var PRIVILEGED_PORT_THRESHOLD = 1024;
|
|
@@ -1331,6 +1506,7 @@ function readPortFromDir(dir) {
|
|
|
1331
1506
|
}
|
|
1332
1507
|
}
|
|
1333
1508
|
var TLS_MARKER_FILE = "proxy.tls";
|
|
1509
|
+
var CUSTOM_CERT_MARKER_FILE = "proxy.custom-cert";
|
|
1334
1510
|
function readTlsMarker(dir) {
|
|
1335
1511
|
try {
|
|
1336
1512
|
return fs3.existsSync(path3.join(dir, TLS_MARKER_FILE));
|
|
@@ -1349,6 +1525,24 @@ function writeTlsMarker(dir, enabled2) {
|
|
|
1349
1525
|
}
|
|
1350
1526
|
}
|
|
1351
1527
|
}
|
|
1528
|
+
function readCustomCertMarker(dir) {
|
|
1529
|
+
try {
|
|
1530
|
+
return fs3.existsSync(path3.join(dir, CUSTOM_CERT_MARKER_FILE));
|
|
1531
|
+
} catch {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
function writeCustomCertMarker(dir, enabled2) {
|
|
1536
|
+
const markerPath = path3.join(dir, CUSTOM_CERT_MARKER_FILE);
|
|
1537
|
+
if (enabled2) {
|
|
1538
|
+
fs3.writeFileSync(markerPath, "1", { mode: 420 });
|
|
1539
|
+
} else {
|
|
1540
|
+
try {
|
|
1541
|
+
fs3.unlinkSync(markerPath);
|
|
1542
|
+
} catch {
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1352
1546
|
var LAN_MARKER_FILE = "proxy.lan";
|
|
1353
1547
|
function readLanMarker(dir) {
|
|
1354
1548
|
try {
|
|
@@ -1718,10 +1912,10 @@ function spawnCommand(commandArgs, options) {
|
|
|
1718
1912
|
}
|
|
1719
1913
|
}
|
|
1720
1914
|
}
|
|
1721
|
-
const child = isWindows ?
|
|
1915
|
+
const child = isWindows ? spawn2("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
|
|
1722
1916
|
stdio: "inherit",
|
|
1723
1917
|
env
|
|
1724
|
-
}) :
|
|
1918
|
+
}) : spawn2("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
|
|
1725
1919
|
stdio: "inherit",
|
|
1726
1920
|
env,
|
|
1727
1921
|
detached: true
|
|
@@ -1829,6 +2023,7 @@ var PORTLESS_STATE_FILES = [
|
|
|
1829
2023
|
"proxy.port",
|
|
1830
2024
|
"proxy.log",
|
|
1831
2025
|
"proxy.tls",
|
|
2026
|
+
"proxy.custom-cert",
|
|
1832
2027
|
"proxy.tld",
|
|
1833
2028
|
"proxy.lan",
|
|
1834
2029
|
"ca-key.pem",
|
|
@@ -1867,7 +2062,7 @@ function removePortlessStateFiles(dir) {
|
|
|
1867
2062
|
}
|
|
1868
2063
|
|
|
1869
2064
|
// src/mdns.ts
|
|
1870
|
-
import { spawn as
|
|
2065
|
+
import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
|
|
1871
2066
|
|
|
1872
2067
|
// src/lan-ip.ts
|
|
1873
2068
|
import { createSocket } from "dgram";
|
|
@@ -1983,7 +2178,7 @@ function getMdnsPublisher() {
|
|
|
1983
2178
|
return null;
|
|
1984
2179
|
}
|
|
1985
2180
|
function hasCommand(command, probeArgs) {
|
|
1986
|
-
const result =
|
|
2181
|
+
const result = spawnSync3(command, probeArgs, {
|
|
1987
2182
|
stdio: "ignore",
|
|
1988
2183
|
timeout: 1e3,
|
|
1989
2184
|
windowsHide: true
|
|
@@ -2042,7 +2237,7 @@ function publish(hostname, port, ip, onError) {
|
|
|
2042
2237
|
if (!publisher) {
|
|
2043
2238
|
return;
|
|
2044
2239
|
}
|
|
2045
|
-
const child =
|
|
2240
|
+
const child = spawn3(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
|
|
2046
2241
|
stdio: "ignore",
|
|
2047
2242
|
detached: false
|
|
2048
2243
|
});
|
|
@@ -2595,7 +2790,7 @@ function hasTurboConfig(wsRoot) {
|
|
|
2595
2790
|
import * as fs8 from "fs";
|
|
2596
2791
|
import * as os2 from "os";
|
|
2597
2792
|
import * as path8 from "path";
|
|
2598
|
-
import { spawnSync as
|
|
2793
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
2599
2794
|
var DEFAULT_SERVICE_PORT = getProtocolPort(true);
|
|
2600
2795
|
var SERVICE_LABEL = "sh.portless.proxy";
|
|
2601
2796
|
var SYSTEMD_SERVICE = "portless.service";
|
|
@@ -2614,8 +2809,8 @@ var DEFAULT_SERVICE_CONFIG = {
|
|
|
2614
2809
|
useWildcard: false,
|
|
2615
2810
|
extraEnv: {}
|
|
2616
2811
|
};
|
|
2617
|
-
function
|
|
2618
|
-
return
|
|
2812
|
+
function defaultRunner3(command, args, options) {
|
|
2813
|
+
return spawnSync4(command, args, {
|
|
2619
2814
|
encoding: "utf-8",
|
|
2620
2815
|
stdio: options?.stdio ?? "pipe"
|
|
2621
2816
|
});
|
|
@@ -3337,7 +3532,7 @@ async function uninstallService(entryScript, runner) {
|
|
|
3337
3532
|
}
|
|
3338
3533
|
console.log(colors_default.green("Portless service uninstalled."));
|
|
3339
3534
|
}
|
|
3340
|
-
function tryUninstallService(entryScript, runner =
|
|
3535
|
+
function tryUninstallService(entryScript, runner = defaultRunner3) {
|
|
3341
3536
|
let installed = false;
|
|
3342
3537
|
try {
|
|
3343
3538
|
const spec = currentServiceSpec(entryScript);
|
|
@@ -3465,7 +3660,7 @@ ${colors_default.bold("Notes:")}
|
|
|
3465
3660
|
}
|
|
3466
3661
|
async function handleService(args, options) {
|
|
3467
3662
|
const action = args[1];
|
|
3468
|
-
const runner = options.runner ||
|
|
3663
|
+
const runner = options.runner || defaultRunner3;
|
|
3469
3664
|
if (!action || action === "--help" || action === "-h") {
|
|
3470
3665
|
printServiceHelp();
|
|
3471
3666
|
process.exit(0);
|
|
@@ -3666,7 +3861,7 @@ function collectPortlessEnvArgs2() {
|
|
|
3666
3861
|
function sudoStop(port) {
|
|
3667
3862
|
const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
|
|
3668
3863
|
console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
|
|
3669
|
-
const result =
|
|
3864
|
+
const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
|
|
3670
3865
|
stdio: "inherit",
|
|
3671
3866
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3672
3867
|
});
|
|
@@ -3675,7 +3870,7 @@ function sudoStop(port) {
|
|
|
3675
3870
|
function runCleanWithSudo(reason) {
|
|
3676
3871
|
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3677
3872
|
const home = process.env.HOME;
|
|
3678
|
-
const result =
|
|
3873
|
+
const result = spawnSync5(
|
|
3679
3874
|
"sudo",
|
|
3680
3875
|
[
|
|
3681
3876
|
"env",
|
|
@@ -3694,13 +3889,21 @@ function runCleanWithSudo(reason) {
|
|
|
3694
3889
|
}
|
|
3695
3890
|
function runServiceUninstallWithSudo(reason) {
|
|
3696
3891
|
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3697
|
-
const result =
|
|
3892
|
+
const result = spawnSync5("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
|
|
3698
3893
|
stdio: "inherit",
|
|
3699
3894
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3700
3895
|
});
|
|
3701
3896
|
return result.status === 0;
|
|
3702
3897
|
}
|
|
3703
|
-
function
|
|
3898
|
+
function isEnabledEnv(value) {
|
|
3899
|
+
return value === "1" || value === "true";
|
|
3900
|
+
}
|
|
3901
|
+
function formatProcessExitSuffix(code, signal) {
|
|
3902
|
+
if (signal) return ` (signal ${signal})`;
|
|
3903
|
+
if (code !== null) return ` (exit ${code})`;
|
|
3904
|
+
return "";
|
|
3905
|
+
}
|
|
3906
|
+
function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict, customCert = false) {
|
|
3704
3907
|
store.ensureDir();
|
|
3705
3908
|
const isTls = !!tlsOptions;
|
|
3706
3909
|
const mdnsSupport = isMdnsSupported();
|
|
@@ -3829,6 +4032,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
3829
4032
|
fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
|
|
3830
4033
|
fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
|
|
3831
4034
|
writeTlsMarker(store.dir, isTls);
|
|
4035
|
+
writeCustomCertMarker(store.dir, isTls && customCert);
|
|
3832
4036
|
writeTldFile(store.dir, tld);
|
|
3833
4037
|
writeLanMarker(store.dir, activeLanIp);
|
|
3834
4038
|
fixOwnership(store.dir, store.pidPath, store.portFilePath);
|
|
@@ -3841,7 +4045,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
3841
4045
|
if (activeLanIp) {
|
|
3842
4046
|
console.log(chalk.green(`LAN mode: ${activeLanIp}`));
|
|
3843
4047
|
console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
|
|
3844
|
-
if (isTls) {
|
|
4048
|
+
if (isTls && !customCert) {
|
|
3845
4049
|
console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
|
|
3846
4050
|
console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
|
|
3847
4051
|
}
|
|
@@ -3883,6 +4087,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
3883
4087
|
} catch {
|
|
3884
4088
|
}
|
|
3885
4089
|
writeTlsMarker(store.dir, false);
|
|
4090
|
+
writeCustomCertMarker(store.dir, false);
|
|
3886
4091
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
3887
4092
|
writeLanMarker(store.dir, null);
|
|
3888
4093
|
if (autoSyncHosts) cleanHostsFile();
|
|
@@ -3921,6 +4126,7 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
3921
4126
|
} catch {
|
|
3922
4127
|
}
|
|
3923
4128
|
writeTlsMarker(store.dir, false);
|
|
4129
|
+
writeCustomCertMarker(store.dir, false);
|
|
3924
4130
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
3925
4131
|
writeLanMarker(store.dir, null);
|
|
3926
4132
|
console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
|
|
@@ -3960,6 +4166,7 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
3960
4166
|
console.error(colors_default.red("Corrupted PID file. Removing it."));
|
|
3961
4167
|
fs9.unlinkSync(pidPath);
|
|
3962
4168
|
writeTlsMarker(store.dir, false);
|
|
4169
|
+
writeCustomCertMarker(store.dir, false);
|
|
3963
4170
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
3964
4171
|
writeLanMarker(store.dir, null);
|
|
3965
4172
|
return;
|
|
@@ -3978,6 +4185,7 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
3978
4185
|
} catch {
|
|
3979
4186
|
}
|
|
3980
4187
|
writeTlsMarker(store.dir, false);
|
|
4188
|
+
writeCustomCertMarker(store.dir, false);
|
|
3981
4189
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
3982
4190
|
writeLanMarker(store.dir, null);
|
|
3983
4191
|
return;
|
|
@@ -3991,6 +4199,7 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
3991
4199
|
console.log(colors_default.yellow("Removing stale PID file."));
|
|
3992
4200
|
fs9.unlinkSync(pidPath);
|
|
3993
4201
|
writeTlsMarker(store.dir, false);
|
|
4202
|
+
writeCustomCertMarker(store.dir, false);
|
|
3994
4203
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
3995
4204
|
writeLanMarker(store.dir, null);
|
|
3996
4205
|
return;
|
|
@@ -4002,6 +4211,7 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
4002
4211
|
} catch {
|
|
4003
4212
|
}
|
|
4004
4213
|
writeTlsMarker(store.dir, false);
|
|
4214
|
+
writeCustomCertMarker(store.dir, false);
|
|
4005
4215
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
4006
4216
|
writeLanMarker(store.dir, null);
|
|
4007
4217
|
console.log(colors_default.green("Proxy stopped."));
|
|
@@ -4038,6 +4248,9 @@ function listRoutes(store, proxyPort, tls2) {
|
|
|
4038
4248
|
const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
|
|
4039
4249
|
console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
|
|
4040
4250
|
}
|
|
4251
|
+
if (route.ngrokUrl) {
|
|
4252
|
+
console.log(` ${colors_default.gray("ngrok:")} ${colors_default.green(route.ngrokUrl)}`);
|
|
4253
|
+
}
|
|
4041
4254
|
}
|
|
4042
4255
|
console.log();
|
|
4043
4256
|
}
|
|
@@ -4121,7 +4334,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
|
|
|
4121
4334
|
proxyPort: startPort
|
|
4122
4335
|
});
|
|
4123
4336
|
const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
|
|
4124
|
-
const result =
|
|
4337
|
+
const result = spawnSync5(process.execPath, startArgs, {
|
|
4125
4338
|
stdio: "inherit",
|
|
4126
4339
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
4127
4340
|
});
|
|
@@ -4155,8 +4368,9 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
|
|
|
4155
4368
|
console.log(chalk.blue.bold(`
|
|
4156
4369
|
portless
|
|
4157
4370
|
`));
|
|
4158
|
-
const wantsFunnel = process.env.PORTLESS_FUNNEL
|
|
4159
|
-
const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE
|
|
4371
|
+
const wantsFunnel = isEnabledEnv(process.env.PORTLESS_FUNNEL);
|
|
4372
|
+
const wantsTailscale = wantsFunnel || isEnabledEnv(process.env.PORTLESS_TAILSCALE);
|
|
4373
|
+
const wantsNgrok = isEnabledEnv(process.env.PORTLESS_NGROK);
|
|
4160
4374
|
let tsBaseUrl;
|
|
4161
4375
|
if (wantsTailscale) {
|
|
4162
4376
|
try {
|
|
@@ -4177,6 +4391,18 @@ portless
|
|
|
4177
4391
|
process.exit(1);
|
|
4178
4392
|
}
|
|
4179
4393
|
}
|
|
4394
|
+
if (wantsNgrok) {
|
|
4395
|
+
try {
|
|
4396
|
+
ensureNgrokAvailable();
|
|
4397
|
+
} catch (err) {
|
|
4398
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4399
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
4400
|
+
if (message.includes("not found")) {
|
|
4401
|
+
console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
|
|
4402
|
+
}
|
|
4403
|
+
process.exit(1);
|
|
4404
|
+
}
|
|
4405
|
+
}
|
|
4180
4406
|
let desired;
|
|
4181
4407
|
try {
|
|
4182
4408
|
desired = resolveProxyDesiredState(lanMode);
|
|
@@ -4262,6 +4488,36 @@ portless
|
|
|
4262
4488
|
}
|
|
4263
4489
|
let tailscaleHttpsPort;
|
|
4264
4490
|
let tailscaleUrl;
|
|
4491
|
+
let ngrokUrl;
|
|
4492
|
+
let ngrokProcess;
|
|
4493
|
+
let stoppingNgrok = false;
|
|
4494
|
+
let ngrokRouteReady = false;
|
|
4495
|
+
let ngrokExitHandled = false;
|
|
4496
|
+
let pendingNgrokExit;
|
|
4497
|
+
const handleNgrokExit = (code, signal) => {
|
|
4498
|
+
if (stoppingNgrok || ngrokExitHandled) return;
|
|
4499
|
+
if (!ngrokRouteReady) {
|
|
4500
|
+
pendingNgrokExit = { code, signal };
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4503
|
+
ngrokExitHandled = true;
|
|
4504
|
+
ngrokUrl = void 0;
|
|
4505
|
+
console.warn(
|
|
4506
|
+
colors_default.yellow(
|
|
4507
|
+
`Warning: ngrok tunnel for ${hostname} stopped${formatProcessExitSuffix(
|
|
4508
|
+
code,
|
|
4509
|
+
signal
|
|
4510
|
+
)}. Removing its public URL from the route list.`
|
|
4511
|
+
)
|
|
4512
|
+
);
|
|
4513
|
+
try {
|
|
4514
|
+
store.updateRoute(hostname, {
|
|
4515
|
+
ngrokUrl: null,
|
|
4516
|
+
ngrokPid: null
|
|
4517
|
+
});
|
|
4518
|
+
} catch {
|
|
4519
|
+
}
|
|
4520
|
+
};
|
|
4265
4521
|
if (wantsTailscale && tsBaseUrl) {
|
|
4266
4522
|
const maxAttempts = 3;
|
|
4267
4523
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -4299,6 +4555,51 @@ portless
|
|
|
4299
4555
|
} catch {
|
|
4300
4556
|
}
|
|
4301
4557
|
}
|
|
4558
|
+
if (wantsNgrok) {
|
|
4559
|
+
try {
|
|
4560
|
+
ngrokProcess = await startNgrok(port, {
|
|
4561
|
+
hostHeader: hostname,
|
|
4562
|
+
onExit: handleNgrokExit
|
|
4563
|
+
});
|
|
4564
|
+
ngrokUrl = ngrokProcess.url;
|
|
4565
|
+
console.log(chalk.green(` ngrok -> ${ngrokUrl}`));
|
|
4566
|
+
console.log(chalk.gray(" (accessible from the public internet via ngrok)\n"));
|
|
4567
|
+
try {
|
|
4568
|
+
store.updateRoute(hostname, {
|
|
4569
|
+
ngrokUrl,
|
|
4570
|
+
ngrokPid: ngrokProcess.pid
|
|
4571
|
+
});
|
|
4572
|
+
} catch {
|
|
4573
|
+
} finally {
|
|
4574
|
+
ngrokRouteReady = true;
|
|
4575
|
+
if (pendingNgrokExit) {
|
|
4576
|
+
handleNgrokExit(pendingNgrokExit.code, pendingNgrokExit.signal);
|
|
4577
|
+
pendingNgrokExit = void 0;
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
} catch (err) {
|
|
4581
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4582
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
4583
|
+
if (message.includes("not found")) {
|
|
4584
|
+
console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
|
|
4585
|
+
} else if (message.includes("authentication")) {
|
|
4586
|
+
console.error(colors_default.blue("Configure ngrok authentication:"));
|
|
4587
|
+
console.error(colors_default.cyan(" ngrok config add-authtoken <token>"));
|
|
4588
|
+
}
|
|
4589
|
+
try {
|
|
4590
|
+
unregisterTailscale({
|
|
4591
|
+
tailscaleHttpsPort,
|
|
4592
|
+
tailscaleFunnel: wantsFunnel || void 0
|
|
4593
|
+
});
|
|
4594
|
+
} catch {
|
|
4595
|
+
}
|
|
4596
|
+
try {
|
|
4597
|
+
store.removeRoute(hostname, process.pid);
|
|
4598
|
+
} catch {
|
|
4599
|
+
}
|
|
4600
|
+
process.exit(1);
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4302
4603
|
const basename5 = path9.basename(commandArgs[0]);
|
|
4303
4604
|
const isExpo = basename5 === "expo";
|
|
4304
4605
|
const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
|
|
@@ -4333,9 +4634,12 @@ portless
|
|
|
4333
4634
|
// own LAN discovery natively.
|
|
4334
4635
|
...lanMode ? { PORTLESS_LAN: "1" } : {},
|
|
4335
4636
|
...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
|
|
4637
|
+
...ngrokUrl ? { PORTLESS_NGROK_URL: ngrokUrl } : {},
|
|
4336
4638
|
...caEnv
|
|
4337
4639
|
},
|
|
4338
4640
|
onCleanup: () => {
|
|
4641
|
+
stoppingNgrok = true;
|
|
4642
|
+
stopNgrokProcess(ngrokProcess?.child);
|
|
4339
4643
|
try {
|
|
4340
4644
|
unregisterTailscale({
|
|
4341
4645
|
tailscaleHttpsPort,
|
|
@@ -4344,7 +4648,7 @@ portless
|
|
|
4344
4648
|
} catch {
|
|
4345
4649
|
}
|
|
4346
4650
|
try {
|
|
4347
|
-
store.removeRoute(hostname);
|
|
4651
|
+
store.removeRoute(hostname, process.pid);
|
|
4348
4652
|
} catch {
|
|
4349
4653
|
}
|
|
4350
4654
|
}
|
|
@@ -4372,7 +4676,7 @@ function appPortFromEnv() {
|
|
|
4372
4676
|
}
|
|
4373
4677
|
return port;
|
|
4374
4678
|
}
|
|
4375
|
-
function
|
|
4679
|
+
function applySharingFlag(flag) {
|
|
4376
4680
|
if (flag === "--tailscale") {
|
|
4377
4681
|
process.env.PORTLESS_TAILSCALE = "1";
|
|
4378
4682
|
return true;
|
|
@@ -4382,6 +4686,10 @@ function applyTailscaleFlag(flag) {
|
|
|
4382
4686
|
process.env.PORTLESS_TAILSCALE = "1";
|
|
4383
4687
|
return true;
|
|
4384
4688
|
}
|
|
4689
|
+
if (flag === "--ngrok") {
|
|
4690
|
+
process.env.PORTLESS_NGROK = "1";
|
|
4691
|
+
return true;
|
|
4692
|
+
}
|
|
4385
4693
|
return false;
|
|
4386
4694
|
}
|
|
4387
4695
|
function parseRunArgs(args) {
|
|
@@ -4407,6 +4715,9 @@ ${colors_default.bold("Options:")}
|
|
|
4407
4715
|
--name <name> Override the inferred base name (worktree prefix still applies)
|
|
4408
4716
|
--force Kill the existing process and take over its route
|
|
4409
4717
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
4718
|
+
--tailscale Share the app on your Tailscale network (tailnet)
|
|
4719
|
+
--funnel Share the app publicly via Tailscale Funnel
|
|
4720
|
+
--ngrok Share the app publicly via ngrok
|
|
4410
4721
|
--help, -h Show this help
|
|
4411
4722
|
|
|
4412
4723
|
${colors_default.bold("Name inference (in order):")}
|
|
@@ -4440,11 +4751,13 @@ ${colors_default.bold("Examples:")}
|
|
|
4440
4751
|
process.exit(1);
|
|
4441
4752
|
}
|
|
4442
4753
|
name = args[i];
|
|
4443
|
-
} else if (
|
|
4754
|
+
} else if (applySharingFlag(args[i])) {
|
|
4444
4755
|
} else {
|
|
4445
4756
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
4446
4757
|
console.error(
|
|
4447
|
-
colors_default.blue(
|
|
4758
|
+
colors_default.blue(
|
|
4759
|
+
"Known flags: --name, --force, --app-port, --tailscale, --funnel, --ngrok, --help"
|
|
4760
|
+
)
|
|
4448
4761
|
);
|
|
4449
4762
|
process.exit(1);
|
|
4450
4763
|
}
|
|
@@ -4466,10 +4779,12 @@ function parseAppArgs(args) {
|
|
|
4466
4779
|
} else if (args[i] === "--app-port") {
|
|
4467
4780
|
i++;
|
|
4468
4781
|
appPort = parseAppPort(args[i]);
|
|
4469
|
-
} else if (
|
|
4782
|
+
} else if (applySharingFlag(args[i])) {
|
|
4470
4783
|
} else {
|
|
4471
4784
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
4472
|
-
console.error(
|
|
4785
|
+
console.error(
|
|
4786
|
+
colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
|
|
4787
|
+
);
|
|
4473
4788
|
process.exit(1);
|
|
4474
4789
|
}
|
|
4475
4790
|
i++;
|
|
@@ -4485,10 +4800,12 @@ function parseAppArgs(args) {
|
|
|
4485
4800
|
} else if (args[i] === "--app-port") {
|
|
4486
4801
|
i++;
|
|
4487
4802
|
appPort = parseAppPort(args[i]);
|
|
4488
|
-
} else if (
|
|
4803
|
+
} else if (applySharingFlag(args[i])) {
|
|
4489
4804
|
} else {
|
|
4490
4805
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
4491
|
-
console.error(
|
|
4806
|
+
console.error(
|
|
4807
|
+
colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
|
|
4808
|
+
);
|
|
4492
4809
|
process.exit(1);
|
|
4493
4810
|
}
|
|
4494
4811
|
i++;
|
|
@@ -4516,13 +4833,14 @@ ${colors_default.bold("Usage:")}
|
|
|
4516
4833
|
${colors_default.cyan("portless run")} Same as above
|
|
4517
4834
|
${colors_default.cyan("portless run <cmd>")} Run a command through the proxy
|
|
4518
4835
|
${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
|
|
4519
|
-
${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
|
|
4836
|
+
${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon); rarely needed since it auto-starts on first run
|
|
4520
4837
|
${colors_default.cyan("portless proxy stop")} Stop the proxy
|
|
4521
4838
|
${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
|
|
4522
4839
|
${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
|
|
4523
4840
|
${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
|
|
4524
4841
|
${colors_default.cyan("portless alias --remove <name>")} Remove a static route
|
|
4525
4842
|
${colors_default.cyan("portless list")} Show active routes
|
|
4843
|
+
${colors_default.cyan("portless doctor")} Check local portless health
|
|
4526
4844
|
${colors_default.cyan("portless trust")} Add local CA to system trust store
|
|
4527
4845
|
${colors_default.cyan("portless clean")} Remove portless state, trust entry, and hosts block
|
|
4528
4846
|
${colors_default.cyan("portless prune")} Kill orphaned dev servers from crashed sessions
|
|
@@ -4540,8 +4858,10 @@ ${colors_default.bold("Examples:")}
|
|
|
4540
4858
|
portless service install --lan # Persist LAN mode in the startup service
|
|
4541
4859
|
portless service install --wildcard # Persist wildcard routing in the startup service
|
|
4542
4860
|
portless get backend # -> https://backend.localhost
|
|
4861
|
+
portless doctor # Check proxy, routes, DNS, and CA trust
|
|
4543
4862
|
portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
|
|
4544
4863
|
portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
|
|
4864
|
+
portless myapp --ngrok next dev # -> also https://<random>.ngrok.app (public)
|
|
4545
4865
|
|
|
4546
4866
|
${colors_default.bold("Configuration (portless.json):")}
|
|
4547
4867
|
Optional. Portless works out of the box by running the "dev" script
|
|
@@ -4603,6 +4923,11 @@ ${colors_default.bold("Tailscale sharing:")}
|
|
|
4603
4923
|
${colors_default.cyan("portless myapp --tailscale next dev")}
|
|
4604
4924
|
${colors_default.cyan("portless myapp --funnel next dev")}
|
|
4605
4925
|
|
|
4926
|
+
${colors_default.bold("ngrok sharing:")}
|
|
4927
|
+
Use --ngrok to expose your dev server to the public internet with ngrok.
|
|
4928
|
+
Requires the ngrok CLI to be installed and authenticated.
|
|
4929
|
+
${colors_default.cyan("portless myapp --ngrok next dev")}
|
|
4930
|
+
|
|
4606
4931
|
${colors_default.bold("Options:")}
|
|
4607
4932
|
run [--name <name>] <cmd> Infer project name (or override with --name)
|
|
4608
4933
|
Adds worktree prefix in git worktrees
|
|
@@ -4622,6 +4947,7 @@ ${colors_default.bold("Options:")}
|
|
|
4622
4947
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
4623
4948
|
--tailscale Share the app on your Tailscale network (tailnet)
|
|
4624
4949
|
--funnel Share the app publicly via Tailscale Funnel
|
|
4950
|
+
--ngrok Share the app publicly via ngrok
|
|
4625
4951
|
--force Kill the existing process and take over its route
|
|
4626
4952
|
--name <name> Use <name> as the app name (bypasses subcommand dispatch)
|
|
4627
4953
|
-- Stop flag parsing; everything after is passed to the child
|
|
@@ -4637,6 +4963,7 @@ ${colors_default.bold("Environment variables:")}
|
|
|
4637
4963
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
|
|
4638
4964
|
PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
|
|
4639
4965
|
PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
|
|
4966
|
+
PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok)
|
|
4640
4967
|
PORTLESS_STATE_DIR=<path> Override the state directory
|
|
4641
4968
|
PORTLESS=0 Run command directly without proxy
|
|
4642
4969
|
|
|
@@ -4646,6 +4973,7 @@ ${colors_default.bold("Child process environment:")}
|
|
|
4646
4973
|
PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
|
|
4647
4974
|
PORTLESS_LAN Set to 1 when proxy is in LAN mode
|
|
4648
4975
|
PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
|
|
4976
|
+
PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
|
|
4649
4977
|
NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
|
|
4650
4978
|
|
|
4651
4979
|
${colors_default.bold("Safari / DNS:")}
|
|
@@ -4661,14 +4989,14 @@ ${colors_default.bold("Skip portless:")}
|
|
|
4661
4989
|
PORTLESS=0 pnpm dev # Runs command directly without proxy
|
|
4662
4990
|
|
|
4663
4991
|
${colors_default.bold("Reserved names:")}
|
|
4664
|
-
run, get, alias, hosts, list, trust, clean, prune, proxy, service are subcommands and
|
|
4992
|
+
run, get, alias, hosts, list, doctor, trust, clean, prune, proxy, service are subcommands and
|
|
4665
4993
|
cannot be used as app names directly. Use "portless run" to infer the name,
|
|
4666
4994
|
or "portless --name <name>" to force any name including reserved ones.
|
|
4667
4995
|
`);
|
|
4668
4996
|
process.exit(0);
|
|
4669
4997
|
}
|
|
4670
4998
|
function printVersion() {
|
|
4671
|
-
console.log("0.
|
|
4999
|
+
console.log("0.15.0");
|
|
4672
5000
|
process.exit(0);
|
|
4673
5001
|
}
|
|
4674
5002
|
async function handleTrust() {
|
|
@@ -4689,7 +5017,7 @@ async function handleTrust() {
|
|
|
4689
5017
|
const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
|
|
4690
5018
|
if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
|
|
4691
5019
|
console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
|
|
4692
|
-
const sudoResult =
|
|
5020
|
+
const sudoResult = spawnSync5(
|
|
4693
5021
|
"sudo",
|
|
4694
5022
|
[
|
|
4695
5023
|
"env",
|
|
@@ -4772,6 +5100,10 @@ ${colors_default.bold("Options:")}
|
|
|
4772
5100
|
} catch {
|
|
4773
5101
|
}
|
|
4774
5102
|
}
|
|
5103
|
+
if (route.ngrokPid) {
|
|
5104
|
+
stopNgrok(route);
|
|
5105
|
+
console.log(colors_default.green(`Stopped ngrok tunnel for ${route.hostname}.`));
|
|
5106
|
+
}
|
|
4775
5107
|
}
|
|
4776
5108
|
const stateDirs = collectStateDirsForCleanup();
|
|
4777
5109
|
for (const stateDir of stateDirs) {
|
|
@@ -4851,6 +5183,10 @@ ${colors_default.bold("Options:")}
|
|
|
4851
5183
|
} catch {
|
|
4852
5184
|
}
|
|
4853
5185
|
}
|
|
5186
|
+
if (route.ngrokPid) {
|
|
5187
|
+
stopNgrok(route);
|
|
5188
|
+
console.log(` ${route.hostname} - stopped ngrok tunnel`);
|
|
5189
|
+
}
|
|
4854
5190
|
}
|
|
4855
5191
|
let killed = 0;
|
|
4856
5192
|
for (const route of stale) {
|
|
@@ -5029,7 +5365,7 @@ ${colors_default.bold("Auto-sync:")}
|
|
|
5029
5365
|
`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
|
|
5030
5366
|
)
|
|
5031
5367
|
);
|
|
5032
|
-
const result =
|
|
5368
|
+
const result = spawnSync5(
|
|
5033
5369
|
"sudo",
|
|
5034
5370
|
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
|
|
5035
5371
|
{
|
|
@@ -5082,7 +5418,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
|
|
|
5082
5418
|
console.log(
|
|
5083
5419
|
colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
5084
5420
|
);
|
|
5085
|
-
const result =
|
|
5421
|
+
const result = spawnSync5(
|
|
5086
5422
|
"sudo",
|
|
5087
5423
|
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
|
|
5088
5424
|
{
|
|
@@ -5097,6 +5433,355 @@ ${colors_default.bold("Usage: portless hosts <command>")}
|
|
|
5097
5433
|
);
|
|
5098
5434
|
process.exit(1);
|
|
5099
5435
|
}
|
|
5436
|
+
function colorDoctorStatus(status) {
|
|
5437
|
+
if (status === "fail") return colors_default.red;
|
|
5438
|
+
if (status === "warn") return colors_default.yellow;
|
|
5439
|
+
if (status === "ok") return colors_default.green;
|
|
5440
|
+
return colors_default.gray;
|
|
5441
|
+
}
|
|
5442
|
+
function printDoctorFinding(finding) {
|
|
5443
|
+
const status = finding.status.padEnd(5);
|
|
5444
|
+
console.log(`${colorDoctorStatus(finding.status)(status)} ${finding.message}`);
|
|
5445
|
+
if (finding.hint) {
|
|
5446
|
+
console.log(colors_default.gray(` ${finding.hint}`));
|
|
5447
|
+
}
|
|
5448
|
+
}
|
|
5449
|
+
function isProcessAliveForDoctor(pid) {
|
|
5450
|
+
if (pid <= 0) return true;
|
|
5451
|
+
return isProcessAlive(pid);
|
|
5452
|
+
}
|
|
5453
|
+
function checkPathWritable(targetPath) {
|
|
5454
|
+
try {
|
|
5455
|
+
fs9.accessSync(targetPath, fs9.constants.W_OK);
|
|
5456
|
+
return true;
|
|
5457
|
+
} catch {
|
|
5458
|
+
return false;
|
|
5459
|
+
}
|
|
5460
|
+
}
|
|
5461
|
+
function findExistingAncestor(targetPath) {
|
|
5462
|
+
let current = targetPath;
|
|
5463
|
+
for (; ; ) {
|
|
5464
|
+
if (fs9.existsSync(current)) return current;
|
|
5465
|
+
const parent = path9.dirname(current);
|
|
5466
|
+
if (parent === current) return null;
|
|
5467
|
+
current = parent;
|
|
5468
|
+
}
|
|
5469
|
+
}
|
|
5470
|
+
function checkCommandAvailable(command, args) {
|
|
5471
|
+
const result = spawnSync5(command, args, {
|
|
5472
|
+
stdio: "ignore",
|
|
5473
|
+
timeout: 3e3,
|
|
5474
|
+
windowsHide: true
|
|
5475
|
+
});
|
|
5476
|
+
return !result.error && (result.status === 0 || result.status === null);
|
|
5477
|
+
}
|
|
5478
|
+
function pluralize(count, singular, plural = `${singular}s`) {
|
|
5479
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
5480
|
+
}
|
|
5481
|
+
function isValidTcpPort(port) {
|
|
5482
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
5483
|
+
}
|
|
5484
|
+
function doctorProxyStartHint(proxyPort, tls2) {
|
|
5485
|
+
const defaultPort = getDefaultPort(tls2);
|
|
5486
|
+
const portArgs = proxyPort === defaultPort ? "" : ` -p ${proxyPort}`;
|
|
5487
|
+
const tlsArgs = tls2 ? "" : " --no-tls";
|
|
5488
|
+
return `Run: portless proxy start${portArgs}${tlsArgs}`;
|
|
5489
|
+
}
|
|
5490
|
+
async function handleDoctor(args) {
|
|
5491
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
5492
|
+
console.log(`
|
|
5493
|
+
${colors_default.bold("portless doctor")} - Check local portless health and print suggested fixes.
|
|
5494
|
+
|
|
5495
|
+
${colors_default.bold("Usage:")}
|
|
5496
|
+
${colors_default.cyan("portless doctor")}
|
|
5497
|
+
|
|
5498
|
+
Checks Node.js, the state directory, proxy liveness, route entries, HTTPS CA
|
|
5499
|
+
trust, hostname resolution, and LAN mode prerequisites. It does not start,
|
|
5500
|
+
stop, clean, prune, trust, or modify portless state.
|
|
5501
|
+
|
|
5502
|
+
${colors_default.bold("Options:")}
|
|
5503
|
+
--help, -h Show this help
|
|
5504
|
+
`);
|
|
5505
|
+
process.exit(0);
|
|
5506
|
+
}
|
|
5507
|
+
if (args.length > 1) {
|
|
5508
|
+
console.error(colors_default.red(`Error: Unknown argument "${args[1]}".`));
|
|
5509
|
+
console.error(colors_default.cyan(" portless doctor --help"));
|
|
5510
|
+
process.exit(1);
|
|
5511
|
+
}
|
|
5512
|
+
const findings = [];
|
|
5513
|
+
const add = (status, message, hint) => {
|
|
5514
|
+
findings.push({ status, message, hint });
|
|
5515
|
+
};
|
|
5516
|
+
let state;
|
|
5517
|
+
try {
|
|
5518
|
+
state = await discoverState();
|
|
5519
|
+
} catch (err) {
|
|
5520
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5521
|
+
add("fail", `Could not discover portless state: ${message}`);
|
|
5522
|
+
state = {
|
|
5523
|
+
dir: resolveStateDir(),
|
|
5524
|
+
port: getDefaultPort(!isHttpsEnvDisabled()),
|
|
5525
|
+
tls: !isHttpsEnvDisabled(),
|
|
5526
|
+
tld: DEFAULT_TLD,
|
|
5527
|
+
lanMode: isLanEnvEnabled(),
|
|
5528
|
+
lanIp: null
|
|
5529
|
+
};
|
|
5530
|
+
}
|
|
5531
|
+
const store = new RouteStore(state.dir, {
|
|
5532
|
+
onWarning: (msg) => add("warn", msg)
|
|
5533
|
+
});
|
|
5534
|
+
const hasPortFile = fs9.existsSync(store.portFilePath);
|
|
5535
|
+
const configuredTls = hasPortFile ? state.tls : !isHttpsEnvDisabled();
|
|
5536
|
+
const configuredPort = hasPortFile ? state.port : getDefaultPort(configuredTls);
|
|
5537
|
+
const initialProxyRunning = await isProxyRunning(state.port, state.tls);
|
|
5538
|
+
const probePort = initialProxyRunning || hasPortFile ? state.port : configuredPort;
|
|
5539
|
+
const probeTls = initialProxyRunning || hasPortFile ? state.tls : configuredTls;
|
|
5540
|
+
const proxyRunning = initialProxyRunning && probePort === state.port ? true : await isProxyRunning(probePort, probeTls);
|
|
5541
|
+
const portListening = proxyRunning ? true : await isPortListening(probePort);
|
|
5542
|
+
const proxyPort = proxyRunning || portListening || hasPortFile ? probePort : configuredPort;
|
|
5543
|
+
const proxyTls = proxyRunning || portListening || hasPortFile ? probeTls : configuredTls;
|
|
5544
|
+
const currentProxyStateIsHttp = (proxyRunning || portListening || hasPortFile) && !proxyTls;
|
|
5545
|
+
const proxyUsesCustomCert = proxyTls && readCustomCertMarker(state.dir);
|
|
5546
|
+
const stateExists = fs9.existsSync(state.dir);
|
|
5547
|
+
console.log(colors_default.blue.bold("\nportless doctor\n"));
|
|
5548
|
+
console.log(`Version: ${"0.15.0"}`);
|
|
5549
|
+
console.log(`Node.js: ${process.versions.node}`);
|
|
5550
|
+
console.log(`Platform: ${process.platform} ${process.arch}`);
|
|
5551
|
+
console.log(`State dir: ${state.dir}`);
|
|
5552
|
+
console.log(`Proxy target: ${formatUrl("127.0.0.1", proxyPort, proxyTls)}`);
|
|
5553
|
+
console.log(`Mode: ${proxyTls ? "HTTPS" : "HTTP"}, .${state.tld}${state.lanMode ? ", LAN" : ""}`);
|
|
5554
|
+
console.log("");
|
|
5555
|
+
const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
|
|
5556
|
+
if (nodeMajor >= 24) {
|
|
5557
|
+
add("ok", `Node.js ${process.versions.node} satisfies portless requirements.`);
|
|
5558
|
+
} else {
|
|
5559
|
+
add("fail", `Node.js ${process.versions.node} is unsupported.`, "Install Node.js 24 or newer.");
|
|
5560
|
+
}
|
|
5561
|
+
if (stateExists) {
|
|
5562
|
+
try {
|
|
5563
|
+
const stat = fs9.statSync(state.dir);
|
|
5564
|
+
if (!stat.isDirectory()) {
|
|
5565
|
+
add("fail", `State path exists but is not a directory: ${state.dir}`);
|
|
5566
|
+
} else if (checkPathWritable(state.dir)) {
|
|
5567
|
+
add("ok", `State directory is writable: ${state.dir}`);
|
|
5568
|
+
} else {
|
|
5569
|
+
add("fail", `State directory is not writable: ${state.dir}`);
|
|
5570
|
+
}
|
|
5571
|
+
} catch (err) {
|
|
5572
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5573
|
+
add("fail", `Could not inspect state directory: ${message}`);
|
|
5574
|
+
}
|
|
5575
|
+
} else {
|
|
5576
|
+
const ancestor = findExistingAncestor(path9.dirname(state.dir));
|
|
5577
|
+
if (!ancestor) {
|
|
5578
|
+
add(
|
|
5579
|
+
"fail",
|
|
5580
|
+
`State directory does not exist and no writable ancestor was found: ${state.dir}`
|
|
5581
|
+
);
|
|
5582
|
+
} else {
|
|
5583
|
+
const ancestorStat = fs9.statSync(ancestor);
|
|
5584
|
+
if (!ancestorStat.isDirectory()) {
|
|
5585
|
+
add("fail", `State directory does not exist and ancestor is not a directory: ${ancestor}`);
|
|
5586
|
+
} else if (checkPathWritable(ancestor)) {
|
|
5587
|
+
add("info", `State directory has not been created yet: ${state.dir}`);
|
|
5588
|
+
} else {
|
|
5589
|
+
add("fail", `State directory does not exist and ancestor is not writable: ${ancestor}`);
|
|
5590
|
+
}
|
|
5591
|
+
}
|
|
5592
|
+
}
|
|
5593
|
+
if (proxyRunning) {
|
|
5594
|
+
add("ok", `Proxy is responding on port ${proxyPort}.`);
|
|
5595
|
+
} else if (portListening) {
|
|
5596
|
+
const pid = findPidOnPort(proxyPort);
|
|
5597
|
+
add(
|
|
5598
|
+
"fail",
|
|
5599
|
+
`Port ${proxyPort} is in use, but it is not a portless proxy.`,
|
|
5600
|
+
pid ? `Process on port: PID ${pid}` : "Could not identify the process holding the port."
|
|
5601
|
+
);
|
|
5602
|
+
} else {
|
|
5603
|
+
add(
|
|
5604
|
+
"warn",
|
|
5605
|
+
`Proxy is not running on port ${proxyPort}.`,
|
|
5606
|
+
doctorProxyStartHint(proxyPort, proxyTls)
|
|
5607
|
+
);
|
|
5608
|
+
}
|
|
5609
|
+
if (fs9.existsSync(store.pidPath)) {
|
|
5610
|
+
try {
|
|
5611
|
+
const rawPid = fs9.readFileSync(store.pidPath, "utf-8").trim();
|
|
5612
|
+
const pid = parseInt(rawPid, 10);
|
|
5613
|
+
if (isNaN(pid) || pid <= 0) {
|
|
5614
|
+
add("fail", `Proxy PID file is invalid: ${store.pidPath}`);
|
|
5615
|
+
} else if (!isProcessAliveForDoctor(pid)) {
|
|
5616
|
+
add("warn", `Proxy PID file is stale: ${pid}`, "Run: portless proxy stop");
|
|
5617
|
+
} else if (!proxyRunning) {
|
|
5618
|
+
add(
|
|
5619
|
+
"warn",
|
|
5620
|
+
`Proxy PID file points to PID ${pid}, but no portless proxy is responding on port ${proxyPort}.`,
|
|
5621
|
+
"Run: portless proxy stop"
|
|
5622
|
+
);
|
|
5623
|
+
} else {
|
|
5624
|
+
const portPid = findPidOnPort(proxyPort);
|
|
5625
|
+
if (portPid !== null && portPid !== pid) {
|
|
5626
|
+
add(
|
|
5627
|
+
"warn",
|
|
5628
|
+
`Proxy PID file points to PID ${pid}, but port ${proxyPort} is owned by PID ${portPid}.`,
|
|
5629
|
+
"Run: portless proxy stop"
|
|
5630
|
+
);
|
|
5631
|
+
} else {
|
|
5632
|
+
add("ok", `Proxy PID file points to the responding proxy process: ${pid}`);
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
} catch (err) {
|
|
5636
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5637
|
+
add("fail", `Could not read proxy PID file: ${message}`);
|
|
5638
|
+
}
|
|
5639
|
+
} else if (proxyRunning) {
|
|
5640
|
+
add("warn", `Proxy is running but the PID file is missing: ${store.pidPath}`);
|
|
5641
|
+
}
|
|
5642
|
+
if (proxyUsesCustomCert) {
|
|
5643
|
+
add("ok", "Proxy is configured with a custom TLS certificate.");
|
|
5644
|
+
} else if (proxyTls || !currentProxyStateIsHttp && !isHttpsEnvDisabled()) {
|
|
5645
|
+
if (checkCommandAvailable("openssl", ["version"])) {
|
|
5646
|
+
add("ok", "OpenSSL is available for certificate generation.");
|
|
5647
|
+
} else {
|
|
5648
|
+
add(
|
|
5649
|
+
"fail",
|
|
5650
|
+
"OpenSSL is not available on PATH.",
|
|
5651
|
+
isWindows ? "Install OpenSSL or add Git for Windows OpenSSL to PATH." : "Install OpenSSL with your system package manager."
|
|
5652
|
+
);
|
|
5653
|
+
}
|
|
5654
|
+
} else {
|
|
5655
|
+
add("info", "HTTPS is disabled, so OpenSSL is not required for this run.");
|
|
5656
|
+
}
|
|
5657
|
+
if (proxyTls && proxyUsesCustomCert) {
|
|
5658
|
+
add("info", "Generated local CA is not required for custom TLS certificates.");
|
|
5659
|
+
} else if (proxyTls) {
|
|
5660
|
+
const caPath = path9.join(state.dir, "ca.pem");
|
|
5661
|
+
if (fs9.existsSync(caPath)) {
|
|
5662
|
+
if (isCATrusted(state.dir)) {
|
|
5663
|
+
add("ok", "Local CA is trusted by the OS trust store.");
|
|
5664
|
+
} else {
|
|
5665
|
+
add(
|
|
5666
|
+
"warn",
|
|
5667
|
+
"Local CA exists but is not trusted by the OS trust store.",
|
|
5668
|
+
"Run: portless trust"
|
|
5669
|
+
);
|
|
5670
|
+
}
|
|
5671
|
+
} else if (proxyRunning) {
|
|
5672
|
+
add("warn", `Generated CA file is missing: ${caPath}`);
|
|
5673
|
+
} else {
|
|
5674
|
+
add("info", "Local CA has not been generated yet.");
|
|
5675
|
+
}
|
|
5676
|
+
} else {
|
|
5677
|
+
add("info", "HTTPS is disabled for the current proxy state.");
|
|
5678
|
+
}
|
|
5679
|
+
let rawRoutes = [];
|
|
5680
|
+
try {
|
|
5681
|
+
rawRoutes = store.loadRoutesRaw();
|
|
5682
|
+
} catch (err) {
|
|
5683
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5684
|
+
add("fail", `Could not read routes: ${message}`);
|
|
5685
|
+
}
|
|
5686
|
+
const liveRoutes = rawRoutes.filter(
|
|
5687
|
+
(route) => route.pid === 0 || isProcessAliveForDoctor(route.pid)
|
|
5688
|
+
);
|
|
5689
|
+
const staleRoutes = rawRoutes.filter(
|
|
5690
|
+
(route) => route.pid !== 0 && !isProcessAliveForDoctor(route.pid)
|
|
5691
|
+
);
|
|
5692
|
+
if (rawRoutes.length === 0) {
|
|
5693
|
+
add("info", "No routes are registered.");
|
|
5694
|
+
} else if (staleRoutes.length === 0) {
|
|
5695
|
+
add("ok", `Routes: ${pluralize(liveRoutes.length, "active route")}.`);
|
|
5696
|
+
} else {
|
|
5697
|
+
add(
|
|
5698
|
+
"warn",
|
|
5699
|
+
`Routes: ${pluralize(liveRoutes.length, "active route")}, ${pluralize(staleRoutes.length, "stale route")}.`,
|
|
5700
|
+
"Run: portless prune"
|
|
5701
|
+
);
|
|
5702
|
+
}
|
|
5703
|
+
for (const route of staleRoutes.slice(0, 5)) {
|
|
5704
|
+
add("warn", `Stale route ${route.hostname} is owned by exited PID ${route.pid}.`);
|
|
5705
|
+
}
|
|
5706
|
+
if (staleRoutes.length > 5) {
|
|
5707
|
+
add("warn", `${staleRoutes.length - 5} additional stale routes hidden.`);
|
|
5708
|
+
}
|
|
5709
|
+
const routePortChecks = await Promise.all(
|
|
5710
|
+
liveRoutes.map(async (route) => {
|
|
5711
|
+
const validPort = isValidTcpPort(route.port);
|
|
5712
|
+
return {
|
|
5713
|
+
route,
|
|
5714
|
+
invalidPort: !validPort,
|
|
5715
|
+
listening: validPort ? await isPortListening(route.port) : false
|
|
5716
|
+
};
|
|
5717
|
+
})
|
|
5718
|
+
);
|
|
5719
|
+
for (const { route, invalidPort, listening } of routePortChecks) {
|
|
5720
|
+
if (invalidPort) {
|
|
5721
|
+
add(
|
|
5722
|
+
"warn",
|
|
5723
|
+
`Route ${route.hostname} has invalid port ${route.port}.`,
|
|
5724
|
+
route.pid === 0 ? "Remove or recreate the alias." : "Run: portless prune"
|
|
5725
|
+
);
|
|
5726
|
+
continue;
|
|
5727
|
+
}
|
|
5728
|
+
if (listening) continue;
|
|
5729
|
+
add(
|
|
5730
|
+
"warn",
|
|
5731
|
+
`Route ${route.hostname} points to port ${route.port}, but nothing is listening there.`,
|
|
5732
|
+
route.pid === 0 ? "Remove the alias or start that service." : "The app may still be starting."
|
|
5733
|
+
);
|
|
5734
|
+
}
|
|
5735
|
+
if (state.lanMode || isLanEnvEnabled()) {
|
|
5736
|
+
const mdns = isMdnsSupported();
|
|
5737
|
+
if (mdns.supported) {
|
|
5738
|
+
add("ok", "mDNS publishing support is available for LAN mode.");
|
|
5739
|
+
} else {
|
|
5740
|
+
add("fail", `LAN mode is enabled but mDNS publishing is unavailable: ${mdns.reason}`);
|
|
5741
|
+
}
|
|
5742
|
+
if (state.lanIp) {
|
|
5743
|
+
add("ok", `LAN IP is recorded: ${state.lanIp}`);
|
|
5744
|
+
} else {
|
|
5745
|
+
add("warn", "LAN mode is enabled but no LAN IP is recorded.");
|
|
5746
|
+
}
|
|
5747
|
+
} else if (liveRoutes.length > 0) {
|
|
5748
|
+
const managedHosts = new Set(getManagedHostnames());
|
|
5749
|
+
const resolutionChecks = await Promise.all(
|
|
5750
|
+
liveRoutes.map(async (route) => ({
|
|
5751
|
+
hostname: route.hostname,
|
|
5752
|
+
resolves: await checkHostResolution(route.hostname),
|
|
5753
|
+
managed: managedHosts.has(route.hostname)
|
|
5754
|
+
}))
|
|
5755
|
+
);
|
|
5756
|
+
const unresolved = resolutionChecks.filter((result) => !result.resolves);
|
|
5757
|
+
if (unresolved.length === 0) {
|
|
5758
|
+
add("ok", "Registered hostnames resolve through the system resolver.");
|
|
5759
|
+
} else {
|
|
5760
|
+
add(
|
|
5761
|
+
"warn",
|
|
5762
|
+
`${pluralize(unresolved.length, "hostname")} did not resolve through the system resolver.`,
|
|
5763
|
+
"Run: portless hosts sync"
|
|
5764
|
+
);
|
|
5765
|
+
for (const result of unresolved.slice(0, 5)) {
|
|
5766
|
+
const hostState = result.managed ? "present in hosts block" : "missing from hosts block";
|
|
5767
|
+
add("warn", `${result.hostname} is ${hostState}.`);
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
}
|
|
5771
|
+
for (const finding of findings) {
|
|
5772
|
+
printDoctorFinding(finding);
|
|
5773
|
+
}
|
|
5774
|
+
const failures = findings.filter((finding) => finding.status === "fail").length;
|
|
5775
|
+
const warnings = findings.filter((finding) => finding.status === "warn").length;
|
|
5776
|
+
console.log("");
|
|
5777
|
+
if (failures > 0) {
|
|
5778
|
+
console.log(
|
|
5779
|
+
colors_default.red(`Summary: ${pluralize(failures, "failure")}, ${pluralize(warnings, "warning")}.`)
|
|
5780
|
+
);
|
|
5781
|
+
process.exit(1);
|
|
5782
|
+
}
|
|
5783
|
+
console.log(colors_default.green(`Summary: 0 failures, ${pluralize(warnings, "warning")}.`));
|
|
5784
|
+
}
|
|
5100
5785
|
async function handleProxy(args) {
|
|
5101
5786
|
if (args[1] === "stop") {
|
|
5102
5787
|
let explicitPort;
|
|
@@ -5373,7 +6058,7 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
5373
6058
|
if (!hasExplicitPort) {
|
|
5374
6059
|
console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
|
|
5375
6060
|
}
|
|
5376
|
-
const result =
|
|
6061
|
+
const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
|
|
5377
6062
|
stdio: "inherit",
|
|
5378
6063
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
5379
6064
|
});
|
|
@@ -5482,7 +6167,15 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
5482
6167
|
}
|
|
5483
6168
|
if (isForeground) {
|
|
5484
6169
|
console.log(chalk.blue.bold("\nportless proxy\n"));
|
|
5485
|
-
startProxyServer(
|
|
6170
|
+
startProxyServer(
|
|
6171
|
+
store,
|
|
6172
|
+
proxyPort,
|
|
6173
|
+
tld,
|
|
6174
|
+
tlsOptions,
|
|
6175
|
+
lanIp,
|
|
6176
|
+
desiredWildcard ? false : void 0,
|
|
6177
|
+
!!(customCertPath && customKeyPath)
|
|
6178
|
+
);
|
|
5486
6179
|
return;
|
|
5487
6180
|
}
|
|
5488
6181
|
store.ensureDir();
|
|
@@ -5513,7 +6206,7 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
5513
6206
|
skipTrust: true
|
|
5514
6207
|
}).args
|
|
5515
6208
|
];
|
|
5516
|
-
const child =
|
|
6209
|
+
const child = spawn4(process.execPath, daemonArgs, {
|
|
5517
6210
|
detached: true,
|
|
5518
6211
|
stdio: ["ignore", logFd, logFd],
|
|
5519
6212
|
env: process.env,
|
|
@@ -5619,7 +6312,7 @@ async function handleDefaultSingle(cwd, scriptName, appConfig) {
|
|
|
5619
6312
|
);
|
|
5620
6313
|
}
|
|
5621
6314
|
function spawnChildProcess(commandArgs, env, cwd) {
|
|
5622
|
-
return
|
|
6315
|
+
return spawn4(commandArgs[0], commandArgs.slice(1), {
|
|
5623
6316
|
stdio: ["ignore", "pipe", "pipe"],
|
|
5624
6317
|
env,
|
|
5625
6318
|
cwd,
|
|
@@ -5698,7 +6391,7 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
|
|
|
5698
6391
|
}
|
|
5699
6392
|
if (capturedStore && capturedHostname) {
|
|
5700
6393
|
try {
|
|
5701
|
-
capturedStore.removeRoute(capturedHostname);
|
|
6394
|
+
capturedStore.removeRoute(capturedHostname, process.pid);
|
|
5702
6395
|
} catch {
|
|
5703
6396
|
}
|
|
5704
6397
|
}
|
|
@@ -5890,7 +6583,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
5890
6583
|
const pm = detectPackageManager(wsRoot);
|
|
5891
6584
|
const useRootScript = hasScript(scriptName, wsRoot);
|
|
5892
6585
|
const turboArgs = useRootScript ? [pm, "run", scriptName, ...extraArgs] : pm === "npm" ? ["npx", "turbo", "run", scriptName, ...extraArgs] : pm === "bun" ? ["bunx", "turbo", "run", scriptName, ...extraArgs] : [pm, "exec", "turbo", "run", scriptName, ...extraArgs];
|
|
5893
|
-
const turboChild =
|
|
6586
|
+
const turboChild = spawn4(turboArgs[0], turboArgs.slice(1), {
|
|
5894
6587
|
stdio: "inherit",
|
|
5895
6588
|
cwd: wsRoot,
|
|
5896
6589
|
env: {
|
|
@@ -5912,7 +6605,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
5912
6605
|
}, SIGKILL_TIMEOUT_MS).unref();
|
|
5913
6606
|
for (const { hostname } of routes) {
|
|
5914
6607
|
try {
|
|
5915
|
-
store.removeRoute(hostname);
|
|
6608
|
+
store.removeRoute(hostname, process.pid);
|
|
5916
6609
|
} catch {
|
|
5917
6610
|
}
|
|
5918
6611
|
}
|
|
@@ -5976,7 +6669,7 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
|
|
|
5976
6669
|
}, SIGKILL_TIMEOUT_MS).unref();
|
|
5977
6670
|
for (const { store, hostname } of routeEntries) {
|
|
5978
6671
|
try {
|
|
5979
|
-
store.removeRoute(hostname);
|
|
6672
|
+
store.removeRoute(hostname, process.pid);
|
|
5980
6673
|
} catch {
|
|
5981
6674
|
}
|
|
5982
6675
|
}
|
|
@@ -6153,6 +6846,9 @@ async function main() {
|
|
|
6153
6846
|
process.env.PORTLESS_FUNNEL = "1";
|
|
6154
6847
|
process.env.PORTLESS_TAILSCALE = "1";
|
|
6155
6848
|
}
|
|
6849
|
+
if (stripGlobalFlag("--ngrok", false)) {
|
|
6850
|
+
process.env.PORTLESS_NGROK = "1";
|
|
6851
|
+
}
|
|
6156
6852
|
const scriptResult = stripGlobalFlag("--script", true);
|
|
6157
6853
|
if (scriptResult === false) {
|
|
6158
6854
|
console.error(colors_default.red("Error: --script requires a script name."));
|
|
@@ -6185,7 +6881,7 @@ async function main() {
|
|
|
6185
6881
|
args.shift();
|
|
6186
6882
|
}
|
|
6187
6883
|
const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
|
|
6188
|
-
if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
|
|
6884
|
+
if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "doctor" && args[0] !== "service")) {
|
|
6189
6885
|
const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
|
|
6190
6886
|
let commandArgs = parsed.commandArgs;
|
|
6191
6887
|
if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
|
|
@@ -6233,6 +6929,10 @@ async function main() {
|
|
|
6233
6929
|
await handleList();
|
|
6234
6930
|
return;
|
|
6235
6931
|
}
|
|
6932
|
+
if (args[0] === "doctor") {
|
|
6933
|
+
await handleDoctor(args);
|
|
6934
|
+
return;
|
|
6935
|
+
}
|
|
6236
6936
|
if (args[0] === "get") {
|
|
6237
6937
|
await handleGet(args);
|
|
6238
6938
|
return;
|