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.
Files changed (3) hide show
  1. package/README.md +13 -2
  2. package/dist/cli.js +446 -52
  3. 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. 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.
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 20+
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((resolve3) => {
1559
+ return new Promise((resolve4) => {
1560
1560
  const server = net.createServer();
1561
1561
  server.listen(port, () => {
1562
- server.close(() => resolve3(true));
1562
+ server.close(() => resolve4(true));
1563
1563
  });
1564
- server.on("error", () => resolve3(false));
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((resolve3) => {
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
- resolve3(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
1594
+ resolve4(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
1595
1595
  }
1596
1596
  );
1597
- req.on("error", () => resolve3(false));
1597
+ req.on("error", () => resolve4(false));
1598
1598
  req.on("timeout", () => {
1599
1599
  req.destroy();
1600
- resolve3(false);
1600
+ resolve4(false);
1601
1601
  });
1602
1602
  req.end();
1603
1603
  });
1604
1604
  }
1605
1605
  function isPortListening(port) {
1606
- return new Promise((resolve3) => {
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
- resolve3(result);
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((resolve3) => setTimeout(resolve3, intervalMs));
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((resolve3, reject) => {
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
- resolve3(addr.address);
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 SERVICE_PORT = getProtocolPort(true);
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(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/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 config = buildProxyStartConfig({
2657
- useHttps: true,
2658
- lanMode: false,
2659
- tld: DEFAULT_TLD,
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: SERVICE_PORT,
2830
+ proxyPort: serviceConfig.proxyPort,
2663
2831
  skipTrust: true
2664
2832
  });
2665
- return [entryScript, "proxy", "start", ...config.args];
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: options.stateDir || defaultStateDir(options.platform, options.userHome),
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: process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home),
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 stopExistingProxy(entryScript, runner) {
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
- SERVICE_PORT.toString()
3231
+ proxyPort.toString()
2911
3232
  ]);
2912
3233
  }
2913
- function prepareTrust(stateDir) {
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
- async function installService(entryScript, runner) {
2939
- requireUnixElevation([entryScript, "service", "install"], runner);
2940
- const spec = currentServiceSpec(entryScript);
2941
- prepareTrust(spec.stateDir);
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 proxyRunning = await isProxyRunning(SERVICE_PORT);
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 { installed: installed2, managerState, proxyRunning, details: spec.plistPath };
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(` Proxy on 443: ${status.proxyRunning ? "responding" : "not responding"}`);
3060
- console.log(` State directory: ${spec.stateDir}`);
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")} Install and start the HTTPS service on port 443
3071
- ${colors_default.cyan("portless service uninstall")} Stop and remove the startup service
3072
- ${colors_default.cyan("portless service status")} Show service and proxy status
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: HTTPS on port 443.
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.0");
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((resolve3) => {
5531
- turboChild.on("exit", (code) => resolve3(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((resolve3) => {
5596
- child.on("exit", () => resolve3());
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.0",
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": ">=20"
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": "^20.11.0",
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",