@openagentsinc/pylon 0.1.0 → 0.1.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/README.md +20 -4
- package/package.json +1 -1
- package/src/cli.js +108 -8
- package/src/index.js +1050 -82
package/src/index.js
CHANGED
|
@@ -4,18 +4,42 @@ import { createReadStream } from "node:fs";
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import readline from "node:readline/promises";
|
|
7
8
|
|
|
8
9
|
export const DEFAULT_RELEASE_REPO = "OpenAgentsInc/openagents";
|
|
9
10
|
export const DEFAULT_RELEASE_API_BASE = "https://api.github.com";
|
|
11
|
+
export const DEFAULT_RELEASE_GIT_BASE = "https://github.com";
|
|
12
|
+
export const DEFAULT_RUSTUP_INIT_URL = "https://sh.rustup.rs";
|
|
10
13
|
export const DEFAULT_MODEL_ID = "gemma-4-e4b";
|
|
11
14
|
export const DEFAULT_DIAGNOSTIC_REPEATS = 3;
|
|
12
15
|
export const DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS = 96;
|
|
16
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
13
17
|
const PYLON_RELEASE_TAG_PREFIX = "pylon-v";
|
|
18
|
+
const RELEASE_ASSET_INSTALL_METHOD = "release_asset";
|
|
19
|
+
const SOURCE_BUILD_INSTALL_METHOD = "source_build";
|
|
20
|
+
|
|
21
|
+
function emitStatus(onStatus, message, detail = null) {
|
|
22
|
+
if (typeof onStatus === "function") {
|
|
23
|
+
onStatus({ message, detail });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emitVerboseStatus(onStatus, verbose, message, detail = null) {
|
|
28
|
+
if (verbose) {
|
|
29
|
+
emitStatus(onStatus, message, detail);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
14
32
|
|
|
15
33
|
function normalizeVersion(value) {
|
|
16
34
|
return value.replace(/^pylon-v/, "").replace(/^v/, "");
|
|
17
35
|
}
|
|
18
36
|
|
|
37
|
+
function createBootstrapError(message, context = {}) {
|
|
38
|
+
const error = new Error(message);
|
|
39
|
+
Object.assign(error, context);
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
async function pathExists(value) {
|
|
20
44
|
try {
|
|
21
45
|
await fs.access(value);
|
|
@@ -29,6 +53,28 @@ function defaultInstallRoot() {
|
|
|
29
53
|
return path.join(os.homedir(), ".openagents", "pylon", "bootstrap");
|
|
30
54
|
}
|
|
31
55
|
|
|
56
|
+
class MissingReleaseAssetsError extends Error {
|
|
57
|
+
constructor({
|
|
58
|
+
tagName,
|
|
59
|
+
version,
|
|
60
|
+
target,
|
|
61
|
+
archiveBasename,
|
|
62
|
+
archiveName,
|
|
63
|
+
checksumName,
|
|
64
|
+
targetCommitish,
|
|
65
|
+
}) {
|
|
66
|
+
super(`Release ${tagName} is missing ${archiveName} or ${checksumName}.`);
|
|
67
|
+
this.name = "MissingReleaseAssetsError";
|
|
68
|
+
this.tagName = tagName;
|
|
69
|
+
this.version = version;
|
|
70
|
+
this.target = target;
|
|
71
|
+
this.archiveBasename = archiveBasename;
|
|
72
|
+
this.archiveName = archiveName;
|
|
73
|
+
this.checksumName = checksumName;
|
|
74
|
+
this.targetCommitish = targetCommitish ?? null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
function requestHeaders() {
|
|
33
79
|
const headers = {
|
|
34
80
|
accept: "application/vnd.github+json",
|
|
@@ -40,6 +86,166 @@ function requestHeaders() {
|
|
|
40
86
|
return headers;
|
|
41
87
|
}
|
|
42
88
|
|
|
89
|
+
function formatRequestHeaders(headers = requestHeaders()) {
|
|
90
|
+
return Object.entries(headers).flatMap(([key, value]) => [
|
|
91
|
+
"--header",
|
|
92
|
+
`${key}: ${value}`,
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function timedSignal(timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
|
97
|
+
if (typeof AbortSignal?.timeout === "function") {
|
|
98
|
+
return {
|
|
99
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
100
|
+
dispose() {},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
106
|
+
return {
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
dispose() {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function classifyNetworkError(error) {
|
|
115
|
+
const cause = error?.cause ?? null;
|
|
116
|
+
const code = cause?.code ?? cause?.errno ?? null;
|
|
117
|
+
const detail = [error?.message, cause?.message].filter(Boolean).join(" :: ");
|
|
118
|
+
const lowered = detail.toLowerCase();
|
|
119
|
+
let classification = "network";
|
|
120
|
+
|
|
121
|
+
if (
|
|
122
|
+
error?.name === "AbortError" ||
|
|
123
|
+
code === "ETIMEDOUT" ||
|
|
124
|
+
lowered.includes("timeout")
|
|
125
|
+
) {
|
|
126
|
+
classification = "timeout";
|
|
127
|
+
} else if (
|
|
128
|
+
code === "ENOTFOUND" ||
|
|
129
|
+
code === "EAI_AGAIN" ||
|
|
130
|
+
lowered.includes("getaddrinfo")
|
|
131
|
+
) {
|
|
132
|
+
classification = "dns";
|
|
133
|
+
} else if (
|
|
134
|
+
code === "CERT_HAS_EXPIRED" ||
|
|
135
|
+
code === "DEPTH_ZERO_SELF_SIGNED_CERT" ||
|
|
136
|
+
lowered.includes("certificate") ||
|
|
137
|
+
lowered.includes("tls") ||
|
|
138
|
+
lowered.includes("ssl")
|
|
139
|
+
) {
|
|
140
|
+
classification = "tls";
|
|
141
|
+
} else if (
|
|
142
|
+
code === "ECONNREFUSED" ||
|
|
143
|
+
code === "ECONNRESET" ||
|
|
144
|
+
code === "EHOSTUNREACH" ||
|
|
145
|
+
code === "ENETUNREACH" ||
|
|
146
|
+
code === "EADDRNOTAVAIL" ||
|
|
147
|
+
lowered.includes("connect")
|
|
148
|
+
) {
|
|
149
|
+
classification = "connect";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const endpoint =
|
|
153
|
+
cause?.address || cause?.hostname || cause?.port
|
|
154
|
+
? [
|
|
155
|
+
cause?.hostname ?? cause?.address ?? null,
|
|
156
|
+
cause?.port != null ? String(cause.port) : null,
|
|
157
|
+
]
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.join(":")
|
|
160
|
+
: null;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
classification,
|
|
164
|
+
code,
|
|
165
|
+
endpoint,
|
|
166
|
+
detail: detail || "network request failed",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isRetryableFetchError(error) {
|
|
171
|
+
if (!(error instanceof Error)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return classifyNetworkError(error).classification !== "tls";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderNetworkFailure({ stage, url, error, curlError = null }) {
|
|
178
|
+
const details = classifyNetworkError(error);
|
|
179
|
+
const parts = [
|
|
180
|
+
`${stage} failed for ${url}.`,
|
|
181
|
+
`classification=${details.classification}`,
|
|
182
|
+
];
|
|
183
|
+
if (details.code) {
|
|
184
|
+
parts.push(`code=${details.code}`);
|
|
185
|
+
}
|
|
186
|
+
if (details.endpoint) {
|
|
187
|
+
parts.push(`endpoint=${details.endpoint}`);
|
|
188
|
+
}
|
|
189
|
+
parts.push(`detail=${details.detail}`);
|
|
190
|
+
if (curlError) {
|
|
191
|
+
parts.push(
|
|
192
|
+
`curl_fallback=${curlError instanceof Error ? curlError.message : String(curlError)}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return parts.join(" ");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function fetchResponse(fetchImpl, url, headers = requestHeaders()) {
|
|
199
|
+
const timeout = timedSignal();
|
|
200
|
+
try {
|
|
201
|
+
return await fetchImpl(url, {
|
|
202
|
+
headers,
|
|
203
|
+
signal: timeout.signal,
|
|
204
|
+
});
|
|
205
|
+
} finally {
|
|
206
|
+
timeout.dispose();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runCurlText(runProcessImpl, url, headers = requestHeaders()) {
|
|
211
|
+
const { stdout } = await runProcessImpl("curl", [
|
|
212
|
+
"--fail",
|
|
213
|
+
"--silent",
|
|
214
|
+
"--show-error",
|
|
215
|
+
"--location",
|
|
216
|
+
"--connect-timeout",
|
|
217
|
+
"15",
|
|
218
|
+
"--max-time",
|
|
219
|
+
"60",
|
|
220
|
+
...formatRequestHeaders(headers),
|
|
221
|
+
url,
|
|
222
|
+
]);
|
|
223
|
+
return stdout;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runCurlDownload(
|
|
227
|
+
runProcessImpl,
|
|
228
|
+
url,
|
|
229
|
+
destination,
|
|
230
|
+
headers = requestHeaders(),
|
|
231
|
+
) {
|
|
232
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
233
|
+
await runProcessImpl("curl", [
|
|
234
|
+
"--fail",
|
|
235
|
+
"--silent",
|
|
236
|
+
"--show-error",
|
|
237
|
+
"--location",
|
|
238
|
+
"--connect-timeout",
|
|
239
|
+
"15",
|
|
240
|
+
"--max-time",
|
|
241
|
+
"300",
|
|
242
|
+
"--output",
|
|
243
|
+
destination,
|
|
244
|
+
...formatRequestHeaders(headers),
|
|
245
|
+
url,
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
|
|
43
249
|
export function resolvePlatformTarget(
|
|
44
250
|
platform = process.platform,
|
|
45
251
|
arch = process.arch,
|
|
@@ -106,42 +312,164 @@ export function parseSha256File(payload, expectedAssetName) {
|
|
|
106
312
|
return sha256.toLowerCase();
|
|
107
313
|
}
|
|
108
314
|
|
|
109
|
-
async function fetchJson(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
315
|
+
async function fetchJson(
|
|
316
|
+
fetchImpl,
|
|
317
|
+
url,
|
|
318
|
+
{
|
|
319
|
+
headers = requestHeaders(),
|
|
320
|
+
runProcessImpl = runProcess,
|
|
321
|
+
onStatus = null,
|
|
322
|
+
stage = "GitHub release lookup",
|
|
323
|
+
verbose = false,
|
|
324
|
+
} = {},
|
|
325
|
+
) {
|
|
326
|
+
try {
|
|
327
|
+
emitVerboseStatus(onStatus, verbose, stage, url);
|
|
328
|
+
const response = await fetchResponse(fetchImpl, url, headers);
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
throw createBootstrapError(
|
|
331
|
+
`GitHub release lookup failed for ${url} (${response.status} ${response.statusText}).`,
|
|
332
|
+
{
|
|
333
|
+
stage,
|
|
334
|
+
url,
|
|
335
|
+
httpStatus: response.status,
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return response.json();
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (
|
|
342
|
+
!error?.httpStatus &&
|
|
343
|
+
isRetryableFetchError(error) &&
|
|
344
|
+
typeof runProcessImpl === "function"
|
|
345
|
+
) {
|
|
346
|
+
emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
|
|
347
|
+
try {
|
|
348
|
+
return JSON.parse(await runCurlText(runProcessImpl, url, headers));
|
|
349
|
+
} catch (curlError) {
|
|
350
|
+
throw createBootstrapError(
|
|
351
|
+
renderNetworkFailure({ stage, url, error, curlError }),
|
|
352
|
+
{ stage, url, cause: error },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (error?.httpStatus) {
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
|
|
360
|
+
stage,
|
|
361
|
+
url,
|
|
362
|
+
cause: error,
|
|
363
|
+
});
|
|
117
364
|
}
|
|
118
|
-
return response.json();
|
|
119
365
|
}
|
|
120
366
|
|
|
121
|
-
async function fetchText(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
367
|
+
async function fetchText(
|
|
368
|
+
fetchImpl,
|
|
369
|
+
url,
|
|
370
|
+
{
|
|
371
|
+
headers = requestHeaders(),
|
|
372
|
+
runProcessImpl = runProcess,
|
|
373
|
+
onStatus = null,
|
|
374
|
+
stage = "download text",
|
|
375
|
+
verbose = false,
|
|
376
|
+
} = {},
|
|
377
|
+
) {
|
|
378
|
+
try {
|
|
379
|
+
emitVerboseStatus(onStatus, verbose, stage, url);
|
|
380
|
+
const response = await fetchResponse(fetchImpl, url, headers);
|
|
381
|
+
if (!response.ok) {
|
|
382
|
+
throw createBootstrapError(
|
|
383
|
+
`Download failed for ${url} (${response.status} ${response.statusText}).`,
|
|
384
|
+
{
|
|
385
|
+
stage,
|
|
386
|
+
url,
|
|
387
|
+
httpStatus: response.status,
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return response.text();
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (
|
|
394
|
+
!error?.httpStatus &&
|
|
395
|
+
isRetryableFetchError(error) &&
|
|
396
|
+
typeof runProcessImpl === "function"
|
|
397
|
+
) {
|
|
398
|
+
emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
|
|
399
|
+
try {
|
|
400
|
+
return await runCurlText(runProcessImpl, url, headers);
|
|
401
|
+
} catch (curlError) {
|
|
402
|
+
throw createBootstrapError(
|
|
403
|
+
renderNetworkFailure({ stage, url, error, curlError }),
|
|
404
|
+
{ stage, url, cause: error },
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (error?.httpStatus) {
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
|
|
412
|
+
stage,
|
|
413
|
+
url,
|
|
414
|
+
cause: error,
|
|
415
|
+
});
|
|
129
416
|
}
|
|
130
|
-
return response.text();
|
|
131
417
|
}
|
|
132
418
|
|
|
133
|
-
async function downloadFile(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
419
|
+
async function downloadFile(
|
|
420
|
+
fetchImpl,
|
|
421
|
+
url,
|
|
422
|
+
destination,
|
|
423
|
+
{
|
|
424
|
+
headers = requestHeaders(),
|
|
425
|
+
runProcessImpl = runProcess,
|
|
426
|
+
onStatus = null,
|
|
427
|
+
stage = "download file",
|
|
428
|
+
verbose = false,
|
|
429
|
+
} = {},
|
|
430
|
+
) {
|
|
431
|
+
try {
|
|
432
|
+
emitVerboseStatus(onStatus, verbose, stage, url);
|
|
433
|
+
const response = await fetchResponse(fetchImpl, url, headers);
|
|
434
|
+
if (!response.ok) {
|
|
435
|
+
throw createBootstrapError(
|
|
436
|
+
`Download failed for ${url} (${response.status} ${response.statusText}).`,
|
|
437
|
+
{
|
|
438
|
+
stage,
|
|
439
|
+
url,
|
|
440
|
+
httpStatus: response.status,
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const payload = Buffer.from(await response.arrayBuffer());
|
|
445
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
446
|
+
await fs.writeFile(destination, payload);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (
|
|
449
|
+
!error?.httpStatus &&
|
|
450
|
+
isRetryableFetchError(error) &&
|
|
451
|
+
typeof runProcessImpl === "function"
|
|
452
|
+
) {
|
|
453
|
+
emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
|
|
454
|
+
try {
|
|
455
|
+
await runCurlDownload(runProcessImpl, url, destination, headers);
|
|
456
|
+
return;
|
|
457
|
+
} catch (curlError) {
|
|
458
|
+
throw createBootstrapError(
|
|
459
|
+
renderNetworkFailure({ stage, url, error, curlError }),
|
|
460
|
+
{ stage, url, cause: error },
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (error?.httpStatus) {
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
|
|
468
|
+
stage,
|
|
469
|
+
url,
|
|
470
|
+
cause: error,
|
|
471
|
+
});
|
|
141
472
|
}
|
|
142
|
-
const payload = Buffer.from(await response.arrayBuffer());
|
|
143
|
-
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
144
|
-
await fs.writeFile(destination, payload);
|
|
145
473
|
}
|
|
146
474
|
|
|
147
475
|
async function sha256File(filePath) {
|
|
@@ -187,6 +515,9 @@ export function selectLatestPylonRelease(releases) {
|
|
|
187
515
|
|
|
188
516
|
export async function fetchReleaseMetadata({
|
|
189
517
|
fetchImpl = globalThis.fetch,
|
|
518
|
+
runProcessImpl = runProcess,
|
|
519
|
+
onStatus = null,
|
|
520
|
+
verbose = false,
|
|
190
521
|
apiBase = DEFAULT_RELEASE_API_BASE,
|
|
191
522
|
repo = DEFAULT_RELEASE_REPO,
|
|
192
523
|
version = null,
|
|
@@ -198,7 +529,14 @@ export async function fetchReleaseMetadata({
|
|
|
198
529
|
)}`
|
|
199
530
|
: `/repos/${repo}/releases?per_page=100`;
|
|
200
531
|
const url = `${apiBase.replace(/\/$/, "")}${endpoint}`;
|
|
201
|
-
const payload = await fetchJson(fetchImpl, url
|
|
532
|
+
const payload = await fetchJson(fetchImpl, url, {
|
|
533
|
+
runProcessImpl,
|
|
534
|
+
onStatus,
|
|
535
|
+
verbose,
|
|
536
|
+
stage: normalizedVersion
|
|
537
|
+
? "GitHub tagged release lookup"
|
|
538
|
+
: "GitHub release list lookup",
|
|
539
|
+
});
|
|
202
540
|
return normalizedVersion ? payload : selectLatestPylonRelease(payload);
|
|
203
541
|
}
|
|
204
542
|
|
|
@@ -217,9 +555,15 @@ export function selectReleaseAssets(release, target) {
|
|
|
217
555
|
const checksumAsset = assets.find((asset) => asset.name === checksumName);
|
|
218
556
|
|
|
219
557
|
if (!archiveAsset || !checksumAsset) {
|
|
220
|
-
throw new
|
|
221
|
-
|
|
222
|
-
|
|
558
|
+
throw new MissingReleaseAssetsError({
|
|
559
|
+
tagName,
|
|
560
|
+
version,
|
|
561
|
+
target,
|
|
562
|
+
archiveBasename,
|
|
563
|
+
archiveName,
|
|
564
|
+
checksumName,
|
|
565
|
+
targetCommitish: release?.target_commitish ?? null,
|
|
566
|
+
});
|
|
223
567
|
}
|
|
224
568
|
|
|
225
569
|
return {
|
|
@@ -261,25 +605,447 @@ export function buildInstallPaths(installRoot, version, target) {
|
|
|
261
605
|
};
|
|
262
606
|
}
|
|
263
607
|
|
|
608
|
+
async function readInstallManifest(manifestPath) {
|
|
609
|
+
try {
|
|
610
|
+
const payload = await fs.readFile(manifestPath, "utf8");
|
|
611
|
+
return JSON.parse(payload);
|
|
612
|
+
} catch {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function writeInstallManifest(manifestPath, payload) {
|
|
618
|
+
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
619
|
+
await fs.writeFile(manifestPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function deriveReleaseGitBase(apiBase = DEFAULT_RELEASE_API_BASE) {
|
|
623
|
+
const normalized = (apiBase ?? DEFAULT_RELEASE_API_BASE).replace(/\/$/, "");
|
|
624
|
+
if (normalized === DEFAULT_RELEASE_API_BASE) {
|
|
625
|
+
return DEFAULT_RELEASE_GIT_BASE;
|
|
626
|
+
}
|
|
627
|
+
return normalized.replace(/\/api(?:\/v3)?$/i, "");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function buildReleaseCloneUrl(
|
|
631
|
+
repo,
|
|
632
|
+
{
|
|
633
|
+
apiBase = DEFAULT_RELEASE_API_BASE,
|
|
634
|
+
gitBase = null,
|
|
635
|
+
cloneUrl = null,
|
|
636
|
+
} = {},
|
|
637
|
+
) {
|
|
638
|
+
if (cloneUrl) {
|
|
639
|
+
return cloneUrl;
|
|
640
|
+
}
|
|
641
|
+
return `${(gitBase ?? deriveReleaseGitBase(apiBase)).replace(/\/$/, "")}/${repo}.git`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function withPrependedPath(env, entry) {
|
|
645
|
+
const normalizedEntry = path.resolve(entry);
|
|
646
|
+
const parts = (env.PATH ?? process.env.PATH ?? "")
|
|
647
|
+
.split(path.delimiter)
|
|
648
|
+
.filter(Boolean);
|
|
649
|
+
if (!parts.includes(normalizedEntry)) {
|
|
650
|
+
parts.unshift(normalizedEntry);
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
...env,
|
|
654
|
+
PATH: parts.join(path.delimiter),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function commandExists(command, env = process.env) {
|
|
659
|
+
const pathValue = env.PATH ?? process.env.PATH ?? "";
|
|
660
|
+
const directories = pathValue.split(path.delimiter).filter(Boolean);
|
|
661
|
+
const suffixes =
|
|
662
|
+
process.platform === "win32"
|
|
663
|
+
? (env.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
|
|
664
|
+
.split(";")
|
|
665
|
+
.filter(Boolean)
|
|
666
|
+
: [""];
|
|
667
|
+
|
|
668
|
+
for (const directory of directories) {
|
|
669
|
+
for (const suffix of suffixes) {
|
|
670
|
+
const candidate = path.join(directory, `${command}${suffix}`);
|
|
671
|
+
if (await pathExists(candidate)) {
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function promptForApproval(message) {
|
|
681
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`${message}\nInteractive approval is required, but this terminal is not interactive.`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const terminal = readline.createInterface({
|
|
687
|
+
input: process.stdin,
|
|
688
|
+
output: process.stdout,
|
|
689
|
+
});
|
|
690
|
+
try {
|
|
691
|
+
const answer = await terminal.question(`${message} [y/N] `);
|
|
692
|
+
return /^y(?:es)?$/i.test(answer.trim());
|
|
693
|
+
} finally {
|
|
694
|
+
terminal.close();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function manualSourceBuildCommands(tagName, cloneUrl) {
|
|
699
|
+
return [
|
|
700
|
+
`git clone --depth 1 --branch ${tagName} ${cloneUrl}`,
|
|
701
|
+
"cd openagents",
|
|
702
|
+
"cargo build --release -p pylon -p pylon-tui",
|
|
703
|
+
].join("\n");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function rustInstallCommand() {
|
|
707
|
+
return "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y";
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function ensureRustToolchain({
|
|
711
|
+
target,
|
|
712
|
+
fetchImpl,
|
|
713
|
+
runProcessImpl,
|
|
714
|
+
onStatus,
|
|
715
|
+
promptImpl = promptForApproval,
|
|
716
|
+
commandExistsImpl = commandExists,
|
|
717
|
+
env = process.env,
|
|
718
|
+
rustupInitUrl = DEFAULT_RUSTUP_INIT_URL,
|
|
719
|
+
}) {
|
|
720
|
+
let toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
|
|
721
|
+
const hasCargo = await commandExistsImpl("cargo", toolchainEnv);
|
|
722
|
+
const hasRustc = await commandExistsImpl("rustc", toolchainEnv);
|
|
723
|
+
if (hasCargo && hasRustc) {
|
|
724
|
+
return toolchainEnv;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
emitStatus(
|
|
728
|
+
onStatus,
|
|
729
|
+
"Rust toolchain required for source build",
|
|
730
|
+
`${target.os}-${target.arch}`,
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const approved = await promptImpl(
|
|
734
|
+
`Rust is required to build Pylon from source for ${target.os}-${target.arch}. Install the official Rust toolchain now via rustup?`,
|
|
735
|
+
);
|
|
736
|
+
if (!approved) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`Rust is required to build Pylon from source.\nInstall it manually and rerun:\n${rustInstallCommand()}`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
emitStatus(onStatus, "Installing Rust toolchain", "official rustup installer");
|
|
743
|
+
const scriptPayload = await fetchText(fetchImpl, rustupInitUrl, {
|
|
744
|
+
headers: {
|
|
745
|
+
accept: "text/plain",
|
|
746
|
+
"user-agent": "@openagentsinc/pylon bootstrap",
|
|
747
|
+
},
|
|
748
|
+
runProcessImpl,
|
|
749
|
+
onStatus,
|
|
750
|
+
stage: "Rust toolchain installer download",
|
|
751
|
+
});
|
|
752
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
|
|
753
|
+
const scriptPath = path.join(tempDir, "rustup-init.sh");
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
await fs.writeFile(scriptPath, scriptPayload);
|
|
757
|
+
await fs.chmod(scriptPath, 0o755);
|
|
758
|
+
await runProcessImpl("sh", [scriptPath, "-y"], {
|
|
759
|
+
cwd: tempDir,
|
|
760
|
+
env: toolchainEnv,
|
|
761
|
+
stdio: "inherit",
|
|
762
|
+
});
|
|
763
|
+
} finally {
|
|
764
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
|
|
768
|
+
const cargoInstalled = await commandExistsImpl("cargo", toolchainEnv);
|
|
769
|
+
const rustcInstalled = await commandExistsImpl("rustc", toolchainEnv);
|
|
770
|
+
if (!cargoInstalled || !rustcInstalled) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`Rust install completed, but \`cargo\` and \`rustc\` were not found on PATH.\nInstall them manually and rerun:\n${rustInstallCommand()}`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
emitStatus(
|
|
777
|
+
onStatus,
|
|
778
|
+
"Rust toolchain installed",
|
|
779
|
+
path.join(os.homedir(), ".cargo", "bin"),
|
|
780
|
+
);
|
|
781
|
+
return toolchainEnv;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function installSourceBuild(
|
|
785
|
+
{
|
|
786
|
+
selected,
|
|
787
|
+
options,
|
|
788
|
+
paths,
|
|
789
|
+
target,
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
fetchImpl,
|
|
793
|
+
runProcessImpl,
|
|
794
|
+
onStatus,
|
|
795
|
+
promptImpl = promptForApproval,
|
|
796
|
+
commandExistsImpl = commandExists,
|
|
797
|
+
},
|
|
798
|
+
) {
|
|
799
|
+
const cloneUrl = buildReleaseCloneUrl(options.repo ?? DEFAULT_RELEASE_REPO, {
|
|
800
|
+
apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
|
|
801
|
+
cloneUrl: options.sourceRepoUrl ?? null,
|
|
802
|
+
gitBase: options.gitBase ?? null,
|
|
803
|
+
});
|
|
804
|
+
const manualBuildInstructions = manualSourceBuildCommands(
|
|
805
|
+
selected.tagName,
|
|
806
|
+
cloneUrl,
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
emitStatus(
|
|
810
|
+
onStatus,
|
|
811
|
+
"Prebuilt asset missing; falling back to source build",
|
|
812
|
+
`${selected.tagName} for ${target.os}-${target.arch}`,
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
if (!(await commandExistsImpl("git", process.env))) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`Source build fallback requires \`git\`.\nInstall it and rerun \`npx @openagentsinc/pylon\`, or build manually:\n${manualBuildInstructions}`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const buildEnv = await ensureRustToolchain({
|
|
822
|
+
target,
|
|
823
|
+
fetchImpl,
|
|
824
|
+
runProcessImpl,
|
|
825
|
+
onStatus,
|
|
826
|
+
promptImpl,
|
|
827
|
+
commandExistsImpl,
|
|
828
|
+
});
|
|
829
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-source-build-"));
|
|
830
|
+
const repoDir = path.join(tempDir, "openagents");
|
|
831
|
+
const buildCommand = [
|
|
832
|
+
"cargo",
|
|
833
|
+
"build",
|
|
834
|
+
"--release",
|
|
835
|
+
"-p",
|
|
836
|
+
"pylon",
|
|
837
|
+
"-p",
|
|
838
|
+
"pylon-tui",
|
|
839
|
+
];
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
await fs.mkdir(repoDir, { recursive: true });
|
|
843
|
+
emitStatus(onStatus, "Fetching source checkout", selected.tagName);
|
|
844
|
+
await runProcessImpl("git", ["init"], {
|
|
845
|
+
cwd: repoDir,
|
|
846
|
+
env: buildEnv,
|
|
847
|
+
});
|
|
848
|
+
await runProcessImpl("git", ["remote", "add", "origin", cloneUrl], {
|
|
849
|
+
cwd: repoDir,
|
|
850
|
+
env: buildEnv,
|
|
851
|
+
});
|
|
852
|
+
await runProcessImpl(
|
|
853
|
+
"git",
|
|
854
|
+
[
|
|
855
|
+
"fetch",
|
|
856
|
+
"--depth",
|
|
857
|
+
"1",
|
|
858
|
+
"origin",
|
|
859
|
+
`refs/tags/${selected.tagName}:refs/tags/${selected.tagName}`,
|
|
860
|
+
],
|
|
861
|
+
{
|
|
862
|
+
cwd: repoDir,
|
|
863
|
+
env: buildEnv,
|
|
864
|
+
},
|
|
865
|
+
);
|
|
866
|
+
await runProcessImpl(
|
|
867
|
+
"git",
|
|
868
|
+
["checkout", "--detach", `refs/tags/${selected.tagName}`],
|
|
869
|
+
{
|
|
870
|
+
cwd: repoDir,
|
|
871
|
+
env: buildEnv,
|
|
872
|
+
},
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
const { stdout: commitStdout } = await runProcessImpl(
|
|
876
|
+
"git",
|
|
877
|
+
["rev-parse", "HEAD"],
|
|
878
|
+
{
|
|
879
|
+
cwd: repoDir,
|
|
880
|
+
env: buildEnv,
|
|
881
|
+
},
|
|
882
|
+
);
|
|
883
|
+
const sourceCommit = commitStdout.trim();
|
|
884
|
+
if (
|
|
885
|
+
selected.targetCommitish &&
|
|
886
|
+
/^[a-f0-9]{40}$/i.test(selected.targetCommitish) &&
|
|
887
|
+
sourceCommit !== selected.targetCommitish
|
|
888
|
+
) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`Resolved release tag ${selected.tagName} checked out ${sourceCommit}, expected ${selected.targetCommitish}.`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
emitStatus(
|
|
895
|
+
onStatus,
|
|
896
|
+
"Building Pylon from source",
|
|
897
|
+
`${selected.tagName} (${sourceCommit.slice(0, 12)})`,
|
|
898
|
+
);
|
|
899
|
+
await runProcessImpl(buildCommand[0], buildCommand.slice(1), {
|
|
900
|
+
cwd: repoDir,
|
|
901
|
+
env: buildEnv,
|
|
902
|
+
stdio: "inherit",
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
const builtPylonPath = path.join(repoDir, "target", "release", "pylon");
|
|
906
|
+
const builtPylonTuiPath = path.join(repoDir, "target", "release", "pylon-tui");
|
|
907
|
+
if (!(await pathExists(builtPylonPath)) || !(await pathExists(builtPylonTuiPath))) {
|
|
908
|
+
throw new Error(
|
|
909
|
+
`Source build completed without the expected binaries at ${path.join(repoDir, "target", "release")}.`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
await fs.rm(paths.installDir, { recursive: true, force: true });
|
|
914
|
+
await fs.mkdir(paths.installDir, { recursive: true });
|
|
915
|
+
await Promise.all([
|
|
916
|
+
fs.copyFile(builtPylonPath, paths.pylonPath),
|
|
917
|
+
fs.copyFile(builtPylonTuiPath, paths.pylonTuiPath),
|
|
918
|
+
]);
|
|
919
|
+
await Promise.allSettled([
|
|
920
|
+
fs.chmod(paths.pylonPath, 0o755),
|
|
921
|
+
fs.chmod(paths.pylonTuiPath, 0o755),
|
|
922
|
+
]);
|
|
923
|
+
|
|
924
|
+
await writeInstallManifest(paths.manifestPath, {
|
|
925
|
+
version: selected.version,
|
|
926
|
+
tagName: selected.tagName,
|
|
927
|
+
target,
|
|
928
|
+
installMethod: SOURCE_BUILD_INSTALL_METHOD,
|
|
929
|
+
sourceCloneUrl: cloneUrl,
|
|
930
|
+
sourceCommit,
|
|
931
|
+
sourceTargetCommitish: selected.targetCommitish ?? null,
|
|
932
|
+
buildCommand: buildCommand.join(" "),
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
emitStatus(
|
|
936
|
+
onStatus,
|
|
937
|
+
"Installed source-built binaries",
|
|
938
|
+
`${selected.tagName} for ${target.os}-${target.arch}`,
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
...selected,
|
|
943
|
+
...paths,
|
|
944
|
+
target,
|
|
945
|
+
cached: false,
|
|
946
|
+
expectedSha256: null,
|
|
947
|
+
installMethod: SOURCE_BUILD_INSTALL_METHOD,
|
|
948
|
+
sourceCloneUrl: cloneUrl,
|
|
949
|
+
sourceCommit,
|
|
950
|
+
};
|
|
951
|
+
} catch (error) {
|
|
952
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
953
|
+
throw new Error(
|
|
954
|
+
`${message}\nManual source-build fallback:\n${manualBuildInstructions}`,
|
|
955
|
+
);
|
|
956
|
+
} finally {
|
|
957
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function findLatestCachedInstall(installRoot, target) {
|
|
962
|
+
const normalizedRoot = path.resolve(installRoot ?? defaultInstallRoot());
|
|
963
|
+
const versionsDir = path.join(normalizedRoot, "versions");
|
|
964
|
+
let entries;
|
|
965
|
+
try {
|
|
966
|
+
entries = await fs.readdir(versionsDir, { withFileTypes: true });
|
|
967
|
+
} catch {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const candidates = [];
|
|
972
|
+
for (const entry of entries) {
|
|
973
|
+
if (!entry.isDirectory()) {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
if (!entry.name.endsWith(`-${target.os}-${target.arch}`)) {
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const installDir = path.join(versionsDir, entry.name);
|
|
981
|
+
const manifestPath = path.join(installDir, "install.json");
|
|
982
|
+
const pylonPath = path.join(installDir, "pylon");
|
|
983
|
+
const pylonTuiPath = path.join(installDir, "pylon-tui");
|
|
984
|
+
if (
|
|
985
|
+
!(await pathExists(manifestPath)) ||
|
|
986
|
+
!(await pathExists(pylonPath)) ||
|
|
987
|
+
!(await pathExists(pylonTuiPath))
|
|
988
|
+
) {
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
try {
|
|
993
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
994
|
+
const manifestStat = await fs.stat(manifestPath);
|
|
995
|
+
candidates.push({
|
|
996
|
+
version: manifest.version,
|
|
997
|
+
tagName: manifest.tagName,
|
|
998
|
+
target,
|
|
999
|
+
installRoot: normalizedRoot,
|
|
1000
|
+
versionsDir,
|
|
1001
|
+
downloadsDir: path.join(
|
|
1002
|
+
normalizedRoot,
|
|
1003
|
+
"downloads",
|
|
1004
|
+
`pylon-v${normalizeVersion(manifest.version)}`,
|
|
1005
|
+
),
|
|
1006
|
+
installDir,
|
|
1007
|
+
archiveBasename: entry.name,
|
|
1008
|
+
archivePath: null,
|
|
1009
|
+
checksumPath: null,
|
|
1010
|
+
manifestPath,
|
|
1011
|
+
pylonPath,
|
|
1012
|
+
pylonTuiPath,
|
|
1013
|
+
expectedSha256: manifest.sha256 ?? null,
|
|
1014
|
+
cached: true,
|
|
1015
|
+
mtimeMs: manifestStat.mtimeMs,
|
|
1016
|
+
});
|
|
1017
|
+
} catch {
|
|
1018
|
+
// Ignore malformed cache entries and keep scanning.
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
1023
|
+
return candidates[0] ?? null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
264
1026
|
export async function runProcess(
|
|
265
1027
|
command,
|
|
266
1028
|
args,
|
|
267
|
-
{ cwd, env } = {},
|
|
1029
|
+
{ cwd, env, stdio = ["ignore", "pipe", "pipe"] } = {},
|
|
268
1030
|
) {
|
|
269
1031
|
return new Promise((resolve, reject) => {
|
|
270
1032
|
const child = spawn(command, args, {
|
|
271
1033
|
cwd,
|
|
272
1034
|
env,
|
|
273
|
-
stdio
|
|
1035
|
+
stdio,
|
|
274
1036
|
});
|
|
275
1037
|
let stdout = "";
|
|
276
1038
|
let stderr = "";
|
|
277
|
-
child.stdout
|
|
278
|
-
stdout
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
1039
|
+
if (child.stdout) {
|
|
1040
|
+
child.stdout.on("data", (chunk) => {
|
|
1041
|
+
stdout += chunk.toString();
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
if (child.stderr) {
|
|
1045
|
+
child.stderr.on("data", (chunk) => {
|
|
1046
|
+
stderr += chunk.toString();
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
283
1049
|
child.on("error", (error) => {
|
|
284
1050
|
reject(
|
|
285
1051
|
new Error(
|
|
@@ -343,44 +1109,172 @@ async function runPylonJson(pylonPath, args, options, runProcessImpl) {
|
|
|
343
1109
|
}
|
|
344
1110
|
}
|
|
345
1111
|
|
|
1112
|
+
function isUnsupportedGemmaDiagnoseError(error) {
|
|
1113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1114
|
+
return (
|
|
1115
|
+
message.includes("unknown gemma command: diagnose") ||
|
|
1116
|
+
message.includes("unknown command: diagnose")
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
346
1120
|
export async function ensureReleaseInstall(
|
|
347
1121
|
options = {},
|
|
348
1122
|
{
|
|
349
1123
|
fetchImpl = globalThis.fetch,
|
|
350
1124
|
runProcessImpl = runProcess,
|
|
1125
|
+
onStatus = null,
|
|
1126
|
+
promptImpl = promptForApproval,
|
|
1127
|
+
commandExistsImpl = commandExists,
|
|
351
1128
|
} = {},
|
|
352
1129
|
) {
|
|
353
1130
|
if (typeof fetchImpl !== "function") {
|
|
354
1131
|
throw new Error("A global fetch implementation is required to bootstrap Pylon.");
|
|
355
1132
|
}
|
|
356
1133
|
|
|
1134
|
+
emitStatus(
|
|
1135
|
+
onStatus,
|
|
1136
|
+
"Resolving latest tagged Pylon release",
|
|
1137
|
+
options.version ? `requested ${options.version}` : "default release track",
|
|
1138
|
+
);
|
|
357
1139
|
const target = resolvePlatformTarget(options.platform, options.arch);
|
|
358
1140
|
const installRoot = options.installRoot ?? defaultInstallRoot();
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
1141
|
+
if (options.version) {
|
|
1142
|
+
const requestedPaths = buildInstallPaths(installRoot, options.version, target);
|
|
1143
|
+
const requestedCached =
|
|
1144
|
+
(await pathExists(requestedPaths.pylonPath)) &&
|
|
1145
|
+
(await pathExists(requestedPaths.pylonTuiPath));
|
|
1146
|
+
if (requestedCached) {
|
|
1147
|
+
emitStatus(
|
|
1148
|
+
onStatus,
|
|
1149
|
+
"Using cached standalone binaries",
|
|
1150
|
+
`pylon-v${normalizeVersion(options.version)} for ${target.os}-${target.arch}`,
|
|
1151
|
+
);
|
|
1152
|
+
return {
|
|
1153
|
+
version: normalizeVersion(options.version),
|
|
1154
|
+
tagName: `pylon-v${normalizeVersion(options.version)}`,
|
|
1155
|
+
target,
|
|
1156
|
+
...requestedPaths,
|
|
1157
|
+
expectedSha256: await fs
|
|
1158
|
+
.readFile(requestedPaths.manifestPath, "utf8")
|
|
1159
|
+
.then((payload) => JSON.parse(payload).sha256)
|
|
1160
|
+
.catch(() => null),
|
|
1161
|
+
cached: true,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
let release;
|
|
1167
|
+
try {
|
|
1168
|
+
release = await fetchReleaseMetadata({
|
|
1169
|
+
fetchImpl,
|
|
1170
|
+
runProcessImpl,
|
|
1171
|
+
onStatus,
|
|
1172
|
+
verbose: Boolean(options.verbose),
|
|
1173
|
+
apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
|
|
1174
|
+
repo: options.repo ?? DEFAULT_RELEASE_REPO,
|
|
1175
|
+
version: options.version ?? null,
|
|
1176
|
+
});
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
const cached = !options.version
|
|
1179
|
+
? await findLatestCachedInstall(installRoot, target)
|
|
1180
|
+
: null;
|
|
1181
|
+
if (cached) {
|
|
1182
|
+
emitStatus(
|
|
1183
|
+
onStatus,
|
|
1184
|
+
"Using cached standalone binaries",
|
|
1185
|
+
`release lookup failed; falling back to ${cached.tagName}`,
|
|
1186
|
+
);
|
|
1187
|
+
return cached;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const requestedVersion = normalizeRequestedVersion(options.version);
|
|
1191
|
+
const recovery = [
|
|
1192
|
+
error instanceof Error ? error.message : String(error),
|
|
1193
|
+
`Retry with verbose diagnostics: npx @openagentsinc/pylon --verbose${requestedVersion ? ` --version ${requestedVersion}` : ""}`,
|
|
1194
|
+
"Tagged Pylon releases: https://github.com/OpenAgentsInc/openagents/releases?q=pylon-v",
|
|
1195
|
+
];
|
|
1196
|
+
if (requestedVersion) {
|
|
1197
|
+
recovery.push(
|
|
1198
|
+
`Expected asset: ${buildAssetNames(requestedVersion, target).archiveName}`,
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
throw createBootstrapError(recovery.join("\n"), { cause: error });
|
|
1202
|
+
}
|
|
1203
|
+
let selected;
|
|
1204
|
+
let missingAssetsError = null;
|
|
1205
|
+
try {
|
|
1206
|
+
selected = selectReleaseAssets(release, target);
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
if (!(error instanceof MissingReleaseAssetsError)) {
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
missingAssetsError = error;
|
|
1212
|
+
selected = {
|
|
1213
|
+
tagName: error.tagName,
|
|
1214
|
+
version: error.version,
|
|
1215
|
+
archiveBasename: error.archiveBasename,
|
|
1216
|
+
targetCommitish: error.targetCommitish,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
366
1219
|
const paths = buildInstallPaths(installRoot, selected.version, target);
|
|
1220
|
+
const manifest = await readInstallManifest(paths.manifestPath);
|
|
367
1221
|
|
|
368
1222
|
const binariesPresent =
|
|
369
1223
|
(await pathExists(paths.pylonPath)) && (await pathExists(paths.pylonTuiPath));
|
|
370
1224
|
if (binariesPresent) {
|
|
1225
|
+
const installMethod =
|
|
1226
|
+
manifest?.installMethod ??
|
|
1227
|
+
(missingAssetsError
|
|
1228
|
+
? SOURCE_BUILD_INSTALL_METHOD
|
|
1229
|
+
: RELEASE_ASSET_INSTALL_METHOD);
|
|
1230
|
+
emitStatus(
|
|
1231
|
+
onStatus,
|
|
1232
|
+
installMethod === SOURCE_BUILD_INSTALL_METHOD
|
|
1233
|
+
? "Using cached source-built binaries"
|
|
1234
|
+
: "Using cached standalone binaries",
|
|
1235
|
+
`${selected.tagName} for ${target.os}-${target.arch}`,
|
|
1236
|
+
);
|
|
371
1237
|
return {
|
|
372
1238
|
...selected,
|
|
373
1239
|
...paths,
|
|
374
1240
|
target,
|
|
375
|
-
expectedSha256:
|
|
376
|
-
.readFile(paths.manifestPath, "utf8")
|
|
377
|
-
.then((payload) => JSON.parse(payload).sha256)
|
|
378
|
-
.catch(() => null),
|
|
1241
|
+
expectedSha256: manifest?.sha256 ?? null,
|
|
379
1242
|
cached: true,
|
|
1243
|
+
installMethod,
|
|
1244
|
+
sourceCloneUrl: manifest?.sourceCloneUrl ?? null,
|
|
1245
|
+
sourceCommit: manifest?.sourceCommit ?? null,
|
|
380
1246
|
};
|
|
381
1247
|
}
|
|
382
1248
|
|
|
383
|
-
|
|
1249
|
+
if (missingAssetsError) {
|
|
1250
|
+
return installSourceBuild(
|
|
1251
|
+
{
|
|
1252
|
+
selected,
|
|
1253
|
+
options,
|
|
1254
|
+
paths,
|
|
1255
|
+
target,
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
fetchImpl,
|
|
1259
|
+
runProcessImpl,
|
|
1260
|
+
onStatus,
|
|
1261
|
+
promptImpl,
|
|
1262
|
+
commandExistsImpl,
|
|
1263
|
+
},
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
emitStatus(
|
|
1268
|
+
onStatus,
|
|
1269
|
+
"Fetching release checksum",
|
|
1270
|
+
selected.checksumAsset.name,
|
|
1271
|
+
);
|
|
1272
|
+
const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url, {
|
|
1273
|
+
runProcessImpl,
|
|
1274
|
+
onStatus,
|
|
1275
|
+
verbose: Boolean(options.verbose),
|
|
1276
|
+
stage: "Release checksum download",
|
|
1277
|
+
});
|
|
384
1278
|
const expectedSha256 = parseSha256File(
|
|
385
1279
|
checksumPayload,
|
|
386
1280
|
selected.archiveAsset.name,
|
|
@@ -393,7 +1287,17 @@ export async function ensureReleaseInstall(
|
|
|
393
1287
|
archiveReady = (await sha256File(paths.archivePath)) === expectedSha256;
|
|
394
1288
|
}
|
|
395
1289
|
if (!archiveReady) {
|
|
396
|
-
|
|
1290
|
+
emitStatus(
|
|
1291
|
+
onStatus,
|
|
1292
|
+
"Downloading standalone binaries",
|
|
1293
|
+
selected.archiveAsset.name,
|
|
1294
|
+
);
|
|
1295
|
+
await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
|
|
1296
|
+
runProcessImpl,
|
|
1297
|
+
onStatus,
|
|
1298
|
+
verbose: Boolean(options.verbose),
|
|
1299
|
+
stage: "Release archive download",
|
|
1300
|
+
});
|
|
397
1301
|
}
|
|
398
1302
|
|
|
399
1303
|
const actualSha256 = await sha256File(paths.archivePath);
|
|
@@ -403,6 +1307,11 @@ export async function ensureReleaseInstall(
|
|
|
403
1307
|
);
|
|
404
1308
|
}
|
|
405
1309
|
|
|
1310
|
+
emitStatus(
|
|
1311
|
+
onStatus,
|
|
1312
|
+
"Extracting standalone binaries",
|
|
1313
|
+
paths.installDir,
|
|
1314
|
+
);
|
|
406
1315
|
await fs.rm(paths.installDir, { recursive: true, force: true });
|
|
407
1316
|
await extractArchive(paths.archivePath, paths.versionsDir, runProcessImpl);
|
|
408
1317
|
|
|
@@ -417,19 +1326,19 @@ export async function ensureReleaseInstall(
|
|
|
417
1326
|
fs.chmod(paths.pylonTuiPath, 0o755),
|
|
418
1327
|
]);
|
|
419
1328
|
|
|
420
|
-
await
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
1329
|
+
await writeInstallManifest(paths.manifestPath, {
|
|
1330
|
+
version: selected.version,
|
|
1331
|
+
tagName: selected.tagName,
|
|
1332
|
+
target,
|
|
1333
|
+
archive: selected.archiveAsset.name,
|
|
1334
|
+
sha256: expectedSha256,
|
|
1335
|
+
installMethod: RELEASE_ASSET_INSTALL_METHOD,
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
emitStatus(
|
|
1339
|
+
onStatus,
|
|
1340
|
+
"Installed standalone binaries",
|
|
1341
|
+
`${selected.tagName} for ${target.os}-${target.arch}`,
|
|
433
1342
|
);
|
|
434
1343
|
|
|
435
1344
|
return {
|
|
@@ -445,6 +1354,7 @@ export async function bootstrapInstalledPylon(
|
|
|
445
1354
|
options,
|
|
446
1355
|
{
|
|
447
1356
|
runProcessImpl = runProcess,
|
|
1357
|
+
onStatus = null,
|
|
448
1358
|
} = {},
|
|
449
1359
|
) {
|
|
450
1360
|
const pylonPath = path.resolve(options.pylonPath);
|
|
@@ -455,14 +1365,18 @@ export async function bootstrapInstalledPylon(
|
|
|
455
1365
|
const diagnosticMaxOutputTokens =
|
|
456
1366
|
options.diagnosticMaxOutputTokens ?? DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS;
|
|
457
1367
|
|
|
1368
|
+
emitStatus(onStatus, "Verifying Pylon binary", path.basename(pylonPath));
|
|
458
1369
|
await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
|
|
1370
|
+
emitStatus(onStatus, "Bootstrapping local Pylon identity");
|
|
459
1371
|
const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
|
|
1372
|
+
emitStatus(onStatus, "Checking runtime health");
|
|
460
1373
|
const status = await runPylonJson(
|
|
461
1374
|
pylonPath,
|
|
462
1375
|
["status", "--json"],
|
|
463
1376
|
options,
|
|
464
1377
|
runProcessImpl,
|
|
465
1378
|
);
|
|
1379
|
+
emitStatus(onStatus, "Scanning for local models");
|
|
466
1380
|
const inventory = await runPylonJson(
|
|
467
1381
|
pylonPath,
|
|
468
1382
|
["inventory", "--json"],
|
|
@@ -472,31 +1386,52 @@ export async function bootstrapInstalledPylon(
|
|
|
472
1386
|
|
|
473
1387
|
let download = null;
|
|
474
1388
|
if (!options.skipModelDownload) {
|
|
1389
|
+
emitStatus(onStatus, "Downloading curated model bundle", model);
|
|
475
1390
|
download = await runPylonJson(
|
|
476
1391
|
pylonPath,
|
|
477
1392
|
["gemma", "download", model, "--json"],
|
|
478
1393
|
options,
|
|
479
1394
|
runProcessImpl,
|
|
480
1395
|
);
|
|
1396
|
+
} else {
|
|
1397
|
+
emitStatus(
|
|
1398
|
+
onStatus,
|
|
1399
|
+
"Skipping optional curated GGUF cache",
|
|
1400
|
+
"use --download-curated-cache to prefetch Hugging Face weights",
|
|
1401
|
+
);
|
|
481
1402
|
}
|
|
482
1403
|
|
|
483
1404
|
let diagnostic = null;
|
|
484
1405
|
if (!options.skipDiagnostics) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1406
|
+
emitStatus(onStatus, "Running first-run diagnostic", model);
|
|
1407
|
+
try {
|
|
1408
|
+
diagnostic = await runPylonJson(
|
|
1409
|
+
pylonPath,
|
|
1410
|
+
[
|
|
1411
|
+
"gemma",
|
|
1412
|
+
"diagnose",
|
|
1413
|
+
model,
|
|
1414
|
+
"--max-output-tokens",
|
|
1415
|
+
String(diagnosticMaxOutputTokens),
|
|
1416
|
+
"--repeats",
|
|
1417
|
+
String(diagnosticRepeats),
|
|
1418
|
+
"--json",
|
|
1419
|
+
],
|
|
1420
|
+
options,
|
|
1421
|
+
runProcessImpl,
|
|
1422
|
+
);
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
if (!isUnsupportedGemmaDiagnoseError(error)) {
|
|
1425
|
+
throw error;
|
|
1426
|
+
}
|
|
1427
|
+
emitStatus(
|
|
1428
|
+
onStatus,
|
|
1429
|
+
"Skipping first-run diagnostic",
|
|
1430
|
+
"installed Pylon release does not expose gemma diagnose",
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
} else {
|
|
1434
|
+
emitStatus(onStatus, "Skipping first-run diagnostic", model);
|
|
500
1435
|
}
|
|
501
1436
|
|
|
502
1437
|
const diagnosticResult =
|
|
@@ -504,6 +1439,14 @@ export async function bootstrapInstalledPylon(
|
|
|
504
1439
|
diagnostic?.results?.[0] ??
|
|
505
1440
|
null;
|
|
506
1441
|
|
|
1442
|
+
emitStatus(
|
|
1443
|
+
onStatus,
|
|
1444
|
+
"Bootstrap complete",
|
|
1445
|
+
diagnosticResult?.status
|
|
1446
|
+
? `diagnostic ${diagnosticResult.status}`
|
|
1447
|
+
: "smoke path complete",
|
|
1448
|
+
);
|
|
1449
|
+
|
|
507
1450
|
return {
|
|
508
1451
|
version: options.version,
|
|
509
1452
|
tagName: options.tagName ?? `pylon-v${options.version}`,
|
|
@@ -525,6 +1468,21 @@ export async function bootstrapInstalledPylon(
|
|
|
525
1468
|
};
|
|
526
1469
|
}
|
|
527
1470
|
|
|
1471
|
+
export async function launchInstalledPylonTui(
|
|
1472
|
+
options,
|
|
1473
|
+
{
|
|
1474
|
+
runProcessImpl = runProcess,
|
|
1475
|
+
onStatus = null,
|
|
1476
|
+
} = {},
|
|
1477
|
+
) {
|
|
1478
|
+
const pylonTuiPath = path.resolve(options.pylonTuiPath);
|
|
1479
|
+
emitStatus(onStatus, "Opening Pylon terminal UI", path.basename(pylonTuiPath));
|
|
1480
|
+
return runProcessImpl(pylonTuiPath, [], {
|
|
1481
|
+
env: buildPylonEnv(options),
|
|
1482
|
+
stdio: "inherit",
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
|
|
528
1486
|
export function renderBootstrapSummary(summary) {
|
|
529
1487
|
const lines = [
|
|
530
1488
|
`Pylon release: ${summary.version} (${summary.target.os}-${summary.target.arch})`,
|
|
@@ -551,6 +1509,16 @@ export function renderBootstrapSummary(summary) {
|
|
|
551
1509
|
lines.push(
|
|
552
1510
|
`Model download (${summary.model}): ${result?.status ?? "completed"}`,
|
|
553
1511
|
);
|
|
1512
|
+
} else {
|
|
1513
|
+
lines.push(
|
|
1514
|
+
"Curated GGUF cache: skipped by default (pass --download-curated-cache to prefetch optional Hugging Face weights)",
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const localGemmaError =
|
|
1519
|
+
summary.status?.snapshot?.availability?.local_gemma?.last_error ?? null;
|
|
1520
|
+
if (localGemmaError) {
|
|
1521
|
+
lines.push(`Local runtime note: ${localGemmaError}`);
|
|
554
1522
|
}
|
|
555
1523
|
|
|
556
1524
|
if (summary.diagnostic) {
|