portless 0.12.0 → 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.
Files changed (3) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +714 -85
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -249,6 +249,18 @@ portless trust
249
249
 
250
250
  On Linux, `portless trust` supports Debian/Ubuntu, Arch, Fedora/RHEL/CentOS, and openSUSE (via `update-ca-certificates` or `update-ca-trust`). On Windows, it uses `certutil` to add the CA to the system trust store.
251
251
 
252
+ ## Start at OS startup
253
+
254
+ Install the proxy as an OS startup service so clean HTTPS URLs are available after reboot without starting the proxy from a terminal:
255
+
256
+ ```bash
257
+ portless service install
258
+ portless service status
259
+ portless service uninstall
260
+ ```
261
+
262
+ The service uses portless defaults: HTTPS on port 443 with `.localhost` names. macOS and Linux install a root-owned service so port 443 can bind at boot. Windows installs a Task Scheduler startup task that runs as SYSTEM. Installation and removal may require administrator privileges. `portless clean` automatically removes the service.
263
+
252
264
  ## LAN mode
253
265
 
254
266
  ```bash
@@ -300,9 +312,11 @@ portless myapp --funnel next dev
300
312
  # -> https://devbox.yourteam.ts.net (public)
301
313
  ```
302
314
 
315
+ Tailscale HTTPS certificates must be enabled before `--tailscale` or `--funnel` can register HTTPS URLs. Funnel must also be enabled for the tailnet and node before `--funnel` can register the public URL. If either setting is missing, portless exits before starting the child process.
316
+
303
317
  Set `PORTLESS_TAILSCALE=1` in your shell profile or `.env` to share every app by default. `portless list` shows both local and tailnet URLs. Tailscale serve registrations are cleaned up automatically when the app exits.
304
318
 
305
- Requires the Tailscale CLI to be installed and connected (`tailscale up`).
319
+ Requires the Tailscale CLI to be installed and connected (`tailscale up`), with Tailscale HTTPS certificates enabled.
306
320
 
307
321
  ## Commands
308
322
 
@@ -332,6 +346,11 @@ portless proxy start -p 1355 # Start on a custom port (no sudo)
332
346
  portless proxy start --foreground # Start in foreground (for debugging)
333
347
  portless proxy start --wildcard # Allow unregistered subdomains to fall back to parent
334
348
  portless proxy stop # Stop the proxy
349
+
350
+ # OS startup service
351
+ portless service install # Start HTTPS proxy when the OS starts
352
+ portless service status # Show service and proxy status
353
+ portless service uninstall # Remove the startup service
335
354
  ```
336
355
 
337
356
  ### Options
@@ -378,7 +397,7 @@ PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is a
378
397
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
379
398
  ```
380
399
 
381
- > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, `clean`, `prune`, and `proxy` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
400
+ > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, `clean`, `prune`, `proxy`, and `service` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
382
401
 
383
402
  ## Uninstall / reset
384
403
 
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]
@@ -2524,6 +2591,522 @@ 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 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
+
2527
3110
  // src/cli.ts
2528
3111
  var chalk = colors_default;
2529
3112
  var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
@@ -2676,16 +3259,16 @@ function getEntryScript() {
2676
3259
  function isLocallyInstalled() {
2677
3260
  let dir = process.cwd();
2678
3261
  for (; ; ) {
2679
- if (fs8.existsSync(path8.join(dir, "node_modules", "portless", "package.json"))) {
3262
+ if (fs9.existsSync(path9.join(dir, "node_modules", "portless", "package.json"))) {
2680
3263
  return true;
2681
3264
  }
2682
- const parent = path8.dirname(dir);
3265
+ const parent = path9.dirname(dir);
2683
3266
  if (parent === dir) break;
2684
3267
  dir = parent;
2685
3268
  }
2686
3269
  return false;
2687
3270
  }
2688
- function collectPortlessEnvArgs() {
3271
+ function collectPortlessEnvArgs2() {
2689
3272
  const envArgs = [];
2690
3273
  for (const key of Object.keys(process.env)) {
2691
3274
  if (key.startsWith("PORTLESS_") && process.env[key]) {
@@ -2697,7 +3280,35 @@ function collectPortlessEnvArgs() {
2697
3280
  function sudoStop(port) {
2698
3281
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
2699
3282
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
2700
- const result = spawnSync3("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()), {
2701
3312
  stdio: "inherit",
2702
3313
  timeout: SUDO_SPAWN_TIMEOUT_MS
2703
3314
  });
@@ -2715,11 +3326,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2715
3326
  console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
2716
3327
  }
2717
3328
  const routesPath = store.getRoutesPath();
2718
- if (!fs8.existsSync(routesPath)) {
2719
- fs8.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
3329
+ if (!fs9.existsSync(routesPath)) {
3330
+ fs9.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
2720
3331
  }
2721
3332
  try {
2722
- fs8.chmodSync(routesPath, FILE_MODE);
3333
+ fs9.chmodSync(routesPath, FILE_MODE);
2723
3334
  } catch {
2724
3335
  }
2725
3336
  fixOwnership(routesPath);
@@ -2779,7 +3390,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2779
3390
  }
2780
3391
  };
2781
3392
  try {
2782
- watcher = fs8.watch(routesPath, () => {
3393
+ watcher = fs9.watch(routesPath, () => {
2783
3394
  if (debounceTimer) clearTimeout(debounceTimer);
2784
3395
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
2785
3396
  });
@@ -2829,8 +3440,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2829
3440
  redirectServer.listen(80);
2830
3441
  }
2831
3442
  server.listen(proxyPort, () => {
2832
- fs8.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
2833
- 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 });
2834
3445
  writeTlsMarker(store.dir, isTls);
2835
3446
  writeTldFile(store.dir, tld);
2836
3447
  writeLanMarker(store.dir, activeLanIp);
@@ -2846,7 +3457,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2846
3457
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
2847
3458
  if (isTls) {
2848
3459
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
2849
- console.log(chalk.gray(` ${path8.join(store.dir, "ca.pem")}`));
3460
+ console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
2850
3461
  }
2851
3462
  if (!lanIpPinned) {
2852
3463
  lanMonitor = startLanIpMonitor({
@@ -2878,11 +3489,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
2878
3489
  redirectServer.close();
2879
3490
  }
2880
3491
  try {
2881
- fs8.unlinkSync(store.pidPath);
3492
+ fs9.unlinkSync(store.pidPath);
2882
3493
  } catch {
2883
3494
  }
2884
3495
  try {
2885
- fs8.unlinkSync(store.portFilePath);
3496
+ fs9.unlinkSync(store.portFilePath);
2886
3497
  } catch {
2887
3498
  }
2888
3499
  writeTlsMarker(store.dir, false);
@@ -2912,7 +3523,7 @@ function sudoStopOrHint(port) {
2912
3523
  }
2913
3524
  async function stopProxy(store, proxyPort, _tls) {
2914
3525
  const pidPath = store.pidPath;
2915
- if (!fs8.existsSync(pidPath)) {
3526
+ if (!fs9.existsSync(pidPath)) {
2916
3527
  if (await isProxyRunning(proxyPort)) {
2917
3528
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
2918
3529
  const pid = findPidOnPort(proxyPort);
@@ -2920,7 +3531,7 @@ async function stopProxy(store, proxyPort, _tls) {
2920
3531
  try {
2921
3532
  process.kill(pid, "SIGTERM");
2922
3533
  try {
2923
- fs8.unlinkSync(store.portFilePath);
3534
+ fs9.unlinkSync(store.portFilePath);
2924
3535
  } catch {
2925
3536
  }
2926
3537
  writeTlsMarker(store.dir, false);
@@ -2958,10 +3569,10 @@ async function stopProxy(store, proxyPort, _tls) {
2958
3569
  return;
2959
3570
  }
2960
3571
  try {
2961
- const pid = parseInt(fs8.readFileSync(pidPath, "utf-8"), 10);
3572
+ const pid = parseInt(fs9.readFileSync(pidPath, "utf-8"), 10);
2962
3573
  if (isNaN(pid)) {
2963
3574
  console.error(colors_default.red("Corrupted PID file. Removing it."));
2964
- fs8.unlinkSync(pidPath);
3575
+ fs9.unlinkSync(pidPath);
2965
3576
  writeTlsMarker(store.dir, false);
2966
3577
  writeTldFile(store.dir, DEFAULT_TLD);
2967
3578
  writeLanMarker(store.dir, null);
@@ -2975,9 +3586,9 @@ async function stopProxy(store, proxyPort, _tls) {
2975
3586
  return;
2976
3587
  }
2977
3588
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
2978
- fs8.unlinkSync(pidPath);
3589
+ fs9.unlinkSync(pidPath);
2979
3590
  try {
2980
- fs8.unlinkSync(store.portFilePath);
3591
+ fs9.unlinkSync(store.portFilePath);
2981
3592
  } catch {
2982
3593
  }
2983
3594
  writeTlsMarker(store.dir, false);
@@ -2992,16 +3603,16 @@ async function stopProxy(store, proxyPort, _tls) {
2992
3603
  )
2993
3604
  );
2994
3605
  console.log(colors_default.yellow("Removing stale PID file."));
2995
- fs8.unlinkSync(pidPath);
3606
+ fs9.unlinkSync(pidPath);
2996
3607
  writeTlsMarker(store.dir, false);
2997
3608
  writeTldFile(store.dir, DEFAULT_TLD);
2998
3609
  writeLanMarker(store.dir, null);
2999
3610
  return;
3000
3611
  }
3001
3612
  process.kill(pid, "SIGTERM");
3002
- fs8.unlinkSync(pidPath);
3613
+ fs9.unlinkSync(pidPath);
3003
3614
  try {
3004
- fs8.unlinkSync(store.portFilePath);
3615
+ fs9.unlinkSync(store.portFilePath);
3005
3616
  } catch {
3006
3617
  }
3007
3618
  writeTlsMarker(store.dir, false);
@@ -3124,7 +3735,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
3124
3735
  proxyPort: startPort
3125
3736
  });
3126
3737
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
3127
- const result = spawnSync3(process.execPath, startArgs, {
3738
+ const result = spawnSync4(process.execPath, startArgs, {
3128
3739
  stdio: "inherit",
3129
3740
  timeout: SUDO_SPAWN_TIMEOUT_MS
3130
3741
  });
@@ -3142,10 +3753,10 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
3142
3753
  if (!discovered) {
3143
3754
  console.error(colors_default.red("Failed to start proxy."));
3144
3755
  const fallbackDir = resolveStateDir(effectivePort);
3145
- const logPath = path8.join(fallbackDir, "proxy.log");
3756
+ const logPath = path9.join(fallbackDir, "proxy.log");
3146
3757
  console.error(colors_default.blue("Try starting it manually:"));
3147
3758
  console.error(colors_default.cyan(` ${manualStartCommand}`));
3148
- if (fs8.existsSync(logPath)) {
3759
+ if (fs9.existsSync(logPath)) {
3149
3760
  console.error(colors_default.gray(`Logs: ${logPath}`));
3150
3761
  }
3151
3762
  process.exit(1);
@@ -3163,14 +3774,17 @@ portless
3163
3774
  let tsBaseUrl;
3164
3775
  if (wantsTailscale) {
3165
3776
  try {
3166
- const tsReady = ensureTailscaleReady();
3777
+ const tsReady = ensureTailscaleReady({
3778
+ requireFunnel: wantsFunnel,
3779
+ requireHttps: true
3780
+ });
3167
3781
  tsBaseUrl = tsReady.baseUrl;
3168
3782
  } catch (err) {
3169
3783
  const message = err instanceof Error ? err.message : String(err);
3170
3784
  console.error(colors_default.red(`Error: ${message}`));
3171
3785
  if (message.includes("not found")) {
3172
3786
  console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
3173
- } else {
3787
+ } else if (!message.includes("not enabled on your tailnet")) {
3174
3788
  console.error(colors_default.blue("Make sure Tailscale is connected:"));
3175
3789
  console.error(colors_default.cyan(" tailscale up"));
3176
3790
  }
@@ -3299,7 +3913,7 @@ portless
3299
3913
  } catch {
3300
3914
  }
3301
3915
  }
3302
- const basename5 = path8.basename(commandArgs[0]);
3916
+ const basename5 = path9.basename(commandArgs[0]);
3303
3917
  const isExpo = basename5 === "expo";
3304
3918
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
3305
3919
  const hostBind = isExpoLan ? void 0 : "127.0.0.1";
@@ -3309,8 +3923,8 @@ portless
3309
3923
  injectFrameworkFlags(commandArgs, port);
3310
3924
  const caEnv = {};
3311
3925
  if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
3312
- const caPath = path8.join(stateDir, "ca.pem");
3313
- if (fs8.existsSync(caPath)) {
3926
+ const caPath = path9.join(stateDir, "ca.pem");
3927
+ if (fs9.existsSync(caPath)) {
3314
3928
  caEnv.NODE_EXTRA_CA_CERTS = caPath;
3315
3929
  }
3316
3930
  }
@@ -3515,6 +4129,7 @@ ${colors_default.bold("Usage:")}
3515
4129
  ${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
3516
4130
  ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
3517
4131
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
4132
+ ${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
3518
4133
  ${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
3519
4134
  ${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
3520
4135
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
@@ -3532,6 +4147,7 @@ ${colors_default.bold("Examples:")}
3532
4147
  portless myapp next dev # -> https://myapp.localhost
3533
4148
  portless run next dev # -> https://<project>.localhost
3534
4149
  portless run next dev # in worktree -> https://<worktree>.<project>.localhost
4150
+ portless service install # Start HTTPS proxy on OS startup
3535
4151
  portless get backend # -> https://backend.localhost
3536
4152
  portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
3537
4153
  portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
@@ -3590,7 +4206,9 @@ ${colors_default.bold("Tailscale sharing:")}
3590
4206
  Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
3591
4207
  8444, etc.) so no basePath configuration is needed.
3592
4208
  Use --funnel to expose your dev server to the public internet via
3593
- Tailscale Funnel. Requires Tailscale CLI to be installed and connected.
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.
3594
4212
  ${colors_default.cyan("portless myapp --tailscale next dev")}
3595
4213
  ${colors_default.cyan("portless myapp --funnel next dev")}
3596
4214
 
@@ -3650,20 +4268,20 @@ ${colors_default.bold("Skip portless:")}
3650
4268
  PORTLESS=0 pnpm dev # Runs command directly without proxy
3651
4269
 
3652
4270
  ${colors_default.bold("Reserved names:")}
3653
- 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
3654
4272
  cannot be used as app names directly. Use "portless run" to infer the name,
3655
4273
  or "portless --name <name>" to force any name including reserved ones.
3656
4274
  `);
3657
4275
  process.exit(0);
3658
4276
  }
3659
4277
  function printVersion() {
3660
- console.log("0.12.0");
4278
+ console.log("0.13.0");
3661
4279
  process.exit(0);
3662
4280
  }
3663
4281
  async function handleTrust() {
3664
4282
  const { dir } = await discoverState();
3665
- if (!fs8.existsSync(dir)) {
3666
- fs8.mkdirSync(dir, { recursive: true });
4283
+ if (!fs9.existsSync(dir)) {
4284
+ fs9.mkdirSync(dir, { recursive: true });
3667
4285
  }
3668
4286
  const { caGenerated } = ensureCerts(dir);
3669
4287
  if (caGenerated) {
@@ -3675,14 +4293,14 @@ async function handleTrust() {
3675
4293
  console.log(colors_default.gray("Browsers will now trust portless HTTPS certificates."));
3676
4294
  return;
3677
4295
  }
3678
- const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
3679
- 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) {
3680
4298
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
3681
- const sudoResult = spawnSync3(
4299
+ const sudoResult = spawnSync4(
3682
4300
  "sudo",
3683
4301
  [
3684
4302
  "env",
3685
- ...collectPortlessEnvArgs(),
4303
+ ...collectPortlessEnvArgs2(),
3686
4304
  `PORTLESS_STATE_DIR=${dir}`,
3687
4305
  process.execPath,
3688
4306
  getEntryScript(),
@@ -3704,10 +4322,11 @@ async function handleClean(args) {
3704
4322
  console.log(`
3705
4323
  ${colors_default.bold("portless clean")} - Remove portless artifacts from this machine.
3706
4324
 
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}.
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}.
3711
4330
 
3712
4331
  Only allowlisted filenames under each state directory are deleted. Custom
3713
4332
  certificate paths from --cert and --key are never removed.
@@ -3728,6 +4347,23 @@ ${colors_default.bold("Options:")}
3728
4347
  console.error(colors_default.cyan(" portless clean --help"));
3729
4348
  process.exit(1);
3730
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
+ }
3731
4367
  console.log(colors_default.cyan("Stopping proxy if it is running..."));
3732
4368
  const { dir, port, tls: tls2 } = await discoverState();
3733
4369
  const store = new RouteStore(dir, {
@@ -3746,8 +4382,8 @@ ${colors_default.bold("Options:")}
3746
4382
  }
3747
4383
  const stateDirs = collectStateDirsForCleanup();
3748
4384
  for (const stateDir of stateDirs) {
3749
- const caPath = path8.join(stateDir, "ca.pem");
3750
- if (!fs8.existsSync(caPath)) continue;
4385
+ const caPath = path9.join(stateDir, "ca.pem");
4386
+ if (!fs9.existsSync(caPath)) continue;
3751
4387
  const wasTrusted = isCATrusted(stateDir);
3752
4388
  if (!wasTrusted) continue;
3753
4389
  const untrustResult = untrustCA(stateDir);
@@ -3769,18 +4405,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
3769
4405
  if (cleanHostsFile()) {
3770
4406
  console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
3771
4407
  } 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) {
4408
+ if (!runCleanWithSudo(`Updating ${HOSTS_DISPLAY} requires elevated privileges.`)) {
3784
4409
  console.error(colors_default.red(`Failed to update ${HOSTS_DISPLAY}. Run: sudo portless clean`));
3785
4410
  process.exit(1);
3786
4411
  }
@@ -4011,9 +4636,9 @@ ${colors_default.bold("Auto-sync:")}
4011
4636
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
4012
4637
  )
4013
4638
  );
4014
- const result = spawnSync3(
4639
+ const result = spawnSync4(
4015
4640
  "sudo",
4016
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
4641
+ ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
4017
4642
  {
4018
4643
  stdio: "inherit",
4019
4644
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -4064,9 +4689,9 @@ ${colors_default.bold("Usage: portless hosts <command>")}
4064
4689
  console.log(
4065
4690
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
4066
4691
  );
4067
- const result = spawnSync3(
4692
+ const result = spawnSync4(
4068
4693
  "sudo",
4069
- ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
4694
+ ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
4070
4695
  {
4071
4696
  stdio: "inherit",
4072
4697
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -4355,7 +4980,7 @@ ${colors_default.bold("LAN mode (--lan):")}
4355
4980
  if (!hasExplicitPort) {
4356
4981
  console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
4357
4982
  }
4358
- const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
4983
+ const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
4359
4984
  stdio: "inherit",
4360
4985
  timeout: SUDO_SPAWN_TIMEOUT_MS
4361
4986
  });
@@ -4365,8 +4990,8 @@ ${colors_default.bold("LAN mode (--lan):")}
4365
4990
  console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
4366
4991
  } else {
4367
4992
  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)) {
4993
+ const logPath2 = path9.join(resolveStateDir(proxyPort), "proxy.log");
4994
+ if (fs9.existsSync(logPath2)) {
4370
4995
  console.error(colors_default.gray(`Logs: ${logPath2}`));
4371
4996
  }
4372
4997
  }
@@ -4405,8 +5030,8 @@ ${colors_default.bold("LAN mode (--lan):")}
4405
5030
  store.ensureDir();
4406
5031
  if (customCertPath && customKeyPath) {
4407
5032
  try {
4408
- const cert = fs8.readFileSync(customCertPath);
4409
- const key = fs8.readFileSync(customKeyPath);
5033
+ const cert = fs9.readFileSync(customCertPath);
5034
+ const key = fs9.readFileSync(customKeyPath);
4410
5035
  const certStr = cert.toString("utf-8");
4411
5036
  const keyStr = key.toString("utf-8");
4412
5037
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -4451,9 +5076,9 @@ ${colors_default.bold("LAN mode (--lan):")}
4451
5076
  console.warn(colors_default.cyan(" portless trust"));
4452
5077
  }
4453
5078
  }
4454
- const cert = fs8.readFileSync(certs.certPath);
4455
- const key = fs8.readFileSync(certs.keyPath);
4456
- 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);
4457
5082
  tlsOptions = {
4458
5083
  cert,
4459
5084
  key,
@@ -4468,11 +5093,11 @@ ${colors_default.bold("LAN mode (--lan):")}
4468
5093
  return;
4469
5094
  }
4470
5095
  store.ensureDir();
4471
- const logPath = path8.join(stateDir, "proxy.log");
4472
- const logFd = fs8.openSync(logPath, "a");
5096
+ const logPath = path9.join(stateDir, "proxy.log");
5097
+ const logFd = fs9.openSync(logPath, "a");
4473
5098
  try {
4474
5099
  try {
4475
- fs8.chmodSync(logPath, FILE_MODE);
5100
+ fs9.chmodSync(logPath, FILE_MODE);
4476
5101
  } catch {
4477
5102
  }
4478
5103
  fixOwnership(logPath);
@@ -4503,13 +5128,13 @@ ${colors_default.bold("LAN mode (--lan):")}
4503
5128
  });
4504
5129
  child.unref();
4505
5130
  } finally {
4506
- fs8.closeSync(logFd);
5131
+ fs9.closeSync(logFd);
4507
5132
  }
4508
5133
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
4509
5134
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
4510
5135
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
4511
5136
  console.error(colors_default.cyan(" portless proxy start --foreground"));
4512
- if (fs8.existsSync(logPath)) {
5137
+ if (fs9.existsSync(logPath)) {
4513
5138
  console.error(colors_default.gray(`Logs: ${logPath}`));
4514
5139
  }
4515
5140
  process.exit(1);
@@ -4661,8 +5286,8 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
4661
5286
  PORTLESS_URL: url
4662
5287
  };
4663
5288
  if (tls2) {
4664
- const caPath = path8.join(stateDir, "ca.pem");
4665
- if (fs8.existsSync(caPath)) {
5289
+ const caPath = path9.join(stateDir, "ca.pem");
5290
+ if (fs9.existsSync(caPath)) {
4666
5291
  env.NODE_EXTRA_CA_CERTS = caPath;
4667
5292
  }
4668
5293
  }
@@ -4744,7 +5369,7 @@ async function handleDefaultMulti(wsRoot, globalScript, extraArgs = []) {
4744
5369
  }
4745
5370
  const apps = [];
4746
5371
  for (const pkg of packages) {
4747
- const rel = path8.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
5372
+ const rel = path9.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
4748
5373
  const rootOverride = loaded ? resolveAppConfig(loaded.config, loaded.configDir, pkg.dir) : null;
4749
5374
  let pkgConfig;
4750
5375
  try {
@@ -4852,8 +5477,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
4852
5477
  PORTLESS_URL: url
4853
5478
  };
4854
5479
  if (tls2) {
4855
- const caPath = path8.join(stateDir, "ca.pem");
4856
- if (fs8.existsSync(caPath)) {
5480
+ const caPath = path9.join(stateDir, "ca.pem");
5481
+ if (fs9.existsSync(caPath)) {
4857
5482
  entry.NODE_EXTRA_CA_CERTS = caPath;
4858
5483
  }
4859
5484
  }
@@ -5166,7 +5791,7 @@ async function main() {
5166
5791
  args.shift();
5167
5792
  }
5168
5793
  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")) {
5794
+ if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
5170
5795
  const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
5171
5796
  let commandArgs = parsed.commandArgs;
5172
5797
  if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
@@ -5230,6 +5855,10 @@ async function main() {
5230
5855
  await handleProxy(args);
5231
5856
  return;
5232
5857
  }
5858
+ if (args[0] === "service") {
5859
+ await handleService(args, { entryScript: getEntryScript() });
5860
+ return;
5861
+ }
5233
5862
  }
5234
5863
  if (isRunCommand) {
5235
5864
  await handleRunMode(args, globalScript);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",