portless 0.11.0 → 0.11.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
@@ -289,6 +289,7 @@ portless alias --remove <name> # Remove a static route
289
289
  portless list # Show active routes
290
290
  portless trust # Add local CA to system trust store
291
291
  portless clean # Remove state, CA trust entry, and hosts block
292
+ portless prune # Kill orphaned dev servers from crashed sessions
292
293
  portless hosts sync # Add routes to /etc/hosts (fixes Safari)
293
294
  portless hosts clean # Remove portless entries from /etc/hosts
294
295
 
@@ -344,7 +345,7 @@ PORTLESS_URL Public URL (e.g. https://myapp.localhost)
344
345
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
345
346
  ```
346
347
 
347
- > **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.
348
+ > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, `clean`, `prune`, 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.
348
349
 
349
350
  ## Uninstall / reset
350
351
 
@@ -853,6 +853,59 @@ var RouteStore = class _RouteStore {
853
853
  }
854
854
  return killedPid;
855
855
  }
856
+ /**
857
+ * Load all routes from disk without filtering out dead PIDs. Used by
858
+ * `portless prune` to discover stale entries whose owning CLI is gone
859
+ * but whose dev server may still be holding a port.
860
+ */
861
+ loadRoutesRaw() {
862
+ if (!fs3.existsSync(this.routesPath)) {
863
+ return [];
864
+ }
865
+ try {
866
+ const raw = fs3.readFileSync(this.routesPath, "utf-8");
867
+ let parsed;
868
+ try {
869
+ parsed = JSON.parse(raw);
870
+ } catch {
871
+ return [];
872
+ }
873
+ if (!Array.isArray(parsed)) {
874
+ return [];
875
+ }
876
+ return parsed.filter(isValidRoute);
877
+ } catch {
878
+ return [];
879
+ }
880
+ }
881
+ /**
882
+ * Remove all route entries whose owning process is dead and persist the
883
+ * result. Returns the removed stale entries so the caller can act on them.
884
+ */
885
+ pruneStaleRoutes() {
886
+ this.ensureDir();
887
+ if (!this.acquireLock()) {
888
+ throw new Error("Failed to acquire route lock");
889
+ }
890
+ try {
891
+ const all = this.loadRoutesRaw();
892
+ const alive = [];
893
+ const stale = [];
894
+ for (const r of all) {
895
+ if (r.pid === 0 || this.isProcessAlive(r.pid)) {
896
+ alive.push(r);
897
+ } else {
898
+ stale.push(r);
899
+ }
900
+ }
901
+ if (stale.length > 0) {
902
+ this.saveRoutes(alive);
903
+ }
904
+ return stale;
905
+ } finally {
906
+ this.releaseLock();
907
+ }
908
+ }
856
909
  removeRoute(hostname) {
857
910
  this.ensureDir();
858
911
  if (!this.acquireLock()) {
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  parseHostname,
14
14
  shouldAutoSyncHosts,
15
15
  syncHostsFile
16
- } from "./chunk-6PDLZVDS.js";
16
+ } from "./chunk-RRH2JUIU.js";
17
17
 
18
18
  // src/colors.ts
19
19
  function supportsColor() {
@@ -1044,6 +1044,23 @@ var SIGNAL_CODES = {
1044
1044
  SIGKILL: 9,
1045
1045
  SIGTERM: 15
1046
1046
  };
1047
+ function killTree(child, signal = "SIGTERM") {
1048
+ if (!child.pid) {
1049
+ child.kill(signal);
1050
+ return;
1051
+ }
1052
+ if (!isWindows) {
1053
+ try {
1054
+ process.kill(-child.pid, signal);
1055
+ return;
1056
+ } catch {
1057
+ }
1058
+ }
1059
+ try {
1060
+ child.kill(signal);
1061
+ } catch {
1062
+ }
1063
+ }
1047
1064
  function getProtocolPort(tls2) {
1048
1065
  return tls2 ? 443 : 80;
1049
1066
  }
@@ -1372,6 +1389,25 @@ function parsePidFromNetstat(output, port) {
1372
1389
  }
1373
1390
  return null;
1374
1391
  }
1392
+ function findPidsOnPort(port) {
1393
+ try {
1394
+ if (isWindows) {
1395
+ const output2 = execSync("netstat -ano -p tcp", {
1396
+ encoding: "utf-8",
1397
+ timeout: PID_LOOKUP_TIMEOUT_MS
1398
+ });
1399
+ const pid = parsePidFromNetstat(output2, port);
1400
+ return pid === null ? [] : [pid];
1401
+ }
1402
+ const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
1403
+ encoding: "utf-8",
1404
+ timeout: PID_LOOKUP_TIMEOUT_MS
1405
+ });
1406
+ return output.trim().split("\n").map((s) => parseInt(s, 10)).filter((n) => !isNaN(n) && n > 0);
1407
+ } catch {
1408
+ return [];
1409
+ }
1410
+ }
1375
1411
  function findPidOnPort(port) {
1376
1412
  try {
1377
1413
  if (isWindows) {
@@ -1442,7 +1478,8 @@ function spawnCommand(commandArgs, options) {
1442
1478
  env
1443
1479
  }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
1444
1480
  stdio: "inherit",
1445
- env
1481
+ env,
1482
+ detached: true
1446
1483
  });
1447
1484
  let exiting = false;
1448
1485
  const cleanup = () => {
@@ -1453,7 +1490,7 @@ function spawnCommand(commandArgs, options) {
1453
1490
  const handleSignal = (signal) => {
1454
1491
  if (exiting) return;
1455
1492
  exiting = true;
1456
- child.kill(signal);
1493
+ killTree(child, signal);
1457
1494
  cleanup();
1458
1495
  process.exit(128 + (SIGNAL_CODES[signal] || 15));
1459
1496
  };
@@ -3219,6 +3256,7 @@ ${colors_default.bold("Usage:")}
3219
3256
  ${colors_default.cyan("portless list")} Show active routes
3220
3257
  ${colors_default.cyan("portless trust")} Add local CA to system trust store
3221
3258
  ${colors_default.cyan("portless clean")} Remove portless state, trust entry, and hosts block
3259
+ ${colors_default.cyan("portless prune")} Kill orphaned dev servers from crashed sessions
3222
3260
  ${colors_default.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
3223
3261
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
3224
3262
 
@@ -3331,14 +3369,14 @@ ${colors_default.bold("Skip portless:")}
3331
3369
  PORTLESS=0 pnpm dev # Runs command directly without proxy
3332
3370
 
3333
3371
  ${colors_default.bold("Reserved names:")}
3334
- run, get, alias, hosts, list, trust, clean, proxy are subcommands and cannot
3335
- be used as app names directly. Use "portless run" to infer the name,
3372
+ run, get, alias, hosts, list, trust, clean, prune, proxy are subcommands and
3373
+ cannot be used as app names directly. Use "portless run" to infer the name,
3336
3374
  or "portless --name <name>" to force any name including reserved ones.
3337
3375
  `);
3338
3376
  process.exit(0);
3339
3377
  }
3340
3378
  function printVersion() {
3341
- console.log("0.11.0");
3379
+ console.log("0.11.1");
3342
3380
  process.exit(0);
3343
3381
  }
3344
3382
  async function handleTrust() {
@@ -3464,6 +3502,63 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
3464
3502
  }
3465
3503
  console.log(colors_default.green("Clean finished."));
3466
3504
  }
3505
+ async function handlePrune(args) {
3506
+ if (args[1] === "--help" || args[1] === "-h") {
3507
+ console.log(`
3508
+ ${colors_default.bold("portless prune")} - Kill orphaned dev servers left behind by crashed portless sessions.
3509
+
3510
+ When portless is killed with SIGKILL (kill -9) or crashes, child dev servers
3511
+ may survive and continue holding their ports. This command finds those orphans
3512
+ by checking routes whose owning CLI process is dead but whose port is still in
3513
+ use, then terminates them and cleans up the stale route entries.
3514
+
3515
+ ${colors_default.bold("Usage:")}
3516
+ ${colors_default.cyan("portless prune")}
3517
+ ${colors_default.cyan("portless prune --force")} Send SIGKILL instead of SIGTERM
3518
+
3519
+ ${colors_default.bold("Options:")}
3520
+ --force Send SIGKILL instead of SIGTERM
3521
+ --help, -h Show this help
3522
+ `);
3523
+ process.exit(0);
3524
+ }
3525
+ const forceKill = args.includes("--force");
3526
+ const { dir } = await discoverState();
3527
+ const store = new RouteStore(dir, {
3528
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
3529
+ });
3530
+ const stale = store.pruneStaleRoutes();
3531
+ if (stale.length === 0) {
3532
+ console.log("No orphaned routes found.");
3533
+ return;
3534
+ }
3535
+ let killed = 0;
3536
+ for (const route of stale) {
3537
+ const pids = findPidsOnPort(route.port);
3538
+ if (pids.length === 0) {
3539
+ console.log(` ${route.hostname} :${route.port} - route removed (port already free)`);
3540
+ continue;
3541
+ }
3542
+ const signal = forceKill ? "SIGKILL" : "SIGTERM";
3543
+ for (const pid of pids) {
3544
+ try {
3545
+ process.kill(pid, signal);
3546
+ killed++;
3547
+ console.log(` ${route.hostname} :${route.port} - killed PID ${pid} (${signal})`);
3548
+ } catch {
3549
+ console.log(` ${route.hostname} :${route.port} - PID ${pid} already exited`);
3550
+ }
3551
+ }
3552
+ }
3553
+ const routeWord = stale.length === 1 ? "route" : "routes";
3554
+ const procWord = killed === 1 ? "process" : "processes";
3555
+ console.log(
3556
+ colors_default.green(
3557
+ `
3558
+ Pruned ${stale.length} stale ${routeWord}, killed ${killed} orphaned ${procWord}.`
3559
+ )
3560
+ );
3561
+ }
3467
3562
  async function handleList() {
3468
3563
  const { dir, port, tls: tls2 } = await discoverState();
3469
3564
  const store = new RouteStore(dir, {
@@ -4207,7 +4302,8 @@ function spawnChildProcess(commandArgs, env, cwd) {
4207
4302
  return spawn3(commandArgs[0], commandArgs.slice(1), {
4208
4303
  stdio: ["ignore", "pipe", "pipe"],
4209
4304
  env,
4210
- cwd
4305
+ cwd,
4306
+ ...isWindows ? {} : { detached: true }
4211
4307
  });
4212
4308
  }
4213
4309
  function prefixStream(stream, output, prefix) {
@@ -4480,23 +4576,18 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
4480
4576
  env: {
4481
4577
  ...process.env,
4482
4578
  NODE_OPTIONS: buildNodeOptions()
4483
- }
4579
+ },
4580
+ ...isWindows ? {} : { detached: true }
4484
4581
  });
4485
4582
  const SIGKILL_TIMEOUT_MS = 5e3;
4486
4583
  let cleanedUp = false;
4487
4584
  const cleanup = () => {
4488
4585
  if (cleanedUp) return;
4489
4586
  cleanedUp = true;
4490
- try {
4491
- turboChild.kill("SIGTERM");
4492
- } catch {
4493
- }
4587
+ killTree(turboChild, "SIGTERM");
4494
4588
  setTimeout(() => {
4495
4589
  if (turboChild.exitCode === null && !turboChild.killed) {
4496
- try {
4497
- turboChild.kill("SIGKILL");
4498
- } catch {
4499
- }
4590
+ killTree(turboChild, "SIGKILL");
4500
4591
  }
4501
4592
  }, SIGKILL_TIMEOUT_MS).unref();
4502
4593
  for (const { hostname } of routes) {
@@ -4554,18 +4645,12 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
4554
4645
  if (cleanedUp) return;
4555
4646
  cleanedUp = true;
4556
4647
  for (const child of children) {
4557
- try {
4558
- child.kill("SIGTERM");
4559
- } catch {
4560
- }
4648
+ killTree(child, "SIGTERM");
4561
4649
  }
4562
4650
  setTimeout(() => {
4563
4651
  for (const child of children) {
4564
4652
  if (child.exitCode === null && !child.killed) {
4565
- try {
4566
- child.kill("SIGKILL");
4567
- } catch {
4568
- }
4653
+ killTree(child, "SIGKILL");
4569
4654
  }
4570
4655
  }
4571
4656
  }, SIGKILL_TIMEOUT_MS).unref();
@@ -4812,6 +4897,10 @@ async function main() {
4812
4897
  await handleClean(args);
4813
4898
  return;
4814
4899
  }
4900
+ if (args[0] === "prune") {
4901
+ await handlePrune(args);
4902
+ return;
4903
+ }
4815
4904
  if (args[0] === "list") {
4816
4905
  await handleList();
4817
4906
  return;
package/dist/index.d.ts CHANGED
@@ -108,6 +108,17 @@ declare class RouteStore {
108
108
  * log it.
109
109
  */
110
110
  addRoute(hostname: string, port: number, pid: number, force?: boolean): number | undefined;
111
+ /**
112
+ * Load all routes from disk without filtering out dead PIDs. Used by
113
+ * `portless prune` to discover stale entries whose owning CLI is gone
114
+ * but whose dev server may still be holding a port.
115
+ */
116
+ loadRoutesRaw(): RouteMapping[];
117
+ /**
118
+ * Remove all route entries whose owning process is dead and persist the
119
+ * result. Returns the removed stale entries so the caller can act on them.
120
+ */
121
+ pruneStaleRoutes(): RouteMapping[];
111
122
  removeRoute(hostname: string): void;
112
123
  }
113
124
 
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  removeBlock,
20
20
  shouldAutoSyncHosts,
21
21
  syncHostsFile
22
- } from "./chunk-6PDLZVDS.js";
22
+ } from "./chunk-RRH2JUIU.js";
23
23
  export {
24
24
  DIR_MODE,
25
25
  FILE_MODE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.11.0",
3
+ "version": "0.11.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",