portless 0.2.1 → 0.3.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/dist/cli.js CHANGED
@@ -1,21 +1,89 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ FILE_MODE,
4
+ PORTLESS_HEADER,
3
5
  RouteStore,
4
6
  createProxyServer,
7
+ formatUrl,
5
8
  isErrnoException,
6
9
  parseHostname
7
- } from "./chunk-SE7KL62V.js";
10
+ } from "./chunk-Y5OVKUR4.js";
8
11
 
9
12
  // src/cli.ts
10
13
  import chalk from "chalk";
11
- import * as fs from "fs";
12
- import * as path from "path";
13
- import * as readline from "readline";
14
- import { execSync, spawn, spawnSync } from "child_process";
14
+ import * as fs2 from "fs";
15
+ import * as path2 from "path";
16
+ import { spawn as spawn2, spawnSync } from "child_process";
15
17
 
16
18
  // src/cli-utils.ts
19
+ import * as fs from "fs";
20
+ import * as http from "http";
17
21
  import * as net from "net";
18
- async function findFreePort(minPort = 4e3, maxPort = 4999) {
22
+ import * as os from "os";
23
+ import * as path from "path";
24
+ import * as readline from "readline";
25
+ import { execSync, spawn } from "child_process";
26
+ var DEFAULT_PROXY_PORT = 1355;
27
+ var PRIVILEGED_PORT_THRESHOLD = 1024;
28
+ var SYSTEM_STATE_DIR = "/tmp/portless";
29
+ var USER_STATE_DIR = path.join(os.homedir(), ".portless");
30
+ var MIN_APP_PORT = 4e3;
31
+ var MAX_APP_PORT = 4999;
32
+ var RANDOM_PORT_ATTEMPTS = 50;
33
+ var SOCKET_TIMEOUT_MS = 500;
34
+ var LSOF_TIMEOUT_MS = 5e3;
35
+ var WAIT_FOR_PROXY_MAX_ATTEMPTS = 20;
36
+ var WAIT_FOR_PROXY_INTERVAL_MS = 250;
37
+ var SIGNAL_CODES = {
38
+ SIGHUP: 1,
39
+ SIGINT: 2,
40
+ SIGQUIT: 3,
41
+ SIGABRT: 6,
42
+ SIGKILL: 9,
43
+ SIGTERM: 15
44
+ };
45
+ function getDefaultPort() {
46
+ const envPort = process.env.PORTLESS_PORT;
47
+ if (envPort) {
48
+ const port = parseInt(envPort, 10);
49
+ if (!isNaN(port) && port >= 1 && port <= 65535) return port;
50
+ }
51
+ return DEFAULT_PROXY_PORT;
52
+ }
53
+ function resolveStateDir(port) {
54
+ if (process.env.PORTLESS_STATE_DIR) return process.env.PORTLESS_STATE_DIR;
55
+ return port < PRIVILEGED_PORT_THRESHOLD ? SYSTEM_STATE_DIR : USER_STATE_DIR;
56
+ }
57
+ function readPortFromDir(dir) {
58
+ try {
59
+ const raw = fs.readFileSync(path.join(dir, "proxy.port"), "utf-8").trim();
60
+ const port = parseInt(raw, 10);
61
+ return isNaN(port) ? null : port;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ async function discoverState() {
67
+ if (process.env.PORTLESS_STATE_DIR) {
68
+ const dir = process.env.PORTLESS_STATE_DIR;
69
+ const port = readPortFromDir(dir) ?? getDefaultPort();
70
+ return { dir, port };
71
+ }
72
+ const userPort = readPortFromDir(USER_STATE_DIR);
73
+ if (userPort !== null && await isProxyRunning(userPort)) {
74
+ return { dir: USER_STATE_DIR, port: userPort };
75
+ }
76
+ const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
77
+ if (systemPort !== null && await isProxyRunning(systemPort)) {
78
+ return { dir: SYSTEM_STATE_DIR, port: systemPort };
79
+ }
80
+ const defaultPort = getDefaultPort();
81
+ return { dir: resolveStateDir(defaultPort), port: defaultPort };
82
+ }
83
+ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
84
+ if (minPort > maxPort) {
85
+ throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
86
+ }
19
87
  const tryPort = (port) => {
20
88
  return new Promise((resolve) => {
21
89
  const server = net.createServer();
@@ -25,7 +93,7 @@ async function findFreePort(minPort = 4e3, maxPort = 4999) {
25
93
  server.on("error", () => resolve(false));
26
94
  });
27
95
  };
28
- for (let i = 0; i < 50; i++) {
96
+ for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
29
97
  const port = minPort + Math.floor(Math.random() * (maxPort - minPort + 1));
30
98
  if (await tryPort(port)) {
31
99
  return port;
@@ -38,58 +106,34 @@ async function findFreePort(minPort = 4e3, maxPort = 4999) {
38
106
  }
39
107
  throw new Error(`No free port found in range ${minPort}-${maxPort}`);
40
108
  }
41
- function isProxyRunning(port = 80) {
109
+ function isProxyRunning(port) {
42
110
  return new Promise((resolve) => {
43
- const socket = new net.Socket();
44
- socket.setTimeout(500);
45
- socket.on("connect", () => {
46
- socket.destroy();
47
- resolve(true);
48
- });
49
- socket.on("error", () => resolve(false));
50
- socket.on("timeout", () => {
51
- socket.destroy();
111
+ const req = http.request(
112
+ {
113
+ hostname: "127.0.0.1",
114
+ port,
115
+ path: "/",
116
+ method: "HEAD",
117
+ timeout: SOCKET_TIMEOUT_MS
118
+ },
119
+ (res) => {
120
+ res.resume();
121
+ resolve(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
122
+ }
123
+ );
124
+ req.on("error", () => resolve(false));
125
+ req.on("timeout", () => {
126
+ req.destroy();
52
127
  resolve(false);
53
128
  });
54
- socket.connect(port, "127.0.0.1");
129
+ req.end();
55
130
  });
56
131
  }
57
-
58
- // src/cli.ts
59
- var SIGNAL_CODES = { SIGINT: 2, SIGTERM: 15 };
60
- function prompt(question) {
61
- const rl = readline.createInterface({
62
- input: process.stdin,
63
- output: process.stdout
64
- });
65
- return new Promise((resolve) => {
66
- rl.on("close", () => resolve(""));
67
- rl.question(question, (answer) => {
68
- rl.close();
69
- resolve(answer.trim().toLowerCase());
70
- });
71
- });
72
- }
73
- var PORTLESS_DIR = "/tmp/portless";
74
- var PROXY_PORT_PATH = path.join(PORTLESS_DIR, "proxy.port");
75
- var DEFAULT_PROXY_PORT = 80;
76
- var store = new RouteStore(PORTLESS_DIR, {
77
- onWarning: (msg) => console.warn(chalk.yellow(msg))
78
- });
79
- function readProxyPort() {
80
- try {
81
- const raw = fs.readFileSync(PROXY_PORT_PATH, "utf-8").trim();
82
- const port = parseInt(raw, 10);
83
- return isNaN(port) ? DEFAULT_PROXY_PORT : port;
84
- } catch {
85
- return DEFAULT_PROXY_PORT;
86
- }
87
- }
88
132
  function findPidOnPort(port) {
89
133
  try {
90
134
  const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
91
135
  encoding: "utf-8",
92
- timeout: 5e3
136
+ timeout: LSOF_TIMEOUT_MS
93
137
  });
94
138
  const pid = parseInt(output.trim().split("\n")[0], 10);
95
139
  return isNaN(pid) ? null : pid;
@@ -97,8 +141,7 @@ function findPidOnPort(port) {
97
141
  return null;
98
142
  }
99
143
  }
100
- async function waitForProxy(proxyPort, maxAttempts = 20, intervalMs = 250) {
101
- const port = proxyPort ?? readProxyPort();
144
+ async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS) {
102
145
  for (let i = 0; i < maxAttempts; i++) {
103
146
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
104
147
  if (await isProxyRunning(port)) {
@@ -113,45 +156,75 @@ function spawnCommand(commandArgs, options) {
113
156
  env: options?.env
114
157
  });
115
158
  let exiting = false;
159
+ const cleanup = () => {
160
+ process.removeListener("SIGINT", onSigInt);
161
+ process.removeListener("SIGTERM", onSigTerm);
162
+ options?.onCleanup?.();
163
+ };
116
164
  const handleSignal = (signal) => {
117
165
  if (exiting) return;
118
166
  exiting = true;
119
167
  child.kill(signal);
120
- options?.onCleanup?.();
168
+ cleanup();
121
169
  process.exit(128 + (SIGNAL_CODES[signal] || 15));
122
170
  };
123
- process.on("SIGINT", () => handleSignal("SIGINT"));
124
- process.on("SIGTERM", () => handleSignal("SIGTERM"));
171
+ const onSigInt = () => handleSignal("SIGINT");
172
+ const onSigTerm = () => handleSignal("SIGTERM");
173
+ process.on("SIGINT", onSigInt);
174
+ process.on("SIGTERM", onSigTerm);
125
175
  child.on("error", (err) => {
126
176
  if (exiting) return;
127
177
  exiting = true;
128
- console.error(chalk.red(`Failed to run command: ${err.message}`));
129
- options?.onCleanup?.();
178
+ console.error(`Failed to run command: ${err.message}`);
179
+ if (err.code === "ENOENT") {
180
+ console.error(`Is "${commandArgs[0]}" installed and in your PATH?`);
181
+ }
182
+ cleanup();
130
183
  process.exit(1);
131
184
  });
132
185
  child.on("exit", (code, signal) => {
133
186
  if (exiting) return;
134
187
  exiting = true;
135
- options?.onCleanup?.();
188
+ cleanup();
136
189
  if (signal) {
137
- process.exit(128 + (SIGNAL_CODES[signal] || 1));
190
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
138
191
  }
139
192
  process.exit(code ?? 1);
140
193
  });
141
194
  }
142
- function startProxyServer(proxyPort) {
195
+ function prompt(question) {
196
+ const rl = readline.createInterface({
197
+ input: process.stdin,
198
+ output: process.stdout
199
+ });
200
+ return new Promise((resolve) => {
201
+ rl.on("close", () => resolve(""));
202
+ rl.question(question, (answer) => {
203
+ rl.close();
204
+ resolve(answer.trim().toLowerCase());
205
+ });
206
+ });
207
+ }
208
+
209
+ // src/cli.ts
210
+ var DEBOUNCE_MS = 100;
211
+ var POLL_INTERVAL_MS = 3e3;
212
+ var EXIT_TIMEOUT_MS = 2e3;
213
+ var SUDO_SPAWN_TIMEOUT_MS = 3e4;
214
+ function startProxyServer(store, proxyPort) {
143
215
  store.ensureDir();
144
216
  const routesPath = store.getRoutesPath();
145
- if (!fs.existsSync(routesPath)) {
146
- fs.writeFileSync(routesPath, "[]", { mode: 420 });
217
+ if (!fs2.existsSync(routesPath)) {
218
+ fs2.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
147
219
  }
148
220
  try {
149
- fs.chmodSync(routesPath, 420);
221
+ fs2.chmodSync(routesPath, FILE_MODE);
150
222
  } catch {
151
223
  }
152
224
  let cachedRoutes = store.loadRoutes();
153
225
  let debounceTimer = null;
154
226
  let watcher = null;
227
+ let pollingInterval = null;
155
228
  const reloadRoutes = () => {
156
229
  try {
157
230
  cachedRoutes = store.loadRoutes();
@@ -159,60 +232,72 @@ function startProxyServer(proxyPort) {
159
232
  }
160
233
  };
161
234
  try {
162
- watcher = fs.watch(routesPath, () => {
235
+ watcher = fs2.watch(routesPath, () => {
163
236
  if (debounceTimer) clearTimeout(debounceTimer);
164
- debounceTimer = setTimeout(reloadRoutes, 100);
237
+ debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
165
238
  });
166
239
  } catch {
167
240
  console.warn(chalk.yellow("fs.watch unavailable; falling back to polling for route changes"));
168
- setInterval(reloadRoutes, 3e3);
241
+ pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
169
242
  }
170
243
  const server = createProxyServer({
171
244
  getRoutes: () => cachedRoutes,
245
+ proxyPort,
172
246
  onError: (msg) => console.error(chalk.red(msg))
173
247
  });
174
248
  server.on("error", (err) => {
175
249
  if (err.code === "EADDRINUSE") {
176
- console.error(chalk.red(`Port ${proxyPort} is already in use`));
250
+ console.error(chalk.red(`Port ${proxyPort} is already in use.`));
251
+ console.error(chalk.blue("Stop the existing proxy first:"));
252
+ console.error(chalk.cyan(" portless proxy stop"));
253
+ console.error(chalk.blue("Or check what is using the port:"));
254
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
177
255
  } else if (err.code === "EACCES") {
178
- console.error(chalk.red("Permission denied. Use: sudo portless proxy"));
256
+ console.error(chalk.red(`Permission denied for port ${proxyPort}.`));
257
+ console.error(chalk.blue("Either run with sudo:"));
258
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
259
+ console.error(chalk.blue("Or use a non-privileged port (no sudo needed):"));
260
+ console.error(chalk.cyan(" portless proxy start"));
179
261
  } else {
180
262
  console.error(chalk.red(`Proxy error: ${err.message}`));
181
263
  }
182
264
  process.exit(1);
183
265
  });
184
266
  server.listen(proxyPort, () => {
185
- fs.writeFileSync(store.pidPath, process.pid.toString(), { mode: 420 });
186
- fs.writeFileSync(PROXY_PORT_PATH, proxyPort.toString(), { mode: 420 });
267
+ fs2.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
268
+ fs2.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
187
269
  console.log(chalk.green(`HTTP proxy listening on port ${proxyPort}`));
188
270
  });
189
271
  let exiting = false;
190
272
  const cleanup = () => {
191
273
  if (exiting) return;
192
274
  exiting = true;
275
+ if (debounceTimer) clearTimeout(debounceTimer);
276
+ if (pollingInterval) clearInterval(pollingInterval);
193
277
  if (watcher) {
194
278
  watcher.close();
195
279
  }
196
280
  try {
197
- fs.unlinkSync(store.pidPath);
281
+ fs2.unlinkSync(store.pidPath);
198
282
  } catch {
199
283
  }
200
284
  try {
201
- fs.unlinkSync(PROXY_PORT_PATH);
285
+ fs2.unlinkSync(store.portFilePath);
202
286
  } catch {
203
287
  }
204
288
  server.close(() => process.exit(0));
205
- setTimeout(() => process.exit(0), 2e3).unref();
289
+ setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
206
290
  };
207
291
  process.on("SIGINT", cleanup);
208
292
  process.on("SIGTERM", cleanup);
209
293
  console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
210
294
  console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
211
295
  }
212
- async function stopProxy() {
296
+ async function stopProxy(store, proxyPort) {
213
297
  const pidPath = store.pidPath;
214
- const proxyPort = readProxyPort();
215
- if (!fs.existsSync(pidPath)) {
298
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
299
+ const sudoHint = needsSudo ? "sudo " : "";
300
+ if (!fs2.existsSync(pidPath)) {
216
301
  if (await isProxyRunning(proxyPort)) {
217
302
  console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
218
303
  const pid = findPidOnPort(proxyPort);
@@ -220,25 +305,30 @@ async function stopProxy() {
220
305
  try {
221
306
  process.kill(pid, "SIGTERM");
222
307
  try {
223
- fs.unlinkSync(PROXY_PORT_PATH);
308
+ fs2.unlinkSync(store.portFilePath);
224
309
  } catch {
225
310
  }
226
311
  console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
227
312
  } catch (err) {
228
313
  if (isErrnoException(err) && err.code === "EPERM") {
229
- console.error(chalk.red("Permission denied. The proxy runs as root."));
230
- console.log(chalk.blue("Use: sudo portless proxy stop"));
314
+ console.error(chalk.red("Permission denied. The proxy was started with sudo."));
315
+ console.error(chalk.blue("Stop it with:"));
316
+ console.error(chalk.cyan(" sudo portless proxy stop"));
231
317
  } else {
232
318
  const message = err instanceof Error ? err.message : String(err);
233
- console.error(chalk.red("Failed to stop proxy:"), message);
319
+ console.error(chalk.red(`Failed to stop proxy: ${message}`));
320
+ console.error(chalk.blue("Check if the process is still running:"));
321
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
234
322
  }
235
323
  }
236
324
  } else if (process.getuid?.() !== 0) {
237
- console.error(chalk.red("Permission denied. The proxy likely runs as root."));
238
- console.log(chalk.blue("Use: sudo portless proxy stop"));
325
+ console.error(chalk.red("Cannot identify the process. It may be running as root."));
326
+ console.error(chalk.blue("Try stopping with sudo:"));
327
+ console.error(chalk.cyan(" sudo portless proxy stop"));
239
328
  } else {
240
329
  console.error(chalk.red(`Could not identify the process on port ${proxyPort}.`));
241
- console.log(chalk.blue(`Try: sudo kill "$(lsof -ti tcp:${proxyPort})"`));
330
+ console.error(chalk.blue("Try manually:"));
331
+ console.error(chalk.cyan(` sudo kill "$(lsof -ti tcp:${proxyPort})"`));
242
332
  }
243
333
  } else {
244
334
  console.log(chalk.yellow("Proxy is not running."));
@@ -246,17 +336,21 @@ async function stopProxy() {
246
336
  return;
247
337
  }
248
338
  try {
249
- const pid = parseInt(fs.readFileSync(pidPath, "utf-8"), 10);
339
+ const pid = parseInt(fs2.readFileSync(pidPath, "utf-8"), 10);
250
340
  if (isNaN(pid)) {
251
341
  console.error(chalk.red("Corrupted PID file. Removing it."));
252
- fs.unlinkSync(pidPath);
342
+ fs2.unlinkSync(pidPath);
253
343
  return;
254
344
  }
255
345
  try {
256
346
  process.kill(pid, 0);
257
347
  } catch {
258
- console.log(chalk.yellow("Proxy process is no longer running. Cleaning up."));
259
- fs.unlinkSync(pidPath);
348
+ console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
349
+ fs2.unlinkSync(pidPath);
350
+ try {
351
+ fs2.unlinkSync(store.portFilePath);
352
+ } catch {
353
+ }
260
354
  return;
261
355
  }
262
356
  if (!await isProxyRunning(proxyPort)) {
@@ -266,27 +360,30 @@ async function stopProxy() {
266
360
  )
267
361
  );
268
362
  console.log(chalk.yellow("Removing stale PID file."));
269
- fs.unlinkSync(pidPath);
363
+ fs2.unlinkSync(pidPath);
270
364
  return;
271
365
  }
272
366
  process.kill(pid, "SIGTERM");
273
- fs.unlinkSync(pidPath);
367
+ fs2.unlinkSync(pidPath);
274
368
  try {
275
- fs.unlinkSync(PROXY_PORT_PATH);
369
+ fs2.unlinkSync(store.portFilePath);
276
370
  } catch {
277
371
  }
278
372
  console.log(chalk.green("Proxy stopped."));
279
373
  } catch (err) {
280
374
  if (isErrnoException(err) && err.code === "EPERM") {
281
- console.error(chalk.red("Permission denied. The proxy runs as root."));
282
- console.log(chalk.blue("Use: sudo portless proxy stop"));
375
+ console.error(chalk.red("Permission denied. The proxy was started with sudo."));
376
+ console.error(chalk.blue("Stop it with:"));
377
+ console.error(chalk.cyan(` ${sudoHint}portless proxy stop`));
283
378
  } else {
284
379
  const message = err instanceof Error ? err.message : String(err);
285
- console.error(chalk.red("Failed to stop proxy:"), message);
380
+ console.error(chalk.red(`Failed to stop proxy: ${message}`));
381
+ console.error(chalk.blue("Check if the process is still running:"));
382
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
286
383
  }
287
384
  }
288
385
  }
289
- function listRoutes() {
386
+ function listRoutes(store, proxyPort) {
290
387
  const routes = store.loadRoutes();
291
388
  if (routes.length === 0) {
292
389
  console.log(chalk.yellow("No active routes."));
@@ -295,21 +392,32 @@ function listRoutes() {
295
392
  }
296
393
  console.log(chalk.blue.bold("\nActive routes:\n"));
297
394
  for (const route of routes) {
395
+ const url = formatUrl(route.hostname, proxyPort);
298
396
  console.log(
299
- ` ${chalk.cyan(`http://${route.hostname}`)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
397
+ ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
300
398
  );
301
399
  }
302
400
  console.log();
303
401
  }
304
- async function runApp(name, commandArgs) {
402
+ async function runApp(store, proxyPort, stateDir, name, commandArgs) {
305
403
  const hostname = parseHostname(name);
306
- const proxyPort = readProxyPort();
404
+ const appUrl = formatUrl(hostname, proxyPort);
307
405
  console.log(chalk.blue.bold(`
308
406
  portless
309
407
  `));
310
408
  console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
311
409
  if (!await isProxyRunning(proxyPort)) {
312
- if (process.stdin.isTTY) {
410
+ const defaultPort = getDefaultPort();
411
+ const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
412
+ if (needsSudo) {
413
+ if (!process.stdin.isTTY) {
414
+ console.error(chalk.red("Proxy is not running."));
415
+ console.error(chalk.blue("Start the proxy first (requires sudo for this port):"));
416
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
417
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
418
+ console.error(chalk.cyan(" portless proxy start"));
419
+ process.exit(1);
420
+ }
313
421
  const answer = await prompt(chalk.yellow("Proxy not running. Start it? [Y/n/skip] "));
314
422
  if (answer === "n" || answer === "no") {
315
423
  console.log(chalk.gray("Cancelled."));
@@ -321,29 +429,40 @@ portless
321
429
  return;
322
430
  }
323
431
  console.log(chalk.yellow("Starting proxy (requires sudo)..."));
324
- const result = spawnSync("sudo", [process.execPath, process.argv[1], "proxy", "--daemon"], {
432
+ const result = spawnSync("sudo", [process.execPath, process.argv[1], "proxy", "start"], {
325
433
  stdio: "inherit",
326
- timeout: 3e4
434
+ timeout: SUDO_SPAWN_TIMEOUT_MS
327
435
  });
328
436
  if (result.status !== 0) {
329
- console.log(chalk.red("\nFailed to start proxy"));
437
+ console.error(chalk.red("Failed to start proxy."));
438
+ console.error(chalk.blue("Try starting it manually:"));
439
+ console.error(chalk.cyan(" sudo portless proxy start"));
330
440
  process.exit(1);
331
441
  }
332
- if (!await waitForProxy()) {
333
- console.log(chalk.red("\nProxy failed to start"));
334
- const logPath = path.join(PORTLESS_DIR, "proxy.log");
335
- if (fs.existsSync(logPath)) {
336
- console.log(chalk.gray(`Check logs: ${logPath}`));
337
- }
442
+ } else {
443
+ console.log(chalk.yellow("Starting proxy..."));
444
+ const result = spawnSync(process.execPath, [process.argv[1], "proxy", "start"], {
445
+ stdio: "inherit",
446
+ timeout: SUDO_SPAWN_TIMEOUT_MS
447
+ });
448
+ if (result.status !== 0) {
449
+ console.error(chalk.red("Failed to start proxy."));
450
+ console.error(chalk.blue("Try starting it manually:"));
451
+ console.error(chalk.cyan(" portless proxy start"));
338
452
  process.exit(1);
339
453
  }
340
- console.log(chalk.green("Proxy started in background"));
341
- } else {
342
- console.log(chalk.red("\nProxy is not running!"));
343
- console.log(chalk.blue("\nStart the proxy first (one time):"));
344
- console.log(chalk.cyan(" sudo portless proxy\n"));
454
+ }
455
+ if (!await waitForProxy(defaultPort)) {
456
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
457
+ const logPath = path2.join(stateDir, "proxy.log");
458
+ console.error(chalk.blue("Try starting the proxy manually to see the error:"));
459
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
460
+ if (fs2.existsSync(logPath)) {
461
+ console.error(chalk.gray(`Logs: ${logPath}`));
462
+ }
345
463
  process.exit(1);
346
464
  }
465
+ console.log(chalk.green("Proxy started in background"));
347
466
  } else {
348
467
  console.log(chalk.gray("-- Proxy is running"));
349
468
  }
@@ -351,7 +470,7 @@ portless
351
470
  console.log(chalk.green(`-- Using port ${port}`));
352
471
  store.addRoute(hostname, port, process.pid);
353
472
  console.log(chalk.cyan.bold(`
354
- -> http://${hostname}
473
+ -> ${appUrl}
355
474
  `));
356
475
  console.log(chalk.gray(`Running: PORT=${port} ${commandArgs.join(" ")}
357
476
  `));
@@ -367,6 +486,14 @@ portless
367
486
  }
368
487
  async function main() {
369
488
  const args = process.argv.slice(2);
489
+ const isNpx = process.env.npm_command === "exec" && !process.env.npm_lifecycle_event;
490
+ const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
491
+ if (isNpx || isPnpmDlx) {
492
+ console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
493
+ console.error(chalk.blue("Install globally instead:"));
494
+ console.error(chalk.cyan(" npm install -g portless"));
495
+ process.exit(1);
496
+ }
370
497
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
371
498
  if (skipPortless && args.length >= 2 && args[0] !== "proxy") {
372
499
  spawnCommand(args.slice(1));
@@ -379,17 +506,21 @@ ${chalk.bold("portless")} - Replace port numbers with stable, named .localhost U
379
506
  Eliminates port conflicts, memorizing port numbers, and cookie/storage
380
507
  clashes by giving each dev server a stable .localhost URL.
381
508
 
509
+ ${chalk.bold("Install:")}
510
+ ${chalk.cyan("npm install -g portless")}
511
+ Do NOT add portless as a project dependency.
512
+
382
513
  ${chalk.bold("Usage:")}
383
- ${chalk.cyan("sudo portless proxy")} Start the proxy (run once, keep open)
384
- ${chalk.cyan("sudo portless proxy --port 8080")} Start the proxy on a custom port
385
- ${chalk.cyan("sudo portless proxy stop")} Stop the proxy
514
+ ${chalk.cyan("portless proxy start")} Start the proxy (background daemon)
515
+ ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
516
+ ${chalk.cyan("portless proxy stop")} Stop the proxy
386
517
  ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
387
518
  ${chalk.cyan("portless list")} Show active routes
388
519
 
389
520
  ${chalk.bold("Examples:")}
390
- sudo portless proxy # Start proxy in terminal 1
391
- portless myapp next dev # Terminal 2 -> http://myapp.localhost
392
- portless api.myapp pnpm start # Terminal 3 -> http://api.myapp.localhost
521
+ portless proxy start # Start proxy on port 1355
522
+ portless myapp next dev # -> http://myapp.localhost:1355
523
+ portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
393
524
 
394
525
  ${chalk.bold("In package.json:")}
395
526
  {
@@ -399,14 +530,20 @@ ${chalk.bold("In package.json:")}
399
530
  }
400
531
 
401
532
  ${chalk.bold("How it works:")}
402
- 1. Start the proxy once with sudo (listens on port 80 by default)
403
- 2. Run your apps - they register automatically
404
- 3. Access via http://<name>.localhost
533
+ 1. Start the proxy once (listens on port 1355 by default, no sudo needed)
534
+ 2. Run your apps - they auto-start the proxy and register automatically
535
+ 3. Access via http://<name>.localhost:1355
405
536
  4. .localhost domains auto-resolve to 127.0.0.1
406
537
 
407
538
  ${chalk.bold("Options:")}
408
- --port <number> Port for the proxy to listen on (default: 80)
409
- Ports >= 1024 do not require sudo
539
+ -p, --port <number> Port for the proxy to listen on (default: 1355)
540
+ Ports < 1024 require sudo
541
+ --foreground Run proxy in foreground (for debugging)
542
+
543
+ ${chalk.bold("Environment variables:")}
544
+ PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
545
+ PORTLESS_STATE_DIR=<path> Override the state directory
546
+ PORTLESS=0 | PORTLESS=skip Run command directly without proxy
410
547
 
411
548
  ${chalk.bold("Skip portless:")}
412
549
  PORTLESS=0 pnpm dev # Runs command directly without proxy
@@ -415,88 +552,134 @@ ${chalk.bold("Skip portless:")}
415
552
  process.exit(0);
416
553
  }
417
554
  if (args[0] === "--version" || args[0] === "-v") {
418
- console.log("0.2.1");
555
+ console.log("0.3.0");
419
556
  process.exit(0);
420
557
  }
421
558
  if (args[0] === "list") {
422
- listRoutes();
559
+ const { dir: dir2, port: port2 } = await discoverState();
560
+ const store2 = new RouteStore(dir2, {
561
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
562
+ });
563
+ listRoutes(store2, port2);
423
564
  return;
424
565
  }
425
566
  if (args[0] === "proxy") {
426
567
  if (args[1] === "stop") {
427
- await stopProxy();
568
+ const { dir: dir2, port: port2 } = await discoverState();
569
+ const store3 = new RouteStore(dir2, {
570
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
571
+ });
572
+ await stopProxy(store3, port2);
428
573
  return;
429
574
  }
430
- const isDaemon = args.includes("--daemon");
431
- let proxyPort = DEFAULT_PROXY_PORT;
432
- const portFlagIndex = args.indexOf("--port");
575
+ if (args[1] !== "start") {
576
+ console.log(`
577
+ ${chalk.bold("Usage: portless proxy <command>")}
578
+
579
+ ${chalk.cyan("portless proxy start")} Start the proxy (daemon)
580
+ ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
581
+ ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
582
+ ${chalk.cyan("portless proxy stop")} Stop the proxy
583
+ `);
584
+ process.exit(args[1] ? 1 : 0);
585
+ }
586
+ const isForeground = args.includes("--foreground");
587
+ let proxyPort = getDefaultPort();
588
+ let portFlagIndex = args.indexOf("--port");
589
+ if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
433
590
  if (portFlagIndex !== -1) {
434
591
  const portValue = args[portFlagIndex + 1];
435
592
  if (!portValue || portValue.startsWith("-")) {
436
- console.error(chalk.red("Error: --port requires a port number"));
437
- console.log(chalk.blue("Usage: portless proxy --port 8080"));
593
+ console.error(chalk.red("Error: --port / -p requires a port number."));
594
+ console.error(chalk.blue("Usage:"));
595
+ console.error(chalk.cyan(" portless proxy start -p 8080"));
438
596
  process.exit(1);
439
597
  }
440
598
  proxyPort = parseInt(portValue, 10);
441
599
  if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
442
600
  console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
443
- console.log(chalk.blue("Port must be between 1 and 65535"));
601
+ console.error(chalk.blue("Port must be between 1 and 65535."));
444
602
  process.exit(1);
445
603
  }
446
604
  }
605
+ const stateDir = resolveStateDir(proxyPort);
606
+ const store2 = new RouteStore(stateDir, {
607
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
608
+ });
447
609
  if (await isProxyRunning(proxyPort)) {
448
- if (!isDaemon) {
449
- console.log(chalk.yellow("Proxy is already running."));
450
- console.log(chalk.blue("To restart: portless proxy stop && sudo portless proxy"));
610
+ if (isForeground) {
611
+ return;
451
612
  }
613
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
614
+ const sudoPrefix = needsSudo ? "sudo " : "";
615
+ console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
616
+ console.log(
617
+ chalk.blue(`To restart: portless proxy stop && ${sudoPrefix}portless proxy start`)
618
+ );
452
619
  return;
453
620
  }
454
- if (proxyPort < 1024 && process.getuid() !== 0) {
455
- console.error(chalk.red(`Error: Proxy requires sudo for port ${proxyPort}`));
456
- console.log(chalk.blue("Usage: sudo portless proxy"));
621
+ if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
622
+ console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
623
+ console.error(chalk.blue("Either run with sudo:"));
624
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
625
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
626
+ console.error(chalk.cyan(" portless proxy start"));
457
627
  process.exit(1);
458
628
  }
459
- if (isDaemon) {
460
- store.ensureDir();
461
- const logPath = path.join(PORTLESS_DIR, "proxy.log");
462
- const logFd = fs.openSync(logPath, "a");
629
+ if (isForeground) {
630
+ console.log(chalk.blue.bold("\nportless proxy\n"));
631
+ startProxyServer(store2, proxyPort);
632
+ return;
633
+ }
634
+ store2.ensureDir();
635
+ const logPath = path2.join(stateDir, "proxy.log");
636
+ const logFd = fs2.openSync(logPath, "a");
637
+ try {
463
638
  try {
464
- fs.chmodSync(logPath, 420);
639
+ fs2.chmodSync(logPath, FILE_MODE);
465
640
  } catch {
466
641
  }
467
- const daemonArgs = [process.argv[1], "proxy"];
642
+ const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
468
643
  if (portFlagIndex !== -1) {
469
644
  daemonArgs.push("--port", proxyPort.toString());
470
645
  }
471
- const child = spawn(process.execPath, daemonArgs, {
646
+ const child = spawn2(process.execPath, daemonArgs, {
472
647
  detached: true,
473
648
  stdio: ["ignore", logFd, logFd],
474
649
  env: process.env
475
650
  });
476
651
  child.unref();
477
- fs.closeSync(logFd);
478
- if (!await waitForProxy(proxyPort)) {
479
- console.error(chalk.red("Proxy failed to start"));
480
- if (fs.existsSync(logPath)) {
481
- console.log(chalk.gray(`Check logs: ${logPath}`));
482
- }
483
- process.exit(1);
652
+ } finally {
653
+ fs2.closeSync(logFd);
654
+ }
655
+ if (!await waitForProxy(proxyPort)) {
656
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
657
+ console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
658
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
659
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
660
+ if (fs2.existsSync(logPath)) {
661
+ console.error(chalk.gray(`Logs: ${logPath}`));
484
662
  }
485
- return;
663
+ process.exit(1);
486
664
  }
487
- console.log(chalk.blue.bold("\nportless proxy\n"));
488
- startProxyServer(proxyPort);
665
+ console.log(chalk.green(`Proxy started on port ${proxyPort}`));
489
666
  return;
490
667
  }
491
668
  const name = args[0];
492
669
  const commandArgs = args.slice(1);
493
670
  if (commandArgs.length === 0) {
494
- console.error(chalk.red("Error: No command provided"));
495
- console.log(chalk.blue("Usage: portless <name> <command...>"));
496
- console.log(chalk.blue("Example: portless myapp next dev"));
671
+ console.error(chalk.red("Error: No command provided."));
672
+ console.error(chalk.blue("Usage:"));
673
+ console.error(chalk.cyan(" portless <name> <command...>"));
674
+ console.error(chalk.blue("Example:"));
675
+ console.error(chalk.cyan(" portless myapp next dev"));
497
676
  process.exit(1);
498
677
  }
499
- await runApp(name, commandArgs);
678
+ const { dir, port } = await discoverState();
679
+ const store = new RouteStore(dir, {
680
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
681
+ });
682
+ await runApp(store, port, dir, name, commandArgs);
500
683
  }
501
684
  main().catch((err) => {
502
685
  const message = err instanceof Error ? err.message : String(err);