@runuai/host 0.8.13 → 0.8.17
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/README.md +23 -0
- package/lib/codex-auth.ts +112 -28
- package/lib/engines.ts +307 -10
- package/lib/env.ts +29 -10
- package/lib/obs.ts +514 -0
- package/lib/orchestrator.ts +25 -2
- package/package.json +2 -1
- package/scripts/agent/task-up.sh +19 -11
- package/src/cli.ts +33 -1
- package/src/load-env.ts +54 -11
- package/src/main.ts +21 -2
- package/src/ui/server.ts +46 -3
- package/src/ui/types.ts +3 -0
- package/ui/app.js +62 -9
package/src/cli.ts
CHANGED
|
@@ -27,6 +27,10 @@ import { dirname } from "node:path";
|
|
|
27
27
|
|
|
28
28
|
import { ulid } from "ulid";
|
|
29
29
|
|
|
30
|
+
import {
|
|
31
|
+
persistTelemetryDisclosure,
|
|
32
|
+
telemetryNoticeIfActive,
|
|
33
|
+
} from "../lib/obs";
|
|
30
34
|
import { envLocalPath, serviceLogPath, uaiHome, uiPortFilePath } from "./paths";
|
|
31
35
|
import { CloudResponse, StatusResponse, TasksResponse } from "./ui/types";
|
|
32
36
|
import type { InstallContext, Installer } from "../scripts/install/types";
|
|
@@ -322,6 +326,7 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
322
326
|
return;
|
|
323
327
|
}
|
|
324
328
|
console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
|
|
329
|
+
printTelemetryNotice();
|
|
325
330
|
console.log("");
|
|
326
331
|
console.log("Start the host:");
|
|
327
332
|
console.log(cyan(" uai-host install && uai-host start") + dim(" (first time)"));
|
|
@@ -342,6 +347,7 @@ async function cmdPair(token: string | undefined): Promise<void> {
|
|
|
342
347
|
upsertEnvLocal("UAI_HOST_TOKEN", token);
|
|
343
348
|
console.log(green("paired") + dim(` — wrote UAI_HOST_TOKEN to ${envLocalPath()}`));
|
|
344
349
|
console.log(dim("apply it: uai-host restart (or start the service)"));
|
|
350
|
+
printTelemetryNotice();
|
|
345
351
|
}
|
|
346
352
|
|
|
347
353
|
function upsertEnvLocal(key: string, value: string): void {
|
|
@@ -361,15 +367,41 @@ function upsertEnvLocal(key: string, value: string): void {
|
|
|
361
367
|
// --- install dispatch -------------------------------------------------------
|
|
362
368
|
|
|
363
369
|
async function cmdInstall(dryRun: boolean): Promise<void> {
|
|
370
|
+
// Disclosure BEFORE activation (review): install starts the service, and
|
|
371
|
+
// the notice must precede the first telemetry-armed run.
|
|
372
|
+
if (!dryRun) printTelemetryNotice();
|
|
364
373
|
const installer = await loadInstaller();
|
|
365
374
|
await installer.install(installContext(dryRun));
|
|
366
|
-
if (!dryRun)
|
|
375
|
+
if (!dryRun) {
|
|
376
|
+
console.log(green("installed") + dim(" — start it: uai-host start"));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* ADR-071 default-on disclosure at the surfaces an operator actually SEES:
|
|
382
|
+
* the service's own start line only reaches host.out.log/journald, so the
|
|
383
|
+
* interactive commands (install/pair/setup/start/restart) print it — and
|
|
384
|
+
* PERSIST the shown-once marker that gates the baked default (a silently
|
|
385
|
+
* upgraded service stays off until one of these runs).
|
|
386
|
+
*/
|
|
387
|
+
function printTelemetryNotice(): void {
|
|
388
|
+
const notice = telemetryNoticeIfActive();
|
|
389
|
+
if (notice) {
|
|
390
|
+
console.log(yellow("telemetry: ") + notice);
|
|
391
|
+
persistTelemetryDisclosure();
|
|
392
|
+
}
|
|
367
393
|
}
|
|
368
394
|
|
|
369
395
|
async function cmdInstaller(
|
|
370
396
|
action: "uninstall" | "start" | "stop" | "restart",
|
|
371
397
|
dryRun: boolean,
|
|
372
398
|
): Promise<void> {
|
|
399
|
+
// Upgrade path (review finding): operators of EXISTING installs meet
|
|
400
|
+
// default-on telemetry through start/restart — disclose BEFORE the
|
|
401
|
+
// service action activates anything, and persist the gate marker.
|
|
402
|
+
if (!dryRun && (action === "start" || action === "restart")) {
|
|
403
|
+
printTelemetryNotice();
|
|
404
|
+
}
|
|
373
405
|
const installer = await loadInstaller();
|
|
374
406
|
await installer[action](dryRun);
|
|
375
407
|
if (!dryRun && action === "uninstall") console.log(green("uninstalled"));
|
package/src/load-env.ts
CHANGED
|
@@ -3,16 +3,37 @@
|
|
|
3
3
|
* read environment variables.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
6
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
11
|
+
import { config as dotenvConfig } from "dotenv";
|
|
12
|
+
|
|
11
13
|
const proc = process as unknown as {
|
|
12
14
|
loadEnvFile?: (path: string) => void;
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
// Real path, not the import path: pnpm workspace links reach this file via
|
|
18
|
+
// symlinks — a repo checkout must resolve to the checkout, and an installed
|
|
19
|
+
// tarball to its true node_modules location.
|
|
20
|
+
const here = (() => {
|
|
21
|
+
const raw = dirname(fileURLToPath(import.meta.url)); // <pkg>/src
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
return raw;
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
// An INSTALLED copy always lives under a node_modules directory. Without
|
|
30
|
+
// this guard, a tarball installed beneath some user's pnpm workspace that
|
|
31
|
+
// happens to contain a sibling host-agent/ dir was mistaken for this source
|
|
32
|
+
// checkout — suppressing telemetry AND selecting the wrong UAI_HOME
|
|
33
|
+
// (review finding). Packaged desktop resources contain no node_modules
|
|
34
|
+
// segment and no workspace above them; the repo checkout contains neither
|
|
35
|
+
// problem.
|
|
36
|
+
const installedCopy = here.split(sep).includes("node_modules");
|
|
16
37
|
|
|
17
38
|
// Resolve the same UAI_HOME as lib/env.ts, but independently: this module runs
|
|
18
39
|
// FIRST (before lib/env is imported) so it can't depend on it. .env.local is
|
|
@@ -28,22 +49,44 @@ function findUp(start: string, pred: (dir: string) => boolean): string | null {
|
|
|
28
49
|
}
|
|
29
50
|
}
|
|
30
51
|
|
|
31
|
-
const repoRoot =
|
|
32
|
-
|
|
33
|
-
(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
52
|
+
const repoRoot = installedCopy
|
|
53
|
+
? null
|
|
54
|
+
: findUp(
|
|
55
|
+
here,
|
|
56
|
+
(d) =>
|
|
57
|
+
existsSync(resolve(d, "pnpm-workspace.yaml")) &&
|
|
58
|
+
existsSync(resolve(d, "host-agent")),
|
|
59
|
+
);
|
|
37
60
|
|
|
38
61
|
const uaiHome = process.env.UAI_HOME
|
|
39
62
|
? resolve(process.env.UAI_HOME.replace(/^~(?=\/|$)/, homedir()))
|
|
40
63
|
: (repoRoot ?? resolve(homedir(), ".uai"));
|
|
41
64
|
|
|
65
|
+
// ADR-071: expose the repo-checkout detection computed above to the
|
|
66
|
+
// telemetry gate (lib/obs resolveHostDsn). `pnpm host-agent` from a fresh
|
|
67
|
+
// checkout and host-desktop's non-packaged dev launch set neither NODE_ENV
|
|
68
|
+
// nor UAI_AGENT_FROM_REPO — this marker is the signal tied to the REAL
|
|
69
|
+
// launch path, so dev runs never default-activate crash reporting. Packaged
|
|
70
|
+
// installs (npm tarball, desktop resources) have no workspace file above
|
|
71
|
+
// them and stay unmarked.
|
|
72
|
+
if (repoRoot && !process.env.UAI_LAUNCHED_FROM_REPO) {
|
|
73
|
+
process.env.UAI_LAUNCHED_FROM_REPO = "1";
|
|
74
|
+
}
|
|
75
|
+
|
|
42
76
|
for (const file of [".env.local", ".env"]) {
|
|
43
77
|
const path = join(uaiHome, file);
|
|
44
|
-
if (!
|
|
78
|
+
if (!existsSync(path)) continue;
|
|
45
79
|
try {
|
|
46
|
-
proc.loadEnvFile
|
|
80
|
+
if (proc.loadEnvFile) {
|
|
81
|
+
proc.loadEnvFile(path);
|
|
82
|
+
} else {
|
|
83
|
+
// process.loadEnvFile landed in Node 20.12; the package supports
|
|
84
|
+
// >=20. Silently skipping .env.local on 20.0–20.11 would ignore
|
|
85
|
+
// UAI_TELEMETRY_DISABLED — the telemetry kill switch MUST load on
|
|
86
|
+
// every supported Node, so fall back to dotenv (already a dep).
|
|
87
|
+
// Neither loader overrides pre-set process.env — same semantics.
|
|
88
|
+
dotenvConfig({ path });
|
|
89
|
+
}
|
|
47
90
|
} catch (err) {
|
|
48
91
|
console.warn(
|
|
49
92
|
`[host-agent] could not load ${file}: ${(err as Error).message}`,
|
package/src/main.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
markReconnecting,
|
|
24
24
|
} from "../lib/cloud-state";
|
|
25
25
|
import { getHostTask } from "../lib/runtime-state";
|
|
26
|
-
import { getOrchestrator } from "../lib/orchestrator";
|
|
26
|
+
import { getOrchestrator, recoveryComplete } from "../lib/orchestrator";
|
|
27
27
|
import {
|
|
28
28
|
connectedUserIds,
|
|
29
29
|
isTransientGithubError,
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "../lib/host-env";
|
|
46
46
|
import { handleMcpOp } from "../lib/mcp-connections";
|
|
47
47
|
import { startMcpGateway } from "../lib/mcp-gateway";
|
|
48
|
+
import { addHostBreadcrumb, initHostObs, setHostObsTag } from "../lib/obs";
|
|
48
49
|
import {
|
|
49
50
|
packageVersion,
|
|
50
51
|
serviceLogPath,
|
|
@@ -89,6 +90,11 @@ import {
|
|
|
89
90
|
FilesOpInput,
|
|
90
91
|
} from "./protocol";
|
|
91
92
|
|
|
93
|
+
// Crash reporting (ADR-071) — default-ON for installed hosts via the baked
|
|
94
|
+
// DSN (lib/obs.ts); repo checkouts and dev/test never default-activate.
|
|
95
|
+
// UAI_TELEMETRY_DISABLED=1 turns it off; UAI_SENTRY_DSN overrides.
|
|
96
|
+
initHostObs({ version: packageVersion() });
|
|
97
|
+
|
|
92
98
|
const PING_INTERVAL_MS = 15_000;
|
|
93
99
|
const DEAD_AFTER_MS = 45_000;
|
|
94
100
|
// Reconnect backoff, capped low (~15s) on purpose: cloud deploys drop the
|
|
@@ -159,7 +165,13 @@ void ensureStandardImage();
|
|
|
159
165
|
// token → `codex login`) otherwise reaches only NEW tasks — re-copy into
|
|
160
166
|
// running tasks on start (covers the desktop "Connect Codex", which restarts
|
|
161
167
|
// the host) and watch ~/.codex for future logins (a terminal `codex login`).
|
|
162
|
-
|
|
168
|
+
// Sequenced AFTER boot recovery: reinjecting while recovery was still
|
|
169
|
+
// `docker start`ing exited containers raced — `docker cp` into a stopped
|
|
170
|
+
// container succeeds but the ownership-fix exec can't run, stranding 0600
|
|
171
|
+
// files owned by the host uid (macOS 501) that the container's node (1000)
|
|
172
|
+
// can't read, and Codex died at startup. The post-recovery sweep also
|
|
173
|
+
// self-heals such containers: it re-copies and chowns every running task.
|
|
174
|
+
void recoveryComplete().then(() => reinjectCodexRunningTasks());
|
|
163
175
|
watchCodexAuth();
|
|
164
176
|
connect();
|
|
165
177
|
// Local browser UI (ADR-028) — same single process, alongside the WSS client.
|
|
@@ -262,6 +274,8 @@ function connect(): void {
|
|
|
262
274
|
reconnectAttempt = 0;
|
|
263
275
|
markConnected();
|
|
264
276
|
console.log(`[host-agent] connected as ${hostId}`);
|
|
277
|
+
setHostObsTag(hostId);
|
|
278
|
+
addHostBreadcrumb("bridge", "connected");
|
|
265
279
|
|
|
266
280
|
unsubscribe = hostEvents.subscribe((event) => {
|
|
267
281
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
@@ -440,6 +454,11 @@ function connect(): void {
|
|
|
440
454
|
console.warn(
|
|
441
455
|
`[host-agent] disconnected${ready ? "" : " before auth"}; reconnecting in ${Math.round(delay)}ms`,
|
|
442
456
|
);
|
|
457
|
+
addHostBreadcrumb("bridge", "disconnected", {
|
|
458
|
+
code,
|
|
459
|
+
authed: ready,
|
|
460
|
+
retryInMs: Math.round(delay),
|
|
461
|
+
});
|
|
443
462
|
setTimeout(connect, delay);
|
|
444
463
|
});
|
|
445
464
|
}
|
package/src/ui/server.ts
CHANGED
|
@@ -30,6 +30,8 @@ import { dockerCli } from "../../lib/docker-exec";
|
|
|
30
30
|
import {
|
|
31
31
|
connectEngine,
|
|
32
32
|
disconnectEngine,
|
|
33
|
+
engineCliStatuses,
|
|
34
|
+
installEngineCli,
|
|
33
35
|
engineCatalog,
|
|
34
36
|
engineStatuses,
|
|
35
37
|
isEngineKind,
|
|
@@ -151,6 +153,8 @@ async function handle(
|
|
|
151
153
|
switch (path) {
|
|
152
154
|
case "/api/engines/connect":
|
|
153
155
|
return await handleEngineConnect(req, res, opts);
|
|
156
|
+
case "/api/engines/install":
|
|
157
|
+
return await handleEngineInstall(req, res);
|
|
154
158
|
case "/api/engines/disconnect":
|
|
155
159
|
return await handleEngineDisconnect(req, res, opts);
|
|
156
160
|
case "/api/tasks/stop":
|
|
@@ -181,7 +185,7 @@ async function handle(
|
|
|
181
185
|
case "/api/users":
|
|
182
186
|
return sendJson(res, UsersResponse, usersBody(opts));
|
|
183
187
|
case "/api/engines":
|
|
184
|
-
return sendJson(res, EnginesResponse, enginesBody());
|
|
188
|
+
return sendJson(res, EnginesResponse, await enginesBody());
|
|
185
189
|
}
|
|
186
190
|
if (path.startsWith("/api/")) {
|
|
187
191
|
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
@@ -194,8 +198,47 @@ async function handle(
|
|
|
194
198
|
|
|
195
199
|
// --- engines ----------------------------------------------------------------
|
|
196
200
|
|
|
197
|
-
function enginesBody(): EnginesResponse {
|
|
198
|
-
return {
|
|
201
|
+
async function enginesBody(): Promise<EnginesResponse> {
|
|
202
|
+
return {
|
|
203
|
+
catalog: engineCatalog(),
|
|
204
|
+
statuses: engineStatuses(),
|
|
205
|
+
cli: await engineCliStatuses(),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* POST /api/engines/install `{kind}` — run the engine's pinned installer,
|
|
211
|
+
* streaming NDJSON like connect: `{"line":…}` frames, then `{done,ok,message}`.
|
|
212
|
+
* No re-advertise or image rebuild here: installing a CLI connects nothing;
|
|
213
|
+
* that happens on the connect that follows.
|
|
214
|
+
*/
|
|
215
|
+
async function handleEngineInstall(
|
|
216
|
+
req: IncomingMessage,
|
|
217
|
+
res: ServerResponse,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const body = await readJsonBody(req);
|
|
220
|
+
const kind = body?.kind;
|
|
221
|
+
if (!isEngineKind(kind)) {
|
|
222
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
223
|
+
}
|
|
224
|
+
res.writeHead(200, {
|
|
225
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
226
|
+
"cache-control": "no-store",
|
|
227
|
+
});
|
|
228
|
+
const emit = (obj: unknown): void => {
|
|
229
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
230
|
+
};
|
|
231
|
+
let result: { ok: boolean; message: string };
|
|
232
|
+
try {
|
|
233
|
+
result = await installEngineCli(kind, (line) => emit({ line }));
|
|
234
|
+
} catch (err) {
|
|
235
|
+
result = {
|
|
236
|
+
ok: false,
|
|
237
|
+
message: err instanceof Error ? err.message : "install failed",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
241
|
+
res.end();
|
|
199
242
|
}
|
|
200
243
|
|
|
201
244
|
/**
|
package/src/ui/types.ts
CHANGED
|
@@ -84,12 +84,15 @@ export const EngineCatalogEntry = z.object({
|
|
|
84
84
|
// Pasted-API-key alternative to the login/token flow (null = not offered).
|
|
85
85
|
apiKeyHint: z.string().nullable(),
|
|
86
86
|
apiKeyUrl: z.string().nullable(),
|
|
87
|
+
// Install command shown when the CLI is missing (null = nothing to install).
|
|
88
|
+
installHint: z.string().nullable(),
|
|
87
89
|
});
|
|
88
90
|
export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
|
|
89
91
|
|
|
90
92
|
export const EnginesResponse = z.object({
|
|
91
93
|
catalog: z.array(EngineCatalogEntry),
|
|
92
94
|
statuses: z.record(z.boolean()), // kind → connected
|
|
95
|
+
cli: z.record(z.boolean()), // kind → CLI resolvable on this host
|
|
93
96
|
});
|
|
94
97
|
export type EnginesResponse = z.infer<typeof EnginesResponse>;
|
|
95
98
|
|
package/ui/app.js
CHANGED
|
@@ -483,6 +483,11 @@ function commandForm(e) {
|
|
|
483
483
|
log.className = "log";
|
|
484
484
|
log.hidden = true;
|
|
485
485
|
|
|
486
|
+
const pushLine = (line) => {
|
|
487
|
+
log.textContent += (log.textContent ? "\n" : "") + line;
|
|
488
|
+
log.scrollTop = log.scrollHeight;
|
|
489
|
+
};
|
|
490
|
+
|
|
486
491
|
const actions = document.createElement("div");
|
|
487
492
|
actions.className = "setup-actions";
|
|
488
493
|
const connect = document.createElement("button");
|
|
@@ -499,10 +504,7 @@ function commandForm(e) {
|
|
|
499
504
|
: "Your browser will open to sign in…";
|
|
500
505
|
log.hidden = false;
|
|
501
506
|
log.textContent = "";
|
|
502
|
-
const result = await runConnect({ kind: e.kind },
|
|
503
|
-
log.textContent += (log.textContent ? "\n" : "") + line;
|
|
504
|
-
log.scrollTop = log.scrollHeight;
|
|
505
|
-
});
|
|
507
|
+
const result = await runConnect({ kind: e.kind }, pushLine);
|
|
506
508
|
if (result.ok) {
|
|
507
509
|
await poll();
|
|
508
510
|
closeModal();
|
|
@@ -514,6 +516,47 @@ function commandForm(e) {
|
|
|
514
516
|
}
|
|
515
517
|
});
|
|
516
518
|
actions.append(connect);
|
|
519
|
+
|
|
520
|
+
// CLI missing → offer the pinned installer first, chaining into connect on
|
|
521
|
+
// success. The command is shown verbatim; its output streams into the log.
|
|
522
|
+
const cliMissing =
|
|
523
|
+
!!latestEngines?.cli && latestEngines.cli[e.kind] === false && !!e.installHint;
|
|
524
|
+
if (cliMissing) {
|
|
525
|
+
connect.hidden = true;
|
|
526
|
+
const installNote = document.createElement("p");
|
|
527
|
+
installNote.className = "setup-note";
|
|
528
|
+
installNote.textContent = `The ${e.label} CLI isn't installed on this Mac. Install it with:`;
|
|
529
|
+
const installCmd = document.createElement("pre");
|
|
530
|
+
installCmd.className = "log";
|
|
531
|
+
installCmd.textContent = e.installHint;
|
|
532
|
+
const install = document.createElement("button");
|
|
533
|
+
install.className = "btn";
|
|
534
|
+
install.type = "button";
|
|
535
|
+
install.textContent = "Install & connect";
|
|
536
|
+
install.addEventListener("click", async () => {
|
|
537
|
+
install.disabled = true;
|
|
538
|
+
install.textContent = "Installing…";
|
|
539
|
+
status.className = "setup-status";
|
|
540
|
+
status.textContent = "Running the installer…";
|
|
541
|
+
log.hidden = false;
|
|
542
|
+
log.textContent = "";
|
|
543
|
+
const result = await runInstall({ kind: e.kind }, pushLine);
|
|
544
|
+
if (result.ok) {
|
|
545
|
+
await poll();
|
|
546
|
+
install.hidden = true;
|
|
547
|
+
connect.hidden = false;
|
|
548
|
+
connect.click();
|
|
549
|
+
} else {
|
|
550
|
+
install.disabled = false;
|
|
551
|
+
install.textContent = "Try again";
|
|
552
|
+
status.className = "setup-status err";
|
|
553
|
+
status.textContent = result.message || "Install failed.";
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
actions.prepend(install);
|
|
557
|
+
form.append(installNote, installCmd);
|
|
558
|
+
}
|
|
559
|
+
|
|
517
560
|
form.append(actions, status, log);
|
|
518
561
|
|
|
519
562
|
// Manual paste fallback for when the browser flow isn't possible: Claude
|
|
@@ -590,14 +633,24 @@ function commandForm(e) {
|
|
|
590
633
|
return form;
|
|
591
634
|
}
|
|
592
635
|
|
|
636
|
+
/** POST /api/engines/connect and consume the NDJSON stream. */
|
|
637
|
+
function runConnect(body, onLine) {
|
|
638
|
+
return runStream("/api/engines/connect", body, onLine);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** POST /api/engines/install and consume the NDJSON stream. */
|
|
642
|
+
function runInstall(body, onLine) {
|
|
643
|
+
return runStream("/api/engines/install", body, onLine);
|
|
644
|
+
}
|
|
645
|
+
|
|
593
646
|
/**
|
|
594
|
-
* POST
|
|
595
|
-
*
|
|
647
|
+
* POST an NDJSON-streaming endpoint: `{line}` frames feed onLine; the final
|
|
648
|
+
* `{done,ok,message}` frame is the result.
|
|
596
649
|
*/
|
|
597
|
-
async function
|
|
650
|
+
async function runStream(path, body, onLine) {
|
|
598
651
|
let res;
|
|
599
652
|
try {
|
|
600
|
-
res = await fetch(
|
|
653
|
+
res = await fetch(path, {
|
|
601
654
|
method: "POST",
|
|
602
655
|
headers: { "content-type": "application/json" },
|
|
603
656
|
body: JSON.stringify(body),
|
|
@@ -606,7 +659,7 @@ async function runConnect(body, onLine) {
|
|
|
606
659
|
return { ok: false, message: String(err) };
|
|
607
660
|
}
|
|
608
661
|
if (!res.ok || !res.body) {
|
|
609
|
-
return { ok: false, message: `
|
|
662
|
+
return { ok: false, message: `request failed (${res.status})` };
|
|
610
663
|
}
|
|
611
664
|
const reader = res.body.getReader();
|
|
612
665
|
const dec = new TextDecoder();
|