ma-agents 3.0.0 → 3.1.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/.opencode/skills/.ma-agents.json +99 -99
- package/bin/cli.js +546 -2
- package/lib/bmad-extension/skills/bmad-ma-agent-cyber/SKILL.md +5 -0
- package/lib/bmad-extension/skills/bmad-ma-agent-devops/SKILL.md +5 -0
- package/lib/bmad-extension/skills/bmad-ma-agent-mil498/SKILL.md +5 -0
- package/lib/bmad-extension/skills/bmad-ma-agent-sre/SKILL.md +5 -0
- package/lib/installer.js +109 -2
- package/lib/templates/project-context.template.md +1 -1
- package/package.json +2 -2
- package/test/cicd-remote-mode.test.js +224 -0
- package/test/config-layout.test.js +230 -0
- package/test/config-lost-on-update.test.js +363 -0
- package/test/config-storage.test.js +275 -0
- package/test/cross-repo-validation.test.js +201 -0
- package/test/generate-project-context.test.js +148 -2
- package/test/portable-paths.test.js +268 -0
- package/test/repo-layout.test.js +246 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Story 16.5: Fix Config Lost on Update
|
|
4
|
+
* Tests readExistingLayout() and collectRepoLayout() with existingLayout parameter
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
let passed = 0;
|
|
14
|
+
let failed = 0;
|
|
15
|
+
const errors = [];
|
|
16
|
+
|
|
17
|
+
function test(name, fn) {
|
|
18
|
+
try {
|
|
19
|
+
fn();
|
|
20
|
+
console.log(` \u2713 ${name}`);
|
|
21
|
+
passed++;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(` \u2717 ${name}: ${err.message}`);
|
|
24
|
+
failed++;
|
|
25
|
+
errors.push({ name, error: err.message });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function asyncTest(name, fn) {
|
|
30
|
+
try {
|
|
31
|
+
await fn();
|
|
32
|
+
console.log(` \u2713 ${name}`);
|
|
33
|
+
passed++;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(` \u2717 ${name}: ${err.message}`);
|
|
36
|
+
failed++;
|
|
37
|
+
errors.push({ name, error: err.message });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Load module ─────────────────────────────────────────────────────────────
|
|
42
|
+
let readExistingLayout, collectRepoLayout, writeProjectLayoutYaml;
|
|
43
|
+
try {
|
|
44
|
+
({ readExistingLayout, collectRepoLayout, writeProjectLayoutYaml } = require('../bin/cli.js'));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error('\n FATAL: Cannot load exports from cli.js');
|
|
47
|
+
console.error(' ', e.message);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Helper: create temp dir and set cwd, returns cleanup fn
|
|
52
|
+
function withTmpDir() {
|
|
53
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
54
|
+
const origCwd = process.cwd();
|
|
55
|
+
process.chdir(tmpDir);
|
|
56
|
+
return {
|
|
57
|
+
tmpDir,
|
|
58
|
+
cleanup() {
|
|
59
|
+
process.chdir(origCwd);
|
|
60
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Helper: write a project-layout.yaml
|
|
66
|
+
function writeLayoutFile(tmpDir, content) {
|
|
67
|
+
const dir = path.join(tmpDir, '_bmad-output');
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
fs.writeFileSync(path.join(dir, 'project-layout.yaml'), content, 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Helper: write a config.yaml with knowledgebase/sprint paths
|
|
73
|
+
function writeConfigFile(tmpDir, content) {
|
|
74
|
+
const dir = path.join(tmpDir, '_bmad', 'bmm');
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(dir, 'config.yaml'), content, 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── readExistingLayout — project-layout.yaml ──────────────────────────────
|
|
80
|
+
console.log('\nreadExistingLayout (project-layout.yaml)');
|
|
81
|
+
|
|
82
|
+
test('returns correct layout from valid project-layout.yaml', () => {
|
|
83
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
84
|
+
try {
|
|
85
|
+
writeLayoutFile(tmpDir, [
|
|
86
|
+
'# Generated by ma-agents',
|
|
87
|
+
'generated: "2026-03-28"',
|
|
88
|
+
'knowledgebase:',
|
|
89
|
+
' mode: local',
|
|
90
|
+
' path: "/ext/kb"',
|
|
91
|
+
'sprint_management:',
|
|
92
|
+
' mode: same',
|
|
93
|
+
' path: "."',
|
|
94
|
+
].join('\n'));
|
|
95
|
+
|
|
96
|
+
const result = readExistingLayout();
|
|
97
|
+
assert.ok(result, 'should return layout object');
|
|
98
|
+
assert.strictEqual(result.knowledgebase.mode, 'local');
|
|
99
|
+
assert.strictEqual(result.knowledgebase.path, '/ext/kb');
|
|
100
|
+
assert.strictEqual(result.sprintManagement.mode, 'same');
|
|
101
|
+
assert.strictEqual(result.sprintManagement.path, '.');
|
|
102
|
+
} finally {
|
|
103
|
+
cleanup();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('parses remote mode with gitUrl', () => {
|
|
108
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
109
|
+
try {
|
|
110
|
+
writeLayoutFile(tmpDir, [
|
|
111
|
+
'knowledgebase:',
|
|
112
|
+
' mode: remote',
|
|
113
|
+
' path: "/tmp/kb"',
|
|
114
|
+
' gitUrl: "https://git.example.com/kb.git"',
|
|
115
|
+
'sprint_management:',
|
|
116
|
+
' mode: local',
|
|
117
|
+
' path: "/tmp/sprint"',
|
|
118
|
+
].join('\n'));
|
|
119
|
+
|
|
120
|
+
const result = readExistingLayout();
|
|
121
|
+
assert.ok(result, 'should return layout');
|
|
122
|
+
assert.strictEqual(result.knowledgebase.mode, 'remote');
|
|
123
|
+
assert.strictEqual(result.knowledgebase.gitUrl, 'https://git.example.com/kb.git');
|
|
124
|
+
assert.strictEqual(result.sprintManagement.mode, 'local');
|
|
125
|
+
assert.strictEqual(result.sprintManagement.path, '/tmp/sprint');
|
|
126
|
+
} finally {
|
|
127
|
+
cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('returns null when no project-layout.yaml exists', () => {
|
|
132
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
133
|
+
try {
|
|
134
|
+
// No files at all
|
|
135
|
+
const result = readExistingLayout();
|
|
136
|
+
assert.strictEqual(result, null, 'should return null when no layout file');
|
|
137
|
+
} finally {
|
|
138
|
+
cleanup();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('returns null on corrupt YAML (no crash)', () => {
|
|
143
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
144
|
+
try {
|
|
145
|
+
writeLayoutFile(tmpDir, '{{{{invalid yaml garbage\n\x00\x01\x02');
|
|
146
|
+
const result = readExistingLayout();
|
|
147
|
+
assert.strictEqual(result, null, 'should return null on corrupt file');
|
|
148
|
+
} finally {
|
|
149
|
+
cleanup();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('fills in default for missing section', () => {
|
|
154
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
155
|
+
try {
|
|
156
|
+
// Only knowledgebase section, no sprint_management
|
|
157
|
+
writeLayoutFile(tmpDir, [
|
|
158
|
+
'knowledgebase:',
|
|
159
|
+
' mode: local',
|
|
160
|
+
' path: "/ext/kb"',
|
|
161
|
+
].join('\n'));
|
|
162
|
+
|
|
163
|
+
const result = readExistingLayout();
|
|
164
|
+
assert.ok(result, 'should return layout');
|
|
165
|
+
assert.strictEqual(result.knowledgebase.mode, 'local');
|
|
166
|
+
assert.strictEqual(result.sprintManagement.mode, 'same', 'missing section defaults to same');
|
|
167
|
+
assert.strictEqual(result.sprintManagement.path, '.', 'missing section defaults to "."');
|
|
168
|
+
} finally {
|
|
169
|
+
cleanup();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── readExistingLayout — config.yaml fallback ─────────────────────────────
|
|
174
|
+
console.log('\nreadExistingLayout (config.yaml fallback)');
|
|
175
|
+
|
|
176
|
+
test('falls back to config.yaml when no project-layout.yaml', () => {
|
|
177
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
178
|
+
try {
|
|
179
|
+
writeConfigFile(tmpDir, [
|
|
180
|
+
'# BMM Config',
|
|
181
|
+
'knowledgebase_path: "/ext/kb"',
|
|
182
|
+
'sprint_management_path: "."',
|
|
183
|
+
].join('\n'));
|
|
184
|
+
|
|
185
|
+
const result = readExistingLayout();
|
|
186
|
+
assert.ok(result, 'should return layout from config.yaml fallback');
|
|
187
|
+
assert.strictEqual(result.knowledgebase.mode, 'local');
|
|
188
|
+
assert.strictEqual(result.knowledgebase.path, '/ext/kb');
|
|
189
|
+
assert.strictEqual(result.sprintManagement.mode, 'same');
|
|
190
|
+
assert.strictEqual(result.sprintManagement.path, '.');
|
|
191
|
+
} finally {
|
|
192
|
+
cleanup();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('returns null from config.yaml when both paths are "."', () => {
|
|
197
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
198
|
+
try {
|
|
199
|
+
writeConfigFile(tmpDir, [
|
|
200
|
+
'knowledgebase_path: "."',
|
|
201
|
+
'sprint_management_path: "."',
|
|
202
|
+
].join('\n'));
|
|
203
|
+
|
|
204
|
+
const result = readExistingLayout();
|
|
205
|
+
assert.strictEqual(result, null, 'all-default config should return null');
|
|
206
|
+
} finally {
|
|
207
|
+
cleanup();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('prefers project-layout.yaml over config.yaml', () => {
|
|
212
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
213
|
+
try {
|
|
214
|
+
writeLayoutFile(tmpDir, [
|
|
215
|
+
'knowledgebase:',
|
|
216
|
+
' mode: local',
|
|
217
|
+
' path: "/layout-path"',
|
|
218
|
+
'sprint_management:',
|
|
219
|
+
' mode: same',
|
|
220
|
+
' path: "."',
|
|
221
|
+
].join('\n'));
|
|
222
|
+
writeConfigFile(tmpDir, [
|
|
223
|
+
'knowledgebase_path: "/config-path"',
|
|
224
|
+
'sprint_management_path: "."',
|
|
225
|
+
].join('\n'));
|
|
226
|
+
|
|
227
|
+
const result = readExistingLayout();
|
|
228
|
+
assert.ok(result, 'should return layout');
|
|
229
|
+
assert.strictEqual(result.knowledgebase.path, '/layout-path',
|
|
230
|
+
'project-layout.yaml should take precedence over config.yaml');
|
|
231
|
+
} finally {
|
|
232
|
+
cleanup();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ─── collectRepoLayout with existingLayout (--yes mode) ────────────────────
|
|
237
|
+
console.log('\ncollectRepoLayout with existingLayout (--yes mode)');
|
|
238
|
+
|
|
239
|
+
asyncTest('--yes preserves existing layout when no env vars', async () => {
|
|
240
|
+
const origKb = process.env.MA_KNOWLEDGEBASE_PATH;
|
|
241
|
+
const origSp = process.env.MA_SPRINT_PATH;
|
|
242
|
+
try {
|
|
243
|
+
delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
244
|
+
delete process.env.MA_SPRINT_PATH;
|
|
245
|
+
|
|
246
|
+
const existing = {
|
|
247
|
+
knowledgebase: { mode: 'local', path: '/preserved/kb' },
|
|
248
|
+
sprintManagement: { mode: 'local', path: '/preserved/sprint' },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const result = await collectRepoLayout({ yes: true }, existing);
|
|
252
|
+
assert.strictEqual(result.knowledgebase.path, '/preserved/kb', 'should preserve kb path');
|
|
253
|
+
assert.strictEqual(result.sprintManagement.path, '/preserved/sprint', 'should preserve sprint path');
|
|
254
|
+
} finally {
|
|
255
|
+
if (origKb !== undefined) process.env.MA_KNOWLEDGEBASE_PATH = origKb;
|
|
256
|
+
else delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
257
|
+
if (origSp !== undefined) process.env.MA_SPRINT_PATH = origSp;
|
|
258
|
+
else delete process.env.MA_SPRINT_PATH;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
asyncTest('--yes with env vars overrides existing layout', async () => {
|
|
263
|
+
const origKb = process.env.MA_KNOWLEDGEBASE_PATH;
|
|
264
|
+
const origSp = process.env.MA_SPRINT_PATH;
|
|
265
|
+
try {
|
|
266
|
+
process.env.MA_KNOWLEDGEBASE_PATH = '/env/kb';
|
|
267
|
+
process.env.MA_SPRINT_PATH = '/env/sprint';
|
|
268
|
+
|
|
269
|
+
const existing = {
|
|
270
|
+
knowledgebase: { mode: 'local', path: '/old/kb' },
|
|
271
|
+
sprintManagement: { mode: 'local', path: '/old/sprint' },
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const result = await collectRepoLayout({ yes: true }, existing);
|
|
275
|
+
assert.strictEqual(result.knowledgebase.path, path.resolve('/env/kb'), 'env var should override kb');
|
|
276
|
+
assert.strictEqual(result.sprintManagement.path, path.resolve('/env/sprint'), 'env var should override sprint');
|
|
277
|
+
} finally {
|
|
278
|
+
if (origKb !== undefined) process.env.MA_KNOWLEDGEBASE_PATH = origKb;
|
|
279
|
+
else delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
280
|
+
if (origSp !== undefined) process.env.MA_SPRINT_PATH = origSp;
|
|
281
|
+
else delete process.env.MA_SPRINT_PATH;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
asyncTest('--yes with no env vars and no existing layout defaults to single-repo', async () => {
|
|
286
|
+
const origKb = process.env.MA_KNOWLEDGEBASE_PATH;
|
|
287
|
+
const origSp = process.env.MA_SPRINT_PATH;
|
|
288
|
+
try {
|
|
289
|
+
delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
290
|
+
delete process.env.MA_SPRINT_PATH;
|
|
291
|
+
|
|
292
|
+
const result = await collectRepoLayout({ yes: true }, null);
|
|
293
|
+
assert.strictEqual(result.knowledgebase.mode, 'same');
|
|
294
|
+
assert.strictEqual(result.knowledgebase.path, '.');
|
|
295
|
+
assert.strictEqual(result.sprintManagement.mode, 'same');
|
|
296
|
+
assert.strictEqual(result.sprintManagement.path, '.');
|
|
297
|
+
} finally {
|
|
298
|
+
if (origKb !== undefined) process.env.MA_KNOWLEDGEBASE_PATH = origKb;
|
|
299
|
+
else delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
300
|
+
if (origSp !== undefined) process.env.MA_SPRINT_PATH = origSp;
|
|
301
|
+
else delete process.env.MA_SPRINT_PATH;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
asyncTest('--yes with partial env vars preserves non-overridden concern from existing', async () => {
|
|
306
|
+
const origKb = process.env.MA_KNOWLEDGEBASE_PATH;
|
|
307
|
+
const origSp = process.env.MA_SPRINT_PATH;
|
|
308
|
+
try {
|
|
309
|
+
process.env.MA_KNOWLEDGEBASE_PATH = '/env/kb';
|
|
310
|
+
delete process.env.MA_SPRINT_PATH;
|
|
311
|
+
|
|
312
|
+
const existing = {
|
|
313
|
+
knowledgebase: { mode: 'local', path: '/old/kb' },
|
|
314
|
+
sprintManagement: { mode: 'local', path: '/existing/sprint' },
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const result = await collectRepoLayout({ yes: true }, existing);
|
|
318
|
+
assert.strictEqual(result.knowledgebase.path, path.resolve('/env/kb'), 'env var overrides kb');
|
|
319
|
+
assert.strictEqual(result.sprintManagement.path, '/existing/sprint', 'existing sprint preserved');
|
|
320
|
+
} finally {
|
|
321
|
+
if (origKb !== undefined) process.env.MA_KNOWLEDGEBASE_PATH = origKb;
|
|
322
|
+
else delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
323
|
+
if (origSp !== undefined) process.env.MA_SPRINT_PATH = origSp;
|
|
324
|
+
else delete process.env.MA_SPRINT_PATH;
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ─── Round-trip: writeProjectLayoutYaml → readExistingLayout ───────────────
|
|
329
|
+
console.log('\nRound-trip: write then read');
|
|
330
|
+
|
|
331
|
+
test('readExistingLayout correctly reads what writeProjectLayoutYaml writes', () => {
|
|
332
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
333
|
+
try {
|
|
334
|
+
const layout = {
|
|
335
|
+
knowledgebase: { mode: 'local', path: 'd:\\Code\\kb-repo' },
|
|
336
|
+
sprintManagement: { mode: 'remote', path: '/tmp/sprint', gitUrl: 'https://git.example.com/sprint.git' }
|
|
337
|
+
};
|
|
338
|
+
writeProjectLayoutYaml(layout);
|
|
339
|
+
|
|
340
|
+
const result = readExistingLayout();
|
|
341
|
+
assert.ok(result, 'should read back layout');
|
|
342
|
+
assert.strictEqual(result.knowledgebase.mode, 'local');
|
|
343
|
+
assert.strictEqual(result.knowledgebase.path, 'd:/Code/kb-repo', 'path should be normalized');
|
|
344
|
+
assert.strictEqual(result.sprintManagement.mode, 'remote');
|
|
345
|
+
assert.strictEqual(result.sprintManagement.gitUrl, 'https://git.example.com/sprint.git');
|
|
346
|
+
} finally {
|
|
347
|
+
cleanup();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ─── Summary ───────────────────────────────────────────────────────────────
|
|
352
|
+
async function runAll() {
|
|
353
|
+
// Wait for any remaining async tests
|
|
354
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
355
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
356
|
+
if (errors.length > 0) {
|
|
357
|
+
console.log('\nFailed tests:');
|
|
358
|
+
errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
|
|
359
|
+
}
|
|
360
|
+
if (failed > 0) process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
runAll();
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Story 16.2: Config Storage and Cross-Reference File
|
|
4
|
+
* Tests writeRepoLayoutConfig(), writeProjectLayoutYaml(), writeConfigField(), normalizePath()
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
let passed = 0;
|
|
14
|
+
let failed = 0;
|
|
15
|
+
const errors = [];
|
|
16
|
+
|
|
17
|
+
function test(name, fn) {
|
|
18
|
+
try {
|
|
19
|
+
fn();
|
|
20
|
+
console.log(` \u2713 ${name}`);
|
|
21
|
+
passed++;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(` \u2717 ${name}: ${err.message}`);
|
|
24
|
+
failed++;
|
|
25
|
+
errors.push({ name, error: err.message });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Load module ─────────────────────────────────────────────────────────────
|
|
30
|
+
let writeConfigField, normalizePath, writeRepoLayoutConfig, writeProjectLayoutYaml;
|
|
31
|
+
try {
|
|
32
|
+
({ writeConfigField, normalizePath, writeRepoLayoutConfig, writeProjectLayoutYaml } = require('../bin/cli.js'));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error('\n FATAL: Cannot load exports from cli.js');
|
|
35
|
+
console.error(' ', e.message);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── normalizePath ──────────────────────────────────────────────────────────
|
|
40
|
+
console.log('\nnormalizePath');
|
|
41
|
+
|
|
42
|
+
test('converts backslashes to forward slashes', () => {
|
|
43
|
+
assert.strictEqual(normalizePath('d:\\Code\\agents'), 'd:/Code/agents');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('leaves forward slashes unchanged', () => {
|
|
47
|
+
assert.strictEqual(normalizePath('/path/to/repo'), '/path/to/repo');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('handles dot path', () => {
|
|
51
|
+
assert.strictEqual(normalizePath('.'), '.');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ─── writeConfigField ───────────────────────────────────────────────────────
|
|
55
|
+
console.log('\nwriteConfigField');
|
|
56
|
+
|
|
57
|
+
test('appends field when not present', () => {
|
|
58
|
+
const result = writeConfigField('existing: value', 'new_field', '/some/path');
|
|
59
|
+
assert.ok(result.includes('new_field: "/some/path"'), `Got: ${result}`);
|
|
60
|
+
assert.ok(result.includes('existing: value'), 'preserves existing content');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('replaces existing field in place', () => {
|
|
64
|
+
const content = 'knowledgebase_path: "old/path"\nother: value';
|
|
65
|
+
const result = writeConfigField(content, 'knowledgebase_path', 'new/path');
|
|
66
|
+
assert.ok(result.includes('knowledgebase_path: "new/path"'), `Got: ${result}`);
|
|
67
|
+
assert.ok(!result.includes('old/path'), 'old value should be replaced');
|
|
68
|
+
assert.ok(result.includes('other: value'), 'other fields preserved');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('does not duplicate field on second write', () => {
|
|
72
|
+
let content = 'knowledgebase_path: "."\n';
|
|
73
|
+
content = writeConfigField(content, 'knowledgebase_path', '/new/path');
|
|
74
|
+
const count = (content.match(/knowledgebase_path/g) || []).length;
|
|
75
|
+
assert.strictEqual(count, 1, `Field duplicated: count=${count}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('double-quotes path values', () => {
|
|
79
|
+
const result = writeConfigField('', 'path_field', 'C:/Code/repo');
|
|
80
|
+
assert.ok(result.includes('"C:/Code/repo"'), 'Path should be double-quoted');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── writeProjectLayoutYaml ─────────────────────────────────────────────────
|
|
84
|
+
console.log('\nwriteProjectLayoutYaml');
|
|
85
|
+
|
|
86
|
+
test('single-repo: does not create project-layout.yaml', () => {
|
|
87
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
88
|
+
const origCwd = process.cwd();
|
|
89
|
+
try {
|
|
90
|
+
process.chdir(tmpDir);
|
|
91
|
+
fs.mkdirSync('_bmad-output', { recursive: true });
|
|
92
|
+
writeProjectLayoutYaml({
|
|
93
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
94
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
95
|
+
});
|
|
96
|
+
assert.ok(!fs.existsSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml')),
|
|
97
|
+
'project-layout.yaml should NOT be created for single-repo');
|
|
98
|
+
} finally {
|
|
99
|
+
process.chdir(origCwd);
|
|
100
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('single-repo: deletes stale project-layout.yaml', () => {
|
|
105
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
106
|
+
const origCwd = process.cwd();
|
|
107
|
+
try {
|
|
108
|
+
process.chdir(tmpDir);
|
|
109
|
+
fs.mkdirSync('_bmad-output', { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'stale content');
|
|
111
|
+
writeProjectLayoutYaml({
|
|
112
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
113
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
114
|
+
});
|
|
115
|
+
assert.ok(!fs.existsSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml')),
|
|
116
|
+
'stale project-layout.yaml should be deleted');
|
|
117
|
+
} finally {
|
|
118
|
+
process.chdir(origCwd);
|
|
119
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('multi-repo: creates project-layout.yaml with correct structure', () => {
|
|
124
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
125
|
+
const origCwd = process.cwd();
|
|
126
|
+
try {
|
|
127
|
+
process.chdir(tmpDir);
|
|
128
|
+
writeProjectLayoutYaml({
|
|
129
|
+
knowledgebase: { mode: 'local', path: 'd:\\Code\\kb-repo' },
|
|
130
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
131
|
+
});
|
|
132
|
+
const content = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
133
|
+
assert.ok(content.includes('knowledgebase:'), 'should have knowledgebase section');
|
|
134
|
+
assert.ok(content.includes('mode: local'), 'should have mode: local');
|
|
135
|
+
assert.ok(content.includes('"d:/Code/kb-repo"'), 'should normalize backslashes and quote');
|
|
136
|
+
assert.ok(content.includes('sprint_management:'), 'should have sprint_management section');
|
|
137
|
+
assert.ok(content.includes('mode: same'), 'should have mode: same for sprint');
|
|
138
|
+
assert.ok(content.includes('generated:'), 'should have generated date');
|
|
139
|
+
} finally {
|
|
140
|
+
process.chdir(origCwd);
|
|
141
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('multi-repo remote: includes gitUrl only for remote mode', () => {
|
|
146
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
147
|
+
const origCwd = process.cwd();
|
|
148
|
+
try {
|
|
149
|
+
process.chdir(tmpDir);
|
|
150
|
+
writeProjectLayoutYaml({
|
|
151
|
+
knowledgebase: { mode: 'remote', path: '/tmp/kb', gitUrl: 'https://git.example.com/kb.git' },
|
|
152
|
+
sprintManagement: { mode: 'local', path: '/tmp/sprint' }
|
|
153
|
+
});
|
|
154
|
+
const content = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
155
|
+
assert.ok(content.includes('gitUrl: "https://git.example.com/kb.git"'), 'should include gitUrl for remote');
|
|
156
|
+
// sprint_management section should NOT have gitUrl
|
|
157
|
+
const sprintSection = content.split('sprint_management:')[1];
|
|
158
|
+
assert.ok(!sprintSection.includes('gitUrl'), 'local mode should not have gitUrl');
|
|
159
|
+
} finally {
|
|
160
|
+
process.chdir(origCwd);
|
|
161
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('path normalization: backslashes converted in YAML output', () => {
|
|
166
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
167
|
+
const origCwd = process.cwd();
|
|
168
|
+
try {
|
|
169
|
+
process.chdir(tmpDir);
|
|
170
|
+
writeProjectLayoutYaml({
|
|
171
|
+
knowledgebase: { mode: 'local', path: 'C:\\Users\\dev\\kb' },
|
|
172
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
173
|
+
});
|
|
174
|
+
const content = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
175
|
+
assert.ok(!content.includes('\\'), 'should not contain backslashes');
|
|
176
|
+
assert.ok(content.includes('C:/Users/dev/kb'), 'should contain normalized path');
|
|
177
|
+
} finally {
|
|
178
|
+
process.chdir(origCwd);
|
|
179
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('idempotency: same config produces identical output (except date)', () => {
|
|
184
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
185
|
+
const origCwd = process.cwd();
|
|
186
|
+
try {
|
|
187
|
+
process.chdir(tmpDir);
|
|
188
|
+
const layout = {
|
|
189
|
+
knowledgebase: { mode: 'local', path: '/stable/path' },
|
|
190
|
+
sprintManagement: { mode: 'local', path: '/stable/sprint' }
|
|
191
|
+
};
|
|
192
|
+
writeProjectLayoutYaml(layout);
|
|
193
|
+
const first = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
194
|
+
writeProjectLayoutYaml(layout);
|
|
195
|
+
const second = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
|
|
196
|
+
assert.strictEqual(first, second, 'identical config should produce identical output');
|
|
197
|
+
} finally {
|
|
198
|
+
process.chdir(origCwd);
|
|
199
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ─── writeRepoLayoutConfig ──────────────────────────────────────────────────
|
|
204
|
+
console.log('\nwriteRepoLayoutConfig');
|
|
205
|
+
|
|
206
|
+
test('writes path fields to config.yaml', () => {
|
|
207
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
208
|
+
const origCwd = process.cwd();
|
|
209
|
+
try {
|
|
210
|
+
process.chdir(tmpDir);
|
|
211
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
212
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
213
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# existing config\n{}');
|
|
214
|
+
writeRepoLayoutConfig({
|
|
215
|
+
knowledgebase: { mode: 'local', path: 'd:\\Code\\kb' },
|
|
216
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
217
|
+
});
|
|
218
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
219
|
+
assert.ok(content.includes('knowledgebase_path: "d:/Code/kb"'), `Got: ${content}`);
|
|
220
|
+
assert.ok(content.includes('sprint_management_path: "."'), `Got: ${content}`);
|
|
221
|
+
} finally {
|
|
222
|
+
process.chdir(origCwd);
|
|
223
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('updates existing fields in place', () => {
|
|
228
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
229
|
+
const origCwd = process.cwd();
|
|
230
|
+
try {
|
|
231
|
+
process.chdir(tmpDir);
|
|
232
|
+
const configDir = path.join(tmpDir, '_bmad', 'bmm');
|
|
233
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
234
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'),
|
|
235
|
+
'# config\nknowledgebase_path: "."\nsprint_management_path: "."\n');
|
|
236
|
+
writeRepoLayoutConfig({
|
|
237
|
+
knowledgebase: { mode: 'local', path: '/new/kb' },
|
|
238
|
+
sprintManagement: { mode: 'local', path: '/new/sprint' }
|
|
239
|
+
});
|
|
240
|
+
const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
241
|
+
assert.ok(content.includes('knowledgebase_path: "/new/kb"'), `Got: ${content}`);
|
|
242
|
+
assert.ok(content.includes('sprint_management_path: "/new/sprint"'), `Got: ${content}`);
|
|
243
|
+
const kbCount = (content.match(/knowledgebase_path/g) || []).length;
|
|
244
|
+
assert.strictEqual(kbCount, 1, 'should not duplicate field');
|
|
245
|
+
} finally {
|
|
246
|
+
process.chdir(origCwd);
|
|
247
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('handles missing config.yaml gracefully', () => {
|
|
252
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
253
|
+
const origCwd = process.cwd();
|
|
254
|
+
try {
|
|
255
|
+
process.chdir(tmpDir);
|
|
256
|
+
// No _bmad/bmm/config.yaml — should not throw
|
|
257
|
+
writeRepoLayoutConfig({
|
|
258
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
259
|
+
sprintManagement: { mode: 'same', path: '.' }
|
|
260
|
+
});
|
|
261
|
+
// If we get here without throwing, the test passes
|
|
262
|
+
assert.ok(true, 'should handle missing config gracefully');
|
|
263
|
+
} finally {
|
|
264
|
+
process.chdir(origCwd);
|
|
265
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ─── Summary ────────────────────────────────────────────────────────────────
|
|
270
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
271
|
+
if (errors.length > 0) {
|
|
272
|
+
console.log('\nFailed tests:');
|
|
273
|
+
errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
|
|
274
|
+
}
|
|
275
|
+
if (failed > 0) process.exit(1);
|