@toloka-platform/sdk 0.1.0-preview.4 → 0.1.0-preview.6
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 +1 -1
- package/dist/cli/toloka.js +132 -126
- 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/cli-v2.md +4 -0
- package/docs/cli.md +82 -6
- package/docs/datasets.md +8 -9
- package/docs/getting-started.md +2 -2
- package/docs/limitations.md +5 -8
- package/docs/troubleshooting.md +0 -11
- package/openapi/gen/api.ts +782 -876
- package/openapi/gen/models/baseModel.ts +9 -0
- package/openapi/gen/models/baseModelId.ts +2 -0
- package/openapi/gen/models/baseModelIdProperty.ts +2 -0
- package/openapi/gen/models/createDatasetBody.ts +2 -0
- package/openapi/gen/models/createDatasetBodyProfile.ts +2 -0
- package/openapi/gen/models/createDatasetUploadBodyFormat.ts +1 -1
- package/openapi/gen/models/createProjectModelBody.ts +8 -0
- package/openapi/gen/models/createProjectModelBodyDescription.ts +2 -0
- package/openapi/gen/models/dataset.ts +2 -0
- package/openapi/gen/models/datasetProfile.ts +3 -0
- package/openapi/gen/models/datasetProfileStamp.ts +6 -0
- package/openapi/gen/models/datasetProfileStampName.ts +2 -0
- package/openapi/gen/models/generationNodeSettingsAudienceAndPricingOneOfOneone.ts +1 -0
- package/openapi/gen/models/index.ts +21 -0
- package/openapi/gen/models/listBaseModels200.ts +5 -0
- package/openapi/gen/models/listModelVersions200.ts +7 -0
- package/openapi/gen/models/listModelVersionsParams.ts +5 -0
- package/openapi/gen/models/listProjectModels200.ts +7 -0
- package/openapi/gen/models/listProjectModelsParams.ts +5 -0
- package/openapi/gen/models/model.ts +16 -0
- package/openapi/gen/models/modelArchivedAt.ts +2 -0
- package/openapi/gen/models/modelDescription.ts +2 -0
- package/openapi/gen/models/modelId.ts +2 -0
- package/openapi/gen/models/modelVersion.ts +9 -0
- package/openapi/gen/models/modelVersionId.ts +2 -0
- package/openapi/gen/models/pipelineGraphSnapshotGraphNodesItemOneOfThreeoneSettingsAudienceAndPricingOneOfOneone.ts +1 -0
- package/openapi/gen/models/runCost.ts +7 -0
- package/openapi/gen/models/validatePipelineGraphBodyGraphNodesItemOneOfThreeoneSettingsAudienceAndPricingOneOfOneone.ts +1 -0
- package/package.json +2 -1
- package/skills/catalog.json +22 -0
- package/skills/{SKILL.md → toloka-build/SKILL.md} +2 -2
- package/skills/{references → toloka-build/references}/workflow-and-cli.md +26 -5
- package/skills/toloka-report-issue/SKILL.md +25 -0
- package/skills/toloka-report-issue/references/report-template.md +34 -0
- package/src/cli/analytics.ts +53 -1
- package/src/cli/data.ts +148 -25
- package/src/cli/file-system.ts +34 -0
- package/src/cli/format-error.ts +19 -0
- package/src/cli/init.ts +149 -194
- package/src/cli/models.ts +188 -0
- package/src/cli/program.ts +202 -22
- package/src/cli/report.ts +144 -0
- package/src/cli/skill.ts +363 -0
- package/src/client.ts +109 -35
- package/src/errors.ts +25 -10
- package/src/pipeline-graph-v1.contract.json +5 -1
- package/src/transport.ts +16 -13
- /package/skills/{references → toloka-build/references}/node-semantics.md +0 -0
package/src/cli/program.ts
CHANGED
|
@@ -4,14 +4,13 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { createClient } from "../client";
|
|
6
6
|
import type { PotatoClient } from "../client";
|
|
7
|
-
import { PotatoApiError } from "../errors";
|
|
8
7
|
import { readTolokaState } from "../state";
|
|
9
8
|
import type { TolokaState } from "../state";
|
|
10
9
|
import { authStatus, login, logout } from "./auth";
|
|
11
10
|
import type { AuthPrompt } from "./auth";
|
|
12
11
|
import { commandNameFromArgv, createCliAnalytics } from "./analytics";
|
|
13
12
|
import type { CliAnalytics, CliAnalyticsCommandOutcome } from "./analytics";
|
|
14
|
-
import { PRODUCTION_BASE_URL, resolveTolokaConfig } from "./config";
|
|
13
|
+
import { PRODUCTION_BASE_URL, resolveInternalBaseUrl, resolveTolokaConfig } from "./config";
|
|
15
14
|
import type { TolokaEnvironment } from "./config";
|
|
16
15
|
import {
|
|
17
16
|
findCommandCredentials,
|
|
@@ -26,7 +25,7 @@ import {
|
|
|
26
25
|
setProjectSecret,
|
|
27
26
|
uploadDataset,
|
|
28
27
|
} from "./data";
|
|
29
|
-
import { initProject, readAgentSkillStalenessWarning } from "./init";
|
|
28
|
+
import { SDK_VERSION, initProject, readAgentSkillStalenessWarning } from "./init";
|
|
30
29
|
import {
|
|
31
30
|
applyProject,
|
|
32
31
|
importProject,
|
|
@@ -34,6 +33,16 @@ import {
|
|
|
34
33
|
projectStatus,
|
|
35
34
|
validateProject,
|
|
36
35
|
} from "./project";
|
|
36
|
+
import {
|
|
37
|
+
archiveModel,
|
|
38
|
+
createModel,
|
|
39
|
+
getBaseModel,
|
|
40
|
+
getModel,
|
|
41
|
+
listBaseModels,
|
|
42
|
+
listModels,
|
|
43
|
+
listModelVersions,
|
|
44
|
+
parseBaseModelId,
|
|
45
|
+
} from "./models";
|
|
37
46
|
import {
|
|
38
47
|
MISSING_PROJECT_ID_MESSAGE,
|
|
39
48
|
addProject,
|
|
@@ -45,6 +54,9 @@ import {
|
|
|
45
54
|
switchProject,
|
|
46
55
|
} from "./project-context";
|
|
47
56
|
import { estimatePipelineCost } from "./cost";
|
|
57
|
+
import { submitIssueReport } from "./report";
|
|
58
|
+
import type { IssueReportAuthor } from "./report";
|
|
59
|
+
import { formatCliError } from "./format-error";
|
|
48
60
|
import {
|
|
49
61
|
cancelPipelineRun,
|
|
50
62
|
listLivePipelines,
|
|
@@ -56,6 +68,7 @@ import {
|
|
|
56
68
|
} from "./runs";
|
|
57
69
|
|
|
58
70
|
import type { RunOutcome } from "./runs";
|
|
71
|
+
import { skillInstallCommand, skillListCommand, skillSearchCommand } from "./skill";
|
|
59
72
|
import { findSourceRoot } from "./source";
|
|
60
73
|
|
|
61
74
|
export type CliIo = {
|
|
@@ -88,6 +101,12 @@ const CREDENTIAL_PRECEDENCE = [
|
|
|
88
101
|
`The platform defaults to ${PRODUCTION_BASE_URL}.`,
|
|
89
102
|
].join("\n");
|
|
90
103
|
|
|
104
|
+
const authoredByOption = (raw: string): IssueReportAuthor => {
|
|
105
|
+
if (raw !== "human" && raw !== "agent")
|
|
106
|
+
throw new InvalidArgumentError('Expected "human" or "agent".');
|
|
107
|
+
return raw;
|
|
108
|
+
};
|
|
109
|
+
|
|
91
110
|
const positiveInteger = (raw: string): number => {
|
|
92
111
|
const value = Number(raw);
|
|
93
112
|
if (!Number.isInteger(value) || value <= 0)
|
|
@@ -104,6 +123,7 @@ const exitCodeForOutcome = (outcome: RunOutcome): number => {
|
|
|
104
123
|
type Resolved = {
|
|
105
124
|
readonly sourceRoot: string;
|
|
106
125
|
readonly client: PotatoClient;
|
|
126
|
+
readonly baseUrl: string;
|
|
107
127
|
readonly state: TolokaState | undefined;
|
|
108
128
|
readonly stored: StoredCredentials | undefined;
|
|
109
129
|
readonly projectId?: string;
|
|
@@ -154,6 +174,7 @@ const resolveContext = async (
|
|
|
154
174
|
return {
|
|
155
175
|
sourceRoot,
|
|
156
176
|
client,
|
|
177
|
+
baseUrl: config.baseUrl,
|
|
157
178
|
state,
|
|
158
179
|
stored,
|
|
159
180
|
...(config.projectId === undefined ? {} : { projectId: config.projectId }),
|
|
@@ -187,6 +208,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
|
|
|
187
208
|
.description(
|
|
188
209
|
"Preview. Build and operate Toloka pipeline projects from TypeScript source. The public API may change.",
|
|
189
210
|
)
|
|
211
|
+
.version(SDK_VERSION, "-v, --version", "output the current version")
|
|
190
212
|
.option(
|
|
191
213
|
"--source-root <directory>",
|
|
192
214
|
"project root; discovered from the working directory when omitted",
|
|
@@ -203,19 +225,30 @@ export function createProgram(context: ProgramContext): CreatedProgram {
|
|
|
203
225
|
|
|
204
226
|
const globals = (): GlobalOptions => program.opts<GlobalOptions>();
|
|
205
227
|
|
|
206
|
-
program.hook("postAction", async (
|
|
228
|
+
program.hook("postAction", async (_thisCommand, actionCommand) => {
|
|
207
229
|
if (warnedAboutStaleSkills) return;
|
|
208
230
|
const commandNames = [];
|
|
209
|
-
for (let current: Command | null =
|
|
231
|
+
for (let current: Command | null = actionCommand; current !== null; current = current.parent) {
|
|
210
232
|
commandNames.push(current.name());
|
|
211
233
|
}
|
|
212
|
-
if (
|
|
234
|
+
if (
|
|
235
|
+
commandNames.includes("init") ||
|
|
236
|
+
commandNames.includes("help") ||
|
|
237
|
+
commandNames.includes("report") ||
|
|
238
|
+
commandNames.includes("url") ||
|
|
239
|
+
commandNames.includes("version")
|
|
240
|
+
)
|
|
241
|
+
return;
|
|
213
242
|
const sourceRoot = await resolveSourceRoot(globals(), context.io);
|
|
214
243
|
const warning = await readAgentSkillStalenessWarning(sourceRoot).catch(() => undefined);
|
|
215
244
|
if (warning === undefined) return;
|
|
216
245
|
warnedAboutStaleSkills = true;
|
|
217
246
|
context.io.stderr(warning);
|
|
218
247
|
});
|
|
248
|
+
program
|
|
249
|
+
.command("version")
|
|
250
|
+
.description("print the current CLI version")
|
|
251
|
+
.action(() => context.io.stdout(SDK_VERSION));
|
|
219
252
|
|
|
220
253
|
program
|
|
221
254
|
.command("login")
|
|
@@ -351,6 +384,19 @@ export function createProgram(context: ProgramContext): CreatedProgram {
|
|
|
351
384
|
});
|
|
352
385
|
if (result.project === undefined) exitCode.value = 1;
|
|
353
386
|
});
|
|
387
|
+
project
|
|
388
|
+
.command("url")
|
|
389
|
+
.description("print the current project URL")
|
|
390
|
+
.action(async () => {
|
|
391
|
+
const { baseUrl, client, projectId } = await resolveContext(globals(), context);
|
|
392
|
+
assert(projectId, MISSING_PROJECT_ID_MESSAGE);
|
|
393
|
+
const live = await client.projects.get({ projectId });
|
|
394
|
+
const url = new URL(
|
|
395
|
+
`/teams/${encodeURIComponent(live.teamId)}/projects/${encodeURIComponent(live.id)}`,
|
|
396
|
+
baseUrl,
|
|
397
|
+
);
|
|
398
|
+
context.io.stdout(url.href);
|
|
399
|
+
});
|
|
354
400
|
|
|
355
401
|
project
|
|
356
402
|
.command("remove")
|
|
@@ -390,7 +436,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
|
|
|
390
436
|
|
|
391
437
|
program
|
|
392
438
|
.command("init")
|
|
393
|
-
.description("initialize this project directory and install agent
|
|
439
|
+
.description("initialize this project directory and install the agent bootstrap")
|
|
394
440
|
.action(async () => {
|
|
395
441
|
const flags = globals();
|
|
396
442
|
const sourceRoot = resolveInitRoot(flags);
|
|
@@ -497,6 +543,94 @@ export function createProgram(context: ProgramContext): CreatedProgram {
|
|
|
497
543
|
}
|
|
498
544
|
});
|
|
499
545
|
|
|
546
|
+
const baseModel = program
|
|
547
|
+
.command("base-model")
|
|
548
|
+
.description("inspect the global base model catalog");
|
|
549
|
+
baseModel
|
|
550
|
+
.command("list")
|
|
551
|
+
.option("--json", "print JSON", false)
|
|
552
|
+
.description("list base models")
|
|
553
|
+
.action(async (options: { json: boolean }) => {
|
|
554
|
+
const { client } = await resolveContext(globals(), context);
|
|
555
|
+
await listBaseModels(client, { json: options.json }, context.io);
|
|
556
|
+
});
|
|
557
|
+
baseModel
|
|
558
|
+
.command("get")
|
|
559
|
+
.argument("<id>", "base model ID")
|
|
560
|
+
.option("--json", "print JSON", false)
|
|
561
|
+
.description("show one base model")
|
|
562
|
+
.action(async (id: string, options: { json: boolean }) => {
|
|
563
|
+
const { client } = await resolveContext(globals(), context);
|
|
564
|
+
await getBaseModel(client, { id: parseBaseModelId(id), json: options.json }, context.io);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const model = program
|
|
568
|
+
.command("model")
|
|
569
|
+
.description("manage project Models and immutable versions");
|
|
570
|
+
model
|
|
571
|
+
.command("list")
|
|
572
|
+
.option("--json", "print JSON", false)
|
|
573
|
+
.description("list Models owned by the selected project")
|
|
574
|
+
.action(async (options: { json: boolean }) => {
|
|
575
|
+
const { client, projectId } = await resolveContext(globals(), context);
|
|
576
|
+
assert(projectId, MISSING_PROJECT_ID_MESSAGE);
|
|
577
|
+
await listModels(client, { projectId, json: options.json }, context.io);
|
|
578
|
+
});
|
|
579
|
+
model
|
|
580
|
+
.command("get")
|
|
581
|
+
.argument("<name-or-id>", "project Model name or ID")
|
|
582
|
+
.option("--json", "print JSON", false)
|
|
583
|
+
.description("show one project Model")
|
|
584
|
+
.action(async (nameOrId: string, options: { json: boolean }) => {
|
|
585
|
+
const { client, projectId } = await resolveContext(globals(), context);
|
|
586
|
+
assert(projectId, MISSING_PROJECT_ID_MESSAGE);
|
|
587
|
+
await getModel(client, { projectId, nameOrId, json: options.json }, context.io);
|
|
588
|
+
});
|
|
589
|
+
model
|
|
590
|
+
.command("versions")
|
|
591
|
+
.argument("<name-or-id>", "project Model name or ID")
|
|
592
|
+
.option("--json", "print JSON", false)
|
|
593
|
+
.description("list immutable versions of a model")
|
|
594
|
+
.action(async (nameOrId: string, options: { json: boolean }) => {
|
|
595
|
+
const { client, projectId } = await resolveContext(globals(), context);
|
|
596
|
+
assert(projectId, MISSING_PROJECT_ID_MESSAGE);
|
|
597
|
+
await listModelVersions(client, { projectId, nameOrId, json: options.json }, context.io);
|
|
598
|
+
});
|
|
599
|
+
model
|
|
600
|
+
.command("create")
|
|
601
|
+
.argument("<name>", "new Model name")
|
|
602
|
+
.requiredOption("--base <id>", "base model ID")
|
|
603
|
+
.option("--description <text>", "Model description")
|
|
604
|
+
.option("--json", "print JSON", false)
|
|
605
|
+
.description("create project Model metadata without a Model Version")
|
|
606
|
+
.action(
|
|
607
|
+
async (name: string, options: { base: string; description?: string; json: boolean }) => {
|
|
608
|
+
const { client, projectId } = await resolveContext(globals(), context);
|
|
609
|
+
assert(projectId, MISSING_PROJECT_ID_MESSAGE);
|
|
610
|
+
await createModel(
|
|
611
|
+
client,
|
|
612
|
+
{
|
|
613
|
+
projectId,
|
|
614
|
+
name,
|
|
615
|
+
description: options.description,
|
|
616
|
+
baseModelId: parseBaseModelId(options.base),
|
|
617
|
+
json: options.json,
|
|
618
|
+
},
|
|
619
|
+
context.io,
|
|
620
|
+
);
|
|
621
|
+
},
|
|
622
|
+
);
|
|
623
|
+
model
|
|
624
|
+
.command("archive")
|
|
625
|
+
.option("--json", "print JSON", false)
|
|
626
|
+
.argument("<name-or-id>", "project Model name or ID")
|
|
627
|
+
.description("archive a project Model")
|
|
628
|
+
.action(async (nameOrId: string, options: { json: boolean }) => {
|
|
629
|
+
const { client, projectId } = await resolveContext(globals(), context);
|
|
630
|
+
assert(projectId, MISSING_PROJECT_ID_MESSAGE);
|
|
631
|
+
await archiveModel(client, { projectId, nameOrId, json: options.json }, context.io);
|
|
632
|
+
});
|
|
633
|
+
|
|
500
634
|
const dataset = program
|
|
501
635
|
.command("dataset")
|
|
502
636
|
.description("inspect and move dataset catalog and rows");
|
|
@@ -544,7 +678,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
|
|
|
544
678
|
dataset
|
|
545
679
|
.command("upload")
|
|
546
680
|
.argument("<name>", "dataset name or id")
|
|
547
|
-
.argument("<file>", "
|
|
681
|
+
.argument("<file>", "JSON array, JSONL, or CSV file")
|
|
548
682
|
.description("append rows to a dataset")
|
|
549
683
|
.action(async (name: string, file: string) => {
|
|
550
684
|
const { sourceRoot, client, projectId } = await resolveContext(globals(), context);
|
|
@@ -677,6 +811,66 @@ export function createProgram(context: ProgramContext): CreatedProgram {
|
|
|
677
811
|
const result = await projectStatus(client, { sourceRoot, projectId });
|
|
678
812
|
context.io.stdout(result.output);
|
|
679
813
|
});
|
|
814
|
+
program
|
|
815
|
+
.command("report")
|
|
816
|
+
.argument("<markdownPath>", "path to a UTF-8 Markdown report")
|
|
817
|
+
.option("--authored-by <who>", "human or agent", authoredByOption, "human")
|
|
818
|
+
.option("--confirm", "confirm submission in a non-interactive environment", false)
|
|
819
|
+
.description("preview and send an issue report to Toloka through PostHog")
|
|
820
|
+
.action(
|
|
821
|
+
async (
|
|
822
|
+
markdownPath: string,
|
|
823
|
+
options: { authoredBy: IssueReportAuthor; confirm: boolean },
|
|
824
|
+
) => {
|
|
825
|
+
assert(context.analytics, "Issue reporting requires CLI analytics.");
|
|
826
|
+
const sourceRoot = resolveInitRoot(globals());
|
|
827
|
+
const baseUrl =
|
|
828
|
+
globals().baseUrl ??
|
|
829
|
+
context.environment.POTATO_BASE_URL ??
|
|
830
|
+
resolveInternalBaseUrl(context.environment) ??
|
|
831
|
+
PRODUCTION_BASE_URL;
|
|
832
|
+
exitCode.value = await submitIssueReport({
|
|
833
|
+
path: markdownPath,
|
|
834
|
+
authoredBy: options.authoredBy,
|
|
835
|
+
platform: new URL(baseUrl).origin,
|
|
836
|
+
confirm: options.confirm,
|
|
837
|
+
sourceRoot,
|
|
838
|
+
cwd: process.cwd(),
|
|
839
|
+
analytics: context.analytics,
|
|
840
|
+
io: context.io,
|
|
841
|
+
});
|
|
842
|
+
},
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const skill = program
|
|
846
|
+
.command("skill")
|
|
847
|
+
.description("discover bundled agent skills and install them into .agents/skills");
|
|
848
|
+
skill
|
|
849
|
+
.command("list")
|
|
850
|
+
.option("--json", "print Skill Descriptors with their status as JSON", false)
|
|
851
|
+
.description("list bundled skills as not-installed, current, or stale")
|
|
852
|
+
.action(async (options: { json: boolean }) => {
|
|
853
|
+
const sourceRoot = await resolveSourceRoot(globals(), context.io);
|
|
854
|
+
await skillListCommand({ sourceRoot, json: options.json, io: context.io });
|
|
855
|
+
});
|
|
856
|
+
skill
|
|
857
|
+
.command("search")
|
|
858
|
+
.argument("<query>", "text matched case-insensitively against id, name, description, and tags")
|
|
859
|
+
.option("--json", "print matching Skill Descriptors with their status as JSON", false)
|
|
860
|
+
.description("find bundled skills for a task")
|
|
861
|
+
.action(async (query: string, options: { json: boolean }) => {
|
|
862
|
+
const sourceRoot = await resolveSourceRoot(globals(), context.io);
|
|
863
|
+
await skillSearchCommand({ sourceRoot, query, json: options.json, io: context.io });
|
|
864
|
+
});
|
|
865
|
+
skill
|
|
866
|
+
.command("install")
|
|
867
|
+
.argument("<skillId>", "skill id from toloka skill list")
|
|
868
|
+
.option("-f, --force", "replace an existing .agents/skills/<skillId> directory", false)
|
|
869
|
+
.description("copy one bundled skill into .agents/skills/<skillId>")
|
|
870
|
+
.action(async (skillId: string, options: { force: boolean }) => {
|
|
871
|
+
const sourceRoot = await resolveSourceRoot(globals(), context.io);
|
|
872
|
+
await skillInstallCommand({ sourceRoot, skillId, force: options.force, io: context.io });
|
|
873
|
+
});
|
|
680
874
|
|
|
681
875
|
return { program, exitCode };
|
|
682
876
|
}
|
|
@@ -785,20 +979,6 @@ type CommandResult = {
|
|
|
785
979
|
readonly outcome: CliAnalyticsCommandOutcome;
|
|
786
980
|
readonly error?: unknown;
|
|
787
981
|
};
|
|
788
|
-
const formatCliError = (error: unknown): string => {
|
|
789
|
-
if (error instanceof PotatoApiError) {
|
|
790
|
-
const details =
|
|
791
|
-
typeof error.body === "string"
|
|
792
|
-
? error.body
|
|
793
|
-
: error.body === undefined
|
|
794
|
-
? undefined
|
|
795
|
-
: JSON.stringify(error.body);
|
|
796
|
-
return details === undefined || details.length === 0
|
|
797
|
-
? error.message
|
|
798
|
-
: `${error.message}\n${details}`;
|
|
799
|
-
}
|
|
800
|
-
return error instanceof Error ? error.message : String(error);
|
|
801
|
-
};
|
|
802
982
|
|
|
803
983
|
const runProgram = async (
|
|
804
984
|
program: Command,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { assert } from "es-toolkit";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, open, type FileHandle } from "node:fs/promises";
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import type { CliAnalytics } from "./analytics";
|
|
7
|
+
|
|
8
|
+
export type IssueReportAuthor = "human" | "agent";
|
|
9
|
+
|
|
10
|
+
type IssueReportIo = {
|
|
11
|
+
readonly stdout: (text: string) => void;
|
|
12
|
+
readonly stderr: (text: string) => void;
|
|
13
|
+
readonly prompt?: (question: string) => Promise<string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ISSUE_REPORT_MAX_BYTES = 256 * 1024;
|
|
17
|
+
const REPORT_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
18
|
+
|
|
19
|
+
const assertNoSymlinkParents = async (absolutePath: string, sourceRoot: string): Promise<void> => {
|
|
20
|
+
const relativePath = relative(sourceRoot, absolutePath);
|
|
21
|
+
if (
|
|
22
|
+
relativePath === "" ||
|
|
23
|
+
relativePath === ".." ||
|
|
24
|
+
relativePath.startsWith(`..${sep}`) ||
|
|
25
|
+
isAbsolute(relativePath)
|
|
26
|
+
) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const segments = relativePath.split(sep);
|
|
31
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
32
|
+
const directory = join(sourceRoot, ...segments.slice(0, index));
|
|
33
|
+
const stats = await lstat(directory);
|
|
34
|
+
assert(
|
|
35
|
+
!stats.isSymbolicLink(),
|
|
36
|
+
`Report file ${absolutePath} is inside the symbolic link ${directory}; select a path that does not traverse symbolic links.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const openReportFile = async (
|
|
42
|
+
path: string,
|
|
43
|
+
sourceRoot: string,
|
|
44
|
+
cwd: string,
|
|
45
|
+
): Promise<{ handle: FileHandle; absolutePath: string; size: number }> => {
|
|
46
|
+
const absolutePath = resolve(cwd, path);
|
|
47
|
+
await assertNoSymlinkParents(absolutePath, sourceRoot);
|
|
48
|
+
let handle: FileHandle;
|
|
49
|
+
try {
|
|
50
|
+
handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
assert(
|
|
53
|
+
!(error instanceof Error && "code" in error && error.code === "ELOOP"),
|
|
54
|
+
`Report file ${absolutePath} is a symbolic link; select the file it points to directly.`,
|
|
55
|
+
);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const stats = await handle.stat();
|
|
61
|
+
assert(stats.isFile(), `Report file ${absolutePath} is not a regular file.`);
|
|
62
|
+
assert(stats.size > 0, `Report file ${absolutePath} is empty.`);
|
|
63
|
+
assert(
|
|
64
|
+
stats.size <= ISSUE_REPORT_MAX_BYTES,
|
|
65
|
+
`Report file ${absolutePath} is ${stats.size} bytes; the limit is 256 KiB.`,
|
|
66
|
+
);
|
|
67
|
+
return { handle, absolutePath, size: stats.size };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await handle.close();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const readReportText = async (path: string, sourceRoot: string, cwd: string): Promise<string> => {
|
|
75
|
+
const opened = await openReportFile(path, sourceRoot, cwd);
|
|
76
|
+
try {
|
|
77
|
+
const bytes = await opened.handle.readFile();
|
|
78
|
+
assert(
|
|
79
|
+
bytes.byteLength === opened.size,
|
|
80
|
+
`${opened.absolutePath} changed while it was being read.`,
|
|
81
|
+
);
|
|
82
|
+
try {
|
|
83
|
+
return REPORT_DECODER.decode(bytes);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error(`Report file ${opened.absolutePath} is not valid UTF-8.`);
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
await opened.handle.close();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const confirmed = async (io: IssueReportIo, confirm: boolean): Promise<boolean> => {
|
|
93
|
+
if (confirm) return true;
|
|
94
|
+
if (io.prompt === undefined) return false;
|
|
95
|
+
const answer = (await io.prompt("Send this report to Toloka? [y/N] ")).trim().toLowerCase();
|
|
96
|
+
return answer === "y" || answer === "yes";
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export async function submitIssueReport(options: {
|
|
100
|
+
readonly path: string;
|
|
101
|
+
readonly authoredBy: IssueReportAuthor;
|
|
102
|
+
readonly platform: string;
|
|
103
|
+
readonly confirm: boolean;
|
|
104
|
+
readonly sourceRoot: string;
|
|
105
|
+
readonly cwd: string;
|
|
106
|
+
readonly analytics: CliAnalytics;
|
|
107
|
+
readonly io: IssueReportIo;
|
|
108
|
+
}): Promise<number> {
|
|
109
|
+
const { analytics, io } = options;
|
|
110
|
+
assert(
|
|
111
|
+
analytics.issueReportingEnabled,
|
|
112
|
+
"Issue reporting is disabled. Re-run with TOLOKA_TELEMETRY=1.",
|
|
113
|
+
);
|
|
114
|
+
const reportText = await readReportText(options.path, options.sourceRoot, options.cwd);
|
|
115
|
+
io.stdout("Issue report preview:");
|
|
116
|
+
io.stdout("");
|
|
117
|
+
io.stdout(
|
|
118
|
+
reportText.replace(
|
|
119
|
+
/(?![\t\n])(?!\r\n)(?:\p{Cc}|\p{Bidi_Control})/gu,
|
|
120
|
+
(character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
io.stdout("");
|
|
124
|
+
io.stdout(`Authored by: ${options.authoredBy}`);
|
|
125
|
+
io.stdout(`Platform: ${options.platform}`);
|
|
126
|
+
io.stdout("");
|
|
127
|
+
|
|
128
|
+
if (!(await confirmed(io, options.confirm))) {
|
|
129
|
+
io.stderr(
|
|
130
|
+
"Refusing to submit without confirmation in a non-interactive environment. Pass --confirm after reviewing the preview above.",
|
|
131
|
+
);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const reportId = randomUUID();
|
|
136
|
+
await analytics.submitIssueReport({
|
|
137
|
+
reportId,
|
|
138
|
+
reportText,
|
|
139
|
+
authoredBy: options.authoredBy,
|
|
140
|
+
platform: options.platform,
|
|
141
|
+
});
|
|
142
|
+
io.stdout(`Submitted issue report: ${reportId}`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|