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/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
- return template.replace('{{MANIFEST_PATHS_LIST}}', manifestList);
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.0.0",
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
+ })();