Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adds a check for a hosting site to exist in hosting init #6493

Merged
merged 14 commits into from
Nov 14, 2023
Merged
26 changes: 25 additions & 1 deletion src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
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 = [
Expand Down Expand Up @@ -60,15 +65,15 @@
.option("--except <targets>", 'deploy to all targets except specified (e.g. "database")')
.before(requireConfig)
.before((options) => {
options.filteredTargets = filterTargets(options, VALID_DEPLOY_TARGETS);

Check warning on line 68 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe member access .filteredTargets on an `any` value

Check warning on line 68 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe argument of type `any` assigned to a parameter of type `Options`
const permissions = options.filteredTargets.reduce((perms: string[], target: string) => {

Check warning on line 69 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe assignment of an `any` value

Check warning on line 69 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe member access .filteredTargets on an `any` value

Check warning on line 69 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe call of an `any` typed value
return perms.concat(TARGET_PERMISSIONS[target]);
}, []);
return requirePermissions(options, permissions);

Check warning on line 72 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe argument of type `any` assigned to a parameter of type `string[]`
})
.before((options) => {
if (options.filteredTargets.includes("functions")) {

Check warning on line 75 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe member access .filteredTargets on an `any` value

Check warning on line 75 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe call of an `any` typed value
return checkServiceAccountIam(options.project);

Check warning on line 76 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe argument of type `any` assigned to a parameter of type `string`

Check warning on line 76 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (18)

Unsafe member access .project on an `any` value
}
})
.before(async (options) => {
Expand All @@ -78,7 +83,26 @@
}

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)
Expand Down
16 changes: 15 additions & 1 deletion src/commands/hosting-channel-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -24,7 +25,20 @@ export const command = new Command("hosting:channel:create [channelId]")
.option("--site <siteId>", "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,
Expand Down
90 changes: 30 additions & 60 deletions src/commands/hosting-sites-create.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,44 @@
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";

export const command = new Command("hosting:sites:create [siteId]")
.description("create a Firebase Hosting site")
.option("--app <appId>", "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<Site> => {
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 (<id>.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<Site> => {
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;
});
4 changes: 4 additions & 0 deletions src/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 24 additions & 1 deletion src/getDefaultHostingSite.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -10,8 +18,23 @@ import { needProjectId } from "./projectUtils";
export async function getDefaultHostingSite(options: any): Promise<string> {
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.`
);
Expand Down
15 changes: 15 additions & 0 deletions src/hosting/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,19 @@ interface LongRunningOperation<T> {
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;
Expand All @@ -237,6 +250,8 @@ export type Site = {

readonly appId: string;

readonly type?: SiteType;

labels: { [key: string]: string };
};

Expand Down
53 changes: 53 additions & 0 deletions src/hosting/interactive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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<Site> {
const nameSuggestion = new RegExp("try something like `(.+)`");

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 (<id>.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)) {
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;
}
34 changes: 31 additions & 3 deletions src/init/features/hosting/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -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<void> {
export async function doSetup(setup: any, config: any, options: Options): Promise<void> {
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;
Expand Down
Loading