@abarcenas/ai-workflow-template 1.29.0 → 1.32.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,172 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // sync-phase.js — Phase 5: spawn sync.js and normalize-memory.js as child
3
+ // processes (avoids process.exit() killing the parent setup)
4
+ // ═════════════════════════════════════════════════════════════════════════════
5
+
6
+ import { spawn } from 'child_process'
7
+ import { join } from 'path'
8
+
9
+ import { resolvePackageRoot, logInfo, logWarn, logError } from './utils.js'
10
+
11
+ // ── Main export ─────────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Runs the sync phase of the setup pipeline.
15
+ *
16
+ * Spawns `scripts/sync.js` and `scripts/normalize-memory.js` as child processes
17
+ * rather than importing them directly. This is required because `sync.js` calls
18
+ * `process.exit()` internally (line 83), which would terminate the entire setup
19
+ * process if it were imported as a module.
20
+ *
21
+ * **Early exits:**
22
+ * - `skipSync` is `true` → returns `{ action: 'skipped' }` without spawning
23
+ * - `dryRun` is `true` → returns `{ action: 'dry-run' }` after logging intent
24
+ *
25
+ * **Normal execution:**
26
+ * Both scripts are spawned sequentially (sync.js first, normalize-memory.js
27
+ * second) with `INIT_CWD` set to `consumerRoot` so they resolve consumer
28
+ * paths correctly. If `flags.force` is set, the `--force` argument is
29
+ * forwarded to both child scripts.
30
+ *
31
+ * Non-zero exit codes from either script are treated as warnings, not
32
+ * failures — the overall action becomes `'partial'` but the pipeline
33
+ * continues (graceful degradation per REQ-04).
34
+ *
35
+ * @param {object} context - Setup context object.
36
+ * @param {string} context.consumerRoot - Absolute path to the consumer project root.
37
+ * @param {boolean} [context.dryRun=false] - If `true`, only simulate without spawning.
38
+ * @param {boolean} [context.skipSync=false] - If `true`, skip the entire sync phase.
39
+ * @param {object} [context.flags={}] - Parsed CLI flags (may include `force`).
40
+ * @returns {Promise<{
41
+ * action: 'completed'|'partial'|'skipped'|'dry-run',
42
+ * message: string,
43
+ * results: Array<{
44
+ * script: string,
45
+ * success: boolean,
46
+ * exitCode: number,
47
+ * output: string
48
+ * }>
49
+ * }>}
50
+ */
51
+ export async function runSyncPhase(context) {
52
+ const { consumerRoot, dryRun = false, skipSync = false, flags = {} } = context
53
+
54
+ // ── Skip path ──────────────────────────────────────────────────────────
55
+ if (skipSync) {
56
+ const message = 'Sync phase skipped (--skip-sync)'
57
+ logInfo(`[setup] ${message}`)
58
+ return { action: 'skipped', message, results: [] }
59
+ }
60
+
61
+ // ── Dry-run path ───────────────────────────────────────────────────────
62
+ if (dryRun) {
63
+ const message =
64
+ `Would run sync.js and normalize-memory.js ` +
65
+ `with INIT_CWD="${consumerRoot}"`
66
+ logInfo(`[setup] dry-run: ${message}`)
67
+ return { action: 'dry-run', message, results: [] }
68
+ }
69
+
70
+ const packageRoot = resolvePackageRoot()
71
+ const extraArgs = flags.force ? ['--force'] : []
72
+
73
+ // ── Spawn sync.js ──────────────────────────────────────────────────────
74
+ const syncPath = join(packageRoot, 'scripts', 'sync.js')
75
+ const syncResult = await spawnScript(
76
+ 'sync.js',
77
+ syncPath,
78
+ consumerRoot,
79
+ extraArgs,
80
+ )
81
+
82
+ // ── Spawn normalize-memory.js ──────────────────────────────────────────
83
+ const normPath = join(packageRoot, 'scripts', 'normalize-memory.js')
84
+ const normResult = await spawnScript(
85
+ 'normalize-memory.js',
86
+ normPath,
87
+ consumerRoot,
88
+ extraArgs,
89
+ )
90
+
91
+ const results = [syncResult, normResult]
92
+ const allSucceeded = results.every((r) => r.success)
93
+ const action = allSucceeded ? 'completed' : 'partial'
94
+ const failureCount = results.filter((r) => !r.success).length
95
+
96
+ const message =
97
+ action === 'completed'
98
+ ? 'Sync phase completed'
99
+ : `Sync phase completed with ${failureCount} script failure(s)`
100
+
101
+ if (!allSucceeded) {
102
+ logWarn(`[setup] ${message}`)
103
+ for (const r of results) {
104
+ if (!r.success) {
105
+ logWarn(`[setup] ${r.script} exited with code ${r.exitCode}`)
106
+ }
107
+ }
108
+ }
109
+
110
+ return { action, message, results }
111
+ }
112
+
113
+ // ── Internal helpers ───────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Spawns a Node.js script as a child process and waits for it to complete.
117
+ *
118
+ * Both stdout and stderr are captured into a single `output` string
119
+ * (interleaved in the order data events arrive). The returned promise
120
+ * resolves when the child process exits or fails to spawn.
121
+ *
122
+ * Uses `process.execPath` so the child always runs the same Node.js
123
+ * binary as the parent setup process.
124
+ *
125
+ * @param {string} name - Display name for the script (e.g. `'sync.js'`).
126
+ * @param {string} scriptPath - Absolute path to the script to execute.
127
+ * @param {string} cwd - Working directory for the child process.
128
+ * @param {string[]} extraArgs - Extra CLI arguments forwarded to the script.
129
+ * @returns {Promise<{
130
+ * script: string,
131
+ * success: boolean,
132
+ * exitCode: number,
133
+ * output: string
134
+ * }>}
135
+ */
136
+ function spawnScript(name, scriptPath, cwd, extraArgs) {
137
+ return new Promise((resolve) => {
138
+ const child = spawn(process.execPath, [scriptPath, ...extraArgs], {
139
+ cwd,
140
+ env: { ...process.env, INIT_CWD: cwd },
141
+ stdio: 'pipe',
142
+ })
143
+
144
+ let output = ''
145
+
146
+ child.stdout.on('data', (data) => {
147
+ output += data.toString()
148
+ })
149
+
150
+ child.stderr.on('data', (data) => {
151
+ output += data.toString()
152
+ })
153
+
154
+ child.on('close', (exitCode) => {
155
+ resolve({
156
+ script: name,
157
+ success: exitCode === 0,
158
+ exitCode: exitCode ?? -1,
159
+ output: output.trim(),
160
+ })
161
+ })
162
+
163
+ child.on('error', (err) => {
164
+ resolve({
165
+ script: name,
166
+ success: false,
167
+ exitCode: -1,
168
+ output: `Failed to spawn child process: ${err.message}`,
169
+ })
170
+ })
171
+ })
172
+ }
@@ -0,0 +1,503 @@
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
+ // ── Public output API ───────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Prints the setup banner showing the package name and version.
146
+ *
147
+ * Example:
148
+ *
149
+ * ────────────────────────────────────────────────────────────
150
+ * @abarcenas/ai-workflow-template v1.29.0 — Setup
151
+ * ────────────────────────────────────────────────────────────
152
+ */
153
+ export function header() {
154
+ const version = getVersion()
155
+ const width = Math.min(process.stdout.columns || 72, 72)
156
+ const sep = DIM + rule() + RST
157
+ const title =
158
+ ` ${BLD}${PACKAGE_NAME}${RST}` +
159
+ ` ${GRN}v${version}${RST}` +
160
+ ` ${DIM}\u2014${RST}` +
161
+ ` ${BLD}Setup${RST}`
162
+
163
+ write()
164
+ write(sep)
165
+ write(title)
166
+ write(sep)
167
+ write()
168
+ }
169
+
170
+ /**
171
+ * Prints a progress step indicator with a rightwards arrow.
172
+ *
173
+ * @param {string} message - Description of the step being performed.
174
+ * @example
175
+ * step('Checking environment…')
176
+ * // → Checking environment…
177
+ */
178
+ export function step(message) {
179
+ write(` ${UNICODE_CHARS.ARROW} ${message}`)
180
+ }
181
+
182
+ /**
183
+ * Prints a step that completed successfully (checkmark).
184
+ *
185
+ * @param {string} message - Description of the completed step.
186
+ * @example
187
+ * stepSuccess('Environment ready')
188
+ * // ✓ Environment ready
189
+ */
190
+ export function stepSuccess(message) {
191
+ write(` ${GRN}${UNICODE_CHARS.CHECK}${RST} ${message}`)
192
+ }
193
+
194
+ /**
195
+ * Prints a step that completed with a warning.
196
+ *
197
+ * @param {string} message - Warning description.
198
+ * @example
199
+ * stepWarn('Husky version is newer than expected')
200
+ * // ⚠ Husky version is newer than expected
201
+ */
202
+ export function stepWarn(message) {
203
+ write(` ${YLW}${UNICODE_CHARS.WARN}${RST} ${message}`)
204
+ }
205
+
206
+ /**
207
+ * Prints a step that failed.
208
+ *
209
+ * @param {string} message - Error description.
210
+ * @example
211
+ * stepError('Git repository not found')
212
+ * // ✗ Git repository not found
213
+ */
214
+ export function stepError(message) {
215
+ write(` ${RED}${UNICODE_CHARS.CROSS}${RST} ${message}`)
216
+ }
217
+
218
+ /**
219
+ * Prints an informational message (default colour, stdout).
220
+ *
221
+ * @param {string} message - Message to display.
222
+ */
223
+ export function info(message) {
224
+ write(message)
225
+ }
226
+
227
+ /**
228
+ * Prints a warning message (yellow, bold) to stderr.
229
+ *
230
+ * @param {string} message - Warning message.
231
+ */
232
+ export function warn(message) {
233
+ console.error(`${YLW}${BLD}warning${RST} ${message}`)
234
+ }
235
+
236
+ /**
237
+ * Prints an error message (red, bold) to stderr.
238
+ *
239
+ * @param {string} message - Error message.
240
+ */
241
+ export function error(message) {
242
+ console.error(`${RED}${BLD}error${RST} ${message}`)
243
+ }
244
+
245
+ /**
246
+ * Prints a success message (green) to stdout.
247
+ *
248
+ * @param {string} message - Success message.
249
+ */
250
+ export function success(message) {
251
+ write(`${GRN}${UNICODE_CHARS.CHECK}${RST} ${message}`)
252
+ }
253
+
254
+ /**
255
+ * Prints a bold section header.
256
+ *
257
+ * @param {string} title - Section title.
258
+ * @example
259
+ * section('Environment Discovery')
260
+ * // Environment Discovery
261
+ */
262
+ export function section(title) {
263
+ write(`${BLD}${title}${RST}`)
264
+ }
265
+
266
+ /**
267
+ * Prints a formatted summary table from phase results.
268
+ *
269
+ * Each result entry is rendered as a single row in a text table with three
270
+ * columns: **Phase**, **Status**, and **Message**. The table is followed by
271
+ * an overall result line (success / warnings / errors).
272
+ *
273
+ * @param {Array<{ phase: string, status: string, message: string }>} results
274
+ * Phase results to display.
275
+ * - `phase` — Short phase name (e.g. `'discover'`, `'hooks'`)
276
+ * - `status` — One of `'success'`, `'warn'`, `'error'`, or a custom string
277
+ * - `message` — Human-readable result description
278
+ *
279
+ * @example
280
+ * summary([
281
+ * { phase: 'discover', status: 'success', message: 'Git repo found' },
282
+ * { phase: 'hooks', status: 'error', message: 'Permission denied' },
283
+ * ])
284
+ */
285
+ export function summary(results) {
286
+ if (!results || results.length === 0) return
287
+
288
+ const headerPhase = 'Phase'
289
+ const headerStatus = 'Status'
290
+ const headerMessage = 'Message'
291
+
292
+ // ── Column widths ───────────────────────────────────────────────────
293
+ const phases = results.map((r) => r.phase)
294
+ const statuses = results.map((r) => r.status)
295
+ const colPhase = Math.max(headerPhase.length, ...phases.map((s) => s.length))
296
+ const colStatus = Math.max(headerStatus.length, ...statuses.map((s) => s.length))
297
+ const colMsg = Math.max(
298
+ headerMessage.length,
299
+ ...results.map((r) => visibleWidth(r.message || '')),
300
+ )
301
+
302
+ // ── Status rendering ────────────────────────────────────────────────
303
+ /**
304
+ * Formats a status string with a colour-coded symbol.
305
+ *
306
+ * @param {string} s - Status key.
307
+ * @returns {string} Formatted status (may contain ANSI codes).
308
+ */
309
+ const fmtStatus = (s) => {
310
+ switch (s) {
311
+ case 'success':
312
+ return `${GRN}${UNICODE_CHARS.CHECK}${RST} success`
313
+ case 'warn':
314
+ return `${YLW}${UNICODE_CHARS.WARN}${RST} warn`
315
+ case 'error':
316
+ return `${RED}${UNICODE_CHARS.CROSS}${RST} error`
317
+ default:
318
+ return s
319
+ }
320
+ }
321
+
322
+ // Number of extra invisible chars added by the colour wrapper in fmtStatus
323
+ const statusAnsiExtra =
324
+ useColor && ['success', 'warn', 'error'].includes(statuses[0])
325
+ ? // Each coloured status wraps the symbol in open + close = 2 escapes
326
+ // e.g. `\x1b[32m✓\x1b[0m success` = 5+5+8 = 18 visible, but 28 chars
327
+ // The extra is the ANSI codes length per status
328
+ 10
329
+ : 0
330
+
331
+ // ── Print ───────────────────────────────────────────────────────────
332
+ write()
333
+ section('Setup Summary')
334
+ divider()
335
+
336
+ // Header
337
+ write(
338
+ ` ${headerPhase.padEnd(colPhase)} ` +
339
+ `${headerStatus.padEnd(colStatus + statusAnsiExtra)} ` +
340
+ `${headerMessage}`,
341
+ )
342
+
343
+ // Separator
344
+ write(
345
+ ` ${DIM}${'\u2500'.repeat(colPhase)}` +
346
+ ` ${'\u2500'.repeat(Math.max(colStatus, 6))}` +
347
+ ` ${'\u2500'.repeat(colMsg)}${RST}`,
348
+ )
349
+
350
+ // Rows
351
+ for (const r of results) {
352
+ const p = r.phase.padEnd(colPhase)
353
+ const s = fmtStatus(r.status).padEnd(colStatus + statusAnsiExtra)
354
+ write(` ${p} ${s} ${r.message || ''}`)
355
+ }
356
+
357
+ divider()
358
+
359
+ // Overall result
360
+ const hasErrors = results.some((r) => r.status === 'error')
361
+ const hasWarnings = results.some((r) => r.status === 'warn')
362
+ if (hasErrors) {
363
+ error('Setup completed with errors.')
364
+ } else if (hasWarnings) {
365
+ warn('Setup completed with warnings.')
366
+ } else {
367
+ success('Setup completed successfully.')
368
+ }
369
+ write()
370
+ }
371
+
372
+ /**
373
+ * Prints usage / help information showing all available CLI flags and their
374
+ * descriptions.
375
+ */
376
+ export function help() {
377
+ const version = getVersion()
378
+
379
+ // ── Build flag display groups ───────────────────────────────────────
380
+ // Invert SUPPORTED_FLAGS to group short + long forms per canonical key.
381
+ /** @type {Record<string, string[]>} */
382
+ const groups = {}
383
+ for (const [flag, canonical] of Object.entries(SUPPORTED_FLAGS)) {
384
+ ;(groups[canonical] || (groups[canonical] = [])).push(flag)
385
+ }
386
+
387
+ // Sort each group so shorter flags come first, then build display text.
388
+ /** @type {Record<string, string>} */
389
+ const display = {}
390
+ let maxLen = 0
391
+ for (const [canonical, flags] of Object.entries(groups)) {
392
+ flags.sort((a, b) => a.length - b.length)
393
+ const text = flags.join(', ')
394
+ display[canonical] = text
395
+ maxLen = Math.max(maxLen, text.length)
396
+ }
397
+
398
+ // Ordered flag descriptions.
399
+ /** @type {Record<string, string>} */
400
+ const descriptions = {
401
+ force: 'Overwrite existing hooks (a .bak backup is created)',
402
+ dryRun: 'Show what would be done without making any changes',
403
+ yes: 'Skip confirmation prompts (non-interactive mode)',
404
+ skipHooks: 'Skip git hook installation phase',
405
+ skipPrepare: 'Skip prepare script modification phase',
406
+ skipSync: 'Skip file sync phase',
407
+ help: 'Show this help message and exit',
408
+ version: 'Show the version number and exit',
409
+ quiet: 'Suppress non-error output (quiet mode)',
410
+ verbose: 'Show detailed debug information',
411
+ }
412
+
413
+ const order = [
414
+ 'force',
415
+ 'dryRun',
416
+ 'yes',
417
+ 'skipHooks',
418
+ 'skipPrepare',
419
+ 'skipSync',
420
+ 'help',
421
+ 'version',
422
+ 'quiet',
423
+ 'verbose',
424
+ ]
425
+
426
+ // ── Print help text ─────────────────────────────────────────────────
427
+ write()
428
+ write(`${BLD}${PACKAGE_NAME}${RST} ${GRN}v${version}${RST}`)
429
+ write()
430
+ write(` ${BLD}Usage:${RST}`)
431
+ write(` npx ${PACKAGE_NAME} setup [options]`)
432
+ write(` ${BIN_NAME} [options]`)
433
+ write()
434
+ write(` ${BLD}Description:${RST}`)
435
+ write(
436
+ ` Sets up a consumer project with git hooks, memory-bank scaffolding,` +
437
+ ` and AI workflow`,
438
+ )
439
+ write(` integration for the ${PACKAGE_NAME} framework.`)
440
+ write()
441
+ write(` ${BLD}Options:${RST}`)
442
+
443
+ for (const key of order) {
444
+ const d = display[key]
445
+ const desc = descriptions[key] || ''
446
+ if (d) {
447
+ write(` ${d.padEnd(maxLen)} ${desc}`)
448
+ }
449
+ }
450
+
451
+ write()
452
+ write(` ${BLD}Examples:${RST}`)
453
+ write(` npx ${PACKAGE_NAME} setup`)
454
+ write(` npx ${PACKAGE_NAME} setup --dry-run --verbose`)
455
+ write(` npx ${PACKAGE_NAME} setup --force --skip-hooks`)
456
+ write(` ${BIN_NAME} --help`)
457
+ write()
458
+ }
459
+
460
+ /**
461
+ * Prints a horizontal rule for visual separation.
462
+ */
463
+ export function divider() {
464
+ write(` ${DIM}${rule()}${RST}`)
465
+ }
466
+
467
+ /**
468
+ * Prints a prominent banner indicating that `--dry-run` mode is active and no
469
+ * files will be modified.
470
+ */
471
+ export function dryRunBanner() {
472
+ const width = Math.min(process.stdout.columns || 72, 72)
473
+ const sep = YLW + rule() + RST
474
+ write()
475
+ write(sep)
476
+ write(
477
+ ` ${YLW}${UNICODE_CHARS.WARN}${RST}` +
478
+ ` ${BLD}DRY RUN MODE${RST}` +
479
+ ` ${DIM}\u2014${RST} No files will be modified.` +
480
+ ` ${YLW}${UNICODE_CHARS.WARN}${RST}`,
481
+ )
482
+ write(sep)
483
+ write()
484
+ }
485
+
486
+ /**
487
+ * Prints a banner indicating CI mode is detected and git hooks will be
488
+ * skipped.
489
+ */
490
+ export function ciModeBanner() {
491
+ const width = Math.min(process.stdout.columns || 72, 72)
492
+ const sep = CYN + rule() + RST
493
+ write()
494
+ write(sep)
495
+ write(
496
+ ` ${CYN}${UNICODE_CHARS.BULLET}${RST}` +
497
+ ` ${BLD}CI MODE DETECTED${RST}` +
498
+ ` ${DIM}\u2014${RST} Git hooks will be skipped.` +
499
+ ` Use ${CYN}--skip-hooks${RST} to suppress this message.`,
500
+ )
501
+ write(sep)
502
+ write()
503
+ }