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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
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;
});
25 changes: 20 additions & 5 deletions src/getDefaultHostingSite.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -10,12 +17,20 @@ 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) {
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;
}
28 changes: 26 additions & 2 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 Expand Up @@ -549,11 +564,20 @@ export async function getSite(project: string, site: string): Promise<Site> {
* @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<Site> {
export async function createSite(
project: string,
site: string,
appId = "",
validateOnly = false
): Promise<Site> {
const queryParams: Record<string, string> = { 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;
}
Expand Down
90 changes: 90 additions & 0 deletions src/hosting/interactive.ts
Original file line number Diff line number Diff line change
@@ -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 (<id>.web.app):";
const prompt =
"Please provide an unique, URL-friendly id for your site. Your site's URL will be <site-id>.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<Site> {
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;
}
Loading
Loading