portless 0.11.1 → 0.12.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/README.md CHANGED
@@ -276,6 +276,34 @@ LAN mode depends on the system mDNS tools that portless already spawns: macOS sh
276
276
 
277
277
  - **Expo / React Native**: portless always injects `--port`. React Native also gets `--host 127.0.0.1`. Expo gets `--host localhost` outside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing `--host` or `HOST`.
278
278
 
279
+ ## Tailscale sharing
280
+
281
+ Share your dev server with teammates on your [Tailscale](https://tailscale.com) network:
282
+
283
+ ```bash
284
+ portless myapp --tailscale next dev
285
+ # -> https://myapp.localhost (local)
286
+ # -> https://devbox.yourteam.ts.net (tailnet)
287
+ ```
288
+
289
+ Each `--tailscale` app is root-mounted on its own Tailscale HTTPS port, so no framework `basePath` configuration is needed. The first app gets port 443, subsequent apps get 8443, 8444, etc.
290
+
291
+ ```bash
292
+ portless myapp --tailscale next dev # -> https://devbox.ts.net
293
+ portless api --tailscale pnpm start # -> https://devbox.ts.net:8443
294
+ ```
295
+
296
+ Use `--funnel` to expose your dev server to the public internet via [Tailscale Funnel](https://tailscale.com/kb/1223/funnel/):
297
+
298
+ ```bash
299
+ portless myapp --funnel next dev
300
+ # -> https://devbox.yourteam.ts.net (public)
301
+ ```
302
+
303
+ 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
+
305
+ Requires the Tailscale CLI to be installed and connected (`tailscale up`).
306
+
279
307
  ## Commands
280
308
 
281
309
  ```bash
@@ -321,6 +349,8 @@ portless proxy stop # Stop the proxy
321
349
  --wildcard Allow unregistered subdomains to fall back to parent route
322
350
  --script <name> Run a specific package.json script (default: dev)
323
351
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
352
+ --tailscale Share the app on your Tailscale network (tailnet)
353
+ --funnel Share the app publicly via Tailscale Funnel
324
354
  --force Kill the existing process and take over its route
325
355
  --name <name> Use <name> as the app name
326
356
  ```
@@ -336,12 +366,15 @@ PORTLESS_LAN=1 Enable LAN mode when set to 1 (auto-detects LAN
336
366
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
337
367
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
338
368
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
369
+ PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
370
+ PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
339
371
  PORTLESS_STATE_DIR=<path> Override the state directory
340
372
 
341
373
  # Injected into child processes
342
374
  PORT Ephemeral port the child should listen on
343
375
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
344
376
  PORTLESS_URL Public URL (e.g. https://myapp.localhost)
377
+ PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
345
378
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
346
379
  ```
347
380
 
@@ -422,3 +455,4 @@ pnpm format # Format all files with Prettier
422
455
 
423
456
  - Node.js 20+
424
457
  - macOS, Linux, or Windows
458
+ - Tailscale CLI (optional, for `--tailscale` and `--funnel`)
@@ -846,7 +846,8 @@ var RouteStore = class _RouteStore {
846
846
  }
847
847
  }
848
848
  const filtered = routes.filter((r) => r.hostname !== hostname);
849
- filtered.push({ hostname, port, pid });
849
+ const entry = { hostname, port, pid };
850
+ filtered.push(entry);
850
851
  this.saveRoutes(filtered);
851
852
  } finally {
852
853
  this.releaseLock();
@@ -906,6 +907,28 @@ var RouteStore = class _RouteStore {
906
907
  this.releaseLock();
907
908
  }
908
909
  }
910
+ /**
911
+ * Update metadata on an existing route entry. Only provided fields are
912
+ * merged; the route must already exist (matched by hostname).
913
+ */
914
+ updateRoute(hostname, fields) {
915
+ this.ensureDir();
916
+ if (!this.acquireLock()) {
917
+ throw new Error("Failed to acquire route lock");
918
+ }
919
+ try {
920
+ const routes = this.loadRoutes(true);
921
+ const route = routes.find((r) => r.hostname === hostname);
922
+ if (!route) return;
923
+ if (fields.tailscaleUrl !== void 0) route.tailscaleUrl = fields.tailscaleUrl;
924
+ if (fields.tailscaleHttpsPort !== void 0)
925
+ route.tailscaleHttpsPort = fields.tailscaleHttpsPort;
926
+ if (fields.tailscaleFunnel !== void 0) route.tailscaleFunnel = fields.tailscaleFunnel;
927
+ this.saveRoutes(routes);
928
+ } finally {
929
+ this.releaseLock();
930
+ }
931
+ }
909
932
  removeRoute(hostname) {
910
933
  this.ensureDir();
911
934
  if (!this.acquireLock()) {
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() {
@@ -41,7 +41,7 @@ var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
41
41
  // src/cli.ts
42
42
  import * as fs8 from "fs";
43
43
  import * as path8 from "path";
44
- import { spawn as spawn3, spawnSync as spawnSync2 } from "child_process";
44
+ import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
45
45
  import { StringDecoder } from "string_decoder";
46
46
 
47
47
  // src/certs.ts
@@ -754,6 +754,184 @@ 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 PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
761
+ var FUNNEL_PORTS = [443, 8443, 1e4];
762
+ function defaultRunner(args) {
763
+ const result = spawnSync(TAILSCALE_BINARY, args, { encoding: "utf-8" });
764
+ return {
765
+ status: result.status,
766
+ stdout: result.stdout ?? "",
767
+ stderr: result.stderr ?? "",
768
+ ...result.error ? { error: result.error } : {}
769
+ };
770
+ }
771
+ function trimDot(value) {
772
+ return value.endsWith(".") ? value.slice(0, -1) : value;
773
+ }
774
+ function normalizeSpace(value) {
775
+ return value.trim().replace(/\s+/g, " ");
776
+ }
777
+ function runOrThrow(args, action, runner) {
778
+ const result = runner(args);
779
+ if (result.error) {
780
+ const errno = result.error;
781
+ if (errno.code === "ENOENT") {
782
+ throw new Error(
783
+ "Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
784
+ );
785
+ }
786
+ throw new Error(`Failed to ${action}: ${result.error.message}`);
787
+ }
788
+ if (result.status !== 0) {
789
+ const details = normalizeSpace(result.stderr || result.stdout);
790
+ throw new Error(`Failed to ${action}: ${details || "unknown tailscale error"}`);
791
+ }
792
+ return result;
793
+ }
794
+ function parseStatusJson(raw) {
795
+ try {
796
+ return JSON.parse(raw);
797
+ } catch {
798
+ throw new Error("Failed to parse `tailscale status --json` output.");
799
+ }
800
+ }
801
+ function statusToDnsName(status) {
802
+ const dnsName = status.Self?.DNSName;
803
+ if (typeof dnsName === "string" && dnsName.length > 0) {
804
+ return trimDot(dnsName);
805
+ }
806
+ const host = status.Self?.HostName;
807
+ const suffix = status.CurrentTailnet?.MagicDNSSuffix;
808
+ if (typeof host === "string" && host.length > 0 && typeof suffix === "string" && suffix.length > 0) {
809
+ return `${host}.${trimDot(suffix)}`;
810
+ }
811
+ throw new Error(
812
+ "Could not determine Tailscale node DNS name from `tailscale status --json`. Is Tailscale connected?"
813
+ );
814
+ }
815
+ function ensureTailscaleReady(runner = defaultRunner) {
816
+ runOrThrow(["version"], "check tailscale version", runner);
817
+ const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
818
+ const status = parseStatusJson(statusResult.stdout);
819
+ const dnsName = statusToDnsName(status);
820
+ return {
821
+ dnsName,
822
+ baseUrl: `https://${dnsName}`
823
+ };
824
+ }
825
+ function getUsedServePorts(runner = defaultRunner) {
826
+ const result = runner(["serve", "status", "--json"]);
827
+ if (result.error || result.status !== 0) {
828
+ return /* @__PURE__ */ new Set();
829
+ }
830
+ try {
831
+ const config = JSON.parse(result.stdout);
832
+ const ports = /* @__PURE__ */ new Set();
833
+ if (config.Web) {
834
+ for (const hostPort of Object.keys(config.Web)) {
835
+ const match = hostPort.match(/:(\d+)$/);
836
+ if (match) {
837
+ ports.add(parseInt(match[1], 10));
838
+ }
839
+ }
840
+ }
841
+ if (config.TCP) {
842
+ for (const portStr of Object.keys(config.TCP)) {
843
+ const p = parseInt(portStr, 10);
844
+ if (!isNaN(p)) ports.add(p);
845
+ }
846
+ }
847
+ return ports;
848
+ } catch {
849
+ return /* @__PURE__ */ new Set();
850
+ }
851
+ }
852
+ function findAvailableServePort(usedPorts, mode = "serve") {
853
+ const pool = mode === "funnel" ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS;
854
+ for (const port2 of pool) {
855
+ if (!usedPorts.has(port2)) return port2;
856
+ }
857
+ if (mode === "funnel") {
858
+ throw new Error(
859
+ "All Tailscale Funnel ports are in use (443, 8443, 10000). Stop an existing funnel to free a port."
860
+ );
861
+ }
862
+ let port = PREFERRED_SERVE_PORTS[PREFERRED_SERVE_PORTS.length - 1] + 1;
863
+ while (usedPorts.has(port)) port++;
864
+ return port;
865
+ }
866
+ function isConflictError(stderr, stdout) {
867
+ const text = `${stderr}
868
+ ${stdout}`.toLowerCase();
869
+ return text.includes("already in use") || text.includes("already exists") || text.includes("port conflict") || text.includes("address already");
870
+ }
871
+ var CONFLICT_MESSAGES = {
872
+ serve: "Stop the existing serve or let portless auto-assign a different port.",
873
+ funnel: "Tailscale Funnel supports ports 443, 8443, and 10000."
874
+ };
875
+ function register(mode, localPort, httpsPort, runner) {
876
+ const target = `http://127.0.0.1:${localPort}`;
877
+ const result = runner([mode, "--bg", "--yes", `--https=${httpsPort}`, target]);
878
+ if (result.error) {
879
+ const errno = result.error;
880
+ if (errno.code === "ENOENT") {
881
+ throw new Error(
882
+ "Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
883
+ );
884
+ }
885
+ throw new Error(`Failed to register tailscale ${mode}: ${result.error.message}`);
886
+ }
887
+ if (result.status !== 0) {
888
+ if (isConflictError(result.stderr, result.stdout)) {
889
+ throw new Error(
890
+ `Tailscale ${mode === "funnel" ? "Funnel " : ""}HTTPS port ${httpsPort} is already in use. ` + CONFLICT_MESSAGES[mode]
891
+ );
892
+ }
893
+ const details = normalizeSpace(result.stderr || result.stdout);
894
+ throw new Error(
895
+ `Failed to register tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
896
+ );
897
+ }
898
+ }
899
+ function unregister(mode, httpsPort, options) {
900
+ const runner = options?.runner ?? defaultRunner;
901
+ const result = runner([mode, "--yes", `--https=${httpsPort}`, "off"]);
902
+ if (result.error) {
903
+ const errno = result.error;
904
+ if (errno.code === "ENOENT") return;
905
+ throw new Error(`Failed to remove tailscale ${mode}: ${result.error.message}`);
906
+ }
907
+ if (result.status !== 0) {
908
+ const text = `${result.stderr}
909
+ ${result.stdout}`.toLowerCase();
910
+ const looksLikeMissing = text.includes("not found") || text.includes("no serve config") || text.includes("nothing to remove") || text.includes("does not exist");
911
+ if (options?.ignoreMissing && looksLikeMissing) return;
912
+ const details = normalizeSpace(result.stderr || result.stdout);
913
+ throw new Error(
914
+ `Failed to remove tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
915
+ );
916
+ }
917
+ }
918
+ function registerServe(localPort, httpsPort, options) {
919
+ register("serve", localPort, httpsPort, options?.runner ?? defaultRunner);
920
+ }
921
+ function registerFunnel(localPort, httpsPort, options) {
922
+ register("funnel", localPort, httpsPort, options?.runner ?? defaultRunner);
923
+ }
924
+ function unregisterTailscale(route) {
925
+ if (!route.tailscaleHttpsPort) return;
926
+ const mode = route.tailscaleFunnel ? "funnel" : "serve";
927
+ unregister(mode, route.tailscaleHttpsPort, { ignoreMissing: true });
928
+ }
929
+ function formatTailscaleUrl(baseUrl, httpsPort) {
930
+ const trimmed = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
931
+ if (httpsPort === 443) return trimmed;
932
+ return `${trimmed}:${httpsPort}`;
933
+ }
934
+
757
935
  // src/auto.ts
758
936
  import { createHash as createHash2 } from "crypto";
759
937
  import { execFileSync as execFileSync2 } from "child_process";
@@ -1622,7 +1800,7 @@ function removePortlessStateFiles(dir) {
1622
1800
  }
1623
1801
 
1624
1802
  // src/mdns.ts
1625
- import { spawn as spawn2, spawnSync } from "child_process";
1803
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
1626
1804
 
1627
1805
  // src/lan-ip.ts
1628
1806
  import { createSocket } from "dgram";
@@ -1738,7 +1916,7 @@ function getMdnsPublisher() {
1738
1916
  return null;
1739
1917
  }
1740
1918
  function hasCommand(command, probeArgs) {
1741
- const result = spawnSync(command, probeArgs, {
1919
+ const result = spawnSync2(command, probeArgs, {
1742
1920
  stdio: "ignore",
1743
1921
  timeout: 1e3,
1744
1922
  windowsHide: true
@@ -2519,7 +2697,7 @@ function collectPortlessEnvArgs() {
2519
2697
  function sudoStop(port) {
2520
2698
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
2521
2699
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
2522
- const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
2700
+ const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
2523
2701
  stdio: "inherit",
2524
2702
  timeout: SUDO_SPAWN_TIMEOUT_MS
2525
2703
  });
@@ -2859,6 +3037,10 @@ function listRoutes(store, proxyPort, tls2) {
2859
3037
  console.log(
2860
3038
  ` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
2861
3039
  );
3040
+ if (route.tailscaleUrl) {
3041
+ const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
3042
+ console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
3043
+ }
2862
3044
  }
2863
3045
  console.log();
2864
3046
  }
@@ -2942,7 +3124,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
2942
3124
  proxyPort: startPort
2943
3125
  });
2944
3126
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
2945
- const result = spawnSync2(process.execPath, startArgs, {
3127
+ const result = spawnSync3(process.execPath, startArgs, {
2946
3128
  stdio: "inherit",
2947
3129
  timeout: SUDO_SPAWN_TIMEOUT_MS
2948
3130
  });
@@ -2976,6 +3158,25 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
2976
3158
  console.log(chalk.blue.bold(`
2977
3159
  portless
2978
3160
  `));
3161
+ const wantsFunnel = process.env.PORTLESS_FUNNEL === "1" || process.env.PORTLESS_FUNNEL === "true";
3162
+ const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
3163
+ let tsBaseUrl;
3164
+ if (wantsTailscale) {
3165
+ try {
3166
+ const tsReady = ensureTailscaleReady();
3167
+ tsBaseUrl = tsReady.baseUrl;
3168
+ } catch (err) {
3169
+ const message = err instanceof Error ? err.message : String(err);
3170
+ console.error(colors_default.red(`Error: ${message}`));
3171
+ if (message.includes("not found")) {
3172
+ console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
3173
+ } else {
3174
+ console.error(colors_default.blue("Make sure Tailscale is connected:"));
3175
+ console.error(colors_default.cyan(" tailscale up"));
3176
+ }
3177
+ process.exit(1);
3178
+ }
3179
+ }
2979
3180
  let desired;
2980
3181
  try {
2981
3182
  desired = resolveProxyDesiredState(lanMode);
@@ -3059,6 +3260,45 @@ portless
3059
3260
  console.log(chalk.green(` LAN -> ${finalUrl}`));
3060
3261
  console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
3061
3262
  }
3263
+ let tailscaleHttpsPort;
3264
+ let tailscaleUrl;
3265
+ if (wantsTailscale && tsBaseUrl) {
3266
+ const maxAttempts = 3;
3267
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3268
+ const usedPorts = getUsedServePorts();
3269
+ tailscaleHttpsPort = findAvailableServePort(usedPorts, wantsFunnel ? "funnel" : "serve");
3270
+ try {
3271
+ if (wantsFunnel) {
3272
+ registerFunnel(port, tailscaleHttpsPort);
3273
+ } else {
3274
+ registerServe(port, tailscaleHttpsPort);
3275
+ }
3276
+ break;
3277
+ } catch (err) {
3278
+ const message = err instanceof Error ? err.message : String(err);
3279
+ const isConflict = message.includes("already in use");
3280
+ if (isConflict && attempt < maxAttempts) continue;
3281
+ console.error(colors_default.red(`Error: ${message}`));
3282
+ process.exit(1);
3283
+ }
3284
+ }
3285
+ tailscaleUrl = formatTailscaleUrl(tsBaseUrl, tailscaleHttpsPort);
3286
+ const label = wantsFunnel ? "Funnel (public)" : "Tailscale";
3287
+ console.log(chalk.green(` ${label} -> ${tailscaleUrl}`));
3288
+ if (wantsFunnel) {
3289
+ console.log(chalk.gray(" (accessible from the public internet via Tailscale Funnel)\n"));
3290
+ } else {
3291
+ console.log(chalk.gray(" (accessible from your tailnet)\n"));
3292
+ }
3293
+ try {
3294
+ store.updateRoute(hostname, {
3295
+ tailscaleUrl,
3296
+ tailscaleHttpsPort,
3297
+ tailscaleFunnel: wantsFunnel || void 0
3298
+ });
3299
+ } catch {
3300
+ }
3301
+ }
3062
3302
  const basename5 = path8.basename(commandArgs[0]);
3063
3303
  const isExpo = basename5 === "expo";
3064
3304
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
@@ -3092,9 +3332,17 @@ portless
3092
3332
  // baked-in pinging, making this env var ineffective. Expo handles its
3093
3333
  // own LAN discovery natively.
3094
3334
  ...lanMode ? { PORTLESS_LAN: "1" } : {},
3335
+ ...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
3095
3336
  ...caEnv
3096
3337
  },
3097
3338
  onCleanup: () => {
3339
+ try {
3340
+ unregisterTailscale({
3341
+ tailscaleHttpsPort,
3342
+ tailscaleFunnel: wantsFunnel || void 0
3343
+ });
3344
+ } catch {
3345
+ }
3098
3346
  try {
3099
3347
  store.removeRoute(hostname);
3100
3348
  } catch {
@@ -3124,6 +3372,18 @@ function appPortFromEnv() {
3124
3372
  }
3125
3373
  return port;
3126
3374
  }
3375
+ function applyTailscaleFlag(flag) {
3376
+ if (flag === "--tailscale") {
3377
+ process.env.PORTLESS_TAILSCALE = "1";
3378
+ return true;
3379
+ }
3380
+ if (flag === "--funnel") {
3381
+ process.env.PORTLESS_FUNNEL = "1";
3382
+ process.env.PORTLESS_TAILSCALE = "1";
3383
+ return true;
3384
+ }
3385
+ return false;
3386
+ }
3127
3387
  function parseRunArgs(args) {
3128
3388
  let force = false;
3129
3389
  let appPort;
@@ -3180,9 +3440,12 @@ ${colors_default.bold("Examples:")}
3180
3440
  process.exit(1);
3181
3441
  }
3182
3442
  name = args[i];
3443
+ } else if (applyTailscaleFlag(args[i])) {
3183
3444
  } else {
3184
3445
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3185
- console.error(colors_default.blue("Known flags: --name, --force, --app-port, --help"));
3446
+ console.error(
3447
+ colors_default.blue("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
3448
+ );
3186
3449
  process.exit(1);
3187
3450
  }
3188
3451
  i++;
@@ -3203,9 +3466,10 @@ function parseAppArgs(args) {
3203
3466
  } else if (args[i] === "--app-port") {
3204
3467
  i++;
3205
3468
  appPort = parseAppPort(args[i]);
3469
+ } else if (applyTailscaleFlag(args[i])) {
3206
3470
  } else {
3207
3471
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3208
- console.error(colors_default.blue("Known flags: --force, --app-port"));
3472
+ console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
3209
3473
  process.exit(1);
3210
3474
  }
3211
3475
  i++;
@@ -3221,9 +3485,10 @@ function parseAppArgs(args) {
3221
3485
  } else if (args[i] === "--app-port") {
3222
3486
  i++;
3223
3487
  appPort = parseAppPort(args[i]);
3488
+ } else if (applyTailscaleFlag(args[i])) {
3224
3489
  } else {
3225
3490
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
3226
- console.error(colors_default.blue("Known flags: --force, --app-port"));
3491
+ console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
3227
3492
  process.exit(1);
3228
3493
  }
3229
3494
  i++;
@@ -3268,6 +3533,8 @@ ${colors_default.bold("Examples:")}
3268
3533
  portless run next dev # -> https://<project>.localhost
3269
3534
  portless run next dev # in worktree -> https://<worktree>.<project>.localhost
3270
3535
  portless get backend # -> https://backend.localhost
3536
+ portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
3537
+ portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
3271
3538
 
3272
3539
  ${colors_default.bold("Configuration (portless.json):")}
3273
3540
  Optional. Portless works out of the box by running the "dev" script
@@ -3318,6 +3585,15 @@ ${colors_default.bold("LAN mode:")}
3318
3585
  ${colors_default.cyan("portless proxy start --lan --https")}
3319
3586
  ${colors_default.cyan("portless proxy start --lan --ip 192.168.1.42")}
3320
3587
 
3588
+ ${colors_default.bold("Tailscale sharing:")}
3589
+ Use --tailscale to share your dev server with teammates on your tailnet.
3590
+ Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
3591
+ 8444, etc.) so no basePath configuration is needed.
3592
+ Use --funnel to expose your dev server to the public internet via
3593
+ Tailscale Funnel. Requires Tailscale CLI to be installed and connected.
3594
+ ${colors_default.cyan("portless myapp --tailscale next dev")}
3595
+ ${colors_default.cyan("portless myapp --funnel next dev")}
3596
+
3321
3597
  ${colors_default.bold("Options:")}
3322
3598
  run [--name <name>] <cmd> Infer project name (or override with --name)
3323
3599
  Adds worktree prefix in git worktrees
@@ -3334,6 +3610,8 @@ ${colors_default.bold("Options:")}
3334
3610
  --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
3335
3611
  --wildcard Allow unregistered subdomains to fall back to parent route
3336
3612
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
3613
+ --tailscale Share the app on your Tailscale network (tailnet)
3614
+ --funnel Share the app publicly via Tailscale Funnel
3337
3615
  --force Kill the existing process and take over its route
3338
3616
  --name <name> Use <name> as the app name (bypasses subcommand dispatch)
3339
3617
  -- Stop flag parsing; everything after is passed to the child
@@ -3346,6 +3624,8 @@ ${colors_default.bold("Environment variables:")}
3346
3624
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
3347
3625
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
3348
3626
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
3627
+ PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
3628
+ PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
3349
3629
  PORTLESS_STATE_DIR=<path> Override the state directory
3350
3630
  PORTLESS=0 Run command directly without proxy
3351
3631
 
@@ -3354,6 +3634,7 @@ ${colors_default.bold("Child process environment:")}
3354
3634
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
3355
3635
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
3356
3636
  PORTLESS_LAN Set to 1 when proxy is in LAN mode
3637
+ PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
3357
3638
  NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
3358
3639
 
3359
3640
  ${colors_default.bold("Safari / DNS:")}
@@ -3376,7 +3657,7 @@ ${colors_default.bold("Reserved names:")}
3376
3657
  process.exit(0);
3377
3658
  }
3378
3659
  function printVersion() {
3379
- console.log("0.11.1");
3660
+ console.log("0.12.0");
3380
3661
  process.exit(0);
3381
3662
  }
3382
3663
  async function handleTrust() {
@@ -3397,7 +3678,7 @@ async function handleTrust() {
3397
3678
  const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
3398
3679
  if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
3399
3680
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
3400
- const sudoResult = spawnSync2(
3681
+ const sudoResult = spawnSync3(
3401
3682
  "sudo",
3402
3683
  [
3403
3684
  "env",
@@ -3453,6 +3734,16 @@ ${colors_default.bold("Options:")}
3453
3734
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
3454
3735
  });
3455
3736
  await stopProxy(store, port, tls2);
3737
+ const routesForClean = store.loadRoutesRaw();
3738
+ for (const route of routesForClean) {
3739
+ if (route.tailscaleHttpsPort) {
3740
+ try {
3741
+ unregisterTailscale(route);
3742
+ console.log(colors_default.green(`Removed tailscale serve on port ${route.tailscaleHttpsPort}.`));
3743
+ } catch {
3744
+ }
3745
+ }
3746
+ }
3456
3747
  const stateDirs = collectStateDirsForCleanup();
3457
3748
  for (const stateDir of stateDirs) {
3458
3749
  const caPath = path8.join(stateDir, "ca.pem");
@@ -3481,7 +3772,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
3481
3772
  console.log(
3482
3773
  colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
3483
3774
  );
3484
- const result = spawnSync2(
3775
+ const result = spawnSync3(
3485
3776
  "sudo",
3486
3777
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
3487
3778
  {
@@ -3532,6 +3823,17 @@ ${colors_default.bold("Options:")}
3532
3823
  console.log("No orphaned routes found.");
3533
3824
  return;
3534
3825
  }
3826
+ for (const route of stale) {
3827
+ if (route.tailscaleHttpsPort) {
3828
+ try {
3829
+ unregisterTailscale(route);
3830
+ console.log(
3831
+ ` ${route.hostname} - removed tailscale serve on port ${route.tailscaleHttpsPort}`
3832
+ );
3833
+ } catch {
3834
+ }
3835
+ }
3836
+ }
3535
3837
  let killed = 0;
3536
3838
  for (const route of stale) {
3537
3839
  const pids = findPidsOnPort(route.port);
@@ -3709,7 +4011,7 @@ ${colors_default.bold("Auto-sync:")}
3709
4011
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
3710
4012
  )
3711
4013
  );
3712
- const result = spawnSync2(
4014
+ const result = spawnSync3(
3713
4015
  "sudo",
3714
4016
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
3715
4017
  {
@@ -3762,7 +4064,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
3762
4064
  console.log(
3763
4065
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
3764
4066
  );
3765
- const result = spawnSync2(
4067
+ const result = spawnSync3(
3766
4068
  "sudo",
3767
4069
  ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
3768
4070
  {
@@ -4053,7 +4355,7 @@ ${colors_default.bold("LAN mode (--lan):")}
4053
4355
  if (!hasExplicitPort) {
4054
4356
  console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
4055
4357
  }
4056
- const result = spawnSync2("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
4358
+ const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
4057
4359
  stdio: "inherit",
4058
4360
  timeout: SUDO_SPAWN_TIMEOUT_MS
4059
4361
  });
@@ -4825,6 +5127,13 @@ async function main() {
4825
5127
  process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
4826
5128
  process.env.PORTLESS_LAN = "1";
4827
5129
  }
5130
+ if (stripGlobalFlag("--tailscale", false)) {
5131
+ process.env.PORTLESS_TAILSCALE = "1";
5132
+ }
5133
+ if (stripGlobalFlag("--funnel", false)) {
5134
+ process.env.PORTLESS_FUNNEL = "1";
5135
+ process.env.PORTLESS_TAILSCALE = "1";
5136
+ }
4828
5137
  const scriptResult = stripGlobalFlag("--script", true);
4829
5138
  if (scriptResult === false) {
4830
5139
  console.error(colors_default.red("Error: --script requires a script name."));
package/dist/index.d.ts CHANGED
@@ -61,6 +61,9 @@ declare const FILE_MODE = 420;
61
61
  declare const DIR_MODE = 493;
62
62
  interface RouteMapping extends RouteInfo {
63
63
  pid: number;
64
+ tailscaleUrl?: string;
65
+ tailscaleHttpsPort?: number;
66
+ tailscaleFunnel?: boolean;
64
67
  }
65
68
  /**
66
69
  * Thrown when a route is already registered by a live process and --force was
@@ -119,6 +122,11 @@ declare class RouteStore {
119
122
  * result. Returns the removed stale entries so the caller can act on them.
120
123
  */
121
124
  pruneStaleRoutes(): RouteMapping[];
125
+ /**
126
+ * Update metadata on an existing route entry. Only provided fields are
127
+ * merged; the route must already exist (matched by hostname).
128
+ */
129
+ updateRoute(hostname: string, fields: Partial<Pick<RouteMapping, "tailscaleUrl" | "tailscaleHttpsPort" | "tailscaleFunnel">>): void;
122
130
  removeRoute(hostname: string): void;
123
131
  }
124
132
 
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  removeBlock,
20
20
  shouldAutoSyncHosts,
21
21
  syncHostsFile
22
- } from "./chunk-RRH2JUIU.js";
22
+ } from "./chunk-3WLVQXFE.js";
23
23
  export {
24
24
  DIR_MODE,
25
25
  FILE_MODE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.11.1",
3
+ "version": "0.12.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",