Repository: firebase/firebase-tools
Pull request #1572 (2019-07-31 01:24:59)

Add the ability to add Firebase resources to an existing GCP project
### Description
Design proposal: [go/firebase-cli-import](https://goto.google.com/firebase-cli-import)

- Added `firebase projects:addfirebase` command to add Firebase resources to an existing Google Cloud Platform (GCP) project
- Updated `firebase init` command with a new option to add Firebase to a GCP project and set it as the default project for the current directory

### Notes
- This PR has to be  merged to master after https://github.com/firebase/firebase-tools/pull/1562

### Testing
- Unit testing
- Manual testing
 

coveralls: 
[![Coverage Status](https://coveralls.io/builds/24892228/badge)](https://coveralls.io/builds/24892228)

Coverage increased (+0.2%) to 64.862% when pulling **3faca294c0b2b963f04d04b06f621b7f4f266283 on tc-addFirebase** into **f9360d49e2710b31492252ee8d54b254fbdac48f on tc-management**.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee216a493..bcf8d79fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 * Introduce experimental support for browser clients to the Firestore emulator.
 * No longer require authentication to run the functions emulator.
-* Add new project management commands: `projects:create`, `projects:list`.
+* Add new project management commands: `projects:create`, `projects:list`,
+  `projects:addFirebase`.
 * Add new app management commands: `apps:create`, `apps:list`, `apps:sdkconfig`.
 * Improve `init` command to be able to create a new project.
diff --git a/src/commands/apps-create.ts b/src/commands/apps-create.ts
index 21a84868c..196676ad5 100644
--- a/src/commands/apps-create.ts
+++ b/src/commands/apps-create.ts
@@ -6,6 +6,7 @@ import * as getProjectId from "../getProjectId";
 import { FirebaseError } from "../error";
 import {
   AndroidAppMetadata,
+  AppMetadata,
   AppPlatform,
   createAndroidApp,
   createIosApp,
@@ -167,7 +168,11 @@ module.exports = new Command("apps:create [platform] [displayName]")
   .option("-s, --app-store-id <appStoreId>", "(optional) app store id for the iOS app")
   .before(requireAuth)
   .action(
-    async (platform: string = "", displayName: string | undefined, options: any): Promise<any> => {
+    async (
+      platform: string = "",
+      displayName: string | undefined,
+      options: any
+    ): Promise<AppMetadata> => {
       const projectId = getProjectId(options);
 
       if (!options.nonInteractive && !platform) {
diff --git a/src/commands/apps-sdkconfig.ts b/src/commands/apps-sdkconfig.ts
index 091a1baa3..802bbbf0c 100644
--- a/src/commands/apps-sdkconfig.ts
+++ b/src/commands/apps-sdkconfig.ts
@@ -3,6 +3,7 @@ import * as fs from "fs-extra";
 
 import * as Command from "../command";
 import {
+  AppConfigurationData,
   AppMetadata,
   AppPlatform,
   getAppConfig,
@@ -58,7 +59,11 @@ module.exports = new Command("apps:sdkconfig [platform] [appId]")
   .option("-o, --out [file]", "(optional) write config output to a file")
   .before(requireAuth)
   .action(
-    async (platform: string = "", appId: string = "", options: any): Promise<any> => {
+    async (
+      platform: string = "",
+      appId: string = "",
+      options: any
+    ): Promise<AppConfigurationData> => {
       let appPlatform = getAppPlatform(platform);
 
       if (!appId) {
diff --git a/src/commands/index.js b/src/commands/index.js
index 12cdd89b2..fa63b814f 100644
--- a/src/commands/index.js
+++ b/src/commands/index.js
@@ -62,6 +62,7 @@ module.exports = function(client) {
   client.logout = loadCommand("logout");
   client.open = loadCommand("open");
   client.projects = {};
+  client.projects.addfirebase = loadCommand("projects-addfirebase");
   client.projects.list = loadCommand("projects-list");
   client.projects.create = loadCommand("projects-create");
   client.serve = loadCommand("serve");
diff --git a/src/commands/projects-addfirebase.ts b/src/commands/projects-addfirebase.ts
new file mode 100644
index 000000000..b65a9e8f0
--- /dev/null
+++ b/src/commands/projects-addfirebase.ts
@@ -0,0 +1,27 @@
+import * as _ from "lodash";
+
+import * as Command from "../command";
+import { FirebaseError } from "../error";
+import {
+  addFirebaseToCloudProjectAndLog,
+  FirebaseProjectMetadata,
+  promptAvailableProjectId,
+} from "../management/projects";
+import * as requireAuth from "../requireAuth";
+
+module.exports = new Command("projects:addfirebase [projectId]")
+  .description("add Firebase resources to a Google Cloud Platform project")
+  .before(requireAuth)
+  .action(
+    async (projectId: string | undefined, options: any): Promise<FirebaseProjectMetadata> => {
+      if (!options.nonInteractive && !projectId) {
+        projectId = await promptAvailableProjectId();
+      }
+
+      if (!projectId) {
+        throw new FirebaseError("Project ID cannot be empty");
+      }
+
+      return addFirebaseToCloudProjectAndLog(projectId);
+    }
+  );
diff --git a/src/commands/projects-create.ts b/src/commands/projects-create.ts
index 73e38b847..5ca232a74 100644
--- a/src/commands/projects-create.ts
+++ b/src/commands/projects-create.ts
@@ -2,6 +2,7 @@ import * as Command from "../command";
 import { FirebaseError } from "../error";
 import {
   createFirebaseProjectAndLog,
+  FirebaseProjectMetadata,
   ProjectParentResourceType,
   PROJECTS_CREATE_QUESTIONS,
 } from "../management/projects";
@@ -21,7 +22,7 @@ module.exports = new Command("projects:create [projectId]")
   )
   .before(requireAuth)
   .action(
-    async (projectId: string | undefined, options: any): Promise<any> => {
+    async (projectId: string | undefined, options: any): Promise<FirebaseProjectMetadata> => {
       options.projectId = projectId; // add projectId into options to pass into prompt function
 
       if (options.organization && options.folder) {
diff --git a/src/init/features/project.ts b/src/init/features/project.ts
index 2188175a5..5c739169d 100644
--- a/src/init/features/project.ts
+++ b/src/init/features/project.ts
@@ -4,11 +4,13 @@ import * as _ from "lodash";
 import * as Config from "../../config";
 import { FirebaseError } from "../../error";
 import {
+  addFirebaseToCloudProjectAndLog,
   createFirebaseProjectAndLog,
   FirebaseProjectMetadata,
   getFirebaseProject,
   getOrPromptProject,
   PROJECTS_CREATE_QUESTIONS,
+  promptAvailableProjectId,
 } from "../../management/projects";
 import * as logger from "../../logger";
 import { prompt, promptOnce } from "../../prompt";
@@ -17,6 +19,7 @@ import * as utils from "../../utils";
 const OPTION_NO_PROJECT = "Don't set up a default project";
 const OPTION_USE_PROJECT = "Use an existing project";
 const OPTION_NEW_PROJECT = "Create a new project";
+const OPTION_ADD_FIREBASE = "Add Firebase to an existing Google Cloud Platform project";
 
 /**
  * Used in init flows to keep information about the project - basically
@@ -55,6 +58,14 @@ async function promptAndCreateNewProject(): Promise<FirebaseProjectMetadata> {
   });
 }
 
+async function promptAndAddFirebaseToCloudProject(): Promise<FirebaseProjectMetadata> {
+  const projectId = await promptAvailableProjectId();
+  if (!projectId) {
+    throw new FirebaseError("Project ID cannot be empty");
+  }
+  return await addFirebaseToCloudProjectAndLog(projectId);
+}
+
 /**
  * Sets up the default project if provided and writes .firebaserc file.
  * @param setup A helper object to use for the rest of the init features.
@@ -86,6 +97,7 @@ export async function doSetup(setup: any, config: Config, options: any): Promise
   const choices = [
     { name: OPTION_USE_PROJECT, value: OPTION_USE_PROJECT },
     { name: OPTION_NEW_PROJECT, value: OPTION_NEW_PROJECT },
+    { name: OPTION_ADD_FIREBASE, value: OPTION_ADD_FIREBASE },
     { name: OPTION_NO_PROJECT, value: OPTION_NO_PROJECT },
   ];
   const projectSetupOption: string = await promptOnce({
@@ -96,13 +108,19 @@ export async function doSetup(setup: any, config: Config, options: any): Promise
   });
 
   let projectMetaData;
-  if (projectSetupOption === OPTION_USE_PROJECT) {
-    projectMetaData = await getOrPromptProject(options);
-  } else if (projectSetupOption === OPTION_NEW_PROJECT) {
-    projectMetaData = await promptAndCreateNewProject();
-  } else {
-    // Do nothing if user chooses NO_PROJECT
-    return;
+  switch (projectSetupOption) {
+    case OPTION_USE_PROJECT:
+      projectMetaData = await getOrPromptProject(options);
+      break;
+    case OPTION_NEW_PROJECT:
+      projectMetaData = await promptAndCreateNewProject();
+      break;
+    case OPTION_ADD_FIREBASE:
+      projectMetaData = await promptAndAddFirebaseToCloudProject();
+      break;
+    default:
+      // Do nothing if user chooses NO_PROJECT
+      return;
   }
 
   const projectInfo = toProjectInfo(projectMetaData);
diff --git a/src/management/projects.ts b/src/management/projects.ts
index b20b6c8f0..65f661029 100644
--- a/src/management/projects.ts
+++ b/src/management/projects.ts
@@ -15,8 +15,14 @@ const MAXIMUM_PROMPT_LIST = 100;
 const PROJECT_LIST_PAGE_SIZE = 1000;
 const CREATE_PROJECT_API_REQUEST_TIMEOUT_MILLIS = 15000;
 
-export interface FirebaseProjectPage {
-  projects: FirebaseProjectMetadata[];
+export interface CloudProjectInfo {
+  project: string /* The resource name of the GCP project: "projects/projectId" */;
+  displayName?: string;
+  locationId?: string;
+}
+
+export interface ProjectPage<T> {
+  projects: T[];
   nextPageToken?: string;
 }
 
@@ -66,33 +72,53 @@ export async function createFirebaseProjectAndLog(
   projectId: string,
   options: { displayName?: string; parentResource?: ProjectParentResource }
 ): Promise<FirebaseProjectMetadata> {
-  let spinner = ora("Creating Google Cloud Platform project").start();
+  const spinner = ora("Creating Google Cloud Platform project").start();
+
   try {
     await createCloudProject(projectId, options);
     spinner.succeed();
+  } catch (err) {
+    spinner.fail();
+    throw err;
+  }
 
-    spinner = ora("Adding Firebase to Google Cloud project").start();
-    const projectInfo = await addFirebaseToCloudProject(projectId);
-    spinner.succeed();
+  return addFirebaseToCloudProjectAndLog(projectId);
+}
 
-    logger.info("");
-    if (process.platform === "win32") {
-      logger.info("=== Your Firebase project is ready! ===");
-    } else {
-      logger.info("🎉🎉🎉 Your Firebase project is ready! 🎉🎉🎉");
-    }
-    logger.info("");
-    logger.info("Project information:");
-    logger.info(`   - Project ID: ${clc.bold(projectInfo.projectId)}`);
-    logger.info(`   - Project Name: ${clc.bold(projectInfo.displayName)}`);
-    logger.info("");
-    logger.info("Firebase console is available at");
-    logger.info(`https://console.firebase.google.com/project/${clc.bold(projectId)}/overview`);
-    return projectInfo;
+export async function addFirebaseToCloudProjectAndLog(
+  projectId: string
+): Promise<FirebaseProjectMetadata> {
+  let projectInfo;
+  const spinner = ora("Adding Firebase resources to Google Cloud Platform project").start();
+
+  try {
+    projectInfo = await addFirebaseToCloudProject(projectId);
   } catch (err) {
     spinner.fail();
     throw err;
   }
+
+  spinner.succeed();
+  logNewFirebaseProjectInfo(projectInfo);
+  return projectInfo;
+}
+
+function logNewFirebaseProjectInfo(projectInfo: FirebaseProjectMetadata): void {
+  logger.info("");
+  if (process.platform === "win32") {
+    logger.info("=== Your Firebase project is ready! ===");
+  } else {
+    logger.info("🎉🎉🎉 Your Firebase project is ready! 🎉🎉🎉");
+  }
+  logger.info("");
+  logger.info("Project information:");
+  logger.info(`   - Project ID: ${clc.bold(projectInfo.projectId)}`);
+  logger.info(`   - Project Name: ${clc.bold(projectInfo.displayName)}`);
+  logger.info("");
+  logger.info("Firebase console is available at");
+  logger.info(
+    `https://console.firebase.google.com/project/${clc.bold(projectInfo.projectId)}/overview`
+  );
 }
 
 /**
@@ -108,9 +134,9 @@ export async function getOrPromptProject(options: any): Promise<FirebaseProjectM
 async function selectProjectInteractively(
   pageSize: number = MAXIMUM_PROMPT_LIST
 ): Promise<FirebaseProjectMetadata> {
-  const { projects, nextPageToken } = await getProjectPage(pageSize);
+  const { projects, nextPageToken } = await getFirebaseProjectPage(pageSize);
   if (projects.length === 0) {
-    throw new FirebaseError("There is no Firebase project associated with this account.");
+    throw new FirebaseError("There are no Firebase projects associated with this account.");
   }
   if (nextPageToken) {
     // Prompt user for project ID if we can't list all projects in 1 page
@@ -166,6 +192,49 @@ async function selectProjectFromList(
   return project;
 }
 
+function getProjectId(cloudProject: CloudProjectInfo): string {
+  const resourceName = cloudProject.project;
+  // According to
+  // https://firebase.google.com/docs/projects/api/reference/rest/v1beta1/availableProjects/list#projectinfo,
+  // resource name has the format of "projects/projectId"
+  return resourceName.substring(resourceName.lastIndexOf("/") + 1);
+}
+
+/**
+ * Prompt user to select an available GCP project to add Firebase resources
+ */
+export async function promptAvailableProjectId(): Promise<string> {
+  const { projects, nextPageToken } = await getAvailableCloudProjectPage(MAXIMUM_PROMPT_LIST);
+  if (projects.length === 0) {
+    throw new FirebaseError(
+      "There are no available Google Cloud projects to add Firebase services."
+    );
+  }
+
+  if (nextPageToken) {
+    // Prompt for project ID if we can't list all projects in 1 page
+    return await promptOnce({
+      type: "input",
+      message: "Please input the ID of the Google Cloud Project you would like to add Firebase:",
+    });
+  } else {
+    let choices = projects.filter((p: CloudProjectInfo) => !!p).map((p) => {
+      const projectId = getProjectId(p);
+      return {
+        name: projectId + (p.displayName ? ` (${p.displayName})` : ""),
+        value: projectId,
+      };
+    });
+    choices = _.orderBy(choices, ["name"], ["asc"]);
+    return await promptOnce({
+      type: "list",
+      name: "id",
+      message: "Select the Google Cloud Platform project you would like to add Firebase:",
+      choices,
+    });
+  }
+}
+
 /**
  * Send an API request to create a new Google Cloud Platform project and poll the LRO to get the
  * new project information.
@@ -204,7 +273,9 @@ export async function createCloudProject(
  * to get the new Firebase project information.
  * @return a promise that resolves to the new firebase project information
  */
-export async function addFirebaseToCloudProject(projectId: string): Promise<any> {
+export async function addFirebaseToCloudProject(
+  projectId: string
+): Promise<FirebaseProjectMetadata> {
   try {
     const response = await api.request("POST", `/v1beta1/projects/${projectId}:addFirebase`, {
       auth: true,
@@ -227,27 +298,48 @@ export async function addFirebaseToCloudProject(projectId: string): Promise<any>
   }
 }
 
+async function getProjectPage<T>(
+  apiResource: string,
+  options: {
+    responseKey: string; // The list is located at "apiResponse.body[responseKey]"
+    pageSize: number;
+    pageToken?: string;
+  }
+): Promise<ProjectPage<T>> {
+  let apiResponse;
+  const { responseKey, pageToken, pageSize } = options;
+  const pageTokenQueryString = pageToken ? `&pageToken=${pageToken}` : "";
+  apiResponse = await api.request(
+    "GET",
+    `${apiResource}?pageSize=${pageSize}${pageTokenQueryString}`,
+    {
+      auth: true,
+      origin: api.firebaseApiOrigin,
+      timeout: TIMEOUT_MILLIS,
+    }
+  );
+  return {
+    projects: apiResponse.body[responseKey] || [],
+    nextPageToken: apiResponse.body.nextPageToken,
+  };
+}
+
 /**
  * Lists Firebase projects in a page using the paginated API, identified by the page token and its
  * size.
  */
-export async function getProjectPage(
+export async function getFirebaseProjectPage(
   pageSize: number = PROJECT_LIST_PAGE_SIZE,
   pageToken?: string
-): Promise<FirebaseProjectPage> {
-  let apiResponse;
+): Promise<ProjectPage<FirebaseProjectMetadata>> {
+  let projectPage;
 
   try {
-    const pageTokenQueryString = pageToken ? `&pageToken=${pageToken}` : "";
-    apiResponse = await api.request(
-      "GET",
-      `/v1beta1/projects?pageSize=${pageSize}${pageTokenQueryString}`,
-      {
-        auth: true,
-        origin: api.firebaseApiOrigin,
-        timeout: TIMEOUT_MILLIS,
-      }
-    );
+    projectPage = await getProjectPage<FirebaseProjectMetadata>("/v1beta1/projects", {
+      responseKey: "results",
+      pageSize,
+      pageToken,
+    });
   } catch (err) {
     logger.debug(err.message);
     throw new FirebaseError(
@@ -256,10 +348,30 @@ export async function getProjectPage(
     );
   }
 
-  return {
-    projects: apiResponse.body.results || [],
-    nextPageToken: apiResponse.body.nextPageToken,
-  };
+  return projectPage;
+}
+
+/**
+ * Lists a page of available Google Cloud Platform projects that are available to have Firebase
+ * resources added, using the paginated API, identified by the page token and its size.
+ */
+export async function getAvailableCloudProjectPage(
+  pageSize: number = PROJECT_LIST_PAGE_SIZE,
+  pageToken?: string
+): Promise<ProjectPage<CloudProjectInfo>> {
+  try {
+    return await getProjectPage<CloudProjectInfo>("/v1beta1/availableProjects", {
+      responseKey: "projectInfo",
+      pageSize,
+      pageToken,
+    });
+  } catch (err) {
+    logger.debug(err.message);
+    throw new FirebaseError(
+      "Failed to list available Google Cloud Platform projects. See firebase-debug.log for more info.",
+      { exit: 2, original: err }
+    );
+  }
 }
 
 /**
@@ -272,7 +384,10 @@ export async function listFirebaseProjects(pageSize?: number): Promise<FirebaseP
   let nextPageToken;
 
   do {
-    const projectPage: FirebaseProjectPage = await getProjectPage(pageSize, nextPageToken);
+    const projectPage: ProjectPage<FirebaseProjectMetadata> = await getFirebaseProjectPage(
+      pageSize,
+      nextPageToken
+    );
     projects.push(...projectPage.projects);
     nextPageToken = projectPage.nextPageToken;
   } while (nextPageToken);


Test patch:
diff --git a/src/test/init/features/project.spec.ts b/src/test/init/features/project.spec.ts
index d56d6d3e5..562481ad5 100644
--- a/src/test/init/features/project.spec.ts
+++ b/src/test/init/features/project.spec.ts
@@ -39,6 +39,8 @@ describe("project", () => {
   let getProjectStub: sinon.SinonStub;
   let createFirebaseProjectStub: sinon.SinonStub;
   let getOrPromptProjectStub: sinon.SinonStub;
+  let addFirebaseProjectStub: sinon.SinonStub;
+  let promptAvailableProjectIdStub: sinon.SinonStub;
   let promptOnceStub: sinon.SinonStub;
   let promptStub: sinon.SinonStub;
 
@@ -46,6 +48,8 @@ describe("project", () => {
     getProjectStub = sandbox.stub(projectManager, "getFirebaseProject");
     createFirebaseProjectStub = sandbox.stub(projectManager, "createFirebaseProjectAndLog");
     getOrPromptProjectStub = sandbox.stub(projectManager, "getOrPromptProject");
+    addFirebaseProjectStub = sandbox.stub(projectManager, "addFirebaseToCloudProjectAndLog");
+    promptAvailableProjectIdStub = sandbox.stub(projectManager, "promptAvailableProjectId");
     promptStub = sandbox.stub(prompt, "prompt").throws("Unexpected prompt call");
     promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call");
   });
@@ -127,6 +131,49 @@ describe("project", () => {
       });
     });
 
+    describe('with "Add Firebase resources to GCP project" option', () => {
+      it("should add firebase resources and set up the correct properties", async () => {
+        const options = {};
+        const setup = { config: {}, rcfile: {} };
+        promptOnceStub
+          .onFirstCall()
+          .resolves("Add Firebase to an existing Google Cloud Platform project");
+        promptAvailableProjectIdStub.onFirstCall().resolves("my-project-123");
+        addFirebaseProjectStub.onFirstCall().resolves(TEST_FIREBASE_PROJECT);
+
+        await doSetup(setup, {}, options);
+
+        expect(_.get(setup, "projectId")).to.deep.equal("my-project-123");
+        expect(_.get(setup, "instance")).to.deep.equal("my-project");
+        expect(_.get(setup, "projectLocation")).to.deep.equal("us-central");
+        expect(_.get(setup.rcfile, "projects.default")).to.deep.equal("my-project-123");
+        expect(promptOnceStub).to.be.calledOnce;
+        expect(promptAvailableProjectIdStub).to.be.calledOnce;
+        expect(addFirebaseProjectStub).to.be.calledOnceWith("my-project-123");
+      });
+
+      it("should throw if project ID is empty after prompt", async () => {
+        const options = {};
+        const setup = { config: {}, rcfile: {} };
+        promptOnceStub
+          .onFirstCall()
+          .resolves("Add Firebase to an existing Google Cloud Platform project");
+        promptAvailableProjectIdStub.onFirstCall().resolves("");
+
+        let err;
+        try {
+          await doSetup(setup, {}, options);
+        } catch (e) {
+          err = e;
+        }
+
+        expect(err.message).to.equal("Project ID cannot be empty");
+        expect(promptOnceStub).to.be.calledOnce;
+        expect(promptAvailableProjectIdStub).to.be.calledOnce;
+        expect(addFirebaseProjectStub).to.be.not.called;
+      });
+    });
+
     describe(`with "Don't set up a default project" option`, () => {
       it("should set up the correct properties when not choosing a project", async () => {
         const options = {};
diff --git a/src/test/management/projects.spec.ts b/src/test/management/projects.spec.ts
index ed632be8c..9761a19cf 100644
--- a/src/test/management/projects.spec.ts
+++ b/src/test/management/projects.spec.ts
@@ -44,7 +44,19 @@ const ANOTHER_FIREBASE_PROJECT: projectManager.FirebaseProjectMetadata = {
   resources: {},
 };
 
-function generateProjectList(counts: number): projectManager.FirebaseProjectMetadata[] {
+const TEST_CLOUD_PROJECT: projectManager.CloudProjectInfo = {
+  project: "projects/my-project-123",
+  displayName: "my-project",
+  locationId: "us-central",
+};
+
+const ANOTHER_CLOUD_PROJECT: projectManager.CloudProjectInfo = {
+  project: "projects/another-project",
+  displayName: "another-project",
+  locationId: "us-central",
+};
+
+function generateFirebaseProjectList(counts: number): projectManager.FirebaseProjectMetadata[] {
   return Array.from(Array(counts), (_, i: number) => ({
     name: `projects/project-id-${i}`,
     projectId: `project-id-${i}`,
@@ -59,6 +71,14 @@ function generateProjectList(counts: number): projectManager.FirebaseProjectMeta
   }));
 }
 
+function generateCloudProjectList(counts: number): projectManager.CloudProjectInfo[] {
+  return Array.from(Array(counts), (_, i: number) => ({
+    project: `projects/project-id-${i}`,
+    displayName: `Project ${i}`,
+    locationId: `location-${i}`,
+  }));
+}
+
 describe("Project management", () => {
   let sandbox: sinon.SinonSandbox;
   let apiRequestStub: sinon.SinonStub;
@@ -133,8 +153,10 @@ describe("Project management", () => {
           err = e;
         }
 
-        expect(err.message).to.equal("There is no Firebase project associated with this account.");
-        expect(promptStub).to.be.not.called;
+        expect(err.message).to.equal(
+          "There are no Firebase projects associated with this account."
+        );
+        expect(promptOnceStub).to.be.not.called;
       });
 
       it("should get the correct project info when --project is supplied", async () => {
@@ -167,6 +189,55 @@ describe("Project management", () => {
         expect(promptOnceStub).to.be.not.called;
       });
     });
+
+    describe("promptAvailableProjectId", () => {
+      it("should select project from list if it is able to list all projects", async () => {
+        apiRequestStub.onFirstCall().resolves({
+          body: {
+            projectInfo: [TEST_CLOUD_PROJECT, ANOTHER_CLOUD_PROJECT],
+          },
+        });
+        promptOnceStub.resolves("my-project-123");
+
+        const projectId = await projectManager.promptAvailableProjectId();
+
+        expect(projectId).to.deep.equal("my-project-123");
+        expect(promptOnceStub).to.be.calledOnce;
+        expect(promptOnceStub.firstCall.args[0].type).to.equal("list");
+      });
+
+      it("should prompt project id if it is not able to list all projects", async () => {
+        apiRequestStub.onFirstCall().resolves({
+          body: {
+            projectInfo: [TEST_CLOUD_PROJECT, ANOTHER_CLOUD_PROJECT],
+            nextPageToken: "token",
+          },
+        });
+        promptOnceStub.resolves("my-project-123");
+
+        const projectId = await projectManager.promptAvailableProjectId();
+
+        expect(projectId).to.deep.equal("my-project-123");
+        expect(promptOnceStub).to.be.calledOnce;
+        expect(promptOnceStub.firstCall.args[0].type).to.equal("input");
+      });
+
+      it("should throw if there's no project", async () => {
+        apiRequestStub.onFirstCall().resolves({ body: { projectInfo: [] } });
+
+        let err;
+        try {
+          await projectManager.promptAvailableProjectId();
+        } catch (e) {
+          err = e;
+        }
+
+        expect(err.message).to.equal(
+          "There are no available Google Cloud projects to add Firebase services."
+        );
+        expect(promptOnceStub).to.be.not.called;
+      });
+    });
   });
 
   describe("API methods", () => {
@@ -350,15 +421,96 @@ describe("Project management", () => {
       });
     });
 
-    describe("getProjectPage", () => {
+    describe("getAvailableCloudProjectPage", () => {
+      it("should resolve with a project page if it succeeds (no input token)", async () => {
+        const pageSize = 10;
+        const expectedProjectList = generateCloudProjectList(pageSize);
+        apiRequestStub.onFirstCall().resolves({
+          body: { projectInfo: expectedProjectList, nextPageToken: NEXT_PAGE_TOKEN },
+        });
+
+        const projectPage = await projectManager.getAvailableCloudProjectPage(pageSize);
+
+        expect(projectPage.projects).to.deep.equal(expectedProjectList);
+        expect(projectPage.nextPageToken).to.equal(NEXT_PAGE_TOKEN);
+        expect(apiRequestStub).to.be.calledOnceWith(
+          "GET",
+          "/v1beta1/availableProjects?pageSize=10",
+          {
+            auth: true,
+            origin: api.firebaseApiOrigin,
+            timeout: 30000,
+          }
+        );
+      });
+
+      it("should resolve with a project page if it succeeds (with input token)", async () => {
+        const pageSize = 10;
+        const expectedProjectList = generateCloudProjectList(pageSize);
+        apiRequestStub.onFirstCall().resolves({
+          body: { projectInfo: expectedProjectList, nextPageToken: NEXT_PAGE_TOKEN },
+        });
+
+        const projectPage = await projectManager.getAvailableCloudProjectPage(pageSize, PAGE_TOKEN);
+
+        expect(projectPage.projects).to.deep.equal(expectedProjectList);
+        expect(projectPage.nextPageToken).to.equal(NEXT_PAGE_TOKEN);
+        expect(apiRequestStub).to.be.calledOnceWith(
+          "GET",
+          `/v1beta1/availableProjects?pageSize=10&pageToken=${PAGE_TOKEN}`
+        );
+      });
+
+      it("should resolve with a project page if it succeeds with no next page token", async () => {
+        const pageSize = 10;
+        const projectCounts = 5;
+        const expectedProjectList = generateCloudProjectList(projectCounts);
+        apiRequestStub.onFirstCall().resolves({
+          body: { projectInfo: expectedProjectList },
+        });
+
+        const projectPage = await projectManager.getAvailableCloudProjectPage(pageSize);
+
+        expect(projectPage.projects).to.deep.equal(expectedProjectList);
+        expect(projectPage.nextPageToken).to.be.undefined;
+        expect(apiRequestStub).to.be.calledOnceWith(
+          "GET",
+          "/v1beta1/availableProjects?pageSize=10"
+        );
+      });
+
+      it("should reject if the api call fails", async () => {
+        const pageSize = 100;
+        const expectedError = new Error("HTTP Error 404: Not Found");
+        apiRequestStub.onFirstCall().rejects(expectedError);
+
+        let err;
+        try {
+          await projectManager.getAvailableCloudProjectPage(pageSize, PAGE_TOKEN);
+        } catch (e) {
+          err = e;
+        }
+
+        expect(err.message).to.equal(
+          "Failed to list available Google Cloud Platform projects. See firebase-debug.log for more info."
+        );
+        expect(err.original).to.equal(expectedError);
+        expect(apiRequestStub).to.be.calledOnceWith(
+          "GET",
+          `/v1beta1/availableProjects?pageSize=100&pageToken=${PAGE_TOKEN}`
+        );
+      });
+    });
+
+    describe("getFirebaseProjectPage", () => {
       it("should resolve with a project page if it succeeds (no input token)", async () => {
         const pageSize = 10;
-        const expectedProjectList = generateProjectList(pageSize);
+        const expectedProjectList = generateFirebaseProjectList(pageSize);
         apiRequestStub.onFirstCall().resolves({
           body: { results: expectedProjectList, nextPageToken: NEXT_PAGE_TOKEN },
         });
 
-        const projectPage = await projectManager.getProjectPage(pageSize);
+        const projectPage = await projectManager.getFirebaseProjectPage(pageSize);
 
         expect(projectPage.projects).to.deep.equal(expectedProjectList);
         expect(projectPage.nextPageToken).to.equal(NEXT_PAGE_TOKEN);
@@ -371,12 +523,12 @@ describe("Project management", () => {
 
       it("should resolve with a project page if it succeeds (with input token)", async () => {
         const pageSize = 10;
-        const expectedProjectList = generateProjectList(pageSize);
+        const expectedProjectList = generateFirebaseProjectList(pageSize);
         apiRequestStub.onFirstCall().resolves({
           body: { results: expectedProjectList, nextPageToken: NEXT_PAGE_TOKEN },
         });
 
-        const projectPage = await projectManager.getProjectPage(pageSize, PAGE_TOKEN);
+        const projectPage = await projectManager.getFirebaseProjectPage(pageSize, PAGE_TOKEN);
 
         expect(projectPage.projects).to.deep.equal(expectedProjectList);
         expect(projectPage.nextPageToken).to.equal(NEXT_PAGE_TOKEN);
@@ -389,12 +541,12 @@ describe("Project management", () => {
       it("should resolve with a project page if it succeeds with no next page token", async () => {
         const pageSize = 10;
         const projectCounts = 5;
-        const expectedProjectList = generateProjectList(projectCounts);
+        const expectedProjectList = generateFirebaseProjectList(projectCounts);
         apiRequestStub.onFirstCall().resolves({
           body: { results: expectedProjectList },
         });
 
-        const projectPage = await projectManager.getProjectPage(pageSize);
+        const projectPage = await projectManager.getFirebaseProjectPage(pageSize);
 
         expect(projectPage.projects).to.deep.equal(expectedProjectList);
         expect(projectPage.nextPageToken).to.be.undefined;
@@ -408,7 +560,7 @@ describe("Project management", () => {
 
         let err;
         try {
-          await projectManager.getProjectPage(pageSize, PAGE_TOKEN);
+          await projectManager.getFirebaseProjectPage(pageSize, PAGE_TOKEN);
         } catch (e) {
           err = e;
         }
@@ -427,7 +579,7 @@ describe("Project management", () => {
     describe("listFirebaseProjects", () => {
       it("should resolve with project list if it succeeds with only 1 api call", async () => {
         const projectCounts = 10;
-        const expectedProjectList = generateProjectList(projectCounts);
+        const expectedProjectList = generateFirebaseProjectList(projectCounts);
         apiRequestStub.onFirstCall().resolves({
           body: { results: expectedProjectList },
         });
@@ -446,7 +598,7 @@ describe("Project management", () => {
         const projectCounts = 10;
         const pageSize = 5;
         const nextPageToken = "next-page-token";
-        const expectedProjectList = generateProjectList(projectCounts);
+        const expectedProjectList = generateFirebaseProjectList(projectCounts);
         apiRequestStub
           .onFirstCall()
           .resolves({
@@ -491,7 +643,7 @@ describe("Project management", () => {
         const projectCounts = 10;
         const pageSize = 5;
         const nextPageToken = "next-page-token";
-        const expectedProjectList = generateProjectList(projectCounts);
+        const expectedProjectList = generateFirebaseProjectList(projectCounts);
         const expectedError = new Error("HTTP Error 400: unexpected error");
         apiRequestStub.onFirstCall().resolves({
           body: { results: expectedProjectList.slice(0, pageSize), nextPageToken },