portless 0.10.0 → 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/README.md CHANGED
@@ -88,7 +88,7 @@ portless myapp next dev
88
88
  # -> https://myapp.test
89
89
  ```
90
90
 
91
- The proxy auto-syncs `/etc/hosts` for custom TLDs, so `.test` domains resolve correctly.
91
+ The proxy auto-syncs `/etc/hosts` for route hostnames (including `.test`), so those domains resolve on your machine.
92
92
 
93
93
  Recommended: `.test` (IANA-reserved, no collision risk). Avoid `.local` (conflicts with mDNS/Bonjour) and `.dev` (Google-owned, forces HTTPS via HSTS).
94
94
 
@@ -166,6 +166,7 @@ portless alias <name> <port> --force # Overwrite an existing route
166
166
  portless alias --remove <name> # Remove a static route
167
167
  portless list # Show active routes
168
168
  portless trust # Add local CA to system trust store
169
+ portless clean # Remove state, CA trust entry, and hosts block
169
170
  portless hosts sync # Add routes to /etc/hosts (fixes Safari)
170
171
  portless hosts clean # Remove portless entries from /etc/hosts
171
172
 
@@ -210,7 +211,7 @@ PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
210
211
  PORTLESS_LAN=1 Enable LAN mode when set to 1 (auto-detects LAN IP)
211
212
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
212
213
  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)
214
+ PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
214
215
  PORTLESS_STATE_DIR=<path> Override the state directory
215
216
 
216
217
  # Injected into child processes
@@ -219,7 +220,17 @@ HOST Usually 127.0.0.1 (omitted for Expo in LAN mode
219
220
  PORTLESS_URL Public URL (e.g. https://myapp.localhost)
220
221
  ```
221
222
 
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.
223
+ > **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.
224
+
225
+ ## Uninstall / reset
226
+
227
+ 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`):
228
+
229
+ ```bash
230
+ portless clean
231
+ ```
232
+
233
+ macOS/Linux may prompt for `sudo`. Custom certificate paths passed with `--cert` and `--key` are not deleted.
223
234
 
224
235
  ## Safari / DNS
225
236
 
@@ -232,7 +243,7 @@ portless hosts sync # Add current routes to /etc/hosts
232
243
  portless hosts clean # Clean up later
233
244
  ```
234
245
 
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`.
246
+ Auto-syncs `/etc/hosts` for route hostnames by default (`.localhost`, custom TLDs, LAN `.local`). Set `PORTLESS_SYNC_HOSTS=0` to disable.
236
247
 
237
248
  ## Proxying Between Portless Apps
238
249
 
@@ -614,6 +614,9 @@ function buildBlock(hostnames) {
614
614
  ${entries}
615
615
  ${MARKER_END}`;
616
616
  }
617
+ function shouldAutoSyncHosts(syncVal) {
618
+ return syncVal !== "0" && syncVal !== "false";
619
+ }
617
620
  function syncHostsFile(hostnames) {
618
621
  try {
619
622
  const content = readHostsFile();
@@ -1475,6 +1478,7 @@ export {
1475
1478
  extractManagedBlock,
1476
1479
  removeBlock,
1477
1480
  buildBlock,
1481
+ shouldAutoSyncHosts,
1478
1482
  syncHostsFile,
1479
1483
  cleanHostsFile,
1480
1484
  getManagedHostnames,
@@ -1484,6 +1488,8 @@ export {
1484
1488
  PRIVILEGED_PORT_THRESHOLD,
1485
1489
  INTERNAL_LAN_IP_ENV,
1486
1490
  INTERNAL_LAN_IP_FLAG,
1491
+ SYSTEM_STATE_DIR,
1492
+ USER_STATE_DIR,
1487
1493
  WAIT_FOR_PROXY_MAX_ATTEMPTS,
1488
1494
  WAIT_FOR_PROXY_INTERVAL_MS,
1489
1495
  getDefaultPort,
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,
@@ -36,6 +38,7 @@ import {
36
38
  readTldFromDir,
37
39
  readTlsMarker,
38
40
  resolveStateDir,
41
+ shouldAutoSyncHosts,
39
42
  spawnCommand,
40
43
  syncHostsFile,
41
44
  validateTld,
@@ -43,7 +46,7 @@ import {
43
46
  writeLanMarker,
44
47
  writeTldFile,
45
48
  writeTlsMarker
46
- } from "./chunk-WXEE5QH6.js";
49
+ } from "./chunk-FM7IA4AF.js";
47
50
 
48
51
  // src/colors.ts
49
52
  function supportsColor() {
@@ -71,8 +74,8 @@ var gray = wrap("90", "39");
71
74
  var colors_default = { bold, red, green, yellow, blue, cyan, white, gray };
72
75
 
73
76
  // src/cli.ts
74
- import * as fs3 from "fs";
75
- import * as path3 from "path";
77
+ import * as fs4 from "fs";
78
+ import * as path4 from "path";
76
79
  import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
77
80
 
78
81
  // src/certs.ts
@@ -608,6 +611,129 @@ function trustCA(stateDir) {
608
611
  return { trusted: false, error: message };
609
612
  }
610
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
+ }
611
737
 
612
738
  // src/auto.ts
613
739
  import { createHash } from "crypto";
@@ -783,6 +909,53 @@ function readBranchFromHead(gitdir) {
783
909
  }
784
910
  }
785
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
+
786
959
  // src/mdns.ts
787
960
  import { spawn, spawnSync } from "child_process";
788
961
 
@@ -815,7 +988,7 @@ function isInternalInterface(iname, macStr, internal) {
815
988
  return false;
816
989
  }
817
990
  function probeDefaultRouteIPv4() {
818
- return new Promise((resolve2, reject) => {
991
+ return new Promise((resolve3, reject) => {
819
992
  const socket = createSocket({ type: "udp4", reuseAddr: true });
820
993
  socket.on("error", (error) => {
821
994
  socket.close();
@@ -827,7 +1000,7 @@ function probeDefaultRouteIPv4() {
827
1000
  socket.close();
828
1001
  socket.unref();
829
1002
  if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
830
- resolve2(addr.address);
1003
+ resolve3(addr.address);
831
1004
  } else {
832
1005
  reject(new Error("No route to host"));
833
1006
  }
@@ -1138,10 +1311,10 @@ function getEntryScript() {
1138
1311
  function isLocallyInstalled() {
1139
1312
  let dir = process.cwd();
1140
1313
  for (; ; ) {
1141
- if (fs3.existsSync(path3.join(dir, "node_modules", "portless", "package.json"))) {
1314
+ if (fs4.existsSync(path4.join(dir, "node_modules", "portless", "package.json"))) {
1142
1315
  return true;
1143
1316
  }
1144
- const parent = path3.dirname(dir);
1317
+ const parent = path4.dirname(dir);
1145
1318
  if (parent === dir) break;
1146
1319
  dir = parent;
1147
1320
  }
@@ -1177,11 +1350,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1177
1350
  console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
1178
1351
  }
1179
1352
  const routesPath = store.getRoutesPath();
1180
- if (!fs3.existsSync(routesPath)) {
1181
- fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
1353
+ if (!fs4.existsSync(routesPath)) {
1354
+ fs4.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
1182
1355
  }
1183
1356
  try {
1184
- fs3.chmodSync(routesPath, FILE_MODE);
1357
+ fs4.chmodSync(routesPath, FILE_MODE);
1185
1358
  } catch {
1186
1359
  }
1187
1360
  fixOwnership(routesPath);
@@ -1189,8 +1362,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1189
1362
  let debounceTimer = null;
1190
1363
  let watcher = null;
1191
1364
  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";
1365
+ const autoSyncHosts = shouldAutoSyncHosts(process.env.PORTLESS_SYNC_HOSTS);
1194
1366
  const onMdnsError = (msg) => console.warn(chalk.yellow(msg));
1195
1367
  const publishCachedRoutes = () => {
1196
1368
  if (!activeLanIp) return;
@@ -1242,7 +1414,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1242
1414
  }
1243
1415
  };
1244
1416
  try {
1245
- watcher = fs3.watch(routesPath, () => {
1417
+ watcher = fs4.watch(routesPath, () => {
1246
1418
  if (debounceTimer) clearTimeout(debounceTimer);
1247
1419
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
1248
1420
  });
@@ -1292,8 +1464,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1292
1464
  redirectServer.listen(80);
1293
1465
  }
1294
1466
  server.listen(proxyPort, () => {
1295
- fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
1296
- 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 });
1297
1469
  writeTlsMarker(store.dir, isTls);
1298
1470
  writeTldFile(store.dir, tld);
1299
1471
  writeLanMarker(store.dir, activeLanIp);
@@ -1309,7 +1481,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1309
1481
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
1310
1482
  if (isTls) {
1311
1483
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
1312
- console.log(chalk.gray(` ${path3.join(store.dir, "ca.pem")}`));
1484
+ console.log(chalk.gray(` ${path4.join(store.dir, "ca.pem")}`));
1313
1485
  }
1314
1486
  if (!lanIpPinned) {
1315
1487
  lanMonitor = startLanIpMonitor({
@@ -1341,11 +1513,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1341
1513
  redirectServer.close();
1342
1514
  }
1343
1515
  try {
1344
- fs3.unlinkSync(store.pidPath);
1516
+ fs4.unlinkSync(store.pidPath);
1345
1517
  } catch {
1346
1518
  }
1347
1519
  try {
1348
- fs3.unlinkSync(store.portFilePath);
1520
+ fs4.unlinkSync(store.portFilePath);
1349
1521
  } catch {
1350
1522
  }
1351
1523
  writeTlsMarker(store.dir, false);
@@ -1374,7 +1546,7 @@ function sudoStopOrHint(port) {
1374
1546
  }
1375
1547
  async function stopProxy(store, proxyPort, _tls) {
1376
1548
  const pidPath = store.pidPath;
1377
- if (!fs3.existsSync(pidPath)) {
1549
+ if (!fs4.existsSync(pidPath)) {
1378
1550
  if (await isProxyRunning(proxyPort)) {
1379
1551
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
1380
1552
  const pid = findPidOnPort(proxyPort);
@@ -1382,7 +1554,7 @@ async function stopProxy(store, proxyPort, _tls) {
1382
1554
  try {
1383
1555
  process.kill(pid, "SIGTERM");
1384
1556
  try {
1385
- fs3.unlinkSync(store.portFilePath);
1557
+ fs4.unlinkSync(store.portFilePath);
1386
1558
  } catch {
1387
1559
  }
1388
1560
  console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
@@ -1417,10 +1589,10 @@ async function stopProxy(store, proxyPort, _tls) {
1417
1589
  return;
1418
1590
  }
1419
1591
  try {
1420
- const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
1592
+ const pid = parseInt(fs4.readFileSync(pidPath, "utf-8"), 10);
1421
1593
  if (isNaN(pid)) {
1422
1594
  console.error(colors_default.red("Corrupted PID file. Removing it."));
1423
- fs3.unlinkSync(pidPath);
1595
+ fs4.unlinkSync(pidPath);
1424
1596
  return;
1425
1597
  }
1426
1598
  try {
@@ -1431,9 +1603,9 @@ async function stopProxy(store, proxyPort, _tls) {
1431
1603
  return;
1432
1604
  }
1433
1605
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
1434
- fs3.unlinkSync(pidPath);
1606
+ fs4.unlinkSync(pidPath);
1435
1607
  try {
1436
- fs3.unlinkSync(store.portFilePath);
1608
+ fs4.unlinkSync(store.portFilePath);
1437
1609
  } catch {
1438
1610
  }
1439
1611
  return;
@@ -1445,13 +1617,13 @@ async function stopProxy(store, proxyPort, _tls) {
1445
1617
  )
1446
1618
  );
1447
1619
  console.log(colors_default.yellow("Removing stale PID file."));
1448
- fs3.unlinkSync(pidPath);
1620
+ fs4.unlinkSync(pidPath);
1449
1621
  return;
1450
1622
  }
1451
1623
  process.kill(pid, "SIGTERM");
1452
- fs3.unlinkSync(pidPath);
1624
+ fs4.unlinkSync(pidPath);
1453
1625
  try {
1454
- fs3.unlinkSync(store.portFilePath);
1626
+ fs4.unlinkSync(store.portFilePath);
1455
1627
  } catch {
1456
1628
  }
1457
1629
  console.log(colors_default.green("Proxy stopped."));
@@ -1581,10 +1753,10 @@ portless
1581
1753
  if (!discovered) {
1582
1754
  console.error(colors_default.red("Failed to start proxy."));
1583
1755
  const fallbackDir = resolveStateDir(getDefaultPort(desiredConfig.useHttps));
1584
- const logPath = path3.join(fallbackDir, "proxy.log");
1756
+ const logPath = path4.join(fallbackDir, "proxy.log");
1585
1757
  console.error(colors_default.blue("Try starting it manually:"));
1586
1758
  console.error(colors_default.cyan(` ${manualStartCommand}`));
1587
- if (fs3.existsSync(logPath)) {
1759
+ if (fs4.existsSync(logPath)) {
1588
1760
  console.error(colors_default.gray(`Logs: ${logPath}`));
1589
1761
  }
1590
1762
  process.exit(1);
@@ -1657,7 +1829,7 @@ portless
1657
1829
  console.log(chalk.green(` LAN -> ${finalUrl}`));
1658
1830
  console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
1659
1831
  }
1660
- const basename3 = path3.basename(commandArgs[0]);
1832
+ const basename3 = path4.basename(commandArgs[0]);
1661
1833
  const isExpo = basename3 === "expo";
1662
1834
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
1663
1835
  const hostBind = isExpoLan ? void 0 : "127.0.0.1";
@@ -1839,6 +2011,7 @@ ${colors_default.bold("Usage:")}
1839
2011
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
1840
2012
  ${colors_default.cyan("portless list")} Show active routes
1841
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
1842
2015
  ${colors_default.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
1843
2016
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1844
2017
 
@@ -1916,7 +2089,7 @@ ${colors_default.bold("Environment variables:")}
1916
2089
  PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
1917
2090
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
1918
2091
  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)
2092
+ PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
1920
2093
  PORTLESS_STATE_DIR=<path> Override the state directory
1921
2094
  PORTLESS=0 Run command directly without proxy
1922
2095
 
@@ -1929,8 +2102,8 @@ ${colors_default.bold("Child process environment:")}
1929
2102
  ${colors_default.bold("Safari / DNS:")}
1930
2103
  .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1931
2104
  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:
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:
1934
2107
  ${colors_default.cyan("portless hosts sync")}
1935
2108
  Clean up later with:
1936
2109
  ${colors_default.cyan("portless hosts clean")}
@@ -1939,20 +2112,20 @@ ${colors_default.bold("Skip portless:")}
1939
2112
  PORTLESS=0 pnpm dev # Runs command directly without proxy
1940
2113
 
1941
2114
  ${colors_default.bold("Reserved names:")}
1942
- run, get, alias, hosts, list, trust, proxy are subcommands and cannot
2115
+ run, get, alias, hosts, list, trust, clean, proxy are subcommands and cannot
1943
2116
  be used as app names directly. Use "portless run" to infer the name,
1944
2117
  or "portless --name <name>" to force any name including reserved ones.
1945
2118
  `);
1946
2119
  process.exit(0);
1947
2120
  }
1948
2121
  function printVersion() {
1949
- console.log("0.10.0");
2122
+ console.log("0.10.1");
1950
2123
  process.exit(0);
1951
2124
  }
1952
2125
  async function handleTrust() {
1953
2126
  const { dir } = await discoverState();
1954
- if (!fs3.existsSync(dir)) {
1955
- fs3.mkdirSync(dir, { recursive: true });
2127
+ if (!fs4.existsSync(dir)) {
2128
+ fs4.mkdirSync(dir, { recursive: true });
1956
2129
  }
1957
2130
  const { caGenerated } = ensureCerts(dir);
1958
2131
  if (caGenerated) {
@@ -1988,6 +2161,90 @@ async function handleTrust() {
1988
2161
  console.error(colors_default.red(`Failed to trust CA: ${result.error}`));
1989
2162
  process.exit(1);
1990
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
+ }
1991
2248
  async function handleList() {
1992
2249
  const { dir, port, tls: tls2 } = await discoverState();
1993
2250
  const store = new RouteStore(dir, {
@@ -2122,8 +2379,8 @@ ${colors_default.bold("Usage:")}
2122
2379
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
2123
2380
 
2124
2381
  ${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.
2382
+ The proxy updates ${HOSTS_DISPLAY} for route hostnames by default. Disable with
2383
+ PORTLESS_SYNC_HOSTS=0.
2127
2384
  `);
2128
2385
  process.exit(0);
2129
2386
  }
@@ -2491,8 +2748,8 @@ ${colors_default.bold("LAN mode (--lan):")}
2491
2748
  console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
2492
2749
  } else {
2493
2750
  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)) {
2751
+ const logPath2 = path4.join(resolveStateDir(proxyPort), "proxy.log");
2752
+ if (fs4.existsSync(logPath2)) {
2496
2753
  console.error(colors_default.gray(`Logs: ${logPath2}`));
2497
2754
  }
2498
2755
  }
@@ -2531,8 +2788,8 @@ ${colors_default.bold("LAN mode (--lan):")}
2531
2788
  store.ensureDir();
2532
2789
  if (customCertPath && customKeyPath) {
2533
2790
  try {
2534
- const cert = fs3.readFileSync(customCertPath);
2535
- const key = fs3.readFileSync(customKeyPath);
2791
+ const cert = fs4.readFileSync(customCertPath);
2792
+ const key = fs4.readFileSync(customKeyPath);
2536
2793
  const certStr = cert.toString("utf-8");
2537
2794
  const keyStr = key.toString("utf-8");
2538
2795
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -2577,9 +2834,9 @@ ${colors_default.bold("LAN mode (--lan):")}
2577
2834
  console.warn(colors_default.cyan(" portless trust"));
2578
2835
  }
2579
2836
  }
2580
- const cert = fs3.readFileSync(certs.certPath);
2581
- const key = fs3.readFileSync(certs.keyPath);
2582
- 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);
2583
2840
  tlsOptions = {
2584
2841
  cert,
2585
2842
  key,
@@ -2594,11 +2851,11 @@ ${colors_default.bold("LAN mode (--lan):")}
2594
2851
  return;
2595
2852
  }
2596
2853
  store.ensureDir();
2597
- const logPath = path3.join(stateDir, "proxy.log");
2598
- const logFd = fs3.openSync(logPath, "a");
2854
+ const logPath = path4.join(stateDir, "proxy.log");
2855
+ const logFd = fs4.openSync(logPath, "a");
2599
2856
  try {
2600
2857
  try {
2601
- fs3.chmodSync(logPath, FILE_MODE);
2858
+ fs4.chmodSync(logPath, FILE_MODE);
2602
2859
  } catch {
2603
2860
  }
2604
2861
  fixOwnership(logPath);
@@ -2628,13 +2885,13 @@ ${colors_default.bold("LAN mode (--lan):")}
2628
2885
  });
2629
2886
  child.unref();
2630
2887
  } finally {
2631
- fs3.closeSync(logFd);
2888
+ fs4.closeSync(logFd);
2632
2889
  }
2633
2890
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
2634
2891
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
2635
2892
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
2636
2893
  console.error(colors_default.cyan(" portless proxy start --foreground"));
2637
- if (fs3.existsSync(logPath)) {
2894
+ if (fs4.existsSync(logPath)) {
2638
2895
  console.error(colors_default.gray(`Logs: ${logPath}`));
2639
2896
  }
2640
2897
  process.exit(1);
@@ -2795,7 +3052,7 @@ async function main() {
2795
3052
  args.shift();
2796
3053
  }
2797
3054
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
2798
- if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy")) {
3055
+ if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
2799
3056
  const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
2800
3057
  if (commandArgs.length === 0) {
2801
3058
  console.error(colors_default.red("Error: No command provided."));
@@ -2817,6 +3074,10 @@ async function main() {
2817
3074
  await handleTrust();
2818
3075
  return;
2819
3076
  }
3077
+ if (args[0] === "clean") {
3078
+ await handleClean(args);
3079
+ return;
3080
+ }
2820
3081
  if (args[0] === "list") {
2821
3082
  await handleList();
2822
3083
  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-FM7IA4AF.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.1",
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",