From 413add69e6035c3196fdfa2bfa500caee1c1adbf Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Mon, 23 Feb 2026 12:14:43 -0800 Subject: [PATCH 01/12] Add a firebase studio export command under an experiment flag. --- src/commands/index.ts | 4 ++++ src/commands/studio-export.ts | 11 +++++++++++ src/experiments.ts | 5 +++++ 3 files changed, 20 insertions(+) create mode 100644 src/commands/studio-export.ts diff --git a/src/commands/index.ts b/src/commands/index.ts index ba1ef14a56e..ac2ee4a4d83 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -257,6 +257,10 @@ export function load(client: CLIClient): CLIClient { client.dataconnect.compile = loadCommand("dataconnect-compile"); client.dataconnect.sdk = {}; client.dataconnect.sdk.generate = loadCommand("dataconnect-sdk-generate"); + if (experiments.isEnabled("studioexport")) { + client.studio = {}; + client.studio.export = loadCommand("studio-export"); + } client.target = loadCommand("target"); client.target.apply = loadCommand("target-apply"); client.target.clear = loadCommand("target-clear"); diff --git a/src/commands/studio-export.ts b/src/commands/studio-export.ts new file mode 100644 index 00000000000..038157f8069 --- /dev/null +++ b/src/commands/studio-export.ts @@ -0,0 +1,11 @@ +import { Command } from "../command"; +import { logger } from "../logger"; +import * as experiments from "../experiments"; + +export const command = new Command("studio:export") + .description("export Firebase Studio apps for migration to Antigravity") + .action(() => { + experiments.assertEnabled("studioexport", "export Studio apps"); + logger.info("Exporting Studio apps to Antigravity..."); + // TODO: implement export logic + }); diff --git a/src/experiments.ts b/src/experiments.ts index b9f15f38c3d..752bd926807 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -169,6 +169,11 @@ export const ALL_EXPERIMENTS = experiments({ default: false, public: false, }, + studioexport: { + shortDescription: "Enable the experimental studio:export command.", + default: false, + public: false, + }, }); export type ExperimentName = keyof typeof ALL_EXPERIMENTS; From 6d4bd72a16715cd849b43478550d336265841968 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 02:52:55 -0800 Subject: [PATCH 02/12] typescript conversion --- src/commands/studio-export.ts | 441 +++++++++++++++++++++++++++++++++- 1 file changed, 435 insertions(+), 6 deletions(-) diff --git a/src/commands/studio-export.ts b/src/commands/studio-export.ts index 038157f8069..501eca085e1 100644 --- a/src/commands/studio-export.ts +++ b/src/commands/studio-export.ts @@ -1,11 +1,440 @@ import { Command } from "../command"; import { logger } from "../logger"; -import * as experiments from "../experiments"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { execSync, spawn } from "child_process"; +import * as readline from "node:readline/promises"; +import { Options } from "../options"; -export const command = new Command("studio:export") +export const command = new Command("studio:export [path]") .description("export Firebase Studio apps for migration to Antigravity") - .action(() => { - experiments.assertEnabled("studioexport", "export Studio apps"); - logger.info("Exporting Studio apps to Antigravity..."); - // TODO: implement export logic + .action(async (exportPath: string | undefined, options: Options) => { + const rootPath = path.resolve(exportPath || options.cwd || process.cwd()); + logger.info(`Exporting Studio apps from ${rootPath} to Antigravity...`); + await migrate(rootPath); }); + +interface GitHubItem { + name: string; + type: "dir" | "file"; + url: string; + download_url: string; +} + +async function downloadGitHubDir(apiUrl: string, localPath: string): Promise { + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`Failed to fetch directory listing: ${apiUrl}`); + } + const items = (await response.json()) as GitHubItem[]; + + 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)); + } + } + } +} + +interface Metadata { + projectId?: string; + [key: string]: any; +} + +async function extractMetadata(rootPath: string): Promise<{ + projectId: string; + appName: string; + blueprintContent: string; +}> { + // 1. Verify export & Extract Metadata + const metadataPath = path.join(rootPath, "metadata.json"); + let metadata: Metadata = {}; + try { + const metadataContent = await fs.readFile(metadataPath, "utf8"); + metadata = JSON.parse(metadataContent) as Metadata; + } catch (err) {} + + let projectId = metadata.projectId; + if (!projectId) { + // try to get from .firebaserc + try { + const firebasercContent = await fs.readFile(path.join(rootPath, ".firebaserc"), "utf8"); + const firebaserc = JSON.parse(firebasercContent) as { projects?: { default?: string } }; + projectId = firebaserc.projects?.default; + } catch (err) {} + } + + if (projectId) { + console.log(`✅ Detected Firebase Project: ${projectId}`); + } else { + projectId = "studio-8559296606-bdfe5"; // FIXME + } + + // 2. Extract App Name and Blueprint Content + 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) {} + + if (appName !== "firebase-studio-export") { + console.log(`✅ Detected App Name: ${appName}`); + } + + return { projectId, appName, blueprintContent }; +} + +async function updateReadme( + rootPath: string, + blueprintContent: string, + appName: string, +): Promise { + // Update README.md + const readmePath = path.join(rootPath, "README.md"); + const newReadme = `# ${appName} + +This project was migrated from Firebase Studio. +**Previous Name:** ${appName} +**Export Date:** ${new Date().toLocaleDateString()} + +${blueprintContent.replace(/# \*\*App Name\*\*: .*/, "").trim()} + +--- +To get started, run \`npm run dev\` and visit \`http://localhost:9002\`. +`; + await fs.writeFile(readmePath, newReadme); + console.log("✅ Updated README.md with project details and origin info"); + + // Remove docs/blueprint.md and empty docs directory + const docsDir = path.join(rootPath, "docs"); + const blueprintPath = path.join(docsDir, "blueprint.md"); + try { + await fs.unlink(blueprintPath); + console.log("✅ Cleaned up docs/blueprint.md"); + } catch (err) {} + + try { + const files = await fs.readdir(docsDir); + if (files.length === 0) { + await fs.rmdir(docsDir); + console.log("✅ Removed empty docs directory"); + } + } catch (err) {} +} + +async function injectAgyContext(rootPath: string, projectId: string, appName: string): Promise { + const agentDir = path.join(rootPath, ".agent"); + 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 }); + + // Download Skills from GitHub + console.log("⏳ Fetching AGY 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()) as GitHubItem[]; + + 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 { + console.warn("⚠️ GitHub API response for skills is not an array."); + } + console.log(`✅ Downloaded Firebase skills`); + } catch (err: any) { + console.warn("⚠️ Could not download AGY skills, skipping.", err.message); + } + + // System Instructions + const systemInstructions = `--- +trigger: always_on +--- + +# Project Context +This project was migrated from Firebase Studio. +${projectId ? `Original Project ID: ${projectId}` : ""} +App Name: ${appName} + +# Migration Guidelines +- Focus on ensuring zero-friction deployments to Firebase App Hosting. +- Maintain the original intent defined in docs/blueprint.md. +- Use Genkit for AI features as already configured in src/ai/. +`; + await fs.writeFile(path.join(rulesDir, "migration-context.md"), systemInstructions); + console.log("✅ Injected AGY rules"); + + // Startup Workflow + const startupWorkflow = `--- +name: Initial Project Setup +description: Run initial checks and fix common migration issues +--- + +# Step 1: Check Compilation +Run \`npm run typecheck\` and \`npm run build\` to ensure the project is in a healthy state. + +# Step 2: Verify Firebase Auth/Firestore +If the app uses Firebase services, ensure the environment variables are correctly set or provided via App Hosting. + +# Step 3: Cleanup Genkit config +If genkit is otherwise unused in this project, remove the configuration in src/ai/genkit.ts and remove related dependencies in package.json. +`; + await fs.writeFile(path.join(workflowsDir, "startup.md"), startupWorkflow); + console.log("✅ Created AGY startup workflow"); +} + +async function assertSystemState(): Promise { + // Assertion: Check for firebase-tools + try { + execSync("firebase --version", { stdio: "ignore" }); + console.log("✅ Firebase CLI detected"); + } catch (err) { + console.error("❌ Error: Firebase CLI (firebase-tools) is not installed or not in your PATH."); + console.error("👉 Please install it using: npm install -g firebase-tools"); + process.exit(1); + } + + // Assertion: Check for Antigravity (agy) + try { + execSync("agy --version", { stdio: "ignore" }); + console.log("✅ Antigravity IDE CLI (agy) detected"); + } catch (err) { + const downloadLink = "https://antigravity.google/download"; + + console.warn("⚠️ Warning: Antigravity IDE CLI (agy) not found in your PATH."); + console.warn( + `👉 To ensure a seamless migration, please download and install Antigravity: ${downloadLink}`, + ); + process.exit(1); + } +} + +interface Backend { + name: string; + displayName?: string; +} + +async function createFirebaseConfigs(rootPath: string, projectId: string): Promise { + // 3. Create Firebase Configs + // .firebaserc + const firebaserc = { + projects: { + default: projectId, + }, + }; + await fs.writeFile(path.join(rootPath, ".firebaserc"), JSON.stringify(firebaserc, null, 2)); + console.log("✅ Created .firebaserc"); + + // firebase.json (App Hosting) + const firebaseJsonPath = path.join(rootPath, "firebase.json"); + try { + await fs.access(firebaseJsonPath); + console.log("ℹ️ firebase.json already exists, skipping creation."); + } catch { + let backendId = "studio"; // Default + try { + console.log(`⏳ Fetching App Hosting backends for project ${projectId}...`); + const backendsOutput = execSync( + `firebase apphosting:backends:list --project=${projectId} --json`, + { encoding: "utf8" }, + ); + const backendsData = JSON.parse(backendsOutput) as { result?: Backend[] }; + const backends = backendsData.result || []; + + if (backends.length > 0) { + const studioBackend = backends.find( + (b) => b.name.endsWith("/studio") || b.displayName?.toLowerCase() === "studio", + ); + if (studioBackend) { + backendId = studioBackend.name.split("/").pop()!; + } else { + backendId = backends[0].name.split("/").pop()!; + } + console.log(`✅ Selected App Hosting backend: ${backendId}`); + } else { + console.warn('⚠️ No App Hosting backends found, using default "studio"'); + } + } catch (err) { + console.warn('⚠️ Could not fetch backends from Firebase CLI, using default "studio"'); + } + + const firebaseJson = { + apphosting: { + backendId: backendId, + }, + }; + await fs.writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 2)); + console.log(`✅ Created firebase.json with backendId: ${backendId}`); + } +} + +async function writeAgyConfigs(rootPath: string): Promise { + // 5. IDE Configs (VS Code / AGY) + const vscodeDir = path.join(rootPath, ".vscode"); + await fs.mkdir(vscodeDir, { recursive: true }); + + // Create tasks.json for pre-launch tasks + 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)); + console.log("✅ Created .vscode/tasks.json"); + + // Clean and set preferences in .vscode/settings.json + const settingsPath = path.join(vscodeDir, "settings.json"); + let settings: Record = {}; + try { + const settingsContent = await fs.readFile(settingsPath, "utf8"); + settings = JSON.parse(settingsContent) as Record; + } catch (err) {} + + const cleanSettings: Record = {}; + for (const [key, value] of Object.entries(settings)) { + if (!key.startsWith("IDX.")) { + cleanSettings[key] = value; + } + } + + // Add AGY/VSCode startup preference + cleanSettings["workbench.startupEditor"] = "readme"; + + await fs.writeFile(settingsPath, JSON.stringify(cleanSettings, null, 2)); + console.log("✅ 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)); + console.log("✅ Created .vscode/launch.json"); +} + +async function askToOpenAgy( + rootPath: string, + appName: string, + noStartAgyFlag: boolean, +): Promise { + // 8. Open in Antigravity (Optional) + if (noStartAgyFlag) { + console.log( + `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, + ); + return; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question( + `\n🚀 Migration complete for ${appName}! Would you like to open it in Antigravity now? (y/n): `, + ); + if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { + console.log(`⏳ Opening ${appName} in Antigravity...`); + try { + execSync("agy .", { cwd: rootPath, stdio: "inherit" }); + } catch (err) { + console.warn("⚠️ Could not open Antigravity IDE automatically. Please open it manually."); + } + } else { + console.log( + `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, + ); + } + } finally { + rl.close(); + } +} + +async function migrate(rootPath: string): Promise { + const args = process.argv.slice(2); + const noStartAgyFlag = args.includes("--nostart_agy"); + + console.log("🚀 Starting Firebase Studio to Antigravity migration..."); + + await assertSystemState(); + + const { projectId, appName, blueprintContent } = await extractMetadata(rootPath); + + await updateReadme(rootPath, blueprintContent, appName); + + await createFirebaseConfigs(rootPath, projectId); + + // 4. Inject AGY Context + await injectAgyContext(rootPath, projectId, appName); + await writeAgyConfigs(rootPath); + // 6. Cleanup + const metadataPath = path.join(rootPath, "metadata.json"); + try { + await fs.unlink(metadataPath); + console.log("✅ Cleaned up metadata.json"); + } catch (err) {} + + const modifiedPath = path.join(rootPath, ".modified"); + try { + await fs.unlink(modifiedPath); + console.log("✅ Cleaned up .modified"); + } catch (err) {} + + // 7. Folder Renaming (Optional/Attempt) + // Note: This might fail if the script is running inside the folder + + // Suggest renaming if we are in the 'download' folder + const currentFolderName = path.basename(rootPath); + if (currentFolderName === "download") { + console.log( + `\n💡 Tip: You might want to rename this folder to "${appName + .toLowerCase() + .replace(/\\s+/g, "-")}"`, + ); + } + await askToOpenAgy(rootPath, appName, noStartAgyFlag); +} From e7079bf6838f13a8e6d46f217443ee3e66961ce3 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 04:17:55 -0800 Subject: [PATCH 03/12] Refactored to a folder, cleaned up, tested --- npm-shrinkwrap.json | 2 +- package.json | 2 +- src/commands/studio-export.ts | 433 +----------------- src/firebase_studio/migrate.ts | 428 +++++++++++++++++ src/firebase_studio/readme_template.md | 10 + src/firebase_studio/system_instructions.md | 12 + .../workflows/startup_workflow.md | 13 + 7 files changed, 469 insertions(+), 431 deletions(-) create mode 100644 src/firebase_studio/migrate.ts create mode 100644 src/firebase_studio/readme_template.md create mode 100644 src/firebase_studio/system_instructions.md create mode 100644 src/firebase_studio/workflows/startup_workflow.md diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index cd72a28c489..dc70a10d38f 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -26680,7 +26680,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "requires": { - "ajv": "^8.17.1" + "ajv": "^8.0.0" } }, "ansi-align": { diff --git a/package.json b/package.json index 4df091626cc..824953843b1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build:publish": "tsc --build tsconfig.publish.json && npm run copyfiles", "build:watch": "npm run build && tsc --watch", "clean": "node -e \"fs.rmSync('lib', { recursive: true, force: true }); fs.rmSync('dev', { recursive: true, force: true });\"", - "copyfiles": "node -e \"const fs = require('fs'); fs.mkdirSync('./lib', {recursive:true}); fs.copyFileSync('./src/dynamicImport.js', './lib/dynamicImport.js')\"", + "copyfiles": "node -e \"const fs = require('fs'); fs.mkdirSync('./lib/firebase_studio', {recursive:true}); fs.copyFileSync('./src/dynamicImport.js', './lib/dynamicImport.js'); fs.cpSync('./src/firebase_studio', './lib/firebase_studio', {recursive: true, filter: (src) => fs.statSync(src).isDirectory() || src.endsWith('.md')});\"", "format": "npm run format:ts && npm run format:other", "format:other": "npm run lint:other -- --write", "format:ts": "npm run lint:ts -- --fix --quiet", diff --git a/src/commands/studio-export.ts b/src/commands/studio-export.ts index 501eca085e1..5a3cf31abfd 100644 --- a/src/commands/studio-export.ts +++ b/src/commands/studio-export.ts @@ -1,440 +1,15 @@ import { Command } from "../command"; import { logger } from "../logger"; -import * as fs from "fs/promises"; -import * as path from "path"; -import { execSync, spawn } from "child_process"; -import * as readline from "node:readline/promises"; import { Options } from "../options"; +import { migrate } from "../firebase_studio/migrate"; +import * as path from "path"; +import * as experiments from "../experiments"; export const command = new Command("studio:export [path]") .description("export Firebase Studio apps for migration to Antigravity") .action(async (exportPath: string | undefined, options: Options) => { + experiments.assertEnabled("studioexport", "export Studio apps"); const rootPath = path.resolve(exportPath || options.cwd || process.cwd()); logger.info(`Exporting Studio apps from ${rootPath} to Antigravity...`); await migrate(rootPath); }); - -interface GitHubItem { - name: string; - type: "dir" | "file"; - url: string; - download_url: string; -} - -async function downloadGitHubDir(apiUrl: string, localPath: string): Promise { - const response = await fetch(apiUrl); - if (!response.ok) { - throw new Error(`Failed to fetch directory listing: ${apiUrl}`); - } - const items = (await response.json()) as GitHubItem[]; - - 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)); - } - } - } -} - -interface Metadata { - projectId?: string; - [key: string]: any; -} - -async function extractMetadata(rootPath: string): Promise<{ - projectId: string; - appName: string; - blueprintContent: string; -}> { - // 1. Verify export & Extract Metadata - const metadataPath = path.join(rootPath, "metadata.json"); - let metadata: Metadata = {}; - try { - const metadataContent = await fs.readFile(metadataPath, "utf8"); - metadata = JSON.parse(metadataContent) as Metadata; - } catch (err) {} - - let projectId = metadata.projectId; - if (!projectId) { - // try to get from .firebaserc - try { - const firebasercContent = await fs.readFile(path.join(rootPath, ".firebaserc"), "utf8"); - const firebaserc = JSON.parse(firebasercContent) as { projects?: { default?: string } }; - projectId = firebaserc.projects?.default; - } catch (err) {} - } - - if (projectId) { - console.log(`✅ Detected Firebase Project: ${projectId}`); - } else { - projectId = "studio-8559296606-bdfe5"; // FIXME - } - - // 2. Extract App Name and Blueprint Content - 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) {} - - if (appName !== "firebase-studio-export") { - console.log(`✅ Detected App Name: ${appName}`); - } - - return { projectId, appName, blueprintContent }; -} - -async function updateReadme( - rootPath: string, - blueprintContent: string, - appName: string, -): Promise { - // Update README.md - const readmePath = path.join(rootPath, "README.md"); - const newReadme = `# ${appName} - -This project was migrated from Firebase Studio. -**Previous Name:** ${appName} -**Export Date:** ${new Date().toLocaleDateString()} - -${blueprintContent.replace(/# \*\*App Name\*\*: .*/, "").trim()} - ---- -To get started, run \`npm run dev\` and visit \`http://localhost:9002\`. -`; - await fs.writeFile(readmePath, newReadme); - console.log("✅ Updated README.md with project details and origin info"); - - // Remove docs/blueprint.md and empty docs directory - const docsDir = path.join(rootPath, "docs"); - const blueprintPath = path.join(docsDir, "blueprint.md"); - try { - await fs.unlink(blueprintPath); - console.log("✅ Cleaned up docs/blueprint.md"); - } catch (err) {} - - try { - const files = await fs.readdir(docsDir); - if (files.length === 0) { - await fs.rmdir(docsDir); - console.log("✅ Removed empty docs directory"); - } - } catch (err) {} -} - -async function injectAgyContext(rootPath: string, projectId: string, appName: string): Promise { - const agentDir = path.join(rootPath, ".agent"); - 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 }); - - // Download Skills from GitHub - console.log("⏳ Fetching AGY 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()) as GitHubItem[]; - - 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 { - console.warn("⚠️ GitHub API response for skills is not an array."); - } - console.log(`✅ Downloaded Firebase skills`); - } catch (err: any) { - console.warn("⚠️ Could not download AGY skills, skipping.", err.message); - } - - // System Instructions - const systemInstructions = `--- -trigger: always_on ---- - -# Project Context -This project was migrated from Firebase Studio. -${projectId ? `Original Project ID: ${projectId}` : ""} -App Name: ${appName} - -# Migration Guidelines -- Focus on ensuring zero-friction deployments to Firebase App Hosting. -- Maintain the original intent defined in docs/blueprint.md. -- Use Genkit for AI features as already configured in src/ai/. -`; - await fs.writeFile(path.join(rulesDir, "migration-context.md"), systemInstructions); - console.log("✅ Injected AGY rules"); - - // Startup Workflow - const startupWorkflow = `--- -name: Initial Project Setup -description: Run initial checks and fix common migration issues ---- - -# Step 1: Check Compilation -Run \`npm run typecheck\` and \`npm run build\` to ensure the project is in a healthy state. - -# Step 2: Verify Firebase Auth/Firestore -If the app uses Firebase services, ensure the environment variables are correctly set or provided via App Hosting. - -# Step 3: Cleanup Genkit config -If genkit is otherwise unused in this project, remove the configuration in src/ai/genkit.ts and remove related dependencies in package.json. -`; - await fs.writeFile(path.join(workflowsDir, "startup.md"), startupWorkflow); - console.log("✅ Created AGY startup workflow"); -} - -async function assertSystemState(): Promise { - // Assertion: Check for firebase-tools - try { - execSync("firebase --version", { stdio: "ignore" }); - console.log("✅ Firebase CLI detected"); - } catch (err) { - console.error("❌ Error: Firebase CLI (firebase-tools) is not installed or not in your PATH."); - console.error("👉 Please install it using: npm install -g firebase-tools"); - process.exit(1); - } - - // Assertion: Check for Antigravity (agy) - try { - execSync("agy --version", { stdio: "ignore" }); - console.log("✅ Antigravity IDE CLI (agy) detected"); - } catch (err) { - const downloadLink = "https://antigravity.google/download"; - - console.warn("⚠️ Warning: Antigravity IDE CLI (agy) not found in your PATH."); - console.warn( - `👉 To ensure a seamless migration, please download and install Antigravity: ${downloadLink}`, - ); - process.exit(1); - } -} - -interface Backend { - name: string; - displayName?: string; -} - -async function createFirebaseConfigs(rootPath: string, projectId: string): Promise { - // 3. Create Firebase Configs - // .firebaserc - const firebaserc = { - projects: { - default: projectId, - }, - }; - await fs.writeFile(path.join(rootPath, ".firebaserc"), JSON.stringify(firebaserc, null, 2)); - console.log("✅ Created .firebaserc"); - - // firebase.json (App Hosting) - const firebaseJsonPath = path.join(rootPath, "firebase.json"); - try { - await fs.access(firebaseJsonPath); - console.log("ℹ️ firebase.json already exists, skipping creation."); - } catch { - let backendId = "studio"; // Default - try { - console.log(`⏳ Fetching App Hosting backends for project ${projectId}...`); - const backendsOutput = execSync( - `firebase apphosting:backends:list --project=${projectId} --json`, - { encoding: "utf8" }, - ); - const backendsData = JSON.parse(backendsOutput) as { result?: Backend[] }; - const backends = backendsData.result || []; - - if (backends.length > 0) { - const studioBackend = backends.find( - (b) => b.name.endsWith("/studio") || b.displayName?.toLowerCase() === "studio", - ); - if (studioBackend) { - backendId = studioBackend.name.split("/").pop()!; - } else { - backendId = backends[0].name.split("/").pop()!; - } - console.log(`✅ Selected App Hosting backend: ${backendId}`); - } else { - console.warn('⚠️ No App Hosting backends found, using default "studio"'); - } - } catch (err) { - console.warn('⚠️ Could not fetch backends from Firebase CLI, using default "studio"'); - } - - const firebaseJson = { - apphosting: { - backendId: backendId, - }, - }; - await fs.writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 2)); - console.log(`✅ Created firebase.json with backendId: ${backendId}`); - } -} - -async function writeAgyConfigs(rootPath: string): Promise { - // 5. IDE Configs (VS Code / AGY) - const vscodeDir = path.join(rootPath, ".vscode"); - await fs.mkdir(vscodeDir, { recursive: true }); - - // Create tasks.json for pre-launch tasks - 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)); - console.log("✅ Created .vscode/tasks.json"); - - // Clean and set preferences in .vscode/settings.json - const settingsPath = path.join(vscodeDir, "settings.json"); - let settings: Record = {}; - try { - const settingsContent = await fs.readFile(settingsPath, "utf8"); - settings = JSON.parse(settingsContent) as Record; - } catch (err) {} - - const cleanSettings: Record = {}; - for (const [key, value] of Object.entries(settings)) { - if (!key.startsWith("IDX.")) { - cleanSettings[key] = value; - } - } - - // Add AGY/VSCode startup preference - cleanSettings["workbench.startupEditor"] = "readme"; - - await fs.writeFile(settingsPath, JSON.stringify(cleanSettings, null, 2)); - console.log("✅ 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)); - console.log("✅ Created .vscode/launch.json"); -} - -async function askToOpenAgy( - rootPath: string, - appName: string, - noStartAgyFlag: boolean, -): Promise { - // 8. Open in Antigravity (Optional) - if (noStartAgyFlag) { - console.log( - `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, - ); - return; - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { - const answer = await rl.question( - `\n🚀 Migration complete for ${appName}! Would you like to open it in Antigravity now? (y/n): `, - ); - if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { - console.log(`⏳ Opening ${appName} in Antigravity...`); - try { - execSync("agy .", { cwd: rootPath, stdio: "inherit" }); - } catch (err) { - console.warn("⚠️ Could not open Antigravity IDE automatically. Please open it manually."); - } - } else { - console.log( - `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, - ); - } - } finally { - rl.close(); - } -} - -async function migrate(rootPath: string): Promise { - const args = process.argv.slice(2); - const noStartAgyFlag = args.includes("--nostart_agy"); - - console.log("🚀 Starting Firebase Studio to Antigravity migration..."); - - await assertSystemState(); - - const { projectId, appName, blueprintContent } = await extractMetadata(rootPath); - - await updateReadme(rootPath, blueprintContent, appName); - - await createFirebaseConfigs(rootPath, projectId); - - // 4. Inject AGY Context - await injectAgyContext(rootPath, projectId, appName); - await writeAgyConfigs(rootPath); - // 6. Cleanup - const metadataPath = path.join(rootPath, "metadata.json"); - try { - await fs.unlink(metadataPath); - console.log("✅ Cleaned up metadata.json"); - } catch (err) {} - - const modifiedPath = path.join(rootPath, ".modified"); - try { - await fs.unlink(modifiedPath); - console.log("✅ Cleaned up .modified"); - } catch (err) {} - - // 7. Folder Renaming (Optional/Attempt) - // Note: This might fail if the script is running inside the folder - - // Suggest renaming if we are in the 'download' folder - const currentFolderName = path.basename(rootPath); - if (currentFolderName === "download") { - console.log( - `\n💡 Tip: You might want to rename this folder to "${appName - .toLowerCase() - .replace(/\\s+/g, "-")}"`, - ); - } - await askToOpenAgy(rootPath, appName, noStartAgyFlag); -} diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts new file mode 100644 index 00000000000..01a88fa2933 --- /dev/null +++ b/src/firebase_studio/migrate.ts @@ -0,0 +1,428 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { execSync, spawn } from "child_process"; +import * as readline from "node:readline/promises"; + +interface GitHubItem { + name: string; + type: "dir" | "file"; + url: string; + download_url: string; +} + +async function downloadGitHubDir(apiUrl: string, localPath: string): Promise { + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`Failed to fetch directory listing: ${apiUrl}`); + } + const items = (await response.json()) as GitHubItem[]; + + 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)); + } + } + } +} + +interface Metadata { + projectId?: string; + [key: string]: any; +} + +async function extractMetadata(rootPath: string): Promise<{ + projectId: string; + appName: string; + blueprintContent: string; +}> { + // 1. Verify export & Extract Metadata + const metadataPath = path.join(rootPath, "metadata.json"); + let metadata: Metadata = {}; + try { + const metadataContent = await fs.readFile(metadataPath, "utf8"); + metadata = JSON.parse(metadataContent) as Metadata; + } catch (err) {} + + let projectId = metadata.projectId; + if (!projectId) { + // try to get from .firebaserc + try { + const firebasercContent = await fs.readFile(path.join(rootPath, ".firebaserc"), "utf8"); + const firebaserc = JSON.parse(firebasercContent) as { projects?: { default?: string } }; + projectId = firebaserc.projects?.default; + } catch (err) {} + } + + if (projectId) { + console.log(`✅ Detected Firebase Project: ${projectId}`); + } else { + projectId = "studio-8559296606-bdfe5"; // FIXME + } + + // 2. Extract App Name and Blueprint Content + 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) {} + + if (appName !== "firebase-studio-export") { + console.log(`✅ Detected App Name: ${appName}`); + } + + return { projectId, appName, blueprintContent }; +} + +async function updateReadme( + rootPath: string, + blueprintContent: string, + appName: string, +): Promise { + // Update README.md + const readmePath = path.join(rootPath, "README.md"); + const readmeTemplate = await fs.readFile(path.join(__dirname, "readme_template.md"), "utf8"); + const newReadme = readmeTemplate + .replace("${appName}", appName) + .replace("${appName}", appName) // Replace twice for name and previous name + .replace("${exportDate}", new Date().toLocaleDateString()) + .replace("${blueprintContent}", blueprintContent.replace(/# \*\*App Name\*\*: .*/, "").trim()); + + await fs.writeFile(readmePath, newReadme); + console.log("✅ Updated README.md with project details and origin info"); + + // Remove docs/blueprint.md and empty docs directory + const docsDir = path.join(rootPath, "docs"); + const blueprintPath = path.join(docsDir, "blueprint.md"); + try { + await fs.unlink(blueprintPath); + console.log("✅ Cleaned up docs/blueprint.md"); + } catch (err) {} + + try { + const files = await fs.readdir(docsDir); + if (files.length === 0) { + await fs.rmdir(docsDir); + console.log("✅ Removed empty docs directory"); + } + } catch (err) {} +} + +async function injectAgyContext(rootPath: string, projectId: string, appName: string): Promise { + const agentDir = path.join(rootPath, ".agent"); + 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 }); + + // Download Skills from GitHub + console.log("⏳ Fetching AGY 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()) as GitHubItem[]; + + 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 { + console.warn("⚠️ GitHub API response for skills is not an array."); + } + console.log(`✅ Downloaded Firebase skills`); + } catch (err: any) { + console.warn("⚠️ Could not download AGY skills, skipping.", err.message); + } + + + // Download Genkit skill + console.log("⏳ Fetching Genkit skill..."); + try { + const genkitSkillDir = path.join(skillsDir, "developing-genkit-js"); + await downloadGitHubDir( + "https://api.github.com/repos/genkit-ai/skills/contents/skills/developing-genkit-js?ref=main", + genkitSkillDir, + ); + console.log(`✅ Downloaded Genkit skill`); + } catch (err: any) { + console.warn("⚠️ Could not download Genkit skill, skipping.", err.message); + } + + // System Instructions + const systemInstructionsTemplate = await fs.readFile( + path.join(__dirname, "system_instructions.md"), + "utf8", + ); + const systemInstructions = systemInstructionsTemplate + .replace("${projectId}", projectId || "") + .replace("${appName}", appName); + + await fs.writeFile(path.join(rulesDir, "migration-context.md"), systemInstructions); + console.log("✅ Injected AGY rules"); + + // Startup Workflow + const startupWorkflow = await fs.readFile( + path.join(__dirname, "workflows", "startup_workflow.md"), + "utf8", + ); + await fs.writeFile(path.join(workflowsDir, "startup.md"), startupWorkflow); + console.log("✅ Created AGY startup workflow"); +} + +async function assertSystemState(): Promise { + // Assertion: Check for firebase-tools + try { + execSync("firebase --version", { stdio: "ignore" }); + console.log("✅ Firebase CLI detected"); + } catch (err) { + console.error("❌ Error: Firebase CLI (firebase-tools) is not installed or not in your PATH."); + console.error("👉 Please install it using: npm install -g firebase-tools"); + process.exit(1); + } + + // Assertion: Check for Antigravity (agy) + try { + execSync("agy --version", { stdio: "ignore" }); + console.log("✅ Antigravity IDE CLI (agy) detected"); + } catch (err) { + const downloadLink = "https://antigravity.google/download"; + + console.warn("⚠️ Warning: Antigravity IDE CLI (agy) not found in your PATH."); + console.warn( + `👉 To ensure a seamless migration, please download and install Antigravity: ${downloadLink}`, + ); + process.exit(1); + } +} + +interface Backend { + name: string; + displayName?: string; +} + +async function createFirebaseConfigs(rootPath: string, projectId: string): Promise { + // 3. Create Firebase Configs + // .firebaserc + const firebaserc = { + projects: { + default: projectId, + }, + }; + await fs.writeFile(path.join(rootPath, ".firebaserc"), JSON.stringify(firebaserc, null, 2)); + console.log("✅ Created .firebaserc"); + + // firebase.json (App Hosting) + const firebaseJsonPath = path.join(rootPath, "firebase.json"); + try { + await fs.access(firebaseJsonPath); + console.log("ℹ️ firebase.json already exists, skipping creation."); + } catch { + let backendId = "studio"; // Default + try { + console.log(`⏳ Fetching App Hosting backends for project ${projectId}...`); + const backendsOutput = execSync( + `firebase apphosting:backends:list --project=${projectId} --json`, + { encoding: "utf8" }, + ); + const backendsData = JSON.parse(backendsOutput) as { result?: Backend[] }; + const backends = backendsData.result || []; + + if (backends.length > 0) { + const studioBackend = backends.find( + (b) => b.name.endsWith("/studio") || b.displayName?.toLowerCase() === "studio", + ); + if (studioBackend) { + backendId = studioBackend.name.split("/").pop()!; + } else { + backendId = backends[0].name.split("/").pop()!; + } + console.log(`✅ Selected App Hosting backend: ${backendId}`); + } else { + console.warn('⚠️ No App Hosting backends found, using default "studio"'); + } + } catch (err) { + console.warn('⚠️ Could not fetch backends from Firebase CLI, using default "studio"'); + } + + const firebaseJson = { + apphosting: { + backendId: backendId, + }, + }; + await fs.writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 2)); + console.log(`✅ Created firebase.json with backendId: ${backendId}`); + } +} + +async function writeAgyConfigs(rootPath: string): Promise { + // 5. IDE Configs (VS Code / AGY) + const vscodeDir = path.join(rootPath, ".vscode"); + await fs.mkdir(vscodeDir, { recursive: true }); + + // Create tasks.json for pre-launch tasks + 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)); + console.log("✅ Created .vscode/tasks.json"); + + // Clean and set preferences in .vscode/settings.json + const settingsPath = path.join(vscodeDir, "settings.json"); + let settings: Record = {}; + try { + const settingsContent = await fs.readFile(settingsPath, "utf8"); + settings = JSON.parse(settingsContent) as Record; + } catch (err) {} + + const cleanSettings: Record = {}; + for (const [key, value] of Object.entries(settings)) { + if (!key.startsWith("IDX.")) { + cleanSettings[key] = value; + } + } + + // Add AGY/VSCode startup preference + cleanSettings["workbench.startupEditor"] = "readme"; + + await fs.writeFile(settingsPath, JSON.stringify(cleanSettings, null, 2)); + console.log("✅ 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)); + console.log("✅ Created .vscode/launch.json"); +} + +async function askToOpenAgy( + rootPath: string, + appName: string, + noStartAgyFlag: boolean, +): Promise { + // 8. Open in Antigravity (Optional) + if (noStartAgyFlag) { + console.log( + `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, + ); + return; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question( + `\n🚀 Migration complete for ${appName}! Would you like to open it in Antigravity now? (y/n): `, + ); + if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { + console.log(`⏳ Opening ${appName} in Antigravity...`); + try { + const agyProcess = spawn("agy", ["."], { + cwd: rootPath, + stdio: "ignore", + detached: true, + }); + agyProcess.unref(); + } catch (err) { + console.warn("⚠️ Could not open Antigravity IDE automatically. Please open it manually."); + } + } else { + console.log( + `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, + ); + } + } finally { + rl.close(); + } +} + +export async function migrate(rootPath: string): Promise { + const args = process.argv.slice(2); + const noStartAgyFlag = args.includes("--nostart_agy"); + + console.log("🚀 Starting Firebase Studio to Antigravity migration..."); + + await assertSystemState(); + + const { projectId, appName, blueprintContent } = await extractMetadata(rootPath); + + await updateReadme(rootPath, blueprintContent, appName); + + await createFirebaseConfigs(rootPath, projectId); + + // 4. Inject AGY Context + await injectAgyContext(rootPath, projectId, appName); + await writeAgyConfigs(rootPath); + // 6. Cleanup + const metadataPath = path.join(rootPath, "metadata.json"); + try { + await fs.unlink(metadataPath); + console.log("✅ Cleaned up metadata.json"); + } catch (err) {} + + const modifiedPath = path.join(rootPath, ".modified"); + try { + await fs.unlink(modifiedPath); + console.log("✅ Cleaned up .modified"); + } catch (err) {} + + // 7. Folder Renaming (Optional/Attempt) + // Note: This might fail if the script is running inside the folder + + // Suggest renaming if we are in the 'download' folder + const currentFolderName = path.basename(rootPath); + if (currentFolderName === "download") { + console.log( + `\n💡 Tip: You might want to rename this folder to "${appName + .toLowerCase() + .replace(/\\s+/g, "-")}"`, + ); + } + await askToOpenAgy(rootPath, appName, noStartAgyFlag); +} \ No newline at end of file diff --git a/src/firebase_studio/readme_template.md b/src/firebase_studio/readme_template.md new file mode 100644 index 00000000000..87827144d67 --- /dev/null +++ b/src/firebase_studio/readme_template.md @@ -0,0 +1,10 @@ +# ${appName} + +This project was migrated from Firebase Studio. +**Previous Name:** ${appName} +**Export Date:** ${exportDate} + +${blueprintContent} + +--- +To get started, run \`npm run dev\` and visit \`http://localhost:9002\`. diff --git a/src/firebase_studio/system_instructions.md b/src/firebase_studio/system_instructions.md new file mode 100644 index 00000000000..0780701278e --- /dev/null +++ b/src/firebase_studio/system_instructions.md @@ -0,0 +1,12 @@ +--- +trigger: always_on +--- + +# Project Context +This project was migrated from Firebase Studio. +App Name: ${appName} + +# Migration Guidelines +- Focus on ensuring zero-friction deployments to Firebase App Hosting. +- Maintain the original intent defined in docs/blueprint.md. +- Use Genkit for AI features as already configured in src/ai/. \ No newline at end of file diff --git a/src/firebase_studio/workflows/startup_workflow.md b/src/firebase_studio/workflows/startup_workflow.md new file mode 100644 index 00000000000..92c0e820d82 --- /dev/null +++ b/src/firebase_studio/workflows/startup_workflow.md @@ -0,0 +1,13 @@ +--- +name: Initial Project Setup +description: Run initial checks and fix common migration issues +--- + +# Step 1: Check Compilation +Run \`npm run typecheck\` and \`npm run build\` to ensure the project is in a healthy state. + +# Step 2: Verify Firebase Auth/Firestore +If the app uses Firebase services, ensure the environment variables are correctly set or provided via App Hosting. + +# Step 3: Cleanup Genkit config +If genkit is otherwise unused in this project, remove the configuration in src/ai/genkit.ts and remove related dependencies in package.json. From c74fd91b353b3298f441c088740b8067319e45ea Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 07:13:28 -0800 Subject: [PATCH 04/12] Added tests --- src/firebase_studio/migrate.spec.ts | 106 ++++++++++++++ src/firebase_studio/migrate.ts | 218 ++++++++++++++-------------- 2 files changed, 217 insertions(+), 107 deletions(-) create mode 100644 src/firebase_studio/migrate.spec.ts diff --git a/src/firebase_studio/migrate.spec.ts b/src/firebase_studio/migrate.spec.ts new file mode 100644 index 00000000000..45ab7108878 --- /dev/null +++ b/src/firebase_studio/migrate.spec.ts @@ -0,0 +1,106 @@ +import { expect } from "chai"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as sinon from "sinon"; +import { migrate } from "./migrate"; +import * as apphosting from "../gcp/apphosting"; +import * as prompt from "../prompt"; + +describe("migrate", () => { + let sandbox: sinon.SinonSandbox; + const testRoot = "/test/root"; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("migrate", () => { + it("should perform a full migration successfully", async function() { + this.timeout(5000); + + // Stub global fetch + const fetchStub = sandbox.stub(global, "fetch"); + + // Mock GitHub API for skills listing + fetchStub.withArgs("https://api.github.com/repos/firebase/agent-skills/contents/skills") + .resolves({ + ok: true, + json: async () => [ + { name: "test-skill", type: "dir", url: "https://api.github.com/repos/firebase/agent-skills/contents/skills/test-skill" } + ] + } as any); + + // Mock GitHub API for specific skill content + fetchStub.withArgs("https://api.github.com/repos/firebase/agent-skills/contents/skills/test-skill") + .resolves({ + ok: true, + json: async () => [] + } as any); + + // Mock GitHub API for Genkit skill content + fetchStub.withArgs("https://api.github.com/repos/genkit-ai/skills/contents/skills/developing-genkit-js?ref=main") + .resolves({ + ok: true, + json: async () => [] + } as any); + + // Mock filesystem + sandbox.stub(fs, "readFile").callsFake(async (p: any) => { + const pStr = p.toString(); + if (pStr.endsWith("metadata.json")) { + return JSON.stringify({ projectId: "test-project", appName: "Test App" }); + } + if (pStr.endsWith("readme_template.md")) { + return "# ${appName}\nExport Date: ${exportDate}\n${blueprintContent}"; + } + if (pStr.endsWith("system_instructions.md")) { + return "Project: ${appName}"; + } + if (pStr.endsWith("startup_workflow.md")) { + return "Step 1: Build"; + } + if (pStr.endsWith(".firebaserc")) { + return JSON.stringify({ projects: { default: "test-project" } }); + } + if (pStr.endsWith("blueprint.md")) { + return "# **App Name**: Test App\nSome blueprint content"; + } + throw new Error(`Unexpected readFile: ${pStr}`); + }); + + sandbox.stub(fs, "writeFile").resolves(); + sandbox.stub(fs, "mkdir").resolves(); + sandbox.stub(fs, "unlink").resolves(); + sandbox.stub(fs, "readdir").resolves([]); + sandbox.stub(fs, "access").rejects({ code: "ENOENT" }); + + // Mock App Hosting backends + sandbox.stub(apphosting, "listBackends").resolves({ + backends: [ + { name: "projects/test-project/locations/us-central1/backends/studio", uri: "example.com", servingLocality: "GLOBAL_ACCESS", labels: {}, createTime: "", updateTime: "" } + ] as any[], + unreachable: [] + }); + + // Mock prompt + sandbox.stub(prompt, "confirm").resolves(false); + + // Mock execSync + const child_process = require("child_process"); + sandbox.stub(child_process, "execSync").returns(Buffer.from("1.0.0")); + + await migrate(testRoot); + + // Verify key files were written + const writeStub = fs.writeFile as sinon.SinonStub; + + expect(writeStub.calledWith(path.join(testRoot, ".firebaserc"), sinon.match(/test-project/))).to.be.true; + expect(writeStub.calledWith(path.join(testRoot, "firebase.json"), sinon.match(/"backendId": "studio"/))).to.be.true; + expect(writeStub.calledWith(path.join(testRoot, "README.md"), sinon.match(/Test App/))).to.be.true; + }); + }); +}); diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts index 01a88fa2933..1a1f1702916 100644 --- a/src/firebase_studio/migrate.ts +++ b/src/firebase_studio/migrate.ts @@ -1,7 +1,12 @@ import * as fs from "fs/promises"; import * as path from "path"; import { execSync, spawn } from "child_process"; -import * as readline from "node:readline/promises"; + +import { logger } from "../logger"; +import { FirebaseError } from "../error"; +import * as prompt from "../prompt"; +import * as apphosting from "../gcp/apphosting"; +import * as utils from "../utils"; interface GitHubItem { name: string; @@ -49,7 +54,9 @@ async function extractMetadata(rootPath: string): Promise<{ try { const metadataContent = await fs.readFile(metadataPath, "utf8"); metadata = JSON.parse(metadataContent) as Metadata; - } catch (err) {} + } catch (err: unknown) { + logger.debug(`Could not read metadata.json at ${metadataPath}: ${err}`); + } let projectId = metadata.projectId; if (!projectId) { @@ -58,11 +65,13 @@ async function extractMetadata(rootPath: string): Promise<{ const firebasercContent = await fs.readFile(path.join(rootPath, ".firebaserc"), "utf8"); const firebaserc = JSON.parse(firebasercContent) as { projects?: { default?: string } }; projectId = firebaserc.projects?.default; - } catch (err) {} + } catch (err: unknown) { + logger.debug(`Could not read .firebaserc at ${rootPath}: ${err}`); + } } if (projectId) { - console.log(`✅ Detected Firebase Project: ${projectId}`); + logger.info(`✅ Detected Firebase Project: ${projectId}`); } else { projectId = "studio-8559296606-bdfe5"; // FIXME } @@ -77,10 +86,12 @@ async function extractMetadata(rootPath: string): Promise<{ if (nameMatch && nameMatch[1]) { appName = nameMatch[1].trim(); } - } catch (err) {} + } catch (err: unknown) { + logger.debug(`Could not read blueprint.md at ${blueprintPath}: ${err}`); + } if (appName !== "firebase-studio-export") { - console.log(`✅ Detected App Name: ${appName}`); + logger.info(`✅ Detected App Name: ${appName}`); } return { projectId, appName, blueprintContent }; @@ -95,29 +106,32 @@ async function updateReadme( const readmePath = path.join(rootPath, "README.md"); const readmeTemplate = await fs.readFile(path.join(__dirname, "readme_template.md"), "utf8"); const newReadme = readmeTemplate - .replace("${appName}", appName) - .replace("${appName}", appName) // Replace twice for name and previous name + .replace(/\${appName}/g, appName) .replace("${exportDate}", new Date().toLocaleDateString()) .replace("${blueprintContent}", blueprintContent.replace(/# \*\*App Name\*\*: .*/, "").trim()); await fs.writeFile(readmePath, newReadme); - console.log("✅ Updated README.md with project details and origin info"); + logger.info("✅ Updated README.md with project details and origin info"); // Remove docs/blueprint.md and empty docs directory const docsDir = path.join(rootPath, "docs"); const blueprintPath = path.join(docsDir, "blueprint.md"); try { await fs.unlink(blueprintPath); - console.log("✅ Cleaned up docs/blueprint.md"); - } catch (err) {} + logger.info("✅ Cleaned up docs/blueprint.md"); + } catch (err: unknown) { + logger.debug(`Could not delete ${blueprintPath}: ${err}`); + } try { const files = await fs.readdir(docsDir); if (files.length === 0) { await fs.rmdir(docsDir); - console.log("✅ Removed empty docs directory"); + logger.info("✅ Removed empty docs directory"); } - } catch (err) {} + } catch (err: unknown) { + logger.debug(`Could not remove ${docsDir}: ${err}`); + } } async function injectAgyContext(rootPath: string, projectId: string, appName: string): Promise { @@ -131,7 +145,7 @@ async function injectAgyContext(rootPath: string, projectId: string, appName: st await fs.mkdir(skillsDir, { recursive: true }); // Download Skills from GitHub - console.log("⏳ Fetching AGY skills from firebase/agent-skills..."); + logger.info("⏳ Fetching AGY skills from firebase/agent-skills..."); try { const skillsResponse = await fetch( "https://api.github.com/repos/firebase/agent-skills/contents/skills", @@ -151,27 +165,26 @@ async function injectAgyContext(rootPath: string, projectId: string, appName: st } } } else { - console.warn("⚠️ GitHub API response for skills is not an array."); + utils.logWarning("GitHub API response for skills is not an array."); } - console.log(`✅ Downloaded Firebase skills`); - } catch (err: any) { - console.warn("⚠️ Could not download AGY skills, skipping.", err.message); + logger.info(`✅ Downloaded Firebase skills`); + } catch (err: unknown) { + utils.logWarning(`Could not download AGY skills, skipping. ${err}`); } - // Download Genkit skill - console.log("⏳ Fetching Genkit skill..."); + logger.info("⏳ Fetching Genkit skill..."); try { const genkitSkillDir = path.join(skillsDir, "developing-genkit-js"); await downloadGitHubDir( "https://api.github.com/repos/genkit-ai/skills/contents/skills/developing-genkit-js?ref=main", genkitSkillDir, ); - console.log(`✅ Downloaded Genkit skill`); - } catch (err: any) { - console.warn("⚠️ Could not download Genkit skill, skipping.", err.message); + logger.info(`✅ Downloaded Genkit skill`); + } catch (err: unknown) { + utils.logWarning(`Could not download Genkit skill, skipping. ${err}`); } - + // System Instructions const systemInstructionsTemplate = await fs.readFile( path.join(__dirname, "system_instructions.md"), @@ -182,40 +195,32 @@ async function injectAgyContext(rootPath: string, projectId: string, appName: st .replace("${appName}", appName); await fs.writeFile(path.join(rulesDir, "migration-context.md"), systemInstructions); - console.log("✅ Injected AGY rules"); + logger.info("✅ Injected AGY rules"); // Startup Workflow - const startupWorkflow = await fs.readFile( - path.join(__dirname, "workflows", "startup_workflow.md"), - "utf8", - ); - await fs.writeFile(path.join(workflowsDir, "startup.md"), startupWorkflow); - console.log("✅ Created AGY startup workflow"); -} - -async function assertSystemState(): Promise { - // Assertion: Check for firebase-tools try { - execSync("firebase --version", { stdio: "ignore" }); - console.log("✅ Firebase CLI detected"); - } catch (err) { - console.error("❌ Error: Firebase CLI (firebase-tools) is not installed or not in your PATH."); - console.error("👉 Please install it using: npm install -g firebase-tools"); - process.exit(1); + const startupWorkflow = await fs.readFile( + path.join(__dirname, "workflows", "startup_workflow.md"), + "utf8", + ); + await fs.writeFile(path.join(workflowsDir, "startup.md"), startupWorkflow); + logger.info("✅ Created AGY startup workflow"); + } catch (err: unknown) { + logger.debug(`Could not read or write startup workflow: ${err}`); } +} +async function assertSystemState(): Promise { // Assertion: Check for Antigravity (agy) try { execSync("agy --version", { stdio: "ignore" }); - console.log("✅ Antigravity IDE CLI (agy) detected"); - } catch (err) { + logger.info("✅ Antigravity IDE CLI (agy) detected"); + } catch (err: unknown) { const downloadLink = "https://antigravity.google/download"; - - console.warn("⚠️ Warning: Antigravity IDE CLI (agy) not found in your PATH."); - console.warn( - `👉 To ensure a seamless migration, please download and install Antigravity: ${downloadLink}`, + throw new FirebaseError( + `Antigravity IDE CLI (agy) not found in your PATH. To ensure a seamless migration, please download and install Antigravity: ${downloadLink}`, + { exit: 1 }, ); - process.exit(1); } } @@ -233,48 +238,53 @@ async function createFirebaseConfigs(rootPath: string, projectId: string): Promi }, }; await fs.writeFile(path.join(rootPath, ".firebaserc"), JSON.stringify(firebaserc, null, 2)); - console.log("✅ Created .firebaserc"); + logger.info("✅ Created .firebaserc"); // firebase.json (App Hosting) const firebaseJsonPath = path.join(rootPath, "firebase.json"); try { await fs.access(firebaseJsonPath); - console.log("ℹ️ firebase.json already exists, skipping creation."); + logger.info("ℹ️ firebase.json already exists, skipping creation."); } catch { let backendId = "studio"; // Default try { - console.log(`⏳ Fetching App Hosting backends for project ${projectId}...`); - const backendsOutput = execSync( - `firebase apphosting:backends:list --project=${projectId} --json`, - { encoding: "utf8" }, - ); - const backendsData = JSON.parse(backendsOutput) as { result?: Backend[] }; - const backends = backendsData.result || []; + 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.displayName?.toLowerCase() === "studio", + (b) => b.name.endsWith("/studio") || b.name.toLowerCase().includes("studio"), ); if (studioBackend) { backendId = studioBackend.name.split("/").pop()!; } else { backendId = backends[0].name.split("/").pop()!; } - console.log(`✅ Selected App Hosting backend: ${backendId}`); + logger.info(`✅ Selected App Hosting backend: ${backendId}`); } else { - console.warn('⚠️ No App Hosting backends found, using default "studio"'); + utils.logWarning('No App Hosting backends found, using default "studio"'); } - } catch (err) { - console.warn('⚠️ Could not fetch backends from Firebase CLI, using default "studio"'); + } catch (err: unknown) { + utils.logWarning(`Could not fetch backends from Firebase CLI, using default "studio". ${err}`); } const firebaseJson = { apphosting: { backendId: backendId, + ignore: [ + "node_modules", + ".git", + ".agent", + ".idx", + "firebase-debug.log", + "firebase-debug.*.log", + "functions" + ] }, }; await fs.writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 2)); - console.log(`✅ Created firebase.json with backendId: ${backendId}`); + logger.info(`✅ Created firebase.json with backendId: ${backendId}`); } } @@ -296,7 +306,7 @@ async function writeAgyConfigs(rootPath: string): Promise { ], }; await fs.writeFile(path.join(vscodeDir, "tasks.json"), JSON.stringify(tasksJson, null, 2)); - console.log("✅ Created .vscode/tasks.json"); + logger.info("✅ Created .vscode/tasks.json"); // Clean and set preferences in .vscode/settings.json const settingsPath = path.join(vscodeDir, "settings.json"); @@ -304,7 +314,9 @@ async function writeAgyConfigs(rootPath: string): Promise { try { const settingsContent = await fs.readFile(settingsPath, "utf8"); settings = JSON.parse(settingsContent) as Record; - } catch (err) {} + } catch (err: unknown) { + logger.debug(`Could not read ${settingsPath}: ${err}`); + } const cleanSettings: Record = {}; for (const [key, value] of Object.entries(settings)) { @@ -317,7 +329,7 @@ async function writeAgyConfigs(rootPath: string): Promise { cleanSettings["workbench.startupEditor"] = "readme"; await fs.writeFile(settingsPath, JSON.stringify(cleanSettings, null, 2)); - console.log("✅ Updated .vscode/settings.json with startup preferences"); + logger.info("✅ Updated .vscode/settings.json with startup preferences"); const launchJson = { version: "0.2.0", @@ -335,7 +347,7 @@ async function writeAgyConfigs(rootPath: string): Promise { ], }; await fs.writeFile(path.join(vscodeDir, "launch.json"), JSON.stringify(launchJson, null, 2)); - console.log("✅ Created .vscode/launch.json"); + logger.info("✅ Created .vscode/launch.json"); } async function askToOpenAgy( @@ -345,40 +357,33 @@ async function askToOpenAgy( ): Promise { // 8. Open in Antigravity (Optional) if (noStartAgyFlag) { - console.log( - `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, + logger.info( + '\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.', ); return; } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, + const answer = await prompt.confirm({ + message: `Migration complete for ${appName}! Would you like to open it in Antigravity now?`, + default: true, }); - try { - const answer = await rl.question( - `\n🚀 Migration complete for ${appName}! Would you like to open it in Antigravity now? (y/n): `, - ); - if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { - console.log(`⏳ Opening ${appName} in Antigravity...`); - try { - const agyProcess = spawn("agy", ["."], { - cwd: rootPath, - stdio: "ignore", - detached: true, - }); - agyProcess.unref(); - } catch (err) { - console.warn("⚠️ Could not open Antigravity IDE automatically. Please open it manually."); - } - } else { - console.log( - `\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.`, - ); + if (answer) { + logger.info(`⏳ Opening ${appName} in Antigravity...`); + try { + const agyProcess = spawn("agy", ["."], { + cwd: rootPath, + stdio: "ignore", + detached: true, + }); + agyProcess.unref(); + } catch (err: unknown) { + utils.logWarning("Could not open Antigravity IDE automatically. Please open it manually."); } - } finally { - rl.close(); + } else { + logger.info( + '\n👉 Next steps: Open this folder in Antigravity and run the "Initial Project Setup" workflow.', + ); } } @@ -386,7 +391,7 @@ export async function migrate(rootPath: string): Promise { const args = process.argv.slice(2); const noStartAgyFlag = args.includes("--nostart_agy"); - console.log("🚀 Starting Firebase Studio to Antigravity migration..."); + logger.info("🚀 Starting Firebase Studio to Antigravity migration..."); await assertSystemState(); @@ -403,26 +408,25 @@ export async function migrate(rootPath: string): Promise { const metadataPath = path.join(rootPath, "metadata.json"); try { await fs.unlink(metadataPath); - console.log("✅ Cleaned up metadata.json"); - } catch (err) {} + logger.info("✅ Cleaned up metadata.json"); + } catch (err: unknown) { + logger.debug(`Could not delete ${metadataPath}: ${err}`); + } const modifiedPath = path.join(rootPath, ".modified"); try { await fs.unlink(modifiedPath); - console.log("✅ Cleaned up .modified"); - } catch (err) {} - - // 7. Folder Renaming (Optional/Attempt) - // Note: This might fail if the script is running inside the folder + logger.info("✅ Cleaned up .modified"); + } catch (err: unknown) { + logger.debug(`Could not delete ${modifiedPath}: ${err}`); + } // Suggest renaming if we are in the 'download' folder const currentFolderName = path.basename(rootPath); if (currentFolderName === "download") { - console.log( - `\n💡 Tip: You might want to rename this folder to "${appName - .toLowerCase() - .replace(/\\s+/g, "-")}"`, + logger.info( + `\n💡 Tip: You might want to rename this folder to "${appName.toLowerCase().replace(/\s+/g, "-")}"`, ); } await askToOpenAgy(rootPath, appName, noStartAgyFlag); -} \ No newline at end of file +} From fc3e9504cb9000c12578f753eca3948f0a99196c Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 11:39:54 -0800 Subject: [PATCH 05/12] Cleanup --- src/firebase_studio/migrate.ts | 107 ++++++++++++++++----------------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts index 1a1f1702916..15bf913beaa 100644 --- a/src/firebase_studio/migrate.ts +++ b/src/firebase_studio/migrate.ts @@ -15,6 +15,7 @@ interface GitHubItem { download_url: string; } +// TODO revisit quota limits async function downloadGitHubDir(apiUrl: string, localPath: string): Promise { const response = await fetch(apiUrl); if (!response.ok) { @@ -44,11 +45,11 @@ interface Metadata { } async function extractMetadata(rootPath: string): Promise<{ - projectId: string; + projectId: string | undefined; appName: string; blueprintContent: string; }> { - // 1. Verify export & Extract Metadata + // Verify export & Extract Metadata const metadataPath = path.join(rootPath, "metadata.json"); let metadata: Metadata = {}; try { @@ -60,7 +61,7 @@ async function extractMetadata(rootPath: string): Promise<{ let projectId = metadata.projectId; if (!projectId) { - // try to get from .firebaserc + // try to get project ID from .firebaserc try { const firebasercContent = await fs.readFile(path.join(rootPath, ".firebaserc"), "utf8"); const firebaserc = JSON.parse(firebasercContent) as { projects?: { default?: string } }; @@ -73,10 +74,11 @@ async function extractMetadata(rootPath: string): Promise<{ if (projectId) { logger.info(`✅ Detected Firebase Project: ${projectId}`); } else { - projectId = "studio-8559296606-bdfe5"; // FIXME + // TODO need a mitigation here + logger.info(`✅ Failed to determine the Firebase Project ID`); } - // 2. Extract App Name and Blueprint Content + // Extract App Name and Blueprint Content let appName = "firebase-studio-export"; let blueprintContent = ""; const blueprintPath = path.join(rootPath, "docs", "blueprint.md"); @@ -112,29 +114,9 @@ async function updateReadme( await fs.writeFile(readmePath, newReadme); logger.info("✅ Updated README.md with project details and origin info"); - - // Remove docs/blueprint.md and empty docs directory - const docsDir = path.join(rootPath, "docs"); - const blueprintPath = path.join(docsDir, "blueprint.md"); - try { - await fs.unlink(blueprintPath); - logger.info("✅ Cleaned up docs/blueprint.md"); - } catch (err: unknown) { - logger.debug(`Could not delete ${blueprintPath}: ${err}`); - } - - try { - const files = await fs.readdir(docsDir); - if (files.length === 0) { - await fs.rmdir(docsDir); - logger.info("✅ Removed empty docs directory"); - } - } catch (err: unknown) { - logger.debug(`Could not remove ${docsDir}: ${err}`); - } } -async function injectAgyContext(rootPath: string, projectId: string, appName: string): Promise { +async function injectAgyContext(rootPath: string, projectId: string | undefined, appName: string): Promise { const agentDir = path.join(rootPath, ".agent"); const rulesDir = path.join(agentDir, "rules"); const workflowsDir = path.join(agentDir, "workflows"); @@ -191,7 +173,7 @@ async function injectAgyContext(rootPath: string, projectId: string, appName: st "utf8", ); const systemInstructions = systemInstructionsTemplate - .replace("${projectId}", projectId || "") + .replace("${projectId}", projectId || "None") .replace("${appName}", appName); await fs.writeFile(path.join(rulesDir, "migration-context.md"), systemInstructions); @@ -224,14 +206,10 @@ async function assertSystemState(): Promise { } } -interface Backend { - name: string; - displayName?: string; -} - -async function createFirebaseConfigs(rootPath: string, projectId: string): Promise { - // 3. Create Firebase Configs - // .firebaserc +async function createFirebaseConfigs(rootPath: string, projectId: string | undefined): Promise { + if (!projectId) { + return; + } const firebaserc = { projects: { default: projectId, @@ -350,6 +328,44 @@ async function writeAgyConfigs(rootPath: string): Promise { logger.info("✅ Created .vscode/launch.json"); } +async function cleanupUnusedFiles(rootPath: string): Promise { + + // Remove docs/blueprint.md and empty docs directory + const docsDir = path.join(rootPath, "docs"); + const blueprintPath = path.join(docsDir, "blueprint.md"); + try { + await fs.unlink(blueprintPath); + logger.info("✅ Cleaned up docs/blueprint.md"); + } catch (err: unknown) { + logger.debug(`Could not delete ${blueprintPath}: ${err}`); + } + + try { + const files = await fs.readdir(docsDir); + if (files.length === 0) { + await fs.rmdir(docsDir); + logger.info("✅ Removed empty docs directory"); + } + } catch (err: unknown) { + logger.debug(`Could not remove ${docsDir}: ${err}`); + } + + const metadataPath = path.join(rootPath, "metadata.json"); + try { + await fs.unlink(metadataPath); + logger.info("✅ Cleaned up metadata.json"); + } catch (err: unknown) { + logger.debug(`Could not delete ${metadataPath}: ${err}`); + } + + const modifiedPath = path.join(rootPath, ".modified"); + try { + await fs.unlink(modifiedPath); + logger.info("✅ Cleaned up .modified"); + } catch (err: unknown) { + logger.debug(`Could not delete ${modifiedPath}: ${err}`); + } +} async function askToOpenAgy( rootPath: string, appName: string, @@ -398,28 +414,10 @@ export async function migrate(rootPath: string): Promise { const { projectId, appName, blueprintContent } = await extractMetadata(rootPath); await updateReadme(rootPath, blueprintContent, appName); - await createFirebaseConfigs(rootPath, projectId); - - // 4. Inject AGY Context await injectAgyContext(rootPath, projectId, appName); await writeAgyConfigs(rootPath); - // 6. Cleanup - const metadataPath = path.join(rootPath, "metadata.json"); - try { - await fs.unlink(metadataPath); - logger.info("✅ Cleaned up metadata.json"); - } catch (err: unknown) { - logger.debug(`Could not delete ${metadataPath}: ${err}`); - } - - const modifiedPath = path.join(rootPath, ".modified"); - try { - await fs.unlink(modifiedPath); - logger.info("✅ Cleaned up .modified"); - } catch (err: unknown) { - logger.debug(`Could not delete ${modifiedPath}: ${err}`); - } + await cleanupUnusedFiles(rootPath); // Suggest renaming if we are in the 'download' folder const currentFolderName = path.basename(rootPath); @@ -428,5 +426,6 @@ export async function migrate(rootPath: string): Promise { `\n💡 Tip: You might want to rename this folder to "${appName.toLowerCase().replace(/\s+/g, "-")}"`, ); } + await askToOpenAgy(rootPath, appName, noStartAgyFlag); } From bbd044d2234447ce3a932a14f8c8b10b7b294271 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 12:20:55 -0800 Subject: [PATCH 06/12] Lint --- src/commands/studio-export.ts | 3 +- src/firebase_studio/migrate.spec.ts | 59 +++++++++++++------ src/firebase_studio/migrate.ts | 29 ++++++--- src/firebase_studio/readme_template.md | 1 + src/firebase_studio/system_instructions.md | 4 +- .../workflows/startup_workflow.md | 3 + 6 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/commands/studio-export.ts b/src/commands/studio-export.ts index 5a3cf31abfd..9665b47358a 100644 --- a/src/commands/studio-export.ts +++ b/src/commands/studio-export.ts @@ -7,9 +7,10 @@ import * as experiments from "../experiments"; export const command = new Command("studio:export [path]") .description("export Firebase Studio apps for migration to Antigravity") + .option("--no-start-agy", "skip starting Antigravity IDE after migration") .action(async (exportPath: string | undefined, options: Options) => { experiments.assertEnabled("studioexport", "export Studio apps"); const rootPath = path.resolve(exportPath || options.cwd || process.cwd()); logger.info(`Exporting Studio apps from ${rootPath} to Antigravity...`); - await migrate(rootPath); + await migrate(rootPath, { noStartAgy: options.noStartAgy }); }); diff --git a/src/firebase_studio/migrate.spec.ts b/src/firebase_studio/migrate.spec.ts index 45ab7108878..a35d80f26da 100644 --- a/src/firebase_studio/migrate.spec.ts +++ b/src/firebase_studio/migrate.spec.ts @@ -19,33 +19,40 @@ describe("migrate", () => { }); describe("migrate", () => { - it("should perform a full migration successfully", async function() { - this.timeout(5000); - + it("should perform a full migration successfully", async () => { // Stub global fetch const fetchStub = sandbox.stub(global, "fetch"); - + // Mock GitHub API for skills listing - fetchStub.withArgs("https://api.github.com/repos/firebase/agent-skills/contents/skills") + fetchStub + .withArgs("https://api.github.com/repos/firebase/agent-skills/contents/skills") .resolves({ ok: true, json: async () => [ - { name: "test-skill", type: "dir", url: "https://api.github.com/repos/firebase/agent-skills/contents/skills/test-skill" } - ] + { + name: "test-skill", + type: "dir", + url: "https://api.github.com/repos/firebase/agent-skills/contents/skills/test-skill", + }, + ], } as any); // Mock GitHub API for specific skill content - fetchStub.withArgs("https://api.github.com/repos/firebase/agent-skills/contents/skills/test-skill") + fetchStub + .withArgs("https://api.github.com/repos/firebase/agent-skills/contents/skills/test-skill") .resolves({ ok: true, - json: async () => [] + json: async () => [], } as any); // Mock GitHub API for Genkit skill content - fetchStub.withArgs("https://api.github.com/repos/genkit-ai/skills/contents/skills/developing-genkit-js?ref=main") + fetchStub + .withArgs( + "https://api.github.com/repos/genkit-ai/skills/contents/skills/developing-genkit-js?ref=main", + ) .resolves({ ok: true, - json: async () => [] + json: async () => [], } as any); // Mock filesystem @@ -81,26 +88,40 @@ describe("migrate", () => { // Mock App Hosting backends sandbox.stub(apphosting, "listBackends").resolves({ backends: [ - { name: "projects/test-project/locations/us-central1/backends/studio", uri: "example.com", servingLocality: "GLOBAL_ACCESS", labels: {}, createTime: "", updateTime: "" } + { + name: "projects/test-project/locations/us-central1/backends/studio", + uri: "example.com", + servingLocality: "GLOBAL_ACCESS", + labels: {}, + createTime: "", + updateTime: "", + }, ] as any[], - unreachable: [] + unreachable: [], }); // Mock prompt sandbox.stub(prompt, "confirm").resolves(false); // Mock execSync - const child_process = require("child_process"); - sandbox.stub(child_process, "execSync").returns(Buffer.from("1.0.0")); + const childProcess = require("child_process"); + sandbox.stub(childProcess, "execSync").returns(Buffer.from("1.0.0")); await migrate(testRoot); // Verify key files were written const writeStub = fs.writeFile as sinon.SinonStub; - - expect(writeStub.calledWith(path.join(testRoot, ".firebaserc"), sinon.match(/test-project/))).to.be.true; - expect(writeStub.calledWith(path.join(testRoot, "firebase.json"), sinon.match(/"backendId": "studio"/))).to.be.true; - expect(writeStub.calledWith(path.join(testRoot, "README.md"), sinon.match(/Test App/))).to.be.true; + + expect(writeStub.calledWith(path.join(testRoot, ".firebaserc"), sinon.match(/test-project/))) + .to.be.true; + expect( + writeStub.calledWith( + path.join(testRoot, "firebase.json"), + sinon.match(/"backendId": "studio"/), + ), + ).to.be.true; + expect(writeStub.calledWith(path.join(testRoot, "README.md"), sinon.match(/Test App/))).to.be + .true; }); }); }); diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts index 15bf913beaa..53d67565939 100644 --- a/src/firebase_studio/migrate.ts +++ b/src/firebase_studio/migrate.ts @@ -116,7 +116,11 @@ async function updateReadme( logger.info("✅ Updated README.md with project details and origin info"); } -async function injectAgyContext(rootPath: string, projectId: string | undefined, appName: string): Promise { +async function injectAgyContext( + rootPath: string, + projectId: string | undefined, + appName: string, +): Promise { const agentDir = path.join(rootPath, ".agent"); const rulesDir = path.join(agentDir, "rules"); const workflowsDir = path.join(agentDir, "workflows"); @@ -206,7 +210,10 @@ async function assertSystemState(): Promise { } } -async function createFirebaseConfigs(rootPath: string, projectId: string | undefined): Promise { +async function createFirebaseConfigs( + rootPath: string, + projectId: string | undefined, +): Promise { if (!projectId) { return; } @@ -244,7 +251,9 @@ async function createFirebaseConfigs(rootPath: string, projectId: string | undef utils.logWarning('No App Hosting backends found, using default "studio"'); } } catch (err: unknown) { - utils.logWarning(`Could not fetch backends from Firebase CLI, using default "studio". ${err}`); + utils.logWarning( + `Could not fetch backends from Firebase CLI, using default "studio". ${err}`, + ); } const firebaseJson = { @@ -257,8 +266,8 @@ async function createFirebaseConfigs(rootPath: string, projectId: string | undef ".idx", "firebase-debug.log", "firebase-debug.*.log", - "functions" - ] + "functions", + ], }, }; await fs.writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 2)); @@ -329,7 +338,6 @@ async function writeAgyConfigs(rootPath: string): Promise { } async function cleanupUnusedFiles(rootPath: string): Promise { - // Remove docs/blueprint.md and empty docs directory const docsDir = path.join(rootPath, "docs"); const blueprintPath = path.join(docsDir, "blueprint.md"); @@ -403,9 +411,12 @@ async function askToOpenAgy( } } -export async function migrate(rootPath: string): Promise { - const args = process.argv.slice(2); - const noStartAgyFlag = args.includes("--nostart_agy"); +export interface MigrateOptions { + noStartAgy?: boolean; +} + +export async function migrate(rootPath: string, options: MigrateOptions = {}): Promise { + const noStartAgyFlag = !!options.noStartAgy; logger.info("🚀 Starting Firebase Studio to Antigravity migration..."); diff --git a/src/firebase_studio/readme_template.md b/src/firebase_studio/readme_template.md index 87827144d67..a746d62648e 100644 --- a/src/firebase_studio/readme_template.md +++ b/src/firebase_studio/readme_template.md @@ -7,4 +7,5 @@ This project was migrated from Firebase Studio. ${blueprintContent} --- + To get started, run \`npm run dev\` and visit \`http://localhost:9002\`. diff --git a/src/firebase_studio/system_instructions.md b/src/firebase_studio/system_instructions.md index 0780701278e..2c6c41593ea 100644 --- a/src/firebase_studio/system_instructions.md +++ b/src/firebase_studio/system_instructions.md @@ -3,10 +3,12 @@ trigger: always_on --- # Project Context + This project was migrated from Firebase Studio. App Name: ${appName} # Migration Guidelines + - Focus on ensuring zero-friction deployments to Firebase App Hosting. - Maintain the original intent defined in docs/blueprint.md. -- Use Genkit for AI features as already configured in src/ai/. \ No newline at end of file +- Use Genkit for AI features as already configured in src/ai/. diff --git a/src/firebase_studio/workflows/startup_workflow.md b/src/firebase_studio/workflows/startup_workflow.md index 92c0e820d82..6843fede672 100644 --- a/src/firebase_studio/workflows/startup_workflow.md +++ b/src/firebase_studio/workflows/startup_workflow.md @@ -4,10 +4,13 @@ description: Run initial checks and fix common migration issues --- # Step 1: Check Compilation + Run \`npm run typecheck\` and \`npm run build\` to ensure the project is in a healthy state. # Step 2: Verify Firebase Auth/Firestore + If the app uses Firebase services, ensure the environment variables are correctly set or provided via App Hosting. # Step 3: Cleanup Genkit config + If genkit is otherwise unused in this project, remove the configuration in src/ai/genkit.ts and remove related dependencies in package.json. From 8733bba0f2447b8c5fee092a6d083a1c40a25dd2 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 12:22:08 -0800 Subject: [PATCH 07/12] Reorganize --- src/firebase_studio/migrate.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts index 53d67565939..ec7bc4f3fec 100644 --- a/src/firebase_studio/migrate.ts +++ b/src/firebase_studio/migrate.ts @@ -8,6 +8,10 @@ import * as prompt from "../prompt"; import * as apphosting from "../gcp/apphosting"; import * as utils from "../utils"; +export interface MigrateOptions { + noStartAgy?: boolean; +} + interface GitHubItem { name: string; type: "dir" | "file"; @@ -15,6 +19,11 @@ interface GitHubItem { download_url: string; } +interface Metadata { + projectId?: string; + [key: string]: any; +} + // TODO revisit quota limits async function downloadGitHubDir(apiUrl: string, localPath: string): Promise { const response = await fetch(apiUrl); @@ -39,10 +48,6 @@ async function downloadGitHubDir(apiUrl: string, localPath: string): Promise { const noStartAgyFlag = !!options.noStartAgy; From 50c4dbf0bd3115d8818d44ad3e82b7d0890cae0c Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 14:23:48 -0800 Subject: [PATCH 08/12] Flags and comment fixes --- package.json | 2 +- src/commands/studio-export.ts | 16 ++++++----- src/firebase_studio/migrate.ts | 27 +++++++++---------- .../readme_template.md | 0 .../system_instructions_template.md | 0 .../workflows/startup_workflow.md | 0 6 files changed, 23 insertions(+), 22 deletions(-) rename {src/firebase_studio => templates/firebase-studio-export}/readme_template.md (100%) rename src/firebase_studio/system_instructions.md => templates/firebase-studio-export/system_instructions_template.md (100%) rename {src/firebase_studio => templates/firebase-studio-export}/workflows/startup_workflow.md (100%) diff --git a/package.json b/package.json index 824953843b1..412cb342dd2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build:publish": "tsc --build tsconfig.publish.json && npm run copyfiles", "build:watch": "npm run build && tsc --watch", "clean": "node -e \"fs.rmSync('lib', { recursive: true, force: true }); fs.rmSync('dev', { recursive: true, force: true });\"", - "copyfiles": "node -e \"const fs = require('fs'); fs.mkdirSync('./lib/firebase_studio', {recursive:true}); fs.copyFileSync('./src/dynamicImport.js', './lib/dynamicImport.js'); fs.cpSync('./src/firebase_studio', './lib/firebase_studio', {recursive: true, filter: (src) => fs.statSync(src).isDirectory() || src.endsWith('.md')});\"", + "copyfiles": "node -e \"const fs = require('fs'); fs.copyFileSync('./src/dynamicImport.js', './lib/dynamicImport.js'); fs.cpSync('./src/firebase_studio', './lib/firebase_studio', {recursive: true, filter: (src) => fs.statSync(src).isDirectory() || src.endsWith('.md') || src.endsWith('.js')});\"", "format": "npm run format:ts && npm run format:other", "format:other": "npm run lint:other -- --write", "format:ts": "npm run lint:ts -- --fix --quiet", diff --git a/src/commands/studio-export.ts b/src/commands/studio-export.ts index 9665b47358a..ce5540fb72a 100644 --- a/src/commands/studio-export.ts +++ b/src/commands/studio-export.ts @@ -4,13 +4,17 @@ import { Options } from "../options"; import { migrate } from "../firebase_studio/migrate"; import * as path from "path"; import * as experiments from "../experiments"; +import { FirebaseError } from "../error"; -export const command = new Command("studio:export [path]") - .description("export Firebase Studio apps for migration to Antigravity") - .option("--no-start-agy", "skip starting Antigravity IDE after migration") - .action(async (exportPath: string | undefined, options: Options) => { +export const command = new Command("studio:export ") + .description("Bootstrap Firebase Studio apps for migration to Antigravity. Run on the unzipped folder from the Firebase Studio download.") + .option("--no-start-agy", "skip starting the Antigravity IDE after migration") + .action(async (exportPath: string, options: Options) => { experiments.assertEnabled("studioexport", "export Studio apps"); - const rootPath = path.resolve(exportPath || options.cwd || process.cwd()); + if (!exportPath) { + throw new FirebaseError("Must specify a path for migration.", { exit: 1 }); + } + const rootPath = path.resolve(exportPath); logger.info(`Exporting Studio apps from ${rootPath} to Antigravity...`); - await migrate(rootPath, { noStartAgy: options.noStartAgy }); + await migrate(rootPath, { noStartAgy: !options.startAgy }); }); diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts index ec7bc4f3fec..1d6cc649517 100644 --- a/src/firebase_studio/migrate.ts +++ b/src/firebase_studio/migrate.ts @@ -7,9 +7,10 @@ import { FirebaseError } from "../error"; import * as prompt from "../prompt"; import * as apphosting from "../gcp/apphosting"; import * as utils from "../utils"; +import { readTemplate } from "../templates"; export interface MigrateOptions { - noStartAgy?: boolean; + noStartAgy: boolean; } interface GitHubItem { @@ -104,6 +105,7 @@ async function extractMetadata(rootPath: string): Promise<{ return { projectId, appName, blueprintContent }; } + async function updateReadme( rootPath: string, blueprintContent: string, @@ -111,10 +113,10 @@ async function updateReadme( ): Promise { // Update README.md const readmePath = path.join(rootPath, "README.md"); - const readmeTemplate = await fs.readFile(path.join(__dirname, "readme_template.md"), "utf8"); + const readmeTemplate = await readTemplate("firebase-studio-export/readme_template.md"); const newReadme = readmeTemplate .replace(/\${appName}/g, appName) - .replace("${exportDate}", new Date().toLocaleDateString()) + .replace("${exportDate}", new Date().toISOString().split("T")[0]) // YYYY-MM-DD format .replace("${blueprintContent}", blueprintContent.replace(/# \*\*App Name\*\*: .*/, "").trim()); await fs.writeFile(readmePath, newReadme); @@ -177,9 +179,8 @@ async function injectAgyContext( } // System Instructions - const systemInstructionsTemplate = await fs.readFile( - path.join(__dirname, "system_instructions.md"), - "utf8", + const systemInstructionsTemplate = await readTemplate( + "firebase-studio-export/system_instructions_template.md", ); const systemInstructions = systemInstructionsTemplate .replace("${projectId}", projectId || "None") @@ -190,9 +191,8 @@ async function injectAgyContext( // Startup Workflow try { - const startupWorkflow = await fs.readFile( - path.join(__dirname, "workflows", "startup_workflow.md"), - "utf8", + const startupWorkflow = await readTemplate( + "firebase-studio-export/workflows/startup_workflow.md", ); await fs.writeFile(path.join(workflowsDir, "startup.md"), startupWorkflow); logger.info("✅ Created AGY startup workflow"); @@ -379,7 +379,7 @@ async function cleanupUnusedFiles(rootPath: string): Promise { logger.debug(`Could not delete ${modifiedPath}: ${err}`); } } -async function askToOpenAgy( +async function askToOpenAntigravity( rootPath: string, appName: string, noStartAgyFlag: boolean, @@ -416,10 +416,7 @@ async function askToOpenAgy( } } - -export async function migrate(rootPath: string, options: MigrateOptions = {}): Promise { - const noStartAgyFlag = !!options.noStartAgy; - +export async function migrate(rootPath: string, options: MigrateOptions = { noStartAgy: false }): Promise { logger.info("🚀 Starting Firebase Studio to Antigravity migration..."); await assertSystemState(); @@ -440,5 +437,5 @@ export async function migrate(rootPath: string, options: MigrateOptions = {}): P ); } - await askToOpenAgy(rootPath, appName, noStartAgyFlag); + await askToOpenAntigravity(rootPath, appName, options.noStartAgy); } diff --git a/src/firebase_studio/readme_template.md b/templates/firebase-studio-export/readme_template.md similarity index 100% rename from src/firebase_studio/readme_template.md rename to templates/firebase-studio-export/readme_template.md diff --git a/src/firebase_studio/system_instructions.md b/templates/firebase-studio-export/system_instructions_template.md similarity index 100% rename from src/firebase_studio/system_instructions.md rename to templates/firebase-studio-export/system_instructions_template.md diff --git a/src/firebase_studio/workflows/startup_workflow.md b/templates/firebase-studio-export/workflows/startup_workflow.md similarity index 100% rename from src/firebase_studio/workflows/startup_workflow.md rename to templates/firebase-studio-export/workflows/startup_workflow.md From ce7481284acf141f2ee46ff18ef806aa4c4202f0 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 14:36:14 -0800 Subject: [PATCH 09/12] Lint --- src/commands/studio-export.ts | 4 +++- src/firebase_studio/migrate.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/studio-export.ts b/src/commands/studio-export.ts index ce5540fb72a..edb57fef728 100644 --- a/src/commands/studio-export.ts +++ b/src/commands/studio-export.ts @@ -7,7 +7,9 @@ import * as experiments from "../experiments"; import { FirebaseError } from "../error"; export const command = new Command("studio:export ") - .description("Bootstrap Firebase Studio apps for migration to Antigravity. Run on the unzipped folder from the Firebase Studio download.") + .description( + "Bootstrap Firebase Studio apps for migration to Antigravity. Run on the unzipped folder from the Firebase Studio download.", + ) .option("--no-start-agy", "skip starting the Antigravity IDE after migration") .action(async (exportPath: string, options: Options) => { experiments.assertEnabled("studioexport", "export Studio apps"); diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts index 1d6cc649517..69041d408ff 100644 --- a/src/firebase_studio/migrate.ts +++ b/src/firebase_studio/migrate.ts @@ -49,7 +49,6 @@ async function downloadGitHubDir(apiUrl: string, localPath: string): Promise { +export async function migrate( + rootPath: string, + options: MigrateOptions = { noStartAgy: false }, +): Promise { logger.info("🚀 Starting Firebase Studio to Antigravity migration..."); await assertSystemState(); From 9df8c7ec764aa21209ade4e85646bb4bc9776b5d Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 14:46:07 -0800 Subject: [PATCH 10/12] shrinkwrap --- npm-shrinkwrap.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index dc70a10d38f..cd72a28c489 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -26680,7 +26680,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "requires": { - "ajv": "^8.0.0" + "ajv": "^8.17.1" } }, "ansi-align": { From 87feb76cde714dc02c451800b8947daa63888021 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 15:08:46 -0800 Subject: [PATCH 11/12] Test fix --- src/firebase_studio/migrate.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firebase_studio/migrate.spec.ts b/src/firebase_studio/migrate.spec.ts index a35d80f26da..6921d7d887f 100644 --- a/src/firebase_studio/migrate.spec.ts +++ b/src/firebase_studio/migrate.spec.ts @@ -64,7 +64,7 @@ describe("migrate", () => { if (pStr.endsWith("readme_template.md")) { return "# ${appName}\nExport Date: ${exportDate}\n${blueprintContent}"; } - if (pStr.endsWith("system_instructions.md")) { + if (pStr.endsWith("system_instructions_template.md")) { return "Project: ${appName}"; } if (pStr.endsWith("startup_workflow.md")) { From 15a5fe674b4f68e36bf1d78bbb8db09b9a2f0787 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 25 Feb 2026 15:11:21 -0800 Subject: [PATCH 12/12] Merge --- src/commands/studio-export.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/commands/studio-export.ts b/src/commands/studio-export.ts index d40d3a8acf0..edb57fef728 100644 --- a/src/commands/studio-export.ts +++ b/src/commands/studio-export.ts @@ -1,6 +1,5 @@ import { Command } from "../command"; import { logger } from "../logger"; -<<<<<<< exportLogic import { Options } from "../options"; import { migrate } from "../firebase_studio/migrate"; import * as path from "path"; @@ -20,14 +19,4 @@ export const command = new Command("studio:export ") const rootPath = path.resolve(exportPath); logger.info(`Exporting Studio apps from ${rootPath} to Antigravity...`); await migrate(rootPath, { noStartAgy: !options.startAgy }); -======= -import * as experiments from "../experiments"; - -export const command = new Command("studio:export") - .description("export Firebase Studio apps to continue development locally") - .action(() => { - experiments.assertEnabled("studioexport", "export Studio apps"); - logger.info("Exporting Studio apps to Antigravity..."); - // TODO: implement export logic ->>>>>>> main });