From 58f073ebdc360e152b7eb6a123f6d12d50373a0e Mon Sep 17 00:00:00 2001 From: Magal Date: Tue, 19 May 2026 17:51:41 -0300 Subject: [PATCH] feat(installer): add memtrace bootstrap installer and test suite (Epic 1) - Add install-bmad-memtrace.sh: aggressive cleanup, .memtrace-workspace generation, MCP JSON config injection - Add test/test-inject-mcp-config.js: MCP config injection tests - Add test/verify-installer.js: installer integration verification tests --- install-bmad-memtrace.sh | 113 +++++++++++++++ test/test-inject-mcp-config.js | 258 +++++++++++++++++++++++++++++++++ test/verify-installer.js | 214 +++++++++++++++++++++++++++ 3 files changed, 585 insertions(+) create mode 100644 install-bmad-memtrace.sh create mode 100644 test/test-inject-mcp-config.js create mode 100644 test/verify-installer.js diff --git a/install-bmad-memtrace.sh b/install-bmad-memtrace.sh new file mode 100644 index 000000000..0d1cf6911 --- /dev/null +++ b/install-bmad-memtrace.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# install-bmad-memtrace.sh +# Installation script for BMad Memtrace Integration +# Aggressively cleans up the repository clone, preserving only necessary files. + +# --- Interactive mode selection --- +# First step: confirm the user wants Memtrace (not Vanilla BMad). +while true; do + echo "" + echo "============================================" + echo " BMad-Memtrace Installation" + echo "============================================" + echo "" + echo "This installer sets up the Memtrace-integrated fork of BMad Method." + echo "" + read -r -p "Choose mode [Memtrace / Vanilla]: " mode_choice || { echo "Error: Unexpected end of input. Aborting."; exit 1; } + mode_choice=$(printf '%s' "$mode_choice" | tr '[:upper:]' '[:lower:]') + case "$mode_choice" in + memtrace) + echo "" + echo "Proceeding with Memtrace-integrated installation..." + echo "" + break + ;; + vanilla) + echo "" + echo "You selected Vanilla BMad Method." + echo "This fork includes Memtrace structural analysis integration." + echo "For the official BMad Method without Memtrace, clone:" + echo " https://github.com/bmad-code-org/BMAD-METHOD.git" + echo "" + echo "Installation aborted. No files were modified." + exit 0 + ;; + *) + echo "" + echo "Invalid choice: '${mode_choice}'. Please type 'Memtrace' or 'Vanilla'." + echo "" + ;; + esac +done + +set -e + +echo "Starting BMad Memtrace standalone environment setup..." + +INSTALL_DIR="bmad-install" + +# Create a safe staging directory +echo "Creating staging directory: $INSTALL_DIR" +mkdir -p "$INSTALL_DIR" + +# Move essential files to the staging directory +echo "Copying core files to staging directory..." +[ -d "_bmad" ] && cp -a _bmad "$INSTALL_DIR/" +[ -d ".agents" ] && cp -a .agents "$INSTALL_DIR/" +[ -f "package.json" ] && cp -a package.json "$INSTALL_DIR/" +# Also copy docs if present +[ -d "docs" ] && cp -a docs "$INSTALL_DIR/" + +# Remove explicit non-essential bmad cloned files and .git +echo "Cleaning up legacy clone files via git index..." +if [ -d .git ] && command -v git &> /dev/null; then + # Use git to get the exact list of cloned files to guarantee precision + git ls-files | while IFS= read -r file; do + if [ "$file" != "install-bmad-memtrace.sh" ]; then + rm -f "$file" + fi + done +else + # Fallback if git is not available + rm -f README.md LICENSE .gitignore .eslintrc.json tsconfig.json webpack.config.js || true + rm -rf _bmad .agents package.json docs || true +fi + +echo "Removing .git directory..." +rm -rf .git + +# Cleanup any empty directories left behind +find . -type d -empty -delete 2>/dev/null || true + +# Copy files back to the root of the project +echo "Restoring core files to root..." +# We use * to avoid copying the script over itself if it were in the staging dir, +# though we intentionally didn't stage it this time to prevent 'Text file busy' issues. +cp -a "$INSTALL_DIR"/* . 2>/dev/null || true + +# Remove staging directory +echo "Removing staging directory..." +rm -rf "$INSTALL_DIR" + +# Generate local workspace anchor file (.memtrace-workspace) to prevent 0-nodes errors +echo "Generating workspace anchor file..." +if [ ! -f .memtrace-workspace ]; then + touch .memtrace-workspace + echo "Created .memtrace-workspace anchor." +else + echo ".memtrace-workspace anchor already exists." +fi + +# Configure local MCP servers in Claude Desktop and OpenCode +if command -v node &> /dev/null; then + echo "Configuring local MCP servers..." + node _bmad/scripts/memtrace/inject-mcp-config.mjs --mode claude || echo "Warning: Failed to configure Claude Desktop config." + node _bmad/scripts/memtrace/inject-mcp-config.mjs --mode opencode || echo "Warning: Failed to configure OpenCode config." +else + echo "Warning: node command not found. Skipping MCP config injection." + echo "To configure MCP manually, please run:" + echo " node _bmad/scripts/memtrace/inject-mcp-config.mjs --mode claude" + echo " node _bmad/scripts/memtrace/inject-mcp-config.mjs --mode opencode" +fi + +echo "Cleanup and configuration complete! You now have a clean, standalone BMad-Memtrace runtime environment." diff --git a/test/test-inject-mcp-config.js b/test/test-inject-mcp-config.js new file mode 100644 index 000000000..c260d2647 --- /dev/null +++ b/test/test-inject-mcp-config.js @@ -0,0 +1,258 @@ +/** + * Test Suite for inject-mcp-config.mjs + * + * Verifies that the MCP server injector works correctly for both Claude Desktop + * and OpenCode configurations under different initial file states. + * + * Usage: node test/test-inject-mcp-config.js + */ + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { exec } = require('node:child_process'); + +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + yellow: '\u001B[33m', + cyan: '\u001B[36m', + dim: '\u001B[2m', +}; + +let passed = 0; +let failed = 0; + +function assert(condition, testName, errorMessage = '') { + if (condition) { + console.log(`${colors.green}✓${colors.reset} ${testName}`); + passed++; + } else { + console.log(`${colors.red}✗${colors.reset} ${testName}`); + if (errorMessage) { + console.log(` ${colors.dim}${errorMessage}${colors.reset}`); + } + failed++; + } +} + +// Executes the injector script in a subprocess with custom env variables +function runInjector(mode, envOverrides = {}) { + return new Promise((resolve, reject) => { + const scriptPath = path.resolve(__dirname, '../_bmad/scripts/memtrace/inject-mcp-config.mjs'); + const command = `node "${scriptPath}" --mode ${mode}`; + + exec(command, { + env: { ...process.env, ...envOverrides } + }, (error, stdout, stderr) => { + if (error) { + reject({ error, stdout, stderr }); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +async function runTests() { + console.log(`${colors.cyan}========================================`); + console.log('MCP Config Injector Unit Tests'); + console.log(`========================================${colors.reset}\n`); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-injector-test-')); + + // ============================================================ + // Test Suite 1: Claude Desktop configuration injection + // ============================================================ + console.log(`${colors.yellow}Test Suite 1: Claude Desktop Configuration${colors.reset}\n`); + + const claudeTestFile = path.join(tempDir, 'claude_desktop_config.json'); + + // Test 1.1: File does not exist (creates skeleton) + try { + await runInjector('claude', { TEST_CLAUDE_CONFIG_PATH: claudeTestFile }); + const content = await fs.readFile(claudeTestFile, 'utf8'); + const config = JSON.parse(content); + + assert( + config.mcpServers !== undefined && config.mcpServers.memtrace !== undefined, + 'Test 1.1: Creates new Claude config file and injects memtrace server skeleton' + ); + assert( + config.mcpServers.memtrace.command === 'memtrace' && config.mcpServers.memtrace.args[0] === 'mcp', + 'Test 1.1: Injected server details match expected schema format' + ); + } catch (err) { + assert(false, 'Test 1.1 Failed with error', err.message || JSON.stringify(err)); + } + + // Test 1.2: File exists with other servers (preserves other servers) + try { + const preExistingConfig = { + mcpServers: { + otherServer: { + command: 'node', + args: ['other-path/server.js'] + } + } + }; + await fs.writeFile(claudeTestFile, JSON.stringify(preExistingConfig, null, 2), 'utf8'); + + await runInjector('claude', { TEST_CLAUDE_CONFIG_PATH: claudeTestFile }); + const content = await fs.readFile(claudeTestFile, 'utf8'); + const config = JSON.parse(content); + + assert( + config.mcpServers.otherServer !== undefined && config.mcpServers.otherServer.command === 'node', + 'Test 1.2: Preserves pre-existing mcpServers in Claude config' + ); + assert( + config.mcpServers.memtrace !== undefined && config.mcpServers.memtrace.command === 'memtrace', + 'Test 1.2: Correctly appends memtrace server configuration alongside existing ones' + ); + } catch (err) { + assert(false, 'Test 1.2 Failed with error', err.message || JSON.stringify(err)); + } + + // Test 1.3: File exists and memtrace key already exists (overwrites memtrace key only) + try { + const preExistingConfig = { + mcpServers: { + otherServer: { + command: 'node', + args: ['other-path/server.js'] + }, + memtrace: { + command: 'old-command', + args: ['old-arg'] + } + } + }; + await fs.writeFile(claudeTestFile, JSON.stringify(preExistingConfig, null, 2), 'utf8'); + + await runInjector('claude', { TEST_CLAUDE_CONFIG_PATH: claudeTestFile }); + const content = await fs.readFile(claudeTestFile, 'utf8'); + const config = JSON.parse(content); + + assert( + config.mcpServers.otherServer !== undefined && config.mcpServers.otherServer.command === 'node', + 'Test 1.3: Overwriting preserves other servers' + ); + assert( + config.mcpServers.memtrace.command === 'memtrace' && config.mcpServers.memtrace.args[0] === 'mcp', + 'Test 1.3: Correctly overwrites only the memtrace key' + ); + } catch (err) { + assert(false, 'Test 1.3 Failed with error', err.message || JSON.stringify(err)); + } + + console.log(''); + + // ============================================================ + // Test Suite 2: OpenCode configuration injection + // ============================================================ + console.log(`${colors.yellow}Test Suite 2: OpenCode Configuration${colors.reset}\n`); + + const opencodeTestFile = path.join(tempDir, 'opencode.json'); + + // Test 2.1: File does not exist (creates skeleton) + try { + await runInjector('opencode', { TEST_OPENCODE_CONFIG_PATH: opencodeTestFile }); + const content = await fs.readFile(opencodeTestFile, 'utf8'); + const config = JSON.parse(content); + + assert( + config.mcp !== undefined && config.mcp.memtrace !== undefined, + 'Test 2.1: Creates new OpenCode config file and injects memtrace server skeleton' + ); + assert( + config.mcp.memtrace.type === 'local' && config.mcp.memtrace.command[0] === 'memtrace' && config.mcp.memtrace.command[1] === 'mcp', + 'Test 2.1: Injected server details match expected OpenCode schema format' + ); + } catch (err) { + assert(false, 'Test 2.1 Failed with error', err.message || JSON.stringify(err)); + } + + // Test 2.2: File exists with other keys (preserves other keys) + try { + const preExistingConfig = { + mcp: { + otherServer: { + type: 'local', + command: ['other-server'] + } + } + }; + await fs.writeFile(opencodeTestFile, JSON.stringify(preExistingConfig, null, 2), 'utf8'); + + await runInjector('opencode', { TEST_OPENCODE_CONFIG_PATH: opencodeTestFile }); + const content = await fs.readFile(opencodeTestFile, 'utf8'); + const config = JSON.parse(content); + + assert( + config.mcp.otherServer !== undefined && config.mcp.otherServer.type === 'local', + 'Test 2.2: Preserves pre-existing mcp in OpenCode config' + ); + assert( + config.mcp.memtrace !== undefined && config.mcp.memtrace.type === 'local', + 'Test 2.2: Correctly appends memtrace server configuration alongside existing ones' + ); + } catch (err) { + assert(false, 'Test 2.2 Failed with error', err.message || JSON.stringify(err)); + } + + // Test 2.3: File exists and memtrace key already exists (overwrites memtrace key only) + try { + const preExistingConfig = { + mcp: { + otherServer: { + type: 'local', + command: ['other-server'] + }, + memtrace: { + type: 'remote', + command: ['old-memtrace'] + } + } + }; + await fs.writeFile(opencodeTestFile, JSON.stringify(preExistingConfig, null, 2), 'utf8'); + + await runInjector('opencode', { TEST_OPENCODE_CONFIG_PATH: opencodeTestFile }); + const content = await fs.readFile(opencodeTestFile, 'utf8'); + const config = JSON.parse(content); + + assert( + config.mcp.otherServer !== undefined && config.mcp.otherServer.type === 'local', + 'Test 2.3: Overwriting preserves other OpenCode servers' + ); + assert( + config.mcp.memtrace.type === 'local' && config.mcp.memtrace.command[0] === 'memtrace', + 'Test 2.3: Correctly overwrites only the memtrace key in OpenCode config' + ); + } catch (err) { + assert(false, 'Test 2.3 Failed with error', err.message || JSON.stringify(err)); + } + + // Clean up + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (err) { + // Ignore cleanup errors + } + + console.log(`\n${colors.cyan}========================================`); + console.log(`Tests Run Summary: Passed: ${passed}, Failed: ${failed}`); + console.log(`========================================${colors.reset}\n`); + + if (failed > 0) { + process.exit(1); + } else { + process.exit(0); + } +} + +runTests().catch(err => { + console.error('Fatal test error:', err); + process.exit(1); +}); diff --git a/test/verify-installer.js b/test/verify-installer.js new file mode 100644 index 000000000..808b7f464 --- /dev/null +++ b/test/verify-installer.js @@ -0,0 +1,214 @@ +/** + * Verification Test for install-bmad-memtrace.sh + * + * Simulates the installation process in a clean temporary directory by: + * 1. Initializing a mock Git repository. + * 2. Committing some dummy "legacy clone" files. + * 3. Adding the install script and helper script fixtures. + * 4. Running the bash installer. + * 5. Verifying cleanup, restore, workspace anchor generation, and MCP config injection. + * + * Usage: node test/verify-installer.js + */ + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { exec } = require('node:child_process'); + +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + yellow: '\u001B[33m', + cyan: '\u001B[36m', + dim: '\u001B[2m', +}; + +let passed = 0; +let failed = 0; + +function assert(condition, testName, errorMessage = '') { + if (condition) { + console.log(`${colors.green}✓${colors.reset} ${testName}`); + passed++; + } else { + console.log(`${colors.red}✗${colors.reset} ${testName}`); + if (errorMessage) { + console.log(` ${colors.dim}${errorMessage}${colors.reset}`); + } + failed++; + } +} + +function runCmd(command, cwd) { + return new Promise((resolve, reject) => { + exec(command, { cwd }, (error, stdout, stderr) => { + if (error) { + reject({ error, stdout, stderr }); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +// Helper to ensure parent directories exist +async function ensureDir(filePath) { + const dir = path.dirname(filePath); + try { + await fs.mkdir(dir, { recursive: true }); + } catch (err) {} +} + +async function runVerification() { + console.log(`${colors.cyan}========================================`); + console.log('Standalone Installer Verification'); + console.log(`========================================${colors.reset}\n`); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-installer-verify-')); + console.log(`Created temporary test environment at: ${tempDir}\n`); + + try { + // 1. Initialize git repository + await runCmd('git init -b main', tempDir); + await runCmd('git config user.name "Test"', tempDir); + await runCmd('git config user.email "test@example.com"', tempDir); + + // 2. Create and commit dummy cloned files (to simulate legacy clone cleanup) + const dummyClonedFile = path.join(tempDir, 'README.md'); + await fs.writeFile(dummyClonedFile, '# Legacy README\n'); + await runCmd('git add README.md', tempDir); + await runCmd('git commit -m "initial commit"', tempDir); + + // 3. Create core BMad directory structure and helper script + const helperSourcePath = path.resolve(__dirname, '../_bmad/scripts/memtrace/inject-mcp-config.mjs'); + const helperDestPath = path.join(tempDir, '_bmad/scripts/memtrace/inject-mcp-config.mjs'); + await ensureDir(helperDestPath); + await fs.copyFile(helperSourcePath, helperDestPath); + + // Also copy install script + const installerSourcePath = path.resolve(__dirname, '../install-bmad-memtrace.sh'); + const installerDestPath = path.join(tempDir, 'install-bmad-memtrace.sh'); + await fs.copyFile(installerSourcePath, installerDestPath); + + // 4. Run installer script in bash + // We override TEST_CLAUDE_CONFIG_PATH and TEST_OPENCODE_CONFIG_PATH so it targets temp config files + const claudeTestConfig = path.join(tempDir, 'claude_desktop_config.json'); + const opencodeTestConfig = path.join(tempDir, 'opencode.json'); + + console.log('Running install-bmad-memtrace.sh in temp directory...'); + const envOverrides = { + ...process.env, + TEST_CLAUDE_CONFIG_PATH: claudeTestConfig, + TEST_OPENCODE_CONFIG_PATH: opencodeTestConfig, + }; + + // Run bash on Windows (locate git-bash path to avoid WSL-relay failures) + let command = 'bash install-bmad-memtrace.sh'; + if (process.platform === 'win32') { + try { + const { execSync } = require('node:child_process'); + const gitPath = execSync('where git').toString().trim().split('\r\n')[0]; + if (gitPath) { + const gitDir = path.dirname(gitPath); // C:\Program Files\Git\cmd + const gitParent = path.dirname(gitDir); + const possibleBash1 = path.join(gitParent, 'bin', 'bash.exe'); + const possibleBash2 = path.join(gitParent, 'bin', 'sh.exe'); + + if (await fs.access(possibleBash1).then(() => true).catch(() => false)) { + command = `"${possibleBash1}" install-bmad-memtrace.sh`; + } else if (await fs.access(possibleBash2).then(() => true).catch(() => false)) { + command = `"${possibleBash2}" install-bmad-memtrace.sh`; + } + } + } catch (e) { + // Fallback + const paths = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + ]; + for (const p of paths) { + if (await fs.access(p).then(() => true).catch(() => false)) { + command = `"${p}" install-bmad-memtrace.sh`; + break; + } + } + } + } + + console.log(`Running installer with command: ${command}`); + const result = await new Promise((resolve, reject) => { + exec(command, { cwd: tempDir, env: envOverrides }, (error, stdout, stderr) => { + if (error) reject({ error, stdout, stderr }); + else resolve({ stdout, stderr }); + }); + }); + + console.log(`${colors.dim}${result.stdout}${colors.reset}\n`); + + // 5. Assertions + + // AC 1: .memtrace-workspace exists + const anchorExists = await fs.access(path.join(tempDir, '.memtrace-workspace')).then(() => true).catch(() => false); + assert(anchorExists, 'AC 1: .memtrace-workspace anchor file successfully created in project root'); + + // Legacy cleanup: README.md is deleted + const readmeExists = await fs.access(dummyClonedFile).then(() => true).catch(() => false); + assert(!readmeExists, 'Legacy cleanup: Tracked clone files (README.md) successfully deleted'); + + // Git removal: .git is deleted + const gitExists = await fs.access(path.join(tempDir, '.git')).then(() => true).catch(() => false); + assert(!gitExists, 'Security/Standalone: .git directory completely removed'); + + // Staging cleanup: bmad-install is deleted + const stagingExists = await fs.access(path.join(tempDir, 'bmad-install')).then(() => true).catch(() => false); + assert(!stagingExists, 'Runtime cleanup: bmad-install staging directory successfully removed'); + + // BMad preservation: _bmad directory remains + const bmadExists = await fs.access(path.join(tempDir, '_bmad')).then(() => true).catch(() => false); + assert(bmadExists, 'Core preservation: _bmad directory preserved post-cleanup'); + + // AC 2: Claude Desktop configuration successfully injected + const claudeContent = await fs.readFile(claudeTestConfig, 'utf8'); + const claudeConfig = JSON.parse(claudeContent); + assert( + claudeConfig.mcpServers && claudeConfig.mcpServers.memtrace && claudeConfig.mcpServers.memtrace.command === 'memtrace', + 'AC 2: Claude Desktop config correctly created and populated with memtrace MCP server' + ); + + // AC 3: OpenCode configuration successfully injected + const opencodeContent = await fs.readFile(opencodeTestConfig, 'utf8'); + const opencodeConfig = JSON.parse(opencodeContent); + assert( + opencodeConfig.mcp && opencodeConfig.mcp.memtrace && opencodeConfig.mcp.memtrace.type === 'local', + 'AC 3: OpenCode config correctly created and populated with memtrace local definition' + ); + + } catch (err) { + console.error('Verification failed with error:', err); + if (err.stdout) console.error('stdout:', err.stdout); + if (err.stderr) console.error('stderr:', err.stderr); + failed++; + } + + // Clean up + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (err) {} + + console.log(`\n${colors.cyan}========================================`); + console.log(`Verification Summary: Passed: ${passed}, Failed: ${failed}`); + console.log(`========================================${colors.reset}\n`); + + if (failed > 0) { + process.exit(1); + } else { + process.exit(0); + } +} + +runVerification().catch(err => { + console.error('Fatal verification error:', err); + process.exit(1); +});