952e8584f0
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js) cuts git's fresh hunk down to it, folding the other blocks into the side being checked; LaneDiffView hangs one review bar per block (unified + split) and its arrows walk blocks. - git exclude: entries AgentHub writes for a lane go to the COMMON git dir with the lane's path prefix (server/lib/git-exclude.js, used by router-apply and lane-mcp) — a linked worktree's own info/exclude is never read by git, and a subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree lane or creates a stray .git in a non-repo lane. - codex lanes carry no pipeline: codex-ingest mirrors only idle/running, codex-nudge is removed, agents install writes nothing for codex, the Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and every server start clears leftover codex stage data and the old AGENTS.md stage block. - Changes panel: a whole file opens for reading and enters the editor on a click on its text (caret at the clicked line), not automatically. - macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by scripts/service-manager.js). - Adds the AgentHub introduction slides and their image-embedding script.
126 lines
5.0 KiB
JavaScript
126 lines
5.0 KiB
JavaScript
/**
|
|
* @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 <lane.cwd>/.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 apart
|
|
* from the one `git rev-parse` behind `excludeFromGit`.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const { excludeFromGit } = require("./git-exclude");
|
|
|
|
/** 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/<feature>/...) always
|
|
* land under <laneDir>/.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 <sourceRepo>/.playwright-mcp/profiles/<name>/ 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;
|
|
}
|
|
|
|
/**
|
|
* @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 };
|