@hamedb89/localghost 0.1.10 → 0.1.13

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,16 +1510,18 @@ 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";
1518
+ if (existsSync5(join7(cwd, "bun.lock")) || existsSync5(join7(cwd, "bun.lockb"))) return "bun";
879
1519
  return "npm";
880
1520
  }
881
1521
  function packageRunCommand(packageManager, script) {
882
1522
  if (packageManager === "yarn") return `yarn ${script}`;
883
1523
  if (packageManager === "pnpm") return `pnpm ${script}`;
1524
+ if (packageManager === "bun") return `bun run ${script}`;
884
1525
  return `npm run ${script}`;
885
1526
  }
886
1527
  function renderConfig(options) {
@@ -893,9 +1534,9 @@ function renderConfig(options) {
893
1534
  ""
894
1535
  ].join("\n");
895
1536
  }
896
- function readPackageJson(path) {
1537
+ function readPackageJson2(path) {
897
1538
  try {
898
- return JSON.parse(readFileSync5(path, "utf8"));
1539
+ return JSON.parse(readFileSync6(path, "utf8"));
899
1540
  } catch {
900
1541
  return null;
901
1542
  }
@@ -908,7 +1549,7 @@ function getConfigFlag(configFile) {
908
1549
  return configFile === LOCALGHOST_CONFIG_FILE ? "" : ` --config ${shellQuote(configFile)}`;
909
1550
  }
910
1551
  function updatePackageScripts(packageJsonPath, configFile) {
911
- const pkg = readPackageJson(packageJsonPath);
1552
+ const pkg = readPackageJson2(packageJsonPath);
912
1553
  if (!pkg) return false;
913
1554
  const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
914
1555
  const configFlag = getConfigFlag(configFile);
@@ -919,6 +1560,7 @@ function updatePackageScripts(packageJsonPath, configFile) {
919
1560
  "localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
920
1561
  "localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
921
1562
  "localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
1563
+ "localghost:repair": scripts["localghost:repair"] ?? `localghost repair${configFlag}`,
922
1564
  "localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
923
1565
  "localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
924
1566
  "localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
@@ -947,8 +1589,8 @@ function initLocalghost(options = {}) {
947
1589
  const apiPort = options.apiPort ?? 8787;
948
1590
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
949
1591
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
950
- const configPath = join6(cwd, configFile);
951
- const configExists = existsSync4(configPath);
1592
+ const configPath = join7(cwd, configFile);
1593
+ const configExists = existsSync5(configPath);
952
1594
  if (configExists && !options.force) {
953
1595
  return {
954
1596
  configPath,
@@ -964,12 +1606,12 @@ function initLocalghost(options = {}) {
964
1606
  };
965
1607
  }
966
1608
  writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
967
- const packageJsonPath = join6(cwd, "package.json");
1609
+ const packageJsonPath = join7(cwd, "package.json");
968
1610
  const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
969
1611
  return {
970
1612
  configPath,
971
1613
  configCreated: true,
972
- ...existsSync4(packageJsonPath) ? { packageJsonPath } : {},
1614
+ ...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
973
1615
  packageJsonChanged,
974
1616
  packageManager,
975
1617
  nextSteps: [
@@ -1061,21 +1703,22 @@ function formatGhostTunnel(config, options = {}) {
1061
1703
  if (options.verbose) {
1062
1704
  lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
1063
1705
  lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
1064
- lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
1706
+ lines.push(` protocol: ${config.requireHttps ? "https required" : "http allowed"}`);
1707
+ lines.push(` transport: ${config.transport.kind}`);
1065
1708
  }
1066
1709
  return lines.join("\n");
1067
1710
  }
1068
1711
 
1069
1712
  // src/state.ts
1070
- import { existsSync as existsSync5 } from "fs";
1071
- import { join as join7 } from "path";
1713
+ import { existsSync as existsSync6 } from "fs";
1714
+ import { join as join8 } from "path";
1072
1715
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
1073
1716
  function getLocalghostStatePath(cwd = process.cwd()) {
1074
- return join7(cwd, LOCALGHOST_STATE_FILE);
1717
+ return join8(cwd, LOCALGHOST_STATE_FILE);
1075
1718
  }
1076
1719
  function readLocalghostState(cwd = process.cwd()) {
1077
1720
  const path = getLocalghostStatePath(cwd);
1078
- if (!existsSync5(path)) return null;
1721
+ if (!existsSync6(path)) return null;
1079
1722
  return JSON.parse(readTextFile(path));
1080
1723
  }
1081
1724
  function writeLocalghostState(cwd, state) {
@@ -1091,11 +1734,11 @@ function patchLocalghostState(cwd, patch) {
1091
1734
  }
1092
1735
 
1093
1736
  // src/update-check.ts
1094
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
1737
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
1095
1738
  import { homedir as homedir2 } from "os";
1096
- import { dirname as dirname4, join as join8 } from "path";
1739
+ import { dirname as dirname4, join as join9 } from "path";
1097
1740
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
1098
- var LOCALGHOST_VERSION = "0.1.10";
1741
+ var LOCALGHOST_VERSION = "0.1.13";
1099
1742
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1100
1743
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
1101
1744
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -1107,13 +1750,13 @@ function isUpdateCheckDisabled(env = process.env) {
1107
1750
  }
1108
1751
  function getUpdateCheckCachePath(env = process.env) {
1109
1752
  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");
1753
+ const cacheRoot = env.XDG_CACHE_HOME || join9(homedir2(), ".cache");
1754
+ return join9(cacheRoot, "localghost", "update-check.json");
1112
1755
  }
1113
1756
  function readCache(path = getUpdateCheckCachePath()) {
1114
- if (!existsSync6(path)) return null;
1757
+ if (!existsSync7(path)) return null;
1115
1758
  try {
1116
- return JSON.parse(readFileSync6(path, "utf8"));
1759
+ return JSON.parse(readFileSync7(path, "utf8"));
1117
1760
  } catch {
1118
1761
  return null;
1119
1762
  }
@@ -1270,6 +1913,10 @@ function warnAboutLocalMdns(entries) {
1270
1913
  function shouldColor() {
1271
1914
  return process.stdout.isTTY && !process.env.NO_COLOR;
1272
1915
  }
1916
+ function printLocalghostBanner() {
1917
+ console.log(renderLocalghostBanner());
1918
+ console.log("");
1919
+ }
1273
1920
  function logDomainRoutes(entries, options = {}) {
1274
1921
  console.log(formatDomainRoutes(entries, options));
1275
1922
  if (options.ghostTunnel?.enabled) {
@@ -1288,8 +1935,8 @@ function parsePort2(value) {
1288
1935
  return port;
1289
1936
  }
1290
1937
  function parsePackageManager(value) {
1291
- if (value === "npm" || value === "yarn" || value === "pnpm") return value;
1292
- throw new InvalidArgumentError("Package manager must be npm, yarn, or pnpm.");
1938
+ if (value === "npm" || value === "yarn" || value === "pnpm" || value === "bun") return value;
1939
+ throw new InvalidArgumentError("Package manager must be npm, pnpm, yarn, or bun.");
1293
1940
  }
1294
1941
  function collect(value, previous = []) {
1295
1942
  return [...previous, value];
@@ -1308,7 +1955,8 @@ function contextOptionsFromCli(options) {
1308
1955
  ...options.project ? { project: options.project } : {},
1309
1956
  ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
1310
1957
  ...options.configPattern ? { configPattern: options.configPattern } : {},
1311
- ...useHttps(options) ? { https: true } : {}
1958
+ ...useHttps(options) ? { https: true } : {},
1959
+ ...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
1312
1960
  };
1313
1961
  }
1314
1962
  function readOptionsFromCli(options) {
@@ -1372,7 +2020,7 @@ function getSetupReadiness(options) {
1372
2020
  }
1373
2021
  const hostsPath = getSystemHostsPath();
1374
2022
  try {
1375
- const hosts = readFileSync7(hostsPath, "utf8");
2023
+ const hosts = readFileSync8(hostsPath, "utf8");
1376
2024
  const expectedHostsBlock = renderHostsBlock(projectName, entries).trimEnd();
1377
2025
  if (!hosts.includes(expectedHostsBlock)) {
1378
2026
  reasons.push(`The Localghost hosts block in ${hostsPath} is missing or stale.`);
@@ -1382,11 +2030,11 @@ function getSetupReadiness(options) {
1382
2030
  reasons.push(`Could not read ${hostsPath}: ${message}`);
1383
2031
  }
1384
2032
  if (!options.ignoreCaddyfile) {
1385
- if (!existsSync7(caddyfilePath)) {
2033
+ if (!existsSync8(caddyfilePath)) {
1386
2034
  reasons.push(`Missing Caddyfile at ${caddyfilePath}.`);
1387
2035
  } else {
1388
2036
  const expectedCaddyfile = renderCaddyfile(entries, { https });
1389
- const currentCaddyfile = readFileSync7(caddyfilePath, "utf8");
2037
+ const currentCaddyfile = readFileSync8(caddyfilePath, "utf8");
1390
2038
  if (currentCaddyfile !== expectedCaddyfile) {
1391
2039
  reasons.push(`Caddyfile at ${caddyfilePath} is stale for ${https ? "HTTPS" : "HTTP"} mode.`);
1392
2040
  }
@@ -1430,15 +2078,15 @@ async function runSetupFromReadiness(cwd, https, readiness) {
1430
2078
  entries: readiness.entries
1431
2079
  });
1432
2080
  }
1433
- function wait(ms) {
1434
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2081
+ function wait2(ms) {
2082
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1435
2083
  }
1436
2084
  async function runTrust(cwd, caddyfilePath) {
1437
- await wait(350);
2085
+ await wait2(350);
1438
2086
  try {
1439
2087
  await trustCaddy(caddyfilePath);
1440
2088
  } catch {
1441
- await wait(750);
2089
+ await wait2(750);
1442
2090
  await trustCaddy(caddyfilePath);
1443
2091
  }
1444
2092
  patchLocalghostState(cwd, { caddyTrustedAt: (/* @__PURE__ */ new Date()).toISOString() });
@@ -1478,6 +2126,131 @@ function registerCleanup(id) {
1478
2126
  process.off("exit", cleanup);
1479
2127
  };
1480
2128
  }
2129
+ async function resolveServiceRuntimeEntries(services, dynamicPort) {
2130
+ const usedPorts = /* @__PURE__ */ new Set();
2131
+ const resolved = [];
2132
+ for (const service of services) {
2133
+ let port = service.requestedPort;
2134
+ if (dynamicPort) {
2135
+ let found = false;
2136
+ for (let offset = 0; offset < 50; offset += 1) {
2137
+ const candidate = service.requestedPort + offset;
2138
+ if (candidate > 65535 || usedPorts.has(candidate)) continue;
2139
+ if (await isPortAvailable(candidate)) {
2140
+ port = candidate;
2141
+ found = true;
2142
+ break;
2143
+ }
2144
+ }
2145
+ if (!found) {
2146
+ throw new Error(`No available port found for service ${service.name} from ${service.requestedPort}.`);
2147
+ }
2148
+ } else if (usedPorts.has(port)) {
2149
+ throw new Error(`Services cannot start separate commands on the same fixed port: ${port}.`);
2150
+ }
2151
+ usedPorts.add(port);
2152
+ resolved.push({
2153
+ ...service,
2154
+ port,
2155
+ entry: {
2156
+ host: service.host,
2157
+ port,
2158
+ target: `127.0.0.1:${port}`
2159
+ }
2160
+ });
2161
+ }
2162
+ return resolved;
2163
+ }
2164
+ async function waitForServicePorts(entries, timeoutMs = 1e4) {
2165
+ const deadline = Date.now() + timeoutMs;
2166
+ const ports = [...new Set(entries.map((entry) => entry.port))];
2167
+ while (Date.now() < deadline) {
2168
+ const availability = await Promise.all(ports.map((port) => isPortAvailable(port)));
2169
+ if (availability.every((available) => !available)) return true;
2170
+ await wait2(50);
2171
+ }
2172
+ return false;
2173
+ }
2174
+ async function runDetectedServices(options) {
2175
+ assertLocalDevelopment("run");
2176
+ await assertCaddyReady();
2177
+ const runtimeServices = await resolveServiceRuntimeEntries(options.services, options.dynamicPort);
2178
+ const entries = runtimeServices.map((service) => service.entry);
2179
+ const readiness = getSetupReadiness({
2180
+ cwd: options.cwd,
2181
+ https: options.https,
2182
+ ignoreCaddyfile: true,
2183
+ entries,
2184
+ configPath: options.configPath,
2185
+ projectName: options.projectName
2186
+ });
2187
+ if (!readiness.ready) {
2188
+ if (!options.autoRepair) {
2189
+ throw new Error([
2190
+ "Localghost setup is missing or stale.",
2191
+ ...readiness.reasons.map((reason) => `- ${reason}`),
2192
+ "Automatic repair is disabled. Enable autoRepair or run localghost repair."
2193
+ ].join("\n"));
2194
+ }
2195
+ console.log("Localghost setup is stale; repairing it now.");
2196
+ await runSetupFromReadiness(options.cwd, options.https, readiness);
2197
+ }
2198
+ for (const service of runtimeServices) {
2199
+ if (service.port !== service.requestedPort) {
2200
+ console.log(`${service.name}: port ${service.requestedPort} is busy; using ${service.port}.`);
2201
+ }
2202
+ }
2203
+ const caddyfile = await writeCaddyfile(entries, options.cwd, { https: options.https });
2204
+ await validateCaddyfile(caddyfile);
2205
+ const caddy = startCaddy(caddyfile);
2206
+ const caddyExit = caddy.catch((error) => {
2207
+ if (!caddy.killed) throw error;
2208
+ });
2209
+ const children = runtimeServices.map((service) => execa4(service.command[0], service.command.slice(1), {
2210
+ cwd: service.cwd,
2211
+ stdio: "inherit",
2212
+ env: {
2213
+ ...process.env,
2214
+ LOCALGHOST_PORT: String(service.port),
2215
+ LOCALGHOST_DYNAMIC_PORT: options.dynamicPort ? "1" : "0",
2216
+ LOCALGHOST_SERVICE: service.name,
2217
+ VITE_PORT: String(service.port)
2218
+ }
2219
+ }));
2220
+ const caddyPid = maybePid(caddy.pid);
2221
+ const runRecord = registerLocalghostRun({
2222
+ mode: "run",
2223
+ cwd: options.cwd,
2224
+ projectName: options.projectName,
2225
+ configPath: options.configPath,
2226
+ caddyfilePath: caddyfile,
2227
+ ...caddyPid ? { caddyPid } : {},
2228
+ childCommand: ["services", ...runtimeServices.map((service) => service.name)],
2229
+ https: options.https,
2230
+ dynamicPort: options.dynamicPort,
2231
+ entries
2232
+ });
2233
+ const cleanupRun = registerCleanup(runRecord.id);
2234
+ const processExit = Promise.race([caddyExit, ...children]);
2235
+ try {
2236
+ const ready = await Promise.race([
2237
+ waitForServicePorts(entries),
2238
+ processExit.then(() => false)
2239
+ ]);
2240
+ if (ready) {
2241
+ console.log("");
2242
+ logDomainRoutes(entries, { https: options.https });
2243
+ }
2244
+ await processExit;
2245
+ } finally {
2246
+ for (const child of children) {
2247
+ if (!child.killed) child.kill("SIGINT");
2248
+ }
2249
+ if (!caddy.killed) caddy.kill("SIGINT");
2250
+ await Promise.allSettled([caddyExit, ...children]);
2251
+ cleanupRun();
2252
+ }
2253
+ }
1481
2254
  async function getRouteViews(entries) {
1482
2255
  const portStatus = /* @__PURE__ */ new Map();
1483
2256
  for (const entry of entries) {
@@ -1575,7 +2348,7 @@ program.hook("postAction", async (_thisCommand, actionCommand) => {
1575
2348
  const options = program.opts();
1576
2349
  await maybeNotifyAboutUpdate({ disabled: options.updateCheck === false });
1577
2350
  });
1578
- program.command("init").description("Create a .localghost config for this project").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to create", ".localghost").option("--host <host>", "Primary local hostname").option("--port <number>", "Primary app port", parsePort2).option("--api-host <host>", "API local hostname").option("--api-port <number>", "API port", parsePort2).option("--package-manager <npm|yarn|pnpm>", "Package manager for suggested commands", parsePackageManager).option("--write-scripts", "Add localghost scripts to package.json").option("--force", "Overwrite an existing config file").action((options) => {
2351
+ program.command("init").description("Create a .localghost config for this project").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to create", ".localghost").option("--host <host>", "Primary local hostname").option("--port <number>", "Primary app port", parsePort2).option("--api-host <host>", "API local hostname").option("--api-port <number>", "API port", parsePort2).option("--package-manager <npm|pnpm|yarn|bun>", "Package manager for suggested commands", parsePackageManager).option("--write-scripts", "Add localghost scripts to package.json").option("--force", "Overwrite an existing config file").action((options) => {
1579
2352
  const result = initLocalghost({ ...options, configFile: options.config });
1580
2353
  if (result.configCreated) {
1581
2354
  console.log(`Buh. Created ${result.configPath}`);
@@ -1629,6 +2402,7 @@ program.command("update").description("Check npm for a newer localghost release"
1629
2402
  });
1630
2403
  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
2404
  assertLocalDevelopment("setup");
2405
+ printLocalghostBanner();
1632
2406
  await assertCaddyReady();
1633
2407
  const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
1634
2408
  const https = context.https;
@@ -1686,6 +2460,32 @@ program.command("trust").description("Trust Caddy's local HTTPS CA for this proj
1686
2460
  await validateCaddyfile(caddyfile);
1687
2461
  await runTrust(options.cwd, caddyfile);
1688
2462
  });
2463
+ program.command("repair").description("Reconcile stale hosts, Caddyfile, setup state, and optional HTTPS trust").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Repair an HTTPS Caddy setup").option("--ssl", "Alias for --https").option("--trust", "Re-run Caddy's local HTTPS trust step").action(async (options) => {
2464
+ assertLocalDevelopment("repair");
2465
+ printLocalghostBanner();
2466
+ await assertCaddyReady();
2467
+ const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
2468
+ const readiness = getSetupReadiness({
2469
+ ...options,
2470
+ https: context.https,
2471
+ entries: context.entries,
2472
+ configPath: context.configPath,
2473
+ projectName: context.projectName
2474
+ });
2475
+ if (options.trust && !context.https) {
2476
+ throw new Error("Cannot repair HTTPS trust unless HTTPS is enabled. Pass --https or configure https: true.");
2477
+ }
2478
+ warnAboutLocalMdns(context.entries);
2479
+ logDomainRoutes(context.entries, { https: context.https, ghostTunnel: context.ghostTunnel });
2480
+ await runSetupFromReadiness(options.cwd, context.https, readiness);
2481
+ if (options.trust) {
2482
+ await runTrust(options.cwd, readiness.caddyfilePath);
2483
+ }
2484
+ console.log(`Repaired hosts: ${getSystemHostsPath()}`);
2485
+ console.log(`Repaired Caddyfile: ${readiness.caddyfilePath}`);
2486
+ console.log(`Repaired state: ${readiness.statePath}`);
2487
+ console.log("Repair complete.");
2488
+ });
1689
2489
  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
2490
  assertLocalDevelopment("reset");
1691
2491
  const projectName = sanitizeProjectName(options.project ?? getProjectName(options.cwd));
@@ -1693,13 +2493,13 @@ program.command("reset").description("Remove Localghost setup state without dele
1693
2493
  const statePath = getLocalghostStatePath(options.cwd);
1694
2494
  explainHostsPassword();
1695
2495
  const hostsResult = await removeSystemHosts(projectName);
1696
- if (existsSync7(caddyfilePath)) {
2496
+ if (existsSync8(caddyfilePath)) {
1697
2497
  unlinkSync(caddyfilePath);
1698
2498
  console.log(`Removed ${caddyfilePath}`);
1699
2499
  } else {
1700
2500
  console.log(`${caddyfilePath} was not present`);
1701
2501
  }
1702
- if (existsSync7(statePath)) {
2502
+ if (existsSync8(statePath)) {
1703
2503
  unlinkSync(statePath);
1704
2504
  console.log(`Removed ${statePath}`);
1705
2505
  } else {
@@ -1720,7 +2520,7 @@ program.command("teardown").description("Remove Localghost's managed /etc/hosts
1720
2520
  const hostsResult = await removeSystemHosts(projectName);
1721
2521
  const caddyfilePath = getCaddyfilePath(options.cwd);
1722
2522
  let caddyfileRemoved = false;
1723
- if (options.removeCaddyfile && existsSync7(caddyfilePath)) {
2523
+ if (options.removeCaddyfile && existsSync8(caddyfilePath)) {
1724
2524
  unlinkSync(caddyfilePath);
1725
2525
  caddyfileRemoved = true;
1726
2526
  }
@@ -1800,7 +2600,7 @@ program.command("routes").description("Print domain to upstream routes").option(
1800
2600
  }));
1801
2601
  }
1802
2602
  });
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) => {
2603
+ program.command("dev").description("Run the Localghost Caddy proxy, repairing stale setup when needed").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the proxy").action(async (options) => {
1804
2604
  assertLocalDevelopment("dev");
1805
2605
  await assertCaddyReady();
1806
2606
  const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
@@ -1813,41 +2613,18 @@ program.command("dev").description("Run the Localghost Caddy proxy after setup")
1813
2613
  projectName: context.projectName
1814
2614
  });
1815
2615
  if (!readiness.ready) {
1816
- if (!options.setup) {
2616
+ if (!options.setup && !context.autoRepair) {
1817
2617
  throw new Error(
1818
2618
  [
1819
2619
  "Localghost setup is missing or stale.",
1820
2620
  ...readiness.reasons.map((reason) => `- ${reason}`),
1821
2621
  `Run: ${readiness.setupCommand}`,
1822
- "Or rerun dev with --setup if you want Localghost to perform setup first."
2622
+ "Automatic repair is disabled. Rerun without --auto-repair=no or run localghost repair."
1823
2623
  ].join("\n")
1824
2624
  );
1825
2625
  }
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
- });
2626
+ console.log("Localghost setup is stale; repairing it now.");
2627
+ await runSetupFromReadiness(options.cwd, https, readiness);
1851
2628
  }
1852
2629
  warnAboutLocalMdns(readiness.entries);
1853
2630
  logDomainRoutes(readiness.entries, { https, ghostTunnel: context.ghostTunnel });
@@ -1883,7 +2660,7 @@ program.command("dev").description("Run the Localghost Caddy proxy after setup")
1883
2660
  cleanupRun();
1884
2661
  }
1885
2662
  });
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) => {
2663
+ program.command("run").description("Run Caddy and a dev command from the same Localghost context").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--port <number>", "Initial app port", parsePort2).option("--https", "Run a local HTTPS proxy with Caddy local certificates").option("--ssl", "Alias for --https").option("--setup", "Alias for automatic repair when setup is missing or stale").option("--auto-repair [yes|no]", "Repair stale setup before starting (default: yes)", parseBooleanLike).option("--trust", "Trust Caddy's local HTTPS CA before starting the child command").option("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
1887
2664
  assertLocalDevelopment("run");
1888
2665
  await assertCaddyReady();
1889
2666
  const context = await resolveLocalghostContext({
@@ -1893,7 +2670,8 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1893
2670
  ...options.configPattern ? { configPattern: options.configPattern } : {},
1894
2671
  ...options.port ? { port: options.port } : {},
1895
2672
  ...useHttps(options) ? { https: true } : {},
1896
- ...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {}
2673
+ ...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {},
2674
+ ...typeof options.autoRepair === "boolean" ? { autoRepair: options.autoRepair } : {}
1897
2675
  });
1898
2676
  const https = context.https;
1899
2677
  const readiness = getSetupReadiness({
@@ -1905,18 +2683,19 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1905
2683
  projectName: context.projectName
1906
2684
  });
1907
2685
  if (!readiness.ready) {
1908
- const shouldSetup = options.setup === true || canPrompt() && await confirm("Run caddy:setup now?", true);
1909
- if (!shouldSetup) {
2686
+ if (!options.setup && !context.autoRepair) {
1910
2687
  throw new Error(
1911
2688
  [
1912
2689
  "Localghost setup is missing or stale.",
1913
2690
  ...readiness.reasons.map((reason) => `- ${reason}`),
1914
- `Run: ${readiness.setupCommand}`
2691
+ `Run: ${readiness.setupCommand}`,
2692
+ "Automatic repair is disabled. Rerun without --auto-repair=no or run localghost repair."
1915
2693
  ].join("\n")
1916
2694
  );
1917
2695
  }
2696
+ console.log("Localghost setup is stale; repairing it now.");
1918
2697
  await runSetupFromReadiness(options.cwd, https, readiness);
1919
- console.log(`All set. Setup state: ${getLocalghostStatePath(options.cwd)}`);
2698
+ console.log(`Repair complete. Setup state: ${getLocalghostStatePath(options.cwd)}`);
1920
2699
  }
1921
2700
  if (context.dynamicPort && context.port !== context.requestedPort) {
1922
2701
  console.log(`Port ${context.requestedPort} is busy; using ${context.port}.`);
@@ -1987,6 +2766,53 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1987
2766
  cleanupRun();
1988
2767
  }
1989
2768
  });
2769
+ program.command("tunnel").description("Run the local Ghost Tunnel agent").option("--cwd <path>", "Project directory", process.cwd()).option("--config <file>", "Config file to look for. Can be repeated.", collect, []).option("--config-pattern <regex>", "Regex for config filenames in the project root").option("--ghost-config <file>", "Exact Ghost Tunnel route file", ".ghosttunnel").option("--target-host <host>", "Local target host for .ghosttunnel ports", "127.0.0.1").action(async (options) => {
2770
+ assertLocalDevelopment("tunnel");
2771
+ const context = await resolveLocalghostContext({
2772
+ cwd: options.cwd,
2773
+ ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
2774
+ ...options.configPattern ? { configPattern: options.configPattern } : {},
2775
+ dynamicPort: false
2776
+ });
2777
+ if (!context.ghostTunnel.enabled) {
2778
+ throw new Error("Ghost Tunnel is not enabled in localghost.config.mjs.");
2779
+ }
2780
+ if (context.ghostTunnel.transport.kind !== "tunnel") {
2781
+ throw new Error(`Ghost Tunnel transport must be tunnel for localghost tunnel. Current: ${context.ghostTunnel.transport.kind}`);
2782
+ }
2783
+ const entries = listGhostTunnelEntries({
2784
+ cwd: options.cwd,
2785
+ fileName: options.ghostConfig
2786
+ });
2787
+ if (entries.length === 0) {
2788
+ throw new Error(`No exact Ghost Tunnel routes found in ${options.ghostConfig}.`);
2789
+ }
2790
+ const transport = context.ghostTunnel.transport;
2791
+ const store = createRedisGhostTunnelStoreFromEnv({
2792
+ namespace: transport.store.namespace
2793
+ });
2794
+ const controller = new AbortController();
2795
+ const stop = () => controller.abort();
2796
+ process.once("SIGINT", stop);
2797
+ process.once("SIGTERM", stop);
2798
+ const agent = startGhostTunnelAgent({
2799
+ entries,
2800
+ store,
2801
+ targetHost: options.targetHost,
2802
+ routeTtlSeconds: transport.routeTtlSeconds,
2803
+ requestTtlSeconds: transport.requestTtlSeconds,
2804
+ pollIntervalMs: transport.pollIntervalMs,
2805
+ maxResponseBodyBytes: transport.maxResponseBodyBytes,
2806
+ signal: controller.signal,
2807
+ log: (message) => console.log(message)
2808
+ });
2809
+ try {
2810
+ await agent.done;
2811
+ } finally {
2812
+ process.off("SIGINT", stop);
2813
+ process.off("SIGTERM", stop);
2814
+ }
2815
+ });
1990
2816
  program.command("ps").description("Show Localghost setups and currently running sessions").option("--json", "Print raw JSON").action(async (options) => {
1991
2817
  const setups = listLocalghostSetups();
1992
2818
  const runs = listLocalghostRuns();
@@ -2002,7 +2828,83 @@ program.command("print").description("Print parsed host config").option("--cwd <
2002
2828
  warnAboutLocalMdns(entries);
2003
2829
  console.log(JSON.stringify(entries, null, 2));
2004
2830
  });
2005
- program.parseAsync().catch((error) => {
2831
+ function readImplicitInvocation(args) {
2832
+ if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V")) return null;
2833
+ let cwd = process.cwd();
2834
+ let dryRun = false;
2835
+ let updateCheck = true;
2836
+ for (let index = 0; index < args.length; index += 1) {
2837
+ const arg = args[index];
2838
+ if (!arg) continue;
2839
+ if (arg === "--dry-run") {
2840
+ dryRun = true;
2841
+ continue;
2842
+ }
2843
+ if (arg === "--no-update-check") {
2844
+ updateCheck = false;
2845
+ continue;
2846
+ }
2847
+ if (arg === "--cwd") {
2848
+ const value = args[index + 1];
2849
+ if (!value) throw new Error("--cwd requires a path.");
2850
+ cwd = value;
2851
+ index += 1;
2852
+ continue;
2853
+ }
2854
+ if (arg.startsWith("--cwd=")) {
2855
+ cwd = arg.slice("--cwd=".length);
2856
+ continue;
2857
+ }
2858
+ return null;
2859
+ }
2860
+ return { cwd, dryRun, updateCheck };
2861
+ }
2862
+ async function main() {
2863
+ const implicit = readImplicitInvocation(process.argv.slice(2));
2864
+ if (!implicit) {
2865
+ await program.parseAsync();
2866
+ return;
2867
+ }
2868
+ const projectConfig = await readLocalghostProjectConfig({ cwd: implicit.cwd });
2869
+ printLocalghostBanner();
2870
+ if (projectConfig.config.services) {
2871
+ if (!projectConfig.path) throw new Error("Multi-service configuration must come from localghost.config.mjs.");
2872
+ const services = detectDevServices({
2873
+ cwd: implicit.cwd,
2874
+ services: projectConfig.config.services
2875
+ });
2876
+ console.log(formatDetectedDevServices(services));
2877
+ if (implicit.dryRun) return;
2878
+ await runDetectedServices({
2879
+ cwd: implicit.cwd,
2880
+ services,
2881
+ configPath: projectConfig.path,
2882
+ projectName: sanitizeProjectName(projectConfig.config.project ?? getProjectName(implicit.cwd)),
2883
+ https: projectConfig.config.https ?? false,
2884
+ dynamicPort: projectConfig.config.dynamicPort ?? true,
2885
+ autoRepair: projectConfig.config.autoRepair ?? true
2886
+ });
2887
+ await maybeNotifyAboutUpdate({ disabled: !implicit.updateCheck });
2888
+ return;
2889
+ }
2890
+ const detected = detectDevCommand({
2891
+ cwd: implicit.cwd,
2892
+ ...projectConfig.config.command ? { command: projectConfig.config.command } : {}
2893
+ });
2894
+ console.log(`Localghost detected: ${formatDetectedDevCommand(detected)}`);
2895
+ if (implicit.dryRun) return;
2896
+ await program.parseAsync([
2897
+ process.argv[0] ?? process.execPath,
2898
+ process.argv[1] ?? "localghost",
2899
+ ...implicit.updateCheck ? [] : ["--no-update-check"],
2900
+ "run",
2901
+ "--cwd",
2902
+ implicit.cwd,
2903
+ "--",
2904
+ ...detected.command
2905
+ ]);
2906
+ }
2907
+ main().catch((error) => {
2006
2908
  const message = error instanceof Error ? error.message : String(error);
2007
2909
  console.error(message);
2008
2910
  process.exitCode = 1;