@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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { resolveSettings, timeoutMilliseconds } from "../config.js";
|
|
2
|
+
import { API_KEY_SHAPE } from "../constants.js";
|
|
3
|
+
import { CredentialStore, maskKey, resolveApiKey } from "../credentials.js";
|
|
4
|
+
import { CliError, EXIT_CODES } from "../errors.js";
|
|
5
|
+
import { loadContractsInfo, loadMcpManifest } from "../manifest/load.js";
|
|
6
|
+
import { McpConnection } from "../mcp/session.js";
|
|
7
|
+
import { addBasicOptions, readCommon } from "../options.js";
|
|
8
|
+
import { printResult, writeStdout } from "../output.js";
|
|
9
|
+
import { cliIdentity, isAtLeast, minimumNodeVersion } from "../version.js";
|
|
10
|
+
import { probe, validateUrl, verifyApiKey } from "./verify.js";
|
|
11
|
+
const UPDATE_HINT = 'Update the CLI: npm install -g @widgetic/cli@latest';
|
|
12
|
+
function failure(id, title, error) {
|
|
13
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
title,
|
|
17
|
+
status: 'fail',
|
|
18
|
+
message,
|
|
19
|
+
...(error instanceof CliError && error.hint ? { hint: error.hint } : {})
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function checkCli(context) {
|
|
23
|
+
const { environment } = context;
|
|
24
|
+
return {
|
|
25
|
+
id: 'cli',
|
|
26
|
+
title: 'CLI',
|
|
27
|
+
status: 'ok',
|
|
28
|
+
message: `${cliIdentity()} on Node ${environment.nodeVersion} (${environment.platform})`
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function checkNode(context) {
|
|
32
|
+
const minimum = minimumNodeVersion();
|
|
33
|
+
const have = context.environment.nodeVersion;
|
|
34
|
+
if (minimum === undefined) {
|
|
35
|
+
return { id: 'node', title: 'Node', status: 'skip', message: 'The package names no minimum version.' };
|
|
36
|
+
}
|
|
37
|
+
if (isAtLeast(have, minimum)) {
|
|
38
|
+
return { id: 'node', title: 'Node', status: 'ok', message: `${have} (at least ${minimum} is needed)` };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
id: 'node',
|
|
42
|
+
title: 'Node',
|
|
43
|
+
status: 'fail',
|
|
44
|
+
message: `${have} is older than the ${minimum} this CLI needs.`,
|
|
45
|
+
hint: 'Install a current Node (https://nodejs.org) and run the CLI with it.'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function checkContracts() {
|
|
49
|
+
const info = loadContractsInfo();
|
|
50
|
+
const mcp = loadMcpManifest();
|
|
51
|
+
const details = {
|
|
52
|
+
package: info.package,
|
|
53
|
+
version: info.version,
|
|
54
|
+
commit: info.source.commit,
|
|
55
|
+
publishTag: info.publishTag,
|
|
56
|
+
operations: info.openApi.operations,
|
|
57
|
+
tools: info.mcp.tools,
|
|
58
|
+
toolContract: mcp.contractVersion
|
|
59
|
+
};
|
|
60
|
+
const message = `${info.package} ${info.version} (commit ${info.source.commit.slice(0, 8)}): ${info.openApi.operations} REST operations, ${info.mcp.tools} MCP tools, tool contract ${mcp.contractVersion}`;
|
|
61
|
+
if (info.publishTag !== 'latest' || info.source.dirty) {
|
|
62
|
+
return {
|
|
63
|
+
id: 'contracts',
|
|
64
|
+
title: 'Contracts',
|
|
65
|
+
status: 'warn',
|
|
66
|
+
message: `${message}. They are a pre-release, or were built from uncommitted code.`,
|
|
67
|
+
details,
|
|
68
|
+
hint: 'A released CLI is built from the contracts tagged latest.'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { id: 'contracts', title: 'Contracts', status: 'ok', message, details };
|
|
72
|
+
}
|
|
73
|
+
async function checkCredentials(context) {
|
|
74
|
+
const { settings, store, key } = context;
|
|
75
|
+
const checks = [];
|
|
76
|
+
if (key === undefined) {
|
|
77
|
+
checks.push({
|
|
78
|
+
id: 'credentials',
|
|
79
|
+
title: 'API key',
|
|
80
|
+
status: 'warn',
|
|
81
|
+
message: `No API key for the profile "${settings.profile}", so the servers can only be asked whether they answer.`,
|
|
82
|
+
hint: 'Run `widgetic auth login`, or set WIDGETIC_API_KEY.'
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const where = key.source === 'environment' ? 'WIDGETIC_API_KEY' : store.path;
|
|
87
|
+
const unusual = !API_KEY_SHAPE.test(key.apiKey);
|
|
88
|
+
checks.push({
|
|
89
|
+
id: 'credentials',
|
|
90
|
+
title: 'API key',
|
|
91
|
+
status: unusual ? 'warn' : 'ok',
|
|
92
|
+
message: `${maskKey(key.apiKey)} from ${where} (profile "${settings.profile}")${unusual ? ', which does not look like a Widgetic key' : ''}`,
|
|
93
|
+
details: { profile: settings.profile, source: key.source, key: maskKey(key.apiKey) },
|
|
94
|
+
...(unusual ? { hint: 'Keys start with wgt_usr_, wgt_prt_ or wdg_org_.' } : {})
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (await store.isReadableByOthers()) {
|
|
98
|
+
checks.push({
|
|
99
|
+
id: 'credentials-file',
|
|
100
|
+
title: 'Key file',
|
|
101
|
+
status: 'warn',
|
|
102
|
+
message: `${store.path} can be read by other users of this machine.`,
|
|
103
|
+
hint: `chmod 600 ${store.path}`
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return checks;
|
|
107
|
+
}
|
|
108
|
+
async function checkRest(context) {
|
|
109
|
+
const { environment, settings, key } = context;
|
|
110
|
+
if (key !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
const check = await verifyApiKey(environment, settings, key.apiKey, {
|
|
113
|
+
timeoutMilliseconds: context.timeoutMilliseconds,
|
|
114
|
+
keySource: key.source
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
id: 'rest',
|
|
118
|
+
title: 'REST API',
|
|
119
|
+
status: 'ok',
|
|
120
|
+
message: `${settings.apiUrl} accepted the key (${check.tokenType ?? 'key'}, ${check.scopes.length} scopes)`,
|
|
121
|
+
details: { url: settings.apiUrl, tokenType: check.tokenType, scopes: check.scopes }
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return {
|
|
126
|
+
...failure('rest', 'REST API', error),
|
|
127
|
+
details: {
|
|
128
|
+
url: settings.apiUrl,
|
|
129
|
+
...(error instanceof CliError ? { code: error.code, status: error.status } : {})
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const answer = await probe(environment, validateUrl(settings), { method: 'GET', headers: { accept: 'application/json' } }, context.timeoutMilliseconds);
|
|
136
|
+
return {
|
|
137
|
+
id: 'rest',
|
|
138
|
+
title: 'REST API',
|
|
139
|
+
status: 'ok',
|
|
140
|
+
message: `${settings.apiUrl} answers (HTTP ${answer.status} without a key, ${answer.milliseconds} ms)`,
|
|
141
|
+
details: { url: settings.apiUrl, status: answer.status }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return failure('rest', 'REST API', error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function checkMcp(context) {
|
|
149
|
+
const { environment, settings, key } = context;
|
|
150
|
+
const manifest = loadMcpManifest();
|
|
151
|
+
if (key === undefined) {
|
|
152
|
+
try {
|
|
153
|
+
const answer = await probe(environment, settings.mcpUrl, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
|
156
|
+
body: '{}'
|
|
157
|
+
}, context.timeoutMilliseconds);
|
|
158
|
+
return {
|
|
159
|
+
id: 'mcp',
|
|
160
|
+
title: 'MCP server',
|
|
161
|
+
status: 'ok',
|
|
162
|
+
message: `${settings.mcpUrl} answers (HTTP ${answer.status} without a key, ${answer.milliseconds} ms)`,
|
|
163
|
+
details: { url: settings.mcpUrl, status: answer.status }
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
return failure('mcp', 'MCP server', error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const connection = new McpConnection(environment, settings, { apiKey: key.apiKey, source: key.source }, context.timeoutMilliseconds);
|
|
171
|
+
try {
|
|
172
|
+
const offered = await connection.listTools();
|
|
173
|
+
const serverVersion = connection.serverVersion;
|
|
174
|
+
const known = new Set(manifest.commands.map((command) => command.tool));
|
|
175
|
+
const missingInCli = offered.filter((name) => !known.has(name)).sort();
|
|
176
|
+
const missingOnServer = [...known].filter((name) => !offered.includes(name)).sort();
|
|
177
|
+
const details = {
|
|
178
|
+
url: settings.mcpUrl,
|
|
179
|
+
serverVersion,
|
|
180
|
+
expectedVersion: manifest.contractVersion,
|
|
181
|
+
tools: offered.length,
|
|
182
|
+
missingInCli,
|
|
183
|
+
missingOnServer
|
|
184
|
+
};
|
|
185
|
+
const problems = [];
|
|
186
|
+
if (serverVersion !== manifest.contractVersion) {
|
|
187
|
+
problems.push(`It speaks tool contract ${serverVersion ?? 'unknown'}; this CLI was built for ${manifest.contractVersion}.`);
|
|
188
|
+
}
|
|
189
|
+
if (missingInCli.length > 0) {
|
|
190
|
+
problems.push(`Tools this CLI has no command for: ${missingInCli.join(', ')} (call one with \`widgetic mcp call <tool> --input '{…}'\`).`);
|
|
191
|
+
}
|
|
192
|
+
if (missingOnServer.length > 0) {
|
|
193
|
+
problems.push(`Tools of this CLI that the server does not offer: ${missingOnServer.join(', ')}.`);
|
|
194
|
+
}
|
|
195
|
+
if (problems.length > 0) {
|
|
196
|
+
return {
|
|
197
|
+
id: 'mcp',
|
|
198
|
+
title: 'MCP server',
|
|
199
|
+
status: 'warn',
|
|
200
|
+
message: `${settings.mcpUrl} answers. ${problems.join(' ')}`,
|
|
201
|
+
details,
|
|
202
|
+
hint: UPDATE_HINT
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
id: 'mcp',
|
|
207
|
+
title: 'MCP server',
|
|
208
|
+
status: 'ok',
|
|
209
|
+
message: `${settings.mcpUrl} accepted the key; tool contract ${serverVersion}, the same as this CLI's, ${offered.length} tools`,
|
|
210
|
+
details
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
return { ...failure('mcp', 'MCP server', error), details: { url: settings.mcpUrl } };
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await connection.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const MARKS = { ok: '[ok]', warn: '[warn]', fail: '[fail]', skip: '[skip]' };
|
|
221
|
+
function printReport(environment, checks, summary) {
|
|
222
|
+
const lines = [];
|
|
223
|
+
const width = Math.max(...checks.map((check) => check.title.length));
|
|
224
|
+
for (const check of checks) {
|
|
225
|
+
lines.push(`${MARKS[check.status].padEnd(7)}${check.title.padEnd(width + 2)}${check.message}`);
|
|
226
|
+
if (check.hint)
|
|
227
|
+
lines.push(`${' '.repeat(7 + width + 2)}${check.hint}`);
|
|
228
|
+
}
|
|
229
|
+
lines.push('', summary);
|
|
230
|
+
writeStdout(environment, lines.join('\n'));
|
|
231
|
+
}
|
|
232
|
+
async function runDoctor(environment, node) {
|
|
233
|
+
const common = readCommon(node);
|
|
234
|
+
const settings = resolveSettings(environment, { profile: common.profile });
|
|
235
|
+
const store = new CredentialStore(environment, settings.configDir);
|
|
236
|
+
const checks = [];
|
|
237
|
+
let key;
|
|
238
|
+
try {
|
|
239
|
+
key = await resolveApiKey(environment, store, settings.profile);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
checks.push(failure('credentials', 'API key', error));
|
|
243
|
+
}
|
|
244
|
+
const context = {
|
|
245
|
+
environment,
|
|
246
|
+
settings,
|
|
247
|
+
store,
|
|
248
|
+
key,
|
|
249
|
+
timeoutMilliseconds: timeoutMilliseconds(common.timeout)
|
|
250
|
+
};
|
|
251
|
+
checks.unshift(checkCli(context), checkNode(context));
|
|
252
|
+
try {
|
|
253
|
+
checks.splice(2, 0, checkContracts());
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
checks.splice(2, 0, failure('contracts', 'Contracts', error));
|
|
257
|
+
}
|
|
258
|
+
if (!checks.some((check) => check.id === 'credentials'))
|
|
259
|
+
checks.push(...(await checkCredentials(context)));
|
|
260
|
+
checks.push(await checkRest(context), await checkMcp(context));
|
|
261
|
+
const failed = checks.filter((check) => check.status === 'fail').length;
|
|
262
|
+
const warned = checks.filter((check) => check.status === 'warn').length;
|
|
263
|
+
if (common.json) {
|
|
264
|
+
printResult(environment, { ok: failed === 0, warnings: warned, failures: failed, checks }, true);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const summary = failed > 0
|
|
268
|
+
? `${failed} of ${checks.length} checks failed.`
|
|
269
|
+
: warned > 0
|
|
270
|
+
? `Nothing is broken; ${warned} ${warned === 1 ? 'thing is' : 'things are'} worth a look.`
|
|
271
|
+
: 'All checks passed.';
|
|
272
|
+
printReport(environment, checks, summary);
|
|
273
|
+
}
|
|
274
|
+
if (failed > 0) {
|
|
275
|
+
throw new CliError({
|
|
276
|
+
code: 'DOCTOR_FAILED',
|
|
277
|
+
message: `${failed} of ${checks.length} checks failed.`,
|
|
278
|
+
exitCode: EXIT_CODES.failure
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Adds `doctor` under the root. */
|
|
283
|
+
export function registerDoctorCommand(root, environment) {
|
|
284
|
+
const doctor = root
|
|
285
|
+
.command('doctor')
|
|
286
|
+
.description('Check the install and both servers: the CLI and its contracts, the API key, the REST API, and the MCP server (which tool contract it speaks and which tools it offers, compared with this CLI).\n\nExit code 0 when nothing failed, 1 when a check did; --json prints every check.');
|
|
287
|
+
addBasicOptions(doctor, { server: true });
|
|
288
|
+
doctor.action(async () => {
|
|
289
|
+
await runDoctor(environment, doctor);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { usageError } from "../errors.js";
|
|
2
|
+
import { loadMcpManifest, loadRestManifest } from "../manifest/load.js";
|
|
3
|
+
import { addBasicOptions, readCommon, unwrap } from "../options.js";
|
|
4
|
+
import { printResult, writeStdout } from "../output.js";
|
|
5
|
+
/** The number of single-character edits between two words (insert, delete, change). */
|
|
6
|
+
export function editDistance(a, b) {
|
|
7
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
8
|
+
for (let row = 1; row <= a.length; row += 1) {
|
|
9
|
+
const current = [row];
|
|
10
|
+
for (let column = 1; column <= b.length; column += 1) {
|
|
11
|
+
const change = previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1);
|
|
12
|
+
current[column] = Math.min(change, previous[column] + 1, current[column - 1] + 1);
|
|
13
|
+
}
|
|
14
|
+
previous = current;
|
|
15
|
+
}
|
|
16
|
+
return previous[b.length];
|
|
17
|
+
}
|
|
18
|
+
/** A name without its separators or its case, so `getWidgt`, `get-widgt` and `get_widgt` are the same word. */
|
|
19
|
+
const plain = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
20
|
+
/**
|
|
21
|
+
* Names that look like `word`, nearest first, to say "did you mean" when nothing matches: a name that contains it, or
|
|
22
|
+
* one a few characters away (a third of its length, at least two).
|
|
23
|
+
*/
|
|
24
|
+
export function suggest(word, candidates, limit = 5) {
|
|
25
|
+
const wanted = plain(word);
|
|
26
|
+
if (wanted === '')
|
|
27
|
+
return [];
|
|
28
|
+
const allowed = Math.max(2, Math.floor(wanted.length / 3));
|
|
29
|
+
const ranked = [];
|
|
30
|
+
for (const name of new Set(candidates)) {
|
|
31
|
+
const candidate = plain(name);
|
|
32
|
+
if (wanted.length >= 3 && candidate.includes(wanted)) {
|
|
33
|
+
ranked.push({ name, rank: 0 });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const distance = editDistance(wanted, candidate);
|
|
37
|
+
if (distance <= allowed)
|
|
38
|
+
ranked.push({ name, rank: distance });
|
|
39
|
+
}
|
|
40
|
+
return ranked
|
|
41
|
+
.sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
|
|
42
|
+
.slice(0, limit)
|
|
43
|
+
.map((entry) => entry.name);
|
|
44
|
+
}
|
|
45
|
+
function allRest(manifest) {
|
|
46
|
+
return manifest.groups.flatMap((group) => group.commands.map((command) => ({ group, command })));
|
|
47
|
+
}
|
|
48
|
+
/** `widgets get-widget` (a group and a command, or an alias), or `getWidget` / `get-widget` on its own. */
|
|
49
|
+
export function findRestCommand(manifest, words) {
|
|
50
|
+
const everything = allRest(manifest);
|
|
51
|
+
if (words.length >= 2) {
|
|
52
|
+
const [groupName, commandName] = words;
|
|
53
|
+
return everything.find(({ group, command }) => group.name === groupName &&
|
|
54
|
+
(command.name === commandName ||
|
|
55
|
+
command.aliases.includes(commandName) ||
|
|
56
|
+
command.operationId === commandName));
|
|
57
|
+
}
|
|
58
|
+
const [word] = words;
|
|
59
|
+
const byOperation = everything.find(({ command }) => command.operationId === word);
|
|
60
|
+
if (byOperation)
|
|
61
|
+
return byOperation;
|
|
62
|
+
const byName = everything.filter(({ command }) => command.name === word);
|
|
63
|
+
return byName.length === 1 ? byName[0] : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** `create-widget` or `create_widget`. */
|
|
66
|
+
export function findMcpCommand(commands, word) {
|
|
67
|
+
const normalized = word.replace(/_/g, '-');
|
|
68
|
+
return commands.find((command) => command.name === normalized || command.tool === word);
|
|
69
|
+
}
|
|
70
|
+
function flagView(flag) {
|
|
71
|
+
const view = { flag: `--${flag.flag}`, type: flag.type, required: flag.required };
|
|
72
|
+
for (const key of [
|
|
73
|
+
'enum',
|
|
74
|
+
'default',
|
|
75
|
+
'cliDefault',
|
|
76
|
+
'minimum',
|
|
77
|
+
'maximum',
|
|
78
|
+
'minLength',
|
|
79
|
+
'maxLength',
|
|
80
|
+
'pattern',
|
|
81
|
+
'format'
|
|
82
|
+
]) {
|
|
83
|
+
if (flag[key] !== undefined)
|
|
84
|
+
view[key] = flag[key];
|
|
85
|
+
}
|
|
86
|
+
view.description = flag.description;
|
|
87
|
+
return view;
|
|
88
|
+
}
|
|
89
|
+
export function restSchema(manifest, match) {
|
|
90
|
+
const { group, command } = match;
|
|
91
|
+
const base = command.server === 'root' ? new URL(manifest.apiUrl).origin : manifest.apiUrl;
|
|
92
|
+
const body = command.body;
|
|
93
|
+
return {
|
|
94
|
+
kind: 'rest',
|
|
95
|
+
command: `widgetic api ${group.name} ${command.name}`,
|
|
96
|
+
...(command.aliases.length > 0
|
|
97
|
+
? { aliases: command.aliases.map((alias) => `widgetic api ${group.name} ${alias}`) }
|
|
98
|
+
: {}),
|
|
99
|
+
operationId: command.operationId,
|
|
100
|
+
summary: command.summary,
|
|
101
|
+
method: command.method,
|
|
102
|
+
url: `${base}${command.path}`,
|
|
103
|
+
accessLevel: command.accessLevel,
|
|
104
|
+
authenticated: command.auth,
|
|
105
|
+
asksBeforeRunning: command.confirm,
|
|
106
|
+
description: command.description,
|
|
107
|
+
parameters: command.parameters.map((parameter) => ({
|
|
108
|
+
name: parameter.name,
|
|
109
|
+
in: parameter.in,
|
|
110
|
+
...(parameter.role ? { role: parameter.role } : {}),
|
|
111
|
+
...flagView(parameter)
|
|
112
|
+
})),
|
|
113
|
+
...(body
|
|
114
|
+
? {
|
|
115
|
+
body: {
|
|
116
|
+
kind: body.kind,
|
|
117
|
+
contentType: body.contentType,
|
|
118
|
+
...(body.contentTypes ? { contentTypes: body.contentTypes } : {}),
|
|
119
|
+
required: body.required,
|
|
120
|
+
flags: body.flags.map((flag) => ({ property: flag.property, ...flagView(flag) })),
|
|
121
|
+
...(body.schema ? { schema: body.schema } : {})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
: {})
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function mcpSchema(manifest, command) {
|
|
128
|
+
return {
|
|
129
|
+
kind: 'mcp',
|
|
130
|
+
command: `widgetic mcp ${command.name}`,
|
|
131
|
+
tool: command.tool,
|
|
132
|
+
title: command.title,
|
|
133
|
+
toolContract: manifest.contractVersion,
|
|
134
|
+
scopes: command.scopes,
|
|
135
|
+
readOnly: command.readOnly,
|
|
136
|
+
destructive: command.destructive,
|
|
137
|
+
idempotent: command.idempotent,
|
|
138
|
+
asksBeforeRunning: command.confirm,
|
|
139
|
+
description: command.description,
|
|
140
|
+
flags: command.flags.map((flag) => ({ property: flag.property, ...flagView(flag) })),
|
|
141
|
+
inputOnly: command.inputOnly,
|
|
142
|
+
inputSchema: command.inputSchema
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** What a person reads: the title, the description as paragraphs, then the rest as JSON. */
|
|
146
|
+
function printSchema(environment, schema, title, json) {
|
|
147
|
+
if (json) {
|
|
148
|
+
printResult(environment, schema, true);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const { description, summary, ...rest } = schema;
|
|
152
|
+
const paragraphs = String(description ?? '')
|
|
153
|
+
.trim()
|
|
154
|
+
.split(/\n\s*\n/)
|
|
155
|
+
.map(unwrap)
|
|
156
|
+
.filter((paragraph) => paragraph !== '');
|
|
157
|
+
writeStdout(environment, [title, typeof summary === 'string' ? summary : '', ...paragraphs, JSON.stringify(rest, null, 2)]
|
|
158
|
+
.filter((part) => part !== '')
|
|
159
|
+
.join('\n\n'));
|
|
160
|
+
}
|
|
161
|
+
/** The mistake of naming something the contracts do not have, with the nearest names that exist. */
|
|
162
|
+
function unknownName(what, word, candidates, list) {
|
|
163
|
+
const near = suggest(word, candidates);
|
|
164
|
+
return usageError(`There is no ${what} in the contracts this CLI was built from.`, near.length > 0 ? `Did you mean: ${near.join(', ')}?` : `${list} lists what there is.`);
|
|
165
|
+
}
|
|
166
|
+
/** Why `words` named no operation: the group is wrong, or the command in it is, or (one word) the operationId. */
|
|
167
|
+
export function unknownOperation(manifest, words) {
|
|
168
|
+
if (words.length >= 2) {
|
|
169
|
+
const [groupName, commandName] = words;
|
|
170
|
+
const group = manifest.groups.find((candidate) => candidate.name === groupName);
|
|
171
|
+
if (!group) {
|
|
172
|
+
return unknownName(`group "${groupName}"`, groupName, manifest.groups.map((candidate) => candidate.name), '`widgetic api --help`');
|
|
173
|
+
}
|
|
174
|
+
return unknownName(`command "${commandName}" in the group ${group.name}`, commandName, group.commands.flatMap((command) => [command.name, ...command.aliases, command.operationId]), `\`widgetic api ${group.name} --help\``);
|
|
175
|
+
}
|
|
176
|
+
// The nearest names in the spelling that was typed: an operationId (getWidgetById), a command (get-widget-by-id), or either.
|
|
177
|
+
const word = words[0] ?? '';
|
|
178
|
+
const camel = /[A-Z]/.test(word) && !/[-_]/.test(word);
|
|
179
|
+
const kebab = /[-_]/.test(word) && !/[A-Z]/.test(word);
|
|
180
|
+
return unknownName(`operation "${word}"`, word, allRest(manifest).flatMap(({ command }) => camel ? [command.operationId] : kebab ? [command.name] : [command.operationId, command.name]), '`widgetic api --help`');
|
|
181
|
+
}
|
|
182
|
+
/** Adds `schema` and its two forms under the root. */
|
|
183
|
+
export function registerSchemaCommand(root, environment) {
|
|
184
|
+
const schema = root
|
|
185
|
+
.command('schema')
|
|
186
|
+
.description('Print the whole description of one command and the schema its input is checked against.\n\nIt is the cheap way to find out what a command takes, for a person and for an agent: one command, not the whole API.');
|
|
187
|
+
const api = schema
|
|
188
|
+
.command('api')
|
|
189
|
+
.description('One operation of the REST API.')
|
|
190
|
+
.argument('<command...>', 'A group and a command (widgets get-widget), or an operationId (getWidget)');
|
|
191
|
+
addBasicOptions(api, { server: false });
|
|
192
|
+
api.action(async (words) => {
|
|
193
|
+
const common = readCommon(api);
|
|
194
|
+
const manifest = loadRestManifest();
|
|
195
|
+
const match = findRestCommand(manifest, words);
|
|
196
|
+
if (!match)
|
|
197
|
+
throw unknownOperation(manifest, words);
|
|
198
|
+
printSchema(environment, restSchema(manifest, match), `widgetic api ${match.group.name} ${match.command.name}`, common.json);
|
|
199
|
+
});
|
|
200
|
+
const mcp = schema
|
|
201
|
+
.command('mcp')
|
|
202
|
+
.description('One tool of the MCP server.')
|
|
203
|
+
.argument('<command>', 'The tool, as a command (create-widget) or as the server names it (create_widget)');
|
|
204
|
+
addBasicOptions(mcp, { server: false });
|
|
205
|
+
mcp.action(async (word) => {
|
|
206
|
+
const common = readCommon(mcp);
|
|
207
|
+
const manifest = loadMcpManifest();
|
|
208
|
+
const command = findMcpCommand(manifest.commands, word);
|
|
209
|
+
if (!command) {
|
|
210
|
+
throw unknownName(`tool "${word}"`, word, manifest.commands.map((candidate) => candidate.name), '`widgetic mcp --help`');
|
|
211
|
+
}
|
|
212
|
+
printSchema(environment, mcpSchema(manifest, command), `widgetic mcp ${command.name}`, common.json);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Option } from 'commander';
|
|
2
|
+
import { UPDATE_CHECK_CONFIG } from "../constants.js";
|
|
3
|
+
import { CliError, EXIT_CODES, usageError } from "../errors.js";
|
|
4
|
+
import { addBasicOptions, readCommon } from "../options.js";
|
|
5
|
+
import { debug, note, printResult, writeStdout } from "../output.js";
|
|
6
|
+
import { describeInstallation, updateCommand } from "../update/installation.js";
|
|
7
|
+
import { lookUpLatestVersion } from "../update/lookup.js";
|
|
8
|
+
import { describeInstallFailure, performUpdate } from "../update/perform.js";
|
|
9
|
+
import { isNewerVersion } from "../update/version.js";
|
|
10
|
+
import { cliPackageName, cliVersion } from "../version.js";
|
|
11
|
+
/**
|
|
12
|
+
* `widgetic update`: the same thing the question after a command does, whenever the person asks for it. It asks npm
|
|
13
|
+
* (always, not from what was saved), shows the version that is running and the newest one, and installs the newest
|
|
14
|
+
* after a yes. `--check` only reports; `--yes` installs without asking; `--json` never asks and so only reports unless
|
|
15
|
+
* `--yes` is given with it. The routine question is skipped for this command, so it is never asked twice in a row.
|
|
16
|
+
*/
|
|
17
|
+
/** Why the CLI does not run a package manager for this copy, and what the person can do instead. */
|
|
18
|
+
const NOT_UPDATABLE = {
|
|
19
|
+
unknown: {
|
|
20
|
+
why: 'This copy of the CLI is not a global install by npm, pnpm, yarn or bun, so the CLI does not know which command updates it.',
|
|
21
|
+
hint: (command) => `Update it the way you installed it, for example: ${command} (without -g inside a project).`
|
|
22
|
+
},
|
|
23
|
+
transient: {
|
|
24
|
+
why: 'This copy was fetched to run once (npx, dlx or bunx), so there is no installed copy to update.',
|
|
25
|
+
hint: () => 'Run it as `npx @widgetic/cli@latest` to get the newest, or install it for good: npm install -g @widgetic/cli'
|
|
26
|
+
},
|
|
27
|
+
checkout: {
|
|
28
|
+
why: 'This copy of the CLI runs from a source checkout, not from an installed package.',
|
|
29
|
+
hint: () => 'In the repository: git pull, then npm install and npm run build.'
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function lookupError(failure) {
|
|
33
|
+
const exitCode = failure.reason === 'not-published'
|
|
34
|
+
? EXIT_CODES.notFound
|
|
35
|
+
: failure.reason === 'unreachable'
|
|
36
|
+
? EXIT_CODES.unavailable
|
|
37
|
+
: EXIT_CODES.failure;
|
|
38
|
+
return new CliError({
|
|
39
|
+
code: 'UPDATE_CHECK_FAILED',
|
|
40
|
+
message: `Could not look for a newer version: ${failure.detail}.`,
|
|
41
|
+
exitCode,
|
|
42
|
+
hint: `\`npm view ${cliPackageName()} version\` asks the same registry with your npm settings (a proxy, a mirror).`
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function runUpdate(environment, node) {
|
|
46
|
+
const common = readCommon(node);
|
|
47
|
+
const checkOnly = node.opts().check === true;
|
|
48
|
+
if (checkOnly && common.yes) {
|
|
49
|
+
throw usageError('--check only looks and --yes installs: use one of them.', 'Run `widgetic update --check` to look, or `widgetic update --yes` to install without asking.');
|
|
50
|
+
}
|
|
51
|
+
const packageName = cliPackageName();
|
|
52
|
+
const current = cliVersion();
|
|
53
|
+
const knowledge = await lookUpLatestVersion(environment, {
|
|
54
|
+
force: true,
|
|
55
|
+
timeoutMilliseconds: UPDATE_CHECK_CONFIG.COMMAND_TIMEOUT_MILLISECONDS
|
|
56
|
+
});
|
|
57
|
+
// A look that failed is an error here, even when an older answer is saved: this is the person asking for the truth.
|
|
58
|
+
if (knowledge.failure !== undefined || knowledge.latest === undefined) {
|
|
59
|
+
throw lookupError(knowledge.failure ?? { ok: false, reason: 'unexpected', detail: 'npm did not name a version' });
|
|
60
|
+
}
|
|
61
|
+
const latest = knowledge.latest;
|
|
62
|
+
const updateAvailable = isNewerVersion(latest, current);
|
|
63
|
+
const installation = describeInstallation(environment.packageFolder);
|
|
64
|
+
const manager = installation.kind === 'global' ? installation.manager : undefined;
|
|
65
|
+
debug(environment, `installation: ${JSON.stringify(installation)}; ${current} -> ${latest}`);
|
|
66
|
+
// What installs the newest version: the manager of this copy, else npm as the example to give.
|
|
67
|
+
const command = updateAvailable ? updateCommand(manager ?? 'npm', packageName, latest) : undefined;
|
|
68
|
+
const status = {
|
|
69
|
+
package: packageName,
|
|
70
|
+
current,
|
|
71
|
+
latest,
|
|
72
|
+
updateAvailable,
|
|
73
|
+
updated: false,
|
|
74
|
+
installedWith: manager ?? null,
|
|
75
|
+
command: command?.text ?? null
|
|
76
|
+
};
|
|
77
|
+
if (command === undefined) {
|
|
78
|
+
if (common.json)
|
|
79
|
+
printResult(environment, status, true);
|
|
80
|
+
else if (current === latest)
|
|
81
|
+
writeStdout(environment, `${packageName} ${current} is up to date.`);
|
|
82
|
+
else {
|
|
83
|
+
writeStdout(environment, `${packageName} ${current} is not older than the latest release on npm (${latest}): nothing to update.`);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const available = `A new version of the Widgetic CLI is available: ${current} → ${latest}`;
|
|
88
|
+
// Only looking: `--check`, or `--json` without `--yes` (a program is reading, so nobody is asked).
|
|
89
|
+
if (checkOnly || (common.json && !common.yes)) {
|
|
90
|
+
if (common.json)
|
|
91
|
+
printResult(environment, status, true);
|
|
92
|
+
else {
|
|
93
|
+
writeStdout(environment, manager === undefined
|
|
94
|
+
? `${available}\nUpdate it the way you installed it, for example: ${command.text}`
|
|
95
|
+
: `${available}\nInstall it with \`widgetic update\`, or: ${command.text}`);
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (installation.kind !== 'global') {
|
|
100
|
+
const reason = NOT_UPDATABLE[installation.kind];
|
|
101
|
+
throw new CliError({
|
|
102
|
+
code: 'UPDATE_NOT_POSSIBLE_HERE',
|
|
103
|
+
message: `${available}. ${reason.why}`,
|
|
104
|
+
exitCode: EXIT_CODES.failure,
|
|
105
|
+
hint: reason.hint(command.text)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!common.yes) {
|
|
109
|
+
const confirm = environment.confirm;
|
|
110
|
+
if (confirm === undefined) {
|
|
111
|
+
throw usageError(`${available}, and there is nobody to ask: nothing was changed.`, 'Run it again with --yes to install it, or with --check to only look.');
|
|
112
|
+
}
|
|
113
|
+
note(environment, available);
|
|
114
|
+
let agreed = false;
|
|
115
|
+
try {
|
|
116
|
+
agreed = await confirm(`Update now? (runs \`${command.text}\`)`, {
|
|
117
|
+
timeoutMilliseconds: UPDATE_CHECK_CONFIG.PROMPT_TIMEOUT_SECONDS * 1000
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Ctrl-C or Ctrl-D at the question is a "no".
|
|
122
|
+
note(environment, '');
|
|
123
|
+
}
|
|
124
|
+
if (!agreed) {
|
|
125
|
+
writeStdout(environment, 'Not updating.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const result = await performUpdate(environment, {
|
|
130
|
+
command,
|
|
131
|
+
packageName,
|
|
132
|
+
current,
|
|
133
|
+
latest,
|
|
134
|
+
quiet: common.json
|
|
135
|
+
});
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
throw new CliError({
|
|
138
|
+
code: 'UPDATE_FAILED',
|
|
139
|
+
message: describeInstallFailure(result, { command, packageName, latest }),
|
|
140
|
+
exitCode: EXIT_CODES.failure
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (common.json)
|
|
144
|
+
printResult(environment, { ...status, updated: true }, true);
|
|
145
|
+
}
|
|
146
|
+
/** Adds `update` under the root. */
|
|
147
|
+
export function registerUpdateCommand(root, environment) {
|
|
148
|
+
const update = root
|
|
149
|
+
.command('update')
|
|
150
|
+
// The line the list of commands of `widgetic --help` shows; the whole text is the help of `update` itself.
|
|
151
|
+
.summary('Look for a newer version of this CLI and offer to install it.')
|
|
152
|
+
.description('Look on npm for a newer version of this CLI and, when there is one, show both versions and ask whether to install it.\n\nOnly a global install made by npm, pnpm, yarn or bun is updated by the CLI itself; for any other copy the command to run is printed. After a command, the CLI asks the same question by itself at most once a week for each new version, and only at a terminal: NO_UPDATE_NOTIFIER=1 or WIDGETIC_NO_UPDATE_CHECK=1 turns that off.\n\nExit code 0 when it is up to date, when you said no, and with --check; 1 when it could not update.');
|
|
153
|
+
update.addOption(new Option('--check', 'Only say whether a newer version exists; install nothing and ask nothing.'));
|
|
154
|
+
update.addOption(new Option('--yes', 'Install the newest version without asking.'));
|
|
155
|
+
addBasicOptions(update, { server: false });
|
|
156
|
+
update.action(async () => {
|
|
157
|
+
await runUpdate(environment, update);
|
|
158
|
+
});
|
|
159
|
+
}
|