@abarcenas/ai-workflow-template 1.32.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.
@@ -43,3 +43,26 @@
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
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.33.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
@@ -179,6 +184,7 @@ for (const rootFile of rootFiles) {
179
184
  const recordedHash = manifest.files[trackedKey] ?? null
180
185
 
181
186
  if (!existsSync(targetFile)) {
187
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
182
188
  syncFile(sourceFile, targetFile)
183
189
  manifest.files[trackedKey] = sourceHash
184
190
  copied += 1
@@ -189,6 +195,7 @@ for (const rootFile of rootFiles) {
189
195
  const isUntouched = recordedHash !== null && existingHash === recordedHash
190
196
 
191
197
  if (forceOverwrite || isUntouched) {
198
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
192
199
  syncFile(sourceFile, targetFile)
193
200
  manifest.files[trackedKey] = sourceHash
194
201
  copied += 1
@@ -265,6 +272,7 @@ for (const [fileName, fileContent] of Object.entries(memoryBankStubs)) {
265
272
  }
266
273
 
267
274
  ensureParentDirectory(targetFile)
275
+ if (isVerbose) console.error(` … scaffolding: memory-bank/${fileName}`)
268
276
  writeFileSync(targetFile, fileContent, 'utf8')
269
277
  manifest.files[trackedKey] = hashFile(targetFile)
270
278
  scaffolded += 1
@@ -277,6 +285,7 @@ const targetMcpFile = resolve(consumerRoot, 'opencode.mcp.json')
277
285
  const mcpTrackedKey = '__root__/opencode.mcp.json'
278
286
 
279
287
  if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
288
+ if (isVerbose) console.error(' … scaffolding: opencode.mcp.json from example')
280
289
  copyFileSync(exampleMcpFile, targetMcpFile)
281
290
  manifest.files[mcpTrackedKey] = hashFile(targetMcpFile)
282
291
  scaffolded += 1