hexbus 0.1.0 → 0.2.0

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 +1242 -38
  3. package/dist/index.mjs +990 -283
  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,12 +794,51 @@ 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
  }
455
- function parseCliArgs(rawArgs, commands) {
797
+ function mergeFlags(flags) {
798
+ const merged = /* @__PURE__ */ new Map();
799
+ for (const flag of globalFlags) {
800
+ const primaryName = getPrimaryFlagName(flag);
801
+ if (primaryName) merged.set(primaryName, flag);
802
+ }
803
+ for (const flag of flags) {
804
+ const primaryName = getPrimaryFlagName(flag);
805
+ if (primaryName) merged.set(primaryName, flag);
806
+ }
807
+ return [...merged.values()];
808
+ }
809
+ /**
810
+ * Parses raw command-line arguments into command name, command args, and
811
+ * global and caller-provided flags.
812
+ *
813
+ * @remarks
814
+ * Flags declared in `globalFlags` and `flags` are parsed. Unknown flags and
815
+ * other positional arguments are preserved as command arguments. If the first
816
+ * positional argument matches a registered top-level command in `commands`, it
817
+ * is returned as `commandName` and removed from `commandArgs`.
818
+ *
819
+ * @param rawArgs - Arguments after the executable and script path.
820
+ * @param commands - Top-level commands used to identify the command name.
821
+ * @param flags - Additional global flag definitions to parse alongside
822
+ * `globalFlags`. Caller-provided flags override built-in definitions that use
823
+ * the same primary flag name.
824
+ * @returns Normalized parsed output containing the matched command name,
825
+ * remaining command arguments, and parsed flag values keyed by primary name.
826
+ *
827
+ * @example
828
+ * ```ts
829
+ * const parsed = parseCliArgs(['init', '--logger', 'debug'], commands);
830
+ * // parsed.commandName === 'init'
831
+ * // parsed.parsedFlags.logger === 'debug'
832
+ * ```
833
+ */
834
+ function parseCliArgs(rawArgs, commands, flags = []) {
835
+ const mergedFlags = mergeFlags(flags);
456
836
  const parsedFlags = {};
457
837
  const potentialCommandArgs = [];
458
838
  let commandName;
459
839
  const commandArgs = [];
460
- for (const flag of globalFlags) {
840
+ const knownFlagSet = new Set(mergedFlags.flatMap((flag) => flag.names));
841
+ for (const flag of mergedFlags) {
461
842
  const primaryName = getPrimaryFlagName(flag);
462
843
  if (!primaryName) continue;
463
844
  if (flag.type === "boolean") parsedFlags[primaryName] = flag.defaultValue ?? false;
@@ -467,7 +848,7 @@ function parseCliArgs(rawArgs, commands) {
467
848
  const arg = rawArgs[i];
468
849
  if (typeof arg !== "string") continue;
469
850
  let isFlag = false;
470
- for (const flag of globalFlags) {
851
+ for (const flag of mergedFlags) {
471
852
  if (!flag.names.includes(arg)) continue;
472
853
  const primaryName = getPrimaryFlagName(flag);
473
854
  if (!primaryName) continue;
@@ -475,7 +856,7 @@ function parseCliArgs(rawArgs, commands) {
475
856
  if (flag.type === "boolean") parsedFlags[primaryName] = true;
476
857
  else if (flag.expectsValue) {
477
858
  const nextArg = rawArgs[i + 1];
478
- if (nextArg && !nextArg.startsWith("-")) {
859
+ if (nextArg && !knownFlagSet.has(nextArg)) {
479
860
  parsedFlags[primaryName] = nextArg;
480
861
  i++;
481
862
  } else p.log.warn(formatLogMessage("warn", `Flag ${arg} expects a value, but none was provided`));
@@ -484,72 +865,146 @@ function parseCliArgs(rawArgs, commands) {
484
865
  }
485
866
  if (!isFlag) potentialCommandArgs.push(arg);
486
867
  }
487
- const firstPositional = potentialCommandArgs[0];
868
+ const [firstPositional] = potentialCommandArgs;
488
869
  if (typeof firstPositional === "string" && commands.some((cmd) => cmd.name === firstPositional)) {
489
870
  commandName = firstPositional;
490
871
  commandArgs.push(...potentialCommandArgs.slice(1));
491
872
  } else commandArgs.push(...potentialCommandArgs);
492
873
  return {
493
- commandName,
494
874
  commandArgs,
875
+ commandName,
495
876
  parsedFlags
496
877
  };
497
878
  }
879
+ /**
880
+ * Formats a single flag for display in help output.
881
+ *
882
+ * @param flag - Flag definition to render.
883
+ * @returns A help row containing names, value hint, and description.
884
+ */
498
885
  function formatFlagHelp(flag) {
499
886
  return ` ${flag.names.join(", ")}${flag.expectsValue ? " <value>" : ""}\t${flag.description}`;
500
887
  }
501
- function generateFlagsHelp() {
502
- return globalFlags.map(formatFlagHelp).join("\n");
888
+ /**
889
+ * Formats a provided set of flags for help output.
890
+ *
891
+ * @param flags - Flag definitions to render with `formatFlagHelp`, defaulting
892
+ * to `globalFlags`.
893
+ *
894
+ * @returns Newline-delimited help rows for the provided flag definitions.
895
+ */
896
+ function generateFlagsHelp(flags = globalFlags) {
897
+ return flags.map(formatFlagHelp).join("\n");
503
898
  }
899
+ /**
900
+ * Checks whether a parsed boolean flag is enabled.
901
+ *
902
+ * @param flags - Parsed flag map from `parseCliArgs` or `CliContext`.
903
+ * @param name - Primary flag name without leading dashes.
904
+ * @returns `true` only when the flag value is exactly `true`.
905
+ *
906
+ * @example
907
+ * ```ts
908
+ * if (hasFlag(context.flags, 'force')) {
909
+ * // overwrite existing files
910
+ * }
911
+ * ```
912
+ */
504
913
  function hasFlag(flags, name) {
505
914
  return flags[name] === true;
506
915
  }
916
+ /**
917
+ * Reads a string-valued flag from a parsed flag map.
918
+ *
919
+ * @param flags - Parsed flag map from `parseCliArgs` or `CliContext`.
920
+ * @param name - Primary flag name without leading dashes.
921
+ * @returns The flag value when it is a string, otherwise `undefined`.
922
+ */
507
923
  function getFlagValue(flags, name) {
508
924
  const value = flags[name];
509
925
  if (typeof value === "string") return value;
510
926
  }
927
+ /**
928
+ * Splits command arguments into an optional nested subcommand and remaining
929
+ * args.
930
+ *
931
+ * @param args - Positional command arguments to inspect.
932
+ * @param subcommands - Available subcommands for the current command.
933
+ * @returns The matched subcommand, if any, plus arguments after the subcommand
934
+ * name.
935
+ *
936
+ * @example
937
+ * ```ts
938
+ * const { subcommand, remainingArgs } = parseSubcommand(
939
+ * context.commandArgs,
940
+ * command.subcommands ?? []
941
+ * );
942
+ * ```
943
+ */
511
944
  function parseSubcommand(args, subcommands) {
512
- const subcommandName = args[0];
945
+ const [subcommandName] = args;
513
946
  const subcommand = subcommands.find((cmd) => cmd.name === subcommandName);
514
947
  if (subcommand) return {
515
- subcommand,
516
- remainingArgs: args.slice(1)
948
+ remainingArgs: args.slice(1),
949
+ subcommand
517
950
  };
518
951
  return {
519
- subcommand: void 0,
520
- remainingArgs: args
952
+ remainingArgs: args,
953
+ subcommand: void 0
521
954
  };
522
955
  }
523
956
  //#endregion
524
957
  //#region src/telemetry.ts
958
+ /**
959
+ * Standard telemetry event names emitted by Hexbus runtime helpers.
960
+ */
525
961
  const TelemetryEventName = {
526
- CLI_INVOKED: "cli_invoked",
527
- CLI_ENVIRONMENT_DETECTED: "cli_environment_detected",
528
962
  CLI_COMPLETED: "cli_completed",
963
+ CLI_ENVIRONMENT_DETECTED: "cli_environment_detected",
964
+ CLI_INVOKED: "cli_invoked",
965
+ COMMAND_FAILED: "command_failed",
529
966
  COMMAND_INVOKED: "command_invoked",
530
967
  COMMAND_SUCCEEDED: "command_succeeded",
531
- COMMAND_FAILED: "command_failed",
532
968
  COMMAND_UNKNOWN: "command_unknown",
533
969
  ERROR_OCCURRED: "error_occurred",
534
970
  HELP_DISPLAYED: "help_displayed",
535
- VERSION_DISPLAYED: "version_displayed",
971
+ INTERACTIVE_MENU_EXITED: "interactive_menu_exited",
536
972
  INTERACTIVE_MENU_OPENED: "interactive_menu_opened",
537
- INTERACTIVE_MENU_EXITED: "interactive_menu_exited"
973
+ VERSION_DISPLAYED: "version_displayed"
538
974
  };
539
975
  function isEnvDisabled(prefix) {
540
976
  const value = process.env[`${prefix}_TELEMETRY_DISABLED`];
541
977
  return value === "1" || value === "true";
542
978
  }
979
+ /**
980
+ * Creates a no-op telemetry client.
981
+ *
982
+ * @returns A telemetry implementation whose methods do nothing and whose
983
+ * `isDisabled()` method returns `true`.
984
+ */
543
985
  function createDisabledTelemetry() {
544
986
  return {
545
- trackEvent: () => {},
546
- trackCommand: () => {},
547
- trackError: () => {},
548
987
  flush: async () => {},
988
+ flushBackground: () => {},
989
+ isDisabled: () => true,
549
990
  shutdown: async () => {},
550
- isDisabled: () => true
991
+ trackCommand: () => {},
992
+ trackError: () => {},
993
+ trackEvent: () => {}
551
994
  };
552
995
  }
996
+ /**
997
+ * Creates the built-in telemetry client.
998
+ *
999
+ * @remarks
1000
+ * Events are queued in memory. `flush()` posts the queue to `endpoint` when one
1001
+ * is configured, then clears the queue. Failed flushes are reported through
1002
+ * the optional logger and do not throw, keeping telemetry best effort.
1003
+ *
1004
+ * @param options - Telemetry behavior and event defaults.
1005
+ * @returns An enabled or disabled telemetry client depending on options and
1006
+ * environment opt-out variables.
1007
+ */
553
1008
  function createTelemetry(options = {}) {
554
1009
  const envVarPrefix = options.envVarPrefix ?? "APP";
555
1010
  const disabled = options.disabled === true || isEnvDisabled(envVarPrefix);
@@ -573,45 +1028,57 @@ function createTelemetry(options = {}) {
573
1028
  events.length = 0;
574
1029
  return;
575
1030
  }
576
- const batch = events.splice(0, events.length);
1031
+ const batch = events.splice(0);
577
1032
  try {
578
1033
  await fetch(options.endpoint, {
579
- method: "POST",
580
- headers: { "content-type": "application/json" },
581
1034
  body: JSON.stringify({ events: batch }),
582
- keepalive: true
1035
+ headers: { "content-type": "application/json" },
1036
+ keepalive: true,
1037
+ method: "POST"
583
1038
  });
584
1039
  } catch (error) {
585
1040
  options.logger?.warn(`Failed to send telemetry: ${error instanceof Error ? error.message : String(error)}`);
586
1041
  }
587
1042
  };
588
1043
  return {
589
- trackEvent,
1044
+ flush,
1045
+ flushBackground() {
1046
+ flush();
1047
+ },
1048
+ isDisabled() {
1049
+ return false;
1050
+ },
1051
+ async shutdown() {
1052
+ await flush();
1053
+ },
590
1054
  trackCommand(command, args = [], flags = {}) {
1055
+ const enabledFlags = Object.entries(flags).filter(([, value]) => value !== false && value !== void 0).map(([key]) => key);
1056
+ enabledFlags.sort();
591
1057
  trackEvent(TelemetryEventName.COMMAND_INVOKED, {
592
- command,
593
1058
  argsCount: args.length,
594
- enabledFlags: Object.entries(flags).filter(([, value]) => value !== false && value !== void 0).map(([key]) => key).sort()
1059
+ command,
1060
+ enabledFlags
595
1061
  });
596
1062
  },
597
1063
  trackError(error, command) {
598
1064
  trackEvent(TelemetryEventName.ERROR_OCCURRED, {
599
1065
  command,
600
- errorName: error.name,
601
- errorMessage: error.message
1066
+ errorMessage: error.message,
1067
+ errorName: error.name
602
1068
  });
603
1069
  },
604
- flush,
605
- async shutdown() {
606
- await flush();
607
- },
608
- isDisabled() {
609
- return false;
610
- }
1070
+ trackEvent
611
1071
  };
612
1072
  }
613
1073
  //#endregion
614
1074
  //#region src/context.ts
1075
+ /**
1076
+ * Resolves the active logger level from parsed CLI flags.
1077
+ *
1078
+ * @param parsedFlags - Parsed global flags from the current invocation.
1079
+ * @returns A valid log level, falling back to `info` for missing or invalid
1080
+ * values.
1081
+ */
615
1082
  function getLogLevel(parsedFlags) {
616
1083
  const levelArg = parsedFlags.logger;
617
1084
  if (typeof levelArg === "string") {
@@ -620,17 +1087,34 @@ function getLogLevel(parsedFlags) {
620
1087
  }
621
1088
  return "info";
622
1089
  }
1090
+ /**
1091
+ * Creates file-system helpers scoped to a project root.
1092
+ *
1093
+ * @param cwd - Project root used for package metadata lookup.
1094
+ * @returns File-system utilities for context consumers.
1095
+ */
623
1096
  function createFileSystem(cwd) {
624
1097
  return {
1098
+ async exists(filePath) {
1099
+ try {
1100
+ await fs.access(filePath);
1101
+ return true;
1102
+ } catch {
1103
+ return false;
1104
+ }
1105
+ },
625
1106
  getPackageInfo() {
626
1107
  const packageJsonPath = path.join(cwd, "package.json");
627
1108
  try {
628
1109
  const content = fsSync.readFileSync(packageJsonPath, "utf-8");
629
1110
  const packageInfo = JSON.parse(content);
1111
+ const packageFields = packageInfo && typeof packageInfo === "object" && !Array.isArray(packageInfo) ? packageInfo : {};
1112
+ const name = typeof packageFields.name === "string" ? packageFields.name : "unknown";
1113
+ const version = typeof packageFields.version === "string" ? packageFields.version : "unknown";
630
1114
  return {
631
- ...packageInfo,
632
- name: packageInfo.name || "unknown",
633
- version: packageInfo.version || "unknown"
1115
+ ...packageFields,
1116
+ name: name || "unknown",
1117
+ version: version || "unknown"
634
1118
  };
635
1119
  } catch {
636
1120
  return {
@@ -639,66 +1123,83 @@ function createFileSystem(cwd) {
639
1123
  };
640
1124
  }
641
1125
  },
642
- async exists(filePath) {
643
- try {
644
- await fs.access(filePath);
645
- return true;
646
- } catch {
647
- return false;
648
- }
1126
+ async mkdir(dirPath) {
1127
+ await fs.mkdir(dirPath, { recursive: true });
649
1128
  },
650
1129
  read(filePath) {
651
1130
  return fs.readFile(filePath, "utf-8");
652
1131
  },
653
1132
  write(filePath, content) {
654
1133
  return fs.writeFile(filePath, content, "utf-8");
655
- },
656
- async mkdir(dirPath) {
657
- await fs.mkdir(dirPath, { recursive: true });
658
1134
  }
659
1135
  };
660
1136
  }
1137
+ /**
1138
+ * Creates the resolved context passed to command actions.
1139
+ *
1140
+ * @remarks
1141
+ * Context creation performs the standard CLI bootstrap sequence: parse global
1142
+ * flags, create the logger, detect the project root, detect framework and
1143
+ * package manager metadata, set up telemetry, and attach helpers for config
1144
+ * loading, file-system access, confirmation prompts, and process-ending error
1145
+ * handling.
1146
+ *
1147
+ * @typeParam TPackage - Product-specific package identifier returned from
1148
+ * framework detection.
1149
+ * @param options - Context creation options for the current invocation.
1150
+ * @returns A fully initialized `CliContext`.
1151
+ *
1152
+ * @example
1153
+ * ```ts
1154
+ * const context = await createCliContext({
1155
+ * rawArgs: process.argv.slice(2),
1156
+ * appName: 'acme',
1157
+ * commands,
1158
+ * });
1159
+ *
1160
+ * await commands.find((command) => command.name === context.commandName)
1161
+ * ?.action(context);
1162
+ * ```
1163
+ */
661
1164
  async function createCliContext(options) {
662
1165
  const cwd = options.cwd ?? process.cwd();
663
1166
  const appName = options.appName ?? "cli";
664
- const { commandName, commandArgs, parsedFlags } = parseCliArgs(options.rawArgs, options.commands);
1167
+ const { commandName, commandArgs, parsedFlags } = parseCliArgs(options.rawArgs, options.commands, options.globalFlags);
665
1168
  const logger = createCliLogger(getLogLevel(parsedFlags));
666
1169
  const projectRoot = await detectProjectRoot(cwd, logger);
667
1170
  const fsUtils = createFileSystem(projectRoot);
668
1171
  const framework = await detectFramework(projectRoot, logger, options.packageMap);
669
1172
  const packageManager = await detectPackageManager(projectRoot, logger, { interactive: options.interactivePackageManagerDetection });
670
1173
  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
1174
  appName,
675
- envVarPrefix: options.telemetry?.envVarPrefix ?? appName.toUpperCase(),
1175
+ debug: options.telemetry?.debug === true || parsedFlags["telemetry-debug"] === true,
676
1176
  defaultProperties: {
677
- entryCommand: commandName ?? "interactive",
678
- commandArgsCount: commandArgs.length,
679
1177
  cliVersion: fsUtils.getPackageInfo().version,
1178
+ commandArgsCount: commandArgs.length,
1179
+ entryCommand: commandName ?? "interactive",
680
1180
  framework: framework.framework ?? "unknown",
681
1181
  frameworkVersion: framework.frameworkVersion ?? "unknown",
682
1182
  packageManager: packageManager.name,
683
1183
  ...options.telemetry?.defaultProperties
684
1184
  },
1185
+ disabled: options.telemetry?.disabled === true || parsedFlags["no-telemetry"] === true,
1186
+ endpoint: options.telemetry?.endpoint,
1187
+ envVarPrefix: options.telemetry?.envVarPrefix ?? appName.toUpperCase(),
685
1188
  logger
686
1189
  });
687
1190
  const errorHandlers = createErrorHandlers(logger, telemetry);
688
1191
  const context = {
689
- logger,
690
- flags: parsedFlags,
691
- commandName,
692
1192
  commandArgs,
693
- cwd,
694
- error: errorHandlers,
1193
+ commandName,
695
1194
  config: {
1195
+ getPathAliases() {
1196
+ return null;
1197
+ },
696
1198
  async loadConfig() {
697
- const configPath = typeof parsedFlags.config === "string" ? parsedFlags.config : void 0;
698
1199
  const { config } = await loadConfig({
699
- name: options.configName ?? appName,
1200
+ configFile: typeof parsedFlags.config === "string" ? parsedFlags.config : void 0,
700
1201
  cwd: projectRoot,
701
- configFile: configPath
1202
+ name: options.configName ?? appName
702
1203
  });
703
1204
  return config ?? null;
704
1205
  },
@@ -706,89 +1207,117 @@ async function createCliContext(options) {
706
1207
  const config = await this.loadConfig();
707
1208
  if (!config) throw new CliError("CONFIG_NOT_FOUND");
708
1209
  return config;
709
- },
710
- getPathAliases() {
711
- return null;
712
1210
  }
713
1211
  },
714
- fs: fsUtils,
715
- telemetry,
716
1212
  async confirm(message, initialValue = true) {
717
1213
  if (parsedFlags.y === true || parsedFlags.yes === true) return true;
718
1214
  const result = await p.confirm({
719
- message,
720
- initialValue
1215
+ initialValue,
1216
+ message
721
1217
  });
722
1218
  if (p.isCancel(result)) errorHandlers.handleCancel("Confirmation cancelled");
723
1219
  return result;
724
1220
  },
725
- projectRoot,
1221
+ cwd,
1222
+ error: errorHandlers,
1223
+ flags: parsedFlags,
726
1224
  framework,
727
- packageManager
1225
+ fs: fsUtils,
1226
+ logger,
1227
+ packageManager,
1228
+ projectRoot,
1229
+ telemetry
728
1230
  };
729
1231
  telemetry.trackEvent(TelemetryEventName.CLI_ENVIRONMENT_DETECTED, {
730
1232
  command: commandName ?? "interactive",
731
- projectRootChanged: projectRoot !== cwd,
732
1233
  framework: framework.framework ?? "unknown",
733
1234
  frameworkVersion: framework.frameworkVersion ?? "unknown",
734
- packageManager: packageManager.name,
735
1235
  hasReact: framework.hasReact,
1236
+ packageManager: packageManager.name,
1237
+ projectRootChanged: projectRoot !== cwd,
736
1238
  reactVersion: framework.reactVersion ?? "unknown",
737
1239
  tailwindVersion: framework.tailwindVersion ?? "unknown"
738
1240
  });
739
1241
  return context;
740
1242
  }
1243
+ /**
1244
+ * Creates a deterministic context for unit tests.
1245
+ *
1246
+ * @remarks
1247
+ * The test context disables telemetry, uses an error-only logger, avoids real
1248
+ * framework or package-manager detection, and provides no-op file-system
1249
+ * helpers. Pass overrides to replace only the services a test needs.
1250
+ *
1251
+ * @param overrides - Partial context fields to merge into the default test
1252
+ * context.
1253
+ * @returns A `CliContext` suitable for command and helper tests.
1254
+ *
1255
+ * @example
1256
+ * ```ts
1257
+ * const context = createTestContext({
1258
+ * flags: { force: true },
1259
+ * commandName: 'init',
1260
+ * });
1261
+ * ```
1262
+ */
741
1263
  function createTestContext(overrides = {}) {
742
1264
  const logger = createCliLogger("error");
743
1265
  const telemetry = createDisabledTelemetry();
744
1266
  const error = createErrorHandlers(logger, telemetry);
745
1267
  return {
746
- logger,
747
- flags: {},
748
- commandName: void 0,
749
1268
  commandArgs: [],
750
- cwd: process.cwd(),
751
- error,
1269
+ commandName: void 0,
752
1270
  config: {
753
- loadConfig: async () => null,
754
- requireConfig: async () => {
755
- throw new CliError("CONFIG_NOT_FOUND");
756
- },
757
- getPathAliases: () => null
1271
+ getPathAliases: () => null,
1272
+ loadConfig: () => Promise.resolve(null),
1273
+ requireConfig: () => Promise.reject(new CliError("CONFIG_NOT_FOUND"))
758
1274
  },
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 () => {}
768
- },
769
- telemetry,
770
- confirm: async () => true,
771
- projectRoot: process.cwd(),
1275
+ confirm: () => Promise.resolve(true),
1276
+ cwd: process.cwd(),
1277
+ error,
1278
+ flags: {},
772
1279
  framework: {
773
1280
  framework: null,
774
1281
  frameworkVersion: null,
775
- pkg: null,
776
1282
  hasReact: false,
1283
+ pkg: null,
777
1284
  reactVersion: null,
778
1285
  tailwindVersion: null
779
1286
  },
1287
+ fs: {
1288
+ exists: () => Promise.resolve(false),
1289
+ getPackageInfo: () => ({
1290
+ name: "test",
1291
+ version: "0.0.0"
1292
+ }),
1293
+ mkdir: () => Promise.resolve(),
1294
+ read: () => Promise.resolve(""),
1295
+ write: () => Promise.resolve()
1296
+ },
1297
+ logger,
780
1298
  packageManager: {
781
- name: "npm",
782
- installCommand: "npm install",
783
1299
  addCommand: "npm install",
784
- runCommand: "npm run",
785
- execCommand: "npx"
1300
+ execCommand: "npx",
1301
+ installCommand: "npm install",
1302
+ name: "npm",
1303
+ runCommand: "npm run"
786
1304
  },
1305
+ projectRoot: process.cwd(),
1306
+ telemetry,
787
1307
  ...overrides
788
1308
  };
789
1309
  }
790
1310
  //#endregion
791
1311
  //#region src/help.ts
1312
+ /**
1313
+ * Renders a help menu for commands and global flags.
1314
+ *
1315
+ * @param context - Context subset providing the logger used for output.
1316
+ * @param options - Application metadata for the help menu.
1317
+ * @param commands - Commands to list; commands with `hidden: true` are
1318
+ * omitted.
1319
+ * @param flags - Global flags to list.
1320
+ */
792
1321
  function showHelpMenu(context, options, commands, flags) {
793
1322
  const commandRows = commands.filter((command) => !command.hidden).map((command) => ` ${command.name.padEnd(16)} ${command.description}`).join("\n");
794
1323
  const flagRows = flags.map((flag) => {
@@ -800,17 +1329,23 @@ function showHelpMenu(context, options, commands, flags) {
800
1329
  }
801
1330
  //#endregion
802
1331
  //#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
- });
1332
+ const renderFigletAsync = promisify(figlet);
1333
+ async function renderFiglet(text) {
1334
+ try {
1335
+ return await renderFigletAsync(text) ?? text;
1336
+ } catch {
1337
+ return text;
1338
+ }
813
1339
  }
1340
+ /**
1341
+ * Renders a figlet banner and short intro note.
1342
+ *
1343
+ * @remarks
1344
+ * If figlet rendering fails, the plain banner text is displayed instead.
1345
+ *
1346
+ * @param context - Context subset providing the logger used for output.
1347
+ * @param options - Intro banner metadata.
1348
+ */
814
1349
  async function displayIntro(context, options) {
815
1350
  const banner = await renderFiglet(options.figletText ?? options.appName);
816
1351
  const versionLabel = options.version ? ` v${options.version}` : "";
@@ -819,49 +1354,104 @@ async function displayIntro(context, options) {
819
1354
  }
820
1355
  //#endregion
821
1356
  //#region src/spinner.ts
1357
+ /**
1358
+ * Creates a Clack-backed spinner.
1359
+ *
1360
+ * @param initialMessage - Message used when `start()` is called without an
1361
+ * explicit message.
1362
+ * @returns A spinner controller.
1363
+ */
822
1364
  function createSpinner(initialMessage) {
823
- const spinner = p.spinner();
1365
+ const spinnerInstance = spinner();
824
1366
  return {
1367
+ message(message) {
1368
+ spinnerInstance.message(message);
1369
+ },
825
1370
  start(message) {
826
- spinner.start(message || initialMessage || "Processing...");
1371
+ spinnerInstance.start(message ?? initialMessage ?? "Processing...");
827
1372
  },
828
1373
  stop(message) {
829
- spinner.stop(message || "Done");
830
- },
831
- message(message) {
832
- spinner.message(message);
1374
+ spinnerInstance.stop(message ?? "Done");
833
1375
  }
834
1376
  };
835
1377
  }
1378
+ /**
1379
+ * Runs an async task while displaying a spinner.
1380
+ *
1381
+ * @typeParam T - Value returned by the task.
1382
+ * @param message - Message shown when the spinner starts.
1383
+ * @param task - Async work to run.
1384
+ * @param options - Optional success and error messages shown when the task
1385
+ * settles.
1386
+ * @returns The value returned by `task`.
1387
+ *
1388
+ * @throws Re-throws any error from `task` after stopping the spinner.
1389
+ */
836
1390
  async function withSpinner(message, task, options) {
837
- const spinner = createSpinner(message);
838
- spinner.start();
1391
+ const spinnerInstance = createSpinner(message);
1392
+ spinnerInstance.start();
839
1393
  try {
840
1394
  const result = await task();
841
- spinner.stop(options?.successMessage || "Done");
1395
+ spinnerInstance.stop(options?.successMessage ?? "Done");
842
1396
  return result;
843
1397
  } catch (error) {
844
- spinner.stop(options?.errorMessage || "Failed");
1398
+ spinnerInstance.stop(options?.errorMessage ?? "Failed");
845
1399
  throw error;
846
1400
  }
847
1401
  }
848
1402
  //#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`);
1403
+ //#region src/version-check/compare.ts
1404
+ function parseVersionParts(version) {
1405
+ const [coreVersion = "", prerelease] = version.replace(/^[^\d]*/, "").split("-", 2);
1406
+ return {
1407
+ parts: coreVersion.split(".").map((part) => {
1408
+ const parsed = Number.parseInt(part.replace(/\D.*$/, ""), 10);
1409
+ return Number.isNaN(parsed) ? 0 : parsed;
1410
+ }),
1411
+ prerelease
1412
+ };
1413
+ }
1414
+ function comparePrereleaseIdentifiers(left, right) {
1415
+ const leftIsNumeric = /^\d+$/.test(left);
1416
+ const rightIsNumeric = /^\d+$/.test(right);
1417
+ if (leftIsNumeric && rightIsNumeric) return Math.sign(Number.parseInt(left, 10) - Number.parseInt(right, 10));
1418
+ if (leftIsNumeric) return -1;
1419
+ if (rightIsNumeric) return 1;
1420
+ return Math.sign(left.localeCompare(right));
1421
+ }
1422
+ function comparePrerelease(left, right) {
1423
+ if (!left && !right) return 0;
1424
+ if (!left) return 1;
1425
+ if (!right) return -1;
1426
+ const leftParts = left.split(".");
1427
+ const rightParts = right.split(".");
1428
+ const length = Math.max(leftParts.length, rightParts.length);
1429
+ for (let index = 0; index < length; index++) {
1430
+ const leftValue = leftParts[index];
1431
+ const rightValue = rightParts[index];
1432
+ if (leftValue === void 0) return -1;
1433
+ if (rightValue === void 0) return 1;
1434
+ const result = comparePrereleaseIdentifiers(leftValue, rightValue);
1435
+ if (result !== 0) return result;
860
1436
  }
861
- };
862
- function isVersionRequest(rawArgs) {
863
- return rawArgs.includes("-v") || rawArgs.includes("--version");
1437
+ return 0;
1438
+ }
1439
+ function compareVersions(left, right) {
1440
+ const leftVersion = parseVersionParts(left);
1441
+ const rightVersion = parseVersionParts(right);
1442
+ const leftParts = leftVersion.parts;
1443
+ const rightParts = rightVersion.parts;
1444
+ const length = Math.max(leftParts.length, rightParts.length);
1445
+ for (let index = 0; index < length; index++) {
1446
+ const leftValue = leftParts[index] ?? 0;
1447
+ const rightValue = rightParts[index] ?? 0;
1448
+ if (leftValue > rightValue) return 1;
1449
+ if (leftValue < rightValue) return -1;
1450
+ }
1451
+ return comparePrerelease(leftVersion.prerelease, rightVersion.prerelease);
864
1452
  }
1453
+ //#endregion
1454
+ //#region src/version-check/install-source.ts
865
1455
  function normalizePath(filePath) {
866
1456
  return filePath.replaceAll("\\", "/");
867
1457
  }
@@ -884,12 +1474,28 @@ function isPathUnder(candidate, parent) {
884
1474
  function envValue(name) {
885
1475
  return process.env[name];
886
1476
  }
1477
+ /**
1478
+ * Infers how the current CLI binary was installed.
1479
+ *
1480
+ * @remarks
1481
+ * Detection is heuristic and based on binary path, realpath, and package
1482
+ * manager environment variables. Unknown or transient install modes return
1483
+ * `unknown` instead of throwing.
1484
+ *
1485
+ * @param binPath - Binary path to inspect.
1486
+ * @returns The inferred installation source.
1487
+ */
887
1488
  function detectInstallSource(binPath = process.argv[1] ?? "") {
888
1489
  const resolvedPath = normalizePath(safeRealpath(binPath || ""));
889
1490
  const npmPrefix = envValue("npm_config_prefix");
1491
+ const execPath = normalizePath(process.execPath);
1492
+ const argvPath = normalizePath(process.argv[0] ?? "");
1493
+ const execName = path$1.basename(execPath);
1494
+ const argvName = path$1.basename(argvPath);
1495
+ const isBunInvocation = execName === "bun" || execName === "bunx" || argvName === "bun" || argvName === "bunx";
890
1496
  if (resolvedPath.includes("/opt/homebrew/") || resolvedPath.includes("/usr/local/Cellar/") || resolvedPath.includes("/home/linuxbrew/") || resolvedPath.includes("/Homebrew/Cellar/")) return "brew";
891
1497
  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";
1498
+ if (resolvedPath.includes("/.bun/install/cache/") || Boolean(envValue("BUN_INSTALL")) && (resolvedPath.includes("/.bun/install/run/") || isBunInvocation)) return "bunx";
893
1499
  if (resolvedPath.includes("/.pnpm-store/") || resolvedPath.includes("/dlx-")) return "pnpm-dlx";
894
1500
  if (resolvedPath.includes("/.yarn/berry/cache/") || resolvedPath.includes("/yarn/dlx-")) return "yarn-dlx";
895
1501
  if (resolvedPath.includes("/node_modules/.bin/") || resolvedPath.endsWith("/node_modules/.bin")) return "local";
@@ -906,6 +1512,15 @@ function detectInstallSource(binPath = process.argv[1] ?? "") {
906
1512
  })) return "npm-global";
907
1513
  return "unknown";
908
1514
  }
1515
+ /**
1516
+ * Builds an update command for an installation source.
1517
+ *
1518
+ * @param source - Installation source returned by `detectInstallSource`.
1519
+ * @param packageName - Package name to update.
1520
+ * @param brewFormula - Homebrew formula name when `source` is `brew`.
1521
+ * @returns A command string when the source has an actionable update path,
1522
+ * otherwise `null`.
1523
+ */
909
1524
  function getUpdateCommand(source, packageName, brewFormula = packageName) {
910
1525
  switch (source) {
911
1526
  case "npm-global": return `npm install -g ${packageName}@latest`;
@@ -914,8 +1529,48 @@ function getUpdateCommand(source, packageName, brewFormula = packageName) {
914
1529
  default: return null;
915
1530
  }
916
1531
  }
1532
+ //#endregion
1533
+ //#region src/version-check/registry.ts
1534
+ const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
1535
+ const DEFAULT_TIMEOUT_MS = 1500;
1536
+ const DEFAULT_CACHE_TTL_MS = 1440 * 60 * 1e3;
1537
+ const MAX_CACHE_NAME_LENGTH = 80;
1538
+ const WINDOWS_RESERVED_NAMES = new Set([
1539
+ "con",
1540
+ "prn",
1541
+ "aux",
1542
+ "nul",
1543
+ "com1",
1544
+ "com2",
1545
+ "com3",
1546
+ "com4",
1547
+ "com5",
1548
+ "com6",
1549
+ "com7",
1550
+ "com8",
1551
+ "com9",
1552
+ "lpt1",
1553
+ "lpt2",
1554
+ "lpt3",
1555
+ "lpt4",
1556
+ "lpt5",
1557
+ "lpt6",
1558
+ "lpt7",
1559
+ "lpt8",
1560
+ "lpt9"
1561
+ ]);
1562
+ function getCacheNameHash(packageName) {
1563
+ return createHash("sha256").update(packageName).digest("hex").slice(0, 8);
1564
+ }
917
1565
  function sanitizeCacheName(packageName) {
918
- return packageName.replaceAll("/", "__").replaceAll("@", "");
1566
+ const normalized = packageName.normalize("NFKD").toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "_").replaceAll(/_+/g, "_").replaceAll(/^[._-]+|[._-]+$/g, "");
1567
+ const fallbackName = normalized || "package";
1568
+ const baseName = fallbackName.split(".")[0] ?? fallbackName;
1569
+ const isReservedName = WINDOWS_RESERVED_NAMES.has(baseName);
1570
+ if (!(normalized !== packageName || normalized.length === 0 || isReservedName || normalized.length > MAX_CACHE_NAME_LENGTH)) return normalized;
1571
+ const hash = getCacheNameHash(packageName);
1572
+ const maxStemLength = MAX_CACHE_NAME_LENGTH - hash.length - 1;
1573
+ return `${(isReservedName ? `package-${fallbackName}` : fallbackName).slice(0, maxStemLength).replaceAll(/[._-]+$/g, "") || "package"}-${hash}`;
919
1574
  }
920
1575
  function getCacheDir(options) {
921
1576
  return options.cacheDir ?? path$1.join(os.tmpdir(), "hexbus-version-cache");
@@ -928,8 +1583,8 @@ function readCachedVersion(options) {
928
1583
  const content = fsSync$1.readFileSync(getCachePath(options), "utf-8");
929
1584
  const parsed = JSON.parse(content);
930
1585
  if (typeof parsed.version === "string" && typeof parsed.fetchedAt === "number") return {
931
- version: parsed.version,
932
- fetchedAt: parsed.fetchedAt
1586
+ fetchedAt: parsed.fetchedAt,
1587
+ version: parsed.version
933
1588
  };
934
1589
  } catch {}
935
1590
  return null;
@@ -942,52 +1597,19 @@ function isCacheFresh(cache, options) {
942
1597
  async function writeCachedVersion(options, version) {
943
1598
  const cachePath = getCachePath(options);
944
1599
  const cacheDir = path$1.dirname(cachePath);
945
- const tempPath = `${cachePath}.${process.pid}.tmp`;
1600
+ const tempPath = `${cachePath}.${process.pid}.${randomUUID()}.tmp`;
946
1601
  const payload = {
947
- version,
948
- fetchedAt: options.now?.() ?? Date.now()
1602
+ fetchedAt: options.now?.() ?? Date.now(),
1603
+ version
949
1604
  };
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;
1605
+ try {
1606
+ await fs$1.mkdir(cacheDir, { recursive: true });
1607
+ await fs$1.writeFile(tempPath, `${JSON.stringify(payload)}\n`, "utf-8");
1608
+ await fs$1.rename(tempPath, cachePath);
1609
+ } catch (error) {
1610
+ await fs$1.unlink(tempPath).catch(() => {});
1611
+ throw error;
969
1612
  }
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
1613
  }
992
1614
  async function fetchLatestVersion(options) {
993
1615
  const controller = new AbortController();
@@ -1010,11 +1632,24 @@ async function refreshCache(options) {
1010
1632
  if (latestVersion) await writeCachedVersion(options, latestVersion);
1011
1633
  return latestVersion;
1012
1634
  }
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));
1635
+ //#endregion
1636
+ //#region src/version-check/check.ts
1637
+ /**
1638
+ * Checks whether raw arguments request version output.
1639
+ *
1640
+ * @param rawArgs - Arguments after executable and script path.
1641
+ * @returns `true` when `-v` or `--version` is present.
1642
+ */
1643
+ function isVersionRequest(rawArgs) {
1644
+ return rawArgs.includes("-v") || rawArgs.includes("--version");
1017
1645
  }
1646
+ /**
1647
+ * Formats a user-facing update hint from an update-check result.
1648
+ *
1649
+ * @param result - Update-check result to render.
1650
+ * @returns A formatted hint when the result is outdated and has an update
1651
+ * command, otherwise `null`.
1652
+ */
1018
1653
  function formatUpdateHint(result) {
1019
1654
  if (!result.isOutdated || result.updateCommand === null || result.latestVersion === null) return null;
1020
1655
  if (result.source === "brew") return [
@@ -1028,23 +1663,95 @@ function formatUpdateHint(result) {
1028
1663
  ` ${color.cyan(result.updateCommand)}`
1029
1664
  ].join("\n");
1030
1665
  }
1666
+ function createUpdateCheckResult(options, latestVersion) {
1667
+ const source = detectInstallSource(options.binPath);
1668
+ const updateCommand = getUpdateCommand(source, options.packageName, options.brewFormula);
1669
+ const isOutdated = typeof latestVersion === "string" && compareVersions(options.currentVersion, latestVersion) < 0;
1670
+ const result = {
1671
+ currentVersion: options.currentVersion,
1672
+ isOutdated,
1673
+ latestVersion,
1674
+ source,
1675
+ updateCommand
1676
+ };
1677
+ const hint = formatUpdateHint({
1678
+ ...result,
1679
+ hint: null
1680
+ });
1681
+ return {
1682
+ ...result,
1683
+ hint
1684
+ };
1685
+ }
1686
+ /**
1687
+ * Checks whether a newer package version is available.
1688
+ *
1689
+ * @remarks
1690
+ * The function first reads the local cache. On a cache miss it refreshes the
1691
+ * cache from the configured registry. Refresh failures produce a result with
1692
+ * `latestVersion: null` rather than throwing.
1693
+ *
1694
+ * @param options - Update-check configuration.
1695
+ * @returns Update metadata and a formatted hint when an update is available.
1696
+ */
1697
+ async function checkForUpdate(options) {
1698
+ const cached = readCachedVersion(options);
1699
+ if (cached) return createUpdateCheckResult(options, cached.version);
1700
+ try {
1701
+ return createUpdateCheckResult(options, await refreshCache(options));
1702
+ } catch (error) {
1703
+ options.logger?.debug?.(`Update check failed: ${error instanceof Error ? error.message : String(error)}`);
1704
+ return createUpdateCheckResult(options, null);
1705
+ }
1706
+ }
1707
+ //#endregion
1708
+ //#region src/version-check/display.ts
1709
+ const defaultLogger = {
1710
+ message(message) {
1711
+ process.stdout.write(`${message}\n`);
1712
+ },
1713
+ note(content, title) {
1714
+ const prefix = title ? `${title}\n` : "";
1715
+ process.stdout.write(`${prefix}${content}\n`);
1716
+ }
1717
+ };
1718
+ /**
1719
+ * Prints CLI version information and any available update hint.
1720
+ *
1721
+ * @param options - Version display and update-check options.
1722
+ */
1031
1723
  async function printVersionInfo(options) {
1032
1724
  const logger = options.logger ?? defaultLogger;
1033
1725
  logger.message(`${options.appName} v${options.currentVersion}`);
1034
1726
  const result = await checkForUpdate(options);
1035
1727
  if (result.hint) logger.note(result.hint, "Update available");
1036
1728
  }
1729
+ async function refreshCacheInBackground(options, logger) {
1730
+ try {
1731
+ await refreshCache(options);
1732
+ } catch (error) {
1733
+ logger.debug?.(`Update check failed: ${error instanceof Error ? error.message : String(error)}`);
1734
+ }
1735
+ }
1736
+ /**
1737
+ * Starts a non-blocking update check.
1738
+ *
1739
+ * @remarks
1740
+ * Cached hints are displayed synchronously when available. If the cache is
1741
+ * stale or missing, a refresh is started in the background and failures are
1742
+ * only logged at debug level.
1743
+ *
1744
+ * @param options - Version display and update-check options.
1745
+ */
1037
1746
  function startBackgroundUpdateCheck(options) {
1038
1747
  const logger = options.logger ?? defaultLogger;
1039
1748
  const cached = readCachedVersion(options);
1040
1749
  if (cached) {
1041
- const result = createResult(options, cached.version);
1750
+ const result = createUpdateCheckResult(options, cached.version);
1042
1751
  if (result.hint) logger.note(result.hint, "Update available");
1043
1752
  }
1044
1753
  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
- });
1754
+ refreshCacheInBackground(options, logger);
1048
1755
  }
1049
1756
  //#endregion
1050
1757
  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 };