portless 0.12.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 +33 -3
  2. package/dist/cli.js +1124 -101
  3. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -39,9 +39,9 @@ var gray = dim;
39
39
  var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
40
40
 
41
41
  // src/cli.ts
42
- import * as fs8 from "fs";
43
- import * as path8 from "path";
44
- import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
42
+ import * as fs9 from "fs";
43
+ import * as path9 from "path";
44
+ import { spawn as spawn3, spawnSync as spawnSync4 } from "child_process";
45
45
  import { StringDecoder } from "string_decoder";
46
46
 
47
47
  // src/certs.ts
@@ -757,10 +757,15 @@ function untrustCAWindows(caCertPath) {
757
757
  // src/tailscale.ts
758
758
  import { spawnSync } from "child_process";
759
759
  var TAILSCALE_BINARY = "tailscale";
760
+ var TAILSCALE_COMMAND_TIMEOUT_MS = 3e4;
760
761
  var PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
761
762
  var FUNNEL_PORTS = [443, 8443, 1e4];
762
763
  function defaultRunner(args) {
763
- const result = spawnSync(TAILSCALE_BINARY, args, { encoding: "utf-8" });
764
+ const result = spawnSync(TAILSCALE_BINARY, args, {
765
+ encoding: "utf-8",
766
+ killSignal: "SIGKILL",
767
+ timeout: TAILSCALE_COMMAND_TIMEOUT_MS
768
+ });
764
769
  return {
765
770
  status: result.status,
766
771
  stdout: result.stdout ?? "",
@@ -812,11 +817,52 @@ function statusToDnsName(status) {
812
817
  "Could not determine Tailscale node DNS name from `tailscale status --json`. Is Tailscale connected?"
813
818
  );
814
819
  }
815
- function ensureTailscaleReady(runner = defaultRunner) {
820
+ function isFunnelCapability(value) {
821
+ const normalized = value.toLowerCase();
822
+ return normalized === "funnel" || normalized.endsWith("/funnel");
823
+ }
824
+ function isHttpsCapability(value) {
825
+ const normalized = value.toLowerCase();
826
+ return normalized === "https" || normalized.endsWith("/https");
827
+ }
828
+ function hasCapability(status, predicate) {
829
+ const capabilities = status.Self?.Capabilities;
830
+ if (Array.isArray(capabilities) && capabilities.some(predicate)) {
831
+ return true;
832
+ }
833
+ const capMap = status.Self?.CapMap;
834
+ return Boolean(capMap && Object.keys(capMap).some(predicate));
835
+ }
836
+ function hasHttpsCapability(status) {
837
+ return hasCapability(status, isHttpsCapability);
838
+ }
839
+ function hasFunnelCapability(status) {
840
+ return hasCapability(status, isFunnelCapability);
841
+ }
842
+ function throwHttpsNotEnabled() {
843
+ throw new Error(
844
+ "Tailscale HTTPS is not enabled on your tailnet. Enable HTTPS certificates in Tailscale DNS settings, then run portless again."
845
+ );
846
+ }
847
+ function throwFunnelNotEnabled(status) {
848
+ const nodeId = status.Self?.ID;
849
+ const enableUrl = typeof nodeId === "string" && nodeId.length > 0 ? ` Visit https://login.tailscale.com/f/funnel?node=${nodeId} to enable it.` : "";
850
+ throw new Error(
851
+ "Tailscale Funnel is not enabled on your tailnet. Enable Funnel for this node, then run portless again." + enableUrl
852
+ );
853
+ }
854
+ function ensureTailscaleReady(options = {}) {
855
+ const runner = options.runner ?? defaultRunner;
816
856
  runOrThrow(["version"], "check tailscale version", runner);
817
857
  const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
818
858
  const status = parseStatusJson(statusResult.stdout);
819
859
  const dnsName = statusToDnsName(status);
860
+ if (options.requireHttps && !hasHttpsCapability(status)) {
861
+ throwHttpsNotEnabled();
862
+ }
863
+ if (options.requireFunnel && !hasFunnelCapability(status)) {
864
+ throwFunnelNotEnabled(status);
865
+ }
820
866
  return {
821
867
  dnsName,
822
868
  baseUrl: `https://${dnsName}`
@@ -868,6 +914,16 @@ function isConflictError(stderr, stdout) {
868
914
  ${stdout}`.toLowerCase();
869
915
  return text.includes("already in use") || text.includes("already exists") || text.includes("port conflict") || text.includes("address already");
870
916
  }
917
+ function isFunnelNotEnabledError(stderr, stdout) {
918
+ const text = `${stderr}
919
+ ${stdout}`.toLowerCase();
920
+ return text.includes("funnel is not enabled on your tailnet");
921
+ }
922
+ function formatFunnelNotEnabledError(stderr, stdout) {
923
+ const details = normalizeSpace(`${stderr}
924
+ ${stdout}`);
925
+ return "Tailscale Funnel is not enabled on your tailnet. Enable Funnel for this node, then run portless again." + (details ? ` Tailscale said: ${details}` : "");
926
+ }
871
927
  var CONFLICT_MESSAGES = {
872
928
  serve: "Stop the existing serve or let portless auto-assign a different port.",
873
929
  funnel: "Tailscale Funnel supports ports 443, 8443, and 10000."
@@ -882,9 +938,20 @@ function register(mode, localPort, httpsPort, runner) {
882
938
  "Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
883
939
  );
884
940
  }
941
+ if (mode === "funnel" && isFunnelNotEnabledError(result.stderr, result.stdout)) {
942
+ throw new Error(formatFunnelNotEnabledError(result.stderr, result.stdout));
943
+ }
944
+ if (mode === "funnel" && errno.code === "ETIMEDOUT") {
945
+ throw new Error(
946
+ "Tailscale Funnel registration timed out. Make sure Funnel is enabled on your tailnet, then run portless again."
947
+ );
948
+ }
885
949
  throw new Error(`Failed to register tailscale ${mode}: ${result.error.message}`);
886
950
  }
887
951
  if (result.status !== 0) {
952
+ if (mode === "funnel" && isFunnelNotEnabledError(result.stderr, result.stdout)) {
953
+ throw new Error(formatFunnelNotEnabledError(result.stderr, result.stdout));
954
+ }
888
955
  if (isConflictError(result.stderr, result.stdout)) {
889
956
  throw new Error(
890
957
  `Tailscale ${mode === "funnel" ? "Funnel " : ""}HTTPS port ${httpsPort} is already in use. ` + CONFLICT_MESSAGES[mode]
@@ -1489,12 +1556,12 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
1489
1556
  throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
1490
1557
  }
1491
1558
  const tryPort = (port) => {
1492
- return new Promise((resolve3) => {
1559
+ return new Promise((resolve4) => {
1493
1560
  const server = net.createServer();
1494
1561
  server.listen(port, () => {
1495
- server.close(() => resolve3(true));
1562
+ server.close(() => resolve4(true));
1496
1563
  });
1497
- server.on("error", () => resolve3(false));
1564
+ server.on("error", () => resolve4(false));
1498
1565
  });
1499
1566
  };
1500
1567
  for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
@@ -1511,7 +1578,7 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
1511
1578
  throw new Error(`No free port found in range ${minPort}-${maxPort}`);
1512
1579
  }
1513
1580
  function isProxyRunning(port, tls2 = false) {
1514
- return new Promise((resolve3) => {
1581
+ return new Promise((resolve4) => {
1515
1582
  const requestFn = tls2 ? https.request : http.request;
1516
1583
  const req = requestFn(
1517
1584
  {
@@ -1524,26 +1591,26 @@ function isProxyRunning(port, tls2 = false) {
1524
1591
  },
1525
1592
  (res) => {
1526
1593
  res.resume();
1527
- resolve3(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
1594
+ resolve4(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
1528
1595
  }
1529
1596
  );
1530
- req.on("error", () => resolve3(false));
1597
+ req.on("error", () => resolve4(false));
1531
1598
  req.on("timeout", () => {
1532
1599
  req.destroy();
1533
- resolve3(false);
1600
+ resolve4(false);
1534
1601
  });
1535
1602
  req.end();
1536
1603
  });
1537
1604
  }
1538
1605
  function isPortListening(port) {
1539
- return new Promise((resolve3) => {
1606
+ return new Promise((resolve4) => {
1540
1607
  const socket = net.createConnection({ host: "127.0.0.1", port });
1541
1608
  let settled = false;
1542
1609
  const finish = (result) => {
1543
1610
  if (settled) return;
1544
1611
  settled = true;
1545
1612
  socket.destroy();
1546
- resolve3(result);
1613
+ resolve4(result);
1547
1614
  };
1548
1615
  socket.setTimeout(SOCKET_TIMEOUT_MS);
1549
1616
  socket.once("connect", () => finish(true));
@@ -1607,7 +1674,7 @@ function findPidOnPort(port) {
1607
1674
  }
1608
1675
  async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
1609
1676
  for (let i = 0; i < maxAttempts; i++) {
1610
- await new Promise((resolve3) => setTimeout(resolve3, intervalMs));
1677
+ await new Promise((resolve4) => setTimeout(resolve4, intervalMs));
1611
1678
  if (await isProxyRunning(port, tls2)) {
1612
1679
  return true;
1613
1680
  }
@@ -1831,7 +1898,7 @@ function isInternalInterface(iname, macStr, internal) {
1831
1898
  return false;
1832
1899
  }
1833
1900
  function probeDefaultRouteIPv4() {
1834
- return new Promise((resolve3, reject) => {
1901
+ return new Promise((resolve4, reject) => {
1835
1902
  const socket = createSocket({ type: "udp4", reuseAddr: true });
1836
1903
  socket.on("error", (error) => {
1837
1904
  socket.close();
@@ -1843,7 +1910,7 @@ function probeDefaultRouteIPv4() {
1843
1910
  socket.close();
1844
1911
  socket.unref();
1845
1912
  if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
1846
- resolve3(addr.address);
1913
+ resolve4(addr.address);
1847
1914
  } else {
1848
1915
  reject(new Error("No route to host"));
1849
1916
  }
@@ -2524,6 +2591,908 @@ function hasTurboConfig(wsRoot) {
2524
2591
  }
2525
2592
  }
2526
2593
 
2594
+ // src/service.ts
2595
+ import * as fs8 from "fs";
2596
+ import * as os2 from "os";
2597
+ import * as path8 from "path";
2598
+ import { spawnSync as spawnSync3 } from "child_process";
2599
+ var DEFAULT_SERVICE_PORT = getProtocolPort(true);
2600
+ var SERVICE_LABEL = "sh.portless.proxy";
2601
+ var SYSTEMD_SERVICE = "portless.service";
2602
+ var WINDOWS_TASK_NAME = "Portless Proxy";
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
+ };
2617
+ function defaultRunner2(command, args, options) {
2618
+ return spawnSync3(command, args, {
2619
+ encoding: "utf-8",
2620
+ stdio: options?.stdio ?? "pipe"
2621
+ });
2622
+ }
2623
+ function isSupportedPlatform(platform) {
2624
+ return platform === "darwin" || platform === "linux" || platform === "win32";
2625
+ }
2626
+ function xmlEscape(value) {
2627
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2628
+ }
2629
+ function systemdEscape(value) {
2630
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2631
+ }
2632
+ function windowsQuote(value) {
2633
+ return `"${value.replace(/"/g, '\\"')}"`;
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
+ }
2785
+ function readPasswdHome(username) {
2786
+ try {
2787
+ const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
2788
+ for (const line of passwd.split("\n")) {
2789
+ const fields = line.split(":");
2790
+ if (fields[0] === username && fields[5]) {
2791
+ return fields[5];
2792
+ }
2793
+ }
2794
+ } catch {
2795
+ }
2796
+ return null;
2797
+ }
2798
+ function resolveUserContext(platform) {
2799
+ if (platform === "win32") {
2800
+ const home = process.env.USERPROFILE || os2.homedir();
2801
+ return { home, username: process.env.USERNAME };
2802
+ }
2803
+ const sudoUser = process.env.SUDO_USER;
2804
+ const sudoUid = process.env.SUDO_UID;
2805
+ const sudoGid = process.env.SUDO_GID;
2806
+ if (sudoUser && sudoUser !== "root") {
2807
+ const home = process.env.HOME && process.env.HOME !== "/var/root" && process.env.HOME !== "/root" ? process.env.HOME : readPasswdHome(sudoUser) || (platform === "darwin" ? path8.posix.join("/Users", sudoUser) : path8.posix.join("/home", sudoUser));
2808
+ return { home, uid: sudoUid, gid: sudoGid, username: sudoUser };
2809
+ }
2810
+ const userInfo2 = os2.userInfo();
2811
+ return {
2812
+ home: os2.homedir(),
2813
+ uid: process.getuid?.()?.toString(),
2814
+ gid: process.getgid?.()?.toString(),
2815
+ username: userInfo2.username
2816
+ };
2817
+ }
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,
2828
+ foreground: true,
2829
+ includePort: true,
2830
+ proxyPort: serviceConfig.proxyPort,
2831
+ skipTrust: true
2832
+ });
2833
+ return [entryScript, "proxy", "start", ...proxyConfig.args];
2834
+ }
2835
+ function buildServiceEnv(ctx) {
2836
+ const env = {
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
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
+ }
2852
+ if (ctx.platform === "win32") {
2853
+ env.USERPROFILE = ctx.user.home;
2854
+ env.PATH = ctx.pathEnv;
2855
+ } else {
2856
+ env.HOME = ctx.user.home;
2857
+ if (ctx.user.uid) env.SUDO_UID = ctx.user.uid;
2858
+ if (ctx.user.gid) env.SUDO_GID = ctx.user.gid;
2859
+ }
2860
+ return env;
2861
+ }
2862
+ function defaultStateDir(platform, userHome) {
2863
+ return platform === "win32" ? path8.win32.join(userHome, ".portless") : path8.posix.join(userHome, ".portless");
2864
+ }
2865
+ function buildLaunchdPlist(ctx, programArguments) {
2866
+ const env = buildServiceEnv(ctx);
2867
+ const envEntries = Object.entries(env).map(
2868
+ ([key, value]) => ` <key>${xmlEscape(key)}</key>
2869
+ <string>${xmlEscape(value)}</string>`
2870
+ ).join("\n");
2871
+ const args = programArguments.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n");
2872
+ return `<?xml version="1.0" encoding="UTF-8"?>
2873
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2874
+ <plist version="1.0">
2875
+ <dict>
2876
+ <key>Label</key>
2877
+ <string>${SERVICE_LABEL}</string>
2878
+ <key>ProgramArguments</key>
2879
+ <array>
2880
+ ${args}
2881
+ </array>
2882
+ <key>EnvironmentVariables</key>
2883
+ <dict>
2884
+ ${envEntries}
2885
+ </dict>
2886
+ <key>RunAtLoad</key>
2887
+ <true/>
2888
+ <key>KeepAlive</key>
2889
+ <true/>
2890
+ <key>StandardOutPath</key>
2891
+ <string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
2892
+ <key>StandardErrorPath</key>
2893
+ <string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
2894
+ </dict>
2895
+ </plist>
2896
+ `;
2897
+ }
2898
+ function buildSystemdUnit(ctx, execStart) {
2899
+ const env = buildServiceEnv(ctx);
2900
+ const envLines = Object.entries(env).map(([key, value]) => `Environment=${key}=${systemdEscape(value)}`).join("\n");
2901
+ return `[Unit]
2902
+ Description=Portless HTTPS proxy
2903
+ After=network-online.target
2904
+ Wants=network-online.target
2905
+
2906
+ [Service]
2907
+ Type=simple
2908
+ ${envLines}
2909
+ Environment=PATH=${systemdEscape(ctx.pathEnv)}
2910
+ ExecStart=${execStart.map(systemdEscape).join(" ")}
2911
+ Restart=on-failure
2912
+ RestartSec=2
2913
+ KillSignal=SIGTERM
2914
+ TimeoutStopSec=5
2915
+
2916
+ [Install]
2917
+ WantedBy=multi-user.target
2918
+ `;
2919
+ }
2920
+ function buildWindowsScript(ctx, command) {
2921
+ const env = buildServiceEnv(ctx);
2922
+ const setEnv = Object.entries(env).map(([key, value]) => `set "${key}=${value.replace(/"/g, "").replace(/%/g, "%%")}"`).join("\r\n");
2923
+ const proxyCommand = [windowsQuote(ctx.nodePath), ...command.map(windowsQuote)].join(" ");
2924
+ return `@echo off\r
2925
+ ${setEnv}\r
2926
+ ${proxyCommand}\r
2927
+ `;
2928
+ }
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
+ };
2940
+ const ctx = {
2941
+ platform: options.platform,
2942
+ nodePath: options.nodePath,
2943
+ entryScript: options.entryScript,
2944
+ stateDir,
2945
+ user: {
2946
+ home: options.userHome,
2947
+ uid: options.uid,
2948
+ gid: options.gid,
2949
+ username: options.username
2950
+ },
2951
+ pathEnv: options.pathEnv || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
2952
+ programData: options.programData || "C:\\ProgramData",
2953
+ config: normalizedConfig
2954
+ };
2955
+ const proxyCommand = buildProxyCommand(ctx.entryScript, ctx.config);
2956
+ if (ctx.platform === "darwin") {
2957
+ const programArguments = [ctx.nodePath, ...proxyCommand];
2958
+ return {
2959
+ platform: "darwin",
2960
+ label: SERVICE_LABEL,
2961
+ plistPath: `/Library/LaunchDaemons/${SERVICE_LABEL}.plist`,
2962
+ plist: buildLaunchdPlist(ctx, programArguments),
2963
+ stateDir: ctx.stateDir,
2964
+ config: ctx.config,
2965
+ programArguments
2966
+ };
2967
+ }
2968
+ if (ctx.platform === "linux") {
2969
+ const execStart = [ctx.nodePath, ...proxyCommand];
2970
+ return {
2971
+ platform: "linux",
2972
+ serviceName: SYSTEMD_SERVICE,
2973
+ unitPath: `/etc/systemd/system/${SYSTEMD_SERVICE}`,
2974
+ unit: buildSystemdUnit(ctx, execStart),
2975
+ stateDir: ctx.stateDir,
2976
+ config: ctx.config,
2977
+ execStart
2978
+ };
2979
+ }
2980
+ const scriptDir = path8.win32.join(ctx.programData, "portless", "service");
2981
+ const scriptPath = path8.win32.join(scriptDir, "portless-service.cmd");
2982
+ const script = buildWindowsScript(ctx, proxyCommand);
2983
+ const taskRun = windowsQuote(scriptPath);
2984
+ return {
2985
+ platform: "win32",
2986
+ taskName: WINDOWS_TASK_NAME,
2987
+ stateDir: ctx.stateDir,
2988
+ config: ctx.config,
2989
+ scriptDir,
2990
+ scriptPath,
2991
+ script,
2992
+ taskRun,
2993
+ createArgs: [
2994
+ "/Create",
2995
+ "/TN",
2996
+ WINDOWS_TASK_NAME,
2997
+ "/SC",
2998
+ "ONSTART",
2999
+ "/RU",
3000
+ "SYSTEM",
3001
+ "/RL",
3002
+ "HIGHEST",
3003
+ "/TR",
3004
+ taskRun,
3005
+ "/F"
3006
+ ],
3007
+ runArgs: ["/Run", "/TN", WINDOWS_TASK_NAME],
3008
+ deleteArgs: ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"],
3009
+ queryArgs: ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]
3010
+ };
3011
+ }
3012
+ function currentServiceSpec(entryScript, installConfig) {
3013
+ if (!isSupportedPlatform(process.platform)) {
3014
+ throw new Error(`Unsupported platform: ${process.platform}`);
3015
+ }
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
+ };
3023
+ return buildServiceSpec({
3024
+ platform: process.platform,
3025
+ nodePath: process.execPath,
3026
+ entryScript,
3027
+ userHome: user.home,
3028
+ uid: user.uid,
3029
+ gid: user.gid,
3030
+ username: user.username,
3031
+ stateDir,
3032
+ pathEnv: process.env.PATH,
3033
+ programData: process.env.ProgramData,
3034
+ installConfig: config
3035
+ });
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
+ }
3156
+ function collectPortlessEnvArgs(env = process.env, omit = /* @__PURE__ */ new Set()) {
3157
+ const envArgs = [];
3158
+ for (const key of Object.keys(env)) {
3159
+ if (key.startsWith("PORTLESS_") && env[key] && !omit.has(key)) {
3160
+ envArgs.push(`${key}=${env[key]}`);
3161
+ }
3162
+ }
3163
+ return envArgs;
3164
+ }
3165
+ function buildElevatedEnvArgs(options) {
3166
+ const extraEnv = options.extraEnv ?? {};
3167
+ const overrideKeys = /* @__PURE__ */ new Set(["PORTLESS_STATE_DIR", ...Object.keys(extraEnv)]);
3168
+ return [
3169
+ "env",
3170
+ ...collectPortlessEnvArgs(options.env, overrideKeys),
3171
+ ...Object.entries(extraEnv).map(([key, value]) => `${key}=${value}`),
3172
+ `HOME=${options.home}`,
3173
+ `PORTLESS_STATE_DIR=${options.stateDir}`
3174
+ ];
3175
+ }
3176
+ function buildServiceUninstallSudoArgs(entryScript, options = {}) {
3177
+ const env = options.env ?? process.env;
3178
+ const home = options.home ?? os2.homedir();
3179
+ const stateDir = options.stateDir ?? env.PORTLESS_STATE_DIR ?? path8.join(home, ".portless");
3180
+ return [
3181
+ ...buildElevatedEnvArgs({ home, stateDir, env }),
3182
+ options.nodePath ?? process.execPath,
3183
+ entryScript,
3184
+ "service",
3185
+ "uninstall"
3186
+ ];
3187
+ }
3188
+ function requireUnixElevation(args, runner) {
3189
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
3190
+ if ((process.getuid?.() ?? -1) === 0) return;
3191
+ if (process.env[INTERNAL_ELEVATED_ENV] === "1") return;
3192
+ const home = os2.homedir();
3193
+ const stateDir = process.env.PORTLESS_STATE_DIR || path8.join(home, ".portless");
3194
+ const result = runner(
3195
+ "sudo",
3196
+ [
3197
+ ...buildElevatedEnvArgs({
3198
+ home,
3199
+ stateDir,
3200
+ extraEnv: { [INTERNAL_ELEVATED_ENV]: "1" }
3201
+ }),
3202
+ process.execPath,
3203
+ args[0],
3204
+ ...args.slice(1)
3205
+ ],
3206
+ { stdio: "inherit" }
3207
+ );
3208
+ process.exit(result.status ?? 1);
3209
+ }
3210
+ function runRequired(runner, command, args) {
3211
+ const result = runner(command, args);
3212
+ if (result.status !== 0) {
3213
+ const detail = result.stderr || result.stdout || result.error?.message || `${command} failed`;
3214
+ throw new Error(detail.trim());
3215
+ }
3216
+ }
3217
+ function runOptional(runner, command, args) {
3218
+ runner(command, args);
3219
+ }
3220
+ function isPermissionError(err) {
3221
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
3222
+ const message = err instanceof Error ? err.message : String(err);
3223
+ return code === "EACCES" || code === "EPERM" || /permission denied|operation not permitted|access is denied/i.test(message);
3224
+ }
3225
+ function stopProxyOnPort(entryScript, runner, proxyPort) {
3226
+ runRequired(runner, process.execPath, [
3227
+ entryScript,
3228
+ "proxy",
3229
+ "stop",
3230
+ "--port",
3231
+ proxyPort.toString()
3232
+ ]);
3233
+ }
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) {
3249
+ fs8.mkdirSync(stateDir, { recursive: true });
3250
+ fixOwnership(stateDir);
3251
+ }
3252
+ function prepareTrust(stateDir) {
3253
+ try {
3254
+ ensureCerts(stateDir);
3255
+ } catch (err) {
3256
+ const detail = err instanceof Error ? err.message : String(err);
3257
+ throw new Error(
3258
+ `Failed to generate certificates in ${stateDir}. Ensure OpenSSL is installed.
3259
+ ${detail}`
3260
+ );
3261
+ }
3262
+ if (isCATrusted(stateDir)) return;
3263
+ console.log(colors_default.gray("Trusting portless CA for service startup..."));
3264
+ const trustResult = trustCA(stateDir);
3265
+ if (trustResult.trusted) {
3266
+ console.log(colors_default.green("CA added to the system trust store."));
3267
+ return;
3268
+ }
3269
+ console.warn(colors_default.yellow("Could not add the CA to the system trust store."));
3270
+ if (trustResult.error) {
3271
+ console.warn(colors_default.gray(trustResult.error));
3272
+ }
3273
+ console.warn(colors_default.yellow("Run `portless trust` if browsers show certificate warnings."));
3274
+ }
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
+ }
3294
+ if (spec.platform === "darwin") {
3295
+ runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
3296
+ await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
3297
+ fs8.writeFileSync(spec.plistPath, spec.plist);
3298
+ fs8.chmodSync(spec.plistPath, 420);
3299
+ runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
3300
+ runRequired(runner, "launchctl", ["bootstrap", "system", spec.plistPath]);
3301
+ runRequired(runner, "launchctl", ["enable", `system/${spec.label}`]);
3302
+ runRequired(runner, "launchctl", ["kickstart", "-k", `system/${spec.label}`]);
3303
+ } else if (spec.platform === "linux") {
3304
+ runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
3305
+ await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
3306
+ fs8.writeFileSync(spec.unitPath, spec.unit);
3307
+ fs8.chmodSync(spec.unitPath, 420);
3308
+ runRequired(runner, "systemctl", ["daemon-reload"]);
3309
+ runRequired(runner, "systemctl", ["enable", spec.serviceName]);
3310
+ runRequired(runner, "systemctl", ["restart", spec.serviceName]);
3311
+ } else {
3312
+ runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
3313
+ await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
3314
+ fs8.mkdirSync(spec.scriptDir, { recursive: true });
3315
+ fs8.writeFileSync(spec.scriptPath, spec.script);
3316
+ runRequired(runner, "schtasks", spec.createArgs);
3317
+ runOptional(runner, "schtasks", spec.runArgs);
3318
+ }
3319
+ console.log(colors_default.green("Portless service installed."));
3320
+ console.log(colors_default.gray(`State directory: ${spec.stateDir}`));
3321
+ console.log(colors_default.gray(`Proxy port: ${spec.config.proxyPort}`));
3322
+ }
3323
+ async function uninstallService(entryScript, runner) {
3324
+ requireUnixElevation([entryScript, "service", "uninstall"], runner);
3325
+ const spec = currentServiceSpec(entryScript);
3326
+ if (spec.platform === "darwin") {
3327
+ runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
3328
+ fs8.rmSync(spec.plistPath, { force: true });
3329
+ } else if (spec.platform === "linux") {
3330
+ runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
3331
+ fs8.rmSync(spec.unitPath, { force: true });
3332
+ runOptional(runner, "systemctl", ["daemon-reload"]);
3333
+ } else {
3334
+ runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
3335
+ runOptional(runner, "schtasks", spec.deleteArgs);
3336
+ fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
3337
+ }
3338
+ console.log(colors_default.green("Portless service uninstalled."));
3339
+ }
3340
+ function tryUninstallService(entryScript, runner = defaultRunner2) {
3341
+ let installed = false;
3342
+ try {
3343
+ const spec = currentServiceSpec(entryScript);
3344
+ if (spec.platform === "darwin") {
3345
+ installed = fs8.existsSync(spec.plistPath);
3346
+ if (!installed) return { removed: false, installed: false };
3347
+ runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
3348
+ fs8.rmSync(spec.plistPath, { force: true });
3349
+ } else if (spec.platform === "linux") {
3350
+ installed = fs8.existsSync(spec.unitPath);
3351
+ if (!installed) return { removed: false, installed: false };
3352
+ runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
3353
+ fs8.rmSync(spec.unitPath, { force: true });
3354
+ runOptional(runner, "systemctl", ["daemon-reload"]);
3355
+ } else {
3356
+ const query = runner("schtasks", ["/Query", "/TN", spec.taskName, "/FO", "LIST"]);
3357
+ installed = query.status === 0;
3358
+ if (!installed) return { removed: false, installed: false };
3359
+ runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
3360
+ runRequired(runner, "schtasks", spec.deleteArgs);
3361
+ fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
3362
+ }
3363
+ return { removed: true, installed: true };
3364
+ } catch (err) {
3365
+ return {
3366
+ removed: false,
3367
+ installed,
3368
+ error: err instanceof Error ? err.message : String(err),
3369
+ needsElevation: installed && isPermissionError(err)
3370
+ };
3371
+ }
3372
+ }
3373
+ async function getServiceStatus(entryScript, runner) {
3374
+ const spec = currentServiceSpec(entryScript);
3375
+ const installedConfig = readInstalledServiceConfig(spec) ?? spec.config;
3376
+ const proxyRunning = await isProxyRunning(installedConfig.proxyPort, installedConfig.useHttps);
3377
+ if (spec.platform === "darwin") {
3378
+ const installed2 = fs8.existsSync(spec.plistPath);
3379
+ const result = runner("launchctl", ["print", `system/${spec.label}`]);
3380
+ const output2 = `${result.stdout || ""}${result.stderr || ""}`;
3381
+ const managerState = result.status === 0 && /state = running|pid = \d+/.test(output2) ? "running" : installed2 ? "installed" : "not installed";
3382
+ return {
3383
+ installed: installed2,
3384
+ managerState,
3385
+ proxyRunning,
3386
+ config: installedConfig,
3387
+ details: spec.plistPath
3388
+ };
3389
+ }
3390
+ if (spec.platform === "linux") {
3391
+ const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
3392
+ const active = runner("systemctl", ["is-active", spec.serviceName]);
3393
+ const installed2 = enabled2.status === 0 || active.status === 0 || fs8.existsSync(spec.unitPath);
3394
+ const activeText = (active.stdout || "").trim();
3395
+ return {
3396
+ installed: installed2,
3397
+ managerState: active.status === 0 ? activeText || "active" : installed2 ? "installed" : "not installed",
3398
+ proxyRunning,
3399
+ config: installedConfig,
3400
+ details: spec.unitPath
3401
+ };
3402
+ }
3403
+ const query = runner("schtasks", spec.queryArgs);
3404
+ const output = `${query.stdout || ""}${query.stderr || ""}`;
3405
+ const installed = query.status === 0;
3406
+ const stateMatch = output.match(/^\s*Status:\s*(.+)$/im);
3407
+ return {
3408
+ installed,
3409
+ managerState: installed ? stateMatch?.[1]?.trim() || "installed" : "not installed",
3410
+ proxyRunning,
3411
+ config: installedConfig,
3412
+ details: spec.taskName
3413
+ };
3414
+ }
3415
+ async function printServiceStatus(entryScript, runner) {
3416
+ const status = await getServiceStatus(entryScript, runner);
3417
+ const config = status.config;
3418
+ console.log(colors_default.bold("portless service"));
3419
+ console.log(` Manager state: ${status.managerState}`);
3420
+ console.log(` Installed: ${status.installed ? "yes" : "no"}`);
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}`);
3432
+ if (status.details) {
3433
+ console.log(` Service entry: ${status.details}`);
3434
+ }
3435
+ }
3436
+ function printServiceHelp() {
3437
+ console.log(`
3438
+ ${colors_default.bold("portless service")} - Start portless automatically when the OS starts.
3439
+
3440
+ ${colors_default.bold("Usage:")}
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
3458
+
3459
+ ${colors_default.bold("Notes:")}
3460
+ The service uses the default clean URL mode unless options or PORTLESS_*
3461
+ environment variables are provided during install.
3462
+ macOS and Linux install a root-owned service so port 443 can bind at boot.
3463
+ Windows installs a Task Scheduler startup task that runs as SYSTEM.
3464
+ `);
3465
+ }
3466
+ async function handleService(args, options) {
3467
+ const action = args[1];
3468
+ const runner = options.runner || defaultRunner2;
3469
+ if (!action || action === "--help" || action === "-h") {
3470
+ printServiceHelp();
3471
+ process.exit(0);
3472
+ }
3473
+ try {
3474
+ if (action === "install") {
3475
+ await installService(options.entryScript, runner, args);
3476
+ return;
3477
+ }
3478
+ if (action === "uninstall") {
3479
+ await uninstallService(options.entryScript, runner);
3480
+ return;
3481
+ }
3482
+ if (action === "status") {
3483
+ await printServiceStatus(options.entryScript, runner);
3484
+ return;
3485
+ }
3486
+ console.error(colors_default.red(`Error: Unknown service command "${action}".`));
3487
+ printServiceHelp();
3488
+ process.exit(1);
3489
+ } catch (err) {
3490
+ const message = err instanceof Error ? err.message : String(err);
3491
+ console.error(colors_default.red("Error:"), message);
3492
+ process.exit(1);
3493
+ }
3494
+ }
3495
+
2527
3496
  // src/cli.ts
2528
3497
  var chalk = colors_default;
2529
3498
  var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
@@ -2676,16 +3645,16 @@ function getEntryScript() {
2676
3645
  function isLocallyInstalled() {
2677
3646
  let dir = process.cwd();
2678
3647
  for (; ; ) {
2679
- if (fs8.existsSync(path8.join(dir, "node_modules", "portless", "package.json"))) {
3648
+ if (fs9.existsSync(path9.join(dir, "node_modules", "portless", "package.json"))) {
2680
3649
  return true;
2681
3650
  }
2682
- const parent = path8.dirname(dir);
3651
+ const parent = path9.dirname(dir);
2683
3652
  if (parent === dir) break;
2684
3653
  dir = parent;
2685
3654
  }
2686
3655
  return false;
2687
3656
  }
2688
- function collectPortlessEnvArgs() {
3657
+ function collectPortlessEnvArgs2() {
2689
3658
  const envArgs = [];
2690
3659
  for (const key of Object.keys(process.env)) {
2691
3660
  if (key.startsWith("PORTLESS_") && process.env[key]) {
@@ -2697,7 +3666,35 @@ function collectPortlessEnvArgs() {
2697
3666
  function sudoStop(port) {
2698
3667
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
2699
3668
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
2700
- const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
3669
+ const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
3670
+ stdio: "inherit",
3671
+ timeout: SUDO_SPAWN_TIMEOUT_MS
3672
+ });
3673
+ return result.status === 0;
3674
+ }
3675
+ function runCleanWithSudo(reason) {
3676
+ console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3677
+ const home = process.env.HOME;
3678
+ const result = spawnSync4(
3679
+ "sudo",
3680
+ [
3681
+ "env",
3682
+ ...collectPortlessEnvArgs2(),
3683
+ ...home ? [`HOME=${home}`] : [],
3684
+ process.execPath,
3685
+ getEntryScript(),
3686
+ "clean"
3687
+ ],
3688
+ {
3689
+ stdio: "inherit",
3690
+ timeout: SUDO_SPAWN_TIMEOUT_MS
3691
+ }
3692
+ );
3693
+ return result.status === 0;
3694
+ }
3695
+ function runServiceUninstallWithSudo(reason) {
3696
+ console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3697
+ const result = spawnSync4("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
2701
3698
  stdio: "inherit",
2702
3699
  timeout: SUDO_SPAWN_TIMEOUT_MS
2703
3700
  });
@@ -2715,11 +3712,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2715
3712
  console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
2716
3713
  }
2717
3714
  const routesPath = store.getRoutesPath();
2718
- if (!fs8.existsSync(routesPath)) {
2719
- fs8.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
3715
+ if (!fs9.existsSync(routesPath)) {
3716
+ fs9.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
2720
3717
  }
2721
3718
  try {
2722
- fs8.chmodSync(routesPath, FILE_MODE);
3719
+ fs9.chmodSync(routesPath, FILE_MODE);
2723
3720
  } catch {
2724
3721
  }
2725
3722
  fixOwnership(routesPath);
@@ -2779,7 +3776,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2779
3776
  }
2780
3777
  };
2781
3778
  try {
2782
- watcher = fs8.watch(routesPath, () => {
3779
+ watcher = fs9.watch(routesPath, () => {
2783
3780
  if (debounceTimer) clearTimeout(debounceTimer);
2784
3781
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
2785
3782
  });
@@ -2829,8 +3826,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2829
3826
  redirectServer.listen(80);
2830
3827
  }
2831
3828
  server.listen(proxyPort, () => {
2832
- fs8.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
2833
- fs8.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
3829
+ fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
3830
+ fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
2834
3831
  writeTlsMarker(store.dir, isTls);
2835
3832
  writeTldFile(store.dir, tld);
2836
3833
  writeLanMarker(store.dir, activeLanIp);
@@ -2846,7 +3843,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2846
3843
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
2847
3844
  if (isTls) {
2848
3845
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
2849
- console.log(chalk.gray(` ${path8.join(store.dir, "ca.pem")}`));
3846
+ console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
2850
3847
  }
2851
3848
  if (!lanIpPinned) {
2852
3849
  lanMonitor = startLanIpMonitor({
@@ -2878,11 +3875,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2878
3875
  redirectServer.close();
2879
3876
  }
2880
3877
  try {
2881
- fs8.unlinkSync(store.pidPath);
3878
+ fs9.unlinkSync(store.pidPath);
2882
3879
  } catch {
2883
3880
  }
2884
3881
  try {
2885
- fs8.unlinkSync(store.portFilePath);
3882
+ fs9.unlinkSync(store.portFilePath);
2886
3883
  } catch {
2887
3884
  }
2888
3885
  writeTlsMarker(store.dir, false);
@@ -2912,7 +3909,7 @@ function sudoStopOrHint(port) {
2912
3909
  }
2913
3910
  async function stopProxy(store, proxyPort, _tls) {
2914
3911
  const pidPath = store.pidPath;
2915
- if (!fs8.existsSync(pidPath)) {
3912
+ if (!fs9.existsSync(pidPath)) {
2916
3913
  if (await isProxyRunning(proxyPort)) {
2917
3914
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
2918
3915
  const pid = findPidOnPort(proxyPort);
@@ -2920,7 +3917,7 @@ async function stopProxy(store, proxyPort, _tls) {
2920
3917
  try {
2921
3918
  process.kill(pid, "SIGTERM");
2922
3919
  try {
2923
- fs8.unlinkSync(store.portFilePath);
3920
+ fs9.unlinkSync(store.portFilePath);
2924
3921
  } catch {
2925
3922
  }
2926
3923
  writeTlsMarker(store.dir, false);
@@ -2958,10 +3955,10 @@ async function stopProxy(store, proxyPort, _tls) {
2958
3955
  return;
2959
3956
  }
2960
3957
  try {
2961
- const pid = parseInt(fs8.readFileSync(pidPath, "utf-8"), 10);
3958
+ const pid = parseInt(fs9.readFileSync(pidPath, "utf-8"), 10);
2962
3959
  if (isNaN(pid)) {
2963
3960
  console.error(colors_default.red("Corrupted PID file. Removing it."));
2964
- fs8.unlinkSync(pidPath);
3961
+ fs9.unlinkSync(pidPath);
2965
3962
  writeTlsMarker(store.dir, false);
2966
3963
  writeTldFile(store.dir, DEFAULT_TLD);
2967
3964
  writeLanMarker(store.dir, null);
@@ -2975,9 +3972,9 @@ async function stopProxy(store, proxyPort, _tls) {
2975
3972
  return;
2976
3973
  }
2977
3974
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
2978
- fs8.unlinkSync(pidPath);
3975
+ fs9.unlinkSync(pidPath);
2979
3976
  try {
2980
- fs8.unlinkSync(store.portFilePath);
3977
+ fs9.unlinkSync(store.portFilePath);
2981
3978
  } catch {
2982
3979
  }
2983
3980
  writeTlsMarker(store.dir, false);
@@ -2992,16 +3989,16 @@ async function stopProxy(store, proxyPort, _tls) {
2992
3989
  )
2993
3990
  );
2994
3991
  console.log(colors_default.yellow("Removing stale PID file."));
2995
- fs8.unlinkSync(pidPath);
3992
+ fs9.unlinkSync(pidPath);
2996
3993
  writeTlsMarker(store.dir, false);
2997
3994
  writeTldFile(store.dir, DEFAULT_TLD);
2998
3995
  writeLanMarker(store.dir, null);
2999
3996
  return;
3000
3997
  }
3001
3998
  process.kill(pid, "SIGTERM");
3002
- fs8.unlinkSync(pidPath);
3999
+ fs9.unlinkSync(pidPath);
3003
4000
  try {
3004
- fs8.unlinkSync(store.portFilePath);
4001
+ fs9.unlinkSync(store.portFilePath);
3005
4002
  } catch {
3006
4003
  }
3007
4004
  writeTlsMarker(store.dir, false);
@@ -3124,7 +4121,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
3124
4121
  proxyPort: startPort
3125
4122
  });
3126
4123
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
3127
- const result = spawnSync3(process.execPath, startArgs, {
4124
+ const result = spawnSync4(process.execPath, startArgs, {
3128
4125
  stdio: "inherit",
3129
4126
  timeout: SUDO_SPAWN_TIMEOUT_MS
3130
4127
  });
@@ -3142,10 +4139,10 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
3142
4139
  if (!discovered) {
3143
4140
  console.error(colors_default.red("Failed to start proxy."));
3144
4141
  const fallbackDir = resolveStateDir(effectivePort);
3145
- const logPath = path8.join(fallbackDir, "proxy.log");
4142
+ const logPath = path9.join(fallbackDir, "proxy.log");
3146
4143
  console.error(colors_default.blue("Try starting it manually:"));
3147
4144
  console.error(colors_default.cyan(` ${manualStartCommand}`));
3148
- if (fs8.existsSync(logPath)) {
4145
+ if (fs9.existsSync(logPath)) {
3149
4146
  console.error(colors_default.gray(`Logs: ${logPath}`));
3150
4147
  }
3151
4148
  process.exit(1);
@@ -3163,14 +4160,17 @@ portless
3163
4160
  let tsBaseUrl;
3164
4161
  if (wantsTailscale) {
3165
4162
  try {
3166
- const tsReady = ensureTailscaleReady();
4163
+ const tsReady = ensureTailscaleReady({
4164
+ requireFunnel: wantsFunnel,
4165
+ requireHttps: true
4166
+ });
3167
4167
  tsBaseUrl = tsReady.baseUrl;
3168
4168
  } catch (err) {
3169
4169
  const message = err instanceof Error ? err.message : String(err);
3170
4170
  console.error(colors_default.red(`Error: ${message}`));
3171
4171
  if (message.includes("not found")) {
3172
4172
  console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
3173
- } else {
4173
+ } else if (!message.includes("not enabled on your tailnet")) {
3174
4174
  console.error(colors_default.blue("Make sure Tailscale is connected:"));
3175
4175
  console.error(colors_default.cyan(" tailscale up"));
3176
4176
  }
@@ -3299,7 +4299,7 @@ portless
3299
4299
  } catch {
3300
4300
  }
3301
4301
  }
3302
- const basename5 = path8.basename(commandArgs[0]);
4302
+ const basename5 = path9.basename(commandArgs[0]);
3303
4303
  const isExpo = basename5 === "expo";
3304
4304
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
3305
4305
  const hostBind = isExpoLan ? void 0 : "127.0.0.1";
@@ -3309,8 +4309,8 @@ portless
3309
4309
  injectFrameworkFlags(commandArgs, port);
3310
4310
  const caEnv = {};
3311
4311
  if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
3312
- const caPath = path8.join(stateDir, "ca.pem");
3313
- if (fs8.existsSync(caPath)) {
4312
+ const caPath = path9.join(stateDir, "ca.pem");
4313
+ if (fs9.existsSync(caPath)) {
3314
4314
  caEnv.NODE_EXTRA_CA_CERTS = caPath;
3315
4315
  }
3316
4316
  }
@@ -3507,6 +4507,9 @@ ${colors_default.bold("Install:")}
3507
4507
  ${colors_default.cyan("npm install -g portless")} Global (recommended)
3508
4508
  ${colors_default.cyan("npm install -D portless")} Project dev dependency
3509
4509
 
4510
+ ${colors_default.bold("Requirements:")}
4511
+ Node.js 24+
4512
+
3510
4513
  ${colors_default.bold("Usage:")}
3511
4514
  ${colors_default.cyan("portless")} Run dev script through proxy
3512
4515
  ${colors_default.cyan("portless")} From monorepo root: run all workspace packages
@@ -3515,6 +4518,7 @@ ${colors_default.bold("Usage:")}
3515
4518
  ${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
3516
4519
  ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
3517
4520
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
4521
+ ${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
3518
4522
  ${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
3519
4523
  ${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
3520
4524
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
@@ -3532,6 +4536,9 @@ ${colors_default.bold("Examples:")}
3532
4536
  portless myapp next dev # -> https://myapp.localhost
3533
4537
  portless run next dev # -> https://<project>.localhost
3534
4538
  portless run next dev # in worktree -> https://<worktree>.<project>.localhost
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
3535
4542
  portless get backend # -> https://backend.localhost
3536
4543
  portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
3537
4544
  portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
@@ -3590,7 +4597,9 @@ ${colors_default.bold("Tailscale sharing:")}
3590
4597
  Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
3591
4598
  8444, etc.) so no basePath configuration is needed.
3592
4599
  Use --funnel to expose your dev server to the public internet via
3593
- Tailscale Funnel. Requires Tailscale CLI to be installed and connected.
4600
+ Tailscale Funnel. Requires Tailscale CLI to be installed and connected,
4601
+ with Tailscale HTTPS certificates enabled. Funnel must also be enabled
4602
+ on your tailnet.
3594
4603
  ${colors_default.cyan("portless myapp --tailscale next dev")}
3595
4604
  ${colors_default.cyan("portless myapp --funnel next dev")}
3596
4605
 
@@ -3609,6 +4618,7 @@ ${colors_default.bold("Options:")}
3609
4618
  --foreground Run proxy in foreground (for debugging)
3610
4619
  --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
3611
4620
  --wildcard Allow unregistered subdomains to fall back to parent route
4621
+ --state-dir <path> Use a custom state directory with service install
3612
4622
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
3613
4623
  --tailscale Share the app on your Tailscale network (tailnet)
3614
4624
  --funnel Share the app publicly via Tailscale Funnel
@@ -3621,6 +4631,7 @@ ${colors_default.bold("Environment variables:")}
3621
4631
  PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
3622
4632
  PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
3623
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
3624
4635
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
3625
4636
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
3626
4637
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
@@ -3650,20 +4661,20 @@ ${colors_default.bold("Skip portless:")}
3650
4661
  PORTLESS=0 pnpm dev # Runs command directly without proxy
3651
4662
 
3652
4663
  ${colors_default.bold("Reserved names:")}
3653
- run, get, alias, hosts, list, trust, clean, prune, proxy are subcommands and
4664
+ run, get, alias, hosts, list, trust, clean, prune, proxy, service are subcommands and
3654
4665
  cannot be used as app names directly. Use "portless run" to infer the name,
3655
4666
  or "portless --name <name>" to force any name including reserved ones.
3656
4667
  `);
3657
4668
  process.exit(0);
3658
4669
  }
3659
4670
  function printVersion() {
3660
- console.log("0.12.0");
4671
+ console.log("0.13.1");
3661
4672
  process.exit(0);
3662
4673
  }
3663
4674
  async function handleTrust() {
3664
4675
  const { dir } = await discoverState();
3665
- if (!fs8.existsSync(dir)) {
3666
- fs8.mkdirSync(dir, { recursive: true });
4676
+ if (!fs9.existsSync(dir)) {
4677
+ fs9.mkdirSync(dir, { recursive: true });
3667
4678
  }
3668
4679
  const { caGenerated } = ensureCerts(dir);
3669
4680
  if (caGenerated) {
@@ -3675,14 +4686,14 @@ async function handleTrust() {
3675
4686
  console.log(colors_default.gray("Browsers will now trust portless HTTPS certificates."));
3676
4687
  return;
3677
4688
  }
3678
- const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
3679
- if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
4689
+ const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
4690
+ if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
3680
4691
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
3681
- const sudoResult = spawnSync3(
4692
+ const sudoResult = spawnSync4(
3682
4693
  "sudo",
3683
4694
  [
3684
4695
  "env",
3685
- ...collectPortlessEnvArgs(),
4696
+ ...collectPortlessEnvArgs2(),
3686
4697
  `PORTLESS_STATE_DIR=${dir}`,
3687
4698
  process.execPath,
3688
4699
  getEntryScript(),
@@ -3704,10 +4715,11 @@ async function handleClean(args) {
3704
4715
  console.log(`
3705
4716
  ${colors_default.bold("portless clean")} - Remove portless artifacts from this machine.
3706
4717
 
3707
- Stops the proxy if it is running, removes the local CA from the OS trust store
3708
- when it was installed by portless, deletes known files under state directories
3709
- (~/.portless, the system state directory, and PORTLESS_STATE_DIR when set),
3710
- and removes the portless block from ${HOSTS_DISPLAY}.
4718
+ Stops the proxy if it is running, uninstalls the startup service if installed,
4719
+ removes the local CA from the OS trust store when it was installed by portless,
4720
+ deletes known files under state directories (~/.portless, the system state
4721
+ directory, and PORTLESS_STATE_DIR when set), and removes the portless block
4722
+ from ${HOSTS_DISPLAY}.
3711
4723
 
3712
4724
  Only allowlisted filenames under each state directory are deleted. Custom
3713
4725
  certificate paths from --cert and --key are never removed.
@@ -3728,6 +4740,23 @@ ${colors_default.bold("Options:")}
3728
4740
  console.error(colors_default.cyan(" portless clean --help"));
3729
4741
  process.exit(1);
3730
4742
  }
4743
+ const serviceResult = tryUninstallService(getEntryScript());
4744
+ if (serviceResult.removed) {
4745
+ console.log(colors_default.green("Removed startup service."));
4746
+ } else if (serviceResult.needsElevation && !isWindows && (process.getuid?.() ?? -1) !== 0) {
4747
+ if (!runServiceUninstallWithSudo("Removing the startup service requires elevated privileges.")) {
4748
+ console.error(colors_default.red("Failed to remove startup service with sudo."));
4749
+ process.exit(1);
4750
+ }
4751
+ } else if (serviceResult.error) {
4752
+ const adminHint = isWindows ? " Run as Administrator and try again." : "";
4753
+ const message = `Could not remove startup service: ${serviceResult.error}${adminHint}`;
4754
+ if (serviceResult.installed) {
4755
+ console.error(colors_default.red(message));
4756
+ process.exit(1);
4757
+ }
4758
+ console.warn(colors_default.yellow(message));
4759
+ }
3731
4760
  console.log(colors_default.cyan("Stopping proxy if it is running..."));
3732
4761
  const { dir, port, tls: tls2 } = await discoverState();
3733
4762
  const store = new RouteStore(dir, {
@@ -3746,8 +4775,8 @@ ${colors_default.bold("Options:")}
3746
4775
  }
3747
4776
  const stateDirs = collectStateDirsForCleanup();
3748
4777
  for (const stateDir of stateDirs) {
3749
- const caPath = path8.join(stateDir, "ca.pem");
3750
- if (!fs8.existsSync(caPath)) continue;
4778
+ const caPath = path9.join(stateDir, "ca.pem");
4779
+ if (!fs9.existsSync(caPath)) continue;
3751
4780
  const wasTrusted = isCATrusted(stateDir);
3752
4781
  if (!wasTrusted) continue;
3753
4782
  const untrustResult = untrustCA(stateDir);
@@ -3769,18 +4798,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
3769
4798
  if (cleanHostsFile()) {
3770
4799
  console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
3771
4800
  } else if (!isWindows && process.getuid?.() !== 0) {
3772
- console.log(
3773
- colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
3774
- );
3775
- const result = spawnSync3(
3776
- "sudo",
3777
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
3778
- {
3779
- stdio: "inherit",
3780
- timeout: SUDO_SPAWN_TIMEOUT_MS
3781
- }
3782
- );
3783
- if (result.status !== 0) {
4801
+ if (!runCleanWithSudo(`Updating ${HOSTS_DISPLAY} requires elevated privileges.`)) {
3784
4802
  console.error(colors_default.red(`Failed to update ${HOSTS_DISPLAY}. Run: sudo portless clean`));
3785
4803
  process.exit(1);
3786
4804
  }
@@ -4011,9 +5029,9 @@ ${colors_default.bold("Auto-sync:")}
4011
5029
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
4012
5030
  )
4013
5031
  );
4014
- const result = spawnSync3(
5032
+ const result = spawnSync4(
4015
5033
  "sudo",
4016
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
5034
+ ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
4017
5035
  {
4018
5036
  stdio: "inherit",
4019
5037
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -4064,9 +5082,9 @@ ${colors_default.bold("Usage: portless hosts <command>")}
4064
5082
  console.log(
4065
5083
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
4066
5084
  );
4067
- const result = spawnSync3(
5085
+ const result = spawnSync4(
4068
5086
  "sudo",
4069
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
5087
+ ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
4070
5088
  {
4071
5089
  stdio: "inherit",
4072
5090
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -4355,7 +5373,7 @@ ${colors_default.bold("LAN mode (--lan):")}
4355
5373
  if (!hasExplicitPort) {
4356
5374
  console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
4357
5375
  }
4358
- const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
5376
+ const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
4359
5377
  stdio: "inherit",
4360
5378
  timeout: SUDO_SPAWN_TIMEOUT_MS
4361
5379
  });
@@ -4365,8 +5383,8 @@ ${colors_default.bold("LAN mode (--lan):")}
4365
5383
  console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
4366
5384
  } else {
4367
5385
  console.error(colors_default.red("Proxy process started but is not responding."));
4368
- const logPath2 = path8.join(resolveStateDir(proxyPort), "proxy.log");
4369
- if (fs8.existsSync(logPath2)) {
5386
+ const logPath2 = path9.join(resolveStateDir(proxyPort), "proxy.log");
5387
+ if (fs9.existsSync(logPath2)) {
4370
5388
  console.error(colors_default.gray(`Logs: ${logPath2}`));
4371
5389
  }
4372
5390
  }
@@ -4405,8 +5423,8 @@ ${colors_default.bold("LAN mode (--lan):")}
4405
5423
  store.ensureDir();
4406
5424
  if (customCertPath && customKeyPath) {
4407
5425
  try {
4408
- const cert = fs8.readFileSync(customCertPath);
4409
- const key = fs8.readFileSync(customKeyPath);
5426
+ const cert = fs9.readFileSync(customCertPath);
5427
+ const key = fs9.readFileSync(customKeyPath);
4410
5428
  const certStr = cert.toString("utf-8");
4411
5429
  const keyStr = key.toString("utf-8");
4412
5430
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -4451,9 +5469,9 @@ ${colors_default.bold("LAN mode (--lan):")}
4451
5469
  console.warn(colors_default.cyan(" portless trust"));
4452
5470
  }
4453
5471
  }
4454
- const cert = fs8.readFileSync(certs.certPath);
4455
- const key = fs8.readFileSync(certs.keyPath);
4456
- const ca = fs8.readFileSync(certs.caPath);
5472
+ const cert = fs9.readFileSync(certs.certPath);
5473
+ const key = fs9.readFileSync(certs.keyPath);
5474
+ const ca = fs9.readFileSync(certs.caPath);
4457
5475
  tlsOptions = {
4458
5476
  cert,
4459
5477
  key,
@@ -4468,11 +5486,11 @@ ${colors_default.bold("LAN mode (--lan):")}
4468
5486
  return;
4469
5487
  }
4470
5488
  store.ensureDir();
4471
- const logPath = path8.join(stateDir, "proxy.log");
4472
- const logFd = fs8.openSync(logPath, "a");
5489
+ const logPath = path9.join(stateDir, "proxy.log");
5490
+ const logFd = fs9.openSync(logPath, "a");
4473
5491
  try {
4474
5492
  try {
4475
- fs8.chmodSync(logPath, FILE_MODE);
5493
+ fs9.chmodSync(logPath, FILE_MODE);
4476
5494
  } catch {
4477
5495
  }
4478
5496
  fixOwnership(logPath);
@@ -4503,13 +5521,13 @@ ${colors_default.bold("LAN mode (--lan):")}
4503
5521
  });
4504
5522
  child.unref();
4505
5523
  } finally {
4506
- fs8.closeSync(logFd);
5524
+ fs9.closeSync(logFd);
4507
5525
  }
4508
5526
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
4509
5527
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
4510
5528
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
4511
5529
  console.error(colors_default.cyan(" portless proxy start --foreground"));
4512
- if (fs8.existsSync(logPath)) {
5530
+ if (fs9.existsSync(logPath)) {
4513
5531
  console.error(colors_default.gray(`Logs: ${logPath}`));
4514
5532
  }
4515
5533
  process.exit(1);
@@ -4661,8 +5679,8 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
4661
5679
  PORTLESS_URL: url
4662
5680
  };
4663
5681
  if (tls2) {
4664
- const caPath = path8.join(stateDir, "ca.pem");
4665
- if (fs8.existsSync(caPath)) {
5682
+ const caPath = path9.join(stateDir, "ca.pem");
5683
+ if (fs9.existsSync(caPath)) {
4666
5684
  env.NODE_EXTRA_CA_CERTS = caPath;
4667
5685
  }
4668
5686
  }
@@ -4744,7 +5762,7 @@ async function handleDefaultMulti(wsRoot, globalScript, extraArgs = []) {
4744
5762
  }
4745
5763
  const apps = [];
4746
5764
  for (const pkg of packages) {
4747
- const rel = path8.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
5765
+ const rel = path9.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
4748
5766
  const rootOverride = loaded ? resolveAppConfig(loaded.config, loaded.configDir, pkg.dir) : null;
4749
5767
  let pkgConfig;
4750
5768
  try {
@@ -4852,8 +5870,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
4852
5870
  PORTLESS_URL: url
4853
5871
  };
4854
5872
  if (tls2) {
4855
- const caPath = path8.join(stateDir, "ca.pem");
4856
- if (fs8.existsSync(caPath)) {
5873
+ const caPath = path9.join(stateDir, "ca.pem");
5874
+ if (fs9.existsSync(caPath)) {
4857
5875
  entry.NODE_EXTRA_CA_CERTS = caPath;
4858
5876
  }
4859
5877
  }
@@ -4902,8 +5920,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
4902
5920
  };
4903
5921
  process.on("SIGINT", cleanup);
4904
5922
  process.on("SIGTERM", cleanup);
4905
- const exitCode = await new Promise((resolve3) => {
4906
- turboChild.on("exit", (code) => resolve3(code));
5923
+ const exitCode = await new Promise((resolve4) => {
5924
+ turboChild.on("exit", (code) => resolve4(code));
4907
5925
  });
4908
5926
  cleanup();
4909
5927
  if (exitCode !== 0 && exitCode !== null) {
@@ -4967,8 +5985,8 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
4967
5985
  process.on("SIGTERM", cleanup);
4968
5986
  await Promise.all(
4969
5987
  children.map(
4970
- (child) => new Promise((resolve3) => {
4971
- child.on("exit", () => resolve3());
5988
+ (child) => new Promise((resolve4) => {
5989
+ child.on("exit", () => resolve4());
4972
5990
  })
4973
5991
  )
4974
5992
  );
@@ -5055,6 +6073,7 @@ async function handleNamedMode(args) {
5055
6073
  }
5056
6074
  }
5057
6075
  const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
6076
+ parseHostname(safeName, DEFAULT_TLD);
5058
6077
  const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
5059
6078
  const store = new RouteStore(dir, {
5060
6079
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
@@ -5166,7 +6185,7 @@ async function main() {
5166
6185
  args.shift();
5167
6186
  }
5168
6187
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
5169
- if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
6188
+ if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
5170
6189
  const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
5171
6190
  let commandArgs = parsed.commandArgs;
5172
6191
  if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
@@ -5230,6 +6249,10 @@ async function main() {
5230
6249
  await handleProxy(args);
5231
6250
  return;
5232
6251
  }
6252
+ if (args[0] === "service") {
6253
+ await handleService(args, { entryScript: getEntryScript() });
6254
+ return;
6255
+ }
5233
6256
  }
5234
6257
  if (isRunCommand) {
5235
6258
  await handleRunMode(args, globalScript);