@toloka-platform/sdk 0.2.0 → 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.
Files changed (62) hide show
  1. package/README.md +12 -6
  2. package/dist/cli/toloka.js +269 -142
  3. package/dist/cli/toloka.js.jsc +0 -0
  4. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  5. package/dist/cli/toloka.js.sha256 +1 -1
  6. package/docs/authoring.md +2 -0
  7. package/docs/cli.md +86 -23
  8. package/docs/getting-started.md +11 -9
  9. package/docs/limitations.md +6 -0
  10. package/docs/testing-examples.md +2 -2
  11. package/docs/testing.md +10 -2
  12. package/docs/troubleshooting.md +16 -4
  13. package/openapi/gen/api.ts +30 -0
  14. package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +1 -1
  15. package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +1 -1
  16. package/openapi/gen/models/index.ts +3 -0
  17. package/openapi/gen/models/listAudienceSpecializations200.ts +7 -0
  18. package/openapi/gen/models/listAudienceSpecializationsParams.ts +5 -0
  19. package/openapi/gen/models/specialization.ts +6 -0
  20. package/openapi/gen/models/trainingCostEstimateOneOfTwoseven.ts +0 -2
  21. package/openapi/gen/models/trainingRunQuote.ts +0 -2
  22. package/package.json +1 -1
  23. package/skills/catalog.json +10 -10
  24. package/skills/toloka-build/SKILL.md +8 -5
  25. package/skills/toloka-build/references/node-semantics.md +2 -2
  26. package/skills/toloka-build/references/workflow-and-cli.md +50 -25
  27. package/skills/toloka-fine-tuning/SKILL.md +3 -2
  28. package/skills/toloka-get-started/SKILL.md +4 -3
  29. package/skills/toloka-gisting/SKILL.md +4 -3
  30. package/skills/toloka-report-issue/references/report-template.md +1 -1
  31. package/src/checks/task-ui-typecheck.ts +4 -1
  32. package/src/cli/analytics.ts +1 -0
  33. package/src/cli/audience.ts +31 -0
  34. package/src/cli/auth.ts +382 -81
  35. package/src/cli/auto-update.ts +363 -24
  36. package/src/cli/browser-login.ts +265 -0
  37. package/src/cli/browser-session.ts +244 -0
  38. package/src/cli/check.ts +51 -52
  39. package/src/cli/cli-entry.ts +31 -0
  40. package/src/cli/commands/audience.ts +22 -0
  41. package/src/cli/commands/auth.ts +22 -15
  42. package/src/cli/commands/project.ts +3 -3
  43. package/src/cli/commands/training.ts +10 -2
  44. package/src/cli/config.ts +41 -14
  45. package/src/cli/context.ts +45 -9
  46. package/src/cli/credentials.ts +13 -13
  47. package/src/cli/init.ts +7 -2
  48. package/src/cli/keycloak.ts +189 -0
  49. package/src/cli/models.ts +1 -21
  50. package/src/cli/native-runtime.ts +7 -0
  51. package/src/cli/program.ts +5 -0
  52. package/src/cli/sign-in-page.ts +153 -0
  53. package/src/cli/skill.ts +34 -26
  54. package/src/cli/style.ts +37 -0
  55. package/src/cli/table.ts +21 -0
  56. package/src/cli/terminal-io.ts +9 -2
  57. package/src/cli/trainings.ts +1 -22
  58. package/src/cli/version.ts +12 -1
  59. package/src/client.ts +20 -3
  60. package/src/index.ts +1 -0
  61. package/src/pipeline-graph-v1.contract.json +47 -24
  62. package/src/transport.ts +19 -11
@@ -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
+ };
package/src/cli/check.ts CHANGED
@@ -23,7 +23,8 @@ import {
23
23
  type TestEvidence,
24
24
  type TestEvidenceKind,
25
25
  } from "../testing/test-evidence";
26
- import { resolveCliEntry } from "./cli-entry";
26
+ import { cliRelaunchCommand } from "./cli-entry";
27
+ import { bunSubprocessEnvironment } from "./native-runtime";
27
28
  import { API_KEY_ENVIRONMENT_NAMES } from "./environment";
28
29
  import { loadProjectSource, type ProjectSource } from "./source";
29
30
  import { assertCli, REPORT_HINT, TolokaCliError } from "./cli-error";
@@ -160,16 +161,15 @@ export const collectOutput = async (stream: NodeJS.ReadableStream): Promise<stri
160
161
  return truncated ? `${output}\n…output truncated` : output;
161
162
  };
162
163
 
163
- const runModule = async (
164
+ const runCommand = async (
164
165
  sourceRoot: string,
165
- modulePath: string,
166
+ executable: string,
166
167
  args: readonly string[],
167
- environment: NodeJS.ProcessEnv = process.env,
168
+ environment: NodeJS.ProcessEnv,
168
169
  signal?: AbortSignal,
169
- executable: string = process.execPath,
170
170
  ): Promise<ToolRunResult> => {
171
171
  signal?.throwIfAborted();
172
- const child = spawn(executable, [modulePath, ...args], {
172
+ const child = spawn(executable, args, {
173
173
  cwd: sourceRoot,
174
174
  env: environment,
175
175
  stdio: ["ignore", "pipe", "pipe"],
@@ -208,11 +208,11 @@ const runTypeScript = async (
208
208
  signal?: AbortSignal,
209
209
  ): Promise<ToolRunResult> => {
210
210
  try {
211
- return await runModule(
211
+ return await runCommand(
212
212
  sourceRoot,
213
- resolveProjectModule(sourceRoot, "typescript", "bin/tsc"),
214
- ["--noEmit"],
215
- environment,
213
+ process.execPath,
214
+ [resolveProjectModule(sourceRoot, "typescript", "bin/tsc"), "--noEmit"],
215
+ bunSubprocessEnvironment(environment),
216
216
  signal,
217
217
  );
218
218
  } catch (error) {
@@ -247,11 +247,15 @@ const runVitest = async ({
247
247
  ? typedTestNamePattern(fingerprint, environment[TEST_NODE_FILTER_ENV])
248
248
  : undefined;
249
249
  return classifyVitestRun(
250
- await runModule(
250
+ await runCommand(
251
251
  sourceRoot,
252
- resolveProjectModule(sourceRoot, "vitest", "vitest.mjs"),
253
- ["run", ...(focusedPattern === undefined ? [] : ["--testNamePattern", focusedPattern])],
254
- environment,
252
+ process.execPath,
253
+ [
254
+ resolveProjectModule(sourceRoot, "vitest", "vitest.mjs"),
255
+ "run",
256
+ ...(focusedPattern === undefined ? [] : ["--testNamePattern", focusedPattern]),
257
+ ],
258
+ bunSubprocessEnvironment(environment),
255
259
  signal,
256
260
  ),
257
261
  );
@@ -804,11 +808,9 @@ const parseFreshCheckDiagnostic = (value: unknown): CheckDiagnostic => {
804
808
  const line = Reflect.get(value, "line");
805
809
  const column = Reflect.get(value, "column");
806
810
  const code = Reflect.get(value, "code");
807
- assertCli(
808
- typeof message === "string",
809
- "Fresh check diagnostic has no message",
810
- { hint: REPORT_HINT },
811
- );
811
+ assertCli(typeof message === "string", "Fresh check diagnostic has no message", {
812
+ hint: REPORT_HINT,
813
+ });
812
814
  assertCli(
813
815
  severity === undefined || severity === "error" || severity === "warning",
814
816
  "Fresh check diagnostic has invalid severity",
@@ -845,11 +847,9 @@ const parseFreshCheckDiagnostic = (value: unknown): CheckDiagnostic => {
845
847
  };
846
848
 
847
849
  const parseFreshCheckResultItem = (value: unknown): CheckResult => {
848
- assertCli(
849
- typeof value === "object" && value !== null,
850
- "Fresh check returned an invalid result",
851
- { hint: REPORT_HINT },
852
- );
850
+ assertCli(typeof value === "object" && value !== null, "Fresh check returned an invalid result", {
851
+ hint: REPORT_HINT,
852
+ });
853
853
  const scope = Reflect.get(value, "scope");
854
854
  const kind = Reflect.get(value, "kind");
855
855
  const status = Reflect.get(value, "status");
@@ -861,11 +861,9 @@ const parseFreshCheckResultItem = (value: unknown): CheckResult => {
861
861
  assertCli(isCheckScope(scope), "Fresh check returned an invalid scope", { hint: REPORT_HINT });
862
862
  assertCli(isCheckKind(kind), "Fresh check returned an invalid kind", { hint: REPORT_HINT });
863
863
  assertCli(isCheckStatus(status), "Fresh check returned an invalid status", { hint: REPORT_HINT });
864
- assertCli(
865
- Array.isArray(diagnostics),
866
- "Fresh check returned invalid diagnostics",
867
- { hint: REPORT_HINT },
868
- );
864
+ assertCli(Array.isArray(diagnostics), "Fresh check returned invalid diagnostics", {
865
+ hint: REPORT_HINT,
866
+ });
869
867
  assertCli(
870
868
  sourceDirectory === undefined || typeof sourceDirectory === "string",
871
869
  "Fresh check returned an invalid source directory",
@@ -899,11 +897,9 @@ const parseFreshCheckResultItem = (value: unknown): CheckResult => {
899
897
  };
900
898
 
901
899
  const parseFreshCheckResult = (value: unknown, json: boolean): CheckProjectResult => {
902
- assertCli(
903
- typeof value === "object" && value !== null,
904
- "Fresh check returned invalid JSON",
905
- { hint: REPORT_HINT },
906
- );
900
+ assertCli(typeof value === "object" && value !== null, "Fresh check returned invalid JSON", {
901
+ hint: REPORT_HINT,
902
+ });
907
903
  const exitCode = Reflect.get(value, "exitCode");
908
904
  const rawResults = Reflect.get(value, "results");
909
905
  assertCli(
@@ -973,30 +969,33 @@ export const readFreshCheckResult = async (
973
969
  }
974
970
  };
975
971
  export const runFreshCheck = async (options: CheckProjectOptions): Promise<CheckProjectResult> => {
976
- const entry = resolveCliEntry();
977
972
  const resultDirectory = await mkdtemp(join(tmpdir(), "toloka-check-watch-result-"));
978
973
  const resultPath = join(resultDirectory, "result.json");
974
+ const environment = {
975
+ ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }),
976
+ ...(options.environment ?? process.env),
977
+ [CHECK_RESULT_FILE_ENV]: resultPath,
978
+ };
979
+ const command = cliRelaunchCommand(
980
+ [
981
+ "--source-root",
982
+ options.sourceRoot,
983
+ "check",
984
+ ...(options.pipeline === undefined ? [] : [options.pipeline]),
985
+ ...(options.node === undefined ? [] : ["--node", options.node]),
986
+ "--json",
987
+ ],
988
+ environment,
989
+ );
979
990
  try {
980
991
  let run: ToolRunResult;
981
992
  try {
982
- run = await runModule(
993
+ run = await runCommand(
983
994
  options.sourceRoot,
984
- entry,
985
- [
986
- "--source-root",
987
- options.sourceRoot,
988
- "check",
989
- ...(options.pipeline === undefined ? [] : [options.pipeline]),
990
- ...(options.node === undefined ? [] : ["--node", options.node]),
991
- "--json",
992
- ],
993
- {
994
- ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }),
995
- ...(options.environment ?? process.env),
996
- [CHECK_RESULT_FILE_ENV]: resultPath,
997
- },
995
+ command.executable,
996
+ command.args,
997
+ command.environment,
998
998
  options.signal,
999
- "bun",
1000
999
  );
1001
1000
  } catch (error) {
1002
1001
  throw new FreshCheckProcessError(
@@ -1004,7 +1003,7 @@ export const runFreshCheck = async (options: CheckProjectOptions): Promise<Check
1004
1003
  `Unable to start the local check process: ${error instanceof Error ? error.message : String(error)}`,
1005
1004
  {
1006
1005
  cause: error,
1007
- hint: { description: "Check that bun is installed and on PATH, then re-run toloka check." },
1006
+ hint: { description: "Reinstall the Toloka CLI, then re-run toloka check." },
1008
1007
  },
1009
1008
  );
1010
1009
  }