@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. package/package.json +41 -0
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Routes into an app that is already open.
3
+ *
4
+ * The desk asks an open app window to go somewhere by changing its frame's
5
+ * fragment to `#terminus-route=<encodeURIComponent(path)>`, where the path is
6
+ * same-origin: it starts with "/", its second character is not "/", and it
7
+ * holds no "\". The app host puts this one inline classic script in every
8
+ * HTML document it serves for an app, as the first child of <head>, before any
9
+ * app script runs:
10
+ *
11
+ * - on a fresh load the fragment simply becomes the page's own URL, so an app
12
+ * that reads `location` at startup opens at the route;
13
+ * - on a live `hashchange` it dispatches a cancelable `terminus:route` event on
14
+ * `window` (the SDK's `routes.onRoute`); an app that handles routes calls
15
+ * `preventDefault()`, and any other app reloads at the route.
16
+ *
17
+ * `terminus dev` serves app documents the way the host does, so this is the
18
+ * host's script, word for word — keep it in step with the app host
19
+ * (terminus-frontend `workers/app-host.ts`). The dev servers set no
20
+ * Content-Security-Policy and put no nonce on scripts, so it carries none.
21
+ */
22
+
23
+ /** The script, exactly as the app host injects it. */
24
+ export const APP_ROUTE_SCRIPT = String.raw`(function () {
25
+ var P = "#terminus-route=";
26
+ function read() {
27
+ if (location.hash.indexOf(P) !== 0) return null;
28
+ var route;
29
+ try { route = decodeURIComponent(location.hash.slice(P.length)); } catch (error) { return null; }
30
+ return route.charAt(0) === "/" && route.charAt(1) !== "/" && route.indexOf("\\") < 0 ? route : null;
31
+ }
32
+ if (location.hash.indexOf(P) === 0) {
33
+ history.replaceState(history.state, "", read() || location.pathname + location.search);
34
+ }
35
+ addEventListener("hashchange", function () {
36
+ if (location.hash.indexOf(P) !== 0) return;
37
+ var route = read();
38
+ history.replaceState(history.state, "", route || location.pathname + location.search);
39
+ if (!route) return;
40
+ var event = new CustomEvent("terminus:route", { detail: { route: route }, cancelable: true });
41
+ if (dispatchEvent(event)) location.reload();
42
+ });
43
+ })();`;
44
+
45
+ const HEAD_OPENING = /<head(?=[\s/>])[^>]*>/i;
46
+ const LEADING_DOCTYPE = /^\s*<!doctype[^>]*>/i;
47
+
48
+ /**
49
+ * Put the route script in an app's HTML document: the first child of <head>,
50
+ * or — a document with no <head> — the very top, after the doctype when there
51
+ * is one (anything ahead of a doctype drops the page into quirks mode).
52
+ */
53
+ export function injectAppRouteScript(html) {
54
+ const head = HEAD_OPENING.exec(html);
55
+ const at = head
56
+ ? head.index + head[0].length
57
+ : (LEADING_DOCTYPE.exec(html)?.[0].length ?? 0);
58
+ return `${html.slice(0, at)}<script>${APP_ROUTE_SCRIPT}</script>${html.slice(at)}`;
59
+ }
@@ -0,0 +1,2 @@
1
+ /** Sole public app-runtime major shared by the dev host and SDK metadata command. */
2
+ export const APP_RUNTIME_API_VERSION = 1;
@@ -0,0 +1,346 @@
1
+ /**
2
+ * `terminus dev --remote`: the local ui/ bundle against the PRODUCTION
3
+ * app runtime instead of the SQLite harness.
4
+ *
5
+ * The handshake is the one the wildcard app host performs: the CLI's login
6
+ * session mints a one-time browser authorization for the published app
7
+ * (`POST /v1/app-runtime/authorizations`), exchanges it for an app session
8
+ * token (`POST /v1/app-runtime/session`), and then proxies `/_terminus/*`
9
+ * to the platform with that bearer.
10
+ *
11
+ * The routing is the app host's own, read from its contract (app-host.json,
12
+ * vendored under bin/vendor/app-runtime-v1/ from the SDK's conformance
13
+ * bundle) rather than copied by hand: the first-segment allowlist, the
14
+ * bootstrap and capability rewrites, the header allowlists both ways, the
15
+ * doors the host answers itself (icon, logout and sign-in; callback, open and
16
+ * guest are the browser's way in, which a CLI session never goes through),
17
+ * and the error codes. SSE streams through untouched.
18
+ *
19
+ * The CLI's own calls here (the handshake, the sign-out) are doors of
20
+ * bin/endpoints.mjs like any other; what it forwards for the app goes
21
+ * through bin/http.mjs's `openResponse`, routed by that contract.
22
+ */
23
+
24
+ import { createServer } from "node:http";
25
+ import path from "node:path";
26
+ import { Readable } from "node:stream";
27
+ import { pipeline } from "node:stream/promises";
28
+
29
+ import { readDevPackage, requireBuiltDevBundle, serveUiAsset } from "./appdev.mjs";
30
+ import { CliError } from "./errors.mjs";
31
+ import { Api, openResponse } from "./http.mjs";
32
+ import { APP_HOST, DOORS, returnToPath, runtimeError, sendRuntimeError } from "./dev-contract.mjs";
33
+ import {
34
+ answerDevAbout,
35
+ assertDevPortRangeAvailable,
36
+ closeDevServer,
37
+ DEV_ABOUT_PATH,
38
+ devPortUnavailableError,
39
+ isLoopbackHost,
40
+ isLoopbackOrigin,
41
+ listenDevServer,
42
+ refuseCrossSite,
43
+ refuseForeignHost,
44
+ resolveDevPortRange,
45
+ } from "./dev-ports.mjs";
46
+
47
+ const SAFE_METHODS = new Set(["GET", "HEAD"]);
48
+ const ALLOWED_SEGMENTS = new Set(APP_HOST.allowed_segments);
49
+ const WORKER_HANDLED = new Set(APP_HOST.worker_handled);
50
+ /** What a worker-handled door answers to, by name: its door's method, read
51
+ * from doors.json (a GET door answers HEAD too). A door app-host.json names
52
+ * that doors.json does not have is a contract that disagrees with itself. */
53
+ const WORKER_METHODS = Object.fromEntries(APP_HOST.worker_handled.map((name) => {
54
+ const door = DOORS.doors.find((candidate) => candidate.worker_handled && candidate.path === `/${name}`);
55
+ if (!door) throw new Error(`app-host.json's worker door '${name}' is not in doors.json`);
56
+ return [name, door.method === "GET" ? ["GET", "HEAD"] : [door.method]];
57
+ }));
58
+
59
+ /** Mint an app session exactly the way the production app host does: the
60
+ * login (`api`) authorizes the app, and the code is exchanged with no
61
+ * credential at all, as the host's callback exchanges it. */
62
+ export async function mintRemoteAppSession({ api, appId }) {
63
+ const authorization = await api.json("POST /v1/app-runtime/authorizations", { body: { app_id: appId } });
64
+ if (!authorization?.code || !authorization?.web_host) {
65
+ throw new CliError("the Terminus API returned an incomplete app authorization");
66
+ }
67
+ const session = await new Api({ base: api.base }).json("POST /v1/app-runtime/session", {
68
+ body: { code: authorization.code, host: authorization.web_host },
69
+ });
70
+ if (!session?.token) throw new CliError("the Terminus API returned no app session token");
71
+ return {
72
+ token: session.token,
73
+ expiresAt: session.expires_at ?? null,
74
+ webHost: authorization.web_host,
75
+ appName: authorization.app_name ?? null,
76
+ kind: authorization.kind ?? null,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Where the app host sends `method` `pathname` (a `/_terminus/...` path):
82
+ * `{ upstream, headers }` for a platform door (upstream in the route
83
+ * contract's `/v1/...` spelling), `{ worker }` for a door the host answers
84
+ * itself, or `{ error: { code, message } }`.
85
+ */
86
+ export function remoteRuntimeRoute(pathname, method = "GET") {
87
+ const prefix = `${APP_HOST.prefix}/`;
88
+ if (!pathname.startsWith(prefix)) return { error: { code: "not_found", message: "Unknown app API route" } };
89
+ const suffix = pathname.slice(prefix.length);
90
+ const segments = suffix.split("/");
91
+ const refused = { error: { code: "not_found", message: "Unknown app API route" } };
92
+ if (!suffix || suffix.length > 1024) return refused;
93
+ try {
94
+ for (const encoded of segments) {
95
+ const segment = decodeURIComponent(encoded);
96
+ if (!segment || segment === "." || segment === ".." || /[/\\\p{Cc}]/u.test(segment)) return refused;
97
+ }
98
+ } catch {
99
+ return refused;
100
+ }
101
+ if (segments.length === 1 && WORKER_HANDLED.has(segments[0])) {
102
+ if (!WORKER_METHODS[segments[0]].includes(method)) {
103
+ return { error: { code: "method_not_allowed", message: `${pathname} does not answer ${method}` } };
104
+ }
105
+ return { worker: segments[0] };
106
+ }
107
+ if (`/${suffix}` === APP_HOST.bootstrap.path) {
108
+ if (!APP_HOST.bootstrap.methods.includes(method)) {
109
+ return { error: { code: "method_not_allowed", message: "bootstrap answers GET and HEAD" } };
110
+ }
111
+ return { upstream: APP_HOST.bootstrap.upstream, headers: {} };
112
+ }
113
+ if (!ALLOWED_SEGMENTS.has(segments[0])) return refused;
114
+ for (const rewrite of APP_HOST.rewrites) {
115
+ if (`/${suffix}` === rewrite.prefix || `/${suffix}`.startsWith(`${rewrite.prefix}/`)) {
116
+ return {
117
+ upstream: `${rewrite.upstream_prefix}${`/${suffix}`.slice(rewrite.prefix.length)}`,
118
+ headers: { ...rewrite.request_headers },
119
+ };
120
+ }
121
+ }
122
+ return { upstream: `${APP_HOST.default_upstream_prefix}/${suffix}`, headers: {} };
123
+ }
124
+
125
+ /** A `/v1/...` route on the platform whose API base is `apiBase` (`…/v1`). */
126
+ function upstreamUrl(apiBase, upstream, search = "") {
127
+ const base = apiBase.replace(/\/+$/u, "");
128
+ const root = base.endsWith("/v1") && upstream.startsWith("/v1/") ? base.slice(0, -3) : base;
129
+ return `${root}${upstream}${search}`;
130
+ }
131
+
132
+ /** The response headers the app host passes through for `pathname`. */
133
+ function passedHeaders(upstreamHeaders, pathname) {
134
+ const headers = { ...APP_HOST.added_response_headers };
135
+ for (const name of APP_HOST.response_headers) {
136
+ const value = upstreamHeaders.get(name);
137
+ if (value) headers[name] = value;
138
+ }
139
+ const suffix = pathname.slice(APP_HOST.prefix.length);
140
+ for (const override of APP_HOST.response_header_overrides) {
141
+ if (suffix.startsWith(override.prefix)) Object.assign(headers, override.headers);
142
+ }
143
+ return headers;
144
+ }
145
+
146
+ /** Forward one request to the platform with a bearer (none for a
147
+ * sessionless door), streaming the upstream body straight back. Shared with
148
+ * the local harness, whose capability plane is the same platform proxy. */
149
+ export async function forwardPlatformRequest(target, bearer, method, request, response, artifactContext = null, { pathname = null } = {}) {
150
+ const headers = bearer ? { authorization: `Bearer ${bearer}` } : {};
151
+ if (artifactContext) headers["x-terminus-artifact-context"] = artifactContext;
152
+ for (const name of APP_HOST.request_headers) {
153
+ const value = request.headers[name];
154
+ if (value) headers[name] = Array.isArray(value) ? value.join(", ") : value;
155
+ }
156
+ let body;
157
+ if (!SAFE_METHODS.has(method)) {
158
+ const chunks = [];
159
+ for await (const chunk of request) chunks.push(chunk);
160
+ body = Buffer.concat(chunks);
161
+ }
162
+ const abort = new AbortController();
163
+ response.on("close", () => abort.abort());
164
+ let upstream;
165
+ try {
166
+ // The app host passes redirects through rather than following them, and
167
+ // the platform's own deadlines answer a slow door.
168
+ upstream = await openResponse(target, {
169
+ method,
170
+ headers,
171
+ body,
172
+ bodyBytes: body?.length ?? 0,
173
+ redirect: "manual",
174
+ timeoutMs: null,
175
+ signal: abort.signal,
176
+ });
177
+ } catch (error) {
178
+ if (abort.signal.aborted) return response.end();
179
+ return sendRuntimeError(response, runtimeError("service_unavailable", error.message));
180
+ }
181
+ response.writeHead(upstream.status, passedHeaders(upstream.headers, pathname ?? new URL(target).pathname));
182
+ if (!upstream.body || method === "HEAD") return response.end();
183
+ response.flushHeaders();
184
+ try {
185
+ await pipeline(Readable.fromWeb(upstream.body), response);
186
+ } catch {
187
+ // The browser went away mid-stream (EventSource reconnects, tab closed):
188
+ // the abort above already cancelled the upstream request.
189
+ if (!response.writableEnded) response.end();
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ /**
195
+ * Forward one `/_terminus/*` request the way the app host does. `remote`
196
+ * holds the session; the host answers icon, logout and sign-in itself, and a
197
+ * signed-out session refuses every other door until the app is opened again
198
+ * or signs in (`signIn` mints the session anew, as opening the app does).
199
+ */
200
+ export async function proxyRemoteRuntime(remote, url, request, response, { signIn = null } = {}) {
201
+ const method = (request.method ?? "GET").toUpperCase();
202
+ const route = remoteRuntimeRoute(url.pathname, method);
203
+ if (route.error) {
204
+ if (route.error.code === "method_not_allowed") {
205
+ response.setHeader("allow", (url.pathname.endsWith(APP_HOST.bootstrap.path)
206
+ ? APP_HOST.bootstrap.methods
207
+ : WORKER_METHODS[url.pathname.split("/").at(-1)] ?? []).join(", "));
208
+ }
209
+ return sendRuntimeError(response, runtimeError(route.error.code, route.error.message));
210
+ }
211
+ if (route.worker === "icon") {
212
+ // Sessionless, by the app's own host name, as the app host asks for it.
213
+ return forwardPlatformRequest(
214
+ upstreamUrl(remote.apiBase, "/v1/app-runtime/icon", `?host=${encodeURIComponent(remote.webHost ?? "")}`),
215
+ null,
216
+ method,
217
+ request,
218
+ response,
219
+ null,
220
+ { pathname: url.pathname },
221
+ );
222
+ }
223
+ if (route.worker === "logout") {
224
+ // Revoke the session, as the host does; the browser's next open of the
225
+ // app mints a new one (the host would send it to the sign-in page).
226
+ const revoked = remote.token;
227
+ remote.token = null;
228
+ if (revoked) {
229
+ await new Api({ base: remote.apiBase, token: revoked })
230
+ .request("DELETE /v1/app-runtime/session/current", { as: "none" })
231
+ .catch(() => undefined);
232
+ }
233
+ response.writeHead(303, { location: "/" });
234
+ return response.end();
235
+ }
236
+ if (route.worker === "signin" && signIn) {
237
+ // Sign in, then come back: the app session is the CLI login's, so a live
238
+ // one goes straight back to the place, and a signed-out page signs in
239
+ // anew first. The place is a path on this origin, or `/`.
240
+ if (!remote.token) await signIn();
241
+ response.writeHead(303, { location: returnToPath(url.searchParams.get("return_to")) });
242
+ return response.end();
243
+ }
244
+ if (route.worker) {
245
+ return sendRuntimeError(response, runtimeError(
246
+ "unsupported_in_dev",
247
+ `terminus dev --remote holds the app session itself; the browser's /_terminus/${route.worker} is the app host's`,
248
+ ));
249
+ }
250
+ if (!remote.token) {
251
+ return sendRuntimeError(response, runtimeError("unauthorized", "signed out: open the app again to sign in"));
252
+ }
253
+ return forwardPlatformRequest(
254
+ upstreamUrl(remote.apiBase, route.upstream, url.search),
255
+ remote.token,
256
+ method,
257
+ request,
258
+ response,
259
+ route.headers["x-terminus-artifact-context"] ?? null,
260
+ { pathname: url.pathname },
261
+ );
262
+ }
263
+
264
+ /** One origin: the local ui/ bundle plus the proxied production runtime.
265
+ * `remote` is `{ login, token, appId, webHost }`: the login's client (which
266
+ * mints a new app session when a signed-out page is opened again) and the
267
+ * app session in hand. */
268
+ export async function startRemoteDevServer(dir, options = {}) {
269
+ const pkg = options.pkg ?? await readDevPackage(dir);
270
+ requireBuiltDevBundle(pkg);
271
+ const remote = options.remote ? { ...options.remote } : null;
272
+ if (!remote?.login || !remote?.token) throw new CliError("dev --remote needs an app session");
273
+ remote.apiBase = remote.login.base;
274
+ const mint = options.mint ?? (() => mintRemoteAppSession({ api: remote.login, appId: remote.appId }));
275
+ /** A new app session, as the app host's sign-in ends in one. */
276
+ const signIn = async () => {
277
+ const minted = await mint();
278
+ remote.token = minted.token;
279
+ remote.webHost = minted.webHost ?? remote.webHost;
280
+ };
281
+ const port = resolveDevPortRange(options.port, 1);
282
+ await assertDevPortRangeAvailable({
283
+ basePort: port,
284
+ commandArgs: options.commandArgs,
285
+ count: 1,
286
+ directory: dir,
287
+ viteProxy: true,
288
+ });
289
+ const uiDir = path.join(dir, pkg.uiDirectory);
290
+ const server = createServer(async (request, response) => {
291
+ // Every /_terminus call here acts on real data under the app session.
292
+ if (!isLoopbackHost(request)) return refuseForeignHost(request, response);
293
+ const url = new URL(request.url ?? "/", `http://localhost:${request.socket.localPort}`);
294
+ // Nor may a page on another site fire those calls blind (see
295
+ // isLoopbackOrigin); the bundle's pages stay open to any link.
296
+ const runtime = url.pathname === "/_terminus" || url.pathname.startsWith("/_terminus/")
297
+ || url.pathname.startsWith("/__terminus_dev/");
298
+ if (runtime && !isLoopbackOrigin(request)) return refuseCrossSite(response);
299
+ try {
300
+ if (url.pathname === DEV_ABOUT_PATH) {
301
+ answerDevAbout(request, response, {
302
+ kind: "app",
303
+ app: pkg.id ?? pkg.manifest.slug,
304
+ directory: path.resolve(dir),
305
+ remote: true,
306
+ });
307
+ return;
308
+ }
309
+ if (url.pathname === "/_terminus" || url.pathname.startsWith("/_terminus/")) {
310
+ await proxyRemoteRuntime(remote, url, request, response, { signIn });
311
+ return;
312
+ }
313
+ // Opening the app signs a signed-out session back in.
314
+ if (!remote.token && String(request.headers.accept ?? "").includes("text/html")) await signIn();
315
+ await serveUiAsset(pkg, uiDir, url, request, response);
316
+ } catch (error) {
317
+ if (response.headersSent) return response.end();
318
+ sendRuntimeError(response, error);
319
+ }
320
+ });
321
+ try {
322
+ await listenDevServer(server, port);
323
+ } catch (error) {
324
+ if (error?.code === "EADDRINUSE" && port !== 0) {
325
+ throw await devPortUnavailableError({
326
+ basePort: port,
327
+ commandArgs: options.commandArgs,
328
+ count: 1,
329
+ directory: dir,
330
+ failures: [{ port, error }],
331
+ viteProxy: true,
332
+ });
333
+ }
334
+ throw error;
335
+ }
336
+ return {
337
+ manifest: pkg.manifest,
338
+ hasUi: pkg.hasUi,
339
+ uiDirectory: pkg.uiDirectory,
340
+ port: server.address().port,
341
+ webHost: remote.webHost ?? null,
342
+ async close() {
343
+ await closeDevServer(server);
344
+ },
345
+ };
346
+ }