From 93747c07197f7a79a1cc6c82d81eca477732be39 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Fri, 3 Nov 2023 13:46:15 -0700 Subject: [PATCH 01/11] adds a check for a hosting site to exist in hosting init --- src/experiments.ts | 4 ++ src/getDefaultHostingSite.ts | 25 +++++++++++- src/hosting/api.ts | 17 +++++++- src/init/features/hosting/index.ts | 62 ++++++++++++++++++++++++++++-- 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/experiments.ts b/src/experiments.ts index c5f96a03757..920c8010a07 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -57,6 +57,10 @@ export const ALL_EXPERIMENTS = experiments({ public: true, }, + deferredhosting: { + shortDescription: "Causes errors to surface if a default Hosting site does not exist.", + }, + // Emulator experiments emulatoruisnapshot: { shortDescription: "Load pre-release versions of the emulator UI", diff --git a/src/getDefaultHostingSite.ts b/src/getDefaultHostingSite.ts index cffc7b98f6a..5b225e6340f 100644 --- a/src/getDefaultHostingSite.ts +++ b/src/getDefaultHostingSite.ts @@ -1,6 +1,14 @@ +import { FirebaseError } from "./error"; +import { isEnabled } from "./experiments"; +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,8 +18,23 @@ 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) { + if (isEnabled("deferredhosting")) { + 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; + } + logger.debug( `No default hosting site found for project: ${options.project}. Using projectId as hosting site name.` ); diff --git a/src/hosting/api.ts b/src/hosting/api.ts index 4b921ff59aa..59c5bf4c807 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 }; }; @@ -547,7 +562,7 @@ export async function getSite(project: string, site: string): Promise { * @param project project name or number. * @param site the site name to create. * @param appId the Firebase Web App ID (https://firebase.google.com/docs/projects/learn-more#config-files-objects) - * @return site information. + * @return site information. */ export async function createSite(project: string, site: string, appId = ""): Promise { const res = await apiClient.post<{ appId: string }, Site>( diff --git a/src/init/features/hosting/index.ts b/src/init/features/hosting/index.ts index 66829400a03..611ab44008f 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,12 @@ 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, logWarning } from "../../../utils"; +import { Site, createSite } from "../../../hosting/api"; +import { needProjectNumber } from "../../../projectUtils"; +import { FirebaseError } from "../../../error"; const INDEX_TEMPLATE = fs.readFileSync( __dirname + "/../../../../templates/init/hosting/index.html", @@ -21,12 +27,62 @@ const MISSING_TEMPLATE = fs.readFileSync( ); const DEFAULT_IGNORES = ["firebase.json", "**/.*", "**/node_modules/**"]; +const nameSuggestion = new RegExp("try something like `(.+)`"); + /** - * + * 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 = {}; + try { + await getDefaultHostingSite(options); + } catch (err: unknown) { + if (err !== errNoDefaultSite) { + throw err; + } + const confirmCreate = await promptOnce({ + type: "confirm", + message: + "A Firebase Hosting site is required to deploy to. Would you like to create one now?", + default: true, + }); + if (confirmCreate) { + const projectNumber = await needProjectNumber(options); + let newSite: Site | undefined; + let suggestion: string | undefined; + while (!newSite) { + const siteId = await promptOnce({ + type: "input", + message: "Please provide an unique, URL-friendly id for the site (.web.app):", + // TODO: bkendall@ - it should be possible to use validate_only to check the availability of the site ID. + validate: (s: string) => s.length > 0, // Prevents an empty string from being submitted! + default: suggestion, + }); + try { + newSite = await createSite(projectNumber, siteId); + } catch (err: unknown) { + if (err instanceof FirebaseError) { + 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) { + suggestion = match[1]; + } + } + } else { + throw err; + } + } + } + logger.info(); + logSuccess(`Firebase Hosting site ${last(newSite.name.split("/"))} created!`); + logger.info(); + } + return; + } + let discoveredFramework = experiments.isEnabled("webframeworks") ? await discover(config.projectDir, false) : undefined; From a294003157618f3ced299e3d3128522bda2712a7 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Fri, 3 Nov 2023 15:18:26 -0700 Subject: [PATCH 02/11] formatting is hard --- src/hosting/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hosting/api.ts b/src/hosting/api.ts index 59c5bf4c807..fa9bc7fe27f 100644 --- a/src/hosting/api.ts +++ b/src/hosting/api.ts @@ -562,7 +562,7 @@ export async function getSite(project: string, site: string): Promise { * @param project project name or number. * @param site the site name to create. * @param appId the Firebase Web App ID (https://firebase.google.com/docs/projects/learn-more#config-files-objects) - * @return site information. + * @return site information. */ export async function createSite(project: string, site: string, appId = ""): Promise { const res = await apiClient.post<{ appId: string }, Site>( From 10c03dec40bcc502d741e3c8a3f62997f3115d5c Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Fri, 3 Nov 2023 15:47:39 -0700 Subject: [PATCH 03/11] sites:create now will better prompt for a site name if one isn't provided --- src/commands/hosting-sites-create.ts | 85 ++++++++-------------------- src/hosting/interactive.ts | 51 +++++++++++++++++ src/init/features/hosting/index.ts | 45 +++------------ src/requireHostingSite.ts | 18 +++++- 4 files changed, 100 insertions(+), 99 deletions(-) create mode 100644 src/hosting/interactive.ts diff --git a/src/commands/hosting-sites-create.ts b/src/commands/hosting-sites-create.ts index e8dd96ca689..28f51f9076c 100644 --- a/src/commands/hosting-sites-create.ts +++ b/src/commands/hosting-sites-create.ts @@ -1,13 +1,13 @@ 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"; const LOG_TAG = "hosting:sites"; @@ -15,60 +15,25 @@ 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; - 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/hosting/interactive.ts b/src/hosting/interactive.ts new file mode 100644 index 00000000000..c9569ca0246 --- /dev/null +++ b/src/hosting/interactive.ts @@ -0,0 +1,51 @@ +import { FirebaseError } from "../error"; +import { logWarning } from "../utils"; +import { needProjectNumber } from "../projectUtils"; +import { Options } from "../options"; +import { promptOnce } from "../prompt"; +import { Site, createSite } from "./api"; + +/** + * Interactively prompt to create a Hosting site. + */ +export async function interactiveCreateHostingSite( + siteId: string, + appId: string, + options: Options +): Promise { + const nameSuggestion = new RegExp("try something like `(.+)`"); + console.error("HELLO NEW FLOW"); + + const projectNumber = await needProjectNumber(options); + let id = siteId; + let newSite: Site | undefined; + let suggestion: string | undefined; + while (!newSite) { + if (!id || suggestion) { + id = await promptOnce({ + type: "input", + message: "Please provide an unique, URL-friendly id for the site (.web.app):", + // TODO: bkendall@ - it should be possible to use validate_only to check the availability of the site ID. + 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) { + 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) { + suggestion = match[1]; + } + } + } else { + throw err; + } + } + } + return newSite; +} diff --git a/src/init/features/hosting/index.ts b/src/init/features/hosting/index.ts index 611ab44008f..355f6acbcb2 100644 --- a/src/init/features/hosting/index.ts +++ b/src/init/features/hosting/index.ts @@ -12,10 +12,8 @@ import { ALLOWED_SSR_REGIONS, DEFAULT_REGION } from "../../../frameworks/constan import * as experiments from "../../../experiments"; import { errNoDefaultSite, getDefaultHostingSite } from "../../../getDefaultHostingSite"; import { Options } from "../../../options"; -import { last, logSuccess, logWarning } from "../../../utils"; -import { Site, createSite } from "../../../hosting/api"; -import { needProjectNumber } from "../../../projectUtils"; -import { FirebaseError } from "../../../error"; +import { last, logSuccess } from "../../../utils"; +import { interactiveCreateHostingSite } from "../../../hosting/interactive"; const INDEX_TEMPLATE = fs.readFileSync( __dirname + "/../../../../templates/init/hosting/index.html", @@ -27,55 +25,30 @@ const MISSING_TEMPLATE = fs.readFileSync( ); const DEFAULT_IGNORES = ["firebase.json", "**/.*", "**/node_modules/**"]; -const nameSuggestion = new RegExp("try something like `(.+)`"); - /** * Does the setup steps for Firebase Hosting. */ 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 to. Would you like to create one now?", + message: "A Firebase Hosting site is required to deploy. Would you like to create one now?", default: true, }); if (confirmCreate) { - const projectNumber = await needProjectNumber(options); - let newSite: Site | undefined; - let suggestion: string | undefined; - while (!newSite) { - const siteId = await promptOnce({ - type: "input", - message: "Please provide an unique, URL-friendly id for the site (.web.app):", - // TODO: bkendall@ - it should be possible to use validate_only to check the availability of the site ID. - validate: (s: string) => s.length > 0, // Prevents an empty string from being submitted! - default: suggestion, - }); - try { - newSite = await createSite(projectNumber, siteId); - } catch (err: unknown) { - if (err instanceof FirebaseError) { - 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) { - suggestion = match[1]; - } - } - } else { - throw err; - } - } - } + const newSite = await interactiveCreateHostingSite("", "", options); logger.info(); logSuccess(`Firebase Hosting site ${last(newSite.name.split("/"))} created!`); logger.info(); diff --git a/src/requireHostingSite.ts b/src/requireHostingSite.ts index aa12bbd69d9..ed459d91607 100644 --- a/src/requireHostingSite.ts +++ b/src/requireHostingSite.ts @@ -1,4 +1,6 @@ -import { getDefaultHostingSite } from "./getDefaultHostingSite"; +import { bold } from "colorette"; +import { FirebaseError } from "./error"; +import { errNoDefaultSite, getDefaultHostingSite } from "./getDefaultHostingSite"; /** * Ensure that a hosting site is set, fetching it from defaultHostingSite if not already present. @@ -9,6 +11,16 @@ export async function requireHostingSite(options: any) { return Promise.resolve(); } - const site = await getDefaultHostingSite(options); - options.site = site; + try { + const site = await getDefaultHostingSite(options); + options.site = site; + } catch (err: unknown) { + if (err === errNoDefaultSite) { + throw new FirebaseError( + `Unable to create a channel as there is no Hosting site. Use ${bold( + "firebase hosting:sites:create" + )} to create a site.` + ); + } + } } From 0db366bc309ddc08f52dc85522b192755dd0d51c Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Fri, 3 Nov 2023 15:52:20 -0700 Subject: [PATCH 04/11] fix non-interactive flow --- src/commands/hosting-sites-create.ts | 5 +++++ src/hosting/interactive.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/commands/hosting-sites-create.ts b/src/commands/hosting-sites-create.ts index 28f51f9076c..e4272ba8ea7 100644 --- a/src/commands/hosting-sites-create.ts +++ b/src/commands/hosting-sites-create.ts @@ -8,6 +8,7 @@ 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"; @@ -19,6 +20,10 @@ export const command = new Command("hosting:sites:create [siteId]") const projectId = needProjectId(options); const appId = options.app; + if (options.nonInteractive && !siteId) { + throw new FirebaseError(`${bold(siteId)} is required in a non-interactive environment`); + } + const site = await interactiveCreateHostingSite(siteId, appId, options); siteId = last(site.name.split("/")); diff --git a/src/hosting/interactive.ts b/src/hosting/interactive.ts index c9569ca0246..91c82c93797 100644 --- a/src/hosting/interactive.ts +++ b/src/hosting/interactive.ts @@ -14,7 +14,6 @@ export async function interactiveCreateHostingSite( options: Options ): Promise { const nameSuggestion = new RegExp("try something like `(.+)`"); - console.error("HELLO NEW FLOW"); const projectNumber = await needProjectNumber(options); let id = siteId; @@ -35,6 +34,9 @@ export async function interactiveCreateHostingSite( } catch (err: unknown) { if (err instanceof FirebaseError) { if (err.status === 400 && err.message.includes("Invalid name:")) { + if (options.nonInteractive) { + throw err; + } const i = err.message.indexOf("Invalid name:"); logWarning(err.message.substring(i)); const match = nameSuggestion.exec(err.message); From 5f280cc9091574edafae990283d1306e7d024fa4 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Fri, 3 Nov 2023 15:55:04 -0700 Subject: [PATCH 05/11] better organize catch block --- src/hosting/interactive.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/hosting/interactive.ts b/src/hosting/interactive.ts index 91c82c93797..a2d3d8a927c 100644 --- a/src/hosting/interactive.ts +++ b/src/hosting/interactive.ts @@ -32,21 +32,21 @@ export async function interactiveCreateHostingSite( try { newSite = await createSite(projectNumber, id, appId); } catch (err: unknown) { - if (err instanceof FirebaseError) { - if (err.status === 400 && err.message.includes("Invalid name:")) { - if (options.nonInteractive) { - throw err; - } - const i = err.message.indexOf("Invalid name:"); - logWarning(err.message.substring(i)); - const match = nameSuggestion.exec(err.message); - if (match) { - suggestion = match[1]; - } - } - } else { + if (!(err instanceof FirebaseError)) { throw err; } + + if (err.status === 400 && err.message.includes("Invalid name:")) { + if (options.nonInteractive) { + throw err; + } + const i = err.message.indexOf("Invalid name:"); + logWarning(err.message.substring(i)); + const match = nameSuggestion.exec(err.message); + if (match) { + suggestion = match[1]; + } + } } } return newSite; From 81cf0ba24b34c61cfc7ab91fe73e65fe0fdebcbd Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Fri, 3 Nov 2023 16:07:26 -0700 Subject: [PATCH 06/11] protect deploy and channel:deploy with errors --- src/commands/deploy.ts | 26 +++++++++++++++++++++++++- src/commands/hosting-channel-create.ts | 16 +++++++++++++++- src/init/features/hosting/index.ts | 1 - src/requireHostingSite.ts | 18 +++--------------- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index c88df3058b4..a23935cbc6f 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, logLabeledBullet } 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/init/features/hosting/index.ts b/src/init/features/hosting/index.ts index 355f6acbcb2..f0dc6500f7b 100644 --- a/src/init/features/hosting/index.ts +++ b/src/init/features/hosting/index.ts @@ -53,7 +53,6 @@ export async function doSetup(setup: any, config: any, options: Options): Promis logSuccess(`Firebase Hosting site ${last(newSite.name.split("/"))} created!`); logger.info(); } - return; } let discoveredFramework = experiments.isEnabled("webframeworks") diff --git a/src/requireHostingSite.ts b/src/requireHostingSite.ts index ed459d91607..aa12bbd69d9 100644 --- a/src/requireHostingSite.ts +++ b/src/requireHostingSite.ts @@ -1,6 +1,4 @@ -import { bold } from "colorette"; -import { FirebaseError } from "./error"; -import { errNoDefaultSite, getDefaultHostingSite } from "./getDefaultHostingSite"; +import { getDefaultHostingSite } from "./getDefaultHostingSite"; /** * Ensure that a hosting site is set, fetching it from defaultHostingSite if not already present. @@ -11,16 +9,6 @@ export async function requireHostingSite(options: any) { return Promise.resolve(); } - try { - const site = await getDefaultHostingSite(options); - options.site = site; - } catch (err: unknown) { - if (err === errNoDefaultSite) { - throw new FirebaseError( - `Unable to create a channel as there is no Hosting site. Use ${bold( - "firebase hosting:sites:create" - )} to create a site.` - ); - } - } + const site = await getDefaultHostingSite(options); + options.site = site; } From 25ffb6d6e3edfb7e36ad5a3756428783db489351 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Fri, 3 Nov 2023 16:11:45 -0700 Subject: [PATCH 07/11] linting is hard --- src/commands/deploy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index a23935cbc6f..31f38bb4307 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -11,7 +11,7 @@ import { errNoDefaultSite } from "../getDefaultHostingSite"; import { FirebaseError } from "../error"; import { bold } from "colorette"; import { interactiveCreateHostingSite } from "../hosting/interactive"; -import { logBullet, logLabeledBullet } from "../utils"; +import { logBullet } from "../utils"; // in order of least time-consuming to most time-consuming export const VALID_DEPLOY_TARGETS = [ From 96cfcb0f4106a9a52b233f57c6e341d3ac640cd7 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Thu, 9 Nov 2023 10:17:00 -0800 Subject: [PATCH 08/11] always make good and valid suggestions for site creation --- src/hosting/api.ts | 13 +++++-- src/hosting/interactive.ts | 69 +++++++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/hosting/api.ts b/src/hosting/api.ts index fa9bc7fe27f..2299552f868 100644 --- a/src/hosting/api.ts +++ b/src/hosting/api.ts @@ -564,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 index a2d3d8a927c..20adf37623e 100644 --- a/src/hosting/interactive.ts +++ b/src/hosting/interactive.ts @@ -1,10 +1,16 @@ import { FirebaseError } from "../error"; import { logWarning } from "../utils"; -import { needProjectNumber } from "../projectUtils"; +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. */ @@ -13,18 +19,28 @@ export async function interactiveCreateHostingSite( appId: string, options: Options ): Promise { - const nameSuggestion = new RegExp("try something like `(.+)`"); - + 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: "Please provide an unique, URL-friendly id for the site (.web.app):", - // TODO: bkendall@ - it should be possible to use validate_only to check the availability of the site ID. + message: prompt, validate: (s: string) => s.length > 0, // Prevents an empty string from being submitted! default: suggestion, }); @@ -35,19 +51,40 @@ export async function interactiveCreateHostingSite( if (!(err instanceof FirebaseError)) { throw err; } - - if (err.status === 400 && err.message.includes("Invalid name:")) { - if (options.nonInteractive) { - throw err; - } - const i = err.message.indexOf("Invalid name:"); - logWarning(err.message.substring(i)); - const match = nameSuggestion.exec(err.message); - if (match) { - suggestion = match[1]; - } + 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; +} \ No newline at end of file From 79c65b5720e9cec8ebe0cb57b4366ad0f982bf45 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Thu, 9 Nov 2023 10:22:41 -0800 Subject: [PATCH 09/11] formatting is hard --- src/hosting/interactive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hosting/interactive.ts b/src/hosting/interactive.ts index 20adf37623e..f48ff3c8ef2 100644 --- a/src/hosting/interactive.ts +++ b/src/hosting/interactive.ts @@ -87,4 +87,4 @@ function getSuggestionFromError(err: FirebaseError): string | undefined { } } return; -} \ No newline at end of file +} From 78fe2e2eace3b09342b793d483c05308fe7252f0 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Tue, 14 Nov 2023 09:02:15 -0800 Subject: [PATCH 10/11] rm experiment --- src/experiments.ts | 4 ---- src/getDefaultHostingSite.ts | 28 ++++++++++------------------ 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/experiments.ts b/src/experiments.ts index 920c8010a07..c5f96a03757 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -57,10 +57,6 @@ export const ALL_EXPERIMENTS = experiments({ public: true, }, - deferredhosting: { - shortDescription: "Causes errors to surface if a default Hosting site does not exist.", - }, - // Emulator experiments emulatoruisnapshot: { shortDescription: "Load pre-release versions of the emulator UI", diff --git a/src/getDefaultHostingSite.ts b/src/getDefaultHostingSite.ts index 5b225e6340f..5ad09d973be 100644 --- a/src/getDefaultHostingSite.ts +++ b/src/getDefaultHostingSite.ts @@ -1,5 +1,4 @@ import { FirebaseError } from "./error"; -import { isEnabled } from "./experiments"; import { SiteType, listSites } from "./hosting/api"; import { logger } from "./logger"; import { getFirebaseProject } from "./management/projects"; @@ -20,25 +19,18 @@ export async function getDefaultHostingSite(options: any): Promise { const project = await getFirebaseProject(projectId); let site = project.resources?.hostingSite; if (!site) { - if (isEnabled("deferredhosting")) { - 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; - } + 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; } - - logger.debug( - `No default hosting site found for project: ${options.project}. Using projectId as hosting site name.` - ); - return options.project; + if (!site) { + throw errNoDefaultSite; + } + return site; } return site; } From ea0c45aa962c5c0ec99877e84b90fd7b94c11fc4 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Tue, 14 Nov 2023 11:22:32 -0800 Subject: [PATCH 11/11] add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e071ec93eb6..e3353b03d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,5 @@ - Fix blocking functions in the emulator when using multiple codebases (#6504). - Add force flag call-out for bypassing prompts (#6506). +- 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.