@melaya/runner 1.1.30 → 1.1.32
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 +84 -15
- package/dist/pythonEnv.js +57 -4
- package/dist/uvBootstrap.d.ts +28 -0
- package/dist/uvBootstrap.js +213 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -52,14 +52,33 @@ async function main() {
|
|
|
52
52
|
.parse(process.argv);
|
|
53
53
|
const opts = program.opts();
|
|
54
54
|
console.log(BANNER);
|
|
55
|
-
// Detect Python
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
|
61
|
+
const py = await findPython();
|
|
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:"));
|
|
74
|
+
console.log(chalk.gray(" macOS: brew install python@3.12"));
|
|
75
|
+
console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
|
|
76
|
+
console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
python = provisioned;
|
|
80
|
+
console.log(chalk.green(` ✓ Python: ${python} (auto-provisioned 3.${PY_MAX_MINOR})`));
|
|
61
81
|
}
|
|
62
|
-
console.log(chalk.green(` ✓ Python: ${python}`));
|
|
63
82
|
// Detect local models
|
|
64
83
|
const models = await detectModels();
|
|
65
84
|
if (models.length === 0) {
|
|
@@ -81,17 +100,67 @@ async function main() {
|
|
|
81
100
|
verbose: opts.verbose,
|
|
82
101
|
});
|
|
83
102
|
}
|
|
84
|
-
|
|
103
|
+
// Supported CPython range for the runner's venv. agentscope and its native
|
|
104
|
+
// deps (tiktoken, numpy, pydantic-core, curl_cffi via scrapling, sentence-
|
|
105
|
+
// transformers, …) only ship wheels for 3.10–3.12. Building the venv from a
|
|
106
|
+
// 3.13 interpreter — which is now Homebrew's default `python3` — leaves those
|
|
107
|
+
// deps unbuilt, so `import agentscope` fails the probe and every assistant /
|
|
108
|
+
// pipeline turn dies with "agentscope import probe failed". So we must NOT
|
|
109
|
+
// grab the first `python3` on PATH: we rank interpreters and pick a supported
|
|
110
|
+
// minor, only reporting the unsupported one (with install guidance) if that's
|
|
111
|
+
// all that exists. Bump PY_MAX_MINOR once the dep set is validated on a newer
|
|
112
|
+
// CPython.
|
|
113
|
+
const PY_MIN_MINOR = 10;
|
|
114
|
+
const PY_MAX_MINOR = 12;
|
|
115
|
+
async function probePyVersion(cmd) {
|
|
85
116
|
const { execSync } = await import("child_process");
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
117
|
+
try {
|
|
118
|
+
const out = execSync(`"${cmd}" --version`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
119
|
+
const m = out.match(/Python (\d+)\.(\d+)/);
|
|
120
|
+
if (!m)
|
|
121
|
+
return null;
|
|
122
|
+
return { major: Number(m[1]), minor: Number(m[2]) };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function findPython() {
|
|
129
|
+
const candidates = [];
|
|
130
|
+
// 1. Explicit supported-minor binaries first, newest supported first. Homebrew
|
|
131
|
+
// (`brew install python@3.12`) and pyenv both expose these names on PATH,
|
|
132
|
+
// so this catches a supported interpreter even when bare `python3` is 3.13.
|
|
133
|
+
for (let m = PY_MAX_MINOR; m >= PY_MIN_MINOR; m--)
|
|
134
|
+
candidates.push(`python3.${m}`);
|
|
135
|
+
// 2. Common absolute install locations, in case PATH points `python3` at 3.13
|
|
136
|
+
// but a supported keg is installed and simply not linked onto PATH.
|
|
137
|
+
for (let m = PY_MAX_MINOR; m >= PY_MIN_MINOR; m--) {
|
|
138
|
+
if (process.platform === "darwin") {
|
|
139
|
+
candidates.push(`/opt/homebrew/opt/python@3.${m}/bin/python3.${m}`, // Apple-silicon brew keg
|
|
140
|
+
`/usr/local/opt/python@3.${m}/bin/python3.${m}`, // Intel brew keg
|
|
141
|
+
`/opt/homebrew/bin/python3.${m}`, `/usr/local/bin/python3.${m}`);
|
|
91
142
|
}
|
|
92
|
-
|
|
143
|
+
else if (process.platform === "linux") {
|
|
144
|
+
candidates.push(`/usr/bin/python3.${m}`, `/usr/local/bin/python3.${m}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// 3. Generic names last — accepted only if they resolve into the supported range.
|
|
148
|
+
candidates.push("python3", "python");
|
|
149
|
+
let fallback; // a real 3.x that exists but is out of range
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
for (const cmd of candidates) {
|
|
152
|
+
if (seen.has(cmd))
|
|
153
|
+
continue;
|
|
154
|
+
seen.add(cmd);
|
|
155
|
+
const v = await probePyVersion(cmd);
|
|
156
|
+
if (!v || v.major !== 3)
|
|
157
|
+
continue;
|
|
158
|
+
if (v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR)
|
|
159
|
+
return { ok: true, cmd, minor: v.minor };
|
|
160
|
+
if (!fallback)
|
|
161
|
+
fallback = `Python ${v.major}.${v.minor}`;
|
|
93
162
|
}
|
|
94
|
-
return
|
|
163
|
+
return { ok: false, foundVersion: fallback };
|
|
95
164
|
}
|
|
96
165
|
// `melaya-runner copilot login` — GitHub Copilot device-flow sign-in, cached
|
|
97
166
|
// locally. Handled before the runner arg-parse so it needs no --token.
|
package/dist/pythonEnv.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* shared-bundle version. Re-bootstraps when the bundle version changes.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from "child_process";
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
14
14
|
import { createHash } from "crypto";
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { homedir, platform } from "os";
|
|
@@ -19,6 +19,29 @@ const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
|
19
19
|
const VENV_DIR = join(CACHE_DIR, "venv");
|
|
20
20
|
const VENV_MARK = join(CACHE_DIR, "venv-version.txt");
|
|
21
21
|
const AGENTSCOPE = join(CACHE_DIR, "agentscope");
|
|
22
|
+
// Supported CPython range for the venv — MUST match cli.ts's findPython(). A
|
|
23
|
+
// venv is a thin wrapper frozen to the interpreter that created it, so one
|
|
24
|
+
// built from an unsupported minor (e.g. a pre-fix runner that grabbed
|
|
25
|
+
// Homebrew's python3.13) can never install agentscope's deps and its import
|
|
26
|
+
// probe always fails. We detect that from pyvenv.cfg and rebuild automatically.
|
|
27
|
+
const PY_SUPPORTED_MIN = 10;
|
|
28
|
+
const PY_SUPPORTED_MAX = 12;
|
|
29
|
+
// The minor version a venv was built from, read from its pyvenv.cfg
|
|
30
|
+
// (`version = 3.12.4`). Returns null if the file is missing/unparseable.
|
|
31
|
+
function venvMinorVersion() {
|
|
32
|
+
try {
|
|
33
|
+
const cfg = readFileSync(join(VENV_DIR, "pyvenv.cfg"), "utf-8");
|
|
34
|
+
const m = cfg.match(/version(?:_info)?\s*=\s*3\.(\d+)/i);
|
|
35
|
+
return m ? Number(m[1]) : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function venvVersionSupported() {
|
|
42
|
+
const minor = venvMinorVersion();
|
|
43
|
+
return minor !== null && minor >= PY_SUPPORTED_MIN && minor <= PY_SUPPORTED_MAX;
|
|
44
|
+
}
|
|
22
45
|
// Explicit dependency list. The shared-bundle endpoint ships only
|
|
23
46
|
// `*.py` files — no pyproject.toml — so `pip install -e
|
|
24
47
|
// ~/.melaya-runner/agentscope` does NOT work (pip can't find a project
|
|
@@ -243,6 +266,11 @@ function venvIsValid(expectedVersion) {
|
|
|
243
266
|
return false;
|
|
244
267
|
if (!existsSync(VENV_MARK))
|
|
245
268
|
return false;
|
|
269
|
+
// A venv built from an unsupported Python is never "valid", regardless of the
|
|
270
|
+
// marker — this forces the rebuild path (which recreates it from a supported
|
|
271
|
+
// interpreter) instead of self-healing a doomed venv.
|
|
272
|
+
if (!venvVersionSupported())
|
|
273
|
+
return false;
|
|
246
274
|
try {
|
|
247
275
|
const cached = readFileSync(VENV_MARK, "utf-8").trim();
|
|
248
276
|
return cached === venvMarkerValue(expectedVersion);
|
|
@@ -376,6 +404,28 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
|
|
|
376
404
|
};
|
|
377
405
|
}
|
|
378
406
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
407
|
+
// Step 0: heal a venv left behind by a runner that built it from an
|
|
408
|
+
// unsupported interpreter (the classic case: a pre-fix runner grabbed
|
|
409
|
+
// Homebrew's python3.13, so agentscope's deps never installed and the import
|
|
410
|
+
// probe fails forever). A venv's Python is frozen at creation, so the only
|
|
411
|
+
// fix is to delete and recreate it from `systemPython` — which the CLI now
|
|
412
|
+
// guarantees is a supported 3.10–3.12. Fully automatic: no "delete the venv
|
|
413
|
+
// and restart" by hand.
|
|
414
|
+
if (existsSync(venvPython()) && !venvVersionSupported()) {
|
|
415
|
+
const minor = venvMinorVersion();
|
|
416
|
+
onProgress(`existing venv is Python ${minor === null ? "unknown" : `3.${minor}`} ` +
|
|
417
|
+
`(unsupported — need 3.${PY_SUPPORTED_MIN}–3.${PY_SUPPORTED_MAX}); rebuilding it`);
|
|
418
|
+
try {
|
|
419
|
+
rmSync(VENV_DIR, { recursive: true, force: true });
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
pythonPath: systemPython,
|
|
425
|
+
reason: `could not remove the stale venv at ${VENV_DIR} (${e?.message}). Delete it manually and restart the runner.`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
379
429
|
// Step 1: create the venv if missing.
|
|
380
430
|
if (!existsSync(venvPython())) {
|
|
381
431
|
onProgress(`creating venv at ${VENV_DIR} (one-time, ~5s)`);
|
|
@@ -439,19 +489,22 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
|
|
|
439
489
|
// identical whether the venv is fresh or already valid.
|
|
440
490
|
await ensureNltkData(onProgress);
|
|
441
491
|
await _resolveAndCacheCertBundle(onProgress);
|
|
442
|
-
writeFileSync(VENV_MARK, venvMarkerValue(expectedVersion), "utf-8");
|
|
443
492
|
// sanity: confirm shortuuid + agentscope (via PYTHONPATH) resolve now.
|
|
444
493
|
// Probe must mirror the spawn env so PYTHONPATH=CACHE_DIR points at
|
|
445
494
|
// the cached agentscope source — without this the probe ImportError's
|
|
446
|
-
// even though the real spawn would work.
|
|
495
|
+
// even though the real spawn would work. The marker is written ONLY after
|
|
496
|
+
// this passes, so a half-built venv is never cached as valid: the next
|
|
497
|
+
// launch re-enters this rebuild path and self-heals instead of returning a
|
|
498
|
+
// broken venv as ok.
|
|
447
499
|
const probe = await runProc(venvPython(), ["-c", "import shortuuid, agentscope, anthropic, openai"], onProgress, { PYTHONPATH: CACHE_DIR });
|
|
448
500
|
if (probe !== 0) {
|
|
449
501
|
return {
|
|
450
502
|
ok: false,
|
|
451
503
|
pythonPath: systemPython,
|
|
452
|
-
reason: "venv
|
|
504
|
+
reason: `agentscope import probe failed on the ${venvMinorVersion() === null ? "" : `Python 3.${venvMinorVersion()} `}venv — the deps above did not import. See the pip lines above for the cause; the venv will be rebuilt on next launch.`,
|
|
453
505
|
};
|
|
454
506
|
}
|
|
507
|
+
writeFileSync(VENV_MARK, venvMarkerValue(expectedVersion), "utf-8");
|
|
455
508
|
onProgress(`✓ venv ready (python=${venvPython()})`);
|
|
456
509
|
return { ok: true, pythonPath: venvPython() };
|
|
457
510
|
}
|
|
@@ -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,213 @@
|
|
|
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, inactivityMs = 5 * 60_000) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
63
|
+
let done = false;
|
|
64
|
+
let timer;
|
|
65
|
+
const finish = (c) => { if (done)
|
|
66
|
+
return; done = true; if (timer)
|
|
67
|
+
clearTimeout(timer); resolve(c); };
|
|
68
|
+
const bump = () => {
|
|
69
|
+
if (timer)
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
timer = setTimeout(() => { try {
|
|
72
|
+
child.kill("SIGKILL");
|
|
73
|
+
}
|
|
74
|
+
catch { /* gone */ } onLine(`(no output for ${Math.round(inactivityMs / 60000)}m — killed ${cmd})`); finish(1); }, inactivityMs);
|
|
75
|
+
};
|
|
76
|
+
const chew = (b) => { bump(); for (const ln of b.toString().split("\n")) {
|
|
77
|
+
const t = ln.trimEnd();
|
|
78
|
+
if (t)
|
|
79
|
+
onLine(t);
|
|
80
|
+
} };
|
|
81
|
+
child.stdout?.on("data", chew);
|
|
82
|
+
child.stderr?.on("data", chew);
|
|
83
|
+
child.on("exit", (c) => finish(c ?? 1));
|
|
84
|
+
child.on("error", () => finish(1));
|
|
85
|
+
bump();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function capture(cmd, args) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let out = "";
|
|
91
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
92
|
+
child.stdout?.on("data", (b) => { out += b.toString(); });
|
|
93
|
+
child.on("exit", () => resolve(out.trim()));
|
|
94
|
+
child.on("error", () => resolve(""));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function findBinaryRecursive(dir, name) {
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = readdirSync(dir);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
for (const e of entries) {
|
|
106
|
+
const full = join(dir, e);
|
|
107
|
+
let st;
|
|
108
|
+
try {
|
|
109
|
+
st = statSync(full);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (st.isDirectory()) {
|
|
115
|
+
const found = findBinaryRecursive(full, name);
|
|
116
|
+
if (found)
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
else if (e === name)
|
|
120
|
+
return full;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// Download + extract the uv binary from its latest GitHub release. The
|
|
125
|
+
// `/releases/latest/download/<asset>` path redirects to the newest asset, so we
|
|
126
|
+
// track upstream without pinning a possibly-nonexistent tag. tar handles the
|
|
127
|
+
// .tar.gz on macOS/Linux and (via bsdtar) the .zip on Windows.
|
|
128
|
+
async function downloadUv(onLine) {
|
|
129
|
+
const asset = assetName();
|
|
130
|
+
if (!asset) {
|
|
131
|
+
onLine(`no uv build available for ${platform()}/${arch()}`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const url = `https://github.com/astral-sh/uv/releases/latest/download/${asset}`;
|
|
135
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
136
|
+
const tmp = join(BIN_DIR, "_dl");
|
|
137
|
+
try {
|
|
138
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
catch { /* fresh */ }
|
|
141
|
+
mkdirSync(tmp, { recursive: true });
|
|
142
|
+
const archivePath = join(tmp, asset);
|
|
143
|
+
onLine(`downloading uv (${asset})`);
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
onLine(`uv download failed: HTTP ${res.status}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
writeFileSync(archivePath, Buffer.from(await res.arrayBuffer()));
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
onLine(`uv download error: ${e?.message}`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
onLine("extracting uv");
|
|
157
|
+
const code = await run("tar", ["-xf", archivePath, "-C", tmp], onLine);
|
|
158
|
+
if (code !== 0) {
|
|
159
|
+
onLine("tar extraction of uv failed (is `tar` available?)");
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const found = findBinaryRecursive(tmp, uvBinName());
|
|
163
|
+
if (!found) {
|
|
164
|
+
onLine("uv binary not found inside the downloaded archive");
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const dest = localUvPath();
|
|
168
|
+
try {
|
|
169
|
+
copyFileSync(found, dest);
|
|
170
|
+
if (!isWin)
|
|
171
|
+
chmodSync(dest, 0o755);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
onLine(`could not install uv to ${dest}: ${e?.message}`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
catch { /* best-effort cleanup */ }
|
|
181
|
+
return dest;
|
|
182
|
+
}
|
|
183
|
+
async function ensureUv(onLine) {
|
|
184
|
+
if (existsSync(localUvPath()))
|
|
185
|
+
return localUvPath(); // previously downloaded
|
|
186
|
+
const onPath = findOnPath(uvBinName());
|
|
187
|
+
if (onPath)
|
|
188
|
+
return onPath; // operator already has uv
|
|
189
|
+
return downloadUv(onLine); // bootstrap it
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Provision a supported CPython with NO host Python required. Downloads a
|
|
193
|
+
* managed standalone CPython of `version` via uv (bootstrapping uv itself if
|
|
194
|
+
* needed) and returns its interpreter path — which the venv pipeline builds
|
|
195
|
+
* from. Returns null if provisioning isn't possible (offline, unsupported
|
|
196
|
+
* platform, tar missing), so the caller can show manual-install guidance.
|
|
197
|
+
*/
|
|
198
|
+
export async function provisionPython(version, onLine) {
|
|
199
|
+
const uv = await ensureUv(onLine);
|
|
200
|
+
if (!uv)
|
|
201
|
+
return null;
|
|
202
|
+
onLine(`installing a managed Python ${version} (one-time; ~30-60s)`);
|
|
203
|
+
const code = await run(uv, ["python", "install", version], onLine);
|
|
204
|
+
if (code !== 0) {
|
|
205
|
+
onLine(`uv python install ${version} failed`);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const path = await capture(uv, ["python", "find", version]);
|
|
209
|
+
if (path && existsSync(path))
|
|
210
|
+
return path;
|
|
211
|
+
onLine(`uv could not resolve a Python ${version} interpreter path after install`);
|
|
212
|
+
return null;
|
|
213
|
+
}
|