nexo-brain 7.25.1 → 7.25.3
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/bin/nexo-brain.js +25 -7
- package/package.json +1 -1
- package/src/auto_update.py +94 -8
- package/src/doctor/providers/boot.py +106 -0
- package/src/plugins/update.py +113 -3
package/bin/nexo-brain.js
CHANGED
|
@@ -57,6 +57,8 @@ const DEFAULT_ASSISTANT_NAME = "Nova";
|
|
|
57
57
|
const RESERVED_ASSISTANT_NAME_KEYS = new Set(["nexo", "nexobrain", "nexodesktop"]);
|
|
58
58
|
const MIN_INSTALLER_PYTHON_MAJOR = 3;
|
|
59
59
|
const MIN_INSTALLER_PYTHON_MINOR = 10;
|
|
60
|
+
const DESKTOP_BUNDLED_WHEEL_PYTHON_MAJOR = 3;
|
|
61
|
+
const DESKTOP_BUNDLED_WHEEL_PYTHON_MINOR = 12;
|
|
60
62
|
|
|
61
63
|
function normalizeAssistantNameCandidate(value) {
|
|
62
64
|
return String(value || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
@@ -278,11 +280,25 @@ function pythonVersionMeetsMinimum(versionText) {
|
|
|
278
280
|
|| (major === MIN_INSTALLER_PYTHON_MAJOR && minor >= MIN_INSTALLER_PYTHON_MINOR);
|
|
279
281
|
}
|
|
280
282
|
|
|
283
|
+
function pythonVersionMatchesDesktopBundle(versionText) {
|
|
284
|
+
const match = String(versionText || "").trim().match(/^(\d+)\.(\d+)(?:\.|$)/);
|
|
285
|
+
if (!match) return false;
|
|
286
|
+
return Number(match[1]) === DESKTOP_BUNDLED_WHEEL_PYTHON_MAJOR
|
|
287
|
+
&& Number(match[2]) === DESKTOP_BUNDLED_WHEEL_PYTHON_MINOR;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function pythonVersionUsableForInstaller(versionText) {
|
|
291
|
+
if (!pythonVersionMeetsMinimum(versionText)) return false;
|
|
292
|
+
if (isDesktopManagedInstall()) return pythonVersionMatchesDesktopBundle(versionText);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
281
296
|
function resolveInstallerPython() {
|
|
282
297
|
const candidates = [
|
|
283
298
|
process.env.NEXO_BOOTSTRAP_PYTHON,
|
|
284
299
|
process.env.NEXO_RUNTIME_PYTHON,
|
|
285
300
|
process.env.NEXO_PYTHON,
|
|
301
|
+
run("which python3.12"),
|
|
286
302
|
run("which python3"),
|
|
287
303
|
run("which python"),
|
|
288
304
|
].filter(Boolean);
|
|
@@ -292,7 +308,7 @@ function resolveInstallerPython() {
|
|
|
292
308
|
if (!clean || seen.has(clean)) continue;
|
|
293
309
|
seen.add(clean);
|
|
294
310
|
const version = pythonVersion(clean);
|
|
295
|
-
if (version &&
|
|
311
|
+
if (version && pythonVersionUsableForInstaller(version)) return clean;
|
|
296
312
|
}
|
|
297
313
|
return "";
|
|
298
314
|
}
|
|
@@ -366,9 +382,11 @@ function uniqueBackupPath(targetPath, suffix) {
|
|
|
366
382
|
function ensureManagedVenvCompatible(venvPath, venvPython) {
|
|
367
383
|
if (!fs.existsSync(venvPython)) return;
|
|
368
384
|
const version = pythonVersion(venvPython);
|
|
369
|
-
if (version &&
|
|
385
|
+
if (version && pythonVersionUsableForInstaller(version)) return;
|
|
370
386
|
|
|
371
|
-
const reason = version
|
|
387
|
+
const reason = version
|
|
388
|
+
? `Python ${version}${isDesktopManagedInstall() ? " (Desktop bundle requires Python 3.12)" : ""}`
|
|
389
|
+
: "an unreadable Python executable";
|
|
372
390
|
const backupPath = uniqueBackupPath(venvPath, "unsupported-python");
|
|
373
391
|
log(` Existing Python virtual environment uses ${reason}; moving it aside to recreate.`);
|
|
374
392
|
try {
|
|
@@ -3300,14 +3318,14 @@ async function runSetup() {
|
|
|
3300
3318
|
}
|
|
3301
3319
|
if (!python) {
|
|
3302
3320
|
log("Python 3 not found and couldn't install automatically.");
|
|
3303
|
-
log(platform === "darwin" ? "Install it: brew install
|
|
3321
|
+
log(platform === "darwin" ? "Install it: brew install python@3.12" : "Install it: sudo apt install python3");
|
|
3304
3322
|
process.exit(1);
|
|
3305
3323
|
}
|
|
3306
3324
|
}
|
|
3307
3325
|
const pyVersion = pythonVersion(python);
|
|
3308
|
-
if (!pyVersion || !
|
|
3326
|
+
if (!pyVersion || !pythonVersionUsableForInstaller(pyVersion)) {
|
|
3309
3327
|
log(pyVersion
|
|
3310
|
-
? `Python at ${python} is ${pyVersion}; NEXO Brain requires Python >=${MIN_INSTALLER_PYTHON_MAJOR}.${MIN_INSTALLER_PYTHON_MINOR}.`
|
|
3328
|
+
? `Python at ${python} is ${pyVersion}; NEXO Brain requires ${isDesktopManagedInstall() ? "Python 3.12 for Desktop bundled wheels" : `Python >=${MIN_INSTALLER_PYTHON_MAJOR}.${MIN_INSTALLER_PYTHON_MINOR}`}.`
|
|
3311
3329
|
: `Python at ${python || "(not found)"} is not executable.`);
|
|
3312
3330
|
process.exit(1);
|
|
3313
3331
|
}
|
|
@@ -3618,7 +3636,7 @@ async function runSetup() {
|
|
|
3618
3636
|
}
|
|
3619
3637
|
if (fs.existsSync(venvPython)) {
|
|
3620
3638
|
const venvVersion = pythonVersion(venvPython);
|
|
3621
|
-
if (!venvVersion || !
|
|
3639
|
+
if (!venvVersion || !pythonVersionUsableForInstaller(venvVersion)) {
|
|
3622
3640
|
log(`Python virtual environment is unsupported after creation (${venvVersion || "unknown version"}).`);
|
|
3623
3641
|
process.exit(1);
|
|
3624
3642
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.25.
|
|
3
|
+
"version": "7.25.3",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
package/src/auto_update.py
CHANGED
|
@@ -21,7 +21,7 @@ import time
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
23
|
try:
|
|
24
|
-
from product_mode import enforce_desktop_product_contract
|
|
24
|
+
from product_mode import desktop_product_requested, enforce_desktop_product_contract
|
|
25
25
|
except ModuleNotFoundError as exc:
|
|
26
26
|
if getattr(exc, "name", "") != "product_mode":
|
|
27
27
|
raise
|
|
@@ -30,7 +30,7 @@ except ModuleNotFoundError as exc:
|
|
|
30
30
|
core_path = str(_core_runtime)
|
|
31
31
|
if core_path not in sys.path:
|
|
32
32
|
sys.path.insert(0, core_path)
|
|
33
|
-
from product_mode import enforce_desktop_product_contract
|
|
33
|
+
from product_mode import desktop_product_requested, enforce_desktop_product_contract
|
|
34
34
|
from runtime_home import export_resolved_nexo_home, managed_nexo_home
|
|
35
35
|
|
|
36
36
|
try:
|
|
@@ -939,14 +939,94 @@ def _venv_pip_path(runtime_root: Path = NEXO_HOME) -> Path:
|
|
|
939
939
|
return runtime_root / ".venv" / "bin" / "pip"
|
|
940
940
|
|
|
941
941
|
|
|
942
|
+
def _python_version_tuple(python_bin: Path | str) -> tuple[int, int, int] | None:
|
|
943
|
+
try:
|
|
944
|
+
result = subprocess.run(
|
|
945
|
+
[str(python_bin), "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"],
|
|
946
|
+
capture_output=True,
|
|
947
|
+
text=True,
|
|
948
|
+
timeout=15,
|
|
949
|
+
)
|
|
950
|
+
except Exception:
|
|
951
|
+
return None
|
|
952
|
+
if result.returncode != 0:
|
|
953
|
+
return None
|
|
954
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout or result.stderr or "")
|
|
955
|
+
if not match:
|
|
956
|
+
return None
|
|
957
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _managed_venv_python_supported(python_bin: Path | str) -> bool:
|
|
961
|
+
version = _python_version_tuple(python_bin)
|
|
962
|
+
if not version:
|
|
963
|
+
return False
|
|
964
|
+
if desktop_product_requested():
|
|
965
|
+
return version[:2] == (3, 12)
|
|
966
|
+
return version >= (3, 10, 0)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _resolve_managed_venv_base_python() -> str:
|
|
970
|
+
candidates = [
|
|
971
|
+
os.environ.get("NEXO_BOOTSTRAP_PYTHON", ""),
|
|
972
|
+
os.environ.get("NEXO_RUNTIME_PYTHON", ""),
|
|
973
|
+
os.environ.get("NEXO_PYTHON", ""),
|
|
974
|
+
]
|
|
975
|
+
if desktop_product_requested():
|
|
976
|
+
candidates.extend([
|
|
977
|
+
"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12",
|
|
978
|
+
"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
|
|
979
|
+
"/opt/homebrew/bin/python3.12",
|
|
980
|
+
"/usr/local/bin/python3.12",
|
|
981
|
+
shutil.which("python3.12") or "",
|
|
982
|
+
])
|
|
983
|
+
candidates.append(sys.executable)
|
|
984
|
+
candidates.append(shutil.which("python3") or "")
|
|
985
|
+
for candidate in candidates:
|
|
986
|
+
clean = str(candidate or "").strip()
|
|
987
|
+
if clean and Path(clean).exists() and _managed_venv_python_supported(clean):
|
|
988
|
+
return clean
|
|
989
|
+
return sys.executable
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _archive_incompatible_runtime_venv(runtime_root: Path = NEXO_HOME, reason: str = "incompatible-python") -> Path | None:
|
|
993
|
+
venv_dir = runtime_root / ".venv"
|
|
994
|
+
if not venv_dir.exists():
|
|
995
|
+
return None
|
|
996
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
997
|
+
backup_root = runtime_root / "runtime" / "backups"
|
|
998
|
+
try:
|
|
999
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
1000
|
+
target = backup_root / f"venv-{reason}-{stamp}"
|
|
1001
|
+
counter = 2
|
|
1002
|
+
while target.exists():
|
|
1003
|
+
target = backup_root / f"venv-{reason}-{stamp}-{counter}"
|
|
1004
|
+
counter += 1
|
|
1005
|
+
shutil.move(str(venv_dir), str(target))
|
|
1006
|
+
_log(f"Archived incompatible managed venv at {target}")
|
|
1007
|
+
return target
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
_log(f"venv archive failed: {exc}")
|
|
1010
|
+
return None
|
|
1011
|
+
|
|
1012
|
+
|
|
942
1013
|
def _ensure_runtime_venv(runtime_root: Path = NEXO_HOME) -> Path | None:
|
|
943
1014
|
venv_python = _venv_python_path(runtime_root)
|
|
944
1015
|
if venv_python.exists():
|
|
945
|
-
|
|
1016
|
+
if _managed_venv_python_supported(venv_python):
|
|
1017
|
+
return venv_python
|
|
1018
|
+
version = _python_version_tuple(venv_python)
|
|
1019
|
+
reason = f"python-{'.'.join(map(str, version[:2]))}" if version else "unreadable-python"
|
|
1020
|
+
if _archive_incompatible_runtime_venv(runtime_root, reason=reason) is None:
|
|
1021
|
+
return None
|
|
1022
|
+
base_python = _resolve_managed_venv_base_python()
|
|
1023
|
+
if not _managed_venv_python_supported(base_python):
|
|
1024
|
+
_log(f"no supported base Python found for managed venv: {base_python}")
|
|
1025
|
+
return None
|
|
946
1026
|
try:
|
|
947
1027
|
runtime_root.mkdir(parents=True, exist_ok=True)
|
|
948
1028
|
result = subprocess.run(
|
|
949
|
-
[
|
|
1029
|
+
[base_python, "-m", "venv", str(runtime_root / ".venv")],
|
|
950
1030
|
capture_output=True,
|
|
951
1031
|
text=True,
|
|
952
1032
|
timeout=120,
|
|
@@ -965,7 +1045,7 @@ def _reinstall_pip_deps() -> bool:
|
|
|
965
1045
|
req_file = SRC_DIR / "requirements.txt"
|
|
966
1046
|
if not req_file.exists():
|
|
967
1047
|
return True
|
|
968
|
-
_ensure_runtime_venv(NEXO_HOME)
|
|
1048
|
+
venv_python = _ensure_runtime_venv(NEXO_HOME)
|
|
969
1049
|
venv_pip = _venv_pip_path(NEXO_HOME)
|
|
970
1050
|
if not venv_pip.exists() and sys.platform != "win32":
|
|
971
1051
|
alt_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
|
|
@@ -977,11 +1057,14 @@ def _reinstall_pip_deps() -> bool:
|
|
|
977
1057
|
[str(venv_pip), "install", "--quiet", "-r", str(req_file)],
|
|
978
1058
|
capture_output=True, text=True, timeout=120,
|
|
979
1059
|
)
|
|
980
|
-
|
|
1060
|
+
elif not desktop_product_requested():
|
|
981
1061
|
result = subprocess.run(
|
|
982
1062
|
[sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
|
|
983
1063
|
capture_output=True, text=True, timeout=120,
|
|
984
1064
|
)
|
|
1065
|
+
else:
|
|
1066
|
+
_log(f"managed venv unavailable for Desktop dependency repair: {venv_python}")
|
|
1067
|
+
return False
|
|
985
1068
|
if result.returncode != 0:
|
|
986
1069
|
_log(f"pip install failed (exit {result.returncode}): {result.stderr or result.stdout}")
|
|
987
1070
|
return False
|
|
@@ -4665,7 +4748,7 @@ def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
|
|
|
4665
4748
|
req_file = runtime_root / "requirements.txt"
|
|
4666
4749
|
if not req_file.exists():
|
|
4667
4750
|
return True
|
|
4668
|
-
_ensure_runtime_venv(runtime_root)
|
|
4751
|
+
venv_python = _ensure_runtime_venv(runtime_root)
|
|
4669
4752
|
venv_pip = _venv_pip_path(runtime_root)
|
|
4670
4753
|
if not venv_pip.exists() and sys.platform != "win32":
|
|
4671
4754
|
alt_pip = runtime_root / ".venv" / "bin" / "pip3"
|
|
@@ -4679,13 +4762,16 @@ def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
|
|
|
4679
4762
|
text=True,
|
|
4680
4763
|
timeout=120,
|
|
4681
4764
|
)
|
|
4682
|
-
|
|
4765
|
+
elif not desktop_product_requested():
|
|
4683
4766
|
result = subprocess.run(
|
|
4684
4767
|
[sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
|
|
4685
4768
|
capture_output=True,
|
|
4686
4769
|
text=True,
|
|
4687
4770
|
timeout=120,
|
|
4688
4771
|
)
|
|
4772
|
+
else:
|
|
4773
|
+
_log(f"managed venv unavailable for Desktop runtime dependency repair: {venv_python}")
|
|
4774
|
+
return False
|
|
4689
4775
|
return result.returncode == 0
|
|
4690
4776
|
except Exception:
|
|
4691
4777
|
return False
|
|
@@ -549,6 +549,111 @@ def check_python_runtime() -> DoctorCheck:
|
|
|
549
549
|
)
|
|
550
550
|
|
|
551
551
|
|
|
552
|
+
def _desktop_product_requested() -> bool:
|
|
553
|
+
try:
|
|
554
|
+
from product_mode import desktop_product_requested
|
|
555
|
+
return bool(desktop_product_requested())
|
|
556
|
+
except Exception:
|
|
557
|
+
return str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _managed_venv_python_path() -> Path:
|
|
561
|
+
if sys.platform == "win32":
|
|
562
|
+
return NEXO_HOME / ".venv" / "Scripts" / "python.exe"
|
|
563
|
+
return NEXO_HOME / ".venv" / "bin" / "python3"
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _probe_python_version(python_bin: Path | str) -> tuple[int, int, int] | None:
|
|
567
|
+
try:
|
|
568
|
+
result = subprocess.run(
|
|
569
|
+
[str(python_bin), "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"],
|
|
570
|
+
capture_output=True,
|
|
571
|
+
text=True,
|
|
572
|
+
timeout=15,
|
|
573
|
+
)
|
|
574
|
+
except Exception:
|
|
575
|
+
return None
|
|
576
|
+
if result.returncode != 0:
|
|
577
|
+
return None
|
|
578
|
+
import re
|
|
579
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout or result.stderr or "")
|
|
580
|
+
if not match:
|
|
581
|
+
return None
|
|
582
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _managed_venv_version_supported(version: tuple[int, int, int] | None) -> bool:
|
|
586
|
+
if not version:
|
|
587
|
+
return False
|
|
588
|
+
if _desktop_product_requested():
|
|
589
|
+
return version[:2] == (3, 12)
|
|
590
|
+
return version >= (3, 10, 0)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _repair_managed_venv_python() -> bool:
|
|
594
|
+
try:
|
|
595
|
+
import auto_update
|
|
596
|
+
return bool(auto_update._ensure_runtime_venv(NEXO_HOME)) and bool(auto_update._reinstall_pip_deps())
|
|
597
|
+
except Exception:
|
|
598
|
+
return False
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def check_managed_venv_python(fix: bool = False) -> DoctorCheck:
|
|
602
|
+
"""Check the managed NEXO venv is compatible with bundled runtime deps."""
|
|
603
|
+
venv_python = _managed_venv_python_path()
|
|
604
|
+
if not venv_python.exists():
|
|
605
|
+
if not _desktop_product_requested():
|
|
606
|
+
return DoctorCheck(
|
|
607
|
+
id="boot.managed_venv_python",
|
|
608
|
+
tier="boot",
|
|
609
|
+
status="healthy",
|
|
610
|
+
severity="info",
|
|
611
|
+
summary="Managed Python venv not present yet",
|
|
612
|
+
evidence=[str(venv_python)],
|
|
613
|
+
)
|
|
614
|
+
return DoctorCheck(
|
|
615
|
+
id="boot.managed_venv_python",
|
|
616
|
+
tier="boot",
|
|
617
|
+
status="degraded",
|
|
618
|
+
severity="warn",
|
|
619
|
+
summary="Managed Python venv missing",
|
|
620
|
+
evidence=[str(venv_python)],
|
|
621
|
+
repair_plan=["Run nexo doctor --tier boot --fix or nexo update to recreate the managed venv"],
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
version = _probe_python_version(venv_python)
|
|
625
|
+
if _managed_venv_version_supported(version):
|
|
626
|
+
return DoctorCheck(
|
|
627
|
+
id="boot.managed_venv_python",
|
|
628
|
+
tier="boot",
|
|
629
|
+
status="healthy",
|
|
630
|
+
severity="info",
|
|
631
|
+
summary=f"Managed venv Python {'.'.join(map(str, version))}",
|
|
632
|
+
evidence=[str(venv_python)],
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
summary = "Managed venv Python is incompatible"
|
|
636
|
+
if version:
|
|
637
|
+
summary = f"Managed venv Python {'.'.join(map(str, version))} is incompatible"
|
|
638
|
+
if _desktop_product_requested():
|
|
639
|
+
summary += " — Desktop bundled wheels require Python 3.12"
|
|
640
|
+
if fix and _repair_managed_venv_python():
|
|
641
|
+
post = check_managed_venv_python(fix=False)
|
|
642
|
+
if post.status == "healthy":
|
|
643
|
+
post.fixed = True
|
|
644
|
+
post.summary += " (fixed)"
|
|
645
|
+
return post
|
|
646
|
+
return DoctorCheck(
|
|
647
|
+
id="boot.managed_venv_python",
|
|
648
|
+
tier="boot",
|
|
649
|
+
status="degraded",
|
|
650
|
+
severity="warn",
|
|
651
|
+
summary=summary,
|
|
652
|
+
evidence=[str(venv_python)],
|
|
653
|
+
repair_plan=["Run nexo doctor --tier boot --fix or nexo update to archive and recreate the managed venv"],
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
552
657
|
CRITICAL_CONFIG_FILES = (
|
|
553
658
|
("schedule.json", ("config", "schedule.json")),
|
|
554
659
|
("optionals.json", ("config", "optionals.json")),
|
|
@@ -803,6 +908,7 @@ def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
803
908
|
safe_check(check_disk_space),
|
|
804
909
|
safe_check(check_wrapper_scripts),
|
|
805
910
|
safe_check(check_python_runtime),
|
|
911
|
+
safe_check(check_managed_venv_python, fix=fix),
|
|
806
912
|
safe_check(check_config_parse),
|
|
807
913
|
safe_check(check_core_dev_packaged_install),
|
|
808
914
|
safe_check(check_dashboard_desktop_contract),
|
package/src/plugins/update.py
CHANGED
|
@@ -12,6 +12,12 @@ import time
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
from runtime_home import export_resolved_nexo_home
|
|
15
|
+
try:
|
|
16
|
+
from product_mode import desktop_product_requested
|
|
17
|
+
except Exception: # pragma: no cover - stale packaged runtimes may miss product_mode during update
|
|
18
|
+
def desktop_product_requested() -> bool:
|
|
19
|
+
return str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1"
|
|
20
|
+
|
|
15
21
|
from runtime_versioning import (
|
|
16
22
|
activate_versioned_runtime_snapshot,
|
|
17
23
|
compute_mcp_runtime_fingerprint,
|
|
@@ -239,15 +245,94 @@ def _venv_pip_path(runtime_root: Path | None = None) -> Path:
|
|
|
239
245
|
return root / ".venv" / "bin" / "pip"
|
|
240
246
|
|
|
241
247
|
|
|
248
|
+
def _python_version_tuple(python_bin: Path | str) -> tuple[int, int, int] | None:
|
|
249
|
+
try:
|
|
250
|
+
result = subprocess.run(
|
|
251
|
+
[str(python_bin), "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"],
|
|
252
|
+
capture_output=True,
|
|
253
|
+
text=True,
|
|
254
|
+
timeout=15,
|
|
255
|
+
)
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|
|
258
|
+
if result.returncode != 0:
|
|
259
|
+
return None
|
|
260
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout or result.stderr or "")
|
|
261
|
+
if not match:
|
|
262
|
+
return None
|
|
263
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _managed_venv_python_supported(python_bin: Path | str) -> bool:
|
|
267
|
+
version = _python_version_tuple(python_bin)
|
|
268
|
+
if not version:
|
|
269
|
+
return False
|
|
270
|
+
if desktop_product_requested():
|
|
271
|
+
return version[:2] == (3, 12)
|
|
272
|
+
return version >= (3, 10, 0)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _resolve_managed_venv_base_python() -> str:
|
|
276
|
+
candidates = [
|
|
277
|
+
os.environ.get("NEXO_BOOTSTRAP_PYTHON", ""),
|
|
278
|
+
os.environ.get("NEXO_RUNTIME_PYTHON", ""),
|
|
279
|
+
os.environ.get("NEXO_PYTHON", ""),
|
|
280
|
+
]
|
|
281
|
+
if desktop_product_requested():
|
|
282
|
+
candidates.extend([
|
|
283
|
+
"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12",
|
|
284
|
+
"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
|
|
285
|
+
"/opt/homebrew/bin/python3.12",
|
|
286
|
+
"/usr/local/bin/python3.12",
|
|
287
|
+
shutil.which("python3.12") or "",
|
|
288
|
+
])
|
|
289
|
+
candidates.extend([sys.executable, shutil.which("python3") or ""])
|
|
290
|
+
for candidate in candidates:
|
|
291
|
+
clean = str(candidate or "").strip()
|
|
292
|
+
if clean and Path(clean).exists() and _managed_venv_python_supported(clean):
|
|
293
|
+
return clean
|
|
294
|
+
return sys.executable
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _archive_incompatible_managed_venv(root: Path, reason: str = "incompatible-python") -> Path | None:
|
|
298
|
+
venv_dir = root / ".venv"
|
|
299
|
+
if not venv_dir.exists():
|
|
300
|
+
return None
|
|
301
|
+
backup_root = root / "runtime" / "backups"
|
|
302
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
303
|
+
try:
|
|
304
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
target = backup_root / f"venv-{reason}-{stamp}"
|
|
306
|
+
counter = 2
|
|
307
|
+
while target.exists():
|
|
308
|
+
target = backup_root / f"venv-{reason}-{stamp}-{counter}"
|
|
309
|
+
counter += 1
|
|
310
|
+
shutil.move(str(venv_dir), str(target))
|
|
311
|
+
return target
|
|
312
|
+
except Exception:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
242
316
|
def _ensure_managed_venv(runtime_root: Path | None = None) -> str | None:
|
|
243
317
|
root = runtime_root or _nexo_home()
|
|
244
318
|
venv_python = _venv_python_path(root)
|
|
319
|
+
if venv_python.exists():
|
|
320
|
+
if _managed_venv_python_supported(venv_python):
|
|
321
|
+
return None
|
|
322
|
+
version = _python_version_tuple(venv_python)
|
|
323
|
+
reason = f"python-{'.'.join(map(str, version[:2]))}" if version else "unreadable-python"
|
|
324
|
+
archived = _archive_incompatible_managed_venv(root, reason=reason)
|
|
325
|
+
if archived is None:
|
|
326
|
+
return "managed venv uses an incompatible Python and could not be archived"
|
|
327
|
+
base_python = _resolve_managed_venv_base_python()
|
|
328
|
+
if not _managed_venv_python_supported(base_python):
|
|
329
|
+
return f"no supported Python found for managed venv: {base_python}"
|
|
245
330
|
if venv_python.exists():
|
|
246
331
|
return None
|
|
247
332
|
try:
|
|
248
333
|
root.mkdir(parents=True, exist_ok=True)
|
|
249
334
|
result = subprocess.run(
|
|
250
|
-
[
|
|
335
|
+
[base_python, "-m", "venv", str(root / ".venv")],
|
|
251
336
|
capture_output=True,
|
|
252
337
|
text=True,
|
|
253
338
|
timeout=120,
|
|
@@ -280,18 +365,41 @@ def _npm_command_parts() -> tuple[list[str], dict[str, str]]:
|
|
|
280
365
|
desktop_node = str(os.environ.get("NEXO_DESKTOP_NODE", "")).strip()
|
|
281
366
|
bundled_npm_cli = str(os.environ.get("NEXO_DESKTOP_NPM_CLI", "")).strip()
|
|
282
367
|
env = dict(os.environ)
|
|
283
|
-
if desktop_node and bundled_npm_cli and Path(desktop_node).exists():
|
|
368
|
+
if desktop_node and bundled_npm_cli and Path(desktop_node).exists() and Path(bundled_npm_cli).exists():
|
|
284
369
|
env["ELECTRON_RUN_AS_NODE"] = "1"
|
|
370
|
+
_apply_desktop_npm_prefix(env)
|
|
285
371
|
return [desktop_node, bundled_npm_cli], env
|
|
286
372
|
return ["npm"], env
|
|
287
373
|
|
|
288
374
|
|
|
375
|
+
def _desktop_npm_prefix() -> str:
|
|
376
|
+
return (
|
|
377
|
+
str(os.environ.get("NEXO_DESKTOP_NPM_PREFIX", "")).strip()
|
|
378
|
+
or str(os.environ.get("NEXO_CLAUDE_PREFIX", "")).strip()
|
|
379
|
+
or str(NEXO_HOME / "runtime" / "bootstrap" / "npm-global")
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _apply_desktop_npm_prefix(env: dict[str, str]) -> None:
|
|
384
|
+
if env.get("ELECTRON_RUN_AS_NODE") != "1":
|
|
385
|
+
return
|
|
386
|
+
npm_prefix = _desktop_npm_prefix()
|
|
387
|
+
if not npm_prefix:
|
|
388
|
+
return
|
|
389
|
+
env.setdefault("NPM_CONFIG_PREFIX", npm_prefix)
|
|
390
|
+
prefix_bin = str(Path(npm_prefix) / "bin")
|
|
391
|
+
current_path = str(env.get("PATH", ""))
|
|
392
|
+
entries = [entry for entry in current_path.split(os.pathsep) if entry]
|
|
393
|
+
env["PATH"] = os.pathsep.join([prefix_bin, *[entry for entry in entries if entry != prefix_bin]])
|
|
394
|
+
|
|
395
|
+
|
|
289
396
|
def _run_npm(args: list[str], **kwargs):
|
|
290
397
|
cmd, env = _npm_command_parts()
|
|
291
398
|
extra_env = kwargs.pop("env", None)
|
|
292
399
|
merged_env = dict(env)
|
|
293
400
|
if extra_env:
|
|
294
401
|
merged_env.update(extra_env)
|
|
402
|
+
_apply_desktop_npm_prefix(merged_env)
|
|
295
403
|
return subprocess.run([*cmd, *args], env=merged_env, **kwargs)
|
|
296
404
|
|
|
297
405
|
|
|
@@ -653,6 +761,8 @@ def _reinstall_pip_deps() -> str | None:
|
|
|
653
761
|
if alt_pip.exists():
|
|
654
762
|
venv_pip = alt_pip
|
|
655
763
|
if not venv_pip.exists():
|
|
764
|
+
if desktop_product_requested():
|
|
765
|
+
return "managed Desktop venv pip is unavailable after repair"
|
|
656
766
|
# No venv, try system pip with --break-system-packages
|
|
657
767
|
try:
|
|
658
768
|
result = subprocess.run(
|
|
@@ -1268,7 +1378,7 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1268
1378
|
try:
|
|
1269
1379
|
_emit_progress(progress_fn, "Downloading and applying the latest npm package...")
|
|
1270
1380
|
result = _run_npm(
|
|
1271
|
-
["
|
|
1381
|
+
["install", "-g", "nexo-brain@latest"],
|
|
1272
1382
|
capture_output=True, text=True, timeout=120,
|
|
1273
1383
|
env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
|
|
1274
1384
|
)
|