diff --git a/src/__tests__/e2e/mcp-uninstall.test.ts b/src/__tests__/e2e/mcp-uninstall.test.ts new file mode 100644 index 00000000..79a8316c --- /dev/null +++ b/src/__tests__/e2e/mcp-uninstall.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// ─── MCP uninstall end-to-end ───────────────────────────────── +// +// Offline fixture (no TEAMAI_TEST_TOKEN). Drives the real CLI binary: +// mcp inject → servers land in ~/.claude.json +// uninstall → teamai-managed servers are removed; hand-added ones stay +// +// Catches the order bug where removeAll ran after ~/.teamai/ (and thus +// managed-mcp.json) had already been deleted. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +interface RunResult { + code: number | null; + output: string; +} + +function runCLI(args: string[], env: Record, cwd: string): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + env: { ...process.env, FORCE_COLOR: '0', ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + cwd, + }); + let out = ''; + child.stdout.on('data', (d: Buffer) => { out += d.toString(); }); + child.stderr.on('data', (d: Buffer) => { out += d.toString(); }); + child.stdin.end(); + child.on('close', (code) => resolve({ code, output: out })); + }); +} + +const TEAM_YAML = [ + 'team: mcp-uninstall-e2e', + 'repo: https://example.com/mcp-e2e.git', + 'provider: tgit', + 'toolPaths:', + ' claude:', + ' skills: .claude/skills', + ' rules: .claude/rules', + ' settings: .claude/settings.json', + ' claudemd: .claude/CLAUDE.md', + ' mcp: .claude.json', + ' mcpProject: .mcp.json', +].join('\n'); + +const MCP_YAML = [ + 'servers:', + ' - name: team-mcp', + ' transport: http', + ' url: https://example.com/api/mcp', + ' tools: [claude]', +].join('\n'); + +describe('MCP uninstall (e2e)', () => { + let sandbox: string; + let homeDir: string; + let claudeJson: string; + let teamaiHome: string; + + beforeAll(() => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-mcp-uninstall-e2e-')); + homeDir = path.join(sandbox, 'home'); + teamaiHome = path.join(homeDir, '.teamai'); + claudeJson = path.join(homeDir, '.claude.json'); + + fs.mkdirSync(path.join(homeDir, '.claude', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(homeDir, '.claude', 'rules'), { recursive: true }); + + // Local team-repo (no git remote needed — inject/uninstall only read files). + const repoLocal = path.join(teamaiHome, 'team-repo'); + fs.mkdirSync(path.join(repoLocal, 'mcp'), { recursive: true }); + fs.writeFileSync(path.join(repoLocal, 'teamai.yaml'), TEAM_YAML); + fs.writeFileSync(path.join(repoLocal, 'mcp', 'mcp.yaml'), MCP_YAML); + + fs.writeFileSync( + path.join(teamaiHome, 'config.yaml'), + [ + 'repo:', + ` localPath: ${repoLocal}`, + ' remote: https://example.com/mcp-e2e.git', + 'username: e2e-user', + 'updatePolicy: auto', + 'scope: user', + ].join('\n'), + ); + + // Hand-added server that uninstall must leave alone. + fs.writeFileSync( + claudeJson, + JSON.stringify({ mcpServers: { 'my-own': { command: 'my-server' } } }, null, 2), + ); + }, 30_000); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('injects a team MCP server, then uninstall removes it and keeps user servers', async () => { + const env = { HOME: homeDir }; + + const inject = await runCLI(['mcp', 'inject'], env, homeDir); + expect(inject.code).toBe(0); + expect(inject.output).toMatch(/added\s+claude\/team-mcp/); + + const afterInject = JSON.parse(fs.readFileSync(claudeJson, 'utf-8')); + expect(afterInject.mcpServers['team-mcp']).toEqual({ + type: 'http', + url: 'https://example.com/api/mcp', + }); + expect(afterInject.mcpServers['my-own']).toEqual({ command: 'my-server' }); + expect(fs.existsSync(path.join(teamaiHome, 'managed-mcp.json'))).toBe(true); + + const uninstall = await runCLI(['uninstall', '--force'], env, homeDir); + expect(uninstall.code).toBe(0); + expect(uninstall.output).toMatch(/MCP server/i); + + const afterUninstall = JSON.parse(fs.readFileSync(claudeJson, 'utf-8')); + expect(afterUninstall.mcpServers['team-mcp']).toBeUndefined(); + expect(afterUninstall.mcpServers['my-own']).toEqual({ command: 'my-server' }); + expect(fs.existsSync(teamaiHome)).toBe(false); + }, 60_000); +}); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index dd6b469f..1591fc53 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -239,6 +239,53 @@ describe('uninstall', () => { expect(await fse.pathExists(teamaiHome)).toBe(false); }); + // Regression: MCP cleanup used to run after ~/.teamai/ was deleted, so the + // ownership manifest was already gone and removeAll became a no-op. + it('卸载时移除 teamai 管理的 MCP server,并保留用户自建的', async () => { + const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + await fse.writeJson(path.join(homeDir, '.claude.json'), { + mcpServers: { + 'team-mcp': { type: 'http', url: 'https://team.example/mcp' }, + 'my-own': { command: 'my-server' }, + }, + }); + await fse.writeJson(path.join(teamaiHome, 'managed-mcp.json'), { + claude: [{ name: 'team-mcp', hash: 'abc' }], + }); + + const teamConfig = makeTeamConfig({ + toolPaths: { + claude: { + skills: '.claude/skills', + rules: '.claude/rules', + settings: '.claude/settings.json', + claudemd: '.claude/CLAUDE.md', + mcp: '.claude.json', + mcpProject: '.mcp.json', + }, + }, + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: `${teamaiHome}/docs` }, + env: { injectShellProfile: true }, + }, + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(homeDir, repoPath), + teamConfig, + }); + + await uninstall({ force: true }); + + const after = await fse.readJson(path.join(homeDir, '.claude.json')); + expect(after.mcpServers['team-mcp']).toBeUndefined(); + expect(after.mcpServers['my-own']).toEqual({ command: 'my-server' }); + }); + it('移除 OpenClaw 系 agent 的 HOOK.md 目录(无 settings 路径)', async () => { const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/uninstall.ts b/src/uninstall.ts index 909122c5..4378d81c 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -541,19 +541,20 @@ export async function uninstall(opts: UninstallOptions): Promise { } } - await executeRemoval(plan); - - // MCP servers live in tool-owned config files tracked by their own manifest, - // so they are cleaned up through the reconcile engine rather than the plan. + // MCP cleanup must run before executeRemoval deletes ~/.teamai/: ownership is + // tracked in managed-mcp.json inside that directory. Hooks already do this + // inside executeRemoval for the same reason. try { const { reconcileMcpForConfig } = await import('./mcp-reconcile.js'); const { changes } = await reconcileMcpForConfig(teamConfig, localConfig, { removeAll: true }); const removed = changes.filter((c) => c.action === 'removed'); - if (removed.length > 0) log.info(`移除 ${removed.length} 个 teamai 管理的 MCP server`); + if (removed.length > 0) log.info(`Removed ${removed.length} teamai-managed MCP server(s)`); } catch (e) { - log.warn(`移除 MCP server 失败: ${(e as Error).message}`); + log.warn(`Failed to remove MCP servers: ${(e as Error).message}`); } + await executeRemoval(plan); + log.success('teamai 卸载完成'); } else { // Minimal uninstall — just try to remove ~/.teamai/