@toloka-platform/sdk 0.11.1 → 0.11.3

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 (89) hide show
  1. package/README.md +4 -2
  2. package/dist/cli/toloka.js +167 -160
  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 +15 -5
  7. package/docs/cli.md +48 -16
  8. package/docs/getting-started.md +1 -1
  9. package/docs/limitations.md +2 -2
  10. package/docs/testing.md +5 -3
  11. package/docs/troubleshooting.md +1 -1
  12. package/examples/README.md +11 -0
  13. package/examples/app-crowdtesting/README.md +41 -0
  14. package/examples/app-crowdtesting/fields.ts +6 -0
  15. package/examples/app-crowdtesting/nodes/app-flow-test/node.ts +381 -0
  16. package/examples/app-crowdtesting/nodes/collection/node.ts +8 -0
  17. package/examples/app-crowdtesting/pipeline.browser.integration.spec.ts +77 -0
  18. package/examples/app-crowdtesting/pipeline.ts +17 -0
  19. package/examples/egocentric-video-collection/README.md +35 -0
  20. package/examples/egocentric-video-collection/fields.ts +24 -0
  21. package/examples/egocentric-video-collection/nodes/collection/node.ts +5 -0
  22. package/examples/egocentric-video-collection/nodes/record-activity/node.ts +760 -0
  23. package/examples/egocentric-video-collection/pipeline.browser.integration.spec.ts +65 -0
  24. package/examples/egocentric-video-collection/pipeline.ts +17 -0
  25. package/examples/product-moderation/README.md +49 -0
  26. package/examples/product-moderation/fields.ts +37 -0
  27. package/examples/product-moderation/nodes/collection/node.ts +16 -0
  28. package/examples/product-moderation/nodes/moderate-product/instruction.ts +77 -0
  29. package/examples/product-moderation/nodes/moderate-product/node.ts +368 -0
  30. package/examples/product-moderation/pipeline.browser.integration.spec.ts +48 -0
  31. package/examples/product-moderation/pipeline.ts +33 -0
  32. package/examples/video-pair-rating/fields.ts +16 -4
  33. package/examples/video-pair-rating/nodes/adjudicate/node.ts +78 -3
  34. package/examples/video-pair-rating/nodes/adjudicate_op/node.ts +28 -3
  35. package/examples/video-pair-rating/nodes/overall_preference_pick/node.ts +28 -3
  36. package/examples/video-pair-rating/nodes/rate_pair/node.ts +65 -2
  37. package/openapi/gen/api.ts +22 -0
  38. package/openapi/gen/models/generationBudget.ts +8 -0
  39. package/openapi/gen/models/generationBudgetReasoningEffort.ts +2 -0
  40. package/openapi/gen/models/generationModel.ts +12 -0
  41. package/openapi/gen/models/generationModelDefaultReasoningEffort.ts +2 -0
  42. package/openapi/gen/models/generationModelId.ts +2 -0
  43. package/openapi/gen/models/index.ts +8 -0
  44. package/openapi/gen/models/invalidFieldSchema.ts +6 -0
  45. package/openapi/gen/models/listGenerationModels200.ts +7 -0
  46. package/openapi/gen/models/listGenerationModels200DefaultGenerationModelId.ts +2 -0
  47. package/openapi/gen/models/pipelineGraphSnapshot.ts +2 -0
  48. package/package.json +2 -2
  49. package/skills/catalog.json +16 -12
  50. package/skills/toloka-build/SKILL.md +44 -56
  51. package/skills/toloka-build/references/human-work.md +156 -0
  52. package/skills/toloka-build/references/local-setup.md +136 -0
  53. package/skills/toloka-build/references/node-checks.md +11 -0
  54. package/skills/toloka-build/references/node-semantics.md +22 -213
  55. package/skills/toloka-build/references/task-ui.md +61 -0
  56. package/skills/toloka-build/references/workflow-and-cli.md +29 -227
  57. package/skills/toloka-fine-tuning/SKILL.md +1 -1
  58. package/skills/toloka-get-started/SKILL.md +1 -1
  59. package/skills/toloka-gisting/SKILL.md +1 -1
  60. package/src/authoring/index.ts +26 -7
  61. package/src/checks/task-ui-typecheck.ts +3 -3
  62. package/src/cli/analytics.ts +1 -0
  63. package/src/cli/auth.ts +51 -18
  64. package/src/cli/check.ts +88 -17
  65. package/src/cli/commands/auth.ts +5 -3
  66. package/src/cli/commands/generation-model.ts +18 -0
  67. package/src/cli/commands/pipeline.ts +1 -1
  68. package/src/cli/commands/project.ts +7 -2
  69. package/src/cli/commands/source.ts +14 -3
  70. package/src/cli/commands/ui.ts +23 -0
  71. package/src/cli/config.ts +15 -9
  72. package/src/cli/context.ts +30 -12
  73. package/src/cli/credentials.ts +2 -2
  74. package/src/cli/environment.ts +5 -1
  75. package/src/cli/generation-models.ts +46 -0
  76. package/src/cli/program.ts +7 -0
  77. package/src/cli/project-context.ts +5 -2
  78. package/src/cli/project.ts +288 -138
  79. package/src/cli/trainings.ts +12 -0
  80. package/src/cli/ui.ts +63 -0
  81. package/src/client.ts +5 -0
  82. package/src/core-entity-types.json +32 -0
  83. package/src/graph-rules.json +11 -0
  84. package/src/importer.ts +46 -40
  85. package/src/index.ts +4 -0
  86. package/src/testing/task-ui-browser.ts +45 -2
  87. package/src/testing/task-ui.ts +2 -9
  88. package/src/transport.ts +28 -6
  89. package/src/validate.ts +232 -2
package/src/cli/auth.ts CHANGED
@@ -451,27 +451,47 @@ export type AuthStatusOptions = {
451
451
  export type AuthStatusResult = { readonly authenticated: boolean; readonly output: string };
452
452
 
453
453
  type ResolvedValue = { readonly value: string; readonly source: string };
454
+ type ResolvedCredential = ResolvedValue & {
455
+ readonly type: "apiKey" | "scopedToken";
456
+ readonly environmentVariable?: "TOLOKA_SCOPED_TOKEN" | "TOLOKA_API_KEY";
457
+ };
454
458
 
455
- const firstDefined = (
456
- candidates: ReadonlyArray<ResolvedValue | undefined>,
457
- ): ResolvedValue | undefined => candidates.find((candidate) => candidate !== undefined);
459
+ const firstDefined = <T>(candidates: ReadonlyArray<T | undefined>): T | undefined =>
460
+ candidates.find((candidate) => candidate !== undefined);
458
461
 
459
- const resolveStatusApiKey = (
462
+ const resolveStatusCredential = (
460
463
  options: AuthStatusOptions,
461
464
  environment: CliEnvironment,
462
465
  found: FoundCredentials | undefined,
463
466
  storedPath: string,
464
- ): ResolvedValue | undefined =>
465
- firstDefined([
467
+ ): ResolvedCredential | undefined =>
468
+ firstDefined<ResolvedCredential>([
466
469
  options.flagApiKey === undefined
467
470
  ? undefined
468
- : { value: options.flagApiKey, source: "from --api-key" },
471
+ : { type: "apiKey", value: options.flagApiKey, source: "from --api-key" },
472
+ environment.TOLOKA_SCOPED_TOKEN === undefined
473
+ ? undefined
474
+ : {
475
+ type: "scopedToken",
476
+ value: environment.TOLOKA_SCOPED_TOKEN,
477
+ source: "from TOLOKA_SCOPED_TOKEN",
478
+ environmentVariable: "TOLOKA_SCOPED_TOKEN",
479
+ },
469
480
  environment.TOLOKA_API_KEY === undefined
470
481
  ? undefined
471
- : { value: environment.TOLOKA_API_KEY, source: "from TOLOKA_API_KEY" },
482
+ : {
483
+ type: "apiKey",
484
+ value: environment.TOLOKA_API_KEY,
485
+ source: "from TOLOKA_API_KEY",
486
+ environmentVariable: "TOLOKA_API_KEY",
487
+ },
472
488
  found?.credentials.apiKey === undefined
473
489
  ? undefined
474
- : { value: found.credentials.apiKey, source: `stored in ${storedPath}` },
490
+ : {
491
+ type: "apiKey",
492
+ value: found.credentials.apiKey,
493
+ source: `stored in ${storedPath}`,
494
+ },
475
495
  ]);
476
496
 
477
497
  const resolvePlatform = (
@@ -576,17 +596,23 @@ export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthS
576
596
  const now = (options.now ?? unixSeconds)();
577
597
  const credentialLookup =
578
598
  options.credentialLookup ??
579
- (await findCommandCredentials(environment, options.flagApiKey ?? environment.TOLOKA_API_KEY));
599
+ (await findCommandCredentials(
600
+ environment,
601
+ options.flagApiKey ??
602
+ environment.TOLOKA_SCOPED_TOKEN ??
603
+ environment.TOLOKA_API_KEY,
604
+ ));
580
605
  const { found, warning } = credentialLookup;
581
606
  const storedPath = found?.path ?? credentialsPath(environment);
582
- const apiKey = resolveStatusApiKey(options, environment, found, storedPath);
607
+ const credential = resolveStatusCredential(options, environment, found, storedPath);
583
608
  const platform = resolvePlatform(options, environment, found);
584
- const browser = apiKey === undefined ? await findBrowserLogin(platform.value, environment) : undefined;
609
+ const browser =
610
+ credential === undefined ? await findBrowserLogin(platform.value, environment) : undefined;
585
611
  const projectLine = formatProjectContextLine(options.project, found?.credentials);
586
612
 
587
613
  const lines: string[] = warning === undefined ? [] : [warning, ""];
588
614
 
589
- if (apiKey === undefined && browser === undefined) {
615
+ if (credential === undefined && browser === undefined) {
590
616
  lines.push(
591
617
  "Not logged in.",
592
618
  "",
@@ -600,11 +626,15 @@ export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthS
600
626
  const { value: baseUrl, source: urlSource } = platform;
601
627
  lines.push(`Platform: ${baseUrl} (${urlSource})`);
602
628
  let clientConfig: ClientConfig;
603
- if (apiKey !== undefined) {
629
+ if (credential !== undefined) {
630
+ const label = credential.type === "scopedToken" ? "Scoped token:" : "API key: ";
604
631
  lines.push(
605
- `API key: ${options.showToken === true ? apiKey.value : maskApiKey(apiKey.value)} (${apiKey.source})`,
632
+ `${label} ${options.showToken === true ? credential.value : maskApiKey(credential.value)} (${credential.source})`,
606
633
  );
607
- clientConfig = { baseUrl, apiKey: apiKey.value };
634
+ clientConfig =
635
+ credential.type === "scopedToken"
636
+ ? { baseUrl, scopedToken: credential.value }
637
+ : { baseUrl, apiKey: credential.value };
608
638
  } else {
609
639
  assertCli(browser, "No login is in effect.", { hint: { description: "Run toloka login." } });
610
640
  const status = await browserStatus(browser, baseUrl, environment, options, now);
@@ -617,8 +647,11 @@ export async function authStatus(options: AuthStatusOptions = {}): Promise<AuthS
617
647
  }
618
648
  lines.push(projectLine);
619
649
 
620
- if (environment.TOLOKA_API_KEY !== undefined && found?.credentials.apiKey !== undefined) {
621
- lines.push("", `TOLOKA_API_KEY is set, so the key stored in ${found.path} is ignored.`);
650
+ if (found?.credentials.apiKey !== undefined && credential?.environmentVariable !== undefined) {
651
+ lines.push(
652
+ "",
653
+ `${credential.environmentVariable} is set, so the key stored in ${found.path} is ignored.`,
654
+ );
622
655
  }
623
656
  if (found?.legacy === true) {
624
657
  lines.push(
package/src/cli/check.ts CHANGED
@@ -23,14 +23,21 @@ import {
23
23
  type TestEvidence,
24
24
  type TestEvidenceKind,
25
25
  } from "../testing/test-evidence";
26
+ import { platformRuleIssues } from "../validate";
26
27
  import { cliRelaunchCommand } from "./cli-entry";
27
28
  import { bunSubprocessEnvironment } from "./native-runtime";
28
- import { API_KEY_ENVIRONMENT_NAMES } from "./environment";
29
+ import { CREDENTIAL_ENVIRONMENT_NAMES } from "./environment";
29
30
  import { loadProjectSource, type ProjectSource } from "./source";
30
31
  import { assertCli, REPORT_HINT, TolokaCliError } from "./cli-error";
31
32
  import type { TolokaCliErrorOptions } from "./cli-error";
32
33
 
33
- export type CheckStatus = "passed" | "failed" | "missing" | "partial" | "infrastructure_error";
34
+ export type CheckStatus =
35
+ | "passed"
36
+ | "failed"
37
+ | "missing"
38
+ | "partial"
39
+ | "warning"
40
+ | "infrastructure_error";
34
41
  export type CheckKind =
35
42
  | "source"
36
43
  | "project-types"
@@ -38,6 +45,7 @@ export type CheckKind =
38
45
  | "code-test"
39
46
  | "ui-test"
40
47
  | "pipeline-flow"
48
+ | "platform-rules"
41
49
  | "test-runner";
42
50
 
43
51
  export type CheckDiagnostic = {
@@ -276,13 +284,13 @@ const loadSourceWithoutPlatformCredentials = async (
276
284
  environment: NodeJS.ProcessEnv,
277
285
  ): Promise<ProjectSource> => {
278
286
  const targets = [process.env, environment];
279
- const saved = targets.map((current) => API_KEY_ENVIRONMENT_NAMES.map((name) => current[name]));
280
- for (const current of targets) for (const name of API_KEY_ENVIRONMENT_NAMES) delete current[name];
287
+ const saved = targets.map((current) => CREDENTIAL_ENVIRONMENT_NAMES.map((name) => current[name]));
288
+ for (const current of targets) for (const name of CREDENTIAL_ENVIRONMENT_NAMES) delete current[name];
281
289
  try {
282
290
  return await loadProjectSource(sourceRoot);
283
291
  } finally {
284
292
  targets.forEach((current, target) => {
285
- API_KEY_ENVIRONMENT_NAMES.forEach((name, index) => {
293
+ CREDENTIAL_ENVIRONMENT_NAMES.forEach((name, index) => {
286
294
  const value = saved[target]?.[index];
287
295
  if (value === undefined) delete current[name];
288
296
  else current[name] = value;
@@ -504,6 +512,39 @@ const uiTypeResults = async (
504
512
  return results;
505
513
  };
506
514
 
515
+ const platformRuleResults = (
516
+ source: ProjectSource,
517
+ selected: readonly CheckInventoryPipeline[],
518
+ ): CheckResult[] => {
519
+ const selectedDirectories = new Set(selected.map((pipeline) => pipeline.sourceDirectory));
520
+ return source.pipelines
521
+ .filter((pipeline) => selectedDirectories.has(pipeline.sourceDirectory))
522
+ .map((pipeline) => {
523
+ const issues = platformRuleIssues(pipeline.definition.graph);
524
+ return {
525
+ scope: "pipeline",
526
+ kind: "platform-rules",
527
+ status: issues.length === 0 ? "passed" : "warning",
528
+ sourceDirectory: pipeline.sourceDirectory,
529
+ pipelineFingerprint: pipelineFingerprint(pipeline.definition),
530
+ ...boundedDiagnostics(
531
+ issues.length === 0
532
+ ? []
533
+ : [
534
+ ...issues.map((issue) => ({
535
+ message: `node ${issue.node}: ${issue.reason}`,
536
+ severity: "warning" as const,
537
+ })),
538
+ {
539
+ message: "toloka apply refuses to write this pipeline until these are fixed.",
540
+ severity: "warning" as const,
541
+ },
542
+ ],
543
+ ),
544
+ };
545
+ });
546
+ };
547
+
507
548
  const evidenceStatus = (
508
549
  evidence: readonly TestEvidence[],
509
550
  ): Pick<CheckResult, "status" | "diagnostics" | "truncatedDiagnosticCount"> => {
@@ -536,9 +577,9 @@ const evidenceStatus = (
536
577
  status: "partial",
537
578
  diagnostics: [
538
579
  {
539
- message: `Hardware-backed fields not fully covered: ${[
580
+ message: `Hardware-backed fields not covered by a real device: ${[
540
581
  ...new Set(passing.flatMap((entry) => entry.untestedHardwareFields ?? [])),
541
- ].join(", ")}`,
582
+ ].join(", ")}. This does not block apply; try the task on a real device before launching a run.`,
542
583
  },
543
584
  ],
544
585
  };
@@ -559,6 +600,38 @@ const evidenceFor = (
559
600
  (nodeId === undefined || entry.nodeId === nodeId),
560
601
  );
561
602
 
603
+ const typedTestUsage: Record<TestEvidenceKind, { call: string; counts: string }> = {
604
+ code: { call: "test.code(pipeline, node, name, callback)", counts: "runs the node" },
605
+ ui: { call: "test.ui(pipeline, node, name, callback)", counts: "submits the rendered task" },
606
+ pipeline: { call: "test.pipeline(pipeline, name, callback)", counts: "runs the connected nodes" },
607
+ };
608
+
609
+ const coverageStatus = (
610
+ evidence: readonly TestEvidence[],
611
+ inventory: readonly CheckInventoryPipeline[],
612
+ pipeline: CheckInventoryPipeline,
613
+ kind: TestEvidenceKind,
614
+ nodeId?: string,
615
+ ): Pick<CheckResult, "status" | "diagnostics" | "truncatedDiagnosticCount"> => {
616
+ const status = evidenceStatus(evidenceFor(evidence, pipeline, kind, nodeId));
617
+ if (status.status !== "missing") return status;
618
+ const target = nodeId === undefined ? "this pipeline" : "this node";
619
+ const { call, counts } = typedTestUsage[kind];
620
+ const knownFingerprints = new Set(inventory.map((candidate) => candidate.pipelineFingerprint));
621
+ const strayTests = evidence.filter(
622
+ (entry) =>
623
+ entry.kind === kind &&
624
+ entry.status === "passed" &&
625
+ entry.nodeId === nodeId &&
626
+ !knownFingerprints.has(entry.pipelineFingerprint),
627
+ );
628
+ const message =
629
+ strayTests.length > 0
630
+ ? `Passing ${kind} tests for ${target} (${strayTests.length}) ran against a different pipeline definition than the one ${pipeline.sourceDirectory}/pipeline.ts exports and do not count. Pass that exported pipeline to the test instead of building one inside the test.`
631
+ : `No passing ${kind} test covers ${target}. Add ${call} with the pipeline ${pipeline.sourceDirectory}/pipeline.ts exports; it counts once it ${counts}, makes an assertion, and passes.`;
632
+ return { status: "missing", diagnostics: [{ message }] };
633
+ };
634
+
562
635
  const coverageResults = (
563
636
  inventory: readonly CheckInventoryPipeline[],
564
637
  evidence: readonly TestEvidence[],
@@ -576,7 +649,7 @@ const coverageResults = (
576
649
  sourceDirectory: pipeline.sourceDirectory,
577
650
  pipelineFingerprint: pipeline.pipelineFingerprint,
578
651
  nodeId: node.id,
579
- ...evidenceStatus(evidenceFor(evidence, pipeline, evidenceKind, node.id)),
652
+ ...coverageStatus(evidence, inventory, pipeline, evidenceKind, node.id),
580
653
  });
581
654
  }
582
655
  if (nodeSelector === undefined) {
@@ -585,7 +658,7 @@ const coverageResults = (
585
658
  kind: "pipeline-flow",
586
659
  sourceDirectory: pipeline.sourceDirectory,
587
660
  pipelineFingerprint: pipeline.pipelineFingerprint,
588
- ...evidenceStatus(evidenceFor(evidence, pipeline, "pipeline")),
661
+ ...coverageStatus(evidence, inventory, pipeline, "pipeline"),
589
662
  });
590
663
  }
591
664
  return result;
@@ -596,6 +669,7 @@ const statusLabel: Record<CheckStatus, string> = {
596
669
  failed: "FAIL",
597
670
  missing: "MISSING",
598
671
  partial: "PARTIAL",
672
+ warning: "WARN",
599
673
  infrastructure_error: "ERROR",
600
674
  };
601
675
 
@@ -629,13 +703,7 @@ export const formatCheckResults = (results: readonly CheckResult[]): string =>
629
703
 
630
704
  const exitCodeForResults = (results: readonly CheckResult[]): 0 | 1 | 2 => {
631
705
  if (results.some((result) => result.status === "infrastructure_error")) return 2;
632
- if (
633
- results.some(
634
- (result) =>
635
- result.status === "failed" || result.status === "missing" || result.status === "partial",
636
- )
637
- )
638
- return 1;
706
+ if (results.some((result) => result.status === "failed" || result.status === "missing")) return 1;
639
707
  return 0;
640
708
  };
641
709
 
@@ -693,7 +761,7 @@ export async function checkProject(
693
761
  try {
694
762
  const selectedPipeline = selected.pipelines.length === 1 ? selected.pipelines[0] : undefined;
695
763
  const checkEnvironment = { ...(options.environment ?? process.env) };
696
- for (const name of API_KEY_ENVIRONMENT_NAMES) delete checkEnvironment[name];
764
+ for (const name of CREDENTIAL_ENVIRONMENT_NAMES) delete checkEnvironment[name];
697
765
  const environment = {
698
766
  ...checkEnvironment,
699
767
  [TEST_EVIDENCE_DIR_ENV]: evidenceDirectory,
@@ -723,6 +791,7 @@ export async function checkProject(
723
791
  ...uiResults,
724
792
  projectToolResult("test-runner", vitestRun),
725
793
  ...coverageResults(selected.pipelines, evidence, selected.nodeId),
794
+ ...(selected.nodeId === undefined ? platformRuleResults(source, selected.pipelines) : []),
726
795
  ]);
727
796
  const exitCode = exitCodeForResults(results);
728
797
  return {
@@ -787,6 +856,7 @@ const isCheckKind = (value: unknown): value is CheckKind =>
787
856
  value === "code-test" ||
788
857
  value === "ui-test" ||
789
858
  value === "pipeline-flow" ||
859
+ value === "platform-rules" ||
790
860
  value === "test-runner";
791
861
 
792
862
  const isCheckStatus = (value: unknown): value is CheckStatus =>
@@ -794,6 +864,7 @@ const isCheckStatus = (value: unknown): value is CheckStatus =>
794
864
  value === "failed" ||
795
865
  value === "missing" ||
796
866
  value === "partial" ||
867
+ value === "warning" ||
797
868
  value === "infrastructure_error";
798
869
 
799
870
  const parseFreshCheckDiagnostic = (value: unknown): CheckDiagnostic => {
@@ -59,13 +59,15 @@ export const registerAuthCommands = (program: Command, host: CommandHost): void
59
59
  program
60
60
  .command("auth-status")
61
61
  .alias("whoami")
62
- .description("show which platform and login the CLI would use")
63
- .option("--show-token", "print the API key or access token instead of masking it", false)
62
+ .description("show which platform and credential the CLI would use")
63
+ .option("--show-token", "print the API key, scoped token, or access token instead of masking it", false)
64
64
  .action(async (options: { showToken: boolean }) => {
65
65
  const flags = globals();
66
66
  const credentialLookup = await findCommandCredentials(
67
67
  context.environment,
68
- flags.apiKey ?? context.environment.TOLOKA_API_KEY,
68
+ flags.apiKey ??
69
+ context.environment.TOLOKA_SCOPED_TOKEN ??
70
+ context.environment.TOLOKA_API_KEY,
69
71
  );
70
72
  const stored = credentialLookup.found?.credentials;
71
73
  const sourceRoot = await resolveSourceRoot(flags, context.io);
@@ -0,0 +1,18 @@
1
+ import type { Command } from "commander";
2
+ import { resolveContext, type CommandHost } from "../context";
3
+ import { listGenerationModels } from "../generation-models";
4
+
5
+ export const registerGenerationModelCommands = (program: Command, host: CommandHost): void => {
6
+ const { context, globals } = host;
7
+
8
+ program
9
+ .command("generation-model")
10
+ .description("inspect the models synthetic generation nodes can use")
11
+ .command("list")
12
+ .option("--json", "print JSON", false)
13
+ .description("list generation models with reasoning efforts and maxCostPerAttempt bounds")
14
+ .action(async (options: { json: boolean }) => {
15
+ const { client } = await resolveContext(globals(), context);
16
+ await listGenerationModels(client, { json: options.json }, context.io);
17
+ });
18
+ };
@@ -38,7 +38,7 @@ export const registerPipelineCommands = (program: Command, host: CommandHost): v
38
38
  .argument("[name]", pipelineNameHelp)
39
39
  .option("--wait", "wait for the run to reach a terminal state", false)
40
40
  .option("--timeout <ms>", "how long to wait", positiveInteger)
41
- .description("start a run on a deployed pipeline")
41
+ .description("start a run on an applied pipeline")
42
42
  .action(async (name: string | undefined, options: { wait: boolean; timeout?: number }) => {
43
43
  const { client, state, projectId } = await resolveContext(globals(), context);
44
44
  assertProjectSelected(projectId);
@@ -15,6 +15,7 @@ import { readTolokaState } from "../../state";
15
15
  import { assertCli } from "../cli-error";
16
16
  import { formatTable } from "../table";
17
17
  import type { CreateScopedTokenBodyAnyOfPreset, ScopedTokenSummary } from "../../../openapi/gen/models";
18
+ import { SCOPED_TOKEN_PREFIX } from "../../transport";
18
19
 
19
20
  const TOKEN_PRESETS: readonly CreateScopedTokenBodyAnyOfPreset[] = [
20
21
  "read",
@@ -157,8 +158,12 @@ export const registerProjectCommands = (program: Command, host: CommandHost): vo
157
158
  expiresIn?: number;
158
159
  projects?: string;
159
160
  }) => {
160
- const { client, projectId } = await resolveContext(globals(), context);
161
+ const { client, credential, projectId } = await resolveContext(globals(), context);
161
162
  assertProjectSelected(projectId);
163
+ const usesScopedToken =
164
+ credential.type === "scopedToken" ||
165
+ (credential.type === "apiKey" && credential.apiKey.startsWith(SCOPED_TOKEN_PREFIX));
166
+ assertCli(!usesScopedToken, "A scoped token cannot issue tokens.", { hint: { disabled: true } });
162
167
  const scope =
163
168
  options.permissions === undefined
164
169
  ? { preset: options.preset }
@@ -174,7 +179,7 @@ export const registerProjectCommands = (program: Command, host: CommandHost): vo
174
179
  : { projectIds: options.projects.split(",").map((projectId) => projectId.trim()) }),
175
180
  });
176
181
  context.io.stdout(token);
177
- context.io.stderr(`Token expires at ${expiresAt}. Use it as TOLOKA_API_KEY or --api-key.`);
182
+ context.io.stderr(`Token expires at ${expiresAt}. Use it as TOLOKA_SCOPED_TOKEN.`);
178
183
  },
179
184
  );
180
185
  project
@@ -14,6 +14,9 @@ import { applyProject, importProject, planProject, validateProject } from "../pr
14
14
  import { assertProjectSelected, resolveProjectContext } from "../project-context";
15
15
  import { readTolokaState } from "../../state";
16
16
  import { assertCli, REPORT_HINT } from "../cli-error";
17
+ import { writeOutput } from "../table";
18
+
19
+ const VALIDATE_WAIT = { timeoutMs: 5 * 60_000, pollIntervalMs: 5_000 };
17
20
 
18
21
  const runChecksInterruptibly = async (
19
22
  options: CheckProjectOptions,
@@ -88,24 +91,32 @@ export const registerSourceCommands = (program: Command, host: CommandHost): voi
88
91
 
89
92
  program
90
93
  .command("plan")
94
+ .option("--json", "print the planned changes and missing secrets as JSON", false)
91
95
  .description("show what apply would change")
92
- .action(async () => {
96
+ .action(async (options: { json: boolean }) => {
93
97
  const { sourceRoot, client, projectId } = await resolveContext(globals(), context);
94
98
  const result = await planProject(client, {
95
99
  sourceRoot,
96
100
  ...(projectId === undefined ? {} : { projectId }),
97
101
  });
98
- context.io.stdout(result.output);
102
+ writeOutput(
103
+ context.io,
104
+ options.json,
105
+ { changes: result.changes, missingSecrets: result.missingSecrets },
106
+ result.output,
107
+ );
99
108
  });
100
109
 
101
110
  program
102
111
  .command("validate")
112
+ .option("--wait", "wait up to 5 minutes for running lints and checks to finish", false)
103
113
  .description("run platform spec validation and report matching checks")
104
- .action(async () => {
114
+ .action(async (options: { wait: boolean }) => {
105
115
  const { sourceRoot, client, projectId } = await resolveContext(globals(), context);
106
116
  const result = await validateProject(client, {
107
117
  sourceRoot,
108
118
  ...(projectId === undefined ? {} : { projectId }),
119
+ ...(options.wait ? { wait: VALIDATE_WAIT } : {}),
109
120
  });
110
121
  context.io.stdout(result.output);
111
122
  if (!result.valid) exitCode.value = 1;
@@ -0,0 +1,23 @@
1
+ import type { Command } from "commander";
2
+ import { describeTaskUiComponents } from "../../testing/task-ui-browser";
3
+ import { resolveSourceRoot, type CommandHost } from "../context";
4
+ import { uiComponentsCommand } from "../ui";
5
+
6
+ export const registerUiCommands = (program: Command, host: CommandHost): void => {
7
+ const { context, globals } = host;
8
+ const ui = program.command("ui").description("inspect the Task UI components the launcher renders");
9
+ ui.command("components")
10
+ .argument("[names...]", "component names such as Field.RadioGroup; omit to list every component")
11
+ .option("--json", "print the component contracts as JSON", false)
12
+ .description("list Task UI components, or print the props, value shape, and example of the named ones")
13
+ .action(async (names: string[], options: { json: boolean }) => {
14
+ const sourceRoot = await resolveSourceRoot(globals(), context.io);
15
+ await uiComponentsCommand({
16
+ sourceRoot,
17
+ names,
18
+ json: options.json,
19
+ io: context.io,
20
+ describe: context.taskUiComponents ?? describeTaskUiComponents,
21
+ });
22
+ });
23
+ };
package/src/cli/config.ts CHANGED
@@ -12,12 +12,14 @@ export type TolokaCliFlags = {
12
12
 
13
13
  export type TolokaEnvironment = Readonly<Record<string, string | undefined>> & {
14
14
  readonly TOLOKA_API_KEY?: string;
15
+ readonly TOLOKA_SCOPED_TOKEN?: string;
15
16
  readonly TOLOKA_BASE_URL?: string;
16
17
  readonly TOLOKA_PROJECT_ID?: string;
17
18
  };
18
19
 
19
20
  export type CliCredential =
20
21
  | { readonly type: "apiKey"; readonly apiKey: string }
22
+ | { readonly type: "scopedToken"; readonly scopedToken: string }
21
23
  | { readonly type: "browser"; readonly provider: BrowserLoginProvider };
22
24
 
23
25
  export type ResolvedTolokaConfig = {
@@ -60,15 +62,22 @@ export const MISSING_CREDENTIALS_MESSAGE = [
60
62
  "",
61
63
  " toloka login",
62
64
  "",
63
- "Automation can pass --api-key, set TOLOKA_API_KEY, or pipe a key to toloka login --api-key-stdin.",
65
+ "Automation can pass --api-key, set TOLOKA_SCOPED_TOKEN or TOLOKA_API_KEY, or pipe a key to toloka login --api-key-stdin.",
64
66
  "Run toloka auth-status to see what is in effect.",
65
67
  ].join("\n");
66
68
 
67
- export const resolveApiKey = (
69
+ export const resolveConfiguredCredential = (
68
70
  flags: TolokaCliFlags,
69
71
  environment: TolokaEnvironment,
70
72
  stored?: StoredCredentials,
71
- ): string | undefined => flags.apiKey ?? environment.TOLOKA_API_KEY ?? stored?.apiKey;
73
+ ): Exclude<CliCredential, { readonly type: "browser" }> | undefined => {
74
+ if (flags.apiKey !== undefined) return { type: "apiKey", apiKey: flags.apiKey };
75
+ if (environment.TOLOKA_SCOPED_TOKEN !== undefined) {
76
+ return { type: "scopedToken", scopedToken: environment.TOLOKA_SCOPED_TOKEN };
77
+ }
78
+ const apiKey = environment.TOLOKA_API_KEY ?? stored?.apiKey;
79
+ return apiKey === undefined ? undefined : { type: "apiKey", apiKey };
80
+ };
72
81
 
73
82
  export const resolveBaseUrl = (
74
83
  flags: TolokaCliFlags,
@@ -88,7 +97,7 @@ export function resolveTolokaConfig(
88
97
  stored?: StoredCredentials,
89
98
  browserLogin?: BrowserLoginProvider,
90
99
  ): ResolvedTolokaConfig {
91
- const apiKey = resolveApiKey(flags, environment, stored);
100
+ const configuredCredential = resolveConfiguredCredential(flags, environment, stored);
92
101
  const baseUrl = resolveBaseUrl(flags, environment, stored);
93
102
  const project = resolveProjectContext({
94
103
  flags,
@@ -98,11 +107,8 @@ export function resolveTolokaConfig(
98
107
  effectiveBaseUrl: baseUrl,
99
108
  });
100
109
  const credential: CliCredential | undefined =
101
- apiKey !== undefined
102
- ? { type: "apiKey", apiKey }
103
- : browserLogin !== undefined
104
- ? { type: "browser", provider: browserLogin }
105
- : undefined;
110
+ configuredCredential ??
111
+ (browserLogin === undefined ? undefined : { type: "browser", provider: browserLogin });
106
112
  assertCli(credential, MISSING_CREDENTIALS_MESSAGE, { hint: { disabled: true } });
107
113
  return { baseUrl, credential, ...(project === undefined ? {} : { projectId: project.id }) };
108
114
  }
@@ -10,8 +10,14 @@ import type { CliAnalytics } from "./analytics";
10
10
  import type { BrowserLoginFlow } from "./browser-login";
11
11
  import { createBrowserSessionAuth, findBrowserLogin } from "./browser-session";
12
12
  import type { checkProject } from "./check";
13
- import { PRODUCTION_BASE_URL, resolveApiKey, resolveBaseUrl, resolveTolokaConfig } from "./config";
14
- import type { ResolvedTolokaConfig, TolokaEnvironment } from "./config";
13
+ import type { DescribeTaskUiComponents } from "../testing/task-ui-browser";
14
+ import {
15
+ PRODUCTION_BASE_URL,
16
+ resolveBaseUrl,
17
+ resolveConfiguredCredential,
18
+ resolveTolokaConfig,
19
+ } from "./config";
20
+ import type { CliCredential, ResolvedTolokaConfig, TolokaEnvironment } from "./config";
15
21
  import { findCommandCredentials } from "./credentials";
16
22
  import type { StoredCredentials } from "./credentials";
17
23
  import { resolveProjectContext } from "./project-context";
@@ -36,6 +42,7 @@ export type ProgramContext = {
36
42
  readonly browserLogin?: BrowserLoginFlow;
37
43
  readonly runChecks?: typeof checkProject;
38
44
  readonly analytics?: CliAnalytics;
45
+ readonly taskUiComponents?: DescribeTaskUiComponents;
39
46
  };
40
47
 
41
48
  export type GlobalOptions = {
@@ -54,6 +61,7 @@ export type Resolved = {
54
61
  readonly sourceRoot: string;
55
62
  readonly client: PotatoClient;
56
63
  readonly baseUrl: string;
64
+ readonly credential: CliCredential;
57
65
  readonly state: TolokaState | undefined;
58
66
  readonly stored: StoredCredentials | undefined;
59
67
  readonly projectId?: string;
@@ -61,10 +69,11 @@ export type Resolved = {
61
69
 
62
70
  export const CREDENTIAL_PRECEDENCE = [
63
71
  "Credentials resolve in this order, highest first:",
64
- " 1. --api-key / --base-url",
65
- " 2. TOLOKA_API_KEY / TOLOKA_BASE_URL",
66
- " 3. the API key stored by toloka login --api-key-stdin",
67
- " 4. the browser login stored by toloka login",
72
+ " 1. --api-key",
73
+ " 2. TOLOKA_SCOPED_TOKEN",
74
+ " 3. TOLOKA_API_KEY",
75
+ " 4. the API key stored by toloka login --api-key-stdin",
76
+ " 5. the browser login stored by toloka login",
68
77
  `The platform defaults to ${PRODUCTION_BASE_URL}.`,
69
78
  ].join("\n");
70
79
 
@@ -205,7 +214,9 @@ export const resolveInitRoot = (globals: GlobalOptions): string =>
205
214
  export const readCommandCredentials = async (globals: GlobalOptions, context: ProgramContext) => {
206
215
  const { found, warning } = await findCommandCredentials(
207
216
  context.environment,
208
- globals.apiKey ?? context.environment.TOLOKA_API_KEY,
217
+ globals.apiKey ??
218
+ context.environment.TOLOKA_SCOPED_TOKEN ??
219
+ context.environment.TOLOKA_API_KEY,
209
220
  );
210
221
  if (warning !== undefined) context.io.stderr(warning);
211
222
  return found?.credentials;
@@ -229,7 +240,7 @@ export const resolveClient = async (
229
240
  ): Promise<{ readonly config: ResolvedTolokaConfig; readonly client: PotatoClient }> => {
230
241
  const { environment } = context;
231
242
  const browserLogin =
232
- resolveApiKey(globals, environment, stored) === undefined
243
+ resolveConfiguredCredential(globals, environment, stored) === undefined
233
244
  ? (await findBrowserLogin(resolveBaseUrl(globals, environment, stored), environment))?.provider
234
245
  : undefined;
235
246
  const config = resolveTolokaConfig(globals, state, environment, stored, browserLogin);
@@ -239,10 +250,16 @@ export const resolveClient = async (
239
250
  createClient(
240
251
  credential.type === "apiKey"
241
252
  ? { baseUrl, apiKey: credential.apiKey }
242
- : {
243
- baseUrl,
244
- accessToken: createBrowserSessionAuth({ provider: credential.provider, baseUrl, environment }),
245
- },
253
+ : credential.type === "scopedToken"
254
+ ? { baseUrl, scopedToken: credential.scopedToken }
255
+ : {
256
+ baseUrl,
257
+ accessToken: createBrowserSessionAuth({
258
+ provider: credential.provider,
259
+ baseUrl,
260
+ environment,
261
+ }),
262
+ },
246
263
  );
247
264
  return { config, client };
248
265
  };
@@ -261,6 +278,7 @@ export const resolveContext = async (
261
278
  sourceRoot,
262
279
  client,
263
280
  baseUrl: config.baseUrl,
281
+ credential: config.credential,
264
282
  state,
265
283
  stored,
266
284
  ...(config.projectId === undefined ? {} : { projectId: config.projectId }),
@@ -192,12 +192,12 @@ export async function findStoredCredentials(
192
192
 
193
193
  export async function findCommandCredentials(
194
194
  environment: CliEnvironment,
195
- overrideApiKey: string | undefined,
195
+ overrideCredential: string | undefined,
196
196
  ): Promise<CommandCredentialLookup> {
197
197
  try {
198
198
  return { found: await findStoredCredentials(environment) };
199
199
  } catch (error) {
200
- if (!(error instanceof CredentialsError) || overrideApiKey === undefined) throw error;
200
+ if (!(error instanceof CredentialsError) || overrideCredential === undefined) throw error;
201
201
  return { found: undefined, warning: error.message };
202
202
  }
203
203
  }
@@ -6,7 +6,11 @@ const LEGACY_ENVIRONMENT_NAMES: Readonly<Record<string, string>> = {
6
6
  POTATO_PROJECT_ID: "TOLOKA_PROJECT_ID",
7
7
  };
8
8
 
9
- export const API_KEY_ENVIRONMENT_NAMES = ["TOLOKA_API_KEY", "POTATO_API_KEY"];
9
+ export const CREDENTIAL_ENVIRONMENT_NAMES = [
10
+ "TOLOKA_SCOPED_TOKEN",
11
+ "TOLOKA_API_KEY",
12
+ "POTATO_API_KEY",
13
+ ];
10
14
 
11
15
  export type NormalizedEnvironment = {
12
16
  readonly environment: TolokaEnvironment;