peakroute 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,6 +123,8 @@ sudo peakroute trust
123
123
  peakroute <name> <cmd> [args...] # Run app at http://<name>.localhost:1355
124
124
  peakroute list # Show active routes
125
125
  peakroute trust # Add local CA to system trust store
126
+ peakroute alias <host> <port> # Register external service (e.g., Docker)
127
+ peakroute alias remove <host> # Remove an external route
126
128
 
127
129
  # Disable peakroute (run command directly)
128
130
  PEAKROUTE=0 bun dev # Bypasses proxy, uses default port
@@ -164,6 +166,43 @@ Peakroute stores its state (routes, PID file, port file) in a directory that dep
164
166
 
165
167
  Override with the `PEAKROUTE_STATE_DIR` environment variable if needed.
166
168
 
169
+ ## External Services (Docker, etc.)
170
+
171
+ Use the `peakroute alias` command to register routes for services not spawned by peakroute, such as Docker containers or other external processes:
172
+
173
+ ```bash
174
+ # Register a Docker container running on port 3000
175
+ peakroute alias mydocker.localhost 3000
176
+
177
+ # Now access it at http://mydocker.localhost:1355
178
+
179
+ # Remove the alias when done
180
+ peakroute alias remove mydocker.localhost
181
+ ```
182
+
183
+ Aliases are marked with `[external]` in the route list and are never cleaned up as "stale" since they don't have an associated process PID.
184
+
185
+ ```bash
186
+ peakroute list
187
+ # -> http://mydocker.localhost:1355 -> localhost:3000 (pid 0) [external]
188
+ ```
189
+
190
+ ## Git Worktree Support
191
+
192
+ Peakroute automatically detects when you're running inside a [Git Worktree](https://git-scm.com/docs/git-worktree) and prepends the branch name as a subdomain prefix. This gives each worktree a unique URL without any configuration changes.
193
+
194
+ ```bash
195
+ # In a worktree on branch feat/login
196
+ peakroute myapp next dev
197
+ # -> http://feat-login.myapp.localhost:1355
198
+
199
+ # In a worktree on branch fix/auth-bug
200
+ peakroute api next dev
201
+ # -> http://fix-auth-bug.api.localhost:1355
202
+ ```
203
+
204
+ The branch name is sanitized for use as a hostname (slashes become hyphens, invalid characters are removed).
205
+
167
206
  ## Development
168
207
 
169
208
  This repo is a Bun workspace monorepo using [Turborepo](https://turbo.build). The publishable package lives in `packages/peakroute/`.
@@ -10,6 +10,7 @@ var PRIVILEGED_PORT_THRESHOLD = 1024;
10
10
 
11
11
  // src/utils.ts
12
12
  import * as fs from "fs";
13
+ import { execSync } from "child_process";
13
14
  function chmodSafe(path4, mode) {
14
15
  if (IS_WINDOWS) return;
15
16
  try {
@@ -47,12 +48,39 @@ function formatUrl(hostname, proxyPort, tls = false) {
47
48
  const defaultPort = tls ? 443 : 80;
48
49
  return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
49
50
  }
51
+ function detectGitWorktree() {
52
+ try {
53
+ const gitDir = execSync("git rev-parse --git-dir", {
54
+ encoding: "utf-8",
55
+ stdio: ["pipe", "pipe", "ignore"]
56
+ }).trim();
57
+ const isWorktree = gitDir.includes("/worktrees/") || gitDir.includes("\\worktrees\\");
58
+ if (!isWorktree) return null;
59
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
60
+ encoding: "utf-8",
61
+ stdio: ["pipe", "pipe", "ignore"]
62
+ }).trim();
63
+ return branch || null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ function sanitizeBranchName(branch) {
69
+ return branch.replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "");
70
+ }
50
71
  function parseHostname(input) {
51
72
  let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
52
73
  if (!hostname || hostname === ".localhost") {
53
74
  throw new Error("Hostname cannot be empty");
54
75
  }
55
- if (!hostname.endsWith(".localhost")) {
76
+ if (hostname.endsWith(".localhost")) {
77
+ return hostname;
78
+ }
79
+ const branch = detectGitWorktree();
80
+ if (branch) {
81
+ const sanitized = sanitizeBranchName(branch);
82
+ hostname = `${sanitized}.${hostname}.localhost`;
83
+ } else {
56
84
  hostname = `${hostname}.localhost`;
57
85
  }
58
86
  const name = hostname.replace(/\.localhost$/, "");
@@ -71,6 +99,18 @@ function parseHostname(input) {
71
99
  import * as http from "http";
72
100
  import * as http2 from "http2";
73
101
  import * as net from "net";
102
+ function findRoute(routes, host) {
103
+ const exact = routes.find((r) => r.hostname === host);
104
+ if (exact) return exact;
105
+ const parts = host.split(".");
106
+ while (parts.length > 2) {
107
+ parts.shift();
108
+ const wildcard = parts.join(".");
109
+ const match = routes.find((r) => r.hostname === wildcard);
110
+ if (match) return match;
111
+ }
112
+ return null;
113
+ }
74
114
  var PEAKROUTE_HEADER = "X-Peakroute";
75
115
  var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
76
116
  "connection",
@@ -134,7 +174,7 @@ Fix: add changeOrigin: true to your proxy config, e.g.:
134
174
  );
135
175
  return;
136
176
  }
137
- const route = routes.find((r) => r.hostname === host);
177
+ const route = findRoute(routes, host);
138
178
  if (!route) {
139
179
  const safeHost = escapeHtml(host);
140
180
  res.writeHead(404, { "Content-Type": "text/html" });
@@ -221,7 +261,7 @@ Fix: add changeOrigin: true to your proxy config, e.g.:
221
261
  }
222
262
  const routes = getRoutes();
223
263
  const host = getRequestHost(req).split(":")[0];
224
- const route = routes.find((r) => r.hostname === host);
264
+ const route = findRoute(routes, host);
225
265
  if (!route) {
226
266
  socket.destroy();
227
267
  return;
@@ -333,7 +373,7 @@ import * as https from "https";
333
373
  import * as net2 from "net";
334
374
  import * as path2 from "path";
335
375
  import * as readline from "readline";
336
- import { execSync, spawn } from "child_process";
376
+ import { execSync as execSync2, spawn } from "child_process";
337
377
  var DEFAULT_PROXY_PORT = 1355;
338
378
  var MIN_APP_PORT = 4e3;
339
379
  var MAX_APP_PORT = 4999;
@@ -419,7 +459,22 @@ async function discoverState() {
419
459
  const defaultPort = getDefaultPort();
420
460
  return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
421
461
  }
422
- async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
462
+ function isPortAvailable(port) {
463
+ return new Promise((resolve) => {
464
+ const server = net2.createServer();
465
+ server.listen(port, () => {
466
+ server.close(() => resolve(true));
467
+ });
468
+ server.on("error", () => resolve(false));
469
+ });
470
+ }
471
+ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT, preferredPort) {
472
+ if (preferredPort !== void 0) {
473
+ if (await isPortAvailable(preferredPort)) {
474
+ return preferredPort;
475
+ }
476
+ throw new Error(`Port ${preferredPort} is already in use`);
477
+ }
423
478
  if (minPort > maxPort) {
424
479
  throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
425
480
  }
@@ -489,7 +544,7 @@ function parsePidFromNetstat(output, port) {
489
544
  function findPidOnPort(port) {
490
545
  if (IS_WINDOWS) {
491
546
  try {
492
- const output = execSync(`netstat -ano -p tcp`, {
547
+ const output = execSync2(`netstat -ano -p tcp`, {
493
548
  encoding: "utf-8",
494
549
  timeout: LSOF_TIMEOUT_MS
495
550
  });
@@ -499,7 +554,7 @@ function findPidOnPort(port) {
499
554
  }
500
555
  }
501
556
  try {
502
- const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
557
+ const output = execSync2(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
503
558
  encoding: "utf-8",
504
559
  timeout: LSOF_TIMEOUT_MS
505
560
  });
@@ -631,23 +686,91 @@ var FRAMEWORKS_NEEDING_PORT = {
631
686
  vite: { strictPort: true },
632
687
  "react-router": { strictPort: true },
633
688
  astro: { strictPort: false },
634
- ng: { strictPort: false }
689
+ ng: { strictPort: false },
690
+ expo: { strictPort: false },
691
+ "react-native": { strictPort: false }
635
692
  };
636
- function injectFrameworkFlags(commandArgs, port) {
693
+ var PACKAGE_MANAGERS = /* @__PURE__ */ new Set(["npm", "yarn", "pnpm", "bun"]);
694
+ function detectFrameworkFromPackageJson(scriptName) {
695
+ try {
696
+ const pkgPath = path2.join(process.cwd(), "package.json");
697
+ if (!fs2.existsSync(pkgPath)) return null;
698
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
699
+ const script = pkg.scripts?.[scriptName];
700
+ if (!script) return null;
701
+ for (const [framework] of Object.entries(FRAMEWORKS_NEEDING_PORT)) {
702
+ const regex = new RegExp(
703
+ `(?:^|[\\s;&|/]|cross-env(?:\\s+[^\\s]+)*\\s+)${framework}(?=\\s|$)`,
704
+ "i"
705
+ );
706
+ if (regex.test(script)) {
707
+ return framework;
708
+ }
709
+ }
710
+ } catch {
711
+ }
712
+ return null;
713
+ }
714
+ function extractScriptName(commandArgs, basename2) {
715
+ const runIndex = commandArgs.indexOf("run");
716
+ if (runIndex !== -1 && runIndex + 1 < commandArgs.length) {
717
+ return commandArgs[runIndex + 1];
718
+ }
719
+ if (basename2 === "yarn" || basename2 === "bun" || basename2 === "pnpm") {
720
+ const scriptName = commandArgs[1];
721
+ if (scriptName && !scriptName.startsWith("-")) {
722
+ return scriptName;
723
+ }
724
+ }
725
+ return null;
726
+ }
727
+ function resolveFramework(commandArgs) {
637
728
  const cmd = commandArgs[0];
638
- if (!cmd) return;
729
+ if (!cmd) return null;
639
730
  const basename2 = path2.basename(cmd);
640
- const framework = FRAMEWORKS_NEEDING_PORT[basename2];
641
- if (!framework) return;
731
+ const directFramework = FRAMEWORKS_NEEDING_PORT[basename2];
732
+ if (directFramework) {
733
+ return { name: basename2, ...directFramework };
734
+ }
735
+ if (!PACKAGE_MANAGERS.has(basename2)) return null;
736
+ const scriptName = extractScriptName(commandArgs, basename2);
737
+ if (!scriptName) return null;
738
+ const detectedFramework = detectFrameworkFromPackageJson(scriptName);
739
+ if (!detectedFramework) return null;
740
+ return { name: detectedFramework, ...FRAMEWORKS_NEEDING_PORT[detectedFramework] };
741
+ }
742
+ function injectPortAndHostFlags(commandArgs, port, strictPort, framework) {
642
743
  if (!commandArgs.includes("--port")) {
643
744
  commandArgs.push("--port", port.toString());
644
- if (framework.strictPort) {
745
+ if (strictPort) {
645
746
  commandArgs.push("--strictPort");
646
747
  }
647
748
  }
648
749
  if (!commandArgs.includes("--host")) {
649
750
  commandArgs.push("--host", "127.0.0.1");
650
751
  }
752
+ if (framework === "react-native") {
753
+ process.env.RCT_METRO_PORT = port.toString();
754
+ }
755
+ }
756
+ function injectFrameworkFlags(commandArgs, port, manualFramework) {
757
+ if (manualFramework === "force") {
758
+ injectPortAndHostFlags(commandArgs, port, false);
759
+ return;
760
+ }
761
+ if (manualFramework && FRAMEWORKS_NEEDING_PORT[manualFramework]) {
762
+ injectPortAndHostFlags(
763
+ commandArgs,
764
+ port,
765
+ FRAMEWORKS_NEEDING_PORT[manualFramework].strictPort,
766
+ manualFramework
767
+ );
768
+ return;
769
+ }
770
+ const framework = resolveFramework(commandArgs);
771
+ if (framework) {
772
+ injectPortAndHostFlags(commandArgs, port, framework.strictPort, framework.name);
773
+ }
651
774
  }
652
775
  function prompt(question) {
653
776
  const rl = readline.createInterface({
@@ -662,6 +785,83 @@ function prompt(question) {
662
785
  });
663
786
  });
664
787
  }
788
+ var UPDATE_CHECK_CACHE_MS = 24 * 60 * 60 * 1e3;
789
+ function getUpdateCachePath() {
790
+ return path2.join(USER_STATE_DIR, ".update-check.json");
791
+ }
792
+ function compareVersions(v1, v2) {
793
+ const parts1 = v1.split(".").map(Number);
794
+ const parts2 = v2.split(".").map(Number);
795
+ const maxLen = Math.max(parts1.length, parts2.length);
796
+ for (let i = 0; i < maxLen; i++) {
797
+ const a = parts1[i] || 0;
798
+ const b = parts2[i] || 0;
799
+ if (a < b) return -1;
800
+ if (a > b) return 1;
801
+ }
802
+ return 0;
803
+ }
804
+ async function checkForUpdate(currentVersion) {
805
+ if (process.env.PEAKROUTE_NO_UPDATE_CHECK) {
806
+ return null;
807
+ }
808
+ const cachePath = getUpdateCachePath();
809
+ try {
810
+ const cache = JSON.parse(fs2.readFileSync(cachePath, "utf-8"));
811
+ if (Date.now() - cache.lastCheck < UPDATE_CHECK_CACHE_MS) {
812
+ if (compareVersions(cache.version, currentVersion) > 0) {
813
+ return cache.version;
814
+ }
815
+ return null;
816
+ }
817
+ } catch {
818
+ }
819
+ return new Promise((resolve) => {
820
+ const req = https.get(
821
+ "https://registry.npmjs.org/peakroute/latest",
822
+ {
823
+ timeout: 3e3,
824
+ headers: {
825
+ Accept: "application/json"
826
+ }
827
+ },
828
+ (res) => {
829
+ let data = "";
830
+ res.on("data", (chunk) => {
831
+ data += chunk;
832
+ });
833
+ res.on("end", () => {
834
+ try {
835
+ const response = JSON.parse(data);
836
+ const latestVersion = response.version;
837
+ const cache = {
838
+ version: latestVersion,
839
+ lastCheck: Date.now()
840
+ };
841
+ try {
842
+ fs2.mkdirSync(USER_STATE_DIR, { recursive: true });
843
+ fs2.writeFileSync(cachePath, JSON.stringify(cache), { mode: 420 });
844
+ } catch {
845
+ }
846
+ if (compareVersions(latestVersion, currentVersion) > 0) {
847
+ resolve(latestVersion);
848
+ } else {
849
+ resolve(null);
850
+ }
851
+ } catch {
852
+ resolve(null);
853
+ }
854
+ });
855
+ }
856
+ );
857
+ req.on("error", () => resolve(null));
858
+ req.on("timeout", () => {
859
+ req.destroy();
860
+ resolve(null);
861
+ });
862
+ req.setTimeout(3e3);
863
+ });
864
+ }
665
865
 
666
866
  // src/routes.ts
667
867
  import * as fs3 from "fs";
@@ -775,6 +975,9 @@ var RouteStore = class _RouteStore {
775
975
  * is no longer alive. Stale-route cleanup is only persisted when the caller
776
976
  * already holds the lock (i.e. inside addRoute/removeRoute) to avoid
777
977
  * unprotected concurrent writes.
978
+ *
979
+ * Note: Routes with pid=0 are considered external (e.g., Docker containers)
980
+ * and are never filtered out as stale.
778
981
  */
779
982
  loadRoutes(persistCleanup = false) {
780
983
  if (!fs3.existsSync(this.routesPath)) {
@@ -794,7 +997,7 @@ var RouteStore = class _RouteStore {
794
997
  return [];
795
998
  }
796
999
  const routes = parsed.filter(isValidRoute);
797
- const alive = routes.filter((r) => this.isProcessAlive(r.pid));
1000
+ const alive = routes.filter((r) => r.pid === 0 || this.isProcessAlive(r.pid));
798
1001
  if (persistCleanup && alive.length !== routes.length) {
799
1002
  try {
800
1003
  fs3.writeFileSync(this.routesPath, JSON.stringify(alive, null, 2), {
@@ -842,6 +1045,28 @@ var RouteStore = class _RouteStore {
842
1045
  this.releaseLock();
843
1046
  }
844
1047
  }
1048
+ /**
1049
+ * Register an alias for an external service (e.g., Docker container).
1050
+ * Uses pid=0 as a sentinel to indicate the route is not managed by
1051
+ * a child process and should never be cleaned up as "stale".
1052
+ */
1053
+ addAlias(hostname, port) {
1054
+ this.ensureDir();
1055
+ if (!this.acquireLock()) {
1056
+ throw new Error("Failed to acquire route lock");
1057
+ }
1058
+ try {
1059
+ const routes = this.loadRoutes(true);
1060
+ const existing = routes.find((r) => r.hostname === hostname);
1061
+ if (existing) {
1062
+ throw new Error(`Hostname "${hostname}" is already registered`);
1063
+ }
1064
+ routes.push({ hostname, port, pid: 0 });
1065
+ this.saveRoutes(routes);
1066
+ } finally {
1067
+ this.releaseLock();
1068
+ }
1069
+ }
845
1070
  };
846
1071
 
847
1072
  export {
@@ -853,6 +1078,8 @@ export {
853
1078
  isErrnoException,
854
1079
  escapeHtml,
855
1080
  formatUrl,
1081
+ detectGitWorktree,
1082
+ sanitizeBranchName,
856
1083
  parseHostname,
857
1084
  PEAKROUTE_HEADER,
858
1085
  createProxyServer,
@@ -869,6 +1096,7 @@ export {
869
1096
  spawnCommand,
870
1097
  injectFrameworkFlags,
871
1098
  prompt,
1099
+ checkForUpdate,
872
1100
  FILE_MODE,
873
1101
  DIR_MODE,
874
1102
  SYSTEM_DIR_MODE,
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  PRIVILEGED_PORT_THRESHOLD,
6
6
  RouteConflictError,
7
7
  RouteStore,
8
+ checkForUpdate,
8
9
  chmodSafe,
9
10
  createProxyServer,
10
11
  discoverState,
@@ -24,7 +25,7 @@ import {
24
25
  spawnCommand,
25
26
  waitForProxy,
26
27
  writeTlsMarker
27
- } from "./chunk-IVO6GF7V.js";
28
+ } from "./chunk-IJJU4JLF.js";
28
29
 
29
30
  // src/cli.ts
30
31
  import chalk from "chalk";
@@ -250,8 +251,80 @@ function loginKeychainPath() {
250
251
  const home = process.env.HOME || `/Users/${process.env.USER || "unknown"}`;
251
252
  return path.join(home, "Library", "Keychains", "login.keychain-db");
252
253
  }
254
+ function detectLinuxDistro() {
255
+ try {
256
+ const osRelease = fs.readFileSync("/etc/os-release", "utf-8");
257
+ const idMatch = osRelease.match(/^ID=(.+)$/m);
258
+ const idLikeMatch = osRelease.match(/^ID_LIKE=(.+)$/m);
259
+ const id = idMatch?.[1]?.toLowerCase().replace(/"/g, "");
260
+ const idLike = idLikeMatch?.[1]?.toLowerCase().replace(/"/g, "");
261
+ if (id) {
262
+ if (id === "debian" || id === "ubuntu" || id === "linuxmint" || id === "pop") {
263
+ return "debian";
264
+ }
265
+ if (id === "fedora" || id === "rhel" || id === "centos" || id === "rocky" || id === "almalinux") {
266
+ return "fedora";
267
+ }
268
+ if (id === "arch" || id === "manjaro" || id === "endeavouros") {
269
+ return "arch";
270
+ }
271
+ if (id === "opensuse" || id === "opensuse-leap" || id === "opensuse-tumbleweed") {
272
+ return "opensuse";
273
+ }
274
+ }
275
+ if (idLike) {
276
+ if (idLike.includes("debian")) return "debian";
277
+ if (idLike.includes("fedora") || idLike.includes("rhel")) return "fedora";
278
+ if (idLike.includes("arch")) return "arch";
279
+ if (idLike.includes("suse")) return "opensuse";
280
+ }
281
+ } catch {
282
+ }
283
+ try {
284
+ execFileSync("which", ["update-ca-certificates"], { stdio: "pipe" });
285
+ return "debian";
286
+ } catch {
287
+ }
288
+ try {
289
+ execFileSync("which", ["update-ca-trust"], { stdio: "pipe" });
290
+ return "fedora";
291
+ } catch {
292
+ }
293
+ return "unknown";
294
+ }
295
+ function getLinuxCertConfig(distro) {
296
+ switch (distro) {
297
+ case "fedora":
298
+ return {
299
+ certDir: "/etc/pki/ca-trust/source/anchors",
300
+ certFilename: "peakroute-ca.crt",
301
+ updateCommand: ["update-ca-trust", "extract"]
302
+ };
303
+ case "arch":
304
+ return {
305
+ certDir: "/etc/ca-certificates/trust-source/anchors",
306
+ certFilename: "peakroute-ca.crt",
307
+ updateCommand: ["update-ca-trust"]
308
+ };
309
+ case "opensuse":
310
+ return {
311
+ certDir: "/etc/pki/trust/anchors",
312
+ certFilename: "peakroute-ca.crt",
313
+ updateCommand: ["update-ca-certificates"]
314
+ };
315
+ case "debian":
316
+ default:
317
+ return {
318
+ certDir: "/usr/local/share/ca-certificates",
319
+ certFilename: "peakroute-ca.crt",
320
+ updateCommand: ["update-ca-certificates"]
321
+ };
322
+ }
323
+ }
253
324
  function isCATrustedLinux(stateDir) {
254
- const systemCertPath = `/usr/local/share/ca-certificates/peakroute-ca.crt`;
325
+ const distro = detectLinuxDistro();
326
+ const config = getLinuxCertConfig(distro);
327
+ const systemCertPath = path.join(config.certDir, config.certFilename);
255
328
  if (!fileExists(systemCertPath)) return false;
256
329
  try {
257
330
  const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
@@ -409,9 +482,17 @@ function trustCA(stateDir) {
409
482
  );
410
483
  return { trusted: true };
411
484
  } else if (process.platform === "linux") {
412
- const dest = "/usr/local/share/ca-certificates/peakroute-ca.crt";
485
+ const distro = detectLinuxDistro();
486
+ const config = getLinuxCertConfig(distro);
487
+ const dest = path.join(config.certDir, config.certFilename);
488
+ if (!fs.existsSync(config.certDir)) {
489
+ fs.mkdirSync(config.certDir, { recursive: true });
490
+ }
413
491
  fs.copyFileSync(caCertPath, dest);
414
- execFileSync("update-ca-certificates", [], { stdio: "pipe", timeout: 3e4 });
492
+ execFileSync(config.updateCommand[0], config.updateCommand.slice(1), {
493
+ stdio: "pipe",
494
+ timeout: 3e4
495
+ });
415
496
  return { trusted: true };
416
497
  } else if (IS_WINDOWS) {
417
498
  trustCAWindows(caCertPath);
@@ -670,13 +751,14 @@ function listRoutes(store, proxyPort, tls2) {
670
751
  console.log(chalk.blue.bold("\nActive routes:\n"));
671
752
  for (const route of routes) {
672
753
  const url = formatUrl(route.hostname, proxyPort, tls2);
754
+ const externalIndicator = route.pid === 0 ? chalk.gray(" [external]") : "";
673
755
  console.log(
674
- ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
756
+ ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}${externalIndicator}`
675
757
  );
676
758
  }
677
759
  console.log();
678
760
  }
679
- async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force) {
761
+ async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force, inject, preferredAppPort) {
680
762
  const hostname = parseHostname(name);
681
763
  console.log(chalk.blue.bold(`
682
764
  peakroute
@@ -772,7 +854,7 @@ peakroute
772
854
  } else {
773
855
  console.log(chalk.gray("-- Proxy is running"));
774
856
  }
775
- const port = await findFreePort();
857
+ const port = await findFreePort(void 0, void 0, preferredAppPort);
776
858
  console.log(chalk.green(`-- Using port ${port}`));
777
859
  try {
778
860
  store.addRoute(hostname, port, process.pid, force);
@@ -787,7 +869,7 @@ peakroute
787
869
  console.log(chalk.cyan.bold(`
788
870
  -> ${finalUrl}
789
871
  `));
790
- injectFrameworkFlags(commandArgs, port);
872
+ injectFrameworkFlags(commandArgs, port, inject ? "force" : void 0);
791
873
  console.log(chalk.gray(`Running: PORT=${port} HOST=127.0.0.1 ${commandArgs.join(" ")}
792
874
  `));
793
875
  spawnCommand(commandArgs, {
@@ -795,6 +877,7 @@ peakroute
795
877
  ...process.env,
796
878
  PORT: port.toString(),
797
879
  HOST: "127.0.0.1",
880
+ PEAKROUTE_URL: finalUrl,
798
881
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
799
882
  },
800
883
  onCleanup: () => {
@@ -806,6 +889,7 @@ peakroute
806
889
  });
807
890
  }
808
891
  async function main() {
892
+ const updatePromise = checkForUpdate("0.6.0");
809
893
  if (process.stdin.isTTY) {
810
894
  process.on("exit", () => {
811
895
  try {
@@ -815,6 +899,15 @@ async function main() {
815
899
  });
816
900
  }
817
901
  const args = process.argv.slice(2);
902
+ const newerVersion = await updatePromise;
903
+ if (newerVersion) {
904
+ console.log(
905
+ chalk.yellow(`
906
+ \u2192 Update available: ${chalk.bold(newerVersion)} (current: ${"0.6.0"})`)
907
+ );
908
+ console.log(chalk.gray(` Run: npm install -g peakroute
909
+ `));
910
+ }
818
911
  const isNpx = process.env.npm_command === "exec" && !process.env.npm_lifecycle_event;
819
912
  const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
820
913
  if (isNpx || isPnpmDlx) {
@@ -847,6 +940,8 @@ ${chalk.bold("Usage:")}
847
940
  ${chalk.cyan("peakroute <name> <cmd>")} Run your app through the proxy
848
941
  ${chalk.cyan("peakroute list")} Show active routes
849
942
  ${chalk.cyan("peakroute trust")} Add local CA to system trust store
943
+ ${chalk.cyan("peakroute alias <host> <port>")} Register a route for external services (e.g. Docker)
944
+ ${chalk.cyan("peakroute alias remove <host>")} Remove an external route
850
945
 
851
946
  ${chalk.bold("Examples:")}
852
947
  peakroute proxy start # Start proxy on port 1355
@@ -884,9 +979,12 @@ ${chalk.bold("Options:")}
884
979
  --no-tls Disable HTTPS (overrides PEAKROUTE_HTTPS)
885
980
  --foreground Run proxy in foreground (for debugging)
886
981
  --force Override an existing route registered by another process
982
+ --inject Force injection of --port and --host flags (when auto-detection fails)
983
+ --app-port <number> Use a specific port for the app (instead of auto-finding one)
887
984
 
888
985
  ${chalk.bold("Environment variables:")}
889
986
  PEAKROUTE_PORT=<number> Override the default proxy port (e.g. in .bashrc)
987
+ PEAKROUTE_APP_PORT=<number> Use a specific port for the app (alternative to --app-port)
890
988
  PEAKROUTE_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
891
989
  PEAKROUTE_STATE_DIR=<path> Override the state directory
892
990
  PEAKROUTE=0 | PEAKROUTE=skip Run command directly without proxy
@@ -898,7 +996,7 @@ ${chalk.bold("Skip peakroute:")}
898
996
  process.exit(0);
899
997
  }
900
998
  if (args[0] === "--version" || args[0] === "-v") {
901
- console.log("0.5.7");
999
+ console.log("0.6.0");
902
1000
  process.exit(0);
903
1001
  }
904
1002
  if (args[0] === "trust") {
@@ -928,6 +1026,54 @@ ${chalk.bold("Skip peakroute:")}
928
1026
  listRoutes(store2, port2, tls3);
929
1027
  return;
930
1028
  }
1029
+ if (args[0] === "alias") {
1030
+ const { dir: dir2 } = await discoverState();
1031
+ const store2 = new RouteStore(dir2, {
1032
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1033
+ });
1034
+ if (args[1] === "remove") {
1035
+ const hostname2 = args[2];
1036
+ if (!hostname2) {
1037
+ console.error(chalk.red("Error: Missing hostname."));
1038
+ console.error(chalk.blue("Usage:"));
1039
+ console.error(chalk.cyan(" peakroute alias remove <hostname>"));
1040
+ process.exit(1);
1041
+ }
1042
+ try {
1043
+ store2.removeRoute(parseHostname(hostname2));
1044
+ console.log(chalk.green(`Alias removed: ${hostname2}`));
1045
+ } catch (err) {
1046
+ const message = err instanceof Error ? err.message : String(err);
1047
+ console.error(chalk.red(`Failed to remove alias: ${message}`));
1048
+ process.exit(1);
1049
+ }
1050
+ return;
1051
+ }
1052
+ const hostname = args[1];
1053
+ const portStr = args[2];
1054
+ if (!hostname || !portStr) {
1055
+ console.error(chalk.red("Error: Missing arguments."));
1056
+ console.error(chalk.blue("Usage:"));
1057
+ console.error(chalk.cyan(" peakroute alias <hostname> <port>"));
1058
+ console.error(chalk.cyan(" peakroute alias remove <hostname>"));
1059
+ process.exit(1);
1060
+ }
1061
+ const portNum = parseInt(portStr, 10);
1062
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
1063
+ console.error(chalk.red(`Error: Invalid port number: ${portStr}`));
1064
+ console.error(chalk.blue("Port must be between 1 and 65535."));
1065
+ process.exit(1);
1066
+ }
1067
+ try {
1068
+ store2.addAlias(parseHostname(hostname), portNum);
1069
+ console.log(chalk.green(`Alias registered: ${hostname} -> localhost:${portNum}`));
1070
+ } catch (err) {
1071
+ const message = err instanceof Error ? err.message : String(err);
1072
+ console.error(chalk.red(`Failed to register alias: ${message}`));
1073
+ process.exit(1);
1074
+ }
1075
+ return;
1076
+ }
931
1077
  if (args[0] === "proxy") {
932
1078
  if (args[1] === "stop") {
933
1079
  const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
@@ -1133,9 +1279,40 @@ ${chalk.bold("Usage: peakroute proxy <command>")}
1133
1279
  console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
1134
1280
  return;
1135
1281
  }
1282
+ const parsedFlags = /* @__PURE__ */ new Set();
1136
1283
  const forceIdx = args.indexOf("--force");
1137
1284
  const force = forceIdx >= 0 && forceIdx <= 1;
1138
- const appArgs = force ? [...args.slice(0, forceIdx), ...args.slice(forceIdx + 1)] : args;
1285
+ if (force) parsedFlags.add(forceIdx);
1286
+ const injectIdx = args.indexOf("--inject");
1287
+ const injectFlag = injectIdx >= 0 && injectIdx <= 1;
1288
+ if (injectFlag) parsedFlags.add(injectIdx);
1289
+ let preferredAppPort;
1290
+ const appPortIdx = args.indexOf("--app-port");
1291
+ if (appPortIdx >= 0 && appPortIdx <= 1) {
1292
+ const portValue = args[appPortIdx + 1];
1293
+ if (!portValue || portValue.startsWith("-")) {
1294
+ console.error(chalk.red("Error: --app-port requires a port number."));
1295
+ console.error(chalk.blue("Usage:"));
1296
+ console.error(chalk.cyan(" peakroute myapp --app-port 3000 next dev"));
1297
+ process.exit(1);
1298
+ }
1299
+ const parsedPort = parseInt(portValue, 10);
1300
+ if (isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
1301
+ console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
1302
+ console.error(chalk.blue("Port must be between 1 and 65535."));
1303
+ process.exit(1);
1304
+ }
1305
+ preferredAppPort = parsedPort;
1306
+ parsedFlags.add(appPortIdx);
1307
+ parsedFlags.add(appPortIdx + 1);
1308
+ }
1309
+ if (preferredAppPort === void 0 && process.env.PEAKROUTE_APP_PORT) {
1310
+ const envPort = parseInt(process.env.PEAKROUTE_APP_PORT, 10);
1311
+ if (!isNaN(envPort) && envPort >= 1 && envPort <= 65535) {
1312
+ preferredAppPort = envPort;
1313
+ }
1314
+ }
1315
+ const appArgs = args.filter((_, i) => !parsedFlags.has(i));
1139
1316
  const name = appArgs[0];
1140
1317
  const commandArgs = appArgs.slice(1);
1141
1318
  if (commandArgs.length === 0) {
@@ -1150,7 +1327,7 @@ ${chalk.bold("Usage: peakroute proxy <command>")}
1150
1327
  const store = new RouteStore(dir, {
1151
1328
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1152
1329
  });
1153
- await runApp(store, port, dir, name, commandArgs, tls2, force);
1330
+ await runApp(store, port, dir, name, commandArgs, tls2, force, injectFlag, preferredAppPort);
1154
1331
  }
1155
1332
  main().catch((err) => {
1156
1333
  const message = err instanceof Error ? err.message : String(err);
package/dist/index.d.ts CHANGED
@@ -90,11 +90,20 @@ declare class RouteStore {
90
90
  * is no longer alive. Stale-route cleanup is only persisted when the caller
91
91
  * already holds the lock (i.e. inside addRoute/removeRoute) to avoid
92
92
  * unprotected concurrent writes.
93
+ *
94
+ * Note: Routes with pid=0 are considered external (e.g., Docker containers)
95
+ * and are never filtered out as stale.
93
96
  */
94
97
  loadRoutes(persistCleanup?: boolean): RouteMapping[];
95
98
  private saveRoutes;
96
99
  addRoute(hostname: string, port: number, pid: number, force?: boolean): void;
97
100
  removeRoute(hostname: string): void;
101
+ /**
102
+ * Register an alias for an external service (e.g., Docker container).
103
+ * Uses pid=0 as a sentinel to indicate the route is not managed by
104
+ * a child process and should never be cleaned up as "stale".
105
+ */
106
+ addAlias(hostname: string, port: number): void;
98
107
  }
99
108
 
100
109
  /**
@@ -123,10 +132,21 @@ declare function escapeHtml(str: string): string;
123
132
  * (80 for HTTP, 443 for HTTPS).
124
133
  */
125
134
  declare function formatUrl(hostname: string, proxyPort: number, tls?: boolean): string;
135
+ /**
136
+ * Detect if we're in a git worktree and return the branch name.
137
+ * Returns null if not in a worktree or if git is not available.
138
+ */
139
+ declare function detectGitWorktree(): string | null;
140
+ /**
141
+ * Sanitize branch name for use in hostname.
142
+ * Replaces / with - and removes invalid characters.
143
+ */
144
+ declare function sanitizeBranchName(branch: string): string;
126
145
  /**
127
146
  * Parse and normalize a hostname input for use as a .localhost subdomain.
128
147
  * Strips protocol prefixes, validates characters, and appends .localhost if needed.
148
+ * When in a git worktree, prepends the branch name as a subdomain prefix.
129
149
  */
130
150
  declare function parseHostname(input: string): string;
131
151
 
132
- export { DIR_MODE, FILE_MODE, PEAKROUTE_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, SYSTEM_DIR_MODE, SYSTEM_FILE_MODE, chmodSafe, chmodSafeAsync, createProxyServer, escapeHtml, fixOwnership, formatUrl, isErrnoException, parseHostname };
152
+ export { DIR_MODE, FILE_MODE, PEAKROUTE_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, SYSTEM_DIR_MODE, SYSTEM_FILE_MODE, chmodSafe, chmodSafeAsync, createProxyServer, detectGitWorktree, escapeHtml, fixOwnership, formatUrl, isErrnoException, parseHostname, sanitizeBranchName };
package/dist/index.js CHANGED
@@ -9,12 +9,14 @@ import {
9
9
  chmodSafe,
10
10
  chmodSafeAsync,
11
11
  createProxyServer,
12
+ detectGitWorktree,
12
13
  escapeHtml,
13
14
  fixOwnership,
14
15
  formatUrl,
15
16
  isErrnoException,
16
- parseHostname
17
- } from "./chunk-IVO6GF7V.js";
17
+ parseHostname,
18
+ sanitizeBranchName
19
+ } from "./chunk-IJJU4JLF.js";
18
20
  export {
19
21
  DIR_MODE,
20
22
  FILE_MODE,
@@ -26,9 +28,11 @@ export {
26
28
  chmodSafe,
27
29
  chmodSafeAsync,
28
30
  createProxyServer,
31
+ detectGitWorktree,
29
32
  escapeHtml,
30
33
  fixOwnership,
31
34
  formatUrl,
32
35
  isErrnoException,
33
- parseHostname
36
+ parseHostname,
37
+ sanitizeBranchName
34
38
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peakroute",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents. (Formerly portless)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",