@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,354 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // index.js — Setup command orchestrator
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // Implements the full setup pipeline:
6
+ // 1. Parse CLI arguments
7
+ // 2. Handle --help / --version (early exit with code 0)
8
+ // 3. Display header banner
9
+ // 4. Phase 1 — Discovery (detect consumer project state)
10
+ // 5. Phase 2 — Hooks (install / merge git hooks, unless --skip-hooks)
11
+ // 6. Phase 3 — Prepare (configure consumer package.json prepare script)
12
+ // 7. Phase 4 — Husky Init (generate .husky/_/ shims, unless --skip-hooks)
13
+ // 8. Phase 5 — Sync (spawn sync.js + normalize-memory.js, unless --skip-sync)
14
+ // 9. Render summary table via ui.summary()
15
+ // 10. Determine exit code and return it
16
+ //
17
+ // Each phase is wrapped in try/catch. Only discovery failure is fatal.
18
+ // All other phase errors are logged and the pipeline continues (REQ-04).
19
+ // ═════════════════════════════════════════════════════════════════════════════
20
+
21
+ import { readFileSync } from 'fs'
22
+ import { join } from 'path'
23
+
24
+ import { EXIT_CODES } from './constants.js'
25
+ import { parseCliArgs, resolvePackageRoot } from './utils.js'
26
+ import {
27
+ ciModeBanner,
28
+ header,
29
+ help,
30
+ section,
31
+ stepError,
32
+ stepSuccess,
33
+ stepWarn,
34
+ summary,
35
+ } from './ui.js'
36
+ import { discover } from './discover.js'
37
+ import { installHooks } from './hooks.js'
38
+ import { handlePrepare } from './prepare.js'
39
+ import { initHusky } from './husky-init.js'
40
+ import { runSyncPhase } from './sync-phase.js'
41
+
42
+ // ── Package version (lazy, cached) ───────────────────────────────────────────
43
+
44
+ /** @type {string|null} */
45
+ let _cachedVersion = null
46
+
47
+ /**
48
+ * Reads the package version from `package.json`.
49
+ *
50
+ * The result is cached after the first successful read so repeated calls
51
+ * (only the `--version` flag currently) don't re-read the filesystem.
52
+ *
53
+ * @returns {string} Semver string (e.g. `'1.29.0'`). Falls back to `'0.0.0'`.
54
+ */
55
+ function getPackageVersion() {
56
+ if (_cachedVersion === null) {
57
+ try {
58
+ const pkgRoot = resolvePackageRoot()
59
+ const pkgPath = join(pkgRoot, 'package.json')
60
+ _cachedVersion =
61
+ JSON.parse(readFileSync(pkgPath, 'utf-8')).version || '0.0.0'
62
+ } catch {
63
+ _cachedVersion = '0.0.0'
64
+ }
65
+ }
66
+ return _cachedVersion
67
+ }
68
+
69
+ // ── Result mapping helper ────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Maps a phase module action string to a summary status.
73
+ *
74
+ * @param {string} action - Action value returned by a phase module
75
+ * (e.g. `'added'`, `'skipped'`, `'failed'`, `'dry-run'`).
76
+ * @returns {'success' | 'warn' | 'error'}
77
+ */
78
+ function actionStatus(action) {
79
+ switch (action) {
80
+ // These actions represent a successful, completed operation
81
+ case 'added':
82
+ case 'created':
83
+ case 'initialized':
84
+ case 'completed':
85
+ case 'dry-run':
86
+ case 'overwritten':
87
+ case 'merged':
88
+ return 'success'
89
+ // These actions indicate the phase was intentionally skipped or
90
+ // only partially completed — not an error but worth noting
91
+ case 'skipped':
92
+ case 'partial':
93
+ return 'warn'
94
+ // Explicit failure
95
+ case 'failed':
96
+ return 'error'
97
+ // Unknown action — treat as a warning
98
+ default:
99
+ return 'warn'
100
+ }
101
+ }
102
+
103
+ // ── Main export ──────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Runs the full setup pipeline and returns an exit code.
107
+ *
108
+ * The pipeline orchestrates five phases in sequence. Each phase is wrapped
109
+ * in a try/catch so that a non-fatal failure in one phase does not prevent
110
+ * subsequent phases from running (graceful degradation per REQ-04).
111
+ *
112
+ * The returned exit code follows the `EXIT_CODES` constants:
113
+ * - `SUCCESS` (0) — All phases completed without errors or warnings
114
+ * - `WARNINGS` (1) — One or more phases had warnings or were skipped
115
+ * - `FATAL` (2) — Discovery phase failed (fatal, no recovery possible)
116
+ *
117
+ * @param {string[]} argv - CLI arguments (typically `process.argv.slice(2)`).
118
+ * @returns {Promise<number>} Numeric exit code for `process.exit(code)`.
119
+ *
120
+ * @example
121
+ * ```js
122
+ * const code = await main(process.argv.slice(2))
123
+ * process.exit(code)
124
+ * ```
125
+ */
126
+ export async function main(argv) {
127
+ // ── 1. Parse CLI arguments ──────────────────────────────────────────────
128
+ const flags = parseCliArgs(argv)
129
+
130
+ // ── 2. Handle early-exit flags ──────────────────────────────────────────
131
+ if (flags.help) {
132
+ help()
133
+ return EXIT_CODES.SUCCESS
134
+ }
135
+
136
+ if (flags.version) {
137
+ console.log(getPackageVersion())
138
+ return EXIT_CODES.SUCCESS
139
+ }
140
+
141
+ // ── 3. Display header banner ────────────────────────────────────────────
142
+ if (!flags.quiet) {
143
+ header()
144
+ }
145
+
146
+ // Accumulated phase results for the final summary table.
147
+ // Each entry: `{ phase: string, status: 'success'|'warn'|'error', message: string }`
148
+ /** @type {Array<{ phase: string, status: string, message: string }>} */
149
+ const phases = []
150
+
151
+ // Mutable context object — enriched by discovery and each subsequent phase.
152
+ /** @type {import('./discover.js').Context | object} */
153
+ let context
154
+
155
+ // ═════════════════════════════════════════════════════════════════════════
156
+ // Phase 1 — Discovery
157
+ // ═════════════════════════════════════════════════════════════════════════
158
+ section('Phase 1: Discovery')
159
+
160
+ try {
161
+ context = await discover(flags)
162
+ const msg = context.hasGit
163
+ ? `Git repository found at ${context.consumerRoot}`
164
+ : `Not a git repository (${context.consumerRoot}); hooks will be skipped`
165
+ stepSuccess(msg)
166
+ phases.push({ phase: 'discover', status: 'success', message: msg })
167
+ } catch (err) {
168
+ const msg = `Discovery failed: ${err.message}`
169
+ stepError(msg)
170
+ phases.push({ phase: 'discover', status: 'error', message: msg })
171
+ // Discovery failure is fatal — no further detection is possible
172
+ if (!flags.quiet) summary(phases)
173
+ return EXIT_CODES.FATAL
174
+ }
175
+
176
+ // ── CI mode banner ──────────────────────────────────────────────────────
177
+ if (context.isCI && !flags.quiet) {
178
+ ciModeBanner()
179
+ }
180
+
181
+ // ═════════════════════════════════════════════════════════════════════════
182
+ // Phase 2 — Hooks (unless --skip-hooks)
183
+ // ═════════════════════════════════════════════════════════════════════════
184
+ if (!flags.skipHooks) {
185
+ section('Phase 2: Hooks')
186
+
187
+ if (!context.hasGit) {
188
+ stepWarn(
189
+ 'Not a git repository \u2014 skipping hooks. ' +
190
+ 'Run "git init" first, then re-run setup.',
191
+ )
192
+ phases.push({
193
+ phase: 'hooks',
194
+ status: 'warn',
195
+ message: 'Skipped \u2014 not a git repository',
196
+ })
197
+ } else {
198
+ try {
199
+ const hookResults = await installHooks(context)
200
+ context.hookResults = hookResults
201
+
202
+ // Aggregate per-hook results into a single summary entry
203
+ const actions = hookResults.map((r) => r.action)
204
+ const hasErrors = actions.some((a) => a === 'skipped')
205
+ const onlyDryRun = actions.every((a) => a === 'dry-run')
206
+ const allCreated = actions.every((a) => a === 'created')
207
+ const allOverwritten = actions.every((a) => a === 'overwritten')
208
+
209
+ if (onlyDryRun) {
210
+ phases.push({
211
+ phase: 'hooks',
212
+ status: 'success',
213
+ message: 'Dry-run complete (no files modified)',
214
+ })
215
+ } else if (hasErrors) {
216
+ phases.push({
217
+ phase: 'hooks',
218
+ status: 'error',
219
+ message: 'One or more hooks failed',
220
+ })
221
+ } else if (allCreated || allOverwritten) {
222
+ phases.push({
223
+ phase: 'hooks',
224
+ status: 'success',
225
+ message: 'All hooks installed',
226
+ })
227
+ } else {
228
+ // Mixed results (created, merged, overwritten, etc.)
229
+ const summaryParts = [...new Set(actions)].map((a) => {
230
+ const count = actions.filter((x) => x === a).length
231
+ return `${count} ${a}`
232
+ })
233
+ phases.push({
234
+ phase: 'hooks',
235
+ status: 'success',
236
+ message: summaryParts.join(', '),
237
+ })
238
+ }
239
+ } catch (err) {
240
+ stepError(`Hook installation failed: ${err.message}`)
241
+ phases.push({
242
+ phase: 'hooks',
243
+ status: 'error',
244
+ message: err.message,
245
+ })
246
+ }
247
+ }
248
+ }
249
+
250
+ // ═════════════════════════════════════════════════════════════════════════
251
+ // Phase 3 — Prepare (unless --skip-prepare)
252
+ // ═════════════════════════════════════════════════════════════════════════
253
+ if (!flags.skipPrepare) {
254
+ section('Phase 3: Prepare')
255
+
256
+ try {
257
+ const prepareResult = await handlePrepare(context)
258
+ context.prepareResult = prepareResult
259
+ phases.push({
260
+ phase: 'prepare',
261
+ status: actionStatus(prepareResult.action),
262
+ message: prepareResult.message,
263
+ })
264
+ } catch (err) {
265
+ stepError(`Prepare script handling failed: ${err.message}`)
266
+ phases.push({
267
+ phase: 'prepare',
268
+ status: 'error',
269
+ message: err.message,
270
+ })
271
+ }
272
+ }
273
+
274
+ // ═════════════════════════════════════════════════════════════════════════
275
+ // Phase 4 — Husky Init (unless --skip-hooks)
276
+ // ═════════════════════════════════════════════════════════════════════════
277
+ if (!flags.skipHooks) {
278
+ section('Phase 4: Husky Init')
279
+
280
+ if (!context.hasGit) {
281
+ stepWarn(
282
+ 'Not a git repository \u2014 skipping husky init. ' +
283
+ 'Run "git init" first, then re-run setup.',
284
+ )
285
+ phases.push({
286
+ phase: 'husky-init',
287
+ status: 'warn',
288
+ message: 'Skipped \u2014 not a git repository',
289
+ })
290
+ } else {
291
+ try {
292
+ const huskyResult = await initHusky(context)
293
+ context.huskyResult = huskyResult
294
+ phases.push({
295
+ phase: 'husky-init',
296
+ status: actionStatus(huskyResult.action),
297
+ message: huskyResult.message,
298
+ })
299
+ } catch (err) {
300
+ stepError(`Husky initialization failed: ${err.message}`)
301
+ phases.push({
302
+ phase: 'husky-init',
303
+ status: 'error',
304
+ message: err.message,
305
+ })
306
+ }
307
+ }
308
+ }
309
+
310
+ // ═════════════════════════════════════════════════════════════════════════
311
+ // Phase 5 — Sync (unless --skip-sync)
312
+ // ═════════════════════════════════════════════════════════════════════════
313
+ if (!flags.skipSync) {
314
+ section('Phase 5: Sync')
315
+
316
+ try {
317
+ const syncResult = await runSyncPhase(context)
318
+ context.syncResult = syncResult
319
+ phases.push({
320
+ phase: 'sync',
321
+ status: actionStatus(syncResult.action),
322
+ message: syncResult.message,
323
+ })
324
+ } catch (err) {
325
+ stepError(`Sync phase failed: ${err.message}`)
326
+ phases.push({
327
+ phase: 'sync',
328
+ status: 'error',
329
+ message: err.message,
330
+ })
331
+ }
332
+ }
333
+
334
+ // ═════════════════════════════════════════════════════════════════════════
335
+ // Summary
336
+ // ═════════════════════════════════════════════════════════════════════════
337
+ if (!flags.quiet) {
338
+ summary(phases)
339
+ }
340
+
341
+ // ═════════════════════════════════════════════════════════════════════════
342
+ // Exit code
343
+ // ═════════════════════════════════════════════════════════════════════════
344
+ const hasErrors = phases.some(
345
+ (p) => p.status === 'error',
346
+ )
347
+ const hasWarnings = phases.some(
348
+ (p) => p.status === 'warn',
349
+ )
350
+
351
+ if (hasErrors) return EXIT_CODES.FATAL
352
+ if (hasWarnings) return EXIT_CODES.WARNINGS
353
+ return EXIT_CODES.SUCCESS
354
+ }