swaggerbot 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/swaggerbot.mjs +1824 -0
- package/package.json +30 -4
|
@@ -0,0 +1,1824 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { Writable } from "node:stream";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { open, rename, rm, stat } from "node:fs/promises";
|
|
10
|
+
var package_default = {
|
|
11
|
+
name: "swaggerbot",
|
|
12
|
+
version: "0.1.0",
|
|
13
|
+
license: "MIT",
|
|
14
|
+
description: "The swagger.bot CLI: the name of an API in, its verified OpenAPI/Swagger Spec with its Provenance out.",
|
|
15
|
+
type: "module",
|
|
16
|
+
bin: { "swaggerbot": "./dist/swaggerbot.mjs" },
|
|
17
|
+
files: ["dist", "README.md"],
|
|
18
|
+
engines: { "node": ">=20" },
|
|
19
|
+
repository: {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/evolv3ai/swaggerbot.git",
|
|
22
|
+
"directory": "cli"
|
|
23
|
+
},
|
|
24
|
+
homepage: "https://swaggerbot.dev",
|
|
25
|
+
keywords: [
|
|
26
|
+
"openapi",
|
|
27
|
+
"swagger",
|
|
28
|
+
"api",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
publishConfig: { "access": "public" }
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/cli/args.ts
|
|
35
|
+
var CLI_USAGE = `usage: swaggerbot <command> [options]
|
|
36
|
+
lookup <name> [--api-version V] [--community] [--fresh]
|
|
37
|
+
resolve a name to its verified Spec and Provenance,
|
|
38
|
+
or say why not
|
|
39
|
+
login [--key K] check a key and store it for this swagger.bot
|
|
40
|
+
(prompted for it when --key isn't given)
|
|
41
|
+
logout forget the stored key
|
|
42
|
+
key the key in use: its id, credits left, when they reset
|
|
43
|
+
spec <name | apiId | specId> [--form published|normalized]
|
|
44
|
+
[--api-version V] [-o FILE] [--force] [--no-wait]
|
|
45
|
+
download a Spec: by name (a Lookup, Resolved only),
|
|
46
|
+
apiId (its Current Spec) or Spec id; to FILE, to
|
|
47
|
+
stdout on a pipe, else to the file the server names
|
|
48
|
+
(--force to overwrite it). --no-wait: fail at once
|
|
49
|
+
while the Normalized Form is being built. --json
|
|
50
|
+
prints what was written, so it needs -o or a terminal
|
|
51
|
+
outline <name | apiId> [--tag T] [--query Q] [--spec-id S] [--limit N]
|
|
52
|
+
[--all] its Spec Outline: title, servers, security schemes,
|
|
53
|
+
tags, then the operations (100 a page, --limit up to
|
|
54
|
+
500), by tag or a word of the path, operationId or
|
|
55
|
+
summary; --all lists every tag and every page
|
|
56
|
+
operation <name | apiId> <method> <path> [--spec-id S]
|
|
57
|
+
one operation, every reference inlined, as JSON
|
|
58
|
+
schema <name | apiId> <schemaName> [--spec-id S]
|
|
59
|
+
one component schema, as JSON
|
|
60
|
+
vendors [<query>] [--all]
|
|
61
|
+
the Vendors in the Index, 50 a page, their id, name
|
|
62
|
+
and API count; those whose id or name holds <query>;
|
|
63
|
+
--all lists every page
|
|
64
|
+
vendor <vendor> a Vendor's APIs in the Index, each with its Current
|
|
65
|
+
Spec, Provenance and download URLs
|
|
66
|
+
Options:
|
|
67
|
+
--json print the answer as the HTTP API gives it
|
|
68
|
+
--key <key> the API key (or SWAGGERBOT_API_KEY, or the one
|
|
69
|
+
login stored); needed for Discovery and --fresh
|
|
70
|
+
--url <origin> another swagger.bot (or SWAGGERBOT_URL);
|
|
71
|
+
default https://swaggerbot.dev
|
|
72
|
+
--no-color no colour (or NO_COLOR); never on a pipe anyway
|
|
73
|
+
-V, --version print the version
|
|
74
|
+
-h, --help print this
|
|
75
|
+
Exit codes: 0 Resolved or success, 3 Ambiguous, 4 Unconfirmed, 5 No Spec,
|
|
76
|
+
6 Unknown, 1 refused or failed (no key, quota, unreachable), 2 usage.`;
|
|
77
|
+
/** The methods an OpenAPI Path Item holds operations under. */
|
|
78
|
+
var METHODS = [
|
|
79
|
+
"get",
|
|
80
|
+
"put",
|
|
81
|
+
"post",
|
|
82
|
+
"delete",
|
|
83
|
+
"options",
|
|
84
|
+
"head",
|
|
85
|
+
"patch",
|
|
86
|
+
"trace"
|
|
87
|
+
];
|
|
88
|
+
function fail(error) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error,
|
|
92
|
+
exitCode: 2
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** The flags that go with some commands only, to refuse them elsewhere. */
|
|
96
|
+
var COMMAND_FLAGS = {
|
|
97
|
+
lookup: [
|
|
98
|
+
"api-version",
|
|
99
|
+
"community",
|
|
100
|
+
"fresh"
|
|
101
|
+
],
|
|
102
|
+
spec: [
|
|
103
|
+
"api-version",
|
|
104
|
+
"form",
|
|
105
|
+
"output",
|
|
106
|
+
"force",
|
|
107
|
+
"no-wait"
|
|
108
|
+
],
|
|
109
|
+
outline: [
|
|
110
|
+
"tag",
|
|
111
|
+
"query",
|
|
112
|
+
"spec-id",
|
|
113
|
+
"limit",
|
|
114
|
+
"all",
|
|
115
|
+
"cursor"
|
|
116
|
+
],
|
|
117
|
+
operation: ["spec-id"],
|
|
118
|
+
schema: ["spec-id"],
|
|
119
|
+
vendors: ["all", "cursor"]
|
|
120
|
+
};
|
|
121
|
+
/** `-o` is `--output` to `parseArgs`; say it as it was given. */
|
|
122
|
+
var FLAG_WORDS = { output: "-o" };
|
|
123
|
+
/** A Spec id: the lowercase hex sha256 of its Published Form. */
|
|
124
|
+
var SPEC_ID = /^[0-9a-f]{64}$/i;
|
|
125
|
+
/** Parses and validates `swaggerbot`'s arguments without running anything. */
|
|
126
|
+
function parseCliArgs(args) {
|
|
127
|
+
let values;
|
|
128
|
+
let positionals;
|
|
129
|
+
try {
|
|
130
|
+
({values, positionals} = parseArgs({
|
|
131
|
+
args,
|
|
132
|
+
allowPositionals: true,
|
|
133
|
+
options: {
|
|
134
|
+
json: { type: "boolean" },
|
|
135
|
+
key: { type: "string" },
|
|
136
|
+
url: { type: "string" },
|
|
137
|
+
"no-color": { type: "boolean" },
|
|
138
|
+
version: {
|
|
139
|
+
type: "boolean",
|
|
140
|
+
short: "V"
|
|
141
|
+
},
|
|
142
|
+
help: {
|
|
143
|
+
type: "boolean",
|
|
144
|
+
short: "h"
|
|
145
|
+
},
|
|
146
|
+
"api-version": { type: "string" },
|
|
147
|
+
community: { type: "boolean" },
|
|
148
|
+
fresh: { type: "boolean" },
|
|
149
|
+
form: { type: "string" },
|
|
150
|
+
output: {
|
|
151
|
+
type: "string",
|
|
152
|
+
short: "o"
|
|
153
|
+
},
|
|
154
|
+
force: { type: "boolean" },
|
|
155
|
+
"no-wait": { type: "boolean" },
|
|
156
|
+
tag: { type: "string" },
|
|
157
|
+
query: { type: "string" },
|
|
158
|
+
"spec-id": { type: "string" },
|
|
159
|
+
limit: { type: "string" },
|
|
160
|
+
all: { type: "boolean" },
|
|
161
|
+
cursor: { type: "string" }
|
|
162
|
+
}
|
|
163
|
+
}));
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return fail(err.message);
|
|
166
|
+
}
|
|
167
|
+
const globals = {
|
|
168
|
+
json: values.json === true,
|
|
169
|
+
noColor: values["no-color"] === true
|
|
170
|
+
};
|
|
171
|
+
if (values.key !== void 0) {
|
|
172
|
+
if (!values.key.trim()) return fail("--key needs a key");
|
|
173
|
+
globals.key = values.key.trim();
|
|
174
|
+
}
|
|
175
|
+
if (values.url !== void 0) {
|
|
176
|
+
const url = values.url.trim().replace(/\/+$/, "");
|
|
177
|
+
if (!/^https?:\/\/\S+$/.test(url)) return fail(`--url must be an http(s) origin (got "${values.url}")`);
|
|
178
|
+
globals.url = url;
|
|
179
|
+
}
|
|
180
|
+
if (values.version) return {
|
|
181
|
+
ok: true,
|
|
182
|
+
command: { command: "version" },
|
|
183
|
+
globals
|
|
184
|
+
};
|
|
185
|
+
if (values.help) return {
|
|
186
|
+
ok: true,
|
|
187
|
+
command: { command: "help" },
|
|
188
|
+
globals
|
|
189
|
+
};
|
|
190
|
+
const [command, ...rest] = positionals;
|
|
191
|
+
const owned = new Set(command ? COMMAND_FLAGS[command] : void 0);
|
|
192
|
+
for (const flag of new Set(Object.values(COMMAND_FLAGS).flat())) {
|
|
193
|
+
if (owned.has(flag) || values[flag] === void 0) continue;
|
|
194
|
+
const owners = Object.keys(COMMAND_FLAGS).filter((owner) => COMMAND_FLAGS[owner]?.includes(flag));
|
|
195
|
+
return fail(`${FLAG_WORDS[flag] ?? `--${flag}`} only goes with ${owners.join(" or ")}`);
|
|
196
|
+
}
|
|
197
|
+
switch (command) {
|
|
198
|
+
case "lookup": {
|
|
199
|
+
const [name, ...extra] = rest;
|
|
200
|
+
if (!name?.trim()) return fail("lookup needs a name");
|
|
201
|
+
if (extra.length > 0) return fail(`unexpected argument: ${extra[0]}`);
|
|
202
|
+
if (values["api-version"] !== void 0 && !values["api-version"].trim()) return fail("--api-version needs a version");
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
command: {
|
|
206
|
+
command,
|
|
207
|
+
name: name.trim(),
|
|
208
|
+
...values["api-version"] === void 0 ? {} : { apiVersion: values["api-version"].trim() },
|
|
209
|
+
community: values.community === true,
|
|
210
|
+
fresh: values.fresh === true
|
|
211
|
+
},
|
|
212
|
+
globals
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
case "spec": {
|
|
216
|
+
const [given, ...extra] = rest;
|
|
217
|
+
const arg = given?.trim();
|
|
218
|
+
if (!arg) return fail("spec needs a name, an apiId or a Spec id");
|
|
219
|
+
if (extra.length > 0) return fail(`unexpected argument: ${extra[0]}`);
|
|
220
|
+
const target = SPEC_ID.test(arg) ? {
|
|
221
|
+
kind: "specId",
|
|
222
|
+
specId: arg.toLowerCase()
|
|
223
|
+
} : arg.includes("/") ? {
|
|
224
|
+
kind: "apiId",
|
|
225
|
+
apiId: arg
|
|
226
|
+
} : {
|
|
227
|
+
kind: "name",
|
|
228
|
+
name: arg
|
|
229
|
+
};
|
|
230
|
+
const form = values.form ?? "published";
|
|
231
|
+
if (form !== "published" && form !== "normalized") return fail(`--form is published or normalized (got "${form}")`);
|
|
232
|
+
const apiVersion = values["api-version"];
|
|
233
|
+
if (apiVersion !== void 0 && !apiVersion.trim()) return fail("--api-version needs a version");
|
|
234
|
+
if (apiVersion !== void 0 && target.kind !== "name") return fail(target.kind === "specId" ? "--api-version goes with a name: a Spec id names one Spec already" : "--api-version goes with a name: an apiId gives its Current Spec only");
|
|
235
|
+
if (values.output !== void 0 && !values.output.trim()) return fail("-o needs a file");
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
command: {
|
|
239
|
+
command,
|
|
240
|
+
target,
|
|
241
|
+
form,
|
|
242
|
+
...apiVersion === void 0 ? {} : { apiVersion: apiVersion.trim() },
|
|
243
|
+
...values.output === void 0 ? {} : { output: values.output },
|
|
244
|
+
force: values.force === true,
|
|
245
|
+
noWait: values["no-wait"] === true
|
|
246
|
+
},
|
|
247
|
+
globals
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
case "outline": {
|
|
251
|
+
const [given, ...extra] = rest;
|
|
252
|
+
const target = apiTargetOf(given);
|
|
253
|
+
if (!target) return fail("outline needs a name or an apiId");
|
|
254
|
+
if (extra.length > 0) return fail(`unexpected argument: ${extra[0]}`);
|
|
255
|
+
const specId = specIdOf(values["spec-id"]);
|
|
256
|
+
if (specId === null) return fail(SPEC_ID_WORDS);
|
|
257
|
+
for (const flag of [
|
|
258
|
+
"tag",
|
|
259
|
+
"query",
|
|
260
|
+
"cursor"
|
|
261
|
+
]) if (values[flag] !== void 0 && !values[flag].trim()) return fail(`--${flag} needs a value`);
|
|
262
|
+
let limit;
|
|
263
|
+
if (values.limit !== void 0) {
|
|
264
|
+
limit = Number(values.limit);
|
|
265
|
+
if (!/^\d+$/.test(values.limit.trim()) || limit < 1 || limit > 500) return fail(`--limit is a whole number from 1 to 500 (got "${values.limit}")`);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
command: {
|
|
270
|
+
command,
|
|
271
|
+
target,
|
|
272
|
+
...values.tag === void 0 ? {} : { tag: values.tag.trim() },
|
|
273
|
+
...values.query === void 0 ? {} : { query: values.query.trim() },
|
|
274
|
+
...specId === void 0 ? {} : { specId },
|
|
275
|
+
...limit === void 0 ? {} : { limit },
|
|
276
|
+
all: values.all === true,
|
|
277
|
+
...values.cursor === void 0 ? {} : { cursor: values.cursor.trim() }
|
|
278
|
+
},
|
|
279
|
+
globals
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
case "operation": {
|
|
283
|
+
const [given, method, path, ...extra] = rest;
|
|
284
|
+
const target = apiTargetOf(given);
|
|
285
|
+
if (!target || !method?.trim() || !path?.trim()) return fail("operation needs a name or an apiId, a method and a path, as in: operation stripe.com/stripe-api post /v1/customers");
|
|
286
|
+
if (extra.length > 0) return fail(`unexpected argument: ${extra[0]}`);
|
|
287
|
+
if (!METHODS.includes(method.trim().toLowerCase())) return fail(`the method is one of ${METHODS.join(", ")} (got "${method}")`);
|
|
288
|
+
const specId = specIdOf(values["spec-id"]);
|
|
289
|
+
if (specId === null) return fail(SPEC_ID_WORDS);
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
command: {
|
|
293
|
+
command,
|
|
294
|
+
target,
|
|
295
|
+
method: method.trim().toLowerCase(),
|
|
296
|
+
path: path.trim(),
|
|
297
|
+
...specId === void 0 ? {} : { specId }
|
|
298
|
+
},
|
|
299
|
+
globals
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
case "schema": {
|
|
303
|
+
const [given, name, ...extra] = rest;
|
|
304
|
+
const target = apiTargetOf(given);
|
|
305
|
+
if (!target || !name?.trim()) return fail("schema needs a name or an apiId and a schema name, as in: schema stripe.com/stripe-api customer");
|
|
306
|
+
if (extra.length > 0) return fail(`unexpected argument: ${extra[0]}`);
|
|
307
|
+
const specId = specIdOf(values["spec-id"]);
|
|
308
|
+
if (specId === null) return fail(SPEC_ID_WORDS);
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
command: {
|
|
312
|
+
command,
|
|
313
|
+
target,
|
|
314
|
+
name: name.trim(),
|
|
315
|
+
...specId === void 0 ? {} : { specId }
|
|
316
|
+
},
|
|
317
|
+
globals
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
case "vendors": {
|
|
321
|
+
const [query, ...extra] = rest;
|
|
322
|
+
if (extra.length > 0) return fail(`unexpected argument: ${extra[0]}`);
|
|
323
|
+
if (values.cursor !== void 0 && !values.cursor.trim()) return fail("--cursor needs a value");
|
|
324
|
+
return {
|
|
325
|
+
ok: true,
|
|
326
|
+
command: {
|
|
327
|
+
command,
|
|
328
|
+
...query?.trim() ? { query: query.trim() } : {},
|
|
329
|
+
all: values.all === true,
|
|
330
|
+
...values.cursor === void 0 ? {} : { cursor: values.cursor.trim() }
|
|
331
|
+
},
|
|
332
|
+
globals
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
case "vendor": {
|
|
336
|
+
const [vendor, ...extra] = rest;
|
|
337
|
+
if (!vendor?.trim()) return fail("vendor needs a Vendor: its id, domain or name, as in: vendor stripe.com");
|
|
338
|
+
if (extra.length > 0) return fail(`unexpected argument: ${extra[0]}`);
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
command: {
|
|
342
|
+
command,
|
|
343
|
+
vendor: vendor.trim()
|
|
344
|
+
},
|
|
345
|
+
globals
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
case "login":
|
|
349
|
+
case "logout":
|
|
350
|
+
case "key":
|
|
351
|
+
if (rest.length > 0) return fail(`unexpected argument: ${rest[0]}`);
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
command: { command },
|
|
355
|
+
globals
|
|
356
|
+
};
|
|
357
|
+
case void 0: return fail("no command given");
|
|
358
|
+
default: return fail(`unknown command: ${command}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
var SPEC_ID_WORDS = "--spec-id is a Spec id: 64 hex characters";
|
|
362
|
+
/** A name, or an apiId when it has a `/`; none when blank. */
|
|
363
|
+
function apiTargetOf(given) {
|
|
364
|
+
const arg = given?.trim();
|
|
365
|
+
if (!arg) return void 0;
|
|
366
|
+
return arg.includes("/") ? {
|
|
367
|
+
kind: "apiId",
|
|
368
|
+
apiId: arg
|
|
369
|
+
} : {
|
|
370
|
+
kind: "name",
|
|
371
|
+
name: arg
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/** `--spec-id` lowercased; `undefined` when not given, `null` when it isn't a Spec id. */
|
|
375
|
+
function specIdOf(given) {
|
|
376
|
+
if (given === void 0) return void 0;
|
|
377
|
+
return SPEC_ID.test(given.trim()) ? given.trim().toLowerCase() : null;
|
|
378
|
+
}
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/cli/config.ts
|
|
381
|
+
/**
|
|
382
|
+
* Where the config file lives: `SWAGGERBOT_CONFIG` when set; else
|
|
383
|
+
* `%APPDATA%\swaggerbot\config.json` on Windows and
|
|
384
|
+
* `$XDG_CONFIG_HOME/swaggerbot/config.json` (`~/.config/…`) elsewhere.
|
|
385
|
+
*/
|
|
386
|
+
function configPathOf(env, { platform = process.platform, home = homedir() } = {}) {
|
|
387
|
+
const override = env.SWAGGERBOT_CONFIG?.trim();
|
|
388
|
+
if (override) return override;
|
|
389
|
+
const base = platform === "win32" ? env.APPDATA?.trim() || join(home, "AppData", "Roaming") : env.XDG_CONFIG_HOME?.trim() || join(home, ".config");
|
|
390
|
+
return join(base, "swaggerbot", "config.json");
|
|
391
|
+
}
|
|
392
|
+
/** The origin a swagger.bot URL names, the part a key is stored under; undefined when it isn't a URL. */
|
|
393
|
+
function originOf(baseUrl) {
|
|
394
|
+
try {
|
|
395
|
+
return new URL(baseUrl).origin;
|
|
396
|
+
} catch {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The config file as it stands, or an empty one when there is none. A file
|
|
402
|
+
* that isn't JSON is read as empty too, and `note` is told, so no command
|
|
403
|
+
* is blocked by it and the next write replaces it.
|
|
404
|
+
*/
|
|
405
|
+
function readConfig(path, note) {
|
|
406
|
+
let text;
|
|
407
|
+
try {
|
|
408
|
+
text = readFileSync(path, "utf8");
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err.code === "ENOENT") return { keys: {} };
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
413
|
+
let parsed;
|
|
414
|
+
try {
|
|
415
|
+
parsed = JSON.parse(text);
|
|
416
|
+
} catch {
|
|
417
|
+
note(`${path} isn't JSON: read as holding no key; the next \`swaggerbot login\` or \`logout\` replaces it.`);
|
|
418
|
+
return { keys: {} };
|
|
419
|
+
}
|
|
420
|
+
const keys = parsed && typeof parsed === "object" ? parsed.keys : void 0;
|
|
421
|
+
const config = { keys: {} };
|
|
422
|
+
if (keys && typeof keys === "object") {
|
|
423
|
+
for (const [origin, key] of Object.entries(keys)) if (typeof key === "string" && key) config.keys[origin] = key;
|
|
424
|
+
}
|
|
425
|
+
return config;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Writes the config, readable by its owner only (mode 0600, in a 0700
|
|
429
|
+
* directory, made as needed): written whole to a sibling, then moved into
|
|
430
|
+
* place, so a crash leaves the old file, never half of the new.
|
|
431
|
+
*/
|
|
432
|
+
function writeConfig(path, config) {
|
|
433
|
+
mkdirSync(dirname(path), {
|
|
434
|
+
recursive: true,
|
|
435
|
+
mode: 448
|
|
436
|
+
});
|
|
437
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
438
|
+
writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
439
|
+
if (process.platform !== "win32") chmodSync(temp, 384);
|
|
440
|
+
renameSync(temp, path);
|
|
441
|
+
}
|
|
442
|
+
/** The key stored for `baseUrl`'s origin, and no other's; none without one. */
|
|
443
|
+
function storedKeyOf(path, baseUrl, note) {
|
|
444
|
+
const origin = originOf(baseUrl);
|
|
445
|
+
if (!origin) return void 0;
|
|
446
|
+
return readConfig(path, note).keys[origin];
|
|
447
|
+
}
|
|
448
|
+
/** Stores `key` for `baseUrl`'s origin, keeping the other origins' keys. */
|
|
449
|
+
function storeKey(path, baseUrl, key, note) {
|
|
450
|
+
const origin = originOf(baseUrl);
|
|
451
|
+
if (!origin) throw new Error(`${baseUrl} isn't a URL.`);
|
|
452
|
+
const config = readConfig(path, note);
|
|
453
|
+
config.keys[origin] = key;
|
|
454
|
+
writeConfig(path, config);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Removes the key stored for `baseUrl`'s origin: true when there was one.
|
|
458
|
+
* A file that isn't JSON is replaced by an empty config, so `logout` always
|
|
459
|
+
* leaves a config with no key for the origin.
|
|
460
|
+
*/
|
|
461
|
+
function removeKey(path, baseUrl, note) {
|
|
462
|
+
const origin = originOf(baseUrl);
|
|
463
|
+
if (!origin) return false;
|
|
464
|
+
let replace = false;
|
|
465
|
+
const config = readConfig(path, (message) => {
|
|
466
|
+
replace = true;
|
|
467
|
+
note(message);
|
|
468
|
+
});
|
|
469
|
+
const removed = origin in config.keys;
|
|
470
|
+
delete config.keys[origin];
|
|
471
|
+
if (removed || replace) writeConfig(path, config);
|
|
472
|
+
return removed;
|
|
473
|
+
}
|
|
474
|
+
/** Where a Caller gets an API key. */
|
|
475
|
+
var KEYS_URL = "https://swaggerbot.dev/keys";
|
|
476
|
+
/** Said whenever Discovery or `--fresh` is asked for without a key. */
|
|
477
|
+
var NEEDS_KEY_HINT = `Discovery needs an API key. Get one at ${KEYS_URL}, then run \`swaggerbot login\`.`;
|
|
478
|
+
/** A request without a key waits this long; a keyed Lookup (Discovery) longer. */
|
|
479
|
+
var TIMEOUT_MS = 3e4;
|
|
480
|
+
var KEYED_LOOKUP_TIMEOUT_MS = 12e4;
|
|
481
|
+
/** A download waits this long for its headers and again between chunks of its body. */
|
|
482
|
+
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
483
|
+
/**
|
|
484
|
+
* The CLI's origin: `--url`, else `SWAGGERBOT_URL`, else swaggerbot.dev,
|
|
485
|
+
* without a trailing slash.
|
|
486
|
+
*/
|
|
487
|
+
function baseUrlOf(flag, env) {
|
|
488
|
+
return (flag ?? env.SWAGGERBOT_URL?.trim() ?? "").replace(/\/+$/, "") || "https://swaggerbot.dev";
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The key in hand: `--key`, else `SWAGGERBOT_API_KEY`; none without either.
|
|
492
|
+
* The one `swaggerbot login` stored comes after these (D3), read by a
|
|
493
|
+
* command only when it needs a key (`storedKeyOf`).
|
|
494
|
+
*/
|
|
495
|
+
function keyOf(flag, env) {
|
|
496
|
+
return flag ?? (env.SWAGGERBOT_API_KEY?.trim() || void 0);
|
|
497
|
+
}
|
|
498
|
+
/** `swaggerbot-cli/<version> node/<v>`, so the server can tell the CLI apart. */
|
|
499
|
+
function userAgentOf(version) {
|
|
500
|
+
return `swaggerbot-cli/${version} node/${process.versions.node}`;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* `POST <baseUrl><path>` with a JSON body, the bearer when a key is given,
|
|
504
|
+
* and `timeoutMs` to answer, body included. A 2xx is `ok` with the body parsed and its
|
|
505
|
+
* exact text; anything else, or no answer, is the error mapped from the
|
|
506
|
+
* server's body (`error`, `hint`) with a hint for the statuses the CLI
|
|
507
|
+
* knows how to get past.
|
|
508
|
+
*/
|
|
509
|
+
async function postJson(http, path, body, { timeoutMs }) {
|
|
510
|
+
return requestJson(http, path, {
|
|
511
|
+
timeoutMs,
|
|
512
|
+
method: "POST",
|
|
513
|
+
body
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/** `GET <baseUrl><path>`, otherwise as `postJson`. */
|
|
517
|
+
async function getJson(http, path, { timeoutMs }) {
|
|
518
|
+
return requestJson(http, path, {
|
|
519
|
+
timeoutMs,
|
|
520
|
+
method: "GET"
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
async function requestJson(http, path, { timeoutMs, method, body }) {
|
|
524
|
+
const headers = {
|
|
525
|
+
accept: "application/json",
|
|
526
|
+
"user-agent": userAgentOf(http.version)
|
|
527
|
+
};
|
|
528
|
+
if (method === "POST") headers["content-type"] = "application/json";
|
|
529
|
+
if (http.key) headers.authorization = `Bearer ${http.key}`;
|
|
530
|
+
const doFetch = http.fetch ?? globalThis.fetch;
|
|
531
|
+
let response;
|
|
532
|
+
let text;
|
|
533
|
+
try {
|
|
534
|
+
response = await doFetch(`${http.baseUrl}${path}`, {
|
|
535
|
+
method,
|
|
536
|
+
headers,
|
|
537
|
+
...method === "POST" ? { body: JSON.stringify(body) } : {},
|
|
538
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
539
|
+
});
|
|
540
|
+
text = await response.text();
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
error: transportError(err, http.baseUrl, timeoutMs)
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
if (response.ok) try {
|
|
548
|
+
return {
|
|
549
|
+
ok: true,
|
|
550
|
+
status: response.status,
|
|
551
|
+
body: JSON.parse(text),
|
|
552
|
+
text,
|
|
553
|
+
headers: response.headers
|
|
554
|
+
};
|
|
555
|
+
} catch {
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
error: {
|
|
559
|
+
status: response.status,
|
|
560
|
+
error: `${http.baseUrl} answered ${response.status} but not with JSON.`
|
|
561
|
+
},
|
|
562
|
+
text,
|
|
563
|
+
headers: response.headers
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
error: serverError(response, text, http.baseUrl),
|
|
569
|
+
text,
|
|
570
|
+
headers: response.headers
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* A download's answer: the response with its body still to read, or the
|
|
575
|
+
* error as `getJson` gives it. `url` is absolute or a path on the base URL;
|
|
576
|
+
* no key goes with it, as downloads are public and an absolute URL may be
|
|
577
|
+
* another origin's. The headers have `DOWNLOAD_TIMEOUT_MS` to come, and so
|
|
578
|
+
* has each chunk of the body after them: `alive` restarts that wait,
|
|
579
|
+
* `done` stops it.
|
|
580
|
+
*/
|
|
581
|
+
async function getDownload(http, url) {
|
|
582
|
+
const target = /^https?:\/\//i.test(url) ? url : `${http.baseUrl}${url}`;
|
|
583
|
+
const doFetch = http.fetch ?? globalThis.fetch;
|
|
584
|
+
const controller = new AbortController();
|
|
585
|
+
let timer;
|
|
586
|
+
const done = () => clearTimeout(timer);
|
|
587
|
+
const alive = () => {
|
|
588
|
+
done();
|
|
589
|
+
timer = setTimeout(() => controller.abort(new DOMException("The download stalled.", "TimeoutError")), DOWNLOAD_TIMEOUT_MS);
|
|
590
|
+
};
|
|
591
|
+
const failed = (err) => {
|
|
592
|
+
done();
|
|
593
|
+
return {
|
|
594
|
+
ok: false,
|
|
595
|
+
error: transportError(err, http.baseUrl, DOWNLOAD_TIMEOUT_MS)
|
|
596
|
+
};
|
|
597
|
+
};
|
|
598
|
+
alive();
|
|
599
|
+
let response;
|
|
600
|
+
try {
|
|
601
|
+
response = await doFetch(target, {
|
|
602
|
+
headers: { "user-agent": userAgentOf(http.version) },
|
|
603
|
+
signal: controller.signal
|
|
604
|
+
});
|
|
605
|
+
} catch (err) {
|
|
606
|
+
return failed(err);
|
|
607
|
+
}
|
|
608
|
+
if (response.ok) return {
|
|
609
|
+
ok: true,
|
|
610
|
+
response,
|
|
611
|
+
alive,
|
|
612
|
+
done
|
|
613
|
+
};
|
|
614
|
+
let text;
|
|
615
|
+
try {
|
|
616
|
+
text = await response.text();
|
|
617
|
+
} catch (err) {
|
|
618
|
+
return failed(err);
|
|
619
|
+
}
|
|
620
|
+
done();
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: serverError(response, text, http.baseUrl),
|
|
624
|
+
text,
|
|
625
|
+
headers: response.headers
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/** A failed response as the CLI's error: the server's words, plus a way past. */
|
|
629
|
+
function serverError(response, text, baseUrl) {
|
|
630
|
+
const { status } = response;
|
|
631
|
+
let parsed = {};
|
|
632
|
+
try {
|
|
633
|
+
const json = JSON.parse(text);
|
|
634
|
+
if (json && typeof json === "object") parsed = json;
|
|
635
|
+
} catch {}
|
|
636
|
+
const error = typeof parsed.error === "string" && parsed.error ? parsed.error : `${baseUrl} answered ${status}${response.statusText ? ` ${response.statusText}` : ""}.`;
|
|
637
|
+
const serverHint = typeof parsed.hint === "string" ? parsed.hint : void 0;
|
|
638
|
+
const retryAfter = response.headers.get("retry-after");
|
|
639
|
+
switch (status) {
|
|
640
|
+
case 401: return {
|
|
641
|
+
status,
|
|
642
|
+
error,
|
|
643
|
+
hint: /needs an api key/i.test(error) ? NEEDS_KEY_HINT : `Check the key, or get a new one at ${KEYS_URL}, then run \`swaggerbot login\`.`
|
|
644
|
+
};
|
|
645
|
+
case 429: return {
|
|
646
|
+
status,
|
|
647
|
+
error,
|
|
648
|
+
hint: retryAfter ? `Retry in ${retryAfterWords(retryAfter)}.` : serverHint ?? "Retry later."
|
|
649
|
+
};
|
|
650
|
+
case 503: return {
|
|
651
|
+
status,
|
|
652
|
+
error,
|
|
653
|
+
hint: retryAfter ? `Retry in ${retryAfterWords(retryAfter)}.` : serverHint ?? "Retry shortly."
|
|
654
|
+
};
|
|
655
|
+
default: return serverHint ? {
|
|
656
|
+
status,
|
|
657
|
+
error,
|
|
658
|
+
hint: serverHint
|
|
659
|
+
} : {
|
|
660
|
+
status,
|
|
661
|
+
error
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/** No answer at all: refused, unreachable or too slow. Status 0. */
|
|
666
|
+
function transportError(err, baseUrl, timeoutMs) {
|
|
667
|
+
const name = err instanceof Error ? err.name : "";
|
|
668
|
+
if (name === "TimeoutError" || name === "AbortError") return {
|
|
669
|
+
status: 0,
|
|
670
|
+
error: `${baseUrl} did not answer within ${Math.round(timeoutMs / 1e3)} s.`,
|
|
671
|
+
hint: "Retry, or check the service at https://swaggerbot.dev/api/health."
|
|
672
|
+
};
|
|
673
|
+
const cause = err?.cause;
|
|
674
|
+
return {
|
|
675
|
+
status: 0,
|
|
676
|
+
error: `Could not reach ${baseUrl}: ${cause instanceof Error ? cause.message : err instanceof Error ? err.message : String(err)}`,
|
|
677
|
+
hint: "Check the network, or --url / SWAGGERBOT_URL if you set one."
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/** `retry-after` as words: seconds, or an HTTP date's distance from now. */
|
|
681
|
+
function retryAfterWords(value) {
|
|
682
|
+
return durationWords(retryAfterSeconds(value, Date.now()) ?? 0);
|
|
683
|
+
}
|
|
684
|
+
/** `retry-after` in seconds: as given, or an HTTP date's distance from `now`; none when it is neither. */
|
|
685
|
+
function retryAfterSeconds(value, now) {
|
|
686
|
+
if (!value) return void 0;
|
|
687
|
+
if (/^\d+$/.test(value.trim())) return Number(value.trim());
|
|
688
|
+
const at = Date.parse(value);
|
|
689
|
+
return Number.isNaN(at) ? void 0 : Math.ceil((at - now) / 1e3);
|
|
690
|
+
}
|
|
691
|
+
/** A wait in a person's words: `45 s`, `20 min`, `5 h`; `a moment` when it is over or unknown. */
|
|
692
|
+
function durationWords(seconds) {
|
|
693
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return "a moment";
|
|
694
|
+
if (seconds < 90) return `${seconds} s`;
|
|
695
|
+
if (seconds < 5400) return `${Math.ceil(seconds / 60)} min`;
|
|
696
|
+
return `${Math.ceil(seconds / 3600)} h`;
|
|
697
|
+
}
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/cli/render.ts
|
|
700
|
+
/**
|
|
701
|
+
* Whether to colour a stream's output: only when it is a terminal, and never
|
|
702
|
+
* with `--no-color` or `NO_COLOR` set in the environment (https://no-color.org).
|
|
703
|
+
*/
|
|
704
|
+
function colorEnabled(isTTY, env, noColor) {
|
|
705
|
+
if (noColor) return false;
|
|
706
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
707
|
+
return isTTY;
|
|
708
|
+
}
|
|
709
|
+
/** The few ANSI colours the views use; identity when colour is off. */
|
|
710
|
+
function palette(color) {
|
|
711
|
+
const wrap = (open, close) => (text) => color ? `\u001b[${open}m${text}\u001b[${close}m` : text;
|
|
712
|
+
return {
|
|
713
|
+
green: wrap(32, 39),
|
|
714
|
+
yellow: wrap(33, 39),
|
|
715
|
+
red: wrap(31, 39),
|
|
716
|
+
dim: wrap(2, 22),
|
|
717
|
+
bold: wrap(1, 22)
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The CLI's exit codes (D4), the one mapping every command uses: 0 for
|
|
722
|
+
* Resolved or success, one code per other Outcome, 1 for a refusal or a
|
|
723
|
+
* failed request (401, 429, 503, no answer), 2 for a usage error. `--json`
|
|
724
|
+
* exits the same.
|
|
725
|
+
*/
|
|
726
|
+
var EXIT = {
|
|
727
|
+
ok: 0,
|
|
728
|
+
failed: 1,
|
|
729
|
+
usage: 2,
|
|
730
|
+
Ambiguous: 3,
|
|
731
|
+
Unconfirmed: 4,
|
|
732
|
+
NoSpec: 5,
|
|
733
|
+
Unknown: 6
|
|
734
|
+
};
|
|
735
|
+
/** The exit code an Outcome earns: 0 for Resolved, 3 to 6 for the others. */
|
|
736
|
+
function outcomeExitCode(outcome) {
|
|
737
|
+
return outcome.outcome === "Resolved" ? EXIT.ok : EXIT[outcome.outcome];
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* A download URL as the server gives it, made absolute: the server gives a
|
|
741
|
+
* path (`/api/specs/…`) when it has no `PUBLIC_BASE_URL`.
|
|
742
|
+
*/
|
|
743
|
+
function absolute(url, baseUrl) {
|
|
744
|
+
return /^https?:\/\//i.test(url) ? url : `${baseUrl}${url}`;
|
|
745
|
+
}
|
|
746
|
+
/** `2026-09-24T00:07:16.403Z` → `2026-09-24`. */
|
|
747
|
+
function day(timestamp) {
|
|
748
|
+
return timestamp.slice(0, 10);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* One line for the Outcome, the Spec's URL (its Published Form) and the
|
|
752
|
+
* Provenance where there is one; Resolved is that one line alone. Dimmer
|
|
753
|
+
* lines follow for what explains the other Outcomes: candidates, reasons,
|
|
754
|
+
* diagnostics.
|
|
755
|
+
*/
|
|
756
|
+
function renderOutcome(outcome, { baseUrl, color }) {
|
|
757
|
+
const p = palette(color);
|
|
758
|
+
const lines = [];
|
|
759
|
+
switch (outcome.outcome) {
|
|
760
|
+
case "Resolved":
|
|
761
|
+
lines.push([
|
|
762
|
+
p.green(p.bold("Resolved")),
|
|
763
|
+
`${outcome.api.name} (${outcome.api.id})`,
|
|
764
|
+
outcome.provenance,
|
|
765
|
+
absolute(outcome.currentSpec.downloads.published, baseUrl),
|
|
766
|
+
p.dim(`verified ${day(outcome.verifiedAt)}`)
|
|
767
|
+
].join(" "));
|
|
768
|
+
break;
|
|
769
|
+
case "Ambiguous":
|
|
770
|
+
lines.push([p.yellow(p.bold("Ambiguous")), `${outcome.candidates.length} APIs could be meant; say which`].join(" "));
|
|
771
|
+
for (const c of outcome.candidates) lines.push(p.dim(` ${c.name}${c.vendor ? ` (${c.vendor})` : ""}${c.apiId ? ` ${c.apiId}` : ""} ${Math.round(c.probability * 100)}%`));
|
|
772
|
+
break;
|
|
773
|
+
case "Unconfirmed": {
|
|
774
|
+
let best;
|
|
775
|
+
for (const tier of [
|
|
776
|
+
"Official",
|
|
777
|
+
"Endorsed",
|
|
778
|
+
"Mirror",
|
|
779
|
+
"Community"
|
|
780
|
+
]) if (!best && outcome.sources.some((s) => s.provenance === tier)) best = tier;
|
|
781
|
+
lines.push([
|
|
782
|
+
p.yellow(p.bold("Unconfirmed")),
|
|
783
|
+
`${outcome.api.name} (${outcome.api.id})`,
|
|
784
|
+
best ?? "",
|
|
785
|
+
absolute(outcome.spec.downloads.published, baseUrl),
|
|
786
|
+
p.dim(`verified ${day(outcome.verifiedAt)}`)
|
|
787
|
+
].filter(Boolean).join(" "));
|
|
788
|
+
lines.push(p.dim(` not confirmed to describe it: ${outcome.reasons.join("; ")}`));
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
case "NoSpec":
|
|
792
|
+
lines.push([
|
|
793
|
+
p.red(p.bold("No Spec")),
|
|
794
|
+
`${outcome.api.name} (${outcome.api.id})`,
|
|
795
|
+
outcome.communityAvailable ? "a Community Spec exists: add --community to allow it" : "no Spec at an allowed Provenance"
|
|
796
|
+
].join(" "));
|
|
797
|
+
break;
|
|
798
|
+
case "Unknown": lines.push([p.red(p.bold("Unknown")), `no API matches "${outcome.name}"`].join(" "));
|
|
799
|
+
}
|
|
800
|
+
if (outcome.diagnostics?.length) lines.push(p.dim(` diagnostics: ${outcome.diagnostics.join(" | ")}`));
|
|
801
|
+
return `${lines.join("\n")}\n`;
|
|
802
|
+
}
|
|
803
|
+
/** A size as a person reads it: `512 B`, `72.5 kB`, `6.1 MB`. */
|
|
804
|
+
function sizeWords(bytes) {
|
|
805
|
+
if (bytes < 1e3) return `${bytes} B`;
|
|
806
|
+
if (bytes < 1e6) return `${(bytes / 1e3).toFixed(1)} kB`;
|
|
807
|
+
return `${(bytes / 1e6).toFixed(1)} MB`;
|
|
808
|
+
}
|
|
809
|
+
/** `87 credits left today`; `1 credit`; `no credits left today`. */
|
|
810
|
+
function creditsWords(remaining) {
|
|
811
|
+
if (remaining <= 0) return "no credits left today";
|
|
812
|
+
return `${remaining} credit${remaining === 1 ? "" : "s"} left today`;
|
|
813
|
+
}
|
|
814
|
+
/** `resets at 00:00 UTC, in 5 h`, from when the credits are next refilled. */
|
|
815
|
+
function resetWords(resetsAt, now) {
|
|
816
|
+
const at = new Date(resetsAt);
|
|
817
|
+
if (Number.isNaN(at.getTime())) return "";
|
|
818
|
+
return `resets at ${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")} UTC, in ${durationWords(Math.ceil((at.getTime() - now.getTime()) / 1e3))}`;
|
|
819
|
+
}
|
|
820
|
+
/** A failed request, for stderr: what went wrong, then what fixes it. */
|
|
821
|
+
function renderError(error, color) {
|
|
822
|
+
const p = palette(color);
|
|
823
|
+
const lines = [`${p.red(p.bold("error"))} ${error.error}`];
|
|
824
|
+
if (error.hint) lines.push(` ${error.hint}`);
|
|
825
|
+
return `${lines.join("\n")}\n`;
|
|
826
|
+
}
|
|
827
|
+
/** A warning, for stderr: something the command went on past. */
|
|
828
|
+
function renderWarning(message, color) {
|
|
829
|
+
const p = palette(color);
|
|
830
|
+
return `${p.yellow(p.bold("warning"))} ${message}\n`;
|
|
831
|
+
}
|
|
832
|
+
/** A usage error, for stderr, with where to read the usage. */
|
|
833
|
+
function renderUsageError(message, color) {
|
|
834
|
+
const p = palette(color);
|
|
835
|
+
return `${p.red(p.bold("usage error"))} ${message}\n Run \`swaggerbot --help\` for the usage.\n`;
|
|
836
|
+
}
|
|
837
|
+
/** The widest the method, path and operationId columns are padded to. */
|
|
838
|
+
var OUTLINE_COLUMN_CAPS = [
|
|
839
|
+
7,
|
|
840
|
+
48,
|
|
841
|
+
36
|
|
842
|
+
];
|
|
843
|
+
/**
|
|
844
|
+
* The Spec Outline as a person reads it: the title and API Version, the
|
|
845
|
+
* servers and security schemes, the tags with their operation counts (the
|
|
846
|
+
* `SHOWN_TAGS` largest unless `allTags`), then the operations as a table,
|
|
847
|
+
* `METHOD path operationId summary`, and how many of how many there are,
|
|
848
|
+
* with the cursor of the next page when there is one.
|
|
849
|
+
*/
|
|
850
|
+
function renderOutline(page, { allTags, color }) {
|
|
851
|
+
const p = palette(color);
|
|
852
|
+
const lines = [];
|
|
853
|
+
lines.push([
|
|
854
|
+
p.bold(clean(page.title ?? page.apiId)),
|
|
855
|
+
page.apiVersion === null ? "" : `API Version ${clean(page.apiVersion)}`,
|
|
856
|
+
p.dim(page.apiId)
|
|
857
|
+
].filter(Boolean).join(" "));
|
|
858
|
+
const field = (name, value) => lines.push(`${p.dim(name.padEnd(9))} ${value}`);
|
|
859
|
+
field("servers", page.servers.length > 0 ? page.servers.map(clean).join(" ") : "none");
|
|
860
|
+
field("security", page.securitySchemes.length > 0 ? page.securitySchemes.map((s) => `${clean(s.name)} (${[
|
|
861
|
+
s.type,
|
|
862
|
+
s.scheme,
|
|
863
|
+
s.in && `in ${s.in}`
|
|
864
|
+
].filter(Boolean).map((w) => clean(w)).join(" ")})`).join(", ") : "none");
|
|
865
|
+
const tags = [...page.tags].sort((a, b) => b.operationCount - a.operationCount);
|
|
866
|
+
const shown = allTags ? tags : tags.slice(0, 20);
|
|
867
|
+
const more = [tags.length > shown.length ? `+${tags.length - shown.length} more: --all lists them` : "", page.tagsCut ? "the service sent only the largest" : ""].filter(Boolean);
|
|
868
|
+
field("tags", shown.length > 0 ? `${shown.map((t) => `${clean(t.name)} ${t.operationCount}`).join(", ")}${more.length > 0 ? p.dim(` (${more.join("; ")})`) : ""}` : "none");
|
|
869
|
+
if (page.operations.length > 0) {
|
|
870
|
+
lines.push("");
|
|
871
|
+
const rows = page.operations.map((op) => [
|
|
872
|
+
op.method.toUpperCase(),
|
|
873
|
+
clean(op.path),
|
|
874
|
+
clean(op.operationId ?? ""),
|
|
875
|
+
`${clean(op.summary ?? "")}${op.deprecated ? p.dim(" (deprecated)") : ""}`
|
|
876
|
+
]);
|
|
877
|
+
const widths = OUTLINE_COLUMN_CAPS.map((cap, i) => Math.min(cap, Math.max(...rows.map((row) => row[i]?.length ?? 0))));
|
|
878
|
+
for (const [method = "", path = "", operationId = "", summary = ""] of rows) lines.push([
|
|
879
|
+
p.bold(method.padEnd(widths[0] ?? 0)),
|
|
880
|
+
path.padEnd(widths[1] ?? 0),
|
|
881
|
+
p.dim(operationId.padEnd(widths[2] ?? 0)),
|
|
882
|
+
summary
|
|
883
|
+
].join(" ").trimEnd());
|
|
884
|
+
}
|
|
885
|
+
lines.push("");
|
|
886
|
+
const { totalOperations: total, matchedOperations: matched } = page;
|
|
887
|
+
const count = matched === total ? `${page.operations.length} of ${total} operations` : `${page.operations.length} of ${matched} matching operations (${total} in all)`;
|
|
888
|
+
lines.push(page.nextCursor === null ? count : `${count}; next page: --cursor ${page.nextCursor}`);
|
|
889
|
+
return `${lines.join("\n")}\n`;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* One operation: a heading, `METHOD path · operationId · summary`, then
|
|
893
|
+
* the operation as pretty JSON (it is a JSON object; a prose view would
|
|
894
|
+
* invent structure), then the security schemes it names and the schemas
|
|
895
|
+
* that recur within themselves, each as JSON; `truncated` and `circular`
|
|
896
|
+
* are said under the heading.
|
|
897
|
+
*/
|
|
898
|
+
function renderOperation(body, color) {
|
|
899
|
+
const p = palette(color);
|
|
900
|
+
const { operationId, summary } = body.operation;
|
|
901
|
+
const lines = [
|
|
902
|
+
[
|
|
903
|
+
p.bold(`${body.method.toUpperCase()} ${clean(body.path)}`),
|
|
904
|
+
typeof operationId === "string" ? clean(operationId) : "",
|
|
905
|
+
typeof summary === "string" ? clean(summary) : ""
|
|
906
|
+
].filter(Boolean).join(" · "),
|
|
907
|
+
...expansionNotes(body, p),
|
|
908
|
+
prettyJson(body.operation)
|
|
909
|
+
];
|
|
910
|
+
if (Object.keys(body.securitySchemes).length > 0) lines.push(p.dim("security schemes"), prettyJson(body.securitySchemes));
|
|
911
|
+
if (Object.keys(body.circular).length > 0) lines.push(p.dim("circular"), prettyJson(body.circular));
|
|
912
|
+
return `${lines.join("\n")}\n`;
|
|
913
|
+
}
|
|
914
|
+
/** One schema: a heading, its name and API, then the schema as pretty JSON, as `renderOperation`. */
|
|
915
|
+
function renderSchema(body, color) {
|
|
916
|
+
const p = palette(color);
|
|
917
|
+
const lines = [
|
|
918
|
+
`${p.bold(clean(body.name))} · ${p.dim(`components.schemas of ${body.apiId}`)}`,
|
|
919
|
+
...expansionNotes(body, p),
|
|
920
|
+
prettyJson(body.schema)
|
|
921
|
+
];
|
|
922
|
+
if (Object.keys(body.circular).length > 0) lines.push(p.dim("circular"), prettyJson(body.circular));
|
|
923
|
+
return `${lines.join("\n")}\n`;
|
|
924
|
+
}
|
|
925
|
+
/** What `truncated` and `circular` mean, said only when they apply. */
|
|
926
|
+
function expansionNotes({ truncated, circular }, p) {
|
|
927
|
+
const notes = [];
|
|
928
|
+
if (truncated) notes.push(p.yellow("truncated: inlining stopped at the size cap; each reference left is {\"$ref\", \"x-truncated\": true}"));
|
|
929
|
+
const names = Object.keys(circular);
|
|
930
|
+
if (names.length > 0) notes.push(p.dim(`circular: ${names.map(clean).join(", ")} ${names.length === 1 ? "recurs within itself" : "recur within themselves"}; each is under "circular" once, and {"$ref", "x-circular": true} where it recurs`));
|
|
931
|
+
return notes;
|
|
932
|
+
}
|
|
933
|
+
/** The widest the id and name columns of the Vendor list are padded to. */
|
|
934
|
+
var VENDOR_COLUMN_CAPS = [32, 32];
|
|
935
|
+
/**
|
|
936
|
+
* The Vendor list as a person reads it: a table of `id name N APIs`, then
|
|
937
|
+
* how many of how many there are, with the cursor of the next page when
|
|
938
|
+
* there is one.
|
|
939
|
+
*/
|
|
940
|
+
function renderVendors(page, { query, color }) {
|
|
941
|
+
const p = palette(color);
|
|
942
|
+
const lines = [];
|
|
943
|
+
const rows = page.vendors.map((v) => [
|
|
944
|
+
clean(v.id),
|
|
945
|
+
clean(v.name),
|
|
946
|
+
`${v.apiCount} API${v.apiCount === 1 ? "" : "s"}`
|
|
947
|
+
]);
|
|
948
|
+
const widths = VENDOR_COLUMN_CAPS.map((cap, i) => Math.min(cap, Math.max(...rows.map((row) => row[i]?.length ?? 0))));
|
|
949
|
+
for (const [id = "", name = "", count = ""] of rows) lines.push([
|
|
950
|
+
p.bold(id.padEnd(widths[0] ?? 0)),
|
|
951
|
+
name.padEnd(widths[1] ?? 0),
|
|
952
|
+
p.dim(count)
|
|
953
|
+
].join(" "));
|
|
954
|
+
if (lines.length > 0) lines.push("");
|
|
955
|
+
const count = `${page.vendors.length} of ${page.total} Vendors${query === void 0 ? "" : ` matching "${clean(query)}"`}`;
|
|
956
|
+
lines.push(page.nextCursor === null ? count : `${count}; --cursor ${page.nextCursor} for the next page`);
|
|
957
|
+
return `${lines.join("\n")}\n`;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* A Vendor's APIs as a person reads them: the Vendor, then each API with its
|
|
961
|
+
* id, its Current Spec (API Version, format, Provenance, when it was
|
|
962
|
+
* verified) and both download URLs, or that the Index has no confirmed Spec
|
|
963
|
+
* of it yet.
|
|
964
|
+
*/
|
|
965
|
+
function renderVendorApis(body, { baseUrl, color }) {
|
|
966
|
+
const p = palette(color);
|
|
967
|
+
const lines = [`${p.bold(clean(body.vendor.name))} ${p.dim(clean(body.vendor.id))}`];
|
|
968
|
+
for (const { api, currentSpec, provenance, verifiedAt } of body.apis) {
|
|
969
|
+
lines.push("", `${p.bold(clean(api.name))} ${clean(api.id)}`);
|
|
970
|
+
if (!currentSpec) {
|
|
971
|
+
lines.push(p.dim(" no confirmed Spec in the Index yet"));
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
lines.push(` ${[
|
|
975
|
+
currentSpec.apiVersion === null ? "no API Version" : `API Version ${clean(currentSpec.apiVersion)}`,
|
|
976
|
+
currentSpec.format,
|
|
977
|
+
provenance ?? "",
|
|
978
|
+
verifiedAt ? p.dim(`verified ${day(verifiedAt)}`) : ""
|
|
979
|
+
].filter(Boolean).join(" ")}`, ` ${p.dim("published ")} ${absolute(currentSpec.downloads.published, baseUrl)}`, ` ${p.dim("normalized")} ${absolute(currentSpec.downloads.normalized, baseUrl)}${currentSpec.normalized === "ready" ? "" : p.dim(currentSpec.normalized === "pending" ? " (being built)" : " (couldn't be built)")}`);
|
|
980
|
+
}
|
|
981
|
+
lines.push("", `${body.apis.length} API${body.apis.length === 1 ? "" : "s"} in the Index`);
|
|
982
|
+
return `${lines.join("\n")}\n`;
|
|
983
|
+
}
|
|
984
|
+
/** The Vendors a name could mean, as `lookup` shows an Ambiguous Outcome. */
|
|
985
|
+
function renderVendorMatches(body, { vendor, color }) {
|
|
986
|
+
const p = palette(color);
|
|
987
|
+
const width = Math.max(...body.vendors.map((v) => clean(v.id).length));
|
|
988
|
+
return `${[
|
|
989
|
+
[p.yellow(p.bold("Ambiguous")), `${body.vendors.length} Vendors match "${clean(vendor)}"; say which`].join(" "),
|
|
990
|
+
...body.vendors.map((v) => p.dim(` ${clean(v.id).padEnd(width)} ${clean(v.name)}`)),
|
|
991
|
+
p.dim(" `swaggerbot vendor <id>` lists one")
|
|
992
|
+
].join("\n")}\n`;
|
|
993
|
+
}
|
|
994
|
+
/** A Vendor the Index doesn't hold, as `lookup` shows an Unknown Outcome, and how to add one. */
|
|
995
|
+
function renderUnknownVendor(vendor, color) {
|
|
996
|
+
const p = palette(color);
|
|
997
|
+
return `${[[p.red(p.bold("Unknown")), `no Vendor "${clean(vendor)}" in the Index`].join(" "), p.dim(" Only APIs already in the Index are listed: `swaggerbot lookup <name>` finds an API and adds it, with its Vendor, to the Index.")].join("\n")}\n`;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* `value` as JSON indented by two, safe on a terminal: `JSON.stringify`
|
|
1001
|
+
* escapes C0 controls itself, and DEL and the C1 controls are escaped too.
|
|
1002
|
+
*/
|
|
1003
|
+
function prettyJson(value) {
|
|
1004
|
+
return JSON.stringify(value, null, 2).replace(/[\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1005
|
+
}
|
|
1006
|
+
/** A Spec's text for one line of a terminal: runs of whitespace as one space, control characters gone. */
|
|
1007
|
+
function clean(text) {
|
|
1008
|
+
return text.replace(/\s+/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/cli/commands/lookup.ts
|
|
1012
|
+
/**
|
|
1013
|
+
* `swaggerbot lookup <name>`: `POST /api/lookup`, then one line per Outcome
|
|
1014
|
+
* (or the body as is with `--json`). Exit 0 for Resolved, 3 to 6 for the
|
|
1015
|
+
* other Outcomes, 1 when the request failed (`EXIT`). The key in hand goes with the
|
|
1016
|
+
* request; the stored one is read only when a key is needed: for `--fresh`
|
|
1017
|
+
* before sending, else when the server answers 401 for a name the Index
|
|
1018
|
+
* can't answer, and the Lookup is sent again with it. `--fresh` with no
|
|
1019
|
+
* key at all says what the server would, and sends nothing; with `--json`
|
|
1020
|
+
* the request goes out so stdout carries the server's own answer.
|
|
1021
|
+
*/
|
|
1022
|
+
async function runLookup(args, { http, configPath, note, globals, io, color, stderrColor }) {
|
|
1023
|
+
const stored = () => storedKeyOf(configPath, http.baseUrl, note);
|
|
1024
|
+
let keyed = http;
|
|
1025
|
+
if (args.fresh && !http.key) keyed = {
|
|
1026
|
+
...http,
|
|
1027
|
+
key: stored()
|
|
1028
|
+
};
|
|
1029
|
+
if (args.fresh && !keyed.key && !globals.json) return failed({ error: {
|
|
1030
|
+
status: 401,
|
|
1031
|
+
error: "--fresh needs an API key.",
|
|
1032
|
+
hint: NEEDS_KEY_HINT
|
|
1033
|
+
} }, globals, io, stderrColor);
|
|
1034
|
+
const result = await requestLookup({
|
|
1035
|
+
name: args.name,
|
|
1036
|
+
...args.apiVersion === void 0 ? {} : { apiVersion: args.apiVersion },
|
|
1037
|
+
...args.community ? { allowCommunity: true } : {},
|
|
1038
|
+
...args.fresh ? { fresh: true } : {}
|
|
1039
|
+
}, keyed, {
|
|
1040
|
+
retryWithStored: !args.fresh,
|
|
1041
|
+
stored
|
|
1042
|
+
});
|
|
1043
|
+
if (!result.ok) return failed(result, globals, io, stderrColor);
|
|
1044
|
+
return printOutcome(result, {
|
|
1045
|
+
http,
|
|
1046
|
+
globals,
|
|
1047
|
+
io,
|
|
1048
|
+
color
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* `POST /api/lookup` with the key in `http`, if any; a 401 for a Lookup sent
|
|
1053
|
+
* without one is sent again with the stored key (`stored`) when
|
|
1054
|
+
* `retryWithStored` and there is one.
|
|
1055
|
+
*/
|
|
1056
|
+
async function requestLookup(body, http, { retryWithStored, stored }) {
|
|
1057
|
+
const post = (through) => postJson(through, "/api/lookup", body, { timeoutMs: through.key ? KEYED_LOOKUP_TIMEOUT_MS : TIMEOUT_MS });
|
|
1058
|
+
const result = await post(http);
|
|
1059
|
+
if (!result.ok && result.error.status === 401 && !http.key && retryWithStored) {
|
|
1060
|
+
const key = stored();
|
|
1061
|
+
if (key) return post({
|
|
1062
|
+
...http,
|
|
1063
|
+
key
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return result;
|
|
1067
|
+
}
|
|
1068
|
+
/** The Outcome as `lookup` prints it (or its body as is with `--json`), and its exit code. */
|
|
1069
|
+
function printOutcome(result, { http, globals, io, color }) {
|
|
1070
|
+
if (globals.json) io.stdout.write(`${result.text}\n`);
|
|
1071
|
+
else io.stdout.write(renderOutcome(result.body, {
|
|
1072
|
+
baseUrl: http.baseUrl,
|
|
1073
|
+
color
|
|
1074
|
+
}));
|
|
1075
|
+
return outcomeExitCode(result.body);
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* A failed request. With `--json`, stdout gets the server's body exactly as
|
|
1079
|
+
* it answered, or `{"status":0,"error":…}` when no answer came; otherwise
|
|
1080
|
+
* stderr gets the error for a person. Exit 1.
|
|
1081
|
+
*/
|
|
1082
|
+
function failed({ error, text }, globals, io, color) {
|
|
1083
|
+
if (globals.json) io.stdout.write(`${text ?? JSON.stringify({
|
|
1084
|
+
status: error.status,
|
|
1085
|
+
error: error.error
|
|
1086
|
+
})}\n`);
|
|
1087
|
+
else io.stderr.write(renderError(error, color));
|
|
1088
|
+
return EXIT.failed;
|
|
1089
|
+
}
|
|
1090
|
+
//#endregion
|
|
1091
|
+
//#region src/cli/commands/key.ts
|
|
1092
|
+
/**
|
|
1093
|
+
* `swaggerbot key`: `GET /api/key` with the key in use (`--key`, else
|
|
1094
|
+
* `SWAGGERBOT_API_KEY`, else the one `login` stored for this origin), and
|
|
1095
|
+
* one line: its id, the credits left today and when they reset. Without a
|
|
1096
|
+
* key it says so. Exit 0, or 1 when there is no key or the request failed.
|
|
1097
|
+
*/
|
|
1098
|
+
async function runKey(_args, { http, configPath, note, globals, io, color, stderrColor }) {
|
|
1099
|
+
const key = http.key ?? storedKeyOf(configPath, http.baseUrl, note);
|
|
1100
|
+
if (!key) return failed({ error: {
|
|
1101
|
+
status: 0,
|
|
1102
|
+
error: `No API key is configured for ${http.baseUrl}.`,
|
|
1103
|
+
hint: `Get one at ${KEYS_URL}, then run \`swaggerbot login\`.`
|
|
1104
|
+
} }, globals, io, stderrColor);
|
|
1105
|
+
const result = await getJson({
|
|
1106
|
+
...http,
|
|
1107
|
+
key
|
|
1108
|
+
}, "/api/key", { timeoutMs: TIMEOUT_MS });
|
|
1109
|
+
if (!result.ok) return failed(result, globals, io, stderrColor);
|
|
1110
|
+
if (globals.json) {
|
|
1111
|
+
io.stdout.write(`${result.text}\n`);
|
|
1112
|
+
return 0;
|
|
1113
|
+
}
|
|
1114
|
+
const p = palette(color);
|
|
1115
|
+
const { id, remaining, limit, resetsAt } = result.body;
|
|
1116
|
+
const now = io.now?.() ?? /* @__PURE__ */ new Date();
|
|
1117
|
+
const parts = [p.bold(id)];
|
|
1118
|
+
if (remaining === void 0) parts.push("credits unknown");
|
|
1119
|
+
else parts.push(`${creditsWords(remaining)}${limit === void 0 ? "" : ` of ${limit}`}`);
|
|
1120
|
+
const reset = resetsAt ? resetWords(resetsAt, now) : "";
|
|
1121
|
+
if (reset) parts.push(p.dim(reset));
|
|
1122
|
+
io.stdout.write(`${parts.join(" ")}\n`);
|
|
1123
|
+
return 0;
|
|
1124
|
+
}
|
|
1125
|
+
//#endregion
|
|
1126
|
+
//#region src/cli/commands/login.ts
|
|
1127
|
+
/** Said when a key is needed and the person has none in hand. */
|
|
1128
|
+
var NO_KEY_GIVEN = `No key given. Get one at ${KEYS_URL} and pass it with --key, or pipe it in.`;
|
|
1129
|
+
/**
|
|
1130
|
+
* `swaggerbot login [--key K]`: the key from `--key`, else prompted for
|
|
1131
|
+
* (hidden on a terminal, read from a pipe otherwise), checked with
|
|
1132
|
+
* `GET /api/key` at no cost, then stored for this swagger.bot's origin and
|
|
1133
|
+
* no other's. A key that isn't live (401) is said so and stored nowhere;
|
|
1134
|
+
* a key store outage (503) is said with when to retry. The key is never
|
|
1135
|
+
* echoed. Exit 0 when signed in, 1 when the request failed, 2 without a
|
|
1136
|
+
* key to check.
|
|
1137
|
+
*/
|
|
1138
|
+
async function runLogin(_args, { http, configPath, note, globals, io, color, stderrColor }) {
|
|
1139
|
+
const key = globals.key ?? await promptKey(io);
|
|
1140
|
+
if (!key) {
|
|
1141
|
+
io.stderr.write(`${palette(stderrColor).red(palette(stderrColor).bold("error"))} ${NO_KEY_GIVEN}\n`);
|
|
1142
|
+
return 2;
|
|
1143
|
+
}
|
|
1144
|
+
const result = await getJson({
|
|
1145
|
+
...http,
|
|
1146
|
+
key
|
|
1147
|
+
}, "/api/key", { timeoutMs: TIMEOUT_MS });
|
|
1148
|
+
if (!result.ok) return failed({
|
|
1149
|
+
error: result.error.status === 401 ? {
|
|
1150
|
+
...result.error,
|
|
1151
|
+
error: "That key isn't live."
|
|
1152
|
+
} : result.error,
|
|
1153
|
+
text: result.text
|
|
1154
|
+
}, globals, io, stderrColor);
|
|
1155
|
+
storeKey(configPath, http.baseUrl, key, note);
|
|
1156
|
+
if (globals.json) {
|
|
1157
|
+
io.stdout.write(`${result.text}\n`);
|
|
1158
|
+
return 0;
|
|
1159
|
+
}
|
|
1160
|
+
const p = palette(color);
|
|
1161
|
+
const { id, remaining } = result.body;
|
|
1162
|
+
const credits = remaining === void 0 ? "" : `: ${creditsWords(remaining)}`;
|
|
1163
|
+
io.stdout.write(`${p.green(p.bold("Signed in"))} to ${http.baseUrl} as ${id}${credits}\n`);
|
|
1164
|
+
return 0;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* The key typed at a terminal, hidden (readline writes its echo to a sink,
|
|
1168
|
+
* so nothing shows), or the first line piped in; empty when neither gives
|
|
1169
|
+
* one, or when the prompt is left with Ctrl-C or Ctrl-D.
|
|
1170
|
+
*/
|
|
1171
|
+
async function promptKey(io) {
|
|
1172
|
+
const { stdin } = io;
|
|
1173
|
+
if (!stdin) return "";
|
|
1174
|
+
if (stdin.isTTY) {
|
|
1175
|
+
const muted = new Writable({ write(_chunk, _encoding, callback) {
|
|
1176
|
+
callback();
|
|
1177
|
+
} });
|
|
1178
|
+
const rl = createInterface({
|
|
1179
|
+
input: stdin,
|
|
1180
|
+
output: muted,
|
|
1181
|
+
terminal: true
|
|
1182
|
+
});
|
|
1183
|
+
rl.on("SIGINT", () => rl.close());
|
|
1184
|
+
io.stderr.write("API key (hidden): ");
|
|
1185
|
+
try {
|
|
1186
|
+
return (await new Promise((resolve) => {
|
|
1187
|
+
rl.once("close", () => resolve(""));
|
|
1188
|
+
rl.question("", (answer) => resolve(answer));
|
|
1189
|
+
})).trim();
|
|
1190
|
+
} finally {
|
|
1191
|
+
rl.close();
|
|
1192
|
+
io.stderr.write("\n");
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const rl = createInterface({
|
|
1196
|
+
input: stdin,
|
|
1197
|
+
terminal: false
|
|
1198
|
+
});
|
|
1199
|
+
try {
|
|
1200
|
+
for await (const line of rl) {
|
|
1201
|
+
const key = line.trim();
|
|
1202
|
+
if (key) return key;
|
|
1203
|
+
}
|
|
1204
|
+
return "";
|
|
1205
|
+
} finally {
|
|
1206
|
+
rl.close();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
//#endregion
|
|
1210
|
+
//#region src/cli/commands/logout.ts
|
|
1211
|
+
/**
|
|
1212
|
+
* `swaggerbot logout`: removes the key `login` stored for this
|
|
1213
|
+
* swagger.bot's origin, and says when there was none; a config file that
|
|
1214
|
+
* isn't JSON is replaced by an empty one. Exit 0 either way. With `--json`,
|
|
1215
|
+
* `{ "url", "removed" }`.
|
|
1216
|
+
*/
|
|
1217
|
+
async function runLogout(_args, { http, configPath, note, globals, io, color }) {
|
|
1218
|
+
const removed = removeKey(configPath, http.baseUrl, note);
|
|
1219
|
+
if (globals.json) {
|
|
1220
|
+
io.stdout.write(`${JSON.stringify({
|
|
1221
|
+
url: http.baseUrl,
|
|
1222
|
+
removed
|
|
1223
|
+
})}\n`);
|
|
1224
|
+
return 0;
|
|
1225
|
+
}
|
|
1226
|
+
const p = palette(color);
|
|
1227
|
+
io.stdout.write(removed ? `${p.green(p.bold("Signed out"))} of ${http.baseUrl}: the stored key is forgotten\n` : `${p.dim("No key was stored")} for ${http.baseUrl}; nothing to forget\n`);
|
|
1228
|
+
return 0;
|
|
1229
|
+
}
|
|
1230
|
+
//#endregion
|
|
1231
|
+
//#region src/cli/commands/navigate.ts
|
|
1232
|
+
/**
|
|
1233
|
+
* The apiId `target` names: an apiId as is; a name through a Lookup, as
|
|
1234
|
+
* `spec` makes one, Resolved only. Any other Outcome is printed as `lookup`
|
|
1235
|
+
* prints it, and its exit code is the answer, as is a failed Lookup's.
|
|
1236
|
+
*/
|
|
1237
|
+
async function apiIdOf(target, context) {
|
|
1238
|
+
if (target.kind === "apiId") return target.apiId;
|
|
1239
|
+
const { http, configPath, note, globals, io, stderrColor } = context;
|
|
1240
|
+
const result = await requestLookup({ name: target.name }, http, {
|
|
1241
|
+
retryWithStored: true,
|
|
1242
|
+
stored: () => storedKeyOf(configPath, http.baseUrl, note)
|
|
1243
|
+
});
|
|
1244
|
+
if (!result.ok) return failed(result, globals, io, stderrColor);
|
|
1245
|
+
if (result.body.outcome !== "Resolved") return printOutcome(result, context);
|
|
1246
|
+
return result.body.api.id;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* `GET /api/apis/{apiId}/{resource}?{params}`, the apiId keeping its slash.
|
|
1250
|
+
* A failure is printed (`failed`: the server's body as is with `--json`)
|
|
1251
|
+
* and its exit code is the answer. 409 and 422 say the Spec's forms are
|
|
1252
|
+
* pending or failed, as the server answers them; `what` names what was asked
|
|
1253
|
+
* for, as in "The operation". `notFoundHint` gives a 404 the hint read from
|
|
1254
|
+
* its body, when the server's own hint isn't enough.
|
|
1255
|
+
*/
|
|
1256
|
+
async function getNavigation({ apiId, resource, params, what, notFoundHint }, { http, globals, io, stderrColor }) {
|
|
1257
|
+
const query = new URLSearchParams();
|
|
1258
|
+
for (const [name, value] of Object.entries(params)) if (value !== void 0) query.set(name, value);
|
|
1259
|
+
const result = await getJson(http, `/api/apis/${apiId.split("/").map(encodeURIComponent).join("/")}/${resource}${query.size > 0 ? `?${query}` : ""}`, { timeoutMs: TIMEOUT_MS });
|
|
1260
|
+
if (result.ok) return result;
|
|
1261
|
+
const { status } = result.error;
|
|
1262
|
+
if (status === 409) {
|
|
1263
|
+
const retry = retryAfterSeconds(result.headers?.get("retry-after"), (io.now?.() ?? /* @__PURE__ */ new Date()).getTime());
|
|
1264
|
+
return failed({
|
|
1265
|
+
...result,
|
|
1266
|
+
error: {
|
|
1267
|
+
status,
|
|
1268
|
+
error: `${what} can't be read yet: the Spec's forms are pending (still being built).`,
|
|
1269
|
+
hint: `Retry in ${durationWords(retry ?? 0)}.`
|
|
1270
|
+
}
|
|
1271
|
+
}, globals, io, stderrColor);
|
|
1272
|
+
}
|
|
1273
|
+
if (status === 422) return failed({
|
|
1274
|
+
...result,
|
|
1275
|
+
error: {
|
|
1276
|
+
status,
|
|
1277
|
+
error: `The Spec's forms failed: ${result.error.error}`,
|
|
1278
|
+
hint: "So it has no Spec Outline or Normalized Form to read; `swaggerbot spec` still downloads its Published Form."
|
|
1279
|
+
}
|
|
1280
|
+
}, globals, io, stderrColor);
|
|
1281
|
+
if (status === 404 && notFoundHint) {
|
|
1282
|
+
let body;
|
|
1283
|
+
try {
|
|
1284
|
+
body = JSON.parse(result.text ?? "");
|
|
1285
|
+
} catch {}
|
|
1286
|
+
const hint = notFoundHint(body);
|
|
1287
|
+
if (hint) return failed({
|
|
1288
|
+
...result,
|
|
1289
|
+
error: {
|
|
1290
|
+
...result.error,
|
|
1291
|
+
hint
|
|
1292
|
+
}
|
|
1293
|
+
}, globals, io, stderrColor);
|
|
1294
|
+
}
|
|
1295
|
+
return failed(result, globals, io, stderrColor);
|
|
1296
|
+
}
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region src/cli/commands/operation.ts
|
|
1299
|
+
/**
|
|
1300
|
+
* `swaggerbot operation <name | apiId> <method> <path>`:
|
|
1301
|
+
* `GET /api/apis/{apiId}/operation?method=&path=[&specId=]`, the operation
|
|
1302
|
+
* with every reference it reaches inlined, as `renderOperation` shows it. A
|
|
1303
|
+
* name is looked up first, as `spec` does. `--json` prints the server's
|
|
1304
|
+
* body unchanged. An operation not in the Spec is the server's 404 and its
|
|
1305
|
+
* hint (the outline lists the paths), exit 1, as for any failed request.
|
|
1306
|
+
*/
|
|
1307
|
+
async function runOperation(args, context) {
|
|
1308
|
+
const { globals, io, color } = context;
|
|
1309
|
+
const apiId = await apiIdOf(args.target, context);
|
|
1310
|
+
if (typeof apiId === "number") return apiId;
|
|
1311
|
+
const result = await getNavigation({
|
|
1312
|
+
apiId,
|
|
1313
|
+
resource: "operation",
|
|
1314
|
+
params: {
|
|
1315
|
+
method: args.method,
|
|
1316
|
+
path: args.path,
|
|
1317
|
+
specId: args.specId
|
|
1318
|
+
},
|
|
1319
|
+
what: "The operation"
|
|
1320
|
+
}, context);
|
|
1321
|
+
if (typeof result === "number") return result;
|
|
1322
|
+
io.stdout.write(globals.json ? `${result.text}\n` : renderOperation(result.body, color));
|
|
1323
|
+
return EXIT.ok;
|
|
1324
|
+
}
|
|
1325
|
+
/** Operations a page when `--all` follows every page and `--limit` isn't given: the most the route allows. */
|
|
1326
|
+
var ALL_PAGE_LIMIT$1 = 500;
|
|
1327
|
+
/**
|
|
1328
|
+
* `swaggerbot outline <name | apiId>`: `GET /api/apis/{apiId}/outline`, one
|
|
1329
|
+
* page of the Spec Outline (`--tag`, `--query`, `--spec-id`, `--limit`, the
|
|
1330
|
+
* hidden `--cursor`), as `renderOutline` shows it; `--all` follows every
|
|
1331
|
+
* page and lists every tag. A name is looked up first, as `spec` does.
|
|
1332
|
+
* `--json` prints the server's body unchanged, one line a page. Exit 1 for
|
|
1333
|
+
* a failed request, pending or failed forms included (`EXIT`).
|
|
1334
|
+
*/
|
|
1335
|
+
async function runOutline(args, context) {
|
|
1336
|
+
const { globals, io, color, stderrColor } = context;
|
|
1337
|
+
const apiId = await apiIdOf(args.target, context);
|
|
1338
|
+
if (typeof apiId === "number") return apiId;
|
|
1339
|
+
const limit = args.limit ?? (args.all ? ALL_PAGE_LIMIT$1 : 100);
|
|
1340
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1341
|
+
let cursor = args.cursor;
|
|
1342
|
+
let first;
|
|
1343
|
+
const operations = [];
|
|
1344
|
+
for (;;) {
|
|
1345
|
+
const result = await getNavigation({
|
|
1346
|
+
apiId,
|
|
1347
|
+
resource: "outline",
|
|
1348
|
+
params: {
|
|
1349
|
+
tag: args.tag,
|
|
1350
|
+
query: args.query,
|
|
1351
|
+
specId: args.specId,
|
|
1352
|
+
cursor,
|
|
1353
|
+
limit: String(limit)
|
|
1354
|
+
},
|
|
1355
|
+
what: "The Spec Outline"
|
|
1356
|
+
}, context);
|
|
1357
|
+
if (typeof result === "number") return result;
|
|
1358
|
+
if (globals.json) io.stdout.write(`${result.text}\n`);
|
|
1359
|
+
const page = result.body;
|
|
1360
|
+
first ??= page;
|
|
1361
|
+
operations.push(...page.operations);
|
|
1362
|
+
if (!args.all || page.nextCursor === null) {
|
|
1363
|
+
if (!globals.json) io.stdout.write(renderOutline({
|
|
1364
|
+
...first,
|
|
1365
|
+
operations,
|
|
1366
|
+
nextCursor: page.nextCursor
|
|
1367
|
+
}, {
|
|
1368
|
+
allTags: args.all,
|
|
1369
|
+
color
|
|
1370
|
+
}));
|
|
1371
|
+
return EXIT.ok;
|
|
1372
|
+
}
|
|
1373
|
+
if (seen.has(page.nextCursor)) {
|
|
1374
|
+
io.stderr.write(renderUsageError(`the service handed out cursor ${page.nextCursor} twice; stopped paging`, stderrColor));
|
|
1375
|
+
return EXIT.failed;
|
|
1376
|
+
}
|
|
1377
|
+
seen.add(page.nextCursor);
|
|
1378
|
+
cursor = page.nextCursor;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/cli/commands/schema.ts
|
|
1383
|
+
/**
|
|
1384
|
+
* `swaggerbot schema <name | apiId> <schemaName>`:
|
|
1385
|
+
* `GET /api/apis/{apiId}/schema?name=[&specId=]`, one component schema
|
|
1386
|
+
* expanded, as `renderSchema` shows it. A name is looked up first, as `spec`
|
|
1387
|
+
* does. `--json` prints the server's body unchanged. A schema not in the
|
|
1388
|
+
* Spec is the server's 404 with its `nearest` names, exit 1.
|
|
1389
|
+
*/
|
|
1390
|
+
async function runSchema(args, context) {
|
|
1391
|
+
const { globals, io, color } = context;
|
|
1392
|
+
const apiId = await apiIdOf(args.target, context);
|
|
1393
|
+
if (typeof apiId === "number") return apiId;
|
|
1394
|
+
const result = await getNavigation({
|
|
1395
|
+
apiId,
|
|
1396
|
+
resource: "schema",
|
|
1397
|
+
params: {
|
|
1398
|
+
name: args.name,
|
|
1399
|
+
specId: args.specId
|
|
1400
|
+
},
|
|
1401
|
+
what: "The schema",
|
|
1402
|
+
notFoundHint: nearestHint
|
|
1403
|
+
}, context);
|
|
1404
|
+
if (typeof result === "number") return result;
|
|
1405
|
+
io.stdout.write(globals.json ? `${result.text}\n` : renderSchema(result.body, color));
|
|
1406
|
+
return EXIT.ok;
|
|
1407
|
+
}
|
|
1408
|
+
/** A 404's `nearest` schema names, as a hint; none when it gives none. */
|
|
1409
|
+
function nearestHint(body) {
|
|
1410
|
+
const nearest = body?.nearest;
|
|
1411
|
+
if (!Array.isArray(nearest)) return void 0;
|
|
1412
|
+
const names = nearest.filter((n) => typeof n === "string");
|
|
1413
|
+
return names.length > 0 ? `The nearest it has: ${names.join(", ")}.` : void 0;
|
|
1414
|
+
}
|
|
1415
|
+
/** The wait between tries when the server gives no `retry-after`. */
|
|
1416
|
+
var DEFAULT_RETRY_SECONDS = 10;
|
|
1417
|
+
/**
|
|
1418
|
+
* `swaggerbot spec <name | apiId | specId>`: downloads one form of a Spec.
|
|
1419
|
+
* A Spec id needs no Lookup; an apiId's Current Spec is read from its
|
|
1420
|
+
* outline; a name is looked up, and only a Resolved answer is downloaded:
|
|
1421
|
+
* its Current Spec, or with `--api-version` the Spec a Lookup for that API
|
|
1422
|
+
* Version resolves to (else a plain Lookup says which API Versions it has);
|
|
1423
|
+
* any other Outcome is printed as `lookup` prints it, with its exit code.
|
|
1424
|
+
* The bytes go to `-o FILE`, else to stdout on a pipe, else to the file
|
|
1425
|
+
* the server names in the current directory (`--force` to overwrite it).
|
|
1426
|
+
* A Normalized Form being built is waited for, `retry-after` at a time, up
|
|
1427
|
+
* to `BUILD_WAIT_SECONDS`; `--no-wait` fails at once. The Published Form's
|
|
1428
|
+
* sha256 must be its Spec id. Exit codes as `EXIT`.
|
|
1429
|
+
*/
|
|
1430
|
+
async function runSpec(args, context) {
|
|
1431
|
+
const { globals, io, stderrColor } = context;
|
|
1432
|
+
if (globals.json && args.output === void 0 && !io.isTTY) {
|
|
1433
|
+
io.stderr.write(renderUsageError("--json can't share stdout with the Spec's bytes: give -o FILE", stderrColor));
|
|
1434
|
+
return EXIT.usage;
|
|
1435
|
+
}
|
|
1436
|
+
const chosen = await choose(args, context);
|
|
1437
|
+
if (typeof chosen === "number") return chosen;
|
|
1438
|
+
return download(args, chosen, context);
|
|
1439
|
+
}
|
|
1440
|
+
/** The Spec the argument names, or the exit code when there is none to download. */
|
|
1441
|
+
async function choose(args, context) {
|
|
1442
|
+
const { http, configPath, note, globals, io, stderrColor } = context;
|
|
1443
|
+
const { target, form } = args;
|
|
1444
|
+
switch (target.kind) {
|
|
1445
|
+
case "specId": return {
|
|
1446
|
+
specId: target.specId,
|
|
1447
|
+
apiId: null,
|
|
1448
|
+
url: `/api/specs/${target.specId}/${form}`
|
|
1449
|
+
};
|
|
1450
|
+
case "apiId": {
|
|
1451
|
+
const path = `/api/apis/${target.apiId.split("/").map(encodeURIComponent).join("/")}/outline`;
|
|
1452
|
+
const result = await whileBuilding(() => getJson(http, path, { timeoutMs: TIMEOUT_MS }), args, context);
|
|
1453
|
+
if (typeof result === "number") return result;
|
|
1454
|
+
if (!result.ok) return failed(result.error.status === 422 ? {
|
|
1455
|
+
...result,
|
|
1456
|
+
error: {
|
|
1457
|
+
...result.error,
|
|
1458
|
+
hint: `Its Spec Outline couldn't be built, so its Current Spec can't be found by apiId: name the API, or give its Spec id.`
|
|
1459
|
+
}
|
|
1460
|
+
} : result, globals, io, stderrColor);
|
|
1461
|
+
return {
|
|
1462
|
+
specId: result.body.specId,
|
|
1463
|
+
apiId: result.body.apiId,
|
|
1464
|
+
url: result.body.downloads[form]
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
case "name": {
|
|
1468
|
+
const lookup = (body) => requestLookup(body, http, {
|
|
1469
|
+
retryWithStored: true,
|
|
1470
|
+
stored: () => storedKeyOf(configPath, http.baseUrl, note)
|
|
1471
|
+
});
|
|
1472
|
+
const { apiVersion } = args;
|
|
1473
|
+
let needsKey = false;
|
|
1474
|
+
if (apiVersion !== void 0) {
|
|
1475
|
+
const versioned = await lookup({
|
|
1476
|
+
name: target.name,
|
|
1477
|
+
apiVersion
|
|
1478
|
+
});
|
|
1479
|
+
needsKey = !versioned.ok && versioned.error.status === 401;
|
|
1480
|
+
if (!versioned.ok && !needsKey) return failed(versioned, globals, io, stderrColor);
|
|
1481
|
+
const outcome = versioned.ok ? versioned.body : void 0;
|
|
1482
|
+
if (outcome?.outcome === "Resolved" && outcome.currentSpec.apiVersion === apiVersion) return {
|
|
1483
|
+
specId: outcome.currentSpec.id,
|
|
1484
|
+
apiId: outcome.api.id,
|
|
1485
|
+
url: outcome.currentSpec.downloads[form]
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
const result = await lookup({ name: target.name });
|
|
1489
|
+
if (!result.ok) return failed(result, globals, io, stderrColor);
|
|
1490
|
+
const outcome = result.body;
|
|
1491
|
+
if (outcome.outcome !== "Resolved") return printOutcome(result, context);
|
|
1492
|
+
if (apiVersion !== void 0) {
|
|
1493
|
+
const versions = [outcome.currentSpec, ...outcome.alternateSpecs].map((s) => `${s.apiVersion ?? "none"}${s === outcome.currentSpec ? " (Current)" : ""}`);
|
|
1494
|
+
return failed({ error: {
|
|
1495
|
+
status: 404,
|
|
1496
|
+
error: `${outcome.api.name} (${outcome.api.id}) has no Spec for API Version ${apiVersion}.`,
|
|
1497
|
+
hint: `Its API Versions: ${versions.join(", ")}.${needsKey ? ` With an API key (swaggerbot login), Discovery can look for ${apiVersion}.` : ""}`
|
|
1498
|
+
} }, globals, io, stderrColor);
|
|
1499
|
+
}
|
|
1500
|
+
return {
|
|
1501
|
+
specId: outcome.currentSpec.id,
|
|
1502
|
+
apiId: outcome.api.id,
|
|
1503
|
+
url: outcome.currentSpec.downloads[form]
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Tries `attempt` until it answers other than 409 (a Normalized Form being
|
|
1510
|
+
* built), waiting the server's `retry-after` between tries and
|
|
1511
|
+
* `BUILD_WAIT_SECONDS` in all; `--no-wait` gives up at the first 409. Gives
|
|
1512
|
+
* the last answer, or the exit code when it gave up.
|
|
1513
|
+
*/
|
|
1514
|
+
async function whileBuilding(attempt, args, { globals, io, stderrColor }) {
|
|
1515
|
+
const sleep = io.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1516
|
+
const now = () => (io.now?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
1517
|
+
let waited = 0;
|
|
1518
|
+
for (;;) {
|
|
1519
|
+
const result = await attempt();
|
|
1520
|
+
if (result.ok || result.error.status !== 409) return result;
|
|
1521
|
+
const retry = Math.max(1, retryAfterSeconds(result.headers?.get("retry-after"), now()) ?? DEFAULT_RETRY_SECONDS);
|
|
1522
|
+
const giveUp = (error, hint) => failed({
|
|
1523
|
+
error: {
|
|
1524
|
+
status: 409,
|
|
1525
|
+
error,
|
|
1526
|
+
hint
|
|
1527
|
+
},
|
|
1528
|
+
text: result.text
|
|
1529
|
+
}, globals, io, stderrColor);
|
|
1530
|
+
if (args.noWait) return giveUp("The Normalized Form is being built.", `Retry in ${durationWords(retry)}, or leave out --no-wait to wait for it.`);
|
|
1531
|
+
if (waited >= 120) return giveUp(`The Normalized Form is still being built after 120 s.`, args.form === "normalized" ? "Retry in a minute, or download the Published Form (leave out --form normalized)." : "Retry in a minute, or name the API instead of its apiId.");
|
|
1532
|
+
if (waited === 0) io.stderr.write(`${palette(stderrColor).dim("Normalized Form being built…")}\n`);
|
|
1533
|
+
const wait = Math.min(retry, 120 - waited);
|
|
1534
|
+
await sleep(wait * 1e3);
|
|
1535
|
+
waited += wait;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/** Downloads `chosen`'s form to where `args` and the terminal say, then says what was written. */
|
|
1539
|
+
async function download(args, chosen, context) {
|
|
1540
|
+
const { http, globals, io, stderrColor } = context;
|
|
1541
|
+
const refuse = (error, hint) => failed({ error: {
|
|
1542
|
+
status: 0,
|
|
1543
|
+
error,
|
|
1544
|
+
...hint ? { hint } : {}
|
|
1545
|
+
} }, globals, io, stderrColor);
|
|
1546
|
+
const result = await whileBuilding(() => getDownload(http, chosen.url), args, context);
|
|
1547
|
+
if (typeof result === "number") return result;
|
|
1548
|
+
if (!result.ok) return failed(result, globals, io, stderrColor);
|
|
1549
|
+
const { response, alive, done } = result;
|
|
1550
|
+
let file;
|
|
1551
|
+
if (args.output !== void 0) file = {
|
|
1552
|
+
path: args.output,
|
|
1553
|
+
shown: args.output
|
|
1554
|
+
};
|
|
1555
|
+
else if (io.isTTY) {
|
|
1556
|
+
const name = fileNameOf(response.headers, chosen, args.form);
|
|
1557
|
+
file = {
|
|
1558
|
+
path: join(io.cwd ?? process.cwd(), name),
|
|
1559
|
+
shown: name
|
|
1560
|
+
};
|
|
1561
|
+
if (!args.force && await exists(file.path)) {
|
|
1562
|
+
done();
|
|
1563
|
+
await response.body?.cancel().catch(() => {});
|
|
1564
|
+
return refuse(`${name} exists already.`, "Pass --force to overwrite it, or -o another path.");
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const part = file ? join(dirname(file.path), `.${basename(file.path)}.${randomBytes(4).toString("hex")}.part`) : void 0;
|
|
1568
|
+
let handle;
|
|
1569
|
+
const discard = async () => {
|
|
1570
|
+
await handle?.close().catch(() => {});
|
|
1571
|
+
if (part) await rm(part, { force: true });
|
|
1572
|
+
};
|
|
1573
|
+
if (file && part) try {
|
|
1574
|
+
handle = await open(part, "wx");
|
|
1575
|
+
} catch (err) {
|
|
1576
|
+
done();
|
|
1577
|
+
await response.body?.cancel().catch(() => {});
|
|
1578
|
+
return refuse(`Can't write ${file.shown}: ${err.message}`);
|
|
1579
|
+
}
|
|
1580
|
+
const hash = createHash("sha256");
|
|
1581
|
+
let bytes = 0;
|
|
1582
|
+
try {
|
|
1583
|
+
const reader = response.body?.getReader();
|
|
1584
|
+
for (;;) {
|
|
1585
|
+
const next = await reader?.read();
|
|
1586
|
+
if (!next || next.done) break;
|
|
1587
|
+
alive();
|
|
1588
|
+
hash.update(next.value);
|
|
1589
|
+
bytes += next.value.byteLength;
|
|
1590
|
+
if (handle) await handle.write(next.value);
|
|
1591
|
+
else await writeOut(io, next.value);
|
|
1592
|
+
}
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
await discard();
|
|
1595
|
+
if (typeof err.code === "string" && file) return refuse(`Can't write ${file.shown}: ${err.message}`);
|
|
1596
|
+
return failed({ error: transportError(err, http.baseUrl, DOWNLOAD_TIMEOUT_MS) }, globals, io, stderrColor);
|
|
1597
|
+
} finally {
|
|
1598
|
+
done();
|
|
1599
|
+
}
|
|
1600
|
+
const sha256 = hash.digest("hex");
|
|
1601
|
+
if (args.form === "published" && sha256 !== chosen.specId) {
|
|
1602
|
+
await discard();
|
|
1603
|
+
return refuse(`The bytes downloaded aren't Spec ${chosen.specId}: their sha256 is ${sha256}.`, file ? "Nothing was kept. Retry; if it happens again, something between you and the server changed them." : "Don't use what was written to stdout. Retry; if it happens again, something between you and the server changed them.");
|
|
1604
|
+
}
|
|
1605
|
+
if (handle && file && part) {
|
|
1606
|
+
try {
|
|
1607
|
+
await handle.close();
|
|
1608
|
+
handle = void 0;
|
|
1609
|
+
await rename(part, file.path);
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
await discard();
|
|
1612
|
+
return refuse(`Can't write ${file.shown}: ${err.message}`);
|
|
1613
|
+
}
|
|
1614
|
+
io.stderr.write(`Wrote ${file.shown} (${sizeWords(bytes)})\n`);
|
|
1615
|
+
}
|
|
1616
|
+
if (globals.json) io.stdout.write(`${JSON.stringify({
|
|
1617
|
+
specId: chosen.specId,
|
|
1618
|
+
apiId: chosen.apiId,
|
|
1619
|
+
form: args.form,
|
|
1620
|
+
path: file ? file.shown : "stdout",
|
|
1621
|
+
bytes,
|
|
1622
|
+
sha256
|
|
1623
|
+
})}\n`);
|
|
1624
|
+
return EXIT.ok;
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* The file name the server gives in `content-disposition`, as a plain name
|
|
1628
|
+
* in the current directory and nothing else; else one made like it:
|
|
1629
|
+
* `<api>-<first 12 of the Spec id>.json`, `.normalized.json` for that form.
|
|
1630
|
+
*/
|
|
1631
|
+
function fileNameOf(headers, chosen, form) {
|
|
1632
|
+
const given = /filename\s*=\s*"?([^";]+)"?/i.exec(headers.get("content-disposition") ?? "")?.[1]?.trim();
|
|
1633
|
+
const name = [...basename((given ?? "").replace(/\\/g, "/"))].filter((c) => c >= " " && c !== "").join("");
|
|
1634
|
+
if (name && !name.startsWith(".")) return name;
|
|
1635
|
+
const slug = chosen.apiId?.slice(chosen.apiId.indexOf("/") + 1);
|
|
1636
|
+
const extension = form === "normalized" ? "normalized.json" : /yaml/i.test(headers.get("content-type") ?? "") ? "yaml" : "json";
|
|
1637
|
+
return `${slug ? `${slug}-` : ""}${chosen.specId.slice(0, 12)}.${extension}`;
|
|
1638
|
+
}
|
|
1639
|
+
/** Whether anything is at `path`. */
|
|
1640
|
+
async function exists(path) {
|
|
1641
|
+
try {
|
|
1642
|
+
await stat(path);
|
|
1643
|
+
return true;
|
|
1644
|
+
} catch {
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
/** Writes `chunk` to stdout, waiting for it to drain when it says to. */
|
|
1649
|
+
async function writeOut(io, chunk) {
|
|
1650
|
+
if (io.stdout.write(chunk) === false && io.stdout.once) await new Promise((resolve) => io.stdout.once?.("drain", resolve));
|
|
1651
|
+
}
|
|
1652
|
+
//#endregion
|
|
1653
|
+
//#region src/cli/commands/vendors.ts
|
|
1654
|
+
/** Vendors a page when `--all` follows every page: the most the route allows. */
|
|
1655
|
+
var ALL_PAGE_LIMIT = 200;
|
|
1656
|
+
/**
|
|
1657
|
+
* `swaggerbot vendors [<query>]`: `GET /api/vendors[?query=]`, one page of
|
|
1658
|
+
* the Vendors in the Index (the server's 50, or from the hidden `--cursor`),
|
|
1659
|
+
* as `renderVendors` shows it; `--all` follows every page. `--json` prints
|
|
1660
|
+
* the server's body unchanged, one line a page. Exit 1 for a failed request
|
|
1661
|
+
* (`EXIT`).
|
|
1662
|
+
*/
|
|
1663
|
+
async function runVendors(args, { http, globals, io, color, stderrColor }) {
|
|
1664
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1665
|
+
let cursor = args.cursor;
|
|
1666
|
+
let total = 0;
|
|
1667
|
+
const vendors = [];
|
|
1668
|
+
for (;;) {
|
|
1669
|
+
const query = new URLSearchParams();
|
|
1670
|
+
if (args.query !== void 0) query.set("query", args.query);
|
|
1671
|
+
if (cursor !== void 0) query.set("cursor", cursor);
|
|
1672
|
+
if (args.all) query.set("limit", String(ALL_PAGE_LIMIT));
|
|
1673
|
+
const result = await getJson(http, `/api/vendors${query.size > 0 ? `?${query}` : ""}`, { timeoutMs: TIMEOUT_MS });
|
|
1674
|
+
if (!result.ok) return failed(result, globals, io, stderrColor);
|
|
1675
|
+
if (globals.json) io.stdout.write(`${result.text}\n`);
|
|
1676
|
+
const page = result.body;
|
|
1677
|
+
total = page.total;
|
|
1678
|
+
vendors.push(...page.vendors);
|
|
1679
|
+
if (!args.all || page.nextCursor === null) {
|
|
1680
|
+
if (!globals.json) io.stdout.write(renderVendors({
|
|
1681
|
+
vendors,
|
|
1682
|
+
total,
|
|
1683
|
+
nextCursor: page.nextCursor
|
|
1684
|
+
}, {
|
|
1685
|
+
query: args.query,
|
|
1686
|
+
color
|
|
1687
|
+
}));
|
|
1688
|
+
return EXIT.ok;
|
|
1689
|
+
}
|
|
1690
|
+
if (seen.has(page.nextCursor)) {
|
|
1691
|
+
io.stderr.write(renderUsageError(`the service handed out cursor ${page.nextCursor} twice; stopped paging`, stderrColor));
|
|
1692
|
+
return EXIT.failed;
|
|
1693
|
+
}
|
|
1694
|
+
seen.add(page.nextCursor);
|
|
1695
|
+
cursor = page.nextCursor;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* `swaggerbot vendor <vendor>`: `GET /api/vendors/{vendor}/apis`. A 200 lists
|
|
1700
|
+
* the Vendor's APIs (`renderVendorApis`), exit 0; a 300 lists the Vendors it
|
|
1701
|
+
* could mean, exit 3 as Ambiguous; a 404 says the Index holds no such Vendor
|
|
1702
|
+
* and how a Lookup adds one, exit 6 as Unknown. `--json` prints the server's
|
|
1703
|
+
* body unchanged, with the same exit codes. Exit 1 for any other failure.
|
|
1704
|
+
*/
|
|
1705
|
+
async function runVendor(args, { http, globals, io, color, stderrColor }) {
|
|
1706
|
+
const result = await getJson(http, `/api/vendors/${encodeURIComponent(args.vendor)}/apis`, { timeoutMs: TIMEOUT_MS });
|
|
1707
|
+
if (result.ok) {
|
|
1708
|
+
io.stdout.write(globals.json ? `${result.text}\n` : renderVendorApis(result.body, {
|
|
1709
|
+
baseUrl: http.baseUrl,
|
|
1710
|
+
color
|
|
1711
|
+
}));
|
|
1712
|
+
return EXIT.ok;
|
|
1713
|
+
}
|
|
1714
|
+
const { status } = result.error;
|
|
1715
|
+
if (status === 300) {
|
|
1716
|
+
const body = parsed(result.text);
|
|
1717
|
+
if (Array.isArray(body?.vendors) && body.vendors.length > 0) {
|
|
1718
|
+
io.stdout.write(globals.json ? `${result.text}\n` : renderVendorMatches({ vendors: body.vendors }, {
|
|
1719
|
+
vendor: args.vendor,
|
|
1720
|
+
color
|
|
1721
|
+
}));
|
|
1722
|
+
return EXIT.Ambiguous;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (status === 404 && parsed(result.text) !== void 0) {
|
|
1726
|
+
io.stdout.write(globals.json ? `${result.text}\n` : renderUnknownVendor(args.vendor, color));
|
|
1727
|
+
return EXIT.Unknown;
|
|
1728
|
+
}
|
|
1729
|
+
return failed(result, globals, io, stderrColor);
|
|
1730
|
+
}
|
|
1731
|
+
/** `text` as JSON; none when there is no text or it isn't JSON. */
|
|
1732
|
+
function parsed(text) {
|
|
1733
|
+
try {
|
|
1734
|
+
return text === void 0 ? void 0 : JSON.parse(text);
|
|
1735
|
+
} catch {
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
//#endregion
|
|
1740
|
+
//#region src/cli/main.ts
|
|
1741
|
+
/** The CLI's version, from `cli/package.json`, bundled in. */
|
|
1742
|
+
var CLI_VERSION = package_default.version;
|
|
1743
|
+
/** The command table, one line per command; a command missing here is a type error. */
|
|
1744
|
+
var COMMANDS = {
|
|
1745
|
+
lookup: runLookup,
|
|
1746
|
+
login: runLogin,
|
|
1747
|
+
logout: runLogout,
|
|
1748
|
+
key: runKey,
|
|
1749
|
+
spec: runSpec,
|
|
1750
|
+
outline: runOutline,
|
|
1751
|
+
operation: runOperation,
|
|
1752
|
+
schema: runSchema,
|
|
1753
|
+
vendors: runVendors,
|
|
1754
|
+
vendor: runVendor
|
|
1755
|
+
};
|
|
1756
|
+
/**
|
|
1757
|
+
* Runs `swaggerbot` for `argv` (without node and the script) and answers
|
|
1758
|
+
* with the exit code (D4, `EXIT`): 0 for Resolved or success, 3 Ambiguous,
|
|
1759
|
+
* 4 Unconfirmed, 5 No Spec, 6 Unknown, 1 for a refusal or a failed request,
|
|
1760
|
+
* 2 for a usage error. Nothing is written but to `io`.
|
|
1761
|
+
*/
|
|
1762
|
+
async function runCli(argv, io) {
|
|
1763
|
+
const parsed = parseCliArgs(argv);
|
|
1764
|
+
const noColor = parsed.ok ? parsed.globals.noColor : argv.includes("--no-color");
|
|
1765
|
+
const stderrColor = colorEnabled(io.stderrIsTTY, io.env, noColor);
|
|
1766
|
+
if (!parsed.ok) {
|
|
1767
|
+
io.stderr.write(renderUsageError(parsed.error, stderrColor));
|
|
1768
|
+
return parsed.exitCode;
|
|
1769
|
+
}
|
|
1770
|
+
const { command, globals } = parsed;
|
|
1771
|
+
const color = colorEnabled(io.isTTY, io.env, noColor);
|
|
1772
|
+
switch (command.command) {
|
|
1773
|
+
case "version":
|
|
1774
|
+
io.stdout.write(`${CLI_VERSION}\n`);
|
|
1775
|
+
return 0;
|
|
1776
|
+
case "help":
|
|
1777
|
+
io.stdout.write(`${CLI_USAGE}\n`);
|
|
1778
|
+
return 0;
|
|
1779
|
+
default: {
|
|
1780
|
+
const baseUrl = baseUrlOf(globals.url, io.env);
|
|
1781
|
+
const configPath = configPathOf(io.env);
|
|
1782
|
+
const run = COMMANDS[command.command];
|
|
1783
|
+
return run(command, {
|
|
1784
|
+
http: {
|
|
1785
|
+
baseUrl,
|
|
1786
|
+
key: keyOf(globals.key, io.env),
|
|
1787
|
+
version: CLI_VERSION
|
|
1788
|
+
},
|
|
1789
|
+
configPath,
|
|
1790
|
+
note: (message) => io.stderr.write(renderWarning(message, stderrColor)),
|
|
1791
|
+
globals,
|
|
1792
|
+
io,
|
|
1793
|
+
color,
|
|
1794
|
+
stderrColor
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
/** `runCli` over the process: its arguments, streams and environment. */
|
|
1800
|
+
async function main() {
|
|
1801
|
+
const io = {
|
|
1802
|
+
stdout: process.stdout,
|
|
1803
|
+
stderr: process.stderr,
|
|
1804
|
+
stdin: process.stdin,
|
|
1805
|
+
isTTY: process.stdout.isTTY === true,
|
|
1806
|
+
stderrIsTTY: process.stderr.isTTY === true,
|
|
1807
|
+
env: process.env
|
|
1808
|
+
};
|
|
1809
|
+
process.stdout.on("error", (err) => {
|
|
1810
|
+
if (err.code !== "EPIPE") throw err;
|
|
1811
|
+
process.exit(process.exitCode ?? 0);
|
|
1812
|
+
});
|
|
1813
|
+
try {
|
|
1814
|
+
process.exitCode = await runCli(process.argv.slice(2), io);
|
|
1815
|
+
} catch (err) {
|
|
1816
|
+
io.stderr.write(`error ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1817
|
+
process.exitCode = 1;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
//#endregion
|
|
1821
|
+
//#region src/cli/bin.ts
|
|
1822
|
+
await main();
|
|
1823
|
+
//#endregion
|
|
1824
|
+
export {};
|