portless 0.2.2 → 0.4.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,494 @@
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-VRBD6YAY.js";
8
11
 
9
12
  // src/cli.ts
10
13
  import chalk from "chalk";
14
+ import * as fs3 from "fs";
15
+ import * as path3 from "path";
16
+ import { spawn as spawn2, spawnSync } from "child_process";
17
+
18
+ // src/certs.ts
11
19
  import * as fs from "fs";
12
20
  import * as path from "path";
13
- import * as readline from "readline";
14
- import { execSync, spawn, spawnSync } from "child_process";
21
+ import * as crypto from "crypto";
22
+ import * as tls from "tls";
23
+ import { execFile as execFileCb, execFileSync } from "child_process";
24
+ import { promisify } from "util";
25
+ var CA_VALIDITY_DAYS = 3650;
26
+ function fixOwnership(...paths) {
27
+ const uid = process.env.SUDO_UID;
28
+ const gid = process.env.SUDO_GID;
29
+ if (!uid || process.getuid?.() !== 0) return;
30
+ for (const p of paths) {
31
+ try {
32
+ fs.chownSync(p, parseInt(uid, 10), parseInt(gid || uid, 10));
33
+ } catch {
34
+ }
35
+ }
36
+ }
37
+ var SERVER_VALIDITY_DAYS = 365;
38
+ var EXPIRY_BUFFER_MS = 7 * 24 * 60 * 60 * 1e3;
39
+ var CA_COMMON_NAME = "portless Local CA";
40
+ var OPENSSL_TIMEOUT_MS = 15e3;
41
+ var CA_KEY_FILE = "ca-key.pem";
42
+ var CA_CERT_FILE = "ca.pem";
43
+ var SERVER_KEY_FILE = "server-key.pem";
44
+ var SERVER_CERT_FILE = "server.pem";
45
+ function fileExists(filePath) {
46
+ try {
47
+ fs.accessSync(filePath, fs.constants.R_OK);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+ function isCertValid(certPath) {
54
+ try {
55
+ const pem = fs.readFileSync(certPath, "utf-8");
56
+ const cert = new crypto.X509Certificate(pem);
57
+ const expiry = new Date(cert.validTo).getTime();
58
+ return Date.now() + EXPIRY_BUFFER_MS < expiry;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ function openssl(args, options) {
64
+ try {
65
+ return execFileSync("openssl", args, {
66
+ encoding: "utf-8",
67
+ timeout: OPENSSL_TIMEOUT_MS,
68
+ input: options?.input,
69
+ stdio: ["pipe", "pipe", "pipe"]
70
+ });
71
+ } catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ throw new Error(
74
+ `openssl failed: ${message}
75
+
76
+ Make sure openssl is installed (ships with macOS and most Linux distributions).`
77
+ );
78
+ }
79
+ }
80
+ var execFileAsync = promisify(execFileCb);
81
+ async function opensslAsync(args) {
82
+ try {
83
+ const { stdout } = await execFileAsync("openssl", args, {
84
+ encoding: "utf-8",
85
+ timeout: OPENSSL_TIMEOUT_MS
86
+ });
87
+ return stdout;
88
+ } catch (err) {
89
+ const message = err instanceof Error ? err.message : String(err);
90
+ throw new Error(
91
+ `openssl failed: ${message}
92
+
93
+ Make sure openssl is installed (ships with macOS and most Linux distributions).`
94
+ );
95
+ }
96
+ }
97
+ function generateCA(stateDir) {
98
+ const keyPath = path.join(stateDir, CA_KEY_FILE);
99
+ const certPath = path.join(stateDir, CA_CERT_FILE);
100
+ openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
101
+ openssl([
102
+ "req",
103
+ "-new",
104
+ "-x509",
105
+ "-key",
106
+ keyPath,
107
+ "-out",
108
+ certPath,
109
+ "-days",
110
+ CA_VALIDITY_DAYS.toString(),
111
+ "-subj",
112
+ `/CN=${CA_COMMON_NAME}`,
113
+ "-addext",
114
+ "basicConstraints=critical,CA:TRUE",
115
+ "-addext",
116
+ "keyUsage=critical,keyCertSign,cRLSign"
117
+ ]);
118
+ fs.chmodSync(keyPath, 384);
119
+ fs.chmodSync(certPath, 420);
120
+ fixOwnership(keyPath, certPath);
121
+ return { certPath, keyPath };
122
+ }
123
+ function generateServerCert(stateDir) {
124
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
125
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
126
+ const serverKeyPath = path.join(stateDir, SERVER_KEY_FILE);
127
+ const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
128
+ const csrPath = path.join(stateDir, "server.csr");
129
+ const extPath = path.join(stateDir, "server-ext.cnf");
130
+ openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", serverKeyPath]);
131
+ openssl(["req", "-new", "-key", serverKeyPath, "-out", csrPath, "-subj", "/CN=localhost"]);
132
+ fs.writeFileSync(
133
+ extPath,
134
+ [
135
+ "authorityKeyIdentifier=keyid,issuer",
136
+ "basicConstraints=CA:FALSE",
137
+ "keyUsage=digitalSignature,keyEncipherment",
138
+ "extendedKeyUsage=serverAuth",
139
+ "subjectAltName=DNS:localhost,DNS:*.localhost"
140
+ ].join("\n") + "\n"
141
+ );
142
+ openssl([
143
+ "x509",
144
+ "-req",
145
+ "-in",
146
+ csrPath,
147
+ "-CA",
148
+ caCertPath,
149
+ "-CAkey",
150
+ caKeyPath,
151
+ "-CAcreateserial",
152
+ "-out",
153
+ serverCertPath,
154
+ "-days",
155
+ SERVER_VALIDITY_DAYS.toString(),
156
+ "-extfile",
157
+ extPath
158
+ ]);
159
+ for (const tmp of [csrPath, extPath]) {
160
+ try {
161
+ fs.unlinkSync(tmp);
162
+ } catch {
163
+ }
164
+ }
165
+ fs.chmodSync(serverKeyPath, 384);
166
+ fs.chmodSync(serverCertPath, 420);
167
+ fixOwnership(serverKeyPath, serverCertPath);
168
+ return { certPath: serverCertPath, keyPath: serverKeyPath };
169
+ }
170
+ function ensureCerts(stateDir) {
171
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
172
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
173
+ const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
174
+ let caGenerated = false;
175
+ if (!fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath)) {
176
+ generateCA(stateDir);
177
+ caGenerated = true;
178
+ }
179
+ if (caGenerated || !fileExists(serverCertPath) || !isCertValid(serverCertPath)) {
180
+ generateServerCert(stateDir);
181
+ }
182
+ return {
183
+ certPath: serverCertPath,
184
+ keyPath: path.join(stateDir, SERVER_KEY_FILE),
185
+ caPath: caCertPath,
186
+ caGenerated
187
+ };
188
+ }
189
+ function isCATrusted(stateDir) {
190
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
191
+ if (!fileExists(caCertPath)) return false;
192
+ if (process.platform === "darwin") {
193
+ return isCATrustedMacOS(caCertPath);
194
+ } else if (process.platform === "linux") {
195
+ return isCATrustedLinux(stateDir);
196
+ }
197
+ return false;
198
+ }
199
+ function isCATrustedMacOS(caCertPath) {
200
+ try {
201
+ const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
202
+ for (const keychain of [loginKeychainPath(), "/Library/Keychains/System.keychain"]) {
203
+ try {
204
+ const result = execFileSync("security", ["find-certificate", "-a", "-Z", keychain], {
205
+ encoding: "utf-8",
206
+ timeout: 5e3,
207
+ stdio: ["pipe", "pipe", "pipe"]
208
+ });
209
+ if (result.toLowerCase().includes(fingerprint)) return true;
210
+ } catch {
211
+ }
212
+ }
213
+ return false;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+ function loginKeychainPath() {
219
+ try {
220
+ const result = execFileSync("security", ["default-keychain"], {
221
+ encoding: "utf-8",
222
+ timeout: 5e3
223
+ }).trim();
224
+ const match = result.match(/"(.+)"/);
225
+ if (match) return match[1];
226
+ } catch {
227
+ }
228
+ const home = process.env.HOME || `/Users/${process.env.USER || "unknown"}`;
229
+ return path.join(home, "Library", "Keychains", "login.keychain-db");
230
+ }
231
+ function isCATrustedLinux(stateDir) {
232
+ const systemCertPath = `/usr/local/share/ca-certificates/portless-ca.crt`;
233
+ if (!fileExists(systemCertPath)) return false;
234
+ try {
235
+ const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
236
+ const installed = fs.readFileSync(systemCertPath, "utf-8").trim();
237
+ return ours === installed;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+ var HOST_CERTS_DIR = "host-certs";
243
+ function sanitizeHostForFilename(hostname) {
244
+ return hostname.replace(/\./g, "_").replace(/[^a-z0-9_-]/gi, "");
245
+ }
246
+ async function generateHostCertAsync(stateDir, hostname) {
247
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
248
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
249
+ const hostDir = path.join(stateDir, HOST_CERTS_DIR);
250
+ if (!fs.existsSync(hostDir)) {
251
+ await fs.promises.mkdir(hostDir, { recursive: true, mode: 493 });
252
+ fixOwnership(hostDir);
253
+ }
254
+ const safeName = sanitizeHostForFilename(hostname);
255
+ const keyPath = path.join(hostDir, `${safeName}-key.pem`);
256
+ const certPath = path.join(hostDir, `${safeName}.pem`);
257
+ const csrPath = path.join(hostDir, `${safeName}.csr`);
258
+ const extPath = path.join(hostDir, `${safeName}-ext.cnf`);
259
+ await opensslAsync(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
260
+ await opensslAsync(["req", "-new", "-key", keyPath, "-out", csrPath, "-subj", `/CN=${hostname}`]);
261
+ const sans = [`DNS:${hostname}`];
262
+ const parts = hostname.split(".");
263
+ if (parts.length >= 2) {
264
+ sans.push(`DNS:*.${parts.slice(1).join(".")}`);
265
+ }
266
+ await fs.promises.writeFile(
267
+ extPath,
268
+ [
269
+ "authorityKeyIdentifier=keyid,issuer",
270
+ "basicConstraints=CA:FALSE",
271
+ "keyUsage=digitalSignature,keyEncipherment",
272
+ "extendedKeyUsage=serverAuth",
273
+ `subjectAltName=${sans.join(",")}`
274
+ ].join("\n") + "\n"
275
+ );
276
+ await opensslAsync([
277
+ "x509",
278
+ "-req",
279
+ "-in",
280
+ csrPath,
281
+ "-CA",
282
+ caCertPath,
283
+ "-CAkey",
284
+ caKeyPath,
285
+ "-CAcreateserial",
286
+ "-out",
287
+ certPath,
288
+ "-days",
289
+ SERVER_VALIDITY_DAYS.toString(),
290
+ "-extfile",
291
+ extPath
292
+ ]);
293
+ for (const tmp of [csrPath, extPath]) {
294
+ try {
295
+ await fs.promises.unlink(tmp);
296
+ } catch {
297
+ }
298
+ }
299
+ await fs.promises.chmod(keyPath, 384);
300
+ await fs.promises.chmod(certPath, 420);
301
+ fixOwnership(keyPath, certPath);
302
+ return { certPath, keyPath };
303
+ }
304
+ function isSimpleLocalhostSubdomain(hostname) {
305
+ const parts = hostname.split(".");
306
+ return parts.length === 2 && parts[1] === "localhost";
307
+ }
308
+ function createSNICallback(stateDir, defaultCert, defaultKey) {
309
+ const cache = /* @__PURE__ */ new Map();
310
+ const pending = /* @__PURE__ */ new Map();
311
+ const defaultCtx = tls.createSecureContext({ cert: defaultCert, key: defaultKey });
312
+ return (servername, cb) => {
313
+ if (servername === "localhost" || isSimpleLocalhostSubdomain(servername)) {
314
+ cb(null, defaultCtx);
315
+ return;
316
+ }
317
+ if (cache.has(servername)) {
318
+ cb(null, cache.get(servername));
319
+ return;
320
+ }
321
+ const safeName = sanitizeHostForFilename(servername);
322
+ const hostDir = path.join(stateDir, HOST_CERTS_DIR);
323
+ const certPath = path.join(hostDir, `${safeName}.pem`);
324
+ const keyPath = path.join(hostDir, `${safeName}-key.pem`);
325
+ if (fileExists(certPath) && fileExists(keyPath) && isCertValid(certPath)) {
326
+ try {
327
+ const ctx = tls.createSecureContext({
328
+ cert: fs.readFileSync(certPath),
329
+ key: fs.readFileSync(keyPath)
330
+ });
331
+ cache.set(servername, ctx);
332
+ cb(null, ctx);
333
+ return;
334
+ } catch {
335
+ }
336
+ }
337
+ if (pending.has(servername)) {
338
+ pending.get(servername).then((ctx) => cb(null, ctx)).catch((err) => cb(err instanceof Error ? err : new Error(String(err))));
339
+ return;
340
+ }
341
+ const promise = generateHostCertAsync(stateDir, servername).then(async (generated) => {
342
+ const [cert, key] = await Promise.all([
343
+ fs.promises.readFile(generated.certPath),
344
+ fs.promises.readFile(generated.keyPath)
345
+ ]);
346
+ return tls.createSecureContext({ cert, key });
347
+ });
348
+ pending.set(servername, promise);
349
+ promise.then((ctx) => {
350
+ cache.set(servername, ctx);
351
+ pending.delete(servername);
352
+ cb(null, ctx);
353
+ }).catch((err) => {
354
+ pending.delete(servername);
355
+ cb(err instanceof Error ? err : new Error(String(err)));
356
+ });
357
+ };
358
+ }
359
+ function trustCA(stateDir) {
360
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
361
+ if (!fileExists(caCertPath)) {
362
+ return { trusted: false, error: "CA certificate not found. Run with --https first." };
363
+ }
364
+ try {
365
+ if (process.platform === "darwin") {
366
+ const keychain = loginKeychainPath();
367
+ execFileSync(
368
+ "security",
369
+ ["add-trusted-cert", "-r", "trustRoot", "-k", keychain, caCertPath],
370
+ { stdio: "pipe", timeout: 3e4 }
371
+ );
372
+ return { trusted: true };
373
+ } else if (process.platform === "linux") {
374
+ const dest = "/usr/local/share/ca-certificates/portless-ca.crt";
375
+ fs.copyFileSync(caCertPath, dest);
376
+ execFileSync("update-ca-certificates", [], { stdio: "pipe", timeout: 3e4 });
377
+ return { trusted: true };
378
+ }
379
+ return { trusted: false, error: `Unsupported platform: ${process.platform}` };
380
+ } catch (err) {
381
+ const message = err instanceof Error ? err.message : String(err);
382
+ if (message.includes("authorization") || message.includes("permission") || message.includes("EACCES")) {
383
+ return {
384
+ trusted: false,
385
+ error: "Permission denied. Try: sudo portless trust"
386
+ };
387
+ }
388
+ return { trusted: false, error: message };
389
+ }
390
+ }
15
391
 
16
392
  // src/cli-utils.ts
393
+ import * as fs2 from "fs";
394
+ import * as http from "http";
395
+ import * as https from "https";
17
396
  import * as net from "net";
18
- async function findFreePort(minPort = 4e3, maxPort = 4999) {
397
+ import * as os from "os";
398
+ import * as path2 from "path";
399
+ import * as readline from "readline";
400
+ import { execSync, spawn } from "child_process";
401
+ var DEFAULT_PROXY_PORT = 1355;
402
+ var PRIVILEGED_PORT_THRESHOLD = 1024;
403
+ var SYSTEM_STATE_DIR = "/tmp/portless";
404
+ var USER_STATE_DIR = path2.join(os.homedir(), ".portless");
405
+ var MIN_APP_PORT = 4e3;
406
+ var MAX_APP_PORT = 4999;
407
+ var RANDOM_PORT_ATTEMPTS = 50;
408
+ var SOCKET_TIMEOUT_MS = 500;
409
+ var LSOF_TIMEOUT_MS = 5e3;
410
+ var WAIT_FOR_PROXY_MAX_ATTEMPTS = 20;
411
+ var WAIT_FOR_PROXY_INTERVAL_MS = 250;
412
+ var SIGNAL_CODES = {
413
+ SIGHUP: 1,
414
+ SIGINT: 2,
415
+ SIGQUIT: 3,
416
+ SIGABRT: 6,
417
+ SIGKILL: 9,
418
+ SIGTERM: 15
419
+ };
420
+ function getDefaultPort() {
421
+ const envPort = process.env.PORTLESS_PORT;
422
+ if (envPort) {
423
+ const port = parseInt(envPort, 10);
424
+ if (!isNaN(port) && port >= 1 && port <= 65535) return port;
425
+ }
426
+ return DEFAULT_PROXY_PORT;
427
+ }
428
+ function resolveStateDir(port) {
429
+ if (process.env.PORTLESS_STATE_DIR) return process.env.PORTLESS_STATE_DIR;
430
+ return port < PRIVILEGED_PORT_THRESHOLD ? SYSTEM_STATE_DIR : USER_STATE_DIR;
431
+ }
432
+ function readPortFromDir(dir) {
433
+ try {
434
+ const raw = fs2.readFileSync(path2.join(dir, "proxy.port"), "utf-8").trim();
435
+ const port = parseInt(raw, 10);
436
+ return isNaN(port) ? null : port;
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+ var TLS_MARKER_FILE = "proxy.tls";
442
+ function readTlsMarker(dir) {
443
+ try {
444
+ return fs2.existsSync(path2.join(dir, TLS_MARKER_FILE));
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+ function writeTlsMarker(dir, enabled) {
450
+ const markerPath = path2.join(dir, TLS_MARKER_FILE);
451
+ if (enabled) {
452
+ fs2.writeFileSync(markerPath, "1", { mode: 420 });
453
+ } else {
454
+ try {
455
+ fs2.unlinkSync(markerPath);
456
+ } catch {
457
+ }
458
+ }
459
+ }
460
+ function isHttpsEnvEnabled() {
461
+ const val = process.env.PORTLESS_HTTPS;
462
+ return val === "1" || val === "true";
463
+ }
464
+ async function discoverState() {
465
+ if (process.env.PORTLESS_STATE_DIR) {
466
+ const dir = process.env.PORTLESS_STATE_DIR;
467
+ const port = readPortFromDir(dir) ?? getDefaultPort();
468
+ const tls2 = readTlsMarker(dir);
469
+ return { dir, port, tls: tls2 };
470
+ }
471
+ const userPort = readPortFromDir(USER_STATE_DIR);
472
+ if (userPort !== null) {
473
+ const tls2 = readTlsMarker(USER_STATE_DIR);
474
+ if (await isProxyRunning(userPort, tls2)) {
475
+ return { dir: USER_STATE_DIR, port: userPort, tls: tls2 };
476
+ }
477
+ }
478
+ const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
479
+ if (systemPort !== null) {
480
+ const tls2 = readTlsMarker(SYSTEM_STATE_DIR);
481
+ if (await isProxyRunning(systemPort, tls2)) {
482
+ return { dir: SYSTEM_STATE_DIR, port: systemPort, tls: tls2 };
483
+ }
484
+ }
485
+ const defaultPort = getDefaultPort();
486
+ return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
487
+ }
488
+ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
489
+ if (minPort > maxPort) {
490
+ throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
491
+ }
19
492
  const tryPort = (port) => {
20
493
  return new Promise((resolve) => {
21
494
  const server = net.createServer();
@@ -25,7 +498,7 @@ async function findFreePort(minPort = 4e3, maxPort = 4999) {
25
498
  server.on("error", () => resolve(false));
26
499
  });
27
500
  };
28
- for (let i = 0; i < 50; i++) {
501
+ for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
29
502
  const port = minPort + Math.floor(Math.random() * (maxPort - minPort + 1));
30
503
  if (await tryPort(port)) {
31
504
  return port;
@@ -38,58 +511,36 @@ async function findFreePort(minPort = 4e3, maxPort = 4999) {
38
511
  }
39
512
  throw new Error(`No free port found in range ${minPort}-${maxPort}`);
40
513
  }
41
- function isProxyRunning(port = 80) {
514
+ function isProxyRunning(port, tls2 = false) {
42
515
  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();
516
+ const requestFn = tls2 ? https.request : http.request;
517
+ const req = requestFn(
518
+ {
519
+ hostname: "127.0.0.1",
520
+ port,
521
+ path: "/",
522
+ method: "HEAD",
523
+ timeout: SOCKET_TIMEOUT_MS,
524
+ ...tls2 ? { rejectUnauthorized: false } : {}
525
+ },
526
+ (res) => {
527
+ res.resume();
528
+ resolve(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
529
+ }
530
+ );
531
+ req.on("error", () => resolve(false));
532
+ req.on("timeout", () => {
533
+ req.destroy();
52
534
  resolve(false);
53
535
  });
54
- socket.connect(port, "127.0.0.1");
536
+ req.end();
55
537
  });
56
538
  }
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
539
  function findPidOnPort(port) {
89
540
  try {
90
541
  const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
91
542
  encoding: "utf-8",
92
- timeout: 5e3
543
+ timeout: LSOF_TIMEOUT_MS
93
544
  });
94
545
  const pid = parseInt(output.trim().split("\n")[0], 10);
95
546
  return isNaN(pid) ? null : pid;
@@ -97,11 +548,10 @@ function findPidOnPort(port) {
97
548
  return null;
98
549
  }
99
550
  }
100
- async function waitForProxy(proxyPort, maxAttempts = 20, intervalMs = 250) {
101
- const port = proxyPort ?? readProxyPort();
551
+ async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
102
552
  for (let i = 0; i < maxAttempts; i++) {
103
553
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
104
- if (await isProxyRunning(port)) {
554
+ if (await isProxyRunning(port, tls2)) {
105
555
  return true;
106
556
  }
107
557
  }
@@ -113,45 +563,76 @@ function spawnCommand(commandArgs, options) {
113
563
  env: options?.env
114
564
  });
115
565
  let exiting = false;
566
+ const cleanup = () => {
567
+ process.removeListener("SIGINT", onSigInt);
568
+ process.removeListener("SIGTERM", onSigTerm);
569
+ options?.onCleanup?.();
570
+ };
116
571
  const handleSignal = (signal) => {
117
572
  if (exiting) return;
118
573
  exiting = true;
119
574
  child.kill(signal);
120
- options?.onCleanup?.();
575
+ cleanup();
121
576
  process.exit(128 + (SIGNAL_CODES[signal] || 15));
122
577
  };
123
- process.on("SIGINT", () => handleSignal("SIGINT"));
124
- process.on("SIGTERM", () => handleSignal("SIGTERM"));
578
+ const onSigInt = () => handleSignal("SIGINT");
579
+ const onSigTerm = () => handleSignal("SIGTERM");
580
+ process.on("SIGINT", onSigInt);
581
+ process.on("SIGTERM", onSigTerm);
125
582
  child.on("error", (err) => {
126
583
  if (exiting) return;
127
584
  exiting = true;
128
- console.error(chalk.red(`Failed to run command: ${err.message}`));
129
- options?.onCleanup?.();
585
+ console.error(`Failed to run command: ${err.message}`);
586
+ if (err.code === "ENOENT") {
587
+ console.error(`Is "${commandArgs[0]}" installed and in your PATH?`);
588
+ }
589
+ cleanup();
130
590
  process.exit(1);
131
591
  });
132
592
  child.on("exit", (code, signal) => {
133
593
  if (exiting) return;
134
594
  exiting = true;
135
- options?.onCleanup?.();
595
+ cleanup();
136
596
  if (signal) {
137
- process.exit(128 + (SIGNAL_CODES[signal] || 1));
597
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
138
598
  }
139
599
  process.exit(code ?? 1);
140
600
  });
141
601
  }
142
- function startProxyServer(proxyPort) {
602
+ function prompt(question) {
603
+ const rl = readline.createInterface({
604
+ input: process.stdin,
605
+ output: process.stdout
606
+ });
607
+ return new Promise((resolve) => {
608
+ rl.on("close", () => resolve(""));
609
+ rl.question(question, (answer) => {
610
+ rl.close();
611
+ resolve(answer.trim().toLowerCase());
612
+ });
613
+ });
614
+ }
615
+
616
+ // src/cli.ts
617
+ var DEBOUNCE_MS = 100;
618
+ var POLL_INTERVAL_MS = 3e3;
619
+ var EXIT_TIMEOUT_MS = 2e3;
620
+ var SUDO_SPAWN_TIMEOUT_MS = 3e4;
621
+ function startProxyServer(store, proxyPort, tlsOptions) {
143
622
  store.ensureDir();
623
+ const isTls = !!tlsOptions;
144
624
  const routesPath = store.getRoutesPath();
145
- if (!fs.existsSync(routesPath)) {
146
- fs.writeFileSync(routesPath, "[]", { mode: 420 });
625
+ if (!fs3.existsSync(routesPath)) {
626
+ fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
147
627
  }
148
628
  try {
149
- fs.chmodSync(routesPath, 420);
629
+ fs3.chmodSync(routesPath, FILE_MODE);
150
630
  } catch {
151
631
  }
152
632
  let cachedRoutes = store.loadRoutes();
153
633
  let debounceTimer = null;
154
634
  let watcher = null;
635
+ let pollingInterval = null;
155
636
  const reloadRoutes = () => {
156
637
  try {
157
638
  cachedRoutes = store.loadRoutes();
@@ -159,86 +640,107 @@ function startProxyServer(proxyPort) {
159
640
  }
160
641
  };
161
642
  try {
162
- watcher = fs.watch(routesPath, () => {
643
+ watcher = fs3.watch(routesPath, () => {
163
644
  if (debounceTimer) clearTimeout(debounceTimer);
164
- debounceTimer = setTimeout(reloadRoutes, 100);
645
+ debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
165
646
  });
166
647
  } catch {
167
648
  console.warn(chalk.yellow("fs.watch unavailable; falling back to polling for route changes"));
168
- setInterval(reloadRoutes, 3e3);
649
+ pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
169
650
  }
170
651
  const server = createProxyServer({
171
652
  getRoutes: () => cachedRoutes,
172
- onError: (msg) => console.error(chalk.red(msg))
653
+ proxyPort,
654
+ onError: (msg) => console.error(chalk.red(msg)),
655
+ tls: tlsOptions
173
656
  });
174
657
  server.on("error", (err) => {
175
658
  if (err.code === "EADDRINUSE") {
176
- console.error(chalk.red(`Port ${proxyPort} is already in use`));
659
+ console.error(chalk.red(`Port ${proxyPort} is already in use.`));
660
+ console.error(chalk.blue("Stop the existing proxy first:"));
661
+ console.error(chalk.cyan(" portless proxy stop"));
662
+ console.error(chalk.blue("Or check what is using the port:"));
663
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
177
664
  } else if (err.code === "EACCES") {
178
- console.error(chalk.red("Permission denied. Use: sudo portless proxy"));
665
+ console.error(chalk.red(`Permission denied for port ${proxyPort}.`));
666
+ console.error(chalk.blue("Either run with sudo:"));
667
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
668
+ console.error(chalk.blue("Or use a non-privileged port (no sudo needed):"));
669
+ console.error(chalk.cyan(" portless proxy start"));
179
670
  } else {
180
671
  console.error(chalk.red(`Proxy error: ${err.message}`));
181
672
  }
182
673
  process.exit(1);
183
674
  });
184
675
  server.listen(proxyPort, () => {
185
- fs.writeFileSync(store.pidPath, process.pid.toString(), { mode: 420 });
186
- fs.writeFileSync(PROXY_PORT_PATH, proxyPort.toString(), { mode: 420 });
187
- console.log(chalk.green(`HTTP proxy listening on port ${proxyPort}`));
676
+ fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
677
+ fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
678
+ writeTlsMarker(store.dir, isTls);
679
+ const proto = isTls ? "HTTPS/2" : "HTTP";
680
+ console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}`));
188
681
  });
189
682
  let exiting = false;
190
683
  const cleanup = () => {
191
684
  if (exiting) return;
192
685
  exiting = true;
686
+ if (debounceTimer) clearTimeout(debounceTimer);
687
+ if (pollingInterval) clearInterval(pollingInterval);
193
688
  if (watcher) {
194
689
  watcher.close();
195
690
  }
196
691
  try {
197
- fs.unlinkSync(store.pidPath);
692
+ fs3.unlinkSync(store.pidPath);
198
693
  } catch {
199
694
  }
200
695
  try {
201
- fs.unlinkSync(PROXY_PORT_PATH);
696
+ fs3.unlinkSync(store.portFilePath);
202
697
  } catch {
203
698
  }
699
+ writeTlsMarker(store.dir, false);
204
700
  server.close(() => process.exit(0));
205
- setTimeout(() => process.exit(0), 2e3).unref();
701
+ setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
206
702
  };
207
703
  process.on("SIGINT", cleanup);
208
704
  process.on("SIGTERM", cleanup);
209
705
  console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
210
706
  console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
211
707
  }
212
- async function stopProxy() {
708
+ async function stopProxy(store, proxyPort, tls2) {
213
709
  const pidPath = store.pidPath;
214
- const proxyPort = readProxyPort();
215
- if (!fs.existsSync(pidPath)) {
216
- if (await isProxyRunning(proxyPort)) {
710
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
711
+ const sudoHint = needsSudo ? "sudo " : "";
712
+ if (!fs3.existsSync(pidPath)) {
713
+ if (await isProxyRunning(proxyPort, tls2)) {
217
714
  console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
218
715
  const pid = findPidOnPort(proxyPort);
219
716
  if (pid !== null) {
220
717
  try {
221
718
  process.kill(pid, "SIGTERM");
222
719
  try {
223
- fs.unlinkSync(PROXY_PORT_PATH);
720
+ fs3.unlinkSync(store.portFilePath);
224
721
  } catch {
225
722
  }
226
723
  console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
227
724
  } catch (err) {
228
725
  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"));
726
+ console.error(chalk.red("Permission denied. The proxy was started with sudo."));
727
+ console.error(chalk.blue("Stop it with:"));
728
+ console.error(chalk.cyan(" sudo portless proxy stop"));
231
729
  } else {
232
730
  const message = err instanceof Error ? err.message : String(err);
233
- console.error(chalk.red("Failed to stop proxy:"), message);
731
+ console.error(chalk.red(`Failed to stop proxy: ${message}`));
732
+ console.error(chalk.blue("Check if the process is still running:"));
733
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
234
734
  }
235
735
  }
236
736
  } 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"));
737
+ console.error(chalk.red("Cannot identify the process. It may be running as root."));
738
+ console.error(chalk.blue("Try stopping with sudo:"));
739
+ console.error(chalk.cyan(" sudo portless proxy stop"));
239
740
  } else {
240
741
  console.error(chalk.red(`Could not identify the process on port ${proxyPort}.`));
241
- console.log(chalk.blue(`Try: sudo kill "$(lsof -ti tcp:${proxyPort})"`));
742
+ console.error(chalk.blue("Try manually:"));
743
+ console.error(chalk.cyan(` sudo kill "$(lsof -ti tcp:${proxyPort})"`));
242
744
  }
243
745
  } else {
244
746
  console.log(chalk.yellow("Proxy is not running."));
@@ -246,47 +748,54 @@ async function stopProxy() {
246
748
  return;
247
749
  }
248
750
  try {
249
- const pid = parseInt(fs.readFileSync(pidPath, "utf-8"), 10);
751
+ const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
250
752
  if (isNaN(pid)) {
251
753
  console.error(chalk.red("Corrupted PID file. Removing it."));
252
- fs.unlinkSync(pidPath);
754
+ fs3.unlinkSync(pidPath);
253
755
  return;
254
756
  }
255
757
  try {
256
758
  process.kill(pid, 0);
257
759
  } catch {
258
- console.log(chalk.yellow("Proxy process is no longer running. Cleaning up."));
259
- fs.unlinkSync(pidPath);
760
+ console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
761
+ fs3.unlinkSync(pidPath);
762
+ try {
763
+ fs3.unlinkSync(store.portFilePath);
764
+ } catch {
765
+ }
260
766
  return;
261
767
  }
262
- if (!await isProxyRunning(proxyPort)) {
768
+ if (!await isProxyRunning(proxyPort, tls2)) {
263
769
  console.log(
264
770
  chalk.yellow(
265
771
  `PID file exists but port ${proxyPort} is not listening. The PID may have been recycled.`
266
772
  )
267
773
  );
268
774
  console.log(chalk.yellow("Removing stale PID file."));
269
- fs.unlinkSync(pidPath);
775
+ fs3.unlinkSync(pidPath);
270
776
  return;
271
777
  }
272
778
  process.kill(pid, "SIGTERM");
273
- fs.unlinkSync(pidPath);
779
+ fs3.unlinkSync(pidPath);
274
780
  try {
275
- fs.unlinkSync(PROXY_PORT_PATH);
781
+ fs3.unlinkSync(store.portFilePath);
276
782
  } catch {
277
783
  }
278
784
  console.log(chalk.green("Proxy stopped."));
279
785
  } catch (err) {
280
786
  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"));
787
+ console.error(chalk.red("Permission denied. The proxy was started with sudo."));
788
+ console.error(chalk.blue("Stop it with:"));
789
+ console.error(chalk.cyan(` ${sudoHint}portless proxy stop`));
283
790
  } else {
284
791
  const message = err instanceof Error ? err.message : String(err);
285
- console.error(chalk.red("Failed to stop proxy:"), message);
792
+ console.error(chalk.red(`Failed to stop proxy: ${message}`));
793
+ console.error(chalk.blue("Check if the process is still running:"));
794
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
286
795
  }
287
796
  }
288
797
  }
289
- function listRoutes() {
798
+ function listRoutes(store, proxyPort, tls2) {
290
799
  const routes = store.loadRoutes();
291
800
  if (routes.length === 0) {
292
801
  console.log(chalk.yellow("No active routes."));
@@ -295,21 +804,32 @@ function listRoutes() {
295
804
  }
296
805
  console.log(chalk.blue.bold("\nActive routes:\n"));
297
806
  for (const route of routes) {
807
+ const url = formatUrl(route.hostname, proxyPort, tls2);
298
808
  console.log(
299
- ` ${chalk.cyan(`http://${route.hostname}`)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
809
+ ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
300
810
  );
301
811
  }
302
812
  console.log();
303
813
  }
304
- async function runApp(name, commandArgs) {
814
+ async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2) {
305
815
  const hostname = parseHostname(name);
306
- const proxyPort = readProxyPort();
307
816
  console.log(chalk.blue.bold(`
308
817
  portless
309
818
  `));
310
819
  console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
311
- if (!await isProxyRunning(proxyPort)) {
312
- if (process.stdin.isTTY) {
820
+ if (!await isProxyRunning(proxyPort, tls2)) {
821
+ const defaultPort = getDefaultPort();
822
+ const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
823
+ const wantHttps = isHttpsEnvEnabled();
824
+ if (needsSudo) {
825
+ if (!process.stdin.isTTY) {
826
+ console.error(chalk.red("Proxy is not running."));
827
+ console.error(chalk.blue("Start the proxy first (requires sudo for this port):"));
828
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
829
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
830
+ console.error(chalk.cyan(" portless proxy start"));
831
+ process.exit(1);
832
+ }
313
833
  const answer = await prompt(chalk.yellow("Proxy not running. Start it? [Y/n/skip] "));
314
834
  if (answer === "n" || answer === "no") {
315
835
  console.log(chalk.gray("Cancelled."));
@@ -321,37 +841,55 @@ portless
321
841
  return;
322
842
  }
323
843
  console.log(chalk.yellow("Starting proxy (requires sudo)..."));
324
- const result = spawnSync("sudo", [process.execPath, process.argv[1], "proxy", "--daemon"], {
844
+ const startArgs = [process.execPath, process.argv[1], "proxy", "start"];
845
+ if (wantHttps) startArgs.push("--https");
846
+ const result = spawnSync("sudo", startArgs, {
325
847
  stdio: "inherit",
326
- timeout: 3e4
848
+ timeout: SUDO_SPAWN_TIMEOUT_MS
327
849
  });
328
850
  if (result.status !== 0) {
329
- console.log(chalk.red("\nFailed to start proxy"));
851
+ console.error(chalk.red("Failed to start proxy."));
852
+ console.error(chalk.blue("Try starting it manually:"));
853
+ console.error(chalk.cyan(" sudo portless proxy start"));
330
854
  process.exit(1);
331
855
  }
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
- }
856
+ } else {
857
+ console.log(chalk.yellow("Starting proxy..."));
858
+ const startArgs = [process.argv[1], "proxy", "start"];
859
+ if (wantHttps) startArgs.push("--https");
860
+ const result = spawnSync(process.execPath, startArgs, {
861
+ stdio: "inherit",
862
+ timeout: SUDO_SPAWN_TIMEOUT_MS
863
+ });
864
+ if (result.status !== 0) {
865
+ console.error(chalk.red("Failed to start proxy."));
866
+ console.error(chalk.blue("Try starting it manually:"));
867
+ console.error(chalk.cyan(" portless proxy start"));
338
868
  process.exit(1);
339
869
  }
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"));
870
+ }
871
+ const autoTls = readTlsMarker(stateDir);
872
+ if (!await waitForProxy(defaultPort, void 0, void 0, autoTls)) {
873
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
874
+ const logPath = path3.join(stateDir, "proxy.log");
875
+ console.error(chalk.blue("Try starting the proxy manually to see the error:"));
876
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
877
+ if (fs3.existsSync(logPath)) {
878
+ console.error(chalk.gray(`Logs: ${logPath}`));
879
+ }
345
880
  process.exit(1);
346
881
  }
882
+ tls2 = autoTls;
883
+ console.log(chalk.green("Proxy started in background"));
347
884
  } else {
348
885
  console.log(chalk.gray("-- Proxy is running"));
349
886
  }
350
887
  const port = await findFreePort();
351
888
  console.log(chalk.green(`-- Using port ${port}`));
352
889
  store.addRoute(hostname, port, process.pid);
890
+ const finalUrl = formatUrl(hostname, proxyPort, tls2);
353
891
  console.log(chalk.cyan.bold(`
354
- -> http://${hostname}
892
+ -> ${finalUrl}
355
893
  `));
356
894
  console.log(chalk.gray(`Running: PORT=${port} ${commandArgs.join(" ")}
357
895
  `));
@@ -371,7 +909,8 @@ async function main() {
371
909
  const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
372
910
  if (isNpx || isPnpmDlx) {
373
911
  console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
374
- console.log(chalk.blue("Install globally: npm install -g portless"));
912
+ console.error(chalk.blue("Install globally instead:"));
913
+ console.error(chalk.cyan(" npm install -g portless"));
375
914
  process.exit(1);
376
915
  }
377
916
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
@@ -391,16 +930,19 @@ ${chalk.bold("Install:")}
391
930
  Do NOT add portless as a project dependency.
392
931
 
393
932
  ${chalk.bold("Usage:")}
394
- ${chalk.cyan("sudo portless proxy")} Start the proxy (run once, keep open)
395
- ${chalk.cyan("sudo portless proxy --port 8080")} Start the proxy on a custom port
396
- ${chalk.cyan("sudo portless proxy stop")} Stop the proxy
933
+ ${chalk.cyan("portless proxy start")} Start the proxy (background daemon)
934
+ ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS (auto-generates certs)
935
+ ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
936
+ ${chalk.cyan("portless proxy stop")} Stop the proxy
397
937
  ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
398
938
  ${chalk.cyan("portless list")} Show active routes
939
+ ${chalk.cyan("portless trust")} Add local CA to system trust store
399
940
 
400
941
  ${chalk.bold("Examples:")}
401
- sudo portless proxy # Start proxy in terminal 1
402
- portless myapp next dev # Terminal 2 -> http://myapp.localhost
403
- portless api.myapp pnpm start # Terminal 3 -> http://api.myapp.localhost
942
+ portless proxy start # Start proxy on port 1355
943
+ portless proxy start --https # Start with HTTPS/2 (faster page loads)
944
+ portless myapp next dev # -> http://myapp.localhost:1355
945
+ portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
404
946
 
405
947
  ${chalk.bold("In package.json:")}
406
948
  {
@@ -410,14 +952,30 @@ ${chalk.bold("In package.json:")}
410
952
  }
411
953
 
412
954
  ${chalk.bold("How it works:")}
413
- 1. Start the proxy once with sudo (listens on port 80 by default)
414
- 2. Run your apps - they register automatically
415
- 3. Access via http://<name>.localhost
955
+ 1. Start the proxy once (listens on port 1355 by default, no sudo needed)
956
+ 2. Run your apps - they auto-start the proxy and register automatically
957
+ 3. Access via http://<name>.localhost:1355
416
958
  4. .localhost domains auto-resolve to 127.0.0.1
417
959
 
960
+ ${chalk.bold("HTTP/2 + HTTPS:")}
961
+ Use --https for HTTP/2 multiplexing (faster dev server page loads).
962
+ On first use, portless generates a local CA and adds it to your
963
+ system trust store. No browser warnings. No sudo required on macOS.
964
+
418
965
  ${chalk.bold("Options:")}
419
- --port <number> Port for the proxy to listen on (default: 80)
420
- Ports >= 1024 do not require sudo
966
+ -p, --port <number> Port for the proxy to listen on (default: 1355)
967
+ Ports < 1024 require sudo
968
+ --https Enable HTTP/2 + TLS with auto-generated certs
969
+ --cert <path> Use a custom TLS certificate (implies --https)
970
+ --key <path> Use a custom TLS private key (implies --https)
971
+ --no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
972
+ --foreground Run proxy in foreground (for debugging)
973
+
974
+ ${chalk.bold("Environment variables:")}
975
+ PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
976
+ PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
977
+ PORTLESS_STATE_DIR=<path> Override the state directory
978
+ PORTLESS=0 | PORTLESS=skip Run command directly without proxy
421
979
 
422
980
  ${chalk.bold("Skip portless:")}
423
981
  PORTLESS=0 pnpm dev # Runs command directly without proxy
@@ -426,88 +984,245 @@ ${chalk.bold("Skip portless:")}
426
984
  process.exit(0);
427
985
  }
428
986
  if (args[0] === "--version" || args[0] === "-v") {
429
- console.log("0.2.2");
987
+ console.log("0.4.0");
430
988
  process.exit(0);
431
989
  }
990
+ if (args[0] === "trust") {
991
+ const { dir: dir2 } = await discoverState();
992
+ const result = trustCA(dir2);
993
+ if (result.trusted) {
994
+ console.log(chalk.green("Local CA added to system trust store."));
995
+ console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
996
+ } else {
997
+ console.error(chalk.red(`Failed to trust CA: ${result.error}`));
998
+ if (result.error?.includes("sudo")) {
999
+ console.error(chalk.blue("Run with sudo:"));
1000
+ console.error(chalk.cyan(" sudo portless trust"));
1001
+ }
1002
+ process.exit(1);
1003
+ }
1004
+ return;
1005
+ }
432
1006
  if (args[0] === "list") {
433
- listRoutes();
1007
+ const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
1008
+ const store2 = new RouteStore(dir2, {
1009
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1010
+ });
1011
+ listRoutes(store2, port2, tls3);
434
1012
  return;
435
1013
  }
436
1014
  if (args[0] === "proxy") {
437
1015
  if (args[1] === "stop") {
438
- await stopProxy();
1016
+ const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
1017
+ const store3 = new RouteStore(dir2, {
1018
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1019
+ });
1020
+ await stopProxy(store3, port2, tls3);
439
1021
  return;
440
1022
  }
441
- const isDaemon = args.includes("--daemon");
442
- let proxyPort = DEFAULT_PROXY_PORT;
443
- const portFlagIndex = args.indexOf("--port");
1023
+ if (args[1] !== "start") {
1024
+ console.log(`
1025
+ ${chalk.bold("Usage: portless proxy <command>")}
1026
+
1027
+ ${chalk.cyan("portless proxy start")} Start the proxy (daemon)
1028
+ ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
1029
+ ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
1030
+ ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
1031
+ ${chalk.cyan("portless proxy stop")} Stop the proxy
1032
+ `);
1033
+ process.exit(args[1] ? 1 : 0);
1034
+ }
1035
+ const isForeground = args.includes("--foreground");
1036
+ let proxyPort = getDefaultPort();
1037
+ let portFlagIndex = args.indexOf("--port");
1038
+ if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
444
1039
  if (portFlagIndex !== -1) {
445
1040
  const portValue = args[portFlagIndex + 1];
446
1041
  if (!portValue || portValue.startsWith("-")) {
447
- console.error(chalk.red("Error: --port requires a port number"));
448
- console.log(chalk.blue("Usage: portless proxy --port 8080"));
1042
+ console.error(chalk.red("Error: --port / -p requires a port number."));
1043
+ console.error(chalk.blue("Usage:"));
1044
+ console.error(chalk.cyan(" portless proxy start -p 8080"));
449
1045
  process.exit(1);
450
1046
  }
451
1047
  proxyPort = parseInt(portValue, 10);
452
1048
  if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
453
1049
  console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
454
- console.log(chalk.blue("Port must be between 1 and 65535"));
1050
+ console.error(chalk.blue("Port must be between 1 and 65535."));
455
1051
  process.exit(1);
456
1052
  }
457
1053
  }
1054
+ const hasNoTls = args.includes("--no-tls");
1055
+ const hasHttpsFlag = args.includes("--https");
1056
+ const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
1057
+ let customCertPath = null;
1058
+ let customKeyPath = null;
1059
+ const certIdx = args.indexOf("--cert");
1060
+ if (certIdx !== -1) {
1061
+ customCertPath = args[certIdx + 1] || null;
1062
+ if (!customCertPath || customCertPath.startsWith("-")) {
1063
+ console.error(chalk.red("Error: --cert requires a file path."));
1064
+ process.exit(1);
1065
+ }
1066
+ }
1067
+ const keyIdx = args.indexOf("--key");
1068
+ if (keyIdx !== -1) {
1069
+ customKeyPath = args[keyIdx + 1] || null;
1070
+ if (!customKeyPath || customKeyPath.startsWith("-")) {
1071
+ console.error(chalk.red("Error: --key requires a file path."));
1072
+ process.exit(1);
1073
+ }
1074
+ }
1075
+ if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
1076
+ console.error(chalk.red("Error: --cert and --key must be used together."));
1077
+ process.exit(1);
1078
+ }
1079
+ const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1080
+ const stateDir = resolveStateDir(proxyPort);
1081
+ const store2 = new RouteStore(stateDir, {
1082
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1083
+ });
458
1084
  if (await isProxyRunning(proxyPort)) {
459
- if (!isDaemon) {
460
- console.log(chalk.yellow("Proxy is already running."));
461
- console.log(chalk.blue("To restart: portless proxy stop && sudo portless proxy"));
1085
+ if (isForeground) {
1086
+ return;
462
1087
  }
1088
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1089
+ const sudoPrefix = needsSudo ? "sudo " : "";
1090
+ console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
1091
+ console.log(
1092
+ chalk.blue(`To restart: portless proxy stop && ${sudoPrefix}portless proxy start`)
1093
+ );
463
1094
  return;
464
1095
  }
465
- if (proxyPort < 1024 && process.getuid() !== 0) {
466
- console.error(chalk.red(`Error: Proxy requires sudo for port ${proxyPort}`));
467
- console.log(chalk.blue("Usage: sudo portless proxy"));
1096
+ if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1097
+ console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
1098
+ console.error(chalk.blue("Either run with sudo:"));
1099
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
1100
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
1101
+ console.error(chalk.cyan(" portless proxy start"));
468
1102
  process.exit(1);
469
1103
  }
470
- if (isDaemon) {
471
- store.ensureDir();
472
- const logPath = path.join(PORTLESS_DIR, "proxy.log");
473
- const logFd = fs.openSync(logPath, "a");
1104
+ let tlsOptions;
1105
+ if (useHttps) {
1106
+ store2.ensureDir();
1107
+ if (customCertPath && customKeyPath) {
1108
+ try {
1109
+ const cert = fs3.readFileSync(customCertPath);
1110
+ const key = fs3.readFileSync(customKeyPath);
1111
+ const certStr = cert.toString("utf-8");
1112
+ const keyStr = key.toString("utf-8");
1113
+ if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
1114
+ console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
1115
+ console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
1116
+ process.exit(1);
1117
+ }
1118
+ if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
1119
+ console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
1120
+ console.error(
1121
+ chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----")
1122
+ );
1123
+ process.exit(1);
1124
+ }
1125
+ tlsOptions = { cert, key };
1126
+ } catch (err) {
1127
+ const message = err instanceof Error ? err.message : String(err);
1128
+ console.error(chalk.red(`Error reading certificate files: ${message}`));
1129
+ process.exit(1);
1130
+ }
1131
+ } else {
1132
+ console.log(chalk.gray("Ensuring TLS certificates..."));
1133
+ const certs = ensureCerts(stateDir);
1134
+ if (certs.caGenerated) {
1135
+ console.log(chalk.green("Generated local CA certificate."));
1136
+ }
1137
+ if (!isCATrusted(stateDir)) {
1138
+ console.log(chalk.yellow("Adding CA to system trust store..."));
1139
+ const trustResult = trustCA(stateDir);
1140
+ if (trustResult.trusted) {
1141
+ console.log(
1142
+ chalk.green("CA added to system trust store. Browsers will trust portless certs.")
1143
+ );
1144
+ } else {
1145
+ console.warn(chalk.yellow("Could not add CA to system trust store."));
1146
+ if (trustResult.error) {
1147
+ console.warn(chalk.gray(trustResult.error));
1148
+ }
1149
+ console.warn(
1150
+ chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
1151
+ );
1152
+ console.warn(chalk.cyan(" portless trust"));
1153
+ }
1154
+ }
1155
+ const cert = fs3.readFileSync(certs.certPath);
1156
+ const key = fs3.readFileSync(certs.keyPath);
1157
+ tlsOptions = {
1158
+ cert,
1159
+ key,
1160
+ SNICallback: createSNICallback(stateDir, cert, key)
1161
+ };
1162
+ }
1163
+ }
1164
+ if (isForeground) {
1165
+ console.log(chalk.blue.bold("\nportless proxy\n"));
1166
+ startProxyServer(store2, proxyPort, tlsOptions);
1167
+ return;
1168
+ }
1169
+ store2.ensureDir();
1170
+ const logPath = path3.join(stateDir, "proxy.log");
1171
+ const logFd = fs3.openSync(logPath, "a");
1172
+ try {
474
1173
  try {
475
- fs.chmodSync(logPath, 420);
1174
+ fs3.chmodSync(logPath, FILE_MODE);
476
1175
  } catch {
477
1176
  }
478
- const daemonArgs = [process.argv[1], "proxy"];
1177
+ const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
479
1178
  if (portFlagIndex !== -1) {
480
1179
  daemonArgs.push("--port", proxyPort.toString());
481
1180
  }
482
- const child = spawn(process.execPath, daemonArgs, {
1181
+ if (useHttps) {
1182
+ if (customCertPath && customKeyPath) {
1183
+ daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
1184
+ } else {
1185
+ daemonArgs.push("--https");
1186
+ }
1187
+ }
1188
+ const child = spawn2(process.execPath, daemonArgs, {
483
1189
  detached: true,
484
1190
  stdio: ["ignore", logFd, logFd],
485
1191
  env: process.env
486
1192
  });
487
1193
  child.unref();
488
- fs.closeSync(logFd);
489
- if (!await waitForProxy(proxyPort)) {
490
- console.error(chalk.red("Proxy failed to start"));
491
- if (fs.existsSync(logPath)) {
492
- console.log(chalk.gray(`Check logs: ${logPath}`));
493
- }
494
- process.exit(1);
1194
+ } finally {
1195
+ fs3.closeSync(logFd);
1196
+ }
1197
+ if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
1198
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1199
+ console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
1200
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1201
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
1202
+ if (fs3.existsSync(logPath)) {
1203
+ console.error(chalk.gray(`Logs: ${logPath}`));
495
1204
  }
496
- return;
1205
+ process.exit(1);
497
1206
  }
498
- console.log(chalk.blue.bold("\nportless proxy\n"));
499
- startProxyServer(proxyPort);
1207
+ const proto = useHttps ? "HTTPS/2" : "HTTP";
1208
+ console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
500
1209
  return;
501
1210
  }
502
1211
  const name = args[0];
503
1212
  const commandArgs = args.slice(1);
504
1213
  if (commandArgs.length === 0) {
505
- console.error(chalk.red("Error: No command provided"));
506
- console.log(chalk.blue("Usage: portless <name> <command...>"));
507
- console.log(chalk.blue("Example: portless myapp next dev"));
1214
+ console.error(chalk.red("Error: No command provided."));
1215
+ console.error(chalk.blue("Usage:"));
1216
+ console.error(chalk.cyan(" portless <name> <command...>"));
1217
+ console.error(chalk.blue("Example:"));
1218
+ console.error(chalk.cyan(" portless myapp next dev"));
508
1219
  process.exit(1);
509
1220
  }
510
- await runApp(name, commandArgs);
1221
+ const { dir, port, tls: tls2 } = await discoverState();
1222
+ const store = new RouteStore(dir, {
1223
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1224
+ });
1225
+ await runApp(store, port, dir, name, commandArgs, tls2);
511
1226
  }
512
1227
  main().catch((err) => {
513
1228
  const message = err instanceof Error ? err.message : String(err);