@abarcenas/ai-workflow-template 1.29.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/instructions/learned-knowledge.instructions.md +46 -0
- package/.github/workflows/npm-publish.yml +3 -0
- package/.github/workflows/pr-test.yml +23 -0
- package/.husky/post-merge +1 -0
- package/.husky/pre-commit +2 -0
- package/README.md +48 -1
- package/bin/setup.js +23 -0
- package/package.json +7 -2
- package/scripts/setup/constants.js +226 -0
- package/scripts/setup/discover.js +261 -0
- package/scripts/setup/discover.test.js +408 -0
- package/scripts/setup/hooks.js +280 -0
- package/scripts/setup/hooks.test.js +527 -0
- package/scripts/setup/husky-init.js +127 -0
- package/scripts/setup/index.js +354 -0
- package/scripts/setup/index.test.js +664 -0
- package/scripts/setup/prepare.js +127 -0
- package/scripts/setup/prepare.test.js +718 -0
- package/scripts/setup/sync-phase.js +179 -0
- package/scripts/setup/sync-phase.test.js +195 -0
- package/scripts/setup/ui.js +521 -0
- package/scripts/setup/utils.js +371 -0
- package/scripts/sync.js +9 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// sync-phase.js — Phase 5: spawn sync.js and normalize-memory.js as child
|
|
3
|
+
// processes (avoids process.exit() killing the parent setup)
|
|
4
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'child_process'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
|
|
9
|
+
import { resolvePackageRoot, logInfo, logWarn, logError } from './utils.js'
|
|
10
|
+
import { verbose } from './ui.js'
|
|
11
|
+
|
|
12
|
+
// ── Main export ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs the sync phase of the setup pipeline.
|
|
16
|
+
*
|
|
17
|
+
* Spawns `scripts/sync.js` and `scripts/normalize-memory.js` as child processes
|
|
18
|
+
* rather than importing them directly. This is required because `sync.js` calls
|
|
19
|
+
* `process.exit()` internally (line 83), which would terminate the entire setup
|
|
20
|
+
* process if it were imported as a module.
|
|
21
|
+
*
|
|
22
|
+
* **Early exits:**
|
|
23
|
+
* - `skipSync` is `true` → returns `{ action: 'skipped' }` without spawning
|
|
24
|
+
* - `dryRun` is `true` → returns `{ action: 'dry-run' }` after logging intent
|
|
25
|
+
*
|
|
26
|
+
* **Normal execution:**
|
|
27
|
+
* Both scripts are spawned sequentially (sync.js first, normalize-memory.js
|
|
28
|
+
* second) with `INIT_CWD` set to `consumerRoot` so they resolve consumer
|
|
29
|
+
* paths correctly. If `flags.force` is set, the `--force` argument is
|
|
30
|
+
* forwarded to both child scripts.
|
|
31
|
+
*
|
|
32
|
+
* Non-zero exit codes from either script are treated as warnings, not
|
|
33
|
+
* failures — the overall action becomes `'partial'` but the pipeline
|
|
34
|
+
* continues (graceful degradation per REQ-04).
|
|
35
|
+
*
|
|
36
|
+
* @param {object} context - Setup context object.
|
|
37
|
+
* @param {string} context.consumerRoot - Absolute path to the consumer project root.
|
|
38
|
+
* @param {boolean} [context.dryRun=false] - If `true`, only simulate without spawning.
|
|
39
|
+
* @param {boolean} [context.skipSync=false] - If `true`, skip the entire sync phase.
|
|
40
|
+
* @param {object} [context.flags={}] - Parsed CLI flags (may include `force`).
|
|
41
|
+
* @returns {Promise<{
|
|
42
|
+
* action: 'completed'|'partial'|'skipped'|'dry-run',
|
|
43
|
+
* message: string,
|
|
44
|
+
* results: Array<{
|
|
45
|
+
* script: string,
|
|
46
|
+
* success: boolean,
|
|
47
|
+
* exitCode: number,
|
|
48
|
+
* output: string
|
|
49
|
+
* }>
|
|
50
|
+
* }>}
|
|
51
|
+
*/
|
|
52
|
+
export async function runSyncPhase(context) {
|
|
53
|
+
const { consumerRoot, dryRun = false, skipSync = false, flags = {} } = context
|
|
54
|
+
|
|
55
|
+
// ── Skip path ──────────────────────────────────────────────────────────
|
|
56
|
+
if (skipSync) {
|
|
57
|
+
const message = 'Sync phase skipped (--skip-sync)'
|
|
58
|
+
logInfo(`[setup] ${message}`)
|
|
59
|
+
return { action: 'skipped', message, results: [] }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Dry-run path ───────────────────────────────────────────────────────
|
|
63
|
+
if (dryRun) {
|
|
64
|
+
const message =
|
|
65
|
+
`Would run sync.js and normalize-memory.js ` +
|
|
66
|
+
`with INIT_CWD="${consumerRoot}"`
|
|
67
|
+
logInfo(`[setup] dry-run: ${message}`)
|
|
68
|
+
return { action: 'dry-run', message, results: [] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const packageRoot = resolvePackageRoot()
|
|
72
|
+
const extraArgs = flags.force ? ['--force'] : []
|
|
73
|
+
|
|
74
|
+
// ── Spawn sync.js ──────────────────────────────────────────────────────
|
|
75
|
+
const syncPath = join(packageRoot, 'scripts', 'sync.js')
|
|
76
|
+
verbose(context.verbose, 'Spawning sync.js\u2026')
|
|
77
|
+
const syncResult = await spawnScript(
|
|
78
|
+
'sync.js',
|
|
79
|
+
syncPath,
|
|
80
|
+
consumerRoot,
|
|
81
|
+
extraArgs,
|
|
82
|
+
context.verbose,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// ── Spawn normalize-memory.js ──────────────────────────────────────────
|
|
86
|
+
const normPath = join(packageRoot, 'scripts', 'normalize-memory.js')
|
|
87
|
+
verbose(context.verbose, 'Spawning normalize-memory.js\u2026')
|
|
88
|
+
const normResult = await spawnScript(
|
|
89
|
+
'normalize-memory.js',
|
|
90
|
+
normPath,
|
|
91
|
+
consumerRoot,
|
|
92
|
+
extraArgs,
|
|
93
|
+
context.verbose,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const results = [syncResult, normResult]
|
|
97
|
+
const allSucceeded = results.every((r) => r.success)
|
|
98
|
+
const action = allSucceeded ? 'completed' : 'partial'
|
|
99
|
+
const failureCount = results.filter((r) => !r.success).length
|
|
100
|
+
|
|
101
|
+
const message =
|
|
102
|
+
action === 'completed'
|
|
103
|
+
? 'Sync phase completed'
|
|
104
|
+
: `Sync phase completed with ${failureCount} script failure(s)`
|
|
105
|
+
|
|
106
|
+
if (!allSucceeded) {
|
|
107
|
+
logWarn(`[setup] ${message}`)
|
|
108
|
+
for (const r of results) {
|
|
109
|
+
if (!r.success) {
|
|
110
|
+
logWarn(`[setup] ${r.script} exited with code ${r.exitCode}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { action, message, results }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Internal helpers ───────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Spawns a Node.js script as a child process and waits for it to complete.
|
|
122
|
+
*
|
|
123
|
+
* Both stdout and stderr are captured into a single `output` string
|
|
124
|
+
* (interleaved in the order data events arrive). The returned promise
|
|
125
|
+
* resolves when the child process exits or fails to spawn.
|
|
126
|
+
*
|
|
127
|
+
* Uses `process.execPath` so the child always runs the same Node.js
|
|
128
|
+
* binary as the parent setup process.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} name - Display name for the script (e.g. `'sync.js'`).
|
|
131
|
+
* @param {string} scriptPath - Absolute path to the script to execute.
|
|
132
|
+
* @param {string} cwd - Working directory for the child process.
|
|
133
|
+
* @param {string[]} extraArgs - Extra CLI arguments forwarded to the script.
|
|
134
|
+
* @returns {Promise<{
|
|
135
|
+
* script: string,
|
|
136
|
+
* success: boolean,
|
|
137
|
+
* exitCode: number,
|
|
138
|
+
* output: string
|
|
139
|
+
* }>}
|
|
140
|
+
*/
|
|
141
|
+
function spawnScript(name, scriptPath, cwd, extraArgs, verbose = false) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const child = spawn(process.execPath, [scriptPath, ...extraArgs], {
|
|
144
|
+
cwd,
|
|
145
|
+
env: { ...process.env, INIT_CWD: cwd, AI_WORKFLOW_VERBOSE: verbose ? '1' : '0' },
|
|
146
|
+
stdio: verbose ? 'inherit' : 'pipe',
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
let output = ''
|
|
150
|
+
|
|
151
|
+
if (!verbose) {
|
|
152
|
+
child.stdout.on('data', (data) => {
|
|
153
|
+
output += data.toString()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
child.stderr.on('data', (data) => {
|
|
157
|
+
output += data.toString()
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
child.on('close', (exitCode) => {
|
|
162
|
+
resolve({
|
|
163
|
+
script: name,
|
|
164
|
+
success: exitCode === 0,
|
|
165
|
+
exitCode: exitCode ?? -1,
|
|
166
|
+
output: verbose ? '(streamed to terminal)' : output.trim(),
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
child.on('error', (err) => {
|
|
171
|
+
resolve({
|
|
172
|
+
script: name,
|
|
173
|
+
success: false,
|
|
174
|
+
exitCode: -1,
|
|
175
|
+
output: `Failed to spawn child process: ${err.message}`,
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
}
|
|
@@ -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
|
+
})
|