@amd-gaia/agent-ui 0.17.1 → 0.17.3

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,1082 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * backend-installer.cjs — Shared GAIA Python backend bootstrap logic.
6
+ *
7
+ * Single source of truth for installing / upgrading the GAIA Python backend
8
+ * (`~/.gaia/venv` with `amd-gaia[ui]==<pinned-version>`). Called from both:
9
+ *
10
+ * - `bin/gaia-ui.cjs` (the npm CLI entry point)
11
+ * - `main.cjs` (the Electron app, on first-run bootstrap)
12
+ *
13
+ * Pure CommonJS with no Electron imports so it can run in both contexts.
14
+ *
15
+ * Exports:
16
+ * - ensureUv() → Promise<void>
17
+ * - installBackend(opts) → Promise<void>
18
+ * - ensureBackend(opts) → Promise<string> (returns gaia bin path)
19
+ * - getInstalledVersion(gaiaBin) → string | null
20
+ * - findGaiaBin() → string | null
21
+ * - getState() / setState() → state machine helpers
22
+ * - getLogPath() / getStatePath() → path helpers
23
+ * - runPreChecks(opts) → Promise<PreCheckResult>
24
+ * - STATES → state name constants
25
+ *
26
+ * Progress callbacks are invoked as `onProgress(stage, percent, message)` —
27
+ * the module never touches Electron APIs, so the caller (main.cjs) is
28
+ * responsible for rendering the progress UI.
29
+ */
30
+
31
+ "use strict";
32
+
33
+ const { spawn, spawnSync, execSync } = require("child_process");
34
+ const fs = require("fs");
35
+ const path = require("path");
36
+ const os = require("os");
37
+ const https = require("https");
38
+
39
+ // ── Constants ────────────────────────────────────────────────────────────────
40
+
41
+ const IS_WINDOWS = process.platform === "win32";
42
+ const GAIA_HOME = path.join(os.homedir(), ".gaia");
43
+ const GAIA_VENV = path.join(GAIA_HOME, "venv");
44
+ const GAIA_VENV_DISPLAY = "~/.gaia/venv";
45
+ const GAIA_BIN = IS_WINDOWS
46
+ ? path.join(GAIA_VENV, "Scripts", "gaia.exe")
47
+ : path.join(GAIA_VENV, "bin", "gaia");
48
+ const GAIA_PYTHON_BIN = IS_WINDOWS
49
+ ? path.join(GAIA_VENV, "Scripts", "python.exe")
50
+ : path.join(GAIA_VENV, "bin", "python");
51
+
52
+ const STATE_FILE = path.join(GAIA_HOME, "electron-install-state.json");
53
+ const LOG_FILE = path.join(GAIA_HOME, "electron-install.log");
54
+
55
+ // 5 GB — PyTorch wheels have grown significantly and `gaia init` downloads
56
+ // additional model data on first run; 3 GB is no longer enough headroom.
57
+ const MIN_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
58
+ const NETWORK_CHECK_HOSTS = Object.freeze([
59
+ "https://pypi.org/simple/",
60
+ "https://astral.sh",
61
+ ]);
62
+ const NETWORK_CHECK_TIMEOUT_MS = 5000;
63
+
64
+ // TODO: Pin uv to a specific version and verify SHA256 of the downloaded
65
+ // binary. Currently ensureUv() uses the unversioned astral.sh install
66
+ // script which always fetches the latest release. A follow-up should
67
+ // download a specific release tarball from GitHub and verify its SHA256
68
+ // against known-good hashes. See:
69
+ // https://github.com/astral-sh/uv/releases
70
+
71
+ const STATES = Object.freeze({
72
+ IDLE: "idle",
73
+ INSTALLING: "installing",
74
+ FAILED: "failed",
75
+ PARTIAL: "partial",
76
+ READY: "ready",
77
+ });
78
+
79
+ const STAGES = Object.freeze({
80
+ PRE_CHECKS: "pre-checks",
81
+ ENSURE_UV: "ensure-uv",
82
+ CREATE_VENV: "create-venv",
83
+ INSTALL_PACKAGE: "install-package",
84
+ GAIA_INIT: "gaia-init",
85
+ VERIFY: "verify",
86
+ });
87
+
88
+ // Weight each stage contributes to the overall 0-100 progress.
89
+ // Sum must equal 100.
90
+ const STAGE_WEIGHTS = {
91
+ [STAGES.PRE_CHECKS]: 2,
92
+ [STAGES.ENSURE_UV]: 8,
93
+ [STAGES.CREATE_VENV]: 10,
94
+ [STAGES.INSTALL_PACKAGE]: 50,
95
+ [STAGES.GAIA_INIT]: 28,
96
+ [STAGES.VERIFY]: 2,
97
+ };
98
+
99
+ const STAGE_ORDER = [
100
+ STAGES.PRE_CHECKS,
101
+ STAGES.ENSURE_UV,
102
+ STAGES.CREATE_VENV,
103
+ STAGES.INSTALL_PACKAGE,
104
+ STAGES.GAIA_INIT,
105
+ STAGES.VERIFY,
106
+ ];
107
+
108
+ // ── Logging ──────────────────────────────────────────────────────────────────
109
+
110
+ let logStream = null;
111
+
112
+ /**
113
+ * Log rotation is a session-level concern: we want a fresh log on the first
114
+ * `ensureBackend` call of a given process, but NOT on subsequent retries
115
+ * within the same session, because the original failure log is what the
116
+ * user needs to attach to a bug report after clicking Retry. Flipping this
117
+ * to `true` is a one-way operation; subsequent `openLog({ truncate: true })`
118
+ * calls turn into plain appends.
119
+ */
120
+ let logRotatedThisSession = false;
121
+
122
+ function ensureGaiaHome() {
123
+ try {
124
+ if (!fs.existsSync(GAIA_HOME)) {
125
+ fs.mkdirSync(GAIA_HOME, { recursive: true });
126
+ }
127
+ } catch (err) {
128
+ // Non-fatal; log to console only.
129
+ // We will still try to proceed — callers can fail more loudly.
130
+ // eslint-disable-next-line no-console
131
+ console.error(`[backend-installer] Could not create ${GAIA_HOME}:`, err.message);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Open the log file for append. When `truncate` is true (i.e. on a fresh
137
+ * install attempt), the existing log is rotated to `${LOG_FILE}.prev` rather
138
+ * than deleted, so the user can still attach the previous attempt to a bug
139
+ * report after clicking Retry. Only the most recent prior attempt is kept.
140
+ */
141
+ function openLog({ truncate = false } = {}) {
142
+ ensureGaiaHome();
143
+ try {
144
+ if (logStream) {
145
+ try {
146
+ logStream.end();
147
+ } catch {
148
+ // ignore
149
+ }
150
+ logStream = null;
151
+ }
152
+ // Honor `truncate` only once per process. Multiple retries within the
153
+ // same session (user clicks "Retry" twice) must NOT destroy the
154
+ // original failure log — that's the log the user needs to share.
155
+ const shouldRotate = truncate && !logRotatedThisSession;
156
+ if (truncate && logRotatedThisSession) {
157
+ // no-op, but make it visible in the new log that we intentionally
158
+ // kept the previous attempt's data.
159
+ // eslint-disable-next-line no-console
160
+ console.log(
161
+ "[backend-installer] openLog: retry within same session — appending (no rotation)"
162
+ );
163
+ }
164
+ if (shouldRotate) {
165
+ // Rotate: move the existing log aside (overwriting any older .prev)
166
+ // before opening the new log. This preserves the previous attempt
167
+ // for bug reports while keeping disk usage bounded to two log files.
168
+ try {
169
+ if (fs.existsSync(LOG_FILE)) {
170
+ const prevLog = `${LOG_FILE}.prev`;
171
+ // Use renameSync (atomic on POSIX, near-atomic on Windows)
172
+ try {
173
+ if (fs.existsSync(prevLog)) {
174
+ fs.unlinkSync(prevLog);
175
+ }
176
+ fs.renameSync(LOG_FILE, prevLog);
177
+ } catch (rotateErr) {
178
+ // If rotation fails (e.g. permissions), fall back to truncation
179
+ // so we don't block the install on log housekeeping.
180
+ // eslint-disable-next-line no-console
181
+ console.warn(
182
+ `[backend-installer] Could not rotate log to .prev:`,
183
+ rotateErr.message
184
+ );
185
+ }
186
+ }
187
+ } catch {
188
+ // ignore — rotation is best-effort
189
+ }
190
+ // Mark the session as rotated so future retries append instead of
191
+ // rotating again (preserving the original failure log for bug reports).
192
+ logRotatedThisSession = true;
193
+ }
194
+ logStream = fs.createWriteStream(LOG_FILE, {
195
+ flags: "a", // always append now (rotation handled above)
196
+ });
197
+ log(`──── backend-installer opened (${new Date().toISOString()}) ────`);
198
+ log(`platform=${process.platform} arch=${process.arch} node=${process.version}`);
199
+ } catch (err) {
200
+ // eslint-disable-next-line no-console
201
+ console.error(`[backend-installer] Could not open log ${LOG_FILE}:`, err.message);
202
+ logStream = null;
203
+ }
204
+ }
205
+
206
+ function closeLog() {
207
+ if (logStream) {
208
+ try {
209
+ logStream.end();
210
+ } catch {
211
+ // ignore
212
+ }
213
+ logStream = null;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Log a line to both the log file and stdout.
219
+ * Accepts the same args as console.log.
220
+ */
221
+ function log(...args) {
222
+ const line = args
223
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
224
+ .join(" ");
225
+ const timestamped = `[${new Date().toISOString()}] ${line}`;
226
+ // eslint-disable-next-line no-console
227
+ console.log(line);
228
+ if (logStream) {
229
+ try {
230
+ logStream.write(timestamped + "\n");
231
+ } catch {
232
+ // ignore
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Log an error line to both the log file and stderr.
239
+ */
240
+ function logError(...args) {
241
+ const line = args
242
+ .map((a) => (typeof a === "string" ? a : (a && a.stack) || JSON.stringify(a)))
243
+ .join(" ");
244
+ const timestamped = `[${new Date().toISOString()}] ERROR ${line}`;
245
+ // eslint-disable-next-line no-console
246
+ console.error(line);
247
+ if (logStream) {
248
+ try {
249
+ logStream.write(timestamped + "\n");
250
+ } catch {
251
+ // ignore
252
+ }
253
+ }
254
+ }
255
+
256
+ function getLogPath() {
257
+ return LOG_FILE;
258
+ }
259
+
260
+ function getStatePath() {
261
+ return STATE_FILE;
262
+ }
263
+
264
+ // ── State machine ────────────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Read the persisted install state. Returns `null` if no state file exists
268
+ * or the file is unreadable / corrupt (treated as "idle").
269
+ */
270
+ function getState() {
271
+ try {
272
+ if (!fs.existsSync(STATE_FILE)) return null;
273
+ const raw = fs.readFileSync(STATE_FILE, "utf8");
274
+ const parsed = JSON.parse(raw);
275
+ if (!parsed || typeof parsed !== "object" || !parsed.state) return null;
276
+ return parsed;
277
+ } catch (err) {
278
+ logError(`Could not read install state: ${err.message}`);
279
+ return null;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Persist the install state to disk. Non-fatal on failure.
285
+ */
286
+ function setState(state, extra = {}) {
287
+ ensureGaiaHome();
288
+ const payload = {
289
+ state,
290
+ stage: extra.stage || null,
291
+ message: extra.message || null,
292
+ version: extra.version || null,
293
+ updatedAt: new Date().toISOString(),
294
+ ...extra,
295
+ };
296
+ try {
297
+ fs.writeFileSync(STATE_FILE, JSON.stringify(payload, null, 2), "utf8");
298
+ log(`state: ${state}${extra.stage ? ` (${extra.stage})` : ""}`);
299
+ } catch (err) {
300
+ logError(`Could not write install state: ${err.message}`);
301
+ }
302
+ }
303
+
304
+ function clearState() {
305
+ try {
306
+ if (fs.existsSync(STATE_FILE)) {
307
+ fs.unlinkSync(STATE_FILE);
308
+ }
309
+ } catch (err) {
310
+ logError(`Could not clear install state: ${err.message}`);
311
+ }
312
+ }
313
+
314
+ // ── Progress helpers ─────────────────────────────────────────────────────────
315
+
316
+ /**
317
+ * Compute overall 0-100 progress given the current stage and within-stage
318
+ * percent (0-100).
319
+ */
320
+ function computeOverallPercent(stage, withinStagePercent) {
321
+ const idx = STAGE_ORDER.indexOf(stage);
322
+ if (idx === -1) return 0;
323
+ let base = 0;
324
+ for (let i = 0; i < idx; i++) {
325
+ base += STAGE_WEIGHTS[STAGE_ORDER[i]] || 0;
326
+ }
327
+ const stageWeight = STAGE_WEIGHTS[stage] || 0;
328
+ const within = Math.max(0, Math.min(100, withinStagePercent || 0));
329
+ return Math.max(0, Math.min(100, Math.round(base + (stageWeight * within) / 100)));
330
+ }
331
+
332
+ /**
333
+ * Wrap a caller-provided `onProgress` callback so it converts stage-local
334
+ * progress into overall 0-100 progress.
335
+ */
336
+ function makeProgressReporter(onProgress) {
337
+ const safe = typeof onProgress === "function" ? onProgress : () => {};
338
+ return function report(stage, withinStagePercent, message) {
339
+ const percent = computeOverallPercent(stage, withinStagePercent);
340
+ try {
341
+ safe(stage, percent, message || "");
342
+ } catch (err) {
343
+ logError(`onProgress callback threw: ${err.message}`);
344
+ }
345
+ };
346
+ }
347
+
348
+ // ── Command helpers ──────────────────────────────────────────────────────────
349
+
350
+ /**
351
+ * Check if a command exists on PATH.
352
+ */
353
+ function commandExists(cmd) {
354
+ try {
355
+ const check = IS_WINDOWS ? `where ${cmd}` : `command -v ${cmd}`;
356
+ execSync(check, { stdio: "ignore" });
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Find the gaia binary — prefer the managed venv, fall back to PATH.
365
+ */
366
+ function findGaiaBin() {
367
+ if (fs.existsSync(GAIA_BIN)) {
368
+ return GAIA_BIN;
369
+ }
370
+ if (commandExists("gaia")) {
371
+ return "gaia";
372
+ }
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * Run a child process and stream output to the log file in real time.
378
+ * Returns a Promise that resolves with { code, stdout, stderr }.
379
+ */
380
+ function runCommand(cmd, args, { env, stageLabel } = {}) {
381
+ return new Promise((resolve) => {
382
+ log(`$ ${cmd} ${args.join(" ")}`);
383
+ let proc;
384
+ try {
385
+ proc = spawn(cmd, args, {
386
+ cwd: os.homedir(), // Electron's cwd is "/" on macOS when launched from Finder
387
+ env: env || process.env,
388
+ stdio: ["ignore", "pipe", "pipe"],
389
+ windowsHide: true,
390
+ shell: false,
391
+ });
392
+ } catch (err) {
393
+ logError(`Failed to spawn ${cmd}: ${err.message}`);
394
+ resolve({ code: -1, stdout: "", stderr: String(err.message || err), error: err });
395
+ return;
396
+ }
397
+
398
+ let stdout = "";
399
+ let stderr = "";
400
+
401
+ proc.stdout.on("data", (data) => {
402
+ const chunk = data.toString();
403
+ stdout += chunk;
404
+ chunk.split(/\r?\n/).forEach((line) => {
405
+ if (line) log(` ${stageLabel ? `[${stageLabel}] ` : ""}${line}`);
406
+ });
407
+ });
408
+
409
+ proc.stderr.on("data", (data) => {
410
+ const chunk = data.toString();
411
+ stderr += chunk;
412
+ chunk.split(/\r?\n/).forEach((line) => {
413
+ if (line) log(` ${stageLabel ? `[${stageLabel}] ` : ""}${line}`);
414
+ });
415
+ });
416
+
417
+ proc.on("error", (err) => {
418
+ logError(`${cmd} error: ${err.message}`);
419
+ resolve({ code: -1, stdout, stderr, error: err });
420
+ });
421
+
422
+ proc.on("exit", (code) => {
423
+ log(` exit code: ${code}`);
424
+ resolve({ code, stdout, stderr });
425
+ });
426
+ });
427
+ }
428
+
429
+ // ── Pre-checks ───────────────────────────────────────────────────────────────
430
+
431
+ /**
432
+ * Check disk space at `~/.gaia/`'s parent.
433
+ * Returns { ok, freeBytes, requiredBytes, message? }.
434
+ */
435
+ function checkDiskSpace() {
436
+ const parent = path.dirname(GAIA_HOME);
437
+ try {
438
+ // Node 18.15+ has fs.statfsSync on all platforms.
439
+ if (typeof fs.statfsSync === "function") {
440
+ const stat = fs.statfsSync(parent);
441
+ // `bavail` is blocks available to unprivileged users; `bsize` is block size.
442
+ const free = BigInt(stat.bavail) * BigInt(stat.bsize);
443
+ const freeBytes = Number(free);
444
+ return {
445
+ ok: freeBytes >= MIN_DISK_SPACE_BYTES,
446
+ freeBytes,
447
+ requiredBytes: MIN_DISK_SPACE_BYTES,
448
+ };
449
+ }
450
+ } catch (err) {
451
+ logError(`statfsSync failed: ${err.message}`);
452
+ }
453
+
454
+ // Fallback: platform-specific shell commands. Non-fatal if unavailable.
455
+ try {
456
+ if (IS_WINDOWS) {
457
+ // Use PowerShell to read free space on the drive containing parent.
458
+ const drive = path.parse(parent).root.replace(/\\$/, "");
459
+ const out = execSync(
460
+ `powershell -NoProfile -Command "(Get-PSDrive -Name '${drive.replace(":", "")}').Free"`,
461
+ { encoding: "utf8", timeout: 5000 }
462
+ ).trim();
463
+ const freeBytes = parseInt(out, 10);
464
+ if (!Number.isNaN(freeBytes)) {
465
+ return {
466
+ ok: freeBytes >= MIN_DISK_SPACE_BYTES,
467
+ freeBytes,
468
+ requiredBytes: MIN_DISK_SPACE_BYTES,
469
+ };
470
+ }
471
+ } else {
472
+ // `df -k <parent>` — second line, 4th column is available 1K blocks.
473
+ const out = execSync(`df -k "${parent}"`, { encoding: "utf8", timeout: 5000 });
474
+ const lines = out.trim().split("\n");
475
+ if (lines.length >= 2) {
476
+ const cols = lines[lines.length - 1].trim().split(/\s+/);
477
+ // `df` can have 6 or 9 columns depending on platform; available is
478
+ // usually the 4th field (Linux) or also the 4th field on macOS.
479
+ const availKb = parseInt(cols[3], 10);
480
+ if (!Number.isNaN(availKb)) {
481
+ const freeBytes = availKb * 1024;
482
+ return {
483
+ ok: freeBytes >= MIN_DISK_SPACE_BYTES,
484
+ freeBytes,
485
+ requiredBytes: MIN_DISK_SPACE_BYTES,
486
+ };
487
+ }
488
+ }
489
+ }
490
+ } catch (err) {
491
+ logError(`Fallback disk-space check failed: ${err.message}`);
492
+ }
493
+
494
+ // Could not determine — be optimistic but record a warning.
495
+ log("Warning: could not determine free disk space; proceeding anyway");
496
+ return {
497
+ ok: true,
498
+ freeBytes: null,
499
+ requiredBytes: MIN_DISK_SPACE_BYTES,
500
+ message: "Free disk space could not be determined",
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Best-effort network reachability check. Performs a HEAD request to
506
+ * https://astral.sh (where the uv installer lives). Resolves { ok, message? }.
507
+ */
508
+ function _checkOneHost(url) {
509
+ return new Promise((resolve) => {
510
+ let settled = false;
511
+ const finish = (result) => {
512
+ if (settled) return;
513
+ settled = true;
514
+ resolve(result);
515
+ };
516
+
517
+ try {
518
+ const req = https.request(
519
+ url,
520
+ {
521
+ method: "HEAD",
522
+ timeout: NETWORK_CHECK_TIMEOUT_MS,
523
+ headers: { "User-Agent": "gaia-backend-installer/1.0" },
524
+ },
525
+ (res) => {
526
+ // Any response (even 3xx/4xx) means we have basic connectivity.
527
+ res.resume();
528
+ finish({ ok: true, status: res.statusCode });
529
+ }
530
+ );
531
+ req.on("timeout", () => {
532
+ req.destroy();
533
+ finish({
534
+ ok: false,
535
+ message: `${url}: timed out after ${NETWORK_CHECK_TIMEOUT_MS / 1000}s`,
536
+ });
537
+ });
538
+ req.on("error", (err) => {
539
+ finish({
540
+ ok: false,
541
+ message: `${url}: ${err.message}`,
542
+ });
543
+ });
544
+ req.end();
545
+ } catch (err) {
546
+ finish({
547
+ ok: false,
548
+ message: `${url}: ${err.message}`,
549
+ });
550
+ }
551
+ });
552
+ }
553
+
554
+ /**
555
+ * Probe each host in ``NETWORK_CHECK_HOSTS`` sequentially. Succeed as
556
+ * soon as ANY host responds (even 3xx/4xx counts — it proves
557
+ * connectivity). Only fail if ALL hosts are unreachable.
558
+ */
559
+ async function checkNetwork() {
560
+ const errors = [];
561
+ for (const url of NETWORK_CHECK_HOSTS) {
562
+ const result = await _checkOneHost(url);
563
+ if (result.ok) return result;
564
+ errors.push(result.message);
565
+ }
566
+ return {
567
+ ok: false,
568
+ message: `Network check failed for all hosts: ${errors.join("; ")}`,
569
+ };
570
+ }
571
+
572
+ /**
573
+ * Run all pre-checks. Returns a structured result; the caller decides what
574
+ * to do on failure (show a dialog, abort, etc.).
575
+ *
576
+ * Shape:
577
+ * {
578
+ * ok: boolean,
579
+ * disk: { ok, freeBytes, requiredBytes, message? },
580
+ * network:{ ok, message? },
581
+ * previousState: object | null,
582
+ * }
583
+ */
584
+ async function runPreChecks(opts = {}) {
585
+ const report = makeProgressReporter(opts.onProgress);
586
+ report(STAGES.PRE_CHECKS, 0, "Running pre-flight checks");
587
+
588
+ log("Running pre-checks...");
589
+ const previousState = getState();
590
+ if (previousState && previousState.state) {
591
+ log(`Found existing state file: ${previousState.state}`);
592
+ }
593
+
594
+ report(STAGES.PRE_CHECKS, 25, "Checking disk space");
595
+ const disk = checkDiskSpace();
596
+ log(
597
+ `Disk: ${disk.ok ? "ok" : "insufficient"} (free=${disk.freeBytes}, required=${disk.requiredBytes})`
598
+ );
599
+
600
+ report(STAGES.PRE_CHECKS, 60, "Checking network connectivity");
601
+ const network = await checkNetwork();
602
+ log(`Network: ${network.ok ? "ok" : "unreachable"} ${network.message || ""}`);
603
+
604
+ report(STAGES.PRE_CHECKS, 100, "Pre-flight checks complete");
605
+
606
+ return {
607
+ ok: disk.ok && network.ok,
608
+ disk,
609
+ network,
610
+ previousState,
611
+ };
612
+ }
613
+
614
+ // ── uv install ───────────────────────────────────────────────────────────────
615
+
616
+ class InstallError extends Error {
617
+ constructor(message, { stage, code, suggestion } = {}) {
618
+ super(message);
619
+ this.name = "InstallError";
620
+ this.stage = stage || null;
621
+ this.code = code || null;
622
+ this.suggestion = suggestion || null;
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Ensure `uv` is available. Installs it from astral.sh if missing.
628
+ * Throws an InstallError if installation fails.
629
+ */
630
+ async function ensureUv({ onProgress } = {}) {
631
+ const report = makeProgressReporter(onProgress);
632
+
633
+ if (commandExists("uv")) {
634
+ log("uv already installed");
635
+ report(STAGES.ENSURE_UV, 100, "uv is already installed");
636
+ return;
637
+ }
638
+
639
+ report(STAGES.ENSURE_UV, 0, "Installing uv (Python package manager)");
640
+ log("Installing uv...");
641
+
642
+ let result;
643
+ if (IS_WINDOWS) {
644
+ result = await runCommand(
645
+ "powershell",
646
+ ["-ExecutionPolicy", "Bypass", "-Command", "irm https://astral.sh/uv/install.ps1 | iex"],
647
+ { stageLabel: "uv-install" }
648
+ );
649
+ } else {
650
+ result = await runCommand(
651
+ "bash",
652
+ ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
653
+ { stageLabel: "uv-install" }
654
+ );
655
+ }
656
+
657
+ if (result.code !== 0) {
658
+ throw new InstallError(
659
+ `Could not install uv automatically (exit code ${result.code}).`,
660
+ {
661
+ stage: STAGES.ENSURE_UV,
662
+ code: result.code,
663
+ suggestion: IS_WINDOWS
664
+ ? 'Install uv manually: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
665
+ : "Install uv manually: curl -LsSf https://astral.sh/uv/install.sh | sh",
666
+ }
667
+ );
668
+ }
669
+
670
+ // On some shells, PATH isn't refreshed for the current process. Re-check
671
+ // after adding the default uv install dirs. uv has shipped under both
672
+ // ~/.cargo/bin and ~/.local/bin at different times — most notably the
673
+ // Windows installer migrated from .cargo/bin to %USERPROFILE%\.local\bin
674
+ // in early 2025. Try BOTH paths on every platform to be robust against
675
+ // installer version drift.
676
+ if (!commandExists("uv")) {
677
+ const candidates = [
678
+ path.join(os.homedir(), ".local", "bin"),
679
+ path.join(os.homedir(), ".cargo", "bin"),
680
+ ];
681
+ for (const uvDir of candidates) {
682
+ if (process.env.PATH && !process.env.PATH.includes(uvDir)) {
683
+ process.env.PATH = `${uvDir}${path.delimiter}${process.env.PATH}`;
684
+ log(`Added ${uvDir} to PATH for this process`);
685
+ }
686
+ }
687
+ }
688
+
689
+ if (!commandExists("uv")) {
690
+ throw new InstallError(
691
+ "uv installed but not found on PATH. A shell restart may be required.",
692
+ {
693
+ stage: STAGES.ENSURE_UV,
694
+ suggestion:
695
+ "Restart your terminal or reboot, then re-launch GAIA. If the problem persists, install uv manually from https://astral.sh/uv",
696
+ }
697
+ );
698
+ }
699
+
700
+ report(STAGES.ENSURE_UV, 100, "uv installed");
701
+ log("uv install complete");
702
+ }
703
+
704
+ // ── Backend install ──────────────────────────────────────────────────────────
705
+
706
+ /**
707
+ * Read the pinned backend version from package.json (or a caller override).
708
+ */
709
+ function resolveBackendVersion(opts = {}) {
710
+ if (opts.version) return opts.version;
711
+ try {
712
+ // package.json is one directory up from the services/ (or bin/) directory.
713
+ // We look relative to this module's own location.
714
+ const pkgPath = path.join(__dirname, "..", "package.json");
715
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
716
+ return pkg.version || "latest";
717
+ } catch (err) {
718
+ logError(`Could not read package.json version: ${err.message}`);
719
+ return "latest";
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Install the GAIA Python backend from scratch.
725
+ *
726
+ * opts:
727
+ * - onProgress(stage, percent, message)
728
+ * - version: string — override the pinned version
729
+ * - skipGaiaInit: boolean — skip `gaia init` (for testing)
730
+ *
731
+ * Throws InstallError on failure. The state file is updated to reflect
732
+ * the current stage so a subsequent launch can recover.
733
+ */
734
+ async function installBackend(opts = {}) {
735
+ const report = makeProgressReporter(opts.onProgress);
736
+ const version = resolveBackendVersion(opts);
737
+ const pipPackage = `amd-gaia[ui]==${version}`;
738
+
739
+ log("================================================");
740
+ log(" Installing GAIA backend");
741
+ log("================================================");
742
+ log(`Package: ${pipPackage}`);
743
+ log(`Location: ${GAIA_VENV_DISPLAY}`);
744
+
745
+ setState(STATES.INSTALLING, { stage: STAGES.ENSURE_UV, version });
746
+
747
+ // Stage 1: ensure uv
748
+ await ensureUv({ onProgress: opts.onProgress });
749
+
750
+ // Stage 2: create venv
751
+ setState(STATES.INSTALLING, { stage: STAGES.CREATE_VENV, version });
752
+ report(STAGES.CREATE_VENV, 0, "Creating Python 3.12 environment");
753
+
754
+ ensureGaiaHome();
755
+
756
+ // If the venv exists but the python binary is missing, treat as partial.
757
+ const venvLooksValid =
758
+ fs.existsSync(GAIA_VENV) && fs.existsSync(GAIA_PYTHON_BIN);
759
+
760
+ if (!venvLooksValid) {
761
+ if (fs.existsSync(GAIA_VENV)) {
762
+ log("Existing venv appears broken — removing and recreating");
763
+ try {
764
+ fs.rmSync(GAIA_VENV, { recursive: true, force: true });
765
+ } catch (err) {
766
+ logError(`Could not remove broken venv: ${err.message}`);
767
+ }
768
+ }
769
+
770
+ const venvResult = await runCommand(
771
+ "uv",
772
+ ["venv", GAIA_VENV, "--python", "3.12"],
773
+ { stageLabel: "venv" }
774
+ );
775
+ if (venvResult.code !== 0) {
776
+ throw new InstallError(
777
+ `Failed to create Python environment (uv venv exit ${venvResult.code}).`,
778
+ {
779
+ stage: STAGES.CREATE_VENV,
780
+ code: venvResult.code,
781
+ suggestion: `Try creating it manually:\n uv venv ${GAIA_VENV_DISPLAY} --python 3.12\nThen restart GAIA.`,
782
+ }
783
+ );
784
+ }
785
+ } else {
786
+ log("Existing venv looks valid — reusing");
787
+ }
788
+ report(STAGES.CREATE_VENV, 100, "Python environment ready");
789
+
790
+ // Stage 3: pip install
791
+ setState(STATES.INSTALLING, { stage: STAGES.INSTALL_PACKAGE, version });
792
+ report(STAGES.INSTALL_PACKAGE, 0, `Installing ${pipPackage}`);
793
+
794
+ const pipArgs = [
795
+ "pip",
796
+ "install",
797
+ pipPackage,
798
+ "--refresh",
799
+ "--python",
800
+ GAIA_PYTHON_BIN,
801
+ ];
802
+ // Linux/macOS: use CPU-only PyTorch to avoid huge CUDA wheels.
803
+ if (!IS_WINDOWS) {
804
+ pipArgs.push("--extra-index-url", "https://download.pytorch.org/whl/cpu");
805
+ }
806
+
807
+ const installResult = await runCommand("uv", pipArgs, { stageLabel: "pip" });
808
+ if (installResult.code !== 0) {
809
+ throw new InstallError(
810
+ `Failed to install ${pipPackage} (pip exit ${installResult.code}).`,
811
+ {
812
+ stage: STAGES.INSTALL_PACKAGE,
813
+ code: installResult.code,
814
+ suggestion: `Try installing manually:\n uv pip install ${pipPackage} --python ${
815
+ IS_WINDOWS ? `${GAIA_VENV_DISPLAY}/Scripts/python.exe` : `${GAIA_VENV_DISPLAY}/bin/python`
816
+ }\nThen restart GAIA. See https://amd-gaia.ai/quickstart#cli-install`,
817
+ }
818
+ );
819
+ }
820
+
821
+ if (!fs.existsSync(GAIA_BIN)) {
822
+ throw new InstallError(
823
+ `GAIA binary not found at ${GAIA_VENV_DISPLAY} after install.`,
824
+ {
825
+ stage: STAGES.INSTALL_PACKAGE,
826
+ suggestion: "The package was installed but the gaia executable is missing. Try reinstalling from https://amd-gaia.ai/quickstart",
827
+ }
828
+ );
829
+ }
830
+ report(STAGES.INSTALL_PACKAGE, 100, "GAIA package installed");
831
+
832
+ // Stage 4: gaia init
833
+ if (!opts.skipGaiaInit) {
834
+ setState(STATES.INSTALLING, { stage: STAGES.GAIA_INIT, version });
835
+ report(
836
+ STAGES.GAIA_INIT,
837
+ 0,
838
+ "Setting up Lemonade Server and downloading models (this can take several minutes)"
839
+ );
840
+
841
+ const initResult = await runCommand(
842
+ GAIA_BIN,
843
+ ["init", "--profile", "minimal", "--yes"],
844
+ { stageLabel: "gaia-init" }
845
+ );
846
+
847
+ if (initResult.code !== 0) {
848
+ // gaia init failure is non-fatal (user can retry later), but we still
849
+ // log it and treat the rest of the install as successful.
850
+ log(
851
+ `Warning: gaia init exited with code ${initResult.code}. Continuing anyway.`
852
+ );
853
+ }
854
+ report(STAGES.GAIA_INIT, 100, "Lemonade Server setup complete");
855
+ } else {
856
+ log("Skipping gaia init (skipGaiaInit=true)");
857
+ }
858
+
859
+ // Stage 5: verify
860
+ setState(STATES.INSTALLING, { stage: STAGES.VERIFY, version });
861
+ report(STAGES.VERIFY, 0, "Verifying installation");
862
+
863
+ const verifiedBin = findGaiaBin();
864
+ if (!verifiedBin) {
865
+ throw new InstallError(
866
+ "GAIA backend not found after install verification.",
867
+ {
868
+ stage: STAGES.VERIFY,
869
+ suggestion: "Check the log file for details and try reinstalling.",
870
+ }
871
+ );
872
+ }
873
+ const installedVersion = getInstalledVersion(verifiedBin);
874
+ log(`Verified gaia binary: ${verifiedBin} (version=${installedVersion || "unknown"})`);
875
+ report(STAGES.VERIFY, 100, "Install verified");
876
+
877
+ setState(STATES.READY, { stage: null, version, installedVersion });
878
+ log("Backend install complete");
879
+ }
880
+
881
+ // ── Version-aware ensure ─────────────────────────────────────────────────────
882
+
883
+ /**
884
+ * Run `<gaiaBin> --version` and extract the installed version string.
885
+ * Returns null on failure.
886
+ */
887
+ function getInstalledVersion(gaiaBin) {
888
+ try {
889
+ const result = spawnSync(gaiaBin, ["--version"], {
890
+ stdio: ["ignore", "pipe", "pipe"],
891
+ timeout: 5000,
892
+ windowsHide: true,
893
+ });
894
+ if (result.status === 0 && result.stdout) {
895
+ const match = result.stdout.toString().trim().match(/(\d+\.\d+\.\d+)/);
896
+ return match ? match[1] : null;
897
+ }
898
+ } catch {
899
+ // ignore
900
+ }
901
+ return null;
902
+ }
903
+
904
+ /**
905
+ * Ensure the GAIA backend is installed at the expected version.
906
+ * Returns the path to the gaia binary on success.
907
+ *
908
+ * opts:
909
+ * - onProgress(stage, percent, message)
910
+ * - version: override the pinned version
911
+ * - skipGaiaInit: bool
912
+ * - allowPartialRestart: bool (default true) — restart from scratch if
913
+ * the state file indicates a `partial` install.
914
+ *
915
+ * Throws InstallError on failure and updates the state file.
916
+ */
917
+ async function ensureBackend(opts = {}) {
918
+ openLog({ truncate: true });
919
+
920
+ try {
921
+ const preChecks = await runPreChecks({ onProgress: opts.onProgress });
922
+
923
+ // Handle a pre-existing partial install first (before disk/network fails
924
+ // would hide the interrupted state).
925
+ if (preChecks.previousState) {
926
+ const prev = preChecks.previousState;
927
+ if (prev.state === STATES.INSTALLING) {
928
+ // The previous run never finished. Record this and proceed with a
929
+ // fresh restart (per §10.4 recommendation A).
930
+ log(
931
+ `Previous install was interrupted at stage=${prev.stage || "?"} — restarting from scratch`
932
+ );
933
+ setState(STATES.PARTIAL, { stage: prev.stage, message: "Previous install interrupted" });
934
+ } else if (prev.state === STATES.PARTIAL) {
935
+ log("Previous launch detected a partial install — restarting from scratch");
936
+ } else if (prev.state === STATES.FAILED) {
937
+ log(`Previous install failed: ${prev.message || "(no detail)"} — retrying`);
938
+ } else if (prev.state === STATES.READY) {
939
+ log("Previous state: ready");
940
+ }
941
+ }
942
+
943
+ // Disk check failure: fatal, surface as InstallError.
944
+ if (!preChecks.disk.ok) {
945
+ const freeMb =
946
+ preChecks.disk.freeBytes != null
947
+ ? Math.round(preChecks.disk.freeBytes / (1024 * 1024))
948
+ : null;
949
+ const requiredMb = Math.round(
950
+ preChecks.disk.requiredBytes / (1024 * 1024)
951
+ );
952
+ const err = new InstallError(
953
+ `Not enough free disk space. Required: ${requiredMb} MB${
954
+ freeMb != null ? `, available: ${freeMb} MB` : ""
955
+ }.`,
956
+ {
957
+ stage: STAGES.PRE_CHECKS,
958
+ suggestion: `Free at least ${requiredMb} MB at ${path.dirname(
959
+ GAIA_HOME
960
+ )} and try again.`,
961
+ }
962
+ );
963
+ setState(STATES.FAILED, { stage: STAGES.PRE_CHECKS, message: err.message });
964
+ throw err;
965
+ }
966
+
967
+ // Network check failure: fatal, surface as InstallError.
968
+ if (!preChecks.network.ok) {
969
+ const err = new InstallError(
970
+ `You appear to be offline. ${preChecks.network.message || "Could not reach any network host."}`,
971
+ {
972
+ stage: STAGES.PRE_CHECKS,
973
+ suggestion:
974
+ "Connect to the internet and try again. If you are behind a corporate proxy, configure HTTPS_PROXY and re-launch GAIA.",
975
+ }
976
+ );
977
+ setState(STATES.FAILED, { stage: STAGES.PRE_CHECKS, message: err.message });
978
+ throw err;
979
+ }
980
+
981
+ // Fast-path: already installed at the expected version.
982
+ const expectedVersion = resolveBackendVersion(opts);
983
+ const existingBin = findGaiaBin();
984
+ if (existingBin) {
985
+ const installedVersion = getInstalledVersion(existingBin);
986
+ if (installedVersion === expectedVersion) {
987
+ log(
988
+ `GAIA backend already installed at version ${installedVersion} — nothing to do`
989
+ );
990
+ setState(STATES.READY, {
991
+ version: expectedVersion,
992
+ installedVersion,
993
+ });
994
+ // Tell the UI we are instantly ready.
995
+ const report = makeProgressReporter(opts.onProgress);
996
+ report(STAGES.VERIFY, 100, `GAIA ${installedVersion} ready`);
997
+ return existingBin;
998
+ }
999
+ log(
1000
+ `Version mismatch: expected=${expectedVersion} installed=${installedVersion || "unknown"} — upgrading`
1001
+ );
1002
+ } else {
1003
+ log("GAIA backend not found — installing from scratch");
1004
+ }
1005
+
1006
+ await installBackend(opts);
1007
+
1008
+ const verified = findGaiaBin();
1009
+ if (!verified) {
1010
+ const err = new InstallError(
1011
+ "GAIA backend not found after installation.",
1012
+ {
1013
+ stage: STAGES.VERIFY,
1014
+ suggestion: "Check the log file and try reinstalling. See https://amd-gaia.ai/quickstart",
1015
+ }
1016
+ );
1017
+ setState(STATES.FAILED, {
1018
+ stage: STAGES.VERIFY,
1019
+ message: err.message,
1020
+ });
1021
+ throw err;
1022
+ }
1023
+
1024
+ return verified;
1025
+ } catch (err) {
1026
+ if (err instanceof InstallError) {
1027
+ setState(STATES.FAILED, {
1028
+ stage: err.stage || null,
1029
+ message: err.message,
1030
+ });
1031
+ throw err;
1032
+ }
1033
+ // Unexpected — still mark failed and wrap.
1034
+ logError(`Unexpected error during ensureBackend: ${err.message}`);
1035
+ setState(STATES.FAILED, { message: err.message });
1036
+ throw new InstallError(`Unexpected error: ${err.message}`, {
1037
+ suggestion: "Check the log file for details.",
1038
+ });
1039
+ } finally {
1040
+ closeLog();
1041
+ }
1042
+ }
1043
+
1044
+ // ── Exports ──────────────────────────────────────────────────────────────────
1045
+
1046
+ module.exports = {
1047
+ // Core API
1048
+ ensureUv,
1049
+ installBackend,
1050
+ ensureBackend,
1051
+ getInstalledVersion,
1052
+ findGaiaBin,
1053
+
1054
+ // Pre-checks
1055
+ runPreChecks,
1056
+ checkDiskSpace,
1057
+ checkNetwork,
1058
+
1059
+ // State machine
1060
+ getState,
1061
+ setState,
1062
+ clearState,
1063
+
1064
+ // Logging
1065
+ openLog,
1066
+ closeLog,
1067
+ log,
1068
+ logError,
1069
+ getLogPath,
1070
+ getStatePath,
1071
+
1072
+ // Constants
1073
+ STATES,
1074
+ STAGES,
1075
+ GAIA_HOME,
1076
+ GAIA_VENV,
1077
+ GAIA_BIN,
1078
+ MIN_DISK_SPACE_BYTES,
1079
+
1080
+ // Error
1081
+ InstallError,
1082
+ };