@abarcenas/ai-workflow-template 1.32.0 → 1.34.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.
@@ -43,3 +43,53 @@
43
43
  - **coder (parallel batches)**: Batch ordering discipline worked — foundation first, then core, then orchestrator. The truly independent batches (D: markers, F: tests) could run alongside later batches. Key lesson: define interfaces (Context shape, return types) before splitting work across parallel coders
44
44
  - **unit-tester**: Using real temp directories (`mkdtempSync`) over mocking filesystem operations catches path resolution and permission issues that mocks would miss. Mock only environment-dependent utilities (CI detection, Node version), not filesystem
45
45
  - **tracker**: Should capture ecosystem discoveries alongside implementation details — the npm v12 finding and marker idempotency pattern are valuable knowledge for future pipeline sessions
46
+
47
+ ## Session: 2026-07-24 — Verbose Logging for ai-workflow-setup
48
+
49
+ **Pipeline:** implementer (bootstrap) → researcher → implementer (planning) → coder (4 parallel batches) → tracker
50
+ **Coverage:** 96/96 tests passing across 5 test files (discover 23, hooks 16, prepare 27, index 25, sync-phase 5)
51
+ **TDD Iterations:** 0 (feature pipeline, not TDD)
52
+
53
+ **New knowledge:**
54
+ - **Dead code infrastructure is a valuable find** — the `--verbose` flag already existed in `constants.js` and was correctly parsed by `parseCliArgs()`, but was never checked anywhere. Always check for existing-but-unused infrastructure before building new mechanisms
55
+ - **`spawnScript()` in `sync-phase.js` was the root cause of silent hangs** — child process stdout/stderr was captured into a string but that string was never displayed or logged. The fix: use `stdio: 'inherit'` when verbose, `'pipe'` for normal mode
56
+ - **Child process communication requires env vars** — parent and child processes share no context object. `sync.js` reads `AI_WORKFLOW_VERBOSE` from `process.env` set by `sync-phase.js` before spawning
57
+ - **`console.error` (stderr) is correct for verbose output in child processes** — stdout is captured by the parent for result parsing, but stderr streams to terminal in `stdio: 'inherit'` mode. Diagnostic messages go to stderr
58
+ - **`index.js` needed ZERO changes** — the Context `verbose` field flows from `discover(flags)` through all phase modules automatically via the existing orchestration pattern. This validates the Context enrichment architecture
59
+ - **Parallel batching with 4 batches and 10 tasks worked cleanly**: Batch A (T1–T2 foundation) → Batch B (T3–T6 core fixes) → Batch C (T7 single) → Batch D (T8–T10 tests). 0 collisions, clean interface boundaries. The explicit dependency graph (Batch A → B → C, B → D) prevented race conditions
60
+ - **`normalize-memory.js` already has good internal logging** — when verbose mode streams child stdout in real-time, this existing logging becomes visible to the user for free, with no code changes needed
61
+ - **Dim ANSI style (` … prefix`) for verbose output** — visually distinguishes verbose progress from normal output. Uses existing color infrastructure with TTY guard
62
+
63
+ **Agent tuning notes:**
64
+ - **researcher**: Should examine existing flag/configuration infrastructure for dead code — the `--verbose` flag was fully defined and parsed but never checked. This was the single most impactful finding because it revealed both the root cause (spawnScript capture) and the solution path (activate existing flag)
65
+ - **implementer (planning)**: The "index.js needs zero changes" insight should be explicitly validated before including in plans — it's easy to miss that context flows automatically. Read the orchestrator code to verify before committing to "no changes" claims
66
+ - **coder**: When fixing child process output capture, consider both stdout and stderr channels independently — they serve different purposes (results vs diagnostics). `stdio: 'inherit'` is clean for real-time streaming but means stdout is no longer capturable programmatically
67
+ - **unit-tester**: When testing child process spawn behavior, mock `child_process` spawn module rather than using real subprocesses — this avoids side effects and keeps tests fast. Use `vi.mock` with factory functions to control `stdio` arg and `env` values
68
+ - **tracker**: Record dead-code discoveries alongside implementation details — the "existing but unused infrastructure" finding is a recurring pattern worth tracking across sessions
69
+
70
+ ## Session: 2026-07-24 — Fix Hook Script References for Consumer Projects
71
+
72
+ **Pipeline:** researcher → implementer → coder (5 parallel tasks T1–T5) → tracker
73
+ **Coverage:** 7/7 unit tests passing (2 existing + 5 new), 52/52 integration assertions, 13/13 MCP references verified
74
+ **TDD Iterations:** 0 (feature pipeline, not TDD)
75
+
76
+ **New knowledge:**
77
+ - **Hash-based manifest namespace pattern** — `__scripts__/<relPath>` extends the proven `.agents/` (bare path) and `__opencode__/` namespace pattern. The 3-case sync logic (new → copy, untouched → overwrite, locally modified → skip with warning) applies identically across all sync sections. No manifest version schema change needed for adding new namespace keys.
78
+ - **Co-location constraint for imported dependencies** — `memory-cli.js` imports `'./memory-index.js'` and `mcp-memory-server.js` also imports `'./memory-index.js'`. Both the consumer-facing script AND its co-located dependency must be in the same sync array. This is an architectural constraint: if you sync one, you must sync all of its relative-import dependencies.
79
+ - **`ensureParentDirectory()` handles nested paths automatically** — `mcp/playwright-mcp-launcher.js` lives in a subdirectory, and the existing `ensureParentDirectory()` helper (which calls `mkdirSync(dirname(filePath), { recursive: true })`) creates it without any special handling.
80
+ - **`spawnSync` for testing modules with `process.exit()`** — `sync.js` calls `process.exit()` and cannot be imported as a module. The proven pattern: use `child_process.spawnSync` to invoke it as a child process, with `INIT_CWD` pointing to an isolated temp directory (created via `mkdtempSync`), and clean up in `afterAll`. This is the same pattern used by `sync-phase.js` in production.
81
+ - **Two Playwright MCP patterns coexist by design** — The template's own root `opencode.json` uses `npx @playwright/mcp@latest` (modern, no file dependency), while `opencode.mcp.example.json` uses the launcher script wrapper with env var configuration (`HEADLESS`, `SLOW_MO`, `VIEWPORT`). Both serve different audiences: the template itself vs. consumer projects that may need launcher configuration.
82
+ - **Marker comment for managed-file detection** — The `HOOK_MARKER` (`# Managed by @abarcenas/ai-workflow-template setup`) is the first line of hook files. This enables `discover.js` `isManaged` detection by reading the first line. The marker must be on line 1 for detection to work — the shebang (`#!/bin/sh`) moves to line 2, which is safe because husky sources hook files rather than executing them directly.
83
+ - **Relative paths in hooks become valid after script sync — no hook content changes needed** — The key architectural insight: `constants.js` `TEMPLATE_HOOKS` content uses `node scripts/memory-cli.js update` which resolves correctly from the consumer root once `scripts/` exists there. Zero modifications to `constants.js`, `hooks.js`, `.husky/post-merge`, or `.husky/pre-commit` are needed.
84
+ - **Content integrity via SHA-256 manifest tracking** — Every synced file is hashed with SHA-256. The manifest tracks `__scripts__/<relPath>` → hash. This enables idempotent re-runs (hash match → no warning), local modification detection (on-disk hash ≠ manifest hash → skip with warning), and force overwrite (`--force` ignores local modifications).
85
+ - **Cross-file MCP reference auditing is essential** — 13 reference points across 7 files needed verification (`opencode.mcp.example.json`, `opencode.mcp.json`, `opencode.json` root, `scripts/mcp-memory-server.js`, `scripts/sync.js`, `scripts/sync.test.js`, `.agents-sync-manifest.json`, `docs/playwright-mcp-configuration.md`). Every reference from MCP configs, sync arrays, test assertions, manifest entries, and documentation must be cross-checked to ensure path resolution after sync changes — this is easy to miss.
86
+ - **`--force` flag consistency across all sync sections** — The `--force` flag semantics are identical for `.agents/`, `.opencode/`, `__scripts__/`, and root files: it bypasses local modification protection and overwrites. This consistency should be preserved for any future sync sections.
87
+
88
+ **Agent tuning notes:**
89
+ - **researcher**: Must conduct a 6-tier script inventory with dependency chain analysis when determining sync scope. The co-location constraint (`memory-cli.js` imports `'./memory-index.js'`) was discovered by tracing `import` chains — always follow relative imports to find all files that must be co-located. Also check MCP config files (`opencode.mcp.json`, `opencode.json`) for script path references that create additional sync requirements.
90
+ - **implementer (planning)**: When parallel batches share a dependency (Phase 2 and 3 both depend on Phase 1), verify that batch-independent tasks truly have no shared-file dependencies. T2 (test code), T3 (integration verification), T4 (audit), and T5 (documentation) all depended on T1 but were fully independent of each other — this enabled parallel execution without conflicts. The plan's explicit dependency table with "Can run in parallel?" and "Depends on" columns is essential for coder batch scheduling.
91
+ - **coder (T1 — sync.js core logic)**: When adding a new sync section that mirrors an existing one (`.opencode/` block), copy-paste with careful variable renaming is correct and efficient. The 3-case hash logic is proven and consistent. Only 3 things need to change: (1) the tracked key namespace (`__scripts__/` instead of `__opencode__/`), (2) source/target directory paths, (3) the log prefix in verbose messages. The accumulator pattern (`scriptsCopied`/`scriptsSkipped` feeding into global `copied`/`skipped`) should also be mirrored.
92
+ - **coder (T2 — unit tests)**: Test infrastructure for `process.exit()` modules is reusable across all sync test files — create a `createTempDir()` helper with `mkdtempSync`, a `runSync(tempDir, args)` helper using `spawnSync`, and cleanup in `afterAll`. Assertion patterns to reuse: file existence (`expect(existsSync(path)).toBe(true)`), content match (`readFileSync(path) === sourceContent`), mtime invariance for skip tests, manifest key presence, and SHA-256 hash format validation (`/^[a-f0-9]{64}$/`).
93
+ - **coder (T3 — integration verification)**: Integration verification in a simulated consumer project requires a comprehensive test harness. The pattern: create temp consumer dir with minimal `package.json`, run sync with `INIT_CWD`, then assert on filesystem state and script execution. Content integrity via SHA-256 comparison is more reliable than byte-by-byte comparison. Test groups should cover: first-run sync, idempotent re-run, locally modified preservation, `--force` overwrite, `--dry-run`, hook path resolution, post-merge hook path, syntax validation for all synced scripts, and script execution from consumer root.
94
+ - **coder (T4 — MCP audit)**: MCP reference auditing requires structured cross-referencing. Create a table with columns: File, Lines, Reference text, and Status. Track every reference point across ALL files, not just obvious ones (documentation references are the ones most often missed). Note architectural nuances like the two Playwright patterns (npx vs launcher script) — these are design decisions worth documenting, not bugs.
95
+ - **tracker**: The feature-specific documentation (`docs/hook-script-references/tracker.md`) should include a "Total Results" summary section at the end with unit tests, integration assertions, MCP references, and files modified. This gives readers an executive-summary view without reading the full multi-step pipeline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abarcenas/ai-workflow-template",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "A comprehensive template repository for AI-assisted development workflows using GitHub Copilot. This template provides a structured approach to leveraging AI assistance through pre-configured skills, prompts, agents, and coding standards.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -14,6 +14,8 @@ import {
14
14
  safeReadJson,
15
15
  } from './utils.js'
16
16
 
17
+ import { verbose } from './ui.js'
18
+
17
19
  // ── Defaults ─────────────────────────────────────────────────────────────────
18
20
 
19
21
  /**
@@ -52,6 +54,7 @@ function createDefaultContext(flags = {}) {
52
54
  isCI: false,
53
55
  nodeVersion: 0,
54
56
  dryRun: !!flags.dryRun,
57
+ verbose: !!flags.verbose,
55
58
  }
56
59
  }
57
60
 
@@ -144,8 +147,10 @@ function detectHook(consumerRoot, hookName) {
144
147
  */
145
148
  export async function discover(flags = {}) {
146
149
  const ctx = createDefaultContext(flags)
150
+ const verboseEnabled = !!flags.verbose
147
151
 
148
152
  // ── 1. Resolve consumer root ──────────────────────────────────────────
153
+ verbose(verboseEnabled, 'Resolving consumer root from INIT_CWD\u2026')
149
154
  try {
150
155
  ctx.consumerRoot = getConsumerRoot()
151
156
  } catch {
@@ -163,25 +168,31 @@ export async function discover(flags = {}) {
163
168
  }
164
169
 
165
170
  // ── 2. Detect git repository ──────────────────────────────────────────
171
+ verbose(verboseEnabled, 'Checking for .git directory\u2026')
166
172
  try {
167
173
  ctx.hasGit = dirExists(resolve(ctx.consumerRoot, '.git'))
174
+ verbose(verboseEnabled, ctx.hasGit ? 'Git repository found' : 'No git repository')
168
175
  } catch {
169
176
  // Non-fatal — hasGit stays false
170
177
  }
171
178
 
172
179
  // ── 3. Detect .husky/ directory ───────────────────────────────────────
180
+ verbose(verboseEnabled, 'Checking for .husky/ directory\u2026')
173
181
  try {
174
182
  ctx.hasHuskyDir = dirExists(resolve(ctx.consumerRoot, '.husky'))
183
+ verbose(verboseEnabled, ctx.hasHuskyDir ? '.husky/ directory found' : '.husky/ directory not found')
175
184
  } catch {
176
185
  // Non-fatal — hasHuskyDir stays false
177
186
  }
178
187
 
179
188
  // ── 4. Detect + parse package.json ────────────────────────────────────
189
+ verbose(verboseEnabled, 'Reading package.json\u2026')
180
190
  try {
181
191
  const pkgPath = resolve(ctx.consumerRoot, 'package.json')
182
192
  const pkg = safeReadJson(pkgPath)
183
193
  ctx.hasPackageJson = pkg !== undefined
184
194
  ctx.consumerPackageJson = pkg ?? null
195
+ verbose(verboseEnabled, ctx.hasPackageJson ? 'package.json found' : 'package.json not found')
185
196
  } catch {
186
197
  // Non-fatal — hasPackageJson stays false, consumerPackageJson stays null
187
198
  }
@@ -190,21 +201,30 @@ export async function discover(flags = {}) {
190
201
  // existingHooks is pre-initialised by createDefaultContext() so the loop
191
202
  // below only overrides the entries it successfully reads. Any hook that
192
203
  // cannot be read keeps its safe default (exists: false).
204
+ verbose(verboseEnabled, 'Detecting existing hooks\u2026')
193
205
  try {
194
206
  for (const hookName of Object.keys(TEMPLATE_HOOKS)) {
195
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
+ }
196
214
  }
197
215
  } catch {
198
216
  // Non-fatal — existingHooks already contains default entries
199
217
  }
200
218
 
201
219
  // ── 6. Extract existing prepare script ────────────────────────────────
220
+ verbose(verboseEnabled, 'Extracting prepare script\u2026')
202
221
  try {
203
222
  const prepare = ctx.consumerPackageJson?.scripts?.prepare
204
223
  ctx.existingPrepare =
205
224
  typeof prepare === 'string' && prepare.trim().length > 0
206
225
  ? prepare.trim()
207
226
  : null
227
+ verbose(verboseEnabled, ctx.existingPrepare ? `Prepare script: ${ctx.existingPrepare}` : 'No prepare script')
208
228
  } catch {
209
229
  // Non-fatal — existingPrepare stays null
210
230
  }
@@ -217,20 +237,25 @@ export async function discover(flags = {}) {
217
237
  }
218
238
 
219
239
  // ── 8. Detect CI environment ──────────────────────────────────────────
240
+ verbose(verboseEnabled, 'Detecting CI environment\u2026')
220
241
  try {
221
242
  ctx.isCI = isCI()
243
+ verbose(verboseEnabled, ctx.isCI ? 'CI detected' : 'Not CI')
222
244
  } catch {
223
245
  ctx.isCI = false
224
246
  }
225
247
 
226
248
  // ── 9. Detect Node.js version ─────────────────────────────────────────
249
+ verbose(verboseEnabled, 'Detecting Node.js version\u2026')
227
250
  try {
228
251
  ctx.nodeVersion = getNodeVersion()
252
+ verbose(verboseEnabled, `Node.js v${ctx.nodeVersion}`)
229
253
  } catch {
230
254
  ctx.nodeVersion = 0
231
255
  }
232
256
 
233
257
  // NOTE: `ctx.dryRun` is already set by createDefaultContext(flags)
234
258
 
259
+ verbose(verboseEnabled, 'Discovery complete')
235
260
  return ctx
236
261
  }
@@ -385,4 +385,24 @@ describe('discover()', () => {
385
385
  expect(h.content).toBe(content)
386
386
  expect(h.isManaged).toBe(true)
387
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
+ })
388
408
  })
@@ -29,6 +29,7 @@ import {
29
29
  stepError,
30
30
  stepSuccess,
31
31
  stepWarn,
32
+ verbose,
32
33
  } from './ui.js'
33
34
 
34
35
  // ── Content builder ──────────────────────────────────────────────────────────
@@ -191,9 +192,13 @@ export async function installHooks(context) {
191
192
  try {
192
193
  // ── Case A: No .husky/ directory — create it and write hook ──
193
194
  if (!hasHuskyDir) {
195
+ verbose(context.verbose, `Creating .husky/ directory\u2026`)
194
196
  ensureDir(resolve(context.consumerRoot, '.husky'))
197
+ verbose(context.verbose, `Writing ${hookName} hook\u2026`)
195
198
  safeWriteFile(target, content)
199
+ verbose(context.verbose, 'Setting executable permissions\u2026')
196
200
  chmodX(target)
201
+ verbose(context.verbose, ' \u2713 done')
197
202
  const msg = `${hookName} hook created (\u2713 ${desc})`
198
203
  stepSuccess(msg)
199
204
  results.push({ hook: hookName, action: 'created', message: msg })
@@ -202,8 +207,11 @@ export async function installHooks(context) {
202
207
 
203
208
  // ── Case B: .husky/ exists but no hook file ─────────────────
204
209
  if (!existing) {
210
+ verbose(context.verbose, `Writing ${hookName} hook\u2026`)
205
211
  safeWriteFile(target, content)
212
+ verbose(context.verbose, 'Setting executable permissions\u2026')
206
213
  chmodX(target)
214
+ verbose(context.verbose, ' \u2713 done')
207
215
  const msg = `${hookName} hook created (\u2713 ${desc})`
208
216
  stepSuccess(msg)
209
217
  results.push({ hook: hookName, action: 'created', message: msg })
@@ -212,8 +220,11 @@ export async function installHooks(context) {
212
220
 
213
221
  // ── Case C: Hook exists AND is managed by us — overwrite ────
214
222
  if (isManaged) {
223
+ verbose(context.verbose, `Overwriting ${hookName} (idempotent update)\u2026`)
215
224
  safeWriteFile(target, content)
225
+ verbose(context.verbose, 'Setting executable permissions\u2026')
216
226
  chmodX(target)
227
+ verbose(context.verbose, ' \u2713 done')
217
228
  const msg = `${hookName} hook overwritten (idempotent update, ${desc})`
218
229
  stepSuccess(msg)
219
230
  results.push({ hook: hookName, action: 'overwritten', message: msg })
@@ -222,11 +233,15 @@ export async function installHooks(context) {
222
233
 
223
234
  // ── Case D: Hook exists, not ours, --force — back up + overwrite
224
235
  if (force) {
236
+ verbose(context.verbose, `Backing up existing ${hookName}\u2026`)
225
237
  const bakPath = target + '.bak'
226
238
  const originalContent = safeReadFile(target)
227
239
  safeWriteFile(bakPath, originalContent ?? '')
240
+ verbose(context.verbose, `Writing ${hookName} hook (force)\u2026`)
228
241
  safeWriteFile(target, content)
242
+ verbose(context.verbose, 'Setting executable permissions\u2026')
229
243
  chmodX(target)
244
+ verbose(context.verbose, ' \u2713 done')
230
245
  const msg =
231
246
  `${hookName} hook overwritten (original backed up to .bak, ${desc})`
232
247
  stepSuccess(msg)
@@ -235,11 +250,15 @@ export async function installHooks(context) {
235
250
  }
236
251
 
237
252
  // ── Case E: Hook exists, not ours, no --force — append ──────
253
+ verbose(context.verbose, `Reading existing ${hookName} for merge\u2026`)
238
254
  const existingContent = safeReadFile(target)
239
255
  const mergedContent =
240
256
  (existingContent ?? '') + '\n' + HOOK_MERGE_SEPARATOR + '\n' + content
257
+ verbose(context.verbose, `Writing merged ${hookName}\u2026`)
241
258
  safeWriteFile(target, mergedContent)
259
+ verbose(context.verbose, 'Setting executable permissions\u2026')
242
260
  chmodX(target)
261
+ verbose(context.verbose, ' \u2713 done')
243
262
  const msg = `${hookName} hook merged with existing content (${desc})`
244
263
  stepSuccess(msg)
245
264
  results.push({ hook: hookName, action: 'merged', message: msg })
@@ -12,10 +12,13 @@
12
12
  // installed husky version is used.
13
13
  // ═════════════════════════════════════════════════════════════════════════════
14
14
 
15
+ import { execSync } from 'child_process'
15
16
  import { existsSync } from 'fs'
16
17
  import { join } from 'path'
17
18
  import { createRequire } from 'module'
18
19
 
20
+ import { info, warn, verbose } from './ui.js'
21
+
19
22
  // ── Main export ──────────────────────────────────────────────────────────────
20
23
 
21
24
  /**
@@ -34,14 +37,15 @@ import { createRequire } from 'module'
34
37
  * @param {boolean} context.hasGit - Whether the project has a `.git/` directory.
35
38
  * @param {boolean} context.dryRun - When `true` only report, do nothing.
36
39
  * @param {boolean} context.isCI - When `true` skip husky init entirely.
40
+ * @param {boolean} context.verbose - When `true` emit verbose progress messages.
37
41
  * @returns {Promise<{action: string, message: string}>}
38
42
  */
39
43
  export async function initHusky(context) {
40
- const { consumerRoot, hasGit, dryRun, isCI } = context
44
+ const { consumerRoot, hasGit, dryRun, isCI, verbose: verboseFlag } = context
41
45
 
42
46
  // ── Dry-run mode ─────────────────────────────────────────────────────
43
47
  if (dryRun) {
44
- console.log(
48
+ info(
45
49
  '[setup] Would initialize husky (generate .husky/_/ shims, set core.hooksPath)',
46
50
  )
47
51
  return { action: 'dry-run', message: 'Would initialize husky' }
@@ -49,7 +53,7 @@ export async function initHusky(context) {
49
53
 
50
54
  // ── CI environment ───────────────────────────────────────────────────
51
55
  if (isCI) {
52
- console.log('[setup] CI environment detected — skipping husky init')
56
+ warn('[setup] CI environment detected — skipping husky init')
53
57
  return {
54
58
  action: 'skipped',
55
59
  message: 'Skipping husky init in CI environment',
@@ -67,27 +71,48 @@ export async function initHusky(context) {
67
71
 
68
72
  // ── Resolve husky from the consumer's node_modules ──────────────────
69
73
  let husky
74
+ verbose(verboseFlag, 'Resolving husky from consumer node_modules\u2026')
70
75
  try {
71
76
  const consumerRequire = createRequire(join(consumerRoot, 'package.json'))
72
77
  const huskyPath = consumerRequire.resolve('husky')
78
+ verbose(verboseFlag, `Found husky at ${huskyPath}`)
73
79
  const mod = await import(huskyPath)
74
80
  husky = mod.default
75
81
  } catch {
76
82
  // createRequire.resolve or import() failed — husky is not installed
83
+ verbose(verboseFlag, 'husky not found \u2014 skipping init')
77
84
  return {
78
85
  action: 'skipped',
79
86
  message: 'husky is not installed. Run: npm install husky --save-dev',
80
87
  }
81
88
  }
82
89
 
90
+ // ── Clear stale hooksPath ──────────────────────────────────────────
91
+ // Husky v9's programmatic API is a no-op when core.hooksPath already
92
+ // exists (even if pointing to a wrong value). Unset it so husky()
93
+ // actually generates the .husky/_/ shim files from scratch.
94
+ verbose(verboseFlag, 'Clearing stale core.hooksPath\u2026')
95
+ try {
96
+ execSync('git config --local --unset core.hooksPath', {
97
+ cwd: consumerRoot,
98
+ stdio: 'pipe',
99
+ })
100
+ } catch {
101
+ // Silently continue if the key didn't exist or is not unset-able.
102
+ // husky() will handle hooksPath setup internally.
103
+ }
104
+
83
105
  // ── Invoke husky() ──────────────────────────────────────────────────
106
+ verbose(verboseFlag, 'Calling husky()\u2026')
84
107
  try {
85
108
  husky(consumerRoot)
86
109
  } catch (err) {
110
+ verbose(verboseFlag, `husky() failed: ${err.message}`)
87
111
  return { action: 'failed', message: err.message }
88
112
  }
89
113
 
90
114
  // ── Verify post-condition ───────────────────────────────────────────
115
+ verbose(verboseFlag, 'Verifying .husky/_/h exists\u2026')
91
116
  const hPath = join(consumerRoot, '.husky', '_', 'h')
92
117
  if (!existsSync(hPath)) {
93
118
  return {
@@ -135,6 +135,7 @@ const defaultContext = {
135
135
  isCI: false,
136
136
  nodeVersion: 20,
137
137
  dryRun: false,
138
+ verbose: false,
138
139
  }
139
140
 
140
141
  /**
@@ -557,7 +558,31 @@ describe('main() — setup orchestrator', () => {
557
558
  expect(runSyncPhase).toHaveBeenCalledWith(ctx)
558
559
  })
559
560
 
560
- // ── 19. Flags object passed to discover ─────────────────────────────────
561
+ // ── 19a. --verbose flag passthrough to Context ──────────────────────────
562
+
563
+ it('--verbose: passes verbose flag through to Context', async () => {
564
+ const flags = { ...defaultFlags, verbose: true }
565
+ parseCliArgs.mockReturnValue(flags)
566
+ const ctx = { ...defaultContext, verbose: true }
567
+ discover.mockResolvedValue(ctx)
568
+
569
+ await main(['--verbose'])
570
+
571
+ // parseCliArgs was called with the expected argv
572
+ expect(parseCliArgs).toHaveBeenCalledWith(['--verbose'])
573
+ // discover received the flags with verbose: true
574
+ expect(discover).toHaveBeenCalledWith(flags)
575
+ // Every phase receives the context with verbose: true preserved
576
+ expect(installHooks).toHaveBeenCalledWith(ctx)
577
+ expect(handlePrepare).toHaveBeenCalledWith(ctx)
578
+ expect(initHusky).toHaveBeenCalledWith(ctx)
579
+ expect(runSyncPhase).toHaveBeenCalledWith(ctx)
580
+ // Normal pipeline — header and summary shown
581
+ expect(header).toHaveBeenCalledTimes(1)
582
+ expect(summary).toHaveBeenCalledTimes(1)
583
+ })
584
+
585
+ // ── 20. Flags object passed to discover ─────────────────────────────────
561
586
 
562
587
  it('passes the flags object to discover()', async () => {
563
588
  const flags = { ...defaultFlags, dryRun: true }
@@ -7,6 +7,7 @@ import { spawn } from 'child_process'
7
7
  import { join } from 'path'
8
8
 
9
9
  import { resolvePackageRoot, logInfo, logWarn, logError } from './utils.js'
10
+ import { verbose } from './ui.js'
10
11
 
11
12
  // ── Main export ─────────────────────────────────────────────────────────────
12
13
 
@@ -72,20 +73,24 @@ export async function runSyncPhase(context) {
72
73
 
73
74
  // ── Spawn sync.js ──────────────────────────────────────────────────────
74
75
  const syncPath = join(packageRoot, 'scripts', 'sync.js')
76
+ verbose(context.verbose, 'Spawning sync.js\u2026')
75
77
  const syncResult = await spawnScript(
76
78
  'sync.js',
77
79
  syncPath,
78
80
  consumerRoot,
79
81
  extraArgs,
82
+ context.verbose,
80
83
  )
81
84
 
82
85
  // ── Spawn normalize-memory.js ──────────────────────────────────────────
83
86
  const normPath = join(packageRoot, 'scripts', 'normalize-memory.js')
87
+ verbose(context.verbose, 'Spawning normalize-memory.js\u2026')
84
88
  const normResult = await spawnScript(
85
89
  'normalize-memory.js',
86
90
  normPath,
87
91
  consumerRoot,
88
92
  extraArgs,
93
+ context.verbose,
89
94
  )
90
95
 
91
96
  const results = [syncResult, normResult]
@@ -133,30 +138,32 @@ export async function runSyncPhase(context) {
133
138
  * output: string
134
139
  * }>}
135
140
  */
136
- function spawnScript(name, scriptPath, cwd, extraArgs) {
141
+ function spawnScript(name, scriptPath, cwd, extraArgs, verbose = false) {
137
142
  return new Promise((resolve) => {
138
143
  const child = spawn(process.execPath, [scriptPath, ...extraArgs], {
139
144
  cwd,
140
- env: { ...process.env, INIT_CWD: cwd },
141
- stdio: 'pipe',
145
+ env: { ...process.env, INIT_CWD: cwd, AI_WORKFLOW_VERBOSE: verbose ? '1' : '0' },
146
+ stdio: verbose ? 'inherit' : 'pipe',
142
147
  })
143
148
 
144
149
  let output = ''
145
150
 
146
- child.stdout.on('data', (data) => {
147
- output += data.toString()
148
- })
151
+ if (!verbose) {
152
+ child.stdout.on('data', (data) => {
153
+ output += data.toString()
154
+ })
149
155
 
150
- child.stderr.on('data', (data) => {
151
- output += data.toString()
152
- })
156
+ child.stderr.on('data', (data) => {
157
+ output += data.toString()
158
+ })
159
+ }
153
160
 
154
161
  child.on('close', (exitCode) => {
155
162
  resolve({
156
163
  script: name,
157
164
  success: exitCode === 0,
158
165
  exitCode: exitCode ?? -1,
159
- output: output.trim(),
166
+ output: verbose ? '(streamed to terminal)' : output.trim(),
160
167
  })
161
168
  })
162
169
 
@@ -0,0 +1,195 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // sync-phase.test.js — Unit tests for the sync phase module
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // Tests the spawnScript() function's verbose vs non-verbose spawn behavior
6
+ // by mocking `child_process.spawn` and inspecting the options passed.
7
+ //
8
+ // Covered scenarios (5 tests per T10 requirements):
9
+ // 1. Uses stdio: 'pipe' when verbose is false (default behavior)
10
+ // 2. Uses stdio: 'inherit' when verbose is true
11
+ // 3. Sets AI_WORKFLOW_VERBOSE=1 in child env when verbose is true
12
+ // 4. Sets AI_WORKFLOW_VERBOSE=0 in child env when verbose is false
13
+ // 5. Captures stdout/stderr output correctly in non-verbose mode
14
+ // ═════════════════════════════════════════════════════════════════════════════
15
+
16
+ import { vi, describe, it, expect, beforeEach } from 'vitest'
17
+
18
+ // ── Mocks (vitest hoists these to the top of the module) ──────────────────
19
+
20
+ vi.mock('child_process', () => ({
21
+ spawn: vi.fn(),
22
+ }))
23
+
24
+ vi.mock('./ui.js', () => ({
25
+ verbose: vi.fn(),
26
+ }))
27
+
28
+ vi.mock('./utils.js', () => ({
29
+ resolvePackageRoot: vi.fn(() => '/fake/package/root'),
30
+ logInfo: vi.fn(),
31
+ logWarn: vi.fn(),
32
+ logError: vi.fn(),
33
+ }))
34
+
35
+ import { spawn } from 'child_process'
36
+ import { runSyncPhase } from './sync-phase.js'
37
+
38
+ // ── Test helpers ──────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Creates a mock child process that mimics a spawned Node.js script.
42
+ *
43
+ * The mock registers event handlers for stdout/stderr 'data' events and
44
+ * the 'close' event, then fires them asynchronously on a microtask.
45
+ * This allows the Promise-based flow in spawnScript() to resolve naturally.
46
+ *
47
+ * @param {object} opts
48
+ * @param {number} [opts.exitCode=0] - Exit code for the 'close' event
49
+ * @param {string} [opts.stdoutData=''] - Data emitted via stdout 'data'
50
+ * @param {string} [opts.stderrData=''] - Data emitted via stderr 'data'
51
+ * @returns {object} Mock child process with .stdout.on, .stderr.on, .on
52
+ */
53
+ function createMockChild({ exitCode = 0, stdoutData = '', stderrData = '' } = {}) {
54
+ const stdoutHandlers = []
55
+ const stderrHandlers = []
56
+ let closeHandler = null
57
+
58
+ const child = {
59
+ stdout: {
60
+ on: (event, handler) => {
61
+ if (event === 'data') stdoutHandlers.push(handler)
62
+ },
63
+ },
64
+ stderr: {
65
+ on: (event, handler) => {
66
+ if (event === 'data') stderrHandlers.push(handler)
67
+ },
68
+ },
69
+ on: (event, handler) => {
70
+ if (event === 'close') closeHandler = handler
71
+ },
72
+ }
73
+
74
+ // Fire events on a microtask so handlers are registered first
75
+ Promise.resolve().then(() => {
76
+ if (stdoutData) {
77
+ stdoutHandlers.forEach((h) => h(Buffer.from(stdoutData)))
78
+ }
79
+ if (stderrData) {
80
+ stderrHandlers.forEach((h) => h(Buffer.from(stderrData)))
81
+ }
82
+ if (closeHandler) closeHandler(exitCode)
83
+ })
84
+
85
+ return child
86
+ }
87
+
88
+ /**
89
+ * Builds a minimal context object accepted by runSyncPhase().
90
+ *
91
+ * @param {object} [overrides] - Properties to override on the default context.
92
+ * @returns {object} Context object.
93
+ */
94
+ function makeContext(overrides = {}) {
95
+ return {
96
+ consumerRoot: '/fake/consumer',
97
+ verbose: false,
98
+ dryRun: false,
99
+ skipSync: false,
100
+ flags: {},
101
+ ...overrides,
102
+ }
103
+ }
104
+
105
+ // ── Tests ─────────────────────────────────────────────────────────────────
106
+
107
+ describe('sync-phase verbose spawn behavior', () => {
108
+ beforeEach(() => {
109
+ vi.clearAllMocks()
110
+ })
111
+
112
+ // ── 1. Pipe mode (non-verbose, default) ───────────────────────────────
113
+
114
+ it('uses stdio: "pipe" when verbose is false', async () => {
115
+ vi.mocked(spawn).mockImplementation(() => createMockChild({ exitCode: 0 }))
116
+
117
+ await runSyncPhase(makeContext({ verbose: false }))
118
+
119
+ const calls = vi.mocked(spawn).mock.calls
120
+ expect(calls.length).toBeGreaterThanOrEqual(2)
121
+ for (const call of calls) {
122
+ expect(call[2]).toBeDefined()
123
+ expect(call[2].stdio).toBe('pipe')
124
+ }
125
+ })
126
+
127
+ // ── 2. Inherit mode (verbose) ─────────────────────────────────────────
128
+
129
+ it('uses stdio: "inherit" when verbose is true', async () => {
130
+ vi.mocked(spawn).mockImplementation(() => createMockChild({ exitCode: 0 }))
131
+
132
+ await runSyncPhase(makeContext({ verbose: true }))
133
+
134
+ const calls = vi.mocked(spawn).mock.calls
135
+ expect(calls.length).toBeGreaterThanOrEqual(2)
136
+ for (const call of calls) {
137
+ expect(call[2]).toBeDefined()
138
+ expect(call[2].stdio).toBe('inherit')
139
+ }
140
+ })
141
+
142
+ // ── 3. Verbose env var = '1' ──────────────────────────────────────────
143
+
144
+ it('sets AI_WORKFLOW_VERBOSE=1 in child env when verbose is true', async () => {
145
+ vi.mocked(spawn).mockImplementation(() => createMockChild({ exitCode: 0 }))
146
+
147
+ await runSyncPhase(makeContext({ verbose: true }))
148
+
149
+ const calls = vi.mocked(spawn).mock.calls
150
+ expect(calls.length).toBeGreaterThanOrEqual(2)
151
+ for (const call of calls) {
152
+ expect(call[2]).toBeDefined()
153
+ expect(call[2].env).toBeDefined()
154
+ expect(call[2].env.AI_WORKFLOW_VERBOSE).toBe('1')
155
+ }
156
+ })
157
+
158
+ // ── 4. Non-verbose env var = '0' ──────────────────────────────────────
159
+
160
+ it('sets AI_WORKFLOW_VERBOSE=0 in child env when verbose is false', async () => {
161
+ vi.mocked(spawn).mockImplementation(() => createMockChild({ exitCode: 0 }))
162
+
163
+ await runSyncPhase(makeContext({ verbose: false }))
164
+
165
+ const calls = vi.mocked(spawn).mock.calls
166
+ expect(calls.length).toBeGreaterThanOrEqual(2)
167
+ for (const call of calls) {
168
+ expect(call[2]).toBeDefined()
169
+ expect(call[2].env).toBeDefined()
170
+ expect(call[2].env.AI_WORKFLOW_VERBOSE).toBe('0')
171
+ }
172
+ })
173
+
174
+ // ── 5. Output capture in non-verbose mode ─────────────────────────────
175
+
176
+ it('captures stdout/stderr output in non-verbose mode', async () => {
177
+ vi.mocked(spawn).mockImplementation(() =>
178
+ createMockChild({
179
+ exitCode: 0,
180
+ stdoutData: 'copying files...\n',
181
+ stderrData: '',
182
+ }),
183
+ )
184
+
185
+ const result = await runSyncPhase(makeContext({ verbose: false }))
186
+
187
+ expect(result.action).toBe('completed')
188
+ expect(result.results).toHaveLength(2)
189
+ for (const r of result.results) {
190
+ expect(r.output).toBe('copying files...')
191
+ expect(r.success).toBe(true)
192
+ expect(r.exitCode).toBe(0)
193
+ }
194
+ })
195
+ })
@@ -139,6 +139,24 @@ function write(...lines) {
139
139
  }
140
140
  }
141
141
 
142
+ /**
143
+ * Prints a dimmed verbose/debug message to stdout when verbose mode is
144
+ * enabled.
145
+ *
146
+ * When `enabled` is falsy the function is a no-op, making it safe to call
147
+ * unconditionally in hot paths without an extra `if` guard.
148
+ *
149
+ * @param {*} enabled - Truthy to print, falsy to skip.
150
+ * @param {string} message - Debug message to display.
151
+ * @example
152
+ * verbose(true, 'Resolving consumer root from INIT_CWD\u2026')
153
+ * // \u2026 Resolving consumer root from INIT_CWD\u2026
154
+ */
155
+ export function verbose(enabled, message) {
156
+ if (!enabled) return
157
+ write(` ${DIM}\u2026 ${message}${RST}`)
158
+ }
159
+
142
160
  // ── Public output API ───────────────────────────────────────────────────────
143
161
 
144
162
  /**
package/scripts/sync.js CHANGED
@@ -13,6 +13,7 @@ const excludedRelativePaths = new Set([])
13
13
  const args = new Set(process.argv.slice(2))
14
14
  const forceOverwrite = args.has('--force')
15
15
  const dryRun = args.has('--dry-run')
16
+ const isVerbose = process.env.AI_WORKFLOW_VERBOSE === '1'
16
17
 
17
18
  function hashFile(filePath) {
18
19
  const contents = readFileSync(filePath)
@@ -104,6 +105,7 @@ for (const sourceFile of sourceFiles) {
104
105
  const recordedHash = manifest.files[trackedKey] ?? null
105
106
 
106
107
  if (!existsSync(targetFile)) {
108
+ if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
107
109
  syncFile(sourceFile, targetFile)
108
110
  manifest.files[trackedKey] = sourceHash
109
111
  copied += 1
@@ -114,6 +116,7 @@ for (const sourceFile of sourceFiles) {
114
116
  const isUntouched = recordedHash !== null && existingHash === recordedHash
115
117
 
116
118
  if (forceOverwrite || isUntouched) {
119
+ if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
117
120
  syncFile(sourceFile, targetFile)
118
121
  manifest.files[trackedKey] = sourceHash
119
122
  copied += 1
@@ -145,6 +148,7 @@ if (existsSync(sourceOpenCodeDir)) {
145
148
  const recordedHash = manifest.files[trackedKey] ?? null
146
149
 
147
150
  if (!existsSync(targetFile)) {
151
+ if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
148
152
  syncFile(sourceFile, targetFile)
149
153
  manifest.files[trackedKey] = sourceHash
150
154
  copied += 1
@@ -155,6 +159,7 @@ if (existsSync(sourceOpenCodeDir)) {
155
159
  const isUntouched = recordedHash !== null && existingHash === recordedHash
156
160
 
157
161
  if (forceOverwrite || isUntouched) {
162
+ if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
158
163
  syncFile(sourceFile, targetFile)
159
164
  manifest.files[trackedKey] = sourceHash
160
165
  copied += 1
@@ -166,6 +171,61 @@ if (existsSync(sourceOpenCodeDir)) {
166
171
  }
167
172
  }
168
173
 
174
+ /* ── Sync scripts/ directory ──────────────────────────────── */
175
+
176
+ const scriptsToSync = [
177
+ 'memory-cli.js',
178
+ 'memory-index.js',
179
+ 'bump-version.js',
180
+ 'validate-memory-schema.js',
181
+ 'mcp-memory-server.js',
182
+ 'mcp/playwright-mcp-launcher.js',
183
+ ]
184
+
185
+ const sourceScriptsDir = resolve(packageRoot, 'scripts')
186
+ const targetScriptsDir = resolve(consumerRoot, 'scripts')
187
+ let scriptsCopied = 0
188
+ let scriptsSkipped = 0
189
+
190
+ for (const scriptRelPath of scriptsToSync) {
191
+ const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
192
+ const targetFile = resolve(targetScriptsDir, scriptRelPath)
193
+
194
+ if (!existsSync(sourceFile)) {
195
+ continue
196
+ }
197
+
198
+ const sourceHash = hashFile(sourceFile)
199
+ const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
200
+ const trackedKey = `__scripts__/${scriptRelPath}`
201
+ const recordedHash = manifest.files[trackedKey] ?? null
202
+
203
+ if (!existsSync(targetFile)) {
204
+ if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
205
+ syncFile(sourceFile, targetFile)
206
+ manifest.files[trackedKey] = sourceHash
207
+ scriptsCopied += 1
208
+ copied += 1
209
+ added += 1
210
+ continue
211
+ }
212
+
213
+ const isUntouched = recordedHash !== null && existingHash === recordedHash
214
+
215
+ if (forceOverwrite || isUntouched) {
216
+ if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
217
+ syncFile(sourceFile, targetFile)
218
+ manifest.files[trackedKey] = sourceHash
219
+ scriptsCopied += 1
220
+ copied += 1
221
+ continue
222
+ }
223
+
224
+ scriptsSkipped += 1
225
+ skipped += 1
226
+ console.warn(`[ai-workflow-template] Skipped locally modified script file: scripts/${scriptRelPath}`)
227
+ }
228
+
169
229
  for (const rootFile of rootFiles) {
170
230
  const sourceFile = resolve(packageRoot, rootFile)
171
231
  if (!existsSync(sourceFile)) {
@@ -179,6 +239,7 @@ for (const rootFile of rootFiles) {
179
239
  const recordedHash = manifest.files[trackedKey] ?? null
180
240
 
181
241
  if (!existsSync(targetFile)) {
242
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
182
243
  syncFile(sourceFile, targetFile)
183
244
  manifest.files[trackedKey] = sourceHash
184
245
  copied += 1
@@ -189,6 +250,7 @@ for (const rootFile of rootFiles) {
189
250
  const isUntouched = recordedHash !== null && existingHash === recordedHash
190
251
 
191
252
  if (forceOverwrite || isUntouched) {
253
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
192
254
  syncFile(sourceFile, targetFile)
193
255
  manifest.files[trackedKey] = sourceHash
194
256
  copied += 1
@@ -265,6 +327,7 @@ for (const [fileName, fileContent] of Object.entries(memoryBankStubs)) {
265
327
  }
266
328
 
267
329
  ensureParentDirectory(targetFile)
330
+ if (isVerbose) console.error(` … scaffolding: memory-bank/${fileName}`)
268
331
  writeFileSync(targetFile, fileContent, 'utf8')
269
332
  manifest.files[trackedKey] = hashFile(targetFile)
270
333
  scaffolded += 1
@@ -277,6 +340,7 @@ const targetMcpFile = resolve(consumerRoot, 'opencode.mcp.json')
277
340
  const mcpTrackedKey = '__root__/opencode.mcp.json'
278
341
 
279
342
  if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
343
+ if (isVerbose) console.error(' … scaffolding: opencode.mcp.json from example')
280
344
  copyFileSync(exampleMcpFile, targetMcpFile)
281
345
  manifest.files[mcpTrackedKey] = hashFile(targetMcpFile)
282
346
  scaffolded += 1
@@ -1,4 +1,40 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, afterAll } from 'vitest'
2
+ import { spawnSync } from 'child_process'
3
+ import {
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ rmSync,
10
+ mkdtempSync,
11
+ statSync,
12
+ } from 'fs'
13
+ import { join, dirname } from 'path'
14
+ import { tmpdir } from 'os'
15
+
16
+ const projectRoot = process.cwd()
17
+ const tempDirs = []
18
+
19
+ function createTempDir() {
20
+ const tmpDir = mkdtempSync(join(tmpdir(), 'sync-test-'))
21
+ tempDirs.push(tmpDir)
22
+ return tmpDir
23
+ }
24
+
25
+ function runSync(tempDir, args = []) {
26
+ return spawnSync('node', ['scripts/sync.js', ...args], {
27
+ cwd: projectRoot,
28
+ env: { ...process.env, INIT_CWD: tempDir },
29
+ encoding: 'utf8',
30
+ })
31
+ }
32
+
33
+ afterAll(() => {
34
+ for (const dir of tempDirs) {
35
+ rmSync(dir, { recursive: true, force: true })
36
+ }
37
+ })
2
38
 
3
39
  describe('sync utility functions', () => {
4
40
  it('should validate basic assertions work', () => {
@@ -10,3 +46,143 @@ describe('sync utility functions', () => {
10
46
  expect(result).toBe('ai workflow template')
11
47
  })
12
48
  })
49
+
50
+ describe('__scripts__ sync behavior', () => {
51
+ const scriptsToSync = [
52
+ 'memory-cli.js',
53
+ 'memory-index.js',
54
+ 'bump-version.js',
55
+ 'validate-memory-schema.js',
56
+ 'mcp-memory-server.js',
57
+ 'mcp/playwright-mcp-launcher.js',
58
+ ]
59
+
60
+ it('should copy scripts when they do not exist in consumer project', () => {
61
+ const tempDir = createTempDir()
62
+
63
+ // Create a manifest with __scripts__ entries (initial tracking)
64
+ const manifest = {
65
+ version: 1,
66
+ files: Object.fromEntries(
67
+ scriptsToSync.map((s) => [`__scripts__/${s}`, null]),
68
+ ),
69
+ }
70
+ writeFileSync(
71
+ join(tempDir, '.agents-sync-manifest.json'),
72
+ JSON.stringify(manifest, null, 2),
73
+ )
74
+
75
+ const result = runSync(tempDir)
76
+ expect(result.status).toBe(0)
77
+
78
+ // Verify all 6 script files were created in the consumer's scripts/ dir
79
+ for (const script of scriptsToSync) {
80
+ const targetFile = join(tempDir, 'scripts', script)
81
+ expect(
82
+ existsSync(targetFile),
83
+ `Expected scripts/${script} to exist`,
84
+ ).toBe(true)
85
+ }
86
+ })
87
+
88
+ it('should skip scripts that exist in target without manifest tracking', () => {
89
+ const tempDir = createTempDir()
90
+ const sourceScriptsDir = join(projectRoot, 'scripts')
91
+
92
+ // Pre-populate target with files matching source content (same hash)
93
+ // but create a manifest that has NO __scripts__ entries
94
+ for (const script of scriptsToSync) {
95
+ const sourceFile = join(sourceScriptsDir, script)
96
+ const targetFile = join(tempDir, 'scripts', script)
97
+ mkdirSync(dirname(targetFile), { recursive: true })
98
+ copyFileSync(sourceFile, targetFile)
99
+ }
100
+
101
+ // Empty manifest — no __scripts__ keys → isUntouched = false for all
102
+ writeFileSync(
103
+ join(tempDir, '.agents-sync-manifest.json'),
104
+ JSON.stringify({ version: 1, files: {} }),
105
+ )
106
+
107
+ // Record mtimes before sync
108
+ const mtimesBefore = {}
109
+ for (const script of scriptsToSync) {
110
+ mtimesBefore[script] = statSync(
111
+ join(tempDir, 'scripts', script),
112
+ ).mtimeMs
113
+ }
114
+
115
+ const result = runSync(tempDir)
116
+ expect(result.status).toBe(0)
117
+
118
+ // Verify mtimes are unchanged (files were NOT overwritten)
119
+ for (const script of scriptsToSync) {
120
+ expect(statSync(join(tempDir, 'scripts', script)).mtimeMs).toBe(
121
+ mtimesBefore[script],
122
+ )
123
+ }
124
+ })
125
+
126
+ it('should force overwrite all scripts with --force', () => {
127
+ const tempDir = createTempDir()
128
+ const sourceScriptsDir = join(projectRoot, 'scripts')
129
+
130
+ // Pre-populate target with dummy content (different from source)
131
+ for (const script of scriptsToSync) {
132
+ const targetFile = join(tempDir, 'scripts', script)
133
+ mkdirSync(dirname(targetFile), { recursive: true })
134
+ writeFileSync(targetFile, `// modified ${script}`)
135
+ }
136
+
137
+ const result = runSync(tempDir, ['--force'])
138
+ expect(result.status).toBe(0)
139
+
140
+ // Verify all 6 files were overwritten with source content
141
+ for (const script of scriptsToSync) {
142
+ const targetFile = join(tempDir, 'scripts', script)
143
+ const sourceFile = join(sourceScriptsDir, script)
144
+ expect(existsSync(targetFile)).toBe(true)
145
+ expect(readFileSync(targetFile, 'utf8')).toBe(
146
+ readFileSync(sourceFile, 'utf8'),
147
+ )
148
+ }
149
+ })
150
+
151
+ it('should show scripts in --dry-run output but not write files', () => {
152
+ const tempDir = createTempDir()
153
+
154
+ const result = runSync(tempDir, ['--dry-run'])
155
+ expect(result.status).toBe(0)
156
+ expect(result.stdout).toContain('dry-run')
157
+
158
+ // Verify no script files were written to disk
159
+ for (const script of scriptsToSync) {
160
+ expect(existsSync(join(tempDir, 'scripts', script))).toBe(false)
161
+ }
162
+ })
163
+
164
+ it('should track new manifest entries after sync', () => {
165
+ const tempDir = createTempDir()
166
+
167
+ // Create empty manifest
168
+ writeFileSync(
169
+ join(tempDir, '.agents-sync-manifest.json'),
170
+ JSON.stringify({ version: 1, files: {} }),
171
+ )
172
+
173
+ const result = runSync(tempDir)
174
+ expect(result.status).toBe(0)
175
+
176
+ // Read back the updated manifest
177
+ const updatedManifest = JSON.parse(
178
+ readFileSync(join(tempDir, '.agents-sync-manifest.json'), 'utf8'),
179
+ )
180
+
181
+ // Verify all 6 __scripts__ entries are tracked with valid SHA-256 hashes
182
+ for (const script of scriptsToSync) {
183
+ const key = `__scripts__/${script}`
184
+ expect(updatedManifest.files[key]).toBeDefined()
185
+ expect(updatedManifest.files[key]).toMatch(/^[a-f0-9]{64}$/)
186
+ }
187
+ })
188
+ })