@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.
@@ -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
+ })