@widgetic/cli 0.0.0-stage → 0.1.2
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/LICENSE +122 -0
- package/README.md +184 -2
- package/bin/widgetic.js +5 -0
- package/dist/cli.js +138 -0
- package/dist/commands/auth.js +269 -0
- package/dist/commands/call.js +97 -0
- package/dist/commands/completion.js +150 -0
- package/dist/commands/doctor.js +293 -0
- package/dist/commands/schema.js +216 -0
- package/dist/commands/update.js +159 -0
- package/dist/commands/verify.js +64 -0
- package/dist/config.js +98 -0
- package/dist/confirm.js +15 -0
- package/dist/constants.js +57 -0
- package/dist/contract-note.js +42 -0
- package/dist/credentials.js +111 -0
- package/dist/environment.js +150 -0
- package/dist/errors.js +67 -0
- package/dist/help.js +174 -0
- package/dist/http.js +122 -0
- package/dist/input.js +37 -0
- package/dist/main.js +30 -0
- package/dist/manifest/load.js +39 -0
- package/dist/manifest/types.js +14 -0
- package/dist/mcp/plan.js +32 -0
- package/dist/mcp/register.js +112 -0
- package/dist/mcp/session.js +186 -0
- package/dist/mcp/wait.js +68 -0
- package/dist/options.js +228 -0
- package/dist/output.js +90 -0
- package/dist/overlay.js +54 -0
- package/dist/patterns.js +11 -0
- package/dist/rest/execute.js +67 -0
- package/dist/rest/mime.js +37 -0
- package/dist/rest/plan.js +185 -0
- package/dist/rest/register.js +144 -0
- package/dist/retry.js +49 -0
- package/dist/secrets.js +149 -0
- package/dist/state.js +37 -0
- package/dist/update/installation.js +83 -0
- package/dist/update/lookup.js +128 -0
- package/dist/update/offer.js +165 -0
- package/dist/update/perform.js +33 -0
- package/dist/update/registry.js +68 -0
- package/dist/update/version.js +39 -0
- package/dist/validate.js +93 -0
- package/dist/version.js +59 -0
- package/generated/contracts.json +1 -0
- package/generated/mcp-manifest.json +1 -0
- package/generated/rest-manifest.json +1 -0
- package/package.json +56 -5
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { usageError } from "../errors.js";
|
|
2
|
+
import { errorFromAnswer, networkFailure, readAnswer } from "../http.js";
|
|
3
|
+
import { withRetries } from "../retry.js";
|
|
4
|
+
async function filePart(environment, file) {
|
|
5
|
+
let bytes;
|
|
6
|
+
try {
|
|
7
|
+
bytes = await environment.fs.readFile(file.path);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
throw usageError(`The file ${file.path} cannot be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
11
|
+
}
|
|
12
|
+
return new Blob([bytes], { type: file.contentType });
|
|
13
|
+
}
|
|
14
|
+
async function bodyOf(environment, plan) {
|
|
15
|
+
switch (plan.body.kind) {
|
|
16
|
+
case 'json':
|
|
17
|
+
return JSON.stringify(plan.body.value);
|
|
18
|
+
case 'binary':
|
|
19
|
+
return filePart(environment, plan.body.file);
|
|
20
|
+
case 'multipart': {
|
|
21
|
+
const form = new FormData();
|
|
22
|
+
for (const field of plan.body.fields) {
|
|
23
|
+
if ('file' in field)
|
|
24
|
+
form.append(field.name, await filePart(environment, field.file), field.file.name);
|
|
25
|
+
else
|
|
26
|
+
form.append(field.name, field.value);
|
|
27
|
+
}
|
|
28
|
+
return form;
|
|
29
|
+
}
|
|
30
|
+
default:
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Whether a second try of the call cannot do anything twice: it reads, or it carries an idempotency key. */
|
|
35
|
+
export function isSafeToRepeat(plan) {
|
|
36
|
+
return (plan.method === 'GET' ||
|
|
37
|
+
plan.method === 'HEAD' ||
|
|
38
|
+
plan.method === 'OPTIONS' ||
|
|
39
|
+
plan.idempotencyKey !== undefined);
|
|
40
|
+
}
|
|
41
|
+
/** Sends the request (again, for the failures worth another try) and returns the answer, or throws what it stands for. */
|
|
42
|
+
export async function sendRestRequest(environment, plan, options) {
|
|
43
|
+
const attempt = async () => {
|
|
44
|
+
const headers = new Headers(plan.headers);
|
|
45
|
+
if (plan.authenticated && options.apiKey !== undefined)
|
|
46
|
+
headers.set('authorization', `Bearer ${options.apiKey}`);
|
|
47
|
+
// Read before the request starts, so a file that cannot be read is a mistake of the command line and not a network failure.
|
|
48
|
+
const body = await bodyOf(environment, plan);
|
|
49
|
+
let answer;
|
|
50
|
+
try {
|
|
51
|
+
const response = await environment.fetch(plan.url, {
|
|
52
|
+
method: plan.method,
|
|
53
|
+
headers,
|
|
54
|
+
body,
|
|
55
|
+
signal: AbortSignal.timeout(options.timeoutMilliseconds)
|
|
56
|
+
});
|
|
57
|
+
answer = await readAnswer(response);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw networkFailure(error, plan.url, options.timeoutMilliseconds);
|
|
61
|
+
}
|
|
62
|
+
if (answer.status >= 200 && answer.status < 300)
|
|
63
|
+
return answer;
|
|
64
|
+
throw errorFromAnswer(answer, environment.now(), options.keySource);
|
|
65
|
+
};
|
|
66
|
+
return withRetries(environment, { safeToRepeat: isSafeToRepeat(plan), hasIdempotencyKey: plan.idempotencyKey !== undefined }, attempt, options.onRetry);
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* The content type of a file, from its extension, for the files the API takes (images, audio, video, documents). A file
|
|
4
|
+
* with an extension this list does not know is sent as `application/octet-stream`; `--content-type` says otherwise.
|
|
5
|
+
*/
|
|
6
|
+
const BY_EXTENSION = {
|
|
7
|
+
'.png': 'image/png',
|
|
8
|
+
'.jpg': 'image/jpeg',
|
|
9
|
+
'.jpeg': 'image/jpeg',
|
|
10
|
+
'.gif': 'image/gif',
|
|
11
|
+
'.webp': 'image/webp',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
'.heic': 'image/heic',
|
|
14
|
+
'.heif': 'image/heif',
|
|
15
|
+
'.avif': 'image/avif',
|
|
16
|
+
'.pdf': 'application/pdf',
|
|
17
|
+
'.mp4': 'video/mp4',
|
|
18
|
+
'.mov': 'video/quicktime',
|
|
19
|
+
'.webm': 'video/webm',
|
|
20
|
+
'.mp3': 'audio/mpeg',
|
|
21
|
+
'.wav': 'audio/wav',
|
|
22
|
+
'.ogg': 'audio/ogg',
|
|
23
|
+
'.aac': 'audio/aac',
|
|
24
|
+
'.flac': 'audio/flac',
|
|
25
|
+
'.m4a': 'audio/mp4',
|
|
26
|
+
'.json': 'application/json',
|
|
27
|
+
'.csv': 'text/csv',
|
|
28
|
+
'.txt': 'text/plain',
|
|
29
|
+
'.md': 'text/markdown',
|
|
30
|
+
'.html': 'text/html',
|
|
31
|
+
'.zip': 'application/zip'
|
|
32
|
+
};
|
|
33
|
+
export const UNKNOWN_CONTENT_TYPE = 'application/octet-stream';
|
|
34
|
+
/** The content type a file name suggests, or `undefined` when its extension is not one the list knows. */
|
|
35
|
+
export function contentTypeOfFile(path) {
|
|
36
|
+
return BY_EXTENSION[extname(path).toLowerCase()];
|
|
37
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { basename, resolve } from 'node:path';
|
|
2
|
+
import { usageError } from "../errors.js";
|
|
3
|
+
import { readDocument } from "../input.js";
|
|
4
|
+
import { redactSecrets, withSecrets } from "../secrets.js";
|
|
5
|
+
import { namedByFlag, requireValid } from "../validate.js";
|
|
6
|
+
import { identityHeaders } from "../version.js";
|
|
7
|
+
import { contentTypeOfFile, UNKNOWN_CONTENT_TYPE } from "./mime.js";
|
|
8
|
+
/** A list is the parameter repeated; `pairs` are `name[key]=value` (the form the gateway reads for an object of strings). */
|
|
9
|
+
function appendQuery(query, parameter, value) {
|
|
10
|
+
if (parameter.type === 'pairs') {
|
|
11
|
+
for (const [key, text] of Object.entries(value))
|
|
12
|
+
query.append(`${parameter.name}[${key}]`, text);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
16
|
+
query.append(parameter.name, String(item));
|
|
17
|
+
}
|
|
18
|
+
async function plannedFile(environment, path, flag, contentType) {
|
|
19
|
+
const absolute = resolve(environment.cwd, path);
|
|
20
|
+
const found = await environment.fs.stat(absolute);
|
|
21
|
+
if (!found)
|
|
22
|
+
throw usageError(`--${flag}: there is no file at ${absolute}.`);
|
|
23
|
+
if (!found.isFile)
|
|
24
|
+
throw usageError(`--${flag}: ${absolute} is not a file.`);
|
|
25
|
+
return {
|
|
26
|
+
path: absolute,
|
|
27
|
+
name: basename(absolute),
|
|
28
|
+
size: found.size,
|
|
29
|
+
contentType: contentType ?? contentTypeOfFile(absolute) ?? UNKNOWN_CONTENT_TYPE
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function planJsonBody(input, body) {
|
|
33
|
+
const { environment, command, data } = input;
|
|
34
|
+
// A secret flag holds `-` or `@path`: the secret it names is read first, so two readers of standard input are
|
|
35
|
+
// refused before either reads it, and a secret typed as text is refused before anything is sent.
|
|
36
|
+
const values = await withSecrets(environment, body.flags, input.values, {
|
|
37
|
+
canAsk: input.canAsk,
|
|
38
|
+
others: data === '-' ? ['data'] : []
|
|
39
|
+
});
|
|
40
|
+
const document = data === undefined ? {} : await readDocument(environment, data, 'data');
|
|
41
|
+
let given = data !== undefined;
|
|
42
|
+
for (const flag of body.flags) {
|
|
43
|
+
if (values.has(flag.flag)) {
|
|
44
|
+
// A flag is the same key of the document, and wins over the document's own.
|
|
45
|
+
document[flag.property] = values.get(flag.flag);
|
|
46
|
+
given = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!given && !body.required)
|
|
50
|
+
return { kind: 'none' };
|
|
51
|
+
const flagOf = new Map(body.flags.map((flag) => [flag.property, flag.flag]));
|
|
52
|
+
await requireValid(body.schema ?? {}, document, `The document of ${command.operationId}`, (path) => namedByFlag(path, flagOf));
|
|
53
|
+
return { kind: 'json', value: document };
|
|
54
|
+
}
|
|
55
|
+
async function planMultipartBody(input, body) {
|
|
56
|
+
const { environment, values } = input;
|
|
57
|
+
const fields = [];
|
|
58
|
+
const missing = [];
|
|
59
|
+
for (const flag of body.flags) {
|
|
60
|
+
const value = values.get(flag.flag);
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
if (flag.required)
|
|
63
|
+
missing.push(`--${flag.flag}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (flag.type === 'file')
|
|
67
|
+
fields.push({
|
|
68
|
+
name: flag.property,
|
|
69
|
+
file: await plannedFile(environment, String(value), flag.flag, undefined)
|
|
70
|
+
});
|
|
71
|
+
else
|
|
72
|
+
fields.push({ name: flag.property, value: String(value) });
|
|
73
|
+
}
|
|
74
|
+
if (missing.length > 0)
|
|
75
|
+
throw usageError(`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required.`);
|
|
76
|
+
return { kind: 'multipart', fields };
|
|
77
|
+
}
|
|
78
|
+
async function planBinaryBody(input, body) {
|
|
79
|
+
const { environment, values } = input;
|
|
80
|
+
const path = values.get('file');
|
|
81
|
+
if (typeof path !== 'string')
|
|
82
|
+
throw usageError('--file is required: this command sends the bytes of a file.');
|
|
83
|
+
const accepted = body.contentTypes ?? [body.contentType];
|
|
84
|
+
const given = values.get('content-type');
|
|
85
|
+
let contentType = typeof given === 'string' ? given : undefined;
|
|
86
|
+
if (contentType !== undefined && !accepted.includes(contentType)) {
|
|
87
|
+
throw usageError(`--content-type ${contentType} is not one this command takes: ${accepted.join(', ')}.`);
|
|
88
|
+
}
|
|
89
|
+
if (contentType === undefined) {
|
|
90
|
+
const guessed = contentTypeOfFile(path);
|
|
91
|
+
if (guessed !== undefined && accepted.includes(guessed))
|
|
92
|
+
contentType = guessed;
|
|
93
|
+
else if (accepted.length === 1)
|
|
94
|
+
contentType = accepted[0];
|
|
95
|
+
else
|
|
96
|
+
throw usageError(`Say what kind of file it is with --content-type (one of: ${accepted.join(', ')}).`);
|
|
97
|
+
}
|
|
98
|
+
return { kind: 'binary', file: await plannedFile(environment, path, 'file', contentType) };
|
|
99
|
+
}
|
|
100
|
+
export async function planRestRequest(input) {
|
|
101
|
+
const { environment, command, settings, values } = input;
|
|
102
|
+
let path = command.path;
|
|
103
|
+
const query = new URLSearchParams();
|
|
104
|
+
const headers = { accept: 'application/json', ...identityHeaders(environment) };
|
|
105
|
+
let idempotencyKey;
|
|
106
|
+
for (const parameter of command.parameters) {
|
|
107
|
+
let value = values.get(parameter.flag);
|
|
108
|
+
if (value === undefined && parameter.role === 'idempotency-key') {
|
|
109
|
+
// A call that creates is made safe to repeat: one key for the call, the same on every try.
|
|
110
|
+
value = `cli-${environment.randomUUID()}`;
|
|
111
|
+
}
|
|
112
|
+
if (value === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
if (parameter.in === 'path')
|
|
115
|
+
path = path.replace(`{${parameter.name}}`, encodeURIComponent(String(value)));
|
|
116
|
+
else if (parameter.in === 'query')
|
|
117
|
+
appendQuery(query, parameter, value);
|
|
118
|
+
else
|
|
119
|
+
headers[parameter.name.toLowerCase()] = String(value);
|
|
120
|
+
if (parameter.role === 'idempotency-key')
|
|
121
|
+
idempotencyKey = String(value);
|
|
122
|
+
}
|
|
123
|
+
let body = { kind: 'none' };
|
|
124
|
+
if (command.body) {
|
|
125
|
+
switch (command.body.kind) {
|
|
126
|
+
case 'json':
|
|
127
|
+
body = await planJsonBody(input, command.body);
|
|
128
|
+
break;
|
|
129
|
+
case 'multipart':
|
|
130
|
+
body = await planMultipartBody(input, command.body);
|
|
131
|
+
break;
|
|
132
|
+
case 'binary':
|
|
133
|
+
body = await planBinaryBody(input, command.body);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (body.kind === 'json')
|
|
137
|
+
headers['content-type'] = 'application/json';
|
|
138
|
+
if (body.kind === 'binary')
|
|
139
|
+
headers['content-type'] = body.file.contentType;
|
|
140
|
+
}
|
|
141
|
+
const base = command.server === 'root' ? settings.apiRootUrl : settings.apiUrl;
|
|
142
|
+
const queryText = query.toString();
|
|
143
|
+
return {
|
|
144
|
+
command,
|
|
145
|
+
method: command.method,
|
|
146
|
+
url: `${base}${path}${queryText === '' ? '' : `?${queryText}`}`,
|
|
147
|
+
headers,
|
|
148
|
+
authenticated: command.auth,
|
|
149
|
+
body,
|
|
150
|
+
idempotencyKey
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** What `--dry-run` prints: the request exactly as it would be sent, with the key left out. */
|
|
154
|
+
export function describeRestPlan(plan) {
|
|
155
|
+
const headers = { ...plan.headers };
|
|
156
|
+
if (plan.authenticated)
|
|
157
|
+
headers.authorization = 'Bearer [redacted]';
|
|
158
|
+
const sorted = Object.fromEntries(Object.entries(headers).sort(([a], [b]) => a.localeCompare(b)));
|
|
159
|
+
const description = {
|
|
160
|
+
dryRun: true,
|
|
161
|
+
method: plan.method,
|
|
162
|
+
url: plan.url,
|
|
163
|
+
headers: sorted
|
|
164
|
+
};
|
|
165
|
+
switch (plan.body.kind) {
|
|
166
|
+
case 'json':
|
|
167
|
+
// A secret of the document is left out, wherever the contract has it (see `secrets.ts`).
|
|
168
|
+
description.body = redactSecrets(plan.body.value, plan.command.body?.schema);
|
|
169
|
+
break;
|
|
170
|
+
case 'multipart':
|
|
171
|
+
description.form = Object.fromEntries(plan.body.fields.map((field) => [
|
|
172
|
+
field.name,
|
|
173
|
+
'file' in field
|
|
174
|
+
? `@${field.file.path} (${field.file.size} bytes, ${field.file.contentType})`
|
|
175
|
+
: field.value
|
|
176
|
+
]));
|
|
177
|
+
break;
|
|
178
|
+
case 'binary':
|
|
179
|
+
description.file = `@${plan.body.file.path} (${plan.body.file.size} bytes, ${plan.body.file.contentType})`;
|
|
180
|
+
break;
|
|
181
|
+
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
return description;
|
|
185
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { resolveSettings, timeoutMilliseconds } from "../config.js";
|
|
2
|
+
import { confirmOrStop } from "../confirm.js";
|
|
3
|
+
import { requireApiKey } from "../credentials.js";
|
|
4
|
+
import { apiGroupSummary } from "../help.js";
|
|
5
|
+
import { addCommonOptions, addFlag, commandHelp, readCommon, readFlags } from "../options.js";
|
|
6
|
+
import { note, printResult } from "../output.js";
|
|
7
|
+
import { retryNotifier } from "../retry.js";
|
|
8
|
+
import { sendRestRequest } from "./execute.js";
|
|
9
|
+
import { describeRestPlan, planRestRequest } from "./plan.js";
|
|
10
|
+
/**
|
|
11
|
+
* The `api` namespace: one command for every operation of the public OpenAPI description, built from the manifest the
|
|
12
|
+
* generator wrote. A command is a group (the operation's tag) and a name (its operationId in kebab case); a path,
|
|
13
|
+
* query or header parameter is a flag, and so is each simple property of a JSON document (`--data` takes the rest).
|
|
14
|
+
*/
|
|
15
|
+
const PARAMETER_GROUPS = {
|
|
16
|
+
path: 'Path parameters:',
|
|
17
|
+
query: 'Query parameters:',
|
|
18
|
+
header: 'Headers:'
|
|
19
|
+
};
|
|
20
|
+
/** What a command prints when the server answered with something: the body as the server sent it. */
|
|
21
|
+
function printAnswer(environment, answer, json) {
|
|
22
|
+
if (answer.body === undefined) {
|
|
23
|
+
// An empty answer (204): the exit code says it worked, and `--json` stdout stays empty for a program to read.
|
|
24
|
+
if (!json)
|
|
25
|
+
note(environment, 'Done.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
printResult(environment, answer.body, json);
|
|
29
|
+
}
|
|
30
|
+
/** Runs one command: plan, then (unless `--dry-run`) confirm, find the key, send, print. */
|
|
31
|
+
export async function runRestCommand(run, command, node, bindings) {
|
|
32
|
+
const { environment, manifest } = run;
|
|
33
|
+
const common = readCommon(node);
|
|
34
|
+
const values = readFlags(node, bindings);
|
|
35
|
+
const options = node.opts();
|
|
36
|
+
const data = typeof options.data === 'string' ? options.data : undefined;
|
|
37
|
+
const settings = resolveSettings(environment, { profile: common.profile, manifestApiUrl: manifest.apiUrl });
|
|
38
|
+
const plan = await planRestRequest({ environment, command, settings, values, data, canAsk: !common.json });
|
|
39
|
+
if (common.dryRun) {
|
|
40
|
+
printResult(environment, describeRestPlan(plan), common.json);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (command.confirm) {
|
|
44
|
+
await confirmOrStop(environment, {
|
|
45
|
+
what: `${plan.method} ${new URL(plan.url).pathname} changes or removes something`,
|
|
46
|
+
yes: common.yes,
|
|
47
|
+
json: common.json
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const key = plan.authenticated ? await requireApiKey(environment, settings) : undefined;
|
|
51
|
+
const answer = await sendRestRequest(environment, plan, {
|
|
52
|
+
apiKey: key?.apiKey,
|
|
53
|
+
keySource: key?.source,
|
|
54
|
+
timeoutMilliseconds: timeoutMilliseconds(common.timeout),
|
|
55
|
+
onRetry: retryNotifier(environment, common.json)
|
|
56
|
+
});
|
|
57
|
+
printAnswer(environment, answer, common.json);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What the flag of an idempotency key says. The contract's own text starts with "Required: a call without it is refused",
|
|
61
|
+
* which is true of the API and not of the CLI, and it is too long for `--help` to show whole: the limits are checked
|
|
62
|
+
* against the contract and named by the error when a key is wrong, so they are not repeated here.
|
|
63
|
+
*/
|
|
64
|
+
const IDEMPOTENCY_KEY_HELP = 'Names this one request, so a repeat of it is carried out once. The CLI makes a key when this is left out and sends the same one on every try; give your own (a UUID is ideal) to repeat the call by hand later.';
|
|
65
|
+
/**
|
|
66
|
+
* A parameter as the flag shows it (in `--help` and in the generated reference): the CLI makes the idempotency key
|
|
67
|
+
* itself, so it is never demanded.
|
|
68
|
+
*/
|
|
69
|
+
export function flagSpecOf(parameter) {
|
|
70
|
+
if (parameter.role !== 'idempotency-key')
|
|
71
|
+
return parameter;
|
|
72
|
+
return { ...parameter, required: false, description: IDEMPOTENCY_KEY_HELP };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The kind of key an operation needs, when it is not just any: said at the end of its help (so a limit on the length of
|
|
76
|
+
* the description cannot cut it), because a 403 is the only other way to learn it.
|
|
77
|
+
*/
|
|
78
|
+
export function accessNote(command) {
|
|
79
|
+
if (command.accessLevel === 'partner')
|
|
80
|
+
return 'Needs a partner API key (wgt_prt_…).';
|
|
81
|
+
if (!command.auth)
|
|
82
|
+
return 'Needs no API key: browsers call it, and none is sent.';
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function registerCommand(parent, group, command, run) {
|
|
86
|
+
const access = accessNote(command);
|
|
87
|
+
const help = commandHelp(command.summary, command.description, `widgetic schema api ${group.name} ${command.name}`);
|
|
88
|
+
const node = parent
|
|
89
|
+
.command(command.name, { hidden: command.hidden })
|
|
90
|
+
.summary(command.summary)
|
|
91
|
+
.description(access === undefined ? help : `${help}\n\n${access}`);
|
|
92
|
+
for (const alias of command.aliases)
|
|
93
|
+
node.alias(alias);
|
|
94
|
+
const bindings = [];
|
|
95
|
+
for (const parameter of command.parameters) {
|
|
96
|
+
bindings.push(addFlag(node, flagSpecOf(parameter), { mandatory: true, group: PARAMETER_GROUPS[parameter.in] }));
|
|
97
|
+
}
|
|
98
|
+
const body = command.body;
|
|
99
|
+
if (body?.kind === 'json') {
|
|
100
|
+
for (const flag of body.flags)
|
|
101
|
+
bindings.push(addFlag(node, flag, { mandatory: false, group: 'Fields of the document:' }));
|
|
102
|
+
}
|
|
103
|
+
else if (body?.kind === 'multipart') {
|
|
104
|
+
for (const flag of body.flags)
|
|
105
|
+
bindings.push(addFlag(node, flag, { mandatory: true, group: 'Form fields:' }));
|
|
106
|
+
}
|
|
107
|
+
else if (body?.kind === 'binary') {
|
|
108
|
+
const accepted = body.contentTypes ?? [body.contentType];
|
|
109
|
+
bindings.push(addFlag(node, {
|
|
110
|
+
flag: 'file',
|
|
111
|
+
name: 'file',
|
|
112
|
+
type: 'file',
|
|
113
|
+
required: true,
|
|
114
|
+
description: 'The file to send: its bytes are the body of the request.'
|
|
115
|
+
}, { mandatory: true, group: 'The file:' }), addFlag(node, {
|
|
116
|
+
flag: 'content-type',
|
|
117
|
+
name: 'content-type',
|
|
118
|
+
type: 'string',
|
|
119
|
+
required: false,
|
|
120
|
+
description: 'What kind of file it is; guessed from the file name when left out.',
|
|
121
|
+
enum: accepted
|
|
122
|
+
}, { mandatory: false, group: 'The file:' }));
|
|
123
|
+
}
|
|
124
|
+
addCommonOptions(node, { asks: command.confirm, document: body?.kind === 'json' ? 'data' : undefined });
|
|
125
|
+
node.action(async () => {
|
|
126
|
+
await runRestCommand(run, command, node, bindings);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Adds the groups of the manifest, and their commands, under `api`. A group is listed with the line of
|
|
131
|
+
* `apiGroupSummary` (what it works on, and the key it needs when that is a partner's), which is also the first line
|
|
132
|
+
* of its own help: the contract names the group and says nothing else about it.
|
|
133
|
+
*/
|
|
134
|
+
export function registerRestCommands(api, manifest, environment) {
|
|
135
|
+
const run = { environment, manifest };
|
|
136
|
+
for (const group of manifest.groups) {
|
|
137
|
+
const node = api
|
|
138
|
+
.command(group.name, { hidden: group.hidden })
|
|
139
|
+
.summary(apiGroupSummary(group))
|
|
140
|
+
.description(apiGroupSummary(group));
|
|
141
|
+
for (const command of group.commands)
|
|
142
|
+
registerCommand(node, group, command, run);
|
|
143
|
+
}
|
|
144
|
+
}
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { RETRY_CONFIG } from "./constants.js";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { note } from "./output.js";
|
|
4
|
+
const UNPROCESSED = new Set([429]);
|
|
5
|
+
const LOST = new Set([502, 503, 504]);
|
|
6
|
+
/** Milliseconds to wait before the next try, or `undefined` when the failure is not one to try again. */
|
|
7
|
+
export function retryDelay(error, attempt, rules) {
|
|
8
|
+
if (attempt >= RETRY_CONFIG.MAX_ATTEMPTS)
|
|
9
|
+
return undefined;
|
|
10
|
+
const status = error.status;
|
|
11
|
+
const lost = error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT' || (status !== undefined && LOST.has(status));
|
|
12
|
+
const refusedFirst = (status !== undefined && UNPROCESSED.has(status)) ||
|
|
13
|
+
(status === 409 && rules.hasIdempotencyKey === true && error.code === 'CONFLICT_IDEMPOTENCY_IN_PROGRESS');
|
|
14
|
+
if (!refusedFirst && !(lost && rules.safeToRepeat))
|
|
15
|
+
return undefined;
|
|
16
|
+
if (error.retryAfter !== undefined) {
|
|
17
|
+
// A server that asks for a long wait is believed, and reported: the caller decides whether to wait that long.
|
|
18
|
+
if (error.retryAfter > RETRY_CONFIG.MAX_RETRY_AFTER_SECONDS)
|
|
19
|
+
return undefined;
|
|
20
|
+
return Math.max(error.retryAfter, 0) * 1000;
|
|
21
|
+
}
|
|
22
|
+
return RETRY_CONFIG.BASE_DELAY_MS * RETRY_CONFIG.GROWTH_FACTOR ** (attempt - 1);
|
|
23
|
+
}
|
|
24
|
+
/** The note a person reads while the CLI waits to try again; `--json` gets none, its stderr holds errors only. */
|
|
25
|
+
export function retryNotifier(environment, json) {
|
|
26
|
+
return (error, delayMilliseconds, nextAttempt) => {
|
|
27
|
+
if (json)
|
|
28
|
+
return;
|
|
29
|
+
const seconds = Math.max(1, Math.round(delayMilliseconds / 1000));
|
|
30
|
+
note(environment, `${error.message} Trying again in ${seconds}s (attempt ${nextAttempt} of ${RETRY_CONFIG.MAX_ATTEMPTS}).`);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Runs `attempt` until it succeeds or fails for good; `onRetry` hears about each wait (for a note on stderr). */
|
|
34
|
+
export async function withRetries(environment, rules, attempt, onRetry) {
|
|
35
|
+
for (let tries = 1;; tries += 1) {
|
|
36
|
+
try {
|
|
37
|
+
return await attempt();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (!(error instanceof CliError))
|
|
41
|
+
throw error;
|
|
42
|
+
const delay = retryDelay(error, tries, rules);
|
|
43
|
+
if (delay === undefined)
|
|
44
|
+
throw error;
|
|
45
|
+
onRetry?.(error, delay, tries + 1);
|
|
46
|
+
await environment.sleep(delay);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { readAll } from "./environment.js";
|
|
3
|
+
import { usageError } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Secrets. A value the contract says is a secret (`format: password`, or `writeOnly`: the server takes it and never
|
|
6
|
+
* gives it back) is the key of an adapter, the secret half of a storage key or a database connection string. It is
|
|
7
|
+
* handled in three ways, so that it does not end up where a person did not look:
|
|
8
|
+
*
|
|
9
|
+
* - it is never the text of a flag: what is typed on a command line stays in the shell's history and in the list of
|
|
10
|
+
* processes. The flag takes `-` (ask on the terminal, or read standard input) or `@path` (read a file), the way
|
|
11
|
+
* `--data` does, and a value typed on the command line is refused without being repeated;
|
|
12
|
+
* - `--dry-run` prints `[redacted]` in its place, whether it came from a flag or from the document of `--data`;
|
|
13
|
+
* - nothing else prints it: an error names the flag, never the value.
|
|
14
|
+
*
|
|
15
|
+
* Which values are secrets is the contract's word, not a list of names in this program: a new secret field is covered
|
|
16
|
+
* the day the gateway marks it, and the generator stops (`scripts/lib/`) at a secret it has no safe way to take.
|
|
17
|
+
*/
|
|
18
|
+
/** What `--dry-run` prints in the place of a secret. */
|
|
19
|
+
export const REDACTED = '[redacted]';
|
|
20
|
+
/** A secret the person gave as the text of a flag: refused, and the text is not repeated. */
|
|
21
|
+
function typedOnTheCommandLine(flag) {
|
|
22
|
+
return usageError(`--${flag} takes a secret, and a secret typed on the command line stays in the shell's history: give @path to read it from a file, or - to type it where it is not shown (or to read it from standard input).`, 'Or put it in the document of --data, which a file or standard input can hold.');
|
|
23
|
+
}
|
|
24
|
+
/** Whether a `-` asks on the terminal (hidden): there is a terminal, and the command may ask (`--json` never does). */
|
|
25
|
+
function asksOnTerminal(environment, canAsk) {
|
|
26
|
+
return canAsk && environment.promptSecret !== undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The secret a flag names: `-` asks on the terminal (hidden) when there is one and the command may ask, and reads
|
|
30
|
+
* standard input when not; `@path` reads a file. The line break at the end of a file or a pipe is not part of the secret.
|
|
31
|
+
*/
|
|
32
|
+
export async function readSecret(environment, source, flag, canAsk) {
|
|
33
|
+
let text;
|
|
34
|
+
if (source === '-') {
|
|
35
|
+
text =
|
|
36
|
+
canAsk && environment.promptSecret !== undefined
|
|
37
|
+
? await environment.promptSecret(`--${flag} (it is not shown): `)
|
|
38
|
+
: await readAll(environment.stdin);
|
|
39
|
+
}
|
|
40
|
+
else if (source.startsWith('@') && source.length > 1) {
|
|
41
|
+
const path = resolve(environment.cwd, source.slice(1));
|
|
42
|
+
try {
|
|
43
|
+
text = new TextDecoder().decode(await environment.fs.readFile(path));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw usageError(`--${flag}: the file ${path} cannot be read.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
throw typedOnTheCommandLine(flag);
|
|
51
|
+
}
|
|
52
|
+
const secret = text.replace(/[\r\n]+$/, '');
|
|
53
|
+
if (secret === '')
|
|
54
|
+
throw usageError(`--${flag}: the secret is empty.`);
|
|
55
|
+
return secret;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The values the person typed, with the `-` or `@path` of each secret flag replaced by the secret it names. Only one
|
|
59
|
+
* source can be standard input: two readers of it are stopped before either reads. (A `-` that asks on the terminal
|
|
60
|
+
* does not read standard input, so nothing is shared then.)
|
|
61
|
+
*/
|
|
62
|
+
export async function withSecrets(environment, flags, values, options) {
|
|
63
|
+
const resolved = new Map(values);
|
|
64
|
+
const given = flags.filter((flag) => flag.secret === true && typeof values.get(flag.flag) === 'string');
|
|
65
|
+
if (!asksOnTerminal(environment, options.canAsk)) {
|
|
66
|
+
const dashed = given.filter((flag) => values.get(flag.flag) === '-').map((flag) => flag.flag);
|
|
67
|
+
const readers = [...(options.others ?? []), ...dashed];
|
|
68
|
+
if (readers.length > 1) {
|
|
69
|
+
throw usageError(`${readers.map((name) => `--${name}`).join(' and ')} all read standard input, and only one of them can.`, 'Give the others as @path.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const flag of given) {
|
|
73
|
+
resolved.set(flag.flag, await readSecret(environment, String(values.get(flag.flag)), flag.flag, options.canAsk));
|
|
74
|
+
}
|
|
75
|
+
return resolved;
|
|
76
|
+
}
|
|
77
|
+
// ------------------------------------------------------------------------------------------------------- REDACTION
|
|
78
|
+
/** How deep a schema is followed: a cycle of `$ref`s ends here instead of never. */
|
|
79
|
+
const MOST_NESTING = 16;
|
|
80
|
+
function isRecord(value) {
|
|
81
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
/** The schema a `$ref` of this document points at (`#`, `#/$defs/Name`, `#/definitions/Name`), or the schema itself. */
|
|
84
|
+
function followed(root, schema) {
|
|
85
|
+
let current = schema;
|
|
86
|
+
for (let hops = 0; hops < MOST_NESTING; hops += 1) {
|
|
87
|
+
const ref = current?.$ref;
|
|
88
|
+
if (current === undefined || typeof ref !== 'string')
|
|
89
|
+
return current;
|
|
90
|
+
if (ref === '#') {
|
|
91
|
+
current = root;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!ref.startsWith('#/'))
|
|
95
|
+
return undefined;
|
|
96
|
+
let node = root;
|
|
97
|
+
for (const segment of ref.slice(2).split('/')) {
|
|
98
|
+
node = isRecord(node) ? node[segment.replace(/~1/g, '/').replace(/~0/g, '~')] : undefined;
|
|
99
|
+
}
|
|
100
|
+
current = isRecord(node) ? node : undefined;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
function redact(value, schema, root, depth) {
|
|
105
|
+
if (value === null || value === undefined || depth > MOST_NESTING)
|
|
106
|
+
return value;
|
|
107
|
+
const resolved = followed(root, schema);
|
|
108
|
+
if (resolved === undefined)
|
|
109
|
+
return value;
|
|
110
|
+
// The value itself is a secret, whatever its type.
|
|
111
|
+
if (resolved.format === 'password' || resolved.writeOnly === true)
|
|
112
|
+
return REDACTED;
|
|
113
|
+
let result = value;
|
|
114
|
+
// Alternatives: a secret in any of them is hidden, even when another one is the shape the value really has.
|
|
115
|
+
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
|
|
116
|
+
const alternatives = resolved[keyword];
|
|
117
|
+
if (!Array.isArray(alternatives))
|
|
118
|
+
continue;
|
|
119
|
+
for (const alternative of alternatives) {
|
|
120
|
+
if (isRecord(alternative))
|
|
121
|
+
result = redact(result, alternative, root, depth + 1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(result) && isRecord(resolved.items)) {
|
|
125
|
+
const items = resolved.items;
|
|
126
|
+
result = result.map((item) => redact(item, items, root, depth + 1));
|
|
127
|
+
}
|
|
128
|
+
if (isRecord(result)) {
|
|
129
|
+
const properties = isRecord(resolved.properties) ? resolved.properties : {};
|
|
130
|
+
const rest = isRecord(resolved.additionalProperties) ? resolved.additionalProperties : undefined;
|
|
131
|
+
const copy = { ...result };
|
|
132
|
+
for (const [key, entry] of Object.entries(result)) {
|
|
133
|
+
const own = properties[key];
|
|
134
|
+
const schemaOfEntry = isRecord(own) ? own : rest;
|
|
135
|
+
if (schemaOfEntry !== undefined)
|
|
136
|
+
copy[key] = redact(entry, schemaOfEntry, root, depth + 1);
|
|
137
|
+
}
|
|
138
|
+
result = copy;
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A copy of `value` in which every part the schema calls a secret is `[redacted]`: what `--dry-run` prints of a
|
|
144
|
+
* document. The schema is the contract's own (`$ref`s into its `$defs` are followed), so a secret is found wherever
|
|
145
|
+
* the contract puts it, not only at the top; a part the schema does not describe is left as it is.
|
|
146
|
+
*/
|
|
147
|
+
export function redactSecrets(value, schema) {
|
|
148
|
+
return schema === undefined ? value : redact(value, schema, schema, 0);
|
|
149
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { isRecord } from "./http.js";
|
|
3
|
+
/**
|
|
4
|
+
* `state.json`, beside the credentials: what the CLI remembers between two runs that is not a secret. It is one JSON
|
|
5
|
+
* object with a key for each thing remembered (`contractNote`, `updateCheck`), and every writer changes only its own key,
|
|
6
|
+
* so one feature never erases what another kept.
|
|
7
|
+
*
|
|
8
|
+
* Nothing in it is needed: a file that is missing, cannot be read or is not an object is the same as an empty one, and
|
|
9
|
+
* a write that fails is not an error of the command that asked for it (the CLI only says the thing again, or looks again).
|
|
10
|
+
*/
|
|
11
|
+
const STATE_FILE = 'state.json';
|
|
12
|
+
export function statePath(configDir) {
|
|
13
|
+
return join(configDir, STATE_FILE);
|
|
14
|
+
}
|
|
15
|
+
/** Everything that was remembered; an empty object when there is no file or it is damaged. */
|
|
16
|
+
export async function readState(environment, configDir) {
|
|
17
|
+
const path = statePath(configDir);
|
|
18
|
+
try {
|
|
19
|
+
if (!(await environment.fs.stat(path)))
|
|
20
|
+
return {};
|
|
21
|
+
const parsed = JSON.parse(new TextDecoder().decode(await environment.fs.readFile(path)));
|
|
22
|
+
return isRecord(parsed) ? parsed : {};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// A damaged state file is the same as none.
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Sets one key and leaves the others as they are (the file is read again just before it is written, so a record that
|
|
31
|
+
* another run saved a moment ago is kept). Rejects when the file cannot be written: the caller decides whether that matters.
|
|
32
|
+
*/
|
|
33
|
+
export async function writeStateKey(environment, configDir, key, value) {
|
|
34
|
+
const state = await readState(environment, configDir);
|
|
35
|
+
state[key] = value;
|
|
36
|
+
await environment.fs.writeFilePrivately(statePath(configDir), `${JSON.stringify(state, null, 2)}\n`);
|
|
37
|
+
}
|