Skip to content

Commit 1fa48f8

Browse files
kmcnellisbkendall
andauthored
Add Hosting Site API commands (#3182)
Changes command from hosting:site to hosting:sites. Improves formatting for site api to align with channels. Adds the app-id option when creating a site Adds the prompt/force interaction for deleting sites Adds the ability to prompt for site name when creating new site. Co-authored-by: Bryan Kendall <bkend@google.com>
1 parent 7984151 commit 1fa48f8

File tree

8 files changed

+311
-2
lines changed

8 files changed

+311
-2
lines changed

src/commands/hosting-channel-create.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export default new Command("hosting:channel:create [channelId]")
6262
try {
6363
channel = await createChannel(projectId, site, channelId, expireTTL);
6464
} catch (e) {
65-
if (e.status == 409) {
65+
if (e.status === 409) {
6666
throw new FirebaseError(
6767
`Channel ${bold(channelId)} already exists on site ${bold(site)}. Deploy to ${bold(
6868
channelId
@@ -102,7 +102,7 @@ export default new Command("hosting:channel:create [channelId]")
102102
logLabeledSuccess(LOG_TAG, `Channel URL: ${channel.url}`);
103103
logger.info();
104104
logger.info(
105-
`To deploy to this channel, use \`firebase hosting:channel:deploy ${channelId}\`.`
105+
`To deploy to this channel, use ${yellow(`firebase hosting:channel:deploy ${channelId}`)}.`
106106
);
107107

108108
return channel;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { bold, yellow } from "cli-color";
2+
3+
import { logLabeledSuccess } from "../utils";
4+
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 * as getProjectId from "../getProjectId";
10+
import * as logger from "../logger";
11+
12+
const LOG_TAG = "hosting:sites";
13+
14+
export default new Command("hosting:sites:create [siteId]")
15+
.description("create a Firebase Hosting site")
16+
.option("--app <appId>", "specify an existing Firebase Web App ID")
17+
.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 = getProjectId(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+
}
42+
43+
let site: Site;
44+
try {
45+
site = await createSite(projectId, siteId, appId);
46+
} catch (e) {
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+
}
55+
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;
73+
}
74+
);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { bold, underline } from "cli-color";
2+
import { Command } from "../command";
3+
import { logLabeledSuccess } from "../utils";
4+
import { getSite, deleteSite } from "../hosting/api";
5+
import { promptOnce } from "../prompt";
6+
import { FirebaseError } from "../error";
7+
import { requirePermissions } from "../requirePermissions";
8+
import * as getProjectId from "../getProjectId";
9+
import * as requireConfig from "../requireConfig";
10+
import * as logger from "../logger";
11+
12+
const LOG_TAG = "hosting:sites";
13+
14+
export default new Command("hosting:sites:delete <siteId>")
15+
.description("delete a Firebase Hosting site")
16+
.option("-f, --force", "delete without confirmation")
17+
.before(requireConfig)
18+
.before(requirePermissions, ["firebasehosting.sites.delete"])
19+
.action(
20+
async (
21+
siteId: string,
22+
options: any // eslint-disable-line @typescript-eslint/no-explicit-any
23+
): Promise<void> => {
24+
const projectId = getProjectId(options);
25+
if (!siteId) {
26+
throw new FirebaseError("siteId is required");
27+
}
28+
logger.info(
29+
`Deleting a site is a permanent action. If you delete a site, Firebase doesn't maintain records of deployed files or deployment history, and the site ${underline(
30+
siteId
31+
)} cannot be reactivated by you or anyone else.`
32+
);
33+
logger.info();
34+
35+
let confirmed = Boolean(options.force);
36+
if (!confirmed) {
37+
confirmed = await promptOnce({
38+
message: `Are you sure you want to delete the Hosting site ${underline(
39+
siteId
40+
)} for project ${underline(projectId)}? `,
41+
type: "confirm",
42+
default: false,
43+
});
44+
}
45+
if (!confirmed) {
46+
return;
47+
}
48+
49+
// Check that the site exists first, to avoid giving a sucessesful message on a non-existant site.
50+
await getSite(projectId, siteId);
51+
await deleteSite(projectId, siteId);
52+
logLabeledSuccess(
53+
LOG_TAG,
54+
`Successfully deleted site ${bold(siteId)} from project ${bold(projectId)}`
55+
);
56+
}
57+
);

src/commands/hosting-sites-get.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Table = require("cli-table");
2+
3+
import { Command } from "../command";
4+
import { Site, getSite } from "../hosting/api";
5+
import { requirePermissions } from "../requirePermissions";
6+
import * as getProjectId from "../getProjectId";
7+
import * as logger from "../logger";
8+
import { FirebaseError } from "../error";
9+
10+
export default new Command("hosting:sites:get <siteId>")
11+
.description("print info about a Firebase Hosting site")
12+
.before(requirePermissions, ["firebasehosting.sites.get"])
13+
.action(
14+
async (siteId: string, options): Promise<Site> => {
15+
const projectId = getProjectId(options);
16+
if (!siteId) {
17+
throw new FirebaseError("<siteId> must be specified");
18+
}
19+
const site = await getSite(projectId, siteId);
20+
const table = new Table();
21+
table.push(["Site ID:", site.name.split("/").pop()]);
22+
table.push(["Default URL:", site.defaultUrl]);
23+
table.push(["App ID:", site.appId || ""]);
24+
// table.push(["Labels:", JSON.stringify(site.labels)]);
25+
26+
logger.info();
27+
logger.info(table.toString());
28+
29+
return site;
30+
}
31+
);

src/commands/hosting-sites-list.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { bold } from "cli-color";
2+
import Table = require("cli-table");
3+
4+
import { Command } from "../command";
5+
import { Site, listSites } from "../hosting/api";
6+
import { requirePermissions } from "../requirePermissions";
7+
import * as getProjectId from "../getProjectId";
8+
import * as logger from "../logger";
9+
10+
const TABLE_HEAD = ["Site ID", "Default URL", "App ID (if set)"];
11+
12+
export default new Command("hosting:sites:list")
13+
.description("list Firebase Hosting sites")
14+
.before(requirePermissions, ["firebasehosting.sites.get"])
15+
.action(
16+
async (
17+
options: any // eslint-disable-line @typescript-eslint/no-explicit-any
18+
): Promise<{ sites: Site[] }> => {
19+
const projectId = getProjectId(options);
20+
const sites = await listSites(projectId);
21+
const table = new Table({ head: TABLE_HEAD, style: { head: ["green"] } });
22+
for (const site of sites) {
23+
const siteId = site.name.split("/").pop();
24+
table.push([siteId, site.defaultUrl, site.appId || "--"]);
25+
}
26+
27+
logger.info();
28+
logger.info(`Sites for project ${bold(projectId)}`);
29+
logger.info();
30+
logger.info(table.toString());
31+
32+
return { sites };
33+
}
34+
);

src/commands/index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ module.exports = function (client) {
100100
client.hosting.channel.open = loadCommand("hosting-channel-open");
101101
client.hosting.clone = loadCommand("hosting-clone");
102102
client.hosting.disable = loadCommand("hosting-disable");
103+
if (previews.hostingsites) {
104+
client.hosting.sites = {};
105+
client.hosting.sites.create = loadCommand("hosting-sites-create");
106+
client.hosting.sites.delete = loadCommand("hosting-sites-delete");
107+
client.hosting.sites.get = loadCommand("hosting-sites-get");
108+
client.hosting.sites.list = loadCommand("hosting-sites-list");
109+
}
103110
client.init = loadCommand("init");
104111
client.login = loadCommand("login");
105112
client.login.ci = loadCommand("login-ci");

src/hosting/api.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@ interface LongRunningOperation<T> {
171171
readonly metadata: T | undefined;
172172
}
173173

174+
export type Site = {
175+
// Fully qualified name of the site.
176+
name: string;
177+
178+
readonly defaultUrl: string;
179+
180+
readonly appId: string;
181+
182+
labels: { [key: string]: string };
183+
};
184+
174185
/**
175186
* normalizeName normalizes a name given to it. Most useful for normalizing
176187
* user provided names. This removes any `/`, ':', '_', or '#' characters and
@@ -352,6 +363,99 @@ export async function createRelease(
352363
return res.body;
353364
}
354365

366+
/**
367+
* List the Hosting sites for a given project.
368+
* @param project project name or number.
369+
* @return list of Sites.
370+
*/
371+
export async function listSites(project: string): Promise<Site[]> {
372+
const sites: Site[] = [];
373+
let nextPageToken = "";
374+
for (;;) {
375+
try {
376+
const res = await apiClient.get<{ sites: Site[]; nextPageToken?: string }>(
377+
`/projects/${project}/sites`,
378+
{ queryParams: { pageToken: nextPageToken, pageSize: 10 } }
379+
);
380+
const c = res.body?.sites;
381+
if (c) {
382+
sites.push(...c);
383+
}
384+
nextPageToken = res.body?.nextPageToken || "";
385+
if (!nextPageToken) {
386+
return sites;
387+
}
388+
} catch (e) {
389+
if (e.status === 404) {
390+
throw new FirebaseError(`could not find sites for project "${project}"`, {
391+
original: e,
392+
});
393+
}
394+
throw e;
395+
}
396+
}
397+
}
398+
399+
/**
400+
* Get a Hosting site.
401+
* @param project project name or number.
402+
* @param site site name.
403+
* @return site information.
404+
*/
405+
export async function getSite(project: string, site: string): Promise<Site> {
406+
try {
407+
const res = await apiClient.get<Site>(`/projects/${project}/sites/${site}`);
408+
return res.body;
409+
} catch (e) {
410+
if (e.status === 404) {
411+
throw new FirebaseError(`could not find site "${site}" for project "${project}"`, {
412+
original: e,
413+
});
414+
}
415+
throw e;
416+
}
417+
}
418+
419+
/**
420+
* Create a Hosting site.
421+
* @param project project name or number.
422+
* @param site the site name to create.
423+
* @param appId the Firebase Web App ID (https://firebase.google.com/docs/projects/learn-more#config-files-objects)
424+
* @return site information.
425+
*/
426+
export async function createSite(project: string, site: string, appId = ""): Promise<Site> {
427+
const res = await apiClient.post<{ appId: string }, Site>(
428+
`/projects/${project}/sites`,
429+
{ appId: appId },
430+
{ queryParams: { site_id: site } }
431+
);
432+
return res.body;
433+
}
434+
435+
/**
436+
* Update a Hosting site.
437+
* @param project project name or number.
438+
* @param site the site to update.
439+
* @param fields the fields to update.
440+
* @return site information.
441+
*/
442+
export async function updateSite(project: string, site: Site, fields: string[]): Promise<Site> {
443+
const res = await apiClient.patch<Site, Site>(`/projects/${project}/sites/${site.name}`, site, {
444+
queryParams: { updateMask: fields.join(",") },
445+
});
446+
return res.body;
447+
}
448+
449+
/**
450+
* Delete a Hosting site.
451+
* @param project project name or number.
452+
* @param site the site to update.
453+
* @return nothing.
454+
*/
455+
export async function deleteSite(project: string, site: string): Promise<void> {
456+
await apiClient.delete<void>(`/projects/${project}/sites/${site}`);
457+
}
458+
355459
/**
356460
* Adds list of channel domains to Firebase Auth list.
357461
* @param project the project ID.

src/previews.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface PreviewFlags {
66
ext: boolean;
77
extdev: boolean;
88
rtdbmanagement: boolean;
9+
hostingsites: boolean;
910
}
1011

1112
export const previews: PreviewFlags = Object.assign(
@@ -15,6 +16,7 @@ export const previews: PreviewFlags = Object.assign(
1516
ext: false,
1617
extdev: false,
1718
rtdbmanagement: false,
19+
hostingsites: false,
1820
},
1921
configstore.get("previews")
2022
);

0 commit comments

Comments
 (0)