portless 0.10.2 → 0.11.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,53 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- DEFAULT_TLD,
4
- FALLBACK_PROXY_PORT,
5
3
  FILE_MODE,
6
- INTERNAL_LAN_IP_ENV,
7
- INTERNAL_LAN_IP_FLAG,
8
- PRIVILEGED_PORT_THRESHOLD,
9
- RISKY_TLDS,
4
+ PORTLESS_HEADER,
10
5
  RouteConflictError,
11
6
  RouteStore,
12
- SYSTEM_STATE_DIR,
13
- USER_STATE_DIR,
14
- WAIT_FOR_PROXY_INTERVAL_MS,
15
- WAIT_FOR_PROXY_MAX_ATTEMPTS,
16
- buildProxyStartConfig,
17
7
  cleanHostsFile,
18
8
  createHttpRedirectServer,
19
9
  createProxyServer,
20
- discoverState,
21
- findFreePort,
22
- findPidOnPort,
23
10
  fixOwnership,
24
11
  formatUrl,
25
- getDefaultPort,
26
- getDefaultTld,
27
- injectFrameworkFlags,
28
12
  isErrnoException,
29
- isHttpsEnvDisabled,
30
- isLanEnvEnabled,
31
- isPortListening,
32
- isProxyRunning,
33
- isWildcardEnvEnabled,
34
- isWindows,
35
13
  parseHostname,
36
- prompt,
37
- readLanMarker,
38
- readPersistedProxyState,
39
- readTldFromDir,
40
- readTlsMarker,
41
- resolveStateDir,
42
14
  shouldAutoSyncHosts,
43
- spawnCommand,
44
- syncHostsFile,
45
- validateTld,
46
- waitForProxy,
47
- writeLanMarker,
48
- writeTldFile,
49
- writeTlsMarker
50
- } from "./chunk-EZJWUTUA.js";
15
+ syncHostsFile
16
+ } from "./chunk-6PDLZVDS.js";
51
17
 
52
18
  // src/colors.ts
53
19
  function supportsColor() {
@@ -60,24 +26,23 @@ var wrap = (open, close) => {
60
26
  if (!enabled) return (s) => s;
61
27
  return (s) => `\x1B[${open}m${s}\x1B[${close}m`;
62
28
  };
29
+ var identity = (s) => s;
63
30
  var bold = wrap("1", "22");
31
+ var dim = wrap("2", "22");
64
32
  var red = wrap("31", "39");
65
- var green = wrap("32", "39");
33
+ var green = identity;
66
34
  var yellow = wrap("33", "39");
67
- var blue = Object.assign(wrap("34", "39"), {
68
- bold: enabled ? (s) => `\x1B[34;1m${s}\x1B[22;39m` : (s) => s
69
- });
70
- var cyan = Object.assign(wrap("36", "39"), {
71
- bold: enabled ? (s) => `\x1B[36;1m${s}\x1B[22;39m` : (s) => s
72
- });
73
- var white = wrap("37", "39");
74
- var gray = wrap("90", "39");
75
- var colors_default = { bold, red, green, yellow, blue, cyan, white, gray };
35
+ var blue = Object.assign(identity, { bold });
36
+ var cyan = Object.assign(identity, { bold });
37
+ var white = identity;
38
+ var gray = dim;
39
+ var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
76
40
 
77
41
  // src/cli.ts
78
- import * as fs4 from "fs";
79
- import * as path4 from "path";
80
- import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
42
+ import * as fs8 from "fs";
43
+ import * as path8 from "path";
44
+ import { spawn as spawn3, spawnSync as spawnSync2 } from "child_process";
45
+ import { StringDecoder } from "string_decoder";
81
46
 
82
47
  // src/certs.ts
83
48
  import * as fs from "fs";
@@ -98,6 +63,7 @@ var CA_KEY_FILE = "ca-key.pem";
98
63
  var CA_CERT_FILE = "ca.pem";
99
64
  var SERVER_KEY_FILE = "server-key.pem";
100
65
  var SERVER_CERT_FILE = "server.pem";
66
+ var CA_TRUST_MARKER = "ca.trusted";
101
67
  function fileExists(filePath) {
102
68
  try {
103
69
  fs.accessSync(filePath, fs.constants.R_OK);
@@ -106,6 +72,36 @@ function fileExists(filePath) {
106
72
  return false;
107
73
  }
108
74
  }
75
+ function caFingerprint(stateDir) {
76
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
77
+ try {
78
+ const pem = fs.readFileSync(caCertPath);
79
+ return crypto.createHash("sha256").update(pem).digest("hex");
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+ function readTrustMarker(stateDir) {
85
+ try {
86
+ const value = fs.readFileSync(path.join(stateDir, CA_TRUST_MARKER), "utf-8").trim();
87
+ return value || null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+ function writeTrustMarker(stateDir) {
93
+ const fp = caFingerprint(stateDir);
94
+ if (fp) {
95
+ fs.writeFileSync(path.join(stateDir, CA_TRUST_MARKER), fp + "\n");
96
+ fixOwnership(path.join(stateDir, CA_TRUST_MARKER));
97
+ }
98
+ }
99
+ function clearTrustMarker(stateDir) {
100
+ try {
101
+ fs.unlinkSync(path.join(stateDir, CA_TRUST_MARKER));
102
+ } catch {
103
+ }
104
+ }
109
105
  var _opensslEnv;
110
106
  function getOpensslEnv() {
111
107
  if (process.platform !== "win32") return void 0;
@@ -228,6 +224,7 @@ function generateCA(stateDir) {
228
224
  fs.chmodSync(keyPath, 384);
229
225
  fs.chmodSync(certPath, 420);
230
226
  fixOwnership(keyPath, certPath);
227
+ clearTrustMarker(stateDir);
231
228
  return { certPath, keyPath };
232
229
  }
233
230
  function generateServerCert(stateDir) {
@@ -293,7 +290,8 @@ function ensureCerts(stateDir) {
293
290
  const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
294
291
  const serverKeyPath = path.join(stateDir, SERVER_KEY_FILE);
295
292
  let caGenerated = false;
296
- if (!fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath) || !isCertSignatureStrong(caCertPath)) {
293
+ const caMissing = !fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath) || !isCertSignatureStrong(caCertPath);
294
+ if (caMissing) {
297
295
  generateCA(stateDir);
298
296
  caGenerated = true;
299
297
  }
@@ -302,7 +300,7 @@ function ensureCerts(stateDir) {
302
300
  }
303
301
  return {
304
302
  certPath: serverCertPath,
305
- keyPath: path.join(stateDir, SERVER_KEY_FILE),
303
+ keyPath: serverKeyPath,
306
304
  caPath: caCertPath,
307
305
  caGenerated
308
306
  };
@@ -310,6 +308,11 @@ function ensureCerts(stateDir) {
310
308
  function isCATrusted(stateDir) {
311
309
  const caCertPath = path.join(stateDir, CA_CERT_FILE);
312
310
  if (!fileExists(caCertPath)) return false;
311
+ const marker = readTrustMarker(stateDir);
312
+ if (marker) {
313
+ const fp = caFingerprint(stateDir);
314
+ if (fp && marker === fp) return true;
315
+ }
313
316
  if (process.platform === "darwin") {
314
317
  return isCATrustedMacOS(caCertPath);
315
318
  } else if (process.platform === "linux") {
@@ -586,6 +589,7 @@ function trustCA(stateDir) {
586
589
  { stdio: "pipe", timeout: MACOS_SECURITY_AUTH_TIMEOUT_MS }
587
590
  );
588
591
  }
592
+ writeTrustMarker(stateDir);
589
593
  return { trusted: true };
590
594
  } else if (process.platform === "linux") {
591
595
  const config = getLinuxCATrustConfig();
@@ -595,12 +599,14 @@ function trustCA(stateDir) {
595
599
  const dest = path.join(config.certDir, "portless-ca.crt");
596
600
  fs.copyFileSync(caCertPath, dest);
597
601
  execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
602
+ writeTrustMarker(stateDir);
598
603
  return { trusted: true };
599
604
  } else if (process.platform === "win32") {
600
605
  execFileSync("certutil", ["-addstore", "-user", "Root", caCertPath], {
601
606
  stdio: "pipe",
602
607
  timeout: 3e4
603
608
  });
609
+ writeTrustMarker(stateDir);
604
610
  return { trusted: true };
605
611
  }
606
612
  return { trusted: false, error: `Unsupported platform: ${process.platform}` };
@@ -622,22 +628,26 @@ function trustCA(stateDir) {
622
628
  function untrustCA(stateDir) {
623
629
  const caCertPath = path.join(stateDir, CA_CERT_FILE);
624
630
  if (!fileExists(caCertPath)) {
631
+ clearTrustMarker(stateDir);
625
632
  return { removed: true };
626
633
  }
627
634
  if (!isCATrusted(stateDir)) {
635
+ clearTrustMarker(stateDir);
628
636
  return { removed: true };
629
637
  }
630
638
  try {
639
+ let result;
631
640
  if (process.platform === "darwin") {
632
- return untrustCAMacOS(caCertPath);
633
- }
634
- if (process.platform === "linux") {
635
- return untrustCALinux(stateDir);
636
- }
637
- if (process.platform === "win32") {
638
- return untrustCAWindows(caCertPath);
641
+ result = untrustCAMacOS(caCertPath);
642
+ } else if (process.platform === "linux") {
643
+ result = untrustCALinux(stateDir);
644
+ } else if (process.platform === "win32") {
645
+ result = untrustCAWindows(caCertPath);
646
+ } else {
647
+ result = { removed: false, error: `Unsupported platform: ${process.platform}` };
639
648
  }
640
- return { removed: false, error: `Unsupported platform: ${process.platform}` };
649
+ if (result.removed) clearTrustMarker(stateDir);
650
+ return result;
641
651
  } catch (err) {
642
652
  const message = err instanceof Error ? err.message : String(err);
643
653
  return { removed: false, error: message };
@@ -655,12 +665,13 @@ function untrustCAMacOS(caCertPath) {
655
665
  return false;
656
666
  }
657
667
  };
658
- if (tryExec(["remove-trusted-cert", caCertPath])) {
659
- return isCATrustedMacOSAfterAttempt(caCertPath) ? { removed: false, error: errors.join("; ") || "Trust entry may still be present" } : { removed: true };
668
+ tryExec(["remove-trusted-cert", caCertPath]);
669
+ const keychains = [loginKeychainPath(), "/Library/Keychains/System.keychain"];
670
+ for (const kc of keychains) {
671
+ for (let i = 0; i < 20; i++) {
672
+ if (!tryExec(["delete-certificate", "-c", CA_COMMON_NAME, kc])) break;
673
+ }
660
674
  }
661
- const login = loginKeychainPath();
662
- tryExec(["delete-certificate", "-c", CA_COMMON_NAME, login]);
663
- tryExec(["delete-certificate", "-c", CA_COMMON_NAME, "/Library/Keychains/System.keychain"]);
664
675
  return isCATrustedMacOSAfterAttempt(caCertPath) ? { removed: false, error: errors.join("; ") || "Could not remove CA from keychain (try sudo)" } : { removed: true };
665
676
  }
666
677
  function isCATrustedMacOSAfterAttempt(caCertPath) {
@@ -744,14 +755,14 @@ function untrustCAWindows(caCertPath) {
744
755
  }
745
756
 
746
757
  // src/auto.ts
747
- import { createHash } from "crypto";
758
+ import { createHash as createHash2 } from "crypto";
748
759
  import { execFileSync as execFileSync2 } from "child_process";
749
760
  import * as fs2 from "fs";
750
761
  import * as path2 from "path";
751
762
  var MAX_DNS_LABEL_LENGTH = 63;
752
763
  function truncateLabel(label) {
753
764
  if (label.length <= MAX_DNS_LABEL_LENGTH) return label;
754
- const hash = createHash("sha256").update(label).digest("hex").slice(0, 6);
765
+ const hash = createHash2("sha256").update(label).digest("hex").slice(0, 6);
755
766
  const maxPrefixLength = MAX_DNS_LABEL_LENGTH - 7;
756
767
  const prefix = label.slice(0, maxPrefixLength).replace(/-+$/, "");
757
768
  return `${prefix}-${hash}`;
@@ -917,9 +928,618 @@ function readBranchFromHead(gitdir) {
917
928
  }
918
929
  }
919
930
 
920
- // src/clean-utils.ts
931
+ // src/cli-utils.ts
921
932
  import * as fs3 from "fs";
933
+ import * as http from "http";
934
+ import * as https from "https";
935
+ import * as net from "net";
936
+ import * as os from "os";
922
937
  import * as path3 from "path";
938
+ import * as readline from "readline";
939
+ import { execSync, spawn } from "child_process";
940
+ var isWindows = process.platform === "win32";
941
+ var FALLBACK_PROXY_PORT = 1355;
942
+ var PRIVILEGED_PORT_THRESHOLD = 1024;
943
+ var INTERNAL_LAN_IP_ENV = "PORTLESS_INTERNAL_LAN_IP";
944
+ var INTERNAL_LAN_IP_FLAG = "--lan-ip-auto";
945
+ var LEGACY_SYSTEM_STATE_DIR = isWindows ? path3.join(os.tmpdir(), "portless") : "/tmp/portless";
946
+ var USER_STATE_DIR = path3.join(os.homedir(), ".portless");
947
+ var MIN_APP_PORT = 4e3;
948
+ var MAX_APP_PORT = 4999;
949
+ var RANDOM_PORT_ATTEMPTS = 50;
950
+ var BLOCKED_PORTS = /* @__PURE__ */ new Set([
951
+ 0,
952
+ 1,
953
+ 7,
954
+ 9,
955
+ 11,
956
+ 13,
957
+ 15,
958
+ 17,
959
+ 19,
960
+ 20,
961
+ 21,
962
+ 22,
963
+ 23,
964
+ 25,
965
+ 37,
966
+ 42,
967
+ 43,
968
+ 53,
969
+ 69,
970
+ 77,
971
+ 79,
972
+ 87,
973
+ 95,
974
+ 101,
975
+ 102,
976
+ 103,
977
+ 104,
978
+ 109,
979
+ 110,
980
+ 111,
981
+ 113,
982
+ 115,
983
+ 117,
984
+ 119,
985
+ 123,
986
+ 135,
987
+ 137,
988
+ 139,
989
+ 143,
990
+ 161,
991
+ 179,
992
+ 389,
993
+ 427,
994
+ 465,
995
+ 512,
996
+ 513,
997
+ 514,
998
+ 515,
999
+ 526,
1000
+ 530,
1001
+ 531,
1002
+ 532,
1003
+ 540,
1004
+ 548,
1005
+ 554,
1006
+ 556,
1007
+ 563,
1008
+ 587,
1009
+ 601,
1010
+ 636,
1011
+ 989,
1012
+ 990,
1013
+ 993,
1014
+ 995,
1015
+ 1719,
1016
+ 1720,
1017
+ 1723,
1018
+ 2049,
1019
+ 3659,
1020
+ 4045,
1021
+ 4190,
1022
+ 5060,
1023
+ 5061,
1024
+ 6e3,
1025
+ 6566,
1026
+ 6665,
1027
+ 6666,
1028
+ 6667,
1029
+ 6668,
1030
+ 6669,
1031
+ 6679,
1032
+ 6697,
1033
+ 10080
1034
+ ]);
1035
+ var SOCKET_TIMEOUT_MS = 500;
1036
+ var PID_LOOKUP_TIMEOUT_MS = 5e3;
1037
+ var WAIT_FOR_PROXY_MAX_ATTEMPTS = 20;
1038
+ var WAIT_FOR_PROXY_INTERVAL_MS = 250;
1039
+ var SIGNAL_CODES = {
1040
+ SIGHUP: 1,
1041
+ SIGINT: 2,
1042
+ SIGQUIT: 3,
1043
+ SIGABRT: 6,
1044
+ SIGKILL: 9,
1045
+ SIGTERM: 15
1046
+ };
1047
+ function getProtocolPort(tls2) {
1048
+ return tls2 ? 443 : 80;
1049
+ }
1050
+ function getDefaultPort(tls2) {
1051
+ const envPort = process.env.PORTLESS_PORT;
1052
+ if (envPort) {
1053
+ const port = parseInt(envPort, 10);
1054
+ if (!isNaN(port) && port >= 1 && port <= 65535) return port;
1055
+ }
1056
+ return tls2 === void 0 ? FALLBACK_PROXY_PORT : getProtocolPort(tls2);
1057
+ }
1058
+ function resolveStateDir(_port) {
1059
+ if (process.env.PORTLESS_STATE_DIR) return process.env.PORTLESS_STATE_DIR;
1060
+ return USER_STATE_DIR;
1061
+ }
1062
+ function readPortFromDir(dir) {
1063
+ try {
1064
+ const raw = fs3.readFileSync(path3.join(dir, "proxy.port"), "utf-8").trim();
1065
+ const port = parseInt(raw, 10);
1066
+ return isNaN(port) ? null : port;
1067
+ } catch {
1068
+ return null;
1069
+ }
1070
+ }
1071
+ var TLS_MARKER_FILE = "proxy.tls";
1072
+ function readTlsMarker(dir) {
1073
+ try {
1074
+ return fs3.existsSync(path3.join(dir, TLS_MARKER_FILE));
1075
+ } catch {
1076
+ return false;
1077
+ }
1078
+ }
1079
+ function writeTlsMarker(dir, enabled2) {
1080
+ const markerPath = path3.join(dir, TLS_MARKER_FILE);
1081
+ if (enabled2) {
1082
+ fs3.writeFileSync(markerPath, "1", { mode: 420 });
1083
+ } else {
1084
+ try {
1085
+ fs3.unlinkSync(markerPath);
1086
+ } catch {
1087
+ }
1088
+ }
1089
+ }
1090
+ var LAN_MARKER_FILE = "proxy.lan";
1091
+ function readLanMarker(dir) {
1092
+ try {
1093
+ const raw = fs3.readFileSync(path3.join(dir, LAN_MARKER_FILE), "utf-8").trim();
1094
+ return raw || null;
1095
+ } catch {
1096
+ return null;
1097
+ }
1098
+ }
1099
+ function writeLanMarker(dir, ip) {
1100
+ const markerPath = path3.join(dir, LAN_MARKER_FILE);
1101
+ if (!ip) {
1102
+ try {
1103
+ fs3.unlinkSync(markerPath);
1104
+ } catch {
1105
+ }
1106
+ } else {
1107
+ fs3.writeFileSync(markerPath, ip, { mode: 420 });
1108
+ }
1109
+ }
1110
+ var DEFAULT_TLD = "localhost";
1111
+ var RISKY_TLDS = /* @__PURE__ */ new Map([
1112
+ ["local", "conflicts with mDNS/Bonjour on macOS"],
1113
+ ["dev", "Google-owned; browsers force HTTPS via preloaded HSTS"],
1114
+ ["com", "public TLD; DNS requests will leak to the internet"],
1115
+ ["org", "public TLD; DNS requests will leak to the internet"],
1116
+ ["net", "public TLD; DNS requests will leak to the internet"],
1117
+ ["io", "public TLD; DNS requests will leak to the internet"],
1118
+ ["app", "public TLD; DNS requests will leak to the internet"],
1119
+ ["edu", "public TLD; DNS requests will leak to the internet"],
1120
+ ["gov", "public TLD; DNS requests will leak to the internet"],
1121
+ ["mil", "public TLD; DNS requests will leak to the internet"],
1122
+ ["int", "public TLD; DNS requests will leak to the internet"]
1123
+ ]);
1124
+ function validateTld(tld) {
1125
+ if (!tld) return "TLD cannot be empty";
1126
+ if (!/^[a-z0-9]+$/.test(tld)) {
1127
+ return `Invalid TLD "${tld}": must contain only lowercase letters and digits`;
1128
+ }
1129
+ return null;
1130
+ }
1131
+ var TLD_FILE = "proxy.tld";
1132
+ function readTldFromDir(dir) {
1133
+ try {
1134
+ const raw = fs3.readFileSync(path3.join(dir, TLD_FILE), "utf-8").trim();
1135
+ return raw || DEFAULT_TLD;
1136
+ } catch {
1137
+ return DEFAULT_TLD;
1138
+ }
1139
+ }
1140
+ function writeTldFile(dir, tld) {
1141
+ const filePath = path3.join(dir, TLD_FILE);
1142
+ if (tld === DEFAULT_TLD) {
1143
+ try {
1144
+ fs3.unlinkSync(filePath);
1145
+ } catch {
1146
+ }
1147
+ } else {
1148
+ fs3.writeFileSync(filePath, tld, { mode: 420 });
1149
+ }
1150
+ }
1151
+ function getDefaultTld() {
1152
+ const val = process.env.PORTLESS_TLD?.trim().toLowerCase();
1153
+ if (!val) return DEFAULT_TLD;
1154
+ const err = validateTld(val);
1155
+ if (err) throw new Error(`PORTLESS_TLD: ${err}`);
1156
+ return val;
1157
+ }
1158
+ function isHttpsEnvDisabled() {
1159
+ const val = process.env.PORTLESS_HTTPS;
1160
+ return val === "0" || val === "false";
1161
+ }
1162
+ function isWildcardEnvEnabled() {
1163
+ const val = process.env.PORTLESS_WILDCARD;
1164
+ return val === "1" || val === "true";
1165
+ }
1166
+ function isLanEnvEnabled() {
1167
+ const val = process.env.PORTLESS_LAN;
1168
+ return val === "1" || val === "true";
1169
+ }
1170
+ function readPersistedProxyState() {
1171
+ const dir = process.env.PORTLESS_STATE_DIR || USER_STATE_DIR;
1172
+ const port = readPortFromDir(dir);
1173
+ if (port !== null) {
1174
+ const tls2 = readTlsMarker(dir);
1175
+ const tld = readTldFromDir(dir);
1176
+ const lanIp = readLanMarker(dir);
1177
+ return { port, tls: tls2, tld, lanMode: lanIp !== null || tld === "local" };
1178
+ }
1179
+ return null;
1180
+ }
1181
+ function buildProxyStartConfig(options) {
1182
+ const effectiveTld = options.lanMode ? "local" : options.tld;
1183
+ const args = [];
1184
+ if (options.foreground) {
1185
+ args.push("--foreground");
1186
+ }
1187
+ if (options.includePort && options.proxyPort !== void 0) {
1188
+ args.push("--port", options.proxyPort.toString());
1189
+ }
1190
+ if (options.useHttps) {
1191
+ if (options.customCertPath && options.customKeyPath) {
1192
+ args.push("--cert", options.customCertPath, "--key", options.customKeyPath);
1193
+ } else {
1194
+ args.push("--https");
1195
+ }
1196
+ } else {
1197
+ args.push("--no-tls");
1198
+ }
1199
+ if (options.lanMode) {
1200
+ args.push("--lan");
1201
+ if (options.lanIp) {
1202
+ if (options.lanIpExplicit) {
1203
+ args.push("--ip", options.lanIp);
1204
+ } else {
1205
+ args.push(INTERNAL_LAN_IP_FLAG, options.lanIp);
1206
+ }
1207
+ }
1208
+ } else if (effectiveTld !== DEFAULT_TLD) {
1209
+ args.push("--tld", effectiveTld);
1210
+ }
1211
+ if (options.useWildcard) {
1212
+ args.push("--wildcard");
1213
+ }
1214
+ if (options.skipTrust) {
1215
+ args.push("--skip-trust");
1216
+ }
1217
+ return { effectiveTld, args };
1218
+ }
1219
+ async function discoverState() {
1220
+ if (process.env.PORTLESS_STATE_DIR) {
1221
+ const dir2 = process.env.PORTLESS_STATE_DIR;
1222
+ const port = readPortFromDir(dir2) ?? getDefaultPort();
1223
+ const lanIp = readLanMarker(dir2);
1224
+ if (await isProxyRunning(port) || await isPortListening(port)) {
1225
+ const tls2 = readTlsMarker(dir2);
1226
+ const tld = readTldFromDir(dir2);
1227
+ return { dir: dir2, port, tls: tls2, tld, lanMode: lanIp !== null || tld === "local", lanIp };
1228
+ }
1229
+ return {
1230
+ dir: dir2,
1231
+ port,
1232
+ tls: readTlsMarker(dir2),
1233
+ tld: readTldFromDir(dir2),
1234
+ lanMode: lanIp !== null,
1235
+ lanIp: null
1236
+ };
1237
+ }
1238
+ const userPort = readPortFromDir(USER_STATE_DIR);
1239
+ if (userPort !== null) {
1240
+ if (await isProxyRunning(userPort)) {
1241
+ const tls2 = readTlsMarker(USER_STATE_DIR);
1242
+ const tld = readTldFromDir(USER_STATE_DIR);
1243
+ const lanIp = readLanMarker(USER_STATE_DIR);
1244
+ return {
1245
+ dir: USER_STATE_DIR,
1246
+ port: userPort,
1247
+ tls: tls2,
1248
+ tld,
1249
+ lanMode: lanIp !== null || tld === "local",
1250
+ lanIp
1251
+ };
1252
+ }
1253
+ }
1254
+ const legacyPort = readPortFromDir(LEGACY_SYSTEM_STATE_DIR);
1255
+ if (legacyPort !== null) {
1256
+ if (await isProxyRunning(legacyPort)) {
1257
+ const tls2 = readTlsMarker(LEGACY_SYSTEM_STATE_DIR);
1258
+ const tld = readTldFromDir(LEGACY_SYSTEM_STATE_DIR);
1259
+ const lanIp = readLanMarker(LEGACY_SYSTEM_STATE_DIR);
1260
+ return {
1261
+ dir: LEGACY_SYSTEM_STATE_DIR,
1262
+ port: legacyPort,
1263
+ tls: tls2,
1264
+ tld,
1265
+ lanMode: lanIp !== null || tld === "local",
1266
+ lanIp
1267
+ };
1268
+ }
1269
+ }
1270
+ const configuredPort = getDefaultPort();
1271
+ const probePorts = /* @__PURE__ */ new Set([443, 80, FALLBACK_PROXY_PORT, configuredPort]);
1272
+ for (const port of probePorts) {
1273
+ if (await isProxyRunning(port)) {
1274
+ const dir2 = resolveStateDir(port);
1275
+ const markerTls = readTlsMarker(dir2);
1276
+ const tls2 = markerTls || port === getProtocolPort(true);
1277
+ const tld = readTldFromDir(dir2);
1278
+ const lanIp = readLanMarker(dir2);
1279
+ return { dir: dir2, port, tls: tls2, tld, lanMode: lanIp !== null || tld === "local", lanIp };
1280
+ }
1281
+ }
1282
+ const dir = resolveStateDir(configuredPort);
1283
+ return {
1284
+ dir,
1285
+ port: configuredPort,
1286
+ tls: readTlsMarker(dir),
1287
+ tld: readTldFromDir(dir),
1288
+ lanMode: readLanMarker(dir) !== null,
1289
+ lanIp: null
1290
+ };
1291
+ }
1292
+ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
1293
+ if (minPort > maxPort) {
1294
+ throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
1295
+ }
1296
+ const tryPort = (port) => {
1297
+ return new Promise((resolve3) => {
1298
+ const server = net.createServer();
1299
+ server.listen(port, () => {
1300
+ server.close(() => resolve3(true));
1301
+ });
1302
+ server.on("error", () => resolve3(false));
1303
+ });
1304
+ };
1305
+ for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
1306
+ const port = minPort + Math.floor(Math.random() * (maxPort - minPort + 1));
1307
+ if (!BLOCKED_PORTS.has(port) && await tryPort(port)) {
1308
+ return port;
1309
+ }
1310
+ }
1311
+ for (let port = minPort; port <= maxPort; port++) {
1312
+ if (!BLOCKED_PORTS.has(port) && await tryPort(port)) {
1313
+ return port;
1314
+ }
1315
+ }
1316
+ throw new Error(`No free port found in range ${minPort}-${maxPort}`);
1317
+ }
1318
+ function isProxyRunning(port, tls2 = false) {
1319
+ return new Promise((resolve3) => {
1320
+ const requestFn = tls2 ? https.request : http.request;
1321
+ const req = requestFn(
1322
+ {
1323
+ hostname: "127.0.0.1",
1324
+ port,
1325
+ path: "/",
1326
+ method: "HEAD",
1327
+ timeout: SOCKET_TIMEOUT_MS,
1328
+ ...tls2 ? { rejectUnauthorized: false } : {}
1329
+ },
1330
+ (res) => {
1331
+ res.resume();
1332
+ resolve3(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
1333
+ }
1334
+ );
1335
+ req.on("error", () => resolve3(false));
1336
+ req.on("timeout", () => {
1337
+ req.destroy();
1338
+ resolve3(false);
1339
+ });
1340
+ req.end();
1341
+ });
1342
+ }
1343
+ function isPortListening(port) {
1344
+ return new Promise((resolve3) => {
1345
+ const socket = net.createConnection({ host: "127.0.0.1", port });
1346
+ let settled = false;
1347
+ const finish = (result) => {
1348
+ if (settled) return;
1349
+ settled = true;
1350
+ socket.destroy();
1351
+ resolve3(result);
1352
+ };
1353
+ socket.setTimeout(SOCKET_TIMEOUT_MS);
1354
+ socket.once("connect", () => finish(true));
1355
+ socket.once("error", () => finish(false));
1356
+ socket.once("timeout", () => finish(false));
1357
+ });
1358
+ }
1359
+ function parsePidFromNetstat(output, port) {
1360
+ for (const line of output.split(/\r?\n/)) {
1361
+ if (!line.includes("LISTENING")) continue;
1362
+ const parts = line.trim().split(/\s+/);
1363
+ if (parts.length < 5) continue;
1364
+ const localAddr = parts[1];
1365
+ const lastColon = localAddr.lastIndexOf(":");
1366
+ if (lastColon === -1) continue;
1367
+ const addrPort = parseInt(localAddr.substring(lastColon + 1), 10);
1368
+ if (addrPort === port) {
1369
+ const pid = parseInt(parts[parts.length - 1], 10);
1370
+ if (!isNaN(pid) && pid > 0) return pid;
1371
+ }
1372
+ }
1373
+ return null;
1374
+ }
1375
+ function findPidOnPort(port) {
1376
+ try {
1377
+ if (isWindows) {
1378
+ const output2 = execSync("netstat -ano -p tcp", {
1379
+ encoding: "utf-8",
1380
+ timeout: PID_LOOKUP_TIMEOUT_MS
1381
+ });
1382
+ return parsePidFromNetstat(output2, port);
1383
+ }
1384
+ const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
1385
+ encoding: "utf-8",
1386
+ timeout: PID_LOOKUP_TIMEOUT_MS
1387
+ });
1388
+ const pid = parseInt(output.trim().split("\n")[0], 10);
1389
+ return isNaN(pid) ? null : pid;
1390
+ } catch {
1391
+ return null;
1392
+ }
1393
+ }
1394
+ async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
1395
+ for (let i = 0; i < maxAttempts; i++) {
1396
+ await new Promise((resolve3) => setTimeout(resolve3, intervalMs));
1397
+ if (await isProxyRunning(port, tls2)) {
1398
+ return true;
1399
+ }
1400
+ }
1401
+ return false;
1402
+ }
1403
+ function shellEscape(arg) {
1404
+ return `'${arg.replace(/'/g, "'\\''")}'`;
1405
+ }
1406
+ function collectBinPaths(cwd) {
1407
+ const dirs = [];
1408
+ let dir = cwd;
1409
+ for (; ; ) {
1410
+ const bin = path3.join(dir, "node_modules", ".bin");
1411
+ if (fs3.existsSync(bin)) {
1412
+ dirs.push(bin);
1413
+ }
1414
+ const parent = path3.dirname(dir);
1415
+ if (parent === dir) break;
1416
+ dir = parent;
1417
+ }
1418
+ return dirs;
1419
+ }
1420
+ function augmentedPath(env, cwd) {
1421
+ const source = env ?? process.env;
1422
+ const base = source.PATH ?? source.Path ?? "";
1423
+ const bins = collectBinPaths(cwd ?? process.cwd());
1424
+ const nodeBin = path3.dirname(process.execPath);
1425
+ const allBins = [...bins, nodeBin];
1426
+ return allBins.join(path3.delimiter) + path3.delimiter + base;
1427
+ }
1428
+ function spawnCommand(commandArgs, options) {
1429
+ const env = {
1430
+ ...options?.env ?? process.env,
1431
+ PATH: augmentedPath(options?.env)
1432
+ };
1433
+ if (isWindows) {
1434
+ for (const key of Object.keys(env)) {
1435
+ if (key !== "PATH" && key.toUpperCase() === "PATH") {
1436
+ delete env[key];
1437
+ }
1438
+ }
1439
+ }
1440
+ const child = isWindows ? spawn("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
1441
+ stdio: "inherit",
1442
+ env
1443
+ }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
1444
+ stdio: "inherit",
1445
+ env
1446
+ });
1447
+ let exiting = false;
1448
+ const cleanup = () => {
1449
+ process.removeListener("SIGINT", onSigInt);
1450
+ process.removeListener("SIGTERM", onSigTerm);
1451
+ options?.onCleanup?.();
1452
+ };
1453
+ const handleSignal = (signal) => {
1454
+ if (exiting) return;
1455
+ exiting = true;
1456
+ child.kill(signal);
1457
+ cleanup();
1458
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
1459
+ };
1460
+ const onSigInt = () => handleSignal("SIGINT");
1461
+ const onSigTerm = () => handleSignal("SIGTERM");
1462
+ process.on("SIGINT", onSigInt);
1463
+ process.on("SIGTERM", onSigTerm);
1464
+ child.on("error", (err) => {
1465
+ if (exiting) return;
1466
+ exiting = true;
1467
+ console.error(`Failed to run command: ${err.message}`);
1468
+ if (err.code === "ENOENT") {
1469
+ console.error(`Is "${commandArgs[0]}" installed and in your PATH?`);
1470
+ }
1471
+ cleanup();
1472
+ process.exit(1);
1473
+ });
1474
+ child.on("exit", (code, signal) => {
1475
+ if (exiting) return;
1476
+ exiting = true;
1477
+ cleanup();
1478
+ if (signal) {
1479
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
1480
+ }
1481
+ process.exit(code ?? 1);
1482
+ });
1483
+ }
1484
+ var FRAMEWORKS_NEEDING_PORT = {
1485
+ vite: { strictPort: true },
1486
+ vp: { strictPort: true },
1487
+ "react-router": { strictPort: true },
1488
+ rsbuild: { strictPort: false },
1489
+ astro: { strictPort: false },
1490
+ ng: { strictPort: false },
1491
+ "react-native": { strictPort: false },
1492
+ expo: { strictPort: false }
1493
+ };
1494
+ var PACKAGE_RUNNERS = {
1495
+ npx: [],
1496
+ bunx: [],
1497
+ pnpx: [],
1498
+ yarn: ["dlx", "exec"],
1499
+ pnpm: ["dlx", "exec"]
1500
+ };
1501
+ function findFrameworkBasename(commandArgs) {
1502
+ if (commandArgs.length === 0) return null;
1503
+ const first = path3.basename(commandArgs[0]);
1504
+ if (FRAMEWORKS_NEEDING_PORT[first]) return first;
1505
+ const subcommands = PACKAGE_RUNNERS[first];
1506
+ if (!subcommands) return null;
1507
+ let i = 1;
1508
+ if (subcommands.length > 0) {
1509
+ while (i < commandArgs.length && commandArgs[i].startsWith("-")) i++;
1510
+ if (i >= commandArgs.length) return null;
1511
+ if (!subcommands.includes(commandArgs[i])) {
1512
+ const name2 = path3.basename(commandArgs[i]);
1513
+ return FRAMEWORKS_NEEDING_PORT[name2] ? name2 : null;
1514
+ }
1515
+ i++;
1516
+ }
1517
+ while (i < commandArgs.length && commandArgs[i].startsWith("-")) i++;
1518
+ if (i >= commandArgs.length) return null;
1519
+ const name = path3.basename(commandArgs[i]);
1520
+ return FRAMEWORKS_NEEDING_PORT[name] ? name : null;
1521
+ }
1522
+ function injectFrameworkFlags(commandArgs, port) {
1523
+ const basename5 = findFrameworkBasename(commandArgs);
1524
+ if (!basename5) return;
1525
+ const framework = FRAMEWORKS_NEEDING_PORT[basename5];
1526
+ if (!commandArgs.includes("--port")) {
1527
+ commandArgs.push("--port", port.toString());
1528
+ if (framework.strictPort) {
1529
+ commandArgs.push("--strictPort");
1530
+ }
1531
+ }
1532
+ if (!commandArgs.includes("--host")) {
1533
+ const isExpoLan = basename5 === "expo" && isLanEnvEnabled();
1534
+ if (isExpoLan) return;
1535
+ const hostValue = basename5 === "expo" ? "localhost" : "127.0.0.1";
1536
+ commandArgs.push("--host", hostValue);
1537
+ }
1538
+ }
1539
+
1540
+ // src/clean-utils.ts
1541
+ import * as fs4 from "fs";
1542
+ import * as path4 from "path";
923
1543
  var PORTLESS_STATE_FILES = [
924
1544
  "routes.json",
925
1545
  "routes.lock",
@@ -943,29 +1563,29 @@ function collectStateDirsForCleanup() {
943
1563
  const add = (d) => {
944
1564
  const trimmed = d?.trim();
945
1565
  if (!trimmed) return;
946
- const resolved = path3.resolve(trimmed);
947
- if (fs3.existsSync(resolved)) dirs.add(resolved);
1566
+ const resolved = path4.resolve(trimmed);
1567
+ if (fs4.existsSync(resolved)) dirs.add(resolved);
948
1568
  };
949
1569
  add(USER_STATE_DIR);
950
- add(SYSTEM_STATE_DIR);
1570
+ add(LEGACY_SYSTEM_STATE_DIR);
951
1571
  add(process.env.PORTLESS_STATE_DIR);
952
1572
  return [...dirs];
953
1573
  }
954
1574
  function removePortlessStateFiles(dir) {
955
1575
  for (const f of PORTLESS_STATE_FILES) {
956
1576
  try {
957
- fs3.unlinkSync(path3.join(dir, f));
1577
+ fs4.unlinkSync(path4.join(dir, f));
958
1578
  } catch {
959
1579
  }
960
1580
  }
961
1581
  try {
962
- fs3.rmSync(path3.join(dir, HOST_CERTS_DIR2), { recursive: true, force: true });
1582
+ fs4.rmSync(path4.join(dir, HOST_CERTS_DIR2), { recursive: true, force: true });
963
1583
  } catch {
964
1584
  }
965
1585
  }
966
1586
 
967
1587
  // src/mdns.ts
968
- import { spawn, spawnSync } from "child_process";
1588
+ import { spawn as spawn2, spawnSync } from "child_process";
969
1589
 
970
1590
  // src/lan-ip.ts
971
1591
  import { createSocket } from "dgram";
@@ -1140,7 +1760,7 @@ function publish(hostname, port, ip, onError) {
1140
1760
  if (!publisher) {
1141
1761
  return;
1142
1762
  }
1143
- const child = spawn(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
1763
+ const child = spawn2(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
1144
1764
  stdio: "ignore",
1145
1765
  detached: false
1146
1766
  });
@@ -1167,6 +1787,528 @@ function cleanupAll() {
1167
1787
  activePublishers.clear();
1168
1788
  }
1169
1789
 
1790
+ // src/config.ts
1791
+ import * as fs5 from "fs";
1792
+ import * as path5 from "path";
1793
+ var ConfigValidationError = class extends Error {
1794
+ constructor(message) {
1795
+ super(message);
1796
+ this.name = "ConfigValidationError";
1797
+ }
1798
+ };
1799
+ var CONFIG_FILENAME = "portless.json";
1800
+ function loadConfig(cwd = process.cwd()) {
1801
+ const configPath = path5.join(cwd, CONFIG_FILENAME);
1802
+ try {
1803
+ const raw = fs5.readFileSync(configPath, "utf-8");
1804
+ const parsed = JSON.parse(raw);
1805
+ validateConfig(parsed, configPath);
1806
+ return { config: parsed, configDir: cwd };
1807
+ } catch (err) {
1808
+ if (isErrnoException2(err) && err.code === "ENOENT") {
1809
+ return loadConfigFromPackageJson(cwd);
1810
+ }
1811
+ if (err instanceof SyntaxError) {
1812
+ throw new ConfigValidationError(`Invalid JSON in ${configPath}`);
1813
+ }
1814
+ throw err;
1815
+ }
1816
+ }
1817
+ function normalizePortlessValue(value) {
1818
+ if (typeof value === "string") {
1819
+ return value.trim() ? { name: value.trim() } : null;
1820
+ }
1821
+ return value;
1822
+ }
1823
+ function loadConfigFromPackageJson(dir) {
1824
+ const pkgPath = path5.join(dir, "package.json");
1825
+ try {
1826
+ const raw = fs5.readFileSync(pkgPath, "utf-8");
1827
+ const pkg = JSON.parse(raw);
1828
+ if (pkg && typeof pkg === "object" && "portless" in pkg) {
1829
+ const config = normalizePortlessValue(pkg.portless);
1830
+ if (config === null) return null;
1831
+ validateConfig(config, `${pkgPath} "portless"`);
1832
+ return { config, configDir: dir };
1833
+ }
1834
+ } catch (err) {
1835
+ if (isErrnoException2(err) && err.code === "ENOENT") return null;
1836
+ if (err instanceof SyntaxError) return null;
1837
+ throw err;
1838
+ }
1839
+ return null;
1840
+ }
1841
+ function loadPackagePortlessConfig(dir) {
1842
+ const pkgPath = path5.join(dir, "package.json");
1843
+ try {
1844
+ const raw = fs5.readFileSync(pkgPath, "utf-8");
1845
+ const pkg = JSON.parse(raw);
1846
+ if (pkg && typeof pkg === "object" && "portless" in pkg) {
1847
+ const config = normalizePortlessValue(pkg.portless);
1848
+ if (config === null) return null;
1849
+ if (typeof config === "object" && !Array.isArray(config)) {
1850
+ validateAppConfig(config, "portless", pkgPath);
1851
+ return config;
1852
+ }
1853
+ }
1854
+ } catch (err) {
1855
+ if (err instanceof ConfigValidationError) throw err;
1856
+ }
1857
+ return null;
1858
+ }
1859
+ function resolveAppConfig(config, configDir, packageDir) {
1860
+ if (config.apps) {
1861
+ const rel = normalizePath(path5.relative(configDir, packageDir));
1862
+ if (rel && !rel.startsWith("..")) {
1863
+ let candidate = rel;
1864
+ while (candidate) {
1865
+ if (config.apps[candidate]) {
1866
+ return config.apps[candidate];
1867
+ }
1868
+ const parent = path5.dirname(candidate);
1869
+ if (parent === "." || parent === candidate) break;
1870
+ candidate = normalizePath(parent);
1871
+ }
1872
+ }
1873
+ return {};
1874
+ }
1875
+ return { name: config.name, script: config.script, appPort: config.appPort, proxy: config.proxy };
1876
+ }
1877
+ function hasScript(scriptName, dir) {
1878
+ const pkgPath = path5.join(dir, "package.json");
1879
+ try {
1880
+ const raw = fs5.readFileSync(pkgPath, "utf-8");
1881
+ const pkg = JSON.parse(raw);
1882
+ return typeof pkg?.scripts?.[scriptName] === "string";
1883
+ } catch {
1884
+ return false;
1885
+ }
1886
+ }
1887
+ var LOCK_FILES = [
1888
+ ["pnpm-lock.yaml", "pnpm"],
1889
+ ["yarn.lock", "yarn"],
1890
+ ["bun.lockb", "bun"],
1891
+ ["bun.lock", "bun"],
1892
+ ["package-lock.json", "npm"]
1893
+ ];
1894
+ function detectPackageManager(cwd) {
1895
+ let dir = cwd;
1896
+ for (; ; ) {
1897
+ const pkgPath = path5.join(dir, "package.json");
1898
+ try {
1899
+ const raw = fs5.readFileSync(pkgPath, "utf-8");
1900
+ const pkg = JSON.parse(raw);
1901
+ if (typeof pkg.packageManager === "string") {
1902
+ const name = pkg.packageManager.split("@")[0];
1903
+ if (name === "pnpm" || name === "yarn" || name === "bun" || name === "npm") {
1904
+ return name;
1905
+ }
1906
+ }
1907
+ } catch {
1908
+ }
1909
+ for (const [file, pm] of LOCK_FILES) {
1910
+ try {
1911
+ fs5.accessSync(path5.join(dir, file), fs5.constants.F_OK);
1912
+ return pm;
1913
+ } catch {
1914
+ }
1915
+ }
1916
+ const parent = path5.dirname(dir);
1917
+ if (parent === dir) break;
1918
+ dir = parent;
1919
+ }
1920
+ return "npm";
1921
+ }
1922
+ function resolveScriptCommand(scriptName, packageDir) {
1923
+ if (!hasScript(scriptName, packageDir)) return null;
1924
+ const pm = detectPackageManager(packageDir);
1925
+ return [pm, "run", scriptName];
1926
+ }
1927
+ function splitCommand(command) {
1928
+ const args = [];
1929
+ let current = "";
1930
+ let inSingle = false;
1931
+ let inDouble = false;
1932
+ let escaped = false;
1933
+ for (const ch of command) {
1934
+ if (escaped) {
1935
+ current += ch;
1936
+ escaped = false;
1937
+ continue;
1938
+ }
1939
+ if (ch === "\\" && !inSingle) {
1940
+ escaped = true;
1941
+ continue;
1942
+ }
1943
+ if (ch === "'" && !inDouble) {
1944
+ inSingle = !inSingle;
1945
+ } else if (ch === '"' && !inSingle) {
1946
+ inDouble = !inDouble;
1947
+ } else if (/\s/.test(ch) && !inSingle && !inDouble) {
1948
+ if (current) {
1949
+ args.push(current);
1950
+ current = "";
1951
+ }
1952
+ } else {
1953
+ current += ch;
1954
+ }
1955
+ }
1956
+ if (current) args.push(current);
1957
+ return args;
1958
+ }
1959
+ var BUILD_ONLY_COMMANDS = /* @__PURE__ */ new Set([
1960
+ "tsup",
1961
+ "tsc",
1962
+ "esbuild",
1963
+ "rollup",
1964
+ "babel",
1965
+ "swc",
1966
+ "unbuild",
1967
+ "pkgroll",
1968
+ "ncc",
1969
+ "microbundle"
1970
+ ]);
1971
+ function isServerCommand(args) {
1972
+ if (args.length === 0) return false;
1973
+ const bin = path5.basename(args[0]);
1974
+ return !BUILD_ONLY_COMMANDS.has(bin);
1975
+ }
1976
+ function normalizePath(p) {
1977
+ return p.replace(/\\/g, "/");
1978
+ }
1979
+ function isErrnoException2(err) {
1980
+ return err instanceof Error && "code" in err;
1981
+ }
1982
+ var KNOWN_TOP_KEYS = /* @__PURE__ */ new Set(["name", "script", "appPort", "proxy", "apps", "turbo"]);
1983
+ var KNOWN_APP_KEYS = /* @__PURE__ */ new Set(["name", "script", "appPort", "proxy"]);
1984
+ function validateConfig(config, configPath) {
1985
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
1986
+ throw new ConfigValidationError(`${configPath} must be a JSON object.`);
1987
+ }
1988
+ const obj = config;
1989
+ if (obj.name !== void 0) {
1990
+ if (typeof obj.name !== "string" || !obj.name.trim()) {
1991
+ throw new ConfigValidationError(`"name" in ${configPath} must be a non-empty string.`);
1992
+ }
1993
+ }
1994
+ if (obj.script !== void 0) {
1995
+ if (typeof obj.script !== "string" || !obj.script.trim()) {
1996
+ throw new ConfigValidationError(`"script" in ${configPath} must be a non-empty string.`);
1997
+ }
1998
+ }
1999
+ if (obj.appPort !== void 0) {
2000
+ if (typeof obj.appPort !== "number" || !Number.isInteger(obj.appPort) || obj.appPort < 1 || obj.appPort > 65535) {
2001
+ throw new ConfigValidationError(
2002
+ `"appPort" in ${configPath} must be an integer between 1 and 65535.`
2003
+ );
2004
+ }
2005
+ }
2006
+ if (obj.proxy !== void 0) {
2007
+ if (typeof obj.proxy !== "boolean") {
2008
+ throw new ConfigValidationError(`"proxy" in ${configPath} must be a boolean.`);
2009
+ }
2010
+ }
2011
+ if (obj.turbo !== void 0) {
2012
+ if (typeof obj.turbo !== "boolean") {
2013
+ throw new ConfigValidationError(`"turbo" in ${configPath} must be a boolean.`);
2014
+ }
2015
+ }
2016
+ if (obj.apps !== void 0) {
2017
+ if (typeof obj.apps !== "object" || obj.apps === null || Array.isArray(obj.apps)) {
2018
+ throw new ConfigValidationError(`"apps" in ${configPath} must be an object.`);
2019
+ }
2020
+ for (const [key, value] of Object.entries(obj.apps)) {
2021
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2022
+ throw new ConfigValidationError(`"apps.${key}" in ${configPath} must be an object.`);
2023
+ }
2024
+ validateAppConfig(value, `apps.${key}`, configPath);
2025
+ }
2026
+ }
2027
+ warnUnknownKeys(obj, KNOWN_TOP_KEYS, configPath);
2028
+ }
2029
+ function validateAppConfig(obj, prefix, configPath) {
2030
+ if (obj.name !== void 0) {
2031
+ if (typeof obj.name !== "string" || !obj.name.trim()) {
2032
+ throw new ConfigValidationError(
2033
+ `"${prefix}.name" in ${configPath} must be a non-empty string.`
2034
+ );
2035
+ }
2036
+ }
2037
+ if (obj.script !== void 0) {
2038
+ if (typeof obj.script !== "string" || !obj.script.trim()) {
2039
+ throw new ConfigValidationError(
2040
+ `"${prefix}.script" in ${configPath} must be a non-empty string.`
2041
+ );
2042
+ }
2043
+ }
2044
+ if (obj.appPort !== void 0) {
2045
+ if (typeof obj.appPort !== "number" || !Number.isInteger(obj.appPort) || obj.appPort < 1 || obj.appPort > 65535) {
2046
+ throw new ConfigValidationError(
2047
+ `"${prefix}.appPort" in ${configPath} must be an integer between 1 and 65535.`
2048
+ );
2049
+ }
2050
+ }
2051
+ if (obj.proxy !== void 0) {
2052
+ if (typeof obj.proxy !== "boolean") {
2053
+ throw new ConfigValidationError(`"${prefix}.proxy" in ${configPath} must be a boolean.`);
2054
+ }
2055
+ }
2056
+ warnUnknownKeys(obj, KNOWN_APP_KEYS, configPath, prefix);
2057
+ }
2058
+ function warnUnknownKeys(obj, known, configPath, prefix) {
2059
+ for (const key of Object.keys(obj)) {
2060
+ if (!known.has(key)) {
2061
+ const label = prefix ? `"${prefix}.${key}"` : `"${key}"`;
2062
+ console.warn(
2063
+ `Warning: Unknown key ${label} in ${configPath}. Known keys: ${[...known].join(", ")}`
2064
+ );
2065
+ }
2066
+ }
2067
+ }
2068
+
2069
+ // src/workspace.ts
2070
+ import * as fs6 from "fs";
2071
+ import * as path6 from "path";
2072
+ function findWorkspaceRoot(cwd = process.cwd()) {
2073
+ let dir = cwd;
2074
+ for (; ; ) {
2075
+ try {
2076
+ fs6.accessSync(path6.join(dir, "pnpm-workspace.yaml"), fs6.constants.R_OK);
2077
+ return dir;
2078
+ } catch {
2079
+ }
2080
+ if (readWorkspacesFromPackageJson(dir) !== null) {
2081
+ return dir;
2082
+ }
2083
+ const parent = path6.dirname(dir);
2084
+ if (parent === dir) return null;
2085
+ dir = parent;
2086
+ }
2087
+ }
2088
+ function detectWorkspaceSource(workspaceRoot) {
2089
+ try {
2090
+ fs6.accessSync(path6.join(workspaceRoot, "pnpm-workspace.yaml"), fs6.constants.R_OK);
2091
+ return "pnpm";
2092
+ } catch {
2093
+ }
2094
+ if (readWorkspacesFromPackageJson(workspaceRoot) !== null) {
2095
+ return "package-json";
2096
+ }
2097
+ return null;
2098
+ }
2099
+ function readWorkspacesFromPackageJson(dir) {
2100
+ const pkgPath = path6.join(dir, "package.json");
2101
+ try {
2102
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
2103
+ const pkg = JSON.parse(raw);
2104
+ if (!pkg || typeof pkg !== "object") return null;
2105
+ const ws = pkg.workspaces;
2106
+ if (Array.isArray(ws)) {
2107
+ return ws.filter((g) => typeof g === "string");
2108
+ }
2109
+ if (ws && typeof ws === "object" && !Array.isArray(ws) && Array.isArray(ws.packages)) {
2110
+ return ws.packages.filter((g) => typeof g === "string");
2111
+ }
2112
+ } catch {
2113
+ }
2114
+ return null;
2115
+ }
2116
+ function discoverWorkspacePackages(workspaceRoot) {
2117
+ const source = detectWorkspaceSource(workspaceRoot);
2118
+ let globs;
2119
+ if (source === "pnpm") {
2120
+ const wsPath = path6.join(workspaceRoot, "pnpm-workspace.yaml");
2121
+ let content;
2122
+ try {
2123
+ content = fs6.readFileSync(wsPath, "utf-8");
2124
+ } catch {
2125
+ return [];
2126
+ }
2127
+ globs = parsePnpmWorkspaceYaml(content);
2128
+ } else if (source === "package-json") {
2129
+ globs = readWorkspacesFromPackageJson(workspaceRoot) ?? [];
2130
+ } else {
2131
+ return [];
2132
+ }
2133
+ const dirs = expandPackageGlobs(workspaceRoot, globs);
2134
+ const packages = [];
2135
+ for (const dir of dirs) {
2136
+ const pkgPath = path6.join(dir, "package.json");
2137
+ try {
2138
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
2139
+ const pkg = JSON.parse(raw);
2140
+ const rawName = typeof pkg.name === "string" ? pkg.name : null;
2141
+ const scopeMatch = rawName?.match(/^@([^/]+)\//);
2142
+ const scope = scopeMatch ? scopeMatch[1] : null;
2143
+ const name = rawName ? rawName.replace(/^@[^/]+\//, "") : null;
2144
+ const scripts = typeof pkg.scripts === "object" && pkg.scripts !== null ? pkg.scripts : {};
2145
+ packages.push({ dir, name, scope, scripts });
2146
+ } catch {
2147
+ }
2148
+ }
2149
+ return packages;
2150
+ }
2151
+ function parsePnpmWorkspaceYaml(content) {
2152
+ const lines = content.split("\n");
2153
+ const globs = [];
2154
+ let inPackages = false;
2155
+ for (const rawLine of lines) {
2156
+ const line = rawLine.trimEnd();
2157
+ const headerMatch = line.match(/^packages\s*:(.*)/);
2158
+ if (headerMatch) {
2159
+ const rest = headerMatch[1].trim();
2160
+ if (rest.startsWith("[")) {
2161
+ return parseFlowSequence(rest);
2162
+ }
2163
+ inPackages = true;
2164
+ continue;
2165
+ }
2166
+ if (inPackages) {
2167
+ if (line.length > 0 && !line.startsWith(" ") && !line.startsWith(" ") && !line.startsWith("-")) {
2168
+ break;
2169
+ }
2170
+ const trimmed = line.trim();
2171
+ if (!trimmed || trimmed.startsWith("#")) continue;
2172
+ const match = trimmed.match(/^-\s+['"]?([^'"#]+?)['"]?\s*(?:#.*)?$/);
2173
+ if (match) {
2174
+ const glob = match[1].trim();
2175
+ if (glob) globs.push(glob);
2176
+ }
2177
+ }
2178
+ }
2179
+ return globs;
2180
+ }
2181
+ function parseFlowSequence(input) {
2182
+ const inner = input.replace(/^\[/, "").replace(/]\s*$/, "");
2183
+ return inner.split(",").map((s) => s.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim()).filter(Boolean);
2184
+ }
2185
+ function expandPackageGlobs(root, globs) {
2186
+ const included = /* @__PURE__ */ new Set();
2187
+ const excluded = /* @__PURE__ */ new Set();
2188
+ for (const glob of globs) {
2189
+ if (glob.startsWith("!")) {
2190
+ const negated = glob.slice(1);
2191
+ for (const dir of expandSingleGlob(root, negated)) {
2192
+ excluded.add(dir);
2193
+ }
2194
+ } else {
2195
+ for (const dir of expandSingleGlob(root, glob)) {
2196
+ included.add(dir);
2197
+ }
2198
+ }
2199
+ }
2200
+ for (const dir of excluded) {
2201
+ included.delete(dir);
2202
+ }
2203
+ return [...included].sort();
2204
+ }
2205
+ function segmentMatches(pattern, name) {
2206
+ if (pattern === "*" || pattern === "**") return true;
2207
+ const starIdx = pattern.indexOf("*");
2208
+ if (starIdx === -1) return pattern === name;
2209
+ const prefix = pattern.slice(0, starIdx);
2210
+ const suffix = pattern.slice(starIdx + 1).replace(/\*+$/, "");
2211
+ return name.startsWith(prefix) && name.endsWith(suffix);
2212
+ }
2213
+ function expandSingleGlob(root, glob) {
2214
+ const segments = glob.split("/");
2215
+ return expandSegments(root, segments);
2216
+ }
2217
+ function expandSegments(base, segments) {
2218
+ if (segments.length === 0) {
2219
+ try {
2220
+ const stat = fs6.statSync(base);
2221
+ if (stat.isDirectory()) return [base];
2222
+ } catch {
2223
+ }
2224
+ return [];
2225
+ }
2226
+ const [current, ...rest] = segments;
2227
+ if (current.includes("*")) {
2228
+ try {
2229
+ const entries = fs6.readdirSync(base, { withFileTypes: true });
2230
+ const matched = entries.filter((e) => e.isDirectory() && segmentMatches(current, e.name));
2231
+ if (rest.length === 0) {
2232
+ return matched.map((e) => path6.join(base, e.name));
2233
+ }
2234
+ const results = [];
2235
+ for (const entry of matched) {
2236
+ results.push(...expandSegments(path6.join(base, entry.name), rest));
2237
+ }
2238
+ return results;
2239
+ } catch {
2240
+ return [];
2241
+ }
2242
+ }
2243
+ return expandSegments(path6.join(base, current), rest);
2244
+ }
2245
+
2246
+ // src/turbo.ts
2247
+ import * as fs7 from "fs";
2248
+ import * as path7 from "path";
2249
+ var LOADER_FILENAME = "turbo-env-loader.cjs";
2250
+ var MANIFEST_FILENAME = "dev-manifest.json";
2251
+ function loaderPath(baseDir = USER_STATE_DIR) {
2252
+ return path7.join(baseDir, LOADER_FILENAME);
2253
+ }
2254
+ function manifestPath(baseDir = USER_STATE_DIR) {
2255
+ return path7.join(baseDir, MANIFEST_FILENAME);
2256
+ }
2257
+ function loaderSource(baseDir = USER_STATE_DIR) {
2258
+ return `"use strict";
2259
+ var fs = require("fs");
2260
+ var path = require("path");
2261
+ var manifestPath = path.join(${JSON.stringify(baseDir)}, "dev-manifest.json");
2262
+ try {
2263
+ var raw = fs.readFileSync(manifestPath, "utf-8");
2264
+ var manifest = JSON.parse(raw);
2265
+ var cwd = process.cwd();
2266
+ var entry = manifest[cwd];
2267
+ if (entry && typeof entry === "object") {
2268
+ var keys = Object.keys(entry);
2269
+ for (var i = 0; i < keys.length; i++) {
2270
+ process.env[keys[i]] = entry[keys[i]];
2271
+ }
2272
+ }
2273
+ } catch (_) {}
2274
+ `;
2275
+ }
2276
+ function ensureEnvLoader(baseDir = USER_STATE_DIR) {
2277
+ fs7.mkdirSync(baseDir, { recursive: true, mode: 493 });
2278
+ const target = loaderPath(baseDir);
2279
+ const source = loaderSource(baseDir);
2280
+ try {
2281
+ const existing = fs7.readFileSync(target, "utf-8");
2282
+ if (existing === source) return;
2283
+ } catch {
2284
+ }
2285
+ fs7.writeFileSync(target, source, { mode: 420 });
2286
+ }
2287
+ function writeManifest(entries, baseDir = USER_STATE_DIR) {
2288
+ fs7.mkdirSync(baseDir, { recursive: true, mode: 493 });
2289
+ fs7.writeFileSync(manifestPath(baseDir), JSON.stringify(entries, null, 2) + "\n", { mode: 420 });
2290
+ }
2291
+ function removeManifest(baseDir = USER_STATE_DIR) {
2292
+ try {
2293
+ fs7.unlinkSync(manifestPath(baseDir));
2294
+ } catch {
2295
+ }
2296
+ }
2297
+ function buildNodeOptions(baseDir = USER_STATE_DIR) {
2298
+ const existing = process.env.NODE_OPTIONS || "";
2299
+ const lp = loaderPath(baseDir);
2300
+ const requireFlag = lp.includes(" ") ? `--require "${lp}"` : `--require ${lp}`;
2301
+ return existing ? `${requireFlag} ${existing}` : requireFlag;
2302
+ }
2303
+ function hasTurboConfig(wsRoot) {
2304
+ try {
2305
+ fs7.accessSync(path7.join(wsRoot, "turbo.json"), fs7.constants.R_OK);
2306
+ return true;
2307
+ } catch {
2308
+ return false;
2309
+ }
2310
+ }
2311
+
1170
2312
  // src/cli.ts
1171
2313
  var chalk = colors_default;
1172
2314
  var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
@@ -1319,10 +2461,10 @@ function getEntryScript() {
1319
2461
  function isLocallyInstalled() {
1320
2462
  let dir = process.cwd();
1321
2463
  for (; ; ) {
1322
- if (fs4.existsSync(path4.join(dir, "node_modules", "portless", "package.json"))) {
2464
+ if (fs8.existsSync(path8.join(dir, "node_modules", "portless", "package.json"))) {
1323
2465
  return true;
1324
2466
  }
1325
- const parent = path4.dirname(dir);
2467
+ const parent = path8.dirname(dir);
1326
2468
  if (parent === dir) break;
1327
2469
  dir = parent;
1328
2470
  }
@@ -1358,11 +2500,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1358
2500
  console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
1359
2501
  }
1360
2502
  const routesPath = store.getRoutesPath();
1361
- if (!fs4.existsSync(routesPath)) {
1362
- fs4.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
2503
+ if (!fs8.existsSync(routesPath)) {
2504
+ fs8.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
1363
2505
  }
1364
2506
  try {
1365
- fs4.chmodSync(routesPath, FILE_MODE);
2507
+ fs8.chmodSync(routesPath, FILE_MODE);
1366
2508
  } catch {
1367
2509
  }
1368
2510
  fixOwnership(routesPath);
@@ -1422,7 +2564,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1422
2564
  }
1423
2565
  };
1424
2566
  try {
1425
- watcher = fs4.watch(routesPath, () => {
2567
+ watcher = fs8.watch(routesPath, () => {
1426
2568
  if (debounceTimer) clearTimeout(debounceTimer);
1427
2569
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
1428
2570
  });
@@ -1472,8 +2614,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1472
2614
  redirectServer.listen(80);
1473
2615
  }
1474
2616
  server.listen(proxyPort, () => {
1475
- fs4.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
1476
- fs4.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
2617
+ fs8.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
2618
+ fs8.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
1477
2619
  writeTlsMarker(store.dir, isTls);
1478
2620
  writeTldFile(store.dir, tld);
1479
2621
  writeLanMarker(store.dir, activeLanIp);
@@ -1489,7 +2631,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1489
2631
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
1490
2632
  if (isTls) {
1491
2633
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
1492
- console.log(chalk.gray(` ${path4.join(store.dir, "ca.pem")}`));
2634
+ console.log(chalk.gray(` ${path8.join(store.dir, "ca.pem")}`));
1493
2635
  }
1494
2636
  if (!lanIpPinned) {
1495
2637
  lanMonitor = startLanIpMonitor({
@@ -1521,15 +2663,16 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
1521
2663
  redirectServer.close();
1522
2664
  }
1523
2665
  try {
1524
- fs4.unlinkSync(store.pidPath);
2666
+ fs8.unlinkSync(store.pidPath);
1525
2667
  } catch {
1526
2668
  }
1527
2669
  try {
1528
- fs4.unlinkSync(store.portFilePath);
2670
+ fs8.unlinkSync(store.portFilePath);
1529
2671
  } catch {
1530
2672
  }
1531
2673
  writeTlsMarker(store.dir, false);
1532
2674
  writeTldFile(store.dir, DEFAULT_TLD);
2675
+ writeLanMarker(store.dir, null);
1533
2676
  if (autoSyncHosts) cleanHostsFile();
1534
2677
  server.close(() => process.exit(0));
1535
2678
  setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
@@ -1554,7 +2697,7 @@ function sudoStopOrHint(port) {
1554
2697
  }
1555
2698
  async function stopProxy(store, proxyPort, _tls) {
1556
2699
  const pidPath = store.pidPath;
1557
- if (!fs4.existsSync(pidPath)) {
2700
+ if (!fs8.existsSync(pidPath)) {
1558
2701
  if (await isProxyRunning(proxyPort)) {
1559
2702
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
1560
2703
  const pid = findPidOnPort(proxyPort);
@@ -1562,9 +2705,12 @@ async function stopProxy(store, proxyPort, _tls) {
1562
2705
  try {
1563
2706
  process.kill(pid, "SIGTERM");
1564
2707
  try {
1565
- fs4.unlinkSync(store.portFilePath);
2708
+ fs8.unlinkSync(store.portFilePath);
1566
2709
  } catch {
1567
2710
  }
2711
+ writeTlsMarker(store.dir, false);
2712
+ writeTldFile(store.dir, DEFAULT_TLD);
2713
+ writeLanMarker(store.dir, null);
1568
2714
  console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
1569
2715
  } catch (err) {
1570
2716
  if (isErrnoException(err) && err.code === "EPERM") {
@@ -1597,10 +2743,13 @@ async function stopProxy(store, proxyPort, _tls) {
1597
2743
  return;
1598
2744
  }
1599
2745
  try {
1600
- const pid = parseInt(fs4.readFileSync(pidPath, "utf-8"), 10);
2746
+ const pid = parseInt(fs8.readFileSync(pidPath, "utf-8"), 10);
1601
2747
  if (isNaN(pid)) {
1602
2748
  console.error(colors_default.red("Corrupted PID file. Removing it."));
1603
- fs4.unlinkSync(pidPath);
2749
+ fs8.unlinkSync(pidPath);
2750
+ writeTlsMarker(store.dir, false);
2751
+ writeTldFile(store.dir, DEFAULT_TLD);
2752
+ writeLanMarker(store.dir, null);
1604
2753
  return;
1605
2754
  }
1606
2755
  try {
@@ -1611,11 +2760,14 @@ async function stopProxy(store, proxyPort, _tls) {
1611
2760
  return;
1612
2761
  }
1613
2762
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
1614
- fs4.unlinkSync(pidPath);
2763
+ fs8.unlinkSync(pidPath);
1615
2764
  try {
1616
- fs4.unlinkSync(store.portFilePath);
2765
+ fs8.unlinkSync(store.portFilePath);
1617
2766
  } catch {
1618
2767
  }
2768
+ writeTlsMarker(store.dir, false);
2769
+ writeTldFile(store.dir, DEFAULT_TLD);
2770
+ writeLanMarker(store.dir, null);
1619
2771
  return;
1620
2772
  }
1621
2773
  if (!await isProxyRunning(proxyPort)) {
@@ -1625,15 +2777,21 @@ async function stopProxy(store, proxyPort, _tls) {
1625
2777
  )
1626
2778
  );
1627
2779
  console.log(colors_default.yellow("Removing stale PID file."));
1628
- fs4.unlinkSync(pidPath);
2780
+ fs8.unlinkSync(pidPath);
2781
+ writeTlsMarker(store.dir, false);
2782
+ writeTldFile(store.dir, DEFAULT_TLD);
2783
+ writeLanMarker(store.dir, null);
1629
2784
  return;
1630
2785
  }
1631
2786
  process.kill(pid, "SIGTERM");
1632
- fs4.unlinkSync(pidPath);
2787
+ fs8.unlinkSync(pidPath);
1633
2788
  try {
1634
- fs4.unlinkSync(store.portFilePath);
2789
+ fs8.unlinkSync(store.portFilePath);
1635
2790
  } catch {
1636
2791
  }
2792
+ writeTlsMarker(store.dir, false);
2793
+ writeTldFile(store.dir, DEFAULT_TLD);
2794
+ writeLanMarker(store.dir, null);
1637
2795
  console.log(colors_default.green("Proxy stopped."));
1638
2796
  } catch (err) {
1639
2797
  if (isErrnoException(err) && err.code === "EPERM") {
@@ -1659,26 +2817,16 @@ function listRoutes(store, proxyPort, tls2) {
1659
2817
  }
1660
2818
  console.log(colors_default.blue.bold("\nActive routes:\n"));
1661
2819
  for (const route of routes) {
1662
- const url = formatUrl(route.hostname, proxyPort, tls2);
1663
- const label = route.pid === 0 ? "(alias)" : `(pid ${route.pid})`;
1664
- console.log(
1665
- ` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
1666
- );
1667
- }
1668
- console.log();
1669
- }
1670
- async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort, lanMode = false, lanIp) {
1671
- let store = initialStore;
1672
- console.log(chalk.blue.bold(`
1673
- portless
1674
- `));
1675
- let envTld;
1676
- try {
1677
- envTld = getDefaultTld();
1678
- } catch (err) {
1679
- console.error(colors_default.red(`Error: ${err.message}`));
1680
- process.exit(1);
2820
+ const url = formatUrl(route.hostname, proxyPort, tls2);
2821
+ const label = route.pid === 0 ? "(alias)" : `(pid ${route.pid})`;
2822
+ console.log(
2823
+ ` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
2824
+ );
1681
2825
  }
2826
+ console.log();
2827
+ }
2828
+ function resolveProxyDesiredState(lanMode) {
2829
+ const envTld = getDefaultTld();
1682
2830
  const explicit = {
1683
2831
  useHttps: process.env.PORTLESS_HTTPS !== void 0,
1684
2832
  customCert: false,
@@ -1699,131 +2847,139 @@ portless
1699
2847
  tld: envTld,
1700
2848
  useWildcard: isWildcardEnvEnabled()
1701
2849
  });
1702
- parseHostname(name, tld);
2850
+ return { explicit, desiredConfig, envTld };
2851
+ }
2852
+ async function ensureProxyRunning(proxyPort, tls2, desired) {
2853
+ const { explicit, desiredConfig } = desired;
1703
2854
  const proxyResponsive = await isProxyRunning(proxyPort, tls2);
1704
2855
  const proxyListeningFromStateDir = !!process.env.PORTLESS_STATE_DIR && await isPortListening(proxyPort);
1705
- if (!proxyResponsive && !proxyListeningFromStateDir) {
1706
- const persisted = readPersistedProxyState();
1707
- const startConfig = { ...desiredConfig };
1708
- let startPort;
1709
- if (persisted) {
1710
- if (!explicit.useHttps && persisted.tls !== desiredConfig.useHttps) {
1711
- startConfig.useHttps = persisted.tls;
1712
- }
1713
- if (!explicit.tld && persisted.tld !== desiredConfig.tld) {
1714
- startConfig.tld = persisted.tld;
1715
- }
1716
- if (!explicit.lanMode && persisted.lanMode !== desiredConfig.lanMode) {
1717
- startConfig.lanMode = persisted.lanMode;
1718
- }
1719
- const envPort = getDefaultPort(startConfig.useHttps);
1720
- if (persisted.port !== envPort) {
1721
- startPort = persisted.port;
1722
- }
2856
+ if (proxyResponsive || proxyListeningFromStateDir) {
2857
+ return { started: false };
2858
+ }
2859
+ const persisted = readPersistedProxyState();
2860
+ const startConfig = { ...desiredConfig };
2861
+ let startPort;
2862
+ if (persisted) {
2863
+ if (!explicit.useHttps && persisted.tls !== desiredConfig.useHttps) {
2864
+ startConfig.useHttps = persisted.tls;
1723
2865
  }
1724
- const effectivePort = startPort ?? getDefaultPort(startConfig.useHttps);
1725
- const needsSudo = !isWindows && effectivePort < PRIVILEGED_PORT_THRESHOLD;
1726
- const manualStartCommand = formatProxyStartCommand(effectivePort, startConfig);
1727
- const fallbackStartCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, startConfig);
1728
- const isInteractive = !!process.stdin.isTTY && !process.env.CI;
1729
- if (needsSudo && !isInteractive) {
1730
- console.error(colors_default.red("Proxy is not running and no TTY is available for sudo."));
1731
- console.error(colors_default.blue("Option 1: start the proxy in a terminal (will prompt for sudo):"));
1732
- console.error(colors_default.cyan(` ${manualStartCommand}`));
1733
- console.error(
1734
- colors_default.blue(
1735
- `Option 2: use an unprivileged port (no sudo needed, URLs will include :${FALLBACK_PROXY_PORT}):`
1736
- )
1737
- );
1738
- console.error(colors_default.cyan(` ${fallbackStartCommand}`));
1739
- process.exit(1);
2866
+ if (!explicit.tld && persisted.tld !== desiredConfig.tld) {
2867
+ startConfig.tld = persisted.tld;
1740
2868
  }
1741
- if (needsSudo) {
1742
- const answer = await prompt(colors_default.yellow("Proxy not running. Start it? [Y/n/skip] "));
1743
- if (answer === "n" || answer === "no") {
1744
- console.log(colors_default.gray("Cancelled."));
1745
- process.exit(0);
1746
- }
1747
- if (answer === "s" || answer === "skip") {
1748
- console.log(colors_default.gray("Skipping proxy, running command directly...\n"));
1749
- spawnCommand(commandArgs);
1750
- return;
1751
- }
2869
+ if (!explicit.lanMode && persisted.lanMode !== desiredConfig.lanMode) {
2870
+ startConfig.lanMode = persisted.lanMode;
1752
2871
  }
1753
- if (persisted && startPort !== void 0) {
1754
- console.log(
1755
- colors_default.yellow(
1756
- `Starting proxy with previous configuration (port ${startPort}, ${startConfig.useHttps ? "HTTPS" : "HTTP"})...`
1757
- )
1758
- );
1759
- } else {
1760
- console.log(colors_default.yellow("Starting proxy..."));
1761
- }
1762
- const proxyStartConfig = buildProxyStartConfig({
1763
- useHttps: startConfig.useHttps,
1764
- customCertPath: startConfig.customCertPath,
1765
- customKeyPath: startConfig.customKeyPath,
1766
- lanMode: startConfig.lanMode,
1767
- lanIp: startConfig.lanIpExplicit ? startConfig.lanIp : null,
1768
- lanIpExplicit: startConfig.lanIpExplicit,
1769
- tld: startConfig.tld,
1770
- useWildcard: startConfig.useWildcard,
1771
- includePort: startPort !== void 0,
1772
- proxyPort: startPort
1773
- });
1774
- const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
1775
- const result = spawnSync2(process.execPath, startArgs, {
1776
- stdio: "inherit",
1777
- timeout: SUDO_SPAWN_TIMEOUT_MS
1778
- });
1779
- let discovered = null;
1780
- if (!result.signal) {
1781
- for (let i = 0; i < WAIT_FOR_PROXY_MAX_ATTEMPTS; i++) {
1782
- await new Promise((r) => setTimeout(r, WAIT_FOR_PROXY_INTERVAL_MS));
1783
- const state = await discoverState();
1784
- if (await isProxyRunning(state.port)) {
1785
- discovered = state;
1786
- break;
1787
- }
1788
- }
2872
+ const envPort = getDefaultPort(startConfig.useHttps);
2873
+ if (persisted.port !== envPort) {
2874
+ startPort = persisted.port;
1789
2875
  }
1790
- if (!discovered) {
1791
- console.error(colors_default.red("Failed to start proxy."));
1792
- const fallbackDir = resolveStateDir(effectivePort);
1793
- const logPath = path4.join(fallbackDir, "proxy.log");
1794
- console.error(colors_default.blue("Try starting it manually:"));
1795
- console.error(colors_default.cyan(` ${manualStartCommand}`));
1796
- if (fs4.existsSync(logPath)) {
1797
- console.error(colors_default.gray(`Logs: ${logPath}`));
2876
+ }
2877
+ const effectivePort = startPort ?? getDefaultPort(startConfig.useHttps);
2878
+ const needsSudo = !isWindows && effectivePort < PRIVILEGED_PORT_THRESHOLD;
2879
+ const manualStartCommand = formatProxyStartCommand(effectivePort, startConfig);
2880
+ const fallbackStartCommand = formatProxyStartCommand(FALLBACK_PROXY_PORT, startConfig);
2881
+ const isInteractive = !!process.stdin.isTTY && !process.env.CI;
2882
+ if (needsSudo && !isInteractive) {
2883
+ console.error(colors_default.red("Proxy is not running and no TTY is available for sudo."));
2884
+ console.error(colors_default.blue("Option 1: start the proxy in a terminal (will prompt for sudo):"));
2885
+ console.error(colors_default.cyan(` ${manualStartCommand}`));
2886
+ console.error(
2887
+ colors_default.blue(
2888
+ `Option 2: use an unprivileged port (no sudo needed, URLs will include :${FALLBACK_PROXY_PORT}):`
2889
+ )
2890
+ );
2891
+ console.error(colors_default.cyan(` ${fallbackStartCommand}`));
2892
+ process.exit(1);
2893
+ }
2894
+ console.log(colors_default.gray("Starting proxy..."));
2895
+ const proxyStartConfig = buildProxyStartConfig({
2896
+ useHttps: startConfig.useHttps,
2897
+ customCertPath: startConfig.customCertPath,
2898
+ customKeyPath: startConfig.customKeyPath,
2899
+ lanMode: startConfig.lanMode,
2900
+ lanIp: startConfig.lanIpExplicit ? startConfig.lanIp : null,
2901
+ lanIpExplicit: startConfig.lanIpExplicit,
2902
+ tld: startConfig.tld,
2903
+ useWildcard: startConfig.useWildcard,
2904
+ includePort: startPort !== void 0,
2905
+ proxyPort: startPort
2906
+ });
2907
+ const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
2908
+ const result = spawnSync2(process.execPath, startArgs, {
2909
+ stdio: "inherit",
2910
+ timeout: SUDO_SPAWN_TIMEOUT_MS
2911
+ });
2912
+ let discovered = null;
2913
+ if (!result.signal) {
2914
+ for (let i = 0; i < WAIT_FOR_PROXY_MAX_ATTEMPTS; i++) {
2915
+ await new Promise((r) => setTimeout(r, WAIT_FOR_PROXY_INTERVAL_MS));
2916
+ const state = await discoverState();
2917
+ if (await isProxyRunning(state.port)) {
2918
+ discovered = state;
2919
+ break;
1798
2920
  }
1799
- process.exit(1);
1800
- return;
1801
2921
  }
1802
- proxyPort = discovered.port;
1803
- stateDir = discovered.dir;
1804
- tld = discovered.tld;
1805
- tls2 = discovered.tls;
1806
- lanMode = discovered.lanMode;
1807
- lanIp = discovered.lanIp;
2922
+ }
2923
+ if (!discovered) {
2924
+ console.error(colors_default.red("Failed to start proxy."));
2925
+ const fallbackDir = resolveStateDir(effectivePort);
2926
+ const logPath = path8.join(fallbackDir, "proxy.log");
2927
+ console.error(colors_default.blue("Try starting it manually:"));
2928
+ console.error(colors_default.cyan(` ${manualStartCommand}`));
2929
+ if (fs8.existsSync(logPath)) {
2930
+ console.error(colors_default.gray(`Logs: ${logPath}`));
2931
+ }
2932
+ process.exit(1);
2933
+ return { started: false };
2934
+ }
2935
+ return { started: true, state: discovered };
2936
+ }
2937
+ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort, lanMode = false, lanIp) {
2938
+ let store = initialStore;
2939
+ console.log(chalk.blue.bold(`
2940
+ portless
2941
+ `));
2942
+ let desired;
2943
+ try {
2944
+ desired = resolveProxyDesiredState(lanMode);
2945
+ } catch (err) {
2946
+ console.error(colors_default.red(`Error: ${err.message}`));
2947
+ process.exit(1);
2948
+ }
2949
+ parseHostname(name, tld);
2950
+ const ensureResult = await ensureProxyRunning(proxyPort, tls2, desired);
2951
+ if (ensureResult.started) {
2952
+ proxyPort = ensureResult.state.port;
2953
+ stateDir = ensureResult.state.dir;
2954
+ tld = ensureResult.state.tld;
2955
+ tls2 = ensureResult.state.tls;
2956
+ lanMode = ensureResult.state.lanMode;
2957
+ lanIp = ensureResult.state.lanIp;
1808
2958
  store = new RouteStore(stateDir, {
1809
2959
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
1810
2960
  });
1811
- console.log(colors_default.green("Proxy started in background"));
2961
+ if (tls2 && !isCATrusted(stateDir)) {
2962
+ await handleTrust();
2963
+ }
1812
2964
  } else {
1813
2965
  const runningConfig = readCurrentProxyConfig(stateDir);
1814
- const mismatchMessages = getProxyConfigMismatchMessages(desiredConfig, runningConfig, explicit);
2966
+ const mismatchMessages = getProxyConfigMismatchMessages(
2967
+ desired.desiredConfig,
2968
+ runningConfig,
2969
+ desired.explicit
2970
+ );
1815
2971
  if (mismatchMessages.length > 0) {
1816
- printProxyConfigMismatch(proxyPort, desiredConfig, mismatchMessages);
2972
+ printProxyConfigMismatch(proxyPort, desired.desiredConfig, mismatchMessages);
1817
2973
  }
1818
2974
  lanMode = runningConfig.lanMode;
1819
2975
  lanIp = runningConfig.lanIp;
1820
2976
  console.log(chalk.gray("-- Proxy is running"));
1821
2977
  }
1822
2978
  const hostname = parseHostname(name, tld);
1823
- if (envTld !== DEFAULT_TLD && envTld !== tld) {
2979
+ if (desired.envTld !== DEFAULT_TLD && desired.envTld !== tld) {
1824
2980
  console.warn(
1825
2981
  chalk.yellow(
1826
- `Warning: PORTLESS_TLD=${envTld} but the running proxy uses .${tld}. Using .${tld}.`
2982
+ `Warning: PORTLESS_TLD=${desired.envTld} but the running proxy uses .${tld}. Using .${tld}.`
1827
2983
  )
1828
2984
  );
1829
2985
  }
@@ -1866,8 +3022,8 @@ portless
1866
3022
  console.log(chalk.green(` LAN -> ${finalUrl}`));
1867
3023
  console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
1868
3024
  }
1869
- const basename3 = path4.basename(commandArgs[0]);
1870
- const isExpo = basename3 === "expo";
3025
+ const basename5 = path8.basename(commandArgs[0]);
3026
+ const isExpo = basename5 === "expo";
1871
3027
  const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
1872
3028
  const hostBind = isExpoLan ? void 0 : "127.0.0.1";
1873
3029
  if (lanMode && !process.env.PORTLESS_LAN) {
@@ -1876,8 +3032,8 @@ portless
1876
3032
  injectFrameworkFlags(commandArgs, port);
1877
3033
  const caEnv = {};
1878
3034
  if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
1879
- const caPath = path4.join(stateDir, "ca.pem");
1880
- if (fs4.existsSync(caPath)) {
3035
+ const caPath = path8.join(stateDir, "ca.pem");
3036
+ if (fs8.existsSync(caPath)) {
1881
3037
  caEnv.NODE_EXTRA_CA_CERTS = caPath;
1882
3038
  }
1883
3039
  }
@@ -1945,7 +3101,10 @@ function parseRunArgs(args) {
1945
3101
  ${colors_default.bold("portless run")} - Infer project name and run through the proxy.
1946
3102
 
1947
3103
  ${colors_default.bold("Usage:")}
1948
- ${colors_default.cyan("portless run [options] <command...>")}
3104
+ ${colors_default.cyan("portless run [options] [command...]")}
3105
+
3106
+ When no command is given, runs the configured script (default: "dev")
3107
+ from package.json.
1949
3108
 
1950
3109
  ${colors_default.bold("Options:")}
1951
3110
  --name <name> Override the inferred base name (worktree prefix still applies)
@@ -1954,15 +3113,17 @@ ${colors_default.bold("Options:")}
1954
3113
  --help, -h Show this help
1955
3114
 
1956
3115
  ${colors_default.bold("Name inference (in order):")}
1957
- 1. package.json "name" field (walks up directories)
1958
- 2. Git repo root directory name
1959
- 3. Current directory basename
3116
+ 1. portless.json "name" field
3117
+ 2. package.json "name" field (walks up directories)
3118
+ 3. Git repo root directory name
3119
+ 4. Current directory basename
1960
3120
 
1961
3121
  Use --name to override the inferred name while keeping worktree prefixes.
1962
3122
  In git worktrees, the branch name is prepended as a subdomain prefix
1963
3123
  (e.g. feature-auth.myapp.localhost).
1964
3124
 
1965
3125
  ${colors_default.bold("Examples:")}
3126
+ portless run # Run dev script through proxy
1966
3127
  portless run next dev # -> https://<project>.localhost
1967
3128
  portless run --name myapp next dev # -> https://myapp.localhost
1968
3129
  portless run vite dev # -> https://<project>.localhost
@@ -2045,13 +3206,13 @@ ${colors_default.bold("Install:")}
2045
3206
  ${colors_default.cyan("npm install -D portless")} Project dev dependency
2046
3207
 
2047
3208
  ${colors_default.bold("Usage:")}
3209
+ ${colors_default.cyan("portless")} Run dev script through proxy
3210
+ ${colors_default.cyan("portless")} From monorepo root: run all workspace packages
3211
+ ${colors_default.cyan("portless run")} Same as above
3212
+ ${colors_default.cyan("portless run <cmd>")} Run a command through the proxy
3213
+ ${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
2048
3214
  ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
2049
- ${colors_default.cyan("portless proxy start --no-tls")} Start without HTTPS (port 80)
2050
- ${colors_default.cyan("portless proxy start --lan")} Start in LAN mode (mDNS for real device testing)
2051
- ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
2052
3215
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
2053
- ${colors_default.cyan("portless <name> <cmd>")} Run your app through the proxy
2054
- ${colors_default.cyan("portless run <cmd>")} Infer name from project, run through proxy
2055
3216
  ${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
2056
3217
  ${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
2057
3218
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
@@ -2062,22 +3223,31 @@ ${colors_default.bold("Usage:")}
2062
3223
  ${colors_default.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
2063
3224
 
2064
3225
  ${colors_default.bold("Examples:")}
2065
- portless proxy start # Start HTTPS proxy on port 443
2066
- portless proxy start --no-tls # Start HTTP proxy on port 80
3226
+ portless # Run dev script through proxy
3227
+ portless # From monorepo root: start all apps
3228
+ portless --script start # Run "start" script instead of "dev"
2067
3229
  portless myapp next dev # -> https://myapp.localhost
2068
- portless myapp vite dev # -> https://myapp.localhost
2069
- portless api.myapp pnpm start # -> https://api.myapp.localhost
2070
3230
  portless run next dev # -> https://<project>.localhost
2071
3231
  portless run next dev # in worktree -> https://<worktree>.<project>.localhost
2072
- portless get backend # -> https://backend.localhost (for cross-service refs)
2073
- # Wildcard subdomains: tenant.myapp.localhost also routes to myapp
3232
+ portless get backend # -> https://backend.localhost
3233
+
3234
+ ${colors_default.bold("Configuration (portless.json):")}
3235
+ Optional. Portless works out of the box by running the "dev" script
3236
+ from package.json. Use portless.json to override defaults.
3237
+
3238
+ Override name: { "name": "myapp" }
3239
+ Override script: { "name": "myapp", "script": "start" }
3240
+ Monorepo: { "apps": { "apps/web": { "name": "myapp" } } }
2074
3241
 
2075
3242
  ${colors_default.bold("In package.json:")}
2076
3243
  {
2077
3244
  "scripts": {
2078
- "dev": "portless run next dev"
3245
+ "dev": "next dev"
2079
3246
  }
2080
3247
  }
3248
+ Then run: portless
3249
+ Or: portless run
3250
+ Or: portless run next dev
2081
3251
 
2082
3252
  ${colors_default.bold("How it works:")}
2083
3253
  1. Start the proxy once (HTTPS on port 443 by default, auto-elevates with sudo)
@@ -2113,6 +3283,7 @@ ${colors_default.bold("LAN mode:")}
2113
3283
  ${colors_default.bold("Options:")}
2114
3284
  run [--name <name>] <cmd> Infer project name (or override with --name)
2115
3285
  Adds worktree prefix in git worktrees
3286
+ --script <name> Run a specific package.json script (default: dev)
2116
3287
  -p, --port <number> Port for the proxy (default: 443, or 80 with --no-tls)
2117
3288
  Standard ports auto-elevate with sudo on macOS/Linux
2118
3289
  --no-tls Disable HTTPS (use plain HTTP on port 80)
@@ -2167,13 +3338,13 @@ ${colors_default.bold("Reserved names:")}
2167
3338
  process.exit(0);
2168
3339
  }
2169
3340
  function printVersion() {
2170
- console.log("0.10.2");
3341
+ console.log("0.11.0");
2171
3342
  process.exit(0);
2172
3343
  }
2173
3344
  async function handleTrust() {
2174
3345
  const { dir } = await discoverState();
2175
- if (!fs4.existsSync(dir)) {
2176
- fs4.mkdirSync(dir, { recursive: true });
3346
+ if (!fs8.existsSync(dir)) {
3347
+ fs8.mkdirSync(dir, { recursive: true });
2177
3348
  }
2178
3349
  const { caGenerated } = ensureCerts(dir);
2179
3350
  if (caGenerated) {
@@ -2246,8 +3417,8 @@ ${colors_default.bold("Options:")}
2246
3417
  await stopProxy(store, port, tls2);
2247
3418
  const stateDirs = collectStateDirsForCleanup();
2248
3419
  for (const stateDir of stateDirs) {
2249
- const caPath = path4.join(stateDir, "ca.pem");
2250
- if (!fs4.existsSync(caPath)) continue;
3420
+ const caPath = path8.join(stateDir, "ca.pem");
3421
+ if (!fs8.existsSync(caPath)) continue;
2251
3422
  const wasTrusted = isCATrusted(stateDir);
2252
3423
  if (!wasTrusted) continue;
2253
3424
  const untrustResult = untrustCA(stateDir);
@@ -2797,8 +3968,8 @@ ${colors_default.bold("LAN mode (--lan):")}
2797
3968
  console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
2798
3969
  } else {
2799
3970
  console.error(colors_default.red("Proxy process started but is not responding."));
2800
- const logPath2 = path4.join(resolveStateDir(proxyPort), "proxy.log");
2801
- if (fs4.existsSync(logPath2)) {
3971
+ const logPath2 = path8.join(resolveStateDir(proxyPort), "proxy.log");
3972
+ if (fs8.existsSync(logPath2)) {
2802
3973
  console.error(colors_default.gray(`Logs: ${logPath2}`));
2803
3974
  }
2804
3975
  }
@@ -2837,8 +4008,8 @@ ${colors_default.bold("LAN mode (--lan):")}
2837
4008
  store.ensureDir();
2838
4009
  if (customCertPath && customKeyPath) {
2839
4010
  try {
2840
- const cert = fs4.readFileSync(customCertPath);
2841
- const key = fs4.readFileSync(customKeyPath);
4011
+ const cert = fs8.readFileSync(customCertPath);
4012
+ const key = fs8.readFileSync(customKeyPath);
2842
4013
  const certStr = cert.toString("utf-8");
2843
4014
  const keyStr = key.toString("utf-8");
2844
4015
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -2883,9 +4054,9 @@ ${colors_default.bold("LAN mode (--lan):")}
2883
4054
  console.warn(colors_default.cyan(" portless trust"));
2884
4055
  }
2885
4056
  }
2886
- const cert = fs4.readFileSync(certs.certPath);
2887
- const key = fs4.readFileSync(certs.keyPath);
2888
- const ca = fs4.readFileSync(certs.caPath);
4057
+ const cert = fs8.readFileSync(certs.certPath);
4058
+ const key = fs8.readFileSync(certs.keyPath);
4059
+ const ca = fs8.readFileSync(certs.caPath);
2889
4060
  tlsOptions = {
2890
4061
  cert,
2891
4062
  key,
@@ -2900,11 +4071,11 @@ ${colors_default.bold("LAN mode (--lan):")}
2900
4071
  return;
2901
4072
  }
2902
4073
  store.ensureDir();
2903
- const logPath = path4.join(stateDir, "proxy.log");
2904
- const logFd = fs4.openSync(logPath, "a");
4074
+ const logPath = path8.join(stateDir, "proxy.log");
4075
+ const logFd = fs8.openSync(logPath, "a");
2905
4076
  try {
2906
4077
  try {
2907
- fs4.chmodSync(logPath, FILE_MODE);
4078
+ fs8.chmodSync(logPath, FILE_MODE);
2908
4079
  } catch {
2909
4080
  }
2910
4081
  fixOwnership(logPath);
@@ -2927,7 +4098,7 @@ ${colors_default.bold("LAN mode (--lan):")}
2927
4098
  skipTrust: true
2928
4099
  }).args
2929
4100
  ];
2930
- const child = spawn2(process.execPath, daemonArgs, {
4101
+ const child = spawn3(process.execPath, daemonArgs, {
2931
4102
  detached: true,
2932
4103
  stdio: ["ignore", logFd, logFd],
2933
4104
  env: process.env,
@@ -2935,13 +4106,13 @@ ${colors_default.bold("LAN mode (--lan):")}
2935
4106
  });
2936
4107
  child.unref();
2937
4108
  } finally {
2938
- fs4.closeSync(logFd);
4109
+ fs8.closeSync(logFd);
2939
4110
  }
2940
4111
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
2941
4112
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
2942
4113
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
2943
4114
  console.error(colors_default.cyan(" portless proxy start --foreground"));
2944
- if (fs4.existsSync(logPath)) {
4115
+ if (fs8.existsSync(logPath)) {
2945
4116
  console.error(colors_default.gray(`Logs: ${logPath}`));
2946
4117
  }
2947
4118
  process.exit(1);
@@ -2953,8 +4124,488 @@ ${colors_default.bold("LAN mode (--lan):")}
2953
4124
  console.log(chalk.gray("Services will be discoverable as <name>.local on your network."));
2954
4125
  }
2955
4126
  }
2956
- async function handleRunMode(args) {
4127
+ function loadAppConfig(cwd = process.cwd()) {
4128
+ try {
4129
+ const loaded = loadConfig(cwd);
4130
+ if (!loaded) return null;
4131
+ return resolveAppConfig(loaded.config, loaded.configDir, cwd);
4132
+ } catch (err) {
4133
+ if (err instanceof ConfigValidationError) {
4134
+ console.error(colors_default.red(`Error: ${err.message}`));
4135
+ process.exit(1);
4136
+ }
4137
+ throw err;
4138
+ }
4139
+ }
4140
+ async function handleDefaultMode(globalScript, extraArgs = []) {
4141
+ const cwd = process.cwd();
4142
+ const wsRoot = findWorkspaceRoot(cwd);
4143
+ if (wsRoot === cwd) {
4144
+ const packages = discoverWorkspacePackages(cwd);
4145
+ let wsScriptName;
4146
+ try {
4147
+ wsScriptName = globalScript ?? loadConfig(cwd)?.config.script ?? "dev";
4148
+ } catch (err) {
4149
+ if (err instanceof ConfigValidationError) {
4150
+ console.error(colors_default.red(`Error: ${err.message}`));
4151
+ process.exit(1);
4152
+ }
4153
+ throw err;
4154
+ }
4155
+ const hasMatchingPackages = packages.some((p) => p.scripts[wsScriptName]);
4156
+ if (hasMatchingPackages) {
4157
+ await handleDefaultMulti(cwd, globalScript, extraArgs);
4158
+ return true;
4159
+ }
4160
+ }
4161
+ const appConfig = loadAppConfig(cwd);
4162
+ const scriptName = globalScript ?? appConfig?.script ?? "dev";
4163
+ if (hasScript(scriptName, cwd)) {
4164
+ await handleDefaultSingle(cwd, scriptName, appConfig);
4165
+ return true;
4166
+ }
4167
+ return false;
4168
+ }
4169
+ async function handleDefaultSingle(cwd, scriptName, appConfig) {
4170
+ const resolved = resolveScriptCommand(scriptName, cwd);
4171
+ if (!resolved) {
4172
+ console.error(colors_default.red(`Error: No "${scriptName}" script found in package.json.`));
4173
+ process.exit(1);
4174
+ }
4175
+ let baseName;
4176
+ let nameSource;
4177
+ if (appConfig?.name) {
4178
+ baseName = appConfig.name.split(".").map((label) => truncateLabel(label)).join(".");
4179
+ nameSource = "portless.json";
4180
+ } else {
4181
+ const inferred = inferProjectName(cwd);
4182
+ baseName = inferred.name;
4183
+ nameSource = inferred.source;
4184
+ }
4185
+ const worktree = detectWorktreePrefix(cwd);
4186
+ const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
4187
+ const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
4188
+ const store = new RouteStore(dir, {
4189
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
4190
+ });
4191
+ await runApp(
4192
+ store,
4193
+ port,
4194
+ dir,
4195
+ effectiveName,
4196
+ resolved,
4197
+ tls2,
4198
+ tld,
4199
+ false,
4200
+ { nameSource, prefix: worktree?.prefix, prefixSource: worktree?.source },
4201
+ appConfig?.appPort,
4202
+ lanMode,
4203
+ lanIp
4204
+ );
4205
+ }
4206
+ function spawnChildProcess(commandArgs, env, cwd) {
4207
+ return spawn3(commandArgs[0], commandArgs.slice(1), {
4208
+ stdio: ["ignore", "pipe", "pipe"],
4209
+ env,
4210
+ cwd
4211
+ });
4212
+ }
4213
+ function prefixStream(stream, output, prefix) {
4214
+ if (!stream) return;
4215
+ const decoder = new StringDecoder("utf8");
4216
+ let buffer = "";
4217
+ stream.on("data", (data) => {
4218
+ buffer += decoder.write(data);
4219
+ let idx;
4220
+ while ((idx = buffer.indexOf("\n")) !== -1) {
4221
+ const line = buffer.slice(0, idx).replace(/\r$/, "");
4222
+ buffer = buffer.slice(idx + 1);
4223
+ output.write(`${prefix} ${line}
4224
+ `);
4225
+ }
4226
+ });
4227
+ stream.on("end", () => {
4228
+ buffer += decoder.end();
4229
+ if (buffer) output.write(`${prefix} ${buffer}
4230
+ `);
4231
+ });
4232
+ }
4233
+ function pipeOutput(child, prefix) {
4234
+ prefixStream(child.stdout, process.stdout, prefix);
4235
+ prefixStream(child.stderr, process.stderr, prefix);
4236
+ }
4237
+ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
4238
+ const usesPortless = app.commandArgs[0] === "portless";
4239
+ const pkgEnv = { ...process.env };
4240
+ pkgEnv.PATH = augmentedPath(pkgEnv, app.pkg.dir);
4241
+ let env;
4242
+ let store = null;
4243
+ let hostname = null;
4244
+ let displayUrl;
4245
+ if (usesPortless) {
4246
+ env = pkgEnv;
4247
+ displayUrl = "(managed by portless)";
4248
+ } else {
4249
+ store = new RouteStore(stateDir, {
4250
+ onWarning: (msg) => console.warn(colors_default.yellow(`[${app.name}] ${msg}`))
4251
+ });
4252
+ const appPort = app.appPort ?? await findFreePort();
4253
+ const protocol = tls2 ? "https" : "http";
4254
+ const portSuffix = tls2 && proxyPort === 443 || !tls2 && proxyPort === 80 ? "" : `:${proxyPort}`;
4255
+ const url = `${protocol}://${app.name}.${tld}${portSuffix}`;
4256
+ displayUrl = url;
4257
+ hostname = parseHostname(app.name, tld);
4258
+ store.addRoute(hostname, appPort, process.pid);
4259
+ env = {
4260
+ ...pkgEnv,
4261
+ PORT: String(appPort),
4262
+ HOST: "127.0.0.1",
4263
+ PORTLESS_URL: url
4264
+ };
4265
+ if (tls2) {
4266
+ const caPath = path8.join(stateDir, "ca.pem");
4267
+ if (fs8.existsSync(caPath)) {
4268
+ env.NODE_EXTRA_CA_CERTS = caPath;
4269
+ }
4270
+ }
4271
+ }
4272
+ const child = spawnChildProcess(app.commandArgs, env, app.pkg.dir);
4273
+ pipeOutput(child, chalk.cyan(`[${app.name}]`));
4274
+ const capturedStore = store;
4275
+ const capturedHostname = hostname;
4276
+ child.on("exit", (code, signal) => {
4277
+ exitCodes.set(app.name, code);
4278
+ if (code !== 0 && code !== null) {
4279
+ console.error(colors_default.red(`[${app.name}] exited with code ${code}`));
4280
+ } else if (signal) {
4281
+ console.error(colors_default.yellow(`[${app.name}] killed by ${signal}`));
4282
+ }
4283
+ if (capturedStore && capturedHostname) {
4284
+ try {
4285
+ capturedStore.removeRoute(capturedHostname);
4286
+ } catch {
4287
+ }
4288
+ }
4289
+ });
4290
+ const route = store && hostname ? { store, hostname } : null;
4291
+ return { child, displayUrl, route };
4292
+ }
4293
+ function spawnTaskApp(app, exitCodes) {
4294
+ const pkgEnv = { ...process.env };
4295
+ pkgEnv.PATH = augmentedPath(pkgEnv, app.pkg.dir);
4296
+ const child = spawnChildProcess(app.commandArgs, pkgEnv, app.pkg.dir);
4297
+ pipeOutput(child, chalk.gray(`[${app.name}]`));
4298
+ child.on("exit", (code, signal) => {
4299
+ exitCodes.set(app.name, code);
4300
+ if (code !== 0 && code !== null) {
4301
+ console.error(colors_default.red(`[${app.name}] exited with code ${code}`));
4302
+ } else if (signal) {
4303
+ console.error(colors_default.yellow(`[${app.name}] killed by ${signal}`));
4304
+ }
4305
+ });
4306
+ return child;
4307
+ }
4308
+ async function handleDefaultMulti(wsRoot, globalScript, extraArgs = []) {
4309
+ let loaded;
4310
+ try {
4311
+ loaded = loadConfig(wsRoot);
4312
+ } catch (err) {
4313
+ if (err instanceof ConfigValidationError) {
4314
+ console.error(colors_default.red(`Error: ${err.message}`));
4315
+ process.exit(1);
4316
+ }
4317
+ throw err;
4318
+ }
4319
+ const packages = discoverWorkspacePackages(wsRoot);
4320
+ if (packages.length === 0) {
4321
+ console.error(colors_default.red("Error: No workspace packages found."));
4322
+ process.exit(1);
4323
+ }
4324
+ const scriptName = globalScript ?? loaded?.config.script ?? "dev";
4325
+ let projectName;
4326
+ if (loaded?.config.name) {
4327
+ projectName = loaded.config.name.split(".").map((label) => truncateLabel(label)).join(".");
4328
+ } else {
4329
+ const scopeCounts = /* @__PURE__ */ new Map();
4330
+ for (const p of packages) {
4331
+ if (p.scope) scopeCounts.set(p.scope, (scopeCounts.get(p.scope) ?? 0) + 1);
4332
+ }
4333
+ let commonScope;
4334
+ let maxCount = 0;
4335
+ for (const [scope, count] of scopeCounts) {
4336
+ if (count > maxCount) {
4337
+ commonScope = scope;
4338
+ maxCount = count;
4339
+ }
4340
+ }
4341
+ if (commonScope) {
4342
+ projectName = sanitizeForHostname(commonScope) || inferProjectName(wsRoot).name;
4343
+ } else {
4344
+ projectName = inferProjectName(wsRoot).name;
4345
+ }
4346
+ }
4347
+ const apps = [];
4348
+ for (const pkg of packages) {
4349
+ const rel = path8.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
4350
+ const rootOverride = loaded ? resolveAppConfig(loaded.config, loaded.configDir, pkg.dir) : null;
4351
+ let pkgConfig;
4352
+ try {
4353
+ pkgConfig = loadPackagePortlessConfig(pkg.dir);
4354
+ } catch (err) {
4355
+ if (err instanceof ConfigValidationError) {
4356
+ console.error(colors_default.red(`Error: ${err.message}`));
4357
+ process.exit(1);
4358
+ }
4359
+ throw err;
4360
+ }
4361
+ const appOverride = {
4362
+ ...Object.fromEntries(Object.entries(rootOverride ?? {}).filter(([, v]) => v !== void 0)),
4363
+ ...Object.fromEntries(Object.entries(pkgConfig ?? {}).filter(([, v]) => v !== void 0))
4364
+ };
4365
+ const effectiveScript = appOverride.script ?? scriptName;
4366
+ const scriptValue = pkg.scripts[effectiveScript];
4367
+ if (!scriptValue) continue;
4368
+ const rawScript = splitCommand(scriptValue);
4369
+ if (rawScript.length === 0) continue;
4370
+ const pm = detectPackageManager(pkg.dir);
4371
+ const commandArgs = [pm, "run", effectiveScript];
4372
+ const proxied = appOverride.proxy ?? isServerCommand(rawScript);
4373
+ let name;
4374
+ let label;
4375
+ if (appOverride.name) {
4376
+ name = appOverride.name.split(".").map((l) => truncateLabel(l)).join(".");
4377
+ label = appOverride.name;
4378
+ } else {
4379
+ let pkgLabel;
4380
+ if (pkg.name) {
4381
+ const sanitized = sanitizeForHostname(pkg.name);
4382
+ pkgLabel = sanitized || rel.replace(/\//g, "-");
4383
+ } else {
4384
+ pkgLabel = rel.replace(/\//g, "-");
4385
+ }
4386
+ name = pkgLabel === projectName ? projectName : `${pkgLabel}.${projectName}`;
4387
+ label = pkg.scope ? `@${pkg.scope}/${pkg.name}` : pkg.name ?? rel;
4388
+ }
4389
+ apps.push({ pkg, name, label, commandArgs, appPort: appOverride.appPort, proxied });
4390
+ }
4391
+ if (apps.length === 0) {
4392
+ console.error(colors_default.yellow(`No workspace packages have a "${scriptName}" script.`));
4393
+ process.exit(1);
4394
+ }
4395
+ apps.sort((a, b) => a.label.localeCompare(b.label));
4396
+ const proxiedApps = apps.filter((a) => a.proxied);
4397
+ const taskApps = apps.filter((a) => !a.proxied);
4398
+ console.log(chalk.blue.bold(`
4399
+ portless
4400
+ `));
4401
+ let { dir, port, tls: tls2, tld } = await discoverState();
4402
+ if (proxiedApps.length > 0) {
4403
+ let multiDesired;
4404
+ try {
4405
+ multiDesired = resolveProxyDesiredState(false);
4406
+ } catch (err) {
4407
+ console.error(colors_default.red(`Error: ${err.message}`));
4408
+ process.exit(1);
4409
+ }
4410
+ const ensureResult = await ensureProxyRunning(port, tls2, multiDesired);
4411
+ if (ensureResult.started) {
4412
+ dir = ensureResult.state.dir;
4413
+ port = ensureResult.state.port;
4414
+ tls2 = ensureResult.state.tls;
4415
+ tld = ensureResult.state.tld;
4416
+ } else {
4417
+ ({ dir, port, tls: tls2, tld } = await discoverState());
4418
+ }
4419
+ if (tls2 && !isCATrusted(dir)) {
4420
+ await handleTrust();
4421
+ }
4422
+ }
4423
+ const useTurbo = loaded?.config.turbo !== false && hasTurboConfig(wsRoot);
4424
+ if (useTurbo) {
4425
+ await runWithTurbo(wsRoot, dir, port, tls2, tld, scriptName, proxiedApps, taskApps, extraArgs);
4426
+ } else {
4427
+ await runWithDirectSpawn(dir, port, tls2, tld, proxiedApps, taskApps);
4428
+ }
4429
+ }
4430
+ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName, proxiedApps, taskApps, extraArgs = []) {
4431
+ const store = new RouteStore(stateDir, {
4432
+ onWarning: (msg) => console.warn(colors_default.yellow(msg))
4433
+ });
4434
+ const manifest = {};
4435
+ const routes = [];
4436
+ const appUrls = [];
4437
+ for (const app of proxiedApps) {
4438
+ const usesPortless = app.commandArgs[0] === "portless";
4439
+ if (usesPortless) {
4440
+ appUrls.push({ label: app.label, url: "(managed by portless)" });
4441
+ continue;
4442
+ }
4443
+ const appPort = app.appPort ?? await findFreePort();
4444
+ const protocol = tls2 ? "https" : "http";
4445
+ const portSuffix = tls2 && proxyPort === 443 || !tls2 && proxyPort === 80 ? "" : `:${proxyPort}`;
4446
+ const url = `${protocol}://${app.name}.${tld}${portSuffix}`;
4447
+ appUrls.push({ label: app.label, url });
4448
+ const hostname = parseHostname(app.name, tld);
4449
+ store.addRoute(hostname, appPort, process.pid);
4450
+ routes.push({ hostname });
4451
+ const entry = {
4452
+ PORT: String(appPort),
4453
+ HOST: "127.0.0.1",
4454
+ PORTLESS_URL: url
4455
+ };
4456
+ if (tls2) {
4457
+ const caPath = path8.join(stateDir, "ca.pem");
4458
+ if (fs8.existsSync(caPath)) {
4459
+ entry.NODE_EXTRA_CA_CERTS = caPath;
4460
+ }
4461
+ }
4462
+ manifest[app.pkg.dir] = entry;
4463
+ }
4464
+ ensureEnvLoader();
4465
+ writeManifest(manifest);
4466
+ if (appUrls.length > 0) {
4467
+ const maxLabel = Math.max(...appUrls.map((a) => a.label.length));
4468
+ for (const { label, url } of appUrls) {
4469
+ const pad = " ".repeat(maxLabel - label.length);
4470
+ console.log(` ${label}${pad} ${chalk.dim(url)}`);
4471
+ }
4472
+ }
4473
+ console.log("");
4474
+ const pm = detectPackageManager(wsRoot);
4475
+ const useRootScript = hasScript(scriptName, wsRoot);
4476
+ 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];
4477
+ const turboChild = spawn3(turboArgs[0], turboArgs.slice(1), {
4478
+ stdio: "inherit",
4479
+ cwd: wsRoot,
4480
+ env: {
4481
+ ...process.env,
4482
+ NODE_OPTIONS: buildNodeOptions()
4483
+ }
4484
+ });
4485
+ const SIGKILL_TIMEOUT_MS = 5e3;
4486
+ let cleanedUp = false;
4487
+ const cleanup = () => {
4488
+ if (cleanedUp) return;
4489
+ cleanedUp = true;
4490
+ try {
4491
+ turboChild.kill("SIGTERM");
4492
+ } catch {
4493
+ }
4494
+ setTimeout(() => {
4495
+ if (turboChild.exitCode === null && !turboChild.killed) {
4496
+ try {
4497
+ turboChild.kill("SIGKILL");
4498
+ } catch {
4499
+ }
4500
+ }
4501
+ }, SIGKILL_TIMEOUT_MS).unref();
4502
+ for (const { hostname } of routes) {
4503
+ try {
4504
+ store.removeRoute(hostname);
4505
+ } catch {
4506
+ }
4507
+ }
4508
+ removeManifest();
4509
+ };
4510
+ process.on("SIGINT", cleanup);
4511
+ process.on("SIGTERM", cleanup);
4512
+ const exitCode = await new Promise((resolve3) => {
4513
+ turboChild.on("exit", (code) => resolve3(code));
4514
+ });
4515
+ cleanup();
4516
+ if (exitCode !== 0 && exitCode !== null) {
4517
+ process.exit(exitCode);
4518
+ }
4519
+ }
4520
+ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, taskApps) {
4521
+ const children = [];
4522
+ const exitCodes = /* @__PURE__ */ new Map();
4523
+ const appUrls = [];
4524
+ const routeEntries = [];
4525
+ for (const app of proxiedApps) {
4526
+ const { child, displayUrl, route } = await spawnProxiedApp(
4527
+ app,
4528
+ stateDir,
4529
+ proxyPort,
4530
+ tls2,
4531
+ tld,
4532
+ exitCodes
4533
+ );
4534
+ children.push(child);
4535
+ if (route) routeEntries.push(route);
4536
+ appUrls.push({ label: app.label, url: displayUrl });
4537
+ }
4538
+ const taskLabels = [];
4539
+ for (const app of taskApps) {
4540
+ children.push(spawnTaskApp(app, exitCodes));
4541
+ taskLabels.push(app.label);
4542
+ }
4543
+ if (appUrls.length > 0) {
4544
+ const maxLabel = Math.max(...appUrls.map((a) => a.label.length));
4545
+ for (const { label, url } of appUrls) {
4546
+ const pad = " ".repeat(maxLabel - label.length);
4547
+ console.log(` ${label}${pad} ${chalk.dim(url)}`);
4548
+ }
4549
+ }
4550
+ console.log("");
4551
+ const SIGKILL_TIMEOUT_MS = 5e3;
4552
+ let cleanedUp = false;
4553
+ const cleanup = () => {
4554
+ if (cleanedUp) return;
4555
+ cleanedUp = true;
4556
+ for (const child of children) {
4557
+ try {
4558
+ child.kill("SIGTERM");
4559
+ } catch {
4560
+ }
4561
+ }
4562
+ setTimeout(() => {
4563
+ for (const child of children) {
4564
+ if (child.exitCode === null && !child.killed) {
4565
+ try {
4566
+ child.kill("SIGKILL");
4567
+ } catch {
4568
+ }
4569
+ }
4570
+ }
4571
+ }, SIGKILL_TIMEOUT_MS).unref();
4572
+ for (const { store, hostname } of routeEntries) {
4573
+ try {
4574
+ store.removeRoute(hostname);
4575
+ } catch {
4576
+ }
4577
+ }
4578
+ };
4579
+ process.on("SIGINT", cleanup);
4580
+ process.on("SIGTERM", cleanup);
4581
+ await Promise.all(
4582
+ children.map(
4583
+ (child) => new Promise((resolve3) => {
4584
+ child.on("exit", () => resolve3());
4585
+ })
4586
+ )
4587
+ );
4588
+ const failed = [...exitCodes.entries()].filter(([, code]) => code !== 0 && code !== null);
4589
+ if (failed.length > 0) {
4590
+ console.error(
4591
+ colors_default.red(
4592
+ `
4593
+ ${failed.length} app${failed.length === 1 ? "" : "s"} exited with errors: ${failed.map(([name, code]) => `${name} (${code})`).join(", ")}`
4594
+ )
4595
+ );
4596
+ process.exit(1);
4597
+ }
4598
+ }
4599
+ async function handleRunMode(args, globalScript) {
2957
4600
  const parsed = parseRunArgs(args);
4601
+ const appConfig = loadAppConfig();
4602
+ if (parsed.commandArgs.length === 0) {
4603
+ const scriptName = globalScript ?? appConfig?.script ?? "dev";
4604
+ const resolved = resolveScriptCommand(scriptName, process.cwd());
4605
+ if (resolved) {
4606
+ parsed.commandArgs = resolved;
4607
+ }
4608
+ }
2958
4609
  if (parsed.commandArgs.length === 0) {
2959
4610
  console.error(colors_default.red("Error: No command provided."));
2960
4611
  console.error(colors_default.blue("Usage:"));
@@ -2968,11 +4619,17 @@ async function handleRunMode(args) {
2968
4619
  if (parsed.name) {
2969
4620
  baseName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
2970
4621
  nameSource = "--name flag";
4622
+ } else if (appConfig?.name) {
4623
+ baseName = appConfig.name.split(".").map((label) => truncateLabel(label)).join(".");
4624
+ nameSource = "portless.json";
2971
4625
  } else {
2972
4626
  const inferred = inferProjectName();
2973
4627
  baseName = inferred.name;
2974
4628
  nameSource = inferred.source;
2975
4629
  }
4630
+ if (!parsed.appPort && appConfig?.appPort) {
4631
+ parsed.appPort = appConfig.appPort;
4632
+ }
2976
4633
  const worktree = detectWorktreePrefix();
2977
4634
  const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
2978
4635
  const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
@@ -3004,6 +4661,12 @@ async function handleNamedMode(args) {
3004
4661
  console.error(colors_default.cyan(" portless myapp next dev"));
3005
4662
  process.exit(1);
3006
4663
  }
4664
+ if (!parsed.appPort) {
4665
+ const appConfig = loadAppConfig();
4666
+ if (appConfig?.appPort) {
4667
+ parsed.appPort = appConfig.appPort;
4668
+ }
4669
+ }
3007
4670
  const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
3008
4671
  const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
3009
4672
  const store = new RouteStore(dir, {
@@ -3077,6 +4740,13 @@ async function main() {
3077
4740
  process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
3078
4741
  process.env.PORTLESS_LAN = "1";
3079
4742
  }
4743
+ const scriptResult = stripGlobalFlag("--script", true);
4744
+ if (scriptResult === false) {
4745
+ console.error(colors_default.red("Error: --script requires a script name."));
4746
+ console.error(colors_default.cyan(" portless --script start"));
4747
+ process.exit(1);
4748
+ }
4749
+ const globalScript = typeof scriptResult === "string" ? scriptResult : void 0;
3080
4750
  if (args[0] === "--name") {
3081
4751
  args.shift();
3082
4752
  if (!args[0]) {
@@ -3102,8 +4772,15 @@ async function main() {
3102
4772
  args.shift();
3103
4773
  }
3104
4774
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
3105
- if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
3106
- const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
4775
+ if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
4776
+ const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
4777
+ let commandArgs = parsed.commandArgs;
4778
+ if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
4779
+ const appConfig = loadAppConfig();
4780
+ const scriptName = globalScript ?? appConfig?.script ?? "dev";
4781
+ const resolved = resolveScriptCommand(scriptName, process.cwd());
4782
+ if (resolved) commandArgs = resolved;
4783
+ }
3107
4784
  if (commandArgs.length === 0) {
3108
4785
  console.error(colors_default.red("Error: No command provided."));
3109
4786
  process.exit(1);
@@ -3112,7 +4789,14 @@ async function main() {
3112
4789
  return;
3113
4790
  }
3114
4791
  if (!isRunCommand) {
3115
- if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
4792
+ if (args[0] === "--help" || args[0] === "-h") {
4793
+ printHelp();
4794
+ return;
4795
+ }
4796
+ if (args.length === 0 || args[0] === "--") {
4797
+ const extraArgs = args[0] === "--" ? args.slice(1) : [];
4798
+ const handled = await handleDefaultMode(globalScript, extraArgs);
4799
+ if (handled) return;
3116
4800
  printHelp();
3117
4801
  return;
3118
4802
  }
@@ -3150,7 +4834,7 @@ async function main() {
3150
4834
  }
3151
4835
  }
3152
4836
  if (isRunCommand) {
3153
- await handleRunMode(args);
4837
+ await handleRunMode(args, globalScript);
3154
4838
  } else {
3155
4839
  await handleNamedMode(args);
3156
4840
  }