From da503a82c505b1bfcb0ed25f32c92663877234c5 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Fri, 27 Feb 2026 09:52:02 -0300 Subject: [PATCH] feat(site-research): add Site Research client plugin New client-only plugin that analyzes websites across SEO, brand, and content quality. Uses a Virtual MCP with Firecrawl + Perplexity + OpenRouter to run analysis steps, stores results as JSON files in object storage, and synthesizes a unified report via LLM. Package: mesh-plugin-site-research - Steps: crawl (Firecrawl map+scrape), brand research (Perplexity), SEO analysis (Firecrawl+Perplexity), with LLM report synthesis - File-based state in object storage with resume support - Progress UI with step-by-step status, session history, report view - Report rendering reuses ReportSectionRenderer from mesh-plugin-reports Supporting changes: - local-dev: add PUT support to /files/ handler + CORS fix for uploads - mesh-sdk: add extraArgs to useCollectionList for include_virtual - binding-selector: include virtual connections in dropdown Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/package.json | 2 + .../mesh/src/api/routes/local-dev-discover.ts | 13 +- apps/mesh/src/api/routes/proxy.ts | 22 +- .../src/web/components/binding-selector.tsx | 4 +- apps/mesh/src/web/layouts/plugin-layout.tsx | 6 + apps/mesh/src/web/plugins.ts | 6 +- packages/cli/src/lib/local-dev-manager.ts | 5 +- packages/cli/src/lib/mesh-auth.test.ts | 387 +++++++++++++++++ packages/cli/src/lib/mesh-auth.ts | 335 +++++++++++++++ packages/cli/src/lib/mesh-client.ts | 193 +++++++++ packages/local-dev/src/logger.ts | 21 + packages/local-dev/src/server.ts | 23 +- packages/local-dev/src/tools.ts | 7 +- .../components/declare-layout.tsx | 401 ++++++++++++++++++ .../components/declare-setup.tsx | 151 +++++++ packages/mesh-plugin-declare/index.tsx | 36 ++ packages/mesh-plugin-declare/lib/binding.ts | 34 ++ .../mesh-plugin-declare/lib/query-keys.ts | 14 + packages/mesh-plugin-declare/package.json | 21 + packages/mesh-plugin-declare/tsconfig.json | 11 + packages/mesh-plugin-object-storage/index.tsx | 2 +- .../components/hire-agent-cta.tsx | 40 ++ .../components/research-layout.tsx | 295 +++++++++++++ .../components/research-list.tsx | 66 +++ .../components/research-progress.tsx | 133 ++++++ .../components/research-report.tsx | 135 ++++++ .../components/research-start-form.tsx | 54 +++ .../hooks/use-research-progress.ts | 59 +++ .../hooks/use-research-runner.ts | 221 ++++++++++ .../hooks/use-research-sessions.ts | 51 +++ packages/mesh-plugin-site-research/index.tsx | 43 ++ .../lib/query-keys.ts | 16 + .../mesh-plugin-site-research/lib/report.ts | 101 +++++ .../mesh-plugin-site-research/lib/steps.ts | 40 ++ .../mesh-plugin-site-research/lib/storage.ts | 77 ++++ .../mesh-plugin-site-research/lib/types.ts | 82 ++++ .../mesh-plugin-site-research/package.json | 20 + packages/mesh-plugin-site-research/shared.ts | 3 + .../mesh-plugin-site-research/tsconfig.json | 11 + .../mesh-sdk/src/hooks/use-collections.ts | 4 + 40 files changed, 3110 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/lib/mesh-auth.test.ts create mode 100644 packages/cli/src/lib/mesh-auth.ts create mode 100644 packages/cli/src/lib/mesh-client.ts create mode 100644 packages/mesh-plugin-declare/components/declare-layout.tsx create mode 100644 packages/mesh-plugin-declare/components/declare-setup.tsx create mode 100644 packages/mesh-plugin-declare/index.tsx create mode 100644 packages/mesh-plugin-declare/lib/binding.ts create mode 100644 packages/mesh-plugin-declare/lib/query-keys.ts create mode 100644 packages/mesh-plugin-declare/package.json create mode 100644 packages/mesh-plugin-declare/tsconfig.json create mode 100644 packages/mesh-plugin-site-research/components/hire-agent-cta.tsx create mode 100644 packages/mesh-plugin-site-research/components/research-layout.tsx create mode 100644 packages/mesh-plugin-site-research/components/research-list.tsx create mode 100644 packages/mesh-plugin-site-research/components/research-progress.tsx create mode 100644 packages/mesh-plugin-site-research/components/research-report.tsx create mode 100644 packages/mesh-plugin-site-research/components/research-start-form.tsx create mode 100644 packages/mesh-plugin-site-research/hooks/use-research-progress.ts create mode 100644 packages/mesh-plugin-site-research/hooks/use-research-runner.ts create mode 100644 packages/mesh-plugin-site-research/hooks/use-research-sessions.ts create mode 100644 packages/mesh-plugin-site-research/index.tsx create mode 100644 packages/mesh-plugin-site-research/lib/query-keys.ts create mode 100644 packages/mesh-plugin-site-research/lib/report.ts create mode 100644 packages/mesh-plugin-site-research/lib/steps.ts create mode 100644 packages/mesh-plugin-site-research/lib/storage.ts create mode 100644 packages/mesh-plugin-site-research/lib/types.ts create mode 100644 packages/mesh-plugin-site-research/package.json create mode 100644 packages/mesh-plugin-site-research/shared.ts create mode 100644 packages/mesh-plugin-site-research/tsconfig.json diff --git a/apps/mesh/package.json b/apps/mesh/package.json index f729e73392..b7cccf4258 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -114,11 +114,13 @@ "jose": "^6.0.11", "lucide-react": "^0.468.0", "marked": "^15.0.6", + "mesh-plugin-declare": "workspace:*", "mesh-plugin-object-storage": "workspace:*", "mesh-plugin-preview": "workspace:*", "mesh-plugin-private-registry": "workspace:*", "farmrio-collection-reorder": "workspace:*", "mesh-plugin-reports": "workspace:*", + "mesh-plugin-site-research": "workspace:*", "mesh-plugin-user-sandbox": "workspace:*", "mesh-plugin-workflows": "workspace:*", "nanoid": "^5.1.6", diff --git a/apps/mesh/src/api/routes/local-dev-discover.ts b/apps/mesh/src/api/routes/local-dev-discover.ts index 69a3a227ce..65948acc70 100644 --- a/apps/mesh/src/api/routes/local-dev-discover.ts +++ b/apps/mesh/src/api/routes/local-dev-discover.ts @@ -188,12 +188,8 @@ app.get("/discover", async (c) => { if (meta?.localDevRoot) { linkedRoots.add(meta.localDevRoot); - // Reconcile port drift for this connection (best-effort, don't block discovery) - try { - await reconcileLocalDevConnection(conn, meshContext.storage); - } catch { - // Non-critical — discovery continues for other connections - } + // Reconcile port drift for this connection + await reconcileLocalDevConnection(conn, meshContext.storage); continue; } @@ -276,7 +272,7 @@ app.post("/add-project", async (c) => { slug, name, description: `Local development project (${root})`, - enabledPlugins: ["object-storage", "preview"], + enabledPlugins: ["object-storage", "preview", "declare"], ui: { banner: null, bannerColor: "#10B981", @@ -292,6 +288,9 @@ app.post("/add-project", async (c) => { await ctx.storage.projectPluginConfigs.upsert(project.id, "preview", { connectionId: connection.id, }); + await ctx.storage.projectPluginConfigs.upsert(project.id, "declare", { + connectionId: connection.id, + }); // 5. Create a Virtual MCP (agent) so the local-dev tools are available in chat const virtualMcp = await ctx.storage.virtualMcps.create( diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 6c369ff3dc..171be05469 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -136,21 +136,15 @@ async function createMCPProxyDoNotUseDirectly( throw new Error(`Connection inactive: ${connection.status}`); } - // Reconcile local-dev port drift before connecting (only for local-dev connections) - const meta = connection.metadata as { localDevRoot?: string } | null; - if (meta?.localDevRoot) { - const reconciled = await reconcileLocalDevConnection( - connection, - ctx.storage, + // Reconcile local-dev port drift before connecting + const reconciled = await reconcileLocalDevConnection(connection, ctx.storage); + if (reconciled.connection_url === null) { + throw new Error( + "Local dev server is not running. Start it with `deco link` and try again.", ); - if (reconciled.connection_url === null) { - throw new Error( - "Local dev server is not running. Start it with `deco link` and try again.", - ); - } - if (reconciled.connection_url !== connection.connection_url) { - connection.connection_url = reconciled.connection_url; - } + } + if (reconciled.connection_url !== connection.connection_url) { + connection.connection_url = reconciled.connection_url; } // Create base client with auth + monitoring transports diff --git a/apps/mesh/src/web/components/binding-selector.tsx b/apps/mesh/src/web/components/binding-selector.tsx index b380a04246..6459a5055e 100644 --- a/apps/mesh/src/web/components/binding-selector.tsx +++ b/apps/mesh/src/web/components/binding-selector.tsx @@ -64,7 +64,9 @@ export function BindingSelector({ const isInstalling = isLocalInstalling || isGlobalInstalling; - const allConnections = useConnections(); + const allConnections = useConnections({ + extraArgs: { include_virtual: true }, + }); // Filter connections based on binding type // Use the hook for string bindings diff --git a/apps/mesh/src/web/layouts/plugin-layout.tsx b/apps/mesh/src/web/layouts/plugin-layout.tsx index f7bfe3b5ac..0eca4394b1 100644 --- a/apps/mesh/src/web/layouts/plugin-layout.tsx +++ b/apps/mesh/src/web/layouts/plugin-layout.tsx @@ -40,6 +40,7 @@ import { useQuery } from "@tanstack/react-query"; import { KEYS } from "@/web/lib/query-keys"; import { Button } from "@deco/ui/components/button.tsx"; import { Page } from "@/web/components/page"; +import { SaveChangesButton } from "@/web/components/topbar/save-changes-button"; interface PluginLayoutProps { /** @@ -289,6 +290,11 @@ export function PluginLayout({ onConnectionChange: () => {}, })} + + + + + { try { diff --git a/packages/cli/src/lib/mesh-auth.test.ts b/packages/cli/src/lib/mesh-auth.test.ts new file mode 100644 index 0000000000..6cb9a653a5 --- /dev/null +++ b/packages/cli/src/lib/mesh-auth.test.ts @@ -0,0 +1,387 @@ +/** + * mesh-auth E2E tests + * + * Tests the CLI authentication callback flow: + * 1. A local HTTP callback server starts on a random port + * 2. After login, the browser redirects to localhost:PORT/callback with session cookies + * 3. The callback server uses the cookies to create an API key via the Mesh API + * 4. The API key is returned to the CLI + * + * These tests mock the Mesh API server and simulate the browser redirect. + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { createServer, type Server } from "node:http"; + +// ============================================================================ +// Mock Mesh API server +// ============================================================================ + +function startMockMeshApi(opts?: { + expectedCookie?: string; + expectedOrigin?: string; + apiKey?: string; + rejectPermissions?: boolean; +}): Promise<{ server: Server; url: string }> { + const apiKey = opts?.apiKey ?? "mesh_test_key_abc123"; + + return new Promise((resolve) => { + const server = createServer(async (req, res) => { + // Only handle api-key/create + if (req.url === "/api/auth/api-key/create" && req.method === "POST") { + // Check Origin header (Better Auth CSRF check) + const origin = req.headers.origin; + if (!origin) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + code: "MISSING_OR_NULL_ORIGIN", + message: "Missing or null Origin", + }), + ); + return; + } + + // Check for session cookie or Authorization header + const cookie = req.headers.cookie ?? ""; + const auth = req.headers.authorization ?? ""; + if (!cookie && !auth) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ message: "Unauthorized" })); + return; + } + + // Read body to check for forbidden fields + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = JSON.parse(Buffer.concat(chunks).toString()); + + if (opts?.rejectPermissions && body.permissions) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + code: "THE_PROPERTY_YOURE_TRYING_TO_SET_CAN_ONLY_BE_SET_FROM_THE_SERVER_AUTH_INSTANCE_ONLY", + message: + "The property you're trying to set can only be set from the server auth instance only.", + }), + ); + return; + } + + // Success — return API key + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ key: apiKey })); + return; + } + + res.writeHead(404); + res.end("Not found"); + }); + + server.listen(0, () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolve({ server, url: `http://localhost:${port}` }); + }); + }); +} + +// ============================================================================ +// Simulate the CLI callback server (mirrors runBrowserOAuthFlow logic) +// ============================================================================ + +/** + * Start a callback server identical to the one in mesh-auth.ts, + * returning a promise that resolves with the API key. + */ +function startCallbackServer(meshUrl: string): Promise<{ + server: Server; + callbackUrl: string; + apiKeyPromise: Promise; +}> { + return new Promise((resolveSetup) => { + let resolveKey: (key: string) => void; + let rejectKey: (err: Error) => void; + const apiKeyPromise = new Promise((res, rej) => { + resolveKey = res; + rejectKey = rej; + }); + + const server = createServer(async (req, res) => { + try { + const reqUrl = new URL(req.url ?? "/", "http://localhost"); + + if (reqUrl.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const cookie = req.headers.cookie ?? ""; + const tokenParam = reqUrl.searchParams.get("token"); + + let apiKey: string; + + if (tokenParam) { + const keyRes = await fetch(`${meshUrl}/api/auth/api-key/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenParam}`, + Origin: meshUrl, + }, + body: JSON.stringify({ name: "deco-link-cli" }), + }); + if (!keyRes.ok) { + const text = await keyRes.text().catch(() => keyRes.statusText); + throw new Error( + `Failed to create API key: ${keyRes.status} ${text}`, + ); + } + const data = (await keyRes.json()) as { + key?: string; + apiKey?: { key?: string }; + }; + apiKey = data.key ?? data.apiKey?.key ?? ""; + if (!apiKey) throw new Error("No API key returned"); + } else if (cookie) { + // Cookie-based — same as createMeshApiKey() + const keyRes = await fetch(`${meshUrl}/api/auth/api-key/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + Origin: meshUrl, + }, + body: JSON.stringify({ name: "deco-link-cli" }), + }); + if (!keyRes.ok) { + const text = await keyRes.text().catch(() => keyRes.statusText); + throw new Error( + `Failed to create API key: ${keyRes.status} ${text}`, + ); + } + const data = (await keyRes.json()) as { + key?: string; + apiKey?: { key?: string }; + }; + apiKey = data.key ?? data.apiKey?.key ?? ""; + if (!apiKey) throw new Error("No API key returned"); + } else { + res.writeHead(400); + res.end("Authentication failed — no session token received."); + server.close(() => + rejectKey!(new Error("No session token received from Mesh login")), + ); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "

Authentication successful!

", + ); + server.close(() => resolveKey!(apiKey)); + } catch (err) { + res.writeHead(500); + res.end("Authentication error"); + server.close(() => + rejectKey!(err instanceof Error ? err : new Error(String(err))), + ); + } + }); + + server.listen(0, () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolveSetup({ + server, + callbackUrl: `http://localhost:${port}/callback`, + apiKeyPromise, + }); + }); + }); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe("deco link CLI auth callback flow", () => { + let mockMesh: { server: Server; url: string } | null = null; + let callbackSetup: { + server: Server; + callbackUrl: string; + apiKeyPromise: Promise; + } | null = null; + + afterEach(() => { + mockMesh?.server.close(); + mockMesh = null; + // callback server closes itself on success/error + callbackSetup = null; + }); + + it("should receive cookies from browser redirect and create an API key", async () => { + mockMesh = await startMockMeshApi({ + apiKey: "mesh_key_from_cookie_flow", + rejectPermissions: true, // Mimic real Better Auth behavior + }); + callbackSetup = await startCallbackServer(mockMesh.url); + + // Simulate browser redirect: GET /callback with session cookies + const resp = await fetch(callbackSetup.callbackUrl, { + headers: { + Cookie: "better-auth.session_token=sess_abc123", + }, + }); + + expect(resp.status).toBe(200); + const body = await resp.text(); + expect(body).toContain("Authentication successful"); + + const apiKey = await callbackSetup.apiKeyPromise; + expect(apiKey).toBe("mesh_key_from_cookie_flow"); + }); + + it("should handle token query param flow", async () => { + mockMesh = await startMockMeshApi({ + apiKey: "mesh_key_from_token_flow", + }); + callbackSetup = await startCallbackServer(mockMesh.url); + + // Simulate redirect with token param instead of cookies + const resp = await fetch(`${callbackSetup.callbackUrl}?token=bearer_xyz`); + + expect(resp.status).toBe(200); + const apiKey = await callbackSetup.apiKeyPromise; + expect(apiKey).toBe("mesh_key_from_token_flow"); + }); + + it("should fail when no cookies and no token are provided", async () => { + mockMesh = await startMockMeshApi(); + callbackSetup = await startCallbackServer(mockMesh.url); + + // Capture the rejection early so bun doesn't treat it as unhandled + const apiKeyPromise = callbackSetup.apiKeyPromise; + let rejectedError: Error | null = null; + apiKeyPromise.catch((err) => { + rejectedError = err; + }); + + // Simulate redirect with no auth + const resp = await fetch(callbackSetup.callbackUrl); + + expect(resp.status).toBe(400); + const body = await resp.text(); + expect(body).toContain("no session token"); + + // Wait for the rejection to propagate + try { + await apiKeyPromise; + } catch { + // expected + } + expect(rejectedError).toBeInstanceOf(Error); + expect((rejectedError as unknown as Error).message).toContain( + "No session token received", + ); + }); + + it("should fail if Mesh API rejects due to missing Origin", async () => { + // Start a mock that requires Origin header + const meshServer = createServer(async (req, res) => { + if (req.url === "/api/auth/api-key/create") { + if (!req.headers.origin) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + code: "MISSING_OR_NULL_ORIGIN", + message: "Missing or null Origin", + }), + ); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ key: "should_not_reach" })); + } else { + res.writeHead(404); + res.end(); + } + }); + + await new Promise((r) => meshServer.listen(0, r)); + const meshAddr = meshServer.address(); + const meshPort = + typeof meshAddr === "object" && meshAddr ? meshAddr.port : 0; + const meshUrl = `http://localhost:${meshPort}`; + + // Our callback server sends Origin, so this should succeed + callbackSetup = await startCallbackServer(meshUrl); + + const resp = await fetch(callbackSetup.callbackUrl, { + headers: { + Cookie: "better-auth.session_token=sess_abc", + }, + }); + + expect(resp.status).toBe(200); + const apiKey = await callbackSetup.apiKeyPromise; + expect(apiKey).toBe("should_not_reach"); // succeeds because Origin is sent + + meshServer.close(); + }); + + it("should not send permissions field (Better Auth rejects it from client)", async () => { + // This mock rejects requests that include permissions + mockMesh = await startMockMeshApi({ + apiKey: "mesh_key_no_perms", + rejectPermissions: true, + }); + callbackSetup = await startCallbackServer(mockMesh.url); + + const resp = await fetch(callbackSetup.callbackUrl, { + headers: { + Cookie: "better-auth.session_token=sess_123", + }, + }); + + // Should succeed because we DON'T send permissions + expect(resp.status).toBe(200); + const apiKey = await callbackSetup.apiKeyPromise; + expect(apiKey).toBe("mesh_key_no_perms"); + }); +}); + +describe("login route redirectTo validation", () => { + // Tests for isLocalhostUrl logic (same as in login.tsx) + function isLocalhostUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") + ); + } catch { + return false; + } + } + + it("should allow http://localhost:PORT/callback", () => { + expect(isLocalhostUrl("http://localhost:59882/callback")).toBe(true); + expect(isLocalhostUrl("http://localhost:3000/callback")).toBe(true); + expect(isLocalhostUrl("http://127.0.0.1:8080/callback")).toBe(true); + }); + + it("should reject non-localhost URLs (open redirect prevention)", () => { + expect(isLocalhostUrl("https://evil.com/callback")).toBe(false); + expect(isLocalhostUrl("http://attacker.com:3000/callback")).toBe(false); + expect(isLocalhostUrl("https://localhost:3000/callback")).toBe(false); // https, not http + }); + + it("should reject invalid URLs", () => { + expect(isLocalhostUrl("not-a-url")).toBe(false); + expect(isLocalhostUrl("")).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/mesh-auth.ts b/packages/cli/src/lib/mesh-auth.ts new file mode 100644 index 0000000000..bab53e3c51 --- /dev/null +++ b/packages/cli/src/lib/mesh-auth.ts @@ -0,0 +1,335 @@ +/** + * Mesh authentication module. + * + * Provides browser OAuth flow for authenticating against a Mesh instance (Better Auth), + * API key creation, and persistent token storage in the system keychain. + * + * Storage strategy: + * - Primary: system keychain (security on macOS, secret-tool on Linux, file fallback on Windows) + * - Fallback: ~/.deco_mesh_tokens.json with chmod 600 + * + * Auth flow: browser OAuth only (no email+password fallback). + */ + +import { execFileSync } from "node:child_process"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { promises as fsPromises } from "node:fs"; +import { spawn } from "node:child_process"; +import process from "node:process"; + +/** Path to the file-based fallback token store */ +function getTokenFilePath(): string { + return join(homedir(), ".deco_mesh_tokens.json"); +} + +/** Read all tokens from the fallback file (returns empty object on any error) */ +async function readTokenFile(): Promise> { + try { + const content = await fsPromises.readFile(getTokenFilePath(), "utf-8"); + const parsed = JSON.parse(content); + if (typeof parsed === "object" && parsed !== null) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +} + +/** Write tokens to the fallback file with chmod 600 */ +async function writeTokenFile(tokens: Record): Promise { + const filePath = getTokenFilePath(); + await fsPromises.writeFile(filePath, JSON.stringify(tokens, null, 2)); + if (process.platform !== "win32") { + try { + await fsPromises.chmod(filePath, 0o600); + } catch { + // Silently ignore chmod errors + } + } +} + +/** + * Read the stored API key for the given Mesh URL from the system keychain. + * Returns null if not found or if the keychain CLI is unavailable. + */ +export async function readMeshToken(meshUrl: string): Promise { + // Try system keychain first + try { + if (process.platform === "darwin") { + const token = execFileSync( + "security", + ["find-generic-password", "-a", "deco-mesh", "-s", meshUrl, "-w"], + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + if (token) return token; + } else if (process.platform === "linux") { + const token = execFileSync( + "secret-tool", + ["lookup", "service", "deco-mesh", "url", meshUrl], + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + if (token) return token; + } + // Windows: fall through to file-based storage (cmdkey read is limited) + } catch { + // Keychain CLI not available or entry not found — try file fallback + } + + // File-based fallback + try { + const tokens = await readTokenFile(); + return tokens[meshUrl] ?? null; + } catch { + return null; + } +} + +/** + * Save an API key for the given Mesh URL to the system keychain. + * Falls back to ~/.deco_mesh_tokens.json if the keychain CLI is unavailable. + */ +export async function saveMeshToken( + meshUrl: string, + apiKey: string, +): Promise { + let savedToKeychain = false; + + // Try system keychain first + try { + if (process.platform === "darwin") { + execFileSync( + "security", + [ + "add-generic-password", + "-a", + "deco-mesh", + "-s", + meshUrl, + "-w", + apiKey, + "-U", + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + savedToKeychain = true; + } else if (process.platform === "linux") { + // secret-tool reads the secret from stdin + execFileSync( + "secret-tool", + [ + "store", + "--label", + "deco-mesh", + "service", + "deco-mesh", + "url", + meshUrl, + ], + { input: apiKey, stdio: ["pipe", "pipe", "pipe"] }, + ); + savedToKeychain = true; + } else if (process.platform === "win32") { + // cmdkey is limited for reading; skip keychain and go straight to file on Windows + } + } catch { + // Keychain CLI not available + } + + if (!savedToKeychain) { + // File-based fallback + console.warn( + "Warning: Could not save to system keychain. Storing token in ~/.deco_mesh_tokens.json with restricted permissions.", + ); + const tokens = await readTokenFile(); + tokens[meshUrl] = apiKey; + await writeTokenFile(tokens); + } +} + +/** Open the browser to a URL using the platform-appropriate command */ +function openBrowser(url: string): void { + const browserCommands: Record = { + linux: "xdg-open", + darwin: "open", + win32: "start", + freebsd: "xdg-open", + openbsd: "xdg-open", + sunos: "xdg-open", + aix: "open", + }; + + const browser = + process.env.BROWSER ?? browserCommands[process.platform] ?? "open"; + + const command = + process.platform === "win32" && browser === "start" + ? spawn("cmd", ["/c", "start", url], { detached: true }) + : spawn(browser, [url], { detached: true }); + + command.unref(); + command.on("error", () => { + // Ignore browser open errors — the URL will be printed as fallback + }); +} + +/** + * Create a persistent API key via the server-side CLI auth endpoint. + * + * Uses POST /api/cli/auth which creates the API key server-side with + * the user's organization embedded in metadata. This avoids Better Auth's + * client-side restrictions on metadata and permissions fields. + * + * Requires a valid session cookie (received from the OAuth callback). + */ +async function createMeshApiKey( + meshUrl: string, + sessionCookie: string, +): Promise { + const res = await fetch(`${meshUrl}/api/cli/auth`, { + method: "POST", + headers: { + Cookie: sessionCookie, + Origin: meshUrl, + }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`Failed to create API key: ${res.status} ${text}`); + } + + const data = (await res.json()) as { key?: string }; + if (!data.key) { + throw new Error("Unexpected response from /api/cli/auth — no key field"); + } + return data.key; +} + +/** + * Authenticate against the Mesh instance using a browser OAuth flow. + * + * Flow: + * 1. Start a local HTTP callback server on a random port. + * 2. Open browser to ${meshUrl}/login?cli&redirectTo=http://localhost:PORT/callback + * 3. On callback, extract session cookies from the request. + * 4. Use the session cookie to call POST /api/auth/api-key/create. + * 5. Save the API key via saveMeshToken(). + * 6. Return the API key. + * + * Rejects after 120 seconds if the user doesn't complete login. + */ +async function runBrowserOAuthFlow(meshUrl: string): Promise { + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + server.close(); + reject( + new Error( + "Authentication timed out after 120 seconds. Please try again.", + ), + ); + }, 120_000); + + const server = createServer(async (req, res) => { + try { + const reqUrl = new URL(req.url ?? "/", "http://localhost"); + + if (reqUrl.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + // Extract session cookies forwarded from the Mesh login redirect + const cookie = req.headers.cookie ?? ""; + + // Also check if a token was passed as a query param (alternative strategy) + const tokenParam = reqUrl.searchParams.get("token"); + + let apiKey: string; + + if (tokenParam) { + // Token passed directly — not currently supported by /api/cli/auth + // Fall through to cookie-based flow + apiKey = await createMeshApiKey(meshUrl, ""); + } else if (cookie) { + // Cookie-based session (standard OAuth redirect) + apiKey = await createMeshApiKey(meshUrl, cookie); + } else { + res.writeHead(400); + res.end("Authentication failed — no session token received."); + reject(new Error("No session token received from Mesh login")); + return; + } + + // Success response + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "

Authentication successful!

You can close this tab and return to the terminal.

", + ); + + clearTimeout(timeoutHandle); + server.close(() => { + saveMeshToken(meshUrl, apiKey) + .then(() => resolve(apiKey)) + .catch((err) => { + console.warn( + "Warning: Could not persist Mesh token:", + err instanceof Error ? err.message : String(err), + ); + resolve(apiKey); + }); + }); + } catch (err) { + res.writeHead(500); + res.end("Authentication error — check terminal for details."); + clearTimeout(timeoutHandle); + server.close(() => + reject(err instanceof Error ? err : new Error(String(err))), + ); + } + }); + + server.on("error", (err) => { + clearTimeout(timeoutHandle); + reject(err); + }); + + // Listen on a random port + server.listen(0, () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 3458; + const callbackUrl = `http://localhost:${port}/callback`; + const loginUrl = `${meshUrl}/login?cli&redirectTo=${encodeURIComponent(callbackUrl)}`; + + console.log("Opening browser for Mesh authentication..."); + console.log( + "If the browser does not open automatically, visit:", + loginUrl, + ); + + openBrowser(loginUrl); + }); + }); +} + +/** + * Ensure the CLI has a valid Mesh API key for the given Mesh URL. + * + * - If a stored token exists, returns it immediately. + * - If not, starts a browser OAuth flow (120-second timeout). + * + * Returns the API key string. + */ +export async function ensureMeshAuth(meshUrl: string): Promise { + const existing = await readMeshToken(meshUrl); + if (existing) { + return existing; + } + + // No token found — start browser OAuth flow + const apiKey = await runBrowserOAuthFlow(meshUrl); + return apiKey; +} diff --git a/packages/cli/src/lib/mesh-client.ts b/packages/cli/src/lib/mesh-client.ts new file mode 100644 index 0000000000..a5d422ebd2 --- /dev/null +++ b/packages/cli/src/lib/mesh-client.ts @@ -0,0 +1,193 @@ +/** + * MCP client factory for the Mesh /mcp/self endpoint. + * + * Provides: + * - createMeshSelfClient: connect to Mesh's self-management MCP endpoint + * - callMeshTool: call a tool and parse the text response + * - getOrganizationId: retrieve the active organization ID from the Mesh session + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +/** + * Create an MCP client connected to the Mesh /mcp/self endpoint. + * + * Authentication is done via Bearer token in the Authorization header. + */ +export async function createMeshSelfClient( + meshUrl: string, + apiKey: string, +): Promise { + const client = new Client({ name: "deco-link", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL("/mcp/self", meshUrl), + { requestInit: { headers: { Authorization: `Bearer ${apiKey}` } } }, + ); + await client.connect(transport); + return client; +} + +/** + * Call a tool on a Mesh MCP client and return the parsed result. + * + * Extracts the text content from the MCP response, JSON.parses it, and + * returns the parsed value. Throws if the response is an error. + */ +export async function callMeshTool( + client: Client, + toolName: string, + args: Record, +): Promise { + const response = await client.callTool({ name: toolName, arguments: args }); + + if (response.isError) { + const errText = + Array.isArray(response.content) && response.content.length > 0 + ? ((response.content[0] as { text?: string }).text ?? + String(response.content[0])) + : "Unknown error"; + throw new Error(`Tool ${toolName} returned an error: ${errText}`); + } + + if (!Array.isArray(response.content) || response.content.length === 0) { + throw new Error(`Tool ${toolName} returned empty content`); + } + + const first = response.content[0] as { type?: string; text?: string }; + if (first.type !== "text" || !first.text) { + throw new Error( + `Tool ${toolName} returned non-text content: ${first.type}`, + ); + } + + try { + return JSON.parse(first.text); + } catch { + // Some tools return plain strings; return as-is + return first.text; + } +} + +/** Shape of the Better Auth session response */ +interface BetterAuthSession { + session?: { + activeOrganizationId?: string | null; + organizationId?: string | null; + }; + user?: { + id?: string; + }; +} + +/** Shape of an organization membership list */ +interface OrganizationMembership { + organizationId?: string; + organization?: { id?: string }; +} + +/** + * Get the active organization ID for the authenticated user. + * + * Calls GET /api/auth/session first. If activeOrganizationId is present, use it. + * Otherwise, call GET /api/auth/organization/list to find the first organization. + * Throws with a clear message if no organization is found. + */ +/** Organization info returned by getOrganization */ +export interface OrgInfo { + id: string; + slug: string; +} + +/** + * Get the active organization for the authenticated user. + * + * Returns both id and slug, needed for constructing Mesh UI URLs. + */ +export async function getOrganization( + meshUrl: string, + apiKey: string, +): Promise { + // Fetch the full organization list (includes slug) + const orgsRes = await fetch(`${meshUrl}/api/auth/organization/list`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (orgsRes.ok) { + const orgs = (await orgsRes.json()) as Array<{ + id?: string; + slug?: string; + organization?: { id?: string; slug?: string }; + }>; + if (Array.isArray(orgs) && orgs.length > 0) { + const org = orgs[0]; + const id = org.id ?? org.organization?.id; + const slug = org.slug ?? org.organization?.slug; + if (id && slug) return { id, slug }; + } + } + + // Fallback to getOrganizationId (no slug available) + const id = await getOrganizationId(meshUrl, apiKey); + return { id, slug: id }; // Use id as slug fallback +} + +export async function getOrganizationId( + meshUrl: string, + apiKey: string, +): Promise { + // Fetch the current session + const sessionRes = await fetch(`${meshUrl}/api/auth/get-session`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!sessionRes.ok) { + throw new Error( + `Failed to fetch Mesh session: ${sessionRes.status} ${sessionRes.statusText}`, + ); + } + + const session = (await sessionRes.json()) as BetterAuthSession; + + const directOrgId = + session?.session?.activeOrganizationId ?? session?.session?.organizationId; + + if (directOrgId) { + return directOrgId; + } + + // No active org in session — try listing organizations + const orgsRes = await fetch(`${meshUrl}/api/auth/organization/list-members`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (orgsRes.ok) { + const memberships = (await orgsRes.json()) as OrganizationMembership[]; + if (Array.isArray(memberships) && memberships.length > 0) { + const orgId = + memberships[0].organizationId ?? memberships[0].organization?.id; + if (orgId) return orgId; + } + } + + // Try the standard Better Auth organizations endpoint + const orgs2Res = await fetch(`${meshUrl}/api/auth/organization/list`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (orgs2Res.ok) { + const orgs = (await orgs2Res.json()) as Array<{ + id?: string; + organization?: { id?: string }; + }>; + if (Array.isArray(orgs) && orgs.length > 0) { + const orgId = orgs[0].id ?? orgs[0].organization?.id; + if (orgId) return orgId; + } + } + + throw new Error( + "No organization found for this Mesh account. " + + "Please create an organization in Mesh before using 'deco link'.", + ); +} diff --git a/packages/local-dev/src/logger.ts b/packages/local-dev/src/logger.ts index 285910f2ec..e8e9bc5d9b 100644 --- a/packages/local-dev/src/logger.ts +++ b/packages/local-dev/src/logger.ts @@ -100,6 +100,27 @@ export function logOp( console.error(msg); } +/** + * Log server startup + */ +export function logStart(rootPath: string): void { + console.error( + `\n${prefix} ${colors.cyan}${colors.bold}mcp-local-dev${colors.reset} ${colors.dim}started${colors.reset}`, + ); + console.error( + `${prefix} ${colors.dim}root:${colors.reset} ${colors.white}${rootPath}${colors.reset}\n`, + ); +} + +/** + * Log an error (still uses red, but with the prefix) + */ +export function logError(op: string, path: string, error: Error): void { + console.error( + `${prefix} ${timestamp()} ${colors.yellow}${colors.bold}ERR${colors.reset} ${formatOp(op)} ${formatPath(path)} ${colors.dim}${error.message}${colors.reset}`, + ); +} + /** * Log a tool call */ diff --git a/packages/local-dev/src/server.ts b/packages/local-dev/src/server.ts index 6b64689648..b2806132b4 100644 --- a/packages/local-dev/src/server.ts +++ b/packages/local-dev/src/server.ts @@ -1,6 +1,7 @@ import { createServer } from "node:http"; import { createReadStream } from "node:fs"; -import { extname } from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, extname } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { LocalFileStorage } from "./storage.ts"; @@ -61,7 +62,10 @@ export function createLocalDevServer( // CORS res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + res.setHeader( + "Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS", + ); res.setHeader( "Access-Control-Allow-Headers", "Content-Type, mcp-session-id", @@ -95,6 +99,21 @@ export function createLocalDevServer( try { const absolutePath = storage.resolvePath(key); // resolvePath() already throws if path escapes root + + // PUT: upload file content + if (req.method === "PUT") { + await mkdir(dirname(absolutePath), { recursive: true }); + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + await writeFile(absolutePath, Buffer.concat(chunks)); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true, key })); + return; + } + + // GET: serve file content const mimeTypes: Record = { ".html": "text/html", ".css": "text/css", diff --git a/packages/local-dev/src/tools.ts b/packages/local-dev/src/tools.ts index 56c8221f9d..771e7cb067 100644 --- a/packages/local-dev/src/tools.ts +++ b/packages/local-dev/src/tools.ts @@ -1172,13 +1172,13 @@ export function registerTools( }), ); - // PUT_PRESIGNED_URL - Return upload instructions for local filesystem + // PUT_PRESIGNED_URL - Return URL for uploading a file via HTTP PUT server.registerTool( "PUT_PRESIGNED_URL", { title: "Put Presigned URL", description: - "Get a URL for uploading a file. For local filesystem, returns instructions to use write_file tool.", + "Get a URL for uploading a file. Returns an HTTP URL that accepts PUT requests.", inputSchema: { key: z.string().describe("Object key/path for the upload"), expiresIn: z @@ -1190,13 +1190,10 @@ export function registerTools( annotations: { readOnlyHint: true }, }, withLogging("PUT_PRESIGNED_URL", async (args): Promise => { - // For local filesystem we serve files via HTTP, but PUT is not supported - // Instruct callers to use write_file tool instead const encodedKey = encodeURIComponent(args.key); const result = { url: `http://localhost:${port}/files/${encodedKey}`, expiresIn: 3600, - _note: "Use write_file tool to upload content to this path", }; return { diff --git a/packages/mesh-plugin-declare/components/declare-layout.tsx b/packages/mesh-plugin-declare/components/declare-layout.tsx new file mode 100644 index 0000000000..16e90c7147 --- /dev/null +++ b/packages/mesh-plugin-declare/components/declare-layout.tsx @@ -0,0 +1,401 @@ +/** + * Declare Layout — LayoutComponent + * + * Self-contained layout for the declare plugin. + * Resolves the connection from plugin config, then: + * 1. Read .planning/server.port (written by declare-cc serve) + * 2. If file exists with a port → embed iframe at that port + * 3. If .planning/ exists but no server.port → show "start server" state + * 4. If no .planning/ at all → show init setup screen + * + * No heuristic port detection — each project's declare server writes + * its own .planning/server.port file so there's no cross-project confusion. + */ + +import { useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + SELF_MCP_ALIAS_ID, + useMCPClient, + useMCPClientOptional, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { Loading01 } from "@untitledui/icons"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Button } from "@deco/ui/components/button.tsx"; +import { KEYS } from "../lib/query-keys"; +import DeclareSetup from "./declare-setup"; + +type PluginConfigOutput = { + config: { + id: string; + projectId: string; + pluginId: string; + connectionId: string | null; + settings: Record | null; + } | null; +}; + +/** Extract text from an MCP tool result. */ +function extractText(result: { content?: unknown }): string { + const raw = result.content; + if (Array.isArray(raw)) { + const first = raw[0] as { text?: string } | undefined; + return first?.text ?? ""; + } + if (typeof raw === "string") return raw; + return ""; +} + +/** Run a bash command via MCP, returning { stdout, exitCode }. */ +async function runBash( + client: Client, + cmd: string, +): Promise<{ stdout: string; exitCode: number }> { + try { + const result = (await client.callTool({ + name: "bash", + arguments: { cmd }, + })) as { structuredContent?: unknown; content?: unknown }; + + const text = extractText(result as { content?: unknown }); + + try { + const parsed = JSON.parse(text) as { + stdout?: string; + exitCode?: number; + }; + return { + stdout: parsed.stdout ?? "", + exitCode: parsed.exitCode ?? 1, + }; + } catch { + return { stdout: text.trim(), exitCode: 0 }; + } + } catch { + return { stdout: "", exitCode: 1 }; + } +} + +/** Check if declare server is responding on the given port via /api/graph. */ +async function checkServerRunning( + client: Client, + port: number, +): Promise { + const { stdout, exitCode } = await runBash( + client, + `curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://localhost:${port}/api/graph`, + ); + if (exitCode !== 0) return false; + const code = parseInt(stdout.trim(), 10); + return code === 200; +} + +type DetectResult = + | { state: "has-port"; port: number } + | { state: "has-planning" } + | { state: "no-planning" }; + +/** + * Detect declare state by reading files — no heuristic port probing. + * .planning/server.port is the sole source of truth for the server port. + */ +async function detectDeclareState(client: Client): Promise { + // Try reading .planning/server.port first (most specific check) + const { stdout: portText, exitCode: portExit } = await runBash( + client, + "cat .planning/server.port 2>/dev/null", + ); + if (portExit === 0 && portText.trim()) { + const port = parseInt(portText.trim(), 10); + if (!Number.isNaN(port) && port > 0) { + return { state: "has-port", port }; + } + } + + // Check if .planning/ directory exists at all + const { exitCode: dirExit } = await runBash( + client, + "test -d .planning && echo yes", + ); + if (dirExit === 0) { + return { state: "has-planning" }; + } + + return { state: "no-planning" }; +} + +export default function DeclareLayout() { + const { org, project } = useProjectContext(); + const pluginId = "declare"; + + const selfClient = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + // Fetch the plugin's connection config + const { data: pluginConfig, isLoading: isLoadingConfig } = useQuery({ + queryKey: KEYS.pluginConfig(project.id ?? "", pluginId), + queryFn: async () => { + const result = (await selfClient.callTool({ + name: "PROJECT_PLUGIN_CONFIG_GET", + arguments: { projectId: project.id, pluginId }, + })) as { structuredContent?: unknown }; + return (result.structuredContent ?? result) as PluginConfigOutput; + }, + enabled: !!project.id, + }); + + const configuredConnectionId = pluginConfig?.config?.connectionId ?? null; + + const connectionClient = useMCPClientOptional({ + connectionId: configuredConnectionId ?? undefined, + orgId: org.id, + }); + + // Detect declare state from files + const { + data: declareState, + isLoading: isDetecting, + refetch: redetect, + } = useQuery({ + queryKey: KEYS.planningCheck(configuredConnectionId ?? ""), + queryFn: () => detectDeclareState(connectionClient!), + enabled: !!connectionClient && !!configuredConnectionId, + // Poll while waiting for server.port to appear (user may start server externally) + refetchInterval: (query) => { + const state = query.state.data; + // Poll every 3s when .planning/ exists but no server.port yet + if (state?.state === "has-planning") return 3_000; + return false; + }, + }); + + // Loading state + if (isLoadingConfig || isDetecting) { + return ( +
+ +

Loading...

+
+ ); + } + + // No connection configured + if (!configuredConnectionId || !connectionClient) { + return ( +
+
+

Declare Not Available

+

+ No local-dev connection is configured for this project. Add a + local-dev project first. +

+
+
+ ); + } + + // No .planning/ directory — show init setup + if (!declareState || declareState.state === "no-planning") { + return ( + redetect()} + /> + ); + } + + // .planning/ exists but no server.port — show "start server" prompt + if (declareState.state === "has-planning") { + return ( + redetect()} + /> + ); + } + + // server.port exists — show dashboard + return ( + + ); +} + +/** + * Shown when .planning/ exists but no server.port file. + * Offers to start the declare server. + */ +function DeclareStartServer({ + client, + onStarted, +}: { + client: Client; + onStarted: () => void; +}) { + const [isStarting, setIsStarting] = useState(false); + + const handleStart = async () => { + setIsStarting(true); + try { + await client.callTool({ + name: "bash", + arguments: { + cmd: "nohup npx dcl serve > .planning/serve.log 2>&1 &", + timeout: 0, + }, + }); + // Poll for server.port to be written + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 1_500)); + const { stdout, exitCode } = await runBash( + client, + "cat .planning/server.port 2>/dev/null", + ); + if (exitCode === 0 && stdout.trim()) { + onStarted(); + return; + } + } + onStarted(); // try anyway + } catch { + setIsStarting(false); + } + }; + + if (isStarting) { + return ( +
+ +

+ Starting declare server... +

+
+ ); + } + + return ( +
+
+
+

+ Declare Server Not Running +

+

+ The .planning/ directory exists but the declare server isn't + running. Start it to view the dashboard. +

+
+ +
+
+ ); +} + +/** + * Declare Dashboard — embeds the declare-cc dashboard in an iframe. + * Port comes from .planning/server.port (written by declare-cc serve). + */ +function DeclareDashboard({ + client, + connectionId, + port, +}: { + client: Client; + connectionId: string; + port: number; +}) { + const startedRef = useRef(false); + const iframeUrl = `http://localhost:${port}`; + + // Verify the server is actually responding, start if needed + const serverQuery = useQuery({ + queryKey: KEYS.serverCheck(connectionId, port), + queryFn: async (): Promise< + { status: "running" } | { status: "error"; message: string } + > => { + const isRunning = await checkServerRunning(client, port); + if (isRunning) return { status: "running" }; + + // server.port exists but server not responding — try starting it + if (!startedRef.current) { + startedRef.current = true; + await client.callTool({ + name: "bash", + arguments: { + cmd: "nohup npx dcl serve > .planning/serve.log 2>&1 &", + timeout: 0, + }, + }); + } + + // Poll until ready + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 2_000)); + const ready = await checkServerRunning(client, port); + if (ready) return { status: "running" }; + } + + return { + status: "error", + message: `Declare server didn't respond on port ${port} after 60s`, + }; + }, + staleTime: Infinity, + retry: false, + }); + + const result = serverQuery.data; + const isLoading = serverQuery.isLoading || serverQuery.isFetching; + const isError = result?.status === "error"; + const isRunning = result?.status === "running" && !isLoading; + + if (!isRunning && !isError) { + return ( +
+ +

+ {isLoading + ? "Checking declare server..." + : "Starting declare server..."} +

+
+ ); + } + + if (isError) { + return ( +
+

Failed to start declare server

+

+ {result.message} +

+ +
+ ); + } + + return ( +