@abarcenas/ai-workflow-template 1.33.0 → 1.38.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/knowledgebase.instructions.md +238 -0
- package/.agents/instructions/learned-knowledge.instructions.md +98 -0
- package/.husky/post-commit +10 -0
- package/.husky/post-merge +0 -1
- package/.opencode/agents/architect.agent.md +1 -0
- package/.opencode/agents/coder.agent.md +1 -0
- package/.opencode/agents/deployer.agent.md +1 -0
- package/.opencode/agents/designer.agent.md +1 -0
- package/.opencode/agents/e2e-tester.agent.md +1 -0
- package/.opencode/agents/implementer.agent.md +1 -0
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +1 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +1 -0
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +1 -0
- package/.opencode/agents/researcher.agent.md +1 -0
- package/.opencode/agents/reviewer.agent.md +1 -0
- package/.opencode/agents/tracker.agent.md +2 -0
- package/.opencode/agents/unit-tester.agent.md +1 -0
- package/README.md +1 -1
- package/memory-bank/.vocabulary.json +8 -2
- package/package.json +12 -6
- package/scripts/knowledgebase-cli.js +228 -0
- package/scripts/knowledgebase-cli.test.js +576 -0
- package/scripts/knowledgebase-index.js +780 -0
- package/scripts/knowledgebase-index.test.js +759 -0
- package/scripts/knowledgebase-init.sql +193 -0
- package/scripts/mcp-knowledgebase-server.js +330 -0
- package/scripts/setup/constants.js +36 -3
- package/scripts/setup/hooks.js +2 -2
- package/scripts/setup/hooks.test.js +13 -5
- package/scripts/setup/index.js +29 -2
- package/scripts/setup/index.test.js +36 -1
- package/scripts/setup/knowledgebase.js +204 -0
- package/scripts/setup/knowledgebase.test.js +248 -0
- package/scripts/sync.js +95 -6
- package/scripts/sync.test.js +177 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// knowledgebase.test.js — Unit tests for the knowledgebase registration module
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Tests registerKnowledgebase() from knowledgebase.js — the Phase 6 setup
|
|
6
|
+
// module that registers the consumer project in the centralized knowledgebase.
|
|
7
|
+
//
|
|
8
|
+
// Covered scenarios (6 tests per T17 requirements):
|
|
9
|
+
// 1. skipKnowledgebase flag → early return with action='skipped'
|
|
10
|
+
// 2. No DATABASE_URL → graceful skip with warning message
|
|
11
|
+
// 3. Missing project name → graceful skip
|
|
12
|
+
// 4. Successful index → spawn exits 0, parses chunk counts
|
|
13
|
+
// 5. CLI failure → non-zero exit, action='failed'
|
|
14
|
+
// 6. Spawn error event → ENOENT, graceful handling
|
|
15
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
18
|
+
|
|
19
|
+
// ── Mocks (vitest hoists these to the top of the module) ──────────────────
|
|
20
|
+
|
|
21
|
+
vi.mock('child_process', () => ({
|
|
22
|
+
spawn: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock('./ui.js', () => ({
|
|
26
|
+
verbose: vi.fn(),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
vi.mock('./utils.js', () => ({
|
|
30
|
+
resolvePackageRoot: vi.fn(() => '/fake/package/root'),
|
|
31
|
+
logInfo: vi.fn(),
|
|
32
|
+
logWarn: vi.fn(),
|
|
33
|
+
logError: vi.fn(),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
import { spawn } from 'child_process'
|
|
37
|
+
import { registerKnowledgebase } from './knowledgebase.js'
|
|
38
|
+
|
|
39
|
+
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a mock child process that mimics a spawned Node.js script.
|
|
43
|
+
*
|
|
44
|
+
* Supports two modes controlled by `emitError`:
|
|
45
|
+
* - false (default): registers stdout/stderr 'data' handlers and a 'close'
|
|
46
|
+
* handler, then fires them asynchronously on a microtask so the calling
|
|
47
|
+
* code's event listeners are registered before the events fire.
|
|
48
|
+
* - true: registers and fires an 'error' handler instead (for ENOENT etc.)
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {number} [opts.exitCode=0] - Exit code for the 'close' event
|
|
52
|
+
* @param {string} [opts.stdoutData=''] - Data emitted via stdout 'data'
|
|
53
|
+
* @param {string} [opts.stderrData=''] - Data emitted via stderr 'data'
|
|
54
|
+
* @param {boolean} [opts.emitError=false] - If true, emit 'error' instead
|
|
55
|
+
* @returns {object} Mock child process with .stdout.on, .stderr.on, .on
|
|
56
|
+
*/
|
|
57
|
+
function createMockChild({
|
|
58
|
+
exitCode = 0,
|
|
59
|
+
stdoutData = '',
|
|
60
|
+
stderrData = '',
|
|
61
|
+
emitError = false,
|
|
62
|
+
} = {}) {
|
|
63
|
+
const stdoutHandlers = []
|
|
64
|
+
const stderrHandlers = []
|
|
65
|
+
let closeHandler = null
|
|
66
|
+
let errorHandler = null
|
|
67
|
+
|
|
68
|
+
const child = {
|
|
69
|
+
stdout: {
|
|
70
|
+
on: (event, handler) => {
|
|
71
|
+
if (event === 'data') stdoutHandlers.push(handler)
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
stderr: {
|
|
75
|
+
on: (event, handler) => {
|
|
76
|
+
if (event === 'data') stderrHandlers.push(handler)
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
on: (event, handler) => {
|
|
80
|
+
if (event === 'close') closeHandler = handler
|
|
81
|
+
if (event === 'error') errorHandler = handler
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fire events on a microtask so handlers are registered first
|
|
86
|
+
Promise.resolve().then(() => {
|
|
87
|
+
if (emitError) {
|
|
88
|
+
if (errorHandler) {
|
|
89
|
+
errorHandler(new Error('ENOENT: no such file or directory'))
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
if (stdoutData) {
|
|
93
|
+
stdoutHandlers.forEach((h) => h(Buffer.from(stdoutData)))
|
|
94
|
+
}
|
|
95
|
+
if (stderrData) {
|
|
96
|
+
stderrHandlers.forEach((h) => h(Buffer.from(stderrData)))
|
|
97
|
+
}
|
|
98
|
+
if (closeHandler) closeHandler(exitCode)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return child
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Builds a minimal context object accepted by registerKnowledgebase().
|
|
107
|
+
*
|
|
108
|
+
* Defaults provide a valid DATABASE_URL and consumerPackageJson.name so that
|
|
109
|
+
* tests can focus on one axis of variation at a time without repeating
|
|
110
|
+
* setup code.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} [overrides] - Properties to override on the default context.
|
|
113
|
+
* @returns {object} Context object.
|
|
114
|
+
*/
|
|
115
|
+
function makeContext(overrides = {}) {
|
|
116
|
+
return {
|
|
117
|
+
flags: {},
|
|
118
|
+
verbose: false,
|
|
119
|
+
consumerRoot: '/fake/consumer',
|
|
120
|
+
consumerPackageJson: { name: 'test-consumer' },
|
|
121
|
+
...overrides,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
describe('registerKnowledgebase', () => {
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
vi.clearAllMocks()
|
|
130
|
+
// Provide a valid DATABASE_URL by default so only tests that explicitly
|
|
131
|
+
// delete it exercise the missing-URL path.
|
|
132
|
+
process.env.DATABASE_URL = 'postgres://localhost:5432/knowledgebase'
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
delete process.env.DATABASE_URL
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ── 1. Skip flag ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
it('returns action="skipped" when flags.skipKnowledgebase is true', async () => {
|
|
142
|
+
const ctx = makeContext({ flags: { skipKnowledgebase: true } })
|
|
143
|
+
|
|
144
|
+
const result = await registerKnowledgebase(ctx)
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual({
|
|
147
|
+
action: 'skipped',
|
|
148
|
+
message: expect.stringContaining('skip-knowledgebase'),
|
|
149
|
+
})
|
|
150
|
+
// No child process should be spawned when the flag short-circuits
|
|
151
|
+
expect(spawn).not.toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// ── 2. Missing DATABASE_URL ───────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
it('returns action="skipped" with warning when DATABASE_URL is not set', async () => {
|
|
157
|
+
delete process.env.DATABASE_URL
|
|
158
|
+
const ctx = makeContext()
|
|
159
|
+
|
|
160
|
+
const result = await registerKnowledgebase(ctx)
|
|
161
|
+
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
action: 'skipped',
|
|
164
|
+
message: expect.stringContaining('DATABASE_URL'),
|
|
165
|
+
})
|
|
166
|
+
expect(spawn).not.toHaveBeenCalled()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ── 3. Missing project name ───────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
it('returns action="skipped" when consumerPackageJson has no name field', async () => {
|
|
172
|
+
const ctx = makeContext({ consumerPackageJson: {} })
|
|
173
|
+
|
|
174
|
+
const result = await registerKnowledgebase(ctx)
|
|
175
|
+
|
|
176
|
+
expect(result).toEqual({
|
|
177
|
+
action: 'skipped',
|
|
178
|
+
message: expect.stringContaining('project name'),
|
|
179
|
+
})
|
|
180
|
+
expect(spawn).not.toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ── 4. Successful index ───────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
it('returns action="indexed" with parsed chunk counts on successful spawn', async () => {
|
|
186
|
+
vi.mocked(spawn).mockImplementation(() =>
|
|
187
|
+
createMockChild({
|
|
188
|
+
exitCode: 0,
|
|
189
|
+
stdoutData: [
|
|
190
|
+
'[knowledgebase]',
|
|
191
|
+
'Indexed 5 new, updated 3, skipped 2 chunks',
|
|
192
|
+
'from project "test-consumer"',
|
|
193
|
+
].join(' '),
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const ctx = makeContext()
|
|
198
|
+
const result = await registerKnowledgebase(ctx)
|
|
199
|
+
|
|
200
|
+
expect(result).toEqual({
|
|
201
|
+
action: 'indexed',
|
|
202
|
+
message: expect.stringContaining('8 chunks indexed'),
|
|
203
|
+
chunks_indexed: 8, // 5 new + 3 updated
|
|
204
|
+
chunks_skipped: 2,
|
|
205
|
+
})
|
|
206
|
+
expect(spawn).toHaveBeenCalledTimes(1)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ── 5. CLI failure (non-zero exit) ────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
it('returns action="failed" when the CLI exits with a non-zero code', async () => {
|
|
212
|
+
vi.mocked(spawn).mockImplementation(() =>
|
|
213
|
+
createMockChild({
|
|
214
|
+
exitCode: 1,
|
|
215
|
+
stderrData: 'Error: connection refused',
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const ctx = makeContext()
|
|
220
|
+
const result = await registerKnowledgebase(ctx)
|
|
221
|
+
|
|
222
|
+
expect(result).toEqual({
|
|
223
|
+
action: 'failed',
|
|
224
|
+
message: expect.stringContaining('exit code 1'),
|
|
225
|
+
})
|
|
226
|
+
expect(spawn).toHaveBeenCalledTimes(1)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ── 6. Spawn error event (ENOENT) ─────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
it('gracefully handles spawn error (ENOENT) returning action="failed"', async () => {
|
|
232
|
+
vi.mocked(spawn).mockImplementation(() =>
|
|
233
|
+
createMockChild({ emitError: true }),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const ctx = makeContext()
|
|
237
|
+
|
|
238
|
+
// The function must not throw — the spawn 'error' event resolves the
|
|
239
|
+
// internal promise with success=false, and registerKnowledgebase
|
|
240
|
+
// converts that into a graceful 'failed' result.
|
|
241
|
+
const result = await registerKnowledgebase(ctx)
|
|
242
|
+
|
|
243
|
+
expect(result.action).toBe('failed')
|
|
244
|
+
expect(result.message).toContain('exit code -1')
|
|
245
|
+
expect(result.message).toContain('ENOENT')
|
|
246
|
+
expect(spawn).toHaveBeenCalledTimes(1)
|
|
247
|
+
})
|
|
248
|
+
})
|
package/scripts/sync.js
CHANGED
|
@@ -9,7 +9,9 @@ const sourceAgentsDir = resolve(packageRoot, '.agents')
|
|
|
9
9
|
const consumerRoot = process.env.INIT_CWD ? resolve(process.env.INIT_CWD) : process.cwd()
|
|
10
10
|
const targetAgentsDir = resolve(consumerRoot, '.agents')
|
|
11
11
|
const manifestPath = resolve(consumerRoot, '.agents-sync-manifest.json')
|
|
12
|
-
const excludedRelativePaths = new Set([
|
|
12
|
+
const excludedRelativePaths = new Set([
|
|
13
|
+
'instructions/learned-knowledge.instructions.md',
|
|
14
|
+
])
|
|
13
15
|
const args = new Set(process.argv.slice(2))
|
|
14
16
|
const forceOverwrite = args.has('--force')
|
|
15
17
|
const dryRun = args.has('--dry-run')
|
|
@@ -77,7 +79,7 @@ function syncFile(sourceFile, targetFile) {
|
|
|
77
79
|
copyFileSync(sourceFile, targetFile)
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
const rootFiles = ['AGENTS.md', 'opencode.
|
|
82
|
+
const rootFiles = ['AGENTS.md', 'opencode.json', 'skills-lock.json']
|
|
81
83
|
|
|
82
84
|
if (!existsSync(sourceAgentsDir)) {
|
|
83
85
|
console.warn('[ai-workflow-template] No .agents directory found to sync.')
|
|
@@ -171,6 +173,66 @@ if (existsSync(sourceOpenCodeDir)) {
|
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/* ── Sync scripts/ directory ──────────────────────────────── */
|
|
177
|
+
|
|
178
|
+
const scriptsToSync = [
|
|
179
|
+
'memory-cli.js',
|
|
180
|
+
'memory-index.js',
|
|
181
|
+
'bump-version.js',
|
|
182
|
+
'validate-memory-schema.js',
|
|
183
|
+
'mcp-memory-server.js',
|
|
184
|
+
'mcp/playwright-mcp-launcher.js',
|
|
185
|
+
// Knowledgebase scripts
|
|
186
|
+
'knowledgebase-cli.js',
|
|
187
|
+
'knowledgebase-index.js',
|
|
188
|
+
'mcp-knowledgebase-server.js',
|
|
189
|
+
'knowledgebase-init.sql',
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
const sourceScriptsDir = resolve(packageRoot, 'scripts')
|
|
193
|
+
const targetScriptsDir = resolve(consumerRoot, 'scripts')
|
|
194
|
+
let scriptsCopied = 0
|
|
195
|
+
let scriptsSkipped = 0
|
|
196
|
+
|
|
197
|
+
for (const scriptRelPath of scriptsToSync) {
|
|
198
|
+
const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
|
|
199
|
+
const targetFile = resolve(targetScriptsDir, scriptRelPath)
|
|
200
|
+
|
|
201
|
+
if (!existsSync(sourceFile)) {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sourceHash = hashFile(sourceFile)
|
|
206
|
+
const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
|
|
207
|
+
const trackedKey = `__scripts__/${scriptRelPath}`
|
|
208
|
+
const recordedHash = manifest.files[trackedKey] ?? null
|
|
209
|
+
|
|
210
|
+
if (!existsSync(targetFile)) {
|
|
211
|
+
if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
|
|
212
|
+
syncFile(sourceFile, targetFile)
|
|
213
|
+
manifest.files[trackedKey] = sourceHash
|
|
214
|
+
scriptsCopied += 1
|
|
215
|
+
copied += 1
|
|
216
|
+
added += 1
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const isUntouched = recordedHash !== null && existingHash === recordedHash
|
|
221
|
+
|
|
222
|
+
if (forceOverwrite || isUntouched) {
|
|
223
|
+
if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
|
|
224
|
+
syncFile(sourceFile, targetFile)
|
|
225
|
+
manifest.files[trackedKey] = sourceHash
|
|
226
|
+
scriptsCopied += 1
|
|
227
|
+
copied += 1
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
scriptsSkipped += 1
|
|
232
|
+
skipped += 1
|
|
233
|
+
console.warn(`[ai-workflow-template] Skipped locally modified script file: scripts/${scriptRelPath}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
174
236
|
for (const rootFile of rootFiles) {
|
|
175
237
|
const sourceFile = resolve(packageRoot, rootFile)
|
|
176
238
|
if (!existsSync(sourceFile)) {
|
|
@@ -278,14 +340,41 @@ for (const [fileName, fileContent] of Object.entries(memoryBankStubs)) {
|
|
|
278
340
|
scaffolded += 1
|
|
279
341
|
}
|
|
280
342
|
|
|
281
|
-
/* ──
|
|
343
|
+
/* ── Scaffold .agents/instructions/ directory ───────────────── */
|
|
344
|
+
|
|
345
|
+
const agentInstructionsStubs = {
|
|
346
|
+
'learned-knowledge.instructions.md': `# Learned Knowledge
|
|
347
|
+
|
|
348
|
+
Cross-session knowledge accumulated by agent pipelines. Each session records discoveries, patterns, gotchas, and agent tuning notes for future reference.
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
`,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const [fileName, fileContent] of Object.entries(agentInstructionsStubs)) {
|
|
356
|
+
const targetFile = resolve(targetAgentsDir, 'instructions', fileName)
|
|
357
|
+
const trackedKey = `__agents-instructions__/${fileName}`
|
|
358
|
+
|
|
359
|
+
if (existsSync(targetFile)) {
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
ensureParentDirectory(targetFile)
|
|
364
|
+
if (isVerbose) console.error(` … scaffolding: .agents/instructions/${fileName}`)
|
|
365
|
+
writeFileSync(targetFile, fileContent, 'utf8')
|
|
366
|
+
manifest.files[trackedKey] = hashFile(targetFile)
|
|
367
|
+
scaffolded += 1
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
|
|
282
371
|
|
|
283
372
|
const exampleMcpFile = resolve(packageRoot, 'opencode.mcp.example.json')
|
|
284
|
-
const targetMcpFile = resolve(consumerRoot, 'opencode.
|
|
285
|
-
const mcpTrackedKey = '__root__/opencode.
|
|
373
|
+
const targetMcpFile = resolve(consumerRoot, 'opencode.json')
|
|
374
|
+
const mcpTrackedKey = '__root__/opencode.json'
|
|
286
375
|
|
|
287
376
|
if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
|
|
288
|
-
if (isVerbose) console.error(' … scaffolding: opencode.
|
|
377
|
+
if (isVerbose) console.error(' … scaffolding: opencode.json from example')
|
|
289
378
|
copyFileSync(exampleMcpFile, targetMcpFile)
|
|
290
379
|
manifest.files[mcpTrackedKey] = hashFile(targetMcpFile)
|
|
291
380
|
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
|
+
})
|