portless 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  DEFAULT_TLD,
4
+ FALLBACK_PROXY_PORT,
4
5
  FILE_MODE,
5
6
  PRIVILEGED_PORT_THRESHOLD,
6
7
  RISKY_TLDS,
7
8
  RouteConflictError,
8
9
  RouteStore,
10
+ WAIT_FOR_PROXY_INTERVAL_MS,
11
+ WAIT_FOR_PROXY_MAX_ATTEMPTS,
9
12
  cleanHostsFile,
13
+ createHttpRedirectServer,
10
14
  createProxyServer,
11
15
  discoverState,
12
16
  findFreePort,
@@ -15,15 +19,15 @@ import {
15
19
  formatUrl,
16
20
  getDefaultPort,
17
21
  getDefaultTld,
22
+ getProtocolPort,
18
23
  injectFrameworkFlags,
19
24
  isErrnoException,
20
- isHttpsEnvEnabled,
25
+ isHttpsEnvDisabled,
21
26
  isProxyRunning,
27
+ isWildcardEnvEnabled,
22
28
  isWindows,
23
29
  parseHostname,
24
30
  prompt,
25
- readTldFromDir,
26
- readTlsMarker,
27
31
  resolveStateDir,
28
32
  spawnCommand,
29
33
  syncHostsFile,
@@ -31,10 +35,34 @@ import {
31
35
  waitForProxy,
32
36
  writeTldFile,
33
37
  writeTlsMarker
34
- } from "./chunk-ROBZDJST.js";
38
+ } from "./chunk-5BR7NCNI.js";
39
+
40
+ // src/colors.ts
41
+ function supportsColor() {
42
+ if ("NO_COLOR" in process.env) return false;
43
+ if ("FORCE_COLOR" in process.env) return true;
44
+ return !!(process.stdout.isTTY || process.stderr.isTTY);
45
+ }
46
+ var enabled = supportsColor();
47
+ var wrap = (open, close) => {
48
+ if (!enabled) return (s) => s;
49
+ return (s) => `\x1B[${open}m${s}\x1B[${close}m`;
50
+ };
51
+ var bold = wrap("1", "22");
52
+ var red = wrap("31", "39");
53
+ var green = wrap("32", "39");
54
+ var yellow = wrap("33", "39");
55
+ var blue = Object.assign(wrap("34", "39"), {
56
+ bold: enabled ? (s) => `\x1B[34;1m${s}\x1B[22;39m` : (s) => s
57
+ });
58
+ var cyan = Object.assign(wrap("36", "39"), {
59
+ bold: enabled ? (s) => `\x1B[36;1m${s}\x1B[22;39m` : (s) => s
60
+ });
61
+ var white = wrap("37", "39");
62
+ var gray = wrap("90", "39");
63
+ var colors_default = { bold, red, green, yellow, blue, cyan, white, gray };
35
64
 
36
65
  // src/cli.ts
37
- import chalk from "chalk";
38
66
  import * as fs3 from "fs";
39
67
  import * as path3 from "path";
40
68
  import { spawn, spawnSync } from "child_process";
@@ -164,6 +192,13 @@ function generateServerCert(stateDir) {
164
192
  "subjectAltName=DNS:localhost,DNS:*.localhost"
165
193
  ].join("\n") + "\n"
166
194
  );
195
+ const srlPath = path.join(stateDir, "ca.srl");
196
+ if (!fileExists(srlPath)) {
197
+ fs.writeFileSync(
198
+ srlPath,
199
+ crypto.randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase() + "\n"
200
+ );
201
+ }
167
202
  openssl([
168
203
  "x509",
169
204
  "-req",
@@ -174,7 +209,8 @@ function generateServerCert(stateDir) {
174
209
  caCertPath,
175
210
  "-CAkey",
176
211
  caKeyPath,
177
- "-CAcreateserial",
212
+ "-CAserial",
213
+ srlPath,
178
214
  "-out",
179
215
  serverCertPath,
180
216
  "-days",
@@ -364,6 +400,13 @@ async function generateHostCertAsync(stateDir, hostname) {
364
400
  `subjectAltName=${sans.join(",")}`
365
401
  ].join("\n") + "\n"
366
402
  );
403
+ const srlPath = path.join(stateDir, "ca.srl");
404
+ if (!fs.existsSync(srlPath)) {
405
+ await fs.promises.writeFile(
406
+ srlPath,
407
+ crypto.randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase() + "\n"
408
+ );
409
+ }
367
410
  await opensslAsync([
368
411
  "x509",
369
412
  "-req",
@@ -374,7 +417,8 @@ async function generateHostCertAsync(stateDir, hostname) {
374
417
  caCertPath,
375
418
  "-CAkey",
376
419
  caKeyPath,
377
- "-CAcreateserial",
420
+ "-CAserial",
421
+ srlPath,
378
422
  "-out",
379
423
  certPath,
380
424
  "-days",
@@ -497,7 +541,7 @@ function trustCA(stateDir) {
497
541
  if (message.includes("authorization") || message.includes("permission") || message.includes("EACCES")) {
498
542
  return {
499
543
  trusted: false,
500
- error: "Permission denied. Try: sudo portless trust"
544
+ error: "Permission denied. Try: portless trust"
501
545
  };
502
546
  }
503
547
  return { trusted: false, error: message };
@@ -680,12 +724,36 @@ function readBranchFromHead(gitdir) {
680
724
 
681
725
  // src/cli.ts
682
726
  var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
683
- var SUDO_PREFIX = isWindows ? "" : "sudo ";
684
727
  var DEBOUNCE_MS = 100;
685
728
  var POLL_INTERVAL_MS = 3e3;
686
729
  var EXIT_TIMEOUT_MS = 2e3;
687
730
  var SUDO_SPAWN_TIMEOUT_MS = 3e4;
688
- function startProxyServer(store, proxyPort, tld, tlsOptions) {
731
+ function getEntryScript() {
732
+ const script = process.argv[1];
733
+ if (!script) {
734
+ throw new Error("Cannot determine portless entry script (process.argv[1] is undefined)");
735
+ }
736
+ return script;
737
+ }
738
+ function collectPortlessEnvArgs() {
739
+ const envArgs = [];
740
+ for (const key of Object.keys(process.env)) {
741
+ if (key.startsWith("PORTLESS_") && process.env[key]) {
742
+ envArgs.push(`${key}=${process.env[key]}`);
743
+ }
744
+ }
745
+ return envArgs;
746
+ }
747
+ function sudoStop(port) {
748
+ const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
749
+ console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
750
+ const result = spawnSync("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
751
+ stdio: "inherit",
752
+ timeout: SUDO_SPAWN_TIMEOUT_MS
753
+ });
754
+ return result.status === 0;
755
+ }
756
+ function startProxyServer(store, proxyPort, tld, tlsOptions, strict) {
689
757
  store.ensureDir();
690
758
  const isTls = !!tlsOptions;
691
759
  const routesPath = store.getRoutesPath();
@@ -718,7 +786,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions) {
718
786
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
719
787
  });
720
788
  } catch {
721
- console.warn(chalk.yellow("fs.watch unavailable; falling back to polling for route changes"));
789
+ console.warn(colors_default.yellow("fs.watch unavailable; falling back to polling for route changes"));
722
790
  pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
723
791
  }
724
792
  if (autoSyncHosts) {
@@ -728,31 +796,39 @@ function startProxyServer(store, proxyPort, tld, tlsOptions) {
728
796
  getRoutes: () => cachedRoutes,
729
797
  proxyPort,
730
798
  tld,
731
- onError: (msg) => console.error(chalk.red(msg)),
799
+ strict,
800
+ onError: (msg) => console.error(colors_default.red(msg)),
732
801
  tls: tlsOptions
733
802
  });
734
803
  server.on("error", (err) => {
735
804
  if (err.code === "EADDRINUSE") {
736
- console.error(chalk.red(`Port ${proxyPort} is already in use.`));
737
- console.error(chalk.blue("Stop the existing proxy first:"));
738
- console.error(chalk.cyan(" portless proxy stop"));
739
- console.error(chalk.blue("Or check what is using the port:"));
805
+ console.error(colors_default.red(`Port ${proxyPort} is already in use.`));
806
+ console.error(colors_default.blue("Stop the existing proxy first:"));
807
+ console.error(colors_default.cyan(" portless proxy stop"));
808
+ console.error(colors_default.blue("Or check what is using the port:"));
740
809
  console.error(
741
- chalk.cyan(
810
+ colors_default.cyan(
742
811
  isWindows ? ` netstat -ano | findstr :${proxyPort}` : ` lsof -ti tcp:${proxyPort}`
743
812
  )
744
813
  );
745
814
  } else if (err.code === "EACCES") {
746
- console.error(chalk.red(`Permission denied for port ${proxyPort}.`));
747
- console.error(chalk.blue("Either run with sudo:"));
748
- console.error(chalk.cyan(" sudo portless proxy start -p 80"));
749
- console.error(chalk.blue("Or use a non-privileged port (no sudo needed):"));
750
- console.error(chalk.cyan(" portless proxy start"));
815
+ console.error(colors_default.red(`Permission denied for port ${proxyPort}.`));
816
+ console.error(colors_default.blue("Use an unprivileged port (no sudo needed):"));
817
+ console.error(colors_default.cyan(" portless proxy start -p 1355"));
751
818
  } else {
752
- console.error(chalk.red(`Proxy error: ${err.message}`));
819
+ console.error(colors_default.red(`Proxy error: ${err.message}`));
753
820
  }
821
+ if (redirectServer) redirectServer.close();
754
822
  process.exit(1);
755
823
  });
824
+ let redirectServer = null;
825
+ if (isTls && proxyPort !== 80) {
826
+ redirectServer = createHttpRedirectServer(proxyPort);
827
+ redirectServer.on("error", () => {
828
+ redirectServer = null;
829
+ });
830
+ redirectServer.listen(80);
831
+ }
756
832
  server.listen(proxyPort, () => {
757
833
  fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
758
834
  fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
@@ -761,7 +837,13 @@ function startProxyServer(store, proxyPort, tld, tlsOptions) {
761
837
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
762
838
  const proto = isTls ? "HTTPS/2" : "HTTP";
763
839
  const tldLabel = tld !== DEFAULT_TLD ? ` (TLD: .${tld})` : "";
764
- console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}${tldLabel}`));
840
+ const modeLabel = strict === false ? " (wildcard)" : "";
841
+ console.log(
842
+ colors_default.green(`${proto} proxy listening on port ${proxyPort}${tldLabel}${modeLabel}`)
843
+ );
844
+ if (redirectServer) {
845
+ console.log(colors_default.green("HTTP-to-HTTPS redirect listening on port 80"));
846
+ }
765
847
  });
766
848
  let exiting = false;
767
849
  const cleanup = () => {
@@ -772,6 +854,9 @@ function startProxyServer(store, proxyPort, tld, tlsOptions) {
772
854
  if (watcher) {
773
855
  watcher.close();
774
856
  }
857
+ if (redirectServer) {
858
+ redirectServer.close();
859
+ }
775
860
  try {
776
861
  fs3.unlinkSync(store.pidPath);
777
862
  } catch {
@@ -788,16 +873,27 @@ function startProxyServer(store, proxyPort, tld, tlsOptions) {
788
873
  };
789
874
  process.on("SIGINT", cleanup);
790
875
  process.on("SIGTERM", cleanup);
791
- console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
792
- console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
876
+ console.log(colors_default.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
877
+ console.log(colors_default.gray(`Routes file: ${store.getRoutesPath()}`));
878
+ }
879
+ function sudoStopOrHint(port) {
880
+ if (!isWindows) {
881
+ if (!sudoStop(port)) {
882
+ console.error(colors_default.red("Failed to stop proxy with sudo."));
883
+ console.error(colors_default.blue("Try manually:"));
884
+ console.error(colors_default.cyan(` portless proxy stop -p ${port}`));
885
+ }
886
+ } else {
887
+ console.error(colors_default.red("Permission denied. The proxy was started with elevated privileges."));
888
+ console.error(colors_default.blue("Stop it with:"));
889
+ console.error(colors_default.cyan(" Run portless proxy stop as Administrator"));
890
+ }
793
891
  }
794
892
  async function stopProxy(store, proxyPort, _tls) {
795
893
  const pidPath = store.pidPath;
796
- const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
797
- const sudoHint = needsSudo ? "sudo " : "";
798
894
  if (!fs3.existsSync(pidPath)) {
799
895
  if (await isProxyRunning(proxyPort)) {
800
- console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
896
+ console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
801
897
  const pid = findPidOnPort(proxyPort);
802
898
  if (pid !== null) {
803
899
  try {
@@ -806,58 +902,52 @@ async function stopProxy(store, proxyPort, _tls) {
806
902
  fs3.unlinkSync(store.portFilePath);
807
903
  } catch {
808
904
  }
809
- console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
905
+ console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
810
906
  } catch (err) {
811
907
  if (isErrnoException(err) && err.code === "EPERM") {
812
- console.error(
813
- chalk.red("Permission denied. The proxy was started with elevated privileges.")
814
- );
815
- console.error(chalk.blue("Stop it with:"));
816
- console.error(
817
- chalk.cyan(
818
- isWindows ? " Run portless proxy stop as Administrator" : " sudo portless proxy stop"
819
- )
820
- );
908
+ sudoStopOrHint(proxyPort);
821
909
  } else {
822
910
  const message = err instanceof Error ? err.message : String(err);
823
- console.error(chalk.red(`Failed to stop proxy: ${message}`));
824
- console.error(chalk.blue("Check if the process is still running:"));
911
+ console.error(colors_default.red(`Failed to stop proxy: ${message}`));
912
+ console.error(colors_default.blue("Check if the process is still running:"));
825
913
  console.error(
826
- chalk.cyan(
914
+ colors_default.cyan(
827
915
  isWindows ? ` netstat -ano | findstr :${proxyPort}` : ` lsof -ti tcp:${proxyPort}`
828
916
  )
829
917
  );
830
918
  }
831
919
  }
832
920
  } else if (!isWindows && process.getuid?.() !== 0) {
833
- console.error(chalk.red("Cannot identify the process. It may be running as root."));
834
- console.error(chalk.blue("Try stopping with sudo:"));
835
- console.error(chalk.cyan(" sudo portless proxy stop"));
921
+ sudoStopOrHint(proxyPort);
836
922
  } else {
837
- console.error(chalk.red(`Could not identify the process on port ${proxyPort}.`));
838
- console.error(chalk.blue("Try manually:"));
923
+ console.error(colors_default.red(`Could not identify the process on port ${proxyPort}.`));
924
+ console.error(colors_default.blue("Try manually:"));
839
925
  console.error(
840
- chalk.cyan(
926
+ colors_default.cyan(
841
927
  isWindows ? " taskkill /F /PID <pid>" : ` sudo kill "$(lsof -ti tcp:${proxyPort})"`
842
928
  )
843
929
  );
844
930
  }
845
931
  } else {
846
- console.log(chalk.yellow("Proxy is not running."));
932
+ console.log(colors_default.yellow("Proxy is not running."));
847
933
  }
848
934
  return;
849
935
  }
850
936
  try {
851
937
  const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
852
938
  if (isNaN(pid)) {
853
- console.error(chalk.red("Corrupted PID file. Removing it."));
939
+ console.error(colors_default.red("Corrupted PID file. Removing it."));
854
940
  fs3.unlinkSync(pidPath);
855
941
  return;
856
942
  }
857
943
  try {
858
944
  process.kill(pid, 0);
859
- } catch {
860
- console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
945
+ } catch (err) {
946
+ if (isErrnoException(err) && err.code === "EPERM") {
947
+ sudoStopOrHint(proxyPort);
948
+ return;
949
+ }
950
+ console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
861
951
  fs3.unlinkSync(pidPath);
862
952
  try {
863
953
  fs3.unlinkSync(store.portFilePath);
@@ -867,11 +957,11 @@ async function stopProxy(store, proxyPort, _tls) {
867
957
  }
868
958
  if (!await isProxyRunning(proxyPort)) {
869
959
  console.log(
870
- chalk.yellow(
960
+ colors_default.yellow(
871
961
  `PID file exists but port ${proxyPort} is not listening. The PID may have been recycled.`
872
962
  )
873
963
  );
874
- console.log(chalk.yellow("Removing stale PID file."));
964
+ console.log(colors_default.yellow("Removing stale PID file."));
875
965
  fs3.unlinkSync(pidPath);
876
966
  return;
877
967
  }
@@ -881,20 +971,16 @@ async function stopProxy(store, proxyPort, _tls) {
881
971
  fs3.unlinkSync(store.portFilePath);
882
972
  } catch {
883
973
  }
884
- console.log(chalk.green("Proxy stopped."));
974
+ console.log(colors_default.green("Proxy stopped."));
885
975
  } catch (err) {
886
976
  if (isErrnoException(err) && err.code === "EPERM") {
887
- console.error(
888
- chalk.red("Permission denied. The proxy was started with elevated privileges.")
889
- );
890
- console.error(chalk.blue("Stop it with:"));
891
- console.error(chalk.cyan(` ${sudoHint}portless proxy stop`));
977
+ sudoStopOrHint(proxyPort);
892
978
  } else {
893
979
  const message = err instanceof Error ? err.message : String(err);
894
- console.error(chalk.red(`Failed to stop proxy: ${message}`));
895
- console.error(chalk.blue("Check if the process is still running:"));
980
+ console.error(colors_default.red(`Failed to stop proxy: ${message}`));
981
+ console.error(colors_default.blue("Check if the process is still running:"));
896
982
  console.error(
897
- chalk.cyan(
983
+ colors_default.cyan(
898
984
  isWindows ? ` netstat -ano | findstr :${proxyPort}` : ` lsof -ti tcp:${proxyPort}`
899
985
  )
900
986
  );
@@ -904,143 +990,140 @@ async function stopProxy(store, proxyPort, _tls) {
904
990
  function listRoutes(store, proxyPort, tls2) {
905
991
  const routes = store.loadRoutes();
906
992
  if (routes.length === 0) {
907
- console.log(chalk.yellow("No active routes."));
908
- console.log(chalk.gray("Start an app with: portless <name> <command>"));
993
+ console.log(colors_default.yellow("No active routes."));
994
+ console.log(colors_default.gray("Start an app with: portless <name> <command>"));
909
995
  return;
910
996
  }
911
- console.log(chalk.blue.bold("\nActive routes:\n"));
997
+ console.log(colors_default.blue.bold("\nActive routes:\n"));
912
998
  for (const route of routes) {
913
999
  const url = formatUrl(route.hostname, proxyPort, tls2);
914
1000
  const label = route.pid === 0 ? "(alias)" : `(pid ${route.pid})`;
915
1001
  console.log(
916
- ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(label)}`
1002
+ ` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
917
1003
  );
918
1004
  }
919
1005
  console.log();
920
1006
  }
921
- async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort) {
922
- const hostname = parseHostname(name, tld);
1007
+ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort) {
1008
+ let store = initialStore;
923
1009
  let envTld;
924
1010
  try {
925
1011
  envTld = getDefaultTld();
926
1012
  } catch (err) {
927
- console.error(chalk.red(`Error: ${err.message}`));
1013
+ console.error(colors_default.red(`Error: ${err.message}`));
928
1014
  process.exit(1);
929
1015
  }
930
1016
  if (envTld !== DEFAULT_TLD && envTld !== tld) {
931
1017
  console.warn(
932
- chalk.yellow(
1018
+ colors_default.yellow(
933
1019
  `Warning: PORTLESS_TLD=${envTld} but the running proxy uses .${tld}. Using .${tld}.`
934
1020
  )
935
1021
  );
936
1022
  }
937
- console.log(chalk.blue.bold(`
1023
+ console.log(colors_default.blue.bold(`
938
1024
  portless
939
1025
  `));
940
- console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
1026
+ console.log(colors_default.gray(`-- ${parseHostname(name, tld)} (auto-resolves to 127.0.0.1)`));
941
1027
  if (autoInfo) {
942
1028
  const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name;
943
- console.log(chalk.gray(`-- Name "${baseName}" (from ${autoInfo.nameSource})`));
1029
+ console.log(colors_default.gray(`-- Name "${baseName}" (from ${autoInfo.nameSource})`));
944
1030
  if (autoInfo.prefix) {
945
- console.log(chalk.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`));
1031
+ console.log(colors_default.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`));
946
1032
  }
947
1033
  }
948
1034
  if (!await isProxyRunning(proxyPort, tls2)) {
949
- const defaultPort = getDefaultPort();
1035
+ const wantTls = !isHttpsEnvDisabled();
1036
+ const defaultPort = getDefaultPort(wantTls);
950
1037
  const needsSudo = !isWindows && defaultPort < PRIVILEGED_PORT_THRESHOLD;
951
- const wantHttps = isHttpsEnvEnabled();
952
- if (needsSudo) {
953
- if (!process.stdin.isTTY) {
954
- console.error(chalk.red("Proxy is not running."));
955
- console.error(chalk.blue("Start the proxy first (requires sudo for this port):"));
956
- console.error(chalk.cyan(" sudo portless proxy start -p 80"));
957
- console.error(chalk.blue("Or use the default port (no sudo needed):"));
958
- console.error(chalk.cyan(" portless proxy start"));
959
- process.exit(1);
960
- }
961
- const answer = await prompt(chalk.yellow("Proxy not running. Start it? [Y/n/skip] "));
1038
+ if (needsSudo && !process.stdin.isTTY) {
1039
+ console.error(colors_default.red("Proxy is not running and no TTY is available for sudo."));
1040
+ console.error(colors_default.blue("Option 1: start the proxy in a terminal (will prompt for sudo):"));
1041
+ console.error(colors_default.cyan(" portless proxy start"));
1042
+ console.error(
1043
+ colors_default.blue(
1044
+ `Option 2: use an unprivileged port (no sudo needed, URLs will include :${FALLBACK_PROXY_PORT}):`
1045
+ )
1046
+ );
1047
+ console.error(colors_default.cyan(` portless proxy start -p ${FALLBACK_PROXY_PORT}`));
1048
+ process.exit(1);
1049
+ }
1050
+ if (needsSudo && process.stdin.isTTY) {
1051
+ const answer = await prompt(colors_default.yellow("Proxy not running. Start it? [Y/n/skip] "));
962
1052
  if (answer === "n" || answer === "no") {
963
- console.log(chalk.gray("Cancelled."));
1053
+ console.log(colors_default.gray("Cancelled."));
964
1054
  process.exit(0);
965
1055
  }
966
1056
  if (answer === "s" || answer === "skip") {
967
- console.log(chalk.gray("Skipping proxy, running command directly...\n"));
1057
+ console.log(colors_default.gray("Skipping proxy, running command directly...\n"));
968
1058
  spawnCommand(commandArgs);
969
1059
  return;
970
1060
  }
971
- console.log(chalk.yellow("Starting proxy (requires sudo)..."));
972
- const startArgs = [process.execPath, process.argv[1], "proxy", "start"];
973
- if (wantHttps) startArgs.push("--https");
974
- if (tld !== DEFAULT_TLD) startArgs.push("--tld", tld);
975
- const result = spawnSync("sudo", startArgs, {
976
- stdio: "inherit",
977
- timeout: SUDO_SPAWN_TIMEOUT_MS
978
- });
979
- if (result.status !== 0) {
980
- if (!await isProxyRunning(proxyPort)) {
981
- console.error(chalk.red("Failed to start proxy."));
982
- console.error(chalk.blue("Try starting it manually:"));
983
- console.error(chalk.cyan(" sudo portless proxy start"));
984
- process.exit(1);
985
- }
986
- }
987
- } else {
988
- console.log(chalk.yellow("Starting proxy..."));
989
- const startArgs = [process.argv[1], "proxy", "start"];
990
- if (wantHttps) startArgs.push("--https");
991
- if (tld !== DEFAULT_TLD) startArgs.push("--tld", tld);
992
- const result = spawnSync(process.execPath, startArgs, {
993
- stdio: "inherit",
994
- timeout: SUDO_SPAWN_TIMEOUT_MS
995
- });
996
- if (result.status !== 0) {
997
- if (!await isProxyRunning(proxyPort)) {
998
- console.error(chalk.red("Failed to start proxy."));
999
- console.error(chalk.blue("Try starting it manually:"));
1000
- console.error(chalk.cyan(" portless proxy start"));
1001
- process.exit(1);
1061
+ }
1062
+ console.log(colors_default.yellow("Starting proxy..."));
1063
+ const startArgs = [getEntryScript(), "proxy", "start"];
1064
+ if (!wantTls) startArgs.push("--no-tls");
1065
+ if (tld !== DEFAULT_TLD) startArgs.push("--tld", tld);
1066
+ const result = spawnSync(process.execPath, startArgs, {
1067
+ stdio: "inherit",
1068
+ timeout: SUDO_SPAWN_TIMEOUT_MS
1069
+ });
1070
+ let discovered = null;
1071
+ if (result.status === 0) {
1072
+ for (let i = 0; i < WAIT_FOR_PROXY_MAX_ATTEMPTS; i++) {
1073
+ await new Promise((r) => setTimeout(r, WAIT_FOR_PROXY_INTERVAL_MS));
1074
+ const state = await discoverState();
1075
+ if (await isProxyRunning(state.port)) {
1076
+ discovered = state;
1077
+ break;
1002
1078
  }
1003
1079
  }
1004
1080
  }
1005
- const autoTls = readTlsMarker(stateDir);
1006
- tld = readTldFromDir(stateDir);
1007
- if (!await waitForProxy(defaultPort, void 0, void 0, autoTls)) {
1008
- console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1009
- const logPath = path3.join(stateDir, "proxy.log");
1010
- console.error(chalk.blue("Try starting the proxy manually to see the error:"));
1011
- console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
1081
+ if (!discovered) {
1082
+ console.error(colors_default.red("Failed to start proxy."));
1083
+ const fallbackDir = resolveStateDir(getDefaultPort(wantTls));
1084
+ const logPath = path3.join(fallbackDir, "proxy.log");
1085
+ console.error(colors_default.blue("Try starting it manually:"));
1086
+ console.error(colors_default.cyan(" portless proxy start"));
1012
1087
  if (fs3.existsSync(logPath)) {
1013
- console.error(chalk.gray(`Logs: ${logPath}`));
1088
+ console.error(colors_default.gray(`Logs: ${logPath}`));
1014
1089
  }
1015
1090
  process.exit(1);
1091
+ return;
1016
1092
  }
1017
- tls2 = autoTls;
1018
- console.log(chalk.green("Proxy started in background"));
1093
+ proxyPort = discovered.port;
1094
+ stateDir = discovered.dir;
1095
+ tld = discovered.tld;
1096
+ tls2 = discovered.tls;
1097
+ store = new RouteStore(stateDir, {
1098
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1099
+ });
1100
+ console.log(colors_default.green("Proxy started in background"));
1019
1101
  } else {
1020
- console.log(chalk.gray("-- Proxy is running"));
1102
+ console.log(colors_default.gray("-- Proxy is running"));
1021
1103
  }
1104
+ const hostname = parseHostname(name, tld);
1022
1105
  const port = desiredPort ?? await findFreePort();
1023
1106
  if (desiredPort) {
1024
- console.log(chalk.green(`-- Using port ${port} (fixed)`));
1107
+ console.log(colors_default.green(`-- Using port ${port} (fixed)`));
1025
1108
  } else {
1026
- console.log(chalk.green(`-- Using port ${port}`));
1109
+ console.log(colors_default.green(`-- Using port ${port}`));
1027
1110
  }
1028
1111
  try {
1029
1112
  store.addRoute(hostname, port, process.pid, force);
1030
1113
  } catch (err) {
1031
1114
  if (err instanceof RouteConflictError) {
1032
- console.error(chalk.red(`Error: ${err.message}`));
1115
+ console.error(colors_default.red(`Error: ${err.message}`));
1033
1116
  process.exit(1);
1034
1117
  }
1035
1118
  throw err;
1036
1119
  }
1037
1120
  const finalUrl = formatUrl(hostname, proxyPort, tls2);
1038
- console.log(chalk.cyan.bold(`
1121
+ console.log(colors_default.cyan.bold(`
1039
1122
  -> ${finalUrl}
1040
1123
  `));
1041
1124
  injectFrameworkFlags(commandArgs, port);
1042
1125
  console.log(
1043
- chalk.gray(
1126
+ colors_default.gray(
1044
1127
  `Running: PORT=${port} HOST=127.0.0.1 PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
1045
1128
  `
1046
1129
  )
@@ -1063,12 +1146,12 @@ portless
1063
1146
  }
1064
1147
  function parseAppPort(value) {
1065
1148
  if (!value || value.startsWith("--")) {
1066
- console.error(chalk.red("Error: --app-port requires a port number."));
1149
+ console.error(colors_default.red("Error: --app-port requires a port number."));
1067
1150
  process.exit(1);
1068
1151
  }
1069
1152
  const port = parseInt(value, 10);
1070
1153
  if (isNaN(port) || port < 1 || port > 65535) {
1071
- console.error(chalk.red(`Error: Invalid app port "${value}". Must be 1-65535.`));
1154
+ console.error(colors_default.red(`Error: Invalid app port "${value}". Must be 1-65535.`));
1072
1155
  process.exit(1);
1073
1156
  }
1074
1157
  return port;
@@ -1078,7 +1161,7 @@ function appPortFromEnv() {
1078
1161
  if (!envVal) return void 0;
1079
1162
  const port = parseInt(envVal, 10);
1080
1163
  if (isNaN(port) || port < 1 || port > 65535) {
1081
- console.error(chalk.red(`Error: Invalid PORTLESS_APP_PORT="${envVal}". Must be 1-65535.`));
1164
+ console.error(colors_default.red(`Error: Invalid PORTLESS_APP_PORT="${envVal}". Must be 1-65535.`));
1082
1165
  process.exit(1);
1083
1166
  }
1084
1167
  return port;
@@ -1094,18 +1177,18 @@ function parseRunArgs(args) {
1094
1177
  break;
1095
1178
  } else if (args[i] === "--help" || args[i] === "-h") {
1096
1179
  console.log(`
1097
- ${chalk.bold("portless run")} - Infer project name and run through the proxy.
1180
+ ${colors_default.bold("portless run")} - Infer project name and run through the proxy.
1098
1181
 
1099
- ${chalk.bold("Usage:")}
1100
- ${chalk.cyan("portless run [options] <command...>")}
1182
+ ${colors_default.bold("Usage:")}
1183
+ ${colors_default.cyan("portless run [options] <command...>")}
1101
1184
 
1102
- ${chalk.bold("Options:")}
1185
+ ${colors_default.bold("Options:")}
1103
1186
  --name <name> Override the inferred base name (worktree prefix still applies)
1104
1187
  --force Override an existing route registered by another process
1105
1188
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
1106
1189
  --help, -h Show this help
1107
1190
 
1108
- ${chalk.bold("Name inference (in order):")}
1191
+ ${colors_default.bold("Name inference (in order):")}
1109
1192
  1. package.json "name" field (walks up directories)
1110
1193
  2. Git repo root directory name
1111
1194
  3. Current directory basename
@@ -1114,10 +1197,10 @@ ${chalk.bold("Name inference (in order):")}
1114
1197
  In git worktrees, the branch name is prepended as a subdomain prefix
1115
1198
  (e.g. feature-auth.myapp.localhost).
1116
1199
 
1117
- ${chalk.bold("Examples:")}
1118
- portless run next dev # -> http://<project>.localhost:1355
1119
- portless run --name myapp next dev # -> http://myapp.localhost:1355
1120
- portless run vite dev # -> http://<project>.localhost:1355
1200
+ ${colors_default.bold("Examples:")}
1201
+ portless run next dev # -> https://<project>.localhost
1202
+ portless run --name myapp next dev # -> https://myapp.localhost
1203
+ portless run vite dev # -> https://<project>.localhost
1121
1204
  portless run --app-port 3000 pnpm start
1122
1205
  `);
1123
1206
  process.exit(0);
@@ -1129,14 +1212,14 @@ ${chalk.bold("Examples:")}
1129
1212
  } else if (args[i] === "--name") {
1130
1213
  i++;
1131
1214
  if (!args[i] || args[i].startsWith("-")) {
1132
- console.error(chalk.red("Error: --name requires a name value."));
1133
- console.error(chalk.cyan(" portless run --name <name> <command...>"));
1215
+ console.error(colors_default.red("Error: --name requires a name value."));
1216
+ console.error(colors_default.cyan(" portless run --name <name> <command...>"));
1134
1217
  process.exit(1);
1135
1218
  }
1136
1219
  name = args[i];
1137
1220
  } else {
1138
- console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1139
- console.error(chalk.blue("Known flags: --name, --force, --app-port, --help"));
1221
+ console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
1222
+ console.error(colors_default.blue("Known flags: --name, --force, --app-port, --help"));
1140
1223
  process.exit(1);
1141
1224
  }
1142
1225
  i++;
@@ -1158,8 +1241,8 @@ function parseAppArgs(args) {
1158
1241
  i++;
1159
1242
  appPort = parseAppPort(args[i]);
1160
1243
  } else {
1161
- console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1162
- console.error(chalk.blue("Known flags: --force, --app-port"));
1244
+ console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
1245
+ console.error(colors_default.blue("Known flags: --force, --app-port"));
1163
1246
  process.exit(1);
1164
1247
  }
1165
1248
  i++;
@@ -1176,8 +1259,8 @@ function parseAppArgs(args) {
1176
1259
  i++;
1177
1260
  appPort = parseAppPort(args[i]);
1178
1261
  } else {
1179
- console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1180
- console.error(chalk.blue("Known flags: --force, --app-port"));
1262
+ console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
1263
+ console.error(colors_default.blue("Known flags: --force, --app-port"));
1181
1264
  process.exit(1);
1182
1265
  }
1183
1266
  i++;
@@ -1187,105 +1270,107 @@ function parseAppArgs(args) {
1187
1270
  }
1188
1271
  function printHelp() {
1189
1272
  console.log(`
1190
- ${chalk.bold("portless")} - Replace port numbers with stable, named .localhost URLs. For humans and agents.
1273
+ ${colors_default.bold("portless")} - Replace port numbers with stable, named .localhost URLs. For humans and agents.
1191
1274
 
1192
1275
  Eliminates port conflicts, memorizing port numbers, and cookie/storage
1193
1276
  clashes by giving each dev server a stable .localhost URL.
1194
1277
 
1195
- ${chalk.bold("Install:")}
1196
- ${chalk.cyan("npm install -g portless")}
1278
+ ${colors_default.bold("Install:")}
1279
+ ${colors_default.cyan("npm install -g portless")}
1197
1280
  Do NOT add portless as a project dependency.
1198
1281
 
1199
- ${chalk.bold("Usage:")}
1200
- ${chalk.cyan("portless proxy start")} Start the proxy (background daemon)
1201
- ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS (auto-generates certs)
1202
- ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
1203
- ${chalk.cyan("portless proxy stop")} Stop the proxy
1204
- ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
1205
- ${chalk.cyan("portless run <cmd>")} Infer name from project, run through proxy
1206
- ${chalk.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
1207
- ${chalk.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
1208
- ${chalk.cyan("portless alias --remove <name>")} Remove a static route
1209
- ${chalk.cyan("portless list")} Show active routes
1210
- ${chalk.cyan("portless trust")} Add local CA to system trust store
1211
- ${chalk.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
1212
- ${chalk.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1282
+ ${colors_default.bold("Usage:")}
1283
+ ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
1284
+ ${colors_default.cyan("portless proxy start --no-tls")} Start without HTTPS (port 80)
1285
+ ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
1286
+ ${colors_default.cyan("portless proxy stop")} Stop the proxy
1287
+ ${colors_default.cyan("portless <name> <cmd>")} Run your app through the proxy
1288
+ ${colors_default.cyan("portless run <cmd>")} Infer name from project, run through proxy
1289
+ ${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
1290
+ ${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
1291
+ ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
1292
+ ${colors_default.cyan("portless list")} Show active routes
1293
+ ${colors_default.cyan("portless trust")} Add local CA to system trust store
1294
+ ${colors_default.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
1295
+ ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1213
1296
 
1214
- ${chalk.bold("Examples:")}
1215
- portless proxy start # Start proxy on port 1355
1216
- portless proxy start --https # Start with HTTPS/2 (faster page loads)
1217
- portless myapp next dev # -> http://myapp.localhost:1355
1218
- portless myapp vite dev # -> http://myapp.localhost:1355
1219
- portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
1220
- portless run next dev # -> http://<project>.localhost:1355
1221
- portless run next dev # in worktree -> http://<worktree>.<project>.localhost:1355
1222
- portless get backend # -> http://backend.localhost:1355 (for cross-service refs)
1297
+ ${colors_default.bold("Examples:")}
1298
+ portless proxy start # Start HTTPS proxy on port 443
1299
+ portless proxy start --no-tls # Start HTTP proxy on port 80
1300
+ portless myapp next dev # -> https://myapp.localhost
1301
+ portless myapp vite dev # -> https://myapp.localhost
1302
+ portless api.myapp pnpm start # -> https://api.myapp.localhost
1303
+ portless run next dev # -> https://<project>.localhost
1304
+ portless run next dev # in worktree -> https://<worktree>.<project>.localhost
1305
+ portless get backend # -> https://backend.localhost (for cross-service refs)
1223
1306
  # Wildcard subdomains: tenant.myapp.localhost also routes to myapp
1224
1307
 
1225
- ${chalk.bold("In package.json:")}
1308
+ ${colors_default.bold("In package.json:")}
1226
1309
  {
1227
1310
  "scripts": {
1228
1311
  "dev": "portless run next dev"
1229
1312
  }
1230
1313
  }
1231
1314
 
1232
- ${chalk.bold("How it works:")}
1233
- 1. Start the proxy once (listens on port 1355 by default, no sudo needed)
1315
+ ${colors_default.bold("How it works:")}
1316
+ 1. Start the proxy once (HTTPS on port 443 by default, auto-elevates with sudo)
1234
1317
  2. Run your apps - they auto-start the proxy and register automatically
1235
1318
  (apps get a random port in the 4000-4999 range via PORT)
1236
- 3. Access via http://<name>.localhost:1355
1319
+ 3. Access via https://<name>.localhost
1237
1320
  4. .localhost domains auto-resolve to 127.0.0.1
1238
1321
  5. Frameworks that ignore PORT (Vite, Astro, React Router, Angular,
1239
1322
  Expo, React Native) get --port and --host flags injected automatically
1240
1323
 
1241
- ${chalk.bold("HTTP/2 + HTTPS:")}
1242
- Use --https for HTTP/2 multiplexing (faster dev server page loads).
1324
+ ${colors_default.bold("HTTP/2 + HTTPS (default):")}
1325
+ HTTPS with HTTP/2 multiplexing is enabled by default (faster page loads).
1243
1326
  On first use, portless generates a local CA and adds it to your
1244
- system trust store. No browser warnings. No sudo required on macOS.
1327
+ system trust store. No browser warnings. Disable with --no-tls.
1245
1328
 
1246
- ${chalk.bold("Options:")}
1329
+ ${colors_default.bold("Options:")}
1247
1330
  run [--name <name>] <cmd> Infer project name (or override with --name)
1248
1331
  Adds worktree prefix in git worktrees
1249
- -p, --port <number> Port for the proxy to listen on (default: 1355)
1250
- Ports < 1024 require sudo
1251
- --https Enable HTTP/2 + TLS with auto-generated certs
1252
- --cert <path> Use a custom TLS certificate (implies --https)
1253
- --key <path> Use a custom TLS private key (implies --https)
1254
- --no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
1332
+ -p, --port <number> Port for the proxy (default: 443, or 80 with --no-tls)
1333
+ Standard ports auto-elevate with sudo on macOS/Linux
1334
+ --no-tls Disable HTTPS (use plain HTTP on port 80)
1335
+ --https Enable HTTPS (default, accepted for compatibility)
1336
+ --cert <path> Use a custom TLS certificate
1337
+ --key <path> Use a custom TLS private key
1255
1338
  --foreground Run proxy in foreground (for debugging)
1256
1339
  --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
1340
+ --wildcard Allow unregistered subdomains to fall back to parent route
1257
1341
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
1258
1342
  --force Override an existing route registered by another process
1259
1343
  --name <name> Use <name> as the app name (bypasses subcommand dispatch)
1260
1344
  -- Stop flag parsing; everything after is passed to the child
1261
1345
 
1262
- ${chalk.bold("Environment variables:")}
1346
+ ${colors_default.bold("Environment variables:")}
1263
1347
  PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
1264
1348
  PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
1265
- PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
1349
+ PORTLESS_HTTPS HTTPS on by default; set to 0 to disable (same as --no-tls)
1266
1350
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
1351
+ PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
1267
1352
  PORTLESS_SYNC_HOSTS=1 Auto-sync ${HOSTS_DISPLAY} (auto-enabled for custom TLDs)
1268
1353
  PORTLESS_STATE_DIR=<path> Override the state directory
1269
1354
  PORTLESS=0 Run command directly without proxy
1270
1355
 
1271
- ${chalk.bold("Child process environment:")}
1356
+ ${colors_default.bold("Child process environment:")}
1272
1357
  PORT Ephemeral port the child should listen on
1273
1358
  HOST Always 127.0.0.1
1274
- PORTLESS_URL Public URL of the app (e.g. http://myapp.localhost:1355)
1359
+ PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
1275
1360
 
1276
- ${chalk.bold("Safari / DNS:")}
1361
+ ${colors_default.bold("Safari / DNS:")}
1277
1362
  .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1278
1363
  Safari relies on the system DNS resolver, which may not handle them.
1279
1364
  Auto-syncs ${HOSTS_DISPLAY} for custom TLDs (e.g. --tld test). For .localhost,
1280
1365
  set PORTLESS_SYNC_HOSTS=1 to enable. To manually sync:
1281
- ${chalk.cyan(`${SUDO_PREFIX}portless hosts sync`)}
1366
+ ${colors_default.cyan("portless hosts sync")}
1282
1367
  Clean up later with:
1283
- ${chalk.cyan(`${SUDO_PREFIX}portless hosts clean`)}
1368
+ ${colors_default.cyan("portless hosts clean")}
1284
1369
 
1285
- ${chalk.bold("Skip portless:")}
1370
+ ${colors_default.bold("Skip portless:")}
1286
1371
  PORTLESS=0 pnpm dev # Runs command directly without proxy
1287
1372
 
1288
- ${chalk.bold("Reserved names:")}
1373
+ ${colors_default.bold("Reserved names:")}
1289
1374
  run, get, alias, hosts, list, trust, proxy are subcommands and cannot
1290
1375
  be used as app names directly. Use "portless run" to infer the name,
1291
1376
  or "portless --name <name>" to force any name including reserved ones.
@@ -1293,38 +1378,44 @@ ${chalk.bold("Reserved names:")}
1293
1378
  process.exit(0);
1294
1379
  }
1295
1380
  function printVersion() {
1296
- console.log("0.7.2");
1381
+ console.log("0.9.0");
1297
1382
  process.exit(0);
1298
1383
  }
1299
1384
  async function handleTrust() {
1300
1385
  const { dir } = await discoverState();
1301
1386
  const result = trustCA(dir);
1302
1387
  if (result.trusted) {
1303
- console.log(chalk.green("Local CA added to system trust store."));
1304
- console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
1305
- } else {
1306
- console.error(chalk.red(`Failed to trust CA: ${result.error}`));
1307
- if (result.error?.includes("sudo")) {
1308
- console.error(chalk.blue("Run with sudo:"));
1309
- console.error(chalk.cyan(" sudo portless trust"));
1310
- }
1311
- process.exit(1);
1388
+ console.log(colors_default.green("Local CA added to system trust store."));
1389
+ console.log(colors_default.gray("Browsers will now trust portless HTTPS certificates."));
1390
+ return;
1391
+ }
1392
+ const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
1393
+ if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
1394
+ console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
1395
+ const sudoResult = spawnSync("sudo", [process.execPath, getEntryScript(), "trust"], {
1396
+ stdio: "inherit",
1397
+ timeout: SUDO_SPAWN_TIMEOUT_MS
1398
+ });
1399
+ if (sudoResult.status === 0) return;
1400
+ console.error(colors_default.red("sudo elevation also failed."));
1312
1401
  }
1402
+ console.error(colors_default.red(`Failed to trust CA: ${result.error}`));
1403
+ process.exit(1);
1313
1404
  }
1314
1405
  async function handleList() {
1315
1406
  const { dir, port, tls: tls2 } = await discoverState();
1316
1407
  const store = new RouteStore(dir, {
1317
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1408
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1318
1409
  });
1319
1410
  listRoutes(store, port, tls2);
1320
1411
  }
1321
1412
  async function handleGet(args) {
1322
1413
  if (args[1] === "--help" || args[1] === "-h") {
1323
1414
  console.log(`
1324
- ${chalk.bold("portless get")} - Print the URL for a service.
1415
+ ${colors_default.bold("portless get")} - Print the URL for a service.
1325
1416
 
1326
- ${chalk.bold("Usage:")}
1327
- ${chalk.cyan("portless get <name>")}
1417
+ ${colors_default.bold("Usage:")}
1418
+ ${colors_default.cyan("portless get <name>")}
1328
1419
 
1329
1420
  Constructs the URL using the same hostname and worktree logic as
1330
1421
  "portless run", then prints it to stdout. Useful for wiring services
@@ -1332,14 +1423,14 @@ together:
1332
1423
 
1333
1424
  BACKEND_URL=$(portless get backend)
1334
1425
 
1335
- ${chalk.bold("Options:")}
1426
+ ${colors_default.bold("Options:")}
1336
1427
  --no-worktree Skip worktree prefix detection
1337
1428
  --help, -h Show this help
1338
1429
 
1339
- ${chalk.bold("Examples:")}
1340
- portless get backend # -> http://backend.localhost:1355
1341
- portless get backend # in worktree -> http://auth.backend.localhost:1355
1342
- portless get backend --no-worktree # -> http://backend.localhost:1355 (skip worktree)
1430
+ ${colors_default.bold("Examples:")}
1431
+ portless get backend # -> https://backend.localhost
1432
+ portless get backend # in worktree -> https://auth.backend.localhost
1433
+ portless get backend --no-worktree # -> https://backend.localhost (skip worktree)
1343
1434
  `);
1344
1435
  process.exit(0);
1345
1436
  }
@@ -1349,19 +1440,19 @@ ${chalk.bold("Examples:")}
1349
1440
  if (args[i] === "--no-worktree") {
1350
1441
  skipWorktree = true;
1351
1442
  } else if (args[i].startsWith("-")) {
1352
- console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1353
- console.error(chalk.blue("Known flags: --no-worktree, --help"));
1443
+ console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
1444
+ console.error(colors_default.blue("Known flags: --no-worktree, --help"));
1354
1445
  process.exit(1);
1355
1446
  } else {
1356
1447
  positional.push(args[i]);
1357
1448
  }
1358
1449
  }
1359
1450
  if (positional.length === 0) {
1360
- console.error(chalk.red("Error: Missing service name."));
1361
- console.error(chalk.blue("Usage:"));
1362
- console.error(chalk.cyan(" portless get <name>"));
1363
- console.error(chalk.blue("Example:"));
1364
- console.error(chalk.cyan(" portless get backend"));
1451
+ console.error(colors_default.red("Error: Missing service name."));
1452
+ console.error(colors_default.blue("Usage:"));
1453
+ console.error(colors_default.cyan(" portless get <name>"));
1454
+ console.error(colors_default.blue("Example:"));
1455
+ console.error(colors_default.cyan(" portless get backend"));
1365
1456
  process.exit(1);
1366
1457
  }
1367
1458
  const name = positional[0];
@@ -1375,76 +1466,76 @@ ${chalk.bold("Examples:")}
1375
1466
  async function handleAlias(args) {
1376
1467
  if (args[1] === "--help" || args[1] === "-h") {
1377
1468
  console.log(`
1378
- ${chalk.bold("portless alias")} - Register a static route for services not managed by portless.
1469
+ ${colors_default.bold("portless alias")} - Register a static route for services not managed by portless.
1379
1470
 
1380
- ${chalk.bold("Usage:")}
1381
- ${chalk.cyan("portless alias <name> <port>")} Register a route
1382
- ${chalk.cyan("portless alias --remove <name>")} Remove a route
1383
- ${chalk.cyan("portless alias <name> <port> --force")} Override existing route
1471
+ ${colors_default.bold("Usage:")}
1472
+ ${colors_default.cyan("portless alias <name> <port>")} Register a route
1473
+ ${colors_default.cyan("portless alias --remove <name>")} Remove a route
1474
+ ${colors_default.cyan("portless alias <name> <port> --force")} Override existing route
1384
1475
 
1385
- ${chalk.bold("Examples:")}
1386
- portless alias my-postgres 5432 # -> http://my-postgres.localhost:1355
1387
- portless alias redis 6379 # -> http://redis.localhost:1355
1476
+ ${colors_default.bold("Examples:")}
1477
+ portless alias my-postgres 5432 # -> https://my-postgres.localhost
1478
+ portless alias redis 6379 # -> https://redis.localhost
1388
1479
  portless alias --remove my-postgres # Remove the alias
1389
1480
  `);
1390
1481
  process.exit(0);
1391
1482
  }
1392
1483
  const { dir, tld } = await discoverState();
1393
1484
  const store = new RouteStore(dir, {
1394
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1485
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1395
1486
  });
1396
1487
  if (args[1] === "--remove") {
1397
1488
  const aliasName2 = args[2];
1398
1489
  if (!aliasName2) {
1399
- console.error(chalk.red("Error: No alias name provided."));
1400
- console.error(chalk.cyan(" portless alias --remove <name>"));
1490
+ console.error(colors_default.red("Error: No alias name provided."));
1491
+ console.error(colors_default.cyan(" portless alias --remove <name>"));
1401
1492
  process.exit(1);
1402
1493
  }
1403
1494
  const hostname2 = parseHostname(aliasName2, tld);
1404
1495
  const routes = store.loadRoutes();
1405
1496
  const existing = routes.find((r) => r.hostname === hostname2 && r.pid === 0);
1406
1497
  if (!existing) {
1407
- console.error(chalk.red(`Error: No alias found for "${hostname2}".`));
1498
+ console.error(colors_default.red(`Error: No alias found for "${hostname2}".`));
1408
1499
  process.exit(1);
1409
1500
  }
1410
1501
  store.removeRoute(hostname2);
1411
- console.log(chalk.green(`Removed alias: ${hostname2}`));
1502
+ console.log(colors_default.green(`Removed alias: ${hostname2}`));
1412
1503
  return;
1413
1504
  }
1414
1505
  const aliasName = args[1];
1415
1506
  const aliasPort = args[2];
1416
1507
  if (!aliasName || !aliasPort) {
1417
- console.error(chalk.red("Error: Missing arguments."));
1418
- console.error(chalk.blue("Usage:"));
1419
- console.error(chalk.cyan(" portless alias <name> <port>"));
1420
- console.error(chalk.cyan(" portless alias --remove <name>"));
1421
- console.error(chalk.blue("Example:"));
1422
- console.error(chalk.cyan(" portless alias my-postgres 5432"));
1508
+ console.error(colors_default.red("Error: Missing arguments."));
1509
+ console.error(colors_default.blue("Usage:"));
1510
+ console.error(colors_default.cyan(" portless alias <name> <port>"));
1511
+ console.error(colors_default.cyan(" portless alias --remove <name>"));
1512
+ console.error(colors_default.blue("Example:"));
1513
+ console.error(colors_default.cyan(" portless alias my-postgres 5432"));
1423
1514
  process.exit(1);
1424
1515
  }
1425
1516
  const hostname = parseHostname(aliasName, tld);
1426
1517
  const port = parseInt(aliasPort, 10);
1427
1518
  if (isNaN(port) || port < 1 || port > 65535) {
1428
- console.error(chalk.red(`Error: Invalid port "${aliasPort}". Must be 1-65535.`));
1519
+ console.error(colors_default.red(`Error: Invalid port "${aliasPort}". Must be 1-65535.`));
1429
1520
  process.exit(1);
1430
1521
  }
1431
1522
  const force = args.includes("--force");
1432
1523
  store.addRoute(hostname, port, 0, force);
1433
- console.log(chalk.green(`Alias registered: ${hostname} -> 127.0.0.1:${port}`));
1524
+ console.log(colors_default.green(`Alias registered: ${hostname} -> 127.0.0.1:${port}`));
1434
1525
  }
1435
1526
  async function handleHosts(args) {
1436
1527
  if (args[1] === "--help" || args[1] === "-h") {
1437
1528
  console.log(`
1438
- ${chalk.bold("portless hosts")} - Manage ${HOSTS_DISPLAY} entries for .localhost subdomains.
1529
+ ${colors_default.bold("portless hosts")} - Manage ${HOSTS_DISPLAY} entries for .localhost subdomains.
1439
1530
 
1440
1531
  Safari relies on the system DNS resolver, which may not handle .localhost
1441
1532
  subdomains. This command adds entries to ${HOSTS_DISPLAY} as a workaround.
1442
1533
 
1443
- ${chalk.bold("Usage:")}
1444
- ${chalk.cyan(`${SUDO_PREFIX}portless hosts sync`)} Add current routes to ${HOSTS_DISPLAY}
1445
- ${chalk.cyan(`${SUDO_PREFIX}portless hosts clean`)} Remove portless entries from ${HOSTS_DISPLAY}
1534
+ ${colors_default.bold("Usage:")}
1535
+ ${colors_default.cyan("portless hosts sync")} Add current routes to ${HOSTS_DISPLAY}
1536
+ ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1446
1537
 
1447
- ${chalk.bold("Auto-sync:")}
1538
+ ${colors_default.bold("Auto-sync:")}
1448
1539
  Auto-enabled for custom TLDs (e.g. --tld test). For .localhost, set
1449
1540
  PORTLESS_SYNC_HOSTS=1 to enable. Disable with PORTLESS_SYNC_HOSTS=0.
1450
1541
  `);
@@ -1452,114 +1543,137 @@ ${chalk.bold("Auto-sync:")}
1452
1543
  }
1453
1544
  if (args[1] === "clean") {
1454
1545
  if (cleanHostsFile()) {
1455
- console.log(chalk.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
1456
- } else {
1457
- console.error(
1458
- chalk.red(
1459
- `Failed to update ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : " (requires sudo)."}`
1546
+ console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
1547
+ return;
1548
+ }
1549
+ if (!isWindows && process.getuid?.() !== 0) {
1550
+ console.log(
1551
+ colors_default.yellow(
1552
+ `Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
1460
1553
  )
1461
1554
  );
1462
- console.error(chalk.cyan(` ${SUDO_PREFIX}portless hosts clean`));
1463
- process.exit(1);
1555
+ const result = spawnSync(
1556
+ "sudo",
1557
+ ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
1558
+ {
1559
+ stdio: "inherit",
1560
+ timeout: SUDO_SPAWN_TIMEOUT_MS
1561
+ }
1562
+ );
1563
+ if (result.status === 0) return;
1464
1564
  }
1565
+ console.error(
1566
+ colors_default.red(`Failed to update ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : "."}`)
1567
+ );
1568
+ process.exit(1);
1465
1569
  return;
1466
1570
  }
1467
1571
  if (!args[1]) {
1468
1572
  console.log(`
1469
- ${chalk.bold("Usage: portless hosts <command>")}
1573
+ ${colors_default.bold("Usage: portless hosts <command>")}
1470
1574
 
1471
- ${chalk.cyan(`${SUDO_PREFIX}portless hosts sync`)} Add current routes to ${HOSTS_DISPLAY}
1472
- ${chalk.cyan(`${SUDO_PREFIX}portless hosts clean`)} Remove portless entries from ${HOSTS_DISPLAY}
1575
+ ${colors_default.cyan("portless hosts sync")} Add current routes to ${HOSTS_DISPLAY}
1576
+ ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1473
1577
  `);
1474
1578
  process.exit(0);
1475
1579
  }
1476
1580
  if (args[1] !== "sync") {
1477
- console.error(chalk.red(`Error: Unknown hosts subcommand "${args[1]}".`));
1478
- console.error(chalk.blue("Usage:"));
1479
- console.error(
1480
- chalk.cyan(` ${SUDO_PREFIX}portless hosts sync # Add routes to ${HOSTS_DISPLAY}`)
1481
- );
1482
- console.error(chalk.cyan(` ${SUDO_PREFIX}portless hosts clean # Remove portless entries`));
1581
+ console.error(colors_default.red(`Error: Unknown hosts subcommand "${args[1]}".`));
1582
+ console.error(colors_default.blue("Usage:"));
1583
+ console.error(colors_default.cyan(` portless hosts sync # Add routes to ${HOSTS_DISPLAY}`));
1584
+ console.error(colors_default.cyan(" portless hosts clean # Remove portless entries"));
1483
1585
  process.exit(1);
1484
1586
  }
1485
1587
  const { dir } = await discoverState();
1486
1588
  const store = new RouteStore(dir, {
1487
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1589
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1488
1590
  });
1489
1591
  const routes = store.loadRoutes();
1490
1592
  if (routes.length === 0) {
1491
- console.log(chalk.yellow("No active routes to sync."));
1593
+ console.log(colors_default.yellow("No active routes to sync."));
1492
1594
  return;
1493
1595
  }
1494
1596
  const hostnames = routes.map((r) => r.hostname);
1495
1597
  if (syncHostsFile(hostnames)) {
1496
- console.log(chalk.green(`Synced ${hostnames.length} hostname(s) to ${HOSTS_DISPLAY}:`));
1598
+ console.log(colors_default.green(`Synced ${hostnames.length} hostname(s) to ${HOSTS_DISPLAY}:`));
1497
1599
  for (const h of hostnames) {
1498
- console.log(chalk.cyan(` 127.0.0.1 ${h}`));
1600
+ console.log(colors_default.cyan(` 127.0.0.1 ${h}`));
1499
1601
  }
1500
- } else {
1501
- console.error(
1502
- chalk.red(
1503
- `Failed to update ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : " (requires sudo)."}`
1504
- )
1602
+ return;
1603
+ }
1604
+ if (!isWindows && process.getuid?.() !== 0) {
1605
+ console.log(
1606
+ colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
1505
1607
  );
1506
- console.error(chalk.cyan(` ${SUDO_PREFIX}portless hosts sync`));
1507
- process.exit(1);
1608
+ const result = spawnSync(
1609
+ "sudo",
1610
+ ["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
1611
+ {
1612
+ stdio: "inherit",
1613
+ timeout: SUDO_SPAWN_TIMEOUT_MS
1614
+ }
1615
+ );
1616
+ if (result.status === 0) return;
1508
1617
  }
1618
+ console.error(
1619
+ colors_default.red(`Failed to update ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : "."}`)
1620
+ );
1621
+ process.exit(1);
1509
1622
  }
1510
1623
  async function handleProxy(args) {
1511
1624
  if (args[1] === "stop") {
1512
- const { dir, port, tls: tls2 } = await discoverState();
1513
- const store2 = new RouteStore(dir, {
1514
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1515
- });
1516
- await stopProxy(store2, port, tls2);
1625
+ let explicitPort;
1626
+ const portIdx = args.indexOf("--port") !== -1 ? args.indexOf("--port") : args.indexOf("-p");
1627
+ if (portIdx !== -1) {
1628
+ const portValue = args[portIdx + 1];
1629
+ if (portValue && !portValue.startsWith("-")) {
1630
+ const parsed = parseInt(portValue, 10);
1631
+ if (!isNaN(parsed) && parsed >= 1 && parsed <= 65535) {
1632
+ explicitPort = parsed;
1633
+ }
1634
+ }
1635
+ }
1636
+ if (explicitPort !== void 0) {
1637
+ const dir = resolveStateDir(explicitPort);
1638
+ const store2 = new RouteStore(dir, {
1639
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1640
+ });
1641
+ await stopProxy(store2, explicitPort, false);
1642
+ } else {
1643
+ const { dir, port, tls: tls2 } = await discoverState();
1644
+ const store2 = new RouteStore(dir, {
1645
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1646
+ });
1647
+ await stopProxy(store2, port, tls2);
1648
+ }
1517
1649
  return;
1518
1650
  }
1519
1651
  const isProxyHelp = args[1] === "--help" || args[1] === "-h";
1520
1652
  if (isProxyHelp || args[1] !== "start") {
1521
1653
  console.log(`
1522
- ${chalk.bold("portless proxy")} - Manage the portless proxy server.
1654
+ ${colors_default.bold("portless proxy")} - Manage the portless proxy server.
1523
1655
 
1524
- ${chalk.bold("Usage:")}
1525
- ${chalk.cyan("portless proxy start")} Start the proxy (daemon)
1526
- ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
1527
- ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
1528
- ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
1529
- ${chalk.cyan("portless proxy start --tld test")} Use .test instead of .localhost
1530
- ${chalk.cyan("portless proxy stop")} Stop the proxy
1656
+ ${colors_default.bold("Usage:")}
1657
+ ${colors_default.cyan("portless proxy start")} Start the HTTPS proxy on port 443 (daemon)
1658
+ ${colors_default.cyan("portless proxy start --no-tls")} Start without HTTPS (port 80)
1659
+ ${colors_default.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
1660
+ ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
1661
+ ${colors_default.cyan("portless proxy start --tld test")} Use .test instead of .localhost
1662
+ ${colors_default.cyan("portless proxy start --wildcard")} Allow unregistered subdomains to fall back to parent
1663
+ ${colors_default.cyan("portless proxy stop")} Stop the proxy
1531
1664
  `);
1532
1665
  process.exit(isProxyHelp || !args[1] ? 0 : 1);
1533
1666
  }
1534
1667
  const isForeground = args.includes("--foreground");
1535
- let proxyPort = getDefaultPort();
1536
- let portFlagIndex = args.indexOf("--port");
1537
- if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
1538
- if (portFlagIndex !== -1) {
1539
- const portValue = args[portFlagIndex + 1];
1540
- if (!portValue || portValue.startsWith("-")) {
1541
- console.error(chalk.red("Error: --port / -p requires a port number."));
1542
- console.error(chalk.blue("Usage:"));
1543
- console.error(chalk.cyan(" portless proxy start -p 8080"));
1544
- process.exit(1);
1545
- }
1546
- proxyPort = parseInt(portValue, 10);
1547
- if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
1548
- console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
1549
- console.error(chalk.blue("Port must be between 1 and 65535."));
1550
- process.exit(1);
1551
- }
1552
- }
1553
- const hasNoTls = args.includes("--no-tls");
1554
- const hasHttpsFlag = args.includes("--https");
1555
- const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
1668
+ const hasNoTls = args.includes("--no-tls") || isHttpsEnvDisabled();
1669
+ const wantHttps = !hasNoTls;
1556
1670
  let customCertPath = null;
1557
1671
  let customKeyPath = null;
1558
1672
  const certIdx = args.indexOf("--cert");
1559
1673
  if (certIdx !== -1) {
1560
1674
  customCertPath = args[certIdx + 1] || null;
1561
1675
  if (!customCertPath || customCertPath.startsWith("-")) {
1562
- console.error(chalk.red("Error: --cert requires a file path."));
1676
+ console.error(colors_default.red("Error: --cert requires a file path."));
1563
1677
  process.exit(1);
1564
1678
  }
1565
1679
  }
@@ -1567,76 +1681,158 @@ ${chalk.bold("Usage:")}
1567
1681
  if (keyIdx !== -1) {
1568
1682
  customKeyPath = args[keyIdx + 1] || null;
1569
1683
  if (!customKeyPath || customKeyPath.startsWith("-")) {
1570
- console.error(chalk.red("Error: --key requires a file path."));
1684
+ console.error(colors_default.red("Error: --key requires a file path."));
1571
1685
  process.exit(1);
1572
1686
  }
1573
1687
  }
1574
1688
  if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
1575
- console.error(chalk.red("Error: --cert and --key must be used together."));
1689
+ console.error(colors_default.red("Error: --cert and --key must be used together."));
1576
1690
  process.exit(1);
1577
1691
  }
1692
+ const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1693
+ let hasExplicitPort = false;
1694
+ let proxyPort = getDefaultPort(useHttps);
1695
+ let portFlagIndex = args.indexOf("--port");
1696
+ if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
1697
+ if (portFlagIndex !== -1) {
1698
+ const portValue = args[portFlagIndex + 1];
1699
+ if (!portValue || portValue.startsWith("-")) {
1700
+ console.error(colors_default.red("Error: --port / -p requires a port number."));
1701
+ console.error(colors_default.blue("Usage:"));
1702
+ console.error(colors_default.cyan(" portless proxy start -p 8080"));
1703
+ process.exit(1);
1704
+ }
1705
+ proxyPort = parseInt(portValue, 10);
1706
+ if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
1707
+ console.error(colors_default.red(`Error: Invalid port number: ${portValue}`));
1708
+ console.error(colors_default.blue("Port must be between 1 and 65535."));
1709
+ process.exit(1);
1710
+ }
1711
+ hasExplicitPort = true;
1712
+ }
1578
1713
  let tld;
1579
1714
  try {
1580
1715
  tld = getDefaultTld();
1581
1716
  } catch (err) {
1582
- console.error(chalk.red(`Error: ${err.message}`));
1717
+ console.error(colors_default.red(`Error: ${err.message}`));
1583
1718
  process.exit(1);
1584
1719
  }
1585
1720
  const tldIdx = args.indexOf("--tld");
1586
1721
  if (tldIdx !== -1) {
1587
1722
  const tldValue = args[tldIdx + 1];
1588
1723
  if (!tldValue || tldValue.startsWith("-")) {
1589
- console.error(chalk.red("Error: --tld requires a TLD value (e.g. test, localhost)."));
1724
+ console.error(colors_default.red("Error: --tld requires a TLD value (e.g. test, localhost)."));
1590
1725
  process.exit(1);
1591
1726
  }
1592
1727
  tld = tldValue.trim().toLowerCase();
1593
1728
  const tldErr = validateTld(tld);
1594
1729
  if (tldErr) {
1595
- console.error(chalk.red(`Error: ${tldErr}`));
1730
+ console.error(colors_default.red(`Error: ${tldErr}`));
1596
1731
  process.exit(1);
1597
1732
  }
1598
1733
  }
1599
1734
  const riskyReason = RISKY_TLDS.get(tld);
1600
1735
  if (riskyReason) {
1601
- console.warn(chalk.yellow(`Warning: .${tld} -- ${riskyReason}`));
1736
+ console.warn(colors_default.yellow(`Warning: .${tld}: ${riskyReason}`));
1602
1737
  }
1603
1738
  const syncDisabled = process.env.PORTLESS_SYNC_HOSTS === "0" || process.env.PORTLESS_SYNC_HOSTS === "false";
1604
1739
  if (tld !== DEFAULT_TLD && syncDisabled) {
1605
1740
  console.warn(
1606
- chalk.yellow(
1741
+ colors_default.yellow(
1607
1742
  `Warning: .${tld} domains require ${HOSTS_DISPLAY} entries to resolve to 127.0.0.1.`
1608
1743
  )
1609
1744
  );
1610
- console.warn(chalk.yellow("Hosts sync is disabled. To add entries manually, run:"));
1611
- console.warn(chalk.cyan(` ${SUDO_PREFIX}portless hosts sync`));
1745
+ console.warn(colors_default.yellow("Hosts sync is disabled. To add entries manually, run:"));
1746
+ console.warn(colors_default.cyan(" portless hosts sync"));
1612
1747
  }
1613
- const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1614
- const stateDir = resolveStateDir(proxyPort);
1615
- const store = new RouteStore(stateDir, {
1616
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1748
+ const useWildcard = args.includes("--wildcard") || isWildcardEnvEnabled();
1749
+ let stateDir = resolveStateDir(proxyPort);
1750
+ let store = new RouteStore(stateDir, {
1751
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1617
1752
  });
1618
1753
  if (await isProxyRunning(proxyPort)) {
1619
1754
  if (isForeground) {
1620
1755
  return;
1621
1756
  }
1622
- const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1623
- const sudoPrefix = needsSudo ? "sudo " : "";
1624
- const portFlag = proxyPort !== getDefaultPort() ? ` -p ${proxyPort}` : "";
1625
- console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
1757
+ const portFlag = proxyPort !== getProtocolPort(useHttps) ? ` -p ${proxyPort}` : "";
1758
+ console.log(colors_default.yellow(`Proxy is already running on port ${proxyPort}.`));
1626
1759
  console.log(
1627
- chalk.blue(
1628
- `To restart: ${sudoPrefix}portless proxy stop${portFlag} && ${sudoPrefix}portless proxy start${portFlag}`
1629
- )
1760
+ colors_default.blue(`To restart: portless proxy stop${portFlag} && portless proxy start${portFlag}`)
1630
1761
  );
1631
1762
  return;
1632
1763
  }
1633
1764
  if (!isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1634
- console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
1635
- console.error(chalk.blue("Either run with sudo:"));
1636
- console.error(chalk.cyan(" sudo portless proxy start -p 80"));
1637
- console.error(chalk.blue("Or use the default port (no sudo needed):"));
1638
- console.error(chalk.cyan(" portless proxy start"));
1639
- process.exit(1);
1765
+ const baseArgs = [
1766
+ process.execPath,
1767
+ getEntryScript(),
1768
+ "proxy",
1769
+ "start",
1770
+ "-p",
1771
+ String(proxyPort)
1772
+ ];
1773
+ const optionalFlags = [];
1774
+ if (hasNoTls) optionalFlags.push("--no-tls");
1775
+ if (tld !== DEFAULT_TLD) optionalFlags.push("--tld", tld);
1776
+ if (useWildcard) optionalFlags.push("--wildcard");
1777
+ if (isForeground) optionalFlags.push("--foreground");
1778
+ if (customCertPath && customKeyPath)
1779
+ optionalFlags.push("--cert", customCertPath, "--key", customKeyPath);
1780
+ const startArgs = [...baseArgs, ...optionalFlags];
1781
+ const extraFlags = optionalFlags.map((a) => ` ${a}`).join("");
1782
+ console.log(
1783
+ colors_default.yellow(`Port ${proxyPort} requires elevated privileges. Requesting sudo...`)
1784
+ );
1785
+ if (!hasExplicitPort) {
1786
+ console.log(
1787
+ colors_default.gray(
1788
+ `(To skip sudo, use an unprivileged port: portless proxy start -p ${FALLBACK_PROXY_PORT}${extraFlags})`
1789
+ )
1790
+ );
1791
+ }
1792
+ const result = spawnSync("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
1793
+ stdio: "inherit",
1794
+ timeout: SUDO_SPAWN_TIMEOUT_MS
1795
+ });
1796
+ if (result.status === 0) {
1797
+ if (!isForeground) {
1798
+ if (await waitForProxy(proxyPort)) {
1799
+ console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
1800
+ } else {
1801
+ console.error(colors_default.red("Proxy process started but is not responding."));
1802
+ const logPath2 = path3.join(resolveStateDir(proxyPort), "proxy.log");
1803
+ if (fs3.existsSync(logPath2)) {
1804
+ console.error(colors_default.gray(`Logs: ${logPath2}`));
1805
+ }
1806
+ }
1807
+ }
1808
+ return;
1809
+ }
1810
+ if (result.signal) {
1811
+ process.exit(1);
1812
+ }
1813
+ if (!hasExplicitPort) {
1814
+ proxyPort = FALLBACK_PROXY_PORT;
1815
+ console.log(colors_default.yellow(`Falling back to port ${proxyPort}.`));
1816
+ console.log(
1817
+ colors_default.blue(`For clean URLs without port numbers, re-run and accept the sudo prompt:`)
1818
+ );
1819
+ console.log(colors_default.cyan(` portless proxy start${extraFlags}`));
1820
+ if (await isProxyRunning(proxyPort)) {
1821
+ console.log(colors_default.yellow(`Proxy is already running on port ${proxyPort}.`));
1822
+ return;
1823
+ }
1824
+ stateDir = resolveStateDir(proxyPort);
1825
+ store = new RouteStore(stateDir, {
1826
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1827
+ });
1828
+ } else {
1829
+ console.error(
1830
+ colors_default.red(`Error: Port ${proxyPort} requires elevated privileges and sudo failed.`)
1831
+ );
1832
+ console.error(colors_default.blue("Try again (portless will prompt for sudo):"));
1833
+ console.error(colors_default.cyan(` portless proxy start -p ${proxyPort}${extraFlags}`));
1834
+ process.exit(1);
1835
+ }
1640
1836
  }
1641
1837
  let tlsOptions;
1642
1838
  if (useHttps) {
@@ -1648,43 +1844,45 @@ ${chalk.bold("Usage:")}
1648
1844
  const certStr = cert.toString("utf-8");
1649
1845
  const keyStr = key.toString("utf-8");
1650
1846
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
1651
- console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
1652
- console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
1847
+ console.error(colors_default.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
1848
+ console.error(colors_default.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
1653
1849
  process.exit(1);
1654
1850
  }
1655
1851
  if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
1656
- console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
1657
- console.error(chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----"));
1852
+ console.error(colors_default.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
1853
+ console.error(
1854
+ colors_default.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----")
1855
+ );
1658
1856
  process.exit(1);
1659
1857
  }
1660
1858
  tlsOptions = { cert, key };
1661
1859
  } catch (err) {
1662
1860
  const message = err instanceof Error ? err.message : String(err);
1663
- console.error(chalk.red(`Error reading certificate files: ${message}`));
1861
+ console.error(colors_default.red(`Error reading certificate files: ${message}`));
1664
1862
  process.exit(1);
1665
1863
  }
1666
1864
  } else {
1667
- console.log(chalk.gray("Ensuring TLS certificates..."));
1865
+ console.log(colors_default.gray("Ensuring TLS certificates..."));
1668
1866
  const certs = ensureCerts(stateDir);
1669
1867
  if (certs.caGenerated) {
1670
- console.log(chalk.green("Generated local CA certificate."));
1868
+ console.log(colors_default.green("Generated local CA certificate."));
1671
1869
  }
1672
1870
  if (!isCATrusted(stateDir)) {
1673
- console.log(chalk.yellow("Adding CA to system trust store..."));
1871
+ console.log(colors_default.yellow("Adding CA to system trust store..."));
1674
1872
  const trustResult = trustCA(stateDir);
1675
1873
  if (trustResult.trusted) {
1676
1874
  console.log(
1677
- chalk.green("CA added to system trust store. Browsers will trust portless certs.")
1875
+ colors_default.green("CA added to system trust store. Browsers will trust portless certs.")
1678
1876
  );
1679
1877
  } else {
1680
- console.warn(chalk.yellow("Could not add CA to system trust store."));
1878
+ console.warn(colors_default.yellow("Could not add CA to system trust store."));
1681
1879
  if (trustResult.error) {
1682
- console.warn(chalk.gray(trustResult.error));
1880
+ console.warn(colors_default.gray(trustResult.error));
1683
1881
  }
1684
1882
  console.warn(
1685
- chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
1883
+ colors_default.yellow("Browsers will show certificate warnings. To fix this later, run:")
1686
1884
  );
1687
- console.warn(chalk.cyan(" portless trust"));
1885
+ console.warn(colors_default.cyan(" portless trust"));
1688
1886
  }
1689
1887
  }
1690
1888
  const cert = fs3.readFileSync(certs.certPath);
@@ -1697,8 +1895,8 @@ ${chalk.bold("Usage:")}
1697
1895
  }
1698
1896
  }
1699
1897
  if (isForeground) {
1700
- console.log(chalk.blue.bold("\nportless proxy\n"));
1701
- startProxyServer(store, proxyPort, tld, tlsOptions);
1898
+ console.log(colors_default.blue.bold("\nportless proxy\n"));
1899
+ startProxyServer(store, proxyPort, tld, tlsOptions, useWildcard ? false : void 0);
1702
1900
  return;
1703
1901
  }
1704
1902
  store.ensureDir();
@@ -1710,20 +1908,29 @@ ${chalk.bold("Usage:")}
1710
1908
  } catch {
1711
1909
  }
1712
1910
  fixOwnership(logPath);
1713
- const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
1714
- if (portFlagIndex !== -1) {
1715
- daemonArgs.push("--port", proxyPort.toString());
1716
- }
1911
+ const daemonArgs = [
1912
+ getEntryScript(),
1913
+ "proxy",
1914
+ "start",
1915
+ "--foreground",
1916
+ "--port",
1917
+ proxyPort.toString()
1918
+ ];
1717
1919
  if (useHttps) {
1718
1920
  if (customCertPath && customKeyPath) {
1719
1921
  daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
1720
1922
  } else {
1721
1923
  daemonArgs.push("--https");
1722
1924
  }
1925
+ } else {
1926
+ daemonArgs.push("--no-tls");
1723
1927
  }
1724
1928
  if (tld !== DEFAULT_TLD) {
1725
1929
  daemonArgs.push("--tld", tld);
1726
1930
  }
1931
+ if (useWildcard) {
1932
+ daemonArgs.push("--wildcard");
1933
+ }
1727
1934
  const child = spawn(process.execPath, daemonArgs, {
1728
1935
  detached: true,
1729
1936
  stdio: ["ignore", logFd, logFd],
@@ -1735,32 +1942,31 @@ ${chalk.bold("Usage:")}
1735
1942
  fs3.closeSync(logFd);
1736
1943
  }
1737
1944
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
1738
- console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1739
- console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
1740
- const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1741
- console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
1945
+ console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
1946
+ console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
1947
+ console.error(colors_default.cyan(" portless proxy start --foreground"));
1742
1948
  if (fs3.existsSync(logPath)) {
1743
- console.error(chalk.gray(`Logs: ${logPath}`));
1949
+ console.error(colors_default.gray(`Logs: ${logPath}`));
1744
1950
  }
1745
1951
  process.exit(1);
1746
1952
  }
1747
1953
  const proto = useHttps ? "HTTPS/2" : "HTTP";
1748
- console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
1954
+ console.log(colors_default.green(`${proto} proxy started on port ${proxyPort}`));
1749
1955
  }
1750
1956
  async function handleRunMode(args) {
1751
1957
  const parsed = parseRunArgs(args);
1752
1958
  if (parsed.commandArgs.length === 0) {
1753
- console.error(chalk.red("Error: No command provided."));
1754
- console.error(chalk.blue("Usage:"));
1755
- console.error(chalk.cyan(" portless run <command...>"));
1756
- console.error(chalk.blue("Example:"));
1757
- console.error(chalk.cyan(" portless run next dev"));
1959
+ console.error(colors_default.red("Error: No command provided."));
1960
+ console.error(colors_default.blue("Usage:"));
1961
+ console.error(colors_default.cyan(" portless run <command...>"));
1962
+ console.error(colors_default.blue("Example:"));
1963
+ console.error(colors_default.cyan(" portless run next dev"));
1758
1964
  process.exit(1);
1759
1965
  }
1760
1966
  let baseName;
1761
1967
  let nameSource;
1762
1968
  if (parsed.name) {
1763
- baseName = parsed.name;
1969
+ baseName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
1764
1970
  nameSource = "--name flag";
1765
1971
  } else {
1766
1972
  const inferred = inferProjectName();
@@ -1771,7 +1977,7 @@ async function handleRunMode(args) {
1771
1977
  const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
1772
1978
  const { dir, port, tls: tls2, tld } = await discoverState();
1773
1979
  const store = new RouteStore(dir, {
1774
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1980
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1775
1981
  });
1776
1982
  await runApp(
1777
1983
  store,
@@ -1789,22 +1995,23 @@ async function handleRunMode(args) {
1789
1995
  async function handleNamedMode(args) {
1790
1996
  const parsed = parseAppArgs(args);
1791
1997
  if (parsed.commandArgs.length === 0) {
1792
- console.error(chalk.red("Error: No command provided."));
1793
- console.error(chalk.blue("Usage:"));
1794
- console.error(chalk.cyan(" portless <name> <command...>"));
1795
- console.error(chalk.blue("Example:"));
1796
- console.error(chalk.cyan(" portless myapp next dev"));
1998
+ console.error(colors_default.red("Error: No command provided."));
1999
+ console.error(colors_default.blue("Usage:"));
2000
+ console.error(colors_default.cyan(" portless <name> <command...>"));
2001
+ console.error(colors_default.blue("Example:"));
2002
+ console.error(colors_default.cyan(" portless myapp next dev"));
1797
2003
  process.exit(1);
1798
2004
  }
2005
+ const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
1799
2006
  const { dir, port, tls: tls2, tld } = await discoverState();
1800
2007
  const store = new RouteStore(dir, {
1801
- onWarning: (msg) => console.warn(chalk.yellow(msg))
2008
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
1802
2009
  });
1803
2010
  await runApp(
1804
2011
  store,
1805
2012
  port,
1806
2013
  dir,
1807
- parsed.name,
2014
+ safeName,
1808
2015
  parsed.commandArgs,
1809
2016
  tls2,
1810
2017
  tld,
@@ -1826,23 +2033,23 @@ async function main() {
1826
2033
  const isNpx = process.env.npm_command === "exec" && !process.env.npm_lifecycle_event;
1827
2034
  const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
1828
2035
  if (isNpx || isPnpmDlx) {
1829
- console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
1830
- console.error(chalk.blue("Install globally instead:"));
1831
- console.error(chalk.cyan(" npm install -g portless"));
2036
+ console.error(colors_default.red("Error: portless should not be run via npx or pnpm dlx."));
2037
+ console.error(colors_default.blue("Install globally instead:"));
2038
+ console.error(colors_default.cyan(" npm install -g portless"));
1832
2039
  process.exit(1);
1833
2040
  }
1834
2041
  if (args[0] === "--name") {
1835
2042
  args.shift();
1836
2043
  if (!args[0]) {
1837
- console.error(chalk.red("Error: --name requires an app name."));
1838
- console.error(chalk.cyan(" portless --name <name> <command...>"));
2044
+ console.error(colors_default.red("Error: --name requires an app name."));
2045
+ console.error(colors_default.cyan(" portless --name <name> <command...>"));
1839
2046
  process.exit(1);
1840
2047
  }
1841
2048
  const skipPortless2 = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
1842
2049
  if (skipPortless2) {
1843
2050
  const { commandArgs } = parseAppArgs(args);
1844
2051
  if (commandArgs.length === 0) {
1845
- console.error(chalk.red("Error: No command provided."));
2052
+ console.error(colors_default.red("Error: No command provided."));
1846
2053
  process.exit(1);
1847
2054
  }
1848
2055
  spawnCommand(commandArgs);
@@ -1859,7 +2066,7 @@ async function main() {
1859
2066
  if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy")) {
1860
2067
  const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
1861
2068
  if (commandArgs.length === 0) {
1862
- console.error(chalk.red("Error: No command provided."));
2069
+ console.error(colors_default.red("Error: No command provided."));
1863
2070
  process.exit(1);
1864
2071
  }
1865
2072
  spawnCommand(commandArgs);
@@ -1907,6 +2114,6 @@ async function main() {
1907
2114
  }
1908
2115
  main().catch((err) => {
1909
2116
  const message = err instanceof Error ? err.message : String(err);
1910
- console.error(chalk.red("Error:"), message);
2117
+ console.error(colors_default.red("Error:"), message);
1911
2118
  process.exit(1);
1912
2119
  });