@hamedb89/localghost 0.1.15 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,8 @@ Check whether the machine is ready:
93
93
  localghost doctor
94
94
  ```
95
95
 
96
+ Doctor also reports occupied configured ports, stale registry leases, and port allocations shared by multiple Localghost instances. Use `--json` for agent-readable output.
97
+
96
98
  Prepare `/etc/hosts` and the local Caddyfile:
97
99
 
98
100
  ```sh
@@ -111,6 +113,14 @@ Repair stale hosts, Caddy configuration, or setup state:
111
113
  localghost repair
112
114
  ```
113
115
 
116
+ If an existing project port is occupied or was allocated incorrectly, ask Localghost to choose and remember the next available port:
117
+
118
+ ```sh
119
+ localghost repair --reallocate-port
120
+ ```
121
+
122
+ This does not edit `.localghost`; it records a stable runtime allocation in `~/.localghost` and regenerates the managed hosts and Caddy state from that allocation. To remove expired or dead leases as part of the repair, add `--prune-registry`.
123
+
114
124
  Run only the local proxy:
115
125
 
116
126
  ```sh
@@ -584,9 +594,9 @@ The app bundle is written to `dist/LocalghostWidget.app`.
584
594
  ```sh
585
595
  localghost [--cwd path] [--dry-run]
586
596
  localghost init [--write-scripts] [--config file] [--host host] [--port port]
587
- localghost doctor
597
+ localghost doctor [--cwd path] [--config file] [--config-pattern regex] [--json]
588
598
  localghost setup [--project name] [--config file] [--config-pattern regex] [--https|--ssl]
589
- localghost repair [--project name] [--config file] [--config-pattern regex] [--https|--ssl] [--trust]
599
+ localghost repair [--project name] [--config file] [--config-pattern regex] [--https|--ssl] [--trust] [--reallocate-port] [--prune-registry]
590
600
  localghost trust [--project name] [--config file] [--config-pattern regex] [--https|--ssl]
591
601
  localghost reset [--project name]
592
602
  localghost teardown [--project name] [--remove-caddyfile]
package/dist/cli.js CHANGED
@@ -86,6 +86,7 @@ function registerLocalghostRun(input2, path = getLocalghostActivityPath()) {
86
86
  ...input2.configPath ? { configPath: input2.configPath } : {},
87
87
  ...input2.caddyfilePath ? { caddyfilePath: input2.caddyfilePath } : {},
88
88
  ...input2.caddyPid ? { caddyPid: input2.caddyPid } : {},
89
+ ...input2.caddyPgid ? { caddyPgid: input2.caddyPgid } : {},
89
90
  ...input2.childPid ? { childPid: input2.childPid } : {},
90
91
  ...input2.childCommand ? { childCommand: input2.childCommand } : {},
91
92
  ...typeof input2.https === "boolean" ? { https: input2.https } : {},
@@ -336,7 +337,8 @@ async function validateCaddyfile(path) {
336
337
  function startCaddy(path) {
337
338
  return execa("caddy", ["run", "--config", path], {
338
339
  cwd: dirname3(path),
339
- stdio: caddyStdio()
340
+ stdio: caddyStdio(),
341
+ detached: process.platform !== "win32"
340
342
  });
341
343
  }
342
344
  function stopCaddyProcesses(pids, killProcess = (pid, signal) => process.kill(pid, signal)) {
@@ -628,6 +630,18 @@ function createLocalghostRegistry(options = {}) {
628
630
  lockPath,
629
631
  ownerToken,
630
632
  read: readRegistry,
633
+ async prune() {
634
+ const releaseLock = await lock();
635
+ try {
636
+ const registry = await readRegistry();
637
+ const before = registry.leases.length;
638
+ pruneRegistry(registry, now(), isRunning);
639
+ await writeRegistry(registry);
640
+ return { removedLeases: before - registry.leases.length };
641
+ } finally {
642
+ await releaseLock();
643
+ }
644
+ },
631
645
  async acquirePort(acquireOptions) {
632
646
  const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
633
647
  if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
@@ -1207,11 +1221,52 @@ async function checkCaddy() {
1207
1221
  };
1208
1222
  }
1209
1223
  }
1210
- async function runDoctor() {
1224
+ async function runDoctor(options = {}) {
1211
1225
  const caddy = await checkCaddy();
1226
+ const cwd = options.cwd ?? process.cwd();
1227
+ const registry = createLocalghostRegistry({ cwd });
1228
+ const data = await registry.read();
1229
+ const now = Date.now();
1230
+ const staleLeases = data.leases.filter((lease) => lease.expiresAt <= now || !isProcessRunning(lease.pid)).map(({ projectCwd, instanceKey, port, pid }) => ({ projectCwd, instanceKey, port, pid }));
1231
+ const allocationsByPort = /* @__PURE__ */ new Map();
1232
+ for (const allocation of data.allocations) {
1233
+ const projects = allocationsByPort.get(allocation.port) ?? [];
1234
+ projects.push(`${allocation.projectCwd}#${allocation.instanceKey}`);
1235
+ allocationsByPort.set(allocation.port, projects);
1236
+ }
1237
+ const duplicateAllocations = [...allocationsByPort.entries()].filter(([, projects]) => projects.length > 1).map(([port, projects]) => ({ port, projects }));
1238
+ let configured;
1239
+ let available;
1240
+ try {
1241
+ const context = await resolveLocalghostContext({
1242
+ cwd,
1243
+ ...options.configFiles ? { configFiles: options.configFiles } : {},
1244
+ ...options.configPattern ? { configPattern: options.configPattern } : {},
1245
+ dynamicPort: false
1246
+ });
1247
+ configured = context.requestedPort;
1248
+ available = await isPortAvailable(configured);
1249
+ } catch {
1250
+ }
1251
+ const currentProjectCwd = canonicalizeLocalghostProjectCwd(cwd);
1252
+ const currentAllocation = data.allocations.find((allocation) => allocation.projectCwd === currentProjectCwd);
1212
1253
  return {
1213
- ok: caddy.found,
1214
- caddy
1254
+ ok: caddy.found && available !== false && staleLeases.length === 0 && duplicateAllocations.length === 0,
1255
+ caddy,
1256
+ ports: {
1257
+ ...configured !== void 0 ? { configured } : {},
1258
+ ...available !== void 0 ? { available } : {},
1259
+ registryPath: registry.registryPath,
1260
+ staleLeases,
1261
+ duplicateAllocations,
1262
+ ...currentAllocation ? {
1263
+ currentAllocation: {
1264
+ projectCwd: currentAllocation.projectCwd,
1265
+ instanceKey: currentAllocation.instanceKey,
1266
+ port: currentAllocation.port
1267
+ }
1268
+ } : {}
1269
+ }
1215
1270
  };
1216
1271
  }
1217
1272
 
@@ -1868,7 +1923,8 @@ Use \`localghost dev\` only when the Caddy proxy should run without starting the
1868
1923
  - \`localghost repair\`: repair managed hosts and Caddy setup.
1869
1924
  - \`localghost ps --json\`: inspect Localghost-managed repositories, instances, and ports.
1870
1925
  - \`localghost routes\`: inspect hostname-to-port routing.
1871
- - \`localghost doctor\`: check machine prerequisites.
1926
+ - \`localghost doctor\`: check machine prerequisites, ports, and registry state.
1927
+ - \`localghost repair --reallocate-port\`: move an occupied project port to a stable available port.
1872
1928
 
1873
1929
  ## Configuration
1874
1930
 
@@ -2012,7 +2068,7 @@ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as rea
2012
2068
  import { homedir as homedir3 } from "os";
2013
2069
  import { dirname as dirname4, join as join10 } from "path";
2014
2070
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
2015
- var LOCALGHOST_VERSION = "0.1.15";
2071
+ var LOCALGHOST_VERSION = "0.2.0";
2016
2072
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2017
2073
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
2018
2074
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -2176,6 +2232,27 @@ ${message}`);
2176
2232
 
2177
2233
  // src/cli.ts
2178
2234
  import { execa as execa4 } from "execa";
2235
+
2236
+ // src/process.ts
2237
+ function signalManagedProcessPid(pid, signal, killProcess = (value, processSignal) => process.kill(value, processSignal)) {
2238
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid < 1) return false;
2239
+ try {
2240
+ killProcess(process.platform === "win32" ? pid : -pid, signal);
2241
+ return true;
2242
+ } catch (error) {
2243
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return false;
2244
+ throw error;
2245
+ }
2246
+ }
2247
+ function signalManagedProcess(child, signal) {
2248
+ if (process.platform === "win32") {
2249
+ if (!child.killed) child.kill(signal);
2250
+ return true;
2251
+ }
2252
+ return signalManagedProcessPid(child.pid, signal);
2253
+ }
2254
+
2255
+ // src/cli.ts
2179
2256
  function warnAboutLocalMdns(entries) {
2180
2257
  const localHosts = findLocalMdnsHosts(entries);
2181
2258
  if (localHosts.length > 0) {
@@ -2255,10 +2332,18 @@ async function assertCaddyReady() {
2255
2332
  }
2256
2333
  function cleanManagedCaddyProcesses() {
2257
2334
  const runs = listLocalghostRuns();
2258
- const caddyPids = runs.flatMap((run) => run.caddyPid ? [run.caddyPid] : []);
2259
- const result = stopCaddyProcesses(caddyPids);
2335
+ const legacyPids = runs.flatMap((run) => run.caddyPid && !run.caddyPgid ? [run.caddyPid] : []);
2336
+ const managedPgid = runs.flatMap((run) => run.caddyPgid ? [run.caddyPgid] : []);
2337
+ const legacyResult = stopCaddyProcesses(legacyPids);
2338
+ const managedResult = stopCaddyProcesses(managedPgid, signalManagedProcessPid);
2339
+ const result = {
2340
+ stopped: [...legacyResult.stopped, ...managedResult.stopped],
2341
+ alreadyExited: [...legacyResult.alreadyExited, ...managedResult.alreadyExited],
2342
+ failed: [...legacyResult.failed, ...managedResult.failed]
2343
+ };
2260
2344
  for (const run of runs) {
2261
- if (run.caddyPid && (result.stopped.includes(run.caddyPid) || result.alreadyExited.includes(run.caddyPid))) {
2345
+ const caddyIdentity = run.caddyPgid ?? run.caddyPid;
2346
+ if (caddyIdentity && (result.stopped.includes(caddyIdentity) || result.alreadyExited.includes(caddyIdentity))) {
2262
2347
  unregisterLocalghostRun(run.id);
2263
2348
  }
2264
2349
  }
@@ -2468,6 +2553,16 @@ async function waitForServicePorts(entries, timeoutMs = 1e4) {
2468
2553
  }
2469
2554
  return false;
2470
2555
  }
2556
+ async function waitForPortsToBeAvailable(entries, timeoutMs = 1e4) {
2557
+ const deadline = Date.now() + timeoutMs;
2558
+ const ports = [...new Set(entries.map((entry) => entry.port))];
2559
+ while (Date.now() < deadline) {
2560
+ const availability = await Promise.all(ports.map((port) => isPortAvailable(port)));
2561
+ if (availability.every(Boolean)) return true;
2562
+ await wait2(50);
2563
+ }
2564
+ return false;
2565
+ }
2471
2566
  async function runDetectedServices(options) {
2472
2567
  assertLocalDevelopment("run");
2473
2568
  await assertCaddyReady();
@@ -2508,6 +2603,7 @@ async function runDetectedServices(options) {
2508
2603
  const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
2509
2604
  cwd: service.cwd,
2510
2605
  stdio: "inherit",
2606
+ detached: process.platform !== "win32",
2511
2607
  env: {
2512
2608
  ...process.env,
2513
2609
  LOCALGHOST_PORT: String(service.port),
@@ -2524,6 +2620,7 @@ async function runDetectedServices(options) {
2524
2620
  configPath: options.configPath,
2525
2621
  caddyfilePath: caddyfile,
2526
2622
  ...caddyPid ? { caddyPid } : {},
2623
+ ...caddyPid ? { caddyPgid: caddyPid } : {},
2527
2624
  childCommand: ["services", ...runtimeServices.map((service) => service.name)],
2528
2625
  https: options.https,
2529
2626
  dynamicPort: options.dynamicPort,
@@ -2543,10 +2640,13 @@ async function runDetectedServices(options) {
2543
2640
  await processExit;
2544
2641
  } finally {
2545
2642
  for (const child of children) {
2546
- if (!child.killed) child.kill("SIGINT");
2643
+ signalManagedProcess(child, "SIGINT");
2547
2644
  }
2548
- if (!caddy.killed) caddy.kill("SIGINT");
2645
+ signalManagedProcess(caddy, "SIGINT");
2549
2646
  await Promise.allSettled([caddyExit, ...children]);
2647
+ if (!await waitForPortsToBeAvailable(entries)) {
2648
+ console.warn("Localghost: timed out waiting for service ports to be released.");
2649
+ }
2550
2650
  cleanupRun();
2551
2651
  }
2552
2652
  } finally {
@@ -2674,8 +2774,17 @@ program.command("init").description("Create a .localghost config for this projec
2674
2774
  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
2775
  console.log(formatLocalghostAgentGuide(options.json ? "json" : "text"));
2676
2776
  });
2677
- program.command("doctor").description("Check machine prerequisites").action(async () => {
2678
- const result = await runDoctor();
2777
+ program.command("doctor").description("Check machine prerequisites, ports, and Localghost registry state").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to inspect. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--json", "Print raw JSON").action(async (options) => {
2778
+ const result = await runDoctor({
2779
+ cwd: options.cwd,
2780
+ ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
2781
+ ...options.configPattern ? { configPattern: options.configPattern } : {}
2782
+ });
2783
+ if (options.json) {
2784
+ console.log(JSON.stringify(result, null, 2));
2785
+ if (!result.ok) process.exitCode = 1;
2786
+ return;
2787
+ }
2679
2788
  if (result.caddy.found) {
2680
2789
  console.log(`Caddy: ${result.caddy.version ?? "found"}`);
2681
2790
  } else {
@@ -2683,6 +2792,17 @@ program.command("doctor").description("Check machine prerequisites").action(asyn
2683
2792
  console.log(`Run: ${result.caddy.installHint}`);
2684
2793
  console.log("Localghost will not install it for you. No surprise spells.");
2685
2794
  }
2795
+ if (result.ports.configured === void 0) {
2796
+ console.log("Port: could not resolve project configuration.");
2797
+ } else {
2798
+ console.log(`Port ${result.ports.configured}: ${result.ports.available ? "available" : "occupied"}`);
2799
+ }
2800
+ if (result.ports.staleLeases.length > 0) {
2801
+ console.log(`Registry: ${result.ports.staleLeases.length} stale lease(s); run localghost repair --prune-registry.`);
2802
+ }
2803
+ for (const duplicate of result.ports.duplicateAllocations) {
2804
+ console.log(`Registry: port ${duplicate.port} is allocated to ${duplicate.projects.join(", ")}.`);
2805
+ }
2686
2806
  if (!result.ok) {
2687
2807
  process.exitCode = 1;
2688
2808
  }
@@ -2789,11 +2909,20 @@ program.command("trust").description("Trust Caddy's local HTTPS CA for this proj
2789
2909
  await validateCaddyfile(caddyfile);
2790
2910
  await runTrust(options.cwd, caddyfile);
2791
2911
  });
2792
- program.command("repair").description("Reconcile stale hosts, Caddyfile, setup state, and optional HTTPS trust").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", "Repair an HTTPS Caddy setup").option("--ssl", "Alias for --https").option("--trust", "Re-run Caddy's local HTTPS trust step").action(async (options) => {
2912
+ program.command("repair").description("Reconcile stale setup, ports, registry state, and optional HTTPS trust").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", "Repair an HTTPS Caddy setup").option("--ssl", "Alias for --https").option("--trust", "Re-run Caddy's local HTTPS trust step").option("--reallocate-port", "Persist a stable replacement for an occupied port").option("--prune-registry", "Remove expired or dead registry leases").action(async (options) => {
2793
2913
  assertLocalDevelopment("repair");
2794
2914
  printLocalghostBanner();
2795
2915
  await assertCaddyReady();
2796
- const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
2916
+ const registry = createLocalghostRegistry({ cwd: options.cwd });
2917
+ if (options.pruneRegistry) {
2918
+ const result = await registry.prune();
2919
+ console.log(`Pruned ${result.removedLeases} stale registry lease${result.removedLeases === 1 ? "" : "s"}.`);
2920
+ }
2921
+ const context = await resolveLocalghostContext({
2922
+ ...contextOptionsFromCli(options),
2923
+ dynamicPort: options.reallocatePort ? true : false,
2924
+ ...options.reallocatePort ? { reservePort: true, instanceKey: "run" } : {}
2925
+ });
2797
2926
  const readiness = getSetupReadiness({
2798
2927
  ...options,
2799
2928
  https: context.https,
@@ -2806,14 +2935,21 @@ program.command("repair").description("Reconcile stale hosts, Caddyfile, setup s
2806
2935
  }
2807
2936
  warnAboutLocalMdns(context.entries);
2808
2937
  logDomainRoutes(context.entries, { https: context.https, ghostTunnel: context.ghostTunnel });
2809
- await runSetupFromReadiness(options.cwd, context.https, readiness);
2810
- if (options.trust) {
2811
- await runTrust(options.cwd, readiness.caddyfilePath);
2812
- }
2813
- console.log(`Repaired hosts: ${getSystemHostsPath()}`);
2814
- console.log(`Repaired Caddyfile: ${readiness.caddyfilePath}`);
2815
- console.log(`Repaired state: ${readiness.statePath}`);
2816
- console.log("Repair complete.");
2938
+ try {
2939
+ await runSetupFromReadiness(options.cwd, context.https, readiness);
2940
+ if (options.trust) {
2941
+ await runTrust(options.cwd, readiness.caddyfilePath);
2942
+ }
2943
+ if (context.port !== context.requestedPort) {
2944
+ console.log(`Reallocated port ${context.requestedPort} -> ${context.port}.`);
2945
+ }
2946
+ console.log(`Repaired hosts: ${getSystemHostsPath()}`);
2947
+ console.log(`Repaired Caddyfile: ${readiness.caddyfilePath}`);
2948
+ console.log(`Repaired state: ${readiness.statePath}`);
2949
+ console.log("Repair complete.");
2950
+ } finally {
2951
+ await context.releasePort?.();
2952
+ }
2817
2953
  });
2818
2954
  program.command("reset").description("Remove Localghost setup state without deleting .localghost").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).action(async (options) => {
2819
2955
  assertLocalDevelopment("reset");
@@ -2969,7 +3105,7 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
2969
3105
  ...typeof options.trust === "boolean" ? { trust: options.trust } : {}
2970
3106
  });
2971
3107
  } catch (error) {
2972
- if (!caddy.killed) caddy.kill("SIGINT");
3108
+ signalManagedProcess(caddy, "SIGINT");
2973
3109
  throw error;
2974
3110
  }
2975
3111
  const caddyPid = maybePid(caddy.pid);
@@ -2980,6 +3116,7 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
2980
3116
  configPath: readiness.configPath,
2981
3117
  caddyfilePath: caddyfile,
2982
3118
  ...caddyPid ? { caddyPid } : {},
3119
+ ...caddyPid ? { caddyPgid: caddyPid } : {},
2983
3120
  https,
2984
3121
  entries: readiness.entries
2985
3122
  });
@@ -3049,7 +3186,7 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
3049
3186
  ...typeof options.trust === "boolean" ? { trust: options.trust } : {}
3050
3187
  });
3051
3188
  } catch (error) {
3052
- if (!caddy.killed) caddy.kill("SIGINT");
3189
+ signalManagedProcess(caddy, "SIGINT");
3053
3190
  throw error;
3054
3191
  }
3055
3192
  const [binary, ...args] = command;
@@ -3059,6 +3196,7 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
3059
3196
  const child = execa4(binary, args, {
3060
3197
  cwd: options.cwd,
3061
3198
  stdio: "inherit",
3199
+ detached: process.platform !== "win32",
3062
3200
  env: {
3063
3201
  ...process.env,
3064
3202
  LOCALGHOST_PORT: String(context.port),
@@ -3075,6 +3213,7 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
3075
3213
  configPath: context.configPath,
3076
3214
  caddyfilePath: caddyfile,
3077
3215
  ...caddyPid ? { caddyPid } : {},
3216
+ ...caddyPid ? { caddyPgid: caddyPid } : {},
3078
3217
  ...childPid ? { childPid } : {},
3079
3218
  childCommand: command,
3080
3219
  https,
@@ -3085,10 +3224,10 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
3085
3224
  });
3086
3225
  const cleanupRun = registerCleanup(runRecord.id);
3087
3226
  const stopCaddy = () => {
3088
- if (!caddy.killed) caddy.kill("SIGINT");
3227
+ signalManagedProcess(caddy, "SIGINT");
3089
3228
  };
3090
3229
  const stopChild = () => {
3091
- if (!child.killed) child.kill("SIGINT");
3230
+ signalManagedProcess(child, "SIGINT");
3092
3231
  };
3093
3232
  try {
3094
3233
  await Promise.race([child, caddyExit]);
@@ -3096,6 +3235,9 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
3096
3235
  stopChild();
3097
3236
  stopCaddy();
3098
3237
  await Promise.allSettled([child, caddyExit]);
3238
+ if (!await waitForPortsToBeAvailable(context.entries)) {
3239
+ console.warn("Localghost: timed out waiting for service ports to be released.");
3240
+ }
3099
3241
  cleanupRun();
3100
3242
  await context.releasePort?.();
3101
3243
  }