@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,186 @@
|
|
|
1
|
+
import { KEY_REFUSED_HINT } from "../constants.js";
|
|
2
|
+
import { CliError, EXIT_CODES, exitCodeForStatus } from "../errors.js";
|
|
3
|
+
import { errorFromAnswer, isRecord, networkFailure, readAnswer } from "../http.js";
|
|
4
|
+
import { cliVersion, identityHeaders } from "../version.js";
|
|
5
|
+
/** Reads a `tools/call` result: the data, the text, whether it is a refusal. */
|
|
6
|
+
export function outcomeOf(result) {
|
|
7
|
+
const record = isRecord(result) ? result : {};
|
|
8
|
+
const content = Array.isArray(record.content) ? record.content : [];
|
|
9
|
+
const text = content
|
|
10
|
+
.filter((block) => isRecord(block) && block.type === 'text' && typeof block.text === 'string')
|
|
11
|
+
.map((block) => block.text)
|
|
12
|
+
.join('\n\n');
|
|
13
|
+
const meta = isRecord(record._meta) ? record._meta : {};
|
|
14
|
+
return {
|
|
15
|
+
isError: record.isError === true,
|
|
16
|
+
structured: isRecord(record.structuredContent) ? record.structuredContent : undefined,
|
|
17
|
+
text,
|
|
18
|
+
authChallenge: 'mcp/www_authenticate' in meta
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** The error a refusal of a tool is: exit 3 when the key is not accepted, else exit 5 with the tool's own words. */
|
|
22
|
+
export function refusalOf(outcome) {
|
|
23
|
+
if (outcome.authChallenge) {
|
|
24
|
+
return new CliError({
|
|
25
|
+
code: 'AUTH_REQUIRED',
|
|
26
|
+
message: outcome.text || 'The API key was not accepted.',
|
|
27
|
+
exitCode: EXIT_CODES.auth,
|
|
28
|
+
hint: KEY_REFUSED_HINT
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return new CliError({
|
|
32
|
+
code: 'TOOL_ERROR',
|
|
33
|
+
message: outcome.text || 'The tool refused the call.',
|
|
34
|
+
exitCode: EXIT_CODES.rejected
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const JSON_RPC = {
|
|
38
|
+
invalidRequest: -32600,
|
|
39
|
+
methodNotFound: -32601,
|
|
40
|
+
invalidParams: -32602,
|
|
41
|
+
connectionClosed: -32000,
|
|
42
|
+
requestTimeout: -32001
|
|
43
|
+
};
|
|
44
|
+
function isMcpError(error) {
|
|
45
|
+
return (error instanceof Error &&
|
|
46
|
+
error.name === 'McpError' &&
|
|
47
|
+
typeof error.code === 'number');
|
|
48
|
+
}
|
|
49
|
+
function isHttpError(error) {
|
|
50
|
+
return error instanceof Error && error.name === 'StreamableHTTPError';
|
|
51
|
+
}
|
|
52
|
+
export class McpConnection {
|
|
53
|
+
/** The version the server reported when it was opened: the version of its tool contract. */
|
|
54
|
+
serverVersion;
|
|
55
|
+
client;
|
|
56
|
+
lastRefusal;
|
|
57
|
+
environment;
|
|
58
|
+
settings;
|
|
59
|
+
auth;
|
|
60
|
+
timeoutMilliseconds;
|
|
61
|
+
constructor(environment, settings, auth, timeoutMilliseconds) {
|
|
62
|
+
this.environment = environment;
|
|
63
|
+
this.settings = settings;
|
|
64
|
+
this.auth = auth;
|
|
65
|
+
this.timeoutMilliseconds = timeoutMilliseconds;
|
|
66
|
+
}
|
|
67
|
+
/** Calls a tool; throws a `CliError` for everything that is not an answer (a refusal of the tool is an answer: see `refusalOf`). */
|
|
68
|
+
async call(tool, args) {
|
|
69
|
+
this.lastRefusal = undefined;
|
|
70
|
+
try {
|
|
71
|
+
const client = await this.connected();
|
|
72
|
+
const result = await client.callTool({ name: tool, arguments: args }, undefined, {
|
|
73
|
+
timeout: this.timeoutMilliseconds
|
|
74
|
+
});
|
|
75
|
+
return outcomeOf(result);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const failure = this.explain(error);
|
|
79
|
+
// A connection that failed is not trusted again: the next call opens its own.
|
|
80
|
+
await this.close();
|
|
81
|
+
throw failure;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The names of the tools the server offers (`tools/list`), to compare with the ones this CLI was built for. */
|
|
85
|
+
async listTools() {
|
|
86
|
+
this.lastRefusal = undefined;
|
|
87
|
+
try {
|
|
88
|
+
const client = await this.connected();
|
|
89
|
+
const names = [];
|
|
90
|
+
let cursor;
|
|
91
|
+
do {
|
|
92
|
+
const page = await client.listTools(cursor === undefined ? undefined : { cursor }, {
|
|
93
|
+
timeout: this.timeoutMilliseconds
|
|
94
|
+
});
|
|
95
|
+
for (const tool of page.tools)
|
|
96
|
+
names.push(tool.name);
|
|
97
|
+
cursor = page.nextCursor;
|
|
98
|
+
} while (cursor !== undefined);
|
|
99
|
+
return names;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const failure = this.explain(error);
|
|
103
|
+
await this.close();
|
|
104
|
+
throw failure;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async close() {
|
|
108
|
+
const client = this.client;
|
|
109
|
+
this.client = undefined;
|
|
110
|
+
try {
|
|
111
|
+
await client?.close();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Closing is a courtesy; the command's result does not depend on it.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async connected() {
|
|
118
|
+
if (this.client)
|
|
119
|
+
return this.client;
|
|
120
|
+
const [{ Client: SdkClient }, { StreamableHTTPClientTransport }] = await Promise.all([
|
|
121
|
+
import('@modelcontextprotocol/sdk/client/index.js'),
|
|
122
|
+
import('@modelcontextprotocol/sdk/client/streamableHttp.js')
|
|
123
|
+
]);
|
|
124
|
+
const transport = new StreamableHTTPClientTransport(new URL(this.settings.mcpUrl), {
|
|
125
|
+
fetch: async (url, init) => {
|
|
126
|
+
const response = await this.environment.fetch(url, init);
|
|
127
|
+
// Only the answer to a message says why a call failed. The stream a client may open with GET is refused with a 405 by a server that keeps none, and that is not a failure.
|
|
128
|
+
if (!response.ok && init?.method === 'POST')
|
|
129
|
+
this.lastRefusal = await readAnswer(response.clone());
|
|
130
|
+
return response;
|
|
131
|
+
},
|
|
132
|
+
requestInit: {
|
|
133
|
+
headers: { authorization: `Bearer ${this.auth.apiKey}`, ...identityHeaders(this.environment) }
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
const client = new SdkClient({ name: '@widgetic/cli', version: cliVersion() }, { capabilities: {} });
|
|
137
|
+
await client.connect(transport, { timeout: this.timeoutMilliseconds });
|
|
138
|
+
this.client = client;
|
|
139
|
+
this.serverVersion = client.getServerVersion()?.version;
|
|
140
|
+
return client;
|
|
141
|
+
}
|
|
142
|
+
/** What a failure of the SDK stands for, in the CLI's terms. */
|
|
143
|
+
explain(error) {
|
|
144
|
+
if (error instanceof CliError)
|
|
145
|
+
return error;
|
|
146
|
+
// The server refused at the HTTP layer (401, 403, 426, 429 …): its own envelope says what and why.
|
|
147
|
+
if (this.lastRefusal)
|
|
148
|
+
return errorFromAnswer(this.lastRefusal, this.environment.now(), this.auth.source);
|
|
149
|
+
if (isMcpError(error)) {
|
|
150
|
+
const message = error.message.replace(/^MCP error -?\d+:\s*/, '');
|
|
151
|
+
switch (error.code) {
|
|
152
|
+
case JSON_RPC.requestTimeout:
|
|
153
|
+
return networkFailure(Object.assign(new Error(message), { name: 'TimeoutError' }), this.settings.mcpUrl, this.timeoutMilliseconds);
|
|
154
|
+
case JSON_RPC.connectionClosed:
|
|
155
|
+
return networkFailure(error, this.settings.mcpUrl, this.timeoutMilliseconds);
|
|
156
|
+
case JSON_RPC.invalidParams:
|
|
157
|
+
case JSON_RPC.invalidRequest:
|
|
158
|
+
case JSON_RPC.methodNotFound:
|
|
159
|
+
return new CliError({
|
|
160
|
+
code: 'MCP_REJECTED',
|
|
161
|
+
message,
|
|
162
|
+
exitCode: EXIT_CODES.rejected,
|
|
163
|
+
hint: 'The server did not accept the call. This CLI may be older than the server: `widgetic doctor` compares them.',
|
|
164
|
+
cause: error
|
|
165
|
+
});
|
|
166
|
+
default:
|
|
167
|
+
return new CliError({
|
|
168
|
+
code: `MCP_ERROR_${Math.abs(error.code)}`,
|
|
169
|
+
message,
|
|
170
|
+
exitCode: EXIT_CODES.failure,
|
|
171
|
+
cause: error
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (isHttpError(error) && typeof error.code === 'number' && error.code > 0) {
|
|
176
|
+
return new CliError({
|
|
177
|
+
code: `HTTP_${error.code}`,
|
|
178
|
+
message: error.message,
|
|
179
|
+
exitCode: exitCodeForStatus(error.code),
|
|
180
|
+
status: error.code,
|
|
181
|
+
cause: error
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return networkFailure(error, this.settings.mcpUrl, this.timeoutMilliseconds);
|
|
185
|
+
}
|
|
186
|
+
}
|
package/dist/mcp/wait.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { CliError, EXIT_CODES } from "../errors.js";
|
|
2
|
+
import { note } from "../output.js";
|
|
3
|
+
import { WAIT_OVERLAY } from "../overlay.js";
|
|
4
|
+
import { retryNotifier, withRetries } from "../retry.js";
|
|
5
|
+
import { refusalOf } from "./session.js";
|
|
6
|
+
/**
|
|
7
|
+
* `--wait`: a tool that starts a build answers at once with the widget's id and the phase it is in; the command then
|
|
8
|
+
* asks the poll tool again and again until the phase is one of the finished ones, and its result is the finished
|
|
9
|
+
* widget. What is waited for, and how often it is asked, is the overlay (`overlay.ts`), checked against the manifests.
|
|
10
|
+
*/
|
|
11
|
+
function phaseOf(outcome) {
|
|
12
|
+
const phase = outcome.structured?.[WAIT_OVERLAY.phaseProperty];
|
|
13
|
+
return typeof phase === 'string' ? phase : undefined;
|
|
14
|
+
}
|
|
15
|
+
export async function waitForBuild(environment, connection, started, json) {
|
|
16
|
+
const widgetId = started.structured?.[WAIT_OVERLAY.idProperty];
|
|
17
|
+
if (typeof widgetId !== 'string') {
|
|
18
|
+
throw new CliError({
|
|
19
|
+
code: 'WAIT_UNAVAILABLE',
|
|
20
|
+
message: `--wait: the answer names no widget (${WAIT_OVERLAY.idProperty}), so there is nothing to wait for.`,
|
|
21
|
+
exitCode: EXIT_CODES.failure,
|
|
22
|
+
details: started.structured
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const readyPhases = WAIT_OVERLAY.readyPhases;
|
|
26
|
+
const failedPhases = WAIT_OVERLAY.failedPhases;
|
|
27
|
+
const clockStart = environment.now().getTime();
|
|
28
|
+
let waitedSeconds = 0;
|
|
29
|
+
let reported;
|
|
30
|
+
let outcome = started;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const phase = phaseOf(outcome);
|
|
33
|
+
if (phase !== undefined && phase !== reported) {
|
|
34
|
+
reported = phase;
|
|
35
|
+
if (!json)
|
|
36
|
+
note(environment, `Widget ${widgetId}: ${phase}`);
|
|
37
|
+
}
|
|
38
|
+
if (phase !== undefined && readyPhases.includes(phase))
|
|
39
|
+
return outcome;
|
|
40
|
+
if (phase !== undefined && failedPhases.includes(phase)) {
|
|
41
|
+
throw new CliError({
|
|
42
|
+
code: 'BUILD_FAILED',
|
|
43
|
+
message: `The build of widget ${widgetId} ended in phase "${phase}".`,
|
|
44
|
+
exitCode: EXIT_CODES.rejected,
|
|
45
|
+
details: outcome.structured,
|
|
46
|
+
hint: `See the widget: widgetic mcp get-widget --${WAIT_OVERLAY.idProperty.replace(/_/g, '-')} ${widgetId}`
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const elapsed = Math.max(waitedSeconds, (environment.now().getTime() - clockStart) / 1000);
|
|
50
|
+
if (elapsed >= WAIT_OVERLAY.defaultMaxSeconds) {
|
|
51
|
+
throw new CliError({
|
|
52
|
+
code: 'WAIT_TIMED_OUT',
|
|
53
|
+
message: `Widget ${widgetId} is still building (phase: ${phase ?? 'unknown'}) after ${Math.round(WAIT_OVERLAY.defaultMaxSeconds / 60)} minutes. It keeps going on the server.`,
|
|
54
|
+
exitCode: EXIT_CODES.failure,
|
|
55
|
+
details: outcome.structured,
|
|
56
|
+
hint: `Look again later: widgetic mcp get-widget --${WAIT_OVERLAY.idProperty.replace(/_/g, '-')} ${widgetId}`
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
await environment.sleep(WAIT_OVERLAY.pollIntervalSeconds * 1000);
|
|
60
|
+
waitedSeconds += WAIT_OVERLAY.pollIntervalSeconds;
|
|
61
|
+
outcome = await withRetries(environment, { safeToRepeat: true }, async () => {
|
|
62
|
+
const polled = await connection.call(WAIT_OVERLAY.pollTool, { [WAIT_OVERLAY.idProperty]: widgetId });
|
|
63
|
+
if (polled.isError)
|
|
64
|
+
throw refusalOf(polled);
|
|
65
|
+
return polled;
|
|
66
|
+
}, retryNotifier(environment, json));
|
|
67
|
+
}
|
|
68
|
+
}
|
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,54 @@
|
|
|
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
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The line `widgetic api --help` shows beside each group of the REST API. The contract names the groups (they are the
|
|
34
|
+
* tags of its operations) and says nothing about them, so without these lines the list would be thirteen bare nouns and
|
|
35
|
+
* a person could not tell `compositions` from `canvases`. Each line is written from what the operations of the group
|
|
36
|
+
* say about themselves; a group that has none yet (a later contract adds one) is listed with the number of its commands
|
|
37
|
+
* and `test/overlay.test.ts` fails until somebody writes its line.
|
|
38
|
+
*/
|
|
39
|
+
export const API_GROUP_SUMMARIES = {
|
|
40
|
+
canvases: 'The canvases that hold your widgets: create, list, read, update and delete them.',
|
|
41
|
+
compositions: 'What is made from a widget: its content, design, preview, versions, data and embed settings.',
|
|
42
|
+
credits: "An account's credit balance and credit ledger.",
|
|
43
|
+
entitlements: 'The entitlements of a partner and of its managed users.',
|
|
44
|
+
invoices: "A partner's invoices.",
|
|
45
|
+
'managed-users': "A partner's managed users: their limits, design defaults and editor sessions.",
|
|
46
|
+
operations: 'The status of work that goes on after the call that started it has answered.',
|
|
47
|
+
partners: 'A partner account: analytics, billing, presets, notifications, storage and data.',
|
|
48
|
+
'public-embed': 'What the browser of a visitor calls to show and run a published widget; it needs no API key. Left out of the list.',
|
|
49
|
+
tokens: 'Check that the API key in use is valid.',
|
|
50
|
+
uploads: 'Upload files, list and delete them, and see the upload limits.',
|
|
51
|
+
users: 'Your account: API keys, the websites that may show your widgets, and adapter connections.',
|
|
52
|
+
widgets: 'Your library of widgets: list and create them, and start a composition from one.',
|
|
53
|
+
workflows: 'Workflows: create, edit, publish, pause and archive them, and restore their versions.'
|
|
54
|
+
};
|
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
|
+
}
|