@hamedb89/localghost 0.1.6 → 0.1.9

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 existsSync6, readFileSync as readFileSync6, unlinkSync } from "fs";
4
+ import { existsSync as existsSync7, readFileSync as readFileSync7, unlinkSync } from "fs";
5
5
  import { Command, InvalidArgumentError } from "commander";
6
6
 
7
7
  // src/activity.ts
@@ -25,7 +25,7 @@ function isProcessRunning(pid) {
25
25
  }
26
26
  }
27
27
  function emptyActivity() {
28
- return { version: LOCALGHOST_ACTIVITY_VERSION, runs: [] };
28
+ return { version: LOCALGHOST_ACTIVITY_VERSION, runs: [], setups: [] };
29
29
  }
30
30
  function readLocalghostActivity(path = getLocalghostActivityPath()) {
31
31
  if (!existsSync(path)) return emptyActivity();
@@ -33,7 +33,8 @@ function readLocalghostActivity(path = getLocalghostActivityPath()) {
33
33
  const parsed = JSON.parse(readFileSync(path, "utf8"));
34
34
  return {
35
35
  version: LOCALGHOST_ACTIVITY_VERSION,
36
- runs: Array.isArray(parsed.runs) ? parsed.runs : []
36
+ runs: Array.isArray(parsed.runs) ? parsed.runs : [],
37
+ setups: Array.isArray(parsed.setups) ? parsed.setups : []
37
38
  };
38
39
  } catch {
39
40
  return emptyActivity();
@@ -48,22 +49,29 @@ function writeLocalghostActivity(activity, path = getLocalghostActivityPath()) {
48
49
  function createRunId(input2, pid) {
49
50
  return `${input2.projectName}:${input2.mode}:${pid}:${Date.now()}`;
50
51
  }
52
+ function createSetupId(input2) {
53
+ return `${input2.projectName}:${input2.cwd}:${input2.configPath ?? ""}`;
54
+ }
51
55
  function pruneLocalghostActivity(path = getLocalghostActivityPath()) {
52
56
  const activity = readLocalghostActivity(path);
53
57
  const activeRuns = activity.runs.filter((run) => isProcessRunning(run.pid));
54
58
  const pruned = activeRuns.length !== activity.runs.length;
55
59
  if (pruned) {
56
- writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: activeRuns }, path);
60
+ writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, runs: activeRuns }, path);
57
61
  }
58
62
  return {
59
63
  path,
60
64
  pruned,
61
- runs: activeRuns
65
+ runs: activeRuns,
66
+ setups: activity.setups
62
67
  };
63
68
  }
64
69
  function listLocalghostRuns(path = getLocalghostActivityPath()) {
65
70
  return pruneLocalghostActivity(path).runs;
66
71
  }
72
+ function listLocalghostSetups(path = getLocalghostActivityPath()) {
73
+ return pruneLocalghostActivity(path).setups;
74
+ }
67
75
  function registerLocalghostRun(input2, path = getLocalghostActivityPath()) {
68
76
  const now = (/* @__PURE__ */ new Date()).toISOString();
69
77
  const pid = input2.pid ?? process.pid;
@@ -87,14 +95,43 @@ function registerLocalghostRun(input2, path = getLocalghostActivityPath()) {
87
95
  entries: input2.entries
88
96
  };
89
97
  const current = pruneLocalghostActivity(path).runs.filter((run) => run.id !== record.id);
90
- writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: [...current, record] }, path);
98
+ const activity = readLocalghostActivity(path);
99
+ writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: [...current, record], setups: activity.setups }, path);
100
+ return record;
101
+ }
102
+ function registerLocalghostSetup(input2, path = getLocalghostActivityPath()) {
103
+ const record = {
104
+ id: input2.id ?? createSetupId(input2),
105
+ cwd: input2.cwd,
106
+ projectName: input2.projectName,
107
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
108
+ ...input2.configPath ? { configPath: input2.configPath } : {},
109
+ ...input2.caddyfilePath ? { caddyfilePath: input2.caddyfilePath } : {},
110
+ ...typeof input2.https === "boolean" ? { https: input2.https } : {},
111
+ entries: input2.entries
112
+ };
113
+ const activity = pruneLocalghostActivity(path);
114
+ const setups = activity.setups.filter((setup) => setup.id !== record.id);
115
+ writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: activity.runs, setups: [...setups, record] }, path);
91
116
  return record;
92
117
  }
93
118
  function unregisterLocalghostRun(id, path = getLocalghostActivityPath()) {
94
119
  const activity = readLocalghostActivity(path);
95
120
  const runs = activity.runs.filter((run) => run.id !== id);
96
121
  if (runs.length !== activity.runs.length) {
97
- writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs }, path);
122
+ writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, runs }, path);
123
+ }
124
+ }
125
+ function unregisterLocalghostSetup(options, path = getLocalghostActivityPath()) {
126
+ const activity = readLocalghostActivity(path);
127
+ const setups = activity.setups.filter((setup) => {
128
+ if (setup.cwd !== options.cwd) return true;
129
+ if (options.projectName && setup.projectName !== options.projectName) return true;
130
+ if (options.configPath && setup.configPath !== options.configPath) return true;
131
+ return false;
132
+ });
133
+ if (setups.length !== activity.setups.length) {
134
+ writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, setups }, path);
98
135
  }
99
136
  }
100
137
 
@@ -280,6 +317,17 @@ function startCaddy(path) {
280
317
  stdio: "inherit"
281
318
  });
282
319
  }
320
+ async function trustCaddy(path) {
321
+ await execa("caddy", ["trust", "--config", path], {
322
+ cwd: dirname3(path),
323
+ stdio: "inherit"
324
+ });
325
+ }
326
+
327
+ // src/context.ts
328
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
329
+ import { join as join4 } from "path";
330
+ import { pathToFileURL } from "url";
283
331
 
284
332
  // src/port.ts
285
333
  import { createServer } from "net";
@@ -307,7 +355,263 @@ async function findAvailablePort(startPort, options = {}) {
307
355
  throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
308
356
  }
309
357
 
358
+ // src/tunnel.ts
359
+ import { domainToASCII } from "url";
360
+ var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
361
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
362
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
363
+ var DEFAULT_GHOST_TUNNEL_MODE = "manual";
364
+ function isResolvedGhostTunnelConfig(value) {
365
+ return typeof value === "object" && value !== null && "enabled" in value;
366
+ }
367
+ function toGhostTunnelConfig(options) {
368
+ return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
369
+ }
370
+ function stripHostPort(value) {
371
+ const trimmed = value.trim().toLowerCase();
372
+ if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
373
+ const portSeparator = trimmed.lastIndexOf(":");
374
+ if (portSeparator === -1) return trimmed;
375
+ const port = trimmed.slice(portSeparator + 1);
376
+ return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
377
+ }
378
+ function normalizeDomain(value) {
379
+ const host = stripHostPort(value.replace(/^\*\./, ""));
380
+ const ascii = domainToASCII(host);
381
+ if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
382
+ if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
383
+ if (ascii.includes("*")) return null;
384
+ if (!ascii.split(".").every(isValidHostLabel)) return null;
385
+ return ascii;
386
+ }
387
+ function isValidHostLabel(value) {
388
+ return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
389
+ }
390
+ function isValidNamespaceTag(value) {
391
+ return /^[a-z][a-z0-9_]*$/i.test(value);
392
+ }
393
+ function isNamespaceTagList(options) {
394
+ return Array.isArray(options);
395
+ }
396
+ function assertValidSubdomain(value) {
397
+ if (!isValidHostLabel(value)) {
398
+ throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
399
+ }
400
+ }
401
+ function normalizeDomains(domains) {
402
+ const values = typeof domains === "string" ? [domains] : [...domains ?? []];
403
+ const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
404
+ const domain = normalizeDomain(value);
405
+ if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
406
+ return domain;
407
+ });
408
+ return [...new Set(normalized)];
409
+ }
410
+ function parseGhostTunnelMode(value) {
411
+ return value ?? DEFAULT_GHOST_TUNNEL_MODE;
412
+ }
413
+ function resolveNamespaceConfig(options) {
414
+ const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
415
+ let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
416
+ let spreadTag = tags.includes("project") ? "project" : void 0;
417
+ if (options && !isNamespaceTagList(options)) {
418
+ separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
419
+ spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
420
+ }
421
+ if (tags.length === 0) {
422
+ throw new Error("Ghost tunnel namespace must include at least one tag.");
423
+ }
424
+ for (const tag of tags) {
425
+ if (!isValidNamespaceTag(tag)) {
426
+ throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
427
+ }
428
+ }
429
+ if (spreadTag && !tags.includes(spreadTag)) {
430
+ throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
431
+ }
432
+ if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
433
+ throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
434
+ }
435
+ return {
436
+ tags,
437
+ separator,
438
+ ...spreadTag ? { spreadTag } : {}
439
+ };
440
+ }
441
+ function normalizeNamespaceValue(tag, value, separator, options = {}) {
442
+ const normalized = normalizeDomain(value);
443
+ if (!normalized || normalized.includes(".")) {
444
+ throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
445
+ }
446
+ if (!options.allowSeparator && normalized.includes(separator)) {
447
+ throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
448
+ }
449
+ return normalized;
450
+ }
451
+ function createNamespaceSlug(config, values) {
452
+ const parts = config.tags.map((tag) => {
453
+ const value = values[tag];
454
+ if (!value) {
455
+ throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
456
+ }
457
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
458
+ });
459
+ const slug = parts.join(config.separator);
460
+ if (!isValidHostLabel(slug)) {
461
+ throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
462
+ }
463
+ return slug;
464
+ }
465
+ function createNamespaceDisplaySlug(config, values = {}) {
466
+ return config.tags.map((tag) => {
467
+ const value = values[tag];
468
+ if (!value) return `<${tag}>`;
469
+ try {
470
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
471
+ } catch {
472
+ return `<${tag}>`;
473
+ }
474
+ }).join(config.separator);
475
+ }
476
+ function getPreviewDefaults(preview, defaults) {
477
+ return {
478
+ domain: preview?.domain ?? defaults?.domain,
479
+ route: preview?.route ?? defaults?.route,
480
+ project: preview?.project ?? defaults?.project,
481
+ owner: preview?.owner ?? defaults?.owner,
482
+ values: {
483
+ ...defaults?.values ?? {},
484
+ ...preview?.values ?? {}
485
+ },
486
+ path: preview?.path,
487
+ protocol: preview?.protocol
488
+ };
489
+ }
490
+ function getDisplayValues(input2) {
491
+ return {
492
+ ...input2.route ? { route: input2.route } : {},
493
+ ...input2.project ? { project: input2.project } : {},
494
+ ...input2.owner ? { owner: input2.owner } : {},
495
+ ...input2.values
496
+ };
497
+ }
498
+ function createDisplayUrl(config, defaults, domain) {
499
+ const input2 = getPreviewDefaults(config.preview, defaults);
500
+ const protocol = input2.protocol ?? "https";
501
+ const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input2));
502
+ const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input2.domain ? getGhostTunnelEntryHost(input2.domain, config) : `${config.subdomain}.*`;
503
+ const url = `${protocol}://${slug}.${entryHost}/`;
504
+ if (!input2.path) return url;
505
+ return `${url}${input2.path.replace(/^\/+/, "")}`;
506
+ }
507
+ function createDisplayUrls(config, defaults) {
508
+ const domains = config.domains.length > 0 ? config.domains : defaults?.domain ? [defaults.domain] : [];
509
+ const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, defaults, domain)) : [createDisplayUrl(config, defaults)];
510
+ return [...new Set(urls)];
511
+ }
512
+ function maybeConstructPreviewUrl(config, defaults) {
513
+ if (!config.preview) return void 0;
514
+ const input2 = getPreviewDefaults(config.preview, defaults);
515
+ if (!input2.domain || !input2.route || !input2.project || !input2.owner) return void 0;
516
+ return constructGhostTunnelUrl({
517
+ domain: input2.domain,
518
+ route: input2.route,
519
+ project: input2.project,
520
+ owner: input2.owner,
521
+ values: input2.values,
522
+ ...input2.path ? { path: input2.path } : {},
523
+ ...input2.protocol ? { protocol: input2.protocol } : {},
524
+ ghostTunnel: config
525
+ });
526
+ }
527
+ function resolveGhostTunnelConfig(options, defaults) {
528
+ if (options === false || typeof options === "undefined") {
529
+ return {
530
+ enabled: false,
531
+ mode: DEFAULT_GHOST_TUNNEL_MODE,
532
+ domains: [],
533
+ subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
534
+ namespace: resolveNamespaceConfig(void 0),
535
+ displayUrls: [],
536
+ requireHttps: true,
537
+ requireAuth: true
538
+ };
539
+ }
540
+ const config = typeof options === "string" ? { mode: options } : options;
541
+ const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
542
+ assertValidSubdomain(subdomain);
543
+ const domains = normalizeDomains(config.domains);
544
+ const enabled = config.enabled ?? true;
545
+ const resolved = {
546
+ enabled,
547
+ mode: parseGhostTunnelMode(config.mode),
548
+ domains,
549
+ subdomain,
550
+ namespace: resolveNamespaceConfig(config.namespace),
551
+ ...config.preview ? { preview: config.preview } : {},
552
+ displayUrls: [],
553
+ requireHttps: config.requireHttps ?? true,
554
+ requireAuth: config.requireAuth ?? true
555
+ };
556
+ if (!enabled) {
557
+ return resolved;
558
+ }
559
+ const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
560
+ const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
561
+ return {
562
+ ...resolved,
563
+ displayUrls,
564
+ ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
565
+ ...previewUrl ? { previewUrl } : {}
566
+ };
567
+ }
568
+ function getGhostTunnelEntryHost(domain, options = {}) {
569
+ const config = toGhostTunnelConfig(options);
570
+ const normalizedDomain = normalizeDomain(domain);
571
+ if (!normalizedDomain) {
572
+ throw new Error(`Invalid ghost tunnel domain: ${domain}`);
573
+ }
574
+ return `${config.subdomain}.${normalizedDomain}`;
575
+ }
576
+ function constructGhostTunnelHost(input2) {
577
+ const config = toGhostTunnelConfig(input2.ghostTunnel ?? {});
578
+ if (!config.enabled) {
579
+ throw new Error("Ghost tunnel is not enabled.");
580
+ }
581
+ const namespaceValues = {
582
+ route: input2.route,
583
+ project: input2.project,
584
+ owner: input2.owner,
585
+ ...input2.values ?? {}
586
+ };
587
+ const slug = createNamespaceSlug(config.namespace, namespaceValues);
588
+ return `${slug}.${getGhostTunnelEntryHost(input2.domain, config)}`;
589
+ }
590
+ function constructGhostTunnelUrl(input2) {
591
+ const protocol = input2.protocol ?? "https";
592
+ const host = constructGhostTunnelHost(input2);
593
+ const url = new URL(`${protocol}://${host}/`);
594
+ if (input2.path) {
595
+ url.pathname = `/${input2.path.replace(/^\/+/, "")}`;
596
+ }
597
+ if (input2.searchParams instanceof URLSearchParams) {
598
+ url.search = input2.searchParams.toString();
599
+ } else if (input2.searchParams) {
600
+ for (const [key, value] of Object.entries(input2.searchParams)) {
601
+ if (typeof value !== "undefined" && value !== null) {
602
+ url.searchParams.set(key, String(value));
603
+ }
604
+ }
605
+ }
606
+ return url.toString();
607
+ }
608
+
310
609
  // src/context.ts
610
+ var LOCALGHOST_PROJECT_CONFIG_FILES = [
611
+ "localghost.config.mjs",
612
+ "localghost.config.js",
613
+ "localghost.config.cjs"
614
+ ];
311
615
  function parsePort(value) {
312
616
  if (!value) return void 0;
313
617
  const port = Number.parseInt(value, 10);
@@ -321,6 +625,30 @@ function envDynamicPort() {
321
625
  if (!value) return void 0;
322
626
  return ["1", "true", "yes", "on"].includes(value.toLowerCase());
323
627
  }
628
+ function envHttps() {
629
+ const value = process.env.LOCALGHOST_HTTPS;
630
+ if (!value) return void 0;
631
+ return ["1", "true", "yes", "on"].includes(value.toLowerCase());
632
+ }
633
+ function getPackageName(cwd) {
634
+ try {
635
+ const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf8"));
636
+ return typeof pkg.name === "string" ? pkg.name : void 0;
637
+ } catch {
638
+ return void 0;
639
+ }
640
+ }
641
+ function getPackageOwner(cwd) {
642
+ const packageName = getPackageName(cwd);
643
+ if (!packageName?.startsWith("@")) return void 0;
644
+ return packageName.slice(1).split("/")[0];
645
+ }
646
+ function getLocalOwner(cwd) {
647
+ return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
648
+ }
649
+ function getRouteName(primaryHost, fallback) {
650
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
651
+ }
324
652
  function readOptionsFromContext(options) {
325
653
  return {
326
654
  cwd: options.cwd ?? process.cwd(),
@@ -338,22 +666,68 @@ function withRuntimePort(entries, requestedPort, port) {
338
666
  function uniqueHosts(entries) {
339
667
  return [...new Set(entries.map((entry) => entry.host))];
340
668
  }
669
+ function isAliasableHost(host) {
670
+ return host.includes(".") && !host.startsWith("www.") && !host.includes(":");
671
+ }
672
+ function getDefaultWwwAlias(host) {
673
+ return isAliasableHost(host) ? `www.${host}` : null;
674
+ }
675
+ function addDefaultWwwAliases(entries) {
676
+ const seen = new Set(entries.map((entry) => entry.host));
677
+ const aliases = [];
678
+ for (const entry of entries) {
679
+ const alias = getDefaultWwwAlias(entry.host);
680
+ if (alias && !seen.has(alias)) {
681
+ aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
682
+ seen.add(alias);
683
+ }
684
+ }
685
+ return [...entries, ...aliases];
686
+ }
687
+ function defined(input2) {
688
+ return Object.fromEntries(Object.entries(input2).filter(([, value]) => typeof value !== "undefined"));
689
+ }
690
+ async function readLocalghostProjectConfig(options = {}) {
691
+ const cwd = options.cwd ?? process.cwd();
692
+ if (options.configFile === false) return { config: {} };
693
+ const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
694
+ const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
695
+ if (!path) return { config: {} };
696
+ const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
697
+ const config = imported.default ?? imported;
698
+ return { config, path };
699
+ }
341
700
  async function resolveLocalghostContext(options = {}) {
342
701
  const cwd = options.cwd ?? process.cwd();
343
- const readOptions = readOptionsFromContext({ ...options, cwd });
702
+ const projectConfig = await readLocalghostProjectConfig({
703
+ cwd,
704
+ ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
705
+ });
706
+ const merged = {
707
+ ...projectConfig.config,
708
+ ...defined(options)
709
+ };
710
+ const readOptions = readOptionsFromContext({ ...merged, cwd });
344
711
  const resolvedPath = resolveDevHostsPath(readOptions);
345
712
  const configEntries = readDevHosts(readOptions);
346
- const requestedPort = options.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
347
- const dynamicPort = options.dynamicPort ?? envDynamicPort() ?? false;
348
- const bindHost = options.bindHost ?? "127.0.0.1";
713
+ const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
714
+ const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
715
+ const bindHost = merged.bindHost ?? "127.0.0.1";
349
716
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
350
717
  const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
351
- const entries = withRuntimePort(configEntries, requestedPort, port);
718
+ const wwwAlias = merged.wwwAlias ?? true;
719
+ const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
352
720
  const hosts = uniqueHosts(entries);
353
- const primaryHost = options.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
721
+ const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
722
+ const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
723
+ const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
724
+ route: getRouteName(primaryHost, projectName),
725
+ project: projectName,
726
+ owner: getLocalOwner(cwd)
727
+ });
354
728
  return {
355
729
  cwd,
356
- projectName: sanitizeProjectName(options.project ?? getProjectName(cwd)),
730
+ projectName,
357
731
  readOptions,
358
732
  configPath: resolvedPath.path,
359
733
  configFileName: resolvedPath.fileName,
@@ -365,7 +739,10 @@ async function resolveLocalghostContext(options = {}) {
365
739
  dynamicPort,
366
740
  bindHost,
367
741
  primaryHost,
368
- https: options.https === true
742
+ https: merged.https ?? envHttps() ?? false,
743
+ wwwAlias,
744
+ ghostTunnel,
745
+ ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
369
746
  };
370
747
  }
371
748
 
@@ -415,7 +792,7 @@ function assertLocalDevelopment(command, env = process.env) {
415
792
  // src/hosts-file.ts
416
793
  import { writeFileSync as writeFileSync3 } from "fs";
417
794
  import { tmpdir } from "os";
418
- import { join as join4 } from "path";
795
+ import { join as join5 } from "path";
419
796
  import { execa as execa3 } from "execa";
420
797
  function escapeRegExp(value) {
421
798
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -457,7 +834,7 @@ function removeManagedBlock(existing, projectName) {
457
834
  }
458
835
  async function writeSystemHostsFile(hostsPath, next, projectName) {
459
836
  const sanitizedProjectName = sanitizeProjectName(projectName);
460
- const tempPath = join4(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
837
+ const tempPath = join5(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
461
838
  writeFileSync3(tempPath, next, "utf8");
462
839
  if (process.platform === "win32") {
463
840
  throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
@@ -490,11 +867,11 @@ async function removeSystemHosts(projectName) {
490
867
  }
491
868
 
492
869
  // src/init.ts
493
- import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
494
- import { join as join5 } from "path";
870
+ import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
871
+ import { join as join6 } from "path";
495
872
  function detectPackageManager(cwd = process.cwd()) {
496
- if (existsSync3(join5(cwd, "pnpm-lock.yaml"))) return "pnpm";
497
- if (existsSync3(join5(cwd, "yarn.lock"))) return "yarn";
873
+ if (existsSync4(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
874
+ if (existsSync4(join6(cwd, "yarn.lock"))) return "yarn";
498
875
  return "npm";
499
876
  }
500
877
  function packageRunCommand(packageManager, script) {
@@ -514,7 +891,7 @@ function renderConfig(options) {
514
891
  }
515
892
  function readPackageJson(path) {
516
893
  try {
517
- return JSON.parse(readFileSync4(path, "utf8"));
894
+ return JSON.parse(readFileSync5(path, "utf8"));
518
895
  } catch {
519
896
  return null;
520
897
  }
@@ -538,6 +915,7 @@ function updatePackageScripts(packageJsonPath, configFile) {
538
915
  "localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
539
916
  "localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
540
917
  "localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
918
+ "localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
541
919
  "localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
542
920
  "localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
543
921
  "localghost:routes": scripts["localghost:routes"] ?? `localghost routes${configFlag}`,
@@ -565,8 +943,8 @@ function initLocalghost(options = {}) {
565
943
  const apiPort = options.apiPort ?? 8787;
566
944
  const packageManager = options.packageManager ?? detectPackageManager(cwd);
567
945
  const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
568
- const configPath = join5(cwd, configFile);
569
- const configExists = existsSync3(configPath);
946
+ const configPath = join6(cwd, configFile);
947
+ const configExists = existsSync4(configPath);
570
948
  if (configExists && !options.force) {
571
949
  return {
572
950
  configPath,
@@ -582,12 +960,12 @@ function initLocalghost(options = {}) {
582
960
  };
583
961
  }
584
962
  writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
585
- const packageJsonPath = join5(cwd, "package.json");
963
+ const packageJsonPath = join6(cwd, "package.json");
586
964
  const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
587
965
  return {
588
966
  configPath,
589
967
  configCreated: true,
590
- ...existsSync3(packageJsonPath) ? { packageJsonPath } : {},
968
+ ...existsSync4(packageJsonPath) ? { packageJsonPath } : {},
591
969
  packageJsonChanged,
592
970
  packageManager,
593
971
  nextSteps: [
@@ -623,6 +1001,20 @@ async function confirm(question, defaultValue = true) {
623
1001
  }
624
1002
 
625
1003
  // src/routes.ts
1004
+ var ansi = {
1005
+ cyan: "\x1B[36m",
1006
+ dim: "\x1B[2m",
1007
+ green: "\x1B[32m",
1008
+ reset: "\x1B[0m",
1009
+ yellow: "\x1B[33m"
1010
+ };
1011
+ function colorize(value, color, enabled) {
1012
+ return enabled ? `${color}${value}${ansi.reset}` : value;
1013
+ }
1014
+ function colorizeUrl(value, enabled) {
1015
+ if (!enabled) return value;
1016
+ return colorize(value.replace(/\*/g, `${ansi.yellow}*${ansi.cyan}`), ansi.cyan, enabled);
1017
+ }
626
1018
  function getDomainRoutes(entries, options = {}) {
627
1019
  const protocol = options.https === true ? "https" : "http";
628
1020
  return [...entries].sort((left, right) => left.host.localeCompare(right.host) || left.port - right.port).map((entry) => ({
@@ -642,32 +1034,64 @@ function formatDomainRoutes(entries, options = {}) {
642
1034
  ...routes.map((route) => ` ${route.url} -> ${route.upstream}`)
643
1035
  ].join("\n");
644
1036
  }
1037
+ function formatGhostTunnel(config, options = {}) {
1038
+ if (!config.enabled) return null;
1039
+ const color = options.color === true;
1040
+ const label = options.label ?? "expected";
1041
+ const labelColor = label === "running" ? ansi.green : ansi.dim;
1042
+ const lines = [
1043
+ "localghost ghost tunnel",
1044
+ ` mode: ${config.mode}`
1045
+ ];
1046
+ const urls = config.displayUrls.length > 0 ? config.displayUrls : config.displayUrl ? [config.displayUrl] : [];
1047
+ if (urls.length === 0) {
1048
+ lines.push(` ${label}: unavailable`);
1049
+ } else if (urls.length === 1) {
1050
+ lines.push(` ${colorize(label, labelColor, color)}: ${colorizeUrl(urls[0], color)}`);
1051
+ } else {
1052
+ lines.push(` ${colorize(label, labelColor, color)}:`);
1053
+ for (const url of urls) {
1054
+ lines.push(` ${colorizeUrl(url, color)}`);
1055
+ }
1056
+ }
1057
+ if (options.verbose) {
1058
+ lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
1059
+ lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
1060
+ lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
1061
+ }
1062
+ return lines.join("\n");
1063
+ }
645
1064
 
646
1065
  // src/state.ts
647
- import { existsSync as existsSync4 } from "fs";
648
- import { join as join6 } from "path";
1066
+ import { existsSync as existsSync5 } from "fs";
1067
+ import { join as join7 } from "path";
649
1068
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
650
1069
  function getLocalghostStatePath(cwd = process.cwd()) {
651
- return join6(cwd, LOCALGHOST_STATE_FILE);
1070
+ return join7(cwd, LOCALGHOST_STATE_FILE);
652
1071
  }
653
1072
  function readLocalghostState(cwd = process.cwd()) {
654
1073
  const path = getLocalghostStatePath(cwd);
655
- if (!existsSync4(path)) return null;
1074
+ if (!existsSync5(path)) return null;
656
1075
  return JSON.parse(readTextFile(path));
657
1076
  }
658
1077
  function writeLocalghostState(cwd, state) {
659
1078
  const path = getLocalghostStatePath(cwd);
660
- writeTextFile(path, `${JSON.stringify({ version: 1, updatedAt: (/* @__PURE__ */ new Date()).toISOString(), ...state }, null, 2)}
1079
+ writeTextFile(path, `${JSON.stringify({ ...state, version: 1, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
661
1080
  `);
662
1081
  return path;
663
1082
  }
1083
+ function patchLocalghostState(cwd, patch) {
1084
+ const current = readLocalghostState(cwd);
1085
+ if (!current) return null;
1086
+ return writeLocalghostState(cwd, { ...current, ...patch });
1087
+ }
664
1088
 
665
1089
  // src/update-check.ts
666
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
1090
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
667
1091
  import { homedir as homedir2 } from "os";
668
- import { dirname as dirname4, join as join7 } from "path";
1092
+ import { dirname as dirname4, join as join8 } from "path";
669
1093
  var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
670
- var LOCALGHOST_VERSION = "0.1.6";
1094
+ var LOCALGHOST_VERSION = "0.1.9";
671
1095
  var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
672
1096
  var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
673
1097
  var UPDATE_CHECK_TIMEOUT_MS = 900;
@@ -679,13 +1103,13 @@ function isUpdateCheckDisabled(env = process.env) {
679
1103
  }
680
1104
  function getUpdateCheckCachePath(env = process.env) {
681
1105
  if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
682
- const cacheRoot = env.XDG_CACHE_HOME || join7(homedir2(), ".cache");
683
- return join7(cacheRoot, "localghost", "update-check.json");
1106
+ const cacheRoot = env.XDG_CACHE_HOME || join8(homedir2(), ".cache");
1107
+ return join8(cacheRoot, "localghost", "update-check.json");
684
1108
  }
685
1109
  function readCache(path = getUpdateCheckCachePath()) {
686
- if (!existsSync5(path)) return null;
1110
+ if (!existsSync6(path)) return null;
687
1111
  try {
688
- return JSON.parse(readFileSync5(path, "utf8"));
1112
+ return JSON.parse(readFileSync6(path, "utf8"));
689
1113
  } catch {
690
1114
  return null;
691
1115
  }
@@ -839,8 +1263,18 @@ function warnAboutLocalMdns(entries) {
839
1263
  );
840
1264
  }
841
1265
  }
1266
+ function shouldColor() {
1267
+ return process.stdout.isTTY && !process.env.NO_COLOR;
1268
+ }
842
1269
  function logDomainRoutes(entries, options = {}) {
843
1270
  console.log(formatDomainRoutes(entries, options));
1271
+ if (options.ghostTunnel?.enabled) {
1272
+ console.log(formatGhostTunnel(options.ghostTunnel, {
1273
+ color: shouldColor(),
1274
+ label: "expected",
1275
+ verbose: options.verbose === true
1276
+ }));
1277
+ }
844
1278
  }
845
1279
  function parsePort2(value) {
846
1280
  const port = Number.parseInt(value, 10);
@@ -864,6 +1298,15 @@ function parseBooleanLike(value) {
864
1298
  if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
865
1299
  throw new InvalidArgumentError("Value must be yes or no.");
866
1300
  }
1301
+ function contextOptionsFromCli(options) {
1302
+ return {
1303
+ cwd: options.cwd,
1304
+ ...options.project ? { project: options.project } : {},
1305
+ ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
1306
+ ...options.configPattern ? { configPattern: options.configPattern } : {},
1307
+ ...useHttps(options) ? { https: true } : {}
1308
+ };
1309
+ }
867
1310
  function readOptionsFromCli(options) {
868
1311
  return {
869
1312
  cwd: options.cwd,
@@ -880,10 +1323,22 @@ async function assertCaddyReady() {
880
1323
  "Localghost will not install it for you. No surprise spells."
881
1324
  ].join("\n"));
882
1325
  }
1326
+ function existingTrustMarkers(cwd) {
1327
+ const state = readLocalghostState(cwd);
1328
+ return {
1329
+ ...state?.caddyTrustedAt ? { caddyTrustedAt: state.caddyTrustedAt } : {},
1330
+ ...state?.caddyTrustPromptedAt ? { caddyTrustPromptedAt: state.caddyTrustPromptedAt } : {}
1331
+ };
1332
+ }
883
1333
  function explainHostsPassword() {
884
1334
  console.log("Localghost may ask for your password to update its managed block in /etc/hosts.");
885
1335
  console.log("It will only touch the lines between # localghost:start and # localghost:end.");
886
1336
  }
1337
+ function explainTrustPassword() {
1338
+ console.log("Localghost can trust Caddy's local HTTPS CA so browsers stop showing local certificate warnings.");
1339
+ console.log("macOS may ask for your password to add that local CA to Keychain.");
1340
+ console.log("This only affects Caddy's local development certificates on this machine.");
1341
+ }
887
1342
  function useHttps(options) {
888
1343
  return options.https === true || options.ssl === true;
889
1344
  }
@@ -895,10 +1350,10 @@ function getSetupCommand(options) {
895
1350
  return `localghost setup${configFlags}${options.https ? " --https" : ""}`;
896
1351
  }
897
1352
  function getSetupReadiness(options) {
898
- const projectName = sanitizeProjectName(options.project ?? getProjectName(options.cwd));
1353
+ const projectName = sanitizeProjectName(options.projectName ?? options.project ?? getProjectName(options.cwd));
899
1354
  const readOptions = readOptionsFromCli(options);
900
- const entries = readDevHosts(readOptions);
901
- const configPath = resolveDevHostsPath(readOptions).path;
1355
+ const entries = options.entries ?? readDevHosts(readOptions);
1356
+ const configPath = options.configPath ?? resolveDevHostsPath(readOptions).path;
902
1357
  const caddyfilePath = getCaddyfilePath(options.cwd);
903
1358
  const statePath = getLocalghostStatePath(options.cwd);
904
1359
  const state = readLocalghostState(options.cwd);
@@ -913,7 +1368,7 @@ function getSetupReadiness(options) {
913
1368
  }
914
1369
  const hostsPath = getSystemHostsPath();
915
1370
  try {
916
- const hosts = readFileSync6(hostsPath, "utf8");
1371
+ const hosts = readFileSync7(hostsPath, "utf8");
917
1372
  const expectedHostsBlock = renderHostsBlock(projectName, entries).trimEnd();
918
1373
  if (!hosts.includes(expectedHostsBlock)) {
919
1374
  reasons.push(`The Localghost hosts block in ${hostsPath} is missing or stale.`);
@@ -923,11 +1378,11 @@ function getSetupReadiness(options) {
923
1378
  reasons.push(`Could not read ${hostsPath}: ${message}`);
924
1379
  }
925
1380
  if (!options.ignoreCaddyfile) {
926
- if (!existsSync6(caddyfilePath)) {
1381
+ if (!existsSync7(caddyfilePath)) {
927
1382
  reasons.push(`Missing Caddyfile at ${caddyfilePath}.`);
928
1383
  } else {
929
1384
  const expectedCaddyfile = renderCaddyfile(entries, { https });
930
- const currentCaddyfile = readFileSync6(caddyfilePath, "utf8");
1385
+ const currentCaddyfile = readFileSync7(caddyfilePath, "utf8");
931
1386
  if (currentCaddyfile !== expectedCaddyfile) {
932
1387
  reasons.push(`Caddyfile at ${caddyfilePath} is stale for ${https ? "HTTPS" : "HTTP"} mode.`);
933
1388
  }
@@ -959,8 +1414,49 @@ async function runSetupFromReadiness(cwd, https, readiness) {
959
1414
  ...hostsResult.tempPath ? { hostsTempPath: hostsResult.tempPath } : {},
960
1415
  caddyfilePath,
961
1416
  caddyHttps: https,
1417
+ ...existingTrustMarkers(cwd),
962
1418
  entries: readiness.entries
963
1419
  });
1420
+ registerLocalghostSetup({
1421
+ cwd,
1422
+ projectName: readiness.projectName,
1423
+ configPath: readiness.configPath,
1424
+ caddyfilePath,
1425
+ https,
1426
+ entries: readiness.entries
1427
+ });
1428
+ }
1429
+ function wait(ms) {
1430
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1431
+ }
1432
+ async function runTrust(cwd, caddyfilePath) {
1433
+ await wait(350);
1434
+ try {
1435
+ await trustCaddy(caddyfilePath);
1436
+ } catch {
1437
+ await wait(750);
1438
+ await trustCaddy(caddyfilePath);
1439
+ }
1440
+ patchLocalghostState(cwd, { caddyTrustedAt: (/* @__PURE__ */ new Date()).toISOString() });
1441
+ console.log("Local HTTPS trust is ready.");
1442
+ }
1443
+ async function maybeTrustCaddy(options) {
1444
+ if (!options.https) return;
1445
+ const state = readLocalghostState(options.cwd);
1446
+ if (!options.trust && state?.caddyTrustedAt) return;
1447
+ let shouldTrust = options.trust === true;
1448
+ if (!shouldTrust) {
1449
+ if (state?.caddyTrustPromptedAt || !canPrompt()) return;
1450
+ explainTrustPassword();
1451
+ shouldTrust = await confirm("Trust local HTTPS certificates now?", true);
1452
+ }
1453
+ if (!shouldTrust) {
1454
+ patchLocalghostState(options.cwd, { caddyTrustPromptedAt: (/* @__PURE__ */ new Date()).toISOString() });
1455
+ console.log("Okay. Localghost will still run HTTPS, but the browser may show a certificate warning.");
1456
+ console.log("Run localghost trust when you want to trust Caddy's local CA.");
1457
+ return;
1458
+ }
1459
+ await runTrust(options.cwd, options.caddyfilePath);
964
1460
  }
965
1461
  function maybePid(pid) {
966
1462
  return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : void 0;
@@ -978,35 +1474,91 @@ function registerCleanup(id) {
978
1474
  process.off("exit", cleanup);
979
1475
  };
980
1476
  }
981
- async function getRunView(run) {
1477
+ async function getRouteViews(entries) {
982
1478
  const portStatus = /* @__PURE__ */ new Map();
983
- for (const entry of run.entries) {
1479
+ for (const entry of entries) {
984
1480
  if (!portStatus.has(entry.port)) {
985
1481
  portStatus.set(entry.port, !await isPortAvailable(entry.port));
986
1482
  }
987
1483
  }
1484
+ return entries.map((entry) => ({
1485
+ host: entry.host,
1486
+ port: entry.port,
1487
+ target: `127.0.0.1:${entry.port}`,
1488
+ listening: portStatus.get(entry.port) ?? false
1489
+ }));
1490
+ }
1491
+ function setupKey(input2) {
1492
+ return `${input2.projectName}:${input2.cwd}:${input2.configPath ?? ""}`;
1493
+ }
1494
+ function runKey(input2) {
1495
+ return `${input2.projectName}:${input2.cwd}:${input2.configPath ?? ""}`;
1496
+ }
1497
+ async function getInstanceViews(setups, runs) {
1498
+ const runBySetup = new Map(runs.map((run) => [runKey(run), run]));
1499
+ const instances = [];
1500
+ for (const setup of setups) {
1501
+ const run = runBySetup.get(setupKey(setup));
1502
+ if (run) {
1503
+ instances.push(await getRunInstanceView(run, setup));
1504
+ runBySetup.delete(setupKey(setup));
1505
+ continue;
1506
+ }
1507
+ instances.push({
1508
+ id: setup.id,
1509
+ cwd: setup.cwd,
1510
+ projectName: setup.projectName,
1511
+ running: false,
1512
+ mode: "setup",
1513
+ updatedAt: setup.updatedAt,
1514
+ ...setup.configPath ? { configPath: setup.configPath } : {},
1515
+ ...setup.caddyfilePath ? { caddyfilePath: setup.caddyfilePath } : {},
1516
+ ...typeof setup.https === "boolean" ? { https: setup.https } : {},
1517
+ routes: await getRouteViews(setup.entries)
1518
+ });
1519
+ }
1520
+ for (const run of runBySetup.values()) {
1521
+ instances.push(await getRunInstanceView(run));
1522
+ }
1523
+ return instances.sort((left, right) => {
1524
+ if (left.running !== right.running) return left.running ? -1 : 1;
1525
+ return left.projectName.localeCompare(right.projectName);
1526
+ });
1527
+ }
1528
+ async function getRunInstanceView(run, setup) {
988
1529
  return {
989
- ...run,
990
- routes: run.entries.map((entry) => ({
991
- host: entry.host,
992
- port: entry.port,
993
- target: `127.0.0.1:${entry.port}`,
994
- listening: portStatus.get(entry.port) ?? false
995
- }))
1530
+ id: setup?.id ?? run.id,
1531
+ cwd: run.cwd,
1532
+ projectName: run.projectName,
1533
+ running: true,
1534
+ mode: run.mode,
1535
+ updatedAt: setup?.updatedAt ?? run.updatedAt,
1536
+ startedAt: run.startedAt,
1537
+ pid: run.pid,
1538
+ ...run.caddyPid ? { caddyPid: run.caddyPid } : {},
1539
+ ...run.childPid ? { childPid: run.childPid } : {},
1540
+ ...run.childCommand ? { childCommand: run.childCommand } : {},
1541
+ ...run.configPath ? { configPath: run.configPath } : {},
1542
+ ...run.caddyfilePath ? { caddyfilePath: run.caddyfilePath } : {},
1543
+ ...typeof run.https === "boolean" ? { https: run.https } : {},
1544
+ routes: await getRouteViews(run.entries)
996
1545
  };
997
1546
  }
998
- function formatRunViews(runs) {
999
- if (runs.length === 0) return "No Localghost apps are running.";
1547
+ function formatInstanceViews(instances) {
1548
+ if (instances.length === 0) return "No Localghost setups found.";
1000
1549
  const lines = ["localghost ps"];
1001
- for (const run of runs) {
1002
- const command = run.childCommand?.length ? ` ${run.childCommand.join(" ")}` : "";
1003
- const mode = command ? `${run.mode}:${command}` : run.mode;
1550
+ for (const instance of instances) {
1551
+ const command = instance.childCommand?.length ? ` ${instance.childCommand.join(" ")}` : "";
1552
+ const mode = command ? `${instance.mode}:${command}` : instance.mode === "setup" ? "" : instance.mode;
1004
1553
  lines.push("");
1005
- lines.push(`${run.projectName} ${mode}`);
1006
- lines.push(` cwd: ${run.cwd}`);
1007
- lines.push(` pid: ${run.pid}${run.caddyPid ? `, caddy: ${run.caddyPid}` : ""}${run.childPid ? `, child: ${run.childPid}` : ""}`);
1008
- lines.push(` started: ${run.startedAt}`);
1009
- for (const route of run.routes) {
1554
+ lines.push(`${instance.projectName} ${instance.running ? "running" : "setup"}${mode ? ` ${mode}` : ""}`);
1555
+ lines.push(` cwd: ${instance.cwd}`);
1556
+ if (instance.pid) {
1557
+ lines.push(` pid: ${instance.pid}${instance.caddyPid ? `, caddy: ${instance.caddyPid}` : ""}${instance.childPid ? `, child: ${instance.childPid}` : ""}`);
1558
+ }
1559
+ if (instance.startedAt) lines.push(` started: ${instance.startedAt}`);
1560
+ if (!instance.startedAt && instance.updatedAt) lines.push(` setup: ${instance.updatedAt}`);
1561
+ for (const route of instance.routes) {
1010
1562
  lines.push(` ${route.host} -> ${route.target} (${route.listening ? "listening" : "not listening"})`);
1011
1563
  }
1012
1564
  }
@@ -1074,13 +1626,13 @@ program.command("update").description("Check npm for a newer localghost release"
1074
1626
  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) => {
1075
1627
  assertLocalDevelopment("setup");
1076
1628
  await assertCaddyReady();
1077
- const https = useHttps(options);
1078
- const projectName = sanitizeProjectName(options.project ?? getProjectName(options.cwd));
1079
- const readOptions = readOptionsFromCli(options);
1080
- const configPath = resolveDevHostsPath(readOptions).path;
1081
- const entries = readDevHosts(readOptions);
1629
+ const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
1630
+ const https = context.https;
1631
+ const projectName = context.projectName;
1632
+ const configPath = context.configPath;
1633
+ const entries = context.entries;
1082
1634
  warnAboutLocalMdns(entries);
1083
- logDomainRoutes(entries, { https });
1635
+ logDomainRoutes(entries, { https, ghostTunnel: context.ghostTunnel });
1084
1636
  explainHostsPassword();
1085
1637
  const hostsResult = await updateSystemHosts(projectName, entries);
1086
1638
  if (hostsResult.changed) {
@@ -1100,6 +1652,15 @@ program.command("setup").description("Update /etc/hosts and generate/validate Ca
1100
1652
  ...hostsResult.tempPath ? { hostsTempPath: hostsResult.tempPath } : {},
1101
1653
  caddyfilePath: caddyfile,
1102
1654
  caddyHttps: https,
1655
+ ...existingTrustMarkers(options.cwd),
1656
+ entries
1657
+ });
1658
+ registerLocalghostSetup({
1659
+ cwd: options.cwd,
1660
+ projectName,
1661
+ configPath,
1662
+ caddyfilePath: caddyfile,
1663
+ https,
1103
1664
  entries
1104
1665
  });
1105
1666
  console.log(`Generated ${caddyfile}`);
@@ -1107,6 +1668,20 @@ program.command("setup").description("Update /etc/hosts and generate/validate Ca
1107
1668
  console.log(`State ${statePath}`);
1108
1669
  console.log("Setup complete.");
1109
1670
  });
1671
+ program.command("trust").description("Trust Caddy's local HTTPS CA for this project's HTTPS proxy").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", "Use HTTPS mode for the Caddyfile").option("--ssl", "Alias for --https").action(async (options) => {
1672
+ assertLocalDevelopment("trust");
1673
+ await assertCaddyReady();
1674
+ const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
1675
+ if (!context.https) {
1676
+ throw new Error("Localghost HTTPS is not enabled for this context. Set https: true in localghost.config.mjs or pass --https.");
1677
+ }
1678
+ warnAboutLocalMdns(context.entries);
1679
+ logDomainRoutes(context.entries, { https: true, ghostTunnel: context.ghostTunnel });
1680
+ explainTrustPassword();
1681
+ const caddyfile = await writeCaddyfile(context.entries, options.cwd, { https: true });
1682
+ await validateCaddyfile(caddyfile);
1683
+ await runTrust(options.cwd, caddyfile);
1684
+ });
1110
1685
  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) => {
1111
1686
  assertLocalDevelopment("reset");
1112
1687
  const projectName = sanitizeProjectName(options.project ?? getProjectName(options.cwd));
@@ -1114,13 +1689,13 @@ program.command("reset").description("Remove Localghost setup state without dele
1114
1689
  const statePath = getLocalghostStatePath(options.cwd);
1115
1690
  explainHostsPassword();
1116
1691
  const hostsResult = await removeSystemHosts(projectName);
1117
- if (existsSync6(caddyfilePath)) {
1692
+ if (existsSync7(caddyfilePath)) {
1118
1693
  unlinkSync(caddyfilePath);
1119
1694
  console.log(`Removed ${caddyfilePath}`);
1120
1695
  } else {
1121
1696
  console.log(`${caddyfilePath} was not present`);
1122
1697
  }
1123
- if (existsSync6(statePath)) {
1698
+ if (existsSync7(statePath)) {
1124
1699
  unlinkSync(statePath);
1125
1700
  console.log(`Removed ${statePath}`);
1126
1701
  } else {
@@ -1131,6 +1706,7 @@ program.command("reset").description("Remove Localghost setup state without dele
1131
1706
  } else {
1132
1707
  console.log(`No Localghost hosts block found in ${hostsResult.hostsPath}`);
1133
1708
  }
1709
+ unregisterLocalghostSetup({ cwd: options.cwd, projectName });
1134
1710
  console.log(".localghost was left in place. Run localghost setup when you are ready.");
1135
1711
  });
1136
1712
  program.command("teardown").description("Remove Localghost's managed /etc/hosts block").option("--project <name>", "Managed /etc/hosts block name").option("--cwd <path>", "Project directory", process.cwd()).option("--remove-caddyfile", "Also remove ops/local/Caddyfile").action(async (options) => {
@@ -1140,7 +1716,7 @@ program.command("teardown").description("Remove Localghost's managed /etc/hosts
1140
1716
  const hostsResult = await removeSystemHosts(projectName);
1141
1717
  const caddyfilePath = getCaddyfilePath(options.cwd);
1142
1718
  let caddyfileRemoved = false;
1143
- if (options.removeCaddyfile && existsSync6(caddyfilePath)) {
1719
+ if (options.removeCaddyfile && existsSync7(caddyfilePath)) {
1144
1720
  unlinkSync(caddyfilePath);
1145
1721
  caddyfileRemoved = true;
1146
1722
  }
@@ -1162,12 +1738,20 @@ program.command("teardown").description("Remove Localghost's managed /etc/hosts
1162
1738
  if (options.removeCaddyfile) {
1163
1739
  console.log(caddyfileRemoved ? `Removed ${caddyfilePath}` : `${caddyfilePath} was not present`);
1164
1740
  }
1741
+ unregisterLocalghostSetup({ cwd: options.cwd, projectName });
1165
1742
  console.log(`State ${statePath}`);
1166
1743
  });
1167
- program.command("status").description("Print Localghost's project-local state file").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("--ready", "Exit non-zero when setup is missing or stale").option("--https", "Check setup readiness for HTTPS mode").option("--ssl", "Alias for --https").option("--json", "Print raw JSON").action((options) => {
1744
+ program.command("status").description("Print Localghost's project-local state file").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("--ready", "Exit non-zero when setup is missing or stale").option("--https", "Check setup readiness for HTTPS mode").option("--ssl", "Alias for --https").option("--json", "Print raw JSON").action(async (options) => {
1168
1745
  const state = readLocalghostState(options.cwd);
1169
1746
  const statePath = getLocalghostStatePath(options.cwd);
1170
- const readiness = getSetupReadiness({ ...options, https: useHttps(options) });
1747
+ const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
1748
+ const readiness = getSetupReadiness({
1749
+ ...options,
1750
+ https: context.https,
1751
+ entries: context.entries,
1752
+ configPath: context.configPath,
1753
+ projectName: context.projectName
1754
+ });
1171
1755
  if (options.json) {
1172
1756
  console.log(JSON.stringify({ state, setup: readiness }, null, 2));
1173
1757
  return;
@@ -1183,6 +1767,8 @@ program.command("status").description("Print Localghost's project-local state fi
1183
1767
  if (state.hostsPath) console.log(`Hosts: ${state.hostsPath}`);
1184
1768
  if (state.caddyfilePath) console.log(`Caddyfile: ${state.caddyfilePath}`);
1185
1769
  if (typeof state.caddyHttps === "boolean") console.log(`Mode: ${state.caddyHttps ? "HTTPS" : "HTTP"}`);
1770
+ if (state.caddyTrustedAt) console.log(`HTTPS trust: yes (${state.caddyTrustedAt})`);
1771
+ if (!state.caddyTrustedAt && state.caddyTrustPromptedAt) console.log(`HTTPS trust: not enabled (asked ${state.caddyTrustPromptedAt})`);
1186
1772
  if (typeof state.caddyfileRemoved === "boolean") console.log(`Caddyfile removed: ${state.caddyfileRemoved}`);
1187
1773
  }
1188
1774
  if (readiness.ready) {
@@ -1198,16 +1784,30 @@ program.command("status").description("Print Localghost's project-local state fi
1198
1784
  process.exitCode = 1;
1199
1785
  }
1200
1786
  });
1201
- program.command("routes").description("Print domain to upstream routes").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("--http", "Print domain URLs with http instead of https").option("--https", "Print domain URLs with https").option("--ssl", "Alias for --https").action((options) => {
1202
- const entries = readDevHosts(readOptionsFromCli(options));
1203
- warnAboutLocalMdns(entries);
1204
- console.log(formatDomainRoutes(entries, { https: options.http ? false : useHttps(options) }));
1787
+ program.command("routes").description("Print domain to upstream routes").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("--http", "Print domain URLs with http instead of https").option("--https", "Print domain URLs with https").option("--ssl", "Alias for --https").option("--verbose", "Print Ghost Tunnel mode, domains, and guardrails").action(async (options) => {
1788
+ const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
1789
+ warnAboutLocalMdns(context.entries);
1790
+ console.log(formatDomainRoutes(context.entries, { https: options.http ? false : context.https }));
1791
+ if (context.ghostTunnel.enabled) {
1792
+ console.log(formatGhostTunnel(context.ghostTunnel, {
1793
+ color: shouldColor(),
1794
+ label: "expected",
1795
+ verbose: options.verbose === true
1796
+ }));
1797
+ }
1205
1798
  });
1206
- 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").action(async (options) => {
1799
+ 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) => {
1207
1800
  assertLocalDevelopment("dev");
1208
1801
  await assertCaddyReady();
1209
- const https = useHttps(options);
1210
- const readiness = getSetupReadiness({ ...options, https });
1802
+ const context = await resolveLocalghostContext({ ...contextOptionsFromCli(options), dynamicPort: false });
1803
+ const https = context.https;
1804
+ const readiness = getSetupReadiness({
1805
+ ...options,
1806
+ https,
1807
+ entries: context.entries,
1808
+ configPath: context.configPath,
1809
+ projectName: context.projectName
1810
+ });
1211
1811
  if (!readiness.ready) {
1212
1812
  if (!options.setup) {
1213
1813
  throw new Error(
@@ -1233,14 +1833,34 @@ program.command("dev").description("Run the Localghost Caddy proxy after setup")
1233
1833
  ...hostsResult.tempPath ? { hostsTempPath: hostsResult.tempPath } : {},
1234
1834
  caddyfilePath,
1235
1835
  caddyHttps: https,
1836
+ ...existingTrustMarkers(options.cwd),
1837
+ entries: readiness.entries
1838
+ });
1839
+ registerLocalghostSetup({
1840
+ cwd: options.cwd,
1841
+ projectName: readiness.projectName,
1842
+ configPath: readiness.configPath,
1843
+ caddyfilePath,
1844
+ https,
1236
1845
  entries: readiness.entries
1237
1846
  });
1238
1847
  }
1239
1848
  warnAboutLocalMdns(readiness.entries);
1240
- logDomainRoutes(readiness.entries, { https });
1849
+ logDomainRoutes(readiness.entries, { https, ghostTunnel: context.ghostTunnel });
1241
1850
  const caddyfile = await writeCaddyfile(readiness.entries, options.cwd, { https });
1242
1851
  await validateCaddyfile(caddyfile);
1243
1852
  const caddy = startCaddy(caddyfile);
1853
+ try {
1854
+ await maybeTrustCaddy({
1855
+ cwd: options.cwd,
1856
+ https,
1857
+ caddyfilePath: caddyfile,
1858
+ ...typeof options.trust === "boolean" ? { trust: options.trust } : {}
1859
+ });
1860
+ } catch (error) {
1861
+ if (!caddy.killed) caddy.kill("SIGINT");
1862
+ throw error;
1863
+ }
1244
1864
  const caddyPid = maybePid(caddy.pid);
1245
1865
  const runRecord = registerLocalghostRun({
1246
1866
  mode: "dev",
@@ -1259,20 +1879,27 @@ program.command("dev").description("Run the Localghost Caddy proxy after setup")
1259
1879
  cleanupRun();
1260
1880
  }
1261
1881
  });
1262
- 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("--dynamic-port [yes|no]", "Use the requested port if free, otherwise continue upward", parseBooleanLike, false).argument("<command...>", "Command to run after --, for example: localghost run -- vite").action(async (command, options) => {
1882
+ 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) => {
1263
1883
  assertLocalDevelopment("run");
1264
1884
  await assertCaddyReady();
1265
- const https = useHttps(options);
1266
1885
  const context = await resolveLocalghostContext({
1267
1886
  cwd: options.cwd,
1268
1887
  ...options.project ? { project: options.project } : {},
1269
1888
  ...options.config && options.config.length > 0 ? { configFiles: options.config } : {},
1270
1889
  ...options.configPattern ? { configPattern: options.configPattern } : {},
1271
1890
  ...options.port ? { port: options.port } : {},
1272
- https,
1891
+ ...useHttps(options) ? { https: true } : {},
1273
1892
  ...typeof options.dynamicPort === "boolean" ? { dynamicPort: options.dynamicPort } : {}
1274
1893
  });
1275
- const readiness = getSetupReadiness({ ...options, https, ignoreCaddyfile: true });
1894
+ const https = context.https;
1895
+ const readiness = getSetupReadiness({
1896
+ ...options,
1897
+ https,
1898
+ ignoreCaddyfile: true,
1899
+ entries: context.entries,
1900
+ configPath: context.configPath,
1901
+ projectName: context.projectName
1902
+ });
1276
1903
  if (!readiness.ready) {
1277
1904
  const shouldSetup = options.setup === true || canPrompt() && await confirm("Run caddy:setup now?", true);
1278
1905
  if (!shouldSetup) {
@@ -1291,13 +1918,24 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1291
1918
  console.log(`Port ${context.requestedPort} is busy; using ${context.port}.`);
1292
1919
  }
1293
1920
  warnAboutLocalMdns(context.entries);
1294
- logDomainRoutes(context.entries, { https });
1921
+ logDomainRoutes(context.entries, { https, ghostTunnel: context.ghostTunnel });
1295
1922
  const caddyfile = await writeCaddyfile(context.entries, options.cwd, { https });
1296
1923
  await validateCaddyfile(caddyfile);
1297
1924
  const caddy = startCaddy(caddyfile);
1298
1925
  const caddyExit = caddy.catch((error) => {
1299
1926
  if (!caddy.killed) throw error;
1300
1927
  });
1928
+ try {
1929
+ await maybeTrustCaddy({
1930
+ cwd: options.cwd,
1931
+ https,
1932
+ caddyfilePath: caddyfile,
1933
+ ...typeof options.trust === "boolean" ? { trust: options.trust } : {}
1934
+ });
1935
+ } catch (error) {
1936
+ if (!caddy.killed) caddy.kill("SIGINT");
1937
+ throw error;
1938
+ }
1301
1939
  const [binary, ...args] = command;
1302
1940
  if (!binary) {
1303
1941
  throw new Error("Missing command. Use: localghost run -- vite");
@@ -1345,13 +1983,15 @@ program.command("run").description("Run Caddy and a dev command from the same Lo
1345
1983
  cleanupRun();
1346
1984
  }
1347
1985
  });
1348
- program.command("ps").description("Show Localghost dev sessions that are currently running").option("--json", "Print raw JSON").action(async (options) => {
1349
- const runs = await Promise.all(listLocalghostRuns().map((run) => getRunView(run)));
1986
+ program.command("ps").description("Show Localghost setups and currently running sessions").option("--json", "Print raw JSON").action(async (options) => {
1987
+ const setups = listLocalghostSetups();
1988
+ const runs = listLocalghostRuns();
1989
+ const instances = await getInstanceViews(setups, runs);
1350
1990
  if (options.json) {
1351
- console.log(JSON.stringify({ activityPath: getLocalghostActivityPath(), runs }, null, 2));
1991
+ console.log(JSON.stringify({ activityPath: getLocalghostActivityPath(), setups, runs, instances }, null, 2));
1352
1992
  return;
1353
1993
  }
1354
- console.log(formatRunViews(runs));
1994
+ console.log(formatInstanceViews(instances));
1355
1995
  });
1356
1996
  program.command("print").description("Print parsed host config").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").action((options) => {
1357
1997
  const entries = readDevHosts(readOptionsFromCli(options));