hexbus 0.1.0 → 0.1.1

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.
Files changed (4) hide show
  1. package/README.md +133 -10
  2. package/dist/index.d.mts +1216 -36
  3. package/dist/index.mjs +959 -277
  4. package/package.json +45 -46
package/dist/index.mjs CHANGED
@@ -5,42 +5,44 @@ import fs from "node:fs/promises";
5
5
  import * as path$1 from "node:path";
6
6
  import path from "node:path";
7
7
  import * as p from "@clack/prompts";
8
+ import { spinner } from "@clack/prompts";
8
9
  import { loadConfig } from "c12";
9
- import color from "picocolors";
10
+ import { promisify } from "node:util";
10
11
  import figlet from "figlet";
11
12
  import * as os from "node:os";
13
+ import { createHash, randomUUID } from "node:crypto";
12
14
  //#region src/detection.ts
13
15
  const LOCK_FILE_MAP = {
14
- "bun.lockb": "bun",
15
16
  "bun.lock": "bun",
17
+ "bun.lockb": "bun",
18
+ "package-lock.json": "npm",
16
19
  "pnpm-lock.yaml": "pnpm",
17
- "yarn.lock": "yarn",
18
- "package-lock.json": "npm"
20
+ "yarn.lock": "yarn"
19
21
  };
20
22
  const PACKAGE_MANAGER_CONFIG = {
21
23
  bun: {
22
- installCommand: "bun install",
23
24
  addCommand: "bun add",
24
- runCommand: "bun run",
25
- execCommand: "bunx"
25
+ execCommand: "bunx",
26
+ installCommand: "bun install",
27
+ runCommand: "bun run"
28
+ },
29
+ npm: {
30
+ addCommand: "npm install",
31
+ execCommand: "npx",
32
+ installCommand: "npm install",
33
+ runCommand: "npm run"
26
34
  },
27
35
  pnpm: {
28
- installCommand: "pnpm install",
29
36
  addCommand: "pnpm add",
30
- runCommand: "pnpm",
31
- execCommand: "pnpm dlx"
37
+ execCommand: "pnpm dlx",
38
+ installCommand: "pnpm install",
39
+ runCommand: "pnpm"
32
40
  },
33
41
  yarn: {
34
- installCommand: "yarn",
35
42
  addCommand: "yarn add",
36
- runCommand: "yarn",
37
- execCommand: "yarn dlx"
38
- },
39
- npm: {
40
- installCommand: "npm install",
41
- addCommand: "npm install",
42
- runCommand: "npm run",
43
- execCommand: "npx"
43
+ execCommand: "yarn dlx",
44
+ installCommand: "yarn",
45
+ runCommand: "yarn"
44
46
  }
45
47
  };
46
48
  async function readPackageJson(projectRoot) {
@@ -48,6 +50,21 @@ async function readPackageJson(projectRoot) {
48
50
  const content = await fs.readFile(packageJsonPath, "utf-8");
49
51
  return JSON.parse(content);
50
52
  }
53
+ /**
54
+ * Detects common frontend frameworks from project dependencies.
55
+ *
56
+ * @remarks
57
+ * Detection reads the nearest project's `package.json` and looks at
58
+ * dependencies and devDependencies. It recognizes Next.js, Remix, Vite React,
59
+ * Gatsby, generic React, Tailwind CSS, and a configured core fallback.
60
+ *
61
+ * @typeParam TPackage - Product-specific package identifier returned in
62
+ * `FrameworkDetectionResult.pkg`.
63
+ * @param projectRoot - Directory containing the package.json to inspect.
64
+ * @param logger - Optional logger for debug diagnostics.
65
+ * @param packageMap - Product packages to select for detected frameworks.
66
+ * @returns Framework metadata and the selected product package identifier.
67
+ */
51
68
  async function detectFramework(projectRoot, logger, packageMap = {}) {
52
69
  try {
53
70
  logger?.debug(`Detecting framework in ${projectRoot}`);
@@ -86,8 +103,8 @@ async function detectFramework(projectRoot, logger, packageMap = {}) {
86
103
  return {
87
104
  framework,
88
105
  frameworkVersion,
89
- pkg,
90
106
  hasReact,
107
+ pkg,
91
108
  reactVersion: reactVersion ?? null,
92
109
  tailwindVersion
93
110
  };
@@ -96,13 +113,25 @@ async function detectFramework(projectRoot, logger, packageMap = {}) {
96
113
  return {
97
114
  framework: null,
98
115
  frameworkVersion: null,
99
- pkg: packageMap.core ?? null,
100
116
  hasReact: false,
117
+ pkg: packageMap.core ?? null,
101
118
  reactVersion: null,
102
119
  tailwindVersion: null
103
120
  };
104
121
  }
105
122
  }
123
+ /**
124
+ * Finds the nearest project root by walking up to a package.json.
125
+ *
126
+ * @remarks
127
+ * The search is capped at ten directory levels to avoid surprising filesystem
128
+ * traversal. When no root is found, the original current working directory is
129
+ * returned and a warning is logged.
130
+ *
131
+ * @param cwd - Directory where invocation started.
132
+ * @param logger - Optional logger for warning output.
133
+ * @returns The detected project root or `cwd` as a fallback.
134
+ */
106
135
  async function detectProjectRoot(cwd, logger) {
107
136
  let projectRoot = cwd;
108
137
  let previousDirectory = "";
@@ -143,30 +172,45 @@ async function promptForPackageManager(logger) {
143
172
  message: "Which package manager do you use?",
144
173
  options: [
145
174
  {
146
- value: "bun",
175
+ hint: "Fast all-in-one toolkit",
147
176
  label: "bun",
148
- hint: "Fast all-in-one toolkit"
177
+ value: "bun"
149
178
  },
150
179
  {
151
- value: "pnpm",
180
+ hint: "Fast, disk space efficient",
152
181
  label: "pnpm",
153
- hint: "Fast, disk space efficient"
182
+ value: "pnpm"
154
183
  },
155
184
  {
156
- value: "yarn",
185
+ hint: "Classic package manager",
157
186
  label: "yarn",
158
- hint: "Classic package manager"
187
+ value: "yarn"
159
188
  },
160
189
  {
161
- value: "npm",
190
+ hint: "Default Node.js package manager",
162
191
  label: "npm",
163
- hint: "Default Node.js package manager"
192
+ value: "npm"
164
193
  }
165
194
  ]
166
195
  });
167
196
  if (p.isCancel(result)) throw new Error("Package manager selection cancelled");
168
197
  return result;
169
198
  }
199
+ /**
200
+ * Detects the package manager used by a project.
201
+ *
202
+ * @remarks
203
+ * Detection prefers lockfiles, then the `packageManager` field in
204
+ * `package.json`, then an optional interactive prompt, and finally `npm`.
205
+ *
206
+ * @param projectRoot - Directory to inspect for lockfiles and package.json.
207
+ * @param logger - Optional logger for debug output.
208
+ * @param options - Detection options.
209
+ * @returns Command templates for the detected package manager.
210
+ *
211
+ * @throws When interactive prompting is enabled and the user cancels package
212
+ * manager selection.
213
+ */
170
214
  async function detectPackageManager(projectRoot, logger, options) {
171
215
  let pm = await detectFromLockFile(projectRoot, logger);
172
216
  if (!pm) pm = await detectFromPackageJson(projectRoot, logger);
@@ -180,55 +224,129 @@ async function detectPackageManager(projectRoot, logger, options) {
180
224
  ...PACKAGE_MANAGER_CONFIG[pm]
181
225
  };
182
226
  }
227
+ /**
228
+ * Builds a dependency installation command for the detected package manager.
229
+ *
230
+ * @param pm - Package manager command templates.
231
+ * @param packages - Package names to install.
232
+ * @param options - Install command options.
233
+ * @returns A shell command string suitable for display or execution.
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * getInstallCommand(pm, ['typescript'], { dev: true });
238
+ * ```
239
+ */
183
240
  function getInstallCommand(pm, packages, options) {
184
241
  const pkgList = packages.join(" ");
185
- const devFlag = options?.dev ? pm.name === "npm" ? "--save-dev" : "-D" : "";
186
- return `${pm.addCommand} ${devFlag} ${pkgList}`.trim().replace(/\s+/g, " ");
242
+ let devFlag = "";
243
+ if (options?.dev) devFlag = pm.name === "npm" ? "--save-dev" : "-D";
244
+ return `${pm.addCommand} ${devFlag} ${pkgList}`.trim().replaceAll(/\s+/g, " ");
187
245
  }
246
+ /**
247
+ * Builds a package-script command for the detected package manager.
248
+ *
249
+ * @param pm - Package manager command templates.
250
+ * @param script - Package script name to run.
251
+ * @returns A shell command string.
252
+ */
188
253
  function getRunCommand(pm, script) {
189
254
  return `${pm.runCommand} ${script}`;
190
255
  }
256
+ /**
257
+ * Builds a one-off binary execution command for the detected package manager.
258
+ *
259
+ * @param pm - Package manager command templates.
260
+ * @param binary - Binary name to execute.
261
+ * @param args - Optional arguments appended after the binary name.
262
+ * @returns A shell command string.
263
+ */
191
264
  function getExecCommand(pm, binary, args) {
192
265
  const argString = args?.join(" ") || "";
193
266
  return `${pm.execCommand} ${binary} ${argString}`.trim();
194
267
  }
195
268
  //#endregion
196
269
  //#region src/errors.ts
270
+ /**
271
+ * Built-in errors used by the Hexbus runtime.
272
+ */
197
273
  const DEFAULT_ERROR_CATALOG = {
274
+ CANCELLED: {
275
+ code: "CANCELLED",
276
+ message: "Operation cancelled"
277
+ },
278
+ COMMAND_NOT_FOUND: {
279
+ code: "COMMAND_NOT_FOUND",
280
+ hint: "Run --help to see available commands",
281
+ message: "Unknown command"
282
+ },
198
283
  CONFIG_NOT_FOUND: {
199
284
  code: "CONFIG_NOT_FOUND",
200
- message: "Configuration not found",
201
- hint: "Run the setup command to create a configuration"
285
+ hint: "Run the setup command to create a configuration",
286
+ message: "Configuration not found"
202
287
  },
203
288
  FLAG_VALUE_REQUIRED: {
204
289
  code: "FLAG_VALUE_REQUIRED",
205
290
  message: "Flag requires a value"
206
291
  },
207
- COMMAND_NOT_FOUND: {
208
- code: "COMMAND_NOT_FOUND",
209
- message: "Unknown command",
210
- hint: "Run --help to see available commands"
211
- },
212
- CANCELLED: {
213
- code: "CANCELLED",
214
- message: "Operation cancelled"
215
- },
216
292
  UNKNOWN_ERROR: {
217
293
  code: "UNKNOWN_ERROR",
218
294
  message: "An unexpected error occurred"
219
295
  }
220
296
  };
221
297
  let activeCatalog = { ...DEFAULT_ERROR_CATALOG };
298
+ /**
299
+ * Adds or replaces entries in the active error catalog.
300
+ *
301
+ * @remarks
302
+ * This mutates process-local catalog state. Product CLIs should call it once
303
+ * during startup before command actions create `CliError` instances.
304
+ *
305
+ * @param entries - Error entries keyed by their stable code.
306
+ *
307
+ * @example
308
+ * ```ts
309
+ * extendErrorCatalog({
310
+ * PROJECT_NOT_READY: {
311
+ * code: 'PROJECT_NOT_READY',
312
+ * message: 'Project is not ready',
313
+ * hint: 'Run init before running this command.',
314
+ * },
315
+ * });
316
+ * ```
317
+ */
222
318
  function extendErrorCatalog(entries) {
223
319
  activeCatalog = {
224
320
  ...activeCatalog,
225
321
  ...entries
226
322
  };
227
323
  }
324
+ /**
325
+ * Error type that carries a catalog entry and optional structured context.
326
+ *
327
+ * @remarks
328
+ * `CliError` keeps command code focused on stable error codes while shared
329
+ * handlers render the configured message, hint, and documentation link.
330
+ */
228
331
  var CliError = class CliError extends Error {
332
+ /**
333
+ * Error code requested by the caller.
334
+ */
229
335
  code;
336
+ /**
337
+ * Structured diagnostic details attached by the caller.
338
+ */
230
339
  context;
340
+ /**
341
+ * Catalog entry used to render this error.
342
+ */
231
343
  entry;
344
+ /**
345
+ * Creates a catalog-backed CLI error.
346
+ *
347
+ * @param code - Built-in or product-defined error code.
348
+ * @param context - Optional diagnostic details for rendering or telemetry.
349
+ */
232
350
  constructor(code, context) {
233
351
  const entry = activeCatalog[code] ?? activeCatalog.UNKNOWN_ERROR ?? DEFAULT_ERROR_CATALOG.UNKNOWN_ERROR;
234
352
  super(entry.message);
@@ -238,13 +356,27 @@ var CliError = class CliError extends Error {
238
356
  this.entry = entry;
239
357
  if (Error.captureStackTrace) Error.captureStackTrace(this, CliError);
240
358
  }
359
+ /**
360
+ * Renders the error message, hint, and docs link through a logger.
361
+ *
362
+ * @param logger - Logger used for user-facing output.
363
+ */
241
364
  display(logger) {
242
- let message = this.entry.message;
365
+ let { message } = this.entry;
243
366
  if (this.context?.details) message += `: ${this.context.details}`;
244
367
  logger.error(message);
245
368
  if (this.entry.hint) logger.info(`Hint: ${this.entry.hint}`);
246
369
  if (this.entry.docs) logger.info(`Docs: ${this.entry.docs}`);
247
370
  }
371
+ /**
372
+ * Normalizes an unknown thrown value into a `CliError`.
373
+ *
374
+ * @param error - Value caught from a `try`/`catch` block.
375
+ * @param fallbackCode - Catalog code to use when `error` is not already a
376
+ * `CliError`.
377
+ * @returns `error` unchanged when it is already a `CliError`, otherwise a
378
+ * wrapped `CliError`.
379
+ */
248
380
  static from(error, fallbackCode = "UNKNOWN_ERROR") {
249
381
  if (error instanceof CliError) return error;
250
382
  return new CliError(fallbackCode, {
@@ -253,31 +385,59 @@ var CliError = class CliError extends Error {
253
385
  });
254
386
  }
255
387
  };
388
+ /**
389
+ * Narrows an unknown value to a `CliError`.
390
+ *
391
+ * @param error - Value to inspect.
392
+ * @param code - Optional code that must match the error.
393
+ * @returns `true` when the value is a `CliError` and, if provided, matches the
394
+ * requested code.
395
+ */
256
396
  function isCliError(error, code) {
257
397
  if (!(error instanceof CliError)) return false;
258
398
  if (code) return error.code === code;
259
399
  return true;
260
400
  }
401
+ /**
402
+ * Creates shared process-ending error and cancellation handlers.
403
+ *
404
+ * @param logger - Logger used to render messages.
405
+ * @param telemetry - Optional telemetry sink for failed command errors.
406
+ * @returns Error handlers suitable for `CliContext.error`.
407
+ */
261
408
  function createErrorHandlers(logger, telemetry) {
262
409
  return {
410
+ handleCancel(message = "Operation cancelled", context) {
411
+ logger.warn(message);
412
+ if (context?.command) logger.info(`Command: ${context.command}`);
413
+ process.exit(0);
414
+ },
263
415
  handleError(error, command) {
264
416
  const cliError = CliError.from(error);
265
417
  try {
266
418
  telemetry?.trackError(cliError, command);
267
- } catch (error) {
268
- const message = error instanceof Error ? error.message : String(error);
419
+ } catch (telemetryError) {
420
+ const message = telemetryError instanceof Error ? telemetryError.message : String(telemetryError);
269
421
  logger.warn(`Failed to track error telemetry: ${message}`);
270
422
  }
271
423
  cliError.display(logger);
272
424
  process.exit(1);
273
- },
274
- handleCancel(message = "Operation cancelled", context) {
275
- logger.warn(message);
276
- if (context?.command) logger.info(`Command: ${context.command}`);
277
- process.exit(0);
278
425
  }
279
426
  };
280
427
  }
428
+ /**
429
+ * Wraps an async function with CLI-style error rendering and process exit.
430
+ *
431
+ * @remarks
432
+ * This helper is useful near process entrypoints. Library-style code should
433
+ * usually throw `CliError` and let the caller decide when to exit.
434
+ *
435
+ * @typeParam T - Async function type to preserve.
436
+ * @param fn - Async function to run.
437
+ * @param logger - Logger used to render caught errors.
438
+ * @param context - Optional command metadata shown after an error.
439
+ * @returns A function with the same call signature as `fn`.
440
+ */
281
441
  function withErrorHandling(fn, logger, context) {
282
442
  return (async (...args) => {
283
443
  try {
@@ -290,19 +450,141 @@ function withErrorHandling(fn, logger, context) {
290
450
  });
291
451
  }
292
452
  //#endregion
453
+ //#region src/color.ts
454
+ const replaceClose = (string, close, replace, startIndex) => {
455
+ let result = "";
456
+ let cursor = 0;
457
+ let index = startIndex;
458
+ do {
459
+ result += string.slice(cursor, index) + replace;
460
+ cursor = index + close.length;
461
+ index = string.indexOf(close, cursor);
462
+ } while (index !== -1);
463
+ return result + string.slice(cursor);
464
+ };
465
+ const createFormatter = (open, close, replace = open) => (input) => {
466
+ const string = String(input);
467
+ const index = string.indexOf(close, open.length);
468
+ return index === -1 ? open + string + close : open + replaceClose(string, close, replace, index) + close;
469
+ };
470
+ const createPlainFormatter = () => String;
471
+ /**
472
+ * Detects whether ANSI color output should be enabled.
473
+ *
474
+ * @remarks
475
+ * Detection follows common CLI conventions: `NO_COLOR`, `--no-color`, and
476
+ * `FORCE_COLOR=0` disable colors; `FORCE_COLOR` and `--color` enable colors;
477
+ * Windows defaults to enabled; otherwise TTY, non-dumb terminals, and CI
478
+ * environments are considered color-capable.
479
+ *
480
+ * @param options - Optional process-like inputs for detection.
481
+ * @returns `true` when color formatters should emit ANSI escape codes.
482
+ */
483
+ function detectColorSupport(options = {}) {
484
+ const argv = options.argv ?? process.argv;
485
+ const env = options.env ?? process.env;
486
+ const platform = options.platform ?? process.platform;
487
+ const stdout = options.stdout ?? process.stdout;
488
+ const forceColor = env.FORCE_COLOR ? env.FORCE_COLOR.toLowerCase() : void 0;
489
+ const hasNoColor = !!env.NO_COLOR;
490
+ const isCI = !!env.CI;
491
+ const isForceColorDisabled = forceColor === "0" || forceColor === "false";
492
+ if (hasNoColor || argv.includes("--no-color") || isForceColorDisabled) return false;
493
+ if (forceColor !== void 0 && forceColor !== "0" && forceColor !== "false" || argv.includes("--color")) return true;
494
+ if (platform === "win32") return true;
495
+ return Boolean(stdout.isTTY) && env.TERM !== "dumb" || isCI;
496
+ }
497
+ /**
498
+ * Process-level color support detected at module load time.
499
+ */
500
+ const isColorSupported = detectColorSupport();
501
+ /**
502
+ * Creates a complete set of color and style formatters.
503
+ *
504
+ * @param enabled - Whether returned formatters should emit ANSI escape codes.
505
+ * @returns A `Colors` object with either active ANSI formatters or plain string
506
+ * pass-through formatters.
507
+ */
508
+ function createColors(enabled = isColorSupported) {
509
+ const formatter = enabled ? createFormatter : createPlainFormatter;
510
+ return {
511
+ bgBlack: formatter("\x1B[40m", "\x1B[49m"),
512
+ bgBlackBright: formatter("\x1B[100m", "\x1B[49m"),
513
+ bgBlue: formatter("\x1B[44m", "\x1B[49m"),
514
+ bgBlueBright: formatter("\x1B[104m", "\x1B[49m"),
515
+ bgCyan: formatter("\x1B[46m", "\x1B[49m"),
516
+ bgCyanBright: formatter("\x1B[106m", "\x1B[49m"),
517
+ bgGreen: formatter("\x1B[42m", "\x1B[49m"),
518
+ bgGreenBright: formatter("\x1B[102m", "\x1B[49m"),
519
+ bgMagenta: formatter("\x1B[45m", "\x1B[49m"),
520
+ bgMagentaBright: formatter("\x1B[105m", "\x1B[49m"),
521
+ bgRed: formatter("\x1B[41m", "\x1B[49m"),
522
+ bgRedBright: formatter("\x1B[101m", "\x1B[49m"),
523
+ bgWhite: formatter("\x1B[47m", "\x1B[49m"),
524
+ bgWhiteBright: formatter("\x1B[107m", "\x1B[49m"),
525
+ bgYellow: formatter("\x1B[43m", "\x1B[49m"),
526
+ bgYellowBright: formatter("\x1B[103m", "\x1B[49m"),
527
+ black: formatter("\x1B[30m", "\x1B[39m"),
528
+ blackBright: formatter("\x1B[90m", "\x1B[39m"),
529
+ blue: formatter("\x1B[34m", "\x1B[39m"),
530
+ blueBright: formatter("\x1B[94m", "\x1B[39m"),
531
+ bold: formatter("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
532
+ cyan: formatter("\x1B[36m", "\x1B[39m"),
533
+ cyanBright: formatter("\x1B[96m", "\x1B[39m"),
534
+ dim: formatter("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
535
+ gray: formatter("\x1B[90m", "\x1B[39m"),
536
+ green: formatter("\x1B[32m", "\x1B[39m"),
537
+ greenBright: formatter("\x1B[92m", "\x1B[39m"),
538
+ hidden: formatter("\x1B[8m", "\x1B[28m"),
539
+ inverse: formatter("\x1B[7m", "\x1B[27m"),
540
+ isColorSupported: enabled,
541
+ italic: formatter("\x1B[3m", "\x1B[23m"),
542
+ magenta: formatter("\x1B[35m", "\x1B[39m"),
543
+ magentaBright: formatter("\x1B[95m", "\x1B[39m"),
544
+ red: formatter("\x1B[31m", "\x1B[39m"),
545
+ redBright: formatter("\x1B[91m", "\x1B[39m"),
546
+ reset: formatter("\x1B[0m", "\x1B[0m"),
547
+ strikethrough: formatter("\x1B[9m", "\x1B[29m"),
548
+ underline: formatter("\x1B[4m", "\x1B[24m"),
549
+ white: formatter("\x1B[37m", "\x1B[39m"),
550
+ whiteBright: formatter("\x1B[97m", "\x1B[39m"),
551
+ yellow: formatter("\x1B[33m", "\x1B[39m"),
552
+ yellowBright: formatter("\x1B[93m", "\x1B[39m")
553
+ };
554
+ }
555
+ /**
556
+ * Default color formatter set for the current process.
557
+ *
558
+ * @remarks
559
+ * Use `color.createColors(false)` when tests need deterministic plain-text
560
+ * output.
561
+ */
562
+ const color = Object.assign(createColors(), { createColors });
563
+ /**
564
+ * Named color and style formatter exports from the default `color` object.
565
+ */
566
+ const { reset, bold, dim, italic, underline, inverse, hidden, strikethrough, black, red, green, yellow, blue, magenta, cyan, white, gray, bgBlack, bgRed, bgGreen, bgYellow, bgBlue, bgMagenta, bgCyan, bgWhite, blackBright, redBright, greenBright, yellowBright, blueBright, magentaBright, cyanBright, whiteBright, bgBlackBright, bgRedBright, bgGreenBright, bgYellowBright, bgBlueBright, bgMagentaBright, bgCyanBright, bgWhiteBright } = color;
567
+ //#endregion
293
568
  //#region src/logger.ts
569
+ /**
570
+ * Supported log levels in ascending verbosity.
571
+ */
294
572
  const LOG_LEVELS = [
295
573
  "error",
296
574
  "warn",
297
575
  "info",
298
576
  "debug"
299
577
  ];
578
+ /**
579
+ * Alias for `LOG_LEVELS` kept for callers that prefer validation-oriented
580
+ * naming.
581
+ */
300
582
  const validLogLevels = LOG_LEVELS;
301
583
  const LOG_LEVEL_PRIORITY = {
584
+ debug: 3,
302
585
  error: 0,
303
- warn: 1,
304
586
  info: 2,
305
- debug: 3
587
+ warn: 1
306
588
  };
307
589
  function safeStringify(arg) {
308
590
  try {
@@ -315,6 +597,14 @@ function formatArgs(args) {
315
597
  if (args.length === 0) return "";
316
598
  return `\n${args.map((arg) => ` - ${safeStringify(arg)}`).join("\n")}`;
317
599
  }
600
+ /**
601
+ * Formats a log message with a level badge and optional structured arguments.
602
+ *
603
+ * @param logLevel - Log level or custom badge label.
604
+ * @param message - Primary message to render.
605
+ * @param args - Additional values rendered as indented JSON-like bullets.
606
+ * @returns A formatted message string.
607
+ */
318
608
  function formatLogMessage(logLevel, message, args = []) {
319
609
  const messageStr = typeof message === "string" ? message : String(message);
320
610
  const formattedArgs = formatArgs(args);
@@ -328,6 +618,13 @@ function formatLogMessage(logLevel, message, args = []) {
328
618
  default: return `[${logLevel.toUpperCase()}] ${messageStr}${formattedArgs}`;
329
619
  }
330
620
  }
621
+ /**
622
+ * Emits a formatted message through the prompt logger.
623
+ *
624
+ * @param logLevel - Log level or custom badge label.
625
+ * @param message - Primary message to render.
626
+ * @param args - Additional values rendered below the message.
627
+ */
331
628
  function logMessage(logLevel, message, ...args) {
332
629
  const formattedMessage = formatLogMessage(logLevel, message, args);
333
630
  switch (logLevel) {
@@ -348,11 +645,35 @@ function logMessage(logLevel, message, ...args) {
348
645
  default: p.log.message(formattedMessage);
349
646
  }
350
647
  }
648
+ /**
649
+ * Formats a bounded progress step indicator.
650
+ *
651
+ * @remarks
652
+ * `current` is clamped between `0` and `total`, and `total` is clamped to at
653
+ * least `0` so malformed progress values still render predictably.
654
+ *
655
+ * @param current - Current step number.
656
+ * @param total - Total number of steps.
657
+ * @param label - Step label shown after the progress bar.
658
+ * @returns A formatted progress row.
659
+ */
351
660
  function formatStep(current, total, label) {
352
661
  const safeTotal = Math.max(0, total);
353
662
  const safeCurrent = Math.min(Math.max(0, current), safeTotal);
354
663
  return `[${color.green("█".repeat(safeCurrent))}${color.dim("░".repeat(safeTotal - safeCurrent))}] Step ${safeCurrent}/${safeTotal}: ${label}`;
355
664
  }
665
+ /**
666
+ * Creates a `CliLogger` backed by Clack prompt output.
667
+ *
668
+ * @remarks
669
+ * Messages below the configured verbosity are ignored for `debug`, `info`,
670
+ * `warn`, and `error`. Other output helpers such as `message`, `note`,
671
+ * `success`, `failed`, and `outro` always render because they represent
672
+ * explicit user interaction states rather than diagnostic verbosity.
673
+ *
674
+ * @param level - Minimum log level to emit.
675
+ * @returns A logger suitable for `CliContext.logger`.
676
+ */
356
677
  function createCliLogger(level = "info") {
357
678
  const currentLevelPriority = LOG_LEVEL_PRIORITY[level];
358
679
  const shouldLog = (targetLevel) => LOG_LEVEL_PRIORITY[targetLevel] <= currentLevelPriority;
@@ -360,91 +681,112 @@ function createCliLogger(level = "info") {
360
681
  debug(message, ...args) {
361
682
  if (shouldLog("debug")) logMessage("debug", message, ...args);
362
683
  },
363
- info(message, ...args) {
364
- if (shouldLog("info")) logMessage("info", message, ...args);
365
- },
366
- warn(message, ...args) {
367
- if (shouldLog("warn")) logMessage("warn", message, ...args);
368
- },
369
684
  error(message, ...args) {
370
685
  if (shouldLog("error")) logMessage("error", message, ...args);
371
686
  },
687
+ failed(message, exitCode = 1) {
688
+ logMessage("failed", message);
689
+ process.exit(exitCode);
690
+ },
691
+ info(message, ...args) {
692
+ if (shouldLog("info")) logMessage("info", message, ...args);
693
+ },
372
694
  message(message) {
373
695
  p.log.message(message);
374
696
  },
375
697
  note(content, title) {
376
698
  p.note(content, title, { format: (line) => line });
377
699
  },
378
- success(message) {
379
- logMessage("success", message);
380
- },
381
- failed(message, exitCode = 1) {
382
- logMessage("failed", message);
383
- process.exit(exitCode);
384
- },
385
700
  outro(message) {
386
701
  p.outro(message);
387
702
  },
388
703
  step(current, total, label) {
389
704
  p.log.step(formatStep(current, total, label));
705
+ },
706
+ success(message) {
707
+ logMessage("success", message);
708
+ },
709
+ warn(message, ...args) {
710
+ if (shouldLog("warn")) logMessage("warn", message, ...args);
390
711
  }
391
712
  };
392
713
  }
393
714
  //#endregion
394
715
  //#region src/parser.ts
716
+ /**
717
+ * Built-in flags parsed for every Hexbus CLI invocation.
718
+ *
719
+ * @remarks
720
+ * Primary flag names are derived from the first long flag in each entry. For
721
+ * example, `--no-telemetry` is exposed as `parsedFlags['no-telemetry']`.
722
+ */
395
723
  const globalFlags = [
396
724
  {
397
- names: ["--help", "-h"],
398
725
  description: "Show this help message",
399
- type: "special",
400
- expectsValue: false
726
+ expectsValue: false,
727
+ names: ["--help", "-h"],
728
+ type: "special"
401
729
  },
402
730
  {
403
- names: ["--version", "-v"],
404
731
  description: "Show the CLI version",
405
- type: "special",
406
- expectsValue: false
732
+ expectsValue: false,
733
+ names: ["--version", "-v"],
734
+ type: "special"
407
735
  },
408
736
  {
409
- names: ["--logger"],
737
+ defaultValue: "info",
410
738
  description: "Set log level (error, warn, info, debug)",
411
- type: "string",
412
739
  expectsValue: true,
413
- defaultValue: "info"
740
+ names: ["--logger"],
741
+ type: "string"
742
+ },
743
+ {
744
+ defaultValue: false,
745
+ description: "Force color output",
746
+ expectsValue: false,
747
+ names: ["--color"],
748
+ type: "boolean"
749
+ },
750
+ {
751
+ defaultValue: false,
752
+ description: "Disable color output",
753
+ expectsValue: false,
754
+ names: ["--no-color"],
755
+ type: "boolean"
414
756
  },
415
757
  {
416
- names: ["--config"],
417
758
  description: "Specify path to configuration file",
418
- type: "string",
419
- expectsValue: true
759
+ expectsValue: true,
760
+ names: ["--config"],
761
+ type: "string"
420
762
  },
421
763
  {
422
- names: ["-y", "--yes"],
764
+ defaultValue: false,
423
765
  description: "Skip confirmation prompts",
424
- type: "boolean",
425
766
  expectsValue: false,
426
- defaultValue: false
767
+ names: ["-y", "--yes"],
768
+ type: "boolean"
427
769
  },
428
770
  {
429
- names: ["--no-telemetry"],
771
+ defaultValue: false,
430
772
  description: "Disable telemetry data collection",
431
- type: "boolean",
432
773
  expectsValue: false,
433
- defaultValue: false
774
+ names: ["--no-telemetry"],
775
+ type: "boolean"
434
776
  },
435
777
  {
436
- names: ["--telemetry-debug"],
778
+ defaultValue: false,
437
779
  description: "Enable debug mode for telemetry",
438
- type: "boolean",
439
780
  expectsValue: false,
440
- defaultValue: false
781
+ names: ["--telemetry-debug"],
782
+ type: "boolean"
441
783
  },
442
784
  {
443
- names: ["--force"],
785
+ defaultValue: false,
444
786
  description: "Force operation even if files exist",
445
- type: "boolean",
446
787
  expectsValue: false,
447
- defaultValue: false
788
+ names: ["--force"],
789
+ type: "boolean"
448
790
  }
449
791
  ];
450
792
  function getPrimaryFlagName(flag) {
@@ -452,11 +794,32 @@ function getPrimaryFlagName(flag) {
452
794
  const fallback = flag.names.reduce((longest, name) => name.length > longest.length ? name : longest, "");
453
795
  return (longName ?? fallback).replace(/^--?/, "");
454
796
  }
797
+ /**
798
+ * Parses raw command-line arguments into command name, command args, and
799
+ * global flags.
800
+ *
801
+ * @remarks
802
+ * Only flags declared in `globalFlags` are parsed. Unknown flags and
803
+ * positional arguments are preserved as command arguments unless the first
804
+ * positional argument matches a registered top-level command.
805
+ *
806
+ * @param rawArgs - Arguments after the executable and script path.
807
+ * @param commands - Top-level commands used to identify the command name.
808
+ * @returns Normalized parsed arguments for context creation or custom routing.
809
+ *
810
+ * @example
811
+ * ```ts
812
+ * const parsed = parseCliArgs(['init', '--logger', 'debug'], commands);
813
+ * // parsed.commandName === 'init'
814
+ * // parsed.parsedFlags.logger === 'debug'
815
+ * ```
816
+ */
455
817
  function parseCliArgs(rawArgs, commands) {
456
818
  const parsedFlags = {};
457
819
  const potentialCommandArgs = [];
458
820
  let commandName;
459
821
  const commandArgs = [];
822
+ const knownFlagSet = new Set(globalFlags.flatMap((flag) => flag.names));
460
823
  for (const flag of globalFlags) {
461
824
  const primaryName = getPrimaryFlagName(flag);
462
825
  if (!primaryName) continue;
@@ -475,7 +838,7 @@ function parseCliArgs(rawArgs, commands) {
475
838
  if (flag.type === "boolean") parsedFlags[primaryName] = true;
476
839
  else if (flag.expectsValue) {
477
840
  const nextArg = rawArgs[i + 1];
478
- if (nextArg && !nextArg.startsWith("-")) {
841
+ if (nextArg && !knownFlagSet.has(nextArg)) {
479
842
  parsedFlags[primaryName] = nextArg;
480
843
  i++;
481
844
  } else p.log.warn(formatLogMessage("warn", `Flag ${arg} expects a value, but none was provided`));
@@ -484,72 +847,142 @@ function parseCliArgs(rawArgs, commands) {
484
847
  }
485
848
  if (!isFlag) potentialCommandArgs.push(arg);
486
849
  }
487
- const firstPositional = potentialCommandArgs[0];
850
+ const [firstPositional] = potentialCommandArgs;
488
851
  if (typeof firstPositional === "string" && commands.some((cmd) => cmd.name === firstPositional)) {
489
852
  commandName = firstPositional;
490
853
  commandArgs.push(...potentialCommandArgs.slice(1));
491
854
  } else commandArgs.push(...potentialCommandArgs);
492
855
  return {
493
- commandName,
494
856
  commandArgs,
857
+ commandName,
495
858
  parsedFlags
496
859
  };
497
860
  }
861
+ /**
862
+ * Formats a single flag for display in help output.
863
+ *
864
+ * @param flag - Flag definition to render.
865
+ * @returns A help row containing names, value hint, and description.
866
+ */
498
867
  function formatFlagHelp(flag) {
499
868
  return ` ${flag.names.join(", ")}${flag.expectsValue ? " <value>" : ""}\t${flag.description}`;
500
869
  }
870
+ /**
871
+ * Formats all built-in global flags for help output.
872
+ *
873
+ * @returns Newline-delimited help rows for `globalFlags`.
874
+ */
501
875
  function generateFlagsHelp() {
502
876
  return globalFlags.map(formatFlagHelp).join("\n");
503
877
  }
878
+ /**
879
+ * Checks whether a parsed boolean flag is enabled.
880
+ *
881
+ * @param flags - Parsed flag map from `parseCliArgs` or `CliContext`.
882
+ * @param name - Primary flag name without leading dashes.
883
+ * @returns `true` only when the flag value is exactly `true`.
884
+ *
885
+ * @example
886
+ * ```ts
887
+ * if (hasFlag(context.flags, 'force')) {
888
+ * // overwrite existing files
889
+ * }
890
+ * ```
891
+ */
504
892
  function hasFlag(flags, name) {
505
893
  return flags[name] === true;
506
894
  }
895
+ /**
896
+ * Reads a string-valued flag from a parsed flag map.
897
+ *
898
+ * @param flags - Parsed flag map from `parseCliArgs` or `CliContext`.
899
+ * @param name - Primary flag name without leading dashes.
900
+ * @returns The flag value when it is a string, otherwise `undefined`.
901
+ */
507
902
  function getFlagValue(flags, name) {
508
903
  const value = flags[name];
509
904
  if (typeof value === "string") return value;
510
905
  }
906
+ /**
907
+ * Splits command arguments into an optional nested subcommand and remaining
908
+ * args.
909
+ *
910
+ * @param args - Positional command arguments to inspect.
911
+ * @param subcommands - Available subcommands for the current command.
912
+ * @returns The matched subcommand, if any, plus arguments after the subcommand
913
+ * name.
914
+ *
915
+ * @example
916
+ * ```ts
917
+ * const { subcommand, remainingArgs } = parseSubcommand(
918
+ * context.commandArgs,
919
+ * command.subcommands ?? []
920
+ * );
921
+ * ```
922
+ */
511
923
  function parseSubcommand(args, subcommands) {
512
- const subcommandName = args[0];
924
+ const [subcommandName] = args;
513
925
  const subcommand = subcommands.find((cmd) => cmd.name === subcommandName);
514
926
  if (subcommand) return {
515
- subcommand,
516
- remainingArgs: args.slice(1)
927
+ remainingArgs: args.slice(1),
928
+ subcommand
517
929
  };
518
930
  return {
519
- subcommand: void 0,
520
- remainingArgs: args
931
+ remainingArgs: args,
932
+ subcommand: void 0
521
933
  };
522
934
  }
523
935
  //#endregion
524
936
  //#region src/telemetry.ts
937
+ /**
938
+ * Standard telemetry event names emitted by Hexbus runtime helpers.
939
+ */
525
940
  const TelemetryEventName = {
526
- CLI_INVOKED: "cli_invoked",
527
- CLI_ENVIRONMENT_DETECTED: "cli_environment_detected",
528
941
  CLI_COMPLETED: "cli_completed",
942
+ CLI_ENVIRONMENT_DETECTED: "cli_environment_detected",
943
+ CLI_INVOKED: "cli_invoked",
944
+ COMMAND_FAILED: "command_failed",
529
945
  COMMAND_INVOKED: "command_invoked",
530
946
  COMMAND_SUCCEEDED: "command_succeeded",
531
- COMMAND_FAILED: "command_failed",
532
947
  COMMAND_UNKNOWN: "command_unknown",
533
948
  ERROR_OCCURRED: "error_occurred",
534
949
  HELP_DISPLAYED: "help_displayed",
535
- VERSION_DISPLAYED: "version_displayed",
950
+ INTERACTIVE_MENU_EXITED: "interactive_menu_exited",
536
951
  INTERACTIVE_MENU_OPENED: "interactive_menu_opened",
537
- INTERACTIVE_MENU_EXITED: "interactive_menu_exited"
952
+ VERSION_DISPLAYED: "version_displayed"
538
953
  };
539
954
  function isEnvDisabled(prefix) {
540
955
  const value = process.env[`${prefix}_TELEMETRY_DISABLED`];
541
956
  return value === "1" || value === "true";
542
957
  }
958
+ /**
959
+ * Creates a no-op telemetry client.
960
+ *
961
+ * @returns A telemetry implementation whose methods do nothing and whose
962
+ * `isDisabled()` method returns `true`.
963
+ */
543
964
  function createDisabledTelemetry() {
544
965
  return {
545
- trackEvent: () => {},
546
- trackCommand: () => {},
547
- trackError: () => {},
548
966
  flush: async () => {},
967
+ isDisabled: () => true,
549
968
  shutdown: async () => {},
550
- isDisabled: () => true
969
+ trackCommand: () => {},
970
+ trackError: () => {},
971
+ trackEvent: () => {}
551
972
  };
552
973
  }
974
+ /**
975
+ * Creates the built-in telemetry client.
976
+ *
977
+ * @remarks
978
+ * Events are queued in memory. `flush()` posts the queue to `endpoint` when one
979
+ * is configured, then clears the queue. Failed flushes are reported through
980
+ * the optional logger and do not throw, keeping telemetry best effort.
981
+ *
982
+ * @param options - Telemetry behavior and event defaults.
983
+ * @returns An enabled or disabled telemetry client depending on options and
984
+ * environment opt-out variables.
985
+ */
553
986
  function createTelemetry(options = {}) {
554
987
  const envVarPrefix = options.envVarPrefix ?? "APP";
555
988
  const disabled = options.disabled === true || isEnvDisabled(envVarPrefix);
@@ -573,45 +1006,54 @@ function createTelemetry(options = {}) {
573
1006
  events.length = 0;
574
1007
  return;
575
1008
  }
576
- const batch = events.splice(0, events.length);
1009
+ const batch = events.splice(0);
577
1010
  try {
578
1011
  await fetch(options.endpoint, {
579
- method: "POST",
580
- headers: { "content-type": "application/json" },
581
1012
  body: JSON.stringify({ events: batch }),
582
- keepalive: true
1013
+ headers: { "content-type": "application/json" },
1014
+ keepalive: true,
1015
+ method: "POST"
583
1016
  });
584
1017
  } catch (error) {
585
1018
  options.logger?.warn(`Failed to send telemetry: ${error instanceof Error ? error.message : String(error)}`);
586
1019
  }
587
1020
  };
588
1021
  return {
589
- trackEvent,
1022
+ flush,
1023
+ isDisabled() {
1024
+ return false;
1025
+ },
1026
+ async shutdown() {
1027
+ await flush();
1028
+ },
590
1029
  trackCommand(command, args = [], flags = {}) {
1030
+ const enabledFlags = Object.entries(flags).filter(([, value]) => value !== false && value !== void 0).map(([key]) => key);
1031
+ enabledFlags.sort();
591
1032
  trackEvent(TelemetryEventName.COMMAND_INVOKED, {
592
- command,
593
1033
  argsCount: args.length,
594
- enabledFlags: Object.entries(flags).filter(([, value]) => value !== false && value !== void 0).map(([key]) => key).sort()
1034
+ command,
1035
+ enabledFlags
595
1036
  });
596
1037
  },
597
1038
  trackError(error, command) {
598
1039
  trackEvent(TelemetryEventName.ERROR_OCCURRED, {
599
1040
  command,
600
- errorName: error.name,
601
- errorMessage: error.message
1041
+ errorMessage: error.message,
1042
+ errorName: error.name
602
1043
  });
603
1044
  },
604
- flush,
605
- async shutdown() {
606
- await flush();
607
- },
608
- isDisabled() {
609
- return false;
610
- }
1045
+ trackEvent
611
1046
  };
612
1047
  }
613
1048
  //#endregion
614
1049
  //#region src/context.ts
1050
+ /**
1051
+ * Resolves the active logger level from parsed CLI flags.
1052
+ *
1053
+ * @param parsedFlags - Parsed global flags from the current invocation.
1054
+ * @returns A valid log level, falling back to `info` for missing or invalid
1055
+ * values.
1056
+ */
615
1057
  function getLogLevel(parsedFlags) {
616
1058
  const levelArg = parsedFlags.logger;
617
1059
  if (typeof levelArg === "string") {
@@ -620,17 +1062,34 @@ function getLogLevel(parsedFlags) {
620
1062
  }
621
1063
  return "info";
622
1064
  }
1065
+ /**
1066
+ * Creates file-system helpers scoped to a project root.
1067
+ *
1068
+ * @param cwd - Project root used for package metadata lookup.
1069
+ * @returns File-system utilities for context consumers.
1070
+ */
623
1071
  function createFileSystem(cwd) {
624
1072
  return {
1073
+ async exists(filePath) {
1074
+ try {
1075
+ await fs.access(filePath);
1076
+ return true;
1077
+ } catch {
1078
+ return false;
1079
+ }
1080
+ },
625
1081
  getPackageInfo() {
626
1082
  const packageJsonPath = path.join(cwd, "package.json");
627
1083
  try {
628
1084
  const content = fsSync.readFileSync(packageJsonPath, "utf-8");
629
1085
  const packageInfo = JSON.parse(content);
1086
+ const packageFields = packageInfo && typeof packageInfo === "object" && !Array.isArray(packageInfo) ? packageInfo : {};
1087
+ const name = typeof packageFields.name === "string" ? packageFields.name : "unknown";
1088
+ const version = typeof packageFields.version === "string" ? packageFields.version : "unknown";
630
1089
  return {
631
- ...packageInfo,
632
- name: packageInfo.name || "unknown",
633
- version: packageInfo.version || "unknown"
1090
+ ...packageFields,
1091
+ name: name || "unknown",
1092
+ version: version || "unknown"
634
1093
  };
635
1094
  } catch {
636
1095
  return {
@@ -639,25 +1098,44 @@ function createFileSystem(cwd) {
639
1098
  };
640
1099
  }
641
1100
  },
642
- async exists(filePath) {
643
- try {
644
- await fs.access(filePath);
645
- return true;
646
- } catch {
647
- return false;
648
- }
1101
+ async mkdir(dirPath) {
1102
+ await fs.mkdir(dirPath, { recursive: true });
649
1103
  },
650
1104
  read(filePath) {
651
1105
  return fs.readFile(filePath, "utf-8");
652
1106
  },
653
1107
  write(filePath, content) {
654
1108
  return fs.writeFile(filePath, content, "utf-8");
655
- },
656
- async mkdir(dirPath) {
657
- await fs.mkdir(dirPath, { recursive: true });
658
1109
  }
659
1110
  };
660
1111
  }
1112
+ /**
1113
+ * Creates the resolved context passed to command actions.
1114
+ *
1115
+ * @remarks
1116
+ * Context creation performs the standard CLI bootstrap sequence: parse global
1117
+ * flags, create the logger, detect the project root, detect framework and
1118
+ * package manager metadata, set up telemetry, and attach helpers for config
1119
+ * loading, file-system access, confirmation prompts, and process-ending error
1120
+ * handling.
1121
+ *
1122
+ * @typeParam TPackage - Product-specific package identifier returned from
1123
+ * framework detection.
1124
+ * @param options - Context creation options for the current invocation.
1125
+ * @returns A fully initialized `CliContext`.
1126
+ *
1127
+ * @example
1128
+ * ```ts
1129
+ * const context = await createCliContext({
1130
+ * rawArgs: process.argv.slice(2),
1131
+ * appName: 'acme',
1132
+ * commands,
1133
+ * });
1134
+ *
1135
+ * await commands.find((command) => command.name === context.commandName)
1136
+ * ?.action(context);
1137
+ * ```
1138
+ */
661
1139
  async function createCliContext(options) {
662
1140
  const cwd = options.cwd ?? process.cwd();
663
1141
  const appName = options.appName ?? "cli";
@@ -668,37 +1146,35 @@ async function createCliContext(options) {
668
1146
  const framework = await detectFramework(projectRoot, logger, options.packageMap);
669
1147
  const packageManager = await detectPackageManager(projectRoot, logger, { interactive: options.interactivePackageManagerDetection });
670
1148
  const telemetry = createTelemetry({
671
- disabled: options.telemetry?.disabled === true || parsedFlags["no-telemetry"] === true,
672
- debug: options.telemetry?.debug === true || parsedFlags["telemetry-debug"] === true,
673
- endpoint: options.telemetry?.endpoint,
674
1149
  appName,
675
- envVarPrefix: options.telemetry?.envVarPrefix ?? appName.toUpperCase(),
1150
+ debug: options.telemetry?.debug === true || parsedFlags["telemetry-debug"] === true,
676
1151
  defaultProperties: {
677
- entryCommand: commandName ?? "interactive",
678
- commandArgsCount: commandArgs.length,
679
1152
  cliVersion: fsUtils.getPackageInfo().version,
1153
+ commandArgsCount: commandArgs.length,
1154
+ entryCommand: commandName ?? "interactive",
680
1155
  framework: framework.framework ?? "unknown",
681
1156
  frameworkVersion: framework.frameworkVersion ?? "unknown",
682
1157
  packageManager: packageManager.name,
683
1158
  ...options.telemetry?.defaultProperties
684
1159
  },
1160
+ disabled: options.telemetry?.disabled === true || parsedFlags["no-telemetry"] === true,
1161
+ endpoint: options.telemetry?.endpoint,
1162
+ envVarPrefix: options.telemetry?.envVarPrefix ?? appName.toUpperCase(),
685
1163
  logger
686
1164
  });
687
1165
  const errorHandlers = createErrorHandlers(logger, telemetry);
688
1166
  const context = {
689
- logger,
690
- flags: parsedFlags,
691
- commandName,
692
1167
  commandArgs,
693
- cwd,
694
- error: errorHandlers,
1168
+ commandName,
695
1169
  config: {
1170
+ getPathAliases() {
1171
+ return null;
1172
+ },
696
1173
  async loadConfig() {
697
- const configPath = typeof parsedFlags.config === "string" ? parsedFlags.config : void 0;
698
1174
  const { config } = await loadConfig({
699
- name: options.configName ?? appName,
1175
+ configFile: typeof parsedFlags.config === "string" ? parsedFlags.config : void 0,
700
1176
  cwd: projectRoot,
701
- configFile: configPath
1177
+ name: options.configName ?? appName
702
1178
  });
703
1179
  return config ?? null;
704
1180
  },
@@ -706,89 +1182,117 @@ async function createCliContext(options) {
706
1182
  const config = await this.loadConfig();
707
1183
  if (!config) throw new CliError("CONFIG_NOT_FOUND");
708
1184
  return config;
709
- },
710
- getPathAliases() {
711
- return null;
712
1185
  }
713
1186
  },
714
- fs: fsUtils,
715
- telemetry,
716
1187
  async confirm(message, initialValue = true) {
717
1188
  if (parsedFlags.y === true || parsedFlags.yes === true) return true;
718
1189
  const result = await p.confirm({
719
- message,
720
- initialValue
1190
+ initialValue,
1191
+ message
721
1192
  });
722
1193
  if (p.isCancel(result)) errorHandlers.handleCancel("Confirmation cancelled");
723
1194
  return result;
724
1195
  },
725
- projectRoot,
1196
+ cwd,
1197
+ error: errorHandlers,
1198
+ flags: parsedFlags,
726
1199
  framework,
727
- packageManager
1200
+ fs: fsUtils,
1201
+ logger,
1202
+ packageManager,
1203
+ projectRoot,
1204
+ telemetry
728
1205
  };
729
1206
  telemetry.trackEvent(TelemetryEventName.CLI_ENVIRONMENT_DETECTED, {
730
1207
  command: commandName ?? "interactive",
731
- projectRootChanged: projectRoot !== cwd,
732
1208
  framework: framework.framework ?? "unknown",
733
1209
  frameworkVersion: framework.frameworkVersion ?? "unknown",
734
- packageManager: packageManager.name,
735
1210
  hasReact: framework.hasReact,
1211
+ packageManager: packageManager.name,
1212
+ projectRootChanged: projectRoot !== cwd,
736
1213
  reactVersion: framework.reactVersion ?? "unknown",
737
1214
  tailwindVersion: framework.tailwindVersion ?? "unknown"
738
1215
  });
739
1216
  return context;
740
1217
  }
1218
+ /**
1219
+ * Creates a deterministic context for unit tests.
1220
+ *
1221
+ * @remarks
1222
+ * The test context disables telemetry, uses an error-only logger, avoids real
1223
+ * framework or package-manager detection, and provides no-op file-system
1224
+ * helpers. Pass overrides to replace only the services a test needs.
1225
+ *
1226
+ * @param overrides - Partial context fields to merge into the default test
1227
+ * context.
1228
+ * @returns A `CliContext` suitable for command and helper tests.
1229
+ *
1230
+ * @example
1231
+ * ```ts
1232
+ * const context = createTestContext({
1233
+ * flags: { force: true },
1234
+ * commandName: 'init',
1235
+ * });
1236
+ * ```
1237
+ */
741
1238
  function createTestContext(overrides = {}) {
742
1239
  const logger = createCliLogger("error");
743
1240
  const telemetry = createDisabledTelemetry();
744
1241
  const error = createErrorHandlers(logger, telemetry);
745
1242
  return {
746
- logger,
747
- flags: {},
748
- commandName: void 0,
749
1243
  commandArgs: [],
750
- cwd: process.cwd(),
751
- error,
1244
+ commandName: void 0,
752
1245
  config: {
753
- loadConfig: async () => null,
754
- requireConfig: async () => {
755
- throw new CliError("CONFIG_NOT_FOUND");
756
- },
757
- getPathAliases: () => null
758
- },
759
- fs: {
760
- getPackageInfo: () => ({
761
- name: "test",
762
- version: "0.0.0"
763
- }),
764
- exists: async () => false,
765
- read: async () => "",
766
- write: async () => {},
767
- mkdir: async () => {}
1246
+ getPathAliases: () => null,
1247
+ loadConfig: () => Promise.resolve(null),
1248
+ requireConfig: () => Promise.reject(new CliError("CONFIG_NOT_FOUND"))
768
1249
  },
769
- telemetry,
770
- confirm: async () => true,
771
- projectRoot: process.cwd(),
1250
+ confirm: () => Promise.resolve(true),
1251
+ cwd: process.cwd(),
1252
+ error,
1253
+ flags: {},
772
1254
  framework: {
773
1255
  framework: null,
774
1256
  frameworkVersion: null,
775
- pkg: null,
776
1257
  hasReact: false,
1258
+ pkg: null,
777
1259
  reactVersion: null,
778
1260
  tailwindVersion: null
779
1261
  },
1262
+ fs: {
1263
+ exists: () => Promise.resolve(false),
1264
+ getPackageInfo: () => ({
1265
+ name: "test",
1266
+ version: "0.0.0"
1267
+ }),
1268
+ mkdir: () => Promise.resolve(),
1269
+ read: () => Promise.resolve(""),
1270
+ write: () => Promise.resolve()
1271
+ },
1272
+ logger,
780
1273
  packageManager: {
781
- name: "npm",
782
- installCommand: "npm install",
783
1274
  addCommand: "npm install",
784
- runCommand: "npm run",
785
- execCommand: "npx"
1275
+ execCommand: "npx",
1276
+ installCommand: "npm install",
1277
+ name: "npm",
1278
+ runCommand: "npm run"
786
1279
  },
1280
+ projectRoot: process.cwd(),
1281
+ telemetry,
787
1282
  ...overrides
788
1283
  };
789
1284
  }
790
1285
  //#endregion
791
1286
  //#region src/help.ts
1287
+ /**
1288
+ * Renders a help menu for commands and global flags.
1289
+ *
1290
+ * @param context - Context subset providing the logger used for output.
1291
+ * @param options - Application metadata for the help menu.
1292
+ * @param commands - Commands to list; commands with `hidden: true` are
1293
+ * omitted.
1294
+ * @param flags - Global flags to list.
1295
+ */
792
1296
  function showHelpMenu(context, options, commands, flags) {
793
1297
  const commandRows = commands.filter((command) => !command.hidden).map((command) => ` ${command.name.padEnd(16)} ${command.description}`).join("\n");
794
1298
  const flagRows = flags.map((flag) => {
@@ -800,17 +1304,23 @@ function showHelpMenu(context, options, commands, flags) {
800
1304
  }
801
1305
  //#endregion
802
1306
  //#region src/intro.ts
803
- function renderFiglet(text) {
804
- return new Promise((resolve) => {
805
- figlet(text, (error, data) => {
806
- if (error || !data) {
807
- resolve(text);
808
- return;
809
- }
810
- resolve(data);
811
- });
812
- });
1307
+ const renderFigletAsync = promisify(figlet);
1308
+ async function renderFiglet(text) {
1309
+ try {
1310
+ return await renderFigletAsync(text) ?? text;
1311
+ } catch {
1312
+ return text;
1313
+ }
813
1314
  }
1315
+ /**
1316
+ * Renders a figlet banner and short intro note.
1317
+ *
1318
+ * @remarks
1319
+ * If figlet rendering fails, the plain banner text is displayed instead.
1320
+ *
1321
+ * @param context - Context subset providing the logger used for output.
1322
+ * @param options - Intro banner metadata.
1323
+ */
814
1324
  async function displayIntro(context, options) {
815
1325
  const banner = await renderFiglet(options.figletText ?? options.appName);
816
1326
  const versionLabel = options.version ? ` v${options.version}` : "";
@@ -819,49 +1329,104 @@ async function displayIntro(context, options) {
819
1329
  }
820
1330
  //#endregion
821
1331
  //#region src/spinner.ts
1332
+ /**
1333
+ * Creates a Clack-backed spinner.
1334
+ *
1335
+ * @param initialMessage - Message used when `start()` is called without an
1336
+ * explicit message.
1337
+ * @returns A spinner controller.
1338
+ */
822
1339
  function createSpinner(initialMessage) {
823
- const spinner = p.spinner();
1340
+ const spinnerInstance = spinner();
824
1341
  return {
1342
+ message(message) {
1343
+ spinnerInstance.message(message);
1344
+ },
825
1345
  start(message) {
826
- spinner.start(message || initialMessage || "Processing...");
1346
+ spinnerInstance.start(message ?? initialMessage ?? "Processing...");
827
1347
  },
828
1348
  stop(message) {
829
- spinner.stop(message || "Done");
830
- },
831
- message(message) {
832
- spinner.message(message);
1349
+ spinnerInstance.stop(message ?? "Done");
833
1350
  }
834
1351
  };
835
1352
  }
1353
+ /**
1354
+ * Runs an async task while displaying a spinner.
1355
+ *
1356
+ * @typeParam T - Value returned by the task.
1357
+ * @param message - Message shown when the spinner starts.
1358
+ * @param task - Async work to run.
1359
+ * @param options - Optional success and error messages shown when the task
1360
+ * settles.
1361
+ * @returns The value returned by `task`.
1362
+ *
1363
+ * @throws Re-throws any error from `task` after stopping the spinner.
1364
+ */
836
1365
  async function withSpinner(message, task, options) {
837
- const spinner = createSpinner(message);
838
- spinner.start();
1366
+ const spinnerInstance = createSpinner(message);
1367
+ spinnerInstance.start();
839
1368
  try {
840
1369
  const result = await task();
841
- spinner.stop(options?.successMessage || "Done");
1370
+ spinnerInstance.stop(options?.successMessage ?? "Done");
842
1371
  return result;
843
1372
  } catch (error) {
844
- spinner.stop(options?.errorMessage || "Failed");
1373
+ spinnerInstance.stop(options?.errorMessage ?? "Failed");
845
1374
  throw error;
846
1375
  }
847
1376
  }
848
1377
  //#endregion
849
- //#region src/version-check.ts
850
- const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
851
- const DEFAULT_TIMEOUT_MS = 1500;
852
- const DEFAULT_CACHE_TTL_MS = 1440 * 60 * 1e3;
853
- const defaultLogger = {
854
- message(message) {
855
- process.stdout.write(`${message}\n`);
856
- },
857
- note(content, title) {
858
- const prefix = title ? `${title}\n` : "";
859
- process.stdout.write(`${prefix}${content}\n`);
1378
+ //#region src/version-check/compare.ts
1379
+ function parseVersionParts(version) {
1380
+ const [coreVersion = "", prerelease] = version.replace(/^[^\d]*/, "").split("-", 2);
1381
+ return {
1382
+ parts: coreVersion.split(".").map((part) => {
1383
+ const parsed = Number.parseInt(part.replace(/\D.*$/, ""), 10);
1384
+ return Number.isNaN(parsed) ? 0 : parsed;
1385
+ }),
1386
+ prerelease
1387
+ };
1388
+ }
1389
+ function comparePrereleaseIdentifiers(left, right) {
1390
+ const leftIsNumeric = /^\d+$/.test(left);
1391
+ const rightIsNumeric = /^\d+$/.test(right);
1392
+ if (leftIsNumeric && rightIsNumeric) return Math.sign(Number.parseInt(left, 10) - Number.parseInt(right, 10));
1393
+ if (leftIsNumeric) return -1;
1394
+ if (rightIsNumeric) return 1;
1395
+ return Math.sign(left.localeCompare(right));
1396
+ }
1397
+ function comparePrerelease(left, right) {
1398
+ if (!left && !right) return 0;
1399
+ if (!left) return 1;
1400
+ if (!right) return -1;
1401
+ const leftParts = left.split(".");
1402
+ const rightParts = right.split(".");
1403
+ const length = Math.max(leftParts.length, rightParts.length);
1404
+ for (let index = 0; index < length; index++) {
1405
+ const leftValue = leftParts[index];
1406
+ const rightValue = rightParts[index];
1407
+ if (leftValue === void 0) return -1;
1408
+ if (rightValue === void 0) return 1;
1409
+ const result = comparePrereleaseIdentifiers(leftValue, rightValue);
1410
+ if (result !== 0) return result;
860
1411
  }
861
- };
862
- function isVersionRequest(rawArgs) {
863
- return rawArgs.includes("-v") || rawArgs.includes("--version");
1412
+ return 0;
1413
+ }
1414
+ function compareVersions(left, right) {
1415
+ const leftVersion = parseVersionParts(left);
1416
+ const rightVersion = parseVersionParts(right);
1417
+ const leftParts = leftVersion.parts;
1418
+ const rightParts = rightVersion.parts;
1419
+ const length = Math.max(leftParts.length, rightParts.length);
1420
+ for (let index = 0; index < length; index++) {
1421
+ const leftValue = leftParts[index] ?? 0;
1422
+ const rightValue = rightParts[index] ?? 0;
1423
+ if (leftValue > rightValue) return 1;
1424
+ if (leftValue < rightValue) return -1;
1425
+ }
1426
+ return comparePrerelease(leftVersion.prerelease, rightVersion.prerelease);
864
1427
  }
1428
+ //#endregion
1429
+ //#region src/version-check/install-source.ts
865
1430
  function normalizePath(filePath) {
866
1431
  return filePath.replaceAll("\\", "/");
867
1432
  }
@@ -884,12 +1449,28 @@ function isPathUnder(candidate, parent) {
884
1449
  function envValue(name) {
885
1450
  return process.env[name];
886
1451
  }
1452
+ /**
1453
+ * Infers how the current CLI binary was installed.
1454
+ *
1455
+ * @remarks
1456
+ * Detection is heuristic and based on binary path, realpath, and package
1457
+ * manager environment variables. Unknown or transient install modes return
1458
+ * `unknown` instead of throwing.
1459
+ *
1460
+ * @param binPath - Binary path to inspect.
1461
+ * @returns The inferred installation source.
1462
+ */
887
1463
  function detectInstallSource(binPath = process.argv[1] ?? "") {
888
1464
  const resolvedPath = normalizePath(safeRealpath(binPath || ""));
889
1465
  const npmPrefix = envValue("npm_config_prefix");
1466
+ const execPath = normalizePath(process.execPath);
1467
+ const argvPath = normalizePath(process.argv[0] ?? "");
1468
+ const execName = path$1.basename(execPath);
1469
+ const argvName = path$1.basename(argvPath);
1470
+ const isBunInvocation = execName === "bun" || execName === "bunx" || argvName === "bun" || argvName === "bunx";
890
1471
  if (resolvedPath.includes("/opt/homebrew/") || resolvedPath.includes("/usr/local/Cellar/") || resolvedPath.includes("/home/linuxbrew/") || resolvedPath.includes("/Homebrew/Cellar/")) return "brew";
891
1472
  if (resolvedPath.includes("/.npm/_npx/") || resolvedPath.includes("/_npx/") || process.env.npm_command === "exec") return "npx";
892
- if (resolvedPath.includes("/.bun/install/cache/") || Boolean(envValue("BUN_INSTALL"))) return "bunx";
1473
+ if (resolvedPath.includes("/.bun/install/cache/") || Boolean(envValue("BUN_INSTALL")) && (resolvedPath.includes("/.bun/install/run/") || isBunInvocation)) return "bunx";
893
1474
  if (resolvedPath.includes("/.pnpm-store/") || resolvedPath.includes("/dlx-")) return "pnpm-dlx";
894
1475
  if (resolvedPath.includes("/.yarn/berry/cache/") || resolvedPath.includes("/yarn/dlx-")) return "yarn-dlx";
895
1476
  if (resolvedPath.includes("/node_modules/.bin/") || resolvedPath.endsWith("/node_modules/.bin")) return "local";
@@ -906,6 +1487,15 @@ function detectInstallSource(binPath = process.argv[1] ?? "") {
906
1487
  })) return "npm-global";
907
1488
  return "unknown";
908
1489
  }
1490
+ /**
1491
+ * Builds an update command for an installation source.
1492
+ *
1493
+ * @param source - Installation source returned by `detectInstallSource`.
1494
+ * @param packageName - Package name to update.
1495
+ * @param brewFormula - Homebrew formula name when `source` is `brew`.
1496
+ * @returns A command string when the source has an actionable update path,
1497
+ * otherwise `null`.
1498
+ */
909
1499
  function getUpdateCommand(source, packageName, brewFormula = packageName) {
910
1500
  switch (source) {
911
1501
  case "npm-global": return `npm install -g ${packageName}@latest`;
@@ -914,8 +1504,48 @@ function getUpdateCommand(source, packageName, brewFormula = packageName) {
914
1504
  default: return null;
915
1505
  }
916
1506
  }
1507
+ //#endregion
1508
+ //#region src/version-check/registry.ts
1509
+ const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
1510
+ const DEFAULT_TIMEOUT_MS = 1500;
1511
+ const DEFAULT_CACHE_TTL_MS = 1440 * 60 * 1e3;
1512
+ const MAX_CACHE_NAME_LENGTH = 80;
1513
+ const WINDOWS_RESERVED_NAMES = new Set([
1514
+ "con",
1515
+ "prn",
1516
+ "aux",
1517
+ "nul",
1518
+ "com1",
1519
+ "com2",
1520
+ "com3",
1521
+ "com4",
1522
+ "com5",
1523
+ "com6",
1524
+ "com7",
1525
+ "com8",
1526
+ "com9",
1527
+ "lpt1",
1528
+ "lpt2",
1529
+ "lpt3",
1530
+ "lpt4",
1531
+ "lpt5",
1532
+ "lpt6",
1533
+ "lpt7",
1534
+ "lpt8",
1535
+ "lpt9"
1536
+ ]);
1537
+ function getCacheNameHash(packageName) {
1538
+ return createHash("sha256").update(packageName).digest("hex").slice(0, 8);
1539
+ }
917
1540
  function sanitizeCacheName(packageName) {
918
- return packageName.replaceAll("/", "__").replaceAll("@", "");
1541
+ const normalized = packageName.normalize("NFKD").toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "_").replaceAll(/_+/g, "_").replaceAll(/^[._-]+|[._-]+$/g, "");
1542
+ const fallbackName = normalized || "package";
1543
+ const baseName = fallbackName.split(".")[0] ?? fallbackName;
1544
+ const isReservedName = WINDOWS_RESERVED_NAMES.has(baseName);
1545
+ if (!(normalized !== packageName || normalized.length === 0 || isReservedName || normalized.length > MAX_CACHE_NAME_LENGTH)) return normalized;
1546
+ const hash = getCacheNameHash(packageName);
1547
+ const maxStemLength = MAX_CACHE_NAME_LENGTH - hash.length - 1;
1548
+ return `${(isReservedName ? `package-${fallbackName}` : fallbackName).slice(0, maxStemLength).replaceAll(/[._-]+$/g, "") || "package"}-${hash}`;
919
1549
  }
920
1550
  function getCacheDir(options) {
921
1551
  return options.cacheDir ?? path$1.join(os.tmpdir(), "hexbus-version-cache");
@@ -928,8 +1558,8 @@ function readCachedVersion(options) {
928
1558
  const content = fsSync$1.readFileSync(getCachePath(options), "utf-8");
929
1559
  const parsed = JSON.parse(content);
930
1560
  if (typeof parsed.version === "string" && typeof parsed.fetchedAt === "number") return {
931
- version: parsed.version,
932
- fetchedAt: parsed.fetchedAt
1561
+ fetchedAt: parsed.fetchedAt,
1562
+ version: parsed.version
933
1563
  };
934
1564
  } catch {}
935
1565
  return null;
@@ -942,52 +1572,19 @@ function isCacheFresh(cache, options) {
942
1572
  async function writeCachedVersion(options, version) {
943
1573
  const cachePath = getCachePath(options);
944
1574
  const cacheDir = path$1.dirname(cachePath);
945
- const tempPath = `${cachePath}.${process.pid}.tmp`;
1575
+ const tempPath = `${cachePath}.${process.pid}.${randomUUID()}.tmp`;
946
1576
  const payload = {
947
- version,
948
- fetchedAt: options.now?.() ?? Date.now()
1577
+ fetchedAt: options.now?.() ?? Date.now(),
1578
+ version
949
1579
  };
950
- await fs$1.mkdir(cacheDir, { recursive: true });
951
- await fs$1.writeFile(tempPath, `${JSON.stringify(payload)}\n`, "utf-8");
952
- await fs$1.rename(tempPath, cachePath);
953
- }
954
- function parseVersionParts(version) {
955
- return (version.replace(/^[^\d]*/, "").split("-")[0] ?? "").split(".").map((part) => {
956
- const parsed = Number.parseInt(part.replace(/\D.*$/, ""), 10);
957
- return Number.isNaN(parsed) ? 0 : parsed;
958
- });
959
- }
960
- function compareVersions(left, right) {
961
- const leftParts = parseVersionParts(left);
962
- const rightParts = parseVersionParts(right);
963
- const length = Math.max(leftParts.length, rightParts.length);
964
- for (let index = 0; index < length; index++) {
965
- const leftValue = leftParts[index] ?? 0;
966
- const rightValue = rightParts[index] ?? 0;
967
- if (leftValue > rightValue) return 1;
968
- if (leftValue < rightValue) return -1;
1580
+ try {
1581
+ await fs$1.mkdir(cacheDir, { recursive: true });
1582
+ await fs$1.writeFile(tempPath, `${JSON.stringify(payload)}\n`, "utf-8");
1583
+ await fs$1.rename(tempPath, cachePath);
1584
+ } catch (error) {
1585
+ await fs$1.unlink(tempPath).catch(() => {});
1586
+ throw error;
969
1587
  }
970
- return 0;
971
- }
972
- function createResult(options, latestVersion) {
973
- const source = detectInstallSource(options.binPath);
974
- const updateCommand = getUpdateCommand(source, options.packageName, options.brewFormula);
975
- const isOutdated = typeof latestVersion === "string" && compareVersions(options.currentVersion, latestVersion) < 0;
976
- const result = {
977
- currentVersion: options.currentVersion,
978
- latestVersion,
979
- isOutdated,
980
- source,
981
- updateCommand
982
- };
983
- const hint = formatUpdateHint({
984
- ...result,
985
- hint: null
986
- });
987
- return {
988
- ...result,
989
- hint
990
- };
991
1588
  }
992
1589
  async function fetchLatestVersion(options) {
993
1590
  const controller = new AbortController();
@@ -1010,11 +1607,24 @@ async function refreshCache(options) {
1010
1607
  if (latestVersion) await writeCachedVersion(options, latestVersion);
1011
1608
  return latestVersion;
1012
1609
  }
1013
- async function checkForUpdate(options) {
1014
- const cached = readCachedVersion(options);
1015
- if (cached) return createResult(options, cached.version);
1016
- return createResult(options, await refreshCache(options));
1610
+ //#endregion
1611
+ //#region src/version-check/check.ts
1612
+ /**
1613
+ * Checks whether raw arguments request version output.
1614
+ *
1615
+ * @param rawArgs - Arguments after executable and script path.
1616
+ * @returns `true` when `-v` or `--version` is present.
1617
+ */
1618
+ function isVersionRequest(rawArgs) {
1619
+ return rawArgs.includes("-v") || rawArgs.includes("--version");
1017
1620
  }
1621
+ /**
1622
+ * Formats a user-facing update hint from an update-check result.
1623
+ *
1624
+ * @param result - Update-check result to render.
1625
+ * @returns A formatted hint when the result is outdated and has an update
1626
+ * command, otherwise `null`.
1627
+ */
1018
1628
  function formatUpdateHint(result) {
1019
1629
  if (!result.isOutdated || result.updateCommand === null || result.latestVersion === null) return null;
1020
1630
  if (result.source === "brew") return [
@@ -1028,23 +1638,95 @@ function formatUpdateHint(result) {
1028
1638
  ` ${color.cyan(result.updateCommand)}`
1029
1639
  ].join("\n");
1030
1640
  }
1641
+ function createUpdateCheckResult(options, latestVersion) {
1642
+ const source = detectInstallSource(options.binPath);
1643
+ const updateCommand = getUpdateCommand(source, options.packageName, options.brewFormula);
1644
+ const isOutdated = typeof latestVersion === "string" && compareVersions(options.currentVersion, latestVersion) < 0;
1645
+ const result = {
1646
+ currentVersion: options.currentVersion,
1647
+ isOutdated,
1648
+ latestVersion,
1649
+ source,
1650
+ updateCommand
1651
+ };
1652
+ const hint = formatUpdateHint({
1653
+ ...result,
1654
+ hint: null
1655
+ });
1656
+ return {
1657
+ ...result,
1658
+ hint
1659
+ };
1660
+ }
1661
+ /**
1662
+ * Checks whether a newer package version is available.
1663
+ *
1664
+ * @remarks
1665
+ * The function first reads the local cache. On a cache miss it refreshes the
1666
+ * cache from the configured registry. Refresh failures produce a result with
1667
+ * `latestVersion: null` rather than throwing.
1668
+ *
1669
+ * @param options - Update-check configuration.
1670
+ * @returns Update metadata and a formatted hint when an update is available.
1671
+ */
1672
+ async function checkForUpdate(options) {
1673
+ const cached = readCachedVersion(options);
1674
+ if (cached) return createUpdateCheckResult(options, cached.version);
1675
+ try {
1676
+ return createUpdateCheckResult(options, await refreshCache(options));
1677
+ } catch (error) {
1678
+ options.logger?.debug?.(`Update check failed: ${error instanceof Error ? error.message : String(error)}`);
1679
+ return createUpdateCheckResult(options, null);
1680
+ }
1681
+ }
1682
+ //#endregion
1683
+ //#region src/version-check/display.ts
1684
+ const defaultLogger = {
1685
+ message(message) {
1686
+ process.stdout.write(`${message}\n`);
1687
+ },
1688
+ note(content, title) {
1689
+ const prefix = title ? `${title}\n` : "";
1690
+ process.stdout.write(`${prefix}${content}\n`);
1691
+ }
1692
+ };
1693
+ /**
1694
+ * Prints CLI version information and any available update hint.
1695
+ *
1696
+ * @param options - Version display and update-check options.
1697
+ */
1031
1698
  async function printVersionInfo(options) {
1032
1699
  const logger = options.logger ?? defaultLogger;
1033
1700
  logger.message(`${options.appName} v${options.currentVersion}`);
1034
1701
  const result = await checkForUpdate(options);
1035
1702
  if (result.hint) logger.note(result.hint, "Update available");
1036
1703
  }
1704
+ async function refreshCacheInBackground(options, logger) {
1705
+ try {
1706
+ await refreshCache(options);
1707
+ } catch (error) {
1708
+ logger.debug?.(`Update check failed: ${error instanceof Error ? error.message : String(error)}`);
1709
+ }
1710
+ }
1711
+ /**
1712
+ * Starts a non-blocking update check.
1713
+ *
1714
+ * @remarks
1715
+ * Cached hints are displayed synchronously when available. If the cache is
1716
+ * stale or missing, a refresh is started in the background and failures are
1717
+ * only logged at debug level.
1718
+ *
1719
+ * @param options - Version display and update-check options.
1720
+ */
1037
1721
  function startBackgroundUpdateCheck(options) {
1038
1722
  const logger = options.logger ?? defaultLogger;
1039
1723
  const cached = readCachedVersion(options);
1040
1724
  if (cached) {
1041
- const result = createResult(options, cached.version);
1725
+ const result = createUpdateCheckResult(options, cached.version);
1042
1726
  if (result.hint) logger.note(result.hint, "Update available");
1043
1727
  }
1044
1728
  if (cached && isCacheFresh(cached, options)) return;
1045
- refreshCache(options).catch((error) => {
1046
- logger.debug?.(`Update check failed: ${error instanceof Error ? error.message : String(error)}`);
1047
- });
1729
+ refreshCacheInBackground(options, logger);
1048
1730
  }
1049
1731
  //#endregion
1050
1732
  export { CliError, DEFAULT_ERROR_CATALOG, LOG_LEVELS, TelemetryEventName, checkForUpdate, color, createCliContext, createCliLogger, createDisabledTelemetry, createErrorHandlers, createSpinner, createTelemetry, createTestContext, detectFramework, detectInstallSource, detectPackageManager, detectProjectRoot, displayIntro, extendErrorCatalog, formatFlagHelp, formatLogMessage, formatStep, formatUpdateHint, generateFlagsHelp, getExecCommand, getFlagValue, getInstallCommand, getRunCommand, getUpdateCommand, globalFlags, hasFlag, isCliError, isVersionRequest, logMessage, parseCliArgs, parseSubcommand, printVersionInfo, showHelpMenu, startBackgroundUpdateCheck, validLogLevels, withErrorHandling, withSpinner };