Skip to content

Commit 490b68c

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 The Quickstart changes are left out, since docs are not backported. (cherry picked from commit 38dfc3f)
1 parent 1119199 commit 490b68c

6 files changed

Lines changed: 71 additions & 13 deletions

File tree

‎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
@@ -10,6 +10,7 @@ import open from 'open';
1010
import { satisfies } from 'semver';
1111
import tripleBeam from 'triple-beam';
1212
import * as winston from 'winston';
13+
import { getActiveAccount } from '../firebaseTools.js';
1314
import { BuildTarget, CloudRunOptions, DeployBuilderSchema, FSHost, FirebaseTools } from '../interfaces';
1415
import { firebaseFunctionsDependencies } from '../versions.json';
1516
import { DEFAULT_FUNCTION_NAME, defaultFunction, defaultPackage, dockerfile, functionGen2 } from './functions-templates';
@@ -547,6 +548,12 @@ export const deployToCloudRun = async (
547548
});
548549
};
549550

551+
/** Whether the Firebase CLI has at least one signed-in account. */
552+
const isSignedIn = async (firebaseTools: FirebaseTools) => {
553+
const accounts = await firebaseTools.login.list();
554+
return Array.isArray(accounts) && accounts.length > 0;
555+
};
556+
550557
export default async function deploy(
551558
firebaseTools: FirebaseTools,
552559
context: BuilderContext,
@@ -560,9 +567,16 @@ export default async function deploy(
560567
const legacyNgDeploy = !options.version || options.version < 2;
561568

562569
if (!firebaseToken && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
563-
await firebaseTools.login();
564-
const user = await firebaseTools.login({ projectRoot: context.workspaceRoot });
565-
console.log(`Logged into Firebase as ${user.email}.`);
570+
if (!await isSignedIn(firebaseTools)) {
571+
await firebaseTools.login();
572+
if (!await isSignedIn(firebaseTools)) {
573+
throw new Error('No Firebase account is signed in. Run `firebase login`, then run `ng deploy` again.');
574+
}
575+
}
576+
const user = await getActiveAccount(firebaseTools, context.workspaceRoot);
577+
if (user) {
578+
console.log(`Logged into Firebase as ${user.email}.`);
579+
}
566580
}
567581

568582
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
export const getFirebaseTools = () => globalThis.firebaseTools ?
1220
Promise.resolve(globalThis.firebaseTools) :
1321
new Promise<FirebaseTools>((resolve, reject) => {

‎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,
@@ -86,7 +86,7 @@ export const ngAddSetupProject = (
8686
);
8787

8888
const user = await userPrompt({ projectRoot });
89-
const defaultUser = await firebaseTools.login(options);
89+
const defaultUser = await getActiveAccount(firebaseTools, projectRoot);
9090
if (user.email !== defaultUser?.email) {
9191
await firebaseTools.login.use(user.email, { projectRoot });
9292
}

‎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)