@abarcenas/ai-workflow-template 1.28.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.
- package/.agents/instructions/learned-knowledge.instructions.md +23 -0
- package/.github/workflows/npm-publish.yml +3 -0
- package/.github/workflows/pr-test.yml +23 -0
- package/.husky/post-merge +1 -0
- package/.husky/pre-commit +2 -0
- package/.opencode/agents/architect.agent.md +2 -2
- package/.opencode/agents/designer.agent.md +13 -13
- package/.opencode/agents/e2e-tester.agent.md +33 -32
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +46 -56
- package/.opencode/agents/orchestrator/orchestrator.agent.md +55 -67
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +47 -59
- package/.opencode/agents/researcher.agent.md +15 -14
- package/.opencode/agents/unit-tester.agent.md +2 -1
- package/README.md +48 -1
- package/bin/setup.js +23 -0
- package/package.json +7 -2
- package/scripts/setup/constants.js +226 -0
- package/scripts/setup/discover.js +236 -0
- package/scripts/setup/discover.test.js +388 -0
- package/scripts/setup/hooks.js +261 -0
- package/scripts/setup/hooks.test.js +527 -0
- package/scripts/setup/husky-init.js +102 -0
- package/scripts/setup/index.js +354 -0
- package/scripts/setup/index.test.js +639 -0
- package/scripts/setup/prepare.js +127 -0
- package/scripts/setup/prepare.test.js +718 -0
- package/scripts/setup/sync-phase.js +172 -0
- package/scripts/setup/ui.js +503 -0
- package/scripts/setup/utils.js +371 -0
|
@@ -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,102 @@
|
|
|
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 { existsSync } from 'fs'
|
|
16
|
+
import { join } from 'path'
|
|
17
|
+
import { createRequire } from 'module'
|
|
18
|
+
|
|
19
|
+
// ── Main export ──────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initializes husky in the consumer's project.
|
|
23
|
+
*
|
|
24
|
+
* Calls the husky programmatic API (`husky()`) to:
|
|
25
|
+
* - Set `git config core.hooksPath .husky/_` in the consumer repo
|
|
26
|
+
* - Generate `.husky/_/` shim files (h, husky.sh, pre-commit, post-merge)
|
|
27
|
+
* - Generate `.husky/_/.gitignore` with content `*`
|
|
28
|
+
*
|
|
29
|
+
* The call is **idempotent** — running it multiple times is safe with no
|
|
30
|
+
* duplicate side-effects.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} context - Pipeline context object.
|
|
33
|
+
* @param {string} context.consumerRoot - Absolute path to the consumer project.
|
|
34
|
+
* @param {boolean} context.hasGit - Whether the project has a `.git/` directory.
|
|
35
|
+
* @param {boolean} context.dryRun - When `true` only report, do nothing.
|
|
36
|
+
* @param {boolean} context.isCI - When `true` skip husky init entirely.
|
|
37
|
+
* @returns {Promise<{action: string, message: string}>}
|
|
38
|
+
*/
|
|
39
|
+
export async function initHusky(context) {
|
|
40
|
+
const { consumerRoot, hasGit, dryRun, isCI } = context
|
|
41
|
+
|
|
42
|
+
// ── Dry-run mode ─────────────────────────────────────────────────────
|
|
43
|
+
if (dryRun) {
|
|
44
|
+
console.log(
|
|
45
|
+
'[setup] Would initialize husky (generate .husky/_/ shims, set core.hooksPath)',
|
|
46
|
+
)
|
|
47
|
+
return { action: 'dry-run', message: 'Would initialize husky' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── CI environment ───────────────────────────────────────────────────
|
|
51
|
+
if (isCI) {
|
|
52
|
+
console.log('[setup] CI environment detected — skipping husky init')
|
|
53
|
+
return {
|
|
54
|
+
action: 'skipped',
|
|
55
|
+
message: 'Skipping husky init in CI environment',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── No git repository ────────────────────────────────────────────────
|
|
60
|
+
if (!hasGit) {
|
|
61
|
+
console.warn('[setup] No .git directory — not a git repository')
|
|
62
|
+
return {
|
|
63
|
+
action: 'skipped',
|
|
64
|
+
message: 'No .git directory — not a git repository',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Resolve husky from the consumer's node_modules ──────────────────
|
|
69
|
+
let husky
|
|
70
|
+
try {
|
|
71
|
+
const consumerRequire = createRequire(join(consumerRoot, 'package.json'))
|
|
72
|
+
const huskyPath = consumerRequire.resolve('husky')
|
|
73
|
+
const mod = await import(huskyPath)
|
|
74
|
+
husky = mod.default
|
|
75
|
+
} catch {
|
|
76
|
+
// createRequire.resolve or import() failed — husky is not installed
|
|
77
|
+
return {
|
|
78
|
+
action: 'skipped',
|
|
79
|
+
message: 'husky is not installed. Run: npm install husky --save-dev',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Invoke husky() ──────────────────────────────────────────────────
|
|
84
|
+
try {
|
|
85
|
+
husky(consumerRoot)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { action: 'failed', message: err.message }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Verify post-condition ───────────────────────────────────────────
|
|
91
|
+
const hPath = join(consumerRoot, '.husky', '_', 'h')
|
|
92
|
+
if (!existsSync(hPath)) {
|
|
93
|
+
return {
|
|
94
|
+
action: 'failed',
|
|
95
|
+
message:
|
|
96
|
+
'Husky initialization may have failed — .husky/_/h not found after init',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Success ─────────────────────────────────────────────────────────
|
|
101
|
+
return { action: 'initialized', message: 'Husky initialized successfully' }
|
|
102
|
+
}
|