viatris-exam-cli 0.0.0-stage → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
package/src/app.js ADDED
@@ -0,0 +1,36 @@
1
+ import { assertNoExtraPositionals, parseArgs } from "./args.js";
2
+ import { resolveCommand } from "./commands.js";
3
+ import { configFrom } from "./config.js";
4
+ import { helpText } from "./help.js";
5
+ import { request } from "./http.js";
6
+ import { describeCli, VERSION } from "./metadata.js";
7
+ import { writeJson } from "./output.js";
8
+
9
+ export async function run(
10
+ argv,
11
+ {
12
+ stdout = process.stdout,
13
+ environmentVariables = process.env,
14
+ } = {},
15
+ ) {
16
+ const { positionals, options } = parseArgs(argv);
17
+
18
+ if (options.version) {
19
+ stdout.write(`${VERSION}\n`);
20
+ return;
21
+ }
22
+ if (options.help || positionals.length === 0) {
23
+ stdout.write(helpText());
24
+ return;
25
+ }
26
+ if (positionals[0] === "describe") {
27
+ assertNoExtraPositionals(positionals, 1);
28
+ writeJson(stdout, describeCli(), options.compact);
29
+ return;
30
+ }
31
+
32
+ const command = await resolveCommand(positionals, options);
33
+ const config = configFrom(options, environmentVariables);
34
+ const response = await request(command, config);
35
+ writeJson(stdout, response, options.compact);
36
+ }
package/src/args.js ADDED
@@ -0,0 +1,89 @@
1
+ import { CliError } from "./errors.js";
2
+
3
+ const BOOLEAN_OPTIONS = new Set(["help", "compact", "version"]);
4
+ const GLOBAL_OPTIONS = [
5
+ "base_url",
6
+ "key_header",
7
+ "key_prefix",
8
+ "timeout",
9
+ "retries",
10
+ "compact",
11
+ ];
12
+
13
+ export function parseArgs(argv) {
14
+ const positionals = [];
15
+ const options = {};
16
+
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const token = argv[index];
19
+ if (token === "--") {
20
+ positionals.push(...argv.slice(index + 1));
21
+ break;
22
+ }
23
+ if (!token.startsWith("--")) {
24
+ positionals.push(token);
25
+ continue;
26
+ }
27
+
28
+ const equalsAt = token.indexOf("=");
29
+ const rawName = token.slice(2, equalsAt === -1 ? undefined : equalsAt);
30
+ const name = rawName.replaceAll("-", "_");
31
+ if (BOOLEAN_OPTIONS.has(rawName)) {
32
+ if (equalsAt !== -1) {
33
+ throw new CliError("usage_error", `--${rawName} does not accept a value.`);
34
+ }
35
+ options[name] = true;
36
+ continue;
37
+ }
38
+
39
+ const value = equalsAt === -1 ? argv[index + 1] : token.slice(equalsAt + 1);
40
+ if (value === undefined || (equalsAt === -1 && value.startsWith("--"))) {
41
+ throw new CliError("usage_error", `--${rawName} requires a value.`);
42
+ }
43
+ options[name] = value;
44
+ if (equalsAt === -1) index += 1;
45
+ }
46
+
47
+ return { positionals, options };
48
+ }
49
+
50
+ export function requiredId(positionals, index, label) {
51
+ const id = positionals[index];
52
+ if (!id) throw new CliError("usage_error", `Missing required ${label}.`);
53
+ return encodeURIComponent(id);
54
+ }
55
+
56
+ export function integerOption(value, name, min, max) {
57
+ if (value === undefined) return undefined;
58
+ if (!/^\d+$/.test(value)) {
59
+ throw new CliError("validation_error", `--${name} must be an integer.`);
60
+ }
61
+ const number = Number(value);
62
+ if (number < min || number > max) {
63
+ throw new CliError(
64
+ "validation_error",
65
+ `--${name} must be between ${min} and ${max}.`,
66
+ );
67
+ }
68
+ return number;
69
+ }
70
+
71
+ export function assertNoExtraPositionals(positionals, count) {
72
+ if (positionals.length > count) {
73
+ throw new CliError(
74
+ "usage_error",
75
+ `Unexpected argument: ${positionals[count]}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ export function assertOptions(options, commandOptions = []) {
81
+ const allowed = new Set([...GLOBAL_OPTIONS, ...commandOptions]);
82
+ const unknown = Object.keys(options).find((name) => !allowed.has(name));
83
+ if (unknown) {
84
+ throw new CliError(
85
+ "usage_error",
86
+ `Unknown or unsupported option for this command: --${unknown.replaceAll("_", "-")}`,
87
+ );
88
+ }
89
+ }
package/src/cli.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from "./app.js";
4
+ import { normalizeError } from "./errors.js";
5
+ import { writeError } from "./output.js";
6
+
7
+ run(process.argv.slice(2)).catch((error) => {
8
+ const cliError = normalizeError(error);
9
+ writeError(process.stderr, cliError);
10
+ process.exitCode = cliError.exitCode;
11
+ });
@@ -0,0 +1,43 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+ import { readJsonInput } from "../input.js";
7
+
8
+ export async function resolveContent(positionals, options) {
9
+ const [resource, action] = positionals;
10
+
11
+ if (resource === "exams" && action === "create") {
12
+ assertNoExtraPositionals(positionals, 2);
13
+ assertOptions(options, ["input", "json"]);
14
+ return {
15
+ method: "POST",
16
+ path: "/openapi/exams",
17
+ body: await readJsonInput(options),
18
+ };
19
+ }
20
+
21
+ if (resource === "questions" && action === "create") {
22
+ assertNoExtraPositionals(positionals, 2);
23
+ assertOptions(options, ["input", "json"]);
24
+ return {
25
+ method: "POST",
26
+ path: "/openapi/questions",
27
+ body: await readJsonInput(options),
28
+ };
29
+ }
30
+
31
+ if (resource === "questions" && action === "version") {
32
+ assertNoExtraPositionals(positionals, 3);
33
+ assertOptions(options, ["input", "json"]);
34
+ const id = requiredId(positionals, 2, "question ID");
35
+ return {
36
+ method: "POST",
37
+ path: `/openapi/questions/${id}/versions`,
38
+ body: await readJsonInput(options),
39
+ };
40
+ }
41
+
42
+ return null;
43
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+
7
+ export async function resolveExamRecords(positionals, options) {
8
+ const [resource, action] = positionals;
9
+ if (resource !== "exam-records") return null;
10
+
11
+ if (action === "get") {
12
+ assertNoExtraPositionals(positionals, 3);
13
+ assertOptions(options);
14
+ const id = requiredId(positionals, 2, "exam record ID");
15
+ return { method: "GET", path: `/openapi/exam-records/${id}` };
16
+ }
17
+
18
+ if (action === "scoring-rules") {
19
+ assertNoExtraPositionals(positionals, 3);
20
+ assertOptions(options);
21
+ const id = requiredId(positionals, 2, "exam record ID");
22
+ return {
23
+ method: "GET",
24
+ path: `/openapi/exam-records/${id}/scoring-rules`,
25
+ };
26
+ }
27
+
28
+ return null;
29
+ }
@@ -0,0 +1,53 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ integerOption,
5
+ requiredId,
6
+ } from "../args.js";
7
+ import { CliError } from "../errors.js";
8
+ import { readJsonInput } from "../input.js";
9
+
10
+ export async function resolveGenTasks(positionals, options) {
11
+ const [resource, action] = positionals;
12
+ if (resource !== "gen-tasks") return null;
13
+
14
+ if (action === "list") {
15
+ assertNoExtraPositionals(positionals, 2);
16
+ assertOptions(options, ["status", "limit"]);
17
+ const allowedStatuses = new Set(["generating", "partial_failed", "failed"]);
18
+ if (options.status && !allowedStatuses.has(options.status)) {
19
+ throw new CliError(
20
+ "validation_error",
21
+ "--status must be generating, partial_failed, or failed.",
22
+ );
23
+ }
24
+ const query = new URLSearchParams();
25
+ if (options.status) query.set("status", options.status);
26
+ const limit = integerOption(options.limit, "limit", 1, 50);
27
+ if (limit !== undefined) query.set("limit", String(limit));
28
+ return {
29
+ method: "GET",
30
+ path: `/openapi/gen-tasks${query.size ? `?${query}` : ""}`,
31
+ };
32
+ }
33
+
34
+ if (action === "get") {
35
+ assertNoExtraPositionals(positionals, 3);
36
+ assertOptions(options);
37
+ const id = requiredId(positionals, 2, "task ID");
38
+ return { method: "GET", path: `/openapi/gen-tasks/${id}` };
39
+ }
40
+
41
+ if (action === "submit") {
42
+ assertNoExtraPositionals(positionals, 3);
43
+ assertOptions(options, ["input", "json"]);
44
+ const id = requiredId(positionals, 2, "task ID");
45
+ return {
46
+ method: "POST",
47
+ path: `/openapi/gen-tasks/${id}/questions`,
48
+ body: await readJsonInput(options),
49
+ };
50
+ }
51
+
52
+ return null;
53
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+ import { readJsonInput } from "../input.js";
7
+
8
+ function jsonCommand(method, path, options) {
9
+ assertOptions(options, ["input", "json"]);
10
+ return readJsonInput(options).then((body) => ({ method, path, body }));
11
+ }
12
+
13
+ function nodePath(positionals, label) {
14
+ return requiredId(positionals, 3, label);
15
+ }
16
+
17
+ export async function resolveKnowledge(positionals, options) {
18
+ const [resource, area, action] = positionals;
19
+ if (resource !== "knowledge") return null;
20
+
21
+ if (area === "tree") {
22
+ assertNoExtraPositionals(positionals, 3);
23
+ assertOptions(options);
24
+ const product = requiredId(positionals, 2, "product code");
25
+ return {
26
+ method: "GET",
27
+ path: `/openapi/knowledge/tree?product=${product}`,
28
+ };
29
+ }
30
+
31
+ if (area === "nodes" && action === "by-path") {
32
+ assertNoExtraPositionals(positionals, 3);
33
+ return jsonCommand(
34
+ "POST",
35
+ "/openapi/knowledge/nodes/by-path",
36
+ options,
37
+ );
38
+ }
39
+
40
+ if (area === "nodes" && action === "create") {
41
+ assertNoExtraPositionals(positionals, 3);
42
+ return jsonCommand("POST", "/openapi/knowledge/nodes", options);
43
+ }
44
+
45
+ if (area === "nodes" && action === "get") {
46
+ assertNoExtraPositionals(positionals, 4);
47
+ assertOptions(options);
48
+ const id = nodePath(positionals, "knowledge node ID");
49
+ return { method: "GET", path: `/openapi/knowledge/nodes/${id}` };
50
+ }
51
+
52
+ if (area === "nodes" && action === "update") {
53
+ assertNoExtraPositionals(positionals, 4);
54
+ const id = nodePath(positionals, "knowledge node ID");
55
+ return jsonCommand(
56
+ "PATCH",
57
+ `/openapi/knowledge/nodes/${id}`,
58
+ options,
59
+ );
60
+ }
61
+
62
+ if (area === "nodes" && action === "delete") {
63
+ assertNoExtraPositionals(positionals, 4);
64
+ assertOptions(options);
65
+ const id = nodePath(positionals, "knowledge node ID");
66
+ return { method: "DELETE", path: `/openapi/knowledge/nodes/${id}` };
67
+ }
68
+
69
+ if (area === "nodes" && action === "move") {
70
+ assertNoExtraPositionals(positionals, 4);
71
+ const id = nodePath(positionals, "knowledge node ID");
72
+ return jsonCommand(
73
+ "PUT",
74
+ `/openapi/knowledge/nodes/${id}/move`,
75
+ options,
76
+ );
77
+ }
78
+
79
+ if (area === "documents" && action === "list") {
80
+ assertNoExtraPositionals(positionals, 4);
81
+ assertOptions(options);
82
+ const id = nodePath(positionals, "knowledge node ID");
83
+ return {
84
+ method: "GET",
85
+ path: `/openapi/knowledge/nodes/${id}/documents`,
86
+ };
87
+ }
88
+
89
+ if (area === "documents" && action === "create") {
90
+ assertNoExtraPositionals(positionals, 4);
91
+ const id = nodePath(positionals, "knowledge node ID");
92
+ return jsonCommand(
93
+ "POST",
94
+ `/openapi/knowledge/nodes/${id}/documents`,
95
+ options,
96
+ );
97
+ }
98
+
99
+ if (area === "documents" && action === "upsert") {
100
+ assertNoExtraPositionals(positionals, 4);
101
+ const id = nodePath(positionals, "knowledge node ID");
102
+ return jsonCommand(
103
+ "PUT",
104
+ `/openapi/knowledge/nodes/${id}/documents`,
105
+ options,
106
+ );
107
+ }
108
+
109
+ if (area === "documents" && action === "update") {
110
+ assertNoExtraPositionals(positionals, 4);
111
+ const id = nodePath(positionals, "knowledge document ID");
112
+ return jsonCommand(
113
+ "PUT",
114
+ `/openapi/knowledge/documents/${id}`,
115
+ options,
116
+ );
117
+ }
118
+
119
+ if (area === "documents" && action === "delete") {
120
+ assertNoExtraPositionals(positionals, 4);
121
+ assertOptions(options);
122
+ const id = nodePath(positionals, "knowledge document ID");
123
+ return {
124
+ method: "DELETE",
125
+ path: `/openapi/knowledge/documents/${id}`,
126
+ };
127
+ }
128
+
129
+ return null;
130
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ integerOption,
5
+ } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { hasPipedInput, readJsonInput } from "../input.js";
8
+
9
+ export async function resolveMedicalSearch(positionals, options) {
10
+ const [resource, action] = positionals;
11
+ if (resource !== "medical-search" || action !== "search") return null;
12
+
13
+ assertNoExtraPositionals(positionals, 3);
14
+ assertOptions(options, [
15
+ "input",
16
+ "json",
17
+ "file_type",
18
+ "limit",
19
+ "max_length",
20
+ ]);
21
+ const hasStructuredInput =
22
+ options.input !== undefined ||
23
+ options.json !== undefined ||
24
+ (hasPipedInput() && positionals[2] === undefined);
25
+ if (hasStructuredInput) {
26
+ if (
27
+ positionals[2] !== undefined ||
28
+ options.file_type !== undefined ||
29
+ options.limit !== undefined ||
30
+ options.max_length !== undefined
31
+ ) {
32
+ throw new CliError(
33
+ "usage_error",
34
+ "Do not combine JSON input with medical-search query flags.",
35
+ );
36
+ }
37
+ return {
38
+ method: "POST",
39
+ path: "/openapi/medical-search",
40
+ body: await readJsonInput(options),
41
+ };
42
+ }
43
+
44
+ const query = positionals[2];
45
+ if (!query) {
46
+ throw new CliError("usage_error", "Missing medical search query.");
47
+ }
48
+ const body = { query };
49
+ if (options.file_type !== undefined) body.fileType = options.file_type;
50
+ const limit = integerOption(options.limit, "limit", 1, 20);
51
+ if (limit !== undefined) body.limit = limit;
52
+ const maxLength = integerOption(options.max_length, "max-length", 100, 5000);
53
+ if (maxLength !== undefined) body.maxLength = maxLength;
54
+ return { method: "POST", path: "/openapi/medical-search", body };
55
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+ import { readJsonInput } from "../input.js";
7
+
8
+ export async function resolveProducts(positionals, options) {
9
+ const [resource, action] = positionals;
10
+ if (resource !== "products") return null;
11
+
12
+ if (action === "get") {
13
+ assertNoExtraPositionals(positionals, 3);
14
+ assertOptions(options);
15
+ const id = requiredId(positionals, 2, "product ID");
16
+ return { method: "GET", path: `/openapi/products/${id}` };
17
+ }
18
+
19
+ if (action === "update-knowledge-tree") {
20
+ assertNoExtraPositionals(positionals, 3);
21
+ assertOptions(options, ["input", "json"]);
22
+ const id = requiredId(positionals, 2, "product ID");
23
+ return {
24
+ method: "PUT",
25
+ path: `/openapi/products/${id}/knowledge-tree`,
26
+ body: await readJsonInput(options),
27
+ };
28
+ }
29
+
30
+ return null;
31
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ requiredId,
5
+ } from "../args.js";
6
+ import { readJsonInput } from "../input.js";
7
+
8
+ export async function resolveScoring(positionals, options) {
9
+ const [resource, action] = positionals;
10
+ if (resource !== "scoring") return null;
11
+
12
+ if (action === "get") {
13
+ assertNoExtraPositionals(positionals, 3);
14
+ assertOptions(options);
15
+ const id = requiredId(positionals, 2, "task ID");
16
+ return { method: "GET", path: `/openapi/scoring/${id}` };
17
+ }
18
+
19
+ if (action === "submit") {
20
+ assertNoExtraPositionals(positionals, 3);
21
+ assertOptions(options, ["input", "json"]);
22
+ const id = requiredId(positionals, 2, "task ID");
23
+ return {
24
+ method: "POST",
25
+ path: `/openapi/scoring/${id}/results`,
26
+ body: await readJsonInput(options),
27
+ };
28
+ }
29
+
30
+ return null;
31
+ }
@@ -0,0 +1,30 @@
1
+ import { resolveContent } from "./command-handlers/content.js";
2
+ import { resolveExamRecords } from "./command-handlers/exam-records.js";
3
+ import { resolveGenTasks } from "./command-handlers/gen-tasks.js";
4
+ import { resolveKnowledge } from "./command-handlers/knowledge.js";
5
+ import { resolveMedicalSearch } from "./command-handlers/medical-search.js";
6
+ import { resolveProducts } from "./command-handlers/products.js";
7
+ import { resolveScoring } from "./command-handlers/scoring.js";
8
+ import { CliError } from "./errors.js";
9
+
10
+ const resolvers = [
11
+ resolveProducts,
12
+ resolveKnowledge,
13
+ resolveGenTasks,
14
+ resolveExamRecords,
15
+ resolveScoring,
16
+ resolveContent,
17
+ resolveMedicalSearch,
18
+ ];
19
+
20
+ export async function resolveCommand(positionals, options) {
21
+ for (const resolve of resolvers) {
22
+ const command = await resolve(positionals, options);
23
+ if (command) return command;
24
+ }
25
+
26
+ throw new CliError(
27
+ "usage_error",
28
+ "Unknown command. Run `viatris-exam --help` or `viatris-exam describe`.",
29
+ );
30
+ }
package/src/config.js ADDED
@@ -0,0 +1,85 @@
1
+ import { integerOption } from "./args.js";
2
+ import { CliError } from "./errors.js";
3
+ import {
4
+ API_ENVIRONMENT_ALIASES,
5
+ API_ENVIRONMENTS,
6
+ DEFAULT_RETRIES,
7
+ DEFAULT_TIMEOUT_MS,
8
+ } from "./metadata.js";
9
+
10
+ export function configFrom(options, environmentVariables = process.env) {
11
+ const baseUrlOverride =
12
+ options.base_url ?? environmentVariables.VIATRIS_API_BASE_URL;
13
+ let environment = null;
14
+ let baseUrl = baseUrlOverride;
15
+ if (baseUrlOverride === undefined) {
16
+ const requestedEnvironment = (
17
+ environmentVariables.VIATRIS_API_ENV ?? "production"
18
+ ).toLowerCase();
19
+ environment =
20
+ API_ENVIRONMENT_ALIASES[requestedEnvironment] ?? requestedEnvironment;
21
+ if (!Object.hasOwn(API_ENVIRONMENTS, environment)) {
22
+ throw new CliError(
23
+ "config_error",
24
+ "VIATRIS_API_ENV must be production, development, prod, or dev.",
25
+ { exitCode: 3 },
26
+ );
27
+ }
28
+ baseUrl = API_ENVIRONMENTS[environment];
29
+ }
30
+
31
+ const apiKey = environmentVariables.VIATRIS_API_KEY;
32
+ const keyHeader =
33
+ options.key_header ??
34
+ environmentVariables.VIATRIS_API_KEY_HEADER ??
35
+ "X-API-Key";
36
+ const keyPrefix =
37
+ options.key_prefix ?? environmentVariables.VIATRIS_API_KEY_PREFIX ?? "";
38
+ const timeoutMs = integerOption(
39
+ options.timeout ?? String(DEFAULT_TIMEOUT_MS),
40
+ "timeout",
41
+ 1,
42
+ 600_000,
43
+ );
44
+ const retries = integerOption(
45
+ options.retries ?? String(DEFAULT_RETRIES),
46
+ "retries",
47
+ 0,
48
+ 5,
49
+ );
50
+
51
+ if (!baseUrl) {
52
+ throw new CliError(
53
+ "config_error",
54
+ "The API base URL override cannot be empty.",
55
+ { exitCode: 3 },
56
+ );
57
+ }
58
+ if (!/^https?:\/\//.test(baseUrl)) {
59
+ throw new CliError(
60
+ "config_error",
61
+ "VIATRIS_API_BASE_URL must start with http:// or https://.",
62
+ { exitCode: 3 },
63
+ );
64
+ }
65
+ if (!apiKey) {
66
+ throw new CliError("config_error", "VIATRIS_API_KEY is required.", {
67
+ exitCode: 3,
68
+ });
69
+ }
70
+ if (!/^[A-Za-z0-9-]+$/.test(keyHeader)) {
71
+ throw new CliError("config_error", "API key header name is invalid.", {
72
+ exitCode: 3,
73
+ });
74
+ }
75
+
76
+ return {
77
+ baseUrl: baseUrl.replace(/\/+$/, ""),
78
+ environment,
79
+ apiKey,
80
+ keyHeader,
81
+ keyPrefix,
82
+ timeoutMs,
83
+ retries,
84
+ };
85
+ }
package/src/errors.js ADDED
@@ -0,0 +1,14 @@
1
+ export class CliError extends Error {
2
+ constructor(type, message, { exitCode = 2, details } = {}) {
3
+ super(message);
4
+ this.name = "CliError";
5
+ this.type = type;
6
+ this.exitCode = exitCode;
7
+ this.details = details;
8
+ }
9
+ }
10
+
11
+ export function normalizeError(error) {
12
+ if (error instanceof CliError) return error;
13
+ return new CliError("internal_error", error.message, { exitCode: 1 });
14
+ }