diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5a145a6ee..fffdd1cf249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ - Fix blocking functions in the emulator when using multiple codebases (#6504). - Add force flag call-out for bypassing prompts (#6506). - Fixed an issue where the functions emulator did not respect the `--log-verbosity` flag (#2859). +- Add the ability to look for the default Hosting site via Hosting's API. +- Add logic to create a Hosting site when one is not available in a project. +- Add checks for the default Hosting site when one is assumed to exist. diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index ca9cfe3e083..063f5acadee 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -7,6 +7,11 @@ import { deploy } from "../deploy"; import { requireConfig } from "../requireConfig"; import { filterTargets } from "../filterTargets"; import { requireHostingSite } from "../requireHostingSite"; +import { errNoDefaultSite } from "../getDefaultHostingSite"; +import { FirebaseError } from "../error"; +import { bold } from "colorette"; +import { interactiveCreateHostingSite } from "../hosting/interactive"; +import { logBullet } from "../utils"; // in order of least time-consuming to most time-consuming export const VALID_DEPLOY_TARGETS = [ @@ -78,7 +83,26 @@ export const command = new Command("deploy") } if (options.filteredTargets.includes("hosting")) { - await requireHostingSite(options); + let createSite = false; + try { + await requireHostingSite(options); + } catch (err: unknown) { + if (err === errNoDefaultSite) { + createSite = true; + } + } + if (!createSite) { + return; + } + if (options.nonInteractive) { + throw new FirebaseError( + `Unable to deploy to Hosting as there is no Hosting site. Use ${bold( + "firebase hosting:sites:create" + )} to create a site.` + ); + } + logBullet("No Hosting site detected."); + await interactiveCreateHostingSite("", "", options); } }) .before(checkValidTargetFilters) diff --git a/src/commands/hosting-channel-create.ts b/src/commands/hosting-channel-create.ts index b8b5a7ba3b1..47e8a0a1d37 100644 --- a/src/commands/hosting-channel-create.ts +++ b/src/commands/hosting-channel-create.ts @@ -12,6 +12,7 @@ import { logger } from "../logger"; import { requireConfig } from "../requireConfig"; import { marked } from "marked"; import { requireHostingSite } from "../requireHostingSite"; +import { errNoDefaultSite } from "../getDefaultHostingSite"; const LOG_TAG = "hosting:channel"; @@ -24,7 +25,20 @@ export const command = new Command("hosting:channel:create [channelId]") .option("--site ", "site for which to create the channel") .before(requireConfig) .before(requirePermissions, ["firebasehosting.sites.update"]) - .before(requireHostingSite) + .before(async (options) => { + try { + await requireHostingSite(options); + } catch (err: unknown) { + if (err === errNoDefaultSite) { + throw new FirebaseError( + `Unable to deploy to Hosting as there is no Hosting site. Use ${bold( + "firebase hosting:sites:create" + )} to create a site.` + ); + } + throw err; + } + }) .action( async ( channelId: string, diff --git a/src/commands/hosting-sites-create.ts b/src/commands/hosting-sites-create.ts index e8dd96ca689..e4272ba8ea7 100644 --- a/src/commands/hosting-sites-create.ts +++ b/src/commands/hosting-sites-create.ts @@ -1,13 +1,14 @@ import { bold } from "colorette"; -import { logLabeledSuccess } from "../utils"; import { Command } from "../command"; -import { Site, createSite } from "../hosting/api"; -import { promptOnce } from "../prompt"; -import { FirebaseError } from "../error"; -import { requirePermissions } from "../requirePermissions"; -import { needProjectId } from "../projectUtils"; +import { interactiveCreateHostingSite } from "../hosting/interactive"; +import { last, logLabeledSuccess } from "../utils"; import { logger } from "../logger"; +import { needProjectId } from "../projectUtils"; +import { Options } from "../options"; +import { requirePermissions } from "../requirePermissions"; +import { Site } from "../hosting/api"; +import { FirebaseError } from "../error"; const LOG_TAG = "hosting:sites"; @@ -15,60 +16,29 @@ export const command = new Command("hosting:sites:create [siteId]") .description("create a Firebase Hosting site") .option("--app ", "specify an existing Firebase Web App ID") .before(requirePermissions, ["firebasehosting.sites.update"]) - .action( - async ( - siteId: string, - options: any // eslint-disable-line @typescript-eslint/no-explicit-any - ): Promise => { - const projectId = needProjectId(options); - const appId = options.app; - if (!siteId) { - if (options.nonInteractive) { - throw new FirebaseError( - `"siteId" argument must be provided in a non-interactive environment` - ); - } - siteId = await promptOnce( - { - type: "input", - message: "Please provide an unique, URL-friendly id for the site (.web.app):", - validate: (s) => s.length > 0, - } // Prevents an empty string from being submitted! - ); - } - if (!siteId) { - throw new FirebaseError(`"siteId" must not be empty`); - } + .action(async (siteId: string, options: Options & { app: string }): Promise => { + const projectId = needProjectId(options); + const appId = options.app; + + if (options.nonInteractive && !siteId) { + throw new FirebaseError(`${bold(siteId)} is required in a non-interactive environment`); + } - let site: Site; - try { - site = await createSite(projectId, siteId, appId); - } catch (e: any) { - if (e.status === 409) { - throw new FirebaseError( - `Site ${bold(siteId)} already exists in project ${bold(projectId)}.`, - { original: e } - ); - } - throw e; - } + const site = await interactiveCreateHostingSite(siteId, appId, options); + siteId = last(site.name.split("/")); - logger.info(); - logLabeledSuccess( - LOG_TAG, - `Site ${bold(siteId)} has been created in project ${bold(projectId)}.` - ); - if (appId) { - logLabeledSuccess( - LOG_TAG, - `Site ${bold(siteId)} has been linked to web app ${bold(appId)}` - ); - } - logLabeledSuccess(LOG_TAG, `Site URL: ${site.defaultUrl}`); - logger.info(); - logger.info( - `To deploy to this site, follow the guide at https://firebase.google.com/docs/hosting/multisites.` - ); - return site; + logger.info(); + logLabeledSuccess( + LOG_TAG, + `Site ${bold(siteId)} has been created in project ${bold(projectId)}.` + ); + if (appId) { + logLabeledSuccess(LOG_TAG, `Site ${bold(siteId)} has been linked to web app ${bold(appId)}`); } - ); + logLabeledSuccess(LOG_TAG, `Site URL: ${site.defaultUrl}`); + logger.info(); + logger.info( + `To deploy to this site, follow the guide at https://firebase.google.com/docs/hosting/multisites.` + ); + return site; + }); diff --git a/src/getDefaultHostingSite.ts b/src/getDefaultHostingSite.ts index cffc7b98f6a..5ad09d973be 100644 --- a/src/getDefaultHostingSite.ts +++ b/src/getDefaultHostingSite.ts @@ -1,6 +1,13 @@ +import { FirebaseError } from "./error"; +import { SiteType, listSites } from "./hosting/api"; import { logger } from "./logger"; import { getFirebaseProject } from "./management/projects"; import { needProjectId } from "./projectUtils"; +import { last } from "./utils"; + +export const errNoDefaultSite = new FirebaseError( + "Could not determine the default site for the project." +); /** * Tries to determine the default hosting site for a project, else falls back to projectId. @@ -10,12 +17,20 @@ import { needProjectId } from "./projectUtils"; export async function getDefaultHostingSite(options: any): Promise { const projectId = needProjectId(options); const project = await getFirebaseProject(projectId); - const site = project.resources?.hostingSite; + let site = project.resources?.hostingSite; if (!site) { - logger.debug( - `No default hosting site found for project: ${options.project}. Using projectId as hosting site name.` - ); - return options.project; + logger.debug(`the default site does not exist on the Firebase project; asking Hosting.`); + const sites = await listSites(projectId); + for (const s of sites) { + if (s.type === SiteType.DEFAULT_SITE) { + site = last(s.name.split("/")); + break; + } + } + if (!site) { + throw errNoDefaultSite; + } + return site; } return site; } diff --git a/src/hosting/api.ts b/src/hosting/api.ts index 4b921ff59aa..2299552f868 100644 --- a/src/hosting/api.ts +++ b/src/hosting/api.ts @@ -229,6 +229,19 @@ interface LongRunningOperation { readonly metadata: T | undefined; } +// The possible types of a site. +export enum SiteType { + // Unknown state, likely the result of an error on the backend. + TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED", + + // The default Hosting site that is provisioned when a Firebase project is + // created. + DEFAULT_SITE = "DEFAULT_SITE", + + // A Hosting site that the user created. + USER_SITE = "USER_SITE", +} + export type Site = { // Fully qualified name of the site. name: string; @@ -237,6 +250,8 @@ export type Site = { readonly appId: string; + readonly type?: SiteType; + labels: { [key: string]: string }; }; @@ -549,11 +564,20 @@ export async function getSite(project: string, site: string): Promise { * @param appId the Firebase Web App ID (https://firebase.google.com/docs/projects/learn-more#config-files-objects) * @return site information. */ -export async function createSite(project: string, site: string, appId = ""): Promise { +export async function createSite( + project: string, + site: string, + appId = "", + validateOnly = false +): Promise { + const queryParams: Record = { siteId: site }; + if (validateOnly) { + queryParams.validateOnly = "true"; + } const res = await apiClient.post<{ appId: string }, Site>( `/projects/${project}/sites`, { appId: appId }, - { queryParams: { siteId: site } } + { queryParams } ); return res.body; } diff --git a/src/hosting/interactive.ts b/src/hosting/interactive.ts new file mode 100644 index 00000000000..f48ff3c8ef2 --- /dev/null +++ b/src/hosting/interactive.ts @@ -0,0 +1,90 @@ +import { FirebaseError } from "../error"; +import { logWarning } from "../utils"; +import { needProjectId, needProjectNumber } from "../projectUtils"; +import { Options } from "../options"; +import { promptOnce } from "../prompt"; +import { Site, createSite } from "./api"; + +const nameSuggestion = new RegExp("try something like `(.+)`"); +// const prompt = "Please provide an unique, URL-friendly id for the site (.web.app):"; +const prompt = + "Please provide an unique, URL-friendly id for your site. Your site's URL will be .web.app. " + + 'We recommend using letters, numbers, and hyphens (e.g. "{project-id}-{random-hash}"):'; + +/** + * Interactively prompt to create a Hosting site. + */ +export async function interactiveCreateHostingSite( + siteId: string, + appId: string, + options: Options +): Promise { + const projectId = needProjectId(options); + const projectNumber = await needProjectNumber(options); + let id = siteId; + let newSite: Site | undefined; + let suggestion: string | undefined; + + // If we were given an ID, we're going to start with that, so don't check the project ID. + // If we weren't given an ID, let's _suggest_ the project ID as the site name (or a variant). + if (!id) { + const attempt = await trySiteID(projectNumber, projectId); + if (attempt.available) { + suggestion = projectId; + } else { + suggestion = attempt.suggestion; + } + } + + while (!newSite) { + if (!id || suggestion) { + id = await promptOnce({ + type: "input", + message: prompt, + validate: (s: string) => s.length > 0, // Prevents an empty string from being submitted! + default: suggestion, + }); + } + try { + newSite = await createSite(projectNumber, id, appId); + } catch (err: unknown) { + if (!(err instanceof FirebaseError)) { + throw err; + } + if (options.nonInteractive) { + throw err; + } + + suggestion = getSuggestionFromError(err); + } + } + return newSite; +} + +async function trySiteID( + projectNumber: string, + id: string +): Promise<{ available: boolean; suggestion?: string }> { + try { + await createSite(projectNumber, id, "", true); + return { available: true }; + } catch (err: unknown) { + if (!(err instanceof FirebaseError)) { + throw err; + } + const suggestion = getSuggestionFromError(err); + return { available: false, suggestion }; + } +} + +function getSuggestionFromError(err: FirebaseError): string | undefined { + if (err.status === 400 && err.message.includes("Invalid name:")) { + const i = err.message.indexOf("Invalid name:"); + logWarning(err.message.substring(i)); + const match = nameSuggestion.exec(err.message); + if (match) { + return match[1]; + } + } + return; +} diff --git a/src/init/features/hosting/index.ts b/src/init/features/hosting/index.ts index 66829400a03..f0dc6500f7b 100644 --- a/src/init/features/hosting/index.ts +++ b/src/init/features/hosting/index.ts @@ -1,6 +1,7 @@ import * as clc from "colorette"; import * as fs from "fs"; import { sync as rimraf } from "rimraf"; +import { join } from "path"; import { Client } from "../../../apiv2"; import { initGitHub } from "./github"; @@ -9,7 +10,10 @@ import { logger } from "../../../logger"; import { discover, WebFrameworks } from "../../../frameworks"; import { ALLOWED_SSR_REGIONS, DEFAULT_REGION } from "../../../frameworks/constants"; import * as experiments from "../../../experiments"; -import { join } from "path"; +import { errNoDefaultSite, getDefaultHostingSite } from "../../../getDefaultHostingSite"; +import { Options } from "../../../options"; +import { last, logSuccess } from "../../../utils"; +import { interactiveCreateHostingSite } from "../../../hosting/interactive"; const INDEX_TEMPLATE = fs.readFileSync( __dirname + "/../../../../templates/init/hosting/index.html", @@ -22,11 +26,35 @@ const MISSING_TEMPLATE = fs.readFileSync( const DEFAULT_IGNORES = ["firebase.json", "**/.*", "**/node_modules/**"]; /** - * + * Does the setup steps for Firebase Hosting. */ -export async function doSetup(setup: any, config: any): Promise { +export async function doSetup(setup: any, config: any, options: Options): Promise { setup.hosting = {}; + let hasHostingSite = true; + try { + await getDefaultHostingSite(options); + } catch (err: unknown) { + if (err !== errNoDefaultSite) { + throw err; + } + hasHostingSite = false; + } + + if (!hasHostingSite) { + const confirmCreate = await promptOnce({ + type: "confirm", + message: "A Firebase Hosting site is required to deploy. Would you like to create one now?", + default: true, + }); + if (confirmCreate) { + const newSite = await interactiveCreateHostingSite("", "", options); + logger.info(); + logSuccess(`Firebase Hosting site ${last(newSite.name.split("/"))} created!`); + logger.info(); + } + } + let discoveredFramework = experiments.isEnabled("webframeworks") ? await discover(config.projectDir, false) : undefined;