UNPKG

firebase-tools

Version:
623 lines (622 loc) 25.3 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 semver = require("semver"); 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"); const agentSkills_1 = require("../agentSkills"); async function setupAntigravityMcpServer(rootPath, appType, nonInteractive) { 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 = {}; } } let updated = false; if (!mcpConfig.mcpServers["firebase"]) { if (utils.commandExistsSync("npx")) { const confirmFirebase = await prompt.confirm({ message: "Would you like to enable the Firebase MCP server for Antigravity?", default: true, nonInteractive, }); if (confirmFirebase) { mcpConfig.mcpServers["firebase"] = { command: "npx", args: ["-y", "firebase-tools@latest", "mcp", "--dir", path.resolve(rootPath)], }; updated = true; logger_1.logger.info(`✅ Configured Firebase MCP server in ${mcpConfigPath}`); } } else { logger_1.logger.info("ℹ️ npx not found on PATH, skipping Firebase MCP server configuration."); } } else { logger_1.logger.info("ℹ️ Firebase MCP server already configured in Antigravity, skipping."); } if (appType === "FLUTTER") { if (utils.commandExistsSync("dart")) { if (!mcpConfig.mcpServers["dart"]) { const confirmDart = await prompt.confirm({ message: "Would you like to enable the Dart MCP server for Antigravity?", default: true, nonInteractive, }); if (confirmDart) { mcpConfig.mcpServers["dart"] = { command: "dart", args: ["mcp-server"], }; updated = true; logger_1.logger.info(`✅ Configured Dart MCP server in ${mcpConfigPath}`); } } else { logger_1.logger.info("ℹ️ Dart MCP server already configured in Antigravity, skipping."); } } else { utils.logWarning("Couldn't find Dart/Flutter on PATH. Install Flutter by following the instruction at https://docs.flutter.dev/install."); } } if (updated) { await fs.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); } } catch (err) { utils.logWarning(`Could not configure Antigravity MCP server: ${(0, error_1.getErrMsg)(err)}`); } } 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"; } 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 studioJsonPath = path.join(rootPath, "studio.json"); const metadataPath = path.join(rootPath, "metadata.json"); let metadata = {}; for (const metadataFile of [metadataPath, studioJsonPath]) { try { const metadataContent = await fs.readFile(metadataFile, "utf8"); metadata = JSON.parse(metadataContent); logger_1.logger.info(`✅ Read ${metadataFile}`); } catch (err) { logger_1.logger.debug(`Could not read metadata at ${metadataFile}: ${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.debug(`❌ Failed to determine the Firebase Project ID. You can set a project later by setting the '--project' flag.`); } let appName = "firebase-studio-export"; const blueprintPath = path.join(rootPath, "docs", "blueprint.md"); try { const content = await fs.readFile(blueprintPath, "utf8"); const nameMatch = content.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 }; } async function updateReadme(rootPath, framework) { const readmePath = path.join(rootPath, "README.md"); const readmeTemplate = await (0, templates_1.readTemplate)("firebase-studio-export/readme_template.md"); const frameworkConfigs = { NEXT_JS: { startCommand: "npm run dev", localUrl: "http://localhost:9002" }, ANGULAR: { startCommand: "npm run start", localUrl: "http://localhost:4200" }, FLUTTER: { startCommand: "flutter run -d chrome --web-port=8080", localUrl: "http://localhost:8080", }, OTHER: { startCommand: "npm run dev", localUrl: "http://localhost:9002" }, }; const { startCommand, localUrl } = frameworkConfigs[framework]; let existingReadme = ""; try { existingReadme = await fs.readFile(readmePath, "utf8"); } catch (err) { } let newReadme = readmeTemplate .replace("${exportDate}", new Date().toISOString().split("T")[0]) .replace("${startCommand}", startCommand) .replace("${localUrl}", localUrl); if (existingReadme.trim()) { newReadme += `\n\n---\n\n## Previous README.md contents:\n\n${existingReadme}`; } await fs.writeFile(readmePath, newReadme); logger_1.logger.info("✅ Updated README.md with project details and origin info"); } async function injectAntigravityContext(rootPath, projectId, appName, nonInteractive) { 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 }); const installLocation = await prompt.select({ message: "Where would you like to install Firebase project skills?", choices: [ { name: "Locally in the project", value: "local" }, { name: "Globally for all projects", value: "global" }, ], default: "local", nonInteractive: nonInteractive || process.env.NODE_ENV === "test", }); await (0, agentSkills_1.installAgentSkills)({ cwd: rootPath, global: installLocation === "global", background: false, agentName: "gemini-cli", }); const systemInstructionsTemplate = await (0, templates_1.readTemplate)("firebase-studio-export/system_instructions_template.md"); const systemInstructions = systemInstructionsTemplate.replace("${appName}", appName); await fs.writeFile(path.join(rulesDir, "migration-context.md"), systemInstructions); logger_1.logger.info("✅ Injected Antigravity rules"); try { const cleanupWorkflow = await (0, templates_1.readTemplate)("firebase-studio-export/workflows/cleanup.md"); await fs.writeFile(path.join(workflowsDir, "cleanup.md"), cleanupWorkflow); logger_1.logger.info("✅ Created Antigravity startup workflow"); } catch (err) { logger_1.logger.debug(`Could not read or write startup workflow: ${(0, error_1.getErrMsg)(err)}`); } } 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, nonInteractive) { 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 backendIds = backends.map((b) => b.name.split("/").pop()); const studioBackend = backends.find((b) => b.name.endsWith("/studio") || b.name.toLowerCase().includes("studio")); let selectedBackendId = ""; if (studioBackend) { selectedBackendId = studioBackend.name.split("/").pop(); } else { selectedBackendId = backendIds[0]; } const confirmBackend = await prompt.confirm({ message: `Would you like to use the App Hosting backend "${selectedBackendId}"?`, default: true, nonInteractive: nonInteractive || process.env.NODE_ENV === "test", }); if (confirmBackend) { backendId = selectedBackendId; } else { logger_1.logger.info("Available App Hosting backends:"); for (const id of backendIds) { logger_1.logger.info(` - ${id}`); } const inputBackendId = await prompt.input({ message: "Please enter the name of the backend you would like to use:", }); if (!backendIds.includes(inputBackendId)) { throw new error_1.FirebaseError(`Invalid backend selected: ${inputBackendId}`, { exit: 1 }); } backendId = inputBackendId; } logger_1.logger.info(`✅ Selected App Hosting backend: ${backendId}`); } else { utils.logWarning('No App Hosting backends found, using default "studio"'); } } catch (err) { utils.logWarning(`Could not fetch backends from Firebase CLI, using default "studio". ${(0, error_1.getErrMsg)(err)}`); } 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, framework) { const vscodeDir = path.join(rootPath, ".vscode"); await fs.mkdir(vscodeDir, { recursive: true }); const tasksJson = { version: "2.0.0", tasks: [], }; if (framework === "FLUTTER") { tasksJson.tasks.push({ label: "flutter-pub-get", type: "shell", command: "flutter pub get", problemMatcher: [], group: { kind: "build", isDefault: true, }, }); } else { tasksJson.tasks.push({ 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) { logger_1.logger.debug(`Could not read ${settingsPath}: ${(0, error_1.getErrMsg)(err)}`); } 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: [], }; if (framework === "ANGULAR") { launchJson.configurations.push({ type: "node", request: "launch", name: "Angular: debug server-side", runtimeExecutable: "npm", runtimeArgs: ["run", "start"], port: 4200, console: "integratedTerminal", preLaunchTask: "npm-install", }); } else if (framework === "NEXT_JS") { launchJson.configurations.push({ type: "node", request: "launch", name: "Next.js: debug server-side", runtimeExecutable: "npm", runtimeArgs: ["run", "dev"], port: 9002, console: "integratedTerminal", preLaunchTask: "npm-install", }); } else if (framework === "FLUTTER") { launchJson.configurations.push({ name: "Flutter", request: "launch", type: "dart", preLaunchTask: "flutter-pub-get", }); } else { return; } 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) { logger_1.logger.debug(`Could not remove ${docsDir}: ${(0, error_1.getErrMsg)(err)}`); } const modifiedPath = path.join(rootPath, ".modified"); try { await fs.unlink(modifiedPath); logger_1.logger.info("✅ Cleaned up .modified"); } catch (err) { logger_1.logger.debug(`Could not delete ${modifiedPath}: ${(0, error_1.getErrMsg)(err)}`); } const mcpJsonPath = path.join(rootPath, ".idx", "mcp.json"); try { await fs.unlink(mcpJsonPath); logger_1.logger.info("✅ Cleaned up .idx/mcp.json"); } catch (err) { logger_1.logger.debug(`Could not delete ${mcpJsonPath}: ${(0, error_1.getErrMsg)(err)}`); } } async function upgradeGenkitVersion(rootPath) { const packageJsonPath = path.join(rootPath, "package.json"); try { const packageJsonContent = await fs.readFile(packageJsonPath, "utf8"); const packageJson = JSON.parse(packageJsonContent); let modified = false; const targetVersion = "1.29.0"; const checkAndUpgrade = (deps) => { if (!deps || !deps["genkit-cli"]) { return; } const currentVersion = deps["genkit-cli"]; if (currentVersion.startsWith("^")) { return; } const coerced = semver.coerce(currentVersion); if (coerced && semver.lt(coerced, targetVersion)) { deps["genkit-cli"] = "^1.29"; modified = true; } }; checkAndUpgrade(packageJson.dependencies); checkAndUpgrade(packageJson.devDependencies); if (modified) { await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n"); logger_1.logger.info("✅ Upgraded genkit-cli version to 1.29 in package.json"); } } catch (err) { logger_1.logger.debug(`Could not upgrade Genkit version: ${(0, error_1.getErrMsg)(err)}`); } } 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) { utils.logWarning(`Failed to upload GEMINI_API_KEY secret: ${(0, error_1.getErrMsg)(err)}`); } } async function askToOpenAntigravity(rootPath, appName, startAntigravity, nonInteractive) { 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, nonInteractive, }); 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 checkDirectoryExists(dir) { try { const stat = await fs.stat(dir); if (!stat.isDirectory()) { throw new error_1.FirebaseError(`The path ${dir} is not a directory.`, { exit: 1 }); } } catch (err) { if (err.code === "ENOENT") { throw new error_1.FirebaseError(`The directory ${dir} does not exist.`, { exit: 1 }); } throw err; } } async function migrate(rootPath, options = { startAntigravity: true }) { await checkDirectoryExists(rootPath); const appType = await detectAppType(rootPath); await track.trackGA4("firebase_studio_migrate", { app_type: appType, result: "started" }); logger_1.logger.info("🚀 Starting Firebase Studio to Antigravity migration..."); logger_1.logger.info("\nFile any bugs at https://github.com/firebase/firebase-tools/issues"); const { projectId, appName } = await extractMetadata(rootPath, options.project); if (appType) { logger_1.logger.info(`✅ Detected framework: ${appType}`); } await updateReadme(rootPath, appType); await createFirebaseConfigs(rootPath, projectId, options.nonInteractive); await uploadSecrets(rootPath, projectId); await upgradeGenkitVersion(rootPath); await injectAntigravityContext(rootPath, projectId, appName, options.nonInteractive); await writeAntigravityConfigs(rootPath, appType); await setupAntigravityMcpServer(rootPath, appType, options.nonInteractive); 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, options.nonInteractive); }