portless 0.13.0 → 0.13.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 +13 -2
- package/dist/cli.js +446 -52
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -255,11 +255,16 @@ Install the proxy as an OS startup service so clean HTTPS URLs are available aft
|
|
|
255
255
|
|
|
256
256
|
```bash
|
|
257
257
|
portless service install
|
|
258
|
+
portless service install --lan
|
|
259
|
+
portless service install --wildcard
|
|
260
|
+
PORTLESS_STATE_DIR=~/.portless-lan PORTLESS_LAN=1 portless service install
|
|
258
261
|
portless service status
|
|
259
262
|
portless service uninstall
|
|
260
263
|
```
|
|
261
264
|
|
|
262
|
-
The service uses portless defaults: HTTPS on port 443 with `.localhost` names.
|
|
265
|
+
The service uses portless defaults unless install options or `PORTLESS_*` environment variables are provided: HTTPS on port 443 with `.localhost` names. `service install` accepts the proxy options you would use with `proxy start`, including `--port`, `--no-tls`, `--lan`, `--ip`, `--tld`, `--wildcard`, `--cert`, and `--key`. Use `--state-dir <path>` or `PORTLESS_STATE_DIR=<path>` to choose where service state and logs are written.
|
|
266
|
+
|
|
267
|
+
The chosen service configuration is written into launchd, systemd, or Task Scheduler and reused after reboot. `portless service status` reports the installed port, HTTPS mode, TLD, LAN mode, wildcard mode, and state directory. macOS and Linux install a root-owned service so port 443 can bind at boot. Windows installs a Task Scheduler startup task that runs as SYSTEM. Installation and removal may require administrator privileges. `portless clean` automatically removes the service.
|
|
263
268
|
|
|
264
269
|
## LAN mode
|
|
265
270
|
|
|
@@ -349,6 +354,8 @@ portless proxy stop # Stop the proxy
|
|
|
349
354
|
|
|
350
355
|
# OS startup service
|
|
351
356
|
portless service install # Start HTTPS proxy when the OS starts
|
|
357
|
+
portless service install --lan # Start service in LAN mode
|
|
358
|
+
portless service install --wildcard # Persist wildcard routing in the service
|
|
352
359
|
portless service status # Show service and proxy status
|
|
353
360
|
portless service uninstall # Remove the startup service
|
|
354
361
|
```
|
|
@@ -366,6 +373,7 @@ portless service uninstall # Remove the startup service
|
|
|
366
373
|
--foreground Run proxy in foreground instead of daemon
|
|
367
374
|
--tld <tld> Use a custom TLD instead of .localhost (e.g. test)
|
|
368
375
|
--wildcard Allow unregistered subdomains to fall back to parent route
|
|
376
|
+
--state-dir <path> Use a custom state directory with service install
|
|
369
377
|
--script <name> Run a specific package.json script (default: dev)
|
|
370
378
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
371
379
|
--tailscale Share the app on your Tailscale network (tailnet)
|
|
@@ -382,6 +390,7 @@ PORTLESS_PORT=<number> Override the default proxy port
|
|
|
382
390
|
PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
|
|
383
391
|
PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
|
|
384
392
|
PORTLESS_LAN=1 Enable LAN mode when set to 1 (auto-detects LAN IP)
|
|
393
|
+
PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
|
|
385
394
|
PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
|
|
386
395
|
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
|
|
387
396
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
|
|
@@ -460,6 +469,8 @@ Portless detects this misconfiguration and responds with `508 Loop Detected` alo
|
|
|
460
469
|
|
|
461
470
|
This repo is a pnpm workspace monorepo using [Turborepo](https://turbo.build). The publishable package lives in `packages/portless/`.
|
|
462
471
|
|
|
472
|
+
Use Node.js 24+ and pnpm 11 for repository development. The `.node-version` file pins the Node major for version managers.
|
|
473
|
+
|
|
463
474
|
```bash
|
|
464
475
|
pnpm install # Install all dependencies
|
|
465
476
|
pnpm build # Build all packages
|
|
@@ -472,6 +483,6 @@ pnpm format # Format all files with Prettier
|
|
|
472
483
|
|
|
473
484
|
## Requirements
|
|
474
485
|
|
|
475
|
-
- Node.js
|
|
486
|
+
- Node.js 24+
|
|
476
487
|
- macOS, Linux, or Windows
|
|
477
488
|
- Tailscale CLI (optional, for `--tailscale` and `--funnel`)
|
package/dist/cli.js
CHANGED
|
@@ -1556,12 +1556,12 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
|
1556
1556
|
throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
|
|
1557
1557
|
}
|
|
1558
1558
|
const tryPort = (port) => {
|
|
1559
|
-
return new Promise((
|
|
1559
|
+
return new Promise((resolve4) => {
|
|
1560
1560
|
const server = net.createServer();
|
|
1561
1561
|
server.listen(port, () => {
|
|
1562
|
-
server.close(() =>
|
|
1562
|
+
server.close(() => resolve4(true));
|
|
1563
1563
|
});
|
|
1564
|
-
server.on("error", () =>
|
|
1564
|
+
server.on("error", () => resolve4(false));
|
|
1565
1565
|
});
|
|
1566
1566
|
};
|
|
1567
1567
|
for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
|
|
@@ -1578,7 +1578,7 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
|
1578
1578
|
throw new Error(`No free port found in range ${minPort}-${maxPort}`);
|
|
1579
1579
|
}
|
|
1580
1580
|
function isProxyRunning(port, tls2 = false) {
|
|
1581
|
-
return new Promise((
|
|
1581
|
+
return new Promise((resolve4) => {
|
|
1582
1582
|
const requestFn = tls2 ? https.request : http.request;
|
|
1583
1583
|
const req = requestFn(
|
|
1584
1584
|
{
|
|
@@ -1591,26 +1591,26 @@ function isProxyRunning(port, tls2 = false) {
|
|
|
1591
1591
|
},
|
|
1592
1592
|
(res) => {
|
|
1593
1593
|
res.resume();
|
|
1594
|
-
|
|
1594
|
+
resolve4(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
|
|
1595
1595
|
}
|
|
1596
1596
|
);
|
|
1597
|
-
req.on("error", () =>
|
|
1597
|
+
req.on("error", () => resolve4(false));
|
|
1598
1598
|
req.on("timeout", () => {
|
|
1599
1599
|
req.destroy();
|
|
1600
|
-
|
|
1600
|
+
resolve4(false);
|
|
1601
1601
|
});
|
|
1602
1602
|
req.end();
|
|
1603
1603
|
});
|
|
1604
1604
|
}
|
|
1605
1605
|
function isPortListening(port) {
|
|
1606
|
-
return new Promise((
|
|
1606
|
+
return new Promise((resolve4) => {
|
|
1607
1607
|
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
1608
1608
|
let settled = false;
|
|
1609
1609
|
const finish = (result) => {
|
|
1610
1610
|
if (settled) return;
|
|
1611
1611
|
settled = true;
|
|
1612
1612
|
socket.destroy();
|
|
1613
|
-
|
|
1613
|
+
resolve4(result);
|
|
1614
1614
|
};
|
|
1615
1615
|
socket.setTimeout(SOCKET_TIMEOUT_MS);
|
|
1616
1616
|
socket.once("connect", () => finish(true));
|
|
@@ -1674,7 +1674,7 @@ function findPidOnPort(port) {
|
|
|
1674
1674
|
}
|
|
1675
1675
|
async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
|
|
1676
1676
|
for (let i = 0; i < maxAttempts; i++) {
|
|
1677
|
-
await new Promise((
|
|
1677
|
+
await new Promise((resolve4) => setTimeout(resolve4, intervalMs));
|
|
1678
1678
|
if (await isProxyRunning(port, tls2)) {
|
|
1679
1679
|
return true;
|
|
1680
1680
|
}
|
|
@@ -1898,7 +1898,7 @@ function isInternalInterface(iname, macStr, internal) {
|
|
|
1898
1898
|
return false;
|
|
1899
1899
|
}
|
|
1900
1900
|
function probeDefaultRouteIPv4() {
|
|
1901
|
-
return new Promise((
|
|
1901
|
+
return new Promise((resolve4, reject) => {
|
|
1902
1902
|
const socket = createSocket({ type: "udp4", reuseAddr: true });
|
|
1903
1903
|
socket.on("error", (error) => {
|
|
1904
1904
|
socket.close();
|
|
@@ -1910,7 +1910,7 @@ function probeDefaultRouteIPv4() {
|
|
|
1910
1910
|
socket.close();
|
|
1911
1911
|
socket.unref();
|
|
1912
1912
|
if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
|
|
1913
|
-
|
|
1913
|
+
resolve4(addr.address);
|
|
1914
1914
|
} else {
|
|
1915
1915
|
reject(new Error("No route to host"));
|
|
1916
1916
|
}
|
|
@@ -2596,11 +2596,24 @@ import * as fs8 from "fs";
|
|
|
2596
2596
|
import * as os2 from "os";
|
|
2597
2597
|
import * as path8 from "path";
|
|
2598
2598
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
2599
|
-
var
|
|
2599
|
+
var DEFAULT_SERVICE_PORT = getProtocolPort(true);
|
|
2600
2600
|
var SERVICE_LABEL = "sh.portless.proxy";
|
|
2601
2601
|
var SYSTEMD_SERVICE = "portless.service";
|
|
2602
2602
|
var WINDOWS_TASK_NAME = "Portless Proxy";
|
|
2603
2603
|
var INTERNAL_ELEVATED_ENV = "PORTLESS_INTERNAL_SERVICE_ELEVATED";
|
|
2604
|
+
var SERVICE_ENV_KEYS = /* @__PURE__ */ new Set(["PORTLESS_SYNC_HOSTS"]);
|
|
2605
|
+
var DEFAULT_SERVICE_CONFIG = {
|
|
2606
|
+
proxyPort: DEFAULT_SERVICE_PORT,
|
|
2607
|
+
useHttps: true,
|
|
2608
|
+
customCertPath: null,
|
|
2609
|
+
customKeyPath: null,
|
|
2610
|
+
lanMode: false,
|
|
2611
|
+
lanIp: null,
|
|
2612
|
+
lanIpExplicit: false,
|
|
2613
|
+
tld: DEFAULT_TLD,
|
|
2614
|
+
useWildcard: false,
|
|
2615
|
+
extraEnv: {}
|
|
2616
|
+
};
|
|
2604
2617
|
function defaultRunner2(command, args, options) {
|
|
2605
2618
|
return spawnSync3(command, args, {
|
|
2606
2619
|
encoding: "utf-8",
|
|
@@ -2619,6 +2632,156 @@ function systemdEscape(value) {
|
|
|
2619
2632
|
function windowsQuote(value) {
|
|
2620
2633
|
return `"${value.replace(/"/g, '\\"')}"`;
|
|
2621
2634
|
}
|
|
2635
|
+
function xmlUnescape(value) {
|
|
2636
|
+
return value.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
|
2637
|
+
}
|
|
2638
|
+
function parseBooleanEnv(value) {
|
|
2639
|
+
if (value === void 0) return null;
|
|
2640
|
+
if (value === "1" || value === "true") return true;
|
|
2641
|
+
if (value === "0" || value === "false") return false;
|
|
2642
|
+
return null;
|
|
2643
|
+
}
|
|
2644
|
+
function parsePortValue(value, source) {
|
|
2645
|
+
const port = parseInt(value, 10);
|
|
2646
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
2647
|
+
throw new Error(`${source} must be a number between 1 and 65535.`);
|
|
2648
|
+
}
|
|
2649
|
+
return port;
|
|
2650
|
+
}
|
|
2651
|
+
function getFlagValue(args, index, flag) {
|
|
2652
|
+
const value = args[index + 1];
|
|
2653
|
+
if (!value || value.startsWith("-")) {
|
|
2654
|
+
throw new Error(`${flag} requires a value.`);
|
|
2655
|
+
}
|
|
2656
|
+
return value;
|
|
2657
|
+
}
|
|
2658
|
+
function resolveServicePath(value) {
|
|
2659
|
+
const expanded = value === "~" ? os2.homedir() : value.startsWith("~/") || value.startsWith("~\\") ? path8.join(os2.homedir(), value.slice(2)) : value;
|
|
2660
|
+
return path8.resolve(expanded);
|
|
2661
|
+
}
|
|
2662
|
+
function normalizeServiceInstallPaths(config) {
|
|
2663
|
+
return {
|
|
2664
|
+
...config,
|
|
2665
|
+
stateDir: config.stateDir ? resolveServicePath(config.stateDir) : void 0,
|
|
2666
|
+
customCertPath: config.customCertPath ? resolveServicePath(config.customCertPath) : null,
|
|
2667
|
+
customKeyPath: config.customKeyPath ? resolveServicePath(config.customKeyPath) : null
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
function collectServiceExtraEnv(env) {
|
|
2671
|
+
const extraEnv = {};
|
|
2672
|
+
for (const key of SERVICE_ENV_KEYS) {
|
|
2673
|
+
const value = env[key];
|
|
2674
|
+
if (value) extraEnv[key] = value;
|
|
2675
|
+
}
|
|
2676
|
+
return extraEnv;
|
|
2677
|
+
}
|
|
2678
|
+
function parseServiceInstallConfig(args, env = process.env, options = {}) {
|
|
2679
|
+
const config = {
|
|
2680
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
2681
|
+
extraEnv: collectServiceExtraEnv(env)
|
|
2682
|
+
};
|
|
2683
|
+
if (env.PORTLESS_STATE_DIR) {
|
|
2684
|
+
config.stateDir = env.PORTLESS_STATE_DIR;
|
|
2685
|
+
}
|
|
2686
|
+
const envHttps = parseBooleanEnv(env.PORTLESS_HTTPS);
|
|
2687
|
+
if (envHttps !== null) {
|
|
2688
|
+
config.useHttps = envHttps;
|
|
2689
|
+
}
|
|
2690
|
+
const envLan = parseBooleanEnv(env.PORTLESS_LAN);
|
|
2691
|
+
if (envLan !== null) {
|
|
2692
|
+
config.lanMode = envLan;
|
|
2693
|
+
}
|
|
2694
|
+
if (env.PORTLESS_LAN_IP) {
|
|
2695
|
+
config.lanMode = true;
|
|
2696
|
+
config.lanIp = env.PORTLESS_LAN_IP;
|
|
2697
|
+
config.lanIpExplicit = true;
|
|
2698
|
+
}
|
|
2699
|
+
if (env.PORTLESS_TLD) {
|
|
2700
|
+
const tld = env.PORTLESS_TLD.trim().toLowerCase();
|
|
2701
|
+
const err = validateTld(tld);
|
|
2702
|
+
if (err) throw new Error(`PORTLESS_TLD: ${err}`);
|
|
2703
|
+
config.tld = tld;
|
|
2704
|
+
}
|
|
2705
|
+
const envWildcard = parseBooleanEnv(env.PORTLESS_WILDCARD);
|
|
2706
|
+
if (envWildcard !== null) {
|
|
2707
|
+
config.useWildcard = envWildcard;
|
|
2708
|
+
}
|
|
2709
|
+
if (env.PORTLESS_PORT) {
|
|
2710
|
+
config.proxyPort = parsePortValue(env.PORTLESS_PORT, "PORTLESS_PORT");
|
|
2711
|
+
} else {
|
|
2712
|
+
config.proxyPort = getProtocolPort(config.useHttps);
|
|
2713
|
+
}
|
|
2714
|
+
const tokens = args[0] === "service" ? args.slice(2) : args;
|
|
2715
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
2716
|
+
const token = tokens[i];
|
|
2717
|
+
switch (token) {
|
|
2718
|
+
case "-p":
|
|
2719
|
+
case "--port":
|
|
2720
|
+
config.proxyPort = parsePortValue(getFlagValue(tokens, i, token), token);
|
|
2721
|
+
i += 1;
|
|
2722
|
+
break;
|
|
2723
|
+
case "--https":
|
|
2724
|
+
config.useHttps = true;
|
|
2725
|
+
break;
|
|
2726
|
+
case "--no-tls":
|
|
2727
|
+
config.useHttps = false;
|
|
2728
|
+
break;
|
|
2729
|
+
case "--lan":
|
|
2730
|
+
config.lanMode = true;
|
|
2731
|
+
break;
|
|
2732
|
+
case "--ip":
|
|
2733
|
+
config.lanMode = true;
|
|
2734
|
+
config.lanIp = getFlagValue(tokens, i, token);
|
|
2735
|
+
config.lanIpExplicit = true;
|
|
2736
|
+
i += 1;
|
|
2737
|
+
break;
|
|
2738
|
+
case "--tld": {
|
|
2739
|
+
const tld = getFlagValue(tokens, i, token).trim().toLowerCase();
|
|
2740
|
+
const err = validateTld(tld);
|
|
2741
|
+
if (err) throw new Error(err);
|
|
2742
|
+
config.tld = tld;
|
|
2743
|
+
i += 1;
|
|
2744
|
+
break;
|
|
2745
|
+
}
|
|
2746
|
+
case "--wildcard":
|
|
2747
|
+
config.useWildcard = true;
|
|
2748
|
+
break;
|
|
2749
|
+
case "--cert":
|
|
2750
|
+
config.customCertPath = getFlagValue(tokens, i, token);
|
|
2751
|
+
config.useHttps = true;
|
|
2752
|
+
i += 1;
|
|
2753
|
+
break;
|
|
2754
|
+
case "--key":
|
|
2755
|
+
config.customKeyPath = getFlagValue(tokens, i, token);
|
|
2756
|
+
config.useHttps = true;
|
|
2757
|
+
i += 1;
|
|
2758
|
+
break;
|
|
2759
|
+
case "--state-dir":
|
|
2760
|
+
config.stateDir = getFlagValue(tokens, i, token);
|
|
2761
|
+
i += 1;
|
|
2762
|
+
break;
|
|
2763
|
+
case "--foreground":
|
|
2764
|
+
case "--skip-trust":
|
|
2765
|
+
if (!options.allowRuntimeFlags) {
|
|
2766
|
+
throw new Error(`Unknown service install option "${token}".`);
|
|
2767
|
+
}
|
|
2768
|
+
break;
|
|
2769
|
+
default:
|
|
2770
|
+
throw new Error(`Unknown service install option "${token}".`);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
if (config.customCertPath && !config.customKeyPath || !config.customCertPath && config.customKeyPath) {
|
|
2774
|
+
throw new Error("--cert and --key must be used together.");
|
|
2775
|
+
}
|
|
2776
|
+
if (!env.PORTLESS_PORT && !tokens.includes("--port") && !tokens.includes("-p")) {
|
|
2777
|
+
config.proxyPort = getProtocolPort(config.useHttps);
|
|
2778
|
+
}
|
|
2779
|
+
if (!config.lanMode) {
|
|
2780
|
+
config.lanIp = null;
|
|
2781
|
+
config.lanIpExplicit = false;
|
|
2782
|
+
}
|
|
2783
|
+
return config;
|
|
2784
|
+
}
|
|
2622
2785
|
function readPasswdHome(username) {
|
|
2623
2786
|
try {
|
|
2624
2787
|
const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
|
|
@@ -2652,22 +2815,40 @@ function resolveUserContext(platform) {
|
|
|
2652
2815
|
username: userInfo2.username
|
|
2653
2816
|
};
|
|
2654
2817
|
}
|
|
2655
|
-
function buildProxyCommand(entryScript) {
|
|
2656
|
-
const
|
|
2657
|
-
useHttps:
|
|
2658
|
-
|
|
2659
|
-
|
|
2818
|
+
function buildProxyCommand(entryScript, serviceConfig) {
|
|
2819
|
+
const proxyConfig = buildProxyStartConfig({
|
|
2820
|
+
useHttps: serviceConfig.useHttps,
|
|
2821
|
+
customCertPath: serviceConfig.customCertPath,
|
|
2822
|
+
customKeyPath: serviceConfig.customKeyPath,
|
|
2823
|
+
lanMode: serviceConfig.lanMode,
|
|
2824
|
+
lanIp: serviceConfig.lanIp,
|
|
2825
|
+
lanIpExplicit: serviceConfig.lanIpExplicit,
|
|
2826
|
+
tld: serviceConfig.tld,
|
|
2827
|
+
useWildcard: serviceConfig.useWildcard,
|
|
2660
2828
|
foreground: true,
|
|
2661
2829
|
includePort: true,
|
|
2662
|
-
proxyPort:
|
|
2830
|
+
proxyPort: serviceConfig.proxyPort,
|
|
2663
2831
|
skipTrust: true
|
|
2664
2832
|
});
|
|
2665
|
-
return [entryScript, "proxy", "start", ...
|
|
2833
|
+
return [entryScript, "proxy", "start", ...proxyConfig.args];
|
|
2666
2834
|
}
|
|
2667
2835
|
function buildServiceEnv(ctx) {
|
|
2668
2836
|
const env = {
|
|
2669
|
-
PORTLESS_STATE_DIR: ctx.stateDir
|
|
2837
|
+
PORTLESS_STATE_DIR: ctx.stateDir,
|
|
2838
|
+
PORTLESS_PORT: ctx.config.proxyPort.toString(),
|
|
2839
|
+
PORTLESS_HTTPS: ctx.config.useHttps ? "1" : "0",
|
|
2840
|
+
PORTLESS_LAN: ctx.config.lanMode ? "1" : "0",
|
|
2841
|
+
PORTLESS_WILDCARD: ctx.config.useWildcard ? "1" : "0",
|
|
2842
|
+
...ctx.config.extraEnv
|
|
2670
2843
|
};
|
|
2844
|
+
if (ctx.config.lanMode && ctx.config.lanIpExplicit && ctx.config.lanIp) {
|
|
2845
|
+
env.PORTLESS_LAN_IP = ctx.config.lanIp;
|
|
2846
|
+
}
|
|
2847
|
+
if (ctx.config.lanMode) {
|
|
2848
|
+
env.PORTLESS_TLD = "local";
|
|
2849
|
+
} else if (ctx.config.tld !== DEFAULT_TLD) {
|
|
2850
|
+
env.PORTLESS_TLD = ctx.config.tld;
|
|
2851
|
+
}
|
|
2671
2852
|
if (ctx.platform === "win32") {
|
|
2672
2853
|
env.USERPROFILE = ctx.user.home;
|
|
2673
2854
|
env.PATH = ctx.pathEnv;
|
|
@@ -2746,11 +2927,21 @@ ${proxyCommand}\r
|
|
|
2746
2927
|
`;
|
|
2747
2928
|
}
|
|
2748
2929
|
function buildServiceSpec(options) {
|
|
2930
|
+
const installConfig = {
|
|
2931
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
2932
|
+
...options.installConfig,
|
|
2933
|
+
extraEnv: options.installConfig?.extraEnv ?? {}
|
|
2934
|
+
};
|
|
2935
|
+
const stateDir = options.stateDir || installConfig.stateDir || defaultStateDir(options.platform, options.userHome);
|
|
2936
|
+
const normalizedConfig = {
|
|
2937
|
+
...installConfig,
|
|
2938
|
+
stateDir
|
|
2939
|
+
};
|
|
2749
2940
|
const ctx = {
|
|
2750
2941
|
platform: options.platform,
|
|
2751
2942
|
nodePath: options.nodePath,
|
|
2752
2943
|
entryScript: options.entryScript,
|
|
2753
|
-
stateDir
|
|
2944
|
+
stateDir,
|
|
2754
2945
|
user: {
|
|
2755
2946
|
home: options.userHome,
|
|
2756
2947
|
uid: options.uid,
|
|
@@ -2758,9 +2949,10 @@ function buildServiceSpec(options) {
|
|
|
2758
2949
|
username: options.username
|
|
2759
2950
|
},
|
|
2760
2951
|
pathEnv: options.pathEnv || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
2761
|
-
programData: options.programData || "C:\\ProgramData"
|
|
2952
|
+
programData: options.programData || "C:\\ProgramData",
|
|
2953
|
+
config: normalizedConfig
|
|
2762
2954
|
};
|
|
2763
|
-
const proxyCommand = buildProxyCommand(ctx.entryScript);
|
|
2955
|
+
const proxyCommand = buildProxyCommand(ctx.entryScript, ctx.config);
|
|
2764
2956
|
if (ctx.platform === "darwin") {
|
|
2765
2957
|
const programArguments = [ctx.nodePath, ...proxyCommand];
|
|
2766
2958
|
return {
|
|
@@ -2769,6 +2961,7 @@ function buildServiceSpec(options) {
|
|
|
2769
2961
|
plistPath: `/Library/LaunchDaemons/${SERVICE_LABEL}.plist`,
|
|
2770
2962
|
plist: buildLaunchdPlist(ctx, programArguments),
|
|
2771
2963
|
stateDir: ctx.stateDir,
|
|
2964
|
+
config: ctx.config,
|
|
2772
2965
|
programArguments
|
|
2773
2966
|
};
|
|
2774
2967
|
}
|
|
@@ -2780,6 +2973,7 @@ function buildServiceSpec(options) {
|
|
|
2780
2973
|
unitPath: `/etc/systemd/system/${SYSTEMD_SERVICE}`,
|
|
2781
2974
|
unit: buildSystemdUnit(ctx, execStart),
|
|
2782
2975
|
stateDir: ctx.stateDir,
|
|
2976
|
+
config: ctx.config,
|
|
2783
2977
|
execStart
|
|
2784
2978
|
};
|
|
2785
2979
|
}
|
|
@@ -2791,6 +2985,7 @@ function buildServiceSpec(options) {
|
|
|
2791
2985
|
platform: "win32",
|
|
2792
2986
|
taskName: WINDOWS_TASK_NAME,
|
|
2793
2987
|
stateDir: ctx.stateDir,
|
|
2988
|
+
config: ctx.config,
|
|
2794
2989
|
scriptDir,
|
|
2795
2990
|
scriptPath,
|
|
2796
2991
|
script,
|
|
@@ -2814,11 +3009,17 @@ function buildServiceSpec(options) {
|
|
|
2814
3009
|
queryArgs: ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]
|
|
2815
3010
|
};
|
|
2816
3011
|
}
|
|
2817
|
-
function currentServiceSpec(entryScript) {
|
|
3012
|
+
function currentServiceSpec(entryScript, installConfig) {
|
|
2818
3013
|
if (!isSupportedPlatform(process.platform)) {
|
|
2819
3014
|
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
2820
3015
|
}
|
|
2821
3016
|
const user = resolveUserContext(process.platform);
|
|
3017
|
+
const stateDir = installConfig?.stateDir || process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home);
|
|
3018
|
+
const config = installConfig ?? {
|
|
3019
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
3020
|
+
stateDir,
|
|
3021
|
+
extraEnv: collectServiceExtraEnv(process.env)
|
|
3022
|
+
};
|
|
2822
3023
|
return buildServiceSpec({
|
|
2823
3024
|
platform: process.platform,
|
|
2824
3025
|
nodePath: process.execPath,
|
|
@@ -2827,11 +3028,131 @@ function currentServiceSpec(entryScript) {
|
|
|
2827
3028
|
uid: user.uid,
|
|
2828
3029
|
gid: user.gid,
|
|
2829
3030
|
username: user.username,
|
|
2830
|
-
stateDir
|
|
3031
|
+
stateDir,
|
|
2831
3032
|
pathEnv: process.env.PATH,
|
|
2832
|
-
programData: process.env.ProgramData
|
|
3033
|
+
programData: process.env.ProgramData,
|
|
3034
|
+
installConfig: config
|
|
2833
3035
|
});
|
|
2834
3036
|
}
|
|
3037
|
+
function parseQuotedWords(input, options = {}) {
|
|
3038
|
+
const words = [];
|
|
3039
|
+
let current = "";
|
|
3040
|
+
let inQuote = false;
|
|
3041
|
+
let inWord = false;
|
|
3042
|
+
const unescapeBackslash = options.unescapeBackslash ?? true;
|
|
3043
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
3044
|
+
const char = input[i];
|
|
3045
|
+
if (char === '"') {
|
|
3046
|
+
inQuote = !inQuote;
|
|
3047
|
+
inWord = true;
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
if (char === "\\" && i + 1 < input.length && (input[i + 1] === '"' || unescapeBackslash && input[i + 1] === "\\")) {
|
|
3051
|
+
current += input[i + 1];
|
|
3052
|
+
inWord = true;
|
|
3053
|
+
i += 1;
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
if (/\s/.test(char) && !inQuote) {
|
|
3057
|
+
if (inWord) {
|
|
3058
|
+
words.push(current);
|
|
3059
|
+
current = "";
|
|
3060
|
+
inWord = false;
|
|
3061
|
+
}
|
|
3062
|
+
continue;
|
|
3063
|
+
}
|
|
3064
|
+
current += char;
|
|
3065
|
+
inWord = true;
|
|
3066
|
+
}
|
|
3067
|
+
if (inWord) {
|
|
3068
|
+
words.push(current);
|
|
3069
|
+
}
|
|
3070
|
+
return words;
|
|
3071
|
+
}
|
|
3072
|
+
function parsePlistStrings(block) {
|
|
3073
|
+
return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) => xmlUnescape(match[1]));
|
|
3074
|
+
}
|
|
3075
|
+
function parsePlistEnv(block) {
|
|
3076
|
+
const env = {};
|
|
3077
|
+
for (const match of block.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
3078
|
+
env[xmlUnescape(match[1])] = xmlUnescape(match[2]);
|
|
3079
|
+
}
|
|
3080
|
+
return env;
|
|
3081
|
+
}
|
|
3082
|
+
function readInstalledServiceSnapshot(spec) {
|
|
3083
|
+
try {
|
|
3084
|
+
if (spec.platform === "darwin") {
|
|
3085
|
+
if (!fs8.existsSync(spec.plistPath)) return null;
|
|
3086
|
+
const plist = fs8.readFileSync(spec.plistPath, "utf-8");
|
|
3087
|
+
const argsBlock = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
3088
|
+
const envBlock = plist.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
|
3089
|
+
if (!argsBlock) return null;
|
|
3090
|
+
return {
|
|
3091
|
+
command: parsePlistStrings(argsBlock[1]),
|
|
3092
|
+
env: envBlock ? parsePlistEnv(envBlock[1]) : {}
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
if (spec.platform === "linux") {
|
|
3096
|
+
if (!fs8.existsSync(spec.unitPath)) return null;
|
|
3097
|
+
const unit = fs8.readFileSync(spec.unitPath, "utf-8");
|
|
3098
|
+
const env2 = {};
|
|
3099
|
+
let command = null;
|
|
3100
|
+
for (const line of unit.split("\n")) {
|
|
3101
|
+
if (line.startsWith("Environment=")) {
|
|
3102
|
+
const entry = line.slice("Environment=".length);
|
|
3103
|
+
const eq = entry.indexOf("=");
|
|
3104
|
+
if (eq > 0) {
|
|
3105
|
+
const key = entry.slice(0, eq);
|
|
3106
|
+
const value = parseQuotedWords(entry.slice(eq + 1))[0] ?? "";
|
|
3107
|
+
env2[key] = value;
|
|
3108
|
+
}
|
|
3109
|
+
} else if (line.startsWith("ExecStart=")) {
|
|
3110
|
+
command = parseQuotedWords(line.slice("ExecStart=".length));
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
return command ? { command, env: env2 } : null;
|
|
3114
|
+
}
|
|
3115
|
+
if (!fs8.existsSync(spec.scriptPath)) return null;
|
|
3116
|
+
const script = fs8.readFileSync(spec.scriptPath, "utf-8");
|
|
3117
|
+
const env = {};
|
|
3118
|
+
let commandLine = null;
|
|
3119
|
+
for (const rawLine of script.split(/\r?\n/)) {
|
|
3120
|
+
const line = rawLine.trim();
|
|
3121
|
+
if (!line || line.toLowerCase() === "@echo off") continue;
|
|
3122
|
+
const envMatch = line.match(/^set "([^=]+)=(.*)"$/);
|
|
3123
|
+
if (envMatch) {
|
|
3124
|
+
env[envMatch[1]] = envMatch[2].replace(/%%/g, "%");
|
|
3125
|
+
continue;
|
|
3126
|
+
}
|
|
3127
|
+
commandLine = line;
|
|
3128
|
+
}
|
|
3129
|
+
return commandLine ? { command: parseQuotedWords(commandLine, { unescapeBackslash: false }), env } : null;
|
|
3130
|
+
} catch {
|
|
3131
|
+
return null;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
function installedConfigFromSnapshot(snapshot, fallback) {
|
|
3135
|
+
const proxyIndex = snapshot.command.findIndex(
|
|
3136
|
+
(arg, index) => arg === "proxy" && snapshot.command[index + 1] === "start"
|
|
3137
|
+
);
|
|
3138
|
+
if (proxyIndex === -1) return null;
|
|
3139
|
+
try {
|
|
3140
|
+
const parsed = parseServiceInstallConfig(
|
|
3141
|
+
["service", "install", ...snapshot.command.slice(proxyIndex + 2)],
|
|
3142
|
+
snapshot.env,
|
|
3143
|
+
{ allowRuntimeFlags: true }
|
|
3144
|
+
);
|
|
3145
|
+
const stateDir = parsed.stateDir || snapshot.env.PORTLESS_STATE_DIR || fallback.stateDir;
|
|
3146
|
+
return { ...parsed, stateDir };
|
|
3147
|
+
} catch {
|
|
3148
|
+
return null;
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
function readInstalledServiceConfig(spec) {
|
|
3152
|
+
const snapshot = readInstalledServiceSnapshot(spec);
|
|
3153
|
+
if (!snapshot) return null;
|
|
3154
|
+
return installedConfigFromSnapshot(snapshot, spec.config);
|
|
3155
|
+
}
|
|
2835
3156
|
function collectPortlessEnvArgs(env = process.env, omit = /* @__PURE__ */ new Set()) {
|
|
2836
3157
|
const envArgs = [];
|
|
2837
3158
|
for (const key of Object.keys(env)) {
|
|
@@ -2901,18 +3222,34 @@ function isPermissionError(err) {
|
|
|
2901
3222
|
const message = err instanceof Error ? err.message : String(err);
|
|
2902
3223
|
return code === "EACCES" || code === "EPERM" || /permission denied|operation not permitted|access is denied/i.test(message);
|
|
2903
3224
|
}
|
|
2904
|
-
function
|
|
3225
|
+
function stopProxyOnPort(entryScript, runner, proxyPort) {
|
|
2905
3226
|
runRequired(runner, process.execPath, [
|
|
2906
3227
|
entryScript,
|
|
2907
3228
|
"proxy",
|
|
2908
3229
|
"stop",
|
|
2909
3230
|
"--port",
|
|
2910
|
-
|
|
3231
|
+
proxyPort.toString()
|
|
2911
3232
|
]);
|
|
2912
3233
|
}
|
|
2913
|
-
function
|
|
3234
|
+
async function stopExistingProxy(entryScript, runner, proxyPort) {
|
|
3235
|
+
const ports = /* @__PURE__ */ new Set();
|
|
3236
|
+
try {
|
|
3237
|
+
const currentState = await discoverState();
|
|
3238
|
+
if (currentState.port !== proxyPort && await isProxyRunning(currentState.port)) {
|
|
3239
|
+
ports.add(currentState.port);
|
|
3240
|
+
}
|
|
3241
|
+
} catch {
|
|
3242
|
+
}
|
|
3243
|
+
ports.add(proxyPort);
|
|
3244
|
+
for (const port of ports) {
|
|
3245
|
+
stopProxyOnPort(entryScript, runner, port);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
function prepareServiceState(stateDir) {
|
|
2914
3249
|
fs8.mkdirSync(stateDir, { recursive: true });
|
|
2915
3250
|
fixOwnership(stateDir);
|
|
3251
|
+
}
|
|
3252
|
+
function prepareTrust(stateDir) {
|
|
2916
3253
|
try {
|
|
2917
3254
|
ensureCerts(stateDir);
|
|
2918
3255
|
} catch (err) {
|
|
@@ -2935,13 +3272,28 @@ ${detail}`
|
|
|
2935
3272
|
}
|
|
2936
3273
|
console.warn(colors_default.yellow("Run `portless trust` if browsers show certificate warnings."));
|
|
2937
3274
|
}
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
const
|
|
2941
|
-
|
|
3275
|
+
function ensureServiceConfigSupported(config) {
|
|
3276
|
+
if (!config.lanMode) return;
|
|
3277
|
+
const mdnsSupport = isMdnsSupported();
|
|
3278
|
+
if (mdnsSupport.supported) return;
|
|
3279
|
+
const reason = mdnsSupport.reason ? `
|
|
3280
|
+
${mdnsSupport.reason}` : "";
|
|
3281
|
+
throw new Error(
|
|
3282
|
+
`LAN mode requires mDNS publishing, which is not supported on this platform.${reason}`
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
async function installService(entryScript, runner, args) {
|
|
3286
|
+
const installConfig = normalizeServiceInstallPaths(parseServiceInstallConfig(args));
|
|
3287
|
+
ensureServiceConfigSupported(installConfig);
|
|
3288
|
+
requireUnixElevation([entryScript, ...args], runner);
|
|
3289
|
+
const spec = currentServiceSpec(entryScript, installConfig);
|
|
3290
|
+
prepareServiceState(spec.stateDir);
|
|
3291
|
+
if (spec.config.useHttps && !spec.config.customCertPath) {
|
|
3292
|
+
prepareTrust(spec.stateDir);
|
|
3293
|
+
}
|
|
2942
3294
|
if (spec.platform === "darwin") {
|
|
2943
3295
|
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
2944
|
-
stopExistingProxy(entryScript, runner);
|
|
3296
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
2945
3297
|
fs8.writeFileSync(spec.plistPath, spec.plist);
|
|
2946
3298
|
fs8.chmodSync(spec.plistPath, 420);
|
|
2947
3299
|
runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
|
|
@@ -2950,7 +3302,7 @@ async function installService(entryScript, runner) {
|
|
|
2950
3302
|
runRequired(runner, "launchctl", ["kickstart", "-k", `system/${spec.label}`]);
|
|
2951
3303
|
} else if (spec.platform === "linux") {
|
|
2952
3304
|
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
2953
|
-
stopExistingProxy(entryScript, runner);
|
|
3305
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
2954
3306
|
fs8.writeFileSync(spec.unitPath, spec.unit);
|
|
2955
3307
|
fs8.chmodSync(spec.unitPath, 420);
|
|
2956
3308
|
runRequired(runner, "systemctl", ["daemon-reload"]);
|
|
@@ -2958,7 +3310,7 @@ async function installService(entryScript, runner) {
|
|
|
2958
3310
|
runRequired(runner, "systemctl", ["restart", spec.serviceName]);
|
|
2959
3311
|
} else {
|
|
2960
3312
|
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
2961
|
-
stopExistingProxy(entryScript, runner);
|
|
3313
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
2962
3314
|
fs8.mkdirSync(spec.scriptDir, { recursive: true });
|
|
2963
3315
|
fs8.writeFileSync(spec.scriptPath, spec.script);
|
|
2964
3316
|
runRequired(runner, "schtasks", spec.createArgs);
|
|
@@ -2966,6 +3318,7 @@ async function installService(entryScript, runner) {
|
|
|
2966
3318
|
}
|
|
2967
3319
|
console.log(colors_default.green("Portless service installed."));
|
|
2968
3320
|
console.log(colors_default.gray(`State directory: ${spec.stateDir}`));
|
|
3321
|
+
console.log(colors_default.gray(`Proxy port: ${spec.config.proxyPort}`));
|
|
2969
3322
|
}
|
|
2970
3323
|
async function uninstallService(entryScript, runner) {
|
|
2971
3324
|
requireUnixElevation([entryScript, "service", "uninstall"], runner);
|
|
@@ -3019,13 +3372,20 @@ function tryUninstallService(entryScript, runner = defaultRunner2) {
|
|
|
3019
3372
|
}
|
|
3020
3373
|
async function getServiceStatus(entryScript, runner) {
|
|
3021
3374
|
const spec = currentServiceSpec(entryScript);
|
|
3022
|
-
const
|
|
3375
|
+
const installedConfig = readInstalledServiceConfig(spec) ?? spec.config;
|
|
3376
|
+
const proxyRunning = await isProxyRunning(installedConfig.proxyPort, installedConfig.useHttps);
|
|
3023
3377
|
if (spec.platform === "darwin") {
|
|
3024
3378
|
const installed2 = fs8.existsSync(spec.plistPath);
|
|
3025
3379
|
const result = runner("launchctl", ["print", `system/${spec.label}`]);
|
|
3026
3380
|
const output2 = `${result.stdout || ""}${result.stderr || ""}`;
|
|
3027
3381
|
const managerState = result.status === 0 && /state = running|pid = \d+/.test(output2) ? "running" : installed2 ? "installed" : "not installed";
|
|
3028
|
-
return {
|
|
3382
|
+
return {
|
|
3383
|
+
installed: installed2,
|
|
3384
|
+
managerState,
|
|
3385
|
+
proxyRunning,
|
|
3386
|
+
config: installedConfig,
|
|
3387
|
+
details: spec.plistPath
|
|
3388
|
+
};
|
|
3029
3389
|
}
|
|
3030
3390
|
if (spec.platform === "linux") {
|
|
3031
3391
|
const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
|
|
@@ -3036,6 +3396,7 @@ async function getServiceStatus(entryScript, runner) {
|
|
|
3036
3396
|
installed: installed2,
|
|
3037
3397
|
managerState: active.status === 0 ? activeText || "active" : installed2 ? "installed" : "not installed",
|
|
3038
3398
|
proxyRunning,
|
|
3399
|
+
config: installedConfig,
|
|
3039
3400
|
details: spec.unitPath
|
|
3040
3401
|
};
|
|
3041
3402
|
}
|
|
@@ -3047,17 +3408,27 @@ async function getServiceStatus(entryScript, runner) {
|
|
|
3047
3408
|
installed,
|
|
3048
3409
|
managerState: installed ? stateMatch?.[1]?.trim() || "installed" : "not installed",
|
|
3049
3410
|
proxyRunning,
|
|
3411
|
+
config: installedConfig,
|
|
3050
3412
|
details: spec.taskName
|
|
3051
3413
|
};
|
|
3052
3414
|
}
|
|
3053
3415
|
async function printServiceStatus(entryScript, runner) {
|
|
3054
|
-
const spec = currentServiceSpec(entryScript);
|
|
3055
3416
|
const status = await getServiceStatus(entryScript, runner);
|
|
3417
|
+
const config = status.config;
|
|
3056
3418
|
console.log(colors_default.bold("portless service"));
|
|
3057
3419
|
console.log(` Manager state: ${status.managerState}`);
|
|
3058
3420
|
console.log(` Installed: ${status.installed ? "yes" : "no"}`);
|
|
3059
|
-
console.log(
|
|
3060
|
-
|
|
3421
|
+
console.log(
|
|
3422
|
+
` Proxy on ${config.proxyPort}: ${status.proxyRunning ? "responding" : "not responding"}`
|
|
3423
|
+
);
|
|
3424
|
+
console.log(` HTTPS: ${config.useHttps ? "yes" : "no"}`);
|
|
3425
|
+
console.log(` TLD: ${config.lanMode ? "local" : config.tld}`);
|
|
3426
|
+
console.log(` LAN mode: ${config.lanMode ? "yes" : "no"}`);
|
|
3427
|
+
if (config.lanIpExplicit && config.lanIp) {
|
|
3428
|
+
console.log(` LAN IP: ${config.lanIp}`);
|
|
3429
|
+
}
|
|
3430
|
+
console.log(` Wildcard: ${config.useWildcard ? "yes" : "no"}`);
|
|
3431
|
+
console.log(` State directory: ${config.stateDir}`);
|
|
3061
3432
|
if (status.details) {
|
|
3062
3433
|
console.log(` Service entry: ${status.details}`);
|
|
3063
3434
|
}
|
|
@@ -3067,12 +3438,27 @@ function printServiceHelp() {
|
|
|
3067
3438
|
${colors_default.bold("portless service")} - Start portless automatically when the OS starts.
|
|
3068
3439
|
|
|
3069
3440
|
${colors_default.bold("Usage:")}
|
|
3070
|
-
${colors_default.cyan("portless service install")}
|
|
3071
|
-
${colors_default.cyan("portless service
|
|
3072
|
-
${colors_default.cyan("portless service
|
|
3441
|
+
${colors_default.cyan("portless service install")} Install and start the HTTPS service on port 443
|
|
3442
|
+
${colors_default.cyan("portless service install --lan")} Enable LAN mode for the startup service
|
|
3443
|
+
${colors_default.cyan("portless service install -p 8443")} Use a custom proxy port
|
|
3444
|
+
${colors_default.cyan("portless service uninstall")} Stop and remove the startup service
|
|
3445
|
+
${colors_default.cyan("portless service status")} Show service and proxy status
|
|
3446
|
+
|
|
3447
|
+
${colors_default.bold("Install options:")}
|
|
3448
|
+
-p, --port <number> Port for the proxy service
|
|
3449
|
+
--no-tls Disable HTTPS
|
|
3450
|
+
--https Enable HTTPS
|
|
3451
|
+
--lan Enable LAN mode
|
|
3452
|
+
--ip <address> Pin a specific LAN IP
|
|
3453
|
+
--tld <tld> Use a custom TLD outside LAN mode
|
|
3454
|
+
--wildcard Allow subdomain fallback
|
|
3455
|
+
--cert <path> Use a custom TLS certificate
|
|
3456
|
+
--key <path> Use a custom TLS private key
|
|
3457
|
+
--state-dir <path> Use a custom service state directory
|
|
3073
3458
|
|
|
3074
3459
|
${colors_default.bold("Notes:")}
|
|
3075
|
-
The service uses the default clean URL mode
|
|
3460
|
+
The service uses the default clean URL mode unless options or PORTLESS_*
|
|
3461
|
+
environment variables are provided during install.
|
|
3076
3462
|
macOS and Linux install a root-owned service so port 443 can bind at boot.
|
|
3077
3463
|
Windows installs a Task Scheduler startup task that runs as SYSTEM.
|
|
3078
3464
|
`);
|
|
@@ -3086,7 +3472,7 @@ async function handleService(args, options) {
|
|
|
3086
3472
|
}
|
|
3087
3473
|
try {
|
|
3088
3474
|
if (action === "install") {
|
|
3089
|
-
await installService(options.entryScript, runner);
|
|
3475
|
+
await installService(options.entryScript, runner, args);
|
|
3090
3476
|
return;
|
|
3091
3477
|
}
|
|
3092
3478
|
if (action === "uninstall") {
|
|
@@ -4121,6 +4507,9 @@ ${colors_default.bold("Install:")}
|
|
|
4121
4507
|
${colors_default.cyan("npm install -g portless")} Global (recommended)
|
|
4122
4508
|
${colors_default.cyan("npm install -D portless")} Project dev dependency
|
|
4123
4509
|
|
|
4510
|
+
${colors_default.bold("Requirements:")}
|
|
4511
|
+
Node.js 24+
|
|
4512
|
+
|
|
4124
4513
|
${colors_default.bold("Usage:")}
|
|
4125
4514
|
${colors_default.cyan("portless")} Run dev script through proxy
|
|
4126
4515
|
${colors_default.cyan("portless")} From monorepo root: run all workspace packages
|
|
@@ -4148,6 +4537,8 @@ ${colors_default.bold("Examples:")}
|
|
|
4148
4537
|
portless run next dev # -> https://<project>.localhost
|
|
4149
4538
|
portless run next dev # in worktree -> https://<worktree>.<project>.localhost
|
|
4150
4539
|
portless service install # Start HTTPS proxy on OS startup
|
|
4540
|
+
portless service install --lan # Persist LAN mode in the startup service
|
|
4541
|
+
portless service install --wildcard # Persist wildcard routing in the startup service
|
|
4151
4542
|
portless get backend # -> https://backend.localhost
|
|
4152
4543
|
portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
|
|
4153
4544
|
portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
|
|
@@ -4227,6 +4618,7 @@ ${colors_default.bold("Options:")}
|
|
|
4227
4618
|
--foreground Run proxy in foreground (for debugging)
|
|
4228
4619
|
--tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
|
|
4229
4620
|
--wildcard Allow unregistered subdomains to fall back to parent route
|
|
4621
|
+
--state-dir <path> Use a custom state directory with service install
|
|
4230
4622
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
4231
4623
|
--tailscale Share the app on your Tailscale network (tailnet)
|
|
4232
4624
|
--funnel Share the app publicly via Tailscale Funnel
|
|
@@ -4239,6 +4631,7 @@ ${colors_default.bold("Environment variables:")}
|
|
|
4239
4631
|
PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
|
|
4240
4632
|
PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
|
|
4241
4633
|
PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
|
|
4634
|
+
PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
|
|
4242
4635
|
PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
|
|
4243
4636
|
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
|
|
4244
4637
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
|
|
@@ -4275,7 +4668,7 @@ ${colors_default.bold("Reserved names:")}
|
|
|
4275
4668
|
process.exit(0);
|
|
4276
4669
|
}
|
|
4277
4670
|
function printVersion() {
|
|
4278
|
-
console.log("0.13.
|
|
4671
|
+
console.log("0.13.1");
|
|
4279
4672
|
process.exit(0);
|
|
4280
4673
|
}
|
|
4281
4674
|
async function handleTrust() {
|
|
@@ -5527,8 +5920,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
5527
5920
|
};
|
|
5528
5921
|
process.on("SIGINT", cleanup);
|
|
5529
5922
|
process.on("SIGTERM", cleanup);
|
|
5530
|
-
const exitCode = await new Promise((
|
|
5531
|
-
turboChild.on("exit", (code) =>
|
|
5923
|
+
const exitCode = await new Promise((resolve4) => {
|
|
5924
|
+
turboChild.on("exit", (code) => resolve4(code));
|
|
5532
5925
|
});
|
|
5533
5926
|
cleanup();
|
|
5534
5927
|
if (exitCode !== 0 && exitCode !== null) {
|
|
@@ -5592,8 +5985,8 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
|
|
|
5592
5985
|
process.on("SIGTERM", cleanup);
|
|
5593
5986
|
await Promise.all(
|
|
5594
5987
|
children.map(
|
|
5595
|
-
(child) => new Promise((
|
|
5596
|
-
child.on("exit", () =>
|
|
5988
|
+
(child) => new Promise((resolve4) => {
|
|
5989
|
+
child.on("exit", () => resolve4());
|
|
5597
5990
|
})
|
|
5598
5991
|
)
|
|
5599
5992
|
);
|
|
@@ -5680,6 +6073,7 @@ async function handleNamedMode(args) {
|
|
|
5680
6073
|
}
|
|
5681
6074
|
}
|
|
5682
6075
|
const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
|
|
6076
|
+
parseHostname(safeName, DEFAULT_TLD);
|
|
5683
6077
|
const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
|
|
5684
6078
|
const store = new RouteStore(dir, {
|
|
5685
6079
|
onWarning: (msg) => console.warn(colors_default.yellow(msg))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portless",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dist"
|
|
19
19
|
],
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
21
|
+
"node": ">=24"
|
|
22
22
|
},
|
|
23
23
|
"os": [
|
|
24
24
|
"darwin",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"url": "https://github.com/vercel-labs/portless/issues"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@types/node": "^
|
|
56
|
+
"@types/node": "^24.12.4",
|
|
57
57
|
"@vitest/coverage-v8": "^4.0.18",
|
|
58
58
|
"eslint": "^9.39.2",
|
|
59
59
|
"tsup": "^8.0.1",
|