@toloka-platform/sdk 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import { assertCli, TolokaCliError } from "./cli-error";
6
+ import type { CliEnvironment } from "./credentials";
7
+ import {
8
+ requestTokens,
9
+ TokenRequestError,
10
+ } from "./keycloak";
11
+ import type { BrowserLoginProvider, TokenResponse } from "./keycloak";
12
+ import { signInPage, statusPage } from "./sign-in-page";
13
+ import { PLAIN_STYLE } from "./style";
14
+ import type { Style } from "./style";
15
+
16
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
17
+ const LAUNCH_GRACE_MS = 2_000;
18
+ const CALLBACK_PATH = "/callback";
19
+
20
+ export const createPkcePair = (): { readonly verifier: string; readonly challenge: string } => {
21
+ const verifier = randomBytes(32).toString("base64url");
22
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
23
+ return { verifier, challenge };
24
+ };
25
+
26
+ export const authorizationUrl = (request: {
27
+ readonly provider: BrowserLoginProvider;
28
+ readonly redirectUri: string;
29
+ readonly state: string;
30
+ readonly codeChallenge: string;
31
+ readonly identityProviderHint?: string;
32
+ }): string => {
33
+ const url = new URL(request.provider.authorizationEndpoint);
34
+ url.searchParams.set("client_id", request.provider.clientId);
35
+ url.searchParams.set("redirect_uri", request.redirectUri);
36
+ url.searchParams.set("response_type", "code");
37
+ url.searchParams.set("scope", "openid");
38
+ url.searchParams.set("state", request.state);
39
+ url.searchParams.set("code_challenge", request.codeChallenge);
40
+ url.searchParams.set("code_challenge_method", "S256");
41
+ if (request.identityProviderHint !== undefined) {
42
+ url.searchParams.set("kc_idp_hint", request.identityProviderHint);
43
+ }
44
+ return url.toString();
45
+ };
46
+
47
+ export type BrowserLauncher = (url: string) => Promise<boolean>;
48
+
49
+ const launcherCommand = (
50
+ environment: CliEnvironment,
51
+ platform: NodeJS.Platform,
52
+ ): readonly [string, ...string[]] => {
53
+ const configured = environment.BROWSER?.trim();
54
+ if (configured !== undefined && configured.length > 0) return [configured];
55
+ if (platform === "darwin") return ["open"];
56
+ if (platform === "win32") return ["rundll32", "url.dll,FileProtocolHandler"];
57
+ return ["xdg-open"];
58
+ };
59
+
60
+ const launchSystemBrowser = (url: string, environment: CliEnvironment): Promise<boolean> =>
61
+ new Promise((resolve) => {
62
+ const [command, ...prefix] = launcherCommand(environment, process.platform);
63
+ const child = spawn(command, [...prefix, url], { detached: true, stdio: "ignore" });
64
+ const settle = (launched: boolean) => {
65
+ clearTimeout(grace);
66
+ child.unref();
67
+ resolve(launched);
68
+ };
69
+ const grace = setTimeout(() => settle(true), LAUNCH_GRACE_MS);
70
+ child.once("error", () => settle(false));
71
+ child.once("exit", (code) => settle(code === 0));
72
+ });
73
+
74
+ const RETRY_HINT = {
75
+ description: "Run toloka login again and finish the sign-in in the tab it opens.",
76
+ };
77
+
78
+ export type LoopbackServer = {
79
+ readonly origin: string;
80
+ readonly redirectUri: string;
81
+ readonly authorizationCode: Promise<string>;
82
+ readonly close: () => void;
83
+ };
84
+
85
+ export const startLoopbackServer = async (
86
+ state: string,
87
+ timeoutMs: number,
88
+ chooser?: (redirectUri: string) => string,
89
+ ): Promise<LoopbackServer> => {
90
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
91
+ let host = "";
92
+ let redirectUri = "";
93
+ const server = createServer((request, response) => {
94
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
95
+ const reply = (status: number, html: string) => {
96
+ response.writeHead(status, {
97
+ "Content-Type": "text/html; charset=utf-8",
98
+ "Cache-Control": "no-store",
99
+ Connection: "close",
100
+ });
101
+ response.end(html);
102
+ };
103
+ if (request.headers.host !== host) {
104
+ reply(400, statusPage("Unexpected address", "This page is only served on the loopback address the Toloka CLI opened."));
105
+ return;
106
+ }
107
+ if (url.pathname === "/" && chooser !== undefined) {
108
+ reply(200, chooser(redirectUri));
109
+ return;
110
+ }
111
+ if (url.pathname !== CALLBACK_PATH) {
112
+ reply(404, statusPage("Not found", "This address only serves the Toloka CLI sign-in."));
113
+ return;
114
+ }
115
+ if (url.searchParams.get("state") !== state) {
116
+ reply(400, statusPage("Sign-in mismatch", "This response belongs to another sign-in attempt."));
117
+ return;
118
+ }
119
+ const error = url.searchParams.get("error");
120
+ if (error !== null) {
121
+ const description = url.searchParams.get("error_description") ?? error;
122
+ reply(400, statusPage("Sign-in failed", description));
123
+ reject(
124
+ new TolokaCliError(`The browser sign-in was not completed: ${description}`, {
125
+ hint: RETRY_HINT,
126
+ }),
127
+ );
128
+ return;
129
+ }
130
+ const code = url.searchParams.get("code");
131
+ if (code === null || code.length === 0) {
132
+ reply(400, statusPage("Sign-in failed", "The response carried no authorization code."));
133
+ reject(
134
+ new TolokaCliError("The browser returned no authorization code.", { hint: RETRY_HINT }),
135
+ );
136
+ return;
137
+ }
138
+ reply(200, statusPage("You are signed in", "You can close this tab and return to the terminal."));
139
+ resolve(code);
140
+ });
141
+
142
+ await new Promise<void>((ready, failed) => {
143
+ server.once("error", failed);
144
+ server.listen(0, "127.0.0.1", () => ready());
145
+ });
146
+ const { port } = server.address() as AddressInfo;
147
+ host = `127.0.0.1:${port}`;
148
+ const origin = `http://${host}`;
149
+ redirectUri = `${origin}${CALLBACK_PATH}`;
150
+ const minutes = Math.max(1, Math.round(timeoutMs / 60_000));
151
+ const timer = setTimeout(() => {
152
+ reject(
153
+ new TolokaCliError(
154
+ `Timed out after ${minutes} minute${minutes === 1 ? "" : "s"} waiting for the browser sign-in.`,
155
+ { hint: RETRY_HINT },
156
+ ),
157
+ );
158
+ }, timeoutMs);
159
+ const close = () => {
160
+ clearTimeout(timer);
161
+ server.close();
162
+ server.closeIdleConnections();
163
+ };
164
+ promise.then(close, close);
165
+
166
+ return { origin, redirectUri, authorizationCode: promise, close };
167
+ };
168
+
169
+ export type BrowserLoginRequest = {
170
+ readonly provider: BrowserLoginProvider;
171
+ readonly openBrowser: boolean;
172
+ readonly requireBrowser?: boolean;
173
+ readonly registrationUrl?: string;
174
+ readonly style?: Style;
175
+ readonly write: (text: string) => void;
176
+ };
177
+
178
+ export type BrowserLoginDependencies = {
179
+ readonly environment?: CliEnvironment;
180
+ readonly fetch?: typeof globalThis.fetch;
181
+ readonly launchBrowser?: BrowserLauncher;
182
+ readonly timeoutMs?: number;
183
+ };
184
+
185
+ export type BrowserLoginFlow = (
186
+ request: BrowserLoginRequest,
187
+ dependencies?: BrowserLoginDependencies,
188
+ ) => Promise<TokenResponse>;
189
+
190
+ const openSignIn = async (
191
+ request: BrowserLoginRequest,
192
+ url: string,
193
+ dependencies: BrowserLoginDependencies,
194
+ ): Promise<void> => {
195
+ const launch =
196
+ dependencies.launchBrowser ??
197
+ ((target: string) => launchSystemBrowser(target, dependencies.environment ?? process.env));
198
+ const launched = request.openBrowser && (await launch(url));
199
+ assertCli(
200
+ launched || request.requireBrowser !== true,
201
+ "No browser could be opened and there is no terminal to fall back to.",
202
+ {
203
+ hint: {
204
+ description:
205
+ "Run toloka login where a browser is available, pass --no-browser to get a link you can open elsewhere, or pipe an API key to toloka login --api-key-stdin.",
206
+ },
207
+ },
208
+ );
209
+ const style = request.style ?? PLAIN_STYLE;
210
+ request.write(
211
+ launched
212
+ ? style.dim(
213
+ "Waiting for the sign-in to finish in your browser... If no browser opened, run toloka login --no-browser to get a link.",
214
+ )
215
+ : `Open this link in your browser to sign in:\n\n ${style.cyan(url)}\n\n${style.dim("Waiting for the sign-in to finish...")}`,
216
+ );
217
+ };
218
+
219
+ export const loginWithBrowser: BrowserLoginFlow = async (request, dependencies = {}) => {
220
+ const pkce = createPkcePair();
221
+ const state = randomBytes(16).toString("base64url");
222
+ const authorizationUrlFor = (redirectUri: string, hint: string | undefined) =>
223
+ authorizationUrl({
224
+ provider: request.provider,
225
+ redirectUri,
226
+ state,
227
+ codeChallenge: pkce.challenge,
228
+ ...(hint === undefined ? {} : { identityProviderHint: hint }),
229
+ });
230
+ const server = await startLoopbackServer(
231
+ state,
232
+ dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS,
233
+ (redirectUri) =>
234
+ signInPage(
235
+ request.provider.identityProviders,
236
+ (hint) => authorizationUrlFor(redirectUri, hint),
237
+ request.registrationUrl,
238
+ ),
239
+ );
240
+
241
+ try {
242
+ await openSignIn(request, `${server.origin}/`, dependencies);
243
+ const code = await server.authorizationCode;
244
+ return await requestTokens(
245
+ request.provider,
246
+ {
247
+ grant_type: "authorization_code",
248
+ code,
249
+ redirect_uri: server.redirectUri,
250
+ code_verifier: pkce.verifier,
251
+ },
252
+ dependencies.fetch,
253
+ );
254
+ } catch (error) {
255
+ if (error instanceof TokenRequestError) {
256
+ throw new TolokaCliError(`The login service rejected the browser sign-in: ${error.message}`, {
257
+ hint: RETRY_HINT,
258
+ cause: error,
259
+ });
260
+ }
261
+ throw error;
262
+ } finally {
263
+ server.close();
264
+ }
265
+ };
@@ -0,0 +1,244 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { TolokaCliError } from "./cli-error";
6
+ import { preparePrivateFilePath } from "./credentials";
7
+ import type { CliEnvironment } from "./credentials";
8
+ import { browserLoginProviderFor, requestTokens, TokenRequestError } from "./keycloak";
9
+ import type { BrowserLoginProvider, TokenResponse } from "./keycloak";
10
+
11
+ export type BrowserSession = {
12
+ readonly version: 1;
13
+ readonly issuer: string;
14
+ readonly client_id: string;
15
+ readonly token_endpoint: string;
16
+ readonly access_token: string;
17
+ readonly access_token_expires_at: number;
18
+ readonly refresh_token: string;
19
+ readonly refresh_token_expires_at: number;
20
+ readonly scope: string;
21
+ readonly obtained_at: number;
22
+ };
23
+
24
+ export type BrowserLogin = {
25
+ readonly provider: BrowserLoginProvider;
26
+ readonly session: BrowserSession;
27
+ readonly path: string;
28
+ };
29
+
30
+ const ACCESS_TOKEN_LEEWAY_SECONDS = 60;
31
+
32
+ export const unixSeconds = (): number => Math.floor(Date.now() / 1000);
33
+
34
+ const browserSessionsDirectory = (environment: CliEnvironment): string =>
35
+ environment.TOLOKA_CLI_CREDENTIALS_DIR ??
36
+ (environment.TOLOKA_CREDENTIALS_FILE === undefined
37
+ ? join(environment.HOME ?? homedir(), ".toloka", "cli", "credentials")
38
+ : join(dirname(environment.TOLOKA_CREDENTIALS_FILE), "browser-sessions"));
39
+
40
+ export const browserSessionPath = (
41
+ provider: BrowserLoginProvider,
42
+ environment: CliEnvironment = process.env,
43
+ ): string =>
44
+ join(
45
+ browserSessionsDirectory(environment),
46
+ provider.authHost,
47
+ provider.realm,
48
+ `${provider.clientId}.json`,
49
+ );
50
+
51
+ export const browserSessionFromTokens = (
52
+ provider: BrowserLoginProvider,
53
+ tokens: TokenResponse,
54
+ obtainedAt: number = unixSeconds(),
55
+ ): BrowserSession => ({
56
+ version: 1,
57
+ issuer: provider.issuer,
58
+ client_id: provider.clientId,
59
+ token_endpoint: provider.tokenEndpoint,
60
+ access_token: tokens.access_token,
61
+ access_token_expires_at: obtainedAt + tokens.expires_in,
62
+ refresh_token: tokens.refresh_token,
63
+ refresh_token_expires_at:
64
+ tokens.refresh_expires_in > 0 ? obtainedAt + tokens.refresh_expires_in : 0,
65
+ scope: tokens.scope,
66
+ obtained_at: obtainedAt,
67
+ });
68
+
69
+ const readField = (payload: unknown, key: string): unknown =>
70
+ typeof payload === "object" && payload !== null ? Reflect.get(payload, key) : undefined;
71
+
72
+ const nonEmptyString = (value: unknown): value is string =>
73
+ typeof value === "string" && value.length > 0;
74
+
75
+ const finiteNumber = (value: unknown): value is number =>
76
+ typeof value === "number" && Number.isFinite(value);
77
+
78
+ const asBrowserSession = (
79
+ payload: unknown,
80
+ provider: BrowserLoginProvider,
81
+ ): BrowserSession | undefined => {
82
+ if (readField(payload, "version") !== 1) return undefined;
83
+ const issuer = readField(payload, "issuer");
84
+ const clientId = readField(payload, "client_id");
85
+ if (issuer !== provider.issuer || clientId !== provider.clientId) return undefined;
86
+ const tokenEndpoint = readField(payload, "token_endpoint");
87
+ const accessToken = readField(payload, "access_token");
88
+ const accessTokenExpiresAt = readField(payload, "access_token_expires_at");
89
+ const refreshToken = readField(payload, "refresh_token");
90
+ const refreshTokenExpiresAt = readField(payload, "refresh_token_expires_at");
91
+ const scope = readField(payload, "scope");
92
+ const obtainedAt = readField(payload, "obtained_at");
93
+ if (!nonEmptyString(tokenEndpoint) || !nonEmptyString(accessToken)) return undefined;
94
+ if (!nonEmptyString(refreshToken) || typeof scope !== "string") return undefined;
95
+ if (!finiteNumber(accessTokenExpiresAt) || !finiteNumber(refreshTokenExpiresAt)) return undefined;
96
+ if (!finiteNumber(obtainedAt)) return undefined;
97
+ return {
98
+ version: 1,
99
+ issuer,
100
+ client_id: clientId,
101
+ token_endpoint: tokenEndpoint,
102
+ access_token: accessToken,
103
+ access_token_expires_at: accessTokenExpiresAt,
104
+ refresh_token: refreshToken,
105
+ refresh_token_expires_at: refreshTokenExpiresAt,
106
+ scope,
107
+ obtained_at: obtainedAt,
108
+ };
109
+ };
110
+
111
+ export async function readBrowserSession(
112
+ provider: BrowserLoginProvider,
113
+ environment: CliEnvironment = process.env,
114
+ ): Promise<BrowserSession | undefined> {
115
+ let raw: string;
116
+ try {
117
+ raw = await readFile(browserSessionPath(provider, environment), "utf8");
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ try {
122
+ return asBrowserSession(JSON.parse(raw), provider);
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ export async function writeBrowserSession(
129
+ session: BrowserSession,
130
+ provider: BrowserLoginProvider,
131
+ environment: CliEnvironment = process.env,
132
+ ): Promise<string> {
133
+ const path = browserSessionPath(provider, environment);
134
+ await preparePrivateFilePath(path);
135
+ const temporary = `${path}.${randomUUID()}.tmp`;
136
+ try {
137
+ await writeFile(temporary, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600, flag: "wx" });
138
+ await rename(temporary, path);
139
+ } catch (error) {
140
+ await rm(temporary, { force: true });
141
+ throw error;
142
+ }
143
+ return path;
144
+ }
145
+
146
+ export async function removeBrowserSession(
147
+ provider: BrowserLoginProvider,
148
+ environment: CliEnvironment = process.env,
149
+ ): Promise<string | undefined> {
150
+ const path = browserSessionPath(provider, environment);
151
+ try {
152
+ await rm(path);
153
+ return path;
154
+ } catch (error) {
155
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ export async function findBrowserLogin(
161
+ baseUrl: string,
162
+ environment: CliEnvironment = process.env,
163
+ ): Promise<BrowserLogin | undefined> {
164
+ const provider = browserLoginProviderFor(baseUrl);
165
+ if (provider === undefined) return undefined;
166
+ const session = await readBrowserSession(provider, environment);
167
+ if (session === undefined) return undefined;
168
+ return { provider, session, path: browserSessionPath(provider, environment) };
169
+ }
170
+
171
+ const isAccessTokenFresh = (session: BrowserSession, now: number): boolean =>
172
+ session.access_token_expires_at - ACCESS_TOKEN_LEEWAY_SECONDS > now;
173
+
174
+ export const isRefreshTokenExpired = (
175
+ session: BrowserSession,
176
+ now: number = unixSeconds(),
177
+ ): boolean => session.refresh_token_expires_at > 0 && session.refresh_token_expires_at <= now;
178
+
179
+ export class BrowserLoginExpiredError extends TolokaCliError {
180
+ constructor(baseUrl: string, cause?: unknown) {
181
+ super(`Your browser login for ${baseUrl} has expired.`, {
182
+ hint: { description: "Run toloka login to sign in again." },
183
+ ...(cause === undefined ? {} : { cause }),
184
+ });
185
+ this.name = "BrowserLoginExpiredError";
186
+ }
187
+ }
188
+
189
+ export const createBrowserSessionAuth = ({
190
+ provider,
191
+ baseUrl,
192
+ environment = process.env,
193
+ fetch: fetchImplementation = globalThis.fetch,
194
+ now = unixSeconds,
195
+ }: {
196
+ readonly provider: BrowserLoginProvider;
197
+ readonly baseUrl: string;
198
+ readonly environment?: CliEnvironment;
199
+ readonly fetch?: typeof globalThis.fetch;
200
+ readonly now?: () => number;
201
+ }): (() => Promise<string>) => {
202
+ let pending: Promise<string> | undefined;
203
+
204
+ const refresh = async (session: BrowserSession): Promise<BrowserSession> => {
205
+ if (isRefreshTokenExpired(session, now())) throw new BrowserLoginExpiredError(baseUrl);
206
+ let tokens: TokenResponse;
207
+ try {
208
+ tokens = await requestTokens(
209
+ provider,
210
+ { grant_type: "refresh_token", refresh_token: session.refresh_token },
211
+ fetchImplementation,
212
+ );
213
+ } catch (error) {
214
+ if (error instanceof TokenRequestError && error.oauthError === "invalid_grant") {
215
+ throw new BrowserLoginExpiredError(baseUrl, error);
216
+ }
217
+ const reason = error instanceof Error ? error.message : String(error);
218
+ throw new TolokaCliError(`Could not renew your browser login for ${baseUrl}: ${reason}`, {
219
+ hint: { description: "Check network access and retry; run toloka login if it keeps failing." },
220
+ cause: error,
221
+ });
222
+ }
223
+ const renewed = browserSessionFromTokens(provider, tokens, now());
224
+ await writeBrowserSession(renewed, provider, environment);
225
+ return renewed;
226
+ };
227
+
228
+ const resolve = async (): Promise<string> => {
229
+ const session = await readBrowserSession(provider, environment);
230
+ if (session === undefined) {
231
+ throw new TolokaCliError(`Not logged in to ${baseUrl}.`, {
232
+ hint: { description: "Run toloka login." },
233
+ });
234
+ }
235
+ return (isAccessTokenFresh(session, now()) ? session : await refresh(session)).access_token;
236
+ };
237
+
238
+ return () => {
239
+ pending ??= resolve().finally(() => {
240
+ pending = undefined;
241
+ });
242
+ return pending;
243
+ };
244
+ };
@@ -1,56 +1,66 @@
1
1
  import type { Command } from "commander";
2
- import { createClient } from "../../client";
3
2
  import { authStatus, login, logout } from "../auth";
4
3
  import {
5
4
  identifyClientUser,
5
+ resolveClient,
6
6
  resolveSelectedProject,
7
7
  resolveSourceRoot,
8
8
  type CommandHost,
9
9
  } from "../context";
10
10
  import { findCommandCredentials, readStoredCredentials } from "../credentials";
11
- import { resolveTolokaConfig } from "../config";
12
11
  import { readTolokaState } from "../../state";
13
12
 
13
+ type LoginCommandOptions = {
14
+ readonly apiKeyStdin: boolean;
15
+ readonly browser?: boolean;
16
+ };
17
+
14
18
  export const registerAuthCommands = (program: Command, host: CommandHost): void => {
15
19
  const { context, exitCode, globals } = host;
16
20
 
17
21
  program
18
22
  .command("login")
19
- .description("store an API key for this machine after verifying it")
20
- .option("--api-key-stdin", "read the API key from standard input instead of prompting", false)
21
- .action(async (options: { apiKeyStdin: boolean }) => {
23
+ .description("sign in through your browser, or store an API key, after verifying access")
24
+ .option("--browser", "sign in through your browser without asking; pick the provider on the page it opens")
25
+ .option("--no-browser", "the same, but print the sign-in link instead of opening a browser")
26
+ .option("--api-key-stdin", "store an API key read from standard input instead", false)
27
+ .action(async (options: LoginCommandOptions) => {
22
28
  const apiKey = options.apiKeyStdin ? (await context.io.readStdin()).trim() : globals().apiKey;
23
29
  const injectedClient = context.injectedClient;
24
30
  const result = await login({
25
31
  ...(apiKey === undefined ? {} : { apiKey }),
32
+ ...(options.browser === undefined ? {} : { browser: options.browser }),
26
33
  ...(globals().baseUrl === undefined ? {} : { baseUrl: globals().baseUrl }),
27
34
  ...(context.io.prompt === undefined ? {} : { prompt: context.io.prompt }),
35
+ ...(context.io.choose === undefined ? {} : { choose: context.io.choose }),
28
36
  ...(injectedClient === undefined ? {} : { probe: () => injectedClient }),
37
+ ...(context.browserLogin === undefined ? {} : { browserLogin: context.browserLogin }),
38
+ ...(context.io.style === undefined ? {} : { style: context.io.style }),
29
39
  write: context.io.stdout,
30
40
  environment: context.environment,
31
41
  });
32
42
  context.io.stdout(result.output);
33
43
  try {
34
44
  const stored = await readStoredCredentials(context.environment);
35
- if (stored === undefined) return;
36
- const client = context.injectedClient ?? createClient(stored);
45
+ const { client } = await resolveClient(globals(), context, undefined, stored);
37
46
  await identifyClientUser(client, context.analytics);
38
47
  } catch {}
39
48
  });
40
49
 
41
50
  program
42
51
  .command("logout")
43
- .description("remove stored credentials from this machine")
52
+ .description("remove stored credentials from this machine and end the browser login")
44
53
  .action(async () => {
45
- const result = await logout(context.environment);
54
+ const baseUrl = globals().baseUrl;
55
+ const result = await logout(context.environment, baseUrl === undefined ? {} : { baseUrl });
46
56
  context.io.stdout(result.output);
47
57
  });
48
58
 
49
59
  program
50
60
  .command("auth-status")
51
61
  .alias("whoami")
52
- .description("show which platform and key the CLI would use")
53
- .option("--show-token", "print the API key instead of masking it", false)
62
+ .description("show which platform and login the CLI would use")
63
+ .option("--show-token", "print the API key or access token instead of masking it", false)
54
64
  .action(async (options: { showToken: boolean }) => {
55
65
  const flags = globals();
56
66
  const credentialLookup = await findCommandCredentials(
@@ -79,10 +89,7 @@ export const registerAuthCommands = (program: Command, host: CommandHost): void
79
89
  return;
80
90
  }
81
91
  try {
82
- const config = resolveTolokaConfig(flags, state, context.environment, stored);
83
- const client =
84
- context.injectedClient ??
85
- createClient({ apiKey: config.apiKey, baseUrl: config.baseUrl });
92
+ const { client } = await resolveClient(flags, context, state, stored);
86
93
  await identifyClientUser(client, context.analytics);
87
94
  } catch {}
88
95
  });
@@ -1,7 +1,7 @@
1
1
  import type { Command } from "commander";
2
- import { createClient } from "../../client";
3
2
  import {
4
3
  identifyClientUser,
4
+ resolveClient,
5
5
  positiveInteger,
6
6
  readCommandCredentials,
7
7
  resolveContext,
@@ -42,7 +42,7 @@ export const registerProjectCommands = (program: Command, host: CommandHost): vo
42
42
  .action(async (alias?: string, projectId?: string) => {
43
43
  const flags = globals();
44
44
  const found = await requireStoredCredentials(context.environment);
45
- const client = context.injectedClient ?? createClient(found.credentials);
45
+ const { client } = await resolveClient(flags, context, undefined, found.credentials);
46
46
  await addProject({
47
47
  environment: context.environment,
48
48
  flags,
@@ -147,7 +147,7 @@ export const registerProjectCommands = (program: Command, host: CommandHost): vo
147
147
  ) => {
148
148
  const flags = globals();
149
149
  const found = await requireStoredCredentials(context.environment);
150
- const client = context.injectedClient ?? createClient(found.credentials);
150
+ const { client } = await resolveClient(flags, context, undefined, found.credentials);
151
151
  const sourceRoot = await resolveSourceRoot(flags, context.io);
152
152
  await createProject({
153
153
  environment: context.environment,