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
This commit is contained in:
Magal 2026-05-19 17:51:41 -03:00
parent bf572f945f
commit 58f073ebdc
3 changed files with 585 additions and 0 deletions

113
install-bmad-memtrace.sh Normal file
View File

@ -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."

View File

@ -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);
});

214
test/verify-installer.js Normal file
View File

@ -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);
});