@openagentsinc/pylon 0.1.1 → 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 +16 -5
- package/package.json +1 -1
- package/src/cli.js +21 -6
- package/src/index.js +935 -62
package/README.md
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# `@openagentsinc/pylon`
|
|
2
2
|
|
|
3
3
|
Bootstrap the latest tagged standalone `Pylon` release asset from GitHub
|
|
4
|
-
Releases,
|
|
5
|
-
|
|
4
|
+
Releases, fall back to a deterministic source build when no matching asset
|
|
5
|
+
exists for the local platform, stream first-run status updates in the terminal,
|
|
6
|
+
and open the Pylon terminal UI without Cargo when prebuilt binaries are
|
|
7
|
+
available.
|
|
6
8
|
|
|
7
9
|
## Usage
|
|
8
10
|
|
|
9
11
|
```bash
|
|
10
12
|
npx @openagentsinc/pylon
|
|
11
|
-
npx @openagentsinc/pylon --version 0.0.1-
|
|
13
|
+
npx @openagentsinc/pylon --version 0.0.1-rc4
|
|
12
14
|
npx @openagentsinc/pylon --no-launch
|
|
13
|
-
npx @openagentsinc/pylon --model gemma-4-e2b --diagnostic-repeats 2
|
|
15
|
+
npx @openagentsinc/pylon --download-curated-cache --model gemma-4-e2b --diagnostic-repeats 2
|
|
16
|
+
npx @openagentsinc/pylon --verbose
|
|
14
17
|
```
|
|
15
18
|
|
|
16
19
|
The launcher:
|
|
@@ -19,14 +22,22 @@ The launcher:
|
|
|
19
22
|
tagged `Pylon` version when `--version` is provided
|
|
20
23
|
- resolves the correct `pylon-v<version>-<os>-<arch>.tar.gz` asset for the
|
|
21
24
|
current machine
|
|
25
|
+
- falls back to the exact tagged source checkout and builds `pylon` plus
|
|
26
|
+
`pylon-tui` locally when no matching release asset exists for the machine
|
|
27
|
+
- prompts before installing the Rust toolchain via `rustup` if a source build
|
|
28
|
+
is needed and `cargo` / `rustc` are missing
|
|
22
29
|
- downloads the archive and published SHA-256 checksum
|
|
23
30
|
- verifies the checksum before extracting
|
|
24
31
|
- caches the unpacked binaries under `~/.openagents/pylon/bootstrap/`
|
|
25
32
|
- prints status lines such as release resolution, runtime checks, and local
|
|
26
33
|
model scanning while it runs
|
|
27
34
|
- runs `pylon --help`, `init`, `status --json`, and `inventory --json`
|
|
28
|
-
- runs `pylon gemma download <model>`
|
|
29
35
|
- runs `pylon gemma diagnose <model> --json`
|
|
36
|
+
- only runs `pylon gemma download <model>` when `--download-curated-cache` is
|
|
37
|
+
set, because the optional GGUF cache does not satisfy the sellable runtime by
|
|
38
|
+
itself
|
|
39
|
+
- falls back to `curl` for release metadata and asset downloads when the Node
|
|
40
|
+
fetch path fails in constrained network contexts
|
|
30
41
|
- opens `pylon-tui` by default after the smoke path unless `--no-launch` is set
|
|
31
42
|
|
|
32
43
|
## Publish
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -70,24 +70,31 @@ export function usage() {
|
|
|
70
70
|
|
|
71
71
|
Description:
|
|
72
72
|
Download the latest tagged standalone Pylon release asset for this machine,
|
|
73
|
-
or a specific tagged Pylon version when --version is set.
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
or a specific tagged Pylon version when --version is set. If no matching
|
|
74
|
+
asset exists for the local platform, fetch the exact tagged source checkout
|
|
75
|
+
and build it locally instead. Cache the binaries, run the first-run smoke
|
|
76
|
+
path, and then open the Pylon terminal UI by default with live status
|
|
77
|
+
updates.
|
|
76
78
|
|
|
77
79
|
Options:
|
|
78
80
|
--version <x.y.z> Resolve a specific Pylon release.
|
|
79
81
|
--install-root <path> Override the launcher cache/install root.
|
|
80
82
|
--config-path <path> Override OPENAGENTS_PYLON_CONFIG_PATH.
|
|
81
83
|
--pylon-home <path> Override OPENAGENTS_PYLON_HOME.
|
|
82
|
-
--model <model-id> Model to
|
|
84
|
+
--model <model-id> Model to diagnose, and optionally
|
|
85
|
+
prefetch into the local GGUF cache.
|
|
83
86
|
Default: ${DEFAULT_MODEL_ID}
|
|
87
|
+
--download-curated-cache Prefetch the optional Hugging Face GGUF
|
|
88
|
+
cache before opening the TUI.
|
|
84
89
|
--diagnostic-repeats <n> Repeat count for pylon gemma diagnose.
|
|
85
90
|
Default: ${DEFAULT_DIAGNOSTIC_REPEATS}
|
|
86
91
|
--diagnostic-max-output-tokens <n> Max output tokens for diagnostics.
|
|
87
92
|
Default: ${DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS}
|
|
88
|
-
--skip-model-download
|
|
93
|
+
--skip-model-download Keep the curated GGUF cache skipped.
|
|
89
94
|
--skip-diagnostics Skip pylon gemma diagnose.
|
|
90
95
|
--no-launch Do not open pylon-tui after bootstrap.
|
|
96
|
+
--verbose Print extra network and recovery detail.
|
|
97
|
+
--debug-network Alias for --verbose.
|
|
91
98
|
--json Emit a machine-readable JSON summary.
|
|
92
99
|
|
|
93
100
|
Test and maintainer options:
|
|
@@ -108,9 +115,10 @@ export function parseArgs(argv) {
|
|
|
108
115
|
model: DEFAULT_MODEL_ID,
|
|
109
116
|
diagnosticRepeats: DEFAULT_DIAGNOSTIC_REPEATS,
|
|
110
117
|
diagnosticMaxOutputTokens: DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS,
|
|
111
|
-
skipModelDownload:
|
|
118
|
+
skipModelDownload: true,
|
|
112
119
|
skipDiagnostics: false,
|
|
113
120
|
noLaunch: false,
|
|
121
|
+
verbose: false,
|
|
114
122
|
json: false,
|
|
115
123
|
help: false,
|
|
116
124
|
};
|
|
@@ -148,6 +156,9 @@ export function parseArgs(argv) {
|
|
|
148
156
|
throw new Error("--model requires a value.");
|
|
149
157
|
}
|
|
150
158
|
break;
|
|
159
|
+
case "--download-curated-cache":
|
|
160
|
+
options.skipModelDownload = false;
|
|
161
|
+
break;
|
|
151
162
|
case "--diagnostic-repeats":
|
|
152
163
|
options.diagnosticRepeats = parseIntegerFlag(
|
|
153
164
|
argv[++index],
|
|
@@ -169,6 +180,10 @@ export function parseArgs(argv) {
|
|
|
169
180
|
case "--no-launch":
|
|
170
181
|
options.noLaunch = true;
|
|
171
182
|
break;
|
|
183
|
+
case "--verbose":
|
|
184
|
+
case "--debug-network":
|
|
185
|
+
options.verbose = true;
|
|
186
|
+
break;
|
|
172
187
|
case "--json":
|
|
173
188
|
options.json = true;
|
|
174
189
|
break;
|
package/src/index.js
CHANGED
|
@@ -4,13 +4,19 @@ 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";
|
|
14
20
|
|
|
15
21
|
function emitStatus(onStatus, message, detail = null) {
|
|
16
22
|
if (typeof onStatus === "function") {
|
|
@@ -18,10 +24,22 @@ function emitStatus(onStatus, message, detail = null) {
|
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
function emitVerboseStatus(onStatus, verbose, message, detail = null) {
|
|
28
|
+
if (verbose) {
|
|
29
|
+
emitStatus(onStatus, message, detail);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
function normalizeVersion(value) {
|
|
22
34
|
return value.replace(/^pylon-v/, "").replace(/^v/, "");
|
|
23
35
|
}
|
|
24
36
|
|
|
37
|
+
function createBootstrapError(message, context = {}) {
|
|
38
|
+
const error = new Error(message);
|
|
39
|
+
Object.assign(error, context);
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
async function pathExists(value) {
|
|
26
44
|
try {
|
|
27
45
|
await fs.access(value);
|
|
@@ -35,6 +53,28 @@ function defaultInstallRoot() {
|
|
|
35
53
|
return path.join(os.homedir(), ".openagents", "pylon", "bootstrap");
|
|
36
54
|
}
|
|
37
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
|
+
|
|
38
78
|
function requestHeaders() {
|
|
39
79
|
const headers = {
|
|
40
80
|
accept: "application/vnd.github+json",
|
|
@@ -46,6 +86,166 @@ function requestHeaders() {
|
|
|
46
86
|
return headers;
|
|
47
87
|
}
|
|
48
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
|
+
|
|
49
249
|
export function resolvePlatformTarget(
|
|
50
250
|
platform = process.platform,
|
|
51
251
|
arch = process.arch,
|
|
@@ -112,42 +312,164 @@ export function parseSha256File(payload, expectedAssetName) {
|
|
|
112
312
|
return sha256.toLowerCase();
|
|
113
313
|
}
|
|
114
314
|
|
|
115
|
-
async function fetchJson(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
});
|
|
123
364
|
}
|
|
124
|
-
return response.json();
|
|
125
365
|
}
|
|
126
366
|
|
|
127
|
-
async function fetchText(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
});
|
|
135
416
|
}
|
|
136
|
-
return response.text();
|
|
137
417
|
}
|
|
138
418
|
|
|
139
|
-
async function downloadFile(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
});
|
|
147
472
|
}
|
|
148
|
-
const payload = Buffer.from(await response.arrayBuffer());
|
|
149
|
-
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
150
|
-
await fs.writeFile(destination, payload);
|
|
151
473
|
}
|
|
152
474
|
|
|
153
475
|
async function sha256File(filePath) {
|
|
@@ -193,6 +515,9 @@ export function selectLatestPylonRelease(releases) {
|
|
|
193
515
|
|
|
194
516
|
export async function fetchReleaseMetadata({
|
|
195
517
|
fetchImpl = globalThis.fetch,
|
|
518
|
+
runProcessImpl = runProcess,
|
|
519
|
+
onStatus = null,
|
|
520
|
+
verbose = false,
|
|
196
521
|
apiBase = DEFAULT_RELEASE_API_BASE,
|
|
197
522
|
repo = DEFAULT_RELEASE_REPO,
|
|
198
523
|
version = null,
|
|
@@ -204,7 +529,14 @@ export async function fetchReleaseMetadata({
|
|
|
204
529
|
)}`
|
|
205
530
|
: `/repos/${repo}/releases?per_page=100`;
|
|
206
531
|
const url = `${apiBase.replace(/\/$/, "")}${endpoint}`;
|
|
207
|
-
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
|
+
});
|
|
208
540
|
return normalizedVersion ? payload : selectLatestPylonRelease(payload);
|
|
209
541
|
}
|
|
210
542
|
|
|
@@ -223,9 +555,15 @@ export function selectReleaseAssets(release, target) {
|
|
|
223
555
|
const checksumAsset = assets.find((asset) => asset.name === checksumName);
|
|
224
556
|
|
|
225
557
|
if (!archiveAsset || !checksumAsset) {
|
|
226
|
-
throw new
|
|
227
|
-
|
|
228
|
-
|
|
558
|
+
throw new MissingReleaseAssetsError({
|
|
559
|
+
tagName,
|
|
560
|
+
version,
|
|
561
|
+
target,
|
|
562
|
+
archiveBasename,
|
|
563
|
+
archiveName,
|
|
564
|
+
checksumName,
|
|
565
|
+
targetCommitish: release?.target_commitish ?? null,
|
|
566
|
+
});
|
|
229
567
|
}
|
|
230
568
|
|
|
231
569
|
return {
|
|
@@ -267,6 +605,424 @@ export function buildInstallPaths(installRoot, version, target) {
|
|
|
267
605
|
};
|
|
268
606
|
}
|
|
269
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
|
+
|
|
270
1026
|
export async function runProcess(
|
|
271
1027
|
command,
|
|
272
1028
|
args,
|
|
@@ -367,6 +1123,8 @@ export async function ensureReleaseInstall(
|
|
|
367
1123
|
fetchImpl = globalThis.fetch,
|
|
368
1124
|
runProcessImpl = runProcess,
|
|
369
1125
|
onStatus = null,
|
|
1126
|
+
promptImpl = promptForApproval,
|
|
1127
|
+
commandExistsImpl = commandExists,
|
|
370
1128
|
} = {},
|
|
371
1129
|
) {
|
|
372
1130
|
if (typeof fetchImpl !== "function") {
|
|
@@ -380,41 +1138,143 @@ export async function ensureReleaseInstall(
|
|
|
380
1138
|
);
|
|
381
1139
|
const target = resolvePlatformTarget(options.platform, options.arch);
|
|
382
1140
|
const installRoot = options.installRoot ?? defaultInstallRoot();
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
}
|
|
390
1219
|
const paths = buildInstallPaths(installRoot, selected.version, target);
|
|
1220
|
+
const manifest = await readInstallManifest(paths.manifestPath);
|
|
391
1221
|
|
|
392
1222
|
const binariesPresent =
|
|
393
1223
|
(await pathExists(paths.pylonPath)) && (await pathExists(paths.pylonTuiPath));
|
|
394
1224
|
if (binariesPresent) {
|
|
1225
|
+
const installMethod =
|
|
1226
|
+
manifest?.installMethod ??
|
|
1227
|
+
(missingAssetsError
|
|
1228
|
+
? SOURCE_BUILD_INSTALL_METHOD
|
|
1229
|
+
: RELEASE_ASSET_INSTALL_METHOD);
|
|
395
1230
|
emitStatus(
|
|
396
1231
|
onStatus,
|
|
397
|
-
|
|
1232
|
+
installMethod === SOURCE_BUILD_INSTALL_METHOD
|
|
1233
|
+
? "Using cached source-built binaries"
|
|
1234
|
+
: "Using cached standalone binaries",
|
|
398
1235
|
`${selected.tagName} for ${target.os}-${target.arch}`,
|
|
399
1236
|
);
|
|
400
1237
|
return {
|
|
401
1238
|
...selected,
|
|
402
1239
|
...paths,
|
|
403
1240
|
target,
|
|
404
|
-
expectedSha256:
|
|
405
|
-
.readFile(paths.manifestPath, "utf8")
|
|
406
|
-
.then((payload) => JSON.parse(payload).sha256)
|
|
407
|
-
.catch(() => null),
|
|
1241
|
+
expectedSha256: manifest?.sha256 ?? null,
|
|
408
1242
|
cached: true,
|
|
1243
|
+
installMethod,
|
|
1244
|
+
sourceCloneUrl: manifest?.sourceCloneUrl ?? null,
|
|
1245
|
+
sourceCommit: manifest?.sourceCommit ?? null,
|
|
409
1246
|
};
|
|
410
1247
|
}
|
|
411
1248
|
|
|
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
|
+
|
|
412
1267
|
emitStatus(
|
|
413
1268
|
onStatus,
|
|
414
1269
|
"Fetching release checksum",
|
|
415
1270
|
selected.checksumAsset.name,
|
|
416
1271
|
);
|
|
417
|
-
const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url
|
|
1272
|
+
const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url, {
|
|
1273
|
+
runProcessImpl,
|
|
1274
|
+
onStatus,
|
|
1275
|
+
verbose: Boolean(options.verbose),
|
|
1276
|
+
stage: "Release checksum download",
|
|
1277
|
+
});
|
|
418
1278
|
const expectedSha256 = parseSha256File(
|
|
419
1279
|
checksumPayload,
|
|
420
1280
|
selected.archiveAsset.name,
|
|
@@ -432,7 +1292,12 @@ export async function ensureReleaseInstall(
|
|
|
432
1292
|
"Downloading standalone binaries",
|
|
433
1293
|
selected.archiveAsset.name,
|
|
434
1294
|
);
|
|
435
|
-
await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath
|
|
1295
|
+
await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
|
|
1296
|
+
runProcessImpl,
|
|
1297
|
+
onStatus,
|
|
1298
|
+
verbose: Boolean(options.verbose),
|
|
1299
|
+
stage: "Release archive download",
|
|
1300
|
+
});
|
|
436
1301
|
}
|
|
437
1302
|
|
|
438
1303
|
const actualSha256 = await sha256File(paths.archivePath);
|
|
@@ -461,20 +1326,14 @@ export async function ensureReleaseInstall(
|
|
|
461
1326
|
fs.chmod(paths.pylonTuiPath, 0o755),
|
|
462
1327
|
]);
|
|
463
1328
|
|
|
464
|
-
await
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
sha256: expectedSha256,
|
|
473
|
-
},
|
|
474
|
-
null,
|
|
475
|
-
2,
|
|
476
|
-
)}\n`,
|
|
477
|
-
);
|
|
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
|
+
});
|
|
478
1337
|
|
|
479
1338
|
emitStatus(
|
|
480
1339
|
onStatus,
|
|
@@ -535,7 +1394,11 @@ export async function bootstrapInstalledPylon(
|
|
|
535
1394
|
runProcessImpl,
|
|
536
1395
|
);
|
|
537
1396
|
} else {
|
|
538
|
-
emitStatus(
|
|
1397
|
+
emitStatus(
|
|
1398
|
+
onStatus,
|
|
1399
|
+
"Skipping optional curated GGUF cache",
|
|
1400
|
+
"use --download-curated-cache to prefetch Hugging Face weights",
|
|
1401
|
+
);
|
|
539
1402
|
}
|
|
540
1403
|
|
|
541
1404
|
let diagnostic = null;
|
|
@@ -646,6 +1509,16 @@ export function renderBootstrapSummary(summary) {
|
|
|
646
1509
|
lines.push(
|
|
647
1510
|
`Model download (${summary.model}): ${result?.status ?? "completed"}`,
|
|
648
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}`);
|
|
649
1522
|
}
|
|
650
1523
|
|
|
651
1524
|
if (summary.diagnostic) {
|