portless 0.13.1 → 0.14.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
@@ -323,6 +323,20 @@ Set `PORTLESS_TAILSCALE=1` in your shell profile or `.env` to share every app by
323
323
 
324
324
  Requires the Tailscale CLI to be installed and connected (`tailscale up`), with Tailscale HTTPS certificates enabled.
325
325
 
326
+ ## ngrok sharing
327
+
328
+ Expose your dev server to the public internet with [ngrok](https://ngrok.com):
329
+
330
+ ```bash
331
+ portless myapp --ngrok next dev
332
+ # -> https://myapp.localhost (local)
333
+ # -> https://abc123.ngrok.app (public)
334
+ ```
335
+
336
+ Set `PORTLESS_NGROK=1` in your shell profile or `.env` to enable ngrok by default when portless runs an app. `portless list` shows both local and ngrok URLs. The ngrok tunnel is cleaned up automatically when the app exits.
337
+
338
+ Requires the ngrok CLI to be installed and authenticated. If ngrok reports an authentication error, run `ngrok config add-authtoken <token>` and try again.
339
+
326
340
  ## Commands
327
341
 
328
342
  ```bash
@@ -378,6 +392,7 @@ portless service uninstall # Remove the startup service
378
392
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
379
393
  --tailscale Share the app on your Tailscale network (tailnet)
380
394
  --funnel Share the app publicly via Tailscale Funnel
395
+ --ngrok Share the app publicly via ngrok
381
396
  --force Kill the existing process and take over its route
382
397
  --name <name> Use <name> as the app name
383
398
  ```
@@ -396,6 +411,7 @@ PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to p
396
411
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
397
412
  PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
398
413
  PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
414
+ PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok)
399
415
  PORTLESS_STATE_DIR=<path> Override the state directory
400
416
 
401
417
  # Injected into child processes
@@ -403,6 +419,7 @@ PORT Ephemeral port the child should listen on
403
419
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
404
420
  PORTLESS_URL Public URL (e.g. https://myapp.localhost)
405
421
  PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
422
+ PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
406
423
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
407
424
  ```
408
425
 
@@ -486,3 +503,4 @@ pnpm format # Format all files with Prettier
486
503
  - Node.js 24+
487
504
  - macOS, Linux, or Windows
488
505
  - Tailscale CLI (optional, for `--tailscale` and `--funnel`)
506
+ - ngrok CLI (optional, for `--ngrok`)
@@ -920,10 +920,28 @@ var RouteStore = class _RouteStore {
920
920
  const routes = this.loadRoutes(true);
921
921
  const route = routes.find((r) => r.hostname === hostname);
922
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;
923
+ if ("tailscaleUrl" in fields) {
924
+ if (fields.tailscaleUrl === null) delete route.tailscaleUrl;
925
+ else if (fields.tailscaleUrl !== void 0) route.tailscaleUrl = fields.tailscaleUrl;
926
+ }
927
+ if ("tailscaleHttpsPort" in fields) {
928
+ if (fields.tailscaleHttpsPort === null) delete route.tailscaleHttpsPort;
929
+ else if (fields.tailscaleHttpsPort !== void 0)
930
+ route.tailscaleHttpsPort = fields.tailscaleHttpsPort;
931
+ }
932
+ if ("tailscaleFunnel" in fields) {
933
+ if (fields.tailscaleFunnel === null) delete route.tailscaleFunnel;
934
+ else if (fields.tailscaleFunnel !== void 0)
935
+ route.tailscaleFunnel = fields.tailscaleFunnel;
936
+ }
937
+ if ("ngrokUrl" in fields) {
938
+ if (fields.ngrokUrl === null) delete route.ngrokUrl;
939
+ else if (fields.ngrokUrl !== void 0) route.ngrokUrl = fields.ngrokUrl;
940
+ }
941
+ if ("ngrokPid" in fields) {
942
+ if (fields.ngrokPid === null) delete route.ngrokPid;
943
+ else if (fields.ngrokPid !== void 0) route.ngrokPid = fields.ngrokPid;
944
+ }
927
945
  this.saveRoutes(routes);
928
946
  } finally {
929
947
  this.releaseLock();
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  parseHostname,
14
14
  shouldAutoSyncHosts,
15
15
  syncHostsFile
16
- } from "./chunk-3WLVQXFE.js";
16
+ } from "./chunk-OZM4AEYL.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 fs9 from "fs";
43
43
  import * as path9 from "path";
44
- import { spawn as spawn3, spawnSync as spawnSync4 } from "child_process";
44
+ import { spawn as spawn4, spawnSync as spawnSync5 } from "child_process";
45
45
  import { StringDecoder } from "string_decoder";
46
46
 
47
47
  // src/certs.ts
@@ -999,6 +999,178 @@ function formatTailscaleUrl(baseUrl, httpsPort) {
999
999
  return `${trimmed}:${httpsPort}`;
1000
1000
  }
1001
1001
 
1002
+ // src/ngrok.ts
1003
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1004
+ var NGROK_BINARY = "ngrok";
1005
+ var NGROK_START_TIMEOUT_MS = 3e4;
1006
+ var NGROK_COMMAND_TIMEOUT_MS = 1e4;
1007
+ var OUTPUT_BUFFER_LIMIT = 16384;
1008
+ function defaultSpawner(args) {
1009
+ return spawn(NGROK_BINARY, args, {
1010
+ stdio: ["ignore", "pipe", "pipe"],
1011
+ windowsHide: true
1012
+ });
1013
+ }
1014
+ function defaultRunner2(args) {
1015
+ const result = spawnSync2(NGROK_BINARY, args, {
1016
+ encoding: "utf-8",
1017
+ killSignal: "SIGKILL",
1018
+ timeout: NGROK_COMMAND_TIMEOUT_MS
1019
+ });
1020
+ return {
1021
+ status: result.status,
1022
+ stdout: result.stdout ?? "",
1023
+ stderr: result.stderr ?? "",
1024
+ ...result.error ? { error: result.error } : {}
1025
+ };
1026
+ }
1027
+ function normalizeSpace2(value) {
1028
+ return value.trim().replace(/\s+/g, " ");
1029
+ }
1030
+ function formatSpawnError(error) {
1031
+ const errno = error;
1032
+ if (errno.code === "ENOENT") {
1033
+ return new Error(
1034
+ "ngrok CLI not found. Install ngrok (https://ngrok.com/download) and ensure `ngrok` is on PATH."
1035
+ );
1036
+ }
1037
+ return new Error(`Failed to start ngrok: ${error.message}`);
1038
+ }
1039
+ function formatOutputError(output) {
1040
+ const details = normalizeSpace2(output);
1041
+ const lower = details.toLowerCase();
1042
+ if (lower.includes("authtoken") || lower.includes("authentication") || lower.includes("not logged in")) {
1043
+ return new Error(
1044
+ "ngrok could not start because authentication is not configured. Run `ngrok config add-authtoken <token>`, then run portless again."
1045
+ );
1046
+ }
1047
+ return new Error(
1048
+ `Failed to start ngrok tunnel: ${details || "ngrok exited before printing a public URL"}`
1049
+ );
1050
+ }
1051
+ function ensureNgrokAvailable(runner = defaultRunner2) {
1052
+ const result = runner(["version"]);
1053
+ if (result.error) {
1054
+ throw formatSpawnError(result.error);
1055
+ }
1056
+ if (result.status !== 0) {
1057
+ const details = normalizeSpace2(result.stderr || result.stdout);
1058
+ throw new Error(`Failed to check ngrok version: ${details || "unknown ngrok error"}`);
1059
+ }
1060
+ }
1061
+ function cleanUrl(value) {
1062
+ return value.replace(/[),.]+$/g, "");
1063
+ }
1064
+ function extractNgrokUrl(output) {
1065
+ const urlMatches = output.matchAll(/https:\/\/[^\s"'<>]+/g);
1066
+ for (const match of urlMatches) {
1067
+ const raw = match[0];
1068
+ const matchIndex = match.index ?? 0;
1069
+ const before = output.slice(Math.max(0, matchIndex - 80), matchIndex).toLowerCase();
1070
+ const looksLikeTunnel = before.includes("forwarding") || before.includes("url=") || before.includes('"url"') || before.includes("started tunnel");
1071
+ if (!looksLikeTunnel) continue;
1072
+ const candidate = cleanUrl(raw);
1073
+ try {
1074
+ const parsed = new URL(candidate);
1075
+ if (parsed.hostname === "ngrok.com" || parsed.hostname.endsWith(".ngrok.com")) {
1076
+ continue;
1077
+ }
1078
+ return parsed.toString().replace(/\/$/, "");
1079
+ } catch {
1080
+ continue;
1081
+ }
1082
+ }
1083
+ return null;
1084
+ }
1085
+ function buildNgrokArgs(localPort, hostHeader = "rewrite") {
1086
+ return [
1087
+ "http",
1088
+ "--log=stdout",
1089
+ "--log-format=logfmt",
1090
+ `--host-header=${hostHeader}`,
1091
+ `http://127.0.0.1:${localPort}`
1092
+ ];
1093
+ }
1094
+ function startNgrok(localPort, options = {}) {
1095
+ const spawner = options.spawner ?? defaultSpawner;
1096
+ const timeoutMs = options.timeoutMs ?? NGROK_START_TIMEOUT_MS;
1097
+ const args = buildNgrokArgs(localPort, options.hostHeader);
1098
+ let child;
1099
+ try {
1100
+ child = spawner(args);
1101
+ } catch (err) {
1102
+ return Promise.reject(formatSpawnError(err instanceof Error ? err : new Error(String(err))));
1103
+ }
1104
+ return new Promise((resolve4, reject) => {
1105
+ let settled = false;
1106
+ let started = false;
1107
+ let output = "";
1108
+ const settle = (fn) => {
1109
+ if (settled) return;
1110
+ settled = true;
1111
+ clearTimeout(timer);
1112
+ fn();
1113
+ };
1114
+ const appendOutput = (chunk) => {
1115
+ if (settled) return;
1116
+ output += chunk.toString();
1117
+ if (output.length > OUTPUT_BUFFER_LIMIT) {
1118
+ output = output.slice(-OUTPUT_BUFFER_LIMIT);
1119
+ }
1120
+ const url = extractNgrokUrl(output);
1121
+ if (url) {
1122
+ settle(() => {
1123
+ started = true;
1124
+ resolve4({ url, pid: child.pid, child });
1125
+ });
1126
+ }
1127
+ };
1128
+ const timer = setTimeout(() => {
1129
+ try {
1130
+ child.kill("SIGTERM");
1131
+ } catch {
1132
+ }
1133
+ settle(
1134
+ () => reject(
1135
+ new Error(
1136
+ "Timed out waiting for ngrok to print a public URL. Check that ngrok is authenticated and can connect."
1137
+ )
1138
+ )
1139
+ );
1140
+ }, timeoutMs);
1141
+ child.stdout?.on("data", appendOutput);
1142
+ child.stderr?.on("data", appendOutput);
1143
+ child.on("error", (err) => {
1144
+ settle(() => reject(formatSpawnError(err)));
1145
+ });
1146
+ child.on("exit", (code, signal) => {
1147
+ if (settled) {
1148
+ if (started) options.onExit?.(code, signal);
1149
+ return;
1150
+ }
1151
+ settle(() => {
1152
+ const suffix = signal ? ` (signal ${signal})` : code !== null ? ` (exit ${code})` : "";
1153
+ const error = formatOutputError(output);
1154
+ reject(new Error(`${error.message}${suffix}`));
1155
+ });
1156
+ });
1157
+ });
1158
+ }
1159
+ function stopNgrokProcess(child) {
1160
+ if (!child) return;
1161
+ try {
1162
+ child.kill("SIGTERM");
1163
+ } catch {
1164
+ }
1165
+ }
1166
+ function stopNgrok(route) {
1167
+ if (!route.ngrokPid) return;
1168
+ try {
1169
+ process.kill(route.ngrokPid, "SIGTERM");
1170
+ } catch {
1171
+ }
1172
+ }
1173
+
1002
1174
  // src/auto.ts
1003
1175
  import { createHash as createHash2 } from "crypto";
1004
1176
  import { execFileSync as execFileSync2 } from "child_process";
@@ -1181,7 +1353,7 @@ import * as net from "net";
1181
1353
  import * as os from "os";
1182
1354
  import * as path3 from "path";
1183
1355
  import * as readline from "readline";
1184
- import { execSync, spawn } from "child_process";
1356
+ import { execSync, spawn as spawn2 } from "child_process";
1185
1357
  var isWindows = process.platform === "win32";
1186
1358
  var FALLBACK_PROXY_PORT = 1355;
1187
1359
  var PRIVILEGED_PORT_THRESHOLD = 1024;
@@ -1718,10 +1890,10 @@ function spawnCommand(commandArgs, options) {
1718
1890
  }
1719
1891
  }
1720
1892
  }
1721
- const child = isWindows ? spawn("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
1893
+ const child = isWindows ? spawn2("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
1722
1894
  stdio: "inherit",
1723
1895
  env
1724
- }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
1896
+ }) : spawn2("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
1725
1897
  stdio: "inherit",
1726
1898
  env,
1727
1899
  detached: true
@@ -1867,7 +2039,7 @@ function removePortlessStateFiles(dir) {
1867
2039
  }
1868
2040
 
1869
2041
  // src/mdns.ts
1870
- import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
2042
+ import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
1871
2043
 
1872
2044
  // src/lan-ip.ts
1873
2045
  import { createSocket } from "dgram";
@@ -1983,7 +2155,7 @@ function getMdnsPublisher() {
1983
2155
  return null;
1984
2156
  }
1985
2157
  function hasCommand(command, probeArgs) {
1986
- const result = spawnSync2(command, probeArgs, {
2158
+ const result = spawnSync3(command, probeArgs, {
1987
2159
  stdio: "ignore",
1988
2160
  timeout: 1e3,
1989
2161
  windowsHide: true
@@ -2042,7 +2214,7 @@ function publish(hostname, port, ip, onError) {
2042
2214
  if (!publisher) {
2043
2215
  return;
2044
2216
  }
2045
- const child = spawn2(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
2217
+ const child = spawn3(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
2046
2218
  stdio: "ignore",
2047
2219
  detached: false
2048
2220
  });
@@ -2595,7 +2767,7 @@ function hasTurboConfig(wsRoot) {
2595
2767
  import * as fs8 from "fs";
2596
2768
  import * as os2 from "os";
2597
2769
  import * as path8 from "path";
2598
- import { spawnSync as spawnSync3 } from "child_process";
2770
+ import { spawnSync as spawnSync4 } from "child_process";
2599
2771
  var DEFAULT_SERVICE_PORT = getProtocolPort(true);
2600
2772
  var SERVICE_LABEL = "sh.portless.proxy";
2601
2773
  var SYSTEMD_SERVICE = "portless.service";
@@ -2614,8 +2786,8 @@ var DEFAULT_SERVICE_CONFIG = {
2614
2786
  useWildcard: false,
2615
2787
  extraEnv: {}
2616
2788
  };
2617
- function defaultRunner2(command, args, options) {
2618
- return spawnSync3(command, args, {
2789
+ function defaultRunner3(command, args, options) {
2790
+ return spawnSync4(command, args, {
2619
2791
  encoding: "utf-8",
2620
2792
  stdio: options?.stdio ?? "pipe"
2621
2793
  });
@@ -3337,7 +3509,7 @@ async function uninstallService(entryScript, runner) {
3337
3509
  }
3338
3510
  console.log(colors_default.green("Portless service uninstalled."));
3339
3511
  }
3340
- function tryUninstallService(entryScript, runner = defaultRunner2) {
3512
+ function tryUninstallService(entryScript, runner = defaultRunner3) {
3341
3513
  let installed = false;
3342
3514
  try {
3343
3515
  const spec = currentServiceSpec(entryScript);
@@ -3465,7 +3637,7 @@ ${colors_default.bold("Notes:")}
3465
3637
  }
3466
3638
  async function handleService(args, options) {
3467
3639
  const action = args[1];
3468
- const runner = options.runner || defaultRunner2;
3640
+ const runner = options.runner || defaultRunner3;
3469
3641
  if (!action || action === "--help" || action === "-h") {
3470
3642
  printServiceHelp();
3471
3643
  process.exit(0);
@@ -3666,7 +3838,7 @@ function collectPortlessEnvArgs2() {
3666
3838
  function sudoStop(port) {
3667
3839
  const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
3668
3840
  console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
3669
- const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
3841
+ const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
3670
3842
  stdio: "inherit",
3671
3843
  timeout: SUDO_SPAWN_TIMEOUT_MS
3672
3844
  });
@@ -3675,7 +3847,7 @@ function sudoStop(port) {
3675
3847
  function runCleanWithSudo(reason) {
3676
3848
  console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3677
3849
  const home = process.env.HOME;
3678
- const result = spawnSync4(
3850
+ const result = spawnSync5(
3679
3851
  "sudo",
3680
3852
  [
3681
3853
  "env",
@@ -3694,12 +3866,20 @@ function runCleanWithSudo(reason) {
3694
3866
  }
3695
3867
  function runServiceUninstallWithSudo(reason) {
3696
3868
  console.log(colors_default.yellow(`${reason} Requesting sudo...`));
3697
- const result = spawnSync4("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
3869
+ const result = spawnSync5("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
3698
3870
  stdio: "inherit",
3699
3871
  timeout: SUDO_SPAWN_TIMEOUT_MS
3700
3872
  });
3701
3873
  return result.status === 0;
3702
3874
  }
3875
+ function isEnabledEnv(value) {
3876
+ return value === "1" || value === "true";
3877
+ }
3878
+ function formatProcessExitSuffix(code, signal) {
3879
+ if (signal) return ` (signal ${signal})`;
3880
+ if (code !== null) return ` (exit ${code})`;
3881
+ return "";
3882
+ }
3703
3883
  function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
3704
3884
  store.ensureDir();
3705
3885
  const isTls = !!tlsOptions;
@@ -4038,6 +4218,9 @@ function listRoutes(store, proxyPort, tls2) {
4038
4218
  const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
4039
4219
  console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
4040
4220
  }
4221
+ if (route.ngrokUrl) {
4222
+ console.log(` ${colors_default.gray("ngrok:")} ${colors_default.green(route.ngrokUrl)}`);
4223
+ }
4041
4224
  }
4042
4225
  console.log();
4043
4226
  }
@@ -4121,7 +4304,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
4121
4304
  proxyPort: startPort
4122
4305
  });
4123
4306
  const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
4124
- const result = spawnSync4(process.execPath, startArgs, {
4307
+ const result = spawnSync5(process.execPath, startArgs, {
4125
4308
  stdio: "inherit",
4126
4309
  timeout: SUDO_SPAWN_TIMEOUT_MS
4127
4310
  });
@@ -4155,8 +4338,9 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
4155
4338
  console.log(chalk.blue.bold(`
4156
4339
  portless
4157
4340
  `));
4158
- const wantsFunnel = process.env.PORTLESS_FUNNEL === "1" || process.env.PORTLESS_FUNNEL === "true";
4159
- const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
4341
+ const wantsFunnel = isEnabledEnv(process.env.PORTLESS_FUNNEL);
4342
+ const wantsTailscale = wantsFunnel || isEnabledEnv(process.env.PORTLESS_TAILSCALE);
4343
+ const wantsNgrok = isEnabledEnv(process.env.PORTLESS_NGROK);
4160
4344
  let tsBaseUrl;
4161
4345
  if (wantsTailscale) {
4162
4346
  try {
@@ -4177,6 +4361,18 @@ portless
4177
4361
  process.exit(1);
4178
4362
  }
4179
4363
  }
4364
+ if (wantsNgrok) {
4365
+ try {
4366
+ ensureNgrokAvailable();
4367
+ } catch (err) {
4368
+ const message = err instanceof Error ? err.message : String(err);
4369
+ console.error(colors_default.red(`Error: ${message}`));
4370
+ if (message.includes("not found")) {
4371
+ console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
4372
+ }
4373
+ process.exit(1);
4374
+ }
4375
+ }
4180
4376
  let desired;
4181
4377
  try {
4182
4378
  desired = resolveProxyDesiredState(lanMode);
@@ -4262,6 +4458,36 @@ portless
4262
4458
  }
4263
4459
  let tailscaleHttpsPort;
4264
4460
  let tailscaleUrl;
4461
+ let ngrokUrl;
4462
+ let ngrokProcess;
4463
+ let stoppingNgrok = false;
4464
+ let ngrokRouteReady = false;
4465
+ let ngrokExitHandled = false;
4466
+ let pendingNgrokExit;
4467
+ const handleNgrokExit = (code, signal) => {
4468
+ if (stoppingNgrok || ngrokExitHandled) return;
4469
+ if (!ngrokRouteReady) {
4470
+ pendingNgrokExit = { code, signal };
4471
+ return;
4472
+ }
4473
+ ngrokExitHandled = true;
4474
+ ngrokUrl = void 0;
4475
+ console.warn(
4476
+ colors_default.yellow(
4477
+ `Warning: ngrok tunnel for ${hostname} stopped${formatProcessExitSuffix(
4478
+ code,
4479
+ signal
4480
+ )}. Removing its public URL from the route list.`
4481
+ )
4482
+ );
4483
+ try {
4484
+ store.updateRoute(hostname, {
4485
+ ngrokUrl: null,
4486
+ ngrokPid: null
4487
+ });
4488
+ } catch {
4489
+ }
4490
+ };
4265
4491
  if (wantsTailscale && tsBaseUrl) {
4266
4492
  const maxAttempts = 3;
4267
4493
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -4299,6 +4525,51 @@ portless
4299
4525
  } catch {
4300
4526
  }
4301
4527
  }
4528
+ if (wantsNgrok) {
4529
+ try {
4530
+ ngrokProcess = await startNgrok(port, {
4531
+ hostHeader: hostname,
4532
+ onExit: handleNgrokExit
4533
+ });
4534
+ ngrokUrl = ngrokProcess.url;
4535
+ console.log(chalk.green(` ngrok -> ${ngrokUrl}`));
4536
+ console.log(chalk.gray(" (accessible from the public internet via ngrok)\n"));
4537
+ try {
4538
+ store.updateRoute(hostname, {
4539
+ ngrokUrl,
4540
+ ngrokPid: ngrokProcess.pid
4541
+ });
4542
+ } catch {
4543
+ } finally {
4544
+ ngrokRouteReady = true;
4545
+ if (pendingNgrokExit) {
4546
+ handleNgrokExit(pendingNgrokExit.code, pendingNgrokExit.signal);
4547
+ pendingNgrokExit = void 0;
4548
+ }
4549
+ }
4550
+ } catch (err) {
4551
+ const message = err instanceof Error ? err.message : String(err);
4552
+ console.error(colors_default.red(`Error: ${message}`));
4553
+ if (message.includes("not found")) {
4554
+ console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
4555
+ } else if (message.includes("authentication")) {
4556
+ console.error(colors_default.blue("Configure ngrok authentication:"));
4557
+ console.error(colors_default.cyan(" ngrok config add-authtoken <token>"));
4558
+ }
4559
+ try {
4560
+ unregisterTailscale({
4561
+ tailscaleHttpsPort,
4562
+ tailscaleFunnel: wantsFunnel || void 0
4563
+ });
4564
+ } catch {
4565
+ }
4566
+ try {
4567
+ store.removeRoute(hostname);
4568
+ } catch {
4569
+ }
4570
+ process.exit(1);
4571
+ }
4572
+ }
4302
4573
  const basename5 = path9.basename(commandArgs[0]);
4303
4574
  const isExpo = basename5 === "expo";
4304
4575
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
@@ -4333,9 +4604,12 @@ portless
4333
4604
  // own LAN discovery natively.
4334
4605
  ...lanMode ? { PORTLESS_LAN: "1" } : {},
4335
4606
  ...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
4607
+ ...ngrokUrl ? { PORTLESS_NGROK_URL: ngrokUrl } : {},
4336
4608
  ...caEnv
4337
4609
  },
4338
4610
  onCleanup: () => {
4611
+ stoppingNgrok = true;
4612
+ stopNgrokProcess(ngrokProcess?.child);
4339
4613
  try {
4340
4614
  unregisterTailscale({
4341
4615
  tailscaleHttpsPort,
@@ -4372,7 +4646,7 @@ function appPortFromEnv() {
4372
4646
  }
4373
4647
  return port;
4374
4648
  }
4375
- function applyTailscaleFlag(flag) {
4649
+ function applySharingFlag(flag) {
4376
4650
  if (flag === "--tailscale") {
4377
4651
  process.env.PORTLESS_TAILSCALE = "1";
4378
4652
  return true;
@@ -4382,6 +4656,10 @@ function applyTailscaleFlag(flag) {
4382
4656
  process.env.PORTLESS_TAILSCALE = "1";
4383
4657
  return true;
4384
4658
  }
4659
+ if (flag === "--ngrok") {
4660
+ process.env.PORTLESS_NGROK = "1";
4661
+ return true;
4662
+ }
4385
4663
  return false;
4386
4664
  }
4387
4665
  function parseRunArgs(args) {
@@ -4407,6 +4685,9 @@ ${colors_default.bold("Options:")}
4407
4685
  --name <name> Override the inferred base name (worktree prefix still applies)
4408
4686
  --force Kill the existing process and take over its route
4409
4687
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
4688
+ --tailscale Share the app on your Tailscale network (tailnet)
4689
+ --funnel Share the app publicly via Tailscale Funnel
4690
+ --ngrok Share the app publicly via ngrok
4410
4691
  --help, -h Show this help
4411
4692
 
4412
4693
  ${colors_default.bold("Name inference (in order):")}
@@ -4440,11 +4721,13 @@ ${colors_default.bold("Examples:")}
4440
4721
  process.exit(1);
4441
4722
  }
4442
4723
  name = args[i];
4443
- } else if (applyTailscaleFlag(args[i])) {
4724
+ } else if (applySharingFlag(args[i])) {
4444
4725
  } else {
4445
4726
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4446
4727
  console.error(
4447
- colors_default.blue("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
4728
+ colors_default.blue(
4729
+ "Known flags: --name, --force, --app-port, --tailscale, --funnel, --ngrok, --help"
4730
+ )
4448
4731
  );
4449
4732
  process.exit(1);
4450
4733
  }
@@ -4466,10 +4749,12 @@ function parseAppArgs(args) {
4466
4749
  } else if (args[i] === "--app-port") {
4467
4750
  i++;
4468
4751
  appPort = parseAppPort(args[i]);
4469
- } else if (applyTailscaleFlag(args[i])) {
4752
+ } else if (applySharingFlag(args[i])) {
4470
4753
  } else {
4471
4754
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4472
- console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
4755
+ console.error(
4756
+ colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
4757
+ );
4473
4758
  process.exit(1);
4474
4759
  }
4475
4760
  i++;
@@ -4485,10 +4770,12 @@ function parseAppArgs(args) {
4485
4770
  } else if (args[i] === "--app-port") {
4486
4771
  i++;
4487
4772
  appPort = parseAppPort(args[i]);
4488
- } else if (applyTailscaleFlag(args[i])) {
4773
+ } else if (applySharingFlag(args[i])) {
4489
4774
  } else {
4490
4775
  console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
4491
- console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
4776
+ console.error(
4777
+ colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
4778
+ );
4492
4779
  process.exit(1);
4493
4780
  }
4494
4781
  i++;
@@ -4542,6 +4829,7 @@ ${colors_default.bold("Examples:")}
4542
4829
  portless get backend # -> https://backend.localhost
4543
4830
  portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
4544
4831
  portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
4832
+ portless myapp --ngrok next dev # -> also https://<random>.ngrok.app (public)
4545
4833
 
4546
4834
  ${colors_default.bold("Configuration (portless.json):")}
4547
4835
  Optional. Portless works out of the box by running the "dev" script
@@ -4603,6 +4891,11 @@ ${colors_default.bold("Tailscale sharing:")}
4603
4891
  ${colors_default.cyan("portless myapp --tailscale next dev")}
4604
4892
  ${colors_default.cyan("portless myapp --funnel next dev")}
4605
4893
 
4894
+ ${colors_default.bold("ngrok sharing:")}
4895
+ Use --ngrok to expose your dev server to the public internet with ngrok.
4896
+ Requires the ngrok CLI to be installed and authenticated.
4897
+ ${colors_default.cyan("portless myapp --ngrok next dev")}
4898
+
4606
4899
  ${colors_default.bold("Options:")}
4607
4900
  run [--name <name>] <cmd> Infer project name (or override with --name)
4608
4901
  Adds worktree prefix in git worktrees
@@ -4622,6 +4915,7 @@ ${colors_default.bold("Options:")}
4622
4915
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
4623
4916
  --tailscale Share the app on your Tailscale network (tailnet)
4624
4917
  --funnel Share the app publicly via Tailscale Funnel
4918
+ --ngrok Share the app publicly via ngrok
4625
4919
  --force Kill the existing process and take over its route
4626
4920
  --name <name> Use <name> as the app name (bypasses subcommand dispatch)
4627
4921
  -- Stop flag parsing; everything after is passed to the child
@@ -4637,6 +4931,7 @@ ${colors_default.bold("Environment variables:")}
4637
4931
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
4638
4932
  PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
4639
4933
  PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
4934
+ PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok)
4640
4935
  PORTLESS_STATE_DIR=<path> Override the state directory
4641
4936
  PORTLESS=0 Run command directly without proxy
4642
4937
 
@@ -4646,6 +4941,7 @@ ${colors_default.bold("Child process environment:")}
4646
4941
  PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
4647
4942
  PORTLESS_LAN Set to 1 when proxy is in LAN mode
4648
4943
  PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
4944
+ PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
4649
4945
  NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
4650
4946
 
4651
4947
  ${colors_default.bold("Safari / DNS:")}
@@ -4668,7 +4964,7 @@ ${colors_default.bold("Reserved names:")}
4668
4964
  process.exit(0);
4669
4965
  }
4670
4966
  function printVersion() {
4671
- console.log("0.13.1");
4967
+ console.log("0.14.0");
4672
4968
  process.exit(0);
4673
4969
  }
4674
4970
  async function handleTrust() {
@@ -4689,7 +4985,7 @@ async function handleTrust() {
4689
4985
  const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
4690
4986
  if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
4691
4987
  console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
4692
- const sudoResult = spawnSync4(
4988
+ const sudoResult = spawnSync5(
4693
4989
  "sudo",
4694
4990
  [
4695
4991
  "env",
@@ -4772,6 +5068,10 @@ ${colors_default.bold("Options:")}
4772
5068
  } catch {
4773
5069
  }
4774
5070
  }
5071
+ if (route.ngrokPid) {
5072
+ stopNgrok(route);
5073
+ console.log(colors_default.green(`Stopped ngrok tunnel for ${route.hostname}.`));
5074
+ }
4775
5075
  }
4776
5076
  const stateDirs = collectStateDirsForCleanup();
4777
5077
  for (const stateDir of stateDirs) {
@@ -4851,6 +5151,10 @@ ${colors_default.bold("Options:")}
4851
5151
  } catch {
4852
5152
  }
4853
5153
  }
5154
+ if (route.ngrokPid) {
5155
+ stopNgrok(route);
5156
+ console.log(` ${route.hostname} - stopped ngrok tunnel`);
5157
+ }
4854
5158
  }
4855
5159
  let killed = 0;
4856
5160
  for (const route of stale) {
@@ -5029,7 +5333,7 @@ ${colors_default.bold("Auto-sync:")}
5029
5333
  `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
5030
5334
  )
5031
5335
  );
5032
- const result = spawnSync4(
5336
+ const result = spawnSync5(
5033
5337
  "sudo",
5034
5338
  ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
5035
5339
  {
@@ -5082,7 +5386,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
5082
5386
  console.log(
5083
5387
  colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
5084
5388
  );
5085
- const result = spawnSync4(
5389
+ const result = spawnSync5(
5086
5390
  "sudo",
5087
5391
  ["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
5088
5392
  {
@@ -5373,7 +5677,7 @@ ${colors_default.bold("LAN mode (--lan):")}
5373
5677
  if (!hasExplicitPort) {
5374
5678
  console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
5375
5679
  }
5376
- const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
5680
+ const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
5377
5681
  stdio: "inherit",
5378
5682
  timeout: SUDO_SPAWN_TIMEOUT_MS
5379
5683
  });
@@ -5513,7 +5817,7 @@ ${colors_default.bold("LAN mode (--lan):")}
5513
5817
  skipTrust: true
5514
5818
  }).args
5515
5819
  ];
5516
- const child = spawn3(process.execPath, daemonArgs, {
5820
+ const child = spawn4(process.execPath, daemonArgs, {
5517
5821
  detached: true,
5518
5822
  stdio: ["ignore", logFd, logFd],
5519
5823
  env: process.env,
@@ -5619,7 +5923,7 @@ async function handleDefaultSingle(cwd, scriptName, appConfig) {
5619
5923
  );
5620
5924
  }
5621
5925
  function spawnChildProcess(commandArgs, env, cwd) {
5622
- return spawn3(commandArgs[0], commandArgs.slice(1), {
5926
+ return spawn4(commandArgs[0], commandArgs.slice(1), {
5623
5927
  stdio: ["ignore", "pipe", "pipe"],
5624
5928
  env,
5625
5929
  cwd,
@@ -5890,7 +6194,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
5890
6194
  const pm = detectPackageManager(wsRoot);
5891
6195
  const useRootScript = hasScript(scriptName, wsRoot);
5892
6196
  const turboArgs = useRootScript ? [pm, "run", scriptName, ...extraArgs] : pm === "npm" ? ["npx", "turbo", "run", scriptName, ...extraArgs] : pm === "bun" ? ["bunx", "turbo", "run", scriptName, ...extraArgs] : [pm, "exec", "turbo", "run", scriptName, ...extraArgs];
5893
- const turboChild = spawn3(turboArgs[0], turboArgs.slice(1), {
6197
+ const turboChild = spawn4(turboArgs[0], turboArgs.slice(1), {
5894
6198
  stdio: "inherit",
5895
6199
  cwd: wsRoot,
5896
6200
  env: {
@@ -6153,6 +6457,9 @@ async function main() {
6153
6457
  process.env.PORTLESS_FUNNEL = "1";
6154
6458
  process.env.PORTLESS_TAILSCALE = "1";
6155
6459
  }
6460
+ if (stripGlobalFlag("--ngrok", false)) {
6461
+ process.env.PORTLESS_NGROK = "1";
6462
+ }
6156
6463
  const scriptResult = stripGlobalFlag("--script", true);
6157
6464
  if (scriptResult === false) {
6158
6465
  console.error(colors_default.red("Error: --script requires a script name."));
package/dist/index.d.ts CHANGED
@@ -64,7 +64,16 @@ interface RouteMapping extends RouteInfo {
64
64
  tailscaleUrl?: string;
65
65
  tailscaleHttpsPort?: number;
66
66
  tailscaleFunnel?: boolean;
67
+ ngrokUrl?: string;
68
+ ngrokPid?: number;
67
69
  }
70
+ type RouteMetadataPatch = {
71
+ tailscaleUrl?: string | null;
72
+ tailscaleHttpsPort?: number | null;
73
+ tailscaleFunnel?: boolean | null;
74
+ ngrokUrl?: string | null;
75
+ ngrokPid?: number | null;
76
+ };
68
77
  /**
69
78
  * Thrown when a route is already registered by a live process and --force was
70
79
  * not specified. With --force, the existing process is killed instead.
@@ -126,7 +135,7 @@ declare class RouteStore {
126
135
  * Update metadata on an existing route entry. Only provided fields are
127
136
  * merged; the route must already exist (matched by hostname).
128
137
  */
129
- updateRoute(hostname: string, fields: Partial<Pick<RouteMapping, "tailscaleUrl" | "tailscaleHttpsPort" | "tailscaleFunnel">>): void;
138
+ updateRoute(hostname: string, fields: RouteMetadataPatch): void;
130
139
  removeRoute(hostname: string): void;
131
140
  }
132
141
 
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  removeBlock,
20
20
  shouldAutoSyncHosts,
21
21
  syncHostsFile
22
- } from "./chunk-3WLVQXFE.js";
22
+ } from "./chunk-OZM4AEYL.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.13.1",
3
+ "version": "0.14.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",