@hamedb89/localghost 0.4.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +395 -120
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +45 -14
- package/dist/index.js +112 -12
- package/dist/index.js.map +1 -1
- package/dist/vite.js +46 -0
- package/dist/vite.js.map +1 -1
- package/docs/localghost.1.md +26 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -487,13 +487,13 @@ import { pathToFileURL } from "url";
|
|
|
487
487
|
// src/port.ts
|
|
488
488
|
import { createServer } from "net";
|
|
489
489
|
async function isPortAvailable(port, host = "127.0.0.1") {
|
|
490
|
-
return new Promise((
|
|
490
|
+
return new Promise((resolve5) => {
|
|
491
491
|
const server = createServer();
|
|
492
492
|
server.once("error", () => {
|
|
493
|
-
|
|
493
|
+
resolve5(false);
|
|
494
494
|
});
|
|
495
495
|
server.once("listening", () => {
|
|
496
|
-
server.close(() =>
|
|
496
|
+
server.close(() => resolve5(true));
|
|
497
497
|
});
|
|
498
498
|
server.listen(port, host);
|
|
499
499
|
});
|
|
@@ -546,6 +546,9 @@ function validRegistry(value) {
|
|
|
546
546
|
function pruneRegistry(registry, now, isRunning) {
|
|
547
547
|
registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
|
|
548
548
|
}
|
|
549
|
+
function isTestSessionKey(instanceKey) {
|
|
550
|
+
return instanceKey.startsWith("test:");
|
|
551
|
+
}
|
|
549
552
|
async function readJson(path) {
|
|
550
553
|
try {
|
|
551
554
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -642,6 +645,37 @@ function createLocalghostRegistry(options = {}) {
|
|
|
642
645
|
await releaseLock();
|
|
643
646
|
}
|
|
644
647
|
},
|
|
648
|
+
async pruneTestSessions() {
|
|
649
|
+
const releaseLock = await lock();
|
|
650
|
+
try {
|
|
651
|
+
const registry = await readRegistry();
|
|
652
|
+
const staleTestKeys = new Set(
|
|
653
|
+
registry.leases.filter((lease) => isTestSessionKey(lease.instanceKey) && (lease.expiresAt <= now() || !isRunning(lease.pid))).map((lease) => leaseKey(lease.projectCwd, lease.instanceKey))
|
|
654
|
+
);
|
|
655
|
+
const beforeLeases = registry.leases.length;
|
|
656
|
+
registry.leases = registry.leases.filter((lease) => !staleTestKeys.has(leaseKey(lease.projectCwd, lease.instanceKey)));
|
|
657
|
+
const activeKeys = new Set(registry.leases.map((lease) => leaseKey(lease.projectCwd, lease.instanceKey)));
|
|
658
|
+
const beforeAllocations = registry.allocations.length;
|
|
659
|
+
registry.allocations = registry.allocations.filter(
|
|
660
|
+
(allocation) => !isTestSessionKey(allocation.instanceKey) || activeKeys.has(leaseKey(allocation.projectCwd, allocation.instanceKey))
|
|
661
|
+
);
|
|
662
|
+
await writeRegistry(registry);
|
|
663
|
+
return {
|
|
664
|
+
removedLeases: beforeLeases - registry.leases.length,
|
|
665
|
+
removedAllocations: beforeAllocations - registry.allocations.length
|
|
666
|
+
};
|
|
667
|
+
} finally {
|
|
668
|
+
await releaseLock();
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
async reset() {
|
|
672
|
+
const releaseLock = await lock();
|
|
673
|
+
try {
|
|
674
|
+
await writeRegistry({ version: 1, allocations: [], leases: [] });
|
|
675
|
+
} finally {
|
|
676
|
+
await releaseLock();
|
|
677
|
+
}
|
|
678
|
+
},
|
|
645
679
|
async acquirePort(acquireOptions) {
|
|
646
680
|
const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
|
|
647
681
|
if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
|
|
@@ -679,6 +713,18 @@ function createLocalghostRegistry(options = {}) {
|
|
|
679
713
|
return lease;
|
|
680
714
|
});
|
|
681
715
|
},
|
|
716
|
+
async renewPort(renewOptions) {
|
|
717
|
+
const projectCwd = canonicalizeLocalghostProjectCwd(renewOptions.projectCwd ?? cwd);
|
|
718
|
+
return withLock(async (registry) => {
|
|
719
|
+
const lease = registry.leases.find(
|
|
720
|
+
(candidate) => candidate.projectCwd === projectCwd && candidate.instanceKey === renewOptions.instanceKey && candidate.ownerToken === ownerToken
|
|
721
|
+
);
|
|
722
|
+
if (!lease || lease.expiresAt <= now() || !isRunning(lease.pid)) return void 0;
|
|
723
|
+
lease.expiresAt = now() + (renewOptions.leaseTtlMs ?? 30 * 60 * 1e3);
|
|
724
|
+
await writeRegistry(registry);
|
|
725
|
+
return lease;
|
|
726
|
+
});
|
|
727
|
+
},
|
|
682
728
|
async releasePort(releaseOptions) {
|
|
683
729
|
const projectCwd = canonicalizeLocalghostProjectCwd(releaseOptions.projectCwd ?? cwd);
|
|
684
730
|
return withLock(async (registry) => {
|
|
@@ -1558,11 +1604,11 @@ function isStopped(signal, localSignal) {
|
|
|
1558
1604
|
}
|
|
1559
1605
|
function wait(ms, signal, localSignal) {
|
|
1560
1606
|
if (isStopped(signal, localSignal)) return Promise.resolve();
|
|
1561
|
-
return new Promise((
|
|
1562
|
-
const timeout = setTimeout(
|
|
1607
|
+
return new Promise((resolve5) => {
|
|
1608
|
+
const timeout = setTimeout(resolve5, ms);
|
|
1563
1609
|
const stop = () => {
|
|
1564
1610
|
clearTimeout(timeout);
|
|
1565
|
-
|
|
1611
|
+
resolve5();
|
|
1566
1612
|
};
|
|
1567
1613
|
signal?.addEventListener("abort", stop, { once: true });
|
|
1568
1614
|
localSignal.addEventListener("abort", stop, { once: true });
|
|
@@ -1784,13 +1830,23 @@ async function removeSystemHosts(projectName) {
|
|
|
1784
1830
|
|
|
1785
1831
|
// src/init.ts
|
|
1786
1832
|
import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
1787
|
-
import { join as join8 } from "path";
|
|
1833
|
+
import { dirname as dirname4, join as join8, resolve as resolve4 } from "path";
|
|
1788
1834
|
function detectPackageManager(cwd = process.cwd()) {
|
|
1789
1835
|
if (existsSync5(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
1790
1836
|
if (existsSync5(join8(cwd, "yarn.lock"))) return "yarn";
|
|
1791
1837
|
if (existsSync5(join8(cwd, "bun.lock")) || existsSync5(join8(cwd, "bun.lockb"))) return "bun";
|
|
1792
1838
|
return "npm";
|
|
1793
1839
|
}
|
|
1840
|
+
function isPnpmWorkspaceRoot(cwd = process.cwd()) {
|
|
1841
|
+
const target = resolve4(cwd);
|
|
1842
|
+
let current = target;
|
|
1843
|
+
while (true) {
|
|
1844
|
+
if (existsSync5(join8(current, "pnpm-workspace.yaml"))) return current === target;
|
|
1845
|
+
const parent = dirname4(current);
|
|
1846
|
+
if (parent === current) return false;
|
|
1847
|
+
current = parent;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1794
1850
|
function packageRunCommand(packageManager, script) {
|
|
1795
1851
|
if (packageManager === "yarn") return `yarn ${script}`;
|
|
1796
1852
|
if (packageManager === "pnpm") return `pnpm ${script}`;
|
|
@@ -1910,6 +1966,59 @@ function initLocalghost(options = {}) {
|
|
|
1910
1966
|
};
|
|
1911
1967
|
}
|
|
1912
1968
|
|
|
1969
|
+
// src/test-session.ts
|
|
1970
|
+
async function createLocalghostTestSession(options) {
|
|
1971
|
+
if (!options.instanceKey) throw new Error("instanceKey is required");
|
|
1972
|
+
const registry = createLocalghostRegistry(options.cwd ? { cwd: options.cwd } : {});
|
|
1973
|
+
const leases = [];
|
|
1974
|
+
const ports = {};
|
|
1975
|
+
const leaseTtlMs = options.leaseTtlMs ?? 30 * 60 * 1e3;
|
|
1976
|
+
if (!Number.isFinite(leaseTtlMs) || leaseTtlMs < 1e3) throw new Error("leaseTtlMs must be at least 1000 milliseconds.");
|
|
1977
|
+
try {
|
|
1978
|
+
for (const [name, service] of Object.entries(options.services)) {
|
|
1979
|
+
const lease = await registry.acquirePort({
|
|
1980
|
+
...options.cwd ? { projectCwd: options.cwd } : {},
|
|
1981
|
+
instanceKey: `test:${options.instanceKey}:${name}`,
|
|
1982
|
+
startPort: service.startPort,
|
|
1983
|
+
...service.maxAttempts !== void 0 ? { maxAttempts: service.maxAttempts } : {},
|
|
1984
|
+
...service.host !== void 0 ? { host: service.host } : {},
|
|
1985
|
+
leaseTtlMs,
|
|
1986
|
+
reservedPorts: Object.values(ports)
|
|
1987
|
+
});
|
|
1988
|
+
leases.push(lease);
|
|
1989
|
+
ports[name] = lease.port;
|
|
1990
|
+
}
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
await Promise.all(leases.map((lease) => registry.releasePort({ projectCwd: lease.projectCwd, instanceKey: lease.instanceKey })));
|
|
1993
|
+
throw error;
|
|
1994
|
+
}
|
|
1995
|
+
let released = false;
|
|
1996
|
+
const renew = async () => {
|
|
1997
|
+
if (released) return;
|
|
1998
|
+
await Promise.all(leases.map(async (lease) => {
|
|
1999
|
+
const renewed = await registry.renewPort({ projectCwd: lease.projectCwd, instanceKey: lease.instanceKey, leaseTtlMs });
|
|
2000
|
+
if (!renewed) throw new Error(`Localghost test lease expired: ${lease.instanceKey}`);
|
|
2001
|
+
}));
|
|
2002
|
+
};
|
|
2003
|
+
return {
|
|
2004
|
+
instanceKey: options.instanceKey,
|
|
2005
|
+
ports,
|
|
2006
|
+
leases,
|
|
2007
|
+
renew,
|
|
2008
|
+
startHeartbeat(intervalMs = Math.max(1e3, Math.floor(leaseTtlMs / 3))) {
|
|
2009
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 1e3) throw new Error("Heartbeat interval must be at least 1000 milliseconds.");
|
|
2010
|
+
const timer = setInterval(() => void renew().catch(() => void 0), intervalMs);
|
|
2011
|
+
timer.unref();
|
|
2012
|
+
return timer;
|
|
2013
|
+
},
|
|
2014
|
+
async release() {
|
|
2015
|
+
if (released) return;
|
|
2016
|
+
released = true;
|
|
2017
|
+
await Promise.all(leases.map((lease) => registry.releasePort({ projectCwd: lease.projectCwd, instanceKey: lease.instanceKey })));
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1913
2022
|
// src/guide.ts
|
|
1914
2023
|
var LOCALGHOST_AGENT_GUIDE = `# Localghost agent guide
|
|
1915
2024
|
|
|
@@ -2100,9 +2209,9 @@ function patchLocalghostState(cwd, patch) {
|
|
|
2100
2209
|
// src/update-check.ts
|
|
2101
2210
|
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
2102
2211
|
import { homedir as homedir3 } from "os";
|
|
2103
|
-
import { dirname as
|
|
2212
|
+
import { dirname as dirname5, join as join10 } from "path";
|
|
2104
2213
|
var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
|
|
2105
|
-
var LOCALGHOST_VERSION = "0.
|
|
2214
|
+
var LOCALGHOST_VERSION = "0.6.1";
|
|
2106
2215
|
var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2107
2216
|
var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2108
2217
|
var UPDATE_CHECK_TIMEOUT_MS = 900;
|
|
@@ -2127,7 +2236,7 @@ function readCache(path = getUpdateCheckCachePath()) {
|
|
|
2127
2236
|
}
|
|
2128
2237
|
function writeCache(cache, path = getUpdateCheckCachePath()) {
|
|
2129
2238
|
try {
|
|
2130
|
-
mkdirSync3(
|
|
2239
|
+
mkdirSync3(dirname5(path), { recursive: true });
|
|
2131
2240
|
writeFileSync5(path, `${JSON.stringify(cache, null, 2)}
|
|
2132
2241
|
`, "utf8");
|
|
2133
2242
|
} catch {
|
|
@@ -2270,7 +2379,7 @@ import { execa as execa4 } from "execa";
|
|
|
2270
2379
|
|
|
2271
2380
|
// src/process.ts
|
|
2272
2381
|
function signalManagedProcessPid(pid, signal, killProcess = (value, processSignal) => process.kill(value, processSignal)) {
|
|
2273
|
-
if (typeof pid !== "number" || !Number.isInteger(pid) || pid
|
|
2382
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
|
2274
2383
|
try {
|
|
2275
2384
|
killProcess(process.platform === "win32" ? pid : -pid, signal);
|
|
2276
2385
|
return true;
|
|
@@ -2320,6 +2429,13 @@ function parsePort2(value) {
|
|
|
2320
2429
|
}
|
|
2321
2430
|
return port;
|
|
2322
2431
|
}
|
|
2432
|
+
function parseCount(value) {
|
|
2433
|
+
const count = Number.parseInt(value, 10);
|
|
2434
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) {
|
|
2435
|
+
throw new InvalidArgumentError("Count must be a number between 1 and 100.");
|
|
2436
|
+
}
|
|
2437
|
+
return count;
|
|
2438
|
+
}
|
|
2323
2439
|
function parsePackageManager(value) {
|
|
2324
2440
|
if (value === "npm" || value === "yarn" || value === "pnpm" || value === "bun") return value;
|
|
2325
2441
|
throw new InvalidArgumentError("Package manager must be npm, pnpm, yarn, or bun.");
|
|
@@ -2496,7 +2612,7 @@ async function runSetupFromReadiness(cwd, https, readiness) {
|
|
|
2496
2612
|
});
|
|
2497
2613
|
}
|
|
2498
2614
|
function wait2(ms) {
|
|
2499
|
-
return new Promise((
|
|
2615
|
+
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
2500
2616
|
}
|
|
2501
2617
|
async function runTrust(cwd, caddyfilePath) {
|
|
2502
2618
|
await wait2(350);
|
|
@@ -2527,6 +2643,69 @@ async function maybeTrustCaddy(options) {
|
|
|
2527
2643
|
}
|
|
2528
2644
|
await runTrust(options.cwd, options.caddyfilePath);
|
|
2529
2645
|
}
|
|
2646
|
+
async function readPortStatuses() {
|
|
2647
|
+
const registry = createLocalghostRegistry();
|
|
2648
|
+
const data = await registry.read();
|
|
2649
|
+
const rows = /* @__PURE__ */ new Map();
|
|
2650
|
+
const now = Date.now();
|
|
2651
|
+
for (const allocation of data.allocations) {
|
|
2652
|
+
rows.set(`${allocation.projectCwd}\0${allocation.instanceKey}`, {
|
|
2653
|
+
port: allocation.port,
|
|
2654
|
+
state: "available",
|
|
2655
|
+
projectCwd: allocation.projectCwd,
|
|
2656
|
+
instanceKey: allocation.instanceKey,
|
|
2657
|
+
available: true
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
for (const lease of data.leases) {
|
|
2661
|
+
const running = lease.expiresAt > now && isProcessRunning(lease.pid);
|
|
2662
|
+
const key = `${lease.projectCwd}\0${lease.instanceKey}`;
|
|
2663
|
+
rows.set(key, {
|
|
2664
|
+
port: lease.port,
|
|
2665
|
+
state: running ? "active" : "stale",
|
|
2666
|
+
projectCwd: lease.projectCwd,
|
|
2667
|
+
instanceKey: lease.instanceKey,
|
|
2668
|
+
available: false,
|
|
2669
|
+
pid: lease.pid,
|
|
2670
|
+
processRunning: isProcessRunning(lease.pid),
|
|
2671
|
+
expiresAt: lease.expiresAt
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
const activity = readLocalghostActivity();
|
|
2675
|
+
for (const run of activity.runs) {
|
|
2676
|
+
const ports = [...new Set(run.entries.map((entry) => entry.port))];
|
|
2677
|
+
for (const port of ports) {
|
|
2678
|
+
const key = `${run.cwd}\0activity:${run.id}:${port}`;
|
|
2679
|
+
if (!rows.has(key)) {
|
|
2680
|
+
const running = isProcessRunning(run.pid);
|
|
2681
|
+
rows.set(key, {
|
|
2682
|
+
port,
|
|
2683
|
+
state: running ? "active" : "stale",
|
|
2684
|
+
projectCwd: run.cwd,
|
|
2685
|
+
instanceKey: `activity:${run.mode}`,
|
|
2686
|
+
available: false,
|
|
2687
|
+
pid: run.pid,
|
|
2688
|
+
processRunning: running
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
const statuses = await Promise.all([...rows.values()].map(async (row) => {
|
|
2694
|
+
if (row.state === "active" || row.state === "stale") {
|
|
2695
|
+
return { ...row, available: await isPortAvailable(row.port) };
|
|
2696
|
+
}
|
|
2697
|
+
const available = await isPortAvailable(row.port);
|
|
2698
|
+
return { ...row, available, state: available ? "available" : "occupied" };
|
|
2699
|
+
}));
|
|
2700
|
+
return { registryPath: registry.registryPath, statuses };
|
|
2701
|
+
}
|
|
2702
|
+
async function findFreePorts(startPort, count) {
|
|
2703
|
+
const ports = [];
|
|
2704
|
+
for (let port = startPort; port <= 65535 && ports.length < count; port += 1) {
|
|
2705
|
+
if (await isPortAvailable(port)) ports.push(port);
|
|
2706
|
+
}
|
|
2707
|
+
return ports;
|
|
2708
|
+
}
|
|
2530
2709
|
function maybePid(pid) {
|
|
2531
2710
|
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
2532
2711
|
}
|
|
@@ -2552,10 +2731,13 @@ function registerSignalShutdown(stop) {
|
|
|
2552
2731
|
};
|
|
2553
2732
|
process.once("SIGINT", request);
|
|
2554
2733
|
process.once("SIGTERM", request);
|
|
2555
|
-
return
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2734
|
+
return {
|
|
2735
|
+
wasRequested: () => requested,
|
|
2736
|
+
finish: () => {
|
|
2737
|
+
process.off("SIGINT", request);
|
|
2738
|
+
process.off("SIGTERM", request);
|
|
2739
|
+
if (!requested) stop();
|
|
2740
|
+
}
|
|
2559
2741
|
};
|
|
2560
2742
|
}
|
|
2561
2743
|
async function resolveServiceRuntimeEntries(services, dynamicPort, projectCwd) {
|
|
@@ -2607,11 +2789,22 @@ async function waitForPortsToBeAvailable(entries, timeoutMs = 1e4) {
|
|
|
2607
2789
|
const deadline = Date.now() + timeoutMs;
|
|
2608
2790
|
const ports = [...new Set(entries.map((entry) => entry.port))];
|
|
2609
2791
|
while (Date.now() < deadline) {
|
|
2610
|
-
const
|
|
2611
|
-
if (
|
|
2792
|
+
const availability2 = await Promise.all(ports.map((port) => isPortAvailable(port)));
|
|
2793
|
+
if (availability2.every(Boolean)) return { available: true, blocked: [] };
|
|
2612
2794
|
await wait2(50);
|
|
2613
2795
|
}
|
|
2614
|
-
|
|
2796
|
+
const availability = await Promise.all(entries.map(async (entry) => ({
|
|
2797
|
+
entry,
|
|
2798
|
+
available: await isPortAvailable(entry.port)
|
|
2799
|
+
})));
|
|
2800
|
+
const blocked = availability.filter(({ available }) => !available).map(({ entry }) => entry);
|
|
2801
|
+
return { available: blocked.length === 0, blocked };
|
|
2802
|
+
}
|
|
2803
|
+
function formatBlockedPorts(entries) {
|
|
2804
|
+
return [...new Map(entries.map((entry) => [
|
|
2805
|
+
`${entry.host}:${entry.port}`,
|
|
2806
|
+
`${entry.host} (port ${entry.port})`
|
|
2807
|
+
])).values()].join(", ");
|
|
2615
2808
|
}
|
|
2616
2809
|
async function waitForProcessShutdown(processes, forceStop, timeoutMs = 1e4) {
|
|
2617
2810
|
const settled = Promise.allSettled(processes);
|
|
@@ -2702,7 +2895,7 @@ async function runDetectedServices(options) {
|
|
|
2702
2895
|
}
|
|
2703
2896
|
signalManagedProcess(caddy, signal);
|
|
2704
2897
|
};
|
|
2705
|
-
const
|
|
2898
|
+
const shutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
|
|
2706
2899
|
try {
|
|
2707
2900
|
const ready = await Promise.race([
|
|
2708
2901
|
waitForServicePorts(entries),
|
|
@@ -2714,15 +2907,18 @@ async function runDetectedServices(options) {
|
|
|
2714
2907
|
}
|
|
2715
2908
|
await processExit;
|
|
2716
2909
|
} finally {
|
|
2717
|
-
|
|
2910
|
+
shutdown.finish();
|
|
2718
2911
|
await waitForProcessShutdown(
|
|
2719
2912
|
[caddyExit, ...children],
|
|
2720
2913
|
stopManaged
|
|
2721
2914
|
);
|
|
2722
|
-
|
|
2723
|
-
|
|
2915
|
+
const drained = await waitForPortsToBeAvailable(entries);
|
|
2916
|
+
if (!drained.available && !shutdown.wasRequested()) {
|
|
2917
|
+
console.warn(`Localghost: timed out waiting for service ports to be released: ${formatBlockedPorts(drained.blocked)}.`);
|
|
2724
2918
|
stopManaged("SIGTERM");
|
|
2725
|
-
|
|
2919
|
+
const terminated = await waitForPortsToBeAvailable(entries, 2e3);
|
|
2920
|
+
if (!terminated.available) {
|
|
2921
|
+
console.warn(`Localghost: service ports still occupied after termination: ${formatBlockedPorts(terminated.blocked)}.`);
|
|
2726
2922
|
stopManaged("SIGKILL");
|
|
2727
2923
|
}
|
|
2728
2924
|
}
|
|
@@ -2908,6 +3104,9 @@ program.command("upgrade").description("Install or update Localghost in the curr
|
|
|
2908
3104
|
const packageManager = detectPackageManager(options.cwd);
|
|
2909
3105
|
const packageName = "@hamedb89/localghost@latest";
|
|
2910
3106
|
const args = packageManager === "yarn" ? ["add", "--dev", packageName] : packageManager === "pnpm" ? ["add", "--save-dev", packageName] : packageManager === "bun" ? ["add", "--dev", packageName] : ["install", "--save-dev", packageName];
|
|
3107
|
+
if (packageManager === "pnpm" && isPnpmWorkspaceRoot(options.cwd)) {
|
|
3108
|
+
args.splice(1, 0, "--workspace-root");
|
|
3109
|
+
}
|
|
2911
3110
|
console.log(`Upgrading ${packageName} with ${packageManager}...`);
|
|
2912
3111
|
await execa4(packageManager, args, { cwd: options.cwd, stdio: "inherit" });
|
|
2913
3112
|
console.log(`Localghost is upgraded in ${options.cwd}.`);
|
|
@@ -3097,7 +3296,77 @@ program.command("teardown").description("Remove Localghost's managed /etc/hosts
|
|
|
3097
3296
|
unregisterLocalghostSetup({ cwd: options.cwd, projectName });
|
|
3098
3297
|
console.log(`State ${statePath}`);
|
|
3099
3298
|
});
|
|
3100
|
-
program.command("
|
|
3299
|
+
program.command("ports").description("Manage Localghost port registry state").addCommand(new Command("prune").description("Remove expired or dead port leases").option("--cwd <path>", "Project directory", process.cwd()).option("--test-only", "Remove only stale test-session leases and records").option("--json", "Print raw JSON").action(async (options) => {
|
|
3300
|
+
const result = options.testOnly ? await createLocalghostRegistry({ cwd: options.cwd }).pruneTestSessions() : await createLocalghostRegistry({ cwd: options.cwd }).prune();
|
|
3301
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
3302
|
+
else console.log(`Pruned ${result.removedLeases} stale registry lease${result.removedLeases === 1 ? "" : "s"}${"removedAllocations" in result ? ` and ${result.removedAllocations} test allocation${result.removedAllocations === 1 ? "" : "s"}` : ""}.`);
|
|
3303
|
+
})).addCommand(new Command("reset").description("Clear all remembered allocations and leases").option("--cwd <path>", "Project directory", process.cwd()).option("--yes", "Confirm the destructive registry reset").action(async (options) => {
|
|
3304
|
+
if (!options.yes) throw new Error("Resetting the port registry is destructive. Re-run with --yes.");
|
|
3305
|
+
await createLocalghostRegistry({ cwd: options.cwd }).reset();
|
|
3306
|
+
console.log("Localghost port registry reset.");
|
|
3307
|
+
}));
|
|
3308
|
+
program.command("test").description("Run a test command with an isolated Localghost port").option("--cwd <path>", "Project directory", process.cwd()).option("--instance <name>", "Parallel test instance name", String(process.pid)).option("--port <number>", "Initial test port", parsePort2, 5173).option("--lease-ttl <milliseconds>", "Lease lifetime before heartbeat renewal", Number, 30 * 60 * 1e3).argument("<command...>", "Command to run after --").action(async (command, options) => {
|
|
3309
|
+
const [binary, ...args] = command;
|
|
3310
|
+
if (!binary) throw new Error("Missing command. Use: localghost test -- <command>");
|
|
3311
|
+
const session = await createLocalghostTestSession({
|
|
3312
|
+
cwd: options.cwd,
|
|
3313
|
+
instanceKey: options.instance,
|
|
3314
|
+
leaseTtlMs: options.leaseTtl,
|
|
3315
|
+
services: { default: { startPort: options.port } }
|
|
3316
|
+
});
|
|
3317
|
+
const heartbeat = session.startHeartbeat();
|
|
3318
|
+
const child = execa4(binary, args, {
|
|
3319
|
+
cwd: options.cwd,
|
|
3320
|
+
stdio: "inherit",
|
|
3321
|
+
detached: process.platform !== "win32",
|
|
3322
|
+
env: {
|
|
3323
|
+
...process.env,
|
|
3324
|
+
LOCALGHOST_INSTANCE: options.instance,
|
|
3325
|
+
LOCALGHOST_PORT: String(session.ports.default),
|
|
3326
|
+
LOCALGHOST_DYNAMIC_PORT: "1",
|
|
3327
|
+
VITE_PORT: String(session.ports.default)
|
|
3328
|
+
}
|
|
3329
|
+
});
|
|
3330
|
+
const stop = (signal) => signalManagedProcess(child, signal);
|
|
3331
|
+
process.once("SIGINT", stop);
|
|
3332
|
+
process.once("SIGTERM", stop);
|
|
3333
|
+
try {
|
|
3334
|
+
await child;
|
|
3335
|
+
} finally {
|
|
3336
|
+
process.off("SIGINT", stop);
|
|
3337
|
+
process.off("SIGTERM", stop);
|
|
3338
|
+
clearInterval(heartbeat);
|
|
3339
|
+
await session.release();
|
|
3340
|
+
}
|
|
3341
|
+
});
|
|
3342
|
+
program.command("status").description("Print Localghost's project-local state file").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("--ready", "Exit non-zero when setup is missing or stale").option("--https", "Check setup readiness for HTTPS mode").option("--ssl", "Alias for --https").option("--ports", "List Localghost-managed and active ports").option("--from <port>", "First port to probe for free ports", parsePort2, 3e3).option("--count <number>", "Number of free ports to find", parseCount, 5).option("--json", "Print raw JSON").action(async (options) => {
|
|
3343
|
+
if (options.ports) {
|
|
3344
|
+
const [{ registryPath, statuses }, freePorts] = await Promise.all([
|
|
3345
|
+
readPortStatuses(),
|
|
3346
|
+
findFreePorts(options.from, options.count)
|
|
3347
|
+
]);
|
|
3348
|
+
const result = {
|
|
3349
|
+
registryPath,
|
|
3350
|
+
ports: statuses.sort((left, right) => left.port - right.port),
|
|
3351
|
+
free: { from: options.from, count: options.count, ports: freePorts }
|
|
3352
|
+
};
|
|
3353
|
+
if (options.json) {
|
|
3354
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
console.log(`Registry: ${registryPath}`);
|
|
3358
|
+
if (result.ports.length === 0) {
|
|
3359
|
+
console.log("No Localghost ports are currently recorded.");
|
|
3360
|
+
} else {
|
|
3361
|
+
console.log("Port State Project / instance");
|
|
3362
|
+
for (const port of result.ports) {
|
|
3363
|
+
const owner = `${port.projectCwd} / ${port.instanceKey}`;
|
|
3364
|
+
console.log(`${String(port.port).padEnd(5)} ${port.state.padEnd(10)} ${owner}`);
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
console.log(`Free ports from ${options.from}: ${freePorts.join(", ") || "none found"}`);
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3101
3370
|
const state = readLocalghostState(options.cwd);
|
|
3102
3371
|
const statePath = getLocalghostStatePath(options.cwd);
|
|
3103
3372
|
const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
|
|
@@ -3230,107 +3499,113 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
|
|
|
3230
3499
|
...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {},
|
|
3231
3500
|
...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
|
|
3232
3501
|
});
|
|
3233
|
-
const https = context.https;
|
|
3234
|
-
const readiness = getSetupReadiness({
|
|
3235
|
-
...options,
|
|
3236
|
-
https,
|
|
3237
|
-
ignoreCaddyfile: true,
|
|
3238
|
-
entries: context.entries,
|
|
3239
|
-
configPath: context.configPath,
|
|
3240
|
-
projectName: context.projectName
|
|
3241
|
-
});
|
|
3242
|
-
if (!readiness.ready) {
|
|
3243
|
-
if (!options.setup && !context.autoRepair) {
|
|
3244
|
-
throw new Error(
|
|
3245
|
-
[
|
|
3246
|
-
"Localghost setup is missing or stale.",
|
|
3247
|
-
...readiness.reasons.map((reason) => `- ${reason}`),
|
|
3248
|
-
`Run: ${readiness.setupCommand}`,
|
|
3249
|
-
"Automatic repair is disabled. Rerun without --auto-repair=no or run localghost repair."
|
|
3250
|
-
].join("\n")
|
|
3251
|
-
);
|
|
3252
|
-
}
|
|
3253
|
-
console.log("Localghost setup is stale; repairing it now.");
|
|
3254
|
-
await runSetupFromReadiness(options.cwd, https, readiness);
|
|
3255
|
-
console.log(`Repair complete. Setup state: ${getLocalghostStatePath(options.cwd)}`);
|
|
3256
|
-
}
|
|
3257
|
-
if (context.dynamicPort && context.port !== context.requestedPort) {
|
|
3258
|
-
console.log(`Port ${context.requestedPort} is busy; using ${context.port}.`);
|
|
3259
|
-
}
|
|
3260
|
-
warnAboutLocalMdns(context.entries);
|
|
3261
|
-
logDomainRoutes(context.entries, { https, ghostTunnel: context.ghostTunnel });
|
|
3262
|
-
const caddyfile = await writeCaddyfile(context.entries, options.cwd, { https });
|
|
3263
|
-
await validateCaddyfile(caddyfile);
|
|
3264
|
-
const caddy = startCaddy(caddyfile);
|
|
3265
|
-
const caddyExit = caddy.catch((error) => {
|
|
3266
|
-
if (!caddy.killed) throw error;
|
|
3267
|
-
});
|
|
3268
3502
|
try {
|
|
3269
|
-
|
|
3270
|
-
|
|
3503
|
+
const https = context.https;
|
|
3504
|
+
const readiness = getSetupReadiness({
|
|
3505
|
+
...options,
|
|
3271
3506
|
https,
|
|
3272
|
-
|
|
3273
|
-
|
|
3507
|
+
ignoreCaddyfile: true,
|
|
3508
|
+
entries: context.entries,
|
|
3509
|
+
configPath: context.configPath,
|
|
3510
|
+
projectName: context.projectName
|
|
3274
3511
|
});
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
LOCALGHOST_PORT: String(context.port),
|
|
3290
|
-
LOCALGHOST_DYNAMIC_PORT: context.dynamicPort ? "1" : "0",
|
|
3291
|
-
VITE_PORT: String(context.port)
|
|
3512
|
+
if (!readiness.ready) {
|
|
3513
|
+
if (!options.setup && !context.autoRepair) {
|
|
3514
|
+
throw new Error(
|
|
3515
|
+
[
|
|
3516
|
+
"Localghost setup is missing or stale.",
|
|
3517
|
+
...readiness.reasons.map((reason) => `- ${reason}`),
|
|
3518
|
+
`Run: ${readiness.setupCommand}`,
|
|
3519
|
+
"Automatic repair is disabled. Rerun without --auto-repair=no or run localghost repair."
|
|
3520
|
+
].join("\n")
|
|
3521
|
+
);
|
|
3522
|
+
}
|
|
3523
|
+
console.log("Localghost setup is stale; repairing it now.");
|
|
3524
|
+
await runSetupFromReadiness(options.cwd, https, readiness);
|
|
3525
|
+
console.log(`Repair complete. Setup state: ${getLocalghostStatePath(options.cwd)}`);
|
|
3292
3526
|
}
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3527
|
+
if (context.dynamicPort && context.port !== context.requestedPort) {
|
|
3528
|
+
console.log(`Port ${context.requestedPort} is busy; using ${context.port}.`);
|
|
3529
|
+
}
|
|
3530
|
+
warnAboutLocalMdns(context.entries);
|
|
3531
|
+
logDomainRoutes(context.entries, { https, ghostTunnel: context.ghostTunnel });
|
|
3532
|
+
const caddyfile = await writeCaddyfile(context.entries, options.cwd, { https });
|
|
3533
|
+
await validateCaddyfile(caddyfile);
|
|
3534
|
+
const caddy = startCaddy(caddyfile);
|
|
3535
|
+
const caddyExit = caddy.catch((error) => {
|
|
3536
|
+
if (!caddy.killed) throw error;
|
|
3537
|
+
});
|
|
3538
|
+
try {
|
|
3539
|
+
await maybeTrustCaddy({
|
|
3540
|
+
cwd: options.cwd,
|
|
3541
|
+
https,
|
|
3542
|
+
caddyfilePath: caddyfile,
|
|
3543
|
+
...typeof options.trust === "boolean" ? { trust: options.trust } : {}
|
|
3544
|
+
});
|
|
3545
|
+
} catch (error) {
|
|
3546
|
+
signalManagedProcess(caddy, "SIGINT");
|
|
3547
|
+
throw error;
|
|
3548
|
+
}
|
|
3549
|
+
const [binary, ...args] = command;
|
|
3550
|
+
if (!binary) {
|
|
3551
|
+
throw new Error("Missing command. Use: localghost run -- vite");
|
|
3552
|
+
}
|
|
3553
|
+
const child = execa4(binary, args, {
|
|
3554
|
+
cwd: options.cwd,
|
|
3555
|
+
stdio: "inherit",
|
|
3556
|
+
detached: process.platform !== "win32",
|
|
3557
|
+
env: {
|
|
3558
|
+
...process.env,
|
|
3559
|
+
LOCALGHOST_PORT: String(context.port),
|
|
3560
|
+
LOCALGHOST_DYNAMIC_PORT: context.dynamicPort ? "1" : "0",
|
|
3561
|
+
VITE_PORT: String(context.port)
|
|
3562
|
+
}
|
|
3563
|
+
});
|
|
3564
|
+
const caddyPid = maybePid(caddy.pid);
|
|
3565
|
+
const childPid = maybePid(child.pid);
|
|
3566
|
+
const runRecord = registerLocalghostRun({
|
|
3567
|
+
mode: "run",
|
|
3568
|
+
cwd: context.cwd,
|
|
3569
|
+
projectName: context.projectName,
|
|
3570
|
+
configPath: context.configPath,
|
|
3571
|
+
caddyfilePath: caddyfile,
|
|
3572
|
+
...caddyPid ? { caddyPid } : {},
|
|
3573
|
+
...caddyPid ? { caddyPgid: caddyPid } : {},
|
|
3574
|
+
...childPid ? { childPid } : {},
|
|
3575
|
+
childCommand: command,
|
|
3576
|
+
https,
|
|
3577
|
+
requestedPort: context.requestedPort,
|
|
3578
|
+
port: context.port,
|
|
3579
|
+
dynamicPort: context.dynamicPort,
|
|
3580
|
+
entries: context.entries
|
|
3581
|
+
});
|
|
3582
|
+
const cleanupRun = registerCleanup(runRecord.id);
|
|
3583
|
+
const stopManaged = (signal) => {
|
|
3584
|
+
signalManagedProcess(child, signal);
|
|
3585
|
+
signalManagedProcess(caddy, signal);
|
|
3586
|
+
};
|
|
3587
|
+
const shutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
|
|
3588
|
+
try {
|
|
3589
|
+
await Promise.race([child, caddyExit]);
|
|
3590
|
+
} finally {
|
|
3591
|
+
shutdown.finish();
|
|
3592
|
+
await waitForProcessShutdown(
|
|
3593
|
+
[child, caddyExit],
|
|
3594
|
+
stopManaged
|
|
3595
|
+
);
|
|
3596
|
+
const drained = await waitForPortsToBeAvailable(context.entries);
|
|
3597
|
+
if (!drained.available && !shutdown.wasRequested()) {
|
|
3598
|
+
console.warn(`Localghost: timed out waiting for service ports to be released: ${formatBlockedPorts(drained.blocked)}.`);
|
|
3599
|
+
stopManaged("SIGTERM");
|
|
3600
|
+
const terminated = await waitForPortsToBeAvailable(context.entries, 2e3);
|
|
3601
|
+
if (!terminated.available) {
|
|
3602
|
+
console.warn(`Localghost: service ports still occupied after termination: ${formatBlockedPorts(terminated.blocked)}.`);
|
|
3603
|
+
stopManaged("SIGKILL");
|
|
3604
|
+
}
|
|
3331
3605
|
}
|
|
3606
|
+
cleanupRun();
|
|
3332
3607
|
}
|
|
3333
|
-
|
|
3608
|
+
} finally {
|
|
3334
3609
|
await context.releasePort?.();
|
|
3335
3610
|
}
|
|
3336
3611
|
});
|