@amd-gaia/agent-ui 0.17.0 → 0.17.2
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/bin/gaia-ui.cjs +370 -0
- package/dist/assets/index-CmLC9Yd5.js +437 -0
- package/dist/assets/index-DdsmIsYZ.css +1 -0
- package/dist/index.html +3 -3
- package/main.cjs +189 -54
- package/package.json +8 -11
- package/preload.cjs +42 -0
- package/services/auto-updater.cjs +437 -0
- package/services/backend-installer-progress-dialog.cjs +429 -0
- package/services/backend-installer.cjs +1082 -0
- package/services/tray-manager.cjs +1 -1
- package/bin/gaia-ui.mjs +0 -572
- package/dist/assets/index-C7oO2M6Q.js +0 -432
- package/dist/assets/index-TyWv9Ej0.css +0 -1
|
@@ -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
|
+
};
|