@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,527 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // hooks.test.js — Unit tests for the hook installation module
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // Tests the 6-case merge algorithm implemented in hooks.js using real temp
6
+ // directories. No mocks on fs — we create and inspect real files.
7
+ //
8
+ // Covered scenarios (≥10, per plan T15):
9
+ // 1. Case A — No .husky/ dir → created
10
+ // 2. Case B — .husky/ exists, no hook file → created
11
+ // 3. Case C — Managed hook present → overwritten
12
+ // 4. Case D — Unmanaged hook + --force → overwritten (+ .bak)
13
+ // 5. Case E — Unmanaged hook, no --force → merged
14
+ // 6. Case F — dryRun=true → dry-run, no writes
15
+ // 7. Partial failure → mixed results
16
+ // 8. .husky is a file (not a dir) → skipped
17
+ // 9. Executable permission (chmod) → 0o755
18
+ // 10. Empty .husky/ dir → works (Case B)
19
+ // 11. flags.force alternative → overwritten
20
+ // 12. Partial existingHooks map → graceful
21
+ // 13. Result structure validation → all fields present
22
+ // ═════════════════════════════════════════════════════════════════════════════
23
+
24
+ import { describe, it, expect, afterEach } from 'vitest'
25
+ import {
26
+ mkdtempSync,
27
+ mkdirSync,
28
+ writeFileSync,
29
+ readFileSync,
30
+ statSync,
31
+ existsSync,
32
+ rmSync,
33
+ realpathSync,
34
+ } from 'fs'
35
+ import { join } from 'path'
36
+ import { tmpdir } from 'os'
37
+
38
+ import { installHooks } from './hooks.js'
39
+ import { HOOK_MARKER, HOOK_MERGE_SEPARATOR, TEMPLATE_HOOKS } from './constants.js'
40
+
41
+ // ── Test helpers ──────────────────────────────────────────────────────────
42
+
43
+ const tempDirs = new Set()
44
+
45
+ /**
46
+ * Creates and registers a temporary directory for a single test case.
47
+ * The directory is automatically cleaned up in `afterEach`.
48
+ *
49
+ * @returns {string} Absolute path to the new temp directory.
50
+ */
51
+ function createTempDir() {
52
+ const dir = mkdtempSync(join(realpathSync(tmpdir()), 'hooks-test-'))
53
+ tempDirs.add(dir)
54
+ return dir
55
+ }
56
+
57
+ function cleanupTempDirs() {
58
+ for (const dir of tempDirs) {
59
+ try {
60
+ rmSync(dir, { recursive: true, force: true })
61
+ } catch {
62
+ /* best-effort cleanup */
63
+ }
64
+ }
65
+ tempDirs.clear()
66
+ }
67
+
68
+ /**
69
+ * Replicates the internal `buildHookContent()` logic so we can assert on
70
+ * expected file content without exporting a private function.
71
+ *
72
+ * @param {string} hookName - Key from `TEMPLATE_HOOKS` ('pre-commit' | 'post-merge')
73
+ * @returns {string} The full hook file content as written by hooks.js.
74
+ */
75
+ function expectedContent(hookName) {
76
+ const raw = TEMPLATE_HOOKS[hookName].content
77
+ return HOOK_MARKER + '\n' + raw
78
+ }
79
+
80
+ /**
81
+ * Builds a context object suitable for `installHooks()`.
82
+ *
83
+ * Always uses a fresh temp directory as `consumerRoot` unless an explicit
84
+ * `consumerRoot` is passed in `overrides`.
85
+ *
86
+ * @param {object} [overrides] - Partial context properties to override.
87
+ * @returns {object} Context object.
88
+ */
89
+ function makeContext(overrides = {}) {
90
+ const dir = overrides.consumerRoot || createTempDir()
91
+ return {
92
+ consumerRoot: dir,
93
+ hasHuskyDir: false,
94
+ dryRun: false,
95
+ force: false,
96
+ existingHooks: { /* empty by default */ },
97
+ flags: {},
98
+ ...overrides,
99
+ consumerRoot: dir, // enforce temp dir regardless of overrides
100
+ }
101
+ }
102
+
103
+ // ── Tests ─────────────────────────────────────────────────────────────────
104
+
105
+ describe('installHooks', () => {
106
+ afterEach(() => {
107
+ cleanupTempDirs()
108
+ })
109
+
110
+ // ── 1. Case A: No .husky/ directory ───────────────────────────────────
111
+
112
+ it('Case A: no .husky/ dir → creates dir + hook files, returns action="created"', async () => {
113
+ const ctx = makeContext({ hasHuskyDir: false })
114
+
115
+ const results = await installHooks(ctx)
116
+
117
+ // .husky/ directory was created
118
+ const huskyDir = join(ctx.consumerRoot, '.husky')
119
+ expect(existsSync(huskyDir)).toBe(true)
120
+ expect(statSync(huskyDir).isDirectory()).toBe(true)
121
+
122
+ // Every template hook file is present with correct content and permissions
123
+ for (const hookName of Object.keys(TEMPLATE_HOOKS)) {
124
+ const fp = join(huskyDir, hookName)
125
+ expect(existsSync(fp)).toBe(true)
126
+ expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(hookName))
127
+ expect(statSync(fp).mode & 0o111).not.toBe(0)
128
+ }
129
+
130
+ // Results match the expected count and action
131
+ expect(results).toHaveLength(Object.keys(TEMPLATE_HOOKS).length)
132
+ for (const r of results) {
133
+ expect(r.action).toBe('created')
134
+ }
135
+ })
136
+
137
+ // ── 2. Case B: .husky/ exists, no existing hook file ──────────────────
138
+
139
+ it('Case B: .husky/ exists, no existing hook → creates hook file, returns action="created"', async () => {
140
+ const ctx = makeContext({
141
+ hasHuskyDir: true,
142
+ existingHooks: {
143
+ 'pre-commit': { exists: false, isManaged: false },
144
+ 'post-merge': { exists: false, isManaged: false },
145
+ },
146
+ })
147
+ mkdirSync(join(ctx.consumerRoot, '.husky'))
148
+
149
+ const results = await installHooks(ctx)
150
+
151
+ const huskyDir = join(ctx.consumerRoot, '.husky')
152
+ for (const hookName of Object.keys(TEMPLATE_HOOKS)) {
153
+ const fp = join(huskyDir, hookName)
154
+ expect(existsSync(fp)).toBe(true)
155
+ expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(hookName))
156
+ expect(statSync(fp).mode & 0o111).not.toBe(0)
157
+ }
158
+
159
+ for (const r of results) {
160
+ expect(r.action).toBe('created')
161
+ }
162
+ })
163
+
164
+ // ── 3. Case C: Managed hook present → idempotent overwrite ────────────
165
+
166
+ it('Case C: .husky/ exists, managed hook present → overwrites, returns action="overwritten"', async () => {
167
+ const ctx = makeContext({
168
+ hasHuskyDir: true,
169
+ existingHooks: {
170
+ 'pre-commit': { exists: true, isManaged: true },
171
+ 'post-merge': { exists: true, isManaged: true },
172
+ },
173
+ })
174
+ const huskyDir = join(ctx.consumerRoot, '.husky')
175
+ mkdirSync(huskyDir)
176
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
177
+ writeFileSync(join(huskyDir, name), '# old content\n')
178
+ }
179
+
180
+ const results = await installHooks(ctx)
181
+
182
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
183
+ const fp = join(huskyDir, name)
184
+ // Content was updated
185
+ expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(name))
186
+ // Executable
187
+ expect(statSync(fp).mode & 0o111).not.toBe(0)
188
+ // No .bak created (idempotent overwrite)
189
+ expect(existsSync(fp + '.bak')).toBe(false)
190
+ }
191
+
192
+ for (const r of results) {
193
+ expect(r.action).toBe('overwritten')
194
+ }
195
+ })
196
+
197
+ // ── 4. Case D: Unmanaged + --force → backup + overwrite ───────────────
198
+
199
+ it('Case D: .husky/ exists, unmanaged hook, --force → backups to .bak, overwrites', async () => {
200
+ const ctx = makeContext({
201
+ hasHuskyDir: true,
202
+ force: true,
203
+ existingHooks: {
204
+ 'pre-commit': { exists: true, isManaged: false },
205
+ 'post-merge': { exists: true, isManaged: false },
206
+ },
207
+ })
208
+ const huskyDir = join(ctx.consumerRoot, '.husky')
209
+ mkdirSync(huskyDir)
210
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
211
+ writeFileSync(join(huskyDir, name), `# ${name} original\n`)
212
+ }
213
+
214
+ const results = await installHooks(ctx)
215
+
216
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
217
+ const fp = join(huskyDir, name)
218
+
219
+ // .bak exists with original content
220
+ expect(existsSync(fp + '.bak')).toBe(true)
221
+ expect(readFileSync(fp + '.bak', 'utf-8')).toBe(`# ${name} original\n`)
222
+
223
+ // Hook file has new content + executable
224
+ expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(name))
225
+ expect(statSync(fp).mode & 0o111).not.toBe(0)
226
+ }
227
+
228
+ for (const r of results) {
229
+ expect(r.action).toBe('overwritten')
230
+ expect(r.message).toContain('.bak')
231
+ }
232
+ })
233
+
234
+ // ── 5. Case E: Unmanaged, no --force → append ─────────────────────────
235
+
236
+ it('Case E: .husky/ exists, unmanaged hook, no --force → appends separator+content', async () => {
237
+ const ctx = makeContext({
238
+ hasHuskyDir: true,
239
+ force: false,
240
+ existingHooks: {
241
+ 'pre-commit': { exists: true, isManaged: false },
242
+ 'post-merge': { exists: true, isManaged: false },
243
+ },
244
+ })
245
+ const huskyDir = join(ctx.consumerRoot, '.husky')
246
+ mkdirSync(huskyDir)
247
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
248
+ writeFileSync(join(huskyDir, name), `# ${name} original\n`)
249
+ }
250
+
251
+ const results = await installHooks(ctx)
252
+
253
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
254
+ const fp = join(huskyDir, name)
255
+ const content = readFileSync(fp, 'utf-8')
256
+
257
+ // Original content preserved
258
+ expect(content).toContain(`# ${name} original`)
259
+ // Separator inserted between original and new content
260
+ expect(content).toContain(HOOK_MERGE_SEPARATOR)
261
+ // New content appended
262
+ expect(content).toContain(expectedContent(name))
263
+ // File is executable
264
+ expect(statSync(fp).mode & 0o111).not.toBe(0)
265
+ // No .bak in merge mode
266
+ expect(existsSync(fp + '.bak')).toBe(false)
267
+ }
268
+
269
+ for (const r of results) {
270
+ expect(r.action).toBe('merged')
271
+ }
272
+ })
273
+
274
+ // ── 6. Case F: dry-run ────────────────────────────────────────────────
275
+
276
+ it('Case F: dryRun=true → returns action="dry-run", no files written', async () => {
277
+ const ctx = makeContext({
278
+ hasHuskyDir: false,
279
+ dryRun: true,
280
+ })
281
+
282
+ const results = await installHooks(ctx)
283
+
284
+ // No files or directories were created
285
+ expect(existsSync(join(ctx.consumerRoot, '.husky'))).toBe(false)
286
+
287
+ for (const r of results) {
288
+ expect(r.action).toBe('dry-run')
289
+ }
290
+ })
291
+
292
+ // ── 7. Partial failure: one hook succeeds, the other fails ────────────
293
+
294
+ it('partial failure: one hook succeeds, other fails → mixed results, errors do not block', async () => {
295
+ const ctx = makeContext({
296
+ hasHuskyDir: true,
297
+ existingHooks: {
298
+ 'pre-commit': { exists: true, isManaged: true },
299
+ 'post-merge': { exists: true, isManaged: true },
300
+ },
301
+ })
302
+ const huskyDir = join(ctx.consumerRoot, '.husky')
303
+ mkdirSync(huskyDir)
304
+
305
+ // Make post-merge a directory → writeFileSync fails (EISDIR)
306
+ mkdirSync(join(huskyDir, 'post-merge'), { recursive: true })
307
+ writeFileSync(join(huskyDir, 'pre-commit'), '# old\n')
308
+
309
+ const results = await installHooks(ctx)
310
+
311
+ expect(results).toHaveLength(Object.keys(TEMPLATE_HOOKS).length)
312
+
313
+ const preCommitResult = results.find((r) => r.hook === 'pre-commit')
314
+ const postMergeResult = results.find((r) => r.hook === 'post-merge')
315
+
316
+ expect(preCommitResult.action).toBe('overwritten')
317
+ expect(postMergeResult.action).toBe('skipped')
318
+
319
+ // Successful hook's file was updated
320
+ expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(
321
+ expectedContent('pre-commit'),
322
+ )
323
+ })
324
+
325
+ // ── 8. .husky is a file, not a directory ──────────────────────────────
326
+
327
+ it('.husky is a file, not a directory → graceful error, returns action="skipped"', async () => {
328
+ const ctx = makeContext({
329
+ hasHuskyDir: false,
330
+ existingHooks: {},
331
+ })
332
+
333
+ // Create .husky as a regular file — ensureDir will fail
334
+ writeFileSync(join(ctx.consumerRoot, '.husky'), '# i am a file\n', 'utf-8')
335
+
336
+ const results = await installHooks(ctx)
337
+
338
+ for (const r of results) {
339
+ expect(r.action).toBe('skipped')
340
+ }
341
+
342
+ // The .husky "file" is still a file
343
+ expect(statSync(join(ctx.consumerRoot, '.husky')).isFile()).toBe(true)
344
+ })
345
+
346
+ // ── 9. Executable permission check ────────────────────────────────────
347
+
348
+ it('created hook files are made executable (chmod 755)', async () => {
349
+ const ctx = makeContext({ hasHuskyDir: false })
350
+ await installHooks(ctx)
351
+
352
+ const huskyDir = join(ctx.consumerRoot, '.husky')
353
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
354
+ const fp = join(huskyDir, name)
355
+ const mode = statSync(fp).mode
356
+
357
+ // Owner execute (0o100)
358
+ expect(mode & 0o100).not.toBe(0)
359
+ // Group execute (0o010)
360
+ expect(mode & 0o010).not.toBe(0)
361
+ // Other execute (0o001)
362
+ expect(mode & 0o001).not.toBe(0)
363
+ }
364
+ })
365
+
366
+ // ── 10. Empty .husky/ dir → works correctly (Case B) ──────────────────
367
+
368
+ it('empty .husky/ dir (no files at all) → works correctly', async () => {
369
+ const ctx = makeContext({
370
+ hasHuskyDir: true,
371
+ existingHooks: {
372
+ 'pre-commit': { exists: false, isManaged: false },
373
+ 'post-merge': { exists: false, isManaged: false },
374
+ },
375
+ })
376
+ mkdirSync(join(ctx.consumerRoot, '.husky'))
377
+
378
+ const results = await installHooks(ctx)
379
+
380
+ const huskyDir = join(ctx.consumerRoot, '.husky')
381
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
382
+ const fp = join(huskyDir, name)
383
+ expect(existsSync(fp)).toBe(true)
384
+ expect(readFileSync(fp, 'utf-8')).toBe(expectedContent(name))
385
+ expect(statSync(fp).mode & 0o111).not.toBe(0)
386
+ }
387
+
388
+ for (const r of results) {
389
+ expect(r.action).toBe('created')
390
+ }
391
+ })
392
+
393
+ // ── 11. flags.force alternative path ───────────────────────────────────
394
+
395
+ it('flags.force is respected as alternative to direct context.force', async () => {
396
+ const ctx = makeContext({
397
+ hasHuskyDir: true,
398
+ force: false, // context-level force is false
399
+ flags: { force: true }, // but flags object has force=true
400
+ existingHooks: {
401
+ 'pre-commit': { exists: true, isManaged: false },
402
+ 'post-merge': { exists: true, isManaged: false },
403
+ },
404
+ })
405
+ const huskyDir = join(ctx.consumerRoot, '.husky')
406
+ mkdirSync(huskyDir)
407
+ writeFileSync(join(huskyDir, 'pre-commit'), '# orig\n')
408
+ writeFileSync(join(huskyDir, 'post-merge'), '# orig\n')
409
+
410
+ const results = await installHooks(ctx)
411
+
412
+ for (const r of results) {
413
+ expect(r.action).toBe('overwritten')
414
+ }
415
+ expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(true)
416
+ expect(existsSync(join(huskyDir, 'post-merge.bak'))).toBe(true)
417
+ })
418
+
419
+ // ── 12. Partial existingHooks map ─────────────────────────────────────
420
+
421
+ it('handles partial existingHooks (one hook missing from map)', async () => {
422
+ const ctx = makeContext({
423
+ hasHuskyDir: true,
424
+ existingHooks: {
425
+ 'pre-commit': { exists: true, isManaged: true },
426
+ // post-merge absent → treated as exists=false, isManaged=false
427
+ },
428
+ })
429
+ const huskyDir = join(ctx.consumerRoot, '.husky')
430
+ mkdirSync(huskyDir)
431
+ writeFileSync(join(huskyDir, 'pre-commit'), '# old\n')
432
+
433
+ const results = await installHooks(ctx)
434
+
435
+ const preCommitResult = results.find((r) => r.hook === 'pre-commit')
436
+ const postMergeResult = results.find((r) => r.hook === 'post-merge')
437
+
438
+ expect(preCommitResult.action).toBe('overwritten')
439
+ expect(postMergeResult.action).toBe('created')
440
+
441
+ // Post-merge was created from scratch
442
+ expect(existsSync(join(huskyDir, 'post-merge'))).toBe(true)
443
+ })
444
+
445
+ // ── 13. Result structure validation ───────────────────────────────────
446
+
447
+ it('each result entry has hook, action, and message fields with correct types', async () => {
448
+ const ctx = makeContext({ hasHuskyDir: false })
449
+ const results = await installHooks(ctx)
450
+
451
+ for (const r of results) {
452
+ expect(r).toHaveProperty('hook')
453
+ expect(r).toHaveProperty('action')
454
+ expect(r).toHaveProperty('message')
455
+ expect(typeof r.hook).toBe('string')
456
+ expect(typeof r.action).toBe('string')
457
+ expect(typeof r.message).toBe('string')
458
+ }
459
+
460
+ // All expected hook names are present
461
+ const hookNames = results.map((r) => r.hook).sort()
462
+ expect(hookNames).toEqual(Object.keys(TEMPLATE_HOOKS).sort())
463
+ })
464
+
465
+ // ── 14. Dry-run with existing unmanaged hooks + force ─────────────────
466
+
467
+ it('dry-run with existing unmanaged hooks + force flag → no .bak files created', async () => {
468
+ const ctx = makeContext({
469
+ hasHuskyDir: true,
470
+ dryRun: true,
471
+ force: true,
472
+ existingHooks: {
473
+ 'pre-commit': { exists: true, isManaged: false },
474
+ 'post-merge': { exists: true, isManaged: false },
475
+ },
476
+ })
477
+ const huskyDir = join(ctx.consumerRoot, '.husky')
478
+ mkdirSync(huskyDir)
479
+ writeFileSync(join(huskyDir, 'pre-commit'), '# original\n')
480
+ writeFileSync(join(huskyDir, 'post-merge'), '# original\n')
481
+
482
+ const results = await installHooks(ctx)
483
+
484
+ // Files are completely unchanged
485
+ expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe('# original\n')
486
+ expect(readFileSync(join(huskyDir, 'post-merge'), 'utf-8')).toBe('# original\n')
487
+ // No .bak files created
488
+ expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
489
+ expect(existsSync(join(huskyDir, 'post-merge.bak'))).toBe(false)
490
+
491
+ for (const r of results) {
492
+ expect(r.action).toBe('dry-run')
493
+ }
494
+ })
495
+
496
+ // ── 15. Idempotent overwrite does NOT create .bak ─────────────────────
497
+
498
+ it('no .bak file created for idempotent overwrite (Case C, isManaged=true)', async () => {
499
+ const ctx = makeContext({
500
+ hasHuskyDir: true,
501
+ existingHooks: {
502
+ 'pre-commit': { exists: true, isManaged: true },
503
+ 'post-merge': { exists: true, isManaged: true },
504
+ },
505
+ })
506
+ const huskyDir = join(ctx.consumerRoot, '.husky')
507
+ mkdirSync(huskyDir)
508
+ writeFileSync(join(huskyDir, 'pre-commit'), '# managed content\n')
509
+ writeFileSync(join(huskyDir, 'post-merge'), '# managed content\n')
510
+
511
+ await installHooks(ctx)
512
+
513
+ expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
514
+ expect(existsSync(join(huskyDir, 'post-merge.bak'))).toBe(false)
515
+ })
516
+
517
+ // ── 16. Result messages contain hook name and description ─────────────
518
+
519
+ it('result messages contain the hook name for traceability', async () => {
520
+ const ctx = makeContext({ hasHuskyDir: false })
521
+ const results = await installHooks(ctx)
522
+
523
+ for (const r of results) {
524
+ expect(r.message).toContain(r.hook)
525
+ }
526
+ })
527
+ })
@@ -0,0 +1,127 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // husky-init.js — Phase 4: Programmatic husky initialization
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // This module generates the `.husky/_/` shim directory and sets
6
+ // `core.hooksPath` via husky's programmatic API. It is called after
7
+ // hook files have been installed/merged (hooks.js) and the consumer's
8
+ // `prepare` script has been configured (prepare.js).
9
+ //
10
+ // The dynamic import of husky resolves from the **consumer's**
11
+ // node_modules (never the package's own) so that the consumer's
12
+ // installed husky version is used.
13
+ // ═════════════════════════════════════════════════════════════════════════════
14
+
15
+ import { execSync } from 'child_process'
16
+ import { existsSync } from 'fs'
17
+ import { join } from 'path'
18
+ import { createRequire } from 'module'
19
+
20
+ import { info, warn, verbose } from './ui.js'
21
+
22
+ // ── Main export ──────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Initializes husky in the consumer's project.
26
+ *
27
+ * Calls the husky programmatic API (`husky()`) to:
28
+ * - Set `git config core.hooksPath .husky/_` in the consumer repo
29
+ * - Generate `.husky/_/` shim files (h, husky.sh, pre-commit, post-merge)
30
+ * - Generate `.husky/_/.gitignore` with content `*`
31
+ *
32
+ * The call is **idempotent** — running it multiple times is safe with no
33
+ * duplicate side-effects.
34
+ *
35
+ * @param {object} context - Pipeline context object.
36
+ * @param {string} context.consumerRoot - Absolute path to the consumer project.
37
+ * @param {boolean} context.hasGit - Whether the project has a `.git/` directory.
38
+ * @param {boolean} context.dryRun - When `true` only report, do nothing.
39
+ * @param {boolean} context.isCI - When `true` skip husky init entirely.
40
+ * @param {boolean} context.verbose - When `true` emit verbose progress messages.
41
+ * @returns {Promise<{action: string, message: string}>}
42
+ */
43
+ export async function initHusky(context) {
44
+ const { consumerRoot, hasGit, dryRun, isCI, verbose: verboseFlag } = context
45
+
46
+ // ── Dry-run mode ─────────────────────────────────────────────────────
47
+ if (dryRun) {
48
+ info(
49
+ '[setup] Would initialize husky (generate .husky/_/ shims, set core.hooksPath)',
50
+ )
51
+ return { action: 'dry-run', message: 'Would initialize husky' }
52
+ }
53
+
54
+ // ── CI environment ───────────────────────────────────────────────────
55
+ if (isCI) {
56
+ warn('[setup] CI environment detected — skipping husky init')
57
+ return {
58
+ action: 'skipped',
59
+ message: 'Skipping husky init in CI environment',
60
+ }
61
+ }
62
+
63
+ // ── No git repository ────────────────────────────────────────────────
64
+ if (!hasGit) {
65
+ console.warn('[setup] No .git directory — not a git repository')
66
+ return {
67
+ action: 'skipped',
68
+ message: 'No .git directory — not a git repository',
69
+ }
70
+ }
71
+
72
+ // ── Resolve husky from the consumer's node_modules ──────────────────
73
+ let husky
74
+ verbose(verboseFlag, 'Resolving husky from consumer node_modules\u2026')
75
+ try {
76
+ const consumerRequire = createRequire(join(consumerRoot, 'package.json'))
77
+ const huskyPath = consumerRequire.resolve('husky')
78
+ verbose(verboseFlag, `Found husky at ${huskyPath}`)
79
+ const mod = await import(huskyPath)
80
+ husky = mod.default
81
+ } catch {
82
+ // createRequire.resolve or import() failed — husky is not installed
83
+ verbose(verboseFlag, 'husky not found \u2014 skipping init')
84
+ return {
85
+ action: 'skipped',
86
+ message: 'husky is not installed. Run: npm install husky --save-dev',
87
+ }
88
+ }
89
+
90
+ // ── Clear stale hooksPath ──────────────────────────────────────────
91
+ // Husky v9's programmatic API is a no-op when core.hooksPath already
92
+ // exists (even if pointing to a wrong value). Unset it so husky()
93
+ // actually generates the .husky/_/ shim files from scratch.
94
+ verbose(verboseFlag, 'Clearing stale core.hooksPath\u2026')
95
+ try {
96
+ execSync('git config --local --unset core.hooksPath', {
97
+ cwd: consumerRoot,
98
+ stdio: 'pipe',
99
+ })
100
+ } catch {
101
+ // Silently continue if the key didn't exist or is not unset-able.
102
+ // husky() will handle hooksPath setup internally.
103
+ }
104
+
105
+ // ── Invoke husky() ──────────────────────────────────────────────────
106
+ verbose(verboseFlag, 'Calling husky()\u2026')
107
+ try {
108
+ husky(consumerRoot)
109
+ } catch (err) {
110
+ verbose(verboseFlag, `husky() failed: ${err.message}`)
111
+ return { action: 'failed', message: err.message }
112
+ }
113
+
114
+ // ── Verify post-condition ───────────────────────────────────────────
115
+ verbose(verboseFlag, 'Verifying .husky/_/h exists\u2026')
116
+ const hPath = join(consumerRoot, '.husky', '_', 'h')
117
+ if (!existsSync(hPath)) {
118
+ return {
119
+ action: 'failed',
120
+ message:
121
+ 'Husky initialization may have failed — .husky/_/h not found after init',
122
+ }
123
+ }
124
+
125
+ // ── Success ─────────────────────────────────────────────────────────
126
+ return { action: 'initialized', message: 'Husky initialized successfully' }
127
+ }