@topy-ai/maggie 0.7.0 → 0.7.2
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/README.md +35 -9
- package/bin/maggie.js +6 -6
- package/bundled-references/maggiedash-dashboard-ui.md +28 -0
- package/bundled-skills/maggie-blog/SKILL.md +12 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +9 -0
- package/bundled-skills/maggie-dash/SKILL.md +45 -2
- package/bundled-skills/maggie-deployment/SKILL.md +17 -0
- package/bundled-skills/maggie-ops/SKILL.md +27 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +21 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +6 -0
- package/bundled-templates/maggiedash/README.md +15 -0
- package/bundled-templates/maggiedash/dashboard-ui-contract.json +31 -0
- package/bundled-tools/clis/maggie.py +29 -4
- package/bundled-tools/clis/maggie_analytics.py +14 -1
- package/bundled-tools/clis/maggie_blog.py +6 -0
- package/bundled-tools/clis/maggie_dash.py +179 -0
- package/bundled-tools/clis/maggie_deployment.py +43 -0
- package/bundled-tools/clis/maggie_feedback.py +16 -1
- package/bundled-tools/clis/maggie_ops.py +21 -1
- package/bundled-tools/clis/maggie_service_booking.py +6 -3
- package/bundled-tools/clis/maggie_sitemap.py +16 -2
- package/bundled-tools/runtime/analytics_traffic.py +30 -0
- package/bundled-tools/runtime/content_localization.py +63 -1
- package/bundled-tools/runtime/dependency_lock.py +43 -0
- package/bundled-tools/runtime/integration_state.py +17 -0
- package/bundled-tools/runtime/maggie_dash_store.py +150 -6
- package/bundled-tools/runtime/maggie_dash_ui.py +60 -0
- package/bundled-tools/runtime/maggie_sitemap.py +50 -4
- package/bundled-tools/runtime/route_imports.py +51 -0
- package/bundled-tools/runtime/seed_evidence.py +25 -0
- package/package.json +1 -1
- package/references/maggiedash-dashboard-ui.md +28 -0
|
@@ -11,12 +11,18 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
13
|
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
14
17
|
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from datetime import datetime, timezone
|
|
15
20
|
from pathlib import Path
|
|
16
21
|
|
|
17
22
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
18
23
|
from maggie_dash_store import MaggieDashStore # noqa: E402
|
|
19
24
|
from service_variants import ServiceVariantStore # noqa: E402
|
|
25
|
+
from maggie_dash_ui import load_and_validate # noqa: E402
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
def project_root(args: argparse.Namespace) -> Path:
|
|
@@ -37,6 +43,118 @@ def require_confirm(args: argparse.Namespace) -> None:
|
|
|
37
43
|
raise SystemExit(2)
|
|
38
44
|
|
|
39
45
|
|
|
46
|
+
DEFAULT_DASH_SOURCE = "https://github.com/TOPY-AI-LTD/MaggieDash.git"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def utc_now() -> str:
|
|
50
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def git_revision(path: Path) -> str | None:
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True)
|
|
56
|
+
except (OSError, subprocess.CalledProcessError):
|
|
57
|
+
return None
|
|
58
|
+
return result.stdout.strip() or None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def copy_dashboard_tree(source: Path, target: Path, force: bool) -> tuple[list[str], list[str]]:
|
|
62
|
+
installed: list[str] = []
|
|
63
|
+
preserved: list[str] = []
|
|
64
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
for path in sorted(source.rglob("*")):
|
|
66
|
+
relative = path.relative_to(source)
|
|
67
|
+
destination = target / relative
|
|
68
|
+
if path.is_dir():
|
|
69
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
continue
|
|
71
|
+
if destination.exists() and not force:
|
|
72
|
+
preserved.append(str(destination))
|
|
73
|
+
continue
|
|
74
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
shutil.copy2(path, destination)
|
|
76
|
+
installed.append(str(destination))
|
|
77
|
+
return installed, preserved
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def command_install(args: argparse.Namespace) -> int:
|
|
81
|
+
require_confirm(args)
|
|
82
|
+
root = project_root(args)
|
|
83
|
+
source_value = args.source or os.environ.get("MAGGIE_DASH_SOURCE") or DEFAULT_DASH_SOURCE
|
|
84
|
+
ref = args.ref
|
|
85
|
+
target = root / args.target
|
|
86
|
+
temporary: tempfile.TemporaryDirectory[str] | None = None
|
|
87
|
+
source = Path(source_value).expanduser().resolve() if not source_value.startswith(("https://", "http://", "git@")) else None
|
|
88
|
+
try:
|
|
89
|
+
if source is None:
|
|
90
|
+
temporary = tempfile.TemporaryDirectory(prefix="maggiedash-install-")
|
|
91
|
+
clone = ["git", "clone", "--depth", "1"]
|
|
92
|
+
if ref:
|
|
93
|
+
clone.extend(["--branch", ref])
|
|
94
|
+
clone.extend([source_value, temporary.name])
|
|
95
|
+
result = subprocess.run(clone, capture_output=True, text=True)
|
|
96
|
+
if result.returncode:
|
|
97
|
+
detail = (result.stderr or result.stdout).strip().splitlines()[-1:]
|
|
98
|
+
raise RuntimeError("MaggieDash source could not be cloned" + (f": {detail[0]}" if detail else ""))
|
|
99
|
+
source = Path(temporary.name)
|
|
100
|
+
if not source.is_dir():
|
|
101
|
+
raise RuntimeError(f"MaggieDash source directory not found: {source}")
|
|
102
|
+
manifest_path = source / "maggiedash.json"
|
|
103
|
+
if not manifest_path.exists():
|
|
104
|
+
raise RuntimeError("MaggieDash source has no maggiedash.json manifest")
|
|
105
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
106
|
+
if manifest.get("schemaVersion") != "maggiedash-distribution.v1":
|
|
107
|
+
raise RuntimeError("unsupported MaggieDash distribution schema")
|
|
108
|
+
if manifest.get("target") != "_maggie/admin" and args.target == "_maggie/admin":
|
|
109
|
+
raise RuntimeError("MaggieDash manifest target is not ./_maggie/admin")
|
|
110
|
+
installed: list[str] = []
|
|
111
|
+
preserved: list[str] = []
|
|
112
|
+
for item in manifest.get("files", []):
|
|
113
|
+
relative = Path(item)
|
|
114
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
115
|
+
raise RuntimeError(f"unsafe MaggieDash manifest path: {item}")
|
|
116
|
+
source_item = source / relative
|
|
117
|
+
if not source_item.exists():
|
|
118
|
+
raise RuntimeError(f"MaggieDash manifest file is missing: {item}")
|
|
119
|
+
if relative.parts and relative.parts[0] == "admin":
|
|
120
|
+
destination = target / Path(*relative.parts[1:])
|
|
121
|
+
else:
|
|
122
|
+
destination = target / relative
|
|
123
|
+
if source_item.is_dir():
|
|
124
|
+
current_installed, current_preserved = copy_dashboard_tree(source_item, destination, args.force)
|
|
125
|
+
else:
|
|
126
|
+
current_installed = []
|
|
127
|
+
current_preserved = []
|
|
128
|
+
if destination.exists() and not args.force:
|
|
129
|
+
current_preserved = [str(destination)]
|
|
130
|
+
else:
|
|
131
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
shutil.copy2(source_item, destination)
|
|
133
|
+
current_installed = [str(destination)]
|
|
134
|
+
installed.extend(current_installed)
|
|
135
|
+
preserved.extend(current_preserved)
|
|
136
|
+
state = {
|
|
137
|
+
"schemaVersion": "maggiedash-install.v1",
|
|
138
|
+
"status": "installed",
|
|
139
|
+
"source": source_value,
|
|
140
|
+
"ref": ref,
|
|
141
|
+
"revision": git_revision(source),
|
|
142
|
+
"version": manifest.get("version"),
|
|
143
|
+
"target": str(target),
|
|
144
|
+
"installed": sorted(set(installed)),
|
|
145
|
+
"preserved": sorted(set(preserved)),
|
|
146
|
+
"updatedAt": utc_now(),
|
|
147
|
+
}
|
|
148
|
+
state_path = root / ".maggie" / "dash-install.json"
|
|
149
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
151
|
+
emit({"status": "installed", "version": manifest.get("version"), "target": str(target), "installed": len(set(installed)), "preserved": len(set(preserved)), "state": str(state_path)})
|
|
152
|
+
return 0
|
|
153
|
+
finally:
|
|
154
|
+
if temporary is not None:
|
|
155
|
+
temporary.cleanup()
|
|
156
|
+
|
|
157
|
+
|
|
40
158
|
def command_init(args: argparse.Namespace) -> int:
|
|
41
159
|
require_confirm(args)
|
|
42
160
|
root = project_root(args)
|
|
@@ -93,6 +211,38 @@ def command_transition(args: argparse.Namespace) -> int:
|
|
|
93
211
|
store.close()
|
|
94
212
|
|
|
95
213
|
|
|
214
|
+
def command_cms(args: argparse.Namespace) -> int:
|
|
215
|
+
require_confirm(args)
|
|
216
|
+
store = store_for(project_root(args))
|
|
217
|
+
try:
|
|
218
|
+
if args.cms_command == "revisions":
|
|
219
|
+
result = store.list_revisions(args.project_id, args.document_id)
|
|
220
|
+
elif args.cms_command == "trash":
|
|
221
|
+
result = store.trash(args.project_id, args.document_id, args.actor, args.reason)
|
|
222
|
+
elif args.cms_command == "restore":
|
|
223
|
+
result = store.restore(args.project_id, args.document_id, args.actor, args.reason)
|
|
224
|
+
elif args.cms_command == "schedule":
|
|
225
|
+
result = store.schedule_publish(args.project_id, args.document_id, args.publish_at, args.actor, args.reason)
|
|
226
|
+
elif args.cms_command == "duplicate":
|
|
227
|
+
result = store.duplicate(args.project_id, args.document_id, args.new_id, args.new_slug, args.actor, args.reason)
|
|
228
|
+
elif args.cms_command == "redirect":
|
|
229
|
+
result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
|
|
230
|
+
elif args.cms_command == "preview":
|
|
231
|
+
result = store.issue_preview(args.project_id, args.document_id, args.secret, args.ttl)
|
|
232
|
+
else:
|
|
233
|
+
raise ValueError(f"unknown CMS command: {args.cms_command}")
|
|
234
|
+
emit(result)
|
|
235
|
+
return 0
|
|
236
|
+
finally:
|
|
237
|
+
store.close()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def command_ui(args: argparse.Namespace) -> int:
|
|
241
|
+
result = load_and_validate(Path(args.contract).resolve(), [Path(value).resolve() for value in args.source])
|
|
242
|
+
emit(result)
|
|
243
|
+
return 0 if result["passed"] else 1
|
|
244
|
+
|
|
245
|
+
|
|
96
246
|
def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
|
|
97
247
|
return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
|
|
98
248
|
|
|
@@ -138,6 +288,14 @@ def parser() -> argparse.ArgumentParser:
|
|
|
138
288
|
init.add_argument("--locale", default="en-GB")
|
|
139
289
|
init.add_argument("--confirm", action="store_true")
|
|
140
290
|
init.set_defaults(func=command_init)
|
|
291
|
+
install = sub.add_parser("install", help="install the MaggieDash admin workspace")
|
|
292
|
+
install.add_argument("--project", default=".")
|
|
293
|
+
install.add_argument("--source", help="local checkout or Git URL; defaults to MaggieDash repository")
|
|
294
|
+
install.add_argument("--ref", default="main", help="Git ref when installing from a remote source")
|
|
295
|
+
install.add_argument("--target", default="_maggie/admin", help="installation target relative to the project")
|
|
296
|
+
install.add_argument("--force", action="store_true", help="replace existing dashboard files")
|
|
297
|
+
install.add_argument("--confirm", action="store_true")
|
|
298
|
+
install.set_defaults(func=command_install)
|
|
141
299
|
status = sub.add_parser("status", help="inspect project and documents")
|
|
142
300
|
status.add_argument("--project", default=".")
|
|
143
301
|
status.add_argument("--project-id", default="local-project")
|
|
@@ -155,6 +313,27 @@ def parser() -> argparse.ArgumentParser:
|
|
|
155
313
|
transition.add_argument("--reason", required=True)
|
|
156
314
|
transition.add_argument("--confirm", action="store_true")
|
|
157
315
|
transition.set_defaults(func=command_transition)
|
|
316
|
+
cms = sub.add_parser("cms", help="manage revisions, trash, scheduling, redirects and previews")
|
|
317
|
+
cms_sub = cms.add_subparsers(dest="cms_command", required=True)
|
|
318
|
+
for name in ("revisions", "trash", "restore"):
|
|
319
|
+
command = cms_sub.add_parser(name)
|
|
320
|
+
command.add_argument("--project", default="."); command.add_argument("--project-id", default="local-project")
|
|
321
|
+
command.add_argument("--document-id", required=True); command.add_argument("--actor", default="cli"); command.add_argument("--reason", default="CMS operation"); command.add_argument("--confirm", action="store_true")
|
|
322
|
+
schedule = cms_sub.add_parser("schedule")
|
|
323
|
+
schedule.add_argument("--project", default="."); schedule.add_argument("--project-id", default="local-project"); schedule.add_argument("--document-id", required=True); schedule.add_argument("--publish-at", required=True); schedule.add_argument("--actor", default="cli"); schedule.add_argument("--reason", required=True); schedule.add_argument("--confirm", action="store_true")
|
|
324
|
+
duplicate = cms_sub.add_parser("duplicate")
|
|
325
|
+
duplicate.add_argument("--project", default="."); duplicate.add_argument("--project-id", default="local-project"); duplicate.add_argument("--document-id", required=True); duplicate.add_argument("--new-id", required=True); duplicate.add_argument("--new-slug", required=True); duplicate.add_argument("--actor", default="cli"); duplicate.add_argument("--reason", required=True); duplicate.add_argument("--confirm", action="store_true")
|
|
326
|
+
redirect = cms_sub.add_parser("redirect")
|
|
327
|
+
redirect.add_argument("--project", default="."); redirect.add_argument("--project-id", default="local-project"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
|
|
328
|
+
preview = cms_sub.add_parser("preview")
|
|
329
|
+
preview.add_argument("--project", default="."); preview.add_argument("--project-id", default="local-project"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
|
|
330
|
+
cms.set_defaults(func=command_cms)
|
|
331
|
+
ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
|
|
332
|
+
ui_sub = ui.add_subparsers(dest="ui_command", required=True)
|
|
333
|
+
ui_validate = ui_sub.add_parser("validate")
|
|
334
|
+
ui_validate.add_argument("--contract", required=True)
|
|
335
|
+
ui_validate.add_argument("--source", action="append", default=[])
|
|
336
|
+
ui_validate.set_defaults(func=command_ui)
|
|
158
337
|
variant = sub.add_parser("variant", help="manage service variant lifecycle")
|
|
159
338
|
variant_sub = variant.add_subparsers(dest="variant_command", required=True)
|
|
160
339
|
create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
|
+
import subprocess
|
|
8
9
|
from datetime import datetime, timezone
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
@@ -106,6 +107,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
|
|
|
106
107
|
"restart": f"systemctl restart {service}",
|
|
107
108
|
"verify": f"systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
108
109
|
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && systemctl restart {service}",
|
|
110
|
+
"data_rollback": "restore the matching data checkpoint before restarting the previous code release",
|
|
109
111
|
},
|
|
110
112
|
"files": {
|
|
111
113
|
"systemd": f"{service}.service",
|
|
@@ -130,10 +132,36 @@ def validate_data_checkpoint(path: Path) -> dict:
|
|
|
130
132
|
if isinstance(required, list) and isinstance(current, dict):
|
|
131
133
|
missing = [table for table in required if table not in current]
|
|
132
134
|
if missing: errors.append("currentCounts is missing required tables")
|
|
135
|
+
empty = [table for table in required if table in current and (not isinstance(current[table], int) or current[table] <= 0)]
|
|
136
|
+
if empty: errors.append("currentCounts has empty required tables: " + ", ".join(empty))
|
|
133
137
|
if not value.get("release") or not value.get("checkedAt"): errors.append("release and checkedAt are required")
|
|
138
|
+
rollback = value.get("rollback")
|
|
139
|
+
if not isinstance(rollback, dict) or not rollback.get("backupId") or not rollback.get("restoreCommand"):
|
|
140
|
+
errors.append("rollback.backupId and rollback.restoreCommand are required for data rollback")
|
|
134
141
|
return {"passed": not errors, "declared": True, "errors": errors}
|
|
135
142
|
|
|
136
143
|
|
|
144
|
+
def validate_scheduler_request(method: str, origin: str | None, has_auth: bool) -> dict:
|
|
145
|
+
errors = []
|
|
146
|
+
if method.upper() == "POST":
|
|
147
|
+
if not origin or not re.fullmatch(r"https?://[^/\s]+", origin): errors.append("POST scheduler requests require an explicit Origin header")
|
|
148
|
+
if not has_auth: errors.append("POST scheduler requests require the configured auth contract")
|
|
149
|
+
return {"schemaVersion": "maggie-deployment-request.v1", "method": method.upper(), "originPresent": bool(origin), "authPresent": bool(has_auth), "passed": not errors, "errors": errors}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def verify_infrastructure(units_dir: Path | None, service: str, timer: str | None = None) -> dict:
|
|
153
|
+
timer_name = timer or f"maggie-{service}.timer"
|
|
154
|
+
service_name = f"maggie-{service}.service" if not service.endswith(".service") else service
|
|
155
|
+
declared = bool(units_dir and (units_dir / service_name).is_file() and (units_dir / timer_name).is_file())
|
|
156
|
+
systemd = {"service": False, "timer": False}
|
|
157
|
+
for unit, key in ((service_name, "service"), (timer_name, "timer")):
|
|
158
|
+
try:
|
|
159
|
+
systemd[key] = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True, check=False, timeout=5).returncode == 0
|
|
160
|
+
except (OSError, subprocess.SubprocessError):
|
|
161
|
+
systemd[key] = False
|
|
162
|
+
return {"schemaVersion": "maggie-deployment-infra.v1", "service": service_name, "timer": timer_name, "declaredArtifacts": declared, "systemdEnabled": systemd, "passed": declared or all(systemd.values()), "errors": [] if declared or all(systemd.values()) else ["systemd service/timer are not declared or enabled"]}
|
|
163
|
+
|
|
164
|
+
|
|
137
165
|
def retention_plan(release_root: str, current_link: str, keep: int) -> dict:
|
|
138
166
|
if keep < 2 or keep > 5:
|
|
139
167
|
raise ValueError("keep must be between 2 and 5")
|
|
@@ -206,6 +234,13 @@ def main() -> int:
|
|
|
206
234
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
207
235
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
208
236
|
parser.add_argument("--keep-releases", type=int, default=2)
|
|
237
|
+
parser.add_argument("--verify-infra", action="store_true", help="verify declared or installed systemd service/timer")
|
|
238
|
+
parser.add_argument("--units-dir", help="directory containing generated .service and .timer units")
|
|
239
|
+
parser.add_argument("--timer", help="systemd timer unit name")
|
|
240
|
+
parser.add_argument("--validate-request", action="store_true", help="validate a scheduler request contract")
|
|
241
|
+
parser.add_argument("--method", default="POST")
|
|
242
|
+
parser.add_argument("--origin")
|
|
243
|
+
parser.add_argument("--has-auth", action="store_true")
|
|
209
244
|
args = parser.parse_args()
|
|
210
245
|
try:
|
|
211
246
|
if args.vps_plan:
|
|
@@ -226,6 +261,14 @@ def main() -> int:
|
|
|
226
261
|
output.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8")
|
|
227
262
|
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
228
263
|
return 0
|
|
264
|
+
if args.validate_request:
|
|
265
|
+
result = validate_scheduler_request(args.method, args.origin, args.has_auth)
|
|
266
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
267
|
+
return 0 if result["passed"] else 1
|
|
268
|
+
if args.verify_infra:
|
|
269
|
+
result = verify_infrastructure(Path(args.units_dir).resolve() if args.units_dir else None, args.service, args.timer)
|
|
270
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
271
|
+
return 0 if result["passed"] else 1
|
|
229
272
|
result = preflight(Path(args.project).resolve(), args.target, args.environment)
|
|
230
273
|
if args.output:
|
|
231
274
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -34,6 +34,21 @@ def project_fingerprint(project: Path) -> str:
|
|
|
34
34
|
return hashlib.sha256(str(project.resolve()).encode()).hexdigest()[:16]
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def installed_version(project: Path) -> str:
|
|
38
|
+
"""Resolve the version from the runtime wrapper or project install state."""
|
|
39
|
+
if os.environ.get("MAGGIE_VERSION"):
|
|
40
|
+
return safe_text(os.environ["MAGGIE_VERSION"])
|
|
41
|
+
for path in (project / ".maggie" / "install.json", project / "VERSION"):
|
|
42
|
+
try:
|
|
43
|
+
value = json.loads(path.read_text(encoding="utf-8")) if path.suffix == ".json" else path.read_text(encoding="utf-8").strip()
|
|
44
|
+
if isinstance(value, dict): value = value.get("version", "")
|
|
45
|
+
if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?", str(value)):
|
|
46
|
+
return str(value)
|
|
47
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
48
|
+
continue
|
|
49
|
+
return "unknown"
|
|
50
|
+
|
|
51
|
+
|
|
37
52
|
def feedback_id(project: Path) -> str:
|
|
38
53
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
39
54
|
return f"fb-{timestamp}-{project_fingerprint(project)[:6]}-{secrets.token_hex(3)}"
|
|
@@ -68,7 +83,7 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
68
83
|
"feedbackId": feedback_id(project),
|
|
69
84
|
"createdAt": now(),
|
|
70
85
|
"source": "maggie-cli",
|
|
71
|
-
"maggieVersion":
|
|
86
|
+
"maggieVersion": installed_version(project),
|
|
72
87
|
"type": args.type,
|
|
73
88
|
"skill": skill,
|
|
74
89
|
"runId": run_id,
|
|
@@ -9,6 +9,10 @@ import sys
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
13
|
+
from dependency_lock import audit as audit_lockfiles
|
|
14
|
+
from seed_evidence import validate_manifest
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
REQUIRED_ROUTES = (
|
|
14
18
|
"/ops", "/ops/posts", "/ops/sitemap", "/ops/reports",
|
|
@@ -132,14 +136,30 @@ def command_verify(args: argparse.Namespace) -> int:
|
|
|
132
136
|
return 0 if result["checks"]["ready"] and result["stateFile"] else 1
|
|
133
137
|
|
|
134
138
|
|
|
139
|
+
def command_lockfiles(args: argparse.Namespace) -> int:
|
|
140
|
+
result = audit_lockfiles(root(args))
|
|
141
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
142
|
+
return 0 if result["passed"] else 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def command_seed_manifest(args: argparse.Namespace) -> int:
|
|
146
|
+
value = read_json(Path(args.manifest).resolve())
|
|
147
|
+
result = validate_manifest(value)
|
|
148
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
149
|
+
return 0 if result["passed"] else 1
|
|
150
|
+
|
|
151
|
+
|
|
135
152
|
def main() -> int:
|
|
136
153
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
137
154
|
parser.add_argument("--project", default=".")
|
|
138
155
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
139
|
-
for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify)):
|
|
156
|
+
for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify), ("lockfiles", command_lockfiles)):
|
|
140
157
|
command = sub.add_parser(name)
|
|
141
158
|
if name == "preflight": command.add_argument("--write", action="store_true")
|
|
142
159
|
command.set_defaults(func=handler)
|
|
160
|
+
seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
|
|
161
|
+
seed.add_argument("--manifest", required=True)
|
|
162
|
+
seed.set_defaults(func=command_seed_manifest)
|
|
143
163
|
record = sub.add_parser("record", help="record an explicitly authorised operation")
|
|
144
164
|
record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
|
|
145
165
|
record.add_argument("--dry-run", action="store_true")
|
|
@@ -10,6 +10,9 @@ from pathlib import Path
|
|
|
10
10
|
from urllib.parse import quote, urljoin, urlsplit
|
|
11
11
|
from urllib.request import Request, urlopen
|
|
12
12
|
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
14
|
+
from route_imports import imported_components # noqa: E402
|
|
15
|
+
|
|
13
16
|
NOW = lambda: datetime.now(timezone.utc).isoformat()
|
|
14
17
|
MONEY = re.compile(r"(?:£|GBP\s*)\s*([0-9]+(?:[.,][0-9]{1,2})?)", re.I)
|
|
15
18
|
DURATION = re.compile(r"(\d{2,3})\s*(?:min|mins|minutes?)", re.I)
|
|
@@ -751,7 +754,7 @@ def inventory_candidates(project, pages_root):
|
|
|
751
754
|
found={}
|
|
752
755
|
for page in pages_root.rglob("*") if pages_root.exists() else []:
|
|
753
756
|
if page.is_file() and page.suffix.lower() in {".astro",".md",".mdx",".html"} and page.stem not in {"index","404"}:
|
|
754
|
-
found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem"}
|
|
757
|
+
found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem","imports":imported_components(page)}
|
|
755
758
|
for name in ("pages.json","page-content.json"):
|
|
756
759
|
source=project/"docs"/name
|
|
757
760
|
if not source.exists(): continue
|
|
@@ -762,7 +765,7 @@ def inventory_candidates(project, pages_root):
|
|
|
762
765
|
if not isinstance(item,dict): continue
|
|
763
766
|
value=item.get("path") or item.get("url") or item.get("route")
|
|
764
767
|
if not value: continue
|
|
765
|
-
current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory"}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
|
|
768
|
+
current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory","imports":[]}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
|
|
766
769
|
return list(found.values())
|
|
767
770
|
def cmd_match_pages_review(args):
|
|
768
771
|
project=root(args); data=load(project); candidates=inventory_candidates(project,(project/args.pages_dir).resolve()); aliases={"lymphatic drainage":{"lymphatic","lymph drainage","lymphdrainage","manual lymphatic","ml d"},"deep tissue":{"deep tissue","therapeutic massage"},"thai":{"thai massage","thai"},"postpartum":{"postpartum","new mom"},"facial":{"facial","hydra facial","hydrafacial"},"massage":{"massage"}}
|
|
@@ -779,7 +782,7 @@ def cmd_match_pages_review(args):
|
|
|
779
782
|
matches=[]
|
|
780
783
|
for page in candidates:
|
|
781
784
|
path_slug=slug(page["path"].rsplit("/",1)[-1].rsplit(".",1)[0]); page_words=page_tokens(path_slug+" "+page["text"][:2000]); exact=path_slug==service.get("slug"); overlap=len(expanded & page_words)/max(1,len(expanded)); generic=bool(concepts & {"massage","facial","acupuncture","waxing"}) and overlap<0.75; score=1.0 if exact else min(0.49 if generic else overlap,0.99)
|
|
782
|
-
if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
|
|
785
|
+
if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"imports":page.get("imports",[]),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
|
|
783
786
|
matches.sort(key=lambda x:(-x["confidence"],x["path"])); report.append({"serviceId":service["id"],"title":service["title"],"candidates":matches})
|
|
784
787
|
for path_value in selections.get(service["id"], []):
|
|
785
788
|
if path_value in {m["path"] for m in matches}: selected.append((service,path_value))
|
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
13
|
-
from maggie_sitemap import build_plan, parse_routes, validate_plan_data # noqa: E402
|
|
13
|
+
from maggie_sitemap import agent_files, build_plan, parse_routes, validate_plan_data # noqa: E402
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def write_json(path: str, value: dict) -> None:
|
|
@@ -31,6 +31,12 @@ def main() -> int:
|
|
|
31
31
|
plan.add_argument("--output", required=True)
|
|
32
32
|
validate = sub.add_parser("validate")
|
|
33
33
|
validate.add_argument("--plan", required=True)
|
|
34
|
+
validate.add_argument("--strict-semantic", action="store_true")
|
|
35
|
+
agent = sub.add_parser("agent-files")
|
|
36
|
+
agent.add_argument("--origin", required=True)
|
|
37
|
+
agent.add_argument("--routes-file", required=True)
|
|
38
|
+
agent.add_argument("--output-dir", required=True)
|
|
39
|
+
agent.add_argument("--locale", default="en")
|
|
34
40
|
apply = sub.add_parser("apply")
|
|
35
41
|
apply.add_argument("--plan", required=True)
|
|
36
42
|
apply.add_argument("--public-dir", required=True)
|
|
@@ -65,9 +71,17 @@ def main() -> int:
|
|
|
65
71
|
restored += 1
|
|
66
72
|
print(json.dumps({"status": "rolled-back", "restored": restored}, indent=2))
|
|
67
73
|
return 0
|
|
74
|
+
if args.command == "agent-files":
|
|
75
|
+
output = Path(args.output_dir)
|
|
76
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
files = agent_files(parse_routes(Path(args.routes_file).read_text(encoding="utf-8")), args.origin, args.locale)
|
|
78
|
+
for name, content in files.items():
|
|
79
|
+
(output / name).write_text(content, encoding="utf-8")
|
|
80
|
+
print(json.dumps({"status": "generated", "locale": args.locale, "files": [str(output / name) for name in files]}, indent=2))
|
|
81
|
+
return 0
|
|
68
82
|
plan_value = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
|
69
83
|
if args.command == "validate":
|
|
70
|
-
result = validate_plan_data(plan_value)
|
|
84
|
+
result = validate_plan_data(plan_value, args.strict_semantic)
|
|
71
85
|
print(json.dumps(result, indent=2))
|
|
72
86
|
return 0 if result["status"] == "pass" else 1
|
|
73
87
|
if not args.confirm:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Safe classification of known first-party/toolchain traffic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
TOOL_USER_AGENT = re.compile(r"(?:maggie|playwright|puppeteer|selenium|lighthouse|headlesschrome|synthetic|test[-_ ]?sweep)", re.I)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_tool_traffic(event: dict) -> bool:
|
|
13
|
+
if str(event.get("maggieTest") or "").lower() == "true":
|
|
14
|
+
return True
|
|
15
|
+
if str(event.get("xMaggieTest") or "").lower() == "true":
|
|
16
|
+
return True
|
|
17
|
+
return bool(TOOL_USER_AGENT.search(str(event.get("userAgent") or "")))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def audit_events(events: Iterable[dict]) -> dict:
|
|
21
|
+
totals = Counter()
|
|
22
|
+
excluded = Counter()
|
|
23
|
+
included = 0
|
|
24
|
+
for event in events:
|
|
25
|
+
totals[str(event.get("event") or "unknown")] += 1
|
|
26
|
+
if is_tool_traffic(event):
|
|
27
|
+
excluded[str(event.get("event") or "unknown")] += 1
|
|
28
|
+
else:
|
|
29
|
+
included += 1
|
|
30
|
+
return {"schemaVersion": "maggie-analytics-traffic.v1", "total": sum(totals.values()), "included": included, "excluded": sum(excluded.values()), "byEvent": dict(sorted(totals.items())), "excludedByEvent": dict(sorted(excluded.items())), "policy": "exclude only explicit Maggie/toolchain markers; no IP or identity inference"}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any, Iterable
|
|
7
7
|
|
|
8
8
|
# Deliberately broad, dependency-free registry of the major web/content
|
|
9
9
|
# languages. Script variants remain distinct where they affect copy, search,
|
|
@@ -19,6 +19,68 @@ OPERATIONS = {"translate", "polish", "rewrite", "localise", "rebrand", "manual_e
|
|
|
19
19
|
PROTECTED_FIELDS = {"price", "currency", "rating", "provider", "providerId", "bookingUrl", "paymentUrl", "legalClaims", "healthClaims", "contentId", "slug", "canonicalUrl", "translationGroupId"}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
class TranslationIndex:
|
|
23
|
+
"""One canonical index for translated records and their public wording.
|
|
24
|
+
|
|
25
|
+
Hosts may persist this structure wherever they keep content, but they must
|
|
26
|
+
build it once and reject conflicting duplicate `(contentId, locale)` rows.
|
|
27
|
+
This prevents a registry and a second dictionary from silently disagreeing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, records: Iterable[dict[str, Any]] = ()):
|
|
31
|
+
self._records: dict[tuple[str, str], dict[str, Any]] = {}
|
|
32
|
+
self.conflicts: list[dict[str, Any]] = []
|
|
33
|
+
for record in records:
|
|
34
|
+
self.add(record)
|
|
35
|
+
|
|
36
|
+
def add(self, record: dict[str, Any]) -> None:
|
|
37
|
+
content_id = str(record.get("contentId") or "").strip()
|
|
38
|
+
locale = str(record.get("locale") or "").strip()
|
|
39
|
+
if not content_id or not locale:
|
|
40
|
+
raise ValueError("translation index records require contentId and locale")
|
|
41
|
+
key = (content_id, locale)
|
|
42
|
+
previous = self._records.get(key)
|
|
43
|
+
if previous and previous != record:
|
|
44
|
+
self.conflicts.append({"key": key, "existing": previous, "incoming": record})
|
|
45
|
+
raise ValueError(f"conflicting translation record for {content_id}/{locale}")
|
|
46
|
+
self._records[key] = dict(record)
|
|
47
|
+
|
|
48
|
+
def get(self, content_id: str, locale: str) -> dict[str, Any] | None:
|
|
49
|
+
record = self._records.get((content_id, locale))
|
|
50
|
+
return dict(record) if record else None
|
|
51
|
+
|
|
52
|
+
def records(self) -> list[dict[str, Any]]:
|
|
53
|
+
return [dict(self._records[key]) for key in sorted(self._records)]
|
|
54
|
+
|
|
55
|
+
def as_dict(self) -> dict[str, dict[str, Any]]:
|
|
56
|
+
return {f"{content_id}:{locale}": dict(record) for (content_id, locale), record in sorted(self._records.items())}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_translated_path(path: str, exact_paths: Iterable[str] = (), prefixes: Iterable[str] = ()) -> bool:
|
|
60
|
+
"""Use exact routes and normalized prefix routes with identical semantics."""
|
|
61
|
+
normalized = "/" + str(path or "").lstrip("/")
|
|
62
|
+
normalized = normalized.rstrip("/") or "/"
|
|
63
|
+
exact = {("/" + str(item).lstrip("/")).rstrip("/") or "/" for item in exact_paths}
|
|
64
|
+
normalized_prefixes = {("/" + str(item).lstrip("/")).rstrip("/") or "/" for item in prefixes}
|
|
65
|
+
return normalized in exact or any(normalized == prefix or normalized.startswith(prefix + "/") for prefix in normalized_prefixes)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def should_localize_path(path: str, locale_prefixes: Iterable[str], static_extensions: Iterable[str] = (".txt", ".md", ".xml", ".json")) -> bool:
|
|
69
|
+
"""Locale middleware only handles localized routes, never root static files."""
|
|
70
|
+
normalized = "/" + str(path or "").lstrip("/")
|
|
71
|
+
if any(normalized.lower().endswith(extension.lower()) for extension in static_extensions):
|
|
72
|
+
return False
|
|
73
|
+
return is_translated_path(normalized, prefixes=locale_prefixes)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rewrite_once(request_key: str, seen: set[str]) -> bool:
|
|
77
|
+
"""Return true only for the first rewrite observation of a request."""
|
|
78
|
+
if request_key in seen:
|
|
79
|
+
return False
|
|
80
|
+
seen.add(request_key)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
22
84
|
def parse_locale(value: Any) -> tuple[str | None, str | None, str | None]:
|
|
23
85
|
if not isinstance(value, str):
|
|
24
86
|
return None, None, None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Detect package-manager drift before an npm-ci deployment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def package_dependencies(project: Path) -> set[str]:
|
|
9
|
+
data = json.loads((project / "package.json").read_text(encoding="utf-8"))
|
|
10
|
+
return set(data.get("dependencies", {})) | set(data.get("devDependencies", {})) | set(data.get("optionalDependencies", {}))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def npm_lock_dependencies(path: Path) -> set[str]:
|
|
14
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
15
|
+
packages = data.get("packages", {})
|
|
16
|
+
names = {key.removeprefix("node_modules/") for key in packages
|
|
17
|
+
if key.startswith("node_modules/") and "/node_modules/" not in key}
|
|
18
|
+
if not packages and isinstance(data.get("dependencies"), dict):
|
|
19
|
+
names = set(data["dependencies"])
|
|
20
|
+
return names
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pnpm_lock_dependencies(path: Path) -> set[str]:
|
|
24
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
25
|
+
return {match.group(1) for match in re.finditer(r"^\s{4,}/?(@[^/\s]+/[^/\s]+|[A-Za-z0-9_.-]+)@[^:]+:", text, re.M)}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def audit(project: Path) -> dict:
|
|
29
|
+
errors = []
|
|
30
|
+
try:
|
|
31
|
+
declared = package_dependencies(project)
|
|
32
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
33
|
+
return {"passed": False, "errors": [f"package.json: {error}"]}
|
|
34
|
+
npm = project / "package-lock.json"
|
|
35
|
+
pnpm = project / "pnpm-lock.yaml"
|
|
36
|
+
npm_names = npm_lock_dependencies(npm) if npm.exists() else set()
|
|
37
|
+
pnpm_names = pnpm_lock_dependencies(pnpm) if pnpm.exists() else set()
|
|
38
|
+
if not npm.exists(): errors.append("package-lock.json is required by npm ci")
|
|
39
|
+
missing = sorted(declared - npm_names) if npm.exists() else sorted(declared)
|
|
40
|
+
if missing: errors.append("package-lock missing declared dependencies: " + ", ".join(missing))
|
|
41
|
+
return {"schemaVersion": "maggie-dependency-lock-audit.v1", "passed": not errors,
|
|
42
|
+
"errors": errors, "declared": sorted(declared), "npmLock": sorted(npm_names),
|
|
43
|
+
"pnpmLock": sorted(pnpm_names), "lockfiles": {"npm": npm.exists(), "pnpm": pnpm.exists()}}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Explicit state model for optional Search/AI visibility integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def integration_state(*, configured: bool, consent_required: bool = False, consent: bool = False, authorized: bool = False, error: str | None = None) -> dict:
|
|
7
|
+
if error:
|
|
8
|
+
status = "error"
|
|
9
|
+
elif not configured:
|
|
10
|
+
status = "not-configured"
|
|
11
|
+
elif consent_required and not consent:
|
|
12
|
+
status = "awaiting-consent"
|
|
13
|
+
elif not authorized:
|
|
14
|
+
status = "awaiting-authorization"
|
|
15
|
+
else:
|
|
16
|
+
status = "ready"
|
|
17
|
+
return {"status": status, "configured": configured, "consentRequired": consent_required, "consent": consent, "authorized": authorized, "reason": error or status}
|