portless 0.15.2 → 0.15.4

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
@@ -16,9 +16,10 @@ import {
16
16
  isProcessAlive,
17
17
  parseHostname,
18
18
  parseHostnames,
19
+ resolveUserHome,
19
20
  shouldAutoSyncHosts,
20
21
  syncHostsFile
21
- } from "./chunk-T7CGCIRO.js";
22
+ } from "./chunk-JSJUKQRJ.js";
22
23
 
23
24
  // src/colors.ts
24
25
  function supportsColor() {
@@ -44,18 +45,96 @@ var gray = dim;
44
45
  var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
45
46
 
46
47
  // src/cli.ts
47
- import * as fs9 from "fs";
48
+ import * as fs10 from "fs";
48
49
  import * as path9 from "path";
49
50
  import { spawn as spawn4, spawnSync as spawnSync5 } from "child_process";
50
51
  import { StringDecoder } from "string_decoder";
51
52
 
52
53
  // src/certs.ts
53
- import * as fs from "fs";
54
+ import * as fs2 from "fs";
54
55
  import * as path from "path";
55
- import * as crypto from "crypto";
56
+ import * as crypto2 from "crypto";
56
57
  import * as tls from "tls";
57
- import { execFile as execFileCb, execFileSync } from "child_process";
58
+ import { execFile as execFileCb, execFileSync as execFileSync2 } from "child_process";
58
59
  import { promisify } from "util";
60
+
61
+ // src/windows-ca.ts
62
+ import * as crypto from "crypto";
63
+ import * as fs from "fs";
64
+ import * as os from "os";
65
+ import { execFileSync } from "child_process";
66
+ var WINDOWS_COMMAND_TIMEOUT_MS = 3e4;
67
+ var commandOptions = {
68
+ encoding: "utf-8",
69
+ timeout: WINDOWS_COMMAND_TIMEOUT_MS,
70
+ stdio: ["pipe", "pipe", "pipe"]
71
+ };
72
+ var defaultRunner = (command, args, options) => execFileSync(command, args, options);
73
+ function isWSL(options = {}) {
74
+ const platform = options.platform ?? process.platform;
75
+ if (platform !== "linux") return false;
76
+ const env = options.env ?? process.env;
77
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
78
+ const release2 = options.release ?? os.release();
79
+ return release2.toLowerCase().includes("microsoft");
80
+ }
81
+ function wslWindowsCAStoreOptions(run = defaultRunner) {
82
+ const command = run(
83
+ "wslpath",
84
+ ["-u", String.raw`C:\Windows\System32\certutil.exe`],
85
+ commandOptions
86
+ ).trim();
87
+ return {
88
+ command,
89
+ certificatePath: (certificatePath) => run("wslpath", ["-w", certificatePath], commandOptions).trim(),
90
+ run
91
+ };
92
+ }
93
+ function certificateFingerprint(certificatePath) {
94
+ const certificate = new crypto.X509Certificate(fs.readFileSync(certificatePath));
95
+ return certificate.fingerprint.replace(/:/g, "").toLowerCase();
96
+ }
97
+ function storeOptions(options) {
98
+ return {
99
+ command: options.command ?? "certutil",
100
+ certificatePath: options.certificatePath ?? ((certificatePath) => certificatePath),
101
+ run: options.run ?? defaultRunner
102
+ };
103
+ }
104
+ function storeContainsFingerprint(fingerprint, resolved) {
105
+ const listing = resolved.run(resolved.command, ["-store", "-user", "Root"], commandOptions);
106
+ return listing.replace(/\s/g, "").toLowerCase().includes(fingerprint);
107
+ }
108
+ function isWindowsCATrusted(caCertPath, options = {}) {
109
+ try {
110
+ const resolved = storeOptions(options);
111
+ const fingerprint = certificateFingerprint(caCertPath);
112
+ return storeContainsFingerprint(fingerprint, resolved);
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+ function trustWindowsCA(caCertPath, options = {}) {
118
+ const resolved = storeOptions(options);
119
+ resolved.run(
120
+ resolved.command,
121
+ ["-addstore", "-user", "Root", resolved.certificatePath(caCertPath)],
122
+ commandOptions
123
+ );
124
+ }
125
+ function untrustWindowsCA(caCertPath, options = {}) {
126
+ try {
127
+ const resolved = storeOptions(options);
128
+ const fingerprint = certificateFingerprint(caCertPath);
129
+ if (!storeContainsFingerprint(fingerprint, resolved)) return { removed: true };
130
+ resolved.run(resolved.command, ["-delstore", "-user", "Root", fingerprint], commandOptions);
131
+ return storeContainsFingerprint(fingerprint, resolved) ? { removed: false, error: "certutil could not remove the portless CA from Root" } : { removed: true };
132
+ } catch (error) {
133
+ return { removed: false, error: error instanceof Error ? error.message : String(error) };
134
+ }
135
+ }
136
+
137
+ // src/certs.ts
59
138
  var CA_VALIDITY_DAYS = 3650;
60
139
  var SERVER_VALIDITY_DAYS = 365;
61
140
  var EXPIRY_BUFFER_MS = 7 * 24 * 60 * 60 * 1e3;
@@ -69,9 +148,10 @@ var CA_CERT_FILE = "ca.pem";
69
148
  var SERVER_KEY_FILE = "server-key.pem";
70
149
  var SERVER_CERT_FILE = "server.pem";
71
150
  var CA_TRUST_MARKER = "ca.trusted";
151
+ var CA_TRUST_REFRESH_PENDING = "ca.trust-refresh-pending";
72
152
  function fileExists(filePath) {
73
153
  try {
74
- fs.accessSync(filePath, fs.constants.R_OK);
154
+ fs2.accessSync(filePath, fs2.constants.R_OK);
75
155
  return true;
76
156
  } catch {
77
157
  return false;
@@ -80,30 +160,38 @@ function fileExists(filePath) {
80
160
  function caFingerprint(stateDir) {
81
161
  const caCertPath = path.join(stateDir, CA_CERT_FILE);
82
162
  try {
83
- const pem = fs.readFileSync(caCertPath);
84
- return crypto.createHash("sha256").update(pem).digest("hex");
163
+ const pem = fs2.readFileSync(caCertPath);
164
+ return crypto2.createHash("sha256").update(pem).digest("hex");
85
165
  } catch {
86
166
  return null;
87
167
  }
88
168
  }
89
169
  function readTrustMarker(stateDir) {
90
170
  try {
91
- const value = fs.readFileSync(path.join(stateDir, CA_TRUST_MARKER), "utf-8").trim();
171
+ const value = fs2.readFileSync(path.join(stateDir, CA_TRUST_MARKER), "utf-8").trim();
92
172
  return value || null;
93
173
  } catch {
94
174
  return null;
95
175
  }
96
176
  }
177
+ function clearTrustRefreshPending(stateDir) {
178
+ try {
179
+ fs2.unlinkSync(path.join(stateDir, CA_TRUST_REFRESH_PENDING));
180
+ } catch {
181
+ }
182
+ }
97
183
  function writeTrustMarker(stateDir) {
98
184
  const fp = caFingerprint(stateDir);
99
185
  if (fp) {
100
- fs.writeFileSync(path.join(stateDir, CA_TRUST_MARKER), fp + "\n");
186
+ clearTrustRefreshPending(stateDir);
187
+ const marker = isWSL() ? `wsl:${fp}` : fp;
188
+ fs2.writeFileSync(path.join(stateDir, CA_TRUST_MARKER), marker + "\n");
101
189
  fixOwnership(path.join(stateDir, CA_TRUST_MARKER));
102
190
  }
103
191
  }
104
192
  function clearTrustMarker(stateDir) {
105
193
  try {
106
- fs.unlinkSync(path.join(stateDir, CA_TRUST_MARKER));
194
+ fs2.unlinkSync(path.join(stateDir, CA_TRUST_MARKER));
107
195
  } catch {
108
196
  }
109
197
  }
@@ -143,8 +231,8 @@ function opensslErrorMessage() {
143
231
  }
144
232
  function isCertValid(certPath) {
145
233
  try {
146
- const pem = fs.readFileSync(certPath, "utf-8");
147
- const cert = new crypto.X509Certificate(pem);
234
+ const pem = fs2.readFileSync(certPath, "utf-8");
235
+ const cert = new crypto2.X509Certificate(pem);
148
236
  const expiry = new Date(cert.validTo).getTime();
149
237
  return Date.now() + EXPIRY_BUFFER_MS < expiry;
150
238
  } catch {
@@ -173,7 +261,7 @@ function isCertSignatureStrong(certPath) {
173
261
  function openssl(args, options) {
174
262
  try {
175
263
  const extraEnv = getOpensslEnv();
176
- return execFileSync("openssl", args, {
264
+ return execFileSync2("openssl", args, {
177
265
  encoding: "utf-8",
178
266
  timeout: OPENSSL_TIMEOUT_MS,
179
267
  input: options?.input,
@@ -226,8 +314,8 @@ function generateCA(stateDir) {
226
314
  "-addext",
227
315
  "keyUsage=critical,keyCertSign,cRLSign"
228
316
  ]);
229
- fs.chmodSync(keyPath, 384);
230
- fs.chmodSync(certPath, 420);
317
+ fs2.chmodSync(keyPath, 384);
318
+ fs2.chmodSync(certPath, 420);
231
319
  fixOwnership(keyPath, certPath);
232
320
  clearTrustMarker(stateDir);
233
321
  return { certPath, keyPath };
@@ -242,7 +330,7 @@ function generateServerCert(stateDir) {
242
330
  openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", serverKeyPath]);
243
331
  openssl(["req", "-new", "-key", serverKeyPath, "-out", csrPath, "-subj", "/CN=localhost"]);
244
332
  const sans = ["DNS:localhost", "DNS:*.localhost", "DNS:*.local"];
245
- fs.writeFileSync(
333
+ fs2.writeFileSync(
246
334
  extPath,
247
335
  [
248
336
  "authorityKeyIdentifier=keyid,issuer",
@@ -254,9 +342,9 @@ function generateServerCert(stateDir) {
254
342
  );
255
343
  const srlPath = path.join(stateDir, "ca.srl");
256
344
  if (!fileExists(srlPath)) {
257
- fs.writeFileSync(
345
+ fs2.writeFileSync(
258
346
  srlPath,
259
- crypto.randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase() + "\n"
347
+ crypto2.randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase() + "\n"
260
348
  );
261
349
  }
262
350
  openssl([
@@ -280,12 +368,12 @@ function generateServerCert(stateDir) {
280
368
  ]);
281
369
  for (const tmp of [csrPath, extPath]) {
282
370
  try {
283
- fs.unlinkSync(tmp);
371
+ fs2.unlinkSync(tmp);
284
372
  } catch {
285
373
  }
286
374
  }
287
- fs.chmodSync(serverKeyPath, 384);
288
- fs.chmodSync(serverCertPath, 420);
375
+ fs2.chmodSync(serverKeyPath, 384);
376
+ fs2.chmodSync(serverCertPath, 420);
289
377
  fixOwnership(serverKeyPath, serverCertPath);
290
378
  return { certPath: serverCertPath, keyPath: serverKeyPath };
291
379
  }
@@ -316,36 +404,30 @@ function isCATrusted(stateDir) {
316
404
  const marker = readTrustMarker(stateDir);
317
405
  if (marker) {
318
406
  const fp = caFingerprint(stateDir);
319
- if (fp && marker === fp) return true;
407
+ const expected = fp && isWSL() ? `wsl:${fp}` : fp;
408
+ if (expected && marker === expected) return true;
320
409
  }
321
410
  if (process.platform === "darwin") {
322
411
  return isCATrustedMacOS(caCertPath);
323
412
  } else if (process.platform === "linux") {
324
- return isCATrustedLinux(stateDir);
413
+ if (!isCATrustedLinux(stateDir)) return false;
414
+ if (!isWSL()) return true;
415
+ try {
416
+ return isWindowsCATrusted(caCertPath, wslWindowsCAStoreOptions());
417
+ } catch {
418
+ return false;
419
+ }
325
420
  } else if (process.platform === "win32") {
326
- return isCATrustedWindows(caCertPath);
421
+ return isWindowsCATrusted(caCertPath);
327
422
  }
328
423
  return false;
329
424
  }
330
- function isCATrustedWindows(caCertPath) {
331
- try {
332
- const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
333
- const result = execFileSync("certutil", ["-store", "-user", "Root"], {
334
- encoding: "utf-8",
335
- timeout: 1e4,
336
- stdio: ["pipe", "pipe", "pipe"]
337
- });
338
- return result.replace(/\s/g, "").toLowerCase().includes(fingerprint);
339
- } catch {
340
- return false;
341
- }
342
- }
343
425
  function isCATrustedMacOS(caCertPath) {
344
426
  try {
345
427
  const isRoot = (process.getuid?.() ?? -1) === 0;
346
428
  const sudoUser = process.env.SUDO_USER;
347
429
  if (isRoot && sudoUser) {
348
- execFileSync(
430
+ execFileSync2(
349
431
  "sudo",
350
432
  ["-u", sudoUser, "security", "verify-cert", "-c", caCertPath, "-L", "-p", "ssl"],
351
433
  {
@@ -354,7 +436,7 @@ function isCATrustedMacOS(caCertPath) {
354
436
  }
355
437
  );
356
438
  } else {
357
- execFileSync("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
439
+ execFileSync2("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
358
440
  stdio: "pipe",
359
441
  timeout: MACOS_SECURITY_TIMEOUT_MS
360
442
  });
@@ -366,7 +448,7 @@ function isCATrustedMacOS(caCertPath) {
366
448
  }
367
449
  function loginKeychainPath() {
368
450
  try {
369
- const result = execFileSync("security", ["default-keychain"], {
451
+ const result = execFileSync2("security", ["default-keychain"], {
370
452
  encoding: "utf-8",
371
453
  timeout: MACOS_SECURITY_TIMEOUT_MS
372
454
  }).trim();
@@ -397,7 +479,7 @@ var LINUX_CA_TRUST_CONFIGS = {
397
479
  };
398
480
  function detectLinuxDistro() {
399
481
  try {
400
- const osRelease = fs.readFileSync("/etc/os-release", "utf-8").toLowerCase();
482
+ const osRelease = fs2.readFileSync("/etc/os-release", "utf-8").toLowerCase();
401
483
  if (osRelease.includes("arch")) return "arch";
402
484
  if (osRelease.includes("fedora") || osRelease.includes("rhel") || osRelease.includes("centos"))
403
485
  return "fedora";
@@ -407,8 +489,8 @@ function detectLinuxDistro() {
407
489
  }
408
490
  for (const [distro, config] of Object.entries(LINUX_CA_TRUST_CONFIGS)) {
409
491
  try {
410
- execFileSync("which", [config.updateCommand], { stdio: "pipe", timeout: 5e3 });
411
- if (fs.existsSync(path.dirname(config.certDir))) return distro;
492
+ execFileSync2("which", [config.updateCommand], { stdio: "pipe", timeout: 5e3 });
493
+ if (fs2.existsSync(path.dirname(config.certDir))) return distro;
412
494
  } catch {
413
495
  }
414
496
  }
@@ -418,13 +500,12 @@ function getLinuxCATrustConfig() {
418
500
  const distro = detectLinuxDistro();
419
501
  return LINUX_CA_TRUST_CONFIGS[distro ?? "debian"];
420
502
  }
421
- function isCATrustedLinux(stateDir) {
422
- const config = getLinuxCATrustConfig();
503
+ function isCATrustedLinux(stateDir, config = getLinuxCATrustConfig()) {
423
504
  const systemCertPath = path.join(config.certDir, "portless-ca.crt");
424
505
  if (!fileExists(systemCertPath)) return false;
425
506
  try {
426
- const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
427
- const installed = fs.readFileSync(systemCertPath, "utf-8").trim();
507
+ const ours = fs2.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
508
+ const installed = fs2.readFileSync(systemCertPath, "utf-8").trim();
428
509
  return ours === installed;
429
510
  } catch {
430
511
  return false;
@@ -439,8 +520,8 @@ async function generateHostCertAsync(stateDir, hostname) {
439
520
  const caKeyPath = path.join(stateDir, CA_KEY_FILE);
440
521
  const caCertPath = path.join(stateDir, CA_CERT_FILE);
441
522
  const hostDir = path.join(stateDir, HOST_CERTS_DIR);
442
- if (!fs.existsSync(hostDir)) {
443
- await fs.promises.mkdir(hostDir, { recursive: true, mode: 493 });
523
+ if (!fs2.existsSync(hostDir)) {
524
+ await fs2.promises.mkdir(hostDir, { recursive: true, mode: 493 });
444
525
  fixOwnership(hostDir);
445
526
  }
446
527
  const safeName = sanitizeHostForFilename(hostname);
@@ -456,7 +537,7 @@ async function generateHostCertAsync(stateDir, hostname) {
456
537
  if (parts.length >= 2) {
457
538
  sans.push(`DNS:*.${parts.slice(1).join(".")}`);
458
539
  }
459
- await fs.promises.writeFile(
540
+ await fs2.promises.writeFile(
460
541
  extPath,
461
542
  [
462
543
  "authorityKeyIdentifier=keyid,issuer",
@@ -467,10 +548,10 @@ async function generateHostCertAsync(stateDir, hostname) {
467
548
  ].join("\n") + "\n"
468
549
  );
469
550
  const srlPath = path.join(stateDir, "ca.srl");
470
- if (!fs.existsSync(srlPath)) {
471
- await fs.promises.writeFile(
551
+ if (!fs2.existsSync(srlPath)) {
552
+ await fs2.promises.writeFile(
472
553
  srlPath,
473
- crypto.randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase() + "\n"
554
+ crypto2.randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase() + "\n"
474
555
  );
475
556
  }
476
557
  await opensslAsync([
@@ -494,12 +575,12 @@ async function generateHostCertAsync(stateDir, hostname) {
494
575
  ]);
495
576
  for (const tmp of [csrPath, extPath]) {
496
577
  try {
497
- await fs.promises.unlink(tmp);
578
+ await fs2.promises.unlink(tmp);
498
579
  } catch {
499
580
  }
500
581
  }
501
- await fs.promises.chmod(keyPath, 384);
502
- await fs.promises.chmod(certPath, 420);
582
+ await fs2.promises.chmod(keyPath, 384);
583
+ await fs2.promises.chmod(certPath, 420);
503
584
  fixOwnership(keyPath, certPath);
504
585
  return { certPath, keyPath };
505
586
  }
@@ -526,10 +607,10 @@ function createSNICallback(stateDir, defaultCert, defaultKey, tlds = "localhost"
526
607
  const keyPath = path.join(hostDir, `${safeName}-key.pem`);
527
608
  if (fileExists(certPath) && fileExists(keyPath) && isCertValid(certPath) && isCertSignatureStrong(certPath)) {
528
609
  try {
529
- const hostCert = fs.readFileSync(certPath);
610
+ const hostCert = fs2.readFileSync(certPath);
530
611
  const ctx = tls.createSecureContext({
531
612
  cert: caCert ? Buffer.concat([hostCert, caCert]) : hostCert,
532
- key: fs.readFileSync(keyPath)
613
+ key: fs2.readFileSync(keyPath)
533
614
  });
534
615
  cache.set(servername, ctx);
535
616
  cb(null, ctx);
@@ -543,8 +624,8 @@ function createSNICallback(stateDir, defaultCert, defaultKey, tlds = "localhost"
543
624
  }
544
625
  const promise = generateHostCertAsync(stateDir, servername).then(async (generated) => {
545
626
  const [hostCert, key] = await Promise.all([
546
- fs.promises.readFile(generated.certPath),
547
- fs.promises.readFile(generated.keyPath)
627
+ fs2.promises.readFile(generated.certPath),
628
+ fs2.promises.readFile(generated.keyPath)
548
629
  ]);
549
630
  return tls.createSecureContext({
550
631
  cert: caCert ? Buffer.concat([hostCert, caCert]) : hostCert,
@@ -574,7 +655,7 @@ function trustCA(stateDir) {
574
655
  if (process.platform === "darwin") {
575
656
  const isRoot = (process.getuid?.() ?? -1) === 0;
576
657
  if (isRoot) {
577
- execFileSync(
658
+ execFileSync2(
578
659
  "security",
579
660
  [
580
661
  "add-trusted-cert",
@@ -589,7 +670,7 @@ function trustCA(stateDir) {
589
670
  );
590
671
  } else {
591
672
  const keychain = loginKeychainPath();
592
- execFileSync(
673
+ execFileSync2(
593
674
  "security",
594
675
  ["add-trusted-cert", "-r", "trustRoot", "-k", keychain, caCertPath],
595
676
  { stdio: "pipe", timeout: MACOS_SECURITY_AUTH_TIMEOUT_MS }
@@ -599,19 +680,19 @@ function trustCA(stateDir) {
599
680
  return { trusted: true };
600
681
  } else if (process.platform === "linux") {
601
682
  const config = getLinuxCATrustConfig();
602
- if (!fs.existsSync(config.certDir)) {
603
- fs.mkdirSync(config.certDir, { recursive: true });
683
+ if (!fs2.existsSync(config.certDir)) {
684
+ fs2.mkdirSync(config.certDir, { recursive: true });
604
685
  }
605
686
  const dest = path.join(config.certDir, "portless-ca.crt");
606
- fs.copyFileSync(caCertPath, dest);
607
- execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
687
+ fs2.copyFileSync(caCertPath, dest);
688
+ execFileSync2(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
689
+ if (isWSL()) {
690
+ trustWindowsCA(caCertPath, wslWindowsCAStoreOptions());
691
+ }
608
692
  writeTrustMarker(stateDir);
609
693
  return { trusted: true };
610
694
  } else if (process.platform === "win32") {
611
- execFileSync("certutil", ["-addstore", "-user", "Root", caCertPath], {
612
- stdio: "pipe",
613
- timeout: 3e4
614
- });
695
+ trustWindowsCA(caCertPath);
615
696
  writeTrustMarker(stateDir);
616
697
  return { trusted: true };
617
698
  }
@@ -637,18 +718,15 @@ function untrustCA(stateDir) {
637
718
  clearTrustMarker(stateDir);
638
719
  return { removed: true };
639
720
  }
640
- if (!isCATrusted(stateDir)) {
641
- clearTrustMarker(stateDir);
642
- return { removed: true };
643
- }
721
+ const runningInWSL = isWSL();
644
722
  try {
645
723
  let result;
646
724
  if (process.platform === "darwin") {
647
725
  result = untrustCAMacOS(caCertPath);
648
726
  } else if (process.platform === "linux") {
649
- result = untrustCALinux(stateDir);
727
+ result = runningInWSL ? untrustCAWSL(stateDir, caCertPath) : untrustCALinux(stateDir);
650
728
  } else if (process.platform === "win32") {
651
- result = untrustCAWindows(caCertPath);
729
+ result = untrustWindowsCA(caCertPath);
652
730
  } else {
653
731
  result = { removed: false, error: `Unsupported platform: ${process.platform}` };
654
732
  }
@@ -663,7 +741,7 @@ function untrustCAMacOS(caCertPath) {
663
741
  const errors = [];
664
742
  const tryExec = (args) => {
665
743
  try {
666
- execFileSync("security", args, { stdio: "pipe", timeout: MACOS_SECURITY_ROOT_TIMEOUT_MS });
744
+ execFileSync2("security", args, { stdio: "pipe", timeout: MACOS_SECURITY_ROOT_TIMEOUT_MS });
667
745
  return true;
668
746
  } catch (err) {
669
747
  const message = err instanceof Error ? err.message : String(err);
@@ -685,13 +763,13 @@ function isCATrustedMacOSAfterAttempt(caCertPath) {
685
763
  const isRoot = (process.getuid?.() ?? -1) === 0;
686
764
  const sudoUser = process.env.SUDO_USER;
687
765
  if (isRoot && sudoUser) {
688
- execFileSync(
766
+ execFileSync2(
689
767
  "sudo",
690
768
  ["-u", sudoUser, "security", "verify-cert", "-c", caCertPath, "-L", "-p", "ssl"],
691
769
  { stdio: "pipe", timeout: MACOS_SECURITY_TIMEOUT_MS }
692
770
  );
693
771
  } else {
694
- execFileSync("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
772
+ execFileSync2("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
695
773
  stdio: "pipe",
696
774
  timeout: MACOS_SECURITY_TIMEOUT_MS
697
775
  });
@@ -701,63 +779,67 @@ function isCATrustedMacOSAfterAttempt(caCertPath) {
701
779
  return false;
702
780
  }
703
781
  }
704
- function untrustCALinux(stateDir) {
782
+ function untrustCALinux(stateDir, options = {}) {
705
783
  const errors = [];
706
- let deletedAny = false;
707
- for (const config of Object.values(LINUX_CA_TRUST_CONFIGS)) {
784
+ const pendingRefreshPath = path.join(stateDir, CA_TRUST_REFRESH_PENDING);
785
+ let refreshNeeded = fileExists(pendingRefreshPath);
786
+ const configs = options.configs ?? Object.values(LINUX_CA_TRUST_CONFIGS);
787
+ const activeConfig = options.activeConfig ?? getLinuxCATrustConfig();
788
+ const runUpdate = options.runUpdate ?? ((command) => {
789
+ execFileSync2(command, [], { stdio: "pipe", timeout: 3e4 });
790
+ });
791
+ for (const config of configs) {
708
792
  const dest = path.join(config.certDir, "portless-ca.crt");
709
793
  try {
710
794
  if (fileExists(dest)) {
711
- const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
712
- const installed = fs.readFileSync(dest, "utf-8").trim();
795
+ const ours = fs2.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
796
+ const installed = fs2.readFileSync(dest, "utf-8").trim();
713
797
  if (ours === installed) {
714
- fs.unlinkSync(dest);
715
- deletedAny = true;
798
+ if (!refreshNeeded) {
799
+ fs2.writeFileSync(pendingRefreshPath, "1\n");
800
+ fixOwnership(pendingRefreshPath);
801
+ refreshNeeded = true;
802
+ }
803
+ fs2.unlinkSync(dest);
716
804
  }
717
805
  }
718
806
  } catch (err) {
719
807
  errors.push(err instanceof Error ? err.message : String(err));
720
808
  }
721
809
  }
722
- if (deletedAny) {
810
+ if (refreshNeeded) {
723
811
  try {
724
- const config = getLinuxCATrustConfig();
725
- execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
812
+ runUpdate(activeConfig.updateCommand);
726
813
  } catch (err) {
727
814
  errors.push(err instanceof Error ? err.message : String(err));
728
815
  }
729
816
  }
730
- if (isCATrusted(stateDir)) {
817
+ if (errors.length > 0 || isCATrustedLinux(stateDir, activeConfig)) {
731
818
  return {
732
819
  removed: false,
733
820
  error: errors.join("; ") || "CA still trusted (remove portless-ca.crt and run the distro CA update command, often with sudo)"
734
821
  };
735
822
  }
823
+ clearTrustRefreshPending(stateDir);
736
824
  return { removed: true };
737
825
  }
738
- function untrustCAWindows(caCertPath) {
826
+ function untrustCAWSL(stateDir, caCertPath) {
827
+ const linuxResult = untrustCALinux(stateDir);
828
+ let windowsResult;
739
829
  try {
740
- const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
741
- const storeListing = execFileSync("certutil", ["-store", "-user", "Root"], {
742
- encoding: "utf-8",
743
- timeout: 1e4,
744
- stdio: ["pipe", "pipe", "pipe"]
745
- });
746
- const normalized = storeListing.replace(/\s/g, "").toLowerCase();
747
- if (!normalized.includes(fingerprint)) {
748
- return { removed: true };
749
- }
750
- execFileSync("certutil", ["-delstore", "-user", "Root", "portless Local CA"], {
751
- stdio: "pipe",
752
- timeout: 3e4
753
- });
754
- if (isCATrustedWindows(caCertPath)) {
755
- return { removed: false, error: "certutil could not remove the portless CA from Root" };
756
- }
757
- return { removed: true };
830
+ windowsResult = untrustWindowsCA(caCertPath, wslWindowsCAStoreOptions());
758
831
  } catch (err) {
759
- return { removed: false, error: err instanceof Error ? err.message : String(err) };
832
+ windowsResult = {
833
+ removed: false,
834
+ error: err instanceof Error ? err.message : String(err)
835
+ };
760
836
  }
837
+ if (linuxResult.removed && windowsResult.removed) return { removed: true };
838
+ const errors = [linuxResult.error, windowsResult.error].filter(Boolean);
839
+ return {
840
+ removed: false,
841
+ error: errors.join("; ") || "Could not remove the portless CA from every WSL trust store"
842
+ };
761
843
  }
762
844
 
763
845
  // src/tailscale.ts
@@ -766,7 +848,7 @@ var TAILSCALE_BINARY = "tailscale";
766
848
  var TAILSCALE_COMMAND_TIMEOUT_MS = 3e4;
767
849
  var PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
768
850
  var FUNNEL_PORTS = [443, 8443, 1e4];
769
- function defaultRunner(args) {
851
+ function defaultRunner2(args) {
770
852
  const result = spawnSync(TAILSCALE_BINARY, args, {
771
853
  encoding: "utf-8",
772
854
  killSignal: "SIGKILL",
@@ -858,7 +940,7 @@ function throwFunnelNotEnabled(status) {
858
940
  );
859
941
  }
860
942
  function ensureTailscaleReady(options = {}) {
861
- const runner = options.runner ?? defaultRunner;
943
+ const runner = options.runner ?? defaultRunner2;
862
944
  runOrThrow(["version"], "check tailscale version", runner);
863
945
  const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
864
946
  const status = parseStatusJson(statusResult.stdout);
@@ -874,7 +956,7 @@ function ensureTailscaleReady(options = {}) {
874
956
  baseUrl: `https://${dnsName}`
875
957
  };
876
958
  }
877
- function getUsedServePorts(runner = defaultRunner) {
959
+ function getUsedServePorts(runner = defaultRunner2) {
878
960
  const result = runner(["serve", "status", "--json"]);
879
961
  if (result.error || result.status !== 0) {
880
962
  return /* @__PURE__ */ new Set();
@@ -970,7 +1052,7 @@ function register(mode, localPort, httpsPort, runner) {
970
1052
  }
971
1053
  }
972
1054
  function unregister(mode, httpsPort, options) {
973
- const runner = options?.runner ?? defaultRunner;
1055
+ const runner = options?.runner ?? defaultRunner2;
974
1056
  const result = runner([mode, "--yes", `--https=${httpsPort}`, "off"]);
975
1057
  if (result.error) {
976
1058
  const errno = result.error;
@@ -989,10 +1071,10 @@ ${result.stdout}`.toLowerCase();
989
1071
  }
990
1072
  }
991
1073
  function registerServe(localPort, httpsPort, options) {
992
- register("serve", localPort, httpsPort, options?.runner ?? defaultRunner);
1074
+ register("serve", localPort, httpsPort, options?.runner ?? defaultRunner2);
993
1075
  }
994
1076
  function registerFunnel(localPort, httpsPort, options) {
995
- register("funnel", localPort, httpsPort, options?.runner ?? defaultRunner);
1077
+ register("funnel", localPort, httpsPort, options?.runner ?? defaultRunner2);
996
1078
  }
997
1079
  function unregisterTailscale(route) {
998
1080
  if (!route.tailscaleHttpsPort) return;
@@ -1017,7 +1099,7 @@ function defaultSpawner(args) {
1017
1099
  windowsHide: true
1018
1100
  });
1019
1101
  }
1020
- function defaultRunner2(args) {
1102
+ function defaultRunner3(args) {
1021
1103
  const result = spawnSync2(NGROK_BINARY, args, {
1022
1104
  encoding: "utf-8",
1023
1105
  killSignal: "SIGKILL",
@@ -1054,7 +1136,7 @@ function formatOutputError(output) {
1054
1136
  `Failed to start ngrok tunnel: ${details || "ngrok exited before printing a public URL"}`
1055
1137
  );
1056
1138
  }
1057
- function ensureNgrokAvailable(runner = defaultRunner2) {
1139
+ function ensureNgrokAvailable(runner = defaultRunner3) {
1058
1140
  const result = runner(["version"]);
1059
1141
  if (result.error) {
1060
1142
  throw formatSpawnError(result.error);
@@ -1179,8 +1261,8 @@ function stopNgrok(route) {
1179
1261
 
1180
1262
  // src/auto.ts
1181
1263
  import { createHash as createHash2 } from "crypto";
1182
- import { execFileSync as execFileSync2 } from "child_process";
1183
- import * as fs2 from "fs";
1264
+ import { execFileSync as execFileSync3 } from "child_process";
1265
+ import * as fs3 from "fs";
1184
1266
  import * as path2 from "path";
1185
1267
  var MAX_DNS_LABEL_LENGTH = 63;
1186
1268
  function truncateLabel(label) {
@@ -1220,7 +1302,7 @@ function findPackageJsonName(startDir) {
1220
1302
  for (; ; ) {
1221
1303
  const pkgPath = path2.join(dir, "package.json");
1222
1304
  try {
1223
- const raw = fs2.readFileSync(pkgPath, "utf-8");
1305
+ const raw = fs3.readFileSync(pkgPath, "utf-8");
1224
1306
  const pkg = JSON.parse(raw);
1225
1307
  if (typeof pkg.name === "string" && pkg.name) {
1226
1308
  return pkg.name.replace(/^@[^/]+\//, "");
@@ -1235,7 +1317,7 @@ function findPackageJsonName(startDir) {
1235
1317
  }
1236
1318
  function findGitRoot(startDir) {
1237
1319
  try {
1238
- const toplevel = execFileSync2("git", ["rev-parse", "--show-toplevel"], {
1320
+ const toplevel = execFileSync3("git", ["rev-parse", "--show-toplevel"], {
1239
1321
  cwd: startDir,
1240
1322
  encoding: "utf-8",
1241
1323
  timeout: 5e3,
@@ -1248,7 +1330,7 @@ function findGitRoot(startDir) {
1248
1330
  for (; ; ) {
1249
1331
  const gitPath = path2.join(dir, ".git");
1250
1332
  try {
1251
- const stat = fs2.statSync(gitPath);
1333
+ const stat = fs3.statSync(gitPath);
1252
1334
  if (stat.isDirectory()) return dir;
1253
1335
  if (stat.isFile()) return dir;
1254
1336
  } catch {
@@ -1276,7 +1358,7 @@ function detectWorktreePrefix(cwd = process.cwd()) {
1276
1358
  }
1277
1359
  function detectWorktreeViaCli(cwd) {
1278
1360
  try {
1279
- const listOutput = execFileSync2("git", ["worktree", "list", "--porcelain"], {
1361
+ const listOutput = execFileSync3("git", ["worktree", "list", "--porcelain"], {
1280
1362
  cwd,
1281
1363
  encoding: "utf-8",
1282
1364
  timeout: 5e3,
@@ -1286,7 +1368,7 @@ function detectWorktreeViaCli(cwd) {
1286
1368
  if (worktreeCount <= 1) return null;
1287
1369
  const gitDir = path2.resolve(
1288
1370
  cwd,
1289
- execFileSync2("git", ["rev-parse", "--git-dir"], {
1371
+ execFileSync3("git", ["rev-parse", "--git-dir"], {
1290
1372
  cwd,
1291
1373
  encoding: "utf-8",
1292
1374
  timeout: 5e3,
@@ -1295,7 +1377,7 @@ function detectWorktreeViaCli(cwd) {
1295
1377
  );
1296
1378
  const gitCommonDir = path2.resolve(
1297
1379
  cwd,
1298
- execFileSync2("git", ["rev-parse", "--git-common-dir"], {
1380
+ execFileSync3("git", ["rev-parse", "--git-common-dir"], {
1299
1381
  cwd,
1300
1382
  encoding: "utf-8",
1301
1383
  timeout: 5e3,
@@ -1303,7 +1385,7 @@ function detectWorktreeViaCli(cwd) {
1303
1385
  }).trim()
1304
1386
  );
1305
1387
  if (gitDir === gitCommonDir) return null;
1306
- const branch = execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1388
+ const branch = execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
1307
1389
  cwd,
1308
1390
  encoding: "utf-8",
1309
1391
  timeout: 5e3,
@@ -1321,12 +1403,12 @@ function detectWorktreeViaFilesystem(startDir) {
1321
1403
  for (; ; ) {
1322
1404
  const gitPath = path2.join(dir, ".git");
1323
1405
  try {
1324
- const stat = fs2.statSync(gitPath);
1406
+ const stat = fs3.statSync(gitPath);
1325
1407
  if (stat.isDirectory()) {
1326
1408
  return null;
1327
1409
  }
1328
1410
  if (stat.isFile()) {
1329
- const content = fs2.readFileSync(gitPath, "utf-8").trim();
1411
+ const content = fs3.readFileSync(gitPath, "utf-8").trim();
1330
1412
  const match = content.match(/^gitdir:\s*(.+)$/);
1331
1413
  if (!match) return null;
1332
1414
  const gitdir = match[1];
@@ -1346,7 +1428,7 @@ function detectWorktreeViaFilesystem(startDir) {
1346
1428
  }
1347
1429
  function readBranchFromHead(gitdir) {
1348
1430
  try {
1349
- const head = fs2.readFileSync(path2.join(gitdir, "HEAD"), "utf-8").trim();
1431
+ const head = fs3.readFileSync(path2.join(gitdir, "HEAD"), "utf-8").trim();
1350
1432
  const refMatch = head.match(/^ref: refs\/heads\/(.+)$/);
1351
1433
  return refMatch ? refMatch[1] : null;
1352
1434
  } catch {
@@ -1355,11 +1437,11 @@ function readBranchFromHead(gitdir) {
1355
1437
  }
1356
1438
 
1357
1439
  // src/cli-utils.ts
1358
- import * as fs3 from "fs";
1440
+ import * as fs4 from "fs";
1359
1441
  import * as http from "http";
1360
1442
  import * as https from "https";
1361
1443
  import * as net from "net";
1362
- import * as os from "os";
1444
+ import * as os2 from "os";
1363
1445
  import * as path3 from "path";
1364
1446
  import * as readline from "readline";
1365
1447
  import { execSync, spawn as spawn2 } from "child_process";
@@ -1368,8 +1450,12 @@ var FALLBACK_PROXY_PORT = 1355;
1368
1450
  var PRIVILEGED_PORT_THRESHOLD = 1024;
1369
1451
  var INTERNAL_LAN_IP_ENV = "PORTLESS_INTERNAL_LAN_IP";
1370
1452
  var INTERNAL_LAN_IP_FLAG = "--lan-ip-auto";
1371
- var LEGACY_SYSTEM_STATE_DIR = isWindows ? path3.join(os.tmpdir(), "portless") : "/tmp/portless";
1372
- var USER_STATE_DIR = path3.join(os.homedir(), ".portless");
1453
+ var IPV4_LOOPBACK_PROXY_HOST = "127.0.0.1";
1454
+ var IPV6_LOOPBACK_PROXY_HOST = "::1";
1455
+ var IPV4_LAN_PROXY_HOST = "0.0.0.0";
1456
+ var IPV6_LAN_PROXY_HOST = "::";
1457
+ var LEGACY_SYSTEM_STATE_DIR = isWindows ? path3.join(os2.tmpdir(), "portless") : "/tmp/portless";
1458
+ var USER_STATE_DIR = path3.join(resolveUserHome(), ".portless");
1373
1459
  var MIN_APP_PORT = 4e3;
1374
1460
  var MAX_APP_PORT = 4999;
1375
1461
  var RANDOM_PORT_ATTEMPTS = 50;
@@ -1470,6 +1556,12 @@ var SIGNAL_CODES = {
1470
1556
  SIGKILL: 9,
1471
1557
  SIGTERM: 15
1472
1558
  };
1559
+ function getProxyBindTargets(lanMode) {
1560
+ return lanMode ? [{ host: IPV4_LAN_PROXY_HOST }, { host: IPV6_LAN_PROXY_HOST, ipv6Only: true }] : [{ host: IPV4_LOOPBACK_PROXY_HOST }, { host: IPV6_LOOPBACK_PROXY_HOST, ipv6Only: true }];
1561
+ }
1562
+ function listenOnProxyInterface(server, port, target, listener) {
1563
+ server.listen({ port, host: target.host, ipv6Only: target.ipv6Only }, listener);
1564
+ }
1473
1565
  function killTree(child, signal = "SIGTERM") {
1474
1566
  if (!child.pid) {
1475
1567
  child.kill(signal);
@@ -1504,7 +1596,7 @@ function resolveStateDir(_port) {
1504
1596
  }
1505
1597
  function readPortFromDir(dir) {
1506
1598
  try {
1507
- const raw = fs3.readFileSync(path3.join(dir, "proxy.port"), "utf-8").trim();
1599
+ const raw = fs4.readFileSync(path3.join(dir, "proxy.port"), "utf-8").trim();
1508
1600
  const port = parseInt(raw, 10);
1509
1601
  return isNaN(port) ? null : port;
1510
1602
  } catch {
@@ -1515,7 +1607,7 @@ var TLS_MARKER_FILE = "proxy.tls";
1515
1607
  var CUSTOM_CERT_MARKER_FILE = "proxy.custom-cert";
1516
1608
  function readTlsMarker(dir) {
1517
1609
  try {
1518
- return fs3.existsSync(path3.join(dir, TLS_MARKER_FILE));
1610
+ return fs4.existsSync(path3.join(dir, TLS_MARKER_FILE));
1519
1611
  } catch {
1520
1612
  return false;
1521
1613
  }
@@ -1523,17 +1615,17 @@ function readTlsMarker(dir) {
1523
1615
  function writeTlsMarker(dir, enabled2) {
1524
1616
  const markerPath = path3.join(dir, TLS_MARKER_FILE);
1525
1617
  if (enabled2) {
1526
- fs3.writeFileSync(markerPath, "1", { mode: 420 });
1618
+ fs4.writeFileSync(markerPath, "1", { mode: 420 });
1527
1619
  } else {
1528
1620
  try {
1529
- fs3.unlinkSync(markerPath);
1621
+ fs4.unlinkSync(markerPath);
1530
1622
  } catch {
1531
1623
  }
1532
1624
  }
1533
1625
  }
1534
1626
  function readCustomCertMarker(dir) {
1535
1627
  try {
1536
- return fs3.existsSync(path3.join(dir, CUSTOM_CERT_MARKER_FILE));
1628
+ return fs4.existsSync(path3.join(dir, CUSTOM_CERT_MARKER_FILE));
1537
1629
  } catch {
1538
1630
  return false;
1539
1631
  }
@@ -1541,10 +1633,10 @@ function readCustomCertMarker(dir) {
1541
1633
  function writeCustomCertMarker(dir, enabled2) {
1542
1634
  const markerPath = path3.join(dir, CUSTOM_CERT_MARKER_FILE);
1543
1635
  if (enabled2) {
1544
- fs3.writeFileSync(markerPath, "1", { mode: 420 });
1636
+ fs4.writeFileSync(markerPath, "1", { mode: 420 });
1545
1637
  } else {
1546
1638
  try {
1547
- fs3.unlinkSync(markerPath);
1639
+ fs4.unlinkSync(markerPath);
1548
1640
  } catch {
1549
1641
  }
1550
1642
  }
@@ -1552,7 +1644,7 @@ function writeCustomCertMarker(dir, enabled2) {
1552
1644
  var LAN_MARKER_FILE = "proxy.lan";
1553
1645
  function readLanMarker(dir) {
1554
1646
  try {
1555
- const raw = fs3.readFileSync(path3.join(dir, LAN_MARKER_FILE), "utf-8").trim();
1647
+ const raw = fs4.readFileSync(path3.join(dir, LAN_MARKER_FILE), "utf-8").trim();
1556
1648
  return raw || null;
1557
1649
  } catch {
1558
1650
  return null;
@@ -1562,11 +1654,11 @@ function writeLanMarker(dir, ip) {
1562
1654
  const markerPath = path3.join(dir, LAN_MARKER_FILE);
1563
1655
  if (!ip) {
1564
1656
  try {
1565
- fs3.unlinkSync(markerPath);
1657
+ fs4.unlinkSync(markerPath);
1566
1658
  } catch {
1567
1659
  }
1568
1660
  } else {
1569
- fs3.writeFileSync(markerPath, ip, { mode: 420 });
1661
+ fs4.writeFileSync(markerPath, ip, { mode: 420 });
1570
1662
  }
1571
1663
  }
1572
1664
  var DEFAULT_TLD = "localhost";
@@ -1610,7 +1702,7 @@ var TLD_FILE = "proxy.tld";
1610
1702
  var TLDS_FILE = "proxy.tlds";
1611
1703
  function readLegacyTldFromDir(dir) {
1612
1704
  try {
1613
- const raw = fs3.readFileSync(path3.join(dir, TLD_FILE), "utf-8").trim();
1705
+ const raw = fs4.readFileSync(path3.join(dir, TLD_FILE), "utf-8").trim();
1614
1706
  return raw || DEFAULT_TLD;
1615
1707
  } catch {
1616
1708
  return DEFAULT_TLD;
@@ -1618,7 +1710,7 @@ function readLegacyTldFromDir(dir) {
1618
1710
  }
1619
1711
  function readTldsFromDir(dir) {
1620
1712
  try {
1621
- const raw = fs3.readFileSync(path3.join(dir, TLDS_FILE), "utf-8").trim();
1713
+ const raw = fs4.readFileSync(path3.join(dir, TLDS_FILE), "utf-8").trim();
1622
1714
  const parsed = raw.startsWith("[") ? JSON.parse(raw) : raw.split(/\r?\n/).flatMap((line) => line.split(",")).map((line) => line.trim()).filter(Boolean);
1623
1715
  if (!Array.isArray(parsed)) return [readLegacyTldFromDir(dir)];
1624
1716
  const tlds = parsed.flatMap((value) => typeof value === "string" ? parseTldList(value) : []);
@@ -1633,16 +1725,16 @@ function writeTldsFile(dir, tlds) {
1633
1725
  const tldPath = path3.join(dir, TLD_FILE);
1634
1726
  if (uniqueTlds.length === 1 && uniqueTlds[0] === DEFAULT_TLD) {
1635
1727
  try {
1636
- fs3.unlinkSync(tldsPath);
1728
+ fs4.unlinkSync(tldsPath);
1637
1729
  } catch {
1638
1730
  }
1639
1731
  try {
1640
- fs3.unlinkSync(tldPath);
1732
+ fs4.unlinkSync(tldPath);
1641
1733
  } catch {
1642
1734
  }
1643
1735
  } else {
1644
- fs3.writeFileSync(tldsPath, uniqueTlds.join("\n") + "\n", { mode: 420 });
1645
- fs3.writeFileSync(tldPath, uniqueTlds[0] ?? DEFAULT_TLD, { mode: 420 });
1736
+ fs4.writeFileSync(tldsPath, uniqueTlds.join("\n") + "\n", { mode: 420 });
1737
+ fs4.writeFileSync(tldPath, uniqueTlds[0] ?? DEFAULT_TLD, { mode: 420 });
1646
1738
  }
1647
1739
  }
1648
1740
  function writeTldFile(dir, tld) {
@@ -1957,7 +2049,7 @@ function collectBinPaths(cwd) {
1957
2049
  let dir = cwd;
1958
2050
  for (; ; ) {
1959
2051
  const bin = path3.join(dir, "node_modules", ".bin");
1960
- if (fs3.existsSync(bin)) {
2052
+ if (fs4.existsSync(bin)) {
1961
2053
  dirs.push(bin);
1962
2054
  }
1963
2055
  const parent = path3.dirname(dir);
@@ -2088,7 +2180,7 @@ function injectFrameworkFlags(commandArgs, port) {
2088
2180
  }
2089
2181
 
2090
2182
  // src/clean-utils.ts
2091
- import * as fs4 from "fs";
2183
+ import * as fs5 from "fs";
2092
2184
  import * as path4 from "path";
2093
2185
  var PORTLESS_STATE_FILES = [
2094
2186
  "routes.json",
@@ -2101,6 +2193,8 @@ var PORTLESS_STATE_FILES = [
2101
2193
  "proxy.tld",
2102
2194
  "proxy.tlds",
2103
2195
  "proxy.lan",
2196
+ "ca.trusted",
2197
+ "ca.trust-refresh-pending",
2104
2198
  "ca-key.pem",
2105
2199
  "ca.pem",
2106
2200
  "server-key.pem",
@@ -2110,28 +2204,38 @@ var PORTLESS_STATE_FILES = [
2110
2204
  "ca.srl"
2111
2205
  ];
2112
2206
  var HOST_CERTS_DIR2 = "host-certs";
2207
+ var CA_IDENTITY_FILES = /* @__PURE__ */ new Set(["ca-key.pem", "ca.pem", "ca.trust-refresh-pending"]);
2113
2208
  function collectStateDirsForCleanup() {
2114
2209
  const dirs = /* @__PURE__ */ new Set();
2115
2210
  const add = (d) => {
2116
2211
  const trimmed = d?.trim();
2117
2212
  if (!trimmed) return;
2118
2213
  const resolved = path4.resolve(trimmed);
2119
- if (fs4.existsSync(resolved)) dirs.add(resolved);
2214
+ if (fs5.existsSync(resolved)) dirs.add(resolved);
2120
2215
  };
2121
2216
  add(USER_STATE_DIR);
2122
2217
  add(LEGACY_SYSTEM_STATE_DIR);
2123
2218
  add(process.env.PORTLESS_STATE_DIR);
2124
2219
  return [...dirs];
2125
2220
  }
2126
- function removePortlessStateFiles(dir) {
2221
+ function attemptCATrustRemovalForCleanup(stateDirs, untrust) {
2222
+ const results = /* @__PURE__ */ new Map();
2223
+ for (const stateDir of stateDirs) {
2224
+ if (!fs5.existsSync(path4.join(stateDir, "ca.pem"))) continue;
2225
+ results.set(stateDir, untrust(stateDir));
2226
+ }
2227
+ return results;
2228
+ }
2229
+ function removePortlessStateFiles(dir, options = {}) {
2127
2230
  for (const f of PORTLESS_STATE_FILES) {
2231
+ if (options.preserveCAIdentity && CA_IDENTITY_FILES.has(f)) continue;
2128
2232
  try {
2129
- fs4.unlinkSync(path4.join(dir, f));
2233
+ fs5.unlinkSync(path4.join(dir, f));
2130
2234
  } catch {
2131
2235
  }
2132
2236
  }
2133
2237
  try {
2134
- fs4.rmSync(path4.join(dir, HOST_CERTS_DIR2), { recursive: true, force: true });
2238
+ fs5.rmSync(path4.join(dir, HOST_CERTS_DIR2), { recursive: true, force: true });
2135
2239
  } catch {
2136
2240
  }
2137
2241
  }
@@ -2340,7 +2444,7 @@ function cleanupAll() {
2340
2444
  }
2341
2445
 
2342
2446
  // src/config.ts
2343
- import * as fs5 from "fs";
2447
+ import * as fs6 from "fs";
2344
2448
  import * as path5 from "path";
2345
2449
  var ConfigValidationError = class extends Error {
2346
2450
  constructor(message) {
@@ -2352,7 +2456,7 @@ var CONFIG_FILENAME = "portless.json";
2352
2456
  function loadConfig(cwd = process.cwd()) {
2353
2457
  const configPath = path5.join(cwd, CONFIG_FILENAME);
2354
2458
  try {
2355
- const raw = fs5.readFileSync(configPath, "utf-8");
2459
+ const raw = fs6.readFileSync(configPath, "utf-8");
2356
2460
  const parsed = JSON.parse(raw);
2357
2461
  validateConfig(parsed, configPath);
2358
2462
  return { config: parsed, configDir: cwd };
@@ -2375,7 +2479,7 @@ function normalizePortlessValue(value) {
2375
2479
  function loadConfigFromPackageJson(dir) {
2376
2480
  const pkgPath = path5.join(dir, "package.json");
2377
2481
  try {
2378
- const raw = fs5.readFileSync(pkgPath, "utf-8");
2482
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
2379
2483
  const pkg = JSON.parse(raw);
2380
2484
  if (pkg && typeof pkg === "object" && "portless" in pkg) {
2381
2485
  const config = normalizePortlessValue(pkg.portless);
@@ -2393,7 +2497,7 @@ function loadConfigFromPackageJson(dir) {
2393
2497
  function loadPackagePortlessConfig(dir) {
2394
2498
  const pkgPath = path5.join(dir, "package.json");
2395
2499
  try {
2396
- const raw = fs5.readFileSync(pkgPath, "utf-8");
2500
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
2397
2501
  const pkg = JSON.parse(raw);
2398
2502
  if (pkg && typeof pkg === "object" && "portless" in pkg) {
2399
2503
  const config = normalizePortlessValue(pkg.portless);
@@ -2429,7 +2533,7 @@ function resolveAppConfig(config, configDir, packageDir) {
2429
2533
  function hasScript(scriptName, dir) {
2430
2534
  const pkgPath = path5.join(dir, "package.json");
2431
2535
  try {
2432
- const raw = fs5.readFileSync(pkgPath, "utf-8");
2536
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
2433
2537
  const pkg = JSON.parse(raw);
2434
2538
  return typeof pkg?.scripts?.[scriptName] === "string";
2435
2539
  } catch {
@@ -2448,7 +2552,7 @@ function detectPackageManager(cwd) {
2448
2552
  for (; ; ) {
2449
2553
  const pkgPath = path5.join(dir, "package.json");
2450
2554
  try {
2451
- const raw = fs5.readFileSync(pkgPath, "utf-8");
2555
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
2452
2556
  const pkg = JSON.parse(raw);
2453
2557
  if (typeof pkg.packageManager === "string") {
2454
2558
  const name = pkg.packageManager.split("@")[0];
@@ -2460,7 +2564,7 @@ function detectPackageManager(cwd) {
2460
2564
  }
2461
2565
  for (const [file, pm] of LOCK_FILES) {
2462
2566
  try {
2463
- fs5.accessSync(path5.join(dir, file), fs5.constants.F_OK);
2567
+ fs6.accessSync(path5.join(dir, file), fs6.constants.F_OK);
2464
2568
  return pm;
2465
2569
  } catch {
2466
2570
  }
@@ -2619,13 +2723,13 @@ function warnUnknownKeys(obj, known, configPath, prefix) {
2619
2723
  }
2620
2724
 
2621
2725
  // src/workspace.ts
2622
- import * as fs6 from "fs";
2726
+ import * as fs7 from "fs";
2623
2727
  import * as path6 from "path";
2624
2728
  function findWorkspaceRoot(cwd = process.cwd()) {
2625
2729
  let dir = cwd;
2626
2730
  for (; ; ) {
2627
2731
  try {
2628
- fs6.accessSync(path6.join(dir, "pnpm-workspace.yaml"), fs6.constants.R_OK);
2732
+ fs7.accessSync(path6.join(dir, "pnpm-workspace.yaml"), fs7.constants.R_OK);
2629
2733
  return dir;
2630
2734
  } catch {
2631
2735
  }
@@ -2639,7 +2743,7 @@ function findWorkspaceRoot(cwd = process.cwd()) {
2639
2743
  }
2640
2744
  function detectWorkspaceSource(workspaceRoot) {
2641
2745
  try {
2642
- fs6.accessSync(path6.join(workspaceRoot, "pnpm-workspace.yaml"), fs6.constants.R_OK);
2746
+ fs7.accessSync(path6.join(workspaceRoot, "pnpm-workspace.yaml"), fs7.constants.R_OK);
2643
2747
  return "pnpm";
2644
2748
  } catch {
2645
2749
  }
@@ -2651,7 +2755,7 @@ function detectWorkspaceSource(workspaceRoot) {
2651
2755
  function readWorkspacesFromPackageJson(dir) {
2652
2756
  const pkgPath = path6.join(dir, "package.json");
2653
2757
  try {
2654
- const raw = fs6.readFileSync(pkgPath, "utf-8");
2758
+ const raw = fs7.readFileSync(pkgPath, "utf-8");
2655
2759
  const pkg = JSON.parse(raw);
2656
2760
  if (!pkg || typeof pkg !== "object") return null;
2657
2761
  const ws = pkg.workspaces;
@@ -2672,7 +2776,7 @@ function discoverWorkspacePackages(workspaceRoot) {
2672
2776
  const wsPath = path6.join(workspaceRoot, "pnpm-workspace.yaml");
2673
2777
  let content;
2674
2778
  try {
2675
- content = fs6.readFileSync(wsPath, "utf-8");
2779
+ content = fs7.readFileSync(wsPath, "utf-8");
2676
2780
  } catch {
2677
2781
  return [];
2678
2782
  }
@@ -2687,7 +2791,7 @@ function discoverWorkspacePackages(workspaceRoot) {
2687
2791
  for (const dir of dirs) {
2688
2792
  const pkgPath = path6.join(dir, "package.json");
2689
2793
  try {
2690
- const raw = fs6.readFileSync(pkgPath, "utf-8");
2794
+ const raw = fs7.readFileSync(pkgPath, "utf-8");
2691
2795
  const pkg = JSON.parse(raw);
2692
2796
  const rawName = typeof pkg.name === "string" ? pkg.name : null;
2693
2797
  const scopeMatch = rawName?.match(/^@([^/]+)\//);
@@ -2769,7 +2873,7 @@ function expandSingleGlob(root, glob) {
2769
2873
  function expandSegments(base, segments) {
2770
2874
  if (segments.length === 0) {
2771
2875
  try {
2772
- const stat = fs6.statSync(base);
2876
+ const stat = fs7.statSync(base);
2773
2877
  if (stat.isDirectory()) return [base];
2774
2878
  } catch {
2775
2879
  }
@@ -2778,7 +2882,7 @@ function expandSegments(base, segments) {
2778
2882
  const [current, ...rest] = segments;
2779
2883
  if (current.includes("*")) {
2780
2884
  try {
2781
- const entries = fs6.readdirSync(base, { withFileTypes: true });
2885
+ const entries = fs7.readdirSync(base, { withFileTypes: true });
2782
2886
  const matched = entries.filter((e) => e.isDirectory() && segmentMatches(current, e.name));
2783
2887
  if (rest.length === 0) {
2784
2888
  return matched.map((e) => path6.join(base, e.name));
@@ -2796,7 +2900,7 @@ function expandSegments(base, segments) {
2796
2900
  }
2797
2901
 
2798
2902
  // src/turbo.ts
2799
- import * as fs7 from "fs";
2903
+ import * as fs8 from "fs";
2800
2904
  import * as path7 from "path";
2801
2905
  var LOADER_FILENAME = "turbo-env-loader.cjs";
2802
2906
  var MANIFEST_FILENAME = "dev-manifest.json";
@@ -2826,23 +2930,23 @@ try {
2826
2930
  `;
2827
2931
  }
2828
2932
  function ensureEnvLoader(baseDir = USER_STATE_DIR) {
2829
- fs7.mkdirSync(baseDir, { recursive: true, mode: 493 });
2933
+ fs8.mkdirSync(baseDir, { recursive: true, mode: 493 });
2830
2934
  const target = loaderPath(baseDir);
2831
2935
  const source = loaderSource(baseDir);
2832
2936
  try {
2833
- const existing = fs7.readFileSync(target, "utf-8");
2937
+ const existing = fs8.readFileSync(target, "utf-8");
2834
2938
  if (existing === source) return;
2835
2939
  } catch {
2836
2940
  }
2837
- fs7.writeFileSync(target, source, { mode: 420 });
2941
+ fs8.writeFileSync(target, source, { mode: 420 });
2838
2942
  }
2839
2943
  function writeManifest(entries, baseDir = USER_STATE_DIR) {
2840
- fs7.mkdirSync(baseDir, { recursive: true, mode: 493 });
2841
- fs7.writeFileSync(manifestPath(baseDir), JSON.stringify(entries, null, 2) + "\n", { mode: 420 });
2944
+ fs8.mkdirSync(baseDir, { recursive: true, mode: 493 });
2945
+ fs8.writeFileSync(manifestPath(baseDir), JSON.stringify(entries, null, 2) + "\n", { mode: 420 });
2842
2946
  }
2843
2947
  function removeManifest(baseDir = USER_STATE_DIR) {
2844
2948
  try {
2845
- fs7.unlinkSync(manifestPath(baseDir));
2949
+ fs8.unlinkSync(manifestPath(baseDir));
2846
2950
  } catch {
2847
2951
  }
2848
2952
  }
@@ -2854,7 +2958,7 @@ function buildNodeOptions(baseDir = USER_STATE_DIR) {
2854
2958
  }
2855
2959
  function hasTurboConfig(wsRoot) {
2856
2960
  try {
2857
- fs7.accessSync(path7.join(wsRoot, "turbo.json"), fs7.constants.R_OK);
2961
+ fs8.accessSync(path7.join(wsRoot, "turbo.json"), fs8.constants.R_OK);
2858
2962
  return true;
2859
2963
  } catch {
2860
2964
  return false;
@@ -2862,8 +2966,8 @@ function hasTurboConfig(wsRoot) {
2862
2966
  }
2863
2967
 
2864
2968
  // src/service.ts
2865
- import * as fs8 from "fs";
2866
- import * as os2 from "os";
2969
+ import * as fs9 from "fs";
2970
+ import * as os3 from "os";
2867
2971
  import * as path8 from "path";
2868
2972
  import { spawnSync as spawnSync4 } from "child_process";
2869
2973
  var DEFAULT_SERVICE_PORT = getProtocolPort(true);
@@ -2894,7 +2998,7 @@ var DEFAULT_SERVICE_CONFIG = {
2894
2998
  useWildcard: false,
2895
2999
  extraEnv: {}
2896
3000
  };
2897
- function defaultRunner3(command, args, options) {
3001
+ function defaultRunner4(command, args, options) {
2898
3002
  return spawnSync4(command, args, {
2899
3003
  encoding: "utf-8",
2900
3004
  stdio: options?.stdio ?? "pipe"
@@ -2936,7 +3040,7 @@ function getFlagValue(args, index, flag) {
2936
3040
  return value;
2937
3041
  }
2938
3042
  function resolveServicePath(value) {
2939
- const expanded = value === "~" ? os2.homedir() : value.startsWith("~/") || value.startsWith("~\\") ? path8.join(os2.homedir(), value.slice(2)) : value;
3043
+ const expanded = value === "~" ? os3.homedir() : value.startsWith("~/") || value.startsWith("~\\") ? path8.join(os3.homedir(), value.slice(2)) : value;
2940
3044
  return path8.resolve(expanded);
2941
3045
  }
2942
3046
  function normalizeServiceInstallPaths(config) {
@@ -3064,34 +3168,21 @@ function parseServiceInstallConfig(args, env = process.env, options = {}) {
3064
3168
  }
3065
3169
  return config;
3066
3170
  }
3067
- function readPasswdHome(username) {
3068
- try {
3069
- const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
3070
- for (const line of passwd.split("\n")) {
3071
- const fields = line.split(":");
3072
- if (fields[0] === username && fields[5]) {
3073
- return fields[5];
3074
- }
3075
- }
3076
- } catch {
3077
- }
3078
- return null;
3079
- }
3080
3171
  function resolveUserContext(platform) {
3081
3172
  if (platform === "win32") {
3082
- const home = process.env.USERPROFILE || os2.homedir();
3173
+ const home = resolveUserHome({ platform });
3083
3174
  return { home, username: process.env.USERNAME };
3084
3175
  }
3085
3176
  const sudoUser = process.env.SUDO_USER;
3086
3177
  const sudoUid = process.env.SUDO_UID;
3087
3178
  const sudoGid = process.env.SUDO_GID;
3088
3179
  if (sudoUser && sudoUser !== "root") {
3089
- const home = process.env.HOME && process.env.HOME !== "/var/root" && process.env.HOME !== "/root" ? process.env.HOME : readPasswdHome(sudoUser) || (platform === "darwin" ? path8.posix.join("/Users", sudoUser) : path8.posix.join("/home", sudoUser));
3180
+ const home = resolveUserHome({ platform });
3090
3181
  return { home, uid: sudoUid, gid: sudoGid, username: sudoUser };
3091
3182
  }
3092
- const userInfo2 = os2.userInfo();
3183
+ const userInfo2 = os3.userInfo();
3093
3184
  return {
3094
- home: os2.homedir(),
3185
+ home: os3.homedir(),
3095
3186
  uid: process.getuid?.()?.toString(),
3096
3187
  gid: process.getgid?.()?.toString(),
3097
3188
  username: userInfo2.username
@@ -3369,8 +3460,8 @@ function parsePlistEnv(block) {
3369
3460
  function readInstalledServiceSnapshot(spec) {
3370
3461
  try {
3371
3462
  if (spec.platform === "darwin") {
3372
- if (!fs8.existsSync(spec.plistPath)) return null;
3373
- const plist = fs8.readFileSync(spec.plistPath, "utf-8");
3463
+ if (!fs9.existsSync(spec.plistPath)) return null;
3464
+ const plist = fs9.readFileSync(spec.plistPath, "utf-8");
3374
3465
  const argsBlock = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
3375
3466
  const envBlock = plist.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
3376
3467
  if (!argsBlock) return null;
@@ -3380,8 +3471,8 @@ function readInstalledServiceSnapshot(spec) {
3380
3471
  };
3381
3472
  }
3382
3473
  if (spec.platform === "linux") {
3383
- if (!fs8.existsSync(spec.unitPath)) return null;
3384
- const unit = fs8.readFileSync(spec.unitPath, "utf-8");
3474
+ if (!fs9.existsSync(spec.unitPath)) return null;
3475
+ const unit = fs9.readFileSync(spec.unitPath, "utf-8");
3385
3476
  const env2 = {};
3386
3477
  let command = null;
3387
3478
  for (const line of unit.split("\n")) {
@@ -3399,8 +3490,8 @@ function readInstalledServiceSnapshot(spec) {
3399
3490
  }
3400
3491
  return command ? { command, env: env2 } : null;
3401
3492
  }
3402
- if (!fs8.existsSync(spec.scriptPath)) return null;
3403
- const script = fs8.readFileSync(spec.scriptPath, "utf-8");
3493
+ if (!fs9.existsSync(spec.scriptPath)) return null;
3494
+ const script = fs9.readFileSync(spec.scriptPath, "utf-8");
3404
3495
  const env = {};
3405
3496
  let commandLine = null;
3406
3497
  for (const rawLine of script.split(/\r?\n/)) {
@@ -3462,7 +3553,7 @@ function buildElevatedEnvArgs(options) {
3462
3553
  }
3463
3554
  function buildServiceUninstallSudoArgs(entryScript, options = {}) {
3464
3555
  const env = options.env ?? process.env;
3465
- const home = options.home ?? os2.homedir();
3556
+ const home = options.home ?? os3.homedir();
3466
3557
  const stateDir = options.stateDir ?? env.PORTLESS_STATE_DIR ?? path8.join(home, ".portless");
3467
3558
  return [
3468
3559
  ...buildElevatedEnvArgs({ home, stateDir, env }),
@@ -3476,7 +3567,7 @@ function requireUnixElevation(args, runner) {
3476
3567
  if (process.platform !== "darwin" && process.platform !== "linux") return;
3477
3568
  if ((process.getuid?.() ?? -1) === 0) return;
3478
3569
  if (process.env[INTERNAL_ELEVATED_ENV] === "1") return;
3479
- const home = os2.homedir();
3570
+ const home = os3.homedir();
3480
3571
  const stateDir = process.env.PORTLESS_STATE_DIR || path8.join(home, ".portless");
3481
3572
  const result = runner(
3482
3573
  "sudo",
@@ -3533,7 +3624,7 @@ async function stopExistingProxy(entryScript, runner, proxyPort) {
3533
3624
  }
3534
3625
  }
3535
3626
  function prepareServiceState(stateDir) {
3536
- fs8.mkdirSync(stateDir, { recursive: true });
3627
+ fs9.mkdirSync(stateDir, { recursive: true });
3537
3628
  fixOwnership(stateDir);
3538
3629
  }
3539
3630
  function prepareTrust(stateDir) {
@@ -3581,8 +3672,8 @@ async function installService(entryScript, runner, args) {
3581
3672
  if (spec.platform === "darwin") {
3582
3673
  runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
3583
3674
  await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
3584
- fs8.writeFileSync(spec.plistPath, spec.plist);
3585
- fs8.chmodSync(spec.plistPath, 420);
3675
+ fs9.writeFileSync(spec.plistPath, spec.plist);
3676
+ fs9.chmodSync(spec.plistPath, 420);
3586
3677
  runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
3587
3678
  runRequired(runner, "launchctl", ["bootstrap", "system", spec.plistPath]);
3588
3679
  runRequired(runner, "launchctl", ["enable", `system/${spec.label}`]);
@@ -3590,16 +3681,16 @@ async function installService(entryScript, runner, args) {
3590
3681
  } else if (spec.platform === "linux") {
3591
3682
  runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
3592
3683
  await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
3593
- fs8.writeFileSync(spec.unitPath, spec.unit);
3594
- fs8.chmodSync(spec.unitPath, 420);
3684
+ fs9.writeFileSync(spec.unitPath, spec.unit);
3685
+ fs9.chmodSync(spec.unitPath, 420);
3595
3686
  runRequired(runner, "systemctl", ["daemon-reload"]);
3596
3687
  runRequired(runner, "systemctl", ["enable", spec.serviceName]);
3597
3688
  runRequired(runner, "systemctl", ["restart", spec.serviceName]);
3598
3689
  } else {
3599
3690
  runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
3600
3691
  await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
3601
- fs8.mkdirSync(spec.scriptDir, { recursive: true });
3602
- fs8.writeFileSync(spec.scriptPath, spec.script);
3692
+ fs9.mkdirSync(spec.scriptDir, { recursive: true });
3693
+ fs9.writeFileSync(spec.scriptPath, spec.script);
3603
3694
  runRequired(runner, "schtasks", spec.createArgs);
3604
3695
  runOptional(runner, "schtasks", spec.runArgs);
3605
3696
  }
@@ -3612,32 +3703,32 @@ async function uninstallService(entryScript, runner) {
3612
3703
  const spec = currentServiceSpec(entryScript);
3613
3704
  if (spec.platform === "darwin") {
3614
3705
  runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
3615
- fs8.rmSync(spec.plistPath, { force: true });
3706
+ fs9.rmSync(spec.plistPath, { force: true });
3616
3707
  } else if (spec.platform === "linux") {
3617
3708
  runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
3618
- fs8.rmSync(spec.unitPath, { force: true });
3709
+ fs9.rmSync(spec.unitPath, { force: true });
3619
3710
  runOptional(runner, "systemctl", ["daemon-reload"]);
3620
3711
  } else {
3621
3712
  runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
3622
3713
  runOptional(runner, "schtasks", spec.deleteArgs);
3623
- fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
3714
+ fs9.rmSync(spec.scriptDir, { recursive: true, force: true });
3624
3715
  }
3625
3716
  console.log(colors_default.green("Portless service uninstalled."));
3626
3717
  }
3627
- function tryUninstallService(entryScript, runner = defaultRunner3) {
3718
+ function tryUninstallService(entryScript, runner = defaultRunner4) {
3628
3719
  let installed = false;
3629
3720
  try {
3630
3721
  const spec = currentServiceSpec(entryScript);
3631
3722
  if (spec.platform === "darwin") {
3632
- installed = fs8.existsSync(spec.plistPath);
3723
+ installed = fs9.existsSync(spec.plistPath);
3633
3724
  if (!installed) return { removed: false, installed: false };
3634
3725
  runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
3635
- fs8.rmSync(spec.plistPath, { force: true });
3726
+ fs9.rmSync(spec.plistPath, { force: true });
3636
3727
  } else if (spec.platform === "linux") {
3637
- installed = fs8.existsSync(spec.unitPath);
3728
+ installed = fs9.existsSync(spec.unitPath);
3638
3729
  if (!installed) return { removed: false, installed: false };
3639
3730
  runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
3640
- fs8.rmSync(spec.unitPath, { force: true });
3731
+ fs9.rmSync(spec.unitPath, { force: true });
3641
3732
  runOptional(runner, "systemctl", ["daemon-reload"]);
3642
3733
  } else {
3643
3734
  const query = runner("schtasks", ["/Query", "/TN", spec.taskName, "/FO", "LIST"]);
@@ -3645,7 +3736,7 @@ function tryUninstallService(entryScript, runner = defaultRunner3) {
3645
3736
  if (!installed) return { removed: false, installed: false };
3646
3737
  runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
3647
3738
  runRequired(runner, "schtasks", spec.deleteArgs);
3648
- fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
3739
+ fs9.rmSync(spec.scriptDir, { recursive: true, force: true });
3649
3740
  }
3650
3741
  return { removed: true, installed: true };
3651
3742
  } catch (err) {
@@ -3662,7 +3753,7 @@ async function getServiceStatus(entryScript, runner) {
3662
3753
  const installedConfig = readInstalledServiceConfig(spec) ?? spec.config;
3663
3754
  const proxyRunning = await isProxyRunning(installedConfig.proxyPort, installedConfig.useHttps);
3664
3755
  if (spec.platform === "darwin") {
3665
- const installed2 = fs8.existsSync(spec.plistPath);
3756
+ const installed2 = fs9.existsSync(spec.plistPath);
3666
3757
  const result = runner("launchctl", ["print", `system/${spec.label}`]);
3667
3758
  const output2 = `${result.stdout || ""}${result.stderr || ""}`;
3668
3759
  const managerState = result.status === 0 && /state = running|pid = \d+/.test(output2) ? "running" : installed2 ? "installed" : "not installed";
@@ -3677,7 +3768,7 @@ async function getServiceStatus(entryScript, runner) {
3677
3768
  if (spec.platform === "linux") {
3678
3769
  const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
3679
3770
  const active = runner("systemctl", ["is-active", spec.serviceName]);
3680
- const installed2 = enabled2.status === 0 || active.status === 0 || fs8.existsSync(spec.unitPath);
3771
+ const installed2 = enabled2.status === 0 || active.status === 0 || fs9.existsSync(spec.unitPath);
3681
3772
  const activeText = (active.stdout || "").trim();
3682
3773
  return {
3683
3774
  installed: installed2,
@@ -3752,7 +3843,7 @@ ${colors_default.bold("Notes:")}
3752
3843
  }
3753
3844
  async function handleService(args, options) {
3754
3845
  const action = args[1];
3755
- const runner = options.runner || defaultRunner3;
3846
+ const runner = options.runner || defaultRunner4;
3756
3847
  if (!action || action === "--help" || action === "-h") {
3757
3848
  printServiceHelp();
3758
3849
  process.exit(0);
@@ -3949,7 +4040,7 @@ function getEntryScript() {
3949
4040
  function isLocallyInstalled() {
3950
4041
  let dir = process.cwd();
3951
4042
  for (; ; ) {
3952
- if (fs9.existsSync(path9.join(dir, "node_modules", "portless", "package.json"))) {
4043
+ if (fs10.existsSync(path9.join(dir, "node_modules", "portless", "package.json"))) {
3953
4044
  return true;
3954
4045
  }
3955
4046
  const parent = path9.dirname(dir);
@@ -4021,6 +4112,12 @@ function formatUrls(hostnames, proxyPort, tls2) {
4021
4112
  function formatViteAllowedHosts(tlds) {
4022
4113
  return tlds.map((configuredTld) => `.${configuredTld}`).join(",");
4023
4114
  }
4115
+ function formatBindEndpoint(host, port) {
4116
+ return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
4117
+ }
4118
+ function isUnavailableIpv6Bind(err, host) {
4119
+ return host.includes(":") && (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL");
4120
+ }
4024
4121
  function addRoutes(store, hostnames, port, pid, force = false) {
4025
4122
  const registered = [];
4026
4123
  const killedPids = [];
@@ -4056,6 +4153,9 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4056
4153
  const isTls = !!tlsOptions;
4057
4154
  const mdnsSupport = isMdnsSupported();
4058
4155
  let activeLanIp = lanIp && mdnsSupport.supported ? lanIp : null;
4156
+ const lanModeActive = activeLanIp !== null;
4157
+ const bindTargets = getProxyBindTargets(lanModeActive);
4158
+ const primaryBindTarget = bindTargets[0];
4059
4159
  const lanIpPinned = !!process.env.PORTLESS_LAN_IP;
4060
4160
  let lanMonitor = null;
4061
4161
  if (lanIp && !mdnsSupport.supported) {
@@ -4063,11 +4163,11 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4063
4163
  console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
4064
4164
  }
4065
4165
  const routesPath = store.getRoutesPath();
4066
- if (!fs9.existsSync(routesPath)) {
4067
- fs9.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
4166
+ if (!fs10.existsSync(routesPath)) {
4167
+ fs10.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
4068
4168
  }
4069
4169
  try {
4070
- fs9.chmodSync(routesPath, FILE_MODE);
4170
+ fs10.chmodSync(routesPath, FILE_MODE);
4071
4171
  } catch {
4072
4172
  }
4073
4173
  fixOwnership(routesPath);
@@ -4127,7 +4227,7 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4127
4227
  }
4128
4228
  };
4129
4229
  try {
4130
- watcher = fs9.watch(routesPath, () => {
4230
+ watcher = fs10.watch(routesPath, () => {
4131
4231
  if (debounceTimer) clearTimeout(debounceTimer);
4132
4232
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
4133
4233
  });
@@ -4139,7 +4239,7 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4139
4239
  syncHostsFile(cachedRoutes.map((r) => r.hostname));
4140
4240
  }
4141
4241
  publishCachedRoutes();
4142
- const server = createProxyServer({
4242
+ const createServer2 = () => createProxyServer({
4143
4243
  getRoutes: () => cachedRoutes,
4144
4244
  proxyPort,
4145
4245
  tld,
@@ -4148,6 +4248,17 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4148
4248
  onError: (msg) => console.error(colors_default.red(msg)),
4149
4249
  tls: tlsOptions
4150
4250
  });
4251
+ const server = createServer2();
4252
+ const additionalServers = /* @__PURE__ */ new Set();
4253
+ const redirectServers = /* @__PURE__ */ new Set();
4254
+ const closeAuxiliaryServers = () => {
4255
+ for (const auxiliaryServer of [...additionalServers, ...redirectServers]) {
4256
+ try {
4257
+ auxiliaryServer.close();
4258
+ } catch {
4259
+ }
4260
+ }
4261
+ };
4151
4262
  server.on("error", (err) => {
4152
4263
  if (err.code === "EADDRINUSE") {
4153
4264
  console.error(colors_default.red(`Port ${proxyPort} is already in use.`));
@@ -4166,30 +4277,61 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4166
4277
  } else {
4167
4278
  console.error(colors_default.red(`Proxy error: ${err.message}`));
4168
4279
  }
4169
- if (redirectServer) redirectServer.close();
4280
+ closeAuxiliaryServers();
4170
4281
  process.exit(1);
4171
4282
  });
4172
- let redirectServer = null;
4173
- if (isTls && proxyPort !== 80) {
4174
- redirectServer = createHttpRedirectServer(proxyPort);
4175
- redirectServer.on("error", () => {
4176
- redirectServer = null;
4283
+ const proto = isTls ? "HTTPS/2" : "HTTP";
4284
+ const tldLabel = tlds.length > 1 || tld !== DEFAULT_TLD ? ` (TLDs: ${formatTldList2(tlds)})` : "";
4285
+ const modeLabel = strict === false ? " (wildcard)" : "";
4286
+ for (const bindTarget of bindTargets.slice(1)) {
4287
+ const additionalServer = createServer2();
4288
+ additionalServers.add(additionalServer);
4289
+ additionalServer.on("error", (err) => {
4290
+ additionalServers.delete(additionalServer);
4291
+ if (!isUnavailableIpv6Bind(err, bindTarget.host)) {
4292
+ console.warn(
4293
+ colors_default.yellow(
4294
+ `Could not listen on ${formatBindEndpoint(bindTarget.host, proxyPort)}: ${err.message}`
4295
+ )
4296
+ );
4297
+ }
4298
+ });
4299
+ listenOnProxyInterface(additionalServer, proxyPort, bindTarget, () => {
4300
+ console.log(
4301
+ colors_default.green(
4302
+ `${proto} proxy listening on ${formatBindEndpoint(bindTarget.host, proxyPort)}${tldLabel}${modeLabel}`
4303
+ )
4304
+ );
4177
4305
  });
4178
- redirectServer.listen(80);
4179
4306
  }
4180
- server.listen(proxyPort, () => {
4181
- fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
4182
- fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
4307
+ if (isTls && proxyPort !== 80) {
4308
+ for (const bindTarget of bindTargets) {
4309
+ const redirectServer = createHttpRedirectServer(proxyPort);
4310
+ redirectServers.add(redirectServer);
4311
+ redirectServer.on("error", () => {
4312
+ redirectServers.delete(redirectServer);
4313
+ });
4314
+ listenOnProxyInterface(redirectServer, 80, bindTarget, () => {
4315
+ console.log(
4316
+ colors_default.green(
4317
+ `HTTP-to-HTTPS redirect listening on ${formatBindEndpoint(bindTarget.host, 80)}`
4318
+ )
4319
+ );
4320
+ });
4321
+ }
4322
+ }
4323
+ listenOnProxyInterface(server, proxyPort, primaryBindTarget, () => {
4324
+ fs10.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
4325
+ fs10.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
4183
4326
  writeTlsMarker(store.dir, isTls);
4184
4327
  writeCustomCertMarker(store.dir, isTls && customCert);
4185
4328
  writeTldsFile(store.dir, tlds);
4186
4329
  writeLanMarker(store.dir, activeLanIp);
4187
4330
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
4188
- const proto = isTls ? "HTTPS/2" : "HTTP";
4189
- const tldLabel = tlds.length > 1 || tld !== DEFAULT_TLD ? ` (TLDs: ${formatTldList2(tlds)})` : "";
4190
- const modeLabel = strict === false ? " (wildcard)" : "";
4191
4331
  console.log(
4192
- colors_default.green(`${proto} proxy listening on port ${proxyPort}${tldLabel}${modeLabel}`)
4332
+ colors_default.green(
4333
+ `${proto} proxy listening on ${formatBindEndpoint(primaryBindTarget.host, proxyPort)}${tldLabel}${modeLabel}`
4334
+ )
4193
4335
  );
4194
4336
  if (activeLanIp) {
4195
4337
  console.log(chalk.green(`LAN mode: ${activeLanIp}`));
@@ -4209,9 +4351,6 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4209
4351
  });
4210
4352
  }
4211
4353
  }
4212
- if (redirectServer) {
4213
- console.log(colors_default.green("HTTP-to-HTTPS redirect listening on port 80"));
4214
- }
4215
4354
  });
4216
4355
  let exiting = false;
4217
4356
  const cleanup = () => {
@@ -4224,15 +4363,13 @@ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict
4224
4363
  watcher.close();
4225
4364
  }
4226
4365
  if (activeLanIp) cleanupAll();
4227
- if (redirectServer) {
4228
- redirectServer.close();
4229
- }
4366
+ closeAuxiliaryServers();
4230
4367
  try {
4231
- fs9.unlinkSync(store.pidPath);
4368
+ fs10.unlinkSync(store.pidPath);
4232
4369
  } catch {
4233
4370
  }
4234
4371
  try {
4235
- fs9.unlinkSync(store.portFilePath);
4372
+ fs10.unlinkSync(store.portFilePath);
4236
4373
  } catch {
4237
4374
  }
4238
4375
  writeTlsMarker(store.dir, false);
@@ -4263,7 +4400,7 @@ function sudoStopOrHint(port) {
4263
4400
  }
4264
4401
  async function stopProxy(store, proxyPort, _tls) {
4265
4402
  const pidPath = store.pidPath;
4266
- if (!fs9.existsSync(pidPath)) {
4403
+ if (!fs10.existsSync(pidPath)) {
4267
4404
  if (await isProxyRunning(proxyPort)) {
4268
4405
  console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
4269
4406
  const pid = findPidOnPort(proxyPort);
@@ -4271,7 +4408,7 @@ async function stopProxy(store, proxyPort, _tls) {
4271
4408
  try {
4272
4409
  process.kill(pid, "SIGTERM");
4273
4410
  try {
4274
- fs9.unlinkSync(store.portFilePath);
4411
+ fs10.unlinkSync(store.portFilePath);
4275
4412
  } catch {
4276
4413
  }
4277
4414
  writeTlsMarker(store.dir, false);
@@ -4310,10 +4447,10 @@ async function stopProxy(store, proxyPort, _tls) {
4310
4447
  return;
4311
4448
  }
4312
4449
  try {
4313
- const pid = parseInt(fs9.readFileSync(pidPath, "utf-8"), 10);
4450
+ const pid = parseInt(fs10.readFileSync(pidPath, "utf-8"), 10);
4314
4451
  if (isNaN(pid)) {
4315
4452
  console.error(colors_default.red("Corrupted PID file. Removing it."));
4316
- fs9.unlinkSync(pidPath);
4453
+ fs10.unlinkSync(pidPath);
4317
4454
  writeTlsMarker(store.dir, false);
4318
4455
  writeCustomCertMarker(store.dir, false);
4319
4456
  writeTldFile(store.dir, DEFAULT_TLD);
@@ -4328,9 +4465,9 @@ async function stopProxy(store, proxyPort, _tls) {
4328
4465
  return;
4329
4466
  }
4330
4467
  console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
4331
- fs9.unlinkSync(pidPath);
4468
+ fs10.unlinkSync(pidPath);
4332
4469
  try {
4333
- fs9.unlinkSync(store.portFilePath);
4470
+ fs10.unlinkSync(store.portFilePath);
4334
4471
  } catch {
4335
4472
  }
4336
4473
  writeTlsMarker(store.dir, false);
@@ -4346,7 +4483,7 @@ async function stopProxy(store, proxyPort, _tls) {
4346
4483
  )
4347
4484
  );
4348
4485
  console.log(colors_default.yellow("Removing stale PID file."));
4349
- fs9.unlinkSync(pidPath);
4486
+ fs10.unlinkSync(pidPath);
4350
4487
  writeTlsMarker(store.dir, false);
4351
4488
  writeCustomCertMarker(store.dir, false);
4352
4489
  writeTldFile(store.dir, DEFAULT_TLD);
@@ -4354,9 +4491,9 @@ async function stopProxy(store, proxyPort, _tls) {
4354
4491
  return;
4355
4492
  }
4356
4493
  process.kill(pid, "SIGTERM");
4357
- fs9.unlinkSync(pidPath);
4494
+ fs10.unlinkSync(pidPath);
4358
4495
  try {
4359
- fs9.unlinkSync(store.portFilePath);
4496
+ fs10.unlinkSync(store.portFilePath);
4360
4497
  } catch {
4361
4498
  }
4362
4499
  writeTlsMarker(store.dir, false);
@@ -4507,7 +4644,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
4507
4644
  const logPath = path9.join(fallbackDir, "proxy.log");
4508
4645
  console.error(colors_default.blue("Try starting it manually:"));
4509
4646
  console.error(colors_default.cyan(` ${manualStartCommand}`));
4510
- if (fs9.existsSync(logPath)) {
4647
+ if (fs10.existsSync(logPath)) {
4511
4648
  console.error(colors_default.gray(`Logs: ${logPath}`));
4512
4649
  }
4513
4650
  process.exit(1);
@@ -4772,7 +4909,7 @@ portless
4772
4909
  const caEnv = {};
4773
4910
  if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
4774
4911
  const caPath = path9.join(stateDir, "ca.pem");
4775
- if (fs9.existsSync(caPath)) {
4912
+ if (fs10.existsSync(caPath)) {
4776
4913
  caEnv.NODE_EXTRA_CA_CERTS = caPath;
4777
4914
  }
4778
4915
  }
@@ -5051,15 +5188,20 @@ ${colors_default.bold("How it works:")}
5051
5188
  5. Frameworks that ignore PORT (Vite, VitePlus, Astro, React Router, Angular,
5052
5189
  Expo, React Native) get --port and, when needed, --host flags
5053
5190
  injected automatically
5191
+ 6. The proxy listens only on 127.0.0.1 and ::1 unless LAN mode is enabled
5192
+ Elevated proxy processes keep the invoking user's ~/.portless state directory.
5054
5193
 
5055
5194
  ${colors_default.bold("HTTP/2 + HTTPS (default):")}
5056
5195
  HTTPS with HTTP/2 multiplexing is enabled by default (faster page loads).
5057
5196
  On first use, portless generates a local CA and adds it to your
5058
5197
  system trust store. No browser warnings. Disable with --no-tls.
5198
+ On WSL, portless also adds the CA to the Windows user certificate store.
5059
5199
 
5060
5200
  ${colors_default.bold("LAN mode:")}
5061
5201
  Use --lan to make services accessible from other devices (phones,
5062
5202
  tablets) on the same WiFi network via mDNS (.local domains).
5203
+ Normal mode binds only to 127.0.0.1 and ::1.
5204
+ LAN mode binds to 0.0.0.0 and ::.
5063
5205
  Useful for testing React Native / Expo apps on real devices.
5064
5206
  Expo keeps Metro's default LAN host behavior in this mode.
5065
5207
  Auto-detected LAN IPs follow network changes automatically.
@@ -5157,13 +5299,13 @@ ${colors_default.bold("Reserved names:")}
5157
5299
  process.exit(0);
5158
5300
  }
5159
5301
  function printVersion() {
5160
- console.log("0.15.2");
5302
+ console.log("0.15.4");
5161
5303
  process.exit(0);
5162
5304
  }
5163
5305
  async function handleTrust() {
5164
5306
  const { dir } = await discoverState();
5165
- if (!fs9.existsSync(dir)) {
5166
- fs9.mkdirSync(dir, { recursive: true });
5307
+ if (!fs10.existsSync(dir)) {
5308
+ fs10.mkdirSync(dir, { recursive: true });
5167
5309
  }
5168
5310
  const { caGenerated } = ensureCerts(dir);
5169
5311
  if (caGenerated) {
@@ -5211,7 +5353,8 @@ directory, and PORTLESS_STATE_DIR when set), and removes the portless block
5211
5353
  from ${HOSTS_DISPLAY}.
5212
5354
 
5213
5355
  Only allowlisted filenames under each state directory are deleted. Custom
5214
- certificate paths from --cert and --key are never removed.
5356
+ certificate paths from --cert and --key are never removed. If trust removal
5357
+ fails, the CA certificate and key are retained so clean can safely retry.
5215
5358
 
5216
5359
  macOS/Linux may prompt for sudo when the proxy, trust store, or ${HOSTS_DISPLAY}
5217
5360
  require elevated privileges. On Windows, run as Administrator if needed.
@@ -5267,27 +5410,32 @@ ${colors_default.bold("Options:")}
5267
5410
  }
5268
5411
  }
5269
5412
  const stateDirs = collectStateDirsForCleanup();
5270
- for (const stateDir of stateDirs) {
5271
- const caPath = path9.join(stateDir, "ca.pem");
5272
- if (!fs9.existsSync(caPath)) continue;
5273
- const wasTrusted = isCATrusted(stateDir);
5274
- if (!wasTrusted) continue;
5275
- const untrustResult = untrustCA(stateDir);
5413
+ const failedCAStateDirs = /* @__PURE__ */ new Set();
5414
+ const caRemovalResults = attemptCATrustRemovalForCleanup(stateDirs, untrustCA);
5415
+ for (const [stateDir, untrustResult] of caRemovalResults) {
5276
5416
  if (untrustResult.removed) {
5277
5417
  console.log(colors_default.green("Removed local CA from the system trust store."));
5278
- } else if (untrustResult.error) {
5418
+ } else {
5419
+ failedCAStateDirs.add(stateDir);
5279
5420
  console.warn(
5280
5421
  colors_default.yellow(
5281
- `Could not remove CA from trust store: ${untrustResult.error}
5422
+ `Could not remove CA from trust store: ${untrustResult.error ?? "unknown error"}
5282
5423
  Try: sudo portless clean (Linux), or delete the certificate manually.`
5283
5424
  )
5284
5425
  );
5285
5426
  }
5286
5427
  }
5287
5428
  for (const stateDir of stateDirs) {
5288
- removePortlessStateFiles(stateDir);
5429
+ removePortlessStateFiles(stateDir, {
5430
+ preserveCAIdentity: failedCAStateDirs.has(stateDir)
5431
+ });
5289
5432
  }
5290
5433
  console.log(colors_default.green("Removed portless state files from known state directories."));
5434
+ if (failedCAStateDirs.size > 0) {
5435
+ console.warn(
5436
+ colors_default.yellow("Retained CA identity files so trust removal can be retried safely.")
5437
+ );
5438
+ }
5291
5439
  if (cleanHostsFile()) {
5292
5440
  console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
5293
5441
  } else if (!isWindows && process.getuid?.() !== 0) {
@@ -5613,7 +5761,7 @@ function isProcessAliveForDoctor(pid) {
5613
5761
  }
5614
5762
  function checkPathWritable(targetPath) {
5615
5763
  try {
5616
- fs9.accessSync(targetPath, fs9.constants.W_OK);
5764
+ fs10.accessSync(targetPath, fs10.constants.W_OK);
5617
5765
  return true;
5618
5766
  } catch {
5619
5767
  return false;
@@ -5622,7 +5770,7 @@ function checkPathWritable(targetPath) {
5622
5770
  function findExistingAncestor(targetPath) {
5623
5771
  let current = targetPath;
5624
5772
  for (; ; ) {
5625
- if (fs9.existsSync(current)) return current;
5773
+ if (fs10.existsSync(current)) return current;
5626
5774
  const parent = path9.dirname(current);
5627
5775
  if (parent === current) return null;
5628
5776
  current = parent;
@@ -5693,7 +5841,7 @@ ${colors_default.bold("Options:")}
5693
5841
  const store = new RouteStore(state.dir, {
5694
5842
  onWarning: (msg) => add("warn", msg)
5695
5843
  });
5696
- const hasPortFile = fs9.existsSync(store.portFilePath);
5844
+ const hasPortFile = fs10.existsSync(store.portFilePath);
5697
5845
  const configuredTls = hasPortFile ? state.tls : !isHttpsEnvDisabled();
5698
5846
  const configuredPort = hasPortFile ? state.port : getDefaultPort(configuredTls);
5699
5847
  const initialProxyRunning = await isProxyRunning(state.port, state.tls);
@@ -5705,9 +5853,9 @@ ${colors_default.bold("Options:")}
5705
5853
  const proxyTls = proxyRunning || portListening || hasPortFile ? probeTls : configuredTls;
5706
5854
  const currentProxyStateIsHttp = (proxyRunning || portListening || hasPortFile) && !proxyTls;
5707
5855
  const proxyUsesCustomCert = proxyTls && readCustomCertMarker(state.dir);
5708
- const stateExists = fs9.existsSync(state.dir);
5856
+ const stateExists = fs10.existsSync(state.dir);
5709
5857
  console.log(colors_default.blue.bold("\nportless doctor\n"));
5710
- console.log(`Version: ${"0.15.2"}`);
5858
+ console.log(`Version: ${"0.15.4"}`);
5711
5859
  console.log(`Node.js: ${process.versions.node}`);
5712
5860
  console.log(`Platform: ${process.platform} ${process.arch}`);
5713
5861
  console.log(`State dir: ${state.dir}`);
@@ -5724,7 +5872,7 @@ ${colors_default.bold("Options:")}
5724
5872
  }
5725
5873
  if (stateExists) {
5726
5874
  try {
5727
- const stat = fs9.statSync(state.dir);
5875
+ const stat = fs10.statSync(state.dir);
5728
5876
  if (!stat.isDirectory()) {
5729
5877
  add("fail", `State path exists but is not a directory: ${state.dir}`);
5730
5878
  } else if (checkPathWritable(state.dir)) {
@@ -5744,7 +5892,7 @@ ${colors_default.bold("Options:")}
5744
5892
  `State directory does not exist and no writable ancestor was found: ${state.dir}`
5745
5893
  );
5746
5894
  } else {
5747
- const ancestorStat = fs9.statSync(ancestor);
5895
+ const ancestorStat = fs10.statSync(ancestor);
5748
5896
  if (!ancestorStat.isDirectory()) {
5749
5897
  add("fail", `State directory does not exist and ancestor is not a directory: ${ancestor}`);
5750
5898
  } else if (checkPathWritable(ancestor)) {
@@ -5770,9 +5918,9 @@ ${colors_default.bold("Options:")}
5770
5918
  doctorProxyStartHint(proxyPort, proxyTls)
5771
5919
  );
5772
5920
  }
5773
- if (fs9.existsSync(store.pidPath)) {
5921
+ if (fs10.existsSync(store.pidPath)) {
5774
5922
  try {
5775
- const rawPid = fs9.readFileSync(store.pidPath, "utf-8").trim();
5923
+ const rawPid = fs10.readFileSync(store.pidPath, "utf-8").trim();
5776
5924
  const pid = parseInt(rawPid, 10);
5777
5925
  if (isNaN(pid) || pid <= 0) {
5778
5926
  add("fail", `Proxy PID file is invalid: ${store.pidPath}`);
@@ -5822,7 +5970,7 @@ ${colors_default.bold("Options:")}
5822
5970
  add("info", "Generated local CA is not required for custom TLS certificates.");
5823
5971
  } else if (proxyTls) {
5824
5972
  const caPath = path9.join(state.dir, "ca.pem");
5825
- if (fs9.existsSync(caPath)) {
5973
+ if (fs10.existsSync(caPath)) {
5826
5974
  if (isCATrusted(state.dir)) {
5827
5975
  add("ok", "Local CA is trusted by the OS trust store.");
5828
5976
  } else {
@@ -5991,6 +6139,8 @@ ${colors_default.bold("Usage:")}
5991
6139
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
5992
6140
 
5993
6141
  ${colors_default.bold("LAN mode (--lan):")}
6142
+ Without LAN mode, the proxy listens only on 127.0.0.1 and ::1.
6143
+ LAN mode explicitly binds the proxy to 0.0.0.0 and ::.
5994
6144
  Makes services accessible from other devices on the same WiFi network
5995
6145
  via mDNS (.local domains). Useful for testing on real mobile devices.
5996
6146
  Auto-detects your LAN IP and follows changes automatically, or use
@@ -6247,7 +6397,7 @@ ${colors_default.bold("LAN mode (--lan):")}
6247
6397
  } else {
6248
6398
  console.error(colors_default.red("Proxy process started but is not responding."));
6249
6399
  const logPath2 = path9.join(resolveStateDir(proxyPort), "proxy.log");
6250
- if (fs9.existsSync(logPath2)) {
6400
+ if (fs10.existsSync(logPath2)) {
6251
6401
  console.error(colors_default.gray(`Logs: ${logPath2}`));
6252
6402
  }
6253
6403
  }
@@ -6286,8 +6436,8 @@ ${colors_default.bold("LAN mode (--lan):")}
6286
6436
  store.ensureDir();
6287
6437
  if (customCertPath && customKeyPath) {
6288
6438
  try {
6289
- const cert = fs9.readFileSync(customCertPath);
6290
- const key = fs9.readFileSync(customKeyPath);
6439
+ const cert = fs10.readFileSync(customCertPath);
6440
+ const key = fs10.readFileSync(customKeyPath);
6291
6441
  const certStr = cert.toString("utf-8");
6292
6442
  const keyStr = key.toString("utf-8");
6293
6443
  if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
@@ -6332,9 +6482,9 @@ ${colors_default.bold("LAN mode (--lan):")}
6332
6482
  console.warn(colors_default.cyan(" portless trust"));
6333
6483
  }
6334
6484
  }
6335
- const cert = fs9.readFileSync(certs.certPath);
6336
- const key = fs9.readFileSync(certs.keyPath);
6337
- const ca = fs9.readFileSync(certs.caPath);
6485
+ const cert = fs10.readFileSync(certs.certPath);
6486
+ const key = fs10.readFileSync(certs.keyPath);
6487
+ const ca = fs10.readFileSync(certs.caPath);
6338
6488
  tlsOptions = {
6339
6489
  cert,
6340
6490
  key,
@@ -6359,10 +6509,10 @@ ${colors_default.bold("LAN mode (--lan):")}
6359
6509
  }
6360
6510
  store.ensureDir();
6361
6511
  const logPath = path9.join(stateDir, "proxy.log");
6362
- const logFd = fs9.openSync(logPath, "a");
6512
+ const logFd = fs10.openSync(logPath, "a");
6363
6513
  try {
6364
6514
  try {
6365
- fs9.chmodSync(logPath, FILE_MODE);
6515
+ fs10.chmodSync(logPath, FILE_MODE);
6366
6516
  } catch {
6367
6517
  }
6368
6518
  fixOwnership(logPath);
@@ -6394,13 +6544,13 @@ ${colors_default.bold("LAN mode (--lan):")}
6394
6544
  });
6395
6545
  child.unref();
6396
6546
  } finally {
6397
- fs9.closeSync(logFd);
6547
+ fs10.closeSync(logFd);
6398
6548
  }
6399
6549
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
6400
6550
  console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
6401
6551
  console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
6402
6552
  console.error(colors_default.cyan(" portless proxy start --foreground"));
6403
- if (fs9.existsSync(logPath)) {
6553
+ if (fs10.existsSync(logPath)) {
6404
6554
  console.error(colors_default.gray(`Logs: ${logPath}`));
6405
6555
  }
6406
6556
  process.exit(1);
@@ -6553,7 +6703,7 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tlds, exitCodes)
6553
6703
  };
6554
6704
  if (tls2) {
6555
6705
  const caPath = path9.join(stateDir, "ca.pem");
6556
- if (fs9.existsSync(caPath)) {
6706
+ if (fs10.existsSync(caPath)) {
6557
6707
  env.NODE_EXTRA_CA_CERTS = caPath;
6558
6708
  }
6559
6709
  }
@@ -6743,7 +6893,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tlds, scriptName,
6743
6893
  };
6744
6894
  if (tls2) {
6745
6895
  const caPath = path9.join(stateDir, "ca.pem");
6746
- if (fs9.existsSync(caPath)) {
6896
+ if (fs10.existsSync(caPath)) {
6747
6897
  entry.NODE_EXTRA_CA_CERTS = caPath;
6748
6898
  }
6749
6899
  }