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/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-3WLVQXFE.js";
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 spawn3, spawnSync as spawnSync4 } from "child_process";
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 ? spawn("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
1915
+ const child = isWindows ? spawn2("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
1722
1916
  stdio: "inherit",
1723
1917
  env
1724
- }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
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 spawn2, spawnSync as spawnSync2 } from "child_process";
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 = spawnSync2(command, probeArgs, {
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 = spawn2(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
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 spawnSync3 } from "child_process";
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 defaultRunner2(command, args, options) {
2618
- return spawnSync3(command, args, {
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 = defaultRunner2) {
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 || defaultRunner2;
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 = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
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 = spawnSync4(
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 = spawnSync4("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
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 startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
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 = spawnSync4(process.execPath, startArgs, {
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 === "1" || process.env.PORTLESS_FUNNEL === "true";
4159
- const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
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 applyTailscaleFlag(flag) {
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 (applyTailscaleFlag(args[i])) {
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("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
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 (applyTailscaleFlag(args[i])) {
4782
+ } else if (applySharingFlag(args[i])) {
4470
4783
  } else {
4471
4784
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4472
- console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
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 (applyTailscaleFlag(args[i])) {
4803
+ } else if (applySharingFlag(args[i])) {
4489
4804
  } else {
4490
4805
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4491
- console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
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.13.1");
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 = spawnSync4(
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 = spawnSync4(
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 = spawnSync4(
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 = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
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(store, proxyPort, tld, tlsOptions, lanIp, desiredWildcard ? false : void 0);
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 = spawn3(process.execPath, daemonArgs, {
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 spawn3(commandArgs[0], commandArgs.slice(1), {
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 = spawn3(turboArgs[0], turboArgs.slice(1), {
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;