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.` }`); }, );