portless 0.5.2 → 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,107 +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
19
+
20
+ ```bash
21
+ # Enable HTTPS (one-time setup, auto-generates certs)
22
+ portless proxy start --https
15
23
 
16
- # Run your app (auto-starts the proxy if needed)
17
- portless run next dev
18
- # -> http://<project>.localhost:1355
24
+ portless myapp next dev
25
+ # -> https://myapp.localhost
19
26
 
20
- # Or specify a name explicitly
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
60
-
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
65
54
  ```
66
55
 
67
- ### Git Worktrees
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).
68
57
 
69
- `portless run` automatically detects git worktrees. When you're in a linked worktree, the branch name is prepended as a subdomain so each worktree gets its own URL without any config changes:
58
+ ## Git Worktrees
59
+
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:
70
61
 
71
62
  ```bash
72
- # Main worktree (main/master branch) -- no prefix, works normally
73
- portless run next dev
74
- # -> http://myapp.localhost:1355
63
+ # Main worktree -- no prefix
64
+ portless run next dev # -> http://myapp.localhost:1355
75
65
 
76
- # Linked worktree on branch "fix-ui" -- branch name becomes a prefix
77
- portless run next dev
78
- # -> http://fix-ui.myapp.localhost:1355
66
+ # Linked worktree on branch "fix-ui"
67
+ portless run next dev # -> http://fix-ui.myapp.localhost:1355
68
+ ```
79
69
 
80
- # Linked worktree on branch "feature/auth" -- uses last segment
81
- portless run next dev
82
- # -> http://auth.myapp.localhost:1355
70
+ Use `--name` to override the inferred base name while keeping the worktree prefix:
71
+
72
+ ```bash
73
+ portless run --name myapp next dev # -> http://fix-ui.myapp.localhost:1355
83
74
  ```
84
75
 
85
- This means you can put `portless run` in your `package.json` once and it just works everywhere -- the main checkout uses the plain name, and each worktree gets a unique subdomain. No `--force` needed, no name collisions.
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`.
86
77
 
87
- ### In package.json
78
+ ## Custom TLD
88
79
 
89
- ```json
90
- {
91
- "scripts": {
92
- "dev": "portless run next dev"
93
- }
94
- }
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
95
86
  ```
96
87
 
97
- The proxy auto-starts when you run an app. Or start it explicitly: `portless proxy start`.
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).
98
91
 
99
- ## How It Works
92
+ ## How it works
100
93
 
101
94
  ```mermaid
102
95
  flowchart TD
103
96
  Browser["Browser\nmyapp.localhost:1355"]
104
- Proxy["portless proxy<br>(port 1355)"]
97
+ Proxy["portless proxy\n(port 1355)"]
105
98
  App1[":4123\nmyapp"]
106
99
  App2[":4567\napi"]
107
100
 
@@ -114,8 +107,6 @@ flowchart TD
114
107
  2. **Run apps** -- `portless <name> <command>` assigns a free port and registers with the proxy
115
108
  3. **Access via URL** -- `http://<name>.localhost:1355` routes through the proxy to your app
116
109
 
117
- 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.
118
-
119
110
  ## HTTP/2 + HTTPS
120
111
 
121
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.
@@ -143,7 +134,7 @@ On Linux, `portless trust` supports Debian/Ubuntu, Arch, Fedora/RHEL/CentOS, and
143
134
  ## Commands
144
135
 
145
136
  ```bash
146
- 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
147
138
  portless <name> <cmd> [args...] # Run app at http://<name>.localhost:1355
148
139
  portless alias <name> <port> # Register a static route (e.g. for Docker)
149
140
  portless alias <name> <port> --force # Overwrite an existing route
@@ -155,7 +146,6 @@ portless hosts clean # Remove portless entries from /etc/hosts
155
146
 
156
147
  # Disable portless (run command directly)
157
148
  PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
158
- # Also accepts PORTLESS=skip
159
149
 
160
150
  # Proxy control
161
151
  portless proxy start # Start the proxy (port 1355, daemon)
@@ -163,64 +153,42 @@ portless proxy start --https # Start with HTTP/2 + TLS
163
153
  portless proxy start -p 80 # Start on port 80 (requires sudo)
164
154
  portless proxy start --foreground # Start in foreground (for debugging)
165
155
  portless proxy stop # Stop the proxy
166
-
167
- # Options
168
- -p, --port <number> # Port for the proxy (default: 1355)
169
- # Ports < 1024 require sudo
170
- --https # Enable HTTP/2 + TLS with auto-generated certs
171
- --cert <path> # Use a custom TLS certificate (implies --https)
172
- --key <path> # Use a custom TLS private key (implies --https)
173
- --no-tls # Disable HTTPS (overrides PORTLESS_HTTPS)
174
- --foreground # Run proxy in foreground instead of daemon
175
- --app-port <number> # Use a fixed port for the app (skip auto-assignment)
176
- --force # Override a route registered by another process
177
- --name <name> # Use <name> as the app name (bypasses subcommand dispatch)
178
- -- # Stop flag parsing; everything after is passed to the child
179
-
180
- # Injected into child processes
181
- PORT # Ephemeral port the child should listen on
182
- HOST # Always 127.0.0.1
183
- PORTLESS_URL # Public URL (e.g. http://myapp.localhost:1355)
184
-
185
- # Configuration
186
- PORTLESS_PORT=<number> # Override the default proxy port
187
- PORTLESS_APP_PORT=<number> # Use a fixed port for the app (same as --app-port)
188
- PORTLESS_HTTPS=1|true # Always enable HTTPS
189
- PORTLESS_SYNC_HOSTS=1 # Auto-sync /etc/hosts when routes change
190
- PORTLESS_STATE_DIR=<path> # Override the state directory
191
-
192
- # Info
193
- portless --help # Show help
194
- portless run --help # Show help for a specific subcommand
195
- portless --version # Show version
196
156
  ```
197
157
 
198
- > **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.
199
-
200
- ## State Directory
201
-
202
- Portless stores its state (routes, PID file, port file) in a directory that depends on the proxy port:
203
-
204
- - **Port < 1024** (sudo required): `/tmp/portless` -- shared between root and user processes
205
- - **Port >= 1024** (no sudo): `~/.portless` -- user-scoped, no root involvement
158
+ ### Options
206
159
 
207
- Override with the `PORTLESS_STATE_DIR` environment variable if needed.
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
+ ```
208
172
 
209
- ## Development
173
+ ### Environment variables
210
174
 
211
- 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
212
183
 
213
- ```bash
214
- pnpm install # Install all dependencies
215
- pnpm build # Build all packages
216
- pnpm test # Run tests
217
- pnpm test:coverage # Run tests with coverage
218
- pnpm test:watch # Run tests in watch mode
219
- pnpm lint # Lint all packages
220
- pnpm typecheck # Type-check all packages
221
- 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)
222
188
  ```
223
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
+
224
192
  ## Safari / DNS
225
193
 
226
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.
@@ -228,23 +196,15 @@ pnpm format # Format all files with Prettier
228
196
  If Safari can't find your `.localhost` URL:
229
197
 
230
198
  ```bash
231
- # Add current routes to /etc/hosts (requires sudo)
232
- sudo portless hosts sync
233
-
234
- # Clean up later
235
- sudo portless hosts clean
199
+ sudo portless hosts sync # Add current routes to /etc/hosts
200
+ sudo portless hosts clean # Clean up later
236
201
  ```
237
202
 
238
- To auto-sync `/etc/hosts` whenever routes change, set `PORTLESS_SYNC_HOSTS=1` and start the proxy with sudo:
239
-
240
- ```bash
241
- export PORTLESS_SYNC_HOSTS=1
242
- sudo portless proxy start
243
- ```
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`.
244
204
 
245
205
  ## Proxying Between Portless Apps
246
206
 
247
- 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.
248
208
 
249
209
  **Vite** (`vite.config.ts`):
250
210
 
@@ -253,7 +213,7 @@ server: {
253
213
  proxy: {
254
214
  "/api": {
255
215
  target: "http://api.myapp.localhost:1355",
256
- changeOrigin: true, // Required: rewrites Host header to match target
216
+ changeOrigin: true,
257
217
  ws: true,
258
218
  },
259
219
  },
@@ -267,13 +227,27 @@ devServer: {
267
227
  proxy: [{
268
228
  context: ["/api"],
269
229
  target: "http://api.myapp.localhost:1355",
270
- changeOrigin: true, // Required: rewrites Host header to match target
230
+ changeOrigin: true,
271
231
  }],
272
232
  }
273
233
  ```
274
234
 
275
235
  Portless detects this misconfiguration and responds with `508 Loop Detected` along with a message pointing to this fix.
276
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
+
277
251
  ## Requirements
278
252
 
279
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.2");
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.2",
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",