@terminus-ai/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- package/package.json +41 -0
package/bin/http.mjs
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one HTTP client the CLI talks to Terminus through.
|
|
3
|
+
*
|
|
4
|
+
* - Where: one API base (`--api-base`, else TERMINUS_API_BASE, else the saved
|
|
5
|
+
* login's, else production) and one token (TERMINUS_TOKEN, else the saved
|
|
6
|
+
* login), resolved here and nowhere else.
|
|
7
|
+
* - What: every door is a template from bin/endpoints.mjs, so the CLI can only
|
|
8
|
+
* call a door that table lists — and the table is checked against the
|
|
9
|
+
* backend's route contract. The one exception is the app host's
|
|
10
|
+
* passthrough in `terminus dev` (`openResponse`), which forwards an app's
|
|
11
|
+
* own `/_terminus` requests by the app host's contract instead.
|
|
12
|
+
* - How: every request says `User-Agent: terminus-cli/<version>`; the request
|
|
13
|
+
* has 30 s, plus time for what it uploads, to be answered, and the answer
|
|
14
|
+
* may take as long as its bytes keep arriving; a failure keeps the
|
|
15
|
+
* backend's `{error: {code, message, details}}` on the thrown CliError
|
|
16
|
+
* (`status`, `apiCode`, `details`) and picks the exit code from it; and only
|
|
17
|
+
* a request that is safe to repeat (GET, HEAD, or one carrying an
|
|
18
|
+
* Idempotency-Key) is repeated after a transient failure, with backoff.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
26
|
+
|
|
27
|
+
import { endpointPath } from "./endpoints.mjs";
|
|
28
|
+
import { CliError, authError } from "./errors.mjs";
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_TERMINUS_API_BASE = "https://api.terminus.build";
|
|
31
|
+
|
|
32
|
+
export const CLI_VERSION = JSON.parse(
|
|
33
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
34
|
+
).version;
|
|
35
|
+
|
|
36
|
+
/** Every request names the client it came from; the backend records it on
|
|
37
|
+
* skill uses and in the sign-in audit. */
|
|
38
|
+
export const USER_AGENT = `terminus-cli/${CLI_VERSION}`;
|
|
39
|
+
|
|
40
|
+
/* ── Where the API is ───────────────────────────────────────────────────── */
|
|
41
|
+
|
|
42
|
+
export function normalizeBase(value) {
|
|
43
|
+
return String(value).trim().replace(/\/+$/, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeApiBase(value) {
|
|
47
|
+
const base = normalizeBase(value);
|
|
48
|
+
return base.endsWith("/v1") ? base : `${base}/v1`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Empty values from dotenv templates must not shadow durable credentials.
|
|
52
|
+
export function presentValue(value) {
|
|
53
|
+
if (typeof value !== "string") return undefined;
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return trimmed || undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The API base a command talks to: `--api-base`, TERMINUS_API_BASE, the
|
|
59
|
+
* base the saved login was made against, or production. */
|
|
60
|
+
export async function commandApiBase(flags = {}) {
|
|
61
|
+
const explicit = presentValue(flags.api_base) ?? presentValue(process.env.TERMINUS_API_BASE);
|
|
62
|
+
if (explicit) return normalizeApiBase(explicit);
|
|
63
|
+
const session = await readSession();
|
|
64
|
+
return normalizeApiBase(session?.api_base ?? DEFAULT_TERMINUS_API_BASE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ── Who is asking ──────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
export function sessionPath() {
|
|
70
|
+
return path.join(os.homedir(), ".terminus", "session.json");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readSession() {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(await readFile(sessionPath(), "utf8"));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A saved login is a browser device session, and it always carries its
|
|
82
|
+
* expiry. Files from older builds (an API key, or a token without an
|
|
83
|
+
* expiry) are not logins; `terminus login` replaces them. */
|
|
84
|
+
export function sessionTokenIsUsable(session, now = Date.now()) {
|
|
85
|
+
const token = presentValue(session?.token);
|
|
86
|
+
if (!token || session.api_key) return false;
|
|
87
|
+
const expiresAt = Date.parse(session.expires_at ?? "");
|
|
88
|
+
return Number.isFinite(expiresAt) && expiresAt > now + 30_000;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The exit-3 error for a missing, expired, or pre-device login. */
|
|
92
|
+
export function signInError(session) {
|
|
93
|
+
if (presentValue(session?.token) && session.expires_at && !session.api_key) {
|
|
94
|
+
return authError("Your login has expired. Run `terminus login` to sign in again.");
|
|
95
|
+
}
|
|
96
|
+
return authError("Not signed in. Run `terminus login` (or set TERMINUS_TOKEN for automation).");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The token a command signs its requests with: TERMINUS_TOKEN (a session
|
|
101
|
+
* token the environment supplies, for automation), else the saved login.
|
|
102
|
+
* `source` says which, and `session` is the saved login when it is that.
|
|
103
|
+
* Required and missing, it throws the exit-3 sign-in error; optional and
|
|
104
|
+
* missing, it answers null.
|
|
105
|
+
*/
|
|
106
|
+
export async function resolveToken({ required = true } = {}) {
|
|
107
|
+
const fromEnvironment = presentValue(process.env.TERMINUS_TOKEN);
|
|
108
|
+
if (fromEnvironment) return { token: fromEnvironment, source: "env", session: null };
|
|
109
|
+
const session = await readSession();
|
|
110
|
+
if (sessionTokenIsUsable(session)) return { token: session.token.trim(), source: "session", session };
|
|
111
|
+
if (required) throw signInError(session);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ── How long, and how often ────────────────────────────────────────────── */
|
|
116
|
+
|
|
117
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
118
|
+
const IDLE_TIMEOUT_MS = 30_000;
|
|
119
|
+
/** The slowest connection a transfer is budgeted for. */
|
|
120
|
+
const MIN_BYTES_PER_SECOND = 64 * 1024;
|
|
121
|
+
const MAX_TRANSFER_TIMEOUT_MS = 30 * 60_000;
|
|
122
|
+
const MAX_ATTEMPTS = 3;
|
|
123
|
+
const RETRY_BASE_MS = 250;
|
|
124
|
+
/** A server that asks for a longer pause than this gets it from the person. */
|
|
125
|
+
const MAX_RETRY_AFTER_MS = 10_000;
|
|
126
|
+
|
|
127
|
+
/** The time a transfer of `bytes` gets: 30 s, plus the bytes at 64 KiB/s. */
|
|
128
|
+
export function transferTimeoutMs(bytes = 0) {
|
|
129
|
+
const extra = Math.ceil((Math.max(0, Number(bytes) || 0) / MIN_BYTES_PER_SECOND) * 1000);
|
|
130
|
+
return Math.min(REQUEST_TIMEOUT_MS + extra, MAX_TRANSFER_TIMEOUT_MS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function backoffMs(attempt) {
|
|
134
|
+
const base = RETRY_BASE_MS * 2 ** (attempt - 1);
|
|
135
|
+
return Math.round(base * (0.8 + Math.random() * 0.4));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function retryAfterMs(headers) {
|
|
139
|
+
const raw = headers.get("retry-after");
|
|
140
|
+
if (!raw) return null;
|
|
141
|
+
const seconds = Number(raw);
|
|
142
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
143
|
+
const at = Date.parse(raw);
|
|
144
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── One request ────────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
const CANCELLED = Symbol("cancelled");
|
|
150
|
+
const TIMED_OUT = Symbol("timed out");
|
|
151
|
+
/** What makes a failure worth one more attempt, when the request is safe to
|
|
152
|
+
* repeat: `{ waitMs }` (null waits the usual backoff). */
|
|
153
|
+
const TRANSIENT = Symbol("transient");
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Send one request to `url` and read its answer.
|
|
157
|
+
*
|
|
158
|
+
* `body` is a string or bytes — or a function that returns a fresh body for
|
|
159
|
+
* each attempt, for a stream. `bodyBytes` is what it carries and
|
|
160
|
+
* `expectedBytes` what the answer carries, when known: both only size the
|
|
161
|
+
* timeouts. `as` is how the answer is read: "json" (an empty answer is `{}`),
|
|
162
|
+
* "bytes", or "none". `idempotent: true` declares a request safe to repeat
|
|
163
|
+
* that is not a GET and carries no Idempotency-Key, like a content-addressed
|
|
164
|
+
* upload.
|
|
165
|
+
*/
|
|
166
|
+
export async function send(url, options = {}) {
|
|
167
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
168
|
+
const headers = { "User-Agent": USER_AGENT, ...(options.headers ?? {}) };
|
|
169
|
+
const safeToRepeat = options.idempotent
|
|
170
|
+
?? (method === "GET" || method === "HEAD"
|
|
171
|
+
|| Object.keys(headers).some((name) => name.toLowerCase() === "idempotency-key"));
|
|
172
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
173
|
+
try {
|
|
174
|
+
return await exchange(url, method, headers, options);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const transient = error?.[TRANSIENT];
|
|
177
|
+
if (!transient || !safeToRepeat || attempt >= MAX_ATTEMPTS) throw error;
|
|
178
|
+
await sleep(transient.waitMs ?? backoffMs(attempt));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function exchange(url, method, headers, options) {
|
|
184
|
+
const label = options.label ?? "Terminus API";
|
|
185
|
+
const caller = options.signal;
|
|
186
|
+
if (caller?.aborted) throw new CliError(`${label} request was cancelled: ${url}`);
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const onCaller = () => controller.abort(CANCELLED);
|
|
189
|
+
caller?.addEventListener("abort", onCaller, { once: true });
|
|
190
|
+
const requestMs = options.timeoutMs ?? transferTimeoutMs(options.bodyBytes ?? 0);
|
|
191
|
+
let phase = "request";
|
|
192
|
+
let timer = setTimeout(() => controller.abort(TIMED_OUT), requestMs);
|
|
193
|
+
let whole = null;
|
|
194
|
+
const fail = (error) => {
|
|
195
|
+
if (controller.signal.reason === CANCELLED) {
|
|
196
|
+
return new CliError(`${label} request was cancelled: ${url}`);
|
|
197
|
+
}
|
|
198
|
+
if (controller.signal.reason === TIMED_OUT) {
|
|
199
|
+
return new CliError(
|
|
200
|
+
phase === "request"
|
|
201
|
+
? `${label} request timed out after ${Math.round(requestMs / 1000)}s: ${url}`
|
|
202
|
+
: `${label} stopped answering ${url} partway (nothing arrived for ${IDLE_TIMEOUT_MS / 1000}s, or the answer overran its time)`,
|
|
203
|
+
{ code: "unavailable" },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const detail = error?.cause?.code ?? error?.cause?.message ?? error?.message ?? String(error);
|
|
207
|
+
const failure = new CliError(`could not reach the ${label} (${url}): ${detail}`, { code: "unavailable" });
|
|
208
|
+
failure[TRANSIENT] = { waitMs: null };
|
|
209
|
+
return failure;
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
let response;
|
|
213
|
+
try {
|
|
214
|
+
const body = typeof options.body === "function" ? options.body() : options.body;
|
|
215
|
+
response = await fetch(url, {
|
|
216
|
+
method,
|
|
217
|
+
headers,
|
|
218
|
+
body,
|
|
219
|
+
...(options.duplex ? { duplex: options.duplex } : {}),
|
|
220
|
+
signal: controller.signal,
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw fail(error);
|
|
224
|
+
}
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
phase = "response";
|
|
227
|
+
// The answer may take as long as it keeps arriving; when its size is
|
|
228
|
+
// known, it also gets that size's transfer budget and no more.
|
|
229
|
+
const expected = Number(options.expectedBytes);
|
|
230
|
+
if (Number.isFinite(expected) && expected > 0) {
|
|
231
|
+
whole = setTimeout(() => controller.abort(TIMED_OUT), transferTimeoutMs(expected));
|
|
232
|
+
}
|
|
233
|
+
let bytes;
|
|
234
|
+
try {
|
|
235
|
+
bytes = await readAll(response, controller, () => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
timer = setTimeout(() => controller.abort(TIMED_OUT), IDLE_TIMEOUT_MS);
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw fail(error);
|
|
241
|
+
}
|
|
242
|
+
if (!response.ok) throw responseError(response.status, bytes, response.headers, label);
|
|
243
|
+
return readAs(bytes, options.as ?? "json", url, label);
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
if (whole) clearTimeout(whole);
|
|
247
|
+
caller?.removeEventListener("abort", onCaller);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function readAll(response, controller, onChunk) {
|
|
252
|
+
if (!response.body) return Buffer.alloc(0);
|
|
253
|
+
const chunks = [];
|
|
254
|
+
let size = 0;
|
|
255
|
+
onChunk();
|
|
256
|
+
for await (const chunk of response.body) {
|
|
257
|
+
onChunk();
|
|
258
|
+
chunks.push(chunk);
|
|
259
|
+
size += chunk.byteLength;
|
|
260
|
+
}
|
|
261
|
+
return Buffer.concat(chunks, size);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readAs(bytes, as, url, label) {
|
|
265
|
+
if (as === "bytes") return bytes;
|
|
266
|
+
if (as === "none") return undefined;
|
|
267
|
+
const text = bytes.toString("utf8");
|
|
268
|
+
if (!text.trim()) return {};
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(text);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new CliError(`the ${label} answered ${url} with something that is not JSON`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isPlainObject(value) {
|
|
277
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The CliError for a non-2xx answer, keeping what the backend said:
|
|
282
|
+
* `{error: {code, message, details?}}`. Anything else in the body (a proxy's
|
|
283
|
+
* page, say) is quoted, briefly, as the message.
|
|
284
|
+
*
|
|
285
|
+
* The exit code follows the answer: 401, `unauthorized`, or `session_ended`
|
|
286
|
+
* is "not signed in" (3); a 403 — `forbidden`, `grant_required`, a skill that
|
|
287
|
+
* is not open — is "not allowed" (77); 429 is rate limited (75); 502, 503,
|
|
288
|
+
* and 504 are Terminus being unavailable (69); anything else is an error (1).
|
|
289
|
+
*/
|
|
290
|
+
export function responseError(status, bytes, headers = new Headers(), label = "Terminus API") {
|
|
291
|
+
const text = Buffer.from(bytes ?? []).toString("utf8");
|
|
292
|
+
let parsed = null;
|
|
293
|
+
try {
|
|
294
|
+
parsed = text ? JSON.parse(text) : null;
|
|
295
|
+
} catch {
|
|
296
|
+
parsed = null;
|
|
297
|
+
}
|
|
298
|
+
const envelope = isPlainObject(parsed?.error) ? parsed.error : null;
|
|
299
|
+
const apiCode = typeof envelope?.code === "string" && envelope.code ? envelope.code : undefined;
|
|
300
|
+
const details = envelope && Object.hasOwn(envelope, "details") ? envelope.details : undefined;
|
|
301
|
+
const said = typeof envelope?.message === "string" && envelope.message.trim()
|
|
302
|
+
? envelope.message.trim()
|
|
303
|
+
: text.replace(/\s+/g, " ").trim().slice(0, 200) || `HTTP ${status}`;
|
|
304
|
+
const signedOut = status === 401 || apiCode === "unauthorized" || apiCode === "session_ended";
|
|
305
|
+
const code = signedOut
|
|
306
|
+
? "auth"
|
|
307
|
+
: status === 403
|
|
308
|
+
? "forbidden"
|
|
309
|
+
: status === 429
|
|
310
|
+
? "rate_limited"
|
|
311
|
+
: status === 502 || status === 503 || status === 504
|
|
312
|
+
? "unavailable"
|
|
313
|
+
: "error";
|
|
314
|
+
let message = `${label} returned ${status}${apiCode ? ` (${apiCode})` : ""}: ${said}`;
|
|
315
|
+
if (signedOut) {
|
|
316
|
+
message += " — your login may have expired or been revoked; run `terminus login`";
|
|
317
|
+
}
|
|
318
|
+
const wait = retryAfterMs(headers);
|
|
319
|
+
if (code === "rate_limited") {
|
|
320
|
+
message += wait ? ` (retry in ~${Math.max(1, Math.round(wait / 1000))}s)` : " (retry shortly)";
|
|
321
|
+
}
|
|
322
|
+
const error = new CliError(message, { code, status, apiCode, details });
|
|
323
|
+
if (code === "unavailable" && (wait === null || wait <= MAX_RETRY_AFTER_MS)) {
|
|
324
|
+
error[TRANSIENT] = { waitMs: wait };
|
|
325
|
+
}
|
|
326
|
+
return error;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ── An answer read as it arrives ───────────────────────────────────────── */
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Send one request and hand back its Response the moment its status and
|
|
333
|
+
* headers arrive, the body unread and the status unchecked: for an answer the
|
|
334
|
+
* caller reads as it comes — a turn's SSE frames (`Api#stream`), an icon passed
|
|
335
|
+
* on with its ETag (`Api#open`), or the app host's passthrough in `terminus
|
|
336
|
+
* dev` (bin/appdev-remote.mjs), which routes an app's own requests by the app
|
|
337
|
+
* host's contract, not bin/endpoints.mjs, and hands status, headers, and body
|
|
338
|
+
* to the page untouched.
|
|
339
|
+
*
|
|
340
|
+
* The request has `timeoutMs` (by default 30 s plus its upload) to be
|
|
341
|
+
* answered; `null` waits as long as the platform takes, whose own deadlines
|
|
342
|
+
* then answer. Reading the body is the caller's, and so is cancelling it
|
|
343
|
+
* (`signal`). Never repeated.
|
|
344
|
+
*/
|
|
345
|
+
export async function openResponse(url, options = {}) {
|
|
346
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
347
|
+
const label = options.label ?? "Terminus API";
|
|
348
|
+
const caller = options.signal;
|
|
349
|
+
if (caller?.aborted) throw new CliError(`${label} request was cancelled: ${url}`);
|
|
350
|
+
const requestMs = options.timeoutMs === null
|
|
351
|
+
? null
|
|
352
|
+
: options.timeoutMs ?? transferTimeoutMs(options.bodyBytes ?? 0);
|
|
353
|
+
const timeout = new AbortController();
|
|
354
|
+
const timer = requestMs === null ? null : setTimeout(() => timeout.abort(TIMED_OUT), requestMs);
|
|
355
|
+
try {
|
|
356
|
+
return await fetch(url, {
|
|
357
|
+
method,
|
|
358
|
+
headers: { "User-Agent": USER_AGENT, ...(options.headers ?? {}) },
|
|
359
|
+
body: options.body,
|
|
360
|
+
redirect: options.redirect ?? "follow",
|
|
361
|
+
signal: caller ? AbortSignal.any([caller, timeout.signal]) : timeout.signal,
|
|
362
|
+
});
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (caller?.aborted) throw new CliError(`${label} request was cancelled: ${url}`);
|
|
365
|
+
if (timeout.signal.aborted) {
|
|
366
|
+
throw new CliError(
|
|
367
|
+
`${label} request timed out after ${Math.round(requestMs / 1000)}s: ${url}`,
|
|
368
|
+
{ code: "unavailable" },
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const detail = error?.cause?.code ?? error?.cause?.message ?? error?.message ?? String(error);
|
|
372
|
+
throw new CliError(`could not reach the ${label} (${url}): ${detail}`, { code: "unavailable" });
|
|
373
|
+
} finally {
|
|
374
|
+
if (timer) clearTimeout(timer);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* ── The client a command holds ─────────────────────────────────────────── */
|
|
379
|
+
|
|
380
|
+
function queryString(query) {
|
|
381
|
+
if (!query) return "";
|
|
382
|
+
const params = new URLSearchParams();
|
|
383
|
+
for (const [key, value] of Object.entries(query)) {
|
|
384
|
+
if (value !== undefined && value !== null) params.set(key, String(value));
|
|
385
|
+
}
|
|
386
|
+
const text = params.toString();
|
|
387
|
+
return text ? `?${text}` : "";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The Terminus API as one command sees it: a base, the token it signs with
|
|
392
|
+
* (none for anonymous reads), and the doors of bin/endpoints.mjs.
|
|
393
|
+
*/
|
|
394
|
+
export class Api {
|
|
395
|
+
#me = null;
|
|
396
|
+
|
|
397
|
+
constructor({ base = DEFAULT_TERMINUS_API_BASE, token = null, identity = null } = {}) {
|
|
398
|
+
this.base = normalizeApiBase(base);
|
|
399
|
+
this.token = token;
|
|
400
|
+
/** `{ source: "env" | "session", session }`, when a token was resolved. */
|
|
401
|
+
this.identity = identity;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
authHeaders() {
|
|
405
|
+
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The account this client speaks for, read once. */
|
|
409
|
+
me() {
|
|
410
|
+
this.#me ??= this.json("GET /v1/auth/me");
|
|
411
|
+
return this.#me;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** One door's method and absolute URL. */
|
|
415
|
+
url(template, { params, query } = {}) {
|
|
416
|
+
const { method, path: route } = endpointPath(template, params);
|
|
417
|
+
return { method, url: `${this.base.replace(/\/v1$/, "")}${route}${queryString(query)}` };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A door's method, URL, headers, and payload: `body` goes as JSON,
|
|
421
|
+
* `raw: { data, type }` as the bytes it is. */
|
|
422
|
+
#prepare(template, { params, query, body, raw, headers, idempotencyKey, accept }) {
|
|
423
|
+
const { method, url } = this.url(template, { params, query });
|
|
424
|
+
const payload = raw ? raw.data : body === undefined ? undefined : JSON.stringify(body);
|
|
425
|
+
return {
|
|
426
|
+
method,
|
|
427
|
+
url,
|
|
428
|
+
headers: {
|
|
429
|
+
...(accept ? { Accept: accept } : {}),
|
|
430
|
+
...(raw ? { "Content-Type": raw.type } : payload === undefined ? {} : { "Content-Type": "application/json" }),
|
|
431
|
+
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
|
432
|
+
...this.authHeaders(),
|
|
433
|
+
...(headers ?? {}),
|
|
434
|
+
},
|
|
435
|
+
body: payload,
|
|
436
|
+
bodyBytes: payload === undefined ? 0 : Buffer.byteLength(payload),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* One request through a door. `body` is sent as JSON; `raw: { data, type }`
|
|
442
|
+
* sends bytes as they are. `idempotencyKey` rides the Idempotency-Key
|
|
443
|
+
* header, which also makes the request safe to repeat.
|
|
444
|
+
*/
|
|
445
|
+
request(template, options = {}) {
|
|
446
|
+
const as = options.as ?? "json";
|
|
447
|
+
const prepared = this.#prepare(template, {
|
|
448
|
+
...options,
|
|
449
|
+
accept: as === "json" ? "application/json" : "*/*",
|
|
450
|
+
});
|
|
451
|
+
return send(prepared.url, {
|
|
452
|
+
...prepared,
|
|
453
|
+
timeoutMs: options.timeoutMs,
|
|
454
|
+
expectedBytes: options.expectedBytes,
|
|
455
|
+
signal: options.signal,
|
|
456
|
+
as,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
json(template, options = {}) {
|
|
461
|
+
return this.request(template, { ...options, as: "json" });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
bytes(template, options = {}) {
|
|
465
|
+
return this.request(template, { ...options, as: "bytes" });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** One request through a door, answered the way `openResponse` answers:
|
|
469
|
+
* the Response as soon as it starts, its body unread and its status the
|
|
470
|
+
* caller's to judge (an icon's 304 is an answer, not a failure). */
|
|
471
|
+
open(template, options = {}) {
|
|
472
|
+
const prepared = this.#prepare(template, options);
|
|
473
|
+
return openResponse(prepared.url, {
|
|
474
|
+
...prepared,
|
|
475
|
+
timeoutMs: options.timeoutMs,
|
|
476
|
+
signal: options.signal,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** `open`, for a door whose answer is read as it arrives (a turn's SSE
|
|
481
|
+
* frames): a refusal throws the CliError `json` would, with what the
|
|
482
|
+
* backend said. */
|
|
483
|
+
async stream(template, options = {}) {
|
|
484
|
+
const response = await this.open(template, options);
|
|
485
|
+
if (response.ok) return response;
|
|
486
|
+
const bytes = Buffer.from(await response.arrayBuffer().catch(() => new ArrayBuffer(0)));
|
|
487
|
+
throw responseError(response.status, bytes, response.headers);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The client a command talks to Terminus with: its API base, and the token
|
|
493
|
+
* `auth` asks for — "required" (the default; exit 3 when nobody is signed
|
|
494
|
+
* in), "optional" (anonymous when nobody is), or "none".
|
|
495
|
+
*/
|
|
496
|
+
export async function connect(flags = {}, { auth = "required" } = {}) {
|
|
497
|
+
const base = await commandApiBase(flags);
|
|
498
|
+
const identity = auth === "none" ? null : await resolveToken({ required: auth === "required" });
|
|
499
|
+
return new Api({ base, token: identity?.token ?? null, identity });
|
|
500
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "just-bash@3.2.0 getCommandNames() — the version terminus-backend's bashd pins",
|
|
3
|
+
"commands": [
|
|
4
|
+
"alias",
|
|
5
|
+
"awk",
|
|
6
|
+
"base64",
|
|
7
|
+
"basename",
|
|
8
|
+
"bash",
|
|
9
|
+
"cat",
|
|
10
|
+
"chmod",
|
|
11
|
+
"clear",
|
|
12
|
+
"column",
|
|
13
|
+
"comm",
|
|
14
|
+
"cp",
|
|
15
|
+
"cut",
|
|
16
|
+
"date",
|
|
17
|
+
"diff",
|
|
18
|
+
"dirname",
|
|
19
|
+
"du",
|
|
20
|
+
"echo",
|
|
21
|
+
"egrep",
|
|
22
|
+
"env",
|
|
23
|
+
"expand",
|
|
24
|
+
"expr",
|
|
25
|
+
"false",
|
|
26
|
+
"fgrep",
|
|
27
|
+
"file",
|
|
28
|
+
"find",
|
|
29
|
+
"fold",
|
|
30
|
+
"grep",
|
|
31
|
+
"gunzip",
|
|
32
|
+
"gzip",
|
|
33
|
+
"head",
|
|
34
|
+
"help",
|
|
35
|
+
"history",
|
|
36
|
+
"hostname",
|
|
37
|
+
"html-to-markdown",
|
|
38
|
+
"join",
|
|
39
|
+
"jq",
|
|
40
|
+
"ln",
|
|
41
|
+
"ls",
|
|
42
|
+
"md5sum",
|
|
43
|
+
"mkdir",
|
|
44
|
+
"mv",
|
|
45
|
+
"nl",
|
|
46
|
+
"od",
|
|
47
|
+
"paste",
|
|
48
|
+
"printenv",
|
|
49
|
+
"printf",
|
|
50
|
+
"pwd",
|
|
51
|
+
"readlink",
|
|
52
|
+
"rev",
|
|
53
|
+
"rg",
|
|
54
|
+
"rm",
|
|
55
|
+
"rmdir",
|
|
56
|
+
"sed",
|
|
57
|
+
"seq",
|
|
58
|
+
"sh",
|
|
59
|
+
"sha1sum",
|
|
60
|
+
"sha256sum",
|
|
61
|
+
"sleep",
|
|
62
|
+
"sort",
|
|
63
|
+
"split",
|
|
64
|
+
"sqlite3",
|
|
65
|
+
"stat",
|
|
66
|
+
"strings",
|
|
67
|
+
"tac",
|
|
68
|
+
"tail",
|
|
69
|
+
"tar",
|
|
70
|
+
"tee",
|
|
71
|
+
"time",
|
|
72
|
+
"timeout",
|
|
73
|
+
"touch",
|
|
74
|
+
"tr",
|
|
75
|
+
"tree",
|
|
76
|
+
"true",
|
|
77
|
+
"unalias",
|
|
78
|
+
"unexpand",
|
|
79
|
+
"uniq",
|
|
80
|
+
"wc",
|
|
81
|
+
"which",
|
|
82
|
+
"whoami",
|
|
83
|
+
"xan",
|
|
84
|
+
"xargs",
|
|
85
|
+
"yq",
|
|
86
|
+
"zcat"
|
|
87
|
+
]
|
|
88
|
+
}
|