@hamedb89/localghost 0.1.8 → 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/vite.js CHANGED
@@ -1,10 +1,125 @@
1
1
  // src/vite.ts
2
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
2
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
3
3
  import { normalize, resolve as resolve2 } from "path";
4
+ import { spawn } from "child_process";
5
+ import { emitKeypressEvents } from "readline";
6
+
7
+ // src/activity.ts
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
+ import { homedir } from "os";
10
+ import { dirname, join } from "path";
11
+ var LOCALGHOST_ACTIVITY_VERSION = 1;
12
+ function getLocalghostActivityPath(env = process.env) {
13
+ if (env.LOCALGHOST_ACTIVITY_PATH) return env.LOCALGHOST_ACTIVITY_PATH;
14
+ const stateRoot = env.XDG_STATE_HOME || join(homedir(), ".local/state");
15
+ return join(stateRoot, "localghost", "activity.json");
16
+ }
17
+ function isProcessRunning(pid) {
18
+ if (!Number.isInteger(pid) || pid < 1) return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch (error) {
23
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
24
+ return code === "EPERM";
25
+ }
26
+ }
27
+ function emptyActivity() {
28
+ return { version: LOCALGHOST_ACTIVITY_VERSION, runs: [], setups: [] };
29
+ }
30
+ function readLocalghostActivity(path = getLocalghostActivityPath()) {
31
+ if (!existsSync(path)) return emptyActivity();
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
34
+ return {
35
+ version: LOCALGHOST_ACTIVITY_VERSION,
36
+ runs: Array.isArray(parsed.runs) ? parsed.runs : [],
37
+ setups: Array.isArray(parsed.setups) ? parsed.setups : []
38
+ };
39
+ } catch {
40
+ return emptyActivity();
41
+ }
42
+ }
43
+ function writeLocalghostActivity(activity, path = getLocalghostActivityPath()) {
44
+ mkdirSync(dirname(path), { recursive: true });
45
+ writeFileSync(path, `${JSON.stringify(activity, null, 2)}
46
+ `, "utf8");
47
+ return path;
48
+ }
49
+ function createRunId(input2, pid) {
50
+ return `${input2.projectName}:${input2.mode}:${pid}:${Date.now()}`;
51
+ }
52
+ function createSetupId(input2) {
53
+ return `${input2.projectName}:${input2.cwd}:${input2.configPath ?? ""}`;
54
+ }
55
+ function pruneLocalghostActivity(path = getLocalghostActivityPath()) {
56
+ const activity = readLocalghostActivity(path);
57
+ const activeRuns = activity.runs.filter((run) => isProcessRunning(run.pid));
58
+ const pruned = activeRuns.length !== activity.runs.length;
59
+ if (pruned) {
60
+ writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, runs: activeRuns }, path);
61
+ }
62
+ return {
63
+ path,
64
+ pruned,
65
+ runs: activeRuns,
66
+ setups: activity.setups
67
+ };
68
+ }
69
+ function registerLocalghostRun(input2, path = getLocalghostActivityPath()) {
70
+ const now = (/* @__PURE__ */ new Date()).toISOString();
71
+ const pid = input2.pid ?? process.pid;
72
+ const record = {
73
+ id: input2.id ?? createRunId(input2, pid),
74
+ mode: input2.mode,
75
+ pid,
76
+ cwd: input2.cwd,
77
+ projectName: input2.projectName,
78
+ startedAt: input2.startedAt ?? now,
79
+ updatedAt: now,
80
+ ...input2.configPath ? { configPath: input2.configPath } : {},
81
+ ...input2.caddyfilePath ? { caddyfilePath: input2.caddyfilePath } : {},
82
+ ...input2.caddyPid ? { caddyPid: input2.caddyPid } : {},
83
+ ...input2.childPid ? { childPid: input2.childPid } : {},
84
+ ...input2.childCommand ? { childCommand: input2.childCommand } : {},
85
+ ...typeof input2.https === "boolean" ? { https: input2.https } : {},
86
+ ...input2.requestedPort ? { requestedPort: input2.requestedPort } : {},
87
+ ...input2.port ? { port: input2.port } : {},
88
+ ...typeof input2.dynamicPort === "boolean" ? { dynamicPort: input2.dynamicPort } : {},
89
+ entries: input2.entries
90
+ };
91
+ const current = pruneLocalghostActivity(path).runs.filter((run) => run.id !== record.id);
92
+ const activity = readLocalghostActivity(path);
93
+ writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: [...current, record], setups: activity.setups }, path);
94
+ return record;
95
+ }
96
+ function registerLocalghostSetup(input2, path = getLocalghostActivityPath()) {
97
+ const record = {
98
+ id: input2.id ?? createSetupId(input2),
99
+ cwd: input2.cwd,
100
+ projectName: input2.projectName,
101
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
102
+ ...input2.configPath ? { configPath: input2.configPath } : {},
103
+ ...input2.caddyfilePath ? { caddyfilePath: input2.caddyfilePath } : {},
104
+ ...typeof input2.https === "boolean" ? { https: input2.https } : {},
105
+ entries: input2.entries
106
+ };
107
+ const activity = pruneLocalghostActivity(path);
108
+ const setups = activity.setups.filter((setup) => setup.id !== record.id);
109
+ writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: activity.runs, setups: [...setups, record] }, path);
110
+ return record;
111
+ }
112
+ function unregisterLocalghostRun(id, path = getLocalghostActivityPath()) {
113
+ const activity = readLocalghostActivity(path);
114
+ const runs = activity.runs.filter((run) => run.id !== id);
115
+ if (runs.length !== activity.runs.length) {
116
+ writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, runs }, path);
117
+ }
118
+ }
4
119
 
5
120
  // src/config.ts
6
- import { existsSync, readFileSync, readdirSync } from "fs";
7
- import { basename, join, resolve } from "path";
121
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
122
+ import { basename, join as join2, resolve } from "path";
8
123
 
9
124
  // src/parse.ts
10
125
  var HOST_PATTERN = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*\.?$/i;
@@ -69,7 +184,7 @@ function resolveDevHostsPath(options = {}) {
69
184
  const searchedFiles = getConfigFileCandidates(options);
70
185
  for (const fileName2 of searchedFiles) {
71
186
  const path = resolve(cwd, fileName2);
72
- if (existsSync(path)) {
187
+ if (existsSync2(path)) {
73
188
  return {
74
189
  path,
75
190
  fileName: basename(fileName2),
@@ -102,11 +217,11 @@ function readDevHosts(options = {}) {
102
217
  `Missing Localghost config in ${cwd}. Looked for ${formatSearchedFiles(resolvedPath.searchedFiles, resolvedPath.configPattern)}. Run \`localghost init\` or pass --config/--config-pattern.`
103
218
  );
104
219
  }
105
- return parseDevHosts(readFileSync(resolvedPath.path, "utf8"), resolvedPath.fileName);
220
+ return parseDevHosts(readFileSync2(resolvedPath.path, "utf8"), resolvedPath.fileName);
106
221
  }
107
222
  function getProjectName(cwd = process.cwd()) {
108
223
  try {
109
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
224
+ const pkg = JSON.parse(readFileSync2(join2(cwd, "package.json"), "utf8"));
110
225
  const name = typeof pkg.name === "string" && pkg.name ? pkg.name : "app";
111
226
  return sanitizeProjectName(name.replace(/^@/, ""));
112
227
  } catch {
@@ -119,7 +234,8 @@ function sanitizeProjectName(value) {
119
234
  }
120
235
 
121
236
  // src/context.ts
122
- import { existsSync as existsSync2 } from "fs";
237
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
238
+ import { join as join3 } from "path";
123
239
  import { pathToFileURL } from "url";
124
240
 
125
241
  // src/port.ts
@@ -148,6 +264,257 @@ async function findAvailablePort(startPort, options = {}) {
148
264
  throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
149
265
  }
150
266
 
267
+ // src/tunnel.ts
268
+ import { domainToASCII } from "url";
269
+ var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
270
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
271
+ var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
272
+ var DEFAULT_GHOST_TUNNEL_MODE = "manual";
273
+ function isResolvedGhostTunnelConfig(value) {
274
+ return typeof value === "object" && value !== null && "enabled" in value;
275
+ }
276
+ function toGhostTunnelConfig(options) {
277
+ return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
278
+ }
279
+ function stripHostPort(value) {
280
+ const trimmed = value.trim().toLowerCase();
281
+ if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
282
+ const portSeparator = trimmed.lastIndexOf(":");
283
+ if (portSeparator === -1) return trimmed;
284
+ const port = trimmed.slice(portSeparator + 1);
285
+ return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
286
+ }
287
+ function normalizeDomain(value) {
288
+ const host = stripHostPort(value.replace(/^\*\./, ""));
289
+ const ascii = domainToASCII(host);
290
+ if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
291
+ if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
292
+ if (ascii.includes("*")) return null;
293
+ if (!ascii.split(".").every(isValidHostLabel)) return null;
294
+ return ascii;
295
+ }
296
+ function isValidHostLabel(value) {
297
+ return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
298
+ }
299
+ function isValidNamespaceTag(value) {
300
+ return /^[a-z][a-z0-9_]*$/i.test(value);
301
+ }
302
+ function isNamespaceTagList(options) {
303
+ return Array.isArray(options);
304
+ }
305
+ function assertValidSubdomain(value) {
306
+ if (!isValidHostLabel(value)) {
307
+ throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
308
+ }
309
+ }
310
+ function normalizeDomains(domains) {
311
+ const values = typeof domains === "string" ? [domains] : [...domains ?? []];
312
+ const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
313
+ const domain = normalizeDomain(value);
314
+ if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
315
+ return domain;
316
+ });
317
+ return [...new Set(normalized)];
318
+ }
319
+ function parseGhostTunnelMode(value) {
320
+ return value ?? DEFAULT_GHOST_TUNNEL_MODE;
321
+ }
322
+ function resolveNamespaceConfig(options) {
323
+ const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
324
+ let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
325
+ let spreadTag = tags.includes("project") ? "project" : void 0;
326
+ if (options && !isNamespaceTagList(options)) {
327
+ separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
328
+ spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
329
+ }
330
+ if (tags.length === 0) {
331
+ throw new Error("Ghost tunnel namespace must include at least one tag.");
332
+ }
333
+ for (const tag of tags) {
334
+ if (!isValidNamespaceTag(tag)) {
335
+ throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
336
+ }
337
+ }
338
+ if (spreadTag && !tags.includes(spreadTag)) {
339
+ throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
340
+ }
341
+ if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
342
+ throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
343
+ }
344
+ return {
345
+ tags,
346
+ separator,
347
+ ...spreadTag ? { spreadTag } : {}
348
+ };
349
+ }
350
+ function normalizeNamespaceValue(tag, value, separator, options = {}) {
351
+ const normalized = normalizeDomain(value);
352
+ if (!normalized || normalized.includes(".")) {
353
+ throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
354
+ }
355
+ if (!options.allowSeparator && normalized.includes(separator)) {
356
+ throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
357
+ }
358
+ return normalized;
359
+ }
360
+ function createNamespaceSlug(config, values) {
361
+ const parts = config.tags.map((tag) => {
362
+ const value = values[tag];
363
+ if (!value) {
364
+ throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
365
+ }
366
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
367
+ });
368
+ const slug = parts.join(config.separator);
369
+ if (!isValidHostLabel(slug)) {
370
+ throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
371
+ }
372
+ return slug;
373
+ }
374
+ function createNamespaceDisplaySlug(config, values = {}) {
375
+ return config.tags.map((tag) => {
376
+ const value = values[tag];
377
+ if (!value) return `<${tag}>`;
378
+ try {
379
+ return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
380
+ } catch {
381
+ return `<${tag}>`;
382
+ }
383
+ }).join(config.separator);
384
+ }
385
+ function getPreviewDefaults(preview, defaults) {
386
+ return {
387
+ domain: preview?.domain ?? defaults?.domain,
388
+ route: preview?.route ?? defaults?.route,
389
+ project: preview?.project ?? defaults?.project,
390
+ owner: preview?.owner ?? defaults?.owner,
391
+ values: {
392
+ ...defaults?.values ?? {},
393
+ ...preview?.values ?? {}
394
+ },
395
+ path: preview?.path,
396
+ protocol: preview?.protocol
397
+ };
398
+ }
399
+ function getDisplayValues(input2) {
400
+ return {
401
+ ...input2.route ? { route: input2.route } : {},
402
+ ...input2.project ? { project: input2.project } : {},
403
+ ...input2.owner ? { owner: input2.owner } : {},
404
+ ...input2.values
405
+ };
406
+ }
407
+ function createDisplayUrl(config, defaults, domain) {
408
+ const input2 = getPreviewDefaults(config.preview, defaults);
409
+ const protocol = input2.protocol ?? "https";
410
+ const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input2));
411
+ const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input2.domain ? getGhostTunnelEntryHost(input2.domain, config) : `${config.subdomain}.*`;
412
+ const url = `${protocol}://${slug}.${entryHost}/`;
413
+ if (!input2.path) return url;
414
+ return `${url}${input2.path.replace(/^\/+/, "")}`;
415
+ }
416
+ function createDisplayUrls(config, defaults) {
417
+ const domains = config.domains.length > 0 ? config.domains : defaults?.domain ? [defaults.domain] : [];
418
+ const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, defaults, domain)) : [createDisplayUrl(config, defaults)];
419
+ return [...new Set(urls)];
420
+ }
421
+ function maybeConstructPreviewUrl(config, defaults) {
422
+ if (!config.preview) return void 0;
423
+ const input2 = getPreviewDefaults(config.preview, defaults);
424
+ if (!input2.domain || !input2.route || !input2.project || !input2.owner) return void 0;
425
+ return constructGhostTunnelUrl({
426
+ domain: input2.domain,
427
+ route: input2.route,
428
+ project: input2.project,
429
+ owner: input2.owner,
430
+ values: input2.values,
431
+ ...input2.path ? { path: input2.path } : {},
432
+ ...input2.protocol ? { protocol: input2.protocol } : {},
433
+ ghostTunnel: config
434
+ });
435
+ }
436
+ function resolveGhostTunnelConfig(options, defaults) {
437
+ if (options === false || typeof options === "undefined") {
438
+ return {
439
+ enabled: false,
440
+ mode: DEFAULT_GHOST_TUNNEL_MODE,
441
+ domains: [],
442
+ subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
443
+ namespace: resolveNamespaceConfig(void 0),
444
+ displayUrls: [],
445
+ requireHttps: true,
446
+ requireAuth: true
447
+ };
448
+ }
449
+ const config = typeof options === "string" ? { mode: options } : options;
450
+ const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
451
+ assertValidSubdomain(subdomain);
452
+ const domains = normalizeDomains(config.domains);
453
+ const enabled = config.enabled ?? true;
454
+ const resolved = {
455
+ enabled,
456
+ mode: parseGhostTunnelMode(config.mode),
457
+ domains,
458
+ subdomain,
459
+ namespace: resolveNamespaceConfig(config.namespace),
460
+ ...config.preview ? { preview: config.preview } : {},
461
+ displayUrls: [],
462
+ requireHttps: config.requireHttps ?? true,
463
+ requireAuth: config.requireAuth ?? true
464
+ };
465
+ if (!enabled) {
466
+ return resolved;
467
+ }
468
+ const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
469
+ const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
470
+ return {
471
+ ...resolved,
472
+ displayUrls,
473
+ ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
474
+ ...previewUrl ? { previewUrl } : {}
475
+ };
476
+ }
477
+ function getGhostTunnelEntryHost(domain, options = {}) {
478
+ const config = toGhostTunnelConfig(options);
479
+ const normalizedDomain = normalizeDomain(domain);
480
+ if (!normalizedDomain) {
481
+ throw new Error(`Invalid ghost tunnel domain: ${domain}`);
482
+ }
483
+ return `${config.subdomain}.${normalizedDomain}`;
484
+ }
485
+ function constructGhostTunnelHost(input2) {
486
+ const config = toGhostTunnelConfig(input2.ghostTunnel ?? {});
487
+ if (!config.enabled) {
488
+ throw new Error("Ghost tunnel is not enabled.");
489
+ }
490
+ const namespaceValues = {
491
+ route: input2.route,
492
+ project: input2.project,
493
+ owner: input2.owner,
494
+ ...input2.values ?? {}
495
+ };
496
+ const slug = createNamespaceSlug(config.namespace, namespaceValues);
497
+ return `${slug}.${getGhostTunnelEntryHost(input2.domain, config)}`;
498
+ }
499
+ function constructGhostTunnelUrl(input2) {
500
+ const protocol = input2.protocol ?? "https";
501
+ const host = constructGhostTunnelHost(input2);
502
+ const url = new URL(`${protocol}://${host}/`);
503
+ if (input2.path) {
504
+ url.pathname = `/${input2.path.replace(/^\/+/, "")}`;
505
+ }
506
+ if (input2.searchParams instanceof URLSearchParams) {
507
+ url.search = input2.searchParams.toString();
508
+ } else if (input2.searchParams) {
509
+ for (const [key, value] of Object.entries(input2.searchParams)) {
510
+ if (typeof value !== "undefined" && value !== null) {
511
+ url.searchParams.set(key, String(value));
512
+ }
513
+ }
514
+ }
515
+ return url.toString();
516
+ }
517
+
151
518
  // src/context.ts
152
519
  var LOCALGHOST_PROJECT_CONFIG_FILES = [
153
520
  "localghost.config.mjs",
@@ -172,6 +539,25 @@ function envHttps() {
172
539
  if (!value) return void 0;
173
540
  return ["1", "true", "yes", "on"].includes(value.toLowerCase());
174
541
  }
542
+ function getPackageName(cwd) {
543
+ try {
544
+ const pkg = JSON.parse(readFileSync3(join3(cwd, "package.json"), "utf8"));
545
+ return typeof pkg.name === "string" ? pkg.name : void 0;
546
+ } catch {
547
+ return void 0;
548
+ }
549
+ }
550
+ function getPackageOwner(cwd) {
551
+ const packageName = getPackageName(cwd);
552
+ if (!packageName?.startsWith("@")) return void 0;
553
+ return packageName.slice(1).split("/")[0];
554
+ }
555
+ function getLocalOwner(cwd) {
556
+ return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
557
+ }
558
+ function getRouteName(primaryHost, fallback) {
559
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
560
+ }
175
561
  function readOptionsFromContext(options) {
176
562
  return {
177
563
  cwd: options.cwd ?? process.cwd(),
@@ -210,18 +596,22 @@ function addDefaultWwwAliases(entries) {
210
596
  function defined(input2) {
211
597
  return Object.fromEntries(Object.entries(input2).filter(([, value]) => typeof value !== "undefined"));
212
598
  }
213
- async function readProjectConfig(cwd, configFile) {
214
- if (configFile === false) return {};
215
- const candidates = configFile ? [configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
216
- const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync2(candidate));
217
- if (!path) return {};
599
+ async function readLocalghostProjectConfig(options = {}) {
600
+ const cwd = options.cwd ?? process.cwd();
601
+ if (options.configFile === false) return { config: {} };
602
+ const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
603
+ const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
604
+ if (!path) return { config: {} };
218
605
  const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
219
606
  const config = imported.default ?? imported;
220
607
  return { config, path };
221
608
  }
222
609
  async function resolveLocalghostContext(options = {}) {
223
610
  const cwd = options.cwd ?? process.cwd();
224
- const projectConfig = await readProjectConfig(cwd, options.localghostConfig);
611
+ const projectConfig = await readLocalghostProjectConfig({
612
+ cwd,
613
+ ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
614
+ });
225
615
  const merged = {
226
616
  ...projectConfig.config,
227
617
  ...defined(options)
@@ -230,7 +620,7 @@ async function resolveLocalghostContext(options = {}) {
230
620
  const resolvedPath = resolveDevHostsPath(readOptions);
231
621
  const configEntries = readDevHosts(readOptions);
232
622
  const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
233
- const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? false;
623
+ const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
234
624
  const bindHost = merged.bindHost ?? "127.0.0.1";
235
625
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
236
626
  const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
@@ -238,9 +628,15 @@ async function resolveLocalghostContext(options = {}) {
238
628
  const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
239
629
  const hosts = uniqueHosts(entries);
240
630
  const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
631
+ const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
632
+ const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
633
+ route: getRouteName(primaryHost, projectName),
634
+ project: projectName,
635
+ owner: getLocalOwner(cwd)
636
+ });
241
637
  return {
242
638
  cwd,
243
- projectName: sanitizeProjectName(merged.project ?? getProjectName(cwd)),
639
+ projectName,
244
640
  readOptions,
245
641
  configPath: resolvedPath.path,
246
642
  configFileName: resolvedPath.fileName,
@@ -254,6 +650,7 @@ async function resolveLocalghostContext(options = {}) {
254
650
  primaryHost,
255
651
  https: merged.https ?? envHttps() ?? false,
256
652
  wwwAlias,
653
+ ghostTunnel,
257
654
  ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
258
655
  };
259
656
  }
@@ -293,21 +690,21 @@ function isProductionLike(env = process.env) {
293
690
  }
294
691
 
295
692
  // src/fs.ts
296
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
297
- import { dirname } from "path";
693
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
694
+ import { dirname as dirname2 } from "path";
298
695
  function readTextFile(path) {
299
- return readFileSync2(path, "utf8");
696
+ return readFileSync4(path, "utf8");
300
697
  }
301
698
  function writeTextFile(path, value) {
302
- mkdirSync(dirname(path), { recursive: true });
303
- writeFileSync(path, value, "utf8");
699
+ mkdirSync2(dirname2(path), { recursive: true });
700
+ writeFileSync2(path, value, "utf8");
304
701
  return path;
305
702
  }
306
703
 
307
704
  // src/hosts-file.ts
308
- import { writeFileSync as writeFileSync2 } from "fs";
705
+ import { writeFileSync as writeFileSync3 } from "fs";
309
706
  import { tmpdir } from "os";
310
- import { join as join2 } from "path";
707
+ import { join as join4 } from "path";
311
708
  import { execa as execa2 } from "execa";
312
709
  function escapeRegExp(value) {
313
710
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -342,8 +739,8 @@ ${block}`;
342
739
  }
343
740
  async function writeSystemHostsFile(hostsPath, next, projectName) {
344
741
  const sanitizedProjectName = sanitizeProjectName(projectName);
345
- const tempPath = join2(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
346
- writeFileSync2(tempPath, next, "utf8");
742
+ const tempPath = join4(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
743
+ writeFileSync3(tempPath, next, "utf8");
347
744
  if (process.platform === "win32") {
348
745
  throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
349
746
  }
@@ -394,15 +791,15 @@ async function ask(question, defaultValue) {
394
791
  }
395
792
 
396
793
  // src/state.ts
397
- import { existsSync as existsSync3 } from "fs";
398
- import { join as join3 } from "path";
794
+ import { existsSync as existsSync4 } from "fs";
795
+ import { join as join5 } from "path";
399
796
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
400
797
  function getLocalghostStatePath(cwd = process.cwd()) {
401
- return join3(cwd, LOCALGHOST_STATE_FILE);
798
+ return join5(cwd, LOCALGHOST_STATE_FILE);
402
799
  }
403
800
  function readLocalghostState(cwd = process.cwd()) {
404
801
  const path = getLocalghostStatePath(cwd);
405
- if (!existsSync3(path)) return null;
802
+ if (!existsSync4(path)) return null;
406
803
  return JSON.parse(readTextFile(path));
407
804
  }
408
805
  function writeLocalghostState(cwd, state) {
@@ -413,7 +810,7 @@ function writeLocalghostState(cwd, state) {
413
810
  }
414
811
 
415
812
  // src/caddy.ts
416
- import { dirname as dirname2, join as join4 } from "path";
813
+ import { dirname as dirname3, join as join6 } from "path";
417
814
  import { execa as execa3 } from "execa";
418
815
  function groupByPort(entries) {
419
816
  const groups = /* @__PURE__ */ new Map();
@@ -425,7 +822,7 @@ function groupByPort(entries) {
425
822
  return groups;
426
823
  }
427
824
  function getCaddyfilePath(cwd = process.cwd()) {
428
- return join4(cwd, "ops/local/Caddyfile");
825
+ return join6(cwd, "ops/local/Caddyfile");
429
826
  }
430
827
  function renderCaddyfile(entries, options = {}) {
431
828
  const groups = groupByPort(entries);
@@ -451,11 +848,54 @@ async function writeCaddyfile(entries, cwd = process.cwd(), options = {}) {
451
848
  }
452
849
  async function validateCaddyfile(path) {
453
850
  await execa3("caddy", ["validate", "--config", path], {
454
- cwd: dirname2(path),
851
+ cwd: dirname3(path),
455
852
  stdio: "inherit"
456
853
  });
457
854
  }
458
855
 
856
+ // src/routes.ts
857
+ var ansi = {
858
+ cyan: "\x1B[36m",
859
+ dim: "\x1B[2m",
860
+ green: "\x1B[32m",
861
+ reset: "\x1B[0m",
862
+ yellow: "\x1B[33m"
863
+ };
864
+ function colorize(value, color, enabled) {
865
+ return enabled ? `${color}${value}${ansi.reset}` : value;
866
+ }
867
+ function colorizeUrl(value, enabled) {
868
+ if (!enabled) return value;
869
+ return colorize(value.replace(/\*/g, `${ansi.yellow}*${ansi.cyan}`), ansi.cyan, enabled);
870
+ }
871
+ function formatGhostTunnel(config, options = {}) {
872
+ if (!config.enabled) return null;
873
+ const color = options.color === true;
874
+ const label = options.label ?? "expected";
875
+ const labelColor = label === "running" ? ansi.green : ansi.dim;
876
+ const lines = [
877
+ "localghost ghost tunnel",
878
+ ` mode: ${config.mode}`
879
+ ];
880
+ const urls = config.displayUrls.length > 0 ? config.displayUrls : config.displayUrl ? [config.displayUrl] : [];
881
+ if (urls.length === 0) {
882
+ lines.push(` ${label}: unavailable`);
883
+ } else if (urls.length === 1) {
884
+ lines.push(` ${colorize(label, labelColor, color)}: ${colorizeUrl(urls[0], color)}`);
885
+ } else {
886
+ lines.push(` ${colorize(label, labelColor, color)}:`);
887
+ for (const url of urls) {
888
+ lines.push(` ${colorizeUrl(url, color)}`);
889
+ }
890
+ }
891
+ if (options.verbose) {
892
+ lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
893
+ lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
894
+ lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
895
+ }
896
+ return lines.join("\n");
897
+ }
898
+
459
899
  // src/vite.ts
460
900
  function mergeAllowedHosts(current, hosts) {
461
901
  if (Array.isArray(current)) {
@@ -470,7 +910,11 @@ function getDisplayEntries(entries, vitePort) {
470
910
  const matchingEntries = entries.filter((entry) => entry.port === vitePort);
471
911
  return matchingEntries.length > 0 ? matchingEntries : entries;
472
912
  }
473
- function printLocalHosts(server, entries, vitePort, https) {
913
+ function printLocalHosts(server, context) {
914
+ if (!context) return;
915
+ const entries = context.entries;
916
+ const vitePort = context.port;
917
+ const https = context.https;
474
918
  const displayEntries = getDisplayEntries(entries, vitePort);
475
919
  const protocol = https ? "https" : "http";
476
920
  const urls = displayEntries.map((entry) => `${protocol}://${entry.host}/`);
@@ -484,13 +928,24 @@ function printLocalHosts(server, entries, vitePort, https) {
484
928
  ` local: ${primaryUrl}`,
485
929
  ...urls.slice(1).map((url) => ` also: ${url}`),
486
930
  vitePort ? ` target: http://127.0.0.1:${vitePort}/` : void 0,
487
- https ? " proxy: Caddy local HTTPS" : void 0
931
+ https ? " proxy: Caddy local HTTPS" : void 0,
932
+ context.ghostTunnel.enabled ? formatGhostTunnel(context.ghostTunnel, {
933
+ color: shouldColor(),
934
+ label: "ready",
935
+ verbose: optionsVerbose(context)
936
+ }) : void 0
488
937
  ].filter((line) => Boolean(line));
489
938
  server.config.logger.info(lines.join("\n"), {
490
939
  clear: false,
491
940
  timestamp: false
492
941
  });
493
942
  }
943
+ function shouldColor() {
944
+ return process.stdout.isTTY && !process.env.NO_COLOR;
945
+ }
946
+ function optionsVerbose(context) {
947
+ return process.env.LOCALGHOST_VERBOSE === "1" || process.env.LOCALGHOST_VERBOSE === "true" || context.ghostTunnel.displayUrls.length > 1;
948
+ }
494
949
  function readOptionsFromPlugin(options) {
495
950
  return {
496
951
  cwd: options.cwd ?? process.cwd(),
@@ -522,6 +977,57 @@ function defaultHost(cwd) {
522
977
  const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
523
978
  return `${projectName}.localhost`;
524
979
  }
980
+ function getPackageOwner2(cwd) {
981
+ try {
982
+ const pkg = JSON.parse(readFileSync5(resolve2(cwd, "package.json"), "utf8"));
983
+ if (typeof pkg.name === "string" && pkg.name.startsWith("@")) {
984
+ return pkg.name.slice(1).split("/")[0];
985
+ }
986
+ } catch {
987
+ return void 0;
988
+ }
989
+ return void 0;
990
+ }
991
+ function getLocalOwner2(cwd) {
992
+ return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner2(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
993
+ }
994
+ function getRouteName2(primaryHost, fallback) {
995
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
996
+ }
997
+ function readBuildEntries(options) {
998
+ const readOptions = readOptionsFromPlugin(options);
999
+ const resolved = resolveDevHostsPath(readOptions);
1000
+ if (!resolved.exists) return [];
1001
+ try {
1002
+ return readDevHosts(readOptions);
1003
+ } catch {
1004
+ return [];
1005
+ }
1006
+ }
1007
+ async function maybePrintBuildGhostTunnel(options) {
1008
+ const cwd = options.cwd ?? process.cwd();
1009
+ const projectConfig = await readLocalghostProjectConfig({
1010
+ cwd,
1011
+ ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
1012
+ });
1013
+ const explicitGhostTunnel = typeof options.ghostTunnel !== "undefined" ? options.ghostTunnel : projectConfig.config.ghostTunnel;
1014
+ if (!explicitGhostTunnel) return;
1015
+ const entries = readBuildEntries(options);
1016
+ const projectName = sanitizeProjectName(projectConfig.config.project ?? getProjectName(cwd));
1017
+ const primaryHost = options.primaryHost ?? entries[0]?.host ?? defaultHost(cwd);
1018
+ const ghostTunnel = resolveGhostTunnelConfig(explicitGhostTunnel, {
1019
+ route: getRouteName2(primaryHost, projectName),
1020
+ project: sanitizeProjectName(projectConfig.config.project ?? getProjectName(cwd)),
1021
+ owner: getLocalOwner2(cwd)
1022
+ });
1023
+ if (!ghostTunnel.enabled) return;
1024
+ const formatted = formatGhostTunnel(ghostTunnel, {
1025
+ color: shouldColor(),
1026
+ label: "configured",
1027
+ verbose: options.verbose === true || process.env.LOCALGHOST_VERBOSE === "1" || process.env.LOCALGHOST_VERBOSE === "true"
1028
+ });
1029
+ if (formatted) console.log(formatted);
1030
+ }
525
1031
  async function promptForHosts(cwd, port) {
526
1032
  const primaryHost = await ask("Primary local domain", defaultHost(cwd));
527
1033
  const hosts = [primaryHost.toLowerCase()];
@@ -536,13 +1042,13 @@ function hasReadySetup(cwd, entries, configPath, https) {
536
1042
  const projectName = sanitizeProjectName(getProjectName(cwd));
537
1043
  if (state?.action !== "setup" || state.configPath !== configPath) return false;
538
1044
  try {
539
- const hosts = readFileSync3(getSystemHostsPath(), "utf8");
1045
+ const hosts = readFileSync5(getSystemHostsPath(), "utf8");
540
1046
  if (!hosts.includes(renderHostsBlock(projectName, entries).trimEnd())) return false;
541
1047
  } catch {
542
1048
  return false;
543
1049
  }
544
1050
  const caddyfilePath = getCaddyfilePath(cwd);
545
- return existsSync4(caddyfilePath) && readFileSync3(caddyfilePath, "utf8") === renderCaddyfile(entries, { https });
1051
+ return existsSync5(caddyfilePath) && readFileSync5(caddyfilePath, "utf8") === renderCaddyfile(entries, { https });
546
1052
  }
547
1053
  async function setupProject(cwd, entries, configPath, https) {
548
1054
  const caddy = await checkCaddy();
@@ -571,6 +1077,14 @@ async function setupProject(cwd, entries, configPath, https) {
571
1077
  caddyHttps: https,
572
1078
  entries
573
1079
  });
1080
+ registerLocalghostSetup({
1081
+ cwd,
1082
+ projectName,
1083
+ configPath,
1084
+ caddyfilePath,
1085
+ https,
1086
+ entries
1087
+ });
574
1088
  }
575
1089
  async function ensureLocalghostContext(options, vitePort, https) {
576
1090
  const cwd = options.cwd ?? process.cwd();
@@ -606,16 +1120,91 @@ async function ensureLocalghostContext(options, vitePort, https) {
606
1120
  }
607
1121
  return context;
608
1122
  }
1123
+ function isConcreteGhostUrl(url) {
1124
+ return !url.includes("*") && !url.includes("<") && /^https?:\/\//.test(url);
1125
+ }
1126
+ function getConcreteGhostUrls(context) {
1127
+ return context.ghostTunnel.displayUrls.filter(isConcreteGhostUrl);
1128
+ }
1129
+ function openExternalUrl(url) {
1130
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1131
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
1132
+ const child = spawn(command, args, {
1133
+ detached: true,
1134
+ stdio: "ignore"
1135
+ });
1136
+ child.unref();
1137
+ }
1138
+ function installGhostTunnelMenu(server, context) {
1139
+ if (!context?.ghostTunnel.enabled || !process.stdin.isTTY) return void 0;
1140
+ emitKeypressEvents(process.stdin);
1141
+ let active = false;
1142
+ const concreteUrls = getConcreteGhostUrls(context);
1143
+ const printMenu = () => {
1144
+ active = true;
1145
+ const lines = [
1146
+ "",
1147
+ formatGhostTunnel(context.ghostTunnel, {
1148
+ color: shouldColor(),
1149
+ label: "ready",
1150
+ verbose: true
1151
+ }) ?? "localghost ghost tunnel",
1152
+ ""
1153
+ ];
1154
+ if (concreteUrls.length === 0) {
1155
+ lines.push(" No concrete Ghost Tunnel domain configured.");
1156
+ lines.push(" Add ghostTunnel.domains to localghost.config.mjs to open a URL from this menu.");
1157
+ active = false;
1158
+ } else {
1159
+ concreteUrls.forEach((url, index) => {
1160
+ lines.push(` ${index + 1}. ${url}`);
1161
+ });
1162
+ lines.push("");
1163
+ lines.push(" Press a number to open, or escape to cancel.");
1164
+ }
1165
+ server.config.logger.info(lines.join("\n"), {
1166
+ clear: false,
1167
+ timestamp: false
1168
+ });
1169
+ };
1170
+ const onKeypress = (_input, key = {}) => {
1171
+ if (key.ctrl && key.name === "c") return;
1172
+ if (!active) {
1173
+ if (key.name === "g") printMenu();
1174
+ return;
1175
+ }
1176
+ if (key.name === "escape") {
1177
+ active = false;
1178
+ return;
1179
+ }
1180
+ const index = Number.parseInt(key.name ?? "", 10) - 1;
1181
+ const url = concreteUrls[index];
1182
+ if (!url) return;
1183
+ active = false;
1184
+ openExternalUrl(url);
1185
+ server.config.logger.info(`localghost opened ${url}`, {
1186
+ clear: false,
1187
+ timestamp: false
1188
+ });
1189
+ };
1190
+ process.stdin.on("keypress", onKeypress);
1191
+ return () => {
1192
+ process.stdin.off("keypress", onKeypress);
1193
+ };
1194
+ }
609
1195
  function localGhostPlugin(options = {}) {
610
1196
  let resolvedEntries = [];
611
1197
  let resolvedVitePort;
612
1198
  let resolvedHttps = false;
1199
+ let resolvedContext;
613
1200
  let restartTimer;
1201
+ let activityRunId;
614
1202
  return {
615
1203
  name: "localghost:vite",
616
1204
  enforce: "pre",
617
1205
  async config(userConfig, configEnv) {
618
1206
  if (configEnv.command !== "serve" || configEnv.mode === "production" || isProductionLike()) {
1207
+ await maybePrintBuildGhostTunnel(options);
619
1208
  return {};
620
1209
  }
621
1210
  const existingServer = userConfig.server ?? {};
@@ -628,6 +1217,7 @@ function localGhostPlugin(options = {}) {
628
1217
  resolvedEntries = entries;
629
1218
  resolvedVitePort = context.port;
630
1219
  resolvedHttps = context.https;
1220
+ resolvedContext = context;
631
1221
  const server = {
632
1222
  ...existingServer,
633
1223
  allowedHosts: mergeAllowedHosts(existingServer.allowedHosts, hosts),
@@ -687,9 +1277,38 @@ function localGhostPlugin(options = {}) {
687
1277
  server.watcher.on("unlink", restartOnLocalghostConfigChange);
688
1278
  if (options.log !== false) {
689
1279
  server.printUrls = () => {
690
- printLocalHosts(server, resolvedEntries, resolvedVitePort, resolvedHttps);
1280
+ printLocalHosts(server, resolvedContext);
691
1281
  };
692
1282
  }
1283
+ if (resolvedContext) {
1284
+ const run = registerLocalghostRun({
1285
+ id: `${resolvedContext.projectName}:vite:${process.pid}:${resolvedContext.cwd}`,
1286
+ mode: "vite",
1287
+ pid: process.pid,
1288
+ cwd: resolvedContext.cwd,
1289
+ projectName: resolvedContext.projectName,
1290
+ configPath: resolvedContext.configPath,
1291
+ childCommand: ["vite"],
1292
+ https: resolvedContext.https,
1293
+ requestedPort: resolvedContext.requestedPort,
1294
+ port: resolvedContext.port,
1295
+ dynamicPort: resolvedContext.dynamicPort,
1296
+ entries: resolvedContext.entries
1297
+ });
1298
+ activityRunId = run.id;
1299
+ }
1300
+ const cleanupGhostMenu = installGhostTunnelMenu(server, resolvedContext);
1301
+ const cleanupActivity = () => {
1302
+ if (!activityRunId) return;
1303
+ unregisterLocalghostRun(activityRunId);
1304
+ activityRunId = void 0;
1305
+ };
1306
+ const cleanup = () => {
1307
+ cleanupActivity();
1308
+ cleanupGhostMenu?.();
1309
+ };
1310
+ server.httpServer?.once("close", cleanup);
1311
+ process.once("exit", cleanup);
693
1312
  }
694
1313
  };
695
1314
  }