Skip to content

Commit 61523b9

Browse files
authored
adds a check for a hosting site to exist in hosting init (#6493)
* adds a check for a hosting site to exist in hosting init * formatting is hard * sites:create now will better prompt for a site name if one isn't provided * fix non-interactive flow * better organize catch block * protect deploy and channel:deploy with errors * linting is hard * always make good and valid suggestions for site creation * formatting is hard * rm experiment * add changelog
1 parent aeb2901 commit 61523b9

File tree

8 files changed

+240
-72
lines changed

8 files changed

+240
-72
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
- Fix blocking functions in the emulator when using multiple codebases (#6504).
22
- Add force flag call-out for bypassing prompts (#6506).
33
- Fixed an issue where the functions emulator did not respect the `--log-verbosity` flag (#2859).
4+
- Add the ability to look for the default Hosting site via Hosting's API.
5+
- Add logic to create a Hosting site when one is not available in a project.
6+
- Add checks for the default Hosting site when one is assumed to exist.

src/commands/deploy.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { deploy } from "../deploy";
77
import { requireConfig } from "../requireConfig";
88
import { filterTargets } from "../filterTargets";
99
import { requireHostingSite } from "../requireHostingSite";
10+
import { errNoDefaultSite } from "../getDefaultHostingSite";
11+
import { FirebaseError } from "../error";
12+
import { bold } from "colorette";
13+
import { interactiveCreateHostingSite } from "../hosting/interactive";
14+
import { logBullet } from "../utils";
1015

1116
// in order of least time-consuming to most time-consuming
1217
export const VALID_DEPLOY_TARGETS = [
@@ -78,7 +83,26 @@ export const command = new Command("deploy")
7883
}
7984

8085
if (options.filteredTargets.includes("hosting")) {
81-
await requireHostingSite(options);
86+
let createSite = false;
87+
try {
88+
await requireHostingSite(options);
89+
} catch (err: unknown) {
90+
if (err === errNoDefaultSite) {
91+
createSite = true;
92+
}
93+
}
94+
if (!createSite) {
95+
return;
96+
}
97+
if (options.nonInteractive) {
98+
throw new FirebaseError(
99+
`Unable to deploy to Hosting as there is no Hosting site. Use ${bold(
100+
"firebase hosting:sites:create"
101+
)} to create a site.`
102+
);
103+
}
104+
logBullet("No Hosting site detected.");
105+
await interactiveCreateHostingSite("", "", options);
82106
}
83107
})
84108
.before(checkValidTargetFilters)

src/commands/hosting-channel-create.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { logger } from "../logger";
1212
import { requireConfig } from "../requireConfig";
1313
import { marked } from "marked";
1414
import { requireHostingSite } from "../requireHostingSite";
15+
import { errNoDefaultSite } from "../getDefaultHostingSite";
1516

1617
const LOG_TAG = "hosting:channel";
1718

@@ -24,7 +25,20 @@ export const command = new Command("hosting:channel:create [channelId]")
2425
.option("--site <siteId>", "site for which to create the channel")
2526
.before(requireConfig)
2627
.before(requirePermissions, ["firebasehosting.sites.update"])
27-
.before(requireHostingSite)
28+
.before(async (options) => {
29+
try {
30+
await requireHostingSite(options);
31+
} catch (err: unknown) {
32+
if (err === errNoDefaultSite) {
33+
throw new FirebaseError(
34+
`Unable to deploy to Hosting as there is no Hosting site. Use ${bold(
35+
"firebase hosting:sites:create"
36+
)} to create a site.`
37+
);
38+
}
39+
throw err;
40+
}
41+
})
2842
.action(
2943
async (
3044
channelId: string,

src/commands/hosting-sites-create.ts

+30-60
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,44 @@
11
import { bold } from "colorette";
22

3-
import { logLabeledSuccess } from "../utils";
43
import { Command } from "../command";
5-
import { Site, createSite } from "../hosting/api";
6-
import { promptOnce } from "../prompt";
7-
import { FirebaseError } from "../error";
8-
import { requirePermissions } from "../requirePermissions";
9-
import { needProjectId } from "../projectUtils";
4+
import { interactiveCreateHostingSite } from "../hosting/interactive";
5+
import { last, logLabeledSuccess } from "../utils";
106
import { logger } from "../logger";
7+
import { needProjectId } from "../projectUtils";
8+
import { Options } from "../options";
9+
import { requirePermissions } from "../requirePermissions";
10+
import { Site } from "../hosting/api";
11+
import { FirebaseError } from "../error";
1112

1213
const LOG_TAG = "hosting:sites";
1314

1415
export const command = new Command("hosting:sites:create [siteId]")
1516
.description("create a Firebase Hosting site")
1617
.option("--app <appId>", "specify an existing Firebase Web App ID")
1718
.before(requirePermissions, ["firebasehosting.sites.update"])
18-
.action(
19-
async (
20-
siteId: string,
21-
options: any // eslint-disable-line @typescript-eslint/no-explicit-any
22-
): Promise<Site> => {
23-
const projectId = needProjectId(options);
24-
const appId = options.app;
25-
if (!siteId) {
26-
if (options.nonInteractive) {
27-
throw new FirebaseError(
28-
`"siteId" argument must be provided in a non-interactive environment`
29-
);
30-
}
31-
siteId = await promptOnce(
32-
{
33-
type: "input",
34-
message: "Please provide an unique, URL-friendly id for the site (<id>.web.app):",
35-
validate: (s) => s.length > 0,
36-
} // Prevents an empty string from being submitted!
37-
);
38-
}
39-
if (!siteId) {
40-
throw new FirebaseError(`"siteId" must not be empty`);
41-
}
19+
.action(async (siteId: string, options: Options & { app: string }): Promise<Site> => {
20+
const projectId = needProjectId(options);
21+
const appId = options.app;
22+
23+
if (options.nonInteractive && !siteId) {
24+
throw new FirebaseError(`${bold(siteId)} is required in a non-interactive environment`);
25+
}
4226

43-
let site: Site;
44-
try {
45-
site = await createSite(projectId, siteId, appId);
46-
} catch (e: any) {
47-
if (e.status === 409) {
48-
throw new FirebaseError(
49-
`Site ${bold(siteId)} already exists in project ${bold(projectId)}.`,
50-
{ original: e }
51-
);
52-
}
53-
throw e;
54-
}
27+
const site = await interactiveCreateHostingSite(siteId, appId, options);
28+
siteId = last(site.name.split("/"));
5529

56-
logger.info();
57-
logLabeledSuccess(
58-
LOG_TAG,
59-
`Site ${bold(siteId)} has been created in project ${bold(projectId)}.`
60-
);
61-
if (appId) {
62-
logLabeledSuccess(
63-
LOG_TAG,
64-
`Site ${bold(siteId)} has been linked to web app ${bold(appId)}`
65-
);
66-
}
67-
logLabeledSuccess(LOG_TAG, `Site URL: ${site.defaultUrl}`);
68-
logger.info();
69-
logger.info(
70-
`To deploy to this site, follow the guide at https://firebase.google.com/docs/hosting/multisites.`
71-
);
72-
return site;
30+
logger.info();
31+
logLabeledSuccess(
32+
LOG_TAG,
33+
`Site ${bold(siteId)} has been created in project ${bold(projectId)}.`
34+
);
35+
if (appId) {
36+
logLabeledSuccess(LOG_TAG, `Site ${bold(siteId)} has been linked to web app ${bold(appId)}`);
7337
}
74-
);
38+
logLabeledSuccess(LOG_TAG, `Site URL: ${site.defaultUrl}`);
39+
logger.info();
40+
logger.info(
41+
`To deploy to this site, follow the guide at https://firebase.google.com/docs/hosting/multisites.`
42+
);
43+
return site;
44+
});

src/getDefaultHostingSite.ts

+20-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { FirebaseError } from "./error";
2+
import { SiteType, listSites } from "./hosting/api";
13
import { logger } from "./logger";
24
import { getFirebaseProject } from "./management/projects";
35
import { needProjectId } from "./projectUtils";
6+
import { last } from "./utils";
7+
8+
export const errNoDefaultSite = new FirebaseError(
9+
"Could not determine the default site for the project."
10+
);
411

512
/**
613
* Tries to determine the default hosting site for a project, else falls back to projectId.
@@ -10,12 +17,20 @@ import { needProjectId } from "./projectUtils";
1017
export async function getDefaultHostingSite(options: any): Promise<string> {
1118
const projectId = needProjectId(options);
1219
const project = await getFirebaseProject(projectId);
13-
const site = project.resources?.hostingSite;
20+
let site = project.resources?.hostingSite;
1421
if (!site) {
15-
logger.debug(
16-
`No default hosting site found for project: ${options.project}. Using projectId as hosting site name.`
17-
);
18-
return options.project;
22+
logger.debug(`the default site does not exist on the Firebase project; asking Hosting.`);
23+
const sites = await listSites(projectId);
24+
for (const s of sites) {
25+
if (s.type === SiteType.DEFAULT_SITE) {
26+
site = last(s.name.split("/"));
27+
break;
28+
}
29+
}
30+
if (!site) {
31+
throw errNoDefaultSite;
32+
}
33+
return site;
1934
}
2035
return site;
2136
}

src/hosting/api.ts

+26-2
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,19 @@ interface LongRunningOperation<T> {
229229
readonly metadata: T | undefined;
230230
}
231231

232+
// The possible types of a site.
233+
export enum SiteType {
234+
// Unknown state, likely the result of an error on the backend.
235+
TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED",
236+
237+
// The default Hosting site that is provisioned when a Firebase project is
238+
// created.
239+
DEFAULT_SITE = "DEFAULT_SITE",
240+
241+
// A Hosting site that the user created.
242+
USER_SITE = "USER_SITE",
243+
}
244+
232245
export type Site = {
233246
// Fully qualified name of the site.
234247
name: string;
@@ -237,6 +250,8 @@ export type Site = {
237250

238251
readonly appId: string;
239252

253+
readonly type?: SiteType;
254+
240255
labels: { [key: string]: string };
241256
};
242257

@@ -549,11 +564,20 @@ export async function getSite(project: string, site: string): Promise<Site> {
549564
* @param appId the Firebase Web App ID (https://firebase.google.com/docs/projects/learn-more#config-files-objects)
550565
* @return site information.
551566
*/
552-
export async function createSite(project: string, site: string, appId = ""): Promise<Site> {
567+
export async function createSite(
568+
project: string,
569+
site: string,
570+
appId = "",
571+
validateOnly = false
572+
): Promise<Site> {
573+
const queryParams: Record<string, string> = { siteId: site };
574+
if (validateOnly) {
575+
queryParams.validateOnly = "true";
576+
}
553577
const res = await apiClient.post<{ appId: string }, Site>(
554578
`/projects/${project}/sites`,
555579
{ appId: appId },
556-
{ queryParams: { siteId: site } }
580+
{ queryParams }
557581
);
558582
return res.body;
559583
}

src/hosting/interactive.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { FirebaseError } from "../error";
2+
import { logWarning } from "../utils";
3+
import { needProjectId, needProjectNumber } from "../projectUtils";
4+
import { Options } from "../options";
5+
import { promptOnce } from "../prompt";
6+
import { Site, createSite } from "./api";
7+
8+
const nameSuggestion = new RegExp("try something like `(.+)`");
9+
// const prompt = "Please provide an unique, URL-friendly id for the site (<id>.web.app):";
10+
const prompt =
11+
"Please provide an unique, URL-friendly id for your site. Your site's URL will be <site-id>.web.app. " +
12+
'We recommend using letters, numbers, and hyphens (e.g. "{project-id}-{random-hash}"):';
13+
14+
/**
15+
* Interactively prompt to create a Hosting site.
16+
*/
17+
export async function interactiveCreateHostingSite(
18+
siteId: string,
19+
appId: string,
20+
options: Options
21+
): Promise<Site> {
22+
const projectId = needProjectId(options);
23+
const projectNumber = await needProjectNumber(options);
24+
let id = siteId;
25+
let newSite: Site | undefined;
26+
let suggestion: string | undefined;
27+
28+
// If we were given an ID, we're going to start with that, so don't check the project ID.
29+
// If we weren't given an ID, let's _suggest_ the project ID as the site name (or a variant).
30+
if (!id) {
31+
const attempt = await trySiteID(projectNumber, projectId);
32+
if (attempt.available) {
33+
suggestion = projectId;
34+
} else {
35+
suggestion = attempt.suggestion;
36+
}
37+
}
38+
39+
while (!newSite) {
40+
if (!id || suggestion) {
41+
id = await promptOnce({
42+
type: "input",
43+
message: prompt,
44+
validate: (s: string) => s.length > 0, // Prevents an empty string from being submitted!
45+
default: suggestion,
46+
});
47+
}
48+
try {
49+
newSite = await createSite(projectNumber, id, appId);
50+
} catch (err: unknown) {
51+
if (!(err instanceof FirebaseError)) {
52+
throw err;
53+
}
54+
if (options.nonInteractive) {
55+
throw err;
56+
}
57+
58+
suggestion = getSuggestionFromError(err);
59+
}
60+
}
61+
return newSite;
62+
}
63+
64+
async function trySiteID(
65+
projectNumber: string,
66+
id: string
67+
): Promise<{ available: boolean; suggestion?: string }> {
68+
try {
69+
await createSite(projectNumber, id, "", true);
70+
return { available: true };
71+
} catch (err: unknown) {
72+
if (!(err instanceof FirebaseError)) {
73+
throw err;
74+
}
75+
const suggestion = getSuggestionFromError(err);
76+
return { available: false, suggestion };
77+
}
78+
}
79+
80+
function getSuggestionFromError(err: FirebaseError): string | undefined {
81+
if (err.status === 400 && err.message.includes("Invalid name:")) {
82+
const i = err.message.indexOf("Invalid name:");
83+
logWarning(err.message.substring(i));
84+
const match = nameSuggestion.exec(err.message);
85+
if (match) {
86+
return match[1];
87+
}
88+
}
89+
return;
90+
}

0 commit comments

Comments
 (0)