portless 0.4.1 → 0.5.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,19 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  FILE_MODE,
4
- PORTLESS_HEADER,
4
+ PRIVILEGED_PORT_THRESHOLD,
5
+ RouteConflictError,
5
6
  RouteStore,
7
+ cleanHostsFile,
6
8
  createProxyServer,
9
+ discoverState,
10
+ findFreePort,
11
+ findPidOnPort,
12
+ fixOwnership,
7
13
  formatUrl,
14
+ getDefaultPort,
15
+ injectFrameworkFlags,
8
16
  isErrnoException,
9
- parseHostname
10
- } from "./chunk-VRBD6YAY.js";
17
+ isHttpsEnvEnabled,
18
+ isProxyRunning,
19
+ parseHostname,
20
+ prompt,
21
+ readTlsMarker,
22
+ resolveStateDir,
23
+ spawnCommand,
24
+ syncHostsFile,
25
+ waitForProxy,
26
+ writeTlsMarker
27
+ } from "./chunk-P3DHZHEZ.js";
11
28
 
12
29
  // src/cli.ts
13
30
  import chalk from "chalk";
14
31
  import * as fs3 from "fs";
15
32
  import * as path3 from "path";
16
- import { spawn as spawn2, spawnSync } from "child_process";
33
+ import { spawn, spawnSync } from "child_process";
17
34
 
18
35
  // src/certs.ts
19
36
  import * as fs from "fs";
@@ -23,17 +40,6 @@ import * as tls from "tls";
23
40
  import { execFile as execFileCb, execFileSync } from "child_process";
24
41
  import { promisify } from "util";
25
42
  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
43
  var SERVER_VALIDITY_DAYS = 365;
38
44
  var EXPIRY_BUFFER_MS = 7 * 24 * 60 * 60 * 1e3;
39
45
  var CA_COMMON_NAME = "portless Local CA";
@@ -60,6 +66,17 @@ function isCertValid(certPath) {
60
66
  return false;
61
67
  }
62
68
  }
69
+ function isCertSignatureStrong(certPath) {
70
+ try {
71
+ const text = openssl(["x509", "-in", certPath, "-noout", "-text"]);
72
+ const match = text.match(/Signature Algorithm:\s*(\S+)/i);
73
+ if (!match) return false;
74
+ const algo = match[1].toLowerCase();
75
+ return !algo.includes("sha1");
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
63
80
  function openssl(args, options) {
64
81
  try {
65
82
  return execFileSync("openssl", args, {
@@ -102,6 +119,7 @@ function generateCA(stateDir) {
102
119
  "req",
103
120
  "-new",
104
121
  "-x509",
122
+ "-sha256",
105
123
  "-key",
106
124
  keyPath,
107
125
  "-out",
@@ -142,6 +160,7 @@ function generateServerCert(stateDir) {
142
160
  openssl([
143
161
  "x509",
144
162
  "-req",
163
+ "-sha256",
145
164
  "-in",
146
165
  csrPath,
147
166
  "-CA",
@@ -171,12 +190,13 @@ function ensureCerts(stateDir) {
171
190
  const caCertPath = path.join(stateDir, CA_CERT_FILE);
172
191
  const caKeyPath = path.join(stateDir, CA_KEY_FILE);
173
192
  const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
193
+ const serverKeyPath = path.join(stateDir, SERVER_KEY_FILE);
174
194
  let caGenerated = false;
175
- if (!fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath)) {
195
+ if (!fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath) || !isCertSignatureStrong(caCertPath)) {
176
196
  generateCA(stateDir);
177
197
  caGenerated = true;
178
198
  }
179
- if (caGenerated || !fileExists(serverCertPath) || !isCertValid(serverCertPath)) {
199
+ if (caGenerated || !fileExists(serverCertPath) || !fileExists(serverKeyPath) || !isCertValid(serverCertPath) || !isCertSignatureStrong(serverCertPath)) {
180
200
  generateServerCert(stateDir);
181
201
  }
182
202
  return {
@@ -228,8 +248,50 @@ function loginKeychainPath() {
228
248
  const home = process.env.HOME || `/Users/${process.env.USER || "unknown"}`;
229
249
  return path.join(home, "Library", "Keychains", "login.keychain-db");
230
250
  }
251
+ var LINUX_CA_TRUST_CONFIGS = {
252
+ debian: {
253
+ certDir: "/usr/local/share/ca-certificates",
254
+ updateCommand: "update-ca-certificates"
255
+ },
256
+ arch: {
257
+ certDir: "/etc/ca-certificates/trust-source/anchors",
258
+ updateCommand: "update-ca-trust"
259
+ },
260
+ fedora: {
261
+ certDir: "/etc/pki/ca-trust/source/anchors",
262
+ updateCommand: "update-ca-trust"
263
+ },
264
+ suse: {
265
+ certDir: "/etc/pki/trust/anchors",
266
+ updateCommand: "update-ca-certificates"
267
+ }
268
+ };
269
+ function detectLinuxDistro() {
270
+ try {
271
+ const osRelease = fs.readFileSync("/etc/os-release", "utf-8").toLowerCase();
272
+ if (osRelease.includes("arch")) return "arch";
273
+ if (osRelease.includes("fedora") || osRelease.includes("rhel") || osRelease.includes("centos"))
274
+ return "fedora";
275
+ if (osRelease.includes("suse")) return "suse";
276
+ if (osRelease.includes("debian") || osRelease.includes("ubuntu")) return "debian";
277
+ } catch {
278
+ }
279
+ for (const [distro, config] of Object.entries(LINUX_CA_TRUST_CONFIGS)) {
280
+ try {
281
+ execFileSync("which", [config.updateCommand], { stdio: "pipe", timeout: 5e3 });
282
+ if (fs.existsSync(path.dirname(config.certDir))) return distro;
283
+ } catch {
284
+ }
285
+ }
286
+ return void 0;
287
+ }
288
+ function getLinuxCATrustConfig() {
289
+ const distro = detectLinuxDistro();
290
+ return LINUX_CA_TRUST_CONFIGS[distro ?? "debian"];
291
+ }
231
292
  function isCATrustedLinux(stateDir) {
232
- const systemCertPath = `/usr/local/share/ca-certificates/portless-ca.crt`;
293
+ const config = getLinuxCATrustConfig();
294
+ const systemCertPath = path.join(config.certDir, "portless-ca.crt");
233
295
  if (!fileExists(systemCertPath)) return false;
234
296
  try {
235
297
  const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
@@ -276,6 +338,7 @@ async function generateHostCertAsync(stateDir, hostname) {
276
338
  await opensslAsync([
277
339
  "x509",
278
340
  "-req",
341
+ "-sha256",
279
342
  "-in",
280
343
  csrPath,
281
344
  "-CA",
@@ -301,16 +364,12 @@ async function generateHostCertAsync(stateDir, hostname) {
301
364
  fixOwnership(keyPath, certPath);
302
365
  return { certPath, keyPath };
303
366
  }
304
- function isSimpleLocalhostSubdomain(hostname) {
305
- const parts = hostname.split(".");
306
- return parts.length === 2 && parts[1] === "localhost";
307
- }
308
367
  function createSNICallback(stateDir, defaultCert, defaultKey) {
309
368
  const cache = /* @__PURE__ */ new Map();
310
369
  const pending = /* @__PURE__ */ new Map();
311
370
  const defaultCtx = tls.createSecureContext({ cert: defaultCert, key: defaultKey });
312
371
  return (servername, cb) => {
313
- if (servername === "localhost" || isSimpleLocalhostSubdomain(servername)) {
372
+ if (servername === "localhost") {
314
373
  cb(null, defaultCtx);
315
374
  return;
316
375
  }
@@ -322,7 +381,7 @@ function createSNICallback(stateDir, defaultCert, defaultKey) {
322
381
  const hostDir = path.join(stateDir, HOST_CERTS_DIR);
323
382
  const certPath = path.join(hostDir, `${safeName}.pem`);
324
383
  const keyPath = path.join(hostDir, `${safeName}-key.pem`);
325
- if (fileExists(certPath) && fileExists(keyPath) && isCertValid(certPath)) {
384
+ if (fileExists(certPath) && fileExists(keyPath) && isCertValid(certPath) && isCertSignatureStrong(certPath)) {
326
385
  try {
327
386
  const ctx = tls.createSecureContext({
328
387
  cert: fs.readFileSync(certPath),
@@ -371,9 +430,13 @@ function trustCA(stateDir) {
371
430
  );
372
431
  return { trusted: true };
373
432
  } else if (process.platform === "linux") {
374
- const dest = "/usr/local/share/ca-certificates/portless-ca.crt";
433
+ const config = getLinuxCATrustConfig();
434
+ if (!fs.existsSync(config.certDir)) {
435
+ fs.mkdirSync(config.certDir, { recursive: true });
436
+ }
437
+ const dest = path.join(config.certDir, "portless-ca.crt");
375
438
  fs.copyFileSync(caCertPath, dest);
376
- execFileSync("update-ca-certificates", [], { stdio: "pipe", timeout: 3e4 });
439
+ execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
377
440
  return { trusted: true };
378
441
  }
379
442
  return { trusted: false, error: `Unsupported platform: ${process.platform}` };
@@ -389,251 +452,150 @@ function trustCA(stateDir) {
389
452
  }
390
453
  }
391
454
 
392
- // src/cli-utils.ts
455
+ // src/auto.ts
456
+ import { execFileSync as execFileSync2 } from "child_process";
393
457
  import * as fs2 from "fs";
394
- import * as http from "http";
395
- import * as https from "https";
396
- import * as net from "net";
397
- import * as os from "os";
398
458
  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;
459
+ function sanitizeForHostname(name) {
460
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
431
461
  }
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;
462
+ function inferProjectName(cwd = process.cwd()) {
463
+ const pkgResult = findPackageJsonName(cwd);
464
+ if (pkgResult) {
465
+ const sanitized2 = sanitizeForHostname(pkgResult);
466
+ if (sanitized2) {
467
+ return { name: sanitized2, source: "package.json" };
468
+ }
439
469
  }
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;
470
+ const gitRoot = findGitRoot(cwd);
471
+ if (gitRoot) {
472
+ const sanitized2 = sanitizeForHostname(path2.basename(gitRoot));
473
+ if (sanitized2) {
474
+ return { name: sanitized2, source: "git root" };
475
+ }
476
+ }
477
+ const sanitized = sanitizeForHostname(path2.basename(cwd));
478
+ if (sanitized) {
479
+ return { name: sanitized, source: "directory name" };
447
480
  }
481
+ throw new Error("Could not infer a project name from package.json, git root, or directory name");
448
482
  }
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 {
483
+ function findPackageJsonName(startDir) {
484
+ let dir = startDir;
485
+ for (; ; ) {
486
+ const pkgPath = path2.join(dir, "package.json");
454
487
  try {
455
- fs2.unlinkSync(markerPath);
488
+ const raw = fs2.readFileSync(pkgPath, "utf-8");
489
+ const pkg = JSON.parse(raw);
490
+ if (typeof pkg.name === "string" && pkg.name) {
491
+ return pkg.name.replace(/^@[^/]+\//, "");
492
+ }
456
493
  } catch {
457
494
  }
495
+ const parent = path2.dirname(dir);
496
+ if (parent === dir) break;
497
+ dir = parent;
458
498
  }
499
+ return null;
459
500
  }
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
- }
501
+ function findGitRoot(startDir) {
502
+ try {
503
+ const toplevel = execFileSync2("git", ["rev-parse", "--show-toplevel"], {
504
+ cwd: startDir,
505
+ encoding: "utf-8",
506
+ timeout: 5e3,
507
+ stdio: ["ignore", "pipe", "ignore"]
508
+ }).trim();
509
+ if (toplevel) return toplevel;
510
+ } catch {
477
511
  }
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 };
512
+ let dir = startDir;
513
+ for (; ; ) {
514
+ const gitPath = path2.join(dir, ".git");
515
+ try {
516
+ const stat = fs2.statSync(gitPath);
517
+ if (stat.isDirectory()) return dir;
518
+ if (stat.isFile()) return dir;
519
+ } catch {
483
520
  }
521
+ const parent = path2.dirname(dir);
522
+ if (parent === dir) break;
523
+ dir = parent;
484
524
  }
485
- const defaultPort = getDefaultPort();
486
- return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
525
+ return null;
487
526
  }
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
- }
492
- const tryPort = (port) => {
493
- return new Promise((resolve) => {
494
- const server = net.createServer();
495
- server.listen(port, () => {
496
- server.close(() => resolve(true));
497
- });
498
- server.on("error", () => resolve(false));
499
- });
500
- };
501
- for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
502
- const port = minPort + Math.floor(Math.random() * (maxPort - minPort + 1));
503
- if (await tryPort(port)) {
504
- return port;
505
- }
506
- }
507
- for (let port = minPort; port <= maxPort; port++) {
508
- if (await tryPort(port)) {
509
- return port;
510
- }
511
- }
512
- throw new Error(`No free port found in range ${minPort}-${maxPort}`);
527
+ var DEFAULT_BRANCHES = /* @__PURE__ */ new Set(["main", "master"]);
528
+ function branchToPrefix(branch) {
529
+ if (!branch || branch === "HEAD" || DEFAULT_BRANCHES.has(branch)) return null;
530
+ const lastSegment = branch.split("/").pop();
531
+ const prefix = sanitizeForHostname(lastSegment);
532
+ return prefix || null;
513
533
  }
514
- function isProxyRunning(port, tls2 = false) {
515
- return new Promise((resolve) => {
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();
534
- resolve(false);
535
- });
536
- req.end();
537
- });
534
+ function detectWorktreePrefix(cwd = process.cwd()) {
535
+ const cliResult = detectWorktreeViaCli(cwd);
536
+ if (cliResult !== void 0) return cliResult;
537
+ return detectWorktreeViaFilesystem(cwd);
538
538
  }
539
- function findPidOnPort(port) {
539
+ function detectWorktreeViaCli(cwd) {
540
540
  try {
541
- const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
541
+ const listOutput = execFileSync2("git", ["worktree", "list", "--porcelain"], {
542
+ cwd,
542
543
  encoding: "utf-8",
543
- timeout: LSOF_TIMEOUT_MS
544
+ timeout: 5e3,
545
+ stdio: ["ignore", "pipe", "ignore"]
544
546
  });
545
- const pid = parseInt(output.trim().split("\n")[0], 10);
546
- return isNaN(pid) ? null : pid;
547
+ const worktreeCount = listOutput.split("\n").filter((l) => l.startsWith("worktree ")).length;
548
+ if (worktreeCount <= 1) return null;
549
+ const branch = execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
550
+ cwd,
551
+ encoding: "utf-8",
552
+ timeout: 5e3,
553
+ stdio: ["ignore", "pipe", "ignore"]
554
+ }).trim();
555
+ const prefix = branchToPrefix(branch);
556
+ if (!prefix) return null;
557
+ return { prefix, source: "git branch" };
547
558
  } catch {
548
- return null;
559
+ return void 0;
549
560
  }
550
561
  }
551
- async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
552
- for (let i = 0; i < maxAttempts; i++) {
553
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
554
- if (await isProxyRunning(port, tls2)) {
555
- return true;
562
+ function detectWorktreeViaFilesystem(startDir) {
563
+ let dir = startDir;
564
+ for (; ; ) {
565
+ const gitPath = path2.join(dir, ".git");
566
+ try {
567
+ const stat = fs2.statSync(gitPath);
568
+ if (stat.isDirectory()) {
569
+ return null;
570
+ }
571
+ if (stat.isFile()) {
572
+ const content = fs2.readFileSync(gitPath, "utf-8").trim();
573
+ const match = content.match(/^gitdir:\s*(.+)$/);
574
+ if (!match) return null;
575
+ const gitdir = match[1];
576
+ if (!gitdir.match(/\/worktrees\/[^/]+$/)) return null;
577
+ const branch = readBranchFromHead(path2.resolve(dir, gitdir));
578
+ const prefix = branchToPrefix(branch ?? "");
579
+ if (!prefix) return null;
580
+ return { prefix, source: "git branch" };
581
+ }
582
+ } catch {
556
583
  }
584
+ const parent = path2.dirname(dir);
585
+ if (parent === dir) break;
586
+ dir = parent;
557
587
  }
558
- return false;
588
+ return null;
559
589
  }
560
- function spawnCommand(commandArgs, options) {
561
- const child = spawn(commandArgs[0], commandArgs.slice(1), {
562
- stdio: "inherit",
563
- env: options?.env
564
- });
565
- let exiting = false;
566
- const cleanup = () => {
567
- process.removeListener("SIGINT", onSigInt);
568
- process.removeListener("SIGTERM", onSigTerm);
569
- options?.onCleanup?.();
570
- };
571
- const handleSignal = (signal) => {
572
- if (exiting) return;
573
- exiting = true;
574
- child.kill(signal);
575
- cleanup();
576
- process.exit(128 + (SIGNAL_CODES[signal] || 15));
577
- };
578
- const onSigInt = () => handleSignal("SIGINT");
579
- const onSigTerm = () => handleSignal("SIGTERM");
580
- process.on("SIGINT", onSigInt);
581
- process.on("SIGTERM", onSigTerm);
582
- child.on("error", (err) => {
583
- if (exiting) return;
584
- exiting = true;
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();
590
- process.exit(1);
591
- });
592
- child.on("exit", (code, signal) => {
593
- if (exiting) return;
594
- exiting = true;
595
- cleanup();
596
- if (signal) {
597
- process.exit(128 + (SIGNAL_CODES[signal] || 15));
598
- }
599
- process.exit(code ?? 1);
600
- });
601
- }
602
- var FRAMEWORKS_NEEDING_PORT = {
603
- vite: { strictPort: true },
604
- "react-router": { strictPort: true },
605
- astro: { strictPort: false },
606
- ng: { strictPort: false }
607
- };
608
- function injectFrameworkFlags(commandArgs, port) {
609
- const cmd = commandArgs[0];
610
- if (!cmd) return;
611
- const basename2 = path2.basename(cmd);
612
- const framework = FRAMEWORKS_NEEDING_PORT[basename2];
613
- if (!framework) return;
614
- if (!commandArgs.includes("--port")) {
615
- commandArgs.push("--port", port.toString());
616
- if (framework.strictPort) {
617
- commandArgs.push("--strictPort");
618
- }
619
- }
620
- if (!commandArgs.includes("--host")) {
621
- commandArgs.push("--host", "127.0.0.1");
590
+ function readBranchFromHead(gitdir) {
591
+ try {
592
+ const head = fs2.readFileSync(path2.join(gitdir, "HEAD"), "utf-8").trim();
593
+ const refMatch = head.match(/^ref: refs\/heads\/(.+)$/);
594
+ return refMatch ? refMatch[1] : null;
595
+ } catch {
596
+ return null;
622
597
  }
623
598
  }
624
- function prompt(question) {
625
- const rl = readline.createInterface({
626
- input: process.stdin,
627
- output: process.stdout
628
- });
629
- return new Promise((resolve) => {
630
- rl.on("close", () => resolve(""));
631
- rl.question(question, (answer) => {
632
- rl.close();
633
- resolve(answer.trim().toLowerCase());
634
- });
635
- });
636
- }
637
599
 
638
600
  // src/cli.ts
639
601
  var DEBOUNCE_MS = 100;
@@ -651,13 +613,18 @@ function startProxyServer(store, proxyPort, tlsOptions) {
651
613
  fs3.chmodSync(routesPath, FILE_MODE);
652
614
  } catch {
653
615
  }
616
+ fixOwnership(routesPath);
654
617
  let cachedRoutes = store.loadRoutes();
655
618
  let debounceTimer = null;
656
619
  let watcher = null;
657
620
  let pollingInterval = null;
621
+ const autoSyncHosts = process.env.PORTLESS_SYNC_HOSTS === "1";
658
622
  const reloadRoutes = () => {
659
623
  try {
660
624
  cachedRoutes = store.loadRoutes();
625
+ if (autoSyncHosts) {
626
+ syncHostsFile(cachedRoutes.map((r) => r.hostname));
627
+ }
661
628
  } catch {
662
629
  }
663
630
  };
@@ -670,6 +637,9 @@ function startProxyServer(store, proxyPort, tlsOptions) {
670
637
  console.warn(chalk.yellow("fs.watch unavailable; falling back to polling for route changes"));
671
638
  pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
672
639
  }
640
+ if (autoSyncHosts) {
641
+ syncHostsFile(cachedRoutes.map((r) => r.hostname));
642
+ }
673
643
  const server = createProxyServer({
674
644
  getRoutes: () => cachedRoutes,
675
645
  proxyPort,
@@ -698,6 +668,7 @@ function startProxyServer(store, proxyPort, tlsOptions) {
698
668
  fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
699
669
  fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
700
670
  writeTlsMarker(store.dir, isTls);
671
+ fixOwnership(store.dir, store.pidPath, store.portFilePath);
701
672
  const proto = isTls ? "HTTPS/2" : "HTTP";
702
673
  console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}`));
703
674
  });
@@ -719,6 +690,7 @@ function startProxyServer(store, proxyPort, tlsOptions) {
719
690
  } catch {
720
691
  }
721
692
  writeTlsMarker(store.dir, false);
693
+ if (autoSyncHosts) cleanHostsFile();
722
694
  server.close(() => process.exit(0));
723
695
  setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
724
696
  };
@@ -827,18 +799,26 @@ function listRoutes(store, proxyPort, tls2) {
827
799
  console.log(chalk.blue.bold("\nActive routes:\n"));
828
800
  for (const route of routes) {
829
801
  const url = formatUrl(route.hostname, proxyPort, tls2);
802
+ const label = route.pid === 0 ? "(alias)" : `(pid ${route.pid})`;
830
803
  console.log(
831
- ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
804
+ ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(label)}`
832
805
  );
833
806
  }
834
807
  console.log();
835
808
  }
836
- async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2) {
809
+ async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2, force, autoInfo, desiredPort) {
837
810
  const hostname = parseHostname(name);
838
811
  console.log(chalk.blue.bold(`
839
812
  portless
840
813
  `));
841
814
  console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
815
+ if (autoInfo) {
816
+ const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name;
817
+ console.log(chalk.gray(`-- Name "${baseName}" (from ${autoInfo.nameSource})`));
818
+ if (autoInfo.prefix) {
819
+ console.log(chalk.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`));
820
+ }
821
+ }
842
822
  if (!await isProxyRunning(proxyPort, tls2)) {
843
823
  const defaultPort = getDefaultPort();
844
824
  const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
@@ -906,21 +886,38 @@ portless
906
886
  } else {
907
887
  console.log(chalk.gray("-- Proxy is running"));
908
888
  }
909
- const port = await findFreePort();
910
- console.log(chalk.green(`-- Using port ${port}`));
911
- store.addRoute(hostname, port, process.pid);
889
+ const port = desiredPort ?? await findFreePort();
890
+ if (desiredPort) {
891
+ console.log(chalk.green(`-- Using port ${port} (fixed)`));
892
+ } else {
893
+ console.log(chalk.green(`-- Using port ${port}`));
894
+ }
895
+ try {
896
+ store.addRoute(hostname, port, process.pid, force);
897
+ } catch (err) {
898
+ if (err instanceof RouteConflictError) {
899
+ console.error(chalk.red(`Error: ${err.message}`));
900
+ process.exit(1);
901
+ }
902
+ throw err;
903
+ }
912
904
  const finalUrl = formatUrl(hostname, proxyPort, tls2);
913
905
  console.log(chalk.cyan.bold(`
914
906
  -> ${finalUrl}
915
907
  `));
916
908
  injectFrameworkFlags(commandArgs, port);
917
- console.log(chalk.gray(`Running: PORT=${port} HOST=127.0.0.1 ${commandArgs.join(" ")}
918
- `));
909
+ console.log(
910
+ chalk.gray(
911
+ `Running: PORT=${port} HOST=127.0.0.1 PORTLESS_URL=${finalUrl} ${commandArgs.join(" ")}
912
+ `
913
+ )
914
+ );
919
915
  spawnCommand(commandArgs, {
920
916
  env: {
921
917
  ...process.env,
922
918
  PORT: port.toString(),
923
919
  HOST: "127.0.0.1",
920
+ PORTLESS_URL: finalUrl,
924
921
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
925
922
  },
926
923
  onCleanup: () => {
@@ -931,23 +928,120 @@ portless
931
928
  }
932
929
  });
933
930
  }
934
- async function main() {
935
- const args = process.argv.slice(2);
936
- const isNpx = process.env.npm_command === "exec" && !process.env.npm_lifecycle_event;
937
- const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
938
- if (isNpx || isPnpmDlx) {
939
- console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
940
- console.error(chalk.blue("Install globally instead:"));
941
- console.error(chalk.cyan(" npm install -g portless"));
931
+ function parseAppPort(value) {
932
+ if (!value || value.startsWith("--")) {
933
+ console.error(chalk.red("Error: --app-port requires a port number."));
942
934
  process.exit(1);
943
935
  }
944
- const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
945
- if (skipPortless && args.length >= 2 && args[0] !== "proxy") {
946
- spawnCommand(args.slice(1));
947
- return;
936
+ const port = parseInt(value, 10);
937
+ if (isNaN(port) || port < 1 || port > 65535) {
938
+ console.error(chalk.red(`Error: Invalid app port "${value}". Must be 1-65535.`));
939
+ process.exit(1);
948
940
  }
949
- if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
950
- console.log(`
941
+ return port;
942
+ }
943
+ function appPortFromEnv() {
944
+ const envVal = process.env.PORTLESS_APP_PORT;
945
+ if (!envVal) return void 0;
946
+ const port = parseInt(envVal, 10);
947
+ if (isNaN(port) || port < 1 || port > 65535) {
948
+ console.error(chalk.red(`Error: Invalid PORTLESS_APP_PORT="${envVal}". Must be 1-65535.`));
949
+ process.exit(1);
950
+ }
951
+ return port;
952
+ }
953
+ function parseRunArgs(args) {
954
+ let force = false;
955
+ let appPort;
956
+ let i = 0;
957
+ while (i < args.length && args[i].startsWith("-")) {
958
+ if (args[i] === "--") {
959
+ i++;
960
+ break;
961
+ } else if (args[i] === "--help" || args[i] === "-h") {
962
+ console.log(`
963
+ ${chalk.bold("portless run")} - Infer project name and run through the proxy.
964
+
965
+ ${chalk.bold("Usage:")}
966
+ ${chalk.cyan("portless run [options] <command...>")}
967
+
968
+ ${chalk.bold("Options:")}
969
+ --force Override an existing route registered by another process
970
+ --app-port <number> Use a fixed port for the app (skip auto-assignment)
971
+ --help, -h Show this help
972
+
973
+ ${chalk.bold("Name inference (in order):")}
974
+ 1. package.json "name" field (walks up directories)
975
+ 2. Git repo root directory name
976
+ 3. Current directory basename
977
+
978
+ In git worktrees, the branch name is prepended as a subdomain prefix
979
+ (e.g. feature-auth.myapp.localhost).
980
+
981
+ ${chalk.bold("Examples:")}
982
+ portless run next dev # -> http://<project>.localhost:1355
983
+ portless run vite dev # -> http://<project>.localhost:1355
984
+ portless run --app-port 3000 pnpm start
985
+ `);
986
+ process.exit(0);
987
+ } else if (args[i] === "--force") {
988
+ force = true;
989
+ } else if (args[i] === "--app-port") {
990
+ i++;
991
+ appPort = parseAppPort(args[i]);
992
+ } else {
993
+ console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
994
+ console.error(chalk.blue("Known flags: --force, --app-port, --help"));
995
+ process.exit(1);
996
+ }
997
+ i++;
998
+ }
999
+ if (!appPort) appPort = appPortFromEnv();
1000
+ return { force, appPort, commandArgs: args.slice(i) };
1001
+ }
1002
+ function parseAppArgs(args) {
1003
+ let force = false;
1004
+ let appPort;
1005
+ let i = 0;
1006
+ while (i < args.length && args[i].startsWith("-")) {
1007
+ if (args[i] === "--") {
1008
+ i++;
1009
+ break;
1010
+ } else if (args[i] === "--force") {
1011
+ force = true;
1012
+ } else if (args[i] === "--app-port") {
1013
+ i++;
1014
+ appPort = parseAppPort(args[i]);
1015
+ } else {
1016
+ console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1017
+ console.error(chalk.blue("Known flags: --force, --app-port"));
1018
+ process.exit(1);
1019
+ }
1020
+ i++;
1021
+ }
1022
+ const name = args[i];
1023
+ i++;
1024
+ while (i < args.length && args[i].startsWith("--")) {
1025
+ if (args[i] === "--") {
1026
+ i++;
1027
+ break;
1028
+ } else if (args[i] === "--force") {
1029
+ force = true;
1030
+ } else if (args[i] === "--app-port") {
1031
+ i++;
1032
+ appPort = parseAppPort(args[i]);
1033
+ } else {
1034
+ console.error(chalk.red(`Error: Unknown flag "${args[i]}".`));
1035
+ console.error(chalk.blue("Known flags: --force, --app-port"));
1036
+ process.exit(1);
1037
+ }
1038
+ i++;
1039
+ }
1040
+ if (!appPort) appPort = appPortFromEnv();
1041
+ return { force, appPort, name, commandArgs: args.slice(i) };
1042
+ }
1043
+ function printHelp() {
1044
+ console.log(`
951
1045
  ${chalk.bold("portless")} - Replace port numbers with stable, named .localhost URLs. For humans and agents.
952
1046
 
953
1047
  Eliminates port conflicts, memorizing port numbers, and cookie/storage
@@ -963,8 +1057,13 @@ ${chalk.bold("Usage:")}
963
1057
  ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
964
1058
  ${chalk.cyan("portless proxy stop")} Stop the proxy
965
1059
  ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
1060
+ ${chalk.cyan("portless run <cmd>")} Infer name from project, run through proxy
1061
+ ${chalk.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
1062
+ ${chalk.cyan("portless alias --remove <name>")} Remove a static route
966
1063
  ${chalk.cyan("portless list")} Show active routes
967
1064
  ${chalk.cyan("portless trust")} Add local CA to system trust store
1065
+ ${chalk.cyan("portless hosts sync")} Add routes to /etc/hosts (fixes Safari)
1066
+ ${chalk.cyan("portless hosts clean")} Remove portless entries from /etc/hosts
968
1067
 
969
1068
  ${chalk.bold("Examples:")}
970
1069
  portless proxy start # Start proxy on port 1355
@@ -972,21 +1071,25 @@ ${chalk.bold("Examples:")}
972
1071
  portless myapp next dev # -> http://myapp.localhost:1355
973
1072
  portless myapp vite dev # -> http://myapp.localhost:1355
974
1073
  portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
1074
+ portless run next dev # -> http://<project>.localhost:1355
1075
+ portless run next dev # in worktree -> http://<worktree>.<project>.localhost:1355
1076
+ # Wildcard subdomains: tenant.myapp.localhost also routes to myapp
975
1077
 
976
1078
  ${chalk.bold("In package.json:")}
977
1079
  {
978
1080
  "scripts": {
979
- "dev": "portless myapp next dev"
1081
+ "dev": "portless run next dev"
980
1082
  }
981
1083
  }
982
1084
 
983
1085
  ${chalk.bold("How it works:")}
984
1086
  1. Start the proxy once (listens on port 1355 by default, no sudo needed)
985
1087
  2. Run your apps - they auto-start the proxy and register automatically
1088
+ (apps get a random port in the 4000-4999 range via PORT)
986
1089
  3. Access via http://<name>.localhost:1355
987
1090
  4. .localhost domains auto-resolve to 127.0.0.1
988
- 5. Frameworks that ignore PORT (Vite, Astro, React Router, Angular) get
989
- --port and --host flags injected automatically
1091
+ 5. Frameworks that ignore PORT (Vite, Astro, React Router, Angular,
1092
+ Expo, React Native) get --port and --host flags injected automatically
990
1093
 
991
1094
  ${chalk.bold("HTTP/2 + HTTPS:")}
992
1095
  Use --https for HTTP/2 multiplexing (faster dev server page loads).
@@ -994,6 +1097,8 @@ ${chalk.bold("HTTP/2 + HTTPS:")}
994
1097
  system trust store. No browser warnings. No sudo required on macOS.
995
1098
 
996
1099
  ${chalk.bold("Options:")}
1100
+ run <cmd> Infer project name from package.json / git / cwd
1101
+ Adds worktree prefix in git worktrees
997
1102
  -p, --port <number> Port for the proxy to listen on (default: 1355)
998
1103
  Ports < 1024 require sudo
999
1104
  --https Enable HTTP/2 + TLS with auto-generated certs
@@ -1001,247 +1106,423 @@ ${chalk.bold("Options:")}
1001
1106
  --key <path> Use a custom TLS private key (implies --https)
1002
1107
  --no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
1003
1108
  --foreground Run proxy in foreground (for debugging)
1109
+ --app-port <number> Use a fixed port for the app (skip auto-assignment)
1110
+ --force Override an existing route registered by another process
1111
+ --name <name> Use <name> as the app name (bypasses subcommand dispatch)
1112
+ -- Stop flag parsing; everything after is passed to the child
1004
1113
 
1005
1114
  ${chalk.bold("Environment variables:")}
1006
1115
  PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
1007
- PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
1116
+ 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)
1008
1119
  PORTLESS_STATE_DIR=<path> Override the state directory
1009
1120
  PORTLESS=0 | PORTLESS=skip Run command directly without proxy
1010
1121
 
1122
+ ${chalk.bold("Child process environment:")}
1123
+ PORT Ephemeral port the child should listen on
1124
+ HOST Always 127.0.0.1
1125
+ PORTLESS_URL Public URL of the app (e.g. http://myapp.localhost:1355)
1126
+
1127
+ ${chalk.bold("Safari / DNS:")}
1128
+ .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1129
+ Safari relies on the system DNS resolver, which may not handle them.
1130
+ If Safari can't find your .localhost URL, run:
1131
+ ${chalk.cyan("sudo portless hosts sync")}
1132
+ This adds entries to /etc/hosts. Clean up later with:
1133
+ ${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
+
1011
1137
  ${chalk.bold("Skip portless:")}
1012
1138
  PORTLESS=0 pnpm dev # Runs command directly without proxy
1013
1139
  PORTLESS=skip pnpm dev # Same as above
1140
+
1141
+ ${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.
1145
+ `);
1146
+ process.exit(0);
1147
+ }
1148
+ function printVersion() {
1149
+ console.log("0.5.0");
1150
+ process.exit(0);
1151
+ }
1152
+ async function handleTrust() {
1153
+ const { dir } = await discoverState();
1154
+ const result = trustCA(dir);
1155
+ if (result.trusted) {
1156
+ console.log(chalk.green("Local CA added to system trust store."));
1157
+ console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
1158
+ } else {
1159
+ console.error(chalk.red(`Failed to trust CA: ${result.error}`));
1160
+ if (result.error?.includes("sudo")) {
1161
+ console.error(chalk.blue("Run with sudo:"));
1162
+ console.error(chalk.cyan(" sudo portless trust"));
1163
+ }
1164
+ process.exit(1);
1165
+ }
1166
+ }
1167
+ async function handleList() {
1168
+ const { dir, port, tls: tls2 } = await discoverState();
1169
+ const store = new RouteStore(dir, {
1170
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1171
+ });
1172
+ listRoutes(store, port, tls2);
1173
+ }
1174
+ async function handleAlias(args) {
1175
+ if (args[1] === "--help" || args[1] === "-h") {
1176
+ console.log(`
1177
+ ${chalk.bold("portless alias")} - Register a static route for services not managed by portless.
1178
+
1179
+ ${chalk.bold("Usage:")}
1180
+ ${chalk.cyan("portless alias <name> <port>")} Register a route
1181
+ ${chalk.cyan("portless alias --remove <name>")} Remove a route
1182
+ ${chalk.cyan("portless alias <name> <port> --force")} Override existing route
1183
+
1184
+ ${chalk.bold("Examples:")}
1185
+ portless alias my-postgres 5432 # -> http://my-postgres.localhost:1355
1186
+ portless alias redis 6379 # -> http://redis.localhost:1355
1187
+ portless alias --remove my-postgres # Remove the alias
1014
1188
  `);
1015
1189
  process.exit(0);
1016
1190
  }
1017
- if (args[0] === "--version" || args[0] === "-v") {
1018
- console.log("0.4.1");
1191
+ const { dir } = await discoverState();
1192
+ const store = new RouteStore(dir, {
1193
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1194
+ });
1195
+ if (args[1] === "--remove") {
1196
+ const aliasName2 = args[2];
1197
+ if (!aliasName2) {
1198
+ console.error(chalk.red("Error: No alias name provided."));
1199
+ console.error(chalk.cyan(" portless alias --remove <name>"));
1200
+ process.exit(1);
1201
+ }
1202
+ const hostname2 = parseHostname(aliasName2);
1203
+ const routes = store.loadRoutes();
1204
+ const existing = routes.find((r) => r.hostname === hostname2 && r.pid === 0);
1205
+ if (!existing) {
1206
+ console.error(chalk.red(`Error: No alias found for "${hostname2}".`));
1207
+ process.exit(1);
1208
+ }
1209
+ store.removeRoute(hostname2);
1210
+ console.log(chalk.green(`Removed alias: ${hostname2}`));
1211
+ return;
1212
+ }
1213
+ const aliasName = args[1];
1214
+ const aliasPort = args[2];
1215
+ if (!aliasName || !aliasPort) {
1216
+ console.error(chalk.red("Error: Missing arguments."));
1217
+ console.error(chalk.blue("Usage:"));
1218
+ console.error(chalk.cyan(" portless alias <name> <port>"));
1219
+ console.error(chalk.cyan(" portless alias --remove <name>"));
1220
+ console.error(chalk.blue("Example:"));
1221
+ console.error(chalk.cyan(" portless alias my-postgres 5432"));
1222
+ process.exit(1);
1223
+ }
1224
+ const hostname = parseHostname(aliasName);
1225
+ const port = parseInt(aliasPort, 10);
1226
+ if (isNaN(port) || port < 1 || port > 65535) {
1227
+ console.error(chalk.red(`Error: Invalid port "${aliasPort}". Must be 1-65535.`));
1228
+ process.exit(1);
1229
+ }
1230
+ const force = args.includes("--force");
1231
+ store.addRoute(hostname, port, 0, force);
1232
+ console.log(chalk.green(`Alias registered: ${hostname} -> localhost:${port}`));
1233
+ }
1234
+ async function handleHosts(args) {
1235
+ if (args[1] === "--help" || args[1] === "-h") {
1236
+ console.log(`
1237
+ ${chalk.bold("portless hosts")} - Manage /etc/hosts entries for .localhost subdomains.
1238
+
1239
+ Safari relies on the system DNS resolver, which may not handle .localhost
1240
+ subdomains. This command adds entries to /etc/hosts as a workaround.
1241
+
1242
+ ${chalk.bold("Usage:")}
1243
+ ${chalk.cyan("sudo portless hosts sync")} Add current routes to /etc/hosts
1244
+ ${chalk.cyan("sudo portless hosts clean")} Remove portless entries from /etc/hosts
1245
+
1246
+ ${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.
1249
+ `);
1019
1250
  process.exit(0);
1020
1251
  }
1021
- if (args[0] === "trust") {
1022
- const { dir: dir2 } = await discoverState();
1023
- const result = trustCA(dir2);
1024
- if (result.trusted) {
1025
- console.log(chalk.green("Local CA added to system trust store."));
1026
- console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
1252
+ if (args[1] === "clean") {
1253
+ if (cleanHostsFile()) {
1254
+ console.log(chalk.green("Removed portless entries from /etc/hosts."));
1027
1255
  } else {
1028
- console.error(chalk.red(`Failed to trust CA: ${result.error}`));
1029
- if (result.error?.includes("sudo")) {
1030
- console.error(chalk.blue("Run with sudo:"));
1031
- console.error(chalk.cyan(" sudo portless trust"));
1032
- }
1256
+ console.error(chalk.red("Failed to update /etc/hosts (requires sudo)."));
1257
+ console.error(chalk.cyan(" sudo portless hosts clean"));
1033
1258
  process.exit(1);
1034
1259
  }
1035
1260
  return;
1036
1261
  }
1037
- if (args[0] === "list") {
1038
- const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
1039
- const store2 = new RouteStore(dir2, {
1262
+ if (!args[1]) {
1263
+ console.log(`
1264
+ ${chalk.bold("Usage: portless hosts <command>")}
1265
+
1266
+ ${chalk.cyan("sudo portless hosts sync")} Add current routes to /etc/hosts
1267
+ ${chalk.cyan("sudo portless hosts clean")} Remove portless entries from /etc/hosts
1268
+ `);
1269
+ process.exit(0);
1270
+ }
1271
+ if (args[1] !== "sync") {
1272
+ console.error(chalk.red(`Error: Unknown hosts subcommand "${args[1]}".`));
1273
+ console.error(chalk.blue("Usage:"));
1274
+ console.error(chalk.cyan(" portless hosts sync # Add routes to /etc/hosts"));
1275
+ console.error(chalk.cyan(" portless hosts clean # Remove portless entries"));
1276
+ process.exit(1);
1277
+ }
1278
+ const { dir } = await discoverState();
1279
+ const store = new RouteStore(dir, {
1280
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1281
+ });
1282
+ const routes = store.loadRoutes();
1283
+ if (routes.length === 0) {
1284
+ console.log(chalk.yellow("No active routes to sync."));
1285
+ return;
1286
+ }
1287
+ const hostnames = routes.map((r) => r.hostname);
1288
+ if (syncHostsFile(hostnames)) {
1289
+ console.log(chalk.green(`Synced ${hostnames.length} hostname(s) to /etc/hosts:`));
1290
+ for (const h of hostnames) {
1291
+ console.log(chalk.cyan(` 127.0.0.1 ${h}`));
1292
+ }
1293
+ } else {
1294
+ console.error(chalk.red("Failed to update /etc/hosts (requires sudo)."));
1295
+ console.error(chalk.cyan(" sudo portless hosts sync"));
1296
+ process.exit(1);
1297
+ }
1298
+ }
1299
+ async function handleProxy(args) {
1300
+ if (args[1] === "stop") {
1301
+ const { dir, port, tls: tls2 } = await discoverState();
1302
+ const store2 = new RouteStore(dir, {
1040
1303
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1041
1304
  });
1042
- listRoutes(store2, port2, tls3);
1305
+ await stopProxy(store2, port, tls2);
1043
1306
  return;
1044
1307
  }
1045
- if (args[0] === "proxy") {
1046
- if (args[1] === "stop") {
1047
- const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
1048
- const store3 = new RouteStore(dir2, {
1049
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1050
- });
1051
- await stopProxy(store3, port2, tls3);
1052
- return;
1053
- }
1054
- if (args[1] !== "start") {
1055
- console.log(`
1056
- ${chalk.bold("Usage: portless proxy <command>")}
1308
+ const isProxyHelp = args[1] === "--help" || args[1] === "-h";
1309
+ if (isProxyHelp || args[1] !== "start") {
1310
+ console.log(`
1311
+ ${chalk.bold("portless proxy")} - Manage the portless proxy server.
1057
1312
 
1313
+ ${chalk.bold("Usage:")}
1058
1314
  ${chalk.cyan("portless proxy start")} Start the proxy (daemon)
1059
1315
  ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
1060
1316
  ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
1061
1317
  ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
1062
1318
  ${chalk.cyan("portless proxy stop")} Stop the proxy
1063
1319
  `);
1064
- process.exit(args[1] ? 1 : 0);
1065
- }
1066
- const isForeground = args.includes("--foreground");
1067
- let proxyPort = getDefaultPort();
1068
- let portFlagIndex = args.indexOf("--port");
1069
- if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
1070
- if (portFlagIndex !== -1) {
1071
- const portValue = args[portFlagIndex + 1];
1072
- if (!portValue || portValue.startsWith("-")) {
1073
- console.error(chalk.red("Error: --port / -p requires a port number."));
1074
- console.error(chalk.blue("Usage:"));
1075
- console.error(chalk.cyan(" portless proxy start -p 8080"));
1076
- process.exit(1);
1077
- }
1078
- proxyPort = parseInt(portValue, 10);
1079
- if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
1080
- console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
1081
- console.error(chalk.blue("Port must be between 1 and 65535."));
1082
- process.exit(1);
1083
- }
1084
- }
1085
- const hasNoTls = args.includes("--no-tls");
1086
- const hasHttpsFlag = args.includes("--https");
1087
- const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
1088
- let customCertPath = null;
1089
- let customKeyPath = null;
1090
- const certIdx = args.indexOf("--cert");
1091
- if (certIdx !== -1) {
1092
- customCertPath = args[certIdx + 1] || null;
1093
- if (!customCertPath || customCertPath.startsWith("-")) {
1094
- console.error(chalk.red("Error: --cert requires a file path."));
1095
- process.exit(1);
1096
- }
1097
- }
1098
- const keyIdx = args.indexOf("--key");
1099
- if (keyIdx !== -1) {
1100
- customKeyPath = args[keyIdx + 1] || null;
1101
- if (!customKeyPath || customKeyPath.startsWith("-")) {
1102
- console.error(chalk.red("Error: --key requires a file path."));
1103
- process.exit(1);
1104
- }
1105
- }
1106
- if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
1107
- console.error(chalk.red("Error: --cert and --key must be used together."));
1320
+ process.exit(isProxyHelp || !args[1] ? 0 : 1);
1321
+ }
1322
+ const isForeground = args.includes("--foreground");
1323
+ let proxyPort = getDefaultPort();
1324
+ let portFlagIndex = args.indexOf("--port");
1325
+ if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
1326
+ if (portFlagIndex !== -1) {
1327
+ const portValue = args[portFlagIndex + 1];
1328
+ if (!portValue || portValue.startsWith("-")) {
1329
+ console.error(chalk.red("Error: --port / -p requires a port number."));
1330
+ console.error(chalk.blue("Usage:"));
1331
+ console.error(chalk.cyan(" portless proxy start -p 8080"));
1108
1332
  process.exit(1);
1109
1333
  }
1110
- const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1111
- const stateDir = resolveStateDir(proxyPort);
1112
- const store2 = new RouteStore(stateDir, {
1113
- onWarning: (msg) => console.warn(chalk.yellow(msg))
1114
- });
1115
- if (await isProxyRunning(proxyPort)) {
1116
- if (isForeground) {
1117
- return;
1118
- }
1119
- const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1120
- const sudoPrefix = needsSudo ? "sudo " : "";
1121
- console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
1122
- console.log(
1123
- chalk.blue(`To restart: portless proxy stop && ${sudoPrefix}portless proxy start`)
1124
- );
1125
- return;
1334
+ proxyPort = parseInt(portValue, 10);
1335
+ if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
1336
+ console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
1337
+ console.error(chalk.blue("Port must be between 1 and 65535."));
1338
+ process.exit(1);
1126
1339
  }
1127
- if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1128
- console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
1129
- console.error(chalk.blue("Either run with sudo:"));
1130
- console.error(chalk.cyan(" sudo portless proxy start -p 80"));
1131
- console.error(chalk.blue("Or use the default port (no sudo needed):"));
1132
- console.error(chalk.cyan(" portless proxy start"));
1340
+ }
1341
+ const hasNoTls = args.includes("--no-tls");
1342
+ const hasHttpsFlag = args.includes("--https");
1343
+ const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
1344
+ let customCertPath = null;
1345
+ let customKeyPath = null;
1346
+ const certIdx = args.indexOf("--cert");
1347
+ if (certIdx !== -1) {
1348
+ customCertPath = args[certIdx + 1] || null;
1349
+ if (!customCertPath || customCertPath.startsWith("-")) {
1350
+ console.error(chalk.red("Error: --cert requires a file path."));
1133
1351
  process.exit(1);
1134
1352
  }
1135
- let tlsOptions;
1136
- if (useHttps) {
1137
- store2.ensureDir();
1138
- if (customCertPath && customKeyPath) {
1139
- try {
1140
- const cert = fs3.readFileSync(customCertPath);
1141
- const key = fs3.readFileSync(customKeyPath);
1142
- const certStr = cert.toString("utf-8");
1143
- const keyStr = key.toString("utf-8");
1144
- if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
1145
- console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
1146
- console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
1147
- process.exit(1);
1148
- }
1149
- if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
1150
- console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
1151
- console.error(
1152
- chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----")
1153
- );
1154
- process.exit(1);
1155
- }
1156
- tlsOptions = { cert, key };
1157
- } catch (err) {
1158
- const message = err instanceof Error ? err.message : String(err);
1159
- console.error(chalk.red(`Error reading certificate files: ${message}`));
1160
- process.exit(1);
1161
- }
1162
- } else {
1163
- console.log(chalk.gray("Ensuring TLS certificates..."));
1164
- const certs = ensureCerts(stateDir);
1165
- if (certs.caGenerated) {
1166
- console.log(chalk.green("Generated local CA certificate."));
1167
- }
1168
- if (!isCATrusted(stateDir)) {
1169
- console.log(chalk.yellow("Adding CA to system trust store..."));
1170
- const trustResult = trustCA(stateDir);
1171
- if (trustResult.trusted) {
1172
- console.log(
1173
- chalk.green("CA added to system trust store. Browsers will trust portless certs.")
1174
- );
1175
- } else {
1176
- console.warn(chalk.yellow("Could not add CA to system trust store."));
1177
- if (trustResult.error) {
1178
- console.warn(chalk.gray(trustResult.error));
1179
- }
1180
- console.warn(
1181
- chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
1182
- );
1183
- console.warn(chalk.cyan(" portless trust"));
1184
- }
1185
- }
1186
- const cert = fs3.readFileSync(certs.certPath);
1187
- const key = fs3.readFileSync(certs.keyPath);
1188
- tlsOptions = {
1189
- cert,
1190
- key,
1191
- SNICallback: createSNICallback(stateDir, cert, key)
1192
- };
1193
- }
1353
+ }
1354
+ const keyIdx = args.indexOf("--key");
1355
+ if (keyIdx !== -1) {
1356
+ customKeyPath = args[keyIdx + 1] || null;
1357
+ if (!customKeyPath || customKeyPath.startsWith("-")) {
1358
+ console.error(chalk.red("Error: --key requires a file path."));
1359
+ process.exit(1);
1194
1360
  }
1361
+ }
1362
+ if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
1363
+ console.error(chalk.red("Error: --cert and --key must be used together."));
1364
+ process.exit(1);
1365
+ }
1366
+ const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1367
+ const stateDir = resolveStateDir(proxyPort);
1368
+ const store = new RouteStore(stateDir, {
1369
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1370
+ });
1371
+ if (await isProxyRunning(proxyPort)) {
1195
1372
  if (isForeground) {
1196
- console.log(chalk.blue.bold("\nportless proxy\n"));
1197
- startProxyServer(store2, proxyPort, tlsOptions);
1198
1373
  return;
1199
1374
  }
1200
- store2.ensureDir();
1201
- const logPath = path3.join(stateDir, "proxy.log");
1202
- const logFd = fs3.openSync(logPath, "a");
1203
- try {
1375
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1376
+ const sudoPrefix = needsSudo ? "sudo " : "";
1377
+ 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`));
1379
+ return;
1380
+ }
1381
+ if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1382
+ console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
1383
+ console.error(chalk.blue("Either run with sudo:"));
1384
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
1385
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
1386
+ console.error(chalk.cyan(" portless proxy start"));
1387
+ process.exit(1);
1388
+ }
1389
+ let tlsOptions;
1390
+ if (useHttps) {
1391
+ store.ensureDir();
1392
+ if (customCertPath && customKeyPath) {
1204
1393
  try {
1205
- fs3.chmodSync(logPath, FILE_MODE);
1206
- } catch {
1394
+ const cert = fs3.readFileSync(customCertPath);
1395
+ const key = fs3.readFileSync(customKeyPath);
1396
+ const certStr = cert.toString("utf-8");
1397
+ const keyStr = key.toString("utf-8");
1398
+ if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
1399
+ console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
1400
+ console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
1401
+ process.exit(1);
1402
+ }
1403
+ if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
1404
+ console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
1405
+ console.error(chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----"));
1406
+ process.exit(1);
1407
+ }
1408
+ tlsOptions = { cert, key };
1409
+ } catch (err) {
1410
+ const message = err instanceof Error ? err.message : String(err);
1411
+ console.error(chalk.red(`Error reading certificate files: ${message}`));
1412
+ process.exit(1);
1207
1413
  }
1208
- const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
1209
- if (portFlagIndex !== -1) {
1210
- daemonArgs.push("--port", proxyPort.toString());
1414
+ } else {
1415
+ console.log(chalk.gray("Ensuring TLS certificates..."));
1416
+ const certs = ensureCerts(stateDir);
1417
+ if (certs.caGenerated) {
1418
+ console.log(chalk.green("Generated local CA certificate."));
1211
1419
  }
1212
- if (useHttps) {
1213
- if (customCertPath && customKeyPath) {
1214
- daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
1420
+ if (!isCATrusted(stateDir)) {
1421
+ console.log(chalk.yellow("Adding CA to system trust store..."));
1422
+ const trustResult = trustCA(stateDir);
1423
+ if (trustResult.trusted) {
1424
+ console.log(
1425
+ chalk.green("CA added to system trust store. Browsers will trust portless certs.")
1426
+ );
1215
1427
  } else {
1216
- daemonArgs.push("--https");
1428
+ console.warn(chalk.yellow("Could not add CA to system trust store."));
1429
+ if (trustResult.error) {
1430
+ console.warn(chalk.gray(trustResult.error));
1431
+ }
1432
+ console.warn(
1433
+ chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
1434
+ );
1435
+ console.warn(chalk.cyan(" portless trust"));
1217
1436
  }
1218
1437
  }
1219
- const child = spawn2(process.execPath, daemonArgs, {
1220
- detached: true,
1221
- stdio: ["ignore", logFd, logFd],
1222
- env: process.env
1223
- });
1224
- child.unref();
1225
- } finally {
1226
- fs3.closeSync(logFd);
1438
+ const cert = fs3.readFileSync(certs.certPath);
1439
+ const key = fs3.readFileSync(certs.keyPath);
1440
+ tlsOptions = {
1441
+ cert,
1442
+ key,
1443
+ SNICallback: createSNICallback(stateDir, cert, key)
1444
+ };
1227
1445
  }
1228
- if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
1229
- console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1230
- console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
1231
- const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1232
- console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
1233
- if (fs3.existsSync(logPath)) {
1234
- console.error(chalk.gray(`Logs: ${logPath}`));
1446
+ }
1447
+ if (isForeground) {
1448
+ console.log(chalk.blue.bold("\nportless proxy\n"));
1449
+ startProxyServer(store, proxyPort, tlsOptions);
1450
+ return;
1451
+ }
1452
+ store.ensureDir();
1453
+ const logPath = path3.join(stateDir, "proxy.log");
1454
+ const logFd = fs3.openSync(logPath, "a");
1455
+ try {
1456
+ try {
1457
+ fs3.chmodSync(logPath, FILE_MODE);
1458
+ } catch {
1459
+ }
1460
+ fixOwnership(logPath);
1461
+ const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
1462
+ if (portFlagIndex !== -1) {
1463
+ daemonArgs.push("--port", proxyPort.toString());
1464
+ }
1465
+ if (useHttps) {
1466
+ if (customCertPath && customKeyPath) {
1467
+ daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
1468
+ } else {
1469
+ daemonArgs.push("--https");
1235
1470
  }
1236
- process.exit(1);
1237
1471
  }
1238
- const proto = useHttps ? "HTTPS/2" : "HTTP";
1239
- console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
1240
- return;
1472
+ const child = spawn(process.execPath, daemonArgs, {
1473
+ detached: true,
1474
+ stdio: ["ignore", logFd, logFd],
1475
+ env: process.env
1476
+ });
1477
+ child.unref();
1478
+ } finally {
1479
+ fs3.closeSync(logFd);
1480
+ }
1481
+ if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
1482
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1483
+ console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
1484
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1485
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
1486
+ if (fs3.existsSync(logPath)) {
1487
+ console.error(chalk.gray(`Logs: ${logPath}`));
1488
+ }
1489
+ process.exit(1);
1490
+ }
1491
+ const proto = useHttps ? "HTTPS/2" : "HTTP";
1492
+ console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
1493
+ }
1494
+ async function handleRunMode(args) {
1495
+ const parsed = parseRunArgs(args);
1496
+ if (parsed.commandArgs.length === 0) {
1497
+ console.error(chalk.red("Error: No command provided."));
1498
+ console.error(chalk.blue("Usage:"));
1499
+ console.error(chalk.cyan(" portless run <command...>"));
1500
+ console.error(chalk.blue("Example:"));
1501
+ console.error(chalk.cyan(" portless run next dev"));
1502
+ process.exit(1);
1241
1503
  }
1242
- const name = args[0];
1243
- const commandArgs = args.slice(1);
1244
- if (commandArgs.length === 0) {
1504
+ const inferred = inferProjectName();
1505
+ const worktree = detectWorktreePrefix();
1506
+ const effectiveName = worktree ? `${worktree.prefix}.${inferred.name}` : inferred.name;
1507
+ const { dir, port, tls: tls2 } = await discoverState();
1508
+ const store = new RouteStore(dir, {
1509
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
1510
+ });
1511
+ await runApp(
1512
+ store,
1513
+ port,
1514
+ dir,
1515
+ effectiveName,
1516
+ parsed.commandArgs,
1517
+ tls2,
1518
+ parsed.force,
1519
+ { nameSource: inferred.source, prefix: worktree?.prefix, prefixSource: worktree?.source },
1520
+ parsed.appPort
1521
+ );
1522
+ }
1523
+ async function handleNamedMode(args) {
1524
+ const parsed = parseAppArgs(args);
1525
+ if (parsed.commandArgs.length === 0) {
1245
1526
  console.error(chalk.red("Error: No command provided."));
1246
1527
  console.error(chalk.blue("Usage:"));
1247
1528
  console.error(chalk.cyan(" portless <name> <command...>"));
@@ -1253,7 +1534,105 @@ ${chalk.bold("Usage: portless proxy <command>")}
1253
1534
  const store = new RouteStore(dir, {
1254
1535
  onWarning: (msg) => console.warn(chalk.yellow(msg))
1255
1536
  });
1256
- await runApp(store, port, dir, name, commandArgs, tls2);
1537
+ await runApp(
1538
+ store,
1539
+ port,
1540
+ dir,
1541
+ parsed.name,
1542
+ parsed.commandArgs,
1543
+ tls2,
1544
+ parsed.force,
1545
+ void 0,
1546
+ parsed.appPort
1547
+ );
1548
+ }
1549
+ async function main() {
1550
+ if (process.stdin.isTTY) {
1551
+ process.on("exit", () => {
1552
+ try {
1553
+ process.stdin.setRawMode(false);
1554
+ } catch {
1555
+ }
1556
+ });
1557
+ }
1558
+ const args = process.argv.slice(2);
1559
+ const isNpx = process.env.npm_command === "exec" && !process.env.npm_lifecycle_event;
1560
+ const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
1561
+ if (isNpx || isPnpmDlx) {
1562
+ console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
1563
+ console.error(chalk.blue("Install globally instead:"));
1564
+ console.error(chalk.cyan(" npm install -g portless"));
1565
+ process.exit(1);
1566
+ }
1567
+ if (args[0] === "--name") {
1568
+ args.shift();
1569
+ if (!args[0]) {
1570
+ console.error(chalk.red("Error: --name requires an app name."));
1571
+ console.error(chalk.cyan(" portless --name <name> <command...>"));
1572
+ process.exit(1);
1573
+ }
1574
+ const skipPortless2 = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
1575
+ if (skipPortless2) {
1576
+ const { commandArgs } = parseAppArgs(args);
1577
+ if (commandArgs.length === 0) {
1578
+ console.error(chalk.red("Error: No command provided."));
1579
+ process.exit(1);
1580
+ }
1581
+ spawnCommand(commandArgs);
1582
+ return;
1583
+ }
1584
+ await handleNamedMode(args);
1585
+ return;
1586
+ }
1587
+ const isRunCommand = args[0] === "run";
1588
+ if (isRunCommand) {
1589
+ args.shift();
1590
+ }
1591
+ const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
1592
+ if (skipPortless && (isRunCommand || args.length >= 2 && args[0] !== "proxy")) {
1593
+ const { commandArgs } = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
1594
+ if (commandArgs.length === 0) {
1595
+ console.error(chalk.red("Error: No command provided."));
1596
+ process.exit(1);
1597
+ }
1598
+ spawnCommand(commandArgs);
1599
+ return;
1600
+ }
1601
+ if (!isRunCommand) {
1602
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
1603
+ printHelp();
1604
+ return;
1605
+ }
1606
+ if (args[0] === "--version" || args[0] === "-v") {
1607
+ printVersion();
1608
+ return;
1609
+ }
1610
+ if (args[0] === "trust") {
1611
+ await handleTrust();
1612
+ return;
1613
+ }
1614
+ if (args[0] === "list") {
1615
+ await handleList();
1616
+ return;
1617
+ }
1618
+ if (args[0] === "alias") {
1619
+ await handleAlias(args);
1620
+ return;
1621
+ }
1622
+ if (args[0] === "hosts") {
1623
+ await handleHosts(args);
1624
+ return;
1625
+ }
1626
+ if (args[0] === "proxy") {
1627
+ await handleProxy(args);
1628
+ return;
1629
+ }
1630
+ }
1631
+ if (isRunCommand) {
1632
+ await handleRunMode(args);
1633
+ } else {
1634
+ await handleNamedMode(args);
1635
+ }
1257
1636
  }
1258
1637
  main().catch((err) => {
1259
1638
  const message = err instanceof Error ? err.message : String(err);