/** * @file Gives a lane the same MCP servers as its source repo: reads the * source repo's already-configured mcpServers from ~/.claude.json (normal * Claude Code project-scope config — the human sets this up once, the same * way they would for any project), relocates any absolute path under the * source repo to the lane's own directory, pins a Playwright server's proof * output dir, and writes /.mcp.json. Also seeds the lane's * Chromium browser profiles from the source repo's own (preserves saved * logins — a QA account only needs to log in once per machine). * * Deliberately does NOT write any permission or settings.local.json content * — see the F1 design spec's Scope section for why. Plain file I/O; no git * calls, unlike lane-sync.js (E2) or lane-agents.js (E3). * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); /** Read the source repo's mcpServers from ~/.claude.json. Throws * ENOMCPCONFIG if the file is missing or the project has none declared — * a lane with zero MCP servers synced would silently break Stage 3/6 much * later, at a far less debuggable point, so this fails loud and early. */ function readSourceMcpServers(sourceRepo) { const claudeJsonPath = path.join(os.homedir(), ".claude.json"); let cfg; try { cfg = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8")); } catch { cfg = null; } const servers = cfg?.projects?.[sourceRepo]?.mcpServers; if (!servers || !Object.keys(servers).length) { throw Object.assign( new Error( `no mcpServers configured for source repo ${sourceRepo} in ~/.claude.json — ` + `configure them there first (see the ship-feature-lane skill's Setup section)` ), { code: "ENOMCPCONFIG" } ); } return servers; } /** Deep-walk a server config, replacing every occurrence of `sourceRepo` * inside a string with `laneDir`. Strings, arrays, and plain objects only * — an MCP server def never contains anything else. */ function relocate(value, sourceRepo, laneDir) { if (typeof value === "string") return value.split(sourceRepo).join(laneDir); if (Array.isArray(value)) return value.map((v) => relocate(v, sourceRepo, laneDir)); if (value && typeof value === "object") { const out = {}; for (const [k, v] of Object.entries(value)) out[k] = relocate(v, sourceRepo, laneDir); return out; } return value; } /** Pin --output-dir for a @playwright/mcp server that doesn't already * declare one, so relative proof filenames (proof//...) always * land under /.playwright-mcp — the one place the dashboard's * proof gallery reads. */ function pinPlaywrightOutputDir(servers, laneDir) { for (const server of Object.values(servers)) { const args = server.args; if (!Array.isArray(args)) continue; const isPlaywright = args.some((a) => typeof a === "string" && a.startsWith("@playwright/mcp")); if (isPlaywright && !args.includes("--output-dir")) { server.args = [...args, "--output-dir", path.join(laneDir, ".playwright-mcp")]; } } } /** Copy each /.playwright-mcp/profiles// into the lane * ONLY if that name doesn't already exist there — an existing profile * means a session already logged in with it; never clobber that. Strips * Singleton* lock files from the freshly-seeded copy (stale locks from the * source's own last browser process would make the lane's browser refuse * to start, thinking another instance already holds the profile). */ function seedProfiles(sourceRepo, laneDir) { const srcProfiles = path.join(sourceRepo, ".playwright-mcp", "profiles"); if (!fs.existsSync(srcProfiles)) return []; const seeded = []; for (const name of fs.readdirSync(srcProfiles)) { const src = path.join(srcProfiles, name); if (!fs.statSync(src).isDirectory()) continue; const dest = path.join(laneDir, ".playwright-mcp", "profiles", name); if (fs.existsSync(dest)) continue; fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.cpSync(src, dest, { recursive: true }); for (const entry of fs.readdirSync(dest)) { if (entry.startsWith("Singleton")) fs.rmSync(path.join(dest, entry), { force: true }); } seeded.push(name); } return seeded; } /** Idempotently append a line to /.git/info/exclude — same * read-existing-then-append-if-missing pattern E2/E3 already use for * .git/info/attributes and .git/info/exclude. .mcp.json sits at the lane * root (not inside .git), so this needs no --git-common-dir resolution — * the exclude file itself is always local to this lane's own working copy. */ function excludeFromGit(laneDir, line) { const excludePath = path.join(laneDir, ".git", "info", "exclude"); fs.mkdirSync(path.dirname(excludePath), { recursive: true }); const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; const lines = existing.split("\n").filter(Boolean); if (!lines.includes(line)) { lines.push(line); fs.writeFileSync(excludePath, lines.join("\n") + "\n"); } } /** * @param {{cwd: string, source_repo: string}} lane * @returns {{servers: string[], profilesSeeded: string[]}} */ async function syncMcp(lane) { const sourceRepo = lane.source_repo || lane.cwd; const sourceServers = readSourceMcpServers(sourceRepo); const relocated = relocate(sourceServers, sourceRepo, lane.cwd); pinPlaywrightOutputDir(relocated, lane.cwd); fs.writeFileSync( path.join(lane.cwd, ".mcp.json"), JSON.stringify({ mcpServers: relocated }, null, 2) + "\n" ); excludeFromGit(lane.cwd, ".mcp.json"); const profilesSeeded = seedProfiles(sourceRepo, lane.cwd); return { servers: Object.keys(relocated), profilesSeeded }; } module.exports = { syncMcp };