@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.
- package/.agents/instructions/learned-knowledge.instructions.md +50 -0
- package/package.json +1 -1
- package/scripts/setup/discover.js +25 -0
- package/scripts/setup/discover.test.js +20 -0
- package/scripts/setup/hooks.js +19 -0
- package/scripts/setup/husky-init.js +28 -3
- package/scripts/setup/index.test.js +26 -1
- package/scripts/setup/sync-phase.js +17 -10
- package/scripts/setup/sync-phase.test.js +195 -0
- package/scripts/setup/ui.js +18 -0
- package/scripts/sync.js +64 -0
- package/scripts/sync.test.js +177 -1
|
@@ -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.
|
|
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
|
})
|
package/scripts/setup/hooks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
if (!verbose) {
|
|
152
|
+
child.stdout.on('data', (data) => {
|
|
153
|
+
output += data.toString()
|
|
154
|
+
})
|
|
149
155
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
})
|
package/scripts/setup/ui.js
CHANGED
|
@@ -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
|
package/scripts/sync.test.js
CHANGED
|
@@ -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
|
+
})
|