portless 0.5.1 → 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
@@ -1,87 +1,100 @@
1
1
  # portless
2
2
 
3
- Replace port numbers with stable, named .localhost URLs. For humans and agents.
3
+ Replace port numbers with stable, named .localhost URLs for local development. For humans and agents.
4
4
 
5
5
  ```diff
6
- - "dev": "next dev" # http://localhost:3000
7
- + "dev": "portless run next dev" # http://myapp.localhost:1355
6
+ - "dev": "next dev" # http://localhost:3000
7
+ + "dev": "portless run next dev" # https://myapp.localhost
8
8
  ```
9
9
 
10
- ## Quick Start
10
+ ## Install
11
11
 
12
12
  ```bash
13
- # Install
14
13
  npm install -g portless
14
+ ```
15
+
16
+ > Install globally. Do not add as a project dependency or run via npx.
17
+
18
+ ## Run your app
15
19
 
16
- # Run your app (auto-starts the proxy if needed)
17
- portless run next dev
18
- # -> http://<project>.localhost:1355
20
+ ```bash
21
+ # Enable HTTPS (one-time setup, auto-generates certs)
22
+ portless proxy start --https
19
23
 
20
- # Or specify a name explicitly
24
+ portless myapp next dev
25
+ # -> https://myapp.localhost
26
+
27
+ # Without --https, runs on port 1355
21
28
  portless myapp next dev
22
29
  # -> http://myapp.localhost:1355
23
30
  ```
24
31
 
25
- > The proxy auto-starts when you run an app. You can also start it explicitly with `portless proxy start`.
26
-
27
- ## Why
32
+ The proxy auto-starts when you run an app. A random port (4000--4999) is assigned via the `PORT` environment variable. Most frameworks (Next.js, Express, Nuxt, etc.) respect this automatically. For frameworks that ignore `PORT` (Vite, Astro, React Router, Angular), portless auto-injects `--port` and `--host` flags.
28
33
 
29
- Local dev with port numbers is fragile:
34
+ ## Use in package.json
30
35
 
31
- - **Port conflicts** -- two projects default to the same port and you get `EADDRINUSE`
32
- - **Memorizing ports** -- was the API on 3001 or 8080?
33
- - **Refreshing shows the wrong app** -- stop one server, start another on the same port, and your open tab now shows something completely different
34
- - **Monorepo multiplier** -- every problem above scales with each service in the repo
35
- - **Agents test the wrong port** -- AI coding agents guess or hardcode the wrong port, especially in monorepos
36
- - **Cookie and storage clashes** -- cookies set on `localhost` bleed across apps on different ports; localStorage is lost when ports shift
37
- - **Hardcoded ports in config** -- CORS allowlists, OAuth redirect URIs, and `.env` files all break when ports change
38
- - **Sharing URLs with teammates** -- "what port is that on?" becomes a Slack question
39
- - **Browser history is useless** -- your history for `localhost:3000` is a jumble of unrelated projects
36
+ ```json
37
+ {
38
+ "scripts": {
39
+ "dev": "portless run next dev"
40
+ }
41
+ }
42
+ ```
40
43
 
41
- Portless fixes all of this by giving each dev server a stable, named `.localhost` URL that both humans and agents can rely on.
44
+ ## Subdomains
42
45
 
43
- ## Usage
46
+ Organize services with subdomains:
44
47
 
45
48
  ```bash
46
- # Auto-infer name from package.json / git / directory
47
- portless run next dev
48
- # -> http://<project>.localhost:1355
49
-
50
- # Explicit name
51
- portless myapp next dev
52
- # -> http://myapp.localhost:1355
53
-
54
- # Subdomains
55
49
  portless api.myapp pnpm start
56
50
  # -> http://api.myapp.localhost:1355
57
51
 
58
52
  portless docs.myapp next dev
59
53
  # -> http://docs.myapp.localhost:1355
54
+ ```
55
+
56
+ Wildcard subdomain routing: any subdomain of a registered route routes to that app automatically (e.g. `tenant1.myapp.localhost:1355` routes to the `myapp` app without extra registration).
57
+
58
+ ## Git Worktrees
60
59
 
61
- # Wildcard subdomains (no extra registration needed)
62
- # Any subdomain of a registered route routes automatically:
63
- # tenant1.myapp.localhost:1355 -> myapp
64
- # tenant2.myapp.localhost:1355 -> myapp
60
+ `portless run` automatically detects git worktrees. In a linked worktree, the branch name is prepended as a subdomain so each worktree gets its own URL without any config changes:
61
+
62
+ ```bash
63
+ # Main worktree -- no prefix
64
+ portless run next dev # -> http://myapp.localhost:1355
65
+
66
+ # Linked worktree on branch "fix-ui"
67
+ portless run next dev # -> http://fix-ui.myapp.localhost:1355
65
68
  ```
66
69
 
67
- ### In package.json
70
+ Use `--name` to override the inferred base name while keeping the worktree prefix:
68
71
 
69
- ```json
70
- {
71
- "scripts": {
72
- "dev": "portless run next dev"
73
- }
74
- }
72
+ ```bash
73
+ portless run --name myapp next dev # -> http://fix-ui.myapp.localhost:1355
75
74
  ```
76
75
 
77
- The proxy auto-starts when you run an app. Or start it explicitly: `portless proxy start`.
76
+ Put `portless run` in your `package.json` once and it works everywhere -- the main checkout uses the plain name, each worktree gets a unique subdomain. No collisions, no `--force`.
77
+
78
+ ## Custom TLD
78
79
 
79
- ## How It Works
80
+ By default, portless uses `.localhost` which auto-resolves to `127.0.0.1` in most browsers. If you prefer a different TLD (e.g. `.test`), use `--tld`:
81
+
82
+ ```bash
83
+ sudo portless proxy start --https --tld test
84
+ portless myapp next dev
85
+ # -> https://myapp.test
86
+ ```
87
+
88
+ The proxy auto-syncs `/etc/hosts` for custom TLDs when started with sudo, so `.test` domains resolve correctly.
89
+
90
+ Recommended: `.test` (IANA-reserved, no collision risk). Avoid `.local` (conflicts with mDNS/Bonjour) and `.dev` (Google-owned, forces HTTPS via HSTS).
91
+
92
+ ## How it works
80
93
 
81
94
  ```mermaid
82
95
  flowchart TD
83
96
  Browser["Browser\nmyapp.localhost:1355"]
84
- Proxy["portless proxy<br>(port 1355)"]
97
+ Proxy["portless proxy\n(port 1355)"]
85
98
  App1[":4123\nmyapp"]
86
99
  App2[":4567\napi"]
87
100
 
@@ -94,8 +107,6 @@ flowchart TD
94
107
  2. **Run apps** -- `portless <name> <command>` assigns a free port and registers with the proxy
95
108
  3. **Access via URL** -- `http://<name>.localhost:1355` routes through the proxy to your app
96
109
 
97
- Apps are assigned a random port (4000-4999) via the `PORT` and `HOST` environment variables. Most frameworks (Next.js, Express, Nuxt, etc.) respect these automatically. For frameworks that ignore `PORT` (Vite, Astro, React Router, Angular, Expo, React Native), portless auto-injects the correct `--port` and `--host` flags.
98
-
99
110
  ## HTTP/2 + HTTPS
100
111
 
101
112
  Enable HTTP/2 for faster dev server page loads. Browsers limit HTTP/1.1 to 6 connections per host, which bottlenecks dev servers that serve many unbundled files (Vite, Nuxt, etc.). HTTP/2 multiplexes all requests over a single connection.
@@ -123,7 +134,7 @@ On Linux, `portless trust` supports Debian/Ubuntu, Arch, Fedora/RHEL/CentOS, and
123
134
  ## Commands
124
135
 
125
136
  ```bash
126
- portless run <cmd> [args...] # Infer name from project, run through proxy
137
+ portless run [--name <name>] <cmd> [args...] # Infer name (or override with --name), run through proxy
127
138
  portless <name> <cmd> [args...] # Run app at http://<name>.localhost:1355
128
139
  portless alias <name> <port> # Register a static route (e.g. for Docker)
129
140
  portless alias <name> <port> --force # Overwrite an existing route
@@ -135,7 +146,6 @@ portless hosts clean # Remove portless entries from /etc/hosts
135
146
 
136
147
  # Disable portless (run command directly)
137
148
  PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
138
- # Also accepts PORTLESS=skip
139
149
 
140
150
  # Proxy control
141
151
  portless proxy start # Start the proxy (port 1355, daemon)
@@ -143,64 +153,42 @@ portless proxy start --https # Start with HTTP/2 + TLS
143
153
  portless proxy start -p 80 # Start on port 80 (requires sudo)
144
154
  portless proxy start --foreground # Start in foreground (for debugging)
145
155
  portless proxy stop # Stop the proxy
146
-
147
- # Options
148
- -p, --port <number> # Port for the proxy (default: 1355)
149
- # Ports < 1024 require sudo
150
- --https # Enable HTTP/2 + TLS with auto-generated certs
151
- --cert <path> # Use a custom TLS certificate (implies --https)
152
- --key <path> # Use a custom TLS private key (implies --https)
153
- --no-tls # Disable HTTPS (overrides PORTLESS_HTTPS)
154
- --foreground # Run proxy in foreground instead of daemon
155
- --app-port <number> # Use a fixed port for the app (skip auto-assignment)
156
- --force # Override a route registered by another process
157
- --name <name> # Use <name> as the app name (bypasses subcommand dispatch)
158
- -- # Stop flag parsing; everything after is passed to the child
159
-
160
- # Injected into child processes
161
- PORT # Ephemeral port the child should listen on
162
- HOST # Always 127.0.0.1
163
- PORTLESS_URL # Public URL (e.g. http://myapp.localhost:1355)
164
-
165
- # Configuration
166
- PORTLESS_PORT=<number> # Override the default proxy port
167
- PORTLESS_APP_PORT=<number> # Use a fixed port for the app (same as --app-port)
168
- PORTLESS_HTTPS=1|true # Always enable HTTPS
169
- PORTLESS_SYNC_HOSTS=1 # Auto-sync /etc/hosts when routes change
170
- PORTLESS_STATE_DIR=<path> # Override the state directory
171
-
172
- # Info
173
- portless --help # Show help
174
- portless run --help # Show help for a specific subcommand
175
- portless --version # Show version
176
156
  ```
177
157
 
178
- > **Reserved names:** `run`, `alias`, `hosts`, `list`, `trust`, and `proxy` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
179
-
180
- ## State Directory
181
-
182
- Portless stores its state (routes, PID file, port file) in a directory that depends on the proxy port:
158
+ ### Options
183
159
 
184
- - **Port < 1024** (sudo required): `/tmp/portless` -- shared between root and user processes
185
- - **Port >= 1024** (no sudo): `~/.portless` -- user-scoped, no root involvement
160
+ ```
161
+ -p, --port <number> Port for the proxy (default: 1355)
162
+ --https Enable HTTP/2 + TLS with auto-generated certs
163
+ --cert <path> Use a custom TLS certificate (implies --https)
164
+ --key <path> Use a custom TLS private key (implies --https)
165
+ --no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
166
+ --foreground Run proxy in foreground instead of daemon
167
+ --tld <tld> Use a custom TLD instead of .localhost (e.g. test)
168
+ --app-port <number> Use a fixed port for the app (skip auto-assignment)
169
+ --force Override a route registered by another process
170
+ --name <name> Use <name> as the app name
171
+ ```
186
172
 
187
- Override with the `PORTLESS_STATE_DIR` environment variable if needed.
173
+ ### Environment variables
188
174
 
189
- ## Development
190
-
191
- This repo is a pnpm workspace monorepo using [Turborepo](https://turbo.build). The publishable package lives in `packages/portless/`.
175
+ ```
176
+ # Configuration
177
+ PORTLESS_PORT=<number> Override the default proxy port
178
+ PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
179
+ PORTLESS_HTTPS=1 Always enable HTTPS
180
+ PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
181
+ PORTLESS_SYNC_HOSTS=1 Auto-sync /etc/hosts (auto-enabled for custom TLDs)
182
+ PORTLESS_STATE_DIR=<path> Override the state directory
192
183
 
193
- ```bash
194
- pnpm install # Install all dependencies
195
- pnpm build # Build all packages
196
- pnpm test # Run tests
197
- pnpm test:coverage # Run tests with coverage
198
- pnpm test:watch # Run tests in watch mode
199
- pnpm lint # Lint all packages
200
- pnpm typecheck # Type-check all packages
201
- pnpm format # Format all files with Prettier
184
+ # Injected into child processes
185
+ PORT Ephemeral port the child should listen on
186
+ HOST Always 127.0.0.1
187
+ PORTLESS_URL Public URL (e.g. https://myapp.localhost)
202
188
  ```
203
189
 
190
+ > **Reserved names:** `run`, `alias`, `hosts`, `list`, `trust`, and `proxy` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
191
+
204
192
  ## Safari / DNS
205
193
 
206
194
  `.localhost` subdomains auto-resolve to `127.0.0.1` in Chrome, Firefox, and Edge. Safari relies on the system DNS resolver, which may not handle `.localhost` subdomains on all configurations.
@@ -208,23 +196,15 @@ pnpm format # Format all files with Prettier
208
196
  If Safari can't find your `.localhost` URL:
209
197
 
210
198
  ```bash
211
- # Add current routes to /etc/hosts (requires sudo)
212
- sudo portless hosts sync
213
-
214
- # Clean up later
215
- sudo portless hosts clean
199
+ sudo portless hosts sync # Add current routes to /etc/hosts
200
+ sudo portless hosts clean # Clean up later
216
201
  ```
217
202
 
218
- To auto-sync `/etc/hosts` whenever routes change, set `PORTLESS_SYNC_HOSTS=1` and start the proxy with sudo:
219
-
220
- ```bash
221
- export PORTLESS_SYNC_HOSTS=1
222
- sudo portless proxy start
223
- ```
203
+ Auto-syncs `/etc/hosts` for custom TLDs (e.g. `--tld test`). For `.localhost`, set `PORTLESS_SYNC_HOSTS=1` to enable. Disable with `PORTLESS_SYNC_HOSTS=0`.
224
204
 
225
205
  ## Proxying Between Portless Apps
226
206
 
227
- If your frontend dev server (e.g. Vite, webpack) proxies API requests to another portless app, make sure the proxy rewrites the `Host` header. Without this, the proxy sends the **original** Host header, causing portless to route the request back to the frontend in an infinite loop.
207
+ If your frontend dev server (e.g. Vite, webpack) proxies API requests to another portless app, make sure the proxy rewrites the `Host` header. Without this, portless routes the request back to the frontend in an infinite loop.
228
208
 
229
209
  **Vite** (`vite.config.ts`):
230
210
 
@@ -233,7 +213,7 @@ server: {
233
213
  proxy: {
234
214
  "/api": {
235
215
  target: "http://api.myapp.localhost:1355",
236
- changeOrigin: true, // Required: rewrites Host header to match target
216
+ changeOrigin: true,
237
217
  ws: true,
238
218
  },
239
219
  },
@@ -247,13 +227,27 @@ devServer: {
247
227
  proxy: [{
248
228
  context: ["/api"],
249
229
  target: "http://api.myapp.localhost:1355",
250
- changeOrigin: true, // Required: rewrites Host header to match target
230
+ changeOrigin: true,
251
231
  }],
252
232
  }
253
233
  ```
254
234
 
255
235
  Portless detects this misconfiguration and responds with `508 Loop Detected` along with a message pointing to this fix.
256
236
 
237
+ ## Development
238
+
239
+ This repo is a pnpm workspace monorepo using [Turborepo](https://turbo.build). The publishable package lives in `packages/portless/`.
240
+
241
+ ```bash
242
+ pnpm install # Install all dependencies
243
+ pnpm build # Build all packages
244
+ pnpm test # Run tests
245
+ pnpm test:coverage # Run tests with coverage
246
+ pnpm lint # Lint all packages
247
+ pnpm typecheck # Type-check all packages
248
+ pnpm format # Format all files with Prettier
249
+ ```
250
+
257
251
  ## Requirements
258
252
 
259
253
  - Node.js 20+
@@ -22,15 +22,19 @@ function formatUrl(hostname, proxyPort, tls = false) {
22
22
  const defaultPort = tls ? 443 : 80;
23
23
  return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
24
24
  }
25
- function parseHostname(input) {
25
+ function parseHostname(input, tld = "localhost") {
26
+ const suffix = `.${tld}`;
26
27
  let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
27
- if (!hostname || hostname === ".localhost") {
28
+ if (tld !== "localhost" && hostname.endsWith(".localhost")) {
29
+ hostname = hostname.slice(0, -".localhost".length);
30
+ }
31
+ if (!hostname || hostname === suffix) {
28
32
  throw new Error("Hostname cannot be empty");
29
33
  }
30
- if (!hostname.endsWith(".localhost")) {
31
- hostname = `${hostname}.localhost`;
34
+ if (!hostname.endsWith(suffix)) {
35
+ hostname = `${hostname}${suffix}`;
32
36
  }
33
- const name = hostname.replace(/\.localhost$/, "");
37
+ const name = hostname.slice(0, -suffix.length);
34
38
  if (name.includes("..")) {
35
39
  throw new Error(`Invalid hostname "${name}": consecutive dots are not allowed`);
36
40
  }
@@ -39,6 +43,14 @@ function parseHostname(input) {
39
43
  `Invalid hostname "${name}": must contain only lowercase letters, digits, hyphens, and dots`
40
44
  );
41
45
  }
46
+ const labels = name.split(".");
47
+ for (const label of labels) {
48
+ if (label.length > 63) {
49
+ throw new Error(
50
+ `Invalid hostname "${name}": label "${label}" exceeds 63-character DNS limit`
51
+ );
52
+ }
53
+ }
42
54
  return hostname;
43
55
  }
44
56
 
@@ -291,7 +303,14 @@ function findRoute(routes, host) {
291
303
  return routes.find((r) => r.hostname === host) || routes.find((r) => host.endsWith("." + r.hostname));
292
304
  }
293
305
  function createProxyServer(options) {
294
- const { getRoutes, proxyPort, onError = (msg) => console.error(msg), tls } = options;
306
+ const {
307
+ getRoutes,
308
+ proxyPort,
309
+ tld = "localhost",
310
+ onError = (msg) => console.error(msg),
311
+ tls
312
+ } = options;
313
+ const tldSuffix = `.${tld}`;
295
314
  const isTls = !!tls;
296
315
  const handleRequest = (req, res) => {
297
316
  res.setHeader(PORTLESS_HEADER, "1");
@@ -314,7 +333,7 @@ function createProxyServer(options) {
314
333
  "Loop Detected",
315
334
  `<div class="content"><p class="desc">This request has passed through portless ${hops} times. This usually means a dev server (Vite, webpack, etc.) is proxying requests back through portless without rewriting the Host header.</p><div class="section"><p class="label">Fix: add changeOrigin to your proxy config</p><pre class="terminal">proxy: {
316
335
  "/api": {
317
- target: "http://&lt;backend&gt;.localhost:&lt;port&gt;",
336
+ target: "http://&lt;backend&gt;${escapeHtml(tldSuffix)}:&lt;port&gt;",
318
337
  changeOrigin: true,
319
338
  },
320
339
  }</pre></div></div>`
@@ -325,13 +344,15 @@ function createProxyServer(options) {
325
344
  const route = findRoute(routes, host);
326
345
  if (!route) {
327
346
  const safeHost = escapeHtml(host);
328
- const routesList = routes.length > 0 ? `<div class="section"><p class="label">Active apps</p><ul class="card">${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}" class="card-link"><span class="name">${escapeHtml(r.hostname)}</span><span class="meta"><code class="port">localhost:${escapeHtml(String(r.port))}</code><span class="arrow">${ARROW_SVG}</span></span></a></li>`).join("")}</ul></div>` : '<p class="empty">No apps running.</p>';
347
+ const strippedHost = host.endsWith(tldSuffix) ? host.slice(0, -tldSuffix.length) : host;
348
+ const safeSuggestion = escapeHtml(strippedHost);
349
+ const routesList = routes.length > 0 ? `<div class="section"><p class="label">Active apps</p><ul class="card">${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}" class="card-link"><span class="name">${escapeHtml(r.hostname)}</span><span class="meta"><code class="port">127.0.0.1:${escapeHtml(String(r.port))}</code><span class="arrow">${ARROW_SVG}</span></span></a></li>`).join("")}</ul></div>` : '<p class="empty">No apps running.</p>';
329
350
  res.writeHead(404, { "Content-Type": "text/html" });
330
351
  res.end(
331
352
  renderPage(
332
353
  404,
333
354
  "Not Found",
334
- `<div class="content"><p class="desc">No app registered for <strong>${safeHost}</strong></p>${routesList}<div class="section"><div class="terminal"><span class="prompt">$ </span>portless ${safeHost.replace(".localhost", "")} your-command</div></div></div>`
355
+ `<div class="content"><p class="desc">No app registered for <strong>${safeHost}</strong></p>${routesList}<div class="section"><div class="terminal"><span class="prompt">$ </span>portless ${safeSuggestion} your-command</div></div></div>`
335
356
  )
336
357
  );
337
358
  return;
@@ -587,7 +608,7 @@ function getManagedHostnames() {
587
608
  return parts.length >= 2 ? parts[1] : "";
588
609
  }).filter(Boolean);
589
610
  }
590
- function checkLocalhostResolution(hostname) {
611
+ function checkHostResolution(hostname) {
591
612
  return new Promise((resolve) => {
592
613
  dns.lookup(hostname, { family: 4 }, (err, address) => {
593
614
  if (err) {
@@ -667,6 +688,54 @@ function writeTlsMarker(dir, enabled) {
667
688
  }
668
689
  }
669
690
  }
691
+ var DEFAULT_TLD = "localhost";
692
+ var RISKY_TLDS = /* @__PURE__ */ new Map([
693
+ ["local", "conflicts with mDNS/Bonjour on macOS"],
694
+ ["dev", "Google-owned; browsers force HTTPS via preloaded HSTS"],
695
+ ["com", "public TLD -- DNS requests will leak to the internet"],
696
+ ["org", "public TLD -- DNS requests will leak to the internet"],
697
+ ["net", "public TLD -- DNS requests will leak to the internet"],
698
+ ["io", "public TLD -- DNS requests will leak to the internet"],
699
+ ["app", "public TLD -- DNS requests will leak to the internet"],
700
+ ["edu", "public TLD -- DNS requests will leak to the internet"],
701
+ ["gov", "public TLD -- DNS requests will leak to the internet"],
702
+ ["mil", "public TLD -- DNS requests will leak to the internet"],
703
+ ["int", "public TLD -- DNS requests will leak to the internet"]
704
+ ]);
705
+ function validateTld(tld) {
706
+ if (!tld) return "TLD cannot be empty";
707
+ if (!/^[a-z0-9]+$/.test(tld)) {
708
+ return `Invalid TLD "${tld}": must contain only lowercase letters and digits`;
709
+ }
710
+ return null;
711
+ }
712
+ var TLD_FILE = "proxy.tld";
713
+ function readTldFromDir(dir) {
714
+ try {
715
+ const raw = fs3.readFileSync(path.join(dir, TLD_FILE), "utf-8").trim();
716
+ return raw || DEFAULT_TLD;
717
+ } catch {
718
+ return DEFAULT_TLD;
719
+ }
720
+ }
721
+ function writeTldFile(dir, tld) {
722
+ const filePath = path.join(dir, TLD_FILE);
723
+ if (tld === DEFAULT_TLD) {
724
+ try {
725
+ fs3.unlinkSync(filePath);
726
+ } catch {
727
+ }
728
+ } else {
729
+ fs3.writeFileSync(filePath, tld, { mode: 420 });
730
+ }
731
+ }
732
+ function getDefaultTld() {
733
+ const val = process.env.PORTLESS_TLD?.trim().toLowerCase();
734
+ if (!val) return DEFAULT_TLD;
735
+ const err = validateTld(val);
736
+ if (err) throw new Error(`PORTLESS_TLD: ${err}`);
737
+ return val;
738
+ }
670
739
  function isHttpsEnvEnabled() {
671
740
  const val = process.env.PORTLESS_HTTPS;
672
741
  return val === "1" || val === "true";
@@ -676,24 +745,36 @@ async function discoverState() {
676
745
  const dir = process.env.PORTLESS_STATE_DIR;
677
746
  const port = readPortFromDir(dir) ?? getDefaultPort();
678
747
  const tls = readTlsMarker(dir);
679
- return { dir, port, tls };
748
+ const tld = readTldFromDir(dir);
749
+ return { dir, port, tls, tld };
680
750
  }
681
751
  const userPort = readPortFromDir(USER_STATE_DIR);
682
752
  if (userPort !== null) {
683
- const tls = readTlsMarker(USER_STATE_DIR);
684
- if (await isProxyRunning(userPort, tls)) {
685
- return { dir: USER_STATE_DIR, port: userPort, tls };
753
+ if (await isProxyRunning(userPort)) {
754
+ const tls = readTlsMarker(USER_STATE_DIR);
755
+ const tld = readTldFromDir(USER_STATE_DIR);
756
+ return { dir: USER_STATE_DIR, port: userPort, tls, tld };
686
757
  }
687
758
  }
688
759
  const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
689
760
  if (systemPort !== null) {
690
- const tls = readTlsMarker(SYSTEM_STATE_DIR);
691
- if (await isProxyRunning(systemPort, tls)) {
692
- return { dir: SYSTEM_STATE_DIR, port: systemPort, tls };
761
+ if (await isProxyRunning(systemPort)) {
762
+ const tls = readTlsMarker(SYSTEM_STATE_DIR);
763
+ const tld = readTldFromDir(SYSTEM_STATE_DIR);
764
+ return { dir: SYSTEM_STATE_DIR, port: systemPort, tls, tld };
693
765
  }
694
766
  }
695
767
  const defaultPort = getDefaultPort();
696
- return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
768
+ const probePorts = /* @__PURE__ */ new Set([defaultPort, 443, 80]);
769
+ for (const port of probePorts) {
770
+ if (await isProxyRunning(port)) {
771
+ const dir = resolveStateDir(port);
772
+ const tls = readTlsMarker(dir);
773
+ const tld = readTldFromDir(dir);
774
+ return { dir, port, tls, tld };
775
+ }
776
+ }
777
+ return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false, tld: getDefaultTld() };
697
778
  }
698
779
  async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
699
780
  if (minPort > maxPort) {
@@ -1067,12 +1148,18 @@ export {
1067
1148
  syncHostsFile,
1068
1149
  cleanHostsFile,
1069
1150
  getManagedHostnames,
1070
- checkLocalhostResolution,
1151
+ checkHostResolution,
1071
1152
  PRIVILEGED_PORT_THRESHOLD,
1072
1153
  getDefaultPort,
1073
1154
  resolveStateDir,
1074
1155
  readTlsMarker,
1075
1156
  writeTlsMarker,
1157
+ DEFAULT_TLD,
1158
+ RISKY_TLDS,
1159
+ validateTld,
1160
+ readTldFromDir,
1161
+ writeTldFile,
1162
+ getDefaultTld,
1076
1163
  isHttpsEnvEnabled,
1077
1164
  discoverState,
1078
1165
  findFreePort,
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DEFAULT_TLD,
3
4
  FILE_MODE,
4
5
  PRIVILEGED_PORT_THRESHOLD,
6
+ RISKY_TLDS,
5
7
  RouteConflictError,
6
8
  RouteStore,
7
9
  cleanHostsFile,
@@ -12,19 +14,23 @@ import {
12
14
  fixOwnership,
13
15
  formatUrl,
14
16
  getDefaultPort,
17
+ getDefaultTld,
15
18
  injectFrameworkFlags,
16
19
  isErrnoException,
17
20
  isHttpsEnvEnabled,
18
21
  isProxyRunning,
19
22
  parseHostname,
20
23
  prompt,
24
+ readTldFromDir,
21
25
  readTlsMarker,
22
26
  resolveStateDir,
23
27
  spawnCommand,
24
28
  syncHostsFile,
29
+ validateTld,
25
30
  waitForProxy,
31
+ writeTldFile,
26
32
  writeTlsMarker
27
- } from "./chunk-P3DHZHEZ.js";
33
+ } from "./chunk-AB3HUERH.js";
28
34
 
29
35
  // src/cli.ts
30
36
  import chalk from "chalk";
@@ -218,19 +224,24 @@ function isCATrusted(stateDir) {
218
224
  }
219
225
  function isCATrustedMacOS(caCertPath) {
220
226
  try {
221
- const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
222
- for (const keychain of [loginKeychainPath(), "/Library/Keychains/System.keychain"]) {
223
- try {
224
- const result = execFileSync("security", ["find-certificate", "-a", "-Z", keychain], {
225
- encoding: "utf-8",
226
- timeout: 5e3,
227
- stdio: ["pipe", "pipe", "pipe"]
228
- });
229
- if (result.toLowerCase().includes(fingerprint)) return true;
230
- } catch {
231
- }
227
+ const isRoot = (process.getuid?.() ?? -1) === 0;
228
+ const sudoUser = process.env.SUDO_USER;
229
+ if (isRoot && sudoUser) {
230
+ execFileSync(
231
+ "sudo",
232
+ ["-u", sudoUser, "security", "verify-cert", "-c", caCertPath, "-L", "-p", "ssl"],
233
+ {
234
+ stdio: "pipe",
235
+ timeout: 5e3
236
+ }
237
+ );
238
+ } else {
239
+ execFileSync("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
240
+ stdio: "pipe",
241
+ timeout: 5e3
242
+ });
232
243
  }
233
- return false;
244
+ return true;
234
245
  } catch {
235
246
  return false;
236
247
  }
@@ -364,12 +375,12 @@ async function generateHostCertAsync(stateDir, hostname) {
364
375
  fixOwnership(keyPath, certPath);
365
376
  return { certPath, keyPath };
366
377
  }
367
- function createSNICallback(stateDir, defaultCert, defaultKey) {
378
+ function createSNICallback(stateDir, defaultCert, defaultKey, tld = "localhost") {
368
379
  const cache = /* @__PURE__ */ new Map();
369
380
  const pending = /* @__PURE__ */ new Map();
370
381
  const defaultCtx = tls.createSecureContext({ cert: defaultCert, key: defaultKey });
371
382
  return (servername, cb) => {
372
- if (servername === "localhost") {
383
+ if (servername === tld) {
373
384
  cb(null, defaultCtx);
374
385
  return;
375
386
  }
@@ -422,12 +433,29 @@ function trustCA(stateDir) {
422
433
  }
423
434
  try {
424
435
  if (process.platform === "darwin") {
425
- const keychain = loginKeychainPath();
426
- execFileSync(
427
- "security",
428
- ["add-trusted-cert", "-r", "trustRoot", "-k", keychain, caCertPath],
429
- { stdio: "pipe", timeout: 3e4 }
430
- );
436
+ const isRoot = (process.getuid?.() ?? -1) === 0;
437
+ if (isRoot) {
438
+ execFileSync(
439
+ "security",
440
+ [
441
+ "add-trusted-cert",
442
+ "-d",
443
+ "-r",
444
+ "trustRoot",
445
+ "-k",
446
+ "/Library/Keychains/System.keychain",
447
+ caCertPath
448
+ ],
449
+ { stdio: "pipe", timeout: 3e4 }
450
+ );
451
+ } else {
452
+ const keychain = loginKeychainPath();
453
+ execFileSync(
454
+ "security",
455
+ ["add-trusted-cert", "-r", "trustRoot", "-k", keychain, caCertPath],
456
+ { stdio: "pipe", timeout: 3e4 }
457
+ );
458
+ }
431
459
  return { trusted: true };
432
460
  } else if (process.platform === "linux") {
433
461
  const config = getLinuxCATrustConfig();
@@ -453,11 +481,21 @@ function trustCA(stateDir) {
453
481
  }
454
482
 
455
483
  // src/auto.ts
484
+ import { createHash } from "crypto";
456
485
  import { execFileSync as execFileSync2 } from "child_process";
457
486
  import * as fs2 from "fs";
458
487
  import * as path2 from "path";
488
+ var MAX_DNS_LABEL_LENGTH = 63;
489
+ function truncateLabel(label) {
490
+ if (label.length <= MAX_DNS_LABEL_LENGTH) return label;
491
+ const hash = createHash("sha256").update(label).digest("hex").slice(0, 6);
492
+ const maxPrefixLength = MAX_DNS_LABEL_LENGTH - 7;
493
+ const prefix = label.slice(0, maxPrefixLength).replace(/-+$/, "");
494
+ return `${prefix}-${hash}`;
495
+ }
459
496
  function sanitizeForHostname(name) {
460
- return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
497
+ const sanitized = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
498
+ return truncateLabel(sanitized);
461
499
  }
462
500
  function inferProjectName(cwd = process.cwd()) {
463
501
  const pkgResult = findPackageJsonName(cwd);
@@ -602,7 +640,7 @@ var DEBOUNCE_MS = 100;
602
640
  var POLL_INTERVAL_MS = 3e3;
603
641
  var EXIT_TIMEOUT_MS = 2e3;
604
642
  var SUDO_SPAWN_TIMEOUT_MS = 3e4;
605
- function startProxyServer(store, proxyPort, tlsOptions) {
643
+ function startProxyServer(store, proxyPort, tld, tlsOptions) {
606
644
  store.ensureDir();
607
645
  const isTls = !!tlsOptions;
608
646
  const routesPath = store.getRoutesPath();
@@ -618,7 +656,8 @@ function startProxyServer(store, proxyPort, tlsOptions) {
618
656
  let debounceTimer = null;
619
657
  let watcher = null;
620
658
  let pollingInterval = null;
621
- const autoSyncHosts = process.env.PORTLESS_SYNC_HOSTS === "1";
659
+ const syncVal = process.env.PORTLESS_SYNC_HOSTS;
660
+ const autoSyncHosts = syncVal === "1" || syncVal === "true" || tld !== DEFAULT_TLD && syncVal !== "0" && syncVal !== "false";
622
661
  const reloadRoutes = () => {
623
662
  try {
624
663
  cachedRoutes = store.loadRoutes();
@@ -643,6 +682,7 @@ function startProxyServer(store, proxyPort, tlsOptions) {
643
682
  const server = createProxyServer({
644
683
  getRoutes: () => cachedRoutes,
645
684
  proxyPort,
685
+ tld,
646
686
  onError: (msg) => console.error(chalk.red(msg)),
647
687
  tls: tlsOptions
648
688
  });
@@ -668,9 +708,11 @@ function startProxyServer(store, proxyPort, tlsOptions) {
668
708
  fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
669
709
  fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
670
710
  writeTlsMarker(store.dir, isTls);
711
+ writeTldFile(store.dir, tld);
671
712
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
672
713
  const proto = isTls ? "HTTPS/2" : "HTTP";
673
- console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}`));
714
+ const tldLabel = tld !== DEFAULT_TLD ? ` (TLD: .${tld})` : "";
715
+ console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}${tldLabel}`));
674
716
  });
675
717
  let exiting = false;
676
718
  const cleanup = () => {
@@ -690,6 +732,7 @@ function startProxyServer(store, proxyPort, tlsOptions) {
690
732
  } catch {
691
733
  }
692
734
  writeTlsMarker(store.dir, false);
735
+ writeTldFile(store.dir, DEFAULT_TLD);
693
736
  if (autoSyncHosts) cleanHostsFile();
694
737
  server.close(() => process.exit(0));
695
738
  setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
@@ -699,12 +742,12 @@ function startProxyServer(store, proxyPort, tlsOptions) {
699
742
  console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
700
743
  console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
701
744
  }
702
- async function stopProxy(store, proxyPort, tls2) {
745
+ async function stopProxy(store, proxyPort, _tls) {
703
746
  const pidPath = store.pidPath;
704
747
  const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
705
748
  const sudoHint = needsSudo ? "sudo " : "";
706
749
  if (!fs3.existsSync(pidPath)) {
707
- if (await isProxyRunning(proxyPort, tls2)) {
750
+ if (await isProxyRunning(proxyPort)) {
708
751
  console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
709
752
  const pid = findPidOnPort(proxyPort);
710
753
  if (pid !== null) {
@@ -759,7 +802,7 @@ async function stopProxy(store, proxyPort, tls2) {
759
802
  }
760
803
  return;
761
804
  }
762
- if (!await isProxyRunning(proxyPort, tls2)) {
805
+ if (!await isProxyRunning(proxyPort)) {
763
806
  console.log(
764
807
  chalk.yellow(
765
808
  `PID file exists but port ${proxyPort} is not listening. The PID may have been recycled.`
@@ -806,8 +849,22 @@ function listRoutes(store, proxyPort, tls2) {
806
849
  }
807
850
  console.log();
808
851
  }
809
- async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force, autoInfo, desiredPort) {
810
- const hostname = parseHostname(name);
852
+ async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort) {
853
+ const hostname = parseHostname(name, tld);
854
+ let envTld;
855
+ try {
856
+ envTld = getDefaultTld();
857
+ } catch (err) {
858
+ console.error(chalk.red(`Error: ${err.message}`));
859
+ process.exit(1);
860
+ }
861
+ if (envTld !== DEFAULT_TLD && envTld !== tld) {
862
+ console.warn(
863
+ chalk.yellow(
864
+ `Warning: PORTLESS_TLD=${envTld} but the running proxy uses .${tld}. Using .${tld}.`
865
+ )
866
+ );
867
+ }
811
868
  console.log(chalk.blue.bold(`
812
869
  portless
813
870
  `));
@@ -845,6 +902,7 @@ portless
845
902
  console.log(chalk.yellow("Starting proxy (requires sudo)..."));
846
903
  const startArgs = [process.execPath, process.argv[1], "proxy", "start"];
847
904
  if (wantHttps) startArgs.push("--https");
905
+ if (tld !== DEFAULT_TLD) startArgs.push("--tld", tld);
848
906
  const result = spawnSync("sudo", startArgs, {
849
907
  stdio: "inherit",
850
908
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -859,6 +917,7 @@ portless
859
917
  console.log(chalk.yellow("Starting proxy..."));
860
918
  const startArgs = [process.argv[1], "proxy", "start"];
861
919
  if (wantHttps) startArgs.push("--https");
920
+ if (tld !== DEFAULT_TLD) startArgs.push("--tld", tld);
862
921
  const result = spawnSync(process.execPath, startArgs, {
863
922
  stdio: "inherit",
864
923
  timeout: SUDO_SPAWN_TIMEOUT_MS
@@ -871,6 +930,7 @@ portless
871
930
  }
872
931
  }
873
932
  const autoTls = readTlsMarker(stateDir);
933
+ tld = readTldFromDir(stateDir);
874
934
  if (!await waitForProxy(defaultPort, void 0, void 0, autoTls)) {
875
935
  console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
876
936
  const logPath = path3.join(stateDir, "proxy.log");
@@ -918,7 +978,7 @@ portless
918
978
  PORT: port.toString(),
919
979
  HOST: "127.0.0.1",
920
980
  PORTLESS_URL: finalUrl,
921
- __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
981
+ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${tld}`
922
982
  },
923
983
  onCleanup: () => {
924
984
  try {
@@ -953,6 +1013,7 @@ function appPortFromEnv() {
953
1013
  function parseRunArgs(args) {
954
1014
  let force = false;
955
1015
  let appPort;
1016
+ let name;
956
1017
  let i = 0;
957
1018
  while (i < args.length && args[i].startsWith("-")) {
958
1019
  if (args[i] === "--") {
@@ -966,6 +1027,7 @@ ${chalk.bold("Usage:")}
966
1027
  ${chalk.cyan("portless run [options] <command...>")}
967
1028
 
968
1029
  ${chalk.bold("Options:")}
1030
+ --name <name> Override the inferred base name (worktree prefix still applies)
969
1031
  --force Override an existing route registered by another process
970
1032
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
971
1033
  --help, -h Show this help
@@ -975,11 +1037,13 @@ ${chalk.bold("Name inference (in order):")}
975
1037
  2. Git repo root directory name
976
1038
  3. Current directory basename
977
1039
 
1040
+ Use --name to override the inferred name while keeping worktree prefixes.
978
1041
  In git worktrees, the branch name is prepended as a subdomain prefix
979
1042
  (e.g. feature-auth.myapp.localhost).
980
1043
 
981
1044
  ${chalk.bold("Examples:")}
982
1045
  portless run next dev # -> http://<project>.localhost:1355
1046
+ portless run --name myapp next dev # -> http://myapp.localhost:1355
983
1047
  portless run vite dev # -> http://<project>.localhost:1355
984
1048
  portless run --app-port 3000 pnpm start
985
1049
  `);
@@ -989,15 +1053,23 @@ ${chalk.bold("Examples:")}
989
1053
  } else if (args[i] === "--app-port") {
990
1054
  i++;
991
1055
  appPort = parseAppPort(args[i]);
1056
+ } else if (args[i] === "--name") {
1057
+ i++;
1058
+ if (!args[i] || args[i].startsWith("-")) {
1059
+ console.error(chalk.red("Error: --name requires a name value."));
1060
+ console.error(chalk.cyan(" portless run --name <name> <command...>"));
1061
+ process.exit(1);
1062
+ }
1063
+ name = args[i];
992
1064
  } else {
993
1065
  console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
994
- console.error(chalk.blue("Known flags: --force, --app-port, --help"));
1066
+ console.error(chalk.blue("Known flags: --name, --force, --app-port, --help"));
995
1067
  process.exit(1);
996
1068
  }
997
1069
  i++;
998
1070
  }
999
1071
  if (!appPort) appPort = appPortFromEnv();
1000
- return { force, appPort, commandArgs: args.slice(i) };
1072
+ return { force, appPort, name, commandArgs: args.slice(i) };
1001
1073
  }
1002
1074
  function parseAppArgs(args) {
1003
1075
  let force = false;
@@ -1058,6 +1130,7 @@ ${chalk.bold("Usage:")}
1058
1130
  ${chalk.cyan("portless proxy stop")} Stop the proxy
1059
1131
  ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
1060
1132
  ${chalk.cyan("portless run <cmd>")} Infer name from project, run through proxy
1133
+ ${chalk.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
1061
1134
  ${chalk.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
1062
1135
  ${chalk.cyan("portless alias --remove <name>")} Remove a static route
1063
1136
  ${chalk.cyan("portless list")} Show active routes
@@ -1073,6 +1146,7 @@ ${chalk.bold("Examples:")}
1073
1146
  portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
1074
1147
  portless run next dev # -> http://<project>.localhost:1355
1075
1148
  portless run next dev # in worktree -> http://<worktree>.<project>.localhost:1355
1149
+ portless get backend # -> http://backend.localhost:1355 (for cross-service refs)
1076
1150
  # Wildcard subdomains: tenant.myapp.localhost also routes to myapp
1077
1151
 
1078
1152
  ${chalk.bold("In package.json:")}
@@ -1097,7 +1171,7 @@ ${chalk.bold("HTTP/2 + HTTPS:")}
1097
1171
  system trust store. No browser warnings. No sudo required on macOS.
1098
1172
 
1099
1173
  ${chalk.bold("Options:")}
1100
- run <cmd> Infer project name from package.json / git / cwd
1174
+ run [--name <name>] <cmd> Infer project name (or override with --name)
1101
1175
  Adds worktree prefix in git worktrees
1102
1176
  -p, --port <number> Port for the proxy to listen on (default: 1355)
1103
1177
  Ports < 1024 require sudo
@@ -1106,6 +1180,7 @@ ${chalk.bold("Options:")}
1106
1180
  --key <path> Use a custom TLS private key (implies --https)
1107
1181
  --no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
1108
1182
  --foreground Run proxy in foreground (for debugging)
1183
+ --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
1109
1184
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
1110
1185
  --force Override an existing route registered by another process
1111
1186
  --name <name> Use <name> as the app name (bypasses subcommand dispatch)
@@ -1114,10 +1189,11 @@ ${chalk.bold("Options:")}
1114
1189
  ${chalk.bold("Environment variables:")}
1115
1190
  PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
1116
1191
  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)
1192
+ PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
1193
+ PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
1194
+ PORTLESS_SYNC_HOSTS=1 Auto-sync /etc/hosts (auto-enabled for custom TLDs)
1119
1195
  PORTLESS_STATE_DIR=<path> Override the state directory
1120
- PORTLESS=0 | PORTLESS=skip Run command directly without proxy
1196
+ PORTLESS=0 Run command directly without proxy
1121
1197
 
1122
1198
  ${chalk.bold("Child process environment:")}
1123
1199
  PORT Ephemeral port the child should listen on
@@ -1127,26 +1203,24 @@ ${chalk.bold("Child process environment:")}
1127
1203
  ${chalk.bold("Safari / DNS:")}
1128
1204
  .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1129
1205
  Safari relies on the system DNS resolver, which may not handle them.
1130
- If Safari can't find your .localhost URL, run:
1206
+ Auto-syncs /etc/hosts for custom TLDs (e.g. --tld test). For .localhost,
1207
+ set PORTLESS_SYNC_HOSTS=1 to enable. To manually sync:
1131
1208
  ${chalk.cyan("sudo portless hosts sync")}
1132
- This adds entries to /etc/hosts. Clean up later with:
1209
+ Clean up later with:
1133
1210
  ${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
1211
 
1137
1212
  ${chalk.bold("Skip portless:")}
1138
1213
  PORTLESS=0 pnpm dev # Runs command directly without proxy
1139
- PORTLESS=skip pnpm dev # Same as above
1140
1214
 
1141
1215
  ${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.
1216
+ run, get, alias, hosts, list, trust, proxy are subcommands and cannot
1217
+ be used as app names directly. Use "portless run" to infer the name,
1218
+ or "portless --name <name>" to force any name including reserved ones.
1145
1219
  `);
1146
1220
  process.exit(0);
1147
1221
  }
1148
1222
  function printVersion() {
1149
- console.log("0.5.1");
1223
+ console.log("0.6.0");
1150
1224
  process.exit(0);
1151
1225
  }
1152
1226
  async function handleTrust() {
@@ -1171,6 +1245,60 @@ async function handleList() {
1171
1245
  });
1172
1246
  listRoutes(store, port, tls2);
1173
1247
  }
1248
+ async function handleGet(args) {
1249
+ if (args[1] === "--help" || args[1] === "-h") {
1250
+ console.log(`
1251
+ ${chalk.bold("portless get")} - Print the URL for a service.
1252
+
1253
+ ${chalk.bold("Usage:")}
1254
+ ${chalk.cyan("portless get <name>")}
1255
+
1256
+ Constructs the URL using the same hostname and worktree logic as
1257
+ "portless run", then prints it to stdout. Useful for wiring services
1258
+ together:
1259
+
1260
+ BACKEND_URL=$(portless get backend)
1261
+
1262
+ ${chalk.bold("Options:")}
1263
+ --no-worktree Skip worktree prefix detection
1264
+ --help, -h Show this help
1265
+
1266
+ ${chalk.bold("Examples:")}
1267
+ portless get backend # -> http://backend.localhost:1355
1268
+ portless get backend # in worktree -> http://auth.backend.localhost:1355
1269
+ portless get backend --no-worktree # -> http://backend.localhost:1355 (skip worktree)
1270
+ `);
1271
+ process.exit(0);
1272
+ }
1273
+ let skipWorktree = false;
1274
+ const positional = [];
1275
+ for (let i = 1; i < args.length; i++) {
1276
+ if (args[i] === "--no-worktree") {
1277
+ skipWorktree = true;
1278
+ } else if (args[i].startsWith("-")) {
1279
+ console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1280
+ console.error(chalk.blue("Known flags: --no-worktree, --help"));
1281
+ process.exit(1);
1282
+ } else {
1283
+ positional.push(args[i]);
1284
+ }
1285
+ }
1286
+ if (positional.length === 0) {
1287
+ console.error(chalk.red("Error: Missing service name."));
1288
+ console.error(chalk.blue("Usage:"));
1289
+ console.error(chalk.cyan(" portless get <name>"));
1290
+ console.error(chalk.blue("Example:"));
1291
+ console.error(chalk.cyan(" portless get backend"));
1292
+ process.exit(1);
1293
+ }
1294
+ const name = positional[0];
1295
+ const worktree = skipWorktree ? null : detectWorktreePrefix();
1296
+ const effectiveName = worktree ? `${worktree.prefix}.${name}` : name;
1297
+ const { port, tls: tls2, tld } = await discoverState();
1298
+ const hostname = parseHostname(effectiveName, tld);
1299
+ const url = formatUrl(hostname, port, tls2);
1300
+ process.stdout.write(url + "\n");
1301
+ }
1174
1302
  async function handleAlias(args) {
1175
1303
  if (args[1] === "--help" || args[1] === "-h") {
1176
1304
  console.log(`
@@ -1188,7 +1316,7 @@ ${chalk.bold("Examples:")}
1188
1316
  `);
1189
1317
  process.exit(0);
1190
1318
  }
1191
- const { dir } = await discoverState();
1319
+ const { dir, tld } = await discoverState();
1192
1320
  const store = new RouteStore(dir, {
1193
1321
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1194
1322
  });
@@ -1199,7 +1327,7 @@ ${chalk.bold("Examples:")}
1199
1327
  console.error(chalk.cyan(" portless alias --remove <name>"));
1200
1328
  process.exit(1);
1201
1329
  }
1202
- const hostname2 = parseHostname(aliasName2);
1330
+ const hostname2 = parseHostname(aliasName2, tld);
1203
1331
  const routes = store.loadRoutes();
1204
1332
  const existing = routes.find((r) => r.hostname === hostname2 && r.pid === 0);
1205
1333
  if (!existing) {
@@ -1221,7 +1349,7 @@ ${chalk.bold("Examples:")}
1221
1349
  console.error(chalk.cyan(" portless alias my-postgres 5432"));
1222
1350
  process.exit(1);
1223
1351
  }
1224
- const hostname = parseHostname(aliasName);
1352
+ const hostname = parseHostname(aliasName, tld);
1225
1353
  const port = parseInt(aliasPort, 10);
1226
1354
  if (isNaN(port) || port < 1 || port > 65535) {
1227
1355
  console.error(chalk.red(`Error: Invalid port "${aliasPort}". Must be 1-65535.`));
@@ -1229,7 +1357,7 @@ ${chalk.bold("Examples:")}
1229
1357
  }
1230
1358
  const force = args.includes("--force");
1231
1359
  store.addRoute(hostname, port, 0, force);
1232
- console.log(chalk.green(`Alias registered: ${hostname} -> localhost:${port}`));
1360
+ console.log(chalk.green(`Alias registered: ${hostname} -> 127.0.0.1:${port}`));
1233
1361
  }
1234
1362
  async function handleHosts(args) {
1235
1363
  if (args[1] === "--help" || args[1] === "-h") {
@@ -1244,8 +1372,8 @@ ${chalk.bold("Usage:")}
1244
1372
  ${chalk.cyan("sudo portless hosts clean")} Remove portless entries from /etc/hosts
1245
1373
 
1246
1374
  ${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.
1375
+ Auto-enabled for custom TLDs (e.g. --tld test). For .localhost, set
1376
+ PORTLESS_SYNC_HOSTS=1 to enable. Disable with PORTLESS_SYNC_HOSTS=0.
1249
1377
  `);
1250
1378
  process.exit(0);
1251
1379
  }
@@ -1315,6 +1443,7 @@ ${chalk.bold("Usage:")}
1315
1443
  ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
1316
1444
  ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
1317
1445
  ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
1446
+ ${chalk.cyan("portless proxy start --tld test")} Use .test instead of .localhost
1318
1447
  ${chalk.cyan("portless proxy stop")} Stop the proxy
1319
1448
  `);
1320
1449
  process.exit(isProxyHelp || !args[1] ? 0 : 1);
@@ -1363,6 +1492,39 @@ ${chalk.bold("Usage:")}
1363
1492
  console.error(chalk.red("Error: --cert and --key must be used together."));
1364
1493
  process.exit(1);
1365
1494
  }
1495
+ let tld;
1496
+ try {
1497
+ tld = getDefaultTld();
1498
+ } catch (err) {
1499
+ console.error(chalk.red(`Error: ${err.message}`));
1500
+ process.exit(1);
1501
+ }
1502
+ const tldIdx = args.indexOf("--tld");
1503
+ if (tldIdx !== -1) {
1504
+ const tldValue = args[tldIdx + 1];
1505
+ if (!tldValue || tldValue.startsWith("-")) {
1506
+ console.error(chalk.red("Error: --tld requires a TLD value (e.g. test, localhost)."));
1507
+ process.exit(1);
1508
+ }
1509
+ tld = tldValue.trim().toLowerCase();
1510
+ const tldErr = validateTld(tld);
1511
+ if (tldErr) {
1512
+ console.error(chalk.red(`Error: ${tldErr}`));
1513
+ process.exit(1);
1514
+ }
1515
+ }
1516
+ const riskyReason = RISKY_TLDS.get(tld);
1517
+ if (riskyReason) {
1518
+ console.warn(chalk.yellow(`Warning: .${tld} -- ${riskyReason}`));
1519
+ }
1520
+ const syncDisabled = process.env.PORTLESS_SYNC_HOSTS === "0" || process.env.PORTLESS_SYNC_HOSTS === "false";
1521
+ if (tld !== DEFAULT_TLD && syncDisabled) {
1522
+ console.warn(
1523
+ chalk.yellow(`Warning: .${tld} domains require /etc/hosts entries to resolve to 127.0.0.1.`)
1524
+ );
1525
+ console.warn(chalk.yellow("Hosts sync is disabled. To add entries manually, run:"));
1526
+ console.warn(chalk.cyan(" sudo portless hosts sync"));
1527
+ }
1366
1528
  const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1367
1529
  const stateDir = resolveStateDir(proxyPort);
1368
1530
  const store = new RouteStore(stateDir, {
@@ -1374,8 +1536,13 @@ ${chalk.bold("Usage:")}
1374
1536
  }
1375
1537
  const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1376
1538
  const sudoPrefix = needsSudo ? "sudo " : "";
1539
+ const portFlag = proxyPort !== getDefaultPort() ? ` -p ${proxyPort}` : "";
1377
1540
  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`));
1541
+ console.log(
1542
+ chalk.blue(
1543
+ `To restart: ${sudoPrefix}portless proxy stop${portFlag} && ${sudoPrefix}portless proxy start${portFlag}`
1544
+ )
1545
+ );
1379
1546
  return;
1380
1547
  }
1381
1548
  if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
@@ -1440,13 +1607,13 @@ ${chalk.bold("Usage:")}
1440
1607
  tlsOptions = {
1441
1608
  cert,
1442
1609
  key,
1443
- SNICallback: createSNICallback(stateDir, cert, key)
1610
+ SNICallback: createSNICallback(stateDir, cert, key, tld)
1444
1611
  };
1445
1612
  }
1446
1613
  }
1447
1614
  if (isForeground) {
1448
1615
  console.log(chalk.blue.bold("\nportless proxy\n"));
1449
- startProxyServer(store, proxyPort, tlsOptions);
1616
+ startProxyServer(store, proxyPort, tld, tlsOptions);
1450
1617
  return;
1451
1618
  }
1452
1619
  store.ensureDir();
@@ -1469,6 +1636,9 @@ ${chalk.bold("Usage:")}
1469
1636
  daemonArgs.push("--https");
1470
1637
  }
1471
1638
  }
1639
+ if (tld !== DEFAULT_TLD) {
1640
+ daemonArgs.push("--tld", tld);
1641
+ }
1472
1642
  const child = spawn(process.execPath, daemonArgs, {
1473
1643
  detached: true,
1474
1644
  stdio: ["ignore", logFd, logFd],
@@ -1501,10 +1671,24 @@ async function handleRunMode(args) {
1501
1671
  console.error(chalk.cyan(" portless run next dev"));
1502
1672
  process.exit(1);
1503
1673
  }
1504
- const inferred = inferProjectName();
1674
+ let baseName;
1675
+ let nameSource;
1676
+ if (parsed.name) {
1677
+ const sanitized = sanitizeForHostname(parsed.name);
1678
+ if (!sanitized) {
1679
+ console.error(chalk.red(`Error: --name value "${parsed.name}" produces an empty hostname.`));
1680
+ process.exit(1);
1681
+ }
1682
+ baseName = sanitized;
1683
+ nameSource = "--name flag";
1684
+ } else {
1685
+ const inferred = inferProjectName();
1686
+ baseName = inferred.name;
1687
+ nameSource = inferred.source;
1688
+ }
1505
1689
  const worktree = detectWorktreePrefix();
1506
- const effectiveName = worktree ? `${worktree.prefix}.${inferred.name}` : inferred.name;
1507
- const { dir, port, tls: tls2 } = await discoverState();
1690
+ const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
1691
+ const { dir, port, tls: tls2, tld } = await discoverState();
1508
1692
  const store = new RouteStore(dir, {
1509
1693
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1510
1694
  });
@@ -1515,8 +1699,9 @@ async function handleRunMode(args) {
1515
1699
  effectiveName,
1516
1700
  parsed.commandArgs,
1517
1701
  tls2,
1702
+ tld,
1518
1703
  parsed.force,
1519
- { nameSource: inferred.source, prefix: worktree?.prefix, prefixSource: worktree?.source },
1704
+ { nameSource, prefix: worktree?.prefix, prefixSource: worktree?.source },
1520
1705
  parsed.appPort
1521
1706
  );
1522
1707
  }
@@ -1530,7 +1715,7 @@ async function handleNamedMode(args) {
1530
1715
  console.error(chalk.cyan(" portless myapp next dev"));
1531
1716
  process.exit(1);
1532
1717
  }
1533
- const { dir, port, tls: tls2 } = await discoverState();
1718
+ const { dir, port, tls: tls2, tld } = await discoverState();
1534
1719
  const store = new RouteStore(dir, {
1535
1720
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1536
1721
  });
@@ -1541,6 +1726,7 @@ async function handleNamedMode(args) {
1541
1726
  parsed.name,
1542
1727
  parsed.commandArgs,
1543
1728
  tls2,
1729
+ tld,
1544
1730
  parsed.force,
1545
1731
  void 0,
1546
1732
  parsed.appPort
@@ -1571,7 +1757,7 @@ async function main() {
1571
1757
  console.error(chalk.cyan(" portless --name <name> <command...>"));
1572
1758
  process.exit(1);
1573
1759
  }
1574
- const skipPortless2 = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
1760
+ const skipPortless2 = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
1575
1761
  if (skipPortless2) {
1576
1762
  const { commandArgs } = parseAppArgs(args);
1577
1763
  if (commandArgs.length === 0) {
@@ -1588,7 +1774,7 @@ async function main() {
1588
1774
  if (isRunCommand) {
1589
1775
  args.shift();
1590
1776
  }
1591
- const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
1777
+ const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
1592
1778
  if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy")) {
1593
1779
  const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
1594
1780
  if (commandArgs.length === 0) {
@@ -1615,6 +1801,10 @@ async function main() {
1615
1801
  await handleList();
1616
1802
  return;
1617
1803
  }
1804
+ if (args[0] === "get") {
1805
+ await handleGet(args);
1806
+ return;
1807
+ }
1618
1808
  if (args[0] === "alias") {
1619
1809
  await handleAlias(args);
1620
1810
  return;
package/dist/index.d.ts CHANGED
@@ -12,6 +12,8 @@ interface ProxyServerOptions {
12
12
  getRoutes: () => RouteInfo[];
13
13
  /** The port the proxy is listening on (used to build correct URLs). */
14
14
  proxyPort: number;
15
+ /** TLD suffix used for hostnames (default: "localhost"). */
16
+ tld?: string;
15
17
  /** Optional error logger; defaults to console.error. */
16
18
  onError?: (message: string) => void;
17
19
  /** When provided, enables HTTP/2 over TLS (HTTPS). */
@@ -109,15 +111,16 @@ declare function isErrnoException(err: unknown): err is NodeJS.ErrnoException;
109
111
  */
110
112
  declare function escapeHtml(str: string): string;
111
113
  /**
112
- * Format a .localhost URL. Omits the port when it matches the protocol default
113
- * (80 for HTTP, 443 for HTTPS).
114
+ * Format a URL for the given hostname. Omits the port when it matches the
115
+ * protocol default (80 for HTTP, 443 for HTTPS).
114
116
  */
115
117
  declare function formatUrl(hostname: string, proxyPort: number, tls?: boolean): string;
116
118
  /**
117
- * Parse and normalize a hostname input for use as a .localhost subdomain.
118
- * Strips protocol prefixes, validates characters, and appends .localhost if needed.
119
+ * Parse and normalize a hostname input for use as a subdomain of the
120
+ * configured TLD. Strips protocol prefixes, validates characters, and
121
+ * appends the TLD suffix if needed.
119
122
  */
120
- declare function parseHostname(input: string): string;
123
+ declare function parseHostname(input: string, tld?: string): string;
121
124
 
122
125
  /**
123
126
  * Extract the portless-managed block from /etc/hosts content.
@@ -150,9 +153,9 @@ declare function cleanHostsFile(): boolean;
150
153
  */
151
154
  declare function getManagedHostnames(): string[];
152
155
  /**
153
- * Check whether a .localhost subdomain resolves to 127.0.0.1 via the
154
- * system DNS resolver. Returns true if resolution works, false otherwise.
156
+ * Check whether a hostname resolves to 127.0.0.1 via the system DNS resolver.
157
+ * Returns true if resolution works, false otherwise.
155
158
  */
156
- declare function checkLocalhostResolution(hostname: string): Promise<boolean>;
159
+ declare function checkHostResolution(hostname: string): Promise<boolean>;
157
160
 
158
- export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, SYSTEM_DIR_MODE, SYSTEM_FILE_MODE, buildBlock, checkLocalhostResolution, cleanHostsFile, createProxyServer, escapeHtml, extractManagedBlock, fixOwnership, formatUrl, getManagedHostnames, isErrnoException, parseHostname, removeBlock, syncHostsFile };
161
+ export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, SYSTEM_DIR_MODE, SYSTEM_FILE_MODE, buildBlock, checkHostResolution, cleanHostsFile, createProxyServer, escapeHtml, extractManagedBlock, fixOwnership, formatUrl, getManagedHostnames, isErrnoException, parseHostname, removeBlock, syncHostsFile };
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  SYSTEM_DIR_MODE,
8
8
  SYSTEM_FILE_MODE,
9
9
  buildBlock,
10
- checkLocalhostResolution,
10
+ checkHostResolution,
11
11
  cleanHostsFile,
12
12
  createProxyServer,
13
13
  escapeHtml,
@@ -19,7 +19,7 @@ import {
19
19
  parseHostname,
20
20
  removeBlock,
21
21
  syncHostsFile
22
- } from "./chunk-P3DHZHEZ.js";
22
+ } from "./chunk-AB3HUERH.js";
23
23
  export {
24
24
  DIR_MODE,
25
25
  FILE_MODE,
@@ -29,7 +29,7 @@ export {
29
29
  SYSTEM_DIR_MODE,
30
30
  SYSTEM_FILE_MODE,
31
31
  buildBlock,
32
- checkLocalhostResolution,
32
+ checkHostResolution,
33
33
  cleanHostsFile,
34
34
  createProxyServer,
35
35
  escapeHtml,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",