diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 386d7fbe6d..8e75b18f0e 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -49,6 +49,9 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@freestyle-sh/with-bun": "^0.2.10", + "@freestyle-sh/with-deno": "^0.0.2", + "@freestyle-sh/with-nodejs": "^0.2.8", "@inkjs/ui": "^2.0.0", "@modelcontextprotocol/ext-apps": "^1.2.2", "@openrouter/ai-sdk-provider": "^2.2.5", @@ -57,6 +60,7 @@ "ai-sdk-provider-claude-code": "^3.4.4", "ai-sdk-provider-codex-cli": "^1.1.0", "embedded-postgres": "^18.3.0-beta.16", + "freestyle-sandboxes": "^0.1.43", "ink": "^6.8.0", "kysely": "^0.28.12", "nats": "^2.29.3", @@ -73,13 +77,13 @@ "@decocms/better-auth": "1.5.17", "@decocms/bindings": "workspace:*", "@decocms/mcp-utils": "workspace:*", - "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@decocms/mesh-sdk": "workspace:*", "@decocms/runtime": "workspace:*", "@decocms/vite-plugin": "workspace:*", "@electric-sql/pglite": "^0.3.15", "@floating-ui/react": "^0.27.16", "@hookform/resolvers": "^5.2.2", + "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@modelcontextprotocol/sdk": "1.27.1", "@monaco-editor/react": "^4.7.0", "@opentelemetry/api": "^1.9.0", @@ -123,6 +127,7 @@ "@vercel/nft": "^1.1.1", "@vitejs/plugin-react": "^5.1.0", "ai": "^6.0.116", + "ansi-to-html": "^0.7.2", "babel-plugin-react-compiler": "^1.0.0", "better-auth": "1.4.5", "class-variance-authority": "^0.7.1", @@ -136,7 +141,6 @@ "jose": "^6.0.11", "kysely-pglite": "^0.6.1", "lucide-react": "^0.468.0", - "react-resizable-panels": "^2.1.7", "marked": "^15.0.6", "mesh-plugin-workflows": "workspace:*", "nanoid": "^5.1.6", @@ -144,6 +148,7 @@ "prettier": "^3.4.2", "react-hook-form": "^7.66.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^2.1.7", "react-syntax-highlighter": "^15.6.1", "recharts": "^3.6.0", "rehype-raw": "^7.0.0", diff --git a/apps/mesh/src/tools/github/device-flow-poll.ts b/apps/mesh/src/tools/github/device-flow-poll.ts new file mode 100644 index 0000000000..a9b3587716 --- /dev/null +++ b/apps/mesh/src/tools/github/device-flow-poll.ts @@ -0,0 +1,94 @@ +/** + * GITHUB_DEVICE_FLOW_POLL Tool + * + * Polls GitHub Device Flow for access token. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; + +const GITHUB_CLIENT_ID = "Iv23liLNDj260RBdPV7p"; + +export const GITHUB_DEVICE_FLOW_POLL = defineTool({ + name: "GITHUB_DEVICE_FLOW_POLL", + description: "Poll GitHub Device Flow for the access token.", + annotations: { + title: "Poll GitHub Device Flow", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + deviceCode: z.string().describe("Device code from the start step"), + }), + outputSchema: z.object({ + status: z.enum(["pending", "success", "expired", "error"]), + token: z.string().nullable(), + error: z.string().nullable(), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const response = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + device_code: input.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }, + ); + + if (!response.ok) { + return { + status: "error" as const, + token: null, + error: `HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as { + access_token?: string; + error?: string; + error_description?: string; + }; + + if (data.access_token) { + return { + status: "success" as const, + token: data.access_token, + error: null, + }; + } + + if (data.error === "authorization_pending" || data.error === "slow_down") { + return { status: "pending" as const, token: null, error: null }; + } + + if (data.error === "expired_token") { + return { + status: "expired" as const, + token: null, + error: "Device code expired", + }; + } + + return { + status: "error" as const, + token: null, + error: data.error_description ?? data.error ?? "Unknown error", + }; + }, +}); diff --git a/apps/mesh/src/tools/github/device-flow-start.ts b/apps/mesh/src/tools/github/device-flow-start.ts new file mode 100644 index 0000000000..89889f98ba --- /dev/null +++ b/apps/mesh/src/tools/github/device-flow-start.ts @@ -0,0 +1,71 @@ +/** + * GITHUB_DEVICE_FLOW_START Tool + * + * Starts GitHub Device Flow authentication. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; + +// Deco CMS GitHub App client ID (public, safe to hardcode) +const GITHUB_CLIENT_ID = "Iv23liLNDj260RBdPV7p"; + +export const GITHUB_DEVICE_FLOW_START = defineTool({ + name: "GITHUB_DEVICE_FLOW_START", + description: "Start GitHub Device Flow authentication to get a user code.", + annotations: { + title: "Start GitHub Device Flow", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({}), + outputSchema: z.object({ + userCode: z.string(), + verificationUri: z.string(), + deviceCode: z.string(), + expiresIn: z.number(), + interval: z.number(), + }), + + handler: async (_input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const response = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + scope: "", + }), + }); + + if (!response.ok) { + throw new Error(`GitHub Device Flow error: ${response.status}`); + } + + const data = (await response.json()) as { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; + }; + + return { + userCode: data.user_code, + verificationUri: data.verification_uri, + deviceCode: data.device_code, + expiresIn: data.expires_in, + interval: data.interval, + }; + }, +}); diff --git a/apps/mesh/src/tools/github/get-file-content.ts b/apps/mesh/src/tools/github/get-file-content.ts new file mode 100644 index 0000000000..b790bd99ec --- /dev/null +++ b/apps/mesh/src/tools/github/get-file-content.ts @@ -0,0 +1,56 @@ +/** + * GITHUB_GET_FILE_CONTENT Tool + * + * Fetches raw file content from a GitHub repository. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; + +export const GITHUB_GET_FILE_CONTENT = defineTool({ + name: "GITHUB_GET_FILE_CONTENT", + description: "Fetch raw file content from a GitHub repository by path.", + annotations: { + title: "Get GitHub File Content", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + token: z.string().describe("GitHub access token"), + owner: z.string().describe("Repository owner"), + repo: z.string().describe("Repository name"), + path: z.string().describe("File path within the repository"), + }), + outputSchema: z.object({ + content: z.string().nullable(), + found: z.boolean(), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const url = `https://raw.githubusercontent.com/${input.owner}/${input.repo}/HEAD/${input.path}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${input.token}`, + }, + }); + + if (response.status === 404) { + return { content: null, found: false }; + } + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const content = await response.text(); + return { content, found: true }; + }, +}); diff --git a/apps/mesh/src/tools/github/index.ts b/apps/mesh/src/tools/github/index.ts new file mode 100644 index 0000000000..eb174c7d52 --- /dev/null +++ b/apps/mesh/src/tools/github/index.ts @@ -0,0 +1,11 @@ +/** + * GitHub Tools + * + * Tools for GitHub integration (app-only, not visible to AI models). + */ + +export { GITHUB_LIST_INSTALLATIONS } from "./list-installations"; +export { GITHUB_LIST_REPOS } from "./list-repos"; +export { GITHUB_DEVICE_FLOW_START } from "./device-flow-start"; +export { GITHUB_DEVICE_FLOW_POLL } from "./device-flow-poll"; +export { GITHUB_GET_FILE_CONTENT } from "./get-file-content"; diff --git a/apps/mesh/src/tools/github/list-installations.ts b/apps/mesh/src/tools/github/list-installations.ts new file mode 100644 index 0000000000..11502bc657 --- /dev/null +++ b/apps/mesh/src/tools/github/list-installations.ts @@ -0,0 +1,93 @@ +/** + * GITHUB_LIST_INSTALLATIONS Tool + * + * Lists GitHub App installations of the Deco CMS app for the current user. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; + +const InstallationSchema = z.object({ + installationId: z.number(), + orgName: z.string(), + avatarUrl: z.string().nullable(), +}); + +export const GITHUB_LIST_INSTALLATIONS = defineTool({ + name: "GITHUB_LIST_INSTALLATIONS", + description: + "List GitHub App installations of the Deco CMS app for the current user.", + annotations: { + title: "List GitHub Installations", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + token: z.string().describe("GitHub access token"), + }), + outputSchema: z.object({ + installations: z.array(InstallationSchema), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const headers = { + Authorization: `Bearer ${input.token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + type GitHubInstallation = { + id: number; + account: { + login: string; + avatar_url: string; + }; + }; + + const allInstallations: GitHubInstallation[] = []; + let nextUrl: string | undefined = + "https://api.github.com/user/installations?per_page=100"; + + while (nextUrl) { + const response: Response = await fetch(nextUrl, { headers }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as { + installations: GitHubInstallation[]; + }; + allInstallations.push(...data.installations); + + // Parse Link header for next page + nextUrl = undefined; + const link = response.headers.get("link"); + if (link) { + const next = link + .split(",") + .find((part: string) => part.includes('rel="next"')); + if (next) { + const match = next.match(/<([^>]+)>/); + if (match?.[1]) nextUrl = match[1]; + } + } + } + + const installations = allInstallations.map((inst) => ({ + installationId: inst.id, + orgName: inst.account.login, + avatarUrl: inst.account.avatar_url, + })); + + return { installations }; + }, +}); diff --git a/apps/mesh/src/tools/github/list-repos.ts b/apps/mesh/src/tools/github/list-repos.ts new file mode 100644 index 0000000000..962383b4b3 --- /dev/null +++ b/apps/mesh/src/tools/github/list-repos.ts @@ -0,0 +1,95 @@ +/** + * GITHUB_LIST_REPOS Tool + * + * Lists repositories accessible under a GitHub App installation. + * App-only tool — not visible to AI models. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireAuth } from "../../core/mesh-context"; + +const RepoSchema = z.object({ + owner: z.string(), + name: z.string(), + fullName: z.string(), + url: z.string(), + private: z.boolean(), +}); + +export const GITHUB_LIST_REPOS = defineTool({ + name: "GITHUB_LIST_REPOS", + description: "List repositories accessible under a GitHub App installation.", + annotations: { + title: "List GitHub Repos", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + token: z.string().describe("GitHub access token"), + installationId: z.number().describe("GitHub App installation ID"), + }), + outputSchema: z.object({ + repos: z.array(RepoSchema), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + + const headers = { + Authorization: `Bearer ${input.token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + type GitHubRepo = { + owner: { login: string }; + name: string; + full_name: string; + html_url: string; + private: boolean; + }; + + const allRepos: GitHubRepo[] = []; + let nextUrl: string | undefined = + `https://api.github.com/user/installations/${input.installationId}/repositories?per_page=100`; + + while (nextUrl) { + const response: Response = await fetch(nextUrl, { headers }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as { repositories: GitHubRepo[] }; + allRepos.push(...data.repositories); + + // Parse Link header for next page + nextUrl = undefined; + const link = response.headers.get("link"); + if (link) { + const next = link + .split(",") + .find((part: string) => part.includes('rel="next"')); + if (next) { + const match = next.match(/<([^>]+)>/); + if (match?.[1]) nextUrl = match[1]; + } + } + } + + const repos = allRepos.map((repo) => ({ + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + url: repo.html_url, + private: repo.private, + })); + + return { repos }; + }, +}); diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index f0b21e5a70..2c736b5bd8 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -33,6 +33,8 @@ import * as AiProvidersTools from "./ai-providers"; import { getPrompts, getResources } from "./guides"; import * as ObjectStorageTools from "./object-storage"; import * as RegistryTools from "./registry/index"; +import * as GitHubTools from "./github"; +import * as VmTools from "./vm"; import { ToolName } from "./registry-metadata"; // Core tools - always available const CORE_TOOLS = [ @@ -152,6 +154,18 @@ const CORE_TOOLS = [ // Registry tools ...RegistryTools.tools, + + // GitHub tools (app-only) + GitHubTools.GITHUB_LIST_INSTALLATIONS, + GitHubTools.GITHUB_LIST_REPOS, + GitHubTools.GITHUB_DEVICE_FLOW_START, + GitHubTools.GITHUB_DEVICE_FLOW_POLL, + GitHubTools.GITHUB_GET_FILE_CONTENT, + + // VM tools (app-only) + VmTools.VM_START, + VmTools.VM_DELETE, + VmTools.VM_EXEC, ] as const satisfies { name: ToolName }[]; // Plugin tools - collected at startup, gated by org settings at runtime diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index dacc4a5ab4..04bb668c83 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -31,7 +31,9 @@ export type ToolCategory = | "AI Providers" | "Automations" | "Object Storage" - | "Registry"; + | "Registry" + | "GitHub" + | "VM"; /** * All tool names - keep in sync with ALL_TOOLS in index.ts @@ -174,6 +176,18 @@ const ALL_TOOL_NAMES = [ "REGISTRY_MONITOR_CONNECTION_UPDATE_AUTH", "REGISTRY_MONITOR_SCHEDULE_SET", "REGISTRY_MONITOR_SCHEDULE_CANCEL", + + // GitHub tools (app-only) + "GITHUB_LIST_INSTALLATIONS", + "GITHUB_LIST_REPOS", + "GITHUB_DEVICE_FLOW_START", + "GITHUB_DEVICE_FLOW_POLL", + "GITHUB_GET_FILE_CONTENT", + + // VM tools (app-only) + "VM_START", + "VM_DELETE", + "VM_EXEC", ] as const; /** @@ -838,6 +852,47 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Cancel monitor schedule", category: "Registry", }, + // GitHub tools + { + name: "GITHUB_LIST_INSTALLATIONS", + description: "List GitHub App installations", + category: "GitHub", + }, + { + name: "GITHUB_LIST_REPOS", + description: "List repositories for a GitHub App installation", + category: "GitHub", + }, + { + name: "GITHUB_DEVICE_FLOW_START", + description: "Start GitHub Device Flow authentication", + category: "GitHub", + }, + { + name: "GITHUB_DEVICE_FLOW_POLL", + description: "Poll GitHub Device Flow for access token", + category: "GitHub", + }, + { + name: "GITHUB_GET_FILE_CONTENT", + description: "Fetch file content from a GitHub repository", + category: "GitHub", + }, + { + name: "VM_START", + description: "Start a Freestyle VM with dev server preview", + category: "VM", + }, + { + name: "VM_DELETE", + description: "Stop and delete a Freestyle VM", + category: "VM", + }, + { + name: "VM_EXEC", + description: "Execute install or dev commands inside a running VM", + category: "VM", + }, ]; /** @@ -969,6 +1024,18 @@ const TOOL_LABELS: Record = { REGISTRY_MONITOR_CONNECTION_UPDATE_AUTH: "Update connection auth", REGISTRY_MONITOR_SCHEDULE_SET: "Set monitor schedule", REGISTRY_MONITOR_SCHEDULE_CANCEL: "Cancel monitor schedule", + + // GitHub + GITHUB_LIST_INSTALLATIONS: "List GitHub App installations", + GITHUB_LIST_REPOS: "List GitHub repositories", + GITHUB_DEVICE_FLOW_START: "Start GitHub Device Flow", + GITHUB_DEVICE_FLOW_POLL: "Poll GitHub Device Flow", + GITHUB_GET_FILE_CONTENT: "Get GitHub file content", + + // VM + VM_START: "Start VM preview", + VM_DELETE: "Delete VM preview", + VM_EXEC: "Execute VM command", }; // ============================================================================ @@ -993,6 +1060,7 @@ export function getToolsByCategory() { Automations: [], "Object Storage": [], Registry: [], + GitHub: [], }; for (const tool of MANAGEMENT_TOOLS) { diff --git a/apps/mesh/src/tools/vm/exec.test.ts b/apps/mesh/src/tools/vm/exec.test.ts new file mode 100644 index 0000000000..acb0533ff5 --- /dev/null +++ b/apps/mesh/src/tools/vm/exec.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test"; +import type { MeshContext } from "../../core/mesh-context"; +import type { VmEntry, VmMetadata } from "./types"; + +// --------------------------------------------------------------------------- +// Mock freestyle-sandboxes BEFORE importing VM_EXEC (Bun requires this order) +// --------------------------------------------------------------------------- + +const mockVmExec = mock( + (_command: unknown): Promise => Promise.resolve(), +); + +const mockVmsRef = mock((_input: { vmId: string }) => ({ + exec: (command: unknown) => mockVmExec(command), +})); + +mock.module("freestyle-sandboxes", () => ({ + freestyle: { + vms: { + ref: (input: { vmId: string }) => mockVmsRef(input), + }, + }, +})); + +// Now import after mocking +const { VM_EXEC } = await import("./exec"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const BASE_METADATA: VmMetadata = { + githubRepo: { + url: "https://github.com/acme/app", + owner: "acme", + name: "app", + }, + runtime: { + detected: "npm", + selected: "npm", + installScript: "npm install", + devScript: "npm run dev", + port: "3000", + }, +}; + +const EXISTING_ENTRY: VmEntry = { + vmId: "vm_existing", + previewUrl: "https://vmcp-1.deco.studio", + terminalUrl: "https://vmcp-1-term.deco.studio", +}; + +function makeVirtualMcp(orgId: string, metadata: VmMetadata, id = "vmcp_1") { + return { + id, + organization_id: orgId, + metadata, + title: "Test Virtual MCP", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user_1", + }; +} + +function makeCtx(overrides: { + orgId?: string; + userId?: string; + virtualMcp?: ReturnType | null; +}): MeshContext { + const { orgId = "org_1", userId = "user_1", virtualMcp } = overrides; + + const findById = mock(async (_id: string) => virtualMcp ?? null); + + return { + auth: { + user: { + id: userId, + email: "[email protected]", + name: "Test", + role: "user", + }, + }, + organization: { id: orgId, slug: "test-org", name: "Test Org" }, + access: { + granted: () => true, + check: async () => {}, + grant: () => {}, + setToolName: () => {}, + }, + storage: { + virtualMcps: { + findById, + }, + } as never, + timings: { + measure: async (_name: string, cb: () => Promise) => await cb(), + }, + vault: null as never, + authInstance: null as never, + boundAuth: null as never, + db: null as never, + tracer: { + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (span: unknown) => unknown, + ) => + fn({ + setStatus: () => {}, + recordException: () => {}, + end: () => {}, + }), + } as never, + meter: { + createHistogram: () => ({ record: () => {} }), + createCounter: () => ({ add: () => {} }), + } as never, + baseUrl: "https://mesh.example.com", + metadata: { requestId: "req_1", timestamp: new Date() }, + eventBus: null as never, + objectStorage: null as never, + aiProviders: null as never, + createMCPProxy: null as never, + getOrCreateClient: null as never, + pendingRevalidations: [], + monitoring: null as never, + } as unknown as MeshContext; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("VM_EXEC", () => { + beforeEach(() => { + mockVmExec.mockReset(); + mockVmsRef.mockReset(); + mockVmExec.mockImplementation(async () => {}); + mockVmsRef.mockImplementation((_input: { vmId: string }) => ({ + exec: (command: unknown) => mockVmExec(command), + })); + }); + + it("install action calls vm.exec with install commands and returns { success: true }", async () => { + const metadata: VmMetadata = { + ...BASE_METADATA, + activeVms: { user_1: EXISTING_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const ctx = makeCtx({ virtualMcp }); + + const result = await VM_EXEC.handler( + { virtualMcpId: "vmcp_1", action: "install" }, + ctx, + ); + + expect(result).toEqual({ success: true }); + expect(mockVmsRef).toHaveBeenCalledWith({ vmId: EXISTING_ENTRY.vmId }); + // Single nohup fire-and-forget call that runs install in background + expect(mockVmExec.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it("dev action calls vm.exec with nohup dev command and returns { success: true }", async () => { + const metadata: VmMetadata = { + ...BASE_METADATA, + activeVms: { user_1: EXISTING_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const ctx = makeCtx({ virtualMcp }); + + const result = await VM_EXEC.handler( + { virtualMcpId: "vmcp_1", action: "dev" }, + ctx, + ); + + expect(result).toEqual({ success: true }); + expect(mockVmsRef).toHaveBeenCalledWith({ vmId: EXISTING_ENTRY.vmId }); + + // Find the nohup dev server call + const calls = mockVmExec.mock.calls as unknown[][]; + const nohupCall = calls.find((args) => { + const cmd = args[0]; + if (typeof cmd === "object" && cmd !== null && "command" in cmd) { + return (cmd as { command: string }).command.includes("nohup bash -c"); + } + return false; + }); + expect(nohupCall).toBeDefined(); + const nohupCmd = nohupCall![0] as { command: string }; + expect(nohupCmd.command).toContain("npm run dev"); + expect(nohupCmd.command).toContain("PORT=3000"); + }); + + it("throws when no active VM entry exists for current user", async () => { + const metadata: VmMetadata = { + ...BASE_METADATA, + activeVms: { other_user: EXISTING_ENTRY }, // no entry for user_1 + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const ctx = makeCtx({ virtualMcp }); + + await expect( + VM_EXEC.handler({ virtualMcpId: "vmcp_1", action: "install" }, ctx), + ).rejects.toThrow("No active VM found. Start a VM first."); + }); + + it("returns { success: false, error } when vm.exec fails", async () => { + const metadata: VmMetadata = { + ...BASE_METADATA, + activeVms: { user_1: EXISTING_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const ctx = makeCtx({ virtualMcp }); + + mockVmExec.mockImplementation(async () => { + throw new Error("exec failed: timeout"); + }); + + const result = await VM_EXEC.handler( + { virtualMcpId: "vmcp_1", action: "install" }, + ctx, + ); + + expect(result).toEqual({ success: false, error: "exec failed: timeout" }); + }); + + it("install action with deno runtime runs deno install without curl setup", async () => { + const denoMetadata: VmMetadata = { + githubRepo: BASE_METADATA.githubRepo, + runtime: { + detected: "deno", + selected: "deno", + installScript: "deno install", + devScript: "deno task dev", + port: "8000", + }, + activeVms: { user_1: EXISTING_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", denoMetadata); + const ctx = makeCtx({ virtualMcp }); + + const result = await VM_EXEC.handler( + { virtualMcpId: "vmcp_1", action: "install" }, + ctx, + ); + + expect(result).toEqual({ success: true }); + + // Verify no curl install script — runtime is pre-installed via Freestyle integrations + const calls = mockVmExec.mock.calls as unknown[][]; + const curlCall = calls.find((args) => { + const cmd = args[0]; + if (typeof cmd === "object" && cmd !== null && "command" in cmd) { + return (cmd as { command: string }).command.includes("curl"); + } + return false; + }); + expect(curlCall).toBeUndefined(); + + // Verify deno install is called + const installCall = calls.find((args) => { + const cmd = args[0]; + if (typeof cmd === "object" && cmd !== null && "command" in cmd) { + return (cmd as { command: string }).command.includes("deno install"); + } + return false; + }); + expect(installCall).toBeDefined(); + }); +}); diff --git a/apps/mesh/src/tools/vm/exec.ts b/apps/mesh/src/tools/vm/exec.ts new file mode 100644 index 0000000000..064d0b3079 --- /dev/null +++ b/apps/mesh/src/tools/vm/exec.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { freestyle } from "freestyle-sandboxes"; +import { requireVmEntry, resolveRuntimeConfig } from "./helpers"; + +export const VM_EXEC = defineTool({ + name: "VM_EXEC", + description: "Execute install or dev commands inside a running VM.", + annotations: { + title: "Execute VM Command", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + virtualMcpId: z.string().describe("Virtual MCP ID"), + action: z.enum(["install", "dev"]).describe("Action to execute"), + }), + outputSchema: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + + handler: async (input, ctx) => { + const { entry, metadata } = await requireVmEntry(input, ctx); + if (!entry) { + throw new Error("No active VM found. Start a VM first."); + } + + const vm = freestyle.vms.ref({ vmId: entry.vmId }); + const { installScript, devScript, port, runtimeBinPath } = + resolveRuntimeConfig(metadata); + const pathPrefix = runtimeBinPath + ? `export PATH=${runtimeBinPath}:$PATH && ` + : ""; + + try { + if (input.action === "install") { + // Build the full install script that runs in the background. + // All output goes to /tmp/vm.log so the ttyd terminal shows progress. + // Runtime (node/deno/bun) is pre-installed via Freestyle integrations. + // No manual curl installs needed. + const steps: string[] = [ + 'echo "" && echo "--- Reinstalling dependencies ---"', + // Wait for git repo to be synced (oneshot services become "inactive" on + // success, so is-active returns exit 3 — treat that as OK). + "systemctl is-active --wait freestyle-git-sync.service || [ $? -eq 3 ]", + `${pathPrefix}echo "$ ${installScript}" && cd /app && ${installScript}`, + ]; + + const script = steps.join(" && "); + + // Fire and forget — output streams to /tmp/vm.log via ttyd. + // Don't await: vm.exec() blocks until all child processes exit. + vm.exec({ + command: `nohup bash -c '(${script}) >> /tmp/vm.log 2>&1 &'`, + }).catch(console.error); + + return { success: true }; + } + + // action === "dev" + // Kill old dev server and start a new one. Fire-and-forget to avoid + // blocking — vm.exec() waits for all child processes to exit. + // iframe-proxy is managed by its systemd service, no manual start needed. + vm.exec({ + command: `nohup bash -c 'kill $(cat /tmp/dev.pid) 2>/dev/null || true; ${pathPrefix}echo "" >> /tmp/vm.log && echo "--- Starting dev server ---" >> /tmp/vm.log && cd /app && HOST=0.0.0.0 HOSTNAME=0.0.0.0 PORT=${port} ${devScript} >> /tmp/vm.log 2>&1 & echo $! > /tmp/dev.pid'`, + }).catch(console.error); + + return { success: true }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Command execution failed"; + return { success: false, error: message }; + } + }, +}); diff --git a/apps/mesh/src/tools/vm/helpers.test.ts b/apps/mesh/src/tools/vm/helpers.test.ts new file mode 100644 index 0000000000..9b0dabda5a --- /dev/null +++ b/apps/mesh/src/tools/vm/helpers.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "bun:test"; +import { resolveRuntimeConfig } from "./helpers"; +import type { VmMetadata } from "./types"; + +// --------------------------------------------------------------------------- +// Tests for resolveRuntimeConfig (pure function — no MeshContext needed) +// --------------------------------------------------------------------------- + +describe("resolveRuntimeConfig", () => { + it("returns npm defaults when no runtime config is set", () => { + const metadata: VmMetadata = {}; + + const result = resolveRuntimeConfig(metadata); + + expect(result.installScript).toBe("npm install"); + expect(result.devScript).toBe("npm run dev"); + expect(result.detected).toBe("npm"); + expect(result.port).toBe("3000"); + }); + + it("returns npm defaults when runtime is null", () => { + const metadata: VmMetadata = { runtime: null }; + + const result = resolveRuntimeConfig(metadata); + + expect(result.installScript).toBe("npm install"); + expect(result.devScript).toBe("npm run dev"); + expect(result.detected).toBe("npm"); + expect(result.port).toBe("3000"); + }); + + it("detects deno runtime", () => { + const metadata: VmMetadata = { + runtime: { + detected: "deno", + selected: "deno", + installScript: "deno install", + devScript: "deno task dev", + port: "8000", + }, + }; + + const result = resolveRuntimeConfig(metadata); + + expect(result.detected).toBe("deno"); + }); + + it("detects bun runtime", () => { + const metadata: VmMetadata = { + runtime: { + detected: "bun", + selected: "bun", + installScript: "bun install", + devScript: "bun run dev", + port: "3000", + }, + }; + + const result = resolveRuntimeConfig(metadata); + + expect(result.detected).toBe("bun"); + }); + + it("detects npm runtime", () => { + const metadata: VmMetadata = { + runtime: { + detected: "npm", + selected: "npm", + installScript: "npm install", + devScript: "npm run dev", + port: "3000", + }, + }; + + const result = resolveRuntimeConfig(metadata); + + expect(result.detected).toBe("npm"); + }); + + it("uses custom scripts from metadata", () => { + const metadata: VmMetadata = { + runtime: { + detected: "npm", + selected: "npm", + installScript: "pnpm install", + devScript: "pnpm dev", + port: "4200", + }, + }; + + const result = resolveRuntimeConfig(metadata); + + expect(result.installScript).toBe("pnpm install"); + expect(result.devScript).toBe("pnpm dev"); + expect(result.port).toBe("4200"); + }); + + it("falls back to defaults when individual runtime fields are null", () => { + const metadata: VmMetadata = { + runtime: { + detected: null, + selected: null, + installScript: null, + devScript: null, + port: null, + }, + }; + + const result = resolveRuntimeConfig(metadata); + + expect(result.installScript).toBe("npm install"); + expect(result.devScript).toBe("npm run dev"); + expect(result.detected).toBe("npm"); + expect(result.port).toBe("3000"); + }); +}); diff --git a/apps/mesh/src/tools/vm/helpers.ts b/apps/mesh/src/tools/vm/helpers.ts new file mode 100644 index 0000000000..c8fa3f33b8 --- /dev/null +++ b/apps/mesh/src/tools/vm/helpers.ts @@ -0,0 +1,61 @@ +/** + * Shared VM helper functions used across VM tools (VM_START, VM_EXEC, VM_DELETE). + * + * Centralizes: + * - Auth + lookup boilerplate (requireVmEntry) + * - Runtime detection logic (resolveRuntimeConfig) + */ + +import { + requireAuth, + requireOrganization, + getUserId, + type MeshContext, +} from "../../core/mesh-context"; +import type { VmMetadata } from "./types"; + +/** + * Extracts common auth + lookup boilerplate shared by all VM tools. + * Validates auth, checks access, fetches and validates the Virtual MCP, + * and returns the metadata and active VM entry for the current user. + */ +export async function requireVmEntry( + input: { virtualMcpId: string }, + ctx: MeshContext, +) { + requireAuth(ctx); + const organization = requireOrganization(ctx); + await ctx.access.check(); + const userId = getUserId(ctx); + if (!userId) throw new Error("User ID required"); + const virtualMcp = await ctx.storage.virtualMcps.findById(input.virtualMcpId); + if (!virtualMcp || virtualMcp.organization_id !== organization.id) { + throw new Error("Virtual MCP not found"); + } + const metadata = virtualMcp.metadata as VmMetadata; + const entry = metadata.activeVms?.[userId]; + return { virtualMcp, metadata, userId, entry, organization }; +} + +/** + * Extracts runtime detection logic from Virtual MCP metadata. + * Returns normalized runtime config with defaults. + * Runtimes (node/deno/bun) are pre-installed via Freestyle integrations + * (@freestyle-sh/with-nodejs, @freestyle-sh/with-deno, @freestyle-sh/with-bun). + */ +export function resolveRuntimeConfig(metadata: VmMetadata) { + const installScript = metadata.runtime?.installScript ?? "npm install"; + const devScript = metadata.runtime?.devScript ?? "npm run dev"; + const detected = metadata.runtime?.detected ?? "npm"; + const port = metadata.runtime?.port ?? "3000"; + // Freestyle integrations install runtimes outside the default PATH: + // VmDeno → /opt/deno/bin, VmBun → /opt/bun/bin + // npm uses the system node/npm already at /usr/local/bin (no prefix needed). + const runtimeBinPath = + detected === "deno" + ? "/opt/deno/bin" + : detected === "bun" + ? "/opt/bun/bin" + : null; + return { installScript, devScript, detected, port, runtimeBinPath }; +} diff --git a/apps/mesh/src/tools/vm/index.ts b/apps/mesh/src/tools/vm/index.ts new file mode 100644 index 0000000000..06d840e11c --- /dev/null +++ b/apps/mesh/src/tools/vm/index.ts @@ -0,0 +1,9 @@ +/** + * VM Tools + * + * Tools for Freestyle VM management (app-only, not visible to AI models). + */ + +export { VM_START } from "./start"; +export { VM_DELETE } from "./stop"; +export { VM_EXEC } from "./exec"; diff --git a/apps/mesh/src/tools/vm/start.test.ts b/apps/mesh/src/tools/vm/start.test.ts new file mode 100644 index 0000000000..edcdc688d7 --- /dev/null +++ b/apps/mesh/src/tools/vm/start.test.ts @@ -0,0 +1,429 @@ +import { createHash } from "node:crypto"; +import { describe, it, expect, mock, beforeEach } from "bun:test"; +import type { MeshContext } from "../../core/mesh-context"; +import type { VmEntry, VmMetadata } from "./types"; + +// --------------------------------------------------------------------------- +// Mock freestyle-sandboxes BEFORE importing VM_START (Bun requires this order) +// --------------------------------------------------------------------------- + +const mockGithubSyncEnable = mock( + (_input: unknown): Promise => Promise.resolve(), +); + +const mockReposCreate = mock( + ( + _input: unknown, + ): Promise<{ + repoId: string; + repo: { githubSync: { enable: typeof mockGithubSyncEnable } }; + }> => + Promise.resolve({ + repoId: "repo_abc", + repo: { githubSync: { enable: mockGithubSyncEnable } }, + }), +); + +const mockRoute = mock((): Promise => Promise.resolve()); + +const mockVmsCreate = mock( + ( + _input: unknown, + ): Promise<{ + vmId: string; + vm: { terminal: { logs: { route: typeof mockRoute } } }; + }> => + Promise.resolve({ + vmId: "vm_xyz", + vm: { terminal: { logs: { route: mockRoute } } }, + }), +); + +const mockVmStart = mock((): Promise => Promise.resolve()); +const mockVmExec = mock((_input: unknown): Promise => Promise.resolve()); + +class MockVmSpec { + builders: Record = {}; + _repo: unknown = undefined; + _files: unknown = undefined; + _services: Record[] = []; + + with(key: string, builder: unknown): MockVmSpec { + const next = Object.assign(new MockVmSpec(), this); + next.builders = { ...this.builders, [key]: builder }; + return next; + } + repo(url: string, dir: string): MockVmSpec { + const next = Object.assign(new MockVmSpec(), this); + next._repo = { url, dir }; + return next; + } + additionalFiles(files: unknown): MockVmSpec { + const next = Object.assign(new MockVmSpec(), this); + next._files = files; + return next; + } + systemdService(svc: Record): MockVmSpec { + const next = Object.assign(new MockVmSpec(), this); + next._services = [...this._services, svc]; + return next; + } +} + +mock.module("freestyle-sandboxes", () => ({ + VmSpec: MockVmSpec, + freestyle: { + git: { + repos: { + create: (a: unknown) => mockReposCreate(a), + }, + }, + vms: { + create: (a: unknown) => mockVmsCreate(a), + ref: (_input: unknown) => ({ + start: () => mockVmStart(), + exec: (cmd: unknown) => mockVmExec(cmd), + }), + }, + }, +})); + +// Mock Freestyle integration packages +mock.module("@freestyle-sh/with-nodejs", () => ({ + VmNodeJs: class VmNodeJs {}, +})); +mock.module("@freestyle-sh/with-deno", () => ({ + VmDeno: class VmDeno {}, +})); +mock.module("@freestyle-sh/with-bun", () => ({ + VmBun: class VmBun {}, +})); +mock.module("@freestyle-sh/with-web-terminal", () => ({ + VmWebTerminal: class VmWebTerminal { + constructor(_config: unknown) {} + }, +})); + +// Now import after mocking +const { VM_START } = await import("./start"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Expected domain key for virtualMcpId="vmcp_1", userId="user_1" +const DOMAIN_KEY = createHash("md5") + .update("vmcp_1:user_1") + .digest("hex") + .slice(0, 16); + +const BASE_METADATA: VmMetadata = { + githubRepo: { + url: "https://github.com/acme/app", + owner: "acme", + name: "app", + }, + runtime: { + detected: "npm", + selected: "npm", + installScript: "npm install", + devScript: "npm run dev", + port: "3000", + }, +}; + +const CACHED_ENTRY: VmEntry = { + vmId: "vm_cached", + previewUrl: "https://virtual-mcp-id.deco.studio", + terminalUrl: null, +}; + +function makeVirtualMcp(orgId: string, metadata: VmMetadata, id = "vmcp_1") { + return { + id, + organization_id: orgId, + metadata, + title: "Test Virtual MCP", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user_1", + }; +} + +function makeCtx(overrides: { + orgId?: string; + userId?: string; + virtualMcp?: ReturnType | null; + updateSpy?: ReturnType; +}): MeshContext { + const { + orgId = "org_1", + userId = "user_1", + virtualMcp, + updateSpy = mock(async () => {}), + } = overrides; + + const findById = mock(async (_id: string) => virtualMcp ?? null); + + return { + auth: { + user: { + id: userId, + email: "[email protected]", + name: "Test", + role: "user", + }, + }, + organization: { id: orgId, slug: "test-org", name: "Test Org" }, + access: { + granted: () => true, + check: async () => {}, + grant: () => {}, + setToolName: () => {}, + }, + storage: { + virtualMcps: { + findById, + update: updateSpy, + }, + } as never, + timings: { + measure: async (_name: string, cb: () => Promise) => await cb(), + }, + vault: null as never, + authInstance: null as never, + boundAuth: null as never, + db: null as never, + tracer: { + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (span: unknown) => unknown, + ) => + fn({ + setStatus: () => {}, + recordException: () => {}, + end: () => {}, + }), + } as never, + meter: { + createHistogram: () => ({ record: () => {} }), + createCounter: () => ({ add: () => {} }), + } as never, + baseUrl: "https://mesh.example.com", + metadata: { requestId: "req_1", timestamp: new Date() }, + eventBus: null as never, + objectStorage: null as never, + aiProviders: null as never, + createMCPProxy: null as never, + getOrCreateClient: null as never, + pendingRevalidations: [], + monitoring: null as never, + } as unknown as MeshContext; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("VM_START", () => { + beforeEach(() => { + mockGithubSyncEnable.mockReset(); + mockReposCreate.mockReset(); + mockVmsCreate.mockReset(); + mockVmStart.mockReset(); + mockVmExec.mockReset(); + mockRoute.mockReset(); + mockGithubSyncEnable.mockImplementation(async () => {}); + mockReposCreate.mockImplementation(async () => ({ + repoId: "repo_abc", + repo: { githubSync: { enable: mockGithubSyncEnable } }, + })); + mockVmsCreate.mockImplementation(async () => ({ + vmId: "vm_xyz", + vm: { terminal: { logs: { route: mockRoute } } }, + })); + mockVmStart.mockImplementation(async () => {}); + mockVmExec.mockImplementation(async () => {}); + mockRoute.mockImplementation(async () => {}); + }); + + it("returns cached entry with isNewVm: false when activeVms[userId] is already set (no freestyle call)", async () => { + const metadata: VmMetadata = { + ...BASE_METADATA, + freestyleRepoId: "repo_cached", + activeVms: { user_1: CACHED_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const ctx = makeCtx({ virtualMcp }); + + const result = await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + expect(result).toEqual({ ...CACHED_ENTRY, isNewVm: false }); + expect(result.isNewVm).toBe(false); + expect(mockReposCreate).not.toHaveBeenCalled(); + expect(mockVmsCreate).not.toHaveBeenCalled(); + // ensureLogViewer is gone — exec must not be called + expect(mockVmExec).not.toHaveBeenCalled(); + // route() must not be called on resume — domain mapping is persistent + expect(mockRoute).not.toHaveBeenCalled(); + }); + + it("creates a new VM with isNewVm: true and persists entry when no existing activeVms entry", async () => { + const metadata: VmMetadata = { + ...BASE_METADATA, + activeVms: { other_user: CACHED_ENTRY }, // existing entry for a different user + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const updateSpy = mock(async () => {}); + const ctx = makeCtx({ virtualMcp, updateSpy }); + + const result = await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + // Freestyle Git repo created and GitHub Sync enabled + expect(mockReposCreate).toHaveBeenCalledTimes(1); + expect(mockGithubSyncEnable).toHaveBeenCalledWith({ + githubRepoName: "acme/app", + }); + expect(mockVmsCreate).toHaveBeenCalledTimes(1); + + // Result contains the newly created VM data with isNewVm flag + expect(result.vmId).toBe("vm_xyz"); + expect(result.previewUrl).toBe(`https://${DOMAIN_KEY}.deco.studio`); + expect(result.terminalUrl).toBeNull(); + expect(result.isNewVm).toBe(true); + + // storage.update called twice: persist repoId + persist new VM entry + expect(updateSpy).toHaveBeenCalledTimes(2); + + // Second update (patchActiveVms) preserves existing entries + const updateCall = (updateSpy.mock.calls as unknown[][])[1]!; + const updatedMetadata = (updateCall[2] as { metadata: VmMetadata }) + .metadata; + expect(updatedMetadata.activeVms?.["other_user"]).toEqual(CACHED_ENTRY); + expect(updatedMetadata.activeVms?.["user_1"]).toMatchObject({ + vmId: "vm_xyz", + }); + }); + + it("only includes daemon in systemd services", async () => { + const virtualMcp = makeVirtualMcp("org_1", BASE_METADATA); + const ctx = makeCtx({ virtualMcp }); + + await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + const createCall = (mockVmsCreate.mock.calls as unknown[][])[0]![0] as { + spec: MockVmSpec; + }; + + const serviceNames = createCall.spec._services.map((s) => s.name as string); + expect(serviceNames).toEqual(["daemon"]); + }); + + it("daemon has no after dependency on dev-server", async () => { + const virtualMcp = makeVirtualMcp("org_1", BASE_METADATA); + const ctx = makeCtx({ virtualMcp }); + + await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + const createCall = (mockVmsCreate.mock.calls as unknown[][])[0]![0] as { + spec: MockVmSpec; + }; + + const daemon = createCall.spec._services.find((s) => s.name === "daemon")!; + expect((daemon.after as string[] | undefined) ?? []).not.toContain( + "dev-server.service", + ); + }); + + it("passes idleTimeoutSeconds: 1800 to freestyle.vms.create", async () => { + const virtualMcp = makeVirtualMcp("org_1", BASE_METADATA); + const ctx = makeCtx({ virtualMcp }); + + await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + const createCall = (mockVmsCreate.mock.calls as unknown[][])[0]![0] as { + idleTimeoutSeconds: number; + }; + expect(createCall.idleTimeoutSeconds).toBe(1800); + }); + + it("daemon script includes /_daemon/events SSE endpoint", async () => { + const virtualMcp = makeVirtualMcp("org_1", BASE_METADATA); + const ctx = makeCtx({ virtualMcp }); + + await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + const createCall = (mockVmsCreate.mock.calls as unknown[][])[0]![0] as { + spec: MockVmSpec; + }; + + const files = createCall.spec._files as Record; + const daemonJs = files["/opt/daemon.js"]; + expect(daemonJs).toBeDefined(); + expect(daemonJs!.content).toContain("/_daemon/events"); + expect(daemonJs!.content).toContain("text/event-stream"); + expect(files["/opt/run-daemon.sh"]).toBeDefined(); + }); + + it("clears stale VM entry, creates new VM when vm.start() throws", async () => { + mockVmStart.mockRejectedValueOnce(new Error("VM not found")); + const metadata: VmMetadata = { + ...BASE_METADATA, + activeVms: { user_1: CACHED_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const updateSpy = mock(async () => {}); + const ctx = makeCtx({ virtualMcp, updateSpy }); + + const result = await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + // Fell through to creating a new VM — Freestyle repo created + expect(mockReposCreate).toHaveBeenCalledTimes(1); + expect(mockVmsCreate).toHaveBeenCalledTimes(1); + expect(result.isNewVm).toBe(true); + expect(result.vmId).toBe("vm_xyz"); + + // updateSpy called three times: clear stale, persist repoId, persist new entry + expect(updateSpy).toHaveBeenCalledTimes(3); + }); + + it("passes VmSpec integrations for bun runtime — includes node and bun runtime", async () => { + const metadata: VmMetadata = { + ...BASE_METADATA, + runtime: { + ...BASE_METADATA.runtime, + detected: "bun", + selected: "bun", + }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const ctx = makeCtx({ virtualMcp }); + + await VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx); + + const createCall = (mockVmsCreate.mock.calls as unknown[][])[0]![0] as { + spec: MockVmSpec; + }; + + expect(createCall.spec.builders.node).toBeDefined(); + expect(createCall.spec.builders.js).toBeDefined(); + }); + + it("throws 'Virtual MCP not found' when findById returns null", async () => { + const ctx = makeCtx({ virtualMcp: null }); + + await expect( + VM_START.handler({ virtualMcpId: "vmcp_missing" }, ctx), + ).rejects.toThrow("Virtual MCP not found"); + }); + + it("throws 'Virtual MCP not found' when Virtual MCP belongs to a different org", async () => { + const virtualMcp = makeVirtualMcp("org_other", BASE_METADATA); // different org + const ctx = makeCtx({ orgId: "org_1", virtualMcp }); + + await expect( + VM_START.handler({ virtualMcpId: "vmcp_1" }, ctx), + ).rejects.toThrow("Virtual MCP not found"); + }); +}); diff --git a/apps/mesh/src/tools/vm/start.ts b/apps/mesh/src/tools/vm/start.ts new file mode 100644 index 0000000000..1f649ff664 --- /dev/null +++ b/apps/mesh/src/tools/vm/start.ts @@ -0,0 +1,410 @@ +/** + * VM_START Tool + * + * Creates a Freestyle VM with the connected GitHub repo + * and infrastructure-only systemd services (ttyd, terminal, iframe-proxy). + * App-only tool — not visible to AI models. + * + * Install/dev lifecycle is handled by VM_EXEC so VM_START returns fast. + * + * Freestyle docs: /v2/vms, /v2/vms/configuration/systemd-services, + * /v2/vms/configuration/ports-networking, /v2/vms/configuration/domains + */ + +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { VmSpec, freestyle } from "freestyle-sandboxes"; +import { VmDeno } from "@freestyle-sh/with-deno"; +import { VmBun } from "@freestyle-sh/with-bun"; +import { VmNodeJs } from "@freestyle-sh/with-nodejs"; +import { type VmEntry, type VmMetadata, patchActiveVms } from "./types"; +import { requireVmEntry, resolveRuntimeConfig } from "./helpers"; +import type { VirtualMCPStoragePort } from "../../storage/ports"; + +const PROXY_PORT = 9000; + +const BOOTSTRAP_SCRIPT = ``; + +// Daemon service that runs inside Freestyle VMs. +// Responsibilities: +// 1. Reverse proxy: strips X-Frame-Options/CSP so the dev server can be embedded in an iframe. +// Injects visual-editor bootstrap script into HTML responses. +// 2. Log tailing: watches /tmp/vm.log via fs.watch (inotify) and streams new lines over SSE. +// 3. Liveness probing: probes upstream dev server (every 3s during startup, 30s steady state). +// 4. SSE endpoint: GET /_daemon/events multiplexes log and status events to connected clients. +// Node's http/fs modules are available in Freestyle VMs by default. +const buildDaemonScript = (upstreamPort: string) => { + if (!/^\d+$/.test(upstreamPort)) { + throw new Error(`Invalid upstream port: ${upstreamPort}`); + } + return `const http = require("http"); +const fs = require("fs"); +const UPSTREAM = "${upstreamPort}"; +const LOG = "/tmp/vm.log"; +const PROXY_PORT = ${PROXY_PORT}; +const BOOTSTRAP = ${JSON.stringify(BOOTSTRAP_SCRIPT)}; +const MAX_SSE_CLIENTS = 10; + +// --- SSE state --- +const sseClients = new Set(); +let logOffset = 0; +let tailing = false; +let lastStatus = { ready: false, htmlSupport: false }; + +// --- Log tailing via fs.watch (inotify) --- +function tailLog() { + if (tailing) return; + tailing = true; + fs.open(LOG, "r", (err, fd) => { + if (err) { tailing = false; return; } + drainLog(fd); + }); +} + +function drainLog(fd) { + const buf = Buffer.alloc(64 * 1024); + fs.read(fd, buf, 0, buf.length, logOffset, (err, bytesRead) => { + if (err || bytesRead === 0) { + fs.close(fd, () => {}); + tailing = false; + return; + } + logOffset += bytesRead; + const text = buf.toString("utf-8", 0, bytesRead); + const lines = text.split("\\n").filter(Boolean); + if (lines.length > 0) { + const payload = JSON.stringify({ type: "log", lines: lines }); + for (const res of sseClients) { + if (res.writable) res.write("event: log\\ndata: " + payload + "\\n\\n"); + } + } + // Continue reading if there may be more data + if (bytesRead === buf.length) { + drainLog(fd); + } else { + fs.close(fd, () => {}); + tailing = false; + } + }); +} + +// Use fs.watch (inotify) for low-latency change detection. +// Falls back to polling if watch is unavailable. +try { + fs.watch(LOG, () => { tailLog(); }); +} catch (e) { + fs.watchFile(LOG, { interval: 500 }, tailLog); +} +// Initial read of existing content +tailLog(); + +// --- Liveness probe --- +// Probes every 3s during first 60s (startup), then every 30s (steady state) +let probeCount = 0; +const FAST_PROBE_MS = 3000; +const SLOW_PROBE_MS = 30000; +const FAST_PROBE_LIMIT = 20; // 20 * 3s = 60s + +function probeUpstream() { + const req = http.request( + { hostname: "127.0.0.1", port: UPSTREAM, path: "/", method: "HEAD", timeout: 5000 }, + (res) => { + const ct = (res.headers["content-type"] || "").toLowerCase(); + const newStatus = { + ready: res.statusCode >= 200 && res.statusCode < 400, + htmlSupport: ct.includes("text/html"), + }; + if (newStatus.ready !== lastStatus.ready || newStatus.htmlSupport !== lastStatus.htmlSupport) { + lastStatus = newStatus; + broadcastStatus(); + } + } + ); + req.on("error", () => { + if (lastStatus.ready) { + lastStatus = { ready: false, htmlSupport: false }; + broadcastStatus(); + } + }); + req.on("timeout", () => { req.destroy(); }); + req.end(); + + probeCount++; + const nextDelay = probeCount < FAST_PROBE_LIMIT ? FAST_PROBE_MS : SLOW_PROBE_MS; + setTimeout(probeUpstream, nextDelay); +} + +function broadcastStatus() { + const payload = JSON.stringify({ type: "status", ...lastStatus }); + for (const res of sseClients) { + if (res.writable) res.write("event: status\\ndata: " + payload + "\\n\\n"); + } +} + +// Start probing after 1s +setTimeout(probeUpstream, 1000); + +// --- HTTP server --- +http.createServer((req, res) => { + // SSE endpoint + if (req.url === "/_daemon/events" && req.method === "GET") { + if (sseClients.size >= MAX_SSE_CLIENTS) { + res.writeHead(429); + res.end("Too many connections"); + return; + } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + // Send current status immediately + res.write("event: status\\ndata: " + JSON.stringify({ type: "status", ...lastStatus }) + "\\n\\n"); + sseClients.add(res); + req.on("close", () => { sseClients.delete(res); }); + // Keepalive every 15s + const ka = setInterval(() => { + if (!res.writable) { clearInterval(ka); sseClients.delete(res); return; } + res.write(": keepalive\\n\\n"); + }, 15000); + req.on("close", () => { clearInterval(ka); }); + return; + } + + // CORS preflight for SSE + if (req.url === "/_daemon/events" && req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(); + return; + } + + // Reverse proxy to upstream + const hdrs = Object.assign({}, req.headers); + delete hdrs["accept-encoding"]; + const opts = { hostname: "127.0.0.1", port: UPSTREAM, path: req.url, method: req.method, headers: hdrs }; + const p = http.request(opts, (upstream) => { + delete upstream.headers["x-frame-options"]; + delete upstream.headers["content-security-policy"]; + delete upstream.headers["content-encoding"]; + const ct = (upstream.headers["content-type"] || "").toLowerCase(); + if (ct.includes("text/html")) { + delete upstream.headers["content-length"]; + res.writeHead(upstream.statusCode, upstream.headers); + const chunks = []; + upstream.on("data", (c) => chunks.push(c)); + upstream.on("end", () => { + let html = Buffer.concat(chunks).toString("utf-8"); + const idx = html.lastIndexOf(""); + if (idx !== -1) { + html = html.slice(0, idx) + BOOTSTRAP + html.slice(idx); + } else { + html += BOOTSTRAP; + } + res.end(html); + }); + } else { + res.writeHead(upstream.statusCode, upstream.headers); + upstream.pipe(res); + } + }); + p.on("error", (e) => { res.writeHead(502); res.end("proxy error: " + e.message); }); + req.pipe(p); +}).listen(PROXY_PORT, "0.0.0.0"); +`; +}; + +/** + * Ensures a Freestyle Git repo exists for the given GitHub repo. + * Creates the repo and enables GitHub Sync on first call, then + * persists the repoId in metadata for reuse. + * Freestyle docs: /v2/git/repos, /v2/git/github-sync + */ +async function ensureFreestyleRepo( + metadata: VmMetadata, + owner: string, + name: string, + virtualMcpId: string, + userId: string, + storage: VirtualMCPStoragePort, +): Promise { + if (metadata.freestyleRepoId) { + return metadata.freestyleRepoId; + } + + const { repo, repoId } = await freestyle.git.repos.create({}); + await repo.githubSync.enable({ githubRepoName: `${owner}/${name}` }); + console.log( + `[VM_START] Created Freestyle repo ${repoId} with GitHub Sync for ${owner}/${name}`, + ); + + // Persist the repoId so subsequent calls reuse it. + const virtualMcp = await storage.findById(virtualMcpId); + if (virtualMcp) { + const meta = virtualMcp.metadata as VmMetadata; + await storage.update(virtualMcpId, userId, { + metadata: { ...meta, freestyleRepoId: repoId } as Record, + }); + } + + return repoId; +} + +export const VM_START = defineTool({ + name: "VM_START", + description: + "Start a Freestyle VM with the connected GitHub repo and dev server.", + annotations: { + title: "Start VM Preview", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + virtualMcpId: z.string().describe("Virtual MCP ID"), + }), + outputSchema: z.object({ + terminalUrl: z.string().nullable(), + previewUrl: z.string(), + vmId: z.string(), + isNewVm: z.boolean(), + }), + + handler: async (input, ctx) => { + const { metadata, userId } = await requireVmEntry(input, ctx); + + if (!metadata.githubRepo) { + throw new Error("No GitHub repo connected"); + } + + const { owner, name } = metadata.githubRepo; + const { detected, port } = resolveRuntimeConfig(metadata); + + // Ensure a Freestyle Git repo exists with GitHub Sync enabled. + // This allows cloning private repos via the GitHub App integration. + // Freestyle docs: /v2/git/repos, /v2/git/github-sync + const repoId = await ensureFreestyleRepo( + metadata, + owner, + name, + input.virtualMcpId, + userId, + ctx.storage.virtualMcps, + ); + + // Generate a unique subdomain per (virtualMcpId, userId) pair. + // MD5 of the composite key guarantees a valid, fixed-length hex subdomain + // and avoids collisions between different users on the same Virtual MCP. + // Freestyle docs: /v2/vms/configuration/domains + const domainKey = createHash("md5") + .update(`${input.virtualMcpId}:${userId}`) + .digest("hex") + .slice(0, 16); + const previewDomain = `${domainKey}.deco.studio`; + + // Build the full VmSpec declaratively — integrations, repo, files, and services. + // VmNodeJs is always included: the iframe-proxy systemd service runs Node.js on every VM. + // Freestyle docs: /v2/vms/integrations/deno, /v2/vms/integrations/bun, /v2/vms/integrations/web-terminal + const baseSpec = new VmSpec() + .with("node", new VmNodeJs()) + .repo(repoId, "/app") + .additionalFiles({ + "/opt/daemon.js": { content: buildDaemonScript(port) }, + "/opt/run-daemon.sh": { + content: + "#!/bin/bash\nsource /etc/profile.d/nvm.sh\nexec node /opt/daemon.js\n", + }, + "/tmp/vm.log": { content: "" }, + }) + .systemdService({ + name: "daemon", + mode: "service", + exec: ["/bin/bash /opt/run-daemon.sh"], + after: ["install-nodejs.service"], + requires: ["install-nodejs.service"], + wantedBy: ["multi-user.target"], + }); + + const spec = + detected === "deno" + ? baseSpec.with("deno", new VmDeno()) + : detected === "bun" + ? baseSpec.with("js", new VmBun()) + : baseSpec; + + // Resume existing VM if one is tracked. + // Try vm.start() which resumes suspended/stopped VMs. If the VM was + // deleted externally, the call will throw — clear the stale entry and + // fall through to create a new one. + const existing = metadata.activeVms?.[userId]; + if (existing) { + try { + const vm = freestyle.vms.ref({ vmId: existing.vmId, spec }); + await vm.start(); + console.log(`[VM_START] Resumed existing VM: ${existing.vmId}`); + return { ...existing, isNewVm: false }; + } catch { + // VM no longer exists on Freestyle — clear stale entry + console.log( + `[VM_START] VM gone, clearing stale entry: ${existing.vmId}`, + ); + await patchActiveVms( + ctx.storage.virtualMcps, + input.virtualMcpId, + userId, + (vms) => { + const updated = { ...vms }; + delete updated[userId]; + return updated; + }, + ); + } + } + + console.log(`[VM_START] repo: ${owner}/${name} runtime: ${detected}`); + + // Create VM from spec. + // Domain routes to the iframe proxy which strips X-Frame-Options/CSP + // so the preview can be embedded in an iframe. + // Terminal domain is routed post-creation via vm.terminal.logs.route() — a persistent mapping. + // Freestyle docs: /v2/vms/configuration/domains + const createResult = await freestyle.vms.create({ + spec, + domains: [{ domain: previewDomain, vmPort: PROXY_PORT }], + // recreate: true so vm.start() rebuilds from spec if evicted. + // Freestyle docs: /v2/vms/lifecycle/persistence + recreate: true, + // 30-minute idle timeout before the VM is automatically stopped. + idleTimeoutSeconds: 1800, + }); + + console.log( + `[VM_START] VM created: ${createResult.vmId} domain: ${previewDomain}`, + ); + + const { vmId } = createResult; + + const previewUrl = `https://${previewDomain}`; + const terminalUrl: string | null = null; + + const entry: VmEntry = { terminalUrl, previewUrl, vmId }; + + // Persist the active VM entry in the Virtual MCP metadata so all pods + // can discover it and avoid spinning up duplicate VMs. + await patchActiveVms( + ctx.storage.virtualMcps, + input.virtualMcpId, + userId, + (vms) => ({ ...vms, [userId]: entry }), + ); + + return { ...entry, isNewVm: true }; + }, +}); diff --git a/apps/mesh/src/tools/vm/stop.test.ts b/apps/mesh/src/tools/vm/stop.test.ts new file mode 100644 index 0000000000..21e22eead5 --- /dev/null +++ b/apps/mesh/src/tools/vm/stop.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test"; +import type { MeshContext } from "../../core/mesh-context"; +import type { VmEntry, VmMetadata } from "./types"; + +// --------------------------------------------------------------------------- +// Mock freestyle-sandboxes BEFORE importing VM_DELETE (Bun requires this order) +// --------------------------------------------------------------------------- + +const mockVmDelete = mock((): Promise => Promise.resolve()); + +mock.module("freestyle-sandboxes", () => ({ + freestyle: { + vms: { + ref: (_input: unknown) => ({ + stop: () => Promise.resolve(), + delete: () => mockVmDelete(), + }), + }, + }, +})); + +// Now import after mocking +const { VM_DELETE } = await import("./stop"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const EXISTING_ENTRY: VmEntry = { + vmId: "vm_existing", + previewUrl: "https://vmcp-1.deco.studio", + terminalUrl: null, +}; + +function makeVirtualMcp(orgId: string, metadata: VmMetadata, id = "vmcp_1") { + return { + id, + organization_id: orgId, + metadata, + title: "Test Virtual MCP", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user_1", + }; +} + +function makeCtx(overrides: { + orgId?: string; + userId?: string; + virtualMcp?: ReturnType | null; + updateSpy?: ReturnType; +}): MeshContext { + const { + orgId = "org_1", + userId = "user-1", + virtualMcp, + updateSpy = mock(async () => {}), + } = overrides; + + const findById = mock(async (_id: string) => virtualMcp ?? null); + + return { + auth: { + user: { + id: userId, + email: "[email protected]", + name: "Test", + role: "user", + }, + }, + organization: { id: orgId, slug: "test-org", name: "Test Org" }, + access: { + granted: () => true, + check: async () => {}, + grant: () => {}, + setToolName: () => {}, + }, + storage: { + virtualMcps: { + findById, + update: updateSpy, + }, + } as never, + timings: { + measure: async (_name: string, cb: () => Promise) => await cb(), + }, + vault: null as never, + authInstance: null as never, + boundAuth: null as never, + db: null as never, + tracer: { + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (span: unknown) => unknown, + ) => + fn({ + setStatus: () => {}, + recordException: () => {}, + end: () => {}, + }), + } as never, + meter: { + createHistogram: () => ({ record: () => {} }), + createCounter: () => ({ add: () => {} }), + } as never, + baseUrl: "https://mesh.example.com", + metadata: { requestId: "req_1", timestamp: new Date() }, + eventBus: null as never, + objectStorage: null as never, + aiProviders: null as never, + createMCPProxy: null as never, + getOrCreateClient: null as never, + pendingRevalidations: [], + monitoring: null as never, + } as unknown as MeshContext; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("VM_DELETE", () => { + beforeEach(() => { + mockVmDelete.mockReset(); + mockVmDelete.mockImplementation(async () => {}); + }); + + it("deletes Freestyle VM and removes DB entry when activeVms entry exists for user", async () => { + const metadata: VmMetadata = { + activeVms: { "user-1": EXISTING_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const updateSpy = mock(async () => {}); + const ctx = makeCtx({ virtualMcp, updateSpy }); + + const result = await VM_DELETE.handler({ virtualMcpId: "vmcp_1" }, ctx); + + expect(result).toEqual({ success: true }); + + // Freestyle vm.delete() was called + expect(mockVmDelete).toHaveBeenCalledTimes(1); + + // patchActiveVms called storage.update once + expect(updateSpy).toHaveBeenCalledTimes(1); + + // Verify user-1 key was removed from activeVms + const updateCall = (updateSpy.mock.calls as unknown[][])[0]!; + const updatedMetadata = (updateCall[2] as { metadata: VmMetadata }) + .metadata; + expect(updatedMetadata.activeVms?.["user-1"]).toBeUndefined(); + }); + + it("skips Freestyle delete and DB update when no activeVms entry for user", async () => { + const metadata: VmMetadata = { + activeVms: { "other-user": EXISTING_ENTRY }, + }; + const virtualMcp = makeVirtualMcp("org_1", metadata); + const updateSpy = mock(async () => {}); + const ctx = makeCtx({ virtualMcp, updateSpy }); + + const result = await VM_DELETE.handler({ virtualMcpId: "vmcp_1" }, ctx); + + expect(result).toEqual({ success: true }); + expect(mockVmDelete).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("returns success when virtualMcp not found (null from findById)", async () => { + const ctx = makeCtx({ virtualMcp: null }); + + const result = await VM_DELETE.handler( + { virtualMcpId: "vmcp_missing" }, + ctx, + ); + + expect(result).toEqual({ success: true }); + expect(mockVmDelete).not.toHaveBeenCalled(); + }); + + it("throws 'User ID required' when userId is unavailable", async () => { + const metadata: VmMetadata = {}; + const virtualMcp = makeVirtualMcp("org_1", metadata); + // Pass empty string to simulate missing userId — getUserId returns undefined/falsy + const ctx = makeCtx({ virtualMcp, userId: "" }); + + // Patch auth.user.id to undefined to simulate missing user ID + (ctx as unknown as { auth: { user: { id: undefined } } }).auth.user.id = + undefined; + + await expect( + VM_DELETE.handler({ virtualMcpId: "vmcp_1" }, ctx), + ).rejects.toThrow("User ID required"); + }); +}); diff --git a/apps/mesh/src/tools/vm/stop.ts b/apps/mesh/src/tools/vm/stop.ts new file mode 100644 index 0000000000..d9fc8f8ad5 --- /dev/null +++ b/apps/mesh/src/tools/vm/stop.ts @@ -0,0 +1,75 @@ +/** + * VM_DELETE Tool + * + * Deletes a Freestyle VM and removes its entry from the Virtual MCP metadata. + * App-only tool — not visible to AI models. + * + * Uses vm.delete() to fully destroy the VM so the next VM_START creates a + * fresh instance with updated systemd config and infrastructure. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { freestyle } from "freestyle-sandboxes"; +import { patchActiveVms } from "./types"; +import { requireVmEntry } from "./helpers"; + +export const VM_DELETE = defineTool({ + name: "VM_DELETE", + description: "Delete a Freestyle VM.", + annotations: { + title: "Delete VM Preview", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + virtualMcpId: z.string().describe("Virtual MCP ID that owns this VM"), + }), + outputSchema: z.object({ + success: z.boolean(), + }), + + handler: async (input, ctx) => { + let vmEntry: Awaited>; + try { + vmEntry = await requireVmEntry(input, ctx); + } catch (err) { + if (err instanceof Error && err.message === "Virtual MCP not found") { + return { success: true }; + } + throw err; + } + const { entry, userId } = vmEntry; + + // Clear the DB entry first so the UI returns to idle immediately. + if (entry) { + await patchActiveVms( + ctx.storage.virtualMcps, + input.virtualMcpId, + userId, + (vms) => { + const updated = { ...vms }; + delete updated[userId]; + return updated; + }, + ); + } + + if (entry) { + const vm = freestyle.vms.ref({ vmId: entry.vmId }); + await Promise.race([ + vm.stop().then(() => vm.delete()), + new Promise((_, reject) => + setTimeout(() => reject(new Error("vm.delete() timed out")), 10_000), + ), + ]).catch((err) => + console.error(`[VM_DELETE] ${entry.vmId}: ${err.message}`), + ); + } + + return { success: true }; + }, +}); diff --git a/apps/mesh/src/tools/vm/types.ts b/apps/mesh/src/tools/vm/types.ts new file mode 100644 index 0000000000..97f27dcd1d --- /dev/null +++ b/apps/mesh/src/tools/vm/types.ts @@ -0,0 +1,66 @@ +/** + * Shared VM types and metadata helpers. + * + * VmEntry / VmMetadata are the runtime view of the `activeVms` sub-key + * stored inside the Virtual MCP's metadata JSON column. + * + * NOTE: The read-modify-write in patchActiveVms is NOT atomic across pods. + * Two concurrent VM_START calls for the same (virtualMcpId, userId) pair + * can both read an empty entry, both create Freestyle VMs, and the second + * write will overwrite the first, leaving an orphaned Freestyle VM. This is + * an accepted trade-off for the current usage pattern (one user per VM per + * agent). A proper fix requires either a Postgres advisory lock or a dedicated + * vm_sessions table with UNIQUE(virtual_mcp_id, user_id). + */ + +import type { VirtualMCPStoragePort } from "../../storage/ports"; +import type { VirtualMCPUpdateData } from "../virtual/schema"; + +export interface VmEntry { + vmId: string; + previewUrl: string; + terminalUrl: string | null; +} + +export type VmMetadata = { + githubRepo?: { + url: string; + owner: string; + name: string; + installationId?: number; + } | null; + runtime?: { + detected: string | null; + selected: string | null; + installScript?: string | null; + devScript?: string | null; + port?: string | null; + } | null; + freestyleRepoId?: string | null; + activeVms?: Record; + [key: string]: unknown; +}; + +/** + * Read-modify-write helper: applies `patch` to `metadata.activeVms` and + * persists the result. Returns the new activeVms map. + */ +export async function patchActiveVms( + storage: VirtualMCPStoragePort, + virtualMcpId: string, + userId: string, + patch: (current: Record) => Record, +): Promise { + const virtualMcp = await storage.findById(virtualMcpId); + if (!virtualMcp) return; + + const meta = virtualMcp.metadata as VmMetadata; + const updated = patch({ ...(meta.activeVms ?? {}) }); + + await storage.update(virtualMcpId, userId, { + metadata: { + ...meta, + activeVms: updated, + } as VirtualMCPUpdateData["metadata"], + }); +} diff --git a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx index f56eece0e7..f47f29e339 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -11,11 +11,18 @@ import { Page } from "@/web/components/page"; import { getIconComponent, parseIconString } from "../agent-icon"; import { usePanelActions } from "@/web/layouts/shell-layout"; -import { Edit05, LayoutLeft, Loading01, Settings02 } from "@untitledui/icons"; +import { + Edit05, + LayoutLeft, + Loading01, + Monitor04, + Settings02, +} from "@untitledui/icons"; import { useVirtualMCPActions, useVirtualMCP } from "@decocms/mesh-sdk"; import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import { Suspense, useEffect, useRef, useState, useTransition } from "react"; import { isMac } from "@/web/lib/keyboard-shortcuts"; +import { usePreferences } from "@/web/hooks/use-preferences"; import { ErrorBoundary } from "../error-boundary"; import { Chat } from "./index"; import { OwnerFilter, TaskListContent } from "./tasks-panel"; @@ -280,6 +287,13 @@ function TasksPanelContent({ }; const isSettingsActive = virtualMcpCtx?.mainView?.type === "settings"; + const isPreviewActive = virtualMcpCtx?.mainView?.type === "preview"; + + const [preferences] = usePreferences(); + const hasGithubRepo = + preferences.experimental_vibecode && + !!(virtualMcp?.metadata as { githubRepo?: unknown } | undefined) + ?.githubRepo; return (
@@ -324,6 +338,23 @@ function TasksPanelContent({ Settings )} + {virtualMcp && virtualMcpCtx && !hideProjectHeader && hasGithubRepo && ( + + )} {virtualMcp && !hideProjectHeader && ( )} diff --git a/apps/mesh/src/web/components/github-repo-button.tsx b/apps/mesh/src/web/components/github-repo-button.tsx new file mode 100644 index 0000000000..43790abed7 --- /dev/null +++ b/apps/mesh/src/web/components/github-repo-button.tsx @@ -0,0 +1,143 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { useInsetContext } from "@/web/layouts/agent-shell-layout"; +import { useState } from "react"; +import { + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; +import { toast } from "sonner"; +import { GitHubRepoDialog } from "./github-repo-dialog"; + +function GitHubIcon({ size = 16 }: { size?: number }) { + return ( + + ); +} + +const GITHUB_TOKEN_KEY = "deco:github-token"; + +function getStoredToken(): string | null { + try { + return localStorage.getItem(GITHUB_TOKEN_KEY); + } catch { + return null; + } +} + +export function GitHubRepoButton() { + const inset = useInsetContext(); + const { org } = useProjectContext(); + const [dialogOpen, setDialogOpen] = useState(false); + const [starting, setStarting] = useState(false); + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + if (!inset?.entity) return null; + + const githubRepo = ( + inset.entity.metadata as { + githubRepo?: { url: string; owner: string; name: string } | null; + } + )?.githubRepo; + + // Connected state: show owner/repo with external link + if (githubRepo) { + return ( + + + + + + {githubRepo.owner}/{githubRepo.name} + + + + + Open {githubRepo.owner}/{githubRepo.name} on GitHub + + + ); + } + + const handleClick = async () => { + // If user already has a token, skip device flow and go straight to repo picker + if (getStoredToken()) { + setDialogOpen(true); + return; + } + + // Start device flow immediately, then open dialog with the code + setStarting(true); + try { + const result = await client.callTool({ + name: "GITHUB_DEVICE_FLOW_START", + arguments: {}, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + const data = payload as { + userCode: string; + verificationUri: string; + deviceCode: string; + expiresIn: number; + interval: number; + }; + + // Auto-open GitHub authorization page + window.open(data.verificationUri, "_blank", "noopener"); + + // Open dialog with device flow data already available + setDialogOpen(true); + // Pass the device flow data via a ref on the dialog + window.__decoGithubDeviceFlow = data; + } catch (error) { + toast.error( + "Failed to start GitHub auth: " + + (error instanceof Error ? error.message : "Unknown error"), + ); + } finally { + setStarting(false); + } + }; + + // Unconnected state: show octocat icon button + return ( + <> + + + + + Connect GitHub repo + + + + ); +} diff --git a/apps/mesh/src/web/components/github-repo-dialog.tsx b/apps/mesh/src/web/components/github-repo-dialog.tsx new file mode 100644 index 0000000000..917787ca44 --- /dev/null +++ b/apps/mesh/src/web/components/github-repo-dialog.tsx @@ -0,0 +1,653 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { useState, useRef } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; +import { useInsetContext } from "@/web/layouts/agent-shell-layout"; +import { KEYS } from "@/web/lib/query-keys"; +import { toast } from "sonner"; +import { Check, Copy01, Loading01 } from "@untitledui/icons"; + +interface Installation { + installationId: number; + orgName: string; + avatarUrl: string | null; +} + +interface Repo { + owner: string; + name: string; + fullName: string; + url: string; + private: boolean; +} + +const GITHUB_APP_INSTALL_URL = + "https://github.com/apps/deco-cms/installations/new"; + +const GITHUB_TOKEN_KEY = "deco:github-token"; + +function getStoredToken(): string | null { + try { + return localStorage.getItem(GITHUB_TOKEN_KEY); + } catch { + return null; + } +} + +function storeToken(token: string): void { + try { + localStorage.setItem(GITHUB_TOKEN_KEY, token); + } catch { + // localStorage unavailable + } +} + +function clearStoredToken(): void { + try { + localStorage.removeItem(GITHUB_TOKEN_KEY); + } catch { + // localStorage unavailable + } +} + +// Typed global for passing device flow data from button to dialog +declare global { + interface Window { + __decoGithubDeviceFlow?: { + userCode: string; + verificationUri: string; + deviceCode: string; + expiresIn: number; + interval: number; + }; + } +} + +export function GitHubRepoDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { org } = useProjectContext(); + const inset = useInsetContext(); + const queryClient = useQueryClient(); + const [token, setToken] = useState(getStoredToken); + const [selectedInstallation, setSelectedInstallation] = + useState(null); + const [search, setSearch] = useState(""); + const [deviceFlow, setDeviceFlow] = useState<{ + userCode: string; + verificationUri: string; + deviceCode: string; + interval: number; + } | null>(null); + const [polling, setPolling] = useState(false); + const [copied, setCopied] = useState(false); + const pollTimerRef = useRef | null>(null); + const pollingStartedRef = useRef(false); + + // Pick up device flow data pre-started by the button when dialog opens + if (open && !deviceFlow && !token && window.__decoGithubDeviceFlow) { + const data = window.__decoGithubDeviceFlow; + delete window.__decoGithubDeviceFlow; + setDeviceFlow(data); + // Auto-copy code to clipboard + navigator.clipboard.writeText(data.userCode).catch(() => {}); + } + + const handleCopyCode = (code: string) => { + navigator.clipboard.writeText(code).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + const startPolling = (deviceCode: string, interval: number) => { + setPolling(true); + // Clear any existing timer + if (pollTimerRef.current) clearInterval(pollTimerRef.current); + + pollTimerRef.current = setInterval( + async () => { + try { + const result = await client.callTool({ + name: "GITHUB_DEVICE_FLOW_POLL", + arguments: { deviceCode }, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? + result; + const data = payload as { + status: "pending" | "success" | "expired" | "error"; + token: string | null; + error: string | null; + }; + + if (data.status === "success" && data.token) { + if (pollTimerRef.current) clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + setPolling(false); + setToken(data.token); + storeToken(data.token); + setDeviceFlow(null); + } else if (data.status === "expired" || data.status === "error") { + if (pollTimerRef.current) clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + setPolling(false); + setDeviceFlow(null); + toast.error( + data.error ?? "Authentication expired. Please try again.", + ); + } + // "pending" — keep polling + } catch { + // Network error — keep polling + } + }, + (interval + 1) * 1000, + ); // Add 1s buffer to avoid "slow_down" + }; + + // Auto-start polling if dialog opened with pre-started device flow data + if (deviceFlow && !pollingStartedRef.current && !polling && !token) { + pollingStartedRef.current = true; + startPolling(deviceFlow.deviceCode, deviceFlow.interval); + } + + // List installations (only when we have a token) + const installationsQuery = useQuery({ + queryKey: KEYS.githubInstallations(org.id), + queryFn: async () => { + const result = await client.callTool({ + name: "GITHUB_LIST_INSTALLATIONS", + arguments: { token: token! }, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + return payload as { installations: Installation[] }; + }, + enabled: open && !!token, + // Auto-poll while waiting for the user to install the GitHub App. + // Once installations are found the UI moves to the next screen. + refetchInterval: 3000, + }); + + // Derive effective installation + const installations = installationsQuery.data?.installations ?? []; + const effectiveInstallation = + installations.length === 1 + ? (installations[0] ?? null) + : selectedInstallation; + + // List repos for effective installation + const reposQuery = useQuery({ + queryKey: KEYS.githubRepos( + org.id, + String(effectiveInstallation?.installationId), + ), + queryFn: async () => { + if (!effectiveInstallation) return { repos: [] }; + const result = await client.callTool({ + name: "GITHUB_LIST_REPOS", + arguments: { + token: token!, + installationId: effectiveInstallation.installationId, + }, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? result; + return payload as { repos: Repo[] }; + }, + enabled: !!effectiveInstallation && !!token, + }); + + // Save selected repo + const saveMutation = useMutation({ + mutationFn: async (repo: Repo) => { + if (!inset?.entity) throw new Error("No virtual MCP context"); + await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_UPDATE", + arguments: { + id: inset.entity.id, + data: { + metadata: { + githubRepo: { + url: repo.url, + owner: repo.owner, + name: repo.name, + installationId: effectiveInstallation!.installationId, + }, + // Clear stale VMs so VM_START creates a fresh VM with the new repo + // instead of resuming one that has the old repo cloned. + activeVms: {}, + }, + }, + }, + }); + }, + onSuccess: (_data, repo) => { + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + key[1] === org.id && + key[3] === "collection" && + key[4] === "VIRTUAL_MCP" + ); + }, + }); + toast.success("GitHub repo connected"); + onOpenChange(false); + + // Background: fetch instructions + detect runtime + const currentToken = getStoredToken(); + if (currentToken && inset?.entity) { + const entityId = inset.entity.id; + const invalidateVirtualMcp = () => + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + key[1] === org.id && + key[3] === "collection" && + key[4] === "VIRTUAL_MCP" + ); + }, + }); + + const checkFileExists = async (path: string) => { + const result = await client.callTool({ + name: "GITHUB_GET_FILE_CONTENT", + arguments: { + token: currentToken, + owner: repo.owner, + repo: repo.name, + path, + }, + }); + const payload = + (result as { structuredContent?: unknown }).structuredContent ?? + result; + return payload as { content: string | null; found: boolean }; + }; + + // Fetch AGENTS.md > CLAUDE.md + const fetchInstructions = async () => { + for (const path of ["AGENTS.md", "CLAUDE.md"]) { + const data = await checkFileExists(path); + if (data.found && data.content) { + await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_UPDATE", + arguments: { + id: entityId, + data: { metadata: { instructions: data.content } }, + }, + }); + invalidateVirtualMcp(); + return; + } + } + }; + + // Detect package manager and scripts + const detectRuntime = async () => { + const runtimeFiles: Array<{ file: string; runtime: string }> = [ + { file: "deno.json", runtime: "deno" }, + { file: "deno.jsonc", runtime: "deno" }, + { file: "bun.lock", runtime: "bun" }, + { file: "bunfig.toml", runtime: "bun" }, + { file: "pnpm-lock.yaml", runtime: "pnpm" }, + { file: "yarn.lock", runtime: "yarn" }, + { file: "package-lock.json", runtime: "npm" }, + { file: "package.json", runtime: "npm" }, + ]; + + const installCommands: Record = { + deno: "deno install", + bun: "bun install", + pnpm: "pnpm install", + yarn: "yarn install", + npm: "npm install", + }; + + let detected: string | null = null; + for (const { file, runtime } of runtimeFiles) { + const data = await checkFileExists(file); + if (data.found) { + detected = runtime; + break; + } + } + + if (!detected) return; + + const installScript = installCommands[detected] ?? ""; + + // Try to find dev script from package.json or deno.json + let devScript = ""; + let devPort = ""; + + if (detected === "deno") { + // Check deno.json for tasks + for (const denoFile of ["deno.json", "deno.jsonc"]) { + const data = await checkFileExists(denoFile); + if (data.found && data.content) { + try { + const deno = JSON.parse(data.content) as { + tasks?: Record; + }; + const tasks = deno.tasks ?? {}; + if (tasks.dev) { + devScript = "deno task dev"; + // Try to extract port from the dev task command + const portMatch = tasks.dev.match( + /(?:--port|PORT=|:)(\d{4,5})/, + ); + if (portMatch?.[1]) devPort = portMatch[1]; + } else if (tasks.start) { + devScript = "deno task start"; + } + } catch { + // Invalid JSON + } + break; + } + } + } else { + const pkgData = await checkFileExists("package.json"); + if (pkgData.found && pkgData.content) { + try { + const pkg = JSON.parse(pkgData.content) as { + scripts?: Record; + }; + const scripts = pkg.scripts ?? {}; + const runPrefix = `${detected} run`; + if (scripts.dev) { + devScript = `${runPrefix} dev`; + const portMatch = scripts.dev.match( + /(?:--port|PORT=|:)(\d{4,5})/, + ); + if (portMatch?.[1]) devPort = portMatch[1]; + } else if (scripts.start) { + devScript = `${runPrefix} start`; + } + } catch { + // Invalid JSON, skip + } + } + } + + await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_UPDATE", + arguments: { + id: entityId, + data: { + metadata: { + runtime: { + detected, + selected: detected, + installScript, + devScript, + port: devPort || "8000", + }, + }, + }, + }, + }); + invalidateVirtualMcp(); + }; + + fetchInstructions().catch(() => {}); + detectRuntime().catch(() => {}); + } + }, + onError: (error) => { + toast.error( + "Failed to connect repo: " + + (error instanceof Error ? error.message : "Unknown error"), + ); + }, + }); + + const handleInstallApp = () => { + const popup = window.open( + GITHUB_APP_INSTALL_URL, + "github-app-install", + "width=800,height=600,popup=yes", + ); + const interval = setInterval(() => { + if (popup?.closed) { + clearInterval(interval); + installationsQuery.refetch(); + } + }, 1000); + }; + + const filteredRepos = + reposQuery.data?.repos.filter((repo) => + repo.fullName.toLowerCase().includes(search.toLowerCase()), + ) ?? []; + + const renderContent = () => { + // No token — show device flow auth + if (!token) { + // Device flow started — show code + if (deviceFlow) { + return ( +
+

+ Enter this code on the GitHub page: +

+ + + Open GitHub → + + {polling && ( +
+ + Waiting for authorization... +
+ )} +
+ ); + } + + // Waiting for device flow to start (shouldn't normally happen) + return ( +
+ +
+ ); + } + + // Loading installations + if (installationsQuery.isLoading) { + return ( +
+ +
+ ); + } + + // Error loading installations (token might be invalid) + if (installationsQuery.isError) { + return ( +
+

+ GitHub token may have expired. +

+ +
+ ); + } + + // No installations — prompt app install + if (installations.length === 0) { + return ( +
+

+ Install the Deco CMS GitHub App on your organization to continue. +

+
+ + +
+ +
+ ); + } + + // Multiple installations and none selected + if (!effectiveInstallation) { + return ( +
+

+ Select an organization: +

+ {installations.map((inst) => ( + + ))} +
+ ); + } + + // Repo picker + if (reposQuery.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {installations.length > 1 && ( + + )} + setSearch(e.target.value)} + autoFocus + /> +
+ {filteredRepos.length === 0 ? ( +

+ No repositories found +

+ ) : ( + filteredRepos.map((repo) => ( + + )) + )} +
+
+ ); + }; + + return ( + + + + Connect GitHub Repository + + {renderContent()} + + + ); +} diff --git a/apps/mesh/src/web/components/preview/visual-editor-prompt.test.ts b/apps/mesh/src/web/components/preview/visual-editor-prompt.test.ts new file mode 100644 index 0000000000..2e8b2f5c8b --- /dev/null +++ b/apps/mesh/src/web/components/preview/visual-editor-prompt.test.ts @@ -0,0 +1,134 @@ +import { describe, test, expect } from "bun:test"; +import { + formatVisualEditorMessage, + computePromptPosition, +} from "./visual-editor-prompt"; +import type { VisualEditorPayload } from "./visual-editor-script"; + +const basePayload: VisualEditorPayload = { + tag: "button", + id: "submit", + classes: "btn btn-primary", + text: "Click me", + html: '', + manifestKey: null, + componentName: null, + parents: "div.container > form", + url: "http://localhost:3000/", + path: "/", + viewport: { width: 1920, height: 1080 }, + position: { x: 500, y: 300 }, +}; + +describe("formatVisualEditorMessage", () => { + test("includes the user prompt", () => { + const msg = formatVisualEditorMessage(basePayload, "make this red"); + expect(msg).toContain('**"make this red"**'); + }); + + test("includes clicked element selector", () => { + const msg = formatVisualEditorMessage(basePayload, "test"); + expect(msg).toContain('"); + }); + + test("escapes triple backticks in html", () => { + const payload = { ...basePayload, html: "test```injection" }; + const msg = formatVisualEditorMessage(payload, "test"); + expect(msg).not.toContain("test```injection"); + expect(msg).toContain("test`` `injection"); + }); + + test("includes manifestKey when present", () => { + const payload = { + ...basePayload, + manifestKey: "site/sections/Hero.tsx", + }; + const msg = formatVisualEditorMessage(payload, "test"); + expect(msg).toContain("site/sections/Hero.tsx"); + }); + + test("omits manifestKey when null", () => { + const msg = formatVisualEditorMessage(basePayload, "test"); + expect(msg).not.toContain("Section source file"); + }); + + test("includes componentName when present", () => { + const payload = { ...basePayload, componentName: "HeroSection" }; + const msg = formatVisualEditorMessage(payload, "test"); + expect(msg).toContain("HeroSection"); + }); + + test("sanitizes markdown special chars in text", () => { + const payload = { + ...basePayload, + text: "click `here` for **bold**", + }; + const msg = formatVisualEditorMessage(payload, "test"); + expect(msg).not.toContain("`here`"); + }); +}); + +describe("computePromptPosition", () => { + test("centers horizontally on click position", () => { + const pos = computePromptPosition( + { x: 500, y: 300 }, + { width: 1920, height: 1080 }, + ); + // left = max(12, min(500-160, 1920-320-12)) = max(12, 340) = 340 + expect(pos.leftPct).toBeCloseTo((340 / 1920) * 100, 1); + }); + + test("clamps to left edge", () => { + const pos = computePromptPosition( + { x: 10, y: 300 }, + { width: 1920, height: 1080 }, + ); + // left = max(12, min(10-160, ...)) = max(12, -150) = 12 + expect(pos.leftPct).toBeCloseTo((12 / 1920) * 100, 1); + }); + + test("clamps to right edge", () => { + const pos = computePromptPosition( + { x: 1910, y: 300 }, + { width: 1920, height: 1080 }, + ); + // left = max(12, min(1910-160, 1920-320-12)) = max(12, min(1750, 1588)) = 1588 + expect(pos.leftPct).toBeCloseTo((1588 / 1920) * 100, 1); + }); + + test("places below click when in upper area", () => { + const pos = computePromptPosition( + { x: 500, y: 300 }, + { width: 1920, height: 1080 }, + ); + // isNearBottom = 300/1080 = 0.28 < 0.68 → below + // top = min(300+18, 1080-44-12) = min(318, 1024) = 318 + expect(pos.topPct).toBeCloseTo((318 / 1080) * 100, 1); + }); + + test("places above click when near bottom", () => { + const pos = computePromptPosition( + { x: 500, y: 900 }, + { width: 1920, height: 1080 }, + ); + // isNearBottom = 900/1080 = 0.83 > 0.68 → above + // top = max(12, 900-44-18) = max(12, 838) = 838 + expect(pos.topPct).toBeCloseTo((838 / 1080) * 100, 1); + }); +}); diff --git a/apps/mesh/src/web/components/preview/visual-editor-prompt.tsx b/apps/mesh/src/web/components/preview/visual-editor-prompt.tsx new file mode 100644 index 0000000000..63f1dc4df2 --- /dev/null +++ b/apps/mesh/src/web/components/preview/visual-editor-prompt.tsx @@ -0,0 +1,161 @@ +import { useRef, useState } from "react"; +import { useChatBridge } from "@/web/components/chat/context"; +import { usePanelActions } from "@/web/layouts/shell-layout"; +import type { VisualEditorPayload } from "./visual-editor-script"; + +/** Sanitize a string for safe embedding in markdown (escape backticks and asterisks) */ +function sanitizeMd(s: string): string { + return s.replace(/[`*_]/g, "\\$&"); +} + +/** + * Build the markdown message sent to the AI from a visual editor click. + * Exported for testing. + */ +export function formatVisualEditorMessage( + payload: VisualEditorPayload, + prompt: string, +): string { + const lines = [ + `The user selected an element on the live preview and asked: **"${sanitizeMd(prompt.trim())}"**`, + "", + "For text content changes, locate the correct source file and apply the change.", + "For code and CSS changes, understand which component the user is referring to and apply changes to the correct component.", + "", + ]; + + if (payload.manifestKey) { + lines.push( + `**Section source file:** \`${sanitizeMd(payload.manifestKey)}\``, + "", + ); + } + + const selector = `<${payload.tag}${payload.classes ? ` class="${sanitizeMd(payload.classes)}"` : ""}>`; + lines.push(`**Clicked element:** \`${selector}\``); + if (payload.parents) + lines.push( + `**DOM breadcrumb:** ${sanitizeMd(payload.parents)} > ${payload.tag}`, + ); + if (payload.text) + lines.push(`**Text content:** "${sanitizeMd(payload.text)}"`); + if (payload.componentName) + lines.push(`**Component name:** ${sanitizeMd(payload.componentName)}`); + + // Escape triple-backticks in html to prevent markdown code fence breakout + const safeHtml = payload.html.replace(/```/g, "`` `"); + lines.push("", "**HTML snippet:**", "```html", safeHtml, "```"); + lines.push( + "", + "Please read the source file, locate the element, and apply the requested change.", + ); + + return lines.join("\n"); +} + +/** + * Compute the floating prompt position relative to the clicked element. + * Returns { leftPct, topPct } as percentages of viewport. + * Exported for testing. + */ +export function computePromptPosition( + position: { x: number; y: number }, + viewport: { width: number; height: number }, + popupW = 320, + popupH = 44, + pad = 12, +): { leftPct: number; topPct: number } { + const { x, y } = position; + const { width: vw, height: vh } = viewport; + const left = Math.max(pad, Math.min(x - popupW / 2, vw - popupW - pad)); + const isNearBottom = y / vh > 0.68; + const top = isNearBottom + ? Math.max(pad, y - popupH - 18) + : Math.min(y + 18, vh - popupH - pad); + + return { + leftPct: (left / vw) * 100, + topPct: (top / vh) * 100, + }; +} + +interface VisualEditorPromptProps { + element: VisualEditorPayload; + onDismiss: () => void; +} + +export function VisualEditorPrompt({ + element, + onDismiss, +}: VisualEditorPromptProps) { + const [input, setInput] = useState(""); + const inputRef = useRef(null); + const { sendMessage } = useChatBridge(); + const { setChatOpen } = usePanelActions(); + + const handleSend = () => { + if (!input.trim()) return; + + const text = formatVisualEditorMessage(element, input); + setChatOpen(true); + sendMessage({ parts: [{ type: "text", text }] }); + onDismiss(); + }; + + const pos = computePromptPosition(element.position, element.viewport); + + return ( +
+
{ + e.preventDefault(); + void handleSend(); + }} + > + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") onDismiss(); + }} + placeholder="Ask the AI..." + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + +
+
+ ); +} diff --git a/apps/mesh/src/web/components/preview/visual-editor-script.ts b/apps/mesh/src/web/components/preview/visual-editor-script.ts new file mode 100644 index 0000000000..cc8daf4f7c --- /dev/null +++ b/apps/mesh/src/web/components/preview/visual-editor-script.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; + +/** + * Payload posted from the iframe to the parent via postMessage + * when the user clicks an element in visual editor mode. + */ +export const VisualEditorPayloadSchema = z.object({ + tag: z.string(), + id: z.string(), + classes: z.string(), + text: z.string(), + html: z.string(), + manifestKey: z.string().nullable(), + componentName: z.string().nullable(), + parents: z.string(), + url: z.string(), + path: z.string(), + viewport: z.object({ width: z.number(), height: z.number() }), + position: z.object({ x: z.number(), y: z.number() }), +}); + +export type VisualEditorPayload = z.infer; + +/** + * Self-contained script string for injection into the preview iframe. + * Stored as a string constant (not .toString()) to survive minification. + * + * Key design decisions: + * - Uses `mousemove` (not `mouseover`) — fires once per position, gives correct target + * - rAF-throttled highlight — avoids layout thrashing + * - No CSS transition on highlight — rAF already provides smooth updates; transitions + * on top/left would trigger redundant layout passes + * - Posts to "*" target origin — the iframe can't know the parent origin reliably; + * origin validation happens on the receiving (parent) side + * - Strips value attributes from captured HTML to avoid leaking form data + * - ~2.5KB unminified — keep it small since it's inlined in the JS bundle + */ +export const VISUAL_EDITOR_SCRIPT = `(function() { + if (window.__visualEditorActive) return; + window.__visualEditorActive = true; + + var cursorStyle = document.createElement("style"); + cursorStyle.textContent = "* { cursor: default !important; }"; + document.head.appendChild(cursorStyle); + + var highlight = document.createElement("div"); + highlight.style.cssText = "position:fixed;pointer-events:none;outline:2px solid #a855f7;background:rgba(168,85,247,0.08);border-radius:2px;z-index:2147483647;display:none;"; + document.body.appendChild(highlight); + + var badge = document.createElement("div"); + badge.style.cssText = "position:fixed;pointer-events:none;background:#a855f7;color:white;font:11px/1 monospace;padding:2px 6px;border-radius:2px;z-index:2147483647;display:none;white-space:nowrap;max-width:240px;overflow:hidden;text-overflow:ellipsis;"; + document.body.appendChild(badge); + + var lastTarget = null; + var rafPending = false; + var moveHandler = function(e) { + if (rafPending) return; + rafPending = true; + var target = e.target; + requestAnimationFrame(function() { + rafPending = false; + var el = target; + if (!el || el === highlight || el === badge) return; + if (el === lastTarget) return; + lastTarget = el; + var r = el.getBoundingClientRect(); + highlight.style.display = "block"; + highlight.style.top = r.top + "px"; + highlight.style.left = r.left + "px"; + highlight.style.width = r.width + "px"; + highlight.style.height = r.height + "px"; + var tag = el.tagName.toLowerCase(); + var id = el.id ? "#" + el.id : ""; + var cls = el.className && typeof el.className === "string" + ? "." + el.className.trim().split(/\\s+/).slice(0, 2).join(".") + : ""; + badge.textContent = tag + id + cls; + badge.style.display = "block"; + badge.style.top = Math.max(0, r.top - 20) + "px"; + badge.style.left = r.left + "px"; + }); + }; + document.addEventListener("mousemove", moveHandler, true); + + var outHandler = function(e) { + if (!e.relatedTarget || e.relatedTarget === document.documentElement) { + highlight.style.display = "none"; + badge.style.display = "none"; + lastTarget = null; + } + }; + document.addEventListener("mouseout", outHandler, true); + + var clickHandler = function(e) { + e.preventDefault(); + e.stopImmediatePropagation(); + var el = e.target; + if (!el || el === highlight || el === badge) return; + + highlight.style.outline = "2px solid #a855f7"; + highlight.style.background = "rgba(168,85,247,0.15)"; + setTimeout(function() { + highlight.style.outline = "2px solid #a855f7"; + highlight.style.background = "rgba(168,85,247,0.08)"; + }, 400); + + var tag = el.tagName.toLowerCase(); + var id = el.id || ""; + var classes = el.className && typeof el.className === "string" ? el.className.trim() : ""; + var text = (el.textContent || "").trim().slice(0, 200); + var html = (el.outerHTML || "").slice(0, 800).replace(/\\svalue=("[^"]*"|'[^']*'|\\S+)/gi, ""); + + var closestSection = el.closest("section[data-manifest-key]"); + var manifestKey = closestSection ? closestSection.getAttribute("data-manifest-key") : null; + + var ancestor = el; + var componentName = null; + for (var i = 0; i < 10 && ancestor; i++) { + if (ancestor.dataset) componentName = ancestor.dataset.componentName || componentName; + ancestor = ancestor.parentElement; + } + + var parents = []; + var p = el.parentElement; + for (var j = 0; j < 4 && p && p !== document.body; j++) { + var pTag = p.tagName ? p.tagName.toLowerCase() : ""; + var pId = p.id ? "#" + p.id : ""; + var pCls = p.className && typeof p.className === "string" + ? "." + p.className.trim().split(/\\s+/)[0] + : ""; + parents.unshift(pTag + pId + pCls); + p = p.parentElement; + } + + window.parent.postMessage({ + type: "visual-editor::element-clicked", + payload: { + tag: tag, id: id, classes: classes, text: text, html: html, + manifestKey: manifestKey, componentName: componentName, + parents: parents.join(" > "), + url: window.location.href, path: window.location.pathname, + viewport: { width: window.innerWidth, height: window.innerHeight }, + position: { x: Math.round(e.clientX), y: Math.round(e.clientY) } + } + }, "*"); + }; + document.addEventListener("click", clickHandler, true); + + window.addEventListener("message", function(e) { + if (e.data && e.data.type === "visual-editor::deactivate") { + highlight.remove(); + badge.remove(); + cursorStyle.remove(); + document.removeEventListener("mousemove", moveHandler, true); + document.removeEventListener("mouseout", outHandler, true); + document.removeEventListener("click", clickHandler, true); + window.__visualEditorActive = false; + } + }); +})();`; diff --git a/apps/mesh/src/web/components/vm-preview.tsx b/apps/mesh/src/web/components/vm-preview.tsx new file mode 100644 index 0000000000..1d8207bab2 --- /dev/null +++ b/apps/mesh/src/web/components/vm-preview.tsx @@ -0,0 +1,526 @@ +import { useState, useRef, useEffect } from "react"; +import { + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; +import { useInsetContext } from "@/web/layouts/agent-shell-layout"; +import { + ChevronDown, + CursorClick01, + LinkExternal01, + Loading01, + Monitor04, + RefreshCw01, + StopCircle, + Terminal, +} from "@untitledui/icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@deco/ui/components/dropdown-menu.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + ViewModeToggle, + type ViewModeOption, +} from "@deco/ui/components/view-mode-toggle.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { + VISUAL_EDITOR_SCRIPT, + VisualEditorPayloadSchema, + type VisualEditorPayload, +} from "./preview/visual-editor-script"; +import { VisualEditorPrompt } from "./preview/visual-editor-prompt"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "./resizable"; +import { useVmEvents } from "@/web/hooks/use-vm-events"; +import { VmTerminal } from "./vm-terminal"; + +interface VmData { + terminalUrl: string | null; + previewUrl: string; + vmId: string; + isNewVm: boolean; +} + +type ViewStatus = + | "idle" + | "creating" + | "running" + | "suspended" + | "stopping" + | "error"; +type PreviewViewMode = "preview" | "visual"; + +const VIEW_MODE_OPTIONS: [ + ViewModeOption, + ViewModeOption, +] = [ + { value: "preview", icon: , tooltip: "Interactive" }, + { + value: "visual", + icon: , + tooltip: "Visual Editor", + }, +]; + +function formatActionError(error: unknown, fallback: string): string { + if (!(error instanceof Error)) return fallback; + // Strip MCP protocol prefixes like "MCP error -32602: " + const msg = error.message.replace(/^MCP error -?\d+:\s*/i, ""); + return msg || fallback; +} + +export function VmPreviewContent() { + const { org } = useProjectContext(); + const inset = useInsetContext(); + const [status, setStatus] = useState("idle"); + const [statusLabel, setStatusLabel] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const [actionError, setActionError] = useState(""); + const [execInFlight, setExecInFlight] = useState(false); + const vmDataRef = useRef(null); + const startingRef = useRef(false); + + // Visual editor state + const [viewMode, setViewMode] = useState("preview"); + const [visualElement, setVisualElement] = + useState(null); + const previewIframeRef = useRef(null); + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + // SSE connection to daemon — only active when VM is running + const vmEvents = useVmEvents( + status === "running" ? (vmDataRef.current?.previewUrl ?? null) : null, + ); + + const hasHtmlPreview = vmEvents.status.htmlSupport; + + const callTool = async (name: string, args: Record) => { + const result = await client.callTool({ name, arguments: args }); + const content = (result as { content?: Array<{ text?: string }> }).content; + if (content?.[0]?.text?.startsWith("Error:")) { + throw new Error(content[0].text); + } + return ( + (result as { structuredContent?: unknown }).structuredContent ?? result + ); + }; + + const handleExec = async (action: "install" | "dev") => { + if (execInFlight || !inset?.entity) return; + setExecInFlight(true); + try { + const data = (await callTool("VM_EXEC", { + virtualMcpId: inset.entity.id, + action, + })) as { success: boolean; error?: string }; + if (!data.success) throw new Error(data.error ?? "Command failed"); + } finally { + setExecInFlight(false); + } + }; + + const handleStart = async () => { + if (startingRef.current) return; + startingRef.current = true; + setStatus("creating"); + setStatusLabel("Connecting..."); + setErrorMsg(""); + + try { + if (!inset?.entity) throw new Error("No virtual MCP context"); + const data = (await callTool("VM_START", { + virtualMcpId: inset.entity.id, + })) as VmData; + + if (!data.previewUrl || !data.vmId) { + throw new Error("Invalid VM response — missing URLs"); + } + + vmDataRef.current = data; + setStatus("running"); + setStatusLabel(""); + + if (!data.isNewVm) { + // Existing VM — kick off dev server restart without blocking. + handleExec("dev").catch(() => {}); + return; + } + + // New VM — run install + dev + await handleExec("install"); + await handleExec("dev"); + } catch (error) { + setStatus("error"); + setErrorMsg( + error instanceof Error ? error.message : "Failed to start VM", + ); + } finally { + startingRef.current = false; + } + }; + + const handleResume = async () => { + setStatus("running"); + setActionError(""); + try { + await handleExec("dev"); + } catch (error) { + setActionError(formatActionError(error, "Failed to resume VM")); + } + }; + + const handleStop = async () => { + vmDataRef.current = null; + setStatus("stopping"); + setVisualElement(null); + setViewMode("preview"); + + const virtualMcpId = inset?.entity?.id; + if (virtualMcpId) { + try { + await client.callTool({ + name: "VM_DELETE", + arguments: { virtualMcpId }, + }); + } catch { + // Best effort + } + } + + setStatus("idle"); + }; + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — auto-start on mount requires DOM lifecycle; no React 19 alternative + useEffect(() => { + if (inset?.entity?.id) { + handleStart(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inset?.entity?.id]); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — postMessage listener requires DOM event subscription; no React 19 alternative + useEffect(() => { + const vmData = vmDataRef.current; + if (status !== "running" || !vmData?.previewUrl) return; + + let allowedOrigin: string; + try { + allowedOrigin = new URL(vmData.previewUrl).origin; + } catch { + return; // Malformed URL — skip listener setup + } + + const handler = (e: MessageEvent) => { + if (e.origin !== allowedOrigin) return; + if (e.data?.type !== "visual-editor::element-clicked") return; + const result = VisualEditorPayloadSchema.safeParse(e.data.payload); + if (result.success) { + setVisualElement(result.data); + } + }; + + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [status]); + + // Detect suspension via SSE disconnect + // oxlint-disable-next-line ban-use-effect/ban-use-effect — responds to vmEvents.suspended changing; drives status transition + useEffect(() => { + if (vmEvents.suspended && status === "running") { + setStatus("suspended"); + } + if (!vmEvents.suspended && status === "suspended") { + setStatus("running"); + } + }, [vmEvents.suspended, status]); + + const injectVisualEditor = () => { + const win = previewIframeRef.current?.contentWindow; + if (!win) return; + win.postMessage( + { type: "visual-editor::activate", script: VISUAL_EDITOR_SCRIPT }, + "*", + ); + }; + + const deactivateVisualEditor = () => { + const win = previewIframeRef.current?.contentWindow; + if (!win) return; + win.postMessage({ type: "visual-editor::deactivate" }, "*"); + }; + + const handleViewModeChange = (mode: PreviewViewMode) => { + setViewMode(mode); + setVisualElement(null); + if (mode === "visual") { + injectVisualEditor(); + } else { + deactivateVisualEditor(); + } + }; + + if (status === "idle" || status === "stopping") { + const isStopping = status === "stopping"; + return ( +
+ +

Preview

+

+ Start a development server from your connected GitHub repository. +

+ +
+ ); + } + + if (status === "creating") { + return ( +
+ +

{statusLabel}

+
+ ); + } + + if (status === "error") { + return ( +
+

{errorMsg}

+ +
+ ); + } + + const vmData = vmDataRef.current; + if (!vmData) return null; + + const isRunning = status === "running" || status === "suspended"; + + return ( +
+ {/* Unified toolbar */} +
+ {isRunning && hasHtmlPreview && ( + + )} + {isRunning && ( + + + + + + { + setActionError(""); + try { + await handleExec("install"); + } catch (error) { + setActionError( + formatActionError(error, "Reinstall failed"), + ); + } + }} + > + Reinstall Dependencies + + { + setActionError(""); + try { + await handleExec("dev"); + } catch (error) { + setActionError(formatActionError(error, "Restart failed")); + } + }} + > + Restart Dev Server + + + + )} +
+ + + + + Refresh + + + {vmData.previewUrl} + + {vmData.vmId && ( + + + + + Copy VM ID + + )} +
+ + + + + Open in new tab + + + + + + Stop VM + +
+ + {/* Content area */} +
+ {status === "suspended" && ( +
+

+ VM suspended due to inactivity. +

+ +
+ )} + + {actionError && ( +
+ {actionError} + +
+ )} + + + + {hasHtmlPreview ? ( + <> + {viewMode === "visual" && !visualElement && ( +
+ + Click any element to ask the AI +
+ )} + {viewMode === "visual" && visualElement && ( + setVisualElement(null)} + /> + )} +