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
package/lib/installer.js
CHANGED
|
@@ -138,9 +138,10 @@ async function generateSkillsManifest(installPath) {
|
|
|
138
138
|
* Generate project-context.md content by stamping the template with installed agent MANIFEST paths.
|
|
139
139
|
* @param {string} projectRoot - Absolute path to project root (unused for path generation but part of API)
|
|
140
140
|
* @param {Array<{skillsDir: string}>} installedAgents - Agent objects with skillsDir property
|
|
141
|
+
* @param {Object|null} [layout=null] - Repository layout from collectRepoLayout() (null for single-repo)
|
|
141
142
|
* @returns {Promise<string>} Stamped template content string (does NOT write any file)
|
|
142
143
|
*/
|
|
143
|
-
async function generateProjectContext(projectRoot, installedAgents) {
|
|
144
|
+
async function generateProjectContext(projectRoot, installedAgents, layout = null) {
|
|
144
145
|
let template;
|
|
145
146
|
try {
|
|
146
147
|
template = await fs.readFile(TEMPLATE_PATH, 'utf8');
|
|
@@ -158,7 +159,111 @@ async function generateProjectContext(projectRoot, installedAgents) {
|
|
|
158
159
|
.join('\n');
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
|
|
162
|
+
let content = template.replace('{{MANIFEST_PATHS_LIST}}', manifestList);
|
|
163
|
+
|
|
164
|
+
// Replace repo layout placeholder (Story 16.3)
|
|
165
|
+
const layoutSection = generateRepoLayoutSection(layout);
|
|
166
|
+
if (layoutSection) {
|
|
167
|
+
content = content.replace('{{REPO_LAYOUT_SECTION}}', '\n' + layoutSection + '\n');
|
|
168
|
+
} else {
|
|
169
|
+
// Clean removal: remove placeholder and avoid double blank lines
|
|
170
|
+
content = content.replace(/\{\{REPO_LAYOUT_SECTION\}\}\r?\n/, '');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return content;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate the Repository Layout markdown section for project-context.md.
|
|
178
|
+
* Returns the section with markers for multi-repo, or empty string for single-repo/null layout.
|
|
179
|
+
* @param {Object|null} layout - Layout object from collectRepoLayout()
|
|
180
|
+
* @returns {string} Markdown section with markers, or empty string
|
|
181
|
+
*/
|
|
182
|
+
function generateRepoLayoutSection(layout) {
|
|
183
|
+
if (!layout || !layout.knowledgebase || !layout.sprintManagement) return '';
|
|
184
|
+
if (layout.knowledgebase.mode === 'same' && layout.sprintManagement.mode === 'same') return '';
|
|
185
|
+
|
|
186
|
+
const normPath = (p) => (p || '.').replace(/\\/g, '/');
|
|
187
|
+
const kbDisplay = layout.knowledgebase.mode === 'same'
|
|
188
|
+
? 'current repository (default)'
|
|
189
|
+
: normPath(layout.knowledgebase.path);
|
|
190
|
+
const spDisplay = layout.sprintManagement.mode === 'same'
|
|
191
|
+
? 'current repository (default)'
|
|
192
|
+
: normPath(layout.sprintManagement.path);
|
|
193
|
+
|
|
194
|
+
let lines = [
|
|
195
|
+
'<!-- ma-agents:repo-layout-start -->',
|
|
196
|
+
'### Repository Layout',
|
|
197
|
+
`- **Knowledgebase:** ${kbDisplay}`,
|
|
198
|
+
`- **Sprint Management:** ${spDisplay}`,
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
if (layout.knowledgebase.mode !== 'same') {
|
|
202
|
+
lines.push('- When creating or reading planning artifacts, use the knowledgebase path');
|
|
203
|
+
}
|
|
204
|
+
if (layout.sprintManagement.mode !== 'same') {
|
|
205
|
+
lines.push('- When creating or reading sprint/story artifacts, use the sprint management path');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
lines.push('<!-- ma-agents:repo-layout-end -->');
|
|
209
|
+
return lines.join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Update the Repository Layout section in an existing project-context.md.
|
|
214
|
+
* Uses marker-based update pattern (same as manifest paths).
|
|
215
|
+
* @param {string} outputPath - Absolute path to existing project-context.md
|
|
216
|
+
* @param {Object|null} layout - Layout object from collectRepoLayout()
|
|
217
|
+
* @returns {Promise<boolean>} true if file was written, false otherwise
|
|
218
|
+
*/
|
|
219
|
+
async function updateProjectContextRepoLayout(outputPath, layout) {
|
|
220
|
+
let content;
|
|
221
|
+
try {
|
|
222
|
+
content = await fs.readFile(outputPath, 'utf8');
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const newSection = generateRepoLayoutSection(layout);
|
|
228
|
+
const START = '<!-- ma-agents:repo-layout-start -->';
|
|
229
|
+
const END = '<!-- ma-agents:repo-layout-end -->';
|
|
230
|
+
const startIdx = content.indexOf(START);
|
|
231
|
+
const endIdx = content.indexOf(END);
|
|
232
|
+
|
|
233
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
234
|
+
// Markers exist — replace content between them
|
|
235
|
+
if (newSection) {
|
|
236
|
+
const newContent = content.slice(0, startIdx) + newSection + content.slice(endIdx + END.length);
|
|
237
|
+
if (newContent === content) return false;
|
|
238
|
+
await fs.writeFile(outputPath, newContent, 'utf8');
|
|
239
|
+
return true;
|
|
240
|
+
} else {
|
|
241
|
+
// Single-repo: remove the entire section including markers and surrounding blank lines
|
|
242
|
+
let before = content.slice(0, startIdx);
|
|
243
|
+
let after = content.slice(endIdx + END.length);
|
|
244
|
+
// Clean up trailing newline from before and leading newline from after
|
|
245
|
+
if (before.endsWith('\n')) before = before.slice(0, -1);
|
|
246
|
+
if (after.startsWith('\n')) after = after.slice(1);
|
|
247
|
+
const newContent = before + '\n' + after;
|
|
248
|
+
if (newContent === content) return false;
|
|
249
|
+
await fs.writeFile(outputPath, newContent, 'utf8');
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Markers don't exist
|
|
255
|
+
if (!newSection) return false; // single-repo, nothing to insert
|
|
256
|
+
|
|
257
|
+
// Find insertion point: before "## Technology Stack"
|
|
258
|
+
const techStackIdx = content.indexOf('## Technology Stack');
|
|
259
|
+
if (techStackIdx === -1) {
|
|
260
|
+
// Can't find expected structure — skip with info
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const newContent = content.slice(0, techStackIdx) + newSection + '\n\n' + content.slice(techStackIdx);
|
|
265
|
+
await fs.writeFile(outputPath, newContent, 'utf8');
|
|
266
|
+
return true;
|
|
162
267
|
}
|
|
163
268
|
|
|
164
269
|
/**
|
|
@@ -949,6 +1054,8 @@ module.exports = {
|
|
|
949
1054
|
ensureBmadOutputTracked,
|
|
950
1055
|
generateSkillsManifest,
|
|
951
1056
|
generateProjectContext,
|
|
1057
|
+
generateRepoLayoutSection,
|
|
1058
|
+
updateProjectContextRepoLayout,
|
|
952
1059
|
_updateProjectContextManifestPaths: updateProjectContextManifestPaths,
|
|
953
1060
|
_testUpdateAgentInstructions: updateAgentInstructions,
|
|
954
1061
|
_MA_AGENTS_SOURCE: MA_AGENTS_SOURCE
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
6. **Human review gate:** Await approval — do NOT auto-merge under any circumstance
|
|
38
38
|
7. **Merge + cleanup:** After approval: merge, `git worktree remove`, delete branch
|
|
39
39
|
8. **Post-mission:** Update story status to done, append session to AiAudit.md
|
|
40
|
-
|
|
40
|
+
{{REPO_LAYOUT_SECTION}}
|
|
41
41
|
## Technology Stack
|
|
42
42
|
<!-- TODO: Populated after architecture phase via /bmad-generate-project-context -->
|
|
43
43
|
<!-- Run: /bmad-generate-project-context to auto-detect your stack -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ma-agents",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "NPX tool to install skills for AI coding agents (Claude Code, Gemini, Copilot, Kilocode, Cline, Cursor)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/cli.js",
|
|
11
|
-
"test": "node test/yes-flag.test.js && node test/skill-authoring.test.js && node test/skill-validation.test.js && node test/skill-mandatory.test.js && node test/skill-customize-agent.test.js && node test/create-agent.test.js && node test/generate-project-context.test.js && node test/build-bmad-args.test.js && node test/bmad-version-bump.test.js && node test/extension-module-restructure.test.js && node test/convert-agents-to-skills.test.js && node test/migration.test.js && node test/migration-validation.test.js && node test/story-15-5-workflow-skills.test.js",
|
|
11
|
+
"test": "node test/yes-flag.test.js && node test/skill-authoring.test.js && node test/skill-validation.test.js && node test/skill-mandatory.test.js && node test/skill-customize-agent.test.js && node test/create-agent.test.js && node test/generate-project-context.test.js && node test/build-bmad-args.test.js && node test/bmad-version-bump.test.js && node test/extension-module-restructure.test.js && node test/convert-agents-to-skills.test.js && node test/migration.test.js && node test/migration-validation.test.js && node test/story-15-5-workflow-skills.test.js && node test/repo-layout.test.js && node test/config-storage.test.js && node test/cross-repo-validation.test.js && node test/config-lost-on-update.test.js && node test/portable-paths.test.js && node test/cicd-remote-mode.test.js && node test/config-layout.test.js",
|
|
12
12
|
"build:bmad-cache": "node scripts/build-bmad-cache.js"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Story 16.8: CI/CD Remote Repository Mode
|
|
4
|
+
* Tests collectRepoLayout() with git URL env vars, ciCloneIfNeeded()
|
|
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 collectRepoLayout, ciCloneIfNeeded;
|
|
43
|
+
try {
|
|
44
|
+
({ collectRepoLayout, ciCloneIfNeeded } = 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: save and restore env vars
|
|
52
|
+
function withEnv(vars, fn) {
|
|
53
|
+
const saved = {};
|
|
54
|
+
const envKeys = [
|
|
55
|
+
'MA_KNOWLEDGEBASE_PATH', 'MA_SPRINT_PATH',
|
|
56
|
+
'MA_KNOWLEDGEBASE_GIT_URL', 'MA_SPRINT_GIT_URL'
|
|
57
|
+
];
|
|
58
|
+
for (const k of envKeys) {
|
|
59
|
+
saved[k] = process.env[k];
|
|
60
|
+
delete process.env[k];
|
|
61
|
+
}
|
|
62
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
63
|
+
process.env[k] = v;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return fn();
|
|
67
|
+
} finally {
|
|
68
|
+
for (const k of envKeys) {
|
|
69
|
+
if (saved[k] !== undefined) process.env[k] = saved[k];
|
|
70
|
+
else delete process.env[k];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── collectRepoLayout: remote mode via env vars ───────────────────────────
|
|
76
|
+
console.log('\ncollectRepoLayout: CI/CD remote mode');
|
|
77
|
+
|
|
78
|
+
asyncTest('--yes + git URL + path returns remote mode', async () => {
|
|
79
|
+
await withEnv({
|
|
80
|
+
MA_KNOWLEDGEBASE_PATH: os.tmpdir(),
|
|
81
|
+
MA_KNOWLEDGEBASE_GIT_URL: 'https://git.example.com/kb.git',
|
|
82
|
+
}, async () => {
|
|
83
|
+
// Create a fake .git dir at tmpdir so clone is skipped
|
|
84
|
+
const gitDir = path.join(os.tmpdir(), '.git');
|
|
85
|
+
const hadGitDir = fs.existsSync(gitDir);
|
|
86
|
+
if (!hadGitDir) fs.mkdirSync(gitDir, { recursive: true });
|
|
87
|
+
try {
|
|
88
|
+
const result = await collectRepoLayout({ yes: true }, null);
|
|
89
|
+
assert.strictEqual(result.knowledgebase.mode, 'remote');
|
|
90
|
+
assert.strictEqual(result.knowledgebase.gitUrl, 'https://git.example.com/kb.git');
|
|
91
|
+
assert.strictEqual(result.knowledgebase.path, path.resolve(os.tmpdir()));
|
|
92
|
+
} finally {
|
|
93
|
+
if (!hadGitDir && fs.existsSync(gitDir)) fs.rmdirSync(gitDir);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
asyncTest('--yes + path only (no git URL) returns local mode (backward compat)', async () => {
|
|
99
|
+
await withEnv({
|
|
100
|
+
MA_KNOWLEDGEBASE_PATH: '/some/local/path',
|
|
101
|
+
}, async () => {
|
|
102
|
+
const result = await collectRepoLayout({ yes: true }, null);
|
|
103
|
+
assert.strictEqual(result.knowledgebase.mode, 'local');
|
|
104
|
+
assert.strictEqual(result.knowledgebase.path, path.resolve('/some/local/path'));
|
|
105
|
+
assert.strictEqual(result.knowledgebase.gitUrl, undefined, 'should not have gitUrl');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
asyncTest('--yes + no env vars defaults to single-repo', async () => {
|
|
110
|
+
await withEnv({}, async () => {
|
|
111
|
+
const result = await collectRepoLayout({ yes: true }, null);
|
|
112
|
+
assert.strictEqual(result.knowledgebase.mode, 'same');
|
|
113
|
+
assert.strictEqual(result.sprintManagement.mode, 'same');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
asyncTest('--yes + sprint git URL + path returns remote for sprint', async () => {
|
|
118
|
+
await withEnv({
|
|
119
|
+
MA_SPRINT_PATH: os.tmpdir(),
|
|
120
|
+
MA_SPRINT_GIT_URL: 'https://git.example.com/sprint.git',
|
|
121
|
+
}, async () => {
|
|
122
|
+
const gitDir = path.join(os.tmpdir(), '.git');
|
|
123
|
+
const hadGitDir = fs.existsSync(gitDir);
|
|
124
|
+
if (!hadGitDir) fs.mkdirSync(gitDir, { recursive: true });
|
|
125
|
+
try {
|
|
126
|
+
const result = await collectRepoLayout({ yes: true }, null);
|
|
127
|
+
assert.strictEqual(result.sprintManagement.mode, 'remote');
|
|
128
|
+
assert.strictEqual(result.sprintManagement.gitUrl, 'https://git.example.com/sprint.git');
|
|
129
|
+
assert.strictEqual(result.knowledgebase.mode, 'same', 'kb should default when not set');
|
|
130
|
+
} finally {
|
|
131
|
+
if (!hadGitDir && fs.existsSync(gitDir)) fs.rmdirSync(gitDir);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── ciCloneIfNeeded ───────────────────────────────────────────────────────
|
|
137
|
+
console.log('\nciCloneIfNeeded');
|
|
138
|
+
|
|
139
|
+
test('skips clone when .git directory exists at destination', () => {
|
|
140
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
141
|
+
try {
|
|
142
|
+
fs.mkdirSync(path.join(tmpDir, '.git'));
|
|
143
|
+
const clonedRepos = new Map();
|
|
144
|
+
// Should not throw — just logs and returns
|
|
145
|
+
ciCloneIfNeeded(tmpDir, 'https://git.example.com/repo.git', clonedRepos);
|
|
146
|
+
assert.ok(clonedRepos.has(`https://git.example.com/repo.git::${tmpDir}`), 'should mark as cloned');
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('deduplicates: same URL+path only processed once', () => {
|
|
153
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
154
|
+
try {
|
|
155
|
+
fs.mkdirSync(path.join(tmpDir, '.git'));
|
|
156
|
+
const clonedRepos = new Map();
|
|
157
|
+
ciCloneIfNeeded(tmpDir, 'https://git.example.com/repo.git', clonedRepos);
|
|
158
|
+
// Second call should be a no-op
|
|
159
|
+
ciCloneIfNeeded(tmpDir, 'https://git.example.com/repo.git', clonedRepos);
|
|
160
|
+
assert.strictEqual(clonedRepos.size, 1, 'should only have one entry');
|
|
161
|
+
} finally {
|
|
162
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('throws error when destination exists but is not a git repo', () => {
|
|
167
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
168
|
+
try {
|
|
169
|
+
// tmpDir exists but has no .git/ — should throw
|
|
170
|
+
try {
|
|
171
|
+
ciCloneIfNeeded(tmpDir, 'https://git.example.com/repo.git', new Map());
|
|
172
|
+
assert.fail('should have thrown');
|
|
173
|
+
} catch (e) {
|
|
174
|
+
assert.ok(e.message.includes('not a git repo'), `error should mention "not a git repo", got: ${e.message}`);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ─── git URL without path env var ──────────────────────────────────────────
|
|
182
|
+
console.log('\ncollectRepoLayout: validation errors');
|
|
183
|
+
|
|
184
|
+
asyncTest('git URL without path env var throws error (knowledgebase)', async () => {
|
|
185
|
+
try {
|
|
186
|
+
await withEnv({
|
|
187
|
+
MA_KNOWLEDGEBASE_GIT_URL: 'https://git.example.com/kb.git',
|
|
188
|
+
// MA_KNOWLEDGEBASE_PATH deliberately NOT set
|
|
189
|
+
}, async () => {
|
|
190
|
+
await collectRepoLayout({ yes: true }, null);
|
|
191
|
+
});
|
|
192
|
+
assert.fail('should have thrown');
|
|
193
|
+
} catch (e) {
|
|
194
|
+
assert.ok(e.message.includes('MA_KNOWLEDGEBASE_GIT_URL requires MA_KNOWLEDGEBASE_PATH'),
|
|
195
|
+
`should mention required PATH, got: ${e.message}`);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
asyncTest('git URL without path env var throws error (sprint)', async () => {
|
|
200
|
+
try {
|
|
201
|
+
await withEnv({
|
|
202
|
+
MA_SPRINT_GIT_URL: 'https://git.example.com/sprint.git',
|
|
203
|
+
}, async () => {
|
|
204
|
+
await collectRepoLayout({ yes: true }, null);
|
|
205
|
+
});
|
|
206
|
+
assert.fail('should have thrown');
|
|
207
|
+
} catch (e) {
|
|
208
|
+
assert.ok(e.message.includes('MA_SPRINT_GIT_URL requires MA_SPRINT_PATH'),
|
|
209
|
+
`should mention required PATH, got: ${e.message}`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ─── Summary ───────────────────────────────────────────────────────────────
|
|
214
|
+
async function runAll() {
|
|
215
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
216
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
217
|
+
if (errors.length > 0) {
|
|
218
|
+
console.log('\nFailed tests:');
|
|
219
|
+
errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
|
|
220
|
+
}
|
|
221
|
+
if (failed > 0) process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
runAll();
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Story 16.9: Reconfigure Layout Workflow
|
|
4
|
+
* Tests showCurrentLayout(), handleConfigLayout(), and CLI command routing
|
|
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 showCurrentLayout, handleConfigLayout, writeProjectLayoutYaml, writeRepoLayoutConfig, readExistingLayout;
|
|
43
|
+
try {
|
|
44
|
+
({ showCurrentLayout, handleConfigLayout, writeProjectLayoutYaml, writeRepoLayoutConfig, readExistingLayout } = 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
|
+
function withTmpDir() {
|
|
52
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
53
|
+
const origCwd = process.cwd();
|
|
54
|
+
process.chdir(tmpDir);
|
|
55
|
+
return { tmpDir, cleanup() { process.chdir(origCwd); fs.rmSync(tmpDir, { recursive: true, force: true }); } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── showCurrentLayout ─────────────────────────────────────────────────────
|
|
59
|
+
console.log('\nshowCurrentLayout');
|
|
60
|
+
|
|
61
|
+
test('--show with no config displays single-repo default', () => {
|
|
62
|
+
const { cleanup } = withTmpDir();
|
|
63
|
+
try {
|
|
64
|
+
// Capture console output
|
|
65
|
+
const logs = [];
|
|
66
|
+
const origLog = console.log;
|
|
67
|
+
console.log = (...args) => logs.push(args.join(' '));
|
|
68
|
+
showCurrentLayout();
|
|
69
|
+
console.log = origLog;
|
|
70
|
+
const output = logs.join('\n');
|
|
71
|
+
assert.ok(output.includes('Single-repo') || output.includes('single-repo') || output.includes('.'),
|
|
72
|
+
`should mention single-repo, got: ${output}`);
|
|
73
|
+
} finally {
|
|
74
|
+
cleanup();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('--show with multi-repo config displays current layout', () => {
|
|
79
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
80
|
+
try {
|
|
81
|
+
// Write a layout file
|
|
82
|
+
const dir = path.join(tmpDir, '_bmad-output');
|
|
83
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
84
|
+
fs.writeFileSync(path.join(dir, 'project-layout.yaml'), [
|
|
85
|
+
'knowledgebase:',
|
|
86
|
+
' mode: local',
|
|
87
|
+
' path: "../kb-repo"',
|
|
88
|
+
'sprint_management:',
|
|
89
|
+
' mode: same',
|
|
90
|
+
' path: "."',
|
|
91
|
+
].join('\n'));
|
|
92
|
+
|
|
93
|
+
const logs = [];
|
|
94
|
+
const origLog = console.log;
|
|
95
|
+
console.log = (...args) => logs.push(args.join(' '));
|
|
96
|
+
showCurrentLayout();
|
|
97
|
+
console.log = origLog;
|
|
98
|
+
const output = logs.join('\n');
|
|
99
|
+
assert.ok(output.includes('Knowledgebase') || output.includes('knowledgebase'),
|
|
100
|
+
`should display knowledgebase, got: ${output}`);
|
|
101
|
+
assert.ok(output.includes('local'), `should display mode, got: ${output}`);
|
|
102
|
+
} finally {
|
|
103
|
+
cleanup();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─── CLI routing ───────────────────────────────────────────────────────────
|
|
108
|
+
console.log('\nCLI routing');
|
|
109
|
+
|
|
110
|
+
test('config command without subcommand exits with error', () => {
|
|
111
|
+
const { execFileSync } = require('child_process');
|
|
112
|
+
try {
|
|
113
|
+
execFileSync('node', [path.join(__dirname, '..', 'bin', 'cli.js'), 'config'], { stdio: 'pipe' });
|
|
114
|
+
assert.fail('should have exited with error');
|
|
115
|
+
} catch (e) {
|
|
116
|
+
assert.ok(e.status === 1, `should exit with code 1, got ${e.status}`);
|
|
117
|
+
assert.ok(e.stderr.toString().includes('Unknown config subcommand'), `should mention unknown subcommand`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('config layout --show does not modify files', () => {
|
|
122
|
+
const { tmpDir, cleanup } = withTmpDir();
|
|
123
|
+
try {
|
|
124
|
+
// Note: this just tests that the command runs without error in single-repo mode
|
|
125
|
+
const { execFileSync } = require('child_process');
|
|
126
|
+
const result = execFileSync('node', [
|
|
127
|
+
path.join(__dirname, '..', 'bin', 'cli.js'), 'config', 'layout', '--show'
|
|
128
|
+
], { stdio: 'pipe', cwd: tmpDir });
|
|
129
|
+
const output = result.toString();
|
|
130
|
+
assert.ok(output.includes('Single-repo') || output.includes('single-repo') || output.includes('.'),
|
|
131
|
+
`should display layout info, got: ${output}`);
|
|
132
|
+
} finally {
|
|
133
|
+
cleanup();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ─── Sequential async runner ───────────────────────────────────────────────
|
|
138
|
+
(async () => {
|
|
139
|
+
// Run async tests sequentially to avoid cwd conflicts
|
|
140
|
+
console.log('\nhandleConfigLayout --yes mode (sequential)');
|
|
141
|
+
|
|
142
|
+
// Test: --yes reconfigure writes updated config files
|
|
143
|
+
await asyncTest('--yes reconfigure writes updated config files', async () => {
|
|
144
|
+
const tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
145
|
+
const origCwd2 = process.cwd();
|
|
146
|
+
process.chdir(tmpDir2);
|
|
147
|
+
const origKb = process.env.MA_KNOWLEDGEBASE_PATH;
|
|
148
|
+
const origSp = process.env.MA_SPRINT_PATH;
|
|
149
|
+
const origKbGit = process.env.MA_KNOWLEDGEBASE_GIT_URL;
|
|
150
|
+
const origSpGit = process.env.MA_SPRINT_GIT_URL;
|
|
151
|
+
try {
|
|
152
|
+
const configDir = path.join(tmpDir2, '_bmad', 'bmm');
|
|
153
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
154
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\nknowledgebase_path: "."\nsprint_management_path: "."');
|
|
155
|
+
|
|
156
|
+
const kbPath = path.join(path.dirname(tmpDir2), 'kb-repo');
|
|
157
|
+
process.env.MA_KNOWLEDGEBASE_PATH = kbPath;
|
|
158
|
+
delete process.env.MA_SPRINT_PATH;
|
|
159
|
+
delete process.env.MA_KNOWLEDGEBASE_GIT_URL;
|
|
160
|
+
delete process.env.MA_SPRINT_GIT_URL;
|
|
161
|
+
|
|
162
|
+
await handleConfigLayout(['--yes']);
|
|
163
|
+
|
|
164
|
+
const config = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
165
|
+
assert.ok(config.includes('kb-repo'), `kb path should reference kb-repo, got: ${config}`);
|
|
166
|
+
assert.ok(fs.existsSync(path.join(tmpDir2, '_bmad-output', 'project-layout.yaml')),
|
|
167
|
+
'project-layout.yaml should be created');
|
|
168
|
+
} finally {
|
|
169
|
+
if (origKb !== undefined) process.env.MA_KNOWLEDGEBASE_PATH = origKb;
|
|
170
|
+
else delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
171
|
+
if (origSp !== undefined) process.env.MA_SPRINT_PATH = origSp;
|
|
172
|
+
else delete process.env.MA_SPRINT_PATH;
|
|
173
|
+
if (origKbGit !== undefined) process.env.MA_KNOWLEDGEBASE_GIT_URL = origKbGit;
|
|
174
|
+
else delete process.env.MA_KNOWLEDGEBASE_GIT_URL;
|
|
175
|
+
if (origSpGit !== undefined) process.env.MA_SPRINT_GIT_URL = origSpGit;
|
|
176
|
+
else delete process.env.MA_SPRINT_GIT_URL;
|
|
177
|
+
process.chdir(origCwd2);
|
|
178
|
+
fs.rmSync(tmpDir2, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Test: --yes preserves existing layout
|
|
183
|
+
await asyncTest('--yes preserves existing layout when no env vars', async () => {
|
|
184
|
+
const tmpDir3 = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
|
|
185
|
+
const origCwd3 = process.cwd();
|
|
186
|
+
process.chdir(tmpDir3);
|
|
187
|
+
const origKb = process.env.MA_KNOWLEDGEBASE_PATH;
|
|
188
|
+
const origSp = process.env.MA_SPRINT_PATH;
|
|
189
|
+
const origKbGit = process.env.MA_KNOWLEDGEBASE_GIT_URL;
|
|
190
|
+
const origSpGit = process.env.MA_SPRINT_GIT_URL;
|
|
191
|
+
try {
|
|
192
|
+
const configDir = path.join(tmpDir3, '_bmad', 'bmm');
|
|
193
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
194
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\nknowledgebase_path: "../kb"\nsprint_management_path: "."');
|
|
195
|
+
const outDir = path.join(tmpDir3, '_bmad-output');
|
|
196
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
197
|
+
fs.writeFileSync(path.join(outDir, 'project-layout.yaml'), 'knowledgebase:\n mode: local\n path: "../kb"\nsprint_management:\n mode: same\n path: "."');
|
|
198
|
+
|
|
199
|
+
delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
200
|
+
delete process.env.MA_SPRINT_PATH;
|
|
201
|
+
delete process.env.MA_KNOWLEDGEBASE_GIT_URL;
|
|
202
|
+
delete process.env.MA_SPRINT_GIT_URL;
|
|
203
|
+
|
|
204
|
+
await handleConfigLayout(['--yes']);
|
|
205
|
+
|
|
206
|
+
assert.ok(fs.existsSync(path.join(outDir, 'project-layout.yaml')),
|
|
207
|
+
'project-layout.yaml should still exist');
|
|
208
|
+
const config = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
|
|
209
|
+
assert.ok(config.includes('kb'), `kb path should be preserved, got: ${config}`);
|
|
210
|
+
} finally {
|
|
211
|
+
if (origKb !== undefined) process.env.MA_KNOWLEDGEBASE_PATH = origKb;
|
|
212
|
+
else delete process.env.MA_KNOWLEDGEBASE_PATH;
|
|
213
|
+
if (origSp !== undefined) process.env.MA_SPRINT_PATH = origSp;
|
|
214
|
+
else delete process.env.MA_SPRINT_PATH;
|
|
215
|
+
if (origKbGit !== undefined) process.env.MA_KNOWLEDGEBASE_GIT_URL = origKbGit;
|
|
216
|
+
else delete process.env.MA_KNOWLEDGEBASE_GIT_URL;
|
|
217
|
+
if (origSpGit !== undefined) process.env.MA_SPRINT_GIT_URL = origSpGit;
|
|
218
|
+
else delete process.env.MA_SPRINT_GIT_URL;
|
|
219
|
+
process.chdir(origCwd3);
|
|
220
|
+
fs.rmSync(tmpDir3, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
225
|
+
if (errors.length > 0) {
|
|
226
|
+
console.log('\nFailed tests:');
|
|
227
|
+
errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
|
|
228
|
+
}
|
|
229
|
+
if (failed > 0) process.exit(1);
|
|
230
|
+
})();
|