@vatio-ai/cli 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ // secrets / tokens / widget — the three things a workspace has that its
2
+ // manifest deliberately does not carry.
3
+ //
4
+ // A secret is a credential, a publishable token is a credential, and the widget
5
+ // screen is a read of what the platform will actually enforce. None of them
6
+ // belong in a file that gets committed, which is why none of them are in
7
+ // vatio.yml and all of them are here.
8
+
9
+ import { deployClient } from "./deploy.mjs";
10
+ import { fail, takeValue } from "../support.mjs";
11
+
12
+ // live, preview, or a named preview like pr-42. This used to accept only the
13
+ // first two, which meant a workspace could deploy to `pr-42` and then have no
14
+ // way to mint the publishable token that makes it embeddable.
15
+ const ENVIRONMENT_NAME = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
16
+ const ENVIRONMENT_MAX = 40;
17
+
18
+ function environmentOption(args, fallback) {
19
+ const value =
20
+ takeValue(args, "--env") ?? takeValue(args, "--environment") ?? takeValue(args, "-e") ?? fallback;
21
+ if (String(value).length > ENVIRONMENT_MAX || !ENVIRONMENT_NAME.test(String(value))) {
22
+ fail(`Unknown environment "${value}" (use live, preview, or a preview name like pr-42)`);
23
+ }
24
+ return String(value);
25
+ }
26
+
27
+ export async function secrets(config, args) {
28
+ const workspace = config.resolveWorkspaceRequired();
29
+ const client = deployClient(config, workspace);
30
+ const sub = args.shift() ?? "list";
31
+
32
+ if (sub === "list") {
33
+ const payload = await client.listSecrets();
34
+ const keys = asArray(payload.secrets);
35
+ if (keys.length === 0) {
36
+ console.log(`No secrets on workspace ${workspace}`);
37
+ console.log(" Set one: vatio secrets set STRIPE_KEY sk_live_…");
38
+ return;
39
+ }
40
+ // Keys only, never values. The platform does not hand a value back once it
41
+ // is stored, and a CLI that printed them would be a CLI that leaks them
42
+ // into a terminal scrollback.
43
+ console.log(`Secrets on workspace ${workspace} (${keys.length}):`);
44
+ for (const secret of keys) console.log(` ${secret.key ?? secret}`);
45
+ return;
46
+ }
47
+ if (sub === "set") {
48
+ const key = args.shift();
49
+ const value = args.shift();
50
+ if (!key || value === undefined) fail("Usage: vatio secrets set KEY VALUE");
51
+ await client.upsertSecret({ key, value });
52
+ console.log(`Set ${key} on workspace ${workspace}`);
53
+ return;
54
+ }
55
+ if (sub === "rm" || sub === "remove" || sub === "delete") {
56
+ const key = args.shift();
57
+ if (!key) fail("Usage: vatio secrets rm KEY");
58
+ await client.deleteSecret({ key });
59
+ console.log(`Removed ${key} from workspace ${workspace}`);
60
+ return;
61
+ }
62
+
63
+ fail("Usage: vatio secrets list|set KEY VALUE|rm KEY");
64
+ }
65
+
66
+ export async function tokens(config, args) {
67
+ const environment = environmentOption(args, "live");
68
+ const label = takeValue(args, "--label") ?? takeValue(args, "-l");
69
+ const workspace = config.resolveWorkspaceRequired();
70
+ const client = deployClient(config, workspace);
71
+ const sub = args.shift() ?? "list";
72
+
73
+ if (sub === "list") {
74
+ const payload = await client.listPublishableTokens();
75
+ const list = asArray(payload.publishable_tokens);
76
+ if (list.length === 0) {
77
+ console.log(`No publishable tokens on workspace ${workspace}`);
78
+ console.log(" Create one: vatio tokens create --env live");
79
+ return;
80
+ }
81
+ console.log(`Publishable tokens on workspace ${workspace} (${list.length}):`);
82
+ for (const token of list) {
83
+ const parts = [token.key_prefix ?? token.prefix, token.environment];
84
+ if (token.label) parts.push(token.label);
85
+ console.log(` ${parts.filter(Boolean).join(" ")}`);
86
+ }
87
+ return;
88
+ }
89
+ if (sub === "create") {
90
+ const payload = await client.createPublishableToken({ environment, label });
91
+ console.log(`Created a publishable token for ${environment} on workspace ${workspace}`);
92
+ console.log("");
93
+ // Shown in full exactly once: the platform stores a digest, so this is the
94
+ // only moment the value exists anywhere the developer can copy it.
95
+ console.log(` ${payload.token ?? payload.publishable_token}`);
96
+ console.log("");
97
+ console.log("Copy it now — it is not shown again.");
98
+ return;
99
+ }
100
+ if (sub === "revoke") {
101
+ const prefix = args.shift();
102
+ if (!prefix) fail("Usage: vatio tokens revoke PREFIX");
103
+ await client.revokePublishableToken({ prefix });
104
+ console.log(`Revoked ${prefix} on workspace ${workspace}`);
105
+ return;
106
+ }
107
+
108
+ fail("Usage: vatio tokens list|create [--env live|preview|NAME] [--label NAME]|revoke PREFIX");
109
+ }
110
+
111
+ // Everything needed to put the bubble on a page, in one screen: what the
112
+ // platform will actually enforce, the tokens that exist, and the snippet to
113
+ // paste. Read-only -- vatio.yml owns every field, so `vatio push` is what
114
+ // changes it.
115
+ export async function widget(config, args) {
116
+ const environment = environmentOption(args, "live");
117
+ const workspace = config.resolveWorkspaceRequired();
118
+ const payload = await deployClient(config, workspace).widget();
119
+
120
+ console.log(JSON.stringify(payload, null, 2));
121
+ console.log("");
122
+ console.log("The widget's look comes from vatio.yml's `widget:` block — edit it and push.");
123
+ console.log(`A token for this environment: vatio tokens create --env ${environment}`);
124
+ }
125
+
126
+ function asArray(value) {
127
+ return Array.isArray(value) ? value : [];
128
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,223 @@
1
+ // Local-only CLI settings, in the same file the Ruby CLI uses:
2
+ // ~/.vatio/config.json, or $VATIO_HOME/config.json.
3
+ //
4
+ // Deliberately the same file and the same keys. Someone who already ran
5
+ // `vatio login` through the curl installer can run `npx vatio push` without
6
+ // logging in again, and can go back, because neither client owns the
7
+ // credential -- the developer's home does.
8
+ //
9
+ // Two scopes, as before: credentials are per developer and live here; workspace
10
+ // identity is per directory and lives in that workspace's own vatio.yml. There
11
+ // is no --workspace flag, because the slug is a property of the directory, and
12
+ // reading it anywhere else is how a push lands in the wrong workspace.
13
+
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join, resolve } from "node:path";
17
+
18
+ import { MANIFEST_FILE, SLUG_FORMAT, findWorkspaceRoot, manifestPath, workspaceSlug } from "./workspace.mjs";
19
+
20
+ export const KEYS = ["base_url", "token"];
21
+ export const DEFAULT_BASE_URL = "https://vatio.ai";
22
+ // `as`, `channel` and `from` configured a simulated visitor for `vatio chat`.
23
+ // That simulation is gone from the platform, so they are refused as keys and
24
+ // dropped from a config file that still carries them.
25
+ export const REMOVED_KEYS = ["as", "channel", "from"];
26
+
27
+ export class ConfigError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = "ConfigError";
31
+ }
32
+ }
33
+
34
+ export function configHome() {
35
+ const override = String(process.env.VATIO_HOME ?? "").trim();
36
+ return override === "" ? join(homedir(), ".vatio") : resolve(override);
37
+ }
38
+
39
+ export class Config {
40
+ constructor({ startDir = process.cwd() } = {}) {
41
+ this.startDir = resolve(startDir);
42
+ this.configPath = join(configHome(), "config.json");
43
+ this.workspaceRoot = findWorkspaceRoot(this.startDir);
44
+ this.legacyKeysRemoved = [];
45
+ }
46
+
47
+ load() {
48
+ if (!existsSync(this.configPath)) return {};
49
+ let data;
50
+ try {
51
+ data = JSON.parse(readFileSync(this.configPath, "utf8"));
52
+ } catch {
53
+ return {};
54
+ }
55
+ if (!data || typeof data !== "object" || Array.isArray(data)) return {};
56
+ return this.#dropRemovedKeys(data);
57
+ }
58
+
59
+ save(data) {
60
+ mkdirSync(dirname(this.configPath), { recursive: true });
61
+ writeFileSync(this.configPath, `${JSON.stringify(data, null, 2)}\n`);
62
+ // A token lives here. 0600 matches what the Ruby CLI writes, so switching
63
+ // between the two clients never loosens the file.
64
+ chmodSync(this.configPath, 0o600);
65
+ return data;
66
+ }
67
+
68
+ get(key) {
69
+ return presence(this.load()[this.#normalizeKey(key)]);
70
+ }
71
+
72
+ set(key, value) {
73
+ const normalized = this.#normalizeKey(key);
74
+ const cleaned = String(value ?? "").trim();
75
+ if (cleaned === "") throw new ConfigError(`${normalized} cannot be empty`);
76
+
77
+ const data = this.load();
78
+ data[normalized] = cleaned;
79
+ this.save(data);
80
+ return cleaned;
81
+ }
82
+
83
+ unset(key) {
84
+ const normalized = this.#normalizeKey(key);
85
+ const data = this.load();
86
+ delete data[normalized];
87
+ this.save(data);
88
+ return null;
89
+ }
90
+
91
+ resolveBaseUrl() {
92
+ return (
93
+ stripSlash(presence(process.env.VATIO_BASE_URL)) ??
94
+ stripSlash(presence(this.load().base_url)) ??
95
+ DEFAULT_BASE_URL
96
+ );
97
+ }
98
+
99
+ resolveToken() {
100
+ return presence(process.env.VATIO_TOKEN) ?? presence(this.load().token);
101
+ }
102
+
103
+ // One source, always: the manifest of the workspace the cwd is in.
104
+ resolveWorkspace() {
105
+ if (!this.workspaceRoot) return null;
106
+ return presence(workspaceSlug(this.workspaceRoot))?.toLowerCase() ?? null;
107
+ }
108
+
109
+ resolveWorkspaceRequired() {
110
+ const slug = this.resolveWorkspace();
111
+ if (slug) return slug;
112
+
113
+ const manifest = this.manifestPath();
114
+ if (manifest) {
115
+ throw new ConfigError(
116
+ `${MANIFEST_FILE} does not say which workspace it is.\n` +
117
+ `Add this as its first line:\n\n workspace: ${this.suggestedSlug()}`
118
+ );
119
+ }
120
+ return this.workspaceRootRequired();
121
+ }
122
+
123
+ workspaceRootRequired() {
124
+ if (this.workspaceRoot) return this.workspaceRoot;
125
+ throw new ConfigError(
126
+ `Not inside a Vatio workspace (no ${MANIFEST_FILE} found walking up from ${this.startDir}).\n` +
127
+ "Run `vatio init SLUG` here to create one."
128
+ );
129
+ }
130
+
131
+ manifestPath() {
132
+ return this.workspaceRoot ? manifestPath(this.workspaceRoot) : null;
133
+ }
134
+
135
+ // The workspace's slice of the API, which is what chat talks to.
136
+ resolveApiUrl({ explicit = null } = {}) {
137
+ const url = presence(explicit);
138
+ if (url) return stripSlash(url);
139
+
140
+ const base = this.resolveBaseUrl();
141
+ const workspace = this.resolveWorkspace();
142
+ if (!base || !workspace) return null;
143
+ return `${base}/api/v1/${workspace}`;
144
+ }
145
+
146
+ writeAuth({ baseUrl, token }) {
147
+ const data = this.load();
148
+ data.base_url = stripSlash(String(baseUrl ?? "")) ?? "";
149
+ data.token = String(token ?? "");
150
+ return this.save(data);
151
+ }
152
+
153
+ clearToken() {
154
+ const data = this.load();
155
+ delete data.token;
156
+ return this.save(data);
157
+ }
158
+
159
+ displayHash() {
160
+ const out = {};
161
+ const base = this.resolveBaseUrl();
162
+ if (base) out.base_url = base;
163
+ const token = this.resolveToken();
164
+ if (token) out.token = maskSecret(token);
165
+ return out;
166
+ }
167
+
168
+ // What `vatio init` would name a workspace created right here.
169
+ suggestedSlug() {
170
+ const base = (this.workspaceRoot ?? this.startDir).split(/[\\/]/).filter(Boolean).pop() ?? "";
171
+ const name = base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
172
+ return name === "" ? "my-agent" : name;
173
+ }
174
+
175
+ #dropRemovedKeys(data) {
176
+ const removed = REMOVED_KEYS.filter((key) => Object.hasOwn(data, key));
177
+ if (removed.length === 0) return data;
178
+
179
+ for (const key of removed) delete data[key];
180
+ this.legacyKeysRemoved = removed;
181
+ this.save(data);
182
+ return data;
183
+ }
184
+
185
+ #normalizeKey(key) {
186
+ const normalized = String(key ?? "").trim();
187
+ if (normalized === "assistants") {
188
+ throw new ConfigError(
189
+ 'config key "assistants" was removed — Vatio no longer installs local skills; see https://vatio.ai/docs'
190
+ );
191
+ }
192
+ if (normalized === "workspace") {
193
+ throw new ConfigError(`the workspace is named by \`workspace:\` in ${MANIFEST_FILE}, never by config`);
194
+ }
195
+ if (REMOVED_KEYS.includes(normalized)) {
196
+ throw new ConfigError(
197
+ `config key "${normalized}" was removed — a \`vatio chat\` is you, the developer holding the ` +
198
+ "token. Test a real channel with `vatio whatsapp numbers` or an Instagram test account, and " +
199
+ "pick a deployment with `vatio chat --env NAME`."
200
+ );
201
+ }
202
+ if (!KEYS.includes(normalized)) {
203
+ throw new ConfigError(`unknown key "${key}" (allowed: ${KEYS.join(", ")})`);
204
+ }
205
+ return normalized;
206
+ }
207
+ }
208
+
209
+ export { SLUG_FORMAT };
210
+
211
+ function presence(value) {
212
+ const text = String(value ?? "").trim();
213
+ return text === "" ? null : text;
214
+ }
215
+
216
+ function stripSlash(value) {
217
+ return value == null ? null : value.replace(/\/$/, "");
218
+ }
219
+
220
+ function maskSecret(value) {
221
+ if (!value) return null;
222
+ return value.length <= 8 ? value : `${value.slice(0, 4)}…${value.slice(-4)}`;
223
+ }
@@ -0,0 +1,68 @@
1
+ // Device-code auth (/cli/device_authorizations): the CLI asks for a code, the
2
+ // developer approves it in a browser, the CLI polls until a token comes back.
3
+ // The port of cli/lib/device_auth_client.rb.
4
+ //
5
+ // Unauthenticated by definition -- this is what mints the token -- so it does
6
+ // not go through HttpClient, which requires one.
7
+
8
+ export class DeviceAuthError extends Error {}
9
+ export class PendingError extends DeviceAuthError {}
10
+ export class DeniedError extends DeviceAuthError {}
11
+ export class ExpiredError extends DeviceAuthError {}
12
+
13
+ export class DeviceAuthClient {
14
+ constructor({ baseUrl }) {
15
+ this.baseUrl = String(baseUrl ?? "").replace(/\/$/, "");
16
+ }
17
+
18
+ start() {
19
+ return this.#post("/cli/device_authorizations", {});
20
+ }
21
+
22
+ poll({ deviceCode }) {
23
+ return this.#post("/cli/device_authorizations/token", { device_code: deviceCode });
24
+ }
25
+
26
+ async #post(path, body) {
27
+ const url = `${this.baseUrl}${path}`;
28
+ let response;
29
+ try {
30
+ response = await fetch(url, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
33
+ body: JSON.stringify(body),
34
+ signal: AbortSignal.timeout(30_000)
35
+ });
36
+ } catch (error) {
37
+ const reason = error?.cause?.code ?? error?.name ?? "request failed";
38
+ throw new DeviceAuthError(`${reason}: ${url}`);
39
+ }
40
+
41
+ const raw = await response.text();
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ } catch {
46
+ parsed = {};
47
+ }
48
+ const described = parsed?.error_description ?? parsed?.error ?? raw;
49
+
50
+ if (response.status === 200 || response.status === 201) {
51
+ // The pending case is a 200 with an error code in the body, which is how
52
+ // the OAuth device flow reports "the human has not clicked yet".
53
+ if (parsed?.error === "authorization_pending") {
54
+ throw new PendingError(parsed.error_description ?? "authorization_pending");
55
+ }
56
+ return parsed;
57
+ }
58
+ if (response.status === 400) {
59
+ if (parsed?.error === "expired_token") throw new ExpiredError(described ?? "expired");
60
+ if (parsed?.error === "access_denied") throw new DeniedError(described ?? "denied");
61
+ throw new DeviceAuthError(String(described));
62
+ }
63
+ if (response.status === 403 || response.status === 404) {
64
+ throw new DeniedError(String(described));
65
+ }
66
+ throw new DeviceAuthError(`HTTP ${response.status}: ${String(described)}`);
67
+ }
68
+ }
package/lib/help.mjs ADDED
@@ -0,0 +1,69 @@
1
+ import { VERSION } from "./version.mjs";
2
+
3
+ export function helpText() {
4
+ return `Vatio CLI ${VERSION}
5
+
6
+ npx @vatio-ai/cli init my-agent Start a workspace here
7
+ npx @vatio-ai/cli push Deploy it to a preview
8
+
9
+ (installed globally with \`npm i -g @vatio-ai/cli\`, the command is just \`vatio\`)
10
+
11
+ A workspace is any directory with a vatio.yml. Every command except init reads
12
+ \`workspace:\` from the vatio.yml at or above the cwd — there is no --workspace
13
+ flag, the directory decides.
14
+
15
+ --env NAME targets a deployment: live, preview, or a preview name like pr-42.
16
+
17
+ Getting started:
18
+ init [SLUG] [--name NAME] Create vatio.yml here, and the remote to match
19
+ login [--base-url URL] Authorize this machine in a browser
20
+ logout Forget the token
21
+ doctor Node, config, workspace and token status
22
+ version What this CLI is
23
+ docs [--save [PATH]] The whole developer contract, live from Vatio
24
+
25
+ Deploy:
26
+ push [--env NAME] Validate the workspace, then update a preview
27
+ publish [--env NAME] Promote a preview to live. --env names which one
28
+ rollback Restore the previous live deployment
29
+ status Preview and live deployment state
30
+ diff [--env NAME] What this directory would change (default: preview)
31
+ --env live answers "what would publishing change?"
32
+ diff --stat|--name-only|--format json|--full
33
+ tools check Validate the workspace against Vatio's contracts
34
+
35
+ Talk to your agent (as yourself — the token is the identity):
36
+ chat "message" [--env NAME] preview by default; --env live is real
37
+ chat transcript|debug|reset [--last N]
38
+ chat destroy CHAT_ID
39
+
40
+ Knowledge:
41
+ kb [list] Bases, and whether a deployed agent reads each
42
+ kb show NAME One base and its sources
43
+ kb create NAME | rm NAME
44
+ kb add-source BASE NAME URL [--include P] [--exclude P]
45
+ kb upload BASE FILE... Upload files into a base
46
+ kb rm-source BASE NAME | reindex BASE [NAME]
47
+
48
+ Credentials and the widget:
49
+ secrets list|set KEY VALUE|rm KEY
50
+ tokens list|create [--env NAME] [--label NAME]|revoke PREFIX
51
+ widget [--env NAME] What the platform enforces, and the tokens there are
52
+
53
+ Channels (your own account serves live; the shared preview always serves preview):
54
+ whatsapp [status]|connect|check|activate|deactivate|disconnect
55
+ whatsapp numbers list|add PHONE|verify PHONE CODE|resend PHONE|remove PHONE
56
+ instagram [status]|connect|check|disconnect
57
+ instagram accounts list|add @HANDLE|verify CODE|resend ID|remove ID
58
+
59
+ Everything else:
60
+ issue "what should change"|list|show ID|comment ID "reply"|--template
61
+ mcp Speak MCP over stdio, for a coding agent
62
+ config show|get KEY|set KEY VALUE|unset KEY
63
+ Keys: base_url, token
64
+
65
+ Environment: VATIO_BASE_URL, VATIO_TOKEN, VATIO_HOME
66
+
67
+ Docs: https://vatio.ai/docs
68
+ `;
69
+ }
package/lib/http.mjs ADDED
@@ -0,0 +1,151 @@
1
+ // Bearer JSON client for the Vatio API. The Ruby CLI's VatioHttpClient, in
2
+ // Node: same paths, same bearer header, same typed errors, so both clients
3
+ // report a 401 or a 422 the same way.
4
+ //
5
+ // fetch() rather than a library: it has been in Node since 18, and this package
6
+ // stays at one dependency (yaml, for the one file a workspace still parses).
7
+
8
+ export class HttpError extends Error {
9
+ constructor(message, { status = null, requestId = null, body = {} } = {}) {
10
+ super(message);
11
+ this.name = "HttpError";
12
+ this.status = status;
13
+ this.requestId = requestId;
14
+ this.body = body && typeof body === "object" ? body : {};
15
+ }
16
+ }
17
+
18
+ export class UnauthorizedError extends HttpError {}
19
+ export class ForbiddenError extends HttpError {}
20
+ export class NotFoundError extends HttpError {}
21
+
22
+ export class UnprocessableError extends HttpError {
23
+ constructor(body) {
24
+ const hash = body && typeof body === "object" ? body : {};
25
+ super(unprocessableMessage(hash), { status: 422, requestId: hash.request_id, body: hash });
26
+ this.errorKey = hash.error_key ?? null;
27
+ this.errorMessage = hash.error_message ?? null;
28
+ }
29
+ }
30
+
31
+ // `errors` is a list of strings on the deploy endpoints and a list of
32
+ // { message, path } on /deploy/check, which carries the file a message is
33
+ // about. Read both rather than printing an object at someone.
34
+ function unprocessableMessage(body) {
35
+ const errors = (Array.isArray(body.errors) ? body.errors : [])
36
+ .filter((entry) => entry != null)
37
+ .map((entry) => (typeof entry === "object" ? String(entry.message ?? "") : String(entry)));
38
+ if (errors.length > 0) return errors.join("; ");
39
+
40
+ const message = body.error_message ?? body.error;
41
+ if (message != null) return Array.isArray(message) ? message.join("; ") : String(message);
42
+
43
+ return "Unprocessable";
44
+ }
45
+
46
+ const CONNECT_TIMEOUT_MS = 5_000;
47
+ const READ_TIMEOUT_MS = 120_000;
48
+
49
+ export class HttpClient {
50
+ constructor({ baseUrl, token }) {
51
+ this.baseUrl = String(baseUrl ?? "").replace(/\/$/, "");
52
+ this.token = String(token ?? "");
53
+ if (!this.baseUrl) throw new Error("baseUrl is required");
54
+ if (!this.token) throw new Error("token is required");
55
+ }
56
+
57
+ get(path, params = {}) {
58
+ const query = new URLSearchParams();
59
+ for (const [key, value] of Object.entries(params)) {
60
+ if (value !== undefined && value !== null && value !== "") query.set(key, String(value));
61
+ }
62
+ const suffix = query.size > 0 ? `?${query}` : "";
63
+ return this.#perform("GET", `${path}${suffix}`);
64
+ }
65
+
66
+ post(path, body = {}) {
67
+ return this.#perform("POST", path, body);
68
+ }
69
+
70
+ put(path, body = {}) {
71
+ return this.#perform("PUT", path, body);
72
+ }
73
+
74
+ patch(path, body = {}) {
75
+ return this.#perform("PATCH", path, body);
76
+ }
77
+
78
+ delete(path) {
79
+ return this.#perform("DELETE", path);
80
+ }
81
+
82
+ async #perform(method, path, body) {
83
+ const url = `${this.baseUrl}${path}`;
84
+ const headers = {
85
+ Authorization: `Bearer ${this.token}`,
86
+ Accept: "application/json"
87
+ };
88
+ const init = { method, headers, signal: AbortSignal.timeout(READ_TIMEOUT_MS) };
89
+ if (body !== undefined) {
90
+ headers["Content-Type"] = "application/json";
91
+ init.body = JSON.stringify(body);
92
+ }
93
+
94
+ let response;
95
+ try {
96
+ response = await fetch(url, init);
97
+ } catch (error) {
98
+ // A refused connection, an unknown host or a timeout are all "the
99
+ // platform did not answer", and the developer only needs to know which
100
+ // host did not answer.
101
+ const reason = error?.cause?.code ?? error?.name ?? "request failed";
102
+ throw new HttpError(`${reason}: ${url}`);
103
+ }
104
+
105
+ return this.#parse(response);
106
+ }
107
+
108
+ async #parse(response) {
109
+ const status = response.status;
110
+ const raw = await response.text();
111
+ const body = parseJson(raw);
112
+ const requestId = body?.request_id ?? response.headers.get("x-request-id") ?? null;
113
+ const message = responseMessage(body, raw, response);
114
+
115
+ if (status >= 200 && status < 300) {
116
+ if (raw.trim() === "") return {};
117
+ if (body === null) throw new HttpError(`HTTP ${status}: invalid JSON`, { status, requestId });
118
+ return body;
119
+ }
120
+
121
+ const options = { status, requestId, body: body ?? {} };
122
+ if (status === 401) throw new UnauthorizedError(message, options);
123
+ if (status === 403) throw new ForbiddenError(message, options);
124
+ if (status === 404) throw new NotFoundError(message, options);
125
+ if (status === 422) throw new UnprocessableError(body);
126
+ throw new HttpError(`HTTP ${status}: ${message}`, options);
127
+ }
128
+ }
129
+
130
+ function parseJson(raw) {
131
+ if (String(raw ?? "").trim() === "") return {};
132
+ try {
133
+ const parsed = JSON.parse(raw);
134
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { data: parsed };
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ function responseMessage(body, raw, response) {
141
+ if (body) {
142
+ let value = body.error_description ?? body.error_message ?? body.error;
143
+ if (value && typeof value === "object" && !Array.isArray(value)) value = value.message;
144
+ if (value != null) return Array.isArray(value) ? value.join("; ") : String(value);
145
+ }
146
+
147
+ const text = String(raw ?? "").trim();
148
+ // An HTML error page says nothing a developer can act on; the status text does.
149
+ if (text === "" || text.startsWith("<")) return response.statusText || `HTTP ${response.status}`;
150
+ return text.slice(0, 200);
151
+ }