peakroute 0.5.8 → 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,7 +686,9 @@ 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
693
  var PACKAGE_MANAGERS = /* @__PURE__ */ new Set(["npm", "yarn", "pnpm", "bun"]);
637
694
  function detectFrameworkFromPackageJson(scriptName) {
@@ -682,7 +739,7 @@ function resolveFramework(commandArgs) {
682
739
  if (!detectedFramework) return null;
683
740
  return { name: detectedFramework, ...FRAMEWORKS_NEEDING_PORT[detectedFramework] };
684
741
  }
685
- function injectPortAndHostFlags(commandArgs, port, strictPort) {
742
+ function injectPortAndHostFlags(commandArgs, port, strictPort, framework) {
686
743
  if (!commandArgs.includes("--port")) {
687
744
  commandArgs.push("--port", port.toString());
688
745
  if (strictPort) {
@@ -692,6 +749,9 @@ function injectPortAndHostFlags(commandArgs, port, strictPort) {
692
749
  if (!commandArgs.includes("--host")) {
693
750
  commandArgs.push("--host", "127.0.0.1");
694
751
  }
752
+ if (framework === "react-native") {
753
+ process.env.RCT_METRO_PORT = port.toString();
754
+ }
695
755
  }
696
756
  function injectFrameworkFlags(commandArgs, port, manualFramework) {
697
757
  if (manualFramework === "force") {
@@ -699,12 +759,17 @@ function injectFrameworkFlags(commandArgs, port, manualFramework) {
699
759
  return;
700
760
  }
701
761
  if (manualFramework && FRAMEWORKS_NEEDING_PORT[manualFramework]) {
702
- injectPortAndHostFlags(commandArgs, port, FRAMEWORKS_NEEDING_PORT[manualFramework].strictPort);
762
+ injectPortAndHostFlags(
763
+ commandArgs,
764
+ port,
765
+ FRAMEWORKS_NEEDING_PORT[manualFramework].strictPort,
766
+ manualFramework
767
+ );
703
768
  return;
704
769
  }
705
770
  const framework = resolveFramework(commandArgs);
706
771
  if (framework) {
707
- injectPortAndHostFlags(commandArgs, port, framework.strictPort);
772
+ injectPortAndHostFlags(commandArgs, port, framework.strictPort, framework.name);
708
773
  }
709
774
  }
710
775
  function prompt(question) {
@@ -910,6 +975,9 @@ var RouteStore = class _RouteStore {
910
975
  * is no longer alive. Stale-route cleanup is only persisted when the caller
911
976
  * already holds the lock (i.e. inside addRoute/removeRoute) to avoid
912
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.
913
981
  */
914
982
  loadRoutes(persistCleanup = false) {
915
983
  if (!fs3.existsSync(this.routesPath)) {
@@ -929,7 +997,7 @@ var RouteStore = class _RouteStore {
929
997
  return [];
930
998
  }
931
999
  const routes = parsed.filter(isValidRoute);
932
- const alive = routes.filter((r) => this.isProcessAlive(r.pid));
1000
+ const alive = routes.filter((r) => r.pid === 0 || this.isProcessAlive(r.pid));
933
1001
  if (persistCleanup && alive.length !== routes.length) {
934
1002
  try {
935
1003
  fs3.writeFileSync(this.routesPath, JSON.stringify(alive, null, 2), {
@@ -977,6 +1045,28 @@ var RouteStore = class _RouteStore {
977
1045
  this.releaseLock();
978
1046
  }
979
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
+ }
980
1070
  };
981
1071
 
982
1072
  export {
@@ -988,6 +1078,8 @@ export {
988
1078
  isErrnoException,
989
1079
  escapeHtml,
990
1080
  formatUrl,
1081
+ detectGitWorktree,
1082
+ sanitizeBranchName,
991
1083
  parseHostname,
992
1084
  PEAKROUTE_HEADER,
993
1085
  createProxyServer,
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  spawnCommand,
26
26
  waitForProxy,
27
27
  writeTlsMarker
28
- } from "./chunk-EWW65DJW.js";
28
+ } from "./chunk-IJJU4JLF.js";
29
29
 
30
30
  // src/cli.ts
31
31
  import chalk from "chalk";
@@ -251,8 +251,80 @@ function loginKeychainPath() {
251
251
  const home = process.env.HOME || `/Users/${process.env.USER || "unknown"}`;
252
252
  return path.join(home, "Library", "Keychains", "login.keychain-db");
253
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
+ }
254
324
  function isCATrustedLinux(stateDir) {
255
- 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);
256
328
  if (!fileExists(systemCertPath)) return false;
257
329
  try {
258
330
  const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
@@ -410,9 +482,17 @@ function trustCA(stateDir) {
410
482
  );
411
483
  return { trusted: true };
412
484
  } else if (process.platform === "linux") {
413
- 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
+ }
414
491
  fs.copyFileSync(caCertPath, dest);
415
- execFileSync("update-ca-certificates", [], { stdio: "pipe", timeout: 3e4 });
492
+ execFileSync(config.updateCommand[0], config.updateCommand.slice(1), {
493
+ stdio: "pipe",
494
+ timeout: 3e4
495
+ });
416
496
  return { trusted: true };
417
497
  } else if (IS_WINDOWS) {
418
498
  trustCAWindows(caCertPath);
@@ -671,13 +751,14 @@ function listRoutes(store, proxyPort, tls2) {
671
751
  console.log(chalk.blue.bold("\nActive routes:\n"));
672
752
  for (const route of routes) {
673
753
  const url = formatUrl(route.hostname, proxyPort, tls2);
754
+ const externalIndicator = route.pid === 0 ? chalk.gray(" [external]") : "";
674
755
  console.log(
675
- ` ${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}`
676
757
  );
677
758
  }
678
759
  console.log();
679
760
  }
680
- async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force, inject) {
761
+ async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force, inject, preferredAppPort) {
681
762
  const hostname = parseHostname(name);
682
763
  console.log(chalk.blue.bold(`
683
764
  peakroute
@@ -773,7 +854,7 @@ peakroute
773
854
  } else {
774
855
  console.log(chalk.gray("-- Proxy is running"));
775
856
  }
776
- const port = await findFreePort();
857
+ const port = await findFreePort(void 0, void 0, preferredAppPort);
777
858
  console.log(chalk.green(`-- Using port ${port}`));
778
859
  try {
779
860
  store.addRoute(hostname, port, process.pid, force);
@@ -796,6 +877,7 @@ peakroute
796
877
  ...process.env,
797
878
  PORT: port.toString(),
798
879
  HOST: "127.0.0.1",
880
+ PEAKROUTE_URL: finalUrl,
799
881
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
800
882
  },
801
883
  onCleanup: () => {
@@ -807,7 +889,7 @@ peakroute
807
889
  });
808
890
  }
809
891
  async function main() {
810
- const updatePromise = checkForUpdate("0.5.8");
892
+ const updatePromise = checkForUpdate("0.6.0");
811
893
  if (process.stdin.isTTY) {
812
894
  process.on("exit", () => {
813
895
  try {
@@ -821,7 +903,7 @@ async function main() {
821
903
  if (newerVersion) {
822
904
  console.log(
823
905
  chalk.yellow(`
824
- \u2192 Update available: ${chalk.bold(newerVersion)} (current: ${"0.5.8"})`)
906
+ \u2192 Update available: ${chalk.bold(newerVersion)} (current: ${"0.6.0"})`)
825
907
  );
826
908
  console.log(chalk.gray(` Run: npm install -g peakroute
827
909
  `));
@@ -858,6 +940,8 @@ ${chalk.bold("Usage:")}
858
940
  ${chalk.cyan("peakroute <name> <cmd>")} Run your app through the proxy
859
941
  ${chalk.cyan("peakroute list")} Show active routes
860
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
861
945
 
862
946
  ${chalk.bold("Examples:")}
863
947
  peakroute proxy start # Start proxy on port 1355
@@ -896,9 +980,11 @@ ${chalk.bold("Options:")}
896
980
  --foreground Run proxy in foreground (for debugging)
897
981
  --force Override an existing route registered by another process
898
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)
899
984
 
900
985
  ${chalk.bold("Environment variables:")}
901
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)
902
988
  PEAKROUTE_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
903
989
  PEAKROUTE_STATE_DIR=<path> Override the state directory
904
990
  PEAKROUTE=0 | PEAKROUTE=skip Run command directly without proxy
@@ -910,7 +996,7 @@ ${chalk.bold("Skip peakroute:")}
910
996
  process.exit(0);
911
997
  }
912
998
  if (args[0] === "--version" || args[0] === "-v") {
913
- console.log("0.5.8");
999
+ console.log("0.6.0");
914
1000
  process.exit(0);
915
1001
  }
916
1002
  if (args[0] === "trust") {
@@ -940,6 +1026,54 @@ ${chalk.bold("Skip peakroute:")}
940
1026
  listRoutes(store2, port2, tls3);
941
1027
  return;
942
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
+ }
943
1077
  if (args[0] === "proxy") {
944
1078
  if (args[1] === "stop") {
945
1079
  const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
@@ -1145,11 +1279,40 @@ ${chalk.bold("Usage: peakroute proxy <command>")}
1145
1279
  console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
1146
1280
  return;
1147
1281
  }
1282
+ const parsedFlags = /* @__PURE__ */ new Set();
1148
1283
  const forceIdx = args.indexOf("--force");
1149
1284
  const force = forceIdx >= 0 && forceIdx <= 1;
1285
+ if (force) parsedFlags.add(forceIdx);
1150
1286
  const injectIdx = args.indexOf("--inject");
1151
1287
  const injectFlag = injectIdx >= 0 && injectIdx <= 1;
1152
- const appArgs = args.filter((_, i) => i !== forceIdx && i !== injectIdx);
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));
1153
1316
  const name = appArgs[0];
1154
1317
  const commandArgs = appArgs.slice(1);
1155
1318
  if (commandArgs.length === 0) {
@@ -1164,7 +1327,7 @@ ${chalk.bold("Usage: peakroute proxy <command>")}
1164
1327
  const store = new RouteStore(dir, {
1165
1328
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1166
1329
  });
1167
- await runApp(store, port, dir, name, commandArgs, tls2, force, injectFlag);
1330
+ await runApp(store, port, dir, name, commandArgs, tls2, force, injectFlag, preferredAppPort);
1168
1331
  }
1169
1332
  main().catch((err) => {
1170
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-EWW65DJW.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.8",
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",