diff --git a/.changeset/pink-lemons-bathe.md b/.changeset/pink-lemons-bathe.md new file mode 100644 index 000000000..676d92987 --- /dev/null +++ b/.changeset/pink-lemons-bathe.md @@ -0,0 +1,5 @@ +--- +"replayio": patch +--- + +Add "whoami" command to print information about the current user and API key diff --git a/packages/replayio/src/bin.ts b/packages/replayio/src/bin.ts index 604004221..952200556 100644 --- a/packages/replayio/src/bin.ts +++ b/packages/replayio/src/bin.ts @@ -1,3 +1,4 @@ +import { initLogger } from "@replay-cli/shared/logger"; import { exitProcess } from "@replay-cli/shared/process/exitProcess"; import { setUserAgent } from "@replay-cli/shared/userAgent"; import { name, version } from "../package.json"; @@ -14,7 +15,7 @@ import "./commands/remove"; import "./commands/update"; import "./commands/upload"; import "./commands/upload-source-maps"; -import { initLogger } from "@replay-cli/shared/logger"; +import "./commands/whoami"; initLogger(name, version); diff --git a/packages/replayio/src/commands/login.ts b/packages/replayio/src/commands/login.ts index 95c75b633..f277a95c8 100644 --- a/packages/replayio/src/commands/login.ts +++ b/packages/replayio/src/commands/login.ts @@ -1,13 +1,13 @@ +import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken"; import { exitProcess } from "@replay-cli/shared/process/exitProcess"; import { registerCommand } from "../utils/commander/registerCommand"; -import { checkAuthentication } from "../utils/initialization/checkAuthentication"; import { promptForAuthentication } from "../utils/initialization/promptForAuthentication"; registerCommand("login").description("Log into your Replay account (or register)").action(login); async function login() { - const authenticated = await checkAuthentication(); - if (authenticated) { + const { accessToken } = await getAccessToken(); + if (accessToken) { console.log("You are already signed in!"); } else { await promptForAuthentication(); diff --git a/packages/replayio/src/commands/logout.ts b/packages/replayio/src/commands/logout.ts index f39df17f8..71ea8e03b 100644 --- a/packages/replayio/src/commands/logout.ts +++ b/packages/replayio/src/commands/logout.ts @@ -9,12 +9,12 @@ registerCommand("logout").description("Log out of your Replay account").action(l async function logout() { await logoutIfAuthenticated(); - const token = await getAccessToken(); - if (token) { - const name = process.env.REPLAY_API_KEY ? "REPLAY_API_KEY" : "RECORD_REPLAY_API_KEY"; - + const { accessToken, apiKeySource } = await getAccessToken(); + if (accessToken && apiKeySource) { console.log( - `You are now signed out but still authenticated via the ${highlight(name)} env variable` + `You have been signed out but you are still authenticated by the ${highlight( + apiKeySource + )} env variable` ); } else { console.log("You are now signed out"); diff --git a/packages/replayio/src/commands/upload-source-maps.ts b/packages/replayio/src/commands/upload-source-maps.ts index 9085b677e..85001ead4 100644 --- a/packages/replayio/src/commands/upload-source-maps.ts +++ b/packages/replayio/src/commands/upload-source-maps.ts @@ -41,12 +41,13 @@ async function uploadSourceMaps( root?: string; } ) { + const { accessToken } = await getAccessToken(); const uploadPromise = uploadSourceMapsExternal({ extensions, filepaths: filePaths, group, ignore, - key: await getAccessToken(), + key: accessToken, root, server: replayApiServer, }); diff --git a/packages/replayio/src/commands/whoami.ts b/packages/replayio/src/commands/whoami.ts new file mode 100644 index 000000000..c5cb97061 --- /dev/null +++ b/packages/replayio/src/commands/whoami.ts @@ -0,0 +1,55 @@ +import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken"; +import { getAuthInfo } from "@replay-cli/shared/graphql/getAuthInfo"; +import { exitProcess } from "@replay-cli/shared/process/exitProcess"; +import { dim, emphasize, highlight, link } from "@replay-cli/shared/theme"; +import { name as packageName } from "../../package.json"; +import { registerCommand } from "../utils/commander/registerCommand"; +import { fetchViewerFromGraphQL } from "../utils/graphql/fetchViewerFromGraphQL"; + +registerCommand("whoami", { + checkForNpmUpdate: false, + checkForRuntimeUpdate: false, + requireAuthentication: false, +}) + .description("Display info about the current user") + .action(info); + +const DOCS_URL = "https://docs.replay.io/reference/api-keys"; + +async function info() { + const { accessToken, apiKeySource } = await getAccessToken(); + if (accessToken) { + const authInfo = await getAuthInfo(accessToken); + + const { userEmail, userName, teamName } = await fetchViewerFromGraphQL(accessToken); + + if (apiKeySource) { + console.log(`You are authenticated by API key ${dim(`(process.env.${apiKeySource})`)}`); + console.log(""); + if (authInfo.type === "user") { + console.log(`This API key belongs to ${emphasize(userName)} (${userEmail})`); + console.log(`Recordings you upload are ${emphasize("private")} by default`); + } else { + console.log(`This API key belongs to the team named ${emphasize(teamName)}`); + console.log(`Recordings you upload are ${emphasize("shared")} with other team members`); + } + console.log(""); + console.log(`Learn more about API keys at ${link(DOCS_URL)}`); + } else { + console.log(`You signed in as ${emphasize(userName)} (${userEmail})`); + console.log(""); + console.log(`Recordings you upload are ${emphasize("private")} by default`); + console.log(""); + console.log(`Learn about other ways to sign in at ${link(DOCS_URL)}`); + } + } else { + console.log("You are not authenticated"); + console.log(""); + console.log(`Sign in by running ${highlight(`${packageName} login`)}`); + console.log(""); + console.log("You can also authenticate with an API key"); + console.log(`Learn more at ${link(DOCS_URL)}`); + } + + await exitProcess(0); +} diff --git a/packages/replayio/src/utils/browser/reportBrowserCrash.ts b/packages/replayio/src/utils/browser/reportBrowserCrash.ts index fbc9b0d4e..3488af705 100644 --- a/packages/replayio/src/utils/browser/reportBrowserCrash.ts +++ b/packages/replayio/src/utils/browser/reportBrowserCrash.ts @@ -1,10 +1,10 @@ +import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken"; import { getReplayPath } from "@replay-cli/shared/getReplayPath"; import { logger } from "@replay-cli/shared/logger"; +import { getUserAgent } from "@replay-cli/shared/userAgent"; import { readFile, writeFileSync } from "fs-extra"; import { File, FormData, fetch } from "undici"; import { replayApiServer } from "../../config"; -import { getUserAgent } from "@replay-cli/shared/userAgent"; -import { checkAuthentication } from "../../utils/initialization/checkAuthentication"; import { getCurrentRuntimeMetadata } from "../../utils/initialization/getCurrentRuntimeMetadata"; import { runtimeMetadata } from "../../utils/installation/config"; import { findMostRecentFile } from "../findMostRecentFile"; @@ -13,8 +13,7 @@ export async function reportBrowserCrash(stderr: string) { const errorLogPath = getReplayPath("recorder-crash.log"); writeFileSync(errorLogPath, stderr, "utf8"); - const accessToken = await checkAuthentication(); - + const { accessToken } = await getAccessToken(); if (!accessToken) { return { errorLogPath, diff --git a/packages/replayio/src/utils/graphql/fetchViewerFromGraphQL.ts b/packages/replayio/src/utils/graphql/fetchViewerFromGraphQL.ts new file mode 100644 index 000000000..84f252b8b --- /dev/null +++ b/packages/replayio/src/utils/graphql/fetchViewerFromGraphQL.ts @@ -0,0 +1,68 @@ +import { GraphQLError } from "@replay-cli/shared/graphql/GraphQLError"; +import { queryGraphQL } from "@replay-cli/shared/graphql/queryGraphQL"; +import { logger } from "@replay-cli/shared/logger"; + +export type AuthInfo = { + userEmail: string | undefined; + userName: string | undefined; + teamName: string | undefined; +}; + +export async function fetchViewerFromGraphQL(accessToken: string): Promise { + logger.debug("Fetching viewer info from GraphQL"); + + const { data, errors } = await queryGraphQL( + "ViewerInfo", + ` + query ViewerInfo { + viewer { + email + user { + name + } + } + auth { + workspaces { + edges { + node { + name + } + } + } + } + } + `, + {}, + accessToken + ); + + if (errors) { + throw new GraphQLError("Failed to fetch auth info", errors); + } + + const response = data as { + viewer: { + email: string; + user: { + name: string; + } | null; + }; + auth: { + workspaces: { + edges: { + node: { + name: string; + }; + }[]; + }; + }; + }; + + const { viewer, auth } = response; + + return { + userEmail: viewer?.email, + userName: viewer?.user?.name, + teamName: auth?.workspaces?.edges?.[0]?.node?.name, + }; +} diff --git a/packages/replayio/src/utils/initialization/checkAuthentication.ts b/packages/replayio/src/utils/initialization/checkAuthentication.ts deleted file mode 100644 index 76ec3ffc1..000000000 --- a/packages/replayio/src/utils/initialization/checkAuthentication.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken"; - -export async function checkAuthentication() { - return await getAccessToken(); -} diff --git a/packages/replayio/src/utils/initialization/initialize.ts b/packages/replayio/src/utils/initialization/initialize.ts index 30730021b..7f6a566bd 100644 --- a/packages/replayio/src/utils/initialization/initialize.ts +++ b/packages/replayio/src/utils/initialization/initialize.ts @@ -1,9 +1,9 @@ import { raceWithTimeout } from "@replay-cli/shared/async/raceWithTimeout"; +import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken"; import { initLaunchDarklyFromAccessToken } from "@replay-cli/shared/launch-darkly/initLaunchDarklyFromAccessToken"; import { initMixpanelForUserSession } from "@replay-cli/shared/mixpanel/initMixpanelForUserSession"; import { name as packageName, version as packageVersion } from "../../../package.json"; import { logPromise } from "../async/logPromise"; -import { checkAuthentication } from "./checkAuthentication"; import { checkForNpmUpdate } from "./checkForNpmUpdate"; import { checkForRuntimeUpdate } from "./checkForRuntimeUpdate"; import { promptForAuthentication } from "./promptForAuthentication"; @@ -22,7 +22,7 @@ export async function initialize({ // These initialization steps can run in parallel to improve startup time // None of them should log anything though; that would interfere with the initialization-in-progress message const promises = Promise.all([ - checkAuthentication(), + getAccessToken().then(({ accessToken }) => accessToken), shouldCheckForRuntimeUpdate ? raceWithTimeout(checkForRuntimeUpdate(), 5_000) : Promise.resolve(), diff --git a/packages/shared/src/authentication/getAccessToken.ts b/packages/shared/src/authentication/getAccessToken.ts index ce8a67caf..21b47f326 100644 --- a/packages/shared/src/authentication/getAccessToken.ts +++ b/packages/shared/src/authentication/getAccessToken.ts @@ -6,29 +6,45 @@ import { cachedAuthPath } from "./config"; import { refreshAccessTokenOrThrow } from "./refreshAccessTokenOrThrow"; import { CachedAuthDetails } from "./types"; -export async function getAccessToken(): Promise { +export type AccessTokenInfo = { + accessToken: string | undefined; + apiKeySource: "REPLAY_API_KEY" | "RECORD_REPLAY_API_KEY" | undefined; +}; + +const NO_ACCESS_TOKEN: AccessTokenInfo = { + accessToken: undefined, + apiKeySource: undefined, +}; + +export async function getAccessToken(): Promise { if (process.env.REPLAY_API_KEY) { logger.debug("Using token from env (REPLAY_API_KEY)"); - return process.env.REPLAY_API_KEY; + return { + accessToken: process.env.REPLAY_API_KEY, + apiKeySource: "REPLAY_API_KEY", + }; } else if (process.env.RECORD_REPLAY_API_KEY) { logger.debug("Using token from env (RECORD_REPLAY_API_KEY)"); - return process.env.RECORD_REPLAY_API_KEY; + return { + accessToken: process.env.RECORD_REPLAY_API_KEY, + apiKeySource: "RECORD_REPLAY_API_KEY", + }; } let { accessToken, refreshToken } = readFromCache(cachedAuthPath) ?? {}; if (typeof accessToken !== "string") { logger.debug("Unexpected accessToken value", { accessToken }); - return; + return NO_ACCESS_TOKEN; } if (typeof refreshToken !== "string") { logger.debug("Unexpected refreshToken", { refreshToken }); - return; + return NO_ACCESS_TOKEN; } const [_, encodedToken, __] = accessToken.split(".", 3); if (typeof encodedToken !== "string") { logger.debug("Token did not contain a valid payload", { accessToken: maskString(accessToken) }); - return; + return NO_ACCESS_TOKEN; } let payload: any; @@ -36,12 +52,12 @@ export async function getAccessToken(): Promise { payload = JSON.parse(Buffer.from(encodedToken, "base64").toString()); } catch (error) { logger.debug("Failed to decode token", { accessToken: maskString(accessToken), error }); - return; + return NO_ACCESS_TOKEN; } if (typeof payload !== "object") { logger.debug("Token payload was not an object"); - return; + return NO_ACCESS_TOKEN; } const expiration = (payload?.exp ?? 0) * 1000; @@ -57,11 +73,11 @@ export async function getAccessToken(): Promise { } catch (error) { writeToCache(cachedAuthPath, undefined); updateCachedAuthInfo(accessToken, undefined); - return; + return NO_ACCESS_TOKEN; } } else { logger.debug(`Access token valid until ${expirationDate.toLocaleDateString()}`); } - return accessToken; + return { accessToken, apiKeySource: undefined }; } diff --git a/packages/shared/src/graphql/getAuthInfo.ts b/packages/shared/src/graphql/getAuthInfo.ts index 9f4fb9089..25ca8925c 100644 --- a/packages/shared/src/graphql/getAuthInfo.ts +++ b/packages/shared/src/graphql/getAuthInfo.ts @@ -9,8 +9,8 @@ export type Cached = { export async function getAuthInfo(accessToken: string): Promise { const cached = readFromCache(cachePath) ?? {}; - let authInfo = cached[accessToken]; + let authInfo = cached[accessToken]; if (!authInfo) { authInfo = await fetchAuthInfoFromGraphQL(accessToken); diff --git a/packages/shared/src/protocol/ProtocolClient.ts b/packages/shared/src/protocol/ProtocolClient.ts index 399606342..f4f829a33 100644 --- a/packages/shared/src/protocol/ProtocolClient.ts +++ b/packages/shared/src/protocol/ProtocolClient.ts @@ -153,7 +153,7 @@ export default class ProtocolClient { private onSocketOpen = async () => { try { - const accessToken = await getAccessToken(); + const { accessToken } = await getAccessToken(); assert(accessToken, "No access token found"); await setAccessToken(this, { accessToken });