-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(mcp): AI Logic Init Feature (CLI Command and MCP Firebase Init Tool) #9185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
2b87a86
Add Firebase App provisioning API integration
TrCaM 74f3687
Firebase MCP init tool schema change to support provisioning
TrCaM 6c2b93a
Add methods to detect existing local apps from directories
TrCaM 31e6327
Add methods to ensure local file locations for app provisioning
TrCaM b68727f
Wire up provisioning logic with fake API service - Verify logic first…
TrCaM 1c2cc10
Archive mock provisioning service for future reference
TrCaM d411761
Complete first working version of revamped firebase init MCP tool
TrCaM a1cd048
Merge master into feature branch
TrCaM 647eb87
Fix lint/formating - Add unit test for new firebase init tool
TrCaM 3309d71
Fix lint/formating - Add unit test for new firebase init tool
TrCaM 0a0af59
Minor pr fixes
joehan 193d196
Adding missing files
joehan 5f3a6b9
Merge branch 'master' into jh-provisioning
joehan e3afdee
Merge branch 'master' into jh-provisioning
joehan 9814ef9
Merge branch 'master' into jh-provisioning
joehan 6bedcaf
Reduce provisioning integration scope for only AI Logic
TrCaM 265d339
Complete orchestration API provisioning to AI Logic feature (Both CLI…
TrCaM 8b0ff79
Merge remote-tracking branch 'origin/master' into caot/mcp/ailogic
TrCaM d032ff7
Fix lint and unit tests
TrCaM 82d5443
Address Gemini code assist comments
TrCaM c3b33c6
Simplify ailogic init feature to only use existing project and app
TrCaM a7de790
Merge remote-tracking branch 'origin/master' into caot-mcp-ailogic
TrCaM ade68ce
Fix linting
TrCaM f1906de
Addresses comments
TrCaM 671cec2
Merge remote-tracking branch 'origin/master' into caot-mcp-ailogic
TrCaM 9dc2299
Check projectId is non-empty for ailogic feature (MCP tool)
TrCaM c8c02ae
Merge branch 'master' into caot-mcp-ailogic
joehan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
import * as prompt from "../../../prompt"; | ||
import { expect } from "chai"; | ||
import * as sinon from "sinon"; | ||
import * as init from "./index"; | ||
import * as utils from "./utils"; | ||
import * as apps from "../../../management/apps"; | ||
import * as provision from "../../../management/provisioning/provision"; | ||
import { Setup } from "../.."; | ||
import { AppPlatform } from "../../../management/apps"; | ||
|
||
describe("init ailogic", () => { | ||
let sandbox: sinon.SinonSandbox; | ||
|
||
beforeEach(() => { | ||
sandbox = sinon.createSandbox(); | ||
}); | ||
|
||
afterEach(() => { | ||
sandbox.restore(); | ||
}); | ||
|
||
describe("askQuestions", () => { | ||
let listFirebaseAppsStub: sinon.SinonStub; | ||
let selectStub: sinon.SinonStub; | ||
|
||
beforeEach(() => { | ||
listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps"); | ||
selectStub = sandbox.stub(prompt, "select"); | ||
}); | ||
|
||
it("should populate ailogic featureInfo with selected app ID", async () => { | ||
const mockApps = [ | ||
{ | ||
appId: "1:123456789:android:abcdef123456", | ||
displayName: "Test Android App", | ||
platform: AppPlatform.ANDROID, | ||
}, | ||
{ | ||
appId: "1:123456789:web:fedcba654321", | ||
displayName: "Test Web App", | ||
platform: AppPlatform.WEB, | ||
}, | ||
]; | ||
const mockSetup = { projectId: "test-project" } as Setup; | ||
|
||
listFirebaseAppsStub.resolves(mockApps); | ||
selectStub.resolves(mockApps[0]); // Select first app | ||
|
||
await init.askQuestions(mockSetup); | ||
|
||
expect(mockSetup.featureInfo).to.have.property("ailogic"); | ||
expect(mockSetup.featureInfo?.ailogic).to.deep.equal({ | ||
appId: "1:123456789:android:abcdef123456", | ||
}); | ||
}); | ||
|
||
it("should throw error when no project ID is found", async () => { | ||
const mockSetup = {} as Setup; // No projectId | ||
|
||
await expect(init.askQuestions(mockSetup)).to.be.rejectedWith( | ||
"No project ID found. Please ensure you are in a Firebase project directory or specify a project.", | ||
); | ||
|
||
sinon.assert.notCalled(listFirebaseAppsStub); | ||
sinon.assert.notCalled(selectStub); | ||
}); | ||
|
||
it("should throw error when no apps are found", async () => { | ||
const mockSetup = { projectId: "test-project" } as Setup; | ||
listFirebaseAppsStub.resolves([]); // No apps | ||
|
||
await expect(init.askQuestions(mockSetup)).to.be.rejectedWith( | ||
"No Firebase apps found in this project. Please create an app first using the Firebase Console or 'firebase apps:create'.", | ||
); | ||
|
||
sinon.assert.calledWith(listFirebaseAppsStub, "test-project", AppPlatform.ANY); | ||
sinon.assert.notCalled(selectStub); | ||
}); | ||
}); | ||
|
||
describe("actuate", () => { | ||
let setup: Setup; | ||
let parseAppIdStub: sinon.SinonStub; | ||
let provisionFirebaseAppStub: sinon.SinonStub; | ||
let getConfigFileNameStub: sinon.SinonStub; | ||
|
||
beforeEach(() => { | ||
setup = { | ||
config: {}, | ||
rcfile: { projects: {}, targets: {}, etags: {} }, | ||
featureInfo: { | ||
ailogic: { | ||
appId: "1:123456789:android:abcdef123456", | ||
}, | ||
}, | ||
projectId: "test-project", | ||
instructions: [], | ||
} as Setup; | ||
|
||
// Stub only the functions used in actuate (no validation stubs) | ||
parseAppIdStub = sandbox.stub(utils, "parseAppId"); | ||
provisionFirebaseAppStub = sandbox.stub(provision, "provisionFirebaseApp"); | ||
getConfigFileNameStub = sandbox.stub(utils, "getConfigFileName"); | ||
}); | ||
|
||
it("should return early if no ailogic feature info", async () => { | ||
setup.featureInfo = {}; | ||
|
||
await init.actuate(setup); | ||
|
||
// No stubs should be called | ||
sinon.assert.notCalled(parseAppIdStub); | ||
sinon.assert.notCalled(provisionFirebaseAppStub); | ||
}); | ||
|
||
it("should provision existing app successfully", async () => { | ||
const mockAppInfo = { | ||
projectNumber: "123456789", | ||
appId: "1:123456789:android:abcdef123456", | ||
platform: AppPlatform.ANDROID, | ||
}; | ||
const mockConfigContent = '{"config": "content"}'; | ||
const base64Config = Buffer.from(mockConfigContent).toString("base64"); | ||
|
||
parseAppIdStub.returns(mockAppInfo); | ||
provisionFirebaseAppStub.resolves({ configData: base64Config }); | ||
getConfigFileNameStub.returns("google-services.json"); | ||
|
||
await init.actuate(setup); | ||
|
||
sinon.assert.calledWith(parseAppIdStub, "1:123456789:android:abcdef123456"); | ||
sinon.assert.calledOnce(provisionFirebaseAppStub); | ||
|
||
expect(setup.instructions).to.include( | ||
"Firebase AI Logic has been enabled for existing ANDROID app: 1:123456789:android:abcdef123456", | ||
); | ||
expect(setup.instructions).to.include( | ||
"Save the following content as google-services.json in your app's root directory:", | ||
); | ||
expect(setup.instructions).to.include(mockConfigContent); | ||
}); | ||
|
||
it("should throw error if no project ID found", async () => { | ||
setup.projectId = undefined; | ||
|
||
await expect(init.actuate(setup)).to.be.rejectedWith( | ||
"AI Logic setup failed: No project ID found. Please ensure you are in a Firebase project directory or specify a project.", | ||
); | ||
|
||
sinon.assert.calledOnce(parseAppIdStub); | ||
sinon.assert.notCalled(provisionFirebaseAppStub); | ||
}); | ||
|
||
it("should handle provisioning errors gracefully", async () => { | ||
const mockAppInfo = { | ||
projectNumber: "123456789", | ||
appId: "1:123456789:android:abcdef123456", | ||
platform: AppPlatform.ANDROID, | ||
}; | ||
|
||
parseAppIdStub.returns(mockAppInfo); | ||
provisionFirebaseAppStub.throws(new Error("Provisioning API failed")); | ||
|
||
await expect(init.actuate(setup)).to.be.rejectedWith( | ||
"AI Logic setup failed: Provisioning API failed", | ||
); | ||
}); | ||
|
||
it("should include config file content in instructions for iOS", async () => { | ||
if (setup.featureInfo?.ailogic) { | ||
setup.featureInfo.ailogic.appId = "1:123456789:ios:abcdef123456"; | ||
} | ||
const mockAppInfo = { | ||
projectNumber: "123456789", | ||
appId: "1:123456789:ios:abcdef123456", | ||
platform: AppPlatform.IOS, | ||
}; | ||
const mockConfigContent = '<?xml version="1.0" encoding="UTF-8"?>'; | ||
const base64Config = Buffer.from(mockConfigContent).toString("base64"); | ||
|
||
parseAppIdStub.returns(mockAppInfo); | ||
provisionFirebaseAppStub.resolves({ configData: base64Config }); | ||
getConfigFileNameStub.returns("GoogleService-Info.plist"); | ||
|
||
await init.actuate(setup); | ||
|
||
expect(setup.instructions).to.include( | ||
"Firebase AI Logic has been enabled for existing IOS app: 1:123456789:ios:abcdef123456", | ||
); | ||
expect(setup.instructions).to.include( | ||
"Save the following content as GoogleService-Info.plist in your app's root directory:", | ||
); | ||
expect(setup.instructions).to.include(mockConfigContent); | ||
}); | ||
|
||
it("should include platform placement guidance in instructions", async () => { | ||
const mockAppInfo = { | ||
projectNumber: "123456789", | ||
appId: "1:123456789:android:abcdef123456", | ||
platform: AppPlatform.ANDROID, | ||
}; | ||
const mockConfigContent = '{"config": "content"}'; | ||
const base64Config = Buffer.from(mockConfigContent).toString("base64"); | ||
|
||
parseAppIdStub.returns(mockAppInfo); | ||
provisionFirebaseAppStub.resolves({ configData: base64Config }); | ||
getConfigFileNameStub.returns("google-services.json"); | ||
|
||
await init.actuate(setup); | ||
|
||
expect(setup.instructions).to.include( | ||
"Place this config file in the appropriate location for your platform.", | ||
); | ||
}); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
import { select } from "../../../prompt"; | ||
import { Setup } from "../.."; | ||
import { FirebaseError } from "../../../error"; | ||
import { AppInfo, getConfigFileName, parseAppId } from "./utils"; | ||
import { listFirebaseApps, AppMetadata, AppPlatform } from "../../../management/apps"; | ||
import { provisionFirebaseApp } from "../../../management/provisioning/provision"; | ||
import { | ||
ProvisionAppOptions, | ||
ProvisionFirebaseAppOptions, | ||
} from "../../../management/provisioning/types"; | ||
|
||
export interface AiLogicInfo { | ||
appId: string; | ||
} | ||
|
||
function checkForApps(apps: AppMetadata[]): void { | ||
if (!apps.length) { | ||
throw new FirebaseError( | ||
"No Firebase apps found in this project. Please create an app first using the Firebase Console or 'firebase apps:create'.", | ||
{ exit: 1 }, | ||
); | ||
} | ||
} | ||
|
||
async function selectAppInteractively(apps: AppMetadata[]): Promise<AppMetadata> { | ||
checkForApps(apps); | ||
|
||
const choices = apps.map((app) => { | ||
let displayText = app.displayName || app.appId; | ||
|
||
if (!app.displayName) { | ||
if (app.platform === AppPlatform.IOS && "bundleId" in app) { | ||
displayText = app.bundleId as string; | ||
} else if (app.platform === AppPlatform.ANDROID && "packageName" in app) { | ||
displayText = app.packageName as string; | ||
} | ||
} | ||
|
||
return { | ||
name: `${displayText} - ${app.appId} (${app.platform})`, | ||
value: app, | ||
}; | ||
}); | ||
|
||
return await select<AppMetadata>({ | ||
message: "Select the Firebase app to enable AI Logic for:", | ||
choices, | ||
}); | ||
} | ||
|
||
/** | ||
* Ask questions for AI Logic setup via CLI | ||
*/ | ||
export async function askQuestions(setup: Setup): Promise<void> { | ||
if (!setup.projectId) { | ||
throw new FirebaseError( | ||
"No project ID found. Please ensure you are in a Firebase project directory or specify a project.", | ||
{ exit: 1 }, | ||
); | ||
} | ||
|
||
const apps = await listFirebaseApps(setup.projectId, AppPlatform.ANY); | ||
const selectedApp = await selectAppInteractively(apps); | ||
|
||
// Set up the feature info | ||
if (!setup.featureInfo) { | ||
setup.featureInfo = {}; | ||
} | ||
|
||
setup.featureInfo.ailogic = { | ||
appId: selectedApp.appId, | ||
}; | ||
} | ||
|
||
function getAppOptions(appInfo: AppInfo): ProvisionAppOptions { | ||
switch (appInfo.platform) { | ||
case AppPlatform.IOS: | ||
return { | ||
platform: AppPlatform.IOS, | ||
appId: appInfo.appId, | ||
}; | ||
case AppPlatform.ANDROID: | ||
return { | ||
platform: AppPlatform.ANDROID, | ||
appId: appInfo.appId, | ||
}; | ||
case AppPlatform.WEB: | ||
return { | ||
platform: AppPlatform.WEB, | ||
appId: appInfo.appId, | ||
}; | ||
default: | ||
throw new FirebaseError(`Unsupported platform ${appInfo.platform}`, { exit: 1 }); | ||
} | ||
} | ||
|
||
/** | ||
* AI Logic provisioning: enables AI Logic via API (assumes app and project are already validated) | ||
*/ | ||
export async function actuate(setup: Setup): Promise<void> { | ||
const ailogicInfo = setup.featureInfo?.ailogic as AiLogicInfo; | ||
if (!ailogicInfo) { | ||
return; | ||
} | ||
|
||
try { | ||
const appInfo = parseAppId(ailogicInfo.appId); | ||
if (!setup.projectId) { | ||
throw new FirebaseError( | ||
"No project ID found. Please ensure you are in a Firebase project directory or specify a project.", | ||
{ exit: 1 }, | ||
); | ||
} | ||
|
||
// Build provision options and call API directly | ||
const provisionOptions: ProvisionFirebaseAppOptions = { | ||
project: { | ||
displayName: "Firebase Project", | ||
parent: { type: "existing_project", projectId: setup.projectId }, | ||
}, | ||
app: getAppOptions(appInfo), | ||
features: { | ||
firebaseAiLogicInput: {}, | ||
}, | ||
}; | ||
|
||
const response = await provisionFirebaseApp(provisionOptions); | ||
|
||
const configFileName = getConfigFileName(appInfo.platform); | ||
const configContent = Buffer.from(response.configData, "base64").toString("utf8"); | ||
|
||
setup.instructions.push( | ||
`Firebase AI Logic has been enabled for existing ${appInfo.platform} app: ${ailogicInfo.appId}`, | ||
`Save the following content as ${configFileName} in your app's root directory:`, | ||
"", | ||
configContent, | ||
"", | ||
"Place this config file in the appropriate location for your platform.", | ||
); | ||
} catch (error) { | ||
throw new FirebaseError( | ||
`AI Logic setup failed: ${error instanceof Error ? error.message : String(error)}`, | ||
{ original: error instanceof Error ? error : new Error(String(error)), exit: 2 }, | ||
); | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.