Skip to content

Commit e6bf8e4

Browse files
TrCaMjoehan
andauthored
feat(mcp): AI Logic Init Feature (CLI Command and MCP Firebase Init Tool) (#9185)
* Add Firebase App provisioning API integration * Firebase MCP init tool schema change to support provisioning * Add methods to detect existing local apps from directories * Add methods to ensure local file locations for app provisioning * Wire up provisioning logic with fake API service - Verify logic first before real API integration * Archive mock provisioning service for future reference * Complete first working version of revamped firebase init MCP tool * Fix lint/formating - Add unit test for new firebase init tool * Fix lint/formating - Add unit test for new firebase init tool * Minor pr fixes * Adding missing files * Reduce provisioning integration scope for only AI Logic * Complete orchestration API provisioning to AI Logic feature (Both CLI and MCP tool) * Fix lint and unit tests * Address Gemini code assist comments * Simplify ailogic init feature to only use existing project and app * Fix linting * Addresses comments * Check projectId is non-empty for ailogic feature (MCP tool) --------- Co-authored-by: Joe Hanley <[email protected]>
1 parent 13bc9b1 commit e6bf8e4

File tree

12 files changed

+2181
-1
lines changed

12 files changed

+2181
-1
lines changed

src/commands/init.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ if (isEnabled("apptesting")) {
117117
});
118118
}
119119

120+
if (isEnabled("ailogic")) {
121+
choices.push({
122+
value: "ailogic",
123+
name: "AI Logic: Set up Firebase AI Logic with app provisioning",
124+
checked: false,
125+
});
126+
}
127+
120128
choices.push({
121129
value: "aitools",
122130
name: "AI Tools: Configure AI coding assistants to work with your Firebase project",

src/experiments.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ export const ALL_EXPERIMENTS = experiments({
156156
shortDescription: "Adds experimental App Testing feature",
157157
public: true,
158158
},
159+
ailogic: {
160+
shortDescription: "Enable Firebase AI Logic feature for existing apps",
161+
fullDescription:
162+
"Enables the AI Logic initialization feature that provisions AI Logic for existing Firebase apps.",
163+
public: true,
164+
default: false,
165+
},
159166
});
160167

161168
export type ExperimentName = keyof typeof ALL_EXPERIMENTS;
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import * as prompt from "../../../prompt";
2+
import { expect } from "chai";
3+
import * as sinon from "sinon";
4+
import * as init from "./index";
5+
import * as utils from "./utils";
6+
import * as apps from "../../../management/apps";
7+
import * as provision from "../../../management/provisioning/provision";
8+
import { Setup } from "../..";
9+
import { AppPlatform } from "../../../management/apps";
10+
11+
describe("init ailogic", () => {
12+
let sandbox: sinon.SinonSandbox;
13+
14+
beforeEach(() => {
15+
sandbox = sinon.createSandbox();
16+
});
17+
18+
afterEach(() => {
19+
sandbox.restore();
20+
});
21+
22+
describe("askQuestions", () => {
23+
let listFirebaseAppsStub: sinon.SinonStub;
24+
let selectStub: sinon.SinonStub;
25+
26+
beforeEach(() => {
27+
listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps");
28+
selectStub = sandbox.stub(prompt, "select");
29+
});
30+
31+
it("should populate ailogic featureInfo with selected app ID", async () => {
32+
const mockApps = [
33+
{
34+
appId: "1:123456789:android:abcdef123456",
35+
displayName: "Test Android App",
36+
platform: AppPlatform.ANDROID,
37+
},
38+
{
39+
appId: "1:123456789:web:fedcba654321",
40+
displayName: "Test Web App",
41+
platform: AppPlatform.WEB,
42+
},
43+
];
44+
const mockSetup = { projectId: "test-project" } as Setup;
45+
46+
listFirebaseAppsStub.resolves(mockApps);
47+
selectStub.resolves(mockApps[0]); // Select first app
48+
49+
await init.askQuestions(mockSetup);
50+
51+
expect(mockSetup.featureInfo).to.have.property("ailogic");
52+
expect(mockSetup.featureInfo?.ailogic).to.deep.equal({
53+
appId: "1:123456789:android:abcdef123456",
54+
});
55+
});
56+
57+
it("should throw error when no project ID is found", async () => {
58+
const mockSetup = {} as Setup; // No projectId
59+
60+
await expect(init.askQuestions(mockSetup)).to.be.rejectedWith(
61+
"No project ID found. Please ensure you are in a Firebase project directory or specify a project.",
62+
);
63+
64+
sinon.assert.notCalled(listFirebaseAppsStub);
65+
sinon.assert.notCalled(selectStub);
66+
});
67+
68+
it("should throw error when no apps are found", async () => {
69+
const mockSetup = { projectId: "test-project" } as Setup;
70+
listFirebaseAppsStub.resolves([]); // No apps
71+
72+
await expect(init.askQuestions(mockSetup)).to.be.rejectedWith(
73+
"No Firebase apps found in this project. Please create an app first using the Firebase Console or 'firebase apps:create'.",
74+
);
75+
76+
sinon.assert.calledWith(listFirebaseAppsStub, "test-project", AppPlatform.ANY);
77+
sinon.assert.notCalled(selectStub);
78+
});
79+
});
80+
81+
describe("actuate", () => {
82+
let setup: Setup;
83+
let parseAppIdStub: sinon.SinonStub;
84+
let provisionFirebaseAppStub: sinon.SinonStub;
85+
let getConfigFileNameStub: sinon.SinonStub;
86+
87+
beforeEach(() => {
88+
setup = {
89+
config: {},
90+
rcfile: { projects: {}, targets: {}, etags: {} },
91+
featureInfo: {
92+
ailogic: {
93+
appId: "1:123456789:android:abcdef123456",
94+
},
95+
},
96+
projectId: "test-project",
97+
instructions: [],
98+
} as Setup;
99+
100+
// Stub only the functions used in actuate (no validation stubs)
101+
parseAppIdStub = sandbox.stub(utils, "parseAppId");
102+
provisionFirebaseAppStub = sandbox.stub(provision, "provisionFirebaseApp");
103+
getConfigFileNameStub = sandbox.stub(utils, "getConfigFileName");
104+
});
105+
106+
it("should return early if no ailogic feature info", async () => {
107+
setup.featureInfo = {};
108+
109+
await init.actuate(setup);
110+
111+
// No stubs should be called
112+
sinon.assert.notCalled(parseAppIdStub);
113+
sinon.assert.notCalled(provisionFirebaseAppStub);
114+
});
115+
116+
it("should provision existing app successfully", async () => {
117+
const mockAppInfo = {
118+
projectNumber: "123456789",
119+
appId: "1:123456789:android:abcdef123456",
120+
platform: AppPlatform.ANDROID,
121+
};
122+
const mockConfigContent = '{"config": "content"}';
123+
const base64Config = Buffer.from(mockConfigContent).toString("base64");
124+
125+
parseAppIdStub.returns(mockAppInfo);
126+
provisionFirebaseAppStub.resolves({ configData: base64Config });
127+
getConfigFileNameStub.returns("google-services.json");
128+
129+
await init.actuate(setup);
130+
131+
sinon.assert.calledWith(parseAppIdStub, "1:123456789:android:abcdef123456");
132+
sinon.assert.calledOnce(provisionFirebaseAppStub);
133+
134+
expect(setup.instructions).to.include(
135+
"Firebase AI Logic has been enabled for existing ANDROID app: 1:123456789:android:abcdef123456",
136+
);
137+
expect(setup.instructions).to.include(
138+
"Save the following content as google-services.json in your app's root directory:",
139+
);
140+
expect(setup.instructions).to.include(mockConfigContent);
141+
});
142+
143+
it("should throw error if no project ID found", async () => {
144+
setup.projectId = undefined;
145+
146+
await expect(init.actuate(setup)).to.be.rejectedWith(
147+
"AI Logic setup failed: No project ID found. Please ensure you are in a Firebase project directory or specify a project.",
148+
);
149+
150+
sinon.assert.calledOnce(parseAppIdStub);
151+
sinon.assert.notCalled(provisionFirebaseAppStub);
152+
});
153+
154+
it("should handle provisioning errors gracefully", async () => {
155+
const mockAppInfo = {
156+
projectNumber: "123456789",
157+
appId: "1:123456789:android:abcdef123456",
158+
platform: AppPlatform.ANDROID,
159+
};
160+
161+
parseAppIdStub.returns(mockAppInfo);
162+
provisionFirebaseAppStub.throws(new Error("Provisioning API failed"));
163+
164+
await expect(init.actuate(setup)).to.be.rejectedWith(
165+
"AI Logic setup failed: Provisioning API failed",
166+
);
167+
});
168+
169+
it("should include config file content in instructions for iOS", async () => {
170+
if (setup.featureInfo?.ailogic) {
171+
setup.featureInfo.ailogic.appId = "1:123456789:ios:abcdef123456";
172+
}
173+
const mockAppInfo = {
174+
projectNumber: "123456789",
175+
appId: "1:123456789:ios:abcdef123456",
176+
platform: AppPlatform.IOS,
177+
};
178+
const mockConfigContent = '<?xml version="1.0" encoding="UTF-8"?>';
179+
const base64Config = Buffer.from(mockConfigContent).toString("base64");
180+
181+
parseAppIdStub.returns(mockAppInfo);
182+
provisionFirebaseAppStub.resolves({ configData: base64Config });
183+
getConfigFileNameStub.returns("GoogleService-Info.plist");
184+
185+
await init.actuate(setup);
186+
187+
expect(setup.instructions).to.include(
188+
"Firebase AI Logic has been enabled for existing IOS app: 1:123456789:ios:abcdef123456",
189+
);
190+
expect(setup.instructions).to.include(
191+
"Save the following content as GoogleService-Info.plist in your app's root directory:",
192+
);
193+
expect(setup.instructions).to.include(mockConfigContent);
194+
});
195+
196+
it("should include platform placement guidance in instructions", async () => {
197+
const mockAppInfo = {
198+
projectNumber: "123456789",
199+
appId: "1:123456789:android:abcdef123456",
200+
platform: AppPlatform.ANDROID,
201+
};
202+
const mockConfigContent = '{"config": "content"}';
203+
const base64Config = Buffer.from(mockConfigContent).toString("base64");
204+
205+
parseAppIdStub.returns(mockAppInfo);
206+
provisionFirebaseAppStub.resolves({ configData: base64Config });
207+
getConfigFileNameStub.returns("google-services.json");
208+
209+
await init.actuate(setup);
210+
211+
expect(setup.instructions).to.include(
212+
"Place this config file in the appropriate location for your platform.",
213+
);
214+
});
215+
});
216+
});

src/init/features/ailogic/index.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { select } from "../../../prompt";
2+
import { Setup } from "../..";
3+
import { FirebaseError } from "../../../error";
4+
import { AppInfo, getConfigFileName, parseAppId } from "./utils";
5+
import { listFirebaseApps, AppMetadata, AppPlatform } from "../../../management/apps";
6+
import { provisionFirebaseApp } from "../../../management/provisioning/provision";
7+
import {
8+
ProvisionAppOptions,
9+
ProvisionFirebaseAppOptions,
10+
} from "../../../management/provisioning/types";
11+
12+
export interface AiLogicInfo {
13+
appId: string;
14+
}
15+
16+
function checkForApps(apps: AppMetadata[]): void {
17+
if (!apps.length) {
18+
throw new FirebaseError(
19+
"No Firebase apps found in this project. Please create an app first using the Firebase Console or 'firebase apps:create'.",
20+
{ exit: 1 },
21+
);
22+
}
23+
}
24+
25+
async function selectAppInteractively(apps: AppMetadata[]): Promise<AppMetadata> {
26+
checkForApps(apps);
27+
28+
const choices = apps.map((app) => {
29+
let displayText = app.displayName || app.appId;
30+
31+
if (!app.displayName) {
32+
if (app.platform === AppPlatform.IOS && "bundleId" in app) {
33+
displayText = app.bundleId as string;
34+
} else if (app.platform === AppPlatform.ANDROID && "packageName" in app) {
35+
displayText = app.packageName as string;
36+
}
37+
}
38+
39+
return {
40+
name: `${displayText} - ${app.appId} (${app.platform})`,
41+
value: app,
42+
};
43+
});
44+
45+
return await select<AppMetadata>({
46+
message: "Select the Firebase app to enable AI Logic for:",
47+
choices,
48+
});
49+
}
50+
51+
/**
52+
* Ask questions for AI Logic setup via CLI
53+
*/
54+
export async function askQuestions(setup: Setup): Promise<void> {
55+
if (!setup.projectId) {
56+
throw new FirebaseError(
57+
"No project ID found. Please ensure you are in a Firebase project directory or specify a project.",
58+
{ exit: 1 },
59+
);
60+
}
61+
62+
const apps = await listFirebaseApps(setup.projectId, AppPlatform.ANY);
63+
const selectedApp = await selectAppInteractively(apps);
64+
65+
// Set up the feature info
66+
if (!setup.featureInfo) {
67+
setup.featureInfo = {};
68+
}
69+
70+
setup.featureInfo.ailogic = {
71+
appId: selectedApp.appId,
72+
};
73+
}
74+
75+
function getAppOptions(appInfo: AppInfo): ProvisionAppOptions {
76+
switch (appInfo.platform) {
77+
case AppPlatform.IOS:
78+
return {
79+
platform: AppPlatform.IOS,
80+
appId: appInfo.appId,
81+
};
82+
case AppPlatform.ANDROID:
83+
return {
84+
platform: AppPlatform.ANDROID,
85+
appId: appInfo.appId,
86+
};
87+
case AppPlatform.WEB:
88+
return {
89+
platform: AppPlatform.WEB,
90+
appId: appInfo.appId,
91+
};
92+
default:
93+
throw new FirebaseError(`Unsupported platform ${appInfo.platform}`, { exit: 1 });
94+
}
95+
}
96+
97+
/**
98+
* AI Logic provisioning: enables AI Logic via API (assumes app and project are already validated)
99+
*/
100+
export async function actuate(setup: Setup): Promise<void> {
101+
const ailogicInfo = setup.featureInfo?.ailogic as AiLogicInfo;
102+
if (!ailogicInfo) {
103+
return;
104+
}
105+
106+
try {
107+
const appInfo = parseAppId(ailogicInfo.appId);
108+
if (!setup.projectId) {
109+
throw new FirebaseError(
110+
"No project ID found. Please ensure you are in a Firebase project directory or specify a project.",
111+
{ exit: 1 },
112+
);
113+
}
114+
115+
// Build provision options and call API directly
116+
const provisionOptions: ProvisionFirebaseAppOptions = {
117+
project: {
118+
displayName: "Firebase Project",
119+
parent: { type: "existing_project", projectId: setup.projectId },
120+
},
121+
app: getAppOptions(appInfo),
122+
features: {
123+
firebaseAiLogicInput: {},
124+
},
125+
};
126+
127+
const response = await provisionFirebaseApp(provisionOptions);
128+
129+
const configFileName = getConfigFileName(appInfo.platform);
130+
const configContent = Buffer.from(response.configData, "base64").toString("utf8");
131+
132+
setup.instructions.push(
133+
`Firebase AI Logic has been enabled for existing ${appInfo.platform} app: ${ailogicInfo.appId}`,
134+
`Save the following content as ${configFileName} in your app's root directory:`,
135+
"",
136+
configContent,
137+
"",
138+
"Place this config file in the appropriate location for your platform.",
139+
);
140+
} catch (error) {
141+
throw new FirebaseError(
142+
`AI Logic setup failed: ${error instanceof Error ? error.message : String(error)}`,
143+
{ original: error instanceof Error ? error : new Error(String(error)), exit: 2 },
144+
);
145+
}
146+
}

0 commit comments

Comments
 (0)