nexo-brain 5.3.19 → 5.3.21
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/bin/nexo-brain.js +52 -10
- package/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,1559 @@
|
|
|
1
|
+
"""NEXO Script Registry — discovery, metadata, validation for personal scripts.
|
|
2
|
+
|
|
3
|
+
Scripts live in NEXO_HOME/scripts/. Core scripts (from manifest) are filtered by default.
|
|
4
|
+
Personal scripts use CLI as stable interface, never direct DB access.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import plistlib
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import stat
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from runtime_home import export_resolved_nexo_home
|
|
20
|
+
|
|
21
|
+
NEXO_HOME = export_resolved_nexo_home()
|
|
22
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
23
|
+
|
|
24
|
+
# Internal artifacts to always ignore
|
|
25
|
+
_IGNORED_FILES = {
|
|
26
|
+
".watchdog-hashes",
|
|
27
|
+
".watchdog-fails",
|
|
28
|
+
".watchdog-nexo-repair.lock",
|
|
29
|
+
"nexo-cron-wrapper.sh",
|
|
30
|
+
"nexo-dashboard.sh",
|
|
31
|
+
"nexo-prevent-sleep.sh",
|
|
32
|
+
"nexo-proactive-dashboard.py",
|
|
33
|
+
"nexo-tcc-approve.sh",
|
|
34
|
+
}
|
|
35
|
+
_IGNORED_DIRS = {"deep-sleep", "__pycache__"}
|
|
36
|
+
|
|
37
|
+
_LEGACY_WAKE_RECOVERY_METADATA = [
|
|
38
|
+
"# nexo: name=nexo-wake-recovery",
|
|
39
|
+
"# nexo: description=Recover interval LaunchAgents after macOS sleep/wake gaps",
|
|
40
|
+
"# nexo: runtime=shell",
|
|
41
|
+
"# nexo: cron_id=wake-recovery",
|
|
42
|
+
"# nexo: schedule_required=true",
|
|
43
|
+
"# nexo: recovery_policy=restart_daemon",
|
|
44
|
+
"# nexo: run_on_boot=true",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_LEGACY_CORE_RUNTIME_FILES = {
|
|
48
|
+
"capture-tool-logs.sh",
|
|
49
|
+
"daily-briefing-check.sh",
|
|
50
|
+
"heartbeat-enforcement.py",
|
|
51
|
+
"heartbeat-posttool.sh",
|
|
52
|
+
"heartbeat-user-msg.sh",
|
|
53
|
+
"nexo-memory-precompact.sh",
|
|
54
|
+
"nexo-memory-stop.sh",
|
|
55
|
+
"nexo-postcompact.sh",
|
|
56
|
+
"nexo-session-briefing.sh",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Forbidden patterns — direct DB access from personal scripts
|
|
60
|
+
_FORBIDDEN_PATTERNS = [
|
|
61
|
+
re.compile(r"\bsqlite3\b"),
|
|
62
|
+
re.compile(r"\bnexo\.db\b"),
|
|
63
|
+
re.compile(r"\bcognitive\.db\b"),
|
|
64
|
+
re.compile(r"/data/nexo\.db"),
|
|
65
|
+
re.compile(r"/data/cognitive\.db"),
|
|
66
|
+
re.compile(r"\bimport\s+db\b"),
|
|
67
|
+
re.compile(r"\bfrom\s+db\s+import\b"),
|
|
68
|
+
re.compile(r"\bimport\s+cognitive\b"),
|
|
69
|
+
re.compile(r"\bfrom\s+cognitive\s+import\b"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
METADATA_KEYS = {
|
|
73
|
+
"name",
|
|
74
|
+
"description",
|
|
75
|
+
"runtime",
|
|
76
|
+
"timeout",
|
|
77
|
+
"requires",
|
|
78
|
+
"tools",
|
|
79
|
+
"hidden",
|
|
80
|
+
"category",
|
|
81
|
+
"cron_id",
|
|
82
|
+
"schedule",
|
|
83
|
+
"interval_seconds",
|
|
84
|
+
"schedule_required",
|
|
85
|
+
"recovery_policy",
|
|
86
|
+
"run_on_boot",
|
|
87
|
+
"run_on_wake",
|
|
88
|
+
"idempotent",
|
|
89
|
+
"max_catchup_age",
|
|
90
|
+
"doctor_allow_db",
|
|
91
|
+
}
|
|
92
|
+
SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
|
|
93
|
+
PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
|
|
94
|
+
SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
|
|
95
|
+
PERSONAL_SCRIPT_FILENAME_PREFIX = "ps-"
|
|
96
|
+
_LEGACY_CORE_SCRIPT_ALIASES = {
|
|
97
|
+
"nexo-postcompact.sh": "post-compact.sh",
|
|
98
|
+
"nexo-memory-precompact.sh": "pre-compact.sh",
|
|
99
|
+
"nexo-memory-stop.sh": "session-stop.sh",
|
|
100
|
+
"nexo-session-briefing.sh": "session-start.sh",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_nexo_home() -> Path:
|
|
105
|
+
return NEXO_HOME
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_scripts_dir() -> Path:
|
|
109
|
+
return NEXO_HOME / "scripts"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _apply_legacy_personal_script_backfills() -> None:
|
|
113
|
+
"""Backfill metadata for known legacy personal scripts shipped before the registry existed."""
|
|
114
|
+
scripts_dir = get_scripts_dir()
|
|
115
|
+
wake_recovery = scripts_dir / "nexo-wake-recovery.sh"
|
|
116
|
+
if not wake_recovery.is_file():
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
text = wake_recovery.read_text()
|
|
121
|
+
except Exception:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if "# nexo:" in "\n".join(text.splitlines()[:25]):
|
|
125
|
+
return
|
|
126
|
+
if "Wake Recovery" not in text:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
lines = text.splitlines(keepends=True)
|
|
130
|
+
head: list[str] = []
|
|
131
|
+
start = 0
|
|
132
|
+
if lines and lines[0].startswith("#!"):
|
|
133
|
+
head.append(lines[0])
|
|
134
|
+
start = 1
|
|
135
|
+
head.extend([line + "\n" for line in _LEGACY_WAKE_RECOVERY_METADATA])
|
|
136
|
+
wake_recovery.write_text("".join(head + lines[start:]))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _add_runtime_artifact_names(names: set[str], artifact_path: Path) -> None:
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(artifact_path.read_text())
|
|
142
|
+
except Exception:
|
|
143
|
+
return
|
|
144
|
+
for key in ("script_names", "hook_names"):
|
|
145
|
+
for item in data.get(key, []):
|
|
146
|
+
if isinstance(item, str) and item.strip():
|
|
147
|
+
names.add(Path(item).name)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _add_filenames_from_dir(names: set[str], directory: Path, *, skip_if_scripts_dir: bool = False) -> None:
|
|
151
|
+
if not directory.is_dir():
|
|
152
|
+
return
|
|
153
|
+
if skip_if_scripts_dir:
|
|
154
|
+
try:
|
|
155
|
+
if directory.resolve() == get_scripts_dir().resolve():
|
|
156
|
+
return
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
for item in directory.iterdir():
|
|
160
|
+
if item.is_file() and not item.name.startswith("."):
|
|
161
|
+
names.add(item.name)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _find_packaged_core_source_dir() -> Path | None:
|
|
165
|
+
repo_root = NEXO_CODE.parent
|
|
166
|
+
if (repo_root / ".git").exists() or (repo_root / ".git").is_file():
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
with contextlib.suppress(Exception):
|
|
170
|
+
result = subprocess.run(
|
|
171
|
+
["npm", "root", "-g"],
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
timeout=10,
|
|
175
|
+
)
|
|
176
|
+
if result.returncode == 0:
|
|
177
|
+
candidate = Path(result.stdout.strip()) / "nexo-brain" / "src"
|
|
178
|
+
if candidate.is_dir():
|
|
179
|
+
return candidate
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_core_script_names() -> set[str]:
|
|
184
|
+
"""Load every core-managed runtime artifact name that must never be treated as personal."""
|
|
185
|
+
names: set[str] = set()
|
|
186
|
+
packaged_src = _find_packaged_core_source_dir()
|
|
187
|
+
|
|
188
|
+
manifest_candidates = []
|
|
189
|
+
if packaged_src is not None:
|
|
190
|
+
manifest_candidates.append(packaged_src / "crons" / "manifest.json")
|
|
191
|
+
manifest_candidates.extend([NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"])
|
|
192
|
+
|
|
193
|
+
for manifest_path in manifest_candidates:
|
|
194
|
+
if manifest_path.exists():
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(manifest_path.read_text())
|
|
197
|
+
for cron in data.get("crons", []):
|
|
198
|
+
script = cron.get("script", "")
|
|
199
|
+
# script is like "scripts/nexo-immune.py" — extract filename
|
|
200
|
+
names.add(Path(script).name)
|
|
201
|
+
break
|
|
202
|
+
except Exception:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if packaged_src is not None:
|
|
206
|
+
_add_filenames_from_dir(names, packaged_src / "hooks")
|
|
207
|
+
_add_filenames_from_dir(names, packaged_src / "scripts")
|
|
208
|
+
else:
|
|
209
|
+
for artifact_path in (
|
|
210
|
+
NEXO_HOME / "config" / "runtime-core-artifacts.json",
|
|
211
|
+
NEXO_CODE / "config" / "runtime-core-artifacts.json",
|
|
212
|
+
NEXO_CODE.parent / "config" / "runtime-core-artifacts.json",
|
|
213
|
+
):
|
|
214
|
+
if artifact_path.exists():
|
|
215
|
+
_add_runtime_artifact_names(names, artifact_path)
|
|
216
|
+
|
|
217
|
+
_add_filenames_from_dir(names, NEXO_HOME / "hooks")
|
|
218
|
+
_add_filenames_from_dir(names, NEXO_CODE / "hooks")
|
|
219
|
+
_add_filenames_from_dir(names, NEXO_CODE / "scripts", skip_if_scripts_dir=True)
|
|
220
|
+
|
|
221
|
+
for legacy_name, canonical_name in _LEGACY_CORE_SCRIPT_ALIASES.items():
|
|
222
|
+
if canonical_name in names:
|
|
223
|
+
names.add(legacy_name)
|
|
224
|
+
names.update(_LEGACY_CORE_RUNTIME_FILES)
|
|
225
|
+
return names
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_inline_metadata(path: Path) -> dict:
|
|
229
|
+
"""Parse inline metadata from first 25 lines.
|
|
230
|
+
|
|
231
|
+
Supported comment prefixes:
|
|
232
|
+
- # nexo:
|
|
233
|
+
- // nexo:
|
|
234
|
+
"""
|
|
235
|
+
meta: dict[str, str] = {}
|
|
236
|
+
try:
|
|
237
|
+
lines = path.read_text(errors="ignore").splitlines()[:25]
|
|
238
|
+
except Exception:
|
|
239
|
+
return meta
|
|
240
|
+
|
|
241
|
+
for line in lines:
|
|
242
|
+
stripped = line.strip()
|
|
243
|
+
payload = ""
|
|
244
|
+
if stripped.startswith("# nexo:"):
|
|
245
|
+
payload = stripped[len("# nexo:"):].strip()
|
|
246
|
+
elif stripped.startswith("// nexo:"):
|
|
247
|
+
payload = stripped[len("// nexo:"):].strip()
|
|
248
|
+
else:
|
|
249
|
+
continue
|
|
250
|
+
if "=" not in payload:
|
|
251
|
+
continue
|
|
252
|
+
key, value = payload.split("=", 1)
|
|
253
|
+
k = key.strip()
|
|
254
|
+
if k in METADATA_KEYS:
|
|
255
|
+
meta[k] = value.strip()
|
|
256
|
+
return meta
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _detect_shebang(path: Path) -> str | None:
|
|
260
|
+
"""Read first line for shebang."""
|
|
261
|
+
try:
|
|
262
|
+
first = path.read_text(errors="ignore").split("\n", 1)[0]
|
|
263
|
+
if first.startswith("#!"):
|
|
264
|
+
return first
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def classify_runtime(path: Path, metadata: dict) -> str:
|
|
271
|
+
"""Detect script runtime: python, shell, node, php, or unknown."""
|
|
272
|
+
# 1. Metadata
|
|
273
|
+
rt = metadata.get("runtime", "").lower()
|
|
274
|
+
if rt in ("python", "shell", "node", "php"):
|
|
275
|
+
return rt
|
|
276
|
+
|
|
277
|
+
# 2. Shebang
|
|
278
|
+
shebang = _detect_shebang(path)
|
|
279
|
+
if shebang:
|
|
280
|
+
if "python" in shebang:
|
|
281
|
+
return "python"
|
|
282
|
+
if "bash" in shebang or "/sh" in shebang:
|
|
283
|
+
return "shell"
|
|
284
|
+
if "node" in shebang:
|
|
285
|
+
return "node"
|
|
286
|
+
if "php" in shebang:
|
|
287
|
+
return "php"
|
|
288
|
+
|
|
289
|
+
# 3. Extension
|
|
290
|
+
ext = path.suffix.lower()
|
|
291
|
+
if ext == ".py":
|
|
292
|
+
return "python"
|
|
293
|
+
if ext == ".sh":
|
|
294
|
+
return "shell"
|
|
295
|
+
if ext == ".js":
|
|
296
|
+
return "node"
|
|
297
|
+
if ext == ".php":
|
|
298
|
+
return "php"
|
|
299
|
+
|
|
300
|
+
return "unknown"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _is_ignored(path: Path) -> bool:
|
|
304
|
+
"""Check if file should be ignored entirely."""
|
|
305
|
+
if path.name in _IGNORED_FILES:
|
|
306
|
+
return True
|
|
307
|
+
if path.name.startswith("."):
|
|
308
|
+
return True
|
|
309
|
+
try:
|
|
310
|
+
relative_path = path.resolve().relative_to(get_scripts_dir().resolve())
|
|
311
|
+
except Exception:
|
|
312
|
+
return False
|
|
313
|
+
for parent in relative_path.parents:
|
|
314
|
+
if parent.name in _IGNORED_DIRS:
|
|
315
|
+
return True
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_script_candidate(path: Path, metadata: dict | None = None) -> bool:
|
|
320
|
+
metadata = metadata or {}
|
|
321
|
+
runtime = classify_runtime(path, metadata)
|
|
322
|
+
if runtime != "unknown":
|
|
323
|
+
return True
|
|
324
|
+
if _detect_shebang(path):
|
|
325
|
+
return True
|
|
326
|
+
try:
|
|
327
|
+
return os.access(path, os.X_OK)
|
|
328
|
+
except Exception:
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _truthy(value: str | bool | None) -> bool:
|
|
333
|
+
if isinstance(value, bool):
|
|
334
|
+
return value
|
|
335
|
+
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _safe_slug(value: str) -> str:
|
|
339
|
+
chars: list[str] = []
|
|
340
|
+
for ch in value.lower():
|
|
341
|
+
if ch.isalnum():
|
|
342
|
+
chars.append(ch)
|
|
343
|
+
elif ch in {"-", "_", " "}:
|
|
344
|
+
chars.append("-")
|
|
345
|
+
slug = "".join(chars).strip("-")
|
|
346
|
+
return slug or "script"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def has_personal_script_filename_prefix(value: str) -> bool:
|
|
350
|
+
return _safe_slug(value).startswith(PERSONAL_SCRIPT_FILENAME_PREFIX)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _logical_personal_script_name(name: str) -> str:
|
|
354
|
+
slug = _safe_slug(name)
|
|
355
|
+
if slug.startswith(PERSONAL_SCRIPT_FILENAME_PREFIX):
|
|
356
|
+
slug = slug[len(PERSONAL_SCRIPT_FILENAME_PREFIX):]
|
|
357
|
+
return slug or "personal-script"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
|
|
361
|
+
"""Parse desired schedule metadata from inline script metadata."""
|
|
362
|
+
explicit_name = metadata.get("name", "").strip()
|
|
363
|
+
explicit_runtime = metadata.get("runtime", "").strip().lower()
|
|
364
|
+
explicit_cron_id = metadata.get("cron_id", "").strip()
|
|
365
|
+
cron_id = explicit_cron_id or _safe_slug(default_name or explicit_name or "script")
|
|
366
|
+
interval_raw = metadata.get("interval_seconds", "").strip()
|
|
367
|
+
schedule_raw = metadata.get("schedule", "").strip()
|
|
368
|
+
schedule_required = _truthy(metadata.get("schedule_required"))
|
|
369
|
+
recovery_policy_raw = metadata.get("recovery_policy", "").strip().lower()
|
|
370
|
+
run_on_boot = _truthy(metadata.get("run_on_boot"))
|
|
371
|
+
run_on_wake = _truthy(metadata.get("run_on_wake"))
|
|
372
|
+
idempotent = _truthy(metadata.get("idempotent"))
|
|
373
|
+
max_catchup_age_raw = metadata.get("max_catchup_age", "").strip()
|
|
374
|
+
required = schedule_required or bool(interval_raw or schedule_raw)
|
|
375
|
+
|
|
376
|
+
if recovery_policy_raw and recovery_policy_raw not in SUPPORTED_RECOVERY_POLICIES:
|
|
377
|
+
return {
|
|
378
|
+
"required": required,
|
|
379
|
+
"valid": False,
|
|
380
|
+
"error": f"Invalid recovery_policy: {recovery_policy_raw}",
|
|
381
|
+
"cron_id": cron_id,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
max_catchup_age = 0
|
|
385
|
+
if max_catchup_age_raw:
|
|
386
|
+
try:
|
|
387
|
+
max_catchup_age = int(max_catchup_age_raw)
|
|
388
|
+
except ValueError:
|
|
389
|
+
return {
|
|
390
|
+
"required": required,
|
|
391
|
+
"valid": False,
|
|
392
|
+
"error": f"Invalid max_catchup_age: {max_catchup_age_raw}",
|
|
393
|
+
"cron_id": cron_id,
|
|
394
|
+
}
|
|
395
|
+
if max_catchup_age < 0:
|
|
396
|
+
return {
|
|
397
|
+
"required": required,
|
|
398
|
+
"valid": False,
|
|
399
|
+
"error": f"max_catchup_age must be >= 0 (got {max_catchup_age_raw})",
|
|
400
|
+
"cron_id": cron_id,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if required:
|
|
404
|
+
missing = []
|
|
405
|
+
if not explicit_name:
|
|
406
|
+
missing.append("name")
|
|
407
|
+
if not explicit_runtime:
|
|
408
|
+
missing.append("runtime")
|
|
409
|
+
elif explicit_runtime not in SUPPORTED_RUNTIMES - {"unknown"}:
|
|
410
|
+
return {
|
|
411
|
+
"required": required,
|
|
412
|
+
"valid": False,
|
|
413
|
+
"error": f"Invalid runtime metadata for scheduled script: {explicit_runtime}",
|
|
414
|
+
"cron_id": cron_id,
|
|
415
|
+
}
|
|
416
|
+
if not explicit_cron_id:
|
|
417
|
+
missing.append("cron_id")
|
|
418
|
+
if not schedule_required:
|
|
419
|
+
missing.append("schedule_required=true")
|
|
420
|
+
if missing:
|
|
421
|
+
return {
|
|
422
|
+
"required": required,
|
|
423
|
+
"valid": False,
|
|
424
|
+
"error": f"Scheduled scripts must declare {', '.join(missing)}",
|
|
425
|
+
"cron_id": cron_id,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
def _effective_run_on_boot(policy: str) -> bool:
|
|
429
|
+
if "run_on_boot" in metadata:
|
|
430
|
+
return run_on_boot
|
|
431
|
+
return policy == "restart_daemon"
|
|
432
|
+
|
|
433
|
+
def _effective_run_on_wake(policy: str) -> bool:
|
|
434
|
+
if "run_on_wake" in metadata:
|
|
435
|
+
return run_on_wake
|
|
436
|
+
return policy in {"catchup", "run_once_on_wake"}
|
|
437
|
+
|
|
438
|
+
def _effective_idempotent(policy: str) -> bool:
|
|
439
|
+
if "idempotent" in metadata:
|
|
440
|
+
return idempotent
|
|
441
|
+
return policy in {"catchup", "run_once_on_wake", "restart", "restart_daemon"}
|
|
442
|
+
|
|
443
|
+
if interval_raw and schedule_raw:
|
|
444
|
+
return {
|
|
445
|
+
"required": required,
|
|
446
|
+
"valid": False,
|
|
447
|
+
"error": "Both schedule and interval_seconds are set; choose one.",
|
|
448
|
+
"cron_id": cron_id,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if interval_raw:
|
|
452
|
+
try:
|
|
453
|
+
interval = int(interval_raw)
|
|
454
|
+
except ValueError:
|
|
455
|
+
return {
|
|
456
|
+
"required": required,
|
|
457
|
+
"valid": False,
|
|
458
|
+
"error": f"Invalid interval_seconds: {interval_raw}",
|
|
459
|
+
"cron_id": cron_id,
|
|
460
|
+
}
|
|
461
|
+
if interval <= 0:
|
|
462
|
+
return {
|
|
463
|
+
"required": required,
|
|
464
|
+
"valid": False,
|
|
465
|
+
"error": f"interval_seconds must be > 0 (got {interval_raw})",
|
|
466
|
+
"cron_id": cron_id,
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
"required": required,
|
|
470
|
+
"valid": True,
|
|
471
|
+
"cron_id": cron_id,
|
|
472
|
+
"schedule_type": "interval",
|
|
473
|
+
"schedule_value": str(interval),
|
|
474
|
+
"schedule_label": f"every {interval}s",
|
|
475
|
+
"schedule": "",
|
|
476
|
+
"interval_seconds": interval,
|
|
477
|
+
"recovery_policy": recovery_policy_raw or "run_once_on_wake",
|
|
478
|
+
"run_on_boot": run_on_boot,
|
|
479
|
+
"run_on_wake": _effective_run_on_wake(recovery_policy_raw or "run_once_on_wake"),
|
|
480
|
+
"idempotent": _effective_idempotent(recovery_policy_raw or "run_once_on_wake"),
|
|
481
|
+
"max_catchup_age": max_catchup_age or max(interval * 4, interval + 900),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if schedule_raw:
|
|
485
|
+
parts = schedule_raw.split(":")
|
|
486
|
+
if len(parts) not in {2, 3}:
|
|
487
|
+
return {
|
|
488
|
+
"required": required,
|
|
489
|
+
"valid": False,
|
|
490
|
+
"error": f"Invalid schedule format: {schedule_raw}",
|
|
491
|
+
"cron_id": cron_id,
|
|
492
|
+
}
|
|
493
|
+
try:
|
|
494
|
+
hour = int(parts[0])
|
|
495
|
+
minute = int(parts[1])
|
|
496
|
+
weekday = int(parts[2]) if len(parts) == 3 else None
|
|
497
|
+
except ValueError:
|
|
498
|
+
return {
|
|
499
|
+
"required": required,
|
|
500
|
+
"valid": False,
|
|
501
|
+
"error": f"Invalid schedule format: {schedule_raw}",
|
|
502
|
+
"cron_id": cron_id,
|
|
503
|
+
}
|
|
504
|
+
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
|
505
|
+
return {
|
|
506
|
+
"required": required,
|
|
507
|
+
"valid": False,
|
|
508
|
+
"error": f"Invalid schedule time: {schedule_raw}",
|
|
509
|
+
"cron_id": cron_id,
|
|
510
|
+
}
|
|
511
|
+
if weekday is not None and not (0 <= weekday <= 6):
|
|
512
|
+
return {
|
|
513
|
+
"required": required,
|
|
514
|
+
"valid": False,
|
|
515
|
+
"error": f"Invalid schedule weekday: {schedule_raw}",
|
|
516
|
+
"cron_id": cron_id,
|
|
517
|
+
}
|
|
518
|
+
label = f"{hour:02d}:{minute:02d}"
|
|
519
|
+
if weekday is not None:
|
|
520
|
+
label += f" weekday={weekday}"
|
|
521
|
+
else:
|
|
522
|
+
label += " daily"
|
|
523
|
+
return {
|
|
524
|
+
"required": required,
|
|
525
|
+
"valid": True,
|
|
526
|
+
"cron_id": cron_id,
|
|
527
|
+
"schedule_type": "calendar",
|
|
528
|
+
"schedule_value": schedule_raw,
|
|
529
|
+
"schedule_label": label,
|
|
530
|
+
"schedule": schedule_raw,
|
|
531
|
+
"interval_seconds": 0,
|
|
532
|
+
"recovery_policy": recovery_policy_raw or "catchup",
|
|
533
|
+
"run_on_boot": _effective_run_on_boot(recovery_policy_raw or "catchup"),
|
|
534
|
+
"run_on_wake": _effective_run_on_wake(recovery_policy_raw or "catchup"),
|
|
535
|
+
"idempotent": _effective_idempotent(recovery_policy_raw or "catchup"),
|
|
536
|
+
"max_catchup_age": max_catchup_age or (14 * 86400 if weekday is not None else 48 * 3600),
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if required and recovery_policy_raw == "restart_daemon":
|
|
540
|
+
return {
|
|
541
|
+
"required": required,
|
|
542
|
+
"valid": True,
|
|
543
|
+
"cron_id": cron_id,
|
|
544
|
+
"schedule_type": "keep_alive",
|
|
545
|
+
"schedule_value": "true",
|
|
546
|
+
"schedule_label": "keep alive",
|
|
547
|
+
"schedule": "",
|
|
548
|
+
"interval_seconds": 0,
|
|
549
|
+
"recovery_policy": "restart_daemon",
|
|
550
|
+
"run_on_boot": _effective_run_on_boot("restart_daemon"),
|
|
551
|
+
"run_on_wake": _effective_run_on_wake("restart_daemon"),
|
|
552
|
+
"idempotent": _effective_idempotent("restart_daemon"),
|
|
553
|
+
"max_catchup_age": max_catchup_age,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
"required": required,
|
|
558
|
+
"valid": not required,
|
|
559
|
+
"error": "" if not required else "schedule_required=true but no schedule metadata was provided.",
|
|
560
|
+
"cron_id": cron_id,
|
|
561
|
+
"recovery_policy": recovery_policy_raw or "none",
|
|
562
|
+
"run_on_boot": run_on_boot,
|
|
563
|
+
"run_on_wake": run_on_wake,
|
|
564
|
+
"idempotent": idempotent,
|
|
565
|
+
"max_catchup_age": max_catchup_age,
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str, reason: str = "") -> dict:
|
|
570
|
+
runtime = classify_runtime(path, meta)
|
|
571
|
+
name = meta.get("name", path.stem)
|
|
572
|
+
entry = {
|
|
573
|
+
"name": name,
|
|
574
|
+
"runtime": runtime,
|
|
575
|
+
"description": meta.get("description", ""),
|
|
576
|
+
"path": str(path),
|
|
577
|
+
"core": is_core,
|
|
578
|
+
"metadata": meta,
|
|
579
|
+
"classification": classification,
|
|
580
|
+
"reason": reason,
|
|
581
|
+
"declared_schedule": get_declared_schedule(meta, name),
|
|
582
|
+
"filename_prefixed": has_personal_script_filename_prefix(path.stem),
|
|
583
|
+
}
|
|
584
|
+
if classification == "personal":
|
|
585
|
+
entry["naming_policy"] = "preferred" if entry["filename_prefixed"] else "legacy-nonprefixed"
|
|
586
|
+
return entry
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def classify_scripts_dir() -> dict:
|
|
590
|
+
"""Classify every file in NEXO_HOME/scripts into personal/core/ignored/non-script buckets."""
|
|
591
|
+
_apply_legacy_personal_script_backfills()
|
|
592
|
+
scripts_dir = get_scripts_dir()
|
|
593
|
+
if not scripts_dir.is_dir():
|
|
594
|
+
return {"scripts_dir": str(scripts_dir), "entries": [], "summary": {}}
|
|
595
|
+
|
|
596
|
+
core_names = load_core_script_names()
|
|
597
|
+
entries: list[dict] = []
|
|
598
|
+
for f in sorted(scripts_dir.iterdir()):
|
|
599
|
+
if not f.is_file():
|
|
600
|
+
continue
|
|
601
|
+
|
|
602
|
+
meta = parse_inline_metadata(f)
|
|
603
|
+
if _is_ignored(f):
|
|
604
|
+
entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
if not _is_script_candidate(f, meta):
|
|
608
|
+
entries.append(_script_entry(f, meta, is_core=False, classification="non-script", reason="not an executable/script candidate"))
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
is_core = f.name in core_names
|
|
612
|
+
classification = "core" if is_core else "personal"
|
|
613
|
+
entries.append(_script_entry(f, meta, is_core=is_core, classification=classification))
|
|
614
|
+
|
|
615
|
+
summary: dict[str, int] = {}
|
|
616
|
+
for entry in entries:
|
|
617
|
+
summary[entry["classification"]] = summary.get(entry["classification"], 0) + 1
|
|
618
|
+
return {"scripts_dir": str(scripts_dir), "entries": entries, "summary": summary}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def list_scripts(include_core: bool = False) -> list[dict]:
|
|
622
|
+
"""List scripts in NEXO_HOME/scripts/.
|
|
623
|
+
|
|
624
|
+
By default only personal scripts. With include_core=True, also shows core/cron scripts.
|
|
625
|
+
"""
|
|
626
|
+
results = []
|
|
627
|
+
for entry in classify_scripts_dir()["entries"]:
|
|
628
|
+
if entry["classification"] not in {"personal", "core"}:
|
|
629
|
+
continue
|
|
630
|
+
if entry["core"] and not include_core:
|
|
631
|
+
continue
|
|
632
|
+
hidden = _truthy(entry.get("metadata", {}).get("hidden"))
|
|
633
|
+
if hidden and not include_core:
|
|
634
|
+
continue
|
|
635
|
+
results.append(entry)
|
|
636
|
+
return results
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _within_scripts_dir(path: Path) -> bool:
|
|
640
|
+
try:
|
|
641
|
+
path.resolve().relative_to(get_scripts_dir().resolve())
|
|
642
|
+
return True
|
|
643
|
+
except Exception:
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def resolve_script(name: str) -> dict | None:
|
|
648
|
+
"""Find a script by name (metadata name or filename stem)."""
|
|
649
|
+
scripts_dir = get_scripts_dir()
|
|
650
|
+
if not scripts_dir.is_dir():
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
for f in scripts_dir.iterdir():
|
|
654
|
+
if not f.is_file() or _is_ignored(f):
|
|
655
|
+
continue
|
|
656
|
+
meta = parse_inline_metadata(f)
|
|
657
|
+
if not _is_script_candidate(f, meta):
|
|
658
|
+
continue
|
|
659
|
+
script_name = meta.get("name", f.stem)
|
|
660
|
+
if script_name == name or f.stem == name:
|
|
661
|
+
runtime = classify_runtime(f, meta)
|
|
662
|
+
return {
|
|
663
|
+
"name": script_name,
|
|
664
|
+
"runtime": runtime,
|
|
665
|
+
"description": meta.get("description", ""),
|
|
666
|
+
"path": str(f),
|
|
667
|
+
"core": f.name in load_core_script_names(),
|
|
668
|
+
"metadata": meta,
|
|
669
|
+
}
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def resolve_script_reference(ref: str) -> dict | None:
|
|
674
|
+
"""Resolve a script by name or by direct filesystem path."""
|
|
675
|
+
direct = Path(ref)
|
|
676
|
+
if direct.is_file():
|
|
677
|
+
meta = parse_inline_metadata(direct)
|
|
678
|
+
return {
|
|
679
|
+
"name": meta.get("name", direct.stem),
|
|
680
|
+
"runtime": classify_runtime(direct, meta),
|
|
681
|
+
"description": meta.get("description", ""),
|
|
682
|
+
"path": str(direct),
|
|
683
|
+
"core": direct.name in load_core_script_names(),
|
|
684
|
+
"metadata": meta,
|
|
685
|
+
}
|
|
686
|
+
return resolve_script(ref)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _extract_script_path_from_program_args(program_args: list) -> Path | None:
|
|
690
|
+
candidate = _extract_script_path_candidate(program_args)
|
|
691
|
+
if candidate is None:
|
|
692
|
+
return None
|
|
693
|
+
if not candidate.is_file():
|
|
694
|
+
return None
|
|
695
|
+
if not _within_scripts_dir(candidate):
|
|
696
|
+
return None
|
|
697
|
+
if _is_ignored(candidate):
|
|
698
|
+
return None
|
|
699
|
+
return candidate
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _extract_script_path_candidate(program_args: list) -> Path | None:
|
|
703
|
+
candidates: list[Path] = []
|
|
704
|
+
for arg in program_args or []:
|
|
705
|
+
if not isinstance(arg, str):
|
|
706
|
+
continue
|
|
707
|
+
candidate = Path(arg).expanduser()
|
|
708
|
+
if not str(candidate).startswith("/") and not str(arg).startswith("~"):
|
|
709
|
+
continue
|
|
710
|
+
candidates.append(candidate)
|
|
711
|
+
if not candidates:
|
|
712
|
+
return None
|
|
713
|
+
return candidates[-1]
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _format_schedule_from_plist(plist_data: dict) -> tuple[str, str, str]:
|
|
717
|
+
if plist_data.get("KeepAlive") is True:
|
|
718
|
+
return "keep_alive", "true", "keep alive"
|
|
719
|
+
if plist_data.get("RunAtLoad") is True and "StartInterval" not in plist_data and "StartCalendarInterval" not in plist_data:
|
|
720
|
+
return "run_at_load", "true", "run at load"
|
|
721
|
+
|
|
722
|
+
if "StartInterval" in plist_data:
|
|
723
|
+
interval = int(plist_data["StartInterval"])
|
|
724
|
+
return "interval", str(interval), f"every {interval}s"
|
|
725
|
+
|
|
726
|
+
cal = plist_data.get("StartCalendarInterval")
|
|
727
|
+
if cal:
|
|
728
|
+
if isinstance(cal, list):
|
|
729
|
+
value = json.dumps(cal, ensure_ascii=False)
|
|
730
|
+
return "calendar", value, "calendar"
|
|
731
|
+
hour = cal.get("Hour")
|
|
732
|
+
minute = cal.get("Minute")
|
|
733
|
+
weekday = cal.get("Weekday")
|
|
734
|
+
if weekday is not None and hour is not None and minute is not None:
|
|
735
|
+
return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} weekday={weekday}"
|
|
736
|
+
if hour is not None and minute is not None:
|
|
737
|
+
return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} daily"
|
|
738
|
+
return "calendar", json.dumps(cal, ensure_ascii=False), "calendar"
|
|
739
|
+
|
|
740
|
+
return "manual", "", ""
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _calendar_payload_from_declared(schedule_value: str) -> dict | list | None:
|
|
744
|
+
if not schedule_value:
|
|
745
|
+
return None
|
|
746
|
+
if schedule_value.lstrip().startswith("{") or schedule_value.lstrip().startswith("["):
|
|
747
|
+
try:
|
|
748
|
+
parsed = json.loads(schedule_value)
|
|
749
|
+
except Exception:
|
|
750
|
+
return None
|
|
751
|
+
return parsed if isinstance(parsed, (dict, list)) else None
|
|
752
|
+
|
|
753
|
+
parts = schedule_value.split(":")
|
|
754
|
+
if len(parts) not in {2, 3}:
|
|
755
|
+
return None
|
|
756
|
+
try:
|
|
757
|
+
hour = int(parts[0])
|
|
758
|
+
minute = int(parts[1])
|
|
759
|
+
weekday = int(parts[2]) if len(parts) == 3 else None
|
|
760
|
+
except ValueError:
|
|
761
|
+
return None
|
|
762
|
+
|
|
763
|
+
payload = {"Hour": hour, "Minute": minute}
|
|
764
|
+
if weekday is not None:
|
|
765
|
+
payload["Weekday"] = weekday
|
|
766
|
+
return payload
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _canonical_schedule_value(schedule_type: str, schedule_value: str | dict | list) -> str:
|
|
770
|
+
if schedule_type == "calendar":
|
|
771
|
+
payload = _calendar_payload_from_declared(str(schedule_value)) if isinstance(schedule_value, str) else schedule_value
|
|
772
|
+
if payload is None:
|
|
773
|
+
return str(schedule_value or "")
|
|
774
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
775
|
+
return str(schedule_value or "")
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _extract_launchctl_value(output: str, prefixes: str | tuple[str, ...]) -> str | None:
|
|
779
|
+
if isinstance(prefixes, str):
|
|
780
|
+
prefixes = (prefixes,)
|
|
781
|
+
for raw_line in output.splitlines():
|
|
782
|
+
line = raw_line.strip()
|
|
783
|
+
for prefix in prefixes:
|
|
784
|
+
if line.startswith(prefix):
|
|
785
|
+
return line[len(prefix):].strip()
|
|
786
|
+
return None
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _launchctl_service_state(label: str) -> dict:
|
|
790
|
+
state = {
|
|
791
|
+
"loaded": None,
|
|
792
|
+
"pid": "",
|
|
793
|
+
"state": "",
|
|
794
|
+
"last_exit_status": "",
|
|
795
|
+
"error": "",
|
|
796
|
+
}
|
|
797
|
+
if platform.system() != "Darwin":
|
|
798
|
+
return state
|
|
799
|
+
|
|
800
|
+
try:
|
|
801
|
+
result = subprocess.run(
|
|
802
|
+
["launchctl", "print", f"gui/{os.getuid()}/{label}"],
|
|
803
|
+
capture_output=True,
|
|
804
|
+
text=True,
|
|
805
|
+
timeout=3,
|
|
806
|
+
)
|
|
807
|
+
except Exception as exc:
|
|
808
|
+
return {**state, "loaded": False, "error": str(exc)}
|
|
809
|
+
|
|
810
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
811
|
+
if result.returncode != 0 or "Could not find service" in output:
|
|
812
|
+
return {**state, "loaded": False, "error": output.strip() or "not loaded"}
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
"loaded": True,
|
|
816
|
+
"pid": _extract_launchctl_value(output, ("pid = ", "PID = ")) or "",
|
|
817
|
+
"state": _extract_launchctl_value(output, "state = ") or "",
|
|
818
|
+
"last_exit_status": _extract_launchctl_value(
|
|
819
|
+
output,
|
|
820
|
+
("last exit code = ", "last exit status = ", "LastExitStatus = "),
|
|
821
|
+
) or "",
|
|
822
|
+
"error": "",
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _keep_alive_runtime_snapshot(record: dict) -> dict:
|
|
827
|
+
if record.get("schedule_type") != "keep_alive":
|
|
828
|
+
return {
|
|
829
|
+
"runtime_state": "unknown",
|
|
830
|
+
"runtime_summary": "",
|
|
831
|
+
"runtime_problems": [],
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
label = record.get("launchd_label") or f"com.nexo.{record.get('cron_id', '')}"
|
|
835
|
+
service = _launchctl_service_state(str(label))
|
|
836
|
+
problems: list[str] = []
|
|
837
|
+
|
|
838
|
+
if service.get("loaded") is False:
|
|
839
|
+
problems.append("keep_alive service not loaded in launchd")
|
|
840
|
+
return {
|
|
841
|
+
"runtime_state": "stale",
|
|
842
|
+
"runtime_summary": "keep_alive service not loaded",
|
|
843
|
+
"runtime_problems": problems,
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
pid = str(service.get("pid", "") or "").strip()
|
|
847
|
+
service_state = str(service.get("state", "") or "").strip().lower()
|
|
848
|
+
last_exit = str(service.get("last_exit_status", "") or "").strip()
|
|
849
|
+
if pid:
|
|
850
|
+
return {
|
|
851
|
+
"runtime_state": "alive",
|
|
852
|
+
"runtime_summary": f"running with pid {pid}",
|
|
853
|
+
"runtime_problems": [],
|
|
854
|
+
}
|
|
855
|
+
if service_state in {"running", "spawned"}:
|
|
856
|
+
return {
|
|
857
|
+
"runtime_state": "alive",
|
|
858
|
+
"runtime_summary": f"launchd state {service_state}",
|
|
859
|
+
"runtime_problems": [],
|
|
860
|
+
}
|
|
861
|
+
if last_exit and last_exit != "0":
|
|
862
|
+
problems.append(f"keep_alive daemon exited with status {last_exit}")
|
|
863
|
+
return {
|
|
864
|
+
"runtime_state": "degraded",
|
|
865
|
+
"runtime_summary": f"last exit {last_exit}",
|
|
866
|
+
"runtime_problems": problems,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
problems.append("keep_alive service is loaded but has no active pid")
|
|
870
|
+
return {
|
|
871
|
+
"runtime_state": "degraded",
|
|
872
|
+
"runtime_summary": "loaded but not running",
|
|
873
|
+
"runtime_problems": problems,
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _discover_personal_schedule_records() -> list[dict]:
|
|
878
|
+
"""Inspect macOS LaunchAgents and return raw personal schedule records."""
|
|
879
|
+
if platform.system() != "Darwin":
|
|
880
|
+
return []
|
|
881
|
+
|
|
882
|
+
results = []
|
|
883
|
+
launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
|
|
884
|
+
if not launch_agents_dir.is_dir():
|
|
885
|
+
return results
|
|
886
|
+
|
|
887
|
+
core_names = load_core_script_names()
|
|
888
|
+
for plist_path in sorted(launch_agents_dir.glob("com.nexo.*.plist")):
|
|
889
|
+
try:
|
|
890
|
+
with plist_path.open("rb") as fh:
|
|
891
|
+
plist_data = plistlib.load(fh)
|
|
892
|
+
except Exception:
|
|
893
|
+
continue
|
|
894
|
+
|
|
895
|
+
env = plist_data.get("EnvironmentVariables") or {}
|
|
896
|
+
if env.get("NEXO_MANAGED_CORE_CRON") == "1":
|
|
897
|
+
continue
|
|
898
|
+
|
|
899
|
+
program_args = plist_data.get("ProgramArguments") or []
|
|
900
|
+
candidate = _extract_script_path_candidate(program_args)
|
|
901
|
+
label = str(plist_data.get("Label", plist_path.stem))
|
|
902
|
+
cron_id = label.replace("com.nexo.", "", 1)
|
|
903
|
+
script_path = candidate.expanduser() if candidate is not None else None
|
|
904
|
+
in_scripts_dir = bool(script_path and _within_scripts_dir(script_path))
|
|
905
|
+
exists = bool(script_path and script_path.is_file())
|
|
906
|
+
ignored = bool(script_path and in_scripts_dir and _is_ignored(script_path))
|
|
907
|
+
is_core = bool(script_path and exists and script_path.name in core_names)
|
|
908
|
+
if is_core or ignored:
|
|
909
|
+
continue
|
|
910
|
+
|
|
911
|
+
schedule_type, schedule_value, schedule_label = _format_schedule_from_plist(plist_data)
|
|
912
|
+
results.append({
|
|
913
|
+
"cron_id": cron_id,
|
|
914
|
+
"script_path": str(script_path) if script_path else "",
|
|
915
|
+
"schedule_type": schedule_type,
|
|
916
|
+
"schedule_value": schedule_value,
|
|
917
|
+
"schedule_label": schedule_label,
|
|
918
|
+
"run_at_load": bool(plist_data.get("RunAtLoad")),
|
|
919
|
+
"launchd_label": label,
|
|
920
|
+
"plist_path": str(plist_path),
|
|
921
|
+
"enabled": True,
|
|
922
|
+
"description": "",
|
|
923
|
+
"managed_marker": env.get(PERSONAL_SCHEDULE_MANAGED_ENV) == "1",
|
|
924
|
+
"script_exists": exists,
|
|
925
|
+
"script_within_scripts_dir": in_scripts_dir,
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
return results
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def audit_personal_schedules() -> dict:
|
|
932
|
+
"""Return semantic schedule audit for personal LaunchAgents.
|
|
933
|
+
|
|
934
|
+
Only schedules created/repaired through the official flow count as managed.
|
|
935
|
+
Manual plists are discovered for visibility and repair, but never blessed.
|
|
936
|
+
"""
|
|
937
|
+
classification = classify_scripts_dir()
|
|
938
|
+
personal_scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
|
|
939
|
+
scripts_by_path = {
|
|
940
|
+
str(Path(entry["path"]).expanduser().resolve(strict=False)): entry
|
|
941
|
+
for entry in personal_scripts
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
audited: list[dict] = []
|
|
945
|
+
summary = {
|
|
946
|
+
"declared_managed": 0,
|
|
947
|
+
"discovered_manual": 0,
|
|
948
|
+
"orphan_schedule": 0,
|
|
949
|
+
"healthy": 0,
|
|
950
|
+
"problems": 0,
|
|
951
|
+
"managed_registered": 0,
|
|
952
|
+
"keep_alive": 0,
|
|
953
|
+
"runtime_alive": 0,
|
|
954
|
+
"runtime_degraded": 0,
|
|
955
|
+
"runtime_duplicated": 0,
|
|
956
|
+
"runtime_stale": 0,
|
|
957
|
+
"runtime_unknown": 0,
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
for record in _discover_personal_schedule_records():
|
|
961
|
+
script_path = record.get("script_path", "")
|
|
962
|
+
resolved_path = str(Path(script_path).expanduser().resolve(strict=False)) if script_path else ""
|
|
963
|
+
script = scripts_by_path.get(resolved_path)
|
|
964
|
+
declared = script.get("declared_schedule", {}) if script else {}
|
|
965
|
+
declared_valid = bool(script and declared.get("required") and declared.get("valid"))
|
|
966
|
+
matches = declared_valid and _schedule_matches(record, declared)
|
|
967
|
+
|
|
968
|
+
if record.get("managed_marker") and declared_valid:
|
|
969
|
+
schedule_origin = "declared_managed"
|
|
970
|
+
elif declared_valid:
|
|
971
|
+
schedule_origin = "discovered_manual"
|
|
972
|
+
else:
|
|
973
|
+
schedule_origin = "orphan_schedule"
|
|
974
|
+
|
|
975
|
+
problems: list[str] = []
|
|
976
|
+
if not record.get("script_within_scripts_dir"):
|
|
977
|
+
problems.append("schedule points outside NEXO_HOME/scripts")
|
|
978
|
+
elif not record.get("script_path"):
|
|
979
|
+
problems.append("schedule does not resolve a script path")
|
|
980
|
+
elif not record.get("script_exists"):
|
|
981
|
+
problems.append(f"scheduled script missing: {record['script_path']}")
|
|
982
|
+
elif not script:
|
|
983
|
+
problems.append("schedule points to a script that is not a registered personal script")
|
|
984
|
+
|
|
985
|
+
if script and not declared.get("required"):
|
|
986
|
+
problems.append("personal schedule exists without declared inline metadata")
|
|
987
|
+
elif script and declared.get("required") and not declared.get("valid"):
|
|
988
|
+
problems.append(declared.get("error", "invalid declared schedule metadata"))
|
|
989
|
+
elif declared_valid and not matches:
|
|
990
|
+
problems.append(
|
|
991
|
+
f"schedule drift: actual {record.get('schedule_label') or record.get('schedule_value') or record.get('schedule_type')} "
|
|
992
|
+
f"!= declared {declared.get('schedule_label') or declared.get('cron_id')}"
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
if declared_valid and not record.get("managed_marker"):
|
|
996
|
+
problems.append("schedule was discovered manually and must be recreated via nexo scripts reconcile")
|
|
997
|
+
|
|
998
|
+
schedule_managed = bool(schedule_origin == "declared_managed" and matches and not problems)
|
|
999
|
+
if schedule_managed:
|
|
1000
|
+
schedule_state = "healthy"
|
|
1001
|
+
elif schedule_origin == "declared_managed":
|
|
1002
|
+
schedule_state = "drifted"
|
|
1003
|
+
elif schedule_origin == "discovered_manual" and matches:
|
|
1004
|
+
schedule_state = "manual_matching_declared"
|
|
1005
|
+
elif schedule_origin == "discovered_manual":
|
|
1006
|
+
schedule_state = "manual_drift"
|
|
1007
|
+
else:
|
|
1008
|
+
schedule_state = "orphaned"
|
|
1009
|
+
|
|
1010
|
+
audited_record = dict(record)
|
|
1011
|
+
runtime_snapshot = _keep_alive_runtime_snapshot(record)
|
|
1012
|
+
audited_record.update({
|
|
1013
|
+
"schedule_origin": schedule_origin,
|
|
1014
|
+
"schedule_declared": declared_valid,
|
|
1015
|
+
"schedule_managed": schedule_managed,
|
|
1016
|
+
"schedule_matches_declared": matches,
|
|
1017
|
+
"schedule_state": schedule_state,
|
|
1018
|
+
"problems": problems,
|
|
1019
|
+
"script_name": script.get("name", "") if script else "",
|
|
1020
|
+
"declared_schedule": declared if script else {},
|
|
1021
|
+
**runtime_snapshot,
|
|
1022
|
+
})
|
|
1023
|
+
audited.append(audited_record)
|
|
1024
|
+
summary[schedule_origin] += 1
|
|
1025
|
+
if schedule_managed:
|
|
1026
|
+
summary["healthy"] += 1
|
|
1027
|
+
summary["managed_registered"] += 1
|
|
1028
|
+
else:
|
|
1029
|
+
summary["problems"] += 1
|
|
1030
|
+
|
|
1031
|
+
duplicate_cron_ids: dict[str, int] = {}
|
|
1032
|
+
duplicate_script_paths: dict[str, int] = {}
|
|
1033
|
+
for record in audited:
|
|
1034
|
+
if record.get("schedule_type") != "keep_alive":
|
|
1035
|
+
continue
|
|
1036
|
+
cron_id = str(record.get("cron_id", "") or "")
|
|
1037
|
+
script_path = str(record.get("script_path", "") or "")
|
|
1038
|
+
if cron_id:
|
|
1039
|
+
duplicate_cron_ids[cron_id] = duplicate_cron_ids.get(cron_id, 0) + 1
|
|
1040
|
+
if script_path:
|
|
1041
|
+
duplicate_script_paths[script_path] = duplicate_script_paths.get(script_path, 0) + 1
|
|
1042
|
+
|
|
1043
|
+
for record in audited:
|
|
1044
|
+
if record.get("schedule_type") == "keep_alive":
|
|
1045
|
+
cron_id = str(record.get("cron_id", "") or "")
|
|
1046
|
+
script_path = str(record.get("script_path", "") or "")
|
|
1047
|
+
duplicated = (
|
|
1048
|
+
(cron_id and duplicate_cron_ids.get(cron_id, 0) > 1)
|
|
1049
|
+
or (script_path and duplicate_script_paths.get(script_path, 0) > 1)
|
|
1050
|
+
)
|
|
1051
|
+
if duplicated:
|
|
1052
|
+
runtime_problems = list(record.get("runtime_problems", []))
|
|
1053
|
+
runtime_problems.append("duplicate keep_alive schedules discovered for the same cron/script")
|
|
1054
|
+
record["runtime_state"] = "duplicated"
|
|
1055
|
+
record["runtime_summary"] = "multiple keep_alive schedules discovered"
|
|
1056
|
+
record["runtime_problems"] = runtime_problems
|
|
1057
|
+
|
|
1058
|
+
if record.get("schedule_type") == "keep_alive":
|
|
1059
|
+
summary["keep_alive"] += 1
|
|
1060
|
+
runtime_state = str(record.get("runtime_state", "unknown") or "unknown")
|
|
1061
|
+
key = f"runtime_{runtime_state}"
|
|
1062
|
+
if key not in summary:
|
|
1063
|
+
summary[key] = 0
|
|
1064
|
+
summary[key] += 1
|
|
1065
|
+
|
|
1066
|
+
return {
|
|
1067
|
+
"schedules": audited,
|
|
1068
|
+
"summary": summary,
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def discover_personal_schedules() -> list[dict]:
|
|
1073
|
+
"""Return only healthy managed personal schedules."""
|
|
1074
|
+
managed: list[dict] = []
|
|
1075
|
+
for record in audit_personal_schedules()["schedules"]:
|
|
1076
|
+
if record.get("schedule_managed"):
|
|
1077
|
+
managed.append({
|
|
1078
|
+
"cron_id": record["cron_id"],
|
|
1079
|
+
"script_path": record["script_path"],
|
|
1080
|
+
"schedule_type": record["schedule_type"],
|
|
1081
|
+
"schedule_value": record["schedule_value"],
|
|
1082
|
+
"schedule_label": record["schedule_label"],
|
|
1083
|
+
"launchd_label": record["launchd_label"],
|
|
1084
|
+
"plist_path": record["plist_path"],
|
|
1085
|
+
"enabled": record.get("enabled", True),
|
|
1086
|
+
"description": record.get("description", ""),
|
|
1087
|
+
})
|
|
1088
|
+
return managed
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def sync_personal_scripts(prune_missing: bool = True) -> dict:
|
|
1092
|
+
"""Sync filesystem + scheduler state into the DB-backed personal scripts registry."""
|
|
1093
|
+
from db import init_db, sync_personal_scripts_registry
|
|
1094
|
+
|
|
1095
|
+
init_db()
|
|
1096
|
+
classification = classify_scripts_dir()
|
|
1097
|
+
scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
|
|
1098
|
+
schedule_audit = audit_personal_schedules()
|
|
1099
|
+
schedules = [record for record in schedule_audit["schedules"] if record.get("schedule_managed")]
|
|
1100
|
+
result = sync_personal_scripts_registry(scripts, schedules, prune_missing=prune_missing)
|
|
1101
|
+
result["classification"] = classification["summary"]
|
|
1102
|
+
missing_declared = []
|
|
1103
|
+
managed_by_path: dict[str, list[dict]] = {}
|
|
1104
|
+
for schedule in schedules:
|
|
1105
|
+
managed_by_path.setdefault(schedule["script_path"], []).append(schedule)
|
|
1106
|
+
schedules_by_path: dict[str, list[dict]] = {}
|
|
1107
|
+
for schedule in schedule_audit["schedules"]:
|
|
1108
|
+
schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
|
|
1109
|
+
for script in scripts:
|
|
1110
|
+
declared = script.get("declared_schedule", {})
|
|
1111
|
+
if not declared.get("required"):
|
|
1112
|
+
continue
|
|
1113
|
+
healthy = managed_by_path.get(script["path"], [])
|
|
1114
|
+
if healthy:
|
|
1115
|
+
continue
|
|
1116
|
+
attached = schedules_by_path.get(script["path"], [])
|
|
1117
|
+
if not attached:
|
|
1118
|
+
missing_declared.append({
|
|
1119
|
+
"name": script["name"],
|
|
1120
|
+
"path": script["path"],
|
|
1121
|
+
"declared_schedule": declared,
|
|
1122
|
+
"reason": "no schedule discovered",
|
|
1123
|
+
})
|
|
1124
|
+
continue
|
|
1125
|
+
attached_states = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in attached]
|
|
1126
|
+
missing_declared.append({
|
|
1127
|
+
"name": script["name"],
|
|
1128
|
+
"path": script["path"],
|
|
1129
|
+
"declared_schedule": declared,
|
|
1130
|
+
"reason": f"schedule discovered but not managed ({', '.join(attached_states)})",
|
|
1131
|
+
})
|
|
1132
|
+
result["schedule_audit"] = schedule_audit
|
|
1133
|
+
result["missing_declared_schedules"] = missing_declared
|
|
1134
|
+
return result
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _schedule_matches(existing: dict, declared: dict) -> bool:
|
|
1138
|
+
if not existing or not declared.get("valid"):
|
|
1139
|
+
return False
|
|
1140
|
+
if existing.get("cron_id") != declared.get("cron_id"):
|
|
1141
|
+
return False
|
|
1142
|
+
if existing.get("schedule_type") != declared.get("schedule_type"):
|
|
1143
|
+
return False
|
|
1144
|
+
existing_value = _canonical_schedule_value(existing.get("schedule_type", ""), existing.get("schedule_value", ""))
|
|
1145
|
+
declared_value = _canonical_schedule_value(declared.get("schedule_type", ""), declared.get("schedule_value", ""))
|
|
1146
|
+
if existing_value != declared_value:
|
|
1147
|
+
return False
|
|
1148
|
+
if bool(existing.get("run_at_load")) != bool(declared.get("run_on_boot")):
|
|
1149
|
+
return False
|
|
1150
|
+
return True
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _remove_schedule_file(*, cron_id: str, plist_path: str) -> dict:
|
|
1154
|
+
removed = {
|
|
1155
|
+
"cron_id": cron_id,
|
|
1156
|
+
"plist_path": plist_path,
|
|
1157
|
+
"deleted": False,
|
|
1158
|
+
}
|
|
1159
|
+
plist = Path(plist_path) if plist_path else None
|
|
1160
|
+
if plist and platform.system() == "Darwin" and plist.exists():
|
|
1161
|
+
subprocess.run(
|
|
1162
|
+
["launchctl", "bootout", f"gui/{os.getuid()}", str(plist)],
|
|
1163
|
+
capture_output=True,
|
|
1164
|
+
)
|
|
1165
|
+
with contextlib.suppress(FileNotFoundError):
|
|
1166
|
+
plist.unlink()
|
|
1167
|
+
removed["deleted"] = True
|
|
1168
|
+
return removed
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
|
|
1172
|
+
"""Create or repair personal schedules declared in inline script metadata."""
|
|
1173
|
+
classification = classify_scripts_dir()
|
|
1174
|
+
scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
|
|
1175
|
+
schedule_audit = audit_personal_schedules()
|
|
1176
|
+
schedules_by_path: dict[str, list[dict]] = {}
|
|
1177
|
+
for schedule in schedule_audit["schedules"]:
|
|
1178
|
+
schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
|
|
1179
|
+
|
|
1180
|
+
report = {
|
|
1181
|
+
"ok": True,
|
|
1182
|
+
"dry_run": dry_run,
|
|
1183
|
+
"created": [],
|
|
1184
|
+
"repaired": [],
|
|
1185
|
+
"already_present": [],
|
|
1186
|
+
"skipped": [],
|
|
1187
|
+
"invalid": [],
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
for script in scripts:
|
|
1191
|
+
declared = script.get("declared_schedule", {})
|
|
1192
|
+
if not declared.get("required"):
|
|
1193
|
+
report["skipped"].append({
|
|
1194
|
+
"name": script["name"],
|
|
1195
|
+
"reason": "no declared schedule",
|
|
1196
|
+
})
|
|
1197
|
+
continue
|
|
1198
|
+
if not declared.get("valid"):
|
|
1199
|
+
report["invalid"].append({
|
|
1200
|
+
"name": script["name"],
|
|
1201
|
+
"path": script["path"],
|
|
1202
|
+
"error": declared.get("error", "invalid schedule metadata"),
|
|
1203
|
+
})
|
|
1204
|
+
continue
|
|
1205
|
+
|
|
1206
|
+
existing = schedules_by_path.get(script["path"], [])
|
|
1207
|
+
matching = next((item for item in existing if item.get("schedule_managed") and _schedule_matches(item, declared)), None)
|
|
1208
|
+
if matching:
|
|
1209
|
+
report["already_present"].append({
|
|
1210
|
+
"name": script["name"],
|
|
1211
|
+
"cron_id": matching["cron_id"],
|
|
1212
|
+
"schedule_label": matching.get("schedule_label", ""),
|
|
1213
|
+
})
|
|
1214
|
+
continue
|
|
1215
|
+
|
|
1216
|
+
repair_reasons = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in existing]
|
|
1217
|
+
if dry_run:
|
|
1218
|
+
report["repaired" if existing else "created"].append({
|
|
1219
|
+
"name": script["name"],
|
|
1220
|
+
"cron_id": declared["cron_id"],
|
|
1221
|
+
"schedule_label": declared["schedule_label"],
|
|
1222
|
+
"dry_run": True,
|
|
1223
|
+
"reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
|
|
1224
|
+
})
|
|
1225
|
+
continue
|
|
1226
|
+
|
|
1227
|
+
removed = []
|
|
1228
|
+
if existing:
|
|
1229
|
+
for item in existing:
|
|
1230
|
+
removed.append(_remove_schedule_file(cron_id=item["cron_id"], plist_path=item.get("plist_path", "")))
|
|
1231
|
+
from db import delete_personal_script_schedule
|
|
1232
|
+
|
|
1233
|
+
for item in existing:
|
|
1234
|
+
delete_personal_script_schedule(item["cron_id"])
|
|
1235
|
+
|
|
1236
|
+
from plugins.schedule import handle_schedule_add
|
|
1237
|
+
|
|
1238
|
+
response = handle_schedule_add(
|
|
1239
|
+
cron_id=declared["cron_id"],
|
|
1240
|
+
script=script["path"],
|
|
1241
|
+
schedule=declared.get("schedule", ""),
|
|
1242
|
+
interval_seconds=declared.get("interval_seconds", 0),
|
|
1243
|
+
description=script.get("description", ""),
|
|
1244
|
+
script_type=script.get("runtime", "auto"),
|
|
1245
|
+
keep_alive=declared.get("schedule_type") == "keep_alive",
|
|
1246
|
+
)
|
|
1247
|
+
target = report["repaired" if existing else "created"]
|
|
1248
|
+
target.append({
|
|
1249
|
+
"name": script["name"],
|
|
1250
|
+
"cron_id": declared["cron_id"],
|
|
1251
|
+
"schedule_label": declared["schedule_label"],
|
|
1252
|
+
"reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
|
|
1253
|
+
"removed": removed,
|
|
1254
|
+
"result": response,
|
|
1255
|
+
})
|
|
1256
|
+
|
|
1257
|
+
sync_result = sync_personal_scripts()
|
|
1258
|
+
report["sync"] = sync_result
|
|
1259
|
+
report["classification"] = classification["summary"]
|
|
1260
|
+
return report
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def reconcile_personal_scripts(*, dry_run: bool = False) -> dict:
|
|
1264
|
+
"""Full lifecycle reconciliation: classify, sync registry, ensure declared schedules."""
|
|
1265
|
+
sync_result = sync_personal_scripts()
|
|
1266
|
+
ensure_result = ensure_personal_schedules(dry_run=dry_run)
|
|
1267
|
+
return {
|
|
1268
|
+
"ok": True,
|
|
1269
|
+
"dry_run": dry_run,
|
|
1270
|
+
"sync": sync_result,
|
|
1271
|
+
"ensure_schedules": ensure_result,
|
|
1272
|
+
"classification": ensure_result.get("classification", sync_result.get("classification", {})),
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _template_path(filename: str) -> Path | None:
|
|
1277
|
+
candidates = [
|
|
1278
|
+
NEXO_HOME / "templates" / filename,
|
|
1279
|
+
NEXO_CODE.parent / "templates" / filename,
|
|
1280
|
+
NEXO_CODE / "templates" / filename,
|
|
1281
|
+
]
|
|
1282
|
+
for candidate in candidates:
|
|
1283
|
+
if candidate.is_file():
|
|
1284
|
+
return candidate
|
|
1285
|
+
return None
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _script_filename_from_name(name: str, runtime: str) -> str:
|
|
1289
|
+
stem = _safe_slug(name) or "personal-script"
|
|
1290
|
+
ext = {
|
|
1291
|
+
"python": ".py",
|
|
1292
|
+
"shell": ".sh",
|
|
1293
|
+
"node": ".js",
|
|
1294
|
+
"php": ".php",
|
|
1295
|
+
}.get(runtime, ".py")
|
|
1296
|
+
return stem + ext
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _personal_script_filename_from_name(name: str, runtime: str) -> str:
|
|
1300
|
+
logical_name = _logical_personal_script_name(name)
|
|
1301
|
+
return _script_filename_from_name(f"{PERSONAL_SCRIPT_FILENAME_PREFIX}{logical_name}", runtime)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def create_script(name: str, *, description: str = "", runtime: str = "python", force: bool = False) -> dict:
|
|
1305
|
+
runtime = runtime if runtime in SUPPORTED_RUNTIMES else "python"
|
|
1306
|
+
if runtime == "unknown":
|
|
1307
|
+
runtime = "python"
|
|
1308
|
+
|
|
1309
|
+
scripts_dir = get_scripts_dir()
|
|
1310
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1311
|
+
logical_name = _logical_personal_script_name(name)
|
|
1312
|
+
filename = _personal_script_filename_from_name(name, runtime)
|
|
1313
|
+
path = scripts_dir / filename
|
|
1314
|
+
if path.exists() and not force:
|
|
1315
|
+
raise FileExistsError(f"Script already exists: {path}")
|
|
1316
|
+
|
|
1317
|
+
if runtime == "shell":
|
|
1318
|
+
template_path = _template_path("script-template.sh")
|
|
1319
|
+
else:
|
|
1320
|
+
template_path = _template_path("script-template.py")
|
|
1321
|
+
|
|
1322
|
+
if template_path:
|
|
1323
|
+
content = template_path.read_text()
|
|
1324
|
+
elif runtime == "shell":
|
|
1325
|
+
content = (
|
|
1326
|
+
"#!/usr/bin/env bash\n"
|
|
1327
|
+
"# nexo: name=example-script\n"
|
|
1328
|
+
"# nexo: description=Example shell script using NEXO\n"
|
|
1329
|
+
"# nexo: runtime=shell\n"
|
|
1330
|
+
"set -euo pipefail\n"
|
|
1331
|
+
"echo \"Hello from NEXO personal script\"\n"
|
|
1332
|
+
)
|
|
1333
|
+
else:
|
|
1334
|
+
content = (
|
|
1335
|
+
"#!/usr/bin/env python3\n"
|
|
1336
|
+
"# nexo: name=example-script\n"
|
|
1337
|
+
"# nexo: description=Example personal script using NEXO\n"
|
|
1338
|
+
"# nexo: runtime=python\n"
|
|
1339
|
+
"print('hello')\n"
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
content = content.replace("example-script", logical_name)
|
|
1343
|
+
content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {logical_name}")
|
|
1344
|
+
content = content.replace("Example shell script using NEXO", description or f"Personal script: {logical_name}")
|
|
1345
|
+
|
|
1346
|
+
path.write_text(content)
|
|
1347
|
+
if runtime in {"shell", "python"}:
|
|
1348
|
+
path.chmod(0o755)
|
|
1349
|
+
sync_result = sync_personal_scripts()
|
|
1350
|
+
return {
|
|
1351
|
+
"ok": True,
|
|
1352
|
+
"name": logical_name,
|
|
1353
|
+
"requested_name": name,
|
|
1354
|
+
"path": str(path),
|
|
1355
|
+
"filename": filename,
|
|
1356
|
+
"runtime": runtime,
|
|
1357
|
+
"description": description,
|
|
1358
|
+
"sync": sync_result,
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def unschedule_personal_script(name_or_path: str) -> dict:
|
|
1363
|
+
"""Remove all personal schedules attached to a script and prune registry entries."""
|
|
1364
|
+
from db import (
|
|
1365
|
+
init_db,
|
|
1366
|
+
get_personal_script,
|
|
1367
|
+
delete_personal_script_schedule,
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
init_db()
|
|
1371
|
+
sync_personal_scripts()
|
|
1372
|
+
script = get_personal_script(name_or_path)
|
|
1373
|
+
if not script:
|
|
1374
|
+
resolved = resolve_script(name_or_path)
|
|
1375
|
+
if not resolved or resolved.get("core"):
|
|
1376
|
+
return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
|
|
1377
|
+
script = resolved
|
|
1378
|
+
|
|
1379
|
+
removed: list[dict] = []
|
|
1380
|
+
audited = audit_personal_schedules()
|
|
1381
|
+
discovered = [
|
|
1382
|
+
item for item in audited["schedules"]
|
|
1383
|
+
if item.get("script_path") == script.get("path")
|
|
1384
|
+
]
|
|
1385
|
+
for schedule in discovered:
|
|
1386
|
+
removed.append(_remove_schedule_file(cron_id=schedule["cron_id"], plist_path=schedule.get("plist_path", "")))
|
|
1387
|
+
|
|
1388
|
+
for schedule in script.get("schedules", []):
|
|
1389
|
+
delete_personal_script_schedule(schedule["cron_id"])
|
|
1390
|
+
if not any(item["cron_id"] == schedule["cron_id"] for item in removed):
|
|
1391
|
+
removed.append({
|
|
1392
|
+
"cron_id": schedule["cron_id"],
|
|
1393
|
+
"plist_path": schedule.get("plist_path", ""),
|
|
1394
|
+
"deleted": False,
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
sync_result = sync_personal_scripts()
|
|
1398
|
+
return {
|
|
1399
|
+
"ok": True,
|
|
1400
|
+
"script": script["name"],
|
|
1401
|
+
"removed_schedules": removed,
|
|
1402
|
+
"sync": sync_result,
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def remove_personal_script(name_or_path: str, *, keep_file: bool = False) -> dict:
|
|
1407
|
+
"""Remove a personal script from the runtime and registry."""
|
|
1408
|
+
from db import init_db, get_personal_script, delete_personal_script
|
|
1409
|
+
|
|
1410
|
+
init_db()
|
|
1411
|
+
sync_personal_scripts()
|
|
1412
|
+
script = get_personal_script(name_or_path)
|
|
1413
|
+
if not script:
|
|
1414
|
+
resolved = resolve_script(name_or_path)
|
|
1415
|
+
if not resolved or resolved.get("core"):
|
|
1416
|
+
return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
|
|
1417
|
+
script = resolved
|
|
1418
|
+
|
|
1419
|
+
if script.get("core"):
|
|
1420
|
+
return {"ok": False, "error": "Refusing to remove a core script via personal scripts lifecycle."}
|
|
1421
|
+
|
|
1422
|
+
unschedule_result = unschedule_personal_script(script["path"])
|
|
1423
|
+
deleted_file = False
|
|
1424
|
+
path = Path(script["path"])
|
|
1425
|
+
if not keep_file and path.is_file() and _within_scripts_dir(path):
|
|
1426
|
+
path.unlink()
|
|
1427
|
+
deleted_file = True
|
|
1428
|
+
delete_personal_script(script["path"])
|
|
1429
|
+
sync_result = sync_personal_scripts()
|
|
1430
|
+
return {
|
|
1431
|
+
"ok": True,
|
|
1432
|
+
"script": script["name"],
|
|
1433
|
+
"path": script["path"],
|
|
1434
|
+
"deleted_file": deleted_file,
|
|
1435
|
+
"keep_file": keep_file,
|
|
1436
|
+
"unschedule": unschedule_result,
|
|
1437
|
+
"sync": sync_result,
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def doctor_script(path_or_name: str) -> dict:
|
|
1442
|
+
"""Validate a single script. Returns dict with pass/warn/fail items."""
|
|
1443
|
+
# Resolve
|
|
1444
|
+
p = Path(path_or_name)
|
|
1445
|
+
if not p.is_file():
|
|
1446
|
+
info = resolve_script(path_or_name)
|
|
1447
|
+
if not info:
|
|
1448
|
+
return {"status": "fail", "items": [{"level": "fail", "msg": f"Script not found: {path_or_name}"}]}
|
|
1449
|
+
p = Path(info["path"])
|
|
1450
|
+
|
|
1451
|
+
items: list[dict] = []
|
|
1452
|
+
meta = parse_inline_metadata(p)
|
|
1453
|
+
runtime = classify_runtime(p, meta)
|
|
1454
|
+
core_names = load_core_script_names()
|
|
1455
|
+
is_core = p.name in core_names
|
|
1456
|
+
|
|
1457
|
+
# File exists
|
|
1458
|
+
if p.is_file():
|
|
1459
|
+
items.append({"level": "pass", "msg": f"File exists: {p.name}"})
|
|
1460
|
+
else:
|
|
1461
|
+
items.append({"level": "fail", "msg": f"File missing: {p.name}"})
|
|
1462
|
+
return {"status": "fail", "items": items}
|
|
1463
|
+
|
|
1464
|
+
# Name collision with core
|
|
1465
|
+
name = meta.get("name", p.stem)
|
|
1466
|
+
if not is_core:
|
|
1467
|
+
for core in core_names:
|
|
1468
|
+
core_stem = Path(core).stem
|
|
1469
|
+
if name == core_stem:
|
|
1470
|
+
items.append({"level": "fail", "msg": f"Name collision with core script: {core}"})
|
|
1471
|
+
|
|
1472
|
+
# Runtime recognized
|
|
1473
|
+
if runtime == "unknown":
|
|
1474
|
+
items.append({"level": "warn", "msg": "Runtime not recognized (no shebang, no extension match)"})
|
|
1475
|
+
else:
|
|
1476
|
+
items.append({"level": "pass", "msg": f"Runtime: {runtime}"})
|
|
1477
|
+
|
|
1478
|
+
# Shebang for shell scripts
|
|
1479
|
+
if runtime == "shell":
|
|
1480
|
+
shebang = _detect_shebang(p)
|
|
1481
|
+
if not shebang:
|
|
1482
|
+
items.append({"level": "warn", "msg": "Shell script without shebang"})
|
|
1483
|
+
else:
|
|
1484
|
+
items.append({"level": "pass", "msg": f"Shebang: {shebang}"})
|
|
1485
|
+
|
|
1486
|
+
# Executable bit for shell scripts
|
|
1487
|
+
if runtime == "shell":
|
|
1488
|
+
mode = p.stat().st_mode
|
|
1489
|
+
if not (mode & stat.S_IXUSR):
|
|
1490
|
+
items.append({"level": "warn", "msg": "Shell script missing executable bit"})
|
|
1491
|
+
else:
|
|
1492
|
+
items.append({"level": "pass", "msg": "Executable bit set"})
|
|
1493
|
+
|
|
1494
|
+
# Timeout parse
|
|
1495
|
+
timeout_str = meta.get("timeout", "")
|
|
1496
|
+
if timeout_str:
|
|
1497
|
+
try:
|
|
1498
|
+
int(timeout_str)
|
|
1499
|
+
items.append({"level": "pass", "msg": f"Timeout: {timeout_str}s"})
|
|
1500
|
+
except ValueError:
|
|
1501
|
+
items.append({"level": "fail", "msg": f"Invalid timeout value: {timeout_str}"})
|
|
1502
|
+
|
|
1503
|
+
declared = get_declared_schedule(meta, name)
|
|
1504
|
+
if declared.get("required"):
|
|
1505
|
+
if declared.get("valid"):
|
|
1506
|
+
items.append({"level": "pass", "msg": f"Declared schedule: {declared['schedule_label']}"})
|
|
1507
|
+
else:
|
|
1508
|
+
items.append({"level": "fail", "msg": declared.get("error", "Invalid declared schedule metadata")})
|
|
1509
|
+
|
|
1510
|
+
if runtime == "node" and not shutil.which("node"):
|
|
1511
|
+
items.append({"level": "fail", "msg": "Node runtime not found in PATH"})
|
|
1512
|
+
if runtime == "php" and not shutil.which("php"):
|
|
1513
|
+
items.append({"level": "fail", "msg": "PHP runtime not found in PATH"})
|
|
1514
|
+
|
|
1515
|
+
# Requires check
|
|
1516
|
+
requires = meta.get("requires", "")
|
|
1517
|
+
if requires:
|
|
1518
|
+
for cmd in requires.split(","):
|
|
1519
|
+
cmd = cmd.strip()
|
|
1520
|
+
if cmd and not shutil.which(cmd):
|
|
1521
|
+
items.append({"level": "fail", "msg": f"Required command not in PATH: {cmd}"})
|
|
1522
|
+
elif cmd:
|
|
1523
|
+
items.append({"level": "pass", "msg": f"Required command found: {cmd}"})
|
|
1524
|
+
|
|
1525
|
+
allow_db_access = str(meta.get("doctor_allow_db", "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
1526
|
+
if allow_db_access:
|
|
1527
|
+
items.append({"level": "pass", "msg": "Doctor DB access explicitly allowed"})
|
|
1528
|
+
|
|
1529
|
+
# Forbidden patterns (only for personal scripts)
|
|
1530
|
+
if not is_core:
|
|
1531
|
+
try:
|
|
1532
|
+
content = p.read_text(errors="ignore")
|
|
1533
|
+
if not allow_db_access:
|
|
1534
|
+
for pat in _FORBIDDEN_PATTERNS:
|
|
1535
|
+
match = pat.search(content)
|
|
1536
|
+
if match:
|
|
1537
|
+
items.append({"level": "fail", "msg": f"Forbidden DB pattern found: {match.group()}"})
|
|
1538
|
+
except Exception:
|
|
1539
|
+
pass
|
|
1540
|
+
|
|
1541
|
+
# Determine overall status
|
|
1542
|
+
levels = [i["level"] for i in items]
|
|
1543
|
+
if "fail" in levels:
|
|
1544
|
+
status = "fail"
|
|
1545
|
+
elif "warn" in levels:
|
|
1546
|
+
status = "warn"
|
|
1547
|
+
else:
|
|
1548
|
+
status = "pass"
|
|
1549
|
+
|
|
1550
|
+
return {"status": status, "items": items, "name": name, "path": str(p)}
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def doctor_all_scripts() -> list[dict]:
|
|
1554
|
+
"""Run doctor on all personal scripts."""
|
|
1555
|
+
results = []
|
|
1556
|
+
for script in list_scripts(include_core=False):
|
|
1557
|
+
result = doctor_script(script["path"])
|
|
1558
|
+
results.append(result)
|
|
1559
|
+
return results
|