@hamedb89/localghost 0.1.8 → 0.1.10

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,261 @@ 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 getDisplayDefaults(config, defaults) {
408
+ return config.mode === "public" && !config.preview ? void 0 : defaults;
409
+ }
410
+ function createDisplayUrl(config, defaults, domain) {
411
+ const input2 = getPreviewDefaults(config.preview, getDisplayDefaults(config, defaults));
412
+ const protocol = input2.protocol ?? "https";
413
+ const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input2));
414
+ const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input2.domain ? getGhostTunnelEntryHost(input2.domain, config) : `${config.subdomain}.*`;
415
+ const url = `${protocol}://${slug}.${entryHost}/`;
416
+ if (!input2.path) return url;
417
+ return `${url}${input2.path.replace(/^\/+/, "")}`;
418
+ }
419
+ function createDisplayUrls(config, defaults) {
420
+ const displayDefaults = getDisplayDefaults(config, defaults);
421
+ const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
422
+ const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
423
+ return [...new Set(urls)];
424
+ }
425
+ function maybeConstructPreviewUrl(config, defaults) {
426
+ if (!config.preview) return void 0;
427
+ const input2 = getPreviewDefaults(config.preview, defaults);
428
+ if (!input2.domain || !input2.route || !input2.project || !input2.owner) return void 0;
429
+ return constructGhostTunnelUrl({
430
+ domain: input2.domain,
431
+ route: input2.route,
432
+ project: input2.project,
433
+ owner: input2.owner,
434
+ values: input2.values,
435
+ ...input2.path ? { path: input2.path } : {},
436
+ ...input2.protocol ? { protocol: input2.protocol } : {},
437
+ ghostTunnel: config
438
+ });
439
+ }
440
+ function resolveGhostTunnelConfig(options, defaults) {
441
+ if (options === false || typeof options === "undefined") {
442
+ return {
443
+ enabled: false,
444
+ mode: DEFAULT_GHOST_TUNNEL_MODE,
445
+ domains: [],
446
+ subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
447
+ namespace: resolveNamespaceConfig(void 0),
448
+ displayUrls: [],
449
+ requireHttps: true,
450
+ requireAuth: true
451
+ };
452
+ }
453
+ const config = typeof options === "string" ? { mode: options } : options;
454
+ const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
455
+ assertValidSubdomain(subdomain);
456
+ const domains = normalizeDomains(config.domains);
457
+ const enabled = config.enabled ?? true;
458
+ const resolved = {
459
+ enabled,
460
+ mode: parseGhostTunnelMode(config.mode),
461
+ domains,
462
+ subdomain,
463
+ namespace: resolveNamespaceConfig(config.namespace),
464
+ ...config.preview ? { preview: config.preview } : {},
465
+ displayUrls: [],
466
+ requireHttps: config.requireHttps ?? true,
467
+ requireAuth: config.requireAuth ?? true
468
+ };
469
+ if (!enabled) {
470
+ return resolved;
471
+ }
472
+ const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
473
+ const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
474
+ return {
475
+ ...resolved,
476
+ displayUrls,
477
+ ...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
478
+ ...previewUrl ? { previewUrl } : {}
479
+ };
480
+ }
481
+ function getGhostTunnelEntryHost(domain, options = {}) {
482
+ const config = toGhostTunnelConfig(options);
483
+ const normalizedDomain = normalizeDomain(domain);
484
+ if (!normalizedDomain) {
485
+ throw new Error(`Invalid ghost tunnel domain: ${domain}`);
486
+ }
487
+ return `${config.subdomain}.${normalizedDomain}`;
488
+ }
489
+ function constructGhostTunnelHost(input2) {
490
+ const config = toGhostTunnelConfig(input2.ghostTunnel ?? {});
491
+ if (!config.enabled) {
492
+ throw new Error("Ghost tunnel is not enabled.");
493
+ }
494
+ const namespaceValues = {
495
+ route: input2.route,
496
+ project: input2.project,
497
+ owner: input2.owner,
498
+ ...input2.values ?? {}
499
+ };
500
+ const slug = createNamespaceSlug(config.namespace, namespaceValues);
501
+ return `${slug}.${getGhostTunnelEntryHost(input2.domain, config)}`;
502
+ }
503
+ function constructGhostTunnelUrl(input2) {
504
+ const protocol = input2.protocol ?? "https";
505
+ const host = constructGhostTunnelHost(input2);
506
+ const url = new URL(`${protocol}://${host}/`);
507
+ if (input2.path) {
508
+ url.pathname = `/${input2.path.replace(/^\/+/, "")}`;
509
+ }
510
+ if (input2.searchParams instanceof URLSearchParams) {
511
+ url.search = input2.searchParams.toString();
512
+ } else if (input2.searchParams) {
513
+ for (const [key, value] of Object.entries(input2.searchParams)) {
514
+ if (typeof value !== "undefined" && value !== null) {
515
+ url.searchParams.set(key, String(value));
516
+ }
517
+ }
518
+ }
519
+ return url.toString();
520
+ }
521
+
151
522
  // src/context.ts
152
523
  var LOCALGHOST_PROJECT_CONFIG_FILES = [
153
524
  "localghost.config.mjs",
@@ -172,6 +543,25 @@ function envHttps() {
172
543
  if (!value) return void 0;
173
544
  return ["1", "true", "yes", "on"].includes(value.toLowerCase());
174
545
  }
546
+ function getPackageName(cwd) {
547
+ try {
548
+ const pkg = JSON.parse(readFileSync3(join3(cwd, "package.json"), "utf8"));
549
+ return typeof pkg.name === "string" ? pkg.name : void 0;
550
+ } catch {
551
+ return void 0;
552
+ }
553
+ }
554
+ function getPackageOwner(cwd) {
555
+ const packageName = getPackageName(cwd);
556
+ if (!packageName?.startsWith("@")) return void 0;
557
+ return packageName.slice(1).split("/")[0];
558
+ }
559
+ function getLocalOwner(cwd) {
560
+ return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
561
+ }
562
+ function getRouteName(primaryHost, fallback) {
563
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
564
+ }
175
565
  function readOptionsFromContext(options) {
176
566
  return {
177
567
  cwd: options.cwd ?? process.cwd(),
@@ -210,18 +600,22 @@ function addDefaultWwwAliases(entries) {
210
600
  function defined(input2) {
211
601
  return Object.fromEntries(Object.entries(input2).filter(([, value]) => typeof value !== "undefined"));
212
602
  }
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 {};
603
+ async function readLocalghostProjectConfig(options = {}) {
604
+ const cwd = options.cwd ?? process.cwd();
605
+ if (options.configFile === false) return { config: {} };
606
+ const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
607
+ const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
608
+ if (!path) return { config: {} };
218
609
  const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
219
610
  const config = imported.default ?? imported;
220
611
  return { config, path };
221
612
  }
222
613
  async function resolveLocalghostContext(options = {}) {
223
614
  const cwd = options.cwd ?? process.cwd();
224
- const projectConfig = await readProjectConfig(cwd, options.localghostConfig);
615
+ const projectConfig = await readLocalghostProjectConfig({
616
+ cwd,
617
+ ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
618
+ });
225
619
  const merged = {
226
620
  ...projectConfig.config,
227
621
  ...defined(options)
@@ -230,7 +624,7 @@ async function resolveLocalghostContext(options = {}) {
230
624
  const resolvedPath = resolveDevHostsPath(readOptions);
231
625
  const configEntries = readDevHosts(readOptions);
232
626
  const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
233
- const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? false;
627
+ const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
234
628
  const bindHost = merged.bindHost ?? "127.0.0.1";
235
629
  const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
236
630
  const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
@@ -238,9 +632,15 @@ async function resolveLocalghostContext(options = {}) {
238
632
  const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
239
633
  const hosts = uniqueHosts(entries);
240
634
  const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
635
+ const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
636
+ const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
637
+ route: getRouteName(primaryHost, projectName),
638
+ project: projectName,
639
+ owner: getLocalOwner(cwd)
640
+ });
241
641
  return {
242
642
  cwd,
243
- projectName: sanitizeProjectName(merged.project ?? getProjectName(cwd)),
643
+ projectName,
244
644
  readOptions,
245
645
  configPath: resolvedPath.path,
246
646
  configFileName: resolvedPath.fileName,
@@ -254,6 +654,7 @@ async function resolveLocalghostContext(options = {}) {
254
654
  primaryHost,
255
655
  https: merged.https ?? envHttps() ?? false,
256
656
  wwwAlias,
657
+ ghostTunnel,
257
658
  ...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
258
659
  };
259
660
  }
@@ -293,21 +694,21 @@ function isProductionLike(env = process.env) {
293
694
  }
294
695
 
295
696
  // src/fs.ts
296
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
297
- import { dirname } from "path";
697
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
698
+ import { dirname as dirname2 } from "path";
298
699
  function readTextFile(path) {
299
- return readFileSync2(path, "utf8");
700
+ return readFileSync4(path, "utf8");
300
701
  }
301
702
  function writeTextFile(path, value) {
302
- mkdirSync(dirname(path), { recursive: true });
303
- writeFileSync(path, value, "utf8");
703
+ mkdirSync2(dirname2(path), { recursive: true });
704
+ writeFileSync2(path, value, "utf8");
304
705
  return path;
305
706
  }
306
707
 
307
708
  // src/hosts-file.ts
308
- import { writeFileSync as writeFileSync2 } from "fs";
709
+ import { writeFileSync as writeFileSync3 } from "fs";
309
710
  import { tmpdir } from "os";
310
- import { join as join2 } from "path";
711
+ import { join as join4 } from "path";
311
712
  import { execa as execa2 } from "execa";
312
713
  function escapeRegExp(value) {
313
714
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -342,8 +743,8 @@ ${block}`;
342
743
  }
343
744
  async function writeSystemHostsFile(hostsPath, next, projectName) {
344
745
  const sanitizedProjectName = sanitizeProjectName(projectName);
345
- const tempPath = join2(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
346
- writeFileSync2(tempPath, next, "utf8");
746
+ const tempPath = join4(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
747
+ writeFileSync3(tempPath, next, "utf8");
347
748
  if (process.platform === "win32") {
348
749
  throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
349
750
  }
@@ -394,15 +795,15 @@ async function ask(question, defaultValue) {
394
795
  }
395
796
 
396
797
  // src/state.ts
397
- import { existsSync as existsSync3 } from "fs";
398
- import { join as join3 } from "path";
798
+ import { existsSync as existsSync4 } from "fs";
799
+ import { join as join5 } from "path";
399
800
  var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
400
801
  function getLocalghostStatePath(cwd = process.cwd()) {
401
- return join3(cwd, LOCALGHOST_STATE_FILE);
802
+ return join5(cwd, LOCALGHOST_STATE_FILE);
402
803
  }
403
804
  function readLocalghostState(cwd = process.cwd()) {
404
805
  const path = getLocalghostStatePath(cwd);
405
- if (!existsSync3(path)) return null;
806
+ if (!existsSync4(path)) return null;
406
807
  return JSON.parse(readTextFile(path));
407
808
  }
408
809
  function writeLocalghostState(cwd, state) {
@@ -413,7 +814,7 @@ function writeLocalghostState(cwd, state) {
413
814
  }
414
815
 
415
816
  // src/caddy.ts
416
- import { dirname as dirname2, join as join4 } from "path";
817
+ import { dirname as dirname3, join as join6 } from "path";
417
818
  import { execa as execa3 } from "execa";
418
819
  function groupByPort(entries) {
419
820
  const groups = /* @__PURE__ */ new Map();
@@ -425,7 +826,7 @@ function groupByPort(entries) {
425
826
  return groups;
426
827
  }
427
828
  function getCaddyfilePath(cwd = process.cwd()) {
428
- return join4(cwd, "ops/local/Caddyfile");
829
+ return join6(cwd, "ops/local/Caddyfile");
429
830
  }
430
831
  function renderCaddyfile(entries, options = {}) {
431
832
  const groups = groupByPort(entries);
@@ -451,11 +852,54 @@ async function writeCaddyfile(entries, cwd = process.cwd(), options = {}) {
451
852
  }
452
853
  async function validateCaddyfile(path) {
453
854
  await execa3("caddy", ["validate", "--config", path], {
454
- cwd: dirname2(path),
855
+ cwd: dirname3(path),
455
856
  stdio: "inherit"
456
857
  });
457
858
  }
458
859
 
860
+ // src/routes.ts
861
+ var ansi = {
862
+ cyan: "\x1B[36m",
863
+ dim: "\x1B[2m",
864
+ green: "\x1B[32m",
865
+ reset: "\x1B[0m",
866
+ yellow: "\x1B[33m"
867
+ };
868
+ function colorize(value, color, enabled) {
869
+ return enabled ? `${color}${value}${ansi.reset}` : value;
870
+ }
871
+ function colorizeUrl(value, enabled) {
872
+ if (!enabled) return value;
873
+ return colorize(value.replace(/\*/g, `${ansi.yellow}*${ansi.cyan}`), ansi.cyan, enabled);
874
+ }
875
+ function formatGhostTunnel(config, options = {}) {
876
+ if (!config.enabled) return null;
877
+ const color = options.color === true;
878
+ const label = options.label ?? "expected";
879
+ const labelColor = label === "running" ? ansi.green : ansi.dim;
880
+ const lines = [
881
+ "localghost ghost tunnel",
882
+ ` mode: ${config.mode}`
883
+ ];
884
+ const urls = config.displayUrls.length > 0 ? config.displayUrls : config.displayUrl ? [config.displayUrl] : [];
885
+ if (urls.length === 0) {
886
+ lines.push(` ${label}: unavailable`);
887
+ } else if (urls.length === 1) {
888
+ lines.push(` ${colorize(label, labelColor, color)}: ${colorizeUrl(urls[0], color)}`);
889
+ } else {
890
+ lines.push(` ${colorize(label, labelColor, color)}:`);
891
+ for (const url of urls) {
892
+ lines.push(` ${colorizeUrl(url, color)}`);
893
+ }
894
+ }
895
+ if (options.verbose) {
896
+ lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
897
+ lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
898
+ lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
899
+ }
900
+ return lines.join("\n");
901
+ }
902
+
459
903
  // src/vite.ts
460
904
  function mergeAllowedHosts(current, hosts) {
461
905
  if (Array.isArray(current)) {
@@ -470,7 +914,11 @@ function getDisplayEntries(entries, vitePort) {
470
914
  const matchingEntries = entries.filter((entry) => entry.port === vitePort);
471
915
  return matchingEntries.length > 0 ? matchingEntries : entries;
472
916
  }
473
- function printLocalHosts(server, entries, vitePort, https) {
917
+ function printLocalHosts(server, context) {
918
+ if (!context) return;
919
+ const entries = context.entries;
920
+ const vitePort = context.port;
921
+ const https = context.https;
474
922
  const displayEntries = getDisplayEntries(entries, vitePort);
475
923
  const protocol = https ? "https" : "http";
476
924
  const urls = displayEntries.map((entry) => `${protocol}://${entry.host}/`);
@@ -484,13 +932,25 @@ function printLocalHosts(server, entries, vitePort, https) {
484
932
  ` local: ${primaryUrl}`,
485
933
  ...urls.slice(1).map((url) => ` also: ${url}`),
486
934
  vitePort ? ` target: http://127.0.0.1:${vitePort}/` : void 0,
487
- https ? " proxy: Caddy local HTTPS" : void 0
935
+ https ? " proxy: Caddy local HTTPS" : void 0,
936
+ context.ghostTunnel.enabled ? formatGhostTunnel(context.ghostTunnel, {
937
+ color: shouldColor(),
938
+ label: "ready",
939
+ verbose: optionsVerbose(context)
940
+ }) : void 0,
941
+ process.stdin.isTTY && context.ghostTunnel.enabled ? " help: press h + enter for Vite, g + enter for Localghost" : void 0
488
942
  ].filter((line) => Boolean(line));
489
943
  server.config.logger.info(lines.join("\n"), {
490
944
  clear: false,
491
945
  timestamp: false
492
946
  });
493
947
  }
948
+ function shouldColor() {
949
+ return process.stdout.isTTY && !process.env.NO_COLOR;
950
+ }
951
+ function optionsVerbose(context) {
952
+ return process.env.LOCALGHOST_VERBOSE === "1" || process.env.LOCALGHOST_VERBOSE === "true" || context.ghostTunnel.displayUrls.length > 1;
953
+ }
494
954
  function readOptionsFromPlugin(options) {
495
955
  return {
496
956
  cwd: options.cwd ?? process.cwd(),
@@ -522,6 +982,57 @@ function defaultHost(cwd) {
522
982
  const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
523
983
  return `${projectName}.localhost`;
524
984
  }
985
+ function getPackageOwner2(cwd) {
986
+ try {
987
+ const pkg = JSON.parse(readFileSync5(resolve2(cwd, "package.json"), "utf8"));
988
+ if (typeof pkg.name === "string" && pkg.name.startsWith("@")) {
989
+ return pkg.name.slice(1).split("/")[0];
990
+ }
991
+ } catch {
992
+ return void 0;
993
+ }
994
+ return void 0;
995
+ }
996
+ function getLocalOwner2(cwd) {
997
+ return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner2(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
998
+ }
999
+ function getRouteName2(primaryHost, fallback) {
1000
+ return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
1001
+ }
1002
+ function readBuildEntries(options) {
1003
+ const readOptions = readOptionsFromPlugin(options);
1004
+ const resolved = resolveDevHostsPath(readOptions);
1005
+ if (!resolved.exists) return [];
1006
+ try {
1007
+ return readDevHosts(readOptions);
1008
+ } catch {
1009
+ return [];
1010
+ }
1011
+ }
1012
+ async function maybePrintBuildGhostTunnel(options) {
1013
+ const cwd = options.cwd ?? process.cwd();
1014
+ const projectConfig = await readLocalghostProjectConfig({
1015
+ cwd,
1016
+ ...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
1017
+ });
1018
+ const explicitGhostTunnel = typeof options.ghostTunnel !== "undefined" ? options.ghostTunnel : projectConfig.config.ghostTunnel;
1019
+ if (!explicitGhostTunnel) return;
1020
+ const entries = readBuildEntries(options);
1021
+ const projectName = sanitizeProjectName(projectConfig.config.project ?? getProjectName(cwd));
1022
+ const primaryHost = options.primaryHost ?? entries[0]?.host ?? defaultHost(cwd);
1023
+ const ghostTunnel = resolveGhostTunnelConfig(explicitGhostTunnel, {
1024
+ route: getRouteName2(primaryHost, projectName),
1025
+ project: sanitizeProjectName(projectConfig.config.project ?? getProjectName(cwd)),
1026
+ owner: getLocalOwner2(cwd)
1027
+ });
1028
+ if (!ghostTunnel.enabled) return;
1029
+ const formatted = formatGhostTunnel(ghostTunnel, {
1030
+ color: shouldColor(),
1031
+ label: "configured",
1032
+ verbose: options.verbose === true || process.env.LOCALGHOST_VERBOSE === "1" || process.env.LOCALGHOST_VERBOSE === "true"
1033
+ });
1034
+ if (formatted) console.log(formatted);
1035
+ }
525
1036
  async function promptForHosts(cwd, port) {
526
1037
  const primaryHost = await ask("Primary local domain", defaultHost(cwd));
527
1038
  const hosts = [primaryHost.toLowerCase()];
@@ -536,13 +1047,13 @@ function hasReadySetup(cwd, entries, configPath, https) {
536
1047
  const projectName = sanitizeProjectName(getProjectName(cwd));
537
1048
  if (state?.action !== "setup" || state.configPath !== configPath) return false;
538
1049
  try {
539
- const hosts = readFileSync3(getSystemHostsPath(), "utf8");
1050
+ const hosts = readFileSync5(getSystemHostsPath(), "utf8");
540
1051
  if (!hosts.includes(renderHostsBlock(projectName, entries).trimEnd())) return false;
541
1052
  } catch {
542
1053
  return false;
543
1054
  }
544
1055
  const caddyfilePath = getCaddyfilePath(cwd);
545
- return existsSync4(caddyfilePath) && readFileSync3(caddyfilePath, "utf8") === renderCaddyfile(entries, { https });
1056
+ return existsSync5(caddyfilePath) && readFileSync5(caddyfilePath, "utf8") === renderCaddyfile(entries, { https });
546
1057
  }
547
1058
  async function setupProject(cwd, entries, configPath, https) {
548
1059
  const caddy = await checkCaddy();
@@ -571,6 +1082,14 @@ async function setupProject(cwd, entries, configPath, https) {
571
1082
  caddyHttps: https,
572
1083
  entries
573
1084
  });
1085
+ registerLocalghostSetup({
1086
+ cwd,
1087
+ projectName,
1088
+ configPath,
1089
+ caddyfilePath,
1090
+ https,
1091
+ entries
1092
+ });
574
1093
  }
575
1094
  async function ensureLocalghostContext(options, vitePort, https) {
576
1095
  const cwd = options.cwd ?? process.cwd();
@@ -606,16 +1125,91 @@ async function ensureLocalghostContext(options, vitePort, https) {
606
1125
  }
607
1126
  return context;
608
1127
  }
1128
+ function isConcreteGhostUrl(url) {
1129
+ return !url.includes("*") && !url.includes("<") && /^https?:\/\//.test(url);
1130
+ }
1131
+ function getConcreteGhostUrls(context) {
1132
+ return context.ghostTunnel.displayUrls.filter(isConcreteGhostUrl);
1133
+ }
1134
+ function openExternalUrl(url) {
1135
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1136
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
1137
+ const child = spawn(command, args, {
1138
+ detached: true,
1139
+ stdio: "ignore"
1140
+ });
1141
+ child.unref();
1142
+ }
1143
+ function installGhostTunnelMenu(server, context) {
1144
+ if (!context?.ghostTunnel.enabled || !process.stdin.isTTY) return void 0;
1145
+ emitKeypressEvents(process.stdin);
1146
+ let active = false;
1147
+ const concreteUrls = getConcreteGhostUrls(context);
1148
+ const printMenu = () => {
1149
+ active = true;
1150
+ const lines = [
1151
+ "",
1152
+ formatGhostTunnel(context.ghostTunnel, {
1153
+ color: shouldColor(),
1154
+ label: "ready",
1155
+ verbose: true
1156
+ }) ?? "localghost ghost tunnel",
1157
+ ""
1158
+ ];
1159
+ if (concreteUrls.length === 0) {
1160
+ lines.push(" No concrete Ghost Tunnel domain configured.");
1161
+ lines.push(" Add ghostTunnel.domains to localghost.config.mjs to open a URL from this menu.");
1162
+ active = false;
1163
+ } else {
1164
+ concreteUrls.forEach((url, index) => {
1165
+ lines.push(` ${index + 1}. ${url}`);
1166
+ });
1167
+ lines.push("");
1168
+ lines.push(" Press a number to open, or escape to cancel.");
1169
+ }
1170
+ server.config.logger.info(lines.join("\n"), {
1171
+ clear: false,
1172
+ timestamp: false
1173
+ });
1174
+ };
1175
+ const onKeypress = (_input, key = {}) => {
1176
+ if (key.ctrl && key.name === "c") return;
1177
+ if (!active) {
1178
+ if (key.name === "g") printMenu();
1179
+ return;
1180
+ }
1181
+ if (key.name === "escape") {
1182
+ active = false;
1183
+ return;
1184
+ }
1185
+ const index = Number.parseInt(key.name ?? "", 10) - 1;
1186
+ const url = concreteUrls[index];
1187
+ if (!url) return;
1188
+ active = false;
1189
+ openExternalUrl(url);
1190
+ server.config.logger.info(`localghost opened ${url}`, {
1191
+ clear: false,
1192
+ timestamp: false
1193
+ });
1194
+ };
1195
+ process.stdin.on("keypress", onKeypress);
1196
+ return () => {
1197
+ process.stdin.off("keypress", onKeypress);
1198
+ };
1199
+ }
609
1200
  function localGhostPlugin(options = {}) {
610
1201
  let resolvedEntries = [];
611
1202
  let resolvedVitePort;
612
1203
  let resolvedHttps = false;
1204
+ let resolvedContext;
613
1205
  let restartTimer;
1206
+ let activityRunId;
614
1207
  return {
615
1208
  name: "localghost:vite",
616
1209
  enforce: "pre",
617
1210
  async config(userConfig, configEnv) {
618
1211
  if (configEnv.command !== "serve" || configEnv.mode === "production" || isProductionLike()) {
1212
+ await maybePrintBuildGhostTunnel(options);
619
1213
  return {};
620
1214
  }
621
1215
  const existingServer = userConfig.server ?? {};
@@ -628,6 +1222,7 @@ function localGhostPlugin(options = {}) {
628
1222
  resolvedEntries = entries;
629
1223
  resolvedVitePort = context.port;
630
1224
  resolvedHttps = context.https;
1225
+ resolvedContext = context;
631
1226
  const server = {
632
1227
  ...existingServer,
633
1228
  allowedHosts: mergeAllowedHosts(existingServer.allowedHosts, hosts),
@@ -687,9 +1282,38 @@ function localGhostPlugin(options = {}) {
687
1282
  server.watcher.on("unlink", restartOnLocalghostConfigChange);
688
1283
  if (options.log !== false) {
689
1284
  server.printUrls = () => {
690
- printLocalHosts(server, resolvedEntries, resolvedVitePort, resolvedHttps);
1285
+ printLocalHosts(server, resolvedContext);
691
1286
  };
692
1287
  }
1288
+ if (resolvedContext) {
1289
+ const run = registerLocalghostRun({
1290
+ id: `${resolvedContext.projectName}:vite:${process.pid}:${resolvedContext.cwd}`,
1291
+ mode: "vite",
1292
+ pid: process.pid,
1293
+ cwd: resolvedContext.cwd,
1294
+ projectName: resolvedContext.projectName,
1295
+ configPath: resolvedContext.configPath,
1296
+ childCommand: ["vite"],
1297
+ https: resolvedContext.https,
1298
+ requestedPort: resolvedContext.requestedPort,
1299
+ port: resolvedContext.port,
1300
+ dynamicPort: resolvedContext.dynamicPort,
1301
+ entries: resolvedContext.entries
1302
+ });
1303
+ activityRunId = run.id;
1304
+ }
1305
+ const cleanupGhostMenu = installGhostTunnelMenu(server, resolvedContext);
1306
+ const cleanupActivity = () => {
1307
+ if (!activityRunId) return;
1308
+ unregisterLocalghostRun(activityRunId);
1309
+ activityRunId = void 0;
1310
+ };
1311
+ const cleanup = () => {
1312
+ cleanupActivity();
1313
+ cleanupGhostMenu?.();
1314
+ };
1315
+ server.httpServer?.once("close", cleanup);
1316
+ process.once("exit", cleanup);
693
1317
  }
694
1318
  };
695
1319
  }