@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/dev-net.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `net.fetch` and `net.image` under `terminus dev` — and a server op's
|
|
3
|
+
* `webFetch`, which the platform makes through the same fetch — guarded the
|
|
4
|
+
* way the platform guards them (terminus-backend src/routes/net.rs): public
|
|
5
|
+
* http(s) on the standard ports only, every resolved address of every hop
|
|
6
|
+
* checked and the connection pinned to one, at most three redirects, ten
|
|
7
|
+
* seconds in all. A page is text, XML, JSON or calendar data of at most
|
|
8
|
+
* 512 KiB — refused past that unless the call asks for its start (`partial`)
|
|
9
|
+
* — and a picture is a PNG, JPEG, GIF, WebP or AVIF by its own bytes, at most
|
|
10
|
+
* 5 MiB, served under the type the bytes say. Refusals carry the contract's
|
|
11
|
+
* codes.
|
|
12
|
+
*
|
|
13
|
+
* A developer's own site on localhost is refused here exactly as the
|
|
14
|
+
* platform refuses it. Tests hand in `fetchImpl` (the WHATWG fetch shape) to
|
|
15
|
+
* stand a local server in for a public one; the URL rules still apply.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import dns from "node:dns/promises";
|
|
19
|
+
import { request as httpRequest } from "node:http";
|
|
20
|
+
import { request as httpsRequest } from "node:https";
|
|
21
|
+
import { isIP } from "node:net";
|
|
22
|
+
|
|
23
|
+
import { LIMITS, runtimeError } from "./dev-contract.mjs";
|
|
24
|
+
|
|
25
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
26
|
+
const MAX_REDIRECTS = 3;
|
|
27
|
+
const USER_AGENT = "terminus-app-fetch/1.0";
|
|
28
|
+
|
|
29
|
+
function ipv4Private(address) {
|
|
30
|
+
const [a, b, c] = address.split(".").map(Number);
|
|
31
|
+
return a === 0 || a >= 224 || a === 10 || a === 127
|
|
32
|
+
|| (a === 169 && b === 254)
|
|
33
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
34
|
+
|| (a === 192 && b === 168)
|
|
35
|
+
|| (a === 192 && b === 0 && c === 0)
|
|
36
|
+
|| (a === 192 && b === 0 && c === 2)
|
|
37
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
38
|
+
|| (a === 198 && b === 51 && c === 100)
|
|
39
|
+
|| (a === 203 && b === 0 && c === 113)
|
|
40
|
+
|| (a === 100 && (b & 0xc0) === 64);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ipv6Groups(address) {
|
|
44
|
+
let text = address.toLowerCase();
|
|
45
|
+
const zone = text.indexOf("%");
|
|
46
|
+
if (zone !== -1) text = text.slice(0, zone);
|
|
47
|
+
let tail = [];
|
|
48
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
49
|
+
if (dotted) {
|
|
50
|
+
const parts = dotted[1].split(".").map(Number);
|
|
51
|
+
tail = [(parts[0] << 8) | parts[1], (parts[2] << 8) | parts[3]];
|
|
52
|
+
text = text.slice(0, -dotted[1].length).replace(/:$/, "") || ":";
|
|
53
|
+
if (text === ":") text = "::";
|
|
54
|
+
}
|
|
55
|
+
const [head, rest] = text.includes("::") ? text.split("::") : [text, null];
|
|
56
|
+
const headGroups = head ? head.split(":").filter(Boolean).map((group) => parseInt(group, 16)) : [];
|
|
57
|
+
const restGroups = rest ? rest.split(":").filter(Boolean).map((group) => parseInt(group, 16)) : [];
|
|
58
|
+
const fill = 8 - headGroups.length - restGroups.length - tail.length;
|
|
59
|
+
return [...headGroups, ...(rest === null ? [] : new Array(Math.max(0, fill)).fill(0)), ...restGroups, ...tail];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `is_private_ip` (net.rs): anything a public fetch must never reach. */
|
|
63
|
+
export function isPrivateAddress(address) {
|
|
64
|
+
const family = isIP(address);
|
|
65
|
+
if (family === 4) return ipv4Private(address);
|
|
66
|
+
if (family !== 6) return true;
|
|
67
|
+
const groups = ipv6Groups(address);
|
|
68
|
+
const mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
|
69
|
+
if (mapped) {
|
|
70
|
+
return ipv4Private(`${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`);
|
|
71
|
+
}
|
|
72
|
+
return (groups[0] & 0xe000) !== 0x2000
|
|
73
|
+
|| groups[0] === 0x2002
|
|
74
|
+
|| (groups[0] === 0x2001 && (groups[1] < 0x0200 || groups[1] === 0x0db8))
|
|
75
|
+
|| groups.every((group) => group === 0)
|
|
76
|
+
|| (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1)
|
|
77
|
+
|| (groups[0] & 0xfe00) === 0xfc00
|
|
78
|
+
|| (groups[0] & 0xffc0) === 0xfe80;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseUrl(raw) {
|
|
82
|
+
try {
|
|
83
|
+
return new URL(String(raw ?? "").trim());
|
|
84
|
+
} catch {
|
|
85
|
+
throw runtimeError("bad_request", "invalid url");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shapeAllowed(url) {
|
|
90
|
+
if (url.username || url.password) return false;
|
|
91
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
92
|
+
if (url.port && url.port !== "80" && url.port !== "443") return false;
|
|
93
|
+
const host = url.hostname.replace(/^\[|\]$/gu, "").toLowerCase();
|
|
94
|
+
if (!host) return false;
|
|
95
|
+
if (isIP(host)) return !isPrivateAddress(host);
|
|
96
|
+
return host !== "localhost" && !host.endsWith(".localhost");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requireShape(url) {
|
|
100
|
+
if (!shapeAllowed(url)) {
|
|
101
|
+
throw runtimeError("url_not_allowed", "only public http(s) URLs on standard ports can be fetched");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pinnedAddress(url) {
|
|
106
|
+
const host = url.hostname.replace(/^\[|\]$/gu, "");
|
|
107
|
+
if (isIP(host)) return null;
|
|
108
|
+
let addresses;
|
|
109
|
+
try {
|
|
110
|
+
addresses = await dns.lookup(host, { all: true, verbatim: true });
|
|
111
|
+
} catch {
|
|
112
|
+
throw runtimeError("upstream_failed", "the host could not be resolved");
|
|
113
|
+
}
|
|
114
|
+
if (!addresses.length) throw runtimeError("upstream_failed", "the host did not resolve to an address");
|
|
115
|
+
if (addresses.some((entry) => isPrivateAddress(entry.address))) {
|
|
116
|
+
throw runtimeError("url_not_allowed", "the host resolves to a private address");
|
|
117
|
+
}
|
|
118
|
+
return addresses[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getPinned(url, pinned, signal, userAgent) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
124
|
+
const outgoing = send(url, {
|
|
125
|
+
method: "GET",
|
|
126
|
+
headers: { "user-agent": userAgent },
|
|
127
|
+
signal,
|
|
128
|
+
...(pinned
|
|
129
|
+
? { lookup: (_host, options, done) => (options?.all
|
|
130
|
+
? done(null, [{ address: pinned.address, family: pinned.family }])
|
|
131
|
+
: done(null, pinned.address, pinned.family)) }
|
|
132
|
+
: {}),
|
|
133
|
+
}, resolve);
|
|
134
|
+
outgoing.on("error", reject);
|
|
135
|
+
outgoing.end();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One answer, in one shape whichever way it was fetched. */
|
|
140
|
+
function fromIncoming(url, incoming) {
|
|
141
|
+
return {
|
|
142
|
+
url: url.href,
|
|
143
|
+
status: incoming.statusCode ?? 0,
|
|
144
|
+
header: (name) => {
|
|
145
|
+
const value = incoming.headers[name];
|
|
146
|
+
return Array.isArray(value) ? value.join(", ") : (value ?? null);
|
|
147
|
+
},
|
|
148
|
+
chunks: incoming,
|
|
149
|
+
discard: () => incoming.resume(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fromResponse(url, response) {
|
|
154
|
+
return {
|
|
155
|
+
url: response.url || url.href,
|
|
156
|
+
status: response.status,
|
|
157
|
+
header: (name) => response.headers.get(name),
|
|
158
|
+
chunks: response.body ?? [],
|
|
159
|
+
discard: () => response.body?.cancel().catch(() => undefined),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Follow the public hops to the answer that is not a redirect. */
|
|
164
|
+
async function openPublic(rawUrl, { fetchImpl, signal, userAgent }) {
|
|
165
|
+
let url = parseUrl(rawUrl);
|
|
166
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
167
|
+
requireShape(url);
|
|
168
|
+
let answer;
|
|
169
|
+
try {
|
|
170
|
+
answer = fetchImpl
|
|
171
|
+
? fromResponse(url, await fetchImpl(url.href, { redirect: "manual", signal, headers: { "user-agent": userAgent } }))
|
|
172
|
+
: fromIncoming(url, await getPinned(url, await pinnedAddress(url), signal, userAgent));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error?.apiCode) throw error;
|
|
175
|
+
throw runtimeError("upstream_failed", `fetch failed: ${error?.message ?? error}`);
|
|
176
|
+
}
|
|
177
|
+
if (![301, 302, 303, 307, 308].includes(answer.status)) return answer;
|
|
178
|
+
answer.discard();
|
|
179
|
+
if (redirects === MAX_REDIRECTS) throw runtimeError("upstream_failed", "too many redirects");
|
|
180
|
+
const location = answer.header("location");
|
|
181
|
+
if (!location) throw runtimeError("upstream_failed", "redirect response omitted Location");
|
|
182
|
+
try {
|
|
183
|
+
url = new URL(location, url);
|
|
184
|
+
} catch {
|
|
185
|
+
throw runtimeError("upstream_failed", "redirect contained an invalid URL");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A fetch within its deadline — ten seconds for all of it: the lookups,
|
|
191
|
+
* every hop and every byte. At the deadline it is dropped wherever it was
|
|
192
|
+
* (a lookup that cannot be cancelled included) and answers
|
|
193
|
+
* deadline_exceeded in the platform's words, whatever the drop broke. */
|
|
194
|
+
async function withDeadline(task, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
195
|
+
const controller = new AbortController();
|
|
196
|
+
const expired = runtimeError("deadline_exceeded", `the fetch did not finish within ${timeoutMs / 1000} seconds`);
|
|
197
|
+
let timer;
|
|
198
|
+
const deadline = new Promise((_, reject) => {
|
|
199
|
+
timer = setTimeout(() => {
|
|
200
|
+
controller.abort(expired);
|
|
201
|
+
reject(expired);
|
|
202
|
+
}, timeoutMs);
|
|
203
|
+
});
|
|
204
|
+
try {
|
|
205
|
+
return await Promise.race([task(controller.signal), deadline]);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw controller.signal.aborted ? expired : error;
|
|
208
|
+
} finally {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function readCapped(answer, cap, { partial = false, tooLarge }) {
|
|
214
|
+
const chunks = [];
|
|
215
|
+
let size = 0;
|
|
216
|
+
let truncated = false;
|
|
217
|
+
try {
|
|
218
|
+
for await (const raw of answer.chunks) {
|
|
219
|
+
const chunk = Buffer.from(raw);
|
|
220
|
+
if (size + chunk.length > cap) {
|
|
221
|
+
if (!partial) {
|
|
222
|
+
answer.discard();
|
|
223
|
+
throw runtimeError("payload_too_large", tooLarge);
|
|
224
|
+
}
|
|
225
|
+
chunks.push(chunk.subarray(0, cap - size));
|
|
226
|
+
truncated = true;
|
|
227
|
+
answer.discard();
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
size += chunk.length;
|
|
231
|
+
chunks.push(chunk);
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error?.apiCode) throw error;
|
|
235
|
+
throw runtimeError("upstream_failed", `fetch failed: ${error?.message ?? error}`);
|
|
236
|
+
}
|
|
237
|
+
return { bytes: Buffer.concat(chunks), truncated };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `GET /net/fetch`: a public text resource → `{url, status, content_type,
|
|
241
|
+
* body, truncated}`. `timeoutMs` is the door's deadline (tests shorten it);
|
|
242
|
+
* `userAgent` names the lane asking (server ops fetch through here too). */
|
|
243
|
+
export function fetchPublicPage(rawUrl, {
|
|
244
|
+
partial = false,
|
|
245
|
+
fetchImpl = null,
|
|
246
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
247
|
+
userAgent = USER_AGENT,
|
|
248
|
+
} = {}) {
|
|
249
|
+
return withDeadline(async (signal) => {
|
|
250
|
+
const answer = await openPublic(rawUrl, { fetchImpl, signal, userAgent });
|
|
251
|
+
const contentType = answer.header("content-type") ?? "";
|
|
252
|
+
const textShaped = !contentType
|
|
253
|
+
|| contentType.startsWith("text/")
|
|
254
|
+
|| contentType.includes("xml")
|
|
255
|
+
|| contentType.includes("json")
|
|
256
|
+
|| contentType.includes("calendar");
|
|
257
|
+
if (!textShaped) {
|
|
258
|
+
answer.discard();
|
|
259
|
+
throw runtimeError("upstream_failed", `unsupported content type '${contentType}'`);
|
|
260
|
+
}
|
|
261
|
+
const { bytes, truncated } = await readCapped(answer, LIMITS.net_fetch_bytes, {
|
|
262
|
+
partial,
|
|
263
|
+
tooLarge: "the response is larger than 512 KiB",
|
|
264
|
+
});
|
|
265
|
+
return {
|
|
266
|
+
url: answer.url,
|
|
267
|
+
status: answer.status,
|
|
268
|
+
content_type: contentType,
|
|
269
|
+
body: bytes.toString("utf8"),
|
|
270
|
+
truncated,
|
|
271
|
+
};
|
|
272
|
+
}, timeoutMs);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** What a body is by its first bytes: only these are served as a picture. */
|
|
276
|
+
export function sniffImageType(bytes) {
|
|
277
|
+
const starts = (...signature) => signature.every((byte, index) => bytes[index] === byte);
|
|
278
|
+
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
|
279
|
+
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
|
280
|
+
const head = bytes.subarray(0, 12).toString("latin1");
|
|
281
|
+
if (head.startsWith("GIF87a") || head.startsWith("GIF89a")) return "image/gif";
|
|
282
|
+
if (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") return "image/webp";
|
|
283
|
+
if (head.slice(4, 8) === "ftyp" && ["avif", "avis"].includes(head.slice(8, 12))) return "image/avif";
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `GET /net/image`: a public raster image → `{url, content_type, bytes}`. */
|
|
288
|
+
export function fetchPublicImage(rawUrl, { fetchImpl = null, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
289
|
+
return withDeadline(async (signal) => {
|
|
290
|
+
const answer = await openPublic(rawUrl, { fetchImpl, signal, userAgent: USER_AGENT });
|
|
291
|
+
if (answer.status < 200 || answer.status > 299) {
|
|
292
|
+
answer.discard();
|
|
293
|
+
throw runtimeError("upstream_failed", `the image could not be read (HTTP ${answer.status})`);
|
|
294
|
+
}
|
|
295
|
+
const declared = (answer.header("content-type") ?? "").toLowerCase();
|
|
296
|
+
if (declared.startsWith("text/") || declared.includes("json") || declared.includes("xml")) {
|
|
297
|
+
answer.discard();
|
|
298
|
+
throw runtimeError("upstream_failed", `unsupported content type '${declared}'`);
|
|
299
|
+
}
|
|
300
|
+
const tooLarge = "the image is larger than 5 MiB";
|
|
301
|
+
const length = Number(answer.header("content-length"));
|
|
302
|
+
if (Number.isFinite(length) && length > LIMITS.net_image_bytes) {
|
|
303
|
+
answer.discard();
|
|
304
|
+
throw runtimeError("payload_too_large", tooLarge);
|
|
305
|
+
}
|
|
306
|
+
const { bytes } = await readCapped(answer, LIMITS.net_image_bytes, { tooLarge });
|
|
307
|
+
const contentType = sniffImageType(bytes);
|
|
308
|
+
if (!contentType) {
|
|
309
|
+
throw runtimeError(
|
|
310
|
+
"upstream_failed",
|
|
311
|
+
"only public raster images (PNG, JPEG, GIF, WebP, AVIF) can be fetched",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return { url: answer.url, content_type: contentType, bytes };
|
|
315
|
+
}, timeoutMs);
|
|
316
|
+
}
|