portless 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -348,6 +348,7 @@ portless alias <name> <port> # Register a static route (e.g. for Docker)
348
348
  portless alias <name> <port> --force # Overwrite an existing route
349
349
  portless alias --remove <name> # Remove a static route
350
350
  portless list # Show active routes
351
+ portless doctor # Check proxy, routes, DNS, and CA trust
351
352
  portless trust # Add local CA to system trust store
352
353
  portless clean # Remove state, CA trust entry, and hosts block
353
354
  portless prune # Kill orphaned dev servers from crashed sessions
@@ -423,7 +424,7 @@ PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
423
424
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
424
425
  ```
425
426
 
426
- > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, `clean`, `prune`, `proxy`, and `service` 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.
427
+ > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `doctor`, `trust`, `clean`, `prune`, `proxy`, and `service` 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.
427
428
 
428
429
  ## Uninstall / reset
429
430
 
@@ -448,6 +449,10 @@ portless hosts clean # Clean up later
448
449
 
449
450
  Auto-syncs `/etc/hosts` for route hostnames by default (`.localhost`, custom TLDs, LAN `.local`). Set `PORTLESS_SYNC_HOSTS=0` to disable.
450
451
 
452
+ ## Troubleshooting
453
+
454
+ Run `portless doctor` to inspect local health without changing state. It checks Node.js, the state directory, proxy liveness, route entries, HTTPS CA trust, hostname resolution, and LAN mode prerequisites, then prints suggested fixes.
455
+
451
456
  ## Proxying Between Portless Apps
452
457
 
453
458
  If your frontend dev server (e.g. Vite, webpack) proxies API requests to another portless app, make sure the proxy rewrites the `Host` header. Without this, portless routes the request back to the frontend in an infinite loop.
@@ -17,6 +17,15 @@ function fixOwnership(...paths) {
17
17
  function isErrnoException(err) {
18
18
  return err instanceof Error && "code" in err && typeof err.code === "string";
19
19
  }
20
+ function isProcessAlive(pid) {
21
+ if (pid <= 0) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ return isErrnoException(err) && err.code === "EPERM";
27
+ }
28
+ }
20
29
  function escapeHtml(str) {
21
30
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22
31
  }
@@ -375,6 +384,9 @@ function createProxyServer(options) {
375
384
  delete proxyReqHeaders[key];
376
385
  }
377
386
  }
387
+ if (!proxyReqHeaders.host) {
388
+ proxyReqHeaders.host = getRequestHost(req);
389
+ }
378
390
  const proxyReq = http.request(
379
391
  {
380
392
  hostname: "127.0.0.1",
@@ -460,6 +472,9 @@ function createProxyServer(options) {
460
472
  delete proxyReqHeaders[key];
461
473
  }
462
474
  }
475
+ if (!proxyReqHeaders.host) {
476
+ proxyReqHeaders.host = getRequestHost(req);
477
+ }
463
478
  const proxyReq = http.request({
464
479
  hostname: "127.0.0.1",
465
480
  port: route.port,
@@ -869,13 +884,17 @@ var RouteStore = class _RouteStore {
869
884
  try {
870
885
  parsed = JSON.parse(raw);
871
886
  } catch {
887
+ this.onWarning?.(`Corrupted routes file (invalid JSON): ${this.routesPath}`);
872
888
  return [];
873
889
  }
874
890
  if (!Array.isArray(parsed)) {
891
+ this.onWarning?.(`Corrupted routes file (expected array): ${this.routesPath}`);
875
892
  return [];
876
893
  }
877
894
  return parsed.filter(isValidRoute);
878
- } catch {
895
+ } catch (err) {
896
+ const message = err instanceof Error ? err.message : String(err);
897
+ this.onWarning?.(`Could not read routes file: ${message}`);
879
898
  return [];
880
899
  }
881
900
  }
@@ -947,13 +966,21 @@ var RouteStore = class _RouteStore {
947
966
  this.releaseLock();
948
967
  }
949
968
  }
950
- removeRoute(hostname) {
969
+ /**
970
+ * Remove a route by hostname. When `ownerPid` is provided, the entry is
971
+ * only removed while it is still owned by that pid. Exit cleanups must
972
+ * pass their own pid: after a `--force` takeover the killed process would
973
+ * otherwise deregister the route the new owner just registered.
974
+ */
975
+ removeRoute(hostname, ownerPid) {
951
976
  this.ensureDir();
952
977
  if (!this.acquireLock()) {
953
978
  throw new Error("Failed to acquire route lock");
954
979
  }
955
980
  try {
956
- const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
981
+ const routes = this.loadRoutes(true).filter(
982
+ (r) => r.hostname !== hostname || ownerPid !== void 0 && r.pid !== ownerPid
983
+ );
957
984
  this.saveRoutes(routes);
958
985
  } finally {
959
986
  this.releaseLock();
@@ -964,6 +991,7 @@ var RouteStore = class _RouteStore {
964
991
  export {
965
992
  fixOwnership,
966
993
  isErrnoException,
994
+ isProcessAlive,
967
995
  escapeHtml,
968
996
  formatUrl,
969
997
  parseHostname,
package/dist/cli.js CHANGED
@@ -4,16 +4,19 @@ import {
4
4
  PORTLESS_HEADER,
5
5
  RouteConflictError,
6
6
  RouteStore,
7
+ checkHostResolution,
7
8
  cleanHostsFile,
8
9
  createHttpRedirectServer,
9
10
  createProxyServer,
10
11
  fixOwnership,
11
12
  formatUrl,
13
+ getManagedHostnames,
12
14
  isErrnoException,
15
+ isProcessAlive,
13
16
  parseHostname,
14
17
  shouldAutoSyncHosts,
15
18
  syncHostsFile
16
- } from "./chunk-OZM4AEYL.js";
19
+ } from "./chunk-PCBKLZK2.js";
17
20
 
18
21
  // src/colors.ts
19
22
  function supportsColor() {
@@ -1503,6 +1506,7 @@ function readPortFromDir(dir) {
1503
1506
  }
1504
1507
  }
1505
1508
  var TLS_MARKER_FILE = "proxy.tls";
1509
+ var CUSTOM_CERT_MARKER_FILE = "proxy.custom-cert";
1506
1510
  function readTlsMarker(dir) {
1507
1511
  try {
1508
1512
  return fs3.existsSync(path3.join(dir, TLS_MARKER_FILE));
@@ -1521,6 +1525,24 @@ function writeTlsMarker(dir, enabled2) {
1521
1525
  }
1522
1526
  }
1523
1527
  }
1528
+ function readCustomCertMarker(dir) {
1529
+ try {
1530
+ return fs3.existsSync(path3.join(dir, CUSTOM_CERT_MARKER_FILE));
1531
+ } catch {
1532
+ return false;
1533
+ }
1534
+ }
1535
+ function writeCustomCertMarker(dir, enabled2) {
1536
+ const markerPath = path3.join(dir, CUSTOM_CERT_MARKER_FILE);
1537
+ if (enabled2) {
1538
+ fs3.writeFileSync(markerPath, "1", { mode: 420 });
1539
+ } else {
1540
+ try {
1541
+ fs3.unlinkSync(markerPath);
1542
+ } catch {
1543
+ }
1544
+ }
1545
+ }
1524
1546
  var LAN_MARKER_FILE = "proxy.lan";
1525
1547
  function readLanMarker(dir) {
1526
1548
  try {
@@ -2001,6 +2023,7 @@ var PORTLESS_STATE_FILES = [
2001
2023
  "proxy.port",
2002
2024
  "proxy.log",
2003
2025
  "proxy.tls",
2026
+ "proxy.custom-cert",
2004
2027
  "proxy.tld",
2005
2028
  "proxy.lan",
2006
2029
  "ca-key.pem",
@@ -3880,7 +3903,7 @@ function formatProcessExitSuffix(code, signal) {
3880
3903
  if (code !== null) return ` (exit ${code})`;
3881
3904
  return "";
3882
3905
  }
3883
- function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
3906
+ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict, customCert = false) {
3884
3907
  store.ensureDir();
3885
3908
  const isTls = !!tlsOptions;
3886
3909
  const mdnsSupport = isMdnsSupported();
@@ -4009,6 +4032,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
4009
4032
  fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
4010
4033
  fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
4011
4034
  writeTlsMarker(store.dir, isTls);
4035
+ writeCustomCertMarker(store.dir, isTls && customCert);
4012
4036
  writeTldFile(store.dir, tld);
4013
4037
  writeLanMarker(store.dir, activeLanIp);
4014
4038
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
@@ -4021,7 +4045,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
4021
4045
  if (activeLanIp) {
4022
4046
  console.log(chalk.green(`LAN mode: ${activeLanIp}`));
4023
4047
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
4024
- if (isTls) {
4048
+ if (isTls && !customCert) {
4025
4049
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
4026
4050
  console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
4027
4051
  }
@@ -4063,6 +4087,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
4063
4087
  } catch {
4064
4088
  }
4065
4089
  writeTlsMarker(store.dir, false);
4090
+ writeCustomCertMarker(store.dir, false);
4066
4091
  writeTldFile(store.dir, DEFAULT_TLD);
4067
4092
  writeLanMarker(store.dir, null);
4068
4093
  if (autoSyncHosts) cleanHostsFile();
@@ -4101,6 +4126,7 @@ async function stopProxy(store, proxyPort, _tls) {
4101
4126
  } catch {
4102
4127
  }
4103
4128
  writeTlsMarker(store.dir, false);
4129
+ writeCustomCertMarker(store.dir, false);
4104
4130
  writeTldFile(store.dir, DEFAULT_TLD);
4105
4131
  writeLanMarker(store.dir, null);
4106
4132
  console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
@@ -4140,6 +4166,7 @@ async function stopProxy(store, proxyPort, _tls) {
4140
4166
  console.error(colors_default.red("Corrupted PID file. Removing it."));
4141
4167
  fs9.unlinkSync(pidPath);
4142
4168
  writeTlsMarker(store.dir, false);
4169
+ writeCustomCertMarker(store.dir, false);
4143
4170
  writeTldFile(store.dir, DEFAULT_TLD);
4144
4171
  writeLanMarker(store.dir, null);
4145
4172
  return;
@@ -4158,6 +4185,7 @@ async function stopProxy(store, proxyPort, _tls) {
4158
4185
  } catch {
4159
4186
  }
4160
4187
  writeTlsMarker(store.dir, false);
4188
+ writeCustomCertMarker(store.dir, false);
4161
4189
  writeTldFile(store.dir, DEFAULT_TLD);
4162
4190
  writeLanMarker(store.dir, null);
4163
4191
  return;
@@ -4171,6 +4199,7 @@ async function stopProxy(store, proxyPort, _tls) {
4171
4199
  console.log(colors_default.yellow("Removing stale PID file."));
4172
4200
  fs9.unlinkSync(pidPath);
4173
4201
  writeTlsMarker(store.dir, false);
4202
+ writeCustomCertMarker(store.dir, false);
4174
4203
  writeTldFile(store.dir, DEFAULT_TLD);
4175
4204
  writeLanMarker(store.dir, null);
4176
4205
  return;
@@ -4182,6 +4211,7 @@ async function stopProxy(store, proxyPort, _tls) {
4182
4211
  } catch {
4183
4212
  }
4184
4213
  writeTlsMarker(store.dir, false);
4214
+ writeCustomCertMarker(store.dir, false);
4185
4215
  writeTldFile(store.dir, DEFAULT_TLD);
4186
4216
  writeLanMarker(store.dir, null);
4187
4217
  console.log(colors_default.green("Proxy stopped."));
@@ -4564,7 +4594,7 @@ portless
4564
4594
  } catch {
4565
4595
  }
4566
4596
  try {
4567
- store.removeRoute(hostname);
4597
+ store.removeRoute(hostname, process.pid);
4568
4598
  } catch {
4569
4599
  }
4570
4600
  process.exit(1);
@@ -4618,7 +4648,7 @@ portless
4618
4648
  } catch {
4619
4649
  }
4620
4650
  try {
4621
- store.removeRoute(hostname);
4651
+ store.removeRoute(hostname, process.pid);
4622
4652
  } catch {
4623
4653
  }
4624
4654
  }
@@ -4803,13 +4833,14 @@ ${colors_default.bold("Usage:")}
4803
4833
  ${colors_default.cyan("portless run")} Same as above
4804
4834
  ${colors_default.cyan("portless run <cmd>")} Run a command through the proxy
4805
4835
  ${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
4806
- ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
4836
+ ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon); rarely needed since it auto-starts on first run
4807
4837
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
4808
4838
  ${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
4809
4839
  ${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
4810
4840
  ${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
4811
4841
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
4812
4842
  ${colors_default.cyan("portless list")} Show active routes
4843
+ ${colors_default.cyan("portless doctor")} Check local portless health
4813
4844
  ${colors_default.cyan("portless trust")} Add local CA to system trust store
4814
4845
  ${colors_default.cyan("portless clean")} Remove portless state, trust entry, and hosts block
4815
4846
  ${colors_default.cyan("portless prune")} Kill orphaned dev servers from crashed sessions
@@ -4827,6 +4858,7 @@ ${colors_default.bold("Examples:")}
4827
4858
  portless service install --lan # Persist LAN mode in the startup service
4828
4859
  portless service install --wildcard # Persist wildcard routing in the startup service
4829
4860
  portless get backend # -> https://backend.localhost
4861
+ portless doctor # Check proxy, routes, DNS, and CA trust
4830
4862
  portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
4831
4863
  portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
4832
4864
  portless myapp --ngrok next dev # -> also https://<random>.ngrok.app (public)
@@ -4957,14 +4989,14 @@ ${colors_default.bold("Skip portless:")}
4957
4989
  PORTLESS=0 pnpm dev # Runs command directly without proxy
4958
4990
 
4959
4991
  ${colors_default.bold("Reserved names:")}
4960
- run, get, alias, hosts, list, trust, clean, prune, proxy, service are subcommands and
4992
+ run, get, alias, hosts, list, doctor, trust, clean, prune, proxy, service are subcommands and
4961
4993
  cannot be used as app names directly. Use "portless run" to infer the name,
4962
4994
  or "portless --name <name>" to force any name including reserved ones.
4963
4995
  `);
4964
4996
  process.exit(0);
4965
4997
  }
4966
4998
  function printVersion() {
4967
- console.log("0.14.0");
4999
+ console.log("0.15.0");
4968
5000
  process.exit(0);
4969
5001
  }
4970
5002
  async function handleTrust() {
@@ -5401,6 +5433,355 @@ ${colors_default.bold("Usage: portless hosts <command>")}
5401
5433
  );
5402
5434
  process.exit(1);
5403
5435
  }
5436
+ function colorDoctorStatus(status) {
5437
+ if (status === "fail") return colors_default.red;
5438
+ if (status === "warn") return colors_default.yellow;
5439
+ if (status === "ok") return colors_default.green;
5440
+ return colors_default.gray;
5441
+ }
5442
+ function printDoctorFinding(finding) {
5443
+ const status = finding.status.padEnd(5);
5444
+ console.log(`${colorDoctorStatus(finding.status)(status)} ${finding.message}`);
5445
+ if (finding.hint) {
5446
+ console.log(colors_default.gray(` ${finding.hint}`));
5447
+ }
5448
+ }
5449
+ function isProcessAliveForDoctor(pid) {
5450
+ if (pid <= 0) return true;
5451
+ return isProcessAlive(pid);
5452
+ }
5453
+ function checkPathWritable(targetPath) {
5454
+ try {
5455
+ fs9.accessSync(targetPath, fs9.constants.W_OK);
5456
+ return true;
5457
+ } catch {
5458
+ return false;
5459
+ }
5460
+ }
5461
+ function findExistingAncestor(targetPath) {
5462
+ let current = targetPath;
5463
+ for (; ; ) {
5464
+ if (fs9.existsSync(current)) return current;
5465
+ const parent = path9.dirname(current);
5466
+ if (parent === current) return null;
5467
+ current = parent;
5468
+ }
5469
+ }
5470
+ function checkCommandAvailable(command, args) {
5471
+ const result = spawnSync5(command, args, {
5472
+ stdio: "ignore",
5473
+ timeout: 3e3,
5474
+ windowsHide: true
5475
+ });
5476
+ return !result.error && (result.status === 0 || result.status === null);
5477
+ }
5478
+ function pluralize(count, singular, plural = `${singular}s`) {
5479
+ return `${count} ${count === 1 ? singular : plural}`;
5480
+ }
5481
+ function isValidTcpPort(port) {
5482
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
5483
+ }
5484
+ function doctorProxyStartHint(proxyPort, tls2) {
5485
+ const defaultPort = getDefaultPort(tls2);
5486
+ const portArgs = proxyPort === defaultPort ? "" : ` -p ${proxyPort}`;
5487
+ const tlsArgs = tls2 ? "" : " --no-tls";
5488
+ return `Run: portless proxy start${portArgs}${tlsArgs}`;
5489
+ }
5490
+ async function handleDoctor(args) {
5491
+ if (args[1] === "--help" || args[1] === "-h") {
5492
+ console.log(`
5493
+ ${colors_default.bold("portless doctor")} - Check local portless health and print suggested fixes.
5494
+
5495
+ ${colors_default.bold("Usage:")}
5496
+ ${colors_default.cyan("portless doctor")}
5497
+
5498
+ Checks Node.js, the state directory, proxy liveness, route entries, HTTPS CA
5499
+ trust, hostname resolution, and LAN mode prerequisites. It does not start,
5500
+ stop, clean, prune, trust, or modify portless state.
5501
+
5502
+ ${colors_default.bold("Options:")}
5503
+ --help, -h Show this help
5504
+ `);
5505
+ process.exit(0);
5506
+ }
5507
+ if (args.length > 1) {
5508
+ console.error(colors_default.red(`Error: Unknown argument "${args[1]}".`));
5509
+ console.error(colors_default.cyan(" portless doctor --help"));
5510
+ process.exit(1);
5511
+ }
5512
+ const findings = [];
5513
+ const add = (status, message, hint) => {
5514
+ findings.push({ status, message, hint });
5515
+ };
5516
+ let state;
5517
+ try {
5518
+ state = await discoverState();
5519
+ } catch (err) {
5520
+ const message = err instanceof Error ? err.message : String(err);
5521
+ add("fail", `Could not discover portless state: ${message}`);
5522
+ state = {
5523
+ dir: resolveStateDir(),
5524
+ port: getDefaultPort(!isHttpsEnvDisabled()),
5525
+ tls: !isHttpsEnvDisabled(),
5526
+ tld: DEFAULT_TLD,
5527
+ lanMode: isLanEnvEnabled(),
5528
+ lanIp: null
5529
+ };
5530
+ }
5531
+ const store = new RouteStore(state.dir, {
5532
+ onWarning: (msg) => add("warn", msg)
5533
+ });
5534
+ const hasPortFile = fs9.existsSync(store.portFilePath);
5535
+ const configuredTls = hasPortFile ? state.tls : !isHttpsEnvDisabled();
5536
+ const configuredPort = hasPortFile ? state.port : getDefaultPort(configuredTls);
5537
+ const initialProxyRunning = await isProxyRunning(state.port, state.tls);
5538
+ const probePort = initialProxyRunning || hasPortFile ? state.port : configuredPort;
5539
+ const probeTls = initialProxyRunning || hasPortFile ? state.tls : configuredTls;
5540
+ const proxyRunning = initialProxyRunning && probePort === state.port ? true : await isProxyRunning(probePort, probeTls);
5541
+ const portListening = proxyRunning ? true : await isPortListening(probePort);
5542
+ const proxyPort = proxyRunning || portListening || hasPortFile ? probePort : configuredPort;
5543
+ const proxyTls = proxyRunning || portListening || hasPortFile ? probeTls : configuredTls;
5544
+ const currentProxyStateIsHttp = (proxyRunning || portListening || hasPortFile) && !proxyTls;
5545
+ const proxyUsesCustomCert = proxyTls && readCustomCertMarker(state.dir);
5546
+ const stateExists = fs9.existsSync(state.dir);
5547
+ console.log(colors_default.blue.bold("\nportless doctor\n"));
5548
+ console.log(`Version: ${"0.15.0"}`);
5549
+ console.log(`Node.js: ${process.versions.node}`);
5550
+ console.log(`Platform: ${process.platform} ${process.arch}`);
5551
+ console.log(`State dir: ${state.dir}`);
5552
+ console.log(`Proxy target: ${formatUrl("127.0.0.1", proxyPort, proxyTls)}`);
5553
+ console.log(`Mode: ${proxyTls ? "HTTPS" : "HTTP"}, .${state.tld}${state.lanMode ? ", LAN" : ""}`);
5554
+ console.log("");
5555
+ const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
5556
+ if (nodeMajor >= 24) {
5557
+ add("ok", `Node.js ${process.versions.node} satisfies portless requirements.`);
5558
+ } else {
5559
+ add("fail", `Node.js ${process.versions.node} is unsupported.`, "Install Node.js 24 or newer.");
5560
+ }
5561
+ if (stateExists) {
5562
+ try {
5563
+ const stat = fs9.statSync(state.dir);
5564
+ if (!stat.isDirectory()) {
5565
+ add("fail", `State path exists but is not a directory: ${state.dir}`);
5566
+ } else if (checkPathWritable(state.dir)) {
5567
+ add("ok", `State directory is writable: ${state.dir}`);
5568
+ } else {
5569
+ add("fail", `State directory is not writable: ${state.dir}`);
5570
+ }
5571
+ } catch (err) {
5572
+ const message = err instanceof Error ? err.message : String(err);
5573
+ add("fail", `Could not inspect state directory: ${message}`);
5574
+ }
5575
+ } else {
5576
+ const ancestor = findExistingAncestor(path9.dirname(state.dir));
5577
+ if (!ancestor) {
5578
+ add(
5579
+ "fail",
5580
+ `State directory does not exist and no writable ancestor was found: ${state.dir}`
5581
+ );
5582
+ } else {
5583
+ const ancestorStat = fs9.statSync(ancestor);
5584
+ if (!ancestorStat.isDirectory()) {
5585
+ add("fail", `State directory does not exist and ancestor is not a directory: ${ancestor}`);
5586
+ } else if (checkPathWritable(ancestor)) {
5587
+ add("info", `State directory has not been created yet: ${state.dir}`);
5588
+ } else {
5589
+ add("fail", `State directory does not exist and ancestor is not writable: ${ancestor}`);
5590
+ }
5591
+ }
5592
+ }
5593
+ if (proxyRunning) {
5594
+ add("ok", `Proxy is responding on port ${proxyPort}.`);
5595
+ } else if (portListening) {
5596
+ const pid = findPidOnPort(proxyPort);
5597
+ add(
5598
+ "fail",
5599
+ `Port ${proxyPort} is in use, but it is not a portless proxy.`,
5600
+ pid ? `Process on port: PID ${pid}` : "Could not identify the process holding the port."
5601
+ );
5602
+ } else {
5603
+ add(
5604
+ "warn",
5605
+ `Proxy is not running on port ${proxyPort}.`,
5606
+ doctorProxyStartHint(proxyPort, proxyTls)
5607
+ );
5608
+ }
5609
+ if (fs9.existsSync(store.pidPath)) {
5610
+ try {
5611
+ const rawPid = fs9.readFileSync(store.pidPath, "utf-8").trim();
5612
+ const pid = parseInt(rawPid, 10);
5613
+ if (isNaN(pid) || pid <= 0) {
5614
+ add("fail", `Proxy PID file is invalid: ${store.pidPath}`);
5615
+ } else if (!isProcessAliveForDoctor(pid)) {
5616
+ add("warn", `Proxy PID file is stale: ${pid}`, "Run: portless proxy stop");
5617
+ } else if (!proxyRunning) {
5618
+ add(
5619
+ "warn",
5620
+ `Proxy PID file points to PID ${pid}, but no portless proxy is responding on port ${proxyPort}.`,
5621
+ "Run: portless proxy stop"
5622
+ );
5623
+ } else {
5624
+ const portPid = findPidOnPort(proxyPort);
5625
+ if (portPid !== null && portPid !== pid) {
5626
+ add(
5627
+ "warn",
5628
+ `Proxy PID file points to PID ${pid}, but port ${proxyPort} is owned by PID ${portPid}.`,
5629
+ "Run: portless proxy stop"
5630
+ );
5631
+ } else {
5632
+ add("ok", `Proxy PID file points to the responding proxy process: ${pid}`);
5633
+ }
5634
+ }
5635
+ } catch (err) {
5636
+ const message = err instanceof Error ? err.message : String(err);
5637
+ add("fail", `Could not read proxy PID file: ${message}`);
5638
+ }
5639
+ } else if (proxyRunning) {
5640
+ add("warn", `Proxy is running but the PID file is missing: ${store.pidPath}`);
5641
+ }
5642
+ if (proxyUsesCustomCert) {
5643
+ add("ok", "Proxy is configured with a custom TLS certificate.");
5644
+ } else if (proxyTls || !currentProxyStateIsHttp && !isHttpsEnvDisabled()) {
5645
+ if (checkCommandAvailable("openssl", ["version"])) {
5646
+ add("ok", "OpenSSL is available for certificate generation.");
5647
+ } else {
5648
+ add(
5649
+ "fail",
5650
+ "OpenSSL is not available on PATH.",
5651
+ isWindows ? "Install OpenSSL or add Git for Windows OpenSSL to PATH." : "Install OpenSSL with your system package manager."
5652
+ );
5653
+ }
5654
+ } else {
5655
+ add("info", "HTTPS is disabled, so OpenSSL is not required for this run.");
5656
+ }
5657
+ if (proxyTls && proxyUsesCustomCert) {
5658
+ add("info", "Generated local CA is not required for custom TLS certificates.");
5659
+ } else if (proxyTls) {
5660
+ const caPath = path9.join(state.dir, "ca.pem");
5661
+ if (fs9.existsSync(caPath)) {
5662
+ if (isCATrusted(state.dir)) {
5663
+ add("ok", "Local CA is trusted by the OS trust store.");
5664
+ } else {
5665
+ add(
5666
+ "warn",
5667
+ "Local CA exists but is not trusted by the OS trust store.",
5668
+ "Run: portless trust"
5669
+ );
5670
+ }
5671
+ } else if (proxyRunning) {
5672
+ add("warn", `Generated CA file is missing: ${caPath}`);
5673
+ } else {
5674
+ add("info", "Local CA has not been generated yet.");
5675
+ }
5676
+ } else {
5677
+ add("info", "HTTPS is disabled for the current proxy state.");
5678
+ }
5679
+ let rawRoutes = [];
5680
+ try {
5681
+ rawRoutes = store.loadRoutesRaw();
5682
+ } catch (err) {
5683
+ const message = err instanceof Error ? err.message : String(err);
5684
+ add("fail", `Could not read routes: ${message}`);
5685
+ }
5686
+ const liveRoutes = rawRoutes.filter(
5687
+ (route) => route.pid === 0 || isProcessAliveForDoctor(route.pid)
5688
+ );
5689
+ const staleRoutes = rawRoutes.filter(
5690
+ (route) => route.pid !== 0 && !isProcessAliveForDoctor(route.pid)
5691
+ );
5692
+ if (rawRoutes.length === 0) {
5693
+ add("info", "No routes are registered.");
5694
+ } else if (staleRoutes.length === 0) {
5695
+ add("ok", `Routes: ${pluralize(liveRoutes.length, "active route")}.`);
5696
+ } else {
5697
+ add(
5698
+ "warn",
5699
+ `Routes: ${pluralize(liveRoutes.length, "active route")}, ${pluralize(staleRoutes.length, "stale route")}.`,
5700
+ "Run: portless prune"
5701
+ );
5702
+ }
5703
+ for (const route of staleRoutes.slice(0, 5)) {
5704
+ add("warn", `Stale route ${route.hostname} is owned by exited PID ${route.pid}.`);
5705
+ }
5706
+ if (staleRoutes.length > 5) {
5707
+ add("warn", `${staleRoutes.length - 5} additional stale routes hidden.`);
5708
+ }
5709
+ const routePortChecks = await Promise.all(
5710
+ liveRoutes.map(async (route) => {
5711
+ const validPort = isValidTcpPort(route.port);
5712
+ return {
5713
+ route,
5714
+ invalidPort: !validPort,
5715
+ listening: validPort ? await isPortListening(route.port) : false
5716
+ };
5717
+ })
5718
+ );
5719
+ for (const { route, invalidPort, listening } of routePortChecks) {
5720
+ if (invalidPort) {
5721
+ add(
5722
+ "warn",
5723
+ `Route ${route.hostname} has invalid port ${route.port}.`,
5724
+ route.pid === 0 ? "Remove or recreate the alias." : "Run: portless prune"
5725
+ );
5726
+ continue;
5727
+ }
5728
+ if (listening) continue;
5729
+ add(
5730
+ "warn",
5731
+ `Route ${route.hostname} points to port ${route.port}, but nothing is listening there.`,
5732
+ route.pid === 0 ? "Remove the alias or start that service." : "The app may still be starting."
5733
+ );
5734
+ }
5735
+ if (state.lanMode || isLanEnvEnabled()) {
5736
+ const mdns = isMdnsSupported();
5737
+ if (mdns.supported) {
5738
+ add("ok", "mDNS publishing support is available for LAN mode.");
5739
+ } else {
5740
+ add("fail", `LAN mode is enabled but mDNS publishing is unavailable: ${mdns.reason}`);
5741
+ }
5742
+ if (state.lanIp) {
5743
+ add("ok", `LAN IP is recorded: ${state.lanIp}`);
5744
+ } else {
5745
+ add("warn", "LAN mode is enabled but no LAN IP is recorded.");
5746
+ }
5747
+ } else if (liveRoutes.length > 0) {
5748
+ const managedHosts = new Set(getManagedHostnames());
5749
+ const resolutionChecks = await Promise.all(
5750
+ liveRoutes.map(async (route) => ({
5751
+ hostname: route.hostname,
5752
+ resolves: await checkHostResolution(route.hostname),
5753
+ managed: managedHosts.has(route.hostname)
5754
+ }))
5755
+ );
5756
+ const unresolved = resolutionChecks.filter((result) => !result.resolves);
5757
+ if (unresolved.length === 0) {
5758
+ add("ok", "Registered hostnames resolve through the system resolver.");
5759
+ } else {
5760
+ add(
5761
+ "warn",
5762
+ `${pluralize(unresolved.length, "hostname")} did not resolve through the system resolver.`,
5763
+ "Run: portless hosts sync"
5764
+ );
5765
+ for (const result of unresolved.slice(0, 5)) {
5766
+ const hostState = result.managed ? "present in hosts block" : "missing from hosts block";
5767
+ add("warn", `${result.hostname} is ${hostState}.`);
5768
+ }
5769
+ }
5770
+ }
5771
+ for (const finding of findings) {
5772
+ printDoctorFinding(finding);
5773
+ }
5774
+ const failures = findings.filter((finding) => finding.status === "fail").length;
5775
+ const warnings = findings.filter((finding) => finding.status === "warn").length;
5776
+ console.log("");
5777
+ if (failures > 0) {
5778
+ console.log(
5779
+ colors_default.red(`Summary: ${pluralize(failures, "failure")}, ${pluralize(warnings, "warning")}.`)
5780
+ );
5781
+ process.exit(1);
5782
+ }
5783
+ console.log(colors_default.green(`Summary: 0 failures, ${pluralize(warnings, "warning")}.`));
5784
+ }
5404
5785
  async function handleProxy(args) {
5405
5786
  if (args[1] === "stop") {
5406
5787
  let explicitPort;
@@ -5786,7 +6167,15 @@ ${colors_default.bold("LAN mode (--lan):")}
5786
6167
  }
5787
6168
  if (isForeground) {
5788
6169
  console.log(chalk.blue.bold("\nportless proxy\n"));
5789
- startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, desiredWildcard ? false : void 0);
6170
+ startProxyServer(
6171
+ store,
6172
+ proxyPort,
6173
+ tld,
6174
+ tlsOptions,
6175
+ lanIp,
6176
+ desiredWildcard ? false : void 0,
6177
+ !!(customCertPath && customKeyPath)
6178
+ );
5790
6179
  return;
5791
6180
  }
5792
6181
  store.ensureDir();
@@ -6002,7 +6391,7 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
6002
6391
  }
6003
6392
  if (capturedStore && capturedHostname) {
6004
6393
  try {
6005
- capturedStore.removeRoute(capturedHostname);
6394
+ capturedStore.removeRoute(capturedHostname, process.pid);
6006
6395
  } catch {
6007
6396
  }
6008
6397
  }
@@ -6216,7 +6605,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
6216
6605
  }, SIGKILL_TIMEOUT_MS).unref();
6217
6606
  for (const { hostname } of routes) {
6218
6607
  try {
6219
- store.removeRoute(hostname);
6608
+ store.removeRoute(hostname, process.pid);
6220
6609
  } catch {
6221
6610
  }
6222
6611
  }
@@ -6280,7 +6669,7 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
6280
6669
  }, SIGKILL_TIMEOUT_MS).unref();
6281
6670
  for (const { store, hostname } of routeEntries) {
6282
6671
  try {
6283
- store.removeRoute(hostname);
6672
+ store.removeRoute(hostname, process.pid);
6284
6673
  } catch {
6285
6674
  }
6286
6675
  }
@@ -6492,7 +6881,7 @@ async function main() {
6492
6881
  args.shift();
6493
6882
  }
6494
6883
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
6495
- if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
6884
+ if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "doctor" && args[0] !== "service")) {
6496
6885
  const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
6497
6886
  let commandArgs = parsed.commandArgs;
6498
6887
  if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
@@ -6540,6 +6929,10 @@ async function main() {
6540
6929
  await handleList();
6541
6930
  return;
6542
6931
  }
6932
+ if (args[0] === "doctor") {
6933
+ await handleDoctor(args);
6934
+ return;
6935
+ }
6543
6936
  if (args[0] === "get") {
6544
6937
  await handleGet(args);
6545
6938
  return;
package/dist/index.d.ts CHANGED
@@ -136,7 +136,13 @@ declare class RouteStore {
136
136
  * merged; the route must already exist (matched by hostname).
137
137
  */
138
138
  updateRoute(hostname: string, fields: RouteMetadataPatch): void;
139
- removeRoute(hostname: string): void;
139
+ /**
140
+ * Remove a route by hostname. When `ownerPid` is provided, the entry is
141
+ * only removed while it is still owned by that pid. Exit cleanups must
142
+ * pass their own pid: after a `--force` takeover the killed process would
143
+ * otherwise deregister the route the new owner just registered.
144
+ */
145
+ removeRoute(hostname: string, ownerPid?: number): void;
140
146
  }
141
147
 
142
148
  /**
@@ -147,6 +153,8 @@ declare class RouteStore {
147
153
  declare function fixOwnership(...paths: string[]): void;
148
154
  /** Type guard for Node.js system errors with an error code. */
149
155
  declare function isErrnoException(err: unknown): err is NodeJS.ErrnoException;
156
+ /** Return whether a process exists, treating permission denial as alive. */
157
+ declare function isProcessAlive(pid: number): boolean;
150
158
  /**
151
159
  * Escape HTML special characters to prevent XSS.
152
160
  */
@@ -204,4 +212,4 @@ declare function getManagedHostnames(): string[];
204
212
  */
205
213
  declare function checkHostResolution(hostname: string): Promise<boolean>;
206
214
 
207
- export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, buildBlock, checkHostResolution, cleanHostsFile, createHttpRedirectServer, createProxyServer, escapeHtml, extractManagedBlock, fixOwnership, formatUrl, getManagedHostnames, isErrnoException, parseHostname, removeBlock, shouldAutoSyncHosts, syncHostsFile };
215
+ export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, buildBlock, checkHostResolution, cleanHostsFile, createHttpRedirectServer, createProxyServer, escapeHtml, extractManagedBlock, fixOwnership, formatUrl, getManagedHostnames, isErrnoException, isProcessAlive, parseHostname, removeBlock, shouldAutoSyncHosts, syncHostsFile };
package/dist/index.js CHANGED
@@ -15,11 +15,12 @@ import {
15
15
  formatUrl,
16
16
  getManagedHostnames,
17
17
  isErrnoException,
18
+ isProcessAlive,
18
19
  parseHostname,
19
20
  removeBlock,
20
21
  shouldAutoSyncHosts,
21
22
  syncHostsFile
22
- } from "./chunk-OZM4AEYL.js";
23
+ } from "./chunk-PCBKLZK2.js";
23
24
  export {
24
25
  DIR_MODE,
25
26
  FILE_MODE,
@@ -37,6 +38,7 @@ export {
37
38
  formatUrl,
38
39
  getManagedHostnames,
39
40
  isErrnoException,
41
+ isProcessAlive,
40
42
  parseHostname,
41
43
  removeBlock,
42
44
  shouldAutoSyncHosts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
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",