diff --git a/packages/gatsby-cli/src/create-cli.ts b/packages/gatsby-cli/src/create-cli.ts index a478cda279693..1793bed411110 100644 --- a/packages/gatsby-cli/src/create-cli.ts +++ b/packages/gatsby-cli/src/create-cli.ts @@ -16,6 +16,9 @@ import report from "./reporter" import { setStore } from "./reporter/redux" import { getLocalGatsbyVersion } from "./util/version" import { initStarter } from "./init-starter" +import { login } from "./login" +import { logout } from "./logout" +import { whoami } from "./whoami" import { recipesHandler } from "./recipes" import { getPackageManager, setPackageManager } from "./util/package-manager" import reporter from "./reporter" @@ -411,6 +414,32 @@ function buildLocalCommands(cli: yargs.Argv, isLocalSite: boolean): void { }), handler: getCommandHandler(`plugin`), }) + + if (process.env.GATSBY_EXPERIMENTAL_CLOUD_CLI) { + cli.command({ + command: `login`, + describe: `Log in to Gatsby Cloud.`, + handler: handlerP(async () => { + await login() + }), + }) + + cli.command({ + command: `logout`, + describe: `Sign out of Gatsby Cloud.`, + handler: handlerP(async () => { + await logout() + }), + }) + + cli.command({ + command: `whoami`, + describe: `Gives the username of the current logged in user.`, + handler: handlerP(async () => { + await whoami() + }), + }) + } } function isLocalGatsbySite(): boolean { diff --git a/packages/gatsby-cli/src/login.ts b/packages/gatsby-cli/src/login.ts new file mode 100644 index 0000000000000..baa5895e9d8f2 --- /dev/null +++ b/packages/gatsby-cli/src/login.ts @@ -0,0 +1,109 @@ +import fetch from "node-fetch" +import opn from "better-opn" +import reporter from "./reporter" +import { getToken, setToken } from "./util/manage-token" + +interface ITicket { + verified: boolean + token?: string | null + expiration?: string | null +} + +const createTicket = async (): Promise => { + let ticketId + try { + const ticketResponse = await fetch( + `https://auth.gatsbyjs.com/auth/tickets/create`, + { + method: `post`, + } + ) + const ticketJson = await ticketResponse.json() + ticketId = ticketJson.ticketId + } catch (e) { + reporter.panic( + `We had trouble connecting to Gatsby Cloud to create a login session. +Please try again later, and if it continues to have trouble connecting file an issue.` + ) + } + + return ticketId +} + +const getTicket = async (ticketId: string): Promise => { + let ticket: ITicket = { + verified: false, + } + try { + const ticketResponse = await fetch( + `https://auth.gatsbyjs.com/auth/tickets/${ticketId}` + ) + const ticketJson = await ticketResponse.json() + ticket = ticketJson + } catch (e) { + reporter.error(e) + } + + return ticket +} + +const handleOpenBrowser = (url): void => { + // TODO: this will break if run from the CLI + // for ideas see https://github.com/netlify/cli/blob/908f285fb80f04bf2635da73381c94387b9c8b0d/src/utils/open-browser.js + console.log(``) + reporter.info(`Opening Gatsby Cloud for you to login from, copy this`) + reporter.info(`url into your browser if it doesn't open automatically:`) + console.log(``) + console.log(url) + opn(url) +} + +/** + * Main function that logs in to Gatsby Cloud using Gatsby Cloud's authentication service. + */ +export async function login(): Promise { + const tokenFromStore = await getToken() + + if (tokenFromStore) { + reporter.info(`You are already logged in!`) + return + } + + const webUrl = `https://gatsbyjs.com` + reporter.info(`Logging into your Gatsby Cloud account...`) + + // Create "ticket" for auth (like an expiring session) + const ticketId = await createTicket() + + // Open browser for authentication + const authUrl = `${webUrl}/dashboard/login?authType=EXTERNAL_AUTH&ticketId=${ticketId}&noredirect=1` + + await handleOpenBrowser(authUrl) + + // Poll until the ticket has been verified, and should have the token attached + function pollForTicket(): Promise { + return new Promise(function (resolve): void { + // eslint-disable-next-line consistent-return + async function verify(): Promise { + const ticket = await getTicket(ticketId) + const timeoutId = setTimeout(verify, 3000) + if (ticket.verified) { + clearTimeout(timeoutId) + return resolve(ticket) + } + } + + verify() + }) + } + + console.log(``) + reporter.info(`Waiting for login from Gatsby Cloud...`) + + const ticket = await pollForTicket() + + if (ticket?.token && ticket?.expiration) { + await setToken(ticket.token, ticket.expiration) + } + reporter.info(`You have been logged in!`) +} diff --git a/packages/gatsby-cli/src/logout.ts b/packages/gatsby-cli/src/logout.ts new file mode 100644 index 0000000000000..2ec2d49c6b890 --- /dev/null +++ b/packages/gatsby-cli/src/logout.ts @@ -0,0 +1,10 @@ +import reporter from "./reporter" +import { setToken } from "./util/manage-token" + +/** + * Main function that logs out of Gatsby Cloud by removing the token from the config store. + */ +export async function logout(): Promise { + await setToken(null, ``) + reporter.info(`You have been logged out of Gatsby Cloud from this device.`) +} diff --git a/packages/gatsby-cli/src/util/manage-token.ts b/packages/gatsby-cli/src/util/manage-token.ts new file mode 100644 index 0000000000000..27c6147eda669 --- /dev/null +++ b/packages/gatsby-cli/src/util/manage-token.ts @@ -0,0 +1,23 @@ +import { getConfigStore } from "gatsby-core-utils" +import report from "../reporter" + +const tokenKey = `cli.token` +const tokenExpirationKey = `cli.tokenExpiration` + +const getExpiration = (): string => getConfigStore().get(tokenExpirationKey) +export const getToken = async (): Promise => { + const expiration = await getExpiration() + const tokenHasExpired = new Date() > new Date(expiration) + if (tokenHasExpired) { + report.warn(`Your token has expired, you may need to login again`) + } + return getConfigStore().get(tokenKey) +} + +export const setToken = (token: string | null, expiration: string): void => { + const store = getConfigStore() + + store.set(tokenKey, token) + // we would be able to decode an expiration off the JWT, but the auth service isn't set up to attach it to the token + store.set(tokenExpirationKey, expiration) +} diff --git a/packages/gatsby-cli/src/whoami.ts b/packages/gatsby-cli/src/whoami.ts new file mode 100644 index 0000000000000..16e0dce35c0e8 --- /dev/null +++ b/packages/gatsby-cli/src/whoami.ts @@ -0,0 +1,43 @@ +import fetch from "node-fetch" +import reporter from "./reporter" +import { getToken } from "./util/manage-token" + +const getUsername = async (token: string): Promise => { + let currentUsername + const query = `query { + currentUser { + name + } + }` + try { + const usernameResponse = await fetch(`https://api.gatsbyjs.com/graphql`, { + method: `post`, + body: JSON.stringify({ query }), + headers: { + Authorization: `Bearer ${token}`, + "content-type": `application/json`, + }, + }) + const resJson = await usernameResponse.json() + currentUsername = resJson.data.currentUser.name + } catch (e) { + reporter.error(e) + } + + return currentUsername +} + +/** + * Reports the username of the logged in user if they are logged in. + */ +export async function whoami(): Promise { + const tokenFromStore = await getToken() + + if (!tokenFromStore) { + reporter.info(`You are not currently logged in!`) + return + } + + const username = await getUsername(tokenFromStore) + reporter.info(username) +}