feat: runner accepts git/npx sources, broker delegates extraction
Runner /load now accepts gitUrl, npxPackage, or sourcePath. It handles git clone and npm install internally. Broker no longer needs shared volume for source extraction — just tells the runner what to fetch. CLI mesh_mcp_deploy now supports npx_package as a third source type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3228,53 +3228,31 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
// --- Source extraction + runner spawn (async, non-blocking) ---
|
// --- Source extraction + runner spawn (async, non-blocking) ---
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
||||||
const { join } = await import("node:path");
|
|
||||||
const sourcePath = join(env.CLAUDEMESH_SERVICES_DIR, conn.meshId, md.server_name, "source");
|
|
||||||
mkdirSync(sourcePath, { recursive: true });
|
|
||||||
|
|
||||||
// Extract source
|
|
||||||
if (md.source.type === "git") {
|
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
const gitUrl = md.source.url;
|
|
||||||
const branch = md.source.branch ?? "main";
|
|
||||||
execSync(`git clone --depth 1 --branch ${branch} ${gitUrl} .`, { cwd: sourcePath, timeout: 60_000 });
|
|
||||||
log.info("git clone complete", { name: md.server_name, url: gitUrl });
|
|
||||||
} else if (md.source.type === "zip" && md.source.file_id) {
|
|
||||||
// Download from MinIO and extract
|
|
||||||
const bucket = meshBucketName(conn.meshId);
|
|
||||||
const fileRow = await getFile(conn.meshId, md.source.file_id);
|
|
||||||
if (!fileRow) throw new Error(`file ${md.source.file_id} not found`);
|
|
||||||
const stream = await minioClient.getObject(bucket, (fileRow as any).minioKey);
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
|
||||||
const zipBuf = Buffer.concat(chunks);
|
|
||||||
// Write zip and extract
|
|
||||||
const zipPath = join(sourcePath, ".._upload.zip");
|
|
||||||
writeFileSync(zipPath, zipBuf);
|
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
execSync(`unzip -o "${zipPath}" -d .`, { cwd: sourcePath, timeout: 30_000 });
|
|
||||||
execSync(`rm -f "${zipPath}"`, { cwd: sourcePath });
|
|
||||||
log.info("zip extracted", { name: md.server_name, file_id: md.source.file_id });
|
|
||||||
} else if (md.source.type === "npx") {
|
|
||||||
// npx-based: no source extraction needed, runner spawns via npx
|
|
||||||
// Write a marker file so runner knows the spawn command
|
|
||||||
writeFileSync(join(sourcePath, ".npx-package"), md.source.package ?? md.server_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve env vars (decrypted by CLI, sent as plaintext over TLS)
|
// Resolve env vars (decrypted by CLI, sent as plaintext over TLS)
|
||||||
const resolvedEnv = md.config?.env ?? {};
|
const resolvedEnv = md.config?.env ?? {};
|
||||||
|
|
||||||
|
// Build runner load payload — runner handles git clone / npm install
|
||||||
|
const loadPayload: Record<string, unknown> = {
|
||||||
|
name: md.server_name,
|
||||||
|
env: resolvedEnv,
|
||||||
|
runtime: md.config?.runtime,
|
||||||
|
};
|
||||||
|
if (md.source.type === "git") {
|
||||||
|
loadPayload.gitUrl = md.source.url;
|
||||||
|
loadPayload.gitBranch = md.source.branch;
|
||||||
|
} else if (md.source.type === "npx") {
|
||||||
|
loadPayload.npxPackage = md.source.package ?? md.server_name;
|
||||||
|
} else if (md.source.type === "zip" && md.source.file_id) {
|
||||||
|
// TODO: download zip from MinIO, upload to runner via multipart
|
||||||
|
// For now, zip deploy requires shared volume
|
||||||
|
loadPayload.sourcePath = `${env.CLAUDEMESH_SERVICES_DIR}/${conn.meshId}/${md.server_name}/source`;
|
||||||
|
}
|
||||||
|
|
||||||
// Call runner HTTP API to load the service
|
// Call runner HTTP API to load the service
|
||||||
const runnerRes = await fetch(`${env.RUNNER_URL}/load`, {
|
const runnerRes = await fetch(`${env.RUNNER_URL}/load`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(loadPayload),
|
||||||
name: md.server_name,
|
|
||||||
sourcePath,
|
|
||||||
env: resolvedEnv,
|
|
||||||
runtime: md.config?.runtime,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const runnerResult = await runnerRes.json() as { status?: string; tools?: any[]; error?: string };
|
const runnerResult = await runnerRes.json() as { status?: string; tools?: any[]; error?: string };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.8.5",
|
"version": "0.8.6",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -1400,18 +1400,21 @@ Your message mode is "${messageMode}".
|
|||||||
|
|
||||||
// --- Service deployment tools ---
|
// --- Service deployment tools ---
|
||||||
case "mesh_mcp_deploy": {
|
case "mesh_mcp_deploy": {
|
||||||
const { server_name, file_id, git_url, git_branch, env: deployEnv, runtime, memory_mb, network_allow, scope } = (args ?? {}) as {
|
const { server_name, file_id, git_url, git_branch, npx_package, env: deployEnv, runtime, memory_mb, network_allow, scope } = (args ?? {}) as {
|
||||||
server_name?: string; file_id?: string; git_url?: string; git_branch?: string;
|
server_name?: string; file_id?: string; git_url?: string; git_branch?: string;
|
||||||
|
npx_package?: string;
|
||||||
env?: Record<string, string>; runtime?: string; memory_mb?: number;
|
env?: Record<string, string>; runtime?: string; memory_mb?: number;
|
||||||
network_allow?: string[]; scope?: unknown;
|
network_allow?: string[]; scope?: unknown;
|
||||||
};
|
};
|
||||||
if (!server_name) return text("mesh_mcp_deploy: `server_name` required", true);
|
if (!server_name) return text("mesh_mcp_deploy: `server_name` required", true);
|
||||||
if (!file_id && !git_url) return text("mesh_mcp_deploy: either `file_id` or `git_url` required", true);
|
if (!file_id && !git_url && !npx_package) return text("mesh_mcp_deploy: one of `file_id`, `git_url`, or `npx_package` required", true);
|
||||||
const client = allClients()[0];
|
const client = allClients()[0];
|
||||||
if (!client) return text("mesh_mcp_deploy: not connected", true);
|
if (!client) return text("mesh_mcp_deploy: not connected", true);
|
||||||
const source = file_id
|
const source = npx_package
|
||||||
? { type: "zip" as const, file_id }
|
? { type: "npx" as const, package: npx_package }
|
||||||
: { type: "git" as const, url: git_url!, branch: git_branch };
|
: file_id
|
||||||
|
? { type: "zip" as const, file_id }
|
||||||
|
: { type: "git" as const, url: git_url!, branch: git_branch };
|
||||||
|
|
||||||
// Resolve $vault: references in env vars — decrypt client-side
|
// Resolve $vault: references in env vars — decrypt client-side
|
||||||
const resolvedEnv: Record<string, string> = {};
|
const resolvedEnv: Record<string, string> = {};
|
||||||
|
|||||||
@@ -906,6 +906,7 @@ export const TOOLS: Tool[] = [
|
|||||||
file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" },
|
file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" },
|
||||||
git_url: { type: "string", description: "Git repo URL" },
|
git_url: { type: "string", description: "Git repo URL" },
|
||||||
git_branch: { type: "string", description: "Branch to clone (default: main)" },
|
git_branch: { type: "string", description: "Branch to clone (default: main)" },
|
||||||
|
npx_package: { type: "string", description: "npm package name to run via npx (e.g. @upstash/context7-mcp)" },
|
||||||
env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." },
|
env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." },
|
||||||
runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" },
|
runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" },
|
||||||
memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" },
|
memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" },
|
||||||
|
|||||||
@@ -117,7 +117,14 @@ async function initMcp(svc) {
|
|||||||
// --- Spawn ---
|
// --- Spawn ---
|
||||||
|
|
||||||
function spawnService(svc) {
|
function spawnService(svc) {
|
||||||
const { cmd, args } = detectEntry(svc.sourcePath, svc.runtime);
|
// npx packages have a pre-resolved binary
|
||||||
|
let cmd, args;
|
||||||
|
if (svc._npxBin) {
|
||||||
|
cmd = "node";
|
||||||
|
args = [svc._npxBin];
|
||||||
|
} else {
|
||||||
|
({ cmd, args } = detectEntry(svc.sourcePath, svc.runtime));
|
||||||
|
}
|
||||||
const child = spawn(cmd, args, {
|
const child = spawn(cmd, args, {
|
||||||
cwd: svc.sourcePath,
|
cwd: svc.sourcePath,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
@@ -208,26 +215,66 @@ const server = createServer(async (req, res) => {
|
|||||||
|
|
||||||
if (req.method === "POST" && req.url === "/load") {
|
if (req.method === "POST" && req.url === "/load") {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const { name, sourcePath, env: svcEnv, runtime: rt } = body;
|
const { name, sourcePath, gitUrl, gitBranch, npxPackage, env: svcEnv, runtime: rt } = body;
|
||||||
if (!name || !sourcePath) return json(res, 400, { error: "name and sourcePath required" });
|
if (!name) return json(res, 400, { error: "name required" });
|
||||||
|
|
||||||
// Kill existing
|
// Kill existing
|
||||||
const existing = services.get(name);
|
const existing = services.get(name);
|
||||||
if (existing?.process) { existing.status = "stopped"; existing.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 1000)); }
|
if (existing?.process) { existing.status = "stopped"; existing.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 1000)); }
|
||||||
|
|
||||||
const runtime = rt || detectRuntime(sourcePath);
|
// Determine source path — git clone, npx, or pre-existing path
|
||||||
const svc = { name, sourcePath, runtime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "installing", pending: new Map(), logs: [], restarts: 0, healthFailures: 0 };
|
let svcSourcePath = sourcePath;
|
||||||
|
let svcRuntime = rt;
|
||||||
|
|
||||||
|
if (gitUrl) {
|
||||||
|
// Git clone into runner's local storage
|
||||||
|
svcSourcePath = join("/var/claudemesh/services", name);
|
||||||
|
const { execSync } = await import("node:child_process");
|
||||||
|
mkdirSync(svcSourcePath, { recursive: true });
|
||||||
|
try {
|
||||||
|
// Clean existing clone
|
||||||
|
execSync(`rm -rf ${svcSourcePath}/*`, { timeout: 10_000 });
|
||||||
|
execSync(`git clone --depth 1 ${gitBranch ? `--branch ${gitBranch}` : ""} ${gitUrl} .`, { cwd: svcSourcePath, timeout: 120_000, stdio: "pipe" });
|
||||||
|
console.log(`[runner] git clone complete: ${gitUrl} -> ${svcSourcePath}`);
|
||||||
|
} catch (e) {
|
||||||
|
return json(res, 500, { error: `git clone failed: ${e.message}` });
|
||||||
|
}
|
||||||
|
} else if (npxPackage) {
|
||||||
|
// npx-based: create a minimal package.json that depends on the package
|
||||||
|
svcSourcePath = join("/var/claudemesh/services", name);
|
||||||
|
mkdirSync(svcSourcePath, { recursive: true });
|
||||||
|
const pkg = { name: `mcp-${name}`, private: true, dependencies: { [npxPackage]: "*" } };
|
||||||
|
writeFileSync(join(svcSourcePath, "package.json"), JSON.stringify(pkg, null, 2));
|
||||||
|
svcRuntime = svcRuntime || "node";
|
||||||
|
} else if (!svcSourcePath) {
|
||||||
|
return json(res, 400, { error: "one of sourcePath, gitUrl, or npxPackage required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = svcRuntime || detectRuntime(svcSourcePath);
|
||||||
|
const svc = { name, sourcePath: svcSourcePath, runtime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "installing", pending: new Map(), logs: [], restarts: 0, healthFailures: 0 };
|
||||||
services.set(name, svc);
|
services.set(name, svc);
|
||||||
|
|
||||||
// Install deps
|
// Install deps
|
||||||
try { await installDeps(sourcePath, runtime); } catch (e) {
|
try { await installDeps(svcSourcePath, runtime); } catch (e) {
|
||||||
svc.status = "failed"; svc.logs.push(`install failed: ${e.message}`);
|
svc.status = "failed"; svc.logs.push(`install failed: ${e.message}`);
|
||||||
return json(res, 500, { error: e.message });
|
return json(res, 500, { error: e.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For npx packages: find the binary in node_modules/.bin
|
||||||
|
if (npxPackage) {
|
||||||
|
const binDir = join(svcSourcePath, "node_modules", ".bin");
|
||||||
|
if (existsSync(binDir)) {
|
||||||
|
// Override detectEntry for npx packages
|
||||||
|
const bins = await import("node:fs").then(fs => fs.readdirSync(binDir));
|
||||||
|
if (bins.length > 0) {
|
||||||
|
svc._npxBin = join(binDir, bins[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn + MCP handshake
|
// Spawn + MCP handshake
|
||||||
spawnService(svc);
|
spawnService(svc);
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 1000)); // npx packages may need more startup time
|
||||||
try {
|
try {
|
||||||
svc.tools = await initMcp(svc);
|
svc.tools = await initMcp(svc);
|
||||||
console.log(`[runner] ${name} ready, ${svc.tools.length} tools`);
|
console.log(`[runner] ${name} ready, ${svc.tools.length} tools`);
|
||||||
|
|||||||
Reference in New Issue
Block a user