@abarcenas/ai-workflow-template 1.29.0 → 1.33.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.
@@ -0,0 +1,521 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // ui.js — User interface / output formatting for the setup command pipeline
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+
5
+ import { readFileSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ import {
9
+ UNICODE_CHARS,
10
+ PACKAGE_NAME,
11
+ BIN_NAME,
12
+ SUPPORTED_FLAGS,
13
+ } from './constants.js'
14
+ import { resolvePackageRoot } from './utils.js'
15
+
16
+ // ── Colour support ──────────────────────────────────────────────────────────
17
+
18
+ /** `true` when stdout is a TTY — ANSI escape codes are emitted. */
19
+ const useColor = process.stdout.isTTY
20
+
21
+ /**
22
+ * Builds an ANSI SGR escape sequence.
23
+ *
24
+ * Returns an empty string when colours are disabled (piped / non-TTY output)
25
+ * so that log files stay clean.
26
+ *
27
+ * @param {string} code - SGR parameter(s), e.g. `'1'`, `'32m'`, `'1;31m'`.
28
+ * @returns {string} Escape sequence, or `''` when colours are off.
29
+ */
30
+ function ansi(code) {
31
+ return useColor ? `\x1b[${code}` : ''
32
+ }
33
+
34
+ /** ANSI reset. */
35
+ const RST = ansi('0m')
36
+ /** ANSI bold / increased intensity. */
37
+ const BLD = ansi('1m')
38
+ /** ANSI dim / decreased intensity. */
39
+ const DIM = ansi('2m')
40
+ /** ANSI green foreground. */
41
+ const GRN = ansi('32m')
42
+ /** ANSI yellow foreground. */
43
+ const YLW = ansi('33m')
44
+ /** ANSI red foreground. */
45
+ const RED = ansi('31m')
46
+ /** ANSI cyan foreground. */
47
+ const CYN = ansi('36m')
48
+
49
+ /**
50
+ * Read-only colour palette for use by downstream consumers.
51
+ *
52
+ * @type {{ reset: string, bold: string, dim: string, green: string,
53
+ * yellow: string, red: string, cyan: string }}
54
+ */
55
+ export const COLORS = Object.freeze({
56
+ reset: RST,
57
+ bold: BLD,
58
+ dim: DIM,
59
+ green: GRN,
60
+ yellow: YLW,
61
+ red: RED,
62
+ cyan: CYN,
63
+ })
64
+
65
+ // ── Helpers ─────────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Strips ANSI escape sequences from a string.
69
+ *
70
+ * @param {string} str - Input that may contain ANSI codes.
71
+ * @returns {string} Plain text without escape sequences.
72
+ */
73
+ function stripAnsi(str) {
74
+ return str.replace(/\x1b\[[0-9;]*m/g, '')
75
+ }
76
+
77
+ /**
78
+ * Returns the visible display width of a string, treating ANSI codes as
79
+ * zero-width.
80
+ *
81
+ * @param {string} str - Input that may contain ANSI codes.
82
+ * @returns {number} Number of visible characters.
83
+ */
84
+ function visibleWidth(str) {
85
+ return stripAnsi(str).length
86
+ }
87
+
88
+ /**
89
+ * Returns a horizontal rule of the given character, bounded by the terminal
90
+ * width.
91
+ *
92
+ * @param {string} [char='─'] - Character to repeat.
93
+ * @param {number} [maxWidth] - Maximum available width (defaults to terminal
94
+ * columns, capped at 72).
95
+ * @returns {string} Rule string.
96
+ */
97
+ function rule(char = '\u2500', maxWidth) {
98
+ const width = maxWidth ?? Math.min(process.stdout.columns || 72, 72)
99
+ return char.repeat(width)
100
+ }
101
+
102
+ // ── Version caching ─────────────────────────────────────────────────────────
103
+
104
+ /** @type {string|null} */
105
+ let _version = null
106
+
107
+ /**
108
+ * Reads the package version from the project's `package.json`.
109
+ *
110
+ * The result is cached after the first successful read so repeated calls
111
+ * (e.g. `header()` then `help()`) don't re-read the filesystem.
112
+ *
113
+ * @returns {string} Semver string (e.g. `'1.29.0'`). Falls back to `'0.0.0'`.
114
+ */
115
+ function getVersion() {
116
+ if (_version === null) {
117
+ try {
118
+ const pkgRoot = resolvePackageRoot()
119
+ const pkgPath = join(pkgRoot, 'package.json')
120
+ const raw = readFileSync(pkgPath, 'utf-8')
121
+ _version = JSON.parse(raw).version || '0.0.0'
122
+ } catch {
123
+ _version = '0.0.0'
124
+ }
125
+ }
126
+ return _version
127
+ }
128
+
129
+ // ── Write helpers ───────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Writes one or more lines to stdout.
133
+ *
134
+ * @param {...string} lines - Lines to print.
135
+ */
136
+ function write(...lines) {
137
+ for (const line of lines) {
138
+ console.log(line)
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Prints a dimmed verbose/debug message to stdout when verbose mode is
144
+ * enabled.
145
+ *
146
+ * When `enabled` is falsy the function is a no-op, making it safe to call
147
+ * unconditionally in hot paths without an extra `if` guard.
148
+ *
149
+ * @param {*} enabled - Truthy to print, falsy to skip.
150
+ * @param {string} message - Debug message to display.
151
+ * @example
152
+ * verbose(true, 'Resolving consumer root from INIT_CWD\u2026')
153
+ * // \u2026 Resolving consumer root from INIT_CWD\u2026
154
+ */
155
+ export function verbose(enabled, message) {
156
+ if (!enabled) return
157
+ write(` ${DIM}\u2026 ${message}${RST}`)
158
+ }
159
+
160
+ // ── Public output API ───────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Prints the setup banner showing the package name and version.
164
+ *
165
+ * Example:
166
+ *
167
+ * ────────────────────────────────────────────────────────────
168
+ * @abarcenas/ai-workflow-template v1.29.0 — Setup
169
+ * ────────────────────────────────────────────────────────────
170
+ */
171
+ export function header() {
172
+ const version = getVersion()
173
+ const width = Math.min(process.stdout.columns || 72, 72)
174
+ const sep = DIM + rule() + RST
175
+ const title =
176
+ ` ${BLD}${PACKAGE_NAME}${RST}` +
177
+ ` ${GRN}v${version}${RST}` +
178
+ ` ${DIM}\u2014${RST}` +
179
+ ` ${BLD}Setup${RST}`
180
+
181
+ write()
182
+ write(sep)
183
+ write(title)
184
+ write(sep)
185
+ write()
186
+ }
187
+
188
+ /**
189
+ * Prints a progress step indicator with a rightwards arrow.
190
+ *
191
+ * @param {string} message - Description of the step being performed.
192
+ * @example
193
+ * step('Checking environment…')
194
+ * // → Checking environment…
195
+ */
196
+ export function step(message) {
197
+ write(` ${UNICODE_CHARS.ARROW} ${message}`)
198
+ }
199
+
200
+ /**
201
+ * Prints a step that completed successfully (checkmark).
202
+ *
203
+ * @param {string} message - Description of the completed step.
204
+ * @example
205
+ * stepSuccess('Environment ready')
206
+ * // ✓ Environment ready
207
+ */
208
+ export function stepSuccess(message) {
209
+ write(` ${GRN}${UNICODE_CHARS.CHECK}${RST} ${message}`)
210
+ }
211
+
212
+ /**
213
+ * Prints a step that completed with a warning.
214
+ *
215
+ * @param {string} message - Warning description.
216
+ * @example
217
+ * stepWarn('Husky version is newer than expected')
218
+ * // ⚠ Husky version is newer than expected
219
+ */
220
+ export function stepWarn(message) {
221
+ write(` ${YLW}${UNICODE_CHARS.WARN}${RST} ${message}`)
222
+ }
223
+
224
+ /**
225
+ * Prints a step that failed.
226
+ *
227
+ * @param {string} message - Error description.
228
+ * @example
229
+ * stepError('Git repository not found')
230
+ * // ✗ Git repository not found
231
+ */
232
+ export function stepError(message) {
233
+ write(` ${RED}${UNICODE_CHARS.CROSS}${RST} ${message}`)
234
+ }
235
+
236
+ /**
237
+ * Prints an informational message (default colour, stdout).
238
+ *
239
+ * @param {string} message - Message to display.
240
+ */
241
+ export function info(message) {
242
+ write(message)
243
+ }
244
+
245
+ /**
246
+ * Prints a warning message (yellow, bold) to stderr.
247
+ *
248
+ * @param {string} message - Warning message.
249
+ */
250
+ export function warn(message) {
251
+ console.error(`${YLW}${BLD}warning${RST} ${message}`)
252
+ }
253
+
254
+ /**
255
+ * Prints an error message (red, bold) to stderr.
256
+ *
257
+ * @param {string} message - Error message.
258
+ */
259
+ export function error(message) {
260
+ console.error(`${RED}${BLD}error${RST} ${message}`)
261
+ }
262
+
263
+ /**
264
+ * Prints a success message (green) to stdout.
265
+ *
266
+ * @param {string} message - Success message.
267
+ */
268
+ export function success(message) {
269
+ write(`${GRN}${UNICODE_CHARS.CHECK}${RST} ${message}`)
270
+ }
271
+
272
+ /**
273
+ * Prints a bold section header.
274
+ *
275
+ * @param {string} title - Section title.
276
+ * @example
277
+ * section('Environment Discovery')
278
+ * // Environment Discovery
279
+ */
280
+ export function section(title) {
281
+ write(`${BLD}${title}${RST}`)
282
+ }
283
+
284
+ /**
285
+ * Prints a formatted summary table from phase results.
286
+ *
287
+ * Each result entry is rendered as a single row in a text table with three
288
+ * columns: **Phase**, **Status**, and **Message**. The table is followed by
289
+ * an overall result line (success / warnings / errors).
290
+ *
291
+ * @param {Array<{ phase: string, status: string, message: string }>} results
292
+ * Phase results to display.
293
+ * - `phase` — Short phase name (e.g. `'discover'`, `'hooks'`)
294
+ * - `status` — One of `'success'`, `'warn'`, `'error'`, or a custom string
295
+ * - `message` — Human-readable result description
296
+ *
297
+ * @example
298
+ * summary([
299
+ * { phase: 'discover', status: 'success', message: 'Git repo found' },
300
+ * { phase: 'hooks', status: 'error', message: 'Permission denied' },
301
+ * ])
302
+ */
303
+ export function summary(results) {
304
+ if (!results || results.length === 0) return
305
+
306
+ const headerPhase = 'Phase'
307
+ const headerStatus = 'Status'
308
+ const headerMessage = 'Message'
309
+
310
+ // ── Column widths ───────────────────────────────────────────────────
311
+ const phases = results.map((r) => r.phase)
312
+ const statuses = results.map((r) => r.status)
313
+ const colPhase = Math.max(headerPhase.length, ...phases.map((s) => s.length))
314
+ const colStatus = Math.max(headerStatus.length, ...statuses.map((s) => s.length))
315
+ const colMsg = Math.max(
316
+ headerMessage.length,
317
+ ...results.map((r) => visibleWidth(r.message || '')),
318
+ )
319
+
320
+ // ── Status rendering ────────────────────────────────────────────────
321
+ /**
322
+ * Formats a status string with a colour-coded symbol.
323
+ *
324
+ * @param {string} s - Status key.
325
+ * @returns {string} Formatted status (may contain ANSI codes).
326
+ */
327
+ const fmtStatus = (s) => {
328
+ switch (s) {
329
+ case 'success':
330
+ return `${GRN}${UNICODE_CHARS.CHECK}${RST} success`
331
+ case 'warn':
332
+ return `${YLW}${UNICODE_CHARS.WARN}${RST} warn`
333
+ case 'error':
334
+ return `${RED}${UNICODE_CHARS.CROSS}${RST} error`
335
+ default:
336
+ return s
337
+ }
338
+ }
339
+
340
+ // Number of extra invisible chars added by the colour wrapper in fmtStatus
341
+ const statusAnsiExtra =
342
+ useColor && ['success', 'warn', 'error'].includes(statuses[0])
343
+ ? // Each coloured status wraps the symbol in open + close = 2 escapes
344
+ // e.g. `\x1b[32m✓\x1b[0m success` = 5+5+8 = 18 visible, but 28 chars
345
+ // The extra is the ANSI codes length per status
346
+ 10
347
+ : 0
348
+
349
+ // ── Print ───────────────────────────────────────────────────────────
350
+ write()
351
+ section('Setup Summary')
352
+ divider()
353
+
354
+ // Header
355
+ write(
356
+ ` ${headerPhase.padEnd(colPhase)} ` +
357
+ `${headerStatus.padEnd(colStatus + statusAnsiExtra)} ` +
358
+ `${headerMessage}`,
359
+ )
360
+
361
+ // Separator
362
+ write(
363
+ ` ${DIM}${'\u2500'.repeat(colPhase)}` +
364
+ ` ${'\u2500'.repeat(Math.max(colStatus, 6))}` +
365
+ ` ${'\u2500'.repeat(colMsg)}${RST}`,
366
+ )
367
+
368
+ // Rows
369
+ for (const r of results) {
370
+ const p = r.phase.padEnd(colPhase)
371
+ const s = fmtStatus(r.status).padEnd(colStatus + statusAnsiExtra)
372
+ write(` ${p} ${s} ${r.message || ''}`)
373
+ }
374
+
375
+ divider()
376
+
377
+ // Overall result
378
+ const hasErrors = results.some((r) => r.status === 'error')
379
+ const hasWarnings = results.some((r) => r.status === 'warn')
380
+ if (hasErrors) {
381
+ error('Setup completed with errors.')
382
+ } else if (hasWarnings) {
383
+ warn('Setup completed with warnings.')
384
+ } else {
385
+ success('Setup completed successfully.')
386
+ }
387
+ write()
388
+ }
389
+
390
+ /**
391
+ * Prints usage / help information showing all available CLI flags and their
392
+ * descriptions.
393
+ */
394
+ export function help() {
395
+ const version = getVersion()
396
+
397
+ // ── Build flag display groups ───────────────────────────────────────
398
+ // Invert SUPPORTED_FLAGS to group short + long forms per canonical key.
399
+ /** @type {Record<string, string[]>} */
400
+ const groups = {}
401
+ for (const [flag, canonical] of Object.entries(SUPPORTED_FLAGS)) {
402
+ ;(groups[canonical] || (groups[canonical] = [])).push(flag)
403
+ }
404
+
405
+ // Sort each group so shorter flags come first, then build display text.
406
+ /** @type {Record<string, string>} */
407
+ const display = {}
408
+ let maxLen = 0
409
+ for (const [canonical, flags] of Object.entries(groups)) {
410
+ flags.sort((a, b) => a.length - b.length)
411
+ const text = flags.join(', ')
412
+ display[canonical] = text
413
+ maxLen = Math.max(maxLen, text.length)
414
+ }
415
+
416
+ // Ordered flag descriptions.
417
+ /** @type {Record<string, string>} */
418
+ const descriptions = {
419
+ force: 'Overwrite existing hooks (a .bak backup is created)',
420
+ dryRun: 'Show what would be done without making any changes',
421
+ yes: 'Skip confirmation prompts (non-interactive mode)',
422
+ skipHooks: 'Skip git hook installation phase',
423
+ skipPrepare: 'Skip prepare script modification phase',
424
+ skipSync: 'Skip file sync phase',
425
+ help: 'Show this help message and exit',
426
+ version: 'Show the version number and exit',
427
+ quiet: 'Suppress non-error output (quiet mode)',
428
+ verbose: 'Show detailed debug information',
429
+ }
430
+
431
+ const order = [
432
+ 'force',
433
+ 'dryRun',
434
+ 'yes',
435
+ 'skipHooks',
436
+ 'skipPrepare',
437
+ 'skipSync',
438
+ 'help',
439
+ 'version',
440
+ 'quiet',
441
+ 'verbose',
442
+ ]
443
+
444
+ // ── Print help text ─────────────────────────────────────────────────
445
+ write()
446
+ write(`${BLD}${PACKAGE_NAME}${RST} ${GRN}v${version}${RST}`)
447
+ write()
448
+ write(` ${BLD}Usage:${RST}`)
449
+ write(` npx ${PACKAGE_NAME} setup [options]`)
450
+ write(` ${BIN_NAME} [options]`)
451
+ write()
452
+ write(` ${BLD}Description:${RST}`)
453
+ write(
454
+ ` Sets up a consumer project with git hooks, memory-bank scaffolding,` +
455
+ ` and AI workflow`,
456
+ )
457
+ write(` integration for the ${PACKAGE_NAME} framework.`)
458
+ write()
459
+ write(` ${BLD}Options:${RST}`)
460
+
461
+ for (const key of order) {
462
+ const d = display[key]
463
+ const desc = descriptions[key] || ''
464
+ if (d) {
465
+ write(` ${d.padEnd(maxLen)} ${desc}`)
466
+ }
467
+ }
468
+
469
+ write()
470
+ write(` ${BLD}Examples:${RST}`)
471
+ write(` npx ${PACKAGE_NAME} setup`)
472
+ write(` npx ${PACKAGE_NAME} setup --dry-run --verbose`)
473
+ write(` npx ${PACKAGE_NAME} setup --force --skip-hooks`)
474
+ write(` ${BIN_NAME} --help`)
475
+ write()
476
+ }
477
+
478
+ /**
479
+ * Prints a horizontal rule for visual separation.
480
+ */
481
+ export function divider() {
482
+ write(` ${DIM}${rule()}${RST}`)
483
+ }
484
+
485
+ /**
486
+ * Prints a prominent banner indicating that `--dry-run` mode is active and no
487
+ * files will be modified.
488
+ */
489
+ export function dryRunBanner() {
490
+ const width = Math.min(process.stdout.columns || 72, 72)
491
+ const sep = YLW + rule() + RST
492
+ write()
493
+ write(sep)
494
+ write(
495
+ ` ${YLW}${UNICODE_CHARS.WARN}${RST}` +
496
+ ` ${BLD}DRY RUN MODE${RST}` +
497
+ ` ${DIM}\u2014${RST} No files will be modified.` +
498
+ ` ${YLW}${UNICODE_CHARS.WARN}${RST}`,
499
+ )
500
+ write(sep)
501
+ write()
502
+ }
503
+
504
+ /**
505
+ * Prints a banner indicating CI mode is detected and git hooks will be
506
+ * skipped.
507
+ */
508
+ export function ciModeBanner() {
509
+ const width = Math.min(process.stdout.columns || 72, 72)
510
+ const sep = CYN + rule() + RST
511
+ write()
512
+ write(sep)
513
+ write(
514
+ ` ${CYN}${UNICODE_CHARS.BULLET}${RST}` +
515
+ ` ${BLD}CI MODE DETECTED${RST}` +
516
+ ` ${DIM}\u2014${RST} Git hooks will be skipped.` +
517
+ ` Use ${CYN}--skip-hooks${RST} to suppress this message.`,
518
+ )
519
+ write(sep)
520
+ write()
521
+ }