@hamedb89/localghost 0.1.12 → 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 +31 -13
- package/dist/cli.js +499 -128
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +81 -3
- package/dist/index.js +320 -35
- 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 +29 -24
- package/docs/ghost-tunnel.md +4 -1
- package/docs/github.md +4 -4
- package/docs/localghost.1.md +32 -5
- 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,15 +1729,17 @@ 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(
|
|
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";
|
|
1518
1737
|
return "npm";
|
|
1519
1738
|
}
|
|
1520
1739
|
function packageRunCommand(packageManager, script) {
|
|
1521
1740
|
if (packageManager === "yarn") return `yarn ${script}`;
|
|
1522
1741
|
if (packageManager === "pnpm") return `pnpm ${script}`;
|
|
1742
|
+
if (packageManager === "bun") return `bun run ${script}`;
|
|
1523
1743
|
return `npm run ${script}`;
|
|
1524
1744
|
}
|
|
1525
1745
|
function renderConfig(options) {
|
|
@@ -1587,7 +1807,7 @@ function initLocalghost(options = {}) {
|
|
|
1587
1807
|
const apiPort = options.apiPort ?? 8787;
|
|
1588
1808
|
const packageManager = options.packageManager ?? detectPackageManager(cwd);
|
|
1589
1809
|
const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
|
|
1590
|
-
const configPath =
|
|
1810
|
+
const configPath = join8(cwd, configFile);
|
|
1591
1811
|
const configExists = existsSync5(configPath);
|
|
1592
1812
|
if (configExists && !options.force) {
|
|
1593
1813
|
return {
|
|
@@ -1604,7 +1824,7 @@ function initLocalghost(options = {}) {
|
|
|
1604
1824
|
};
|
|
1605
1825
|
}
|
|
1606
1826
|
writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
|
|
1607
|
-
const packageJsonPath =
|
|
1827
|
+
const packageJsonPath = join8(cwd, "package.json");
|
|
1608
1828
|
const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
|
|
1609
1829
|
return {
|
|
1610
1830
|
configPath,
|
|
@@ -1621,6 +1841,62 @@ function initLocalghost(options = {}) {
|
|
|
1621
1841
|
};
|
|
1622
1842
|
}
|
|
1623
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
|
+
|
|
1624
1900
|
// src/prompt.ts
|
|
1625
1901
|
import { stdin as input, stdout as output } from "process";
|
|
1626
1902
|
import { createInterface } from "readline/promises";
|
|
@@ -1709,10 +1985,10 @@ function formatGhostTunnel(config, options = {}) {
|
|
|
1709
1985
|
|
|
1710
1986
|
// src/state.ts
|
|
1711
1987
|
import { existsSync as existsSync6 } from "fs";
|
|
1712
|
-
import { join as
|
|
1988
|
+
import { join as join9 } from "path";
|
|
1713
1989
|
var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
|
|
1714
1990
|
function getLocalghostStatePath(cwd = process.cwd()) {
|
|
1715
|
-
return
|
|
1991
|
+
return join9(cwd, LOCALGHOST_STATE_FILE);
|
|
1716
1992
|
}
|
|
1717
1993
|
function readLocalghostState(cwd = process.cwd()) {
|
|
1718
1994
|
const path = getLocalghostStatePath(cwd);
|
|
@@ -1733,10 +2009,10 @@ function patchLocalghostState(cwd, patch) {
|
|
|
1733
2009
|
|
|
1734
2010
|
// src/update-check.ts
|
|
1735
2011
|
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
1736
|
-
import { homedir as
|
|
1737
|
-
import { dirname as dirname4, join as
|
|
2012
|
+
import { homedir as homedir3 } from "os";
|
|
2013
|
+
import { dirname as dirname4, join as join10 } from "path";
|
|
1738
2014
|
var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
|
|
1739
|
-
var LOCALGHOST_VERSION = "0.1.
|
|
2015
|
+
var LOCALGHOST_VERSION = "0.1.15";
|
|
1740
2016
|
var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1741
2017
|
var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1742
2018
|
var UPDATE_CHECK_TIMEOUT_MS = 900;
|
|
@@ -1748,8 +2024,8 @@ function isUpdateCheckDisabled(env = process.env) {
|
|
|
1748
2024
|
}
|
|
1749
2025
|
function getUpdateCheckCachePath(env = process.env) {
|
|
1750
2026
|
if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
|
|
1751
|
-
const cacheRoot = env.XDG_CACHE_HOME ||
|
|
1752
|
-
return
|
|
2027
|
+
const cacheRoot = env.XDG_CACHE_HOME || join10(homedir3(), ".cache");
|
|
2028
|
+
return join10(cacheRoot, "localghost", "update-check.json");
|
|
1753
2029
|
}
|
|
1754
2030
|
function readCache(path = getUpdateCheckCachePath()) {
|
|
1755
2031
|
if (!existsSync7(path)) return null;
|
|
@@ -1801,7 +2077,7 @@ function isNewerVersion(candidate, current = LOCALGHOST_VERSION) {
|
|
|
1801
2077
|
return Boolean(candidate && compareVersions(candidate, current) > 0);
|
|
1802
2078
|
}
|
|
1803
2079
|
async function fetchLatestVersion(packageName, timeoutMs) {
|
|
1804
|
-
const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).
|
|
2080
|
+
const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).replaceAll("/", "%2f")}` : packageName;
|
|
1805
2081
|
const response = await fetch(`https://registry.npmjs.org/${encodedName}`, {
|
|
1806
2082
|
signal: AbortSignal.timeout(timeoutMs),
|
|
1807
2083
|
headers: {
|
|
@@ -1933,8 +2209,12 @@ function parsePort2(value) {
|
|
|
1933
2209
|
return port;
|
|
1934
2210
|
}
|
|
1935
2211
|
function parsePackageManager(value) {
|
|
1936
|
-
if (value === "npm" || value === "yarn" || value === "pnpm") return value;
|
|
1937
|
-
throw new InvalidArgumentError("Package manager must be npm, yarn, or
|
|
2212
|
+
if (value === "npm" || value === "yarn" || value === "pnpm" || value === "bun") return value;
|
|
2213
|
+
throw new InvalidArgumentError("Package manager must be npm, pnpm, yarn, or bun.");
|
|
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.");
|
|
1938
2218
|
}
|
|
1939
2219
|
function collect(value, previous = []) {
|
|
1940
2220
|
return [...previous, value];
|
|
@@ -1973,6 +2253,25 @@ async function assertCaddyReady() {
|
|
|
1973
2253
|
"Localghost will not install it for you. No surprise spells."
|
|
1974
2254
|
].join("\n"));
|
|
1975
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
|
+
}
|
|
1976
2275
|
function existingTrustMarkers(cwd) {
|
|
1977
2276
|
const state = readLocalghostState(cwd);
|
|
1978
2277
|
return {
|
|
@@ -2077,7 +2376,7 @@ async function runSetupFromReadiness(cwd, https, readiness) {
|
|
|
2077
2376
|
});
|
|
2078
2377
|
}
|
|
2079
2378
|
function wait2(ms) {
|
|
2080
|
-
return new Promise((
|
|
2379
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
2081
2380
|
}
|
|
2082
2381
|
async function runTrust(cwd, caddyfilePath) {
|
|
2083
2382
|
await wait2(350);
|
|
@@ -2124,25 +2423,19 @@ function registerCleanup(id) {
|
|
|
2124
2423
|
process.off("exit", cleanup);
|
|
2125
2424
|
};
|
|
2126
2425
|
}
|
|
2127
|
-
async function resolveServiceRuntimeEntries(services, dynamicPort) {
|
|
2426
|
+
async function resolveServiceRuntimeEntries(services, dynamicPort, projectCwd) {
|
|
2128
2427
|
const usedPorts = /* @__PURE__ */ new Set();
|
|
2129
2428
|
const resolved = [];
|
|
2429
|
+
const registry = dynamicPort ? createLocalghostRegistry({ cwd: projectCwd, ownerToken: `${process.pid}:services:${projectCwd}` }) : void 0;
|
|
2130
2430
|
for (const service of services) {
|
|
2131
2431
|
let port = service.requestedPort;
|
|
2132
2432
|
if (dynamicPort) {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
found = true;
|
|
2140
|
-
break;
|
|
2141
|
-
}
|
|
2142
|
-
}
|
|
2143
|
-
if (!found) {
|
|
2144
|
-
throw new Error(`No available port found for service ${service.name} from ${service.requestedPort}.`);
|
|
2145
|
-
}
|
|
2433
|
+
const lease = await registry.acquirePort({
|
|
2434
|
+
instanceKey: `service:${service.name}`,
|
|
2435
|
+
startPort: service.requestedPort,
|
|
2436
|
+
reservedPorts: usedPorts
|
|
2437
|
+
});
|
|
2438
|
+
port = lease.port;
|
|
2146
2439
|
} else if (usedPorts.has(port)) {
|
|
2147
2440
|
throw new Error(`Services cannot start separate commands on the same fixed port: ${port}.`);
|
|
2148
2441
|
}
|
|
@@ -2157,7 +2450,13 @@ async function resolveServiceRuntimeEntries(services, dynamicPort) {
|
|
|
2157
2450
|
}
|
|
2158
2451
|
});
|
|
2159
2452
|
}
|
|
2160
|
-
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
|
+
};
|
|
2161
2460
|
}
|
|
2162
2461
|
async function waitForServicePorts(entries, timeoutMs = 1e4) {
|
|
2163
2462
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -2172,81 +2471,86 @@ async function waitForServicePorts(entries, timeoutMs = 1e4) {
|
|
|
2172
2471
|
async function runDetectedServices(options) {
|
|
2173
2472
|
assertLocalDevelopment("run");
|
|
2174
2473
|
await assertCaddyReady();
|
|
2175
|
-
const
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
if (service.port !== service.requestedPort) {
|
|
2198
|
-
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);
|
|
2199
2496
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
const caddyExit = caddy.catch((error) => {
|
|
2205
|
-
if (!caddy.killed) throw error;
|
|
2206
|
-
});
|
|
2207
|
-
const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
|
|
2208
|
-
cwd: service.cwd,
|
|
2209
|
-
stdio: "inherit",
|
|
2210
|
-
env: {
|
|
2211
|
-
...process.env,
|
|
2212
|
-
LOCALGHOST_PORT: String(service.port),
|
|
2213
|
-
LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
|
|
2214
|
-
LOCALGHOST_SERVICE: service.name,
|
|
2215
|
-
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
|
+
}
|
|
2216
2501
|
}
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
const
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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();
|
|
2241
2551
|
}
|
|
2242
|
-
await processExit;
|
|
2243
2552
|
} finally {
|
|
2244
|
-
|
|
2245
|
-
if (!child.killed) child.kill("SIGINT");
|
|
2246
|
-
}
|
|
2247
|
-
if (!caddy.killed) caddy.kill("SIGINT");
|
|
2248
|
-
await Promise.allSettled([caddyExit, ...children]);
|
|
2249
|
-
cleanupRun();
|
|
2553
|
+
await runtime.release();
|
|
2250
2554
|
}
|
|
2251
2555
|
}
|
|
2252
2556
|
async function getRouteViews(entries) {
|
|
@@ -2342,11 +2646,11 @@ function formatInstanceViews(instances) {
|
|
|
2342
2646
|
var program = new Command();
|
|
2343
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");
|
|
2344
2648
|
program.hook("postAction", async (_thisCommand, actionCommand) => {
|
|
2345
|
-
if (actionCommand.name() === "update") return;
|
|
2649
|
+
if (actionCommand.name() === "update" || actionCommand.name() === "release") return;
|
|
2346
2650
|
const options = program.opts();
|
|
2347
2651
|
await maybeNotifyAboutUpdate({ disabled: options.updateCheck === false });
|
|
2348
2652
|
});
|
|
2349
|
-
program.command("init").description("Create a .localghost config for this project").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to create", ".localghost").option("--host <host>", "Primary local hostname").option("--port <number>", "Primary app port", parsePort2).option("--api-host <host>", "API local hostname").option("--api-port <number>", "API port", parsePort2).option("--package-manager <npm|yarn|
|
|
2653
|
+
program.command("init").description("Create a .localghost config for this project").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to create", ".localghost").option("--host <host>", "Primary local hostname").option("--port <number>", "Primary app port", parsePort2).option("--api-host <host>", "API local hostname").option("--api-port <number>", "API port", parsePort2).option("--package-manager <npm|pnpm|yarn|bun>", "Package manager for suggested commands", parsePackageManager).option("--write-scripts", "Add localghost scripts to package.json").option("--force", "Overwrite an existing config file").action((options) => {
|
|
2350
2654
|
const result = initLocalghost({ ...options, configFile: options.config });
|
|
2351
2655
|
if (result.configCreated) {
|
|
2352
2656
|
console.log(`Buh. Created ${result.configPath}`);
|
|
@@ -2367,6 +2671,9 @@ program.command("init").description("Create a .localghost config for this projec
|
|
|
2367
2671
|
console.log(` ${step}`);
|
|
2368
2672
|
}
|
|
2369
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
|
+
});
|
|
2370
2677
|
program.command("doctor").description("Check machine prerequisites").action(async () => {
|
|
2371
2678
|
const result = await runDoctor();
|
|
2372
2679
|
if (result.caddy.found) {
|
|
@@ -2398,6 +2705,30 @@ program.command("update").description("Check npm for a newer localghost release"
|
|
|
2398
2705
|
}
|
|
2399
2706
|
console.log(`localghost is up to date. Current: ${result.currentVersion}`);
|
|
2400
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
|
+
});
|
|
2401
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) => {
|
|
2402
2733
|
assertLocalDevelopment("setup");
|
|
2403
2734
|
printLocalghostBanner();
|
|
@@ -2598,9 +2929,10 @@ program.command("routes").description("Print domain to upstream routes").option(
|
|
|
2598
2929
|
}));
|
|
2599
2930
|
}
|
|
2600
2931
|
});
|
|
2601
|
-
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) => {
|
|
2602
2933
|
assertLocalDevelopment("dev");
|
|
2603
2934
|
await assertCaddyReady();
|
|
2935
|
+
if (options.cleanCaddy) cleanManagedCaddyProcesses();
|
|
2604
2936
|
const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
|
|
2605
2937
|
const https = context.https;
|
|
2606
2938
|
const readiness = getSetupReadiness({
|
|
@@ -2658,15 +2990,18 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
|
|
|
2658
2990
|
cleanupRun();
|
|
2659
2991
|
}
|
|
2660
2992
|
});
|
|
2661
|
-
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) => {
|
|
2662
2994
|
assertLocalDevelopment("run");
|
|
2663
2995
|
await assertCaddyReady();
|
|
2996
|
+
if (options.cleanCaddy) cleanManagedCaddyProcesses();
|
|
2664
2997
|
const context = await resolveLocalghostContext({
|
|
2665
2998
|
cwd: options.cwd,
|
|
2666
2999
|
...options.project ? { project: options.project } : {},
|
|
2667
3000
|
...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
|
|
2668
3001
|
...options.configPattern ? { configPattern: options.configPattern } : {},
|
|
2669
3002
|
...options.port ? { port: options.port } : {},
|
|
3003
|
+
reservePort: true,
|
|
3004
|
+
instanceKey: "run",
|
|
2670
3005
|
...useHttps(options) ? { https: true } : {},
|
|
2671
3006
|
...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {},
|
|
2672
3007
|
...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
|
|
@@ -2762,9 +3097,10 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
|
|
|
2762
3097
|
stopCaddy();
|
|
2763
3098
|
await Promise.allSettled([child, caddyExit]);
|
|
2764
3099
|
cleanupRun();
|
|
3100
|
+
await context.releasePort?.();
|
|
2765
3101
|
}
|
|
2766
3102
|
});
|
|
2767
|
-
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) => {
|
|
2768
3104
|
assertLocalDevelopment("tunnel");
|
|
2769
3105
|
const context = await resolveLocalghostContext({
|
|
2770
3106
|
cwd: options.cwd,
|
|
@@ -2831,6 +3167,7 @@ function readImplicitInvocation(args) {
|
|
|
2831
3167
|
let cwd = process.cwd();
|
|
2832
3168
|
let dryRun = false;
|
|
2833
3169
|
let updateCheck = true;
|
|
3170
|
+
const forwardedArgs = [];
|
|
2834
3171
|
for (let index = 0; index < args.length; index += 1) {
|
|
2835
3172
|
const arg = args[index];
|
|
2836
3173
|
if (!arg) continue;
|
|
@@ -2842,6 +3179,30 @@ function readImplicitInvocation(args) {
|
|
|
2842
3179
|
updateCheck = false;
|
|
2843
3180
|
continue;
|
|
2844
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
|
+
}
|
|
2845
3206
|
if (arg === "--cwd") {
|
|
2846
3207
|
const value = args[index + 1];
|
|
2847
3208
|
if (!value) throw new Error("--cwd requires a path.");
|
|
@@ -2855,7 +3216,15 @@ function readImplicitInvocation(args) {
|
|
|
2855
3216
|
}
|
|
2856
3217
|
return null;
|
|
2857
3218
|
}
|
|
2858
|
-
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;
|
|
2859
3228
|
}
|
|
2860
3229
|
async function main() {
|
|
2861
3230
|
const implicit = readImplicitInvocation(process.argv.slice(2));
|
|
@@ -2873,14 +3242,15 @@ async function main() {
|
|
|
2873
3242
|
});
|
|
2874
3243
|
console.log(formatDetectedDevServices(services));
|
|
2875
3244
|
if (implicit.dryRun) return;
|
|
3245
|
+
if (hasForwardedFlag(implicit.forwardedArgs, "--clean-caddy")) cleanManagedCaddyProcesses();
|
|
2876
3246
|
await runDetectedServices({
|
|
2877
3247
|
cwd: implicit.cwd,
|
|
2878
3248
|
services,
|
|
2879
3249
|
configPath: projectConfig.path,
|
|
2880
3250
|
projectName: sanitizeProjectName(projectConfig.config.project ?? getProjectName(implicit.cwd)),
|
|
2881
|
-
https: projectConfig.config.https ?? false,
|
|
2882
|
-
dynamicPort: projectConfig.config.dynamicPort ?? true,
|
|
2883
|
-
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
|
|
2884
3254
|
});
|
|
2885
3255
|
await maybeNotifyAboutUpdate({ disabled: !implicit.updateCheck });
|
|
2886
3256
|
return;
|
|
@@ -2896,6 +3266,7 @@ async function main() {
|
|
|
2896
3266
|
process.argv[1] ?? "localghost",
|
|
2897
3267
|
...implicit.updateCheck ? [] : ["--no-update-check"],
|
|
2898
3268
|
"run",
|
|
3269
|
+
...implicit.forwardedArgs,
|
|
2899
3270
|
"--cwd",
|
|
2900
3271
|
implicit.cwd,
|
|
2901
3272
|
"--",
|