@hamedb89/localghost 0.1.10 → 0.1.12

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 existsSync7, readFileSync as readFileSync7, unlinkSync } from "fs";
4
+ import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync } from "fs";
5
5
  import { Command, InvalidArgumentError } from "commander";
6
6
 
7
7
  // src/activity.ts
@@ -254,6 +254,17 @@ function sanitizeProjectName(value) {
254
254
  return projectName || "app";
255
255
  }
256
256
 
257
+ // src/brand.ts
258
+ function renderLocalghostBanner() {
259
+ return [
260
+ " .-.",
261
+ " (o o) LOCALGHOST",
262
+ " | O \\ friendly local domains",
263
+ " \\ \\",
264
+ " `~~~'"
265
+ ].join("\n");
266
+ }
267
+
257
268
  // src/caddy.ts
258
269
  import { dirname as dirname3, join as join3 } from "path";
259
270
  import { execa } from "execa";
@@ -271,6 +282,12 @@ function writeTextFile(path, value) {
271
282
  }
272
283
 
273
284
  // src/caddy.ts
285
+ function shouldShowCaddyLogs() {
286
+ return ["1", "true", "yes", "on"].includes((process.env.LOCALGHOST_CADDY_VERBOSE ?? "").toLowerCase());
287
+ }
288
+ function caddyStdio() {
289
+ return shouldShowCaddyLogs() ? "inherit" : "pipe";
290
+ }
274
291
  function groupByPort(entries) {
275
292
  const groups = /* @__PURE__ */ new Map();
276
293
  for (const entry of entries) {
@@ -289,7 +306,7 @@ function renderCaddyfile(entries, options = {}) {
289
306
  const blocks = [...groups.entries()].sort(([leftPort], [rightPort]) => leftPort - rightPort).map(([port, group]) => {
290
307
  const hosts = group.map((entry) => https ? entry.host : `http://${entry.host}`).sort().join(", ");
291
308
  return `${hosts} {
292
- reverse_proxy 127.0.0.1:${port}
309
+ reverse_proxy 127.0.0.1:${port}
293
310
  }`;
294
311
  });
295
312
  const globalOptions = https ? `{
@@ -308,13 +325,13 @@ async function writeCaddyfile(entries, cwd = process.cwd(), options = {}) {
308
325
  async function validateCaddyfile(path) {
309
326
  await execa("caddy", ["validate", "--config", path], {
310
327
  cwd: dirname3(path),
311
- stdio: "inherit"
328
+ stdio: caddyStdio()
312
329
  });
313
330
  }
314
331
  function startCaddy(path) {
315
332
  return execa("caddy", ["run", "--config", path], {
316
333
  cwd: dirname3(path),
317
- stdio: "inherit"
334
+ stdio: caddyStdio()
318
335
  });
319
336
  }
320
337
  async function trustCaddy(path) {
@@ -324,21 +341,132 @@ async function trustCaddy(path) {
324
341
  });
325
342
  }
326
343
 
327
- // src/context.ts
344
+ // src/command.ts
328
345
  import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
329
- import { join as join4 } from "path";
346
+ import { isAbsolute, join as join4, relative, resolve as resolve2 } from "path";
347
+ function readPackageJson(cwd) {
348
+ const path = join4(cwd, "package.json");
349
+ if (!existsSync3(path)) {
350
+ throw new Error(`No package.json found in ${cwd}. Pass an explicit command with \`localghost run -- <command>\`.`);
351
+ }
352
+ try {
353
+ return JSON.parse(readFileSync4(path, "utf8"));
354
+ } catch {
355
+ throw new Error(`Could not parse ${path}.`);
356
+ }
357
+ }
358
+ function detectDevPackageManager(cwd, packageManager) {
359
+ if (typeof packageManager === "string") {
360
+ const name = packageManager.split("@")[0];
361
+ if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") return name;
362
+ }
363
+ if (existsSync3(join4(cwd, "pnpm-lock.yaml"))) return "pnpm";
364
+ if (existsSync3(join4(cwd, "yarn.lock"))) return "yarn";
365
+ if (existsSync3(join4(cwd, "bun.lock")) || existsSync3(join4(cwd, "bun.lockb"))) return "bun";
366
+ return "npm";
367
+ }
368
+ function scriptCommand(packageManager, script) {
369
+ if (packageManager === "yarn") return ["yarn", script];
370
+ return [packageManager, "run", script];
371
+ }
372
+ function invokesLocalghost(script) {
373
+ return /(^|[\s;&|])(?:npm\s+exec\s+|pnpm\s+exec\s+|bunx\s+|npx\s+)?localghost(?:\s|$)/.test(script);
374
+ }
375
+ function detectDevCommand(options = {}) {
376
+ const cwd = options.cwd ?? process.cwd();
377
+ if (options.command) {
378
+ if (options.command.length === 0 || options.command.some((part) => typeof part !== "string" || part.length === 0)) {
379
+ throw new Error("localghost.config.mjs command must be a non-empty array of strings.");
380
+ }
381
+ if (invokesLocalghost(options.command.join(" "))) {
382
+ throw new Error("localghost.config.mjs command cannot invoke Localghost recursively.");
383
+ }
384
+ return { command: [...options.command], source: "config" };
385
+ }
386
+ const pkg = readPackageJson(cwd);
387
+ const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
388
+ const packageManager = detectDevPackageManager(cwd, pkg.packageManager);
389
+ for (const script of ["dev:raw", "dev"]) {
390
+ const value = scripts[script];
391
+ if (typeof value !== "string" || invokesLocalghost(value)) continue;
392
+ return {
393
+ command: scriptCommand(packageManager, script),
394
+ source: "script",
395
+ packageManager,
396
+ script
397
+ };
398
+ }
399
+ throw new Error([
400
+ `Could not detect a safe development command in ${join4(cwd, "package.json")}.`,
401
+ "Add a non-recursive dev or dev:raw script, configure command in localghost.config.mjs,",
402
+ "or pass an explicit command with `localghost run -- <command>`."
403
+ ].join(" "));
404
+ }
405
+ function formatDetectedDevCommand(detected) {
406
+ const command = detected.command.map((part) => /^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
407
+ const source = detected.source === "config" ? "localghost.config.mjs" : `package.json#scripts.${detected.script}`;
408
+ return `${command} (${source})`;
409
+ }
410
+ function assertServicePath(root, serviceCwd, name) {
411
+ const cwd = resolve2(root, serviceCwd);
412
+ const relativeCwd = relative(root, cwd);
413
+ if (isAbsolute(relativeCwd) || relativeCwd === ".." || relativeCwd.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
414
+ throw new Error(`Service ${name} cwd must stay inside the project root.`);
415
+ }
416
+ return { cwd, relativeCwd: relativeCwd || "." };
417
+ }
418
+ function detectDevServices(options) {
419
+ const root = options.cwd ?? process.cwd();
420
+ if (options.services.length === 0) throw new Error("services must contain at least one service.");
421
+ const names = /* @__PURE__ */ new Set();
422
+ const hosts = /* @__PURE__ */ new Set();
423
+ return options.services.map((service, index) => {
424
+ if (!service || typeof service !== "object") throw new Error(`Service at index ${index} must be an object.`);
425
+ if (!service.name || names.has(service.name)) throw new Error(`Service name must be unique: ${service.name || `<index ${index}>`}.`);
426
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(service.name)) throw new Error(`Invalid service name: ${service.name}.`);
427
+ if (!service.host || hosts.has(service.host)) throw new Error(`Service host must be unique: ${service.host || `<index ${index}>`}.`);
428
+ if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65535) {
429
+ throw new Error(`Invalid port for service ${service.name}: ${service.port}.`);
430
+ }
431
+ names.add(service.name);
432
+ hosts.add(service.host);
433
+ const path = assertServicePath(root, service.cwd, service.name);
434
+ const detected = detectDevCommand({
435
+ cwd: path.cwd,
436
+ ...service.command ? { command: service.command } : {}
437
+ });
438
+ return {
439
+ name: service.name,
440
+ ...path,
441
+ host: service.host,
442
+ requestedPort: service.port,
443
+ command: detected.command,
444
+ commandSource: detected.source
445
+ };
446
+ });
447
+ }
448
+ function formatDetectedDevServices(services) {
449
+ return [
450
+ `Localghost detected ${services.length} services:`,
451
+ ...services.map((service) => `${service.name}: ${service.command.map((part) => /^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : JSON.stringify(part)).join(" ")} (${service.relativeCwd}, ${service.host} -> ${service.requestedPort})`)
452
+ ].join("\n");
453
+ }
454
+
455
+ // src/context.ts
456
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
457
+ import { join as join5 } from "path";
330
458
  import { pathToFileURL } from "url";
331
459
 
332
460
  // src/port.ts
333
461
  import { createServer } from "net";
334
462
  async function isPortAvailable(port, host = "127.0.0.1") {
335
- return new Promise((resolve2) => {
463
+ return new Promise((resolve3) => {
336
464
  const server = createServer();
337
465
  server.once("error", () => {
338
- resolve2(false);
466
+ resolve3(false);
339
467
  });
340
468
  server.once("listening", () => {
341
- server.close(() => resolve2(true));
469
+ server.close(() => resolve3(true));
342
470
  });
343
471
  server.listen(port, host);
344
472
  });
@@ -361,6 +489,17 @@ var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
361
489
  var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
362
490
  var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
363
491
  var DEFAULT_GHOST_TUNNEL_MODE = "manual";
492
+ var DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY = "same-project";
493
+ var DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND = "none";
494
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER = "vercel-redis";
495
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV = "auto";
496
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE = "localghost";
497
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS = 25e3;
498
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS = 250;
499
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS = 30;
500
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS = 60;
501
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
502
+ var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
364
503
  function isResolvedGhostTunnelConfig(value) {
365
504
  return typeof value === "object" && value !== null && "enabled" in value;
366
505
  }
@@ -410,6 +549,86 @@ function normalizeDomains(domains) {
410
549
  function parseGhostTunnelMode(value) {
411
550
  return value ?? DEFAULT_GHOST_TUNNEL_MODE;
412
551
  }
552
+ function parseGhostTunnelAdapterStrategy(value) {
553
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY;
554
+ if (value === "same-project" || value === "separate-relay") return value;
555
+ throw new Error(`Unsupported ghost tunnel adapter strategy: ${String(value)}`);
556
+ }
557
+ function parseGhostTunnelTransportKind(value) {
558
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND;
559
+ if (value === "none" || value === "ip" || value === "tunnel") return value;
560
+ throw new Error(`Unsupported ghost tunnel transport: ${String(value)}`);
561
+ }
562
+ function parsePositiveInteger(value, fallback, name) {
563
+ if (typeof value === "undefined") return fallback;
564
+ if (!Number.isInteger(value) || value < 1) {
565
+ throw new Error(`Invalid ghost tunnel ${name}: ${value}`);
566
+ }
567
+ return value;
568
+ }
569
+ function parseTunnelStoreProvider(value) {
570
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER;
571
+ if (value === "vercel-redis" || value === "redis") return value;
572
+ throw new Error(`Unsupported ghost tunnel tunnel store provider: ${String(value)}`);
573
+ }
574
+ function parseTunnelStoreEnv(value) {
575
+ if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV;
576
+ if (value === "auto") return value;
577
+ throw new Error(`Unsupported ghost tunnel tunnel store env: ${String(value)}`);
578
+ }
579
+ function parseTunnelStoreNamespace(value) {
580
+ const namespace = value ?? DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE;
581
+ if (!/^[a-z][a-z0-9:_-]{0,63}$/i.test(namespace)) {
582
+ throw new Error(`Invalid ghost tunnel tunnel store namespace: ${namespace}`);
583
+ }
584
+ return namespace;
585
+ }
586
+ function resolveGhostTunnelAdapter(input2) {
587
+ if (!input2) return void 0;
588
+ const provider = typeof input2 === "string" ? input2 : input2.provider;
589
+ if (provider !== "vercel") {
590
+ throw new Error(`Unsupported ghost tunnel adapter provider: ${String(provider)}`);
591
+ }
592
+ return {
593
+ provider,
594
+ strategy: typeof input2 === "string" ? DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY : parseGhostTunnelAdapterStrategy(input2.strategy)
595
+ };
596
+ }
597
+ function getLegacyGhostTunnelTransport(input2) {
598
+ if (!input2 || typeof input2 === "string" || !("transport" in input2)) return void 0;
599
+ return input2.transport;
600
+ }
601
+ function resolveGhostTunnelTransport(input2) {
602
+ if (!input2) {
603
+ return { kind: "none" };
604
+ }
605
+ const kind = typeof input2 === "string" ? parseGhostTunnelTransportKind(input2) : parseGhostTunnelTransportKind(input2.kind);
606
+ if (kind === "ip") {
607
+ return {
608
+ kind,
609
+ allowPrivateNetworkAddress: typeof input2 === "string" ? false : input2.kind === "ip" ? input2.allowPrivateNetworkAddress ?? false : false
610
+ };
611
+ }
612
+ if (kind === "tunnel") {
613
+ const config = typeof input2 === "string" || input2.kind !== "tunnel" ? void 0 : input2;
614
+ const store = config?.store ?? {};
615
+ return {
616
+ kind,
617
+ store: {
618
+ provider: parseTunnelStoreProvider(store.provider),
619
+ env: parseTunnelStoreEnv(store.env),
620
+ namespace: parseTunnelStoreNamespace(store.namespace)
621
+ },
622
+ waitMs: parsePositiveInteger(config?.waitMs, DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS, "tunnel waitMs"),
623
+ pollIntervalMs: parsePositiveInteger(config?.pollIntervalMs, DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS, "tunnel pollIntervalMs"),
624
+ routeTtlSeconds: parsePositiveInteger(config?.routeTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS, "tunnel routeTtlSeconds"),
625
+ requestTtlSeconds: parsePositiveInteger(config?.requestTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS, "tunnel requestTtlSeconds"),
626
+ maxRequestBodyBytes: parsePositiveInteger(config?.maxRequestBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES, "tunnel maxRequestBodyBytes"),
627
+ maxResponseBodyBytes: parsePositiveInteger(config?.maxResponseBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES, "tunnel maxResponseBodyBytes")
628
+ };
629
+ }
630
+ return { kind: "none" };
631
+ }
413
632
  function resolveNamespaceConfig(options) {
414
633
  const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
415
634
  let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
@@ -495,11 +714,11 @@ function getDisplayValues(input2) {
495
714
  ...input2.values
496
715
  };
497
716
  }
498
- function getDisplayDefaults(config, defaults) {
499
- return config.mode === "public" && !config.preview ? void 0 : defaults;
717
+ function getDisplayDefaults(defaults) {
718
+ return defaults;
500
719
  }
501
720
  function createDisplayUrl(config, defaults, domain) {
502
- const input2 = getPreviewDefaults(config.preview, getDisplayDefaults(config, defaults));
721
+ const input2 = getPreviewDefaults(config.preview, getDisplayDefaults(defaults));
503
722
  const protocol = input2.protocol ?? "https";
504
723
  const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input2));
505
724
  const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input2.domain ? getGhostTunnelEntryHost(input2.domain, config) : `${config.subdomain}.*`;
@@ -508,7 +727,7 @@ function createDisplayUrl(config, defaults, domain) {
508
727
  return `${url}${input2.path.replace(/^\/+/, "")}`;
509
728
  }
510
729
  function createDisplayUrls(config, defaults) {
511
- const displayDefaults = getDisplayDefaults(config, defaults);
730
+ const displayDefaults = getDisplayDefaults(defaults);
512
731
  const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
513
732
  const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
514
733
  return [...new Set(urls)];
@@ -538,7 +757,8 @@ function resolveGhostTunnelConfig(options, defaults) {
538
757
  namespace: resolveNamespaceConfig(void 0),
539
758
  displayUrls: [],
540
759
  requireHttps: true,
541
- requireAuth: true
760
+ requireAuth: true,
761
+ transport: resolveGhostTunnelTransport(void 0)
542
762
  };
543
763
  }
544
764
  const config = typeof options === "string" ? { mode: options } : options;
@@ -546,6 +766,8 @@ function resolveGhostTunnelConfig(options, defaults) {
546
766
  assertValidSubdomain(subdomain);
547
767
  const domains = normalizeDomains(config.domains);
548
768
  const enabled = config.enabled ?? true;
769
+ const adapter = resolveGhostTunnelAdapter(config.adapter);
770
+ const transport = resolveGhostTunnelTransport(config.transport ?? getLegacyGhostTunnelTransport(config.adapter));
549
771
  const resolved = {
550
772
  enabled,
551
773
  mode: parseGhostTunnelMode(config.mode),
@@ -555,7 +777,9 @@ function resolveGhostTunnelConfig(options, defaults) {
555
777
  ...config.preview ? { preview: config.preview } : {},
556
778
  displayUrls: [],
557
779
  requireHttps: config.requireHttps ?? true,
558
- requireAuth: config.requireAuth ?? true
780
+ requireAuth: config.requireAuth ?? true,
781
+ transport,
782
+ ...adapter ? { adapter } : {}
559
783
  };
560
784
  if (!enabled) {
561
785
  return resolved;
@@ -636,7 +860,7 @@ function envHttps() {
636
860
  }
637
861
  function getPackageName(cwd) {
638
862
  try {
639
- const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf8"));
863
+ const pkg = JSON.parse(readFileSync5(join5(cwd, "package.json"), "utf8"));
640
864
  return typeof pkg.name === "string" ? pkg.name : void 0;
641
865
  } catch {
642
866
  return void 0;
@@ -665,7 +889,7 @@ function withRuntimePort(entries, requestedPort, port) {
665
889
  if (requestedPort === port) return entries;
666
890
  const hasRequestedPort = entries.some((entry) => entry.port === requestedPort);
667
891
  if (!hasRequestedPort) return entries;
668
- return entries.map((entry) => entry.port === requestedPort ? { ...entry, port } : entry);
892
+ return entries.map((entry) => entry.port === requestedPort ? { ...entry, port, target: `127.0.0.1:${port}` } : entry);
669
893
  }
670
894
  function uniqueHosts(entries) {
671
895
  return [...new Set(entries.map((entry) => entry.host))];
@@ -695,7 +919,7 @@ async function readLocalghostProjectConfig(options = {}) {
695
919
  const cwd = options.cwd ?? process.cwd();
696
920
  if (options.configFile === false) return { config: {} };
697
921
  const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
698
- const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
922
+ const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync4(candidate));
699
923
  if (!path) return { config: {} };
700
924
  const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
701
925
  const config = imported.default ?? imported;
@@ -716,6 +940,7 @@ async function resolveLocalghostContext(options = {}) {
716
940
  const configEntries = readDevHosts(readOptions);
717
941
  const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
718
942
  const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
943
+ const autoRepair = merged.autoRepair ?? true;
719
944
  const bindHost = merged.bindHost ?? "127.0.0.1";
720
945
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
721
946
  const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
@@ -741,6 +966,7 @@ async function resolveLocalghostContext(options = {}) {
741
966
  requestedPort,
742
967
  port,
743
968
  dynamicPort,
969
+ autoRepair,
744
970
  bindHost,
745
971
  primaryHost,
746
972
  https: merged.https ?? envHttps() ?? false,
@@ -793,10 +1019,418 @@ function assertLocalDevelopment(command, env = process.env) {
793
1019
  throw new Error(`Localghost only runs in local development. Refusing \`${command}\` because ${reason}.`);
794
1020
  }
795
1021
 
1022
+ // src/ghost-file.ts
1023
+ var LOCALGHOST_GHOST_TUNNEL_FILE = ".ghosttunnel";
1024
+ function toGhostTunnelOptions(options = {}) {
1025
+ const resolved = typeof options === "string" ? { cwd: options } : options;
1026
+ return {
1027
+ ...resolved,
1028
+ fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
1029
+ };
1030
+ }
1031
+ function resolveGhostTunnelPath(options = {}) {
1032
+ return resolveDevHostsPath(toGhostTunnelOptions(options));
1033
+ }
1034
+ function readGhostTunnelEntries(options = {}) {
1035
+ return readDevHosts(toGhostTunnelOptions(options));
1036
+ }
1037
+ function listGhostTunnelEntries(options = {}) {
1038
+ const resolved = resolveGhostTunnelPath(options);
1039
+ if (!resolved.exists) return [];
1040
+ return readGhostTunnelEntries(options);
1041
+ }
1042
+
1043
+ // src/ghost-agent.ts
1044
+ import { randomUUID as randomUUID2 } from "crypto";
1045
+
1046
+ // src/relay.ts
1047
+ import { createHmac, timingSafeEqual } from "crypto";
1048
+ import { domainToASCII as domainToASCII2 } from "url";
1049
+ var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
1050
+ var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
1051
+ var DEFAULT_RELAY_LIMITS = {
1052
+ requestBodyBytes: 5 * 1024 * 1024,
1053
+ responseBytes: 25 * 1024 * 1024,
1054
+ timeoutMs: 3e4,
1055
+ maxConcurrentRequests: 20,
1056
+ perRouteRequestsPerMinute: 120,
1057
+ perIpRequestsPerMinute: 60
1058
+ };
1059
+ var DEFAULT_RELAY_TARGET_POLICY = {
1060
+ allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
1061
+ blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
1062
+ allowPrivateNetworkTargets: false
1063
+ };
1064
+ var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
1065
+ "connection",
1066
+ "keep-alive",
1067
+ "proxy-authenticate",
1068
+ "proxy-authorization",
1069
+ "te",
1070
+ "trailer",
1071
+ "transfer-encoding",
1072
+ "upgrade"
1073
+ ]);
1074
+ var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
1075
+ var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
1076
+ function normalizeHost(host) {
1077
+ const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
1078
+ if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
1079
+ const ascii = domainToASCII2(trimmed);
1080
+ if (!ascii || ascii.includes("..")) return null;
1081
+ return HOST_PATTERN2.test(ascii) ? ascii : null;
1082
+ }
1083
+ function normalizeTargetHost(host) {
1084
+ const trimmed = host.trim().toLowerCase();
1085
+ if (trimmed === "::1" || trimmed === "[::1]") return "::1";
1086
+ if (trimmed.includes("/") || trimmed.includes("*")) return null;
1087
+ if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
1088
+ return normalizeHost(trimmed);
1089
+ }
1090
+ function isValidIpv4(value) {
1091
+ return value.split(".").every((part) => {
1092
+ const octet = Number(part);
1093
+ return Number.isInteger(octet) && octet >= 0 && octet <= 255 && String(octet) === part;
1094
+ });
1095
+ }
1096
+ function isPrivateIpv4(value) {
1097
+ if (!isValidIpv4(value)) return false;
1098
+ const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
1099
+ return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
1100
+ }
1101
+ function isLocalTargetHost(host) {
1102
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1103
+ }
1104
+ function mergeTargetPolicy(policy) {
1105
+ return {
1106
+ allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
1107
+ blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
1108
+ allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
1109
+ };
1110
+ }
1111
+ function assertRelayLocalTarget(target, policyInput) {
1112
+ if (!target || typeof target !== "object") {
1113
+ throw new Error("Relay target must be an explicit local target object.");
1114
+ }
1115
+ const policy = mergeTargetPolicy(policyInput);
1116
+ const host = normalizeTargetHost(target.host);
1117
+ if (!host) {
1118
+ throw new Error(`Invalid relay target host: ${target.host}`);
1119
+ }
1120
+ if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
1121
+ throw new Error(`Invalid relay target port: ${target.port}`);
1122
+ }
1123
+ const protocol = target.protocol ?? "http";
1124
+ if (protocol !== "http" && protocol !== "https") {
1125
+ throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
1126
+ }
1127
+ if (policy.blockedPorts.includes(target.port)) {
1128
+ throw new Error(`Relay target port is blocked: ${target.port}`);
1129
+ }
1130
+ const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
1131
+ if (!allowedHosts.has(host)) {
1132
+ throw new Error(`Relay target host is not explicitly allowed: ${host}`);
1133
+ }
1134
+ if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
1135
+ throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
1136
+ }
1137
+ return {
1138
+ protocol,
1139
+ host,
1140
+ port: target.port
1141
+ };
1142
+ }
1143
+ function stripRelayForwardHeaders(headers) {
1144
+ const stripped = {};
1145
+ for (const [name, value] of Object.entries(headers)) {
1146
+ if (typeof value === "undefined") continue;
1147
+ const lowerName = name.toLowerCase();
1148
+ if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
1149
+ if (lowerName.startsWith("x-localghost-")) continue;
1150
+ stripped[name] = value;
1151
+ }
1152
+ return stripped;
1153
+ }
1154
+
1155
+ // src/ghost-tunnel-store.ts
1156
+ import { randomUUID } from "crypto";
1157
+ function base64Encode(value) {
1158
+ return value.toString("base64");
1159
+ }
1160
+ function encodeGhostTunnelBody(value) {
1161
+ return base64Encode(Buffer.isBuffer(value) ? value : Buffer.from(value));
1162
+ }
1163
+ function decodeGhostTunnelBody(value) {
1164
+ return value ? Buffer.from(value, "base64") : void 0;
1165
+ }
1166
+ function createGhostTunnelRouteHeartbeat(input2) {
1167
+ const now = input2.now ?? /* @__PURE__ */ new Date();
1168
+ return {
1169
+ host: input2.host,
1170
+ agentId: input2.agentId,
1171
+ target: input2.target,
1172
+ updatedAt: now.toISOString(),
1173
+ expiresAt: new Date(now.getTime() + input2.ttlSeconds * 1e3).toISOString()
1174
+ };
1175
+ }
1176
+ function isExpired(expiresAt, now = /* @__PURE__ */ new Date()) {
1177
+ const timestamp = Date.parse(expiresAt);
1178
+ return Number.isNaN(timestamp) || timestamp <= now.getTime();
1179
+ }
1180
+ function serializeJson(value) {
1181
+ return JSON.stringify(value);
1182
+ }
1183
+ function parseJson(value) {
1184
+ if (typeof value !== "string") return null;
1185
+ try {
1186
+ return JSON.parse(value);
1187
+ } catch {
1188
+ return null;
1189
+ }
1190
+ }
1191
+ function keyPart(value) {
1192
+ return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
1193
+ }
1194
+ var RedisGhostTunnelStore = class {
1195
+ url;
1196
+ token;
1197
+ namespace;
1198
+ fetchImpl;
1199
+ constructor(options) {
1200
+ this.url = options.url.replace(/\/+$/, "");
1201
+ this.token = options.token;
1202
+ this.namespace = options.namespace ?? "localghost";
1203
+ this.fetchImpl = options.fetch ?? fetch;
1204
+ }
1205
+ key(kind, id) {
1206
+ return `${this.namespace}:ghost-tunnel:${kind}:${keyPart(id)}`;
1207
+ }
1208
+ async command(command, ...args) {
1209
+ const response = await this.fetchImpl(this.url, {
1210
+ method: "POST",
1211
+ headers: {
1212
+ authorization: `Bearer ${this.token}`,
1213
+ "content-type": "application/json"
1214
+ },
1215
+ body: JSON.stringify([command, ...args])
1216
+ });
1217
+ if (!response.ok) {
1218
+ throw new Error(`Redis Ghost Tunnel command failed: ${response.status} ${response.statusText}`);
1219
+ }
1220
+ const payload = await response.json();
1221
+ if (payload.error) {
1222
+ throw new Error(`Redis Ghost Tunnel command failed: ${payload.error}`);
1223
+ }
1224
+ return typeof payload.result === "undefined" ? null : payload.result;
1225
+ }
1226
+ async heartbeatRoute(route, ttlSeconds) {
1227
+ await this.command("SET", this.key("route", route.host), serializeJson(route), "EX", ttlSeconds);
1228
+ }
1229
+ async getRoute(host) {
1230
+ const route = parseJson(await this.command("GET", this.key("route", host)));
1231
+ return route && !isExpired(route.expiresAt) ? route : null;
1232
+ }
1233
+ async enqueueRequest(request, ttlSeconds) {
1234
+ const queueKey = this.key("queue", request.host);
1235
+ await this.command("RPUSH", queueKey, serializeJson(request));
1236
+ await this.command("EXPIRE", queueKey, ttlSeconds);
1237
+ }
1238
+ async claimRequest(host) {
1239
+ const queueKey = this.key("queue", host);
1240
+ while (true) {
1241
+ const request = parseJson(await this.command("LPOP", queueKey));
1242
+ if (!request) return null;
1243
+ if (!isExpired(request.expiresAt)) return request;
1244
+ }
1245
+ }
1246
+ async writeResponse(response, ttlSeconds) {
1247
+ await this.command("SET", this.key("response", response.id), serializeJson(response), "EX", ttlSeconds);
1248
+ }
1249
+ async readResponse(requestId) {
1250
+ return parseJson(await this.command("GET", this.key("response", requestId)));
1251
+ }
1252
+ async cleanup(requestId) {
1253
+ await this.command("DEL", this.key("response", requestId));
1254
+ }
1255
+ };
1256
+ function createRedisGhostTunnelStore(options) {
1257
+ return new RedisGhostTunnelStore(options);
1258
+ }
1259
+ function resolveRedisGhostTunnelEnv(env = process.env) {
1260
+ const candidates = [
1261
+ env.LOCALGHOST_REDIS_REST_URL && env.LOCALGHOST_REDIS_REST_TOKEN ? { url: env.LOCALGHOST_REDIS_REST_URL, token: env.LOCALGHOST_REDIS_REST_TOKEN, source: "localghost" } : null,
1262
+ env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN ? { url: env.UPSTASH_REDIS_REST_URL, token: env.UPSTASH_REDIS_REST_TOKEN, source: "upstash" } : null,
1263
+ env.KV_REST_API_URL && env.KV_REST_API_TOKEN ? { url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN, source: "vercel-kv" } : null,
1264
+ env.REDIS_REST_API_URL && env.REDIS_REST_API_TOKEN ? { url: env.REDIS_REST_API_URL, token: env.REDIS_REST_API_TOKEN, source: "redis" } : null
1265
+ ];
1266
+ const match = candidates.find((candidate) => Boolean(candidate));
1267
+ if (!match) {
1268
+ throw new Error("Ghost Tunnel Redis transport requires REST env vars: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN, KV_REST_API_URL/KV_REST_API_TOKEN, or LOCALGHOST_REDIS_REST_URL/LOCALGHOST_REDIS_REST_TOKEN.");
1269
+ }
1270
+ return match;
1271
+ }
1272
+ function createRedisGhostTunnelStoreFromEnv(input2 = {}) {
1273
+ const resolved = resolveRedisGhostTunnelEnv(input2.env);
1274
+ return createRedisGhostTunnelStore({
1275
+ url: resolved.url,
1276
+ token: resolved.token,
1277
+ ...input2.namespace ? { namespace: input2.namespace } : {},
1278
+ ...input2.fetch ? { fetch: input2.fetch } : {}
1279
+ });
1280
+ }
1281
+
1282
+ // src/ghost-agent.ts
1283
+ function isStopped(signal, localSignal) {
1284
+ return localSignal.aborted || signal?.aborted === true;
1285
+ }
1286
+ function wait(ms, signal, localSignal) {
1287
+ if (isStopped(signal, localSignal)) return Promise.resolve();
1288
+ return new Promise((resolve3) => {
1289
+ const timeout = setTimeout(resolve3, ms);
1290
+ const stop = () => {
1291
+ clearTimeout(timeout);
1292
+ resolve3();
1293
+ };
1294
+ signal?.addEventListener("abort", stop, { once: true });
1295
+ localSignal.addEventListener("abort", stop, { once: true });
1296
+ });
1297
+ }
1298
+ function toHeaderRecord(headers) {
1299
+ const result = {};
1300
+ headers.forEach((value, name) => {
1301
+ result[name] = value;
1302
+ });
1303
+ return result;
1304
+ }
1305
+ function hasRequestBody(method) {
1306
+ return method !== "GET" && method !== "HEAD";
1307
+ }
1308
+ async function serveGhostTunnelLocalRequest(input2) {
1309
+ const fetchImpl = input2.fetch ?? fetch;
1310
+ const localUrl = new URL(`${input2.target.protocol}://${input2.target.host}:${input2.target.port}/`);
1311
+ const requestPath = new URL(input2.request.path, "http://localghost.invalid");
1312
+ localUrl.pathname = requestPath.pathname;
1313
+ localUrl.search = requestPath.search;
1314
+ try {
1315
+ const body = hasRequestBody(input2.request.method) ? decodeGhostTunnelBody(input2.request.bodyBase64) : void 0;
1316
+ const response = await fetchImpl(localUrl, {
1317
+ method: input2.request.method,
1318
+ headers: {
1319
+ ...stripRelayForwardHeaders(input2.request.headers),
1320
+ "x-forwarded-host": input2.request.host,
1321
+ "x-localghost-tunnel": "1"
1322
+ },
1323
+ ...body ? { body } : {}
1324
+ });
1325
+ const responseBody = Buffer.from(await response.arrayBuffer());
1326
+ if (responseBody.byteLength > input2.maxResponseBodyBytes) {
1327
+ throw new Error(`Ghost Tunnel response exceeded ${input2.maxResponseBodyBytes} bytes.`);
1328
+ }
1329
+ return {
1330
+ id: input2.request.id,
1331
+ status: response.status,
1332
+ headers: toHeaderRecord(response.headers),
1333
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1334
+ ...responseBody.byteLength > 0 ? { bodyBase64: encodeGhostTunnelBody(responseBody) } : {}
1335
+ };
1336
+ } catch (error) {
1337
+ return {
1338
+ id: input2.request.id,
1339
+ status: 502,
1340
+ headers: {
1341
+ "content-type": "text/plain; charset=utf-8",
1342
+ "cache-control": "no-store"
1343
+ },
1344
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1345
+ error: error instanceof Error ? error.message : String(error),
1346
+ bodyBase64: encodeGhostTunnelBody("Ghost Tunnel local target failed.")
1347
+ };
1348
+ }
1349
+ }
1350
+ async function heartbeatRoutes(input2) {
1351
+ for (const entry of input2.entries) {
1352
+ const target = assertRelayLocalTarget({ host: input2.targetHost, port: entry.port });
1353
+ await input2.store.heartbeatRoute(createGhostTunnelRouteHeartbeat({
1354
+ host: entry.host,
1355
+ agentId: input2.agentId,
1356
+ target,
1357
+ ttlSeconds: input2.routeTtlSeconds
1358
+ }), input2.routeTtlSeconds);
1359
+ }
1360
+ }
1361
+ async function claimAndServe(input2) {
1362
+ const request = await input2.store.claimRequest(input2.entry.host);
1363
+ if (!request) return false;
1364
+ const target = assertRelayLocalTarget({ host: input2.targetHost, port: input2.entry.port });
1365
+ const response = await serveGhostTunnelLocalRequest({
1366
+ request,
1367
+ target,
1368
+ maxResponseBodyBytes: input2.maxResponseBodyBytes,
1369
+ ...input2.fetch ? { fetch: input2.fetch } : {}
1370
+ });
1371
+ await input2.store.writeResponse(response, input2.requestTtlSeconds);
1372
+ return true;
1373
+ }
1374
+ function startGhostTunnelAgent(options) {
1375
+ const controller = new AbortController();
1376
+ const localSignal = controller.signal;
1377
+ const signal = options.signal;
1378
+ const agentId = options.agentId ?? `localghost-${randomUUID2()}`;
1379
+ const targetHost = options.targetHost ?? "127.0.0.1";
1380
+ const routeTtlSeconds = options.routeTtlSeconds ?? 30;
1381
+ const requestTtlSeconds = options.requestTtlSeconds ?? 60;
1382
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
1383
+ const maxResponseBodyBytes = options.maxResponseBodyBytes ?? 5 * 1024 * 1024;
1384
+ const done = (async () => {
1385
+ if (options.entries.length === 0) {
1386
+ throw new Error("Ghost Tunnel agent requires at least one .ghosttunnel entry.");
1387
+ }
1388
+ options.log?.(`localghost tunnel agent ${agentId}`);
1389
+ for (const entry of options.entries) {
1390
+ options.log?.(` ${entry.host} -> ${targetHost}:${entry.port}`);
1391
+ }
1392
+ let lastHeartbeat = 0;
1393
+ while (!isStopped(signal, localSignal)) {
1394
+ const now = Date.now();
1395
+ if (now - lastHeartbeat >= Math.max(1e3, Math.floor(routeTtlSeconds * 1e3 / 3))) {
1396
+ await heartbeatRoutes({
1397
+ entries: options.entries,
1398
+ store: options.store,
1399
+ agentId,
1400
+ targetHost,
1401
+ routeTtlSeconds
1402
+ });
1403
+ lastHeartbeat = now;
1404
+ }
1405
+ let served = false;
1406
+ for (const entry of options.entries) {
1407
+ served = await claimAndServe({
1408
+ entry,
1409
+ store: options.store,
1410
+ targetHost,
1411
+ requestTtlSeconds,
1412
+ maxResponseBodyBytes,
1413
+ ...options.fetch ? { fetch: options.fetch } : {}
1414
+ }) || served;
1415
+ }
1416
+ if (!served) {
1417
+ await wait(pollIntervalMs, signal, localSignal);
1418
+ }
1419
+ }
1420
+ })();
1421
+ return {
1422
+ agentId,
1423
+ stop() {
1424
+ controller.abort();
1425
+ },
1426
+ done
1427
+ };
1428
+ }
1429
+
796
1430
  // src/hosts-file.ts
797
1431
  import { writeFileSync as writeFileSync3 } from "fs";
798
1432
  import { tmpdir } from "os";
799
- import { join as join5 } from "path";
1433
+ import { join as join6 } from "path";
800
1434
  import { execa as execa3 } from "execa";
801
1435
  function escapeRegExp(value) {
802
1436
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -807,7 +1441,8 @@ function getManagedBlockPattern(projectName) {
807
1441
  const end = `# localghost:end ${sanitizedProjectName}`;
808
1442
  return new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m");
809
1443
  }
810
- function getSystemHostsPath() {
1444
+ function getSystemHostsPath(env = process.env) {
1445
+ if (env.LOCALGHOST_HOSTS_PATH) return env.LOCALGHOST_HOSTS_PATH;
811
1446
  return process.platform === "win32" ? "C:\\Windows\\System32\\drivers\\etc\\hosts" : "/etc/hosts";
812
1447
  }
813
1448
  function renderHostsBlock(projectName, entries) {
@@ -838,8 +1473,12 @@ function removeManagedBlock(existing, projectName) {
838
1473
  }
839
1474
  async function writeSystemHostsFile(hostsPath, next, projectName) {
840
1475
  const sanitizedProjectName = sanitizeProjectName(projectName);
841
- const tempPath = join5(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
1476
+ const tempPath = join6(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
842
1477
  writeFileSync3(tempPath, next, "utf8");
1478
+ if (process.env.LOCALGHOST_HOSTS_PATH) {
1479
+ writeFileSync3(hostsPath, next, "utf8");
1480
+ return tempPath;
1481
+ }
843
1482
  if (process.platform === "win32") {
844
1483
  throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
845
1484
  }
@@ -871,11 +1510,11 @@ async function removeSystemHosts(projectName) {
871
1510
  }
872
1511
 
873
1512
  // src/init.ts
874
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
875
- import { join as join6 } from "path";
1513
+ import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1514
+ import { join as join7 } from "path";
876
1515
  function detectPackageManager(cwd = process.cwd()) {
877
- if (existsSync4(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
878
- if (existsSync4(join6(cwd, "yarn.lock"))) return "yarn";
1516
+ if (existsSync5(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
1517
+ if (existsSync5(join7(cwd, "yarn.lock"))) return "yarn";
879
1518
  return "npm";
880
1519
  }
881
1520
  function packageRunCommand(packageManager, script) {
@@ -893,9 +1532,9 @@ function renderConfig(options) {
893
1532
  ""
894
1533
  ].join("\n");
895
1534
  }
896
- function readPackageJson(path) {
1535
+ function readPackageJson2(path) {
897
1536
  try {
898
- return JSON.parse(readFileSync5(path, "utf8"));
1537
+ return JSON.parse(readFileSync6(path, "utf8"));
899
1538
  } catch {
900
1539
  return null;
901
1540
  }
@@ -908,7 +1547,7 @@ function getConfigFlag(configFile) {
908
1547
  return configFile === LOCALGHOST_CONFIG_FILE ? "" : ` --config ${shellQuote(configFile)}`;
909
1548
  }
910
1549
  function updatePackageScripts(packageJsonPath, configFile) {
911
- const pkg = readPackageJson(packageJsonPath);
1550
+ const pkg = readPackageJson2(packageJsonPath);
912
1551
  if (!pkg) return false;
913
1552
  const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
914
1553
  const configFlag = getConfigFlag(configFile);
@@ -919,6 +1558,7 @@ function updatePackageScripts(packageJsonPath, configFile) {
919
1558
  "localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
920
1559
  "localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
921
1560
  "localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
1561
+ "localghost:repair": scripts["localghost:repair"] ?? `localghost repair${configFlag}`,
922
1562
  "localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
923
1563
  "localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
924
1564
  "localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
@@ -947,8 +1587,8 @@ function initLocalghost(options = {}) {
947
1587
  const apiPort = options.apiPort ?? 8787;
948
1588
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
949
1589
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
950
- const configPath = join6(cwd, configFile);
951
- const configExists = existsSync4(configPath);
1590
+ const configPath = join7(cwd, configFile);
1591
+ const configExists = existsSync5(configPath);
952
1592
  if (configExists && !options.force) {
953
1593
  return {
954
1594
  configPath,
@@ -964,12 +1604,12 @@ function initLocalghost(options = {}) {
964
1604
  };
965
1605
  }
966
1606
  writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
967
- const packageJsonPath = join6(cwd, "package.json");
1607
+ const packageJsonPath = join7(cwd, "package.json");
968
1608
  const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
969
1609
  return {
970
1610
  configPath,
971
1611
  configCreated: true,
972
- ...existsSync4(packageJsonPath) ? { packageJsonPath } : {},
1612
+ ...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
973
1613
  packageJsonChanged,
974
1614
  packageManager,
975
1615
  nextSteps: [
@@ -1061,21 +1701,22 @@ function formatGhostTunnel(config, options = {}) {
1061
1701
  if (options.verbose) {
1062
1702
  lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
1063
1703
  lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
1064
- lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
1704
+ lines.push(` protocol: ${config.requireHttps ? "https required" : "http allowed"}`);
1705
+ lines.push(` transport: ${config.transport.kind}`);
1065
1706
  }
1066
1707
  return lines.join("\n");
1067
1708
  }
1068
1709
 
1069
1710
  // src/state.ts
1070
- import { existsSync as existsSync5 } from "fs";
1071
- import { join as join7 } from "path";
1711
+ import { existsSync as existsSync6 } from "fs";
1712
+ import { join as join8 } from "path";
1072
1713
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
1073
1714
  function getLocalghostStatePath(cwd = process.cwd()) {
1074
- return join7(cwd, LOCALGHOST_STATE_FILE);
1715
+ return join8(cwd, LOCALGHOST_STATE_FILE);
1075
1716
  }
1076
1717
  function readLocalghostState(cwd = process.cwd()) {
1077
1718
  const path = getLocalghostStatePath(cwd);
1078
- if (!existsSync5(path)) return null;
1719
+ if (!existsSync6(path)) return null;
1079
1720
  return JSON.parse(readTextFile(path));
1080
1721
  }
1081
1722
  function writeLocalghostState(cwd, state) {
@@ -1091,11 +1732,11 @@ function patchLocalghostState(cwd, patch) {
1091
1732
  }
1092
1733
 
1093
1734
  // src/update-check.ts
1094
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
1735
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
1095
1736
  import { homedir as homedir2 } from "os";
1096
- import { dirname as dirname4, join as join8 } from "path";
1737
+ import { dirname as dirname4, join as join9 } from "path";
1097
1738
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
1098
- var LOCALGHOST_VERSION = "0.1.10";
1739
+ var LOCALGHOST_VERSION = "0.1.12";
1099
1740
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1100
1741
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
1101
1742
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -1107,13 +1748,13 @@ function isUpdateCheckDisabled(env = process.env) {
1107
1748
  }
1108
1749
  function getUpdateCheckCachePath(env = process.env) {
1109
1750
  if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
1110
- const cacheRoot = env.XDG_CACHE_HOME || join8(homedir2(), ".cache");
1111
- return join8(cacheRoot, "localghost", "update-check.json");
1751
+ const cacheRoot = env.XDG_CACHE_HOME || join9(homedir2(), ".cache");
1752
+ return join9(cacheRoot, "localghost", "update-check.json");
1112
1753
  }
1113
1754
  function readCache(path = getUpdateCheckCachePath()) {
1114
- if (!existsSync6(path)) return null;
1755
+ if (!existsSync7(path)) return null;
1115
1756
  try {
1116
- return JSON.parse(readFileSync6(path, "utf8"));
1757
+ return JSON.parse(readFileSync7(path, "utf8"));
1117
1758
  } catch {
1118
1759
  return null;
1119
1760
  }
@@ -1270,6 +1911,10 @@ function warnAboutLocalMdns(entries) {
1270
1911
  function shouldColor() {
1271
1912
  return process.stdout.isTTY && !process.env.NO_COLOR;
1272
1913
  }
1914
+ function printLocalghostBanner() {
1915
+ console.log(renderLocalghostBanner());
1916
+ console.log("");
1917
+ }
1273
1918
  function logDomainRoutes(entries, options = {}) {
1274
1919
  console.log(formatDomainRoutes(entries, options));
1275
1920
  if (options.ghostTunnel?.enabled) {
@@ -1308,7 +1953,8 @@ function contextOptionsFromCli(options) {
1308
1953
  ...options.project ? { project: options.project } : {},
1309
1954
  ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
1310
1955
  ...options.configPattern ? { configPattern: options.configPattern } : {},
1311
- ...useHttps(options) ? { https: true } : {}
1956
+ ...useHttps(options) ? { https: true } : {},
1957
+ ...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
1312
1958
  };
1313
1959
  }
1314
1960
  function readOptionsFromCli(options) {
@@ -1372,7 +2018,7 @@ function getSetupReadiness(options) {
1372
2018
  }
1373
2019
  const hostsPath = getSystemHostsPath();
1374
2020
  try {
1375
- const hosts = readFileSync7(hostsPath, "utf8");
2021
+ const hosts = readFileSync8(hostsPath, "utf8");
1376
2022
  const expectedHostsBlock = renderHostsBlock(projectName, entries).trimEnd();
1377
2023
  if (!hosts.includes(expectedHostsBlock)) {
1378
2024
  reasons.push(`The Localghost hosts block in ${hostsPath} is missing or stale.`);
@@ -1382,11 +2028,11 @@ function getSetupReadiness(options) {
1382
2028
  reasons.push(`Could not read ${hostsPath}: ${message}`);
1383
2029
  }
1384
2030
  if (!options.ignoreCaddyfile) {
1385
- if (!existsSync7(caddyfilePath)) {
2031
+ if (!existsSync8(caddyfilePath)) {
1386
2032
  reasons.push(`Missing Caddyfile at ${caddyfilePath}.`);
1387
2033
  } else {
1388
2034
  const expectedCaddyfile = renderCaddyfile(entries, { https });
1389
- const currentCaddyfile = readFileSync7(caddyfilePath, "utf8");
2035
+ const currentCaddyfile = readFileSync8(caddyfilePath, "utf8");
1390
2036
  if (currentCaddyfile !== expectedCaddyfile) {
1391
2037
  reasons.push(`Caddyfile at ${caddyfilePath} is stale for ${https ? "HTTPS" : "HTTP"} mode.`);
1392
2038
  }
@@ -1430,15 +2076,15 @@ async function runSetupFromReadiness(cwd, https, readiness) {
1430
2076
  entries: readiness.entries
1431
2077
  });
1432
2078
  }
1433
- function wait(ms) {
1434
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2079
+ function wait2(ms) {
2080
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1435
2081
  }
1436
2082
  async function runTrust(cwd, caddyfilePath) {
1437
- await wait(350);
2083
+ await wait2(350);
1438
2084
  try {
1439
2085
  await trustCaddy(caddyfilePath);
1440
2086
  } catch {
1441
- await wait(750);
2087
+ await wait2(750);
1442
2088
  await trustCaddy(caddyfilePath);
1443
2089
  }
1444
2090
  patchLocalghostState(cwd, { caddyTrustedAt: (/* @__PURE__ */ new Date()).toISOString() });
@@ -1478,6 +2124,131 @@ function registerCleanup(id) {
1478
2124
  process.off("exit", cleanup);
1479
2125
  };
1480
2126
  }
2127
+ async function resolveServiceRuntimeEntries(services, dynamicPort) {
2128
+ const usedPorts = /* @__PURE__ */ new Set();
2129
+ const resolved = [];
2130
+ for (const service of services) {
2131
+ let port = service.requestedPort;
2132
+ 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
+ }
2146
+ } else if (usedPorts.has(port)) {
2147
+ throw new Error(`Services cannot start separate commands on the same fixed port: ${port}.`);
2148
+ }
2149
+ usedPorts.add(port);
2150
+ resolved.push({
2151
+ ...service,
2152
+ port,
2153
+ entry: {
2154
+ host: service.host,
2155
+ port,
2156
+ target: `127.0.0.1:${port}`
2157
+ }
2158
+ });
2159
+ }
2160
+ return resolved;
2161
+ }
2162
+ async function waitForServicePorts(entries, timeoutMs = 1e4) {
2163
+ const deadline = Date.now() + timeoutMs;
2164
+ const ports = [...new Set(entries.map((entry) => entry.port))];
2165
+ while (Date.now() < deadline) {
2166
+ const availability = await Promise.all(ports.map((port) => isPortAvailable(port)));
2167
+ if (availability.every((available) => !available)) return true;
2168
+ await wait2(50);
2169
+ }
2170
+ return false;
2171
+ }
2172
+ async function runDetectedServices(options) {
2173
+ assertLocalDevelopment("run");
2174
+ 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}.`);
2199
+ }
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)
2216
+ }
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 });
2241
+ }
2242
+ await processExit;
2243
+ } 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();
2250
+ }
2251
+ }
1481
2252
  async function getRouteViews(entries) {
1482
2253
  const portStatus = /* @__PURE__ */ new Map();
1483
2254
  for (const entry of entries) {
@@ -1629,6 +2400,7 @@ program.command("update").description("Check npm for a newer localghost release"
1629
2400
  });
1630
2401
  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) => {
1631
2402
  assertLocalDevelopment("setup");
2403
+ printLocalghostBanner();
1632
2404
  await assertCaddyReady();
1633
2405
  const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
1634
2406
  const https = context.https;
@@ -1686,6 +2458,32 @@ program.command("trust").description("Trust Caddy's local HTTPS CA for this proj
1686
2458
  await validateCaddyfile(caddyfile);
1687
2459
  await runTrust(options.cwd, caddyfile);
1688
2460
  });
2461
+ program.command("repair").description("Reconcile stale hosts, Caddyfile, setup state, and optional HTTPS trust").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Repair an HTTPS Caddy setup").option("--ssl", "Alias for --https").option("--trust", "Re-run Caddy's local HTTPS trust step").action(async (options) => {
2462
+ assertLocalDevelopment("repair");
2463
+ printLocalghostBanner();
2464
+ await assertCaddyReady();
2465
+ const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
2466
+ const readiness = getSetupReadiness({
2467
+ ...options,
2468
+ https: context.https,
2469
+ entries: context.entries,
2470
+ configPath: context.configPath,
2471
+ projectName: context.projectName
2472
+ });
2473
+ if (options.trust && !context.https) {
2474
+ throw new Error("Cannot repair HTTPS trust unless HTTPS is enabled. Pass --https or configure https: true.");
2475
+ }
2476
+ warnAboutLocalMdns(context.entries);
2477
+ logDomainRoutes(context.entries, { https: context.https, ghostTunnel: context.ghostTunnel });
2478
+ await runSetupFromReadiness(options.cwd, context.https, readiness);
2479
+ if (options.trust) {
2480
+ await runTrust(options.cwd, readiness.caddyfilePath);
2481
+ }
2482
+ console.log(`Repaired hosts: ${getSystemHostsPath()}`);
2483
+ console.log(`Repaired Caddyfile: ${readiness.caddyfilePath}`);
2484
+ console.log(`Repaired state: ${readiness.statePath}`);
2485
+ console.log("Repair complete.");
2486
+ });
1689
2487
  program.command("reset").description("Remove Localghost setup state without deleting .localghost").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).action(async (options) => {
1690
2488
  assertLocalDevelopment("reset");
1691
2489
  const projectName = sanitizeProjectName(options.project ?? getProjectName(options.cwd));
@@ -1693,13 +2491,13 @@ program.command("reset").description("Remove Localghost setup state without dele
1693
2491
  const statePath = getLocalghostStatePath(options.cwd);
1694
2492
  explainHostsPassword();
1695
2493
  const hostsResult = await removeSystemHosts(projectName);
1696
- if (existsSync7(caddyfilePath)) {
2494
+ if (existsSync8(caddyfilePath)) {
1697
2495
  unlinkSync(caddyfilePath);
1698
2496
  console.log(`Removed ${caddyfilePath}`);
1699
2497
  } else {
1700
2498
  console.log(`${caddyfilePath} was not present`);
1701
2499
  }
1702
- if (existsSync7(statePath)) {
2500
+ if (existsSync8(statePath)) {
1703
2501
  unlinkSync(statePath);
1704
2502
  console.log(`Removed ${statePath}`);
1705
2503
  } else {
@@ -1720,7 +2518,7 @@ program.command("teardown").description("Remove Localghost's managed /etc/hosts
1720
2518
  const hostsResult = await removeSystemHosts(projectName);
1721
2519
  const caddyfilePath = getCaddyfilePath(options.cwd);
1722
2520
  let caddyfileRemoved = false;
1723
- if (options.removeCaddyfile && existsSync7(caddyfilePath)) {
2521
+ if (options.removeCaddyfile && existsSync8(caddyfilePath)) {
1724
2522
  unlinkSync(caddyfilePath);
1725
2523
  caddyfileRemoved = true;
1726
2524
  }
@@ -1800,7 +2598,7 @@ program.command("routes").description("Print domain to upstream routes").option(
1800
2598
  }));
1801
2599
  }
1802
2600
  });
1803
- program.command("dev").description("Run the Localghost Caddy proxy after setup").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", "Run setup before starting the proxy when setup is missing or stale").option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
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) => {
1804
2602
  assertLocalDevelopment("dev");
1805
2603
  await assertCaddyReady();
1806
2604
  const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
@@ -1813,41 +2611,18 @@ program.command("dev").description("Run the Localghost Caddy proxy after setup")
1813
2611
  projectName: context.projectName
1814
2612
  });
1815
2613
  if (!readiness.ready) {
1816
- if (!options.setup) {
2614
+ if (!options.setup && !context.autoRepair) {
1817
2615
  throw new Error(
1818
2616
  [
1819
2617
  "Localghost setup is missing or stale.",
1820
2618
  ...readiness.reasons.map((reason) => `- ${reason}`),
1821
2619
  `Run: ${readiness.setupCommand}`,
1822
- "Or rerun dev with --setup if you want Localghost to perform setup first."
2620
+ "Automatic repair is disabled. Rerun without --auto-repair=no or run localghost repair."
1823
2621
  ].join("\n")
1824
2622
  );
1825
2623
  }
1826
- explainHostsPassword();
1827
- const hostsResult = await updateSystemHosts(readiness.projectName, readiness.entries);
1828
- const caddyfilePath = await writeCaddyfile(readiness.entries, options.cwd, { https });
1829
- await validateCaddyfile(caddyfilePath);
1830
- writeLocalghostState(options.cwd, {
1831
- action: "setup",
1832
- projectName: readiness.projectName,
1833
- cwd: options.cwd,
1834
- configPath: readiness.configPath,
1835
- hostsPath: hostsResult.hostsPath,
1836
- hostsChanged: hostsResult.changed,
1837
- ...hostsResult.tempPath ? { hostsTempPath: hostsResult.tempPath } : {},
1838
- caddyfilePath,
1839
- caddyHttps: https,
1840
- ...existingTrustMarkers(options.cwd),
1841
- entries: readiness.entries
1842
- });
1843
- registerLocalghostSetup({
1844
- cwd: options.cwd,
1845
- projectName: readiness.projectName,
1846
- configPath: readiness.configPath,
1847
- caddyfilePath,
1848
- https,
1849
- entries: readiness.entries
1850
- });
2624
+ console.log("Localghost setup is stale; repairing it now.");
2625
+ await runSetupFromReadiness(options.cwd, https, readiness);
1851
2626
  }
1852
2627
  warnAboutLocalMdns(readiness.entries);
1853
2628
  logDomainRoutes(readiness.entries, { https, ghostTunnel: context.ghostTunnel });
@@ -1883,7 +2658,7 @@ program.command("dev").description("Run the Localghost Caddy proxy after setup")
1883
2658
  cleanupRun();
1884
2659
  }
1885
2660
  });
1886
- 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", "Run setup before starting when setup is missing or stale").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) => {
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) => {
1887
2662
  assertLocalDevelopment("run");
1888
2663
  await assertCaddyReady();
1889
2664
  const context = await resolveLocalghostContext({
@@ -1893,7 +2668,8 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1893
2668
  ...options.configPattern ? { configPattern: options.configPattern } : {},
1894
2669
  ...options.port ? { port: options.port } : {},
1895
2670
  ...useHttps(options) ? { https: true } : {},
1896
- ...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {}
2671
+ ...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {},
2672
+ ...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
1897
2673
  });
1898
2674
  const https = context.https;
1899
2675
  const readiness = getSetupReadiness({
@@ -1905,18 +2681,19 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1905
2681
  projectName: context.projectName
1906
2682
  });
1907
2683
  if (!readiness.ready) {
1908
- const shouldSetup = options.setup === true || canPrompt() && await confirm("Run caddy:setup now?", true);
1909
- if (!shouldSetup) {
2684
+ if (!options.setup && !context.autoRepair) {
1910
2685
  throw new Error(
1911
2686
  [
1912
2687
  "Localghost setup is missing or stale.",
1913
2688
  ...readiness.reasons.map((reason) => `- ${reason}`),
1914
- `Run: ${readiness.setupCommand}`
2689
+ `Run: ${readiness.setupCommand}`,
2690
+ "Automatic repair is disabled. Rerun without --auto-repair=no or run localghost repair."
1915
2691
  ].join("\n")
1916
2692
  );
1917
2693
  }
2694
+ console.log("Localghost setup is stale; repairing it now.");
1918
2695
  await runSetupFromReadiness(options.cwd, https, readiness);
1919
- console.log(`All set. Setup state: ${getLocalghostStatePath(options.cwd)}`);
2696
+ console.log(`Repair complete. Setup state: ${getLocalghostStatePath(options.cwd)}`);
1920
2697
  }
1921
2698
  if (context.dynamicPort && context.port !== context.requestedPort) {
1922
2699
  console.log(`Port ${context.requestedPort} is busy; using ${context.port}.`);
@@ -1987,6 +2764,53 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1987
2764
  cleanupRun();
1988
2765
  }
1989
2766
  });
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) => {
2768
+ assertLocalDevelopment("tunnel");
2769
+ const context = await resolveLocalghostContext({
2770
+ cwd: options.cwd,
2771
+ ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
2772
+ ...options.configPattern ? { configPattern: options.configPattern } : {},
2773
+ dynamicPort: false
2774
+ });
2775
+ if (!context.ghostTunnel.enabled) {
2776
+ throw new Error("Ghost Tunnel is not enabled in localghost.config.mjs.");
2777
+ }
2778
+ if (context.ghostTunnel.transport.kind !== "tunnel") {
2779
+ throw new Error(`Ghost Tunnel transport must be tunnel for localghost tunnel. Current: ${context.ghostTunnel.transport.kind}`);
2780
+ }
2781
+ const entries = listGhostTunnelEntries({
2782
+ cwd: options.cwd,
2783
+ fileName: options.ghostConfig
2784
+ });
2785
+ if (entries.length === 0) {
2786
+ throw new Error(`No exact Ghost Tunnel routes found in ${options.ghostConfig}.`);
2787
+ }
2788
+ const transport = context.ghostTunnel.transport;
2789
+ const store = createRedisGhostTunnelStoreFromEnv({
2790
+ namespace: transport.store.namespace
2791
+ });
2792
+ const controller = new AbortController();
2793
+ const stop = () => controller.abort();
2794
+ process.once("SIGINT", stop);
2795
+ process.once("SIGTERM", stop);
2796
+ const agent = startGhostTunnelAgent({
2797
+ entries,
2798
+ store,
2799
+ targetHost: options.targetHost,
2800
+ routeTtlSeconds: transport.routeTtlSeconds,
2801
+ requestTtlSeconds: transport.requestTtlSeconds,
2802
+ pollIntervalMs: transport.pollIntervalMs,
2803
+ maxResponseBodyBytes: transport.maxResponseBodyBytes,
2804
+ signal: controller.signal,
2805
+ log: (message) => console.log(message)
2806
+ });
2807
+ try {
2808
+ await agent.done;
2809
+ } finally {
2810
+ process.off("SIGINT", stop);
2811
+ process.off("SIGTERM", stop);
2812
+ }
2813
+ });
1990
2814
  program.command("ps").description("Show Localghost setups and currently running sessions").option("--json", "Print raw JSON").action(async (options) => {
1991
2815
  const setups = listLocalghostSetups();
1992
2816
  const runs = listLocalghostRuns();
@@ -2002,7 +2826,83 @@ program.command("print").description("Print parsed host config").option("--cwd <
2002
2826
  warnAboutLocalMdns(entries);
2003
2827
  console.log(JSON.stringify(entries, null, 2));
2004
2828
  });
2005
- program.parseAsync().catch((error) => {
2829
+ function readImplicitInvocation(args) {
2830
+ if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V")) return null;
2831
+ let cwd = process.cwd();
2832
+ let dryRun = false;
2833
+ let updateCheck = true;
2834
+ for (let index = 0; index < args.length; index += 1) {
2835
+ const arg = args[index];
2836
+ if (!arg) continue;
2837
+ if (arg === "--dry-run") {
2838
+ dryRun = true;
2839
+ continue;
2840
+ }
2841
+ if (arg === "--no-update-check") {
2842
+ updateCheck = false;
2843
+ continue;
2844
+ }
2845
+ if (arg === "--cwd") {
2846
+ const value = args[index + 1];
2847
+ if (!value) throw new Error("--cwd requires a path.");
2848
+ cwd = value;
2849
+ index += 1;
2850
+ continue;
2851
+ }
2852
+ if (arg.startsWith("--cwd=")) {
2853
+ cwd = arg.slice("--cwd=".length);
2854
+ continue;
2855
+ }
2856
+ return null;
2857
+ }
2858
+ return { cwd, dryRun, updateCheck };
2859
+ }
2860
+ async function main() {
2861
+ const implicit = readImplicitInvocation(process.argv.slice(2));
2862
+ if (!implicit) {
2863
+ await program.parseAsync();
2864
+ return;
2865
+ }
2866
+ const projectConfig = await readLocalghostProjectConfig({ cwd: implicit.cwd });
2867
+ printLocalghostBanner();
2868
+ if (projectConfig.config.services) {
2869
+ if (!projectConfig.path) throw new Error("Multi-service configuration must come from localghost.config.mjs.");
2870
+ const services = detectDevServices({
2871
+ cwd: implicit.cwd,
2872
+ services: projectConfig.config.services
2873
+ });
2874
+ console.log(formatDetectedDevServices(services));
2875
+ if (implicit.dryRun) return;
2876
+ await runDetectedServices({
2877
+ cwd: implicit.cwd,
2878
+ services,
2879
+ configPath: projectConfig.path,
2880
+ 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
2884
+ });
2885
+ await maybeNotifyAboutUpdate({ disabled: !implicit.updateCheck });
2886
+ return;
2887
+ }
2888
+ const detected = detectDevCommand({
2889
+ cwd: implicit.cwd,
2890
+ ...projectConfig.config.command ? { command: projectConfig.config.command } : {}
2891
+ });
2892
+ console.log(`Localghost detected: ${formatDetectedDevCommand(detected)}`);
2893
+ if (implicit.dryRun) return;
2894
+ await program.parseAsync([
2895
+ process.argv[0] ?? process.execPath,
2896
+ process.argv[1] ?? "localghost",
2897
+ ...implicit.updateCheck ? [] : ["--no-update-check"],
2898
+ "run",
2899
+ "--cwd",
2900
+ implicit.cwd,
2901
+ "--",
2902
+ ...detected.command
2903
+ ]);
2904
+ }
2905
+ main().catch((error) => {
2006
2906
  const message = error instanceof Error ? error.message : String(error);
2007
2907
  console.error(message);
2008
2908
  process.exitCode = 1;