@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,261 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// discover.js — Consumer project state detection for the setup command pipeline
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'path'
|
|
6
|
+
|
|
7
|
+
import { HOOK_MARKER, TEMPLATE_HOOKS } from './constants.js'
|
|
8
|
+
import {
|
|
9
|
+
dirExists,
|
|
10
|
+
getConsumerRoot,
|
|
11
|
+
getNodeVersion,
|
|
12
|
+
isCI,
|
|
13
|
+
safeReadFile,
|
|
14
|
+
safeReadJson,
|
|
15
|
+
} from './utils.js'
|
|
16
|
+
|
|
17
|
+
import { verbose } from './ui.js'
|
|
18
|
+
|
|
19
|
+
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns a default hook descriptor for cases where detection fails.
|
|
23
|
+
*
|
|
24
|
+
* @returns {{ exists: boolean, content: null, isManaged: boolean }}
|
|
25
|
+
*/
|
|
26
|
+
function defaultHookEntry() {
|
|
27
|
+
return { exists: false, content: null, isManaged: false }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds a minimal Context object with every property initialised to a safe
|
|
32
|
+
* default. Callers can always destructure the returned Context without
|
|
33
|
+
* checking for undefined.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} [flags={}]
|
|
36
|
+
* @returns {Context}
|
|
37
|
+
*/
|
|
38
|
+
function createDefaultContext(flags = {}) {
|
|
39
|
+
/** @type {Record<string, { exists: boolean, content: string|null, isManaged: boolean }>} */
|
|
40
|
+
const existingHooks = {}
|
|
41
|
+
for (const name of Object.keys(TEMPLATE_HOOKS)) {
|
|
42
|
+
existingHooks[name] = defaultHookEntry()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
consumerRoot: '',
|
|
47
|
+
hasGit: false,
|
|
48
|
+
hasHuskyDir: false,
|
|
49
|
+
hasPackageJson: false,
|
|
50
|
+
consumerPackageJson: null,
|
|
51
|
+
existingHooks,
|
|
52
|
+
existingPrepare: null,
|
|
53
|
+
prepareIsSimple: false,
|
|
54
|
+
isCI: false,
|
|
55
|
+
nodeVersion: 0,
|
|
56
|
+
dryRun: !!flags.dryRun,
|
|
57
|
+
verbose: !!flags.verbose,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Prepare script classifier ────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Determines whether an existing `prepare` script is "simple" enough for
|
|
65
|
+
* automatic `&& husky` appending.
|
|
66
|
+
*
|
|
67
|
+
* A script is considered **simple** when it is a single-line command or a basic
|
|
68
|
+
* `&&` chain. It is **complex** (and will not be auto-merged) when any of
|
|
69
|
+
* these apply:
|
|
70
|
+
*
|
|
71
|
+
* - Multi-line (contains `\n`)
|
|
72
|
+
* - Contains the `||` (OR) operator
|
|
73
|
+
* - Contains a semicolon `;` command separator
|
|
74
|
+
*
|
|
75
|
+
* @param {string|null} prepare - The current `prepare` script value.
|
|
76
|
+
* @returns {boolean} `true` if auto-merge is safe.
|
|
77
|
+
*/
|
|
78
|
+
function isPrepareSimple(prepare) {
|
|
79
|
+
if (typeof prepare !== 'string' || prepare.trim() === '') return false
|
|
80
|
+
|
|
81
|
+
const val = prepare.trim()
|
|
82
|
+
if (val.includes('\n')) return false
|
|
83
|
+
if (val.includes('||')) return false
|
|
84
|
+
if (val.includes(';')) return false
|
|
85
|
+
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Hook detection helpers ───────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads a single hook file from the consumer's `.husky/` directory and returns
|
|
93
|
+
* a descriptor containing the existence, raw content, and managed status.
|
|
94
|
+
*
|
|
95
|
+
* A hook is considered **managed** when its very first line (trimmed) matches
|
|
96
|
+
* the `HOOK_MARKER` constant.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} consumerRoot - Absolute path to the consumer project root.
|
|
99
|
+
* @param {string} hookName - Hook filename (e.g. `'pre-commit'`).
|
|
100
|
+
* @returns {{ exists: boolean, content: string|null, isManaged: boolean }}
|
|
101
|
+
*/
|
|
102
|
+
function detectHook(consumerRoot, hookName) {
|
|
103
|
+
try {
|
|
104
|
+
const hookPath = resolve(consumerRoot, '.husky', hookName)
|
|
105
|
+
const content = safeReadFile(hookPath)
|
|
106
|
+
const exists = content !== null
|
|
107
|
+
|
|
108
|
+
if (!exists) return { exists, content: null, isManaged: false }
|
|
109
|
+
|
|
110
|
+
const firstLine = content.split('\n')[0]
|
|
111
|
+
const isManaged =
|
|
112
|
+
firstLine != null && firstLine.trim() === HOOK_MARKER
|
|
113
|
+
|
|
114
|
+
return { exists, content, isManaged }
|
|
115
|
+
} catch {
|
|
116
|
+
return defaultHookEntry()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Main export ──────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Discovers the consumer project's state and returns a Context object.
|
|
124
|
+
*
|
|
125
|
+
* The returned Context captures the environment before any modifications are
|
|
126
|
+
* made, so the orchestrator (and downstream pipeline modules) can decide which
|
|
127
|
+
* phases to run and how to handle each edge case.
|
|
128
|
+
*
|
|
129
|
+
* All detection steps are wrapped in individual try/catch blocks so that a
|
|
130
|
+
* failure in one area (e.g., reading package.json) does not prevent the rest
|
|
131
|
+
* of the discovery from completing. The Context is **always** returned, even
|
|
132
|
+
* if every detection step fails.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} [flags={}] - Parsed CLI flags from `parseCliArgs()`.
|
|
135
|
+
* @param {boolean} [flags.dryRun] - When `true`, discovery notes dry-run mode.
|
|
136
|
+
* @returns {Promise<Context>} Resolved context object.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```js
|
|
140
|
+
* import { parseCliArgs } from './utils.js'
|
|
141
|
+
* import { discover } from './discover.js'
|
|
142
|
+
*
|
|
143
|
+
* const flags = parseCliArgs(process.argv.slice(2))
|
|
144
|
+
* const ctx = await discover(flags)
|
|
145
|
+
* console.log(ctx.hasGit) // true / false
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export async function discover(flags = {}) {
|
|
149
|
+
const ctx = createDefaultContext(flags)
|
|
150
|
+
const verboseEnabled = !!flags.verbose
|
|
151
|
+
|
|
152
|
+
// ── 1. Resolve consumer root ──────────────────────────────────────────
|
|
153
|
+
verbose(verboseEnabled, 'Resolving consumer root from INIT_CWD\u2026')
|
|
154
|
+
try {
|
|
155
|
+
ctx.consumerRoot = getConsumerRoot()
|
|
156
|
+
} catch {
|
|
157
|
+
// If consumerRoot cannot be resolved we return early — no further
|
|
158
|
+
// detection is possible without a target directory.
|
|
159
|
+
ctx.nodeVersion = getNodeVersion()
|
|
160
|
+
ctx.isCI = isCI()
|
|
161
|
+
return ctx
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!ctx.consumerRoot) {
|
|
165
|
+
ctx.nodeVersion = getNodeVersion()
|
|
166
|
+
ctx.isCI = isCI()
|
|
167
|
+
return ctx
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── 2. Detect git repository ──────────────────────────────────────────
|
|
171
|
+
verbose(verboseEnabled, 'Checking for .git directory\u2026')
|
|
172
|
+
try {
|
|
173
|
+
ctx.hasGit = dirExists(resolve(ctx.consumerRoot, '.git'))
|
|
174
|
+
verbose(verboseEnabled, ctx.hasGit ? 'Git repository found' : 'No git repository')
|
|
175
|
+
} catch {
|
|
176
|
+
// Non-fatal — hasGit stays false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── 3. Detect .husky/ directory ───────────────────────────────────────
|
|
180
|
+
verbose(verboseEnabled, 'Checking for .husky/ directory\u2026')
|
|
181
|
+
try {
|
|
182
|
+
ctx.hasHuskyDir = dirExists(resolve(ctx.consumerRoot, '.husky'))
|
|
183
|
+
verbose(verboseEnabled, ctx.hasHuskyDir ? '.husky/ directory found' : '.husky/ directory not found')
|
|
184
|
+
} catch {
|
|
185
|
+
// Non-fatal — hasHuskyDir stays false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 4. Detect + parse package.json ────────────────────────────────────
|
|
189
|
+
verbose(verboseEnabled, 'Reading package.json\u2026')
|
|
190
|
+
try {
|
|
191
|
+
const pkgPath = resolve(ctx.consumerRoot, 'package.json')
|
|
192
|
+
const pkg = safeReadJson(pkgPath)
|
|
193
|
+
ctx.hasPackageJson = pkg !== undefined
|
|
194
|
+
ctx.consumerPackageJson = pkg ?? null
|
|
195
|
+
verbose(verboseEnabled, ctx.hasPackageJson ? 'package.json found' : 'package.json not found')
|
|
196
|
+
} catch {
|
|
197
|
+
// Non-fatal — hasPackageJson stays false, consumerPackageJson stays null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── 5. Detect existing hooks ──────────────────────────────────────────
|
|
201
|
+
// existingHooks is pre-initialised by createDefaultContext() so the loop
|
|
202
|
+
// below only overrides the entries it successfully reads. Any hook that
|
|
203
|
+
// cannot be read keeps its safe default (exists: false).
|
|
204
|
+
verbose(verboseEnabled, 'Detecting existing hooks\u2026')
|
|
205
|
+
try {
|
|
206
|
+
for (const hookName of Object.keys(TEMPLATE_HOOKS)) {
|
|
207
|
+
ctx.existingHooks[hookName] = detectHook(ctx.consumerRoot, hookName)
|
|
208
|
+
const hook = ctx.existingHooks[hookName]
|
|
209
|
+
if (hook.exists) {
|
|
210
|
+
verbose(verboseEnabled, `${hookName}: ${hook.isManaged ? 'managed' : 'found (unmanaged)'}`)
|
|
211
|
+
} else {
|
|
212
|
+
verbose(verboseEnabled, `${hookName}: not found`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Non-fatal — existingHooks already contains default entries
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 6. Extract existing prepare script ────────────────────────────────
|
|
220
|
+
verbose(verboseEnabled, 'Extracting prepare script\u2026')
|
|
221
|
+
try {
|
|
222
|
+
const prepare = ctx.consumerPackageJson?.scripts?.prepare
|
|
223
|
+
ctx.existingPrepare =
|
|
224
|
+
typeof prepare === 'string' && prepare.trim().length > 0
|
|
225
|
+
? prepare.trim()
|
|
226
|
+
: null
|
|
227
|
+
verbose(verboseEnabled, ctx.existingPrepare ? `Prepare script: ${ctx.existingPrepare}` : 'No prepare script')
|
|
228
|
+
} catch {
|
|
229
|
+
// Non-fatal — existingPrepare stays null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── 7. Classify prepare script simplicity ─────────────────────────────
|
|
233
|
+
try {
|
|
234
|
+
ctx.prepareIsSimple = isPrepareSimple(ctx.existingPrepare)
|
|
235
|
+
} catch {
|
|
236
|
+
ctx.prepareIsSimple = false
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── 8. Detect CI environment ──────────────────────────────────────────
|
|
240
|
+
verbose(verboseEnabled, 'Detecting CI environment\u2026')
|
|
241
|
+
try {
|
|
242
|
+
ctx.isCI = isCI()
|
|
243
|
+
verbose(verboseEnabled, ctx.isCI ? 'CI detected' : 'Not CI')
|
|
244
|
+
} catch {
|
|
245
|
+
ctx.isCI = false
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── 9. Detect Node.js version ─────────────────────────────────────────
|
|
249
|
+
verbose(verboseEnabled, 'Detecting Node.js version\u2026')
|
|
250
|
+
try {
|
|
251
|
+
ctx.nodeVersion = getNodeVersion()
|
|
252
|
+
verbose(verboseEnabled, `Node.js v${ctx.nodeVersion}`)
|
|
253
|
+
} catch {
|
|
254
|
+
ctx.nodeVersion = 0
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// NOTE: `ctx.dryRun` is already set by createDefaultContext(flags)
|
|
258
|
+
|
|
259
|
+
verbose(verboseEnabled, 'Discovery complete')
|
|
260
|
+
return ctx
|
|
261
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// discover.test.js — Unit tests for the discover module
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
|
|
10
|
+
// ── Mock utils: environment-dependent functions; keep fs helpers real ─────
|
|
11
|
+
|
|
12
|
+
/** Mutable variable so the mock factory can return different roots per test */
|
|
13
|
+
let mockConsumerRoot = ''
|
|
14
|
+
|
|
15
|
+
vi.mock('./utils.js', async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal()
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
getConsumerRoot: () => mockConsumerRoot,
|
|
20
|
+
getNodeVersion: vi.fn(() => 20),
|
|
21
|
+
isCI: vi.fn(() => false),
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
import { discover } from './discover.js'
|
|
26
|
+
import { HOOK_MARKER, TEMPLATE_HOOKS } from './constants.js'
|
|
27
|
+
import { getNodeVersion, isCI } from './utils.js'
|
|
28
|
+
|
|
29
|
+
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const tempDirs = new Set()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a temporary project directory with a configurable structure.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {boolean} [opts.git=true] – Create a `.git/` directory
|
|
38
|
+
* @param {boolean} [opts.huskyDir=false] – Create a `.husky/` directory
|
|
39
|
+
* @param {object|boolean} [opts.packageJson=true] – `false` = skip, object = custom
|
|
40
|
+
* @param {object} [opts.hooks={}] – Map of hook filename → content
|
|
41
|
+
* @returns {string} Path to the created temp directory.
|
|
42
|
+
*/
|
|
43
|
+
function createProject(opts = {}) {
|
|
44
|
+
const {
|
|
45
|
+
git = true,
|
|
46
|
+
huskyDir = false,
|
|
47
|
+
packageJson = true,
|
|
48
|
+
hooks = {},
|
|
49
|
+
} = opts
|
|
50
|
+
|
|
51
|
+
const dir = mkdtempSync(join(tmpdir(), 'discover-test-'))
|
|
52
|
+
tempDirs.add(dir)
|
|
53
|
+
|
|
54
|
+
if (git) mkdirSync(join(dir, '.git'), { recursive: true })
|
|
55
|
+
if (huskyDir) mkdirSync(join(dir, '.husky'), { recursive: true })
|
|
56
|
+
if (packageJson) {
|
|
57
|
+
const pkg =
|
|
58
|
+
typeof packageJson === 'object' ? packageJson : { name: 'test-pkg' }
|
|
59
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg))
|
|
60
|
+
}
|
|
61
|
+
for (const [name, content] of Object.entries(hooks)) {
|
|
62
|
+
const hookDir = join(dir, '.husky')
|
|
63
|
+
if (!existsSync(hookDir)) mkdirSync(hookDir, { recursive: true })
|
|
64
|
+
writeFileSync(join(hookDir, name), content)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return dir
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cleanupTempDirs() {
|
|
71
|
+
for (const dir of tempDirs) {
|
|
72
|
+
try {
|
|
73
|
+
rmSync(dir, { recursive: true, force: true })
|
|
74
|
+
} catch {
|
|
75
|
+
/* best-effort cleanup */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
tempDirs.clear()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('discover()', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.mocked(getNodeVersion).mockReturnValue(20)
|
|
86
|
+
vi.mocked(isCI).mockReturnValue(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
cleanupTempDirs()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ── 1. Fresh project (no .husky/) ──────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
it('should detect a fresh project with git but no .husky/ directory', async () => {
|
|
96
|
+
const dir = createProject({ git: true, huskyDir: false })
|
|
97
|
+
mockConsumerRoot = dir
|
|
98
|
+
|
|
99
|
+
const ctx = await discover()
|
|
100
|
+
|
|
101
|
+
expect(ctx.consumerRoot).toBe(dir)
|
|
102
|
+
expect(ctx.hasGit).toBe(true)
|
|
103
|
+
expect(ctx.hasHuskyDir).toBe(false)
|
|
104
|
+
for (const name of Object.keys(TEMPLATE_HOOKS)) {
|
|
105
|
+
expect(ctx.existingHooks[name].exists).toBe(false)
|
|
106
|
+
}
|
|
107
|
+
expect(ctx.hasPackageJson).toBe(true)
|
|
108
|
+
expect(ctx.isCI).toBe(false)
|
|
109
|
+
expect(ctx.nodeVersion).toBe(20)
|
|
110
|
+
expect(ctx.dryRun).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ── 2. Project with .husky/ but no hooks ───────────────────────────────
|
|
114
|
+
|
|
115
|
+
it('should detect .husky/ directory but no hook files inside', async () => {
|
|
116
|
+
const dir = createProject({ huskyDir: true, hooks: {} })
|
|
117
|
+
mockConsumerRoot = dir
|
|
118
|
+
|
|
119
|
+
const ctx = await discover()
|
|
120
|
+
|
|
121
|
+
expect(ctx.hasHuskyDir).toBe(true)
|
|
122
|
+
for (const name of Object.keys(TEMPLATE_HOOKS)) {
|
|
123
|
+
expect(ctx.existingHooks[name].exists).toBe(false)
|
|
124
|
+
expect(ctx.existingHooks[name].content).toBeNull()
|
|
125
|
+
expect(ctx.existingHooks[name].isManaged).toBe(false)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ── 3. Existing unmanaged hooks ────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
it('should detect unmanaged hooks (first line is NOT the HOOK_MARKER)', async () => {
|
|
132
|
+
const dir = createProject({
|
|
133
|
+
huskyDir: true,
|
|
134
|
+
hooks: { 'pre-commit': '#!/bin/sh\necho "custom hook"\n' },
|
|
135
|
+
})
|
|
136
|
+
mockConsumerRoot = dir
|
|
137
|
+
|
|
138
|
+
const ctx = await discover()
|
|
139
|
+
|
|
140
|
+
const h = ctx.existingHooks['pre-commit']
|
|
141
|
+
expect(h.exists).toBe(true)
|
|
142
|
+
expect(h.content).toBe('#!/bin/sh\necho "custom hook"\n')
|
|
143
|
+
expect(h.isManaged).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ── 4. Existing managed hooks ──────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
it('should detect managed hooks (first line matches HOOK_MARKER)', async () => {
|
|
149
|
+
const dir = createProject({
|
|
150
|
+
huskyDir: true,
|
|
151
|
+
hooks: { 'pre-commit': `${HOOK_MARKER}\n\necho "managed hook"\n` },
|
|
152
|
+
})
|
|
153
|
+
mockConsumerRoot = dir
|
|
154
|
+
|
|
155
|
+
const ctx = await discover()
|
|
156
|
+
|
|
157
|
+
const h = ctx.existingHooks['pre-commit']
|
|
158
|
+
expect(h.exists).toBe(true)
|
|
159
|
+
expect(h.isManaged).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ── 5. No package.json ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
it('should handle a project with no package.json', async () => {
|
|
165
|
+
const dir = createProject({
|
|
166
|
+
git: true,
|
|
167
|
+
huskyDir: false,
|
|
168
|
+
packageJson: false,
|
|
169
|
+
})
|
|
170
|
+
mockConsumerRoot = dir
|
|
171
|
+
|
|
172
|
+
const ctx = await discover()
|
|
173
|
+
|
|
174
|
+
expect(ctx.hasPackageJson).toBe(false)
|
|
175
|
+
expect(ctx.consumerPackageJson).toBeNull()
|
|
176
|
+
expect(ctx.existingPrepare).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// ── 6. Package.json with no prepare script ─────────────────────────────
|
|
180
|
+
|
|
181
|
+
it('should set existingPrepare=null when prepare script is missing', async () => {
|
|
182
|
+
const dir = createProject({
|
|
183
|
+
packageJson: { name: 'test', scripts: {} },
|
|
184
|
+
})
|
|
185
|
+
mockConsumerRoot = dir
|
|
186
|
+
|
|
187
|
+
const ctx = await discover()
|
|
188
|
+
|
|
189
|
+
expect(ctx.existingPrepare).toBeNull()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// ── 7. Simple prepare script ───────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
it('should detect a simple prepare script and classify as simple', async () => {
|
|
195
|
+
const dir = createProject({
|
|
196
|
+
packageJson: { scripts: { prepare: 'npm run build' } },
|
|
197
|
+
})
|
|
198
|
+
mockConsumerRoot = dir
|
|
199
|
+
|
|
200
|
+
const ctx = await discover()
|
|
201
|
+
|
|
202
|
+
expect(ctx.existingPrepare).toBe('npm run build')
|
|
203
|
+
expect(ctx.prepareIsSimple).toBe(true)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// ── 8. Complex prepare (multi-line) ────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
it('should classify a multi-line prepare script as complex', async () => {
|
|
209
|
+
const dir = createProject({
|
|
210
|
+
packageJson: { scripts: { prepare: 'echo "line1"\necho "line2"' } },
|
|
211
|
+
})
|
|
212
|
+
mockConsumerRoot = dir
|
|
213
|
+
|
|
214
|
+
const ctx = await discover()
|
|
215
|
+
|
|
216
|
+
expect(ctx.prepareIsSimple).toBe(false)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ── 9. Prepare already containing "husky" ──────────────────────────────
|
|
220
|
+
|
|
221
|
+
it('should detect prepare script set to exactly "husky"', async () => {
|
|
222
|
+
const dir = createProject({
|
|
223
|
+
packageJson: { scripts: { prepare: 'husky' } },
|
|
224
|
+
})
|
|
225
|
+
mockConsumerRoot = dir
|
|
226
|
+
|
|
227
|
+
const ctx = await discover()
|
|
228
|
+
|
|
229
|
+
expect(ctx.existingPrepare).toBe('husky')
|
|
230
|
+
expect(ctx.prepareIsSimple).toBe(true)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ── 10. CI environment ─────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
it('should detect CI environment when isCI() returns true', async () => {
|
|
236
|
+
vi.mocked(isCI).mockReturnValue(true)
|
|
237
|
+
const dir = createProject()
|
|
238
|
+
mockConsumerRoot = dir
|
|
239
|
+
|
|
240
|
+
const ctx = await discover()
|
|
241
|
+
|
|
242
|
+
expect(ctx.isCI).toBe(true)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ── 11. Non-CI environment ─────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
it('should detect non-CI environment when isCI() returns false', async () => {
|
|
248
|
+
vi.mocked(isCI).mockReturnValue(false)
|
|
249
|
+
const dir = createProject()
|
|
250
|
+
mockConsumerRoot = dir
|
|
251
|
+
|
|
252
|
+
const ctx = await discover()
|
|
253
|
+
|
|
254
|
+
expect(ctx.isCI).toBe(false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ── 12. Node version detection ─────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
it('should report the correct Node.js major version', async () => {
|
|
260
|
+
vi.mocked(getNodeVersion).mockReturnValue(18)
|
|
261
|
+
const dir = createProject()
|
|
262
|
+
mockConsumerRoot = dir
|
|
263
|
+
|
|
264
|
+
const ctx = await discover()
|
|
265
|
+
|
|
266
|
+
expect(ctx.nodeVersion).toBe(18)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// ── 13. dryRun flag passthrough ────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
it('should set dryRun=true when the dryRun flag is passed', async () => {
|
|
272
|
+
const dir = createProject()
|
|
273
|
+
mockConsumerRoot = dir
|
|
274
|
+
|
|
275
|
+
const ctx = await discover({ dryRun: true })
|
|
276
|
+
|
|
277
|
+
expect(ctx.dryRun).toBe(true)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('should set dryRun=false by default', async () => {
|
|
281
|
+
const dir = createProject()
|
|
282
|
+
mockConsumerRoot = dir
|
|
283
|
+
|
|
284
|
+
const ctx = await discover()
|
|
285
|
+
|
|
286
|
+
expect(ctx.dryRun).toBe(false)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// ── 14. Complex prepare with `||` ──────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
it('should classify a prepare script containing || as complex', async () => {
|
|
292
|
+
const dir = createProject({
|
|
293
|
+
packageJson: { scripts: { prepare: 'cmd1 || cmd2' } },
|
|
294
|
+
})
|
|
295
|
+
mockConsumerRoot = dir
|
|
296
|
+
|
|
297
|
+
const ctx = await discover()
|
|
298
|
+
|
|
299
|
+
expect(ctx.prepareIsSimple).toBe(false)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// ── 15. Complex prepare with `;` ───────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
it('should classify a prepare script containing ; as complex', async () => {
|
|
305
|
+
const dir = createProject({
|
|
306
|
+
packageJson: { scripts: { prepare: 'cmd1; cmd2' } },
|
|
307
|
+
})
|
|
308
|
+
mockConsumerRoot = dir
|
|
309
|
+
|
|
310
|
+
const ctx = await discover()
|
|
311
|
+
|
|
312
|
+
expect(ctx.prepareIsSimple).toBe(false)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// ── 16. No git repository ──────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
it('should set hasGit=false when no .git/ directory exists', async () => {
|
|
318
|
+
const dir = createProject({ git: false })
|
|
319
|
+
mockConsumerRoot = dir
|
|
320
|
+
|
|
321
|
+
const ctx = await discover()
|
|
322
|
+
|
|
323
|
+
expect(ctx.hasGit).toBe(false)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// ── 17. Both hooks detected ────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
it('should detect both pre-commit and post-merge hooks', async () => {
|
|
329
|
+
const dir = createProject({
|
|
330
|
+
huskyDir: true,
|
|
331
|
+
hooks: {
|
|
332
|
+
'pre-commit': `${HOOK_MARKER}\n\necho "pre"\n`,
|
|
333
|
+
'post-merge': `${HOOK_MARKER}\n#!/bin/sh\necho "post"\n`,
|
|
334
|
+
},
|
|
335
|
+
})
|
|
336
|
+
mockConsumerRoot = dir
|
|
337
|
+
|
|
338
|
+
const ctx = await discover()
|
|
339
|
+
|
|
340
|
+
expect(ctx.existingHooks['pre-commit'].exists).toBe(true)
|
|
341
|
+
expect(ctx.existingHooks['post-merge'].exists).toBe(true)
|
|
342
|
+
expect(ctx.existingHooks['post-merge'].isManaged).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// ── 18. Empty prepare string ───────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
it('should treat an empty string prepare script as null', async () => {
|
|
348
|
+
const dir = createProject({
|
|
349
|
+
packageJson: { scripts: { prepare: '' } },
|
|
350
|
+
})
|
|
351
|
+
mockConsumerRoot = dir
|
|
352
|
+
|
|
353
|
+
const ctx = await discover()
|
|
354
|
+
|
|
355
|
+
expect(ctx.existingPrepare).toBeNull()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// ── 19. Consumer package JSON content ──────────────────────────────────
|
|
359
|
+
|
|
360
|
+
it('should correctly parse consumer package.json content', async () => {
|
|
361
|
+
const pkg = { name: 'test-pkg', version: '1.0.0', scripts: { test: 'vitest' } }
|
|
362
|
+
const dir = createProject({ packageJson: pkg })
|
|
363
|
+
mockConsumerRoot = dir
|
|
364
|
+
|
|
365
|
+
const ctx = await discover()
|
|
366
|
+
|
|
367
|
+
expect(ctx.hasPackageJson).toBe(true)
|
|
368
|
+
expect(ctx.consumerPackageJson).toEqual(pkg)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// ── 20. Post-merge hook content ────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
it('should correctly detect post-merge hook content and managed flag', async () => {
|
|
374
|
+
const content = `${HOOK_MARKER}\n#!/bin/sh\necho "custom post-merge"\n`
|
|
375
|
+
const dir = createProject({
|
|
376
|
+
huskyDir: true,
|
|
377
|
+
hooks: { 'post-merge': content },
|
|
378
|
+
})
|
|
379
|
+
mockConsumerRoot = dir
|
|
380
|
+
|
|
381
|
+
const ctx = await discover()
|
|
382
|
+
|
|
383
|
+
const h = ctx.existingHooks['post-merge']
|
|
384
|
+
expect(h.exists).toBe(true)
|
|
385
|
+
expect(h.content).toBe(content)
|
|
386
|
+
expect(h.isManaged).toBe(true)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// ── 21. verbose flag passthrough ─────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
it('should set verbose=true when verbose flag is passed', async () => {
|
|
392
|
+
const dir = createProject()
|
|
393
|
+
mockConsumerRoot = dir
|
|
394
|
+
|
|
395
|
+
const ctx = await discover({ verbose: true })
|
|
396
|
+
|
|
397
|
+
expect(ctx.verbose).toBe(true)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('should set verbose=false when verbose flag is absent', async () => {
|
|
401
|
+
const dir = createProject()
|
|
402
|
+
mockConsumerRoot = dir
|
|
403
|
+
|
|
404
|
+
const ctx = await discover()
|
|
405
|
+
|
|
406
|
+
expect(ctx.verbose).toBe(false)
|
|
407
|
+
})
|
|
408
|
+
})
|