nexo-brain 5.3.6 → 5.3.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 +1 -1
- package/package.json +1 -1
- package/src/cli.py +55 -0
- package/src/doctor/providers/runtime.py +5 -5
- package/src/plugins/update.py +126 -3
- package/src/user_data_portability.py +302 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.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
|
@@ -89,7 +89,7 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
|
|
|
89
89
|
- when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
|
|
90
90
|
- NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
|
|
91
91
|
|
|
92
|
-
Version `5.3.6`
|
|
92
|
+
Version `5.3.7` closes the remaining packaged-runtime happy-path gap and finally exposes portable user-data migration commands: packaged `nexo update` now self-heals cron definitions and LaunchAgents after a successful npm bump, new `nexo export` / `nexo import` commands move operator data as a safe bundle instead of leaving that flow implicit, and runtime doctor now distinguishes tracked historical Codex drift from an actually broken runtime so cleaned installs stop staying red for stale transcript debt alone. Version `5.3.6` hardened the Claude Code bootstrap path and related runtime hygiene: managed client sync now writes the NEXO MCP server where current Claude Code actually reads it (`~/.claude.json`), script classification is stricter about core-vs-personal runtime artifacts, schedule status distinguishes genuinely running jobs from broken ones, and retroactive learnings stop opening keyword-only false positives outside their declared `applies_to` scope. Version `5.3.5` already keeps CLI version visibility honest right after `nexo update`: if the cached npm version lags behind the runtime you just installed, `nexo` / `nexo chat` now clamp `Latest` to the installed version and refresh the cache instead of showing a stale older release. Version `5.3.4` already cleaned up legacy core alias leakage and added the version-status banner. Version `5.3.3` closed the remaining packaged-runtime doctor mismatch: the built-in hourly backup helper is now inventoried as a core LaunchAgent, so clean installs no longer get a false unknown-LaunchAgent warning. Version `5.3.2` already hardened the runtime boundary by persisting which runtime scripts/hooks are core product artifacts, keeping `nexo scripts` from mixing those into the personal bucket, and migrating the legacy Claude Code heartbeat wrappers into managed core hooks.
|
|
93
93
|
|
|
94
94
|
Version `5.3.1` normalizes packaged npm installs so they behave like packaged npm installs: `nexo update` now keeps the runtime anchored to `~/.nexo`, refreshes packaged bootstrap/client artifacts after upgrade, avoids repo-only release-artifact drift in installed runtimes, and keeps personal scripts on the canonical packaged path.
|
|
95
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.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",
|
package/src/cli.py
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
Entry points:
|
|
5
5
|
nexo chat [PATH]
|
|
6
|
+
nexo export [PATH] [--json]
|
|
7
|
+
nexo import PATH [--json]
|
|
6
8
|
nexo scripts list [--all] [--json]
|
|
7
9
|
nexo scripts create NAME [--runtime python|shell] [--description TEXT]
|
|
8
10
|
nexo scripts classify [--json]
|
|
@@ -558,6 +560,43 @@ def _scripts_doctor(args):
|
|
|
558
560
|
return 0
|
|
559
561
|
|
|
560
562
|
|
|
563
|
+
def _export_bundle(args):
|
|
564
|
+
from user_data_portability import export_user_bundle
|
|
565
|
+
|
|
566
|
+
result = export_user_bundle(args.path or "")
|
|
567
|
+
if args.json:
|
|
568
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
569
|
+
else:
|
|
570
|
+
if not result.get("ok"):
|
|
571
|
+
print(result.get("error", "Export failed"), file=sys.stderr)
|
|
572
|
+
return 1
|
|
573
|
+
sections = result.get("sections", {})
|
|
574
|
+
script_count = sections.get("personal_scripts", {}).get("files", 0)
|
|
575
|
+
print(f"User data export written to {result['path']}")
|
|
576
|
+
print(f" Personal scripts: {script_count}")
|
|
577
|
+
print(f" Sections: {', '.join(sorted(sections))}")
|
|
578
|
+
return 0 if result.get("ok") else 1
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _import_bundle(args):
|
|
582
|
+
from user_data_portability import import_user_bundle
|
|
583
|
+
|
|
584
|
+
result = import_user_bundle(args.path)
|
|
585
|
+
if args.json:
|
|
586
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
587
|
+
else:
|
|
588
|
+
if not result.get("ok"):
|
|
589
|
+
print(result.get("error", "Import failed"), file=sys.stderr)
|
|
590
|
+
return 1
|
|
591
|
+
restored = result.get("restored", {})
|
|
592
|
+
script_count = restored.get("personal_scripts", {}).get("files", 0)
|
|
593
|
+
print(f"User data imported from {result['path']}")
|
|
594
|
+
print(f" Safety backup: {result['safety_backup']}")
|
|
595
|
+
print(f" Personal scripts restored: {script_count}")
|
|
596
|
+
print(f" Sections: {', '.join(sorted(restored))}")
|
|
597
|
+
return 0 if result.get("ok") else 1
|
|
598
|
+
|
|
599
|
+
|
|
561
600
|
def _runtime_python_candidates() -> list[str]:
|
|
562
601
|
candidates: list[str] = []
|
|
563
602
|
seen: set[str] = set()
|
|
@@ -1588,6 +1627,8 @@ def _print_help():
|
|
|
1588
1627
|
|
|
1589
1628
|
Commands:
|
|
1590
1629
|
nexo chat [path] [--client claude_code|codex] Launch a NEXO terminal client
|
|
1630
|
+
nexo export [path] Export a portable user-data bundle
|
|
1631
|
+
nexo import PATH Import a portable user-data bundle
|
|
1591
1632
|
nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
|
|
1592
1633
|
nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
|
|
1593
1634
|
Personal scripts
|
|
@@ -1618,6 +1659,16 @@ def main():
|
|
|
1618
1659
|
help="Override the chat picker and launch a specific terminal client",
|
|
1619
1660
|
)
|
|
1620
1661
|
|
|
1662
|
+
# -- export --
|
|
1663
|
+
export_parser = sub.add_parser("export", help="Export a portable user-data bundle")
|
|
1664
|
+
export_parser.add_argument("path", nargs="?", default="", help="Output bundle path (default: NEXO_HOME/exports/...)")
|
|
1665
|
+
export_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
1666
|
+
|
|
1667
|
+
# -- import --
|
|
1668
|
+
import_parser = sub.add_parser("import", help="Import a portable user-data bundle")
|
|
1669
|
+
import_parser.add_argument("path", help="Bundle path created by `nexo export`")
|
|
1670
|
+
import_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
1671
|
+
|
|
1621
1672
|
# -- scripts --
|
|
1622
1673
|
scripts_parser = sub.add_parser("scripts", help="Manage personal scripts")
|
|
1623
1674
|
scripts_sub = scripts_parser.add_subparsers(dest="scripts_command")
|
|
@@ -1828,6 +1879,10 @@ def main():
|
|
|
1828
1879
|
return 0
|
|
1829
1880
|
elif args.command == "chat":
|
|
1830
1881
|
return _chat(args)
|
|
1882
|
+
elif args.command == "export":
|
|
1883
|
+
return _export_bundle(args)
|
|
1884
|
+
elif args.command == "import":
|
|
1885
|
+
return _import_bundle(args)
|
|
1831
1886
|
elif args.command == "update":
|
|
1832
1887
|
return _update(args)
|
|
1833
1888
|
elif args.command == "clients":
|
|
@@ -2157,10 +2157,8 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
|
|
|
2157
2157
|
and audit["delete_without_protocol"] == 0
|
|
2158
2158
|
and audit["delete_without_guard_ack"] == 0
|
|
2159
2159
|
)
|
|
2160
|
-
|
|
2160
|
+
tracked_write_without_open_debt = (
|
|
2161
2161
|
no_open_conditioned_debt
|
|
2162
|
-
and audit.get("latest_violation_age_seconds") is not None
|
|
2163
|
-
and float(audit["latest_violation_age_seconds"]) >= 172800
|
|
2164
2162
|
and audit["write_without_protocol"] > 0
|
|
2165
2163
|
and audit["write_without_guard_ack"] == 0
|
|
2166
2164
|
and audit["delete_without_protocol"] == 0
|
|
@@ -2168,7 +2166,7 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
|
|
|
2168
2166
|
)
|
|
2169
2167
|
|
|
2170
2168
|
if audit["write_without_protocol"] or audit["write_without_guard_ack"]:
|
|
2171
|
-
if
|
|
2169
|
+
if tracked_write_without_open_debt:
|
|
2172
2170
|
status = "healthy"
|
|
2173
2171
|
severity = "info"
|
|
2174
2172
|
else:
|
|
@@ -2191,7 +2189,9 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
|
|
|
2191
2189
|
severity=severity,
|
|
2192
2190
|
summary=(
|
|
2193
2191
|
"Historical Codex conditioned-file drift has no open protocol debt"
|
|
2194
|
-
if historical_read_only
|
|
2192
|
+
if historical_read_only
|
|
2193
|
+
else "Tracked Codex conditioned-file drift has no open protocol debt"
|
|
2194
|
+
if tracked_write_without_open_debt
|
|
2195
2195
|
else "Recent Codex sessions respect conditioned-file discipline"
|
|
2196
2196
|
if status == "healthy"
|
|
2197
2197
|
else "Recent Codex sessions are bypassing conditioned-file discipline"
|
package/src/plugins/update.py
CHANGED
|
@@ -89,7 +89,10 @@ def _refresh_installed_manifest():
|
|
|
89
89
|
dst_crons.mkdir(parents=True, exist_ok=True)
|
|
90
90
|
for f in src_crons.iterdir():
|
|
91
91
|
if f.is_file():
|
|
92
|
-
|
|
92
|
+
dest = dst_crons / f.name
|
|
93
|
+
if _paths_match(f, dest):
|
|
94
|
+
continue
|
|
95
|
+
shutil.copy2(str(f), str(dest))
|
|
93
96
|
config_dir = NEXO_HOME / "config"
|
|
94
97
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
95
98
|
payload = {
|
|
@@ -355,7 +358,8 @@ def _sync_hooks_to_home():
|
|
|
355
358
|
for f in hooks_src.iterdir():
|
|
356
359
|
if f.is_file() and f.suffix == ".sh":
|
|
357
360
|
dest = hooks_dest / f.name
|
|
358
|
-
|
|
361
|
+
if not _paths_match(f, dest):
|
|
362
|
+
shutil.copy2(str(f), str(dest))
|
|
359
363
|
os.chmod(str(dest), 0o755)
|
|
360
364
|
synced += 1
|
|
361
365
|
if synced:
|
|
@@ -499,6 +503,93 @@ def _emit_progress(progress_fn, message: str) -> None:
|
|
|
499
503
|
pass
|
|
500
504
|
|
|
501
505
|
|
|
506
|
+
def _paths_match(src: Path, dest: Path) -> bool:
|
|
507
|
+
try:
|
|
508
|
+
return src.exists() and dest.exists() and src.samefile(dest)
|
|
509
|
+
except Exception:
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _sync_packaged_crons(progress_fn=None) -> tuple[bool, str | None]:
|
|
514
|
+
sync_path = NEXO_HOME / "crons" / "sync.py"
|
|
515
|
+
if not sync_path.is_file():
|
|
516
|
+
_refresh_installed_manifest()
|
|
517
|
+
return True, None
|
|
518
|
+
try:
|
|
519
|
+
_emit_progress(progress_fn, "Syncing core cron definitions...")
|
|
520
|
+
result = subprocess.run(
|
|
521
|
+
[sys.executable, str(sync_path)],
|
|
522
|
+
cwd=str(NEXO_HOME),
|
|
523
|
+
capture_output=True,
|
|
524
|
+
text=True,
|
|
525
|
+
timeout=30,
|
|
526
|
+
env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_HOME)},
|
|
527
|
+
)
|
|
528
|
+
if result.returncode != 0:
|
|
529
|
+
return False, result.stderr.strip() or result.stdout.strip() or "cron sync failed"
|
|
530
|
+
_refresh_installed_manifest()
|
|
531
|
+
return True, None
|
|
532
|
+
except Exception as e:
|
|
533
|
+
return False, f"cron sync error: {e}"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _reload_launch_agents_after_bump() -> dict:
|
|
537
|
+
result: dict = {
|
|
538
|
+
"scanned": 0,
|
|
539
|
+
"reloaded": 0,
|
|
540
|
+
"skipped_missing": 0,
|
|
541
|
+
"errors": [],
|
|
542
|
+
"platform": sys.platform,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if sys.platform != "darwin":
|
|
546
|
+
return result
|
|
547
|
+
|
|
548
|
+
launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
|
|
549
|
+
if not launch_agents_dir.is_dir():
|
|
550
|
+
return result
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
plists = sorted(launch_agents_dir.glob("com.nexo.*.plist"))
|
|
554
|
+
except Exception as e:
|
|
555
|
+
result["errors"].append({"plist": "*", "stderr": f"glob failed: {e}"})
|
|
556
|
+
return result
|
|
557
|
+
|
|
558
|
+
result["scanned"] = len(plists)
|
|
559
|
+
for plist in plists:
|
|
560
|
+
try:
|
|
561
|
+
if not plist.is_file():
|
|
562
|
+
result["skipped_missing"] += 1
|
|
563
|
+
continue
|
|
564
|
+
subprocess.run(
|
|
565
|
+
["launchctl", "unload", str(plist)],
|
|
566
|
+
capture_output=True,
|
|
567
|
+
text=True,
|
|
568
|
+
timeout=10,
|
|
569
|
+
)
|
|
570
|
+
load_proc = subprocess.run(
|
|
571
|
+
["launchctl", "load", "-w", str(plist)],
|
|
572
|
+
capture_output=True,
|
|
573
|
+
text=True,
|
|
574
|
+
timeout=10,
|
|
575
|
+
)
|
|
576
|
+
if load_proc.returncode == 0:
|
|
577
|
+
result["reloaded"] += 1
|
|
578
|
+
else:
|
|
579
|
+
result["errors"].append(
|
|
580
|
+
{
|
|
581
|
+
"plist": plist.name,
|
|
582
|
+
"stderr": (load_proc.stderr or load_proc.stdout or "load failed")[:300],
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
except subprocess.TimeoutExpired:
|
|
586
|
+
result["errors"].append({"plist": plist.name, "stderr": "launchctl timeout"})
|
|
587
|
+
except Exception as e:
|
|
588
|
+
result["errors"].append({"plist": plist.name, "stderr": str(e)[:300]})
|
|
589
|
+
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
502
593
|
def _handle_packaged_update(progress_fn=None) -> str:
|
|
503
594
|
"""Update a packaged (npm) install — no git repo available."""
|
|
504
595
|
old_version = _read_version()
|
|
@@ -581,10 +672,16 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
581
672
|
errors.append(f"verification: {verify_err}")
|
|
582
673
|
|
|
583
674
|
hook_sync_warning = None
|
|
675
|
+
cron_sync_warning = None
|
|
584
676
|
retired_runtime_files: list[str] = []
|
|
677
|
+
launchagent_reload_warning = None
|
|
678
|
+
launchagent_reload_summary = None
|
|
679
|
+
cron_sync_ok, cron_sync_error = _sync_packaged_crons(progress_fn=progress_fn)
|
|
680
|
+
if not cron_sync_ok:
|
|
681
|
+
errors.append(f"cron sync: {cron_sync_error}")
|
|
682
|
+
cron_sync_warning = cron_sync_error
|
|
585
683
|
try:
|
|
586
684
|
_emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
|
|
587
|
-
_refresh_installed_manifest()
|
|
588
685
|
_sync_hooks_to_home()
|
|
589
686
|
retired_runtime_files = _cleanup_retired_runtime_files()
|
|
590
687
|
except Exception as e:
|
|
@@ -596,6 +693,19 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
596
693
|
if not clients_ok:
|
|
597
694
|
client_sync_warning = client_sync_error or "unknown client sync error"
|
|
598
695
|
|
|
696
|
+
if old_version != new_version:
|
|
697
|
+
_emit_progress(progress_fn, "Reloading LaunchAgents after version bump...")
|
|
698
|
+
try:
|
|
699
|
+
launchagent_reload_summary = _reload_launch_agents_after_bump()
|
|
700
|
+
if launchagent_reload_summary.get("errors"):
|
|
701
|
+
launchagent_reload_warning = (
|
|
702
|
+
f"reloaded {launchagent_reload_summary['reloaded']}/"
|
|
703
|
+
f"{launchagent_reload_summary['scanned']} with "
|
|
704
|
+
f"{len(launchagent_reload_summary['errors'])} error(s)"
|
|
705
|
+
)
|
|
706
|
+
except Exception as e:
|
|
707
|
+
launchagent_reload_warning = f"launchagent reload error: {e}"
|
|
708
|
+
|
|
599
709
|
if errors:
|
|
600
710
|
# 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
|
|
601
711
|
if code_backup_dir:
|
|
@@ -632,6 +742,10 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
632
742
|
lines = ["UPDATE SUCCESSFUL (packaged install)"]
|
|
633
743
|
lines.append(f" Version: {old_version} -> {new_version}")
|
|
634
744
|
lines.append(f" Backup: {backup_dir}")
|
|
745
|
+
if not cron_sync_warning:
|
|
746
|
+
lines.append(" Crons: synced with manifest")
|
|
747
|
+
else:
|
|
748
|
+
lines.append(f" WARNING: cron sync: {cron_sync_warning}")
|
|
635
749
|
if not hook_sync_warning:
|
|
636
750
|
lines.append(" Hooks: synced to NEXO_HOME")
|
|
637
751
|
else:
|
|
@@ -642,6 +756,15 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
642
756
|
lines.append(" Clients: configured client targets synced")
|
|
643
757
|
else:
|
|
644
758
|
lines.append(f" WARNING: client sync: {client_sync_warning}")
|
|
759
|
+
if launchagent_reload_summary and launchagent_reload_summary.get("scanned"):
|
|
760
|
+
if not launchagent_reload_warning:
|
|
761
|
+
lines.append(
|
|
762
|
+
" LaunchAgents: reloaded "
|
|
763
|
+
f"{launchagent_reload_summary['reloaded']}/"
|
|
764
|
+
f"{launchagent_reload_summary['scanned']}"
|
|
765
|
+
)
|
|
766
|
+
else:
|
|
767
|
+
lines.append(f" WARNING: launchagent reload: {launchagent_reload_warning}")
|
|
645
768
|
lines.append("")
|
|
646
769
|
lines.append("MCP server restart needed to load new code.")
|
|
647
770
|
return "\n".join(lines)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Portable export/import helpers for operator user data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sqlite3
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from runtime_home import export_resolved_nexo_home
|
|
15
|
+
|
|
16
|
+
NEXO_HOME = export_resolved_nexo_home()
|
|
17
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
18
|
+
EXPORTS_DIR = NEXO_HOME / "exports"
|
|
19
|
+
STAGING_DIR = EXPORTS_DIR / ".staging"
|
|
20
|
+
USER_DIRS = ("brain", "coordination", "nexo-email", "assets")
|
|
21
|
+
USER_CONFIG_FILES = ("schedule.json",)
|
|
22
|
+
IGNORED_FILENAMES = {".DS_Store"}
|
|
23
|
+
IGNORED_DIRS = {"__pycache__"}
|
|
24
|
+
IGNORED_SUFFIXES = {".pyc", ".pyo"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_stamp() -> str:
|
|
28
|
+
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _runtime_version() -> str:
|
|
32
|
+
for candidate, key in (
|
|
33
|
+
(NEXO_HOME / "version.json", "version"),
|
|
34
|
+
(NEXO_CODE.parent / "version.json", "version"),
|
|
35
|
+
(NEXO_CODE.parent / "package.json", "version"),
|
|
36
|
+
(NEXO_HOME / "package.json", "version"),
|
|
37
|
+
):
|
|
38
|
+
try:
|
|
39
|
+
if candidate.is_file():
|
|
40
|
+
return str(json.loads(candidate.read_text()).get(key, "?"))
|
|
41
|
+
except Exception:
|
|
42
|
+
continue
|
|
43
|
+
return "?"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sqlite_backup(src: Path, dest: Path) -> None:
|
|
47
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
src_conn = sqlite3.connect(str(src))
|
|
49
|
+
try:
|
|
50
|
+
dst_conn = sqlite3.connect(str(dest))
|
|
51
|
+
try:
|
|
52
|
+
src_conn.backup(dst_conn)
|
|
53
|
+
finally:
|
|
54
|
+
dst_conn.close()
|
|
55
|
+
finally:
|
|
56
|
+
src_conn.close()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _should_skip_file(path: Path, exclude_names: set[str] | None = None) -> bool:
|
|
60
|
+
exclude = exclude_names or set()
|
|
61
|
+
if path.name in exclude:
|
|
62
|
+
return True
|
|
63
|
+
if path.name in IGNORED_FILENAMES:
|
|
64
|
+
return True
|
|
65
|
+
if path.suffix in IGNORED_SUFFIXES:
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _copy_tree_filtered(src: Path, dest: Path, *, exclude_names: set[str] | None = None) -> int:
|
|
71
|
+
if not src.is_dir():
|
|
72
|
+
return 0
|
|
73
|
+
copied = 0
|
|
74
|
+
for root, dirs, files in os.walk(src):
|
|
75
|
+
root_path = Path(root)
|
|
76
|
+
rel = root_path.relative_to(src)
|
|
77
|
+
dirs[:] = [item for item in dirs if item not in IGNORED_DIRS]
|
|
78
|
+
target_root = dest / rel
|
|
79
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
for name in files:
|
|
81
|
+
file_path = root_path / name
|
|
82
|
+
if _should_skip_file(file_path, exclude_names=exclude_names):
|
|
83
|
+
continue
|
|
84
|
+
shutil.copy2(str(file_path), str(target_root / name))
|
|
85
|
+
copied += 1
|
|
86
|
+
return copied
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _copy_file_if_present(src: Path, dest: Path) -> bool:
|
|
90
|
+
if not src.is_file():
|
|
91
|
+
return False
|
|
92
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
shutil.copy2(str(src), str(dest))
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _safe_extract(archive_path: Path, dest_dir: Path) -> None:
|
|
98
|
+
with tarfile.open(archive_path, "r:*") as tar:
|
|
99
|
+
for member in tar.getmembers():
|
|
100
|
+
target = (dest_dir / member.name).resolve()
|
|
101
|
+
if not str(target).startswith(str(dest_dir.resolve())):
|
|
102
|
+
raise ValueError(f"archive path escapes destination: {member.name}")
|
|
103
|
+
tar.extractall(dest_dir)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_personal_scripts() -> tuple[list[dict], list[dict]]:
|
|
107
|
+
from script_registry import classify_scripts_dir, discover_personal_schedules
|
|
108
|
+
|
|
109
|
+
classification = classify_scripts_dir()
|
|
110
|
+
scripts = [entry for entry in classification.get("entries", []) if entry.get("classification") == "personal"]
|
|
111
|
+
script_paths = {entry["path"] for entry in scripts}
|
|
112
|
+
schedules = [
|
|
113
|
+
schedule for schedule in discover_personal_schedules()
|
|
114
|
+
if schedule.get("script_path") in script_paths
|
|
115
|
+
]
|
|
116
|
+
return scripts, schedules
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def export_user_bundle(output_path: str = "") -> dict:
|
|
120
|
+
output = Path(output_path).expanduser() if output_path.strip() else (EXPORTS_DIR / f"nexo-user-data-{_now_stamp()}.tar.gz")
|
|
121
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
STAGING_DIR.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
stage_dir = Path(tempfile.mkdtemp(prefix="nexo-export-", dir=str(STAGING_DIR)))
|
|
124
|
+
bundle_root = stage_dir / "bundle"
|
|
125
|
+
bundle_root.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
|
|
127
|
+
sections: dict[str, dict] = {}
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
data_db = NEXO_HOME / "data" / "nexo.db"
|
|
131
|
+
if data_db.is_file():
|
|
132
|
+
_sqlite_backup(data_db, bundle_root / "data" / "nexo.db")
|
|
133
|
+
sections["data_db"] = {"path": "data/nexo.db"}
|
|
134
|
+
|
|
135
|
+
brain_dir = NEXO_HOME / "brain"
|
|
136
|
+
if brain_dir.is_dir():
|
|
137
|
+
copied = _copy_tree_filtered(brain_dir, bundle_root / "brain", exclude_names={"nexo.db"})
|
|
138
|
+
brain_db = brain_dir / "nexo.db"
|
|
139
|
+
if brain_db.is_file():
|
|
140
|
+
_sqlite_backup(brain_db, bundle_root / "brain" / "nexo.db")
|
|
141
|
+
copied += 1
|
|
142
|
+
sections["brain"] = {"path": "brain", "files": copied}
|
|
143
|
+
|
|
144
|
+
for dirname in USER_DIRS[1:]:
|
|
145
|
+
src_dir = NEXO_HOME / dirname
|
|
146
|
+
if not src_dir.is_dir():
|
|
147
|
+
continue
|
|
148
|
+
copied = _copy_tree_filtered(src_dir, bundle_root / dirname)
|
|
149
|
+
sections[dirname] = {"path": dirname, "files": copied}
|
|
150
|
+
|
|
151
|
+
copied_configs: list[str] = []
|
|
152
|
+
for filename in USER_CONFIG_FILES:
|
|
153
|
+
if _copy_file_if_present(NEXO_HOME / "config" / filename, bundle_root / "config" / filename):
|
|
154
|
+
copied_configs.append(filename)
|
|
155
|
+
if copied_configs:
|
|
156
|
+
sections["config"] = {"path": "config", "files": copied_configs}
|
|
157
|
+
|
|
158
|
+
scripts, schedules = _load_personal_scripts()
|
|
159
|
+
exported_scripts: list[dict] = []
|
|
160
|
+
scripts_dir = bundle_root / "personal-scripts"
|
|
161
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
for entry in scripts:
|
|
163
|
+
src_path = Path(entry["path"])
|
|
164
|
+
if not src_path.is_file():
|
|
165
|
+
continue
|
|
166
|
+
shutil.copy2(str(src_path), str(scripts_dir / src_path.name))
|
|
167
|
+
exported_scripts.append(
|
|
168
|
+
{
|
|
169
|
+
"name": entry.get("name", src_path.stem),
|
|
170
|
+
"path": f"personal-scripts/{src_path.name}",
|
|
171
|
+
"runtime": entry.get("runtime", "unknown"),
|
|
172
|
+
"description": entry.get("description", ""),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
(scripts_dir / "manifest.json").write_text(
|
|
176
|
+
json.dumps(
|
|
177
|
+
{
|
|
178
|
+
"scripts": exported_scripts,
|
|
179
|
+
"schedules": schedules,
|
|
180
|
+
},
|
|
181
|
+
indent=2,
|
|
182
|
+
ensure_ascii=False,
|
|
183
|
+
) + "\n"
|
|
184
|
+
)
|
|
185
|
+
sections["personal_scripts"] = {
|
|
186
|
+
"path": "personal-scripts",
|
|
187
|
+
"files": len(exported_scripts),
|
|
188
|
+
"schedules": len(schedules),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
manifest = {
|
|
192
|
+
"kind": "nexo-user-data-bundle",
|
|
193
|
+
"version": _runtime_version(),
|
|
194
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
195
|
+
"nexo_home": str(NEXO_HOME),
|
|
196
|
+
"sections": sections,
|
|
197
|
+
}
|
|
198
|
+
(bundle_root / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
|
|
199
|
+
|
|
200
|
+
with tarfile.open(output, "w:gz") as tar:
|
|
201
|
+
tar.add(bundle_root, arcname="bundle")
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"ok": True,
|
|
205
|
+
"path": str(output),
|
|
206
|
+
"kind": manifest["kind"],
|
|
207
|
+
"version": manifest["version"],
|
|
208
|
+
"sections": sections,
|
|
209
|
+
}
|
|
210
|
+
finally:
|
|
211
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def import_user_bundle(bundle_path: str) -> dict:
|
|
215
|
+
archive_path = Path(bundle_path).expanduser()
|
|
216
|
+
if not archive_path.is_file():
|
|
217
|
+
return {"ok": False, "error": f"bundle not found: {archive_path}"}
|
|
218
|
+
|
|
219
|
+
backups_dir = NEXO_HOME / "backups"
|
|
220
|
+
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
safety_backup = backups_dir / f"pre-import-user-data-{_now_stamp()}.tar.gz"
|
|
222
|
+
safety_result = export_user_bundle(str(safety_backup))
|
|
223
|
+
if not safety_result.get("ok"):
|
|
224
|
+
return {"ok": False, "error": "failed to create safety backup", "safety_backup": str(safety_backup)}
|
|
225
|
+
|
|
226
|
+
STAGING_DIR.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
stage_dir = Path(tempfile.mkdtemp(prefix="nexo-import-", dir=str(STAGING_DIR)))
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
_safe_extract(archive_path, stage_dir)
|
|
231
|
+
bundle_root = stage_dir / "bundle"
|
|
232
|
+
manifest_path = bundle_root / "manifest.json"
|
|
233
|
+
if not manifest_path.is_file():
|
|
234
|
+
return {"ok": False, "error": "bundle manifest missing", "safety_backup": str(safety_backup)}
|
|
235
|
+
|
|
236
|
+
manifest = json.loads(manifest_path.read_text())
|
|
237
|
+
if manifest.get("kind") != "nexo-user-data-bundle":
|
|
238
|
+
return {
|
|
239
|
+
"ok": False,
|
|
240
|
+
"error": f"unsupported bundle kind: {manifest.get('kind', 'unknown')}",
|
|
241
|
+
"safety_backup": str(safety_backup),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
restored: dict[str, dict] = {}
|
|
245
|
+
|
|
246
|
+
data_db = bundle_root / "data" / "nexo.db"
|
|
247
|
+
if data_db.is_file():
|
|
248
|
+
_sqlite_backup(data_db, NEXO_HOME / "data" / "nexo.db")
|
|
249
|
+
restored["data_db"] = {"path": "data/nexo.db"}
|
|
250
|
+
|
|
251
|
+
brain_dir = bundle_root / "brain"
|
|
252
|
+
if brain_dir.is_dir():
|
|
253
|
+
copied = _copy_tree_filtered(brain_dir, NEXO_HOME / "brain", exclude_names={"nexo.db"})
|
|
254
|
+
brain_db = brain_dir / "nexo.db"
|
|
255
|
+
if brain_db.is_file():
|
|
256
|
+
_sqlite_backup(brain_db, NEXO_HOME / "brain" / "nexo.db")
|
|
257
|
+
copied += 1
|
|
258
|
+
restored["brain"] = {"path": "brain", "files": copied}
|
|
259
|
+
|
|
260
|
+
for dirname in USER_DIRS[1:]:
|
|
261
|
+
src_dir = bundle_root / dirname
|
|
262
|
+
if not src_dir.is_dir():
|
|
263
|
+
continue
|
|
264
|
+
copied = _copy_tree_filtered(src_dir, NEXO_HOME / dirname)
|
|
265
|
+
restored[dirname] = {"path": dirname, "files": copied}
|
|
266
|
+
|
|
267
|
+
restored_configs: list[str] = []
|
|
268
|
+
for filename in USER_CONFIG_FILES:
|
|
269
|
+
if _copy_file_if_present(bundle_root / "config" / filename, NEXO_HOME / "config" / filename):
|
|
270
|
+
restored_configs.append(filename)
|
|
271
|
+
if restored_configs:
|
|
272
|
+
restored["config"] = {"path": "config", "files": restored_configs}
|
|
273
|
+
|
|
274
|
+
imported_scripts = 0
|
|
275
|
+
scripts_dir = bundle_root / "personal-scripts"
|
|
276
|
+
target_scripts_dir = NEXO_HOME / "scripts"
|
|
277
|
+
target_scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
if scripts_dir.is_dir():
|
|
279
|
+
for script_path in sorted(scripts_dir.iterdir()):
|
|
280
|
+
if not script_path.is_file() or script_path.name == "manifest.json":
|
|
281
|
+
continue
|
|
282
|
+
shutil.copy2(str(script_path), str(target_scripts_dir / script_path.name))
|
|
283
|
+
imported_scripts += 1
|
|
284
|
+
restored["personal_scripts"] = {"path": "personal-scripts", "files": imported_scripts}
|
|
285
|
+
|
|
286
|
+
from db import init_db
|
|
287
|
+
from script_registry import reconcile_personal_scripts
|
|
288
|
+
|
|
289
|
+
init_db()
|
|
290
|
+
reconcile_result = reconcile_personal_scripts(dry_run=False)
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"ok": True,
|
|
294
|
+
"path": str(archive_path),
|
|
295
|
+
"kind": manifest.get("kind"),
|
|
296
|
+
"bundle_version": manifest.get("version"),
|
|
297
|
+
"safety_backup": str(safety_backup),
|
|
298
|
+
"restored": restored,
|
|
299
|
+
"reconciled": reconcile_result,
|
|
300
|
+
}
|
|
301
|
+
finally:
|
|
302
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|