diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 5b3e036138b..53fe96092e9 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -10,8 +10,10 @@ import { requireHostingSite } from "../requireHostingSite"; import { errNoDefaultSite } from "../getDefaultHostingSite"; import { FirebaseError } from "../error"; import { bold } from "colorette"; -import { interactiveCreateHostingSite } from "../hosting/interactive"; +import { pickHostingSiteName } from "../hosting/interactive"; import { logBullet } from "../utils"; +import { createSite } from "../hosting/api"; +import { Options } from "../options"; // in order of least time-consuming to most time-consuming export const VALID_DEPLOY_TARGETS = [ @@ -98,26 +100,26 @@ export const command = new Command("deploy") "In order to provide better validation, this may still enable APIs on the target project", ) .before(requireConfig) - .before((options) => { + .before((options: Options) => { options.filteredTargets = filterTargets(options, VALID_DEPLOY_TARGETS); const permissions = options.filteredTargets.reduce((perms: string[], target: string) => { return perms.concat(TARGET_PERMISSIONS[target]); }, []); return requirePermissions(options, permissions); }) - .before((options) => { + .before((options: Options) => { if (options.filteredTargets.includes("functions")) { - return checkServiceAccountIam(options.project); + return checkServiceAccountIam(options.project!); } }) - .before(async (options) => { + .before(async (options: Options) => { // only fetch the default instance for hosting or database deploys if (options.filteredTargets.includes("database")) { await requireDatabaseInstance(options); } if (options.filteredTargets.includes("hosting")) { - let createSite = false; + let shouldCreateSite = false; try { await requireHostingSite(options); } catch (err: unknown) { @@ -128,10 +130,10 @@ export const command = new Command("deploy") if (isPermissionError) { throw err; } else if (err === errNoDefaultSite) { - createSite = true; + shouldCreateSite = true; } } - if (!createSite) { + if (!shouldCreateSite) { return; } if (options.nonInteractive) { @@ -142,7 +144,8 @@ export const command = new Command("deploy") ); } logBullet("No Hosting site detected."); - await interactiveCreateHostingSite("", "", options); + const siteId = await pickHostingSiteName("", options); + await createSite(options.project!, siteId); } }) .before(checkValidTargetFilters) diff --git a/src/commands/hosting-sites-create.ts b/src/commands/hosting-sites-create.ts index e2e5bfc9f8f..2def56e8ed3 100644 --- a/src/commands/hosting-sites-create.ts +++ b/src/commands/hosting-sites-create.ts @@ -1,13 +1,13 @@ import { bold } from "colorette"; import { Command } from "../command"; -import { interactiveCreateHostingSite } from "../hosting/interactive"; -import { last, logLabeledSuccess } from "../utils"; +import { pickHostingSiteName } from "../hosting/interactive"; +import { 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 { createSite, Site } from "../hosting/api"; import { FirebaseError } from "../error"; const LOG_TAG = "hosting:sites"; @@ -16,16 +16,16 @@ 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: Options & { app: string }): Promise => { + .action(async (siteId: string | undefined, 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`); + throw new FirebaseError(`${bold("siteId")} is required in a non-interactive environment`); } - const site = await interactiveCreateHostingSite(siteId, appId, options); - siteId = last(site.name.split("/")); + siteId = await pickHostingSiteName(siteId ?? "", options); + const site = await createSite(projectId, siteId, appId); logger.info(); logLabeledSuccess( diff --git a/src/frameworks/angular/index.ts b/src/frameworks/angular/index.ts index b2528a8e3ee..9921119f7d0 100644 --- a/src/frameworks/angular/index.ts +++ b/src/frameworks/angular/index.ts @@ -47,7 +47,7 @@ export async function discover(dir: string): Promise { export function init(setup: any, config: any) { execSync( - `npx --yes -p @angular/cli@"${supportedRange}" ng new ${setup.projectId} --directory ${setup.hosting.source} --skip-git`, + `npx --yes -p @angular/cli@"${supportedRange}" ng new ${setup.projectId} --directory ${setup.featureInfo.hosting.source} --skip-git`, { stdio: "inherit", cwd: config.projectDir, diff --git a/src/frameworks/flutter/index.spec.ts b/src/frameworks/flutter/index.spec.ts index 5214866315c..0af6107dd8c 100644 --- a/src/frameworks/flutter/index.spec.ts +++ b/src/frameworks/flutter/index.spec.ts @@ -139,7 +139,7 @@ describe("Flutter", () => { const stub = sandbox.stub(crossSpawn, "sync").returns(process as any); - const result = init({ projectId, hosting: { source } }, { projectDir }); + const result = init({ projectId, featureInfo: { hosting: { source } } }, { projectDir }); expect(await result).to.eql(undefined); sinon.assert.calledWith( diff --git a/src/frameworks/flutter/index.ts b/src/frameworks/flutter/index.ts index 3ab873842e2..2f011eb780a 100644 --- a/src/frameworks/flutter/index.ts +++ b/src/frameworks/flutter/index.ts @@ -36,7 +36,7 @@ export function init(setup: any, config: any) { `--project-name=${projectName}`, "--overwrite", "--platforms=web", - setup.hosting.source, + setup.featureInfo.hosting.source, ], { stdio: "inherit", cwd: config.projectDir }, ); diff --git a/src/frameworks/next/index.ts b/src/frameworks/next/index.ts index b1becc11ded..b381fbba157 100644 --- a/src/frameworks/next/index.ts +++ b/src/frameworks/next/index.ts @@ -387,7 +387,7 @@ export async function init(setup: any, config: any) { }); execSync( `npx --yes create-next-app@"${supportedRange}" -e hello-world ` + - `${setup.hosting.source} --use-npm --${language}`, + `${setup.featureInfo.hosting.source} --use-npm --${language}`, { stdio: "inherit", cwd: config.projectDir }, ); } diff --git a/src/frameworks/nuxt/index.ts b/src/frameworks/nuxt/index.ts index b478b365701..887de68354b 100644 --- a/src/frameworks/nuxt/index.ts +++ b/src/frameworks/nuxt/index.ts @@ -116,7 +116,7 @@ export async function getConfig(cwd: string): Promise { * Utility method used during project initialization. */ export function init(setup: any, config: any) { - execSync(`npx --yes nuxi@"${supportedRange}" init ${setup.hosting.source}`, { + execSync(`npx --yes nuxi@"${supportedRange}" init ${setup.featureInfo.hosting.source}`, { stdio: "inherit", cwd: config.projectDir, }); diff --git a/src/frameworks/vite/index.ts b/src/frameworks/vite/index.ts index f99d2ae968a..509b1a9744f 100644 --- a/src/frameworks/vite/index.ts +++ b/src/frameworks/vite/index.ts @@ -34,13 +34,16 @@ export async function init(setup: any, config: any, baseTemplate: string = "vani ], }); execSync( - `npm create vite@"${supportedRange}" ${setup.hosting.source} --yes -- --template ${template}`, + `npm create vite@"${supportedRange}" ${setup.featureInfo.hosting.source} --yes -- --template ${template}`, { stdio: "inherit", cwd: config.projectDir, }, ); - execSync(`npm install`, { stdio: "inherit", cwd: join(config.projectDir, setup.hosting.source) }); + execSync(`npm install`, { + stdio: "inherit", + cwd: join(config.projectDir, setup.featureInfo.hosting.source), + }); } export const viteDiscoverWithNpmDependency = (dep: string) => async (dir: string) => diff --git a/src/hosting/interactive.ts b/src/hosting/interactive.ts index a141088fa23..50c507b6182 100644 --- a/src/hosting/interactive.ts +++ b/src/hosting/interactive.ts @@ -1,7 +1,7 @@ import { FirebaseError } from "../error"; import { logWarning } from "../utils"; import { needProjectId, needProjectNumber } from "../projectUtils"; -import { Site, createSite } from "./api"; +import { createSite } from "./api"; import { input } from "../prompt"; const nameSuggestion = new RegExp("try something like `(.+)`"); @@ -11,17 +11,16 @@ const prompt = 'We recommend using letters, numbers, and hyphens (e.g. "{project-id}-{random-hash}"):'; /** - * Interactively prompt to create a Hosting site. + * Interactively prompt to name a Hosting site. */ -export async function interactiveCreateHostingSite( +export async function pickHostingSiteName( siteId: string, - appId: string, options: { projectId?: string; nonInteractive?: boolean }, -): Promise { +): Promise { const projectId = needProjectId(options); const projectNumber = await needProjectNumber(options); let id = siteId; - let newSite: Site | undefined; + let nameConfirmed: boolean = false; let suggestion: string | undefined; // If we were given an ID, we're going to start with that, so don't check the project ID. @@ -35,7 +34,7 @@ export async function interactiveCreateHostingSite( } } - while (!newSite) { + while (!nameConfirmed) { if (!id || suggestion) { id = await input({ message: prompt, @@ -43,26 +42,18 @@ export async function interactiveCreateHostingSite( default: suggestion, }); } - try { - newSite = await createSite(projectNumber, id, appId); - } catch (err: unknown) { - if (!(err instanceof FirebaseError)) { - throw err; - } - if (options.nonInteractive) { - throw err; - } - - id = ""; // Clear so the prompt comes back. - suggestion = getSuggestionFromError(err); - } + const attempt = await trySiteID(projectNumber, id, options.nonInteractive); + nameConfirmed = attempt.available; + suggestion = attempt.suggestion; + if (!nameConfirmed) id = ""; // Clear so the prompt comes back. } - return newSite; + return id; } async function trySiteID( projectNumber: string, id: string, + nonInteractive = false, ): Promise<{ available: boolean; suggestion?: string }> { try { await createSite(projectNumber, id, "", true); @@ -71,6 +62,9 @@ async function trySiteID( if (!(err instanceof FirebaseError)) { throw err; } + if (nonInteractive) { + throw err; + } const suggestion = getSuggestionFromError(err); return { available: false, suggestion }; } diff --git a/src/init/features/hosting/index.ts b/src/init/features/hosting/index.ts index b3fc0568e05..5db896fd96e 100644 --- a/src/init/features/hosting/index.ts +++ b/src/init/features/hosting/index.ts @@ -1,5 +1,5 @@ import * as clc from "colorette"; -import { rmSync } from "node:fs"; +import { existsSync, rmSync } from "node:fs"; import { join } from "path"; import { Client } from "../../../apiv2"; @@ -11,20 +11,34 @@ 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 } from "../../../utils"; -import { interactiveCreateHostingSite } from "../../../hosting/interactive"; +import { logSuccess } from "../../../utils"; +import { pickHostingSiteName } from "../../../hosting/interactive"; import { readTemplateSync } from "../../../templates"; +import { FirebaseError } from "../../../error"; +import { Setup } from "../.."; +import { Config } from "../../../config"; +import { createSite } from "../../../hosting/api"; const INDEX_TEMPLATE = readTemplateSync("init/hosting/index.html"); const MISSING_TEMPLATE = readTemplateSync("init/hosting/404.html"); const DEFAULT_IGNORES = ["firebase.json", "**/.*", "**/node_modules/**"]; -/** - * Does the setup steps for Firebase Hosting. - * WARNING: #6527 - `options` may not have all the things you think it does. - */ -export async function doSetup(setup: any, config: any, options: Options): Promise { - setup.hosting = {}; +export interface RequiredInfo { + newSiteId?: string; + source?: string; + useWebFrameworks?: boolean; + useDiscoveredFramework?: boolean; + webFramework?: string; + region?: string; + public?: string; + spa?: boolean; +} + +// TODO: come up with a better way to type this +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function askQuestions(setup: Setup, config: Config, options: Options): Promise { + setup.featureInfo = setup.featureInfo || {}; + setup.featureInfo.hosting = {}; // There's a path where we can set up Hosting without a project, so if // if setup.projectId is empty, we don't do any checking for a Hosting site. @@ -39,68 +53,65 @@ export async function doSetup(setup: any, config: any, options: Options): Promis hasHostingSite = false; } - if (!hasHostingSite) { - // N.B. During prompt migration this did not pass options object, so there is no support - // for force or nonInteractive; there possibly should be. - const confirmCreate = await confirm({ + if ( + !hasHostingSite && + (await confirm({ message: "A Firebase Hosting site is required to deploy. Would you like to create one now?", default: true, - }); - if (confirmCreate) { - const createOptions = { - projectId: setup.projectId, - nonInteractive: options.nonInteractive, - }; - const newSite = await interactiveCreateHostingSite("", "", createOptions); - logger.info(); - logSuccess(`Firebase Hosting site ${last(newSite.name.split("/"))} created!`); - logger.info(); - } + })) + ) { + const createOptions = { + projectId: setup.projectId, + nonInteractive: options.nonInteractive, + }; + setup.featureInfo.hosting.newSiteId = await pickHostingSiteName("", createOptions); } } - let discoveredFramework = experiments.isEnabled("webframeworks") - ? await discover(config.projectDir, false) - : undefined; - if (experiments.isEnabled("webframeworks")) { - if (discoveredFramework) { - const name = WebFrameworks[discoveredFramework.framework].name; - setup.hosting.useDiscoveredFramework ??= await confirm({ - message: `Detected an existing ${name} codebase in the current directory, should we use this?`, + let discoveredFramework = experiments.isEnabled("webframeworks") + ? await discover(config.projectDir, false) + : undefined; + // First, if we're in a framework directory, ask to use that. + if ( + discoveredFramework && + (await confirm({ + message: `Detected an existing ${WebFrameworks[discoveredFramework.framework].name} codebase in the current directory, do you want to use this?`, default: true, - }); - } - if (setup.hosting.useDiscoveredFramework) { - setup.hosting.source = "."; - setup.hosting.useWebFrameworks = true; + })) + ) { + setup.featureInfo.hosting.source = "."; + setup.featureInfo.hosting.useWebFrameworks = true; + setup.featureInfo.hosting.useDiscoveredFramework = true; + setup.featureInfo.hosting.webFramework = discoveredFramework.framework; + // Otherwise, just ask if they want to use web frameworks. } else { - setup.hosting.useWebFrameworks = await confirm( + setup.featureInfo.hosting.useWebFrameworks = await confirm( `Do you want to use a web framework? (${clc.bold("experimental")})`, ); } - } - - if (setup.hosting.useWebFrameworks) { - setup.hosting.source ??= await input({ - message: "What folder would you like to use for your web application's root directory?", - default: "hosting", - }); + // If they say yes, ask for source directory if its not already known + if (setup.featureInfo.hosting.useWebFrameworks) { + setup.featureInfo.hosting.source ??= await input({ + message: "What folder would you like to use for your web application's root directory?", + default: "hosting", + }); - if (setup.hosting.source !== ".") delete setup.hosting.useDiscoveredFramework; - discoveredFramework = await discover(join(config.projectDir, setup.hosting.source)); + discoveredFramework = await discover( + join(config.projectDir, setup.featureInfo.hosting.source), + ); - if (discoveredFramework) { - const name = WebFrameworks[discoveredFramework.framework].name; - setup.hosting.useDiscoveredFramework ??= await confirm({ - message: `Detected an existing ${name} codebase in ${setup.hosting.source}, should we use this?`, - default: true, - }); - } + if (discoveredFramework) { + const name = WebFrameworks[discoveredFramework.framework].name; + setup.featureInfo.hosting.useDiscoveredFramework ??= await confirm({ + message: `Detected an existing ${name} codebase in ${setup.featureInfo.hosting.source}, should we use this?`, + default: true, + }); + if (setup.featureInfo.hosting.useDiscoveredFramework) + setup.featureInfo.hosting.webFramework = discoveredFramework.framework; + } - if (setup.hosting.useDiscoveredFramework && discoveredFramework) { - setup.hosting.webFramework = discoveredFramework.framework; - } else { + // If it is not known already, ask what framework to use. const choices: { name: string; value: string }[] = []; for (const value in WebFrameworks) { if (WebFrameworks[value]) { @@ -113,34 +124,20 @@ export async function doSetup(setup: any, config: any, options: Options): Promis ({ value }) => value === discoveredFramework?.framework, )?.value; - setup.hosting.whichFramework = - setup.hosting.whichFramework || + setup.featureInfo.hosting.webFramework ??= await select({ + message: "Please choose the framework:", + default: defaultChoice, + choices, + }); + + setup.featureInfo.hosting.region = + setup.featureInfo.hosting.region || (await select({ - message: "Please choose the framework:", - default: defaultChoice, - choices, + message: "In which region would you like to host server-side content, if applicable?", + default: DEFAULT_REGION, + choices: ALLOWED_SSR_REGIONS.filter((region) => region.recommended), })); - - if (discoveredFramework) rmSync(setup.hosting.source, { recursive: true }); - await WebFrameworks[setup.hosting.whichFramework].init!(setup, config); } - - setup.hosting.region = - setup.hosting.region || - (await select({ - message: "In which region would you like to host server-side content, if applicable?", - default: DEFAULT_REGION, - choices: ALLOWED_SSR_REGIONS.filter((region) => region.recommended), - })); - - setup.config.hosting = { - source: setup.hosting.source, - // TODO swap out for framework ignores - ignore: DEFAULT_IGNORES, - frameworksBackend: { - region: setup.hosting.region, - }, - }; } else { logger.info(); logger.info( @@ -152,42 +149,76 @@ export async function doSetup(setup: any, config: any, options: Options): Promis logger.info("have a build process for your assets, use your build's output directory."); logger.info(); - setup.hosting.public = - setup.hosting.public || - (await input({ - message: "What do you want to use as your public directory?", - default: "public", - })); - setup.hosting.spa = - setup.hosting.spa || - (await confirm("Configure as a single-page app (rewrite all urls to /index.html)?")); + setup.featureInfo.hosting.public ??= await input({ + message: "What do you want to use as your public directory?", + default: "public", + }); + setup.featureInfo.hosting.spa ??= await confirm( + "Configure as a single-page app (rewrite all urls to /index.html)?", + ); + } + // GitHub Action set up is still structured as doSetup + if (await confirm("Set up automatic builds and deploys with GitHub?")) { + return initGitHub(setup); + } +} + +// TODO: come up with a better way to type this +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function actuate(setup: Setup, config: Config, options: Options): Promise { + const hostingInfo = setup.featureInfo?.hosting; + if (!hostingInfo) { + throw new FirebaseError( + "Could not find hosting info in setup.featureInfo.hosting. This should not happen.", + { exit: 2 }, + ); + } + if (hostingInfo.newSiteId && setup.projectId) { + await createSite(setup.projectId, hostingInfo.newSiteId); + logger.info(); + logSuccess(`Firebase Hosting site ${hostingInfo.newSiteId} created!`); + logger.info(); + } + + if (hostingInfo.webFramework) { + if (!hostingInfo.useDiscoveredFramework) { + if (hostingInfo.source && existsSync(hostingInfo.source)) { + rmSync(hostingInfo.source, { recursive: true }); + } + await WebFrameworks[hostingInfo.webFramework].init!(setup, config); + } setup.config.hosting = { - public: setup.hosting.public, + source: hostingInfo.source, + // TODO swap out for framework ignores + ignore: DEFAULT_IGNORES, + frameworksBackend: { + region: hostingInfo.region, + }, + }; + } else { + setup.config.hosting = { + public: hostingInfo.public, ignore: DEFAULT_IGNORES, }; - } - - setup.hosting.github = - setup.hosting.github || (await confirm("Set up automatic builds and deploys with GitHub?")); - if (!setup.hosting.useWebFrameworks) { - if (setup.hosting.spa) { + if (hostingInfo.spa) { setup.config.hosting.rewrites = [{ source: "**", destination: "/index.html" }]; } else { // SPA doesn't need a 404 page since everything is index.html - await config.askWriteProjectFile(`${setup.hosting.public}/404.html`, MISSING_TEMPLATE); + await config.askWriteProjectFile( + `${hostingInfo.public}/404.html`, + MISSING_TEMPLATE, + !!options.force, + ); } const c = new Client({ urlPrefix: "https://www.gstatic.com", auth: false }); const response = await c.get<{ current: { version: string } }>("/firebasejs/releases.json"); await config.askWriteProjectFile( - `${setup.hosting.public}/index.html`, + `${hostingInfo.public}/index.html`, INDEX_TEMPLATE.replace(/{{VERSION}}/g, response.body.current.version), + !!options.force, ); } - - if (setup.hosting.github) { - return initGitHub(setup); - } } diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 87157d7e57a..d4ecacaa2c1 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -10,7 +10,11 @@ export { actuate as firestoreActuate, } from "./firestore"; export { doSetup as functions } from "./functions"; -export { doSetup as hosting } from "./hosting"; +export { + askQuestions as hostingAskQuestions, + actuate as hostingActuate, + RequiredInfo as HostingInfo, +} from "./hosting"; export { askQuestions as storageAskQuestions, RequiredInfo as StorageInfo, diff --git a/src/init/index.ts b/src/init/index.ts index d7d83bf70e9..e3bfe50a2e3 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -40,6 +40,7 @@ export interface SetupInfo { storage?: features.StorageInfo; apptesting?: features.ApptestingInfo; ailogic?: features.AiLogicInfo; + hosting?: features.HostingInfo; } interface Feature { @@ -80,7 +81,11 @@ const featuresList: Feature[] = [ actuate: features.dataconnectSdkActuate, }, { name: "functions", doSetup: features.functions }, - { name: "hosting", doSetup: features.hosting }, + { + name: "hosting", + askQuestions: features.hostingAskQuestions, + actuate: features.hostingActuate, + }, { name: "storage", askQuestions: features.storageAskQuestions,