diff --git a/src/commands/init.js b/src/commands/init.js index 9d15f472350..c596bec4e8a 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -156,18 +156,5 @@ module.exports = new Command("init [feature]") } logger.info(); utils.logSuccess("Firebase initialization complete!"); - - if (setup.createProject) { - logger.info(); - logger.info( - clc.bold.cyan("Project creation is only available from the Firebase Console") - ); - logger.info( - "Please visit", - clc.underline("https://console.firebase.google.com"), - "to create a new project, then run", - clc.bold("firebase use --add") - ); - } }); }); diff --git a/src/commands/projects-create.ts b/src/commands/projects-create.ts index 81ffed0445f..0ce4538e74e 100644 --- a/src/commands/projects-create.ts +++ b/src/commands/projects-create.ts @@ -1,46 +1,12 @@ -import * as clc from "cli-color"; -import * as ora from "ora"; - import * as Command from "../command"; import * as FirebaseError from "../error"; import { - addFirebaseToCloudProject, - createCloudProject, - ProjectParentResource, + createFirebaseProjectAndLog, ProjectParentResourceType, + PROJECTS_CREATE_QUESTIONS, } from "../management/projects"; import { prompt } from "../prompt"; import * as requireAuth from "../requireAuth"; -import * as logger from "../logger"; - -async function createFirebaseProject( - projectId: string, - options: { displayName?: string; parentResource?: ProjectParentResource } -): Promise { - let spinner = ora("Creating Google Cloud Platform project").start(); - try { - await createCloudProject(projectId, options); - spinner.succeed(); - - spinner = ora("Adding Firebase to Google Cloud project").start(); - const projectInfo = await addFirebaseToCloudProject(projectId); - spinner.succeed(); - - logger.info(""); - 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; - } catch (err) { - spinner.fail(); - throw err; - } -} module.exports = new Command("projects:create [projectId]") .description("create a new firebase project") @@ -64,22 +30,7 @@ module.exports = new Command("projects:create [projectId]") ); } if (!options.nonInteractive) { - await prompt(options, [ - { - type: "input", - name: "projectId", - default: "", - message: - "Please specify a unique project id " + - `(${clc.yellow("warning")}: cannot be modified afterward) [6-30 characters]:\n`, - }, - { - type: "input", - name: "displayName", - default: "", - message: "What would you like to call your project? (defaults to your project ID)", - }, - ]); + await prompt(options, PROJECTS_CREATE_QUESTIONS); } if (!options.projectId) { throw new FirebaseError("Project ID cannot be empty"); @@ -92,7 +43,7 @@ module.exports = new Command("projects:create [projectId]") parentResource = { type: ProjectParentResourceType.FOLDER, id: options.folder }; } - return createFirebaseProject(options.projectId, { + return createFirebaseProjectAndLog(options.projectId, { displayName: options.displayName, parentResource, }); diff --git a/src/init/features/project.ts b/src/init/features/project.ts index 28de5bcf6c1..017c0196e3e 100644 --- a/src/init/features/project.ts +++ b/src/init/features/project.ts @@ -4,12 +4,14 @@ import * as _ from "lodash"; import * as Config from "../../config"; import * as FirebaseError from "../../error"; import { + createFirebaseProjectAndLog, FirebaseProjectMetadata, getFirebaseProject, getProjectPage, + PROJECTS_CREATE_QUESTIONS, } from "../../management/projects"; import * as logger from "../../logger"; -import { promptOnce } from "../../prompt"; +import { prompt, promptOnce } from "../../prompt"; import * as utils from "../../utils"; const MAXIMUM_PROMPT_LIST = 100; @@ -98,6 +100,25 @@ async function selectProjectFromList( return toProjectInfo(project); } +async function promptAndCreateNewProject(): Promise { + utils.logBullet( + "If you want to create a project in a Google Cloud organization or folder, please use " + + `"firebase projects:create" instead, and return to this command when you've created the project.` + ); + + const promptAnswer: { projectId?: string; displayName?: string } = {}; + await prompt(promptAnswer, PROJECTS_CREATE_QUESTIONS); + if (!promptAnswer.projectId) { + throw new FirebaseError("Project ID cannot be empty"); + } + + return toProjectInfo( + await createFirebaseProjectAndLog(promptAnswer.projectId, { + displayName: promptAnswer.displayName, + }) + ); +} + function toProjectInfo(projectMetaData: FirebaseProjectMetadata): ProjectInfo { const { projectId, displayName, resources } = projectMetaData; return { @@ -131,7 +152,7 @@ export async function doSetup(setup: any, config: Config, options: any): Promise // we still need to get project info in case user wants to init firestore or storage, which // require a resource location: const rcProject: FirebaseProjectMetadata = await getFirebaseProject(projectFromRcFile); - setup.projectId = projectFromRcFile; + setup.projectId = rcProject.projectId; setup.projectLocation = _.get(rcProject, "resources.locationId"); return; } @@ -148,18 +169,21 @@ export async function doSetup(setup: any, config: Config, options: any): Promise choices, }); + let projectInfo; if (projectSetupOption === OPTION_USE_PROJECT) { - const projectInfo = await getProjectInfo(options); - utils.logBullet(`Using project ${projectInfo.label}`); - - // write "default" alias and activate it immediately - _.set(setup.rcfile, "projects.default", projectInfo.id); - setup.projectId = projectInfo.id; - setup.instance = projectInfo.instance; - setup.projectLocation = projectInfo.location; - utils.makeActiveProject(config.projectDir, projectInfo.id); + projectInfo = await getProjectInfo(options); } else if (projectSetupOption === OPTION_NEW_PROJECT) { - // TODO(caot): Implement create a new project - setup.createProject = true; + projectInfo = await promptAndCreateNewProject(); + } else { + // Do nothing if use choose NO_PROJECT + return; } + + utils.logBullet(`Using project ${projectInfo.label}`); + // write "default" alias and activate it immediately + _.set(setup.rcfile, "projects.default", projectInfo.id); + setup.projectId = projectInfo.id; + setup.instance = projectInfo.instance; + setup.projectLocation = projectInfo.location; + utils.makeActiveProject(config.projectDir, projectInfo.id); } diff --git a/src/management/projects.ts b/src/management/projects.ts index b6e58214a19..68cb9a0a5fd 100644 --- a/src/management/projects.ts +++ b/src/management/projects.ts @@ -1,7 +1,11 @@ import * as api from "../api"; +import * as clc from "cli-color"; +import * as ora from "ora"; + import * as FirebaseError from "../error"; import * as logger from "../logger"; import { pollOperation } from "../operation-poller"; +import { Question } from "inquirer"; const TIMEOUT_MILLIS = 30000; const PROJECT_LIST_PAGE_SIZE = 1000; @@ -36,6 +40,56 @@ export interface ProjectParentResource { type: ProjectParentResourceType; } +export const PROJECTS_CREATE_QUESTIONS: Question[] = [ + { + type: "input", + name: "projectId", + default: "", + message: + "Please specify a unique project id " + + `(${clc.yellow("warning")}: cannot be modified afterward) [6-30 characters]:\n`, + }, + { + type: "input", + name: "displayName", + default: "", + message: "What would you like to call your project? (defaults to your project ID)", + }, +]; + +export async function createFirebaseProjectAndLog( + projectId: string, + options: { displayName?: string; parentResource?: ProjectParentResource } +): Promise { + let spinner = ora("Creating Google Cloud Platform project").start(); + try { + await createCloudProject(projectId, options); + spinner.succeed(); + + spinner = ora("Adding Firebase to Google Cloud project").start(); + const projectInfo = await addFirebaseToCloudProject(projectId); + spinner.succeed(); + + 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; + } catch (err) { + spinner.fail(); + throw err; + } +} + /** * Send an API request to create a new Google Cloud Platform project and poll the LRO to get the * new project information. diff --git a/src/test/init/features/project.spec.ts b/src/test/init/features/project.spec.ts index e08e97ebb9a..bd6aaa083fb 100644 --- a/src/test/init/features/project.spec.ts +++ b/src/test/init/features/project.spec.ts @@ -38,12 +38,16 @@ describe("project", () => { const sandbox: sinon.SinonSandbox = sinon.createSandbox(); let getProjectPageStub: sinon.SinonStub; let getProjectStub: sinon.SinonStub; + let createFirebaseProjectStub: sinon.SinonStub; + let promptOnceStub: sinon.SinonStub; let promptStub: sinon.SinonStub; beforeEach(() => { getProjectPageStub = sandbox.stub(projectManager, "getProjectPage"); getProjectStub = sandbox.stub(projectManager, "getFirebaseProject"); - promptStub = sandbox.stub(prompt, "promptOnce"); + createFirebaseProjectStub = sandbox.stub(projectManager, "createFirebaseProjectAndLog"); + promptStub = sandbox.stub(prompt, "prompt").throws("Unexpected prompt call"); + promptOnceStub = sandbox.stub(prompt, "promptOnce").throws("Unexpected promptOnce call"); }); afterEach(() => { @@ -56,15 +60,15 @@ describe("project", () => { getProjectPageStub.resolves({ projects: [TEST_FIREBASE_PROJECT, ANOTHER_FIREBASE_PROJECT], }); - promptStub.returns("my-project-123"); + promptOnceStub.resolves("my-project-123"); const project = await getProjectInfo(options); expect(project).to.deep.equal(TEST_PROJECT_INFO); expect(getProjectPageStub).to.be.calledWith(100); expect(getProjectStub).to.be.not.called; - expect(promptStub).to.be.calledOnce; - expect(promptStub.firstCall.args[0].type).to.equal("list"); + 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 () => { @@ -74,21 +78,21 @@ describe("project", () => { nextPageToken: "token", }); getProjectStub.resolves(TEST_FIREBASE_PROJECT); - promptStub.returns("my-project-123"); + promptOnceStub.resolves("my-project-123"); const project = await getProjectInfo(options); expect(project).to.deep.equal(TEST_PROJECT_INFO); expect(getProjectPageStub).to.be.calledWith(100); expect(getProjectStub).to.be.calledWith("my-project-123"); - expect(promptStub).to.be.calledOnce; - expect(promptStub.firstCall.args[0].type).to.equal("input"); + expect(promptOnceStub).to.be.calledOnce; + expect(promptOnceStub.firstCall.args[0].type).to.equal("input"); }); it("should set instance and location to undefined when resources not provided", async () => { const options = {}; - getProjectPageStub.returns({ projects: [ANOTHER_FIREBASE_PROJECT] }); - promptStub.returns("another-project"); + getProjectPageStub.resolves({ projects: [ANOTHER_FIREBASE_PROJECT] }); + promptOnceStub.resolves("another-project"); const project = await getProjectInfo(options); @@ -100,19 +104,19 @@ describe("project", () => { }); expect(getProjectPageStub).to.be.calledWith(100); expect(getProjectStub).to.be.not.called; - expect(promptStub).to.be.calledOnce; - expect(promptStub.firstCall.args[0].type).to.equal("list"); + expect(promptOnceStub).to.be.calledOnce; + expect(promptOnceStub.firstCall.args[0].type).to.equal("list"); }); it("should get the correct project info when --project is supplied", async () => { const options = { project: "my-project-123" }; - getProjectStub.returns(TEST_FIREBASE_PROJECT); + getProjectStub.resolves(TEST_FIREBASE_PROJECT); const project = await getProjectInfo(options); expect(project).to.deep.equal(TEST_PROJECT_INFO); expect(getProjectStub).to.be.calledWith("my-project-123"); - expect(promptStub).to.be.not.called; + expect(promptOnceStub).to.be.not.called; }); it("should throw error when getFirebaseProject throw an error", async () => { @@ -129,58 +133,122 @@ describe("project", () => { expect(err).to.equal(expectedError); expect(getProjectStub).to.be.calledWith("my-project-123"); + expect(promptOnceStub).to.be.not.called; }); }); describe("doSetup", () => { - it("should set up the correct properties in the project", async () => { - const options = { project: "my-project" }; - const setup = { config: {}, rcfile: {} }; - promptStub.onFirstCall().returns("Use an existing project"); - getProjectStub.returns(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(promptStub).to.be.calledOnce; + describe('with "Use an existing project" option', () => { + it("should set up the correct properties in the project", async () => { + const options = { project: "my-project" }; + const setup = { config: {}, rcfile: {} }; + promptOnceStub.onFirstCall().resolves("Use an existing project"); + getProjectStub.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; + }); }); - it("should set up the correct properties when choosing new project", async () => { - const options = {}; - const setup = { config: {}, rcfile: {} }; - getProjectPageStub.returns({ projects: [TEST_FIREBASE_PROJECT, ANOTHER_FIREBASE_PROJECT] }); - promptStub.onFirstCall().returns("Create a new project"); - - await doSetup(setup, {}, options); + describe('with "Create a new project" option', () => { + it("should create a new project and set up the correct properties", async () => { + const options = {}; + const setup = { config: {}, rcfile: {} }; + promptOnceStub.onFirstCall().resolves("Create a new project"); + const fakePromptFn = (promptAnswer: any) => { + promptAnswer.projectId = "my-project-123"; + promptAnswer.displayName = "my-project"; + }; + promptStub + .withArgs({}, projectManager.PROJECTS_CREATE_QUESTIONS) + .onFirstCall() + .callsFake(fakePromptFn); + createFirebaseProjectStub.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(promptStub).to.be.calledOnce; + expect(createFirebaseProjectStub).to.be.calledOnceWith("my-project-123", { + displayName: "my-project", + }); + }); - expect(_.get(setup, "createProject")).to.deep.equal(true); - expect(promptStub).to.be.calledOnce; + it("should throw if project ID is empty after prompt", async () => { + const options = {}; + const setup = { config: {}, rcfile: {} }; + promptOnceStub.onFirstCall().resolves("Create a new project"); + const fakePromptFn = (promptAnswer: any) => { + promptAnswer.projectId = ""; + }; + promptStub + .withArgs({}, projectManager.PROJECTS_CREATE_QUESTIONS) + .onFirstCall() + .callsFake(fakePromptFn); + + 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(promptStub).to.be.calledOnce; + expect(createFirebaseProjectStub).to.be.not.called; + }); }); - it("should set up the correct properties when not choosing a project", async () => { - const options = {}; - const setup = { config: {}, rcfile: {} }; - getProjectPageStub.returns({ projects: [TEST_FIREBASE_PROJECT, ANOTHER_FIREBASE_PROJECT] }); - promptStub.returns("Don't set up a default project"); + 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 = {}; + const setup = { config: {}, rcfile: {} }; + getProjectPageStub.resolves({ + projects: [TEST_FIREBASE_PROJECT, ANOTHER_FIREBASE_PROJECT], + }); + promptOnceStub.resolves("Don't set up a default project"); - await doSetup(setup, {}, options); + await doSetup(setup, {}, options); - expect(setup).to.deep.equal({ config: {}, rcfile: {}, project: {} }); + expect(setup).to.deep.equal({ config: {}, rcfile: {}, project: {} }); + expect(promptOnceStub).to.be.calledOnce; + }); }); - it("should set project location even if .firebaserc is already set up", async () => { - const options = {}; - const setup = { config: {}, rcfile: { projects: { default: "my-project" } } }; - getProjectStub.returns(TEST_FIREBASE_PROJECT); + describe("with defined .firebaserc file", () => { + let options: any; + let setup: any; + + beforeEach(() => { + options = {}; + setup = { config: {}, rcfile: { projects: { default: "my-project-123" } } }; + getProjectStub.onFirstCall().resolves(TEST_FIREBASE_PROJECT); + }); - await doSetup(setup, {}, options); + it("should not prompt", async () => { + await doSetup(setup, {}, options); - expect(_.get(setup, "projectId")).to.equal("my-project"); - expect(_.get(setup, "projectLocation")).to.equal("us-central"); - expect(promptStub).to.be.not.called; + expect(promptOnceStub).to.be.not.called; + expect(promptStub).to.be.not.called; + }); + + it("should set project location even if .firebaserc is already set up", async () => { + await doSetup(setup, {}, options); + + expect(_.get(setup, "projectId")).to.equal("my-project-123"); + expect(_.get(setup, "projectLocation")).to.equal("us-central"); + expect(getProjectStub).to.be.calledOnceWith("my-project-123"); + }); }); }); });