@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app's server code, run the way the platform runs it: the platform's own
|
|
3
|
+
* harness (vendored byte for byte in vendor/appd/) in a fresh process whose
|
|
4
|
+
* sandbox is Node's permission model — the op reads only its own files,
|
|
5
|
+
* writes only its copy of server/, has no network and next to no
|
|
6
|
+
* environment, and nothing carries from one run to the next.
|
|
7
|
+
*
|
|
8
|
+
* `terminus dev` runs every op through here (dev-server-ops.mjs), and the
|
|
9
|
+
* compiler loads an entry through the same harness to read the ops it
|
|
10
|
+
* exports (`describeServerOps`), so both see server/ exactly as the
|
|
11
|
+
* platform does.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
import { CliError } from "./client.mjs";
|
|
22
|
+
|
|
23
|
+
const APPD_DIR = fileURLToPath(new URL("./vendor/appd/", import.meta.url));
|
|
24
|
+
export const SERVER_PROTOCOL = JSON.parse(readFileSync(path.join(APPD_DIR, "server-protocol.json"), "utf8"));
|
|
25
|
+
const NODE_HARNESS = readFileSync(path.join(APPD_DIR, "node-harness.mjs"), "utf8");
|
|
26
|
+
const PYTHON_HARNESS = readFileSync(path.join(APPD_DIR, "python-harness.py"), "utf8");
|
|
27
|
+
|
|
28
|
+
const RESULT_FILE = path.posix.basename(SERVER_PROTOCOL.result_path);
|
|
29
|
+
const INVOCATION_FILE = path.posix.basename(SERVER_PROTOCOL.invocation_path);
|
|
30
|
+
/** bashd keeps the first 64 KiB of stdout and 32 KiB of stderr. */
|
|
31
|
+
const MAX_STDOUT_BYTES = 64 * 1024;
|
|
32
|
+
const MAX_STDERR_BYTES = 32 * 1024;
|
|
33
|
+
/** Loading an entry to read its ops runs its top-level code; this bounds it. */
|
|
34
|
+
const DESCRIBE_TIMEOUT_MS = 10_000;
|
|
35
|
+
const DESCRIBE_FILE = ".terminus_describe.js";
|
|
36
|
+
|
|
37
|
+
/** Head-truncate to a byte budget, as bashd does (a split character decodes as U+FFFD). */
|
|
38
|
+
function truncateUtf8(text, maxBytes) {
|
|
39
|
+
const bytes = Buffer.from(text, "utf8");
|
|
40
|
+
if (bytes.length <= maxBytes) return text;
|
|
41
|
+
return bytes.subarray(0, maxBytes).toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collectOutput(stream, maxBytes) {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let kept = 0;
|
|
47
|
+
stream.on("data", (chunk) => {
|
|
48
|
+
if (kept >= maxBytes) return;
|
|
49
|
+
const piece = chunk.subarray(0, maxBytes - kept);
|
|
50
|
+
chunks.push(piece);
|
|
51
|
+
kept += piece.length;
|
|
52
|
+
});
|
|
53
|
+
return () => truncateUtf8(Buffer.concat(chunks).toString("utf8"), maxBytes);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stop a run and the syscalls it started (it leads its own process group). */
|
|
57
|
+
export function stopGroup(child) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(-child.pid, "SIGKILL");
|
|
60
|
+
} catch {
|
|
61
|
+
child.kill("SIGKILL");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One run of the harness. `program` is the server/ files ({path, bytes}),
|
|
67
|
+
* `entry` the entrypoint relative to server/, `invocation` {op, input, ctx}.
|
|
68
|
+
* `prepareHost(hostDir)` may place an `agentos-terminus` relay beside the
|
|
69
|
+
* sandbox (the node lane's syscalls); `running` tracks live processes.
|
|
70
|
+
* Answers {outcome: completed|failed|timed_out, output, logs, errorText,
|
|
71
|
+
* durationMs, unavailable} — `unavailable` when the runtime itself could not
|
|
72
|
+
* be started (no python3).
|
|
73
|
+
*/
|
|
74
|
+
export async function runServerHarness({ runtime, entry, program, invocation, timeoutMs, prepareHost = null, running = null }) {
|
|
75
|
+
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "terminus-server-op-")));
|
|
76
|
+
const vmDir = path.join(root, "vm");
|
|
77
|
+
const serverBase = path.join(vmDir, "server");
|
|
78
|
+
const hostDir = path.join(root, "host");
|
|
79
|
+
try {
|
|
80
|
+
await mkdir(serverBase, { recursive: true });
|
|
81
|
+
for (const file of program) {
|
|
82
|
+
const target = path.join(vmDir, ...file.path.split("/"));
|
|
83
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
84
|
+
await writeFile(target, file.bytes);
|
|
85
|
+
}
|
|
86
|
+
await writeFile(path.join(serverBase, INVOCATION_FILE), JSON.stringify(invocation));
|
|
87
|
+
let command;
|
|
88
|
+
let args;
|
|
89
|
+
let env;
|
|
90
|
+
if (runtime === "node") {
|
|
91
|
+
const harnessPath = path.join(vmDir, "harness.mjs");
|
|
92
|
+
await writeFile(harnessPath, NODE_HARNESS.replaceAll("__ENTRYPOINT__", entry));
|
|
93
|
+
// The sandbox has no network; say so in words that point at the syscall.
|
|
94
|
+
const denyNetwork = path.join(vmDir, "deny-network.mjs");
|
|
95
|
+
await writeFile(denyNetwork, [
|
|
96
|
+
"const refuse = () => { throw new TypeError(\"server code has no network — fetch through terminus.webFetch(url)\"); };",
|
|
97
|
+
"globalThis.fetch = async () => refuse();",
|
|
98
|
+
"globalThis.WebSocket = class WebSocket { constructor() { refuse(); } };",
|
|
99
|
+
"globalThis.EventSource = class EventSource { constructor() { refuse(); } };",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n"));
|
|
102
|
+
await mkdir(hostDir);
|
|
103
|
+
if (prepareHost) await prepareHost(hostDir);
|
|
104
|
+
command = process.execPath;
|
|
105
|
+
args = [
|
|
106
|
+
"--permission",
|
|
107
|
+
`--allow-fs-read=${vmDir}`,
|
|
108
|
+
`--allow-fs-write=${serverBase}`,
|
|
109
|
+
"--allow-child-process",
|
|
110
|
+
"--disable-warning=SecurityWarning",
|
|
111
|
+
"--import",
|
|
112
|
+
pathToFileURL(denyNetwork).href,
|
|
113
|
+
harnessPath,
|
|
114
|
+
];
|
|
115
|
+
env = { PATH: hostDir, TERMINUS_SERVER_BASE: serverBase };
|
|
116
|
+
} else {
|
|
117
|
+
const harnessPath = path.join(vmDir, "harness.py");
|
|
118
|
+
await writeFile(harnessPath, PYTHON_HARNESS.replaceAll("__ENTRYPOINT__", entry));
|
|
119
|
+
command = "python3";
|
|
120
|
+
args = [harnessPath];
|
|
121
|
+
env = { PATH: process.env.PATH ?? "/usr/bin:/bin", TERMINUS_SERVER_BASE: serverBase };
|
|
122
|
+
}
|
|
123
|
+
const started = Date.now();
|
|
124
|
+
const child = spawn(command, args, { cwd: serverBase, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
125
|
+
running?.add(child);
|
|
126
|
+
const stdout = collectOutput(child.stdout, MAX_STDOUT_BYTES);
|
|
127
|
+
const stderr = collectOutput(child.stderr, MAX_STDERR_BYTES);
|
|
128
|
+
const exit = await new Promise((resolve) => {
|
|
129
|
+
let timedOut = false;
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
timedOut = true;
|
|
132
|
+
stopGroup(child);
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
child.once("error", (error) => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
resolve({ code: 1, timedOut: false, spawnError: error });
|
|
137
|
+
});
|
|
138
|
+
child.once("close", (code) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
resolve({ code: code ?? 1, timedOut });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
running?.delete(child);
|
|
144
|
+
let errorText = stderr().trim();
|
|
145
|
+
if (exit.spawnError) {
|
|
146
|
+
errorText = exit.spawnError.code === "ENOENT" && runtime === "python"
|
|
147
|
+
? "python3 was not found — install Python 3 to run python server ops under terminus dev"
|
|
148
|
+
: String(exit.spawnError.message);
|
|
149
|
+
}
|
|
150
|
+
const outcome = exit.timedOut ? "timed_out" : exit.code === 0 ? "completed" : "failed";
|
|
151
|
+
if (outcome === "timed_out" && !errorText) errorText = `the op did not finish within ${timeoutMs} ms`;
|
|
152
|
+
let output = null;
|
|
153
|
+
if (outcome === "completed") {
|
|
154
|
+
try {
|
|
155
|
+
output = JSON.parse(await readFile(path.join(serverBase, RESULT_FILE), "utf8"));
|
|
156
|
+
} catch {
|
|
157
|
+
output = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// A runtime that never started is the tier's failure, not the op's.
|
|
161
|
+
const unavailable = Boolean(exit.spawnError);
|
|
162
|
+
return { outcome, output, logs: stdout(), errorText, durationMs: Date.now() - started, unavailable };
|
|
163
|
+
} finally {
|
|
164
|
+
await rm(root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The ops a Node entry exports — the functions on its `exports.ops` — read by
|
|
170
|
+
* loading it through the platform harness, exactly as a call would: a tiny
|
|
171
|
+
* probe beside it requires the entry with the harness's own `require` and
|
|
172
|
+
* returns the names.
|
|
173
|
+
*/
|
|
174
|
+
export async function describeServerOps(program, entrypoint) {
|
|
175
|
+
const entry = entrypoint.replace(/^server\//, "");
|
|
176
|
+
const probe = [
|
|
177
|
+
`const entry = require(${JSON.stringify(`./${entry}`)});`,
|
|
178
|
+
"exports.ops = {",
|
|
179
|
+
" describe() {",
|
|
180
|
+
" const ops = entry && entry.ops && typeof entry.ops === \"object\" ? entry.ops : {};",
|
|
181
|
+
" return Object.keys(ops).filter((name) => typeof ops[name] === \"function\");",
|
|
182
|
+
" },",
|
|
183
|
+
"};",
|
|
184
|
+
"",
|
|
185
|
+
].join("\n");
|
|
186
|
+
const run = await runServerHarness({
|
|
187
|
+
runtime: "node",
|
|
188
|
+
entry: DESCRIBE_FILE,
|
|
189
|
+
program: [
|
|
190
|
+
...program.filter((file) => file.path !== `server/${DESCRIBE_FILE}`),
|
|
191
|
+
{ path: `server/${DESCRIBE_FILE}`, bytes: Buffer.from(probe) },
|
|
192
|
+
],
|
|
193
|
+
invocation: { ctx: {}, input: null, op: "describe" },
|
|
194
|
+
timeoutMs: DESCRIBE_TIMEOUT_MS,
|
|
195
|
+
});
|
|
196
|
+
if (run.outcome !== "completed") {
|
|
197
|
+
// An error thrown while loading prints a source excerpt first; the
|
|
198
|
+
// message is the line that names the error.
|
|
199
|
+
const lines = run.errorText.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
200
|
+
const reason = lines.find((line) => /^[A-Za-z]*Error\b/u.test(line)) ?? lines[0] ?? run.outcome;
|
|
201
|
+
throw new CliError(`${entrypoint} could not be loaded the way the platform loads it: ${reason}`);
|
|
202
|
+
}
|
|
203
|
+
return Array.isArray(run.output) ? run.output.filter((name) => typeof name === "string") : [];
|
|
204
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// The `terminus dev` session for `kind: "service"` packages.
|
|
2
|
+
//
|
|
3
|
+
// A service's implementation is already deployed somewhere — the developer's
|
|
4
|
+
// own infrastructure, a platform-internal endpoint, or a third-party API
|
|
5
|
+
// reached with a stored key. What local development needs is a fast way to
|
|
6
|
+
// exercise that deployment through the same lane production traffic takes.
|
|
7
|
+
// This server does exactly that: it imports the package as the caller's
|
|
8
|
+
// draft, serves the package's own test page from `dev/`, and relays
|
|
9
|
+
// `POST /api/invoke/{operation}` through the platform's free draft-test lane
|
|
10
|
+
// (a direct Ed25519-authorized call for public terminus_signed endpoints,
|
|
11
|
+
// else the gateway test door). The page is authored by the service — anydoc
|
|
12
|
+
// ships a drop-a-document surface, obscura a URL box — so the harness stays
|
|
13
|
+
// generic.
|
|
14
|
+
//
|
|
15
|
+
// The security model mirrors bin/devserver.mjs: the user JWT never reaches
|
|
16
|
+
// the page; a per-run random token is injected into the served HTML and
|
|
17
|
+
// required on every /api/* call, and cross-origin browser pages are refused.
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { watch } from "node:fs";
|
|
22
|
+
import { readFile, stat } from "node:fs/promises";
|
|
23
|
+
|
|
24
|
+
import { CliError, usageError } from "./client.mjs";
|
|
25
|
+
import { connect } from "./http.mjs";
|
|
26
|
+
import { importLinkedDraft, invokeDraftOperation, readAppPackage } from "./apps.mjs";
|
|
27
|
+
import {
|
|
28
|
+
answerDevAbout,
|
|
29
|
+
assertDevPortRangeAvailable,
|
|
30
|
+
closeDevServer,
|
|
31
|
+
DEV_ABOUT_PATH,
|
|
32
|
+
devPortUnavailableError,
|
|
33
|
+
isLoopbackHost,
|
|
34
|
+
isLoopbackOrigin,
|
|
35
|
+
listenDevServer,
|
|
36
|
+
refuseForeignHost,
|
|
37
|
+
resolveDevPortRange,
|
|
38
|
+
} from "./dev-ports.mjs";
|
|
39
|
+
import { contentTypeFor, openWithPlatform, timingSafeTokenMatch } from "./devserver.mjs";
|
|
40
|
+
|
|
41
|
+
const SERVICE_DEV_PORT = 4750;
|
|
42
|
+
const PORT_ATTEMPTS = 50;
|
|
43
|
+
// The gateway caps service requests at 8 MiB of raw bytes; the JSON envelope
|
|
44
|
+
// form carries the same payload base64-encoded, so allow the expansion.
|
|
45
|
+
const RAW_BODY_LIMIT = 8 * 1024 * 1024;
|
|
46
|
+
const JSON_BODY_LIMIT = 12 * 1024 * 1024;
|
|
47
|
+
const WATCHED_FILES = new Set(["terminus.json", "openapi.json", "README.md"]);
|
|
48
|
+
|
|
49
|
+
async function readServicePackage(dir) {
|
|
50
|
+
const pkg = await readAppPackage(dir);
|
|
51
|
+
if (pkg.manifest.kind !== "service") {
|
|
52
|
+
throw new CliError(`terminus dev found kind '${pkg.manifest.kind}' where a service was expected`);
|
|
53
|
+
}
|
|
54
|
+
if (pkg.servicePackage.runtime.kind === "builtin") {
|
|
55
|
+
throw new CliError(
|
|
56
|
+
"this service runs inside the Terminus backend (runtime.kind 'builtin'); "
|
|
57
|
+
+ "there is no endpoint to exercise locally — test it through the platform surfaces",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return pkg;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadServiceState(dir, api, flags = {}) {
|
|
64
|
+
const pkg = await readServicePackage(dir);
|
|
65
|
+
// The dev relays calls through the service's draft, so the service has to
|
|
66
|
+
// exist on Terminus already; this refreshes that draft from the folder.
|
|
67
|
+
const imported = await importLinkedDraft(api, pkg, {
|
|
68
|
+
flags,
|
|
69
|
+
message: "Staged by terminus dev",
|
|
70
|
+
});
|
|
71
|
+
const appId = imported?.app?.id;
|
|
72
|
+
if (!appId) throw new CliError("the platform did not return an id for the imported service draft");
|
|
73
|
+
return { pkg, appId };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function serviceBoot(state, token) {
|
|
77
|
+
const service = state.pkg.servicePackage;
|
|
78
|
+
return {
|
|
79
|
+
token,
|
|
80
|
+
service: {
|
|
81
|
+
name: service.name,
|
|
82
|
+
version: service.version,
|
|
83
|
+
description: service.description,
|
|
84
|
+
},
|
|
85
|
+
operations: state.pkg.manifest.runtime.operations.map((operation) => ({
|
|
86
|
+
id: operation.id,
|
|
87
|
+
method: operation.method,
|
|
88
|
+
path: operation.path,
|
|
89
|
+
description: operation.description ?? "",
|
|
90
|
+
request_content_type: operation.request_content_type ?? null,
|
|
91
|
+
response_content_type: operation.response_content_type ?? null,
|
|
92
|
+
timeout_seconds: operation.timeout_seconds ?? 30,
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Inject the boot object without demanding a placeholder from page authors:
|
|
98
|
+
* right after <head> when the page has one, else ahead of everything.
|
|
99
|
+
*
|
|
100
|
+
* The page contract is host-agnostic: `window.__SERVICE_DEV__` carries
|
|
101
|
+
* `{ service, operations, invoke }`, and `invoke(operationId, { input,
|
|
102
|
+
* query, bytes, idempotencyKey })` returns the test-lane result. This host
|
|
103
|
+
* implements invoke as a fetch to its own /api doors; the admin console
|
|
104
|
+
* implements the same contract over postMessage from a sandboxed frame, so
|
|
105
|
+
* pages never hard-code a transport.
|
|
106
|
+
*/
|
|
107
|
+
function injectBoot(html, boot) {
|
|
108
|
+
const data = JSON.stringify(boot).replace(/</g, "\\u003c");
|
|
109
|
+
const script = `<script>(() => {
|
|
110
|
+
const boot = ${data};
|
|
111
|
+
const call = async (operationId, headers, body) => {
|
|
112
|
+
const target = new URL("api/invoke/" + encodeURIComponent(operationId), document.baseURI);
|
|
113
|
+
if (headers.query) {
|
|
114
|
+
for (const [key, value] of Object.entries(headers.query)) {
|
|
115
|
+
target.searchParams.set(key, String(value));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const response = await fetch(target, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { ...headers.http, "x-dev-token": boot.token },
|
|
121
|
+
body,
|
|
122
|
+
});
|
|
123
|
+
const result = await response.json();
|
|
124
|
+
if (!response.ok) throw new Error(result.error ?? ("HTTP " + response.status));
|
|
125
|
+
return result;
|
|
126
|
+
};
|
|
127
|
+
boot.invoke = (operationId, request = {}) => {
|
|
128
|
+
const { input, query, bytes, idempotencyKey } = request;
|
|
129
|
+
if (bytes !== undefined) {
|
|
130
|
+
return call(operationId, { http: { "content-type": "application/octet-stream" }, query }, bytes);
|
|
131
|
+
}
|
|
132
|
+
return call(
|
|
133
|
+
operationId,
|
|
134
|
+
{ http: { "content-type": "application/json" } },
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
...(input !== undefined ? { input } : {}),
|
|
137
|
+
...(query ? { query } : {}),
|
|
138
|
+
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
window.__SERVICE_DEV__ = boot;
|
|
143
|
+
})();</script>`;
|
|
144
|
+
const head = /<head[^>]*>/i.exec(html);
|
|
145
|
+
if (head) {
|
|
146
|
+
const cut = head.index + head[0].length;
|
|
147
|
+
return `${html.slice(0, cut)}${script}${html.slice(cut)}`;
|
|
148
|
+
}
|
|
149
|
+
return `${script}${html}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function safeDevAssetPath(devDir, pathname) {
|
|
153
|
+
const relative = decodeURIComponent(pathname).replace(/^\/+/, "") || "index.html";
|
|
154
|
+
const segments = relative.split("/");
|
|
155
|
+
if (segments.some((segment) => !segment || segment === "." || segment === ".."
|
|
156
|
+
|| segment.includes("\\"))) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return path.join(devDir, ...segments);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readBody(request, limit) {
|
|
163
|
+
const chunks = [];
|
|
164
|
+
let size = 0;
|
|
165
|
+
for await (const chunk of request) {
|
|
166
|
+
size += chunk.length;
|
|
167
|
+
if (size > limit) throw new CliError("request body is too large for the service test lane");
|
|
168
|
+
chunks.push(chunk);
|
|
169
|
+
}
|
|
170
|
+
return Buffer.concat(chunks, size);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function invokeEnvelope(request, url, bytes) {
|
|
174
|
+
const contentType = String(request.headers["content-type"] ?? "").toLowerCase();
|
|
175
|
+
if (contentType.startsWith("application/json")) {
|
|
176
|
+
let envelope = {};
|
|
177
|
+
if (bytes.length) {
|
|
178
|
+
try {
|
|
179
|
+
envelope = JSON.parse(bytes.toString("utf8"));
|
|
180
|
+
} catch {
|
|
181
|
+
throw new CliError("the invoke body is not valid JSON");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
185
|
+
throw new CliError("the invoke body must be a JSON object");
|
|
186
|
+
}
|
|
187
|
+
const body = { query: {} };
|
|
188
|
+
if (envelope.query !== undefined) {
|
|
189
|
+
if (!envelope.query || typeof envelope.query !== "object" || Array.isArray(envelope.query)) {
|
|
190
|
+
throw new CliError("invoke 'query' must be a JSON object");
|
|
191
|
+
}
|
|
192
|
+
body.query = envelope.query;
|
|
193
|
+
}
|
|
194
|
+
if (envelope.body_base64 != null) body.body_base64 = String(envelope.body_base64);
|
|
195
|
+
else if (envelope.input !== undefined) body.input = envelope.input;
|
|
196
|
+
if (envelope.idempotency_key) body.idempotency_key = String(envelope.idempotency_key);
|
|
197
|
+
return body;
|
|
198
|
+
}
|
|
199
|
+
// Any other content type is the operation's raw request body — the page
|
|
200
|
+
// posts file bytes directly and puts parameters in the query string.
|
|
201
|
+
if (bytes.length > RAW_BODY_LIMIT) {
|
|
202
|
+
throw new CliError("the file is larger than the 8 MiB service request limit");
|
|
203
|
+
}
|
|
204
|
+
const query = {};
|
|
205
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
206
|
+
if (key !== "token") query[key] = value;
|
|
207
|
+
}
|
|
208
|
+
return { query, ...(bytes.length ? { body_base64: bytes.toString("base64") } : {}) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function startServiceDevServer({ commandArgs, dir, flags, api }) {
|
|
212
|
+
const pkg = await readServicePackage(dir);
|
|
213
|
+
const devDir = path.join(dir, "dev");
|
|
214
|
+
const indexPath = path.join(devDir, "index.html");
|
|
215
|
+
try {
|
|
216
|
+
const info = await stat(indexPath);
|
|
217
|
+
if (!info.isFile()) throw new Error("not a file");
|
|
218
|
+
} catch {
|
|
219
|
+
throw new CliError(
|
|
220
|
+
"this service package has no dev/index.html test page; add one (it is served "
|
|
221
|
+
+ "locally and never uploaded) or test operations with 'terminus service test'",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
// A busy --port is reported before the draft import, so it costs nothing on
|
|
225
|
+
// Terminus and says who holds the port.
|
|
226
|
+
const basePort = resolveDevPortRange(flags.port, 1, SERVICE_DEV_PORT);
|
|
227
|
+
if (flags.port !== undefined) {
|
|
228
|
+
await assertDevPortRangeAvailable({ basePort, commandArgs, count: 1, directory: dir });
|
|
229
|
+
}
|
|
230
|
+
const imported = await importLinkedDraft(api, pkg, {
|
|
231
|
+
flags,
|
|
232
|
+
message: "Staged by terminus dev",
|
|
233
|
+
});
|
|
234
|
+
const appId = imported?.app?.id;
|
|
235
|
+
if (!appId) throw new CliError("the platform did not return an id for the imported service draft");
|
|
236
|
+
let state = { pkg, appId };
|
|
237
|
+
const token = randomBytes(16).toString("hex");
|
|
238
|
+
|
|
239
|
+
const authorized = (request, url) => {
|
|
240
|
+
const presented = request.headers["x-dev-token"] ?? url.searchParams.get("token");
|
|
241
|
+
return timingSafeTokenMatch(token, presented) && isLoopbackOrigin(request);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const server = createServer(async (request, response) => {
|
|
245
|
+
// The page carries the dev token, which invokes the service on the
|
|
246
|
+
// developer's account.
|
|
247
|
+
if (!isLoopbackHost(request)) return refuseForeignHost(request, response);
|
|
248
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
249
|
+
try {
|
|
250
|
+
if (url.pathname === DEV_ABOUT_PATH) {
|
|
251
|
+
answerDevAbout(request, response, {
|
|
252
|
+
kind: "service",
|
|
253
|
+
app: state.pkg.id ?? state.pkg.manifest.slug,
|
|
254
|
+
directory: path.resolve(dir),
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (request.method === "GET" && !url.pathname.startsWith("/api/")) {
|
|
259
|
+
const asset = safeDevAssetPath(devDir, url.pathname);
|
|
260
|
+
if (!asset) {
|
|
261
|
+
response.writeHead(404).end("not found");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
let bytes;
|
|
265
|
+
try {
|
|
266
|
+
bytes = await readFile(asset);
|
|
267
|
+
} catch {
|
|
268
|
+
response.writeHead(404).end("not found");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (asset === indexPath) {
|
|
272
|
+
const page = injectBoot(bytes.toString("utf8"), serviceBoot(state, token));
|
|
273
|
+
response.writeHead(200, {
|
|
274
|
+
"content-type": "text/html; charset=utf-8",
|
|
275
|
+
"cache-control": "no-store",
|
|
276
|
+
});
|
|
277
|
+
response.end(page);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
response.writeHead(200, {
|
|
281
|
+
"content-type": contentTypeFor(asset),
|
|
282
|
+
"cache-control": "no-store",
|
|
283
|
+
});
|
|
284
|
+
response.end(bytes);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!url.pathname.startsWith("/api/")) {
|
|
288
|
+
response.writeHead(404).end("not found");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!authorized(request, url)) {
|
|
292
|
+
response.writeHead(401, { "content-type": "application/json" });
|
|
293
|
+
response.end(JSON.stringify({ error: "missing or invalid dev token" }));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (request.method === "GET" && url.pathname === "/api/service") {
|
|
297
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
298
|
+
response.end(JSON.stringify({ ...serviceBoot(state, token), app_id: state.appId }));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (request.method === "POST" && url.pathname === "/api/reload") {
|
|
302
|
+
state = await loadServiceState(dir, api, flags);
|
|
303
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
304
|
+
response.end(JSON.stringify({ ok: true }));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const invoke = /^\/api\/invoke\/([A-Za-z0-9][A-Za-z0-9_.-]{0,79})$/.exec(url.pathname);
|
|
308
|
+
if (request.method === "POST" && invoke) {
|
|
309
|
+
const operationId = invoke[1];
|
|
310
|
+
if (!state.pkg.manifest.runtime.operations.some((entry) => entry.id === operationId)) {
|
|
311
|
+
throw new CliError(`service operation '${operationId}' is not declared by OpenAPI`);
|
|
312
|
+
}
|
|
313
|
+
const bytes = await readBody(request, JSON_BODY_LIMIT);
|
|
314
|
+
const body = invokeEnvelope(request, url, bytes);
|
|
315
|
+
const result = await invokeDraftOperation(api, {
|
|
316
|
+
pkg: state.pkg,
|
|
317
|
+
appId: state.appId,
|
|
318
|
+
operationId,
|
|
319
|
+
body,
|
|
320
|
+
});
|
|
321
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
322
|
+
response.end(JSON.stringify(result));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
response.writeHead(404, { "content-type": "application/json" });
|
|
326
|
+
response.end(JSON.stringify({ error: "unknown dev endpoint" }));
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const message = error instanceof CliError ? error.message : String(error?.message ?? error);
|
|
329
|
+
const status = error instanceof CliError ? 400 : 502;
|
|
330
|
+
if (!(error instanceof CliError)) {
|
|
331
|
+
console.error(`service dev error: ${message}`);
|
|
332
|
+
}
|
|
333
|
+
if (!response.headersSent) {
|
|
334
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
335
|
+
}
|
|
336
|
+
response.end(JSON.stringify({ error: message }));
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
let port = basePort;
|
|
341
|
+
let lastError = null;
|
|
342
|
+
for (let attempt = 0; attempt < (flags.port !== undefined ? 1 : PORT_ATTEMPTS); attempt += 1) {
|
|
343
|
+
try {
|
|
344
|
+
await listenDevServer(server, port);
|
|
345
|
+
lastError = null;
|
|
346
|
+
port = server.address().port;
|
|
347
|
+
break;
|
|
348
|
+
} catch (error) {
|
|
349
|
+
lastError = error;
|
|
350
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
351
|
+
port += 1;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (lastError && flags.port !== undefined) {
|
|
355
|
+
// Taken during the import, after the check above said it was free.
|
|
356
|
+
throw await devPortUnavailableError({
|
|
357
|
+
basePort,
|
|
358
|
+
commandArgs,
|
|
359
|
+
count: 1,
|
|
360
|
+
directory: dir,
|
|
361
|
+
failures: [{ port: basePort, error: lastError }],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (lastError) {
|
|
365
|
+
throw new CliError(`no free port between ${basePort} and ${port - 1}; pick one with --port`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Contract edits re-import the draft so the next invocation tests what is
|
|
369
|
+
// on disk; the dev/ page itself is read per request and needs no watcher.
|
|
370
|
+
let reloadTimer = null;
|
|
371
|
+
let watcher = null;
|
|
372
|
+
try {
|
|
373
|
+
watcher = watch(dir, (_event, filename) => {
|
|
374
|
+
if (!filename || !WATCHED_FILES.has(String(filename))) return;
|
|
375
|
+
clearTimeout(reloadTimer);
|
|
376
|
+
reloadTimer = setTimeout(async () => {
|
|
377
|
+
try {
|
|
378
|
+
state = await loadServiceState(dir, api, flags);
|
|
379
|
+
console.log("service contract changed — draft re-imported");
|
|
380
|
+
} catch (error) {
|
|
381
|
+
console.error(`service contract reload failed: ${
|
|
382
|
+
error instanceof CliError ? error.message : error
|
|
383
|
+
}`);
|
|
384
|
+
}
|
|
385
|
+
}, 300);
|
|
386
|
+
if (typeof reloadTimer.unref === "function") reloadTimer.unref();
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
watcher = null; // watching is a convenience; never fail the session for it
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const close = async () => {
|
|
393
|
+
clearTimeout(reloadTimer);
|
|
394
|
+
watcher?.close();
|
|
395
|
+
await closeDevServer(server);
|
|
396
|
+
};
|
|
397
|
+
return {
|
|
398
|
+
server,
|
|
399
|
+
port,
|
|
400
|
+
token,
|
|
401
|
+
url: `http://127.0.0.1:${port}/`,
|
|
402
|
+
state: () => state,
|
|
403
|
+
close,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function serviceDevCommand(dir, flags, commandArgs = []) {
|
|
408
|
+
if (flags.remote) throw usageError("--remote does not apply to service packages");
|
|
409
|
+
if (flags.members) throw usageError("--members only applies to app packages");
|
|
410
|
+
if (flags.guest) throw usageError("--guest only applies to app packages");
|
|
411
|
+
const api = await connect(flags);
|
|
412
|
+
const started = await startServiceDevServer({ commandArgs, dir, flags, api });
|
|
413
|
+
const service = started.state().pkg.servicePackage;
|
|
414
|
+
console.log(`Service dev session: ${service.name}@${service.version} → ${started.url}`);
|
|
415
|
+
console.log(
|
|
416
|
+
"Draft imported; invocations relay through the free test lane against the deployed endpoint.",
|
|
417
|
+
);
|
|
418
|
+
if (!flags.no_open) await openWithPlatform(started.url);
|
|
419
|
+
await new Promise((resolve) => {
|
|
420
|
+
const stop = () => resolve();
|
|
421
|
+
process.once("SIGINT", stop);
|
|
422
|
+
process.once("SIGTERM", stop);
|
|
423
|
+
});
|
|
424
|
+
await started.close();
|
|
425
|
+
}
|