diff --git a/package.json b/package.json index c89b5c7bad2..ab6cf08e130 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "firebase": "./lib/bin/firebase.js" }, "scripts": { - "build:mcp-apps": "vite build --config src/mcp/apps/update_environment/vite.config.ts && vite build --config src/mcp/apps/init/vite.config.ts", + "build:mcp-apps": "vite build --config src/mcp/apps/update_environment/vite.config.ts && vite build --config src/mcp/apps/init/vite.config.ts && vite build --config src/mcp/apps/deploy/vite.config.ts", "build": "npm run build:mcp-apps && tsc && npm run copyfiles", "build:publish": "tsc --build tsconfig.publish.json && npm run copyfiles", "build:watch": "npm run build && tsc --watch", diff --git a/src/deploy/firestore/deploy.ts b/src/deploy/firestore/deploy.ts index e44d5fff02b..ba90f72d9a8 100644 --- a/src/deploy/firestore/deploy.ts +++ b/src/deploy/firestore/deploy.ts @@ -30,6 +30,9 @@ async function deployIndexes(context: any, options: any): Promise { return; } const indexesContext: IndexContext[] = context?.firestore?.indexes; + if (!indexesContext || indexesContext.length === 0) { + return; + } utils.logBullet(clc.bold(clc.cyan("firestore: ")) + "deploying indexes..."); const firestoreIndexes = new FirestoreApi(); diff --git a/src/mcp/apps/deploy/mcp-app.html b/src/mcp/apps/deploy/mcp-app.html new file mode 100644 index 00000000000..b7341d1fde1 --- /dev/null +++ b/src/mcp/apps/deploy/mcp-app.html @@ -0,0 +1,258 @@ + + + + + + Firebase Deploy + + + +
+
+

Deploy Firebase

+

Select services and trigger deployment.

+
+ +
+

Services to Deploy

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ +
+

Deployment Progress

+
+
+
+
+ +
+
+
+ + + diff --git a/src/mcp/apps/deploy/mcp-app.ts b/src/mcp/apps/deploy/mcp-app.ts new file mode 100644 index 00000000000..bce1b317996 --- /dev/null +++ b/src/mcp/apps/deploy/mcp-app.ts @@ -0,0 +1,136 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import { Job } from "../../util/jobs"; + +const app = new App({ name: "firebase-deploy", version: "1.0.0" }); + +const deployBtn = document.getElementById("deploy-btn") as HTMLButtonElement; +const progressBar = document.getElementById("progress-bar") as HTMLDivElement; +const progressContainer = document.getElementById("progress-container") as HTMLDivElement; +const statusList = document.getElementById("status-list") as HTMLDivElement; + +function addLog(message: string, type: "info" | "success" | "error" = "info"): void { + const item = document.createElement("div"); + item.className = `status-item ${type}`; + item.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; + statusList.appendChild(item); + statusList.scrollTop = statusList.scrollHeight; // Auto-scroll +} + +function updateProgress(percentage: number): void { + progressBar.style.width = `${percentage}%`; +} + +function pollStatus(jobId: string): void { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + const interval = setInterval(async () => { + try { + const statusRes = await app.callServerTool({ + name: "firebase_deploy_status", + arguments: { jobId }, + }); + + if (statusRes.isError) { + addLog(`Failed to poll status: ${JSON.stringify(statusRes.content)}`, "error"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + return; + } + + const job = statusRes.structuredContent as unknown as Job; + if (job) { + updateProgress(job.progress); + + // Clear and redraw logs to avoid duplication if we are reading full history + statusList.innerHTML = ""; + job.logs.forEach((log: string) => addLog(log)); + + if (job.status === "success") { + addLog("Deployment completed successfully!", "success"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } else if (job.status === "failed") { + addLog(`Deployment failed: ${job.error || "Unknown error"}`, "error"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + addLog(`Error during polling: ${message}`, "error"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } + }, 2000); +} + +// eslint-disable-next-line @typescript-eslint/no-misused-promises +deployBtn.addEventListener("click", async () => { + // 1. Get checked targets + const targets: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const checkboxes = document.querySelectorAll( + '.checkbox-grid input[type="checkbox"]:checked', + ) as NodeListOf; + checkboxes.forEach((cb) => targets.push(cb.value)); + + if (targets.length === 0) { + addLog("Please select at least one service to deploy.", "error"); + return; + } + + // 2. Disable UI + deployBtn.disabled = true; + deployBtn.textContent = "Deploying..."; + progressContainer.style.display = "block"; + statusList.innerHTML = ""; // Clear old logs + updateProgress(10); + addLog(`Starting deployment for: ${targets.join(", ")}`); + + // 3. Call tool + try { + const onlyArg = targets.join(","); + addLog(`Calling firebase_deploy with only="${onlyArg}"...`); + + const result = await app.callServerTool({ + name: "firebase_deploy", + arguments: { only: onlyArg }, + }); + + if (result.isError) { + addLog(`Deployment failed to start: ${JSON.stringify(result.content)}`, "error"); + updateProgress(0); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } else { + const jobId = (result.structuredContent as unknown as { jobId: string })?.jobId; + if (jobId) { + addLog(`Deployment started with Job ID: ${jobId}. Polling status...`); + pollStatus(jobId); + } else { + addLog("Failed to get Job ID from server.", "error"); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + addLog(`Error calling deploy tool: ${message}`, "error"); + updateProgress(0); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } +}); + +void (async () => { + try { + await app.connect(); + addLog("Connected to host.", "info"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error("Failed to connect app:", message); + } +})(); diff --git a/src/mcp/apps/deploy/vite.config.ts b/src/mcp/apps/deploy/vite.config.ts new file mode 100644 index 00000000000..36b263efe23 --- /dev/null +++ b/src/mcp/apps/deploy/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import * as path from "path"; + +export default defineConfig({ + plugins: [viteSingleFile()], + root: __dirname, + build: { + outDir: path.resolve(__dirname, "../../../../lib/mcp/apps/deploy"), + emptyOutDir: true, + rollupOptions: { + input: path.resolve(__dirname, "mcp-app.html"), + }, + }, +}); diff --git a/src/mcp/resources/deploy_ui.ts b/src/mcp/resources/deploy_ui.ts new file mode 100644 index 00000000000..fa08e732b59 --- /dev/null +++ b/src/mcp/resources/deploy_ui.ts @@ -0,0 +1,32 @@ +import { resource } from "../resource"; +import * as path from "path"; +import * as fs from "fs/promises"; + +export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; +const resourceUri = "ui://core/deploy/mcp-app.html"; + +export const deploy_ui = resource( + { + uri: resourceUri, + name: "Deploy UI", + description: "Visual interface for Firebase Deploy", + mimeType: RESOURCE_MIME_TYPE, + }, + async () => { + try { + const htmlPath = path.join(__dirname, "../apps/deploy/mcp-app.html"); + const html = await fs.readFile(htmlPath, "utf-8"); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }, + ], + }; + } catch (e: any) { + throw new Error(`Failed to load Deploy UI: ${e.message}`); + } + }, +);