@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 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((resolve4) => {
490
+ return new Promise((resolve5) => {
491
491
  const server = createServer();
492
492
  server.once("error", () => {
493
- resolve4(false);
493
+ resolve5(false);
494
494
  });
495
495
  server.once("listening", () => {
496
- server.close(() => resolve4(true));
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((resolve4) => {
1562
- const timeout = setTimeout(resolve4, ms);
1607
+ return new Promise((resolve5) => {
1608
+ const timeout = setTimeout(resolve5, ms);
1563
1609
  const stop = () => {
1564
1610
  clearTimeout(timeout);
1565
- resolve4();
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 dirname4, join as join10 } from "path";
2212
+ import { dirname as dirname5, join as join10 } from "path";
2104
2213
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
2105
- var LOCALGHOST_VERSION = "0.4.1";
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(dirname4(path), { recursive: true });
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 < 1) return false;
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((resolve4) => setTimeout(resolve4, ms));
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
- process.off("SIGINT", request);
2557
- process.off("SIGTERM", request);
2558
- request();
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 availability = await Promise.all(ports.map((port) => isPortAvailable(port)));
2611
- if (availability.every(Boolean)) return true;
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
- return false;
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 finishShutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
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
- finishShutdown();
2910
+ shutdown.finish();
2718
2911
  await waitForProcessShutdown(
2719
2912
  [caddyExit, ...children],
2720
2913
  stopManaged
2721
2914
  );
2722
- if (!await waitForPortsToBeAvailable(entries)) {
2723
- console.warn("Localghost: timed out waiting for service ports to be released.");
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
- if (!await waitForPortsToBeAvailable(entries, 2e3)) {
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("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("--json", "Print raw JSON").action(async (options) => {
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
- await maybeTrustCaddy({
3270
- cwd: options.cwd,
3503
+ const https = context.https;
3504
+ const readiness = getSetupReadiness({
3505
+ ...options,
3271
3506
  https,
3272
- caddyfilePath: caddyfile,
3273
- ...typeof options.trust === "boolean" ? { trust: options.trust } : {}
3507
+ ignoreCaddyfile: true,
3508
+ entries: context.entries,
3509
+ configPath: context.configPath,
3510
+ projectName: context.projectName
3274
3511
  });
3275
- } catch (error) {
3276
- signalManagedProcess(caddy, "SIGINT");
3277
- throw error;
3278
- }
3279
- const [binary, ...args] = command;
3280
- if (!binary) {
3281
- throw new Error("Missing command. Use: localghost run -- vite");
3282
- }
3283
- const child = execa4(binary, args, {
3284
- cwd: options.cwd,
3285
- stdio: "inherit",
3286
- detached: process.platform !== "win32",
3287
- env: {
3288
- ...process.env,
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
- const caddyPid = maybePid(caddy.pid);
3295
- const childPid = maybePid(child.pid);
3296
- const runRecord = registerLocalghostRun({
3297
- mode: "run",
3298
- cwd: context.cwd,
3299
- projectName: context.projectName,
3300
- configPath: context.configPath,
3301
- caddyfilePath: caddyfile,
3302
- ...caddyPid ? { caddyPid } : {},
3303
- ...caddyPid ? { caddyPgid: caddyPid } : {},
3304
- ...childPid ? { childPid } : {},
3305
- childCommand: command,
3306
- https,
3307
- requestedPort: context.requestedPort,
3308
- port: context.port,
3309
- dynamicPort: context.dynamicPort,
3310
- entries: context.entries
3311
- });
3312
- const cleanupRun = registerCleanup(runRecord.id);
3313
- const stopManaged = (signal) => {
3314
- signalManagedProcess(child, signal);
3315
- signalManagedProcess(caddy, signal);
3316
- };
3317
- const finishShutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
3318
- try {
3319
- await Promise.race([child, caddyExit]);
3320
- } finally {
3321
- finishShutdown();
3322
- await waitForProcessShutdown(
3323
- [child, caddyExit],
3324
- stopManaged
3325
- );
3326
- if (!await waitForPortsToBeAvailable(context.entries)) {
3327
- console.warn("Localghost: timed out waiting for service ports to be released.");
3328
- stopManaged("SIGTERM");
3329
- if (!await waitForPortsToBeAvailable(context.entries, 2e3)) {
3330
- stopManaged("SIGKILL");
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
- cleanupRun();
3608
+ } finally {
3334
3609
  await context.releasePort?.();
3335
3610
  }
3336
3611
  });