diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index 8a8c745b95..3d81d1b495 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -64,6 +64,14 @@ const { values, positionals } = parseArgs({ type: "boolean", default: false, }, + "no-autostart": { + type: "boolean", + default: false, + }, + "no-open": { + type: "boolean", + default: false, + }, }, allowPositionals: true, }); @@ -90,6 +98,8 @@ Server Options: --vibe Play synthwave soundtrack while running -h, --help Show this help message -v, --version Show version + --no-autostart Skip auto-launching an MCP project from the cwd + --no-open Don't auto-open the browser to the agent chat Dev Options: --vite-port Vite dev server port (default: 4000) @@ -290,7 +300,21 @@ if (noTui) { } const { startServer } = await import("./cli/commands/serve"); - await startServer({ ...serveOptions, noTui: true }); + const { port: studioPort } = await startServer({ + ...serveOptions, + noTui: true, + }); + + if (!values["no-autostart"] && process.env.DECOCMS_NO_AUTOSTART !== "1") { + const { waitForSeed } = await import("./auth/local-mode"); + await waitForSeed(); + const { maybeAutostartFromCwd } = await import("./cli/autostart"); + await maybeAutostartFromCwd({ + cwd: process.cwd(), + studioPort, + open: values["no-open"] !== true, + }); + } } else { // Ink UI mode const { render } = await import("ink"); @@ -318,5 +342,16 @@ if (noTui) { startVibe(decoHome); } - await startServer(serveOptions); + const { port: studioPort } = await startServer(serveOptions); + + if (!values["no-autostart"] && process.env.DECOCMS_NO_AUTOSTART !== "1") { + const { waitForSeed } = await import("./auth/local-mode"); + await waitForSeed(); + const { maybeAutostartFromCwd } = await import("./cli/autostart"); + await maybeAutostartFromCwd({ + cwd: process.cwd(), + studioPort, + open: values["no-open"] !== true, + }); + } } diff --git a/apps/mesh/src/cli/app.tsx b/apps/mesh/src/cli/app.tsx index 35285770e9..f74664bd93 100644 --- a/apps/mesh/src/cli/app.tsx +++ b/apps/mesh/src/cli/app.tsx @@ -42,6 +42,7 @@ export function App({ home }: { home: string }) { home={home} serverUrl={state.serverUrl} vibe={state.vibe} + autostartProject={state.autostartProject} /> {state.viewMode === "config" ? ( diff --git a/apps/mesh/src/cli/autostart/detect.test.ts b/apps/mesh/src/cli/autostart/detect.test.ts new file mode 100644 index 0000000000..95709af3be --- /dev/null +++ b/apps/mesh/src/cli/autostart/detect.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectProject } from "./detect"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "autostart-detect-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function writePkg( + root: string, + scripts: Record, + deps: Record = {}, +) { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "x", scripts, dependencies: deps }), + ); +} + +describe("detectProject", () => { + it("returns null for empty directory", () => { + expect(detectProject(dir)).toBeNull(); + }); + + it("returns null when cwd is a plain node project (no MCP shape)", () => { + writePkg(dir, { dev: "vite" }); + expect(detectProject(dir)).toBeNull(); + }); + + it("detects mcp/ subfolder with package.json + bun lock + dev script", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "bun run --hot api/main.bun.ts" }); + writeFileSync(join(mcp, "bun.lock"), ""); + const detected = detectProject(dir); + expect(detected).not.toBeNull(); + expect(detected?.root).toBe(mcp); + expect(detected?.packageManager).toBe("bun"); + expect(detected?.starter).toBe("dev"); + }); + + it("falls back to start when dev script absent", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { start: "node server.js" }); + writeFileSync(join(mcp, "package-lock.json"), "{}"); + const detected = detectProject(dir); + expect(detected?.packageManager).toBe("npm"); + expect(detected?.starter).toBe("start"); + }); + + it("returns null when mcp/ exists but has no scripts", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, {}); + writeFileSync(join(mcp, "bun.lock"), ""); + expect(detectProject(dir)).toBeNull(); + }); + + it("detects pnpm via lockfile", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "next" }); + writeFileSync(join(mcp, "pnpm-lock.yaml"), ""); + expect(detectProject(dir)?.packageManager).toBe("pnpm"); + }); + + it("detects yarn via lockfile", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "next" }); + writeFileSync(join(mcp, "yarn.lock"), ""); + expect(detectProject(dir)?.packageManager).toBe("yarn"); + }); + + it("detects deno via deno.json + tasks", () => { + const mcp = join(dir, "mcp"); + mkdirSync(mcp); + writeFileSync( + join(mcp, "deno.json"), + JSON.stringify({ tasks: { dev: "deno run main.ts" } }), + ); + const detected = detectProject(dir); + expect(detected?.packageManager).toBe("deno"); + expect(detected?.starter).toBe("dev"); + }); + + it("detects cwd-itself when api/main.*.ts exists", () => { + writePkg(dir, { dev: "bun run api/main.bun.ts" }); + writeFileSync(join(dir, "bun.lock"), ""); + mkdirSync(join(dir, "api")); + writeFileSync(join(dir, "api", "main.bun.ts"), "// stub"); + const detected = detectProject(dir); + expect(detected?.root).toBe(dir); + }); + + it("detects cwd-itself when @decocms/runtime is a dep", () => { + writePkg(dir, { dev: "bun run x" }, { "@decocms/runtime": "1.0.0" }); + writeFileSync(join(dir, "bun.lock"), ""); + const detected = detectProject(dir); + expect(detected?.root).toBe(dir); + }); + + it("prefers mcp/ over cwd shape when both qualify", () => { + // cwd has shape, but mcp/ also has a project — mcp/ wins + writePkg(dir, { dev: "bun run x" }, { "@decocms/runtime": "1.0.0" }); + writeFileSync(join(dir, "bun.lock"), ""); + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "bun run mcp" }); + writeFileSync(join(mcp, "bun.lock"), ""); + expect(detectProject(dir)?.root).toBe(mcp); + }); + + it("loads README preview when present", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "bun run x" }); + writeFileSync(join(mcp, "bun.lock"), ""); + writeFileSync(join(mcp, "README.md"), "# Hello\n\nWorld"); + expect(detectProject(dir)?.readmePreview).toContain("# Hello"); + }); + + it("uses README H1 as the agent name when present", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "bun run x" }); + writeFileSync(join(mcp, "bun.lock"), ""); + writeFileSync( + join(mcp, "README.md"), + "# CEO Agent — Deco\n\nThe brain of the company.", + ); + const detected = detectProject(dir); + expect(detected?.name).toBe("CEO Agent — Deco"); + expect(detected?.description).toBe("The brain of the company."); + }); + + it("falls back to package.json#name (scope stripped) when no README H1", () => { + const mcp = join(dir, "mcp"); + mkdirSync(mcp, { recursive: true }); + writeFileSync( + join(mcp, "package.json"), + JSON.stringify({ name: "@acme/cool-mcp", scripts: { dev: "x" } }), + ); + writeFileSync(join(mcp, "bun.lock"), ""); + expect(detectProject(dir)?.name).toBe("cool-mcp"); + }); + + it("falls back to dir basename when no README H1 nor pkg name", () => { + const mcp = join(dir, "mcp"); + mkdirSync(mcp, { recursive: true }); + writeFileSync( + join(mcp, "package.json"), + JSON.stringify({ scripts: { dev: "x" } }), + ); + writeFileSync(join(mcp, "bun.lock"), ""); + expect(detectProject(dir)?.name).toBe("mcp"); + }); + + it("prefers package.json description over README paragraph", () => { + const mcp = join(dir, "mcp"); + mkdirSync(mcp, { recursive: true }); + writeFileSync( + join(mcp, "package.json"), + JSON.stringify({ + description: "from pkg", + scripts: { dev: "x" }, + }), + ); + writeFileSync(join(mcp, "bun.lock"), ""); + writeFileSync(join(mcp, "README.md"), "# Hi\n\nfrom readme"); + expect(detectProject(dir)?.description).toBe("from pkg"); + }); + + it("surfaces prompt.md as the promptFile", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "bun run x" }); + writeFileSync(join(mcp, "bun.lock"), ""); + writeFileSync(join(mcp, "prompt.md"), "You are the agent."); + expect(detectProject(dir)?.promptFile).toBe(join(mcp, "prompt.md")); + }); + + it("falls back to AGENTS.md / CLAUDE.md when no prompt.md", () => { + const mcp = join(dir, "mcp"); + writePkg(mcp, { dev: "bun run x" }); + writeFileSync(join(mcp, "bun.lock"), ""); + writeFileSync(join(mcp, "AGENTS.md"), "agent rules"); + expect(detectProject(dir)?.promptFile).toBe(join(mcp, "AGENTS.md")); + }); +}); diff --git a/apps/mesh/src/cli/autostart/detect.ts b/apps/mesh/src/cli/autostart/detect.ts new file mode 100644 index 0000000000..bbf7fe566d --- /dev/null +++ b/apps/mesh/src/cli/autostart/detect.ts @@ -0,0 +1,235 @@ +/** + * Detects whether the cwd looks like an MCP project that we can auto-launch. + * + * Trigger conditions, in priority order: + * 1. /mcp is a directory with package.json or deno.json → root = /mcp + * 2. itself looks like an MCP project (api/main.*.ts, or @decocms/runtime + * / @modelcontextprotocol/sdk in deps) → root = + * 3. otherwise null + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +export type PackageManager = "bun" | "pnpm" | "yarn" | "npm" | "deno"; +export type Starter = "dev" | "start"; + +export interface DetectedProject { + /** Absolute path of the project root we will spawn from. */ + root: string; + /** Display title — README H1 → package.json#name (no @scope/) → basename. */ + name: string; + packageManager: PackageManager; + starter: Starter; + /** Best-effort one-line description: package.json#description → first README paragraph. */ + description: string | null; + /** First ~2KB of README.md, if any. */ + readmePreview: string | null; + /** Path to an authored agent prompt (prompt.md / AGENTS.md / CLAUDE.md), if any. */ + promptFile: string | null; +} + +const WELL_KNOWN_STARTERS = ["dev", "start"] as const; + +const MCP_DEP_HINTS = [ + "@decocms/runtime", + "@modelcontextprotocol/sdk", + "@modelcontextprotocol/ext-apps", +]; + +function isDir(p: string): boolean { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} + +function readJsonSafe(path: string): T | null { + try { + return JSON.parse(readFileSync(path, "utf-8")) as T; + } catch { + return null; + } +} + +function detectPackageManager(root: string): PackageManager | null { + if ( + existsSync(join(root, "deno.json")) || + existsSync(join(root, "deno.jsonc")) + ) { + return "deno"; + } + if ( + existsSync(join(root, "bun.lock")) || + existsSync(join(root, "bun.lockb")) + ) { + return "bun"; + } + if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(join(root, "yarn.lock"))) return "yarn"; + if (existsSync(join(root, "package-lock.json"))) return "npm"; + // Fall back to bun if package.json exists — bunx decocms users likely use bun + if (existsSync(join(root, "package.json"))) return "bun"; + return null; +} + +function discoverStarter(root: string, pm: PackageManager): Starter | null { + let scripts: Record = {}; + if (pm === "deno") { + for (const f of ["deno.json", "deno.jsonc"]) { + const parsed = readJsonSafe<{ tasks?: Record }>( + join(root, f), + ); + if (parsed) { + scripts = parsed.tasks ?? {}; + break; + } + } + } else { + const parsed = readJsonSafe<{ scripts?: Record }>( + join(root, "package.json"), + ); + scripts = parsed?.scripts ?? {}; + } + for (const s of WELL_KNOWN_STARTERS) { + if (scripts[s]) return s; + } + return null; +} + +function readReadme(root: string, max: number): string | null { + for (const name of ["README.md", "readme.md", "README.MD"]) { + const p = join(root, name); + if (!existsSync(p)) continue; + try { + return readFileSync(p, "utf-8").slice(0, max); + } catch { + // ignore + } + } + return null; +} + +function firstReadmeH1(readme: string | null): string | null { + if (!readme) return null; + for (const raw of readme.split("\n")) { + const line = raw.trim(); + if (line.startsWith("# ")) return line.slice(2).trim(); + } + return null; +} + +function firstReadmeParagraph(readme: string | null): string | null { + if (!readme) return null; + const lines = readme.split("\n"); + // Skip leading whitespace and the H1 (and any sub-heading immediately after). + let i = 0; + while (i < lines.length && (!lines[i]!.trim() || lines[i]!.startsWith("#"))) + i++; + const buf: string[] = []; + for (; i < lines.length; i++) { + const line = lines[i]!.trim(); + if (!line) { + if (buf.length > 0) break; + continue; + } + if (line.startsWith("#")) break; + buf.push(line); + } + const text = buf.join(" ").trim(); + return text || null; +} + +function stripScope(name: string): string { + return name.startsWith("@") ? name.split("/").slice(1).join("/") : name; +} + +function resolveDisplayName( + root: string, + readme: string | null, + pkgName: string | null, +): string { + const fromReadme = firstReadmeH1(readme); + if (fromReadme) return fromReadme; + if (pkgName) return stripScope(pkgName); + return basename(root); +} + +function resolveDescription( + pkgDescription: string | null, + readme: string | null, +): string | null { + if (pkgDescription) return pkgDescription; + return firstReadmeParagraph(readme); +} + +const PROMPT_CANDIDATES = ["prompt.md", "AGENTS.md", "CLAUDE.md"]; + +function findPromptFile(root: string): string | null { + for (const name of PROMPT_CANDIDATES) { + const p = join(root, name); + if (existsSync(p)) return p; + } + return null; +} + +function hasMcpShape(root: string): boolean { + // Existence of api/main.*.ts is the strongest signal. + const apiDir = join(root, "api"); + if (isDir(apiDir)) { + try { + const entries = readdirSync(apiDir); + if (entries.some((f) => /^main\.[^.]+\.ts$/.test(f))) return true; + } catch { + // ignore + } + } + // Otherwise look for an MCP dependency. + const pkg = readJsonSafe<{ + dependencies?: Record; + devDependencies?: Record; + }>(join(root, "package.json")); + if (pkg) { + const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }; + if (MCP_DEP_HINTS.some((dep) => all[dep])) return true; + } + return false; +} + +function tryDetect(root: string): DetectedProject | null { + const pm = detectPackageManager(root); + if (!pm) return null; + const starter = discoverStarter(root, pm); + if (!starter) return null; + const pkg = readJsonSafe<{ name?: string; description?: string }>( + join(root, "package.json"), + ); + // Read enough of the README that firstReadmeParagraph has something to work + // with even when the H1 is followed by long paragraphs. + const readme = readReadme(root, 8192); + return { + root, + name: resolveDisplayName(root, readme, pkg?.name?.trim() || null), + packageManager: pm, + starter, + description: resolveDescription(pkg?.description?.trim() || null, readme), + readmePreview: readme ? readme.slice(0, 2048) : null, + promptFile: findPromptFile(root), + }; +} + +export function detectProject(cwd: string): DetectedProject | null { + // Priority 1: /mcp + const mcpDir = join(cwd, "mcp"); + if (isDir(mcpDir)) { + const detected = tryDetect(mcpDir); + if (detected) return detected; + } + // Priority 2: cwd itself, only if it has the shape (don't autostart random + // node projects). + if (hasMcpShape(cwd)) { + return tryDetect(cwd); + } + return null; +} diff --git a/apps/mesh/src/cli/autostart/index.ts b/apps/mesh/src/cli/autostart/index.ts new file mode 100644 index 0000000000..ceb87383d0 --- /dev/null +++ b/apps/mesh/src/cli/autostart/index.ts @@ -0,0 +1,237 @@ +/** + * Studio autostart from cwd. + * + * If the cwd looks like a prepared MCP project (an `mcp/` subfolder, or the + * cwd itself with the right shape), spawn its dev server, register it as a + * Studio connection + agent, and surface a chat-with-agent URL so + * `bunx decocms` lands the user directly in a working conversation. + * + * Best-effort: any failure logs and falls back to the normal Studio boot. + */ + +import { randomUUID } from "node:crypto"; +import { addLogEntry, setAutostartProject } from "../cli-store"; +import { getDb } from "../../database"; +import { getLocalAdminUser, isLocalMode } from "../../auth/local-mode"; +import { CredentialVault } from "../../encryption/credential-vault"; +import { getSettings } from "../../settings"; +import { ConnectionStorage } from "../../storage/connection"; +import { VirtualMCPStorage } from "../../storage/virtual"; +import { fetchToolsFromMCP } from "../../tools/connection/fetch-tools"; +import type { ToolDefinition } from "../../tools/connection/schema"; +import { detectProject } from "./detect"; +import { startMcpDevServer, type SpawnedDevServer } from "./spawn"; +import { draftSystemPrompt } from "./prompt"; +import { resolve as resolvePath } from "node:path"; +import { + autostartConnectionId, + registerProjectAsAgent, + type AutostartLayout, + type PinnedView, +} from "./register"; + +/** + * Read a tool's UI resource URI from `_meta` — same shape ext-apps' SDK uses. + * A tool with a UI sets `_meta.ui.resourceUri = "ui://…"` (or the legacy + * `_meta["ui/resourceUri"]`). + */ +function getToolUiResourceUri(tool: ToolDefinition): string | null { + const meta = tool._meta as Record | null | undefined; + if (!meta) return null; + const fromUi = (meta.ui as { resourceUri?: unknown } | undefined) + ?.resourceUri; + const fromLegacy = meta["ui/resourceUri"]; + const candidate = fromUi ?? fromLegacy; + return typeof candidate === "string" && candidate.startsWith("ui://") + ? candidate + : null; +} + +function buildPinnedViewsAndLayout( + connectionId: string, + tools: ToolDefinition[], +): { pinnedViews: PinnedView[]; layout: AutostartLayout } { + const uiTools = tools.filter((t) => getToolUiResourceUri(t)); + const pinnedViews: PinnedView[] = uiTools.map((t) => ({ + connectionId, + toolName: t.name, + label: t.title || t.annotations?.title || t.name, + icon: null, + })); + const first = uiTools[0]; + const layout: AutostartLayout = first + ? { + defaultMainView: { + type: "ext-apps", + id: connectionId, + toolName: first.name, + }, + chatDefaultOpen: true, + } + : {}; + return { pinnedViews, layout }; +} + +export interface AutostartOptions { + cwd: string; + /** Studio's port (so we can build the chat URL on the right host). */ + studioPort: number; + /** Studio's base URL (overrides http://localhost:). */ + studioBaseUrl?: string; + /** When false, do not open the chat URL in the user's browser. */ + open?: boolean; +} + +/** Tracks every child we spawned so graceful shutdown can stop them. */ +const _children: SpawnedDevServer[] = []; + +export function getAutostartChildren(): readonly SpawnedDevServer[] { + return _children; +} + +function log(line: string) { + addLogEntry({ + method: "", + path: "", + status: 0, + duration: 0, + timestamp: new Date(), + rawLine: line, + }); +} + +function openBrowser(url: string): void { + try { + if (!process.stdout.isTTY) return; + const cmd = + process.platform === "darwin" + ? ["open", url] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }).unref(); + } catch { + // ignore — user has the URL printed + } +} + +async function resolveOrg( + db: ReturnType["db"], + userId: string, +): Promise<{ id: string; slug: string } | null> { + const member = await db + .selectFrom("member") + .innerJoin("organization", "organization.id", "member.organizationId") + .select(["organization.id as id", "organization.slug as slug"]) + .where("member.userId", "=", userId) + .executeTakeFirst(); + if (!member?.id) return null; + return { id: member.id, slug: member.slug ?? member.id }; +} + +export async function maybeAutostartFromCwd( + options: AutostartOptions, +): Promise { + const { cwd, studioPort, studioBaseUrl, open = true } = options; + + if (!isLocalMode()) { + // Autostart relies on the local-mode admin/org; in cloud/multi-tenant + // mode we don't have a single user identity to attach the agent to. + return; + } + + const project = detectProject(cwd); + if (!project) { + return; + } + + setAutostartProject({ + name: project.name, + status: "starting", + chatUrl: null, + }); + log(`[autostart] detected MCP project at ${project.root}`); + + let spawned: SpawnedDevServer | null = null; + try { + spawned = await startMcpDevServer(project); + _children.push(spawned); + log(`[autostart] ${project.name} listening at ${spawned.mcpUrl}`); + + const { db } = getDb(); + const adminUser = await getLocalAdminUser(); + if (!adminUser?.id) { + throw new Error("local admin user not found — seed not complete"); + } + const org = await resolveOrg(db, adminUser.id); + if (!org) { + throw new Error("no organization found for local admin"); + } + + const fetchResult = await fetchToolsFromMCP({ + id: "autostart-probe", + title: project.name, + connection_type: "HTTP", + connection_url: spawned.mcpUrl, + connection_token: null, + connection_headers: null, + }).catch(() => null); + const tools: ToolDefinition[] = fetchResult?.tools ?? []; + log(`[autostart] fetched ${tools.length} tool(s) from ${project.name}`); + + const drafted = await draftSystemPrompt({ + db, + organizationId: org.id, + project, + tools, + }); + log(`[autostart] system prompt source=${drafted.source}`); + + const vault = new CredentialVault(getSettings().encryptionKey); + const connId = autostartConnectionId(resolvePath(project.root)); + const { pinnedViews, layout } = buildPinnedViewsAndLayout(connId, tools); + log( + `[autostart] ${pinnedViews.length} UI tool(s) detected${ + pinnedViews.length > 0 ? ` (default: ${pinnedViews[0]!.label})` : "" + }`, + ); + + const { connectionId, virtualMcpId, isNew } = await registerProjectAsAgent({ + connections: new ConnectionStorage(db, vault), + virtualMcps: new VirtualMCPStorage(db), + organizationId: org.id, + userId: adminUser.id, + project, + mcpUrl: spawned.mcpUrl, + instructions: drafted.prompt, + pinnedViews, + layout, + }); + log( + `[autostart] ${isNew ? "registered" : "refreshed"} agent vir=${virtualMcpId} conn=${connectionId}`, + ); + + const baseUrl = studioBaseUrl ?? `http://localhost:${studioPort}`; + const taskId = randomUUID(); + const chatUrl = `${baseUrl}/${org.slug}/${taskId}?virtualmcpid=${virtualMcpId}`; + + setAutostartProject({ + name: project.name, + status: "ready", + chatUrl, + }); + log(`[autostart] open: ${chatUrl}`); + + if (open) openBrowser(chatUrl); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log(`[autostart] failed: ${msg}`); + setAutostartProject({ + name: project.name, + status: "failed", + chatUrl: null, + error: msg, + }); + if (spawned) spawned.kill(); + } +} diff --git a/apps/mesh/src/cli/autostart/prompt.test.ts b/apps/mesh/src/cli/autostart/prompt.test.ts new file mode 100644 index 0000000000..78fab04e4b --- /dev/null +++ b/apps/mesh/src/cli/autostart/prompt.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeTestDatabase, createTestDatabase } from "../../database/test-db"; +import { createTestSchema } from "../../storage/test-helpers"; +import type { DetectedProject } from "./detect"; +import { draftSystemPrompt } from "./prompt"; + +describe("draftSystemPrompt", () => { + it("returns a template prompt when skipLlm is set", async () => { + const root = mkdtempSync(join(tmpdir(), "autostart-prompt-")); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ description: "demo project" }), + ); + const project: DetectedProject = { + root, + name: "demo", + packageManager: "bun", + starter: "dev", + description: "demo project", + readmePreview: "# demo\n\nA cool MCP", + promptFile: null, + }; + + const database = await createTestDatabase(); + try { + await createTestSchema(database.db); + const result = await draftSystemPrompt({ + db: database.db, + organizationId: "org_x", + project, + tools: [ + { name: "send_email", description: "Send an email" }, + { name: "list_inbox", description: "List inbox messages" }, + ] as never, + skipLlm: true, + }); + expect(result.source).toBe("template"); + expect(result.prompt).toContain("demo"); + expect(result.prompt).toContain("send_email"); + expect(result.prompt).toContain("list_inbox"); + } finally { + await closeTestDatabase(database); + } + }); + + it("uses an authored prompt.md file when present", async () => { + const root = mkdtempSync(join(tmpdir(), "autostart-prompt-")); + const promptPath = join(root, "prompt.md"); + writeFileSync(promptPath, "# CEO Agent\n\nYou tend the company."); + const project: DetectedProject = { + root, + name: "ceo-agent", + packageManager: "bun", + starter: "dev", + description: null, + readmePreview: null, + promptFile: promptPath, + }; + + const database = await createTestDatabase(); + try { + await createTestSchema(database.db); + const result = await draftSystemPrompt({ + db: database.db, + organizationId: "org_x", + project, + tools: [], + skipLlm: true, // confirms file path wins even when LLM is allowed + }); + expect(result.source).toBe("file"); + expect(result.prompt).toContain("You tend the company"); + } finally { + await closeTestDatabase(database); + } + }); + + it("falls back to template when no provider key is configured", async () => { + const root = mkdtempSync(join(tmpdir(), "autostart-prompt-")); + const project: DetectedProject = { + root, + name: "noprovider", + packageManager: "bun", + starter: "dev", + description: null, + readmePreview: null, + promptFile: null, + }; + + const database = await createTestDatabase(); + try { + await createTestSchema(database.db); + // No AI provider keys seeded → falls back to template (skipLlm: false) + const result = await draftSystemPrompt({ + db: database.db, + organizationId: "org_empty", + project, + tools: [], + }); + expect(result.source).toBe("template"); + expect(result.prompt).toContain("noprovider"); + } finally { + await closeTestDatabase(database); + } + }); +}); diff --git a/apps/mesh/src/cli/autostart/prompt.ts b/apps/mesh/src/cli/autostart/prompt.ts new file mode 100644 index 0000000000..6c1f165a5b --- /dev/null +++ b/apps/mesh/src/cli/autostart/prompt.ts @@ -0,0 +1,267 @@ +/** + * Drafts the system prompt for the auto-created agent. + * + * Strategy: + * 1. If the org has at least one configured AI provider key, use a small + * LLM to draft a project-aware prompt from README + package.json + tool + * list. + * 2. Otherwise (or on any failure), fall back to a deterministic template. + * + * Autostart never blocks on this — failures fall back silently. + */ + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Kysely } from "kysely"; +import { generateText } from "ai"; +import { AIProviderFactory } from "../../ai-providers/factory"; +import { CredentialVault } from "../../encryption/credential-vault"; +import { getSettings } from "../../settings"; +import { AIProviderKeyStorage } from "../../storage/ai-provider-keys"; +import type { Database } from "../../storage/types"; +import type { ToolDefinition } from "../../tools/connection/schema"; +import type { DetectedProject } from "./detect"; + +const META_PROMPT = `You write system prompts for AI agents that operate against an MCP server. + +Given a project's README, package.json description, source excerpts, and the +list of MCP tools the agent can call, write a concise system prompt (6–12 lines) +that: + +- Names the agent's role in 1 line. +- States the project's purpose in 1–2 lines (do not paste the README). +- Lists 3–6 of the most useful tools by name with a one-line description each. +- Ends with a short instruction to prefer calling tools over answering from memory. + +Output the system prompt directly — no preamble, no markdown fences, no explanation.`; + +const SOURCE_PATHS_TO_TRY = [ + "api/app.ts", + "api/main.bun.ts", + "api/tools/index.ts", +]; + +const SOURCE_DIRS_TO_LIST = ["api/tools", "tools"]; + +const MAX_README = 8_000; +const MAX_SOURCE = 12_000; +const MAX_DEPS = 30; + +function fallbackTemplate( + project: DetectedProject, + tools: { name: string; description?: string | null }[], +): string { + const lines: string[] = []; + lines.push(`You are the agent for "${project.name}".`); + if (project.description) { + lines.push(project.description); + } else { + const firstReadmeLine = project.readmePreview + ?.split("\n") + .map((l) => l.trim()) + .find((l) => l && !l.startsWith("#")); + if (firstReadmeLine) lines.push(firstReadmeLine); + } + lines.push(""); + lines.push("Source: " + project.root); + lines.push(""); + if (tools.length > 0) { + lines.push("Tools available:"); + for (const t of tools.slice(0, 8)) { + const desc = t.description?.split("\n")[0] ?? ""; + lines.push(`- ${t.name}${desc ? ": " + desc : ""}`); + } + lines.push(""); + } + lines.push( + "Prefer calling your tools over answering from memory. When a user asks", + ); + lines.push( + "you to do something this project supports, find the right tool first.", + ); + return lines.join("\n"); +} + +function readFileSafe(path: string, max: number): string | null { + try { + if (!existsSync(path)) return null; + return readFileSync(path, "utf-8").slice(0, max); + } catch { + return null; + } +} + +function gatherSourceExcerpts(root: string): string { + const chunks: string[] = []; + let budget = MAX_SOURCE; + for (const rel of SOURCE_PATHS_TO_TRY) { + if (budget <= 0) break; + const content = readFileSafe(join(root, rel), Math.min(budget, 4_000)); + if (content) { + chunks.push(`--- ${rel} ---\n${content}`); + budget -= content.length; + } + } + for (const dir of SOURCE_DIRS_TO_LIST) { + if (budget <= 0) break; + const full = join(root, dir); + if (!existsSync(full)) continue; + try { + const entries = readdirSync(full) + .filter((f) => f.endsWith(".ts") || f.endsWith(".js")) + .slice(0, 6); + for (const entry of entries) { + if (budget <= 0) break; + const content = readFileSafe( + join(full, entry), + Math.min(budget, 2_500), + ); + if (content) { + chunks.push(`--- ${dir}/${entry} ---\n${content}`); + budget -= content.length; + } + } + } catch { + // ignore + } + } + return chunks.join("\n\n"); +} + +function summarizePackageJson(root: string): string { + const path = join(root, "package.json"); + if (!existsSync(path)) return ""; + try { + const pkg = JSON.parse(readFileSync(path, "utf-8")) as { + name?: string; + description?: string; + dependencies?: Record; + }; + const deps = Object.keys(pkg.dependencies ?? {}).slice(0, MAX_DEPS); + const out: string[] = []; + if (pkg.name) out.push(`name: ${pkg.name}`); + if (pkg.description) out.push(`description: ${pkg.description}`); + if (deps.length > 0) out.push(`dependencies: ${deps.join(", ")}`); + return out.join("\n"); + } catch { + return ""; + } +} + +async function pickProviderKey( + db: Kysely, + organizationId: string, +): Promise<{ keyId: string; preferredModel: string | null } | null> { + const vault = new CredentialVault(getSettings().encryptionKey); + const storage = new AIProviderKeyStorage(db, vault); + const keys = await storage.list({ organizationId }); + // Prefer Anthropic for prompt drafting (we know which models exist), else + // first available. + const anthropic = keys.find((k) => k.providerId === "anthropic"); + const chosen = anthropic ?? keys[0]; + if (!chosen) return null; + + const preferredModel = + chosen.providerId === "anthropic" ? "claude-haiku-4-5" : null; + + return { keyId: chosen.id, preferredModel }; +} + +export interface DraftPromptParams { + db: Kysely; + organizationId: string; + project: DetectedProject; + tools: ToolDefinition[]; + /** When true, skip LLM and return the template (test/CI escape hatch). */ + skipLlm?: boolean; +} + +export async function draftSystemPrompt( + params: DraftPromptParams, +): Promise<{ prompt: string; source: "file" | "llm" | "template" }> { + const { db, organizationId, project, tools, skipLlm } = params; + + const toolSummaries = tools.map((t) => ({ + name: t.name, + description: t.description ?? null, + })); + + // If the project ships its own agent prompt, that's authoritative. + if (project.promptFile) { + const authored = readFileSafe(project.promptFile, 64_000); + if (authored && authored.trim().length > 0) { + return { prompt: authored.trim(), source: "file" }; + } + } + + if (skipLlm) { + return { + prompt: fallbackTemplate(project, toolSummaries), + source: "template", + }; + } + + try { + const picked = await pickProviderKey(db, organizationId); + if (!picked || !picked.preferredModel) { + return { + prompt: fallbackTemplate(project, toolSummaries), + source: "template", + }; + } + + const vault = new CredentialVault(getSettings().encryptionKey); + const keyStorage = new AIProviderKeyStorage(db, vault); + const factory = new AIProviderFactory(keyStorage); + const provider = await factory.activate(picked.keyId, organizationId); + const model = provider.aiSdk.languageModel(picked.preferredModel); + + const readme = project.readmePreview + ? project.readmePreview.slice(0, MAX_README) + : ""; + + const userInput = [ + `Project name: ${project.name}`, + `Path: ${project.root}`, + "", + "## package.json", + summarizePackageJson(project.root) || "(no package.json)", + "", + "## README.md", + readme || "(no README)", + "", + "## Source excerpts", + gatherSourceExcerpts(project.root) || "(no source files matched)", + "", + "## MCP tools available", + toolSummaries.length > 0 + ? toolSummaries + .map((t) => `- ${t.name}: ${t.description?.split("\n")[0] ?? ""}`) + .join("\n") + : "(no tools fetched)", + ].join("\n"); + + const result = await generateText({ + model, + system: META_PROMPT, + messages: [{ role: "user", content: userInput }], + maxOutputTokens: 600, + temperature: 0.4, + abortSignal: AbortSignal.timeout(20_000), + }); + + const text = result.text.trim(); + if (!text || text.length < 30) { + return { + prompt: fallbackTemplate(project, toolSummaries), + source: "template", + }; + } + return { prompt: text, source: "llm" }; + } catch { + return { + prompt: fallbackTemplate(project, toolSummaries), + source: "template", + }; + } +} diff --git a/apps/mesh/src/cli/autostart/register.test.ts b/apps/mesh/src/cli/autostart/register.test.ts new file mode 100644 index 0000000000..3c930b090b --- /dev/null +++ b/apps/mesh/src/cli/autostart/register.test.ts @@ -0,0 +1,104 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + closeTestDatabase, + createTestDatabase, + type TestDatabase, +} from "../../database/test-db"; +import { CredentialVault } from "../../encryption/credential-vault"; +import { ConnectionStorage } from "../../storage/connection"; +import { + createTestSchema, + seedCommonTestFixtures, +} from "../../storage/test-helpers"; +import { VirtualMCPStorage } from "../../storage/virtual"; +import type { DetectedProject } from "./detect"; +import { registerProjectAsAgent } from "./register"; + +describe("registerProjectAsAgent", () => { + let database: TestDatabase; + let connections: ConnectionStorage; + let virtualMcps: VirtualMCPStorage; + + const project: DetectedProject = { + root: mkdtempSync(join(tmpdir(), "autostart-register-")), + name: "demo", + packageManager: "bun", + starter: "dev", + description: "demo project", + readmePreview: "# demo\n\nA cool MCP", + promptFile: null, + }; + + beforeAll(async () => { + database = await createTestDatabase(); + const vault = new CredentialVault(CredentialVault.generateKey()); + connections = new ConnectionStorage(database.db, vault); + virtualMcps = new VirtualMCPStorage(database.db); + await createTestSchema(database.db); + await seedCommonTestFixtures(database.db); + }); + + afterAll(async () => { + await closeTestDatabase(database); + }); + + it("creates a deterministic connection + virtual mcp on first run", async () => { + const result = await registerProjectAsAgent({ + connections, + virtualMcps, + organizationId: "org_1", + userId: "user_1", + project, + mcpUrl: "http://localhost:3001/mcp", + instructions: "do things", + }); + expect(result.isNew).toBe(true); + expect(result.connectionId).toMatch(/^conn_auto_/); + expect(result.virtualMcpId).toMatch(/^vir_auto_/); + + const conn = await connections.findById(result.connectionId); + expect(conn?.connection_url).toBe("http://localhost:3001/mcp"); + expect(conn?.connection_type).toBe("HTTP"); + + const agent = await virtualMcps.findById(result.virtualMcpId, "org_1"); + expect(agent?.title).toBe("demo"); + expect(agent?.metadata?.instructions).toBe("do things"); + expect(agent?.connections).toHaveLength(1); + expect(agent?.connections[0]?.connection_id).toBe(result.connectionId); + }); + + it("is idempotent on second run with the same path", async () => { + const first = await registerProjectAsAgent({ + connections, + virtualMcps, + organizationId: "org_1", + userId: "user_1", + project, + mcpUrl: "http://localhost:3001/mcp", + instructions: "do things", + }); + const second = await registerProjectAsAgent({ + connections, + virtualMcps, + organizationId: "org_1", + userId: "user_1", + project, + mcpUrl: "http://localhost:3002/mcp", // port changed + instructions: "do other things", // ignored on re-run (instructions present) + }); + expect(second.connectionId).toBe(first.connectionId); + expect(second.virtualMcpId).toBe(first.virtualMcpId); + expect(second.isNew).toBe(false); + + // URL refreshed to new port + const conn = await connections.findById(second.connectionId); + expect(conn?.connection_url).toBe("http://localhost:3002/mcp"); + + // Existing instructions preserved + const agent = await virtualMcps.findById(second.virtualMcpId, "org_1"); + expect(agent?.metadata?.instructions).toBe("do things"); + }); +}); diff --git a/apps/mesh/src/cli/autostart/register.ts b/apps/mesh/src/cli/autostart/register.ts new file mode 100644 index 0000000000..825f39046b --- /dev/null +++ b/apps/mesh/src/cli/autostart/register.ts @@ -0,0 +1,176 @@ +/** + * Registers a detected & spawned MCP project as a Studio connection + agent + * (Virtual MCP) in-process, against the running server's database. + * + * Idempotency: IDs are deterministic from a hash of the absolute project path, + * so re-running `bunx decocms` in the same folder reuses the existing + * connection/agent (just refreshes the URL if the port changed). + */ + +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import type { ConnectionStorage } from "../../storage/connection"; +import type { VirtualMCPStorage } from "../../storage/virtual"; +import type { DetectedProject } from "./detect"; + +export interface PinnedView { + connectionId: string; + toolName: string; + label: string; + icon: string | null; +} + +export interface AutostartLayout { + defaultMainView?: { + type: string; + id?: string; + toolName?: string; + } | null; + chatDefaultOpen?: boolean | null; +} + +export interface RegisterParams { + connections: ConnectionStorage; + virtualMcps: VirtualMCPStorage; + organizationId: string; + userId: string; + project: DetectedProject; + /** The HTTP MCP endpoint we're spawning (full URL, e.g. http://localhost:3001/mcp). */ + mcpUrl: string; + /** Drafted system prompt (template fallback or LLM-generated). */ + instructions: string; + /** Optional pinned views (one per UI-bearing tool). */ + pinnedViews?: PinnedView[]; + /** Optional layout (defaultMainView + chatDefaultOpen). */ + layout?: AutostartLayout; +} + +export interface RegisterResult { + connectionId: string; + virtualMcpId: string; + isNew: boolean; +} + +/** + * Hash an absolute path → 12-char hex slug. Stable across re-runs. + */ +export function pathSlug(absPath: string): string { + return createHash("sha256").update(absPath).digest("hex").slice(0, 12); +} + +export function autostartConnectionId(absPath: string): string { + return `conn_auto_${pathSlug(absPath)}`; +} + +export function autostartVirtualMcpId(absPath: string): string { + return `vir_auto_${pathSlug(absPath)}`; +} + +export async function registerProjectAsAgent( + params: RegisterParams, +): Promise { + const { + connections, + virtualMcps, + organizationId, + userId, + project, + mcpUrl, + instructions, + pinnedViews, + layout, + } = params; + + const absRoot = resolve(project.root); + const connectionId = autostartConnectionId(absRoot); + const virtualMcpId = autostartVirtualMcpId(absRoot); + + // detect.ts already resolves description (pkg.description → first README + // paragraph). Use it as-is. + const description = project.description; + + const existingConnection = await connections.findById( + connectionId, + organizationId, + ); + + let isNew = false; + if (existingConnection) { + // Refresh URL in case the port changed (port is dynamic per run). + if (existingConnection.connection_url !== mcpUrl) { + await connections.update(connectionId, { + connection_url: mcpUrl, + updated_by: userId, + }); + } + } else { + isNew = true; + await connections.create({ + id: connectionId, + organization_id: organizationId, + created_by: userId, + title: project.name, + description, + icon: null, + connection_type: "HTTP", + connection_url: mcpUrl, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + metadata: { autostart: { source: resolve(project.root) } }, + bindings: null, + }); + } + + const existingAgent = await virtualMcps.findById( + virtualMcpId, + organizationId, + ); + + if (existingAgent) { + // Update instructions only if user hasn't customized them (no metadata + // marker), or if --reprompt was passed (caller decides by passing fresh + // instructions; the existing flow doesn't differentiate, so always + // refresh on re-runs is too aggressive — keep the existing instructions + // unless the agent has the autostart marker AND no user edit). + const meta = (existingAgent.metadata ?? {}) as Record; + const isAutostartManaged = Boolean( + (meta.autostart as { source?: string } | undefined)?.source, + ); + const hasInstructions = Boolean(meta.instructions); + if (isAutostartManaged && !hasInstructions) { + await virtualMcps.update(virtualMcpId, userId, { + metadata: { ...meta, instructions }, + }); + } + } else { + isNew = true; + const ui = + pinnedViews && pinnedViews.length > 0 + ? { pinnedViews, layout: layout ?? null } + : layout + ? { pinnedViews: null, layout } + : null; + await virtualMcps.create( + organizationId, + userId, + { + title: project.name, + description, + status: "active", + pinned: true, + connections: [{ connection_id: connectionId }], + metadata: { + instructions, + autostart: { source: resolve(project.root) }, + ...(ui ? { ui } : {}), + }, + }, + { id: virtualMcpId }, + ); + } + + return { connectionId, virtualMcpId, isNew }; +} diff --git a/apps/mesh/src/cli/autostart/spawn.ts b/apps/mesh/src/cli/autostart/spawn.ts new file mode 100644 index 0000000000..9bc1690c54 --- /dev/null +++ b/apps/mesh/src/cli/autostart/spawn.ts @@ -0,0 +1,240 @@ +/** + * Spawns the detected MCP project's dev server and waits for it to listen. + * + * The child's stdout/stderr is piped into the Ink TUI log store via the same + * mechanism `serve.ts` uses for worker processes — so users see the project's + * own logs without having to find a separate terminal. + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { addLogEntry } from "../cli-store"; +import { findAvailablePort } from "../find-available-port"; +import type { DetectedProject } from "./detect"; + +const PM_RUN: Record = { + bun: ["bun", "run"], + pnpm: ["pnpm", "run"], + yarn: ["yarn", "run"], + npm: ["npm", "run"], + deno: ["deno", "task"], +}; + +const PM_INSTALL: Record = { + bun: ["bun", "install"], + pnpm: ["pnpm", "install"], + yarn: ["yarn", "install"], + npm: ["npm", "install"], + deno: null, // deno installs on first run +}; + +const HEALTHCHECK_TIMEOUT_MS = 30_000; +const HEALTHCHECK_INTERVAL_MS = 250; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI codes +// oxlint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(s: string): string { + return s.replace(ANSI_RE, ""); +} + +function pipeChildOutput( + stream: ReadableStream, + prefix: string, +): void { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + function flush(line: string) { + const stripped = stripAnsi(line).trim(); + if (!stripped) return; + addLogEntry({ + method: "", + path: "", + status: 0, + duration: 0, + timestamp: new Date(), + rawLine: `${prefix} ${stripped}`, + }); + } + + void (async () => { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const l of lines) flush(l); + } + if (buffer) flush(buffer); + })(); +} + +export interface SpawnedDevServer { + port: number; + baseUrl: string; + mcpUrl: string; + /** Subprocess handle so the orchestrator can register it for shutdown. */ + child: import("bun").Subprocess; + /** Stop the child. Idempotent. */ + kill: () => void; +} + +/** + * Probe candidate URLs until one of them returns a non-network-error response. + * MCP servers commonly respond 405/406 to GET (they want POST), which is fine — + * we only care that the port is bound. + */ +async function waitForListen( + candidates: string[], + signal: AbortSignal, +): Promise { + const deadline = Date.now() + HEALTHCHECK_TIMEOUT_MS; + while (Date.now() < deadline) { + if (signal.aborted) return null; + for (const url of candidates) { + try { + const res = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(2_000), + }); + // Any HTTP response means the server is up. + void res.body?.cancel(); + return url; + } catch { + // not yet + } + } + await new Promise((r) => setTimeout(r, HEALTHCHECK_INTERVAL_MS)); + } + return null; +} + +async function ensureDepsInstalled(project: DetectedProject): Promise { + const installCmd = PM_INSTALL[project.packageManager]; + if (!installCmd) return; + const nodeModules = join(project.root, "node_modules"); + if (existsSync(nodeModules)) return; + + addLogEntry({ + method: "", + path: "", + status: 0, + duration: 0, + timestamp: new Date(), + rawLine: `[autostart] node_modules missing, running ${installCmd.join(" ")}…`, + }); + + const child = Bun.spawn(installCmd, { + cwd: project.root, + env: { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, + stdout: "pipe", + stderr: "pipe", + }); + const prefix = `[${project.name} install]`; + if (child.stdout) pipeChildOutput(child.stdout, prefix); + if (child.stderr) pipeChildOutput(child.stderr, prefix); + const code = await child.exited; + if (code !== 0) { + throw new Error( + `${installCmd.join(" ")} exited with code ${code} (see logs above)`, + ); + } +} + +export async function startMcpDevServer( + project: DetectedProject, + options?: { startPort?: number; abortSignal?: AbortSignal }, +): Promise { + await ensureDepsInstalled(project); + + const startPort = options?.startPort ?? 3001; + const port = await findAvailablePort(startPort); + const pmCmd = PM_RUN[project.packageManager]; + const cmd = pmCmd[0]!; + const baseArgs = pmCmd.slice(1); + const args = [...baseArgs, project.starter]; + + const env: Record = { + ...process.env, + PORT: String(port), + HOST: "0.0.0.0", + HOSTNAME: "0.0.0.0", + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + }; + + addLogEntry({ + method: "", + path: "", + status: 0, + duration: 0, + timestamp: new Date(), + rawLine: `[autostart] $ ${cmd} ${args.join(" ")} (cwd=${project.root}, PORT=${port})`, + }); + + const child = Bun.spawn([cmd, ...args], { + cwd: project.root, + env, + stdout: "pipe", + stderr: "pipe", + }); + + const prefix = `[${project.name}]`; + if (child.stdout) pipeChildOutput(child.stdout, prefix); + if (child.stderr) pipeChildOutput(child.stderr, prefix); + + const baseUrl = `http://localhost:${port}`; + const candidates = [`${baseUrl}/mcp`, `${baseUrl}/api/mcp`, `${baseUrl}/`]; + + const ctrl = new AbortController(); + if (options?.abortSignal) { + options.abortSignal.addEventListener("abort", () => ctrl.abort(), { + once: true, + }); + } + + // Race healthcheck against the child exiting (broken project, missing deps). + let exitedCode: number | null = null; + const childExitPromise = child.exited.then((code) => { + exitedCode = code; + ctrl.abort(); + return code; + }); + + const ready = await Promise.race([ + waitForListen(candidates, ctrl.signal), + childExitPromise.then(() => null as string | null), + ]); + + let killed = false; + const kill = () => { + if (killed) return; + killed = true; + try { + child.kill(); + } catch { + // already gone + } + }; + + if (!ready) { + kill(); + if (exitedCode !== null) { + throw new Error( + `${project.packageManager} run ${project.starter} exited with code ${exitedCode} before binding :${port} (see logs above)`, + ); + } + throw new Error( + `${project.name} did not bind :${port} within ${HEALTHCHECK_TIMEOUT_MS / 1000}s`, + ); + } + + // Pick the URL we actually got a response from as the canonical mcp URL, + // unless it's just the root path — in that case keep /mcp as a guess. + const mcpUrl = ready.endsWith("/") ? `${baseUrl}/mcp` : ready; + + return { port, baseUrl, mcpUrl, child, kill }; +} diff --git a/apps/mesh/src/cli/cli-store.ts b/apps/mesh/src/cli/cli-store.ts index dde4eae625..78db962d7f 100644 --- a/apps/mesh/src/cli/cli-store.ts +++ b/apps/mesh/src/cli/cli-store.ts @@ -8,6 +8,13 @@ import type { LogEntry } from "./log-emitter"; const MAX_LOGS = 500; +export interface AutostartProjectState { + name: string; + status: "starting" | "ready" | "failed"; + chatUrl: string | null; + error?: string; +} + interface CliState { services: ServiceStatus[]; migrationsStatus: "pending" | "done"; @@ -18,6 +25,7 @@ interface CliState { logFlow: boolean; vibe: boolean; dataDir: string | null; + autostartProject: AutostartProjectState | null; } let state: CliState = { @@ -33,6 +41,7 @@ let state: CliState = { logFlow: false, vibe: false, dataDir: null, + autostartProject: null, }; const listeners = new Set<() => void>(); @@ -68,6 +77,11 @@ export function setServerUrl(url: string) { emit(); } +export function setAutostartProject(p: AutostartProjectState | null) { + state = { ...state, autostartProject: p }; + emit(); +} + export function setEnv(env: Settings) { state = { ...state, env }; emit(); diff --git a/apps/mesh/src/cli/commands/serve.ts b/apps/mesh/src/cli/commands/serve.ts index d0f1a38270..1f6a7d83ba 100644 --- a/apps/mesh/src/cli/commands/serve.ts +++ b/apps/mesh/src/cli/commands/serve.ts @@ -133,7 +133,9 @@ export function interceptConsoleForTui() { }; } -export async function startServer(options: ServeOptions): Promise { +export async function startServer( + options: ServeOptions, +): Promise<{ port: number }> { const port = await findAvailablePort(Number(options.port)); const { settings, services } = await buildSettings({ @@ -208,4 +210,5 @@ export async function startServer(options: ServeOptions): Promise { await import("../../index"); setServerUrl(`http://localhost:${settings.port}`); + return { port: Number(settings.port) }; } diff --git a/apps/mesh/src/cli/header.tsx b/apps/mesh/src/cli/header.tsx index 9ecd55cf97..302af67613 100644 --- a/apps/mesh/src/cli/header.tsx +++ b/apps/mesh/src/cli/header.tsx @@ -11,12 +11,20 @@ export interface ServiceStatus { port: number; } +export interface HeaderAutostartProject { + name: string; + status: "starting" | "ready" | "failed"; + chatUrl: string | null; + error?: string; +} + interface HeaderProps { services: ServiceStatus[]; migrationsStatus: "pending" | "done"; home: string; serverUrl: string | null; vibe?: boolean; + autostartProject?: HeaderAutostartProject | null; } const ASCII_LINES = [ @@ -84,6 +92,7 @@ export function Header({ home, serverUrl, vibe, + autostartProject, }: HeaderProps) { const capyFrame = useSyncExternalStore(subscribeCapyFrame, getCapyFrame); const matrixGrid = useSyncExternalStore(subscribeMatrixGrid, getMatrixGrid); @@ -169,6 +178,34 @@ export function Header({ )} + {autostartProject && ( + + + + Autostart: + {autostartProject.name} + {autostartProject.status === "starting" && } + {autostartProject.status === "failed" && ( + failed + )} + {autostartProject.status === "ready" && ( + ready + )} + + {autostartProject.chatUrl && ( + + {" "}Chat: {autostartProject.chatUrl} + + )} + {autostartProject.error && ( + + {" "} + {autostartProject.error} + + )} + + )} + diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index 125acd8e49..21fc546564 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -267,6 +267,14 @@ async function gracefulShutdown(signal: string) { // 1. Mark as shutting down — readiness returns 503 immediately app.markShuttingDown(); + // Stop any autostart child processes (mcp dev servers we spawned). + try { + const { getAutostartChildren } = await import("./cli/autostart"); + for (const c of getAutostartChildren()) c.kill(); + } catch { + // autostart module never loaded — nothing to kill + } + // 2. Close ingress first so port 7070 frees immediately — next `bun dev` // shouldn't have to wait out our drain. for (const s of ingressServers) s.close();