@openagentsinc/pylon 0.1.0
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 +34 -0
- package/bin/pylon.js +8 -0
- package/package.json +29 -0
- package/src/cli.js +169 -0
- package/src/index.js +585 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# `@openagentsinc/pylon`
|
|
2
|
+
|
|
3
|
+
Bootstrap the latest tagged standalone `Pylon` release asset from GitHub
|
|
4
|
+
Releases and run the first-run smoke path without Cargo.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx @openagentsinc/pylon
|
|
10
|
+
npx @openagentsinc/pylon --version 0.1.0
|
|
11
|
+
npx @openagentsinc/pylon --model gemma-4-e2b --diagnostic-repeats 2
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The launcher:
|
|
15
|
+
|
|
16
|
+
- resolves the latest tagged `pylon-v...` release by default, or a specific
|
|
17
|
+
tagged `Pylon` version when `--version` is provided
|
|
18
|
+
- resolves the correct `pylon-v<version>-<os>-<arch>.tar.gz` asset for the
|
|
19
|
+
current machine
|
|
20
|
+
- downloads the archive and published SHA-256 checksum
|
|
21
|
+
- verifies the checksum before extracting
|
|
22
|
+
- caches the unpacked binaries under `~/.openagents/pylon/bootstrap/`
|
|
23
|
+
- runs `pylon --help`, `init`, `status --json`, and `inventory --json`
|
|
24
|
+
- runs `pylon gemma download <model>`
|
|
25
|
+
- runs `pylon gemma diagnose <model> --json`
|
|
26
|
+
|
|
27
|
+
## Publish
|
|
28
|
+
|
|
29
|
+
Publish directly from this package directory:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd packages/pylon-bootstrap
|
|
33
|
+
npm publish
|
|
34
|
+
```
|
package/bin/pylon.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openagentsinc/pylon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bootstrap the standalone OpenAgents Pylon release asset and run first-run smoke checks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pylon": "./bin/pylon.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/OpenAgentsInc/openagents.git",
|
|
20
|
+
"directory": "packages/pylon-bootstrap"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "bun test"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"license": "Apache-2.0"
|
|
29
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS,
|
|
3
|
+
DEFAULT_DIAGNOSTIC_REPEATS,
|
|
4
|
+
DEFAULT_MODEL_ID,
|
|
5
|
+
DEFAULT_RELEASE_API_BASE,
|
|
6
|
+
DEFAULT_RELEASE_REPO,
|
|
7
|
+
bootstrapInstalledPylon,
|
|
8
|
+
ensureReleaseInstall,
|
|
9
|
+
renderBootstrapSummary,
|
|
10
|
+
} from "./index.js";
|
|
11
|
+
|
|
12
|
+
function parseIntegerFlag(value, label) {
|
|
13
|
+
const parsed = Number.parseInt(value, 10);
|
|
14
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
15
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
16
|
+
}
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usage() {
|
|
21
|
+
return `Usage:
|
|
22
|
+
npx @openagentsinc/pylon [options]
|
|
23
|
+
|
|
24
|
+
Description:
|
|
25
|
+
Download the latest tagged standalone Pylon release asset for this machine,
|
|
26
|
+
or a specific tagged Pylon version when --version is set. Verify its
|
|
27
|
+
checksum, cache the binaries locally, and run the first-run smoke path.
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--version <x.y.z> Resolve a specific Pylon release.
|
|
31
|
+
--install-root <path> Override the launcher cache/install root.
|
|
32
|
+
--config-path <path> Override OPENAGENTS_PYLON_CONFIG_PATH.
|
|
33
|
+
--pylon-home <path> Override OPENAGENTS_PYLON_HOME.
|
|
34
|
+
--model <model-id> Model to download and diagnose.
|
|
35
|
+
Default: ${DEFAULT_MODEL_ID}
|
|
36
|
+
--diagnostic-repeats <n> Repeat count for pylon gemma diagnose.
|
|
37
|
+
Default: ${DEFAULT_DIAGNOSTIC_REPEATS}
|
|
38
|
+
--diagnostic-max-output-tokens <n> Max output tokens for diagnostics.
|
|
39
|
+
Default: ${DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS}
|
|
40
|
+
--skip-model-download Skip pylon gemma download.
|
|
41
|
+
--skip-diagnostics Skip pylon gemma diagnose.
|
|
42
|
+
--json Emit a machine-readable JSON summary.
|
|
43
|
+
|
|
44
|
+
Test and maintainer options:
|
|
45
|
+
--repo <owner/name> Override the GitHub release repo.
|
|
46
|
+
--api-base <url> Override the GitHub API base URL.
|
|
47
|
+
-h, --help Show this help text.
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseArgs(argv) {
|
|
52
|
+
const options = {
|
|
53
|
+
version: null,
|
|
54
|
+
repo: DEFAULT_RELEASE_REPO,
|
|
55
|
+
apiBase: DEFAULT_RELEASE_API_BASE,
|
|
56
|
+
installRoot: null,
|
|
57
|
+
configPath: null,
|
|
58
|
+
pylonHome: null,
|
|
59
|
+
model: DEFAULT_MODEL_ID,
|
|
60
|
+
diagnosticRepeats: DEFAULT_DIAGNOSTIC_REPEATS,
|
|
61
|
+
diagnosticMaxOutputTokens: DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS,
|
|
62
|
+
skipModelDownload: false,
|
|
63
|
+
skipDiagnostics: false,
|
|
64
|
+
json: false,
|
|
65
|
+
help: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
69
|
+
const arg = argv[index];
|
|
70
|
+
switch (arg) {
|
|
71
|
+
case "--version":
|
|
72
|
+
options.version = argv[++index];
|
|
73
|
+
if (!options.version) {
|
|
74
|
+
throw new Error("--version requires a value.");
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
case "--install-root":
|
|
78
|
+
options.installRoot = argv[++index];
|
|
79
|
+
if (!options.installRoot) {
|
|
80
|
+
throw new Error("--install-root requires a value.");
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
case "--config-path":
|
|
84
|
+
options.configPath = argv[++index];
|
|
85
|
+
if (!options.configPath) {
|
|
86
|
+
throw new Error("--config-path requires a value.");
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
case "--pylon-home":
|
|
90
|
+
options.pylonHome = argv[++index];
|
|
91
|
+
if (!options.pylonHome) {
|
|
92
|
+
throw new Error("--pylon-home requires a value.");
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
case "--model":
|
|
96
|
+
options.model = argv[++index];
|
|
97
|
+
if (!options.model) {
|
|
98
|
+
throw new Error("--model requires a value.");
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case "--diagnostic-repeats":
|
|
102
|
+
options.diagnosticRepeats = parseIntegerFlag(
|
|
103
|
+
argv[++index],
|
|
104
|
+
"--diagnostic-repeats",
|
|
105
|
+
);
|
|
106
|
+
break;
|
|
107
|
+
case "--diagnostic-max-output-tokens":
|
|
108
|
+
options.diagnosticMaxOutputTokens = parseIntegerFlag(
|
|
109
|
+
argv[++index],
|
|
110
|
+
"--diagnostic-max-output-tokens",
|
|
111
|
+
);
|
|
112
|
+
break;
|
|
113
|
+
case "--skip-model-download":
|
|
114
|
+
options.skipModelDownload = true;
|
|
115
|
+
break;
|
|
116
|
+
case "--skip-diagnostics":
|
|
117
|
+
options.skipDiagnostics = true;
|
|
118
|
+
break;
|
|
119
|
+
case "--json":
|
|
120
|
+
options.json = true;
|
|
121
|
+
break;
|
|
122
|
+
case "--repo":
|
|
123
|
+
options.repo = argv[++index];
|
|
124
|
+
if (!options.repo) {
|
|
125
|
+
throw new Error("--repo requires a value.");
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
case "--api-base":
|
|
129
|
+
options.apiBase = argv[++index];
|
|
130
|
+
if (!options.apiBase) {
|
|
131
|
+
throw new Error("--api-base requires a value.");
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
case "-h":
|
|
135
|
+
case "--help":
|
|
136
|
+
options.help = true;
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return options;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
147
|
+
const options = parseArgs(argv);
|
|
148
|
+
if (options.help) {
|
|
149
|
+
console.log(usage());
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const install = await ensureReleaseInstall(options, dependencies);
|
|
154
|
+
const summary = await bootstrapInstalledPylon(
|
|
155
|
+
{
|
|
156
|
+
...options,
|
|
157
|
+
...install,
|
|
158
|
+
version: install.version,
|
|
159
|
+
},
|
|
160
|
+
dependencies,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (options.json) {
|
|
164
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
165
|
+
} else {
|
|
166
|
+
console.log(renderBootstrapSummary(summary));
|
|
167
|
+
}
|
|
168
|
+
return summary;
|
|
169
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_RELEASE_REPO = "OpenAgentsInc/openagents";
|
|
9
|
+
export const DEFAULT_RELEASE_API_BASE = "https://api.github.com";
|
|
10
|
+
export const DEFAULT_MODEL_ID = "gemma-4-e4b";
|
|
11
|
+
export const DEFAULT_DIAGNOSTIC_REPEATS = 3;
|
|
12
|
+
export const DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS = 96;
|
|
13
|
+
const PYLON_RELEASE_TAG_PREFIX = "pylon-v";
|
|
14
|
+
|
|
15
|
+
function normalizeVersion(value) {
|
|
16
|
+
return value.replace(/^pylon-v/, "").replace(/^v/, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function pathExists(value) {
|
|
20
|
+
try {
|
|
21
|
+
await fs.access(value);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function defaultInstallRoot() {
|
|
29
|
+
return path.join(os.homedir(), ".openagents", "pylon", "bootstrap");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requestHeaders() {
|
|
33
|
+
const headers = {
|
|
34
|
+
accept: "application/vnd.github+json",
|
|
35
|
+
"user-agent": "@openagentsinc/pylon bootstrap",
|
|
36
|
+
};
|
|
37
|
+
if (process.env.GITHUB_TOKEN) {
|
|
38
|
+
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
39
|
+
}
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolvePlatformTarget(
|
|
44
|
+
platform = process.platform,
|
|
45
|
+
arch = process.arch,
|
|
46
|
+
) {
|
|
47
|
+
const osLabel =
|
|
48
|
+
{
|
|
49
|
+
darwin: "darwin",
|
|
50
|
+
linux: "linux",
|
|
51
|
+
}[platform] ?? null;
|
|
52
|
+
if (!osLabel) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Unsupported platform \`${platform}\`. The npm launcher only supports darwin and linux in v1.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const archLabel =
|
|
59
|
+
{
|
|
60
|
+
arm64: "arm64",
|
|
61
|
+
aarch64: "arm64",
|
|
62
|
+
x64: "x86_64",
|
|
63
|
+
x86_64: "x86_64",
|
|
64
|
+
}[arch] ?? null;
|
|
65
|
+
if (!archLabel) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Unsupported architecture \`${arch}\`. The npm launcher only supports arm64 and x64 in v1.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { os: osLabel, arch: archLabel };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildArchiveBasename(version, target) {
|
|
75
|
+
const normalizedVersion = normalizeVersion(version);
|
|
76
|
+
return `pylon-v${normalizedVersion}-${target.os}-${target.arch}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildAssetNames(version, target) {
|
|
80
|
+
const archiveBasename = buildArchiveBasename(version, target);
|
|
81
|
+
return {
|
|
82
|
+
archiveBasename,
|
|
83
|
+
archiveName: `${archiveBasename}.tar.gz`,
|
|
84
|
+
checksumName: `${archiveBasename}.tar.gz.sha256`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseSha256File(payload, expectedAssetName) {
|
|
89
|
+
const match = payload
|
|
90
|
+
.trim()
|
|
91
|
+
.match(/^([a-fA-F0-9]{64})\s+\*?([^\r\n]+)$/m);
|
|
92
|
+
if (!match) {
|
|
93
|
+
throw new Error("Release checksum file did not contain a valid SHA-256 line.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const [, sha256, filename] = match;
|
|
97
|
+
if (
|
|
98
|
+
expectedAssetName &&
|
|
99
|
+
path.basename(filename.trim()) !== path.basename(expectedAssetName)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Release checksum file was for \`${filename.trim()}\`, expected \`${expectedAssetName}\`.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return sha256.toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function fetchJson(fetchImpl, url) {
|
|
110
|
+
const response = await fetchImpl(url, {
|
|
111
|
+
headers: requestHeaders(),
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`GitHub release lookup failed for ${url} (${response.status} ${response.statusText}).`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return response.json();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fetchText(fetchImpl, url) {
|
|
122
|
+
const response = await fetchImpl(url, {
|
|
123
|
+
headers: requestHeaders(),
|
|
124
|
+
});
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Download failed for ${url} (${response.status} ${response.statusText}).`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return response.text();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function downloadFile(fetchImpl, url, destination) {
|
|
134
|
+
const response = await fetchImpl(url, {
|
|
135
|
+
headers: requestHeaders(),
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Download failed for ${url} (${response.status} ${response.statusText}).`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const payload = Buffer.from(await response.arrayBuffer());
|
|
143
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
144
|
+
await fs.writeFile(destination, payload);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function sha256File(filePath) {
|
|
148
|
+
const hash = createHash("sha256");
|
|
149
|
+
const stream = createReadStream(filePath);
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
152
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
153
|
+
stream.on("error", reject);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function normalizeRequestedVersion(value) {
|
|
158
|
+
if (!value || value === "latest") {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return normalizeVersion(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isPylonReleaseTag(tagName) {
|
|
165
|
+
return (
|
|
166
|
+
typeof tagName === "string" &&
|
|
167
|
+
tagName.startsWith(PYLON_RELEASE_TAG_PREFIX)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function selectLatestPylonRelease(releases) {
|
|
172
|
+
if (!Array.isArray(releases)) {
|
|
173
|
+
throw new Error("GitHub release lookup did not return a release list.");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const release = releases.find(
|
|
177
|
+
(candidate) => !candidate?.draft && isPylonReleaseTag(candidate?.tag_name),
|
|
178
|
+
);
|
|
179
|
+
if (!release) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`GitHub release lookup did not find any published ${PYLON_RELEASE_TAG_PREFIX} releases.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return release;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function fetchReleaseMetadata({
|
|
189
|
+
fetchImpl = globalThis.fetch,
|
|
190
|
+
apiBase = DEFAULT_RELEASE_API_BASE,
|
|
191
|
+
repo = DEFAULT_RELEASE_REPO,
|
|
192
|
+
version = null,
|
|
193
|
+
} = {}) {
|
|
194
|
+
const normalizedVersion = normalizeRequestedVersion(version);
|
|
195
|
+
const endpoint = normalizedVersion
|
|
196
|
+
? `/repos/${repo}/releases/tags/${encodeURIComponent(
|
|
197
|
+
`${PYLON_RELEASE_TAG_PREFIX}${normalizedVersion}`,
|
|
198
|
+
)}`
|
|
199
|
+
: `/repos/${repo}/releases?per_page=100`;
|
|
200
|
+
const url = `${apiBase.replace(/\/$/, "")}${endpoint}`;
|
|
201
|
+
const payload = await fetchJson(fetchImpl, url);
|
|
202
|
+
return normalizedVersion ? payload : selectLatestPylonRelease(payload);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function selectReleaseAssets(release, target) {
|
|
206
|
+
const tagName = release?.tag_name;
|
|
207
|
+
if (!tagName) {
|
|
208
|
+
throw new Error("GitHub release metadata did not include a tag name.");
|
|
209
|
+
}
|
|
210
|
+
const version = normalizeVersion(tagName);
|
|
211
|
+
const { archiveBasename, archiveName, checksumName } = buildAssetNames(
|
|
212
|
+
version,
|
|
213
|
+
target,
|
|
214
|
+
);
|
|
215
|
+
const assets = Array.isArray(release.assets) ? release.assets : [];
|
|
216
|
+
const archiveAsset = assets.find((asset) => asset.name === archiveName);
|
|
217
|
+
const checksumAsset = assets.find((asset) => asset.name === checksumName);
|
|
218
|
+
|
|
219
|
+
if (!archiveAsset || !checksumAsset) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Release ${tagName} is missing ${archiveName} or ${checksumName}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
tagName,
|
|
227
|
+
version,
|
|
228
|
+
archiveBasename,
|
|
229
|
+
archiveAsset: {
|
|
230
|
+
name: archiveAsset.name,
|
|
231
|
+
url: archiveAsset.browser_download_url,
|
|
232
|
+
},
|
|
233
|
+
checksumAsset: {
|
|
234
|
+
name: checksumAsset.name,
|
|
235
|
+
url: checksumAsset.browser_download_url,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function buildInstallPaths(installRoot, version, target) {
|
|
241
|
+
const { archiveBasename, archiveName, checksumName } = buildAssetNames(
|
|
242
|
+
version,
|
|
243
|
+
target,
|
|
244
|
+
);
|
|
245
|
+
const normalizedRoot = path.resolve(installRoot ?? defaultInstallRoot());
|
|
246
|
+
const versionsDir = path.join(normalizedRoot, "versions");
|
|
247
|
+
const downloadsDir = path.join(normalizedRoot, "downloads", `pylon-v${normalizeVersion(version)}`);
|
|
248
|
+
const installDir = path.join(versionsDir, archiveBasename);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
installRoot: normalizedRoot,
|
|
252
|
+
versionsDir,
|
|
253
|
+
downloadsDir,
|
|
254
|
+
installDir,
|
|
255
|
+
archiveBasename,
|
|
256
|
+
archivePath: path.join(downloadsDir, archiveName),
|
|
257
|
+
checksumPath: path.join(downloadsDir, checksumName),
|
|
258
|
+
manifestPath: path.join(installDir, "install.json"),
|
|
259
|
+
pylonPath: path.join(installDir, "pylon"),
|
|
260
|
+
pylonTuiPath: path.join(installDir, "pylon-tui"),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function runProcess(
|
|
265
|
+
command,
|
|
266
|
+
args,
|
|
267
|
+
{ cwd, env } = {},
|
|
268
|
+
) {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const child = spawn(command, args, {
|
|
271
|
+
cwd,
|
|
272
|
+
env,
|
|
273
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
274
|
+
});
|
|
275
|
+
let stdout = "";
|
|
276
|
+
let stderr = "";
|
|
277
|
+
child.stdout.on("data", (chunk) => {
|
|
278
|
+
stdout += chunk.toString();
|
|
279
|
+
});
|
|
280
|
+
child.stderr.on("data", (chunk) => {
|
|
281
|
+
stderr += chunk.toString();
|
|
282
|
+
});
|
|
283
|
+
child.on("error", (error) => {
|
|
284
|
+
reject(
|
|
285
|
+
new Error(
|
|
286
|
+
`Failed to start ${command}: ${error instanceof Error ? error.message : String(error)}`,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
child.on("close", (code) => {
|
|
291
|
+
if (code === 0) {
|
|
292
|
+
resolve({ stdout, stderr });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
reject(
|
|
296
|
+
new Error(
|
|
297
|
+
`${command} ${args.join(" ")} exited with code ${code}${
|
|
298
|
+
stderr.trim() ? `: ${stderr.trim()}` : ""
|
|
299
|
+
}`,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function extractArchive(archivePath, destinationDir, runProcessImpl) {
|
|
307
|
+
await fs.mkdir(destinationDir, { recursive: true });
|
|
308
|
+
await runProcessImpl("tar", ["-xzf", archivePath, "-C", destinationDir]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildPylonEnv({ configPath, pylonHome } = {}) {
|
|
312
|
+
const env = { ...process.env };
|
|
313
|
+
if (configPath) {
|
|
314
|
+
env.OPENAGENTS_PYLON_CONFIG_PATH = path.resolve(configPath);
|
|
315
|
+
}
|
|
316
|
+
if (pylonHome) {
|
|
317
|
+
env.OPENAGENTS_PYLON_HOME = path.resolve(pylonHome);
|
|
318
|
+
}
|
|
319
|
+
return env;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function runPylonCommand(pylonPath, args, options, runProcessImpl) {
|
|
323
|
+
return runProcessImpl(pylonPath, args, {
|
|
324
|
+
env: buildPylonEnv(options),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function runPylonJson(pylonPath, args, options, runProcessImpl) {
|
|
329
|
+
const { stdout } = await runPylonCommand(
|
|
330
|
+
pylonPath,
|
|
331
|
+
args,
|
|
332
|
+
options,
|
|
333
|
+
runProcessImpl,
|
|
334
|
+
);
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(stdout);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Pylon returned invalid JSON for \`${[path.basename(pylonPath), ...args].join(" ")}\`: ${
|
|
340
|
+
error instanceof Error ? error.message : String(error)
|
|
341
|
+
}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function ensureReleaseInstall(
|
|
347
|
+
options = {},
|
|
348
|
+
{
|
|
349
|
+
fetchImpl = globalThis.fetch,
|
|
350
|
+
runProcessImpl = runProcess,
|
|
351
|
+
} = {},
|
|
352
|
+
) {
|
|
353
|
+
if (typeof fetchImpl !== "function") {
|
|
354
|
+
throw new Error("A global fetch implementation is required to bootstrap Pylon.");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const target = resolvePlatformTarget(options.platform, options.arch);
|
|
358
|
+
const installRoot = options.installRoot ?? defaultInstallRoot();
|
|
359
|
+
const release = await fetchReleaseMetadata({
|
|
360
|
+
fetchImpl,
|
|
361
|
+
apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
|
|
362
|
+
repo: options.repo ?? DEFAULT_RELEASE_REPO,
|
|
363
|
+
version: options.version ?? null,
|
|
364
|
+
});
|
|
365
|
+
const selected = selectReleaseAssets(release, target);
|
|
366
|
+
const paths = buildInstallPaths(installRoot, selected.version, target);
|
|
367
|
+
|
|
368
|
+
const binariesPresent =
|
|
369
|
+
(await pathExists(paths.pylonPath)) && (await pathExists(paths.pylonTuiPath));
|
|
370
|
+
if (binariesPresent) {
|
|
371
|
+
return {
|
|
372
|
+
...selected,
|
|
373
|
+
...paths,
|
|
374
|
+
target,
|
|
375
|
+
expectedSha256: await fs
|
|
376
|
+
.readFile(paths.manifestPath, "utf8")
|
|
377
|
+
.then((payload) => JSON.parse(payload).sha256)
|
|
378
|
+
.catch(() => null),
|
|
379
|
+
cached: true,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url);
|
|
384
|
+
const expectedSha256 = parseSha256File(
|
|
385
|
+
checksumPayload,
|
|
386
|
+
selected.archiveAsset.name,
|
|
387
|
+
);
|
|
388
|
+
await fs.mkdir(paths.downloadsDir, { recursive: true });
|
|
389
|
+
await fs.writeFile(paths.checksumPath, `${checksumPayload.trim()}\n`);
|
|
390
|
+
|
|
391
|
+
let archiveReady = false;
|
|
392
|
+
if (await pathExists(paths.archivePath)) {
|
|
393
|
+
archiveReady = (await sha256File(paths.archivePath)) === expectedSha256;
|
|
394
|
+
}
|
|
395
|
+
if (!archiveReady) {
|
|
396
|
+
await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const actualSha256 = await sha256File(paths.archivePath);
|
|
400
|
+
if (actualSha256 !== expectedSha256) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`SHA-256 verification failed for ${selected.archiveAsset.name}: expected ${expectedSha256}, got ${actualSha256}.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await fs.rm(paths.installDir, { recursive: true, force: true });
|
|
407
|
+
await extractArchive(paths.archivePath, paths.versionsDir, runProcessImpl);
|
|
408
|
+
|
|
409
|
+
if (!(await pathExists(paths.pylonPath)) || !(await pathExists(paths.pylonTuiPath))) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Release archive extracted without the expected pylon binaries at ${paths.installDir}.`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await Promise.allSettled([
|
|
416
|
+
fs.chmod(paths.pylonPath, 0o755),
|
|
417
|
+
fs.chmod(paths.pylonTuiPath, 0o755),
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
await fs.writeFile(
|
|
421
|
+
paths.manifestPath,
|
|
422
|
+
`${JSON.stringify(
|
|
423
|
+
{
|
|
424
|
+
version: selected.version,
|
|
425
|
+
tagName: selected.tagName,
|
|
426
|
+
target,
|
|
427
|
+
archive: selected.archiveAsset.name,
|
|
428
|
+
sha256: expectedSha256,
|
|
429
|
+
},
|
|
430
|
+
null,
|
|
431
|
+
2,
|
|
432
|
+
)}\n`,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
...selected,
|
|
437
|
+
...paths,
|
|
438
|
+
target,
|
|
439
|
+
expectedSha256,
|
|
440
|
+
cached: false,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function bootstrapInstalledPylon(
|
|
445
|
+
options,
|
|
446
|
+
{
|
|
447
|
+
runProcessImpl = runProcess,
|
|
448
|
+
} = {},
|
|
449
|
+
) {
|
|
450
|
+
const pylonPath = path.resolve(options.pylonPath);
|
|
451
|
+
const pylonTuiPath = path.resolve(options.pylonTuiPath);
|
|
452
|
+
const model = options.model ?? DEFAULT_MODEL_ID;
|
|
453
|
+
const diagnosticRepeats =
|
|
454
|
+
options.diagnosticRepeats ?? DEFAULT_DIAGNOSTIC_REPEATS;
|
|
455
|
+
const diagnosticMaxOutputTokens =
|
|
456
|
+
options.diagnosticMaxOutputTokens ?? DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS;
|
|
457
|
+
|
|
458
|
+
await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
|
|
459
|
+
const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
|
|
460
|
+
const status = await runPylonJson(
|
|
461
|
+
pylonPath,
|
|
462
|
+
["status", "--json"],
|
|
463
|
+
options,
|
|
464
|
+
runProcessImpl,
|
|
465
|
+
);
|
|
466
|
+
const inventory = await runPylonJson(
|
|
467
|
+
pylonPath,
|
|
468
|
+
["inventory", "--json"],
|
|
469
|
+
options,
|
|
470
|
+
runProcessImpl,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
let download = null;
|
|
474
|
+
if (!options.skipModelDownload) {
|
|
475
|
+
download = await runPylonJson(
|
|
476
|
+
pylonPath,
|
|
477
|
+
["gemma", "download", model, "--json"],
|
|
478
|
+
options,
|
|
479
|
+
runProcessImpl,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let diagnostic = null;
|
|
484
|
+
if (!options.skipDiagnostics) {
|
|
485
|
+
diagnostic = await runPylonJson(
|
|
486
|
+
pylonPath,
|
|
487
|
+
[
|
|
488
|
+
"gemma",
|
|
489
|
+
"diagnose",
|
|
490
|
+
model,
|
|
491
|
+
"--max-output-tokens",
|
|
492
|
+
String(diagnosticMaxOutputTokens),
|
|
493
|
+
"--repeats",
|
|
494
|
+
String(diagnosticRepeats),
|
|
495
|
+
"--json",
|
|
496
|
+
],
|
|
497
|
+
options,
|
|
498
|
+
runProcessImpl,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const diagnosticResult =
|
|
503
|
+
diagnostic?.results?.find((result) => result.model_id === model) ??
|
|
504
|
+
diagnostic?.results?.[0] ??
|
|
505
|
+
null;
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
version: options.version,
|
|
509
|
+
tagName: options.tagName ?? `pylon-v${options.version}`,
|
|
510
|
+
target: options.target,
|
|
511
|
+
cached: Boolean(options.cached),
|
|
512
|
+
binaries: {
|
|
513
|
+
pylon: pylonPath,
|
|
514
|
+
pylonTui: pylonTuiPath,
|
|
515
|
+
},
|
|
516
|
+
configPath: init?.config_path ?? options.configPath ?? null,
|
|
517
|
+
pylonHome: options.pylonHome ? path.resolve(options.pylonHome) : null,
|
|
518
|
+
init,
|
|
519
|
+
status,
|
|
520
|
+
inventory,
|
|
521
|
+
model,
|
|
522
|
+
download,
|
|
523
|
+
diagnostic,
|
|
524
|
+
diagnosticResult,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function renderBootstrapSummary(summary) {
|
|
529
|
+
const lines = [
|
|
530
|
+
`Pylon release: ${summary.version} (${summary.target.os}-${summary.target.arch})`,
|
|
531
|
+
`Archive source: ${summary.tagName}`,
|
|
532
|
+
`Installed from cache: ${summary.cached ? "yes" : "no"}`,
|
|
533
|
+
`Pylon binary: ${summary.binaries.pylon}`,
|
|
534
|
+
`Pylon TUI: ${summary.binaries.pylonTui}`,
|
|
535
|
+
`Config path: ${summary.configPath ?? "unknown"}`,
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
const statusState =
|
|
539
|
+
summary.status?.snapshot?.runtime?.authoritative_status ?? "unknown";
|
|
540
|
+
const inventoryRows = Array.isArray(summary.inventory?.rows)
|
|
541
|
+
? summary.inventory.rows.length
|
|
542
|
+
: 0;
|
|
543
|
+
lines.push(`Status state: ${statusState}`);
|
|
544
|
+
lines.push(`Inventory rows: ${inventoryRows}`);
|
|
545
|
+
|
|
546
|
+
if (summary.download) {
|
|
547
|
+
const result =
|
|
548
|
+
summary.download?.results?.find((row) => row.model_id === summary.model) ??
|
|
549
|
+
summary.download?.results?.[0] ??
|
|
550
|
+
null;
|
|
551
|
+
lines.push(
|
|
552
|
+
`Model download (${summary.model}): ${result?.status ?? "completed"}`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (summary.diagnostic) {
|
|
557
|
+
const result = summary.diagnosticResult;
|
|
558
|
+
lines.push(
|
|
559
|
+
`Diagnostic (${summary.model}): ${result?.status ?? "unknown"}`,
|
|
560
|
+
);
|
|
561
|
+
if (result?.receipt?.mean_total_s != null) {
|
|
562
|
+
lines.push(
|
|
563
|
+
`Mean total latency: ${result.receipt.mean_total_s.toFixed(3)}s`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (result?.receipt?.mean_ttft_s != null) {
|
|
567
|
+
lines.push(
|
|
568
|
+
`Mean first token latency: ${result.receipt.mean_ttft_s.toFixed(3)}s`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (result?.receipt?.mean_decode_tok_s != null) {
|
|
572
|
+
lines.push(
|
|
573
|
+
`Mean decode throughput: ${result.receipt.mean_decode_tok_s.toFixed(2)} tok/s`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (summary.diagnostic.report_path) {
|
|
577
|
+
lines.push(`Diagnostic report: ${summary.diagnostic.report_path}`);
|
|
578
|
+
}
|
|
579
|
+
if (result?.reason) {
|
|
580
|
+
lines.push(`Diagnostic note: ${result.reason}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return lines.join("\n");
|
|
585
|
+
}
|