From 024e485b12a5df6f54eab7f8d9f37cf2d2e66dc7 Mon Sep 17 00:00:00 2001 From: Bryan Atkinson Date: Tue, 28 Jul 2026 11:03:34 -0400 Subject: [PATCH 1/6] feat: add crashlytics web onboarding support ### Description Adds support for onboarding Firebase Web Apps to Crashlytics via the `crashlytics:onboard` command. - Verifies that the project is on the Blaze plan (has a linked billing account) before proceeding, supporting interactive billing selection and non-interactive error guidance. - Enables required Google Cloud APIs (`firebasetelemetry.googleapis.com` and `firebasetelemetryadmin.googleapis.com`). - Creates and configures Cloud Logging bucket `firebase-telemetry` with observability analytics enabled, with automatic fallback to patch existing buckets. - Sets up Cloud Logging sink `firebase-telemetry-routing` to route `firebasetelemetry.googleapis.com/App` resources to the dedicated telemetry bucket. - Creates a Telemetry Config via the Telemetry Admin API with sampling rate 1 and automatic telemetry enablement. ### Scenarios Tested - Tested interactive and non-interactive Blaze plan verification on an unbilled project (`bryan-crash-cli-onboarding`). - Tested live onboarding execution end-to-end against web apps `1:894118895927:web:dad71d14e9abaa7d543ab2` in project `crash-test-app-87b7a` and `1:298871010406:web:f0b7551fb086db3dafe9a7` in project `bryan-crash-cli-onboarding`. - Ran unit test suites for Cloud Logging utilities and Crashlytics onboarding workflow (`npx mocha src/gcp/cloudlogging.spec.ts src/crashlytics/onboarding.spec.ts`). ### Sample Commands ```bash firebase crashlytics:onboard --project firebase crashlytics:onboard 1:123:web:456 --project ``` TAG=agy CONV=0cf98957-1b4e-437c-8a79-5ff873b6c315 --- CHANGELOG.md | 1 + src/api.ts | 7 ++ src/commands/crashlytics-onboard.ts | 70 +++++++++++++++ src/commands/index.ts | 1 + src/crashlytics/onboarding.spec.ts | 95 +++++++++++++++++++++ src/crashlytics/onboarding.ts | 83 ++++++++++++++++++ src/deploy/extensions/validate.ts | 6 +- src/extensions/checkProjectBilling.ts | 110 +----------------------- src/gcp/cloudbilling.spec.ts | 93 ++++++++++++++++++++ src/gcp/cloudbilling.ts | 118 +++++++++++++++++++++++++- src/gcp/cloudlogging.spec.ts | 113 ++++++++++++++++++++++++ src/gcp/cloudlogging.ts | 109 ++++++++++++++++++++++++ src/gcp/firebasetelemetry.spec.ts | 89 +++++++++++++++++++ src/gcp/firebasetelemetry.ts | 63 ++++++++++++++ 14 files changed, 847 insertions(+), 111 deletions(-) create mode 100644 src/commands/crashlytics-onboard.ts create mode 100644 src/crashlytics/onboarding.spec.ts create mode 100644 src/crashlytics/onboarding.ts create mode 100644 src/gcp/firebasetelemetry.spec.ts create mode 100644 src/gcp/firebasetelemetry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a37b3cfee31..94b9d8a3555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added `crashlytics:onboard` CLI command to support Crashlytics web onboarding. - Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers. - Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization). - Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355) diff --git a/src/api.ts b/src/api.ts index c97c1d1d901..5d030cecf05 100755 --- a/src/api.ts +++ b/src/api.ts @@ -123,6 +123,13 @@ export const messagingApiOrigin = () => utils.envOverride("FIREBASE_MESSAGING_CONFIG_URL", "https://fcm.googleapis.com"); export const crashlyticsApiOrigin = () => utils.envOverride("FIREBASE_CRASHLYTICS_URL", "https://firebasecrashlytics.googleapis.com"); +export const firebaseTelemetryOrigin = () => + utils.envOverride("FIREBASE_TELEMETRY_URL", "https://firebasetelemetry.googleapis.com"); +export const firebaseTelemetryAdminOrigin = () => + utils.envOverride( + "FIREBASE_TELEMETRY_ADMIN_URL", + "https://firebasetelemetryadmin.googleapis.com", + ); export const resourceManagerOrigin = () => utils.envOverride("FIREBASE_RESOURCEMANAGER_URL", "https://cloudresourcemanager.googleapis.com"); export const rulesOrigin = () => diff --git a/src/commands/crashlytics-onboard.ts b/src/commands/crashlytics-onboard.ts new file mode 100644 index 00000000000..cc9d490da4d --- /dev/null +++ b/src/commands/crashlytics-onboard.ts @@ -0,0 +1,70 @@ +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { logger } from "../logger"; +import { + AppPlatform, + checkForApps, + listFirebaseApps, + selectAppInteractively, +} from "../management/apps"; +import { needProjectId } from "../projectUtils"; +import { requireAuth } from "../requireAuth"; +import { onboardCrashlyticsWeb, OnboardWebResult } from "../crashlytics/onboarding"; +import { Options } from "../options"; + +export interface CrashlyticsOnboardOptions extends Options { + app?: string; +} + +export const command = new Command("crashlytics:onboard [appId]") + .description("onboard a Firebase app to Crashlytics") + .option("--app ", "the app id of your Firebase app") + .before(requireAuth) + .action( + async ( + appIdInput = "", + options: CrashlyticsOnboardOptions, + ): Promise => { + const projectId = needProjectId(options); + let appId = appIdInput || options.app || ""; + + let appPlatform: AppPlatform = AppPlatform.ANY; + if (!appId) { + const apps = await listFirebaseApps(projectId, AppPlatform.ANY); + checkForApps(apps, AppPlatform.ANY); + if (apps.length === 1) { + appId = apps[0].appId; + appPlatform = apps[0].platform; + } else if (options.nonInteractive) { + throw new FirebaseError( + `Project ${projectId} has multiple apps, must specify an app id.`, + ); + } else { + const appMetadata = await selectAppInteractively(apps, AppPlatform.ANY); + appId = appMetadata.appId; + appPlatform = appMetadata.platform; + } + } else { + const apps = await listFirebaseApps(projectId, AppPlatform.ANY); + const matchedApp = apps.find((a) => a.appId === appId); + if (matchedApp) { + appPlatform = matchedApp.platform; + } else if (appId.includes(":web:")) { + appPlatform = AppPlatform.WEB; + } else if (appId.includes(":android:")) { + appPlatform = AppPlatform.ANDROID; + } else if (appId.includes(":ios:")) { + appPlatform = AppPlatform.IOS; + } + } + + if (appPlatform !== AppPlatform.WEB && !appId.includes(":web:")) { + logger.info( + `Crashlytics onboarding via the CLI is currently only supported for Web apps. No onboarding steps needed for non-Web app: ${appId}`, + ); + return undefined; + } + + return await onboardCrashlyticsWeb(projectId, appId, options); + }, + ); diff --git a/src/commands/index.ts b/src/commands/index.ts index 41f5fc612a9..ddec90f5a43 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -62,6 +62,7 @@ export function load(client: CLIClient): CLIClient { client.auth.export = loadCommand("auth-export"); client.auth.import = loadCommand("auth-import"); client.crashlytics = {}; + client.crashlytics.onboard = loadCommand("crashlytics-onboard"); client.crashlytics.symbols = {}; client.crashlytics.symbols.upload = loadCommand("crashlytics-symbols-upload"); client.crashlytics.mappingfile = {}; diff --git a/src/crashlytics/onboarding.spec.ts b/src/crashlytics/onboarding.spec.ts new file mode 100644 index 00000000000..22f98b3e2af --- /dev/null +++ b/src/crashlytics/onboarding.spec.ts @@ -0,0 +1,95 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import nock from "../test/helpers/nock"; + +import * as onboarding from "./onboarding"; +import * as ensureApiEnabled from "../ensureApiEnabled"; +import * as cloudlogging from "../gcp/cloudlogging"; +import * as cloudbilling from "../gcp/cloudbilling"; +import * as firebasetelemetry from "../gcp/firebasetelemetry"; +import { FirebaseError } from "../error"; + +describe("onboarding", () => { + let ensureStub: sinon.SinonStub; + let bucketStub: sinon.SinonStub; + let sinkStub: sinon.SinonStub; + let configStub: sinon.SinonStub; + let checkBillingStub: sinon.SinonStub; + + before(() => { + nock.disableNetConnect(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + beforeEach(() => { + checkBillingStub = sinon.stub(cloudbilling, "checkBillingEnabled").resolves(true); + ensureStub = sinon.stub(ensureApiEnabled, "ensure").resolves(); + bucketStub = sinon.stub(cloudlogging, "createOrUpdateLogBucket").resolves({ + name: "projects/test-project/locations/global/buckets/firebase-telemetry", + analyticsEnabled: true, + }); + sinkStub = sinon.stub(cloudlogging, "createOrUpdateLogSink").resolves({ + name: "firebase-telemetry-routing", + destination: "dest", + filter: "filter", + }); + configStub = sinon.stub(firebasetelemetry, "createOrUpdateTelemetryConfig").resolves({ + name: "projects/test-project/locations/global/configs/1-123-web-456", + appId: "1:123:web:456", + logBucket: "projects/test-project/locations/global/buckets/firebase-telemetry", + samplingRate: 1, + enablementState: "ENABLED", + }); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + it("should successfully onboard web app", async () => { + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(ensureStub).to.have.been.calledTwice; + expect(bucketStub).to.have.been.calledWith( + "test-project", + "firebase-telemetry", + "global", + true, + ); + expect(sinkStub).to.have.been.calledOnce; + expect(configStub).to.have.been.calledWith( + "test-project", + "1:123:web:456", + "projects/test-project/locations/global/buckets/firebase-telemetry", + 1, + ); + expect(res.config.enablementState).to.equal("ENABLED"); + }); + + it("should throw in non-interactive mode if billing is not enabled", async () => { + checkBillingStub.resolves(false); + + await expect( + onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456", { nonInteractive: true }), + ).to.be.rejectedWith( + FirebaseError, + "Crashlytics web onboarding requires the Blaze plan, but project test-project is not on the Blaze plan.", + ); + }); + + it("should call enableBilling if billing is not enabled in interactive mode", async () => { + checkBillingStub.resolves(false); + const enableBillingStub = sinon.stub(cloudbilling, "enableBilling").resolves(); + + await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(enableBillingStub).to.have.been.calledOnceWith( + "test-project", + "Crashlytics web onboarding", + ); + }); +}); diff --git a/src/crashlytics/onboarding.ts b/src/crashlytics/onboarding.ts new file mode 100644 index 00000000000..2a4dd698195 --- /dev/null +++ b/src/crashlytics/onboarding.ts @@ -0,0 +1,83 @@ +import { ensure } from "../ensureApiEnabled"; +import { FirebaseError } from "../error"; +import { checkBillingEnabled, enableBilling } from "../gcp/cloudbilling"; +import { + createOrUpdateLogBucket, + createOrUpdateLogSink, + LogBucket, + LogSink, +} from "../gcp/cloudlogging"; +import { createOrUpdateTelemetryConfig, TelemetryConfig } from "../gcp/firebasetelemetry"; +import { logLabeledBullet, logLabeledSuccess } from "../utils"; + +export const CRASHLYTICS_TELEMETRY_BUCKET_ID = "firebase-telemetry"; +export const CRASHLYTICS_TELEMETRY_SINK_ID = "firebase-telemetry-routing"; +export const CRASHLYTICS_TELEMETRY_RESOURCE_TYPE = "firebasetelemetry.googleapis.com/App"; + +export interface OnboardWebResult { + bucket: LogBucket; + sink: LogSink; + config: TelemetryConfig; +} + +/** + * Onboards a Firebase Web App to Crashlytics by enabling required APIs, + * setting up Cloud Logging bucket and sink routing, and creating a Telemetry Config. + */ +export async function onboardCrashlyticsWeb( + projectId: string, + appId: string, + options: { nonInteractive?: boolean } = {}, +): Promise { + const billingEnabled = await checkBillingEnabled(projectId); + if (!billingEnabled && options.nonInteractive) { + throw new FirebaseError( + `Crashlytics web onboarding requires the Blaze plan, but project ${projectId} is not on the Blaze plan. ` + + `Please visit https://console.cloud.google.com/billing/linkedaccount?project=${projectId} to upgrade your project.`, + ); + } else if (!billingEnabled) { + await enableBilling(projectId, "Crashlytics web onboarding"); + } + + logLabeledBullet("crashlytics", "Enabling required telemetry APIs..."); + await ensure(projectId, "firebasetelemetry.googleapis.com", "crashlytics", false); + await ensure(projectId, "firebasetelemetryadmin.googleapis.com", "crashlytics", false); + logLabeledSuccess("crashlytics", "Telemetry APIs enabled."); + + logLabeledBullet( + "crashlytics", + `Setting up Cloud Logging bucket '${CRASHLYTICS_TELEMETRY_BUCKET_ID}'...`, + ); + const bucket = await createOrUpdateLogBucket( + projectId, + CRASHLYTICS_TELEMETRY_BUCKET_ID, + "global", + true, + ); + logLabeledSuccess("crashlytics", "Cloud Logging bucket configured."); + + const destination = `logging.googleapis.com/projects/${projectId}/locations/global/buckets/${CRASHLYTICS_TELEMETRY_BUCKET_ID}`; + const filter = `resource.type="${CRASHLYTICS_TELEMETRY_RESOURCE_TYPE}"`; + logLabeledBullet( + "crashlytics", + `Setting up Cloud Logging routing sink '${CRASHLYTICS_TELEMETRY_SINK_ID}'...`, + ); + const sink = await createOrUpdateLogSink( + projectId, + CRASHLYTICS_TELEMETRY_SINK_ID, + destination, + filter, + ); + logLabeledSuccess("crashlytics", "Cloud Logging routing sink configured."); + + logLabeledBullet("crashlytics", "Configuring Crashlytics telemetry for web app..."); + const config = await createOrUpdateTelemetryConfig( + projectId, + appId, + `projects/${projectId}/locations/global/buckets/${CRASHLYTICS_TELEMETRY_BUCKET_ID}`, + 1, + ); + logLabeledSuccess("crashlytics", "Crashlytics telemetry configured successfully."); + + return { bucket, sink, config }; +} diff --git a/src/deploy/extensions/validate.ts b/src/deploy/extensions/validate.ts index 8da8dde11da..c633e5e20e8 100644 --- a/src/deploy/extensions/validate.ts +++ b/src/deploy/extensions/validate.ts @@ -1,7 +1,9 @@ -import { checkBillingEnabled } from "../../gcp/cloudbilling"; -import { enableBilling } from "../../extensions/checkProjectBilling"; +import { checkBillingEnabled, enableBilling } from "../../gcp/cloudbilling"; import { FirebaseError } from "../../error"; +/** + * + */ export async function checkBilling(projectId: string, nonInteractive: boolean) { const enabled = await checkBillingEnabled(projectId); if (!enabled && nonInteractive) { diff --git a/src/extensions/checkProjectBilling.ts b/src/extensions/checkProjectBilling.ts index 379d65b45ea..d22dfefb413 100644 --- a/src/extensions/checkProjectBilling.ts +++ b/src/extensions/checkProjectBilling.ts @@ -1,109 +1,3 @@ -import * as clc from "colorette"; -import * as opn from "open"; +import { enableBilling } from "../gcp/cloudbilling"; -import * as cloudbilling from "../gcp/cloudbilling"; -import { FirebaseError } from "../error"; -import { logger } from "../logger"; -import { logPrefix } from "./extensionsHelper"; -import * as prompt from "../prompt"; -import * as utils from "../utils"; - -const ADD_BILLING_ACCOUNT = "Add new billing account"; -/** - * Logs to console if setting up billing was successful. - */ -function logBillingStatus(enabled: boolean, projectId: string): void { - if (!enabled) { - throw new FirebaseError( - `${logPrefix}: ${clc.bold( - projectId, - )} could not be upgraded. Please add a billing account via the Firebase console before proceeding.`, - ); - } - utils.logLabeledSuccess(logPrefix, `${clc.bold(projectId)} has successfully been upgraded.`); -} - -/** - * Opens URL if applicable and stalls until user responds. - */ -async function openBillingAccount(projectId: string, url: string, open: boolean): Promise { - if (open) { - try { - opn(url); - } catch (err: any) { - logger.debug("Unable to open billing URL: " + err.stack); - } - } - - await prompt.confirm({ - message: - "Press enter when finished upgrading your project to continue setting up your extension.", - default: true, - }); - return cloudbilling.checkBillingEnabled(projectId, true); -} - -/** - * Question prompts user to select billing account for project. - */ -async function chooseBillingAccount( - projectId: string, - accounts: cloudbilling.BillingAccount[], -): Promise { - const choices = accounts.map((m) => m.displayName); - choices.push(ADD_BILLING_ACCOUNT); - - const answer = await prompt.select({ - message: `Extensions require your project to be upgraded to the Blaze plan. You have access to the following billing accounts. -Please select the one that you would like to associate with this project:`, - choices: choices, - }); - - let billingEnabled: boolean; - if (answer === ADD_BILLING_ACCOUNT) { - const billingURL = `https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`; - billingEnabled = await openBillingAccount(projectId, billingURL, true); - } else { - const billingAccount = accounts.find((a) => a.displayName === answer); - billingEnabled = await cloudbilling.setBillingAccount(projectId, billingAccount!.name); - } - - return logBillingStatus(billingEnabled, projectId); -} - -/** - * Directs user to set up billing account over the web and stalls until - * user responds. - */ -async function setUpBillingAccount(projectId: string) { - const billingURL = `https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`; - - logger.info(); - logger.info( - `Extension require your project to be upgraded to the Blaze plan. Please visit the following link to add a billing account:`, - ); - logger.info(); - logger.info(clc.bold(clc.underline(billingURL))); - logger.info(); - - const open = await prompt.confirm({ - message: "Press enter to open the URL.", - default: true, - }); - const billingEnabled = await openBillingAccount(projectId, billingURL, open); - return logBillingStatus(billingEnabled, projectId); -} - -/** - * Sets up billing for the given project. - * @param {string} projectId - */ -export async function enableBilling(projectId: string): Promise { - const billingAccounts = await cloudbilling.listBillingAccounts(projectId); - if (billingAccounts) { - const accounts = billingAccounts.filter((account) => account.open); - return accounts.length > 0 - ? chooseBillingAccount(projectId, accounts) - : setUpBillingAccount(projectId); - } -} +export { enableBilling }; diff --git a/src/gcp/cloudbilling.spec.ts b/src/gcp/cloudbilling.spec.ts index 872eb48e254..962d84a5c46 100644 --- a/src/gcp/cloudbilling.spec.ts +++ b/src/gcp/cloudbilling.spec.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; import nock from "../test/helpers/nock"; import * as sinon from "sinon"; +import * as prompt from "../prompt"; import * as cloudbilling from "./cloudbilling"; import { cloudbillingOrigin } from "../api"; import { Setup } from "../init"; @@ -223,4 +224,96 @@ describe("cloudbilling", () => { expect(nock.isDone()).to.be.true; }); }); + + describe("enableBilling", () => { + let confirmStub: sinon.SinonStub; + let selectStub: sinon.SinonStub; + let checkBillingEnabledStub: sinon.SinonStub; + let listBillingAccountsStub: sinon.SinonStub; + let setBillingAccountStub: sinon.SinonStub; + + beforeEach(() => { + confirmStub = sinon.stub(prompt, "confirm"); + selectStub = sinon.stub(prompt, "select"); + + checkBillingEnabledStub = sinon.stub(cloudbilling, "checkBillingEnabled"); + checkBillingEnabledStub.resolves(); + + listBillingAccountsStub = sinon.stub(cloudbilling, "listBillingAccounts"); + listBillingAccountsStub.resolves(); + + setBillingAccountStub = sinon.stub(cloudbilling, "setBillingAccount"); + setBillingAccountStub.resolves(); + }); + + afterEach(() => { + confirmStub.restore(); + selectStub.restore(); + checkBillingEnabledStub.restore(); + listBillingAccountsStub.restore(); + setBillingAccountStub.restore(); + }); + + it("should resolve if billing enabled", async () => { + const projectId = "already enabled"; + + checkBillingEnabledStub.resolves(true); + + const enabled = await cloudbilling.checkBillingEnabled(projectId); + if (!enabled) { + await cloudbilling.enableBilling(projectId); + } + + expect(listBillingAccountsStub.notCalled).to.be.true; + expect(setBillingAccountStub.notCalled).to.be.true; + expect(confirmStub.notCalled).to.be.true; + expect(selectStub.notCalled).to.be.true; + }); + + it("should list accounts if no billing account set, but accounts available.", async () => { + const projectId = "not set, but have list"; + const accounts = [ + { + name: "test-cloud-billing-account-name", + open: "true", + displayName: "test-account", + masterBillingAccount: "", + }, + ]; + + checkBillingEnabledStub.resolves(false); + listBillingAccountsStub.resolves(accounts); + setBillingAccountStub.resolves(true); + selectStub.resolves("test-account"); + + const enabled = await cloudbilling.checkBillingEnabled(projectId); + if (!enabled) { + await cloudbilling.enableBilling(projectId); + } + + expect(listBillingAccountsStub.calledOnce).to.be.true; + expect(listBillingAccountsStub.calledWith(projectId)).to.be.true; + expect(setBillingAccountStub.calledOnce).to.be.true; + expect(setBillingAccountStub.calledWith(projectId, "test-cloud-billing-account-name")).to.be + .true; + }); + + it("should not list accounts if no billing accounts set or available.", async () => { + const projectId = "not set, not available"; + + checkBillingEnabledStub.onCall(0).resolves(false); + checkBillingEnabledStub.onCall(1).resolves(true); + listBillingAccountsStub.resolves([]); + + const enabled = await cloudbilling.checkBillingEnabled(projectId); + if (!enabled) { + await cloudbilling.enableBilling(projectId); + } + + expect(listBillingAccountsStub.calledOnce).to.be.true; + expect(listBillingAccountsStub.calledWith(projectId)).to.be.true; + expect(setBillingAccountStub.notCalled).to.be.true; + expect(checkBillingEnabledStub.callCount).to.equal(2); + }); + }); }); diff --git a/src/gcp/cloudbilling.ts b/src/gcp/cloudbilling.ts index 642d3fd423b..156a1a04752 100644 --- a/src/gcp/cloudbilling.ts +++ b/src/gcp/cloudbilling.ts @@ -1,6 +1,11 @@ +import * as clc from "colorette"; +import * as opn from "open"; import { cloudbillingOrigin } from "../api"; import { Client } from "../apiv2"; +import { FirebaseError } from "../error"; import { Setup } from "../init"; +import { logger } from "../logger"; +import * as prompt from "../prompt"; import * as utils from "../utils"; import { ensure } from "../ensureApiEnabled"; @@ -99,7 +104,7 @@ export async function setBillingAccount( /** * Lists the billing accounts that the current authenticated user has permission to view. - * @return {!Promise} + * @return {!Promise} */ export async function listBillingAccounts(projectId?: string): Promise { if (projectId) { @@ -114,3 +119,114 @@ export async function listBillingAccounts(projectId?: string): Promise { + if (open) { + try { + opn(url); + } catch (err: any) { + logger.debug("Unable to open billing URL: " + err.stack); + } + } + + const target = featureName === "Extensions" ? "your extension" : featureName.toLowerCase(); + await prompt.confirm({ + message: `Press enter when finished upgrading your project to continue setting up ${target}.`, + default: true, + }); + return exports.checkBillingEnabled(projectId, true); +} + +/** + * Question prompts user to select billing account for project. + */ +async function chooseBillingAccount( + projectId: string, + accounts: BillingAccount[], + featureName: string, +): Promise { + const choices = accounts.map((m) => m.displayName); + choices.push(ADD_BILLING_ACCOUNT); + + const verb = featureName === "Extensions" ? "require" : "requires"; + const answer = await prompt.select({ + message: `${featureName} ${verb} your project to be upgraded to the Blaze plan. You have access to the following billing accounts. +Please select the one that you would like to associate with this project:`, + choices: choices, + }); + + let billingEnabled: boolean; + if (answer === ADD_BILLING_ACCOUNT) { + const billingURL = `https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`; + billingEnabled = await openBillingAccount(projectId, billingURL, true, featureName); + } else { + const billingAccount = accounts.find((a) => a.displayName === answer); + billingEnabled = await exports.setBillingAccount(projectId, billingAccount!.name); + } + + return logBillingStatus(billingEnabled, projectId, featureName); +} + +/** + * Directs user to set up billing account over the web and stalls until + * user responds. + */ +async function setUpBillingAccount(projectId: string, featureName: string) { + const billingURL = `https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`; + const verb = featureName === "Extensions" ? "require" : "requires"; + + logger.info(); + logger.info( + `${featureName} ${verb} your project to be upgraded to the Blaze plan. Please visit the following link to add a billing account:`, + ); + logger.info(); + logger.info(clc.bold(clc.underline(billingURL))); + logger.info(); + + const open = await prompt.confirm({ + message: "Press enter to open the URL.", + default: true, + }); + const billingEnabled = await openBillingAccount(projectId, billingURL, open, featureName); + return logBillingStatus(billingEnabled, projectId, featureName); +} + +/** + * Sets up billing for the given project. + * @param {string} projectId + * @param {string} featureName + */ +export async function enableBilling(projectId: string, featureName = "Extensions"): Promise { + const billingAccounts = await exports.listBillingAccounts(projectId); + if (billingAccounts) { + const accounts = billingAccounts.filter((account: BillingAccount) => account.open); + return accounts.length > 0 + ? chooseBillingAccount(projectId, accounts, featureName) + : setUpBillingAccount(projectId, featureName); + } +} diff --git a/src/gcp/cloudlogging.spec.ts b/src/gcp/cloudlogging.spec.ts index bfd314c65f2..f5b05caa8d4 100644 --- a/src/gcp/cloudlogging.spec.ts +++ b/src/gcp/cloudlogging.spec.ts @@ -54,4 +54,117 @@ describe("cloudlogging", () => { }); }); }); + + describe("createOrUpdateLogBucket", () => { + it("should resolve with bucket when creation succeeds", async () => { + const bucket = { + name: "projects/project/locations/global/buckets/firebase-telemetry", + analyticsEnabled: true, + }; + nock(cloudloggingOrigin()) + .post("/v2/projects/project/locations/global/buckets?bucketId=firebase-telemetry", { + analyticsEnabled: true, + }) + .reply(200, bucket); + + await expect( + cloudlogging.createOrUpdateLogBucket("project", "firebase-telemetry", "global", true), + ).to.eventually.deep.equal(bucket); + }); + + it("should fall back to patch when creation returns 409", async () => { + const bucket = { + name: "projects/project/locations/global/buckets/firebase-telemetry", + analyticsEnabled: true, + }; + nock(cloudloggingOrigin()) + .post("/v2/projects/project/locations/global/buckets?bucketId=firebase-telemetry", { + analyticsEnabled: true, + }) + .reply(409, { error: { status: "ALREADY_EXISTS" } }); + nock(cloudloggingOrigin()) + .patch( + "/v2/projects/project/locations/global/buckets/firebase-telemetry?updateMask=analyticsEnabled", + { analyticsEnabled: true }, + ) + .reply(200, bucket); + + await expect( + cloudlogging.createOrUpdateLogBucket("project", "firebase-telemetry", "global", true), + ).to.eventually.deep.equal(bucket); + }); + + it("should reject if API call fails with error other than 409", async () => { + nock(cloudloggingOrigin()) + .post("/v2/projects/project/locations/global/buckets?bucketId=firebase-telemetry", { + analyticsEnabled: true, + }) + .reply(500, { error: "internal" }); + + await expect( + cloudlogging.createOrUpdateLogBucket("project", "firebase-telemetry", "global", true), + ).to.be.rejectedWith( + FirebaseError, + "Failed to create or update log bucket firebase-telemetry (status 500):", + ); + }); + + it("should throw friendly error when billing account is missing", async () => { + nock(cloudloggingOrigin()) + .post("/v2/projects/project/locations/global/buckets?bucketId=firebase-telemetry", { + analyticsEnabled: true, + }) + .reply(400, { error: { message: "Valid linked billing account is required" } }); + + await expect( + cloudlogging.createOrUpdateLogBucket("project", "firebase-telemetry", "global", true), + ).to.be.rejectedWith( + FirebaseError, + "Creating a Cloud Logging bucket requires a valid linked billing account (Blaze plan). Please attach a billing account to project project and try again.", + ); + }); + }); + + describe("createOrUpdateLogSink", () => { + it("should resolve with sink when creation succeeds", async () => { + const sink = { + name: "firebase-telemetry-routing", + destination: "dest", + filter: "filter", + }; + nock(cloudloggingOrigin()).post("/v2/projects/project/sinks", sink).reply(200, sink); + + await expect( + cloudlogging.createOrUpdateLogSink( + "project", + "firebase-telemetry-routing", + "dest", + "filter", + ), + ).to.eventually.deep.equal(sink); + }); + + it("should fall back to put when creation returns 409", async () => { + const sink = { + name: "firebase-telemetry-routing", + destination: "dest", + filter: "filter", + }; + nock(cloudloggingOrigin()) + .post("/v2/projects/project/sinks", sink) + .reply(409, { error: { status: "ALREADY_EXISTS" } }); + nock(cloudloggingOrigin()) + .put("/v2/projects/project/sinks/firebase-telemetry-routing", sink) + .reply(200, sink); + + await expect( + cloudlogging.createOrUpdateLogSink( + "project", + "firebase-telemetry-routing", + "dest", + "filter", + ), + ).to.eventually.deep.equal(sink); + }); + }); }); diff --git a/src/gcp/cloudlogging.ts b/src/gcp/cloudlogging.ts index d32867458aa..25a2910cf03 100644 --- a/src/gcp/cloudlogging.ts +++ b/src/gcp/cloudlogging.ts @@ -73,3 +73,112 @@ export async function listEntries( }); } } + +export interface LogBucket { + name: string; + description?: string; + createTime?: string; + updateTime?: string; + retentionDays?: number; + locked?: boolean; + lifecycleState?: string; + analyticsEnabled?: boolean; +} + +export interface LogSink { + name: string; + destination: string; + filter?: string; + description?: string; + disabled?: boolean; + writerIdentity?: string; +} + +/** + * Creates or updates a Cloud Logging Log Bucket. + * Ref: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.locations.buckets + */ +export async function createOrUpdateLogBucket( + projectId: string, + bucketId: string, + location = "global", + analyticsEnabled = true, +): Promise { + const client = new Client({ urlPrefix: cloudloggingOrigin(), apiVersion: API_VERSION }); + const path = `/projects/${projectId}/locations/${location}/buckets`; + try { + const res = await client.post<{ analyticsEnabled: boolean }, LogBucket>( + `${path}?bucketId=${bucketId}`, + { analyticsEnabled }, + ); + return res.body; + } catch (err: any) { + if (err.status === 409) { + try { + const patchRes = await client.patch<{ analyticsEnabled: boolean }, LogBucket>( + `${path}/${bucketId}?updateMask=analyticsEnabled`, + { analyticsEnabled }, + ); + return patchRes.body; + } catch (patchErr: any) { + const msg = patchErr.message || JSON.stringify(patchErr.body) || patchErr; + throw new FirebaseError( + `Failed to patch log bucket ${bucketId} (status ${patchErr.status}): ${msg}`, + { original: patchErr }, + ); + } + } + const msg = err.message || JSON.stringify(err.body) || err; + if (typeof msg === "string" && msg.toLowerCase().includes("billing account")) { + throw new FirebaseError( + `Creating a Cloud Logging bucket requires a valid linked billing account (Blaze plan). Please attach a billing account to project ${projectId} and try again.`, + { original: err }, + ); + } + throw new FirebaseError( + `Failed to create or update log bucket ${bucketId} (status ${err.status}): ${msg}`, + { original: err }, + ); + } +} + +/** + * Creates or updates a Cloud Logging Log Sink. + * Ref: https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.sinks + */ +export async function createOrUpdateLogSink( + projectId: string, + sinkName: string, + destination: string, + filter: string, +): Promise { + const client = new Client({ urlPrefix: cloudloggingOrigin(), apiVersion: API_VERSION }); + const path = `/projects/${projectId}/sinks`; + const body = { + name: sinkName, + destination, + filter, + }; + try { + const res = await client.post(path, body); + return res.body; + } catch (err: any) { + if (err.status === 409) { + try { + const putRes = await client.put(`${path}/${sinkName}`, body); + return putRes.body; + } catch (putErr: any) { + const msg = putErr.message || JSON.stringify(putErr.body) || putErr; + throw new FirebaseError( + `Failed to update log sink ${sinkName} (status ${putErr.status}): ${msg}`, + { original: putErr }, + ); + } + } + const msg = err.message || JSON.stringify(err.body) || err; + throw new FirebaseError( + `Failed to create or update log sink ${sinkName} (status ${err.status}): ${msg}`, + { original: err }, + ); + } +} diff --git a/src/gcp/firebasetelemetry.spec.ts b/src/gcp/firebasetelemetry.spec.ts new file mode 100644 index 00000000000..859e16b1835 --- /dev/null +++ b/src/gcp/firebasetelemetry.spec.ts @@ -0,0 +1,89 @@ +import { expect } from "chai"; +import nock from "../test/helpers/nock"; +import * as firebasetelemetry from "./firebasetelemetry"; +import { firebaseTelemetryAdminOrigin } from "../api"; +import { FirebaseError } from "../error"; + +describe("firebasetelemetry", () => { + before(() => { + nock.disableNetConnect(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe("createOrUpdateTelemetryConfig", () => { + const reqConfig = { + name: "projects/test-project/locations/global/configs/1-123-web-456", + appId: "1:123:web:456", + logBucket: "projects/test-project/locations/global/buckets/firebase-telemetry", + samplingRate: 1, + }; + const resConfig = { + ...reqConfig, + enablementState: "ENABLED", + }; + + it("should resolve with config when creation succeeds", async () => { + nock(firebaseTelemetryAdminOrigin()) + .post( + "/v1alpha/projects/test-project/locations/global/configs?configId=1-123-web-456", + reqConfig, + ) + .reply(200, resConfig); + + const res = await firebasetelemetry.createOrUpdateTelemetryConfig( + "test-project", + "1:123:web:456", + "projects/test-project/locations/global/buckets/firebase-telemetry", + 1, + ); + + expect(res).to.deep.equal(resConfig); + }); + + it("should fall back to patch when creation returns 409", async () => { + nock(firebaseTelemetryAdminOrigin()) + .post( + "/v1alpha/projects/test-project/locations/global/configs?configId=1-123-web-456", + reqConfig, + ) + .reply(409, { error: { status: "ALREADY_EXISTS" } }); + nock(firebaseTelemetryAdminOrigin()) + .patch("/v1alpha/projects/test-project/locations/global/configs/1-123-web-456", reqConfig) + .reply(200, resConfig); + + const res = await firebasetelemetry.createOrUpdateTelemetryConfig( + "test-project", + "1:123:web:456", + "projects/test-project/locations/global/buckets/firebase-telemetry", + 1, + ); + + expect(res).to.deep.equal(resConfig); + }); + + it("should reject when creation fails with non-409 error", async () => { + nock(firebaseTelemetryAdminOrigin()) + .post("/v1alpha/projects/test-project/locations/global/configs?configId=1-123-web-456") + .reply(500, { error: "internal" }); + + await expect( + firebasetelemetry.createOrUpdateTelemetryConfig( + "test-project", + "1:123:web:456", + "projects/test-project/locations/global/buckets/firebase-telemetry", + 1, + ), + ).to.be.rejectedWith( + FirebaseError, + "Failed to configure telemetry for web app 1:123:web:456 (status 500):", + ); + }); + }); +}); diff --git a/src/gcp/firebasetelemetry.ts b/src/gcp/firebasetelemetry.ts new file mode 100644 index 00000000000..124cea9a572 --- /dev/null +++ b/src/gcp/firebasetelemetry.ts @@ -0,0 +1,63 @@ +import { firebaseTelemetryAdminOrigin } from "../api"; +import { Client } from "../apiv2"; +import { FirebaseError } from "../error"; + +const API_VERSION = "v1alpha"; + +export interface TelemetryConfig { + name: string; + appId: string; + logBucket: string; + samplingRate: number; + enablementState?: string; +} + +/** + * Creates or updates a Firebase Telemetry Config resource for an app. + * Automatically falls back to PATCH if the config already exists (409 ALREADY_EXISTS). + */ +export async function createOrUpdateTelemetryConfig( + projectId: string, + appId: string, + logBucket: string, + samplingRate = 1, +): Promise { + const client = new Client({ urlPrefix: firebaseTelemetryAdminOrigin(), apiVersion: API_VERSION }); + const configId = appId.replace(/[:.]/g, "-"); + const configName = `projects/${projectId}/locations/global/configs/${configId}`; + const configBody: TelemetryConfig = { + name: configName, + appId, + logBucket, + samplingRate, + }; + + try { + const res = await client.post( + `/projects/${projectId}/locations/global/configs?configId=${configId}`, + configBody, + ); + return res.body; + } catch (err: any) { + if (err.status === 409) { + try { + const patchRes = await client.patch( + `/projects/${projectId}/locations/global/configs/${configId}`, + configBody, + ); + return patchRes.body; + } catch (patchErr: any) { + const msg = patchErr.message || JSON.stringify(patchErr.body) || patchErr; + throw new FirebaseError( + `Failed to patch telemetry config for web app ${appId} (status ${patchErr.status}): ${msg}`, + { original: patchErr }, + ); + } + } + const msg = err.message || JSON.stringify(err.body) || err; + throw new FirebaseError( + `Failed to configure telemetry for web app ${appId} (status ${err.status}): ${msg}`, + { original: err }, + ); + } +} From 9d8f182fe6930d7803652fad204439c0265c3ae0 Mon Sep 17 00:00:00 2001 From: Bryan Atkinson Date: Wed, 29 Jul 2026 15:19:07 -0400 Subject: [PATCH 2/6] refactor(crashlytics): address PR review comments for web onboarding - Move firebasetelemetry client utility from src/gcp/ to src/crashlytics/ - Add updateMask parameter to TelemetryConfig PATCH requests - Parallelize firebasetelemetry and firebasetelemetryadmin API enablement - Simplify billing prompt feature name to 'Crashlytics' - Clarify crashlytics:onboard command description for web apps - Revert unrelated changes to src/deploy/extensions/validate.ts TAG=agy CONV=0cf98957-1b4e-437c-8a79-5ff873b6c315 --- CHANGELOG.md | 2 +- firebase-vscode/src/test/runTest.ts | 1 + src/commands/crashlytics-onboard.ts | 2 +- src/{gcp => crashlytics}/firebasetelemetry.spec.ts | 5 ++++- src/{gcp => crashlytics}/firebasetelemetry.ts | 2 +- src/crashlytics/onboarding.spec.ts | 9 +++------ src/crashlytics/onboarding.ts | 12 +++++++----- src/deploy/extensions/validate.ts | 6 ++---- 8 files changed, 20 insertions(+), 19 deletions(-) rename src/{gcp => crashlytics}/firebasetelemetry.spec.ts (94%) rename src/{gcp => crashlytics}/firebasetelemetry.ts (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b9d8a3555..06b4f83965c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Added `crashlytics:onboard` CLI command to support Crashlytics web onboarding. +- Added `crashlytics:onboard` CLI command to support Crashlytics onboarding for web apps. - Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers. - Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization). - Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355) diff --git a/firebase-vscode/src/test/runTest.ts b/firebase-vscode/src/test/runTest.ts index f6925f85b17..6b39ccf031f 100644 --- a/firebase-vscode/src/test/runTest.ts +++ b/firebase-vscode/src/test/runTest.ts @@ -16,6 +16,7 @@ async function main() { // Download VS Code, unzip it and run the integration test const tmpUserData = path.join(os.tmpdir(), `vsc-ud-${Math.random().toString(36).substring(2, 7)}`); await runTests({ + version: "1.96.4", extensionDevelopmentPath, extensionTestsPath, launchArgs: ["--user-data-dir", tmpUserData], diff --git a/src/commands/crashlytics-onboard.ts b/src/commands/crashlytics-onboard.ts index cc9d490da4d..3a6e9230876 100644 --- a/src/commands/crashlytics-onboard.ts +++ b/src/commands/crashlytics-onboard.ts @@ -17,7 +17,7 @@ export interface CrashlyticsOnboardOptions extends Options { } export const command = new Command("crashlytics:onboard [appId]") - .description("onboard a Firebase app to Crashlytics") + .description("onboard a Firebase web app to Crashlytics") .option("--app ", "the app id of your Firebase app") .before(requireAuth) .action( diff --git a/src/gcp/firebasetelemetry.spec.ts b/src/crashlytics/firebasetelemetry.spec.ts similarity index 94% rename from src/gcp/firebasetelemetry.spec.ts rename to src/crashlytics/firebasetelemetry.spec.ts index 859e16b1835..19c886d6b73 100644 --- a/src/gcp/firebasetelemetry.spec.ts +++ b/src/crashlytics/firebasetelemetry.spec.ts @@ -55,7 +55,10 @@ describe("firebasetelemetry", () => { ) .reply(409, { error: { status: "ALREADY_EXISTS" } }); nock(firebaseTelemetryAdminOrigin()) - .patch("/v1alpha/projects/test-project/locations/global/configs/1-123-web-456", reqConfig) + .patch( + "/v1alpha/projects/test-project/locations/global/configs/1-123-web-456?updateMask=logBucket,samplingRate", + reqConfig, + ) .reply(200, resConfig); const res = await firebasetelemetry.createOrUpdateTelemetryConfig( diff --git a/src/gcp/firebasetelemetry.ts b/src/crashlytics/firebasetelemetry.ts similarity index 97% rename from src/gcp/firebasetelemetry.ts rename to src/crashlytics/firebasetelemetry.ts index 124cea9a572..8cfe2ff2361 100644 --- a/src/gcp/firebasetelemetry.ts +++ b/src/crashlytics/firebasetelemetry.ts @@ -42,7 +42,7 @@ export async function createOrUpdateTelemetryConfig( if (err.status === 409) { try { const patchRes = await client.patch( - `/projects/${projectId}/locations/global/configs/${configId}`, + `/projects/${projectId}/locations/global/configs/${configId}?updateMask=logBucket,samplingRate`, configBody, ); return patchRes.body; diff --git a/src/crashlytics/onboarding.spec.ts b/src/crashlytics/onboarding.spec.ts index 22f98b3e2af..ee27202dc88 100644 --- a/src/crashlytics/onboarding.spec.ts +++ b/src/crashlytics/onboarding.spec.ts @@ -6,7 +6,7 @@ import * as onboarding from "./onboarding"; import * as ensureApiEnabled from "../ensureApiEnabled"; import * as cloudlogging from "../gcp/cloudlogging"; import * as cloudbilling from "../gcp/cloudbilling"; -import * as firebasetelemetry from "../gcp/firebasetelemetry"; +import * as firebasetelemetry from "./firebasetelemetry"; import { FirebaseError } from "../error"; describe("onboarding", () => { @@ -77,7 +77,7 @@ describe("onboarding", () => { onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456", { nonInteractive: true }), ).to.be.rejectedWith( FirebaseError, - "Crashlytics web onboarding requires the Blaze plan, but project test-project is not on the Blaze plan.", + "Crashlytics requires the Blaze plan, but project test-project is not on the Blaze plan.", ); }); @@ -87,9 +87,6 @@ describe("onboarding", () => { await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); - expect(enableBillingStub).to.have.been.calledOnceWith( - "test-project", - "Crashlytics web onboarding", - ); + expect(enableBillingStub).to.have.been.calledOnceWith("test-project", "Crashlytics"); }); }); diff --git a/src/crashlytics/onboarding.ts b/src/crashlytics/onboarding.ts index 2a4dd698195..00e2dd91342 100644 --- a/src/crashlytics/onboarding.ts +++ b/src/crashlytics/onboarding.ts @@ -7,7 +7,7 @@ import { LogBucket, LogSink, } from "../gcp/cloudlogging"; -import { createOrUpdateTelemetryConfig, TelemetryConfig } from "../gcp/firebasetelemetry"; +import { createOrUpdateTelemetryConfig, TelemetryConfig } from "./firebasetelemetry"; import { logLabeledBullet, logLabeledSuccess } from "../utils"; export const CRASHLYTICS_TELEMETRY_BUCKET_ID = "firebase-telemetry"; @@ -32,16 +32,18 @@ export async function onboardCrashlyticsWeb( const billingEnabled = await checkBillingEnabled(projectId); if (!billingEnabled && options.nonInteractive) { throw new FirebaseError( - `Crashlytics web onboarding requires the Blaze plan, but project ${projectId} is not on the Blaze plan. ` + + `Crashlytics requires the Blaze plan, but project ${projectId} is not on the Blaze plan. ` + `Please visit https://console.cloud.google.com/billing/linkedaccount?project=${projectId} to upgrade your project.`, ); } else if (!billingEnabled) { - await enableBilling(projectId, "Crashlytics web onboarding"); + await enableBilling(projectId, "Crashlytics"); } logLabeledBullet("crashlytics", "Enabling required telemetry APIs..."); - await ensure(projectId, "firebasetelemetry.googleapis.com", "crashlytics", false); - await ensure(projectId, "firebasetelemetryadmin.googleapis.com", "crashlytics", false); + await Promise.all([ + ensure(projectId, "firebasetelemetry.googleapis.com", "crashlytics", false), + ensure(projectId, "firebasetelemetryadmin.googleapis.com", "crashlytics", false), + ]); logLabeledSuccess("crashlytics", "Telemetry APIs enabled."); logLabeledBullet( diff --git a/src/deploy/extensions/validate.ts b/src/deploy/extensions/validate.ts index c633e5e20e8..8da8dde11da 100644 --- a/src/deploy/extensions/validate.ts +++ b/src/deploy/extensions/validate.ts @@ -1,9 +1,7 @@ -import { checkBillingEnabled, enableBilling } from "../../gcp/cloudbilling"; +import { checkBillingEnabled } from "../../gcp/cloudbilling"; +import { enableBilling } from "../../extensions/checkProjectBilling"; import { FirebaseError } from "../../error"; -/** - * - */ export async function checkBilling(projectId: string, nonInteractive: boolean) { const enabled = await checkBillingEnabled(projectId); if (!enabled && nonInteractive) { From e3063fbc62cdd47d3aafff2a000c03051a653b15 Mon Sep 17 00:00:00 2001 From: Bryan Atkinson Date: Wed, 29 Jul 2026 16:42:41 -0400 Subject: [PATCH 3/6] feat(crashlytics): rename onboarding command to crashlytics:onboard:web - Rename command to crashlytics:onboard:web - Update command file to src/commands/crashlytics-onboard-web.ts - Update registration in src/commands/index.ts - Update CHANGELOG.md entry TAG=agy CONV=0cf98957-1b4e-437c-8a79-5ff873b6c315 --- CHANGELOG.md | 2 +- .../{crashlytics-onboard.ts => crashlytics-onboard-web.ts} | 2 +- src/commands/index.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) rename src/commands/{crashlytics-onboard.ts => crashlytics-onboard-web.ts} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b4f83965c..caf081dc049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Added `crashlytics:onboard` CLI command to support Crashlytics onboarding for web apps. +- Added `crashlytics:onboard:web` CLI command to support Crashlytics onboarding for web apps. - Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers. - Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization). - Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355) diff --git a/src/commands/crashlytics-onboard.ts b/src/commands/crashlytics-onboard-web.ts similarity index 97% rename from src/commands/crashlytics-onboard.ts rename to src/commands/crashlytics-onboard-web.ts index 3a6e9230876..93ad8182af7 100644 --- a/src/commands/crashlytics-onboard.ts +++ b/src/commands/crashlytics-onboard-web.ts @@ -16,7 +16,7 @@ export interface CrashlyticsOnboardOptions extends Options { app?: string; } -export const command = new Command("crashlytics:onboard [appId]") +export const command = new Command("crashlytics:onboard:web [appId]") .description("onboard a Firebase web app to Crashlytics") .option("--app ", "the app id of your Firebase app") .before(requireAuth) diff --git a/src/commands/index.ts b/src/commands/index.ts index ddec90f5a43..83c3bdfc847 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -62,7 +62,8 @@ export function load(client: CLIClient): CLIClient { client.auth.export = loadCommand("auth-export"); client.auth.import = loadCommand("auth-import"); client.crashlytics = {}; - client.crashlytics.onboard = loadCommand("crashlytics-onboard"); + client.crashlytics.onboard = {}; + client.crashlytics.onboard.web = loadCommand("crashlytics-onboard-web"); client.crashlytics.symbols = {}; client.crashlytics.symbols.upload = loadCommand("crashlytics-symbols-upload"); client.crashlytics.mappingfile = {}; From 1e93749320104fe9b60a76759a72a91b7e6dd9f1 Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Thu, 30 Jul 2026 03:39:40 +0000 Subject: [PATCH 4/6] revert firebase-vscode/src/test/runTest.ts --- firebase-vscode/src/test/runTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/firebase-vscode/src/test/runTest.ts b/firebase-vscode/src/test/runTest.ts index 6b39ccf031f..f6925f85b17 100644 --- a/firebase-vscode/src/test/runTest.ts +++ b/firebase-vscode/src/test/runTest.ts @@ -16,7 +16,6 @@ async function main() { // Download VS Code, unzip it and run the integration test const tmpUserData = path.join(os.tmpdir(), `vsc-ud-${Math.random().toString(36).substring(2, 7)}`); await runTests({ - version: "1.96.4", extensionDevelopmentPath, extensionTestsPath, launchArgs: ["--user-data-dir", tmpUserData], From e2f9456e458427e5d1b0ac5a79e5f97cd409f4f4 Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Thu, 30 Jul 2026 03:46:42 +0000 Subject: [PATCH 5/6] fix format issues --- CHANGELOG.md | 2 +- src/api.ts | 147 ++++++++++++++++++++++++++------------------------- 2 files changed, 77 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e1488b5b4..0a9fb3dc5a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -- Added `crashlytics:onboard:web` CLI command to support Crashlytics onboarding for web apps. \ No newline at end of file +- Added `crashlytics:onboard:web` CLI command to support Crashlytics onboarding for web apps. diff --git a/src/api.ts b/src/api.ts index 1004d96c636..b069f2b8bf6 100755 --- a/src/api.ts +++ b/src/api.ts @@ -5,68 +5,68 @@ import * as utils from "./utils"; let commandScopes = new Set(); -export const authProxyOrigin = () => +export const authProxyOrigin = (): string => utils.envOverride("FIREBASE_AUTHPROXY_URL", "https://auth.firebase.tools"); // "In this context, the client secret is obviously not treated as a secret" // https://developers.google.com/identity/protocols/OAuth2InstalledApp -export const clientId = () => +export const clientId = (): string => utils.envOverride( "FIREBASE_CLIENT_ID", "563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com", ); -export const clientSecret = () => +export const clientSecret = (): string => utils.envOverride("FIREBASE_CLIENT_SECRET", "j9iVZfS8kkCEFUPaAeJV0sAi"); -export const cloudbillingOrigin = () => +export const cloudbillingOrigin = (): string => utils.envOverride("FIREBASE_CLOUDBILLING_URL", "https://cloudbilling.googleapis.com"); -export const cloudloggingOrigin = () => +export const cloudloggingOrigin = (): string => utils.envOverride("FIREBASE_CLOUDLOGGING_URL", "https://logging.googleapis.com"); -export const cloudMonitoringOrigin = () => +export const cloudMonitoringOrigin = (): string => utils.envOverride("CLOUD_MONITORING_URL", "https://monitoring.googleapis.com"); -export const containerRegistryDomain = () => +export const containerRegistryDomain = (): string => utils.envOverride("CONTAINER_REGISTRY_DOMAIN", "gcr.io"); -export const developerConnectOrigin = () => +export const developerConnectOrigin = (): string => utils.envOverride("DEVELOPERCONNECT_URL", "https://developerconnect.googleapis.com"); -export const developerConnectP4SADomain = () => +export const developerConnectP4SADomain = (): string => utils.envOverride("DEVELOPERCONNECT_P4SA_DOMAIN", "gcp-sa-devconnect.iam.gserviceaccount.com"); -export const artifactRegistryDomain = () => +export const artifactRegistryDomain = (): string => utils.envOverride("ARTIFACT_REGISTRY_DOMAIN", "https://artifactregistry.googleapis.com"); -export const appCheckOrigin = () => +export const appCheckOrigin = (): string => utils.envOverride("FIREBASE_APPCHECK_URL", "https://firebaseappcheck.googleapis.com"); -export const appDistributionOrigin = () => +export const appDistributionOrigin = (): string => utils.envOverride( "FIREBASE_APP_DISTRIBUTION_URL", "https://firebaseappdistribution.googleapis.com", ); -export const apphostingOrigin = () => +export const apphostingOrigin = (): string => utils.envOverride("FIREBASE_APPHOSTING_URL", "https://firebaseapphosting.googleapis.com"); -export const apphostingP4SADomain = () => +export const apphostingP4SADomain = (): string => utils.envOverride( "FIREBASE_APPHOSTING_P4SA_DOMAIN", "gcp-sa-firebaseapphosting.iam.gserviceaccount.com", ); -export const apphostingGitHubAppInstallationURL = () => +export const apphostingGitHubAppInstallationURL = (): string => utils.envOverride( "FIREBASE_APPHOSTING_GITHUB_INSTALLATION_URL", "https://github.com/apps/firebase-app-hosting/installations/new", ); -export const authOrigin = () => +export const authOrigin = (): string => utils.envOverride("FIREBASE_AUTH_URL", "https://accounts.google.com"); -export const authManagementOrigin = () => +export const authManagementOrigin = (): string => utils.envOverride("FIREBASE_AUTH_MANAGEMENT_URL", "https://identitytoolkit.googleapis.com"); -export const consoleOrigin = () => +export const consoleOrigin = (): string => utils.envOverride("FIREBASE_CONSOLE_URL", "https://console.firebase.google.com"); -export const eventarcOrigin = () => +export const eventarcOrigin = (): string => utils.envOverride("EVENTARC_URL", "https://eventarc.googleapis.com"); -export const firebaseApiOrigin = () => +export const firebaseApiOrigin = (): string => utils.envOverride("FIREBASE_API_URL", "https://firebase.googleapis.com"); -export const firebaseExtensionsRegistryOrigin = () => +export const firebaseExtensionsRegistryOrigin = (): string => utils.envOverride("FIREBASE_EXT_REGISTRY_ORIGIN", "https://extensions-registry.firebaseapp.com"); -export const firedataOrigin = () => +export const firedataOrigin = (): string => utils.envOverride("FIREBASE_FIREDATA_URL", "https://mobilesdk-pa.googleapis.com"); -export const firestoreOriginOrEmulator = () => +export const firestoreOriginOrEmulator = (): string => utils.envOverride( Constants.FIRESTORE_EMULATOR_HOST, utils.envOverride("FIRESTORE_URL", "https://firestore.googleapis.com"), @@ -77,112 +77,117 @@ export const firestoreOriginOrEmulator = () => return `http://${val}`; }, ); -export const firestoreOrigin = () => +export const firestoreOrigin = (): string => utils.envOverride("FIRESTORE_URL", "https://firestore.googleapis.com"); -export const functionsOrigin = () => +export const functionsOrigin = (): string => utils.envOverride("FIREBASE_FUNCTIONS_URL", "https://cloudfunctions.googleapis.com"); -export const functionsV2Origin = () => +export const functionsV2Origin = (): string => utils.envOverride("FIREBASE_FUNCTIONS_V2_URL", "https://cloudfunctions.googleapis.com"); -export const runOrigin = () => utils.envOverride("CLOUD_RUN_URL", "https://run.googleapis.com"); -export const functionsDefaultRegion = () => +export const runOrigin = (): string => + utils.envOverride("CLOUD_RUN_URL", "https://run.googleapis.com"); +export const functionsDefaultRegion = (): string => utils.envOverride("FIREBASE_FUNCTIONS_DEFAULT_REGION", "REGION_TBD"); -export const cloudbuildOrigin = () => +export const cloudbuildOrigin = (): string => utils.envOverride("FIREBASE_CLOUDBUILD_URL", "https://cloudbuild.googleapis.com"); -export const cloudschedulerOrigin = () => +export const cloudschedulerOrigin = (): string => utils.envOverride("FIREBASE_CLOUDSCHEDULER_URL", "https://cloudscheduler.googleapis.com"); -export const cloudTasksOrigin = () => +export const cloudTasksOrigin = (): string => utils.envOverride("FIREBASE_CLOUD_TAKS_URL", "https://cloudtasks.googleapis.com"); -export const pubsubOrigin = () => +export const pubsubOrigin = (): string => utils.envOverride("FIREBASE_PUBSUB_URL", "https://pubsub.googleapis.com"); -export const googleOrigin = () => +export const googleOrigin = (): string => utils.envOverride( "FIREBASE_TOKEN_URL", utils.envOverride("FIREBASE_GOOGLE_URL", "https://www.googleapis.com"), ); -export const hostingOrigin = () => utils.envOverride("FIREBASE_HOSTING_URL", "https://web.app"); -export const identityOrigin = () => +export const hostingOrigin = (): string => + utils.envOverride("FIREBASE_HOSTING_URL", "https://web.app"); +export const identityOrigin = (): string => utils.envOverride("FIREBASE_IDENTITY_URL", "https://identitytoolkit.googleapis.com"); -export const iamOrigin = () => utils.envOverride("FIREBASE_IAM_URL", "https://iam.googleapis.com"); -export const extensionsOrigin = () => +export const iamOrigin = (): string => + utils.envOverride("FIREBASE_IAM_URL", "https://iam.googleapis.com"); +export const extensionsOrigin = (): string => utils.envOverride("FIREBASE_EXT_URL", "https://firebaseextensions.googleapis.com"); -export const extensionsPublisherOrigin = () => +export const extensionsPublisherOrigin = (): string => utils.envOverride( "FIREBASE_EXT_PUBLISHER_URL", "https://firebaseextensionspublisher.googleapis.com", ); -export const extensionsTOSOrigin = () => +export const extensionsTOSOrigin = (): string => utils.envOverride("FIREBASE_EXT_TOS_URL", "https://firebaseextensionstos-pa.googleapis.com"); -export const realtimeOrigin = () => +export const realtimeOrigin = (): string => utils.envOverride("FIREBASE_REALTIME_URL", "https://firebaseio.com"); -export const rtdbManagementOrigin = () => +export const rtdbManagementOrigin = (): string => utils.envOverride("FIREBASE_RTDB_MANAGEMENT_URL", "https://firebasedatabase.googleapis.com"); -export const rtdbMetadataOrigin = () => +export const rtdbMetadataOrigin = (): string => utils.envOverride("FIREBASE_RTDB_METADATA_URL", "https://metadata-dot-firebase-prod.appspot.com"); -export const remoteConfigApiOrigin = () => +export const remoteConfigApiOrigin = (): string => utils.envOverride("FIREBASE_REMOTE_CONFIG_URL", "https://firebaseremoteconfig.googleapis.com"); -export const messagingApiOrigin = () => +export const messagingApiOrigin = (): string => utils.envOverride("FIREBASE_MESSAGING_CONFIG_URL", "https://fcm.googleapis.com"); -export const crashlyticsApiOrigin = () => +export const crashlyticsApiOrigin = (): string => utils.envOverride("FIREBASE_CRASHLYTICS_URL", "https://firebasecrashlytics.googleapis.com"); -export const firebaseTelemetryOrigin = () => +export const firebaseTelemetryOrigin = (): string => utils.envOverride("FIREBASE_TELEMETRY_URL", "https://firebasetelemetry.googleapis.com"); -export const firebaseTelemetryAdminOrigin = () => +export const firebaseTelemetryAdminOrigin = (): string => utils.envOverride( "FIREBASE_TELEMETRY_ADMIN_URL", "https://firebasetelemetryadmin.googleapis.com", ); -export const resourceManagerOrigin = () => +export const resourceManagerOrigin = (): string => utils.envOverride("FIREBASE_RESOURCEMANAGER_URL", "https://cloudresourcemanager.googleapis.com"); -export const rulesOrigin = () => +export const rulesOrigin = (): string => utils.envOverride("FIREBASE_RULES_URL", "https://firebaserules.googleapis.com"); -export const runtimeconfigOrigin = () => +export const runtimeconfigOrigin = (): string => utils.envOverride("FIREBASE_RUNTIMECONFIG_URL", "https://runtimeconfig.googleapis.com"); -export const storageOrigin = () => +export const storageOrigin = (): string => utils.envOverride("FIREBASE_STORAGE_URL", "https://storage.googleapis.com"); -export const firebaseStorageOrigin = () => +export const firebaseStorageOrigin = (): string => utils.envOverride("FIREBASE_FIREBASESTORAGE_URL", "https://firebasestorage.googleapis.com"); -export const hostingApiOrigin = () => +export const hostingApiOrigin = (): string => utils.envOverride("FIREBASE_HOSTING_API_URL", "https://firebasehosting.googleapis.com"); -export const cloudRunApiOrigin = () => +export const cloudRunApiOrigin = (): string => utils.envOverride("CLOUD_RUN_API_URL", "https://run.googleapis.com"); -export const serviceUsageOrigin = () => +export const serviceUsageOrigin = (): string => utils.envOverride("FIREBASE_SERVICE_USAGE_URL", "https://serviceusage.googleapis.com"); -export const studioApiOrigin = () => +export const studioApiOrigin = (): string => utils.envOverride("FIREBASE_STUDIO_URL", "https://monospace-pa.googleapis.com"); -export const githubOrigin = () => utils.envOverride("GITHUB_URL", "https://github.com"); -export const githubApiOrigin = () => utils.envOverride("GITHUB_API_URL", "https://api.github.com"); -export const secretManagerOrigin = () => +export const githubOrigin = (): string => utils.envOverride("GITHUB_URL", "https://github.com"); +export const githubApiOrigin = (): string => + utils.envOverride("GITHUB_API_URL", "https://api.github.com"); +export const secretManagerOrigin = (): string => utils.envOverride("CLOUD_SECRET_MANAGER_URL", "https://secretmanager.googleapis.com"); -export const computeOrigin = () => +export const computeOrigin = (): string => utils.envOverride("COMPUTE_URL", "https://compute.googleapis.com"); -export const githubClientId = () => utils.envOverride("GITHUB_CLIENT_ID", "89cf50f02ac6aaed3484"); -export const githubClientSecret = () => +export const githubClientId = (): string => + utils.envOverride("GITHUB_CLIENT_ID", "89cf50f02ac6aaed3484"); +export const githubClientSecret = (): string => utils.envOverride("GITHUB_CLIENT_SECRET", "3330d14abc895d9a74d5f17cd7a00711fa2c5bf0"); -export const dataconnectOrigin = () => +export const dataconnectOrigin = (): string => utils.envOverride("FIREBASE_DATACONNECT_URL", "https://firebasedataconnect.googleapis.com"); -export const dataconnectP4SADomain = () => +export const dataconnectP4SADomain = (): string => utils.envOverride( "FIREBASE_DATACONNECT_P4SA_DOMAIN", "gcp-sa-firebasedataconnect.iam.gserviceaccount.com", ); -export const dataConnectLocalConnString = () => +export const dataConnectLocalConnString = (): string => utils.envOverride("FIREBASE_DATACONNECT_POSTGRESQL_STRING", ""); -export const cloudSQLAdminOrigin = () => +export const cloudSQLAdminOrigin = (): string => utils.envOverride("CLOUD_SQL_URL", "https://sqladmin.googleapis.com"); -export const vertexAIOrigin = () => +export const vertexAIOrigin = (): string => utils.envOverride("VERTEX_AI_URL", "https://aiplatform.googleapis.com"); -export const aiLogicProxyOrigin = () => +export const aiLogicProxyOrigin = (): string => utils.envOverride("AI_LOGIC_PROXY_URL", "https://firebasevertexai.googleapis.com"); -export const appTestingOrigin = () => +export const appTestingOrigin = (): string => utils.envOverride("FIREBASE_APP_TESTING_URL", "https://firebaseapptesting.googleapis.com"); -export const cloudTestingOrigin = () => +export const cloudTestingOrigin = (): string => utils.envOverride("CLOUD_TESTING_URL", "https://testing.googleapis.com"); -export const developerKnowledgeOrigin = () => +export const developerKnowledgeOrigin = (): string => utils.envOverride("DEVELOPER_KNOWLEDGE_URL", "https://developerknowledge.googleapis.com"); /** Gets scopes that have been set. */ From b193b64cfdcf9792faf825248c062686d1031ce0 Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Thu, 30 Jul 2026 03:55:01 +0000 Subject: [PATCH 6/6] add vscode version to runTest.ts --- firebase-vscode/src/test/runTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase-vscode/src/test/runTest.ts b/firebase-vscode/src/test/runTest.ts index f6925f85b17..6b39ccf031f 100644 --- a/firebase-vscode/src/test/runTest.ts +++ b/firebase-vscode/src/test/runTest.ts @@ -16,6 +16,7 @@ async function main() { // Download VS Code, unzip it and run the integration test const tmpUserData = path.join(os.tmpdir(), `vsc-ud-${Math.random().toString(36).substring(2, 7)}`); await runTests({ + version: "1.96.4", extensionDevelopmentPath, extensionTestsPath, launchArgs: ["--user-data-dir", tmpUserData],