@hamedb89/localghost 0.1.13 → 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/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 } : {},
@@ -250,7 +251,12 @@ function getProjectName(cwd = process.cwd()) {
250
251
  }
251
252
  }
252
253
  function sanitizeProjectName(value) {
253
- const projectName = value.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
254
+ const sanitized = value.replace(/[^\w.-]+/g, "-");
255
+ let start = 0;
256
+ let end = sanitized.length;
257
+ while (start < end && sanitized.charCodeAt(start) === 45) start += 1;
258
+ while (end > start && sanitized.charCodeAt(end - 1) === 45) end -= 1;
259
+ const projectName = sanitized.slice(start, end);
254
260
  return projectName || "app";
255
261
  }
256
262
 
@@ -331,9 +337,30 @@ async function validateCaddyfile(path) {
331
337
  function startCaddy(path) {
332
338
  return execa("caddy", ["run", "--config", path], {
333
339
  cwd: dirname3(path),
334
- stdio: caddyStdio()
340
+ stdio: caddyStdio(),
341
+ detached: process.platform !== "win32"
335
342
  });
336
343
  }
344
+ function stopCaddyProcesses(pids, killProcess = (pid, signal) => process.kill(pid, signal)) {
345
+ const result = {
346
+ stopped: [],
347
+ alreadyExited: [],
348
+ failed: []
349
+ };
350
+ for (const pid of new Set(pids)) {
351
+ try {
352
+ killProcess(pid, "SIGINT");
353
+ result.stopped.push(pid);
354
+ } catch (error) {
355
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
356
+ result.alreadyExited.push(pid);
357
+ } else {
358
+ result.failed.push({ pid, error });
359
+ }
360
+ }
361
+ }
362
+ return result;
363
+ }
337
364
  async function trustCaddy(path) {
338
365
  await execa("caddy", ["trust", "--config", path], {
339
366
  cwd: dirname3(path),
@@ -454,19 +481,19 @@ function formatDetectedDevServices(services) {
454
481
 
455
482
  // src/context.ts
456
483
  import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
457
- import { join as join5 } from "path";
484
+ import { join as join6 } from "path";
458
485
  import { pathToFileURL } from "url";
459
486
 
460
487
  // src/port.ts
461
488
  import { createServer } from "net";
462
489
  async function isPortAvailable(port, host = "127.0.0.1") {
463
- return new Promise((resolve3) => {
490
+ return new Promise((resolve4) => {
464
491
  const server = createServer();
465
492
  server.once("error", () => {
466
- resolve3(false);
493
+ resolve4(false);
467
494
  });
468
495
  server.once("listening", () => {
469
- server.close(() => resolve3(true));
496
+ server.close(() => resolve4(true));
470
497
  });
471
498
  server.listen(port, host);
472
499
  });
@@ -483,6 +510,188 @@ async function findAvailablePort(startPort, options = {}) {
483
510
  throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
484
511
  }
485
512
 
513
+ // src/registry.ts
514
+ import { randomUUID } from "crypto";
515
+ import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
516
+ import { homedir as homedir2 } from "os";
517
+ import { join as join5, normalize, resolve as resolve3 } from "path";
518
+ var LOCALGHOST_REGISTRY_FILE = "registry.json";
519
+ var LOCALGHOST_REGISTRY_LOCK_FILE = "registry.lock";
520
+ function defaultProcessRunning(pid) {
521
+ if (pid <= 0) return false;
522
+ try {
523
+ process.kill(pid, 0);
524
+ return true;
525
+ } catch (error) {
526
+ return error.code === "EPERM";
527
+ }
528
+ }
529
+ function getLocalghostRegistryRoot(env = process.env) {
530
+ return resolve3(env.LOCALGHOST_HOME || join5(homedir2(), ".localghost"));
531
+ }
532
+ function canonicalizeLocalghostProjectCwd(cwd = process.cwd()) {
533
+ return normalize(resolve3(cwd));
534
+ }
535
+ function emptyRegistry() {
536
+ return { version: 1, allocations: [], leases: [] };
537
+ }
538
+ function leaseKey(projectCwd, instanceKey) {
539
+ return `${projectCwd}\0${instanceKey}`;
540
+ }
541
+ function validRegistry(value) {
542
+ if (!value || typeof value !== "object") return false;
543
+ const candidate = value;
544
+ return candidate.version === 1 && Array.isArray(candidate.allocations) && Array.isArray(candidate.leases);
545
+ }
546
+ function pruneRegistry(registry, now, isRunning) {
547
+ registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
548
+ }
549
+ async function readJson(path) {
550
+ try {
551
+ return JSON.parse(await readFile(path, "utf8"));
552
+ } catch (error) {
553
+ if (error.code === "ENOENT") return void 0;
554
+ return void 0;
555
+ }
556
+ }
557
+ function createLocalghostRegistry(options = {}) {
558
+ const root = resolve3(options.stateRoot ?? getLocalghostRegistryRoot());
559
+ const registryPath = join5(root, LOCALGHOST_REGISTRY_FILE);
560
+ const lockPath = join5(root, LOCALGHOST_REGISTRY_LOCK_FILE);
561
+ const cwd = canonicalizeLocalghostProjectCwd(options.cwd);
562
+ const now = options.now ?? Date.now;
563
+ const pid = options.pid ?? process.pid;
564
+ const ownerToken = options.ownerToken ?? randomUUID();
565
+ const isRunning = options.isProcessRunning ?? defaultProcessRunning;
566
+ const availabilityCheck = options.availabilityCheck ?? isPortAvailable;
567
+ const lockTimeoutMs = options.lockTimeoutMs ?? 5e3;
568
+ const lockRetryMs = options.lockRetryMs ?? 25;
569
+ const lockStaleMs = options.lockStaleMs ?? 3e4;
570
+ async function readRegistry() {
571
+ const value = await readJson(registryPath);
572
+ return validRegistry(value) ? value : emptyRegistry();
573
+ }
574
+ async function writeRegistry(registry) {
575
+ await mkdir(root, { recursive: true });
576
+ const temporaryPath = join5(root, `.registry.${process.pid}.${randomUUID()}.tmp`);
577
+ await writeFile(temporaryPath, `${JSON.stringify(registry, null, 2)}
578
+ `, { mode: 384 });
579
+ await rename(temporaryPath, registryPath);
580
+ }
581
+ async function lock() {
582
+ await mkdir(root, { recursive: true });
583
+ const deadline = now() + lockTimeoutMs;
584
+ const token = randomUUID();
585
+ while (true) {
586
+ try {
587
+ const handle = await open(lockPath, "wx", 384);
588
+ await handle.writeFile(`${JSON.stringify({ pid, createdAt: now(), token })}
589
+ `);
590
+ await handle.close();
591
+ return async () => {
592
+ const current = await readJson(lockPath);
593
+ if (current?.token === token) await unlink(lockPath).catch(() => void 0);
594
+ };
595
+ } catch (error) {
596
+ if (error.code !== "EEXIST") throw error;
597
+ const lockInfo = await readJson(lockPath);
598
+ let stale = false;
599
+ if (lockInfo && typeof lockInfo.pid === "number") {
600
+ stale = !isRunning(lockInfo.pid) && now() - lockInfo.createdAt >= 0;
601
+ } else {
602
+ try {
603
+ stale = now() - (await stat(lockPath)).mtimeMs > lockStaleMs;
604
+ } catch {
605
+ continue;
606
+ }
607
+ }
608
+ if (stale) {
609
+ await rm(lockPath, { force: true }).catch(() => void 0);
610
+ continue;
611
+ }
612
+ if (now() >= deadline) throw new Error(`Timed out waiting for Localghost registry lock: ${lockPath}`);
613
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, lockRetryMs));
614
+ }
615
+ }
616
+ }
617
+ async function withLock(operation) {
618
+ const releaseLock = await lock();
619
+ try {
620
+ const registry = await readRegistry();
621
+ pruneRegistry(registry, now(), isRunning);
622
+ return await operation(registry);
623
+ } finally {
624
+ await releaseLock();
625
+ }
626
+ }
627
+ return {
628
+ root,
629
+ registryPath,
630
+ lockPath,
631
+ ownerToken,
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
+ },
645
+ async acquirePort(acquireOptions) {
646
+ const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
647
+ if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
648
+ return withLock(async (registry) => {
649
+ const key = leaseKey(projectCwd, acquireOptions.instanceKey);
650
+ const existing = registry.allocations.find((entry2) => leaseKey(entry2.projectCwd, entry2.instanceKey) === key);
651
+ const reserved = new Set(acquireOptions.reservedPorts ?? []);
652
+ const activePorts = new Set(registry.leases.map((lease2) => lease2.port));
653
+ const port = existing?.port;
654
+ const ownsActiveLease = registry.leases.some((lease2) => lease2.port === port && leaseKey(lease2.projectCwd, lease2.instanceKey) === key && lease2.ownerToken === ownerToken);
655
+ const reusable = port !== void 0 && !reserved.has(port) && (!activePorts.has(port) || ownsActiveLease) && (ownsActiveLease || await availabilityCheck(port, acquireOptions.host));
656
+ let selectedPort = reusable ? port : void 0;
657
+ if (selectedPort === void 0) {
658
+ const startPort = acquireOptions.startPort ?? 3e3;
659
+ const maxAttempts = acquireOptions.maxAttempts ?? 50;
660
+ for (let offset = 0; offset < maxAttempts; offset += 1) {
661
+ const candidate = startPort + offset;
662
+ if (reserved.has(candidate) || activePorts.has(candidate)) continue;
663
+ if (await availabilityCheck(candidate, acquireOptions.host)) {
664
+ selectedPort = candidate;
665
+ break;
666
+ }
667
+ }
668
+ if (selectedPort === void 0) throw new Error(`No available registry port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
669
+ }
670
+ const timestamp = now();
671
+ const entry = existing ?? { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, updatedAt: timestamp };
672
+ entry.port = selectedPort;
673
+ entry.updatedAt = timestamp;
674
+ if (!existing) registry.allocations.push(entry);
675
+ registry.leases = registry.leases.filter((lease2) => leaseKey(lease2.projectCwd, lease2.instanceKey) !== key);
676
+ const lease = { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, pid, acquiredAt: timestamp, expiresAt: timestamp + (acquireOptions.leaseTtlMs ?? 30 * 60 * 1e3), ownerToken };
677
+ registry.leases.push(lease);
678
+ await writeRegistry(registry);
679
+ return lease;
680
+ });
681
+ },
682
+ async releasePort(releaseOptions) {
683
+ const projectCwd = canonicalizeLocalghostProjectCwd(releaseOptions.projectCwd ?? cwd);
684
+ return withLock(async (registry) => {
685
+ const key = leaseKey(projectCwd, releaseOptions.instanceKey);
686
+ const before = registry.leases.length;
687
+ registry.leases = registry.leases.filter((lease) => leaseKey(lease.projectCwd, lease.instanceKey) !== key || lease.ownerToken !== ownerToken);
688
+ if (registry.leases.length !== before) await writeRegistry(registry);
689
+ return registry.leases.length !== before;
690
+ });
691
+ }
692
+ };
693
+ }
694
+
486
695
  // src/tunnel.ts
487
696
  import { domainToASCII } from "url";
488
697
  var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
@@ -860,7 +1069,7 @@ function envHttps() {
860
1069
  }
861
1070
  function getPackageName(cwd) {
862
1071
  try {
863
- const pkg = JSON.parse(readFileSync5(join5(cwd, "package.json"), "utf8"));
1072
+ const pkg = JSON.parse(readFileSync5(join6(cwd, "package.json"), "utf8"));
864
1073
  return typeof pkg.name === "string" ? pkg.name : void 0;
865
1074
  } catch {
866
1075
  return void 0;
@@ -943,7 +1152,24 @@ async function resolveLocalghostContext(options = {}) {
943
1152
  const autoRepair = merged.autoRepair ?? true;
944
1153
  const bindHost = merged.bindHost ?? "127.0.0.1";
945
1154
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
946
- const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
1155
+ let port = requestedPort;
1156
+ let releasePort;
1157
+ const reservePort = merged.reservePort ?? false;
1158
+ const instanceKey = merged.instanceKey ?? "run";
1159
+ if (reservePort && dynamicPort) {
1160
+ const registry = createLocalghostRegistry({ cwd, ...merged.registryOwnerToken ? { ownerToken: merged.registryOwnerToken } : {} });
1161
+ const lease = await registry.acquirePort({
1162
+ projectCwd: cwd,
1163
+ instanceKey,
1164
+ startPort: requestedPort,
1165
+ host: probeHost,
1166
+ ...options.reservedPorts ? { reservedPorts: options.reservedPorts } : {}
1167
+ });
1168
+ port = lease.port;
1169
+ releasePort = () => registry.releasePort({ projectCwd: cwd, instanceKey });
1170
+ } else if (dynamicPort) {
1171
+ port = await findAvailablePort(requestedPort, { host: probeHost });
1172
+ }
947
1173
  const wwwAlias = merged.wwwAlias ?? true;
948
1174
  const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
949
1175
  const hosts = uniqueHosts(entries);
@@ -972,7 +1198,8 @@ async function resolveLocalghostContext(options = {}) {
972
1198
  https: merged.https ?? envHttps() ?? false,
973
1199
  wwwAlias,
974
1200
  ghostTunnel,
975
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
1201
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
1202
+ ...releasePort ? { releasePort } : {}
976
1203
  };
977
1204
  }
978
1205
 
@@ -994,11 +1221,52 @@ async function checkCaddy() {
994
1221
  };
995
1222
  }
996
1223
  }
997
- async function runDoctor() {
1224
+ async function runDoctor(options = {}) {
998
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);
999
1253
  return {
1000
- ok: caddy.found,
1001
- 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
+ }
1002
1270
  };
1003
1271
  }
1004
1272
 
@@ -1041,7 +1309,7 @@ function listGhostTunnelEntries(options = {}) {
1041
1309
  }
1042
1310
 
1043
1311
  // src/ghost-agent.ts
1044
- import { randomUUID as randomUUID2 } from "crypto";
1312
+ import { randomUUID as randomUUID3 } from "crypto";
1045
1313
 
1046
1314
  // src/relay.ts
1047
1315
  import { createHmac, timingSafeEqual } from "crypto";
@@ -1153,7 +1421,7 @@ function stripRelayForwardHeaders(headers) {
1153
1421
  }
1154
1422
 
1155
1423
  // src/ghost-tunnel-store.ts
1156
- import { randomUUID } from "crypto";
1424
+ import { randomUUID as randomUUID2 } from "crypto";
1157
1425
  function base64Encode(value) {
1158
1426
  return value.toString("base64");
1159
1427
  }
@@ -1191,13 +1459,18 @@ function parseJson(value) {
1191
1459
  function keyPart(value) {
1192
1460
  return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
1193
1461
  }
1462
+ function removeTrailingSlashes(value) {
1463
+ let end = value.length;
1464
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
1465
+ return value.slice(0, end);
1466
+ }
1194
1467
  var RedisGhostTunnelStore = class {
1195
1468
  url;
1196
1469
  token;
1197
1470
  namespace;
1198
1471
  fetchImpl;
1199
1472
  constructor(options) {
1200
- this.url = options.url.replace(/\/+$/, "");
1473
+ this.url = removeTrailingSlashes(options.url);
1201
1474
  this.token = options.token;
1202
1475
  this.namespace = options.namespace ?? "localghost";
1203
1476
  this.fetchImpl = options.fetch ?? fetch;
@@ -1285,11 +1558,11 @@ function isStopped(signal, localSignal) {
1285
1558
  }
1286
1559
  function wait(ms, signal, localSignal) {
1287
1560
  if (isStopped(signal, localSignal)) return Promise.resolve();
1288
- return new Promise((resolve3) => {
1289
- const timeout = setTimeout(resolve3, ms);
1561
+ return new Promise((resolve4) => {
1562
+ const timeout = setTimeout(resolve4, ms);
1290
1563
  const stop = () => {
1291
1564
  clearTimeout(timeout);
1292
- resolve3();
1565
+ resolve4();
1293
1566
  };
1294
1567
  signal?.addEventListener("abort", stop, { once: true });
1295
1568
  localSignal.addEventListener("abort", stop, { once: true });
@@ -1375,7 +1648,7 @@ function startGhostTunnelAgent(options) {
1375
1648
  const controller = new AbortController();
1376
1649
  const localSignal = controller.signal;
1377
1650
  const signal = options.signal;
1378
- const agentId = options.agentId ?? `localghost-${randomUUID2()}`;
1651
+ const agentId = options.agentId ?? `localghost-${randomUUID3()}`;
1379
1652
  const targetHost = options.targetHost ?? "127.0.0.1";
1380
1653
  const routeTtlSeconds = options.routeTtlSeconds ?? 30;
1381
1654
  const requestTtlSeconds = options.requestTtlSeconds ?? 60;
@@ -1430,7 +1703,7 @@ function startGhostTunnelAgent(options) {
1430
1703
  // src/hosts-file.ts
1431
1704
  import { writeFileSync as writeFileSync3 } from "fs";
1432
1705
  import { tmpdir } from "os";
1433
- import { join as join6 } from "path";
1706
+ import { join as join7 } from "path";
1434
1707
  import { execa as execa3 } from "execa";
1435
1708
  function escapeRegExp(value) {
1436
1709
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1473,7 +1746,7 @@ function removeManagedBlock(existing, projectName) {
1473
1746
  }
1474
1747
  async function writeSystemHostsFile(hostsPath, next, projectName) {
1475
1748
  const sanitizedProjectName = sanitizeProjectName(projectName);
1476
- const tempPath = join6(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
1749
+ const tempPath = join7(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
1477
1750
  writeFileSync3(tempPath, next, "utf8");
1478
1751
  if (process.env.LOCALGHOST_HOSTS_PATH) {
1479
1752
  writeFileSync3(hostsPath, next, "utf8");
@@ -1511,11 +1784,11 @@ async function removeSystemHosts(projectName) {
1511
1784
 
1512
1785
  // src/init.ts
1513
1786
  import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1514
- import { join as join7 } from "path";
1787
+ import { join as join8 } from "path";
1515
1788
  function detectPackageManager(cwd = process.cwd()) {
1516
- if (existsSync5(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
1517
- if (existsSync5(join7(cwd, "yarn.lock"))) return "yarn";
1518
- if (existsSync5(join7(cwd, "bun.lock")) || existsSync5(join7(cwd, "bun.lockb"))) return "bun";
1789
+ if (existsSync5(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
1790
+ if (existsSync5(join8(cwd, "yarn.lock"))) return "yarn";
1791
+ if (existsSync5(join8(cwd, "bun.lock")) || existsSync5(join8(cwd, "bun.lockb"))) return "bun";
1519
1792
  return "npm";
1520
1793
  }
1521
1794
  function packageRunCommand(packageManager, script) {
@@ -1589,7 +1862,7 @@ function initLocalghost(options = {}) {
1589
1862
  const apiPort = options.apiPort ?? 8787;
1590
1863
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
1591
1864
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
1592
- const configPath = join7(cwd, configFile);
1865
+ const configPath = join8(cwd, configFile);
1593
1866
  const configExists = existsSync5(configPath);
1594
1867
  if (configExists && !options.force) {
1595
1868
  return {
@@ -1606,7 +1879,7 @@ function initLocalghost(options = {}) {
1606
1879
  };
1607
1880
  }
1608
1881
  writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
1609
- const packageJsonPath = join7(cwd, "package.json");
1882
+ const packageJsonPath = join8(cwd, "package.json");
1610
1883
  const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
1611
1884
  return {
1612
1885
  configPath,
@@ -1623,6 +1896,63 @@ function initLocalghost(options = {}) {
1623
1896
  };
1624
1897
  }
1625
1898
 
1899
+ // src/guide.ts
1900
+ var LOCALGHOST_AGENT_GUIDE = `# Localghost agent guide
1901
+
1902
+ Localghost owns the local development proxy and the app process boundary.
1903
+
1904
+ ## Preferred repository setup
1905
+
1906
+ For a normal repository, use this package script:
1907
+
1908
+ "dev": "localghost"
1909
+
1910
+ For an explicit app command, keep the raw command separate:
1911
+
1912
+ "dev": "localghost run -- vite"
1913
+ "dev:raw": "vite"
1914
+
1915
+ Use \`localghost dev\` only when the Caddy proxy should run without starting the app.
1916
+
1917
+ ## Useful commands
1918
+
1919
+ - \`localghost\`: detect and run the repository development command.
1920
+ - \`localghost run -- <command>\`: wrap an explicit app command.
1921
+ - \`localghost dev\`: run only the local Caddy proxy.
1922
+ - \`localghost status --ready\`: check project setup.
1923
+ - \`localghost repair\`: repair managed hosts and Caddy setup.
1924
+ - \`localghost ps --json\`: inspect Localghost-managed repositories, instances, and ports.
1925
+ - \`localghost routes\`: inspect hostname-to-port routing.
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.
1928
+
1929
+ ## Configuration
1930
+
1931
+ - Commit repository defaults in \`localghost.config.mjs\`.
1932
+ - Keep hostname and requested-port routes in \`.localghost\`.
1933
+ - CLI flags override repository configuration for one invocation.
1934
+ - Localghost remembers active project and instance port assignments in user state under \`~/.localghost\`.
1935
+ - Do not edit the registry manually and do not start Caddy separately.
1936
+
1937
+ ## Port behavior
1938
+
1939
+ 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.
1940
+ `;
1941
+ function formatLocalghostAgentGuide(format = "text") {
1942
+ if (format === "json") {
1943
+ return JSON.stringify({
1944
+ preferredScript: "localghost",
1945
+ explicitScript: "localghost run -- <command>",
1946
+ proxyOnlyCommand: "localghost dev",
1947
+ inspectionCommands: ["localghost status --ready", "localghost ps --json", "localghost routes", "localghost doctor"],
1948
+ projectConfig: "localghost.config.mjs",
1949
+ routeConfig: ".localghost",
1950
+ userState: "~/.localghost"
1951
+ }, null, 2);
1952
+ }
1953
+ return LOCALGHOST_AGENT_GUIDE;
1954
+ }
1955
+
1626
1956
  // src/prompt.ts
1627
1957
  import { stdin as input, stdout as output } from "process";
1628
1958
  import { createInterface } from "readline/promises";
@@ -1711,10 +2041,10 @@ function formatGhostTunnel(config, options = {}) {
1711
2041
 
1712
2042
  // src/state.ts
1713
2043
  import { existsSync as existsSync6 } from "fs";
1714
- import { join as join8 } from "path";
2044
+ import { join as join9 } from "path";
1715
2045
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
1716
2046
  function getLocalghostStatePath(cwd = process.cwd()) {
1717
- return join8(cwd, LOCALGHOST_STATE_FILE);
2047
+ return join9(cwd, LOCALGHOST_STATE_FILE);
1718
2048
  }
1719
2049
  function readLocalghostState(cwd = process.cwd()) {
1720
2050
  const path = getLocalghostStatePath(cwd);
@@ -1735,10 +2065,10 @@ function patchLocalghostState(cwd, patch) {
1735
2065
 
1736
2066
  // src/update-check.ts
1737
2067
  import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
1738
- import { homedir as homedir2 } from "os";
1739
- import { dirname as dirname4, join as join9 } from "path";
2068
+ import { homedir as homedir3 } from "os";
2069
+ import { dirname as dirname4, join as join10 } from "path";
1740
2070
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
1741
- var LOCALGHOST_VERSION = "0.1.13";
2071
+ var LOCALGHOST_VERSION = "0.2.0";
1742
2072
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1743
2073
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
1744
2074
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -1750,8 +2080,8 @@ function isUpdateCheckDisabled(env = process.env) {
1750
2080
  }
1751
2081
  function getUpdateCheckCachePath(env = process.env) {
1752
2082
  if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
1753
- const cacheRoot = env.XDG_CACHE_HOME || join9(homedir2(), ".cache");
1754
- return join9(cacheRoot, "localghost", "update-check.json");
2083
+ const cacheRoot = env.XDG_CACHE_HOME || join10(homedir3(), ".cache");
2084
+ return join10(cacheRoot, "localghost", "update-check.json");
1755
2085
  }
1756
2086
  function readCache(path = getUpdateCheckCachePath()) {
1757
2087
  if (!existsSync7(path)) return null;
@@ -1803,7 +2133,7 @@ function isNewerVersion(candidate, current = LOCALGHOST_VERSION) {
1803
2133
  return Boolean(candidate && compareVersions(candidate, current) > 0);
1804
2134
  }
1805
2135
  async function fetchLatestVersion(packageName, timeoutMs) {
1806
- const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).replace("/", "%2f")}` : packageName;
2136
+ const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).replaceAll("/", "%2f")}` : packageName;
1807
2137
  const response = await fetch(`https://registry.npmjs.org/${encodedName}`, {
1808
2138
  signal: AbortSignal.timeout(timeoutMs),
1809
2139
  headers: {
@@ -1902,6 +2232,27 @@ ${message}`);
1902
2232
 
1903
2233
  // src/cli.ts
1904
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
1905
2256
  function warnAboutLocalMdns(entries) {
1906
2257
  const localHosts = findLocalMdnsHosts(entries);
1907
2258
  if (localHosts.length > 0) {
@@ -1938,6 +2289,10 @@ function parsePackageManager(value) {
1938
2289
  if (value === "npm" || value === "yarn" || value === "pnpm" || value === "bun") return value;
1939
2290
  throw new InvalidArgumentError("Package manager must be npm, pnpm, yarn, or bun.");
1940
2291
  }
2292
+ function parseReleaseBump(value) {
2293
+ if (value === "patch" || value === "minor" || value === "major") return value;
2294
+ throw new InvalidArgumentError("Release bump must be patch, minor, or major.");
2295
+ }
1941
2296
  function collect(value, previous = []) {
1942
2297
  return [...previous, value];
1943
2298
  }
@@ -1975,6 +2330,33 @@ async function assertCaddyReady() {
1975
2330
  "Localghost will not install it for you. No surprise spells."
1976
2331
  ].join("\n"));
1977
2332
  }
2333
+ function cleanManagedCaddyProcesses() {
2334
+ const runs = listLocalghostRuns();
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
+ };
2344
+ for (const run of runs) {
2345
+ const caddyIdentity = run.caddyPgid ?? run.caddyPid;
2346
+ if (caddyIdentity && (result.stopped.includes(caddyIdentity) || result.alreadyExited.includes(caddyIdentity))) {
2347
+ unregisterLocalghostRun(run.id);
2348
+ }
2349
+ }
2350
+ if (result.stopped.length > 0) {
2351
+ console.log(`Stopped ${result.stopped.length} Localghost-managed Caddy process${result.stopped.length === 1 ? "" : "es"}.`);
2352
+ }
2353
+ if (result.alreadyExited.length > 0) {
2354
+ console.log(`Removed ${result.alreadyExited.length} stale Localghost Caddy record${result.alreadyExited.length === 1 ? "" : "s"}.`);
2355
+ }
2356
+ if (result.failed.length > 0) {
2357
+ throw new Error(`Could not stop Localghost-managed Caddy PID(s): ${result.failed.map(({ pid }) => pid).join(", ")}.`);
2358
+ }
2359
+ }
1978
2360
  function existingTrustMarkers(cwd) {
1979
2361
  const state = readLocalghostState(cwd);
1980
2362
  return {
@@ -2079,7 +2461,7 @@ async function runSetupFromReadiness(cwd, https, readiness) {
2079
2461
  });
2080
2462
  }
2081
2463
  function wait2(ms) {
2082
- return new Promise((resolve3) => setTimeout(resolve3, ms));
2464
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
2083
2465
  }
2084
2466
  async function runTrust(cwd, caddyfilePath) {
2085
2467
  await wait2(350);
@@ -2126,25 +2508,19 @@ function registerCleanup(id) {
2126
2508
  process.off("exit", cleanup);
2127
2509
  };
2128
2510
  }
2129
- async function resolveServiceRuntimeEntries(services, dynamicPort) {
2511
+ async function resolveServiceRuntimeEntries(services, dynamicPort, projectCwd) {
2130
2512
  const usedPorts = /* @__PURE__ */ new Set();
2131
2513
  const resolved = [];
2514
+ const registry = dynamicPort ? createLocalghostRegistry({ cwd: projectCwd, ownerToken: `${process.pid}:services:${projectCwd}` }) : void 0;
2132
2515
  for (const service of services) {
2133
2516
  let port = service.requestedPort;
2134
2517
  if (dynamicPort) {
2135
- let found = false;
2136
- for (let offset = 0; offset < 50; offset += 1) {
2137
- const candidate = service.requestedPort + offset;
2138
- if (candidate > 65535 || usedPorts.has(candidate)) continue;
2139
- if (await isPortAvailable(candidate)) {
2140
- port = candidate;
2141
- found = true;
2142
- break;
2143
- }
2144
- }
2145
- if (!found) {
2146
- throw new Error(`No available port found for service ${service.name} from ${service.requestedPort}.`);
2147
- }
2518
+ const lease = await registry.acquirePort({
2519
+ instanceKey: `service:${service.name}`,
2520
+ startPort: service.requestedPort,
2521
+ reservedPorts: usedPorts
2522
+ });
2523
+ port = lease.port;
2148
2524
  } else if (usedPorts.has(port)) {
2149
2525
  throw new Error(`Services cannot start separate commands on the same fixed port: ${port}.`);
2150
2526
  }
@@ -2159,7 +2535,13 @@ async function resolveServiceRuntimeEntries(services, dynamicPort) {
2159
2535
  }
2160
2536
  });
2161
2537
  }
2162
- return resolved;
2538
+ return {
2539
+ services: resolved,
2540
+ release: async () => {
2541
+ if (!registry) return;
2542
+ await Promise.all(resolved.map((service) => registry.releasePort({ instanceKey: `service:${service.name}` })));
2543
+ }
2544
+ };
2163
2545
  }
2164
2546
  async function waitForServicePorts(entries, timeoutMs = 1e4) {
2165
2547
  const deadline = Date.now() + timeoutMs;
@@ -2171,84 +2553,104 @@ async function waitForServicePorts(entries, timeoutMs = 1e4) {
2171
2553
  }
2172
2554
  return false;
2173
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
+ }
2174
2566
  async function runDetectedServices(options) {
2175
2567
  assertLocalDevelopment("run");
2176
2568
  await assertCaddyReady();
2177
- const runtimeServices = await resolveServiceRuntimeEntries(options.services, options.dynamicPort);
2178
- const entries = runtimeServices.map((service) => service.entry);
2179
- const readiness = getSetupReadiness({
2180
- cwd: options.cwd,
2181
- https: options.https,
2182
- ignoreCaddyfile: true,
2183
- entries,
2184
- configPath: options.configPath,
2185
- projectName: options.projectName
2186
- });
2187
- if (!readiness.ready) {
2188
- if (!options.autoRepair) {
2189
- throw new Error([
2190
- "Localghost setup is missing or stale.",
2191
- ...readiness.reasons.map((reason) => `- ${reason}`),
2192
- "Automatic repair is disabled. Enable autoRepair or run localghost repair."
2193
- ].join("\n"));
2194
- }
2195
- console.log("Localghost setup is stale; repairing it now.");
2196
- await runSetupFromReadiness(options.cwd, options.https, readiness);
2197
- }
2198
- for (const service of runtimeServices) {
2199
- if (service.port !== service.requestedPort) {
2200
- console.log(`${service.name}: port ${service.requestedPort} is busy; using ${service.port}.`);
2569
+ const runtime = await resolveServiceRuntimeEntries(options.services, options.dynamicPort, options.cwd);
2570
+ try {
2571
+ const runtimeServices = runtime.services;
2572
+ const entries = runtimeServices.map((service) => service.entry);
2573
+ const readiness = getSetupReadiness({
2574
+ cwd: options.cwd,
2575
+ https: options.https,
2576
+ ignoreCaddyfile: true,
2577
+ entries,
2578
+ configPath: options.configPath,
2579
+ projectName: options.projectName
2580
+ });
2581
+ if (!readiness.ready) {
2582
+ if (!options.autoRepair) {
2583
+ throw new Error([
2584
+ "Localghost setup is missing or stale.",
2585
+ ...readiness.reasons.map((reason) => `- ${reason}`),
2586
+ "Automatic repair is disabled. Enable autoRepair or run localghost repair."
2587
+ ].join("\n"));
2588
+ }
2589
+ console.log("Localghost setup is stale; repairing it now.");
2590
+ await runSetupFromReadiness(options.cwd, options.https, readiness);
2201
2591
  }
2202
- }
2203
- const caddyfile = await writeCaddyfile(entries, options.cwd, { https: options.https });
2204
- await validateCaddyfile(caddyfile);
2205
- const caddy = startCaddy(caddyfile);
2206
- const caddyExit = caddy.catch((error) => {
2207
- if (!caddy.killed) throw error;
2208
- });
2209
- const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
2210
- cwd: service.cwd,
2211
- stdio: "inherit",
2212
- env: {
2213
- ...process.env,
2214
- LOCALGHOST_PORT: String(service.port),
2215
- LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
2216
- LOCALGHOST_SERVICE: service.name,
2217
- VITE_PORT: String(service.port)
2592
+ for (const service of runtimeServices) {
2593
+ if (service.port !== service.requestedPort) {
2594
+ console.log(`${service.name}: port ${service.requestedPort} is busy; using ${service.port}.`);
2595
+ }
2218
2596
  }
2219
- }));
2220
- const caddyPid = maybePid(caddy.pid);
2221
- const runRecord = registerLocalghostRun({
2222
- mode: "run",
2223
- cwd: options.cwd,
2224
- projectName: options.projectName,
2225
- configPath: options.configPath,
2226
- caddyfilePath: caddyfile,
2227
- ...caddyPid ? { caddyPid } : {},
2228
- childCommand: ["services", ...runtimeServices.map((service) => service.name)],
2229
- https: options.https,
2230
- dynamicPort: options.dynamicPort,
2231
- entries
2232
- });
2233
- const cleanupRun = registerCleanup(runRecord.id);
2234
- const processExit = Promise.race([caddyExit, ...children]);
2235
- try {
2236
- const ready = await Promise.race([
2237
- waitForServicePorts(entries),
2238
- processExit.then(() => false)
2239
- ]);
2240
- if (ready) {
2241
- console.log("");
2242
- logDomainRoutes(entries, { https: options.https });
2597
+ const caddyfile = await writeCaddyfile(entries, options.cwd, { https: options.https });
2598
+ await validateCaddyfile(caddyfile);
2599
+ const caddy = startCaddy(caddyfile);
2600
+ const caddyExit = caddy.catch((error) => {
2601
+ if (!caddy.killed) throw error;
2602
+ });
2603
+ const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
2604
+ cwd: service.cwd,
2605
+ stdio: "inherit",
2606
+ detached: process.platform !== "win32",
2607
+ env: {
2608
+ ...process.env,
2609
+ LOCALGHOST_PORT: String(service.port),
2610
+ LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
2611
+ LOCALGHOST_SERVICE: service.name,
2612
+ VITE_PORT: String(service.port)
2613
+ }
2614
+ }));
2615
+ const caddyPid = maybePid(caddy.pid);
2616
+ const runRecord = registerLocalghostRun({
2617
+ mode: "run",
2618
+ cwd: options.cwd,
2619
+ projectName: options.projectName,
2620
+ configPath: options.configPath,
2621
+ caddyfilePath: caddyfile,
2622
+ ...caddyPid ? { caddyPid } : {},
2623
+ ...caddyPid ? { caddyPgid: caddyPid } : {},
2624
+ childCommand: ["services", ...runtimeServices.map((service) => service.name)],
2625
+ https: options.https,
2626
+ dynamicPort: options.dynamicPort,
2627
+ entries
2628
+ });
2629
+ const cleanupRun = registerCleanup(runRecord.id);
2630
+ const processExit = Promise.race([caddyExit, ...children]);
2631
+ try {
2632
+ const ready = await Promise.race([
2633
+ waitForServicePorts(entries),
2634
+ processExit.then(() => false)
2635
+ ]);
2636
+ if (ready) {
2637
+ console.log("");
2638
+ logDomainRoutes(entries, { https: options.https });
2639
+ }
2640
+ await processExit;
2641
+ } finally {
2642
+ for (const child of children) {
2643
+ signalManagedProcess(child, "SIGINT");
2644
+ }
2645
+ signalManagedProcess(caddy, "SIGINT");
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
+ }
2650
+ cleanupRun();
2243
2651
  }
2244
- await processExit;
2245
2652
  } finally {
2246
- for (const child of children) {
2247
- if (!child.killed) child.kill("SIGINT");
2248
- }
2249
- if (!caddy.killed) caddy.kill("SIGINT");
2250
- await Promise.allSettled([caddyExit, ...children]);
2251
- cleanupRun();
2653
+ await runtime.release();
2252
2654
  }
2253
2655
  }
2254
2656
  async function getRouteViews(entries) {
@@ -2344,7 +2746,7 @@ function formatInstanceViews(instances) {
2344
2746
  var program = new Command();
2345
2747
  program.name("localghost").description("Buh. Friendly local hostnames for app repos.").version(LOCALGHOST_VERSION).option("--no-update-check", "Skip the npm update check for this run");
2346
2748
  program.hook("postAction", async (_thisCommand, actionCommand) => {
2347
- if (actionCommand.name() === "update") return;
2749
+ if (actionCommand.name() === "update" || actionCommand.name() === "release") return;
2348
2750
  const options = program.opts();
2349
2751
  await maybeNotifyAboutUpdate({ disabled: options.updateCheck === false });
2350
2752
  });
@@ -2369,8 +2771,20 @@ program.command("init").description("Create a .localghost config for this projec
2369
2771
  console.log(` ${step}`);
2370
2772
  }
2371
2773
  });
2372
- program.command("doctor").description("Check machine prerequisites").action(async () => {
2373
- const result = await runDoctor();
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) => {
2775
+ console.log(formatLocalghostAgentGuide(options.json ? "json" : "text"));
2776
+ });
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
+ }
2374
2788
  if (result.caddy.found) {
2375
2789
  console.log(`Caddy: ${result.caddy.version ?? "found"}`);
2376
2790
  } else {
@@ -2378,6 +2792,17 @@ program.command("doctor").description("Check machine prerequisites").action(asyn
2378
2792
  console.log(`Run: ${result.caddy.installHint}`);
2379
2793
  console.log("Localghost will not install it for you. No surprise spells.");
2380
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
+ }
2381
2806
  if (!result.ok) {
2382
2807
  process.exitCode = 1;
2383
2808
  }
@@ -2400,6 +2825,30 @@ program.command("update").description("Check npm for a newer localghost release"
2400
2825
  }
2401
2826
  console.log(`localghost is up to date. Current: ${result.currentVersion}`);
2402
2827
  });
2828
+ program.command("release").description("Dispatch an automated Localghost CLI release").argument("<bump>", "Semantic version increment: patch, minor, or major", parseReleaseBump).action(async (bump) => {
2829
+ const repository = "hamedb89/localghost";
2830
+ try {
2831
+ await execa4("gh", [
2832
+ "workflow",
2833
+ "run",
2834
+ "release.yml",
2835
+ "--repo",
2836
+ repository,
2837
+ "--ref",
2838
+ "main",
2839
+ "-f",
2840
+ `bump=${bump}`
2841
+ ]);
2842
+ } catch (error) {
2843
+ const detail = error instanceof Error ? error.message : String(error);
2844
+ throw new Error(
2845
+ `Could not dispatch the Localghost release workflow. Install and authenticate GitHub CLI with \`gh auth login\`, then retry.
2846
+ ${detail}`
2847
+ );
2848
+ }
2849
+ console.log(`Dispatched a ${bump} Localghost release from main.`);
2850
+ console.log(`Track it at https://github.com/${repository}/actions/workflows/release.yml`);
2851
+ });
2403
2852
  program.command("setup").description("Update /etc/hosts and generate/validate Caddyfile").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Generate a local HTTPS Caddy proxy with Caddy local certificates").option("--ssl", "Alias for --https").action(async (options) => {
2404
2853
  assertLocalDevelopment("setup");
2405
2854
  printLocalghostBanner();
@@ -2460,11 +2909,20 @@ program.command("trust").description("Trust Caddy's local HTTPS CA for this proj
2460
2909
  await validateCaddyfile(caddyfile);
2461
2910
  await runTrust(options.cwd, caddyfile);
2462
2911
  });
2463
- 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) => {
2464
2913
  assertLocalDevelopment("repair");
2465
2914
  printLocalghostBanner();
2466
2915
  await assertCaddyReady();
2467
- 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
+ });
2468
2926
  const readiness = getSetupReadiness({
2469
2927
  ...options,
2470
2928
  https: context.https,
@@ -2477,14 +2935,21 @@ program.command("repair").description("Reconcile stale hosts, Caddyfile, setup s
2477
2935
  }
2478
2936
  warnAboutLocalMdns(context.entries);
2479
2937
  logDomainRoutes(context.entries, { https: context.https, ghostTunnel: context.ghostTunnel });
2480
- await runSetupFromReadiness(options.cwd, context.https, readiness);
2481
- if (options.trust) {
2482
- await runTrust(options.cwd, readiness.caddyfilePath);
2483
- }
2484
- console.log(`Repaired hosts: ${getSystemHostsPath()}`);
2485
- console.log(`Repaired Caddyfile: ${readiness.caddyfilePath}`);
2486
- console.log(`Repaired state: ${readiness.statePath}`);
2487
- 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
+ }
2488
2953
  });
2489
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) => {
2490
2955
  assertLocalDevelopment("reset");
@@ -2600,9 +3065,10 @@ program.command("routes").description("Print domain to upstream routes").option(
2600
3065
  }));
2601
3066
  }
2602
3067
  });
2603
- program.command("dev").description("Run the Localghost Caddy proxy, repairing stale setup when needed").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
3068
+ program.command("dev").description("Run the Localghost Caddy proxy, repairing stale setup when needed").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--clean-caddy", "Stop Localghost-managed Caddy processes before starting").option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
2604
3069
  assertLocalDevelopment("dev");
2605
3070
  await assertCaddyReady();
3071
+ if (options.cleanCaddy) cleanManagedCaddyProcesses();
2606
3072
  const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
2607
3073
  const https = context.https;
2608
3074
  const readiness = getSetupReadiness({
@@ -2639,7 +3105,7 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
2639
3105
  ...typeof options.trust === "boolean" ? { trust: options.trust } : {}
2640
3106
  });
2641
3107
  } catch (error) {
2642
- if (!caddy.killed) caddy.kill("SIGINT");
3108
+ signalManagedProcess(caddy, "SIGINT");
2643
3109
  throw error;
2644
3110
  }
2645
3111
  const caddyPid = maybePid(caddy.pid);
@@ -2650,6 +3116,7 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
2650
3116
  configPath: readiness.configPath,
2651
3117
  caddyfilePath: caddyfile,
2652
3118
  ...caddyPid ? { caddyPid } : {},
3119
+ ...caddyPid ? { caddyPgid: caddyPid } : {},
2653
3120
  https,
2654
3121
  entries: readiness.entries
2655
3122
  });
@@ -2660,15 +3127,18 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
2660
3127
  cleanupRun();
2661
3128
  }
2662
3129
  });
2663
- program.command("run").description("Run Caddy and a dev command from the same Localghost context").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--port <number>", "Initial app port", parsePort2).option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the child command").option("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
3130
+ program.command("run").description("Run Caddy and a dev command from the same Localghost context").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--port <number>", "Initial app port", parsePort2).option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--clean-caddy", "Stop Localghost-managed Caddy processes before starting").option("--trust", "Trust Caddy's local HTTPS CA before starting the child command").option("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
2664
3131
  assertLocalDevelopment("run");
2665
3132
  await assertCaddyReady();
3133
+ if (options.cleanCaddy) cleanManagedCaddyProcesses();
2666
3134
  const context = await resolveLocalghostContext({
2667
3135
  cwd: options.cwd,
2668
3136
  ...options.project ? { project: options.project } : {},
2669
3137
  ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
2670
3138
  ...options.configPattern ? { configPattern: options.configPattern } : {},
2671
3139
  ...options.port ? { port: options.port } : {},
3140
+ reservePort: true,
3141
+ instanceKey: "run",
2672
3142
  ...useHttps(options) ? { https: true } : {},
2673
3143
  ...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {},
2674
3144
  ...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
@@ -2716,7 +3186,7 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
2716
3186
  ...typeof options.trust === "boolean" ? { trust: options.trust } : {}
2717
3187
  });
2718
3188
  } catch (error) {
2719
- if (!caddy.killed) caddy.kill("SIGINT");
3189
+ signalManagedProcess(caddy, "SIGINT");
2720
3190
  throw error;
2721
3191
  }
2722
3192
  const [binary, ...args] = command;
@@ -2726,6 +3196,7 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
2726
3196
  const child = execa4(binary, args, {
2727
3197
  cwd: options.cwd,
2728
3198
  stdio: "inherit",
3199
+ detached: process.platform !== "win32",
2729
3200
  env: {
2730
3201
  ...process.env,
2731
3202
  LOCALGHOST_PORT: String(context.port),
@@ -2742,6 +3213,7 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
2742
3213
  configPath: context.configPath,
2743
3214
  caddyfilePath: caddyfile,
2744
3215
  ...caddyPid ? { caddyPid } : {},
3216
+ ...caddyPid ? { caddyPgid: caddyPid } : {},
2745
3217
  ...childPid ? { childPid } : {},
2746
3218
  childCommand: command,
2747
3219
  https,
@@ -2752,10 +3224,10 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
2752
3224
  });
2753
3225
  const cleanupRun = registerCleanup(runRecord.id);
2754
3226
  const stopCaddy = () => {
2755
- if (!caddy.killed) caddy.kill("SIGINT");
3227
+ signalManagedProcess(caddy, "SIGINT");
2756
3228
  };
2757
3229
  const stopChild = () => {
2758
- if (!child.killed) child.kill("SIGINT");
3230
+ signalManagedProcess(child, "SIGINT");
2759
3231
  };
2760
3232
  try {
2761
3233
  await Promise.race([child, caddyExit]);
@@ -2763,10 +3235,14 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
2763
3235
  stopChild();
2764
3236
  stopCaddy();
2765
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
+ }
2766
3241
  cleanupRun();
3242
+ await context.releasePort?.();
2767
3243
  }
2768
3244
  });
2769
- program.command("tunnel").description("Run the local Ghost Tunnel agent").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--ghost-config <file>", "Exact Ghost Tunnel route file", ".ghosttunnel").option("--target-host <host>", "Local target host for .ghosttunnel ports", "127.0.0.1").action(async (options) => {
3245
+ program.command("tunnel").description("Run the experimental local Ghost Tunnel agent").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--ghost-config <file>", "Exact Ghost Tunnel route file", ".ghosttunnel").option("--target-host <host>", "Local target host for .ghosttunnel ports", "127.0.0.1").action(async (options) => {
2770
3246
  assertLocalDevelopment("tunnel");
2771
3247
  const context = await resolveLocalghostContext({
2772
3248
  cwd: options.cwd,
@@ -2833,6 +3309,7 @@ function readImplicitInvocation(args) {
2833
3309
  let cwd = process.cwd();
2834
3310
  let dryRun = false;
2835
3311
  let updateCheck = true;
3312
+ const forwardedArgs = [];
2836
3313
  for (let index = 0; index < args.length; index += 1) {
2837
3314
  const arg = args[index];
2838
3315
  if (!arg) continue;
@@ -2844,6 +3321,30 @@ function readImplicitInvocation(args) {
2844
3321
  updateCheck = false;
2845
3322
  continue;
2846
3323
  }
3324
+ if (["--clean-caddy", "--https", "--ssl", "--setup", "--trust"].includes(arg)) {
3325
+ forwardedArgs.push(arg);
3326
+ continue;
3327
+ }
3328
+ if (arg === "--auto-repair" || arg === "--dynamic-port") {
3329
+ const value = args[index + 1];
3330
+ forwardedArgs.push(arg);
3331
+ if (value && !value.startsWith("--")) {
3332
+ forwardedArgs.push(value);
3333
+ index += 1;
3334
+ }
3335
+ continue;
3336
+ }
3337
+ if (arg === "--port" || arg === "--project" || arg === "--config" || arg === "--config-pattern") {
3338
+ const value = args[index + 1];
3339
+ if (!value) throw new Error(`${arg} requires a value.`);
3340
+ forwardedArgs.push(arg, value);
3341
+ index += 1;
3342
+ continue;
3343
+ }
3344
+ if (["--auto-repair=", "--dynamic-port=", "--port=", "--project=", "--config=", "--config-pattern="].some((prefix) => arg.startsWith(prefix))) {
3345
+ forwardedArgs.push(arg);
3346
+ continue;
3347
+ }
2847
3348
  if (arg === "--cwd") {
2848
3349
  const value = args[index + 1];
2849
3350
  if (!value) throw new Error("--cwd requires a path.");
@@ -2857,7 +3358,15 @@ function readImplicitInvocation(args) {
2857
3358
  }
2858
3359
  return null;
2859
3360
  }
2860
- return { cwd, dryRun, updateCheck };
3361
+ return { cwd, dryRun, updateCheck, forwardedArgs };
3362
+ }
3363
+ function hasForwardedFlag(args, ...flags) {
3364
+ return args.some((arg) => flags.includes(arg));
3365
+ }
3366
+ function forwardedBoolean(args, name) {
3367
+ const inline = args.find((arg) => arg.startsWith(`${name}=`));
3368
+ if (inline) return parseBooleanLike(inline.slice(name.length + 1));
3369
+ return args.includes(name) ? true : void 0;
2861
3370
  }
2862
3371
  async function main() {
2863
3372
  const implicit = readImplicitInvocation(process.argv.slice(2));
@@ -2875,14 +3384,15 @@ async function main() {
2875
3384
  });
2876
3385
  console.log(formatDetectedDevServices(services));
2877
3386
  if (implicit.dryRun) return;
3387
+ if (hasForwardedFlag(implicit.forwardedArgs, "--clean-caddy")) cleanManagedCaddyProcesses();
2878
3388
  await runDetectedServices({
2879
3389
  cwd: implicit.cwd,
2880
3390
  services,
2881
3391
  configPath: projectConfig.path,
2882
3392
  projectName: sanitizeProjectName(projectConfig.config.project ?? getProjectName(implicit.cwd)),
2883
- https: projectConfig.config.https ?? false,
2884
- dynamicPort: projectConfig.config.dynamicPort ?? true,
2885
- autoRepair: projectConfig.config.autoRepair ?? true
3393
+ https: hasForwardedFlag(implicit.forwardedArgs, "--https", "--ssl") || (projectConfig.config.https ?? false),
3394
+ dynamicPort: forwardedBoolean(implicit.forwardedArgs, "--dynamic-port") ?? projectConfig.config.dynamicPort ?? true,
3395
+ autoRepair: forwardedBoolean(implicit.forwardedArgs, "--auto-repair") ?? projectConfig.config.autoRepair ?? true
2886
3396
  });
2887
3397
  await maybeNotifyAboutUpdate({ disabled: !implicit.updateCheck });
2888
3398
  return;
@@ -2898,6 +3408,7 @@ async function main() {
2898
3408
  process.argv[1] ?? "localghost",
2899
3409
  ...implicit.updateCheck ? [] : ["--no-update-check"],
2900
3410
  "run",
3411
+ ...implicit.forwardedArgs,
2901
3412
  "--cwd",
2902
3413
  implicit.cwd,
2903
3414
  "--",