@contextretrieval-net/cli 0.0.0-stage → 0.0.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 +75 -2
- package/bin/cli.js +13 -0
- package/brand.json +14 -0
- package/openapi.json +1277 -0
- package/package.json +44 -4
- package/src/commands.js +381 -0
- package/src/config.js +102 -0
- package/src/help.js +127 -0
- package/src/http.js +96 -0
- package/src/main.js +282 -0
- package/src/output.js +57 -0
- package/src/spec.js +109 -0
- package/src/update.js +71 -0
package/src/http.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* One API call. A request that carries an Idempotency-Key (or is a GET) is repeated after a network error, a cut 2xx
|
|
4
|
+
* answer, a 429 or a 5xx: the key makes the repeat rejoin the first request instead of being charged again. A 202
|
|
5
|
+
* "pending" answer (a fetch still running after ~100 s) is repeated with the same key after Retry-After until the
|
|
6
|
+
* result arrives or maxWaitMs runs out.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} Outcome
|
|
11
|
+
* @property {"ok" | "api" | "pending" | "network"} kind
|
|
12
|
+
* @property {number} status 0 for network
|
|
13
|
+
* @property {any} body parsed JSON, else the text
|
|
14
|
+
* @property {string | null} requestId
|
|
15
|
+
* @property {string} [error] network: what went wrong
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const sleep = (/** @type {number} */ ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
|
|
20
|
+
/** Exponential backoff with jitter: ~0.5 s, 1 s, 2 s (capped at 8 s). */
|
|
21
|
+
const backoffMs = (/** @type {number} */ attempt) => {
|
|
22
|
+
const base = Math.min(8000, 500 * 2 ** attempt);
|
|
23
|
+
return base / 2 + Math.random() * (base / 2);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const isPending = (/** @type {any} */ body) => !!body && typeof body === "object" && body.status === "pending";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} o
|
|
30
|
+
* @param {URL} o.url
|
|
31
|
+
* @param {string} o.method
|
|
32
|
+
* @param {Record<string, string>} o.headers
|
|
33
|
+
* @param {any} [o.body]
|
|
34
|
+
* @param {boolean} o.repeatable safe to send again (GET, or an Idempotency-Key is set)
|
|
35
|
+
* @param {number} o.timeoutMs per attempt
|
|
36
|
+
* @param {number} o.maxWaitMs 202 repeats
|
|
37
|
+
* @param {number} [o.retries]
|
|
38
|
+
* @param {(info: { retryAfter: number, idempotencyKey: string | null }) => void} [o.onPending]
|
|
39
|
+
* @param {typeof fetch} [o.fetchImpl]
|
|
40
|
+
* @returns {Promise<Outcome>}
|
|
41
|
+
*/
|
|
42
|
+
export async function send(o) {
|
|
43
|
+
const fetchImpl = o.fetchImpl ?? fetch;
|
|
44
|
+
const retries = o.retries ?? 2;
|
|
45
|
+
const until = Date.now() + o.maxWaitMs;
|
|
46
|
+
const payload = o.body === undefined ? undefined : JSON.stringify(o.body);
|
|
47
|
+
const headers = { ...o.headers, ...(payload !== undefined && { "Content-Type": "application/json" }) };
|
|
48
|
+
|
|
49
|
+
for (let attempt = 0; ; ) {
|
|
50
|
+
const canRetry = o.repeatable && attempt < retries;
|
|
51
|
+
/** @type {Response} */
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await fetchImpl(o.url, { method: o.method.toUpperCase(), headers, body: payload, signal: AbortSignal.timeout(o.timeoutMs) });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (canRetry) {
|
|
57
|
+
await sleep(backoffMs(attempt++));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const err = /** @type {Error} */ (e);
|
|
61
|
+
return { kind: "network", status: 0, body: null, requestId: null, error: err.name === "TimeoutError" ? `no answer within ${Math.round(o.timeoutMs / 1000)} s` : String(err.cause ?? err.message) };
|
|
62
|
+
}
|
|
63
|
+
const requestId = res.headers.get("x-request-id");
|
|
64
|
+
const json = /json/i.test(res.headers.get("content-type") ?? "");
|
|
65
|
+
/** @type {any} */
|
|
66
|
+
let body;
|
|
67
|
+
try {
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
body = json ? JSON.parse(text) : text;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
// A cut 2xx answer is a lost answer, not a result: repeat with the same key, then report it as a network error.
|
|
72
|
+
if (res.ok && canRetry) {
|
|
73
|
+
await sleep(backoffMs(attempt++));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (res.ok) return { kind: "network", status: 0, body: null, requestId, error: `HTTP ${res.status} answer cut or not JSON: ${/** @type {Error} */ (e).message}` };
|
|
77
|
+
body = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (res.status === 202 && isPending(body)) {
|
|
81
|
+
const retryAfter = Math.min(30, Math.max(1, Number(res.headers.get("retry-after") ?? body.retryAfter) || 1));
|
|
82
|
+
if (!o.repeatable || Date.now() + retryAfter * 1000 >= until) return { kind: "pending", status: 202, body, requestId };
|
|
83
|
+
o.onPending?.({ retryAfter, idempotencyKey: body.idempotencyKey ?? null });
|
|
84
|
+
await sleep(retryAfter * 1000);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (res.ok) return { kind: "ok", status: res.status, body, requestId };
|
|
88
|
+
if ((res.status === 429 || res.status >= 500) && canRetry) {
|
|
89
|
+
const ra = Number(res.headers.get("retry-after"));
|
|
90
|
+
await sleep(Number.isFinite(ra) && ra > 0 ? Math.min(ra, 30) * 1000 : backoffMs(attempt));
|
|
91
|
+
attempt++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
return { kind: "api", status: res.status, body, requestId };
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The CLI: global options, the built-in commands (help, login, logout, spec) and one command per API operation.
|
|
4
|
+
* Exit codes: 0 ok, 1 API error (or every item failed with -o text, or still pending), 2 usage, 3 network.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { buildCommands, buildRequest, findCommand, requestUrl, UsageError } from "./commands.js";
|
|
10
|
+
import { cacheDir, configFile, envValue, loadBrand, loadVersion, removeApiKey, resolveApiKey, ROOT, saveApiKey } from "./config.js";
|
|
11
|
+
import { commandHelp, mainHelp } from "./help.js";
|
|
12
|
+
import { send } from "./http.js";
|
|
13
|
+
import { json, render } from "./output.js";
|
|
14
|
+
import { loadSpec } from "./spec.js";
|
|
15
|
+
import { updateNotice } from "./update.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} IO
|
|
19
|
+
* @property {Record<string, string | undefined>} env
|
|
20
|
+
* @property {NodeJS.ReadStream | { isTTY?: boolean } & AsyncIterable<any>} stdin
|
|
21
|
+
* @property {{ write(s: string): unknown, isTTY?: boolean }} stdout
|
|
22
|
+
* @property {{ write(s: string): unknown, isTTY?: boolean }} stderr
|
|
23
|
+
* @property {string} [root] package root (brand.json, package.json, openapi.json)
|
|
24
|
+
* @property {typeof fetch} [fetchImpl]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @typedef {{ apiKey?: string, baseUrl?: string, specUrl?: string, offline: boolean, output: string, data?: string, timeout?: number, maxWait?: number, help: boolean, version: boolean, rest: string[] }} Globals */
|
|
28
|
+
|
|
29
|
+
const VALUE_OPTIONS = /** @type {const} */ ({ "--api-key": "apiKey", "--base-url": "baseUrl", "--spec-url": "specUrl", "-o": "output", "--output": "output", "-d": "data", "--data": "data", "--timeout": "timeout", "--max-wait": "maxWait" });
|
|
30
|
+
|
|
31
|
+
/** Takes the global options out of argv, anywhere before `--`. */
|
|
32
|
+
export function parseGlobals(/** @type {string[]} */ argv) {
|
|
33
|
+
/** @type {Globals} */
|
|
34
|
+
const g = { offline: false, output: "json", help: false, version: false, rest: [] };
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
if (arg === "--") {
|
|
38
|
+
g.rest.push(...argv.slice(i));
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (arg === "-h" || arg === "--help") g.help = true;
|
|
42
|
+
else if (arg === "-v" || arg === "--version") g.version = true;
|
|
43
|
+
else if (arg === "--offline") g.offline = true;
|
|
44
|
+
else {
|
|
45
|
+
const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
|
46
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
47
|
+
const key = /** @type {Record<string, string>} */ (VALUE_OPTIONS)[name];
|
|
48
|
+
if (!key) {
|
|
49
|
+
g.rest.push(arg);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let value;
|
|
53
|
+
if (eq !== -1) value = arg.slice(eq + 1);
|
|
54
|
+
else if (i + 1 < argv.length) value = argv[++i];
|
|
55
|
+
else throw new UsageError(`${name} needs a value`);
|
|
56
|
+
if (key === "timeout" || key === "maxWait") {
|
|
57
|
+
const n = Number(value);
|
|
58
|
+
if (!Number.isFinite(n) || n <= 0) throw new UsageError(`${name} needs a number of seconds, got "${value}"`);
|
|
59
|
+
/** @type {any} */ (g)[key] = n;
|
|
60
|
+
} else /** @type {any} */ (g)[key] = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return g;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readAll(/** @type {AsyncIterable<any>} */ stream) {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
for await (const chunk of stream) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
69
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** --data: JSON, @file or - (stdin). */
|
|
73
|
+
async function readData(/** @type {string} */ raw, /** @type {IO} */ io) {
|
|
74
|
+
let text = raw;
|
|
75
|
+
if (raw === "-") text = await readAll(/** @type {AsyncIterable<any>} */ (io.stdin));
|
|
76
|
+
else if (raw.startsWith("@")) {
|
|
77
|
+
try {
|
|
78
|
+
text = readFileSync(raw.slice(1), "utf8");
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw new UsageError(`--data ${raw}: ${/** @type {Error} */ (e).message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new UsageError(`--data is not valid JSON`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reads a line without echo (the API key). */
|
|
91
|
+
function promptHidden(/** @type {string} */ question, /** @type {any} */ stdin, /** @type {IO["stderr"]} */ stderr) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
stderr.write(question);
|
|
94
|
+
let value = "";
|
|
95
|
+
const done = () => {
|
|
96
|
+
stdin.setRawMode?.(false);
|
|
97
|
+
stdin.pause();
|
|
98
|
+
stdin.off("data", onData);
|
|
99
|
+
stderr.write("\n");
|
|
100
|
+
};
|
|
101
|
+
const onData = (/** @type {string} */ chunk) => {
|
|
102
|
+
for (const ch of chunk) {
|
|
103
|
+
if (ch === "\r" || ch === "\n" || ch === "\u0004") {
|
|
104
|
+
done();
|
|
105
|
+
return resolve(value);
|
|
106
|
+
}
|
|
107
|
+
if (ch === "\u0003") {
|
|
108
|
+
done();
|
|
109
|
+
return reject(new UsageError("cancelled"));
|
|
110
|
+
}
|
|
111
|
+
if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
|
|
112
|
+
else value += ch;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
stdin.setRawMode?.(true);
|
|
116
|
+
stdin.setEncoding("utf8");
|
|
117
|
+
stdin.resume();
|
|
118
|
+
stdin.on("data", onData);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const BUILTIN_HELP = {
|
|
123
|
+
login: (/** @type {string} */ b) => `Usage: ${b} login [--api-key <key>]\n\nSaves your API key (asked without echo, or piped in: echo $KEY | ${b} login) to ~/.config/${b}/config.json, readable\nonly by you. --api-key and env LIVE_WEB_DATA_API_KEY take precedence over the saved key.\n`,
|
|
124
|
+
logout: (/** @type {string} */ b) => `Usage: ${b} logout\n\nRemoves the saved API key.\n`,
|
|
125
|
+
spec: (/** @type {string} */ b) => `Usage: ${b} spec [refresh]\n\nShows where the commands come from: the live API spec, cached for an hour, or the copy bundled with this version.\n\`refresh\` downloads it now.\n`,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const ago = (/** @type {number} */ ms) => (ms < 90_000 ? `${Math.round(ms / 1000)} s ago` : ms < 5_400_000 ? `${Math.round(ms / 60_000)} min ago` : `${Math.round(ms / 3_600_000)} h ago`);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string[]} argv
|
|
132
|
+
* @param {IO} io
|
|
133
|
+
* @returns {Promise<number>} exit code
|
|
134
|
+
*/
|
|
135
|
+
export async function main(argv, io) {
|
|
136
|
+
const root = io.root ?? ROOT;
|
|
137
|
+
const brand = loadBrand(root);
|
|
138
|
+
const version = loadVersion(root);
|
|
139
|
+
const { env, stdout, stderr } = io;
|
|
140
|
+
const fail = (/** @type {Error} */ e) => {
|
|
141
|
+
stderr.write(`${brand.bin}: ${e.message}\n`);
|
|
142
|
+
return 2;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** @type {Globals} */
|
|
146
|
+
let g;
|
|
147
|
+
try {
|
|
148
|
+
g = parseGlobals(argv);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return fail(/** @type {Error} */ (e));
|
|
151
|
+
}
|
|
152
|
+
if (g.version) {
|
|
153
|
+
stdout.write(`${version}\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const userAgent = `${brand.bin}-cli/${version} (+https://${brand.domain})`;
|
|
158
|
+
const cache = cacheDir(env, brand.bin);
|
|
159
|
+
const notice = updateNotice({ bin: brand.bin, npm: brand.npm, version, cacheDir: cache, env, isTTY: !!stderr.isTTY, fetchImpl: io.fetchImpl });
|
|
160
|
+
let code;
|
|
161
|
+
try {
|
|
162
|
+
code = await run(g);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (!(e instanceof UsageError)) throw e;
|
|
165
|
+
code = fail(e);
|
|
166
|
+
}
|
|
167
|
+
const line = await notice;
|
|
168
|
+
if (line) stderr.write(`${line}\n`);
|
|
169
|
+
return code;
|
|
170
|
+
|
|
171
|
+
/** @param {Globals} g */
|
|
172
|
+
async function run(g) {
|
|
173
|
+
const [name, ...args] = g.rest;
|
|
174
|
+
const spec = () =>
|
|
175
|
+
loadSpec({
|
|
176
|
+
url: g.specUrl ?? envValue(env, "LIVE_WEB_DATA_SPEC_URL") ?? brand.specUrl,
|
|
177
|
+
cacheDir: cache,
|
|
178
|
+
bundledPath: join(root, "openapi.json"),
|
|
179
|
+
offline: g.offline,
|
|
180
|
+
headers: { "User-Agent": userAgent },
|
|
181
|
+
fetchImpl: io.fetchImpl,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const builtin = BUILTIN_HELP[/** @type {keyof typeof BUILTIN_HELP} */ (name === "help" ? args[0] : name)];
|
|
185
|
+
if (builtin && (g.help || name === "help")) {
|
|
186
|
+
stdout.write(builtin(brand.bin));
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
if (name === "login") {
|
|
190
|
+
let key = g.apiKey;
|
|
191
|
+
if (!key) key = io.stdin.isTTY ? await promptHidden("API key: ", io.stdin, stderr) : (await readAll(/** @type {AsyncIterable<any>} */ (io.stdin))).trim();
|
|
192
|
+
if (!key || /\s/.test(key)) throw new UsageError("no API key given (paste it at the prompt, pipe it in, or pass --api-key)");
|
|
193
|
+
const file = configFile(env, brand.bin);
|
|
194
|
+
saveApiKey(file, key);
|
|
195
|
+
stderr.write(`Saved the API key to ${file} (readable only by you).\n`);
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
if (name === "logout") {
|
|
199
|
+
const file = configFile(env, brand.bin);
|
|
200
|
+
stderr.write(removeApiKey(file) ? `Removed the saved API key from ${file}.\n` : "No saved API key.\n");
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
if (name === "spec") {
|
|
204
|
+
if (args[0] !== undefined && args[0] !== "refresh") throw new UsageError(`unknown "spec ${args[0]}" (spec, spec refresh)`);
|
|
205
|
+
const refresh = args[0] === "refresh";
|
|
206
|
+
if (refresh && g.offline) throw new UsageError("spec refresh cannot run --offline");
|
|
207
|
+
const loaded = refresh
|
|
208
|
+
? await loadSpec({ url: g.specUrl ?? envValue(env, "LIVE_WEB_DATA_SPEC_URL") ?? brand.specUrl, cacheDir: cache, bundledPath: join(root, "openapi.json"), force: true, headers: { "User-Agent": userAgent }, fetchImpl: io.fetchImpl })
|
|
209
|
+
: await spec();
|
|
210
|
+
const commands = buildCommands(loaded.spec);
|
|
211
|
+
stdout.write(
|
|
212
|
+
[
|
|
213
|
+
`Spec: ${loaded.url}`,
|
|
214
|
+
`Source: ${loaded.source}${loaded.fetchedAt ? ` (downloaded or checked ${ago(Date.now() - loaded.fetchedAt)})` : " (bundled with this version)"}${loaded.etag ? `, ETag ${loaded.etag}` : ""}`,
|
|
215
|
+
`API: ${loaded.spec.info?.title ?? "?"} ${loaded.spec.info?.version ?? ""}`.trimEnd(),
|
|
216
|
+
`Commands: ${commands.map((c) => c.name).join(", ")}`,
|
|
217
|
+
...(loaded.error ? [`Note: could not download it: ${loaded.error}`] : []),
|
|
218
|
+
"",
|
|
219
|
+
].join("\n"),
|
|
220
|
+
);
|
|
221
|
+
return refresh && loaded.error ? 3 : 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const loaded = await spec();
|
|
225
|
+
const commands = buildCommands(loaded.spec);
|
|
226
|
+
if (name === undefined || (name === "help" && args.length === 0)) {
|
|
227
|
+
stdout.write(mainHelp(brand, commands, loaded));
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
const helpFor = name === "help" ? args[0] : g.help ? name : null;
|
|
231
|
+
const command = findCommand(commands, helpFor ?? name);
|
|
232
|
+
if (!command) {
|
|
233
|
+
const known = [...commands.flatMap((c) => [c.name, ...c.aliases]), "login", "logout", "spec"];
|
|
234
|
+
throw new UsageError(`unknown command "${helpFor ?? name}". Commands: ${known.join(", ")} (see ${brand.bin} --help)`);
|
|
235
|
+
}
|
|
236
|
+
if (helpFor) {
|
|
237
|
+
stdout.write(commandHelp(brand, command));
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const data = g.data !== undefined ? await readData(g.data, io) : undefined;
|
|
242
|
+
const req = buildRequest(command, args, data);
|
|
243
|
+
let idempotencyKey = req.idempotencyKey;
|
|
244
|
+
if (command.idempotencyKey && !idempotencyKey) {
|
|
245
|
+
idempotencyKey = `cli_${randomUUID()}`;
|
|
246
|
+
req.headers[command.idempotencyKey.path[0]] = idempotencyKey;
|
|
247
|
+
}
|
|
248
|
+
const baseUrl = g.baseUrl ?? envValue(env, "LIVE_WEB_DATA_API_BASE_URL", "TENEO_API_BASE_URL") ?? brand.apiBaseUrl;
|
|
249
|
+
if (!/^https?:\/\/[^/]/.test(baseUrl)) throw new UsageError(`the base URL must be http(s)://…, got "${baseUrl}"`);
|
|
250
|
+
// The target comes from the base URL only: never from the spec's servers.
|
|
251
|
+
const url = requestUrl(baseUrl, command, req);
|
|
252
|
+
const { apiKey } = resolveApiKey(g.apiKey, env, brand.bin);
|
|
253
|
+
if (!apiKey && command.needsAuth) stderr.write(`${brand.bin}: no API key. Run \`${brand.bin} login\` or set LIVE_WEB_DATA_API_KEY.\n`);
|
|
254
|
+
|
|
255
|
+
const outcome = await send({
|
|
256
|
+
url,
|
|
257
|
+
method: command.method,
|
|
258
|
+
headers: { Accept: "application/json", "User-Agent": userAgent, ...(apiKey && { Authorization: `Bearer ${apiKey}` }), ...req.headers },
|
|
259
|
+
body: req.body,
|
|
260
|
+
repeatable: command.method === "get" || !!idempotencyKey,
|
|
261
|
+
timeoutMs: (g.timeout ?? 120) * 1000,
|
|
262
|
+
maxWaitMs: (g.maxWait ?? 300) * 1000,
|
|
263
|
+
onPending: ({ retryAfter }) => stderr.write(`${brand.bin}: still fetching, asking again in ${retryAfter} s (Idempotency-Key ${idempotencyKey})\n`),
|
|
264
|
+
fetchImpl: io.fetchImpl,
|
|
265
|
+
});
|
|
266
|
+
const again = idempotencyKey ? `Run the same command again with --idempotency-key ${idempotencyKey} within 15 minutes to get the result without a new charge.` : undefined;
|
|
267
|
+
|
|
268
|
+
if (outcome.kind === "ok") {
|
|
269
|
+
const out = render(outcome.body, g.output, command.cli.output);
|
|
270
|
+
stdout.write(out.stdout);
|
|
271
|
+
if (out.stderr) stderr.write(out.stderr);
|
|
272
|
+
return out.failed ? 1 : 0;
|
|
273
|
+
}
|
|
274
|
+
if (outcome.kind === "network") {
|
|
275
|
+
stderr.write(json({ error: { tag: "CONNECTION_ERROR", message: outcome.error }, requestId: outcome.requestId, ...(idempotencyKey && { idempotencyKey, hint: `The API may have received the request. ${again}` }) }));
|
|
276
|
+
return 3;
|
|
277
|
+
}
|
|
278
|
+
const body = outcome.body && typeof outcome.body === "object" && !Array.isArray(outcome.body) ? outcome.body : { error: { message: String(outcome.body ?? "").slice(0, 2000) } };
|
|
279
|
+
stderr.write(json({ status: outcome.status, ...body, requestId: body.requestId ?? outcome.requestId, ...(idempotencyKey && { idempotencyKey }), ...(outcome.kind === "pending" && again && { hint: `Still fetching. ${again}` }) }));
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Output modes. `json` (default): the answer as pretty JSON. `text`: with an `x-cli.output` hint, the content of each
|
|
4
|
+
* item (the first non-empty of hint.fields, e.g. markdown, then text, then html), and failed entries of hint.errors on
|
|
5
|
+
* stderr; without a hint, one `key.path: value` line per field. Any other mode names one field of each item
|
|
6
|
+
* (`-o html`, `-o title`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} OutputHint
|
|
11
|
+
* @property {string} [items] array of the answer to print, e.g. "results"
|
|
12
|
+
* @property {string[]} [fields] content fields in order of preference
|
|
13
|
+
* @property {string} [heading] item field shown above each item when there are several, e.g. "url"
|
|
14
|
+
* @property {string} [errors] array of per-item statuses whose `error` is printed on stderr, e.g. "statuses"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const json = (/** @type {any} */ v) => JSON.stringify(v, null, 2) + "\n";
|
|
18
|
+
|
|
19
|
+
/** Leaf values as `a.b: value` lines. */
|
|
20
|
+
export function flatten(/** @type {any} */ v, prefix = "", /** @type {string[]} */ out = []) {
|
|
21
|
+
if (v && typeof v === "object") {
|
|
22
|
+
const entries = Array.isArray(v) ? v.map((x, i) => [String(i), x]) : Object.entries(v);
|
|
23
|
+
if (!entries.length) out.push(`${prefix}: ${Array.isArray(v) ? "[]" : "{}"}`);
|
|
24
|
+
for (const [k, x] of entries) flatten(x, prefix ? `${prefix}.${k}` : k, out);
|
|
25
|
+
} else out.push(`${prefix}: ${v === null ? "null" : String(v)}`);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const asText = (/** @type {any} */ v) => (typeof v === "string" ? v : v === null || v === undefined ? "" : JSON.stringify(v));
|
|
30
|
+
const withNewline = (/** @type {string} */ s) => (s.endsWith("\n") ? s : `${s}\n`);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {any} body
|
|
34
|
+
* @param {string} mode
|
|
35
|
+
* @param {OutputHint | undefined} hint
|
|
36
|
+
* @returns {{ stdout: string, stderr: string, failed: boolean }} failed: nothing printed and every item failed
|
|
37
|
+
*/
|
|
38
|
+
export function render(body, mode, hint) {
|
|
39
|
+
if (mode === "json") return { stdout: json(body), stderr: "", failed: false };
|
|
40
|
+
if (typeof body === "string") return { stdout: withNewline(body), stderr: "", failed: false };
|
|
41
|
+
const items = hint?.items && Array.isArray(body?.[hint.items]) ? body[hint.items] : null;
|
|
42
|
+
if (!items) {
|
|
43
|
+
if (mode !== "text") return { stdout: withNewline(asText(body?.[mode])), stderr: "", failed: false };
|
|
44
|
+
return { stdout: flatten(body).join("\n") + "\n", stderr: "", failed: false };
|
|
45
|
+
}
|
|
46
|
+
const fields = mode === "text" ? hint?.fields ?? [] : [mode];
|
|
47
|
+
const parts = items.map((/** @type {any} */ item) => {
|
|
48
|
+
const value = fields.map((f) => item?.[f]).find((v) => v !== undefined && v !== null && v !== "");
|
|
49
|
+
return value === undefined && mode === "text" && !fields.length ? json(item) : withNewline(asText(value));
|
|
50
|
+
});
|
|
51
|
+
const stdout = items.length > 1 && hint?.heading
|
|
52
|
+
? items.map((/** @type {any} */ item, /** @type {number} */ i) => `==> ${asText(item?.[/** @type {string} */ (hint.heading)])} <==\n${parts[i]}`).join("\n")
|
|
53
|
+
: parts.join("\n");
|
|
54
|
+
const errors = (hint?.errors && Array.isArray(body?.[hint.errors]) ? body[hint.errors] : []).filter((/** @type {any} */ s) => s?.error);
|
|
55
|
+
const stderr = errors.map((/** @type {any} */ s) => `${s.id ?? s.url ?? "?"}: ${s.error.tag ?? "ERROR"}: ${s.error.message ?? ""}\n`).join("");
|
|
56
|
+
return { stdout, stderr, failed: items.length === 0 && errors.length > 0 };
|
|
57
|
+
}
|
package/src/spec.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The API spec the commands come from. Read at run time so an API change reaches every installed CLI without a new
|
|
4
|
+
* release: a cache younger than SPEC_TTL_MS, else a conditional GET of the live spec (If-None-Match), else the stale
|
|
5
|
+
* cache, else the copy bundled with the package. The spec only describes commands and flags: requests go to the base
|
|
6
|
+
* URL (brand.apiBaseUrl / --base-url), never to the spec's `servers`.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const SPEC_TTL_MS = 60 * 60 * 1000;
|
|
12
|
+
export const SPEC_TIMEOUT_MS = 3000;
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ url: string, etag: string | null, fetchedAt: number }} SpecMeta */
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} LoadedSpec
|
|
17
|
+
* @property {any} spec
|
|
18
|
+
* @property {"cache" | "network" | "stale-cache" | "bundled"} source
|
|
19
|
+
* @property {string} url the spec URL asked for
|
|
20
|
+
* @property {string | null} etag
|
|
21
|
+
* @property {number | null} fetchedAt ms epoch of the last download or revalidation (null: bundled)
|
|
22
|
+
* @property {string} [error] why the network was not used, when it was tried
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Minimal shape check before a downloaded spec replaces the cache. */
|
|
26
|
+
export function isSpec(/** @type {any} */ x) {
|
|
27
|
+
return (
|
|
28
|
+
!!x && typeof x === "object" && typeof x.openapi === "string" && /^3\./.test(x.openapi) &&
|
|
29
|
+
!!x.paths && typeof x.paths === "object" && !Array.isArray(x.paths)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const readJson = (/** @type {string} */ file) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Atomic write; a read-only cache directory is not an error. */
|
|
42
|
+
function writeAtomic(/** @type {string} */ file, /** @type {string} */ text) {
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
45
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
46
|
+
writeFileSync(tmp, text);
|
|
47
|
+
renameSync(tmp, file);
|
|
48
|
+
} catch {
|
|
49
|
+
// no cache then
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} o
|
|
55
|
+
* @param {string} o.url
|
|
56
|
+
* @param {string} o.cacheDir
|
|
57
|
+
* @param {string} o.bundledPath
|
|
58
|
+
* @param {boolean} [o.offline] cache or bundled copy only
|
|
59
|
+
* @param {boolean} [o.force] ignore the TTL (spec refresh)
|
|
60
|
+
* @param {number} [o.now]
|
|
61
|
+
* @param {number} [o.ttlMs]
|
|
62
|
+
* @param {number} [o.timeoutMs]
|
|
63
|
+
* @param {Record<string, string>} [o.headers]
|
|
64
|
+
* @param {typeof fetch} [o.fetchImpl]
|
|
65
|
+
* @returns {Promise<LoadedSpec>}
|
|
66
|
+
*/
|
|
67
|
+
export async function loadSpec(o) {
|
|
68
|
+
const now = o.now ?? Date.now();
|
|
69
|
+
const specFile = join(o.cacheDir, "openapi.json");
|
|
70
|
+
const metaFile = join(o.cacheDir, "openapi.meta.json");
|
|
71
|
+
/** @type {SpecMeta | null} */
|
|
72
|
+
const meta = readJson(metaFile);
|
|
73
|
+
const cachedSpec = meta && meta.url === o.url ? readJson(specFile) : null;
|
|
74
|
+
const cached = meta && isSpec(cachedSpec) ? { spec: cachedSpec, meta } : null;
|
|
75
|
+
const bundled = () => {
|
|
76
|
+
const spec = JSON.parse(readFileSync(o.bundledPath, "utf8"));
|
|
77
|
+
return { spec, url: o.url, etag: null, fetchedAt: null };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (cached && !o.force && (o.offline || now - cached.meta.fetchedAt < (o.ttlMs ?? SPEC_TTL_MS))) {
|
|
81
|
+
return { spec: cached.spec, source: o.offline && now - cached.meta.fetchedAt >= (o.ttlMs ?? SPEC_TTL_MS) ? "stale-cache" : "cache", url: o.url, etag: cached.meta.etag, fetchedAt: cached.meta.fetchedAt };
|
|
82
|
+
}
|
|
83
|
+
if (o.offline) return { ...bundled(), source: "bundled" };
|
|
84
|
+
|
|
85
|
+
let error;
|
|
86
|
+
try {
|
|
87
|
+
const res = await (o.fetchImpl ?? fetch)(o.url, {
|
|
88
|
+
headers: { Accept: "application/json", ...o.headers, ...(cached?.meta.etag ? { "If-None-Match": cached.meta.etag } : {}) },
|
|
89
|
+
signal: AbortSignal.timeout(o.timeoutMs ?? SPEC_TIMEOUT_MS),
|
|
90
|
+
});
|
|
91
|
+
if (res.status === 304 && cached) {
|
|
92
|
+
const fresh = { ...cached.meta, fetchedAt: now };
|
|
93
|
+
writeAtomic(metaFile, JSON.stringify(fresh) + "\n");
|
|
94
|
+
return { spec: cached.spec, source: "cache", url: o.url, etag: fresh.etag, fetchedAt: now };
|
|
95
|
+
}
|
|
96
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
97
|
+
const text = await res.text();
|
|
98
|
+
const spec = JSON.parse(text);
|
|
99
|
+
if (!isSpec(spec)) throw new Error("not an OpenAPI 3 document");
|
|
100
|
+
const etag = res.headers.get("etag");
|
|
101
|
+
writeAtomic(specFile, text);
|
|
102
|
+
writeAtomic(metaFile, JSON.stringify({ url: o.url, etag, fetchedAt: now }) + "\n");
|
|
103
|
+
return { spec, source: "network", url: o.url, etag, fetchedAt: now };
|
|
104
|
+
} catch (e) {
|
|
105
|
+
error = /** @type {Error} */ (e).name === "TimeoutError" ? `timeout after ${o.timeoutMs ?? SPEC_TIMEOUT_MS} ms` : /** @type {Error} */ (e).message;
|
|
106
|
+
}
|
|
107
|
+
if (cached) return { spec: cached.spec, source: "stale-cache", url: o.url, etag: cached.meta.etag, fetchedAt: cached.meta.fetchedAt, error };
|
|
108
|
+
return { ...bundled(), source: "bundled", error };
|
|
109
|
+
}
|
package/src/update.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Update notice: at most once a day, ask the npm registry for the latest version of this package and print one line on
|
|
4
|
+
* stderr when it is newer. Never in CI, never when stderr is not a terminal, never with <BIN>_NO_UPDATE_NOTIFIER or
|
|
5
|
+
* NO_UPDATE_NOTIFIER set. Commands do not depend on it: they follow the live spec anyway.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const UPDATE_CHECK_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
export const REGISTRY = "https://registry.npmjs.org";
|
|
12
|
+
|
|
13
|
+
/** FETCHMCP_NO_UPDATE_NOTIFIER for bin "fetchmcp". */
|
|
14
|
+
export const noUpdateVar = (/** @type {string} */ bin) => `${bin.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_NO_UPDATE_NOTIFIER`;
|
|
15
|
+
|
|
16
|
+
/** a > b for x.y.z versions (a pre-release suffix counts as older). */
|
|
17
|
+
export function newer(/** @type {string} */ a, /** @type {string} */ b) {
|
|
18
|
+
const parse = (/** @type {string} */ v) => {
|
|
19
|
+
const [core, pre] = String(v).replace(/^v/, "").split("-", 2);
|
|
20
|
+
return { nums: core.split(".").map((n) => Number(n) || 0), pre: pre !== undefined };
|
|
21
|
+
};
|
|
22
|
+
const x = parse(a);
|
|
23
|
+
const y = parse(b);
|
|
24
|
+
for (let i = 0; i < 3; i++) if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0)) return (x.nums[i] ?? 0) > (y.nums[i] ?? 0);
|
|
25
|
+
return !x.pre && y.pre;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} o
|
|
30
|
+
* @param {string} o.bin
|
|
31
|
+
* @param {string} o.npm
|
|
32
|
+
* @param {string} o.version
|
|
33
|
+
* @param {string} o.cacheDir
|
|
34
|
+
* @param {Record<string, string | undefined>} o.env
|
|
35
|
+
* @param {boolean} o.isTTY
|
|
36
|
+
* @param {number} [o.now]
|
|
37
|
+
* @param {string} [o.registry]
|
|
38
|
+
* @param {typeof fetch} [o.fetchImpl]
|
|
39
|
+
* @returns {Promise<string | null>}
|
|
40
|
+
*/
|
|
41
|
+
export async function updateNotice(o) {
|
|
42
|
+
if (!o.isTTY || o.env.CI || o.env[noUpdateVar(o.bin)] || o.env.NO_UPDATE_NOTIFIER) return null;
|
|
43
|
+
const now = o.now ?? Date.now();
|
|
44
|
+
const file = join(o.cacheDir, "update-check.json");
|
|
45
|
+
let latest = null;
|
|
46
|
+
try {
|
|
47
|
+
const cached = JSON.parse(readFileSync(file, "utf8"));
|
|
48
|
+
if (now - cached.checkedAt < UPDATE_CHECK_MS) latest = cached.latest;
|
|
49
|
+
} catch {
|
|
50
|
+
// no check yet
|
|
51
|
+
}
|
|
52
|
+
if (latest === null) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await (o.fetchImpl ?? fetch)(`${o.registry ?? REGISTRY}/${encodeURIComponent(o.npm).replace(/^%40/, "@")}/latest`, {
|
|
55
|
+
headers: { Accept: "application/json" },
|
|
56
|
+
signal: AbortSignal.timeout(1500),
|
|
57
|
+
});
|
|
58
|
+
latest = res.ok ? String((await res.json()).version ?? "") : "";
|
|
59
|
+
} catch {
|
|
60
|
+
latest = "";
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(o.cacheDir, { recursive: true });
|
|
64
|
+
writeFileSync(file, JSON.stringify({ checkedAt: now, latest }) + "\n");
|
|
65
|
+
} catch {
|
|
66
|
+
// checked again next time
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!latest || !newer(latest, o.version)) return null;
|
|
70
|
+
return `${o.bin} ${latest} is available (you have ${o.version}). Update: npm install -g ${o.npm}`;
|
|
71
|
+
}
|