portless 0.13.0 → 0.14.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
@@ -13,7 +13,7 @@ import {
13
13
  parseHostname,
14
14
  shouldAutoSyncHosts,
15
15
  syncHostsFile
16
- } from "./chunk-3WLVQXFE.js";
16
+ } from "./chunk-OZM4AEYL.js";
17
17
 
18
18
  // src/colors.ts
19
19
  function supportsColor() {
@@ -41,7 +41,7 @@ var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
41
41
  // src/cli.ts
42
42
  import * as fs9 from "fs";
43
43
  import * as path9 from "path";
44
- import { spawn as spawn3, spawnSync as spawnSync4 } from "child_process";
44
+ import { spawn as spawn4, spawnSync as spawnSync5 } from "child_process";
45
45
  import { StringDecoder } from "string_decoder";
46
46
 
47
47
  // src/certs.ts
@@ -999,6 +999,178 @@ function formatTailscaleUrl(baseUrl, httpsPort) {
999
999
  return `${trimmed}:${httpsPort}`;
1000
1000
  }
1001
1001
 
1002
+ // src/ngrok.ts
1003
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1004
+ var NGROK_BINARY = "ngrok";
1005
+ var NGROK_START_TIMEOUT_MS = 3e4;
1006
+ var NGROK_COMMAND_TIMEOUT_MS = 1e4;
1007
+ var OUTPUT_BUFFER_LIMIT = 16384;
1008
+ function defaultSpawner(args) {
1009
+ return spawn(NGROK_BINARY, args, {
1010
+ stdio: ["ignore", "pipe", "pipe"],
1011
+ windowsHide: true
1012
+ });
1013
+ }
1014
+ function defaultRunner2(args) {
1015
+ const result = spawnSync2(NGROK_BINARY, args, {
1016
+ encoding: "utf-8",
1017
+ killSignal: "SIGKILL",
1018
+ timeout: NGROK_COMMAND_TIMEOUT_MS
1019
+ });
1020
+ return {
1021
+ status: result.status,
1022
+ stdout: result.stdout ?? "",
1023
+ stderr: result.stderr ?? "",
1024
+ ...result.error ? { error: result.error } : {}
1025
+ };
1026
+ }
1027
+ function normalizeSpace2(value) {
1028
+ return value.trim().replace(/\s+/g, " ");
1029
+ }
1030
+ function formatSpawnError(error) {
1031
+ const errno = error;
1032
+ if (errno.code === "ENOENT") {
1033
+ return new Error(
1034
+ "ngrok CLI not found. Install ngrok (https://ngrok.com/download) and ensure `ngrok` is on PATH."
1035
+ );
1036
+ }
1037
+ return new Error(`Failed to start ngrok: ${error.message}`);
1038
+ }
1039
+ function formatOutputError(output) {
1040
+ const details = normalizeSpace2(output);
1041
+ const lower = details.toLowerCase();
1042
+ if (lower.includes("authtoken") || lower.includes("authentication") || lower.includes("not logged in")) {
1043
+ return new Error(
1044
+ "ngrok could not start because authentication is not configured. Run `ngrok config add-authtoken <token>`, then run portless again."
1045
+ );
1046
+ }
1047
+ return new Error(
1048
+ `Failed to start ngrok tunnel: ${details || "ngrok exited before printing a public URL"}`
1049
+ );
1050
+ }
1051
+ function ensureNgrokAvailable(runner = defaultRunner2) {
1052
+ const result = runner(["version"]);
1053
+ if (result.error) {
1054
+ throw formatSpawnError(result.error);
1055
+ }
1056
+ if (result.status !== 0) {
1057
+ const details = normalizeSpace2(result.stderr || result.stdout);
1058
+ throw new Error(`Failed to check ngrok version: ${details || "unknown ngrok error"}`);
1059
+ }
1060
+ }
1061
+ function cleanUrl(value) {
1062
+ return value.replace(/[),.]+$/g, "");
1063
+ }
1064
+ function extractNgrokUrl(output) {
1065
+ const urlMatches = output.matchAll(/https:\/\/[^\s"'<>]+/g);
1066
+ for (const match of urlMatches) {
1067
+ const raw = match[0];
1068
+ const matchIndex = match.index ?? 0;
1069
+ const before = output.slice(Math.max(0, matchIndex - 80), matchIndex).toLowerCase();
1070
+ const looksLikeTunnel = before.includes("forwarding") || before.includes("url=") || before.includes('"url"') || before.includes("started tunnel");
1071
+ if (!looksLikeTunnel) continue;
1072
+ const candidate = cleanUrl(raw);
1073
+ try {
1074
+ const parsed = new URL(candidate);
1075
+ if (parsed.hostname === "ngrok.com" || parsed.hostname.endsWith(".ngrok.com")) {
1076
+ continue;
1077
+ }
1078
+ return parsed.toString().replace(/\/$/, "");
1079
+ } catch {
1080
+ continue;
1081
+ }
1082
+ }
1083
+ return null;
1084
+ }
1085
+ function buildNgrokArgs(localPort, hostHeader = "rewrite") {
1086
+ return [
1087
+ "http",
1088
+ "--log=stdout",
1089
+ "--log-format=logfmt",
1090
+ `--host-header=${hostHeader}`,
1091
+ `http://127.0.0.1:${localPort}`
1092
+ ];
1093
+ }
1094
+ function startNgrok(localPort, options = {}) {
1095
+ const spawner = options.spawner ?? defaultSpawner;
1096
+ const timeoutMs = options.timeoutMs ?? NGROK_START_TIMEOUT_MS;
1097
+ const args = buildNgrokArgs(localPort, options.hostHeader);
1098
+ let child;
1099
+ try {
1100
+ child = spawner(args);
1101
+ } catch (err) {
1102
+ return Promise.reject(formatSpawnError(err instanceof Error ? err : new Error(String(err))));
1103
+ }
1104
+ return new Promise((resolve4, reject) => {
1105
+ let settled = false;
1106
+ let started = false;
1107
+ let output = "";
1108
+ const settle = (fn) => {
1109
+ if (settled) return;
1110
+ settled = true;
1111
+ clearTimeout(timer);
1112
+ fn();
1113
+ };
1114
+ const appendOutput = (chunk) => {
1115
+ if (settled) return;
1116
+ output += chunk.toString();
1117
+ if (output.length > OUTPUT_BUFFER_LIMIT) {
1118
+ output = output.slice(-OUTPUT_BUFFER_LIMIT);
1119
+ }
1120
+ const url = extractNgrokUrl(output);
1121
+ if (url) {
1122
+ settle(() => {
1123
+ started = true;
1124
+ resolve4({ url, pid: child.pid, child });
1125
+ });
1126
+ }
1127
+ };
1128
+ const timer = setTimeout(() => {
1129
+ try {
1130
+ child.kill("SIGTERM");
1131
+ } catch {
1132
+ }
1133
+ settle(
1134
+ () => reject(
1135
+ new Error(
1136
+ "Timed out waiting for ngrok to print a public URL. Check that ngrok is authenticated and can connect."
1137
+ )
1138
+ )
1139
+ );
1140
+ }, timeoutMs);
1141
+ child.stdout?.on("data", appendOutput);
1142
+ child.stderr?.on("data", appendOutput);
1143
+ child.on("error", (err) => {
1144
+ settle(() => reject(formatSpawnError(err)));
1145
+ });
1146
+ child.on("exit", (code, signal) => {
1147
+ if (settled) {
1148
+ if (started) options.onExit?.(code, signal);
1149
+ return;
1150
+ }
1151
+ settle(() => {
1152
+ const suffix = signal ? ` (signal ${signal})` : code !== null ? ` (exit ${code})` : "";
1153
+ const error = formatOutputError(output);
1154
+ reject(new Error(`${error.message}${suffix}`));
1155
+ });
1156
+ });
1157
+ });
1158
+ }
1159
+ function stopNgrokProcess(child) {
1160
+ if (!child) return;
1161
+ try {
1162
+ child.kill("SIGTERM");
1163
+ } catch {
1164
+ }
1165
+ }
1166
+ function stopNgrok(route) {
1167
+ if (!route.ngrokPid) return;
1168
+ try {
1169
+ process.kill(route.ngrokPid, "SIGTERM");
1170
+ } catch {
1171
+ }
1172
+ }
1173
+
1002
1174
  // src/auto.ts
1003
1175
  import { createHash as createHash2 } from "crypto";
1004
1176
  import { execFileSync as execFileSync2 } from "child_process";
@@ -1181,7 +1353,7 @@ import * as net from "net";
1181
1353
  import * as os from "os";
1182
1354
  import * as path3 from "path";
1183
1355
  import * as readline from "readline";
1184
- import { execSync, spawn } from "child_process";
1356
+ import { execSync, spawn as spawn2 } from "child_process";
1185
1357
  var isWindows = process.platform === "win32";
1186
1358
  var FALLBACK_PROXY_PORT = 1355;
1187
1359
  var PRIVILEGED_PORT_THRESHOLD = 1024;
@@ -1556,12 +1728,12 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
1556
1728
  throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
1557
1729
  }
1558
1730
  const tryPort = (port) => {
1559
- return new Promise((resolve3) => {
1731
+ return new Promise((resolve4) => {
1560
1732
  const server = net.createServer();
1561
1733
  server.listen(port, () => {
1562
- server.close(() => resolve3(true));
1734
+ server.close(() => resolve4(true));
1563
1735
  });
1564
- server.on("error", () => resolve3(false));
1736
+ server.on("error", () => resolve4(false));
1565
1737
  });
1566
1738
  };
1567
1739
  for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
@@ -1578,7 +1750,7 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
1578
1750
  throw new Error(`No free port found in range ${minPort}-${maxPort}`);
1579
1751
  }
1580
1752
  function isProxyRunning(port, tls2 = false) {
1581
- return new Promise((resolve3) => {
1753
+ return new Promise((resolve4) => {
1582
1754
  const requestFn = tls2 ? https.request : http.request;
1583
1755
  const req = requestFn(
1584
1756
  {
@@ -1591,26 +1763,26 @@ function isProxyRunning(port, tls2 = false) {
1591
1763
  },
1592
1764
  (res) => {
1593
1765
  res.resume();
1594
- resolve3(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
1766
+ resolve4(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
1595
1767
  }
1596
1768
  );
1597
- req.on("error", () => resolve3(false));
1769
+ req.on("error", () => resolve4(false));
1598
1770
  req.on("timeout", () => {
1599
1771
  req.destroy();
1600
- resolve3(false);
1772
+ resolve4(false);
1601
1773
  });
1602
1774
  req.end();
1603
1775
  });
1604
1776
  }
1605
1777
  function isPortListening(port) {
1606
- return new Promise((resolve3) => {
1778
+ return new Promise((resolve4) => {
1607
1779
  const socket = net.createConnection({ host: "127.0.0.1", port });
1608
1780
  let settled = false;
1609
1781
  const finish = (result) => {
1610
1782
  if (settled) return;
1611
1783
  settled = true;
1612
1784
  socket.destroy();
1613
- resolve3(result);
1785
+ resolve4(result);
1614
1786
  };
1615
1787
  socket.setTimeout(SOCKET_TIMEOUT_MS);
1616
1788
  socket.once("connect", () => finish(true));
@@ -1674,7 +1846,7 @@ function findPidOnPort(port) {
1674
1846
  }
1675
1847
  async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
1676
1848
  for (let i = 0; i < maxAttempts; i++) {
1677
- await new Promise((resolve3) => setTimeout(resolve3, intervalMs));
1849
+ await new Promise((resolve4) => setTimeout(resolve4, intervalMs));
1678
1850
  if (await isProxyRunning(port, tls2)) {
1679
1851
  return true;
1680
1852
  }
@@ -1718,10 +1890,10 @@ function spawnCommand(commandArgs, options) {
1718
1890
  }
1719
1891
  }
1720
1892
  }
1721
- const child = isWindows ? spawn("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
1893
+ const child = isWindows ? spawn2("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
1722
1894
  stdio: "inherit",
1723
1895
  env
1724
- }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
1896
+ }) : spawn2("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
1725
1897
  stdio: "inherit",
1726
1898
  env,
1727
1899
  detached: true
@@ -1867,7 +2039,7 @@ function removePortlessStateFiles(dir) {
1867
2039
  }
1868
2040
 
1869
2041
  // src/mdns.ts
1870
- import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
2042
+ import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
1871
2043
 
1872
2044
  // src/lan-ip.ts
1873
2045
  import { createSocket } from "dgram";
@@ -1898,7 +2070,7 @@ function isInternalInterface(iname, macStr, internal) {
1898
2070
  return false;
1899
2071
  }
1900
2072
  function probeDefaultRouteIPv4() {
1901
- return new Promise((resolve3, reject) => {
2073
+ return new Promise((resolve4, reject) => {
1902
2074
  const socket = createSocket({ type: "udp4", reuseAddr: true });
1903
2075
  socket.on("error", (error) => {
1904
2076
  socket.close();
@@ -1910,7 +2082,7 @@ function probeDefaultRouteIPv4() {
1910
2082
  socket.close();
1911
2083
  socket.unref();
1912
2084
  if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
1913
- resolve3(addr.address);
2085
+ resolve4(addr.address);
1914
2086
  } else {
1915
2087
  reject(new Error("No route to host"));
1916
2088
  }
@@ -1983,7 +2155,7 @@ function getMdnsPublisher() {
1983
2155
  return null;
1984
2156
  }
1985
2157
  function hasCommand(command, probeArgs) {
1986
- const result = spawnSync2(command, probeArgs, {
2158
+ const result = spawnSync3(command, probeArgs, {
1987
2159
  stdio: "ignore",
1988
2160
  timeout: 1e3,
1989
2161
  windowsHide: true
@@ -2042,7 +2214,7 @@ function publish(hostname, port, ip, onError) {
2042
2214
  if (!publisher) {
2043
2215
  return;
2044
2216
  }
2045
- const child = spawn2(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
2217
+ const child = spawn3(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
2046
2218
  stdio: "ignore",
2047
2219
  detached: false
2048
2220
  });
@@ -2595,14 +2767,27 @@ function hasTurboConfig(wsRoot) {
2595
2767
  import * as fs8 from "fs";
2596
2768
  import * as os2 from "os";
2597
2769
  import * as path8 from "path";
2598
- import { spawnSync as spawnSync3 } from "child_process";
2599
- var SERVICE_PORT = getProtocolPort(true);
2770
+ import { spawnSync as spawnSync4 } from "child_process";
2771
+ var DEFAULT_SERVICE_PORT = getProtocolPort(true);
2600
2772
  var SERVICE_LABEL = "sh.portless.proxy";
2601
2773
  var SYSTEMD_SERVICE = "portless.service";
2602
2774
  var WINDOWS_TASK_NAME = "Portless Proxy";
2603
2775
  var INTERNAL_ELEVATED_ENV = "PORTLESS_INTERNAL_SERVICE_ELEVATED";
2604
- function defaultRunner2(command, args, options) {
2605
- return spawnSync3(command, args, {
2776
+ var SERVICE_ENV_KEYS = /* @__PURE__ */ new Set(["PORTLESS_SYNC_HOSTS"]);
2777
+ var DEFAULT_SERVICE_CONFIG = {
2778
+ proxyPort: DEFAULT_SERVICE_PORT,
2779
+ useHttps: true,
2780
+ customCertPath: null,
2781
+ customKeyPath: null,
2782
+ lanMode: false,
2783
+ lanIp: null,
2784
+ lanIpExplicit: false,
2785
+ tld: DEFAULT_TLD,
2786
+ useWildcard: false,
2787
+ extraEnv: {}
2788
+ };
2789
+ function defaultRunner3(command, args, options) {
2790
+ return spawnSync4(command, args, {
2606
2791
  encoding: "utf-8",
2607
2792
  stdio: options?.stdio ?? "pipe"
2608
2793
  });
@@ -2619,6 +2804,156 @@ function systemdEscape(value) {
2619
2804
  function windowsQuote(value) {
2620
2805
  return `"${value.replace(/"/g, '\\"')}"`;
2621
2806
  }
2807
+ function xmlUnescape(value) {
2808
+ return value.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&");
2809
+ }
2810
+ function parseBooleanEnv(value) {
2811
+ if (value === void 0) return null;
2812
+ if (value === "1" || value === "true") return true;
2813
+ if (value === "0" || value === "false") return false;
2814
+ return null;
2815
+ }
2816
+ function parsePortValue(value, source) {
2817
+ const port = parseInt(value, 10);
2818
+ if (isNaN(port) || port < 1 || port > 65535) {
2819
+ throw new Error(`${source} must be a number between 1 and 65535.`);
2820
+ }
2821
+ return port;
2822
+ }
2823
+ function getFlagValue(args, index, flag) {
2824
+ const value = args[index + 1];
2825
+ if (!value || value.startsWith("-")) {
2826
+ throw new Error(`${flag} requires a value.`);
2827
+ }
2828
+ return value;
2829
+ }
2830
+ function resolveServicePath(value) {
2831
+ const expanded = value === "~" ? os2.homedir() : value.startsWith("~/") || value.startsWith("~\\") ? path8.join(os2.homedir(), value.slice(2)) : value;
2832
+ return path8.resolve(expanded);
2833
+ }
2834
+ function normalizeServiceInstallPaths(config) {
2835
+ return {
2836
+ ...config,
2837
+ stateDir: config.stateDir ? resolveServicePath(config.stateDir) : void 0,
2838
+ customCertPath: config.customCertPath ? resolveServicePath(config.customCertPath) : null,
2839
+ customKeyPath: config.customKeyPath ? resolveServicePath(config.customKeyPath) : null
2840
+ };
2841
+ }
2842
+ function collectServiceExtraEnv(env) {
2843
+ const extraEnv = {};
2844
+ for (const key of SERVICE_ENV_KEYS) {
2845
+ const value = env[key];
2846
+ if (value) extraEnv[key] = value;
2847
+ }
2848
+ return extraEnv;
2849
+ }
2850
+ function parseServiceInstallConfig(args, env = process.env, options = {}) {
2851
+ const config = {
2852
+ ...DEFAULT_SERVICE_CONFIG,
2853
+ extraEnv: collectServiceExtraEnv(env)
2854
+ };
2855
+ if (env.PORTLESS_STATE_DIR) {
2856
+ config.stateDir = env.PORTLESS_STATE_DIR;
2857
+ }
2858
+ const envHttps = parseBooleanEnv(env.PORTLESS_HTTPS);
2859
+ if (envHttps !== null) {
2860
+ config.useHttps = envHttps;
2861
+ }
2862
+ const envLan = parseBooleanEnv(env.PORTLESS_LAN);
2863
+ if (envLan !== null) {
2864
+ config.lanMode = envLan;
2865
+ }
2866
+ if (env.PORTLESS_LAN_IP) {
2867
+ config.lanMode = true;
2868
+ config.lanIp = env.PORTLESS_LAN_IP;
2869
+ config.lanIpExplicit = true;
2870
+ }
2871
+ if (env.PORTLESS_TLD) {
2872
+ const tld = env.PORTLESS_TLD.trim().toLowerCase();
2873
+ const err = validateTld(tld);
2874
+ if (err) throw new Error(`PORTLESS_TLD: ${err}`);
2875
+ config.tld = tld;
2876
+ }
2877
+ const envWildcard = parseBooleanEnv(env.PORTLESS_WILDCARD);
2878
+ if (envWildcard !== null) {
2879
+ config.useWildcard = envWildcard;
2880
+ }
2881
+ if (env.PORTLESS_PORT) {
2882
+ config.proxyPort = parsePortValue(env.PORTLESS_PORT, "PORTLESS_PORT");
2883
+ } else {
2884
+ config.proxyPort = getProtocolPort(config.useHttps);
2885
+ }
2886
+ const tokens = args[0] === "service" ? args.slice(2) : args;
2887
+ for (let i = 0; i < tokens.length; i += 1) {
2888
+ const token = tokens[i];
2889
+ switch (token) {
2890
+ case "-p":
2891
+ case "--port":
2892
+ config.proxyPort = parsePortValue(getFlagValue(tokens, i, token), token);
2893
+ i += 1;
2894
+ break;
2895
+ case "--https":
2896
+ config.useHttps = true;
2897
+ break;
2898
+ case "--no-tls":
2899
+ config.useHttps = false;
2900
+ break;
2901
+ case "--lan":
2902
+ config.lanMode = true;
2903
+ break;
2904
+ case "--ip":
2905
+ config.lanMode = true;
2906
+ config.lanIp = getFlagValue(tokens, i, token);
2907
+ config.lanIpExplicit = true;
2908
+ i += 1;
2909
+ break;
2910
+ case "--tld": {
2911
+ const tld = getFlagValue(tokens, i, token).trim().toLowerCase();
2912
+ const err = validateTld(tld);
2913
+ if (err) throw new Error(err);
2914
+ config.tld = tld;
2915
+ i += 1;
2916
+ break;
2917
+ }
2918
+ case "--wildcard":
2919
+ config.useWildcard = true;
2920
+ break;
2921
+ case "--cert":
2922
+ config.customCertPath = getFlagValue(tokens, i, token);
2923
+ config.useHttps = true;
2924
+ i += 1;
2925
+ break;
2926
+ case "--key":
2927
+ config.customKeyPath = getFlagValue(tokens, i, token);
2928
+ config.useHttps = true;
2929
+ i += 1;
2930
+ break;
2931
+ case "--state-dir":
2932
+ config.stateDir = getFlagValue(tokens, i, token);
2933
+ i += 1;
2934
+ break;
2935
+ case "--foreground":
2936
+ case "--skip-trust":
2937
+ if (!options.allowRuntimeFlags) {
2938
+ throw new Error(`Unknown service install option "${token}".`);
2939
+ }
2940
+ break;
2941
+ default:
2942
+ throw new Error(`Unknown service install option "${token}".`);
2943
+ }
2944
+ }
2945
+ if (config.customCertPath && !config.customKeyPath || !config.customCertPath && config.customKeyPath) {
2946
+ throw new Error("--cert and --key must be used together.");
2947
+ }
2948
+ if (!env.PORTLESS_PORT && !tokens.includes("--port") && !tokens.includes("-p")) {
2949
+ config.proxyPort = getProtocolPort(config.useHttps);
2950
+ }
2951
+ if (!config.lanMode) {
2952
+ config.lanIp = null;
2953
+ config.lanIpExplicit = false;
2954
+ }
2955
+ return config;
2956
+ }
2622
2957
  function readPasswdHome(username) {
2623
2958
  try {
2624
2959
  const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
@@ -2652,22 +2987,40 @@ function resolveUserContext(platform) {
2652
2987
  username: userInfo2.username
2653
2988
  };
2654
2989
  }
2655
- function buildProxyCommand(entryScript) {
2656
- const config = buildProxyStartConfig({
2657
- useHttps: true,
2658
- lanMode: false,
2659
- tld: DEFAULT_TLD,
2990
+ function buildProxyCommand(entryScript, serviceConfig) {
2991
+ const proxyConfig = buildProxyStartConfig({
2992
+ useHttps: serviceConfig.useHttps,
2993
+ customCertPath: serviceConfig.customCertPath,
2994
+ customKeyPath: serviceConfig.customKeyPath,
2995
+ lanMode: serviceConfig.lanMode,
2996
+ lanIp: serviceConfig.lanIp,
2997
+ lanIpExplicit: serviceConfig.lanIpExplicit,
2998
+ tld: serviceConfig.tld,
2999
+ useWildcard: serviceConfig.useWildcard,
2660
3000
  foreground: true,
2661
3001
  includePort: true,
2662
- proxyPort: SERVICE_PORT,
3002
+ proxyPort: serviceConfig.proxyPort,
2663
3003
  skipTrust: true
2664
3004
  });
2665
- return [entryScript, "proxy", "start", ...config.args];
3005
+ return [entryScript, "proxy", "start", ...proxyConfig.args];
2666
3006
  }
2667
3007
  function buildServiceEnv(ctx) {
2668
3008
  const env = {
2669
- PORTLESS_STATE_DIR: ctx.stateDir
3009
+ PORTLESS_STATE_DIR: ctx.stateDir,
3010
+ PORTLESS_PORT: ctx.config.proxyPort.toString(),
3011
+ PORTLESS_HTTPS: ctx.config.useHttps ? "1" : "0",
3012
+ PORTLESS_LAN: ctx.config.lanMode ? "1" : "0",
3013
+ PORTLESS_WILDCARD: ctx.config.useWildcard ? "1" : "0",
3014
+ ...ctx.config.extraEnv
2670
3015
  };
3016
+ if (ctx.config.lanMode && ctx.config.lanIpExplicit && ctx.config.lanIp) {
3017
+ env.PORTLESS_LAN_IP = ctx.config.lanIp;
3018
+ }
3019
+ if (ctx.config.lanMode) {
3020
+ env.PORTLESS_TLD = "local";
3021
+ } else if (ctx.config.tld !== DEFAULT_TLD) {
3022
+ env.PORTLESS_TLD = ctx.config.tld;
3023
+ }
2671
3024
  if (ctx.platform === "win32") {
2672
3025
  env.USERPROFILE = ctx.user.home;
2673
3026
  env.PATH = ctx.pathEnv;
@@ -2746,11 +3099,21 @@ ${proxyCommand}\r
2746
3099
  `;
2747
3100
  }
2748
3101
  function buildServiceSpec(options) {
3102
+ const installConfig = {
3103
+ ...DEFAULT_SERVICE_CONFIG,
3104
+ ...options.installConfig,
3105
+ extraEnv: options.installConfig?.extraEnv ?? {}
3106
+ };
3107
+ const stateDir = options.stateDir || installConfig.stateDir || defaultStateDir(options.platform, options.userHome);
3108
+ const normalizedConfig = {
3109
+ ...installConfig,
3110
+ stateDir
3111
+ };
2749
3112
  const ctx = {
2750
3113
  platform: options.platform,
2751
3114
  nodePath: options.nodePath,
2752
3115
  entryScript: options.entryScript,
2753
- stateDir: options.stateDir || defaultStateDir(options.platform, options.userHome),
3116
+ stateDir,
2754
3117
  user: {
2755
3118
  home: options.userHome,
2756
3119
  uid: options.uid,
@@ -2758,9 +3121,10 @@ function buildServiceSpec(options) {
2758
3121
  username: options.username
2759
3122
  },
2760
3123
  pathEnv: options.pathEnv || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
2761
- programData: options.programData || "C:\\ProgramData"
3124
+ programData: options.programData || "C:\\ProgramData",
3125
+ config: normalizedConfig
2762
3126
  };
2763
- const proxyCommand = buildProxyCommand(ctx.entryScript);
3127
+ const proxyCommand = buildProxyCommand(ctx.entryScript, ctx.config);
2764
3128
  if (ctx.platform === "darwin") {
2765
3129
  const programArguments = [ctx.nodePath, ...proxyCommand];
2766
3130
  return {
@@ -2769,6 +3133,7 @@ function buildServiceSpec(options) {
2769
3133
  plistPath: `/Library/LaunchDaemons/${SERVICE_LABEL}.plist`,
2770
3134
  plist: buildLaunchdPlist(ctx, programArguments),
2771
3135
  stateDir: ctx.stateDir,
3136
+ config: ctx.config,
2772
3137
  programArguments
2773
3138
  };
2774
3139
  }
@@ -2780,6 +3145,7 @@ function buildServiceSpec(options) {
2780
3145
  unitPath: `/etc/systemd/system/${SYSTEMD_SERVICE}`,
2781
3146
  unit: buildSystemdUnit(ctx, execStart),
2782
3147
  stateDir: ctx.stateDir,
3148
+ config: ctx.config,
2783
3149
  execStart
2784
3150
  };
2785
3151
  }
@@ -2791,6 +3157,7 @@ function buildServiceSpec(options) {
2791
3157
  platform: "win32",
2792
3158
  taskName: WINDOWS_TASK_NAME,
2793
3159
  stateDir: ctx.stateDir,
3160
+ config: ctx.config,
2794
3161
  scriptDir,
2795
3162
  scriptPath,
2796
3163
  script,
@@ -2814,11 +3181,17 @@ function buildServiceSpec(options) {
2814
3181
  queryArgs: ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]
2815
3182
  };
2816
3183
  }
2817
- function currentServiceSpec(entryScript) {
3184
+ function currentServiceSpec(entryScript, installConfig) {
2818
3185
  if (!isSupportedPlatform(process.platform)) {
2819
3186
  throw new Error(`Unsupported platform: ${process.platform}`);
2820
3187
  }
2821
3188
  const user = resolveUserContext(process.platform);
3189
+ const stateDir = installConfig?.stateDir || process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home);
3190
+ const config = installConfig ?? {
3191
+ ...DEFAULT_SERVICE_CONFIG,
3192
+ stateDir,
3193
+ extraEnv: collectServiceExtraEnv(process.env)
3194
+ };
2822
3195
  return buildServiceSpec({
2823
3196
  platform: process.platform,
2824
3197
  nodePath: process.execPath,
@@ -2827,11 +3200,131 @@ function currentServiceSpec(entryScript) {
2827
3200
  uid: user.uid,
2828
3201
  gid: user.gid,
2829
3202
  username: user.username,
2830
- stateDir: process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home),
3203
+ stateDir,
2831
3204
  pathEnv: process.env.PATH,
2832
- programData: process.env.ProgramData
3205
+ programData: process.env.ProgramData,
3206
+ installConfig: config
2833
3207
  });
2834
3208
  }
3209
+ function parseQuotedWords(input, options = {}) {
3210
+ const words = [];
3211
+ let current = "";
3212
+ let inQuote = false;
3213
+ let inWord = false;
3214
+ const unescapeBackslash = options.unescapeBackslash ?? true;
3215
+ for (let i = 0; i < input.length; i += 1) {
3216
+ const char = input[i];
3217
+ if (char === '"') {
3218
+ inQuote = !inQuote;
3219
+ inWord = true;
3220
+ continue;
3221
+ }
3222
+ if (char === "\\" && i + 1 < input.length && (input[i + 1] === '"' || unescapeBackslash && input[i + 1] === "\\")) {
3223
+ current += input[i + 1];
3224
+ inWord = true;
3225
+ i += 1;
3226
+ continue;
3227
+ }
3228
+ if (/\s/.test(char) && !inQuote) {
3229
+ if (inWord) {
3230
+ words.push(current);
3231
+ current = "";
3232
+ inWord = false;
3233
+ }
3234
+ continue;
3235
+ }
3236
+ current += char;
3237
+ inWord = true;
3238
+ }
3239
+ if (inWord) {
3240
+ words.push(current);
3241
+ }
3242
+ return words;
3243
+ }
3244
+ function parsePlistStrings(block) {
3245
+ return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) => xmlUnescape(match[1]));
3246
+ }
3247
+ function parsePlistEnv(block) {
3248
+ const env = {};
3249
+ for (const match of block.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
3250
+ env[xmlUnescape(match[1])] = xmlUnescape(match[2]);
3251
+ }
3252
+ return env;
3253
+ }
3254
+ function readInstalledServiceSnapshot(spec) {
3255
+ try {
3256
+ if (spec.platform === "darwin") {
3257
+ if (!fs8.existsSync(spec.plistPath)) return null;
3258
+ const plist = fs8.readFileSync(spec.plistPath, "utf-8");
3259
+ const argsBlock = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
3260
+ const envBlock = plist.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
3261
+ if (!argsBlock) return null;
3262
+ return {
3263
+ command: parsePlistStrings(argsBlock[1]),
3264
+ env: envBlock ? parsePlistEnv(envBlock[1]) : {}
3265
+ };
3266
+ }
3267
+ if (spec.platform === "linux") {
3268
+ if (!fs8.existsSync(spec.unitPath)) return null;
3269
+ const unit = fs8.readFileSync(spec.unitPath, "utf-8");
3270
+ const env2 = {};
3271
+ let command = null;
3272
+ for (const line of unit.split("\n")) {
3273
+ if (line.startsWith("Environment=")) {
3274
+ const entry = line.slice("Environment=".length);
3275
+ const eq = entry.indexOf("=");
3276
+ if (eq > 0) {
3277
+ const key = entry.slice(0, eq);
3278
+ const value = parseQuotedWords(entry.slice(eq + 1))[0] ?? "";
3279
+ env2[key] = value;
3280
+ }
3281
+ } else if (line.startsWith("ExecStart=")) {
3282
+ command = parseQuotedWords(line.slice("ExecStart=".length));
3283
+ }
3284
+ }
3285
+ return command ? { command, env: env2 } : null;
3286
+ }
3287
+ if (!fs8.existsSync(spec.scriptPath)) return null;
3288
+ const script = fs8.readFileSync(spec.scriptPath, "utf-8");
3289
+ const env = {};
3290
+ let commandLine = null;
3291
+ for (const rawLine of script.split(/\r?\n/)) {
3292
+ const line = rawLine.trim();
3293
+ if (!line || line.toLowerCase() === "@echo off") continue;
3294
+ const envMatch = line.match(/^set "([^=]+)=(.*)"$/);
3295
+ if (envMatch) {
3296
+ env[envMatch[1]] = envMatch[2].replace(/%%/g, "%");
3297
+ continue;
3298
+ }
3299
+ commandLine = line;
3300
+ }
3301
+ return commandLine ? { command: parseQuotedWords(commandLine, { unescapeBackslash: false }), env } : null;
3302
+ } catch {
3303
+ return null;
3304
+ }
3305
+ }
3306
+ function installedConfigFromSnapshot(snapshot, fallback) {
3307
+ const proxyIndex = snapshot.command.findIndex(
3308
+ (arg, index) => arg === "proxy" && snapshot.command[index + 1] === "start"
3309
+ );
3310
+ if (proxyIndex === -1) return null;
3311
+ try {
3312
+ const parsed = parseServiceInstallConfig(
3313
+ ["service", "install", ...snapshot.command.slice(proxyIndex + 2)],
3314
+ snapshot.env,
3315
+ { allowRuntimeFlags: true }
3316
+ );
3317
+ const stateDir = parsed.stateDir || snapshot.env.PORTLESS_STATE_DIR || fallback.stateDir;
3318
+ return { ...parsed, stateDir };
3319
+ } catch {
3320
+ return null;
3321
+ }
3322
+ }
3323
+ function readInstalledServiceConfig(spec) {
3324
+ const snapshot = readInstalledServiceSnapshot(spec);
3325
+ if (!snapshot) return null;
3326
+ return installedConfigFromSnapshot(snapshot, spec.config);
3327
+ }
2835
3328
  function collectPortlessEnvArgs(env = process.env, omit = /* @__PURE__ */ new Set()) {
2836
3329
  const envArgs = [];
2837
3330
  for (const key of Object.keys(env)) {
@@ -2901,18 +3394,34 @@ function isPermissionError(err) {
2901
3394
  const message = err instanceof Error ? err.message : String(err);
2902
3395
  return code === "EACCES" || code === "EPERM" || /permission denied|operation not permitted|access is denied/i.test(message);
2903
3396
  }
2904
- function stopExistingProxy(entryScript, runner) {
3397
+ function stopProxyOnPort(entryScript, runner, proxyPort) {
2905
3398
  runRequired(runner, process.execPath, [
2906
3399
  entryScript,
2907
3400
  "proxy",
2908
3401
  "stop",
2909
3402
  "--port",
2910
- SERVICE_PORT.toString()
3403
+ proxyPort.toString()
2911
3404
  ]);
2912
3405
  }
2913
- function prepareTrust(stateDir) {
3406
+ async function stopExistingProxy(entryScript, runner, proxyPort) {
3407
+ const ports = /* @__PURE__ */ new Set();
3408
+ try {
3409
+ const currentState = await discoverState();
3410
+ if (currentState.port !== proxyPort && await isProxyRunning(currentState.port)) {
3411
+ ports.add(currentState.port);
3412
+ }
3413
+ } catch {
3414
+ }
3415
+ ports.add(proxyPort);
3416
+ for (const port of ports) {
3417
+ stopProxyOnPort(entryScript, runner, port);
3418
+ }
3419
+ }
3420
+ function prepareServiceState(stateDir) {
2914
3421
  fs8.mkdirSync(stateDir, { recursive: true });
2915
3422
  fixOwnership(stateDir);
3423
+ }
3424
+ function prepareTrust(stateDir) {
2916
3425
  try {
2917
3426
  ensureCerts(stateDir);
2918
3427
  } catch (err) {
@@ -2935,13 +3444,28 @@ ${detail}`
2935
3444
  }
2936
3445
  console.warn(colors_default.yellow("Run `portless trust` if browsers show certificate warnings."));
2937
3446
  }
2938
- async function installService(entryScript, runner) {
2939
- requireUnixElevation([entryScript, "service", "install"], runner);
2940
- const spec = currentServiceSpec(entryScript);
2941
- prepareTrust(spec.stateDir);
3447
+ function ensureServiceConfigSupported(config) {
3448
+ if (!config.lanMode) return;
3449
+ const mdnsSupport = isMdnsSupported();
3450
+ if (mdnsSupport.supported) return;
3451
+ const reason = mdnsSupport.reason ? `
3452
+ ${mdnsSupport.reason}` : "";
3453
+ throw new Error(
3454
+ `LAN mode requires mDNS publishing, which is not supported on this platform.${reason}`
3455
+ );
3456
+ }
3457
+ async function installService(entryScript, runner, args) {
3458
+ const installConfig = normalizeServiceInstallPaths(parseServiceInstallConfig(args));
3459
+ ensureServiceConfigSupported(installConfig);
3460
+ requireUnixElevation([entryScript, ...args], runner);
3461
+ const spec = currentServiceSpec(entryScript, installConfig);
3462
+ prepareServiceState(spec.stateDir);
3463
+ if (spec.config.useHttps && !spec.config.customCertPath) {
3464
+ prepareTrust(spec.stateDir);
3465
+ }
2942
3466
  if (spec.platform === "darwin") {
2943
3467
  runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
2944
- stopExistingProxy(entryScript, runner);
3468
+ await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
2945
3469
  fs8.writeFileSync(spec.plistPath, spec.plist);
2946
3470
  fs8.chmodSync(spec.plistPath, 420);
2947
3471
  runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
@@ -2950,7 +3474,7 @@ async function installService(entryScript, runner) {
2950
3474
  runRequired(runner, "launchctl", ["kickstart", "-k", `system/${spec.label}`]);
2951
3475
  } else if (spec.platform === "linux") {
2952
3476
  runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
2953
- stopExistingProxy(entryScript, runner);
3477
+ await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
2954
3478
  fs8.writeFileSync(spec.unitPath, spec.unit);
2955
3479
  fs8.chmodSync(spec.unitPath, 420);
2956
3480
  runRequired(runner, "systemctl", ["daemon-reload"]);
@@ -2958,7 +3482,7 @@ async function installService(entryScript, runner) {
2958
3482
  runRequired(runner, "systemctl", ["restart", spec.serviceName]);
2959
3483
  } else {
2960
3484
  runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
2961
- stopExistingProxy(entryScript, runner);
3485
+ await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
2962
3486
  fs8.mkdirSync(spec.scriptDir, { recursive: true });
2963
3487
  fs8.writeFileSync(spec.scriptPath, spec.script);
2964
3488
  runRequired(runner, "schtasks", spec.createArgs);
@@ -2966,6 +3490,7 @@ async function installService(entryScript, runner) {
2966
3490
  }
2967
3491
  console.log(colors_default.green("Portless service installed."));
2968
3492
  console.log(colors_default.gray(`State directory: ${spec.stateDir}`));
3493
+ console.log(colors_default.gray(`Proxy port: ${spec.config.proxyPort}`));
2969
3494
  }
2970
3495
  async function uninstallService(entryScript, runner) {
2971
3496
  requireUnixElevation([entryScript, "service", "uninstall"], runner);
@@ -2984,7 +3509,7 @@ async function uninstallService(entryScript, runner) {
2984
3509
  }
2985
3510
  console.log(colors_default.green("Portless service uninstalled."));
2986
3511
  }
2987
- function tryUninstallService(entryScript, runner = defaultRunner2) {
3512
+ function tryUninstallService(entryScript, runner = defaultRunner3) {
2988
3513
  let installed = false;
2989
3514
  try {
2990
3515
  const spec = currentServiceSpec(entryScript);
@@ -3019,13 +3544,20 @@ function tryUninstallService(entryScript, runner = defaultRunner2) {
3019
3544
  }
3020
3545
  async function getServiceStatus(entryScript, runner) {
3021
3546
  const spec = currentServiceSpec(entryScript);
3022
- const proxyRunning = await isProxyRunning(SERVICE_PORT);
3547
+ const installedConfig = readInstalledServiceConfig(spec) ?? spec.config;
3548
+ const proxyRunning = await isProxyRunning(installedConfig.proxyPort, installedConfig.useHttps);
3023
3549
  if (spec.platform === "darwin") {
3024
3550
  const installed2 = fs8.existsSync(spec.plistPath);
3025
3551
  const result = runner("launchctl", ["print", `system/${spec.label}`]);
3026
3552
  const output2 = `${result.stdout || ""}${result.stderr || ""}`;
3027
3553
  const managerState = result.status === 0 && /state = running|pid = \d+/.test(output2) ? "running" : installed2 ? "installed" : "not installed";
3028
- return { installed: installed2, managerState, proxyRunning, details: spec.plistPath };
3554
+ return {
3555
+ installed: installed2,
3556
+ managerState,
3557
+ proxyRunning,
3558
+ config: installedConfig,
3559
+ details: spec.plistPath
3560
+ };
3029
3561
  }
3030
3562
  if (spec.platform === "linux") {
3031
3563
  const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
@@ -3036,6 +3568,7 @@ async function getServiceStatus(entryScript, runner) {
3036
3568
  installed: installed2,
3037
3569
  managerState: active.status === 0 ? activeText || "active" : installed2 ? "installed" : "not installed",
3038
3570
  proxyRunning,
3571
+ config: installedConfig,
3039
3572
  details: spec.unitPath
3040
3573
  };
3041
3574
  }
@@ -3047,17 +3580,27 @@ async function getServiceStatus(entryScript, runner) {
3047
3580
  installed,
3048
3581
  managerState: installed ? stateMatch?.[1]?.trim() || "installed" : "not installed",
3049
3582
  proxyRunning,
3583
+ config: installedConfig,
3050
3584
  details: spec.taskName
3051
3585
  };
3052
3586
  }
3053
3587
  async function printServiceStatus(entryScript, runner) {
3054
- const spec = currentServiceSpec(entryScript);
3055
3588
  const status = await getServiceStatus(entryScript, runner);
3589
+ const config = status.config;
3056
3590
  console.log(colors_default.bold("portless service"));
3057
3591
  console.log(` Manager state: ${status.managerState}`);
3058
3592
  console.log(` Installed: ${status.installed ? "yes" : "no"}`);
3059
- console.log(` Proxy on 443: ${status.proxyRunning ? "responding" : "not responding"}`);
3060
- console.log(` State directory: ${spec.stateDir}`);
3593
+ console.log(
3594
+ ` Proxy on ${config.proxyPort}: ${status.proxyRunning ? "responding" : "not responding"}`
3595
+ );
3596
+ console.log(` HTTPS: ${config.useHttps ? "yes" : "no"}`);
3597
+ console.log(` TLD: ${config.lanMode ? "local" : config.tld}`);
3598
+ console.log(` LAN mode: ${config.lanMode ? "yes" : "no"}`);
3599
+ if (config.lanIpExplicit && config.lanIp) {
3600
+ console.log(` LAN IP: ${config.lanIp}`);
3601
+ }
3602
+ console.log(` Wildcard: ${config.useWildcard ? "yes" : "no"}`);
3603
+ console.log(` State directory: ${config.stateDir}`);
3061
3604
  if (status.details) {
3062
3605
  console.log(` Service entry: ${status.details}`);
3063
3606
  }
@@ -3067,26 +3610,41 @@ function printServiceHelp() {
3067
3610
  ${colors_default.bold("portless service")} - Start portless automatically when the OS starts.
3068
3611
 
3069
3612
  ${colors_default.bold("Usage:")}
3070
- ${colors_default.cyan("portless service install")} Install and start the HTTPS service on port 443
3071
- ${colors_default.cyan("portless service uninstall")} Stop and remove the startup service
3072
- ${colors_default.cyan("portless service status")} Show service and proxy status
3613
+ ${colors_default.cyan("portless service install")} Install and start the HTTPS service on port 443
3614
+ ${colors_default.cyan("portless service install --lan")} Enable LAN mode for the startup service
3615
+ ${colors_default.cyan("portless service install -p 8443")} Use a custom proxy port
3616
+ ${colors_default.cyan("portless service uninstall")} Stop and remove the startup service
3617
+ ${colors_default.cyan("portless service status")} Show service and proxy status
3618
+
3619
+ ${colors_default.bold("Install options:")}
3620
+ -p, --port <number> Port for the proxy service
3621
+ --no-tls Disable HTTPS
3622
+ --https Enable HTTPS
3623
+ --lan Enable LAN mode
3624
+ --ip <address> Pin a specific LAN IP
3625
+ --tld <tld> Use a custom TLD outside LAN mode
3626
+ --wildcard Allow subdomain fallback
3627
+ --cert <path> Use a custom TLS certificate
3628
+ --key <path> Use a custom TLS private key
3629
+ --state-dir <path> Use a custom service state directory
3073
3630
 
3074
3631
  ${colors_default.bold("Notes:")}
3075
- The service uses the default clean URL mode: HTTPS on port 443.
3632
+ The service uses the default clean URL mode unless options or PORTLESS_*
3633
+ environment variables are provided during install.
3076
3634
  macOS and Linux install a root-owned service so port 443 can bind at boot.
3077
3635
  Windows installs a Task Scheduler startup task that runs as SYSTEM.
3078
3636
  `);
3079
3637
  }
3080
3638
  async function handleService(args, options) {
3081
3639
  const action = args[1];
3082
- const runner = options.runner || defaultRunner2;
3640
+ const runner = options.runner || defaultRunner3;
3083
3641
  if (!action || action === "--help" || action === "-h") {
3084
3642
  printServiceHelp();
3085
3643
  process.exit(0);
3086
3644
  }
3087
3645
  try {
3088
3646
  if (action === "install") {
3089
- await installService(options.entryScript, runner);
3647
+ await installService(options.entryScript, runner, args);
3090
3648
  return;
3091
3649
  }
3092
3650
  if (action === "uninstall") {
@@ -3280,7 +3838,7 @@ function collectPortlessEnvArgs2() {
3280
3838
  function sudoStop(port) {
3281
3839
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
3282
3840
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
3283
- const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
3841
+ const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
3284
3842
  stdio: "inherit",
3285
3843
  timeout: SUDO_SPAWN_TIMEOUT_MS
3286
3844
  });
@@ -3289,7 +3847,7 @@ function sudoStop(port) {
3289
3847
  function runCleanWithSudo(reason) {
3290
3848
  console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3291
3849
  const home = process.env.HOME;
3292
- const result = spawnSync4(
3850
+ const result = spawnSync5(
3293
3851
  "sudo",
3294
3852
  [
3295
3853
  "env",
@@ -3308,12 +3866,20 @@ function runCleanWithSudo(reason) {
3308
3866
  }
3309
3867
  function runServiceUninstallWithSudo(reason) {
3310
3868
  console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3311
- const result = spawnSync4("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
3869
+ const result = spawnSync5("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
3312
3870
  stdio: "inherit",
3313
3871
  timeout: SUDO_SPAWN_TIMEOUT_MS
3314
3872
  });
3315
3873
  return result.status === 0;
3316
3874
  }
3875
+ function isEnabledEnv(value) {
3876
+ return value === "1" || value === "true";
3877
+ }
3878
+ function formatProcessExitSuffix(code, signal) {
3879
+ if (signal) return ` (signal ${signal})`;
3880
+ if (code !== null) return ` (exit ${code})`;
3881
+ return "";
3882
+ }
3317
3883
  function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
3318
3884
  store.ensureDir();
3319
3885
  const isTls = !!tlsOptions;
@@ -3652,6 +4218,9 @@ function listRoutes(store, proxyPort, tls2) {
3652
4218
  const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
3653
4219
  console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
3654
4220
  }
4221
+ if (route.ngrokUrl) {
4222
+ console.log(` ${colors_default.gray("ngrok:")} ${colors_default.green(route.ngrokUrl)}`);
4223
+ }
3655
4224
  }
3656
4225
  console.log();
3657
4226
  }
@@ -3735,7 +4304,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
3735
4304
  proxyPort: startPort
3736
4305
  });
3737
4306
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
3738
- const result = spawnSync4(process.execPath, startArgs, {
4307
+ const result = spawnSync5(process.execPath, startArgs, {
3739
4308
  stdio: "inherit",
3740
4309
  timeout: SUDO_SPAWN_TIMEOUT_MS
3741
4310
  });
@@ -3769,8 +4338,9 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
3769
4338
  console.log(chalk.blue.bold(`
3770
4339
  portless
3771
4340
  `));
3772
- const wantsFunnel = process.env.PORTLESS_FUNNEL === "1" || process.env.PORTLESS_FUNNEL === "true";
3773
- const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
4341
+ const wantsFunnel = isEnabledEnv(process.env.PORTLESS_FUNNEL);
4342
+ const wantsTailscale = wantsFunnel || isEnabledEnv(process.env.PORTLESS_TAILSCALE);
4343
+ const wantsNgrok = isEnabledEnv(process.env.PORTLESS_NGROK);
3774
4344
  let tsBaseUrl;
3775
4345
  if (wantsTailscale) {
3776
4346
  try {
@@ -3791,6 +4361,18 @@ portless
3791
4361
  process.exit(1);
3792
4362
  }
3793
4363
  }
4364
+ if (wantsNgrok) {
4365
+ try {
4366
+ ensureNgrokAvailable();
4367
+ } catch (err) {
4368
+ const message = err instanceof Error ? err.message : String(err);
4369
+ console.error(colors_default.red(`Error: ${message}`));
4370
+ if (message.includes("not found")) {
4371
+ console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
4372
+ }
4373
+ process.exit(1);
4374
+ }
4375
+ }
3794
4376
  let desired;
3795
4377
  try {
3796
4378
  desired = resolveProxyDesiredState(lanMode);
@@ -3876,6 +4458,36 @@ portless
3876
4458
  }
3877
4459
  let tailscaleHttpsPort;
3878
4460
  let tailscaleUrl;
4461
+ let ngrokUrl;
4462
+ let ngrokProcess;
4463
+ let stoppingNgrok = false;
4464
+ let ngrokRouteReady = false;
4465
+ let ngrokExitHandled = false;
4466
+ let pendingNgrokExit;
4467
+ const handleNgrokExit = (code, signal) => {
4468
+ if (stoppingNgrok || ngrokExitHandled) return;
4469
+ if (!ngrokRouteReady) {
4470
+ pendingNgrokExit = { code, signal };
4471
+ return;
4472
+ }
4473
+ ngrokExitHandled = true;
4474
+ ngrokUrl = void 0;
4475
+ console.warn(
4476
+ colors_default.yellow(
4477
+ `Warning: ngrok tunnel for ${hostname} stopped${formatProcessExitSuffix(
4478
+ code,
4479
+ signal
4480
+ )}. Removing its public URL from the route list.`
4481
+ )
4482
+ );
4483
+ try {
4484
+ store.updateRoute(hostname, {
4485
+ ngrokUrl: null,
4486
+ ngrokPid: null
4487
+ });
4488
+ } catch {
4489
+ }
4490
+ };
3879
4491
  if (wantsTailscale && tsBaseUrl) {
3880
4492
  const maxAttempts = 3;
3881
4493
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -3913,6 +4525,51 @@ portless
3913
4525
  } catch {
3914
4526
  }
3915
4527
  }
4528
+ if (wantsNgrok) {
4529
+ try {
4530
+ ngrokProcess = await startNgrok(port, {
4531
+ hostHeader: hostname,
4532
+ onExit: handleNgrokExit
4533
+ });
4534
+ ngrokUrl = ngrokProcess.url;
4535
+ console.log(chalk.green(` ngrok -> ${ngrokUrl}`));
4536
+ console.log(chalk.gray(" (accessible from the public internet via ngrok)\n"));
4537
+ try {
4538
+ store.updateRoute(hostname, {
4539
+ ngrokUrl,
4540
+ ngrokPid: ngrokProcess.pid
4541
+ });
4542
+ } catch {
4543
+ } finally {
4544
+ ngrokRouteReady = true;
4545
+ if (pendingNgrokExit) {
4546
+ handleNgrokExit(pendingNgrokExit.code, pendingNgrokExit.signal);
4547
+ pendingNgrokExit = void 0;
4548
+ }
4549
+ }
4550
+ } catch (err) {
4551
+ const message = err instanceof Error ? err.message : String(err);
4552
+ console.error(colors_default.red(`Error: ${message}`));
4553
+ if (message.includes("not found")) {
4554
+ console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
4555
+ } else if (message.includes("authentication")) {
4556
+ console.error(colors_default.blue("Configure ngrok authentication:"));
4557
+ console.error(colors_default.cyan(" ngrok config add-authtoken <token>"));
4558
+ }
4559
+ try {
4560
+ unregisterTailscale({
4561
+ tailscaleHttpsPort,
4562
+ tailscaleFunnel: wantsFunnel || void 0
4563
+ });
4564
+ } catch {
4565
+ }
4566
+ try {
4567
+ store.removeRoute(hostname);
4568
+ } catch {
4569
+ }
4570
+ process.exit(1);
4571
+ }
4572
+ }
3916
4573
  const basename5 = path9.basename(commandArgs[0]);
3917
4574
  const isExpo = basename5 === "expo";
3918
4575
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
@@ -3947,9 +4604,12 @@ portless
3947
4604
  // own LAN discovery natively.
3948
4605
  ...lanMode ? { PORTLESS_LAN: "1" } : {},
3949
4606
  ...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
4607
+ ...ngrokUrl ? { PORTLESS_NGROK_URL: ngrokUrl } : {},
3950
4608
  ...caEnv
3951
4609
  },
3952
4610
  onCleanup: () => {
4611
+ stoppingNgrok = true;
4612
+ stopNgrokProcess(ngrokProcess?.child);
3953
4613
  try {
3954
4614
  unregisterTailscale({
3955
4615
  tailscaleHttpsPort,
@@ -3986,7 +4646,7 @@ function appPortFromEnv() {
3986
4646
  }
3987
4647
  return port;
3988
4648
  }
3989
- function applyTailscaleFlag(flag) {
4649
+ function applySharingFlag(flag) {
3990
4650
  if (flag === "--tailscale") {
3991
4651
  process.env.PORTLESS_TAILSCALE = "1";
3992
4652
  return true;
@@ -3996,6 +4656,10 @@ function applyTailscaleFlag(flag) {
3996
4656
  process.env.PORTLESS_TAILSCALE = "1";
3997
4657
  return true;
3998
4658
  }
4659
+ if (flag === "--ngrok") {
4660
+ process.env.PORTLESS_NGROK = "1";
4661
+ return true;
4662
+ }
3999
4663
  return false;
4000
4664
  }
4001
4665
  function parseRunArgs(args) {
@@ -4021,6 +4685,9 @@ ${colors_default.bold("Options:")}
4021
4685
  --name <name> Override the inferred base name (worktree prefix still applies)
4022
4686
  --force Kill the existing process and take over its route
4023
4687
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
4688
+ --tailscale Share the app on your Tailscale network (tailnet)
4689
+ --funnel Share the app publicly via Tailscale Funnel
4690
+ --ngrok Share the app publicly via ngrok
4024
4691
  --help, -h Show this help
4025
4692
 
4026
4693
  ${colors_default.bold("Name inference (in order):")}
@@ -4054,11 +4721,13 @@ ${colors_default.bold("Examples:")}
4054
4721
  process.exit(1);
4055
4722
  }
4056
4723
  name = args[i];
4057
- } else if (applyTailscaleFlag(args[i])) {
4724
+ } else if (applySharingFlag(args[i])) {
4058
4725
  } else {
4059
4726
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4060
4727
  console.error(
4061
- colors_default.blue("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
4728
+ colors_default.blue(
4729
+ "Known flags: --name, --force, --app-port, --tailscale, --funnel, --ngrok, --help"
4730
+ )
4062
4731
  );
4063
4732
  process.exit(1);
4064
4733
  }
@@ -4080,10 +4749,12 @@ function parseAppArgs(args) {
4080
4749
  } else if (args[i] === "--app-port") {
4081
4750
  i++;
4082
4751
  appPort = parseAppPort(args[i]);
4083
- } else if (applyTailscaleFlag(args[i])) {
4752
+ } else if (applySharingFlag(args[i])) {
4084
4753
  } else {
4085
4754
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4086
- console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
4755
+ console.error(
4756
+ colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
4757
+ );
4087
4758
  process.exit(1);
4088
4759
  }
4089
4760
  i++;
@@ -4099,10 +4770,12 @@ function parseAppArgs(args) {
4099
4770
  } else if (args[i] === "--app-port") {
4100
4771
  i++;
4101
4772
  appPort = parseAppPort(args[i]);
4102
- } else if (applyTailscaleFlag(args[i])) {
4773
+ } else if (applySharingFlag(args[i])) {
4103
4774
  } else {
4104
4775
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4105
- console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
4776
+ console.error(
4777
+ colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
4778
+ );
4106
4779
  process.exit(1);
4107
4780
  }
4108
4781
  i++;
@@ -4121,6 +4794,9 @@ ${colors_default.bold("Install:")}
4121
4794
  ${colors_default.cyan("npm install -g portless")} Global (recommended)
4122
4795
  ${colors_default.cyan("npm install -D portless")} Project dev dependency
4123
4796
 
4797
+ ${colors_default.bold("Requirements:")}
4798
+ Node.js 24+
4799
+
4124
4800
  ${colors_default.bold("Usage:")}
4125
4801
  ${colors_default.cyan("portless")} Run dev script through proxy
4126
4802
  ${colors_default.cyan("portless")} From monorepo root: run all workspace packages
@@ -4148,9 +4824,12 @@ ${colors_default.bold("Examples:")}
4148
4824
  portless run next dev # -> https://<project>.localhost
4149
4825
  portless run next dev # in worktree -> https://<worktree>.<project>.localhost
4150
4826
  portless service install # Start HTTPS proxy on OS startup
4827
+ portless service install --lan # Persist LAN mode in the startup service
4828
+ portless service install --wildcard # Persist wildcard routing in the startup service
4151
4829
  portless get backend # -> https://backend.localhost
4152
4830
  portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
4153
4831
  portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
4832
+ portless myapp --ngrok next dev # -> also https://<random>.ngrok.app (public)
4154
4833
 
4155
4834
  ${colors_default.bold("Configuration (portless.json):")}
4156
4835
  Optional. Portless works out of the box by running the "dev" script
@@ -4212,6 +4891,11 @@ ${colors_default.bold("Tailscale sharing:")}
4212
4891
  ${colors_default.cyan("portless myapp --tailscale next dev")}
4213
4892
  ${colors_default.cyan("portless myapp --funnel next dev")}
4214
4893
 
4894
+ ${colors_default.bold("ngrok sharing:")}
4895
+ Use --ngrok to expose your dev server to the public internet with ngrok.
4896
+ Requires the ngrok CLI to be installed and authenticated.
4897
+ ${colors_default.cyan("portless myapp --ngrok next dev")}
4898
+
4215
4899
  ${colors_default.bold("Options:")}
4216
4900
  run [--name <name>] <cmd> Infer project name (or override with --name)
4217
4901
  Adds worktree prefix in git worktrees
@@ -4227,9 +4911,11 @@ ${colors_default.bold("Options:")}
4227
4911
  --foreground Run proxy in foreground (for debugging)
4228
4912
  --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
4229
4913
  --wildcard Allow unregistered subdomains to fall back to parent route
4914
+ --state-dir <path> Use a custom state directory with service install
4230
4915
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
4231
4916
  --tailscale Share the app on your Tailscale network (tailnet)
4232
4917
  --funnel Share the app publicly via Tailscale Funnel
4918
+ --ngrok Share the app publicly via ngrok
4233
4919
  --force Kill the existing process and take over its route
4234
4920
  --name <name> Use <name> as the app name (bypasses subcommand dispatch)
4235
4921
  -- Stop flag parsing; everything after is passed to the child
@@ -4239,11 +4925,13 @@ ${colors_default.bold("Environment variables:")}
4239
4925
  PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
4240
4926
  PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
4241
4927
  PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
4928
+ PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
4242
4929
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
4243
4930
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
4244
4931
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
4245
4932
  PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
4246
4933
  PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
4934
+ PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok)
4247
4935
  PORTLESS_STATE_DIR=<path> Override the state directory
4248
4936
  PORTLESS=0 Run command directly without proxy
4249
4937
 
@@ -4253,6 +4941,7 @@ ${colors_default.bold("Child process environment:")}
4253
4941
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
4254
4942
  PORTLESS_LAN Set to 1 when proxy is in LAN mode
4255
4943
  PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
4944
+ PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
4256
4945
  NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
4257
4946
 
4258
4947
  ${colors_default.bold("Safari / DNS:")}
@@ -4275,7 +4964,7 @@ ${colors_default.bold("Reserved names:")}
4275
4964
  process.exit(0);
4276
4965
  }
4277
4966
  function printVersion() {
4278
- console.log("0.13.0");
4967
+ console.log("0.14.0");
4279
4968
  process.exit(0);
4280
4969
  }
4281
4970
  async function handleTrust() {
@@ -4296,7 +4985,7 @@ async function handleTrust() {
4296
4985
  const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
4297
4986
  if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
4298
4987
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
4299
- const sudoResult = spawnSync4(
4988
+ const sudoResult = spawnSync5(
4300
4989
  "sudo",
4301
4990
  [
4302
4991
  "env",
@@ -4379,6 +5068,10 @@ ${colors_default.bold("Options:")}
4379
5068
  } catch {
4380
5069
  }
4381
5070
  }
5071
+ if (route.ngrokPid) {
5072
+ stopNgrok(route);
5073
+ console.log(colors_default.green(`Stopped ngrok tunnel for ${route.hostname}.`));
5074
+ }
4382
5075
  }
4383
5076
  const stateDirs = collectStateDirsForCleanup();
4384
5077
  for (const stateDir of stateDirs) {
@@ -4458,6 +5151,10 @@ ${colors_default.bold("Options:")}
4458
5151
  } catch {
4459
5152
  }
4460
5153
  }
5154
+ if (route.ngrokPid) {
5155
+ stopNgrok(route);
5156
+ console.log(` ${route.hostname} - stopped ngrok tunnel`);
5157
+ }
4461
5158
  }
4462
5159
  let killed = 0;
4463
5160
  for (const route of stale) {
@@ -4636,7 +5333,7 @@ ${colors_default.bold("Auto-sync:")}
4636
5333
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
4637
5334
  )
4638
5335
  );
4639
- const result = spawnSync4(
5336
+ const result = spawnSync5(
4640
5337
  "sudo",
4641
5338
  ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
4642
5339
  {
@@ -4689,7 +5386,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
4689
5386
  console.log(
4690
5387
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
4691
5388
  );
4692
- const result = spawnSync4(
5389
+ const result = spawnSync5(
4693
5390
  "sudo",
4694
5391
  ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
4695
5392
  {
@@ -4980,7 +5677,7 @@ ${colors_default.bold("LAN mode (--lan):")}
4980
5677
  if (!hasExplicitPort) {
4981
5678
  console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
4982
5679
  }
4983
- const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
5680
+ const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
4984
5681
  stdio: "inherit",
4985
5682
  timeout: SUDO_SPAWN_TIMEOUT_MS
4986
5683
  });
@@ -5120,7 +5817,7 @@ ${colors_default.bold("LAN mode (--lan):")}
5120
5817
  skipTrust: true
5121
5818
  }).args
5122
5819
  ];
5123
- const child = spawn3(process.execPath, daemonArgs, {
5820
+ const child = spawn4(process.execPath, daemonArgs, {
5124
5821
  detached: true,
5125
5822
  stdio: ["ignore", logFd, logFd],
5126
5823
  env: process.env,
@@ -5226,7 +5923,7 @@ async function handleDefaultSingle(cwd, scriptName, appConfig) {
5226
5923
  );
5227
5924
  }
5228
5925
  function spawnChildProcess(commandArgs, env, cwd) {
5229
- return spawn3(commandArgs[0], commandArgs.slice(1), {
5926
+ return spawn4(commandArgs[0], commandArgs.slice(1), {
5230
5927
  stdio: ["ignore", "pipe", "pipe"],
5231
5928
  env,
5232
5929
  cwd,
@@ -5497,7 +6194,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
5497
6194
  const pm = detectPackageManager(wsRoot);
5498
6195
  const useRootScript = hasScript(scriptName, wsRoot);
5499
6196
  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];
5500
- const turboChild = spawn3(turboArgs[0], turboArgs.slice(1), {
6197
+ const turboChild = spawn4(turboArgs[0], turboArgs.slice(1), {
5501
6198
  stdio: "inherit",
5502
6199
  cwd: wsRoot,
5503
6200
  env: {
@@ -5527,8 +6224,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
5527
6224
  };
5528
6225
  process.on("SIGINT", cleanup);
5529
6226
  process.on("SIGTERM", cleanup);
5530
- const exitCode = await new Promise((resolve3) => {
5531
- turboChild.on("exit", (code) => resolve3(code));
6227
+ const exitCode = await new Promise((resolve4) => {
6228
+ turboChild.on("exit", (code) => resolve4(code));
5532
6229
  });
5533
6230
  cleanup();
5534
6231
  if (exitCode !== 0 && exitCode !== null) {
@@ -5592,8 +6289,8 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
5592
6289
  process.on("SIGTERM", cleanup);
5593
6290
  await Promise.all(
5594
6291
  children.map(
5595
- (child) => new Promise((resolve3) => {
5596
- child.on("exit", () => resolve3());
6292
+ (child) => new Promise((resolve4) => {
6293
+ child.on("exit", () => resolve4());
5597
6294
  })
5598
6295
  )
5599
6296
  );
@@ -5680,6 +6377,7 @@ async function handleNamedMode(args) {
5680
6377
  }
5681
6378
  }
5682
6379
  const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
6380
+ parseHostname(safeName, DEFAULT_TLD);
5683
6381
  const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
5684
6382
  const store = new RouteStore(dir, {
5685
6383
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
@@ -5759,6 +6457,9 @@ async function main() {
5759
6457
  process.env.PORTLESS_FUNNEL = "1";
5760
6458
  process.env.PORTLESS_TAILSCALE = "1";
5761
6459
  }
6460
+ if (stripGlobalFlag("--ngrok", false)) {
6461
+ process.env.PORTLESS_NGROK = "1";
6462
+ }
5762
6463
  const scriptResult = stripGlobalFlag("--script", true);
5763
6464
  if (scriptResult === false) {
5764
6465
  console.error(colors_default.red("Error: --script requires a script name."));