nexo-brain 5.4.4 → 5.4.6
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/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/package.json +4 -1
- package/src/auto_update.py +8 -0
- package/src/cli.py +11 -0
- package/src/crons/manifest.json +12 -0
- package/src/plugins/update.py +231 -2
- package/src/scripts/nexo-auto-update.py +149 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.6",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `5.4.
|
|
21
|
+
Version `5.4.6` is the current packaged-runtime line: runtime dependency management in `nexo update` + daily auto-update cron. `nexo update` now manages external dependencies declared in `runtimeDependencies` (starting with Claude Code).
|
|
22
22
|
|
|
23
|
-
Previously in `5.4.
|
|
23
|
+
Previously in `5.4.5`: test isolation for tree_hygiene module + fake venv to prevent CI timeout.
|
|
24
24
|
|
|
25
25
|
Start here:
|
|
26
26
|
- [5-minute quickstart](docs/quickstart-5-minutes.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.6",
|
|
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",
|
|
@@ -60,6 +60,9 @@
|
|
|
60
60
|
"scripts": {
|
|
61
61
|
"postinstall": "node bin/postinstall.js"
|
|
62
62
|
},
|
|
63
|
+
"runtimeDependencies": [
|
|
64
|
+
{"name": "@anthropic-ai/claude-code", "type": "npm-global", "optional": false}
|
|
65
|
+
],
|
|
63
66
|
"engines": {
|
|
64
67
|
"node": ">=18"
|
|
65
68
|
},
|
package/src/auto_update.py
CHANGED
|
@@ -2267,6 +2267,14 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
|
|
|
2267
2267
|
sync_result["warnings"].append(
|
|
2268
2268
|
f"Preserved {len(copy_stats['script_conflicts'])} personal runtime script collision(s) in NEXO_HOME/scripts"
|
|
2269
2269
|
)
|
|
2270
|
+
# Update runtime dependencies (best-effort)
|
|
2271
|
+
try:
|
|
2272
|
+
from plugins.update import _update_runtime_dependencies, _format_dep_results
|
|
2273
|
+
dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
|
|
2274
|
+
sync_result["runtime_dependencies"] = dep_results
|
|
2275
|
+
except Exception:
|
|
2276
|
+
pass # Non-critical
|
|
2277
|
+
|
|
2270
2278
|
_emit_progress(progress_fn, "Runtime update completed.")
|
|
2271
2279
|
except Exception as e:
|
|
2272
2280
|
_emit_progress(progress_fn, "Update failed; restoring previous runtime state...")
|
package/src/cli.py
CHANGED
|
@@ -903,6 +903,17 @@ def _update(args):
|
|
|
903
903
|
print(f" Personal schedules: {invalid} declarations need review")
|
|
904
904
|
if result.get("pulled_source"):
|
|
905
905
|
print(" Source repo: pulled latest fast-forward before sync")
|
|
906
|
+
for dep in result.get("runtime_dependencies") or []:
|
|
907
|
+
dep_name = dep.get("name", "")
|
|
908
|
+
dep_status = dep.get("status", "")
|
|
909
|
+
if dep_status == "updated":
|
|
910
|
+
print(f" Dependencies: {dep_name} {dep.get('old_version')} -> {dep.get('new_version')}")
|
|
911
|
+
elif dep_status == "installed":
|
|
912
|
+
print(f" Dependencies: {dep_name} installed ({dep.get('new_version')})")
|
|
913
|
+
elif dep_status == "already_latest":
|
|
914
|
+
print(f" Dependencies: {dep_name} {dep.get('old_version')} (latest)")
|
|
915
|
+
elif dep_status == "failed":
|
|
916
|
+
print(f" WARNING: {dep_name} update failed: {dep.get('error', 'unknown')}")
|
|
906
917
|
if choice.get("prompted"):
|
|
907
918
|
print(f" Power policy: {runtime_power['format_power_policy_label'](choice.get('policy'))}")
|
|
908
919
|
if power_result.get("message"):
|
package/src/crons/manifest.json
CHANGED
|
@@ -203,6 +203,18 @@
|
|
|
203
203
|
"max_catchup_age": 0,
|
|
204
204
|
"run_on_boot": true,
|
|
205
205
|
"run_on_wake": true
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"id": "auto-update",
|
|
209
|
+
"script": "scripts/nexo-auto-update.py",
|
|
210
|
+
"schedule": {"hour": 3, "minute": 45},
|
|
211
|
+
"description": "Daily auto-update — Brain + runtime dependencies",
|
|
212
|
+
"core": true,
|
|
213
|
+
"recovery_policy": "catchup",
|
|
214
|
+
"idempotent": true,
|
|
215
|
+
"max_catchup_age": 172800,
|
|
216
|
+
"run_on_boot": true,
|
|
217
|
+
"run_on_wake": true
|
|
206
218
|
}
|
|
207
219
|
]
|
|
208
220
|
}
|
package/src/plugins/update.py
CHANGED
|
@@ -321,6 +321,213 @@ def _reinstall_pip_deps() -> str | None:
|
|
|
321
321
|
return None
|
|
322
322
|
|
|
323
323
|
|
|
324
|
+
def _read_runtime_dependencies() -> list[dict]:
|
|
325
|
+
"""Read runtimeDependencies from package.json."""
|
|
326
|
+
for candidate in (PACKAGE_JSON, NEXO_HOME / "package.json"):
|
|
327
|
+
try:
|
|
328
|
+
if candidate.is_file():
|
|
329
|
+
data = json.loads(candidate.read_text())
|
|
330
|
+
deps = data.get("runtimeDependencies")
|
|
331
|
+
if isinstance(deps, list):
|
|
332
|
+
return deps
|
|
333
|
+
except Exception:
|
|
334
|
+
continue
|
|
335
|
+
# Fallback: check the npm-installed package's package.json
|
|
336
|
+
npm_src = _find_npm_pkg_src()
|
|
337
|
+
if npm_src:
|
|
338
|
+
pkg = npm_src.parent / "package.json"
|
|
339
|
+
try:
|
|
340
|
+
if pkg.is_file():
|
|
341
|
+
data = json.loads(pkg.read_text())
|
|
342
|
+
deps = data.get("runtimeDependencies")
|
|
343
|
+
if isinstance(deps, list):
|
|
344
|
+
return deps
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
return []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _get_npm_global_version(package_name: str) -> str | None:
|
|
351
|
+
"""Return the currently installed global npm package version, or None."""
|
|
352
|
+
try:
|
|
353
|
+
result = subprocess.run(
|
|
354
|
+
["npm", "list", "-g", package_name, "--json", "--depth=0"],
|
|
355
|
+
capture_output=True, text=True, timeout=15,
|
|
356
|
+
)
|
|
357
|
+
if result.returncode == 0:
|
|
358
|
+
data = json.loads(result.stdout)
|
|
359
|
+
deps = data.get("dependencies", {})
|
|
360
|
+
info = deps.get(package_name)
|
|
361
|
+
if info and isinstance(info, dict):
|
|
362
|
+
return info.get("version")
|
|
363
|
+
except subprocess.TimeoutExpired:
|
|
364
|
+
pass
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _get_npm_registry_version(package_name: str) -> str | None:
|
|
371
|
+
"""Return the latest version of a package from the npm registry."""
|
|
372
|
+
try:
|
|
373
|
+
result = subprocess.run(
|
|
374
|
+
["npm", "view", package_name, "version"],
|
|
375
|
+
capture_output=True, text=True, timeout=15,
|
|
376
|
+
)
|
|
377
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
378
|
+
return result.stdout.strip()
|
|
379
|
+
except subprocess.TimeoutExpired:
|
|
380
|
+
pass
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _update_runtime_dependencies(progress_fn=None) -> list[dict]:
|
|
387
|
+
"""Update all declared runtimeDependencies. Returns a list of result dicts.
|
|
388
|
+
|
|
389
|
+
Each result dict contains:
|
|
390
|
+
name: package name
|
|
391
|
+
old_version: version before update (or None if not installed)
|
|
392
|
+
new_version: version after update (or None on failure)
|
|
393
|
+
status: "updated" | "already_latest" | "installed" | "failed" | "skipped"
|
|
394
|
+
error: error message (only when status == "failed")
|
|
395
|
+
"""
|
|
396
|
+
deps = _read_runtime_dependencies()
|
|
397
|
+
if not deps:
|
|
398
|
+
return []
|
|
399
|
+
|
|
400
|
+
results = []
|
|
401
|
+
for dep in deps:
|
|
402
|
+
name = dep.get("name", "")
|
|
403
|
+
dep_type = dep.get("type", "")
|
|
404
|
+
optional = dep.get("optional", True)
|
|
405
|
+
|
|
406
|
+
if not name or dep_type != "npm-global":
|
|
407
|
+
results.append({
|
|
408
|
+
"name": name or "(unknown)",
|
|
409
|
+
"old_version": None,
|
|
410
|
+
"new_version": None,
|
|
411
|
+
"status": "skipped",
|
|
412
|
+
})
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
_emit_progress(progress_fn, f"Checking runtime dependency: {name}...")
|
|
416
|
+
|
|
417
|
+
old_version = _get_npm_global_version(name)
|
|
418
|
+
latest_version = _get_npm_registry_version(name)
|
|
419
|
+
|
|
420
|
+
if old_version is None:
|
|
421
|
+
# Not installed
|
|
422
|
+
if optional:
|
|
423
|
+
results.append({
|
|
424
|
+
"name": name,
|
|
425
|
+
"old_version": None,
|
|
426
|
+
"new_version": None,
|
|
427
|
+
"status": "skipped",
|
|
428
|
+
})
|
|
429
|
+
continue
|
|
430
|
+
# Install it
|
|
431
|
+
_emit_progress(progress_fn, f"Installing {name}...")
|
|
432
|
+
try:
|
|
433
|
+
r = subprocess.run(
|
|
434
|
+
["npm", "install", "-g", name],
|
|
435
|
+
capture_output=True, text=True, timeout=120,
|
|
436
|
+
)
|
|
437
|
+
if r.returncode == 0:
|
|
438
|
+
new_version = _get_npm_global_version(name)
|
|
439
|
+
results.append({
|
|
440
|
+
"name": name,
|
|
441
|
+
"old_version": None,
|
|
442
|
+
"new_version": new_version,
|
|
443
|
+
"status": "installed",
|
|
444
|
+
})
|
|
445
|
+
else:
|
|
446
|
+
results.append({
|
|
447
|
+
"name": name,
|
|
448
|
+
"old_version": None,
|
|
449
|
+
"new_version": None,
|
|
450
|
+
"status": "failed",
|
|
451
|
+
"error": r.stderr or r.stdout or "npm install failed",
|
|
452
|
+
})
|
|
453
|
+
except subprocess.TimeoutExpired:
|
|
454
|
+
results.append({
|
|
455
|
+
"name": name, "old_version": None, "new_version": None,
|
|
456
|
+
"status": "failed", "error": "npm install timed out (120s)",
|
|
457
|
+
})
|
|
458
|
+
except Exception as e:
|
|
459
|
+
results.append({
|
|
460
|
+
"name": name, "old_version": None, "new_version": None,
|
|
461
|
+
"status": "failed", "error": str(e),
|
|
462
|
+
})
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
# Already installed — check if update needed
|
|
466
|
+
if latest_version and old_version == latest_version:
|
|
467
|
+
results.append({
|
|
468
|
+
"name": name,
|
|
469
|
+
"old_version": old_version,
|
|
470
|
+
"new_version": old_version,
|
|
471
|
+
"status": "already_latest",
|
|
472
|
+
})
|
|
473
|
+
continue
|
|
474
|
+
|
|
475
|
+
# Update
|
|
476
|
+
_emit_progress(progress_fn, f"Updating {name} {old_version} -> {latest_version or 'latest'}...")
|
|
477
|
+
try:
|
|
478
|
+
r = subprocess.run(
|
|
479
|
+
["npm", "update", "-g", name],
|
|
480
|
+
capture_output=True, text=True, timeout=120,
|
|
481
|
+
)
|
|
482
|
+
if r.returncode == 0:
|
|
483
|
+
new_version = _get_npm_global_version(name) or latest_version
|
|
484
|
+
results.append({
|
|
485
|
+
"name": name,
|
|
486
|
+
"old_version": old_version,
|
|
487
|
+
"new_version": new_version,
|
|
488
|
+
"status": "updated" if new_version != old_version else "already_latest",
|
|
489
|
+
})
|
|
490
|
+
else:
|
|
491
|
+
results.append({
|
|
492
|
+
"name": name,
|
|
493
|
+
"old_version": old_version,
|
|
494
|
+
"new_version": old_version,
|
|
495
|
+
"status": "failed",
|
|
496
|
+
"error": r.stderr or r.stdout or "npm update failed",
|
|
497
|
+
})
|
|
498
|
+
except subprocess.TimeoutExpired:
|
|
499
|
+
results.append({
|
|
500
|
+
"name": name, "old_version": old_version, "new_version": old_version,
|
|
501
|
+
"status": "failed", "error": "npm update timed out (120s)",
|
|
502
|
+
})
|
|
503
|
+
except Exception as e:
|
|
504
|
+
results.append({
|
|
505
|
+
"name": name, "old_version": old_version, "new_version": old_version,
|
|
506
|
+
"status": "failed", "error": str(e),
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
return results
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _format_dep_results(dep_results: list[dict]) -> list[str]:
|
|
513
|
+
"""Format runtime dependency results as human-readable lines."""
|
|
514
|
+
lines = []
|
|
515
|
+
for dep in dep_results:
|
|
516
|
+
name = dep.get("name", "")
|
|
517
|
+
status = dep.get("status", "")
|
|
518
|
+
old_v = dep.get("old_version")
|
|
519
|
+
new_v = dep.get("new_version")
|
|
520
|
+
if status == "updated":
|
|
521
|
+
lines.append(f" Dependencies: {name} {old_v} -> {new_v}")
|
|
522
|
+
elif status == "installed":
|
|
523
|
+
lines.append(f" Dependencies: {name} installed ({new_v})")
|
|
524
|
+
elif status == "already_latest":
|
|
525
|
+
lines.append(f" Dependencies: {name} {old_v} (latest)")
|
|
526
|
+
elif status == "failed":
|
|
527
|
+
lines.append(f" WARNING: {name} update failed: {dep.get('error', 'unknown')}")
|
|
528
|
+
return lines
|
|
529
|
+
|
|
530
|
+
|
|
324
531
|
def _run_migrations() -> str | None:
|
|
325
532
|
"""Run init_db() to apply pending migrations. Returns error or None."""
|
|
326
533
|
# In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
|
|
@@ -709,6 +916,13 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
709
916
|
except Exception as e:
|
|
710
917
|
hook_sync_warning = f"{e}"
|
|
711
918
|
|
|
919
|
+
# Update runtime dependencies (best-effort, never aborts)
|
|
920
|
+
dep_results: list[dict] = []
|
|
921
|
+
try:
|
|
922
|
+
dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
|
|
923
|
+
except Exception:
|
|
924
|
+
pass # Non-critical
|
|
925
|
+
|
|
712
926
|
client_sync_warning = None
|
|
713
927
|
_emit_progress(progress_fn, "Refreshing shared client configs...")
|
|
714
928
|
clients_ok, client_sync_error = _sync_packaged_clients()
|
|
@@ -774,6 +988,7 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
774
988
|
lines.append(f" WARNING: hook sync: {hook_sync_warning}")
|
|
775
989
|
if retired_runtime_files:
|
|
776
990
|
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
991
|
+
lines.extend(_format_dep_results(dep_results))
|
|
777
992
|
if not client_sync_warning:
|
|
778
993
|
lines.append(" Clients: configured client targets synced")
|
|
779
994
|
else:
|
|
@@ -907,7 +1122,16 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
907
1122
|
except Exception as e:
|
|
908
1123
|
pass # Non-critical, log in function
|
|
909
1124
|
|
|
910
|
-
# Step 10:
|
|
1125
|
+
# Step 10: Update runtime dependencies (best-effort, never aborts)
|
|
1126
|
+
dep_results: list[dict] = []
|
|
1127
|
+
try:
|
|
1128
|
+
dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
|
|
1129
|
+
if dep_results:
|
|
1130
|
+
steps_done.append("runtime-deps")
|
|
1131
|
+
except Exception:
|
|
1132
|
+
pass # Non-critical
|
|
1133
|
+
|
|
1134
|
+
# Step 11: Sync shared client configs
|
|
911
1135
|
try:
|
|
912
1136
|
_emit_progress(progress_fn, "Refreshing shared client configs...")
|
|
913
1137
|
from client_sync import sync_all_clients
|
|
@@ -947,8 +1171,12 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
947
1171
|
pass # Non-critical, configs can be re-synced later
|
|
948
1172
|
|
|
949
1173
|
# Build result
|
|
1174
|
+
dep_summary_lines = _format_dep_results(dep_results)
|
|
950
1175
|
if pull_out == "Already up to date.":
|
|
951
|
-
|
|
1176
|
+
msg = f"Already up to date (v{old_version}). No changes pulled."
|
|
1177
|
+
if dep_summary_lines:
|
|
1178
|
+
msg += "\n" + "\n".join(dep_summary_lines)
|
|
1179
|
+
return msg
|
|
952
1180
|
|
|
953
1181
|
lines = ["UPDATE SUCCESSFUL"]
|
|
954
1182
|
if version_changed:
|
|
@@ -967,6 +1195,7 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
967
1195
|
lines.append(" Hooks: synced to NEXO_HOME")
|
|
968
1196
|
if retired_runtime_files:
|
|
969
1197
|
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
1198
|
+
lines.extend(dep_summary_lines)
|
|
970
1199
|
if "client-sync" in steps_done:
|
|
971
1200
|
lines.append(" Clients: configured client targets synced")
|
|
972
1201
|
lines.append("")
|
|
@@ -1,6 +1,151 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""
|
|
3
|
+
NEXO Auto-Update — Daily automatic update of Brain + runtime dependencies.
|
|
4
|
+
|
|
5
|
+
Runs once daily via LaunchAgent. Executes the same update flow as `nexo update`:
|
|
6
|
+
- Brain itself (git pull or npm update)
|
|
7
|
+
- Runtime dependencies declared in package.json runtimeDependencies
|
|
8
|
+
|
|
9
|
+
Zero interaction required. Logs results to NEXO_HOME/logs/auto-update.log.
|
|
10
|
+
Idempotent — safe to run multiple times.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import fcntl
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
3
17
|
import sys
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
22
|
+
_script_dir = Path(__file__).resolve().parent
|
|
23
|
+
_repo_src = _script_dir.parent
|
|
24
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
|
|
25
|
+
if str(NEXO_CODE) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(NEXO_CODE))
|
|
27
|
+
|
|
28
|
+
LOG_DIR = NEXO_HOME / "logs"
|
|
29
|
+
LOG_FILE = LOG_DIR / "auto-update.log"
|
|
30
|
+
LOCK_FILE = NEXO_HOME / "data" / "auto-update.lock"
|
|
31
|
+
MAX_LOG_SIZE = 512 * 1024 # 512 KB
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _log(msg: str):
|
|
35
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
36
|
+
line = f"[{ts}] {msg}\n"
|
|
37
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
# Rotate if too large
|
|
39
|
+
if LOG_FILE.exists() and LOG_FILE.stat().st_size > MAX_LOG_SIZE:
|
|
40
|
+
rotated = LOG_FILE.with_suffix(".log.1")
|
|
41
|
+
try:
|
|
42
|
+
LOG_FILE.rename(rotated)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
with open(LOG_FILE, "a") as f:
|
|
46
|
+
f.write(line)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _acquire_lock():
|
|
50
|
+
"""Acquire an exclusive lock to prevent concurrent updates."""
|
|
51
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
lock_fd = open(LOCK_FILE, "w")
|
|
53
|
+
try:
|
|
54
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
55
|
+
return lock_fd
|
|
56
|
+
except (IOError, OSError):
|
|
57
|
+
lock_fd.close()
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main():
|
|
62
|
+
lock_fd = _acquire_lock()
|
|
63
|
+
if lock_fd is None:
|
|
64
|
+
_log("Skipped: another auto-update is already running.")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
_log("Auto-update started.")
|
|
69
|
+
|
|
70
|
+
# Use `nexo update --json` via CLI for the full update flow
|
|
71
|
+
nexo_bin = NEXO_HOME / "bin" / "nexo"
|
|
72
|
+
if not nexo_bin.exists():
|
|
73
|
+
# Try the npm global bin
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
["which", "nexo"],
|
|
77
|
+
capture_output=True, text=True, timeout=5,
|
|
78
|
+
)
|
|
79
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
80
|
+
nexo_bin = Path(result.stdout.strip())
|
|
81
|
+
except subprocess.TimeoutExpired:
|
|
82
|
+
pass
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
if not nexo_bin.exists():
|
|
87
|
+
_log("ERROR: nexo CLI not found. Cannot auto-update.")
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
[str(nexo_bin), "update", "--json"],
|
|
93
|
+
capture_output=True, text=True,
|
|
94
|
+
timeout=300, # 5 minutes max
|
|
95
|
+
env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_CODE)},
|
|
96
|
+
)
|
|
97
|
+
except subprocess.TimeoutExpired:
|
|
98
|
+
_log("ERROR: Auto-update timed out after 300s.")
|
|
99
|
+
return 1
|
|
100
|
+
|
|
101
|
+
if result.returncode != 0:
|
|
102
|
+
_log(f"ERROR: nexo update failed (exit {result.returncode}): {result.stderr or result.stdout}")
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
# Parse and log results
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(result.stdout)
|
|
108
|
+
except (json.JSONDecodeError, ValueError):
|
|
109
|
+
_log(f"Update completed but output was not JSON: {result.stdout[:500]}")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
# Log Brain update status
|
|
113
|
+
mode = data.get("mode", "unknown")
|
|
114
|
+
if "Already up to date" in str(data.get("message", "")):
|
|
115
|
+
_log(f"Brain: already up to date ({mode} mode).")
|
|
116
|
+
else:
|
|
117
|
+
version_info = ""
|
|
118
|
+
if data.get("version"):
|
|
119
|
+
version_info = f" v{data['version']}"
|
|
120
|
+
_log(f"Brain: updated{version_info} ({mode} mode).")
|
|
121
|
+
|
|
122
|
+
# Log runtime dependency results
|
|
123
|
+
deps = data.get("runtime_dependencies") or []
|
|
124
|
+
for dep in deps:
|
|
125
|
+
name = dep.get("name", "")
|
|
126
|
+
status = dep.get("status", "")
|
|
127
|
+
if status == "updated":
|
|
128
|
+
_log(f"Dependency: {name} {dep.get('old_version')} -> {dep.get('new_version')}")
|
|
129
|
+
elif status == "installed":
|
|
130
|
+
_log(f"Dependency: {name} installed ({dep.get('new_version')})")
|
|
131
|
+
elif status == "already_latest":
|
|
132
|
+
_log(f"Dependency: {name} {dep.get('old_version')} (latest)")
|
|
133
|
+
elif status == "failed":
|
|
134
|
+
_log(f"Dependency WARNING: {name} failed: {dep.get('error', 'unknown')}")
|
|
135
|
+
|
|
136
|
+
_log("Auto-update completed successfully.")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
except Exception as e:
|
|
140
|
+
_log(f"ERROR: Unexpected error: {e}")
|
|
141
|
+
return 1
|
|
142
|
+
finally:
|
|
143
|
+
try:
|
|
144
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
145
|
+
lock_fd.close()
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
sys.exit(main())
|