@melaya/runner 1.1.31 → 1.1.33

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/dist/cli.js CHANGED
@@ -52,26 +52,33 @@ async function main() {
52
52
  .parse(process.argv);
53
53
  const opts = program.opts();
54
54
  console.log(BANNER);
55
- // Detect Python
55
+ // Detect Python. Fast path: a supported host interpreter (3.10–3.12). If the
56
+ // host only has an unsupported/newer Python (Homebrew now ships 3.13/3.14 as
57
+ // `python3`) — or none at all — we PROVISION a managed CPython 3.12 via uv, so
58
+ // the runner needs zero Python setup on the box. `venv` can't do this (it only
59
+ // wraps an existing interpreter); uv downloads a standalone one.
60
+ let python;
56
61
  const py = await findPython();
57
- if (!py.ok) {
58
- if (py.foundVersion) {
59
- console.log(chalk.red(` ${py.foundVersion} found, but Melaya needs Python 3.${PY_MIN_MINOR}–3.${PY_MAX_MINOR}`));
60
- console.log(chalk.gray(" Python 3.13+ has no wheels yet for the assistant's Python deps (agentscope + native"));
61
- console.log(chalk.gray(" extensions), so the runner's venv can't build and every assistant turn fails."));
62
- console.log(chalk.gray(" Install a supported Python, then restart the runner:"));
62
+ if (py.ok) {
63
+ python = py.cmd;
64
+ console.log(chalk.green(` Python: ${python} (3.${py.minor})`));
65
+ }
66
+ else {
67
+ console.log(chalk.yellow(` ⚠ No supported host Python (need 3.${PY_MIN_MINOR}–3.${PY_MAX_MINOR}` +
68
+ `${py.foundVersion ? `; host has ${py.foundVersion}` : ""}) — provisioning one automatically`));
69
+ const { provisionPython } = await import("./uvBootstrap.js");
70
+ const provisioned = await provisionPython(`3.${PY_MAX_MINOR}`, (m) => console.log(chalk.gray(` ${m}`)));
71
+ if (!provisioned) {
72
+ console.log(chalk.red(` ✗ Could not auto-provision Python 3.${PY_MAX_MINOR}`));
73
+ console.log(chalk.gray(" Install a supported Python manually, then restart the runner:"));
63
74
  console.log(chalk.gray(" macOS: brew install python@3.12"));
64
75
  console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
65
76
  console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
77
+ process.exit(1);
66
78
  }
67
- else {
68
- console.log(chalk.red(" Python not found in PATH"));
69
- console.log(chalk.gray(" Install Python 3.12 from https://python.org"));
70
- }
71
- process.exit(1);
79
+ python = provisioned;
80
+ console.log(chalk.green(` Python: ${python} (auto-provisioned 3.${PY_MAX_MINOR})`));
72
81
  }
73
- const python = py.cmd;
74
- console.log(chalk.green(` ✓ Python: ${python} (3.${py.minor})`));
75
82
  // Detect local models
76
83
  const models = await detectModels();
77
84
  if (models.length === 0) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Zero-setup Python provisioning.
3
+ *
4
+ * `python -m venv` does NOT install a Python — it creates a thin environment
5
+ * that symlinks back to an existing interpreter, frozen to that interpreter's
6
+ * version. So when the host has no supported Python (e.g. Homebrew now ships
7
+ * 3.13/3.14 as `python3`, which has no wheels for agentscope's native deps),
8
+ * the runner can't build a working venv no matter what.
9
+ *
10
+ * This module removes the requirement for a host Python entirely: it uses
11
+ * `uv` (a single static binary) to download a *managed, standalone* CPython of
12
+ * the supported version and returns its interpreter path. The existing venv
13
+ * pipeline then builds from that. `uv` itself is bootstrapped from its official
14
+ * GitHub release if it isn't already installed — no `brew install`, no manual
15
+ * steps. All failures degrade to `null` so the caller can fall back to a clear
16
+ * "install Python 3.12" message.
17
+ */
18
+ type Log = (m: string) => void;
19
+ export declare function localUvPath(): string;
20
+ /**
21
+ * Provision a supported CPython with NO host Python required. Downloads a
22
+ * managed standalone CPython of `version` via uv (bootstrapping uv itself if
23
+ * needed) and returns its interpreter path — which the venv pipeline builds
24
+ * from. Returns null if provisioning isn't possible (offline, unsupported
25
+ * platform, tar missing), so the caller can show manual-install guidance.
26
+ */
27
+ export declare function provisionPython(version: string, onLine: Log): Promise<string | null>;
28
+ export {};
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Zero-setup Python provisioning.
3
+ *
4
+ * `python -m venv` does NOT install a Python — it creates a thin environment
5
+ * that symlinks back to an existing interpreter, frozen to that interpreter's
6
+ * version. So when the host has no supported Python (e.g. Homebrew now ships
7
+ * 3.13/3.14 as `python3`, which has no wheels for agentscope's native deps),
8
+ * the runner can't build a working venv no matter what.
9
+ *
10
+ * This module removes the requirement for a host Python entirely: it uses
11
+ * `uv` (a single static binary) to download a *managed, standalone* CPython of
12
+ * the supported version and returns its interpreter path. The existing venv
13
+ * pipeline then builds from that. `uv` itself is bootstrapped from its official
14
+ * GitHub release if it isn't already installed — no `brew install`, no manual
15
+ * steps. All failures degrade to `null` so the caller can fall back to a clear
16
+ * "install Python 3.12" message.
17
+ */
18
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, statSync, copyFileSync } from "fs";
19
+ import { join } from "path";
20
+ import { homedir, platform, arch } from "os";
21
+ import { spawn, execSync } from "child_process";
22
+ const CACHE_DIR = join(homedir(), ".melaya-runner");
23
+ const BIN_DIR = join(CACHE_DIR, "bin");
24
+ const isWin = platform() === "win32";
25
+ function uvBinName() { return isWin ? "uv.exe" : "uv"; }
26
+ export function localUvPath() { return join(BIN_DIR, uvBinName()); }
27
+ // Release asset for this platform/arch, from github.com/astral-sh/uv/releases.
28
+ function assetName() {
29
+ const p = platform(), a = arch();
30
+ if (p === "darwin")
31
+ return a === "arm64" ? "uv-aarch64-apple-darwin.tar.gz" : "uv-x86_64-apple-darwin.tar.gz";
32
+ if (p === "linux") {
33
+ if (a === "arm64")
34
+ return "uv-aarch64-unknown-linux-gnu.tar.gz";
35
+ if (a === "x64")
36
+ return "uv-x86_64-unknown-linux-gnu.tar.gz";
37
+ return null; // unusual arch — fall back to manual install guidance
38
+ }
39
+ if (p === "win32")
40
+ return a === "arm64" ? "uv-aarch64-pc-windows-msvc.zip" : "uv-x86_64-pc-windows-msvc.zip";
41
+ return null;
42
+ }
43
+ function tryExec(cmd) {
44
+ try {
45
+ return execSync(cmd, { encoding: "utf-8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] }).trim() || null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function findOnPath(bin) {
52
+ const out = tryExec(isWin ? `where ${bin}` : `command -v ${bin}`);
53
+ if (!out)
54
+ return null;
55
+ const first = out.split(/\r?\n/)[0].trim();
56
+ return first || null;
57
+ }
58
+ // Inactivity-bounded child runner (mirrors pythonEnv.runProc): a managed-CPython
59
+ // download legitimately takes a while but streams progress, so we bound silence.
60
+ function run(cmd, args, onLine, opts = {}) {
61
+ const inactivityMs = opts.inactivityMs ?? 5 * 60_000;
62
+ return new Promise((resolve) => {
63
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...opts.env }, cwd: opts.cwd });
64
+ let done = false;
65
+ let timer;
66
+ const finish = (c) => { if (done)
67
+ return; done = true; if (timer)
68
+ clearTimeout(timer); resolve(c); };
69
+ const bump = () => {
70
+ if (timer)
71
+ clearTimeout(timer);
72
+ timer = setTimeout(() => { try {
73
+ child.kill("SIGKILL");
74
+ }
75
+ catch { /* gone */ } onLine(`(no output for ${Math.round(inactivityMs / 60000)}m — killed ${cmd})`); finish(1); }, inactivityMs);
76
+ };
77
+ const chew = (b) => { bump(); for (const ln of b.toString().split("\n")) {
78
+ const t = ln.trimEnd();
79
+ if (t)
80
+ onLine(t);
81
+ } };
82
+ child.stdout?.on("data", chew);
83
+ child.stderr?.on("data", chew);
84
+ child.on("exit", (c) => finish(c ?? 1));
85
+ child.on("error", () => finish(1));
86
+ bump();
87
+ });
88
+ }
89
+ function capture(cmd, args, env = {}) {
90
+ return new Promise((resolve) => {
91
+ let out = "";
92
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
93
+ child.stdout?.on("data", (b) => { out += b.toString(); });
94
+ child.on("exit", () => resolve(out.trim()));
95
+ child.on("error", () => resolve(""));
96
+ });
97
+ }
98
+ // Extract the downloaded uv archive. uv ships .tar.gz for macOS/Linux and .zip
99
+ // for Windows, so extraction must branch by type — a single `tar` call can't
100
+ // cover both portably (a shell's `tar` may be MSYS GNU tar, which can't read
101
+ // zip). destDir already contains the archive; we extract in place.
102
+ async function extractArchive(archivePath, asset, destDir, onLine) {
103
+ if (asset.endsWith(".zip")) {
104
+ // Windows only. PowerShell's Expand-Archive is always present and reliably
105
+ // reads zips, unlike whatever `tar` happens to be on PATH.
106
+ const ps = `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`;
107
+ let code = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], onLine);
108
+ if (code === 0)
109
+ return true;
110
+ // Fallback: Windows' bundled bsdtar (System32\tar.exe) also handles zip.
111
+ const sys32 = join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe");
112
+ code = await run(existsSync(sys32) ? sys32 : "tar", ["-xf", asset, "-C", "."], onLine, { cwd: destDir });
113
+ return code === 0;
114
+ }
115
+ // macOS/Linux: .tar.gz — bsdtar (macOS) and GNU tar (Linux) both read gz.
116
+ // cwd + bare relative name keeps args colon-free (harmless on unix, required
117
+ // on Windows for the fallback path above).
118
+ const code = await run("tar", ["-xf", asset, "-C", "."], onLine, { cwd: destDir });
119
+ return code === 0;
120
+ }
121
+ function findBinaryRecursive(dir, name) {
122
+ let entries;
123
+ try {
124
+ entries = readdirSync(dir);
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ for (const e of entries) {
130
+ const full = join(dir, e);
131
+ let st;
132
+ try {
133
+ st = statSync(full);
134
+ }
135
+ catch {
136
+ continue;
137
+ }
138
+ if (st.isDirectory()) {
139
+ const found = findBinaryRecursive(full, name);
140
+ if (found)
141
+ return found;
142
+ }
143
+ else if (e === name)
144
+ return full;
145
+ }
146
+ return null;
147
+ }
148
+ // Download + extract the uv binary from its latest GitHub release. The
149
+ // `/releases/latest/download/<asset>` path redirects to the newest asset, so we
150
+ // track upstream without pinning a possibly-nonexistent tag. tar handles the
151
+ // .tar.gz on macOS/Linux and (via bsdtar) the .zip on Windows.
152
+ async function downloadUv(onLine) {
153
+ const asset = assetName();
154
+ if (!asset) {
155
+ onLine(`no uv build available for ${platform()}/${arch()}`);
156
+ return null;
157
+ }
158
+ const url = `https://github.com/astral-sh/uv/releases/latest/download/${asset}`;
159
+ mkdirSync(BIN_DIR, { recursive: true });
160
+ const tmp = join(BIN_DIR, "_dl");
161
+ try {
162
+ rmSync(tmp, { recursive: true, force: true });
163
+ }
164
+ catch { /* fresh */ }
165
+ mkdirSync(tmp, { recursive: true });
166
+ const archivePath = join(tmp, asset);
167
+ onLine(`downloading uv (${asset})`);
168
+ try {
169
+ const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
170
+ if (!res.ok) {
171
+ onLine(`uv download failed: HTTP ${res.status}`);
172
+ return null;
173
+ }
174
+ writeFileSync(archivePath, Buffer.from(await res.arrayBuffer()));
175
+ }
176
+ catch (e) {
177
+ onLine(`uv download error: ${e?.message}`);
178
+ return null;
179
+ }
180
+ onLine("extracting uv");
181
+ const extracted = await extractArchive(archivePath, asset, tmp, onLine);
182
+ if (!extracted) {
183
+ onLine("extraction of the uv archive failed");
184
+ return null;
185
+ }
186
+ const found = findBinaryRecursive(tmp, uvBinName());
187
+ if (!found) {
188
+ onLine("uv binary not found inside the downloaded archive");
189
+ return null;
190
+ }
191
+ const dest = localUvPath();
192
+ try {
193
+ copyFileSync(found, dest);
194
+ if (!isWin)
195
+ chmodSync(dest, 0o755);
196
+ }
197
+ catch (e) {
198
+ onLine(`could not install uv to ${dest}: ${e?.message}`);
199
+ return null;
200
+ }
201
+ try {
202
+ rmSync(tmp, { recursive: true, force: true });
203
+ }
204
+ catch { /* best-effort cleanup */ }
205
+ return dest;
206
+ }
207
+ async function ensureUv(onLine) {
208
+ if (existsSync(localUvPath()))
209
+ return localUvPath(); // previously downloaded
210
+ const onPath = findOnPath(uvBinName());
211
+ if (onPath)
212
+ return onPath; // operator already has uv
213
+ return downloadUv(onLine); // bootstrap it
214
+ }
215
+ /**
216
+ * Provision a supported CPython with NO host Python required. Downloads a
217
+ * managed standalone CPython of `version` via uv (bootstrapping uv itself if
218
+ * needed) and returns its interpreter path — which the venv pipeline builds
219
+ * from. Returns null if provisioning isn't possible (offline, unsupported
220
+ * platform, tar missing), so the caller can show manual-install guidance.
221
+ */
222
+ export async function provisionPython(version, onLine) {
223
+ const uv = await ensureUv(onLine);
224
+ if (!uv)
225
+ return null;
226
+ onLine(`installing a managed Python ${version} (one-time; ~30-60s)`);
227
+ // UV_SYSTEM_CERTS makes uv verify against the OS trust store instead of its
228
+ // bundled webpki roots — required behind TLS-intercepting proxies/antivirus
229
+ // (else "invalid peer certificate: UnknownIssuer") and on networks whose
230
+ // issuer isn't in webpki. Set as an env var (not the CLI flag) so it's a
231
+ // no-op on uv versions that don't know it rather than an unknown-flag error;
232
+ // the flag was also renamed from --native-tls, and env vars dodge that churn.
233
+ // UV_HTTP_TIMEOUT lifts the per-request timeout for the ~20MB CPython download.
234
+ const uvEnv = { UV_SYSTEM_CERTS: "1", UV_HTTP_TIMEOUT: "300" };
235
+ const code = await run(uv, ["python", "install", version], onLine, { env: uvEnv });
236
+ if (code !== 0) {
237
+ onLine(`uv python install ${version} failed`);
238
+ return null;
239
+ }
240
+ const path = await capture(uv, ["python", "find", version], uvEnv);
241
+ if (path && existsSync(path))
242
+ return path;
243
+ onLine(`uv could not resolve a Python ${version} interpreter path after install`);
244
+ return null;
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.31",
3
+ "version": "1.1.33",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,