portless 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,11 @@ portless myapp next dev
32
32
 
33
33
  HTTPS with HTTP/2 is enabled by default. On first run, portless generates a local CA, trusts it, and binds port 443 (auto-elevates with sudo on macOS/Linux). Use `--no-tls` for plain HTTP.
34
34
 
35
- The proxy auto-starts when you run an app. A random port (4000--4999) is assigned via the `PORT` environment variable. Most frameworks (Next.js, Express, Nuxt, etc.) respect this automatically. For frameworks that ignore `PORT` (Vite, VitePlus, Astro, React Router, Angular, Expo, React Native), portless auto-injects the right `--port` flag and, when needed, a matching `--host` flag.
35
+ The proxy auto-starts when you run an app. A random port (4000-4999) is assigned via the `PORT` environment variable. Most frameworks (Next.js, Express, Nuxt, etc.) respect this automatically. For frameworks that ignore `PORT` (Vite, VitePlus, Astro, React Router, Angular, Expo, React Native), portless auto-injects the right `--port` flag and, when needed, a matching `--host` flag.
36
+
37
+ When auto-starting, portless reuses the configuration (port, TLS, TLD) from the most recent proxy run, so a restart or reboot does not silently revert to defaults. Explicit env vars (`PORTLESS_PORT`, `PORTLESS_HTTPS`, etc.) always take priority.
38
+
39
+ In non-interactive environments (no TTY, or `CI=1`), portless exits with a descriptive error instead of prompting, so task runners like turborepo and CI scripts fail early with a clear message.
36
40
 
37
41
  ## Use in package.json
38
42
 
@@ -88,7 +92,7 @@ portless myapp next dev
88
92
  # -> https://myapp.test
89
93
  ```
90
94
 
91
- The proxy auto-syncs `/etc/hosts` for custom TLDs, so `.test` domains resolve correctly.
95
+ The proxy auto-syncs `/etc/hosts` for route hostnames (including `.test`), so those domains resolve on your machine.
92
96
 
93
97
  Recommended: `.test` (IANA-reserved, no collision risk). Avoid `.local` (conflicts with mDNS/Bonjour) and `.dev` (Google-owned, forces HTTPS via HSTS).
94
98
 
@@ -139,7 +143,7 @@ portless proxy start --lan --ip 192.168.1.42
139
143
 
140
144
  `--lan` switches the proxy to mDNS discovery: services are advertised as `<name>.local` and reachable from any device on the same network. Portless auto-detects your LAN IP and follows Wi-Fi/IP changes automatically, but you can pin another address with `--ip <address>` or by exporting `PORTLESS_LAN_IP`. Set `PORTLESS_LAN=1` in your shell (0/1 boolean) to make LAN mode the default whenever the proxy starts.
141
145
 
142
- Portless remembers LAN mode via `proxy.lan`, so if you stop a LAN proxy and start it again, it stays in LAN mode. Other proxy settings still follow the current flags and env vars. Use `PORTLESS_LAN=0` for one start to switch back to `.localhost` mode. If a proxy is already running with different explicit LAN/TLS/TLD settings, portless warns and asks you to stop it first.
146
+ Portless remembers LAN mode via `proxy.lan`, so if you stop a LAN proxy and start it again, it stays in LAN mode. All proxy settings (port, TLS, TLD, LAN) are persisted and reused on auto-start unless overridden by explicit flags or env vars. Use `PORTLESS_LAN=0` for one start to switch back to `.localhost` mode. If a proxy is already running with different explicit LAN/TLS/TLD settings, portless warns and asks you to stop it first.
143
147
 
144
148
  LAN mode depends on the system mDNS tools that portless already spawns: macOS ships with `dns-sd`, while Linux uses `avahi-publish-address` from `avahi-utils` (install via `sudo apt install avahi-utils` or your distro’s equivalent). If the command is missing or your network isn’t reachable, `portless proxy start --lan` prints the relevant error and exits.
145
149
 
@@ -166,6 +170,7 @@ portless alias <name> <port> --force # Overwrite an existing route
166
170
  portless alias --remove <name> # Remove a static route
167
171
  portless list # Show active routes
168
172
  portless trust # Add local CA to system trust store
173
+ portless clean # Remove state, CA trust entry, and hosts block
169
174
  portless hosts sync # Add routes to /etc/hosts (fixes Safari)
170
175
  portless hosts clean # Remove portless entries from /etc/hosts
171
176
 
@@ -210,16 +215,27 @@ PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
210
215
  PORTLESS_LAN=1 Enable LAN mode when set to 1 (auto-detects LAN IP)
211
216
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
212
217
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
213
- PORTLESS_SYNC_HOSTS=1 Auto-sync /etc/hosts (auto-enabled for custom TLDs)
218
+ PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
214
219
  PORTLESS_STATE_DIR=<path> Override the state directory
215
220
 
216
221
  # Injected into child processes
217
222
  PORT Ephemeral port the child should listen on
218
223
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
219
224
  PORTLESS_URL Public URL (e.g. https://myapp.localhost)
225
+ NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
226
+ ```
227
+
228
+ > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, `clean`, and `proxy` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
229
+
230
+ ## Uninstall / reset
231
+
232
+ To remove portless data from your machine (proxy state under `~/.portless` and the system state directory, the local CA from the OS trust store when portless installed it, and the portless block in `/etc/hosts`):
233
+
234
+ ```bash
235
+ portless clean
220
236
  ```
221
237
 
222
- > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, and `proxy` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
238
+ macOS/Linux may prompt for `sudo`. Custom certificate paths passed with `--cert` and `--key` are not deleted.
223
239
 
224
240
  ## Safari / DNS
225
241
 
@@ -232,7 +248,7 @@ portless hosts sync # Add current routes to /etc/hosts
232
248
  portless hosts clean # Clean up later
233
249
  ```
234
250
 
235
- Auto-syncs `/etc/hosts` for custom TLDs (e.g. `--tld test`). For `.localhost`, set `PORTLESS_SYNC_HOSTS=1` to enable. Disable with `PORTLESS_SYNC_HOSTS=0`.
251
+ Auto-syncs `/etc/hosts` for route hostnames by default (`.localhost`, custom TLDs, LAN `.local`). Set `PORTLESS_SYNC_HOSTS=0` to disable.
236
252
 
237
253
  ## Proxying Between Portless Apps
238
254
 
@@ -264,7 +280,7 @@ devServer: {
264
280
  }
265
281
  ```
266
282
 
267
- If your tooling doesn't trust the portless CA, point Node.js at it: `NODE_EXTRA_CA_CERTS=/tmp/portless/ca.pem` (or `~/.portless/ca.pem` when the proxy runs on a non-privileged port like 1355). Alternatively, use `--no-tls` for plain HTTP.
283
+ Portless automatically sets `NODE_EXTRA_CA_CERTS` in child processes so Node.js trusts the portless CA. If you run a separate Node.js process outside portless, point it at the CA manually: `NODE_EXTRA_CA_CERTS=/tmp/portless/ca.pem` (or `~/.portless/ca.pem` when the proxy runs on a non-privileged port like 1355). Alternatively, use `--no-tls` for plain HTTP.
268
284
 
269
285
  Portless detects this misconfiguration and responds with `508 Loop Detected` along with a message pointing to this fix.
270
286
 
@@ -392,8 +392,10 @@ function createProxyServer(options) {
392
392
  proxyRes.on("error", () => {
393
393
  if (!res.headersSent) {
394
394
  res.writeHead(502, { "Content-Type": "text/plain" });
395
+ res.end();
396
+ } else {
397
+ res.destroy();
395
398
  }
396
- res.end();
397
399
  });
398
400
  proxyRes.pipe(res);
399
401
  }
@@ -516,9 +518,19 @@ function createProxyServer(options) {
516
518
  cert: tls.ca ? Buffer.concat([tls.cert, tls.ca]) : tls.cert,
517
519
  key: tls.key,
518
520
  allowHTTP1: true,
521
+ // Tolerate high rates of RST_STREAM from browsers during HMR and
522
+ // page navigations. Without this, Node sends GOAWAY INTERNAL_ERROR
523
+ // after ~1000 cumulative stream resets and kills the session,
524
+ // surfacing as ERR_HTTP2_PROTOCOL_ERROR in Chrome. Available in
525
+ // Node 22.11+; silently ignored on older versions.
526
+ ...{ streamResetBurst: 1e4, streamResetRate: 100 },
519
527
  ...tls.SNICallback ? { SNICallback: tls.SNICallback } : {}
520
528
  });
529
+ h2Server.on("sessionError", () => {
530
+ });
521
531
  h2Server.on("request", (req, res) => {
532
+ req.stream?.on("error", () => {
533
+ });
522
534
  handleRequest(req, res);
523
535
  });
524
536
  h2Server.on("upgrade", (req, socket, head) => {
@@ -614,6 +626,9 @@ function buildBlock(hostnames) {
614
626
  ${entries}
615
627
  ${MARKER_END}`;
616
628
  }
629
+ function shouldAutoSyncHosts(syncVal) {
630
+ return syncVal !== "0" && syncVal !== "false";
631
+ }
617
632
  function syncHostsFile(hostnames) {
618
633
  try {
619
634
  const content = readHostsFile();
@@ -898,6 +913,24 @@ function isLanEnvEnabled() {
898
913
  const val = process.env.PORTLESS_LAN;
899
914
  return val === "1" || val === "true";
900
915
  }
916
+ function readPersistedProxyState() {
917
+ const dirs = [];
918
+ if (process.env.PORTLESS_STATE_DIR) {
919
+ dirs.push(process.env.PORTLESS_STATE_DIR);
920
+ } else {
921
+ dirs.push(USER_STATE_DIR, SYSTEM_STATE_DIR);
922
+ }
923
+ for (const dir of dirs) {
924
+ const port = readPortFromDir(dir);
925
+ if (port !== null) {
926
+ const tls = readTlsMarker(dir);
927
+ const tld = readTldFromDir(dir);
928
+ const lanIp = readLanMarker(dir);
929
+ return { port, tls, tld, lanMode: lanIp !== null || tld === "local" };
930
+ }
931
+ }
932
+ return null;
933
+ }
901
934
  function buildProxyStartConfig(options) {
902
935
  const effectiveTld = options.lanMode ? "local" : options.tld;
903
936
  const args = [];
@@ -931,6 +964,9 @@ function buildProxyStartConfig(options) {
931
964
  if (options.useWildcard) {
932
965
  args.push("--wildcard");
933
966
  }
967
+ if (options.skipTrust) {
968
+ args.push("--skip-trust");
969
+ }
934
970
  return { effectiveTld, args };
935
971
  }
936
972
  async function discoverState() {
@@ -999,8 +1035,8 @@ async function discoverState() {
999
1035
  return {
1000
1036
  dir,
1001
1037
  port: configuredPort,
1002
- tls: false,
1003
- tld: getDefaultTld(),
1038
+ tls: readTlsMarker(dir),
1039
+ tld: readTldFromDir(dir),
1004
1040
  lanMode: readLanMarker(dir) !== null,
1005
1041
  lanIp: null
1006
1042
  };
@@ -1269,8 +1305,9 @@ function prompt(question) {
1269
1305
  import * as fs4 from "fs";
1270
1306
  import * as path3 from "path";
1271
1307
  var STALE_LOCK_THRESHOLD_MS = 1e4;
1272
- var LOCK_MAX_RETRIES = 20;
1273
- var LOCK_RETRY_DELAY_MS = 50;
1308
+ var LOCK_TIMEOUT_MS = 5e3;
1309
+ var LOCK_RETRY_BASE_MS = 10;
1310
+ var LOCK_RETRY_CAP_MS = 500;
1274
1311
  var FILE_MODE = 420;
1275
1312
  var DIR_MODE = 493;
1276
1313
  var SYSTEM_DIR_MODE = 1023;
@@ -1334,8 +1371,10 @@ var RouteStore = class _RouteStore {
1334
1371
  syncSleep(ms) {
1335
1372
  Atomics.wait(_RouteStore.sleepBuffer, 0, 0, ms);
1336
1373
  }
1337
- acquireLock(maxRetries = LOCK_MAX_RETRIES, retryDelayMs = LOCK_RETRY_DELAY_MS) {
1338
- for (let i = 0; i < maxRetries; i++) {
1374
+ acquireLock() {
1375
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
1376
+ let delay = LOCK_RETRY_BASE_MS;
1377
+ while (Date.now() < deadline) {
1339
1378
  try {
1340
1379
  fs4.mkdirSync(this.lockPath);
1341
1380
  return true;
@@ -1350,7 +1389,9 @@ var RouteStore = class _RouteStore {
1350
1389
  } catch {
1351
1390
  continue;
1352
1391
  }
1353
- this.syncSleep(retryDelayMs);
1392
+ const jitter = Math.floor(Math.random() * delay);
1393
+ this.syncSleep(delay + jitter);
1394
+ delay = Math.min(delay * 2, LOCK_RETRY_CAP_MS);
1354
1395
  } else {
1355
1396
  return false;
1356
1397
  }
@@ -1475,6 +1516,7 @@ export {
1475
1516
  extractManagedBlock,
1476
1517
  removeBlock,
1477
1518
  buildBlock,
1519
+ shouldAutoSyncHosts,
1478
1520
  syncHostsFile,
1479
1521
  cleanHostsFile,
1480
1522
  getManagedHostnames,
@@ -1484,6 +1526,8 @@ export {
1484
1526
  PRIVILEGED_PORT_THRESHOLD,
1485
1527
  INTERNAL_LAN_IP_ENV,
1486
1528
  INTERNAL_LAN_IP_FLAG,
1529
+ SYSTEM_STATE_DIR,
1530
+ USER_STATE_DIR,
1487
1531
  WAIT_FOR_PROXY_MAX_ATTEMPTS,
1488
1532
  WAIT_FOR_PROXY_INTERVAL_MS,
1489
1533
  getDefaultPort,
@@ -1501,6 +1545,7 @@ export {
1501
1545
  isHttpsEnvDisabled,
1502
1546
  isWildcardEnvEnabled,
1503
1547
  isLanEnvEnabled,
1548
+ readPersistedProxyState,
1504
1549
  buildProxyStartConfig,
1505
1550
  discoverState,
1506
1551
  findFreePort,
package/dist/cli.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  RISKY_TLDS,
10
10
  RouteConflictError,
11
11
  RouteStore,
12
+ SYSTEM_STATE_DIR,
13
+ USER_STATE_DIR,
12
14
  WAIT_FOR_PROXY_INTERVAL_MS,
13
15
  WAIT_FOR_PROXY_MAX_ATTEMPTS,
14
16
  buildProxyStartConfig,
@@ -33,9 +35,11 @@ import {
33
35
  parseHostname,
34
36
  prompt,
35
37
  readLanMarker,
38
+ readPersistedProxyState,
36
39
  readTldFromDir,
37
40
  readTlsMarker,
38
41
  resolveStateDir,
42
+ shouldAutoSyncHosts,
39
43
  spawnCommand,
40
44
  syncHostsFile,
41
45
  validateTld,
@@ -43,7 +47,7 @@ import {
43
47
  writeLanMarker,
44
48
  writeTldFile,
45
49
  writeTlsMarker
46
- } from "./chunk-WXEE5QH6.js";
50
+ } from "./chunk-EZJWUTUA.js";
47
51
 
48
52
  // src/colors.ts
49
53
  function supportsColor() {
@@ -71,8 +75,8 @@ var gray = wrap("90", "39");
71
75
  var colors_default = { bold, red, green, yellow, blue, cyan, white, gray };
72
76
 
73
77
  // src/cli.ts
74
- import * as fs3 from "fs";
75
- import * as path3 from "path";
78
+ import * as fs4 from "fs";
79
+ import * as path4 from "path";
76
80
  import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
77
81
 
78
82
  // src/certs.ts
@@ -87,6 +91,9 @@ var SERVER_VALIDITY_DAYS = 365;
87
91
  var EXPIRY_BUFFER_MS = 7 * 24 * 60 * 60 * 1e3;
88
92
  var CA_COMMON_NAME = "portless Local CA";
89
93
  var OPENSSL_TIMEOUT_MS = 15e3;
94
+ var MACOS_SECURITY_TIMEOUT_MS = 15e3;
95
+ var MACOS_SECURITY_AUTH_TIMEOUT_MS = 12e4;
96
+ var MACOS_SECURITY_ROOT_TIMEOUT_MS = 6e4;
90
97
  var CA_KEY_FILE = "ca-key.pem";
91
98
  var CA_CERT_FILE = "ca.pem";
92
99
  var SERVER_KEY_FILE = "server-key.pem";
@@ -335,13 +342,13 @@ function isCATrustedMacOS(caCertPath) {
335
342
  ["-u", sudoUser, "security", "verify-cert", "-c", caCertPath, "-L", "-p", "ssl"],
336
343
  {
337
344
  stdio: "pipe",
338
- timeout: 5e3
345
+ timeout: MACOS_SECURITY_TIMEOUT_MS
339
346
  }
340
347
  );
341
348
  } else {
342
349
  execFileSync("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
343
350
  stdio: "pipe",
344
- timeout: 5e3
351
+ timeout: MACOS_SECURITY_TIMEOUT_MS
345
352
  });
346
353
  }
347
354
  return true;
@@ -353,7 +360,7 @@ function loginKeychainPath() {
353
360
  try {
354
361
  const result = execFileSync("security", ["default-keychain"], {
355
362
  encoding: "utf-8",
356
- timeout: 5e3
363
+ timeout: MACOS_SECURITY_TIMEOUT_MS
357
364
  }).trim();
358
365
  const match = result.match(/"(.+)"/);
359
366
  if (match) return match[1];
@@ -569,14 +576,14 @@ function trustCA(stateDir) {
569
576
  "/Library/Keychains/System.keychain",
570
577
  caCertPath
571
578
  ],
572
- { stdio: "pipe", timeout: 3e4 }
579
+ { stdio: "pipe", timeout: MACOS_SECURITY_ROOT_TIMEOUT_MS }
573
580
  );
574
581
  } else {
575
582
  const keychain = loginKeychainPath();
576
583
  execFileSync(
577
584
  "security",
578
585
  ["add-trusted-cert", "-r", "trustRoot", "-k", keychain, caCertPath],
579
- { stdio: "pipe", timeout: 3e4 }
586
+ { stdio: "pipe", timeout: MACOS_SECURITY_AUTH_TIMEOUT_MS }
580
587
  );
581
588
  }
582
589
  return { trusted: true };
@@ -599,6 +606,10 @@ function trustCA(stateDir) {
599
606
  return { trusted: false, error: `Unsupported platform: ${process.platform}` };
600
607
  } catch (err) {
601
608
  const message = err instanceof Error ? err.message : String(err);
609
+ if (message.includes("ETIMEDOUT")) {
610
+ const hint = process.platform === "darwin" ? "The macOS security command timed out. This can happen when the Keychain Services daemon is unresponsive or a system authorization dialog was not dismissed in time. Try restarting Keychain Access (or run: sudo killall securityd) and then: portless trust" : "The trust command timed out. Try: portless trust";
611
+ return { trusted: false, error: hint };
612
+ }
602
613
  if (message.includes("authorization") || message.includes("permission") || message.includes("EACCES")) {
603
614
  return {
604
615
  trusted: false,
@@ -608,6 +619,129 @@ function trustCA(stateDir) {
608
619
  return { trusted: false, error: message };
609
620
  }
610
621
  }
622
+ function untrustCA(stateDir) {
623
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
624
+ if (!fileExists(caCertPath)) {
625
+ return { removed: true };
626
+ }
627
+ if (!isCATrusted(stateDir)) {
628
+ return { removed: true };
629
+ }
630
+ try {
631
+ if (process.platform === "darwin") {
632
+ return untrustCAMacOS(caCertPath);
633
+ }
634
+ if (process.platform === "linux") {
635
+ return untrustCALinux(stateDir);
636
+ }
637
+ if (process.platform === "win32") {
638
+ return untrustCAWindows(caCertPath);
639
+ }
640
+ return { removed: false, error: `Unsupported platform: ${process.platform}` };
641
+ } catch (err) {
642
+ const message = err instanceof Error ? err.message : String(err);
643
+ return { removed: false, error: message };
644
+ }
645
+ }
646
+ function untrustCAMacOS(caCertPath) {
647
+ const errors = [];
648
+ const tryExec = (args) => {
649
+ try {
650
+ execFileSync("security", args, { stdio: "pipe", timeout: MACOS_SECURITY_ROOT_TIMEOUT_MS });
651
+ return true;
652
+ } catch (err) {
653
+ const message = err instanceof Error ? err.message : String(err);
654
+ errors.push(message);
655
+ return false;
656
+ }
657
+ };
658
+ if (tryExec(["remove-trusted-cert", caCertPath])) {
659
+ return isCATrustedMacOSAfterAttempt(caCertPath) ? { removed: false, error: errors.join("; ") || "Trust entry may still be present" } : { removed: true };
660
+ }
661
+ const login = loginKeychainPath();
662
+ tryExec(["delete-certificate", "-c", CA_COMMON_NAME, login]);
663
+ tryExec(["delete-certificate", "-c", CA_COMMON_NAME, "/Library/Keychains/System.keychain"]);
664
+ return isCATrustedMacOSAfterAttempt(caCertPath) ? { removed: false, error: errors.join("; ") || "Could not remove CA from keychain (try sudo)" } : { removed: true };
665
+ }
666
+ function isCATrustedMacOSAfterAttempt(caCertPath) {
667
+ try {
668
+ const isRoot = (process.getuid?.() ?? -1) === 0;
669
+ const sudoUser = process.env.SUDO_USER;
670
+ if (isRoot && sudoUser) {
671
+ execFileSync(
672
+ "sudo",
673
+ ["-u", sudoUser, "security", "verify-cert", "-c", caCertPath, "-L", "-p", "ssl"],
674
+ { stdio: "pipe", timeout: MACOS_SECURITY_TIMEOUT_MS }
675
+ );
676
+ } else {
677
+ execFileSync("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
678
+ stdio: "pipe",
679
+ timeout: MACOS_SECURITY_TIMEOUT_MS
680
+ });
681
+ }
682
+ return true;
683
+ } catch {
684
+ return false;
685
+ }
686
+ }
687
+ function untrustCALinux(stateDir) {
688
+ const errors = [];
689
+ let deletedAny = false;
690
+ for (const config of Object.values(LINUX_CA_TRUST_CONFIGS)) {
691
+ const dest = path.join(config.certDir, "portless-ca.crt");
692
+ try {
693
+ if (fileExists(dest)) {
694
+ const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
695
+ const installed = fs.readFileSync(dest, "utf-8").trim();
696
+ if (ours === installed) {
697
+ fs.unlinkSync(dest);
698
+ deletedAny = true;
699
+ }
700
+ }
701
+ } catch (err) {
702
+ errors.push(err instanceof Error ? err.message : String(err));
703
+ }
704
+ }
705
+ if (deletedAny) {
706
+ try {
707
+ const config = getLinuxCATrustConfig();
708
+ execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
709
+ } catch (err) {
710
+ errors.push(err instanceof Error ? err.message : String(err));
711
+ }
712
+ }
713
+ if (isCATrusted(stateDir)) {
714
+ return {
715
+ removed: false,
716
+ error: errors.join("; ") || "CA still trusted (remove portless-ca.crt and run the distro CA update command, often with sudo)"
717
+ };
718
+ }
719
+ return { removed: true };
720
+ }
721
+ function untrustCAWindows(caCertPath) {
722
+ try {
723
+ const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
724
+ const storeListing = execFileSync("certutil", ["-store", "-user", "Root"], {
725
+ encoding: "utf-8",
726
+ timeout: 1e4,
727
+ stdio: ["pipe", "pipe", "pipe"]
728
+ });
729
+ const normalized = storeListing.replace(/\s/g, "").toLowerCase();
730
+ if (!normalized.includes(fingerprint)) {
731
+ return { removed: true };
732
+ }
733
+ execFileSync("certutil", ["-delstore", "-user", "Root", "portless Local CA"], {
734
+ stdio: "pipe",
735
+ timeout: 3e4
736
+ });
737
+ if (isCATrustedWindows(caCertPath)) {
738
+ return { removed: false, error: "certutil could not remove the portless CA from Root" };
739
+ }
740
+ return { removed: true };
741
+ } catch (err) {
742
+ return { removed: false, error: err instanceof Error ? err.message : String(err) };
743
+ }
744
+ }
611
745
 
612
746
  // src/auto.ts
613
747
  import { createHash } from "crypto";
@@ -783,6 +917,53 @@ function readBranchFromHead(gitdir) {
783
917
  }
784
918
  }
785
919
 
920
+ // src/clean-utils.ts
921
+ import * as fs3 from "fs";
922
+ import * as path3 from "path";
923
+ var PORTLESS_STATE_FILES = [
924
+ "routes.json",
925
+ "routes.lock",
926
+ "proxy.pid",
927
+ "proxy.port",
928
+ "proxy.log",
929
+ "proxy.tls",
930
+ "proxy.tld",
931
+ "proxy.lan",
932
+ "ca-key.pem",
933
+ "ca.pem",
934
+ "server-key.pem",
935
+ "server.pem",
936
+ "server.csr",
937
+ "server-ext.cnf",
938
+ "ca.srl"
939
+ ];
940
+ var HOST_CERTS_DIR2 = "host-certs";
941
+ function collectStateDirsForCleanup() {
942
+ const dirs = /* @__PURE__ */ new Set();
943
+ const add = (d) => {
944
+ const trimmed = d?.trim();
945
+ if (!trimmed) return;
946
+ const resolved = path3.resolve(trimmed);
947
+ if (fs3.existsSync(resolved)) dirs.add(resolved);
948
+ };
949
+ add(USER_STATE_DIR);
950
+ add(SYSTEM_STATE_DIR);
951
+ add(process.env.PORTLESS_STATE_DIR);
952
+ return [...dirs];
953
+ }
954
+ function removePortlessStateFiles(dir) {
955
+ for (const f of PORTLESS_STATE_FILES) {
956
+ try {
957
+ fs3.unlinkSync(path3.join(dir, f));
958
+ } catch {
959
+ }
960
+ }
961
+ try {
962
+ fs3.rmSync(path3.join(dir, HOST_CERTS_DIR2), { recursive: true, force: true });
963
+ } catch {
964
+ }
965
+ }
966
+
786
967
  // src/mdns.ts
787
968
  import { spawn, spawnSync } from "child_process";
788
969
 
@@ -815,7 +996,7 @@ function isInternalInterface(iname, macStr, internal) {
815
996
  return false;
816
997
  }
817
998
  function probeDefaultRouteIPv4() {
818
- return new Promise((resolve2, reject) => {
999
+ return new Promise((resolve3, reject) => {
819
1000
  const socket = createSocket({ type: "udp4", reuseAddr: true });
820
1001
  socket.on("error", (error) => {
821
1002
  socket.close();
@@ -827,7 +1008,7 @@ function probeDefaultRouteIPv4() {
827
1008
  socket.close();
828
1009
  socket.unref();
829
1010
  if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
830
- resolve2(addr.address);
1011
+ resolve3(addr.address);
831
1012
  } else {
832
1013
  reject(new Error("No route to host"));
833
1014
  }
@@ -1138,10 +1319,10 @@ function getEntryScript() {
1138
1319
  function isLocallyInstalled() {
1139
1320
  let dir = process.cwd();
1140
1321
  for (; ; ) {
1141
- if (fs3.existsSync(path3.join(dir, "node_modules", "portless", "package.json"))) {
1322
+ if (fs4.existsSync(path4.join(dir, "node_modules", "portless", "package.json"))) {
1142
1323
  return true;
1143
1324
  }
1144
- const parent = path3.dirname(dir);
1325
+ const parent = path4.dirname(dir);
1145
1326
  if (parent === dir) break;
1146
1327
  dir = parent;
1147
1328
  }
@@ -1177,11 +1358,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1177
1358
  console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
1178
1359
  }
1179
1360
  const routesPath = store.getRoutesPath();
1180
- if (!fs3.existsSync(routesPath)) {
1181
- fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
1361
+ if (!fs4.existsSync(routesPath)) {
1362
+ fs4.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
1182
1363
  }
1183
1364
  try {
1184
- fs3.chmodSync(routesPath, FILE_MODE);
1365
+ fs4.chmodSync(routesPath, FILE_MODE);
1185
1366
  } catch {
1186
1367
  }
1187
1368
  fixOwnership(routesPath);
@@ -1189,8 +1370,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1189
1370
  let debounceTimer = null;
1190
1371
  let watcher = null;
1191
1372
  let pollingInterval = null;
1192
- const syncVal = process.env.PORTLESS_SYNC_HOSTS;
1193
- const autoSyncHosts = syncVal === "1" || syncVal === "true" || tld !== DEFAULT_TLD && !activeLanIp && syncVal !== "0" && syncVal !== "false";
1373
+ const autoSyncHosts = shouldAutoSyncHosts(process.env.PORTLESS_SYNC_HOSTS);
1194
1374
  const onMdnsError = (msg) => console.warn(chalk.yellow(msg));
1195
1375
  const publishCachedRoutes = () => {
1196
1376
  if (!activeLanIp) return;
@@ -1242,7 +1422,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1242
1422
  }
1243
1423
  };
1244
1424
  try {
1245
- watcher = fs3.watch(routesPath, () => {
1425
+ watcher = fs4.watch(routesPath, () => {
1246
1426
  if (debounceTimer) clearTimeout(debounceTimer);
1247
1427
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
1248
1428
  });
@@ -1292,8 +1472,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1292
1472
  redirectServer.listen(80);
1293
1473
  }
1294
1474
  server.listen(proxyPort, () => {
1295
- fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
1296
- fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
1475
+ fs4.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
1476
+ fs4.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
1297
1477
  writeTlsMarker(store.dir, isTls);
1298
1478
  writeTldFile(store.dir, tld);
1299
1479
  writeLanMarker(store.dir, activeLanIp);
@@ -1309,7 +1489,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1309
1489
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
1310
1490
  if (isTls) {
1311
1491
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
1312
- console.log(chalk.gray(` ${path3.join(store.dir, "ca.pem")}`));
1492
+ console.log(chalk.gray(` ${path4.join(store.dir, "ca.pem")}`));
1313
1493
  }
1314
1494
  if (!lanIpPinned) {
1315
1495
  lanMonitor = startLanIpMonitor({
@@ -1341,11 +1521,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1341
1521
  redirectServer.close();
1342
1522
  }
1343
1523
  try {
1344
- fs3.unlinkSync(store.pidPath);
1524
+ fs4.unlinkSync(store.pidPath);
1345
1525
  } catch {
1346
1526
  }
1347
1527
  try {
1348
- fs3.unlinkSync(store.portFilePath);
1528
+ fs4.unlinkSync(store.portFilePath);
1349
1529
  } catch {
1350
1530
  }
1351
1531
  writeTlsMarker(store.dir, false);
@@ -1374,7 +1554,7 @@ function sudoStopOrHint(port) {
1374
1554
  }
1375
1555
  async function stopProxy(store, proxyPort, _tls) {
1376
1556
  const pidPath = store.pidPath;
1377
- if (!fs3.existsSync(pidPath)) {
1557
+ if (!fs4.existsSync(pidPath)) {
1378
1558
  if (await isProxyRunning(proxyPort)) {
1379
1559
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
1380
1560
  const pid = findPidOnPort(proxyPort);
@@ -1382,7 +1562,7 @@ async function stopProxy(store, proxyPort, _tls) {
1382
1562
  try {
1383
1563
  process.kill(pid, "SIGTERM");
1384
1564
  try {
1385
- fs3.unlinkSync(store.portFilePath);
1565
+ fs4.unlinkSync(store.portFilePath);
1386
1566
  } catch {
1387
1567
  }
1388
1568
  console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
@@ -1417,10 +1597,10 @@ async function stopProxy(store, proxyPort, _tls) {
1417
1597
  return;
1418
1598
  }
1419
1599
  try {
1420
- const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
1600
+ const pid = parseInt(fs4.readFileSync(pidPath, "utf-8"), 10);
1421
1601
  if (isNaN(pid)) {
1422
1602
  console.error(colors_default.red("Corrupted PID file. Removing it."));
1423
- fs3.unlinkSync(pidPath);
1603
+ fs4.unlinkSync(pidPath);
1424
1604
  return;
1425
1605
  }
1426
1606
  try {
@@ -1431,9 +1611,9 @@ async function stopProxy(store, proxyPort, _tls) {
1431
1611
  return;
1432
1612
  }
1433
1613
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
1434
- fs3.unlinkSync(pidPath);
1614
+ fs4.unlinkSync(pidPath);
1435
1615
  try {
1436
- fs3.unlinkSync(store.portFilePath);
1616
+ fs4.unlinkSync(store.portFilePath);
1437
1617
  } catch {
1438
1618
  }
1439
1619
  return;
@@ -1445,13 +1625,13 @@ async function stopProxy(store, proxyPort, _tls) {
1445
1625
  )
1446
1626
  );
1447
1627
  console.log(colors_default.yellow("Removing stale PID file."));
1448
- fs3.unlinkSync(pidPath);
1628
+ fs4.unlinkSync(pidPath);
1449
1629
  return;
1450
1630
  }
1451
1631
  process.kill(pid, "SIGTERM");
1452
- fs3.unlinkSync(pidPath);
1632
+ fs4.unlinkSync(pidPath);
1453
1633
  try {
1454
- fs3.unlinkSync(store.portFilePath);
1634
+ fs4.unlinkSync(store.portFilePath);
1455
1635
  } catch {
1456
1636
  }
1457
1637
  console.log(colors_default.green("Proxy stopped."));
@@ -1523,11 +1703,30 @@ portless
1523
1703
  const proxyResponsive = await isProxyRunning(proxyPort, tls2);
1524
1704
  const proxyListeningFromStateDir = !!process.env.PORTLESS_STATE_DIR && await isPortListening(proxyPort);
1525
1705
  if (!proxyResponsive && !proxyListeningFromStateDir) {
1526
- const defaultPort = getDefaultPort(desiredConfig.useHttps);
1527
- const needsSudo = !isWindows && defaultPort < PRIVILEGED_PORT_THRESHOLD;
1528
- const manualStartCommand = formatProxyStartCommand(defaultPort, desiredConfig);
1529
- const fallbackStartCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, desiredConfig);
1530
- if (needsSudo && !process.stdin.isTTY) {
1706
+ const persisted = readPersistedProxyState();
1707
+ const startConfig = { ...desiredConfig };
1708
+ let startPort;
1709
+ if (persisted) {
1710
+ if (!explicit.useHttps && persisted.tls !== desiredConfig.useHttps) {
1711
+ startConfig.useHttps = persisted.tls;
1712
+ }
1713
+ if (!explicit.tld && persisted.tld !== desiredConfig.tld) {
1714
+ startConfig.tld = persisted.tld;
1715
+ }
1716
+ if (!explicit.lanMode && persisted.lanMode !== desiredConfig.lanMode) {
1717
+ startConfig.lanMode = persisted.lanMode;
1718
+ }
1719
+ const envPort = getDefaultPort(startConfig.useHttps);
1720
+ if (persisted.port !== envPort) {
1721
+ startPort = persisted.port;
1722
+ }
1723
+ }
1724
+ const effectivePort = startPort ?? getDefaultPort(startConfig.useHttps);
1725
+ const needsSudo = !isWindows && effectivePort < PRIVILEGED_PORT_THRESHOLD;
1726
+ const manualStartCommand = formatProxyStartCommand(effectivePort, startConfig);
1727
+ const fallbackStartCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, startConfig);
1728
+ const isInteractive = !!process.stdin.isTTY && !process.env.CI;
1729
+ if (needsSudo && !isInteractive) {
1531
1730
  console.error(colors_default.red("Proxy is not running and no TTY is available for sudo."));
1532
1731
  console.error(colors_default.blue("Option 1: start the proxy in a terminal (will prompt for sudo):"));
1533
1732
  console.error(colors_default.cyan(` ${manualStartCommand}`));
@@ -1539,7 +1738,7 @@ portless
1539
1738
  console.error(colors_default.cyan(` ${fallbackStartCommand}`));
1540
1739
  process.exit(1);
1541
1740
  }
1542
- if (needsSudo && process.stdin.isTTY) {
1741
+ if (needsSudo) {
1543
1742
  const answer = await prompt(colors_default.yellow("Proxy not running. Start it? [Y/n/skip] "));
1544
1743
  if (answer === "n" || answer === "no") {
1545
1744
  console.log(colors_default.gray("Cancelled."));
@@ -1551,16 +1750,26 @@ portless
1551
1750
  return;
1552
1751
  }
1553
1752
  }
1554
- console.log(colors_default.yellow("Starting proxy..."));
1753
+ if (persisted && startPort !== void 0) {
1754
+ console.log(
1755
+ colors_default.yellow(
1756
+ `Starting proxy with previous configuration (port ${startPort}, ${startConfig.useHttps ? "HTTPS" : "HTTP"})...`
1757
+ )
1758
+ );
1759
+ } else {
1760
+ console.log(colors_default.yellow("Starting proxy..."));
1761
+ }
1555
1762
  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
1763
+ useHttps: startConfig.useHttps,
1764
+ customCertPath: startConfig.customCertPath,
1765
+ customKeyPath: startConfig.customKeyPath,
1766
+ lanMode: startConfig.lanMode,
1767
+ lanIp: startConfig.lanIpExplicit ? startConfig.lanIp : null,
1768
+ lanIpExplicit: startConfig.lanIpExplicit,
1769
+ tld: startConfig.tld,
1770
+ useWildcard: startConfig.useWildcard,
1771
+ includePort: startPort !== void 0,
1772
+ proxyPort: startPort
1564
1773
  });
1565
1774
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
1566
1775
  const result = spawnSync2(process.execPath, startArgs, {
@@ -1580,11 +1789,11 @@ portless
1580
1789
  }
1581
1790
  if (!discovered) {
1582
1791
  console.error(colors_default.red("Failed to start proxy."));
1583
- const fallbackDir = resolveStateDir(getDefaultPort(desiredConfig.useHttps));
1584
- const logPath = path3.join(fallbackDir, "proxy.log");
1792
+ const fallbackDir = resolveStateDir(effectivePort);
1793
+ const logPath = path4.join(fallbackDir, "proxy.log");
1585
1794
  console.error(colors_default.blue("Try starting it manually:"));
1586
1795
  console.error(colors_default.cyan(` ${manualStartCommand}`));
1587
- if (fs3.existsSync(logPath)) {
1796
+ if (fs4.existsSync(logPath)) {
1588
1797
  console.error(colors_default.gray(`Logs: ${logPath}`));
1589
1798
  }
1590
1799
  process.exit(1);
@@ -1657,7 +1866,7 @@ portless
1657
1866
  console.log(chalk.green(` LAN -> ${finalUrl}`));
1658
1867
  console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
1659
1868
  }
1660
- const basename3 = path3.basename(commandArgs[0]);
1869
+ const basename3 = path4.basename(commandArgs[0]);
1661
1870
  const isExpo = basename3 === "expo";
1662
1871
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
1663
1872
  const hostBind = isExpoLan ? void 0 : "127.0.0.1";
@@ -1665,9 +1874,17 @@ portless
1665
1874
  process.env.PORTLESS_LAN = "1";
1666
1875
  }
1667
1876
  injectFrameworkFlags(commandArgs, port);
1877
+ const caEnv = {};
1878
+ if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
1879
+ const caPath = path4.join(stateDir, "ca.pem");
1880
+ if (fs4.existsSync(caPath)) {
1881
+ caEnv.NODE_EXTRA_CA_CERTS = caPath;
1882
+ }
1883
+ }
1884
+ const caFragment = caEnv.NODE_EXTRA_CA_CERTS ? ` NODE_EXTRA_CA_CERTS="${caEnv.NODE_EXTRA_CA_CERTS}"` : "";
1668
1885
  console.log(
1669
1886
  chalk.gray(
1670
- `Running: PORT=${port}${hostBind ? ` HOST=${hostBind}` : ""} PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
1887
+ `Running: PORT=${port}${hostBind ? ` HOST=${hostBind}` : ""} PORTLESS_URL=${finalUrl}${caFragment} ${commandArgs.join(" ")}
1671
1888
  `
1672
1889
  )
1673
1890
  );
@@ -1681,7 +1898,8 @@ portless
1681
1898
  // Note: EXPO_PACKAGER_PROXY_URL is not used — expo-dev-client removed
1682
1899
  // baked-in pinging, making this env var ineffective. Expo handles its
1683
1900
  // own LAN discovery natively.
1684
- ...lanMode ? { PORTLESS_LAN: "1" } : {}
1901
+ ...lanMode ? { PORTLESS_LAN: "1" } : {},
1902
+ ...caEnv
1685
1903
  },
1686
1904
  onCleanup: () => {
1687
1905
  try {
@@ -1839,6 +2057,7 @@ ${colors_default.bold("Usage:")}
1839
2057
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
1840
2058
  ${colors_default.cyan("portless list")} Show active routes
1841
2059
  ${colors_default.cyan("portless trust")} Add local CA to system trust store
2060
+ ${colors_default.cyan("portless clean")} Remove portless state, trust entry, and hosts block
1842
2061
  ${colors_default.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
1843
2062
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1844
2063
 
@@ -1882,7 +2101,8 @@ ${colors_default.bold("LAN mode:")}
1882
2101
  Expo keeps Metro's default LAN host behavior in this mode.
1883
2102
  Auto-detected LAN IPs follow network changes automatically.
1884
2103
  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.
2104
+ All proxy settings are persisted and reused on auto-start unless
2105
+ overridden by explicit flags or env vars.
1886
2106
  Use PORTLESS_LAN=0 for one start to switch back to .localhost mode.
1887
2107
  If a proxy is already running with different explicit LAN/TLS/TLD settings,
1888
2108
  stop it first.
@@ -1916,7 +2136,7 @@ ${colors_default.bold("Environment variables:")}
1916
2136
  PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
1917
2137
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
1918
2138
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
1919
- PORTLESS_SYNC_HOSTS=1 Auto-sync ${HOSTS_DISPLAY} (auto-enabled for custom TLDs)
2139
+ PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
1920
2140
  PORTLESS_STATE_DIR=<path> Override the state directory
1921
2141
  PORTLESS=0 Run command directly without proxy
1922
2142
 
@@ -1925,12 +2145,13 @@ ${colors_default.bold("Child process environment:")}
1925
2145
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
1926
2146
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
1927
2147
  PORTLESS_LAN Set to 1 when proxy is in LAN mode
2148
+ NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
1928
2149
 
1929
2150
  ${colors_default.bold("Safari / DNS:")}
1930
2151
  .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1931
2152
  Safari relies on the system DNS resolver, which may not handle them.
1932
- Auto-syncs ${HOSTS_DISPLAY} for custom TLDs (e.g. --tld test). For .localhost,
1933
- set PORTLESS_SYNC_HOSTS=1 to enable. To manually sync:
2153
+ Auto-syncs ${HOSTS_DISPLAY} for route hostnames by default (including .localhost,
2154
+ custom TLDs, and LAN .local). Set PORTLESS_SYNC_HOSTS=0 to disable. To manually sync:
1934
2155
  ${colors_default.cyan("portless hosts sync")}
1935
2156
  Clean up later with:
1936
2157
  ${colors_default.cyan("portless hosts clean")}
@@ -1939,20 +2160,20 @@ ${colors_default.bold("Skip portless:")}
1939
2160
  PORTLESS=0 pnpm dev # Runs command directly without proxy
1940
2161
 
1941
2162
  ${colors_default.bold("Reserved names:")}
1942
- run, get, alias, hosts, list, trust, proxy are subcommands and cannot
2163
+ run, get, alias, hosts, list, trust, clean, proxy are subcommands and cannot
1943
2164
  be used as app names directly. Use "portless run" to infer the name,
1944
2165
  or "portless --name <name>" to force any name including reserved ones.
1945
2166
  `);
1946
2167
  process.exit(0);
1947
2168
  }
1948
2169
  function printVersion() {
1949
- console.log("0.10.0");
2170
+ console.log("0.10.2");
1950
2171
  process.exit(0);
1951
2172
  }
1952
2173
  async function handleTrust() {
1953
2174
  const { dir } = await discoverState();
1954
- if (!fs3.existsSync(dir)) {
1955
- fs3.mkdirSync(dir, { recursive: true });
2175
+ if (!fs4.existsSync(dir)) {
2176
+ fs4.mkdirSync(dir, { recursive: true });
1956
2177
  }
1957
2178
  const { caGenerated } = ensureCerts(dir);
1958
2179
  if (caGenerated) {
@@ -1988,6 +2209,90 @@ async function handleTrust() {
1988
2209
  console.error(colors_default.red(`Failed to trust CA: ${result.error}`));
1989
2210
  process.exit(1);
1990
2211
  }
2212
+ async function handleClean(args) {
2213
+ if (args[1] === "--help" || args[1] === "-h") {
2214
+ console.log(`
2215
+ ${colors_default.bold("portless clean")} - Remove portless artifacts from this machine.
2216
+
2217
+ Stops the proxy if it is running, removes the local CA from the OS trust store
2218
+ when it was installed by portless, deletes known files under state directories
2219
+ (~/.portless, the system state directory, and PORTLESS_STATE_DIR when set),
2220
+ and removes the portless block from ${HOSTS_DISPLAY}.
2221
+
2222
+ Only allowlisted filenames under each state directory are deleted. Custom
2223
+ certificate paths from --cert and --key are never removed.
2224
+
2225
+ macOS/Linux may prompt for sudo when the proxy, trust store, or ${HOSTS_DISPLAY}
2226
+ require elevated privileges. On Windows, run as Administrator if needed.
2227
+
2228
+ ${colors_default.bold("Usage:")}
2229
+ ${colors_default.cyan("portless clean")}
2230
+
2231
+ ${colors_default.bold("Options:")}
2232
+ --help, -h Show this help
2233
+ `);
2234
+ process.exit(0);
2235
+ }
2236
+ if (args.length > 1) {
2237
+ console.error(colors_default.red(`Error: Unknown argument "${args[1]}".`));
2238
+ console.error(colors_default.cyan(" portless clean --help"));
2239
+ process.exit(1);
2240
+ }
2241
+ console.log(colors_default.cyan("Stopping proxy if it is running..."));
2242
+ const { dir, port, tls: tls2 } = await discoverState();
2243
+ const store = new RouteStore(dir, {
2244
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
2245
+ });
2246
+ await stopProxy(store, port, tls2);
2247
+ const stateDirs = collectStateDirsForCleanup();
2248
+ for (const stateDir of stateDirs) {
2249
+ const caPath = path4.join(stateDir, "ca.pem");
2250
+ if (!fs4.existsSync(caPath)) continue;
2251
+ const wasTrusted = isCATrusted(stateDir);
2252
+ if (!wasTrusted) continue;
2253
+ const untrustResult = untrustCA(stateDir);
2254
+ if (untrustResult.removed) {
2255
+ console.log(colors_default.green("Removed local CA from the system trust store."));
2256
+ } else if (untrustResult.error) {
2257
+ console.warn(
2258
+ colors_default.yellow(
2259
+ `Could not remove CA from trust store: ${untrustResult.error}
2260
+ Try: sudo portless clean (Linux), or delete the certificate manually.`
2261
+ )
2262
+ );
2263
+ }
2264
+ }
2265
+ for (const stateDir of stateDirs) {
2266
+ removePortlessStateFiles(stateDir);
2267
+ }
2268
+ console.log(colors_default.green("Removed portless state files from known state directories."));
2269
+ if (cleanHostsFile()) {
2270
+ console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
2271
+ } else if (!isWindows && process.getuid?.() !== 0) {
2272
+ console.log(
2273
+ colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
2274
+ );
2275
+ const result = spawnSync2(
2276
+ "sudo",
2277
+ ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
2278
+ {
2279
+ stdio: "inherit",
2280
+ timeout: SUDO_SPAWN_TIMEOUT_MS
2281
+ }
2282
+ );
2283
+ if (result.status !== 0) {
2284
+ console.error(colors_default.red(`Failed to update ${HOSTS_DISPLAY}. Run: sudo portless clean`));
2285
+ process.exit(1);
2286
+ }
2287
+ } else {
2288
+ console.warn(
2289
+ colors_default.yellow(
2290
+ `Could not remove portless entries from ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : "."}`
2291
+ )
2292
+ );
2293
+ }
2294
+ console.log(colors_default.green("Clean finished."));
2295
+ }
1991
2296
  async function handleList() {
1992
2297
  const { dir, port, tls: tls2 } = await discoverState();
1993
2298
  const store = new RouteStore(dir, {
@@ -2122,8 +2427,8 @@ ${colors_default.bold("Usage:")}
2122
2427
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
2123
2428
 
2124
2429
  ${colors_default.bold("Auto-sync:")}
2125
- Auto-enabled for custom TLDs (e.g. --tld test). For .localhost, set
2126
- PORTLESS_SYNC_HOSTS=1 to enable. Disable with PORTLESS_SYNC_HOSTS=0.
2430
+ The proxy updates ${HOSTS_DISPLAY} for route hostnames by default. Disable with
2431
+ PORTLESS_SYNC_HOSTS=0.
2127
2432
  `);
2128
2433
  process.exit(0);
2129
2434
  }
@@ -2260,6 +2565,7 @@ ${colors_default.bold("LAN mode (--lan):")}
2260
2565
  process.exit(isProxyHelp || !args[1] ? 0 : 1);
2261
2566
  }
2262
2567
  const isForeground = args.includes("--foreground");
2568
+ const skipTrust = args.includes("--skip-trust");
2263
2569
  const hasHttpsFlag = args.includes("--https");
2264
2570
  const hasNoTls = args.includes("--no-tls") || isHttpsEnvDisabled();
2265
2571
  const wantHttps = !hasNoTls;
@@ -2491,8 +2797,8 @@ ${colors_default.bold("LAN mode (--lan):")}
2491
2797
  console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
2492
2798
  } else {
2493
2799
  console.error(colors_default.red("Proxy process started but is not responding."));
2494
- const logPath2 = path3.join(resolveStateDir(proxyPort), "proxy.log");
2495
- if (fs3.existsSync(logPath2)) {
2800
+ const logPath2 = path4.join(resolveStateDir(proxyPort), "proxy.log");
2801
+ if (fs4.existsSync(logPath2)) {
2496
2802
  console.error(colors_default.gray(`Logs: ${logPath2}`));
2497
2803
  }
2498
2804
  }
@@ -2531,8 +2837,8 @@ ${colors_default.bold("LAN mode (--lan):")}
2531
2837
  store.ensureDir();
2532
2838
  if (customCertPath && customKeyPath) {
2533
2839
  try {
2534
- const cert = fs3.readFileSync(customCertPath);
2535
- const key = fs3.readFileSync(customKeyPath);
2840
+ const cert = fs4.readFileSync(customCertPath);
2841
+ const key = fs4.readFileSync(customKeyPath);
2536
2842
  const certStr = cert.toString("utf-8");
2537
2843
  const keyStr = key.toString("utf-8");
2538
2844
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -2559,7 +2865,7 @@ ${colors_default.bold("LAN mode (--lan):")}
2559
2865
  if (certs.caGenerated) {
2560
2866
  console.log(colors_default.green("Generated local CA certificate."));
2561
2867
  }
2562
- if (!isCATrusted(stateDir)) {
2868
+ if (!skipTrust && !isCATrusted(stateDir)) {
2563
2869
  console.log(colors_default.yellow("Adding CA to system trust store..."));
2564
2870
  const trustResult = trustCA(stateDir);
2565
2871
  if (trustResult.trusted) {
@@ -2577,9 +2883,9 @@ ${colors_default.bold("LAN mode (--lan):")}
2577
2883
  console.warn(colors_default.cyan(" portless trust"));
2578
2884
  }
2579
2885
  }
2580
- const cert = fs3.readFileSync(certs.certPath);
2581
- const key = fs3.readFileSync(certs.keyPath);
2582
- const ca = fs3.readFileSync(certs.caPath);
2886
+ const cert = fs4.readFileSync(certs.certPath);
2887
+ const key = fs4.readFileSync(certs.keyPath);
2888
+ const ca = fs4.readFileSync(certs.caPath);
2583
2889
  tlsOptions = {
2584
2890
  cert,
2585
2891
  key,
@@ -2594,11 +2900,11 @@ ${colors_default.bold("LAN mode (--lan):")}
2594
2900
  return;
2595
2901
  }
2596
2902
  store.ensureDir();
2597
- const logPath = path3.join(stateDir, "proxy.log");
2598
- const logFd = fs3.openSync(logPath, "a");
2903
+ const logPath = path4.join(stateDir, "proxy.log");
2904
+ const logFd = fs4.openSync(logPath, "a");
2599
2905
  try {
2600
2906
  try {
2601
- fs3.chmodSync(logPath, FILE_MODE);
2907
+ fs4.chmodSync(logPath, FILE_MODE);
2602
2908
  } catch {
2603
2909
  }
2604
2910
  fixOwnership(logPath);
@@ -2617,7 +2923,8 @@ ${colors_default.bold("LAN mode (--lan):")}
2617
2923
  useWildcard: desiredWildcard,
2618
2924
  foreground: true,
2619
2925
  includePort: true,
2620
- proxyPort
2926
+ proxyPort,
2927
+ skipTrust: true
2621
2928
  }).args
2622
2929
  ];
2623
2930
  const child = spawn2(process.execPath, daemonArgs, {
@@ -2628,13 +2935,13 @@ ${colors_default.bold("LAN mode (--lan):")}
2628
2935
  });
2629
2936
  child.unref();
2630
2937
  } finally {
2631
- fs3.closeSync(logFd);
2938
+ fs4.closeSync(logFd);
2632
2939
  }
2633
2940
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
2634
2941
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
2635
2942
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
2636
2943
  console.error(colors_default.cyan(" portless proxy start --foreground"));
2637
- if (fs3.existsSync(logPath)) {
2944
+ if (fs4.existsSync(logPath)) {
2638
2945
  console.error(colors_default.gray(`Logs: ${logPath}`));
2639
2946
  }
2640
2947
  process.exit(1);
@@ -2795,7 +3102,7 @@ async function main() {
2795
3102
  args.shift();
2796
3103
  }
2797
3104
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
2798
- if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy")) {
3105
+ if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
2799
3106
  const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
2800
3107
  if (commandArgs.length === 0) {
2801
3108
  console.error(colors_default.red("Error: No command provided."));
@@ -2817,6 +3124,10 @@ async function main() {
2817
3124
  await handleTrust();
2818
3125
  return;
2819
3126
  }
3127
+ if (args[0] === "clean") {
3128
+ await handleClean(args);
3129
+ return;
3130
+ }
2820
3131
  if (args[0] === "list") {
2821
3132
  await handleList();
2822
3133
  return;
package/dist/index.d.ts CHANGED
@@ -157,6 +157,11 @@ declare function removeBlock(content: string): string;
157
157
  * Build a portless-managed block for the given hostnames.
158
158
  */
159
159
  declare function buildBlock(hostnames: string[]): string;
160
+ /**
161
+ * Whether the proxy should write route hostnames to the hosts file.
162
+ * Disabled only when `PORTLESS_SYNC_HOSTS` is `0` or `false` (opt-out).
163
+ */
164
+ declare function shouldAutoSyncHosts(syncVal: string | undefined): boolean;
160
165
  /**
161
166
  * Sync /etc/hosts to include entries for all given hostnames.
162
167
  * Replaces any existing portless-managed block. Requires root access.
@@ -178,4 +183,4 @@ declare function getManagedHostnames(): string[];
178
183
  */
179
184
  declare function checkHostResolution(hostname: string): Promise<boolean>;
180
185
 
181
- export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, SYSTEM_DIR_MODE, SYSTEM_FILE_MODE, buildBlock, checkHostResolution, cleanHostsFile, createHttpRedirectServer, createProxyServer, escapeHtml, extractManagedBlock, fixOwnership, formatUrl, getManagedHostnames, isErrnoException, parseHostname, removeBlock, syncHostsFile };
186
+ export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, SYSTEM_DIR_MODE, SYSTEM_FILE_MODE, buildBlock, checkHostResolution, cleanHostsFile, createHttpRedirectServer, createProxyServer, escapeHtml, extractManagedBlock, fixOwnership, formatUrl, getManagedHostnames, isErrnoException, parseHostname, removeBlock, shouldAutoSyncHosts, syncHostsFile };
package/dist/index.js CHANGED
@@ -19,8 +19,9 @@ import {
19
19
  isErrnoException,
20
20
  parseHostname,
21
21
  removeBlock,
22
+ shouldAutoSyncHosts,
22
23
  syncHostsFile
23
- } from "./chunk-WXEE5QH6.js";
24
+ } from "./chunk-EZJWUTUA.js";
24
25
  export {
25
26
  DIR_MODE,
26
27
  FILE_MODE,
@@ -42,5 +43,6 @@ export {
42
43
  isErrnoException,
43
44
  parseHostname,
44
45
  removeBlock,
46
+ shouldAutoSyncHosts,
45
47
  syncHostsFile
46
48
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",