@toloka-platform/sdk 0.1.0-preview.7 → 0.1.0-preview.9

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 (41) hide show
  1. package/README.md +3 -4
  2. package/dist/cli/toloka.js +133 -130
  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 +3 -5
  7. package/docs/cli.md +15 -0
  8. package/docs/limitations.md +1 -1
  9. package/examples/face-image-collection/nodes/order-faces/node.ts +2 -4
  10. package/examples/video-pair-rating/nodes/adjudication_match/node.ts +15 -17
  11. package/examples/video-pair-rating/nodes/assemble/node.ts +25 -27
  12. package/examples/video-pair-rating/nodes/cascade/node.ts +24 -26
  13. package/examples/video-pair-rating/nodes/finalize_overall/node.ts +17 -19
  14. package/examples/video-pair-rating/nodes/merge_adjudication/node.ts +13 -15
  15. package/examples/video-pair-rating/nodes/merge_op_adjudication/node.ts +12 -14
  16. package/examples/video-pair-rating/nodes/op_quorum_resolve/node.ts +13 -15
  17. package/examples/video-pair-rating/nodes/route_adjudication/node.ts +1 -3
  18. package/examples/video-pair-rating/nodes/route_op_adjudication/node.ts +5 -7
  19. package/examples/video-pair-rating/nodes/route_overall/node.ts +1 -3
  20. package/examples/video-pair-rating/nodes/shuffle_ab/node.ts +11 -13
  21. package/openapi/gen/models/datasetItem.ts +0 -2
  22. package/openapi/gen/models/index.ts +0 -1
  23. package/package.json +1 -1
  24. package/skills/catalog.json +4 -4
  25. package/skills/toloka-build/SKILL.md +1 -0
  26. package/skills/toloka-build/references/node-semantics.md +5 -5
  27. package/skills/toloka-build/references/workflow-and-cli.md +6 -1
  28. package/skills/toloka-report-issue/references/report-template.md +1 -1
  29. package/src/authoring/index.ts +27 -26
  30. package/src/checks/task-ui-typecheck.ts +1 -2
  31. package/src/cli/analytics.ts +8 -19
  32. package/src/cli/auto-update.ts +367 -0
  33. package/src/cli/boolean-flag.ts +13 -0
  34. package/src/cli/check.ts +3 -4
  35. package/src/cli/cli-entry.ts +41 -0
  36. package/src/cli/credentials.ts +3 -0
  37. package/src/cli/program.ts +37 -1
  38. package/src/cli/settings.ts +93 -0
  39. package/src/cli/skill.ts +4 -5
  40. package/src/importer.ts +12 -16
  41. package/openapi/gen/models/datasetItemDuplicateOfSubmissionId.ts +0 -2
@@ -51,7 +51,7 @@ export interface Connection {
51
51
  readonly settings: PipelineGraphV1["connections"][number]["settings"];
52
52
  }
53
53
 
54
- type FieldProxyReference = {
54
+ type FieldReference = {
55
55
  readonly field: Field;
56
56
  readonly optional: boolean;
57
57
  };
@@ -124,26 +124,24 @@ type QuorumOutputEntity = FieldEntity<QuorumNodeSettings["entities"]["output"][n
124
124
  type QuorumQualityCriterion = FieldTarget<QuorumNodeSettings["qualityCriteria"][number]>;
125
125
  type QuorumExam = FieldExam<NonNullable<QuorumNodeSettings["exam"]>>;
126
126
 
127
- type MatchPredicate = Omit<
127
+ type MatchPredicate = Pick<
128
128
  MatchNodeSettings["conditions"][number]["predicates"][number],
129
- "fieldProxyId"
129
+ "jsonPath" | "operator" | "value"
130
130
  > & {
131
131
  readonly field: Field;
132
132
  };
133
133
 
134
- export type RootAuthoringSettings = Omit<
135
- RootNodeSettings,
136
- "datasetId" | "itemFilter" | "startedFieldProxies" | "completedFieldProxies"
137
- > & {
134
+ export type RootAuthoringSettings = Pick<RootNodeSettings, "itemLimit"> & {
138
135
  readonly started: ReadonlyArray<RootFieldReference>;
139
136
  readonly completed: ReadonlyArray<RootFieldReference>;
140
137
  };
141
138
 
142
- export type CodeAuthoringSettings = Omit<CodeNodeSettings, "fieldProxies"> & {
143
- readonly fieldProxies: {
144
- readonly input: ReadonlyArray<FieldProxyReference>;
145
- readonly output: ReadonlyArray<FieldProxyReference>;
146
- };
139
+ export type CodeAuthoringSettings = Pick<
140
+ CodeNodeSettings,
141
+ "about" | "code" | "envVars" | "checks"
142
+ > & {
143
+ readonly input: ReadonlyArray<FieldReference>;
144
+ readonly output: ReadonlyArray<FieldReference>;
147
145
  };
148
146
 
149
147
  export type GenerationAuthoringSettings = Omit<
@@ -162,15 +160,13 @@ export type GenerationAuthoringSettings = Omit<
162
160
  readonly exam?: GenerationExam;
163
161
  };
164
162
 
165
- export type MatchAuthoringSettings = Omit<MatchNodeSettings, "conditions" | "fieldProxies"> & {
163
+ export type MatchAuthoringSettings = Pick<MatchNodeSettings, "about"> & {
166
164
  readonly conditions: ReadonlyArray<
167
165
  Omit<MatchNodeSettings["conditions"][number], "predicates"> & {
168
166
  readonly predicates: ReadonlyArray<MatchPredicate>;
169
167
  }
170
168
  >;
171
- readonly fieldProxies: {
172
- readonly input: ReadonlyArray<FieldProxyReference>;
173
- };
169
+ readonly input: ReadonlyArray<FieldReference>;
174
170
  };
175
171
 
176
172
  export type QuorumAuthoringSettings = Omit<
@@ -316,11 +312,14 @@ function referencedFields(value: unknown): ReadonlyArray<Field> {
316
312
  return references;
317
313
  }
318
314
 
319
- function compileFieldProxy(reference: FieldProxyReference): { id: string; optional: boolean } {
315
+ function compileFieldReference(reference: FieldReference): { id: string; optional: boolean } {
320
316
  return { id: reference.field.id, optional: reference.optional };
321
317
  }
322
318
 
323
- function compileRootFieldProxy(reference: RootFieldReference): { id: string; optional: boolean } {
319
+ function compileRootFieldReference(reference: RootFieldReference): {
320
+ id: string;
321
+ optional: boolean;
322
+ } {
324
323
  return isField(reference)
325
324
  ? { id: reference.id, optional: false }
326
325
  : { id: reference.field.id, optional: reference.optional ?? false };
@@ -457,8 +456,8 @@ function createRoot(
457
456
  type: "root",
458
457
  settings: {
459
458
  ...settingsWithoutFieldProxies,
460
- startedFieldProxies: { output: started.map(compileRootFieldProxy) },
461
- completedFieldProxies: { input: completed.map(compileRootFieldProxy) },
459
+ startedFieldProxies: { output: started.map(compileRootFieldReference) },
460
+ completedFieldProxies: { input: completed.map(compileRootFieldReference) },
462
461
  },
463
462
  started: outputPort(id, "started"),
464
463
  completed: inputPort(id, "completed"),
@@ -477,14 +476,15 @@ export function code(
477
476
  options: AuthoringIdOptions = {},
478
477
  ): AuthoredCodeNode {
479
478
  const id = options.id ?? mintedId("code", name);
479
+ const { input, output, ...wireSettings } = settings;
480
480
  return {
481
481
  id,
482
482
  type: "code",
483
483
  settings: {
484
- ...settings,
484
+ ...wireSettings,
485
485
  fieldProxies: {
486
- input: settings.fieldProxies.input.map(compileFieldProxy),
487
- output: settings.fieldProxies.output.map(compileFieldProxy),
486
+ input: input.map(compileFieldReference),
487
+ output: output.map(compileFieldReference),
488
488
  },
489
489
  },
490
490
  input: inputPort(id, "input"),
@@ -515,19 +515,20 @@ export function match(
515
515
  options: AuthoringIdOptions = {},
516
516
  ): AuthoredMatchNode {
517
517
  const id = options.id ?? mintedId("match", name);
518
+ const { conditions, input, ...wireSettings } = settings;
518
519
  return {
519
520
  id,
520
521
  type: "match",
521
522
  settings: {
522
- ...settings,
523
- conditions: settings.conditions.map((condition) => ({
523
+ ...wireSettings,
524
+ conditions: conditions.map((condition) => ({
524
525
  ...condition,
525
526
  predicates: condition.predicates.map(({ field: referencedField, ...predicate }) => ({
526
527
  ...predicate,
527
528
  fieldProxyId: referencedField.id,
528
529
  })),
529
530
  })),
530
- fieldProxies: { input: settings.fieldProxies.input.map(compileFieldProxy) },
531
+ fieldProxies: { input: input.map(compileFieldReference) },
531
532
  },
532
533
  input: inputPort(id, "input"),
533
534
  default: outputPort(id, "default"),
@@ -1,4 +1,3 @@
1
- import { createRequire } from "node:module";
2
1
  import path from "node:path";
3
2
 
4
3
  import { assert } from "es-toolkit";
@@ -11,7 +10,7 @@ import {
11
10
  createVirtualTypeScriptEnvironment,
12
11
  } from "./typescript-vfs.js";
13
12
 
14
- const tsLibDirectory = path.dirname(createRequire(import.meta.url).resolve("typescript-api"));
13
+ const tsLibDirectory = path.dirname(ts.getDefaultLibFilePath({ target: ts.ScriptTarget.Latest }));
15
14
 
16
15
  const BLOCK_MARKERS = {
17
16
  data: "DATA_INJECTION",
@@ -7,6 +7,7 @@ import { PipelineGraphValidationError, PotatoApiError } from "../errors";
7
7
  import type { TolokaEnvironment } from "./config";
8
8
  import type { CliEnvironment } from "./credentials";
9
9
  import { credentialsPath, preparePrivateFilePath } from "./credentials";
10
+ import { isFalseyFlag, isTruthyFlag } from "./boolean-flag";
10
11
  import { SDK_VERSION } from "./init";
11
12
 
12
13
  export const POSTHOG_PROJECT_KEY = "phc_kqHt0f53B5MVqURyzWpW1aNlbOmYt1YbUYza0JPcg19";
@@ -61,27 +62,13 @@ export type PosthogLike = {
61
62
  shutdown: (shutdownTimeoutMs?: number) => Promise<void>;
62
63
  };
63
64
 
64
- const isTruthy = (value: string | undefined): boolean => {
65
- if (value === undefined) return false;
66
- const normalized = value.trim().toLowerCase();
67
- return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
68
- };
69
-
70
- const isFalsey = (value: string | undefined): boolean => {
71
- if (value === undefined) return false;
72
- const normalized = value.trim().toLowerCase();
73
- return (
74
- normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off"
75
- );
76
- };
77
-
78
65
  export function telemetryEnabled(environment: CliEnvironment): boolean {
79
66
  const explicit = environment.TOLOKA_TELEMETRY ?? process.env.TOLOKA_TELEMETRY;
80
- if (isFalsey(explicit)) return false;
81
- if (isTruthy(explicit)) return true;
82
- if (isTruthy(environment.DO_NOT_TRACK ?? process.env.DO_NOT_TRACK)) return false;
83
- if (isTruthy(environment.CI ?? process.env.CI)) return false;
84
- if (isTruthy(process.env.VITEST)) return false;
67
+ if (isFalseyFlag(explicit)) return false;
68
+ if (isTruthyFlag(explicit)) return true;
69
+ if (isTruthyFlag(environment.DO_NOT_TRACK ?? process.env.DO_NOT_TRACK)) return false;
70
+ if (isTruthyFlag(environment.CI ?? process.env.CI)) return false;
71
+ if (isTruthyFlag(process.env.VITEST)) return false;
85
72
  return true;
86
73
  }
87
74
 
@@ -99,12 +86,14 @@ const COMMAND_GROUPS: Readonly<Record<string, Readonly<Record<string, true>>>> =
99
86
  dataset: { list: true, fields: true, load: true, upload: true },
100
87
  secret: { set: true },
101
88
  skill: { list: true, search: true, install: true },
89
+ config: { get: true, set: true },
102
90
  };
103
91
 
104
92
  const ROOT_COMMANDS: Readonly<Record<string, true>> = {
105
93
  apply: true,
106
94
  check: true,
107
95
  "auth-status": true,
96
+ config: true,
108
97
  help: true,
109
98
  import: true,
110
99
  init: true,
@@ -0,0 +1,367 @@
1
+ import { spawn } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import { realpathSync } from "node:fs";
4
+ import { chmod, readFile, writeFile } from "node:fs/promises";
5
+ import { join, sep } from "node:path";
6
+ import { findCliEntry } from "./cli-entry";
7
+ import type { CliEnvironment } from "./credentials";
8
+ import { configDirectory, preparePrivateFilePath } from "./credentials";
9
+ import { SDK_VERSION } from "./init";
10
+ import { commandNameFromArgv } from "./analytics";
11
+ import { readCliSettings } from "./settings";
12
+ import { CHECK_RESULT_FILE_ENV } from "./check";
13
+ import { isFalseyFlag, isTruthyFlag } from "./boolean-flag";
14
+ import packageMetadata from "../../package.json" with { type: "json" };
15
+
16
+ export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
+ export const AUTO_UPDATE_QUARANTINE_MS = 24 * 60 * 60 * 1000;
18
+ export const SDK_PACKAGE_NAME = packageMetadata.name;
19
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
20
+
21
+ const SKIP_COMMANDS: Readonly<Record<string, true>> = {
22
+ config: true,
23
+ help: true,
24
+ version: true,
25
+ };
26
+
27
+ export type AutoUpdateState = {
28
+ readonly lastCheckedAt: string;
29
+ };
30
+
31
+ export type NpmPackageView = {
32
+ readonly "dist-tags"?: Readonly<Record<string, string>>;
33
+ readonly time?: Readonly<Record<string, string>>;
34
+ };
35
+
36
+ export type AutoUpdateInstall = (options: {
37
+ readonly packageName: string;
38
+ readonly version: string;
39
+ readonly registry: string;
40
+ readonly environment: CliEnvironment;
41
+ }) => Promise<void>;
42
+
43
+ export type AutoUpdateReexec = (options: {
44
+ readonly argv: readonly string[];
45
+ readonly environment: CliEnvironment;
46
+ }) => Promise<number>;
47
+
48
+ export type AutoUpdateHooks = {
49
+ readonly now?: () => Date;
50
+ readonly fetchRegistry?: (url: string) => Promise<NpmPackageView>;
51
+ readonly install?: AutoUpdateInstall;
52
+ readonly reexec?: AutoUpdateReexec;
53
+ readonly isGlobalInstall?: () => boolean | Promise<boolean>;
54
+ };
55
+
56
+ export type AutoUpdateResult =
57
+ | { readonly status: "continue" }
58
+ | { readonly status: "reexec"; readonly exitCode: number };
59
+
60
+ type CliIoWriter = {
61
+ readonly stderr: (text: string) => void;
62
+ };
63
+
64
+ type UpdateCheck = { readonly status: "skip" } | { readonly status: "check"; readonly now: Date };
65
+
66
+ type UpdateCandidate =
67
+ | { readonly status: "skip" }
68
+ | { readonly status: "update"; readonly currentVersion: string; readonly version: string };
69
+
70
+ export const autoUpdateStatePath = (environment: CliEnvironment = process.env): string =>
71
+ join(configDirectory(environment), "auto-update.json");
72
+
73
+ export const npmRegistryUrl = (): string => DEFAULT_NPM_REGISTRY;
74
+
75
+ export const npmPackageMetadataUrl = (packageName: string): string =>
76
+ new URL(encodeURIComponent(packageName), DEFAULT_NPM_REGISTRY).toString();
77
+
78
+ export const parseSdkVersion = (version: string): readonly number[] | undefined => {
79
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-preview\.(\d+))?$/u.exec(version);
80
+ if (match === null) return undefined;
81
+ const [, major, minor, patch, preview] = match;
82
+ return [
83
+ Number(major),
84
+ Number(minor),
85
+ Number(patch),
86
+ preview === undefined ? Number.POSITIVE_INFINITY : Number(preview),
87
+ ];
88
+ };
89
+
90
+ export const compareSdkVersions = (left: string, right: string): number | undefined => {
91
+ const parsedLeft = parseSdkVersion(left);
92
+ const parsedRight = parseSdkVersion(right);
93
+ if (parsedLeft === undefined || parsedRight === undefined) return undefined;
94
+ const length = Math.max(parsedLeft.length, parsedRight.length);
95
+ for (let index = 0; index < length; index += 1) {
96
+ const leftPart = parsedLeft[index] ?? 0;
97
+ const rightPart = parsedRight[index] ?? 0;
98
+ if (leftPart < rightPart) return -1;
99
+ if (leftPart > rightPart) return 1;
100
+ }
101
+ return 0;
102
+ };
103
+
104
+ const asStringRecord = (value: unknown): Readonly<Record<string, string>> | undefined => {
105
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
106
+ const record: Record<string, string> = {};
107
+ for (const [key, entry] of Object.entries(value)) {
108
+ if (typeof entry !== "string") return undefined;
109
+ record[key] = entry;
110
+ }
111
+ return record;
112
+ };
113
+
114
+ export const asNpmPackageView = (payload: unknown): NpmPackageView | undefined => {
115
+ if (typeof payload !== "object" || payload === null) return undefined;
116
+ const tags = asStringRecord(Reflect.get(payload, "dist-tags"));
117
+ const time = asStringRecord(Reflect.get(payload, "time"));
118
+ if (tags === undefined && time === undefined) return {};
119
+ return {
120
+ ...(tags === undefined ? {} : { "dist-tags": tags }),
121
+ ...(time === undefined ? {} : { time }),
122
+ };
123
+ };
124
+
125
+ export const latestPublishedVersion = (view: NpmPackageView): string | undefined => {
126
+ const tags = view["dist-tags"];
127
+ if (tags === undefined) return undefined;
128
+ const preview = tags.preview;
129
+ if (typeof preview === "string" && preview.length > 0) return preview;
130
+ const latest = tags.latest;
131
+ return typeof latest === "string" && latest.length > 0 ? latest : undefined;
132
+ };
133
+
134
+ const publishedAt = (view: NpmPackageView, version: string): Date | undefined => {
135
+ const raw = view.time?.[version];
136
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
137
+ const date = new Date(raw);
138
+ return Number.isNaN(date.getTime()) ? undefined : date;
139
+ };
140
+
141
+ const asState = (payload: unknown): AutoUpdateState | undefined => {
142
+ if (typeof payload !== "object" || payload === null) return undefined;
143
+ const lastCheckedAt = Reflect.get(payload, "lastCheckedAt");
144
+ if (typeof lastCheckedAt !== "string" || lastCheckedAt.length === 0) return undefined;
145
+ const date = new Date(lastCheckedAt);
146
+ if (Number.isNaN(date.getTime())) return undefined;
147
+ return { lastCheckedAt };
148
+ };
149
+
150
+ export async function readAutoUpdateState(
151
+ environment: CliEnvironment = process.env,
152
+ ): Promise<AutoUpdateState | undefined> {
153
+ try {
154
+ return asState(JSON.parse(await readFile(autoUpdateStatePath(environment), "utf8")));
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ }
159
+
160
+ export async function writeAutoUpdateState(
161
+ state: AutoUpdateState,
162
+ environment: CliEnvironment = process.env,
163
+ ): Promise<string> {
164
+ const path = autoUpdateStatePath(environment);
165
+ await preparePrivateFilePath(path);
166
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
167
+ await chmod(path, 0o600);
168
+ return path;
169
+ }
170
+
171
+ const BUN_GLOBAL_MARKER = `${sep}install${sep}global${sep}`;
172
+
173
+ export const isGlobalCliInstall = (
174
+ entry = process.argv[1],
175
+ runtimePath = findCliEntry(),
176
+ ): boolean =>
177
+ [entry, runtimePath].some((path) => {
178
+ if (typeof path !== "string") return false;
179
+ try {
180
+ return realpathSync(path).includes(BUN_GLOBAL_MARKER);
181
+ } catch {
182
+ return path.includes(BUN_GLOBAL_MARKER);
183
+ }
184
+ });
185
+
186
+ export function autoUpdateEnabled(
187
+ environment: CliEnvironment,
188
+ settings: { readonly autoUpdate: boolean },
189
+ ): boolean {
190
+ if (isTruthyFlag(environment.TOLOKA_SKIP_AUTO_UPDATE)) return false;
191
+ const explicit = environment.TOLOKA_AUTO_UPDATE;
192
+ if (isFalseyFlag(explicit)) return false;
193
+ if (isTruthyFlag(explicit)) return true;
194
+ if (environment[CHECK_RESULT_FILE_ENV] !== undefined) return false;
195
+ if (isTruthyFlag(environment.CI)) return false;
196
+ if (isTruthyFlag(environment.VITEST)) return false;
197
+ return settings.autoUpdate;
198
+ }
199
+
200
+ const skippedCommand = (argv: readonly string[]): boolean => {
201
+ const command = commandNameFromArgv(argv);
202
+ return SKIP_COMMANDS[command] === true || command.startsWith("config ");
203
+ };
204
+
205
+ const collectOutput = async (stream: NodeJS.ReadableStream | null): Promise<string> => {
206
+ if (stream === null) return "";
207
+ const chunks: Buffer[] = [];
208
+ stream.on("data", (chunk: Buffer | string) => {
209
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
210
+ });
211
+ await once(stream, "end").catch(() => undefined);
212
+ return Buffer.concat(chunks).toString("utf8");
213
+ };
214
+
215
+ class RegistryError extends Error {
216
+ constructor(message: string) {
217
+ super(message);
218
+ this.name = "RegistryError";
219
+ }
220
+ }
221
+
222
+ const defaultFetchRegistry = async (url: string): Promise<NpmPackageView> => {
223
+ const response = await fetch(url, {
224
+ headers: { accept: "application/json" },
225
+ signal: AbortSignal.timeout(3000),
226
+ });
227
+ if (!response.ok) throw new RegistryError(`npm registry responded ${response.status}`);
228
+ const view = asNpmPackageView(await response.json());
229
+ if (view === undefined) throw new RegistryError("npm registry returned invalid package metadata");
230
+ return view;
231
+ };
232
+
233
+ const defaultInstall: AutoUpdateInstall = async ({
234
+ packageName,
235
+ version,
236
+ registry,
237
+ environment,
238
+ }) => {
239
+ const child = spawn(
240
+ process.execPath,
241
+ ["add", "--global", `${packageName}@${version}`, `--registry=${registry}`],
242
+ {
243
+ env: { ...process.env, ...environment },
244
+ stdio: ["ignore", "pipe", "pipe"],
245
+ },
246
+ );
247
+ const [close, stdout, stderr] = await Promise.all([
248
+ once(child, "close"),
249
+ collectOutput(child.stdout),
250
+ collectOutput(child.stderr),
251
+ ]);
252
+ const exitCode = close[0];
253
+ if (exitCode === 0) return;
254
+ const detail = [stderr.trim(), stdout.trim()].filter((part) => part.length > 0).join("\n");
255
+ throw new Error(
256
+ detail.length > 0
257
+ ? `bun add --global failed: ${detail}`
258
+ : `bun add --global exited ${String(exitCode)}`,
259
+ );
260
+ };
261
+
262
+ const defaultReexec: AutoUpdateReexec = async ({ argv, environment }) => {
263
+ const entry = process.argv[1];
264
+ const child = spawn(process.execPath, entry === undefined ? [...argv] : [entry, ...argv], {
265
+ env: { ...process.env, ...environment, TOLOKA_SKIP_AUTO_UPDATE: "1" },
266
+ stdio: "inherit",
267
+ });
268
+ const [code] = await once(child, "close");
269
+ return typeof code === "number" ? code : 1;
270
+ };
271
+
272
+ const continueResult = (): AutoUpdateResult => ({ status: "continue" });
273
+
274
+ const shouldCheckForUpdate = async (options: {
275
+ readonly argv: readonly string[];
276
+ readonly environment: CliEnvironment;
277
+ readonly hooks: AutoUpdateHooks;
278
+ }): Promise<UpdateCheck> => {
279
+ const { argv, environment, hooks } = options;
280
+ if (skippedCommand(argv)) return { status: "skip" };
281
+ const isGlobal = hooks.isGlobalInstall ?? (() => isGlobalCliInstall());
282
+ if (!(await isGlobal())) return { status: "skip" };
283
+ const settings = await readCliSettings(environment);
284
+ if (!autoUpdateEnabled(environment, settings)) return { status: "skip" };
285
+
286
+ const now = hooks.now?.() ?? new Date();
287
+ const state = await readAutoUpdateState(environment);
288
+ if (state !== undefined) {
289
+ const lastCheckedAt = new Date(state.lastCheckedAt).getTime();
290
+ if (now.getTime() - lastCheckedAt < AUTO_UPDATE_INTERVAL_MS) return { status: "skip" };
291
+ }
292
+ return { status: "check", now };
293
+ };
294
+
295
+ const selectUpdateCandidate = async (options: {
296
+ readonly environment: CliEnvironment;
297
+ readonly io: CliIoWriter;
298
+ readonly now: Date;
299
+ readonly currentVersion: string;
300
+ readonly fetchRegistry: (url: string) => Promise<NpmPackageView>;
301
+ }): Promise<UpdateCandidate> => {
302
+ const { environment, io, now, currentVersion, fetchRegistry } = options;
303
+ try {
304
+ await writeAutoUpdateState({ lastCheckedAt: now.toISOString() }, environment);
305
+ } catch {}
306
+
307
+ let view: NpmPackageView;
308
+ try {
309
+ view = await fetchRegistry(npmPackageMetadataUrl(SDK_PACKAGE_NAME));
310
+ } catch (error) {
311
+ io.stderr(
312
+ `Auto-update check failed: ${error instanceof Error ? error.message : String(error)}.`,
313
+ );
314
+ return { status: "skip" };
315
+ }
316
+
317
+ const version = latestPublishedVersion(view);
318
+ if (version === undefined) return { status: "skip" };
319
+ const order = compareSdkVersions(currentVersion, version);
320
+ if (order === undefined || order >= 0) return { status: "skip" };
321
+ const published = publishedAt(view, version);
322
+ if (published === undefined) return { status: "skip" };
323
+ if (now.getTime() - published.getTime() < AUTO_UPDATE_QUARANTINE_MS) return { status: "skip" };
324
+ return { status: "update", currentVersion, version };
325
+ };
326
+
327
+ export async function maybeAutoUpdate(options: {
328
+ readonly argv: readonly string[];
329
+ readonly environment: CliEnvironment;
330
+ readonly io: CliIoWriter;
331
+ readonly currentVersion?: string;
332
+ readonly hooks?: AutoUpdateHooks;
333
+ }): Promise<AutoUpdateResult> {
334
+ const { argv, environment, io, hooks = {} } = options;
335
+ const check = await shouldCheckForUpdate({ argv, environment, hooks });
336
+ if (check.status === "skip") return continueResult();
337
+
338
+ const candidate = await selectUpdateCandidate({
339
+ environment,
340
+ io,
341
+ now: check.now,
342
+ currentVersion: options.currentVersion ?? SDK_VERSION,
343
+ fetchRegistry: hooks.fetchRegistry ?? defaultFetchRegistry,
344
+ });
345
+ if (candidate.status === "skip") return continueResult();
346
+
347
+ const install = hooks.install ?? defaultInstall;
348
+ try {
349
+ await install({
350
+ packageName: SDK_PACKAGE_NAME,
351
+ version: candidate.version,
352
+ registry: npmRegistryUrl(),
353
+ environment,
354
+ });
355
+ } catch (error) {
356
+ io.stderr(
357
+ `Auto-update to ${candidate.version} failed: ${error instanceof Error ? error.message : String(error)}.`,
358
+ );
359
+ return continueResult();
360
+ }
361
+
362
+ io.stderr(
363
+ `Updated ${SDK_PACKAGE_NAME} from ${candidate.currentVersion} to ${candidate.version}.`,
364
+ );
365
+ const reexec = hooks.reexec ?? defaultReexec;
366
+ return { status: "reexec", exitCode: await reexec({ argv, environment }) };
367
+ }
@@ -0,0 +1,13 @@
1
+ export const isTruthyFlag = (value: string | undefined): boolean => {
2
+ if (value === undefined) return false;
3
+ const normalized = value.trim().toLowerCase();
4
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
5
+ };
6
+
7
+ export const isFalseyFlag = (value: string | undefined): boolean => {
8
+ if (value === undefined) return false;
9
+ const normalized = value.trim().toLowerCase();
10
+ return (
11
+ normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off"
12
+ );
13
+ };
package/src/cli/check.ts CHANGED
@@ -1,11 +1,10 @@
1
- import { existsSync, watch } from "node:fs";
1
+ import { watch } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
4
  import { once } from "node:events";
5
5
  import { mkdtemp, readFile, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
8
 
10
9
  import { assert } from "es-toolkit";
11
10
  import {
@@ -25,6 +24,7 @@ import {
25
24
  type TestEvidence,
26
25
  type TestEvidenceKind,
27
26
  } from "../testing/test-evidence";
27
+ import { resolveCliEntry } from "./cli-entry";
28
28
  import { loadProjectSource, type ProjectSource } from "./source";
29
29
 
30
30
  export type CheckStatus = "passed" | "failed" | "missing" | "partial" | "infrastructure_error";
@@ -866,8 +866,7 @@ const parseFreshCheckResult = (value: unknown, json: boolean): CheckProjectResul
866
866
  };
867
867
  };
868
868
  export const runFreshCheck = async (options: CheckProjectOptions): Promise<CheckProjectResult> => {
869
- const sourceEntry = fileURLToPath(new URL("./bin.ts", import.meta.url));
870
- const entry = existsSync(sourceEntry) ? sourceEntry : fileURLToPath(import.meta.url);
869
+ const entry = resolveCliEntry();
871
870
  const resultDirectory = await mkdtemp(join(tmpdir(), "toloka-check-watch-result-"));
872
871
  const resultPath = join(resultDirectory, "result.json");
873
872
  let run: ToolRunResult | undefined;
@@ -0,0 +1,41 @@
1
+ import { assert } from "es-toolkit";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const CLI_ENTRY = join("bin", "toloka.js");
6
+
7
+ const runtimePath = (entry: string): string => {
8
+ try {
9
+ return realpathSync(entry);
10
+ } catch {
11
+ return entry;
12
+ }
13
+ };
14
+
15
+ const findCliEntryAbove = (directory: string): string | undefined => {
16
+ const candidate = join(directory, CLI_ENTRY);
17
+ if (existsSync(candidate)) return candidate;
18
+ const parent = dirname(directory);
19
+ return parent === directory ? undefined : findCliEntryAbove(parent);
20
+ };
21
+
22
+ export const findCliEntry = (
23
+ runtimeEntry: string | undefined = process.argv[1],
24
+ ): string | undefined => {
25
+ const fromRuntimeEntry =
26
+ runtimeEntry === undefined ? undefined : findCliEntryAbove(dirname(runtimePath(runtimeEntry)));
27
+ return fromRuntimeEntry ?? findCliEntryAbove(process.cwd());
28
+ };
29
+
30
+ export const resolveCliEntry = (runtimeEntry: string | undefined = process.argv[1]): string => {
31
+ const entry = runtimeEntry === undefined ? undefined : runtimePath(runtimeEntry);
32
+ if (entry !== undefined && existsSync(entry) && findCliEntryAbove(dirname(entry)) !== undefined) {
33
+ return entry;
34
+ }
35
+ const workingDirectoryEntry = findCliEntryAbove(process.cwd());
36
+ assert(
37
+ workingDirectoryEntry !== undefined,
38
+ `Cannot locate the toloka CLI entry point. An installed @toloka-platform/sdk ships ${CLI_ENTRY} in a directory above its runtime entry, but none exists above ${runtimeEntry ?? "the unset process.argv[1]"} or above ${process.cwd()}. Reinstall @toloka-platform/sdk.`,
39
+ );
40
+ return workingDirectoryEntry;
41
+ };
@@ -52,6 +52,9 @@ export const credentialsPath = (environment: CliEnvironment = process.env): stri
52
52
  export const legacyCredentialsPath = (environment: CliEnvironment = process.env): string =>
53
53
  join(home(environment), ".toloka", "credentials.json");
54
54
 
55
+ export const configDirectory = (environment: CliEnvironment = process.env): string =>
56
+ dirname(credentialsPath(environment));
57
+
55
58
  const removeTrailingSlashes = (value: string): string => {
56
59
  let end = value.length;
57
60
  while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;