diff --git a/src/init/features/ailogic.ts b/src/init/features/ailogic.ts new file mode 100644 index 00000000000..9990ccb6ebe --- /dev/null +++ b/src/init/features/ailogic.ts @@ -0,0 +1,18 @@ +export interface AiLogicInfo { + // Minimal interface - no configuration needed + [key: string]: unknown; +} + +/** + * + */ +export async function askQuestions(): Promise { + // No-op - questions already handled by MCP schema +} + +/** + * + */ +export async function actuate(): Promise { + // No-op - AI Logic provisioning happens via API, no local config changes needed +} diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 0063eae1d8b..f5b5d34de50 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -40,3 +40,8 @@ export { actuate as apptestingAcutate, } from "./apptesting"; export { doSetup as aitools } from "./aitools"; +export { + askQuestions as aiLogicAskQuestions, + AiLogicInfo, + actuate as aiLogicActuate, +} from "./ailogic"; diff --git a/src/init/index.ts b/src/init/index.ts index dc7171186cf..75600ed8f54 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -38,6 +38,7 @@ export interface SetupInfo { dataconnectSdk?: features.DataconnectSdkInfo; storage?: features.StorageInfo; apptesting?: features.ApptestingInfo; + ailogic?: features.AiLogicInfo; } interface Feature { @@ -96,11 +97,19 @@ const featuresList: Feature[] = [ askQuestions: features.apptestingAskQuestions, actuate: features.apptestingAcutate, }, + { + name: "ailogic", + askQuestions: features.aiLogicAskQuestions, + actuate: features.aiLogicActuate, + }, { name: "aitools", displayName: "AI Tools", doSetup: features.aitools }, ]; const featureMap = new Map(featuresList.map((feature) => [feature.name, feature])); +/** + * + */ export async function init(setup: Setup, config: Config, options: any): Promise { const nextFeature = setup.features?.shift(); if (nextFeature) { diff --git a/src/management/provision.spec.ts b/src/management/provision.spec.ts new file mode 100644 index 00000000000..cfc21cc3b23 --- /dev/null +++ b/src/management/provision.spec.ts @@ -0,0 +1,1158 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as nock from "nock"; +import { firebaseApiOrigin } from "../api"; +import * as pollUtils from "../operation-poller"; +import { + buildAppNamespace, + buildParentString, + buildProvisionRequest, + provisionFirebaseApp, + ProvisionAppOptions, + ProvisionFirebaseAppOptions, + ProvisionFirebaseAppResponse, +} from "./provision"; +import { FirebaseError } from "../error"; +import { AppPlatform } from "./apps"; + +// Test constants +const BUNDLE_ID = "com.example.testapp"; +const PACKAGE_NAME = "com.example.androidapp"; +const WEB_APP_ID = "web-app-123"; +const PROJECT_DISPLAY_NAME = "Test Project"; +const REQUEST_ID = "test-request-id-123"; +const LOCATION = "us-central1"; +const OPERATION_RESOURCE_NAME = "operations/provision.123456789"; +const APP_RESOURCE = "projects/test-project/apps/123456789"; +const CONFIG_DATA = "base64-encoded-config-data"; +const CONFIG_MIME_TYPE = "application/json"; + +describe("Provision module", () => { + let sandbox: sinon.SinonSandbox; + let pollOperationStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + pollOperationStub = sandbox.stub(pollUtils, "pollOperation"); + pollOperationStub.throws("Unexpected poll call"); + nock.disableNetConnect(); + }); + + afterEach(() => { + sandbox.restore(); + nock.enableNetConnect(); + nock.cleanAll(); + }); + + // Phase 1: buildAppNamespace helper function tests + describe("buildAppNamespace", () => { + it("should return bundleId for iOS apps", () => { + const iosApp: ProvisionAppOptions = { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + }; + + const result = buildAppNamespace(iosApp); + + expect(result).to.equal(BUNDLE_ID); + }); + + it("should return packageName for Android apps", () => { + const androidApp: ProvisionAppOptions = { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + }; + + const result = buildAppNamespace(androidApp); + + expect(result).to.equal(PACKAGE_NAME); + }); + + it("should return webAppId for Web apps", () => { + const webApp: ProvisionAppOptions = { + platform: AppPlatform.WEB, + webAppId: WEB_APP_ID, + }; + + const result = buildAppNamespace(webApp); + + expect(result).to.equal(WEB_APP_ID); + }); + + it("should throw error for unsupported platform", () => { + const unsupportedApp = { + platform: "UNSUPPORTED" as any, + bundleId: BUNDLE_ID, + }; + + expect(() => buildAppNamespace(unsupportedApp)).to.throw("Unsupported platform"); + }); + }); + + // Phase 2: buildParentString helper function tests + describe("buildParentString", () => { + it("should format existing project parent correctly", () => { + const parent = { + type: "existing_project" as const, + projectId: "my-project-123", + }; + + const result = buildParentString(parent); + + expect(result).to.equal("projects/my-project-123"); + }); + + it("should format organization parent correctly", () => { + const parent = { + type: "organization" as const, + organizationId: "123456789", + }; + + const result = buildParentString(parent); + + expect(result).to.equal("organizations/123456789"); + }); + + it("should format folder parent correctly", () => { + const parent = { + type: "folder" as const, + folderId: "987654321", + }; + + const result = buildParentString(parent); + + expect(result).to.equal("folders/987654321"); + }); + + it("should throw error for unsupported parent type", () => { + const unsupportedParent = { + type: "invalid" as any, + projectId: "test", + }; + + expect(() => buildParentString(unsupportedParent)).to.throw("Unsupported parent type"); + }); + }); + + // Phase 3: buildProvisionRequest helper function tests + describe("buildProvisionRequest", () => { + it("should build basic request with minimal options", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + webInput: {}, + }); + }); + + it("should include parent when specified", () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "existing_project", projectId: "my-project-123" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + parent: "projects/my-project-123", + webInput: {}, + }); + }); + + it("should include location when specified", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { location: LOCATION }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + location: LOCATION, + webInput: {}, + }); + }); + + it("should include requestId when specified", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + requestId: REQUEST_ID, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + requestId: REQUEST_ID, + webInput: {}, + }); + }); + + it("should build iOS-specific request correctly", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appStoreId: "12345", + teamId: "TEAM123", + }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: BUNDLE_ID, + displayName: PROJECT_DISPLAY_NAME, + appleInput: { + appStoreId: "12345", + teamId: "TEAM123", + }, + }); + }); + + it("should build Android-specific request correctly", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: PACKAGE_NAME, + displayName: PROJECT_DISPLAY_NAME, + androidInput: { + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }, + }); + }); + + it("should build Web-specific request correctly", () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.equal({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + webInput: {}, + }); + }); + + it("should include AI features when specified", () => { + const aiFeatures = { enableAiLogic: true, model: "gemini-pro" }; + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { firebaseAiLogicInput: aiFeatures }, + }; + + const result = buildProvisionRequest(options); + + expect(result).to.deep.include({ + appNamespace: WEB_APP_ID, + displayName: PROJECT_DISPLAY_NAME, + firebaseAiLogicInput: aiFeatures, + webInput: {}, + }); + }); + }); + + // Phase 4: Main Function Success Cases + describe("provisionFirebaseApp - Success Cases", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + it("should provision iOS app successfully", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.IOS, bundleId: BUNDLE_ID }, + }; + + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should provision Android app successfully", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.ANDROID, packageName: PACKAGE_NAME }, + }; + + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should provision Web app successfully", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should provision with existing project parent", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "existing_project", projectId: "parent-project-123" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call with parent verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.parent).to.equal("projects/parent-project-123"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with organization parent", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "organization", organizationId: "987654321" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call with parent verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.parent).to.equal("organizations/987654321"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with folder parent", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { + displayName: PROJECT_DISPLAY_NAME, + parent: { type: "folder", folderId: "123456789" }, + }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call with parent verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.parent).to.equal("folders/123456789"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with requestId for idempotency", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + requestId: REQUEST_ID, + }; + + // Mock API call with requestId verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.requestId).to.equal(REQUEST_ID); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with custom location", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { location: LOCATION }, + }; + + // Mock API call with location verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.location).to.equal(LOCATION); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with AI features enabled", async () => { + const aiFeatures = { enableAiLogic: true, model: "gemini-pro" }; + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + features: { firebaseAiLogicInput: aiFeatures }, + }; + + // Mock API call with AI features verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.firebaseAiLogicInput).to.deep.equal(aiFeatures); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with all optional iOS fields", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appStoreId: "12345", + teamId: "TEAM123", + }, + }; + + // Mock API call with iOS fields verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appleInput).to.deep.equal({ + appStoreId: "12345", + teamId: "TEAM123", + }); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should provision with all optional Android fields", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }, + }; + + // Mock API call with Android fields verification + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.androidInput).to.deep.equal({ + sha1Hashes: ["sha1hash1", "sha1hash2"], + sha256Hashes: ["sha256hash1"], + }); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + }); + + // Phase 5: Request ID Behavior Tests + describe("provisionFirebaseApp - Request ID Behavior", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + const baseOptions: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + it("should work without requestId (undefined)", async () => { + const options = { ...baseOptions }; + // Explicitly ensure requestId is undefined + expect(options.requestId).to.be.undefined; + + // Mock API call and verify requestId is NOT included in request + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body).to.not.have.property("requestId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should work with empty requestId", async () => { + const options: ProvisionFirebaseAppOptions = { + ...baseOptions, + requestId: "", + }; + + // Mock API call and verify empty requestId is NOT included in request + // (empty string is falsy, so it gets filtered out by the implementation) + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body).to.not.have.property("requestId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should work with valid requestId", async () => { + const options: ProvisionFirebaseAppOptions = { + ...baseOptions, + requestId: REQUEST_ID, + }; + + // Mock API call and verify requestId is included in request + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.requestId).to.equal(REQUEST_ID); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should pass requestId to API when provided", async () => { + const customRequestId = "custom-request-12345"; + const options: ProvisionFirebaseAppOptions = { + ...baseOptions, + requestId: customRequestId, + }; + + let actualRequestBody: any; + + // Mock API call and capture request body + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, (uri, requestBody) => { + actualRequestBody = requestBody; + return { name: OPERATION_RESOURCE_NAME }; + }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + // Verify requestId was passed correctly + expect(actualRequestBody.requestId).to.equal(customRequestId); + expect(result).to.deep.equal(mockResponse); + }); + + it("should not include requestId in request when omitted", async () => { + const options = { ...baseOptions }; + // Ensure no requestId property exists + delete (options as any).requestId; + + let actualRequestBody: any; + + // Mock API call and capture request body + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, (uri, requestBody) => { + actualRequestBody = requestBody; + return { name: OPERATION_RESOURCE_NAME }; + }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + // Verify requestId property does not exist in request + expect(actualRequestBody).to.not.have.property("requestId"); + expect(result).to.deep.equal(mockResponse); + }); + }); + + // Phase 6: Error Handling Tests + describe("provisionFirebaseApp - Error Cases", () => { + const baseOptions: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + it("should reject if API call fails with 404", async () => { + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(404, { error: { message: "Project not found" } }); + + // Ensure polling is never called for API failures + pollOperationStub.onFirstCall().throws("Polling should not be called on API failure"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should reject if API call fails with 403", async () => { + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(403, { error: { message: "Permission denied" } }); + + // Ensure polling is never called for API failures + pollOperationStub.onFirstCall().throws("Polling should not be called on API failure"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should reject if API call fails with 500", async () => { + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(500, { error: { message: "Internal server error" } }); + + // Ensure polling is never called for API failures + pollOperationStub.onFirstCall().throws("Polling should not be called on API failure"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should reject if polling operation fails", async () => { + // Mock successful API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling failure + const pollingError = new Error("Polling operation failed"); + pollOperationStub.onFirstCall().rejects(pollingError); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Polling operation failed"); + expect(pollOperationStub.calledOnce).to.be.true; + } + }); + + it("should reject if polling operation times out", async () => { + // Mock successful API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling timeout + const timeoutError = new Error("Operation timed out"); + timeoutError.name = "TIMEOUT"; + pollOperationStub.onFirstCall().rejects(timeoutError); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Operation timed out"); + expect(pollOperationStub.calledOnce).to.be.true; + } + }); + + it("should wrap unknown errors properly", async () => { + // Mock API call that throws unexpected error + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .replyWithError("Unexpected network error"); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Failed to make request"); + expect(pollOperationStub.notCalled).to.be.true; + } + }); + + it("should preserve original error information", async () => { + const originalError = new Error("Original error message"); + originalError.name = "CustomError"; + (originalError as any).code = "CUSTOM_CODE"; + + // Mock API call failure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling failure with custom error + pollOperationStub.onFirstCall().rejects(originalError); + + try { + await provisionFirebaseApp(baseOptions); + expect.fail("Expected function to throw"); + } catch (error: any) { + expect(error).to.be.instanceOf(FirebaseError); + expect(error.message).to.include("Failed to provision Firebase app"); + expect(error.message).to.include("Original error message"); + + // Verify original error is preserved + const firebaseError = error as FirebaseError; + expect(firebaseError.original).to.equal(originalError); + expect(firebaseError.exit).to.equal(2); + } + }); + }); + + // Phase 7: Platform Validation Tests + describe("Platform-Specific Validation", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + it("should require bundleId for iOS apps", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.IOS, bundleId: BUNDLE_ID }, + }; + + // Mock API call to verify bundleId is included as appNamespace + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appNamespace).to.equal(BUNDLE_ID); + expect(body.appleInput).to.exist; + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should require packageName for Android apps", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.ANDROID, packageName: PACKAGE_NAME }, + }; + + // Mock API call to verify packageName is included as appNamespace + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appNamespace).to.equal(PACKAGE_NAME); + expect(body.androidInput).to.exist; + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should require webAppId for Web apps", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + // Mock API call to verify webAppId is included as appNamespace + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appNamespace).to.equal(WEB_APP_ID); + expect(body.webInput).to.exist; + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should accept optional appStoreId for iOS", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + appStoreId: "123456789", + }, + }; + + // Mock API call to verify appStoreId is included + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appleInput.appStoreId).to.equal("123456789"); + expect(body.appleInput).to.not.have.property("teamId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should accept optional teamId for iOS", async () => { + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.IOS, + bundleId: BUNDLE_ID, + teamId: "TEAM123", + }, + }; + + // Mock API call to verify teamId is included + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.appleInput.teamId).to.equal("TEAM123"); + expect(body.appleInput).to.not.have.property("appStoreId"); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + + it("should accept optional SHA hashes for Android", async () => { + const sha1Hashes = ["sha1hash1", "sha1hash2"]; + const sha256Hashes = ["sha256hash1"]; + + const options: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { + platform: AppPlatform.ANDROID, + packageName: PACKAGE_NAME, + sha1Hashes, + sha256Hashes, + }, + }; + + // Mock API call to verify SHA hashes are included + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp", (body) => { + expect(body.androidInput).to.deep.equal({ + sha1Hashes, + sha256Hashes, + }); + return true; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(options); + + expect(result).to.deep.equal(mockResponse); + }); + }); + + // Phase 8: API Integration Tests + describe("API Integration", () => { + const mockResponse: ProvisionFirebaseAppResponse = { + configMimeType: CONFIG_MIME_TYPE, + configData: CONFIG_DATA, + appResource: APP_RESOURCE, + }; + + const baseOptions: ProvisionFirebaseAppOptions = { + project: { displayName: PROJECT_DISPLAY_NAME }, + app: { platform: AppPlatform.WEB, webAppId: WEB_APP_ID }, + }; + + it("should call correct API endpoint", async () => { + let actualApiEndpoint: string | undefined; + + // Capture the API endpoint that was called + nock(firebaseApiOrigin()) + .post((uri) => { + actualApiEndpoint = uri; + return uri === "/v1alpha/firebase:provisionFirebaseApp"; + }) + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(baseOptions); + + expect(actualApiEndpoint).to.equal("/v1alpha/firebase:provisionFirebaseApp"); + expect(result).to.deep.equal(mockResponse); + }); + + it("should use correct API version (v1alpha)", async () => { + // Mock the exact v1alpha endpoint + const scope = nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(baseOptions); + + // Verify the exact endpoint was called + expect(scope.isDone()).to.be.true; + expect(result).to.deep.equal(mockResponse); + }); + + it("should poll with correct API version (v1beta1)", async () => { + // Mock initial API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling - verify the polling call parameters + pollOperationStub.onFirstCall().callsFake(async (options) => { + expect(options.apiOrigin).to.equal(firebaseApiOrigin()); + expect(options.apiVersion).to.equal("v1beta1"); + expect(options.operationResourceName).to.equal(OPERATION_RESOURCE_NAME); + expect(options.pollerName).to.equal("Provision Firebase App Poller"); + return mockResponse; + }); + + const result = await provisionFirebaseApp(baseOptions); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should pass correct request body structure", async () => { + let actualRequestBody: any; + + // Capture and verify request body structure + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, (uri, requestBody) => { + actualRequestBody = requestBody; + return { name: OPERATION_RESOURCE_NAME }; + }); + + // Mock polling + pollOperationStub.onFirstCall().resolves(mockResponse); + + const result = await provisionFirebaseApp(baseOptions); + + // Verify request body has correct structure + expect(actualRequestBody).to.have.property("appNamespace", WEB_APP_ID); + expect(actualRequestBody).to.have.property("displayName", PROJECT_DISPLAY_NAME); + expect(actualRequestBody).to.have.property("webInput"); + expect(actualRequestBody.webInput).to.deep.equal({}); + expect(result).to.deep.equal(mockResponse); + }); + + it("should handle LRO polling correctly", async () => { + // Mock initial API call returning operation + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling with detailed verification + pollOperationStub.onFirstCall().callsFake(async (options) => { + // Verify all polling parameters are correct + expect(options).to.deep.include({ + pollerName: "Provision Firebase App Poller", + apiOrigin: firebaseApiOrigin(), + apiVersion: "v1beta1", + operationResourceName: OPERATION_RESOURCE_NAME, + }); + + // Simulate successful polling result + return mockResponse; + }); + + const result = await provisionFirebaseApp(baseOptions); + + expect(result).to.deep.equal(mockResponse); + expect(pollOperationStub.calledOnce).to.be.true; + }); + + it("should return correct response type", async () => { + // Mock API call + nock(firebaseApiOrigin()) + .post("/v1alpha/firebase:provisionFirebaseApp") + .reply(200, { name: OPERATION_RESOURCE_NAME }); + + // Mock polling with specific response structure + const expectedResponse: ProvisionFirebaseAppResponse = { + configMimeType: "application/json", + configData: "eyJ0ZXN0IjoiZGF0YSJ9", // base64 encoded test data + appResource: "projects/test-project-123/apps/web-app-456", + }; + + pollOperationStub.onFirstCall().resolves(expectedResponse); + + const result = await provisionFirebaseApp(baseOptions); + + // Verify the response structure and types + expect(result).to.have.property("configMimeType"); + expect(result).to.have.property("configData"); + expect(result).to.have.property("appResource"); + expect(typeof result.configMimeType).to.equal("string"); + expect(typeof result.configData).to.equal("string"); + expect(typeof result.appResource).to.equal("string"); + expect(result).to.deep.equal(expectedResponse); + }); + }); +}); diff --git a/src/management/provision.ts b/src/management/provision.ts new file mode 100644 index 00000000000..d0f0b818a55 --- /dev/null +++ b/src/management/provision.ts @@ -0,0 +1,189 @@ +import { Client } from "../apiv2"; +import { firebaseApiOrigin } from "../api"; +import { FirebaseError } from "../error"; +import { pollOperation } from "../operation-poller"; +import { AppPlatform } from "./apps"; + +interface BaseProvisionAppOptions { + platform: AppPlatform; +} + +interface IosAppOptions extends BaseProvisionAppOptions { + platform: AppPlatform.IOS; + bundleId: string; + appStoreId?: string; + teamId?: string; +} + +interface AndroidAppOptions extends BaseProvisionAppOptions { + platform: AppPlatform.ANDROID; + packageName: string; + sha1Hashes?: string[]; + sha256Hashes?: string[]; +} + +interface WebAppOptions extends BaseProvisionAppOptions { + platform: AppPlatform.WEB; + webAppId: string; +} + +export type ProvisionAppOptions = IosAppOptions | AndroidAppOptions | WebAppOptions; + +export interface ProvisionFirebaseAppResponse { + configMimeType: string; + configData: string; + appResource: string; +} + +interface ExistingProjectInput { + type: "existing_project"; + projectId: string; +} + +interface OrganizationInput { + type: "organization"; + organizationId: string; +} + +interface FolderInput { + type: "folder"; + folderId: string; +} + +type ProjectParentInput = ExistingProjectInput | OrganizationInput | FolderInput; + +export interface ProvisionProjectOptions { + displayName?: string; + parent?: ProjectParentInput; + // TODO(caot): Support specifying projectLabels and billing. + // projectLabels?: Record; + // cloudBillingAccountId?: string; +} + +export interface ProvisionFeatureOptions { + location?: string; + firebaseAiLogicInput?: Record; +} + +export interface ProvisionFirebaseAppOptions { + project: ProvisionProjectOptions; + app: ProvisionAppOptions; + features?: ProvisionFeatureOptions; + requestId?: string; +} + +const apiClient = new Client({ + urlPrefix: firebaseApiOrigin(), + apiVersion: "v1alpha", +}); + +export function buildAppNamespace(app: ProvisionAppOptions): string { + switch (app.platform) { + case AppPlatform.IOS: + return app.bundleId; + case AppPlatform.ANDROID: + return app.packageName; + case AppPlatform.WEB: + return app.webAppId; + default: + throw new Error("Unsupported platform"); + } +} + +export function buildParentString(parent: ProjectParentInput): string { + switch (parent.type) { + case "existing_project": + return `projects/${parent.projectId}`; + case "organization": + return `organizations/${parent.organizationId}`; + case "folder": + return `folders/${parent.folderId}`; + default: + throw new Error("Unsupported parent type"); + } +} + +export function buildProvisionRequest( + options: ProvisionFirebaseAppOptions, +): Record { + const request: Record = { + appNamespace: buildAppNamespace(options.app), + displayName: options.project.displayName, + }; + + if (options.project.parent) { + request.parent = buildParentString(options.project.parent); + } + + if (options.features?.location) { + request.location = options.features.location; + } + + if (options.requestId) { + request.requestId = options.requestId; + } + + // if (options.project.projectLabels) request.projectLabels = options.project.projectLabels; // Not enabled yet + // if (options.project.cloudBillingAccountId) request.cloudBillingAccountId = options.project.cloudBillingAccountId; // Not enabled yet + + switch (options.app.platform) { + case AppPlatform.IOS: + request.appleInput = { + appStoreId: options.app.appStoreId, + teamId: options.app.teamId, + }; + break; + case AppPlatform.ANDROID: + request.androidInput = { + sha1Hashes: options.app.sha1Hashes, + sha256Hashes: options.app.sha256Hashes, + }; + break; + case AppPlatform.WEB: + request.webInput = {}; + break; + } + + if (options.features?.firebaseAiLogicInput) { + request.firebaseAiLogicInput = options.features.firebaseAiLogicInput; + } + + return request; +} + +/** + * Provisions a new Firebase App and associated resources using the provisionFirebaseApp API. + * @param options The provision options including project, app, and feature configurations + * @return Promise resolving to the provisioned Firebase app response containing config data and app resource name + */ +export async function provisionFirebaseApp( + options: ProvisionFirebaseAppOptions, +): Promise { + try { + const request = buildProvisionRequest(options); + + const response = await apiClient.request({ + method: "POST", + path: "/firebase:provisionFirebaseApp", + body: request, + }); + + const result = await pollOperation({ + pollerName: "Provision Firebase App Poller", + apiOrigin: firebaseApiOrigin(), + apiVersion: "v1beta1", + operationResourceName: response.body.name, + masterTimeout: 300000, // 5 minutes + backoff: 100, // Initial backoff of 100ms + maxBackoff: 5000, // Max backoff of 5s + }); + + return result; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + throw new FirebaseError(`Failed to provision Firebase app: ${errorMessage}`, { + exit: 2, + original: err instanceof Error ? err : new Error(String(err)), + }); + } +} diff --git a/src/mcp/tools/core/init.spec.ts b/src/mcp/tools/core/init.spec.ts new file mode 100644 index 00000000000..102ccba61a9 --- /dev/null +++ b/src/mcp/tools/core/init.spec.ts @@ -0,0 +1,683 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { validateProvisioningInputs, buildProvisionOptions, init } from "./init"; +import { AppPlatform } from "../../../management/apps"; +import { McpContext } from "../../types"; +import { Config } from "../../../config"; +import { RC } from "../../../rc"; + +describe("init", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("validateProvisioningInputs", () => { + it("should not validate when provisioning is disabled", () => { + expect(() => validateProvisioningInputs()).to.not.throw(); + expect(() => + validateProvisioningInputs({ + enable: false, + overwrite_project: false, + overwrite_configs: false, + }), + ).to.not.throw(); + }); + + it("should throw error when provisioning enabled but no app", () => { + const provisioning = { enable: true, overwrite_project: false, overwrite_configs: false }; + expect(() => validateProvisioningInputs(provisioning)).to.throw( + "app is required when provisioning is enabled", + ); + }); + + it("should throw error when app platform missing", () => { + const provisioning = { enable: true, overwrite_project: false, overwrite_configs: false }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + const app = {} as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect(() => validateProvisioningInputs(provisioning, undefined, app)).to.throw( + "app.platform is required when provisioning is enabled", + ); + }); + + it("should throw error for iOS without bundle_id", () => { + const provisioning = { enable: true, overwrite_project: false, overwrite_configs: false }; + const app = { platform: "ios" as const }; + expect(() => validateProvisioningInputs(provisioning, undefined, app)).to.throw( + "bundle_id is required for iOS apps", + ); + }); + + it("should throw error for Android without package_name", () => { + const provisioning = { enable: true, overwrite_project: false, overwrite_configs: false }; + const app = { platform: "android" as const }; + expect(() => validateProvisioningInputs(provisioning, undefined, app)).to.throw( + "package_name is required for Android apps", + ); + }); + + it("should throw error for Web without web_app_id", () => { + const provisioning = { enable: true, overwrite_project: false, overwrite_configs: false }; + const app = { platform: "web" as const }; + expect(() => validateProvisioningInputs(provisioning, undefined, app)).to.throw( + "web_app_id is required for Web apps", + ); + }); + + it("should validate valid parent formats", () => { + const provisioning = { enable: true, overwrite_project: false, overwrite_configs: false }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + + const validParents = ["projects/my-project", "folders/123456789", "organizations/org-id"]; + + validParents.forEach((parent) => { + const project = { parent }; + expect(() => validateProvisioningInputs(provisioning, project, app)).to.not.throw(); + }); + }); + + it("should throw error for invalid parent format", () => { + const provisioning = { enable: true, overwrite_project: false, overwrite_configs: false }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + const project = { parent: "invalid-format" }; + + expect(() => validateProvisioningInputs(provisioning, project, app)).to.throw( + "parent must be in format: 'projects/id', 'folders/id', or 'organizations/id'", + ); + }); + }); + + describe("buildProvisionOptions", () => { + it("should throw error when app platform missing", () => { + expect(() => buildProvisionOptions()).to.throw("App platform is required for provisioning"); + }); + + it("should build correct options for iOS app", () => { + const project = { display_name: "Test Project", location: "us-central1" }; + const app = { + platform: "ios" as const, + bundle_id: "com.example.app", + app_store_id: "123456789", + team_id: "TEAMID123", + }; + + const result = buildProvisionOptions(project, app); + + expect(result).to.deep.equal({ + project: { + displayName: "Test Project", + }, + app: { + platform: AppPlatform.IOS, + bundleId: "com.example.app", + appStoreId: "123456789", + teamId: "TEAMID123", + }, + features: { + location: "us-central1", + }, + }); + }); + + it("should throw error for iOS without bundle_id", () => { + const app = { platform: "ios" as const }; + expect(() => buildProvisionOptions(undefined, app)).to.throw( + "bundle_id is required for iOS apps", + ); + }); + + it("should build correct options for Android app", () => { + const app = { + platform: "android" as const, + package_name: "com.example.app", + sha1_hashes: ["sha1hash"], + sha256_hashes: ["sha256hash"], + }; + + const result = buildProvisionOptions(undefined, app); + + expect(result).to.deep.equal({ + project: { + displayName: "Firebase Project", + }, + app: { + platform: AppPlatform.ANDROID, + packageName: "com.example.app", + sha1Hashes: ["sha1hash"], + sha256Hashes: ["sha256hash"], + }, + }); + }); + + it("should throw error for Android without package_name", () => { + const app = { platform: "android" as const }; + expect(() => buildProvisionOptions(undefined, app)).to.throw( + "package_name is required for Android apps", + ); + }); + + it("should build correct options for Web app", () => { + const app = { + platform: "web" as const, + web_app_id: "web-app-123", + }; + + const result = buildProvisionOptions(undefined, app); + + expect(result).to.deep.equal({ + project: { + displayName: "Firebase Project", + }, + app: { + platform: AppPlatform.WEB, + webAppId: "web-app-123", + }, + }); + }); + + it("should throw error for Web without web_app_id", () => { + const app = { platform: "web" as const }; + expect(() => buildProvisionOptions(undefined, app)).to.throw( + "web_app_id is required for Web apps", + ); + }); + + it("should handle parent resource parsing", () => { + const project = { parent: "projects/existing-project" }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + + const result = buildProvisionOptions(project, app); + + expect(result.project.parent).to.deep.equal({ + type: "existing_project", + projectId: "existing-project", + }); + }); + + it("should handle folder parent", () => { + const project = { parent: "folders/123456789" }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + + const result = buildProvisionOptions(project, app); + + expect(result.project.parent).to.deep.equal({ + type: "folder", + folderId: "123456789", + }); + }); + + it("should handle organization parent", () => { + const project = { parent: "organizations/org-id" }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + + const result = buildProvisionOptions(project, app); + + expect(result.project.parent).to.deep.equal({ + type: "organization", + organizationId: "org-id", + }); + }); + + it("should add AI Logic feature when requested", () => { + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + const features = { ai_logic: true }; + + const result = buildProvisionOptions(undefined, app, features); + + expect(result.features?.firebaseAiLogicInput).to.deep.equal({}); + }); + }); + + describe("init tool", () => { + let actuateStub: sinon.SinonStub; + let requireGeminiStub: sinon.SinonStub; + + let mockConfig: Partial; + let mockRc: Partial; + let mockContext: Partial; + + beforeEach(async () => { + // Use dynamic imports to avoid require warnings + const initModule = await import("../../../init/index"); + const errorsModule = await import("../../errors"); + const globModule = await import("glob"); + + actuateStub = sandbox.stub(initModule, "actuate").resolves(); + requireGeminiStub = sandbox.stub(errorsModule, "requireGeminiToS").resolves(); + + // Mock glob function since resolveAppContext uses it internally + sandbox.stub(globModule, "glob").resolves([]); + + // Setup mock config + mockConfig = { + projectDir: "/test/project", + src: { projects: {} }, + writeProjectFile: sandbox.stub(), + } as Partial; + + // Setup mock rc + mockRc = { + data: { projects: {} }, + } as Partial; + + // Setup mock context + mockContext = { + projectId: "test-project", + accountEmail: null, + config: mockConfig as Config, + rc: mockRc as RC, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + host: {} as any, + }; + }); + + it("should initialize Firestore feature without provisioning", async () => { + const features = { + firestore: { + database_id: "test-db", + location_id: "us-central1", + rules_filename: "firestore.rules", + rules: "rules_version = '2';", + }, + }; + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(actuateStub).to.have.been.calledOnce; + expect(result.content[0].text).to.include("Successfully setup those features: firestore"); + }); + + it("should initialize Database feature without provisioning", async () => { + const features = { + database: { + rules_filename: "database.rules.json", + rules: '{"rules": {".read": "auth != null", ".write": "auth != null"}}', + }, + }; + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(actuateStub).to.have.been.calledOnce; + expect(result.content[0].text).to.include("Successfully setup those features: database"); + }); + + it("should initialize Data Connect feature without provisioning", async () => { + const features = { + dataconnect: { + service_id: "test-service", + location_id: "us-central1", + cloudsql_instance_id: "test-instance", + cloudsql_database: "testdb", + provision_cloudsql: false, + }, + }; + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(actuateStub).to.have.been.calledOnce; + expect(result.content[0].text).to.include("Successfully setup those features: dataconnect"); + }); + + it("should check Gemini ToS for Data Connect with app description", async () => { + const features = { + dataconnect: { + app_description: "A test app for data connect", + }, + }; + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(requireGeminiStub).to.have.been.calledWith("test-project"); + expect(result.content[0].text).to.include("Successfully setup those features: dataconnect"); + }); + + it("should return error if Gemini ToS check fails", async () => { + const features = { + dataconnect: { + app_description: "A test app", + }, + }; + + const geminiError = { error: "Gemini ToS not accepted" }; + requireGeminiStub.resolves(geminiError); + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(result).to.equal(geminiError); + }); + + it("should initialize Storage feature without provisioning", async () => { + const features = { + storage: { + rules_filename: "storage.rules", + rules: "rules_version = '2';", + }, + }; + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(actuateStub).to.have.been.calledOnce; + expect(result.content[0].text).to.include("Successfully setup those features"); + }); + + it("should initialize AI Logic feature without provisioning", async () => { + const features = { + ai_logic: true, + }; + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(actuateStub).to.have.been.calledOnce; + expect(result.content[0].text).to.include("Successfully setup those features: ailogic"); + }); + + it("should handle validation errors for provisioning", async () => { + const provisioning = { enable: true }; + const features = { firestore: {} }; + + try { + await init.fn({ provisioning, features }, mockContext as McpContext); + expect.fail("Should have thrown error"); + } catch (error) { + expect((error as Error).message).to.include("app is required when provisioning is enabled"); + } + }); + + it("should initialize multiple features together", async () => { + const features = { + firestore: { database_id: "test-db" }, + database: { rules_filename: "database.rules.json" }, + ai_logic: true, + }; + + const result = await init.fn({ features }, mockContext as McpContext); + + expect(result.content[0].text).to.include("firestore"); + expect(result.content[0].text).to.include("database"); + expect(result.content[0].text).to.include("ailogic"); + }); + + it("should exist and be a tool object", () => { + expect(init).to.be.an("object"); + expect(init.mcp.name).to.equal("init"); + expect(init.mcp.description).to.be.a("string"); + }); + + describe("provisioning behaviors", () => { + let provisionFirebaseAppStub: sinon.SinonStub; + let fsExistsSyncStub: sinon.SinonStub; + let writeAppConfigFileStub: sinon.SinonStub; + let extractProjectIdStub: sinon.SinonStub; + let appsModule: typeof import("../../../management/apps"); + + beforeEach(async () => { + // Use dynamic imports to avoid require warnings + const provisionModule = await import("../../../management/provision"); + const fsModule = await import("fs-extra"); + const utilsModule = await import("./utils"); + appsModule = await import("../../../management/apps"); + + provisionFirebaseAppStub = sandbox.stub(provisionModule, "provisionFirebaseApp"); + + // Mock the internal functions from utils + fsExistsSyncStub = sandbox.stub(fsModule, "existsSync"); + writeAppConfigFileStub = sandbox.stub(utilsModule, "writeAppConfigFile"); + extractProjectIdStub = sandbox.stub(utilsModule, "extractProjectIdFromAppResource"); + + // Mock utility functions used internally + sandbox.stub(utilsModule, "generateUniqueAppDirectoryName").returns("ios"); + sandbox + .stub(utilsModule, "getFirebaseConfigFilePath") + .returns("/test/project/ios/GoogleService-Info.plist"); + sandbox.stub(utilsModule, "findExistingIosApp").resolves(undefined); + sandbox.stub(utilsModule, "findExistingAndroidApp").resolves(undefined); + }); + + it("should throw error when project already exists without overwrite_project", async () => { + const provisioning = { enable: true, overwrite_project: false }; + const project = { parent: "projects/new-project" }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + const features = { firestore: {} }; + + // Mock existing project ID + const contextWithProject = { + ...mockContext, + projectId: "existing-project", + }; + + try { + await init.fn({ provisioning, project, app, features }, contextWithProject as McpContext); + expect.fail("Should have thrown error"); + } catch (error) { + expect((error as Error).message).to.include("Project already configured in .firebaserc"); + expect((error as Error).message).to.include("overwrite_project: true"); + } + }); + + it("should allow project overwrite when overwrite_project is true", async () => { + const provisioning = { enable: true, overwrite_project: true }; + const project = { parent: "projects/new-project" }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + const features = { firestore: {} }; + + fsExistsSyncStub.returns(false); + provisionFirebaseAppStub.resolves({ + appResource: "projects/new-project/apps/123456789", + configData: "config-data-base64", + }); + extractProjectIdStub.returns("new-project"); + + const contextWithProject = { + ...mockContext, + projectId: "existing-project", + }; + + const result = await init.fn( + { provisioning, project, app, features }, + contextWithProject as McpContext, + ); + + expect(provisionFirebaseAppStub).to.have.been.calledOnce; + expect(result.content[0].text).to.include("Successfully setup those features"); + }); + + it("should throw error when config file exists without overwrite_configs", async () => { + const provisioning = { enable: true, overwrite_configs: false }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + const features = { firestore: {} }; + + fsExistsSyncStub.returns(true); // Config file exists + + try { + await init.fn({ provisioning, app, features }, mockContext as McpContext); + expect.fail("Should have thrown error"); + } catch (error) { + expect((error as Error).message).to.include("Config file"); + expect((error as Error).message).to.include("already exists"); + expect((error as Error).message).to.include("overwrite_configs: true"); + } + }); + + it("should allow config file overwrite when overwrite_configs is true", async () => { + const provisioning = { enable: true, overwrite_configs: true }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + const features = { firestore: {} }; + + fsExistsSyncStub.returns(true); // Config file exists + provisionFirebaseAppStub.resolves({ + appResource: "projects/test-project/apps/123456789", + configData: "config-data-base64", + }); + extractProjectIdStub.returns("test-project"); + + const result = await init.fn({ provisioning, app, features }, mockContext as McpContext); + + expect(writeAppConfigFileStub).to.have.been.calledWith( + "/test/project/ios/GoogleService-Info.plist", + "config-data-base64", + ); + expect(result.content[0].text).to.include("Successfully setup those features"); + }); + + it("should use active project when no parent is specified", async () => { + const provisioning = { enable: true }; + const app = { platform: "ios" as const, bundle_id: "com.example.app" }; + const features = { firestore: {} }; + + fsExistsSyncStub.returns(false); + provisionFirebaseAppStub.resolves({ + appResource: "projects/test-project/apps/123456789", + configData: "config-data-base64", + }); + extractProjectIdStub.returns("test-project"); + + await init.fn({ provisioning, app, features }, mockContext as McpContext); + + // Should call provisionFirebaseApp with project parent set to active project + expect(provisionFirebaseAppStub).to.have.been.calledWith( + sinon.match({ + project: sinon.match({ + parent: { type: "existing_project", projectId: "test-project" }, + }), + }), + ); + }); + + it("should handle full provisioning flow with Android app", async () => { + const provisioning = { enable: true }; + const project = { display_name: "My Firebase Project" }; + const app = { + platform: "android" as const, + package_name: "com.example.android", + sha1_hashes: ["sha1hash"], + }; + const features = { firestore: {}, database: {} }; + + // Note: getFirebaseConfigFilePath is already stubbed in beforeEach + + fsExistsSyncStub.returns(false); + provisionFirebaseAppStub.resolves({ + appResource: "projects/provisioned-project/apps/987654321", + configData: "android-config-base64", + }); + extractProjectIdStub.returns("provisioned-project"); + + const result = await init.fn( + { provisioning, project, app, features }, + mockContext as McpContext, + ); + + // Verify provisioning was called with correct options + expect(provisionFirebaseAppStub).to.have.been.calledWith( + sinon.match({ + project: sinon.match({ + displayName: "My Firebase Project", + parent: { type: "existing_project", projectId: "test-project" }, + }), + app: sinon.match({ + platform: appsModule.AppPlatform.ANDROID, + packageName: "com.example.android", + sha1Hashes: ["sha1hash"], + }), + }), + ); + + // Verify config file was written + expect(writeAppConfigFileStub).to.have.been.called; + + // Verify project ID was extracted and used + expect(extractProjectIdStub).to.have.been.calledWith( + "projects/provisioned-project/apps/987654321", + ); + + expect(result.content[0].text).to.include("Successfully setup those features"); + expect(result.content[0].text).to.include("firestore"); + expect(result.content[0].text).to.include("database"); + }); + + it("should handle provisioning errors gracefully", async () => { + const provisioning = { enable: true }; + const app = { platform: "web" as const, web_app_id: "my-web-app" }; + const features = { firestore: {} }; + + fsExistsSyncStub.returns(false); + provisionFirebaseAppStub.rejects(new Error("API quota exceeded")); + + try { + await init.fn({ provisioning, app, features }, mockContext as McpContext); + expect.fail("Should have thrown error"); + } catch (error) { + expect((error as Error).message).to.include("Provisioning failed"); + expect((error as Error).message).to.include("API quota exceeded"); + } + }); + + it("should update Firebase RC with provisioned project ID", async () => { + const provisioning = { enable: true }; + const app = { platform: "web" as const, web_app_id: "my-web-app" }; + const features = { firestore: {} }; + + fsExistsSyncStub.returns(false); + provisionFirebaseAppStub.resolves({ + appResource: "projects/new-provisioned-project/apps/web-app-123", + configData: "web-config-base64", + }); + extractProjectIdStub.returns("new-provisioned-project"); + + const testRc = { + data: { + projects: { default: "old-project" }, + targets: {}, + etags: {}, + }, + } as unknown as RC; + const contextWithRc = { + ...mockContext, + rc: testRc, + }; + + await init.fn({ provisioning, app, features }, contextWithRc as McpContext); + + // Verify Firebase RC was updated with new project ID + expect(testRc.data?.projects.default).to.equal("new-provisioned-project"); + }); + + it("should handle provisioning with AI Logic feature", async () => { + const provisioning = { enable: true }; + const project = { location: "us-central1" }; + const app = { platform: "ios" as const, bundle_id: "com.example.aiapp" }; + const features = { ai_logic: true }; + + fsExistsSyncStub.returns(false); + provisionFirebaseAppStub.resolves({ + appResource: "projects/ai-project/apps/123456789", + configData: "ai-config-base64", + }); + extractProjectIdStub.returns("ai-project"); + + const result = await init.fn( + { provisioning, project, app, features }, + mockContext as McpContext, + ); + + // Verify provisioning was called with AI Logic feature + expect(provisionFirebaseAppStub).to.have.been.calledWith( + sinon.match({ + features: sinon.match({ + location: "us-central1", + firebaseAiLogicInput: {}, + }), + }), + ); + + expect(result.content[0].text).to.include("ailogic"); + }); + }); + }); +}); diff --git a/src/mcp/tools/core/init.ts b/src/mcp/tools/core/init.ts index 7ce6ab23d21..8ac8a71cd7b 100644 --- a/src/mcp/tools/core/init.ts +++ b/src/mcp/tools/core/init.ts @@ -4,125 +4,400 @@ import { toContent } from "../../util"; import { DEFAULT_RULES } from "../../../init/features/database"; import { actuate, Setup, SetupInfo } from "../../../init/index"; import { freeTrialTermsLink } from "../../../dataconnect/freeTrial"; +import { + extractProjectIdFromAppResource, + generateUniqueAppDirectoryName, + getFirebaseConfigFilePath, + LocalFirebaseAppState, + findExistingIosApp, + findExistingAndroidApp, + writeAppConfigFile, +} from "./utils"; +import { + provisionFirebaseApp, + ProvisionFirebaseAppOptions, + ProvisionProjectOptions, + ProvisionAppOptions, +} from "../../../management/provision"; +import { AppPlatform } from "../../../management/apps"; +import { RC } from "../../../rc"; +import * as fs from "fs-extra"; +import * as path from "path"; import { requireGeminiToS } from "../../errors"; +import { FirebaseError } from "../../../error"; -export const init = tool( - { - name: "init", - description: - "Initializes selected Firebase features in the workspace (Firestore, Data Connect, Realtime Database). All features are optional; provide only the products you wish to set up. " + - "You can initialize new features into an existing project directory, but re-initializing an existing feature may overwrite configuration. " + - "To deploy the initialized features, run the `firebase deploy` command after `firebase_init` tool.", - inputSchema: z.object({ - features: z.object({ - database: z - .object({ - rules_filename: z - .string() - .optional() - .default("database.rules.json") - .describe("The file to use for Realtime Database Security Rules."), - rules: z - .string() - .optional() - .default(DEFAULT_RULES) - .describe("The security rules to use for Realtime Database Security Rules."), - }) +/** + * Resolves app context by either finding existing app or planning new directory creation + */ +async function resolveAppContext( + projectDirectory: string, + appInput: AppInput | undefined, +): Promise { + if (!appInput?.platform) { + throw new Error("platform is required in app input"); + } + + // Try to find existing app first + let existingApp: LocalFirebaseAppState | undefined; + + if (appInput.platform === "ios" && appInput.bundle_id) { + existingApp = await findExistingIosApp(projectDirectory, appInput.bundle_id); + } else if (appInput.platform === "android" && appInput.package_name) { + existingApp = await findExistingAndroidApp(projectDirectory, appInput.package_name); + } + + if (existingApp) { + return existingApp; + } + + // No existing app found - plan new directory + const directoryName = generateUniqueAppDirectoryName(projectDirectory, appInput.platform); + const fullDirectoryPath = path.join(projectDirectory, directoryName); + const configFilePath = getFirebaseConfigFilePath(fullDirectoryPath, appInput.platform); + + return { + platform: appInput.platform, + configFilePath, + bundleId: appInput.bundle_id, + packageName: appInput.package_name, + webAppId: appInput.web_app_id, + }; +} + +/** + * Updates .firebaserc with project ID (conflicts should be validated beforehand) + */ +function updateFirebaseRC(rc: RC, projectId: string): void { + if (!rc?.data) { + throw new Error("Invalid .firebaserc configuration"); + } + + // Update project configuration + if (!rc.data.projects) { + rc.data.projects = {}; + } + rc.data.projects.default = projectId; +} + +/** + * Validates provisioning inputs for required fields and format + */ +export function validateProvisioningInputs( + provisioning?: ProvisioningInput, + project?: ProjectInput, + app?: AppInput, +): void { + if (!provisioning?.enable) return; + + if (!app) { + throw new Error("app is required when provisioning is enabled"); + } + + const { platform } = app; + if (!platform) { + throw new Error("app.platform is required when provisioning is enabled"); + } + + if (platform === "ios" && !app.bundle_id) { + throw new Error("bundle_id is required for iOS apps"); + } + if (platform === "android" && !app.package_name) { + throw new Error("package_name is required for Android apps"); + } + if (platform === "web" && !app.web_app_id) { + throw new Error("web_app_id is required for Web apps"); + } + + if (project?.parent) { + const validParentPattern = /^(projects|folders|organizations)\/[\w-]+$/; + if (!validParentPattern.test(project.parent)) { + throw new Error( + "parent must be in format: 'projects/id', 'folders/id', or 'organizations/id'", + ); + } + } +} + +/** + * Converts MCP inputs to provisioning API format + */ +export function buildProvisionOptions( + project?: ProjectInput, + app?: AppInput, + features?: { ai_logic?: boolean }, +): ProvisionFirebaseAppOptions { + if (!app?.platform) { + throw new Error("App platform is required for provisioning"); + } + + // Build project options + const projectOptions: ProvisionProjectOptions = { + displayName: project?.display_name || "Firebase Project", + }; + if (project?.parent) { + const parts = project.parent.split("/"); + if (parts.length === 2) { + const [type, id] = parts; + switch (type) { + case "projects": + projectOptions.parent = { type: "existing_project", projectId: id }; + break; + case "folders": + projectOptions.parent = { type: "folder", folderId: id }; + break; + case "organizations": + projectOptions.parent = { type: "organization", organizationId: id }; + break; + default: + // This should be caught by validation, but as a safeguard: + throw new Error(`Unsupported parent type: ${type}`); + } + } + } + + // Build app options based on platform with proper validation + let appOptions: ProvisionAppOptions; + switch (app.platform) { + case "ios": + if (!app.bundle_id) { + throw new Error("bundle_id is required for iOS apps"); + } + appOptions = { + platform: AppPlatform.IOS, + bundleId: app.bundle_id, + appStoreId: app.app_store_id, + teamId: app.team_id, + }; + break; + case "android": + if (!app.package_name) { + throw new Error("package_name is required for Android apps"); + } + appOptions = { + platform: AppPlatform.ANDROID, + packageName: app.package_name, + sha1Hashes: app.sha1_hashes, + sha256Hashes: app.sha256_hashes, + }; + break; + case "web": + if (!app.web_app_id) { + throw new Error("web_app_id is required for Web apps"); + } + appOptions = { + platform: AppPlatform.WEB, + webAppId: app.web_app_id, + }; + break; + } + + const provisionOptions: ProvisionFirebaseAppOptions = { + project: projectOptions, + app: appOptions, + }; + + // Add features if specified + if (project?.location || features?.ai_logic) { + provisionOptions.features = {}; + if (project?.location) { + provisionOptions.features.location = project.location; + } + if (features?.ai_logic) { + provisionOptions.features.firebaseAiLogicInput = {}; + } + } + + return provisionOptions; +} + +const inputSchema = z.object({ + provisioning: z + .object({ + enable: z.boolean().describe("Enable Firebase project/app provisioning via API"), + overwrite_project: z + .boolean() + .optional() + .default(false) + .describe("Allow overwriting existing project in .firebaserc"), + overwrite_configs: z + .boolean() + .optional() + .default(false) + .describe("Allow overwriting existing config files"), + }) + .optional() + .describe("Control how provisioning behaves and handles conflicts"), + + project: z + .object({ + display_name: z.string().optional().describe("Display name for the Firebase project"), + parent: z + .string() + .optional() + .describe( + "Parent resource: 'projects/existing-id', 'folders/123', or 'organizations/456'. Leave it empty to use the active project from .firebaserc. If there is no active project, a new project will be automatically created.", + ), + location: z + .string() + .optional() + .describe("GCP region for resources (used by AI Logic and future products)"), + }) + .optional() + .describe("Project context for provisioning or configuration"), + + app: z + .object({ + platform: z.enum(["ios", "android", "web"]).describe("Platform for the app"), + bundle_id: z + .string() + .optional() + .describe("iOS bundle identifier (required for iOS platform)"), + package_name: z + .string() + .optional() + .describe("Android package name (required for Android platform)"), + web_app_id: z + .string() + .optional() + .describe("Web app identifier (no requirement, just a random string)"), + app_store_id: z.string().optional().describe("iOS App Store ID (optional)"), + team_id: z.string().optional().describe("iOS Team ID (optional)"), + sha1_hashes: z + .array(z.string()) + .optional() + .describe("Android SHA1 certificate hashes (optional)"), + sha256_hashes: z + .array(z.string()) + .optional() + .describe("Android SHA256 certificate hashes (optional)"), + }) + .optional() + .describe("App context for provisioning or configuration"), + + features: z.object({ + database: z + .object({ + rules_filename: z + .string() + .optional() + .default("database.rules.json") + .describe("The file to use for Realtime Database Security Rules."), + rules: z + .string() + .optional() + .default(DEFAULT_RULES) + .describe("The security rules to use for Realtime Database Security Rules."), + }) + .optional() + .describe( + "Provide this object to initialize Firebase Realtime Database in this project directory.", + ), + firestore: z + .object({ + database_id: z + .string() + .optional() + .default("(default)") + .describe("The database ID to use for Firestore."), + location_id: z + .string() + .optional() + .default("nam5") + .describe("The GCP region ID to set up the Firestore database."), + rules_filename: z + .string() + .optional() + .default("firestore.rules") + .describe("The file to use for Firestore Security Rules."), + rules: z + .string() + .optional() + .describe( + "The security rules to use for Firestore Security Rules. Default to open rules that expire in 30 days.", + ), + }) + .optional() + .describe("Provide this object to initialize Cloud Firestore in this project directory."), + dataconnect: z + .object({ + app_description: z + .string() + .optional() + .describe( + "Provide a description of the app you are trying to build. If present, Gemini will help generate Data Connect Schema, Connector and seed data", + ), + service_id: z + .string() + .optional() + .describe( + "The Firebase Data Connect service ID to initialize. Default to match the current folder name.", + ), + location_id: z + .string() + .optional() + .default("us-central1") + .describe("The GCP region ID to set up the Firebase Data Connect service."), + cloudsql_instance_id: z + .string() .optional() .describe( - "Provide this object to initialize Firebase Realtime Database in this project directory.", + "The GCP Cloud SQL instance ID to use in the Firebase Data Connect service. By default, use -fdc. " + + "\nSet `provision_cloudsql` to true to start Cloud SQL provisioning.", ), - firestore: z - .object({ - database_id: z - .string() - .optional() - .default("(default)") - .describe("The database ID to use for Firestore."), - location_id: z - .string() - .optional() - .default("nam5") - .describe("The GCP region ID to set up the Firestore database."), - rules_filename: z - .string() - .optional() - .default("firestore.rules") - .describe("The file to use for Firestore Security Rules."), - rules: z - .string() - .optional() - .describe( - "The security rules to use for Firestore Security Rules. Default to open rules that expire in 30 days.", - ), - }) + cloudsql_database: z + .string() .optional() - .describe("Provide this object to initialize Cloud Firestore in this project directory."), - dataconnect: z - .object({ - app_description: z - .string() - .optional() - .describe( - "Provide a description of the app you are trying to build. If present, Gemini will help generate Data Connect Schema, Connector and seed data", - ), - service_id: z - .string() - .optional() - .describe( - "The Firebase Data Connect service ID to initialize. Default to match the current folder name.", - ), - location_id: z - .string() - .optional() - .default("us-central1") - .describe("The GCP region ID to set up the Firebase Data Connect service."), - cloudsql_instance_id: z - .string() - .optional() - .describe( - "The GCP Cloud SQL instance ID to use in the Firebase Data Connect service. By default, use -fdc. " + - "\nSet `provision_cloudsql` to true to start Cloud SQL provisioning.", - ), - cloudsql_database: z - .string() - .optional() - .default("fdcdb") - .describe("The Postgres database ID to use in the Firebase Data Connect service."), - provision_cloudsql: z - .boolean() - .optional() - .default(false) - .describe( - "If true, provision the Cloud SQL instance if `cloudsql_instance_id` does not exist already. " + - `\nThe first Cloud SQL instance in the project will use the Data Connect no-cost trial. See its terms of service: ${freeTrialTermsLink()}.`, - ), - }) + .default("fdcdb") + .describe("The Postgres database ID to use in the Firebase Data Connect service."), + provision_cloudsql: z + .boolean() .optional() + .default(false) .describe( - "Provide this object to initialize Firebase Data Connect with Cloud SQL Postgres in this project directory.\n" + - "It installs Data Connect Generated SDKs in all detected apps in the folder.", + "If true, provision the Cloud SQL instance if `cloudsql_instance_id` does not exist already. " + + `\nThe first Cloud SQL instance in the project will use the Data Connect no-cost trial. See its terms of service: ${freeTrialTermsLink()}.`, ), - storage: z - .object({ - rules_filename: z - .string() - .optional() - .default("storage.rules") - .describe("The file to use for Firebase Storage Security Rules."), - rules: z - .string() - .optional() - .describe( - "The security rules to use for Firebase Storage Security Rules. Default to closed rules that deny all access.", - ), - }) + }) + .optional() + .describe( + "Provide this object to initialize Firebase Data Connect with Cloud SQL Postgres in this project directory.\n" + + "It installs Data Connect Generated SDKs in all detected apps in the folder.", + ), + storage: z + .object({ + rules_filename: z + .string() + .optional() + .default("storage.rules") + .describe("The file to use for Firebase Storage Security Rules."), + rules: z + .string() .optional() .describe( - "Provide this object to initialize Firebase Storage in this project directory.", + "The security rules to use for Firebase Storage Security Rules. Default to closed rules that deny all access.", ), - }), - }), + }) + .optional() + .describe("Provide this object to initialize Firebase Storage in this project directory."), + ai_logic: z + .boolean() + .optional() + .describe("Enable Firebase AI Logic feature (requires provisioning to be enabled)"), + }), +}); + +type ProvisioningInput = z.infer["provisioning"]; +type ProjectInput = z.infer["project"]; +type AppInput = z.infer["app"]; + +export const init = tool( + { + name: "init", + description: + "Initializes selected Firebase features in the workspace (Firestore, Data Connect, Realtime Database, Firebase AI Logic). All features are optional; provide only the products you wish to set up. " + + "You can initialize new features into an existing project directory, but re-initializing an existing feature may overwrite configuration. " + + "To deploy the initialized features, run the `firebase deploy` command after `firebase_init` tool.", + inputSchema: inputSchema, annotations: { title: "Initialize Firebase Products", readOnlyHint: false, @@ -130,10 +405,62 @@ export const init = tool( }, _meta: { requiresProject: false, // Can start from scratch. - requiresAuth: false, // Will throw error if the specific feature needs it. + requiresAuth: true, // Required for provisioning and Firebase operations. }, }, - async ({ features }, { projectId, config, rc }) => { + async ({ features, provisioning, project, app }, { projectId, config, rc }) => { + validateProvisioningInputs(provisioning, project, app); + + if (provisioning?.enable) { + try { + if (projectId && !!project?.parent && !provisioning.overwrite_project) { + throw new Error( + `Project already configured in .firebaserc as '${projectId}'. ` + + `To provision a new project, use overwrite_project: true to replace the existing configuration.`, + ); + } + + // Resolve app context (existing vs new directory) early for conflict checks + const appContext = await resolveAppContext(config.projectDir, app); + + // Handle config file conflicts BEFORE making API calls to fail fast + if (fs.existsSync(appContext.configFilePath) && !provisioning.overwrite_configs) { + throw new Error( + `Config file ${appContext.configFilePath} already exists. Use overwrite_configs: true to update.`, + ); + } + + // If no project parent is specified and a project is active, use the active project. + let provisionProject: ProjectInput | undefined = project; + if (projectId && !provisionProject?.parent) { + provisionProject = { ...provisionProject, parent: `projects/${projectId}` }; + } + + // Build provisioning options from MCP inputs + const provisionOptions = buildProvisionOptions(provisionProject, app, features); + + // Provision Firebase app using real API (after all conflict checks pass) + const response = await provisionFirebaseApp(provisionOptions); + + // Extract project ID from app resource + const provisionedProjectId = extractProjectIdFromAppResource(response.appResource); + + // Update .firebaserc with the provisioned project ID + updateFirebaseRC(rc, provisionedProjectId); + + // Write config file to the resolved path + writeAppConfigFile(appContext.configFilePath, response.configData); + + // Update context with provisioned project ID for subsequent operations + projectId = provisionedProjectId; + } catch (error) { + throw new FirebaseError( + `Provisioning failed: ${error instanceof Error ? error.message : String(error)}`, + { original: error instanceof Error ? error : new Error(String(error)), exit: 2 }, + ); + } + } + const featuresList: string[] = []; const featureInfo: SetupInfo = {}; if (features.database) { @@ -178,6 +505,10 @@ export const init = tool( apps: [], }; } + if (features.ai_logic) { + featuresList.push("ailogic"); + featureInfo.ailogic = {}; + } const setup: Setup = { config: config?.src, rcfile: rc?.data, diff --git a/src/mcp/tools/core/utils.spec.ts b/src/mcp/tools/core/utils.spec.ts new file mode 100644 index 00000000000..3931f4a2574 --- /dev/null +++ b/src/mcp/tools/core/utils.spec.ts @@ -0,0 +1,490 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as fs from "fs-extra"; +import * as path from "path"; +import { + getFirebaseConfigFileName, + getFirebaseConfigFilePath, + extractBundleIdFromPlist, + hasPackageNameInAndroidConfig, + generateUniqueAppDirectoryName, + findExistingIosApp, + findExistingAndroidApp, + writeAppConfigFile, + extractProjectIdFromAppResource, + SupportedPlatform, +} from "./utils"; + +describe("utils", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("getFirebaseConfigFileName", () => { + it("should return correct filename for iOS platform", () => { + const result = getFirebaseConfigFileName("ios"); + expect(result).to.equal("GoogleService-Info.plist"); + }); + + it("should return correct filename for Android platform", () => { + const result = getFirebaseConfigFileName("android"); + expect(result).to.equal("google-services.json"); + }); + + it("should return correct filename for Web platform", () => { + const result = getFirebaseConfigFileName("web"); + expect(result).to.equal("firebase-config.json"); + }); + + it("should throw error for unsupported platform", () => { + expect(() => getFirebaseConfigFileName("unsupported" as SupportedPlatform)).to.throw( + "Unsupported platform: unsupported", + ); + }); + }); + + describe("getFirebaseConfigFilePath", () => { + it("should combine directory and filename correctly for iOS", () => { + const result = getFirebaseConfigFilePath("/path/to/app", "ios"); + expect(result).to.equal(path.join("/path/to/app", "GoogleService-Info.plist")); + }); + + it("should combine directory and filename correctly for Android", () => { + const result = getFirebaseConfigFilePath("/path/to/app", "android"); + expect(result).to.equal(path.join("/path/to/app", "google-services.json")); + }); + + it("should combine directory and filename correctly for Web", () => { + const result = getFirebaseConfigFilePath("/path/to/app", "web"); + expect(result).to.equal(path.join("/path/to/app", "firebase-config.json")); + }); + }); + + describe("extractBundleIdFromPlist", () => { + beforeEach(() => { + sandbox.stub(fs, "readFileSync"); + }); + + it("should extract bundle ID from valid plist", () => { + const plistContent = ` + BUNDLE_ID + com.example.app + `; + (fs.readFileSync as sinon.SinonStub).returns(plistContent); + + const result = extractBundleIdFromPlist("/path/to/plist"); + expect(result).to.equal("com.example.app"); + }); + + it("should throw error when BUNDLE_ID not found", () => { + const plistContent = ` + OTHER_KEY + some value + `; + (fs.readFileSync as sinon.SinonStub).returns(plistContent); + + expect(() => extractBundleIdFromPlist("/path/to/plist")).to.throw( + "Failed to parse iOS plist file: /path/to/plist", + ); + }); + + it("should throw error when file cannot be read", () => { + (fs.readFileSync as sinon.SinonStub).throws(new Error("File not found")); + + expect(() => extractBundleIdFromPlist("/path/to/plist")).to.throw( + "Failed to parse iOS plist file: /path/to/plist", + ); + }); + + it("should handle empty BUNDLE_ID value", () => { + const plistContent = ` + BUNDLE_ID + + `; + (fs.readFileSync as sinon.SinonStub).returns(plistContent); + + expect(() => extractBundleIdFromPlist("/path/to/plist")).to.throw( + "Failed to parse iOS plist file: /path/to/plist", + ); + }); + }); + + describe("hasPackageNameInAndroidConfig", () => { + beforeEach(() => { + sandbox.stub(fs, "readFileSync"); + }); + + it("should return true when package name matches", () => { + const androidConfig = { + client: [ + { + client_info: { + android_client_info: { + package_name: "com.example.app", + }, + }, + }, + ], + }; + (fs.readFileSync as sinon.SinonStub).returns(JSON.stringify(androidConfig)); + + const result = hasPackageNameInAndroidConfig("/path/to/config", "com.example.app"); + expect(result).to.be.true; + }); + + it("should return false when package name does not match", () => { + const androidConfig = { + client: [ + { + client_info: { + android_client_info: { + package_name: "com.different.app", + }, + }, + }, + ], + }; + (fs.readFileSync as sinon.SinonStub).returns(JSON.stringify(androidConfig)); + + const result = hasPackageNameInAndroidConfig("/path/to/config", "com.example.app"); + expect(result).to.be.false; + }); + + it("should return false when client array is missing", () => { + const androidConfig = {}; + (fs.readFileSync as sinon.SinonStub).returns(JSON.stringify(androidConfig)); + + const result = hasPackageNameInAndroidConfig("/path/to/config", "com.example.app"); + expect(result).to.be.false; + }); + + it("should return false when file cannot be read", () => { + (fs.readFileSync as sinon.SinonStub).throws(new Error("File not found")); + + const result = hasPackageNameInAndroidConfig("/path/to/config", "com.example.app"); + expect(result).to.be.false; + }); + + it("should return false when JSON is invalid", () => { + (fs.readFileSync as sinon.SinonStub).returns("invalid json"); + + const result = hasPackageNameInAndroidConfig("/path/to/config", "com.example.app"); + expect(result).to.be.false; + }); + + it("should handle multiple clients and find matching package name", () => { + const androidConfig = { + client: [ + { + client_info: { + android_client_info: { + package_name: "com.other.app", + }, + }, + }, + { + client_info: { + android_client_info: { + package_name: "com.example.app", + }, + }, + }, + ], + }; + (fs.readFileSync as sinon.SinonStub).returns(JSON.stringify(androidConfig)); + + const result = hasPackageNameInAndroidConfig("/path/to/config", "com.example.app"); + expect(result).to.be.true; + }); + }); + + describe("generateUniqueAppDirectoryName", () => { + beforeEach(() => { + sandbox.stub(fs, "existsSync"); + }); + + it("should return platform name when no conflict exists", () => { + (fs.existsSync as sinon.SinonStub).returns(false); + + const result = generateUniqueAppDirectoryName("/project", "ios"); + expect(result).to.equal("ios"); + }); + + it("should increment counter when conflict exists", () => { + (fs.existsSync as sinon.SinonStub) + .withArgs(path.join("/project", "ios")) + .returns(true) + .withArgs(path.join("/project", "ios-2")) + .returns(false); + + const result = generateUniqueAppDirectoryName("/project", "ios"); + expect(result).to.equal("ios-2"); + }); + + it("should handle multiple conflicts", () => { + (fs.existsSync as sinon.SinonStub) + .withArgs(path.join("/project", "android")) + .returns(true) + .withArgs(path.join("/project", "android-2")) + .returns(true) + .withArgs(path.join("/project", "android-3")) + .returns(true) + .withArgs(path.join("/project", "android-4")) + .returns(false); + + const result = generateUniqueAppDirectoryName("/project", "android"); + expect(result).to.equal("android-4"); + }); + + it("should stop at counter limit", () => { + (fs.existsSync as sinon.SinonStub).returns(true); + + const result = generateUniqueAppDirectoryName("/project", "web"); + expect(result).to.equal("web-1000"); + }); + }); + + describe("findExistingIosApp", () => { + let globStub: sinon.SinonStub; + + beforeEach(async () => { + // Use dynamic import to avoid require warnings + const globModule = await import("glob"); + + globStub = sandbox.stub(globModule, "glob"); + sandbox.stub(fs, "readFileSync"); + }); + + it("should find existing iOS app with matching bundle ID", async () => { + const plistFiles = ["/project/ios/GoogleService-Info.plist"]; + const plistContent = ` + BUNDLE_ID + com.example.app + `; + globStub.resolves(plistFiles); + (fs.readFileSync as sinon.SinonStub).withArgs(plistFiles[0], "utf8").returns(plistContent); + + const result = await findExistingIosApp("/project", "com.example.app"); + + expect(result).to.deep.equal({ + platform: "ios", + configFilePath: plistFiles[0], + bundleId: "com.example.app", + }); + }); + + it("should return undefined when no matching bundle ID found", async () => { + const plistFiles = ["/project/ios/GoogleService-Info.plist"]; + const plistContent = ` + BUNDLE_ID + com.different.app + `; + globStub.resolves(plistFiles); + (fs.readFileSync as sinon.SinonStub).withArgs(plistFiles[0], "utf8").returns(plistContent); + + const result = await findExistingIosApp("/project", "com.example.app"); + expect(result).to.be.undefined; + }); + + it("should return undefined when no plist files found", async () => { + globStub.resolves([]); + + const result = await findExistingIosApp("/project", "com.example.app"); + expect(result).to.be.undefined; + }); + + it("should continue when plist parsing fails", async () => { + const plistFiles = [ + "/project/ios1/GoogleService-Info.plist", + "/project/ios2/GoogleService-Info.plist", + ]; + const validPlistContent = ` + BUNDLE_ID + com.example.app + `; + globStub.resolves(plistFiles); + (fs.readFileSync as sinon.SinonStub) + .withArgs(plistFiles[0], "utf8") + .throws(new Error("Parse error")) + .withArgs(plistFiles[1], "utf8") + .returns(validPlistContent); + + const result = await findExistingIosApp("/project", "com.example.app"); + + expect(result).to.deep.equal({ + platform: "ios", + configFilePath: plistFiles[1], + bundleId: "com.example.app", + }); + }); + }); + + describe("findExistingAndroidApp", () => { + let globStub: sinon.SinonStub; + + beforeEach(async () => { + // Use dynamic import to avoid require warnings + const globModule = await import("glob"); + + globStub = sandbox.stub(globModule, "glob"); + sandbox.stub(fs, "readFileSync"); + }); + + it("should find existing Android app with matching package name", async () => { + const jsonFiles = ["/project/android/google-services.json"]; + const androidConfig = { + client: [ + { + client_info: { + android_client_info: { + package_name: "com.example.app", + }, + }, + }, + ], + }; + globStub.resolves(jsonFiles); + (fs.readFileSync as sinon.SinonStub) + .withArgs(jsonFiles[0], "utf8") + .returns(JSON.stringify(androidConfig)); + + const result = await findExistingAndroidApp("/project", "com.example.app"); + + expect(result).to.deep.equal({ + platform: "android", + configFilePath: jsonFiles[0], + packageName: "com.example.app", + }); + }); + + it("should return undefined when no matching package name found", async () => { + const jsonFiles = ["/project/android/google-services.json"]; + const androidConfig = { + client: [ + { + client_info: { + android_client_info: { + package_name: "com.different.app", + }, + }, + }, + ], + }; + globStub.resolves(jsonFiles); + (fs.readFileSync as sinon.SinonStub) + .withArgs(jsonFiles[0], "utf8") + .returns(JSON.stringify(androidConfig)); + + const result = await findExistingAndroidApp("/project", "com.example.app"); + expect(result).to.be.undefined; + }); + + it("should return undefined when no JSON files found", async () => { + globStub.resolves([]); + + const result = await findExistingAndroidApp("/project", "com.example.app"); + expect(result).to.be.undefined; + }); + + it("should continue when JSON parsing fails", async () => { + const jsonFiles = [ + "/project/android1/google-services.json", + "/project/android2/google-services.json", + ]; + const validAndroidConfig = { + client: [ + { + client_info: { + android_client_info: { + package_name: "com.example.app", + }, + }, + }, + ], + }; + globStub.resolves(jsonFiles); + (fs.readFileSync as sinon.SinonStub) + .withArgs(jsonFiles[0], "utf8") + .throws(new Error("Parse error")) + .withArgs(jsonFiles[1], "utf8") + .returns(JSON.stringify(validAndroidConfig)); + + const result = await findExistingAndroidApp("/project", "com.example.app"); + + expect(result).to.deep.equal({ + platform: "android", + configFilePath: jsonFiles[1], + packageName: "com.example.app", + }); + }); + }); + + describe("writeAppConfigFile", () => { + beforeEach(() => { + sandbox.stub(fs, "ensureDirSync"); + sandbox.stub(fs, "writeFileSync"); + }); + + it("should write decoded base64 content to file", () => { + const base64Data = Buffer.from("test config content").toString("base64"); + const filePath = "/path/to/config.json"; + + writeAppConfigFile(filePath, base64Data); + + expect(fs.ensureDirSync).to.have.been.calledWith(path.dirname(filePath)); + expect(fs.writeFileSync).to.have.been.calledWith(filePath, "test config content", "utf8"); + }); + + it("should handle invalid base64 gracefully", () => { + const invalidBase64 = "invalid-base64!@#"; + const filePath = "/path/to/config.json"; + + // Invalid base64 might still decode to something, so we just ensure it doesn't crash + expect(() => writeAppConfigFile(filePath, invalidBase64)).to.not.throw(); + }); + + it("should throw error when file write fails", () => { + const base64Data = Buffer.from("test content").toString("base64"); + const filePath = "/path/to/config.json"; + (fs.writeFileSync as sinon.SinonStub).throws(new Error("Permission denied")); + + expect(() => writeAppConfigFile(filePath, base64Data)).to.throw( + `Failed to write config file to ${filePath}: Permission denied`, + ); + }); + }); + + describe("extractProjectIdFromAppResource", () => { + it("should extract project ID from valid app resource", () => { + const appResource = "projects/my-project-id/apps/1234567890"; + const result = extractProjectIdFromAppResource(appResource); + expect(result).to.equal("my-project-id"); + }); + + it("should extract project ID with hyphens and numbers", () => { + const appResource = "projects/my-project-123/apps/web-app-id"; + const result = extractProjectIdFromAppResource(appResource); + expect(result).to.equal("my-project-123"); + }); + + it("should throw error for invalid format", () => { + const appResource = "invalid-format"; + expect(() => extractProjectIdFromAppResource(appResource)).to.throw( + `Invalid app resource format: ${appResource}`, + ); + }); + + it("should throw error for missing project prefix", () => { + const appResource = "apps/1234567890"; + expect(() => extractProjectIdFromAppResource(appResource)).to.throw( + `Invalid app resource format: ${appResource}`, + ); + }); + }); +}); diff --git a/src/mcp/tools/core/utils.ts b/src/mcp/tools/core/utils.ts new file mode 100644 index 00000000000..80acc30f1b5 --- /dev/null +++ b/src/mcp/tools/core/utils.ts @@ -0,0 +1,197 @@ +import * as path from "path"; +import * as fs from "fs-extra"; +import { glob } from "glob"; + +export type SupportedPlatform = "ios" | "android" | "web"; + +/** + * Represents the local Firebase app configuration state in the project directory + */ +export interface LocalFirebaseAppState { + platform: SupportedPlatform; + configFilePath: string; + bundleId?: string; + packageName?: string; + webAppId?: string; +} + +/** + * Returns the Firebase configuration filename for a given platform + */ +export function getFirebaseConfigFileName(platform: SupportedPlatform): string { + switch (platform) { + case "ios": + return "GoogleService-Info.plist"; + case "android": + return "google-services.json"; + case "web": + return "firebase-config.json"; + default: + throw new Error(`Unsupported platform: ${platform as string}`); + } +} + +/** + * Returns the full file path for Firebase configuration file in the given app directory + */ +export function getFirebaseConfigFilePath( + appDirectory: string, + platform: SupportedPlatform, +): string { + const filename = getFirebaseConfigFileName(platform); + return path.join(appDirectory, filename); +} + +/** + * Extracts bundle identifier from iOS plist file + */ +export function extractBundleIdFromPlist(plistPath: string): string { + try { + const fileContent = fs.readFileSync(plistPath, "utf8"); + const bundleIdMatch = /BUNDLE_ID<\/key>\s*(.*?)<\/string>/u.exec(fileContent); + + if (!bundleIdMatch?.[1]) { + throw new Error(`BUNDLE_ID not found in plist file`); + } + + return bundleIdMatch[1]; + } catch (error) { + throw new Error(`Failed to parse iOS plist file: ${plistPath}`); + } +} + +/** + * Checks if Android google-services.json file contains the specified package name + */ +export function hasPackageNameInAndroidConfig(jsonPath: string, packageName: string): boolean { + try { + const fileContent = fs.readFileSync(jsonPath, "utf8"); + const config = JSON.parse(fileContent) as { + client?: Array<{ + client_info?: { + android_client_info?: { package_name?: string }; + }; + }>; + }; + + if (!config.client) { + return false; + } + + // Check if any client has the specified package name + return config.client.some( + (client) => client.client_info?.android_client_info?.package_name === packageName, + ); + } catch (error) { + return false; + } +} + +/** + * Generates a unique directory name for a new app, avoiding conflicts with existing directories + */ +export function generateUniqueAppDirectoryName( + projectDirectory: string, + platform: SupportedPlatform, +): string { + let directoryName: string = platform; + let counter = 1; + + while (fs.existsSync(path.join(projectDirectory, directoryName)) && counter < 1000) { + counter++; + directoryName = `${platform}-${counter}`; + } + + return directoryName; +} + +/** + * Common utility to find files using glob patterns + */ +async function findConfigFiles(projectDirectory: string, pattern: string): Promise { + const files = await glob(pattern, { + cwd: projectDirectory, + absolute: true, + ignore: ["**/node_modules/**", "**/.*/**"], + }); + return files; +} + +/** + * Finds existing iOS app with the given bundle ID + */ +export async function findExistingIosApp( + projectDirectory: string, + bundleId: string, +): Promise { + const iosPlistFiles = await findConfigFiles(projectDirectory, "**/GoogleService-Info.plist"); + + for (const configFilePath of iosPlistFiles) { + try { + const existingBundleId = extractBundleIdFromPlist(configFilePath); + if (existingBundleId === bundleId) { + return { + platform: "ios", + configFilePath, + bundleId: existingBundleId, + }; + } + } catch (error) { + continue; + } + } + + return undefined; +} + +/** + * Finds existing Android app with the given package name + */ +export async function findExistingAndroidApp( + projectDirectory: string, + packageName: string, +): Promise { + const androidJsonFiles = await findConfigFiles(projectDirectory, "**/google-services.json"); + + for (const configFilePath of androidJsonFiles) { + try { + if (hasPackageNameInAndroidConfig(configFilePath, packageName)) { + return { + platform: "android", + configFilePath, + packageName: packageName, + }; + } + } catch (error) { + continue; + } + } + + return undefined; +} + +/** + * Writes config file from base64 data with proper decoding + */ +export function writeAppConfigFile(filePath: string, base64Data: string): void { + try { + const configContent = Buffer.from(base64Data, "base64").toString("utf8"); + fs.ensureDirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, configContent, "utf8"); + } catch (error) { + throw new Error( + `Failed to write config file to ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Extracts project ID from app resource name + */ +export function extractProjectIdFromAppResource(appResource: string): string { + const match = /^projects\/([^/]+)/.exec(appResource); + if (!match) { + throw new Error(`Invalid app resource format: ${appResource}`); + } + return match[1]; +}