diff --git a/src/appUtils.spec.ts b/src/appUtils.spec.ts
new file mode 100644
index 00000000000..d107265cdd4
--- /dev/null
+++ b/src/appUtils.spec.ts
@@ -0,0 +1,771 @@
+import * as mockfs from "mock-fs";
+import { expect } from "chai";
+import {
+ extractAppIdentifiersFlutter,
+ extractAppIdentifierIos,
+ extractAppIdentifiersAndroid,
+ Platform,
+ getPlatformsFromFolder,
+ detectApps,
+ App,
+} from "./appUtils";
+
+const FLUTTER_CONFIG = `
+// File generated by FlutterFire CLI.
+// ignore_for_file: type=lint
+import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
+import 'package:flutter/foundation.dart'
+ show defaultTargetPlatform, kIsWeb, TargetPlatform;
+
+class DefaultFirebaseOptions {
+ static FirebaseOptions get currentPlatform {
+ if (kIsWeb) {
+ return web;
+ }
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ return android;
+ case TargetPlatform.iOS:
+ return ios;
+ default:
+ throw UnsupportedError(
+ 'DefaultFirebaseOptions are not supported for this platform.',
+ );
+ }
+ }
+
+ static const FirebaseOptions web = FirebaseOptions(
+ apiKey: 'FAKE_WEB_API_KEY',
+ appId: '1:123456789012:web:abcdef1234567890abcdef',
+ messagingSenderId: '123456789012',
+ projectId: 'fake-project-web',
+ authDomain: 'fake-project-web.firebaseapp.com',
+ storageBucket: 'fake-project-web.firebasestorage.app',
+ );
+
+ static const FirebaseOptions android = FirebaseOptions(
+ apiKey: 'FAKE_ANDROID_API_KEY',
+ appId: '1:123456789012:android:abcdef1234567890abcdef',
+ messagingSenderId: '123456789012',
+ projectId: 'fake-project-android',
+ storageBucket: 'fake-project-android.firebasestorage.app',
+ );
+
+ static const FirebaseOptions ios = FirebaseOptions(
+ apiKey: 'FAKE_IOS_API_KEY',
+ appId: '1:123456789012:ios:abcdef1234567890abcdef',
+ messagingSenderId: '123456789012',
+ projectId: 'fake-project-ios',
+ storageBucket: 'fake-project-ios.firebasestorage.app',
+ iosBundleId: 'com.example.fakeTestsFlutter',
+ );
+}
+`;
+
+const IOS_CONFIG_1 = `
+
+
+
+ API_KEY
+ FAKE_API_KEY
+ GCM_SENDER_ID
+ FAKE_GCM_SENDER_ID
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ com.fake.ios.app
+ PROJECT_ID
+ fake-project-id
+ STORAGE_BUCKET
+ fake-project-id.firebasestorage.app
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:123456789012:ios:abcdef1234567890abcdef
+
+`;
+
+const IOS_CONFIG_2 = `
+
+
+
+ API_KEY
+ FAKE_API_KEY
+ GCM_SENDER_ID
+ FAKE_GCM_SENDER_ID
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ com.fake.ios.app.debug
+ PROJECT_ID
+ fake-project-id-debug
+ STORAGE_BUCKET
+ fake-project-id.firebasestorage.app
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:123456789012:ios:abcdef0987654321abcdef
+
+`;
+
+const ANDROID_CONFIG_1 = `
+{
+ "project_info": {
+ "project_number": "FAKE_PROJECT_NUMBER",
+ "project_id": "fake-project-android",
+ "storage_bucket": "fake-project-android.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:123456789012:android:fakeapp1id",
+ "android_client_info": {
+ "package_name": "com.fake.app1"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "FAKE_API_KEY_1"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}`;
+
+const ANDROID_CONFIG_2 = `
+{
+ "project_info": {
+ "project_number": "FAKE_PROJECT_NUMBER",
+ "project_id": "fake-project-android",
+ "storage_bucket": "fake-project-android.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:123456789012:android:fakeapp2id",
+ "android_client_info": {
+ "package_name": "com.fake.app1.debug"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "FAKE_API_KEY_2"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}`;
+
+const ANDROID_CONFIG_COMBINED = `{
+ "project_info": {
+ "project_number": "FAKE_PROJECT_NUMBER",
+ "project_id": "fake-project-android",
+ "storage_bucket": "fake-project-android.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:123456789012:android:fakeapp1id",
+ "android_client_info": {
+ "package_name": "com.fake.app1"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "FAKE_API_KEY_1"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:123456789012:android:fakeapp2id",
+ "android_client_info": {
+ "package_name": "com.fake.app1.debug"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "FAKE_API_KEY_2"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}`;
+
+function cleanUndefinedFields(apps: App[]): App[] {
+ return apps.map((app) => {
+ const leanApp = Object.fromEntries(
+ Object.entries(app).filter((entry) => entry[1] !== undefined),
+ ) as App;
+ return leanApp;
+ });
+}
+
+describe("appUtils", () => {
+ describe("getPlatformsFromFolder", () => {
+ const testDir = "test-dir";
+
+ afterEach(() => {
+ mockfs.restore();
+ });
+
+ it("should return WEB if package.json exists", async () => {
+ mockfs({ [testDir]: { "package.json": "{}" } });
+ const platforms = await getPlatformsFromFolder(testDir);
+ expect(platforms).to.have.deep.members([Platform.WEB]);
+ });
+
+ it("should return ANDROID if src/main exists", async () => {
+ mockfs({
+ [testDir]: { src: { main: {} } },
+ });
+ const platforms = await getPlatformsFromFolder(testDir);
+ expect(platforms).to.have.deep.members([Platform.ANDROID]);
+ });
+
+ it("should return IOS if .xcodeproj exists", async () => {
+ mockfs({
+ [testDir]: { "a.xcodeproj": {} },
+ });
+ const platforms = await getPlatformsFromFolder(testDir);
+ expect(platforms).to.have.deep.members([Platform.IOS]);
+ });
+
+ it("should return FLUTTER if pubspec.yaml exists", async () => {
+ mockfs({
+ [testDir]: { "pubspec.yaml": "name: test" },
+ });
+ const platforms = await getPlatformsFromFolder(testDir);
+ expect(platforms).to.have.deep.members([Platform.FLUTTER]);
+ });
+
+ it("should return FLUTTER and WEB if both identifiers exist", async () => {
+ mockfs({ [testDir]: { "package.json": "{}", "pubspec.yaml": "name: test" } });
+ const platforms = await getPlatformsFromFolder(testDir);
+ expect(platforms).to.have.deep.members([Platform.FLUTTER, Platform.WEB]);
+ });
+
+ it("should return an empty array if no identifiers exist", async () => {
+ mockfs({ [testDir]: {} });
+ const platforms = await getPlatformsFromFolder(testDir);
+ expect(platforms).to.be.empty;
+ });
+ });
+
+ describe("detectApps", () => {
+ const testDir = "test-dir";
+
+ afterEach(() => {
+ mockfs.restore();
+ });
+
+ it("should detect a web app", async () => {
+ mockfs({ [testDir]: { "package.json": "{}" } });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.WEB,
+ directory: ".",
+ frameworks: [],
+ },
+ ]);
+ });
+
+ it("should detect an android app", async () => {
+ mockfs({ [testDir]: { src: { main: {} } } });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ },
+ ]);
+ });
+
+ it("should detect an android app with Firebase", async () => {
+ mockfs({
+ [testDir]: {
+ src: { main: {} },
+ "google-services.json": ANDROID_CONFIG_1,
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ ]);
+ });
+
+ it("should detect an android app with a suffix", async () => {
+ mockfs({
+ [testDir]: {
+ src: { main: {} },
+ "google-services.json.example": ANDROID_CONFIG_1,
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ ]);
+ });
+
+ it("should detect an android app with extra words", async () => {
+ mockfs({
+ [testDir]: {
+ src: { main: {} },
+ "google-services-example.json": ANDROID_CONFIG_1,
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ ]);
+ });
+
+ it("should detect android app with multiple variants", async () => {
+ mockfs({
+ [testDir]: {
+ src: {
+ main: {},
+ release: {
+ "google-services.json": ANDROID_CONFIG_1,
+ },
+ debug: {
+ "google-services.json": ANDROID_CONFIG_2,
+ },
+ },
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ appId: "1:123456789012:android:fakeapp2id",
+ bundleId: "com.fake.app1.debug",
+ },
+ ]);
+ });
+
+ it("should detect android app with multiple app ids in the same file", async () => {
+ mockfs({
+ [testDir]: {
+ src: { main: {} },
+ "google-services.json": ANDROID_CONFIG_COMBINED,
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ {
+ platform: Platform.ANDROID,
+ directory: ".",
+ appId: "1:123456789012:android:fakeapp2id",
+ bundleId: "com.fake.app1.debug",
+ },
+ ]);
+ });
+
+ it("should detect an ios app", async () => {
+ mockfs({
+ [testDir]: {
+ "a.xcodeproj": {},
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.IOS,
+ directory: ".",
+ },
+ ]);
+ });
+
+ it("should detect an ios app with Firebase", async () => {
+ mockfs({
+ [testDir]: {
+ "a.xcodeproj": {},
+ "GoogleService-Info.plist": IOS_CONFIG_1,
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.IOS,
+ directory: ".",
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.fake.ios.app",
+ },
+ ]);
+ });
+
+ it("should detect an ios app with different suffix", async () => {
+ mockfs({
+ [testDir]: {
+ "a.xcodeproj": {},
+ "GoogleService-Info.plist.example": IOS_CONFIG_1,
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.IOS,
+ directory: ".",
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.fake.ios.app",
+ },
+ ]);
+ });
+
+ it("should detect an ios app replacing Info", async () => {
+ mockfs({
+ [testDir]: {
+ "a.xcodeproj": {},
+ "GoogleService-Prod.plist": IOS_CONFIG_1,
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.IOS,
+ directory: ".",
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.fake.ios.app",
+ },
+ ]);
+ });
+
+ it("should detect an ios app with multiple plist files", async () => {
+ mockfs({
+ [testDir]: {
+ "a.xcodeproj": {},
+ Configs: {
+ Dev: {
+ "GoogleService-Info.plist": IOS_CONFIG_1,
+ },
+ Prod: {
+ "GoogleService-Info.plist": IOS_CONFIG_2,
+ },
+ },
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.IOS,
+ directory: ".",
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.fake.ios.app",
+ },
+ {
+ platform: Platform.IOS,
+ directory: ".",
+ appId: "1:123456789012:ios:abcdef0987654321abcdef",
+ bundleId: "com.fake.ios.app.debug",
+ },
+ ]);
+ });
+
+ it("should detect a flutter app", async () => {
+ mockfs({
+ [testDir]: { "pubspec.yaml": "name: test" },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.FLUTTER,
+ directory: ".",
+ },
+ ]);
+ });
+
+ it("should detect a flutter app with Firebase", async () => {
+ mockfs({
+ [testDir]: {
+ "pubspec.yaml": "name: test",
+ lib: { "firebase_options.dart": FLUTTER_CONFIG },
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.FLUTTER,
+ directory: ".",
+ appId: "1:123456789012:web:abcdef1234567890abcdef",
+ },
+ {
+ platform: Platform.FLUTTER,
+ directory: ".",
+ appId: "1:123456789012:android:abcdef1234567890abcdef",
+ },
+ {
+ platform: Platform.FLUTTER,
+ directory: ".",
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.example.fakeTestsFlutter",
+ },
+ ]);
+ });
+ it("should detect multiple apps", async () => {
+ mockfs({
+ [testDir]: {
+ web: { "package.json": "{}" },
+ android: {
+ src: { main: {} },
+ "google-services.json": ANDROID_CONFIG_1,
+ },
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.WEB,
+ directory: `web`,
+ frameworks: [],
+ },
+ {
+ platform: Platform.ANDROID,
+ directory: `android`,
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ ]);
+ });
+
+ it("should detect the react framework", async () => {
+ mockfs({
+ [testDir]: {
+ "package.json": JSON.stringify({
+ dependencies: {
+ react: "1.0.0",
+ },
+ }),
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.WEB,
+ directory: ".",
+ frameworks: ["REACT"],
+ },
+ ]);
+ });
+
+ it("should detect the next framework", async () => {
+ mockfs({
+ [testDir]: {
+ "package.json": JSON.stringify({
+ dependencies: {
+ next: "1.0.0",
+ },
+ }),
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.WEB,
+ directory: ".",
+ frameworks: ["REACT"],
+ },
+ ]);
+ });
+
+ it("should detect the angular framework", async () => {
+ mockfs({
+ [testDir]: {
+ "package.json": JSON.stringify({
+ dependencies: {
+ "@angular/core": "1.0.0",
+ },
+ }),
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.WEB,
+ directory: ".",
+ frameworks: ["ANGULAR"],
+ },
+ ]);
+ });
+
+ it("should detect a nested web app", async () => {
+ mockfs({
+ [testDir]: {
+ web: {
+ frontend: { "package.json": "{}" },
+ },
+ },
+ });
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ expect(apps).to.have.deep.members([
+ {
+ platform: Platform.WEB,
+ directory: "web/frontend",
+ frameworks: [],
+ },
+ ]);
+ });
+
+ it("should detect multiple top-level and nested apps", async () => {
+ mockfs({
+ [testDir]: {
+ web: {
+ "package.json": "{}",
+ },
+ android: {
+ src: { main: {} },
+ "google-services.json": ANDROID_CONFIG_1,
+ },
+ ios: {
+ "a.xcodeproj": {},
+ "GoogleService-Info.plist": IOS_CONFIG_1,
+ },
+ },
+ });
+
+ const apps = cleanUndefinedFields(await detectApps(testDir));
+ const expected = [
+ {
+ platform: Platform.ANDROID,
+ directory: "android",
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ {
+ platform: Platform.IOS,
+ directory: "ios",
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.fake.ios.app",
+ },
+ {
+ platform: Platform.WEB,
+ directory: "web",
+ frameworks: [],
+ },
+ ];
+ expect(apps).to.have.deep.members(expected);
+ });
+ });
+
+ describe("extractAppIdentifiers", () => {
+ it("should extract all app IDs and bundle IDs from a firebase_options.dart file", () => {
+ const expectedIdentifiers = [
+ {
+ appId: "1:123456789012:web:abcdef1234567890abcdef",
+ bundleId: undefined,
+ },
+ {
+ appId: "1:123456789012:android:abcdef1234567890abcdef",
+ bundleId: undefined,
+ },
+ {
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.example.fakeTestsFlutter",
+ },
+ ];
+
+ const result = extractAppIdentifiersFlutter(FLUTTER_CONFIG);
+ expect(result).to.deep.equal(expectedIdentifiers);
+ });
+
+ it("should extract the GOOGLE_APP_ID and BUNDLE_ID from a GoogleService-Info.plist file", () => {
+ const expectedIdentifier = [
+ {
+ appId: "1:123456789012:ios:abcdef1234567890abcdef",
+ bundleId: "com.fake.ios.app",
+ },
+ ];
+
+ const result = extractAppIdentifierIos(IOS_CONFIG_1);
+ expect(result).to.deep.equal(expectedIdentifier);
+ });
+
+ it("should extract all mobilesdk_app_id and package_name from a google-services.json file", () => {
+ const expectedIdentifiers = [
+ {
+ appId: "1:123456789012:android:fakeapp1id",
+ bundleId: "com.fake.app1",
+ },
+ {
+ appId: "1:123456789012:android:fakeapp2id",
+ bundleId: "com.fake.app1.debug",
+ },
+ ];
+
+ const result = extractAppIdentifiersAndroid(ANDROID_CONFIG_COMBINED);
+ expect(result).to.deep.equal(expectedIdentifiers);
+ });
+ });
+});
diff --git a/src/appUtils.ts b/src/appUtils.ts
new file mode 100644
index 00000000000..6e7d790dc19
--- /dev/null
+++ b/src/appUtils.ts
@@ -0,0 +1,314 @@
+import * as fs from "fs-extra";
+import * as path from "path";
+import { glob } from "glob";
+import { PackageJSON } from "./frameworks/compose/discover/runtime/node";
+
+/**
+ * Supported application platforms.
+ */
+export enum Platform {
+ ANDROID = "ANDROID",
+ WEB = "WEB",
+ IOS = "IOS",
+ FLUTTER = "FLUTTER",
+}
+
+/**
+ * Supported web frameworks.
+ */
+export enum Framework {
+ REACT = "REACT",
+ ANGULAR = "ANGULAR",
+}
+
+interface AppIdentifier {
+ appId: string;
+ bundleId?: string;
+}
+
+/**
+ * Represents a detected application.
+ */
+export interface App {
+ platform: Platform;
+ directory: string;
+ appId?: string;
+ bundleId?: string;
+ frameworks?: Framework[];
+}
+
+/** Returns a string description of the app */
+export function appDescription(a: App): string {
+ return `${a.directory} (${a.platform.toLowerCase()})`;
+}
+
+/**
+ * Given a directory, determine the platform type.
+ * @param dirPath The directory to scan.
+ * @return A list of platforms detected.
+ */
+export async function getPlatformsFromFolder(dirPath: string): Promise {
+ const apps = await detectApps(dirPath);
+ return [...new Set(apps.map((app) => app.platform))];
+}
+
+/**
+ * Detects the apps in a given directory.
+ * @param dirPath The current working directory to scan.
+ * @return A list of apps detected.
+ */
+export async function detectApps(dirPath: string): Promise {
+ const packageJsonFiles = await detectFiles(dirPath, "package.json");
+ const pubSpecYamlFiles = await detectFiles(dirPath, "pubspec.yaml");
+ const srcMainFolders = await detectFiles(dirPath, "src/main/");
+ const xCodeProjects = await detectFiles(dirPath, "*.xcodeproj/");
+ const webApps = await Promise.all(packageJsonFiles.map((p) => packageJsonToWebApp(dirPath, p)));
+
+ const flutterAppPromises = await Promise.all(
+ pubSpecYamlFiles.map((f) => processFlutterDir(dirPath, f)),
+ );
+ const flutterApps = flutterAppPromises.flat();
+
+ const androidAppPromises = await Promise.all(
+ srcMainFolders.map((f) => processAndroidDir(dirPath, f)),
+ );
+ const androidApps = androidAppPromises
+ .flat()
+ .filter((a) => !flutterApps.some((f) => isPathInside(f.directory, a.directory)));
+
+ const iosAppPromises = await Promise.all(xCodeProjects.map((f) => processIosDir(dirPath, f)));
+ const iosApps = iosAppPromises
+ .flat()
+ .filter((a) => !flutterApps.some((f) => isPathInside(f.directory, a.directory)));
+ return [...webApps, ...flutterApps, ...androidApps, ...iosApps];
+}
+
+async function processIosDir(dirPath: string, filePath: string): Promise {
+ // Search for apps in the parent directory
+ const iosDir = path.dirname(filePath);
+ const iosAppIds = await detectAppIdsForPlatform(dirPath, Platform.IOS);
+ if (iosAppIds.length === 0) {
+ return [
+ {
+ platform: Platform.IOS,
+ directory: iosDir,
+ },
+ ];
+ }
+ const iosApps = await Promise.all(
+ iosAppIds.map((app) => ({
+ platform: Platform.IOS,
+ directory: iosDir,
+ appId: app.appId,
+ bundleId: app.bundleId,
+ })),
+ );
+ return iosApps.flat();
+}
+
+async function processAndroidDir(dirPath: string, filePath: string): Promise {
+ // Search for apps in the parent directory, not in the src/main directory
+ const androidDir = path.dirname(path.dirname(filePath));
+ const androidAppIds = await detectAppIdsForPlatform(dirPath, Platform.ANDROID);
+
+ if (androidAppIds.length === 0) {
+ return [
+ {
+ platform: Platform.ANDROID,
+ directory: androidDir,
+ },
+ ];
+ }
+
+ const androidApps = await Promise.all(
+ androidAppIds.map((app) => ({
+ platform: Platform.ANDROID,
+ directory: androidDir,
+ appId: app.appId,
+ bundleId: app.bundleId,
+ })),
+ );
+ return androidApps.flat();
+}
+
+async function processFlutterDir(dirPath: string, filePath: string): Promise {
+ const flutterDir = path.dirname(filePath);
+ const flutterAppIds = await detectAppIdsForPlatform(dirPath, Platform.FLUTTER);
+
+ if (flutterAppIds.length === 0) {
+ return [
+ {
+ platform: Platform.FLUTTER,
+ directory: flutterDir,
+ },
+ ];
+ }
+
+ const flutterApps = await Promise.all(
+ flutterAppIds.map((app) => {
+ const flutterApp: App = {
+ platform: Platform.FLUTTER,
+ directory: flutterDir,
+ appId: app.appId,
+ bundleId: app.bundleId,
+ };
+ return flutterApp;
+ }),
+ );
+
+ return flutterApps.flat();
+}
+
+function isPathInside(parent: string, child: string): boolean {
+ const relativePath = path.relative(parent, child);
+ return !relativePath.startsWith(`..`);
+}
+
+async function packageJsonToWebApp(dirPath: string, packageJsonFile: string): Promise {
+ const fullPath = path.join(dirPath, packageJsonFile);
+ const packageJson = JSON.parse((await fs.readFile(fullPath)).toString()) as PackageJSON;
+ return {
+ platform: Platform.WEB,
+ directory: path.dirname(packageJsonFile),
+ frameworks: getFrameworksFromPackageJson(packageJson),
+ };
+}
+
+const WEB_FRAMEWORKS: Framework[] = Object.values(Framework);
+const WEB_FRAMEWORKS_SIGNALS: { [key in Framework]: string[] } = {
+ REACT: ["react", "next"],
+ ANGULAR: ["@angular/core"],
+};
+
+async function detectAppIdsForPlatform(
+ dirPath: string,
+ platform: Platform,
+): Promise {
+ let appIdFiles;
+ let extractFunc: (fileContent: string) => AppIdentifier[];
+ switch (platform) {
+ // Leaving web out of the mix for now because we have no strong conventions
+ // around where to put Firebase config. It could be anywhere in your codebase.
+ case Platform.ANDROID:
+ appIdFiles = await detectFiles(dirPath, "google-services*.json*");
+ extractFunc = extractAppIdentifiersAndroid;
+ break;
+ case Platform.IOS:
+ appIdFiles = await detectFiles(dirPath, "GoogleService-*.plist*");
+ extractFunc = extractAppIdentifierIos;
+ break;
+ case Platform.FLUTTER:
+ appIdFiles = await detectFiles(dirPath, "firebase_options.dart");
+ extractFunc = extractAppIdentifiersFlutter;
+ break;
+ default:
+ return [];
+ }
+
+ const allAppIds = await Promise.all(
+ appIdFiles.map(async (file) => {
+ const fileContent = (await fs.readFile(path.join(dirPath, file))).toString();
+ return extractFunc(fileContent);
+ }),
+ );
+ return allAppIds.flat();
+}
+
+function getFrameworksFromPackageJson(packageJson: PackageJSON): Framework[] {
+ const devDependencies = Object.keys(packageJson.devDependencies ?? {});
+ const dependencies = Object.keys(packageJson.dependencies ?? {});
+ const allDeps = Array.from(new Set([...devDependencies, ...dependencies]));
+ return WEB_FRAMEWORKS.filter((framework) =>
+ WEB_FRAMEWORKS_SIGNALS[framework].find((dep) => allDeps.includes(dep)),
+ );
+}
+
+/**
+ * Reads a firebase_options.dart file and extracts all appIds and bundleIds.
+ * @param fileContent content of the dart file.
+ * @return a list of appIds and bundleIds.
+ */
+export function extractAppIdentifiersFlutter(fileContent: string): AppIdentifier[] {
+ const optionsRegex = /FirebaseOptions\(([^)]*)\)/g;
+ const appIdRegex = /appId: '([^']*)'/;
+ const bundleIdRegex = /iosBundleId: '([^']*)'/;
+ const matches = fileContent.matchAll(optionsRegex);
+ const identifiers: AppIdentifier[] = [];
+ for (const match of matches) {
+ const optionsContent = match[1];
+ const appIdMatch = appIdRegex.exec(optionsContent);
+ const bundleIdMatch = bundleIdRegex.exec(optionsContent);
+ if (appIdMatch?.[1]) {
+ identifiers.push({
+ appId: appIdMatch[1],
+ bundleId: bundleIdMatch?.[1],
+ });
+ }
+ }
+
+ return identifiers;
+}
+
+/**
+ * Reads a GoogleService-Info.plist file and extracts the GOOGLE_APP_ID and BUNDLE_ID.
+ * @param fileContent content of the plist file.
+ * @return The GOOGLE_APP_ID and BUNDLE_ID or an empty array.
+ */
+export function extractAppIdentifierIos(fileContent: string): AppIdentifier[] {
+ const appIdRegex = /GOOGLE_APP_ID<\/key>\s*([^<]*)<\/string>/;
+ const bundleIdRegex = /BUNDLE_ID<\/key>\s*([^<]*)<\/string>/;
+ const appIdMatch = fileContent.match(appIdRegex);
+ const bundleIdMatch = fileContent.match(bundleIdRegex);
+ if (appIdMatch?.[1]) {
+ return [
+ {
+ appId: appIdMatch[1],
+ bundleId: bundleIdMatch?.[1],
+ },
+ ];
+ }
+ return [];
+}
+
+/**
+ * Reads a google-services.json file and extracts all mobilesdk_app_id and package_name values.
+ * @param fileContent content of the google-services.json file.
+ * @return a list of mobilesdk_app_id and package_name values.
+ */
+export function extractAppIdentifiersAndroid(fileContent: string): AppIdentifier[] {
+ const identifiers: AppIdentifier[] = [];
+ try {
+ const config = JSON.parse(fileContent);
+ if (config.client && Array.isArray(config.client)) {
+ for (const client of config.client) {
+ if (client.client_info?.mobilesdk_app_id) {
+ identifiers.push({
+ appId: client.client_info.mobilesdk_app_id,
+ bundleId: client.client_info.android_client_info?.package_name,
+ });
+ }
+ }
+ }
+ } catch (e) {
+ // Handle parsing errors if necessary
+ console.error("Error parsing google-services.json:", e);
+ }
+ return identifiers;
+}
+
+async function detectFiles(dirPath: string, filePattern: string): Promise {
+ const options = {
+ cwd: dirPath,
+ ignore: [
+ "**/dataconnect*/**",
+ "**/node_modules/**", // Standard dependency directory
+ "**/dist/**", // Common build output
+ "**/build/**", // Common build output
+ "**/out/**", // Another common build output
+ "**/.next/**", // Next.js build directory
+ "**/coverage/**", // Test coverage reports
+ ],
+ absolute: false,
+ };
+ return glob(`**/${filePattern}`, options);
+}
diff --git a/src/mcp/tools/core/get_environment.spec.ts b/src/mcp/tools/core/get_environment.spec.ts
new file mode 100644
index 00000000000..925492bb74b
--- /dev/null
+++ b/src/mcp/tools/core/get_environment.spec.ts
@@ -0,0 +1,256 @@
+import { expect } from "chai";
+import * as sinon from "sinon";
+import { get_environment } from "./get_environment";
+import * as projectUtils from "../../../projectUtils";
+import { configstore } from "../../../configstore";
+import * as appUtils from "../../../appUtils";
+import * as auth from "../../../auth";
+import { RC } from "../../../rc";
+import { Config } from "../../../config";
+import { McpContext } from "../../types";
+import { FirebaseMcpServer } from "../..";
+
+describe("get_environment tool", () => {
+ let sandbox: sinon.SinonSandbox;
+ let getAliasesStub: sinon.SinonStub;
+ let configstoreGetStub: sinon.SinonStub;
+ let detectAppsStub: sinon.SinonStub;
+ let getAllAccountsStub: sinon.SinonStub;
+ let server: FirebaseMcpServer;
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ getAliasesStub = sandbox.stub(projectUtils, "getAliases");
+ configstoreGetStub = sandbox.stub(configstore, "get");
+ detectAppsStub = sandbox.stub(appUtils, "detectApps");
+ getAllAccountsStub = sandbox.stub(auth, "getAllAccounts");
+ server = new FirebaseMcpServer({ projectRoot: "/test-dir" });
+ server.cachedProjectDir = "/test-dir";
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ const mockToolOptions = (
+ projectId?: string,
+ accountEmail?: string,
+ projectFileExists = false,
+ rcProjects: Record = {},
+ firebaseJsonContent = "",
+ ): McpContext => {
+ const rc = new RC(undefined, { projects: rcProjects });
+ const config = new Config({}, { cwd: "/test-dir" });
+ sandbox.stub(config, "projectFileExists").returns(projectFileExists);
+ sandbox.stub(config, "path").returns("/test-dir/firebase.json");
+ sandbox.stub(config, "readProjectFile").returns(firebaseJsonContent);
+
+ // The tool fn receives McpContext, which expects projectId to be a string.
+ // The tool implementation handles a falsy projectId, so we can default to "".
+ return {
+ projectId: projectId || "",
+ host: server,
+ accountEmail: accountEmail ? accountEmail : null,
+ rc,
+ config,
+ };
+ };
+
+ it("should show minimal environment", async () => {
+ getAliasesStub.returns([]);
+ configstoreGetStub.withArgs("gemini").returns(false);
+ detectAppsStub.resolves([]);
+ getAllAccountsStub.returns([]);
+ const options = mockToolOptions(undefined);
+
+ const result = await get_environment.fn({}, options);
+
+ const expectedOutput = `# Environment Information
+
+Project Directory: /test-dir
+Project Config Path:
+Active Project ID:
+Gemini in Firebase Terms of Service:
+Authenticated User:
+Detected App IDs:
+Available Project Aliases (format: '[alias]: [projectId]'):
+
+No firebase.json file was found.
+
+If this project does not use Firebase services that require a firebase.json file, no action is necessary.
+
+If this project uses Firebase services that require a firebase.json file, the user will most likely want to:
+
+a) Change the project directory using the 'firebase_update_environment' tool to select a directory with a 'firebase.json' file in it, or
+b) Initialize a new Firebase project directory using the 'firebase_init' tool.
+
+Confirm with the user before taking action.`;
+ expect(result.content[0].text).to.equal(expectedOutput);
+ });
+
+ it("should show full environment", async () => {
+ getAliasesStub.returns(["my-alias"]);
+ configstoreGetStub.withArgs("gemini").returns(true);
+ detectAppsStub.resolves([
+ { platform: "WEB", directory: "web", appId: "web-app-id" },
+ {
+ platform: "ANDROID",
+ directory: "android",
+ appId: "android-app-id",
+ bundleId: "com.foo.bar",
+ },
+ ]);
+ getAllAccountsStub.returns([
+ { user: { email: "test@example.com" } },
+ { user: { email: "another@example.com" } },
+ ]);
+ const options = mockToolOptions(
+ "test-project",
+ "test@example.com",
+ true,
+ { "my-alias": "test-project", "other-alias": "other-project" },
+ '{ "hosting": { "public": "public" } }',
+ );
+
+ const result = await get_environment.fn({}, options);
+ const expectedOutput = `# Environment Information
+
+Project Directory: /test-dir
+Project Config Path: /test-dir/firebase.json
+Active Project ID: test-project (alias: my-alias)
+Gemini in Firebase Terms of Service: Accepted
+Authenticated User: test@example.com
+Detected App IDs:
+
+web-app-id:
+android-app-id: com.foo.bar
+
+Available Project Aliases (format: '[alias]: [projectId]'):
+
+my-alias: test-project
+other-alias: other-project
+
+Available Accounts:
+
+- test@example.com
+- another@example.com
+
+firebase.json contents:
+
+\`\`\`json
+{ "hosting": { "public": "public" } }
+\`\`\``;
+ expect(result.content[0].text).to.equal(expectedOutput);
+ });
+
+ it("should handle a single alias", async () => {
+ getAliasesStub.returns(["my-alias"]);
+ detectAppsStub.resolves([]);
+ getAllAccountsStub.returns([]);
+ const options = mockToolOptions("test-project", "test@example.com", false, {
+ "my-alias": "test-project",
+ });
+
+ const result = await get_environment.fn({}, options);
+ expect(result.content[0].text).to.include("Active Project ID: test-project (alias: my-alias)");
+ expect(result.content[0].text).to.include(
+ `Available Project Aliases (format: '[alias]: [projectId]'):
+
+my-alias: test-project
+
+`,
+ );
+ });
+
+ it("should handle multiple aliases", async () => {
+ getAliasesStub.returns(["alias1", "alias2"]);
+ detectAppsStub.resolves([]);
+ getAllAccountsStub.returns([]);
+ const options = mockToolOptions("test-project", "test@example.com", false, {
+ alias1: "test-project",
+ alias2: "test-project",
+ });
+
+ const result = await get_environment.fn({}, options);
+ expect(result.content[0].text).to.include(
+ "Active Project ID: test-project (alias: alias1,alias2)",
+ );
+ expect(result.content[0].text).to.include(
+ `Available Project Aliases (format: '[alias]: [projectId]'):
+
+alias1: test-project
+alias2: test-project
+
+`,
+ );
+ });
+
+ it("should handle multiple accounts", async () => {
+ getAliasesStub.returns([]);
+ detectAppsStub.resolves([]);
+ getAllAccountsStub.returns([
+ { user: { email: "test@example.com" } },
+ { user: { email: "another@example.com" } },
+ ]);
+ const options = mockToolOptions("test-project", "test@example.com");
+
+ const result = await get_environment.fn({}, options);
+ expect(result.content[0].text).to.include("Authenticated User: test@example.com");
+ expect(result.content[0].text).to.include(`Available Accounts:
+
+- test@example.com
+- another@example.com
+
+`);
+ });
+
+ it("should handle a single detected app", async () => {
+ getAliasesStub.returns([]);
+ detectAppsStub.resolves([{ platform: "WEB", directory: "web", appId: "web-app-id" }]);
+ getAllAccountsStub.returns([]);
+ const options = mockToolOptions();
+
+ const result = await get_environment.fn({}, options);
+ expect(result.content[0].text).to.include(`Detected App IDs:
+
+web-app-id:
+
+`);
+ });
+
+ it("should handle multiple detected apps with bundleId", async () => {
+ getAliasesStub.returns([]);
+ detectAppsStub.resolves([
+ { platform: "WEB", directory: "web", appId: "web-app-id" },
+ {
+ platform: "ANDROID",
+ directory: "android",
+ appId: "android-app-id",
+ bundleId: "com.foo.bar",
+ },
+ ]);
+ getAllAccountsStub.returns([]);
+ const options = mockToolOptions();
+
+ const result = await get_environment.fn({}, options);
+ expect(result.content[0].text).to.include(`Detected App IDs:
+
+web-app-id:
+android-app-id: com.foo.bar
+
+`);
+ });
+
+ it("should show Gemini ToS not accepted", async () => {
+ getAliasesStub.returns([]);
+ configstoreGetStub.withArgs("gemini").returns(false);
+ detectAppsStub.resolves([]);
+ getAllAccountsStub.returns([]);
+ const options = mockToolOptions();
+
+ const result = await get_environment.fn({}, options);
+ expect(result.content[0].text).to.include(
+ "Gemini in Firebase Terms of Service: ",
+ );
+ });
+});
diff --git a/src/mcp/tools/core/get_environment.ts b/src/mcp/tools/core/get_environment.ts
index 448a0bc60c8..cc28dbf3e26 100644
--- a/src/mcp/tools/core/get_environment.ts
+++ b/src/mcp/tools/core/get_environment.ts
@@ -5,6 +5,7 @@ import { getAliases } from "../../../projectUtils";
import { dump } from "js-yaml";
import { getAllAccounts } from "../../../auth";
import { configstore } from "../../../configstore";
+import { detectApps } from "../../../appUtils";
export const get_environment = tool(
{
@@ -24,37 +25,54 @@ export const get_environment = tool(
async (_, { projectId, host, accountEmail, rc, config }) => {
const aliases = projectId ? getAliases({ rc }, projectId) : [];
const geminiTosAccepted = !!configstore.get("gemini");
+ const projectFileExists = config.projectFileExists("firebase.json");
+ const detectedApps = await detectApps(process.cwd());
+ const allAccounts = getAllAccounts().map((account) => account.user.email);
+ const hasOtherAccounts = allAccounts.filter((email) => email !== accountEmail).length > 0;
+
+ const projectConfigPathString = projectFileExists
+ ? config.path("firebase.json")
+ : "";
+ const detectedAppsMap = detectedApps
+ .filter((app) => !!app.appId)
+ .reduce((map, app) => {
+ if (app.appId) {
+ map.set(app.appId, app.bundleId ? app.bundleId : "");
+ }
+ return map;
+ }, new Map());
+ const activeProjectString = projectId
+ ? `${projectId}${aliases.length ? ` (alias: ${aliases.join(",")})` : ""}`
+ : "";
+ const acceptedGeminiTosString = geminiTosAccepted ? "Accepted" : "";
return toContent(`# Environment Information
Project Directory: ${host.cachedProjectDir}
-Project Config Path: ${config.projectFileExists("firebase.json") ? config.path("firebase.json") : ""}
-Active Project ID: ${
- projectId ? `${projectId}${aliases.length ? ` (alias: ${aliases.join(",")})` : ""}` : ""
- }
+Project Config Path: ${projectConfigPathString}
+Active Project ID: ${activeProjectString}
+Gemini in Firebase Terms of Service: ${acceptedGeminiTosString}
Authenticated User: ${accountEmail || ""}
-Gemini in Firebase Terms of Service: ${geminiTosAccepted ? "Accepted" : "Not Accepted"}
-
-# Available Project Aliases (format: '[alias]: [projectId]')
-
-${dump(rc.projects).trim()}
-
-# Available Accounts:
-
-${dump(getAllAccounts().map((account) => account.user.email)).trim()}
+Detected App IDs: ${detectedAppsMap.size > 0 ? `\n\n${dump(Object.fromEntries(detectedAppsMap)).trim()}\n` : ""}
+Available Project Aliases (format: '[alias]: [projectId]'): ${Object.entries(rc.projects).length > 0 ? `\n\n${dump(rc.projects).trim()}\n` : ""}${
+ hasOtherAccounts ? `\nAvailable Accounts: \n\n${dump(allAccounts).trim()}` : ""
+ }
${
- config.projectFileExists("firebase.json")
- ? `
-# firebase.json contents:
+ projectFileExists
+ ? `\nfirebase.json contents:
\`\`\`json
${config.readProjectFile("firebase.json")}
\`\`\``
- : `\n# Empty Environment
+ : `\nNo firebase.json file was found.
+
+If this project does not use Firebase services that require a firebase.json file, no action is necessary.
-It looks like the current directory is not initialized as a Firebase project. The user will most likely want to:
+If this project uses Firebase services that require a firebase.json file, the user will most likely want to:
a) Change the project directory using the 'firebase_update_environment' tool to select a directory with a 'firebase.json' file in it, or
-b) Initialize a new Firebase project directory using the 'firebase_init' tool.`
+b) Initialize a new Firebase project directory using the 'firebase_init' tool.
+
+Confirm with the user before taking action.`
}`);
},
);