firebase-tools
Version:
Command-Line Interface for Firebase
475 lines (474 loc) • 19.6 kB
JavaScript
;
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);
}