portless 0.11.0 → 0.12.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
@@ -276,6 +276,34 @@ LAN mode depends on the system mDNS tools that portless already spawns: macOS sh
276
276
 
277
277
  - **Expo / React Native**: portless always injects `--port`. React Native also gets `--host 127.0.0.1`. Expo gets `--host localhost` outside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing `--host` or `HOST`.
278
278
 
279
+ ## Tailscale sharing
280
+
281
+ Share your dev server with teammates on your [Tailscale](https://tailscale.com) network:
282
+
283
+ ```bash
284
+ portless myapp --tailscale next dev
285
+ # -> https://myapp.localhost (local)
286
+ # -> https://devbox.yourteam.ts.net (tailnet)
287
+ ```
288
+
289
+ Each `--tailscale` app is root-mounted on its own Tailscale HTTPS port, so no framework `basePath` configuration is needed. The first app gets port 443, subsequent apps get 8443, 8444, etc.
290
+
291
+ ```bash
292
+ portless myapp --tailscale next dev # -> https://devbox.ts.net
293
+ portless api --tailscale pnpm start # -> https://devbox.ts.net:8443
294
+ ```
295
+
296
+ Use `--funnel` to expose your dev server to the public internet via [Tailscale Funnel](https://tailscale.com/kb/1223/funnel/):
297
+
298
+ ```bash
299
+ portless myapp --funnel next dev
300
+ # -> https://devbox.yourteam.ts.net (public)
301
+ ```
302
+
303
+ Set `PORTLESS_TAILSCALE=1` in your shell profile or `.env` to share every app by default. `portless list` shows both local and tailnet URLs. Tailscale serve registrations are cleaned up automatically when the app exits.
304
+
305
+ Requires the Tailscale CLI to be installed and connected (`tailscale up`).
306
+
279
307
  ## Commands
280
308
 
281
309
  ```bash
@@ -289,6 +317,7 @@ portless alias --remove <name> # Remove a static route
289
317
  portless list # Show active routes
290
318
  portless trust # Add local CA to system trust store
291
319
  portless clean # Remove state, CA trust entry, and hosts block
320
+ portless prune # Kill orphaned dev servers from crashed sessions
292
321
  portless hosts sync # Add routes to /etc/hosts (fixes Safari)
293
322
  portless hosts clean # Remove portless entries from /etc/hosts
294
323
 
@@ -320,6 +349,8 @@ portless proxy stop # Stop the proxy
320
349
  --wildcard Allow unregistered subdomains to fall back to parent route
321
350
  --script <name> Run a specific package.json script (default: dev)
322
351
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
352
+ --tailscale Share the app on your Tailscale network (tailnet)
353
+ --funnel Share the app publicly via Tailscale Funnel
323
354
  --force Kill the existing process and take over its route
324
355
  --name <name> Use <name> as the app name
325
356
  ```
@@ -335,16 +366,19 @@ PORTLESS_LAN=1 Enable LAN mode when set to 1 (auto-detects LAN
335
366
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
336
367
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
337
368
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
369
+ PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
370
+ PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
338
371
  PORTLESS_STATE_DIR=<path> Override the state directory
339
372
 
340
373
  # Injected into child processes
341
374
  PORT Ephemeral port the child should listen on
342
375
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
343
376
  PORTLESS_URL Public URL (e.g. https://myapp.localhost)
377
+ PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
344
378
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
345
379
  ```
346
380
 
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.
381
+ > **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
382
 
349
383
  ## Uninstall / reset
350
384
 
@@ -421,3 +455,4 @@ pnpm format # Format all files with Prettier
421
455
 
422
456
  - Node.js 20+
423
457
  - macOS, Linux, or Windows
458
+ - Tailscale CLI (optional, for `--tailscale` and `--funnel`)
@@ -846,13 +846,89 @@ var RouteStore = class _RouteStore {
846
846
  }
847
847
  }
848
848
  const filtered = routes.filter((r) => r.hostname !== hostname);
849
- filtered.push({ hostname, port, pid });
849
+ const entry = { hostname, port, pid };
850
+ filtered.push(entry);
850
851
  this.saveRoutes(filtered);
851
852
  } finally {
852
853
  this.releaseLock();
853
854
  }
854
855
  return killedPid;
855
856
  }
857
+ /**
858
+ * Load all routes from disk without filtering out dead PIDs. Used by
859
+ * `portless prune` to discover stale entries whose owning CLI is gone
860
+ * but whose dev server may still be holding a port.
861
+ */
862
+ loadRoutesRaw() {
863
+ if (!fs3.existsSync(this.routesPath)) {
864
+ return [];
865
+ }
866
+ try {
867
+ const raw = fs3.readFileSync(this.routesPath, "utf-8");
868
+ let parsed;
869
+ try {
870
+ parsed = JSON.parse(raw);
871
+ } catch {
872
+ return [];
873
+ }
874
+ if (!Array.isArray(parsed)) {
875
+ return [];
876
+ }
877
+ return parsed.filter(isValidRoute);
878
+ } catch {
879
+ return [];
880
+ }
881
+ }
882
+ /**
883
+ * Remove all route entries whose owning process is dead and persist the
884
+ * result. Returns the removed stale entries so the caller can act on them.
885
+ */
886
+ pruneStaleRoutes() {
887
+ this.ensureDir();
888
+ if (!this.acquireLock()) {
889
+ throw new Error("Failed to acquire route lock");
890
+ }
891
+ try {
892
+ const all = this.loadRoutesRaw();
893
+ const alive = [];
894
+ const stale = [];
895
+ for (const r of all) {
896
+ if (r.pid === 0 || this.isProcessAlive(r.pid)) {
897
+ alive.push(r);
898
+ } else {
899
+ stale.push(r);
900
+ }
901
+ }
902
+ if (stale.length > 0) {
903
+ this.saveRoutes(alive);
904
+ }
905
+ return stale;
906
+ } finally {
907
+ this.releaseLock();
908
+ }
909
+ }
910
+ /**
911
+ * Update metadata on an existing route entry. Only provided fields are
912
+ * merged; the route must already exist (matched by hostname).
913
+ */
914
+ updateRoute(hostname, fields) {
915
+ this.ensureDir();
916
+ if (!this.acquireLock()) {
917
+ throw new Error("Failed to acquire route lock");
918
+ }
919
+ try {
920
+ const routes = this.loadRoutes(true);
921
+ const route = routes.find((r) => r.hostname === hostname);
922
+ if (!route) return;
923
+ if (fields.tailscaleUrl !== void 0) route.tailscaleUrl = fields.tailscaleUrl;
924
+ if (fields.tailscaleHttpsPort !== void 0)
925
+ route.tailscaleHttpsPort = fields.tailscaleHttpsPort;
926
+ if (fields.tailscaleFunnel !== void 0) route.tailscaleFunnel = fields.tailscaleFunnel;
927
+ this.saveRoutes(routes);
928
+ } finally {
929
+ this.releaseLock();
930
+ }
931
+ }
856
932
  removeRoute(hostname) {
857
933
  this.ensureDir();
858
934
  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-3WLVQXFE.js";
17
17
 
18
18
  // src/colors.ts
19
19
  function supportsColor() {
@@ -41,7 +41,7 @@ var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
41
41
  // src/cli.ts
42
42
  import * as fs8 from "fs";
43
43
  import * as path8 from "path";
44
- import { spawn as spawn3, spawnSync as spawnSync2 } from "child_process";
44
+ import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
45
45
  import { StringDecoder } from "string_decoder";
46
46
 
47
47
  // src/certs.ts
@@ -754,6 +754,184 @@ function untrustCAWindows(caCertPath) {
754
754
  }
755
755
  }
756
756
 
757
+ // src/tailscale.ts
758
+ import { spawnSync } from "child_process";
759
+ var TAILSCALE_BINARY = "tailscale";
760
+ var PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
761
+ var FUNNEL_PORTS = [443, 8443, 1e4];
762
+ function defaultRunner(args) {
763
+ const result = spawnSync(TAILSCALE_BINARY, args, { encoding: "utf-8" });
764
+ return {
765
+ status: result.status,
766
+ stdout: result.stdout ?? "",
767
+ stderr: result.stderr ?? "",
768
+ ...result.error ? { error: result.error } : {}
769
+ };
770
+ }
771
+ function trimDot(value) {
772
+ return value.endsWith(".") ? value.slice(0, -1) : value;
773
+ }
774
+ function normalizeSpace(value) {
775
+ return value.trim().replace(/\s+/g, " ");
776
+ }
777
+ function runOrThrow(args, action, runner) {
778
+ const result = runner(args);
779
+ if (result.error) {
780
+ const errno = result.error;
781
+ if (errno.code === "ENOENT") {
782
+ throw new Error(
783
+ "Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
784
+ );
785
+ }
786
+ throw new Error(`Failed to ${action}: ${result.error.message}`);
787
+ }
788
+ if (result.status !== 0) {
789
+ const details = normalizeSpace(result.stderr || result.stdout);
790
+ throw new Error(`Failed to ${action}: ${details || "unknown tailscale error"}`);
791
+ }
792
+ return result;
793
+ }
794
+ function parseStatusJson(raw) {
795
+ try {
796
+ return JSON.parse(raw);
797
+ } catch {
798
+ throw new Error("Failed to parse `tailscale status --json` output.");
799
+ }
800
+ }
801
+ function statusToDnsName(status) {
802
+ const dnsName = status.Self?.DNSName;
803
+ if (typeof dnsName === "string" && dnsName.length > 0) {
804
+ return trimDot(dnsName);
805
+ }
806
+ const host = status.Self?.HostName;
807
+ const suffix = status.CurrentTailnet?.MagicDNSSuffix;
808
+ if (typeof host === "string" && host.length > 0 && typeof suffix === "string" && suffix.length > 0) {
809
+ return `${host}.${trimDot(suffix)}`;
810
+ }
811
+ throw new Error(
812
+ "Could not determine Tailscale node DNS name from `tailscale status --json`. Is Tailscale connected?"
813
+ );
814
+ }
815
+ function ensureTailscaleReady(runner = defaultRunner) {
816
+ runOrThrow(["version"], "check tailscale version", runner);
817
+ const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
818
+ const status = parseStatusJson(statusResult.stdout);
819
+ const dnsName = statusToDnsName(status);
820
+ return {
821
+ dnsName,
822
+ baseUrl: `https://${dnsName}`
823
+ };
824
+ }
825
+ function getUsedServePorts(runner = defaultRunner) {
826
+ const result = runner(["serve", "status", "--json"]);
827
+ if (result.error || result.status !== 0) {
828
+ return /* @__PURE__ */ new Set();
829
+ }
830
+ try {
831
+ const config = JSON.parse(result.stdout);
832
+ const ports = /* @__PURE__ */ new Set();
833
+ if (config.Web) {
834
+ for (const hostPort of Object.keys(config.Web)) {
835
+ const match = hostPort.match(/:(\d+)$/);
836
+ if (match) {
837
+ ports.add(parseInt(match[1], 10));
838
+ }
839
+ }
840
+ }
841
+ if (config.TCP) {
842
+ for (const portStr of Object.keys(config.TCP)) {
843
+ const p = parseInt(portStr, 10);
844
+ if (!isNaN(p)) ports.add(p);
845
+ }
846
+ }
847
+ return ports;
848
+ } catch {
849
+ return /* @__PURE__ */ new Set();
850
+ }
851
+ }
852
+ function findAvailableServePort(usedPorts, mode = "serve") {
853
+ const pool = mode === "funnel" ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS;
854
+ for (const port2 of pool) {
855
+ if (!usedPorts.has(port2)) return port2;
856
+ }
857
+ if (mode === "funnel") {
858
+ throw new Error(
859
+ "All Tailscale Funnel ports are in use (443, 8443, 10000). Stop an existing funnel to free a port."
860
+ );
861
+ }
862
+ let port = PREFERRED_SERVE_PORTS[PREFERRED_SERVE_PORTS.length - 1] + 1;
863
+ while (usedPorts.has(port)) port++;
864
+ return port;
865
+ }
866
+ function isConflictError(stderr, stdout) {
867
+ const text = `${stderr}
868
+ ${stdout}`.toLowerCase();
869
+ return text.includes("already in use") || text.includes("already exists") || text.includes("port conflict") || text.includes("address already");
870
+ }
871
+ var CONFLICT_MESSAGES = {
872
+ serve: "Stop the existing serve or let portless auto-assign a different port.",
873
+ funnel: "Tailscale Funnel supports ports 443, 8443, and 10000."
874
+ };
875
+ function register(mode, localPort, httpsPort, runner) {
876
+ const target = `http://127.0.0.1:${localPort}`;
877
+ const result = runner([mode, "--bg", "--yes", `--https=${httpsPort}`, target]);
878
+ if (result.error) {
879
+ const errno = result.error;
880
+ if (errno.code === "ENOENT") {
881
+ throw new Error(
882
+ "Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
883
+ );
884
+ }
885
+ throw new Error(`Failed to register tailscale ${mode}: ${result.error.message}`);
886
+ }
887
+ if (result.status !== 0) {
888
+ if (isConflictError(result.stderr, result.stdout)) {
889
+ throw new Error(
890
+ `Tailscale ${mode === "funnel" ? "Funnel " : ""}HTTPS port ${httpsPort} is already in use. ` + CONFLICT_MESSAGES[mode]
891
+ );
892
+ }
893
+ const details = normalizeSpace(result.stderr || result.stdout);
894
+ throw new Error(
895
+ `Failed to register tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
896
+ );
897
+ }
898
+ }
899
+ function unregister(mode, httpsPort, options) {
900
+ const runner = options?.runner ?? defaultRunner;
901
+ const result = runner([mode, "--yes", `--https=${httpsPort}`, "off"]);
902
+ if (result.error) {
903
+ const errno = result.error;
904
+ if (errno.code === "ENOENT") return;
905
+ throw new Error(`Failed to remove tailscale ${mode}: ${result.error.message}`);
906
+ }
907
+ if (result.status !== 0) {
908
+ const text = `${result.stderr}
909
+ ${result.stdout}`.toLowerCase();
910
+ const looksLikeMissing = text.includes("not found") || text.includes("no serve config") || text.includes("nothing to remove") || text.includes("does not exist");
911
+ if (options?.ignoreMissing && looksLikeMissing) return;
912
+ const details = normalizeSpace(result.stderr || result.stdout);
913
+ throw new Error(
914
+ `Failed to remove tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
915
+ );
916
+ }
917
+ }
918
+ function registerServe(localPort, httpsPort, options) {
919
+ register("serve", localPort, httpsPort, options?.runner ?? defaultRunner);
920
+ }
921
+ function registerFunnel(localPort, httpsPort, options) {
922
+ register("funnel", localPort, httpsPort, options?.runner ?? defaultRunner);
923
+ }
924
+ function unregisterTailscale(route) {
925
+ if (!route.tailscaleHttpsPort) return;
926
+ const mode = route.tailscaleFunnel ? "funnel" : "serve";
927
+ unregister(mode, route.tailscaleHttpsPort, { ignoreMissing: true });
928
+ }
929
+ function formatTailscaleUrl(baseUrl, httpsPort) {
930
+ const trimmed = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
931
+ if (httpsPort === 443) return trimmed;
932
+ return `${trimmed}:${httpsPort}`;
933
+ }
934
+
757
935
  // src/auto.ts
758
936
  import { createHash as createHash2 } from "crypto";
759
937
  import { execFileSync as execFileSync2 } from "child_process";
@@ -1044,6 +1222,23 @@ var SIGNAL_CODES = {
1044
1222
  SIGKILL: 9,
1045
1223
  SIGTERM: 15
1046
1224
  };
1225
+ function killTree(child, signal = "SIGTERM") {
1226
+ if (!child.pid) {
1227
+ child.kill(signal);
1228
+ return;
1229
+ }
1230
+ if (!isWindows) {
1231
+ try {
1232
+ process.kill(-child.pid, signal);
1233
+ return;
1234
+ } catch {
1235
+ }
1236
+ }
1237
+ try {
1238
+ child.kill(signal);
1239
+ } catch {
1240
+ }
1241
+ }
1047
1242
  function getProtocolPort(tls2) {
1048
1243
  return tls2 ? 443 : 80;
1049
1244
  }
@@ -1372,6 +1567,25 @@ function parsePidFromNetstat(output, port) {
1372
1567
  }
1373
1568
  return null;
1374
1569
  }
1570
+ function findPidsOnPort(port) {
1571
+ try {
1572
+ if (isWindows) {
1573
+ const output2 = execSync("netstat -ano -p tcp", {
1574
+ encoding: "utf-8",
1575
+ timeout: PID_LOOKUP_TIMEOUT_MS
1576
+ });
1577
+ const pid = parsePidFromNetstat(output2, port);
1578
+ return pid === null ? [] : [pid];
1579
+ }
1580
+ const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
1581
+ encoding: "utf-8",
1582
+ timeout: PID_LOOKUP_TIMEOUT_MS
1583
+ });
1584
+ return output.trim().split("\n").map((s) => parseInt(s, 10)).filter((n) => !isNaN(n) && n > 0);
1585
+ } catch {
1586
+ return [];
1587
+ }
1588
+ }
1375
1589
  function findPidOnPort(port) {
1376
1590
  try {
1377
1591
  if (isWindows) {
@@ -1442,7 +1656,8 @@ function spawnCommand(commandArgs, options) {
1442
1656
  env
1443
1657
  }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
1444
1658
  stdio: "inherit",
1445
- env
1659
+ env,
1660
+ detached: true
1446
1661
  });
1447
1662
  let exiting = false;
1448
1663
  const cleanup = () => {
@@ -1453,7 +1668,7 @@ function spawnCommand(commandArgs, options) {
1453
1668
  const handleSignal = (signal) => {
1454
1669
  if (exiting) return;
1455
1670
  exiting = true;
1456
- child.kill(signal);
1671
+ killTree(child, signal);
1457
1672
  cleanup();
1458
1673
  process.exit(128 + (SIGNAL_CODES[signal] || 15));
1459
1674
  };
@@ -1585,7 +1800,7 @@ function removePortlessStateFiles(dir) {
1585
1800
  }
1586
1801
 
1587
1802
  // src/mdns.ts
1588
- import { spawn as spawn2, spawnSync } from "child_process";
1803
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
1589
1804
 
1590
1805
  // src/lan-ip.ts
1591
1806
  import { createSocket } from "dgram";
@@ -1701,7 +1916,7 @@ function getMdnsPublisher() {
1701
1916
  return null;
1702
1917
  }
1703
1918
  function hasCommand(command, probeArgs) {
1704
- const result = spawnSync(command, probeArgs, {
1919
+ const result = spawnSync2(command, probeArgs, {
1705
1920
  stdio: "ignore",
1706
1921
  timeout: 1e3,
1707
1922
  windowsHide: true
@@ -2482,7 +2697,7 @@ function collectPortlessEnvArgs() {
2482
2697
  function sudoStop(port) {
2483
2698
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
2484
2699
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
2485
- const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
2700
+ const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
2486
2701
  stdio: "inherit",
2487
2702
  timeout: SUDO_SPAWN_TIMEOUT_MS
2488
2703
  });
@@ -2822,6 +3037,10 @@ function listRoutes(store, proxyPort, tls2) {
2822
3037
  console.log(
2823
3038
  ` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
2824
3039
  );
3040
+ if (route.tailscaleUrl) {
3041
+ const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
3042
+ console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
3043
+ }
2825
3044
  }
2826
3045
  console.log();
2827
3046
  }
@@ -2905,7 +3124,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
2905
3124
  proxyPort: startPort
2906
3125
  });
2907
3126
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
2908
- const result = spawnSync2(process.execPath, startArgs, {
3127
+ const result = spawnSync3(process.execPath, startArgs, {
2909
3128
  stdio: "inherit",
2910
3129
  timeout: SUDO_SPAWN_TIMEOUT_MS
2911
3130
  });
@@ -2939,6 +3158,25 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
2939
3158
  console.log(chalk.blue.bold(`
2940
3159
  portless
2941
3160
  `));
3161
+ const wantsFunnel = process.env.PORTLESS_FUNNEL === "1" || process.env.PORTLESS_FUNNEL === "true";
3162
+ const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
3163
+ let tsBaseUrl;
3164
+ if (wantsTailscale) {
3165
+ try {
3166
+ const tsReady = ensureTailscaleReady();
3167
+ tsBaseUrl = tsReady.baseUrl;
3168
+ } catch (err) {
3169
+ const message = err instanceof Error ? err.message : String(err);
3170
+ console.error(colors_default.red(`Error: ${message}`));
3171
+ if (message.includes("not found")) {
3172
+ console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
3173
+ } else {
3174
+ console.error(colors_default.blue("Make sure Tailscale is connected:"));
3175
+ console.error(colors_default.cyan(" tailscale up"));
3176
+ }
3177
+ process.exit(1);
3178
+ }
3179
+ }
2942
3180
  let desired;
2943
3181
  try {
2944
3182
  desired = resolveProxyDesiredState(lanMode);
@@ -3022,6 +3260,45 @@ portless
3022
3260
  console.log(chalk.green(` LAN -> ${finalUrl}`));
3023
3261
  console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
3024
3262
  }
3263
+ let tailscaleHttpsPort;
3264
+ let tailscaleUrl;
3265
+ if (wantsTailscale && tsBaseUrl) {
3266
+ const maxAttempts = 3;
3267
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3268
+ const usedPorts = getUsedServePorts();
3269
+ tailscaleHttpsPort = findAvailableServePort(usedPorts, wantsFunnel ? "funnel" : "serve");
3270
+ try {
3271
+ if (wantsFunnel) {
3272
+ registerFunnel(port, tailscaleHttpsPort);
3273
+ } else {
3274
+ registerServe(port, tailscaleHttpsPort);
3275
+ }
3276
+ break;
3277
+ } catch (err) {
3278
+ const message = err instanceof Error ? err.message : String(err);
3279
+ const isConflict = message.includes("already in use");
3280
+ if (isConflict && attempt < maxAttempts) continue;
3281
+ console.error(colors_default.red(`Error: ${message}`));
3282
+ process.exit(1);
3283
+ }
3284
+ }
3285
+ tailscaleUrl = formatTailscaleUrl(tsBaseUrl, tailscaleHttpsPort);
3286
+ const label = wantsFunnel ? "Funnel (public)" : "Tailscale";
3287
+ console.log(chalk.green(` ${label} -> ${tailscaleUrl}`));
3288
+ if (wantsFunnel) {
3289
+ console.log(chalk.gray(" (accessible from the public internet via Tailscale Funnel)\n"));
3290
+ } else {
3291
+ console.log(chalk.gray(" (accessible from your tailnet)\n"));
3292
+ }
3293
+ try {
3294
+ store.updateRoute(hostname, {
3295
+ tailscaleUrl,
3296
+ tailscaleHttpsPort,
3297
+ tailscaleFunnel: wantsFunnel || void 0
3298
+ });
3299
+ } catch {
3300
+ }
3301
+ }
3025
3302
  const basename5 = path8.basename(commandArgs[0]);
3026
3303
  const isExpo = basename5 === "expo";
3027
3304
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
@@ -3055,9 +3332,17 @@ portless
3055
3332
  // baked-in pinging, making this env var ineffective. Expo handles its
3056
3333
  // own LAN discovery natively.
3057
3334
  ...lanMode ? { PORTLESS_LAN: "1" } : {},
3335
+ ...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
3058
3336
  ...caEnv
3059
3337
  },
3060
3338
  onCleanup: () => {
3339
+ try {
3340
+ unregisterTailscale({
3341
+ tailscaleHttpsPort,
3342
+ tailscaleFunnel: wantsFunnel || void 0
3343
+ });
3344
+ } catch {
3345
+ }
3061
3346
  try {
3062
3347
  store.removeRoute(hostname);
3063
3348
  } catch {
@@ -3087,6 +3372,18 @@ function appPortFromEnv() {
3087
3372
  }
3088
3373
  return port;
3089
3374
  }
3375
+ function applyTailscaleFlag(flag) {
3376
+ if (flag === "--tailscale") {
3377
+ process.env.PORTLESS_TAILSCALE = "1";
3378
+ return true;
3379
+ }
3380
+ if (flag === "--funnel") {
3381
+ process.env.PORTLESS_FUNNEL = "1";
3382
+ process.env.PORTLESS_TAILSCALE = "1";
3383
+ return true;
3384
+ }
3385
+ return false;
3386
+ }
3090
3387
  function parseRunArgs(args) {
3091
3388
  let force = false;
3092
3389
  let appPort;
@@ -3143,9 +3440,12 @@ ${colors_default.bold("Examples:")}
3143
3440
  process.exit(1);
3144
3441
  }
3145
3442
  name = args[i];
3443
+ } else if (applyTailscaleFlag(args[i])) {
3146
3444
  } else {
3147
3445
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3148
- console.error(colors_default.blue("Known flags: --name, --force, --app-port, --help"));
3446
+ console.error(
3447
+ colors_default.blue("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
3448
+ );
3149
3449
  process.exit(1);
3150
3450
  }
3151
3451
  i++;
@@ -3166,9 +3466,10 @@ function parseAppArgs(args) {
3166
3466
  } else if (args[i] === "--app-port") {
3167
3467
  i++;
3168
3468
  appPort = parseAppPort(args[i]);
3469
+ } else if (applyTailscaleFlag(args[i])) {
3169
3470
  } else {
3170
3471
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3171
- console.error(colors_default.blue("Known flags: --force, --app-port"));
3472
+ console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
3172
3473
  process.exit(1);
3173
3474
  }
3174
3475
  i++;
@@ -3184,9 +3485,10 @@ function parseAppArgs(args) {
3184
3485
  } else if (args[i] === "--app-port") {
3185
3486
  i++;
3186
3487
  appPort = parseAppPort(args[i]);
3488
+ } else if (applyTailscaleFlag(args[i])) {
3187
3489
  } else {
3188
3490
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3189
- console.error(colors_default.blue("Known flags: --force, --app-port"));
3491
+ console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
3190
3492
  process.exit(1);
3191
3493
  }
3192
3494
  i++;
@@ -3219,6 +3521,7 @@ ${colors_default.bold("Usage:")}
3219
3521
  ${colors_default.cyan("portless list")} Show active routes
3220
3522
  ${colors_default.cyan("portless trust")} Add local CA to system trust store
3221
3523
  ${colors_default.cyan("portless clean")} Remove portless state, trust entry, and hosts block
3524
+ ${colors_default.cyan("portless prune")} Kill orphaned dev servers from crashed sessions
3222
3525
  ${colors_default.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
3223
3526
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
3224
3527
 
@@ -3230,6 +3533,8 @@ ${colors_default.bold("Examples:")}
3230
3533
  portless run next dev # -> https://<project>.localhost
3231
3534
  portless run next dev # in worktree -> https://<worktree>.<project>.localhost
3232
3535
  portless get backend # -> https://backend.localhost
3536
+ portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
3537
+ portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
3233
3538
 
3234
3539
  ${colors_default.bold("Configuration (portless.json):")}
3235
3540
  Optional. Portless works out of the box by running the "dev" script
@@ -3280,6 +3585,15 @@ ${colors_default.bold("LAN mode:")}
3280
3585
  ${colors_default.cyan("portless proxy start --lan --https")}
3281
3586
  ${colors_default.cyan("portless proxy start --lan --ip 192.168.1.42")}
3282
3587
 
3588
+ ${colors_default.bold("Tailscale sharing:")}
3589
+ Use --tailscale to share your dev server with teammates on your tailnet.
3590
+ Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
3591
+ 8444, etc.) so no basePath configuration is needed.
3592
+ Use --funnel to expose your dev server to the public internet via
3593
+ Tailscale Funnel. Requires Tailscale CLI to be installed and connected.
3594
+ ${colors_default.cyan("portless myapp --tailscale next dev")}
3595
+ ${colors_default.cyan("portless myapp --funnel next dev")}
3596
+
3283
3597
  ${colors_default.bold("Options:")}
3284
3598
  run [--name <name>] <cmd> Infer project name (or override with --name)
3285
3599
  Adds worktree prefix in git worktrees
@@ -3296,6 +3610,8 @@ ${colors_default.bold("Options:")}
3296
3610
  --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
3297
3611
  --wildcard Allow unregistered subdomains to fall back to parent route
3298
3612
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
3613
+ --tailscale Share the app on your Tailscale network (tailnet)
3614
+ --funnel Share the app publicly via Tailscale Funnel
3299
3615
  --force Kill the existing process and take over its route
3300
3616
  --name <name> Use <name> as the app name (bypasses subcommand dispatch)
3301
3617
  -- Stop flag parsing; everything after is passed to the child
@@ -3308,6 +3624,8 @@ ${colors_default.bold("Environment variables:")}
3308
3624
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
3309
3625
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
3310
3626
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
3627
+ PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
3628
+ PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
3311
3629
  PORTLESS_STATE_DIR=<path> Override the state directory
3312
3630
  PORTLESS=0 Run command directly without proxy
3313
3631
 
@@ -3316,6 +3634,7 @@ ${colors_default.bold("Child process environment:")}
3316
3634
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
3317
3635
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
3318
3636
  PORTLESS_LAN Set to 1 when proxy is in LAN mode
3637
+ PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
3319
3638
  NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
3320
3639
 
3321
3640
  ${colors_default.bold("Safari / DNS:")}
@@ -3331,14 +3650,14 @@ ${colors_default.bold("Skip portless:")}
3331
3650
  PORTLESS=0 pnpm dev # Runs command directly without proxy
3332
3651
 
3333
3652
  ${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,
3653
+ run, get, alias, hosts, list, trust, clean, prune, proxy are subcommands and
3654
+ cannot be used as app names directly. Use "portless run" to infer the name,
3336
3655
  or "portless --name <name>" to force any name including reserved ones.
3337
3656
  `);
3338
3657
  process.exit(0);
3339
3658
  }
3340
3659
  function printVersion() {
3341
- console.log("0.11.0");
3660
+ console.log("0.12.0");
3342
3661
  process.exit(0);
3343
3662
  }
3344
3663
  async function handleTrust() {
@@ -3359,7 +3678,7 @@ async function handleTrust() {
3359
3678
  const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
3360
3679
  if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
3361
3680
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
3362
- const sudoResult = spawnSync2(
3681
+ const sudoResult = spawnSync3(
3363
3682
  "sudo",
3364
3683
  [
3365
3684
  "env",
@@ -3415,6 +3734,16 @@ ${colors_default.bold("Options:")}
3415
3734
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
3416
3735
  });
3417
3736
  await stopProxy(store, port, tls2);
3737
+ const routesForClean = store.loadRoutesRaw();
3738
+ for (const route of routesForClean) {
3739
+ if (route.tailscaleHttpsPort) {
3740
+ try {
3741
+ unregisterTailscale(route);
3742
+ console.log(colors_default.green(`Removed tailscale serve on port ${route.tailscaleHttpsPort}.`));
3743
+ } catch {
3744
+ }
3745
+ }
3746
+ }
3418
3747
  const stateDirs = collectStateDirsForCleanup();
3419
3748
  for (const stateDir of stateDirs) {
3420
3749
  const caPath = path8.join(stateDir, "ca.pem");
@@ -3443,7 +3772,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
3443
3772
  console.log(
3444
3773
  colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
3445
3774
  );
3446
- const result = spawnSync2(
3775
+ const result = spawnSync3(
3447
3776
  "sudo",
3448
3777
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
3449
3778
  {
@@ -3464,6 +3793,74 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
3464
3793
  }
3465
3794
  console.log(colors_default.green("Clean finished."));
3466
3795
  }
3796
+ async function handlePrune(args) {
3797
+ if (args[1] === "--help" || args[1] === "-h") {
3798
+ console.log(`
3799
+ ${colors_default.bold("portless prune")} - Kill orphaned dev servers left behind by crashed portless sessions.
3800
+
3801
+ When portless is killed with SIGKILL (kill -9) or crashes, child dev servers
3802
+ may survive and continue holding their ports. This command finds those orphans
3803
+ by checking routes whose owning CLI process is dead but whose port is still in
3804
+ use, then terminates them and cleans up the stale route entries.
3805
+
3806
+ ${colors_default.bold("Usage:")}
3807
+ ${colors_default.cyan("portless prune")}
3808
+ ${colors_default.cyan("portless prune --force")} Send SIGKILL instead of SIGTERM
3809
+
3810
+ ${colors_default.bold("Options:")}
3811
+ --force Send SIGKILL instead of SIGTERM
3812
+ --help, -h Show this help
3813
+ `);
3814
+ process.exit(0);
3815
+ }
3816
+ const forceKill = args.includes("--force");
3817
+ const { dir } = await discoverState();
3818
+ const store = new RouteStore(dir, {
3819
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
3820
+ });
3821
+ const stale = store.pruneStaleRoutes();
3822
+ if (stale.length === 0) {
3823
+ console.log("No orphaned routes found.");
3824
+ return;
3825
+ }
3826
+ for (const route of stale) {
3827
+ if (route.tailscaleHttpsPort) {
3828
+ try {
3829
+ unregisterTailscale(route);
3830
+ console.log(
3831
+ ` ${route.hostname} - removed tailscale serve on port ${route.tailscaleHttpsPort}`
3832
+ );
3833
+ } catch {
3834
+ }
3835
+ }
3836
+ }
3837
+ let killed = 0;
3838
+ for (const route of stale) {
3839
+ const pids = findPidsOnPort(route.port);
3840
+ if (pids.length === 0) {
3841
+ console.log(` ${route.hostname} :${route.port} - route removed (port already free)`);
3842
+ continue;
3843
+ }
3844
+ const signal = forceKill ? "SIGKILL" : "SIGTERM";
3845
+ for (const pid of pids) {
3846
+ try {
3847
+ process.kill(pid, signal);
3848
+ killed++;
3849
+ console.log(` ${route.hostname} :${route.port} - killed PID ${pid} (${signal})`);
3850
+ } catch {
3851
+ console.log(` ${route.hostname} :${route.port} - PID ${pid} already exited`);
3852
+ }
3853
+ }
3854
+ }
3855
+ const routeWord = stale.length === 1 ? "route" : "routes";
3856
+ const procWord = killed === 1 ? "process" : "processes";
3857
+ console.log(
3858
+ colors_default.green(
3859
+ `
3860
+ Pruned ${stale.length} stale ${routeWord}, killed ${killed} orphaned ${procWord}.`
3861
+ )
3862
+ );
3863
+ }
3467
3864
  async function handleList() {
3468
3865
  const { dir, port, tls: tls2 } = await discoverState();
3469
3866
  const store = new RouteStore(dir, {
@@ -3614,7 +4011,7 @@ ${colors_default.bold("Auto-sync:")}
3614
4011
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
3615
4012
  )
3616
4013
  );
3617
- const result = spawnSync2(
4014
+ const result = spawnSync3(
3618
4015
  "sudo",
3619
4016
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
3620
4017
  {
@@ -3667,7 +4064,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
3667
4064
  console.log(
3668
4065
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
3669
4066
  );
3670
- const result = spawnSync2(
4067
+ const result = spawnSync3(
3671
4068
  "sudo",
3672
4069
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
3673
4070
  {
@@ -3958,7 +4355,7 @@ ${colors_default.bold("LAN mode (--lan):")}
3958
4355
  if (!hasExplicitPort) {
3959
4356
  console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
3960
4357
  }
3961
- const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
4358
+ const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
3962
4359
  stdio: "inherit",
3963
4360
  timeout: SUDO_SPAWN_TIMEOUT_MS
3964
4361
  });
@@ -4207,7 +4604,8 @@ function spawnChildProcess(commandArgs, env, cwd) {
4207
4604
  return spawn3(commandArgs[0], commandArgs.slice(1), {
4208
4605
  stdio: ["ignore", "pipe", "pipe"],
4209
4606
  env,
4210
- cwd
4607
+ cwd,
4608
+ ...isWindows ? {} : { detached: true }
4211
4609
  });
4212
4610
  }
4213
4611
  function prefixStream(stream, output, prefix) {
@@ -4480,23 +4878,18 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
4480
4878
  env: {
4481
4879
  ...process.env,
4482
4880
  NODE_OPTIONS: buildNodeOptions()
4483
- }
4881
+ },
4882
+ ...isWindows ? {} : { detached: true }
4484
4883
  });
4485
4884
  const SIGKILL_TIMEOUT_MS = 5e3;
4486
4885
  let cleanedUp = false;
4487
4886
  const cleanup = () => {
4488
4887
  if (cleanedUp) return;
4489
4888
  cleanedUp = true;
4490
- try {
4491
- turboChild.kill("SIGTERM");
4492
- } catch {
4493
- }
4889
+ killTree(turboChild, "SIGTERM");
4494
4890
  setTimeout(() => {
4495
4891
  if (turboChild.exitCode === null && !turboChild.killed) {
4496
- try {
4497
- turboChild.kill("SIGKILL");
4498
- } catch {
4499
- }
4892
+ killTree(turboChild, "SIGKILL");
4500
4893
  }
4501
4894
  }, SIGKILL_TIMEOUT_MS).unref();
4502
4895
  for (const { hostname } of routes) {
@@ -4554,18 +4947,12 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
4554
4947
  if (cleanedUp) return;
4555
4948
  cleanedUp = true;
4556
4949
  for (const child of children) {
4557
- try {
4558
- child.kill("SIGTERM");
4559
- } catch {
4560
- }
4950
+ killTree(child, "SIGTERM");
4561
4951
  }
4562
4952
  setTimeout(() => {
4563
4953
  for (const child of children) {
4564
4954
  if (child.exitCode === null && !child.killed) {
4565
- try {
4566
- child.kill("SIGKILL");
4567
- } catch {
4568
- }
4955
+ killTree(child, "SIGKILL");
4569
4956
  }
4570
4957
  }
4571
4958
  }, SIGKILL_TIMEOUT_MS).unref();
@@ -4740,6 +5127,13 @@ async function main() {
4740
5127
  process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
4741
5128
  process.env.PORTLESS_LAN = "1";
4742
5129
  }
5130
+ if (stripGlobalFlag("--tailscale", false)) {
5131
+ process.env.PORTLESS_TAILSCALE = "1";
5132
+ }
5133
+ if (stripGlobalFlag("--funnel", false)) {
5134
+ process.env.PORTLESS_FUNNEL = "1";
5135
+ process.env.PORTLESS_TAILSCALE = "1";
5136
+ }
4743
5137
  const scriptResult = stripGlobalFlag("--script", true);
4744
5138
  if (scriptResult === false) {
4745
5139
  console.error(colors_default.red("Error: --script requires a script name."));
@@ -4812,6 +5206,10 @@ async function main() {
4812
5206
  await handleClean(args);
4813
5207
  return;
4814
5208
  }
5209
+ if (args[0] === "prune") {
5210
+ await handlePrune(args);
5211
+ return;
5212
+ }
4815
5213
  if (args[0] === "list") {
4816
5214
  await handleList();
4817
5215
  return;
package/dist/index.d.ts CHANGED
@@ -61,6 +61,9 @@ declare const FILE_MODE = 420;
61
61
  declare const DIR_MODE = 493;
62
62
  interface RouteMapping extends RouteInfo {
63
63
  pid: number;
64
+ tailscaleUrl?: string;
65
+ tailscaleHttpsPort?: number;
66
+ tailscaleFunnel?: boolean;
64
67
  }
65
68
  /**
66
69
  * Thrown when a route is already registered by a live process and --force was
@@ -108,6 +111,22 @@ declare class RouteStore {
108
111
  * log it.
109
112
  */
110
113
  addRoute(hostname: string, port: number, pid: number, force?: boolean): number | undefined;
114
+ /**
115
+ * Load all routes from disk without filtering out dead PIDs. Used by
116
+ * `portless prune` to discover stale entries whose owning CLI is gone
117
+ * but whose dev server may still be holding a port.
118
+ */
119
+ loadRoutesRaw(): RouteMapping[];
120
+ /**
121
+ * Remove all route entries whose owning process is dead and persist the
122
+ * result. Returns the removed stale entries so the caller can act on them.
123
+ */
124
+ pruneStaleRoutes(): RouteMapping[];
125
+ /**
126
+ * Update metadata on an existing route entry. Only provided fields are
127
+ * merged; the route must already exist (matched by hostname).
128
+ */
129
+ updateRoute(hostname: string, fields: Partial<Pick<RouteMapping, "tailscaleUrl" | "tailscaleHttpsPort" | "tailscaleFunnel">>): void;
111
130
  removeRoute(hostname: string): void;
112
131
  }
113
132
 
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-3WLVQXFE.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.12.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",