@widgetic/cli 0.0.0-stage → 0.1.1
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 +106 -0
- package/dist/commands/auth.js +269 -0
- package/dist/commands/call.js +97 -0
- package/dist/commands/completion.js +148 -0
- package/dist/commands/doctor.js +291 -0
- package/dist/commands/schema.js +214 -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 +51 -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/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 +185 -0
- package/dist/mcp/wait.js +68 -0
- package/dist/options.js +228 -0
- package/dist/output.js +90 -0
- package/dist/overlay.js +31 -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 +139 -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
package/dist/options.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { InvalidArgumentError, Option } from 'commander';
|
|
2
|
+
import { HELP_DESCRIPTION_LIMIT } from "./constants.js";
|
|
3
|
+
import { patternRequirement } from "./patterns.js";
|
|
4
|
+
const COMMON_GROUP = 'Common options:';
|
|
5
|
+
/** A cut text with no code span left open: what follows the last backtick is dropped when the backticks do not pair up. */
|
|
6
|
+
function closed(text) {
|
|
7
|
+
const backticks = (text.match(/`/g) ?? []).length;
|
|
8
|
+
return backticks % 2 === 0 ? text : text.slice(0, text.lastIndexOf('`')).trimEnd();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The first paragraph of a text, cut at the end of a sentence when it is longer than `limit`. A cut never leaves a
|
|
12
|
+
* code span open (`409 MANAGED_USE…` with a lone backtick would turn the rest of a page into code).
|
|
13
|
+
*/
|
|
14
|
+
export function shorten(text, limit) {
|
|
15
|
+
const paragraph = (text.trim().split(/\n\s*\n/)[0] ?? '').replace(/\s*\n\s*/g, ' ').trim();
|
|
16
|
+
if (paragraph.length <= limit)
|
|
17
|
+
return paragraph;
|
|
18
|
+
const cut = paragraph.slice(0, limit);
|
|
19
|
+
const sentenceEnd = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('.\n'));
|
|
20
|
+
return sentenceEnd > limit / 2 ? closed(cut.slice(0, sentenceEnd + 1)) : `${closed(cut.trimEnd())}…`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A paragraph of a description as one line per sentence run: the line breaks a contract's author wrapped it with are
|
|
24
|
+
* spaces again (the terminal wraps it to its own width), and the lines of a list stay lines.
|
|
25
|
+
*/
|
|
26
|
+
export function unwrap(paragraph) {
|
|
27
|
+
let text = '';
|
|
28
|
+
for (const line of paragraph.split('\n')) {
|
|
29
|
+
const trimmed = line.trim();
|
|
30
|
+
if (text === '')
|
|
31
|
+
text = trimmed;
|
|
32
|
+
else
|
|
33
|
+
text += /^(?:[-*•]|\d+[.)])\s/.test(trimmed) ? `\n${trimmed}` : ` ${trimmed}`;
|
|
34
|
+
}
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The text a command shows in its own `--help`: whole paragraphs of its description while they fit, the first one cut
|
|
39
|
+
* at a sentence when even that is too long. The whole description, and every schema, is what `widgetic schema` prints.
|
|
40
|
+
*/
|
|
41
|
+
export function commandHelp(summary, description, schemaCommand, limit = HELP_DESCRIPTION_LIMIT) {
|
|
42
|
+
const full = description
|
|
43
|
+
.trim()
|
|
44
|
+
.split(/\n\s*\n/)
|
|
45
|
+
.map(unwrap)
|
|
46
|
+
.join('\n\n');
|
|
47
|
+
if (full === '')
|
|
48
|
+
return summary.trim();
|
|
49
|
+
if (full.length <= limit)
|
|
50
|
+
return full;
|
|
51
|
+
const kept = [];
|
|
52
|
+
let length = 0;
|
|
53
|
+
for (const paragraph of full.split(/\n\n/)) {
|
|
54
|
+
const next = length + paragraph.length + (kept.length > 0 ? 2 : 0);
|
|
55
|
+
if (kept.length > 0 && next > limit)
|
|
56
|
+
break;
|
|
57
|
+
kept.push(paragraph);
|
|
58
|
+
length = next;
|
|
59
|
+
}
|
|
60
|
+
const text = kept.join('\n\n');
|
|
61
|
+
return `${text.length > limit ? shorten(text, limit) : text}\n\nThe whole description and the schemas: ${schemaCommand}`;
|
|
62
|
+
}
|
|
63
|
+
/** How a flag shows what it takes: `<int>`, `<uuid>`, `<key=value>`; a secret takes `-` or a file, never the text itself. */
|
|
64
|
+
export function placeholderOf(spec) {
|
|
65
|
+
if (spec.secret)
|
|
66
|
+
return '<@file|->';
|
|
67
|
+
switch (spec.type) {
|
|
68
|
+
case 'integer':
|
|
69
|
+
return '<int>';
|
|
70
|
+
case 'number':
|
|
71
|
+
return '<number>';
|
|
72
|
+
case 'file':
|
|
73
|
+
return '<path>';
|
|
74
|
+
case 'pairs':
|
|
75
|
+
return '<key=value>';
|
|
76
|
+
case 'string[]':
|
|
77
|
+
return '<values...>';
|
|
78
|
+
default:
|
|
79
|
+
return `<${spec.format ?? 'text'}>`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Said by the help of every flag that takes a secret (`src/secrets.ts` explains why it is not typed as text). */
|
|
83
|
+
const SECRET_NOTE = 'A secret: give @path to read it from a file, or - to type it where it is not shown (or to read it from standard input). It is never typed as text, and --dry-run leaves it out.';
|
|
84
|
+
/** What a flag says about itself in `--help` (and in the reference generated from the same manifests). */
|
|
85
|
+
export function describe(spec) {
|
|
86
|
+
const parts = [shorten(spec.description, 220)];
|
|
87
|
+
if (spec.cliDefault !== undefined) {
|
|
88
|
+
// The CLI does not do what the contract's default does (a build is a draft): say what it does, not both.
|
|
89
|
+
parts.push(`In this CLI it is ${String(spec.cliDefault)} unless you pass --${spec.flag}.`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (spec.default !== undefined)
|
|
93
|
+
parts.push(`(default: ${String(spec.default)})`);
|
|
94
|
+
if (spec.type === 'boolean' && spec.default === true)
|
|
95
|
+
parts.push(`--no-${spec.flag} turns it off.`);
|
|
96
|
+
}
|
|
97
|
+
if (spec.type === 'pairs')
|
|
98
|
+
parts.push('Repeat it for more than one.');
|
|
99
|
+
if (spec.secret)
|
|
100
|
+
parts.push(SECRET_NOTE);
|
|
101
|
+
return parts.filter((part) => part !== '').join(' ');
|
|
102
|
+
}
|
|
103
|
+
/** The check a value of a flag has to pass: the contract's own limits, said as the flag. */
|
|
104
|
+
function checkedString(spec, text) {
|
|
105
|
+
if (spec.minLength !== undefined && text.length < spec.minLength) {
|
|
106
|
+
throw new InvalidArgumentError(`it must have at least ${spec.minLength} characters.`);
|
|
107
|
+
}
|
|
108
|
+
if (spec.maxLength !== undefined && text.length > spec.maxLength) {
|
|
109
|
+
throw new InvalidArgumentError(`it must have at most ${spec.maxLength} characters.`);
|
|
110
|
+
}
|
|
111
|
+
if (spec.pattern !== undefined && !new RegExp(spec.pattern).test(text)) {
|
|
112
|
+
throw new InvalidArgumentError(`it must ${patternRequirement(spec.pattern)}.`);
|
|
113
|
+
}
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
function checkedNumber(spec, value) {
|
|
117
|
+
if (spec.minimum !== undefined && value < spec.minimum)
|
|
118
|
+
throw new InvalidArgumentError(`it must be at least ${spec.minimum}.`);
|
|
119
|
+
if (spec.maximum !== undefined && value > spec.maximum)
|
|
120
|
+
throw new InvalidArgumentError(`it must be at most ${spec.maximum}.`);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
function parserFor(spec) {
|
|
124
|
+
// What a secret flag holds is `-` or `@path`, which the contract's limits say nothing about: the secret itself is
|
|
125
|
+
// checked with the rest of the document once it is read, and a text typed there is refused without being repeated.
|
|
126
|
+
if (spec.secret)
|
|
127
|
+
return undefined;
|
|
128
|
+
switch (spec.type) {
|
|
129
|
+
case 'integer':
|
|
130
|
+
return (text) => {
|
|
131
|
+
if (!/^-?\d+$/.test(text.trim()))
|
|
132
|
+
throw new InvalidArgumentError('it must be a whole number.');
|
|
133
|
+
return checkedNumber(spec, Number(text));
|
|
134
|
+
};
|
|
135
|
+
case 'number':
|
|
136
|
+
return (text) => {
|
|
137
|
+
const value = Number(text);
|
|
138
|
+
if (text.trim() === '' || !Number.isFinite(value))
|
|
139
|
+
throw new InvalidArgumentError('it must be a number.');
|
|
140
|
+
return checkedNumber(spec, value);
|
|
141
|
+
};
|
|
142
|
+
case 'string':
|
|
143
|
+
return spec.enum ? undefined : (text) => checkedString(spec, text);
|
|
144
|
+
case 'pairs':
|
|
145
|
+
return (text, previous) => {
|
|
146
|
+
const separator = text.indexOf('=');
|
|
147
|
+
if (separator <= 0)
|
|
148
|
+
throw new InvalidArgumentError('it must be key=value.');
|
|
149
|
+
return {
|
|
150
|
+
...previous,
|
|
151
|
+
[text.slice(0, separator)]: text.slice(separator + 1)
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
default:
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function addFlag(command, spec, options) {
|
|
159
|
+
const long = `--${spec.flag}`;
|
|
160
|
+
const label = describe(spec);
|
|
161
|
+
const text = spec.required && options.mandatory ? `${label} (required)` : label;
|
|
162
|
+
const option = spec.type === 'boolean' ? new Option(long, text) : new Option(`${long} ${placeholderOf(spec)}`, text);
|
|
163
|
+
if (spec.enum)
|
|
164
|
+
option.choices(spec.enum);
|
|
165
|
+
const parser = parserFor(spec);
|
|
166
|
+
if (parser)
|
|
167
|
+
option.argParser(parser);
|
|
168
|
+
if (options.mandatory && spec.required)
|
|
169
|
+
option.makeOptionMandatory();
|
|
170
|
+
option.helpGroup(options.group);
|
|
171
|
+
command.addOption(option);
|
|
172
|
+
if (spec.type === 'boolean') {
|
|
173
|
+
// `--no-x` is read, not listed: the description of `--x` says so when the contract's default is true.
|
|
174
|
+
command.addOption(new Option(`--no-${spec.flag}`, `The opposite of ${long}`).hideHelp());
|
|
175
|
+
}
|
|
176
|
+
return { spec, key: option.attributeName() };
|
|
177
|
+
}
|
|
178
|
+
/** The values the person typed, by flag name; a flag that was left out is not in the map. */
|
|
179
|
+
export function readFlags(command, bindings) {
|
|
180
|
+
const options = command.opts();
|
|
181
|
+
const values = new Map();
|
|
182
|
+
for (const binding of bindings) {
|
|
183
|
+
const value = options[binding.key];
|
|
184
|
+
if (value !== undefined)
|
|
185
|
+
values.set(binding.spec.flag, value);
|
|
186
|
+
}
|
|
187
|
+
return values;
|
|
188
|
+
}
|
|
189
|
+
const jsonOption = () => new Option('--json', 'Print the result as one line of JSON; an error is {"error":{…}} on stderr; never ask anything.');
|
|
190
|
+
const profileOption = () => new Option('--profile <name>', 'The saved API key to use (default: WIDGETIC_PROFILE, else "default").');
|
|
191
|
+
const timeoutOption = () => new Option('--timeout <seconds>', 'Give up on a call after this many seconds (default: 60).').argParser((text) => {
|
|
192
|
+
if (!/^\d+$/.test(text.trim()) || Number(text) < 1)
|
|
193
|
+
throw new InvalidArgumentError('it must be a whole number of seconds, at least 1.');
|
|
194
|
+
return Number(text);
|
|
195
|
+
});
|
|
196
|
+
/** The options every generated command has, and `--yes` listed only where the command asks. */
|
|
197
|
+
export function addCommonOptions(command, config) {
|
|
198
|
+
const add = (option) => {
|
|
199
|
+
command.addOption(option.helpGroup(COMMON_GROUP));
|
|
200
|
+
};
|
|
201
|
+
if (config.document) {
|
|
202
|
+
add(new Option(`--${config.document} <json|@file|->`, 'The document as JSON, @path to a file that holds it, or - for standard input. The flags above override its keys.'));
|
|
203
|
+
}
|
|
204
|
+
add(jsonOption());
|
|
205
|
+
add(new Option('--dry-run', 'Print what would be sent (the API key is left out) and send nothing.'));
|
|
206
|
+
const yes = new Option('--yes', 'Do not ask first: this command changes or removes something.');
|
|
207
|
+
add(config.asks ? yes : yes.hideHelp());
|
|
208
|
+
add(profileOption());
|
|
209
|
+
add(timeoutOption());
|
|
210
|
+
}
|
|
211
|
+
/** The options of the commands that are not generated (`auth`, `doctor`, `schema`): `--json`, and the two that go with a server. */
|
|
212
|
+
export function addBasicOptions(command, config) {
|
|
213
|
+
command.addOption(jsonOption().helpGroup(COMMON_GROUP));
|
|
214
|
+
if (config.server) {
|
|
215
|
+
command.addOption(profileOption().helpGroup(COMMON_GROUP));
|
|
216
|
+
command.addOption(timeoutOption().helpGroup(COMMON_GROUP));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function readCommon(command) {
|
|
220
|
+
const options = command.opts();
|
|
221
|
+
return {
|
|
222
|
+
json: options.json === true,
|
|
223
|
+
dryRun: options.dryRun === true,
|
|
224
|
+
yes: options.yes === true,
|
|
225
|
+
profile: typeof options.profile === 'string' ? options.profile : undefined,
|
|
226
|
+
timeout: typeof options.timeout === 'number' ? options.timeout : undefined
|
|
227
|
+
};
|
|
228
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { CliError, EXIT_CODES } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a command prints, and where.
|
|
4
|
+
*
|
|
5
|
+
* stdout the result and nothing else (so `widgetic ... --json | jq` is always valid JSON)
|
|
6
|
+
* stderr everything about the run: errors, progress, notes, warnings
|
|
7
|
+
*
|
|
8
|
+
* With `--json` the result is one line of JSON and an error is one line of `{"error":{code,message,status,requestId}}`;
|
|
9
|
+
* no prompt is ever shown. Without it the result is printed for a person and an error is two or three lines.
|
|
10
|
+
*/
|
|
11
|
+
function withNewline(text) {
|
|
12
|
+
return text.endsWith('\n') ? text : `${text}\n`;
|
|
13
|
+
}
|
|
14
|
+
export function writeStdout(environment, text) {
|
|
15
|
+
environment.stdout.write(withNewline(text));
|
|
16
|
+
}
|
|
17
|
+
export function writeStderr(environment, text) {
|
|
18
|
+
environment.stderr.write(withNewline(text));
|
|
19
|
+
}
|
|
20
|
+
/** A line about the run (progress, a note) that is never part of the result. */
|
|
21
|
+
export function note(environment, text) {
|
|
22
|
+
writeStderr(environment, text);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A line about what the CLI decided and why, for the person who asks (`WIDGETIC_DEBUG=1`); silent otherwise, so a
|
|
26
|
+
* decision that is made without a word (no look for an update on a pipe) can still be explained.
|
|
27
|
+
*/
|
|
28
|
+
export function debug(environment, text) {
|
|
29
|
+
if (environment.env.WIDGETIC_DEBUG === '1')
|
|
30
|
+
writeStderr(environment, `[widgetic] ${text}`);
|
|
31
|
+
}
|
|
32
|
+
/** Prints a result: one compact line with `--json`, readable text without. */
|
|
33
|
+
export function printResult(environment, value, json) {
|
|
34
|
+
if (typeof value === 'string') {
|
|
35
|
+
writeStdout(environment, value);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
writeStdout(environment, json ? JSON.stringify(value) : JSON.stringify(value, null, 2));
|
|
39
|
+
}
|
|
40
|
+
/** What `{"error":{...}}` carries; keys without a value are left out. */
|
|
41
|
+
export function errorPayload(error) {
|
|
42
|
+
const payload = { code: error.code, message: error.message };
|
|
43
|
+
if (error.status !== undefined)
|
|
44
|
+
payload.status = error.status;
|
|
45
|
+
if (error.requestId !== undefined)
|
|
46
|
+
payload.requestId = error.requestId;
|
|
47
|
+
if (error.retryAfter !== undefined)
|
|
48
|
+
payload.retryAfter = error.retryAfter;
|
|
49
|
+
if (error.details !== undefined)
|
|
50
|
+
payload.details = error.details;
|
|
51
|
+
if (error.hint !== undefined)
|
|
52
|
+
payload.hint = error.hint;
|
|
53
|
+
return { error: payload };
|
|
54
|
+
}
|
|
55
|
+
export function printError(environment, error, json) {
|
|
56
|
+
if (json) {
|
|
57
|
+
writeStderr(environment, JSON.stringify(errorPayload(error)));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// A line of facts is for an answer from the server (its code, the status, the id to quote to support); a mistake
|
|
61
|
+
// on the command line is explained by its message alone.
|
|
62
|
+
const facts = error.status === undefined
|
|
63
|
+
? []
|
|
64
|
+
: [
|
|
65
|
+
error.code,
|
|
66
|
+
`HTTP ${error.status}`,
|
|
67
|
+
error.requestId === undefined ? undefined : `request ${error.requestId}`,
|
|
68
|
+
error.retryAfter === undefined ? undefined : `retry after ${error.retryAfter}s`
|
|
69
|
+
].filter((fact) => fact !== undefined);
|
|
70
|
+
const lines = [`error: ${error.message}`];
|
|
71
|
+
if (facts.length > 0)
|
|
72
|
+
lines.push(` (${facts.join(', ')})`);
|
|
73
|
+
if (error.hint)
|
|
74
|
+
lines.push(` ${error.hint}`);
|
|
75
|
+
writeStderr(environment, lines.join('\n'));
|
|
76
|
+
}
|
|
77
|
+
/** Anything thrown becomes a CliError: the ones we raise keep their exit code, the rest are exit 1. */
|
|
78
|
+
export function asCliError(error, environment) {
|
|
79
|
+
if (error instanceof CliError)
|
|
80
|
+
return error;
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
const debug = environment.env.WIDGETIC_DEBUG === '1' && error instanceof Error ? `\n${error.stack ?? ''}` : '';
|
|
83
|
+
return new CliError({
|
|
84
|
+
code: 'INTERNAL_ERROR',
|
|
85
|
+
message: `${message}${debug}`,
|
|
86
|
+
exitCode: EXIT_CODES.failure,
|
|
87
|
+
hint: 'This is a bug in the CLI. Run again with WIDGETIC_DEBUG=1 and send the output to support.',
|
|
88
|
+
cause: error
|
|
89
|
+
});
|
|
90
|
+
}
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few things about the commands that are ours and that no contract says. Everything else about a command comes
|
|
3
|
+
* from the manifests; this is the whole list, and `test/overlay.test.ts` checks every name in it against the manifests
|
|
4
|
+
* of the installed contracts, so a tool that is renamed or loses an argument fails the build instead of the command.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* `--wait` on the tools that start a build and answer at once: the command keeps asking the poll tool for the widget
|
|
8
|
+
* until its phase is one of the finished ones, and ends with the finished widget as its result. The contract's own
|
|
9
|
+
* words (the description of `get_widget`) are "call it every 5 to 10 seconds until phase is ready, failed or cancelled".
|
|
10
|
+
*/
|
|
11
|
+
export const WAIT_OVERLAY = {
|
|
12
|
+
/** The tools that get `--wait`. Each answers with the widget's id and phase. */
|
|
13
|
+
tools: ['create_widget', 'replicate_widget', 'revise_widget'],
|
|
14
|
+
/** The tool that is asked again and again. */
|
|
15
|
+
pollTool: 'get_widget',
|
|
16
|
+
/** The key of a start tool's answer that names the widget, and the argument of the poll tool that takes it. */
|
|
17
|
+
idProperty: 'widget_id',
|
|
18
|
+
/** The key of both tools' answers that says how far the build is. */
|
|
19
|
+
phaseProperty: 'phase',
|
|
20
|
+
/** The phase the build ends well in: the command exits 0 with the widget. */
|
|
21
|
+
readyPhases: ['ready'],
|
|
22
|
+
/** The phases it ends badly in: the command exits 5 with the widget as the error's details. */
|
|
23
|
+
failedPhases: ['failed', 'cancelled'],
|
|
24
|
+
/** Seconds between two asks. */
|
|
25
|
+
pollIntervalSeconds: 7,
|
|
26
|
+
/** Seconds `--wait` waits when it is given no number. */
|
|
27
|
+
defaultMaxSeconds: 900
|
|
28
|
+
};
|
|
29
|
+
export function isWaitTool(tool) {
|
|
30
|
+
return WAIT_OVERLAY.tools.includes(tool);
|
|
31
|
+
}
|
package/dist/patterns.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saying what a `pattern` of a contract asks for. Nearly every pattern in the contracts is the one for an id (a UUID),
|
|
3
|
+
* and a person told to "match ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8]…" learns nothing from it.
|
|
4
|
+
*/
|
|
5
|
+
const UUID_PATTERN = /\[0-9a-fA-F\]\{8\}-\[0-9a-fA-F\]\{4\}-/;
|
|
6
|
+
/** What the value has to be, to finish "it must …": `be a UUID`, `match the pattern ^[a-z]+$`. */
|
|
7
|
+
export function patternRequirement(pattern) {
|
|
8
|
+
if (UUID_PATTERN.test(pattern))
|
|
9
|
+
return 'be a UUID (like 3f2b8c1e-5d4a-4b7e-9c1d-2a6f8e0b4c3d)';
|
|
10
|
+
return `match the pattern ${pattern}`;
|
|
11
|
+
}
|
|
@@ -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
|
+
}
|