@hamedb89/localghost 0.5.0 → 0.6.2

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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync } from "fs";
4
+ import { existsSync as existsSync9, readFileSync as readFileSync9, unlinkSync } from "fs";
5
5
  import { Command, InvalidArgumentError } from "commander";
6
6
 
7
7
  // src/activity.ts
@@ -480,20 +480,20 @@ function formatDetectedDevServices(services) {
480
480
  }
481
481
 
482
482
  // src/context.ts
483
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
483
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
484
484
  import { join as join6 } from "path";
485
485
  import { pathToFileURL } from "url";
486
486
 
487
487
  // src/port.ts
488
488
  import { createServer } from "net";
489
489
  async function isPortAvailable(port, host = "127.0.0.1") {
490
- return new Promise((resolve5) => {
490
+ return new Promise((resolve6) => {
491
491
  const server = createServer();
492
492
  server.once("error", () => {
493
- resolve5(false);
493
+ resolve6(false);
494
494
  });
495
495
  server.once("listening", () => {
496
- server.close(() => resolve5(true));
496
+ server.close(() => resolve6(true));
497
497
  });
498
498
  server.listen(port, host);
499
499
  });
@@ -546,6 +546,9 @@ function validRegistry(value) {
546
546
  function pruneRegistry(registry, now, isRunning) {
547
547
  registry.leases = registry.leases.filter((lease) => lease.expiresAt > now && isRunning(lease.pid));
548
548
  }
549
+ function isTestSessionKey(instanceKey) {
550
+ return instanceKey.startsWith("test:");
551
+ }
549
552
  async function readJson(path) {
550
553
  try {
551
554
  return JSON.parse(await readFile(path, "utf8"));
@@ -642,6 +645,37 @@ function createLocalghostRegistry(options = {}) {
642
645
  await releaseLock();
643
646
  }
644
647
  },
648
+ async pruneTestSessions() {
649
+ const releaseLock = await lock();
650
+ try {
651
+ const registry = await readRegistry();
652
+ const staleTestKeys = new Set(
653
+ registry.leases.filter((lease) => isTestSessionKey(lease.instanceKey) && (lease.expiresAt <= now() || !isRunning(lease.pid))).map((lease) => leaseKey(lease.projectCwd, lease.instanceKey))
654
+ );
655
+ const beforeLeases = registry.leases.length;
656
+ registry.leases = registry.leases.filter((lease) => !staleTestKeys.has(leaseKey(lease.projectCwd, lease.instanceKey)));
657
+ const activeKeys = new Set(registry.leases.map((lease) => leaseKey(lease.projectCwd, lease.instanceKey)));
658
+ const beforeAllocations = registry.allocations.length;
659
+ registry.allocations = registry.allocations.filter(
660
+ (allocation) => !isTestSessionKey(allocation.instanceKey) || activeKeys.has(leaseKey(allocation.projectCwd, allocation.instanceKey))
661
+ );
662
+ await writeRegistry(registry);
663
+ return {
664
+ removedLeases: beforeLeases - registry.leases.length,
665
+ removedAllocations: beforeAllocations - registry.allocations.length
666
+ };
667
+ } finally {
668
+ await releaseLock();
669
+ }
670
+ },
671
+ async reset() {
672
+ const releaseLock = await lock();
673
+ try {
674
+ await writeRegistry({ version: 1, allocations: [], leases: [] });
675
+ } finally {
676
+ await releaseLock();
677
+ }
678
+ },
645
679
  async acquirePort(acquireOptions) {
646
680
  const projectCwd = canonicalizeLocalghostProjectCwd(acquireOptions.projectCwd ?? cwd);
647
681
  if (!acquireOptions.instanceKey) throw new Error("instanceKey is required");
@@ -704,7 +738,7 @@ function createLocalghostRegistry(options = {}) {
704
738
  };
705
739
  }
706
740
 
707
- // src/tunnel.ts
741
+ // packages/ghost-tunnel/src/tunnel.ts
708
742
  import { domainToASCII } from "url";
709
743
  var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
710
744
  var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
@@ -1055,275 +1089,7 @@ function constructGhostTunnelUrl(input2) {
1055
1089
  return url.toString();
1056
1090
  }
1057
1091
 
1058
- // src/context.ts
1059
- var LOCALGHOST_PROJECT_CONFIG_FILES = [
1060
- "localghost.config.mjs",
1061
- "localghost.config.js",
1062
- "localghost.config.cjs"
1063
- ];
1064
- function parsePort(value) {
1065
- if (!value) return void 0;
1066
- const port = Number.parseInt(value, 10);
1067
- return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
1068
- }
1069
- function envPort() {
1070
- return parsePort(process.env.LOCALGHOST_PORT) ?? parsePort(process.env.VITE_PORT);
1071
- }
1072
- function envDynamicPort() {
1073
- const value = process.env.LOCALGHOST_DYNAMIC_PORT;
1074
- if (!value) return void 0;
1075
- return ["1", "true", "yes", "on"].includes(value.toLowerCase());
1076
- }
1077
- function envHttps() {
1078
- const value = process.env.LOCALGHOST_HTTPS;
1079
- if (!value) return void 0;
1080
- return ["1", "true", "yes", "on"].includes(value.toLowerCase());
1081
- }
1082
- function getPackageName(cwd) {
1083
- try {
1084
- const pkg = JSON.parse(readFileSync5(join6(cwd, "package.json"), "utf8"));
1085
- return typeof pkg.name === "string" ? pkg.name : void 0;
1086
- } catch {
1087
- return void 0;
1088
- }
1089
- }
1090
- function getPackageOwner(cwd) {
1091
- const packageName = getPackageName(cwd);
1092
- if (!packageName?.startsWith("@")) return void 0;
1093
- return packageName.slice(1).split("/")[0];
1094
- }
1095
- function getLocalOwner(cwd) {
1096
- return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
1097
- }
1098
- function getRouteName(primaryHost, fallback) {
1099
- return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
1100
- }
1101
- function readOptionsFromContext(options) {
1102
- return {
1103
- cwd: options.cwd ?? process.cwd(),
1104
- ...options.fileName ? { fileName: options.fileName } : {},
1105
- ...options.configFiles ? { configFiles: options.configFiles } : {},
1106
- ...options.configPattern ? { configPattern: options.configPattern } : {}
1107
- };
1108
- }
1109
- function withRuntimePort(entries, requestedPort, port) {
1110
- if (requestedPort === port) return entries;
1111
- const hasRequestedPort = entries.some((entry) => entry.port === requestedPort);
1112
- if (!hasRequestedPort) return entries;
1113
- return entries.map((entry) => entry.port === requestedPort ? { ...entry, port, target: `127.0.0.1:${port}` } : entry);
1114
- }
1115
- function uniqueHosts(entries) {
1116
- return [...new Set(entries.map((entry) => entry.host))];
1117
- }
1118
- function isAliasableHost(host) {
1119
- return host.includes(".") && !host.startsWith("www.") && !host.includes(":");
1120
- }
1121
- function getDefaultWwwAlias(host) {
1122
- return isAliasableHost(host) ? `www.${host}` : null;
1123
- }
1124
- function addDefaultWwwAliases(entries) {
1125
- const seen = new Set(entries.map((entry) => entry.host));
1126
- const aliases = [];
1127
- for (const entry of entries) {
1128
- const alias = getDefaultWwwAlias(entry.host);
1129
- if (alias && !seen.has(alias)) {
1130
- aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
1131
- seen.add(alias);
1132
- }
1133
- }
1134
- return [...entries, ...aliases];
1135
- }
1136
- function defined(input2) {
1137
- return Object.fromEntries(Object.entries(input2).filter(([, value]) => typeof value !== "undefined"));
1138
- }
1139
- async function readLocalghostProjectConfig(options = {}) {
1140
- const cwd = options.cwd ?? process.cwd();
1141
- if (options.configFile === false) return { config: {} };
1142
- const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
1143
- const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync4(candidate));
1144
- if (!path) return { config: {} };
1145
- const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
1146
- const config = imported.default ?? imported;
1147
- return { config, path };
1148
- }
1149
- async function resolveLocalghostContext(options = {}) {
1150
- const cwd = options.cwd ?? process.cwd();
1151
- const projectConfig = await readLocalghostProjectConfig({
1152
- cwd,
1153
- ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
1154
- });
1155
- const merged = {
1156
- ...projectConfig.config,
1157
- ...defined(options)
1158
- };
1159
- const readOptions = readOptionsFromContext({ ...merged, cwd });
1160
- const resolvedPath = resolveDevHostsPath(readOptions);
1161
- const configEntries = readDevHosts(readOptions);
1162
- const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
1163
- const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
1164
- const autoRepair = merged.autoRepair ?? true;
1165
- const bindHost = merged.bindHost ?? "127.0.0.1";
1166
- const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
1167
- let port = requestedPort;
1168
- let releasePort;
1169
- const reservePort = merged.reservePort ?? false;
1170
- const instanceKey = merged.instanceKey ?? "run";
1171
- if (reservePort && dynamicPort) {
1172
- const registry = createLocalghostRegistry({ cwd, ...merged.registryOwnerToken ? { ownerToken: merged.registryOwnerToken } : {} });
1173
- const lease = await registry.acquirePort({
1174
- projectCwd: cwd,
1175
- instanceKey,
1176
- startPort: requestedPort,
1177
- host: probeHost,
1178
- ...options.reservedPorts ? { reservedPorts: options.reservedPorts } : {}
1179
- });
1180
- port = lease.port;
1181
- releasePort = () => registry.releasePort({ projectCwd: cwd, instanceKey });
1182
- } else if (dynamicPort) {
1183
- port = await findAvailablePort(requestedPort, { host: probeHost });
1184
- }
1185
- const wwwAlias = merged.wwwAlias ?? true;
1186
- const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
1187
- const hosts = uniqueHosts(entries);
1188
- const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
1189
- const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
1190
- const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
1191
- route: getRouteName(primaryHost, projectName),
1192
- project: projectName,
1193
- owner: getLocalOwner(cwd)
1194
- });
1195
- return {
1196
- cwd,
1197
- projectName,
1198
- readOptions,
1199
- configPath: resolvedPath.path,
1200
- configFileName: resolvedPath.fileName,
1201
- configEntries,
1202
- entries,
1203
- hosts,
1204
- requestedPort,
1205
- port,
1206
- dynamicPort,
1207
- autoRepair,
1208
- bindHost,
1209
- primaryHost,
1210
- https: merged.https ?? envHttps() ?? false,
1211
- wwwAlias,
1212
- ghostTunnel,
1213
- ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
1214
- ...releasePort ? { releasePort } : {}
1215
- };
1216
- }
1217
-
1218
- // src/doctor.ts
1219
- import { execa as execa2 } from "execa";
1220
- async function checkCaddy() {
1221
- try {
1222
- const result = await execa2("caddy", ["version"], { reject: false });
1223
- const version = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
1224
- return {
1225
- found: result.exitCode === 0,
1226
- ...version ? { version } : {},
1227
- installHint: "brew install caddy"
1228
- };
1229
- } catch {
1230
- return {
1231
- found: false,
1232
- installHint: "brew install caddy"
1233
- };
1234
- }
1235
- }
1236
- async function runDoctor(options = {}) {
1237
- const caddy = await checkCaddy();
1238
- const cwd = options.cwd ?? process.cwd();
1239
- const registry = createLocalghostRegistry({ cwd });
1240
- const data = await registry.read();
1241
- const now = Date.now();
1242
- const staleLeases = data.leases.filter((lease) => lease.expiresAt <= now || !isProcessRunning(lease.pid)).map(({ projectCwd, instanceKey, port, pid }) => ({ projectCwd, instanceKey, port, pid }));
1243
- const allocationsByPort = /* @__PURE__ */ new Map();
1244
- for (const allocation of data.allocations) {
1245
- const projects = allocationsByPort.get(allocation.port) ?? [];
1246
- projects.push(`${allocation.projectCwd}#${allocation.instanceKey}`);
1247
- allocationsByPort.set(allocation.port, projects);
1248
- }
1249
- const duplicateAllocations = [...allocationsByPort.entries()].filter(([, projects]) => projects.length > 1).map(([port, projects]) => ({ port, projects }));
1250
- let configured;
1251
- let available;
1252
- try {
1253
- const context = await resolveLocalghostContext({
1254
- cwd,
1255
- ...options.configFiles ? { configFiles: options.configFiles } : {},
1256
- ...options.configPattern ? { configPattern: options.configPattern } : {},
1257
- dynamicPort: false
1258
- });
1259
- configured = context.requestedPort;
1260
- available = await isPortAvailable(configured);
1261
- } catch {
1262
- }
1263
- const currentProjectCwd = canonicalizeLocalghostProjectCwd(cwd);
1264
- const currentAllocation = data.allocations.find((allocation) => allocation.projectCwd === currentProjectCwd);
1265
- return {
1266
- ok: caddy.found && available !== false && staleLeases.length === 0 && duplicateAllocations.length === 0,
1267
- caddy,
1268
- ports: {
1269
- ...configured !== void 0 ? { configured } : {},
1270
- ...available !== void 0 ? { available } : {},
1271
- registryPath: registry.registryPath,
1272
- staleLeases,
1273
- duplicateAllocations,
1274
- ...currentAllocation ? {
1275
- currentAllocation: {
1276
- projectCwd: currentAllocation.projectCwd,
1277
- instanceKey: currentAllocation.instanceKey,
1278
- port: currentAllocation.port
1279
- }
1280
- } : {}
1281
- }
1282
- };
1283
- }
1284
-
1285
- // src/env.ts
1286
- function getProductionReason(env = process.env) {
1287
- if (env.LOCALGHOST_ENV === "production") return "LOCALGHOST_ENV=production";
1288
- if (env.NODE_ENV === "production") return "NODE_ENV=production";
1289
- if (env.VERCEL_ENV === "production") return "VERCEL_ENV=production";
1290
- if (env.NETLIFY === "true" && env.CONTEXT === "production") return "NETLIFY=true and CONTEXT=production";
1291
- if (env.CF_PAGES_BRANCH && env.CF_PAGES_BRANCH === env.CF_PAGES_PRODUCTION_BRANCH) {
1292
- return "CF_PAGES_BRANCH matches CF_PAGES_PRODUCTION_BRANCH";
1293
- }
1294
- return null;
1295
- }
1296
- function assertLocalDevelopment(command, env = process.env) {
1297
- const reason = getProductionReason(env);
1298
- if (!reason) return;
1299
- throw new Error(`Localghost only runs in local development. Refusing \`${command}\` because ${reason}.`);
1300
- }
1301
-
1302
- // src/ghost-file.ts
1303
- var LOCALGHOST_GHOST_TUNNEL_FILE = ".ghosttunnel";
1304
- function toGhostTunnelOptions(options = {}) {
1305
- const resolved = typeof options === "string" ? { cwd: options } : options;
1306
- return {
1307
- ...resolved,
1308
- fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
1309
- };
1310
- }
1311
- function resolveGhostTunnelPath(options = {}) {
1312
- return resolveDevHostsPath(toGhostTunnelOptions(options));
1313
- }
1314
- function readGhostTunnelEntries(options = {}) {
1315
- return readDevHosts(toGhostTunnelOptions(options));
1316
- }
1317
- function listGhostTunnelEntries(options = {}) {
1318
- const resolved = resolveGhostTunnelPath(options);
1319
- if (!resolved.exists) return [];
1320
- return readGhostTunnelEntries(options);
1321
- }
1322
-
1323
- // src/ghost-agent.ts
1324
- import { randomUUID as randomUUID3 } from "crypto";
1325
-
1326
- // src/relay.ts
1092
+ // packages/ghost-tunnel/src/relay.ts
1327
1093
  import { createHmac, timingSafeEqual } from "crypto";
1328
1094
  import { domainToASCII as domainToASCII2 } from "url";
1329
1095
  var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
@@ -1432,7 +1198,73 @@ function stripRelayForwardHeaders(headers) {
1432
1198
  return stripped;
1433
1199
  }
1434
1200
 
1435
- // src/ghost-tunnel-store.ts
1201
+ // packages/ghost-tunnel/src/ghost-file.ts
1202
+ import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
1203
+ import { basename as basename2, resolve as resolve4 } from "path";
1204
+ var LOCALGHOST_GHOST_TUNNEL_FILE = ".ghosttunnel";
1205
+ function getCandidates(options) {
1206
+ const exact = [.../* @__PURE__ */ new Set([...options.fileName ? [options.fileName] : [], ...options.configFiles ?? []])];
1207
+ const pattern = options.configPattern ? readdirSync2(options.cwd ?? process.cwd(), { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name).filter((name) => {
1208
+ const matcher = typeof options.configPattern === "string" ? new RegExp(options.configPattern) : options.configPattern;
1209
+ if (!matcher) return false;
1210
+ matcher.lastIndex = 0;
1211
+ return matcher.test(name);
1212
+ }).sort() : [];
1213
+ if (exact.length > 0 || pattern.length > 0) return [.../* @__PURE__ */ new Set([...exact, ...pattern])];
1214
+ return [LOCALGHOST_GHOST_TUNNEL_FILE];
1215
+ }
1216
+ function parseGhostTunnelEntries(input2, fileName) {
1217
+ const entries = [];
1218
+ input2.split(/\r?\n/).forEach((rawLine, index) => {
1219
+ const line = rawLine.replace(/#.*/, "").trim();
1220
+ if (!line) return;
1221
+ const parts = line.split(/\s+/);
1222
+ const host = parts[0];
1223
+ const portRaw = parts[1];
1224
+ if (!host || !portRaw || parts.length > 2) {
1225
+ throw new Error(`Invalid ${fileName} line ${index + 1}: "${rawLine}"`);
1226
+ }
1227
+ if (!/^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*\.?$/i.test(host)) {
1228
+ throw new Error(`Invalid host on line ${index + 1}: "${host}"`);
1229
+ }
1230
+ const port = Number(portRaw);
1231
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1232
+ throw new Error(`Invalid port on line ${index + 1}: "${portRaw}"`);
1233
+ }
1234
+ entries.push({ host: host.toLowerCase().replace(/\.$/, ""), port, target: `127.0.0.1:${port}` });
1235
+ });
1236
+ return entries;
1237
+ }
1238
+ function toGhostTunnelOptions(options = {}) {
1239
+ const resolved = typeof options === "string" ? { cwd: options } : options;
1240
+ return {
1241
+ ...resolved,
1242
+ fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
1243
+ };
1244
+ }
1245
+ function resolveGhostTunnelPath(options = {}) {
1246
+ const resolved = toGhostTunnelOptions(options);
1247
+ const cwd = resolved.cwd ?? process.cwd();
1248
+ const searchedFiles = getCandidates(resolved);
1249
+ for (const fileName2 of searchedFiles) {
1250
+ const path = resolve4(cwd, fileName2);
1251
+ if (existsSync4(path)) return { path, fileName: basename2(fileName2), exists: true, searchedFiles };
1252
+ }
1253
+ const fileName = searchedFiles[0] ?? LOCALGHOST_GHOST_TUNNEL_FILE;
1254
+ return { path: resolve4(cwd, fileName), fileName: basename2(fileName), exists: false, searchedFiles };
1255
+ }
1256
+ function readGhostTunnelEntries(options = {}) {
1257
+ const resolved = resolveGhostTunnelPath(options);
1258
+ if (!resolved.exists) throw new Error(`Missing Ghost Tunnel file: ${resolved.path}`);
1259
+ return parseGhostTunnelEntries(readFileSync5(resolved.path, "utf8"), resolved.fileName);
1260
+ }
1261
+ function listGhostTunnelEntries(options = {}) {
1262
+ const resolved = resolveGhostTunnelPath(options);
1263
+ if (!resolved.exists) return [];
1264
+ return readGhostTunnelEntries(options);
1265
+ }
1266
+
1267
+ // packages/ghost-tunnel/src/ghost-tunnel-store.ts
1436
1268
  import { randomUUID as randomUUID2 } from "crypto";
1437
1269
  function base64Encode(value) {
1438
1270
  return value.toString("base64");
@@ -1564,17 +1396,18 @@ function createRedisGhostTunnelStoreFromEnv(input2 = {}) {
1564
1396
  });
1565
1397
  }
1566
1398
 
1567
- // src/ghost-agent.ts
1399
+ // packages/ghost-tunnel/src/ghost-agent.ts
1400
+ import { randomUUID as randomUUID3 } from "crypto";
1568
1401
  function isStopped(signal, localSignal) {
1569
1402
  return localSignal.aborted || signal?.aborted === true;
1570
1403
  }
1571
1404
  function wait(ms, signal, localSignal) {
1572
1405
  if (isStopped(signal, localSignal)) return Promise.resolve();
1573
- return new Promise((resolve5) => {
1574
- const timeout = setTimeout(resolve5, ms);
1406
+ return new Promise((resolve6) => {
1407
+ const timeout = setTimeout(resolve6, ms);
1575
1408
  const stop = () => {
1576
1409
  clearTimeout(timeout);
1577
- resolve5();
1410
+ resolve6();
1578
1411
  };
1579
1412
  signal?.addEventListener("abort", stop, { once: true });
1580
1413
  localSignal.addEventListener("abort", stop, { once: true });
@@ -1712,6 +1545,250 @@ function startGhostTunnelAgent(options) {
1712
1545
  };
1713
1546
  }
1714
1547
 
1548
+ // src/context.ts
1549
+ var LOCALGHOST_PROJECT_CONFIG_FILES = [
1550
+ "localghost.config.mjs",
1551
+ "localghost.config.js",
1552
+ "localghost.config.cjs"
1553
+ ];
1554
+ function parsePort(value) {
1555
+ if (!value) return void 0;
1556
+ const port = Number.parseInt(value, 10);
1557
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
1558
+ }
1559
+ function envPort() {
1560
+ return parsePort(process.env.LOCALGHOST_PORT) ?? parsePort(process.env.VITE_PORT);
1561
+ }
1562
+ function envDynamicPort() {
1563
+ const value = process.env.LOCALGHOST_DYNAMIC_PORT;
1564
+ if (!value) return void 0;
1565
+ return ["1", "true", "yes", "on"].includes(value.toLowerCase());
1566
+ }
1567
+ function envHttps() {
1568
+ const value = process.env.LOCALGHOST_HTTPS;
1569
+ if (!value) return void 0;
1570
+ return ["1", "true", "yes", "on"].includes(value.toLowerCase());
1571
+ }
1572
+ function getPackageName(cwd) {
1573
+ try {
1574
+ const pkg = JSON.parse(readFileSync6(join6(cwd, "package.json"), "utf8"));
1575
+ return typeof pkg.name === "string" ? pkg.name : void 0;
1576
+ } catch {
1577
+ return void 0;
1578
+ }
1579
+ }
1580
+ function getPackageOwner(cwd) {
1581
+ const packageName = getPackageName(cwd);
1582
+ if (!packageName?.startsWith("@")) return void 0;
1583
+ return packageName.slice(1).split("/")[0];
1584
+ }
1585
+ function getLocalOwner(cwd) {
1586
+ return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
1587
+ }
1588
+ function getRouteName(primaryHost, fallback) {
1589
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
1590
+ }
1591
+ function readOptionsFromContext(options) {
1592
+ return {
1593
+ cwd: options.cwd ?? process.cwd(),
1594
+ ...options.fileName ? { fileName: options.fileName } : {},
1595
+ ...options.configFiles ? { configFiles: options.configFiles } : {},
1596
+ ...options.configPattern ? { configPattern: options.configPattern } : {}
1597
+ };
1598
+ }
1599
+ function withRuntimePort(entries, requestedPort, port) {
1600
+ if (requestedPort === port) return entries;
1601
+ const hasRequestedPort = entries.some((entry) => entry.port === requestedPort);
1602
+ if (!hasRequestedPort) return entries;
1603
+ return entries.map((entry) => entry.port === requestedPort ? { ...entry, port, target: `127.0.0.1:${port}` } : entry);
1604
+ }
1605
+ function uniqueHosts(entries) {
1606
+ return [...new Set(entries.map((entry) => entry.host))];
1607
+ }
1608
+ function isAliasableHost(host) {
1609
+ return host.includes(".") && !host.startsWith("www.") && !host.includes(":");
1610
+ }
1611
+ function getDefaultWwwAlias(host) {
1612
+ return isAliasableHost(host) ? `www.${host}` : null;
1613
+ }
1614
+ function addDefaultWwwAliases(entries) {
1615
+ const seen = new Set(entries.map((entry) => entry.host));
1616
+ const aliases = [];
1617
+ for (const entry of entries) {
1618
+ const alias = getDefaultWwwAlias(entry.host);
1619
+ if (alias && !seen.has(alias)) {
1620
+ aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
1621
+ seen.add(alias);
1622
+ }
1623
+ }
1624
+ return [...entries, ...aliases];
1625
+ }
1626
+ function defined(input2) {
1627
+ return Object.fromEntries(Object.entries(input2).filter(([, value]) => typeof value !== "undefined"));
1628
+ }
1629
+ async function readLocalghostProjectConfig(options = {}) {
1630
+ const cwd = options.cwd ?? process.cwd();
1631
+ if (options.configFile === false) return { config: {} };
1632
+ const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
1633
+ const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync5(candidate));
1634
+ if (!path) return { config: {} };
1635
+ const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
1636
+ const config = imported.default ?? imported;
1637
+ return { config, path };
1638
+ }
1639
+ async function resolveLocalghostContext(options = {}) {
1640
+ const cwd = options.cwd ?? process.cwd();
1641
+ const projectConfig = await readLocalghostProjectConfig({
1642
+ cwd,
1643
+ ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
1644
+ });
1645
+ const merged = {
1646
+ ...projectConfig.config,
1647
+ ...defined(options)
1648
+ };
1649
+ const readOptions = readOptionsFromContext({ ...merged, cwd });
1650
+ const resolvedPath = resolveDevHostsPath(readOptions);
1651
+ const configEntries = readDevHosts(readOptions);
1652
+ const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
1653
+ const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
1654
+ const autoRepair = merged.autoRepair ?? true;
1655
+ const bindHost = merged.bindHost ?? "127.0.0.1";
1656
+ const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
1657
+ let port = requestedPort;
1658
+ let releasePort;
1659
+ const reservePort = merged.reservePort ?? false;
1660
+ const instanceKey = merged.instanceKey ?? "run";
1661
+ if (reservePort && dynamicPort) {
1662
+ const registry = createLocalghostRegistry({ cwd, ...merged.registryOwnerToken ? { ownerToken: merged.registryOwnerToken } : {} });
1663
+ const lease = await registry.acquirePort({
1664
+ projectCwd: cwd,
1665
+ instanceKey,
1666
+ startPort: requestedPort,
1667
+ host: probeHost,
1668
+ ...options.reservedPorts ? { reservedPorts: options.reservedPorts } : {}
1669
+ });
1670
+ port = lease.port;
1671
+ releasePort = () => registry.releasePort({ projectCwd: cwd, instanceKey });
1672
+ } else if (dynamicPort) {
1673
+ port = await findAvailablePort(requestedPort, { host: probeHost });
1674
+ }
1675
+ const wwwAlias = merged.wwwAlias ?? true;
1676
+ const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
1677
+ const hosts = uniqueHosts(entries);
1678
+ const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
1679
+ const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
1680
+ const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
1681
+ route: getRouteName(primaryHost, projectName),
1682
+ project: projectName,
1683
+ owner: getLocalOwner(cwd)
1684
+ });
1685
+ return {
1686
+ cwd,
1687
+ projectName,
1688
+ readOptions,
1689
+ configPath: resolvedPath.path,
1690
+ configFileName: resolvedPath.fileName,
1691
+ configEntries,
1692
+ entries,
1693
+ hosts,
1694
+ requestedPort,
1695
+ port,
1696
+ dynamicPort,
1697
+ autoRepair,
1698
+ bindHost,
1699
+ primaryHost,
1700
+ https: merged.https ?? envHttps() ?? false,
1701
+ wwwAlias,
1702
+ ghostTunnel,
1703
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
1704
+ ...releasePort ? { releasePort } : {}
1705
+ };
1706
+ }
1707
+
1708
+ // src/doctor.ts
1709
+ import { execa as execa2 } from "execa";
1710
+ async function checkCaddy() {
1711
+ try {
1712
+ const result = await execa2("caddy", ["version"], { reject: false });
1713
+ const version = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
1714
+ return {
1715
+ found: result.exitCode === 0,
1716
+ ...version ? { version } : {},
1717
+ installHint: "brew install caddy"
1718
+ };
1719
+ } catch {
1720
+ return {
1721
+ found: false,
1722
+ installHint: "brew install caddy"
1723
+ };
1724
+ }
1725
+ }
1726
+ async function runDoctor(options = {}) {
1727
+ const caddy = await checkCaddy();
1728
+ const cwd = options.cwd ?? process.cwd();
1729
+ const registry = createLocalghostRegistry({ cwd });
1730
+ const data = await registry.read();
1731
+ const now = Date.now();
1732
+ const staleLeases = data.leases.filter((lease) => lease.expiresAt <= now || !isProcessRunning(lease.pid)).map(({ projectCwd, instanceKey, port, pid }) => ({ projectCwd, instanceKey, port, pid }));
1733
+ const allocationsByPort = /* @__PURE__ */ new Map();
1734
+ for (const allocation of data.allocations) {
1735
+ const projects = allocationsByPort.get(allocation.port) ?? [];
1736
+ projects.push(`${allocation.projectCwd}#${allocation.instanceKey}`);
1737
+ allocationsByPort.set(allocation.port, projects);
1738
+ }
1739
+ const duplicateAllocations = [...allocationsByPort.entries()].filter(([, projects]) => projects.length > 1).map(([port, projects]) => ({ port, projects }));
1740
+ let configured;
1741
+ let available;
1742
+ try {
1743
+ const context = await resolveLocalghostContext({
1744
+ cwd,
1745
+ ...options.configFiles ? { configFiles: options.configFiles } : {},
1746
+ ...options.configPattern ? { configPattern: options.configPattern } : {},
1747
+ dynamicPort: false
1748
+ });
1749
+ configured = context.requestedPort;
1750
+ available = await isPortAvailable(configured);
1751
+ } catch {
1752
+ }
1753
+ const currentProjectCwd = canonicalizeLocalghostProjectCwd(cwd);
1754
+ const currentAllocation = data.allocations.find((allocation) => allocation.projectCwd === currentProjectCwd);
1755
+ return {
1756
+ ok: caddy.found && available !== false && staleLeases.length === 0 && duplicateAllocations.length === 0,
1757
+ caddy,
1758
+ ports: {
1759
+ ...configured !== void 0 ? { configured } : {},
1760
+ ...available !== void 0 ? { available } : {},
1761
+ registryPath: registry.registryPath,
1762
+ staleLeases,
1763
+ duplicateAllocations,
1764
+ ...currentAllocation ? {
1765
+ currentAllocation: {
1766
+ projectCwd: currentAllocation.projectCwd,
1767
+ instanceKey: currentAllocation.instanceKey,
1768
+ port: currentAllocation.port
1769
+ }
1770
+ } : {}
1771
+ }
1772
+ };
1773
+ }
1774
+
1775
+ // src/env.ts
1776
+ function getProductionReason(env = process.env) {
1777
+ if (env.LOCALGHOST_ENV === "production") return "LOCALGHOST_ENV=production";
1778
+ if (env.NODE_ENV === "production") return "NODE_ENV=production";
1779
+ if (env.VERCEL_ENV === "production") return "VERCEL_ENV=production";
1780
+ if (env.NETLIFY === "true" && env.CONTEXT === "production") return "NETLIFY=true and CONTEXT=production";
1781
+ if (env.CF_PAGES_BRANCH && env.CF_PAGES_BRANCH === env.CF_PAGES_PRODUCTION_BRANCH) {
1782
+ return "CF_PAGES_BRANCH matches CF_PAGES_PRODUCTION_BRANCH";
1783
+ }
1784
+ return null;
1785
+ }
1786
+ function assertLocalDevelopment(command, env = process.env) {
1787
+ const reason = getProductionReason(env);
1788
+ if (!reason) return;
1789
+ throw new Error(`Localghost only runs in local development. Refusing \`${command}\` because ${reason}.`);
1790
+ }
1791
+
1715
1792
  // src/hosts-file.ts
1716
1793
  import { writeFileSync as writeFileSync3 } from "fs";
1717
1794
  import { tmpdir } from "os";
@@ -1795,19 +1872,19 @@ async function removeSystemHosts(projectName) {
1795
1872
  }
1796
1873
 
1797
1874
  // src/init.ts
1798
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1799
- import { dirname as dirname4, join as join8, resolve as resolve4 } from "path";
1875
+ import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
1876
+ import { dirname as dirname4, join as join8, resolve as resolve5 } from "path";
1800
1877
  function detectPackageManager(cwd = process.cwd()) {
1801
- if (existsSync5(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
1802
- if (existsSync5(join8(cwd, "yarn.lock"))) return "yarn";
1803
- if (existsSync5(join8(cwd, "bun.lock")) || existsSync5(join8(cwd, "bun.lockb"))) return "bun";
1878
+ if (existsSync6(join8(cwd, "pnpm-lock.yaml"))) return "pnpm";
1879
+ if (existsSync6(join8(cwd, "yarn.lock"))) return "yarn";
1880
+ if (existsSync6(join8(cwd, "bun.lock")) || existsSync6(join8(cwd, "bun.lockb"))) return "bun";
1804
1881
  return "npm";
1805
1882
  }
1806
1883
  function isPnpmWorkspaceRoot(cwd = process.cwd()) {
1807
- const target = resolve4(cwd);
1884
+ const target = resolve5(cwd);
1808
1885
  let current = target;
1809
1886
  while (true) {
1810
- if (existsSync5(join8(current, "pnpm-workspace.yaml"))) return current === target;
1887
+ if (existsSync6(join8(current, "pnpm-workspace.yaml"))) return current === target;
1811
1888
  const parent = dirname4(current);
1812
1889
  if (parent === current) return false;
1813
1890
  current = parent;
@@ -1831,7 +1908,7 @@ function renderConfig(options) {
1831
1908
  }
1832
1909
  function readPackageJson2(path) {
1833
1910
  try {
1834
- return JSON.parse(readFileSync6(path, "utf8"));
1911
+ return JSON.parse(readFileSync7(path, "utf8"));
1835
1912
  } catch {
1836
1913
  return null;
1837
1914
  }
@@ -1899,7 +1976,7 @@ function initLocalghost(options = {}) {
1899
1976
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
1900
1977
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
1901
1978
  const configPath = join8(cwd, configFile);
1902
- const configExists = existsSync5(configPath);
1979
+ const configExists = existsSync6(configPath);
1903
1980
  if (configExists && !options.force) {
1904
1981
  return {
1905
1982
  configPath,
@@ -1920,7 +1997,7 @@ function initLocalghost(options = {}) {
1920
1997
  return {
1921
1998
  configPath,
1922
1999
  configCreated: true,
1923
- ...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
2000
+ ...existsSync6(packageJsonPath) ? { packageJsonPath } : {},
1924
2001
  packageJsonChanged,
1925
2002
  packageManager,
1926
2003
  nextSteps: [
@@ -2149,7 +2226,7 @@ function formatGhostTunnel(config, options = {}) {
2149
2226
  }
2150
2227
 
2151
2228
  // src/state.ts
2152
- import { existsSync as existsSync6 } from "fs";
2229
+ import { existsSync as existsSync7 } from "fs";
2153
2230
  import { join as join9 } from "path";
2154
2231
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
2155
2232
  function getLocalghostStatePath(cwd = process.cwd()) {
@@ -2157,7 +2234,7 @@ function getLocalghostStatePath(cwd = process.cwd()) {
2157
2234
  }
2158
2235
  function readLocalghostState(cwd = process.cwd()) {
2159
2236
  const path = getLocalghostStatePath(cwd);
2160
- if (!existsSync6(path)) return null;
2237
+ if (!existsSync7(path)) return null;
2161
2238
  return JSON.parse(readTextFile(path));
2162
2239
  }
2163
2240
  function writeLocalghostState(cwd, state) {
@@ -2173,11 +2250,11 @@ function patchLocalghostState(cwd, patch) {
2173
2250
  }
2174
2251
 
2175
2252
  // src/update-check.ts
2176
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
2253
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
2177
2254
  import { homedir as homedir3 } from "os";
2178
2255
  import { dirname as dirname5, join as join10 } from "path";
2179
2256
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
2180
- var LOCALGHOST_VERSION = "0.5.0";
2257
+ var LOCALGHOST_VERSION = "0.6.2";
2181
2258
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2182
2259
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
2183
2260
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -2193,9 +2270,9 @@ function getUpdateCheckCachePath(env = process.env) {
2193
2270
  return join10(cacheRoot, "localghost", "update-check.json");
2194
2271
  }
2195
2272
  function readCache(path = getUpdateCheckCachePath()) {
2196
- if (!existsSync7(path)) return null;
2273
+ if (!existsSync8(path)) return null;
2197
2274
  try {
2198
- return JSON.parse(readFileSync7(path, "utf8"));
2275
+ return JSON.parse(readFileSync8(path, "utf8"));
2199
2276
  } catch {
2200
2277
  return null;
2201
2278
  }
@@ -2345,7 +2422,7 @@ import { execa as execa4 } from "execa";
2345
2422
 
2346
2423
  // src/process.ts
2347
2424
  function signalManagedProcessPid(pid, signal, killProcess = (value, processSignal) => process.kill(value, processSignal)) {
2348
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid < 1) return false;
2425
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
2349
2426
  try {
2350
2427
  killProcess(process.platform === "win32" ? pid : -pid, signal);
2351
2428
  return true;
@@ -2519,7 +2596,7 @@ function getSetupReadiness(options) {
2519
2596
  }
2520
2597
  const hostsPath = getSystemHostsPath();
2521
2598
  try {
2522
- const hosts = readFileSync8(hostsPath, "utf8");
2599
+ const hosts = readFileSync9(hostsPath, "utf8");
2523
2600
  const expectedHostsBlock = renderHostsBlock(projectName, entries).trimEnd();
2524
2601
  if (!hosts.includes(expectedHostsBlock)) {
2525
2602
  reasons.push(`The Localghost hosts block in ${hostsPath} is missing or stale.`);
@@ -2529,11 +2606,11 @@ function getSetupReadiness(options) {
2529
2606
  reasons.push(`Could not read ${hostsPath}: ${message}`);
2530
2607
  }
2531
2608
  if (!options.ignoreCaddyfile) {
2532
- if (!existsSync8(caddyfilePath)) {
2609
+ if (!existsSync9(caddyfilePath)) {
2533
2610
  reasons.push(`Missing Caddyfile at ${caddyfilePath}.`);
2534
2611
  } else {
2535
2612
  const expectedCaddyfile = renderCaddyfile(entries, { https });
2536
- const currentCaddyfile = readFileSync8(caddyfilePath, "utf8");
2613
+ const currentCaddyfile = readFileSync9(caddyfilePath, "utf8");
2537
2614
  if (currentCaddyfile !== expectedCaddyfile) {
2538
2615
  reasons.push(`Caddyfile at ${caddyfilePath} is stale for ${https ? "HTTPS" : "HTTP"} mode.`);
2539
2616
  }
@@ -2578,7 +2655,7 @@ async function runSetupFromReadiness(cwd, https, readiness) {
2578
2655
  });
2579
2656
  }
2580
2657
  function wait2(ms) {
2581
- return new Promise((resolve5) => setTimeout(resolve5, ms));
2658
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
2582
2659
  }
2583
2660
  async function runTrust(cwd, caddyfilePath) {
2584
2661
  await wait2(350);
@@ -2697,10 +2774,13 @@ function registerSignalShutdown(stop) {
2697
2774
  };
2698
2775
  process.once("SIGINT", request);
2699
2776
  process.once("SIGTERM", request);
2700
- return () => {
2701
- process.off("SIGINT", request);
2702
- process.off("SIGTERM", request);
2703
- request();
2777
+ return {
2778
+ wasRequested: () => requested,
2779
+ finish: () => {
2780
+ process.off("SIGINT", request);
2781
+ process.off("SIGTERM", request);
2782
+ if (!requested) stop();
2783
+ }
2704
2784
  };
2705
2785
  }
2706
2786
  async function resolveServiceRuntimeEntries(services, dynamicPort, projectCwd) {
@@ -2752,11 +2832,22 @@ async function waitForPortsToBeAvailable(entries, timeoutMs = 1e4) {
2752
2832
  const deadline = Date.now() + timeoutMs;
2753
2833
  const ports = [...new Set(entries.map((entry) => entry.port))];
2754
2834
  while (Date.now() < deadline) {
2755
- const availability = await Promise.all(ports.map((port) => isPortAvailable(port)));
2756
- if (availability.every(Boolean)) return true;
2835
+ const availability2 = await Promise.all(ports.map((port) => isPortAvailable(port)));
2836
+ if (availability2.every(Boolean)) return { available: true, blocked: [] };
2757
2837
  await wait2(50);
2758
2838
  }
2759
- return false;
2839
+ const availability = await Promise.all(entries.map(async (entry) => ({
2840
+ entry,
2841
+ available: await isPortAvailable(entry.port)
2842
+ })));
2843
+ const blocked = availability.filter(({ available }) => !available).map(({ entry }) => entry);
2844
+ return { available: blocked.length === 0, blocked };
2845
+ }
2846
+ function formatBlockedPorts(entries) {
2847
+ return [...new Map(entries.map((entry) => [
2848
+ `${entry.host}:${entry.port}`,
2849
+ `${entry.host} (port ${entry.port})`
2850
+ ])).values()].join(", ");
2760
2851
  }
2761
2852
  async function waitForProcessShutdown(processes, forceStop, timeoutMs = 1e4) {
2762
2853
  const settled = Promise.allSettled(processes);
@@ -2847,7 +2938,7 @@ async function runDetectedServices(options) {
2847
2938
  }
2848
2939
  signalManagedProcess(caddy, signal);
2849
2940
  };
2850
- const finishShutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
2941
+ const shutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
2851
2942
  try {
2852
2943
  const ready = await Promise.race([
2853
2944
  waitForServicePorts(entries),
@@ -2859,15 +2950,18 @@ async function runDetectedServices(options) {
2859
2950
  }
2860
2951
  await processExit;
2861
2952
  } finally {
2862
- finishShutdown();
2953
+ shutdown.finish();
2863
2954
  await waitForProcessShutdown(
2864
2955
  [caddyExit, ...children],
2865
2956
  stopManaged
2866
2957
  );
2867
- if (!await waitForPortsToBeAvailable(entries)) {
2868
- console.warn("Localghost: timed out waiting for service ports to be released.");
2958
+ const drained = await waitForPortsToBeAvailable(entries);
2959
+ if (!drained.available && !shutdown.wasRequested()) {
2960
+ console.warn(`Localghost: timed out waiting for service ports to be released: ${formatBlockedPorts(drained.blocked)}.`);
2869
2961
  stopManaged("SIGTERM");
2870
- if (!await waitForPortsToBeAvailable(entries, 2e3)) {
2962
+ const terminated = await waitForPortsToBeAvailable(entries, 2e3);
2963
+ if (!terminated.available) {
2964
+ console.warn(`Localghost: service ports still occupied after termination: ${formatBlockedPorts(terminated.blocked)}.`);
2871
2965
  stopManaged("SIGKILL");
2872
2966
  }
2873
2967
  }
@@ -3193,13 +3287,13 @@ program.command("reset").description("Remove Localghost setup state without dele
3193
3287
  const statePath = getLocalghostStatePath(options.cwd);
3194
3288
  explainHostsPassword();
3195
3289
  const hostsResult = await removeSystemHosts(projectName);
3196
- if (existsSync8(caddyfilePath)) {
3290
+ if (existsSync9(caddyfilePath)) {
3197
3291
  unlinkSync(caddyfilePath);
3198
3292
  console.log(`Removed ${caddyfilePath}`);
3199
3293
  } else {
3200
3294
  console.log(`${caddyfilePath} was not present`);
3201
3295
  }
3202
- if (existsSync8(statePath)) {
3296
+ if (existsSync9(statePath)) {
3203
3297
  unlinkSync(statePath);
3204
3298
  console.log(`Removed ${statePath}`);
3205
3299
  } else {
@@ -3220,7 +3314,7 @@ program.command("teardown").description("Remove Localghost's managed /etc/hosts
3220
3314
  const hostsResult = await removeSystemHosts(projectName);
3221
3315
  const caddyfilePath = getCaddyfilePath(options.cwd);
3222
3316
  let caddyfileRemoved = false;
3223
- if (options.removeCaddyfile && existsSync8(caddyfilePath)) {
3317
+ if (options.removeCaddyfile && existsSync9(caddyfilePath)) {
3224
3318
  unlinkSync(caddyfilePath);
3225
3319
  caddyfileRemoved = true;
3226
3320
  }
@@ -3245,6 +3339,15 @@ program.command("teardown").description("Remove Localghost's managed /etc/hosts
3245
3339
  unregisterLocalghostSetup({ cwd: options.cwd, projectName });
3246
3340
  console.log(`State ${statePath}`);
3247
3341
  });
3342
+ program.command("ports").description("Manage Localghost port registry state").addCommand(new Command("prune").description("Remove expired or dead port leases").option("--cwd <path>", "Project directory", process.cwd()).option("--test-only", "Remove only stale test-session leases and records").option("--json", "Print raw JSON").action(async (options) => {
3343
+ const result = options.testOnly ? await createLocalghostRegistry({ cwd: options.cwd }).pruneTestSessions() : await createLocalghostRegistry({ cwd: options.cwd }).prune();
3344
+ if (options.json) console.log(JSON.stringify(result, null, 2));
3345
+ else console.log(`Pruned ${result.removedLeases} stale registry lease${result.removedLeases === 1 ? "" : "s"}${"removedAllocations" in result ? ` and ${result.removedAllocations} test allocation${result.removedAllocations === 1 ? "" : "s"}` : ""}.`);
3346
+ })).addCommand(new Command("reset").description("Clear all remembered allocations and leases").option("--cwd <path>", "Project directory", process.cwd()).option("--yes", "Confirm the destructive registry reset").action(async (options) => {
3347
+ if (!options.yes) throw new Error("Resetting the port registry is destructive. Re-run with --yes.");
3348
+ await createLocalghostRegistry({ cwd: options.cwd }).reset();
3349
+ console.log("Localghost port registry reset.");
3350
+ }));
3248
3351
  program.command("test").description("Run a test command with an isolated Localghost port").option("--cwd <path>", "Project directory", process.cwd()).option("--instance <name>", "Parallel test instance name", String(process.pid)).option("--port <number>", "Initial test port", parsePort2, 5173).option("--lease-ttl <milliseconds>", "Lease lifetime before heartbeat renewal", Number, 30 * 60 * 1e3).argument("<command...>", "Command to run after --").action(async (command, options) => {
3249
3352
  const [binary, ...args] = command;
3250
3353
  if (!binary) throw new Error("Missing command. Use: localghost test -- <command>");
@@ -3524,19 +3627,22 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
3524
3627
  signalManagedProcess(child, signal);
3525
3628
  signalManagedProcess(caddy, signal);
3526
3629
  };
3527
- const finishShutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
3630
+ const shutdown = registerSignalShutdown(() => stopManaged("SIGINT"));
3528
3631
  try {
3529
3632
  await Promise.race([child, caddyExit]);
3530
3633
  } finally {
3531
- finishShutdown();
3634
+ shutdown.finish();
3532
3635
  await waitForProcessShutdown(
3533
3636
  [child, caddyExit],
3534
3637
  stopManaged
3535
3638
  );
3536
- if (!await waitForPortsToBeAvailable(context.entries)) {
3537
- console.warn("Localghost: timed out waiting for service ports to be released.");
3639
+ const drained = await waitForPortsToBeAvailable(context.entries);
3640
+ if (!drained.available && !shutdown.wasRequested()) {
3641
+ console.warn(`Localghost: timed out waiting for service ports to be released: ${formatBlockedPorts(drained.blocked)}.`);
3538
3642
  stopManaged("SIGTERM");
3539
- if (!await waitForPortsToBeAvailable(context.entries, 2e3)) {
3643
+ const terminated = await waitForPortsToBeAvailable(context.entries, 2e3);
3644
+ if (!terminated.available) {
3645
+ console.warn(`Localghost: service ports still occupied after termination: ${formatBlockedPorts(terminated.blocked)}.`);
3540
3646
  stopManaged("SIGKILL");
3541
3647
  }
3542
3648
  }