@runuai/host 0.1.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
package/src/cli.ts ADDED
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `uai-host` CLI (ADR-028). Hand-rolled argv switch — no argparser dependency.
4
+ *
5
+ * run | install | uninstall | start | stop | restart
6
+ * status | logs [--follow] | pair <token> | open
7
+ *
8
+ * `run` boots the service in-process; the install/start/... family dispatches
9
+ * to the per-OS installer (scripts/install/{darwin,linux,win}.ts) by
10
+ * process.platform. `status`/`open` read the local UI's own JSON API.
11
+ */
12
+
13
+ // MUST be first: load-env populates process.env from .env.local before any
14
+ // import below pulls in lib/env, which freezes its config snapshot (uaiHome,
15
+ // dataDir, hostKeyPath, …) at module-eval. `./paths` imports lib/env, so
16
+ // without this the `run` path boots before UAI_DATA_DIR/UAI_HOME are loaded and
17
+ // resolves the data dir — and the master key that decrypts every secret store
18
+ // — from the wrong base. (main.ts loads it too, but `await import("./main")`
19
+ // runs far too late.)
20
+ import "./load-env";
21
+
22
+ import { spawn, spawnSync } from "node:child_process";
23
+ import { createHash, randomBytes } from "node:crypto";
24
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { hostname } from "node:os";
26
+
27
+ import { ulid } from "ulid";
28
+
29
+ import { envLocalPath, serviceLogPath, uaiHome, uiPortFilePath } from "./paths";
30
+ import { CloudResponse, StatusResponse, TasksResponse } from "./ui/types";
31
+ import type { InstallContext, Installer } from "../scripts/install/types";
32
+
33
+ // --- tiny ANSI (no chalk) ---------------------------------------------------
34
+
35
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
36
+ const paint =
37
+ (code: number) =>
38
+ (s: string): string =>
39
+ useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
40
+ const bold = paint(1);
41
+ const dim = paint(2);
42
+ const red = paint(31);
43
+ const green = paint(32);
44
+ const yellow = paint(33);
45
+ const cyan = paint(36);
46
+
47
+ // --- dispatch ---------------------------------------------------------------
48
+
49
+ async function main(): Promise<void> {
50
+ const [cmd, ...rest] = process.argv.slice(2);
51
+ const dryRun = rest.includes("--dry-run");
52
+ const follow = rest.includes("--follow") || rest.includes("-f");
53
+ switch (cmd) {
54
+ case "run":
55
+ return cmdRun();
56
+ case "status":
57
+ return cmdStatus();
58
+ case "open":
59
+ return cmdOpen();
60
+ case "logs":
61
+ return cmdLogs(follow);
62
+ case "setup":
63
+ return cmdSetup(rest);
64
+ case "pair":
65
+ return cmdPair(rest[0]);
66
+ case "install":
67
+ return cmdInstall(dryRun);
68
+ case "uninstall":
69
+ case "start":
70
+ case "stop":
71
+ case "restart":
72
+ return cmdInstaller(cmd, dryRun);
73
+ case undefined:
74
+ case "help":
75
+ case "-h":
76
+ case "--help":
77
+ return printHelp();
78
+ default:
79
+ console.error(red(`unknown command: ${cmd}`));
80
+ printHelp();
81
+ process.exitCode = 1;
82
+ }
83
+ }
84
+
85
+ // --- run --------------------------------------------------------------------
86
+
87
+ async function cmdRun(): Promise<void> {
88
+ // Boot the service in-process: main.ts's top-level code starts the WSS
89
+ // client + local UI server and keeps the event loop alive.
90
+ await import("./main");
91
+ }
92
+
93
+ // --- status -----------------------------------------------------------------
94
+
95
+ async function cmdStatus(): Promise<void> {
96
+ const port = readPort();
97
+ if (port == null) {
98
+ console.log(
99
+ dim("service not running (no host.port). Start it: uai-host start (or: uai-host run)"),
100
+ );
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ let status, cloud, tasks;
105
+ try {
106
+ status = StatusResponse.parse(await api(port, "/api/status"));
107
+ cloud = CloudResponse.parse(await api(port, "/api/cloud"));
108
+ tasks = TasksResponse.parse(await api(port, "/api/tasks"));
109
+ } catch (err) {
110
+ console.log(
111
+ dim(
112
+ `service unreachable on 127.0.0.1:${port} (${err instanceof Error ? err.message : String(err)})`,
113
+ ),
114
+ );
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+
119
+ const stateColor =
120
+ cloud.state === "connected"
121
+ ? green
122
+ : cloud.state === "reconnecting"
123
+ ? yellow
124
+ : red;
125
+ console.log(bold("Uai host") + dim(` — ${status.hostName} · v${status.version}`));
126
+ console.log(
127
+ ` cloud ${stateColor(cloud.state)} ${dim(status.cloudUrl)}` +
128
+ (cloud.reconnectCount ? dim(` (reconnects: ${cloud.reconnectCount})`) : ""),
129
+ );
130
+ if (cloud.lastError) console.log(` ${dim("last error: " + cloud.lastError)}`);
131
+ console.log(` service ${dim(`pid ${status.pid} · up ${fmtDuration(status.uptime * 1000)}`)}`);
132
+ console.log(` ui ${dim(`http://127.0.0.1:${port}`)}`);
133
+ console.log(` log ${dim(status.logPath)}`);
134
+ console.log(` tasks ${tasks.tasks.length === 0 ? dim("none") : tasks.tasks.length}`);
135
+ for (const t of tasks.tasks) {
136
+ const slugs = t.projectSlugs.length ? t.projectSlugs.join(",") : "scratchpad";
137
+ const age = t.ageMs != null ? fmtDuration(t.ageMs) : "—";
138
+ const mem = t.memoryBytes != null ? fmtBytes(t.memoryBytes) : "—";
139
+ console.log(
140
+ ` ${cyan((t.ownerEmail ?? "unknown").padEnd(22))} ${dim(slugs.padEnd(16))} ` +
141
+ `${(t.status ?? "—").padEnd(9)} ${dim(`up ${age}`.padEnd(11))} ${dim(mem)}`,
142
+ );
143
+ console.log(` ${dim(t.taskId)}`);
144
+ }
145
+ }
146
+
147
+ async function api(port: number, path: string): Promise<unknown> {
148
+ const res = await fetch(`http://127.0.0.1:${port}${path}`);
149
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
150
+ return (await res.json()) as unknown;
151
+ }
152
+
153
+ // --- open -------------------------------------------------------------------
154
+
155
+ async function cmdOpen(): Promise<void> {
156
+ const port = readPort();
157
+ if (port == null) {
158
+ console.error(red("no host.port — is the service running?"));
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ const url = `http://127.0.0.1:${port}`;
163
+ if (process.platform === "darwin") detach("open", [url]);
164
+ else if (process.platform === "win32") detach("cmd", ["/c", "start", "", url]);
165
+ else detach("xdg-open", [url]);
166
+ console.log(`opening ${url}`);
167
+ }
168
+
169
+ function detach(cmd: string, args: string[]): void {
170
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
171
+ child.on("error", (err) => console.error(red(`could not run ${cmd}: ${err.message}`)));
172
+ child.unref();
173
+ }
174
+
175
+ // --- logs -------------------------------------------------------------------
176
+
177
+ async function cmdLogs(follow: boolean): Promise<void> {
178
+ const log = serviceLogPath();
179
+ if (log.startsWith("journald")) {
180
+ const args = ["--user", "-u", "uai-host"];
181
+ if (follow) args.push("-f");
182
+ inherit("journalctl", args);
183
+ return;
184
+ }
185
+ if (!existsSync(log)) {
186
+ console.error(red(`log not found: ${log}`));
187
+ console.error(dim("(install + start the service first, or run in the foreground)"));
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ inherit("tail", follow ? ["-f", log] : ["-n", "200", log]);
192
+ }
193
+
194
+ function inherit(cmd: string, args: string[]): void {
195
+ const child = spawn(cmd, args, { stdio: "inherit" });
196
+ child.on("error", (err) => {
197
+ console.error(red(`could not run ${cmd}: ${err.message}`));
198
+ process.exitCode = 1;
199
+ });
200
+ child.on("exit", (code) => process.exit(code ?? 0));
201
+ }
202
+
203
+ // --- setup (enrollment, ADR-031) --------------------------------------------
204
+
205
+ /** Read a `--name value` or `--name=value` flag from argv. */
206
+ function flagValue(rest: string[], name: string): string | undefined {
207
+ const i = rest.indexOf(name);
208
+ if (i >= 0 && rest[i + 1]) return rest[i + 1];
209
+ const eq = rest.find((a) => a.startsWith(`${name}=`));
210
+ return eq ? eq.slice(name.length + 1) : undefined;
211
+ }
212
+
213
+ /**
214
+ * Claim this machine as a host: redeem a one-time enrollment token (minted in
215
+ * the web app) for a permanent bridge credential, then write the config. The
216
+ * bridge secret is generated HERE and never leaves — only its hash is sent
217
+ * (ADR-015/ADR-031).
218
+ *
219
+ * uai-host setup --cloud wss://app.runuai.com/host --enroll uaienroll_…
220
+ */
221
+ async function cmdSetup(rest: string[]): Promise<void> {
222
+ const enroll = flagValue(rest, "--enroll");
223
+ const cloud = flagValue(rest, "--cloud");
224
+ if (!enroll || !cloud) {
225
+ console.error(
226
+ red("usage: uai-host setup --cloud <wss-url> --enroll <token>"),
227
+ );
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+
232
+ let cloudUrl: URL;
233
+ try {
234
+ cloudUrl = new URL(cloud);
235
+ } catch {
236
+ console.error(red(`invalid --cloud URL: ${cloud}`));
237
+ process.exitCode = 1;
238
+ return;
239
+ }
240
+
241
+ // Don't silently re-enroll an already-claimed machine (would orphan the old
242
+ // host row + steal its slot).
243
+ const existing = existsSync(envLocalPath())
244
+ ? readFileSync(envLocalPath(), "utf8")
245
+ : "";
246
+ if (/^UAI_HOST_TOKEN=/m.test(existing) && !rest.includes("--force")) {
247
+ console.error(red("this machine is already enrolled (UAI_HOST_TOKEN set)."));
248
+ console.error(
249
+ dim("re-enrolling creates a new host + orphans the old one — pass --force to proceed."),
250
+ );
251
+ process.exitCode = 1;
252
+ return;
253
+ }
254
+
255
+ const proto =
256
+ cloudUrl.protocol === "wss:"
257
+ ? "https:"
258
+ : cloudUrl.protocol === "ws:"
259
+ ? "http:"
260
+ : cloudUrl.protocol;
261
+ const redeemUrl = `${proto}//${cloudUrl.host}/api/hosts/enroll/redeem`;
262
+
263
+ // This host's identity + bridge secret. Secret stays local; cloud gets the hash.
264
+ const hostId = ulid().toLowerCase();
265
+ const secret = randomBytes(32).toString("hex");
266
+ const tokenHash = createHash("sha256").update(secret).digest("hex");
267
+ const name = hostname();
268
+
269
+ console.log(dim(`enrolling ${name} (${hostId}) with ${cloudUrl.host}…`));
270
+ let res: Response;
271
+ try {
272
+ res = await fetch(redeemUrl, {
273
+ method: "POST",
274
+ headers: { "content-type": "application/json" },
275
+ body: JSON.stringify({ enrollToken: enroll, hostId, tokenHash, name }),
276
+ signal: AbortSignal.timeout(15_000),
277
+ });
278
+ } catch (err) {
279
+ console.error(
280
+ red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
281
+ );
282
+ process.exitCode = 1;
283
+ return;
284
+ }
285
+ if (!res.ok) {
286
+ let detail = `HTTP ${res.status}`;
287
+ try {
288
+ const j = (await res.json()) as { error?: { message?: string } };
289
+ if (j.error?.message) detail = j.error.message;
290
+ } catch {
291
+ /* keep the HTTP status */
292
+ }
293
+ console.error(red(`enrollment failed: ${detail}`));
294
+ console.error(
295
+ dim("the token may be expired or already used — mint a fresh one in the web app."),
296
+ );
297
+ process.exitCode = 1;
298
+ return;
299
+ }
300
+
301
+ upsertEnvLocal("UAI_HOST_ID", hostId);
302
+ upsertEnvLocal("UAI_HOST_TOKEN", secret);
303
+ upsertEnvLocal("UAI_CLOUD_URL", cloud);
304
+ console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
305
+ console.log("");
306
+ console.log("Start the host:");
307
+ console.log(cyan(" uai-host install && uai-host start") + dim(" (first time)"));
308
+ console.log(cyan(" uai-host restart") + dim(" (already installed)"));
309
+ }
310
+
311
+ // --- pair -------------------------------------------------------------------
312
+
313
+ async function cmdPair(token: string | undefined): Promise<void> {
314
+ if (!token) {
315
+ console.error(red("usage: uai-host pair <token>"));
316
+ process.exitCode = 1;
317
+ return;
318
+ }
319
+ // v1 "pairing" = persist the host token to .env.local (load-env.ts reads it).
320
+ // The cloud-issued-pairing-token -> host-token handshake is a future flow
321
+ // (hosted-architecture.md, "what we don't do now"); this stores what you paste.
322
+ upsertEnvLocal("UAI_HOST_TOKEN", token);
323
+ console.log(green("paired") + dim(` — wrote UAI_HOST_TOKEN to ${envLocalPath()}`));
324
+ console.log(dim("apply it: uai-host restart (or start the service)"));
325
+ }
326
+
327
+ function upsertEnvLocal(key: string, value: string): void {
328
+ const path = envLocalPath();
329
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
330
+ const kept = existing
331
+ .split("\n")
332
+ .filter((l) => !l.startsWith(`${key}=`) && l.trim() !== "");
333
+ kept.push(`${key}=${value}`);
334
+ writeFileSync(path, kept.join("\n") + "\n", { mode: 0o600 });
335
+ }
336
+
337
+ // --- install dispatch -------------------------------------------------------
338
+
339
+ async function cmdInstall(dryRun: boolean): Promise<void> {
340
+ const installer = await loadInstaller();
341
+ await installer.install(installContext(dryRun));
342
+ if (!dryRun) console.log(green("installed") + dim(" — start it: uai-host start"));
343
+ }
344
+
345
+ async function cmdInstaller(
346
+ action: "uninstall" | "start" | "stop" | "restart",
347
+ dryRun: boolean,
348
+ ): Promise<void> {
349
+ const installer = await loadInstaller();
350
+ await installer[action](dryRun);
351
+ if (!dryRun && action === "uninstall") console.log(green("uninstalled"));
352
+ }
353
+
354
+ async function loadInstaller(): Promise<Installer> {
355
+ const name =
356
+ process.platform === "darwin"
357
+ ? "darwin"
358
+ : process.platform === "win32"
359
+ ? "win"
360
+ : "linux";
361
+ try {
362
+ const mod = (await import(`../scripts/install/${name}`)) as {
363
+ installer: Installer;
364
+ };
365
+ return mod.installer;
366
+ } catch (err) {
367
+ throw new Error(
368
+ `no installer available for ${process.platform}: ${err instanceof Error ? err.message : String(err)}`,
369
+ );
370
+ }
371
+ }
372
+
373
+ function installContext(dryRun: boolean): InstallContext {
374
+ const run = resolveRunCommand();
375
+ return { execPath: run.execPath, args: run.args, cwd: uaiHome(), dryRun };
376
+ }
377
+
378
+ /** The command that runs `uai-host run`: the installed binary if on PATH,
379
+ * else `pnpm host-agent` from the repo (dev). */
380
+ function resolveRunCommand(): { execPath: string; args: string[] } {
381
+ const uaiHost = which("uai-host");
382
+ if (uaiHost) return { execPath: uaiHost, args: ["run"] };
383
+ return { execPath: which("pnpm") ?? "pnpm", args: ["host-agent"] };
384
+ }
385
+
386
+ function which(cmd: string): string | null {
387
+ const finder = process.platform === "win32" ? "where" : "which";
388
+ const res = spawnSync(finder, [cmd], { encoding: "utf8" });
389
+ if (res.status !== 0 || !res.stdout) return null;
390
+ return res.stdout.split("\n")[0]?.trim() || null;
391
+ }
392
+
393
+ // --- helpers ----------------------------------------------------------------
394
+
395
+ function readPort(): number | null {
396
+ const p = uiPortFilePath();
397
+ if (!existsSync(p)) return null;
398
+ const n = Number(readFileSync(p, "utf8").trim());
399
+ return Number.isInteger(n) && n > 0 ? n : null;
400
+ }
401
+
402
+ function fmtDuration(ms: number): string {
403
+ const s = Math.floor(ms / 1000);
404
+ if (s < 60) return `${s}s`;
405
+ const m = Math.floor(s / 60);
406
+ if (m < 60) return `${m}m`;
407
+ const h = Math.floor(m / 60);
408
+ if (h < 24) return `${h}h ${m % 60}m`;
409
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
410
+ }
411
+
412
+ function fmtBytes(n: number): string {
413
+ if (n < 1024) return `${n} B`;
414
+ const units = ["KB", "MB", "GB", "TB"];
415
+ let v = n / 1024;
416
+ let i = 0;
417
+ while (v >= 1024 && i < units.length - 1) {
418
+ v /= 1024;
419
+ i += 1;
420
+ }
421
+ return `${v.toFixed(1)} ${units[i]}`;
422
+ }
423
+
424
+ function printHelp(): void {
425
+ console.log(`${bold("uai-host")} — local host service (ADR-028)
426
+
427
+ run run the service in the foreground (debug)
428
+ install [--dry-run] install as a per-user service for this OS
429
+ uninstall remove the service
430
+ start | stop control the installed service
431
+ restart stop + start
432
+ status connection, service info, active tasks (same as the UI)
433
+ logs [--follow] tail the service log
434
+ setup --cloud <wss-url> --enroll <token>
435
+ claim this machine via an enrollment token from the web
436
+ app (mints + writes the host credential)
437
+ pair <token> store a host token directly (manual fallback)
438
+ open open the local UI in your browser
439
+ `);
440
+ }
441
+
442
+ main().catch((err) => {
443
+ console.error(red(err instanceof Error ? err.message : String(err)));
444
+ process.exitCode = 1;
445
+ });