portless 0.4.2 → 0.5.1

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
@@ -4,6 +4,7 @@ import {
4
4
  PRIVILEGED_PORT_THRESHOLD,
5
5
  RouteConflictError,
6
6
  RouteStore,
7
+ cleanHostsFile,
7
8
  createProxyServer,
8
9
  discoverState,
9
10
  findFreePort,
@@ -20,14 +21,15 @@ import {
20
21
  readTlsMarker,
21
22
  resolveStateDir,
22
23
  spawnCommand,
24
+ syncHostsFile,
23
25
  waitForProxy,
24
26
  writeTlsMarker
25
- } from "./chunk-JMRTQAVX.js";
27
+ } from "./chunk-P3DHZHEZ.js";
26
28
 
27
29
  // src/cli.ts
28
30
  import chalk from "chalk";
29
- import * as fs2 from "fs";
30
- import * as path2 from "path";
31
+ import * as fs3 from "fs";
32
+ import * as path3 from "path";
31
33
  import { spawn, spawnSync } from "child_process";
32
34
 
33
35
  // src/certs.ts
@@ -246,8 +248,50 @@ function loginKeychainPath() {
246
248
  const home = process.env.HOME || `/Users/${process.env.USER || "unknown"}`;
247
249
  return path.join(home, "Library", "Keychains", "login.keychain-db");
248
250
  }
251
+ var LINUX_CA_TRUST_CONFIGS = {
252
+ debian: {
253
+ certDir: "/usr/local/share/ca-certificates",
254
+ updateCommand: "update-ca-certificates"
255
+ },
256
+ arch: {
257
+ certDir: "/etc/ca-certificates/trust-source/anchors",
258
+ updateCommand: "update-ca-trust"
259
+ },
260
+ fedora: {
261
+ certDir: "/etc/pki/ca-trust/source/anchors",
262
+ updateCommand: "update-ca-trust"
263
+ },
264
+ suse: {
265
+ certDir: "/etc/pki/trust/anchors",
266
+ updateCommand: "update-ca-certificates"
267
+ }
268
+ };
269
+ function detectLinuxDistro() {
270
+ try {
271
+ const osRelease = fs.readFileSync("/etc/os-release", "utf-8").toLowerCase();
272
+ if (osRelease.includes("arch")) return "arch";
273
+ if (osRelease.includes("fedora") || osRelease.includes("rhel") || osRelease.includes("centos"))
274
+ return "fedora";
275
+ if (osRelease.includes("suse")) return "suse";
276
+ if (osRelease.includes("debian") || osRelease.includes("ubuntu")) return "debian";
277
+ } catch {
278
+ }
279
+ for (const [distro, config] of Object.entries(LINUX_CA_TRUST_CONFIGS)) {
280
+ try {
281
+ execFileSync("which", [config.updateCommand], { stdio: "pipe", timeout: 5e3 });
282
+ if (fs.existsSync(path.dirname(config.certDir))) return distro;
283
+ } catch {
284
+ }
285
+ }
286
+ return void 0;
287
+ }
288
+ function getLinuxCATrustConfig() {
289
+ const distro = detectLinuxDistro();
290
+ return LINUX_CA_TRUST_CONFIGS[distro ?? "debian"];
291
+ }
249
292
  function isCATrustedLinux(stateDir) {
250
- const systemCertPath = `/usr/local/share/ca-certificates/portless-ca.crt`;
293
+ const config = getLinuxCATrustConfig();
294
+ const systemCertPath = path.join(config.certDir, "portless-ca.crt");
251
295
  if (!fileExists(systemCertPath)) return false;
252
296
  try {
253
297
  const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
@@ -386,9 +430,13 @@ function trustCA(stateDir) {
386
430
  );
387
431
  return { trusted: true };
388
432
  } else if (process.platform === "linux") {
389
- const dest = "/usr/local/share/ca-certificates/portless-ca.crt";
433
+ const config = getLinuxCATrustConfig();
434
+ if (!fs.existsSync(config.certDir)) {
435
+ fs.mkdirSync(config.certDir, { recursive: true });
436
+ }
437
+ const dest = path.join(config.certDir, "portless-ca.crt");
390
438
  fs.copyFileSync(caCertPath, dest);
391
- execFileSync("update-ca-certificates", [], { stdio: "pipe", timeout: 3e4 });
439
+ execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
392
440
  return { trusted: true };
393
441
  }
394
442
  return { trusted: false, error: `Unsupported platform: ${process.platform}` };
@@ -404,6 +452,151 @@ function trustCA(stateDir) {
404
452
  }
405
453
  }
406
454
 
455
+ // src/auto.ts
456
+ import { execFileSync as execFileSync2 } from "child_process";
457
+ import * as fs2 from "fs";
458
+ import * as path2 from "path";
459
+ function sanitizeForHostname(name) {
460
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
461
+ }
462
+ function inferProjectName(cwd = process.cwd()) {
463
+ const pkgResult = findPackageJsonName(cwd);
464
+ if (pkgResult) {
465
+ const sanitized2 = sanitizeForHostname(pkgResult);
466
+ if (sanitized2) {
467
+ return { name: sanitized2, source: "package.json" };
468
+ }
469
+ }
470
+ const gitRoot = findGitRoot(cwd);
471
+ if (gitRoot) {
472
+ const sanitized2 = sanitizeForHostname(path2.basename(gitRoot));
473
+ if (sanitized2) {
474
+ return { name: sanitized2, source: "git root" };
475
+ }
476
+ }
477
+ const sanitized = sanitizeForHostname(path2.basename(cwd));
478
+ if (sanitized) {
479
+ return { name: sanitized, source: "directory name" };
480
+ }
481
+ throw new Error("Could not infer a project name from package.json, git root, or directory name");
482
+ }
483
+ function findPackageJsonName(startDir) {
484
+ let dir = startDir;
485
+ for (; ; ) {
486
+ const pkgPath = path2.join(dir, "package.json");
487
+ try {
488
+ const raw = fs2.readFileSync(pkgPath, "utf-8");
489
+ const pkg = JSON.parse(raw);
490
+ if (typeof pkg.name === "string" && pkg.name) {
491
+ return pkg.name.replace(/^@[^/]+\//, "");
492
+ }
493
+ } catch {
494
+ }
495
+ const parent = path2.dirname(dir);
496
+ if (parent === dir) break;
497
+ dir = parent;
498
+ }
499
+ return null;
500
+ }
501
+ function findGitRoot(startDir) {
502
+ try {
503
+ const toplevel = execFileSync2("git", ["rev-parse", "--show-toplevel"], {
504
+ cwd: startDir,
505
+ encoding: "utf-8",
506
+ timeout: 5e3,
507
+ stdio: ["ignore", "pipe", "ignore"]
508
+ }).trim();
509
+ if (toplevel) return toplevel;
510
+ } catch {
511
+ }
512
+ let dir = startDir;
513
+ for (; ; ) {
514
+ const gitPath = path2.join(dir, ".git");
515
+ try {
516
+ const stat = fs2.statSync(gitPath);
517
+ if (stat.isDirectory()) return dir;
518
+ if (stat.isFile()) return dir;
519
+ } catch {
520
+ }
521
+ const parent = path2.dirname(dir);
522
+ if (parent === dir) break;
523
+ dir = parent;
524
+ }
525
+ return null;
526
+ }
527
+ var DEFAULT_BRANCHES = /* @__PURE__ */ new Set(["main", "master"]);
528
+ function branchToPrefix(branch) {
529
+ if (!branch || branch === "HEAD" || DEFAULT_BRANCHES.has(branch)) return null;
530
+ const lastSegment = branch.split("/").pop();
531
+ const prefix = sanitizeForHostname(lastSegment);
532
+ return prefix || null;
533
+ }
534
+ function detectWorktreePrefix(cwd = process.cwd()) {
535
+ const cliResult = detectWorktreeViaCli(cwd);
536
+ if (cliResult !== void 0) return cliResult;
537
+ return detectWorktreeViaFilesystem(cwd);
538
+ }
539
+ function detectWorktreeViaCli(cwd) {
540
+ try {
541
+ const listOutput = execFileSync2("git", ["worktree", "list", "--porcelain"], {
542
+ cwd,
543
+ encoding: "utf-8",
544
+ timeout: 5e3,
545
+ stdio: ["ignore", "pipe", "ignore"]
546
+ });
547
+ const worktreeCount = listOutput.split("\n").filter((l) => l.startsWith("worktree ")).length;
548
+ if (worktreeCount <= 1) return null;
549
+ const branch = execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
550
+ cwd,
551
+ encoding: "utf-8",
552
+ timeout: 5e3,
553
+ stdio: ["ignore", "pipe", "ignore"]
554
+ }).trim();
555
+ const prefix = branchToPrefix(branch);
556
+ if (!prefix) return null;
557
+ return { prefix, source: "git branch" };
558
+ } catch {
559
+ return void 0;
560
+ }
561
+ }
562
+ function detectWorktreeViaFilesystem(startDir) {
563
+ let dir = startDir;
564
+ for (; ; ) {
565
+ const gitPath = path2.join(dir, ".git");
566
+ try {
567
+ const stat = fs2.statSync(gitPath);
568
+ if (stat.isDirectory()) {
569
+ return null;
570
+ }
571
+ if (stat.isFile()) {
572
+ const content = fs2.readFileSync(gitPath, "utf-8").trim();
573
+ const match = content.match(/^gitdir:\s*(.+)$/);
574
+ if (!match) return null;
575
+ const gitdir = match[1];
576
+ if (!gitdir.match(/\/worktrees\/[^/]+$/)) return null;
577
+ const branch = readBranchFromHead(path2.resolve(dir, gitdir));
578
+ const prefix = branchToPrefix(branch ?? "");
579
+ if (!prefix) return null;
580
+ return { prefix, source: "git branch" };
581
+ }
582
+ } catch {
583
+ }
584
+ const parent = path2.dirname(dir);
585
+ if (parent === dir) break;
586
+ dir = parent;
587
+ }
588
+ return null;
589
+ }
590
+ function readBranchFromHead(gitdir) {
591
+ try {
592
+ const head = fs2.readFileSync(path2.join(gitdir, "HEAD"), "utf-8").trim();
593
+ const refMatch = head.match(/^ref: refs\/heads\/(.+)$/);
594
+ return refMatch ? refMatch[1] : null;
595
+ } catch {
596
+ return null;
597
+ }
598
+ }
599
+
407
600
  // src/cli.ts
408
601
  var DEBOUNCE_MS = 100;
409
602
  var POLL_INTERVAL_MS = 3e3;
@@ -413,11 +606,11 @@ function startProxyServer(store, proxyPort, tlsOptions) {
413
606
  store.ensureDir();
414
607
  const isTls = !!tlsOptions;
415
608
  const routesPath = store.getRoutesPath();
416
- if (!fs2.existsSync(routesPath)) {
417
- fs2.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
609
+ if (!fs3.existsSync(routesPath)) {
610
+ fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
418
611
  }
419
612
  try {
420
- fs2.chmodSync(routesPath, FILE_MODE);
613
+ fs3.chmodSync(routesPath, FILE_MODE);
421
614
  } catch {
422
615
  }
423
616
  fixOwnership(routesPath);
@@ -425,14 +618,18 @@ function startProxyServer(store, proxyPort, tlsOptions) {
425
618
  let debounceTimer = null;
426
619
  let watcher = null;
427
620
  let pollingInterval = null;
621
+ const autoSyncHosts = process.env.PORTLESS_SYNC_HOSTS === "1";
428
622
  const reloadRoutes = () => {
429
623
  try {
430
624
  cachedRoutes = store.loadRoutes();
625
+ if (autoSyncHosts) {
626
+ syncHostsFile(cachedRoutes.map((r) => r.hostname));
627
+ }
431
628
  } catch {
432
629
  }
433
630
  };
434
631
  try {
435
- watcher = fs2.watch(routesPath, () => {
632
+ watcher = fs3.watch(routesPath, () => {
436
633
  if (debounceTimer) clearTimeout(debounceTimer);
437
634
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
438
635
  });
@@ -440,6 +637,9 @@ function startProxyServer(store, proxyPort, tlsOptions) {
440
637
  console.warn(chalk.yellow("fs.watch unavailable; falling back to polling for route changes"));
441
638
  pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
442
639
  }
640
+ if (autoSyncHosts) {
641
+ syncHostsFile(cachedRoutes.map((r) => r.hostname));
642
+ }
443
643
  const server = createProxyServer({
444
644
  getRoutes: () => cachedRoutes,
445
645
  proxyPort,
@@ -465,8 +665,8 @@ function startProxyServer(store, proxyPort, tlsOptions) {
465
665
  process.exit(1);
466
666
  });
467
667
  server.listen(proxyPort, () => {
468
- fs2.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
469
- fs2.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
668
+ fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
669
+ fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
470
670
  writeTlsMarker(store.dir, isTls);
471
671
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
472
672
  const proto = isTls ? "HTTPS/2" : "HTTP";
@@ -482,14 +682,15 @@ function startProxyServer(store, proxyPort, tlsOptions) {
482
682
  watcher.close();
483
683
  }
484
684
  try {
485
- fs2.unlinkSync(store.pidPath);
685
+ fs3.unlinkSync(store.pidPath);
486
686
  } catch {
487
687
  }
488
688
  try {
489
- fs2.unlinkSync(store.portFilePath);
689
+ fs3.unlinkSync(store.portFilePath);
490
690
  } catch {
491
691
  }
492
692
  writeTlsMarker(store.dir, false);
693
+ if (autoSyncHosts) cleanHostsFile();
493
694
  server.close(() => process.exit(0));
494
695
  setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
495
696
  };
@@ -502,7 +703,7 @@ async function stopProxy(store, proxyPort, tls2) {
502
703
  const pidPath = store.pidPath;
503
704
  const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
504
705
  const sudoHint = needsSudo ? "sudo " : "";
505
- if (!fs2.existsSync(pidPath)) {
706
+ if (!fs3.existsSync(pidPath)) {
506
707
  if (await isProxyRunning(proxyPort, tls2)) {
507
708
  console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
508
709
  const pid = findPidOnPort(proxyPort);
@@ -510,7 +711,7 @@ async function stopProxy(store, proxyPort, tls2) {
510
711
  try {
511
712
  process.kill(pid, "SIGTERM");
512
713
  try {
513
- fs2.unlinkSync(store.portFilePath);
714
+ fs3.unlinkSync(store.portFilePath);
514
715
  } catch {
515
716
  }
516
717
  console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
@@ -541,19 +742,19 @@ async function stopProxy(store, proxyPort, tls2) {
541
742
  return;
542
743
  }
543
744
  try {
544
- const pid = parseInt(fs2.readFileSync(pidPath, "utf-8"), 10);
745
+ const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
545
746
  if (isNaN(pid)) {
546
747
  console.error(chalk.red("Corrupted PID file. Removing it."));
547
- fs2.unlinkSync(pidPath);
748
+ fs3.unlinkSync(pidPath);
548
749
  return;
549
750
  }
550
751
  try {
551
752
  process.kill(pid, 0);
552
753
  } catch {
553
754
  console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
554
- fs2.unlinkSync(pidPath);
755
+ fs3.unlinkSync(pidPath);
555
756
  try {
556
- fs2.unlinkSync(store.portFilePath);
757
+ fs3.unlinkSync(store.portFilePath);
557
758
  } catch {
558
759
  }
559
760
  return;
@@ -565,13 +766,13 @@ async function stopProxy(store, proxyPort, tls2) {
565
766
  )
566
767
  );
567
768
  console.log(chalk.yellow("Removing stale PID file."));
568
- fs2.unlinkSync(pidPath);
769
+ fs3.unlinkSync(pidPath);
569
770
  return;
570
771
  }
571
772
  process.kill(pid, "SIGTERM");
572
- fs2.unlinkSync(pidPath);
773
+ fs3.unlinkSync(pidPath);
573
774
  try {
574
- fs2.unlinkSync(store.portFilePath);
775
+ fs3.unlinkSync(store.portFilePath);
575
776
  } catch {
576
777
  }
577
778
  console.log(chalk.green("Proxy stopped."));
@@ -598,18 +799,26 @@ function listRoutes(store, proxyPort, tls2) {
598
799
  console.log(chalk.blue.bold("\nActive routes:\n"));
599
800
  for (const route of routes) {
600
801
  const url = formatUrl(route.hostname, proxyPort, tls2);
802
+ const label = route.pid === 0 ? "(alias)" : `(pid ${route.pid})`;
601
803
  console.log(
602
- ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
804
+ ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(label)}`
603
805
  );
604
806
  }
605
807
  console.log();
606
808
  }
607
- async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force) {
809
+ async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force, autoInfo, desiredPort) {
608
810
  const hostname = parseHostname(name);
609
811
  console.log(chalk.blue.bold(`
610
812
  portless
611
813
  `));
612
814
  console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
815
+ if (autoInfo) {
816
+ const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name;
817
+ console.log(chalk.gray(`-- Name "${baseName}" (from ${autoInfo.nameSource})`));
818
+ if (autoInfo.prefix) {
819
+ console.log(chalk.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`));
820
+ }
821
+ }
613
822
  if (!await isProxyRunning(proxyPort, tls2)) {
614
823
  const defaultPort = getDefaultPort();
615
824
  const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
@@ -664,10 +873,10 @@ portless
664
873
  const autoTls = readTlsMarker(stateDir);
665
874
  if (!await waitForProxy(defaultPort, void 0, void 0, autoTls)) {
666
875
  console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
667
- const logPath = path2.join(stateDir, "proxy.log");
876
+ const logPath = path3.join(stateDir, "proxy.log");
668
877
  console.error(chalk.blue("Try starting the proxy manually to see the error:"));
669
878
  console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
670
- if (fs2.existsSync(logPath)) {
879
+ if (fs3.existsSync(logPath)) {
671
880
  console.error(chalk.gray(`Logs: ${logPath}`));
672
881
  }
673
882
  process.exit(1);
@@ -677,8 +886,12 @@ portless
677
886
  } else {
678
887
  console.log(chalk.gray("-- Proxy is running"));
679
888
  }
680
- const port = await findFreePort();
681
- console.log(chalk.green(`-- Using port ${port}`));
889
+ const port = desiredPort ?? await findFreePort();
890
+ if (desiredPort) {
891
+ console.log(chalk.green(`-- Using port ${port} (fixed)`));
892
+ } else {
893
+ console.log(chalk.green(`-- Using port ${port}`));
894
+ }
682
895
  try {
683
896
  store.addRoute(hostname, port, process.pid, force);
684
897
  } catch (err) {
@@ -693,13 +906,18 @@ portless
693
906
  -> ${finalUrl}
694
907
  `));
695
908
  injectFrameworkFlags(commandArgs, port);
696
- console.log(chalk.gray(`Running: PORT=${port} HOST=127.0.0.1 ${commandArgs.join(" ")}
697
- `));
909
+ console.log(
910
+ chalk.gray(
911
+ `Running: PORT=${port} HOST=127.0.0.1 PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
912
+ `
913
+ )
914
+ );
698
915
  spawnCommand(commandArgs, {
699
916
  env: {
700
917
  ...process.env,
701
918
  PORT: port.toString(),
702
919
  HOST: "127.0.0.1",
920
+ PORTLESS_URL: finalUrl,
703
921
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
704
922
  },
705
923
  onCleanup: () => {
@@ -710,31 +928,120 @@ portless
710
928
  }
711
929
  });
712
930
  }
713
- async function main() {
714
- if (process.stdin.isTTY) {
715
- process.on("exit", () => {
716
- try {
717
- process.stdin.setRawMode(false);
718
- } catch {
719
- }
720
- });
931
+ function parseAppPort(value) {
932
+ if (!value || value.startsWith("--")) {
933
+ console.error(chalk.red("Error: --app-port requires a port number."));
934
+ process.exit(1);
721
935
  }
722
- const args = process.argv.slice(2);
723
- const isNpx = process.env.npm_command === "exec" && !process.env.npm_lifecycle_event;
724
- const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
725
- if (isNpx || isPnpmDlx) {
726
- console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
727
- console.error(chalk.blue("Install globally instead:"));
728
- console.error(chalk.cyan(" npm install -g portless"));
936
+ const port = parseInt(value, 10);
937
+ if (isNaN(port) || port < 1 || port > 65535) {
938
+ console.error(chalk.red(`Error: Invalid app port "${value}". Must be 1-65535.`));
729
939
  process.exit(1);
730
940
  }
731
- const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
732
- if (skipPortless && args.length >= 2 && args[0] !== "proxy") {
733
- spawnCommand(args.slice(1));
734
- return;
941
+ return port;
942
+ }
943
+ function appPortFromEnv() {
944
+ const envVal = process.env.PORTLESS_APP_PORT;
945
+ if (!envVal) return void 0;
946
+ const port = parseInt(envVal, 10);
947
+ if (isNaN(port) || port < 1 || port > 65535) {
948
+ console.error(chalk.red(`Error: Invalid PORTLESS_APP_PORT="${envVal}". Must be 1-65535.`));
949
+ process.exit(1);
735
950
  }
736
- if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
737
- console.log(`
951
+ return port;
952
+ }
953
+ function parseRunArgs(args) {
954
+ let force = false;
955
+ let appPort;
956
+ let i = 0;
957
+ while (i < args.length && args[i].startsWith("-")) {
958
+ if (args[i] === "--") {
959
+ i++;
960
+ break;
961
+ } else if (args[i] === "--help" || args[i] === "-h") {
962
+ console.log(`
963
+ ${chalk.bold("portless run")} - Infer project name and run through the proxy.
964
+
965
+ ${chalk.bold("Usage:")}
966
+ ${chalk.cyan("portless run [options] <command...>")}
967
+
968
+ ${chalk.bold("Options:")}
969
+ --force Override an existing route registered by another process
970
+ --app-port <number> Use a fixed port for the app (skip auto-assignment)
971
+ --help, -h Show this help
972
+
973
+ ${chalk.bold("Name inference (in order):")}
974
+ 1. package.json "name" field (walks up directories)
975
+ 2. Git repo root directory name
976
+ 3. Current directory basename
977
+
978
+ In git worktrees, the branch name is prepended as a subdomain prefix
979
+ (e.g. feature-auth.myapp.localhost).
980
+
981
+ ${chalk.bold("Examples:")}
982
+ portless run next dev # -> http://<project>.localhost:1355
983
+ portless run vite dev # -> http://<project>.localhost:1355
984
+ portless run --app-port 3000 pnpm start
985
+ `);
986
+ process.exit(0);
987
+ } else if (args[i] === "--force") {
988
+ force = true;
989
+ } else if (args[i] === "--app-port") {
990
+ i++;
991
+ appPort = parseAppPort(args[i]);
992
+ } else {
993
+ console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
994
+ console.error(chalk.blue("Known flags: --force, --app-port, --help"));
995
+ process.exit(1);
996
+ }
997
+ i++;
998
+ }
999
+ if (!appPort) appPort = appPortFromEnv();
1000
+ return { force, appPort, commandArgs: args.slice(i) };
1001
+ }
1002
+ function parseAppArgs(args) {
1003
+ let force = false;
1004
+ let appPort;
1005
+ let i = 0;
1006
+ while (i < args.length && args[i].startsWith("-")) {
1007
+ if (args[i] === "--") {
1008
+ i++;
1009
+ break;
1010
+ } else if (args[i] === "--force") {
1011
+ force = true;
1012
+ } else if (args[i] === "--app-port") {
1013
+ i++;
1014
+ appPort = parseAppPort(args[i]);
1015
+ } else {
1016
+ console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1017
+ console.error(chalk.blue("Known flags: --force, --app-port"));
1018
+ process.exit(1);
1019
+ }
1020
+ i++;
1021
+ }
1022
+ const name = args[i];
1023
+ i++;
1024
+ while (i < args.length && args[i].startsWith("--")) {
1025
+ if (args[i] === "--") {
1026
+ i++;
1027
+ break;
1028
+ } else if (args[i] === "--force") {
1029
+ force = true;
1030
+ } else if (args[i] === "--app-port") {
1031
+ i++;
1032
+ appPort = parseAppPort(args[i]);
1033
+ } else {
1034
+ console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1035
+ console.error(chalk.blue("Known flags: --force, --app-port"));
1036
+ process.exit(1);
1037
+ }
1038
+ i++;
1039
+ }
1040
+ if (!appPort) appPort = appPortFromEnv();
1041
+ return { force, appPort, name, commandArgs: args.slice(i) };
1042
+ }
1043
+ function printHelp() {
1044
+ console.log(`
738
1045
  ${chalk.bold("portless")} - Replace port numbers with stable, named .localhost URLs. For humans and agents.
739
1046
 
740
1047
  Eliminates port conflicts, memorizing port numbers, and cookie/storage
@@ -750,8 +1057,13 @@ ${chalk.bold("Usage:")}
750
1057
  ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
751
1058
  ${chalk.cyan("portless proxy stop")} Stop the proxy
752
1059
  ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
1060
+ ${chalk.cyan("portless run <cmd>")} Infer name from project, run through proxy
1061
+ ${chalk.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
1062
+ ${chalk.cyan("portless alias --remove <name>")} Remove a static route
753
1063
  ${chalk.cyan("portless list")} Show active routes
754
1064
  ${chalk.cyan("portless trust")} Add local CA to system trust store
1065
+ ${chalk.cyan("portless hosts sync")} Add routes to /etc/hosts (fixes Safari)
1066
+ ${chalk.cyan("portless hosts clean")} Remove portless entries from /etc/hosts
755
1067
 
756
1068
  ${chalk.bold("Examples:")}
757
1069
  portless proxy start # Start proxy on port 1355
@@ -759,21 +1071,25 @@ ${chalk.bold("Examples:")}
759
1071
  portless myapp next dev # -> http://myapp.localhost:1355
760
1072
  portless myapp vite dev # -> http://myapp.localhost:1355
761
1073
  portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
1074
+ portless run next dev # -> http://<project>.localhost:1355
1075
+ portless run next dev # in worktree -> http://<worktree>.<project>.localhost:1355
1076
+ # Wildcard subdomains: tenant.myapp.localhost also routes to myapp
762
1077
 
763
1078
  ${chalk.bold("In package.json:")}
764
1079
  {
765
1080
  "scripts": {
766
- "dev": "portless myapp next dev"
1081
+ "dev": "portless run next dev"
767
1082
  }
768
1083
  }
769
1084
 
770
1085
  ${chalk.bold("How it works:")}
771
1086
  1. Start the proxy once (listens on port 1355 by default, no sudo needed)
772
1087
  2. Run your apps - they auto-start the proxy and register automatically
1088
+ (apps get a random port in the 4000-4999 range via PORT)
773
1089
  3. Access via http://<name>.localhost:1355
774
1090
  4. .localhost domains auto-resolve to 127.0.0.1
775
- 5. Frameworks that ignore PORT (Vite, Astro, React Router, Angular) get
776
- --port and --host flags injected automatically
1091
+ 5. Frameworks that ignore PORT (Vite, Astro, React Router, Angular,
1092
+ Expo, React Native) get --port and --host flags injected automatically
777
1093
 
778
1094
  ${chalk.bold("HTTP/2 + HTTPS:")}
779
1095
  Use --https for HTTP/2 multiplexing (faster dev server page loads).
@@ -781,6 +1097,8 @@ ${chalk.bold("HTTP/2 + HTTPS:")}
781
1097
  system trust store. No browser warnings. No sudo required on macOS.
782
1098
 
783
1099
  ${chalk.bold("Options:")}
1100
+ run <cmd> Infer project name from package.json / git / cwd
1101
+ Adds worktree prefix in git worktrees
784
1102
  -p, --port <number> Port for the proxy to listen on (default: 1355)
785
1103
  Ports < 1024 require sudo
786
1104
  --https Enable HTTP/2 + TLS with auto-generated certs
@@ -788,252 +1106,423 @@ ${chalk.bold("Options:")}
788
1106
  --key <path> Use a custom TLS private key (implies --https)
789
1107
  --no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
790
1108
  --foreground Run proxy in foreground (for debugging)
1109
+ --app-port <number> Use a fixed port for the app (skip auto-assignment)
791
1110
  --force Override an existing route registered by another process
1111
+ --name <name> Use <name> as the app name (bypasses subcommand dispatch)
1112
+ -- Stop flag parsing; everything after is passed to the child
792
1113
 
793
1114
  ${chalk.bold("Environment variables:")}
794
1115
  PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
795
- PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
1116
+ PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
1117
+ PORTLESS_HTTPS=1|true Always enable HTTPS (set in .bashrc / .zshrc)
1118
+ PORTLESS_SYNC_HOSTS=1 Auto-sync /etc/hosts (requires sudo proxy start)
796
1119
  PORTLESS_STATE_DIR=<path> Override the state directory
797
1120
  PORTLESS=0 | PORTLESS=skip Run command directly without proxy
798
1121
 
1122
+ ${chalk.bold("Child process environment:")}
1123
+ PORT Ephemeral port the child should listen on
1124
+ HOST Always 127.0.0.1
1125
+ PORTLESS_URL Public URL of the app (e.g. http://myapp.localhost:1355)
1126
+
1127
+ ${chalk.bold("Safari / DNS:")}
1128
+ .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1129
+ Safari relies on the system DNS resolver, which may not handle them.
1130
+ If Safari can't find your .localhost URL, run:
1131
+ ${chalk.cyan("sudo portless hosts sync")}
1132
+ This adds entries to /etc/hosts. Clean up later with:
1133
+ ${chalk.cyan("sudo portless hosts clean")}
1134
+ To auto-sync whenever routes change, set PORTLESS_SYNC_HOSTS=1 and
1135
+ start the proxy with sudo.
1136
+
799
1137
  ${chalk.bold("Skip portless:")}
800
1138
  PORTLESS=0 pnpm dev # Runs command directly without proxy
801
1139
  PORTLESS=skip pnpm dev # Same as above
1140
+
1141
+ ${chalk.bold("Reserved names:")}
1142
+ run, alias, hosts, list, trust, proxy are subcommands and cannot be
1143
+ used as app names directly. Use "portless run" to infer the name, or
1144
+ "portless --name <name>" to force any name including reserved ones.
1145
+ `);
1146
+ process.exit(0);
1147
+ }
1148
+ function printVersion() {
1149
+ console.log("0.5.1");
1150
+ process.exit(0);
1151
+ }
1152
+ async function handleTrust() {
1153
+ const { dir } = await discoverState();
1154
+ const result = trustCA(dir);
1155
+ if (result.trusted) {
1156
+ console.log(chalk.green("Local CA added to system trust store."));
1157
+ console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
1158
+ } else {
1159
+ console.error(chalk.red(`Failed to trust CA: ${result.error}`));
1160
+ if (result.error?.includes("sudo")) {
1161
+ console.error(chalk.blue("Run with sudo:"));
1162
+ console.error(chalk.cyan(" sudo portless trust"));
1163
+ }
1164
+ process.exit(1);
1165
+ }
1166
+ }
1167
+ async function handleList() {
1168
+ const { dir, port, tls: tls2 } = await discoverState();
1169
+ const store = new RouteStore(dir, {
1170
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1171
+ });
1172
+ listRoutes(store, port, tls2);
1173
+ }
1174
+ async function handleAlias(args) {
1175
+ if (args[1] === "--help" || args[1] === "-h") {
1176
+ console.log(`
1177
+ ${chalk.bold("portless alias")} - Register a static route for services not managed by portless.
1178
+
1179
+ ${chalk.bold("Usage:")}
1180
+ ${chalk.cyan("portless alias <name> <port>")} Register a route
1181
+ ${chalk.cyan("portless alias --remove <name>")} Remove a route
1182
+ ${chalk.cyan("portless alias <name> <port> --force")} Override existing route
1183
+
1184
+ ${chalk.bold("Examples:")}
1185
+ portless alias my-postgres 5432 # -> http://my-postgres.localhost:1355
1186
+ portless alias redis 6379 # -> http://redis.localhost:1355
1187
+ portless alias --remove my-postgres # Remove the alias
802
1188
  `);
803
1189
  process.exit(0);
804
1190
  }
805
- if (args[0] === "--version" || args[0] === "-v") {
806
- console.log("0.4.2");
1191
+ const { dir } = await discoverState();
1192
+ const store = new RouteStore(dir, {
1193
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1194
+ });
1195
+ if (args[1] === "--remove") {
1196
+ const aliasName2 = args[2];
1197
+ if (!aliasName2) {
1198
+ console.error(chalk.red("Error: No alias name provided."));
1199
+ console.error(chalk.cyan(" portless alias --remove <name>"));
1200
+ process.exit(1);
1201
+ }
1202
+ const hostname2 = parseHostname(aliasName2);
1203
+ const routes = store.loadRoutes();
1204
+ const existing = routes.find((r) => r.hostname === hostname2 && r.pid === 0);
1205
+ if (!existing) {
1206
+ console.error(chalk.red(`Error: No alias found for "${hostname2}".`));
1207
+ process.exit(1);
1208
+ }
1209
+ store.removeRoute(hostname2);
1210
+ console.log(chalk.green(`Removed alias: ${hostname2}`));
1211
+ return;
1212
+ }
1213
+ const aliasName = args[1];
1214
+ const aliasPort = args[2];
1215
+ if (!aliasName || !aliasPort) {
1216
+ console.error(chalk.red("Error: Missing arguments."));
1217
+ console.error(chalk.blue("Usage:"));
1218
+ console.error(chalk.cyan(" portless alias <name> <port>"));
1219
+ console.error(chalk.cyan(" portless alias --remove <name>"));
1220
+ console.error(chalk.blue("Example:"));
1221
+ console.error(chalk.cyan(" portless alias my-postgres 5432"));
1222
+ process.exit(1);
1223
+ }
1224
+ const hostname = parseHostname(aliasName);
1225
+ const port = parseInt(aliasPort, 10);
1226
+ if (isNaN(port) || port < 1 || port > 65535) {
1227
+ console.error(chalk.red(`Error: Invalid port "${aliasPort}". Must be 1-65535.`));
1228
+ process.exit(1);
1229
+ }
1230
+ const force = args.includes("--force");
1231
+ store.addRoute(hostname, port, 0, force);
1232
+ console.log(chalk.green(`Alias registered: ${hostname} -> localhost:${port}`));
1233
+ }
1234
+ async function handleHosts(args) {
1235
+ if (args[1] === "--help" || args[1] === "-h") {
1236
+ console.log(`
1237
+ ${chalk.bold("portless hosts")} - Manage /etc/hosts entries for .localhost subdomains.
1238
+
1239
+ Safari relies on the system DNS resolver, which may not handle .localhost
1240
+ subdomains. This command adds entries to /etc/hosts as a workaround.
1241
+
1242
+ ${chalk.bold("Usage:")}
1243
+ ${chalk.cyan("sudo portless hosts sync")} Add current routes to /etc/hosts
1244
+ ${chalk.cyan("sudo portless hosts clean")} Remove portless entries from /etc/hosts
1245
+
1246
+ ${chalk.bold("Auto-sync:")}
1247
+ Set PORTLESS_SYNC_HOSTS=1 and start the proxy with sudo to auto-sync
1248
+ /etc/hosts whenever routes change.
1249
+ `);
807
1250
  process.exit(0);
808
1251
  }
809
- if (args[0] === "trust") {
810
- const { dir: dir2 } = await discoverState();
811
- const result = trustCA(dir2);
812
- if (result.trusted) {
813
- console.log(chalk.green("Local CA added to system trust store."));
814
- console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
1252
+ if (args[1] === "clean") {
1253
+ if (cleanHostsFile()) {
1254
+ console.log(chalk.green("Removed portless entries from /etc/hosts."));
815
1255
  } else {
816
- console.error(chalk.red(`Failed to trust CA: ${result.error}`));
817
- if (result.error?.includes("sudo")) {
818
- console.error(chalk.blue("Run with sudo:"));
819
- console.error(chalk.cyan(" sudo portless trust"));
820
- }
1256
+ console.error(chalk.red("Failed to update /etc/hosts (requires sudo)."));
1257
+ console.error(chalk.cyan(" sudo portless hosts clean"));
821
1258
  process.exit(1);
822
1259
  }
823
1260
  return;
824
1261
  }
825
- if (args[0] === "list") {
826
- const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
827
- const store2 = new RouteStore(dir2, {
1262
+ if (!args[1]) {
1263
+ console.log(`
1264
+ ${chalk.bold("Usage: portless hosts <command>")}
1265
+
1266
+ ${chalk.cyan("sudo portless hosts sync")} Add current routes to /etc/hosts
1267
+ ${chalk.cyan("sudo portless hosts clean")} Remove portless entries from /etc/hosts
1268
+ `);
1269
+ process.exit(0);
1270
+ }
1271
+ if (args[1] !== "sync") {
1272
+ console.error(chalk.red(`Error: Unknown hosts subcommand "${args[1]}".`));
1273
+ console.error(chalk.blue("Usage:"));
1274
+ console.error(chalk.cyan(" portless hosts sync # Add routes to /etc/hosts"));
1275
+ console.error(chalk.cyan(" portless hosts clean # Remove portless entries"));
1276
+ process.exit(1);
1277
+ }
1278
+ const { dir } = await discoverState();
1279
+ const store = new RouteStore(dir, {
1280
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1281
+ });
1282
+ const routes = store.loadRoutes();
1283
+ if (routes.length === 0) {
1284
+ console.log(chalk.yellow("No active routes to sync."));
1285
+ return;
1286
+ }
1287
+ const hostnames = routes.map((r) => r.hostname);
1288
+ if (syncHostsFile(hostnames)) {
1289
+ console.log(chalk.green(`Synced ${hostnames.length} hostname(s) to /etc/hosts:`));
1290
+ for (const h of hostnames) {
1291
+ console.log(chalk.cyan(` 127.0.0.1 ${h}`));
1292
+ }
1293
+ } else {
1294
+ console.error(chalk.red("Failed to update /etc/hosts (requires sudo)."));
1295
+ console.error(chalk.cyan(" sudo portless hosts sync"));
1296
+ process.exit(1);
1297
+ }
1298
+ }
1299
+ async function handleProxy(args) {
1300
+ if (args[1] === "stop") {
1301
+ const { dir, port, tls: tls2 } = await discoverState();
1302
+ const store2 = new RouteStore(dir, {
828
1303
  onWarning: (msg) => console.warn(chalk.yellow(msg))
829
1304
  });
830
- listRoutes(store2, port2, tls3);
1305
+ await stopProxy(store2, port, tls2);
831
1306
  return;
832
1307
  }
833
- if (args[0] === "proxy") {
834
- if (args[1] === "stop") {
835
- const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
836
- const store3 = new RouteStore(dir2, {
837
- onWarning: (msg) => console.warn(chalk.yellow(msg))
838
- });
839
- await stopProxy(store3, port2, tls3);
840
- return;
841
- }
842
- if (args[1] !== "start") {
843
- console.log(`
844
- ${chalk.bold("Usage: portless proxy <command>")}
1308
+ const isProxyHelp = args[1] === "--help" || args[1] === "-h";
1309
+ if (isProxyHelp || args[1] !== "start") {
1310
+ console.log(`
1311
+ ${chalk.bold("portless proxy")} - Manage the portless proxy server.
845
1312
 
1313
+ ${chalk.bold("Usage:")}
846
1314
  ${chalk.cyan("portless proxy start")} Start the proxy (daemon)
847
1315
  ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
848
1316
  ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
849
1317
  ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
850
1318
  ${chalk.cyan("portless proxy stop")} Stop the proxy
851
1319
  `);
852
- process.exit(args[1] ? 1 : 0);
853
- }
854
- const isForeground = args.includes("--foreground");
855
- let proxyPort = getDefaultPort();
856
- let portFlagIndex = args.indexOf("--port");
857
- if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
858
- if (portFlagIndex !== -1) {
859
- const portValue = args[portFlagIndex + 1];
860
- if (!portValue || portValue.startsWith("-")) {
861
- console.error(chalk.red("Error: --port / -p requires a port number."));
862
- console.error(chalk.blue("Usage:"));
863
- console.error(chalk.cyan(" portless proxy start -p 8080"));
864
- process.exit(1);
865
- }
866
- proxyPort = parseInt(portValue, 10);
867
- if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
868
- console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
869
- console.error(chalk.blue("Port must be between 1 and 65535."));
870
- process.exit(1);
871
- }
872
- }
873
- const hasNoTls = args.includes("--no-tls");
874
- const hasHttpsFlag = args.includes("--https");
875
- const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
876
- let customCertPath = null;
877
- let customKeyPath = null;
878
- const certIdx = args.indexOf("--cert");
879
- if (certIdx !== -1) {
880
- customCertPath = args[certIdx + 1] || null;
881
- if (!customCertPath || customCertPath.startsWith("-")) {
882
- console.error(chalk.red("Error: --cert requires a file path."));
883
- process.exit(1);
884
- }
885
- }
886
- const keyIdx = args.indexOf("--key");
887
- if (keyIdx !== -1) {
888
- customKeyPath = args[keyIdx + 1] || null;
889
- if (!customKeyPath || customKeyPath.startsWith("-")) {
890
- console.error(chalk.red("Error: --key requires a file path."));
891
- process.exit(1);
892
- }
893
- }
894
- if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
895
- console.error(chalk.red("Error: --cert and --key must be used together."));
1320
+ process.exit(isProxyHelp || !args[1] ? 0 : 1);
1321
+ }
1322
+ const isForeground = args.includes("--foreground");
1323
+ let proxyPort = getDefaultPort();
1324
+ let portFlagIndex = args.indexOf("--port");
1325
+ if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
1326
+ if (portFlagIndex !== -1) {
1327
+ const portValue = args[portFlagIndex + 1];
1328
+ if (!portValue || portValue.startsWith("-")) {
1329
+ console.error(chalk.red("Error: --port / -p requires a port number."));
1330
+ console.error(chalk.blue("Usage:"));
1331
+ console.error(chalk.cyan(" portless proxy start -p 8080"));
896
1332
  process.exit(1);
897
1333
  }
898
- const useHttps = wantHttps || !!(customCertPath && customKeyPath);
899
- const stateDir = resolveStateDir(proxyPort);
900
- const store2 = new RouteStore(stateDir, {
901
- onWarning: (msg) => console.warn(chalk.yellow(msg))
902
- });
903
- if (await isProxyRunning(proxyPort)) {
904
- if (isForeground) {
905
- return;
906
- }
907
- const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
908
- const sudoPrefix = needsSudo ? "sudo " : "";
909
- console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
910
- console.log(
911
- chalk.blue(`To restart: portless proxy stop && ${sudoPrefix}portless proxy start`)
912
- );
913
- return;
1334
+ proxyPort = parseInt(portValue, 10);
1335
+ if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
1336
+ console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
1337
+ console.error(chalk.blue("Port must be between 1 and 65535."));
1338
+ process.exit(1);
914
1339
  }
915
- if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
916
- console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
917
- console.error(chalk.blue("Either run with sudo:"));
918
- console.error(chalk.cyan(" sudo portless proxy start -p 80"));
919
- console.error(chalk.blue("Or use the default port (no sudo needed):"));
920
- console.error(chalk.cyan(" portless proxy start"));
1340
+ }
1341
+ const hasNoTls = args.includes("--no-tls");
1342
+ const hasHttpsFlag = args.includes("--https");
1343
+ const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
1344
+ let customCertPath = null;
1345
+ let customKeyPath = null;
1346
+ const certIdx = args.indexOf("--cert");
1347
+ if (certIdx !== -1) {
1348
+ customCertPath = args[certIdx + 1] || null;
1349
+ if (!customCertPath || customCertPath.startsWith("-")) {
1350
+ console.error(chalk.red("Error: --cert requires a file path."));
921
1351
  process.exit(1);
922
1352
  }
923
- let tlsOptions;
924
- if (useHttps) {
925
- store2.ensureDir();
926
- if (customCertPath && customKeyPath) {
927
- try {
928
- const cert = fs2.readFileSync(customCertPath);
929
- const key = fs2.readFileSync(customKeyPath);
930
- const certStr = cert.toString("utf-8");
931
- const keyStr = key.toString("utf-8");
932
- if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
933
- console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
934
- console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
935
- process.exit(1);
936
- }
937
- if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
938
- console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
939
- console.error(
940
- chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----")
941
- );
942
- process.exit(1);
943
- }
944
- tlsOptions = { cert, key };
945
- } catch (err) {
946
- const message = err instanceof Error ? err.message : String(err);
947
- console.error(chalk.red(`Error reading certificate files: ${message}`));
948
- process.exit(1);
949
- }
950
- } else {
951
- console.log(chalk.gray("Ensuring TLS certificates..."));
952
- const certs = ensureCerts(stateDir);
953
- if (certs.caGenerated) {
954
- console.log(chalk.green("Generated local CA certificate."));
955
- }
956
- if (!isCATrusted(stateDir)) {
957
- console.log(chalk.yellow("Adding CA to system trust store..."));
958
- const trustResult = trustCA(stateDir);
959
- if (trustResult.trusted) {
960
- console.log(
961
- chalk.green("CA added to system trust store. Browsers will trust portless certs.")
962
- );
963
- } else {
964
- console.warn(chalk.yellow("Could not add CA to system trust store."));
965
- if (trustResult.error) {
966
- console.warn(chalk.gray(trustResult.error));
967
- }
968
- console.warn(
969
- chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
970
- );
971
- console.warn(chalk.cyan(" portless trust"));
972
- }
973
- }
974
- const cert = fs2.readFileSync(certs.certPath);
975
- const key = fs2.readFileSync(certs.keyPath);
976
- tlsOptions = {
977
- cert,
978
- key,
979
- SNICallback: createSNICallback(stateDir, cert, key)
980
- };
981
- }
1353
+ }
1354
+ const keyIdx = args.indexOf("--key");
1355
+ if (keyIdx !== -1) {
1356
+ customKeyPath = args[keyIdx + 1] || null;
1357
+ if (!customKeyPath || customKeyPath.startsWith("-")) {
1358
+ console.error(chalk.red("Error: --key requires a file path."));
1359
+ process.exit(1);
982
1360
  }
1361
+ }
1362
+ if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
1363
+ console.error(chalk.red("Error: --cert and --key must be used together."));
1364
+ process.exit(1);
1365
+ }
1366
+ const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1367
+ const stateDir = resolveStateDir(proxyPort);
1368
+ const store = new RouteStore(stateDir, {
1369
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1370
+ });
1371
+ if (await isProxyRunning(proxyPort)) {
983
1372
  if (isForeground) {
984
- console.log(chalk.blue.bold("\nportless proxy\n"));
985
- startProxyServer(store2, proxyPort, tlsOptions);
986
1373
  return;
987
1374
  }
988
- store2.ensureDir();
989
- const logPath = path2.join(stateDir, "proxy.log");
990
- const logFd = fs2.openSync(logPath, "a");
991
- try {
1375
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1376
+ const sudoPrefix = needsSudo ? "sudo " : "";
1377
+ console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
1378
+ console.log(chalk.blue(`To restart: portless proxy stop && ${sudoPrefix}portless proxy start`));
1379
+ return;
1380
+ }
1381
+ if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1382
+ console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
1383
+ console.error(chalk.blue("Either run with sudo:"));
1384
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
1385
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
1386
+ console.error(chalk.cyan(" portless proxy start"));
1387
+ process.exit(1);
1388
+ }
1389
+ let tlsOptions;
1390
+ if (useHttps) {
1391
+ store.ensureDir();
1392
+ if (customCertPath && customKeyPath) {
992
1393
  try {
993
- fs2.chmodSync(logPath, FILE_MODE);
994
- } catch {
1394
+ const cert = fs3.readFileSync(customCertPath);
1395
+ const key = fs3.readFileSync(customKeyPath);
1396
+ const certStr = cert.toString("utf-8");
1397
+ const keyStr = key.toString("utf-8");
1398
+ if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
1399
+ console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
1400
+ console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
1401
+ process.exit(1);
1402
+ }
1403
+ if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
1404
+ console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
1405
+ console.error(chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----"));
1406
+ process.exit(1);
1407
+ }
1408
+ tlsOptions = { cert, key };
1409
+ } catch (err) {
1410
+ const message = err instanceof Error ? err.message : String(err);
1411
+ console.error(chalk.red(`Error reading certificate files: ${message}`));
1412
+ process.exit(1);
995
1413
  }
996
- fixOwnership(logPath);
997
- const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
998
- if (portFlagIndex !== -1) {
999
- daemonArgs.push("--port", proxyPort.toString());
1414
+ } else {
1415
+ console.log(chalk.gray("Ensuring TLS certificates..."));
1416
+ const certs = ensureCerts(stateDir);
1417
+ if (certs.caGenerated) {
1418
+ console.log(chalk.green("Generated local CA certificate."));
1000
1419
  }
1001
- if (useHttps) {
1002
- if (customCertPath && customKeyPath) {
1003
- daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
1420
+ if (!isCATrusted(stateDir)) {
1421
+ console.log(chalk.yellow("Adding CA to system trust store..."));
1422
+ const trustResult = trustCA(stateDir);
1423
+ if (trustResult.trusted) {
1424
+ console.log(
1425
+ chalk.green("CA added to system trust store. Browsers will trust portless certs.")
1426
+ );
1004
1427
  } else {
1005
- daemonArgs.push("--https");
1428
+ console.warn(chalk.yellow("Could not add CA to system trust store."));
1429
+ if (trustResult.error) {
1430
+ console.warn(chalk.gray(trustResult.error));
1431
+ }
1432
+ console.warn(
1433
+ chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
1434
+ );
1435
+ console.warn(chalk.cyan(" portless trust"));
1006
1436
  }
1007
1437
  }
1008
- const child = spawn(process.execPath, daemonArgs, {
1009
- detached: true,
1010
- stdio: ["ignore", logFd, logFd],
1011
- env: process.env
1012
- });
1013
- child.unref();
1014
- } finally {
1015
- fs2.closeSync(logFd);
1438
+ const cert = fs3.readFileSync(certs.certPath);
1439
+ const key = fs3.readFileSync(certs.keyPath);
1440
+ tlsOptions = {
1441
+ cert,
1442
+ key,
1443
+ SNICallback: createSNICallback(stateDir, cert, key)
1444
+ };
1016
1445
  }
1017
- if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
1018
- console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1019
- console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
1020
- const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1021
- console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
1022
- if (fs2.existsSync(logPath)) {
1023
- console.error(chalk.gray(`Logs: ${logPath}`));
1446
+ }
1447
+ if (isForeground) {
1448
+ console.log(chalk.blue.bold("\nportless proxy\n"));
1449
+ startProxyServer(store, proxyPort, tlsOptions);
1450
+ return;
1451
+ }
1452
+ store.ensureDir();
1453
+ const logPath = path3.join(stateDir, "proxy.log");
1454
+ const logFd = fs3.openSync(logPath, "a");
1455
+ try {
1456
+ try {
1457
+ fs3.chmodSync(logPath, FILE_MODE);
1458
+ } catch {
1459
+ }
1460
+ fixOwnership(logPath);
1461
+ const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
1462
+ if (portFlagIndex !== -1) {
1463
+ daemonArgs.push("--port", proxyPort.toString());
1464
+ }
1465
+ if (useHttps) {
1466
+ if (customCertPath && customKeyPath) {
1467
+ daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
1468
+ } else {
1469
+ daemonArgs.push("--https");
1024
1470
  }
1025
- process.exit(1);
1026
1471
  }
1027
- const proto = useHttps ? "HTTPS/2" : "HTTP";
1028
- console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
1029
- return;
1472
+ const child = spawn(process.execPath, daemonArgs, {
1473
+ detached: true,
1474
+ stdio: ["ignore", logFd, logFd],
1475
+ env: process.env
1476
+ });
1477
+ child.unref();
1478
+ } finally {
1479
+ fs3.closeSync(logFd);
1030
1480
  }
1031
- const forceIdx = args.indexOf("--force");
1032
- const force = forceIdx >= 0 && forceIdx <= 1;
1033
- const appArgs = force ? [...args.slice(0, forceIdx), ...args.slice(forceIdx + 1)] : args;
1034
- const name = appArgs[0];
1035
- const commandArgs = appArgs.slice(1);
1036
- if (commandArgs.length === 0) {
1481
+ if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
1482
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1483
+ console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
1484
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1485
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
1486
+ if (fs3.existsSync(logPath)) {
1487
+ console.error(chalk.gray(`Logs: ${logPath}`));
1488
+ }
1489
+ process.exit(1);
1490
+ }
1491
+ const proto = useHttps ? "HTTPS/2" : "HTTP";
1492
+ console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
1493
+ }
1494
+ async function handleRunMode(args) {
1495
+ const parsed = parseRunArgs(args);
1496
+ if (parsed.commandArgs.length === 0) {
1497
+ console.error(chalk.red("Error: No command provided."));
1498
+ console.error(chalk.blue("Usage:"));
1499
+ console.error(chalk.cyan(" portless run <command...>"));
1500
+ console.error(chalk.blue("Example:"));
1501
+ console.error(chalk.cyan(" portless run next dev"));
1502
+ process.exit(1);
1503
+ }
1504
+ const inferred = inferProjectName();
1505
+ const worktree = detectWorktreePrefix();
1506
+ const effectiveName = worktree ? `${worktree.prefix}.${inferred.name}` : inferred.name;
1507
+ const { dir, port, tls: tls2 } = await discoverState();
1508
+ const store = new RouteStore(dir, {
1509
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1510
+ });
1511
+ await runApp(
1512
+ store,
1513
+ port,
1514
+ dir,
1515
+ effectiveName,
1516
+ parsed.commandArgs,
1517
+ tls2,
1518
+ parsed.force,
1519
+ { nameSource: inferred.source, prefix: worktree?.prefix, prefixSource: worktree?.source },
1520
+ parsed.appPort
1521
+ );
1522
+ }
1523
+ async function handleNamedMode(args) {
1524
+ const parsed = parseAppArgs(args);
1525
+ if (parsed.commandArgs.length === 0) {
1037
1526
  console.error(chalk.red("Error: No command provided."));
1038
1527
  console.error(chalk.blue("Usage:"));
1039
1528
  console.error(chalk.cyan(" portless <name> <command...>"));
@@ -1045,7 +1534,105 @@ ${chalk.bold("Usage: portless proxy <command>")}
1045
1534
  const store = new RouteStore(dir, {
1046
1535
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1047
1536
  });
1048
- await runApp(store, port, dir, name, commandArgs, tls2, force);
1537
+ await runApp(
1538
+ store,
1539
+ port,
1540
+ dir,
1541
+ parsed.name,
1542
+ parsed.commandArgs,
1543
+ tls2,
1544
+ parsed.force,
1545
+ void 0,
1546
+ parsed.appPort
1547
+ );
1548
+ }
1549
+ async function main() {
1550
+ if (process.stdin.isTTY) {
1551
+ process.on("exit", () => {
1552
+ try {
1553
+ process.stdin.setRawMode(false);
1554
+ } catch {
1555
+ }
1556
+ });
1557
+ }
1558
+ const args = process.argv.slice(2);
1559
+ const isNpx = process.env.npm_command === "exec" && !process.env.npm_lifecycle_event;
1560
+ const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
1561
+ if (isNpx || isPnpmDlx) {
1562
+ console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
1563
+ console.error(chalk.blue("Install globally instead:"));
1564
+ console.error(chalk.cyan(" npm install -g portless"));
1565
+ process.exit(1);
1566
+ }
1567
+ if (args[0] === "--name") {
1568
+ args.shift();
1569
+ if (!args[0]) {
1570
+ console.error(chalk.red("Error: --name requires an app name."));
1571
+ console.error(chalk.cyan(" portless --name <name> <command...>"));
1572
+ process.exit(1);
1573
+ }
1574
+ const skipPortless2 = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
1575
+ if (skipPortless2) {
1576
+ const { commandArgs } = parseAppArgs(args);
1577
+ if (commandArgs.length === 0) {
1578
+ console.error(chalk.red("Error: No command provided."));
1579
+ process.exit(1);
1580
+ }
1581
+ spawnCommand(commandArgs);
1582
+ return;
1583
+ }
1584
+ await handleNamedMode(args);
1585
+ return;
1586
+ }
1587
+ const isRunCommand = args[0] === "run";
1588
+ if (isRunCommand) {
1589
+ args.shift();
1590
+ }
1591
+ const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
1592
+ if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy")) {
1593
+ const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
1594
+ if (commandArgs.length === 0) {
1595
+ console.error(chalk.red("Error: No command provided."));
1596
+ process.exit(1);
1597
+ }
1598
+ spawnCommand(commandArgs);
1599
+ return;
1600
+ }
1601
+ if (!isRunCommand) {
1602
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
1603
+ printHelp();
1604
+ return;
1605
+ }
1606
+ if (args[0] === "--version" || args[0] === "-v") {
1607
+ printVersion();
1608
+ return;
1609
+ }
1610
+ if (args[0] === "trust") {
1611
+ await handleTrust();
1612
+ return;
1613
+ }
1614
+ if (args[0] === "list") {
1615
+ await handleList();
1616
+ return;
1617
+ }
1618
+ if (args[0] === "alias") {
1619
+ await handleAlias(args);
1620
+ return;
1621
+ }
1622
+ if (args[0] === "hosts") {
1623
+ await handleHosts(args);
1624
+ return;
1625
+ }
1626
+ if (args[0] === "proxy") {
1627
+ await handleProxy(args);
1628
+ return;
1629
+ }
1630
+ }
1631
+ if (isRunCommand) {
1632
+ await handleRunMode(args);
1633
+ } else {
1634
+ await handleNamedMode(args);
1635
+ }
1049
1636
  }
1050
1637
  main().catch((err) => {
1051
1638
  const message = err instanceof Error ? err.message : String(err);