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/chunk-P3DHZHEZ.js +1091 -0
- package/dist/cli.js +839 -460
- package/dist/index.d.ts +60 -3
- package/dist/index.js +25 -3
- package/package.json +13 -12
- package/LICENSE +0 -201
- package/dist/chunk-VRBD6YAY.js +0 -412
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
FILE_MODE,
|
|
4
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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(
|
|
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/
|
|
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
|
-
|
|
400
|
-
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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.
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
|
|
525
|
+
return null;
|
|
487
526
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
539
|
+
function detectWorktreeViaCli(cwd) {
|
|
540
540
|
try {
|
|
541
|
-
const
|
|
541
|
+
const listOutput = execFileSync2("git", ["worktree", "list", "--porcelain"], {
|
|
542
|
+
cwd,
|
|
542
543
|
encoding: "utf-8",
|
|
543
|
-
timeout:
|
|
544
|
+
timeout: 5e3,
|
|
545
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
544
546
|
});
|
|
545
|
-
const
|
|
546
|
-
|
|
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
|
|
559
|
+
return void 0;
|
|
549
560
|
}
|
|
550
561
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
588
|
+
return null;
|
|
559
589
|
}
|
|
560
|
-
function
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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(
|
|
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
|
-
|
|
911
|
-
|
|
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(
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
945
|
-
if (
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
950
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
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[
|
|
1022
|
-
|
|
1023
|
-
|
|
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(
|
|
1029
|
-
|
|
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[
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
1305
|
+
await stopProxy(store2, port, tls2);
|
|
1043
1306
|
return;
|
|
1044
1307
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
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.
|
|
1206
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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 (
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
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
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
|
1243
|
-
const
|
|
1244
|
-
|
|
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(
|
|
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);
|