portless 0.9.5 → 0.10.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
@@ -3,12 +3,15 @@ import {
3
3
  DEFAULT_TLD,
4
4
  FALLBACK_PROXY_PORT,
5
5
  FILE_MODE,
6
+ INTERNAL_LAN_IP_ENV,
7
+ INTERNAL_LAN_IP_FLAG,
6
8
  PRIVILEGED_PORT_THRESHOLD,
7
9
  RISKY_TLDS,
8
10
  RouteConflictError,
9
11
  RouteStore,
10
12
  WAIT_FOR_PROXY_INTERVAL_MS,
11
13
  WAIT_FOR_PROXY_MAX_ATTEMPTS,
14
+ buildProxyStartConfig,
12
15
  cleanHostsFile,
13
16
  createHttpRedirectServer,
14
17
  createProxyServer,
@@ -19,23 +22,28 @@ import {
19
22
  formatUrl,
20
23
  getDefaultPort,
21
24
  getDefaultTld,
22
- getProtocolPort,
23
25
  injectFrameworkFlags,
24
26
  isErrnoException,
25
27
  isHttpsEnvDisabled,
28
+ isLanEnvEnabled,
29
+ isPortListening,
26
30
  isProxyRunning,
27
31
  isWildcardEnvEnabled,
28
32
  isWindows,
29
33
  parseHostname,
30
34
  prompt,
35
+ readLanMarker,
36
+ readTldFromDir,
37
+ readTlsMarker,
31
38
  resolveStateDir,
32
39
  spawnCommand,
33
40
  syncHostsFile,
34
41
  validateTld,
35
42
  waitForProxy,
43
+ writeLanMarker,
36
44
  writeTldFile,
37
45
  writeTlsMarker
38
- } from "./chunk-D3LR3J7L.js";
46
+ } from "./chunk-WXEE5QH6.js";
39
47
 
40
48
  // src/colors.ts
41
49
  function supportsColor() {
@@ -65,7 +73,7 @@ var colors_default = { bold, red, green, yellow, blue, cyan, white, gray };
65
73
  // src/cli.ts
66
74
  import * as fs3 from "fs";
67
75
  import * as path3 from "path";
68
- import { spawn, spawnSync } from "child_process";
76
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
69
77
 
70
78
  // src/certs.ts
71
79
  import * as fs from "fs";
@@ -135,6 +143,14 @@ function isCertValid(certPath) {
135
143
  return false;
136
144
  }
137
145
  }
146
+ function isCertSansComplete(certPath) {
147
+ try {
148
+ const text = openssl(["x509", "-in", certPath, "-noout", "-text"]);
149
+ return /DNS:\*\.local\b/.test(text);
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
138
154
  function isCertSignatureStrong(certPath) {
139
155
  try {
140
156
  const text = openssl(["x509", "-in", certPath, "-noout", "-text"]);
@@ -216,6 +232,7 @@ function generateServerCert(stateDir) {
216
232
  const extPath = path.join(stateDir, "server-ext.cnf");
217
233
  openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", serverKeyPath]);
218
234
  openssl(["req", "-new", "-key", serverKeyPath, "-out", csrPath, "-subj", "/CN=localhost"]);
235
+ const sans = ["DNS:localhost", "DNS:*.localhost", "DNS:*.local"];
219
236
  fs.writeFileSync(
220
237
  extPath,
221
238
  [
@@ -223,7 +240,7 @@ function generateServerCert(stateDir) {
223
240
  "basicConstraints=CA:FALSE",
224
241
  "keyUsage=digitalSignature,keyEncipherment",
225
242
  "extendedKeyUsage=serverAuth",
226
- "subjectAltName=DNS:localhost,DNS:*.localhost"
243
+ `subjectAltName=${sans.join(",")}`
227
244
  ].join("\n") + "\n"
228
245
  );
229
246
  const srlPath = path.join(stateDir, "ca.srl");
@@ -273,7 +290,7 @@ function ensureCerts(stateDir) {
273
290
  generateCA(stateDir);
274
291
  caGenerated = true;
275
292
  }
276
- if (caGenerated || !fileExists(serverCertPath) || !fileExists(serverKeyPath) || !isCertValid(serverCertPath) || !isCertSignatureStrong(serverCertPath)) {
293
+ if (caGenerated || !fileExists(serverCertPath) || !fileExists(serverKeyPath) || !isCertValid(serverCertPath) || !isCertSignatureStrong(serverCertPath) || !isCertSansComplete(serverCertPath)) {
277
294
  generateServerCert(stateDir);
278
295
  }
279
296
  return {
@@ -766,12 +783,351 @@ function readBranchFromHead(gitdir) {
766
783
  }
767
784
  }
768
785
 
786
+ // src/mdns.ts
787
+ import { spawn, spawnSync } from "child_process";
788
+
789
+ // src/lan-ip.ts
790
+ import { createSocket } from "dgram";
791
+ import { networkInterfaces } from "os";
792
+ var PROBE_HOST = "1.1.1.1";
793
+ var PROBE_PORT = 53;
794
+ var NO_ROUTE_IP = "0.0.0.0";
795
+ function isIPv4Family(family) {
796
+ return family === "IPv4" || family === 4;
797
+ }
798
+ function parseMac(macStr) {
799
+ return macStr.split(":").slice(0, 16).map((seq) => parseInt(seq, 16));
800
+ }
801
+ function isInternalInterface(iname, macStr, internal) {
802
+ if (internal) {
803
+ return true;
804
+ }
805
+ const mac = parseMac(macStr);
806
+ if (mac.every((x) => !x)) {
807
+ return true;
808
+ }
809
+ if (mac[0] === 0 && mac[1] === 21 && mac[2] === 93) {
810
+ return true;
811
+ }
812
+ if (iname.includes("vEthernet") || /^bridge\d+$/.test(iname)) {
813
+ return true;
814
+ }
815
+ return false;
816
+ }
817
+ function probeDefaultRouteIPv4() {
818
+ return new Promise((resolve2, reject) => {
819
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
820
+ socket.on("error", (error) => {
821
+ socket.close();
822
+ socket.unref();
823
+ reject(error);
824
+ });
825
+ socket.connect(PROBE_PORT, PROBE_HOST, () => {
826
+ const addr = socket.address();
827
+ socket.close();
828
+ socket.unref();
829
+ if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
830
+ resolve2(addr.address);
831
+ } else {
832
+ reject(new Error("No route to host"));
833
+ }
834
+ });
835
+ });
836
+ }
837
+ function findInterfaceRowForIp(ip) {
838
+ const ifs = networkInterfaces();
839
+ for (const iname of Object.keys(ifs)) {
840
+ const entries = ifs[iname];
841
+ if (!entries) continue;
842
+ for (const e of entries) {
843
+ if (!isIPv4Family(e.family)) continue;
844
+ if (e.address !== ip) continue;
845
+ return { iname, address: e.address, mac: e.mac, internal: e.internal };
846
+ }
847
+ }
848
+ return null;
849
+ }
850
+ async function getLocalNetworkIp() {
851
+ try {
852
+ const ip = await probeDefaultRouteIPv4();
853
+ if (ip === "127.0.0.1") {
854
+ return null;
855
+ }
856
+ const row = findInterfaceRowForIp(ip);
857
+ if (!row) {
858
+ return null;
859
+ }
860
+ if (row.address === "127.0.0.1") {
861
+ return null;
862
+ }
863
+ if (isInternalInterface(row.iname, row.mac, row.internal)) {
864
+ return null;
865
+ }
866
+ return row.address;
867
+ } catch {
868
+ return null;
869
+ }
870
+ }
871
+
872
+ // src/mdns.ts
873
+ var activePublishers = /* @__PURE__ */ new Map();
874
+ var LAN_IP_POLL_INTERVAL_MS = 5e3;
875
+ function getMdnsPublisher() {
876
+ if (process.platform === "darwin") {
877
+ return {
878
+ command: "dns-sd",
879
+ probeArgs: ["-h"],
880
+ missingReason: "dns-sd not found",
881
+ buildArgs: (fqdn, name, port, ip) => [
882
+ "-P",
883
+ name,
884
+ "_http._tcp",
885
+ "local",
886
+ port.toString(),
887
+ fqdn,
888
+ ip
889
+ ]
890
+ };
891
+ }
892
+ if (process.platform === "linux") {
893
+ return {
894
+ command: "avahi-publish-address",
895
+ probeArgs: ["--help"],
896
+ missingReason: "avahi-publish-address not found. Install avahi-utils: sudo apt install avahi-utils",
897
+ buildArgs: (fqdn, _name, _port, ip) => ["-R", fqdn, ip]
898
+ };
899
+ }
900
+ return null;
901
+ }
902
+ function hasCommand(command, probeArgs) {
903
+ const result = spawnSync(command, probeArgs, {
904
+ stdio: "ignore",
905
+ timeout: 1e3,
906
+ windowsHide: true
907
+ });
908
+ return result.error?.code !== "ENOENT";
909
+ }
910
+ function startLanIpMonitor(options) {
911
+ const resolveIp = options.resolveIp ?? getLocalNetworkIp;
912
+ let currentIp = options.initialIp;
913
+ let stopped = false;
914
+ let polling = false;
915
+ const poll = async () => {
916
+ if (stopped || polling) return;
917
+ polling = true;
918
+ try {
919
+ const nextIp = await resolveIp();
920
+ if (stopped || nextIp === currentIp) return;
921
+ const previousIp = currentIp;
922
+ currentIp = nextIp;
923
+ options.onChange(nextIp, previousIp);
924
+ } catch (error) {
925
+ options.onError?.(error);
926
+ } finally {
927
+ polling = false;
928
+ }
929
+ };
930
+ const timer = setInterval(() => {
931
+ void poll();
932
+ }, options.intervalMs ?? LAN_IP_POLL_INTERVAL_MS);
933
+ timer.unref?.();
934
+ return {
935
+ stop: () => {
936
+ stopped = true;
937
+ clearInterval(timer);
938
+ }
939
+ };
940
+ }
941
+ function isMdnsSupported() {
942
+ const publisher = getMdnsPublisher();
943
+ if (!publisher) {
944
+ return { supported: false, reason: "mDNS publishing is not supported on this platform" };
945
+ }
946
+ if (!hasCommand(publisher.command, publisher.probeArgs)) {
947
+ return { supported: false, reason: publisher.missingReason };
948
+ }
949
+ return { supported: true };
950
+ }
951
+ function serviceName(hostname) {
952
+ return hostname.replace(/\.local$/, "");
953
+ }
954
+ function publish(hostname, port, ip, onError) {
955
+ if (activePublishers.has(hostname)) return;
956
+ const fqdn = hostname.endsWith(".local") ? hostname : `${hostname}.local`;
957
+ const name = serviceName(fqdn);
958
+ const publisher = getMdnsPublisher();
959
+ if (!publisher) {
960
+ return;
961
+ }
962
+ const child = spawn(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
963
+ stdio: "ignore",
964
+ detached: false
965
+ });
966
+ child.on("error", (err) => {
967
+ activePublishers.delete(hostname);
968
+ const msg = err.code === "ENOENT" ? publisher.missingReason : `mDNS publish error for ${hostname}: ${err.message}`;
969
+ onError?.(msg);
970
+ });
971
+ child.on("exit", () => {
972
+ activePublishers.delete(hostname);
973
+ });
974
+ activePublishers.set(hostname, child);
975
+ }
976
+ function unpublish(hostname) {
977
+ const child = activePublishers.get(hostname);
978
+ if (!child) return;
979
+ activePublishers.delete(hostname);
980
+ child.kill("SIGTERM");
981
+ }
982
+ function cleanupAll() {
983
+ for (const child of activePublishers.values()) {
984
+ child.kill("SIGTERM");
985
+ }
986
+ activePublishers.clear();
987
+ }
988
+
769
989
  // src/cli.ts
990
+ var chalk = colors_default;
770
991
  var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
771
992
  var DEBOUNCE_MS = 100;
772
993
  var POLL_INTERVAL_MS = 3e3;
773
994
  var EXIT_TIMEOUT_MS = 2e3;
774
995
  var SUDO_SPAWN_TIMEOUT_MS = 3e4;
996
+ function defaultProxyConfig(tld, useHttps, lanMode) {
997
+ return {
998
+ useHttps,
999
+ customCertPath: null,
1000
+ customKeyPath: null,
1001
+ lanMode,
1002
+ lanIp: null,
1003
+ lanIpExplicit: false,
1004
+ tld: lanMode ? "local" : tld,
1005
+ useWildcard: false
1006
+ };
1007
+ }
1008
+ function resolveProxyConfig(options) {
1009
+ const config = defaultProxyConfig(
1010
+ options.defaultTld,
1011
+ options.useHttps,
1012
+ options.explicit.lanMode ? options.lanMode : options.persistedLanMode
1013
+ );
1014
+ if (options.explicit.useHttps) {
1015
+ config.useHttps = options.useHttps;
1016
+ if (!options.useHttps) {
1017
+ config.customCertPath = null;
1018
+ config.customKeyPath = null;
1019
+ }
1020
+ }
1021
+ if (options.explicit.customCert) {
1022
+ config.useHttps = true;
1023
+ config.customCertPath = options.customCertPath;
1024
+ config.customKeyPath = options.customKeyPath;
1025
+ }
1026
+ if (options.explicit.lanMode) {
1027
+ config.lanMode = options.lanMode;
1028
+ if (!options.lanMode) {
1029
+ config.lanIp = null;
1030
+ config.lanIpExplicit = false;
1031
+ if (!options.explicit.tld) {
1032
+ config.tld = options.defaultTld;
1033
+ }
1034
+ }
1035
+ }
1036
+ if (options.explicit.lanIp && options.lanIp) {
1037
+ config.lanMode = true;
1038
+ config.lanIp = options.lanIp;
1039
+ config.lanIpExplicit = true;
1040
+ }
1041
+ if (options.explicit.tld) {
1042
+ config.tld = options.tld;
1043
+ }
1044
+ if (options.explicit.useWildcard) {
1045
+ config.useWildcard = options.useWildcard;
1046
+ }
1047
+ if (!config.lanMode) {
1048
+ config.lanIp = null;
1049
+ config.lanIpExplicit = false;
1050
+ }
1051
+ if (config.lanMode) {
1052
+ config.tld = "local";
1053
+ if (!config.lanIpExplicit) {
1054
+ config.lanIp = null;
1055
+ }
1056
+ }
1057
+ if (!config.useHttps) {
1058
+ config.customCertPath = null;
1059
+ config.customKeyPath = null;
1060
+ }
1061
+ return config;
1062
+ }
1063
+ function readCurrentProxyConfig(dir) {
1064
+ const lanIp = readLanMarker(dir);
1065
+ const tld = readTldFromDir(dir);
1066
+ return {
1067
+ useHttps: readTlsMarker(dir),
1068
+ customCertPath: null,
1069
+ customKeyPath: null,
1070
+ lanMode: lanIp !== null || tld === "local",
1071
+ lanIp,
1072
+ lanIpExplicit: false,
1073
+ tld,
1074
+ useWildcard: false
1075
+ };
1076
+ }
1077
+ function getProxyConfigMismatchMessages(desiredConfig, actualConfig, explicit) {
1078
+ const messages = [];
1079
+ if (explicit.lanMode && desiredConfig.lanMode !== actualConfig.lanMode) {
1080
+ messages.push(
1081
+ desiredConfig.lanMode ? "requested LAN mode, but the running proxy is not using LAN mode" : "requested non-LAN mode, but the running proxy is using LAN mode"
1082
+ );
1083
+ }
1084
+ if (explicit.lanIp && desiredConfig.lanIp !== actualConfig.lanIp) {
1085
+ messages.push(
1086
+ `requested LAN IP ${desiredConfig.lanIp}, but the running proxy is using ${actualConfig.lanIp ?? "auto-detected LAN mode"}`
1087
+ );
1088
+ }
1089
+ if (explicit.useHttps && desiredConfig.useHttps !== actualConfig.useHttps) {
1090
+ messages.push(
1091
+ desiredConfig.useHttps ? "requested HTTPS, but the running proxy is using HTTP" : "requested HTTP, but the running proxy is using HTTPS"
1092
+ );
1093
+ }
1094
+ if (explicit.tld && desiredConfig.tld !== actualConfig.tld) {
1095
+ messages.push(
1096
+ `requested .${desiredConfig.tld}, but the running proxy is using .${actualConfig.tld}`
1097
+ );
1098
+ }
1099
+ return messages;
1100
+ }
1101
+ function formatProxyStartCommand(proxyPort, config) {
1102
+ const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1103
+ const { args } = buildProxyStartConfig({
1104
+ useHttps: config.useHttps,
1105
+ customCertPath: config.customCertPath,
1106
+ customKeyPath: config.customKeyPath,
1107
+ lanMode: config.lanMode,
1108
+ lanIp: config.lanIpExplicit ? config.lanIp : null,
1109
+ lanIpExplicit: config.lanIpExplicit,
1110
+ tld: config.tld,
1111
+ useWildcard: config.useWildcard,
1112
+ includePort: proxyPort !== getDefaultPort(config.useHttps),
1113
+ proxyPort
1114
+ });
1115
+ return `${needsSudo ? "sudo " : ""}portless proxy start${args.length > 0 ? ` ${args.join(" ")}` : ""}`;
1116
+ }
1117
+ function printProxyConfigMismatch(proxyPort, desiredConfig, messages) {
1118
+ const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1119
+ const portFlag = proxyPort !== getDefaultPort(desiredConfig.useHttps) ? ` -p ${proxyPort}` : "";
1120
+ console.error(
1121
+ chalk.yellow(`Proxy is already running on port ${proxyPort} with a different config.`)
1122
+ );
1123
+ for (const message of messages) {
1124
+ console.error(chalk.yellow(`- ${message}`));
1125
+ }
1126
+ console.error(chalk.blue("Stop it first, then restart with the desired settings:"));
1127
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy stop${portFlag}`));
1128
+ console.error(chalk.cyan(` ${formatProxyStartCommand(proxyPort, desiredConfig)}`));
1129
+ process.exit(1);
1130
+ }
775
1131
  function getEntryScript() {
776
1132
  const script = process.argv[1];
777
1133
  if (!script) {
@@ -803,15 +1159,23 @@ function collectPortlessEnvArgs() {
803
1159
  function sudoStop(port) {
804
1160
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
805
1161
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
806
- const result = spawnSync("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
1162
+ const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
807
1163
  stdio: "inherit",
808
1164
  timeout: SUDO_SPAWN_TIMEOUT_MS
809
1165
  });
810
1166
  return result.status === 0;
811
1167
  }
812
- function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
1168
+ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
813
1169
  store.ensureDir();
814
1170
  const isTls = !!tlsOptions;
1171
+ const mdnsSupport = isMdnsSupported();
1172
+ let activeLanIp = lanIp && mdnsSupport.supported ? lanIp : null;
1173
+ const lanIpPinned = !!process.env.PORTLESS_LAN_IP;
1174
+ let lanMonitor = null;
1175
+ if (lanIp && !mdnsSupport.supported) {
1176
+ const reason = mdnsSupport.reason ?? "mDNS publishing is not supported on this platform.";
1177
+ console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
1178
+ }
815
1179
  const routesPath = store.getRoutesPath();
816
1180
  if (!fs3.existsSync(routesPath)) {
817
1181
  fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
@@ -826,13 +1190,54 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
826
1190
  let watcher = null;
827
1191
  let pollingInterval = null;
828
1192
  const syncVal = process.env.PORTLESS_SYNC_HOSTS;
829
- const autoSyncHosts = syncVal === "1" || syncVal === "true" || tld !== DEFAULT_TLD && syncVal !== "0" && syncVal !== "false";
1193
+ const autoSyncHosts = syncVal === "1" || syncVal === "true" || tld !== DEFAULT_TLD && !activeLanIp && syncVal !== "0" && syncVal !== "false";
1194
+ const onMdnsError = (msg) => console.warn(chalk.yellow(msg));
1195
+ const publishCachedRoutes = () => {
1196
+ if (!activeLanIp) return;
1197
+ for (const route of cachedRoutes) {
1198
+ publish(route.hostname, proxyPort, activeLanIp, onMdnsError);
1199
+ }
1200
+ };
1201
+ const updateLanIp = (nextIp, previousIp = activeLanIp) => {
1202
+ if (nextIp === activeLanIp) return;
1203
+ if (activeLanIp) {
1204
+ cleanupAll();
1205
+ }
1206
+ activeLanIp = nextIp;
1207
+ writeLanMarker(store.dir, activeLanIp);
1208
+ if (previousIp && nextIp) {
1209
+ console.log(chalk.green(`LAN IP changed: ${previousIp} -> ${nextIp}`));
1210
+ } else if (previousIp && !nextIp) {
1211
+ console.warn(chalk.yellow("LAN mode temporarily unavailable: no active LAN IP"));
1212
+ } else if (!previousIp && nextIp) {
1213
+ console.log(chalk.green(`LAN mode restored: ${nextIp}`));
1214
+ }
1215
+ publishCachedRoutes();
1216
+ };
830
1217
  const reloadRoutes = () => {
831
1218
  try {
1219
+ const previousRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port]));
832
1220
  cachedRoutes = store.loadRoutes();
833
1221
  if (autoSyncHosts) {
834
1222
  syncHostsFile(cachedRoutes.map((r) => r.hostname));
835
1223
  }
1224
+ if (activeLanIp) {
1225
+ const currentRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port]));
1226
+ for (const route of cachedRoutes) {
1227
+ const previousPort = previousRoutes.get(route.hostname);
1228
+ if (previousPort === void 0) {
1229
+ publish(route.hostname, proxyPort, activeLanIp, onMdnsError);
1230
+ } else if (previousPort !== route.port) {
1231
+ unpublish(route.hostname);
1232
+ publish(route.hostname, proxyPort, activeLanIp, onMdnsError);
1233
+ }
1234
+ }
1235
+ for (const hostname of previousRoutes.keys()) {
1236
+ if (!currentRoutes.has(hostname)) {
1237
+ unpublish(hostname);
1238
+ }
1239
+ }
1240
+ }
836
1241
  } catch {
837
1242
  }
838
1243
  };
@@ -848,6 +1253,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
848
1253
  if (autoSyncHosts) {
849
1254
  syncHostsFile(cachedRoutes.map((r) => r.hostname));
850
1255
  }
1256
+ publishCachedRoutes();
851
1257
  const server = createProxyServer({
852
1258
  getRoutes: () => cachedRoutes,
853
1259
  proxyPort,
@@ -890,6 +1296,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
890
1296
  fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
891
1297
  writeTlsMarker(store.dir, isTls);
892
1298
  writeTldFile(store.dir, tld);
1299
+ writeLanMarker(store.dir, activeLanIp);
893
1300
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
894
1301
  const proto = isTls ? "HTTPS/2" : "HTTP";
895
1302
  const tldLabel = tld !== DEFAULT_TLD ? ` (TLD: .${tld})` : "";
@@ -897,6 +1304,24 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
897
1304
  console.log(
898
1305
  colors_default.green(`${proto} proxy listening on port ${proxyPort}${tldLabel}${modeLabel}`)
899
1306
  );
1307
+ if (activeLanIp) {
1308
+ console.log(chalk.green(`LAN mode: ${activeLanIp}`));
1309
+ console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
1310
+ if (isTls) {
1311
+ console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
1312
+ console.log(chalk.gray(` ${path3.join(store.dir, "ca.pem")}`));
1313
+ }
1314
+ if (!lanIpPinned) {
1315
+ lanMonitor = startLanIpMonitor({
1316
+ initialIp: activeLanIp,
1317
+ onChange: (nextIp, previousIp) => updateLanIp(nextIp, previousIp),
1318
+ onError: (error) => {
1319
+ const message = error instanceof Error ? error.message : String(error);
1320
+ console.warn(chalk.yellow(`Failed to refresh LAN IP: ${message}`));
1321
+ }
1322
+ });
1323
+ }
1324
+ }
900
1325
  if (redirectServer) {
901
1326
  console.log(colors_default.green("HTTP-to-HTTPS redirect listening on port 80"));
902
1327
  }
@@ -907,9 +1332,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
907
1332
  exiting = true;
908
1333
  if (debounceTimer) clearTimeout(debounceTimer);
909
1334
  if (pollingInterval) clearInterval(pollingInterval);
1335
+ if (lanMonitor) lanMonitor.stop();
910
1336
  if (watcher) {
911
1337
  watcher.close();
912
1338
  }
1339
+ if (activeLanIp) cleanupAll();
913
1340
  if (redirectServer) {
914
1341
  redirectServer.close();
915
1342
  }
@@ -1060,8 +1487,11 @@ function listRoutes(store, proxyPort, tls2) {
1060
1487
  }
1061
1488
  console.log();
1062
1489
  }
1063
- async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort) {
1490
+ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort, lanMode = false, lanIp) {
1064
1491
  let store = initialStore;
1492
+ console.log(chalk.blue.bold(`
1493
+ portless
1494
+ `));
1065
1495
  let envTld;
1066
1496
  try {
1067
1497
  envTld = getDefaultTld();
@@ -1069,38 +1499,44 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
1069
1499
  console.error(colors_default.red(`Error: ${err.message}`));
1070
1500
  process.exit(1);
1071
1501
  }
1072
- if (envTld !== DEFAULT_TLD && envTld !== tld) {
1073
- console.warn(
1074
- colors_default.yellow(
1075
- `Warning: PORTLESS_TLD=${envTld} but the running proxy uses .${tld}. Using .${tld}.`
1076
- )
1077
- );
1078
- }
1079
- console.log(colors_default.blue.bold(`
1080
- portless
1081
- `));
1082
- console.log(colors_default.gray(`-- ${parseHostname(name, tld)} (auto-resolves to 127.0.0.1)`));
1083
- if (autoInfo) {
1084
- const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name;
1085
- console.log(colors_default.gray(`-- Name "${baseName}" (from ${autoInfo.nameSource})`));
1086
- if (autoInfo.prefix) {
1087
- console.log(colors_default.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`));
1088
- }
1089
- }
1090
- if (!await isProxyRunning(proxyPort, tls2)) {
1091
- const wantTls = !isHttpsEnvDisabled();
1092
- const defaultPort = getDefaultPort(wantTls);
1502
+ const explicit = {
1503
+ useHttps: process.env.PORTLESS_HTTPS !== void 0,
1504
+ customCert: false,
1505
+ lanMode: process.env.PORTLESS_LAN !== void 0,
1506
+ lanIp: process.env.PORTLESS_LAN_IP !== void 0,
1507
+ tld: process.env.PORTLESS_TLD !== void 0,
1508
+ useWildcard: process.env.PORTLESS_WILDCARD !== void 0
1509
+ };
1510
+ const desiredConfig = resolveProxyConfig({
1511
+ persistedLanMode: lanMode,
1512
+ explicit,
1513
+ defaultTld: envTld,
1514
+ useHttps: !isHttpsEnvDisabled(),
1515
+ customCertPath: null,
1516
+ customKeyPath: null,
1517
+ lanMode: isLanEnvEnabled(),
1518
+ lanIp: process.env.PORTLESS_LAN_IP || null,
1519
+ tld: envTld,
1520
+ useWildcard: isWildcardEnvEnabled()
1521
+ });
1522
+ parseHostname(name, tld);
1523
+ const proxyResponsive = await isProxyRunning(proxyPort, tls2);
1524
+ const proxyListeningFromStateDir = !!process.env.PORTLESS_STATE_DIR && await isPortListening(proxyPort);
1525
+ if (!proxyResponsive && !proxyListeningFromStateDir) {
1526
+ const defaultPort = getDefaultPort(desiredConfig.useHttps);
1093
1527
  const needsSudo = !isWindows && defaultPort < PRIVILEGED_PORT_THRESHOLD;
1528
+ const manualStartCommand = formatProxyStartCommand(defaultPort, desiredConfig);
1529
+ const fallbackStartCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, desiredConfig);
1094
1530
  if (needsSudo && !process.stdin.isTTY) {
1095
1531
  console.error(colors_default.red("Proxy is not running and no TTY is available for sudo."));
1096
1532
  console.error(colors_default.blue("Option 1: start the proxy in a terminal (will prompt for sudo):"));
1097
- console.error(colors_default.cyan(" portless proxy start"));
1533
+ console.error(colors_default.cyan(` ${manualStartCommand}`));
1098
1534
  console.error(
1099
1535
  colors_default.blue(
1100
1536
  `Option 2: use an unprivileged port (no sudo needed, URLs will include :${FALLBACK_PROXY_PORT}):`
1101
1537
  )
1102
1538
  );
1103
- console.error(colors_default.cyan(` portless proxy start -p ${FALLBACK_PROXY_PORT}`));
1539
+ console.error(colors_default.cyan(` ${fallbackStartCommand}`));
1104
1540
  process.exit(1);
1105
1541
  }
1106
1542
  if (needsSudo && process.stdin.isTTY) {
@@ -1116,15 +1552,23 @@ portless
1116
1552
  }
1117
1553
  }
1118
1554
  console.log(colors_default.yellow("Starting proxy..."));
1119
- const startArgs = [getEntryScript(), "proxy", "start"];
1120
- if (!wantTls) startArgs.push("--no-tls");
1121
- if (tld !== DEFAULT_TLD) startArgs.push("--tld", tld);
1122
- const result = spawnSync(process.execPath, startArgs, {
1555
+ const proxyStartConfig = buildProxyStartConfig({
1556
+ useHttps: desiredConfig.useHttps,
1557
+ customCertPath: desiredConfig.customCertPath,
1558
+ customKeyPath: desiredConfig.customKeyPath,
1559
+ lanMode: desiredConfig.lanMode,
1560
+ lanIp: desiredConfig.lanIpExplicit ? desiredConfig.lanIp : null,
1561
+ lanIpExplicit: desiredConfig.lanIpExplicit,
1562
+ tld: desiredConfig.tld,
1563
+ useWildcard: desiredConfig.useWildcard
1564
+ });
1565
+ const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
1566
+ const result = spawnSync2(process.execPath, startArgs, {
1123
1567
  stdio: "inherit",
1124
1568
  timeout: SUDO_SPAWN_TIMEOUT_MS
1125
1569
  });
1126
1570
  let discovered = null;
1127
- if (result.status === 0) {
1571
+ if (!result.signal) {
1128
1572
  for (let i = 0; i < WAIT_FOR_PROXY_MAX_ATTEMPTS; i++) {
1129
1573
  await new Promise((r) => setTimeout(r, WAIT_FOR_PROXY_INTERVAL_MS));
1130
1574
  const state = await discoverState();
@@ -1136,10 +1580,10 @@ portless
1136
1580
  }
1137
1581
  if (!discovered) {
1138
1582
  console.error(colors_default.red("Failed to start proxy."));
1139
- const fallbackDir = resolveStateDir(getDefaultPort(wantTls));
1583
+ const fallbackDir = resolveStateDir(getDefaultPort(desiredConfig.useHttps));
1140
1584
  const logPath = path3.join(fallbackDir, "proxy.log");
1141
1585
  console.error(colors_default.blue("Try starting it manually:"));
1142
- console.error(colors_default.cyan(" portless proxy start"));
1586
+ console.error(colors_default.cyan(` ${manualStartCommand}`));
1143
1587
  if (fs3.existsSync(logPath)) {
1144
1588
  console.error(colors_default.gray(`Logs: ${logPath}`));
1145
1589
  }
@@ -1150,14 +1594,42 @@ portless
1150
1594
  stateDir = discovered.dir;
1151
1595
  tld = discovered.tld;
1152
1596
  tls2 = discovered.tls;
1597
+ lanMode = discovered.lanMode;
1598
+ lanIp = discovered.lanIp;
1153
1599
  store = new RouteStore(stateDir, {
1154
1600
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
1155
1601
  });
1156
1602
  console.log(colors_default.green("Proxy started in background"));
1157
1603
  } else {
1158
- console.log(colors_default.gray("-- Proxy is running"));
1604
+ const runningConfig = readCurrentProxyConfig(stateDir);
1605
+ const mismatchMessages = getProxyConfigMismatchMessages(desiredConfig, runningConfig, explicit);
1606
+ if (mismatchMessages.length > 0) {
1607
+ printProxyConfigMismatch(proxyPort, desiredConfig, mismatchMessages);
1608
+ }
1609
+ lanMode = runningConfig.lanMode;
1610
+ lanIp = runningConfig.lanIp;
1611
+ console.log(chalk.gray("-- Proxy is running"));
1159
1612
  }
1160
1613
  const hostname = parseHostname(name, tld);
1614
+ if (envTld !== DEFAULT_TLD && envTld !== tld) {
1615
+ console.warn(
1616
+ chalk.yellow(
1617
+ `Warning: PORTLESS_TLD=${envTld} but the running proxy uses .${tld}. Using .${tld}.`
1618
+ )
1619
+ );
1620
+ }
1621
+ if (lanIp) {
1622
+ console.log(chalk.gray(`-- ${hostname} (LAN: ${lanIp})`));
1623
+ } else {
1624
+ console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
1625
+ }
1626
+ if (autoInfo) {
1627
+ const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name;
1628
+ console.log(chalk.gray(`-- Name "${baseName}" (from ${autoInfo.nameSource})`));
1629
+ if (autoInfo.prefix) {
1630
+ console.log(chalk.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`));
1631
+ }
1632
+ }
1161
1633
  const port = desiredPort ?? await findFreePort();
1162
1634
  if (desiredPort) {
1163
1635
  console.log(colors_default.green(`-- Using port ${port} (fixed)`));
@@ -1178,13 +1650,24 @@ portless
1178
1650
  console.log(colors_default.yellow(`Killed existing process (PID ${killedPid})`));
1179
1651
  }
1180
1652
  const finalUrl = formatUrl(hostname, proxyPort, tls2);
1181
- console.log(colors_default.cyan.bold(`
1653
+ console.log(chalk.cyan.bold(`
1182
1654
  -> ${finalUrl}
1183
1655
  `));
1656
+ if (lanIp) {
1657
+ console.log(chalk.green(` LAN -> ${finalUrl}`));
1658
+ console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
1659
+ }
1660
+ const basename3 = path3.basename(commandArgs[0]);
1661
+ const isExpo = basename3 === "expo";
1662
+ const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
1663
+ const hostBind = isExpoLan ? void 0 : "127.0.0.1";
1664
+ if (lanMode && !process.env.PORTLESS_LAN) {
1665
+ process.env.PORTLESS_LAN = "1";
1666
+ }
1184
1667
  injectFrameworkFlags(commandArgs, port);
1185
1668
  console.log(
1186
- colors_default.gray(
1187
- `Running: PORT=${port} HOST=127.0.0.1 PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
1669
+ chalk.gray(
1670
+ `Running: PORT=${port}${hostBind ? ` HOST=${hostBind}` : ""} PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
1188
1671
  `
1189
1672
  )
1190
1673
  );
@@ -1192,9 +1675,13 @@ portless
1192
1675
  env: {
1193
1676
  ...process.env,
1194
1677
  PORT: port.toString(),
1195
- HOST: "127.0.0.1",
1678
+ ...hostBind ? { HOST: hostBind } : {},
1196
1679
  PORTLESS_URL: finalUrl,
1197
- __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${tld}`
1680
+ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${tld}`,
1681
+ // Note: EXPO_PACKAGER_PROXY_URL is not used — expo-dev-client removed
1682
+ // baked-in pinging, making this env var ineffective. Expo handles its
1683
+ // own LAN discovery natively.
1684
+ ...lanMode ? { PORTLESS_LAN: "1" } : {}
1198
1685
  },
1199
1686
  onCleanup: () => {
1200
1687
  try {
@@ -1342,6 +1829,7 @@ ${colors_default.bold("Install:")}
1342
1829
  ${colors_default.bold("Usage:")}
1343
1830
  ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
1344
1831
  ${colors_default.cyan("portless proxy start --no-tls")} Start without HTTPS (port 80)
1832
+ ${colors_default.cyan("portless proxy start --lan")} Start in LAN mode (mDNS for real device testing)
1345
1833
  ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
1346
1834
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
1347
1835
  ${colors_default.cyan("portless <name> <cmd>")} Run your app through the proxy
@@ -1378,14 +1866,30 @@ ${colors_default.bold("How it works:")}
1378
1866
  (apps get a random port in the 4000-4999 range via PORT)
1379
1867
  3. Access via https://<name>.localhost
1380
1868
  4. .localhost domains auto-resolve to 127.0.0.1
1381
- 5. Frameworks that ignore PORT (Vite, Astro, React Router, Angular,
1382
- Expo, React Native) get --port and --host flags injected automatically
1869
+ 5. Frameworks that ignore PORT (Vite, VitePlus, Astro, React Router, Angular,
1870
+ Expo, React Native) get --port and, when needed, --host flags
1871
+ injected automatically
1383
1872
 
1384
1873
  ${colors_default.bold("HTTP/2 + HTTPS (default):")}
1385
1874
  HTTPS with HTTP/2 multiplexing is enabled by default (faster page loads).
1386
1875
  On first use, portless generates a local CA and adds it to your
1387
1876
  system trust store. No browser warnings. Disable with --no-tls.
1388
1877
 
1878
+ ${colors_default.bold("LAN mode:")}
1879
+ Use --lan to make services accessible from other devices (phones,
1880
+ tablets) on the same WiFi network via mDNS (.local domains).
1881
+ Useful for testing React Native / Expo apps on real devices.
1882
+ Expo keeps Metro's default LAN host behavior in this mode.
1883
+ Auto-detected LAN IPs follow network changes automatically.
1884
+ Stopped LAN proxies keep LAN mode for the next start via proxy.lan.
1885
+ Other proxy settings still follow the current flags and env vars.
1886
+ Use PORTLESS_LAN=0 for one start to switch back to .localhost mode.
1887
+ If a proxy is already running with different explicit LAN/TLS/TLD settings,
1888
+ stop it first.
1889
+ ${colors_default.cyan("portless proxy start --lan")}
1890
+ ${colors_default.cyan("portless proxy start --lan --https")}
1891
+ ${colors_default.cyan("portless proxy start --lan --ip 192.168.1.42")}
1892
+
1389
1893
  ${colors_default.bold("Options:")}
1390
1894
  run [--name <name>] <cmd> Infer project name (or override with --name)
1391
1895
  Adds worktree prefix in git worktrees
@@ -1393,6 +1897,8 @@ ${colors_default.bold("Options:")}
1393
1897
  Standard ports auto-elevate with sudo on macOS/Linux
1394
1898
  --no-tls Disable HTTPS (use plain HTTP on port 80)
1395
1899
  --https Enable HTTPS (default, accepted for compatibility)
1900
+ --lan Enable LAN mode (mDNS .local, for real device testing)
1901
+ --ip <address> Pin a specific LAN IP (disables auto-follow; use with --lan)
1396
1902
  --cert <path> Use a custom TLS certificate
1397
1903
  --key <path> Use a custom TLS private key
1398
1904
  --foreground Run proxy in foreground (for debugging)
@@ -1406,7 +1912,8 @@ ${colors_default.bold("Options:")}
1406
1912
  ${colors_default.bold("Environment variables:")}
1407
1913
  PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
1408
1914
  PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
1409
- PORTLESS_HTTPS HTTPS on by default; set to 0 to disable (same as --no-tls)
1915
+ PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
1916
+ PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
1410
1917
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
1411
1918
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
1412
1919
  PORTLESS_SYNC_HOSTS=1 Auto-sync ${HOSTS_DISPLAY} (auto-enabled for custom TLDs)
@@ -1415,8 +1922,9 @@ ${colors_default.bold("Environment variables:")}
1415
1922
 
1416
1923
  ${colors_default.bold("Child process environment:")}
1417
1924
  PORT Ephemeral port the child should listen on
1418
- HOST Always 127.0.0.1
1925
+ HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
1419
1926
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
1927
+ PORTLESS_LAN Set to 1 when proxy is in LAN mode
1420
1928
 
1421
1929
  ${colors_default.bold("Safari / DNS:")}
1422
1930
  .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
@@ -1438,7 +1946,7 @@ ${colors_default.bold("Reserved names:")}
1438
1946
  process.exit(0);
1439
1947
  }
1440
1948
  function printVersion() {
1441
- console.log("0.9.5");
1949
+ console.log("0.10.0");
1442
1950
  process.exit(0);
1443
1951
  }
1444
1952
  async function handleTrust() {
@@ -1459,7 +1967,7 @@ async function handleTrust() {
1459
1967
  const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
1460
1968
  if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
1461
1969
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
1462
- const sudoResult = spawnSync(
1970
+ const sudoResult = spawnSync2(
1463
1971
  "sudo",
1464
1972
  [
1465
1973
  "env",
@@ -1630,7 +2138,7 @@ ${colors_default.bold("Auto-sync:")}
1630
2138
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
1631
2139
  )
1632
2140
  );
1633
- const result = spawnSync(
2141
+ const result = spawnSync2(
1634
2142
  "sudo",
1635
2143
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
1636
2144
  {
@@ -1683,7 +2191,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
1683
2191
  console.log(
1684
2192
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
1685
2193
  );
1686
- const result = spawnSync(
2194
+ const result = spawnSync2(
1687
2195
  "sudo",
1688
2196
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
1689
2197
  {
@@ -1734,15 +2242,25 @@ ${colors_default.bold("portless proxy")} - Manage the portless proxy server.
1734
2242
  ${colors_default.bold("Usage:")}
1735
2243
  ${colors_default.cyan("portless proxy start")} Start the HTTPS proxy on port 443 (daemon)
1736
2244
  ${colors_default.cyan("portless proxy start --no-tls")} Start without HTTPS (port 80)
2245
+ ${colors_default.cyan("portless proxy start --lan")} Enable LAN mode (mDNS, .local TLD)
1737
2246
  ${colors_default.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
1738
2247
  ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
1739
2248
  ${colors_default.cyan("portless proxy start --tld test")} Use .test instead of .localhost
1740
2249
  ${colors_default.cyan("portless proxy start --wildcard")} Allow unregistered subdomains to fall back to parent
1741
2250
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
2251
+
2252
+ ${colors_default.bold("LAN mode (--lan):")}
2253
+ Makes services accessible from other devices on the same WiFi network
2254
+ via mDNS (.local domains). Useful for testing on real mobile devices.
2255
+ Auto-detects your LAN IP and follows changes automatically, or use
2256
+ --ip to pin one.
2257
+ Stopped LAN proxies keep LAN mode for the next start via proxy.lan.
2258
+ Use PORTLESS_LAN=0 for one start to switch back to .localhost mode.
1742
2259
  `);
1743
2260
  process.exit(isProxyHelp || !args[1] ? 0 : 1);
1744
2261
  }
1745
2262
  const isForeground = args.includes("--foreground");
2263
+ const hasHttpsFlag = args.includes("--https");
1746
2264
  const hasNoTls = args.includes("--no-tls") || isHttpsEnvDisabled();
1747
2265
  const wantHttps = !hasNoTls;
1748
2266
  let customCertPath = null;
@@ -1767,7 +2285,7 @@ ${colors_default.bold("Usage:")}
1767
2285
  console.error(colors_default.red("Error: --cert and --key must be used together."));
1768
2286
  process.exit(1);
1769
2287
  }
1770
- const useHttps = wantHttps || !!(customCertPath && customKeyPath);
2288
+ let useHttps = wantHttps || !!(customCertPath && customKeyPath);
1771
2289
  let hasExplicitPort = false;
1772
2290
  let proxyPort = getDefaultPort(useHttps);
1773
2291
  let portFlagIndex = args.indexOf("--port");
@@ -1809,12 +2327,66 @@ ${colors_default.bold("Usage:")}
1809
2327
  process.exit(1);
1810
2328
  }
1811
2329
  }
2330
+ const useWildcard = args.includes("--wildcard") || isWildcardEnvEnabled();
2331
+ const explicit = {
2332
+ useHttps: hasHttpsFlag || hasNoTls || customCertPath !== null || customKeyPath !== null || process.env.PORTLESS_HTTPS !== void 0,
2333
+ customCert: customCertPath !== null || customKeyPath !== null,
2334
+ lanMode: process.env.PORTLESS_LAN !== void 0,
2335
+ lanIp: process.env.PORTLESS_LAN_IP !== void 0,
2336
+ tld: tldIdx !== -1 || process.env.PORTLESS_TLD !== void 0,
2337
+ useWildcard: args.includes("--wildcard") || process.env.PORTLESS_WILDCARD !== void 0
2338
+ };
2339
+ let stateDir = resolveStateDir(proxyPort);
2340
+ let persistedLanMode = readLanMarker(stateDir) !== null;
2341
+ let runningPort = null;
2342
+ if (!hasExplicitPort) {
2343
+ const currentState = await discoverState();
2344
+ persistedLanMode = currentState.lanMode;
2345
+ if (await isProxyRunning(currentState.port) || !!process.env.PORTLESS_STATE_DIR && await isPortListening(currentState.port)) {
2346
+ runningPort = currentState.port;
2347
+ proxyPort = currentState.port;
2348
+ stateDir = currentState.dir;
2349
+ }
2350
+ }
2351
+ const desiredConfig = resolveProxyConfig({
2352
+ persistedLanMode,
2353
+ explicit,
2354
+ defaultTld: getDefaultTld(),
2355
+ useHttps: wantHttps || !!(customCertPath && customKeyPath),
2356
+ customCertPath,
2357
+ customKeyPath,
2358
+ lanMode: isLanEnvEnabled(),
2359
+ lanIp: process.env.PORTLESS_LAN_IP || null,
2360
+ tld,
2361
+ useWildcard
2362
+ });
2363
+ const lanMode = desiredConfig.lanMode;
2364
+ useHttps = desiredConfig.useHttps;
2365
+ customCertPath = desiredConfig.customCertPath;
2366
+ customKeyPath = desiredConfig.customKeyPath;
2367
+ tld = desiredConfig.tld;
2368
+ const desiredWildcard = desiredConfig.useWildcard;
2369
+ let lanIp = desiredConfig.lanIpExplicit ? desiredConfig.lanIp : null;
2370
+ if (!hasExplicitPort && runningPort === null) {
2371
+ proxyPort = getDefaultPort(useHttps);
2372
+ stateDir = resolveStateDir(proxyPort);
2373
+ }
2374
+ if (lanMode && tldIdx !== -1) {
2375
+ const userTld = args[tldIdx + 1];
2376
+ if (userTld && userTld !== "local") {
2377
+ console.warn(
2378
+ chalk.yellow(
2379
+ `Warning: --lan forces .local TLD (mDNS requirement). Ignoring --tld ${userTld}.`
2380
+ )
2381
+ );
2382
+ }
2383
+ }
1812
2384
  const riskyReason = RISKY_TLDS.get(tld);
1813
- if (riskyReason) {
2385
+ if (riskyReason && !lanMode) {
1814
2386
  console.warn(colors_default.yellow(`Warning: .${tld}: ${riskyReason}`));
1815
2387
  }
1816
2388
  const syncDisabled = process.env.PORTLESS_SYNC_HOSTS === "0" || process.env.PORTLESS_SYNC_HOSTS === "false";
1817
- if (tld !== DEFAULT_TLD && syncDisabled) {
2389
+ if (tld !== DEFAULT_TLD && !lanMode && syncDisabled) {
1818
2390
  console.warn(
1819
2391
  colors_default.yellow(
1820
2392
  `Warning: .${tld} domains require ${HOSTS_DISPLAY} entries to resolve to 127.0.0.1.`
@@ -1823,51 +2395,93 @@ ${colors_default.bold("Usage:")}
1823
2395
  console.warn(colors_default.yellow("Hosts sync is disabled. To add entries manually, run:"));
1824
2396
  console.warn(colors_default.cyan(" portless hosts sync"));
1825
2397
  }
1826
- const useWildcard = args.includes("--wildcard") || isWildcardEnvEnabled();
1827
- let stateDir = resolveStateDir(proxyPort);
1828
2398
  let store = new RouteStore(stateDir, {
1829
2399
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
1830
2400
  });
1831
- if (await isProxyRunning(proxyPort)) {
2401
+ const proxyRunning = runningPort !== null || await isProxyRunning(proxyPort);
2402
+ if (proxyRunning) {
2403
+ const runningConfig = readCurrentProxyConfig(stateDir);
2404
+ const mismatchMessages = getProxyConfigMismatchMessages(desiredConfig, runningConfig, explicit);
2405
+ if (mismatchMessages.length > 0) {
2406
+ printProxyConfigMismatch(proxyPort, desiredConfig, mismatchMessages);
2407
+ }
1832
2408
  if (isForeground) {
1833
2409
  return;
1834
2410
  }
1835
- const portFlag = proxyPort !== getProtocolPort(useHttps) ? ` -p ${proxyPort}` : "";
2411
+ const portFlag = proxyPort !== getDefaultPort(useHttps) ? ` -p ${proxyPort}` : "";
1836
2412
  console.log(colors_default.yellow(`Proxy is already running on port ${proxyPort}.`));
1837
2413
  console.log(
1838
2414
  colors_default.blue(`To restart: portless proxy stop${portFlag} && portless proxy start${portFlag}`)
1839
2415
  );
1840
2416
  return;
1841
2417
  }
2418
+ if (lanMode) {
2419
+ const mdnsSupport = isMdnsSupported();
2420
+ if (!mdnsSupport.supported) {
2421
+ console.error(
2422
+ colors_default.red(
2423
+ "Error: LAN mode requires mDNS publishing, which is not supported on this platform."
2424
+ )
2425
+ );
2426
+ if (mdnsSupport.reason) {
2427
+ console.error(colors_default.gray(mdnsSupport.reason));
2428
+ }
2429
+ process.exit(1);
2430
+ }
2431
+ const inheritedLanIp = process.env[INTERNAL_LAN_IP_ENV] || null;
2432
+ delete process.env[INTERNAL_LAN_IP_ENV];
2433
+ if (!lanIp) {
2434
+ lanIp = inheritedLanIp || await getLocalNetworkIp();
2435
+ }
2436
+ if (!lanIp) {
2437
+ console.error(colors_default.red("Error: Could not detect LAN IP. Are you connected to a network?"));
2438
+ console.error(colors_default.blue("Specify manually:"));
2439
+ console.error(colors_default.cyan(" portless proxy start --lan --ip 192.168.1.42"));
2440
+ process.exit(1);
2441
+ }
2442
+ } else {
2443
+ delete process.env[INTERNAL_LAN_IP_ENV];
2444
+ }
2445
+ const resolvedConfig = {
2446
+ ...desiredConfig,
2447
+ useHttps,
2448
+ customCertPath,
2449
+ customKeyPath,
2450
+ lanMode,
2451
+ lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
2452
+ lanIpExplicit: desiredConfig.lanIpExplicit,
2453
+ tld,
2454
+ useWildcard: desiredWildcard
2455
+ };
1842
2456
  if (!isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1843
- const baseArgs = [
2457
+ const startArgs = [
1844
2458
  process.execPath,
1845
2459
  getEntryScript(),
1846
2460
  "proxy",
1847
2461
  "start",
1848
- "-p",
1849
- String(proxyPort)
2462
+ ...buildProxyStartConfig({
2463
+ useHttps,
2464
+ customCertPath,
2465
+ customKeyPath,
2466
+ lanMode,
2467
+ lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
2468
+ lanIpExplicit: desiredConfig.lanIpExplicit,
2469
+ tld,
2470
+ useWildcard: desiredWildcard,
2471
+ foreground: isForeground,
2472
+ includePort: true,
2473
+ proxyPort
2474
+ }).args
1850
2475
  ];
1851
- const optionalFlags = [];
1852
- if (hasNoTls) optionalFlags.push("--no-tls");
1853
- if (tld !== DEFAULT_TLD) optionalFlags.push("--tld", tld);
1854
- if (useWildcard) optionalFlags.push("--wildcard");
1855
- if (isForeground) optionalFlags.push("--foreground");
1856
- if (customCertPath && customKeyPath)
1857
- optionalFlags.push("--cert", customCertPath, "--key", customKeyPath);
1858
- const startArgs = [...baseArgs, ...optionalFlags];
1859
- const extraFlags = optionalFlags.map((a) => ` ${a}`).join("");
2476
+ const fallbackCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, resolvedConfig);
2477
+ const currentCommand = formatProxyStartCommand(proxyPort, resolvedConfig);
1860
2478
  console.log(
1861
2479
  colors_default.yellow(`Port ${proxyPort} requires elevated privileges. Requesting sudo...`)
1862
2480
  );
1863
2481
  if (!hasExplicitPort) {
1864
- console.log(
1865
- colors_default.gray(
1866
- `(To skip sudo, use an unprivileged port: portless proxy start -p ${FALLBACK_PROXY_PORT}${extraFlags})`
1867
- )
1868
- );
2482
+ console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
1869
2483
  }
1870
- const result = spawnSync("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
2484
+ const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
1871
2485
  stdio: "inherit",
1872
2486
  timeout: SUDO_SPAWN_TIMEOUT_MS
1873
2487
  });
@@ -1894,7 +2508,7 @@ ${colors_default.bold("Usage:")}
1894
2508
  console.log(
1895
2509
  colors_default.blue(`For clean URLs without port numbers, re-run and accept the sudo prompt:`)
1896
2510
  );
1897
- console.log(colors_default.cyan(` portless proxy start${extraFlags}`));
2511
+ console.log(colors_default.cyan(` ${fallbackCommand}`));
1898
2512
  if (await isProxyRunning(proxyPort)) {
1899
2513
  console.log(colors_default.yellow(`Proxy is already running on port ${proxyPort}.`));
1900
2514
  return;
@@ -1908,7 +2522,7 @@ ${colors_default.bold("Usage:")}
1908
2522
  colors_default.red(`Error: Port ${proxyPort} requires elevated privileges and sudo failed.`)
1909
2523
  );
1910
2524
  console.error(colors_default.blue("Try again (portless will prompt for sudo):"));
1911
- console.error(colors_default.cyan(` portless proxy start -p ${proxyPort}${extraFlags}`));
2525
+ console.error(colors_default.cyan(` ${currentCommand}`));
1912
2526
  process.exit(1);
1913
2527
  }
1914
2528
  }
@@ -1975,8 +2589,8 @@ ${colors_default.bold("Usage:")}
1975
2589
  }
1976
2590
  }
1977
2591
  if (isForeground) {
1978
- console.log(colors_default.blue.bold("\nportless proxy\n"));
1979
- startProxyServer(store, proxyPort, tld, tlsOptions, useWildcard ? false : void 0);
2592
+ console.log(chalk.blue.bold("\nportless proxy\n"));
2593
+ startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, desiredWildcard ? false : void 0);
1980
2594
  return;
1981
2595
  }
1982
2596
  store.ensureDir();
@@ -1992,26 +2606,21 @@ ${colors_default.bold("Usage:")}
1992
2606
  getEntryScript(),
1993
2607
  "proxy",
1994
2608
  "start",
1995
- "--foreground",
1996
- "--port",
1997
- proxyPort.toString()
2609
+ ...buildProxyStartConfig({
2610
+ useHttps,
2611
+ customCertPath,
2612
+ customKeyPath,
2613
+ lanMode,
2614
+ lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
2615
+ lanIpExplicit: desiredConfig.lanIpExplicit,
2616
+ tld,
2617
+ useWildcard: desiredWildcard,
2618
+ foreground: true,
2619
+ includePort: true,
2620
+ proxyPort
2621
+ }).args
1998
2622
  ];
1999
- if (useHttps) {
2000
- if (customCertPath && customKeyPath) {
2001
- daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
2002
- } else {
2003
- daemonArgs.push("--https");
2004
- }
2005
- } else {
2006
- daemonArgs.push("--no-tls");
2007
- }
2008
- if (tld !== DEFAULT_TLD) {
2009
- daemonArgs.push("--tld", tld);
2010
- }
2011
- if (useWildcard) {
2012
- daemonArgs.push("--wildcard");
2013
- }
2014
- const child = spawn(process.execPath, daemonArgs, {
2623
+ const child = spawn2(process.execPath, daemonArgs, {
2015
2624
  detached: true,
2016
2625
  stdio: ["ignore", logFd, logFd],
2017
2626
  env: process.env,
@@ -2031,7 +2640,11 @@ ${colors_default.bold("Usage:")}
2031
2640
  process.exit(1);
2032
2641
  }
2033
2642
  const proto = useHttps ? "HTTPS/2" : "HTTP";
2034
- console.log(colors_default.green(`${proto} proxy started on port ${proxyPort}`));
2643
+ console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
2644
+ if (lanMode && lanIp) {
2645
+ console.log(chalk.green(`LAN mode active. IP: ${lanIp}`));
2646
+ console.log(chalk.gray("Services will be discoverable as <name>.local on your network."));
2647
+ }
2035
2648
  }
2036
2649
  async function handleRunMode(args) {
2037
2650
  const parsed = parseRunArgs(args);
@@ -2055,7 +2668,7 @@ async function handleRunMode(args) {
2055
2668
  }
2056
2669
  const worktree = detectWorktreePrefix();
2057
2670
  const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
2058
- const { dir, port, tls: tls2, tld } = await discoverState();
2671
+ const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
2059
2672
  const store = new RouteStore(dir, {
2060
2673
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
2061
2674
  });
@@ -2069,7 +2682,9 @@ async function handleRunMode(args) {
2069
2682
  tld,
2070
2683
  parsed.force,
2071
2684
  { nameSource, prefix: worktree?.prefix, prefixSource: worktree?.source },
2072
- parsed.appPort
2685
+ parsed.appPort,
2686
+ lanMode,
2687
+ lanIp
2073
2688
  );
2074
2689
  }
2075
2690
  async function handleNamedMode(args) {
@@ -2083,7 +2698,7 @@ async function handleNamedMode(args) {
2083
2698
  process.exit(1);
2084
2699
  }
2085
2700
  const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
2086
- const { dir, port, tls: tls2, tld } = await discoverState();
2701
+ const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
2087
2702
  const store = new RouteStore(dir, {
2088
2703
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
2089
2704
  });
@@ -2097,7 +2712,9 @@ async function handleNamedMode(args) {
2097
2712
  tld,
2098
2713
  parsed.force,
2099
2714
  void 0,
2100
- parsed.appPort
2715
+ parsed.appPort,
2716
+ lanMode,
2717
+ lanIp
2101
2718
  );
2102
2719
  }
2103
2720
  async function main() {
@@ -2119,6 +2736,40 @@ async function main() {
2119
2736
  console.error(colors_default.cyan(" npm install -D portless"));
2120
2737
  process.exit(1);
2121
2738
  }
2739
+ const stripGlobalFlag = (flag, hasValue) => {
2740
+ const sep = args.indexOf("--");
2741
+ const end = sep === -1 ? args.length : sep;
2742
+ const idx = args.indexOf(flag);
2743
+ if (idx === -1 || idx >= end) return null;
2744
+ if (!hasValue) {
2745
+ args.splice(idx, 1);
2746
+ return true;
2747
+ }
2748
+ const value = args[idx + 1];
2749
+ if (!value || value.startsWith("-")) return false;
2750
+ args.splice(idx, 2);
2751
+ return value;
2752
+ };
2753
+ if (stripGlobalFlag("--lan", false)) {
2754
+ process.env.PORTLESS_LAN = "1";
2755
+ }
2756
+ const ipResult = stripGlobalFlag("--ip", true);
2757
+ if (ipResult === false) {
2758
+ console.error(chalk.red("Error: --ip requires an IP address."));
2759
+ console.error(chalk.cyan(" portless --lan --ip 192.168.1.42 run <command>"));
2760
+ process.exit(1);
2761
+ } else if (typeof ipResult === "string") {
2762
+ process.env.PORTLESS_LAN_IP = ipResult;
2763
+ process.env.PORTLESS_LAN = "1";
2764
+ }
2765
+ const autoIpResult = stripGlobalFlag(INTERNAL_LAN_IP_FLAG, true);
2766
+ if (autoIpResult === false) {
2767
+ console.error(chalk.red(`Error: ${INTERNAL_LAN_IP_FLAG} requires an IP address.`));
2768
+ process.exit(1);
2769
+ } else if (typeof autoIpResult === "string") {
2770
+ process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
2771
+ process.env.PORTLESS_LAN = "1";
2772
+ }
2122
2773
  if (args[0] === "--name") {
2123
2774
  args.shift();
2124
2775
  if (!args[0]) {