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.
@@ -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 {};