Skip to content

Commit 38dfc3f

Browse files
fix(schematics): read the signed-in account on firebase-tools 15.26+ (#3769)
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, and says what a stopped run leaves behind. Fixes #3768
1 parent ac17ede commit 38dfc3f

8 files changed

Lines changed: 76 additions & 16 deletions

File tree

‎docs/install-and-setup.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
## Before you begin
44

55
- **Firebase CLI**
6-
- Setup uses the Firebase CLI (`firebase-tools`). Install version 14 first, with `npm install -g firebase-tools@14`, then `firebase login`.
7-
- 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).
6+
- 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.
7+
- Run `ng add` yourself, not through an AI agent.
8+
- 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')`.
9+
- If it already stopped, run `ng add @angular/fire@next` again yourself. The stopped run leaves only an empty `firebase.json`, which the second run reuses.
810
- 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.
911
- **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.
1012

‎docs/install-firebase-tools.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Firebase Tools Install and Setup
22

3-
> 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.
3+
> 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')`.
44
55
### 1. Install package
66

‎src/schematics/deploy/actions.jasmine.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,46 @@ const initMocks = () => {
115115
describe('Deploy Angular apps', () => {
116116
beforeEach(() => initMocks());
117117

118+
/** Spies on `login()` and keeps a `login.list()`, which `spyOn` would otherwise drop. */
119+
const spyOnLogin = (accounts: { user: Record<string, any> }[]) =>
120+
Object.assign(spyOn(firebaseMock, 'login'), { list: () => Promise.resolve(accounts), add: login.add, use: login.use });
121+
122+
const signedIn = [{ user: { email: 'foo@bar.baz' } }];
123+
118124
it('should call login', async () => {
119-
const spy = spyOn(firebaseMock, 'login').and.resolveTo({ email: 'foo@bar.baz' });
125+
const spy = spyOnLogin(signedIn).and.resolveTo({ email: 'foo@bar.baz' });
120126
await deploy(
121127
firebaseMock, context, STATIC_BUILD_TARGET, undefined,
122128
undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false }
123129
);
124130
expect(spy).toHaveBeenCalled();
125131
});
126132

133+
it('should read the signed-in account with interactive, which firebase-tools 15.26+ needs under an AI agent', async () => {
134+
const spy = spyOnLogin(signedIn).and.resolveTo({ email: 'foo@bar.baz' });
135+
await deploy(
136+
firebaseMock, context, STATIC_BUILD_TARGET, undefined,
137+
undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false }
138+
);
139+
expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({ interactive: true }));
140+
});
141+
142+
it('should deploy when login returns no account for a signed-in user', async () => {
143+
spyOnLogin(signedIn).and.resolveTo(undefined);
144+
await expectAsync(deploy(
145+
firebaseMock, context, STATIC_BUILD_TARGET, undefined,
146+
undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false }
147+
)).toBeResolved();
148+
});
149+
150+
it('should say to run firebase login when no account is signed in', async () => {
151+
spyOnLogin([]).and.resolveTo(undefined);
152+
await expectAsync(deploy(
153+
firebaseMock, context, STATIC_BUILD_TARGET, undefined,
154+
undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false }
155+
)).toBeRejectedWithError(/Run `firebase login`/);
156+
});
157+
127158
it('should not call login', async () => {
128159
const spy = spyOn(firebaseMock, 'login');
129160
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { preview: false }, FIREBASE_TOKEN);

‎src/schematics/deploy/actions.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import open from 'open';
1111
import { satisfies } from 'semver';
1212
import tripleBeam from 'triple-beam';
1313
import * as winston from 'winston';
14+
import { getActiveAccount } from '../firebaseTools.js';
1415
import { BuildTarget, CloudRunOptions, DeployBuilderSchema, FSHost, FirebaseTools } from '../interfaces';
1516
import { assertSafeDependencyName } from '../workspace.js';
1617
import { DEFAULT_FUNCTION_NAME, defaultFunction, defaultPackage, dockerfile, functionGen2 } from './functions-templates.js';
@@ -542,6 +543,12 @@ export const deployToCloudRun = async (
542543
});
543544
};
544545

546+
/** Whether the Firebase CLI has at least one signed-in account. */
547+
const isSignedIn = async (firebaseTools: FirebaseTools) => {
548+
const accounts = await firebaseTools.login.list();
549+
return Array.isArray(accounts) && accounts.length > 0;
550+
};
551+
545552
export default async function deploy(
546553
firebaseTools: FirebaseTools,
547554
context: BuilderContext,
@@ -555,9 +562,16 @@ export default async function deploy(
555562
const legacyNgDeploy = !options.version || options.version < 2;
556563

557564
if (!firebaseToken && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
558-
await firebaseTools.login();
559-
const user = await firebaseTools.login({ projectRoot: context.workspaceRoot });
560-
console.log(`Logged into Firebase as ${user.email}.`);
565+
if (!await isSignedIn(firebaseTools)) {
566+
await firebaseTools.login();
567+
if (!await isSignedIn(firebaseTools)) {
568+
throw new Error('No Firebase account is signed in. Run `firebase login`, then run `ng deploy` again.');
569+
}
570+
}
571+
const user = await getActiveAccount(firebaseTools, context.workspaceRoot);
572+
if (user) {
573+
console.log(`Logged into Firebase as ${user.email}.`);
574+
}
561575
}
562576

563577
if (!firebaseToken && process.env.GOOGLE_APPLICATION_CREDENTIALS) {

‎src/schematics/firebaseTools.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ declare global {
88
var firebaseTools: FirebaseTools|undefined;
99
}
1010

11+
/**
12+
* The account firebase-tools uses in `projectRoot`. Call it only once an account is signed in.
13+
* Without `interactive`, firebase-tools 15.26+ returns undefined whenever it runs non-interactively,
14+
* as it does under an AI agent.
15+
*/
16+
export const getActiveAccount = (firebaseTools: FirebaseTools, projectRoot: string) =>
17+
firebaseTools.login({ projectRoot, interactive: true });
18+
1119
// The minimum firebase-tools version the schematics require.
1220
const minFirebaseToolsVersion = '14.0.0';
1321

‎src/schematics/interfaces.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export interface FirebaseTools {
125125
list(): Promise<{user: Record<string, any>}[] | { users: undefined }>;
126126
add(): Promise<Record<string, any>>;
127127
use(email: string, options?: unknown): Promise<string>;
128-
} & ((options?: unknown) => Promise<Record<string, any>>);
128+
} & ((options?: unknown) => Promise<Record<string, any> | undefined>);
129129

130130
deploy(config: FirebaseDeployConfig): Promise<any>;
131131

‎src/schematics/setup/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { join } from 'path';
33
import { asWindowsPath, normalize } from '@angular-devkit/core';
44
import { SchematicContext, Tree, chain } from '@angular-devkit/schematics';
55
import { addRootProvider } from '@schematics/angular/utility';
6-
import { getFirebaseTools } from '../firebaseTools';
6+
import { getActiveAccount, getFirebaseTools } from '../firebaseTools';
77
import {
88
DataConnectConnectorConfig,
99
DeployOptions, FEATURES, FirebaseApp, FirebaseJSON, FirebaseProject,
@@ -91,7 +91,7 @@ export const ngAddSetupProject = (
9191
);
9292

9393
const user = await userPrompt({ projectRoot });
94-
const defaultUser = await firebaseTools.login(options);
94+
const defaultUser = await getActiveAccount(firebaseTools, projectRoot);
9595
if (user.email !== defaultUser?.email) {
9696
await firebaseTools.login.use(user.email, { projectRoot });
9797
}

‎src/schematics/setup/prompts.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawnSync } from 'child_process';
22
import * as fuzzy from 'fuzzy';
33
import * as inquirer from 'inquirer';
4-
import { getFirebaseTools } from '../firebaseTools';
4+
import { getActiveAccount, getFirebaseTools } from '../firebaseTools';
55
import { FEATURES, FirebaseApp, FirebaseProject, featureOptions } from '../interfaces';
66
import { shortAppId } from '../utils';
77

@@ -96,21 +96,26 @@ export const userPrompt = async (options: { projectRoot: string }): Promise<Reco
9696
const firebaseTools = await getFirebaseTools();
9797
let loginList = await firebaseTools.login.list();
9898
if (!Array.isArray(loginList) || loginList.length === 0) {
99-
spawnSync('firebase login', { shell: true, cwd: options.projectRoot, stdio: 'inherit' });
100-
return await firebaseTools.login(options);
99+
// `--interactive` because firebase-tools 15.26+ otherwise skips the sign-in under an AI agent.
100+
spawnSync('firebase login --interactive', { shell: true, cwd: options.projectRoot, stdio: 'inherit' });
101+
loginList = await firebaseTools.login.list();
102+
if (!Array.isArray(loginList) || loginList.length === 0) {
103+
throw new Error('No Firebase account is signed in. Run `firebase login`, then run `ng add @angular/fire` again.');
104+
}
105+
return loginList[0].user;
101106
} else {
102-
const defaultUser = await firebaseTools.login(options);
107+
const defaultUser = await getActiveAccount(firebaseTools, options.projectRoot);
103108
const choices = loginList.map(({user}) => ({ name: user.email, value: user }));
104109
const newChoice = { name: '[Login in with another account]', value: NEW_OPTION };
105110
const { user } = await inquirer.prompt({
106111
type: 'list',
107112
name: 'user',
108113
choices: [newChoice].concat(choices as any), // TODO types
109114
message: 'Which Firebase account would you like to use?',
110-
default: choices.find(it => it.value.email === defaultUser.email)?.value,
115+
default: choices.find(it => it.value.email === defaultUser?.email)?.value,
111116
}) as any;
112117
if (user === NEW_OPTION) {
113-
spawnSync('firebase login:add', { shell: true, cwd: options.projectRoot, stdio: 'inherit' });
118+
spawnSync('firebase login:add --interactive', { shell: true, cwd: options.projectRoot, stdio: 'inherit' });
114119
loginList = await firebaseTools.login.list();
115120
if (!Array.isArray(loginList)) {
116121
throw new Error("firebase login:list did not respond as expected");

0 commit comments

Comments
 (0)