portless 0.9.6 → 0.10.1

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,17 @@ 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,
12
+ SYSTEM_STATE_DIR,
13
+ USER_STATE_DIR,
10
14
  WAIT_FOR_PROXY_INTERVAL_MS,
11
15
  WAIT_FOR_PROXY_MAX_ATTEMPTS,
16
+ buildProxyStartConfig,
12
17
  cleanHostsFile,
13
18
  createHttpRedirectServer,
14
19
  createProxyServer,
@@ -19,23 +24,29 @@ import {
19
24
  formatUrl,
20
25
  getDefaultPort,
21
26
  getDefaultTld,
22
- getProtocolPort,
23
27
  injectFrameworkFlags,
24
28
  isErrnoException,
25
29
  isHttpsEnvDisabled,
30
+ isLanEnvEnabled,
31
+ isPortListening,
26
32
  isProxyRunning,
27
33
  isWildcardEnvEnabled,
28
34
  isWindows,
29
35
  parseHostname,
30
36
  prompt,
37
+ readLanMarker,
38
+ readTldFromDir,
39
+ readTlsMarker,
31
40
  resolveStateDir,
41
+ shouldAutoSyncHosts,
32
42
  spawnCommand,
33
43
  syncHostsFile,
34
44
  validateTld,
35
45
  waitForProxy,
46
+ writeLanMarker,
36
47
  writeTldFile,
37
48
  writeTlsMarker
38
- } from "./chunk-OPTRASOS.js";
49
+ } from "./chunk-FM7IA4AF.js";
39
50
 
40
51
  // src/colors.ts
41
52
  function supportsColor() {
@@ -63,9 +74,9 @@ var gray = wrap("90", "39");
63
74
  var colors_default = { bold, red, green, yellow, blue, cyan, white, gray };
64
75
 
65
76
  // src/cli.ts
66
- import * as fs3 from "fs";
67
- import * as path3 from "path";
68
- import { spawn, spawnSync } from "child_process";
77
+ import * as fs4 from "fs";
78
+ import * as path4 from "path";
79
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
69
80
 
70
81
  // src/certs.ts
71
82
  import * as fs from "fs";
@@ -135,6 +146,14 @@ function isCertValid(certPath) {
135
146
  return false;
136
147
  }
137
148
  }
149
+ function isCertSansComplete(certPath) {
150
+ try {
151
+ const text = openssl(["x509", "-in", certPath, "-noout", "-text"]);
152
+ return /DNS:\*\.local\b/.test(text);
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
138
157
  function isCertSignatureStrong(certPath) {
139
158
  try {
140
159
  const text = openssl(["x509", "-in", certPath, "-noout", "-text"]);
@@ -216,6 +235,7 @@ function generateServerCert(stateDir) {
216
235
  const extPath = path.join(stateDir, "server-ext.cnf");
217
236
  openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", serverKeyPath]);
218
237
  openssl(["req", "-new", "-key", serverKeyPath, "-out", csrPath, "-subj", "/CN=localhost"]);
238
+ const sans = ["DNS:localhost", "DNS:*.localhost", "DNS:*.local"];
219
239
  fs.writeFileSync(
220
240
  extPath,
221
241
  [
@@ -223,7 +243,7 @@ function generateServerCert(stateDir) {
223
243
  "basicConstraints=CA:FALSE",
224
244
  "keyUsage=digitalSignature,keyEncipherment",
225
245
  "extendedKeyUsage=serverAuth",
226
- "subjectAltName=DNS:localhost,DNS:*.localhost"
246
+ `subjectAltName=${sans.join(",")}`
227
247
  ].join("\n") + "\n"
228
248
  );
229
249
  const srlPath = path.join(stateDir, "ca.srl");
@@ -273,7 +293,7 @@ function ensureCerts(stateDir) {
273
293
  generateCA(stateDir);
274
294
  caGenerated = true;
275
295
  }
276
- if (caGenerated || !fileExists(serverCertPath) || !fileExists(serverKeyPath) || !isCertValid(serverCertPath) || !isCertSignatureStrong(serverCertPath)) {
296
+ if (caGenerated || !fileExists(serverCertPath) || !fileExists(serverKeyPath) || !isCertValid(serverCertPath) || !isCertSignatureStrong(serverCertPath) || !isCertSansComplete(serverCertPath)) {
277
297
  generateServerCert(stateDir);
278
298
  }
279
299
  return {
@@ -591,6 +611,129 @@ function trustCA(stateDir) {
591
611
  return { trusted: false, error: message };
592
612
  }
593
613
  }
614
+ function untrustCA(stateDir) {
615
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
616
+ if (!fileExists(caCertPath)) {
617
+ return { removed: true };
618
+ }
619
+ if (!isCATrusted(stateDir)) {
620
+ return { removed: true };
621
+ }
622
+ try {
623
+ if (process.platform === "darwin") {
624
+ return untrustCAMacOS(caCertPath);
625
+ }
626
+ if (process.platform === "linux") {
627
+ return untrustCALinux(stateDir);
628
+ }
629
+ if (process.platform === "win32") {
630
+ return untrustCAWindows(caCertPath);
631
+ }
632
+ return { removed: false, error: `Unsupported platform: ${process.platform}` };
633
+ } catch (err) {
634
+ const message = err instanceof Error ? err.message : String(err);
635
+ return { removed: false, error: message };
636
+ }
637
+ }
638
+ function untrustCAMacOS(caCertPath) {
639
+ const errors = [];
640
+ const tryExec = (args) => {
641
+ try {
642
+ execFileSync("security", args, { stdio: "pipe", timeout: 3e4 });
643
+ return true;
644
+ } catch (err) {
645
+ const message = err instanceof Error ? err.message : String(err);
646
+ errors.push(message);
647
+ return false;
648
+ }
649
+ };
650
+ if (tryExec(["remove-trusted-cert", caCertPath])) {
651
+ return isCATrustedMacOSAfterAttempt(caCertPath) ? { removed: false, error: errors.join("; ") || "Trust entry may still be present" } : { removed: true };
652
+ }
653
+ const login = loginKeychainPath();
654
+ tryExec(["delete-certificate", "-c", CA_COMMON_NAME, login]);
655
+ tryExec(["delete-certificate", "-c", CA_COMMON_NAME, "/Library/Keychains/System.keychain"]);
656
+ return isCATrustedMacOSAfterAttempt(caCertPath) ? { removed: false, error: errors.join("; ") || "Could not remove CA from keychain (try sudo)" } : { removed: true };
657
+ }
658
+ function isCATrustedMacOSAfterAttempt(caCertPath) {
659
+ try {
660
+ const isRoot = (process.getuid?.() ?? -1) === 0;
661
+ const sudoUser = process.env.SUDO_USER;
662
+ if (isRoot && sudoUser) {
663
+ execFileSync(
664
+ "sudo",
665
+ ["-u", sudoUser, "security", "verify-cert", "-c", caCertPath, "-L", "-p", "ssl"],
666
+ { stdio: "pipe", timeout: 5e3 }
667
+ );
668
+ } else {
669
+ execFileSync("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
670
+ stdio: "pipe",
671
+ timeout: 5e3
672
+ });
673
+ }
674
+ return true;
675
+ } catch {
676
+ return false;
677
+ }
678
+ }
679
+ function untrustCALinux(stateDir) {
680
+ const errors = [];
681
+ let deletedAny = false;
682
+ for (const config of Object.values(LINUX_CA_TRUST_CONFIGS)) {
683
+ const dest = path.join(config.certDir, "portless-ca.crt");
684
+ try {
685
+ if (fileExists(dest)) {
686
+ const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
687
+ const installed = fs.readFileSync(dest, "utf-8").trim();
688
+ if (ours === installed) {
689
+ fs.unlinkSync(dest);
690
+ deletedAny = true;
691
+ }
692
+ }
693
+ } catch (err) {
694
+ errors.push(err instanceof Error ? err.message : String(err));
695
+ }
696
+ }
697
+ if (deletedAny) {
698
+ try {
699
+ const config = getLinuxCATrustConfig();
700
+ execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
701
+ } catch (err) {
702
+ errors.push(err instanceof Error ? err.message : String(err));
703
+ }
704
+ }
705
+ if (isCATrusted(stateDir)) {
706
+ return {
707
+ removed: false,
708
+ error: errors.join("; ") || "CA still trusted (remove portless-ca.crt and run the distro CA update command, often with sudo)"
709
+ };
710
+ }
711
+ return { removed: true };
712
+ }
713
+ function untrustCAWindows(caCertPath) {
714
+ try {
715
+ const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
716
+ const storeListing = execFileSync("certutil", ["-store", "-user", "Root"], {
717
+ encoding: "utf-8",
718
+ timeout: 1e4,
719
+ stdio: ["pipe", "pipe", "pipe"]
720
+ });
721
+ const normalized = storeListing.replace(/\s/g, "").toLowerCase();
722
+ if (!normalized.includes(fingerprint)) {
723
+ return { removed: true };
724
+ }
725
+ execFileSync("certutil", ["-delstore", "-user", "Root", "portless Local CA"], {
726
+ stdio: "pipe",
727
+ timeout: 3e4
728
+ });
729
+ if (isCATrustedWindows(caCertPath)) {
730
+ return { removed: false, error: "certutil could not remove the portless CA from Root" };
731
+ }
732
+ return { removed: true };
733
+ } catch (err) {
734
+ return { removed: false, error: err instanceof Error ? err.message : String(err) };
735
+ }
736
+ }
594
737
 
595
738
  // src/auto.ts
596
739
  import { createHash } from "crypto";
@@ -766,12 +909,398 @@ function readBranchFromHead(gitdir) {
766
909
  }
767
910
  }
768
911
 
912
+ // src/clean-utils.ts
913
+ import * as fs3 from "fs";
914
+ import * as path3 from "path";
915
+ var PORTLESS_STATE_FILES = [
916
+ "routes.json",
917
+ "routes.lock",
918
+ "proxy.pid",
919
+ "proxy.port",
920
+ "proxy.log",
921
+ "proxy.tls",
922
+ "proxy.tld",
923
+ "proxy.lan",
924
+ "ca-key.pem",
925
+ "ca.pem",
926
+ "server-key.pem",
927
+ "server.pem",
928
+ "server.csr",
929
+ "server-ext.cnf",
930
+ "ca.srl"
931
+ ];
932
+ var HOST_CERTS_DIR2 = "host-certs";
933
+ function collectStateDirsForCleanup() {
934
+ const dirs = /* @__PURE__ */ new Set();
935
+ const add = (d) => {
936
+ const trimmed = d?.trim();
937
+ if (!trimmed) return;
938
+ const resolved = path3.resolve(trimmed);
939
+ if (fs3.existsSync(resolved)) dirs.add(resolved);
940
+ };
941
+ add(USER_STATE_DIR);
942
+ add(SYSTEM_STATE_DIR);
943
+ add(process.env.PORTLESS_STATE_DIR);
944
+ return [...dirs];
945
+ }
946
+ function removePortlessStateFiles(dir) {
947
+ for (const f of PORTLESS_STATE_FILES) {
948
+ try {
949
+ fs3.unlinkSync(path3.join(dir, f));
950
+ } catch {
951
+ }
952
+ }
953
+ try {
954
+ fs3.rmSync(path3.join(dir, HOST_CERTS_DIR2), { recursive: true, force: true });
955
+ } catch {
956
+ }
957
+ }
958
+
959
+ // src/mdns.ts
960
+ import { spawn, spawnSync } from "child_process";
961
+
962
+ // src/lan-ip.ts
963
+ import { createSocket } from "dgram";
964
+ import { networkInterfaces } from "os";
965
+ var PROBE_HOST = "1.1.1.1";
966
+ var PROBE_PORT = 53;
967
+ var NO_ROUTE_IP = "0.0.0.0";
968
+ function isIPv4Family(family) {
969
+ return family === "IPv4" || family === 4;
970
+ }
971
+ function parseMac(macStr) {
972
+ return macStr.split(":").slice(0, 16).map((seq) => parseInt(seq, 16));
973
+ }
974
+ function isInternalInterface(iname, macStr, internal) {
975
+ if (internal) {
976
+ return true;
977
+ }
978
+ const mac = parseMac(macStr);
979
+ if (mac.every((x) => !x)) {
980
+ return true;
981
+ }
982
+ if (mac[0] === 0 && mac[1] === 21 && mac[2] === 93) {
983
+ return true;
984
+ }
985
+ if (iname.includes("vEthernet") || /^bridge\d+$/.test(iname)) {
986
+ return true;
987
+ }
988
+ return false;
989
+ }
990
+ function probeDefaultRouteIPv4() {
991
+ return new Promise((resolve3, reject) => {
992
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
993
+ socket.on("error", (error) => {
994
+ socket.close();
995
+ socket.unref();
996
+ reject(error);
997
+ });
998
+ socket.connect(PROBE_PORT, PROBE_HOST, () => {
999
+ const addr = socket.address();
1000
+ socket.close();
1001
+ socket.unref();
1002
+ if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
1003
+ resolve3(addr.address);
1004
+ } else {
1005
+ reject(new Error("No route to host"));
1006
+ }
1007
+ });
1008
+ });
1009
+ }
1010
+ function findInterfaceRowForIp(ip) {
1011
+ const ifs = networkInterfaces();
1012
+ for (const iname of Object.keys(ifs)) {
1013
+ const entries = ifs[iname];
1014
+ if (!entries) continue;
1015
+ for (const e of entries) {
1016
+ if (!isIPv4Family(e.family)) continue;
1017
+ if (e.address !== ip) continue;
1018
+ return { iname, address: e.address, mac: e.mac, internal: e.internal };
1019
+ }
1020
+ }
1021
+ return null;
1022
+ }
1023
+ async function getLocalNetworkIp() {
1024
+ try {
1025
+ const ip = await probeDefaultRouteIPv4();
1026
+ if (ip === "127.0.0.1") {
1027
+ return null;
1028
+ }
1029
+ const row = findInterfaceRowForIp(ip);
1030
+ if (!row) {
1031
+ return null;
1032
+ }
1033
+ if (row.address === "127.0.0.1") {
1034
+ return null;
1035
+ }
1036
+ if (isInternalInterface(row.iname, row.mac, row.internal)) {
1037
+ return null;
1038
+ }
1039
+ return row.address;
1040
+ } catch {
1041
+ return null;
1042
+ }
1043
+ }
1044
+
1045
+ // src/mdns.ts
1046
+ var activePublishers = /* @__PURE__ */ new Map();
1047
+ var LAN_IP_POLL_INTERVAL_MS = 5e3;
1048
+ function getMdnsPublisher() {
1049
+ if (process.platform === "darwin") {
1050
+ return {
1051
+ command: "dns-sd",
1052
+ probeArgs: ["-h"],
1053
+ missingReason: "dns-sd not found",
1054
+ buildArgs: (fqdn, name, port, ip) => [
1055
+ "-P",
1056
+ name,
1057
+ "_http._tcp",
1058
+ "local",
1059
+ port.toString(),
1060
+ fqdn,
1061
+ ip
1062
+ ]
1063
+ };
1064
+ }
1065
+ if (process.platform === "linux") {
1066
+ return {
1067
+ command: "avahi-publish-address",
1068
+ probeArgs: ["--help"],
1069
+ missingReason: "avahi-publish-address not found. Install avahi-utils: sudo apt install avahi-utils",
1070
+ buildArgs: (fqdn, _name, _port, ip) => ["-R", fqdn, ip]
1071
+ };
1072
+ }
1073
+ return null;
1074
+ }
1075
+ function hasCommand(command, probeArgs) {
1076
+ const result = spawnSync(command, probeArgs, {
1077
+ stdio: "ignore",
1078
+ timeout: 1e3,
1079
+ windowsHide: true
1080
+ });
1081
+ return result.error?.code !== "ENOENT";
1082
+ }
1083
+ function startLanIpMonitor(options) {
1084
+ const resolveIp = options.resolveIp ?? getLocalNetworkIp;
1085
+ let currentIp = options.initialIp;
1086
+ let stopped = false;
1087
+ let polling = false;
1088
+ const poll = async () => {
1089
+ if (stopped || polling) return;
1090
+ polling = true;
1091
+ try {
1092
+ const nextIp = await resolveIp();
1093
+ if (stopped || nextIp === currentIp) return;
1094
+ const previousIp = currentIp;
1095
+ currentIp = nextIp;
1096
+ options.onChange(nextIp, previousIp);
1097
+ } catch (error) {
1098
+ options.onError?.(error);
1099
+ } finally {
1100
+ polling = false;
1101
+ }
1102
+ };
1103
+ const timer = setInterval(() => {
1104
+ void poll();
1105
+ }, options.intervalMs ?? LAN_IP_POLL_INTERVAL_MS);
1106
+ timer.unref?.();
1107
+ return {
1108
+ stop: () => {
1109
+ stopped = true;
1110
+ clearInterval(timer);
1111
+ }
1112
+ };
1113
+ }
1114
+ function isMdnsSupported() {
1115
+ const publisher = getMdnsPublisher();
1116
+ if (!publisher) {
1117
+ return { supported: false, reason: "mDNS publishing is not supported on this platform" };
1118
+ }
1119
+ if (!hasCommand(publisher.command, publisher.probeArgs)) {
1120
+ return { supported: false, reason: publisher.missingReason };
1121
+ }
1122
+ return { supported: true };
1123
+ }
1124
+ function serviceName(hostname) {
1125
+ return hostname.replace(/\.local$/, "");
1126
+ }
1127
+ function publish(hostname, port, ip, onError) {
1128
+ if (activePublishers.has(hostname)) return;
1129
+ const fqdn = hostname.endsWith(".local") ? hostname : `${hostname}.local`;
1130
+ const name = serviceName(fqdn);
1131
+ const publisher = getMdnsPublisher();
1132
+ if (!publisher) {
1133
+ return;
1134
+ }
1135
+ const child = spawn(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
1136
+ stdio: "ignore",
1137
+ detached: false
1138
+ });
1139
+ child.on("error", (err) => {
1140
+ activePublishers.delete(hostname);
1141
+ const msg = err.code === "ENOENT" ? publisher.missingReason : `mDNS publish error for ${hostname}: ${err.message}`;
1142
+ onError?.(msg);
1143
+ });
1144
+ child.on("exit", () => {
1145
+ activePublishers.delete(hostname);
1146
+ });
1147
+ activePublishers.set(hostname, child);
1148
+ }
1149
+ function unpublish(hostname) {
1150
+ const child = activePublishers.get(hostname);
1151
+ if (!child) return;
1152
+ activePublishers.delete(hostname);
1153
+ child.kill("SIGTERM");
1154
+ }
1155
+ function cleanupAll() {
1156
+ for (const child of activePublishers.values()) {
1157
+ child.kill("SIGTERM");
1158
+ }
1159
+ activePublishers.clear();
1160
+ }
1161
+
769
1162
  // src/cli.ts
1163
+ var chalk = colors_default;
770
1164
  var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
771
1165
  var DEBOUNCE_MS = 100;
772
1166
  var POLL_INTERVAL_MS = 3e3;
773
1167
  var EXIT_TIMEOUT_MS = 2e3;
774
1168
  var SUDO_SPAWN_TIMEOUT_MS = 3e4;
1169
+ function defaultProxyConfig(tld, useHttps, lanMode) {
1170
+ return {
1171
+ useHttps,
1172
+ customCertPath: null,
1173
+ customKeyPath: null,
1174
+ lanMode,
1175
+ lanIp: null,
1176
+ lanIpExplicit: false,
1177
+ tld: lanMode ? "local" : tld,
1178
+ useWildcard: false
1179
+ };
1180
+ }
1181
+ function resolveProxyConfig(options) {
1182
+ const config = defaultProxyConfig(
1183
+ options.defaultTld,
1184
+ options.useHttps,
1185
+ options.explicit.lanMode ? options.lanMode : options.persistedLanMode
1186
+ );
1187
+ if (options.explicit.useHttps) {
1188
+ config.useHttps = options.useHttps;
1189
+ if (!options.useHttps) {
1190
+ config.customCertPath = null;
1191
+ config.customKeyPath = null;
1192
+ }
1193
+ }
1194
+ if (options.explicit.customCert) {
1195
+ config.useHttps = true;
1196
+ config.customCertPath = options.customCertPath;
1197
+ config.customKeyPath = options.customKeyPath;
1198
+ }
1199
+ if (options.explicit.lanMode) {
1200
+ config.lanMode = options.lanMode;
1201
+ if (!options.lanMode) {
1202
+ config.lanIp = null;
1203
+ config.lanIpExplicit = false;
1204
+ if (!options.explicit.tld) {
1205
+ config.tld = options.defaultTld;
1206
+ }
1207
+ }
1208
+ }
1209
+ if (options.explicit.lanIp && options.lanIp) {
1210
+ config.lanMode = true;
1211
+ config.lanIp = options.lanIp;
1212
+ config.lanIpExplicit = true;
1213
+ }
1214
+ if (options.explicit.tld) {
1215
+ config.tld = options.tld;
1216
+ }
1217
+ if (options.explicit.useWildcard) {
1218
+ config.useWildcard = options.useWildcard;
1219
+ }
1220
+ if (!config.lanMode) {
1221
+ config.lanIp = null;
1222
+ config.lanIpExplicit = false;
1223
+ }
1224
+ if (config.lanMode) {
1225
+ config.tld = "local";
1226
+ if (!config.lanIpExplicit) {
1227
+ config.lanIp = null;
1228
+ }
1229
+ }
1230
+ if (!config.useHttps) {
1231
+ config.customCertPath = null;
1232
+ config.customKeyPath = null;
1233
+ }
1234
+ return config;
1235
+ }
1236
+ function readCurrentProxyConfig(dir) {
1237
+ const lanIp = readLanMarker(dir);
1238
+ const tld = readTldFromDir(dir);
1239
+ return {
1240
+ useHttps: readTlsMarker(dir),
1241
+ customCertPath: null,
1242
+ customKeyPath: null,
1243
+ lanMode: lanIp !== null || tld === "local",
1244
+ lanIp,
1245
+ lanIpExplicit: false,
1246
+ tld,
1247
+ useWildcard: false
1248
+ };
1249
+ }
1250
+ function getProxyConfigMismatchMessages(desiredConfig, actualConfig, explicit) {
1251
+ const messages = [];
1252
+ if (explicit.lanMode && desiredConfig.lanMode !== actualConfig.lanMode) {
1253
+ messages.push(
1254
+ 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"
1255
+ );
1256
+ }
1257
+ if (explicit.lanIp && desiredConfig.lanIp !== actualConfig.lanIp) {
1258
+ messages.push(
1259
+ `requested LAN IP ${desiredConfig.lanIp}, but the running proxy is using ${actualConfig.lanIp ?? "auto-detected LAN mode"}`
1260
+ );
1261
+ }
1262
+ if (explicit.useHttps && desiredConfig.useHttps !== actualConfig.useHttps) {
1263
+ messages.push(
1264
+ desiredConfig.useHttps ? "requested HTTPS, but the running proxy is using HTTP" : "requested HTTP, but the running proxy is using HTTPS"
1265
+ );
1266
+ }
1267
+ if (explicit.tld && desiredConfig.tld !== actualConfig.tld) {
1268
+ messages.push(
1269
+ `requested .${desiredConfig.tld}, but the running proxy is using .${actualConfig.tld}`
1270
+ );
1271
+ }
1272
+ return messages;
1273
+ }
1274
+ function formatProxyStartCommand(proxyPort, config) {
1275
+ const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1276
+ const { args } = buildProxyStartConfig({
1277
+ useHttps: config.useHttps,
1278
+ customCertPath: config.customCertPath,
1279
+ customKeyPath: config.customKeyPath,
1280
+ lanMode: config.lanMode,
1281
+ lanIp: config.lanIpExplicit ? config.lanIp : null,
1282
+ lanIpExplicit: config.lanIpExplicit,
1283
+ tld: config.tld,
1284
+ useWildcard: config.useWildcard,
1285
+ includePort: proxyPort !== getDefaultPort(config.useHttps),
1286
+ proxyPort
1287
+ });
1288
+ return `${needsSudo ? "sudo " : ""}portless proxy start${args.length > 0 ? ` ${args.join(" ")}` : ""}`;
1289
+ }
1290
+ function printProxyConfigMismatch(proxyPort, desiredConfig, messages) {
1291
+ const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1292
+ const portFlag = proxyPort !== getDefaultPort(desiredConfig.useHttps) ? ` -p ${proxyPort}` : "";
1293
+ console.error(
1294
+ chalk.yellow(`Proxy is already running on port ${proxyPort} with a different config.`)
1295
+ );
1296
+ for (const message of messages) {
1297
+ console.error(chalk.yellow(`- ${message}`));
1298
+ }
1299
+ console.error(chalk.blue("Stop it first, then restart with the desired settings:"));
1300
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy stop${portFlag}`));
1301
+ console.error(chalk.cyan(` ${formatProxyStartCommand(proxyPort, desiredConfig)}`));
1302
+ process.exit(1);
1303
+ }
775
1304
  function getEntryScript() {
776
1305
  const script = process.argv[1];
777
1306
  if (!script) {
@@ -782,10 +1311,10 @@ function getEntryScript() {
782
1311
  function isLocallyInstalled() {
783
1312
  let dir = process.cwd();
784
1313
  for (; ; ) {
785
- if (fs3.existsSync(path3.join(dir, "node_modules", "portless", "package.json"))) {
1314
+ if (fs4.existsSync(path4.join(dir, "node_modules", "portless", "package.json"))) {
786
1315
  return true;
787
1316
  }
788
- const parent = path3.dirname(dir);
1317
+ const parent = path4.dirname(dir);
789
1318
  if (parent === dir) break;
790
1319
  dir = parent;
791
1320
  }
@@ -803,21 +1332,29 @@ function collectPortlessEnvArgs() {
803
1332
  function sudoStop(port) {
804
1333
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
805
1334
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
806
- const result = spawnSync("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
1335
+ const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
807
1336
  stdio: "inherit",
808
1337
  timeout: SUDO_SPAWN_TIMEOUT_MS
809
1338
  });
810
1339
  return result.status === 0;
811
1340
  }
812
- function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
1341
+ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
813
1342
  store.ensureDir();
814
1343
  const isTls = !!tlsOptions;
1344
+ const mdnsSupport = isMdnsSupported();
1345
+ let activeLanIp = lanIp && mdnsSupport.supported ? lanIp : null;
1346
+ const lanIpPinned = !!process.env.PORTLESS_LAN_IP;
1347
+ let lanMonitor = null;
1348
+ if (lanIp && !mdnsSupport.supported) {
1349
+ const reason = mdnsSupport.reason ?? "mDNS publishing is not supported on this platform.";
1350
+ console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
1351
+ }
815
1352
  const routesPath = store.getRoutesPath();
816
- if (!fs3.existsSync(routesPath)) {
817
- fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
1353
+ if (!fs4.existsSync(routesPath)) {
1354
+ fs4.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
818
1355
  }
819
1356
  try {
820
- fs3.chmodSync(routesPath, FILE_MODE);
1357
+ fs4.chmodSync(routesPath, FILE_MODE);
821
1358
  } catch {
822
1359
  }
823
1360
  fixOwnership(routesPath);
@@ -825,19 +1362,59 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
825
1362
  let debounceTimer = null;
826
1363
  let watcher = null;
827
1364
  let pollingInterval = null;
828
- const syncVal = process.env.PORTLESS_SYNC_HOSTS;
829
- const autoSyncHosts = syncVal === "1" || syncVal === "true" || tld !== DEFAULT_TLD && syncVal !== "0" && syncVal !== "false";
1365
+ const autoSyncHosts = shouldAutoSyncHosts(process.env.PORTLESS_SYNC_HOSTS);
1366
+ const onMdnsError = (msg) => console.warn(chalk.yellow(msg));
1367
+ const publishCachedRoutes = () => {
1368
+ if (!activeLanIp) return;
1369
+ for (const route of cachedRoutes) {
1370
+ publish(route.hostname, proxyPort, activeLanIp, onMdnsError);
1371
+ }
1372
+ };
1373
+ const updateLanIp = (nextIp, previousIp = activeLanIp) => {
1374
+ if (nextIp === activeLanIp) return;
1375
+ if (activeLanIp) {
1376
+ cleanupAll();
1377
+ }
1378
+ activeLanIp = nextIp;
1379
+ writeLanMarker(store.dir, activeLanIp);
1380
+ if (previousIp && nextIp) {
1381
+ console.log(chalk.green(`LAN IP changed: ${previousIp} -> ${nextIp}`));
1382
+ } else if (previousIp && !nextIp) {
1383
+ console.warn(chalk.yellow("LAN mode temporarily unavailable: no active LAN IP"));
1384
+ } else if (!previousIp && nextIp) {
1385
+ console.log(chalk.green(`LAN mode restored: ${nextIp}`));
1386
+ }
1387
+ publishCachedRoutes();
1388
+ };
830
1389
  const reloadRoutes = () => {
831
1390
  try {
1391
+ const previousRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port]));
832
1392
  cachedRoutes = store.loadRoutes();
833
1393
  if (autoSyncHosts) {
834
1394
  syncHostsFile(cachedRoutes.map((r) => r.hostname));
835
1395
  }
1396
+ if (activeLanIp) {
1397
+ const currentRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port]));
1398
+ for (const route of cachedRoutes) {
1399
+ const previousPort = previousRoutes.get(route.hostname);
1400
+ if (previousPort === void 0) {
1401
+ publish(route.hostname, proxyPort, activeLanIp, onMdnsError);
1402
+ } else if (previousPort !== route.port) {
1403
+ unpublish(route.hostname);
1404
+ publish(route.hostname, proxyPort, activeLanIp, onMdnsError);
1405
+ }
1406
+ }
1407
+ for (const hostname of previousRoutes.keys()) {
1408
+ if (!currentRoutes.has(hostname)) {
1409
+ unpublish(hostname);
1410
+ }
1411
+ }
1412
+ }
836
1413
  } catch {
837
1414
  }
838
1415
  };
839
1416
  try {
840
- watcher = fs3.watch(routesPath, () => {
1417
+ watcher = fs4.watch(routesPath, () => {
841
1418
  if (debounceTimer) clearTimeout(debounceTimer);
842
1419
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
843
1420
  });
@@ -848,6 +1425,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
848
1425
  if (autoSyncHosts) {
849
1426
  syncHostsFile(cachedRoutes.map((r) => r.hostname));
850
1427
  }
1428
+ publishCachedRoutes();
851
1429
  const server = createProxyServer({
852
1430
  getRoutes: () => cachedRoutes,
853
1431
  proxyPort,
@@ -886,10 +1464,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
886
1464
  redirectServer.listen(80);
887
1465
  }
888
1466
  server.listen(proxyPort, () => {
889
- fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
890
- fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
1467
+ fs4.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
1468
+ fs4.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
891
1469
  writeTlsMarker(store.dir, isTls);
892
1470
  writeTldFile(store.dir, tld);
1471
+ writeLanMarker(store.dir, activeLanIp);
893
1472
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
894
1473
  const proto = isTls ? "HTTPS/2" : "HTTP";
895
1474
  const tldLabel = tld !== DEFAULT_TLD ? ` (TLD: .${tld})` : "";
@@ -897,6 +1476,24 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
897
1476
  console.log(
898
1477
  colors_default.green(`${proto} proxy listening on port ${proxyPort}${tldLabel}${modeLabel}`)
899
1478
  );
1479
+ if (activeLanIp) {
1480
+ console.log(chalk.green(`LAN mode: ${activeLanIp}`));
1481
+ console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
1482
+ if (isTls) {
1483
+ console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
1484
+ console.log(chalk.gray(` ${path4.join(store.dir, "ca.pem")}`));
1485
+ }
1486
+ if (!lanIpPinned) {
1487
+ lanMonitor = startLanIpMonitor({
1488
+ initialIp: activeLanIp,
1489
+ onChange: (nextIp, previousIp) => updateLanIp(nextIp, previousIp),
1490
+ onError: (error) => {
1491
+ const message = error instanceof Error ? error.message : String(error);
1492
+ console.warn(chalk.yellow(`Failed to refresh LAN IP: ${message}`));
1493
+ }
1494
+ });
1495
+ }
1496
+ }
900
1497
  if (redirectServer) {
901
1498
  console.log(colors_default.green("HTTP-to-HTTPS redirect listening on port 80"));
902
1499
  }
@@ -907,18 +1504,20 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
907
1504
  exiting = true;
908
1505
  if (debounceTimer) clearTimeout(debounceTimer);
909
1506
  if (pollingInterval) clearInterval(pollingInterval);
1507
+ if (lanMonitor) lanMonitor.stop();
910
1508
  if (watcher) {
911
1509
  watcher.close();
912
1510
  }
1511
+ if (activeLanIp) cleanupAll();
913
1512
  if (redirectServer) {
914
1513
  redirectServer.close();
915
1514
  }
916
1515
  try {
917
- fs3.unlinkSync(store.pidPath);
1516
+ fs4.unlinkSync(store.pidPath);
918
1517
  } catch {
919
1518
  }
920
1519
  try {
921
- fs3.unlinkSync(store.portFilePath);
1520
+ fs4.unlinkSync(store.portFilePath);
922
1521
  } catch {
923
1522
  }
924
1523
  writeTlsMarker(store.dir, false);
@@ -947,7 +1546,7 @@ function sudoStopOrHint(port) {
947
1546
  }
948
1547
  async function stopProxy(store, proxyPort, _tls) {
949
1548
  const pidPath = store.pidPath;
950
- if (!fs3.existsSync(pidPath)) {
1549
+ if (!fs4.existsSync(pidPath)) {
951
1550
  if (await isProxyRunning(proxyPort)) {
952
1551
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
953
1552
  const pid = findPidOnPort(proxyPort);
@@ -955,7 +1554,7 @@ async function stopProxy(store, proxyPort, _tls) {
955
1554
  try {
956
1555
  process.kill(pid, "SIGTERM");
957
1556
  try {
958
- fs3.unlinkSync(store.portFilePath);
1557
+ fs4.unlinkSync(store.portFilePath);
959
1558
  } catch {
960
1559
  }
961
1560
  console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
@@ -990,10 +1589,10 @@ async function stopProxy(store, proxyPort, _tls) {
990
1589
  return;
991
1590
  }
992
1591
  try {
993
- const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
1592
+ const pid = parseInt(fs4.readFileSync(pidPath, "utf-8"), 10);
994
1593
  if (isNaN(pid)) {
995
1594
  console.error(colors_default.red("Corrupted PID file. Removing it."));
996
- fs3.unlinkSync(pidPath);
1595
+ fs4.unlinkSync(pidPath);
997
1596
  return;
998
1597
  }
999
1598
  try {
@@ -1004,9 +1603,9 @@ async function stopProxy(store, proxyPort, _tls) {
1004
1603
  return;
1005
1604
  }
1006
1605
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
1007
- fs3.unlinkSync(pidPath);
1606
+ fs4.unlinkSync(pidPath);
1008
1607
  try {
1009
- fs3.unlinkSync(store.portFilePath);
1608
+ fs4.unlinkSync(store.portFilePath);
1010
1609
  } catch {
1011
1610
  }
1012
1611
  return;
@@ -1018,13 +1617,13 @@ async function stopProxy(store, proxyPort, _tls) {
1018
1617
  )
1019
1618
  );
1020
1619
  console.log(colors_default.yellow("Removing stale PID file."));
1021
- fs3.unlinkSync(pidPath);
1620
+ fs4.unlinkSync(pidPath);
1022
1621
  return;
1023
1622
  }
1024
1623
  process.kill(pid, "SIGTERM");
1025
- fs3.unlinkSync(pidPath);
1624
+ fs4.unlinkSync(pidPath);
1026
1625
  try {
1027
- fs3.unlinkSync(store.portFilePath);
1626
+ fs4.unlinkSync(store.portFilePath);
1028
1627
  } catch {
1029
1628
  }
1030
1629
  console.log(colors_default.green("Proxy stopped."));
@@ -1060,8 +1659,11 @@ function listRoutes(store, proxyPort, tls2) {
1060
1659
  }
1061
1660
  console.log();
1062
1661
  }
1063
- async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort) {
1662
+ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort, lanMode = false, lanIp) {
1064
1663
  let store = initialStore;
1664
+ console.log(chalk.blue.bold(`
1665
+ portless
1666
+ `));
1065
1667
  let envTld;
1066
1668
  try {
1067
1669
  envTld = getDefaultTld();
@@ -1069,38 +1671,44 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
1069
1671
  console.error(colors_default.red(`Error: ${err.message}`));
1070
1672
  process.exit(1);
1071
1673
  }
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);
1674
+ const explicit = {
1675
+ useHttps: process.env.PORTLESS_HTTPS !== void 0,
1676
+ customCert: false,
1677
+ lanMode: process.env.PORTLESS_LAN !== void 0,
1678
+ lanIp: process.env.PORTLESS_LAN_IP !== void 0,
1679
+ tld: process.env.PORTLESS_TLD !== void 0,
1680
+ useWildcard: process.env.PORTLESS_WILDCARD !== void 0
1681
+ };
1682
+ const desiredConfig = resolveProxyConfig({
1683
+ persistedLanMode: lanMode,
1684
+ explicit,
1685
+ defaultTld: envTld,
1686
+ useHttps: !isHttpsEnvDisabled(),
1687
+ customCertPath: null,
1688
+ customKeyPath: null,
1689
+ lanMode: isLanEnvEnabled(),
1690
+ lanIp: process.env.PORTLESS_LAN_IP || null,
1691
+ tld: envTld,
1692
+ useWildcard: isWildcardEnvEnabled()
1693
+ });
1694
+ parseHostname(name, tld);
1695
+ const proxyResponsive = await isProxyRunning(proxyPort, tls2);
1696
+ const proxyListeningFromStateDir = !!process.env.PORTLESS_STATE_DIR && await isPortListening(proxyPort);
1697
+ if (!proxyResponsive && !proxyListeningFromStateDir) {
1698
+ const defaultPort = getDefaultPort(desiredConfig.useHttps);
1093
1699
  const needsSudo = !isWindows && defaultPort < PRIVILEGED_PORT_THRESHOLD;
1700
+ const manualStartCommand = formatProxyStartCommand(defaultPort, desiredConfig);
1701
+ const fallbackStartCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, desiredConfig);
1094
1702
  if (needsSudo && !process.stdin.isTTY) {
1095
1703
  console.error(colors_default.red("Proxy is not running and no TTY is available for sudo."));
1096
1704
  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"));
1705
+ console.error(colors_default.cyan(` ${manualStartCommand}`));
1098
1706
  console.error(
1099
1707
  colors_default.blue(
1100
1708
  `Option 2: use an unprivileged port (no sudo needed, URLs will include :${FALLBACK_PROXY_PORT}):`
1101
1709
  )
1102
1710
  );
1103
- console.error(colors_default.cyan(` portless proxy start -p ${FALLBACK_PROXY_PORT}`));
1711
+ console.error(colors_default.cyan(` ${fallbackStartCommand}`));
1104
1712
  process.exit(1);
1105
1713
  }
1106
1714
  if (needsSudo && process.stdin.isTTY) {
@@ -1116,15 +1724,23 @@ portless
1116
1724
  }
1117
1725
  }
1118
1726
  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, {
1727
+ const proxyStartConfig = buildProxyStartConfig({
1728
+ useHttps: desiredConfig.useHttps,
1729
+ customCertPath: desiredConfig.customCertPath,
1730
+ customKeyPath: desiredConfig.customKeyPath,
1731
+ lanMode: desiredConfig.lanMode,
1732
+ lanIp: desiredConfig.lanIpExplicit ? desiredConfig.lanIp : null,
1733
+ lanIpExplicit: desiredConfig.lanIpExplicit,
1734
+ tld: desiredConfig.tld,
1735
+ useWildcard: desiredConfig.useWildcard
1736
+ });
1737
+ const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
1738
+ const result = spawnSync2(process.execPath, startArgs, {
1123
1739
  stdio: "inherit",
1124
1740
  timeout: SUDO_SPAWN_TIMEOUT_MS
1125
1741
  });
1126
1742
  let discovered = null;
1127
- if (result.status === 0) {
1743
+ if (!result.signal) {
1128
1744
  for (let i = 0; i < WAIT_FOR_PROXY_MAX_ATTEMPTS; i++) {
1129
1745
  await new Promise((r) => setTimeout(r, WAIT_FOR_PROXY_INTERVAL_MS));
1130
1746
  const state = await discoverState();
@@ -1136,11 +1752,11 @@ portless
1136
1752
  }
1137
1753
  if (!discovered) {
1138
1754
  console.error(colors_default.red("Failed to start proxy."));
1139
- const fallbackDir = resolveStateDir(getDefaultPort(wantTls));
1140
- const logPath = path3.join(fallbackDir, "proxy.log");
1755
+ const fallbackDir = resolveStateDir(getDefaultPort(desiredConfig.useHttps));
1756
+ const logPath = path4.join(fallbackDir, "proxy.log");
1141
1757
  console.error(colors_default.blue("Try starting it manually:"));
1142
- console.error(colors_default.cyan(" portless proxy start"));
1143
- if (fs3.existsSync(logPath)) {
1758
+ console.error(colors_default.cyan(` ${manualStartCommand}`));
1759
+ if (fs4.existsSync(logPath)) {
1144
1760
  console.error(colors_default.gray(`Logs: ${logPath}`));
1145
1761
  }
1146
1762
  process.exit(1);
@@ -1150,14 +1766,42 @@ portless
1150
1766
  stateDir = discovered.dir;
1151
1767
  tld = discovered.tld;
1152
1768
  tls2 = discovered.tls;
1769
+ lanMode = discovered.lanMode;
1770
+ lanIp = discovered.lanIp;
1153
1771
  store = new RouteStore(stateDir, {
1154
1772
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
1155
1773
  });
1156
1774
  console.log(colors_default.green("Proxy started in background"));
1157
1775
  } else {
1158
- console.log(colors_default.gray("-- Proxy is running"));
1776
+ const runningConfig = readCurrentProxyConfig(stateDir);
1777
+ const mismatchMessages = getProxyConfigMismatchMessages(desiredConfig, runningConfig, explicit);
1778
+ if (mismatchMessages.length > 0) {
1779
+ printProxyConfigMismatch(proxyPort, desiredConfig, mismatchMessages);
1780
+ }
1781
+ lanMode = runningConfig.lanMode;
1782
+ lanIp = runningConfig.lanIp;
1783
+ console.log(chalk.gray("-- Proxy is running"));
1159
1784
  }
1160
1785
  const hostname = parseHostname(name, tld);
1786
+ if (envTld !== DEFAULT_TLD && envTld !== tld) {
1787
+ console.warn(
1788
+ chalk.yellow(
1789
+ `Warning: PORTLESS_TLD=${envTld} but the running proxy uses .${tld}. Using .${tld}.`
1790
+ )
1791
+ );
1792
+ }
1793
+ if (lanIp) {
1794
+ console.log(chalk.gray(`-- ${hostname} (LAN: ${lanIp})`));
1795
+ } else {
1796
+ console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
1797
+ }
1798
+ if (autoInfo) {
1799
+ const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name;
1800
+ console.log(chalk.gray(`-- Name "${baseName}" (from ${autoInfo.nameSource})`));
1801
+ if (autoInfo.prefix) {
1802
+ console.log(chalk.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`));
1803
+ }
1804
+ }
1161
1805
  const port = desiredPort ?? await findFreePort();
1162
1806
  if (desiredPort) {
1163
1807
  console.log(colors_default.green(`-- Using port ${port} (fixed)`));
@@ -1178,13 +1822,24 @@ portless
1178
1822
  console.log(colors_default.yellow(`Killed existing process (PID ${killedPid})`));
1179
1823
  }
1180
1824
  const finalUrl = formatUrl(hostname, proxyPort, tls2);
1181
- console.log(colors_default.cyan.bold(`
1825
+ console.log(chalk.cyan.bold(`
1182
1826
  -> ${finalUrl}
1183
1827
  `));
1828
+ if (lanIp) {
1829
+ console.log(chalk.green(` LAN -> ${finalUrl}`));
1830
+ console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
1831
+ }
1832
+ const basename3 = path4.basename(commandArgs[0]);
1833
+ const isExpo = basename3 === "expo";
1834
+ const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
1835
+ const hostBind = isExpoLan ? void 0 : "127.0.0.1";
1836
+ if (lanMode && !process.env.PORTLESS_LAN) {
1837
+ process.env.PORTLESS_LAN = "1";
1838
+ }
1184
1839
  injectFrameworkFlags(commandArgs, port);
1185
1840
  console.log(
1186
- colors_default.gray(
1187
- `Running: PORT=${port} HOST=127.0.0.1 PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
1841
+ chalk.gray(
1842
+ `Running: PORT=${port}${hostBind ? ` HOST=${hostBind}` : ""} PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
1188
1843
  `
1189
1844
  )
1190
1845
  );
@@ -1192,9 +1847,13 @@ portless
1192
1847
  env: {
1193
1848
  ...process.env,
1194
1849
  PORT: port.toString(),
1195
- HOST: "127.0.0.1",
1850
+ ...hostBind ? { HOST: hostBind } : {},
1196
1851
  PORTLESS_URL: finalUrl,
1197
- __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${tld}`
1852
+ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${tld}`,
1853
+ // Note: EXPO_PACKAGER_PROXY_URL is not used — expo-dev-client removed
1854
+ // baked-in pinging, making this env var ineffective. Expo handles its
1855
+ // own LAN discovery natively.
1856
+ ...lanMode ? { PORTLESS_LAN: "1" } : {}
1198
1857
  },
1199
1858
  onCleanup: () => {
1200
1859
  try {
@@ -1342,6 +2001,7 @@ ${colors_default.bold("Install:")}
1342
2001
  ${colors_default.bold("Usage:")}
1343
2002
  ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
1344
2003
  ${colors_default.cyan("portless proxy start --no-tls")} Start without HTTPS (port 80)
2004
+ ${colors_default.cyan("portless proxy start --lan")} Start in LAN mode (mDNS for real device testing)
1345
2005
  ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
1346
2006
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
1347
2007
  ${colors_default.cyan("portless <name> <cmd>")} Run your app through the proxy
@@ -1351,6 +2011,7 @@ ${colors_default.bold("Usage:")}
1351
2011
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
1352
2012
  ${colors_default.cyan("portless list")} Show active routes
1353
2013
  ${colors_default.cyan("portless trust")} Add local CA to system trust store
2014
+ ${colors_default.cyan("portless clean")} Remove portless state, trust entry, and hosts block
1354
2015
  ${colors_default.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
1355
2016
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1356
2017
 
@@ -1378,14 +2039,30 @@ ${colors_default.bold("How it works:")}
1378
2039
  (apps get a random port in the 4000-4999 range via PORT)
1379
2040
  3. Access via https://<name>.localhost
1380
2041
  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
2042
+ 5. Frameworks that ignore PORT (Vite, VitePlus, Astro, React Router, Angular,
2043
+ Expo, React Native) get --port and, when needed, --host flags
2044
+ injected automatically
1383
2045
 
1384
2046
  ${colors_default.bold("HTTP/2 + HTTPS (default):")}
1385
2047
  HTTPS with HTTP/2 multiplexing is enabled by default (faster page loads).
1386
2048
  On first use, portless generates a local CA and adds it to your
1387
2049
  system trust store. No browser warnings. Disable with --no-tls.
1388
2050
 
2051
+ ${colors_default.bold("LAN mode:")}
2052
+ Use --lan to make services accessible from other devices (phones,
2053
+ tablets) on the same WiFi network via mDNS (.local domains).
2054
+ Useful for testing React Native / Expo apps on real devices.
2055
+ Expo keeps Metro's default LAN host behavior in this mode.
2056
+ Auto-detected LAN IPs follow network changes automatically.
2057
+ Stopped LAN proxies keep LAN mode for the next start via proxy.lan.
2058
+ Other proxy settings still follow the current flags and env vars.
2059
+ Use PORTLESS_LAN=0 for one start to switch back to .localhost mode.
2060
+ If a proxy is already running with different explicit LAN/TLS/TLD settings,
2061
+ stop it first.
2062
+ ${colors_default.cyan("portless proxy start --lan")}
2063
+ ${colors_default.cyan("portless proxy start --lan --https")}
2064
+ ${colors_default.cyan("portless proxy start --lan --ip 192.168.1.42")}
2065
+
1389
2066
  ${colors_default.bold("Options:")}
1390
2067
  run [--name <name>] <cmd> Infer project name (or override with --name)
1391
2068
  Adds worktree prefix in git worktrees
@@ -1393,6 +2070,8 @@ ${colors_default.bold("Options:")}
1393
2070
  Standard ports auto-elevate with sudo on macOS/Linux
1394
2071
  --no-tls Disable HTTPS (use plain HTTP on port 80)
1395
2072
  --https Enable HTTPS (default, accepted for compatibility)
2073
+ --lan Enable LAN mode (mDNS .local, for real device testing)
2074
+ --ip <address> Pin a specific LAN IP (disables auto-follow; use with --lan)
1396
2075
  --cert <path> Use a custom TLS certificate
1397
2076
  --key <path> Use a custom TLS private key
1398
2077
  --foreground Run proxy in foreground (for debugging)
@@ -1406,23 +2085,25 @@ ${colors_default.bold("Options:")}
1406
2085
  ${colors_default.bold("Environment variables:")}
1407
2086
  PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
1408
2087
  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)
2088
+ PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
2089
+ PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
1410
2090
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
1411
2091
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
1412
- PORTLESS_SYNC_HOSTS=1 Auto-sync ${HOSTS_DISPLAY} (auto-enabled for custom TLDs)
2092
+ PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
1413
2093
  PORTLESS_STATE_DIR=<path> Override the state directory
1414
2094
  PORTLESS=0 Run command directly without proxy
1415
2095
 
1416
2096
  ${colors_default.bold("Child process environment:")}
1417
2097
  PORT Ephemeral port the child should listen on
1418
- HOST Always 127.0.0.1
2098
+ HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
1419
2099
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
2100
+ PORTLESS_LAN Set to 1 when proxy is in LAN mode
1420
2101
 
1421
2102
  ${colors_default.bold("Safari / DNS:")}
1422
2103
  .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1423
2104
  Safari relies on the system DNS resolver, which may not handle them.
1424
- Auto-syncs ${HOSTS_DISPLAY} for custom TLDs (e.g. --tld test). For .localhost,
1425
- set PORTLESS_SYNC_HOSTS=1 to enable. To manually sync:
2105
+ Auto-syncs ${HOSTS_DISPLAY} for route hostnames by default (including .localhost,
2106
+ custom TLDs, and LAN .local). Set PORTLESS_SYNC_HOSTS=0 to disable. To manually sync:
1426
2107
  ${colors_default.cyan("portless hosts sync")}
1427
2108
  Clean up later with:
1428
2109
  ${colors_default.cyan("portless hosts clean")}
@@ -1431,20 +2112,20 @@ ${colors_default.bold("Skip portless:")}
1431
2112
  PORTLESS=0 pnpm dev # Runs command directly without proxy
1432
2113
 
1433
2114
  ${colors_default.bold("Reserved names:")}
1434
- run, get, alias, hosts, list, trust, proxy are subcommands and cannot
2115
+ run, get, alias, hosts, list, trust, clean, proxy are subcommands and cannot
1435
2116
  be used as app names directly. Use "portless run" to infer the name,
1436
2117
  or "portless --name <name>" to force any name including reserved ones.
1437
2118
  `);
1438
2119
  process.exit(0);
1439
2120
  }
1440
2121
  function printVersion() {
1441
- console.log("0.9.6");
2122
+ console.log("0.10.1");
1442
2123
  process.exit(0);
1443
2124
  }
1444
2125
  async function handleTrust() {
1445
2126
  const { dir } = await discoverState();
1446
- if (!fs3.existsSync(dir)) {
1447
- fs3.mkdirSync(dir, { recursive: true });
2127
+ if (!fs4.existsSync(dir)) {
2128
+ fs4.mkdirSync(dir, { recursive: true });
1448
2129
  }
1449
2130
  const { caGenerated } = ensureCerts(dir);
1450
2131
  if (caGenerated) {
@@ -1459,7 +2140,7 @@ async function handleTrust() {
1459
2140
  const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
1460
2141
  if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
1461
2142
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
1462
- const sudoResult = spawnSync(
2143
+ const sudoResult = spawnSync2(
1463
2144
  "sudo",
1464
2145
  [
1465
2146
  "env",
@@ -1480,6 +2161,90 @@ async function handleTrust() {
1480
2161
  console.error(colors_default.red(`Failed to trust CA: ${result.error}`));
1481
2162
  process.exit(1);
1482
2163
  }
2164
+ async function handleClean(args) {
2165
+ if (args[1] === "--help" || args[1] === "-h") {
2166
+ console.log(`
2167
+ ${colors_default.bold("portless clean")} - Remove portless artifacts from this machine.
2168
+
2169
+ Stops the proxy if it is running, removes the local CA from the OS trust store
2170
+ when it was installed by portless, deletes known files under state directories
2171
+ (~/.portless, the system state directory, and PORTLESS_STATE_DIR when set),
2172
+ and removes the portless block from ${HOSTS_DISPLAY}.
2173
+
2174
+ Only allowlisted filenames under each state directory are deleted. Custom
2175
+ certificate paths from --cert and --key are never removed.
2176
+
2177
+ macOS/Linux may prompt for sudo when the proxy, trust store, or ${HOSTS_DISPLAY}
2178
+ require elevated privileges. On Windows, run as Administrator if needed.
2179
+
2180
+ ${colors_default.bold("Usage:")}
2181
+ ${colors_default.cyan("portless clean")}
2182
+
2183
+ ${colors_default.bold("Options:")}
2184
+ --help, -h Show this help
2185
+ `);
2186
+ process.exit(0);
2187
+ }
2188
+ if (args.length > 1) {
2189
+ console.error(colors_default.red(`Error: Unknown argument "${args[1]}".`));
2190
+ console.error(colors_default.cyan(" portless clean --help"));
2191
+ process.exit(1);
2192
+ }
2193
+ console.log(colors_default.cyan("Stopping proxy if it is running..."));
2194
+ const { dir, port, tls: tls2 } = await discoverState();
2195
+ const store = new RouteStore(dir, {
2196
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
2197
+ });
2198
+ await stopProxy(store, port, tls2);
2199
+ const stateDirs = collectStateDirsForCleanup();
2200
+ for (const stateDir of stateDirs) {
2201
+ const caPath = path4.join(stateDir, "ca.pem");
2202
+ if (!fs4.existsSync(caPath)) continue;
2203
+ const wasTrusted = isCATrusted(stateDir);
2204
+ if (!wasTrusted) continue;
2205
+ const untrustResult = untrustCA(stateDir);
2206
+ if (untrustResult.removed) {
2207
+ console.log(colors_default.green("Removed local CA from the system trust store."));
2208
+ } else if (untrustResult.error) {
2209
+ console.warn(
2210
+ colors_default.yellow(
2211
+ `Could not remove CA from trust store: ${untrustResult.error}
2212
+ Try: sudo portless clean (Linux), or delete the certificate manually.`
2213
+ )
2214
+ );
2215
+ }
2216
+ }
2217
+ for (const stateDir of stateDirs) {
2218
+ removePortlessStateFiles(stateDir);
2219
+ }
2220
+ console.log(colors_default.green("Removed portless state files from known state directories."));
2221
+ if (cleanHostsFile()) {
2222
+ console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
2223
+ } else if (!isWindows && process.getuid?.() !== 0) {
2224
+ console.log(
2225
+ colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
2226
+ );
2227
+ const result = spawnSync2(
2228
+ "sudo",
2229
+ ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
2230
+ {
2231
+ stdio: "inherit",
2232
+ timeout: SUDO_SPAWN_TIMEOUT_MS
2233
+ }
2234
+ );
2235
+ if (result.status !== 0) {
2236
+ console.error(colors_default.red(`Failed to update ${HOSTS_DISPLAY}. Run: sudo portless clean`));
2237
+ process.exit(1);
2238
+ }
2239
+ } else {
2240
+ console.warn(
2241
+ colors_default.yellow(
2242
+ `Could not remove portless entries from ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : "."}`
2243
+ )
2244
+ );
2245
+ }
2246
+ console.log(colors_default.green("Clean finished."));
2247
+ }
1483
2248
  async function handleList() {
1484
2249
  const { dir, port, tls: tls2 } = await discoverState();
1485
2250
  const store = new RouteStore(dir, {
@@ -1614,8 +2379,8 @@ ${colors_default.bold("Usage:")}
1614
2379
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1615
2380
 
1616
2381
  ${colors_default.bold("Auto-sync:")}
1617
- Auto-enabled for custom TLDs (e.g. --tld test). For .localhost, set
1618
- PORTLESS_SYNC_HOSTS=1 to enable. Disable with PORTLESS_SYNC_HOSTS=0.
2382
+ The proxy updates ${HOSTS_DISPLAY} for route hostnames by default. Disable with
2383
+ PORTLESS_SYNC_HOSTS=0.
1619
2384
  `);
1620
2385
  process.exit(0);
1621
2386
  }
@@ -1630,7 +2395,7 @@ ${colors_default.bold("Auto-sync:")}
1630
2395
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
1631
2396
  )
1632
2397
  );
1633
- const result = spawnSync(
2398
+ const result = spawnSync2(
1634
2399
  "sudo",
1635
2400
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
1636
2401
  {
@@ -1683,7 +2448,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
1683
2448
  console.log(
1684
2449
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
1685
2450
  );
1686
- const result = spawnSync(
2451
+ const result = spawnSync2(
1687
2452
  "sudo",
1688
2453
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
1689
2454
  {
@@ -1734,15 +2499,25 @@ ${colors_default.bold("portless proxy")} - Manage the portless proxy server.
1734
2499
  ${colors_default.bold("Usage:")}
1735
2500
  ${colors_default.cyan("portless proxy start")} Start the HTTPS proxy on port 443 (daemon)
1736
2501
  ${colors_default.cyan("portless proxy start --no-tls")} Start without HTTPS (port 80)
2502
+ ${colors_default.cyan("portless proxy start --lan")} Enable LAN mode (mDNS, .local TLD)
1737
2503
  ${colors_default.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
1738
2504
  ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
1739
2505
  ${colors_default.cyan("portless proxy start --tld test")} Use .test instead of .localhost
1740
2506
  ${colors_default.cyan("portless proxy start --wildcard")} Allow unregistered subdomains to fall back to parent
1741
2507
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
2508
+
2509
+ ${colors_default.bold("LAN mode (--lan):")}
2510
+ Makes services accessible from other devices on the same WiFi network
2511
+ via mDNS (.local domains). Useful for testing on real mobile devices.
2512
+ Auto-detects your LAN IP and follows changes automatically, or use
2513
+ --ip to pin one.
2514
+ Stopped LAN proxies keep LAN mode for the next start via proxy.lan.
2515
+ Use PORTLESS_LAN=0 for one start to switch back to .localhost mode.
1742
2516
  `);
1743
2517
  process.exit(isProxyHelp || !args[1] ? 0 : 1);
1744
2518
  }
1745
2519
  const isForeground = args.includes("--foreground");
2520
+ const hasHttpsFlag = args.includes("--https");
1746
2521
  const hasNoTls = args.includes("--no-tls") || isHttpsEnvDisabled();
1747
2522
  const wantHttps = !hasNoTls;
1748
2523
  let customCertPath = null;
@@ -1767,7 +2542,7 @@ ${colors_default.bold("Usage:")}
1767
2542
  console.error(colors_default.red("Error: --cert and --key must be used together."));
1768
2543
  process.exit(1);
1769
2544
  }
1770
- const useHttps = wantHttps || !!(customCertPath && customKeyPath);
2545
+ let useHttps = wantHttps || !!(customCertPath && customKeyPath);
1771
2546
  let hasExplicitPort = false;
1772
2547
  let proxyPort = getDefaultPort(useHttps);
1773
2548
  let portFlagIndex = args.indexOf("--port");
@@ -1809,12 +2584,66 @@ ${colors_default.bold("Usage:")}
1809
2584
  process.exit(1);
1810
2585
  }
1811
2586
  }
2587
+ const useWildcard = args.includes("--wildcard") || isWildcardEnvEnabled();
2588
+ const explicit = {
2589
+ useHttps: hasHttpsFlag || hasNoTls || customCertPath !== null || customKeyPath !== null || process.env.PORTLESS_HTTPS !== void 0,
2590
+ customCert: customCertPath !== null || customKeyPath !== null,
2591
+ lanMode: process.env.PORTLESS_LAN !== void 0,
2592
+ lanIp: process.env.PORTLESS_LAN_IP !== void 0,
2593
+ tld: tldIdx !== -1 || process.env.PORTLESS_TLD !== void 0,
2594
+ useWildcard: args.includes("--wildcard") || process.env.PORTLESS_WILDCARD !== void 0
2595
+ };
2596
+ let stateDir = resolveStateDir(proxyPort);
2597
+ let persistedLanMode = readLanMarker(stateDir) !== null;
2598
+ let runningPort = null;
2599
+ if (!hasExplicitPort) {
2600
+ const currentState = await discoverState();
2601
+ persistedLanMode = currentState.lanMode;
2602
+ if (await isProxyRunning(currentState.port) || !!process.env.PORTLESS_STATE_DIR && await isPortListening(currentState.port)) {
2603
+ runningPort = currentState.port;
2604
+ proxyPort = currentState.port;
2605
+ stateDir = currentState.dir;
2606
+ }
2607
+ }
2608
+ const desiredConfig = resolveProxyConfig({
2609
+ persistedLanMode,
2610
+ explicit,
2611
+ defaultTld: getDefaultTld(),
2612
+ useHttps: wantHttps || !!(customCertPath && customKeyPath),
2613
+ customCertPath,
2614
+ customKeyPath,
2615
+ lanMode: isLanEnvEnabled(),
2616
+ lanIp: process.env.PORTLESS_LAN_IP || null,
2617
+ tld,
2618
+ useWildcard
2619
+ });
2620
+ const lanMode = desiredConfig.lanMode;
2621
+ useHttps = desiredConfig.useHttps;
2622
+ customCertPath = desiredConfig.customCertPath;
2623
+ customKeyPath = desiredConfig.customKeyPath;
2624
+ tld = desiredConfig.tld;
2625
+ const desiredWildcard = desiredConfig.useWildcard;
2626
+ let lanIp = desiredConfig.lanIpExplicit ? desiredConfig.lanIp : null;
2627
+ if (!hasExplicitPort && runningPort === null) {
2628
+ proxyPort = getDefaultPort(useHttps);
2629
+ stateDir = resolveStateDir(proxyPort);
2630
+ }
2631
+ if (lanMode && tldIdx !== -1) {
2632
+ const userTld = args[tldIdx + 1];
2633
+ if (userTld && userTld !== "local") {
2634
+ console.warn(
2635
+ chalk.yellow(
2636
+ `Warning: --lan forces .local TLD (mDNS requirement). Ignoring --tld ${userTld}.`
2637
+ )
2638
+ );
2639
+ }
2640
+ }
1812
2641
  const riskyReason = RISKY_TLDS.get(tld);
1813
- if (riskyReason) {
2642
+ if (riskyReason && !lanMode) {
1814
2643
  console.warn(colors_default.yellow(`Warning: .${tld}: ${riskyReason}`));
1815
2644
  }
1816
2645
  const syncDisabled = process.env.PORTLESS_SYNC_HOSTS === "0" || process.env.PORTLESS_SYNC_HOSTS === "false";
1817
- if (tld !== DEFAULT_TLD && syncDisabled) {
2646
+ if (tld !== DEFAULT_TLD && !lanMode && syncDisabled) {
1818
2647
  console.warn(
1819
2648
  colors_default.yellow(
1820
2649
  `Warning: .${tld} domains require ${HOSTS_DISPLAY} entries to resolve to 127.0.0.1.`
@@ -1823,51 +2652,93 @@ ${colors_default.bold("Usage:")}
1823
2652
  console.warn(colors_default.yellow("Hosts sync is disabled. To add entries manually, run:"));
1824
2653
  console.warn(colors_default.cyan(" portless hosts sync"));
1825
2654
  }
1826
- const useWildcard = args.includes("--wildcard") || isWildcardEnvEnabled();
1827
- let stateDir = resolveStateDir(proxyPort);
1828
2655
  let store = new RouteStore(stateDir, {
1829
2656
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
1830
2657
  });
1831
- if (await isProxyRunning(proxyPort)) {
2658
+ const proxyRunning = runningPort !== null || await isProxyRunning(proxyPort);
2659
+ if (proxyRunning) {
2660
+ const runningConfig = readCurrentProxyConfig(stateDir);
2661
+ const mismatchMessages = getProxyConfigMismatchMessages(desiredConfig, runningConfig, explicit);
2662
+ if (mismatchMessages.length > 0) {
2663
+ printProxyConfigMismatch(proxyPort, desiredConfig, mismatchMessages);
2664
+ }
1832
2665
  if (isForeground) {
1833
2666
  return;
1834
2667
  }
1835
- const portFlag = proxyPort !== getProtocolPort(useHttps) ? ` -p ${proxyPort}` : "";
2668
+ const portFlag = proxyPort !== getDefaultPort(useHttps) ? ` -p ${proxyPort}` : "";
1836
2669
  console.log(colors_default.yellow(`Proxy is already running on port ${proxyPort}.`));
1837
2670
  console.log(
1838
2671
  colors_default.blue(`To restart: portless proxy stop${portFlag} && portless proxy start${portFlag}`)
1839
2672
  );
1840
2673
  return;
1841
2674
  }
2675
+ if (lanMode) {
2676
+ const mdnsSupport = isMdnsSupported();
2677
+ if (!mdnsSupport.supported) {
2678
+ console.error(
2679
+ colors_default.red(
2680
+ "Error: LAN mode requires mDNS publishing, which is not supported on this platform."
2681
+ )
2682
+ );
2683
+ if (mdnsSupport.reason) {
2684
+ console.error(colors_default.gray(mdnsSupport.reason));
2685
+ }
2686
+ process.exit(1);
2687
+ }
2688
+ const inheritedLanIp = process.env[INTERNAL_LAN_IP_ENV] || null;
2689
+ delete process.env[INTERNAL_LAN_IP_ENV];
2690
+ if (!lanIp) {
2691
+ lanIp = inheritedLanIp || await getLocalNetworkIp();
2692
+ }
2693
+ if (!lanIp) {
2694
+ console.error(colors_default.red("Error: Could not detect LAN IP. Are you connected to a network?"));
2695
+ console.error(colors_default.blue("Specify manually:"));
2696
+ console.error(colors_default.cyan(" portless proxy start --lan --ip 192.168.1.42"));
2697
+ process.exit(1);
2698
+ }
2699
+ } else {
2700
+ delete process.env[INTERNAL_LAN_IP_ENV];
2701
+ }
2702
+ const resolvedConfig = {
2703
+ ...desiredConfig,
2704
+ useHttps,
2705
+ customCertPath,
2706
+ customKeyPath,
2707
+ lanMode,
2708
+ lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
2709
+ lanIpExplicit: desiredConfig.lanIpExplicit,
2710
+ tld,
2711
+ useWildcard: desiredWildcard
2712
+ };
1842
2713
  if (!isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1843
- const baseArgs = [
2714
+ const startArgs = [
1844
2715
  process.execPath,
1845
2716
  getEntryScript(),
1846
2717
  "proxy",
1847
2718
  "start",
1848
- "-p",
1849
- String(proxyPort)
2719
+ ...buildProxyStartConfig({
2720
+ useHttps,
2721
+ customCertPath,
2722
+ customKeyPath,
2723
+ lanMode,
2724
+ lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
2725
+ lanIpExplicit: desiredConfig.lanIpExplicit,
2726
+ tld,
2727
+ useWildcard: desiredWildcard,
2728
+ foreground: isForeground,
2729
+ includePort: true,
2730
+ proxyPort
2731
+ }).args
1850
2732
  ];
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("");
2733
+ const fallbackCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, resolvedConfig);
2734
+ const currentCommand = formatProxyStartCommand(proxyPort, resolvedConfig);
1860
2735
  console.log(
1861
2736
  colors_default.yellow(`Port ${proxyPort} requires elevated privileges. Requesting sudo...`)
1862
2737
  );
1863
2738
  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
- );
2739
+ console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
1869
2740
  }
1870
- const result = spawnSync("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
2741
+ const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
1871
2742
  stdio: "inherit",
1872
2743
  timeout: SUDO_SPAWN_TIMEOUT_MS
1873
2744
  });
@@ -1877,8 +2748,8 @@ ${colors_default.bold("Usage:")}
1877
2748
  console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
1878
2749
  } else {
1879
2750
  console.error(colors_default.red("Proxy process started but is not responding."));
1880
- const logPath2 = path3.join(resolveStateDir(proxyPort), "proxy.log");
1881
- if (fs3.existsSync(logPath2)) {
2751
+ const logPath2 = path4.join(resolveStateDir(proxyPort), "proxy.log");
2752
+ if (fs4.existsSync(logPath2)) {
1882
2753
  console.error(colors_default.gray(`Logs: ${logPath2}`));
1883
2754
  }
1884
2755
  }
@@ -1894,7 +2765,7 @@ ${colors_default.bold("Usage:")}
1894
2765
  console.log(
1895
2766
  colors_default.blue(`For clean URLs without port numbers, re-run and accept the sudo prompt:`)
1896
2767
  );
1897
- console.log(colors_default.cyan(` portless proxy start${extraFlags}`));
2768
+ console.log(colors_default.cyan(` ${fallbackCommand}`));
1898
2769
  if (await isProxyRunning(proxyPort)) {
1899
2770
  console.log(colors_default.yellow(`Proxy is already running on port ${proxyPort}.`));
1900
2771
  return;
@@ -1908,7 +2779,7 @@ ${colors_default.bold("Usage:")}
1908
2779
  colors_default.red(`Error: Port ${proxyPort} requires elevated privileges and sudo failed.`)
1909
2780
  );
1910
2781
  console.error(colors_default.blue("Try again (portless will prompt for sudo):"));
1911
- console.error(colors_default.cyan(` portless proxy start -p ${proxyPort}${extraFlags}`));
2782
+ console.error(colors_default.cyan(` ${currentCommand}`));
1912
2783
  process.exit(1);
1913
2784
  }
1914
2785
  }
@@ -1917,8 +2788,8 @@ ${colors_default.bold("Usage:")}
1917
2788
  store.ensureDir();
1918
2789
  if (customCertPath && customKeyPath) {
1919
2790
  try {
1920
- const cert = fs3.readFileSync(customCertPath);
1921
- const key = fs3.readFileSync(customKeyPath);
2791
+ const cert = fs4.readFileSync(customCertPath);
2792
+ const key = fs4.readFileSync(customKeyPath);
1922
2793
  const certStr = cert.toString("utf-8");
1923
2794
  const keyStr = key.toString("utf-8");
1924
2795
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -1963,9 +2834,9 @@ ${colors_default.bold("Usage:")}
1963
2834
  console.warn(colors_default.cyan(" portless trust"));
1964
2835
  }
1965
2836
  }
1966
- const cert = fs3.readFileSync(certs.certPath);
1967
- const key = fs3.readFileSync(certs.keyPath);
1968
- const ca = fs3.readFileSync(certs.caPath);
2837
+ const cert = fs4.readFileSync(certs.certPath);
2838
+ const key = fs4.readFileSync(certs.keyPath);
2839
+ const ca = fs4.readFileSync(certs.caPath);
1969
2840
  tlsOptions = {
1970
2841
  cert,
1971
2842
  key,
@@ -1975,16 +2846,16 @@ ${colors_default.bold("Usage:")}
1975
2846
  }
1976
2847
  }
1977
2848
  if (isForeground) {
1978
- console.log(colors_default.blue.bold("\nportless proxy\n"));
1979
- startProxyServer(store, proxyPort, tld, tlsOptions, useWildcard ? false : void 0);
2849
+ console.log(chalk.blue.bold("\nportless proxy\n"));
2850
+ startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, desiredWildcard ? false : void 0);
1980
2851
  return;
1981
2852
  }
1982
2853
  store.ensureDir();
1983
- const logPath = path3.join(stateDir, "proxy.log");
1984
- const logFd = fs3.openSync(logPath, "a");
2854
+ const logPath = path4.join(stateDir, "proxy.log");
2855
+ const logFd = fs4.openSync(logPath, "a");
1985
2856
  try {
1986
2857
  try {
1987
- fs3.chmodSync(logPath, FILE_MODE);
2858
+ fs4.chmodSync(logPath, FILE_MODE);
1988
2859
  } catch {
1989
2860
  }
1990
2861
  fixOwnership(logPath);
@@ -1992,26 +2863,21 @@ ${colors_default.bold("Usage:")}
1992
2863
  getEntryScript(),
1993
2864
  "proxy",
1994
2865
  "start",
1995
- "--foreground",
1996
- "--port",
1997
- proxyPort.toString()
2866
+ ...buildProxyStartConfig({
2867
+ useHttps,
2868
+ customCertPath,
2869
+ customKeyPath,
2870
+ lanMode,
2871
+ lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
2872
+ lanIpExplicit: desiredConfig.lanIpExplicit,
2873
+ tld,
2874
+ useWildcard: desiredWildcard,
2875
+ foreground: true,
2876
+ includePort: true,
2877
+ proxyPort
2878
+ }).args
1998
2879
  ];
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, {
2880
+ const child = spawn2(process.execPath, daemonArgs, {
2015
2881
  detached: true,
2016
2882
  stdio: ["ignore", logFd, logFd],
2017
2883
  env: process.env,
@@ -2019,19 +2885,23 @@ ${colors_default.bold("Usage:")}
2019
2885
  });
2020
2886
  child.unref();
2021
2887
  } finally {
2022
- fs3.closeSync(logFd);
2888
+ fs4.closeSync(logFd);
2023
2889
  }
2024
2890
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
2025
2891
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
2026
2892
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
2027
2893
  console.error(colors_default.cyan(" portless proxy start --foreground"));
2028
- if (fs3.existsSync(logPath)) {
2894
+ if (fs4.existsSync(logPath)) {
2029
2895
  console.error(colors_default.gray(`Logs: ${logPath}`));
2030
2896
  }
2031
2897
  process.exit(1);
2032
2898
  }
2033
2899
  const proto = useHttps ? "HTTPS/2" : "HTTP";
2034
- console.log(colors_default.green(`${proto} proxy started on port ${proxyPort}`));
2900
+ console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
2901
+ if (lanMode && lanIp) {
2902
+ console.log(chalk.green(`LAN mode active. IP: ${lanIp}`));
2903
+ console.log(chalk.gray("Services will be discoverable as <name>.local on your network."));
2904
+ }
2035
2905
  }
2036
2906
  async function handleRunMode(args) {
2037
2907
  const parsed = parseRunArgs(args);
@@ -2055,7 +2925,7 @@ async function handleRunMode(args) {
2055
2925
  }
2056
2926
  const worktree = detectWorktreePrefix();
2057
2927
  const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
2058
- const { dir, port, tls: tls2, tld } = await discoverState();
2928
+ const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
2059
2929
  const store = new RouteStore(dir, {
2060
2930
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
2061
2931
  });
@@ -2069,7 +2939,9 @@ async function handleRunMode(args) {
2069
2939
  tld,
2070
2940
  parsed.force,
2071
2941
  { nameSource, prefix: worktree?.prefix, prefixSource: worktree?.source },
2072
- parsed.appPort
2942
+ parsed.appPort,
2943
+ lanMode,
2944
+ lanIp
2073
2945
  );
2074
2946
  }
2075
2947
  async function handleNamedMode(args) {
@@ -2083,7 +2955,7 @@ async function handleNamedMode(args) {
2083
2955
  process.exit(1);
2084
2956
  }
2085
2957
  const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
2086
- const { dir, port, tls: tls2, tld } = await discoverState();
2958
+ const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
2087
2959
  const store = new RouteStore(dir, {
2088
2960
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
2089
2961
  });
@@ -2097,7 +2969,9 @@ async function handleNamedMode(args) {
2097
2969
  tld,
2098
2970
  parsed.force,
2099
2971
  void 0,
2100
- parsed.appPort
2972
+ parsed.appPort,
2973
+ lanMode,
2974
+ lanIp
2101
2975
  );
2102
2976
  }
2103
2977
  async function main() {
@@ -2119,6 +2993,40 @@ async function main() {
2119
2993
  console.error(colors_default.cyan(" npm install -D portless"));
2120
2994
  process.exit(1);
2121
2995
  }
2996
+ const stripGlobalFlag = (flag, hasValue) => {
2997
+ const sep = args.indexOf("--");
2998
+ const end = sep === -1 ? args.length : sep;
2999
+ const idx = args.indexOf(flag);
3000
+ if (idx === -1 || idx >= end) return null;
3001
+ if (!hasValue) {
3002
+ args.splice(idx, 1);
3003
+ return true;
3004
+ }
3005
+ const value = args[idx + 1];
3006
+ if (!value || value.startsWith("-")) return false;
3007
+ args.splice(idx, 2);
3008
+ return value;
3009
+ };
3010
+ if (stripGlobalFlag("--lan", false)) {
3011
+ process.env.PORTLESS_LAN = "1";
3012
+ }
3013
+ const ipResult = stripGlobalFlag("--ip", true);
3014
+ if (ipResult === false) {
3015
+ console.error(chalk.red("Error: --ip requires an IP address."));
3016
+ console.error(chalk.cyan(" portless --lan --ip 192.168.1.42 run <command>"));
3017
+ process.exit(1);
3018
+ } else if (typeof ipResult === "string") {
3019
+ process.env.PORTLESS_LAN_IP = ipResult;
3020
+ process.env.PORTLESS_LAN = "1";
3021
+ }
3022
+ const autoIpResult = stripGlobalFlag(INTERNAL_LAN_IP_FLAG, true);
3023
+ if (autoIpResult === false) {
3024
+ console.error(chalk.red(`Error: ${INTERNAL_LAN_IP_FLAG} requires an IP address.`));
3025
+ process.exit(1);
3026
+ } else if (typeof autoIpResult === "string") {
3027
+ process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
3028
+ process.env.PORTLESS_LAN = "1";
3029
+ }
2122
3030
  if (args[0] === "--name") {
2123
3031
  args.shift();
2124
3032
  if (!args[0]) {
@@ -2144,7 +3052,7 @@ async function main() {
2144
3052
  args.shift();
2145
3053
  }
2146
3054
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
2147
- if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy")) {
3055
+ if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
2148
3056
  const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
2149
3057
  if (commandArgs.length === 0) {
2150
3058
  console.error(colors_default.red("Error: No command provided."));
@@ -2166,6 +3074,10 @@ async function main() {
2166
3074
  await handleTrust();
2167
3075
  return;
2168
3076
  }
3077
+ if (args[0] === "clean") {
3078
+ await handleClean(args);
3079
+ return;
3080
+ }
2169
3081
  if (args[0] === "list") {
2170
3082
  await handleList();
2171
3083
  return;