From 698e5fda1e09b2156165345376d492232daaf66e Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Thu, 24 Sep 2026 19:56:59 -0700 Subject: [PATCH] fix(schematics): read the signed-in account on firebase-tools 15.26+ firebase-tools 15.26 runs non-interactively when it detects an AI agent or when stdin is not a terminal, and in that mode login() returns undefined instead of the signed-in account. `ng add` and `ng deploy` crashed reading `.email` from it. getActiveAccount passes `interactive: true`, which returns the account without a prompt once one is signed in. The `firebase login` and `firebase login:add` commands setup starts get `--interactive` for the same reason. Setup no longer passes its options to login(), since they carry the Angular project name, which firebase-tools rejects as a project id for names like `myApp`. The Quickstart stops telling readers to install firebase-tools 14. Fixes #3768 --- docs/install-and-setup.md | 6 +++-- docs/install-firebase-tools.md | 2 +- src/schematics/deploy/actions.jasmine.ts | 33 +++++++++++++++++++++++- src/schematics/deploy/actions.ts | 20 +++++++++++--- src/schematics/firebaseTools.ts | 8 ++++++ src/schematics/interfaces.ts | 2 +- src/schematics/setup/index.ts | 4 +-- src/schematics/setup/prompts.ts | 17 +++++++----- 8 files changed, 76 insertions(+), 16 deletions(-) diff --git a/docs/install-and-setup.md b/docs/install-and-setup.md index c498e6e19..5c9f32fbc 100644 --- a/docs/install-and-setup.md +++ b/docs/install-and-setup.md @@ -3,8 +3,10 @@ ## Before you begin - **Firebase CLI** - - Setup uses the Firebase CLI (`firebase-tools`). Install version 14 first, with `npm install -g firebase-tools@14`, then `firebase login`. - - With no CLI on the machine, `ng add` installs the newest version for you, and on version 15 setup stops right after the features question with `Cannot read properties of undefined (reading 'email')` (#3768). + - Setup uses the Firebase CLI (`firebase-tools`). Install it with `npm install -g firebase-tools`, then run `firebase login`. If `ng add` can't find a copy, it installs the newest version for you. + - Run `ng add` yourself, not through an AI agent. + - In AngularFire 21.0.0-rc.1 and earlier, `ng add` run by an agent with firebase-tools 15.26 or later stops after you choose features, with `Cannot read properties of undefined (reading 'email')`. + - If it already stopped, run `ng add @angular/fire@next` again yourself. The stopped run leaves nothing to clean up. - A copy older than 14 stops setup with `firebase-tools version 14.0.0+ is required, please upgrade and run again`, and `ng add` does not upgrade a copy you already have. - **Harmless CLI noise.** The Firebase CLI may print a `punycode` deprecation warning or ask about enabling extra features (for example Gemini) during setup. These come from the CLI, not from AngularFire, and are safe to ignore. diff --git a/docs/install-firebase-tools.md b/docs/install-firebase-tools.md index 623b46364..4379de9e6 100644 --- a/docs/install-firebase-tools.md +++ b/docs/install-firebase-tools.md @@ -1,6 +1,6 @@ # Firebase Tools Install and Setup -> If you are installing this to run `ng add @angular/fire`, install version 14. With firebase-tools 15, setup stops with `Cannot read properties of undefined (reading 'email')` once you have chosen your features. +> Setting up AngularFire? Run `ng add @angular/fire` yourself, not through an AI agent. In AngularFire 21.0.0-rc.1 and earlier, `ng add` run by an agent with firebase-tools 15.26 or later stops after you choose AngularFire features, with `Cannot read properties of undefined (reading 'email')`. ### 1. Install package diff --git a/src/schematics/deploy/actions.jasmine.ts b/src/schematics/deploy/actions.jasmine.ts index e7ee58c0d..4ae8b0366 100644 --- a/src/schematics/deploy/actions.jasmine.ts +++ b/src/schematics/deploy/actions.jasmine.ts @@ -115,8 +115,14 @@ const initMocks = () => { describe('Deploy Angular apps', () => { beforeEach(() => initMocks()); + /** Spies on `login()` and keeps a `login.list()`, which `spyOn` would otherwise drop. */ + const spyOnLogin = (accounts: { user: Record }[]) => + Object.assign(spyOn(firebaseMock, 'login'), { list: () => Promise.resolve(accounts), add: login.add, use: login.use }); + + const signedIn = [{ user: { email: 'foo@bar.baz' } }]; + it('should call login', async () => { - const spy = spyOn(firebaseMock, 'login').and.resolveTo({ email: 'foo@bar.baz' }); + const spy = spyOnLogin(signedIn).and.resolveTo({ email: 'foo@bar.baz' }); await deploy( firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false } @@ -124,6 +130,31 @@ describe('Deploy Angular apps', () => { expect(spy).toHaveBeenCalled(); }); + it('should read the signed-in account with interactive, which firebase-tools 15.26+ needs under an AI agent', async () => { + const spy = spyOnLogin(signedIn).and.resolveTo({ email: 'foo@bar.baz' }); + await deploy( + firebaseMock, context, STATIC_BUILD_TARGET, undefined, + undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false } + ); + expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({ interactive: true })); + }); + + it('should deploy when login returns no account for a signed-in user', async () => { + spyOnLogin(signedIn).and.resolveTo(undefined); + await expectAsync(deploy( + firebaseMock, context, STATIC_BUILD_TARGET, undefined, + undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false } + )).toBeResolved(); + }); + + it('should say to run firebase login when no account is signed in', async () => { + spyOnLogin([]).and.resolveTo(undefined); + await expectAsync(deploy( + firebaseMock, context, STATIC_BUILD_TARGET, undefined, + undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false } + )).toBeRejectedWithError(/Run `firebase login`/); + }); + it('should not call login', async () => { const spy = spyOn(firebaseMock, 'login'); await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { preview: false }, FIREBASE_TOKEN); diff --git a/src/schematics/deploy/actions.ts b/src/schematics/deploy/actions.ts index 841c06808..fda68ac8f 100644 --- a/src/schematics/deploy/actions.ts +++ b/src/schematics/deploy/actions.ts @@ -11,6 +11,7 @@ import open from 'open'; import { satisfies } from 'semver'; import tripleBeam from 'triple-beam'; import * as winston from 'winston'; +import { getActiveAccount } from '../firebaseTools.js'; import { BuildTarget, CloudRunOptions, DeployBuilderSchema, FSHost, FirebaseTools } from '../interfaces'; import { assertSafeDependencyName } from '../workspace.js'; import { DEFAULT_FUNCTION_NAME, defaultFunction, defaultPackage, dockerfile, functionGen2 } from './functions-templates.js'; @@ -542,6 +543,12 @@ export const deployToCloudRun = async ( }); }; +/** Whether the Firebase CLI has at least one signed-in account. */ +const isSignedIn = async (firebaseTools: FirebaseTools) => { + const accounts = await firebaseTools.login.list(); + return Array.isArray(accounts) && accounts.length > 0; +}; + export default async function deploy( firebaseTools: FirebaseTools, context: BuilderContext, @@ -555,9 +562,16 @@ export default async function deploy( const legacyNgDeploy = !options.version || options.version < 2; if (!firebaseToken && !process.env.GOOGLE_APPLICATION_CREDENTIALS) { - await firebaseTools.login(); - const user = await firebaseTools.login({ projectRoot: context.workspaceRoot }); - console.log(`Logged into Firebase as ${user.email}.`); + if (!await isSignedIn(firebaseTools)) { + await firebaseTools.login(); + if (!await isSignedIn(firebaseTools)) { + throw new Error('No Firebase account is signed in. Run `firebase login`, then run `ng deploy` again.'); + } + } + const user = await getActiveAccount(firebaseTools, context.workspaceRoot); + if (user) { + console.log(`Logged into Firebase as ${user.email}.`); + } } if (!firebaseToken && process.env.GOOGLE_APPLICATION_CREDENTIALS) { diff --git a/src/schematics/firebaseTools.ts b/src/schematics/firebaseTools.ts index be6553c8e..a1aa63f39 100644 --- a/src/schematics/firebaseTools.ts +++ b/src/schematics/firebaseTools.ts @@ -8,6 +8,14 @@ declare global { var firebaseTools: FirebaseTools|undefined; } +/** + * The account firebase-tools uses in `projectRoot`. Call it only once an account is signed in. + * Without `interactive`, firebase-tools 15.26+ returns undefined whenever it runs non-interactively, + * as it does under an AI agent. + */ +export const getActiveAccount = (firebaseTools: FirebaseTools, projectRoot: string) => + firebaseTools.login({ projectRoot, interactive: true }); + // The minimum firebase-tools version the schematics require. const minFirebaseToolsVersion = '14.0.0'; diff --git a/src/schematics/interfaces.ts b/src/schematics/interfaces.ts index 3d34ef332..470b2751a 100644 --- a/src/schematics/interfaces.ts +++ b/src/schematics/interfaces.ts @@ -125,7 +125,7 @@ export interface FirebaseTools { list(): Promise<{user: Record}[] | { users: undefined }>; add(): Promise>; use(email: string, options?: unknown): Promise; - } & ((options?: unknown) => Promise>); + } & ((options?: unknown) => Promise | undefined>); deploy(config: FirebaseDeployConfig): Promise; diff --git a/src/schematics/setup/index.ts b/src/schematics/setup/index.ts index 173f4b398..03db70fd3 100644 --- a/src/schematics/setup/index.ts +++ b/src/schematics/setup/index.ts @@ -3,7 +3,7 @@ import { join } from 'path'; import { asWindowsPath, normalize } from '@angular-devkit/core'; import { SchematicContext, Tree, chain } from '@angular-devkit/schematics'; import { addRootProvider } from '@schematics/angular/utility'; -import { getFirebaseTools } from '../firebaseTools'; +import { getActiveAccount, getFirebaseTools } from '../firebaseTools'; import { DataConnectConnectorConfig, DeployOptions, FEATURES, FirebaseApp, FirebaseJSON, FirebaseProject, @@ -91,7 +91,7 @@ export const ngAddSetupProject = ( ); const user = await userPrompt({ projectRoot }); - const defaultUser = await firebaseTools.login(options); + const defaultUser = await getActiveAccount(firebaseTools, projectRoot); if (user.email !== defaultUser?.email) { await firebaseTools.login.use(user.email, { projectRoot }); } diff --git a/src/schematics/setup/prompts.ts b/src/schematics/setup/prompts.ts index 701713b96..e1a4389ba 100644 --- a/src/schematics/setup/prompts.ts +++ b/src/schematics/setup/prompts.ts @@ -1,7 +1,7 @@ import { spawnSync } from 'child_process'; import * as fuzzy from 'fuzzy'; import * as inquirer from 'inquirer'; -import { getFirebaseTools } from '../firebaseTools'; +import { getActiveAccount, getFirebaseTools } from '../firebaseTools'; import { FEATURES, FirebaseApp, FirebaseProject, featureOptions } from '../interfaces'; import { shortAppId } from '../utils'; @@ -96,10 +96,15 @@ export const userPrompt = async (options: { projectRoot: string }): Promise ({ name: user.email, value: user })); const newChoice = { name: '[Login in with another account]', value: NEW_OPTION }; const { user } = await inquirer.prompt({ @@ -107,10 +112,10 @@ export const userPrompt = async (options: { projectRoot: string }): Promise it.value.email === defaultUser.email)?.value, + default: choices.find(it => it.value.email === defaultUser?.email)?.value, }) as any; if (user === NEW_OPTION) { - spawnSync('firebase login:add', { shell: true, cwd: options.projectRoot, stdio: 'inherit' }); + spawnSync('firebase login:add --interactive', { shell: true, cwd: options.projectRoot, stdio: 'inherit' }); loginList = await firebaseTools.login.list(); if (!Array.isArray(loginList)) { throw new Error("firebase login:list did not respond as expected");