@datumos/datum-cli 0.0.0 → 0.588.0

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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * `datum preview up | down | trace` and `datum previews` (#2928) — the ACC
3
+ * preview gateway from a developer's terminal, the twins of `cde app start`,
4
+ * `cde interfaces` and `cde trace`:
5
+ *
6
+ * datum preview up --port 5173 [--vhost shop.example.com] [--machine laptop]
7
+ * datum previews [--json]
8
+ * datum preview trace <id> [--follow] [--limit N]
9
+ * datum preview down <id>
10
+ *
11
+ * `up` asks the platform for a gateway route to a port of the PERSON's own
12
+ * machine; the machine's personal satellite then holds the preview link
13
+ * (#2927) — this process carries no traffic and holds no key or token of the
14
+ * link. It stays in the foreground, printing the link and the state, and
15
+ * Ctrl+C closes the preview (`down`). Hand-written because the derived tree
16
+ * cannot express a foreground loop or a follow: the API is
17
+ * /api/acc-previews (derived as `datum acc-previews …`, raw).
18
+ *
19
+ * A person's only: the routes answer 403 `not-a-person` to an API key — the
20
+ * hint says to `datum login`.
21
+ */
22
+ import os from "node:os";
23
+ import { apiSurface, UsageError } from "./invoke.js";
24
+ /** How often `up` and `trace --follow` ask again. */
25
+ export const PREVIEW_POLL_MS = 5_000;
26
+ export const PREVIEW_TRACE_FOLLOW_MS = 2_000;
27
+ /** `up` renews the preview's lease this often (at most a third of the lease). */
28
+ export const PREVIEW_LEASE_RENEW_MS = 60_000;
29
+ /** The lease when the platform did not say (it answers `leaseMs`). */
30
+ const PREVIEW_LEASE_FALLBACK_MS = 3 * 60_000;
31
+ class ApiFailure extends Error {
32
+ status;
33
+ code;
34
+ constructor(status, code, message) {
35
+ super(message);
36
+ this.status = status;
37
+ this.code = code;
38
+ }
39
+ }
40
+ function client(config, io) {
41
+ apiSurface(config); // no credentials at all: a usage error before anything runs
42
+ const fetchImpl = io.fetchImpl ?? fetch;
43
+ return async (method, path, body) => {
44
+ const refresh = io.freshToken && !config.apiKey && config.bearerToken;
45
+ const surface = apiSurface(refresh ? { ...config, bearerToken: await io.freshToken() } : config);
46
+ const res = await fetchImpl(surface.url(path), {
47
+ method,
48
+ headers: { ...surface.headers, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
49
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
50
+ });
51
+ const text = await res.text();
52
+ let json = {};
53
+ try {
54
+ json = text ? JSON.parse(text) : {};
55
+ }
56
+ catch {
57
+ // a non-JSON answer: its status says it
58
+ }
59
+ if (!res.ok) {
60
+ const code = typeof json.code === "string" ? json.code : undefined;
61
+ const why = typeof json.error === "string" ? json.error : `HTTP ${res.status}`;
62
+ throw new ApiFailure(res.status, code, why);
63
+ }
64
+ return json;
65
+ };
66
+ }
67
+ /** The probe's word for "the machine has no preview link" — `GATEWAY_PROBE_LINK_DOWN` (accGateway/gatewayProbe.ts),
68
+ * spelled here because the CLI package ships cli/ alone (scripts/release/cliPackageFiles.mjs); a test pins the two. */
69
+ export const PROBE_LINK_DOWN = "the machine's preview link is down";
70
+ /** The role gate's refusal of a space the caller is no member of — `SPACE_ACCESS_DENIED_CODE` (auth/roleGate.ts),
71
+ * spelled here so the CLI bundle stays free of the server's Express modules; a test pins the two together. */
72
+ export const SPACE_ACCESS_DENIED = "space_access_denied";
73
+ /** One failure as the person reads it, with what to do. */
74
+ function explain(err, config) {
75
+ if (err instanceof UsageError)
76
+ return err.message;
77
+ if (err instanceof ApiFailure) {
78
+ if (err.code === "not-a-person" || (err.status === 403 && config.apiKey)) {
79
+ return `${err.message} — previews from a terminal are yours: unset DATUM_API_KEY and run \`datum login\``;
80
+ }
81
+ if (err.code === "feature_disabled")
82
+ return "the preview gateway is off in this space (Settings → Features → ACC preview gateway)";
83
+ // Previews live in a space (#2958): without --space the CLI asks the default one, which is often not yours.
84
+ if (err.code === SPACE_ACCESS_DENIED)
85
+ return `${err.message} — name the space your previews are in: --space <slug> (or DATUM_SPACE=<slug>)`;
86
+ return err.message;
87
+ }
88
+ return err?.message ?? String(err);
89
+ }
90
+ /**
91
+ * What `up` reports of a preview (#2958): "ready" only once the app answered through the machine's link — a port
92
+ * nothing listens on is said at once, and so is a link that went and came back. `key` changes = a line is printed.
93
+ */
94
+ export function upStateOf(p) {
95
+ if (p.status !== "provisioned")
96
+ return { key: p.status, line: p.status };
97
+ // An older server sends no probe: the route stands, which was all "ready" ever said.
98
+ if (!p.app)
99
+ return { key: "ready", line: "ready" };
100
+ const { status, ms, error } = p.app;
101
+ if (status > 0 && status < 500)
102
+ return { key: "ready", line: `ready — the app answers (${status} · ${ms} ms)` };
103
+ if (status >= 500)
104
+ return { key: "app-error", line: `the app on port ${p.port} answers with an error (${status}) — visitors see it as it is` };
105
+ if (error === PROBE_LINK_DOWN) {
106
+ return { key: "link-down", line: `${p.machine.name} has no preview link to Datum right now — visitors see "Waiting for the machine…"; it comes back by itself once the satellite reconnects` };
107
+ }
108
+ return { key: "no-answer", line: `no answer from port ${p.port} on ${p.machine.name}${error ? ` (${error})` : ""} — is the app listening there (127.0.0.1 or ::1)?` };
109
+ }
110
+ const ago = (iso) => (iso ? iso.replace("T", " ").slice(0, 16) + " UTC" : "—");
111
+ function printHeaders(io, headers) {
112
+ if (!headers)
113
+ return;
114
+ io.out.write(" your own requests through the edge send:\n");
115
+ for (const [k, v] of Object.entries(headers))
116
+ io.out.write(` ${k}: ${v}\n`);
117
+ }
118
+ /** `datum preview up --port N [--vhost V] [--machine M]`. */
119
+ export async function runPreviewUp(input, config, io) {
120
+ const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
121
+ const now = io.now ?? (() => Date.now());
122
+ let call;
123
+ try {
124
+ call = client(config, io);
125
+ }
126
+ catch (err) {
127
+ io.err.write(`datum: ${explain(err, config)}\n`);
128
+ return 2;
129
+ }
130
+ let id;
131
+ let leaseMs = PREVIEW_LEASE_FALLBACK_MS;
132
+ try {
133
+ const opened = await call("POST", "/api/acc-previews?wait=0", {
134
+ port: input.port,
135
+ ...(input.vhost ? { vhost: input.vhost } : {}),
136
+ ...(input.machine ? { machine: input.machine } : {}),
137
+ // Which of your machines this terminal is: an unnamed `up` previews THIS computer only.
138
+ fromHost: input.fromHost ?? os.hostname(),
139
+ });
140
+ if (!opened.preview) {
141
+ io.err.write(`datum: the preview did not come up${opened.entry?.error ? ` — ${opened.entry.error}` : ""}\n`);
142
+ return 1;
143
+ }
144
+ if (typeof opened.leaseMs === "number" && opened.leaseMs > 0)
145
+ leaseMs = opened.leaseMs;
146
+ const p = opened.preview;
147
+ id = p.id;
148
+ io.out.write(`Previewing port ${p.port} of ${p.machine.name} through ${p.vhost} (preview ${p.id})\n`);
149
+ io.out.write(` open: ${p.link ?? "(no link yet)"}\n`);
150
+ printHeaders(io, p.testHeaders);
151
+ io.out.write(" it stands while this command runs — Ctrl+C closes it\n");
152
+ }
153
+ catch (err) {
154
+ io.err.write(`datum: ${explain(err, config)}\n`);
155
+ return 1;
156
+ }
157
+ // Foreground: renew the lease, report state changes and requests the
158
+ // machine did not answer — until Ctrl+C / SIGHUP / SIGTERM (or the end).
159
+ let stop = false;
160
+ void io.interrupted?.then(() => {
161
+ stop = true;
162
+ });
163
+ let last = "";
164
+ let renewedAt = now();
165
+ let traceCursor = "";
166
+ let warnedNoAnswer = false;
167
+ while (!stop) {
168
+ try {
169
+ // The lease (#2941 review): this command is what keeps the port on the edge.
170
+ if (now() - renewedAt >= Math.min(leaseMs / 3, PREVIEW_LEASE_RENEW_MS)) {
171
+ try {
172
+ await call("POST", `/api/acc-previews/${encodeURIComponent(id)}/lease`);
173
+ renewedAt = now();
174
+ }
175
+ catch (err) {
176
+ if (err instanceof ApiFailure && (err.status === 410 || err.status === 404)) {
177
+ io.err.write("datum: the preview's lease is over (it was closed, or this command could not renew it in time) — run datum preview up again\n");
178
+ return 1;
179
+ }
180
+ throw err;
181
+ }
182
+ }
183
+ const { previews } = await call("GET", "/api/acc-previews?wait=1");
184
+ const p = previews.find((x) => x.id === id);
185
+ if (!p) {
186
+ io.err.write("datum: the preview is gone (closed elsewhere)\n");
187
+ return 1;
188
+ }
189
+ // A change of state is a line (#2958): ready only once the app answers, a lost link and its return said as they happen.
190
+ const state = upStateOf(p);
191
+ if (state.key !== last) {
192
+ // "Back" only on evidence: a provisioned preview whose probe no longer says the link is down (#2960 review).
193
+ const back = last === "link-down" && p.status === "provisioned" && p.app !== undefined && state.key !== "link-down" ? " (the preview link is back)" : "";
194
+ io.out.write(` ${new Date(now()).toISOString().slice(11, 19)} ${state.line}${back}\n`);
195
+ last = state.key;
196
+ }
197
+ if (p.status === "expired" || p.status === "error") {
198
+ io.err.write(`datum: the preview is ${p.status} — run datum preview up again\n`);
199
+ return 1;
200
+ }
201
+ // A request the machine did not answer: nothing listens on the port, or
202
+ // it is one of the machine's own (the daemon's, a runtime's) — said once.
203
+ // A server that probes the app (#2958) has said so in the state line already.
204
+ if (!warnedNoAnswer && !p.app) {
205
+ const { entries } = await call("GET", `/api/acc-previews/${encodeURIComponent(id)}/trace${traceCursor ? `?after=${traceCursor}` : "?limit=50"}`);
206
+ if (entries.length > 0)
207
+ traceCursor = entries.at(-1).seq;
208
+ if (entries.some((e) => e.status === 0)) {
209
+ warnedNoAnswer = true;
210
+ io.err.write(`datum: requests to port ${p.port} get no answer from ${p.machine.name} — is the app listening there (127.0.0.1 or ::1)? A port the machine itself uses (the satellite's own) is refused.\n`);
211
+ }
212
+ }
213
+ }
214
+ catch (err) {
215
+ io.err.write(`datum: ${explain(err, config)} — retrying\n`);
216
+ }
217
+ await Promise.race([sleep(PREVIEW_POLL_MS), io.interrupted ?? new Promise(() => undefined)]);
218
+ }
219
+ return runPreviewDown({ id }, config, io);
220
+ }
221
+ /** `datum preview down <id>`. */
222
+ export async function runPreviewDown(input, config, io) {
223
+ try {
224
+ const { closed = [] } = await client(config, io)("DELETE", `/api/acc-previews/${encodeURIComponent(input.id)}`);
225
+ // The close answers 200 per row; a row that could not be taken down still stands.
226
+ const failed = closed.filter((c) => c.state === "failed");
227
+ if (failed.length > 0) {
228
+ for (const f of failed)
229
+ io.err.write(`datum: preview ${input.id} (${f.vhost}) could not be closed: ${f.error ?? "unknown error"} — try datum preview down ${input.id}\n`);
230
+ return 1;
231
+ }
232
+ io.out.write(`Closed preview ${input.id}\n`);
233
+ return 0;
234
+ }
235
+ catch (err) {
236
+ io.err.write(`datum: ${explain(err, config)}\n`);
237
+ return 1;
238
+ }
239
+ }
240
+ /** `datum previews [--json]` — the gateway's `cde interfaces`. */
241
+ export async function runPreviewsList(input, config, io) {
242
+ try {
243
+ const answer = await client(config, io)("GET", "/api/acc-previews");
244
+ if (input.json) {
245
+ io.out.write(`${JSON.stringify(answer, null, 2)}\n`);
246
+ return 0;
247
+ }
248
+ if (answer.previews.length === 0) {
249
+ io.out.write("No previews on your machines. Start one: datum preview up --port <port>\n");
250
+ }
251
+ else {
252
+ const rows = answer.previews.map((p) => [p.id, p.vhost, p.machine.name, String(p.port), p.status === "provisioned" ? "ready" : p.status, p.owner.mine ? "you" : p.owner.kind, ago(p.expiresAt), p.link ?? "—"]);
253
+ const head = ["ID", "VHOST", "MACHINE", "PORT", "STATE", "OWNER", "EXPIRES", "LINK"];
254
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
255
+ for (const r of [head, ...rows])
256
+ io.out.write(`${r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(widths[i]))).join(" ")}\n`);
257
+ }
258
+ const machines = answer.machines.map((m) => `${m.name} (${m.connected ? "online" : "offline"}${m.previewLink ? "" : ", no preview link — upgrade the satellite"})`);
259
+ io.out.write(`Your machines here: ${machines.join(", ") || "none — pair this computer: datum satellite up --pair"}\n`);
260
+ return 0;
261
+ }
262
+ catch (err) {
263
+ io.err.write(`datum: ${explain(err, config)}\n`);
264
+ return 1;
265
+ }
266
+ }
267
+ /** One trace line: time, method, path, status, timing, size. */
268
+ export function traceLine(e) {
269
+ const size = e.bytesOut >= 1024 * 1024 ? `${(e.bytesOut / 1024 / 1024).toFixed(1)} MB` : e.bytesOut >= 1024 ? `${(e.bytesOut / 1024).toFixed(1)} KB` : `${e.bytesOut} B`;
270
+ const status = e.status === 0 ? "no answer" : String(e.status);
271
+ return `${e.at.slice(11, 23)} ${(e.websocket ? "WS" : e.method).padEnd(6)} ${e.path} → ${status} ${e.ms} ms ${size}`;
272
+ }
273
+ /** `datum preview trace <id> [--follow] [--limit N]` — the gateway's `cde trace`. */
274
+ export async function runPreviewTrace(input, config, io) {
275
+ const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
276
+ let call;
277
+ try {
278
+ call = client(config, io);
279
+ }
280
+ catch (err) {
281
+ io.err.write(`datum: ${explain(err, config)}\n`);
282
+ return 2;
283
+ }
284
+ const base = `/api/acc-previews/${encodeURIComponent(input.id)}/trace`;
285
+ let after = "";
286
+ let stop = false;
287
+ let read = false;
288
+ void io.interrupted?.then(() => {
289
+ stop = true;
290
+ });
291
+ do {
292
+ try {
293
+ const query = after ? `?after=${encodeURIComponent(after)}` : `?limit=${input.limit ?? 50}`;
294
+ const { entries } = await call("GET", `${base}${query}`);
295
+ read = true;
296
+ for (const e of entries)
297
+ io.out.write(`${traceLine(e)}\n`);
298
+ // The cursor is the insert order, never a time: a replica writes its batch late (#2941 review).
299
+ if (entries.length > 0)
300
+ after = entries.at(-1).seq;
301
+ else if (!input.follow && !after)
302
+ io.out.write("No requests yet.\n");
303
+ }
304
+ catch (err) {
305
+ // A closed preview takes its trace with it: a follow that read it before says so (#2948).
306
+ if (read && err instanceof ApiFailure && err.status === 404) {
307
+ io.out.write(`Preview ${input.id} was closed — the trace ends.\n`);
308
+ return 0;
309
+ }
310
+ io.err.write(`datum: ${explain(err, config)}\n`);
311
+ return 1;
312
+ }
313
+ if (!input.follow)
314
+ break;
315
+ await Promise.race([sleep(PREVIEW_TRACE_FOLLOW_MS), io.interrupted ?? new Promise(() => undefined)]);
316
+ } while (!stop);
317
+ return 0;
318
+ }
319
+ /** Ctrl+C / SIGTERM as a promise (the real terminal's `interrupted`). */
320
+ export function interruptedBySignal() {
321
+ // SIGHUP too (#2941 review): a closed terminal window ends `up` cleanly — its preview closed, not left to its lease.
322
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
323
+ return new Promise((resolve) => {
324
+ const done = () => {
325
+ for (const s of signals)
326
+ process.off(s, done);
327
+ resolve();
328
+ };
329
+ for (const s of signals)
330
+ process.once(s, done);
331
+ });
332
+ }
333
+ //# sourceMappingURL=previewCommand.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `datum satellite up --pair <token>` (#2948): the launcher waits for the daemon it starts, so a token
3
+ * on either command line stays readable in `ps` for the daemon's whole life. The launcher takes it OUT
4
+ * of the arguments and hands it over in the daemon's environment (the daemon reads
5
+ * PAIR_TOKEN_ENV since 0.21.2); its own title drops it too.
6
+ */
7
+ /** The daemon's variable for the one-time pairing token (satellite/src/pair.ts PAIR_TOKEN_ENV). */
8
+ export const SATELLITE_PAIR_TOKEN_ENV = "DATUM_SATELLITE_PAIR_TOKEN";
9
+ /** The first daemon that reads it (and answers `satellite --version`). */
10
+ export const SATELLITE_PAIR_TOKEN_ENV_SINCE = "0.21.2";
11
+ /** Does the daemon that answered `satellite --version` with `output` read the token from the
12
+ * environment? An older one prints its usage (no version) — it gets the flag, as before (#2949 review). */
13
+ export function daemonReadsPairTokenEnv(output) {
14
+ const m = /^(\d+)\.(\d+)\.(\d+)\s*$/.exec(output.trim());
15
+ if (!m)
16
+ return false;
17
+ const [have, need] = [m.slice(1).map(Number), SATELLITE_PAIR_TOKEN_ENV_SINCE.split(".").map(Number)];
18
+ for (let i = 0; i < 3; i++)
19
+ if (have[i] !== need[i])
20
+ return have[i] > need[i];
21
+ return true;
22
+ }
23
+ /** The arguments without `--pair <token>` / `--pair=<token>`, and the token (null = none given). */
24
+ export function pairTokenOutOfArgs(args) {
25
+ const out = [];
26
+ let token = null;
27
+ for (let i = 0; i < args.length; i++) {
28
+ const a = args[i];
29
+ if (a === "--pair" && i + 1 < args.length) {
30
+ token = args[++i];
31
+ continue;
32
+ }
33
+ if (a.startsWith("--pair=")) {
34
+ token = a.slice("--pair=".length);
35
+ continue;
36
+ }
37
+ out.push(a);
38
+ }
39
+ return { args: out, token };
40
+ }
41
+ /** The launcher's own title with the token masked (`ps` shows the title once it is set). */
42
+ export function maskedTitle(argv, token) {
43
+ return argv
44
+ .slice(1)
45
+ .map((a) => (a === token ? "***" : a.endsWith(`=${token}`) ? `${a.slice(0, a.length - token.length)}***` : a))
46
+ .join(" ");
47
+ }
48
+ //# sourceMappingURL=satellitePairToken.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Which servers the CLI hands a credential to (#2605).
3
+ *
4
+ * A sign-in token or an API key travels in the `authorization` header of
5
+ * every call. Over plain http anybody on the path reads it, so the CLI
6
+ * sends one only to an https:// origin — or to a loopback host, the local
7
+ * dev stack and the pod-local API an agent run talks to, where the path is
8
+ * this machine.
9
+ */
10
+ /** `1` lifts the refusal for this process — a dev stack on a private network. */
11
+ export const ENV_ALLOW_INSECURE_HTTP = "DATUM_ALLOW_INSECURE_HTTP";
12
+ export function isLoopbackHost(hostname) {
13
+ return ["localhost", "127.0.0.1", "[::1]"].includes(hostname);
14
+ }
15
+ /**
16
+ * Why `baseUrl` must not receive a credential — null when it may. An
17
+ * unparsable URL is not judged here: the caller's own "not a valid URL"
18
+ * names that problem better.
19
+ */
20
+ export function insecureOriginReason(baseUrl, env = process.env) {
21
+ let url;
22
+ try {
23
+ url = new URL(baseUrl);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ if (url.protocol === "https:")
29
+ return null;
30
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname))
31
+ return null;
32
+ if (env[ENV_ALLOW_INSECURE_HTTP]?.trim() === "1")
33
+ return null;
34
+ return (`refusing to send credentials to ${url.origin} — the CLI signs in and sends tokens or API keys only over https:// ` +
35
+ `(or to a loopback http:// host). Use the server's https:// address; ${ENV_ALLOW_INSECURE_HTTP}=1 lifts this for a private dev stack.`);
36
+ }
37
+ //# sourceMappingURL=secureOrigin.js.map