@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
package/src/cli/auth.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { createClient } from "../client";
|
|
2
|
+
import type { ClientConfig } from "../client";
|
|
2
3
|
import { PotatoApiError } from "../errors";
|
|
3
|
-
import {
|
|
4
|
+
import { loginWithBrowser } from "./browser-login";
|
|
5
|
+
import type { BrowserLoginFlow } from "./browser-login";
|
|
6
|
+
import {
|
|
7
|
+
BrowserLoginExpiredError,
|
|
8
|
+
browserSessionFromTokens,
|
|
9
|
+
createBrowserSessionAuth,
|
|
10
|
+
findBrowserLogin,
|
|
11
|
+
isRefreshTokenExpired,
|
|
12
|
+
readBrowserSession,
|
|
13
|
+
removeBrowserSession,
|
|
14
|
+
unixSeconds,
|
|
15
|
+
writeBrowserSession,
|
|
16
|
+
} from "./browser-session";
|
|
17
|
+
import type { BrowserLogin, BrowserSession } from "./browser-session";
|
|
18
|
+
import { PLAIN_STYLE } from "./style";
|
|
19
|
+
import type { Style } from "./style";
|
|
20
|
+
import {
|
|
21
|
+
PRODUCTION_BASE_URL,
|
|
22
|
+
consoleUrlFor,
|
|
23
|
+
registrationUrlFor,
|
|
24
|
+
resolveBaseUrl,
|
|
25
|
+
resolveInternalBaseUrl,
|
|
26
|
+
} from "./config";
|
|
4
27
|
import {
|
|
5
28
|
clearStoredCredentials,
|
|
6
29
|
credentialsPath,
|
|
@@ -8,6 +31,7 @@ import {
|
|
|
8
31
|
inspectCredentialsFile,
|
|
9
32
|
maskApiKey,
|
|
10
33
|
normalizeBaseUrl,
|
|
34
|
+
storedCredentialsPaths,
|
|
11
35
|
writeStoredCredentials,
|
|
12
36
|
} from "./credentials";
|
|
13
37
|
import type {
|
|
@@ -16,6 +40,12 @@ import type {
|
|
|
16
40
|
FoundCredentials,
|
|
17
41
|
StoredCredentials,
|
|
18
42
|
} from "./credentials";
|
|
43
|
+
import {
|
|
44
|
+
browserLoginProviderFor,
|
|
45
|
+
endKeycloakSession,
|
|
46
|
+
KNOWN_BROWSER_LOGIN_PROVIDERS,
|
|
47
|
+
} from "./keycloak";
|
|
48
|
+
import type { BrowserLoginProvider } from "./keycloak";
|
|
19
49
|
import type { ProjectContext } from "./project-context";
|
|
20
50
|
import { formatProjectContextLine } from "./project-context";
|
|
21
51
|
import { assertCli, TolokaCliError } from "./cli-error";
|
|
@@ -26,6 +56,7 @@ type ProbeFailure = { readonly kind: ProbeFailureKind; readonly reason: string }
|
|
|
26
56
|
|
|
27
57
|
const classifyProbeFailure = (error: unknown): ProbeFailure => {
|
|
28
58
|
const reason = error instanceof Error ? error.message : String(error);
|
|
59
|
+
if (error instanceof BrowserLoginExpiredError) return { kind: "rejected", reason };
|
|
29
60
|
if (!(error instanceof PotatoApiError) || error.status === undefined)
|
|
30
61
|
return { kind: "unreachable", reason };
|
|
31
62
|
return {
|
|
@@ -40,18 +71,26 @@ const PROBE_FAILURE_HEADLINE: Record<ProbeFailureKind, string> = {
|
|
|
40
71
|
platform: "Platform error",
|
|
41
72
|
};
|
|
42
73
|
|
|
43
|
-
const
|
|
74
|
+
const API_KEY_LOGIN_FAILURE_ADVICE: Record<ProbeFailureKind, (baseUrl: string) => string> = {
|
|
44
75
|
rejected: () => "Check that the key was copied whole and has not been revoked.",
|
|
45
76
|
unreachable: (baseUrl) =>
|
|
46
77
|
`${baseUrl} did not answer, so the key was not evaluated. Check network access, VPN, or proxy settings and retry.`,
|
|
47
78
|
platform: () => "The platform failed before confirming the key. Retry later.",
|
|
48
79
|
};
|
|
49
80
|
|
|
81
|
+
const BROWSER_LOGIN_FAILURE_ADVICE: Record<ProbeFailureKind, (baseUrl: string) => string> = {
|
|
82
|
+
rejected: (baseUrl) =>
|
|
83
|
+
`${baseUrl} did not accept the account you signed in with. Check that it belongs to a Toloka team, or sign in with another account.`,
|
|
84
|
+
unreachable: (baseUrl) =>
|
|
85
|
+
`${baseUrl} did not answer, so the login was not evaluated. Check network access, VPN, or proxy settings and retry.`,
|
|
86
|
+
platform: () => "The platform failed before confirming the login. Retry later.",
|
|
87
|
+
};
|
|
88
|
+
|
|
50
89
|
const STATUS_FAILURE_ADVICE: Record<ProbeFailureKind, (baseUrl: string) => string> = {
|
|
51
|
-
rejected: () => "Run toloka login to
|
|
90
|
+
rejected: () => "Run toloka login to sign in again.",
|
|
52
91
|
unreachable: (baseUrl) =>
|
|
53
|
-
`Check network access to ${baseUrl}; the stored
|
|
54
|
-
platform: () => "The platform failed before confirming the
|
|
92
|
+
`Check network access to ${baseUrl}; the stored login was not evaluated.`,
|
|
93
|
+
platform: () => "The platform failed before confirming the login. Retry later.",
|
|
55
94
|
};
|
|
56
95
|
|
|
57
96
|
export const apiKeyInstructions = (baseUrl: string): string => {
|
|
@@ -83,6 +122,15 @@ export type AuthPrompt = (
|
|
|
83
122
|
options?: { readonly mask?: boolean },
|
|
84
123
|
) => Promise<string>;
|
|
85
124
|
|
|
125
|
+
export type AuthChoose = (
|
|
126
|
+
message: string,
|
|
127
|
+
choices: readonly {
|
|
128
|
+
readonly name: string;
|
|
129
|
+
readonly value: string;
|
|
130
|
+
readonly description?: string;
|
|
131
|
+
}[],
|
|
132
|
+
) => Promise<string>;
|
|
133
|
+
|
|
86
134
|
export type AuthProbe = {
|
|
87
135
|
readonly projects: {
|
|
88
136
|
readonly listPage: (params: { readonly limit: number }) => Promise<{
|
|
@@ -90,6 +138,7 @@ export type AuthProbe = {
|
|
|
90
138
|
}>;
|
|
91
139
|
};
|
|
92
140
|
readonly me?: () => Promise<{
|
|
141
|
+
readonly email?: string | null;
|
|
93
142
|
readonly internal: boolean;
|
|
94
143
|
readonly teams?: ReadonlyArray<{
|
|
95
144
|
readonly id: string;
|
|
@@ -99,107 +148,287 @@ export type AuthProbe = {
|
|
|
99
148
|
}>;
|
|
100
149
|
};
|
|
101
150
|
|
|
151
|
+
export type LoginMethod = "browser" | "apiKey";
|
|
152
|
+
|
|
102
153
|
export type LoginOptions = {
|
|
103
154
|
readonly apiKey?: string;
|
|
104
155
|
readonly baseUrl?: string;
|
|
156
|
+
readonly browser?: boolean;
|
|
105
157
|
readonly prompt?: AuthPrompt;
|
|
158
|
+
readonly choose?: AuthChoose;
|
|
106
159
|
readonly environment?: CliEnvironment;
|
|
107
|
-
readonly probe?: (
|
|
160
|
+
readonly probe?: (config: ClientConfig) => AuthProbe;
|
|
161
|
+
readonly browserLogin?: BrowserLoginFlow;
|
|
162
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
163
|
+
readonly now?: () => number;
|
|
164
|
+
readonly style?: Style;
|
|
108
165
|
readonly write?: (text: string) => void;
|
|
109
166
|
};
|
|
110
167
|
|
|
111
168
|
export type LoginResult = {
|
|
112
169
|
readonly credentialsFile: string;
|
|
113
170
|
readonly baseUrl: string;
|
|
171
|
+
readonly method: LoginMethod;
|
|
114
172
|
readonly output: string;
|
|
115
173
|
};
|
|
116
174
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
environment.TOLOKA_BASE_URL ??
|
|
123
|
-
resolveInternalBaseUrl(environment) ??
|
|
124
|
-
PRODUCTION_BASE_URL,
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
let apiKey = options.apiKey ?? environment.TOLOKA_API_KEY;
|
|
128
|
-
if (apiKey === undefined) {
|
|
129
|
-
assertCli(
|
|
130
|
-
options.prompt,
|
|
131
|
-
"Missing API key. Without a terminal, pipe the key to toloka login --api-key-stdin, pass --api-key, or set TOLOKA_API_KEY.",
|
|
132
|
-
{ hint: { disabled: true } },
|
|
133
|
-
);
|
|
134
|
-
write(`\n${apiKeyInstructions(baseUrl)}\n`);
|
|
135
|
-
apiKey = (await options.prompt("API key: ", { mask: true })).trim();
|
|
136
|
-
}
|
|
137
|
-
assertCli(
|
|
138
|
-
apiKey.length > 0,
|
|
139
|
-
"No API key entered.",
|
|
140
|
-
{ hint: { description: "Re-run toloka login and paste the key at the prompt, or pass --api-key." } },
|
|
141
|
-
);
|
|
142
|
-
const credentials: StoredCredentials = { baseUrl, apiKey };
|
|
143
|
-
const probe: AuthProbe = options.probe?.(credentials) ?? createClient(credentials);
|
|
144
|
-
|
|
145
|
-
let reachable: string;
|
|
175
|
+
const verifyAccess = async (
|
|
176
|
+
probe: AuthProbe,
|
|
177
|
+
baseUrl: string,
|
|
178
|
+
advice: Record<ProbeFailureKind, (baseUrl: string) => string>,
|
|
179
|
+
): Promise<number> => {
|
|
146
180
|
try {
|
|
147
181
|
const page = await probe.projects.listPage({ limit: 1 });
|
|
148
|
-
|
|
182
|
+
return page.pagination.total;
|
|
149
183
|
} catch (error) {
|
|
150
184
|
const failure = classifyProbeFailure(error);
|
|
151
185
|
throw new TolokaCliError(
|
|
152
186
|
[
|
|
153
187
|
`Could not authenticate against ${baseUrl}: ${failure.reason}`,
|
|
154
188
|
"",
|
|
155
|
-
`Nothing was saved. ${
|
|
189
|
+
`Nothing was saved. ${advice[failure.kind](baseUrl)}`,
|
|
156
190
|
].join("\n"),
|
|
157
191
|
{ cause: error, hint: { disabled: true } },
|
|
158
192
|
);
|
|
159
193
|
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const registryFor = async (
|
|
197
|
+
environment: CliEnvironment,
|
|
198
|
+
baseUrl: string,
|
|
199
|
+
): Promise<Pick<StoredCredentials, "current" | "projects">> => {
|
|
200
|
+
const existing = await inspectCredentialsFile(credentialsPath(environment));
|
|
201
|
+
if (typeof existing !== "object") return {};
|
|
202
|
+
if (normalizeBaseUrl(existing.credentials.baseUrl) !== normalizeBaseUrl(baseUrl)) return {};
|
|
203
|
+
return {
|
|
204
|
+
...(existing.credentials.current === undefined ? {} : { current: existing.credentials.current }),
|
|
205
|
+
...(existing.credentials.projects === undefined
|
|
206
|
+
? {}
|
|
207
|
+
: { projects: existing.credentials.projects }),
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const accountEmail = async (probe: AuthProbe): Promise<string | undefined> => {
|
|
212
|
+
if (probe.me === undefined) return undefined;
|
|
213
|
+
try {
|
|
214
|
+
const email = (await probe.me()).email;
|
|
215
|
+
return typeof email === "string" && email.length > 0 ? email : undefined;
|
|
216
|
+
} catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const formatDate = (unixTime: number): string =>
|
|
222
|
+
new Date(unixTime * 1000).toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
223
|
+
|
|
224
|
+
const projectsVisible = (total: number): string =>
|
|
225
|
+
`${total} ${total === 1 ? "project" : "projects"} visible`;
|
|
160
226
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
227
|
+
const loginWithApiKey = async (
|
|
228
|
+
options: LoginOptions,
|
|
229
|
+
environment: CliEnvironment,
|
|
230
|
+
baseUrl: string,
|
|
231
|
+
write: (text: string) => void,
|
|
232
|
+
): Promise<LoginResult> => {
|
|
233
|
+
let apiKey = options.apiKey ?? environment.TOLOKA_API_KEY;
|
|
234
|
+
if (apiKey === undefined) {
|
|
235
|
+
assertCli(
|
|
236
|
+
options.prompt,
|
|
237
|
+
"No terminal to type the API key in. Pipe it to toloka login --api-key-stdin, pass --api-key, or set TOLOKA_API_KEY.",
|
|
238
|
+
{ hint: { disabled: true } },
|
|
239
|
+
);
|
|
240
|
+
write(`\n${apiKeyInstructions(baseUrl)}\n`);
|
|
241
|
+
apiKey = (await options.prompt("API key: ", { mask: true })).trim();
|
|
242
|
+
}
|
|
243
|
+
assertCli(apiKey.length > 0, "No API key entered.", {
|
|
244
|
+
hint: { description: "Re-run toloka login and paste the key at the prompt, or pass --api-key." },
|
|
245
|
+
});
|
|
246
|
+
const style = options.style ?? PLAIN_STYLE;
|
|
247
|
+
const clientConfig: ClientConfig = { baseUrl, apiKey };
|
|
248
|
+
const probe = options.probe?.(clientConfig) ?? createClient(clientConfig);
|
|
249
|
+
const total = await verifyAccess(probe, baseUrl, API_KEY_LOGIN_FAILURE_ADVICE);
|
|
250
|
+
const registry = await registryFor(environment, baseUrl);
|
|
251
|
+
const credentialsFile = await writeStoredCredentials({ baseUrl, apiKey, ...registry }, environment);
|
|
179
252
|
|
|
180
253
|
return {
|
|
181
254
|
credentialsFile,
|
|
182
255
|
baseUrl,
|
|
256
|
+
method: "apiKey",
|
|
183
257
|
output: [
|
|
184
|
-
|
|
185
|
-
`
|
|
186
|
-
|
|
187
|
-
|
|
258
|
+
`${style.green("✔")} Authenticated against ${style.bold(baseUrl)} with an API key`,
|
|
259
|
+
style.dim(` ${projectsVisible(total)} · stored in ${credentialsFile}`),
|
|
260
|
+
style.dim(
|
|
261
|
+
` This API key does not expire. To revoke it, delete it in ${consoleUrlFor(baseUrl) ?? "the Business Console"}.`,
|
|
262
|
+
),
|
|
188
263
|
].join("\n"),
|
|
189
264
|
};
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const API_KEY_CHOICE = "api-key";
|
|
268
|
+
const BROWSER_CHOICE = "browser";
|
|
269
|
+
|
|
270
|
+
const signInChoices = (provider: BrowserLoginProvider) => [
|
|
271
|
+
{
|
|
272
|
+
name: "Sign in with your browser",
|
|
273
|
+
value: BROWSER_CHOICE,
|
|
274
|
+
description: `${provider.identityProviders.map((choice) => choice.name).join(", ")}. Lasts up to 14 days.`,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: "Use an API key",
|
|
278
|
+
value: API_KEY_CHOICE,
|
|
279
|
+
description:
|
|
280
|
+
"Paste a key from the Business Console. Works until you delete it; made for scripts, CI, and coding agents.",
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
type SignInChoice =
|
|
285
|
+
| { readonly method: "apiKey" }
|
|
286
|
+
| { readonly method: "browser"; readonly openBrowser: boolean; readonly requireBrowser: boolean };
|
|
287
|
+
|
|
288
|
+
const chooseSignIn = async (
|
|
289
|
+
options: LoginOptions,
|
|
290
|
+
provider: BrowserLoginProvider,
|
|
291
|
+
): Promise<SignInChoice> => {
|
|
292
|
+
if (options.browser === false) return { method: "browser", openBrowser: false, requireBrowser: false };
|
|
293
|
+
if (options.browser === true || options.choose === undefined) {
|
|
294
|
+
return { method: "browser", openBrowser: true, requireBrowser: options.choose === undefined };
|
|
295
|
+
}
|
|
296
|
+
const choice = await options.choose("How do you want to sign in?", signInChoices(provider));
|
|
297
|
+
return choice === API_KEY_CHOICE
|
|
298
|
+
? { method: "apiKey" }
|
|
299
|
+
: { method: "browser", openBrowser: true, requireBrowser: false };
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const loginInBrowser = async (
|
|
303
|
+
options: LoginOptions,
|
|
304
|
+
environment: CliEnvironment,
|
|
305
|
+
baseUrl: string,
|
|
306
|
+
provider: BrowserLoginProvider,
|
|
307
|
+
choice: Extract<SignInChoice, { method: "browser" }>,
|
|
308
|
+
write: (text: string) => void,
|
|
309
|
+
): Promise<LoginResult> => {
|
|
310
|
+
const tokens = await (options.browserLogin ?? loginWithBrowser)(
|
|
311
|
+
{
|
|
312
|
+
provider,
|
|
313
|
+
openBrowser: choice.openBrowser,
|
|
314
|
+
requireBrowser: choice.requireBrowser,
|
|
315
|
+
registrationUrl: registrationUrlFor(baseUrl),
|
|
316
|
+
...(options.style === undefined ? {} : { style: options.style }),
|
|
317
|
+
write,
|
|
318
|
+
},
|
|
319
|
+
{ environment, ...(options.fetch === undefined ? {} : { fetch: options.fetch }) },
|
|
320
|
+
);
|
|
321
|
+
const session = browserSessionFromTokens(provider, tokens, (options.now ?? unixSeconds)());
|
|
322
|
+
const clientConfig: ClientConfig = { baseUrl, accessToken: async () => session.access_token };
|
|
323
|
+
const probe = options.probe?.(clientConfig) ?? createClient(clientConfig);
|
|
324
|
+
const total = await verifyAccess(probe, baseUrl, BROWSER_LOGIN_FAILURE_ADVICE);
|
|
325
|
+
const who = await accountEmail(probe);
|
|
326
|
+
await writeBrowserSession(session, provider, environment);
|
|
327
|
+
const registry = await registryFor(environment, baseUrl);
|
|
328
|
+
const credentialsFile = await writeStoredCredentials({ baseUrl, ...registry }, environment);
|
|
329
|
+
const style = options.style ?? PLAIN_STYLE;
|
|
330
|
+
const renewal =
|
|
331
|
+
session.refresh_token_expires_at > 0
|
|
332
|
+
? `session renews automatically until ${formatDate(session.refresh_token_expires_at)}, then run toloka login again`
|
|
333
|
+
: "session renews automatically while the login service allows; when it lapses, run toloka login again";
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
credentialsFile,
|
|
337
|
+
baseUrl,
|
|
338
|
+
method: "browser",
|
|
339
|
+
output: [
|
|
340
|
+
`${style.green("✔")} Logged in to ${style.bold(baseUrl)}${who === undefined ? "" : ` as ${style.bold(who)}`}`,
|
|
341
|
+
style.dim(` ${projectsVisible(total)} · ${renewal}`),
|
|
342
|
+
].join("\n"),
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
export async function login(options: LoginOptions = {}): Promise<LoginResult> {
|
|
347
|
+
const environment = options.environment ?? process.env;
|
|
348
|
+
const write = options.write ?? (() => undefined);
|
|
349
|
+
const baseUrl = normalizeBaseUrl(resolveBaseUrl(options, environment));
|
|
350
|
+
const environmentKey = options.browser === undefined ? environment.TOLOKA_API_KEY : undefined;
|
|
351
|
+
if (options.apiKey !== undefined || environmentKey !== undefined) {
|
|
352
|
+
return loginWithApiKey(options, environment, baseUrl, write);
|
|
353
|
+
}
|
|
354
|
+
const provider = browserLoginProviderFor(baseUrl);
|
|
355
|
+
if (provider === undefined) {
|
|
356
|
+
write(`Browser login is not available for ${baseUrl}, so an API key is needed.`);
|
|
357
|
+
return loginWithApiKey(options, environment, baseUrl, write);
|
|
358
|
+
}
|
|
359
|
+
const choice = await chooseSignIn(options, provider);
|
|
360
|
+
return choice.method === "apiKey"
|
|
361
|
+
? loginWithApiKey(options, environment, baseUrl, write)
|
|
362
|
+
: loginInBrowser(options, environment, baseUrl, provider, choice, write);
|
|
190
363
|
}
|
|
191
364
|
|
|
192
|
-
export type
|
|
365
|
+
export type LogoutOptions = {
|
|
366
|
+
readonly baseUrl?: string;
|
|
367
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
export type LogoutResult = {
|
|
371
|
+
readonly removed: readonly string[];
|
|
372
|
+
readonly sessionEnded?: boolean;
|
|
373
|
+
readonly output: string;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
type EndedBrowserLogin = {
|
|
377
|
+
readonly issuer: string;
|
|
378
|
+
readonly ended: boolean;
|
|
379
|
+
readonly path: string | undefined;
|
|
380
|
+
};
|
|
193
381
|
|
|
194
|
-
|
|
382
|
+
const endBrowserLogins = async (
|
|
383
|
+
environment: CliEnvironment,
|
|
384
|
+
fetchImplementation: typeof globalThis.fetch | undefined,
|
|
385
|
+
): Promise<EndedBrowserLogin[]> => {
|
|
386
|
+
const ended: EndedBrowserLogin[] = [];
|
|
387
|
+
for (const provider of KNOWN_BROWSER_LOGIN_PROVIDERS) {
|
|
388
|
+
const session = await readBrowserSession(provider, environment);
|
|
389
|
+
if (session === undefined) continue;
|
|
390
|
+
const ok = await endKeycloakSession(provider, session.refresh_token, fetchImplementation);
|
|
391
|
+
const path = await removeBrowserSession(provider, environment);
|
|
392
|
+
ended.push({ issuer: provider.issuer, ended: ok, path });
|
|
393
|
+
}
|
|
394
|
+
return ended;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const browserLogoutLine = (browser: EndedBrowserLogin): string =>
|
|
398
|
+
browser.ended
|
|
399
|
+
? `Signed out of ${browser.issuer}.`
|
|
400
|
+
: `The login at ${browser.issuer} could not be ended and will expire on its own; the local session was removed.`;
|
|
401
|
+
|
|
402
|
+
export async function logout(
|
|
403
|
+
environment: CliEnvironment = process.env,
|
|
404
|
+
options: LogoutOptions = {},
|
|
405
|
+
): Promise<LogoutResult> {
|
|
406
|
+
let stored: StoredCredentials | undefined;
|
|
407
|
+
for (const path of storedCredentialsPaths(environment)) {
|
|
408
|
+
const inspected = await inspectCredentialsFile(path);
|
|
409
|
+
if (inspected === "missing") continue;
|
|
410
|
+
stored = typeof inspected === "object" ? inspected.credentials : undefined;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
const baseUrl = normalizeBaseUrl(resolveBaseUrl(options, environment, stored));
|
|
195
414
|
const removed = await clearStoredCredentials(environment);
|
|
415
|
+
const browsers = await endBrowserLogins(environment, options.fetch);
|
|
416
|
+
for (const browser of browsers) if (browser.path !== undefined) removed.push(browser.path);
|
|
417
|
+
|
|
418
|
+
if (removed.length === 0) {
|
|
419
|
+
return { removed, output: `No stored credentials found at ${credentialsPath(environment)}.` };
|
|
420
|
+
}
|
|
196
421
|
|
|
422
|
+
const apiKeyNote = `This does not revoke the API key. Delete it in ${consoleUrlFor(baseUrl) ?? "the Business Console"} to do that.`;
|
|
423
|
+
const lines = [
|
|
424
|
+
`Removed ${removed.join(", ")}.`,
|
|
425
|
+
...(stored?.apiKey === undefined ? [] : ["", apiKeyNote]),
|
|
426
|
+
...browsers.flatMap((browser) => ["", browserLogoutLine(browser)]),
|
|
427
|
+
];
|
|
197
428
|
return {
|
|
198
429
|
removed,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
? `No stored credentials found at ${credentialsPath(environment)}.`
|
|
202
|
-
: `Removed ${removed.join(", ")}.\n\nThis does not revoke the key. Delete it in console.toloka.ai to do that.`,
|
|
430
|
+
...(browsers.length === 0 ? {} : { sessionEnded: browsers.every((browser) => browser.ended) }),
|
|
431
|
+
output: lines.join("\n"),
|
|
203
432
|
};
|
|
204
433
|
}
|
|
205
434
|
|
|
@@ -208,9 +437,11 @@ export type AuthStatusOptions = {
|
|
|
208
437
|
readonly showToken?: boolean;
|
|
209
438
|
readonly flagApiKey?: string;
|
|
210
439
|
readonly flagBaseUrl?: string;
|
|
211
|
-
readonly probe?: (
|
|
440
|
+
readonly probe?: (config: ClientConfig) => AuthProbe;
|
|
212
441
|
readonly credentialLookup?: CommandCredentialLookup;
|
|
213
442
|
readonly project?: ProjectContext;
|
|
443
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
444
|
+
readonly now?: () => number;
|
|
214
445
|
};
|
|
215
446
|
|
|
216
447
|
export type AuthStatusResult = { readonly authenticated: boolean; readonly output: string };
|
|
@@ -221,7 +452,7 @@ const firstDefined = (
|
|
|
221
452
|
candidates: ReadonlyArray<ResolvedValue | undefined>,
|
|
222
453
|
): ResolvedValue | undefined => candidates.find((candidate) => candidate !== undefined);
|
|
223
454
|
|
|
224
|
-
const
|
|
455
|
+
const resolveStatusApiKey = (
|
|
225
456
|
options: AuthStatusOptions,
|
|
226
457
|
environment: CliEnvironment,
|
|
227
458
|
found: FoundCredentials | undefined,
|
|
@@ -234,7 +465,7 @@ const resolveApiKey = (
|
|
|
234
465
|
environment.TOLOKA_API_KEY === undefined
|
|
235
466
|
? undefined
|
|
236
467
|
: { value: environment.TOLOKA_API_KEY, source: "from TOLOKA_API_KEY" },
|
|
237
|
-
found === undefined
|
|
468
|
+
found?.credentials.apiKey === undefined
|
|
238
469
|
? undefined
|
|
239
470
|
: { value: found.credentials.apiKey, source: `stored in ${storedPath}` },
|
|
240
471
|
]);
|
|
@@ -265,12 +496,14 @@ const accountLines = async (probe: AuthProbe): Promise<readonly string[]> => {
|
|
|
265
496
|
if (probe.me === undefined) return [];
|
|
266
497
|
try {
|
|
267
498
|
const me = await probe.me();
|
|
268
|
-
|
|
499
|
+
const account = typeof me.email === "string" && me.email.length > 0 ? [`Account: ${me.email}`] : [];
|
|
500
|
+
if (me.teams === undefined) return [...account, `Internal: ${me.internal}`];
|
|
269
501
|
const teams = me.teams.map((team) => {
|
|
270
502
|
const qualifiers = [...(team.internal ? ["internal"] : []), ...(team.writable ? [] : ["read-only"])];
|
|
271
503
|
return qualifiers.length === 0 ? team.id : `${team.id} (${qualifiers.join(", ")})`;
|
|
272
504
|
});
|
|
273
505
|
return [
|
|
506
|
+
...account,
|
|
274
507
|
`Internal: ${me.internal}`,
|
|
275
508
|
`Teams: ${teams.length === 0 ? "(none)" : teams.join(", ")}`,
|
|
276
509
|
];
|
|
@@ -279,24 +512,81 @@ const accountLines = async (probe: AuthProbe): Promise<readonly string[]> => {
|
|
|
279
512
|
}
|
|
280
513
|
};
|
|
281
514
|
|
|
515
|
+
const describeSession = (session: BrowserSession, now: number): string => {
|
|
516
|
+
if (isRefreshTokenExpired(session, now)) {
|
|
517
|
+
return `expired on ${formatDate(session.refresh_token_expires_at)}; run toloka login`;
|
|
518
|
+
}
|
|
519
|
+
return session.refresh_token_expires_at > 0
|
|
520
|
+
? `renews automatically; the current refresh window ends ${formatDate(session.refresh_token_expires_at)}`
|
|
521
|
+
: "renews automatically";
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
type BrowserStatus = {
|
|
525
|
+
readonly lines: readonly string[];
|
|
526
|
+
readonly clientConfig?: ClientConfig;
|
|
527
|
+
readonly failure?: string;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const browserStatus = async (
|
|
531
|
+
browser: BrowserLogin,
|
|
532
|
+
baseUrl: string,
|
|
533
|
+
environment: CliEnvironment,
|
|
534
|
+
options: AuthStatusOptions,
|
|
535
|
+
now: number,
|
|
536
|
+
): Promise<BrowserStatus> => {
|
|
537
|
+
const header = `Login: browser (stored in ${browser.path})`;
|
|
538
|
+
const accessToken = createBrowserSessionAuth({
|
|
539
|
+
provider: browser.provider,
|
|
540
|
+
baseUrl,
|
|
541
|
+
environment,
|
|
542
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
543
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
544
|
+
});
|
|
545
|
+
try {
|
|
546
|
+
const token = await accessToken();
|
|
547
|
+
const session = (await readBrowserSession(browser.provider, environment)) ?? browser.session;
|
|
548
|
+
return {
|
|
549
|
+
lines: [
|
|
550
|
+
header,
|
|
551
|
+
`Session: ${describeSession(session, now)}`,
|
|
552
|
+
...(options.showToken === true ? [`Access token: ${token}`] : []),
|
|
553
|
+
],
|
|
554
|
+
clientConfig: { baseUrl, accessToken },
|
|
555
|
+
};
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error instanceof BrowserLoginExpiredError) {
|
|
558
|
+
return {
|
|
559
|
+
lines: [header, `Session: ${describeSession(browser.session, now)}`],
|
|
560
|
+
failure: STATUS_FAILURE_ADVICE.rejected(baseUrl),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
lines: [header, "Session: could not be renewed"],
|
|
565
|
+
failure: `Not reachable: ${error instanceof Error ? error.message : String(error)}`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
282
570
|
export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthStatusResult> {
|
|
283
571
|
const environment = options.environment ?? process.env;
|
|
572
|
+
const now = (options.now ?? unixSeconds)();
|
|
284
573
|
const credentialLookup =
|
|
285
574
|
options.credentialLookup ??
|
|
286
575
|
(await findCommandCredentials(environment, options.flagApiKey ?? environment.TOLOKA_API_KEY));
|
|
287
576
|
const { found, warning } = credentialLookup;
|
|
288
577
|
const storedPath = found?.path ?? credentialsPath(environment);
|
|
289
|
-
const apiKey =
|
|
578
|
+
const apiKey = resolveStatusApiKey(options, environment, found, storedPath);
|
|
290
579
|
const platform = resolvePlatform(options, environment, found);
|
|
580
|
+
const browser = apiKey === undefined ? await findBrowserLogin(platform.value, environment) : undefined;
|
|
291
581
|
const projectLine = formatProjectContextLine(options.project, found?.credentials);
|
|
292
582
|
|
|
293
583
|
const lines: string[] = warning === undefined ? [] : [warning, ""];
|
|
294
584
|
|
|
295
|
-
if (apiKey === undefined) {
|
|
585
|
+
if (apiKey === undefined && browser === undefined) {
|
|
296
586
|
lines.push(
|
|
297
587
|
"Not logged in.",
|
|
298
588
|
"",
|
|
299
|
-
"Run toloka login to store an API key
|
|
589
|
+
"Run toloka login to sign in through your browser, or store an API key with toloka login --api-key-stdin.",
|
|
300
590
|
"",
|
|
301
591
|
projectLine,
|
|
302
592
|
);
|
|
@@ -304,13 +594,26 @@ export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthS
|
|
|
304
594
|
}
|
|
305
595
|
|
|
306
596
|
const { value: baseUrl, source: urlSource } = platform;
|
|
307
|
-
lines.push(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
)
|
|
597
|
+
lines.push(`Platform: ${baseUrl} (${urlSource})`);
|
|
598
|
+
let clientConfig: ClientConfig;
|
|
599
|
+
if (apiKey !== undefined) {
|
|
600
|
+
lines.push(
|
|
601
|
+
`API key: ${options.showToken === true ? apiKey.value : maskApiKey(apiKey.value)} (${apiKey.source})`,
|
|
602
|
+
);
|
|
603
|
+
clientConfig = { baseUrl, apiKey: apiKey.value };
|
|
604
|
+
} else {
|
|
605
|
+
assertCli(browser, "No login is in effect.", { hint: { description: "Run toloka login." } });
|
|
606
|
+
const status = await browserStatus(browser, baseUrl, environment, options, now);
|
|
607
|
+
lines.push(...status.lines);
|
|
608
|
+
if (status.clientConfig === undefined) {
|
|
609
|
+
lines.push(projectLine, "", status.failure ?? "");
|
|
610
|
+
return { authenticated: false, output: lines.join("\n") };
|
|
611
|
+
}
|
|
612
|
+
clientConfig = status.clientConfig;
|
|
613
|
+
}
|
|
614
|
+
lines.push(projectLine);
|
|
312
615
|
|
|
313
|
-
if (environment.TOLOKA_API_KEY !== undefined && found !== undefined) {
|
|
616
|
+
if (environment.TOLOKA_API_KEY !== undefined && found?.credentials.apiKey !== undefined) {
|
|
314
617
|
lines.push("", `TOLOKA_API_KEY is set, so the key stored in ${found.path} is ignored.`);
|
|
315
618
|
}
|
|
316
619
|
if (found?.legacy === true) {
|
|
@@ -320,9 +623,7 @@ export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthS
|
|
|
320
623
|
);
|
|
321
624
|
}
|
|
322
625
|
|
|
323
|
-
const probe: AuthProbe =
|
|
324
|
-
options.probe?.({ baseUrl, apiKey: apiKey.value }) ??
|
|
325
|
-
createClient({ baseUrl, apiKey: apiKey.value });
|
|
626
|
+
const probe: AuthProbe = options.probe?.(clientConfig) ?? createClient(clientConfig);
|
|
326
627
|
try {
|
|
327
628
|
const page = await probe.projects.listPage({ limit: 1 });
|
|
328
629
|
lines.push("", `Reachable: ${page.pagination.total} project(s) visible.`);
|