@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,567 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
import { CliError, usageError } from "./client.mjs";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const MAX_PORT = 65_535;
|
|
12
|
+
const PORT_SUGGESTION_ATTEMPTS = 100;
|
|
13
|
+
const SENSITIVE_VALUE_FLAGS = new Set(["--token"]);
|
|
14
|
+
const HARNESS_ANSWER_TIMEOUT_MS = 750;
|
|
15
|
+
const LISTENER_CHECK_TIMEOUT_MS = 500;
|
|
16
|
+
const MAX_COMMAND_LINE = 96;
|
|
17
|
+
/** Every dev server listens here and nowhere else. Their pages carry dev
|
|
18
|
+
* tokens and brokered capabilities, and agent dev executes on this machine:
|
|
19
|
+
* none of it is for the network. */
|
|
20
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
21
|
+
|
|
22
|
+
/** Where a running harness says what it is — app, folder, members, PID — so a
|
|
23
|
+
* `terminus dev` that collides with one of its ports can name it. */
|
|
24
|
+
export const DEV_ABOUT_PATH = "/__terminus_dev/about";
|
|
25
|
+
|
|
26
|
+
/** Parse and validate a consecutive port range before startup mutates state. */
|
|
27
|
+
export function resolveDevPortRange(rawPort, count, defaultPort = 8868) {
|
|
28
|
+
const basePort = Number(rawPort ?? defaultPort);
|
|
29
|
+
if (!Number.isSafeInteger(basePort) || basePort < 0 || basePort > MAX_PORT) {
|
|
30
|
+
throw usageError("--port must be an integer between 0 and 65535");
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isSafeInteger(count) || count < 1) {
|
|
33
|
+
throw new CliError("terminus dev needs at least one local port");
|
|
34
|
+
}
|
|
35
|
+
if (basePort !== 0 && basePort + count - 1 > MAX_PORT) {
|
|
36
|
+
throw usageError(
|
|
37
|
+
`--port ${basePort} cannot fit ${count} consecutive ${count === 1 ? "port" : "ports"}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return basePort;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Listen on loopback, without leaving a startup-only error handler attached
|
|
44
|
+
* to the server. `localhost` still reaches it: clients that try ::1 first fall
|
|
45
|
+
* back to 127.0.0.1 when nothing answers there, which `probePort` makes sure
|
|
46
|
+
* of before startup. */
|
|
47
|
+
export function listenDevServer(server, port) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const onError = (error) => {
|
|
50
|
+
server.off("listening", onListening);
|
|
51
|
+
reject(error);
|
|
52
|
+
};
|
|
53
|
+
const onListening = () => {
|
|
54
|
+
server.off("error", onError);
|
|
55
|
+
resolve();
|
|
56
|
+
};
|
|
57
|
+
server.once("error", onError);
|
|
58
|
+
server.once("listening", onListening);
|
|
59
|
+
server.listen(port, LOOPBACK_HOST);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function closeDevServer(server) {
|
|
64
|
+
if (!server.listening) return;
|
|
65
|
+
server.closeAllConnections?.();
|
|
66
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether a request was addressed to this machine by name: its Host is
|
|
70
|
+
* localhost, a *.localhost name, a 127.x address, or [::1], at any port.
|
|
71
|
+
*
|
|
72
|
+
* Listening on loopback keeps other machines out; this keeps out web pages.
|
|
73
|
+
* A page on attacker.example can re-point its own name at 127.0.0.1 (DNS
|
|
74
|
+
* rebinding), and its browser then treats a dev server as the attacker's own
|
|
75
|
+
* origin: free to read the page that carries a dev token, and every door the
|
|
76
|
+
* token opens. Such a request still says `Host: attacker.example`. The port
|
|
77
|
+
* is not checked, so a proxy that keeps its own Host (the scaffolded Vite
|
|
78
|
+
* proxy sends localhost:5173) still passes. */
|
|
79
|
+
export function isLoopbackHost(request) {
|
|
80
|
+
const host = String(request.headers.host ?? "").trim();
|
|
81
|
+
return isLoopbackName(host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.replace(/:\d*$/, ""));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** localhost, a *.localhost name, a 127.x address, or [::1]: the one
|
|
85
|
+
* definition of this machine that the Host and Origin checks share. */
|
|
86
|
+
function isLoopbackName(name) {
|
|
87
|
+
const bare = name.toLowerCase().replace(/\.$/, "");
|
|
88
|
+
return bare === "localhost" || bare.endsWith(".localhost") || bare === "[::1]"
|
|
89
|
+
|| /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether a request came from a page on this machine, or from no page at
|
|
93
|
+
* all. The Host check keeps out rebound names, but not a page anywhere that
|
|
94
|
+
* fires requests at localhost blind (CSRF): it never reads the answer, and
|
|
95
|
+
* the write happens anyway. Browsers name the page that made a request:
|
|
96
|
+
* `Origin` on every cross-origin fetch and every POST, and `Sec-Fetch-Site`
|
|
97
|
+
* on all of them, including the no-cors GETs (an <img>, a <script>) that
|
|
98
|
+
* carry no Origin. curl, node, and the SDK outside a browser send neither
|
|
99
|
+
* and pass. A loopback page at another port (the Vite dev server's) passes. */
|
|
100
|
+
export function isLoopbackOrigin(request) {
|
|
101
|
+
const origin = request.headers.origin;
|
|
102
|
+
if (origin === undefined) return request.headers["sec-fetch-site"] !== "cross-site";
|
|
103
|
+
try {
|
|
104
|
+
const { protocol, hostname } = new URL(origin);
|
|
105
|
+
return (protocol === "http:" || protocol === "https:") && isLoopbackName(hostname);
|
|
106
|
+
} catch {
|
|
107
|
+
// "null" (a sandboxed frame, a file: page) and anything unparseable.
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a request came from a page on the origin it was sent to: its
|
|
113
|
+
* `Origin` (or else its `Referer`) names the host and port its `Host` does —
|
|
114
|
+
* the rule the app host holds every mutation to. A form a browser posts
|
|
115
|
+
* always carries one; a page on another port, even a loopback one, fails. The
|
|
116
|
+
* Vite dev server's proxy keeps its own `Host`, so a page it serves passes. */
|
|
117
|
+
export function isSameOrigin(request) {
|
|
118
|
+
const host = String(request.headers.host ?? "").trim().toLowerCase();
|
|
119
|
+
const source = request.headers.origin ?? request.headers.referer;
|
|
120
|
+
if (!host || typeof source !== "string") return false;
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(source);
|
|
123
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.host.toLowerCase() === host;
|
|
124
|
+
} catch {
|
|
125
|
+
// "null" (a sandboxed frame, a no-referrer page) and anything unparseable.
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The answer to a runtime request that fails isLoopbackOrigin. */
|
|
131
|
+
export function refuseCrossSite(response) {
|
|
132
|
+
response.writeHead(403, { "content-type": "application/json" });
|
|
133
|
+
response.end(JSON.stringify({
|
|
134
|
+
error: { code: "forbidden", message: "terminus dev refuses requests from pages on other sites" },
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The answer to a request that fails isLoopbackHost. */
|
|
139
|
+
export function refuseForeignHost(request, response) {
|
|
140
|
+
response.writeHead(403, { "content-type": "application/json" });
|
|
141
|
+
response.end(JSON.stringify({
|
|
142
|
+
error: {
|
|
143
|
+
code: "forbidden",
|
|
144
|
+
message: "terminus dev only answers requests addressed to localhost: "
|
|
145
|
+
+ `open http://localhost:${request.socket.localPort}/`,
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Answer DEV_ABOUT_PATH. Only this machine can ask: the server listens on
|
|
151
|
+
* loopback. */
|
|
152
|
+
export function answerDevAbout(request, response, about) {
|
|
153
|
+
response.writeHead(200, { "cache-control": "no-store", "content-type": "application/json" });
|
|
154
|
+
return response.end(JSON.stringify({
|
|
155
|
+
...about,
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
started_at: new Date(performance.timeOrigin).toISOString(),
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether anything accepts connections on this loopback address and port.
|
|
162
|
+
* A connect that hangs means a listener whose backlog is full. */
|
|
163
|
+
function acceptsOn(host, port) {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
const socket = connect({ host, port });
|
|
166
|
+
socket.setTimeout(LISTENER_CHECK_TIMEOUT_MS);
|
|
167
|
+
socket.once("connect", () => {
|
|
168
|
+
socket.destroy();
|
|
169
|
+
resolve(true);
|
|
170
|
+
});
|
|
171
|
+
socket.once("timeout", () => {
|
|
172
|
+
socket.destroy();
|
|
173
|
+
resolve(true);
|
|
174
|
+
});
|
|
175
|
+
// Refused, or no IPv6 loopback on this machine at all.
|
|
176
|
+
socket.once("error", () => resolve(false));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whether a dev server could own localhost:port. Binding 127.0.0.1 alone
|
|
181
|
+
* cannot say: on macOS it coexists silently with a listener on the wildcard
|
|
182
|
+
* or on ::1, and a browser that resolves localhost to ::1 would reach that
|
|
183
|
+
* one instead. So anything answering on either loopback address holds the
|
|
184
|
+
* port, and the bind catches the rest (a privileged port, a socket bound but
|
|
185
|
+
* not yet listening). */
|
|
186
|
+
async function probePort(port) {
|
|
187
|
+
const answered = await Promise.all([acceptsOn(LOOPBACK_HOST, port), acceptsOn("::1", port)]);
|
|
188
|
+
if (answered.includes(true)) {
|
|
189
|
+
return Object.assign(new Error(`port ${port} is in use`), { code: "EADDRINUSE" });
|
|
190
|
+
}
|
|
191
|
+
const server = createServer();
|
|
192
|
+
try {
|
|
193
|
+
await listenDevServer(server, port);
|
|
194
|
+
return null;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return error;
|
|
197
|
+
} finally {
|
|
198
|
+
await closeDevServer(server);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function unavailablePorts(basePort, count) {
|
|
203
|
+
const results = await Promise.all(
|
|
204
|
+
Array.from({ length: count }, async (_, index) => {
|
|
205
|
+
const port = basePort + index;
|
|
206
|
+
return { port, error: await probePort(port) };
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
return results.filter(({ error }) => error);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function processListeningOn(port) {
|
|
213
|
+
if (process.platform === "win32") return null;
|
|
214
|
+
try {
|
|
215
|
+
const { stdout } = await execFileAsync(
|
|
216
|
+
"lsof",
|
|
217
|
+
["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"],
|
|
218
|
+
{ encoding: "utf8", timeout: 1_500 },
|
|
219
|
+
);
|
|
220
|
+
const listeners = [];
|
|
221
|
+
let current = null;
|
|
222
|
+
for (const line of stdout.split("\n")) {
|
|
223
|
+
if (line.startsWith("p")) {
|
|
224
|
+
current = { pid: line.slice(1), command: null };
|
|
225
|
+
listeners.push(current);
|
|
226
|
+
} else if (line.startsWith("c") && current) {
|
|
227
|
+
current.command = line.slice(1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return listeners.find(({ pid }) => /^\d+$/.test(pid)) ?? null;
|
|
231
|
+
} catch {
|
|
232
|
+
// `lsof` is optional. The error still includes a copyable inspection command.
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Ask the port itself: a harness answers DEV_ABOUT_PATH. */
|
|
238
|
+
async function harnessOn(port) {
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch(`http://localhost:${port}${DEV_ABOUT_PATH}`, {
|
|
241
|
+
signal: AbortSignal.timeout(HARNESS_ANSWER_TIMEOUT_MS),
|
|
242
|
+
});
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
await response.body?.cancel();
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const about = await response.json();
|
|
248
|
+
return typeof about?.app === "string" && Number.isSafeInteger(about.pid) ? about : null;
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** ps's elapsed time, `[[dd-]hh:]mm:ss`, in seconds. */
|
|
255
|
+
function elapsedSeconds(text) {
|
|
256
|
+
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(text);
|
|
257
|
+
if (!match) return null;
|
|
258
|
+
const [days, hours, minutes, seconds] = match.slice(1).map((part) => Number(part ?? 0));
|
|
259
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 + seconds;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The listener's command line, folder, and start time, from ps and lsof. */
|
|
263
|
+
async function processDetails(pid) {
|
|
264
|
+
if (process.platform === "win32") return {};
|
|
265
|
+
// Under the C locale both tools escape every non-ASCII byte, which garbles
|
|
266
|
+
// a folder named in any script but Latin.
|
|
267
|
+
const options = { encoding: "utf8", env: { ...process.env, LC_ALL: "C.UTF-8" }, timeout: 1_500 };
|
|
268
|
+
const read = (file, args) => execFileAsync(file, args, options).then(({ stdout }) => stdout, () => "");
|
|
269
|
+
const [ps, cwd] = await Promise.all([
|
|
270
|
+
read("ps", ["-ww", "-o", "etime=,args=", "-p", pid]),
|
|
271
|
+
read("lsof", ["-a", "-p", pid, "-d", "cwd", "-Fn"]),
|
|
272
|
+
]);
|
|
273
|
+
const [, elapsed, args] = /^\s*(\S+)\s+(.*\S)/.exec(ps) ?? [];
|
|
274
|
+
const seconds = elapsed ? elapsedSeconds(elapsed) : null;
|
|
275
|
+
return {
|
|
276
|
+
args: args ?? null,
|
|
277
|
+
directory: /^n(.+)$/m.exec(cwd)?.[1] ?? null,
|
|
278
|
+
startedAt: seconds === null ? null : Date.now() - seconds * 1_000,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Who holds a port, as far as this machine will say. A harness names its
|
|
283
|
+
* app; anything else is the listening process, its command line and folder. */
|
|
284
|
+
async function portHolder(port) {
|
|
285
|
+
const [listener, about] = await Promise.all([processListeningOn(port), harnessOn(port)]);
|
|
286
|
+
// An answer from a PID other than the listener's came through a proxy, and
|
|
287
|
+
// the proxy is what holds the port.
|
|
288
|
+
if (about && (!listener || listener.pid === String(about.pid))) {
|
|
289
|
+
return {
|
|
290
|
+
pid: about.pid,
|
|
291
|
+
app: about.app,
|
|
292
|
+
kind: ["app", "agent", "service"].includes(about.kind) ? about.kind : null,
|
|
293
|
+
remote: about.remote === true,
|
|
294
|
+
member: typeof about.member === "string" ? about.member : null,
|
|
295
|
+
// The port a --guest harness keeps for a visitor who is not signed in.
|
|
296
|
+
guest: Number.isSafeInteger(about.guest_port) && about.guest_port === port,
|
|
297
|
+
ports: [...Object.values(about.ports ?? {}), about.guest_port].filter(Number.isSafeInteger),
|
|
298
|
+
directory: typeof about.directory === "string" ? about.directory : null,
|
|
299
|
+
startedAt: Date.parse(about.started_at) || null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (!listener) return null;
|
|
303
|
+
return {
|
|
304
|
+
pid: Number(listener.pid),
|
|
305
|
+
command: listener.command,
|
|
306
|
+
...(await processDetails(listener.pid)),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function nextAvailableRange(basePort, count) {
|
|
311
|
+
const firstCandidate = basePort + count;
|
|
312
|
+
for (let offset = 0; offset < PORT_SUGGESTION_ATTEMPTS; offset += 1) {
|
|
313
|
+
const candidate = firstCandidate + offset;
|
|
314
|
+
if (candidate + count - 1 > MAX_PORT) return null;
|
|
315
|
+
if ((await unavailablePorts(candidate, count)).length === 0) return candidate;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function rangeLabel(basePort, count) {
|
|
321
|
+
return count === 1 ? String(basePort) : `${basePort}-${basePort + count - 1}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function listLabel(items) {
|
|
325
|
+
return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function inspectionCommand(port) {
|
|
329
|
+
if (process.platform === "win32") return `netstat -ano | findstr :${port}`;
|
|
330
|
+
return `lsof -nP -iTCP:${port} -sTCP:LISTEN`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function stopCommand(pids) {
|
|
334
|
+
if (process.platform === "win32") return `taskkill ${pids.map((pid) => `/PID ${pid}`).join(" ")}`;
|
|
335
|
+
return `kill ${pids.join(" ")}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Text from another process — its answer, its command line — is headed for a
|
|
339
|
+
* terminal: controls and direction overrides go, whitespace folds, and an
|
|
340
|
+
* overlong line loses its middle, keeping the program and its arguments. */
|
|
341
|
+
function terminalText(value, max = Infinity) {
|
|
342
|
+
const chars = [];
|
|
343
|
+
for (const char of String(value ?? "")) {
|
|
344
|
+
const code = char.codePointAt(0);
|
|
345
|
+
const hidden = code < 0x20 || (code >= 0x7f && code < 0xa0) || code === 0x200e || code === 0x200f
|
|
346
|
+
|| (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069);
|
|
347
|
+
chars.push(hidden ? " " : char);
|
|
348
|
+
}
|
|
349
|
+
const folded = [...chars.join("").replace(/\s+/g, " ").trim()];
|
|
350
|
+
if (folded.length <= max) return folded.join("");
|
|
351
|
+
const head = Math.floor((max - 1) * 0.3);
|
|
352
|
+
return `${folded.slice(0, head).join("")}…${folded.slice(folded.length - (max - 1 - head)).join("")}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A folder the way the reader would type it: relative when it is nearby. */
|
|
356
|
+
function folderLabel(directory, { cwd, home }) {
|
|
357
|
+
const relative = path.relative(cwd, directory);
|
|
358
|
+
if (!relative) return "this folder";
|
|
359
|
+
const climbs = relative.split(path.sep).filter((part) => part === "..").length;
|
|
360
|
+
if (!path.isAbsolute(relative) && climbs <= 2) return relative;
|
|
361
|
+
return home && directory.startsWith(`${home}${path.sep}`) ? `~${directory.slice(home.length)}` : directory;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A process's command line, with its own folder and home written short. A
|
|
365
|
+
* daemon's folder is the root, and dropping that would eat every "//". */
|
|
366
|
+
function commandLabel({ args, command, directory }, { home }) {
|
|
367
|
+
let text = args ?? command ?? "";
|
|
368
|
+
if (args && directory && path.dirname(directory) !== directory) {
|
|
369
|
+
text = text.replaceAll(`${directory}${path.sep}`, "");
|
|
370
|
+
}
|
|
371
|
+
if (args && home) text = text.replaceAll(`${home}${path.sep}`, `~${path.sep}`);
|
|
372
|
+
return terminalText(text, MAX_COMMAND_LINE) || "another process";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function uptimeLabel(milliseconds) {
|
|
376
|
+
const minutes = Math.floor(milliseconds / 60_000);
|
|
377
|
+
if (minutes < 1) return "under a minute";
|
|
378
|
+
if (minutes < 60) return `${minutes}m`;
|
|
379
|
+
const hours = Math.floor(minutes / 60);
|
|
380
|
+
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
381
|
+
const days = Math.floor(hours / 24);
|
|
382
|
+
return `${days} ${days === 1 ? "day" : "days"}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function asMember(holder) {
|
|
386
|
+
if (holder.member) return `, as @${terminalText(holder.member)}`;
|
|
387
|
+
return holder.guest ? ", as a guest" : "";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function holderTitle(holder, context) {
|
|
391
|
+
if (!holder.app) return commandLabel(holder, context);
|
|
392
|
+
const mode = holder.remote ? " --remote" : "";
|
|
393
|
+
return `terminus dev${mode} for ${terminalText(holder.app, MAX_COMMAND_LINE)}${asMember(holder)}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function holderFacts(holder, blocked, context) {
|
|
397
|
+
const facts = [];
|
|
398
|
+
if (holder.directory) facts.push(`in ${terminalText(folderLabel(holder.directory, context))}`);
|
|
399
|
+
facts.push(`PID ${holder.pid}`);
|
|
400
|
+
if (holder.startedAt) facts.push(`running for ${uptimeLabel(context.now - holder.startedAt)}`);
|
|
401
|
+
const others = (holder.ports ?? []).filter((port) => !blocked.includes(port));
|
|
402
|
+
if (others.length) facts.push(`also on ${listLabel(others)}`);
|
|
403
|
+
return facts.join(" · ");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function shellQuote(value) {
|
|
407
|
+
const text = String(value);
|
|
408
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
|
|
409
|
+
return `'${text.replaceAll("'", `'"'"'`)}'`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Rebuild the invocation with one final port flag. Raw credential flags are
|
|
413
|
+
* deliberately omitted so an error never copies a secret into terminal logs. */
|
|
414
|
+
export function devCommandWithPort(args, port) {
|
|
415
|
+
const kept = [];
|
|
416
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
417
|
+
const argument = String(args[index]);
|
|
418
|
+
const [flag] = argument.split("=", 1);
|
|
419
|
+
if (flag === "--port" || SENSITIVE_VALUE_FLAGS.has(flag)) {
|
|
420
|
+
if (!argument.includes("=")) index += 1;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
kept.push(argument);
|
|
424
|
+
}
|
|
425
|
+
return ["terminus", "dev", ...kept, "--port", String(port)].map(shellQuote).join(" ");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The collision report, from what each blocked port said about its holder:
|
|
429
|
+
* which app (or program) has it, from which folder, since when, and how to
|
|
430
|
+
* get the port back — stop that holder, or re-run on free ports. Only an app
|
|
431
|
+
* has the scaffolded Vite proxy to re-point (`viteProxy`), and only an app
|
|
432
|
+
* run with --guest has the guest's port, the last one (`guest`). */
|
|
433
|
+
export function devPortUnavailableMessage({
|
|
434
|
+
basePort,
|
|
435
|
+
commandArgs = [],
|
|
436
|
+
count,
|
|
437
|
+
directory = null,
|
|
438
|
+
failures,
|
|
439
|
+
guest = false,
|
|
440
|
+
members = [],
|
|
441
|
+
suggestion = null,
|
|
442
|
+
viteProxy = false,
|
|
443
|
+
now = Date.now(),
|
|
444
|
+
cwd = process.cwd(),
|
|
445
|
+
home = os.homedir(),
|
|
446
|
+
}) {
|
|
447
|
+
const context = { cwd, home, now };
|
|
448
|
+
const blocked = failures.map(({ port }) => port);
|
|
449
|
+
const busy = failures.every(({ error }) => error?.code === "EADDRINUSE");
|
|
450
|
+
const state = `${blocked.length === 1 ? "is" : "are"} ${busy ? "already in use" : "unavailable"}`;
|
|
451
|
+
const who = guest
|
|
452
|
+
? `${members.length} ${members.length === 1 ? "member" : "members"} and a guest`
|
|
453
|
+
: `${count} members`;
|
|
454
|
+
const lines = [count === 1
|
|
455
|
+
? `Cannot start terminus dev: port ${listLabel(blocked)} ${state}.`
|
|
456
|
+
: `Cannot start terminus dev: its ${who} need ports ${rangeLabel(basePort, count)},`
|
|
457
|
+
+ ` and ${listLabel(blocked)} ${state}.`];
|
|
458
|
+
const holders = [];
|
|
459
|
+
const unknown = [];
|
|
460
|
+
for (const { port, error, holder } of failures) {
|
|
461
|
+
const member = count > 1 ? members[port - basePort] : null;
|
|
462
|
+
const seat = member ? `@${member}` : guest && port - basePort === members.length ? "the guest" : null;
|
|
463
|
+
const label = ` ${port}${seat ? ` (for ${seat})` : ""}:`;
|
|
464
|
+
if (error?.code !== "EADDRINUSE") {
|
|
465
|
+
lines.push(`${label} could not be bound${error?.code ? ` (${error.code})` : ""}`);
|
|
466
|
+
} else if (!holder) {
|
|
467
|
+
unknown.push(port);
|
|
468
|
+
lines.push(`${label} in use by another process`);
|
|
469
|
+
} else if (holders.some(({ pid }) => pid === holder.pid)) {
|
|
470
|
+
lines.push(`${label} the same ${holder.app ? "terminus dev" : "process"}${asMember(holder)}`);
|
|
471
|
+
} else {
|
|
472
|
+
holders.push({ ...holder, port });
|
|
473
|
+
lines.push(`${label} ${holderTitle(holder, context)}`, ` ${holderFacts(holder, blocked, context)}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const steps = [];
|
|
477
|
+
const itself = directory
|
|
478
|
+
? holders.find((holder) => holder.app && holder.directory === path.resolve(directory))
|
|
479
|
+
: null;
|
|
480
|
+
if (itself) {
|
|
481
|
+
const address = `http://localhost:${itself.port}/`;
|
|
482
|
+
steps.push(`terminus dev is already running for this ${itself.kind ?? "folder"} at ${address}.`);
|
|
483
|
+
}
|
|
484
|
+
if (holders.length) {
|
|
485
|
+
const one = holders.length === 1;
|
|
486
|
+
steps.push(
|
|
487
|
+
holders.every(({ app }) => app)
|
|
488
|
+
? `Stop ${one ? "it" : "them"} with Ctrl-C in ${one ? "its terminal" : "their terminals"}, or run:`
|
|
489
|
+
: `Stop ${one ? "it" : "them"} if you don't need ${one ? "it" : "them"}:`,
|
|
490
|
+
` ${stopCommand(holders.map(({ pid }) => pid))}`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (steps.length) lines.push("", ...steps);
|
|
494
|
+
if (unknown.length) lines.push("", "Inspect the listener:", ` ${inspectionCommand(unknown[0])}`);
|
|
495
|
+
// No second copy of an app that is already running: each harness holds the
|
|
496
|
+
// folder's spaces in memory and saves them whole, so two would overwrite
|
|
497
|
+
// each other's.
|
|
498
|
+
if (suggestion !== null && !itself) {
|
|
499
|
+
lines.push(
|
|
500
|
+
"",
|
|
501
|
+
count === 1 ? "Or re-run on a free port:" : `Or re-run on free ports ${rangeLabel(suggestion, count)}:`,
|
|
502
|
+
` ${devCommandWithPort(commandArgs, suggestion)}`,
|
|
503
|
+
);
|
|
504
|
+
if (viteProxy) {
|
|
505
|
+
lines.push(`If you use the scaffolded Vite proxy, point its target at http://localhost:${suggestion}.`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return lines.join("\n");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Turn one or more bind failures into one actionable CLI error. */
|
|
512
|
+
export async function devPortUnavailableError({
|
|
513
|
+
basePort,
|
|
514
|
+
commandArgs,
|
|
515
|
+
count,
|
|
516
|
+
directory,
|
|
517
|
+
guest = false,
|
|
518
|
+
members = [],
|
|
519
|
+
failures,
|
|
520
|
+
viteProxy,
|
|
521
|
+
}) {
|
|
522
|
+
const [described, suggestion] = await Promise.all([
|
|
523
|
+
Promise.all(failures.map(async (failure) => ({
|
|
524
|
+
...failure,
|
|
525
|
+
holder: failure.error?.code === "EADDRINUSE" ? await portHolder(failure.port) : null,
|
|
526
|
+
}))),
|
|
527
|
+
nextAvailableRange(basePort, count),
|
|
528
|
+
]);
|
|
529
|
+
return new CliError(devPortUnavailableMessage({
|
|
530
|
+
basePort,
|
|
531
|
+
commandArgs,
|
|
532
|
+
count,
|
|
533
|
+
directory,
|
|
534
|
+
failures: described,
|
|
535
|
+
guest,
|
|
536
|
+
members,
|
|
537
|
+
suggestion,
|
|
538
|
+
viteProxy,
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Check every requested port before startup does anything costly — clearing
|
|
543
|
+
* fixtures (`--fresh`), importing a draft, starting an engine. */
|
|
544
|
+
export async function assertDevPortRangeAvailable({
|
|
545
|
+
basePort,
|
|
546
|
+
commandArgs,
|
|
547
|
+
count,
|
|
548
|
+
directory,
|
|
549
|
+
guest = false,
|
|
550
|
+
members = [],
|
|
551
|
+
viteProxy,
|
|
552
|
+
}) {
|
|
553
|
+
if (basePort === 0) return;
|
|
554
|
+
const failures = await unavailablePorts(basePort, count);
|
|
555
|
+
if (failures.length) {
|
|
556
|
+
throw await devPortUnavailableError({
|
|
557
|
+
basePort,
|
|
558
|
+
commandArgs,
|
|
559
|
+
count,
|
|
560
|
+
directory,
|
|
561
|
+
guest,
|
|
562
|
+
members,
|
|
563
|
+
failures,
|
|
564
|
+
viteProxy,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentos-terminus` under `terminus dev`, where curl cannot carry it: the
|
|
3
|
+
* command an app's server op runs to make a syscall. It hands the command
|
|
4
|
+
* line to the dev runtime's binding host — which runs outside the op's
|
|
5
|
+
* sandbox, as the platform's does — and prints the answer the way the
|
|
6
|
+
* platform's binding command does: the envelope on stdout, or the framed
|
|
7
|
+
* refusal on stderr with exit status 1. A binding host it cannot reach is
|
|
8
|
+
* refused the way the platform refuses an unreachable broker.
|
|
9
|
+
*
|
|
10
|
+
* agentos-terminus <binding> --flag value ...
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const [ticketPath, ...args] = process.argv.slice(2);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { url, token } = JSON.parse(readFileSync(ticketPath, "utf8"));
|
|
19
|
+
const response = await fetch(url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" },
|
|
22
|
+
body: new URLSearchParams(args.map((arg) => ["a", arg])).toString(),
|
|
23
|
+
});
|
|
24
|
+
const text = await response.text();
|
|
25
|
+
if (response.ok) {
|
|
26
|
+
process.stdout.write(text);
|
|
27
|
+
} else {
|
|
28
|
+
process.stderr.write(`${text}\n`);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
const action = String(args[0] ?? "").replaceAll("-", "_");
|
|
33
|
+
process.stderr.write(`${action} failed [service_unavailable 503]: the platform could not be reached\n`);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
}
|