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 +36 -1
- package/dist/{chunk-6PDLZVDS.js → chunk-3WLVQXFE.js} +77 -1
- package/dist/cli.js +435 -37
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
4491
|
-
turboChild.kill("SIGTERM");
|
|
4492
|
-
} catch {
|
|
4493
|
-
}
|
|
4889
|
+
killTree(turboChild, "SIGTERM");
|
|
4494
4890
|
setTimeout(() => {
|
|
4495
4891
|
if (turboChild.exitCode === null && !turboChild.killed) {
|
|
4496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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