Skip to content

Commit 0c515ca

Browse files
committed
Add common appUtils to augment get_environment with app level data
1 parent 60582ae commit 0c515ca

File tree

8 files changed

+1459
-103
lines changed

8 files changed

+1459
-103
lines changed

src/appUtils.spec.ts

Lines changed: 655 additions & 0 deletions
Large diffs are not rendered by default.

src/appUtils.ts

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import * as fs from "fs-extra";
2+
import * as path from "path";
3+
import { glob } from "glob";
4+
import { PackageJSON } from "./frameworks/compose/discover/runtime/node";
5+
6+
/**
7+
* Supported application platforms.
8+
*/
9+
export enum Platform {
10+
NONE = "NONE",
11+
ANDROID = "ANDROID",
12+
WEB = "WEB",
13+
IOS = "IOS",
14+
FLUTTER = "FLUTTER",
15+
}
16+
17+
/**
18+
* Supported web frameworks.
19+
*/
20+
export enum Framework {
21+
REACT = "REACT",
22+
ANGULAR = "ANGULAR",
23+
}
24+
25+
interface AppIdentifier {
26+
appId: string;
27+
bundleId?: string;
28+
}
29+
30+
/**
31+
* Represents a detected application.
32+
*/
33+
export interface App {
34+
platform: Platform;
35+
directory: string;
36+
appId?: string;
37+
bundleId?: string;
38+
frameworks?: Framework[];
39+
}
40+
41+
/** Returns a string description of the app */
42+
export function appDescription(a: App): string {
43+
return `${a.directory} (${a.platform.toLowerCase()})`;
44+
}
45+
46+
/**
47+
* Given a directory, determine the platform type.
48+
* @param dirPath The directory to scan.
49+
* @return A list of platforms detected.
50+
*/
51+
export async function getPlatformsFromFolder(dirPath: string): Promise<Platform[]> {
52+
const apps = await detectApps(dirPath);
53+
const hasWeb = apps.some((app) => app.platform === Platform.WEB);
54+
const hasAndroid = apps.some((app) => app.platform === Platform.ANDROID);
55+
const hasIOS = apps.some((app) => app.platform === Platform.IOS);
56+
const hasDart = apps.some((app) => app.platform === Platform.FLUTTER);
57+
58+
if (!hasWeb && !hasAndroid && !hasIOS && !hasDart) {
59+
return [Platform.NONE];
60+
}
61+
62+
const platforms = [];
63+
if (hasWeb) {
64+
platforms.push(Platform.WEB);
65+
}
66+
67+
if (hasAndroid) {
68+
platforms.push(Platform.ANDROID);
69+
}
70+
71+
if (hasIOS) {
72+
platforms.push(Platform.IOS);
73+
}
74+
75+
if (hasDart) {
76+
platforms.push(Platform.FLUTTER);
77+
}
78+
79+
return platforms;
80+
}
81+
82+
/**
83+
* Detects the apps in a given directory.
84+
* @param dirPath The current working directory to scan.
85+
* @return A list of apps detected.
86+
*/
87+
export async function detectApps(dirPath: string): Promise<App[]> {
88+
const packageJsonFiles = await detectFiles(dirPath, "package.json");
89+
const pubSpecYamlFiles = await detectFiles(dirPath, "pubspec.yaml");
90+
const srcMainFolders = await detectFiles(dirPath, "src/main/");
91+
const xCodeProjects = await detectFiles(dirPath, "*.xcodeproj/");
92+
const webApps = await Promise.all(packageJsonFiles.map((p) => packageJsonToWebApp(dirPath, p)));
93+
94+
const flutterAppPromises = await Promise.all(
95+
pubSpecYamlFiles.map((f) => processFlutterDir(dirPath, f)),
96+
);
97+
const flutterApps = flutterAppPromises.flat();
98+
99+
const androidAppPromises = await Promise.all(
100+
srcMainFolders.map((f) => processAndroidDir(dirPath, f)),
101+
);
102+
const androidApps = androidAppPromises
103+
.flat()
104+
.filter((a) => !flutterApps.some((f) => isPathInside(f.directory, a.directory)));
105+
106+
const iosAppPromises = await Promise.all(xCodeProjects.map((f) => processIosDir(dirPath, f)));
107+
const iosApps = iosAppPromises
108+
.flat()
109+
.filter((a) => !flutterApps.some((f) => isPathInside(f.directory, a.directory)));
110+
return [...webApps, ...flutterApps, ...androidApps, ...iosApps];
111+
}
112+
113+
async function processIosDir(dirPath: string, filePath: string) {
114+
// Search for apps in the parent directory
115+
const iosDir = path.dirname(filePath);
116+
const iosAppIds = await detectAppIdsForPlatform(dirPath, Platform.IOS);
117+
if (iosAppIds.length === 0) {
118+
return [
119+
{
120+
platform: Platform.IOS,
121+
directory: iosDir,
122+
},
123+
];
124+
}
125+
const iosApps = await Promise.all(
126+
iosAppIds.map((app) => ({
127+
platform: Platform.IOS,
128+
directory: iosDir,
129+
appId: app.appId,
130+
bundleId: app.bundleId,
131+
})),
132+
);
133+
return iosApps.flat();
134+
}
135+
136+
async function processAndroidDir(dirPath: string, filePath: string) {
137+
// Search for apps in the parent directory, not in the src/main directory
138+
const androidDir = path.dirname(path.dirname(filePath));
139+
const androidAppIds = await detectAppIdsForPlatform(dirPath, Platform.ANDROID);
140+
141+
if (androidAppIds.length === 0) {
142+
return [
143+
{
144+
platform: Platform.ANDROID,
145+
directory: androidDir,
146+
},
147+
];
148+
}
149+
150+
const androidApps = await Promise.all(
151+
androidAppIds.map((app) => ({
152+
platform: Platform.ANDROID,
153+
directory: androidDir,
154+
appId: app.appId,
155+
bundleId: app.bundleId,
156+
})),
157+
);
158+
return androidApps.flat();
159+
}
160+
161+
async function processFlutterDir(dirPath: string, filePath: string) {
162+
const flutterDir = path.dirname(filePath);
163+
const flutterAppIds = await detectAppIdsForPlatform(dirPath, Platform.FLUTTER);
164+
165+
if (flutterAppIds.length === 0) {
166+
return [
167+
{
168+
platform: Platform.FLUTTER,
169+
directory: flutterDir,
170+
},
171+
];
172+
}
173+
174+
const flutterApps = await Promise.all(
175+
flutterAppIds.map((app) => {
176+
const flutterApp: App = {
177+
platform: Platform.FLUTTER,
178+
directory: flutterDir,
179+
appId: app.appId,
180+
bundleId: app.bundleId,
181+
};
182+
return flutterApp;
183+
}),
184+
);
185+
186+
return flutterApps.flat();
187+
}
188+
189+
function isPathInside(parent: string, child: string): boolean {
190+
const relativePath = path.relative(parent, child);
191+
return !relativePath.startsWith(`..`);
192+
}
193+
194+
async function packageJsonToWebApp(dirPath: string, packageJsonFile: string): Promise<App> {
195+
const fullPath = path.join(dirPath, packageJsonFile);
196+
const packageJson = JSON.parse((await fs.readFile(fullPath)).toString());
197+
return {
198+
platform: Platform.WEB,
199+
directory: path.dirname(packageJsonFile),
200+
frameworks: getFrameworksFromPackageJson(packageJson),
201+
};
202+
}
203+
204+
const WEB_FRAMEWORKS: Framework[] = Object.values(Framework);
205+
const WEB_FRAMEWORKS_SIGNALS: { [key in Framework]: string[] } = {
206+
REACT: ["react", "next"],
207+
ANGULAR: ["@angular/core"],
208+
};
209+
210+
async function detectAppIdsForPlatform(
211+
dirPath: string,
212+
platform: Platform,
213+
): Promise<AppIdentifier[]> {
214+
let appIdFiles;
215+
let extractFunc: (fileContent: string) => AppIdentifier[];
216+
switch (platform) {
217+
case Platform.ANDROID:
218+
appIdFiles = await detectFiles(dirPath, "google-services*.json*");
219+
extractFunc = extractAppIdentifiersAndroid;
220+
break;
221+
case Platform.IOS:
222+
appIdFiles = await detectFiles(dirPath, "GoogleService-*.plist*");
223+
extractFunc = extractAppIdentifierIos;
224+
break;
225+
case Platform.FLUTTER:
226+
appIdFiles = await detectFiles(dirPath, "firebase_options.dart");
227+
extractFunc = extractAppIdentifiersFlutter;
228+
break;
229+
default:
230+
return [];
231+
}
232+
233+
const allAppIds = await Promise.all(
234+
appIdFiles.map(async (file) => {
235+
const fileContent = (await fs.readFile(path.join(dirPath, file))).toString();
236+
return extractFunc(fileContent);
237+
}),
238+
);
239+
return allAppIds.flat();
240+
}
241+
242+
function getFrameworksFromPackageJson(packageJson: PackageJSON): Framework[] {
243+
const devDependencies = Object.keys(packageJson.devDependencies ?? {});
244+
const dependencies = Object.keys(packageJson.dependencies ?? {});
245+
const allDeps = Array.from(new Set([...devDependencies, ...dependencies]));
246+
return WEB_FRAMEWORKS.filter((framework) =>
247+
WEB_FRAMEWORKS_SIGNALS[framework]!.find((dep) => allDeps.includes(dep)),
248+
);
249+
}
250+
251+
/**
252+
* Reads a firebase_options.dart file and extracts all appIds and bundleIds.
253+
* @param fileContent content of the dart file.
254+
* @returns a list of appIds and bundleIds.
255+
*/
256+
export function extractAppIdentifiersFlutter(fileContent: string): AppIdentifier[] {
257+
const optionsRegex = /FirebaseOptions\(([^)]*)\)/g;
258+
const matches = fileContent.matchAll(optionsRegex);
259+
const identifiers: AppIdentifier[] = [];
260+
for (const match of matches) {
261+
const optionsContent = match[1];
262+
const appIdMatch = optionsContent.match(/appId: '([^']*)'/);
263+
const bundleIdMatch = optionsContent.match(/iosBundleId: '([^']*)'/);
264+
if (appIdMatch?.[1]) {
265+
identifiers.push({
266+
appId: appIdMatch[1],
267+
bundleId: bundleIdMatch?.[1],
268+
});
269+
}
270+
}
271+
272+
return identifiers;
273+
}
274+
275+
/**
276+
* Reads a GoogleService-Info.plist file and extracts the GOOGLE_APP_ID and BUNDLE_ID.
277+
* @param fileContent content of the plist file.
278+
* @returns The GOOGLE_APP_ID and BUNDLE_ID or an empty array.
279+
*/
280+
export function extractAppIdentifierIos(fileContent: string): AppIdentifier[] {
281+
const appIdRegex = /<key>GOOGLE_APP_ID<\/key>\s*<string>([^<]*)<\/string>/;
282+
const bundleIdRegex = /<key>BUNDLE_ID<\/key>\s*<string>([^<]*)<\/string>/;
283+
const appIdMatch = fileContent.match(appIdRegex);
284+
const bundleIdMatch = fileContent.match(bundleIdRegex);
285+
if (appIdMatch?.[1]) {
286+
return [
287+
{
288+
appId: appIdMatch[1],
289+
bundleId: bundleIdMatch?.[1],
290+
},
291+
];
292+
}
293+
return [];
294+
}
295+
296+
/**
297+
* Reads a google-services.json file and extracts all mobilesdk_app_id and package_name values.
298+
* @param fileContent content of the google-services.json file.
299+
* @returns a list of mobilesdk_app_id and package_name values.
300+
*/
301+
export function extractAppIdentifiersAndroid(fileContent: string): AppIdentifier[] {
302+
const identifiers: AppIdentifier[] = [];
303+
try {
304+
const config = JSON.parse(fileContent);
305+
if (config.client && Array.isArray(config.client)) {
306+
for (const client of config.client) {
307+
if (client.client_info?.mobilesdk_app_id) {
308+
identifiers.push({
309+
appId: client.client_info.mobilesdk_app_id,
310+
bundleId: client.client_info.android_client_info?.package_name,
311+
});
312+
}
313+
}
314+
}
315+
} catch (e) {
316+
// Handle parsing errors if necessary
317+
console.error("Error parsing google-services.json:", e);
318+
}
319+
return identifiers;
320+
}
321+
322+
async function detectFiles(dirPath: string, filePattern: string): Promise<string[]> {
323+
const options = {
324+
cwd: dirPath,
325+
ignore: [
326+
"**/dataconnect*/**",
327+
"**/node_modules/**", // Standard dependency directory
328+
"**/dist/**", // Common build output
329+
"**/build/**", // Common build output
330+
"**/out/**", // Another common build output
331+
"**/.next/**", // Next.js build directory
332+
"**/coverage/**", // Test coverage reports
333+
],
334+
absolute: false,
335+
};
336+
return glob(`**/${filePattern}`, options);
337+
}

src/bin/mcp.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolve } from "path";
1010
const STARTUP_MESSAGE = `
1111
This is a running process of the Firebase MCP server. This command should only be executed by an MCP client. An example MCP client configuration might be:
1212
13+
An example MCP client configuration for apps using Firebase Build tools might be:
1314
{
1415
"mcpServers": {
1516
"firebase": {
@@ -26,6 +27,7 @@ export async function mcp(): Promise<void> {
2627
only: { type: "string", default: "" },
2728
dir: { type: "string" },
2829
"generate-tool-list": { type: "boolean", default: false },
30+
project: { type: "string" },
2931
},
3032
allowPositionals: true,
3133
});
@@ -38,9 +40,11 @@ export async function mcp(): Promise<void> {
3840
const activeFeatures = (values.only || "")
3941
.split(",")
4042
.filter((f) => SERVER_FEATURES.includes(f as ServerFeature)) as ServerFeature[];
43+
4144
const server = new FirebaseMcpServer({
4245
activeFeatures,
4346
projectRoot: values.dir ? resolve(values.dir) : undefined,
47+
projectId: values["project"],
4448
});
4549
await server.start();
4650
if (process.stdin.isTTY) process.stderr.write(STARTUP_MESSAGE);

src/dataconnect/appFinder.spec.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe("detectApps", () => {
105105
fs.outputFileSync(`${testDir}/web/package.json`, "{}");
106106
fs.mkdirpSync(`${testDir}/android/src/main`);
107107
const apps = await detectApps(testDir);
108-
expect(apps).to.deep.equal([
108+
expect(apps).to.have.deep.members([
109109
{
110110
platform: Platform.WEB,
111111
directory: `web`,
@@ -172,8 +172,6 @@ describe("detectApps", () => {
172172
frameworks: [],
173173
},
174174
];
175-
expect(apps.sort((a, b) => a.directory.localeCompare(b.directory))).to.deep.equal(
176-
expected.sort((a, b) => a.directory.localeCompare(b.directory)),
177-
);
175+
expect(apps).to.have.deep.members(expected);
178176
});
179177
});

0 commit comments

Comments
 (0)