Skip to content

Commit db3f422

Browse files
authored
Add "whoami" command (#572)
- [x] Merge replayio/docs#211 to add the new API keys redirect page ### [Loom demo](https://www.loom.com/share/448e43d722e84fae975a2013cbbd73cb) ### Screenshots | Scenario | | | :--- | :--- | | Not authenticated | ![Not authenticated](https://github.com/replayio/replay-cli/assets/29597/aa0c5ab6-feb8-4a16-9987-dbdc4f7538d0) | | Authenticated by email and password | ![Authenticated by email and password](https://github.com/replayio/replay-cli/assets/29597/2caee8ce-891b-4b06-907f-c33f2c721daf) | Team API key | ![Team API key](https://github.com/replayio/replay-cli/assets/29597/08df1946-cf6c-4bda-80a1-a11f124976db) | Personal API key | ![Personal API key](https://github.com/replayio/replay-cli/assets/29597/a39723cc-ad23-4efa-b6bd-a5ad30c0c5a7)
1 parent 18d4972 commit db3f422

File tree

13 files changed

+173
-33
lines changed

13 files changed

+173
-33
lines changed

.changeset/pink-lemons-bathe.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"replayio": patch
3+
---
4+
5+
Add "whoami" command to print information about the current user and API key

packages/replayio/src/bin.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { initLogger } from "@replay-cli/shared/logger";
12
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
23
import { setUserAgent } from "@replay-cli/shared/userAgent";
34
import { name, version } from "../package.json";
@@ -14,7 +15,7 @@ import "./commands/remove";
1415
import "./commands/update";
1516
import "./commands/upload";
1617
import "./commands/upload-source-maps";
17-
import { initLogger } from "@replay-cli/shared/logger";
18+
import "./commands/whoami";
1819

1920
initLogger(name, version);
2021

packages/replayio/src/commands/login.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
12
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
23
import { registerCommand } from "../utils/commander/registerCommand";
3-
import { checkAuthentication } from "../utils/initialization/checkAuthentication";
44
import { promptForAuthentication } from "../utils/initialization/promptForAuthentication";
55

66
registerCommand("login").description("Log into your Replay account (or register)").action(login);
77

88
async function login() {
9-
const authenticated = await checkAuthentication();
10-
if (authenticated) {
9+
const { accessToken } = await getAccessToken();
10+
if (accessToken) {
1111
console.log("You are already signed in!");
1212
} else {
1313
await promptForAuthentication();

packages/replayio/src/commands/logout.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ registerCommand("logout").description("Log out of your Replay account").action(l
99
async function logout() {
1010
await logoutIfAuthenticated();
1111

12-
const token = await getAccessToken();
13-
if (token) {
14-
const name = process.env.REPLAY_API_KEY ? "REPLAY_API_KEY" : "RECORD_REPLAY_API_KEY";
15-
12+
const { accessToken, apiKeySource } = await getAccessToken();
13+
if (accessToken && apiKeySource) {
1614
console.log(
17-
`You are now signed out but still authenticated via the ${highlight(name)} env variable`
15+
`You have been signed out but you are still authenticated by the ${highlight(
16+
apiKeySource
17+
)} env variable`
1818
);
1919
} else {
2020
console.log("You are now signed out");

packages/replayio/src/commands/upload-source-maps.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ async function uploadSourceMaps(
4141
root?: string;
4242
}
4343
) {
44+
const { accessToken } = await getAccessToken();
4445
const uploadPromise = uploadSourceMapsExternal({
4546
extensions,
4647
filepaths: filePaths,
4748
group,
4849
ignore,
49-
key: await getAccessToken(),
50+
key: accessToken,
5051
root,
5152
server: replayApiServer,
5253
});
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
2+
import { getAuthInfo } from "@replay-cli/shared/graphql/getAuthInfo";
3+
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
4+
import { dim, emphasize, highlight, link } from "@replay-cli/shared/theme";
5+
import { name as packageName } from "../../package.json";
6+
import { registerCommand } from "../utils/commander/registerCommand";
7+
import { fetchViewerFromGraphQL } from "../utils/graphql/fetchViewerFromGraphQL";
8+
9+
registerCommand("whoami", {
10+
checkForNpmUpdate: false,
11+
checkForRuntimeUpdate: false,
12+
requireAuthentication: false,
13+
})
14+
.description("Display info about the current user")
15+
.action(info);
16+
17+
const DOCS_URL = "https://docs.replay.io/reference/api-keys";
18+
19+
async function info() {
20+
const { accessToken, apiKeySource } = await getAccessToken();
21+
if (accessToken) {
22+
const authInfo = await getAuthInfo(accessToken);
23+
24+
const { userEmail, userName, teamName } = await fetchViewerFromGraphQL(accessToken);
25+
26+
if (apiKeySource) {
27+
console.log(`You are authenticated by API key ${dim(`(process.env.${apiKeySource})`)}`);
28+
console.log("");
29+
if (authInfo.type === "user") {
30+
console.log(`This API key belongs to ${emphasize(userName)} (${userEmail})`);
31+
console.log(`Recordings you upload are ${emphasize("private")} by default`);
32+
} else {
33+
console.log(`This API key belongs to the team named ${emphasize(teamName)}`);
34+
console.log(`Recordings you upload are ${emphasize("shared")} with other team members`);
35+
}
36+
console.log("");
37+
console.log(`Learn more about API keys at ${link(DOCS_URL)}`);
38+
} else {
39+
console.log(`You signed in as ${emphasize(userName)} (${userEmail})`);
40+
console.log("");
41+
console.log(`Recordings you upload are ${emphasize("private")} by default`);
42+
console.log("");
43+
console.log(`Learn about other ways to sign in at ${link(DOCS_URL)}`);
44+
}
45+
} else {
46+
console.log("You are not authenticated");
47+
console.log("");
48+
console.log(`Sign in by running ${highlight(`${packageName} login`)}`);
49+
console.log("");
50+
console.log("You can also authenticate with an API key");
51+
console.log(`Learn more at ${link(DOCS_URL)}`);
52+
}
53+
54+
await exitProcess(0);
55+
}

packages/replayio/src/utils/browser/reportBrowserCrash.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
12
import { getReplayPath } from "@replay-cli/shared/getReplayPath";
23
import { logger } from "@replay-cli/shared/logger";
4+
import { getUserAgent } from "@replay-cli/shared/userAgent";
35
import { readFile, writeFileSync } from "fs-extra";
46
import { File, FormData, fetch } from "undici";
57
import { replayApiServer } from "../../config";
6-
import { getUserAgent } from "@replay-cli/shared/userAgent";
7-
import { checkAuthentication } from "../../utils/initialization/checkAuthentication";
88
import { getCurrentRuntimeMetadata } from "../../utils/initialization/getCurrentRuntimeMetadata";
99
import { runtimeMetadata } from "../../utils/installation/config";
1010
import { findMostRecentFile } from "../findMostRecentFile";
@@ -13,8 +13,7 @@ export async function reportBrowserCrash(stderr: string) {
1313
const errorLogPath = getReplayPath("recorder-crash.log");
1414
writeFileSync(errorLogPath, stderr, "utf8");
1515

16-
const accessToken = await checkAuthentication();
17-
16+
const { accessToken } = await getAccessToken();
1817
if (!accessToken) {
1918
return {
2019
errorLogPath,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { GraphQLError } from "@replay-cli/shared/graphql/GraphQLError";
2+
import { queryGraphQL } from "@replay-cli/shared/graphql/queryGraphQL";
3+
import { logger } from "@replay-cli/shared/logger";
4+
5+
export type AuthInfo = {
6+
userEmail: string | undefined;
7+
userName: string | undefined;
8+
teamName: string | undefined;
9+
};
10+
11+
export async function fetchViewerFromGraphQL(accessToken: string): Promise<AuthInfo> {
12+
logger.debug("Fetching viewer info from GraphQL");
13+
14+
const { data, errors } = await queryGraphQL(
15+
"ViewerInfo",
16+
`
17+
query ViewerInfo {
18+
viewer {
19+
email
20+
user {
21+
name
22+
}
23+
}
24+
auth {
25+
workspaces {
26+
edges {
27+
node {
28+
name
29+
}
30+
}
31+
}
32+
}
33+
}
34+
`,
35+
{},
36+
accessToken
37+
);
38+
39+
if (errors) {
40+
throw new GraphQLError("Failed to fetch auth info", errors);
41+
}
42+
43+
const response = data as {
44+
viewer: {
45+
email: string;
46+
user: {
47+
name: string;
48+
} | null;
49+
};
50+
auth: {
51+
workspaces: {
52+
edges: {
53+
node: {
54+
name: string;
55+
};
56+
}[];
57+
};
58+
};
59+
};
60+
61+
const { viewer, auth } = response;
62+
63+
return {
64+
userEmail: viewer?.email,
65+
userName: viewer?.user?.name,
66+
teamName: auth?.workspaces?.edges?.[0]?.node?.name,
67+
};
68+
}

packages/replayio/src/utils/initialization/checkAuthentication.ts

-5
This file was deleted.

packages/replayio/src/utils/initialization/initialize.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { raceWithTimeout } from "@replay-cli/shared/async/raceWithTimeout";
2+
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
23
import { initLaunchDarklyFromAccessToken } from "@replay-cli/shared/launch-darkly/initLaunchDarklyFromAccessToken";
34
import { initMixpanelForUserSession } from "@replay-cli/shared/mixpanel/initMixpanelForUserSession";
45
import { name as packageName, version as packageVersion } from "../../../package.json";
56
import { logPromise } from "../async/logPromise";
6-
import { checkAuthentication } from "./checkAuthentication";
77
import { checkForNpmUpdate } from "./checkForNpmUpdate";
88
import { checkForRuntimeUpdate } from "./checkForRuntimeUpdate";
99
import { promptForAuthentication } from "./promptForAuthentication";
@@ -22,7 +22,7 @@ export async function initialize({
2222
// These initialization steps can run in parallel to improve startup time
2323
// None of them should log anything though; that would interfere with the initialization-in-progress message
2424
const promises = Promise.all([
25-
checkAuthentication(),
25+
getAccessToken().then(({ accessToken }) => accessToken),
2626
shouldCheckForRuntimeUpdate
2727
? raceWithTimeout(checkForRuntimeUpdate(), 5_000)
2828
: Promise.resolve(),

packages/shared/src/authentication/getAccessToken.ts

+26-10
Original file line numberDiff line numberDiff line change
@@ -6,42 +6,58 @@ import { cachedAuthPath } from "./config";
66
import { refreshAccessTokenOrThrow } from "./refreshAccessTokenOrThrow";
77
import { CachedAuthDetails } from "./types";
88

9-
export async function getAccessToken(): Promise<string | undefined> {
9+
export type AccessTokenInfo = {
10+
accessToken: string | undefined;
11+
apiKeySource: "REPLAY_API_KEY" | "RECORD_REPLAY_API_KEY" | undefined;
12+
};
13+
14+
const NO_ACCESS_TOKEN: AccessTokenInfo = {
15+
accessToken: undefined,
16+
apiKeySource: undefined,
17+
};
18+
19+
export async function getAccessToken(): Promise<AccessTokenInfo> {
1020
if (process.env.REPLAY_API_KEY) {
1121
logger.debug("Using token from env (REPLAY_API_KEY)");
12-
return process.env.REPLAY_API_KEY;
22+
return {
23+
accessToken: process.env.REPLAY_API_KEY,
24+
apiKeySource: "REPLAY_API_KEY",
25+
};
1326
} else if (process.env.RECORD_REPLAY_API_KEY) {
1427
logger.debug("Using token from env (RECORD_REPLAY_API_KEY)");
15-
return process.env.RECORD_REPLAY_API_KEY;
28+
return {
29+
accessToken: process.env.RECORD_REPLAY_API_KEY,
30+
apiKeySource: "RECORD_REPLAY_API_KEY",
31+
};
1632
}
1733

1834
let { accessToken, refreshToken } = readFromCache<CachedAuthDetails>(cachedAuthPath) ?? {};
1935
if (typeof accessToken !== "string") {
2036
logger.debug("Unexpected accessToken value", { accessToken });
21-
return;
37+
return NO_ACCESS_TOKEN;
2238
}
2339
if (typeof refreshToken !== "string") {
2440
logger.debug("Unexpected refreshToken", { refreshToken });
25-
return;
41+
return NO_ACCESS_TOKEN;
2642
}
2743

2844
const [_, encodedToken, __] = accessToken.split(".", 3);
2945
if (typeof encodedToken !== "string") {
3046
logger.debug("Token did not contain a valid payload", { accessToken: maskString(accessToken) });
31-
return;
47+
return NO_ACCESS_TOKEN;
3248
}
3349

3450
let payload: any;
3551
try {
3652
payload = JSON.parse(Buffer.from(encodedToken, "base64").toString());
3753
} catch (error) {
3854
logger.debug("Failed to decode token", { accessToken: maskString(accessToken), error });
39-
return;
55+
return NO_ACCESS_TOKEN;
4056
}
4157

4258
if (typeof payload !== "object") {
4359
logger.debug("Token payload was not an object");
44-
return;
60+
return NO_ACCESS_TOKEN;
4561
}
4662

4763
const expiration = (payload?.exp ?? 0) * 1000;
@@ -57,11 +73,11 @@ export async function getAccessToken(): Promise<string | undefined> {
5773
} catch (error) {
5874
writeToCache(cachedAuthPath, undefined);
5975
updateCachedAuthInfo(accessToken, undefined);
60-
return;
76+
return NO_ACCESS_TOKEN;
6177
}
6278
} else {
6379
logger.debug(`Access token valid until ${expirationDate.toLocaleDateString()}`);
6480
}
6581

66-
return accessToken;
82+
return { accessToken, apiKeySource: undefined };
6783
}

packages/shared/src/graphql/getAuthInfo.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ export type Cached = {
99

1010
export async function getAuthInfo(accessToken: string): Promise<AuthInfo> {
1111
const cached = readFromCache<Cached>(cachePath) ?? {};
12-
let authInfo = cached[accessToken];
1312

13+
let authInfo = cached[accessToken];
1414
if (!authInfo) {
1515
authInfo = await fetchAuthInfoFromGraphQL(accessToken);
1616

packages/shared/src/protocol/ProtocolClient.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export default class ProtocolClient {
153153

154154
private onSocketOpen = async () => {
155155
try {
156-
const accessToken = await getAccessToken();
156+
const { accessToken } = await getAccessToken();
157157
assert(accessToken, "No access token found");
158158

159159
await setAccessToken(this, { accessToken });

0 commit comments

Comments
 (0)