@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.
- package/README.md +12 -6
- package/dist/cli/toloka.js +269 -142
- package/dist/cli/toloka.js.jsc +0 -0
- package/dist/cli/toloka.js.jsc.sha256 +1 -1
- package/dist/cli/toloka.js.sha256 +1 -1
- package/docs/authoring.md +2 -0
- package/docs/cli.md +86 -23
- package/docs/getting-started.md +11 -9
- package/docs/limitations.md +6 -0
- package/docs/testing-examples.md +2 -2
- package/docs/testing.md +10 -2
- package/docs/troubleshooting.md +16 -4
- package/openapi/gen/api.ts +30 -0
- package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +1 -1
- package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +1 -1
- package/openapi/gen/models/index.ts +3 -0
- package/openapi/gen/models/listAudienceSpecializations200.ts +7 -0
- package/openapi/gen/models/listAudienceSpecializationsParams.ts +5 -0
- package/openapi/gen/models/specialization.ts +6 -0
- package/openapi/gen/models/trainingCostEstimateOneOfTwoseven.ts +0 -2
- package/openapi/gen/models/trainingRunQuote.ts +0 -2
- package/package.json +1 -1
- package/skills/catalog.json +10 -10
- package/skills/toloka-build/SKILL.md +8 -5
- package/skills/toloka-build/references/node-semantics.md +2 -2
- package/skills/toloka-build/references/workflow-and-cli.md +50 -25
- package/skills/toloka-fine-tuning/SKILL.md +3 -2
- package/skills/toloka-get-started/SKILL.md +4 -3
- package/skills/toloka-gisting/SKILL.md +4 -3
- package/skills/toloka-report-issue/references/report-template.md +1 -1
- package/src/checks/task-ui-typecheck.ts +4 -1
- package/src/cli/analytics.ts +1 -0
- package/src/cli/audience.ts +31 -0
- package/src/cli/auth.ts +382 -81
- package/src/cli/auto-update.ts +363 -24
- package/src/cli/browser-login.ts +265 -0
- package/src/cli/browser-session.ts +244 -0
- package/src/cli/check.ts +51 -52
- package/src/cli/cli-entry.ts +31 -0
- package/src/cli/commands/audience.ts +22 -0
- package/src/cli/commands/auth.ts +22 -15
- package/src/cli/commands/project.ts +3 -3
- package/src/cli/commands/training.ts +10 -2
- package/src/cli/config.ts +41 -14
- package/src/cli/context.ts +45 -9
- package/src/cli/credentials.ts +13 -13
- package/src/cli/init.ts +7 -2
- package/src/cli/keycloak.ts +189 -0
- package/src/cli/models.ts +1 -21
- package/src/cli/native-runtime.ts +7 -0
- package/src/cli/program.ts +5 -0
- package/src/cli/sign-in-page.ts +153 -0
- package/src/cli/skill.ts +34 -26
- package/src/cli/style.ts +37 -0
- package/src/cli/table.ts +21 -0
- package/src/cli/terminal-io.ts +9 -2
- package/src/cli/trainings.ts +1 -22
- package/src/cli/version.ts +12 -1
- package/src/client.ts +20 -3
- package/src/index.ts +1 -0
- package/src/pipeline-graph-v1.contract.json +47 -24
- 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 {
|
|
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
|
|
164
|
+
const runCommand = async (
|
|
164
165
|
sourceRoot: string,
|
|
165
|
-
|
|
166
|
+
executable: string,
|
|
166
167
|
args: readonly string[],
|
|
167
|
-
environment: NodeJS.ProcessEnv
|
|
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,
|
|
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
|
|
211
|
+
return await runCommand(
|
|
212
212
|
sourceRoot,
|
|
213
|
-
|
|
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
|
|
250
|
+
await runCommand(
|
|
251
251
|
sourceRoot,
|
|
252
|
-
|
|
253
|
-
[
|
|
254
|
-
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
|
|
850
|
-
|
|
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
|
-
|
|
866
|
-
|
|
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
|
-
|
|
904
|
-
|
|
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
|
|
993
|
+
run = await runCommand(
|
|
983
994
|
options.sourceRoot,
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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: "
|
|
1006
|
+
hint: { description: "Reinstall the Toloka CLI, then re-run toloka check." },
|
|
1008
1007
|
},
|
|
1009
1008
|
);
|
|
1010
1009
|
}
|