@hamedb89/localghost 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/dist/cli.js +495 -126
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +80 -2
- package/dist/index.js +318 -36
- package/dist/index.js.map +1 -1
- package/dist/vite.d.ts +1 -0
- package/dist/vite.js +218 -19
- package/dist/vite.js.map +1 -1
- package/docs/flows.md +3 -1
- package/docs/ghost-tunnel.md +4 -1
- package/docs/localghost.1.md +29 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -250,7 +250,12 @@ function getProjectName(cwd = process.cwd()) {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
function sanitizeProjectName(value) {
|
|
253
|
-
const
|
|
253
|
+
const sanitized = value.replace(/[^\w.-]+/g, "-");
|
|
254
|
+
let start = 0;
|
|
255
|
+
let end = sanitized.length;
|
|
256
|
+
while (start < end && sanitized.charCodeAt(start) === 45) start += 1;
|
|
257
|
+
while (end > start && sanitized.charCodeAt(end - 1) === 45) end -= 1;
|
|
258
|
+
const projectName = sanitized.slice(start, end);
|
|
254
259
|
return projectName || "app";
|
|
255
260
|
}
|
|
256
261
|
|
|
@@ -334,6 +339,26 @@ function startCaddy(path) {
|
|
|
334
339
|
stdio: caddyStdio()
|
|
335
340
|
});
|
|
336
341
|
}
|
|
342
|
+
function stopCaddyProcesses(pids, killProcess = (pid, signal) => process.kill(pid, signal)) {
|
|
343
|
+
const result = {
|
|
344
|
+
stopped: [],
|
|
345
|
+
alreadyExited: [],
|
|
346
|
+
failed: []
|
|
347
|
+
};
|
|
348
|
+
for (const pid of new Set(pids)) {
|
|
349
|
+
try {
|
|
350
|
+
killProcess(pid, "SIGINT");
|
|
351
|
+
result.stopped.push(pid);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
354
|
+
result.alreadyExited.push(pid);
|
|
355
|
+
} else {
|
|
356
|
+
result.failed.push({ pid, error });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
337
362
|
async function trustCaddy(path) {
|
|
338
363
|
await execa("caddy", ["trust", "--config", path], {
|
|
339
364
|
cwd: dirname3(path),
|
|
@@ -454,19 +479,19 @@ function formatDetectedDevServices(services) {
|
|
|
454
479
|
|
|
455
480
|
// src/context.ts
|
|
456
481
|
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
457
|
-
import { join as
|
|
482
|
+
import { join as join6 } from "path";
|
|
458
483
|
import { pathToFileURL } from "url";
|
|
459
484
|
|
|
460
485
|
// src/port.ts
|
|
461
486
|
import { createServer } from "net";
|
|
462
487
|
async function isPortAvailable(port, host = "127.0.0.1") {
|
|
463
|
-
return new Promise((
|
|
488
|
+
return new Promise((resolve4) => {
|
|
464
489
|
const server = createServer();
|
|
465
490
|
server.once("error", () => {
|
|
466
|
-
|
|
491
|
+
resolve4(false);
|
|
467
492
|
});
|
|
468
493
|
server.once("listening", () => {
|
|
469
|
-
server.close(() =>
|
|
494
|
+
server.close(() => resolve4(true));
|
|
470
495
|
});
|
|
471
496
|
server.listen(port, host);
|
|
472
497
|
});
|
|
@@ -483,6 +508,176 @@ async function findAvailablePort(startPort, options = {}) {
|
|
|
483
508
|
throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
|
|
484
509
|
}
|
|
485
510
|
|
|
511
|
+
// src/registry.ts
|
|
512
|
+
import { randomUUID } from "crypto";
|
|
513
|
+
import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
|
|
514
|
+
import { homedir as homedir2 } from "os";
|
|
515
|
+
import { join as join5, normalize, resolve as resolve3 } from "path";
|
|
516
|
+
var LOCALGHOST_REGISTRY_FILE = "registry.json";
|
|
517
|
+
var LOCALGHOST_REGISTRY_LOCK_FILE = "registry.lock";
|
|
518
|
+
function defaultProcessRunning(pid) {
|
|
519
|
+
if (pid <= 0) return false;
|
|
520
|
+
try {
|
|
521
|
+
process.kill(pid, 0);
|
|
522
|
+
return true;
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return error.code === "EPERM";
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function getLocalghostRegistryRoot(env = process.env) {
|
|
528
|
+
return resolve3(env.LOCALGHOST_HOME || join5(homedir2(), ".localghost"));
|
|
529
|
+
}
|
|
530
|
+
function canonicalizeLocalghostProjectCwd(cwd = process.cwd()) {
|
|
531
|
+
return normalize(resolve3(cwd));
|
|
532
|
+
}
|
|
533
|
+
function emptyRegistry() {
|
|
534
|
+
return { version: 1, allocations: [], leases: [] };
|
|
535
|
+
}
|
|
536
|
+
function leaseKey(projectCwd, instanceKey) {
|
|
537
|
+
return `${projectCwd}\0${instanceKey}`;
|
|
538
|
+
}
|
|
539
|
+
function validRegistry(value) {
|
|
540
|
+
if (!value || typeof value !== "object") return false;
|
|
541
|
+
const candidate = value;
|
|
542
|
+
return candidate.version === 1 && Array.isArray(candidate.allocations) && Array.isArray(candidate.leases);
|
|
543
|
+
}
|
|
544
|
+
function pruneRegistry(registry, now, isRunning) {
|
|
545
|
+
registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
|
|
546
|
+
}
|
|
547
|
+
async function readJson(path) {
|
|
548
|
+
try {
|
|
549
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (error.code === "ENOENT") return void 0;
|
|
552
|
+
return void 0;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function createLocalghostRegistry(options = {}) {
|
|
556
|
+
const root = resolve3(options.stateRoot ?? getLocalghostRegistryRoot());
|
|
557
|
+
const registryPath = join5(root, LOCALGHOST_REGISTRY_FILE);
|
|
558
|
+
const lockPath = join5(root, LOCALGHOST_REGISTRY_LOCK_FILE);
|
|
559
|
+
const cwd = canonicalizeLocalghostProjectCwd(options.cwd);
|
|
560
|
+
const now = options.now ?? Date.now;
|
|
561
|
+
const pid = options.pid ?? process.pid;
|
|
562
|
+
const ownerToken = options.ownerToken ?? randomUUID();
|
|
563
|
+
const isRunning = options.isProcessRunning ?? defaultProcessRunning;
|
|
564
|
+
const availabilityCheck = options.availabilityCheck ?? isPortAvailable;
|
|
565
|
+
const lockTimeoutMs = options.lockTimeoutMs ?? 5e3;
|
|
566
|
+
const lockRetryMs = options.lockRetryMs ?? 25;
|
|
567
|
+
const lockStaleMs = options.lockStaleMs ?? 3e4;
|
|
568
|
+
async function readRegistry() {
|
|
569
|
+
const value = await readJson(registryPath);
|
|
570
|
+
return validRegistry(value) ? value : emptyRegistry();
|
|
571
|
+
}
|
|
572
|
+
async function writeRegistry(registry) {
|
|
573
|
+
await mkdir(root, { recursive: true });
|
|
574
|
+
const temporaryPath = join5(root, `.registry.${process.pid}.${randomUUID()}.tmp`);
|
|
575
|
+
await writeFile(temporaryPath, `${JSON.stringify(registry, null, 2)}
|
|
576
|
+
`, { mode: 384 });
|
|
577
|
+
await rename(temporaryPath, registryPath);
|
|
578
|
+
}
|
|
579
|
+
async function lock() {
|
|
580
|
+
await mkdir(root, { recursive: true });
|
|
581
|
+
const deadline = now() + lockTimeoutMs;
|
|
582
|
+
const token = randomUUID();
|
|
583
|
+
while (true) {
|
|
584
|
+
try {
|
|
585
|
+
const handle = await open(lockPath, "wx", 384);
|
|
586
|
+
await handle.writeFile(`${JSON.stringify({ pid, createdAt: now(), token })}
|
|
587
|
+
`);
|
|
588
|
+
await handle.close();
|
|
589
|
+
return async () => {
|
|
590
|
+
const current = await readJson(lockPath);
|
|
591
|
+
if (current?.token === token) await unlink(lockPath).catch(() => void 0);
|
|
592
|
+
};
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (error.code !== "EEXIST") throw error;
|
|
595
|
+
const lockInfo = await readJson(lockPath);
|
|
596
|
+
let stale = false;
|
|
597
|
+
if (lockInfo && typeof lockInfo.pid === "number") {
|
|
598
|
+
stale = !isRunning(lockInfo.pid) && now() - lockInfo.createdAt >= 0;
|
|
599
|
+
} else {
|
|
600
|
+
try {
|
|
601
|
+
stale = now() - (await stat(lockPath)).mtimeMs > lockStaleMs;
|
|
602
|
+
} catch {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (stale) {
|
|
607
|
+
await rm(lockPath, { force: true }).catch(() => void 0);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (now() >= deadline) throw new Error(`Timed out waiting for Localghost registry lock: ${lockPath}`);
|
|
611
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, lockRetryMs));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async function withLock(operation) {
|
|
616
|
+
const releaseLock = await lock();
|
|
617
|
+
try {
|
|
618
|
+
const registry = await readRegistry();
|
|
619
|
+
pruneRegistry(registry, now(), isRunning);
|
|
620
|
+
return await operation(registry);
|
|
621
|
+
} finally {
|
|
622
|
+
await releaseLock();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
root,
|
|
627
|
+
registryPath,
|
|
628
|
+
lockPath,
|
|
629
|
+
ownerToken,
|
|
630
|
+
read: readRegistry,
|
|
631
|
+
async acquirePort(acquireOptions) {
|
|
632
|
+
const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
|
|
633
|
+
if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
|
|
634
|
+
return withLock(async (registry) => {
|
|
635
|
+
const key = leaseKey(projectCwd, acquireOptions.instanceKey);
|
|
636
|
+
const existing = registry.allocations.find((entry2) => leaseKey(entry2.projectCwd, entry2.instanceKey) === key);
|
|
637
|
+
const reserved = new Set(acquireOptions.reservedPorts ?? []);
|
|
638
|
+
const activePorts = new Set(registry.leases.map((lease2) => lease2.port));
|
|
639
|
+
const port = existing?.port;
|
|
640
|
+
const ownsActiveLease = registry.leases.some((lease2) => lease2.port === port && leaseKey(lease2.projectCwd, lease2.instanceKey) === key && lease2.ownerToken === ownerToken);
|
|
641
|
+
const reusable = port !== void 0 && !reserved.has(port) && (!activePorts.has(port) || ownsActiveLease) && (ownsActiveLease || await availabilityCheck(port, acquireOptions.host));
|
|
642
|
+
let selectedPort = reusable ? port : void 0;
|
|
643
|
+
if (selectedPort === void 0) {
|
|
644
|
+
const startPort = acquireOptions.startPort ?? 3e3;
|
|
645
|
+
const maxAttempts = acquireOptions.maxAttempts ?? 50;
|
|
646
|
+
for (let offset = 0; offset < maxAttempts; offset += 1) {
|
|
647
|
+
const candidate = startPort + offset;
|
|
648
|
+
if (reserved.has(candidate) || activePorts.has(candidate)) continue;
|
|
649
|
+
if (await availabilityCheck(candidate, acquireOptions.host)) {
|
|
650
|
+
selectedPort = candidate;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (selectedPort === void 0) throw new Error(`No available registry port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
|
|
655
|
+
}
|
|
656
|
+
const timestamp = now();
|
|
657
|
+
const entry = existing ?? { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, updatedAt: timestamp };
|
|
658
|
+
entry.port = selectedPort;
|
|
659
|
+
entry.updatedAt = timestamp;
|
|
660
|
+
if (!existing) registry.allocations.push(entry);
|
|
661
|
+
registry.leases = registry.leases.filter((lease2) => leaseKey(lease2.projectCwd, lease2.instanceKey) !== key);
|
|
662
|
+
const lease = { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, pid, acquiredAt: timestamp, expiresAt: timestamp + (acquireOptions.leaseTtlMs ?? 30 * 60 * 1e3), ownerToken };
|
|
663
|
+
registry.leases.push(lease);
|
|
664
|
+
await writeRegistry(registry);
|
|
665
|
+
return lease;
|
|
666
|
+
});
|
|
667
|
+
},
|
|
668
|
+
async releasePort(releaseOptions) {
|
|
669
|
+
const projectCwd = canonicalizeLocalghostProjectCwd(releaseOptions.projectCwd ?? cwd);
|
|
670
|
+
return withLock(async (registry) => {
|
|
671
|
+
const key = leaseKey(projectCwd, releaseOptions.instanceKey);
|
|
672
|
+
const before = registry.leases.length;
|
|
673
|
+
registry.leases = registry.leases.filter((lease) => leaseKey(lease.projectCwd, lease.instanceKey) !== key || lease.ownerToken !== ownerToken);
|
|
674
|
+
if (registry.leases.length !== before) await writeRegistry(registry);
|
|
675
|
+
return registry.leases.length !== before;
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
486
681
|
// src/tunnel.ts
|
|
487
682
|
import { domainToASCII } from "url";
|
|
488
683
|
var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
|
|
@@ -860,7 +1055,7 @@ function envHttps() {
|
|
|
860
1055
|
}
|
|
861
1056
|
function getPackageName(cwd) {
|
|
862
1057
|
try {
|
|
863
|
-
const pkg = JSON.parse(readFileSync5(
|
|
1058
|
+
const pkg = JSON.parse(readFileSync5(join6(cwd, "package.json"), "utf8"));
|
|
864
1059
|
return typeof pkg.name === "string" ? pkg.name : void 0;
|
|
865
1060
|
} catch {
|
|
866
1061
|
return void 0;
|
|
@@ -943,7 +1138,24 @@ async function resolveLocalghostContext(options = {}) {
|
|
|
943
1138
|
const autoRepair = merged.autoRepair ?? true;
|
|
944
1139
|
const bindHost = merged.bindHost ?? "127.0.0.1";
|
|
945
1140
|
const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
|
|
946
|
-
|
|
1141
|
+
let port = requestedPort;
|
|
1142
|
+
let releasePort;
|
|
1143
|
+
const reservePort = merged.reservePort ?? false;
|
|
1144
|
+
const instanceKey = merged.instanceKey ?? "run";
|
|
1145
|
+
if (reservePort && dynamicPort) {
|
|
1146
|
+
const registry = createLocalghostRegistry({ cwd, ...merged.registryOwnerToken ? { ownerToken: merged.registryOwnerToken } : {} });
|
|
1147
|
+
const lease = await registry.acquirePort({
|
|
1148
|
+
projectCwd: cwd,
|
|
1149
|
+
instanceKey,
|
|
1150
|
+
startPort: requestedPort,
|
|
1151
|
+
host: probeHost,
|
|
1152
|
+
...options.reservedPorts ? { reservedPorts: options.reservedPorts } : {}
|
|
1153
|
+
});
|
|
1154
|
+
port = lease.port;
|
|
1155
|
+
releasePort = () => registry.releasePort({ projectCwd: cwd, instanceKey });
|
|
1156
|
+
} else if (dynamicPort) {
|
|
1157
|
+
port = await findAvailablePort(requestedPort, { host: probeHost });
|
|
1158
|
+
}
|
|
947
1159
|
const wwwAlias = merged.wwwAlias ?? true;
|
|
948
1160
|
const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
|
|
949
1161
|
const hosts = uniqueHosts(entries);
|
|
@@ -972,7 +1184,8 @@ async function resolveLocalghostContext(options = {}) {
|
|
|
972
1184
|
https: merged.https ?? envHttps() ?? false,
|
|
973
1185
|
wwwAlias,
|
|
974
1186
|
ghostTunnel,
|
|
975
|
-
...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
|
|
1187
|
+
...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
|
|
1188
|
+
...releasePort ? { releasePort } : {}
|
|
976
1189
|
};
|
|
977
1190
|
}
|
|
978
1191
|
|
|
@@ -1041,7 +1254,7 @@ function listGhostTunnelEntries(options = {}) {
|
|
|
1041
1254
|
}
|
|
1042
1255
|
|
|
1043
1256
|
// src/ghost-agent.ts
|
|
1044
|
-
import { randomUUID as
|
|
1257
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1045
1258
|
|
|
1046
1259
|
// src/relay.ts
|
|
1047
1260
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
@@ -1153,7 +1366,7 @@ function stripRelayForwardHeaders(headers) {
|
|
|
1153
1366
|
}
|
|
1154
1367
|
|
|
1155
1368
|
// src/ghost-tunnel-store.ts
|
|
1156
|
-
import { randomUUID } from "crypto";
|
|
1369
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1157
1370
|
function base64Encode(value) {
|
|
1158
1371
|
return value.toString("base64");
|
|
1159
1372
|
}
|
|
@@ -1191,13 +1404,18 @@ function parseJson(value) {
|
|
|
1191
1404
|
function keyPart(value) {
|
|
1192
1405
|
return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
|
|
1193
1406
|
}
|
|
1407
|
+
function removeTrailingSlashes(value) {
|
|
1408
|
+
let end = value.length;
|
|
1409
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
1410
|
+
return value.slice(0, end);
|
|
1411
|
+
}
|
|
1194
1412
|
var RedisGhostTunnelStore = class {
|
|
1195
1413
|
url;
|
|
1196
1414
|
token;
|
|
1197
1415
|
namespace;
|
|
1198
1416
|
fetchImpl;
|
|
1199
1417
|
constructor(options) {
|
|
1200
|
-
this.url = options.url
|
|
1418
|
+
this.url = removeTrailingSlashes(options.url);
|
|
1201
1419
|
this.token = options.token;
|
|
1202
1420
|
this.namespace = options.namespace ?? "localghost";
|
|
1203
1421
|
this.fetchImpl = options.fetch ?? fetch;
|
|
@@ -1285,11 +1503,11 @@ function isStopped(signal, localSignal) {
|
|
|
1285
1503
|
}
|
|
1286
1504
|
function wait(ms, signal, localSignal) {
|
|
1287
1505
|
if (isStopped(signal, localSignal)) return Promise.resolve();
|
|
1288
|
-
return new Promise((
|
|
1289
|
-
const timeout = setTimeout(
|
|
1506
|
+
return new Promise((resolve4) => {
|
|
1507
|
+
const timeout = setTimeout(resolve4, ms);
|
|
1290
1508
|
const stop = () => {
|
|
1291
1509
|
clearTimeout(timeout);
|
|
1292
|
-
|
|
1510
|
+
resolve4();
|
|
1293
1511
|
};
|
|
1294
1512
|
signal?.addEventListener("abort", stop, { once: true });
|
|
1295
1513
|
localSignal.addEventListener("abort", stop, { once: true });
|
|
@@ -1375,7 +1593,7 @@ function startGhostTunnelAgent(options) {
|
|
|
1375
1593
|
const controller = new AbortController();
|
|
1376
1594
|
const localSignal = controller.signal;
|
|
1377
1595
|
const signal = options.signal;
|
|
1378
|
-
const agentId = options.agentId ?? `localghost-${
|
|
1596
|
+
const agentId = options.agentId ?? `localghost-${randomUUID3()}`;
|
|
1379
1597
|
const targetHost = options.targetHost ?? "127.0.0.1";
|
|
1380
1598
|
const routeTtlSeconds = options.routeTtlSeconds ?? 30;
|
|
1381
1599
|
const requestTtlSeconds = options.requestTtlSeconds ?? 60;
|
|
@@ -1430,7 +1648,7 @@ function startGhostTunnelAgent(options) {
|
|
|
1430
1648
|
// src/hosts-file.ts
|
|
1431
1649
|
import { writeFileSync as writeFileSync3 } from "fs";
|
|
1432
1650
|
import { tmpdir } from "os";
|
|
1433
|
-
import { join as
|
|
1651
|
+
import { join as join7 } from "path";
|
|
1434
1652
|
import { execa as execa3 } from "execa";
|
|
1435
1653
|
function escapeRegExp(value) {
|
|
1436
1654
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -1473,7 +1691,7 @@ function removeManagedBlock(existing, projectName) {
|
|
|
1473
1691
|
}
|
|
1474
1692
|
async function writeSystemHostsFile(hostsPath, next, projectName) {
|
|
1475
1693
|
const sanitizedProjectName = sanitizeProjectName(projectName);
|
|
1476
|
-
const tempPath =
|
|
1694
|
+
const tempPath = join7(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
|
|
1477
1695
|
writeFileSync3(tempPath, next, "utf8");
|
|
1478
1696
|
if (process.env.LOCALGHOST_HOSTS_PATH) {
|
|
1479
1697
|
writeFileSync3(hostsPath, next, "utf8");
|
|
@@ -1511,11 +1729,11 @@ async function removeSystemHosts(projectName) {
|
|
|
1511
1729
|
|
|
1512
1730
|
// src/init.ts
|
|
1513
1731
|
import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
1514
|
-
import { join as
|
|
1732
|
+
import { join as join8 } from "path";
|
|
1515
1733
|
function detectPackageManager(cwd = process.cwd()) {
|
|
1516
|
-
if (existsSync5(
|
|
1517
|
-
if (existsSync5(
|
|
1518
|
-
if (existsSync5(
|
|
1734
|
+
if (existsSync5(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
1735
|
+
if (existsSync5(join8(cwd, "yarn.lock"))) return "yarn";
|
|
1736
|
+
if (existsSync5(join8(cwd, "bun.lock")) || existsSync5(join8(cwd, "bun.lockb"))) return "bun";
|
|
1519
1737
|
return "npm";
|
|
1520
1738
|
}
|
|
1521
1739
|
function packageRunCommand(packageManager, script) {
|
|
@@ -1589,7 +1807,7 @@ function initLocalghost(options = {}) {
|
|
|
1589
1807
|
const apiPort = options.apiPort ?? 8787;
|
|
1590
1808
|
const packageManager = options.packageManager ?? detectPackageManager(cwd);
|
|
1591
1809
|
const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
|
|
1592
|
-
const configPath =
|
|
1810
|
+
const configPath = join8(cwd, configFile);
|
|
1593
1811
|
const configExists = existsSync5(configPath);
|
|
1594
1812
|
if (configExists && !options.force) {
|
|
1595
1813
|
return {
|
|
@@ -1606,7 +1824,7 @@ function initLocalghost(options = {}) {
|
|
|
1606
1824
|
};
|
|
1607
1825
|
}
|
|
1608
1826
|
writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
|
|
1609
|
-
const packageJsonPath =
|
|
1827
|
+
const packageJsonPath = join8(cwd, "package.json");
|
|
1610
1828
|
const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
|
|
1611
1829
|
return {
|
|
1612
1830
|
configPath,
|
|
@@ -1623,6 +1841,62 @@ function initLocalghost(options = {}) {
|
|
|
1623
1841
|
};
|
|
1624
1842
|
}
|
|
1625
1843
|
|
|
1844
|
+
// src/guide.ts
|
|
1845
|
+
var LOCALGHOST_AGENT_GUIDE = `# Localghost agent guide
|
|
1846
|
+
|
|
1847
|
+
Localghost owns the local development proxy and the app process boundary.
|
|
1848
|
+
|
|
1849
|
+
## Preferred repository setup
|
|
1850
|
+
|
|
1851
|
+
For a normal repository, use this package script:
|
|
1852
|
+
|
|
1853
|
+
"dev": "localghost"
|
|
1854
|
+
|
|
1855
|
+
For an explicit app command, keep the raw command separate:
|
|
1856
|
+
|
|
1857
|
+
"dev": "localghost run -- vite"
|
|
1858
|
+
"dev:raw": "vite"
|
|
1859
|
+
|
|
1860
|
+
Use \`localghost dev\` only when the Caddy proxy should run without starting the app.
|
|
1861
|
+
|
|
1862
|
+
## Useful commands
|
|
1863
|
+
|
|
1864
|
+
- \`localghost\`: detect and run the repository development command.
|
|
1865
|
+
- \`localghost run -- <command>\`: wrap an explicit app command.
|
|
1866
|
+
- \`localghost dev\`: run only the local Caddy proxy.
|
|
1867
|
+
- \`localghost status --ready\`: check project setup.
|
|
1868
|
+
- \`localghost repair\`: repair managed hosts and Caddy setup.
|
|
1869
|
+
- \`localghost ps --json\`: inspect Localghost-managed repositories, instances, and ports.
|
|
1870
|
+
- \`localghost routes\`: inspect hostname-to-port routing.
|
|
1871
|
+
- \`localghost doctor\`: check machine prerequisites.
|
|
1872
|
+
|
|
1873
|
+
## Configuration
|
|
1874
|
+
|
|
1875
|
+
- Commit repository defaults in \`localghost.config.mjs\`.
|
|
1876
|
+
- Keep hostname and requested-port routes in \`.localghost\`.
|
|
1877
|
+
- CLI flags override repository configuration for one invocation.
|
|
1878
|
+
- Localghost remembers active project and instance port assignments in user state under \`~/.localghost\`.
|
|
1879
|
+
- Do not edit the registry manually and do not start Caddy separately.
|
|
1880
|
+
|
|
1881
|
+
## Port behavior
|
|
1882
|
+
|
|
1883
|
+
Localghost remembers ports by canonical repository path and instance key. Concurrent Localghost instances receive distinct ports. The operating-system bind check remains authoritative when another tool already owns a port.
|
|
1884
|
+
`;
|
|
1885
|
+
function formatLocalghostAgentGuide(format = "text") {
|
|
1886
|
+
if (format === "json") {
|
|
1887
|
+
return JSON.stringify({
|
|
1888
|
+
preferredScript: "localghost",
|
|
1889
|
+
explicitScript: "localghost run -- <command>",
|
|
1890
|
+
proxyOnlyCommand: "localghost dev",
|
|
1891
|
+
inspectionCommands: ["localghost status --ready", "localghost ps --json", "localghost routes", "localghost doctor"],
|
|
1892
|
+
projectConfig: "localghost.config.mjs",
|
|
1893
|
+
routeConfig: ".localghost",
|
|
1894
|
+
userState: "~/.localghost"
|
|
1895
|
+
}, null, 2);
|
|
1896
|
+
}
|
|
1897
|
+
return LOCALGHOST_AGENT_GUIDE;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1626
1900
|
// src/prompt.ts
|
|
1627
1901
|
import { stdin as input, stdout as output } from "process";
|
|
1628
1902
|
import { createInterface } from "readline/promises";
|
|
@@ -1711,10 +1985,10 @@ function formatGhostTunnel(config, options = {}) {
|
|
|
1711
1985
|
|
|
1712
1986
|
// src/state.ts
|
|
1713
1987
|
import { existsSync as existsSync6 } from "fs";
|
|
1714
|
-
import { join as
|
|
1988
|
+
import { join as join9 } from "path";
|
|
1715
1989
|
var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
|
|
1716
1990
|
function getLocalghostStatePath(cwd = process.cwd()) {
|
|
1717
|
-
return
|
|
1991
|
+
return join9(cwd, LOCALGHOST_STATE_FILE);
|
|
1718
1992
|
}
|
|
1719
1993
|
function readLocalghostState(cwd = process.cwd()) {
|
|
1720
1994
|
const path = getLocalghostStatePath(cwd);
|
|
@@ -1735,10 +2009,10 @@ function patchLocalghostState(cwd, patch) {
|
|
|
1735
2009
|
|
|
1736
2010
|
// src/update-check.ts
|
|
1737
2011
|
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
1738
|
-
import { homedir as
|
|
1739
|
-
import { dirname as dirname4, join as
|
|
2012
|
+
import { homedir as homedir3 } from "os";
|
|
2013
|
+
import { dirname as dirname4, join as join10 } from "path";
|
|
1740
2014
|
var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
|
|
1741
|
-
var LOCALGHOST_VERSION = "0.1.
|
|
2015
|
+
var LOCALGHOST_VERSION = "0.1.15";
|
|
1742
2016
|
var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1743
2017
|
var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1744
2018
|
var UPDATE_CHECK_TIMEOUT_MS = 900;
|
|
@@ -1750,8 +2024,8 @@ function isUpdateCheckDisabled(env = process.env) {
|
|
|
1750
2024
|
}
|
|
1751
2025
|
function getUpdateCheckCachePath(env = process.env) {
|
|
1752
2026
|
if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
|
|
1753
|
-
const cacheRoot = env.XDG_CACHE_HOME ||
|
|
1754
|
-
return
|
|
2027
|
+
const cacheRoot = env.XDG_CACHE_HOME || join10(homedir3(), ".cache");
|
|
2028
|
+
return join10(cacheRoot, "localghost", "update-check.json");
|
|
1755
2029
|
}
|
|
1756
2030
|
function readCache(path = getUpdateCheckCachePath()) {
|
|
1757
2031
|
if (!existsSync7(path)) return null;
|
|
@@ -1803,7 +2077,7 @@ function isNewerVersion(candidate, current = LOCALGHOST_VERSION) {
|
|
|
1803
2077
|
return Boolean(candidate && compareVersions(candidate, current) > 0);
|
|
1804
2078
|
}
|
|
1805
2079
|
async function fetchLatestVersion(packageName, timeoutMs) {
|
|
1806
|
-
const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).
|
|
2080
|
+
const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).replaceAll("/", "%2f")}` : packageName;
|
|
1807
2081
|
const response = await fetch(`https://registry.npmjs.org/${encodedName}`, {
|
|
1808
2082
|
signal: AbortSignal.timeout(timeoutMs),
|
|
1809
2083
|
headers: {
|
|
@@ -1938,6 +2212,10 @@ function parsePackageManager(value) {
|
|
|
1938
2212
|
if (value === "npm" || value === "yarn" || value === "pnpm" || value === "bun") return value;
|
|
1939
2213
|
throw new InvalidArgumentError("Package manager must be npm, pnpm, yarn, or bun.");
|
|
1940
2214
|
}
|
|
2215
|
+
function parseReleaseBump(value) {
|
|
2216
|
+
if (value === "patch" || value === "minor" || value === "major") return value;
|
|
2217
|
+
throw new InvalidArgumentError("Release bump must be patch, minor, or major.");
|
|
2218
|
+
}
|
|
1941
2219
|
function collect(value, previous = []) {
|
|
1942
2220
|
return [...previous, value];
|
|
1943
2221
|
}
|
|
@@ -1975,6 +2253,25 @@ async function assertCaddyReady() {
|
|
|
1975
2253
|
"Localghost will not install it for you. No surprise spells."
|
|
1976
2254
|
].join("\n"));
|
|
1977
2255
|
}
|
|
2256
|
+
function cleanManagedCaddyProcesses() {
|
|
2257
|
+
const runs = listLocalghostRuns();
|
|
2258
|
+
const caddyPids = runs.flatMap((run) => run.caddyPid ? [run.caddyPid] : []);
|
|
2259
|
+
const result = stopCaddyProcesses(caddyPids);
|
|
2260
|
+
for (const run of runs) {
|
|
2261
|
+
if (run.caddyPid && (result.stopped.includes(run.caddyPid) || result.alreadyExited.includes(run.caddyPid))) {
|
|
2262
|
+
unregisterLocalghostRun(run.id);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
if (result.stopped.length > 0) {
|
|
2266
|
+
console.log(`Stopped ${result.stopped.length} Localghost-managed Caddy process${result.stopped.length === 1 ? "" : "es"}.`);
|
|
2267
|
+
}
|
|
2268
|
+
if (result.alreadyExited.length > 0) {
|
|
2269
|
+
console.log(`Removed ${result.alreadyExited.length} stale Localghost Caddy record${result.alreadyExited.length === 1 ? "" : "s"}.`);
|
|
2270
|
+
}
|
|
2271
|
+
if (result.failed.length > 0) {
|
|
2272
|
+
throw new Error(`Could not stop Localghost-managed Caddy PID(s): ${result.failed.map(({ pid }) => pid).join(", ")}.`);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
1978
2275
|
function existingTrustMarkers(cwd) {
|
|
1979
2276
|
const state = readLocalghostState(cwd);
|
|
1980
2277
|
return {
|
|
@@ -2079,7 +2376,7 @@ async function runSetupFromReadiness(cwd, https, readiness) {
|
|
|
2079
2376
|
});
|
|
2080
2377
|
}
|
|
2081
2378
|
function wait2(ms) {
|
|
2082
|
-
return new Promise((
|
|
2379
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
2083
2380
|
}
|
|
2084
2381
|
async function runTrust(cwd, caddyfilePath) {
|
|
2085
2382
|
await wait2(350);
|
|
@@ -2126,25 +2423,19 @@ function registerCleanup(id) {
|
|
|
2126
2423
|
process.off("exit", cleanup);
|
|
2127
2424
|
};
|
|
2128
2425
|
}
|
|
2129
|
-
async function resolveServiceRuntimeEntries(services, dynamicPort) {
|
|
2426
|
+
async function resolveServiceRuntimeEntries(services, dynamicPort, projectCwd) {
|
|
2130
2427
|
const usedPorts = /* @__PURE__ */ new Set();
|
|
2131
2428
|
const resolved = [];
|
|
2429
|
+
const registry = dynamicPort ? createLocalghostRegistry({ cwd: projectCwd, ownerToken: `${process.pid}:services:${projectCwd}` }) : void 0;
|
|
2132
2430
|
for (const service of services) {
|
|
2133
2431
|
let port = service.requestedPort;
|
|
2134
2432
|
if (dynamicPort) {
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
found = true;
|
|
2142
|
-
break;
|
|
2143
|
-
}
|
|
2144
|
-
}
|
|
2145
|
-
if (!found) {
|
|
2146
|
-
throw new Error(`No available port found for service ${service.name} from ${service.requestedPort}.`);
|
|
2147
|
-
}
|
|
2433
|
+
const lease = await registry.acquirePort({
|
|
2434
|
+
instanceKey: `service:${service.name}`,
|
|
2435
|
+
startPort: service.requestedPort,
|
|
2436
|
+
reservedPorts: usedPorts
|
|
2437
|
+
});
|
|
2438
|
+
port = lease.port;
|
|
2148
2439
|
} else if (usedPorts.has(port)) {
|
|
2149
2440
|
throw new Error(`Services cannot start separate commands on the same fixed port: ${port}.`);
|
|
2150
2441
|
}
|
|
@@ -2159,7 +2450,13 @@ async function resolveServiceRuntimeEntries(services, dynamicPort) {
|
|
|
2159
2450
|
}
|
|
2160
2451
|
});
|
|
2161
2452
|
}
|
|
2162
|
-
return
|
|
2453
|
+
return {
|
|
2454
|
+
services: resolved,
|
|
2455
|
+
release: async () => {
|
|
2456
|
+
if (!registry) return;
|
|
2457
|
+
await Promise.all(resolved.map((service) => registry.releasePort({ instanceKey: `service:${service.name}` })));
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2163
2460
|
}
|
|
2164
2461
|
async function waitForServicePorts(entries, timeoutMs = 1e4) {
|
|
2165
2462
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -2174,81 +2471,86 @@ async function waitForServicePorts(entries, timeoutMs = 1e4) {
|
|
|
2174
2471
|
async function runDetectedServices(options) {
|
|
2175
2472
|
assertLocalDevelopment("run");
|
|
2176
2473
|
await assertCaddyReady();
|
|
2177
|
-
const
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
if (service.port !== service.requestedPort) {
|
|
2200
|
-
console.log(`${service.name}: port ${service.requestedPort} is busy; using ${service.port}.`);
|
|
2474
|
+
const runtime = await resolveServiceRuntimeEntries(options.services, options.dynamicPort, options.cwd);
|
|
2475
|
+
try {
|
|
2476
|
+
const runtimeServices = runtime.services;
|
|
2477
|
+
const entries = runtimeServices.map((service) => service.entry);
|
|
2478
|
+
const readiness = getSetupReadiness({
|
|
2479
|
+
cwd: options.cwd,
|
|
2480
|
+
https: options.https,
|
|
2481
|
+
ignoreCaddyfile: true,
|
|
2482
|
+
entries,
|
|
2483
|
+
configPath: options.configPath,
|
|
2484
|
+
projectName: options.projectName
|
|
2485
|
+
});
|
|
2486
|
+
if (!readiness.ready) {
|
|
2487
|
+
if (!options.autoRepair) {
|
|
2488
|
+
throw new Error([
|
|
2489
|
+
"Localghost setup is missing or stale.",
|
|
2490
|
+
...readiness.reasons.map((reason) => `- ${reason}`),
|
|
2491
|
+
"Automatic repair is disabled. Enable autoRepair or run localghost repair."
|
|
2492
|
+
].join("\n"));
|
|
2493
|
+
}
|
|
2494
|
+
console.log("Localghost setup is stale; repairing it now.");
|
|
2495
|
+
await runSetupFromReadiness(options.cwd, options.https, readiness);
|
|
2201
2496
|
}
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
const caddyExit = caddy.catch((error) => {
|
|
2207
|
-
if (!caddy.killed) throw error;
|
|
2208
|
-
});
|
|
2209
|
-
const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
|
|
2210
|
-
cwd: service.cwd,
|
|
2211
|
-
stdio: "inherit",
|
|
2212
|
-
env: {
|
|
2213
|
-
...process.env,
|
|
2214
|
-
LOCALGHOST_PORT: String(service.port),
|
|
2215
|
-
LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
|
|
2216
|
-
LOCALGHOST_SERVICE: service.name,
|
|
2217
|
-
VITE_PORT: String(service.port)
|
|
2497
|
+
for (const service of runtimeServices) {
|
|
2498
|
+
if (service.port !== service.requestedPort) {
|
|
2499
|
+
console.log(`${service.name}: port ${service.requestedPort} is busy; using ${service.port}.`);
|
|
2500
|
+
}
|
|
2218
2501
|
}
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
const
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2502
|
+
const caddyfile = await writeCaddyfile(entries, options.cwd, { https: options.https });
|
|
2503
|
+
await validateCaddyfile(caddyfile);
|
|
2504
|
+
const caddy = startCaddy(caddyfile);
|
|
2505
|
+
const caddyExit = caddy.catch((error) => {
|
|
2506
|
+
if (!caddy.killed) throw error;
|
|
2507
|
+
});
|
|
2508
|
+
const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
|
|
2509
|
+
cwd: service.cwd,
|
|
2510
|
+
stdio: "inherit",
|
|
2511
|
+
env: {
|
|
2512
|
+
...process.env,
|
|
2513
|
+
LOCALGHOST_PORT: String(service.port),
|
|
2514
|
+
LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
|
|
2515
|
+
LOCALGHOST_SERVICE: service.name,
|
|
2516
|
+
VITE_PORT: String(service.port)
|
|
2517
|
+
}
|
|
2518
|
+
}));
|
|
2519
|
+
const caddyPid = maybePid(caddy.pid);
|
|
2520
|
+
const runRecord = registerLocalghostRun({
|
|
2521
|
+
mode: "run",
|
|
2522
|
+
cwd: options.cwd,
|
|
2523
|
+
projectName: options.projectName,
|
|
2524
|
+
configPath: options.configPath,
|
|
2525
|
+
caddyfilePath: caddyfile,
|
|
2526
|
+
...caddyPid ? { caddyPid } : {},
|
|
2527
|
+
childCommand: ["services", ...runtimeServices.map((service) => service.name)],
|
|
2528
|
+
https: options.https,
|
|
2529
|
+
dynamicPort: options.dynamicPort,
|
|
2530
|
+
entries
|
|
2531
|
+
});
|
|
2532
|
+
const cleanupRun = registerCleanup(runRecord.id);
|
|
2533
|
+
const processExit = Promise.race([caddyExit, ...children]);
|
|
2534
|
+
try {
|
|
2535
|
+
const ready = await Promise.race([
|
|
2536
|
+
waitForServicePorts(entries),
|
|
2537
|
+
processExit.then(() => false)
|
|
2538
|
+
]);
|
|
2539
|
+
if (ready) {
|
|
2540
|
+
console.log("");
|
|
2541
|
+
logDomainRoutes(entries, { https: options.https });
|
|
2542
|
+
}
|
|
2543
|
+
await processExit;
|
|
2544
|
+
} finally {
|
|
2545
|
+
for (const child of children) {
|
|
2546
|
+
if (!child.killed) child.kill("SIGINT");
|
|
2547
|
+
}
|
|
2548
|
+
if (!caddy.killed) caddy.kill("SIGINT");
|
|
2549
|
+
await Promise.allSettled([caddyExit, ...children]);
|
|
2550
|
+
cleanupRun();
|
|
2243
2551
|
}
|
|
2244
|
-
await processExit;
|
|
2245
2552
|
} finally {
|
|
2246
|
-
|
|
2247
|
-
if (!child.killed) child.kill("SIGINT");
|
|
2248
|
-
}
|
|
2249
|
-
if (!caddy.killed) caddy.kill("SIGINT");
|
|
2250
|
-
await Promise.allSettled([caddyExit, ...children]);
|
|
2251
|
-
cleanupRun();
|
|
2553
|
+
await runtime.release();
|
|
2252
2554
|
}
|
|
2253
2555
|
}
|
|
2254
2556
|
async function getRouteViews(entries) {
|
|
@@ -2344,7 +2646,7 @@ function formatInstanceViews(instances) {
|
|
|
2344
2646
|
var program = new Command();
|
|
2345
2647
|
program.name("localghost").description("Buh. Friendly local hostnames for app repos.").version(LOCALGHOST_VERSION).option("--no-update-check", "Skip the npm update check for this run");
|
|
2346
2648
|
program.hook("postAction", async (_thisCommand, actionCommand) => {
|
|
2347
|
-
if (actionCommand.name() === "update") return;
|
|
2649
|
+
if (actionCommand.name() === "update" || actionCommand.name() === "release") return;
|
|
2348
2650
|
const options = program.opts();
|
|
2349
2651
|
await maybeNotifyAboutUpdate({ disabled: options.updateCheck === false });
|
|
2350
2652
|
});
|
|
@@ -2369,6 +2671,9 @@ program.command("init").description("Create a .localghost config for this projec
|
|
|
2369
2671
|
console.log(` ${step}`);
|
|
2370
2672
|
}
|
|
2371
2673
|
});
|
|
2674
|
+
program.command("guide").description("Explain the recommended Localghost workflow to humans or agents").option("--agent", "Print the agent-oriented workflow guide").option("--json", "Print the guide as JSON").action((options) => {
|
|
2675
|
+
console.log(formatLocalghostAgentGuide(options.json ? "json" : "text"));
|
|
2676
|
+
});
|
|
2372
2677
|
program.command("doctor").description("Check machine prerequisites").action(async () => {
|
|
2373
2678
|
const result = await runDoctor();
|
|
2374
2679
|
if (result.caddy.found) {
|
|
@@ -2400,6 +2705,30 @@ program.command("update").description("Check npm for a newer localghost release"
|
|
|
2400
2705
|
}
|
|
2401
2706
|
console.log(`localghost is up to date. Current: ${result.currentVersion}`);
|
|
2402
2707
|
});
|
|
2708
|
+
program.command("release").description("Dispatch an automated Localghost CLI release").argument("<bump>", "Semantic version increment: patch, minor, or major", parseReleaseBump).action(async (bump) => {
|
|
2709
|
+
const repository = "hamedb89/localghost";
|
|
2710
|
+
try {
|
|
2711
|
+
await execa4("gh", [
|
|
2712
|
+
"workflow",
|
|
2713
|
+
"run",
|
|
2714
|
+
"release.yml",
|
|
2715
|
+
"--repo",
|
|
2716
|
+
repository,
|
|
2717
|
+
"--ref",
|
|
2718
|
+
"main",
|
|
2719
|
+
"-f",
|
|
2720
|
+
`bump=${bump}`
|
|
2721
|
+
]);
|
|
2722
|
+
} catch (error) {
|
|
2723
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2724
|
+
throw new Error(
|
|
2725
|
+
`Could not dispatch the Localghost release workflow. Install and authenticate GitHub CLI with \`gh auth login\`, then retry.
|
|
2726
|
+
${detail}`
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
console.log(`Dispatched a ${bump} Localghost release from main.`);
|
|
2730
|
+
console.log(`Track it at https://github.com/${repository}/actions/workflows/release.yml`);
|
|
2731
|
+
});
|
|
2403
2732
|
program.command("setup").description("Update /etc/hosts and generate/validate Caddyfile").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Generate a local HTTPS Caddy proxy with Caddy local certificates").option("--ssl", "Alias for --https").action(async (options) => {
|
|
2404
2733
|
assertLocalDevelopment("setup");
|
|
2405
2734
|
printLocalghostBanner();
|
|
@@ -2600,9 +2929,10 @@ program.command("routes").description("Print domain to upstream routes").option(
|
|
|
2600
2929
|
}));
|
|
2601
2930
|
}
|
|
2602
2931
|
});
|
|
2603
|
-
program.command("dev").description("Run the Localghost Caddy proxy, repairing stale setup when needed").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
|
|
2932
|
+
program.command("dev").description("Run the Localghost Caddy proxy, repairing stale setup when needed").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--clean-caddy", "Stop Localghost-managed Caddy processes before starting").option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
|
|
2604
2933
|
assertLocalDevelopment("dev");
|
|
2605
2934
|
await assertCaddyReady();
|
|
2935
|
+
if (options.cleanCaddy) cleanManagedCaddyProcesses();
|
|
2606
2936
|
const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
|
|
2607
2937
|
const https = context.https;
|
|
2608
2938
|
const readiness = getSetupReadiness({
|
|
@@ -2660,15 +2990,18 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
|
|
|
2660
2990
|
cleanupRun();
|
|
2661
2991
|
}
|
|
2662
2992
|
});
|
|
2663
|
-
program.command("run").description("Run Caddy and a dev command from the same Localghost context").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--port <number>", "Initial app port", parsePort2).option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the child command").option("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
|
|
2993
|
+
program.command("run").description("Run Caddy and a dev command from the same Localghost context").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--port <number>", "Initial app port", parsePort2).option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--clean-caddy", "Stop Localghost-managed Caddy processes before starting").option("--trust", "Trust Caddy's local HTTPS CA before starting the child command").option("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
|
|
2664
2994
|
assertLocalDevelopment("run");
|
|
2665
2995
|
await assertCaddyReady();
|
|
2996
|
+
if (options.cleanCaddy) cleanManagedCaddyProcesses();
|
|
2666
2997
|
const context = await resolveLocalghostContext({
|
|
2667
2998
|
cwd: options.cwd,
|
|
2668
2999
|
...options.project ? { project: options.project } : {},
|
|
2669
3000
|
...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
|
|
2670
3001
|
...options.configPattern ? { configPattern: options.configPattern } : {},
|
|
2671
3002
|
...options.port ? { port: options.port } : {},
|
|
3003
|
+
reservePort: true,
|
|
3004
|
+
instanceKey: "run",
|
|
2672
3005
|
...useHttps(options) ? { https: true } : {},
|
|
2673
3006
|
...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {},
|
|
2674
3007
|
...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
|
|
@@ -2764,9 +3097,10 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
|
|
|
2764
3097
|
stopCaddy();
|
|
2765
3098
|
await Promise.allSettled([child, caddyExit]);
|
|
2766
3099
|
cleanupRun();
|
|
3100
|
+
await context.releasePort?.();
|
|
2767
3101
|
}
|
|
2768
3102
|
});
|
|
2769
|
-
program.command("tunnel").description("Run the local Ghost Tunnel agent").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--ghost-config <file>", "Exact Ghost Tunnel route file", ".ghosttunnel").option("--target-host <host>", "Local target host for .ghosttunnel ports", "127.0.0.1").action(async (options) => {
|
|
3103
|
+
program.command("tunnel").description("Run the experimental local Ghost Tunnel agent").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--ghost-config <file>", "Exact Ghost Tunnel route file", ".ghosttunnel").option("--target-host <host>", "Local target host for .ghosttunnel ports", "127.0.0.1").action(async (options) => {
|
|
2770
3104
|
assertLocalDevelopment("tunnel");
|
|
2771
3105
|
const context = await resolveLocalghostContext({
|
|
2772
3106
|
cwd: options.cwd,
|
|
@@ -2833,6 +3167,7 @@ function readImplicitInvocation(args) {
|
|
|
2833
3167
|
let cwd = process.cwd();
|
|
2834
3168
|
let dryRun = false;
|
|
2835
3169
|
let updateCheck = true;
|
|
3170
|
+
const forwardedArgs = [];
|
|
2836
3171
|
for (let index = 0; index < args.length; index += 1) {
|
|
2837
3172
|
const arg = args[index];
|
|
2838
3173
|
if (!arg) continue;
|
|
@@ -2844,6 +3179,30 @@ function readImplicitInvocation(args) {
|
|
|
2844
3179
|
updateCheck = false;
|
|
2845
3180
|
continue;
|
|
2846
3181
|
}
|
|
3182
|
+
if (["--clean-caddy", "--https", "--ssl", "--setup", "--trust"].includes(arg)) {
|
|
3183
|
+
forwardedArgs.push(arg);
|
|
3184
|
+
continue;
|
|
3185
|
+
}
|
|
3186
|
+
if (arg === "--auto-repair" || arg === "--dynamic-port") {
|
|
3187
|
+
const value = args[index + 1];
|
|
3188
|
+
forwardedArgs.push(arg);
|
|
3189
|
+
if (value && !value.startsWith("--")) {
|
|
3190
|
+
forwardedArgs.push(value);
|
|
3191
|
+
index += 1;
|
|
3192
|
+
}
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
if (arg === "--port" || arg === "--project" || arg === "--config" || arg === "--config-pattern") {
|
|
3196
|
+
const value = args[index + 1];
|
|
3197
|
+
if (!value) throw new Error(`${arg} requires a value.`);
|
|
3198
|
+
forwardedArgs.push(arg, value);
|
|
3199
|
+
index += 1;
|
|
3200
|
+
continue;
|
|
3201
|
+
}
|
|
3202
|
+
if (["--auto-repair=", "--dynamic-port=", "--port=", "--project=", "--config=", "--config-pattern="].some((prefix) => arg.startsWith(prefix))) {
|
|
3203
|
+
forwardedArgs.push(arg);
|
|
3204
|
+
continue;
|
|
3205
|
+
}
|
|
2847
3206
|
if (arg === "--cwd") {
|
|
2848
3207
|
const value = args[index + 1];
|
|
2849
3208
|
if (!value) throw new Error("--cwd requires a path.");
|
|
@@ -2857,7 +3216,15 @@ function readImplicitInvocation(args) {
|
|
|
2857
3216
|
}
|
|
2858
3217
|
return null;
|
|
2859
3218
|
}
|
|
2860
|
-
return { cwd, dryRun, updateCheck };
|
|
3219
|
+
return { cwd, dryRun, updateCheck, forwardedArgs };
|
|
3220
|
+
}
|
|
3221
|
+
function hasForwardedFlag(args, ...flags) {
|
|
3222
|
+
return args.some((arg) => flags.includes(arg));
|
|
3223
|
+
}
|
|
3224
|
+
function forwardedBoolean(args, name) {
|
|
3225
|
+
const inline = args.find((arg) => arg.startsWith(`${name}=`));
|
|
3226
|
+
if (inline) return parseBooleanLike(inline.slice(name.length + 1));
|
|
3227
|
+
return args.includes(name) ? true : void 0;
|
|
2861
3228
|
}
|
|
2862
3229
|
async function main() {
|
|
2863
3230
|
const implicit = readImplicitInvocation(process.argv.slice(2));
|
|
@@ -2875,14 +3242,15 @@ async function main() {
|
|
|
2875
3242
|
});
|
|
2876
3243
|
console.log(formatDetectedDevServices(services));
|
|
2877
3244
|
if (implicit.dryRun) return;
|
|
3245
|
+
if (hasForwardedFlag(implicit.forwardedArgs, "--clean-caddy")) cleanManagedCaddyProcesses();
|
|
2878
3246
|
await runDetectedServices({
|
|
2879
3247
|
cwd: implicit.cwd,
|
|
2880
3248
|
services,
|
|
2881
3249
|
configPath: projectConfig.path,
|
|
2882
3250
|
projectName: sanitizeProjectName(projectConfig.config.project ?? getProjectName(implicit.cwd)),
|
|
2883
|
-
https: projectConfig.config.https ?? false,
|
|
2884
|
-
dynamicPort: projectConfig.config.dynamicPort ?? true,
|
|
2885
|
-
autoRepair: projectConfig.config.autoRepair ?? true
|
|
3251
|
+
https: hasForwardedFlag(implicit.forwardedArgs, "--https", "--ssl") || (projectConfig.config.https ?? false),
|
|
3252
|
+
dynamicPort: forwardedBoolean(implicit.forwardedArgs, "--dynamic-port") ?? projectConfig.config.dynamicPort ?? true,
|
|
3253
|
+
autoRepair: forwardedBoolean(implicit.forwardedArgs, "--auto-repair") ?? projectConfig.config.autoRepair ?? true
|
|
2886
3254
|
});
|
|
2887
3255
|
await maybeNotifyAboutUpdate({ disabled: !implicit.updateCheck });
|
|
2888
3256
|
return;
|
|
@@ -2898,6 +3266,7 @@ async function main() {
|
|
|
2898
3266
|
process.argv[1] ?? "localghost",
|
|
2899
3267
|
...implicit.updateCheck ? [] : ["--no-update-check"],
|
|
2900
3268
|
"run",
|
|
3269
|
+
...implicit.forwardedArgs,
|
|
2901
3270
|
"--cwd",
|
|
2902
3271
|
implicit.cwd,
|
|
2903
3272
|
"--",
|