@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.
- package/.agents/instructions/learned-knowledge.instructions.md +46 -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/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 +261 -0
- package/scripts/setup/discover.test.js +408 -0
- package/scripts/setup/hooks.js +280 -0
- package/scripts/setup/hooks.test.js +527 -0
- package/scripts/setup/husky-init.js +127 -0
- package/scripts/setup/index.js +354 -0
- package/scripts/setup/index.test.js +664 -0
- package/scripts/setup/prepare.js +127 -0
- package/scripts/setup/prepare.test.js +718 -0
- package/scripts/setup/sync-phase.js +179 -0
- package/scripts/setup/sync-phase.test.js +195 -0
- package/scripts/setup/ui.js +521 -0
- package/scripts/setup/utils.js +371 -0
- package/scripts/sync.js +9 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// hooks.js — Hook installation and merging for the setup command pipeline
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Implements the 6-case merge algorithm for installing template git hooks
|
|
6
|
+
// (pre-commit, post-merge) into a consumer project's .husky/ directory:
|
|
7
|
+
//
|
|
8
|
+
// Case A : No .husky/ directory → Create + write
|
|
9
|
+
// Case B : No existing hook file → Write
|
|
10
|
+
// Case C : Existing, isManaged (= ours) → Overwrite (idempotent re-run)
|
|
11
|
+
// Case D : Existing, not ours, --force → Backup + overwrite
|
|
12
|
+
// Case E : Existing, not ours, no --force → Append with separator
|
|
13
|
+
// Case F : dry-run mode → Log only, no writes
|
|
14
|
+
//
|
|
15
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
import { resolve } from 'path'
|
|
18
|
+
|
|
19
|
+
import { HOOK_MARKER, HOOK_MERGE_SEPARATOR, TEMPLATE_HOOKS } from './constants.js'
|
|
20
|
+
import {
|
|
21
|
+
chmodX,
|
|
22
|
+
ensureDir,
|
|
23
|
+
safeReadFile,
|
|
24
|
+
safeWriteFile,
|
|
25
|
+
} from './utils.js'
|
|
26
|
+
import {
|
|
27
|
+
dryRunBanner,
|
|
28
|
+
step,
|
|
29
|
+
stepError,
|
|
30
|
+
stepSuccess,
|
|
31
|
+
stepWarn,
|
|
32
|
+
verbose,
|
|
33
|
+
} from './ui.js'
|
|
34
|
+
|
|
35
|
+
// ── Content builder ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the final content for a hook file.
|
|
39
|
+
*
|
|
40
|
+
* The marker comment (`HOOK_MARKER`) is placed on the very first line for
|
|
41
|
+
* idempotency detection. The template hook content from `TEMPLATE_HOOKS`
|
|
42
|
+
* follows immediately.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} hookName - Key from `TEMPLATE_HOOKS` (e.g. `'pre-commit'`).
|
|
45
|
+
* @returns {string} Complete file content with the marker as line 1.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* buildHookContent('pre-commit')
|
|
49
|
+
* // → "# Managed by @abarcenas/ai-workflow-template setup\nnode scripts/bump-version.js\n..."
|
|
50
|
+
*/
|
|
51
|
+
function buildHookContent(hookName) {
|
|
52
|
+
const template = TEMPLATE_HOOKS[hookName]
|
|
53
|
+
// The `content` field is always a string in our constants (pre-composed).
|
|
54
|
+
const raw = template.content
|
|
55
|
+
// Marker is first line, then content.
|
|
56
|
+
return HOOK_MARKER + '\n' + raw
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Hook file path helper ────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the absolute path to a hook file inside the consumer's `.husky/`
|
|
63
|
+
* directory.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} consumerRoot - Consumer project root.
|
|
66
|
+
* @param {string} hookName - Hook filename (e.g. `'pre-commit'`).
|
|
67
|
+
* @returns {string} Absolute path.
|
|
68
|
+
*/
|
|
69
|
+
function hookPath(consumerRoot, hookName) {
|
|
70
|
+
return resolve(consumerRoot, '.husky', hookName)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Dry-run result helper ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Determines what action *would* be taken for a hook, without performing any
|
|
77
|
+
* writes. Used exclusively by dry-run mode (Case F) and also provides the
|
|
78
|
+
* human-readable message for the result entry.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} context - Context object from `discover.js`.
|
|
81
|
+
* @param {string} hookName - Key from `TEMPLATE_HOOKS`.
|
|
82
|
+
* @returns {{ action: string, message: string }}
|
|
83
|
+
*/
|
|
84
|
+
function dryRunResult(context, hookName) {
|
|
85
|
+
const hook = context.existingHooks?.[hookName]
|
|
86
|
+
const hasHuskyDir = !!context.hasHuskyDir
|
|
87
|
+
const existing = hook?.exists ?? false
|
|
88
|
+
const isManaged = hook?.isManaged ?? false
|
|
89
|
+
const force = !!(context.force || context.flags?.force)
|
|
90
|
+
const desc = TEMPLATE_HOOKS[hookName]?.description ?? ''
|
|
91
|
+
|
|
92
|
+
if (!hasHuskyDir) {
|
|
93
|
+
return {
|
|
94
|
+
action: 'dry-run',
|
|
95
|
+
message: `Would create .husky/ and ${hookName} hook (${desc})`,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!existing) {
|
|
99
|
+
return {
|
|
100
|
+
action: 'dry-run',
|
|
101
|
+
message: `Would write ${hookName} hook (${desc})`,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (isManaged) {
|
|
105
|
+
return {
|
|
106
|
+
action: 'dry-run',
|
|
107
|
+
message: `Would overwrite existing ${hookName} hook (idempotent update, ${desc})`,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (force) {
|
|
111
|
+
return {
|
|
112
|
+
action: 'dry-run',
|
|
113
|
+
message: `Would back up existing ${hookName} hook and overwrite with force (${desc})`,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Case E equivalent in dry-run
|
|
117
|
+
return {
|
|
118
|
+
action: 'dry-run',
|
|
119
|
+
message: `Would append to existing ${hookName} hook (${desc})`,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Main export ──────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Installs or merges template hooks into the consumer project's `.husky/`
|
|
127
|
+
* directory.
|
|
128
|
+
*
|
|
129
|
+
* Implements the **6-case merge algorithm** per the setup command design
|
|
130
|
+
* (§4.3). Each hook in `TEMPLATE_HOOKS` (pre-commit, post-merge) is processed
|
|
131
|
+
* independently:
|
|
132
|
+
*
|
|
133
|
+
* | Case | Condition | Action | Result key |
|
|
134
|
+
* |------|----------------------------------------------|-----------------------------|-------------|
|
|
135
|
+
* | A | No `.husky/` directory | Create dir + write hook | `created` |
|
|
136
|
+
* | B | No existing hook file | Write hook file | `created` |
|
|
137
|
+
* | C | Hook exists + `isManaged` (= ours) | Overwrite (idempotent) | `overwritten` |
|
|
138
|
+
* | D | Hook exists + not ours + `--force` | Backup `.bak` + overwrite | `overwritten` |
|
|
139
|
+
* | E | Hook exists + not ours + no `--force` | Append with separator | `merged` |
|
|
140
|
+
* | F | `context.dryRun` is `true` | Log only, no writes | `dry-run` |
|
|
141
|
+
*
|
|
142
|
+
* Every operation is wrapped in a try/catch. A failure for one hook does not
|
|
143
|
+
* prevent the other from being processed.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} context - Context object from `discover.js`.
|
|
146
|
+
* @param {string} context.consumerRoot - Absolute path to consumer project root.
|
|
147
|
+
* @param {boolean} [context.hasHuskyDir] - Whether `.husky/` already exists.
|
|
148
|
+
* @param {Record<string, {exists: boolean, content: string|null, isManaged: boolean}>}
|
|
149
|
+
* [context.existingHooks] - Descriptors for each hook.
|
|
150
|
+
* @param {boolean} [context.dryRun] - When `true`, no files are modified.
|
|
151
|
+
* @param {boolean} [context.force] - When `true`, existing hooks are overwritten.
|
|
152
|
+
* @param {{force?: boolean}} [context.flags] - Parsed CLI flags (alternative to
|
|
153
|
+
* `context.force`).
|
|
154
|
+
* @returns {Promise<Array<{hook: string, action: string, message: string}>>}
|
|
155
|
+
* A result entry for each hook in `TEMPLATE_HOOKS`.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```js
|
|
159
|
+
* const ctx = await discover({ dryRun: true })
|
|
160
|
+
* const results = await installHooks(ctx)
|
|
161
|
+
* // → [{ hook: 'pre-commit', action: 'dry-run', message: '...' }, ...]
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export async function installHooks(context) {
|
|
165
|
+
const hookNames = Object.keys(TEMPLATE_HOOKS)
|
|
166
|
+
const results = []
|
|
167
|
+
|
|
168
|
+
// ── Case F: dry-run mode ────────────────────────────────────────────
|
|
169
|
+
if (context.dryRun) {
|
|
170
|
+
dryRunBanner()
|
|
171
|
+
|
|
172
|
+
for (const hookName of hookNames) {
|
|
173
|
+
const r = dryRunResult(context, hookName)
|
|
174
|
+
step(r.message)
|
|
175
|
+
results.push({ hook: hookName, action: 'dry-run', message: r.message })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return results
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Process each hook ───────────────────────────────────────────────
|
|
182
|
+
for (const hookName of hookNames) {
|
|
183
|
+
const hookInfo = context.existingHooks?.[hookName]
|
|
184
|
+
const hasHuskyDir = !!context.hasHuskyDir
|
|
185
|
+
const existing = hookInfo?.exists ?? false
|
|
186
|
+
const isManaged = hookInfo?.isManaged ?? false
|
|
187
|
+
const force = !!(context.force || context.flags?.force)
|
|
188
|
+
const desc = TEMPLATE_HOOKS[hookName]?.description ?? ''
|
|
189
|
+
const target = hookPath(context.consumerRoot, hookName)
|
|
190
|
+
const content = buildHookContent(hookName)
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// ── Case A: No .husky/ directory — create it and write hook ──
|
|
194
|
+
if (!hasHuskyDir) {
|
|
195
|
+
verbose(context.verbose, `Creating .husky/ directory\u2026`)
|
|
196
|
+
ensureDir(resolve(context.consumerRoot, '.husky'))
|
|
197
|
+
verbose(context.verbose, `Writing ${hookName} hook\u2026`)
|
|
198
|
+
safeWriteFile(target, content)
|
|
199
|
+
verbose(context.verbose, 'Setting executable permissions\u2026')
|
|
200
|
+
chmodX(target)
|
|
201
|
+
verbose(context.verbose, ' \u2713 done')
|
|
202
|
+
const msg = `${hookName} hook created (\u2713 ${desc})`
|
|
203
|
+
stepSuccess(msg)
|
|
204
|
+
results.push({ hook: hookName, action: 'created', message: msg })
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Case B: .husky/ exists but no hook file ─────────────────
|
|
209
|
+
if (!existing) {
|
|
210
|
+
verbose(context.verbose, `Writing ${hookName} hook\u2026`)
|
|
211
|
+
safeWriteFile(target, content)
|
|
212
|
+
verbose(context.verbose, 'Setting executable permissions\u2026')
|
|
213
|
+
chmodX(target)
|
|
214
|
+
verbose(context.verbose, ' \u2713 done')
|
|
215
|
+
const msg = `${hookName} hook created (\u2713 ${desc})`
|
|
216
|
+
stepSuccess(msg)
|
|
217
|
+
results.push({ hook: hookName, action: 'created', message: msg })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Case C: Hook exists AND is managed by us — overwrite ────
|
|
222
|
+
if (isManaged) {
|
|
223
|
+
verbose(context.verbose, `Overwriting ${hookName} (idempotent update)\u2026`)
|
|
224
|
+
safeWriteFile(target, content)
|
|
225
|
+
verbose(context.verbose, 'Setting executable permissions\u2026')
|
|
226
|
+
chmodX(target)
|
|
227
|
+
verbose(context.verbose, ' \u2713 done')
|
|
228
|
+
const msg = `${hookName} hook overwritten (idempotent update, ${desc})`
|
|
229
|
+
stepSuccess(msg)
|
|
230
|
+
results.push({ hook: hookName, action: 'overwritten', message: msg })
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Case D: Hook exists, not ours, --force — back up + overwrite
|
|
235
|
+
if (force) {
|
|
236
|
+
verbose(context.verbose, `Backing up existing ${hookName}\u2026`)
|
|
237
|
+
const bakPath = target + '.bak'
|
|
238
|
+
const originalContent = safeReadFile(target)
|
|
239
|
+
safeWriteFile(bakPath, originalContent ?? '')
|
|
240
|
+
verbose(context.verbose, `Writing ${hookName} hook (force)\u2026`)
|
|
241
|
+
safeWriteFile(target, content)
|
|
242
|
+
verbose(context.verbose, 'Setting executable permissions\u2026')
|
|
243
|
+
chmodX(target)
|
|
244
|
+
verbose(context.verbose, ' \u2713 done')
|
|
245
|
+
const msg =
|
|
246
|
+
`${hookName} hook overwritten (original backed up to .bak, ${desc})`
|
|
247
|
+
stepSuccess(msg)
|
|
248
|
+
results.push({ hook: hookName, action: 'overwritten', message: msg })
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Case E: Hook exists, not ours, no --force — append ──────
|
|
253
|
+
verbose(context.verbose, `Reading existing ${hookName} for merge\u2026`)
|
|
254
|
+
const existingContent = safeReadFile(target)
|
|
255
|
+
const mergedContent =
|
|
256
|
+
(existingContent ?? '') + '\n' + HOOK_MERGE_SEPARATOR + '\n' + content
|
|
257
|
+
verbose(context.verbose, `Writing merged ${hookName}\u2026`)
|
|
258
|
+
safeWriteFile(target, mergedContent)
|
|
259
|
+
verbose(context.verbose, 'Setting executable permissions\u2026')
|
|
260
|
+
chmodX(target)
|
|
261
|
+
verbose(context.verbose, ' \u2713 done')
|
|
262
|
+
const msg = `${hookName} hook merged with existing content (${desc})`
|
|
263
|
+
stepSuccess(msg)
|
|
264
|
+
results.push({ hook: hookName, action: 'merged', message: msg })
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// Any error during file operations (permission denied, .husky/
|
|
267
|
+
// is a file not a directory, read-only filesystem, etc.) is
|
|
268
|
+
// caught here — non-fatal for the other hook.
|
|
269
|
+
const errMsg = `${hookName} hook: ${err.message}`
|
|
270
|
+
stepError(errMsg)
|
|
271
|
+
results.push({
|
|
272
|
+
hook: hookName,
|
|
273
|
+
action: 'skipped',
|
|
274
|
+
message: errMsg,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return results
|
|
280
|
+
}
|