UNPKG

firebase-tools

Version:
475 lines (474 loc) 19.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.extractMetadata = extractMetadata; exports.uploadSecrets = uploadSecrets; exports.migrate = migrate; const fs = require("fs/promises"); const path = require("path"); const child_process_1 = require("child_process"); const logger_1 = require("../logger"); const prompt = require("../prompt"); const apphosting = require("../gcp/apphosting"); const utils = require("../utils"); const templates_1 = require("../templates"); const track = require("../track"); const secrets_1 = require("../apphosting/secrets"); const env = require("../functions/env"); const error_1 = require("../error"); const os = require("os"); async function setupAntigravityMcpServer(rootPath) { const mcpConfigDir = path.join(os.homedir(), ".gemini", "antigravity"); const mcpConfigPath = path.join(mcpConfigDir, "mcp_config.json"); let mcpConfig = { mcpServers: {} }; try { await fs.mkdir(mcpConfigDir, { recursive: true }); const content = await fs .readFile(mcpConfigPath, "utf-8") .catch((err) => { if (err.code === "ENOENT") { return null; } throw err; }); if (content) { mcpConfig = JSON.parse(content); if (!mcpConfig.mcpServers) { mcpConfig.mcpServers = {}; } } if (mcpConfig.mcpServers["firebase"]) { logger_1.logger.info("ℹ️ Firebase MCP server already configured in Antigravity, skipping."); return; } mcpConfig.mcpServers["firebase"] = { command: "npx", args: ["-y", "firebase-tools@latest", "mcp", "--dir", path.resolve(rootPath)], }; await fs.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); logger_1.logger.info(`✅ Configured Firebase MCP server in ${mcpConfigPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); utils.logWarning(`Could not configure Antigravity MCP server: ${message}`); } } async function detectAppType(rootPath) { try { await fs.access(path.join(rootPath, "pubspec.yaml")); return "FLUTTER"; } catch { } try { await fs.access(path.join(rootPath, "angular.json")); return "ANGULAR"; } catch { } try { const packageJsonPath = path.join(rootPath, "package.json"); const packageJsonContent = await fs.readFile(packageJsonPath, "utf8"); const packageJson = JSON.parse(packageJsonContent); const deps = { ...packageJson.dependencies, ...packageJson.devDependencies }; if (deps.next) { return "NEXT_JS"; } if (deps["@angular/core"]) { return "ANGULAR"; } } catch { } for (const configFile of ["next.config.js", "next.config.mjs"]) { try { await fs.access(path.join(rootPath, configFile)); return "NEXT_JS"; } catch { } } return "OTHER"; } async function downloadGitHubDir(apiUrl, localPath) { const response = await fetch(apiUrl); if (!response.ok) { throw new Error(`Failed to fetch directory listing: ${apiUrl}`); } const items = (await response.json()); await fs.mkdir(localPath, { recursive: true }); for (const item of items) { const itemLocalPath = path.join(localPath, item.name); if (item.type === "dir") { await downloadGitHubDir(item.url, itemLocalPath); } else if (item.type === "file") { const fileResponse = await fetch(item.download_url); if (fileResponse.ok) { const content = await fileResponse.arrayBuffer(); await fs.writeFile(itemLocalPath, Buffer.from(content)); } } } } const isValidFirebaseProjectId = (projectId) => { const projectIdRegex = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/; return projectIdRegex.test(projectId); }; async function extractMetadata(rootPath, overrideProjectId) { const metadataPath = path.join(rootPath, "metadata.json"); let metadata = {}; try { const metadataContent = await fs.readFile(metadataPath, "utf8"); metadata = JSON.parse(metadataContent); } catch (err) { logger_1.logger.debug(`Could not read metadata.json at ${metadataPath}: ${err}`); } logger_1.logger.debug(`overrideProjectId ${overrideProjectId}`); logger_1.logger.debug(`metadata.projectId ${metadata.projectId}`); let projectId = overrideProjectId || metadata.projectId; if (!projectId) { try { const firebasercContent = await fs.readFile(path.join(rootPath, ".firebaserc"), "utf8"); const firebaserc = JSON.parse(firebasercContent); projectId = firebaserc.projects?.default; } catch (err) { logger_1.logger.debug(`Could not read .firebaserc at ${rootPath}: ${err}`); } } if (projectId) { if (!isValidFirebaseProjectId(projectId)) { throw new error_1.FirebaseError(`Invalid project ID: ${projectId}.`, { exit: 1, }); } logger_1.logger.info(`✅ Using Firebase Project: ${projectId}`); } else { logger_1.logger.info(`❌ Failed to determine the Firebase Project ID. You can set a project later with 'firebase use <project-id>' or by setting the '--project' flag.`); } let appName = "firebase-studio-export"; let blueprintContent = ""; const blueprintPath = path.join(rootPath, "docs", "blueprint.md"); try { blueprintContent = await fs.readFile(blueprintPath, "utf8"); const nameMatch = blueprintContent.match(/# \*\*App Name\*\*: (.*)/); if (nameMatch && nameMatch[1]) { appName = nameMatch[1].trim(); } } catch (err) { logger_1.logger.debug(`Could not read blueprint.md at ${blueprintPath}: ${err}`); } if (appName !== "firebase-studio-export") { logger_1.logger.info(`✅ Detected App Name: ${appName}`); } return { projectId, appName, blueprintContent }; } async function updateReadme(rootPath, blueprintContent, appName) { const readmePath = path.join(rootPath, "README.md"); const readmeTemplate = await (0, templates_1.readTemplate)("firebase-studio-export/readme_template.md"); const newReadme = readmeTemplate .replace(/\${appName}/g, appName) .replace("${exportDate}", new Date().toISOString().split("T")[0]) .replace("${blueprintContent}", blueprintContent.replace(/# \*\*App Name\*\*: .*/, "").trim()); await fs.writeFile(readmePath, newReadme); logger_1.logger.info("✅ Updated README.md with project details and origin info"); } async function injectAntigravityContext(rootPath, projectId, appName) { const agentDir = path.join(rootPath, ".agents"); const rulesDir = path.join(agentDir, "rules"); const workflowsDir = path.join(agentDir, "workflows"); const skillsDir = path.join(agentDir, "skills"); await fs.mkdir(rulesDir, { recursive: true }); await fs.mkdir(workflowsDir, { recursive: true }); await fs.mkdir(skillsDir, { recursive: true }); logger_1.logger.info("⏳ Fetching Antigravity skills from firebase/agent-skills..."); try { const skillsResponse = await fetch("https://api.github.com/repos/firebase/agent-skills/contents/skills"); if (!skillsResponse.ok) { throw new Error(`GitHub API returned ${skillsResponse.status}`); } const skillsData = (await skillsResponse.json()); if (Array.isArray(skillsData)) { for (const item of skillsData) { if (item.type === "dir") { const skillName = item.name; const skillDir = path.join(skillsDir, skillName); await downloadGitHubDir(item.url, skillDir); } } } else { utils.logWarning("GitHub API response for skills is not an array."); } logger_1.logger.info(`✅ Downloaded Firebase skills`); } catch (err) { utils.logWarning(`Could not download Antigravity skills, skipping. ${err}`); } const systemInstructionsTemplate = await (0, templates_1.readTemplate)("firebase-studio-export/system_instructions_template.md"); const systemInstructions = systemInstructionsTemplate .replace("${projectId}", projectId || "None") .replace("${appName}", appName); await fs.writeFile(path.join(rulesDir, "migration-context.md"), systemInstructions); logger_1.logger.info("✅ Injected Antigravity rules"); try { const startupWorkflow = await (0, templates_1.readTemplate)("firebase-studio-export/workflows/startup_workflow.md"); await fs.writeFile(path.join(workflowsDir, "startup.md"), startupWorkflow); logger_1.logger.info("✅ Created Antigravity startup workflow"); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger_1.logger.debug(`Could not read or write startup workflow: ${message}`); } } async function getAgyCommand(startAgy) { if (!startAgy) { return undefined; } const commands = ["agy", "antigravity"]; for (const cmd of commands) { if (utils.commandExistsSync(cmd)) { logger_1.logger.info(`✅ Antigravity IDE detected`); return cmd; } } if (process.platform === "darwin") { const macPath = "/Applications/Antigravity.app/Contents/Resources/app/bin/agy"; try { await fs.access(macPath); logger_1.logger.info(`✅ Antigravity IDE detected at ${macPath}`); return macPath; } catch { } } if (process.platform === "win32") { const winPath = path.join(process.env.LOCALAPPDATA || "", "Programs", "Antigravity", "bin", "agy.exe"); try { await fs.access(winPath); logger_1.logger.info(`✅ Antigravity IDE CLI detected at ${winPath}`); return winPath; } catch { } } const downloadLink = "https://antigravity.google/download"; logger_1.logger.info(`⚠️ Antigravity IDE not found in your PATH. To ensure a seamless migration, please download and install Antigravity: ${downloadLink}`); return undefined; } async function createFirebaseConfigs(rootPath, projectId) { if (!projectId) { return; } const firebaserc = { projects: { default: projectId, }, }; await fs.writeFile(path.join(rootPath, ".firebaserc"), JSON.stringify(firebaserc, null, 2)); logger_1.logger.info("✅ Created .firebaserc"); const firebaseJsonPath = path.join(rootPath, "firebase.json"); try { await fs.access(firebaseJsonPath); logger_1.logger.info("ℹ️ firebase.json already exists, skipping creation."); } catch { let backendId = "studio"; try { logger_1.logger.info(`⏳ Fetching App Hosting backends for project ${projectId}...`); const backendsData = await apphosting.listBackends(projectId, "-"); const backends = backendsData.backends || []; if (backends.length > 0) { const studioBackend = backends.find((b) => b.name.endsWith("/studio") || b.name.toLowerCase().includes("studio")); if (studioBackend) { backendId = studioBackend.name.split("/").pop(); } else { backendId = backends[0].name.split("/").pop(); } logger_1.logger.info(`✅ Selected App Hosting backend: ${backendId}`); } else { utils.logWarning('No App Hosting backends found, using default "studio"'); } } catch (err) { const message = err instanceof Error ? err.message : String(err); utils.logWarning(`Could not fetch backends from Firebase CLI, using default "studio". ${message}`); } const firebaseJson = { apphosting: { backendId: backendId, ignore: [ "node_modules", ".git", ".agents", ".idx", "firebase-debug.log", "firebase-debug.*.log", "functions", ], }, }; await fs.writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 2)); logger_1.logger.info(`✅ Created firebase.json with backendId: ${backendId}`); } } async function writeAntigravityConfigs(rootPath) { const vscodeDir = path.join(rootPath, ".vscode"); await fs.mkdir(vscodeDir, { recursive: true }); const tasksJson = { version: "2.0.0", tasks: [ { label: "npm-install", type: "shell", command: "npm install", problemMatcher: [], }, ], }; await fs.writeFile(path.join(vscodeDir, "tasks.json"), JSON.stringify(tasksJson, null, 2)); logger_1.logger.info("✅ Created .vscode/tasks.json"); const settingsPath = path.join(vscodeDir, "settings.json"); let settings = {}; try { const settingsContent = await fs.readFile(settingsPath, "utf8"); settings = JSON.parse(settingsContent); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger_1.logger.debug(`Could not read ${settingsPath}: ${message}`); } const cleanSettings = {}; for (const [key, value] of Object.entries(settings)) { if (!key.startsWith("IDX.")) { cleanSettings[key] = value; } } cleanSettings["workbench.startupEditor"] = "readme"; await fs.writeFile(settingsPath, JSON.stringify(cleanSettings, null, 2)); logger_1.logger.info("✅ Updated .vscode/settings.json with startup preferences"); const launchJson = { version: "0.2.0", configurations: [ { type: "node", request: "launch", name: "Next.js: debug server-side", runtimeExecutable: "npm", runtimeArgs: ["run", "dev"], port: 9002, console: "integratedTerminal", preLaunchTask: "npm-install", }, ], }; await fs.writeFile(path.join(vscodeDir, "launch.json"), JSON.stringify(launchJson, null, 2)); logger_1.logger.info("✅ Created .vscode/launch.json"); } async function cleanupUnusedFiles(rootPath) { const docsDir = path.join(rootPath, "docs"); try { const files = await fs.readdir(docsDir); if (files.length === 0) { await fs.rmdir(docsDir); logger_1.logger.info("✅ Removed empty docs directory"); } } catch (err) { const message = err instanceof Error ? err.message : String(err); logger_1.logger.debug(`Could not remove ${docsDir}: ${message}`); } const modifiedPath = path.join(rootPath, ".modified"); try { await fs.unlink(modifiedPath); logger_1.logger.info("✅ Cleaned up .modified"); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger_1.logger.debug(`Could not delete ${modifiedPath}: ${message}`); } } async function uploadSecrets(rootPath, projectId) { if (!projectId) { return; } const envPath = path.join(rootPath, ".env"); try { await fs.access(envPath); } catch { return; } try { const envContent = await fs.readFile(envPath, "utf8"); const parsedEnv = env.parse(envContent); const geminiApiKey = parsedEnv.envs["GEMINI_API_KEY"]; if (geminiApiKey && geminiApiKey.trim().length > 0) { logger_1.logger.info("⏳ Uploading GEMINI_API_KEY from .env to App Hosting secrets..."); await (0, secrets_1.apphostingSecretsSetAction)("GEMINI_API_KEY", projectId, undefined, undefined, envPath, true); logger_1.logger.info("✅ Uploaded GEMINI_API_KEY secret"); } else { logger_1.logger.debug("Skipping GEMINI_API_KEY upload: key is missing or blank in .env"); } } catch (err) { const message = err instanceof Error ? err.message : String(err); utils.logWarning(`Failed to upload GEMINI_API_KEY secret: ${message}`); } } async function askToOpenAntigravity(rootPath, appName, startAntigravity) { const agyCommand = await getAgyCommand(startAntigravity); logger_1.logger.info(`\n🎉 Your Firebase Studio project "${appName}" is now ready for Antigravity!`); logger_1.logger.info("Antigravity is Google's agentic IDE, where you can collaborate with AI agents to build, test, and deploy your application."); logger_1.logger.info("\nWhat to do next inside Antigravity:"); logger_1.logger.info(" 1. Review the README.md: It has been updated with specifics about this migrated project."); logger_1.logger.info(" 2. Open the Agent Chat: Use the side panel or press Cmd+L (Ctrl+L on Windows/Linux). This is your main interface with the AI."); logger_1.logger.info("\nFile any bugs at https://github.com/firebase/firebase-tools/issues"); if (!startAntigravity || !agyCommand) { return; } const answer = await prompt.confirm({ message: "Would you like to open it in Antigravity now?", default: true, }); if (answer) { logger_1.logger.info(`⏳ Opening ${appName} in Antigravity...`); try { const antigravityProcess = (0, child_process_1.spawn)(agyCommand, ["."], { cwd: rootPath, stdio: "ignore", detached: true, shell: process.platform === "win32", }); antigravityProcess.unref(); } catch (err) { utils.logWarning("Could not open Antigravity IDE automatically. Please open it manually."); } } } async function migrate(rootPath, options = { startAntigravity: true }) { const appType = await detectAppType(rootPath); void track.trackGA4("firebase_studio_migrate", { app_type: appType, result: "started" }); logger_1.logger.info("🚀 Starting Firebase Studio to Antigravity migration..."); const { projectId, appName, blueprintContent } = await extractMetadata(rootPath, options.project); await updateReadme(rootPath, blueprintContent, appName); await createFirebaseConfigs(rootPath, projectId); await uploadSecrets(rootPath, projectId); await injectAntigravityContext(rootPath, projectId, appName); await writeAntigravityConfigs(rootPath); await setupAntigravityMcpServer(rootPath); await cleanupUnusedFiles(rootPath); const currentFolderName = path.basename(rootPath); if (currentFolderName === "download") { logger_1.logger.info(`\n💡 Tip: You may want to rename this folder to "${appName.toLowerCase().replace(/\s+/g, "-")}"`); } await track.trackGA4("firebase_studio_migrate", { app_type: appType, result: "success" }); await askToOpenAntigravity(rootPath, appName, options.startAntigravity); }