@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.
Files changed (32) hide show
  1. package/README.md +35 -9
  2. package/bin/maggie.js +6 -6
  3. package/bundled-references/maggiedash-dashboard-ui.md +28 -0
  4. package/bundled-skills/maggie-blog/SKILL.md +12 -0
  5. package/bundled-skills/maggie-content-localization/SKILL.md +9 -0
  6. package/bundled-skills/maggie-dash/SKILL.md +45 -2
  7. package/bundled-skills/maggie-deployment/SKILL.md +17 -0
  8. package/bundled-skills/maggie-ops/SKILL.md +27 -0
  9. package/bundled-skills/maggie-seo-geo/SKILL.md +21 -1
  10. package/bundled-skills/maggie-service-booking/SKILL.md +6 -0
  11. package/bundled-templates/maggiedash/README.md +15 -0
  12. package/bundled-templates/maggiedash/dashboard-ui-contract.json +31 -0
  13. package/bundled-tools/clis/maggie.py +29 -4
  14. package/bundled-tools/clis/maggie_analytics.py +14 -1
  15. package/bundled-tools/clis/maggie_blog.py +6 -0
  16. package/bundled-tools/clis/maggie_dash.py +179 -0
  17. package/bundled-tools/clis/maggie_deployment.py +43 -0
  18. package/bundled-tools/clis/maggie_feedback.py +16 -1
  19. package/bundled-tools/clis/maggie_ops.py +21 -1
  20. package/bundled-tools/clis/maggie_service_booking.py +6 -3
  21. package/bundled-tools/clis/maggie_sitemap.py +16 -2
  22. package/bundled-tools/runtime/analytics_traffic.py +30 -0
  23. package/bundled-tools/runtime/content_localization.py +63 -1
  24. package/bundled-tools/runtime/dependency_lock.py +43 -0
  25. package/bundled-tools/runtime/integration_state.py +17 -0
  26. package/bundled-tools/runtime/maggie_dash_store.py +150 -6
  27. package/bundled-tools/runtime/maggie_dash_ui.py +60 -0
  28. package/bundled-tools/runtime/maggie_sitemap.py +50 -4
  29. package/bundled-tools/runtime/route_imports.py +51 -0
  30. package/bundled-tools/runtime/seed_evidence.py +25 -0
  31. package/package.json +1 -1
  32. 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": os.environ.get("MAGGIE_VERSION", "unknown"),
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}