@abarcenas/ai-workflow-template 1.45.0 → 1.47.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.
Files changed (49) hide show
  1. package/.agents/config/README.md +43 -0
  2. package/.agents/config/domain.md +37 -0
  3. package/.agents/instructions/learned-knowledge.instructions.md +128 -0
  4. package/.agents/instructions/memory-bank.instructions.md +11 -0
  5. package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
  6. package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
  7. package/.agents/skills/domain-modeling/SKILL.md +74 -0
  8. package/.agents/skills/grill-me/SKILL.md +7 -0
  9. package/.agents/skills/grill-with-docs/SKILL.md +7 -0
  10. package/.agents/skills/grilling/SKILL.md +28 -0
  11. package/.agents/skills/prototype/LOGIC.md +67 -0
  12. package/.agents/skills/prototype/SKILL.md +26 -0
  13. package/.agents/skills/prototype/UI.md +112 -0
  14. package/.github/workflows/npm-publish.yml +10 -0
  15. package/.github/workflows/pr-test.yml +31 -0
  16. package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
  17. package/.opencode/agents/scope.agent.md +299 -0
  18. package/.prettierignore +26 -0
  19. package/AGENTS.md +46 -3
  20. package/README.md +29 -2
  21. package/opencode.mcp.example.json +1 -0
  22. package/package.json +3 -1
  23. package/scripts/bump-version.test.js +238 -0
  24. package/scripts/mcp/playwright-mcp-launcher.js +55 -29
  25. package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
  26. package/scripts/mcp-memory-server.js +93 -76
  27. package/scripts/mcp-memory-server.test.js +398 -0
  28. package/scripts/memory-cli.test.js +297 -0
  29. package/scripts/memory-index.js +119 -31
  30. package/scripts/memory-index.test.js +1173 -0
  31. package/scripts/normalize-memory.js +26 -1
  32. package/scripts/normalize-memory.test.js +753 -0
  33. package/scripts/setup/discover.test.js +124 -2
  34. package/scripts/setup/hooks.test.js +188 -1
  35. package/scripts/setup/husky-init.test.js +311 -0
  36. package/scripts/setup/index.js +35 -5
  37. package/scripts/setup/index.test.js +279 -0
  38. package/scripts/setup/knowledgebase.test.js +100 -0
  39. package/scripts/setup/skills-phase.js +568 -0
  40. package/scripts/setup/skills-phase.test.js +1274 -0
  41. package/scripts/setup/sync-phase.js +6 -1
  42. package/scripts/setup/sync-phase.test.js +180 -2
  43. package/scripts/setup/ui.test.js +579 -0
  44. package/scripts/setup/utils.test.js +586 -0
  45. package/scripts/sync-unit.test.js +1881 -0
  46. package/scripts/sync.js +816 -239
  47. package/scripts/sync.test.js +283 -0
  48. package/scripts/vocab-sync.test.js +88 -0
  49. package/skills-lock.json +90 -0
@@ -0,0 +1,568 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // skills-phase.js — Setup phase: install pinned skills from upstream
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // Reads `skills-lock.json`, downloads every file recorded for each *pinned*
6
+ // skill (an entry with a `ref` and a non-empty `files[]`), verifies each
7
+ // buffer's SHA-256 against the lock, and writes the verified bytes into
8
+ // `<consumerRoot>/.agents/skills/<name>/`.
9
+ //
10
+ // The phase converts every upstream failure into a fallback and never throws
11
+ // for network/timeout/non-200/hash errors. On such a failure it copies the
12
+ // pinned skills shipped inside this package (`<packageRoot>/.agents/skills/`)
13
+ // instead and prints exactly one notice (REQ-13, D-08). A destination file
14
+ // whose content differs from the lock is overwritten after a warning naming
15
+ // that file, because the pin is authoritative (REQ-14, D-09). Unexpected
16
+ // filesystem write errors are the caller's to handle (index.js warns).
17
+ //
18
+ // Both paths are stage-then-commit: upstream files are buffered and verified
19
+ // before anything is written, and shipped copies are read in full before the
20
+ // first file lands on disk. A skill therefore never ends up half-written.
21
+ //
22
+ // Skipping/gating:
23
+ // - `flags.skipSync` → `{ action: 'skipped' }` (index.js also gates the call)
24
+ // - no pinned entries → no-op, zero network calls
25
+ //
26
+ // The `fetch` implementation is injectable so tests never touch the network.
27
+ // ═════════════════════════════════════════════════════════════════════════════
28
+
29
+ import { createHash } from 'crypto'
30
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
31
+ import { dirname, join, resolve, sep } from 'path'
32
+ import { fileURLToPath } from 'url'
33
+
34
+ import { stepSuccess, stepWarn } from './ui.js'
35
+
36
+ // ── Constants ────────────────────────────────────────────────────────────────
37
+
38
+ /** Name of the lock file at the package root. */
39
+ const LOCK_FILE = 'skills-lock.json'
40
+
41
+ /** Base URL for raw files on GitHub. */
42
+ const UPSTREAM_BASE = 'https://raw.githubusercontent.com'
43
+
44
+ /** Default network timeout for a single file download. */
45
+ const DEFAULT_TIMEOUT_MS = 5000
46
+
47
+ /** Path segments (from a project root) that contain installed skills. */
48
+ const SKILLS_DIR = ['.agents', 'skills']
49
+
50
+ // ── Main export ──────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Refreshes pinned skills from upstream, falling back to the shipped copies.
54
+ *
55
+ * Every upstream failure mode (network, timeout, non-200, hash mismatch) is
56
+ * converted into a fallback: the returned promise never rejects for those.
57
+ * Unexpected filesystem write errors propagate to the caller.
58
+ *
59
+ * @param {object} [context={}] - Setup context object.
60
+ * @param {string} [context.consumerRoot] - Absolute path to the consumer project.
61
+ * @param {boolean} [context.dryRun=false] - When `true`, perform no writes.
62
+ * @param {object} [context.flags={}] - Parsed CLI flags. `skipSync` short-circuits.
63
+ * @param {object} [options={}] - Injectable dependencies (used by tests).
64
+ * @param {typeof fetch} [options.fetchImpl] - Fetch implementation.
65
+ * @param {string} [options.packageRoot] - Package root override.
66
+ * @param {string} [options.lockPath] - Lock file path override.
67
+ * @param {number} [options.fetchTimeoutMs] - Per-file timeout override.
68
+ * @returns {Promise<{
69
+ * action: 'completed'|'dry-run'|'skipped',
70
+ * message: string,
71
+ * pinned: number,
72
+ * fetched: string[],
73
+ * fallback: string[],
74
+ * skipped: string[],
75
+ * warnings: string[]
76
+ * }>} Structured phase result.
77
+ *
78
+ * @example
79
+ * ```js
80
+ * const result = await runSkillsPhase(context)
81
+ * // result.fetched → ['grilling', 'prototype']
82
+ * ```
83
+ */
84
+ export async function runSkillsPhase(context = {}, options = {}) {
85
+ const { consumerRoot, dryRun = false, flags = {} } = context
86
+ const packageRoot = options.packageRoot ?? resolvePackageRoot()
87
+ const lockPath = options.lockPath ?? join(packageRoot, LOCK_FILE)
88
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch
89
+ const timeoutMs = options.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS
90
+
91
+ const result = {
92
+ action: 'completed',
93
+ message: '',
94
+ pinned: 0,
95
+ fetched: [],
96
+ fallback: [],
97
+ skipped: [],
98
+ warnings: [],
99
+ }
100
+
101
+ // ── 1. Defensive skip (index.js already gates on --skip-sync) ───────────
102
+ if (flags.skipSync) {
103
+ result.action = 'skipped'
104
+ result.message = 'Skills phase skipped (--skip-sync)'
105
+ stepWarn('Skills: phase skipped (--skip-sync)')
106
+ return result
107
+ }
108
+
109
+ // ── 2. No target project ────────────────────────────────────────────────
110
+ if (!consumerRoot || !isDirectory(consumerRoot)) {
111
+ result.message = 'Skills phase skipped \u2014 consumer root not found'
112
+ return result
113
+ }
114
+
115
+ // ── 3. Read the lock and select pinned entries ──────────────────────────
116
+ const pinned = pinnedEntries(readLock(lockPath))
117
+ result.pinned = pinned.length
118
+
119
+ if (pinned.length === 0) {
120
+ result.message = '0 pinned skills, nothing to fetch'
121
+ stepSuccess('Skills: 0 pinned skills, nothing to fetch')
122
+ return result
123
+ }
124
+
125
+ // ── 4. Classify each pinned skill ───────────────────────────────────────
126
+ const toFetch = []
127
+
128
+ for (const { name, entry } of pinned) {
129
+ const files = []
130
+
131
+ for (const file of entry.files) {
132
+ const dest = destinationFor(consumerRoot, name, file, result)
133
+ if (!dest) continue
134
+ files.push({ file, dest, currentHash: hashFile(dest) })
135
+ }
136
+
137
+ if (files.length === 0) continue // every recorded path was rejected
138
+
139
+ const pending = files.filter((f) => f.currentHash !== f.file.sha256)
140
+ if (pending.length === 0) {
141
+ result.skipped.push(name)
142
+ continue
143
+ }
144
+
145
+ if (dryRun) {
146
+ for (const f of pending) noteLocalEdit(f, result, true)
147
+ result.fetched.push(name)
148
+ continue
149
+ }
150
+
151
+ toFetch.push({ name, entry, files })
152
+ }
153
+
154
+ if (dryRun) {
155
+ result.action = 'dry-run'
156
+ result.message = `Would fetch ${result.fetched.length} pinned skill(s) from upstream`
157
+ if (result.skipped.length) {
158
+ result.message += `; ${result.skipped.length} already up to date`
159
+ }
160
+ stepSuccess(`Skills: ${result.message}`)
161
+ return result
162
+ }
163
+
164
+ // ── 5. Fetch, verify, write (fallback per skill) ────────────────────────
165
+ let noticePrinted = false
166
+ let upstreamDown = false
167
+
168
+ for (const { name, entry, files } of toFetch) {
169
+ if (upstreamDown) {
170
+ copyShippedSkill(name, files, packageRoot, result)
171
+ result.fallback.push(name)
172
+ continue
173
+ }
174
+
175
+ let verified
176
+ try {
177
+ verified = await fetchVerifiedFiles(files, entry, fetchImpl, timeoutMs)
178
+ } catch (err) {
179
+ if (!noticePrinted) {
180
+ stepWarn(
181
+ `Skills: upstream fetch unavailable (${describeError(err)}) \u2014 ` +
182
+ 'using the copies shipped with the package.',
183
+ )
184
+ noticePrinted = true
185
+ }
186
+ if (err && err.networkFailure) upstreamDown = true
187
+ copyShippedSkill(name, files, packageRoot, result)
188
+ result.fallback.push(name)
189
+ continue
190
+ }
191
+
192
+ for (const f of verified) {
193
+ if (f.currentHash === f.file.sha256) continue
194
+ noteLocalEdit(f, result, false)
195
+ writeFile(f.dest, f.buffer)
196
+ }
197
+ result.fetched.push(name)
198
+ }
199
+
200
+ // ── 6. Report ───────────────────────────────────────────────────────────
201
+ const parts = []
202
+ if (result.fetched.length) {
203
+ parts.push(`Fetched ${result.fetched.length} pinned skill(s) from upstream`)
204
+ }
205
+ if (result.fallback.length) {
206
+ parts.push(
207
+ `${result.fallback.length} restored from the copies shipped with the package`,
208
+ )
209
+ }
210
+ if (result.skipped.length) {
211
+ parts.push(`${result.skipped.length} already up to date`)
212
+ }
213
+ result.message = parts.join('; ')
214
+ stepSuccess(`Skills: ${result.message}`)
215
+
216
+ return result
217
+ }
218
+
219
+ // ── Lock helpers ─────────────────────────────────────────────────────────────
220
+
221
+ /**
222
+ * Resolves the package root from this module's own location.
223
+ *
224
+ * @returns {string} Absolute path to `<packageRoot>`.
225
+ */
226
+ function resolvePackageRoot() {
227
+ const thisDir = dirname(fileURLToPath(import.meta.url)) // .../scripts/setup
228
+ return resolve(thisDir, '..', '..')
229
+ }
230
+
231
+ /**
232
+ * Reads and parses a JSON lock file.
233
+ *
234
+ * @param {string} lockPath - Absolute path to `skills-lock.json`.
235
+ * @returns {object|null} Parsed lock, or `null` when unreadable/invalid.
236
+ */
237
+ function readLock(lockPath) {
238
+ try {
239
+ return JSON.parse(readFileSync(lockPath, 'utf-8'))
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Extracts the pinned skill entries from a parsed lock file.
247
+ *
248
+ * Only usable file records survive: a malformed lock must degrade to a no-op,
249
+ * never to a thrown `TypeError` while building URLs or destination paths.
250
+ *
251
+ * @param {object|null} lock - Parsed lock object.
252
+ * @returns {Array<{ name: string, entry: object }>} Pinned entries.
253
+ */
254
+ function pinnedEntries(lock) {
255
+ const skills =
256
+ lock && lock.skills && typeof lock.skills === 'object' ? lock.skills : {}
257
+
258
+ return Object.entries(skills)
259
+ .filter(([, entry]) => isPinned(entry))
260
+ .map(([name, entry]) => ({
261
+ name,
262
+ entry: {
263
+ ...entry,
264
+ ref: entry.ref.trim(),
265
+ source:
266
+ typeof entry.source === 'string' && entry.source.trim().length > 0
267
+ ? entry.source.trim()
268
+ : 'mattpocock/skills',
269
+ files: entry.files.filter(isUsableFile),
270
+ },
271
+ }))
272
+ }
273
+
274
+ /**
275
+ * Returns `true` when an entry is pinned to a ref with at least one usable file.
276
+ *
277
+ * Entries without `ref`/`files` (the caveman skills) are deliberately ignored.
278
+ *
279
+ * @param {object|undefined} entry - Lock entry.
280
+ * @returns {boolean} Whether the entry has a usable pin.
281
+ */
282
+ function isPinned(entry) {
283
+ return Boolean(
284
+ entry &&
285
+ typeof entry.ref === 'string' &&
286
+ entry.ref.trim().length > 0 &&
287
+ Array.isArray(entry.files) &&
288
+ entry.files.some(isUsableFile),
289
+ )
290
+ }
291
+
292
+ /**
293
+ * Returns `true` when a recorded file has the fields the phase needs.
294
+ *
295
+ * @param {object|undefined} file - Recorded file entry.
296
+ * @returns {boolean} Whether the record can be fetched and written safely.
297
+ */
298
+ function isUsableFile(file) {
299
+ return Boolean(
300
+ file &&
301
+ typeof file.path === 'string' &&
302
+ file.path.trim().length > 0 &&
303
+ typeof file.sha256 === 'string' &&
304
+ file.sha256.trim().length > 0 &&
305
+ typeof file.upstreamPath === 'string' &&
306
+ file.upstreamPath.trim().length > 0,
307
+ )
308
+ }
309
+
310
+ // ── Fetch helpers ────────────────────────────────────────────────────────────
311
+
312
+ /**
313
+ * Builds the pinned raw URL for one recorded file.
314
+ *
315
+ * @param {object} entry - Pinned lock entry.
316
+ * @param {{ upstreamPath: string }} file - Recorded file.
317
+ * @returns {string} Raw GitHub URL.
318
+ */
319
+ function upstreamUrl(entry, file) {
320
+ const source = entry.source || 'mattpocock/skills'
321
+ return `${UPSTREAM_BASE}/${source}/${entry.ref}/${file.upstreamPath}`
322
+ }
323
+
324
+ /**
325
+ * Downloads and hash-verifies every file of one skill.
326
+ *
327
+ * Buffering all files before returning guarantees a skill is never written
328
+ * half-verified. Throws an error tagged with `networkFailure` for transport
329
+ * failures so the caller can short-circuit the remaining skills.
330
+ *
331
+ * @param {Array<{ file: object, dest: string, currentHash: string|null }>} files
332
+ * @param {object} entry - Pinned lock entry.
333
+ * @param {typeof fetch} fetchImpl - Fetch implementation.
334
+ * @param {number} timeoutMs - Per-file timeout.
335
+ * @returns {Promise<Array<{ file: object, dest: string, currentHash: string|null, buffer: Buffer }>>}
336
+ */
337
+ async function fetchVerifiedFiles(files, entry, fetchImpl, timeoutMs) {
338
+ const verified = []
339
+
340
+ for (const f of files) {
341
+ let response
342
+ try {
343
+ response = await fetchImpl(upstreamUrl(entry, f.file), buildRequestInit(timeoutMs))
344
+ } catch (err) {
345
+ throw markNetworkFailure(err)
346
+ }
347
+
348
+ if (!response || !response.ok) {
349
+ const status = response && response.status != null ? response.status : 'unknown'
350
+ throw markNetworkFailure(new Error(`HTTP ${status}`))
351
+ }
352
+
353
+ const buffer = Buffer.from(await response.arrayBuffer())
354
+ const actual = sha256(buffer)
355
+
356
+ if (actual !== f.file.sha256) {
357
+ const err = new Error(
358
+ `hash mismatch for ${f.file.path} (expected ${f.file.sha256}, got ${actual})`,
359
+ )
360
+ err.networkFailure = false
361
+ throw err
362
+ }
363
+
364
+ verified.push({ ...f, buffer })
365
+ }
366
+
367
+ return verified
368
+ }
369
+
370
+ /**
371
+ * Builds fetch options, adding an abort timeout when the runtime supports it.
372
+ *
373
+ * @param {number} timeoutMs - Timeout in milliseconds.
374
+ * @returns {object} `RequestInit`-compatible options.
375
+ */
376
+ function buildRequestInit(timeoutMs) {
377
+ if (
378
+ typeof AbortSignal === 'undefined' ||
379
+ typeof AbortSignal.timeout !== 'function'
380
+ ) {
381
+ return {}
382
+ }
383
+ return { signal: AbortSignal.timeout(timeoutMs) }
384
+ }
385
+
386
+ /**
387
+ * Tags an error as a transport (network) failure.
388
+ *
389
+ * @param {*} err - Thrown value.
390
+ * @returns {*} The same value, with `networkFailure = true` when possible.
391
+ */
392
+ function markNetworkFailure(err) {
393
+ if (err && typeof err === 'object') err.networkFailure = true
394
+ return err
395
+ }
396
+
397
+ /**
398
+ * Produces a short human-readable reason for a failure.
399
+ *
400
+ * @param {*} err - Thrown value.
401
+ * @returns {string} Reason text.
402
+ */
403
+ function describeError(err) {
404
+ if (!err) return 'unknown error'
405
+ if (err.name === 'TimeoutError') return 'timed out'
406
+ return err.message || String(err)
407
+ }
408
+
409
+ // ── Fallback / write helpers ─────────────────────────────────────────────────
410
+
411
+ /**
412
+ * Copies one skill from the package's shipped copies into the consumer.
413
+ *
414
+ * Every recorded file is read and validated into memory before the first write,
415
+ * so a missing/unreadable shipped file cannot leave a half-populated skill
416
+ * directory. Missing or hash-mismatched shipped files produce warnings but
417
+ * never throw. Files whose current content differs from the pin are reported
418
+ * with the same local-edit warning used by the upstream path (REQ-14).
419
+ *
420
+ * @param {string} name - Skill name.
421
+ * @param {Array<{ file: object, dest: string, currentHash: string|null }>} files
422
+ * Recorded files with their resolved destinations.
423
+ * @param {string} packageRoot - Package root.
424
+ * @param {string} consumerRoot - Consumer project root.
425
+ * @param {object} result - Mutable phase result.
426
+ * @returns {number} Number of files copied.
427
+ */
428
+ function copyShippedSkill(name, files, packageRoot, result) {
429
+ const sourceDir = join(packageRoot, ...SKILLS_DIR, name)
430
+ const staged = []
431
+
432
+ for (const { file, dest, currentHash } of files) {
433
+ const source = join(sourceDir, file.path)
434
+ let content
435
+ try {
436
+ content = readFileSync(source)
437
+ } catch (err) {
438
+ const reason =
439
+ err && err.code === 'ENOENT'
440
+ ? 'Shipped copy missing'
441
+ : 'Shipped copy unreadable'
442
+ const msg = `${reason} for ${name}/${file.path}`
443
+ result.warnings.push(msg)
444
+ stepWarn(`Skills: ${msg}`)
445
+ continue
446
+ }
447
+
448
+ if (sha256(content) !== file.sha256) {
449
+ const msg = `Shipped copy does not match the lock hash: ${name}/${file.path}`
450
+ result.warnings.push(msg)
451
+ stepWarn(`Skills: ${msg}`)
452
+ }
453
+
454
+ staged.push({ file, dest, currentHash, content })
455
+ }
456
+
457
+ // Stage-then-commit: only write when the whole skill is available.
458
+ if (staged.length !== files.length) {
459
+ return 0
460
+ }
461
+
462
+ try {
463
+ for (const { file, dest, currentHash, content } of staged) {
464
+ noteLocalEdit({ file, dest, currentHash }, result, false)
465
+ writeFile(dest, content)
466
+ }
467
+ } catch (err) {
468
+ const msg = `Could not write the shipped copy for ${name}: ${describeError(err)}`
469
+ result.warnings.push(msg)
470
+ stepWarn(`Skills: ${msg}`)
471
+ return 0
472
+ }
473
+
474
+ return staged.length
475
+ }
476
+
477
+ /**
478
+ * Resolves a destination path and rejects paths that escape the skill dir.
479
+ *
480
+ * @param {string} consumerRoot - Consumer project root.
481
+ * @param {string} name - Skill name.
482
+ * @param {{ path: string }} file - Recorded file.
483
+ * @param {object} result - Mutable phase result.
484
+ * @returns {string|null} Absolute destination, or `null` when rejected.
485
+ */
486
+ function destinationFor(consumerRoot, name, file, result) {
487
+ const skillDir = join(consumerRoot, ...SKILLS_DIR, name)
488
+ const dest = resolve(skillDir, file.path)
489
+
490
+ if (dest !== skillDir && !dest.startsWith(skillDir + sep)) {
491
+ const msg = `Refusing to write outside ${skillDir}: ${file.path}`
492
+ result.warnings.push(msg)
493
+ stepWarn(`Skills: ${msg}`)
494
+ return null
495
+ }
496
+
497
+ return dest
498
+ }
499
+
500
+ /**
501
+ * Records (and logs) a warning for a file that has local edits.
502
+ *
503
+ * @param {{ file: object, dest: string, currentHash: string|null }} state
504
+ * @param {object} result - Mutable phase result.
505
+ * @param {boolean} dryRun - Whether the run is a dry run.
506
+ */
507
+ function noteLocalEdit(state, result, dryRun) {
508
+ if (state.currentHash === null || state.currentHash === state.file.sha256) {
509
+ return
510
+ }
511
+
512
+ const msg = dryRun
513
+ ? `Local edits in ${state.dest} would be overwritten by the pinned version`
514
+ : `Local edits in ${state.dest} \u2014 overwriting with the pinned version`
515
+ result.warnings.push(msg)
516
+ stepWarn(`Skills: ${msg}`)
517
+ }
518
+
519
+ // ── Filesystem helpers ───────────────────────────────────────────────────────
520
+
521
+ /**
522
+ * Writes a buffer, creating parent directories.
523
+ *
524
+ * @param {string} dest - Absolute destination path.
525
+ * @param {Buffer|string} content - Content to write.
526
+ */
527
+ function writeFile(dest, content) {
528
+ mkdirSync(dirname(dest), { recursive: true })
529
+ writeFileSync(dest, content)
530
+ }
531
+
532
+ /**
533
+ * Computes the SHA-256 hex digest of a buffer.
534
+ *
535
+ * @param {Buffer} buffer - Content to hash.
536
+ * @returns {string} Lowercase hex digest.
537
+ */
538
+ function sha256(buffer) {
539
+ return createHash('sha256').update(buffer).digest('hex')
540
+ }
541
+
542
+ /**
543
+ * Hashes a file on disk.
544
+ *
545
+ * @param {string} filePath - Absolute file path.
546
+ * @returns {string|null} Hex digest, or `null` when the file is absent.
547
+ */
548
+ function hashFile(filePath) {
549
+ try {
550
+ return sha256(readFileSync(filePath))
551
+ } catch {
552
+ return null
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Checks whether a path exists and is a directory.
558
+ *
559
+ * @param {string} dirPath - Path to check.
560
+ * @returns {boolean} `true` for an existing directory.
561
+ */
562
+ function isDirectory(dirPath) {
563
+ try {
564
+ return statSync(dirPath).isDirectory()
565
+ } catch {
566
+ return false
567
+ }
568
+ }