diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0f97d7814..8a90ff7df84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,5 @@ - `firebase_update_environment` MCP tool supports accepting Gemini in Firebase Terms of Service. - Fixed a bug when `firebase init dataconnect` failed to create a React app when launched from VS Code extension (#9171). - Improved the clarity of the `firebase apptesting:execute` command when you have zero or multiple apps. +- Added 'emulators' to `firebase_init` MCP tool. - `firebase dataconnect:sql:migrate` now supports Cloud SQL instances with only private IPs. The command must be run in the same VPC of the instance to work. (##9200) diff --git a/src/init/features/emulators.ts b/src/init/features/emulators.ts index f7ba12f12d2..00b0fef0622 100644 --- a/src/init/features/emulators.ts +++ b/src/init/features/emulators.ts @@ -9,105 +9,138 @@ import { AdditionalInitFns } from "../../emulator/initEmulators"; import { Config } from "../../config"; import { EmulatorsConfig } from "../../firebaseConfig"; -interface EmulatorsInitSelections { - emulators?: Emulators[]; - download?: boolean; +export interface RequiredInfo { + emulators: Emulators[]; + download: boolean; + config: EmulatorsConfig; } -export async function doSetup(setup: Setup, config: Config) { +export async function askQuestions(setup: Setup, config: Config): Promise { const choices = ALL_SERVICE_EMULATORS.map((e) => { return { value: e, - // TODO: latest versions of inquirer have a name vs description. - // We should learn more and whether it's worth investing in. name: Constants.description(e), checked: config?.has(e) || config?.has(`emulators.${e}`), }; }); - const selections: EmulatorsInitSelections = {}; - selections.emulators = await checkbox({ + const selectedEmulators = await checkbox({ message: "Which Firebase emulators do you want to set up? " + "Press Space to select emulators, then Enter to confirm your choices.", choices: choices, }); - if (!selections.emulators) { + if (!selectedEmulators || !selectedEmulators.length) { return; } - setup.config.emulators = setup.config.emulators || {}; - const emulators: EmulatorsConfig = setup.config.emulators || {}; - for (const selected of selections.emulators) { - if (selected === "extensions") continue; - const selectedEmulator = emulators[selected] || {}; + setup.featureInfo = setup.featureInfo || {}; + const emulatorsInfo: RequiredInfo = { + emulators: selectedEmulators, + config: {}, + download: false, + }; + setup.featureInfo.emulators = emulatorsInfo; + + const newConfig = emulatorsInfo.config; + const existingConfig = setup.config.emulators || {}; - const currentPort = selectedEmulator.port; + for (const selected of selectedEmulators) { + if (selected === "extensions") continue; + newConfig[selected] = {}; + const currentPort = existingConfig[selected]?.port; if (currentPort) { utils.logBullet(`Port for ${selected} already configured: ${clc.cyan(currentPort)}`); } else { - selectedEmulator.port = await number({ + newConfig[selected]!.port = await number({ message: `Which port do you want to use for the ${clc.underline(selected)} emulator?`, default: Constants.getDefaultPort(selected), }); } - emulators[selected] = selectedEmulator; const additionalInitFn = AdditionalInitFns[selected]; if (additionalInitFn) { const additionalOptions = await additionalInitFn(config); if (additionalOptions) { - emulators[selected] = { - ...setup.config.emulators[selected], - ...additionalOptions, - }; + Object.assign(newConfig[selected]!, additionalOptions); } } } - if (selections.emulators.length) { + if (selectedEmulators.length) { const uiDesc = Constants.description(Emulators.UI); - if (setup.config.emulators.ui && setup.config.emulators.ui.enabled !== false) { - const currentPort = setup.config.emulators.ui.port || "(automatic)"; - utils.logBullet(`${uiDesc} already enabled with port: ${clc.cyan(currentPort)}`); - } else { - const ui = setup.config.emulators.ui || {}; - setup.config.emulators.ui = ui; + const existingUiConfig = existingConfig.ui || {}; + newConfig.ui = {}; - ui.enabled = await confirm({ + let enableUi: boolean; + if (existingUiConfig.enabled !== undefined) { + utils.logBullet(`${uiDesc} already ${existingUiConfig.enabled ? "enabled" : "disabled"}.`); + enableUi = existingUiConfig.enabled; + } else { + enableUi = await confirm({ message: `Would you like to enable the ${uiDesc}?`, default: true, }); + } + newConfig.ui.enabled = enableUi; - if (ui.enabled) { - ui.port = await number({ + if (newConfig.ui.enabled) { + const currentPort = existingUiConfig.port; + if (currentPort) { + utils.logBullet(`Port for ${uiDesc} already configured: ${clc.cyan(currentPort)}`); + } else { + newConfig.ui.port = await number({ message: `Which port do you want to use for the ${clc.underline(uiDesc)} (leave empty to use any available port)?`, required: false, }); } } + } - selections.download = await confirm({ + if (selectedEmulators.length) { + emulatorsInfo.download = await confirm({ message: "Would you like to download the emulators now?", default: true, }); } +} + +export async function actuate(setup: Setup): Promise { + const emulatorsInfo = setup.featureInfo?.emulators; + if (!emulatorsInfo) { + return; + } + + setup.config.emulators = setup.config.emulators || {}; + const emulatorsConfig = setup.config.emulators; + + // Merge the config from the questions into the main config. + for (const emulatorName of Object.keys(emulatorsInfo.config)) { + const key = emulatorName as keyof EmulatorsConfig; + if (key === "ui") { + emulatorsConfig.ui = { ...emulatorsConfig.ui, ...emulatorsInfo.config.ui }; + } else if (key === "singleProjectMode") { + emulatorsConfig.singleProjectMode = emulatorsInfo.config[key]; + } else if (emulatorsInfo.config[key]) { + emulatorsConfig[key] = { ...emulatorsConfig[key], ...emulatorsInfo.config[key] }; + } + } // Set the default behavior to be single project mode. - if (setup.config.emulators.singleProjectMode === undefined) { - setup.config.emulators.singleProjectMode = true; + if (emulatorsConfig.singleProjectMode === undefined) { + emulatorsConfig.singleProjectMode = true; } - if (selections.download) { - for (const selected of selections.emulators) { + if (emulatorsInfo.download) { + for (const selected of emulatorsInfo.emulators) { if (isDownloadableEmulator(selected)) { await downloadIfNecessary(selected); } } - if (setup?.config?.emulators?.ui?.enabled) { - downloadIfNecessary(Emulators.UI); + if (emulatorsConfig.ui?.enabled) { + await downloadIfNecessary(Emulators.UI); } } } diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 0063eae1d8b..2bc00945b91 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -16,7 +16,11 @@ export { RequiredInfo as StorageInfo, actuate as storageActuate, } from "./storage"; -export { doSetup as emulators } from "./emulators"; +export { + askQuestions as emulatorsAskQuestions, + RequiredInfo as EmulatorsInfo, + actuate as emulatorsActuate, +} from "./emulators"; export { doSetup as extensions } from "./extensions"; // always runs, sets up .firebaserc export { doSetup as project } from "./project"; diff --git a/src/init/index.ts b/src/init/index.ts index dc7171186cf..87faad423e9 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -38,6 +38,7 @@ export interface SetupInfo { dataconnectSdk?: features.DataconnectSdkInfo; storage?: features.StorageInfo; apptesting?: features.ApptestingInfo; + emulators?: features.EmulatorsInfo; } interface Feature { @@ -84,7 +85,11 @@ const featuresList: Feature[] = [ askQuestions: features.storageAskQuestions, actuate: features.storageActuate, }, - { name: "emulators", doSetup: features.emulators }, + { + name: "emulators", + askQuestions: features.emulatorsAskQuestions, + actuate: features.emulatorsActuate, + }, { name: "extensions", doSetup: features.extensions }, { name: "project", doSetup: features.project }, // always runs, sets up .firebaserc { name: "remoteconfig", doSetup: features.remoteconfig }, diff --git a/src/mcp/tools/core/init.ts b/src/mcp/tools/core/init.ts index 7ce6ab23d21..8671fbb28df 100644 --- a/src/mcp/tools/core/init.ts +++ b/src/mcp/tools/core/init.ts @@ -5,12 +5,18 @@ import { DEFAULT_RULES } from "../../../init/features/database"; import { actuate, Setup, SetupInfo } from "../../../init/index"; import { freeTrialTermsLink } from "../../../dataconnect/freeTrial"; import { requireGeminiToS } from "../../errors"; +import { Emulators } from "../../../emulator/types"; + +const emulatorHostPortSchema = z.object({ + host: z.string().optional().describe("The host to use for the emulator."), + port: z.number().optional().describe("The port to use for the emulator."), +}); export const init = tool( { name: "init", description: - "Initializes selected Firebase features in the workspace (Firestore, Data Connect, Realtime Database). All features are optional; provide only the products you wish to set up. " + + "Initializes selected Firebase features in the workspace (Firestore, Data Connect, Realtime Database, Emulators). All features are optional; provide only the products you wish to set up. " + "You can initialize new features into an existing project directory, but re-initializing an existing feature may overwrite configuration. " + "To deploy the initialized features, run the `firebase deploy` command after `firebase_init` tool.", inputSchema: z.object({ @@ -121,6 +127,31 @@ export const init = tool( .describe( "Provide this object to initialize Firebase Storage in this project directory.", ), + emulators: z + .object({ + auth: emulatorHostPortSchema.optional(), + database: emulatorHostPortSchema.optional(), + firestore: emulatorHostPortSchema.optional(), + functions: emulatorHostPortSchema.optional(), + hosting: emulatorHostPortSchema.optional(), + storage: emulatorHostPortSchema.optional(), + pubsub: emulatorHostPortSchema.optional(), + ui: z + .object({ + enabled: z.boolean().optional(), + host: z.string().optional(), + port: z.number().optional(), + }) + .optional(), + singleProjectMode: z + .boolean() + .optional() + .describe("If true, do not warn on detection of multiple project IDs."), + }) + .optional() + .describe( + "Provide this object to configure Firebase emulators in this project directory.", + ), }), }), annotations: { @@ -178,8 +209,32 @@ export const init = tool( apps: [], }; } + if (features.storage) { + featuresList.push("storage"); + featureInfo.storage = { + rulesFilename: features.storage.rules_filename, + rules: features.storage.rules || "", + writeRules: true, + }; + } + if (features.emulators) { + featuresList.push("emulators"); + const emulatorKeys = Object.keys(features.emulators).filter( + (key) => + key !== "ui" && + key !== "singleProjectMode" && + Object.values(Emulators).includes(key as Emulators), + ) as Emulators[]; + + featureInfo.emulators = { + emulators: emulatorKeys, + config: features.emulators, + download: true, // Non-interactive, so default to downloading. + }; + } + const setup: Setup = { - config: config?.src, + config: config?.src || {}, rcfile: rc?.data, projectId: projectId, features: [...featuresList],