@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 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, stream first-run status updates in the terminal, and open the Pylon
5
- terminal UI without Cargo.
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-rc3
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagentsinc/pylon",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Bootstrap the standalone OpenAgents Pylon release asset and run first-run smoke checks.",
5
5
  "type": "module",
6
6
  "bin": {
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. Verify its
74
- checksum, cache the binaries locally, run the first-run smoke path, and then
75
- open the Pylon terminal UI by default with live status updates.
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 download and diagnose.
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 Skip pylon gemma 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: false,
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(fetchImpl, url) {
116
- const response = await fetchImpl(url, {
117
- headers: requestHeaders(),
118
- });
119
- if (!response.ok) {
120
- throw new Error(
121
- `GitHub release lookup failed for ${url} (${response.status} ${response.statusText}).`,
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(fetchImpl, url) {
128
- const response = await fetchImpl(url, {
129
- headers: requestHeaders(),
130
- });
131
- if (!response.ok) {
132
- throw new Error(
133
- `Download failed for ${url} (${response.status} ${response.statusText}).`,
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(fetchImpl, url, destination) {
140
- const response = await fetchImpl(url, {
141
- headers: requestHeaders(),
142
- });
143
- if (!response.ok) {
144
- throw new Error(
145
- `Download failed for ${url} (${response.status} ${response.statusText}).`,
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 Error(
227
- `Release ${tagName} is missing ${archiveName} or ${checksumName}.`,
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
- const release = await fetchReleaseMetadata({
384
- fetchImpl,
385
- apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
386
- repo: options.repo ?? DEFAULT_RELEASE_REPO,
387
- version: options.version ?? null,
388
- });
389
- const selected = selectReleaseAssets(release, target);
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
- "Using cached standalone binaries",
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: await fs
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 fs.writeFile(
465
- paths.manifestPath,
466
- `${JSON.stringify(
467
- {
468
- version: selected.version,
469
- tagName: selected.tagName,
470
- target,
471
- archive: selected.archiveAsset.name,
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(onStatus, "Skipping curated model download", model);
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) {