portless 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  parseHostname,
14
14
  shouldAutoSyncHosts,
15
15
  syncHostsFile
16
- } from "./chunk-RRH2JUIU.js";
16
+ } from "./chunk-3WLVQXFE.js";
17
17
 
18
18
  // src/colors.ts
19
19
  function supportsColor() {
@@ -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 spawnSync2 } 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
@@ -754,6 +754,251 @@ function untrustCAWindows(caCertPath) {
754
754
  }
755
755
  }
756
756
 
757
+ // src/tailscale.ts
758
+ import { spawnSync } from "child_process";
759
+ var TAILSCALE_BINARY = "tailscale";
760
+ var TAILSCALE_COMMAND_TIMEOUT_MS = 3e4;
761
+ var PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
762
+ var FUNNEL_PORTS = [443, 8443, 1e4];
763
+ function defaultRunner(args) {
764
+ const result = spawnSync(TAILSCALE_BINARY, args, {
765
+ encoding: "utf-8",
766
+ killSignal: "SIGKILL",
767
+ timeout: TAILSCALE_COMMAND_TIMEOUT_MS
768
+ });
769
+ return {
770
+ status: result.status,
771
+ stdout: result.stdout ?? "",
772
+ stderr: result.stderr ?? "",
773
+ ...result.error ? { error: result.error } : {}
774
+ };
775
+ }
776
+ function trimDot(value) {
777
+ return value.endsWith(".") ? value.slice(0, -1) : value;
778
+ }
779
+ function normalizeSpace(value) {
780
+ return value.trim().replace(/\s+/g, " ");
781
+ }
782
+ function runOrThrow(args, action, runner) {
783
+ const result = runner(args);
784
+ if (result.error) {
785
+ const errno = result.error;
786
+ if (errno.code === "ENOENT") {
787
+ throw new Error(
788
+ "Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
789
+ );
790
+ }
791
+ throw new Error(`Failed to ${action}: ${result.error.message}`);
792
+ }
793
+ if (result.status !== 0) {
794
+ const details = normalizeSpace(result.stderr || result.stdout);
795
+ throw new Error(`Failed to ${action}: ${details || "unknown tailscale error"}`);
796
+ }
797
+ return result;
798
+ }
799
+ function parseStatusJson(raw) {
800
+ try {
801
+ return JSON.parse(raw);
802
+ } catch {
803
+ throw new Error("Failed to parse `tailscale status --json` output.");
804
+ }
805
+ }
806
+ function statusToDnsName(status) {
807
+ const dnsName = status.Self?.DNSName;
808
+ if (typeof dnsName === "string" && dnsName.length > 0) {
809
+ return trimDot(dnsName);
810
+ }
811
+ const host = status.Self?.HostName;
812
+ const suffix = status.CurrentTailnet?.MagicDNSSuffix;
813
+ if (typeof host === "string" && host.length > 0 && typeof suffix === "string" && suffix.length > 0) {
814
+ return `${host}.${trimDot(suffix)}`;
815
+ }
816
+ throw new Error(
817
+ "Could not determine Tailscale node DNS name from `tailscale status --json`. Is Tailscale connected?"
818
+ );
819
+ }
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;
856
+ runOrThrow(["version"], "check tailscale version", runner);
857
+ const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
858
+ const status = parseStatusJson(statusResult.stdout);
859
+ const dnsName = statusToDnsName(status);
860
+ if (options.requireHttps && !hasHttpsCapability(status)) {
861
+ throwHttpsNotEnabled();
862
+ }
863
+ if (options.requireFunnel && !hasFunnelCapability(status)) {
864
+ throwFunnelNotEnabled(status);
865
+ }
866
+ return {
867
+ dnsName,
868
+ baseUrl: `https://${dnsName}`
869
+ };
870
+ }
871
+ function getUsedServePorts(runner = defaultRunner) {
872
+ const result = runner(["serve", "status", "--json"]);
873
+ if (result.error || result.status !== 0) {
874
+ return /* @__PURE__ */ new Set();
875
+ }
876
+ try {
877
+ const config = JSON.parse(result.stdout);
878
+ const ports = /* @__PURE__ */ new Set();
879
+ if (config.Web) {
880
+ for (const hostPort of Object.keys(config.Web)) {
881
+ const match = hostPort.match(/:(\d+)$/);
882
+ if (match) {
883
+ ports.add(parseInt(match[1], 10));
884
+ }
885
+ }
886
+ }
887
+ if (config.TCP) {
888
+ for (const portStr of Object.keys(config.TCP)) {
889
+ const p = parseInt(portStr, 10);
890
+ if (!isNaN(p)) ports.add(p);
891
+ }
892
+ }
893
+ return ports;
894
+ } catch {
895
+ return /* @__PURE__ */ new Set();
896
+ }
897
+ }
898
+ function findAvailableServePort(usedPorts, mode = "serve") {
899
+ const pool = mode === "funnel" ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS;
900
+ for (const port2 of pool) {
901
+ if (!usedPorts.has(port2)) return port2;
902
+ }
903
+ if (mode === "funnel") {
904
+ throw new Error(
905
+ "All Tailscale Funnel ports are in use (443, 8443, 10000). Stop an existing funnel to free a port."
906
+ );
907
+ }
908
+ let port = PREFERRED_SERVE_PORTS[PREFERRED_SERVE_PORTS.length - 1] + 1;
909
+ while (usedPorts.has(port)) port++;
910
+ return port;
911
+ }
912
+ function isConflictError(stderr, stdout) {
913
+ const text = `${stderr}
914
+ ${stdout}`.toLowerCase();
915
+ return text.includes("already in use") || text.includes("already exists") || text.includes("port conflict") || text.includes("address already");
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
+ }
927
+ var CONFLICT_MESSAGES = {
928
+ serve: "Stop the existing serve or let portless auto-assign a different port.",
929
+ funnel: "Tailscale Funnel supports ports 443, 8443, and 10000."
930
+ };
931
+ function register(mode, localPort, httpsPort, runner) {
932
+ const target = `http://127.0.0.1:${localPort}`;
933
+ const result = runner([mode, "--bg", "--yes", `--https=${httpsPort}`, target]);
934
+ if (result.error) {
935
+ const errno = result.error;
936
+ if (errno.code === "ENOENT") {
937
+ throw new Error(
938
+ "Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
939
+ );
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
+ }
949
+ throw new Error(`Failed to register tailscale ${mode}: ${result.error.message}`);
950
+ }
951
+ if (result.status !== 0) {
952
+ if (mode === "funnel" && isFunnelNotEnabledError(result.stderr, result.stdout)) {
953
+ throw new Error(formatFunnelNotEnabledError(result.stderr, result.stdout));
954
+ }
955
+ if (isConflictError(result.stderr, result.stdout)) {
956
+ throw new Error(
957
+ `Tailscale ${mode === "funnel" ? "Funnel " : ""}HTTPS port ${httpsPort} is already in use. ` + CONFLICT_MESSAGES[mode]
958
+ );
959
+ }
960
+ const details = normalizeSpace(result.stderr || result.stdout);
961
+ throw new Error(
962
+ `Failed to register tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
963
+ );
964
+ }
965
+ }
966
+ function unregister(mode, httpsPort, options) {
967
+ const runner = options?.runner ?? defaultRunner;
968
+ const result = runner([mode, "--yes", `--https=${httpsPort}`, "off"]);
969
+ if (result.error) {
970
+ const errno = result.error;
971
+ if (errno.code === "ENOENT") return;
972
+ throw new Error(`Failed to remove tailscale ${mode}: ${result.error.message}`);
973
+ }
974
+ if (result.status !== 0) {
975
+ const text = `${result.stderr}
976
+ ${result.stdout}`.toLowerCase();
977
+ const looksLikeMissing = text.includes("not found") || text.includes("no serve config") || text.includes("nothing to remove") || text.includes("does not exist");
978
+ if (options?.ignoreMissing && looksLikeMissing) return;
979
+ const details = normalizeSpace(result.stderr || result.stdout);
980
+ throw new Error(
981
+ `Failed to remove tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
982
+ );
983
+ }
984
+ }
985
+ function registerServe(localPort, httpsPort, options) {
986
+ register("serve", localPort, httpsPort, options?.runner ?? defaultRunner);
987
+ }
988
+ function registerFunnel(localPort, httpsPort, options) {
989
+ register("funnel", localPort, httpsPort, options?.runner ?? defaultRunner);
990
+ }
991
+ function unregisterTailscale(route) {
992
+ if (!route.tailscaleHttpsPort) return;
993
+ const mode = route.tailscaleFunnel ? "funnel" : "serve";
994
+ unregister(mode, route.tailscaleHttpsPort, { ignoreMissing: true });
995
+ }
996
+ function formatTailscaleUrl(baseUrl, httpsPort) {
997
+ const trimmed = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
998
+ if (httpsPort === 443) return trimmed;
999
+ return `${trimmed}:${httpsPort}`;
1000
+ }
1001
+
757
1002
  // src/auto.ts
758
1003
  import { createHash as createHash2 } from "crypto";
759
1004
  import { execFileSync as execFileSync2 } from "child_process";
@@ -1622,7 +1867,7 @@ function removePortlessStateFiles(dir) {
1622
1867
  }
1623
1868
 
1624
1869
  // src/mdns.ts
1625
- import { spawn as spawn2, spawnSync } from "child_process";
1870
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
1626
1871
 
1627
1872
  // src/lan-ip.ts
1628
1873
  import { createSocket } from "dgram";
@@ -1738,7 +1983,7 @@ function getMdnsPublisher() {
1738
1983
  return null;
1739
1984
  }
1740
1985
  function hasCommand(command, probeArgs) {
1741
- const result = spawnSync(command, probeArgs, {
1986
+ const result = spawnSync2(command, probeArgs, {
1742
1987
  stdio: "ignore",
1743
1988
  timeout: 1e3,
1744
1989
  windowsHide: true
@@ -2346,6 +2591,522 @@ function hasTurboConfig(wsRoot) {
2346
2591
  }
2347
2592
  }
2348
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 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
+ function defaultRunner2(command, args, options) {
2605
+ return spawnSync3(command, args, {
2606
+ encoding: "utf-8",
2607
+ stdio: options?.stdio ?? "pipe"
2608
+ });
2609
+ }
2610
+ function isSupportedPlatform(platform) {
2611
+ return platform === "darwin" || platform === "linux" || platform === "win32";
2612
+ }
2613
+ function xmlEscape(value) {
2614
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2615
+ }
2616
+ function systemdEscape(value) {
2617
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2618
+ }
2619
+ function windowsQuote(value) {
2620
+ return `"${value.replace(/"/g, '\\"')}"`;
2621
+ }
2622
+ function readPasswdHome(username) {
2623
+ try {
2624
+ const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
2625
+ for (const line of passwd.split("\n")) {
2626
+ const fields = line.split(":");
2627
+ if (fields[0] === username && fields[5]) {
2628
+ return fields[5];
2629
+ }
2630
+ }
2631
+ } catch {
2632
+ }
2633
+ return null;
2634
+ }
2635
+ function resolveUserContext(platform) {
2636
+ if (platform === "win32") {
2637
+ const home = process.env.USERPROFILE || os2.homedir();
2638
+ return { home, username: process.env.USERNAME };
2639
+ }
2640
+ const sudoUser = process.env.SUDO_USER;
2641
+ const sudoUid = process.env.SUDO_UID;
2642
+ const sudoGid = process.env.SUDO_GID;
2643
+ if (sudoUser && sudoUser !== "root") {
2644
+ 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));
2645
+ return { home, uid: sudoUid, gid: sudoGid, username: sudoUser };
2646
+ }
2647
+ const userInfo2 = os2.userInfo();
2648
+ return {
2649
+ home: os2.homedir(),
2650
+ uid: process.getuid?.()?.toString(),
2651
+ gid: process.getgid?.()?.toString(),
2652
+ username: userInfo2.username
2653
+ };
2654
+ }
2655
+ function buildProxyCommand(entryScript) {
2656
+ const config = buildProxyStartConfig({
2657
+ useHttps: true,
2658
+ lanMode: false,
2659
+ tld: DEFAULT_TLD,
2660
+ foreground: true,
2661
+ includePort: true,
2662
+ proxyPort: SERVICE_PORT,
2663
+ skipTrust: true
2664
+ });
2665
+ return [entryScript, "proxy", "start", ...config.args];
2666
+ }
2667
+ function buildServiceEnv(ctx) {
2668
+ const env = {
2669
+ PORTLESS_STATE_DIR: ctx.stateDir
2670
+ };
2671
+ if (ctx.platform === "win32") {
2672
+ env.USERPROFILE = ctx.user.home;
2673
+ env.PATH = ctx.pathEnv;
2674
+ } else {
2675
+ env.HOME = ctx.user.home;
2676
+ if (ctx.user.uid) env.SUDO_UID = ctx.user.uid;
2677
+ if (ctx.user.gid) env.SUDO_GID = ctx.user.gid;
2678
+ }
2679
+ return env;
2680
+ }
2681
+ function defaultStateDir(platform, userHome) {
2682
+ return platform === "win32" ? path8.win32.join(userHome, ".portless") : path8.posix.join(userHome, ".portless");
2683
+ }
2684
+ function buildLaunchdPlist(ctx, programArguments) {
2685
+ const env = buildServiceEnv(ctx);
2686
+ const envEntries = Object.entries(env).map(
2687
+ ([key, value]) => ` <key>${xmlEscape(key)}</key>
2688
+ <string>${xmlEscape(value)}</string>`
2689
+ ).join("\n");
2690
+ const args = programArguments.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n");
2691
+ return `<?xml version="1.0" encoding="UTF-8"?>
2692
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2693
+ <plist version="1.0">
2694
+ <dict>
2695
+ <key>Label</key>
2696
+ <string>${SERVICE_LABEL}</string>
2697
+ <key>ProgramArguments</key>
2698
+ <array>
2699
+ ${args}
2700
+ </array>
2701
+ <key>EnvironmentVariables</key>
2702
+ <dict>
2703
+ ${envEntries}
2704
+ </dict>
2705
+ <key>RunAtLoad</key>
2706
+ <true/>
2707
+ <key>KeepAlive</key>
2708
+ <true/>
2709
+ <key>StandardOutPath</key>
2710
+ <string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
2711
+ <key>StandardErrorPath</key>
2712
+ <string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
2713
+ </dict>
2714
+ </plist>
2715
+ `;
2716
+ }
2717
+ function buildSystemdUnit(ctx, execStart) {
2718
+ const env = buildServiceEnv(ctx);
2719
+ const envLines = Object.entries(env).map(([key, value]) => `Environment=${key}=${systemdEscape(value)}`).join("\n");
2720
+ return `[Unit]
2721
+ Description=Portless HTTPS proxy
2722
+ After=network-online.target
2723
+ Wants=network-online.target
2724
+
2725
+ [Service]
2726
+ Type=simple
2727
+ ${envLines}
2728
+ Environment=PATH=${systemdEscape(ctx.pathEnv)}
2729
+ ExecStart=${execStart.map(systemdEscape).join(" ")}
2730
+ Restart=on-failure
2731
+ RestartSec=2
2732
+ KillSignal=SIGTERM
2733
+ TimeoutStopSec=5
2734
+
2735
+ [Install]
2736
+ WantedBy=multi-user.target
2737
+ `;
2738
+ }
2739
+ function buildWindowsScript(ctx, command) {
2740
+ const env = buildServiceEnv(ctx);
2741
+ const setEnv = Object.entries(env).map(([key, value]) => `set "${key}=${value.replace(/"/g, "").replace(/%/g, "%%")}"`).join("\r\n");
2742
+ const proxyCommand = [windowsQuote(ctx.nodePath), ...command.map(windowsQuote)].join(" ");
2743
+ return `@echo off\r
2744
+ ${setEnv}\r
2745
+ ${proxyCommand}\r
2746
+ `;
2747
+ }
2748
+ function buildServiceSpec(options) {
2749
+ const ctx = {
2750
+ platform: options.platform,
2751
+ nodePath: options.nodePath,
2752
+ entryScript: options.entryScript,
2753
+ stateDir: options.stateDir || defaultStateDir(options.platform, options.userHome),
2754
+ user: {
2755
+ home: options.userHome,
2756
+ uid: options.uid,
2757
+ gid: options.gid,
2758
+ username: options.username
2759
+ },
2760
+ pathEnv: options.pathEnv || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
2761
+ programData: options.programData || "C:\\ProgramData"
2762
+ };
2763
+ const proxyCommand = buildProxyCommand(ctx.entryScript);
2764
+ if (ctx.platform === "darwin") {
2765
+ const programArguments = [ctx.nodePath, ...proxyCommand];
2766
+ return {
2767
+ platform: "darwin",
2768
+ label: SERVICE_LABEL,
2769
+ plistPath: `/Library/LaunchDaemons/${SERVICE_LABEL}.plist`,
2770
+ plist: buildLaunchdPlist(ctx, programArguments),
2771
+ stateDir: ctx.stateDir,
2772
+ programArguments
2773
+ };
2774
+ }
2775
+ if (ctx.platform === "linux") {
2776
+ const execStart = [ctx.nodePath, ...proxyCommand];
2777
+ return {
2778
+ platform: "linux",
2779
+ serviceName: SYSTEMD_SERVICE,
2780
+ unitPath: `/etc/systemd/system/${SYSTEMD_SERVICE}`,
2781
+ unit: buildSystemdUnit(ctx, execStart),
2782
+ stateDir: ctx.stateDir,
2783
+ execStart
2784
+ };
2785
+ }
2786
+ const scriptDir = path8.win32.join(ctx.programData, "portless", "service");
2787
+ const scriptPath = path8.win32.join(scriptDir, "portless-service.cmd");
2788
+ const script = buildWindowsScript(ctx, proxyCommand);
2789
+ const taskRun = windowsQuote(scriptPath);
2790
+ return {
2791
+ platform: "win32",
2792
+ taskName: WINDOWS_TASK_NAME,
2793
+ stateDir: ctx.stateDir,
2794
+ scriptDir,
2795
+ scriptPath,
2796
+ script,
2797
+ taskRun,
2798
+ createArgs: [
2799
+ "/Create",
2800
+ "/TN",
2801
+ WINDOWS_TASK_NAME,
2802
+ "/SC",
2803
+ "ONSTART",
2804
+ "/RU",
2805
+ "SYSTEM",
2806
+ "/RL",
2807
+ "HIGHEST",
2808
+ "/TR",
2809
+ taskRun,
2810
+ "/F"
2811
+ ],
2812
+ runArgs: ["/Run", "/TN", WINDOWS_TASK_NAME],
2813
+ deleteArgs: ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"],
2814
+ queryArgs: ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]
2815
+ };
2816
+ }
2817
+ function currentServiceSpec(entryScript) {
2818
+ if (!isSupportedPlatform(process.platform)) {
2819
+ throw new Error(`Unsupported platform: ${process.platform}`);
2820
+ }
2821
+ const user = resolveUserContext(process.platform);
2822
+ return buildServiceSpec({
2823
+ platform: process.platform,
2824
+ nodePath: process.execPath,
2825
+ entryScript,
2826
+ userHome: user.home,
2827
+ uid: user.uid,
2828
+ gid: user.gid,
2829
+ username: user.username,
2830
+ stateDir: process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home),
2831
+ pathEnv: process.env.PATH,
2832
+ programData: process.env.ProgramData
2833
+ });
2834
+ }
2835
+ function collectPortlessEnvArgs(env = process.env, omit = /* @__PURE__ */ new Set()) {
2836
+ const envArgs = [];
2837
+ for (const key of Object.keys(env)) {
2838
+ if (key.startsWith("PORTLESS_") && env[key] && !omit.has(key)) {
2839
+ envArgs.push(`${key}=${env[key]}`);
2840
+ }
2841
+ }
2842
+ return envArgs;
2843
+ }
2844
+ function buildElevatedEnvArgs(options) {
2845
+ const extraEnv = options.extraEnv ?? {};
2846
+ const overrideKeys = /* @__PURE__ */ new Set(["PORTLESS_STATE_DIR", ...Object.keys(extraEnv)]);
2847
+ return [
2848
+ "env",
2849
+ ...collectPortlessEnvArgs(options.env, overrideKeys),
2850
+ ...Object.entries(extraEnv).map(([key, value]) => `${key}=${value}`),
2851
+ `HOME=${options.home}`,
2852
+ `PORTLESS_STATE_DIR=${options.stateDir}`
2853
+ ];
2854
+ }
2855
+ function buildServiceUninstallSudoArgs(entryScript, options = {}) {
2856
+ const env = options.env ?? process.env;
2857
+ const home = options.home ?? os2.homedir();
2858
+ const stateDir = options.stateDir ?? env.PORTLESS_STATE_DIR ?? path8.join(home, ".portless");
2859
+ return [
2860
+ ...buildElevatedEnvArgs({ home, stateDir, env }),
2861
+ options.nodePath ?? process.execPath,
2862
+ entryScript,
2863
+ "service",
2864
+ "uninstall"
2865
+ ];
2866
+ }
2867
+ function requireUnixElevation(args, runner) {
2868
+ if (process.platform !== "darwin" && process.platform !== "linux") return;
2869
+ if ((process.getuid?.() ?? -1) === 0) return;
2870
+ if (process.env[INTERNAL_ELEVATED_ENV] === "1") return;
2871
+ const home = os2.homedir();
2872
+ const stateDir = process.env.PORTLESS_STATE_DIR || path8.join(home, ".portless");
2873
+ const result = runner(
2874
+ "sudo",
2875
+ [
2876
+ ...buildElevatedEnvArgs({
2877
+ home,
2878
+ stateDir,
2879
+ extraEnv: { [INTERNAL_ELEVATED_ENV]: "1" }
2880
+ }),
2881
+ process.execPath,
2882
+ args[0],
2883
+ ...args.slice(1)
2884
+ ],
2885
+ { stdio: "inherit" }
2886
+ );
2887
+ process.exit(result.status ?? 1);
2888
+ }
2889
+ function runRequired(runner, command, args) {
2890
+ const result = runner(command, args);
2891
+ if (result.status !== 0) {
2892
+ const detail = result.stderr || result.stdout || result.error?.message || `${command} failed`;
2893
+ throw new Error(detail.trim());
2894
+ }
2895
+ }
2896
+ function runOptional(runner, command, args) {
2897
+ runner(command, args);
2898
+ }
2899
+ function isPermissionError(err) {
2900
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
2901
+ const message = err instanceof Error ? err.message : String(err);
2902
+ return code === "EACCES" || code === "EPERM" || /permission denied|operation not permitted|access is denied/i.test(message);
2903
+ }
2904
+ function stopExistingProxy(entryScript, runner) {
2905
+ runRequired(runner, process.execPath, [
2906
+ entryScript,
2907
+ "proxy",
2908
+ "stop",
2909
+ "--port",
2910
+ SERVICE_PORT.toString()
2911
+ ]);
2912
+ }
2913
+ function prepareTrust(stateDir) {
2914
+ fs8.mkdirSync(stateDir, { recursive: true });
2915
+ fixOwnership(stateDir);
2916
+ try {
2917
+ ensureCerts(stateDir);
2918
+ } catch (err) {
2919
+ const detail = err instanceof Error ? err.message : String(err);
2920
+ throw new Error(
2921
+ `Failed to generate certificates in ${stateDir}. Ensure OpenSSL is installed.
2922
+ ${detail}`
2923
+ );
2924
+ }
2925
+ if (isCATrusted(stateDir)) return;
2926
+ console.log(colors_default.gray("Trusting portless CA for service startup..."));
2927
+ const trustResult = trustCA(stateDir);
2928
+ if (trustResult.trusted) {
2929
+ console.log(colors_default.green("CA added to the system trust store."));
2930
+ return;
2931
+ }
2932
+ console.warn(colors_default.yellow("Could not add the CA to the system trust store."));
2933
+ if (trustResult.error) {
2934
+ console.warn(colors_default.gray(trustResult.error));
2935
+ }
2936
+ console.warn(colors_default.yellow("Run `portless trust` if browsers show certificate warnings."));
2937
+ }
2938
+ async function installService(entryScript, runner) {
2939
+ requireUnixElevation([entryScript, "service", "install"], runner);
2940
+ const spec = currentServiceSpec(entryScript);
2941
+ prepareTrust(spec.stateDir);
2942
+ if (spec.platform === "darwin") {
2943
+ runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
2944
+ stopExistingProxy(entryScript, runner);
2945
+ fs8.writeFileSync(spec.plistPath, spec.plist);
2946
+ fs8.chmodSync(spec.plistPath, 420);
2947
+ runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
2948
+ runRequired(runner, "launchctl", ["bootstrap", "system", spec.plistPath]);
2949
+ runRequired(runner, "launchctl", ["enable", `system/${spec.label}`]);
2950
+ runRequired(runner, "launchctl", ["kickstart", "-k", `system/${spec.label}`]);
2951
+ } else if (spec.platform === "linux") {
2952
+ runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
2953
+ stopExistingProxy(entryScript, runner);
2954
+ fs8.writeFileSync(spec.unitPath, spec.unit);
2955
+ fs8.chmodSync(spec.unitPath, 420);
2956
+ runRequired(runner, "systemctl", ["daemon-reload"]);
2957
+ runRequired(runner, "systemctl", ["enable", spec.serviceName]);
2958
+ runRequired(runner, "systemctl", ["restart", spec.serviceName]);
2959
+ } else {
2960
+ runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
2961
+ stopExistingProxy(entryScript, runner);
2962
+ fs8.mkdirSync(spec.scriptDir, { recursive: true });
2963
+ fs8.writeFileSync(spec.scriptPath, spec.script);
2964
+ runRequired(runner, "schtasks", spec.createArgs);
2965
+ runOptional(runner, "schtasks", spec.runArgs);
2966
+ }
2967
+ console.log(colors_default.green("Portless service installed."));
2968
+ console.log(colors_default.gray(`State directory: ${spec.stateDir}`));
2969
+ }
2970
+ async function uninstallService(entryScript, runner) {
2971
+ requireUnixElevation([entryScript, "service", "uninstall"], runner);
2972
+ const spec = currentServiceSpec(entryScript);
2973
+ if (spec.platform === "darwin") {
2974
+ runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
2975
+ fs8.rmSync(spec.plistPath, { force: true });
2976
+ } else if (spec.platform === "linux") {
2977
+ runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
2978
+ fs8.rmSync(spec.unitPath, { force: true });
2979
+ runOptional(runner, "systemctl", ["daemon-reload"]);
2980
+ } else {
2981
+ runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
2982
+ runOptional(runner, "schtasks", spec.deleteArgs);
2983
+ fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
2984
+ }
2985
+ console.log(colors_default.green("Portless service uninstalled."));
2986
+ }
2987
+ function tryUninstallService(entryScript, runner = defaultRunner2) {
2988
+ let installed = false;
2989
+ try {
2990
+ const spec = currentServiceSpec(entryScript);
2991
+ if (spec.platform === "darwin") {
2992
+ installed = fs8.existsSync(spec.plistPath);
2993
+ if (!installed) return { removed: false, installed: false };
2994
+ runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
2995
+ fs8.rmSync(spec.plistPath, { force: true });
2996
+ } else if (spec.platform === "linux") {
2997
+ installed = fs8.existsSync(spec.unitPath);
2998
+ if (!installed) return { removed: false, installed: false };
2999
+ runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
3000
+ fs8.rmSync(spec.unitPath, { force: true });
3001
+ runOptional(runner, "systemctl", ["daemon-reload"]);
3002
+ } else {
3003
+ const query = runner("schtasks", ["/Query", "/TN", spec.taskName, "/FO", "LIST"]);
3004
+ installed = query.status === 0;
3005
+ if (!installed) return { removed: false, installed: false };
3006
+ runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
3007
+ runRequired(runner, "schtasks", spec.deleteArgs);
3008
+ fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
3009
+ }
3010
+ return { removed: true, installed: true };
3011
+ } catch (err) {
3012
+ return {
3013
+ removed: false,
3014
+ installed,
3015
+ error: err instanceof Error ? err.message : String(err),
3016
+ needsElevation: installed && isPermissionError(err)
3017
+ };
3018
+ }
3019
+ }
3020
+ async function getServiceStatus(entryScript, runner) {
3021
+ const spec = currentServiceSpec(entryScript);
3022
+ const proxyRunning = await isProxyRunning(SERVICE_PORT);
3023
+ if (spec.platform === "darwin") {
3024
+ const installed2 = fs8.existsSync(spec.plistPath);
3025
+ const result = runner("launchctl", ["print", `system/${spec.label}`]);
3026
+ const output2 = `${result.stdout || ""}${result.stderr || ""}`;
3027
+ 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 };
3029
+ }
3030
+ if (spec.platform === "linux") {
3031
+ const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
3032
+ const active = runner("systemctl", ["is-active", spec.serviceName]);
3033
+ const installed2 = enabled2.status === 0 || active.status === 0 || fs8.existsSync(spec.unitPath);
3034
+ const activeText = (active.stdout || "").trim();
3035
+ return {
3036
+ installed: installed2,
3037
+ managerState: active.status === 0 ? activeText || "active" : installed2 ? "installed" : "not installed",
3038
+ proxyRunning,
3039
+ details: spec.unitPath
3040
+ };
3041
+ }
3042
+ const query = runner("schtasks", spec.queryArgs);
3043
+ const output = `${query.stdout || ""}${query.stderr || ""}`;
3044
+ const installed = query.status === 0;
3045
+ const stateMatch = output.match(/^\s*Status:\s*(.+)$/im);
3046
+ return {
3047
+ installed,
3048
+ managerState: installed ? stateMatch?.[1]?.trim() || "installed" : "not installed",
3049
+ proxyRunning,
3050
+ details: spec.taskName
3051
+ };
3052
+ }
3053
+ async function printServiceStatus(entryScript, runner) {
3054
+ const spec = currentServiceSpec(entryScript);
3055
+ const status = await getServiceStatus(entryScript, runner);
3056
+ console.log(colors_default.bold("portless service"));
3057
+ console.log(` Manager state: ${status.managerState}`);
3058
+ 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}`);
3061
+ if (status.details) {
3062
+ console.log(` Service entry: ${status.details}`);
3063
+ }
3064
+ }
3065
+ function printServiceHelp() {
3066
+ console.log(`
3067
+ ${colors_default.bold("portless service")} - Start portless automatically when the OS starts.
3068
+
3069
+ ${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
3073
+
3074
+ ${colors_default.bold("Notes:")}
3075
+ The service uses the default clean URL mode: HTTPS on port 443.
3076
+ macOS and Linux install a root-owned service so port 443 can bind at boot.
3077
+ Windows installs a Task Scheduler startup task that runs as SYSTEM.
3078
+ `);
3079
+ }
3080
+ async function handleService(args, options) {
3081
+ const action = args[1];
3082
+ const runner = options.runner || defaultRunner2;
3083
+ if (!action || action === "--help" || action === "-h") {
3084
+ printServiceHelp();
3085
+ process.exit(0);
3086
+ }
3087
+ try {
3088
+ if (action === "install") {
3089
+ await installService(options.entryScript, runner);
3090
+ return;
3091
+ }
3092
+ if (action === "uninstall") {
3093
+ await uninstallService(options.entryScript, runner);
3094
+ return;
3095
+ }
3096
+ if (action === "status") {
3097
+ await printServiceStatus(options.entryScript, runner);
3098
+ return;
3099
+ }
3100
+ console.error(colors_default.red(`Error: Unknown service command "${action}".`));
3101
+ printServiceHelp();
3102
+ process.exit(1);
3103
+ } catch (err) {
3104
+ const message = err instanceof Error ? err.message : String(err);
3105
+ console.error(colors_default.red("Error:"), message);
3106
+ process.exit(1);
3107
+ }
3108
+ }
3109
+
2349
3110
  // src/cli.ts
2350
3111
  var chalk = colors_default;
2351
3112
  var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
@@ -2498,16 +3259,16 @@ function getEntryScript() {
2498
3259
  function isLocallyInstalled() {
2499
3260
  let dir = process.cwd();
2500
3261
  for (; ; ) {
2501
- if (fs8.existsSync(path8.join(dir, "node_modules", "portless", "package.json"))) {
3262
+ if (fs9.existsSync(path9.join(dir, "node_modules", "portless", "package.json"))) {
2502
3263
  return true;
2503
3264
  }
2504
- const parent = path8.dirname(dir);
3265
+ const parent = path9.dirname(dir);
2505
3266
  if (parent === dir) break;
2506
3267
  dir = parent;
2507
3268
  }
2508
3269
  return false;
2509
3270
  }
2510
- function collectPortlessEnvArgs() {
3271
+ function collectPortlessEnvArgs2() {
2511
3272
  const envArgs = [];
2512
3273
  for (const key of Object.keys(process.env)) {
2513
3274
  if (key.startsWith("PORTLESS_") && process.env[key]) {
@@ -2519,7 +3280,35 @@ function collectPortlessEnvArgs() {
2519
3280
  function sudoStop(port) {
2520
3281
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
2521
3282
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
2522
- const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
3283
+ const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
3284
+ stdio: "inherit",
3285
+ timeout: SUDO_SPAWN_TIMEOUT_MS
3286
+ });
3287
+ return result.status === 0;
3288
+ }
3289
+ function runCleanWithSudo(reason) {
3290
+ console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3291
+ const home = process.env.HOME;
3292
+ const result = spawnSync4(
3293
+ "sudo",
3294
+ [
3295
+ "env",
3296
+ ...collectPortlessEnvArgs2(),
3297
+ ...home ? [`HOME=${home}`] : [],
3298
+ process.execPath,
3299
+ getEntryScript(),
3300
+ "clean"
3301
+ ],
3302
+ {
3303
+ stdio: "inherit",
3304
+ timeout: SUDO_SPAWN_TIMEOUT_MS
3305
+ }
3306
+ );
3307
+ return result.status === 0;
3308
+ }
3309
+ function runServiceUninstallWithSudo(reason) {
3310
+ console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3311
+ const result = spawnSync4("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
2523
3312
  stdio: "inherit",
2524
3313
  timeout: SUDO_SPAWN_TIMEOUT_MS
2525
3314
  });
@@ -2537,11 +3326,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2537
3326
  console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
2538
3327
  }
2539
3328
  const routesPath = store.getRoutesPath();
2540
- if (!fs8.existsSync(routesPath)) {
2541
- fs8.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
3329
+ if (!fs9.existsSync(routesPath)) {
3330
+ fs9.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
2542
3331
  }
2543
3332
  try {
2544
- fs8.chmodSync(routesPath, FILE_MODE);
3333
+ fs9.chmodSync(routesPath, FILE_MODE);
2545
3334
  } catch {
2546
3335
  }
2547
3336
  fixOwnership(routesPath);
@@ -2601,7 +3390,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2601
3390
  }
2602
3391
  };
2603
3392
  try {
2604
- watcher = fs8.watch(routesPath, () => {
3393
+ watcher = fs9.watch(routesPath, () => {
2605
3394
  if (debounceTimer) clearTimeout(debounceTimer);
2606
3395
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
2607
3396
  });
@@ -2651,8 +3440,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2651
3440
  redirectServer.listen(80);
2652
3441
  }
2653
3442
  server.listen(proxyPort, () => {
2654
- fs8.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
2655
- fs8.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
3443
+ fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
3444
+ fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
2656
3445
  writeTlsMarker(store.dir, isTls);
2657
3446
  writeTldFile(store.dir, tld);
2658
3447
  writeLanMarker(store.dir, activeLanIp);
@@ -2668,7 +3457,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2668
3457
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
2669
3458
  if (isTls) {
2670
3459
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
2671
- console.log(chalk.gray(` ${path8.join(store.dir, "ca.pem")}`));
3460
+ console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
2672
3461
  }
2673
3462
  if (!lanIpPinned) {
2674
3463
  lanMonitor = startLanIpMonitor({
@@ -2700,11 +3489,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2700
3489
  redirectServer.close();
2701
3490
  }
2702
3491
  try {
2703
- fs8.unlinkSync(store.pidPath);
3492
+ fs9.unlinkSync(store.pidPath);
2704
3493
  } catch {
2705
3494
  }
2706
3495
  try {
2707
- fs8.unlinkSync(store.portFilePath);
3496
+ fs9.unlinkSync(store.portFilePath);
2708
3497
  } catch {
2709
3498
  }
2710
3499
  writeTlsMarker(store.dir, false);
@@ -2734,7 +3523,7 @@ function sudoStopOrHint(port) {
2734
3523
  }
2735
3524
  async function stopProxy(store, proxyPort, _tls) {
2736
3525
  const pidPath = store.pidPath;
2737
- if (!fs8.existsSync(pidPath)) {
3526
+ if (!fs9.existsSync(pidPath)) {
2738
3527
  if (await isProxyRunning(proxyPort)) {
2739
3528
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
2740
3529
  const pid = findPidOnPort(proxyPort);
@@ -2742,7 +3531,7 @@ async function stopProxy(store, proxyPort, _tls) {
2742
3531
  try {
2743
3532
  process.kill(pid, "SIGTERM");
2744
3533
  try {
2745
- fs8.unlinkSync(store.portFilePath);
3534
+ fs9.unlinkSync(store.portFilePath);
2746
3535
  } catch {
2747
3536
  }
2748
3537
  writeTlsMarker(store.dir, false);
@@ -2780,10 +3569,10 @@ async function stopProxy(store, proxyPort, _tls) {
2780
3569
  return;
2781
3570
  }
2782
3571
  try {
2783
- const pid = parseInt(fs8.readFileSync(pidPath, "utf-8"), 10);
3572
+ const pid = parseInt(fs9.readFileSync(pidPath, "utf-8"), 10);
2784
3573
  if (isNaN(pid)) {
2785
3574
  console.error(colors_default.red("Corrupted PID file. Removing it."));
2786
- fs8.unlinkSync(pidPath);
3575
+ fs9.unlinkSync(pidPath);
2787
3576
  writeTlsMarker(store.dir, false);
2788
3577
  writeTldFile(store.dir, DEFAULT_TLD);
2789
3578
  writeLanMarker(store.dir, null);
@@ -2797,9 +3586,9 @@ async function stopProxy(store, proxyPort, _tls) {
2797
3586
  return;
2798
3587
  }
2799
3588
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
2800
- fs8.unlinkSync(pidPath);
3589
+ fs9.unlinkSync(pidPath);
2801
3590
  try {
2802
- fs8.unlinkSync(store.portFilePath);
3591
+ fs9.unlinkSync(store.portFilePath);
2803
3592
  } catch {
2804
3593
  }
2805
3594
  writeTlsMarker(store.dir, false);
@@ -2814,16 +3603,16 @@ async function stopProxy(store, proxyPort, _tls) {
2814
3603
  )
2815
3604
  );
2816
3605
  console.log(colors_default.yellow("Removing stale PID file."));
2817
- fs8.unlinkSync(pidPath);
3606
+ fs9.unlinkSync(pidPath);
2818
3607
  writeTlsMarker(store.dir, false);
2819
3608
  writeTldFile(store.dir, DEFAULT_TLD);
2820
3609
  writeLanMarker(store.dir, null);
2821
3610
  return;
2822
3611
  }
2823
3612
  process.kill(pid, "SIGTERM");
2824
- fs8.unlinkSync(pidPath);
3613
+ fs9.unlinkSync(pidPath);
2825
3614
  try {
2826
- fs8.unlinkSync(store.portFilePath);
3615
+ fs9.unlinkSync(store.portFilePath);
2827
3616
  } catch {
2828
3617
  }
2829
3618
  writeTlsMarker(store.dir, false);
@@ -2859,6 +3648,10 @@ function listRoutes(store, proxyPort, tls2) {
2859
3648
  console.log(
2860
3649
  ` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
2861
3650
  );
3651
+ if (route.tailscaleUrl) {
3652
+ const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
3653
+ console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
3654
+ }
2862
3655
  }
2863
3656
  console.log();
2864
3657
  }
@@ -2942,7 +3735,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
2942
3735
  proxyPort: startPort
2943
3736
  });
2944
3737
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
2945
- const result = spawnSync2(process.execPath, startArgs, {
3738
+ const result = spawnSync4(process.execPath, startArgs, {
2946
3739
  stdio: "inherit",
2947
3740
  timeout: SUDO_SPAWN_TIMEOUT_MS
2948
3741
  });
@@ -2960,10 +3753,10 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
2960
3753
  if (!discovered) {
2961
3754
  console.error(colors_default.red("Failed to start proxy."));
2962
3755
  const fallbackDir = resolveStateDir(effectivePort);
2963
- const logPath = path8.join(fallbackDir, "proxy.log");
3756
+ const logPath = path9.join(fallbackDir, "proxy.log");
2964
3757
  console.error(colors_default.blue("Try starting it manually:"));
2965
3758
  console.error(colors_default.cyan(` ${manualStartCommand}`));
2966
- if (fs8.existsSync(logPath)) {
3759
+ if (fs9.existsSync(logPath)) {
2967
3760
  console.error(colors_default.gray(`Logs: ${logPath}`));
2968
3761
  }
2969
3762
  process.exit(1);
@@ -2976,6 +3769,28 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
2976
3769
  console.log(chalk.blue.bold(`
2977
3770
  portless
2978
3771
  `));
3772
+ const wantsFunnel = process.env.PORTLESS_FUNNEL === "1" || process.env.PORTLESS_FUNNEL === "true";
3773
+ const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
3774
+ let tsBaseUrl;
3775
+ if (wantsTailscale) {
3776
+ try {
3777
+ const tsReady = ensureTailscaleReady({
3778
+ requireFunnel: wantsFunnel,
3779
+ requireHttps: true
3780
+ });
3781
+ tsBaseUrl = tsReady.baseUrl;
3782
+ } catch (err) {
3783
+ const message = err instanceof Error ? err.message : String(err);
3784
+ console.error(colors_default.red(`Error: ${message}`));
3785
+ if (message.includes("not found")) {
3786
+ console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
3787
+ } else if (!message.includes("not enabled on your tailnet")) {
3788
+ console.error(colors_default.blue("Make sure Tailscale is connected:"));
3789
+ console.error(colors_default.cyan(" tailscale up"));
3790
+ }
3791
+ process.exit(1);
3792
+ }
3793
+ }
2979
3794
  let desired;
2980
3795
  try {
2981
3796
  desired = resolveProxyDesiredState(lanMode);
@@ -3059,7 +3874,46 @@ portless
3059
3874
  console.log(chalk.green(` LAN -> ${finalUrl}`));
3060
3875
  console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
3061
3876
  }
3062
- const basename5 = path8.basename(commandArgs[0]);
3877
+ let tailscaleHttpsPort;
3878
+ let tailscaleUrl;
3879
+ if (wantsTailscale && tsBaseUrl) {
3880
+ const maxAttempts = 3;
3881
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3882
+ const usedPorts = getUsedServePorts();
3883
+ tailscaleHttpsPort = findAvailableServePort(usedPorts, wantsFunnel ? "funnel" : "serve");
3884
+ try {
3885
+ if (wantsFunnel) {
3886
+ registerFunnel(port, tailscaleHttpsPort);
3887
+ } else {
3888
+ registerServe(port, tailscaleHttpsPort);
3889
+ }
3890
+ break;
3891
+ } catch (err) {
3892
+ const message = err instanceof Error ? err.message : String(err);
3893
+ const isConflict = message.includes("already in use");
3894
+ if (isConflict && attempt < maxAttempts) continue;
3895
+ console.error(colors_default.red(`Error: ${message}`));
3896
+ process.exit(1);
3897
+ }
3898
+ }
3899
+ tailscaleUrl = formatTailscaleUrl(tsBaseUrl, tailscaleHttpsPort);
3900
+ const label = wantsFunnel ? "Funnel (public)" : "Tailscale";
3901
+ console.log(chalk.green(` ${label} -> ${tailscaleUrl}`));
3902
+ if (wantsFunnel) {
3903
+ console.log(chalk.gray(" (accessible from the public internet via Tailscale Funnel)\n"));
3904
+ } else {
3905
+ console.log(chalk.gray(" (accessible from your tailnet)\n"));
3906
+ }
3907
+ try {
3908
+ store.updateRoute(hostname, {
3909
+ tailscaleUrl,
3910
+ tailscaleHttpsPort,
3911
+ tailscaleFunnel: wantsFunnel || void 0
3912
+ });
3913
+ } catch {
3914
+ }
3915
+ }
3916
+ const basename5 = path9.basename(commandArgs[0]);
3063
3917
  const isExpo = basename5 === "expo";
3064
3918
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
3065
3919
  const hostBind = isExpoLan ? void 0 : "127.0.0.1";
@@ -3069,8 +3923,8 @@ portless
3069
3923
  injectFrameworkFlags(commandArgs, port);
3070
3924
  const caEnv = {};
3071
3925
  if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
3072
- const caPath = path8.join(stateDir, "ca.pem");
3073
- if (fs8.existsSync(caPath)) {
3926
+ const caPath = path9.join(stateDir, "ca.pem");
3927
+ if (fs9.existsSync(caPath)) {
3074
3928
  caEnv.NODE_EXTRA_CA_CERTS = caPath;
3075
3929
  }
3076
3930
  }
@@ -3092,9 +3946,17 @@ portless
3092
3946
  // baked-in pinging, making this env var ineffective. Expo handles its
3093
3947
  // own LAN discovery natively.
3094
3948
  ...lanMode ? { PORTLESS_LAN: "1" } : {},
3949
+ ...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
3095
3950
  ...caEnv
3096
3951
  },
3097
3952
  onCleanup: () => {
3953
+ try {
3954
+ unregisterTailscale({
3955
+ tailscaleHttpsPort,
3956
+ tailscaleFunnel: wantsFunnel || void 0
3957
+ });
3958
+ } catch {
3959
+ }
3098
3960
  try {
3099
3961
  store.removeRoute(hostname);
3100
3962
  } catch {
@@ -3124,6 +3986,18 @@ function appPortFromEnv() {
3124
3986
  }
3125
3987
  return port;
3126
3988
  }
3989
+ function applyTailscaleFlag(flag) {
3990
+ if (flag === "--tailscale") {
3991
+ process.env.PORTLESS_TAILSCALE = "1";
3992
+ return true;
3993
+ }
3994
+ if (flag === "--funnel") {
3995
+ process.env.PORTLESS_FUNNEL = "1";
3996
+ process.env.PORTLESS_TAILSCALE = "1";
3997
+ return true;
3998
+ }
3999
+ return false;
4000
+ }
3127
4001
  function parseRunArgs(args) {
3128
4002
  let force = false;
3129
4003
  let appPort;
@@ -3180,9 +4054,12 @@ ${colors_default.bold("Examples:")}
3180
4054
  process.exit(1);
3181
4055
  }
3182
4056
  name = args[i];
4057
+ } else if (applyTailscaleFlag(args[i])) {
3183
4058
  } else {
3184
4059
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3185
- console.error(colors_default.blue("Known flags: --name, --force, --app-port, --help"));
4060
+ console.error(
4061
+ colors_default.blue("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
4062
+ );
3186
4063
  process.exit(1);
3187
4064
  }
3188
4065
  i++;
@@ -3203,9 +4080,10 @@ function parseAppArgs(args) {
3203
4080
  } else if (args[i] === "--app-port") {
3204
4081
  i++;
3205
4082
  appPort = parseAppPort(args[i]);
4083
+ } else if (applyTailscaleFlag(args[i])) {
3206
4084
  } else {
3207
4085
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3208
- console.error(colors_default.blue("Known flags: --force, --app-port"));
4086
+ console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
3209
4087
  process.exit(1);
3210
4088
  }
3211
4089
  i++;
@@ -3221,9 +4099,10 @@ function parseAppArgs(args) {
3221
4099
  } else if (args[i] === "--app-port") {
3222
4100
  i++;
3223
4101
  appPort = parseAppPort(args[i]);
4102
+ } else if (applyTailscaleFlag(args[i])) {
3224
4103
  } else {
3225
4104
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3226
- console.error(colors_default.blue("Known flags: --force, --app-port"));
4105
+ console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
3227
4106
  process.exit(1);
3228
4107
  }
3229
4108
  i++;
@@ -3250,6 +4129,7 @@ ${colors_default.bold("Usage:")}
3250
4129
  ${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
3251
4130
  ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
3252
4131
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
4132
+ ${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
3253
4133
  ${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
3254
4134
  ${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
3255
4135
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
@@ -3267,7 +4147,10 @@ ${colors_default.bold("Examples:")}
3267
4147
  portless myapp next dev # -> https://myapp.localhost
3268
4148
  portless run next dev # -> https://<project>.localhost
3269
4149
  portless run next dev # in worktree -> https://<worktree>.<project>.localhost
4150
+ portless service install # Start HTTPS proxy on OS startup
3270
4151
  portless get backend # -> https://backend.localhost
4152
+ portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
4153
+ portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
3271
4154
 
3272
4155
  ${colors_default.bold("Configuration (portless.json):")}
3273
4156
  Optional. Portless works out of the box by running the "dev" script
@@ -3318,6 +4201,17 @@ ${colors_default.bold("LAN mode:")}
3318
4201
  ${colors_default.cyan("portless proxy start --lan --https")}
3319
4202
  ${colors_default.cyan("portless proxy start --lan --ip 192.168.1.42")}
3320
4203
 
4204
+ ${colors_default.bold("Tailscale sharing:")}
4205
+ Use --tailscale to share your dev server with teammates on your tailnet.
4206
+ Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
4207
+ 8444, etc.) so no basePath configuration is needed.
4208
+ Use --funnel to expose your dev server to the public internet via
4209
+ Tailscale Funnel. Requires Tailscale CLI to be installed and connected,
4210
+ with Tailscale HTTPS certificates enabled. Funnel must also be enabled
4211
+ on your tailnet.
4212
+ ${colors_default.cyan("portless myapp --tailscale next dev")}
4213
+ ${colors_default.cyan("portless myapp --funnel next dev")}
4214
+
3321
4215
  ${colors_default.bold("Options:")}
3322
4216
  run [--name <name>] <cmd> Infer project name (or override with --name)
3323
4217
  Adds worktree prefix in git worktrees
@@ -3334,6 +4228,8 @@ ${colors_default.bold("Options:")}
3334
4228
  --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
3335
4229
  --wildcard Allow unregistered subdomains to fall back to parent route
3336
4230
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
4231
+ --tailscale Share the app on your Tailscale network (tailnet)
4232
+ --funnel Share the app publicly via Tailscale Funnel
3337
4233
  --force Kill the existing process and take over its route
3338
4234
  --name <name> Use <name> as the app name (bypasses subcommand dispatch)
3339
4235
  -- Stop flag parsing; everything after is passed to the child
@@ -3346,6 +4242,8 @@ ${colors_default.bold("Environment variables:")}
3346
4242
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
3347
4243
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
3348
4244
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
4245
+ PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
4246
+ PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
3349
4247
  PORTLESS_STATE_DIR=<path> Override the state directory
3350
4248
  PORTLESS=0 Run command directly without proxy
3351
4249
 
@@ -3354,6 +4252,7 @@ ${colors_default.bold("Child process environment:")}
3354
4252
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
3355
4253
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
3356
4254
  PORTLESS_LAN Set to 1 when proxy is in LAN mode
4255
+ PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
3357
4256
  NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
3358
4257
 
3359
4258
  ${colors_default.bold("Safari / DNS:")}
@@ -3369,20 +4268,20 @@ ${colors_default.bold("Skip portless:")}
3369
4268
  PORTLESS=0 pnpm dev # Runs command directly without proxy
3370
4269
 
3371
4270
  ${colors_default.bold("Reserved names:")}
3372
- run, get, alias, hosts, list, trust, clean, prune, proxy are subcommands and
4271
+ run, get, alias, hosts, list, trust, clean, prune, proxy, service are subcommands and
3373
4272
  cannot be used as app names directly. Use "portless run" to infer the name,
3374
4273
  or "portless --name <name>" to force any name including reserved ones.
3375
4274
  `);
3376
4275
  process.exit(0);
3377
4276
  }
3378
4277
  function printVersion() {
3379
- console.log("0.11.1");
4278
+ console.log("0.13.0");
3380
4279
  process.exit(0);
3381
4280
  }
3382
4281
  async function handleTrust() {
3383
4282
  const { dir } = await discoverState();
3384
- if (!fs8.existsSync(dir)) {
3385
- fs8.mkdirSync(dir, { recursive: true });
4283
+ if (!fs9.existsSync(dir)) {
4284
+ fs9.mkdirSync(dir, { recursive: true });
3386
4285
  }
3387
4286
  const { caGenerated } = ensureCerts(dir);
3388
4287
  if (caGenerated) {
@@ -3394,14 +4293,14 @@ async function handleTrust() {
3394
4293
  console.log(colors_default.gray("Browsers will now trust portless HTTPS certificates."));
3395
4294
  return;
3396
4295
  }
3397
- const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
3398
- if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
4296
+ const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
4297
+ if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
3399
4298
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
3400
- const sudoResult = spawnSync2(
4299
+ const sudoResult = spawnSync4(
3401
4300
  "sudo",
3402
4301
  [
3403
4302
  "env",
3404
- ...collectPortlessEnvArgs(),
4303
+ ...collectPortlessEnvArgs2(),
3405
4304
  `PORTLESS_STATE_DIR=${dir}`,
3406
4305
  process.execPath,
3407
4306
  getEntryScript(),
@@ -3423,10 +4322,11 @@ async function handleClean(args) {
3423
4322
  console.log(`
3424
4323
  ${colors_default.bold("portless clean")} - Remove portless artifacts from this machine.
3425
4324
 
3426
- Stops the proxy if it is running, removes the local CA from the OS trust store
3427
- when it was installed by portless, deletes known files under state directories
3428
- (~/.portless, the system state directory, and PORTLESS_STATE_DIR when set),
3429
- and removes the portless block from ${HOSTS_DISPLAY}.
4325
+ Stops the proxy if it is running, uninstalls the startup service if installed,
4326
+ removes the local CA from the OS trust store when it was installed by portless,
4327
+ deletes known files under state directories (~/.portless, the system state
4328
+ directory, and PORTLESS_STATE_DIR when set), and removes the portless block
4329
+ from ${HOSTS_DISPLAY}.
3430
4330
 
3431
4331
  Only allowlisted filenames under each state directory are deleted. Custom
3432
4332
  certificate paths from --cert and --key are never removed.
@@ -3447,16 +4347,43 @@ ${colors_default.bold("Options:")}
3447
4347
  console.error(colors_default.cyan(" portless clean --help"));
3448
4348
  process.exit(1);
3449
4349
  }
4350
+ const serviceResult = tryUninstallService(getEntryScript());
4351
+ if (serviceResult.removed) {
4352
+ console.log(colors_default.green("Removed startup service."));
4353
+ } else if (serviceResult.needsElevation && !isWindows && (process.getuid?.() ?? -1) !== 0) {
4354
+ if (!runServiceUninstallWithSudo("Removing the startup service requires elevated privileges.")) {
4355
+ console.error(colors_default.red("Failed to remove startup service with sudo."));
4356
+ process.exit(1);
4357
+ }
4358
+ } else if (serviceResult.error) {
4359
+ const adminHint = isWindows ? " Run as Administrator and try again." : "";
4360
+ const message = `Could not remove startup service: ${serviceResult.error}${adminHint}`;
4361
+ if (serviceResult.installed) {
4362
+ console.error(colors_default.red(message));
4363
+ process.exit(1);
4364
+ }
4365
+ console.warn(colors_default.yellow(message));
4366
+ }
3450
4367
  console.log(colors_default.cyan("Stopping proxy if it is running..."));
3451
4368
  const { dir, port, tls: tls2 } = await discoverState();
3452
4369
  const store = new RouteStore(dir, {
3453
4370
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
3454
4371
  });
3455
4372
  await stopProxy(store, port, tls2);
4373
+ const routesForClean = store.loadRoutesRaw();
4374
+ for (const route of routesForClean) {
4375
+ if (route.tailscaleHttpsPort) {
4376
+ try {
4377
+ unregisterTailscale(route);
4378
+ console.log(colors_default.green(`Removed tailscale serve on port ${route.tailscaleHttpsPort}.`));
4379
+ } catch {
4380
+ }
4381
+ }
4382
+ }
3456
4383
  const stateDirs = collectStateDirsForCleanup();
3457
4384
  for (const stateDir of stateDirs) {
3458
- const caPath = path8.join(stateDir, "ca.pem");
3459
- if (!fs8.existsSync(caPath)) continue;
4385
+ const caPath = path9.join(stateDir, "ca.pem");
4386
+ if (!fs9.existsSync(caPath)) continue;
3460
4387
  const wasTrusted = isCATrusted(stateDir);
3461
4388
  if (!wasTrusted) continue;
3462
4389
  const untrustResult = untrustCA(stateDir);
@@ -3478,18 +4405,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
3478
4405
  if (cleanHostsFile()) {
3479
4406
  console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
3480
4407
  } else if (!isWindows && process.getuid?.() !== 0) {
3481
- console.log(
3482
- colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
3483
- );
3484
- const result = spawnSync2(
3485
- "sudo",
3486
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
3487
- {
3488
- stdio: "inherit",
3489
- timeout: SUDO_SPAWN_TIMEOUT_MS
3490
- }
3491
- );
3492
- if (result.status !== 0) {
4408
+ if (!runCleanWithSudo(`Updating ${HOSTS_DISPLAY} requires elevated privileges.`)) {
3493
4409
  console.error(colors_default.red(`Failed to update ${HOSTS_DISPLAY}. Run: sudo portless clean`));
3494
4410
  process.exit(1);
3495
4411
  }
@@ -3532,6 +4448,17 @@ ${colors_default.bold("Options:")}
3532
4448
  console.log("No orphaned routes found.");
3533
4449
  return;
3534
4450
  }
4451
+ for (const route of stale) {
4452
+ if (route.tailscaleHttpsPort) {
4453
+ try {
4454
+ unregisterTailscale(route);
4455
+ console.log(
4456
+ ` ${route.hostname} - removed tailscale serve on port ${route.tailscaleHttpsPort}`
4457
+ );
4458
+ } catch {
4459
+ }
4460
+ }
4461
+ }
3535
4462
  let killed = 0;
3536
4463
  for (const route of stale) {
3537
4464
  const pids = findPidsOnPort(route.port);
@@ -3709,9 +4636,9 @@ ${colors_default.bold("Auto-sync:")}
3709
4636
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
3710
4637
  )
3711
4638
  );
3712
- const result = spawnSync2(
4639
+ const result = spawnSync4(
3713
4640
  "sudo",
3714
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
4641
+ ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
3715
4642
  {
3716
4643
  stdio: "inherit",
3717
4644
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -3762,9 +4689,9 @@ ${colors_default.bold("Usage: portless hosts <command>")}
3762
4689
  console.log(
3763
4690
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
3764
4691
  );
3765
- const result = spawnSync2(
4692
+ const result = spawnSync4(
3766
4693
  "sudo",
3767
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
4694
+ ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
3768
4695
  {
3769
4696
  stdio: "inherit",
3770
4697
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -4053,7 +4980,7 @@ ${colors_default.bold("LAN mode (--lan):")}
4053
4980
  if (!hasExplicitPort) {
4054
4981
  console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
4055
4982
  }
4056
- const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
4983
+ const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
4057
4984
  stdio: "inherit",
4058
4985
  timeout: SUDO_SPAWN_TIMEOUT_MS
4059
4986
  });
@@ -4063,8 +4990,8 @@ ${colors_default.bold("LAN mode (--lan):")}
4063
4990
  console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
4064
4991
  } else {
4065
4992
  console.error(colors_default.red("Proxy process started but is not responding."));
4066
- const logPath2 = path8.join(resolveStateDir(proxyPort), "proxy.log");
4067
- if (fs8.existsSync(logPath2)) {
4993
+ const logPath2 = path9.join(resolveStateDir(proxyPort), "proxy.log");
4994
+ if (fs9.existsSync(logPath2)) {
4068
4995
  console.error(colors_default.gray(`Logs: ${logPath2}`));
4069
4996
  }
4070
4997
  }
@@ -4103,8 +5030,8 @@ ${colors_default.bold("LAN mode (--lan):")}
4103
5030
  store.ensureDir();
4104
5031
  if (customCertPath && customKeyPath) {
4105
5032
  try {
4106
- const cert = fs8.readFileSync(customCertPath);
4107
- const key = fs8.readFileSync(customKeyPath);
5033
+ const cert = fs9.readFileSync(customCertPath);
5034
+ const key = fs9.readFileSync(customKeyPath);
4108
5035
  const certStr = cert.toString("utf-8");
4109
5036
  const keyStr = key.toString("utf-8");
4110
5037
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -4149,9 +5076,9 @@ ${colors_default.bold("LAN mode (--lan):")}
4149
5076
  console.warn(colors_default.cyan(" portless trust"));
4150
5077
  }
4151
5078
  }
4152
- const cert = fs8.readFileSync(certs.certPath);
4153
- const key = fs8.readFileSync(certs.keyPath);
4154
- const ca = fs8.readFileSync(certs.caPath);
5079
+ const cert = fs9.readFileSync(certs.certPath);
5080
+ const key = fs9.readFileSync(certs.keyPath);
5081
+ const ca = fs9.readFileSync(certs.caPath);
4155
5082
  tlsOptions = {
4156
5083
  cert,
4157
5084
  key,
@@ -4166,11 +5093,11 @@ ${colors_default.bold("LAN mode (--lan):")}
4166
5093
  return;
4167
5094
  }
4168
5095
  store.ensureDir();
4169
- const logPath = path8.join(stateDir, "proxy.log");
4170
- const logFd = fs8.openSync(logPath, "a");
5096
+ const logPath = path9.join(stateDir, "proxy.log");
5097
+ const logFd = fs9.openSync(logPath, "a");
4171
5098
  try {
4172
5099
  try {
4173
- fs8.chmodSync(logPath, FILE_MODE);
5100
+ fs9.chmodSync(logPath, FILE_MODE);
4174
5101
  } catch {
4175
5102
  }
4176
5103
  fixOwnership(logPath);
@@ -4201,13 +5128,13 @@ ${colors_default.bold("LAN mode (--lan):")}
4201
5128
  });
4202
5129
  child.unref();
4203
5130
  } finally {
4204
- fs8.closeSync(logFd);
5131
+ fs9.closeSync(logFd);
4205
5132
  }
4206
5133
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
4207
5134
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
4208
5135
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
4209
5136
  console.error(colors_default.cyan(" portless proxy start --foreground"));
4210
- if (fs8.existsSync(logPath)) {
5137
+ if (fs9.existsSync(logPath)) {
4211
5138
  console.error(colors_default.gray(`Logs: ${logPath}`));
4212
5139
  }
4213
5140
  process.exit(1);
@@ -4359,8 +5286,8 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
4359
5286
  PORTLESS_URL: url
4360
5287
  };
4361
5288
  if (tls2) {
4362
- const caPath = path8.join(stateDir, "ca.pem");
4363
- if (fs8.existsSync(caPath)) {
5289
+ const caPath = path9.join(stateDir, "ca.pem");
5290
+ if (fs9.existsSync(caPath)) {
4364
5291
  env.NODE_EXTRA_CA_CERTS = caPath;
4365
5292
  }
4366
5293
  }
@@ -4442,7 +5369,7 @@ async function handleDefaultMulti(wsRoot, globalScript, extraArgs = []) {
4442
5369
  }
4443
5370
  const apps = [];
4444
5371
  for (const pkg of packages) {
4445
- const rel = path8.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
5372
+ const rel = path9.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
4446
5373
  const rootOverride = loaded ? resolveAppConfig(loaded.config, loaded.configDir, pkg.dir) : null;
4447
5374
  let pkgConfig;
4448
5375
  try {
@@ -4550,8 +5477,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
4550
5477
  PORTLESS_URL: url
4551
5478
  };
4552
5479
  if (tls2) {
4553
- const caPath = path8.join(stateDir, "ca.pem");
4554
- if (fs8.existsSync(caPath)) {
5480
+ const caPath = path9.join(stateDir, "ca.pem");
5481
+ if (fs9.existsSync(caPath)) {
4555
5482
  entry.NODE_EXTRA_CA_CERTS = caPath;
4556
5483
  }
4557
5484
  }
@@ -4825,6 +5752,13 @@ async function main() {
4825
5752
  process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
4826
5753
  process.env.PORTLESS_LAN = "1";
4827
5754
  }
5755
+ if (stripGlobalFlag("--tailscale", false)) {
5756
+ process.env.PORTLESS_TAILSCALE = "1";
5757
+ }
5758
+ if (stripGlobalFlag("--funnel", false)) {
5759
+ process.env.PORTLESS_FUNNEL = "1";
5760
+ process.env.PORTLESS_TAILSCALE = "1";
5761
+ }
4828
5762
  const scriptResult = stripGlobalFlag("--script", true);
4829
5763
  if (scriptResult === false) {
4830
5764
  console.error(colors_default.red("Error: --script requires a script name."));
@@ -4857,7 +5791,7 @@ async function main() {
4857
5791
  args.shift();
4858
5792
  }
4859
5793
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
4860
- if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
5794
+ if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
4861
5795
  const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
4862
5796
  let commandArgs = parsed.commandArgs;
4863
5797
  if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
@@ -4921,6 +5855,10 @@ async function main() {
4921
5855
  await handleProxy(args);
4922
5856
  return;
4923
5857
  }
5858
+ if (args[0] === "service") {
5859
+ await handleService(args, { entryScript: getEntryScript() });
5860
+ return;
5861
+ }
4924
5862
  }
4925
5863
  if (isRunCommand) {
4926
5864
  await handleRunMode(args, globalScript);