nexo-brain 2.5.1 → 2.6.0
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 +33 -0
- package/.mcp.json +12 -0
- package/README.md +38 -26
- package/bin/nexo-brain.js +35 -32
- package/hooks/hooks.json +14 -0
- package/package.json +11 -4
- package/src/auto_update.py +44 -1
- package/src/cli.py +388 -23
- package/src/cron_recovery.py +283 -0
- package/src/crons/manifest.json +79 -21
- package/src/crons/sync.py +132 -27
- package/src/db/__init__.py +11 -0
- package/src/db/_personal_scripts.py +548 -0
- package/src/db/_schema.py +44 -1
- package/src/doctor/providers/runtime.py +272 -75
- package/src/evolution_cycle.py +4 -1
- package/src/nexo.db +0 -0
- package/src/plugins/personal_scripts.py +117 -0
- package/src/plugins/schedule.py +116 -27
- package/src/script_registry.py +877 -28
- package/src/scripts/nexo-catchup.py +74 -109
- package/src/scripts/nexo-evolution-run.py +37 -12
- package/src/scripts/nexo-watchdog.sh +242 -54
- package/src/tools_learnings.py +8 -0
- package/templates/launchagents/com.nexo.catchup.plist +7 -6
- package/templates/script-template.py +3 -0
- package/templates/script-template.sh +13 -0
- package/src/scripts/nexo-day-orchestrator.sh +0 -139
package/src/script_registry.py
CHANGED
|
@@ -5,8 +5,11 @@ Personal scripts use CLI as stable interface, never direct DB access.
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import contextlib
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
11
|
+
import platform
|
|
12
|
+
import plistlib
|
|
10
13
|
import re
|
|
11
14
|
import shutil
|
|
12
15
|
import stat
|
|
@@ -22,6 +25,10 @@ _IGNORED_FILES = {
|
|
|
22
25
|
".watchdog-fails",
|
|
23
26
|
".watchdog-nexo-repair.lock",
|
|
24
27
|
"nexo-cron-wrapper.sh",
|
|
28
|
+
"nexo-dashboard.sh",
|
|
29
|
+
"nexo-prevent-sleep.sh",
|
|
30
|
+
"nexo-proactive-dashboard.py",
|
|
31
|
+
"nexo-tcc-approve.sh",
|
|
25
32
|
}
|
|
26
33
|
_IGNORED_DIRS = {"deep-sleep", "__pycache__"}
|
|
27
34
|
|
|
@@ -38,7 +45,22 @@ _FORBIDDEN_PATTERNS = [
|
|
|
38
45
|
re.compile(r"\bfrom\s+cognitive\s+import\b"),
|
|
39
46
|
]
|
|
40
47
|
|
|
41
|
-
METADATA_KEYS = {
|
|
48
|
+
METADATA_KEYS = {
|
|
49
|
+
"name",
|
|
50
|
+
"description",
|
|
51
|
+
"runtime",
|
|
52
|
+
"timeout",
|
|
53
|
+
"requires",
|
|
54
|
+
"tools",
|
|
55
|
+
"hidden",
|
|
56
|
+
"category",
|
|
57
|
+
"cron_id",
|
|
58
|
+
"schedule",
|
|
59
|
+
"interval_seconds",
|
|
60
|
+
"schedule_required",
|
|
61
|
+
}
|
|
62
|
+
SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
|
|
63
|
+
PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
|
|
42
64
|
|
|
43
65
|
|
|
44
66
|
def get_nexo_home() -> Path:
|
|
@@ -67,7 +89,12 @@ def load_core_script_names() -> set[str]:
|
|
|
67
89
|
|
|
68
90
|
|
|
69
91
|
def parse_inline_metadata(path: Path) -> dict:
|
|
70
|
-
"""Parse
|
|
92
|
+
"""Parse inline metadata from first 25 lines.
|
|
93
|
+
|
|
94
|
+
Supported comment prefixes:
|
|
95
|
+
- # nexo:
|
|
96
|
+
- // nexo:
|
|
97
|
+
"""
|
|
71
98
|
meta: dict[str, str] = {}
|
|
72
99
|
try:
|
|
73
100
|
lines = path.read_text(errors="ignore").splitlines()[:25]
|
|
@@ -76,9 +103,13 @@ def parse_inline_metadata(path: Path) -> dict:
|
|
|
76
103
|
|
|
77
104
|
for line in lines:
|
|
78
105
|
stripped = line.strip()
|
|
79
|
-
|
|
106
|
+
payload = ""
|
|
107
|
+
if stripped.startswith("# nexo:"):
|
|
108
|
+
payload = stripped[len("# nexo:"):].strip()
|
|
109
|
+
elif stripped.startswith("// nexo:"):
|
|
110
|
+
payload = stripped[len("// nexo:"):].strip()
|
|
111
|
+
else:
|
|
80
112
|
continue
|
|
81
|
-
payload = stripped[len("# nexo:"):].strip()
|
|
82
113
|
if "=" not in payload:
|
|
83
114
|
continue
|
|
84
115
|
key, value = payload.split("=", 1)
|
|
@@ -100,10 +131,10 @@ def _detect_shebang(path: Path) -> str | None:
|
|
|
100
131
|
|
|
101
132
|
|
|
102
133
|
def classify_runtime(path: Path, metadata: dict) -> str:
|
|
103
|
-
"""Detect script runtime: python, shell, or unknown."""
|
|
134
|
+
"""Detect script runtime: python, shell, node, php, or unknown."""
|
|
104
135
|
# 1. Metadata
|
|
105
136
|
rt = metadata.get("runtime", "").lower()
|
|
106
|
-
if rt in ("python", "shell"):
|
|
137
|
+
if rt in ("python", "shell", "node", "php"):
|
|
107
138
|
return rt
|
|
108
139
|
|
|
109
140
|
# 2. Shebang
|
|
@@ -113,6 +144,10 @@ def classify_runtime(path: Path, metadata: dict) -> str:
|
|
|
113
144
|
return "python"
|
|
114
145
|
if "bash" in shebang or "/sh" in shebang:
|
|
115
146
|
return "shell"
|
|
147
|
+
if "node" in shebang:
|
|
148
|
+
return "node"
|
|
149
|
+
if "php" in shebang:
|
|
150
|
+
return "php"
|
|
116
151
|
|
|
117
152
|
# 3. Extension
|
|
118
153
|
ext = path.suffix.lower()
|
|
@@ -120,6 +155,10 @@ def classify_runtime(path: Path, metadata: dict) -> str:
|
|
|
120
155
|
return "python"
|
|
121
156
|
if ext == ".sh":
|
|
122
157
|
return "shell"
|
|
158
|
+
if ext == ".js":
|
|
159
|
+
return "node"
|
|
160
|
+
if ext == ".php":
|
|
161
|
+
return "php"
|
|
123
162
|
|
|
124
163
|
return "unknown"
|
|
125
164
|
|
|
@@ -136,46 +175,237 @@ def _is_ignored(path: Path) -> bool:
|
|
|
136
175
|
return False
|
|
137
176
|
|
|
138
177
|
|
|
139
|
-
def
|
|
140
|
-
|
|
178
|
+
def _is_script_candidate(path: Path, metadata: dict | None = None) -> bool:
|
|
179
|
+
metadata = metadata or {}
|
|
180
|
+
runtime = classify_runtime(path, metadata)
|
|
181
|
+
if runtime != "unknown":
|
|
182
|
+
return True
|
|
183
|
+
if _detect_shebang(path):
|
|
184
|
+
return True
|
|
185
|
+
try:
|
|
186
|
+
return os.access(path, os.X_OK)
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|
|
141
189
|
|
|
142
|
-
|
|
143
|
-
|
|
190
|
+
|
|
191
|
+
def _truthy(value: str | bool | None) -> bool:
|
|
192
|
+
if isinstance(value, bool):
|
|
193
|
+
return value
|
|
194
|
+
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _safe_slug(value: str) -> str:
|
|
198
|
+
chars: list[str] = []
|
|
199
|
+
for ch in value.lower():
|
|
200
|
+
if ch.isalnum():
|
|
201
|
+
chars.append(ch)
|
|
202
|
+
elif ch in {"-", "_", " "}:
|
|
203
|
+
chars.append("-")
|
|
204
|
+
slug = "".join(chars).strip("-")
|
|
205
|
+
return slug or "script"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
|
|
209
|
+
"""Parse desired schedule metadata from inline script metadata."""
|
|
210
|
+
explicit_name = metadata.get("name", "").strip()
|
|
211
|
+
explicit_runtime = metadata.get("runtime", "").strip().lower()
|
|
212
|
+
explicit_cron_id = metadata.get("cron_id", "").strip()
|
|
213
|
+
cron_id = explicit_cron_id or _safe_slug(default_name or explicit_name or "script")
|
|
214
|
+
interval_raw = metadata.get("interval_seconds", "").strip()
|
|
215
|
+
schedule_raw = metadata.get("schedule", "").strip()
|
|
216
|
+
schedule_required = _truthy(metadata.get("schedule_required"))
|
|
217
|
+
required = schedule_required or bool(interval_raw or schedule_raw)
|
|
218
|
+
|
|
219
|
+
if required:
|
|
220
|
+
missing = []
|
|
221
|
+
if not explicit_name:
|
|
222
|
+
missing.append("name")
|
|
223
|
+
if not explicit_runtime:
|
|
224
|
+
missing.append("runtime")
|
|
225
|
+
elif explicit_runtime not in SUPPORTED_RUNTIMES - {"unknown"}:
|
|
226
|
+
return {
|
|
227
|
+
"required": required,
|
|
228
|
+
"valid": False,
|
|
229
|
+
"error": f"Invalid runtime metadata for scheduled script: {explicit_runtime}",
|
|
230
|
+
"cron_id": cron_id,
|
|
231
|
+
}
|
|
232
|
+
if not explicit_cron_id:
|
|
233
|
+
missing.append("cron_id")
|
|
234
|
+
if not schedule_required:
|
|
235
|
+
missing.append("schedule_required=true")
|
|
236
|
+
if missing:
|
|
237
|
+
return {
|
|
238
|
+
"required": required,
|
|
239
|
+
"valid": False,
|
|
240
|
+
"error": f"Scheduled scripts must declare {', '.join(missing)}",
|
|
241
|
+
"cron_id": cron_id,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if interval_raw and schedule_raw:
|
|
245
|
+
return {
|
|
246
|
+
"required": required,
|
|
247
|
+
"valid": False,
|
|
248
|
+
"error": "Both schedule and interval_seconds are set; choose one.",
|
|
249
|
+
"cron_id": cron_id,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if interval_raw:
|
|
253
|
+
try:
|
|
254
|
+
interval = int(interval_raw)
|
|
255
|
+
except ValueError:
|
|
256
|
+
return {
|
|
257
|
+
"required": required,
|
|
258
|
+
"valid": False,
|
|
259
|
+
"error": f"Invalid interval_seconds: {interval_raw}",
|
|
260
|
+
"cron_id": cron_id,
|
|
261
|
+
}
|
|
262
|
+
if interval <= 0:
|
|
263
|
+
return {
|
|
264
|
+
"required": required,
|
|
265
|
+
"valid": False,
|
|
266
|
+
"error": f"interval_seconds must be > 0 (got {interval_raw})",
|
|
267
|
+
"cron_id": cron_id,
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
"required": required,
|
|
271
|
+
"valid": True,
|
|
272
|
+
"cron_id": cron_id,
|
|
273
|
+
"schedule_type": "interval",
|
|
274
|
+
"schedule_value": str(interval),
|
|
275
|
+
"schedule_label": f"every {interval}s",
|
|
276
|
+
"schedule": "",
|
|
277
|
+
"interval_seconds": interval,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if schedule_raw:
|
|
281
|
+
parts = schedule_raw.split(":")
|
|
282
|
+
if len(parts) not in {2, 3}:
|
|
283
|
+
return {
|
|
284
|
+
"required": required,
|
|
285
|
+
"valid": False,
|
|
286
|
+
"error": f"Invalid schedule format: {schedule_raw}",
|
|
287
|
+
"cron_id": cron_id,
|
|
288
|
+
}
|
|
289
|
+
try:
|
|
290
|
+
hour = int(parts[0])
|
|
291
|
+
minute = int(parts[1])
|
|
292
|
+
weekday = int(parts[2]) if len(parts) == 3 else None
|
|
293
|
+
except ValueError:
|
|
294
|
+
return {
|
|
295
|
+
"required": required,
|
|
296
|
+
"valid": False,
|
|
297
|
+
"error": f"Invalid schedule format: {schedule_raw}",
|
|
298
|
+
"cron_id": cron_id,
|
|
299
|
+
}
|
|
300
|
+
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
|
301
|
+
return {
|
|
302
|
+
"required": required,
|
|
303
|
+
"valid": False,
|
|
304
|
+
"error": f"Invalid schedule time: {schedule_raw}",
|
|
305
|
+
"cron_id": cron_id,
|
|
306
|
+
}
|
|
307
|
+
if weekday is not None and not (0 <= weekday <= 6):
|
|
308
|
+
return {
|
|
309
|
+
"required": required,
|
|
310
|
+
"valid": False,
|
|
311
|
+
"error": f"Invalid schedule weekday: {schedule_raw}",
|
|
312
|
+
"cron_id": cron_id,
|
|
313
|
+
}
|
|
314
|
+
label = f"{hour:02d}:{minute:02d}"
|
|
315
|
+
if weekday is not None:
|
|
316
|
+
label += f" weekday={weekday}"
|
|
317
|
+
else:
|
|
318
|
+
label += " daily"
|
|
319
|
+
return {
|
|
320
|
+
"required": required,
|
|
321
|
+
"valid": True,
|
|
322
|
+
"cron_id": cron_id,
|
|
323
|
+
"schedule_type": "calendar",
|
|
324
|
+
"schedule_value": schedule_raw,
|
|
325
|
+
"schedule_label": label,
|
|
326
|
+
"schedule": schedule_raw,
|
|
327
|
+
"interval_seconds": 0,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
"required": required,
|
|
332
|
+
"valid": not required,
|
|
333
|
+
"error": "" if not required else "schedule_required=true but no schedule metadata was provided.",
|
|
334
|
+
"cron_id": cron_id,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str, reason: str = "") -> dict:
|
|
339
|
+
runtime = classify_runtime(path, meta)
|
|
340
|
+
name = meta.get("name", path.stem)
|
|
341
|
+
return {
|
|
342
|
+
"name": name,
|
|
343
|
+
"runtime": runtime,
|
|
344
|
+
"description": meta.get("description", ""),
|
|
345
|
+
"path": str(path),
|
|
346
|
+
"core": is_core,
|
|
347
|
+
"metadata": meta,
|
|
348
|
+
"classification": classification,
|
|
349
|
+
"reason": reason,
|
|
350
|
+
"declared_schedule": get_declared_schedule(meta, name),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def classify_scripts_dir() -> dict:
|
|
355
|
+
"""Classify every file in NEXO_HOME/scripts into personal/core/ignored/non-script buckets."""
|
|
144
356
|
scripts_dir = get_scripts_dir()
|
|
145
357
|
if not scripts_dir.is_dir():
|
|
146
|
-
return []
|
|
358
|
+
return {"scripts_dir": str(scripts_dir), "entries": [], "summary": {}}
|
|
147
359
|
|
|
148
360
|
core_names = load_core_script_names()
|
|
149
|
-
|
|
150
|
-
|
|
361
|
+
entries: list[dict] = []
|
|
151
362
|
for f in sorted(scripts_dir.iterdir()):
|
|
152
363
|
if not f.is_file():
|
|
153
364
|
continue
|
|
365
|
+
|
|
366
|
+
meta = parse_inline_metadata(f)
|
|
154
367
|
if _is_ignored(f):
|
|
368
|
+
entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
|
|
155
369
|
continue
|
|
156
370
|
|
|
157
|
-
|
|
158
|
-
|
|
371
|
+
if not _is_script_candidate(f, meta):
|
|
372
|
+
entries.append(_script_entry(f, meta, is_core=False, classification="non-script", reason="not an executable/script candidate"))
|
|
159
373
|
continue
|
|
160
374
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
375
|
+
is_core = f.name in core_names
|
|
376
|
+
classification = "core" if is_core else "personal"
|
|
377
|
+
entries.append(_script_entry(f, meta, is_core=is_core, classification=classification))
|
|
378
|
+
|
|
379
|
+
summary: dict[str, int] = {}
|
|
380
|
+
for entry in entries:
|
|
381
|
+
summary[entry["classification"]] = summary.get(entry["classification"], 0) + 1
|
|
382
|
+
return {"scripts_dir": str(scripts_dir), "entries": entries, "summary": summary}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def list_scripts(include_core: bool = False) -> list[dict]:
|
|
386
|
+
"""List scripts in NEXO_HOME/scripts/.
|
|
165
387
|
|
|
388
|
+
By default only personal scripts. With include_core=True, also shows core/cron scripts.
|
|
389
|
+
"""
|
|
390
|
+
results = []
|
|
391
|
+
for entry in classify_scripts_dir()["entries"]:
|
|
392
|
+
if entry["classification"] not in {"personal", "core"}:
|
|
393
|
+
continue
|
|
394
|
+
if entry["core"] and not include_core:
|
|
395
|
+
continue
|
|
396
|
+
hidden = _truthy(entry.get("metadata", {}).get("hidden"))
|
|
166
397
|
if hidden and not include_core:
|
|
167
398
|
continue
|
|
399
|
+
results.append(entry)
|
|
400
|
+
return results
|
|
168
401
|
|
|
169
|
-
results.append({
|
|
170
|
-
"name": name,
|
|
171
|
-
"runtime": runtime,
|
|
172
|
-
"description": meta.get("description", ""),
|
|
173
|
-
"path": str(f),
|
|
174
|
-
"core": is_core,
|
|
175
|
-
"metadata": meta,
|
|
176
|
-
})
|
|
177
402
|
|
|
178
|
-
|
|
403
|
+
def _within_scripts_dir(path: Path) -> bool:
|
|
404
|
+
try:
|
|
405
|
+
path.resolve().relative_to(get_scripts_dir().resolve())
|
|
406
|
+
return True
|
|
407
|
+
except Exception:
|
|
408
|
+
return False
|
|
179
409
|
|
|
180
410
|
|
|
181
411
|
def resolve_script(name: str) -> dict | None:
|
|
@@ -188,6 +418,8 @@ def resolve_script(name: str) -> dict | None:
|
|
|
188
418
|
if not f.is_file() or _is_ignored(f):
|
|
189
419
|
continue
|
|
190
420
|
meta = parse_inline_metadata(f)
|
|
421
|
+
if not _is_script_candidate(f, meta):
|
|
422
|
+
continue
|
|
191
423
|
script_name = meta.get("name", f.stem)
|
|
192
424
|
if script_name == name or f.stem == name:
|
|
193
425
|
runtime = classify_runtime(f, meta)
|
|
@@ -218,6 +450,611 @@ def resolve_script_reference(ref: str) -> dict | None:
|
|
|
218
450
|
return resolve_script(ref)
|
|
219
451
|
|
|
220
452
|
|
|
453
|
+
def _extract_script_path_from_program_args(program_args: list) -> Path | None:
|
|
454
|
+
candidate = _extract_script_path_candidate(program_args)
|
|
455
|
+
if candidate is None:
|
|
456
|
+
return None
|
|
457
|
+
if not candidate.is_file():
|
|
458
|
+
return None
|
|
459
|
+
if not _within_scripts_dir(candidate):
|
|
460
|
+
return None
|
|
461
|
+
if _is_ignored(candidate):
|
|
462
|
+
return None
|
|
463
|
+
return candidate
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _extract_script_path_candidate(program_args: list) -> Path | None:
|
|
467
|
+
candidates: list[Path] = []
|
|
468
|
+
for arg in program_args or []:
|
|
469
|
+
if not isinstance(arg, str):
|
|
470
|
+
continue
|
|
471
|
+
candidate = Path(arg).expanduser()
|
|
472
|
+
if not str(candidate).startswith("/") and not str(arg).startswith("~"):
|
|
473
|
+
continue
|
|
474
|
+
candidates.append(candidate)
|
|
475
|
+
if not candidates:
|
|
476
|
+
return None
|
|
477
|
+
return candidates[-1]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _format_schedule_from_plist(plist_data: dict) -> tuple[str, str, str]:
|
|
481
|
+
if plist_data.get("KeepAlive") is True:
|
|
482
|
+
return "keep_alive", "true", "keep alive"
|
|
483
|
+
if plist_data.get("RunAtLoad") is True and "StartInterval" not in plist_data and "StartCalendarInterval" not in plist_data:
|
|
484
|
+
return "run_at_load", "true", "run at load"
|
|
485
|
+
|
|
486
|
+
if "StartInterval" in plist_data:
|
|
487
|
+
interval = int(plist_data["StartInterval"])
|
|
488
|
+
return "interval", str(interval), f"every {interval}s"
|
|
489
|
+
|
|
490
|
+
cal = plist_data.get("StartCalendarInterval")
|
|
491
|
+
if cal:
|
|
492
|
+
if isinstance(cal, list):
|
|
493
|
+
value = json.dumps(cal, ensure_ascii=False)
|
|
494
|
+
return "calendar", value, "calendar"
|
|
495
|
+
hour = cal.get("Hour")
|
|
496
|
+
minute = cal.get("Minute")
|
|
497
|
+
weekday = cal.get("Weekday")
|
|
498
|
+
if weekday is not None and hour is not None and minute is not None:
|
|
499
|
+
return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} weekday={weekday}"
|
|
500
|
+
if hour is not None and minute is not None:
|
|
501
|
+
return "calendar", json.dumps(cal, ensure_ascii=False), f"{hour:02d}:{minute:02d} daily"
|
|
502
|
+
return "calendar", json.dumps(cal, ensure_ascii=False), "calendar"
|
|
503
|
+
|
|
504
|
+
return "manual", "", ""
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _calendar_payload_from_declared(schedule_value: str) -> dict | list | None:
|
|
508
|
+
if not schedule_value:
|
|
509
|
+
return None
|
|
510
|
+
if schedule_value.lstrip().startswith("{") or schedule_value.lstrip().startswith("["):
|
|
511
|
+
try:
|
|
512
|
+
parsed = json.loads(schedule_value)
|
|
513
|
+
except Exception:
|
|
514
|
+
return None
|
|
515
|
+
return parsed if isinstance(parsed, (dict, list)) else None
|
|
516
|
+
|
|
517
|
+
parts = schedule_value.split(":")
|
|
518
|
+
if len(parts) not in {2, 3}:
|
|
519
|
+
return None
|
|
520
|
+
try:
|
|
521
|
+
hour = int(parts[0])
|
|
522
|
+
minute = int(parts[1])
|
|
523
|
+
weekday = int(parts[2]) if len(parts) == 3 else None
|
|
524
|
+
except ValueError:
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
payload = {"Hour": hour, "Minute": minute}
|
|
528
|
+
if weekday is not None:
|
|
529
|
+
payload["Weekday"] = weekday
|
|
530
|
+
return payload
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _canonical_schedule_value(schedule_type: str, schedule_value: str | dict | list) -> str:
|
|
534
|
+
if schedule_type == "calendar":
|
|
535
|
+
payload = _calendar_payload_from_declared(str(schedule_value)) if isinstance(schedule_value, str) else schedule_value
|
|
536
|
+
if payload is None:
|
|
537
|
+
return str(schedule_value or "")
|
|
538
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
539
|
+
return str(schedule_value or "")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _discover_personal_schedule_records() -> list[dict]:
|
|
543
|
+
"""Inspect macOS LaunchAgents and return raw personal schedule records."""
|
|
544
|
+
if platform.system() != "Darwin":
|
|
545
|
+
return []
|
|
546
|
+
|
|
547
|
+
results = []
|
|
548
|
+
launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
|
|
549
|
+
if not launch_agents_dir.is_dir():
|
|
550
|
+
return results
|
|
551
|
+
|
|
552
|
+
core_names = load_core_script_names()
|
|
553
|
+
for plist_path in sorted(launch_agents_dir.glob("com.nexo.*.plist")):
|
|
554
|
+
try:
|
|
555
|
+
with plist_path.open("rb") as fh:
|
|
556
|
+
plist_data = plistlib.load(fh)
|
|
557
|
+
except Exception:
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
env = plist_data.get("EnvironmentVariables") or {}
|
|
561
|
+
if env.get("NEXO_MANAGED_CORE_CRON") == "1":
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
program_args = plist_data.get("ProgramArguments") or []
|
|
565
|
+
candidate = _extract_script_path_candidate(program_args)
|
|
566
|
+
label = str(plist_data.get("Label", plist_path.stem))
|
|
567
|
+
cron_id = label.replace("com.nexo.", "", 1)
|
|
568
|
+
script_path = candidate.expanduser() if candidate is not None else None
|
|
569
|
+
in_scripts_dir = bool(script_path and _within_scripts_dir(script_path))
|
|
570
|
+
exists = bool(script_path and script_path.is_file())
|
|
571
|
+
ignored = bool(script_path and in_scripts_dir and _is_ignored(script_path))
|
|
572
|
+
is_core = bool(script_path and exists and script_path.name in core_names)
|
|
573
|
+
if is_core or ignored:
|
|
574
|
+
continue
|
|
575
|
+
|
|
576
|
+
schedule_type, schedule_value, schedule_label = _format_schedule_from_plist(plist_data)
|
|
577
|
+
results.append({
|
|
578
|
+
"cron_id": cron_id,
|
|
579
|
+
"script_path": str(script_path) if script_path else "",
|
|
580
|
+
"schedule_type": schedule_type,
|
|
581
|
+
"schedule_value": schedule_value,
|
|
582
|
+
"schedule_label": schedule_label,
|
|
583
|
+
"launchd_label": label,
|
|
584
|
+
"plist_path": str(plist_path),
|
|
585
|
+
"enabled": True,
|
|
586
|
+
"description": "",
|
|
587
|
+
"managed_marker": env.get(PERSONAL_SCHEDULE_MANAGED_ENV) == "1",
|
|
588
|
+
"script_exists": exists,
|
|
589
|
+
"script_within_scripts_dir": in_scripts_dir,
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
return results
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def audit_personal_schedules() -> dict:
|
|
596
|
+
"""Return semantic schedule audit for personal LaunchAgents.
|
|
597
|
+
|
|
598
|
+
Only schedules created/repaired through the official flow count as managed.
|
|
599
|
+
Manual plists are discovered for visibility and repair, but never blessed.
|
|
600
|
+
"""
|
|
601
|
+
classification = classify_scripts_dir()
|
|
602
|
+
personal_scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
|
|
603
|
+
scripts_by_path = {
|
|
604
|
+
str(Path(entry["path"]).expanduser().resolve(strict=False)): entry
|
|
605
|
+
for entry in personal_scripts
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
audited: list[dict] = []
|
|
609
|
+
summary = {
|
|
610
|
+
"declared_managed": 0,
|
|
611
|
+
"discovered_manual": 0,
|
|
612
|
+
"orphan_schedule": 0,
|
|
613
|
+
"healthy": 0,
|
|
614
|
+
"problems": 0,
|
|
615
|
+
"managed_registered": 0,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
for record in _discover_personal_schedule_records():
|
|
619
|
+
script_path = record.get("script_path", "")
|
|
620
|
+
resolved_path = str(Path(script_path).expanduser().resolve(strict=False)) if script_path else ""
|
|
621
|
+
script = scripts_by_path.get(resolved_path)
|
|
622
|
+
declared = script.get("declared_schedule", {}) if script else {}
|
|
623
|
+
declared_valid = bool(script and declared.get("required") and declared.get("valid"))
|
|
624
|
+
matches = declared_valid and _schedule_matches(record, declared)
|
|
625
|
+
|
|
626
|
+
if record.get("managed_marker") and declared_valid:
|
|
627
|
+
schedule_origin = "declared_managed"
|
|
628
|
+
elif declared_valid:
|
|
629
|
+
schedule_origin = "discovered_manual"
|
|
630
|
+
else:
|
|
631
|
+
schedule_origin = "orphan_schedule"
|
|
632
|
+
|
|
633
|
+
problems: list[str] = []
|
|
634
|
+
if not record.get("script_within_scripts_dir"):
|
|
635
|
+
problems.append("schedule points outside NEXO_HOME/scripts")
|
|
636
|
+
elif not record.get("script_path"):
|
|
637
|
+
problems.append("schedule does not resolve a script path")
|
|
638
|
+
elif not record.get("script_exists"):
|
|
639
|
+
problems.append(f"scheduled script missing: {record['script_path']}")
|
|
640
|
+
elif not script:
|
|
641
|
+
problems.append("schedule points to a script that is not a registered personal script")
|
|
642
|
+
|
|
643
|
+
if script and not declared.get("required"):
|
|
644
|
+
problems.append("personal schedule exists without declared inline metadata")
|
|
645
|
+
elif script and declared.get("required") and not declared.get("valid"):
|
|
646
|
+
problems.append(declared.get("error", "invalid declared schedule metadata"))
|
|
647
|
+
elif declared_valid and not matches:
|
|
648
|
+
problems.append(
|
|
649
|
+
f"schedule drift: actual {record.get('schedule_label') or record.get('schedule_value') or record.get('schedule_type')} "
|
|
650
|
+
f"!= declared {declared.get('schedule_label') or declared.get('cron_id')}"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if declared_valid and not record.get("managed_marker"):
|
|
654
|
+
problems.append("schedule was discovered manually and must be recreated via nexo scripts reconcile")
|
|
655
|
+
|
|
656
|
+
schedule_managed = bool(schedule_origin == "declared_managed" and matches and not problems)
|
|
657
|
+
if schedule_managed:
|
|
658
|
+
schedule_state = "healthy"
|
|
659
|
+
elif schedule_origin == "declared_managed":
|
|
660
|
+
schedule_state = "drifted"
|
|
661
|
+
elif schedule_origin == "discovered_manual" and matches:
|
|
662
|
+
schedule_state = "manual_matching_declared"
|
|
663
|
+
elif schedule_origin == "discovered_manual":
|
|
664
|
+
schedule_state = "manual_drift"
|
|
665
|
+
else:
|
|
666
|
+
schedule_state = "orphaned"
|
|
667
|
+
|
|
668
|
+
audited_record = dict(record)
|
|
669
|
+
audited_record.update({
|
|
670
|
+
"schedule_origin": schedule_origin,
|
|
671
|
+
"schedule_declared": declared_valid,
|
|
672
|
+
"schedule_managed": schedule_managed,
|
|
673
|
+
"schedule_matches_declared": matches,
|
|
674
|
+
"schedule_state": schedule_state,
|
|
675
|
+
"problems": problems,
|
|
676
|
+
"script_name": script.get("name", "") if script else "",
|
|
677
|
+
"declared_schedule": declared if script else {},
|
|
678
|
+
})
|
|
679
|
+
audited.append(audited_record)
|
|
680
|
+
summary[schedule_origin] += 1
|
|
681
|
+
if schedule_managed:
|
|
682
|
+
summary["healthy"] += 1
|
|
683
|
+
summary["managed_registered"] += 1
|
|
684
|
+
else:
|
|
685
|
+
summary["problems"] += 1
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
"schedules": audited,
|
|
689
|
+
"summary": summary,
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def discover_personal_schedules() -> list[dict]:
|
|
694
|
+
"""Return only healthy managed personal schedules."""
|
|
695
|
+
managed: list[dict] = []
|
|
696
|
+
for record in audit_personal_schedules()["schedules"]:
|
|
697
|
+
if record.get("schedule_managed"):
|
|
698
|
+
managed.append({
|
|
699
|
+
"cron_id": record["cron_id"],
|
|
700
|
+
"script_path": record["script_path"],
|
|
701
|
+
"schedule_type": record["schedule_type"],
|
|
702
|
+
"schedule_value": record["schedule_value"],
|
|
703
|
+
"schedule_label": record["schedule_label"],
|
|
704
|
+
"launchd_label": record["launchd_label"],
|
|
705
|
+
"plist_path": record["plist_path"],
|
|
706
|
+
"enabled": record.get("enabled", True),
|
|
707
|
+
"description": record.get("description", ""),
|
|
708
|
+
})
|
|
709
|
+
return managed
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def sync_personal_scripts(prune_missing: bool = True) -> dict:
|
|
713
|
+
"""Sync filesystem + scheduler state into the DB-backed personal scripts registry."""
|
|
714
|
+
from db import init_db, sync_personal_scripts_registry
|
|
715
|
+
|
|
716
|
+
init_db()
|
|
717
|
+
classification = classify_scripts_dir()
|
|
718
|
+
scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
|
|
719
|
+
schedule_audit = audit_personal_schedules()
|
|
720
|
+
schedules = [record for record in schedule_audit["schedules"] if record.get("schedule_managed")]
|
|
721
|
+
result = sync_personal_scripts_registry(scripts, schedules, prune_missing=prune_missing)
|
|
722
|
+
result["classification"] = classification["summary"]
|
|
723
|
+
missing_declared = []
|
|
724
|
+
managed_by_path: dict[str, list[dict]] = {}
|
|
725
|
+
for schedule in schedules:
|
|
726
|
+
managed_by_path.setdefault(schedule["script_path"], []).append(schedule)
|
|
727
|
+
schedules_by_path: dict[str, list[dict]] = {}
|
|
728
|
+
for schedule in schedule_audit["schedules"]:
|
|
729
|
+
schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
|
|
730
|
+
for script in scripts:
|
|
731
|
+
declared = script.get("declared_schedule", {})
|
|
732
|
+
if not declared.get("required"):
|
|
733
|
+
continue
|
|
734
|
+
healthy = managed_by_path.get(script["path"], [])
|
|
735
|
+
if healthy:
|
|
736
|
+
continue
|
|
737
|
+
attached = schedules_by_path.get(script["path"], [])
|
|
738
|
+
if not attached:
|
|
739
|
+
missing_declared.append({
|
|
740
|
+
"name": script["name"],
|
|
741
|
+
"path": script["path"],
|
|
742
|
+
"declared_schedule": declared,
|
|
743
|
+
"reason": "no schedule discovered",
|
|
744
|
+
})
|
|
745
|
+
continue
|
|
746
|
+
attached_states = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in attached]
|
|
747
|
+
missing_declared.append({
|
|
748
|
+
"name": script["name"],
|
|
749
|
+
"path": script["path"],
|
|
750
|
+
"declared_schedule": declared,
|
|
751
|
+
"reason": f"schedule discovered but not managed ({', '.join(attached_states)})",
|
|
752
|
+
})
|
|
753
|
+
result["schedule_audit"] = schedule_audit
|
|
754
|
+
result["missing_declared_schedules"] = missing_declared
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _schedule_matches(existing: dict, declared: dict) -> bool:
|
|
759
|
+
if not existing or not declared.get("valid"):
|
|
760
|
+
return False
|
|
761
|
+
if existing.get("cron_id") != declared.get("cron_id"):
|
|
762
|
+
return False
|
|
763
|
+
if existing.get("schedule_type") != declared.get("schedule_type"):
|
|
764
|
+
return False
|
|
765
|
+
existing_value = _canonical_schedule_value(existing.get("schedule_type", ""), existing.get("schedule_value", ""))
|
|
766
|
+
declared_value = _canonical_schedule_value(declared.get("schedule_type", ""), declared.get("schedule_value", ""))
|
|
767
|
+
if existing_value != declared_value:
|
|
768
|
+
return False
|
|
769
|
+
return True
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _remove_schedule_file(*, cron_id: str, plist_path: str) -> dict:
|
|
773
|
+
removed = {
|
|
774
|
+
"cron_id": cron_id,
|
|
775
|
+
"plist_path": plist_path,
|
|
776
|
+
"deleted": False,
|
|
777
|
+
}
|
|
778
|
+
plist = Path(plist_path) if plist_path else None
|
|
779
|
+
if plist and platform.system() == "Darwin" and plist.exists():
|
|
780
|
+
subprocess.run(
|
|
781
|
+
["launchctl", "bootout", f"gui/{os.getuid()}", str(plist)],
|
|
782
|
+
capture_output=True,
|
|
783
|
+
)
|
|
784
|
+
with contextlib.suppress(FileNotFoundError):
|
|
785
|
+
plist.unlink()
|
|
786
|
+
removed["deleted"] = True
|
|
787
|
+
return removed
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
|
|
791
|
+
"""Create or repair personal schedules declared in inline script metadata."""
|
|
792
|
+
classification = classify_scripts_dir()
|
|
793
|
+
scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
|
|
794
|
+
schedule_audit = audit_personal_schedules()
|
|
795
|
+
schedules_by_path: dict[str, list[dict]] = {}
|
|
796
|
+
for schedule in schedule_audit["schedules"]:
|
|
797
|
+
schedules_by_path.setdefault(schedule["script_path"], []).append(schedule)
|
|
798
|
+
|
|
799
|
+
report = {
|
|
800
|
+
"ok": True,
|
|
801
|
+
"dry_run": dry_run,
|
|
802
|
+
"created": [],
|
|
803
|
+
"repaired": [],
|
|
804
|
+
"already_present": [],
|
|
805
|
+
"skipped": [],
|
|
806
|
+
"invalid": [],
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
for script in scripts:
|
|
810
|
+
declared = script.get("declared_schedule", {})
|
|
811
|
+
if not declared.get("required"):
|
|
812
|
+
report["skipped"].append({
|
|
813
|
+
"name": script["name"],
|
|
814
|
+
"reason": "no declared schedule",
|
|
815
|
+
})
|
|
816
|
+
continue
|
|
817
|
+
if not declared.get("valid"):
|
|
818
|
+
report["invalid"].append({
|
|
819
|
+
"name": script["name"],
|
|
820
|
+
"path": script["path"],
|
|
821
|
+
"error": declared.get("error", "invalid schedule metadata"),
|
|
822
|
+
})
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
existing = schedules_by_path.get(script["path"], [])
|
|
826
|
+
matching = next((item for item in existing if item.get("schedule_managed") and _schedule_matches(item, declared)), None)
|
|
827
|
+
if matching:
|
|
828
|
+
report["already_present"].append({
|
|
829
|
+
"name": script["name"],
|
|
830
|
+
"cron_id": matching["cron_id"],
|
|
831
|
+
"schedule_label": matching.get("schedule_label", ""),
|
|
832
|
+
})
|
|
833
|
+
continue
|
|
834
|
+
|
|
835
|
+
repair_reasons = [item.get("schedule_state", item.get("schedule_origin", "unknown")) for item in existing]
|
|
836
|
+
if dry_run:
|
|
837
|
+
report["repaired" if existing else "created"].append({
|
|
838
|
+
"name": script["name"],
|
|
839
|
+
"cron_id": declared["cron_id"],
|
|
840
|
+
"schedule_label": declared["schedule_label"],
|
|
841
|
+
"dry_run": True,
|
|
842
|
+
"reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
|
|
843
|
+
})
|
|
844
|
+
continue
|
|
845
|
+
|
|
846
|
+
removed = []
|
|
847
|
+
if existing:
|
|
848
|
+
for item in existing:
|
|
849
|
+
removed.append(_remove_schedule_file(cron_id=item["cron_id"], plist_path=item.get("plist_path", "")))
|
|
850
|
+
from db import delete_personal_script_schedule
|
|
851
|
+
|
|
852
|
+
for item in existing:
|
|
853
|
+
delete_personal_script_schedule(item["cron_id"])
|
|
854
|
+
|
|
855
|
+
from plugins.schedule import handle_schedule_add
|
|
856
|
+
|
|
857
|
+
response = handle_schedule_add(
|
|
858
|
+
cron_id=declared["cron_id"],
|
|
859
|
+
script=script["path"],
|
|
860
|
+
schedule=declared.get("schedule", ""),
|
|
861
|
+
interval_seconds=declared.get("interval_seconds", 0),
|
|
862
|
+
description=script.get("description", ""),
|
|
863
|
+
script_type=script.get("runtime", "auto"),
|
|
864
|
+
)
|
|
865
|
+
target = report["repaired" if existing else "created"]
|
|
866
|
+
target.append({
|
|
867
|
+
"name": script["name"],
|
|
868
|
+
"cron_id": declared["cron_id"],
|
|
869
|
+
"schedule_label": declared["schedule_label"],
|
|
870
|
+
"reason": ", ".join(repair_reasons) if repair_reasons else "missing schedule",
|
|
871
|
+
"removed": removed,
|
|
872
|
+
"result": response,
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
sync_result = sync_personal_scripts()
|
|
876
|
+
report["sync"] = sync_result
|
|
877
|
+
report["classification"] = classification["summary"]
|
|
878
|
+
return report
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def reconcile_personal_scripts(*, dry_run: bool = False) -> dict:
|
|
882
|
+
"""Full lifecycle reconciliation: classify, sync registry, ensure declared schedules."""
|
|
883
|
+
sync_result = sync_personal_scripts()
|
|
884
|
+
ensure_result = ensure_personal_schedules(dry_run=dry_run)
|
|
885
|
+
return {
|
|
886
|
+
"ok": True,
|
|
887
|
+
"dry_run": dry_run,
|
|
888
|
+
"sync": sync_result,
|
|
889
|
+
"ensure_schedules": ensure_result,
|
|
890
|
+
"classification": ensure_result.get("classification", sync_result.get("classification", {})),
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _template_path(filename: str) -> Path | None:
|
|
895
|
+
candidates = [
|
|
896
|
+
NEXO_HOME / "templates" / filename,
|
|
897
|
+
NEXO_CODE.parent / "templates" / filename,
|
|
898
|
+
NEXO_CODE / "templates" / filename,
|
|
899
|
+
]
|
|
900
|
+
for candidate in candidates:
|
|
901
|
+
if candidate.is_file():
|
|
902
|
+
return candidate
|
|
903
|
+
return None
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _script_filename_from_name(name: str, runtime: str) -> str:
|
|
907
|
+
slug = []
|
|
908
|
+
for ch in name.strip().lower():
|
|
909
|
+
if ch.isalnum():
|
|
910
|
+
slug.append(ch)
|
|
911
|
+
elif ch in {" ", "-", "_"}:
|
|
912
|
+
slug.append("-")
|
|
913
|
+
stem = "".join(slug).strip("-") or "personal-script"
|
|
914
|
+
ext = {
|
|
915
|
+
"python": ".py",
|
|
916
|
+
"shell": ".sh",
|
|
917
|
+
"node": ".js",
|
|
918
|
+
"php": ".php",
|
|
919
|
+
}.get(runtime, ".py")
|
|
920
|
+
return stem + ext
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def create_script(name: str, *, description: str = "", runtime: str = "python", force: bool = False) -> dict:
|
|
924
|
+
runtime = runtime if runtime in SUPPORTED_RUNTIMES else "python"
|
|
925
|
+
if runtime == "unknown":
|
|
926
|
+
runtime = "python"
|
|
927
|
+
|
|
928
|
+
scripts_dir = get_scripts_dir()
|
|
929
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
930
|
+
filename = _script_filename_from_name(name, runtime)
|
|
931
|
+
path = scripts_dir / filename
|
|
932
|
+
if path.exists() and not force:
|
|
933
|
+
raise FileExistsError(f"Script already exists: {path}")
|
|
934
|
+
|
|
935
|
+
if runtime == "shell":
|
|
936
|
+
template_path = _template_path("script-template.sh")
|
|
937
|
+
else:
|
|
938
|
+
template_path = _template_path("script-template.py")
|
|
939
|
+
|
|
940
|
+
if template_path:
|
|
941
|
+
content = template_path.read_text()
|
|
942
|
+
elif runtime == "shell":
|
|
943
|
+
content = (
|
|
944
|
+
"#!/usr/bin/env bash\n"
|
|
945
|
+
"# nexo: name=example-script\n"
|
|
946
|
+
"# nexo: description=Example shell script using NEXO\n"
|
|
947
|
+
"# nexo: runtime=shell\n"
|
|
948
|
+
"set -euo pipefail\n"
|
|
949
|
+
"echo \"Hello from NEXO personal script\"\n"
|
|
950
|
+
)
|
|
951
|
+
else:
|
|
952
|
+
content = (
|
|
953
|
+
"#!/usr/bin/env python3\n"
|
|
954
|
+
"# nexo: name=example-script\n"
|
|
955
|
+
"# nexo: description=Example personal script using NEXO\n"
|
|
956
|
+
"# nexo: runtime=python\n"
|
|
957
|
+
"print('hello')\n"
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
script_name = Path(filename).stem
|
|
961
|
+
content = content.replace("example-script", script_name)
|
|
962
|
+
content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {script_name}")
|
|
963
|
+
content = content.replace("Example shell script using NEXO", description or f"Personal script: {script_name}")
|
|
964
|
+
|
|
965
|
+
path.write_text(content)
|
|
966
|
+
if runtime in {"shell", "python"}:
|
|
967
|
+
path.chmod(0o755)
|
|
968
|
+
sync_result = sync_personal_scripts()
|
|
969
|
+
return {
|
|
970
|
+
"ok": True,
|
|
971
|
+
"name": script_name,
|
|
972
|
+
"path": str(path),
|
|
973
|
+
"runtime": runtime,
|
|
974
|
+
"description": description,
|
|
975
|
+
"sync": sync_result,
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def unschedule_personal_script(name_or_path: str) -> dict:
|
|
980
|
+
"""Remove all personal schedules attached to a script and prune registry entries."""
|
|
981
|
+
from db import (
|
|
982
|
+
init_db,
|
|
983
|
+
get_personal_script,
|
|
984
|
+
delete_personal_script_schedule,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
init_db()
|
|
988
|
+
sync_personal_scripts()
|
|
989
|
+
script = get_personal_script(name_or_path)
|
|
990
|
+
if not script:
|
|
991
|
+
resolved = resolve_script(name_or_path)
|
|
992
|
+
if not resolved or resolved.get("core"):
|
|
993
|
+
return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
|
|
994
|
+
script = resolved
|
|
995
|
+
|
|
996
|
+
removed: list[dict] = []
|
|
997
|
+
audited = audit_personal_schedules()
|
|
998
|
+
discovered = [
|
|
999
|
+
item for item in audited["schedules"]
|
|
1000
|
+
if item.get("script_path") == script.get("path")
|
|
1001
|
+
]
|
|
1002
|
+
for schedule in discovered:
|
|
1003
|
+
removed.append(_remove_schedule_file(cron_id=schedule["cron_id"], plist_path=schedule.get("plist_path", "")))
|
|
1004
|
+
|
|
1005
|
+
for schedule in script.get("schedules", []):
|
|
1006
|
+
delete_personal_script_schedule(schedule["cron_id"])
|
|
1007
|
+
if not any(item["cron_id"] == schedule["cron_id"] for item in removed):
|
|
1008
|
+
removed.append({
|
|
1009
|
+
"cron_id": schedule["cron_id"],
|
|
1010
|
+
"plist_path": schedule.get("plist_path", ""),
|
|
1011
|
+
"deleted": False,
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
sync_result = sync_personal_scripts()
|
|
1015
|
+
return {
|
|
1016
|
+
"ok": True,
|
|
1017
|
+
"script": script["name"],
|
|
1018
|
+
"removed_schedules": removed,
|
|
1019
|
+
"sync": sync_result,
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def remove_personal_script(name_or_path: str, *, keep_file: bool = False) -> dict:
|
|
1024
|
+
"""Remove a personal script from the runtime and registry."""
|
|
1025
|
+
from db import init_db, get_personal_script, delete_personal_script
|
|
1026
|
+
|
|
1027
|
+
init_db()
|
|
1028
|
+
sync_personal_scripts()
|
|
1029
|
+
script = get_personal_script(name_or_path)
|
|
1030
|
+
if not script:
|
|
1031
|
+
resolved = resolve_script(name_or_path)
|
|
1032
|
+
if not resolved or resolved.get("core"):
|
|
1033
|
+
return {"ok": False, "error": f"Personal script not found: {name_or_path}"}
|
|
1034
|
+
script = resolved
|
|
1035
|
+
|
|
1036
|
+
if script.get("core"):
|
|
1037
|
+
return {"ok": False, "error": "Refusing to remove a core script via personal scripts lifecycle."}
|
|
1038
|
+
|
|
1039
|
+
unschedule_result = unschedule_personal_script(script["path"])
|
|
1040
|
+
deleted_file = False
|
|
1041
|
+
path = Path(script["path"])
|
|
1042
|
+
if not keep_file and path.is_file() and _within_scripts_dir(path):
|
|
1043
|
+
path.unlink()
|
|
1044
|
+
deleted_file = True
|
|
1045
|
+
delete_personal_script(script["path"])
|
|
1046
|
+
sync_result = sync_personal_scripts()
|
|
1047
|
+
return {
|
|
1048
|
+
"ok": True,
|
|
1049
|
+
"script": script["name"],
|
|
1050
|
+
"path": script["path"],
|
|
1051
|
+
"deleted_file": deleted_file,
|
|
1052
|
+
"keep_file": keep_file,
|
|
1053
|
+
"unschedule": unschedule_result,
|
|
1054
|
+
"sync": sync_result,
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
|
|
221
1058
|
def doctor_script(path_or_name: str) -> dict:
|
|
222
1059
|
"""Validate a single script. Returns dict with pass/warn/fail items."""
|
|
223
1060
|
# Resolve
|
|
@@ -280,6 +1117,18 @@ def doctor_script(path_or_name: str) -> dict:
|
|
|
280
1117
|
except ValueError:
|
|
281
1118
|
items.append({"level": "fail", "msg": f"Invalid timeout value: {timeout_str}"})
|
|
282
1119
|
|
|
1120
|
+
declared = get_declared_schedule(meta, name)
|
|
1121
|
+
if declared.get("required"):
|
|
1122
|
+
if declared.get("valid"):
|
|
1123
|
+
items.append({"level": "pass", "msg": f"Declared schedule: {declared['schedule_label']}"})
|
|
1124
|
+
else:
|
|
1125
|
+
items.append({"level": "fail", "msg": declared.get("error", "Invalid declared schedule metadata")})
|
|
1126
|
+
|
|
1127
|
+
if runtime == "node" and not shutil.which("node"):
|
|
1128
|
+
items.append({"level": "fail", "msg": "Node runtime not found in PATH"})
|
|
1129
|
+
if runtime == "php" and not shutil.which("php"):
|
|
1130
|
+
items.append({"level": "fail", "msg": "PHP runtime not found in PATH"})
|
|
1131
|
+
|
|
283
1132
|
# Requires check
|
|
284
1133
|
requires = meta.get("requires", "")
|
|
285
1134
|
if requires:
|