@hamedb89/localghost 0.1.12 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -250,7 +250,12 @@ function getProjectName(cwd = process.cwd()) {
250
250
  }
251
251
  }
252
252
  function sanitizeProjectName(value) {
253
- const projectName = value.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
253
+ const sanitized = value.replace(/[^\w.-]+/g, "-");
254
+ let start = 0;
255
+ let end = sanitized.length;
256
+ while (start < end && sanitized.charCodeAt(start) === 45) start += 1;
257
+ while (end > start && sanitized.charCodeAt(end - 1) === 45) end -= 1;
258
+ const projectName = sanitized.slice(start, end);
254
259
  return projectName || "app";
255
260
  }
256
261
 
@@ -334,6 +339,26 @@ function startCaddy(path) {
334
339
  stdio: caddyStdio()
335
340
  });
336
341
  }
342
+ function stopCaddyProcesses(pids, killProcess = (pid, signal) => process.kill(pid, signal)) {
343
+ const result = {
344
+ stopped: [],
345
+ alreadyExited: [],
346
+ failed: []
347
+ };
348
+ for (const pid of new Set(pids)) {
349
+ try {
350
+ killProcess(pid, "SIGINT");
351
+ result.stopped.push(pid);
352
+ } catch (error) {
353
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
354
+ result.alreadyExited.push(pid);
355
+ } else {
356
+ result.failed.push({ pid, error });
357
+ }
358
+ }
359
+ }
360
+ return result;
361
+ }
337
362
  async function trustCaddy(path) {
338
363
  await execa("caddy", ["trust", "--config", path], {
339
364
  cwd: dirname3(path),
@@ -454,19 +479,19 @@ function formatDetectedDevServices(services) {
454
479
 
455
480
  // src/context.ts
456
481
  import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
457
- import { join as join5 } from "path";
482
+ import { join as join6 } from "path";
458
483
  import { pathToFileURL } from "url";
459
484
 
460
485
  // src/port.ts
461
486
  import { createServer } from "net";
462
487
  async function isPortAvailable(port, host = "127.0.0.1") {
463
- return new Promise((resolve3) => {
488
+ return new Promise((resolve4) => {
464
489
  const server = createServer();
465
490
  server.once("error", () => {
466
- resolve3(false);
491
+ resolve4(false);
467
492
  });
468
493
  server.once("listening", () => {
469
- server.close(() => resolve3(true));
494
+ server.close(() => resolve4(true));
470
495
  });
471
496
  server.listen(port, host);
472
497
  });
@@ -483,6 +508,176 @@ async function findAvailablePort(startPort, options = {}) {
483
508
  throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
484
509
  }
485
510
 
511
+ // src/registry.ts
512
+ import { randomUUID } from "crypto";
513
+ import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
514
+ import { homedir as homedir2 } from "os";
515
+ import { join as join5, normalize, resolve as resolve3 } from "path";
516
+ var LOCALGHOST_REGISTRY_FILE = "registry.json";
517
+ var LOCALGHOST_REGISTRY_LOCK_FILE = "registry.lock";
518
+ function defaultProcessRunning(pid) {
519
+ if (pid <= 0) return false;
520
+ try {
521
+ process.kill(pid, 0);
522
+ return true;
523
+ } catch (error) {
524
+ return error.code === "EPERM";
525
+ }
526
+ }
527
+ function getLocalghostRegistryRoot(env = process.env) {
528
+ return resolve3(env.LOCALGHOST_HOME || join5(homedir2(), ".localghost"));
529
+ }
530
+ function canonicalizeLocalghostProjectCwd(cwd = process.cwd()) {
531
+ return normalize(resolve3(cwd));
532
+ }
533
+ function emptyRegistry() {
534
+ return { version: 1, allocations: [], leases: [] };
535
+ }
536
+ function leaseKey(projectCwd, instanceKey) {
537
+ return `${projectCwd}\0${instanceKey}`;
538
+ }
539
+ function validRegistry(value) {
540
+ if (!value || typeof value !== "object") return false;
541
+ const candidate = value;
542
+ return candidate.version === 1 && Array.isArray(candidate.allocations) && Array.isArray(candidate.leases);
543
+ }
544
+ function pruneRegistry(registry, now, isRunning) {
545
+ registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
546
+ }
547
+ async function readJson(path) {
548
+ try {
549
+ return JSON.parse(await readFile(path, "utf8"));
550
+ } catch (error) {
551
+ if (error.code === "ENOENT") return void 0;
552
+ return void 0;
553
+ }
554
+ }
555
+ function createLocalghostRegistry(options = {}) {
556
+ const root = resolve3(options.stateRoot ?? getLocalghostRegistryRoot());
557
+ const registryPath = join5(root, LOCALGHOST_REGISTRY_FILE);
558
+ const lockPath = join5(root, LOCALGHOST_REGISTRY_LOCK_FILE);
559
+ const cwd = canonicalizeLocalghostProjectCwd(options.cwd);
560
+ const now = options.now ?? Date.now;
561
+ const pid = options.pid ?? process.pid;
562
+ const ownerToken = options.ownerToken ?? randomUUID();
563
+ const isRunning = options.isProcessRunning ?? defaultProcessRunning;
564
+ const availabilityCheck = options.availabilityCheck ?? isPortAvailable;
565
+ const lockTimeoutMs = options.lockTimeoutMs ?? 5e3;
566
+ const lockRetryMs = options.lockRetryMs ?? 25;
567
+ const lockStaleMs = options.lockStaleMs ?? 3e4;
568
+ async function readRegistry() {
569
+ const value = await readJson(registryPath);
570
+ return validRegistry(value) ? value : emptyRegistry();
571
+ }
572
+ async function writeRegistry(registry) {
573
+ await mkdir(root, { recursive: true });
574
+ const temporaryPath = join5(root, `.registry.${process.pid}.${randomUUID()}.tmp`);
575
+ await writeFile(temporaryPath, `${JSON.stringify(registry, null, 2)}
576
+ `, { mode: 384 });
577
+ await rename(temporaryPath, registryPath);
578
+ }
579
+ async function lock() {
580
+ await mkdir(root, { recursive: true });
581
+ const deadline = now() + lockTimeoutMs;
582
+ const token = randomUUID();
583
+ while (true) {
584
+ try {
585
+ const handle = await open(lockPath, "wx", 384);
586
+ await handle.writeFile(`${JSON.stringify({ pid, createdAt: now(), token })}
587
+ `);
588
+ await handle.close();
589
+ return async () => {
590
+ const current = await readJson(lockPath);
591
+ if (current?.token === token) await unlink(lockPath).catch(() => void 0);
592
+ };
593
+ } catch (error) {
594
+ if (error.code !== "EEXIST") throw error;
595
+ const lockInfo = await readJson(lockPath);
596
+ let stale = false;
597
+ if (lockInfo && typeof lockInfo.pid === "number") {
598
+ stale = !isRunning(lockInfo.pid) && now() - lockInfo.createdAt >= 0;
599
+ } else {
600
+ try {
601
+ stale = now() - (await stat(lockPath)).mtimeMs > lockStaleMs;
602
+ } catch {
603
+ continue;
604
+ }
605
+ }
606
+ if (stale) {
607
+ await rm(lockPath, { force: true }).catch(() => void 0);
608
+ continue;
609
+ }
610
+ if (now() >= deadline) throw new Error(`Timed out waiting for Localghost registry lock: ${lockPath}`);
611
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, lockRetryMs));
612
+ }
613
+ }
614
+ }
615
+ async function withLock(operation) {
616
+ const releaseLock = await lock();
617
+ try {
618
+ const registry = await readRegistry();
619
+ pruneRegistry(registry, now(), isRunning);
620
+ return await operation(registry);
621
+ } finally {
622
+ await releaseLock();
623
+ }
624
+ }
625
+ return {
626
+ root,
627
+ registryPath,
628
+ lockPath,
629
+ ownerToken,
630
+ read: readRegistry,
631
+ async acquirePort(acquireOptions) {
632
+ const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
633
+ if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
634
+ return withLock(async (registry) => {
635
+ const key = leaseKey(projectCwd, acquireOptions.instanceKey);
636
+ const existing = registry.allocations.find((entry2) => leaseKey(entry2.projectCwd, entry2.instanceKey) === key);
637
+ const reserved = new Set(acquireOptions.reservedPorts ?? []);
638
+ const activePorts = new Set(registry.leases.map((lease2) => lease2.port));
639
+ const port = existing?.port;
640
+ const ownsActiveLease = registry.leases.some((lease2) => lease2.port === port && leaseKey(lease2.projectCwd, lease2.instanceKey) === key && lease2.ownerToken === ownerToken);
641
+ const reusable = port !== void 0 && !reserved.has(port) && (!activePorts.has(port) || ownsActiveLease) && (ownsActiveLease || await availabilityCheck(port, acquireOptions.host));
642
+ let selectedPort = reusable ? port : void 0;
643
+ if (selectedPort === void 0) {
644
+ const startPort = acquireOptions.startPort ?? 3e3;
645
+ const maxAttempts = acquireOptions.maxAttempts ?? 50;
646
+ for (let offset = 0; offset < maxAttempts; offset += 1) {
647
+ const candidate = startPort + offset;
648
+ if (reserved.has(candidate) || activePorts.has(candidate)) continue;
649
+ if (await availabilityCheck(candidate, acquireOptions.host)) {
650
+ selectedPort = candidate;
651
+ break;
652
+ }
653
+ }
654
+ if (selectedPort === void 0) throw new Error(`No available registry port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
655
+ }
656
+ const timestamp = now();
657
+ const entry = existing ?? { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, updatedAt: timestamp };
658
+ entry.port = selectedPort;
659
+ entry.updatedAt = timestamp;
660
+ if (!existing) registry.allocations.push(entry);
661
+ registry.leases = registry.leases.filter((lease2) => leaseKey(lease2.projectCwd, lease2.instanceKey) !== key);
662
+ const lease = { projectCwd, instanceKey: acquireOptions.instanceKey, port: selectedPort, pid, acquiredAt: timestamp, expiresAt: timestamp + (acquireOptions.leaseTtlMs ?? 30 * 60 * 1e3), ownerToken };
663
+ registry.leases.push(lease);
664
+ await writeRegistry(registry);
665
+ return lease;
666
+ });
667
+ },
668
+ async releasePort(releaseOptions) {
669
+ const projectCwd = canonicalizeLocalghostProjectCwd(releaseOptions.projectCwd ?? cwd);
670
+ return withLock(async (registry) => {
671
+ const key = leaseKey(projectCwd, releaseOptions.instanceKey);
672
+ const before = registry.leases.length;
673
+ registry.leases = registry.leases.filter((lease) => leaseKey(lease.projectCwd, lease.instanceKey) !== key || lease.ownerToken !== ownerToken);
674
+ if (registry.leases.length !== before) await writeRegistry(registry);
675
+ return registry.leases.length !== before;
676
+ });
677
+ }
678
+ };
679
+ }
680
+
486
681
  // src/tunnel.ts
487
682
  import { domainToASCII } from "url";
488
683
  var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
@@ -860,7 +1055,7 @@ function envHttps() {
860
1055
  }
861
1056
  function getPackageName(cwd) {
862
1057
  try {
863
- const pkg = JSON.parse(readFileSync5(join5(cwd, "package.json"), "utf8"));
1058
+ const pkg = JSON.parse(readFileSync5(join6(cwd, "package.json"), "utf8"));
864
1059
  return typeof pkg.name === "string" ? pkg.name : void 0;
865
1060
  } catch {
866
1061
  return void 0;
@@ -943,7 +1138,24 @@ async function resolveLocalghostContext(options = {}) {
943
1138
  const autoRepair = merged.autoRepair ?? true;
944
1139
  const bindHost = merged.bindHost ?? "127.0.0.1";
945
1140
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
946
- const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
1141
+ let port = requestedPort;
1142
+ let releasePort;
1143
+ const reservePort = merged.reservePort ?? false;
1144
+ const instanceKey = merged.instanceKey ?? "run";
1145
+ if (reservePort && dynamicPort) {
1146
+ const registry = createLocalghostRegistry({ cwd, ...merged.registryOwnerToken ? { ownerToken: merged.registryOwnerToken } : {} });
1147
+ const lease = await registry.acquirePort({
1148
+ projectCwd: cwd,
1149
+ instanceKey,
1150
+ startPort: requestedPort,
1151
+ host: probeHost,
1152
+ ...options.reservedPorts ? { reservedPorts: options.reservedPorts } : {}
1153
+ });
1154
+ port = lease.port;
1155
+ releasePort = () => registry.releasePort({ projectCwd: cwd, instanceKey });
1156
+ } else if (dynamicPort) {
1157
+ port = await findAvailablePort(requestedPort, { host: probeHost });
1158
+ }
947
1159
  const wwwAlias = merged.wwwAlias ?? true;
948
1160
  const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
949
1161
  const hosts = uniqueHosts(entries);
@@ -972,7 +1184,8 @@ async function resolveLocalghostContext(options = {}) {
972
1184
  https: merged.https ?? envHttps() ?? false,
973
1185
  wwwAlias,
974
1186
  ghostTunnel,
975
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
1187
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
1188
+ ...releasePort ? { releasePort } : {}
976
1189
  };
977
1190
  }
978
1191
 
@@ -1041,7 +1254,7 @@ function listGhostTunnelEntries(options = {}) {
1041
1254
  }
1042
1255
 
1043
1256
  // src/ghost-agent.ts
1044
- import { randomUUID as randomUUID2 } from "crypto";
1257
+ import { randomUUID as randomUUID3 } from "crypto";
1045
1258
 
1046
1259
  // src/relay.ts
1047
1260
  import { createHmac, timingSafeEqual } from "crypto";
@@ -1153,7 +1366,7 @@ function stripRelayForwardHeaders(headers) {
1153
1366
  }
1154
1367
 
1155
1368
  // src/ghost-tunnel-store.ts
1156
- import { randomUUID } from "crypto";
1369
+ import { randomUUID as randomUUID2 } from "crypto";
1157
1370
  function base64Encode(value) {
1158
1371
  return value.toString("base64");
1159
1372
  }
@@ -1191,13 +1404,18 @@ function parseJson(value) {
1191
1404
  function keyPart(value) {
1192
1405
  return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
1193
1406
  }
1407
+ function removeTrailingSlashes(value) {
1408
+ let end = value.length;
1409
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
1410
+ return value.slice(0, end);
1411
+ }
1194
1412
  var RedisGhostTunnelStore = class {
1195
1413
  url;
1196
1414
  token;
1197
1415
  namespace;
1198
1416
  fetchImpl;
1199
1417
  constructor(options) {
1200
- this.url = options.url.replace(/\/+$/, "");
1418
+ this.url = removeTrailingSlashes(options.url);
1201
1419
  this.token = options.token;
1202
1420
  this.namespace = options.namespace ?? "localghost";
1203
1421
  this.fetchImpl = options.fetch ?? fetch;
@@ -1285,11 +1503,11 @@ function isStopped(signal, localSignal) {
1285
1503
  }
1286
1504
  function wait(ms, signal, localSignal) {
1287
1505
  if (isStopped(signal, localSignal)) return Promise.resolve();
1288
- return new Promise((resolve3) => {
1289
- const timeout = setTimeout(resolve3, ms);
1506
+ return new Promise((resolve4) => {
1507
+ const timeout = setTimeout(resolve4, ms);
1290
1508
  const stop = () => {
1291
1509
  clearTimeout(timeout);
1292
- resolve3();
1510
+ resolve4();
1293
1511
  };
1294
1512
  signal?.addEventListener("abort", stop, { once: true });
1295
1513
  localSignal.addEventListener("abort", stop, { once: true });
@@ -1375,7 +1593,7 @@ function startGhostTunnelAgent(options) {
1375
1593
  const controller = new AbortController();
1376
1594
  const localSignal = controller.signal;
1377
1595
  const signal = options.signal;
1378
- const agentId = options.agentId ?? `localghost-${randomUUID2()}`;
1596
+ const agentId = options.agentId ?? `localghost-${randomUUID3()}`;
1379
1597
  const targetHost = options.targetHost ?? "127.0.0.1";
1380
1598
  const routeTtlSeconds = options.routeTtlSeconds ?? 30;
1381
1599
  const requestTtlSeconds = options.requestTtlSeconds ?? 60;
@@ -1430,7 +1648,7 @@ function startGhostTunnelAgent(options) {
1430
1648
  // src/hosts-file.ts
1431
1649
  import { writeFileSync as writeFileSync3 } from "fs";
1432
1650
  import { tmpdir } from "os";
1433
- import { join as join6 } from "path";
1651
+ import { join as join7 } from "path";
1434
1652
  import { execa as execa3 } from "execa";
1435
1653
  function escapeRegExp(value) {
1436
1654
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1473,7 +1691,7 @@ function removeManagedBlock(existing, projectName) {
1473
1691
  }
1474
1692
  async function writeSystemHostsFile(hostsPath, next, projectName) {
1475
1693
  const sanitizedProjectName = sanitizeProjectName(projectName);
1476
- const tempPath = join6(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
1694
+ const tempPath = join7(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
1477
1695
  writeFileSync3(tempPath, next, "utf8");
1478
1696
  if (process.env.LOCALGHOST_HOSTS_PATH) {
1479
1697
  writeFileSync3(hostsPath, next, "utf8");
@@ -1511,15 +1729,17 @@ async function removeSystemHosts(projectName) {
1511
1729
 
1512
1730
  // src/init.ts
1513
1731
  import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1514
- import { join as join7 } from "path";
1732
+ import { join as join8 } from "path";
1515
1733
  function detectPackageManager(cwd = process.cwd()) {
1516
- if (existsSync5(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
1517
- if (existsSync5(join7(cwd, "yarn.lock"))) return "yarn";
1734
+ if (existsSync5(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
1735
+ if (existsSync5(join8(cwd, "yarn.lock"))) return "yarn";
1736
+ if (existsSync5(join8(cwd, "bun.lock")) || existsSync5(join8(cwd, "bun.lockb"))) return "bun";
1518
1737
  return "npm";
1519
1738
  }
1520
1739
  function packageRunCommand(packageManager, script) {
1521
1740
  if (packageManager === "yarn") return `yarn ${script}`;
1522
1741
  if (packageManager === "pnpm") return `pnpm ${script}`;
1742
+ if (packageManager === "bun") return `bun run ${script}`;
1523
1743
  return `npm run ${script}`;
1524
1744
  }
1525
1745
  function renderConfig(options) {
@@ -1587,7 +1807,7 @@ function initLocalghost(options = {}) {
1587
1807
  const apiPort = options.apiPort ?? 8787;
1588
1808
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
1589
1809
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
1590
- const configPath = join7(cwd, configFile);
1810
+ const configPath = join8(cwd, configFile);
1591
1811
  const configExists = existsSync5(configPath);
1592
1812
  if (configExists && !options.force) {
1593
1813
  return {
@@ -1604,7 +1824,7 @@ function initLocalghost(options = {}) {
1604
1824
  };
1605
1825
  }
1606
1826
  writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
1607
- const packageJsonPath = join7(cwd, "package.json");
1827
+ const packageJsonPath = join8(cwd, "package.json");
1608
1828
  const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
1609
1829
  return {
1610
1830
  configPath,
@@ -1621,6 +1841,62 @@ function initLocalghost(options = {}) {
1621
1841
  };
1622
1842
  }
1623
1843
 
1844
+ // src/guide.ts
1845
+ var LOCALGHOST_AGENT_GUIDE = `# Localghost agent guide
1846
+
1847
+ Localghost owns the local development proxy and the app process boundary.
1848
+
1849
+ ## Preferred repository setup
1850
+
1851
+ For a normal repository, use this package script:
1852
+
1853
+ "dev": "localghost"
1854
+
1855
+ For an explicit app command, keep the raw command separate:
1856
+
1857
+ "dev": "localghost run -- vite"
1858
+ "dev:raw": "vite"
1859
+
1860
+ Use \`localghost dev\` only when the Caddy proxy should run without starting the app.
1861
+
1862
+ ## Useful commands
1863
+
1864
+ - \`localghost\`: detect and run the repository development command.
1865
+ - \`localghost run -- <command>\`: wrap an explicit app command.
1866
+ - \`localghost dev\`: run only the local Caddy proxy.
1867
+ - \`localghost status --ready\`: check project setup.
1868
+ - \`localghost repair\`: repair managed hosts and Caddy setup.
1869
+ - \`localghost ps --json\`: inspect Localghost-managed repositories, instances, and ports.
1870
+ - \`localghost routes\`: inspect hostname-to-port routing.
1871
+ - \`localghost doctor\`: check machine prerequisites.
1872
+
1873
+ ## Configuration
1874
+
1875
+ - Commit repository defaults in \`localghost.config.mjs\`.
1876
+ - Keep hostname and requested-port routes in \`.localghost\`.
1877
+ - CLI flags override repository configuration for one invocation.
1878
+ - Localghost remembers active project and instance port assignments in user state under \`~/.localghost\`.
1879
+ - Do not edit the registry manually and do not start Caddy separately.
1880
+
1881
+ ## Port behavior
1882
+
1883
+ Localghost remembers ports by canonical repository path and instance key. Concurrent Localghost instances receive distinct ports. The operating-system bind check remains authoritative when another tool already owns a port.
1884
+ `;
1885
+ function formatLocalghostAgentGuide(format = "text") {
1886
+ if (format === "json") {
1887
+ return JSON.stringify({
1888
+ preferredScript: "localghost",
1889
+ explicitScript: "localghost run -- <command>",
1890
+ proxyOnlyCommand: "localghost dev",
1891
+ inspectionCommands: ["localghost status --ready", "localghost ps --json", "localghost routes", "localghost doctor"],
1892
+ projectConfig: "localghost.config.mjs",
1893
+ routeConfig: ".localghost",
1894
+ userState: "~/.localghost"
1895
+ }, null, 2);
1896
+ }
1897
+ return LOCALGHOST_AGENT_GUIDE;
1898
+ }
1899
+
1624
1900
  // src/prompt.ts
1625
1901
  import { stdin as input, stdout as output } from "process";
1626
1902
  import { createInterface } from "readline/promises";
@@ -1709,10 +1985,10 @@ function formatGhostTunnel(config, options = {}) {
1709
1985
 
1710
1986
  // src/state.ts
1711
1987
  import { existsSync as existsSync6 } from "fs";
1712
- import { join as join8 } from "path";
1988
+ import { join as join9 } from "path";
1713
1989
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
1714
1990
  function getLocalghostStatePath(cwd = process.cwd()) {
1715
- return join8(cwd, LOCALGHOST_STATE_FILE);
1991
+ return join9(cwd, LOCALGHOST_STATE_FILE);
1716
1992
  }
1717
1993
  function readLocalghostState(cwd = process.cwd()) {
1718
1994
  const path = getLocalghostStatePath(cwd);
@@ -1733,10 +2009,10 @@ function patchLocalghostState(cwd, patch) {
1733
2009
 
1734
2010
  // src/update-check.ts
1735
2011
  import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
1736
- import { homedir as homedir2 } from "os";
1737
- import { dirname as dirname4, join as join9 } from "path";
2012
+ import { homedir as homedir3 } from "os";
2013
+ import { dirname as dirname4, join as join10 } from "path";
1738
2014
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
1739
- var LOCALGHOST_VERSION = "0.1.12";
2015
+ var LOCALGHOST_VERSION = "0.1.15";
1740
2016
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1741
2017
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
1742
2018
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -1748,8 +2024,8 @@ function isUpdateCheckDisabled(env = process.env) {
1748
2024
  }
1749
2025
  function getUpdateCheckCachePath(env = process.env) {
1750
2026
  if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
1751
- const cacheRoot = env.XDG_CACHE_HOME || join9(homedir2(), ".cache");
1752
- return join9(cacheRoot, "localghost", "update-check.json");
2027
+ const cacheRoot = env.XDG_CACHE_HOME || join10(homedir3(), ".cache");
2028
+ return join10(cacheRoot, "localghost", "update-check.json");
1753
2029
  }
1754
2030
  function readCache(path = getUpdateCheckCachePath()) {
1755
2031
  if (!existsSync7(path)) return null;
@@ -1801,7 +2077,7 @@ function isNewerVersion(candidate, current = LOCALGHOST_VERSION) {
1801
2077
  return Boolean(candidate && compareVersions(candidate, current) > 0);
1802
2078
  }
1803
2079
  async function fetchLatestVersion(packageName, timeoutMs) {
1804
- const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).replace("/", "%2f")}` : packageName;
2080
+ const encodedName = packageName.startsWith("@") ? `@${packageName.slice(1).replaceAll("/", "%2f")}` : packageName;
1805
2081
  const response = await fetch(`https://registry.npmjs.org/${encodedName}`, {
1806
2082
  signal: AbortSignal.timeout(timeoutMs),
1807
2083
  headers: {
@@ -1933,8 +2209,12 @@ function parsePort2(value) {
1933
2209
  return port;
1934
2210
  }
1935
2211
  function parsePackageManager(value) {
1936
- if (value === "npm" || value === "yarn" || value === "pnpm") return value;
1937
- throw new InvalidArgumentError("Package manager must be npm, yarn, or pnpm.");
2212
+ if (value === "npm" || value === "yarn" || value === "pnpm" || value === "bun") return value;
2213
+ throw new InvalidArgumentError("Package manager must be npm, pnpm, yarn, or bun.");
2214
+ }
2215
+ function parseReleaseBump(value) {
2216
+ if (value === "patch" || value === "minor" || value === "major") return value;
2217
+ throw new InvalidArgumentError("Release bump must be patch, minor, or major.");
1938
2218
  }
1939
2219
  function collect(value, previous = []) {
1940
2220
  return [...previous, value];
@@ -1973,6 +2253,25 @@ async function assertCaddyReady() {
1973
2253
  "Localghost will not install it for you. No surprise spells."
1974
2254
  ].join("\n"));
1975
2255
  }
2256
+ function cleanManagedCaddyProcesses() {
2257
+ const runs = listLocalghostRuns();
2258
+ const caddyPids = runs.flatMap((run) => run.caddyPid ? [run.caddyPid] : []);
2259
+ const result = stopCaddyProcesses(caddyPids);
2260
+ for (const run of runs) {
2261
+ if (run.caddyPid && (result.stopped.includes(run.caddyPid) || result.alreadyExited.includes(run.caddyPid))) {
2262
+ unregisterLocalghostRun(run.id);
2263
+ }
2264
+ }
2265
+ if (result.stopped.length > 0) {
2266
+ console.log(`Stopped ${result.stopped.length} Localghost-managed Caddy process${result.stopped.length === 1 ? "" : "es"}.`);
2267
+ }
2268
+ if (result.alreadyExited.length > 0) {
2269
+ console.log(`Removed ${result.alreadyExited.length} stale Localghost Caddy record${result.alreadyExited.length === 1 ? "" : "s"}.`);
2270
+ }
2271
+ if (result.failed.length > 0) {
2272
+ throw new Error(`Could not stop Localghost-managed Caddy PID(s): ${result.failed.map(({ pid }) => pid).join(", ")}.`);
2273
+ }
2274
+ }
1976
2275
  function existingTrustMarkers(cwd) {
1977
2276
  const state = readLocalghostState(cwd);
1978
2277
  return {
@@ -2077,7 +2376,7 @@ async function runSetupFromReadiness(cwd, https, readiness) {
2077
2376
  });
2078
2377
  }
2079
2378
  function wait2(ms) {
2080
- return new Promise((resolve3) => setTimeout(resolve3, ms));
2379
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
2081
2380
  }
2082
2381
  async function runTrust(cwd, caddyfilePath) {
2083
2382
  await wait2(350);
@@ -2124,25 +2423,19 @@ function registerCleanup(id) {
2124
2423
  process.off("exit", cleanup);
2125
2424
  };
2126
2425
  }
2127
- async function resolveServiceRuntimeEntries(services, dynamicPort) {
2426
+ async function resolveServiceRuntimeEntries(services, dynamicPort, projectCwd) {
2128
2427
  const usedPorts = /* @__PURE__ */ new Set();
2129
2428
  const resolved = [];
2429
+ const registry = dynamicPort ? createLocalghostRegistry({ cwd: projectCwd, ownerToken: `${process.pid}:services:${projectCwd}` }) : void 0;
2130
2430
  for (const service of services) {
2131
2431
  let port = service.requestedPort;
2132
2432
  if (dynamicPort) {
2133
- let found = false;
2134
- for (let offset = 0; offset < 50; offset += 1) {
2135
- const candidate = service.requestedPort + offset;
2136
- if (candidate > 65535 || usedPorts.has(candidate)) continue;
2137
- if (await isPortAvailable(candidate)) {
2138
- port = candidate;
2139
- found = true;
2140
- break;
2141
- }
2142
- }
2143
- if (!found) {
2144
- throw new Error(`No available port found for service ${service.name} from ${service.requestedPort}.`);
2145
- }
2433
+ const lease = await registry.acquirePort({
2434
+ instanceKey: `service:${service.name}`,
2435
+ startPort: service.requestedPort,
2436
+ reservedPorts: usedPorts
2437
+ });
2438
+ port = lease.port;
2146
2439
  } else if (usedPorts.has(port)) {
2147
2440
  throw new Error(`Services cannot start separate commands on the same fixed port: ${port}.`);
2148
2441
  }
@@ -2157,7 +2450,13 @@ async function resolveServiceRuntimeEntries(services, dynamicPort) {
2157
2450
  }
2158
2451
  });
2159
2452
  }
2160
- return resolved;
2453
+ return {
2454
+ services: resolved,
2455
+ release: async () => {
2456
+ if (!registry) return;
2457
+ await Promise.all(resolved.map((service) => registry.releasePort({ instanceKey: `service:${service.name}` })));
2458
+ }
2459
+ };
2161
2460
  }
2162
2461
  async function waitForServicePorts(entries, timeoutMs = 1e4) {
2163
2462
  const deadline = Date.now() + timeoutMs;
@@ -2172,81 +2471,86 @@ async function waitForServicePorts(entries, timeoutMs = 1e4) {
2172
2471
  async function runDetectedServices(options) {
2173
2472
  assertLocalDevelopment("run");
2174
2473
  await assertCaddyReady();
2175
- const runtimeServices = await resolveServiceRuntimeEntries(options.services, options.dynamicPort);
2176
- const entries = runtimeServices.map((service) => service.entry);
2177
- const readiness = getSetupReadiness({
2178
- cwd: options.cwd,
2179
- https: options.https,
2180
- ignoreCaddyfile: true,
2181
- entries,
2182
- configPath: options.configPath,
2183
- projectName: options.projectName
2184
- });
2185
- if (!readiness.ready) {
2186
- if (!options.autoRepair) {
2187
- throw new Error([
2188
- "Localghost setup is missing or stale.",
2189
- ...readiness.reasons.map((reason) => `- ${reason}`),
2190
- "Automatic repair is disabled. Enable autoRepair or run localghost repair."
2191
- ].join("\n"));
2192
- }
2193
- console.log("Localghost setup is stale; repairing it now.");
2194
- await runSetupFromReadiness(options.cwd, options.https, readiness);
2195
- }
2196
- for (const service of runtimeServices) {
2197
- if (service.port !== service.requestedPort) {
2198
- console.log(`${service.name}: port ${service.requestedPort} is busy; using ${service.port}.`);
2474
+ const runtime = await resolveServiceRuntimeEntries(options.services, options.dynamicPort, options.cwd);
2475
+ try {
2476
+ const runtimeServices = runtime.services;
2477
+ const entries = runtimeServices.map((service) => service.entry);
2478
+ const readiness = getSetupReadiness({
2479
+ cwd: options.cwd,
2480
+ https: options.https,
2481
+ ignoreCaddyfile: true,
2482
+ entries,
2483
+ configPath: options.configPath,
2484
+ projectName: options.projectName
2485
+ });
2486
+ if (!readiness.ready) {
2487
+ if (!options.autoRepair) {
2488
+ throw new Error([
2489
+ "Localghost setup is missing or stale.",
2490
+ ...readiness.reasons.map((reason) => `- ${reason}`),
2491
+ "Automatic repair is disabled. Enable autoRepair or run localghost repair."
2492
+ ].join("\n"));
2493
+ }
2494
+ console.log("Localghost setup is stale; repairing it now.");
2495
+ await runSetupFromReadiness(options.cwd, options.https, readiness);
2199
2496
  }
2200
- }
2201
- const caddyfile = await writeCaddyfile(entries, options.cwd, { https: options.https });
2202
- await validateCaddyfile(caddyfile);
2203
- const caddy = startCaddy(caddyfile);
2204
- const caddyExit = caddy.catch((error) => {
2205
- if (!caddy.killed) throw error;
2206
- });
2207
- const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
2208
- cwd: service.cwd,
2209
- stdio: "inherit",
2210
- env: {
2211
- ...process.env,
2212
- LOCALGHOST_PORT: String(service.port),
2213
- LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
2214
- LOCALGHOST_SERVICE: service.name,
2215
- VITE_PORT: String(service.port)
2497
+ for (const service of runtimeServices) {
2498
+ if (service.port !== service.requestedPort) {
2499
+ console.log(`${service.name}: port ${service.requestedPort} is busy; using ${service.port}.`);
2500
+ }
2216
2501
  }
2217
- }));
2218
- const caddyPid = maybePid(caddy.pid);
2219
- const runRecord = registerLocalghostRun({
2220
- mode: "run",
2221
- cwd: options.cwd,
2222
- projectName: options.projectName,
2223
- configPath: options.configPath,
2224
- caddyfilePath: caddyfile,
2225
- ...caddyPid ? { caddyPid } : {},
2226
- childCommand: ["services", ...runtimeServices.map((service) => service.name)],
2227
- https: options.https,
2228
- dynamicPort: options.dynamicPort,
2229
- entries
2230
- });
2231
- const cleanupRun = registerCleanup(runRecord.id);
2232
- const processExit = Promise.race([caddyExit, ...children]);
2233
- try {
2234
- const ready = await Promise.race([
2235
- waitForServicePorts(entries),
2236
- processExit.then(() => false)
2237
- ]);
2238
- if (ready) {
2239
- console.log("");
2240
- logDomainRoutes(entries, { https: options.https });
2502
+ const caddyfile = await writeCaddyfile(entries, options.cwd, { https: options.https });
2503
+ await validateCaddyfile(caddyfile);
2504
+ const caddy = startCaddy(caddyfile);
2505
+ const caddyExit = caddy.catch((error) => {
2506
+ if (!caddy.killed) throw error;
2507
+ });
2508
+ const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
2509
+ cwd: service.cwd,
2510
+ stdio: "inherit",
2511
+ env: {
2512
+ ...process.env,
2513
+ LOCALGHOST_PORT: String(service.port),
2514
+ LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
2515
+ LOCALGHOST_SERVICE: service.name,
2516
+ VITE_PORT: String(service.port)
2517
+ }
2518
+ }));
2519
+ const caddyPid = maybePid(caddy.pid);
2520
+ const runRecord = registerLocalghostRun({
2521
+ mode: "run",
2522
+ cwd: options.cwd,
2523
+ projectName: options.projectName,
2524
+ configPath: options.configPath,
2525
+ caddyfilePath: caddyfile,
2526
+ ...caddyPid ? { caddyPid } : {},
2527
+ childCommand: ["services", ...runtimeServices.map((service) => service.name)],
2528
+ https: options.https,
2529
+ dynamicPort: options.dynamicPort,
2530
+ entries
2531
+ });
2532
+ const cleanupRun = registerCleanup(runRecord.id);
2533
+ const processExit = Promise.race([caddyExit, ...children]);
2534
+ try {
2535
+ const ready = await Promise.race([
2536
+ waitForServicePorts(entries),
2537
+ processExit.then(() => false)
2538
+ ]);
2539
+ if (ready) {
2540
+ console.log("");
2541
+ logDomainRoutes(entries, { https: options.https });
2542
+ }
2543
+ await processExit;
2544
+ } finally {
2545
+ for (const child of children) {
2546
+ if (!child.killed) child.kill("SIGINT");
2547
+ }
2548
+ if (!caddy.killed) caddy.kill("SIGINT");
2549
+ await Promise.allSettled([caddyExit, ...children]);
2550
+ cleanupRun();
2241
2551
  }
2242
- await processExit;
2243
2552
  } finally {
2244
- for (const child of children) {
2245
- if (!child.killed) child.kill("SIGINT");
2246
- }
2247
- if (!caddy.killed) caddy.kill("SIGINT");
2248
- await Promise.allSettled([caddyExit, ...children]);
2249
- cleanupRun();
2553
+ await runtime.release();
2250
2554
  }
2251
2555
  }
2252
2556
  async function getRouteViews(entries) {
@@ -2342,11 +2646,11 @@ function formatInstanceViews(instances) {
2342
2646
  var program = new Command();
2343
2647
  program.name("localghost").description("Buh. Friendly local hostnames for app repos.").version(LOCALGHOST_VERSION).option("--no-update-check", "Skip the npm update check for this run");
2344
2648
  program.hook("postAction", async (_thisCommand, actionCommand) => {
2345
- if (actionCommand.name() === "update") return;
2649
+ if (actionCommand.name() === "update" || actionCommand.name() === "release") return;
2346
2650
  const options = program.opts();
2347
2651
  await maybeNotifyAboutUpdate({ disabled: options.updateCheck === false });
2348
2652
  });
2349
- program.command("init").description("Create a .localghost config for this project").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to create", ".localghost").option("--host <host>", "Primary local hostname").option("--port <number>", "Primary app port", parsePort2).option("--api-host <host>", "API local hostname").option("--api-port <number>", "API port", parsePort2).option("--package-manager <npm|yarn|pnpm>", "Package manager for suggested commands", parsePackageManager).option("--write-scripts", "Add localghost scripts to package.json").option("--force", "Overwrite an existing config file").action((options) => {
2653
+ program.command("init").description("Create a .localghost config for this project").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to create", ".localghost").option("--host <host>", "Primary local hostname").option("--port <number>", "Primary app port", parsePort2).option("--api-host <host>", "API local hostname").option("--api-port <number>", "API port", parsePort2).option("--package-manager <npm|pnpm|yarn|bun>", "Package manager for suggested commands", parsePackageManager).option("--write-scripts", "Add localghost scripts to package.json").option("--force", "Overwrite an existing config file").action((options) => {
2350
2654
  const result = initLocalghost({ ...options, configFile: options.config });
2351
2655
  if (result.configCreated) {
2352
2656
  console.log(`Buh. Created ${result.configPath}`);
@@ -2367,6 +2671,9 @@ program.command("init").description("Create a .localghost config for this projec
2367
2671
  console.log(` ${step}`);
2368
2672
  }
2369
2673
  });
2674
+ program.command("guide").description("Explain the recommended Localghost workflow to humans or agents").option("--agent", "Print the agent-oriented workflow guide").option("--json", "Print the guide as JSON").action((options) => {
2675
+ console.log(formatLocalghostAgentGuide(options.json ? "json" : "text"));
2676
+ });
2370
2677
  program.command("doctor").description("Check machine prerequisites").action(async () => {
2371
2678
  const result = await runDoctor();
2372
2679
  if (result.caddy.found) {
@@ -2398,6 +2705,30 @@ program.command("update").description("Check npm for a newer localghost release"
2398
2705
  }
2399
2706
  console.log(`localghost is up to date. Current: ${result.currentVersion}`);
2400
2707
  });
2708
+ program.command("release").description("Dispatch an automated Localghost CLI release").argument("<bump>", "Semantic version increment: patch, minor, or major", parseReleaseBump).action(async (bump) => {
2709
+ const repository = "hamedb89/localghost";
2710
+ try {
2711
+ await execa4("gh", [
2712
+ "workflow",
2713
+ "run",
2714
+ "release.yml",
2715
+ "--repo",
2716
+ repository,
2717
+ "--ref",
2718
+ "main",
2719
+ "-f",
2720
+ `bump=${bump}`
2721
+ ]);
2722
+ } catch (error) {
2723
+ const detail = error instanceof Error ? error.message : String(error);
2724
+ throw new Error(
2725
+ `Could not dispatch the Localghost release workflow. Install and authenticate GitHub CLI with \`gh auth login\`, then retry.
2726
+ ${detail}`
2727
+ );
2728
+ }
2729
+ console.log(`Dispatched a ${bump} Localghost release from main.`);
2730
+ console.log(`Track it at https://github.com/${repository}/actions/workflows/release.yml`);
2731
+ });
2401
2732
  program.command("setup").description("Update /etc/hosts and generate/validate Caddyfile").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Generate a local HTTPS Caddy proxy with Caddy local certificates").option("--ssl", "Alias for --https").action(async (options) => {
2402
2733
  assertLocalDevelopment("setup");
2403
2734
  printLocalghostBanner();
@@ -2598,9 +2929,10 @@ program.command("routes").description("Print domain to upstream routes").option(
2598
2929
  }));
2599
2930
  }
2600
2931
  });
2601
- program.command("dev").description("Run the Localghost Caddy proxy, repairing stale setup when needed").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
2932
+ program.command("dev").description("Run the Localghost Caddy proxy, repairing stale setup when needed").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--clean-caddy", "Stop Localghost-managed Caddy processes before starting").option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
2602
2933
  assertLocalDevelopment("dev");
2603
2934
  await assertCaddyReady();
2935
+ if (options.cleanCaddy) cleanManagedCaddyProcesses();
2604
2936
  const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
2605
2937
  const https = context.https;
2606
2938
  const readiness = getSetupReadiness({
@@ -2658,15 +2990,18 @@ program.command("dev").description("Run the Localghost Caddy proxy, repairing st
2658
2990
  cleanupRun();
2659
2991
  }
2660
2992
  });
2661
- program.command("run").description("Run Caddy and a dev command from the same Localghost context").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--port <number>", "Initial app port", parsePort2).option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the child command").option("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
2993
+ program.command("run").description("Run Caddy and a dev command from the same Localghost context").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--port <number>", "Initial app port", parsePort2).option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--clean-caddy", "Stop Localghost-managed Caddy processes before starting").option("--trust", "Trust Caddy's local HTTPS CA before starting the child command").option("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
2662
2994
  assertLocalDevelopment("run");
2663
2995
  await assertCaddyReady();
2996
+ if (options.cleanCaddy) cleanManagedCaddyProcesses();
2664
2997
  const context = await resolveLocalghostContext({
2665
2998
  cwd: options.cwd,
2666
2999
  ...options.project ? { project: options.project } : {},
2667
3000
  ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
2668
3001
  ...options.configPattern ? { configPattern: options.configPattern } : {},
2669
3002
  ...options.port ? { port: options.port } : {},
3003
+ reservePort: true,
3004
+ instanceKey: "run",
2670
3005
  ...useHttps(options) ? { https: true } : {},
2671
3006
  ...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {},
2672
3007
  ...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
@@ -2762,9 +3097,10 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
2762
3097
  stopCaddy();
2763
3098
  await Promise.allSettled([child, caddyExit]);
2764
3099
  cleanupRun();
3100
+ await context.releasePort?.();
2765
3101
  }
2766
3102
  });
2767
- program.command("tunnel").description("Run the local Ghost Tunnel agent").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--ghost-config <file>", "Exact Ghost Tunnel route file", ".ghosttunnel").option("--target-host <host>", "Local target host for .ghosttunnel ports", "127.0.0.1").action(async (options) => {
3103
+ program.command("tunnel").description("Run the experimental local Ghost Tunnel agent").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--ghost-config <file>", "Exact Ghost Tunnel route file", ".ghosttunnel").option("--target-host <host>", "Local target host for .ghosttunnel ports", "127.0.0.1").action(async (options) => {
2768
3104
  assertLocalDevelopment("tunnel");
2769
3105
  const context = await resolveLocalghostContext({
2770
3106
  cwd: options.cwd,
@@ -2831,6 +3167,7 @@ function readImplicitInvocation(args) {
2831
3167
  let cwd = process.cwd();
2832
3168
  let dryRun = false;
2833
3169
  let updateCheck = true;
3170
+ const forwardedArgs = [];
2834
3171
  for (let index = 0; index < args.length; index += 1) {
2835
3172
  const arg = args[index];
2836
3173
  if (!arg) continue;
@@ -2842,6 +3179,30 @@ function readImplicitInvocation(args) {
2842
3179
  updateCheck = false;
2843
3180
  continue;
2844
3181
  }
3182
+ if (["--clean-caddy", "--https", "--ssl", "--setup", "--trust"].includes(arg)) {
3183
+ forwardedArgs.push(arg);
3184
+ continue;
3185
+ }
3186
+ if (arg === "--auto-repair" || arg === "--dynamic-port") {
3187
+ const value = args[index + 1];
3188
+ forwardedArgs.push(arg);
3189
+ if (value && !value.startsWith("--")) {
3190
+ forwardedArgs.push(value);
3191
+ index += 1;
3192
+ }
3193
+ continue;
3194
+ }
3195
+ if (arg === "--port" || arg === "--project" || arg === "--config" || arg === "--config-pattern") {
3196
+ const value = args[index + 1];
3197
+ if (!value) throw new Error(`${arg} requires a value.`);
3198
+ forwardedArgs.push(arg, value);
3199
+ index += 1;
3200
+ continue;
3201
+ }
3202
+ if (["--auto-repair=", "--dynamic-port=", "--port=", "--project=", "--config=", "--config-pattern="].some((prefix) => arg.startsWith(prefix))) {
3203
+ forwardedArgs.push(arg);
3204
+ continue;
3205
+ }
2845
3206
  if (arg === "--cwd") {
2846
3207
  const value = args[index + 1];
2847
3208
  if (!value) throw new Error("--cwd requires a path.");
@@ -2855,7 +3216,15 @@ function readImplicitInvocation(args) {
2855
3216
  }
2856
3217
  return null;
2857
3218
  }
2858
- return { cwd, dryRun, updateCheck };
3219
+ return { cwd, dryRun, updateCheck, forwardedArgs };
3220
+ }
3221
+ function hasForwardedFlag(args, ...flags) {
3222
+ return args.some((arg) => flags.includes(arg));
3223
+ }
3224
+ function forwardedBoolean(args, name) {
3225
+ const inline = args.find((arg) => arg.startsWith(`${name}=`));
3226
+ if (inline) return parseBooleanLike(inline.slice(name.length + 1));
3227
+ return args.includes(name) ? true : void 0;
2859
3228
  }
2860
3229
  async function main() {
2861
3230
  const implicit = readImplicitInvocation(process.argv.slice(2));
@@ -2873,14 +3242,15 @@ async function main() {
2873
3242
  });
2874
3243
  console.log(formatDetectedDevServices(services));
2875
3244
  if (implicit.dryRun) return;
3245
+ if (hasForwardedFlag(implicit.forwardedArgs, "--clean-caddy")) cleanManagedCaddyProcesses();
2876
3246
  await runDetectedServices({
2877
3247
  cwd: implicit.cwd,
2878
3248
  services,
2879
3249
  configPath: projectConfig.path,
2880
3250
  projectName: sanitizeProjectName(projectConfig.config.project ?? getProjectName(implicit.cwd)),
2881
- https: projectConfig.config.https ?? false,
2882
- dynamicPort: projectConfig.config.dynamicPort ?? true,
2883
- autoRepair: projectConfig.config.autoRepair ?? true
3251
+ https: hasForwardedFlag(implicit.forwardedArgs, "--https", "--ssl") || (projectConfig.config.https ?? false),
3252
+ dynamicPort: forwardedBoolean(implicit.forwardedArgs, "--dynamic-port") ?? projectConfig.config.dynamicPort ?? true,
3253
+ autoRepair: forwardedBoolean(implicit.forwardedArgs, "--auto-repair") ?? projectConfig.config.autoRepair ?? true
2884
3254
  });
2885
3255
  await maybeNotifyAboutUpdate({ disabled: !implicit.updateCheck });
2886
3256
  return;
@@ -2896,6 +3266,7 @@ async function main() {
2896
3266
  process.argv[1] ?? "localghost",
2897
3267
  ...implicit.updateCheck ? [] : ["--no-update-check"],
2898
3268
  "run",
3269
+ ...implicit.forwardedArgs,
2899
3270
  "--cwd",
2900
3271
  implicit.cwd,
2901
3272
  "--",