@topy-ai/maggie 0.7.53 → 0.7.55

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 (65) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +80 -64
  3. package/bin/maggie.js +61 -9
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
  5. package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
  6. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
  7. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
  8. package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
  9. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  10. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  11. package/bundled-skills/catalog.json +1 -1
  12. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  13. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  14. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  15. package/bundled-skills/maggie-blog/SKILL.md +15 -0
  16. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  17. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  18. package/bundled-skills/maggie-dash/SKILL.md +37 -2
  19. package/bundled-skills/maggie-deployment/SKILL.md +104 -1
  20. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  21. package/bundled-skills/maggie-design/SKILL.md +81 -0
  22. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  23. package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
  24. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  25. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  26. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  27. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  28. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  29. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  30. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  31. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  32. package/bundled-skills/maggie-social-share/SKILL.md +50 -0
  33. package/bundled-tools/clis/maggie.py +63 -3
  34. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  35. package/bundled-tools/clis/maggie_booking.py +9 -8
  36. package/bundled-tools/clis/maggie_dash.py +173 -3
  37. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  38. package/bundled-tools/clis/maggie_design.py +7 -1
  39. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  40. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  41. package/bundled-tools/clis/maggie_feedback.py +41 -13
  42. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  43. package/bundled-tools/clis/maggie_live_urls.py +147 -0
  44. package/bundled-tools/clis/maggie_localization.py +3 -0
  45. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  46. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  47. package/bundled-tools/clis/maggie_mobile.py +84 -1
  48. package/bundled-tools/clis/maggie_ops.py +2 -0
  49. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  50. package/bundled-tools/clis/maggie_product_release.py +49 -1
  51. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  52. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  53. package/bundled-tools/clis/maggie_social_share.py +223 -2
  54. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  55. package/bundled-tools/integrations/maggie-api-pull.md +49 -0
  56. package/package.json +1 -1
  57. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  58. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  59. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  60. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  61. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  62. package/bundled-references/maggiedash-booking/README.md +0 -288
  63. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  64. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  65. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -20,6 +20,10 @@ from urllib.request import Request, urlopen
20
20
 
21
21
 
22
22
  DEFAULT_ENDPOINT = "https://feedback.noblox.app/api/feedback"
23
+ # Both hosts accept the same anonymous contract and de-duplicate on feedbackId,
24
+ # so a draft is offered to the next one only when the previous host failed
25
+ # before storing it (network error or a 5xx such as Cloudflare's 52x).
26
+ DEFAULT_ENDPOINTS = (DEFAULT_ENDPOINT, "https://feedback.cmo.so/api/feedback")
23
27
  SECRET_RE = re.compile(r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|npm_config[^=]*|authorization)\s*[=:]\s*)[^\s,;]+")
24
28
  LOCAL_PATH_RE = re.compile(
25
29
  r"(?<![A-Za-z0-9])(?:~/(?:[^\s,;:)\]}]+/)*[^\s,;:)\]}]+|"
@@ -348,28 +352,52 @@ def hosted_payload(data: dict) -> dict:
348
352
  return payload
349
353
 
350
354
 
355
+ def submit_endpoints(value: object) -> list[str]:
356
+ endpoints = [value] if isinstance(value, str) else list(value or DEFAULT_ENDPOINTS)
357
+ result = []
358
+ for endpoint in endpoints:
359
+ endpoint = str(endpoint).rstrip("/")
360
+ parsed = urlparse(endpoint)
361
+ if parsed.scheme != "https" or not parsed.netloc:
362
+ raise ValueError("endpoint must be an absolute HTTPS URL")
363
+ if endpoint not in result:
364
+ result.append(endpoint)
365
+ return result
366
+
367
+
351
368
  def submit(args: argparse.Namespace) -> int:
352
369
  if not args.confirm:
353
370
  raise ValueError("submit requires --confirm; feedback is never sent implicitly")
354
371
  path = Path(args.feedback)
355
372
  data = read_feedback(path)
356
- endpoint = args.endpoint.rstrip("/")
357
- parsed = urlparse(endpoint)
358
- if parsed.scheme != "https" or not parsed.netloc:
359
- raise ValueError("endpoint must be an absolute HTTPS URL")
373
+ endpoints = submit_endpoints(args.endpoint)
360
374
  payload = hosted_payload(data)
361
375
  payload["projectContext"] = data.get("projectContext") if data.get("privacy", {}).get("projectContextAllowed") else None
362
- request = Request(endpoint, data=json.dumps(payload, ensure_ascii=False).encode(), headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
363
- try:
364
- with urlopen(request, timeout=20) as response:
365
- raw = response.read(1_000_000).decode("utf-8", "replace")
366
- result = json.loads(raw) if raw else {}
367
- except (HTTPError, URLError, TimeoutError) as error:
368
- raise RuntimeError(f"feedback submission failed: {error}") from error
376
+ body = json.dumps(payload, ensure_ascii=False).encode()
377
+ failures = []
378
+ for endpoint in endpoints:
379
+ request = Request(endpoint, data=body, headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
380
+ try:
381
+ with urlopen(request, timeout=20) as response:
382
+ raw = response.read(1_000_000).decode("utf-8", "replace")
383
+ result = json.loads(raw) if raw else {}
384
+ break
385
+ except HTTPError as error:
386
+ failures.append({"endpoint": endpoint, "error": f"HTTP {error.code}"})
387
+ # A 4xx is the host rejecting this draft; another host would too.
388
+ if error.code < 500:
389
+ raise RuntimeError(f"feedback submission failed: {endpoint} answered HTTP {error.code}") from error
390
+ except (URLError, TimeoutError, OSError) as error:
391
+ failures.append({"endpoint": endpoint, "error": safe_text(getattr(error, "reason", error))})
392
+ else:
393
+ tried = "; ".join(f"{item['endpoint']} ({item['error']})" for item in failures)
394
+ raise RuntimeError(f"feedback submission failed on every endpoint: {tried}")
369
395
  ack = acknowledgement(result)
370
396
  data["submission"] = {"status": "submitted", "endpoint": endpoint, "submittedAt": now(), "acknowledgement": ack}
397
+ if failures:
398
+ data["submission"]["failedEndpoints"] = failures
371
399
  path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
372
- print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "acknowledgement": ack}, indent=2, ensure_ascii=False))
400
+ print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "endpoint": endpoint, "failedEndpoints": failures, "acknowledgement": ack}, indent=2, ensure_ascii=False))
373
401
  return 0
374
402
 
375
403
 
@@ -815,7 +843,7 @@ def main() -> int:
815
843
  preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
816
844
  submit_parser = sub.add_parser("submit")
817
845
  submit_parser.add_argument("feedback")
818
- submit_parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
846
+ submit_parser.add_argument("--endpoint", action="append", help="HTTPS endpoint; repeat for an ordered fallback list (default: the NoBlox host, then feedback.cmo.so)")
819
847
  submit_parser.add_argument("--confirm", action="store_true")
820
848
  list_parser = sub.add_parser("list")
821
849
  list_parser.add_argument("--project", default=argparse.SUPPRESS)
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env python3
2
+ """Host runtime checks for VPS hosts that serve PHP or containers (``maggie deployment ...``).
3
+
4
+ - ``runtime-inventory``: read-only inventory of one host — OS release, every
5
+ active nginx server block (from ``nginx -T``, which includes files that are
6
+ symlinked into ``sites-enabled``; ``grep -r`` does not follow those links and
7
+ made a host look as if it had no PHP sites), its ``fastcgi_pass`` target,
8
+ host PHP versions and php-fpm units, containers and container units. With
9
+ ``--advisory`` it marks each runtime against the fixed versions and writes a
10
+ reviewable migration plan per affected site. It also flags an operating
11
+ system past its end of standard security support and a pinned container
12
+ tag older than the newest patch of its line (``--latest-tags``). Input is a
13
+ collected evidence file (``--evidence``) or ``--ssh user@host``, which runs
14
+ only the read-only collector printed by ``--print-collector``.
15
+ - ``host-lock``: one agent session at a time on a production host. Acquire it
16
+ before editing systemd units, containers or nginx; ``status`` also lists
17
+ files changed recently under those directories (someone may be working
18
+ there without the lock). Two sessions once recreated the same container in
19
+ turn, each undoing the other's fix.
20
+
21
+ PHP images (``php:*-fpm``, ``wordpress:*-fpm``) are always planned with
22
+ ``--entrypoint docker-php-entrypoint`` and an explicit ``php-fpm`` command:
23
+ the WordPress image's own entrypoint copies a fresh WordPress into a mounted
24
+ site folder that has no ``wp-config.php``, which once exposed a public
25
+ ``wp-admin/setup-config.php`` on a site that was not WordPress.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import re
34
+ import shlex
35
+ import subprocess
36
+ import sys
37
+ from datetime import date, datetime, timedelta, timezone
38
+ from pathlib import Path
39
+
40
+ # End of standard security support (not paid extended support). Override or
41
+ # extend with --eol-table FILE ({"ubuntu": {"24.10": "2025-07-10"}}).
42
+ EOL = {
43
+ "ubuntu": {
44
+ "16.04": "2021-04-30", "18.04": "2023-05-31", "20.04": "2025-05-31", "22.04": "2027-06-01",
45
+ "24.04": "2029-05-31", "26.04": "2031-05-31",
46
+ "20.10": "2021-07-22", "21.04": "2022-01-20", "21.10": "2022-07-14", "22.10": "2023-07-20",
47
+ "23.04": "2024-01-25", "23.10": "2024-07-11", "24.10": "2025-07-10", "25.04": "2026-01-15",
48
+ "25.10": "2026-07-09",
49
+ },
50
+ "debian": {"9": "2022-06-30", "10": "2024-06-30", "11": "2026-08-31", "12": "2028-06-30", "13": "2030-06-30"},
51
+ }
52
+ SECTION = re.compile(r"^### (\S+)\s*$")
53
+ SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
54
+ PHP_IMAGE = re.compile(r"(^|/)(php|wordpress)(:|$)")
55
+
56
+ # Read-only collector. Each section starts with "### name"; the parser ignores
57
+ # anything it does not know. "nginx -T" prints every included file, so
58
+ # symlinked sites-enabled entries are covered; the grep fallback uses -R.
59
+ COLLECTOR = r"""set +e
60
+ s() { printf '\n### %s\n' "$1"; }
61
+ SUDO=""; [ "$(id -u)" != 0 ] && sudo -n true 2>/dev/null && SUDO="sudo -n"
62
+ s os-release; cat /etc/os-release 2>/dev/null
63
+ s nginx-T; $SUDO nginx -T 2>/dev/null
64
+ s nginx-fastcgi-files; $SUDO grep -RIl fastcgi_pass /etc/nginx 2>/dev/null
65
+ s php-v; php -v 2>/dev/null | head -1
66
+ s php-fpm-units; systemctl list-units --all --no-legend --plain 'php*-fpm*' 2>/dev/null
67
+ s docker-ps; $SUDO docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Mounts}}' 2>/dev/null
68
+ s docker-entrypoints; $SUDO docker ps -q 2>/dev/null | xargs -r $SUDO docker inspect --format '{{.Name}}\t{{.Config.Image}}\t{{json .Config.Entrypoint}}' 2>/dev/null
69
+ for f in $($SUDO grep -RlE '(docker|podman) run' /etc/systemd/system 2>/dev/null | sort -u); do s "unit:$f"; $SUDO cat "$f"; done
70
+ """
71
+
72
+
73
+ def parse_sections(text: str) -> dict[str, str]:
74
+ sections: dict[str, list[str]] = {}
75
+ current = None
76
+ for line in text.splitlines():
77
+ match = SECTION.match(line)
78
+ if match:
79
+ current = match.group(1)
80
+ sections.setdefault(current, [])
81
+ elif current:
82
+ sections[current].append(line)
83
+ return {name: "\n".join(lines).strip("\n") for name, lines in sections.items()}
84
+
85
+
86
+ def parse_os(text: str) -> dict:
87
+ values = {}
88
+ for line in text.splitlines():
89
+ if "=" in line:
90
+ key, _, value = line.partition("=")
91
+ values[key.strip()] = value.strip().strip('"')
92
+ return {"id": values.get("ID", ""), "version": values.get("VERSION_ID", ""), "name": values.get("PRETTY_NAME", "")}
93
+
94
+
95
+ def os_finding(os_info: dict, today: date, table: dict) -> dict | None:
96
+ ends = table.get(os_info.get("id", ""), {}).get(os_info.get("version", ""))
97
+ if not ends:
98
+ return {"kind": "os-unknown", "severity": "warning", "message": f"no end-of-support date known for {os_info.get('name') or 'this OS'}; check it by hand or pass --eol-table"} if os_info.get("id") else None
99
+ end = date.fromisoformat(ends)
100
+ if end < today:
101
+ return {"kind": "os-eol", "severity": "error", "ends": ends, "message": f"{os_info['name']} stopped getting standard security fixes on {ends}; plan an upgrade to a supported release"}
102
+ if end - today < timedelta(days=120):
103
+ return {"kind": "os-eol-soon", "severity": "warning", "ends": ends, "message": f"{os_info['name']} stops getting standard security fixes on {ends}"}
104
+ return None
105
+
106
+
107
+ def strip_comment(line: str) -> str:
108
+ return line.split("#", 1)[0]
109
+
110
+
111
+ def parse_nginx(text: str) -> list[dict]:
112
+ """Server blocks from `nginx -T` output: file, server_name, root, listen, fastcgi_pass."""
113
+ servers: list[dict] = []
114
+ current_file = ""
115
+ depth = 0
116
+ server: dict | None = None
117
+ server_depth = 0
118
+ statements: list[str] = []
119
+ for raw in text.splitlines():
120
+ if re.match(r"^# configuration file (.+):\s*$", raw):
121
+ statements.append(raw)
122
+ continue
123
+ body = strip_comment(raw).replace("{", "{\n").replace("}", "\n}\n").replace(";", ";\n")
124
+ statements += [part.strip() for part in body.splitlines() if part.strip()]
125
+ for line in statements:
126
+ marker = re.match(r"^# configuration file (.+):\s*$", line)
127
+ if marker:
128
+ current_file = marker.group(1)
129
+ depth, server = 0, None
130
+ continue
131
+ if server is None and re.match(r"^server\s*\{", line):
132
+ server = {"file": current_file, "serverNames": [], "root": "", "listen": [], "fastcgiPass": []}
133
+ server_depth = depth + 1
134
+ if server is not None:
135
+ for directive, key in (("server_name", "serverNames"), ("listen", "listen"), ("fastcgi_pass", "fastcgiPass")):
136
+ match = re.match(rf"^{directive}\s+([^;]+);", line)
137
+ if match:
138
+ values = match.group(1).split() if directive == "server_name" else [match.group(1).strip()]
139
+ for value in values:
140
+ if value not in server[key]:
141
+ server[key].append(value)
142
+ match = re.match(r"^root\s+([^;]+);", line)
143
+ if match and not server["root"]:
144
+ server["root"] = match.group(1).strip()
145
+ depth += line.count("{") - line.count("}")
146
+ if server is not None and depth < server_depth:
147
+ servers.append(server)
148
+ server = None
149
+ return servers
150
+
151
+
152
+ def version_tuple(value: str) -> tuple[int, ...]:
153
+ return tuple(int(part) for part in re.findall(r"\d+", value)[:3])
154
+
155
+
156
+ def php_line(value: str) -> str:
157
+ match = re.search(r"(\d+)\.(\d+)", value)
158
+ return f"{match.group(1)}.{match.group(2)}" if match else ""
159
+
160
+
161
+ def parse_containers(text: str) -> list[dict]:
162
+ containers = []
163
+ for line in text.splitlines():
164
+ parts = line.split("\t")
165
+ if len(parts) < 2 or not parts[0].strip():
166
+ continue
167
+ name, image = parts[0].strip(), parts[1].strip()
168
+ ports = parts[2] if len(parts) > 2 else ""
169
+ bound = re.findall(r"(?:([\d.]+|\[::\]):)?(\d+)->(\d+)/tcp", ports)
170
+ containers.append({"name": name, "image": image, "ports": [{"host": host or "0.0.0.0", "hostPort": int(hp), "containerPort": int(cp)} for host, hp, cp in bound], "mounts": parts[3].strip() if len(parts) > 3 else ""})
171
+ return containers
172
+
173
+
174
+ def image_info(image: str) -> dict:
175
+ repo, _, tag = image.rpartition(":") if ":" in image.split("/")[-1] else (image, "", "latest")
176
+ name = repo.split("/")[-1]
177
+ php = re.search(r"(?:^|php)(\d+\.\d+(?:\.\d+)?)", tag)
178
+ version = php.group(1) if php and (name == "php" or "php" in tag) else ""
179
+ return {"repository": name, "tag": tag, "php": version, "line": php_line(version), "patchPinned": version.count(".") == 2, "isPhp": bool(PHP_IMAGE.search(image))}
180
+
181
+
182
+ def unit_findings(units: dict[str, str]) -> list[dict]:
183
+ findings = []
184
+ for path, body in units.items():
185
+ for match in re.finditer(r"(?:docker|podman) run[^\n]*", re.sub(r"\\\s*\n", " ", body)):
186
+ command = match.group(0)
187
+ image = next((token for token in command.split() if PHP_IMAGE.search(token) and ":" in token), "")
188
+ if image and "--entrypoint docker-php-entrypoint" not in command and "--entrypoint=docker-php-entrypoint" not in command:
189
+ findings.append({"kind": "php-image-entrypoint", "severity": "error", "unit": path, "image": image,
190
+ "message": f"{path} runs {image} with the image's own entrypoint; add --entrypoint docker-php-entrypoint and an explicit php-fpm command (the WordPress entrypoint copies WordPress into a mounted folder without wp-config.php)"})
191
+ return findings
192
+
193
+
194
+ def runtime_status(version: str, advisory: dict | None) -> str:
195
+ if not advisory:
196
+ return "not-checked"
197
+ line = php_line(version)
198
+ fixed = (advisory.get("fixed") or {}).get(line)
199
+ if not line:
200
+ return "unknown"
201
+ if not fixed:
202
+ lines = sorted((advisory.get("fixed") or {}), key=version_tuple)
203
+ if line in (advisory.get("unsupportedLines") or []) or (lines and version_tuple(line) < version_tuple(lines[0])):
204
+ return "affected-no-fix"
205
+ return "not-listed"
206
+ if version.count(".") < 2:
207
+ return "unknown-patch"
208
+ return "affected" if version_tuple(version) < version_tuple(fixed) else "fixed"
209
+
210
+
211
+ def resolve_runtime(target: str, host_php: str, fpm_units: list[str], containers: list[dict]) -> dict:
212
+ socket = re.search(r"php(\d+\.\d+)-fpm", target)
213
+ if target.startswith("unix:") and socket:
214
+ line = socket.group(1)
215
+ version = host_php if php_line(host_php) == line else line
216
+ return {"kind": "host-php-fpm", "unit": f"php{line}-fpm", "version": version}
217
+ port = re.search(r"(?:^|:)(\d+)$", target)
218
+ if port:
219
+ for container in containers:
220
+ if any(p["hostPort"] == int(port.group(1)) for p in container["ports"]):
221
+ info = image_info(container["image"])
222
+ return {"kind": "container", "container": container["name"], "image": container["image"], "version": info["php"]}
223
+ if target.startswith("unix:") and fpm_units:
224
+ return {"kind": "host-php-fpm", "unit": fpm_units[0], "version": host_php}
225
+ return {"kind": "unknown", "version": ""}
226
+
227
+
228
+ def site_slug(server: dict) -> str:
229
+ name = next((n for n in server["serverNames"] if n not in ("_", "localhost")), "") or Path(server["file"]).name
230
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:40] or "site"
231
+
232
+
233
+ def migration_plan(server: dict, runtime: dict, image: str, port: int, writable: list[str]) -> dict:
234
+ slug = site_slug(server)
235
+ root = server["root"] or "/var/www/SITE"
236
+ container = f"php-{slug}"
237
+ mounts = [f"{root}:{root}:ro"] + [f"{path}:{path}" for path in writable if path.startswith(root)]
238
+ run = ["docker", "run", "-d", "--name", container, "--restart", "unless-stopped",
239
+ "--entrypoint", "docker-php-entrypoint", "--user", "SITE_UID:SITE_GID", "-p", f"127.0.0.1:{port}:9000"]
240
+ for mount in mounts:
241
+ run += ["-v", mount]
242
+ run += [image, "php-fpm"]
243
+ old = runtime.get("target", "")
244
+ host = next((n for n in server["serverNames"] if n not in ("_", "localhost")), "SITE_HOST")
245
+ listing = f"/root/maggie-{slug}-files-before.txt"
246
+ return {
247
+ "site": host, "nginxFile": server["file"], "root": root, "currentRuntime": runtime, "image": image,
248
+ "steps": [
249
+ f"maggie deployment host-lock acquire --host HOST --session SESSION # before any change",
250
+ f"stat -c '%u:%g' {shlex.quote(root)} # SITE_UID:SITE_GID for --user",
251
+ f"find {shlex.quote(root)} -xdev -type f | sort > {listing}",
252
+ f"docker pull {image}",
253
+ " ".join(shlex.quote(part) for part in run),
254
+ f"cp -a {shlex.quote(server['file'])} {shlex.quote(server['file'])}.maggie-bak",
255
+ f"sed -i 's#fastcgi_pass {old};#fastcgi_pass 127.0.0.1:{port};#' {shlex.quote(server['file'])}",
256
+ "nginx -t && systemctl reload nginx",
257
+ ],
258
+ "systemdUnit": "If the container is managed by a systemd unit, its ExecStart must carry the same --entrypoint docker-php-entrypoint and php-fpm command; recreate through the unit, not by hand, or the next restart brings the old command back.",
259
+ "verify": [
260
+ f"find {shlex.quote(root)} -xdev -type f | sort | diff {listing} - # must print nothing: no files appeared",
261
+ f"curl -s -o /dev/null -w '%{{http_code}}' https://{host}/wp-admin/setup-config.php # must not be 200 (404 on a site that is not WordPress)",
262
+ f"curl -sI https://{host}/ | head -1 # the site still answers as before",
263
+ f"docker exec {container} php -v | head -1 # the patched version",
264
+ ],
265
+ "rollback": [
266
+ f"cp -a {shlex.quote(server['file'])}.maggie-bak {shlex.quote(server['file'])} && nginx -t && systemctl reload nginx",
267
+ f"docker rm -f {container}",
268
+ ],
269
+ "mutation": "not executed",
270
+ }
271
+
272
+
273
+ def inventory(evidence_text: str, advisory: dict | None, latest_tags: dict, today: date, eol_table: dict, image_template: str, base_port: int, writable: list[str]) -> dict:
274
+ sections = parse_sections(evidence_text)
275
+ findings: list[dict] = []
276
+ os_info = parse_os(sections.get("os-release", ""))
277
+ if (finding := os_finding(os_info, today, eol_table)):
278
+ findings.append(finding)
279
+ nginx_text = sections.get("nginx-T", "")
280
+ if not nginx_text.strip():
281
+ findings.append({"kind": "nginx-unread", "severity": "warning", "message": "no `nginx -T` output: config discovery must follow sites-enabled symlinks (nginx -T or grep -R, never grep -r)"})
282
+ servers = parse_nginx(nginx_text)
283
+ host_php = (re.search(r"PHP (\d+\.\d+\.\d+)", sections.get("php-v", "")) or [None, ""])[1]
284
+ fpm_units = [line.split()[0] for line in sections.get("php-fpm-units", "").splitlines() if line.strip()]
285
+ containers = parse_containers(sections.get("docker-ps", ""))
286
+ units = {name[5:]: body for name, body in sections.items() if name.startswith("unit:")}
287
+ findings += unit_findings(units)
288
+ for line in sections.get("docker-entrypoints", "").splitlines():
289
+ parts = line.split("\t")
290
+ if len(parts) == 3 and PHP_IMAGE.search(parts[1]) and "docker-php-entrypoint" not in parts[2]:
291
+ findings.append({"kind": "php-image-entrypoint", "severity": "error", "container": parts[0].lstrip("/"), "image": parts[1],
292
+ "message": f"container {parts[0].lstrip('/')} runs {parts[1]} with entrypoint {parts[2]}; recreate it with --entrypoint docker-php-entrypoint and an explicit php-fpm command"})
293
+ for container in containers:
294
+ info = image_info(container["image"])
295
+ newest = ((latest_tags.get(info["repository"]) or {}).get(info["line"]) if info["line"] else None)
296
+ if info["patchPinned"] and newest and version_tuple(info["php"]) < version_tuple(newest):
297
+ findings.append({"kind": "image-stale", "severity": "warning", "container": container["name"], "image": container["image"], "newest": newest,
298
+ "message": f"{container['image']} is pinned to {info['php']}; the newest patch of {info['line']} is {newest}"})
299
+ exposed = [p for p in container["ports"] if p["host"] not in ("127.0.0.1", "::1")]
300
+ if info["isPhp"] and exposed:
301
+ findings.append({"kind": "fpm-port-public", "severity": "error", "container": container["name"], "message": f"{container['name']} publishes php-fpm on {exposed[0]['host']}:{exposed[0]['hostPort']}; bind it to 127.0.0.1"})
302
+ used_ports = {p["hostPort"] for c in containers for p in c["ports"]}
303
+ port = base_port
304
+ sites, plans = [], []
305
+ for server in servers:
306
+ if not server["fastcgiPass"]:
307
+ continue
308
+ for target in server["fastcgiPass"]:
309
+ runtime = resolve_runtime(target, host_php, fpm_units, containers)
310
+ runtime["target"] = target
311
+ status = runtime_status(runtime.get("version", ""), advisory)
312
+ sites.append({"site": next(iter(server["serverNames"]), ""), "nginxFile": server["file"], "root": server["root"], "runtime": runtime, "status": status})
313
+ if status in ("affected", "affected-no-fix", "unknown-patch") and advisory:
314
+ line = php_line(runtime.get("version", ""))
315
+ fixes = advisory.get("fixed") or {}
316
+ fixed = fixes.get(line) or (max(fixes.values(), key=version_tuple) if fixes else line)
317
+ while port in used_ports:
318
+ port += 1
319
+ used_ports.add(port)
320
+ plans.append(migration_plan(server, runtime, image_template.format(version=fixed, line=php_line(fixed)), port, writable))
321
+ affected = [s for s in sites if s["status"] in ("affected", "affected-no-fix")]
322
+ return {
323
+ "schemaVersion": "maggie-host-runtime-inventory.v1",
324
+ "os": os_info, "hostPhp": host_php, "phpFpmUnits": fpm_units, "containers": containers,
325
+ "sites": sites, "advisory": (advisory or {}).get("id"), "affectedSites": len(affected),
326
+ "findings": findings, "migrationPlans": plans,
327
+ "passed": not affected and not any(f["severity"] == "error" for f in findings),
328
+ "mutation": "not executed",
329
+ }
330
+
331
+
332
+ def collect_ssh(target: str, ssh_args: list[str]) -> str:
333
+ command = ["ssh", "-o", "BatchMode=yes", *ssh_args, target, "bash -s"]
334
+ completed = subprocess.run(command, input=COLLECTOR.encode(), capture_output=True, timeout=300, check=False)
335
+ if completed.returncode not in (0, 1) and not completed.stdout:
336
+ raise OSError(f"collector failed on {target} (exit {completed.returncode})")
337
+ return completed.stdout.decode("utf-8", "replace")
338
+
339
+
340
+ # --- host lock -----------------------------------------------------------------
341
+
342
+ LOCK_SCRIPT = r"""set -u
343
+ LOCK=$1; ACTION=$2; SESSION=$3; TTL=$4; PURPOSE=$5; FORCE=$6; MINUTES=$7; shift 7
344
+ NOW=$(date +%s)
345
+ exec 9>"$LOCK.guard" || exit 3
346
+ flock -w 10 9 || exit 3
347
+ HOLDER=""; EXPIRES=0
348
+ if [ -f "$LOCK" ]; then HOLDER=$(sed -n 's/^session=//p' "$LOCK"); EXPIRES=$(sed -n 's/^expires=//p' "$LOCK"); fi
349
+ held() { [ -n "$HOLDER" ] && [ "$HOLDER" != "$SESSION" ] && [ "${EXPIRES:-0}" -gt "$NOW" ]; }
350
+ echo "### lock"; [ -f "$LOCK" ] && cat "$LOCK"
351
+ echo "### recent"; find -L "$@" -xdev -type f -mmin "-$MINUTES" 2>/dev/null | head -50
352
+ case "$ACTION" in
353
+ acquire)
354
+ if held; then echo "### result"; echo held; exit 10; fi
355
+ printf 'session=%s\nacquired=%s\nexpires=%s\npurpose=%s\nuser=%s\n' "$SESSION" "$NOW" "$((NOW + TTL))" "$PURPOSE" "$(id -un)" > "$LOCK.tmp" && mv "$LOCK.tmp" "$LOCK"
356
+ echo "### result"; echo acquired;;
357
+ release)
358
+ if held && [ "$FORCE" != 1 ]; then echo "### result"; echo held; exit 10; fi
359
+ rm -f "$LOCK"; echo "### result"; echo released;;
360
+ *) echo "### result"; if held; then echo held; elif [ -n "$HOLDER" ] && [ "${EXPIRES:-0}" -gt "$NOW" ]; then echo mine; else echo free; fi;;
361
+ esac
362
+ """
363
+ WATCH = ["/etc/systemd/system", "/etc/nginx", "/etc/containers/systemd", "/etc/docker"]
364
+
365
+
366
+ def host_lock(action: str, host: str, session: str, ttl: int, purpose: str, force: bool, minutes: int, lock_path: str, watch: list[str], ssh_args: list[str]) -> dict:
367
+ if not SAFE_NAME.match(session):
368
+ raise ValueError("--session must be 1-64 letters, digits, dot, dash or underscore")
369
+ argv = [lock_path, action, session, str(ttl), re.sub(r"[\n\r]", " ", purpose)[:120], "1" if force else "0", str(minutes), *watch]
370
+ if host == "local":
371
+ command = ["bash", "-c", LOCK_SCRIPT, "lock", *argv]
372
+ else:
373
+ command = ["ssh", "-o", "BatchMode=yes", *ssh_args, host, "bash -c " + shlex.quote(LOCK_SCRIPT) + " lock " + " ".join(shlex.quote(a) for a in argv)]
374
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
375
+ if completed.returncode == 3:
376
+ raise OSError("could not take the lock guard (another command is holding it, or the lock folder is not writable)")
377
+ sections = parse_sections(completed.stdout)
378
+ lock = dict(line.split("=", 1) for line in sections.get("lock", "").splitlines() if "=" in line)
379
+ result = sections.get("result", "").strip() or "error"
380
+ if "expires" in lock:
381
+ lock["expiresAt"] = datetime.fromtimestamp(int(lock["expires"]), timezone.utc).isoformat()
382
+ recent = [line for line in sections.get("recent", "").splitlines() if line.strip() and not line.startswith(lock_path)]
383
+ report = {"schemaVersion": "maggie-host-lock.v1", "host": host, "action": action, "session": session, "result": result,
384
+ "lock": lock, "recentChanges": recent, "recentMinutes": minutes,
385
+ "passed": result in ("acquired", "released", "mine", "free")}
386
+ if result == "held":
387
+ report["message"] = f"another session ({lock.get('session')}, {lock.get('purpose') or 'no purpose given'}) holds this host until {lock.get('expiresAt')}; wait, or ask whoever runs it to stop. Do not edit units, containers or nginx meanwhile."
388
+ elif recent:
389
+ report["message"] = f"{len(recent)} file(s) under the watched folders changed in the last {minutes} minutes; make sure each is yours before editing."
390
+ return report
391
+
392
+
393
+ def main(argv: list[str] | None = None) -> int:
394
+ parser = argparse.ArgumentParser(prog="maggie deployment", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
395
+ sub = parser.add_subparsers(dest="command", required=True)
396
+ ssh_parent = argparse.ArgumentParser(add_help=False)
397
+ ssh_parent.add_argument("--ssh-key")
398
+ ssh_parent.add_argument("--ssh-port", default="22")
399
+ ri = sub.add_parser("runtime-inventory", parents=[ssh_parent], help="read-only PHP/runtime inventory of a host, with an advisory check and migration plans")
400
+ source = ri.add_mutually_exclusive_group()
401
+ source.add_argument("--evidence", type=Path, help="collector output saved from the host")
402
+ source.add_argument("--ssh", help="user@host: run the read-only collector over ssh")
403
+ source.add_argument("--print-collector", action="store_true", help="print the read-only collector script and exit")
404
+ ri.add_argument("--advisory", type=Path, help='JSON: {"id": "CVE-...", "component": "php", "fixed": {"8.1": "8.1.31"}, "unsupportedLines": ["7.4"]}')
405
+ ri.add_argument("--latest-tags", type=Path, help='JSON: newest patch per image line, {"php": {"8.2": "8.2.26"}}')
406
+ ri.add_argument("--eol-table", type=Path, help='JSON added to the built-in table, {"ubuntu": {"25.04": "2026-01-15"}}')
407
+ ri.add_argument("--today", help="YYYY-MM-DD (default: today)")
408
+ ri.add_argument("--image", default="php:{version}-fpm", help="migration image; {version} = fixed version, {line} = its line (e.g. wordpress:php{line}-fpm)")
409
+ ri.add_argument("--base-port", type=int, default=9100, help="first 127.0.0.1 port for new php-fpm containers")
410
+ ri.add_argument("--writable", action="append", default=[], help="folder under the site root the container may write (uploads, cache); everything else is mounted read-only")
411
+ hl = sub.add_parser("host-lock", parents=[ssh_parent], help="one agent session at a time on a production host")
412
+ hl.add_argument("action", choices=("acquire", "status", "release"))
413
+ hl.add_argument("--host", required=True, help='ssh target, or "local"')
414
+ hl.add_argument("--session", required=True, help="this agent session's id")
415
+ hl.add_argument("--ttl", type=int, default=3600, help="seconds the lock lasts without renewal")
416
+ hl.add_argument("--purpose", default="", help="what this session is changing (shown to other sessions)")
417
+ hl.add_argument("--force", action="store_true", help="release a lock held by another session (only after confirming it stopped)")
418
+ hl.add_argument("--recent-minutes", type=int, default=30)
419
+ hl.add_argument("--lock-path", default="/run/lock/maggie-agent-host.lock")
420
+ hl.add_argument("--watch", action="append", default=[], help="folders checked for recent changes (default systemd units, nginx, container units)")
421
+ for command in (ri, hl):
422
+ command.add_argument("--output", type=Path)
423
+ args = parser.parse_args(argv)
424
+ ssh_args = []
425
+ if args.ssh_key:
426
+ ssh_args += ["-i", os.path.expanduser(args.ssh_key)]
427
+ if args.ssh_port != "22":
428
+ ssh_args += ["-p", args.ssh_port]
429
+ try:
430
+ if args.command == "runtime-inventory":
431
+ if args.print_collector:
432
+ print(COLLECTOR)
433
+ return 0
434
+ if not args.evidence and not args.ssh:
435
+ raise ValueError("pass --evidence FILE or --ssh user@host")
436
+ text = args.evidence.read_text(encoding="utf-8", errors="replace") if args.evidence else collect_ssh(args.ssh, ssh_args)
437
+ table = {key: dict(value) for key, value in EOL.items()}
438
+ if args.eol_table:
439
+ for key, value in json.loads(args.eol_table.read_text(encoding="utf-8")).items():
440
+ table.setdefault(key, {}).update(value)
441
+ report = inventory(text, json.loads(args.advisory.read_text(encoding="utf-8")) if args.advisory else None,
442
+ json.loads(args.latest_tags.read_text(encoding="utf-8")) if args.latest_tags else {},
443
+ date.fromisoformat(args.today) if args.today else date.today(), table, args.image, args.base_port, args.writable)
444
+ else:
445
+ report = host_lock(args.action, args.host, args.session, args.ttl, args.purpose, args.force, args.recent_minutes, args.lock_path, args.watch or WATCH, ssh_args)
446
+ except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
447
+ print(f"BLOCKED: maggie deployment {args.command}: {error}", file=sys.stderr)
448
+ return 2
449
+ text = json.dumps(report, indent=2, ensure_ascii=False)
450
+ if args.output:
451
+ args.output.parent.mkdir(parents=True, exist_ok=True)
452
+ args.output.write_text(text + "\n", encoding="utf-8")
453
+ print(text)
454
+ return 0 if report["passed"] else 1
455
+
456
+
457
+ if __name__ == "__main__":
458
+ raise SystemExit(main())