/** * Init wizard regression tests for #2206, #2207, #2208. * * #2206 — mcp-generator must register the server under the 'claude-flow' key * (not 'ruflo') so all plugins resolve as mcp__claude-flow__*. * #2207 — detectExistingRufloMCP must accept both 'claude-flow' and 'ruflo' keys; * a bare .claude/settings.json must NOT be a false positive. * #2208 — writeClaudeMd (--force) must back up an existing CLAUDE.md to * CLAUDE.md.pre-ruflo before overwriting it. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync, rmSync, readdirSync } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; // ─── #2206: mcp-generator server key ───────────────────────────────────────── import { generateMCPConfig, generateMCPCommands } from '../src/init/mcp-generator.js'; import type { InitOptions } from '../src/init/types.js'; import { DEFAULT_INIT_OPTIONS } from '../src/init/types.js'; function makeMCPOptions(overrides: Partial = {}): InitOptions { return { ...DEFAULT_INIT_OPTIONS, mcp: { claudeFlow: true, ruvSwarm: false, flowNexus: false, autoStart: false, port: 3000, }, runtime: { topology: 'hierarchical', maxAgents: 8, memoryBackend: 'hybrid', enableHNSW: true, enableNeural: true, }, ...overrides, }; } describe('#2206 — mcp-generator server key', () => { it('registers the ruflo MCP server under the claude-flow key', () => { const config = generateMCPConfig(makeMCPOptions()) as { mcpServers: Record }; expect(config.mcpServers).toHaveProperty('claude-flow'); }); it('does NOT register the server under the bare ruflo key', () => { const config = generateMCPConfig(makeMCPOptions()) as { mcpServers: Record }; expect(config.mcpServers).not.toHaveProperty('ruflo'); }); it('still invokes ruflo@latest mcp start as the command args', () => { const config = generateMCPConfig(makeMCPOptions()) as { mcpServers: Record }; const entry = config.mcpServers['claude-flow'] as { command: string; args: string[] }; expect(entry.args).toContain('ruflo@latest'); expect(entry.args).toContain('mcp'); expect(entry.args).toContain('start'); }); it('generateMCPCommands uses claude-flow as the registration name', () => { const cmds = generateMCPCommands(makeMCPOptions()); expect(cmds.length).toBeGreaterThan(0); // Every command that adds the main server must use 'claude-flow', not 'ruflo' const mainCmd = cmds.find(c => c.includes('ruflo@latest')); expect(mainCmd).toBeDefined(); expect(mainCmd).toMatch(/claude mcp add claude-flow/); expect(mainCmd).not.toMatch(/claude mcp add ruflo\b/); }); }); // ─── #2207: detectExistingRufloMCP — both keys + no false positive ─────────── // We test the function indirectly via its effect on writeMCPConfig behaviour. // Direct export is not available, so we exercise it through executeInit which // calls writeMCPConfig → detectExistingRufloMCP. // Mock heavy I/O that executeInit performs (skills/agents/helpers copy) so the // test only exercises the MCP-detection branch we care about. vi.mock('node:child_process', () => ({ execSync: vi.fn() })); describe('#2207 — init detector accepts both server keys', () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(path.join(tmpdir(), 'wizard-2207-')); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); it('detects a prior install registered under the new claude-flow key', async () => { // Write a parent .mcp.json with the 'claude-flow' key (post-#2206 install) const parentDir = mkdtempSync(path.join(tmpdir(), 'wizard-2207-parent-')); try { writeFileSync( path.join(parentDir, '.mcp.json'), JSON.stringify({ mcpServers: { 'claude-flow': { command: 'npx', args: ['-y', 'ruflo@latest', 'mcp', 'start'] } } }), ); // Target is a sub-directory so the parent .mcp.json is in the walk path const subDir = path.join(parentDir, 'sub'); mkdirSync(subDir, { recursive: true }); // Import executeInit dynamically so the tmp HOME is picked up const { executeInit } = await import('../src/init/executor.js'); const opts: InitOptions = { ...makeMCPOptions(), targetDir: subDir, force: false, interactive: false, components: { settings: false, skills: false, commands: false, agents: false, helpers: false, statusline: false, mcp: true, runtime: false, claudeMd: false, }, skipGlobalClaudeMd: true, }; const result = await executeInit(opts); // Because a 'claude-flow' key was found in the parent .mcp.json, init should // skip writing a new .mcp.json (duplicate detection). const skipMsg = result.skipped.find(s => s.includes('.mcp.json')); expect(skipMsg).toBeDefined(); } finally { rmSync(parentDir, { recursive: true, force: true }); } }); it('detects a prior install registered under the legacy ruflo key', async () => { const parentDir = mkdtempSync(path.join(tmpdir(), 'wizard-2207-legacy-')); try { writeFileSync( path.join(parentDir, '.mcp.json'), JSON.stringify({ mcpServers: { ruflo: { command: 'npx', args: ['-y', 'ruflo@latest', 'mcp', 'start'] } } }), ); const subDir = path.join(parentDir, 'sub'); mkdirSync(subDir, { recursive: true }); const { executeInit } = await import('../src/init/executor.js'); const opts: InitOptions = { ...makeMCPOptions(), targetDir: subDir, force: false, interactive: false, components: { settings: false, skills: false, commands: false, agents: false, helpers: false, statusline: false, mcp: true, runtime: false, claudeMd: false, }, skipGlobalClaudeMd: true, }; const result = await executeInit(opts); const skipMsg = result.skipped.find(s => s.includes('.mcp.json')); expect(skipMsg).toBeDefined(); } finally { rmSync(parentDir, { recursive: true, force: true }); } }); it('does NOT false-positive on a bare .claude/settings.json with no MCP registration', async () => { // Simulate Claude Code's own project settings.json (no MCP keys at all) const projectDir = mkdtempSync(path.join(tmpdir(), 'wizard-2207-fp-')); try { mkdirSync(path.join(projectDir, '.claude'), { recursive: true }); writeFileSync( path.join(projectDir, '.claude', 'settings.json'), JSON.stringify({ env: {}, hooks: {} }), ); const { executeInit } = await import('../src/init/executor.js'); const opts: InitOptions = { ...makeMCPOptions(), targetDir: projectDir, force: false, interactive: false, components: { settings: false, skills: false, commands: false, agents: false, helpers: false, statusline: false, mcp: true, runtime: false, claudeMd: false, }, skipGlobalClaudeMd: true, }; // Override HOME so we don't accidentally match the real user's ~/.claude.json const origHome = process.env.HOME; process.env.HOME = projectDir; try { const result = await executeInit(opts); // No pre-existing ruflo/claude-flow key → init must NOT skip .mcp.json const mcpSkipped = result.skipped.find(s => s.startsWith('.mcp.json') && !s.includes('existing')); expect(mcpSkipped).toBeUndefined(); } finally { process.env.HOME = origHome; } } finally { rmSync(projectDir, { recursive: true, force: true }); } }); }); // ─── #2208 — CLAUDE.md backup on --force ───────────────────────────────────── describe('#2208 — CLAUDE.md backup before overwrite', () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(path.join(tmpdir(), 'wizard-2208-')); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); it('backs up existing CLAUDE.md to CLAUDE.md.pre-ruflo when --force is used', async () => { const sentinel = '# Sentinel Project Content\nDo not lose this.\n'; writeFileSync(path.join(tmp, 'CLAUDE.md'), sentinel, 'utf-8'); const { executeInit } = await import('../src/init/executor.js'); const opts: InitOptions = { ...makeMCPOptions(), targetDir: tmp, force: true, interactive: false, components: { settings: false, skills: false, commands: false, agents: false, helpers: false, statusline: false, mcp: false, runtime: false, claudeMd: true, }, skipGlobalClaudeMd: true, }; await executeInit(opts); const backupPath = path.join(tmp, 'CLAUDE.md.pre-ruflo'); expect(existsSync(backupPath)).toBe(true); expect(readFileSync(backupPath, 'utf-8')).toBe(sentinel); }); it('uses a timestamped backup name when CLAUDE.md.pre-ruflo already exists', async () => { writeFileSync(path.join(tmp, 'CLAUDE.md'), '# First content\n', 'utf-8'); writeFileSync(path.join(tmp, 'CLAUDE.md.pre-ruflo'), '# Previous backup\n', 'utf-8'); const { executeInit } = await import('../src/init/executor.js'); const opts: InitOptions = { ...makeMCPOptions(), targetDir: tmp, force: true, interactive: false, components: { settings: false, skills: false, commands: false, agents: false, helpers: false, statusline: false, mcp: false, runtime: false, claudeMd: true, }, skipGlobalClaudeMd: true, }; await executeInit(opts); // The original .pre-ruflo must still have its old content expect(readFileSync(path.join(tmp, 'CLAUDE.md.pre-ruflo'), 'utf-8')).toBe('# Previous backup\n'); // A timestamped backup must have been created const entries = readdirSync(tmp); const timestamped = entries.find(e => e.startsWith('CLAUDE.md.pre-ruflo.') && e !== 'CLAUDE.md.pre-ruflo'); expect(timestamped).toBeDefined(); }); it('does NOT create a backup when no pre-existing CLAUDE.md exists', async () => { const { executeInit } = await import('../src/init/executor.js'); const opts: InitOptions = { ...makeMCPOptions(), targetDir: tmp, force: true, interactive: false, components: { settings: false, skills: false, commands: false, agents: false, helpers: false, statusline: false, mcp: false, runtime: false, claudeMd: true, }, skipGlobalClaudeMd: true, }; await executeInit(opts); expect(existsSync(path.join(tmp, 'CLAUDE.md.pre-ruflo'))).toBe(false); expect(existsSync(path.join(tmp, 'CLAUDE.md'))).toBe(true); }); it('skips CLAUDE.md entirely when it exists and --force is not set', async () => { const sentinel = '# My project\n'; writeFileSync(path.join(tmp, 'CLAUDE.md'), sentinel, 'utf-8'); const { executeInit } = await import('../src/init/executor.js'); const opts: InitOptions = { ...makeMCPOptions(), targetDir: tmp, force: false, interactive: false, components: { settings: false, skills: false, commands: false, agents: false, helpers: false, statusline: false, mcp: false, runtime: false, claudeMd: true, }, skipGlobalClaudeMd: true, }; const result = await executeInit(opts); // Must skip, not overwrite expect(result.skipped).toContain('CLAUDE.md'); // Original must be untouched expect(readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf-8')).toBe(sentinel); expect(existsSync(path.join(tmp, 'CLAUDE.md.pre-ruflo'))).toBe(false); }); });