nexo-brain 5.4.5 → 5.4.7
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 +256 -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.7",
|
|
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.7` is the current packaged-runtime line: tool-enforcement-map.json for Protocol Enforcer — canonical map of all 247 tools with enforcement metadata, plus verify script.
|
|
22
22
|
|
|
23
|
-
Previously in `5.4.
|
|
23
|
+
Previously in `5.4.6`: runtime dependency management in `nexo update` + daily auto-update cron.
|
|
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.7",
|
|
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": 2, "minute": 0},
|
|
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
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
"""Update plugin — pull latest code, backup DBs, run migrations, verify."""
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import sqlite3
|
|
7
8
|
import subprocess
|
|
@@ -321,6 +322,237 @@ def _reinstall_pip_deps() -> str | None:
|
|
|
321
322
|
return None
|
|
322
323
|
|
|
323
324
|
|
|
325
|
+
def _read_runtime_dependencies() -> list[dict]:
|
|
326
|
+
"""Read runtimeDependencies from package.json."""
|
|
327
|
+
for candidate in (PACKAGE_JSON, NEXO_HOME / "package.json"):
|
|
328
|
+
try:
|
|
329
|
+
if candidate.is_file():
|
|
330
|
+
data = json.loads(candidate.read_text())
|
|
331
|
+
deps = data.get("runtimeDependencies")
|
|
332
|
+
if isinstance(deps, list):
|
|
333
|
+
return deps
|
|
334
|
+
except Exception:
|
|
335
|
+
continue
|
|
336
|
+
# Fallback: check the npm-installed package's package.json
|
|
337
|
+
npm_src = _find_npm_pkg_src()
|
|
338
|
+
if npm_src:
|
|
339
|
+
pkg = npm_src.parent / "package.json"
|
|
340
|
+
try:
|
|
341
|
+
if pkg.is_file():
|
|
342
|
+
data = json.loads(pkg.read_text())
|
|
343
|
+
deps = data.get("runtimeDependencies")
|
|
344
|
+
if isinstance(deps, list):
|
|
345
|
+
return deps
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
_VALID_NPM_NAME = re.compile(r'^(@[a-z0-9\-_.]+/)?[a-z0-9\-_.]+$')
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _validate_npm_name(name: str) -> bool:
|
|
355
|
+
"""Validate that a package name follows npm naming conventions."""
|
|
356
|
+
return bool(name) and bool(_VALID_NPM_NAME.match(name)) and ".." not in name
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _get_npm_global_version(package_name: str) -> str | None:
|
|
360
|
+
"""Return the currently installed global npm package version, or None."""
|
|
361
|
+
try:
|
|
362
|
+
result = subprocess.run(
|
|
363
|
+
["npm", "list", "-g", package_name, "--json", "--depth=0"],
|
|
364
|
+
capture_output=True, text=True, timeout=15,
|
|
365
|
+
)
|
|
366
|
+
# npm list returns exit 1 with valid JSON for peer dep issues;
|
|
367
|
+
# always try to parse the output regardless of returncode.
|
|
368
|
+
if result.stdout.strip():
|
|
369
|
+
data = json.loads(result.stdout)
|
|
370
|
+
deps = data.get("dependencies", {})
|
|
371
|
+
info = deps.get(package_name)
|
|
372
|
+
if info and isinstance(info, dict):
|
|
373
|
+
return info.get("version")
|
|
374
|
+
except subprocess.TimeoutExpired:
|
|
375
|
+
pass
|
|
376
|
+
except FileNotFoundError:
|
|
377
|
+
pass # npm not installed
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _get_npm_registry_version(package_name: str) -> str | None:
|
|
384
|
+
"""Return the latest version of a package from the npm registry."""
|
|
385
|
+
try:
|
|
386
|
+
result = subprocess.run(
|
|
387
|
+
["npm", "view", package_name, "version"],
|
|
388
|
+
capture_output=True, text=True, timeout=15,
|
|
389
|
+
)
|
|
390
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
391
|
+
return result.stdout.strip()
|
|
392
|
+
except subprocess.TimeoutExpired:
|
|
393
|
+
pass
|
|
394
|
+
except FileNotFoundError:
|
|
395
|
+
pass # npm not installed
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _update_runtime_dependencies(progress_fn=None) -> list[dict]:
|
|
402
|
+
"""Update all declared runtimeDependencies. Returns a list of result dicts.
|
|
403
|
+
|
|
404
|
+
Each result dict contains:
|
|
405
|
+
name: package name
|
|
406
|
+
old_version: version before update (or None if not installed)
|
|
407
|
+
new_version: version after update (or None on failure)
|
|
408
|
+
status: "updated" | "already_latest" | "installed" | "failed" | "skipped"
|
|
409
|
+
error: error message (only when status == "failed")
|
|
410
|
+
"""
|
|
411
|
+
deps = _read_runtime_dependencies()
|
|
412
|
+
if not deps:
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
results = []
|
|
416
|
+
for dep in deps:
|
|
417
|
+
name = dep.get("name", "")
|
|
418
|
+
dep_type = dep.get("type", "")
|
|
419
|
+
optional = dep.get("optional", False)
|
|
420
|
+
|
|
421
|
+
if not name or dep_type != "npm-global":
|
|
422
|
+
results.append({
|
|
423
|
+
"name": name or "(unknown)",
|
|
424
|
+
"old_version": None,
|
|
425
|
+
"new_version": None,
|
|
426
|
+
"status": "skipped",
|
|
427
|
+
})
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
if not _validate_npm_name(name):
|
|
431
|
+
results.append({
|
|
432
|
+
"name": name,
|
|
433
|
+
"old_version": None,
|
|
434
|
+
"new_version": None,
|
|
435
|
+
"status": "failed",
|
|
436
|
+
"error": f"invalid npm package name: {name!r}",
|
|
437
|
+
})
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
_emit_progress(progress_fn, f"Checking runtime dependency: {name}...")
|
|
441
|
+
|
|
442
|
+
old_version = _get_npm_global_version(name)
|
|
443
|
+
latest_version = _get_npm_registry_version(name)
|
|
444
|
+
|
|
445
|
+
if old_version is None:
|
|
446
|
+
# Not installed
|
|
447
|
+
if optional:
|
|
448
|
+
results.append({
|
|
449
|
+
"name": name,
|
|
450
|
+
"old_version": None,
|
|
451
|
+
"new_version": None,
|
|
452
|
+
"status": "skipped",
|
|
453
|
+
})
|
|
454
|
+
continue
|
|
455
|
+
# Install it
|
|
456
|
+
_emit_progress(progress_fn, f"Installing {name}...")
|
|
457
|
+
try:
|
|
458
|
+
r = subprocess.run(
|
|
459
|
+
["npm", "install", "-g", name],
|
|
460
|
+
capture_output=True, text=True, timeout=120,
|
|
461
|
+
)
|
|
462
|
+
if r.returncode == 0:
|
|
463
|
+
new_version = _get_npm_global_version(name)
|
|
464
|
+
results.append({
|
|
465
|
+
"name": name,
|
|
466
|
+
"old_version": None,
|
|
467
|
+
"new_version": new_version,
|
|
468
|
+
"status": "installed",
|
|
469
|
+
})
|
|
470
|
+
else:
|
|
471
|
+
results.append({
|
|
472
|
+
"name": name,
|
|
473
|
+
"old_version": None,
|
|
474
|
+
"new_version": None,
|
|
475
|
+
"status": "failed",
|
|
476
|
+
"error": r.stderr or r.stdout or "npm install failed",
|
|
477
|
+
})
|
|
478
|
+
except subprocess.TimeoutExpired:
|
|
479
|
+
results.append({
|
|
480
|
+
"name": name, "old_version": None, "new_version": None,
|
|
481
|
+
"status": "failed", "error": "npm install timed out (120s)",
|
|
482
|
+
})
|
|
483
|
+
except Exception as e:
|
|
484
|
+
results.append({
|
|
485
|
+
"name": name, "old_version": None, "new_version": None,
|
|
486
|
+
"status": "failed", "error": str(e),
|
|
487
|
+
})
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
# Already installed — check if update needed
|
|
491
|
+
if latest_version and old_version == latest_version:
|
|
492
|
+
results.append({
|
|
493
|
+
"name": name,
|
|
494
|
+
"old_version": old_version,
|
|
495
|
+
"new_version": old_version,
|
|
496
|
+
"status": "already_latest",
|
|
497
|
+
})
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
# Update
|
|
501
|
+
_emit_progress(progress_fn, f"Updating {name} {old_version} -> {latest_version or 'latest'}...")
|
|
502
|
+
try:
|
|
503
|
+
r = subprocess.run(
|
|
504
|
+
["npm", "update", "-g", name],
|
|
505
|
+
capture_output=True, text=True, timeout=120,
|
|
506
|
+
)
|
|
507
|
+
if r.returncode == 0:
|
|
508
|
+
new_version = _get_npm_global_version(name) or latest_version
|
|
509
|
+
results.append({
|
|
510
|
+
"name": name,
|
|
511
|
+
"old_version": old_version,
|
|
512
|
+
"new_version": new_version,
|
|
513
|
+
"status": "updated" if new_version != old_version else "already_latest",
|
|
514
|
+
})
|
|
515
|
+
else:
|
|
516
|
+
results.append({
|
|
517
|
+
"name": name,
|
|
518
|
+
"old_version": old_version,
|
|
519
|
+
"new_version": old_version,
|
|
520
|
+
"status": "failed",
|
|
521
|
+
"error": r.stderr or r.stdout or "npm update failed",
|
|
522
|
+
})
|
|
523
|
+
except subprocess.TimeoutExpired:
|
|
524
|
+
results.append({
|
|
525
|
+
"name": name, "old_version": old_version, "new_version": old_version,
|
|
526
|
+
"status": "failed", "error": "npm update timed out (120s)",
|
|
527
|
+
})
|
|
528
|
+
except Exception as e:
|
|
529
|
+
results.append({
|
|
530
|
+
"name": name, "old_version": old_version, "new_version": old_version,
|
|
531
|
+
"status": "failed", "error": str(e),
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
return results
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _format_dep_results(dep_results: list[dict]) -> list[str]:
|
|
538
|
+
"""Format runtime dependency results as human-readable lines."""
|
|
539
|
+
lines = []
|
|
540
|
+
for dep in dep_results:
|
|
541
|
+
name = dep.get("name", "")
|
|
542
|
+
status = dep.get("status", "")
|
|
543
|
+
old_v = dep.get("old_version")
|
|
544
|
+
new_v = dep.get("new_version")
|
|
545
|
+
if status == "updated":
|
|
546
|
+
lines.append(f" Dependencies: {name} {old_v} -> {new_v}")
|
|
547
|
+
elif status == "installed":
|
|
548
|
+
lines.append(f" Dependencies: {name} installed ({new_v})")
|
|
549
|
+
elif status == "already_latest":
|
|
550
|
+
lines.append(f" Dependencies: {name} {old_v} (latest)")
|
|
551
|
+
elif status == "failed":
|
|
552
|
+
lines.append(f" WARNING: {name} update failed: {dep.get('error', 'unknown')}")
|
|
553
|
+
return lines
|
|
554
|
+
|
|
555
|
+
|
|
324
556
|
def _run_migrations() -> str | None:
|
|
325
557
|
"""Run init_db() to apply pending migrations. Returns error or None."""
|
|
326
558
|
# In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
|
|
@@ -709,6 +941,13 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
709
941
|
except Exception as e:
|
|
710
942
|
hook_sync_warning = f"{e}"
|
|
711
943
|
|
|
944
|
+
# Update runtime dependencies (best-effort, never aborts)
|
|
945
|
+
dep_results: list[dict] = []
|
|
946
|
+
try:
|
|
947
|
+
dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
|
|
948
|
+
except Exception:
|
|
949
|
+
pass # Non-critical
|
|
950
|
+
|
|
712
951
|
client_sync_warning = None
|
|
713
952
|
_emit_progress(progress_fn, "Refreshing shared client configs...")
|
|
714
953
|
clients_ok, client_sync_error = _sync_packaged_clients()
|
|
@@ -774,6 +1013,7 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
774
1013
|
lines.append(f" WARNING: hook sync: {hook_sync_warning}")
|
|
775
1014
|
if retired_runtime_files:
|
|
776
1015
|
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
1016
|
+
lines.extend(_format_dep_results(dep_results))
|
|
777
1017
|
if not client_sync_warning:
|
|
778
1018
|
lines.append(" Clients: configured client targets synced")
|
|
779
1019
|
else:
|
|
@@ -907,7 +1147,16 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
907
1147
|
except Exception as e:
|
|
908
1148
|
pass # Non-critical, log in function
|
|
909
1149
|
|
|
910
|
-
# Step 10:
|
|
1150
|
+
# Step 10: Update runtime dependencies (best-effort, never aborts)
|
|
1151
|
+
dep_results: list[dict] = []
|
|
1152
|
+
try:
|
|
1153
|
+
dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
|
|
1154
|
+
if dep_results:
|
|
1155
|
+
steps_done.append("runtime-deps")
|
|
1156
|
+
except Exception:
|
|
1157
|
+
pass # Non-critical
|
|
1158
|
+
|
|
1159
|
+
# Step 11: Sync shared client configs
|
|
911
1160
|
try:
|
|
912
1161
|
_emit_progress(progress_fn, "Refreshing shared client configs...")
|
|
913
1162
|
from client_sync import sync_all_clients
|
|
@@ -947,8 +1196,12 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
947
1196
|
pass # Non-critical, configs can be re-synced later
|
|
948
1197
|
|
|
949
1198
|
# Build result
|
|
1199
|
+
dep_summary_lines = _format_dep_results(dep_results)
|
|
950
1200
|
if pull_out == "Already up to date.":
|
|
951
|
-
|
|
1201
|
+
msg = f"Already up to date (v{old_version}). No changes pulled."
|
|
1202
|
+
if dep_summary_lines:
|
|
1203
|
+
msg += "\n" + "\n".join(dep_summary_lines)
|
|
1204
|
+
return msg
|
|
952
1205
|
|
|
953
1206
|
lines = ["UPDATE SUCCESSFUL"]
|
|
954
1207
|
if version_changed:
|
|
@@ -967,6 +1220,7 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
967
1220
|
lines.append(" Hooks: synced to NEXO_HOME")
|
|
968
1221
|
if retired_runtime_files:
|
|
969
1222
|
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
1223
|
+
lines.extend(dep_summary_lines)
|
|
970
1224
|
if "client-sync" in steps_done:
|
|
971
1225
|
lines.append(" Clients: configured client targets synced")
|
|
972
1226
|
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())
|