@topy-ai/maggie 0.7.31 → 0.7.32
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-zh-TW.md +2 -2
- package/README.md +99 -19
- package/bin/maggie.js +6 -0
- package/bundled-references/google-integrations-runbook.md +26 -0
- package/bundled-skills/maggie-blog/SKILL.md +18 -1
- package/bundled-skills/maggie-dash/SKILL.md +18 -3
- package/bundled-skills/maggie-deployment/SKILL.md +72 -0
- package/bundled-skills/maggie-deployment/references/vps.md +26 -0
- package/bundled-skills/maggie-design/SKILL.md +16 -0
- package/bundled-skills/maggie-feedback/SKILL.md +11 -2
- package/bundled-skills/maggie-seo-geo/SKILL.md +22 -1
- package/bundled-tools/clis/maggie.py +12 -0
- package/bundled-tools/clis/maggie_analytics.py +34 -0
- package/bundled-tools/clis/maggie_blog.py +13 -0
- package/bundled-tools/clis/maggie_dash.py +17 -0
- package/bundled-tools/clis/maggie_deployment.py +91 -7
- package/bundled-tools/clis/maggie_feedback.py +18 -4
- package/bundled-tools/clis/maggie_icon_release_gate.py +79 -0
- package/bundled-tools/clis/maggie_migration.py +71 -0
- package/bundled-tools/clis/maggie_release_manifest.py +81 -0
- package/bundled-tools/clis/maggie_runtime_preflight.py +76 -0
- package/bundled-tools/clis/site_audit.py +78 -5
- package/bundled-tools/integrations/analytics.md +20 -0
- package/bundled-tools/runtime/maggie_blog_publish.py +38 -0
- package/bundled-tools/runtime/maggie_dash_panels.py +111 -0
- package/bundled-tools/runtime/maggie_quality.py +1 -1
- package/bundled-tools/runtime/maggie_sections.py +39 -1
- package/bundled-tools/runtime/site_baseline.py +23 -2
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +26 -0
|
@@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
|
12
12
|
from maggie_blog import BlogStore, review_settings_from_adapter # noqa: E402
|
|
13
13
|
from localization_runner import process_adapter
|
|
14
14
|
from integration_state import integration_state # noqa: E402
|
|
15
|
+
from maggie_blog_publish import validate_auto_publish # noqa: E402
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def main() -> int:
|
|
@@ -28,6 +29,9 @@ def main() -> int:
|
|
|
28
29
|
ingest = sub.add_parser("ingest"); ingest.add_argument("--project", type=Path, default=Path.cwd()); ingest.add_argument("--input", type=Path, required=True); ingest.add_argument("--source", default="local"); ingest.add_argument("--confirm", action="store_true")
|
|
29
30
|
validate = sub.add_parser("validate"); validate.add_argument("--project", type=Path, default=Path.cwd())
|
|
30
31
|
gate = sub.add_parser("check-gate", help="report whether generated content requires review before publication"); gate.add_argument("--project", type=Path, default=Path.cwd()); gate.add_argument("--settings-file", type=Path, help="sanitized host settings JSON"); gate.add_argument("--adapter-command", help="trusted provider JSON argv array that prints sanitized settings JSON"); gate.add_argument("--timeout", type=int, default=120)
|
|
32
|
+
auto_gate = sub.add_parser("auto-publish-gate", help="validate explicit opt-in evidence before an automated publish")
|
|
33
|
+
auto_gate.add_argument("--evidence", type=Path, required=True)
|
|
34
|
+
auto_gate.add_argument("--output", type=Path, required=True)
|
|
31
35
|
approve = sub.add_parser("approve"); approve.add_argument("--project", type=Path, default=Path.cwd()); approve.add_argument("--slug", required=True); approve.add_argument("--actor", required=True); approve.add_argument("--reason", required=True); approve.add_argument("--confirm", action="store_true")
|
|
32
36
|
publish = sub.add_parser("publish"); publish.add_argument("--project", type=Path, default=Path.cwd()); publish.add_argument("--slug", required=True); publish.add_argument("--actor", required=True); publish.add_argument("--reason", required=True); publish.add_argument("--confirm", action="store_true")
|
|
33
37
|
sitemap = sub.add_parser("sitemap"); sitemap.add_argument("--project", type=Path, default=Path.cwd())
|
|
@@ -35,6 +39,15 @@ def main() -> int:
|
|
|
35
39
|
state = sub.add_parser("integration-state"); state.add_argument("--configured", action="store_true"); state.add_argument("--consent-required", action="store_true"); state.add_argument("--consent", action="store_true"); state.add_argument("--authorized", action="store_true"); state.add_argument("--error")
|
|
36
40
|
rollback = sub.add_parser("rollback"); rollback.add_argument("--project", type=Path, default=Path.cwd()); rollback.add_argument("--backup"); rollback.add_argument("--confirm", action="store_true")
|
|
37
41
|
args = parser.parse_args()
|
|
42
|
+
if args.command == "auto-publish-gate":
|
|
43
|
+
try:
|
|
44
|
+
result = validate_auto_publish(json.loads(args.evidence.resolve().read_text(encoding="utf-8")))
|
|
45
|
+
args.output.resolve().parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
args.output.resolve().write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
47
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
48
|
+
print(f"maggie-blog: {error}", file=sys.stderr); return 1
|
|
49
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
50
|
+
return 0 if result["passed"] else 1
|
|
38
51
|
if args.command == "integration-state":
|
|
39
52
|
result = integration_state(configured=args.configured, consent_required=args.consent_required, consent=args.consent, authorized=args.authorized, error=args.error)
|
|
40
53
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
@@ -27,6 +27,7 @@ from maggie_sections import catalogue, remap_translations, section_id_migration,
|
|
|
27
27
|
from maggie_quality import validate_variant_copy, validate_media_uniqueness, classify_inventory, validate_bindings, validate_reconcile_contract # noqa: E402
|
|
28
28
|
from maggie_api_contract import load_api_contract_source, validate_api_contract # noqa: E402
|
|
29
29
|
from maggie_dash_runtime import validate_runtime_evidence # noqa: E402
|
|
30
|
+
from maggie_dash_panels import validate_panel_report # noqa: E402
|
|
30
31
|
from maggie_schema_audit import audit_schema_inventory # noqa: E402
|
|
31
32
|
from route_imports import classify_bindings # noqa: E402
|
|
32
33
|
|
|
@@ -374,6 +375,18 @@ def command_ui(args: argparse.Namespace) -> int:
|
|
|
374
375
|
return 0 if result["passed"] else 1
|
|
375
376
|
|
|
376
377
|
|
|
378
|
+
def command_panels(args: argparse.Namespace) -> int:
|
|
379
|
+
value = json.loads(Path(args.report).resolve().read_text(encoding="utf-8"))
|
|
380
|
+
result = validate_panel_report(value)
|
|
381
|
+
if args.output:
|
|
382
|
+
output = Path(args.output).resolve()
|
|
383
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
385
|
+
result["output"] = str(output)
|
|
386
|
+
emit(result)
|
|
387
|
+
return 0 if result["passed"] else 1
|
|
388
|
+
|
|
389
|
+
|
|
377
390
|
def command_sections(args: argparse.Namespace) -> int:
|
|
378
391
|
if args.sections_command == "remap-translations":
|
|
379
392
|
require_confirm(args)
|
|
@@ -711,6 +724,10 @@ def parser() -> argparse.ArgumentParser:
|
|
|
711
724
|
conformance.add_argument("--evidence", required=True, help="sanitized endpoint observations JSON")
|
|
712
725
|
conformance.add_argument("--output")
|
|
713
726
|
conformance.set_defaults(func=command_conformance)
|
|
727
|
+
panels = sub.add_parser("panels-validate", help="validate dashboard panel source and freshness evidence")
|
|
728
|
+
panels.add_argument("--report", required=True, help="sanitized measurement-panel evidence JSON")
|
|
729
|
+
panels.add_argument("--output")
|
|
730
|
+
panels.set_defaults(func=command_panels)
|
|
714
731
|
schema_audit = sub.add_parser("schema-audit", help="find declared tables with missing reader evidence")
|
|
715
732
|
schema_audit.add_argument("--inventory", required=True, help="host-produced schema inventory JSON")
|
|
716
733
|
schema_audit.add_argument("--fail-on-unread", action="store_true")
|
|
@@ -6,10 +6,77 @@ import argparse
|
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
8
|
import subprocess
|
|
9
|
+
import sys
|
|
9
10
|
from datetime import datetime, timezone
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
DEPLOYMENT_CREDENTIALS = (
|
|
15
|
+
"DEPLOY_SERVER_IP",
|
|
16
|
+
"DEPLOY_SERVER_SSH_USER",
|
|
17
|
+
"DEPLOY_SERVER_SSH_PASSWORD",
|
|
18
|
+
"DEPLOY_SERVER_SSH_SUDOER",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
23
|
+
values: dict[str, str] = {}
|
|
24
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line or line.startswith("#"):
|
|
27
|
+
continue
|
|
28
|
+
if line.startswith("export "):
|
|
29
|
+
line = line[7:].lstrip()
|
|
30
|
+
if "=" not in line:
|
|
31
|
+
continue
|
|
32
|
+
key, value = line.split("=", 1)
|
|
33
|
+
values[key.strip()] = value.strip().strip("\"'")
|
|
34
|
+
return values
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def credential_preflight(env_file: Path, expected_host: str | None = None) -> dict:
|
|
38
|
+
"""Check deployment variable presence without returning secret values."""
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
try:
|
|
41
|
+
values = parse_env_file(env_file)
|
|
42
|
+
except OSError as error:
|
|
43
|
+
values = {}
|
|
44
|
+
errors.append(f"cannot read env file: {error}")
|
|
45
|
+
present = {name: bool(values.get(name, "").strip()) for name in DEPLOYMENT_CREDENTIALS}
|
|
46
|
+
if not all(present.values()):
|
|
47
|
+
errors.append("required deployment credentials are missing from the env file")
|
|
48
|
+
host_guard = {"status": "not_requested", "matches": None}
|
|
49
|
+
if expected_host:
|
|
50
|
+
host_guard = {"status": "passed" if values.get("DEPLOY_SERVER_IP") == expected_host else "failed", "matches": values.get("DEPLOY_SERVER_IP") == expected_host}
|
|
51
|
+
if host_guard["status"] == "failed":
|
|
52
|
+
errors.append("DEPLOY_SERVER_IP does not match --expected-host")
|
|
53
|
+
return {
|
|
54
|
+
"schemaVersion": "maggie-deployment-credentials.v1",
|
|
55
|
+
"envFileProvided": True,
|
|
56
|
+
"credentialSource": "project-env-file",
|
|
57
|
+
"variables": present,
|
|
58
|
+
"hostGuard": host_guard,
|
|
59
|
+
"passed": not errors,
|
|
60
|
+
"errors": errors,
|
|
61
|
+
"mutation": "not executed",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def credential_preflight_main(argv: list[str]) -> int:
|
|
66
|
+
parser = argparse.ArgumentParser(description="Check named deployment credentials without printing their values.")
|
|
67
|
+
parser.add_argument("--env-file", required=True)
|
|
68
|
+
parser.add_argument("--expected-host")
|
|
69
|
+
parser.add_argument("--output")
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
result = credential_preflight(Path(args.env_file).expanduser().resolve(), args.expected_host)
|
|
72
|
+
if args.output:
|
|
73
|
+
output = Path(args.output).resolve()
|
|
74
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
76
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
77
|
+
return 0 if result["passed"] else 1
|
|
78
|
+
|
|
79
|
+
|
|
13
80
|
def preflight(project: Path, target: str, environment: str) -> dict:
|
|
14
81
|
package_path = project / "package.json"
|
|
15
82
|
package = json.loads(package_path.read_text(encoding="utf-8")) if package_path.exists() else {}
|
|
@@ -78,23 +145,37 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
78
145
|
return result
|
|
79
146
|
|
|
80
147
|
|
|
81
|
-
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None) -> dict:
|
|
148
|
+
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None, deployer_user: str = "maggie-deploy") -> dict:
|
|
82
149
|
"""Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
|
|
83
150
|
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
|
|
84
151
|
raise ValueError("domain must be a hostname without a scheme or path")
|
|
85
152
|
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
|
|
86
153
|
raise ValueError("service must contain only safe systemd name characters")
|
|
154
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", deployer_user):
|
|
155
|
+
raise ValueError("deployer user must be a safe local account name")
|
|
87
156
|
if node_port is None:
|
|
88
157
|
raise ValueError("node port must be supplied explicitly; choose an unused port for this host")
|
|
89
158
|
if not 1024 <= node_port <= 65535:
|
|
90
159
|
raise ValueError("node port must be between 1024 and 65535")
|
|
91
160
|
root = release_root.rstrip("/")
|
|
92
161
|
current = f"{root}/current"
|
|
162
|
+
privileged_commands = [
|
|
163
|
+
f"/bin/systemctl restart {service}",
|
|
164
|
+
f"/bin/systemctl is-active {service}",
|
|
165
|
+
]
|
|
93
166
|
return {
|
|
94
167
|
"schema_version": "1.0",
|
|
95
168
|
"provider": "vps-with-cloudflare-dns",
|
|
96
169
|
"domain": domain,
|
|
97
170
|
"service": service,
|
|
171
|
+
"deployer_user": deployer_user,
|
|
172
|
+
"application_user": "www-data",
|
|
173
|
+
"privileged_commands": privileged_commands,
|
|
174
|
+
"sudoers": {
|
|
175
|
+
"user": deployer_user,
|
|
176
|
+
"commands": privileged_commands,
|
|
177
|
+
"policy": "exact service commands only; no shell, wildcard, or broad sudo",
|
|
178
|
+
},
|
|
98
179
|
"release_root": root,
|
|
99
180
|
"current_release": current,
|
|
100
181
|
"node_port": node_port,
|
|
@@ -111,9 +192,9 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
111
192
|
"commands": {
|
|
112
193
|
"stage": "npm ci && npm run typecheck && npm run build",
|
|
113
194
|
"switch": f"ln -sfn {root}/releases/<release> {current}",
|
|
114
|
-
"restart": f"systemctl restart {service}",
|
|
115
|
-
"verify": f"systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
116
|
-
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && systemctl restart {service}",
|
|
195
|
+
"restart": f"sudo -n /bin/systemctl restart {service}",
|
|
196
|
+
"verify": f"sudo -n /bin/systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
197
|
+
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}",
|
|
117
198
|
"data_rollback": "restore the matching data checkpoint before restarting the previous code release",
|
|
118
199
|
},
|
|
119
200
|
"files": {
|
|
@@ -285,8 +366,8 @@ if [ -d "$CURRENT/.agents" ]; then cp -a "$CURRENT/.agents" "$RELEASE/.agents";
|
|
|
285
366
|
if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude"; fi
|
|
286
367
|
|
|
287
368
|
ln -sfn "$RELEASE" "$CURRENT"
|
|
288
|
-
systemctl restart "{service}"
|
|
289
|
-
systemctl is-active --quiet "{service}"
|
|
369
|
+
sudo -n /bin/systemctl restart "{service}"
|
|
370
|
+
sudo -n /bin/systemctl is-active --quiet "{service}"
|
|
290
371
|
curl -fsS "https://{domain}/robots.txt" >/dev/null
|
|
291
372
|
curl -fsS "https://{domain}/sitemap.xml" >/dev/null
|
|
292
373
|
|
|
@@ -307,6 +388,8 @@ def write_release_runner(path: Path, plan: dict) -> None:
|
|
|
307
388
|
|
|
308
389
|
|
|
309
390
|
def main() -> int:
|
|
391
|
+
if sys.argv[1:2] == ["credential-preflight"]:
|
|
392
|
+
return credential_preflight_main(sys.argv[2:])
|
|
310
393
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
311
394
|
parser.add_argument("project", nargs="?", default=".")
|
|
312
395
|
parser.add_argument("--target", choices=("detected", "cloudflare", "vps", "vps-with-cloudflare-dns", "gcp", "aws"), default="detected")
|
|
@@ -317,6 +400,7 @@ def main() -> int:
|
|
|
317
400
|
parser.add_argument("--service", default="maggie-site", help="systemd service name")
|
|
318
401
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
319
402
|
parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
|
|
403
|
+
parser.add_argument("--deployer-user", default="maggie-deploy", help="dedicated least-privilege SSH/deploy account")
|
|
320
404
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
321
405
|
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
322
406
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
@@ -334,7 +418,7 @@ def main() -> int:
|
|
|
334
418
|
if args.vps_plan:
|
|
335
419
|
if not args.domain:
|
|
336
420
|
parser.error("--domain is required with --vps-plan")
|
|
337
|
-
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port)
|
|
421
|
+
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user)
|
|
338
422
|
if args.plan_dir:
|
|
339
423
|
write_vps_plan(Path(args.plan_dir).resolve(), plan)
|
|
340
424
|
if args.runner_output:
|
|
@@ -151,10 +151,20 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
151
151
|
if batch_id:
|
|
152
152
|
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
153
153
|
raise ValueError("batch ID is invalid")
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
batch_base = getattr(args, "batch_base", 0)
|
|
155
|
+
if batch_base not in (0, 1):
|
|
156
|
+
raise ValueError("batch base must be 0 or 1")
|
|
157
|
+
if not isinstance(batch_index, int) or isinstance(batch_index, bool):
|
|
158
|
+
raise ValueError(f"batch index is required and must be {batch_base}-based when batch ID is provided")
|
|
159
|
+
if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1:
|
|
160
|
+
raise ValueError("batch size must be a positive integer")
|
|
161
|
+
if batch_base == 1 and not 1 <= batch_index <= batch_size:
|
|
162
|
+
raise ValueError(f"1-based batch index must be between 1 and {batch_size} inclusive")
|
|
163
|
+
if batch_base == 0 and not 0 <= batch_index < batch_size:
|
|
164
|
+
raise ValueError(f"0-based batch index must be between 0 and {batch_size - 1} inclusive; use --batch-base 1 for 1-based input")
|
|
165
|
+
# Persist one canonical representation so batch review and the hosted
|
|
166
|
+
# API do not have to guess which indexing convention was used.
|
|
167
|
+
batch_index -= batch_base
|
|
158
168
|
priority = safe_text(getattr(args, "priority", "") or "normal").lower()
|
|
159
169
|
if priority not in {"low", "normal", "high", "critical"}:
|
|
160
170
|
raise ValueError("priority must be low, normal, high, or critical")
|
|
@@ -428,6 +438,10 @@ def main() -> int:
|
|
|
428
438
|
collect_parser.add_argument("--batch-id", default="")
|
|
429
439
|
collect_parser.add_argument("--batch-index", type=int)
|
|
430
440
|
collect_parser.add_argument("--batch-size", type=int)
|
|
441
|
+
collect_parser.add_argument(
|
|
442
|
+
"--batch-base", type=int, choices=(0, 1), default=0,
|
|
443
|
+
help="indexing convention for --batch-index (default: 0; use 1 for 1-based input)",
|
|
444
|
+
)
|
|
431
445
|
collect_parser.add_argument("--priority", choices=("low", "normal", "high", "critical"), default="normal")
|
|
432
446
|
collect_parser.add_argument("--affected-cli", default="")
|
|
433
447
|
collect_parser.add_argument("--route-id", dest="route_ids", action="append", default=[])
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate the complete source-to-browser icon release gate."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
13
|
+
errors: list[str] = []
|
|
14
|
+
if not isinstance(icon_report, dict):
|
|
15
|
+
errors.append("icon report must be an object")
|
|
16
|
+
icon_report = {}
|
|
17
|
+
if not isinstance(browser_report, dict):
|
|
18
|
+
errors.append("browser report must be an object")
|
|
19
|
+
browser_report = {}
|
|
20
|
+
source_ok = (
|
|
21
|
+
icon_report.get("schemaVersion") == "maggie-icon-inventory.v1"
|
|
22
|
+
and icon_report.get("status") == "pass"
|
|
23
|
+
and icon_report.get("missing") == []
|
|
24
|
+
and icon_report.get("unknown") == []
|
|
25
|
+
)
|
|
26
|
+
if not source_ok:
|
|
27
|
+
errors.append("source/runtime icon inventory did not pass")
|
|
28
|
+
browser_ok = browser_report.get("schemaVersion") == "maggie-icon-browser-evidence.v1" and browser_report.get("passed") is True and isinstance(browser_report.get("routes"), list) and bool(browser_report["routes"])
|
|
29
|
+
if not browser_ok:
|
|
30
|
+
errors.append("browser icon evidence did not pass")
|
|
31
|
+
icons = browser_report.get("icons") if isinstance(browser_report.get("icons"), list) else []
|
|
32
|
+
if not icons:
|
|
33
|
+
errors.append("browser evidence must include painted icons")
|
|
34
|
+
invalid: list[str] = []
|
|
35
|
+
for index, icon in enumerate(icons):
|
|
36
|
+
name = str(icon.get("name") or f"icon-{index}") if isinstance(icon, dict) else f"icon-{index}"
|
|
37
|
+
if not isinstance(icon, dict) or icon.get("visible") is not True or not isinstance(icon.get("width"), (int, float)) or icon["width"] <= 0 or not isinstance(icon.get("height"), (int, float)) or icon["height"] <= 0 or not str(icon.get("accessibleName") or "").strip():
|
|
38
|
+
invalid.append(name)
|
|
39
|
+
if not isinstance(icon, dict) or not isinstance(icon.get("assetStatus"), int) or not 200 <= icon["assetStatus"] <= 299:
|
|
40
|
+
invalid.append(name)
|
|
41
|
+
if invalid:
|
|
42
|
+
errors.append("browser icon evidence has invisible, zero-size, inaccessible, or unserved glyphs")
|
|
43
|
+
return {
|
|
44
|
+
"schemaVersion": "maggie-icon-release-gate.v1",
|
|
45
|
+
"passed": not errors,
|
|
46
|
+
"errors": sorted(set(errors)),
|
|
47
|
+
"checks": {
|
|
48
|
+
"sourceRuntime": source_ok,
|
|
49
|
+
"servedAssets": browser_ok and not bool(invalid),
|
|
50
|
+
"paintedGlyphs": bool(icons) and not bool(invalid),
|
|
51
|
+
"accessibility": bool(icons) and not bool(invalid),
|
|
52
|
+
},
|
|
53
|
+
"iconCount": len(icons),
|
|
54
|
+
"invalidIcons": sorted(set(invalid)),
|
|
55
|
+
"mutation": "not executed",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
61
|
+
parser.add_argument("--icon-report", required=True)
|
|
62
|
+
parser.add_argument("--browser-report", required=True)
|
|
63
|
+
parser.add_argument("--output", required=True)
|
|
64
|
+
args = parser.parse_args()
|
|
65
|
+
try:
|
|
66
|
+
icon = json.loads(Path(args.icon_report).resolve().read_text(encoding="utf-8"))
|
|
67
|
+
browser = json.loads(Path(args.browser_report).resolve().read_text(encoding="utf-8"))
|
|
68
|
+
result = validate(icon, browser)
|
|
69
|
+
output = Path(args.output).resolve()
|
|
70
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
72
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
73
|
+
result = {"schemaVersion": "maggie-icon-release-gate.v1", "passed": False, "errors": [f"cannot read icon evidence: {error}"], "mutation": "not executed"}
|
|
74
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
75
|
+
return 0 if result["passed"] else 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
raise SystemExit(main())
|
|
@@ -58,6 +58,8 @@ def validate_project(project: Path, environment: str) -> dict:
|
|
|
58
58
|
IDENTITY_SCHEMA = "maggie-database-identity.v1"
|
|
59
59
|
PREFLIGHT_SCHEMA = "maggie-migration-preflight.v1"
|
|
60
60
|
SAFE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,119}$")
|
|
61
|
+
LEDGER_SCHEMA = "maggie-migration-ledger.v1"
|
|
62
|
+
MIGRATION_FILE = re.compile(r"^(\d{1,9})[_-][A-Za-z0-9][A-Za-z0-9_.-]*\.sql$")
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def _identity_fingerprint(identity: dict) -> str:
|
|
@@ -160,11 +162,80 @@ def preflight_main(argv: list[str]) -> int:
|
|
|
160
162
|
print(json.dumps(result, indent=2)); return 0 if not errors else 1
|
|
161
163
|
|
|
162
164
|
|
|
165
|
+
def reconcile_main(argv: list[str]) -> int:
|
|
166
|
+
"""Compare host-reported applied migrations with local migration files."""
|
|
167
|
+
parser = argparse.ArgumentParser(description="Build a read-only ordered plan for migrations missing from the server ledger.")
|
|
168
|
+
parser.add_argument("--evidence", required=True, help="host-produced ledger evidence JSON")
|
|
169
|
+
parser.add_argument("--migration-dir", required=True, help="directory containing numbered .sql migrations")
|
|
170
|
+
parser.add_argument("--output")
|
|
171
|
+
args = parser.parse_args(argv)
|
|
172
|
+
errors: list[str] = []
|
|
173
|
+
try:
|
|
174
|
+
evidence = json.loads(Path(args.evidence).read_text(encoding="utf-8"))
|
|
175
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
176
|
+
evidence = {}
|
|
177
|
+
errors.append(f"cannot read migration ledger evidence: {error}")
|
|
178
|
+
if not isinstance(evidence, dict):
|
|
179
|
+
evidence = {}
|
|
180
|
+
errors.append("migration ledger evidence must be an object")
|
|
181
|
+
if evidence.get("schemaVersion") != LEDGER_SCHEMA:
|
|
182
|
+
errors.append(f"schemaVersion must be {LEDGER_SCHEMA}")
|
|
183
|
+
if evidence.get("migrationTable") != "maggie_schema_migrations":
|
|
184
|
+
errors.append("migrationTable must be maggie_schema_migrations")
|
|
185
|
+
if evidence.get("environment") not in {"development", "staging", "production"}:
|
|
186
|
+
errors.append("environment is invalid")
|
|
187
|
+
applied = evidence.get("appliedVersions")
|
|
188
|
+
if not isinstance(applied, list) or any(not re.fullmatch(r"\d{1,9}", str(item)) for item in applied) or len(set(map(str, applied))) != len(applied):
|
|
189
|
+
errors.append("appliedVersions must be a unique array of numeric versions")
|
|
190
|
+
applied = []
|
|
191
|
+
applied_set = {str(item).zfill(3) for item in applied}
|
|
192
|
+
files: list[tuple[str, str]] = []
|
|
193
|
+
migration_dir = Path(args.migration_dir).resolve()
|
|
194
|
+
try:
|
|
195
|
+
entries = sorted(migration_dir.iterdir())
|
|
196
|
+
except OSError as error:
|
|
197
|
+
entries = []
|
|
198
|
+
errors.append(f"cannot read migration directory: {error}")
|
|
199
|
+
for entry in entries:
|
|
200
|
+
if not entry.is_file():
|
|
201
|
+
continue
|
|
202
|
+
match = MIGRATION_FILE.fullmatch(entry.name)
|
|
203
|
+
if match:
|
|
204
|
+
files.append((match.group(1).zfill(3), entry.name))
|
|
205
|
+
if not files:
|
|
206
|
+
errors.append("migration directory contains no numbered .sql files")
|
|
207
|
+
versions = [version for version, _ in files]
|
|
208
|
+
if len(set(versions)) != len(versions):
|
|
209
|
+
errors.append("migration directory contains duplicate migration versions")
|
|
210
|
+
ordered = [{"version": version, "file": name, "applied": version in applied_set} for version, name in sorted(files)]
|
|
211
|
+
missing = [item for item in ordered if not item["applied"]]
|
|
212
|
+
result = {
|
|
213
|
+
"schemaVersion": "maggie-migration-ledger-result.v1",
|
|
214
|
+
"environment": evidence.get("environment"),
|
|
215
|
+
"migrationTable": evidence.get("migrationTable"),
|
|
216
|
+
"passed": not errors and not missing,
|
|
217
|
+
"errors": errors,
|
|
218
|
+
"appliedVersions": sorted(applied_set),
|
|
219
|
+
"orderedMigrations": ordered,
|
|
220
|
+
"missing": missing,
|
|
221
|
+
"nextAction": "activate only after applying missing migrations in order" if missing else "ledger matches migration directory",
|
|
222
|
+
"mutation": "not executed",
|
|
223
|
+
}
|
|
224
|
+
if args.output:
|
|
225
|
+
output = Path(args.output)
|
|
226
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
228
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
229
|
+
return 0 if result["passed"] else 1
|
|
230
|
+
|
|
231
|
+
|
|
163
232
|
def main() -> int:
|
|
164
233
|
if sys.argv[1:2] == ["preflight"]:
|
|
165
234
|
return preflight_main(sys.argv[2:])
|
|
166
235
|
if sys.argv[1:2] == ["identity"]:
|
|
167
236
|
return identity_main(sys.argv[2:])
|
|
237
|
+
if sys.argv[1:2] == ["reconcile"]:
|
|
238
|
+
return reconcile_main(sys.argv[2:])
|
|
168
239
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
169
240
|
parser.add_argument("manifest", nargs="?")
|
|
170
241
|
parser.add_argument("--project")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate a cross-workflow, secret-free release evidence manifest."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCHEMA = "maggie-release-evidence.v1"
|
|
14
|
+
REQUIRED = ("routes", "migrations", "runtimeChecks", "assets", "icons", "edgeCache", "browser", "rendered", "rollback")
|
|
15
|
+
VERSION = re.compile(r"0\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$")
|
|
16
|
+
COMMIT = re.compile(r"[0-9a-fA-F]{7,64}$")
|
|
17
|
+
SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate(value: object) -> dict[str, Any]:
|
|
21
|
+
errors: list[str] = []
|
|
22
|
+
if not isinstance(value, dict):
|
|
23
|
+
return {"schemaVersion": "maggie-release-manifest.v1", "passed": False, "errors": ["evidence must be an object"], "mutation": "not executed"}
|
|
24
|
+
if value.get("schemaVersion") != SCHEMA:
|
|
25
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
26
|
+
release_id = value.get("releaseId")
|
|
27
|
+
if not isinstance(release_id, str) or not SAFE_ID.fullmatch(release_id):
|
|
28
|
+
errors.append("releaseId must be a safe identifier")
|
|
29
|
+
commit = value.get("commit")
|
|
30
|
+
if not isinstance(commit, str) or not COMMIT.fullmatch(commit):
|
|
31
|
+
errors.append("commit must be a git SHA")
|
|
32
|
+
version = value.get("maggieVersion")
|
|
33
|
+
if not isinstance(version, str) or not VERSION.fullmatch(version):
|
|
34
|
+
errors.append("maggieVersion must remain on the 0.x release line")
|
|
35
|
+
evidence: dict[str, Any] = {}
|
|
36
|
+
for name in REQUIRED:
|
|
37
|
+
item = value.get(name)
|
|
38
|
+
if not isinstance(item, dict):
|
|
39
|
+
errors.append(f"{name} evidence must be an object")
|
|
40
|
+
continue
|
|
41
|
+
if item.get("passed") is not True:
|
|
42
|
+
errors.append(f"{name}.passed must be true")
|
|
43
|
+
fingerprint = item.get("fingerprint")
|
|
44
|
+
if name in {"routes", "assets", "icons"} and (not isinstance(fingerprint, str) or not SAFE_ID.fullmatch(fingerprint)):
|
|
45
|
+
errors.append(f"{name}.fingerprint must be a safe release fingerprint")
|
|
46
|
+
evidence[name] = {
|
|
47
|
+
"passed": item.get("passed") is True,
|
|
48
|
+
"fingerprint": fingerprint if isinstance(fingerprint, str) else None,
|
|
49
|
+
"release": str(item.get("release") or "") if name in {"edgeCache", "browser", "rendered", "rollback"} else None,
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
"schemaVersion": "maggie-release-manifest.v1",
|
|
53
|
+
"releaseId": release_id if isinstance(release_id, str) else None,
|
|
54
|
+
"commit": commit if isinstance(commit, str) else None,
|
|
55
|
+
"maggieVersion": version if isinstance(version, str) else None,
|
|
56
|
+
"passed": not errors,
|
|
57
|
+
"errors": sorted(set(errors)),
|
|
58
|
+
"evidence": evidence,
|
|
59
|
+
"mutation": "not executed",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
65
|
+
parser.add_argument("--evidence", required=True, help="secret-free cross-workflow evidence JSON")
|
|
66
|
+
parser.add_argument("--output", required=True, help="normalized release manifest output")
|
|
67
|
+
args = parser.parse_args()
|
|
68
|
+
try:
|
|
69
|
+
value = json.loads(Path(args.evidence).resolve().read_text(encoding="utf-8"))
|
|
70
|
+
result = validate(value)
|
|
71
|
+
output = Path(args.output).resolve()
|
|
72
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
74
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
75
|
+
result = {"schemaVersion": "maggie-release-manifest.v1", "passed": False, "errors": [f"cannot read release evidence: {error}"], "mutation": "not executed"}
|
|
76
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
77
|
+
return 0 if result["passed"] else 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Classify deployment runtime checks with actionable remediation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SCHEMA = "maggie-runtime-preflight.v1"
|
|
13
|
+
REQUIRED = ("dependencies", "browser", "worker", "database", "routes")
|
|
14
|
+
STATUSES = {"pass", "missing", "incompatible", "misconfigured", "insufficient", "mismatch", "unknown"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate(value: object) -> dict[str, Any]:
|
|
18
|
+
errors: list[str] = []
|
|
19
|
+
checks: dict[str, dict[str, str]] = {}
|
|
20
|
+
if not isinstance(value, dict):
|
|
21
|
+
return {"schemaVersion": "maggie-runtime-preflight-result.v1", "passed": False, "errors": ["evidence must be an object"], "checks": {}, "mutation": "not executed"}
|
|
22
|
+
if value.get("schemaVersion") != SCHEMA:
|
|
23
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
24
|
+
if value.get("environment") not in {"development", "staging", "production"}:
|
|
25
|
+
errors.append("environment is invalid")
|
|
26
|
+
input_checks = value.get("checks")
|
|
27
|
+
if not isinstance(input_checks, dict):
|
|
28
|
+
input_checks = {}
|
|
29
|
+
errors.append("checks must be an object")
|
|
30
|
+
for name in REQUIRED:
|
|
31
|
+
item = input_checks.get(name)
|
|
32
|
+
if not isinstance(item, dict):
|
|
33
|
+
errors.append(f"checks.{name} must be an object")
|
|
34
|
+
checks[name] = {"status": "unknown", "remediation": "Collect this host check before deployment."}
|
|
35
|
+
continue
|
|
36
|
+
status = item.get("status")
|
|
37
|
+
remediation = str(item.get("remediation") or "").strip()
|
|
38
|
+
if status not in STATUSES:
|
|
39
|
+
errors.append(f"checks.{name}.status is unsupported")
|
|
40
|
+
status = "unknown"
|
|
41
|
+
if not remediation:
|
|
42
|
+
errors.append(f"checks.{name}.remediation is required")
|
|
43
|
+
checks[name] = {"status": status, "remediation": remediation}
|
|
44
|
+
if status != "pass" and not remediation:
|
|
45
|
+
errors.append(f"checks.{name} needs actionable remediation")
|
|
46
|
+
failed = sorted(name for name, item in checks.items() if item["status"] != "pass")
|
|
47
|
+
return {
|
|
48
|
+
"schemaVersion": "maggie-runtime-preflight-result.v1",
|
|
49
|
+
"environment": value.get("environment"),
|
|
50
|
+
"passed": not errors and not failed,
|
|
51
|
+
"errors": sorted(set(errors)),
|
|
52
|
+
"checks": checks,
|
|
53
|
+
"failedChecks": failed,
|
|
54
|
+
"mutation": "not executed",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
60
|
+
parser.add_argument("--evidence", required=True)
|
|
61
|
+
parser.add_argument("--output", required=True)
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
try:
|
|
64
|
+
value = json.loads(Path(args.evidence).resolve().read_text(encoding="utf-8"))
|
|
65
|
+
result = validate(value)
|
|
66
|
+
output = Path(args.output).resolve()
|
|
67
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
69
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
70
|
+
result = {"schemaVersion": "maggie-runtime-preflight-result.v1", "passed": False, "errors": [f"cannot read runtime evidence: {error}"], "checks": {}, "mutation": "not executed"}
|
|
71
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
72
|
+
return 0 if result["passed"] else 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
raise SystemExit(main())
|