@topy-ai/maggie 0.7.47 → 0.7.49
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 +76 -15
- package/bin/maggie.js +23 -6
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +115 -5
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run declared, role-scoped authenticated API canaries without storing credentials or bodies.
|
|
3
|
+
|
|
4
|
+
Public route canaries cannot see authenticated failures such as database
|
|
5
|
+
permission errors, schema mismatches, or 403s on account and business APIs.
|
|
6
|
+
This command executes a reviewed probe plan with credentials read only from
|
|
7
|
+
environment variable *names*, and records status codes and contract checks
|
|
8
|
+
only: never cookies, tokens, headers, response bodies, or personal data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
PLAN_SCHEMA = "maggie-auth-canary-plan.v1"
|
|
25
|
+
EVIDENCE_SCHEMA = "maggie-deployment-auth-canary.v1"
|
|
26
|
+
MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
|
27
|
+
ALLOWED_METHODS = {"GET", "HEAD", "OPTIONS"} | MUTATING
|
|
28
|
+
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
29
|
+
ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
|
|
30
|
+
SECRET_VALUE = re.compile(r"(?:Bearer\s+\S{8,}|eyJ[A-Za-z0-9_-]{10,}\.|sk[-_](?:live|test)[-_]|session=|sid=|[A-Fa-f0-9]{32,})")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PlanError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify(status: int | None) -> str:
|
|
38
|
+
if status is None:
|
|
39
|
+
return "network"
|
|
40
|
+
if status in (401, 403):
|
|
41
|
+
return "authorization"
|
|
42
|
+
if status >= 500:
|
|
43
|
+
return "server"
|
|
44
|
+
if status == 404:
|
|
45
|
+
return "route"
|
|
46
|
+
return "contract"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def contains_literal_secret(value: object) -> bool:
|
|
50
|
+
if isinstance(value, dict):
|
|
51
|
+
return any(contains_literal_secret(item) for item in value.values())
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return any(contains_literal_secret(item) for item in value)
|
|
54
|
+
return isinstance(value, str) and bool(SECRET_VALUE.search(value))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_plan(plan: object) -> dict:
|
|
58
|
+
"""Return the normalized plan or raise PlanError for an unsafe/invalid plan."""
|
|
59
|
+
if not isinstance(plan, dict) or plan.get("schemaVersion") != PLAN_SCHEMA:
|
|
60
|
+
raise PlanError(f"plan schemaVersion must be {PLAN_SCHEMA}")
|
|
61
|
+
if contains_literal_secret(plan):
|
|
62
|
+
raise PlanError("plan contains a literal credential; reference environment variable names only")
|
|
63
|
+
base = str(plan.get("baseUrl") or "")
|
|
64
|
+
parsed = urlparse(base)
|
|
65
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.query or parsed.fragment:
|
|
66
|
+
raise PlanError("baseUrl must be an http(s) origin without query or fragment")
|
|
67
|
+
if parsed.scheme == "http" and parsed.hostname not in LOCAL_HOSTS:
|
|
68
|
+
raise PlanError("baseUrl must use https outside localhost")
|
|
69
|
+
roles = plan.get("roles")
|
|
70
|
+
if not isinstance(roles, dict) or not roles:
|
|
71
|
+
raise PlanError("roles must declare at least one role")
|
|
72
|
+
for name, role in roles.items():
|
|
73
|
+
if not isinstance(role, dict):
|
|
74
|
+
raise PlanError(f"role {name} must be an object")
|
|
75
|
+
keys = [key for key in ("tokenEnv", "cookieEnv") if role.get(key)]
|
|
76
|
+
if len(keys) != 1:
|
|
77
|
+
raise PlanError(f"role {name} must declare exactly one of tokenEnv or cookieEnv")
|
|
78
|
+
if not ENV_NAME.fullmatch(str(role[keys[0]])):
|
|
79
|
+
raise PlanError(f"role {name} {keys[0]} must be an environment variable name")
|
|
80
|
+
unknown = set(role) - {"tokenEnv", "cookieEnv", "scheme", "description"}
|
|
81
|
+
if unknown:
|
|
82
|
+
raise PlanError(f"role {name} has unsupported fields: {', '.join(sorted(unknown))}")
|
|
83
|
+
probes = plan.get("probes")
|
|
84
|
+
if not isinstance(probes, list) or not probes:
|
|
85
|
+
raise PlanError("probes must be a non-empty list")
|
|
86
|
+
seen = set()
|
|
87
|
+
for probe in probes:
|
|
88
|
+
if not isinstance(probe, dict):
|
|
89
|
+
raise PlanError("each probe must be an object")
|
|
90
|
+
probe_id = str(probe.get("id") or "")
|
|
91
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,63}", probe_id) or probe_id in seen:
|
|
92
|
+
raise PlanError(f"probe id is missing, invalid, or duplicated: {probe_id or '<empty>'}")
|
|
93
|
+
seen.add(probe_id)
|
|
94
|
+
if probe.get("role") not in roles:
|
|
95
|
+
raise PlanError(f"probe {probe_id} references an undeclared role")
|
|
96
|
+
method = str(probe.get("method", "GET")).upper()
|
|
97
|
+
if method not in ALLOWED_METHODS:
|
|
98
|
+
raise PlanError(f"probe {probe_id} method is unsupported")
|
|
99
|
+
path = str(probe.get("path") or "")
|
|
100
|
+
if not path.startswith("/") or "//" in path or "?" in path or "#" in path:
|
|
101
|
+
raise PlanError(f"probe {probe_id} path must be an absolute path without query or fragment")
|
|
102
|
+
expect = probe.get("expectStatus", [200])
|
|
103
|
+
if not isinstance(expect, list) or not expect or not all(isinstance(code, int) and 100 <= code <= 599 for code in expect):
|
|
104
|
+
raise PlanError(f"probe {probe_id} expectStatus must be a list of HTTP status codes")
|
|
105
|
+
if method in MUTATING and probe.get("fixture") != "disposable":
|
|
106
|
+
raise PlanError(f"probe {probe_id} mutates state and must declare fixture: disposable")
|
|
107
|
+
keys = probe.get("expectJsonKeys", [])
|
|
108
|
+
if not isinstance(keys, list) or not all(isinstance(key, str) and key for key in keys):
|
|
109
|
+
raise PlanError(f"probe {probe_id} expectJsonKeys must be a list of key names")
|
|
110
|
+
return plan
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def credential_headers(role: dict) -> dict[str, str] | None:
|
|
114
|
+
if role.get("tokenEnv"):
|
|
115
|
+
value = os.environ.get(role["tokenEnv"], "")
|
|
116
|
+
return {"Authorization": f"{role.get('scheme', 'Bearer')} {value}"} if value else None
|
|
117
|
+
value = os.environ.get(role["cookieEnv"], "")
|
|
118
|
+
return {"Cookie": value} if value else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_probe(base: str, probe: dict, role: dict, timeout: float) -> dict:
|
|
122
|
+
method = str(probe.get("method", "GET")).upper()
|
|
123
|
+
record = {"id": probe["id"], "role": probe["role"], "method": method, "path": probe["path"], "expectStatus": probe.get("expectStatus", [200])}
|
|
124
|
+
if probe.get("fixture"):
|
|
125
|
+
record["fixture"] = probe["fixture"]
|
|
126
|
+
headers = credential_headers(role)
|
|
127
|
+
if headers is None:
|
|
128
|
+
record.update({"state": "inconclusive", "reason": "credential environment variable is not set"})
|
|
129
|
+
return record
|
|
130
|
+
headers["User-Agent"] = "maggie-deployment-auth-canary/1.0"
|
|
131
|
+
headers["Accept"] = "application/json"
|
|
132
|
+
data = None
|
|
133
|
+
if method in MUTATING:
|
|
134
|
+
headers["Content-Type"] = "application/json"
|
|
135
|
+
data = json.dumps(probe.get("body", {})).encode("utf-8")
|
|
136
|
+
request = urllib.request.Request(base.rstrip("/") + probe["path"], data=data, headers=headers, method=method)
|
|
137
|
+
status: int | None = None
|
|
138
|
+
body = b""
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
141
|
+
status = response.status
|
|
142
|
+
body = response.read(1_000_000)
|
|
143
|
+
except urllib.error.HTTPError as error:
|
|
144
|
+
status = error.code
|
|
145
|
+
except (urllib.error.URLError, OSError) as error:
|
|
146
|
+
record.update({"state": "failed", "status": None, "failureClass": "network", "reason": type(error).__name__})
|
|
147
|
+
return record
|
|
148
|
+
record["status"] = status
|
|
149
|
+
errors = []
|
|
150
|
+
if status not in record["expectStatus"]:
|
|
151
|
+
errors.append(f"status {status} not in {record['expectStatus']}")
|
|
152
|
+
keys = probe.get("expectJsonKeys", [])
|
|
153
|
+
if keys and not errors:
|
|
154
|
+
try:
|
|
155
|
+
payload = json.loads(body.decode("utf-8") or "null")
|
|
156
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
157
|
+
payload = None
|
|
158
|
+
if not isinstance(payload, dict):
|
|
159
|
+
errors.append("response is not a JSON object")
|
|
160
|
+
else:
|
|
161
|
+
missing = [key for key in keys if key not in payload]
|
|
162
|
+
record["contractKeys"] = {"expected": len(keys), "present": len(keys) - len(missing)}
|
|
163
|
+
if missing:
|
|
164
|
+
errors.append("missing contract keys: " + ", ".join(missing))
|
|
165
|
+
if errors:
|
|
166
|
+
record.update({"state": "failed", "failureClass": classify(status) if status not in record["expectStatus"] else "contract", "reason": "; ".join(errors)})
|
|
167
|
+
else:
|
|
168
|
+
record["state"] = "passed"
|
|
169
|
+
return record
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def auth_canary(plan: dict, allow_mutations: bool, timeout: float = 15.0) -> dict:
|
|
173
|
+
plan = validate_plan(plan)
|
|
174
|
+
results = []
|
|
175
|
+
for probe in plan["probes"]:
|
|
176
|
+
method = str(probe.get("method", "GET")).upper()
|
|
177
|
+
if method in MUTATING and not allow_mutations:
|
|
178
|
+
results.append({"id": probe["id"], "role": probe["role"], "method": method, "path": probe["path"], "fixture": probe.get("fixture"), "state": "blocked", "reason": "mutating probe requires --allow-mutations"})
|
|
179
|
+
continue
|
|
180
|
+
results.append(run_probe(plan["baseUrl"], probe, plan["roles"][probe["role"]], timeout))
|
|
181
|
+
states = {item["state"] for item in results}
|
|
182
|
+
status = "failed" if "failed" in states else "inconclusive" if states & {"inconclusive", "blocked"} else "passed"
|
|
183
|
+
return {
|
|
184
|
+
"schemaVersion": EVIDENCE_SCHEMA,
|
|
185
|
+
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
186
|
+
"origin": urlparse(plan["baseUrl"]).netloc,
|
|
187
|
+
"status": status,
|
|
188
|
+
"passed": status == "passed",
|
|
189
|
+
"roles": sorted(plan["roles"]),
|
|
190
|
+
"probes": results,
|
|
191
|
+
"privacy": "status codes and contract-key counts only; no credentials, headers, bodies, or personal data",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> int:
|
|
196
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
197
|
+
parser.add_argument("--plan", type=Path, required=True, help=f"{PLAN_SCHEMA} probe plan")
|
|
198
|
+
parser.add_argument("--allow-mutations", action="store_true", help="run probes that mutate disposable fixtures")
|
|
199
|
+
parser.add_argument("--timeout", type=float, default=15.0)
|
|
200
|
+
parser.add_argument("--output", type=Path)
|
|
201
|
+
args = parser.parse_args()
|
|
202
|
+
try:
|
|
203
|
+
report = auth_canary(json.loads(args.plan.read_text(encoding="utf-8")), args.allow_mutations, args.timeout)
|
|
204
|
+
except (OSError, json.JSONDecodeError, PlanError) as error:
|
|
205
|
+
print(f"BLOCKED: maggie deployment auth-canary: {error}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
encoded = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
|
|
208
|
+
if args.output:
|
|
209
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
args.output.write_text(encoded, encoding="utf-8")
|
|
211
|
+
print(encoded, end="")
|
|
212
|
+
return 0 if report["passed"] else 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Collect standard host release evidence and emit one reproducible readiness report.
|
|
3
|
+
|
|
4
|
+
`maggie deployment readiness` validates evidence files but does not produce
|
|
5
|
+
them, so each host used to hand-roll browser, SSH, systemd, and retention
|
|
6
|
+
scripts. This collector runs the standard adapters declared in one reviewed
|
|
7
|
+
config (edge/rendered canary, host health, rollback consistency, remote
|
|
8
|
+
release identity, optional authenticated API canary), writes each adapter's
|
|
9
|
+
evidence, and summarizes everything with the existing
|
|
10
|
+
`maggie-deployment-readiness.v1` report. It never deploys, never uses a
|
|
11
|
+
shell, and never records credentials or response bodies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import hashlib
|
|
18
|
+
import importlib.util
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
CONFIG_SCHEMA = "maggie-evidence-collector.v1"
|
|
31
|
+
HERE = Path(__file__).resolve().parent
|
|
32
|
+
STANDARD_EVIDENCE = {
|
|
33
|
+
"unitRegression": ".maggie/verification/unit-regression.json",
|
|
34
|
+
"packageSmoke": ".maggie/verification/package-smoke.json",
|
|
35
|
+
"browserEvidence": ".maggie/verification/browser-evidence.json",
|
|
36
|
+
"deploymentPreflight": ".maggie/release-preflight.json",
|
|
37
|
+
}
|
|
38
|
+
SECRET_ARG = re.compile(r"(?:password|passwd|token|secret|apikey|api_key)=|Bearer\s|sk[-_](?:live|test)[-_]|eyJ[A-Za-z0-9_-]{10,}\.", re.IGNORECASE)
|
|
39
|
+
RELEASE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_module(name: str):
|
|
43
|
+
spec = importlib.util.spec_from_file_location(name, HERE / f"{name}.py")
|
|
44
|
+
assert spec and spec.loader
|
|
45
|
+
module = importlib.util.module_from_spec(spec)
|
|
46
|
+
spec.loader.exec_module(module)
|
|
47
|
+
return module
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def now() -> str:
|
|
51
|
+
return datetime.now(timezone.utc).isoformat()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve(project: Path, value: str | Path) -> Path:
|
|
55
|
+
path = Path(value).expanduser()
|
|
56
|
+
return path if path.is_absolute() else project / path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write(path: Path, payload: dict) -> None:
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_argv(argv: object, label: str) -> list[str]:
|
|
65
|
+
if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item for item in argv):
|
|
66
|
+
raise ValueError(f"{label} command must be a non-empty argv list (no shell string)")
|
|
67
|
+
if any(SECRET_ARG.search(item) for item in argv):
|
|
68
|
+
raise ValueError(f"{label} command contains a literal credential; use host configuration or SSH keys")
|
|
69
|
+
return argv
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def http_status(url: str, timeout: float) -> int | None:
|
|
73
|
+
request = urllib.request.Request(url, headers={"User-Agent": "maggie-deployment-collect/1.0"}, method="GET")
|
|
74
|
+
try:
|
|
75
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
76
|
+
return response.status
|
|
77
|
+
except urllib.error.HTTPError as error:
|
|
78
|
+
return error.code
|
|
79
|
+
except (urllib.error.URLError, OSError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def collect_health(config: dict, timeout: float) -> dict:
|
|
84
|
+
urls = config.get("urls")
|
|
85
|
+
if not isinstance(urls, list) or not urls:
|
|
86
|
+
raise ValueError("health adapter needs a non-empty urls list")
|
|
87
|
+
expect = config.get("expectStatus", [200])
|
|
88
|
+
checks = []
|
|
89
|
+
for url in urls:
|
|
90
|
+
parsed = urlparse(str(url))
|
|
91
|
+
if parsed.scheme not in {"http", "https"} or parsed.query:
|
|
92
|
+
raise ValueError("health URLs must be http(s) without query strings")
|
|
93
|
+
status = http_status(str(url), timeout)
|
|
94
|
+
checks.append({"url": f"{parsed.scheme}://{parsed.netloc}{parsed.path or '/'}", "status": status, "passed": status in expect})
|
|
95
|
+
return {"schemaVersion": "maggie-deployment-health.v1", "generatedAt": now(), "checks": checks, "passed": all(item["passed"] for item in checks)}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def collect_release_identity(config: dict, release: str, timeout: float) -> tuple[dict, dict]:
|
|
99
|
+
run: dict[str, Any] = {}
|
|
100
|
+
observed = ""
|
|
101
|
+
if config.get("command"):
|
|
102
|
+
argv = check_argv(config["command"], "releaseIdentity")
|
|
103
|
+
completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
|
|
104
|
+
run = {"argv": argv, "exitCode": completed.returncode}
|
|
105
|
+
raw = completed.stdout.strip().splitlines()[-1].strip() if completed.returncode == 0 and completed.stdout.strip() else ""
|
|
106
|
+
# A `readlink -f <current>` path resolves to .../releases/<release-id>.
|
|
107
|
+
observed = raw.rstrip("/").rsplit("/", 1)[-1] if raw else ""
|
|
108
|
+
elif config.get("url"):
|
|
109
|
+
url = str(config["url"])
|
|
110
|
+
field = str(config.get("field") or "release")
|
|
111
|
+
request = urllib.request.Request(url, headers={"User-Agent": "maggie-deployment-collect/1.0", "Accept": "application/json"})
|
|
112
|
+
try:
|
|
113
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
114
|
+
payload = json.loads(response.read(200_000).decode("utf-8"))
|
|
115
|
+
observed = str(payload.get(field, "")) if isinstance(payload, dict) else ""
|
|
116
|
+
run = {"url": url, "field": field}
|
|
117
|
+
except (urllib.error.URLError, OSError, ValueError) as error:
|
|
118
|
+
run = {"url": url, "field": field, "error": type(error).__name__}
|
|
119
|
+
else:
|
|
120
|
+
raise ValueError("releaseIdentity adapter needs command (argv) or url")
|
|
121
|
+
if observed and not RELEASE_ID.fullmatch(observed):
|
|
122
|
+
observed = "<unrecognized>"
|
|
123
|
+
evidence = {"schemaVersion": "maggie-release-identity.v1", "generatedAt": now(), "expected": release, "observed": observed, "passed": bool(observed) and observed == release}
|
|
124
|
+
return evidence, run
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def collect_rollback(project: Path, config: dict) -> dict:
|
|
128
|
+
deployment = load_module("maggie_deployment")
|
|
129
|
+
evidence_path = resolve(project, config.get("evidence", "docs/deployment-rollback-smoke.json"))
|
|
130
|
+
keep = None
|
|
131
|
+
if config.get("plan"):
|
|
132
|
+
plan = json.loads(resolve(project, config["plan"]).read_text(encoding="utf-8"))
|
|
133
|
+
keep = (plan.get("retention") or {}).get("keep")
|
|
134
|
+
try:
|
|
135
|
+
result = deployment.validate_rollback_evidence(json.loads(evidence_path.read_text(encoding="utf-8")), keep)
|
|
136
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
137
|
+
result = {"passed": False, "errors": [f"rollback evidence unavailable: {type(error).__name__}"]}
|
|
138
|
+
return {"schemaVersion": "maggie-deployment-rollback-check.v1", "generatedAt": now(), "retentionKeep": keep, "rollbackAvailable": None if keep is None else keep >= 2, "passed": result["passed"], "errors": result["errors"]}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def collect_canary(project: Path, config: dict, destination: Path, timeout: float) -> dict:
|
|
142
|
+
argv = [sys.executable, str(HERE / "maggie_deployment_canary.py"), "--project", str(project), "--output", str(destination)]
|
|
143
|
+
for asset in config.get("assets", []):
|
|
144
|
+
argv += ["--asset", str(asset)]
|
|
145
|
+
if config.get("renderReport"):
|
|
146
|
+
argv += ["--render-report", str(resolve(project, config["renderReport"]))]
|
|
147
|
+
completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
|
|
148
|
+
return {"adapter": "maggie_deployment_canary.py", "exitCode": completed.returncode}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def collect(project: Path, config: dict, evidence_dir: Path, timeout: float = 60.0) -> dict:
|
|
152
|
+
if config.get("schemaVersion") != CONFIG_SCHEMA:
|
|
153
|
+
raise ValueError(f"collector config schemaVersion must be {CONFIG_SCHEMA}")
|
|
154
|
+
release = str(config.get("release") or "")
|
|
155
|
+
if not RELEASE_ID.fullmatch(release):
|
|
156
|
+
raise ValueError("collector config needs the expected release id")
|
|
157
|
+
adapters = config.get("adapters") or {}
|
|
158
|
+
unknown = set(adapters) - {"canary", "health", "rollback", "releaseIdentity", "authCanary"}
|
|
159
|
+
if unknown:
|
|
160
|
+
raise ValueError("unsupported adapters: " + ", ".join(sorted(unknown)))
|
|
161
|
+
readiness = load_module("maggie_deployment_readiness")
|
|
162
|
+
paths = {name: resolve(project, (config.get("evidence") or {}).get(name, default)) for name, default in STANDARD_EVIDENCE.items()}
|
|
163
|
+
runs: dict[str, Any] = {}
|
|
164
|
+
canary_path = evidence_dir / "rendered-canary.json"
|
|
165
|
+
if "canary" in adapters:
|
|
166
|
+
runs["canary"] = collect_canary(project, adapters["canary"], canary_path, timeout)
|
|
167
|
+
paths["renderedCanary"] = canary_path
|
|
168
|
+
else:
|
|
169
|
+
paths["renderedCanary"] = resolve(project, (config.get("evidence") or {}).get("renderedCanary", ".maggie/deployment-canary.json"))
|
|
170
|
+
if "health" in adapters:
|
|
171
|
+
write(evidence_dir / "host-health.json", collect_health(adapters["health"], timeout))
|
|
172
|
+
paths["hostHealth"] = evidence_dir / "host-health.json"
|
|
173
|
+
runs["health"] = {"urls": len(adapters["health"].get("urls", []))}
|
|
174
|
+
if "rollback" in adapters:
|
|
175
|
+
write(evidence_dir / "rollback-check.json", collect_rollback(project, adapters["rollback"]))
|
|
176
|
+
paths["rollbackEvidence"] = evidence_dir / "rollback-check.json"
|
|
177
|
+
runs["rollback"] = {"validator": "validate_rollback_evidence"}
|
|
178
|
+
if "releaseIdentity" in adapters:
|
|
179
|
+
evidence, run = collect_release_identity(adapters["releaseIdentity"], release, timeout)
|
|
180
|
+
write(evidence_dir / "release-identity.json", evidence)
|
|
181
|
+
paths["releaseIdentity"] = evidence_dir / "release-identity.json"
|
|
182
|
+
runs["releaseIdentity"] = run
|
|
183
|
+
if "authCanary" in adapters:
|
|
184
|
+
auth = load_module("maggie_deployment_auth_canary")
|
|
185
|
+
spec = adapters["authCanary"]
|
|
186
|
+
plan = json.loads(resolve(project, spec["plan"]).read_text(encoding="utf-8"))
|
|
187
|
+
write(evidence_dir / "auth-canary.json", auth.auth_canary(plan, bool(spec.get("allowMutations")), timeout))
|
|
188
|
+
paths["authenticatedCanary"] = evidence_dir / "auth-canary.json"
|
|
189
|
+
runs["authCanary"] = {"plan": str(spec["plan"]), "allowMutations": bool(spec.get("allowMutations"))}
|
|
190
|
+
report = readiness.readiness_report(project, paths)
|
|
191
|
+
canonical = json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
192
|
+
report["collector"] = {
|
|
193
|
+
"schemaVersion": CONFIG_SCHEMA,
|
|
194
|
+
"configSha256": hashlib.sha256(canonical).hexdigest(),
|
|
195
|
+
"release": release,
|
|
196
|
+
"evidenceDir": readiness.display_path(evidence_dir, project),
|
|
197
|
+
"adapters": runs,
|
|
198
|
+
"mutation": "not executed",
|
|
199
|
+
}
|
|
200
|
+
return report
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def main() -> int:
|
|
204
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
205
|
+
parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
206
|
+
parser.add_argument("--config", type=Path, default=Path(".maggie/deployment/collector.json"), help=f"{CONFIG_SCHEMA} config")
|
|
207
|
+
parser.add_argument("--evidence-dir", type=Path, default=Path(".maggie/deployment/collected"))
|
|
208
|
+
parser.add_argument("--timeout", type=float, default=60.0)
|
|
209
|
+
parser.add_argument("--output", type=Path, help="readiness report path (default: <evidence-dir>/readiness.json)")
|
|
210
|
+
args = parser.parse_args()
|
|
211
|
+
project = args.project.resolve()
|
|
212
|
+
evidence_dir = resolve(project, args.evidence_dir)
|
|
213
|
+
try:
|
|
214
|
+
config = json.loads(resolve(project, args.config).read_text(encoding="utf-8"))
|
|
215
|
+
report = collect(project, config, evidence_dir, args.timeout)
|
|
216
|
+
except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
|
|
217
|
+
print(f"BLOCKED: maggie deployment collect: {error}", file=sys.stderr)
|
|
218
|
+
return 2
|
|
219
|
+
write(resolve(project, args.output) if args.output else evidence_dir / "readiness.json", report)
|
|
220
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
221
|
+
return 0 if report["status"] == "passed" else 1
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
raise SystemExit(main())
|
|
@@ -92,6 +92,39 @@ def validate_runtime_parity(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
|
92
92
|
return True, "worker/browser runtime parity passed"
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def validate_host_health(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
96
|
+
if payload.get("schemaVersion") != "maggie-deployment-health.v1":
|
|
97
|
+
return False, "host health evidence schemaVersion is unsupported"
|
|
98
|
+
checks = payload.get("checks")
|
|
99
|
+
if payload.get("passed") is not True or not isinstance(checks, list) or not checks:
|
|
100
|
+
return False, "host health evidence must contain passing checks"
|
|
101
|
+
return True, "host health checks passed"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate_release_identity(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
105
|
+
if payload.get("schemaVersion") != "maggie-release-identity.v1":
|
|
106
|
+
return False, "release identity evidence schemaVersion is unsupported"
|
|
107
|
+
if payload.get("passed") is not True or not payload.get("expected") or payload.get("observed") != payload.get("expected"):
|
|
108
|
+
return False, "remote release identity does not match the expected release"
|
|
109
|
+
return True, "remote release identity matches"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_rollback_check(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
113
|
+
if payload.get("schemaVersion") != "maggie-deployment-rollback-check.v1":
|
|
114
|
+
return False, "rollback check schemaVersion is unsupported"
|
|
115
|
+
if payload.get("passed") is not True:
|
|
116
|
+
return False, "rollback evidence is inconsistent with the retention policy or not passed"
|
|
117
|
+
return True, "rollback evidence is consistent with the retention policy"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_auth_canary(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
121
|
+
if payload.get("schemaVersion") != "maggie-deployment-auth-canary.v1":
|
|
122
|
+
return False, "authenticated canary schemaVersion is unsupported"
|
|
123
|
+
if payload.get("status") != "passed" or payload.get("passed") is not True:
|
|
124
|
+
return False, "authenticated API canary did not pass"
|
|
125
|
+
return True, "authenticated API canary passed"
|
|
126
|
+
|
|
127
|
+
|
|
95
128
|
VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
|
|
96
129
|
"unitRegression": validate_unit,
|
|
97
130
|
"packageSmoke": validate_package,
|
|
@@ -99,7 +132,12 @@ VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
|
|
|
99
132
|
"renderedCanary": validate_canary,
|
|
100
133
|
"deploymentPreflight": validate_preflight,
|
|
101
134
|
"runtimeParity": validate_runtime_parity,
|
|
135
|
+
"hostHealth": validate_host_health,
|
|
136
|
+
"releaseIdentity": validate_release_identity,
|
|
137
|
+
"rollbackEvidence": validate_rollback_check,
|
|
138
|
+
"authenticatedCanary": validate_auth_canary,
|
|
102
139
|
}
|
|
140
|
+
OPTIONAL_CHECKS = ("runtimeParity", "hostHealth", "releaseIdentity", "rollbackEvidence", "authenticatedCanary")
|
|
103
141
|
|
|
104
142
|
|
|
105
143
|
def inspect_check(name: str, path: Path, project: Path) -> dict[str, Any]:
|
|
@@ -120,7 +158,7 @@ def inspect_check(name: str, path: Path, project: Path) -> dict[str, Any]:
|
|
|
120
158
|
return result
|
|
121
159
|
|
|
122
160
|
|
|
123
|
-
def
|
|
161
|
+
def readiness_report(project: Path, paths: dict[str, Path]) -> dict[str, Any]:
|
|
124
162
|
checks = {name: inspect_check(name, path, project) for name, path in paths.items()}
|
|
125
163
|
states = {item["state"] for item in checks.values()}
|
|
126
164
|
status = "failed" if "failed" in states else "inconclusive" if "inconclusive" in states else "passed"
|
|
@@ -132,6 +170,12 @@ def readiness(project: Path, paths: dict[str, Path], output: Path | None = None)
|
|
|
132
170
|
"checks": checks,
|
|
133
171
|
"nextAction": "resolve failed evidence before release" if status == "failed" else "collect the missing host adapter evidence" if status == "inconclusive" else "all declared readiness evidence passed",
|
|
134
172
|
}
|
|
173
|
+
return report
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def readiness(project: Path, paths: dict[str, Path], output: Path | None = None) -> int:
|
|
177
|
+
report = readiness_report(project, paths)
|
|
178
|
+
status = report["status"]
|
|
135
179
|
if output:
|
|
136
180
|
destination = output if output.is_absolute() else project / output
|
|
137
181
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -150,6 +194,10 @@ def main() -> int:
|
|
|
150
194
|
parser.add_argument("--rendered-canary", type=Path, default=Path(".maggie/deployment-canary.json"))
|
|
151
195
|
parser.add_argument("--deployment-preflight", type=Path, default=Path(".maggie/release-preflight.json"))
|
|
152
196
|
parser.add_argument("--runtime-parity", type=Path, help="validated worker/browser parity report from maggie deployment parity")
|
|
197
|
+
parser.add_argument("--host-health", type=Path, help="maggie-deployment-health.v1 evidence (e.g. from maggie deployment collect)")
|
|
198
|
+
parser.add_argument("--release-identity", type=Path, help="maggie-release-identity.v1 evidence")
|
|
199
|
+
parser.add_argument("--rollback-check", type=Path, help="maggie-deployment-rollback-check.v1 evidence")
|
|
200
|
+
parser.add_argument("--auth-canary", type=Path, help="maggie-deployment-auth-canary.v1 evidence")
|
|
153
201
|
parser.add_argument("--output", type=Path)
|
|
154
202
|
args = parser.parse_args()
|
|
155
203
|
project = args.project.resolve()
|
|
@@ -159,8 +207,10 @@ def main() -> int:
|
|
|
159
207
|
name: (getattr(args, option).resolve() if getattr(args, option).is_absolute() else project / getattr(args, option))
|
|
160
208
|
for name, option in CHECKS
|
|
161
209
|
}
|
|
162
|
-
|
|
163
|
-
|
|
210
|
+
for name, option in (("runtimeParity", "runtime_parity"), ("hostHealth", "host_health"), ("releaseIdentity", "release_identity"), ("rollbackEvidence", "rollback_check"), ("authenticatedCanary", "auth_canary")):
|
|
211
|
+
value = getattr(args, option)
|
|
212
|
+
if value:
|
|
213
|
+
paths[name] = value.resolve() if value.is_absolute() else project / value
|
|
164
214
|
try:
|
|
165
215
|
return readiness(project, paths, args.output)
|
|
166
216
|
except (OSError, ValueError) as error:
|
|
@@ -854,9 +854,26 @@ commands:
|
|
|
854
854
|
resume restart a failed design workflow
|
|
855
855
|
author plan an original first-party page
|
|
856
856
|
markup-check validate optional style attributes, content keys, and entities
|
|
857
|
+
pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
|
|
858
|
+
layer-check flag unlayered CSS that overrides Tailwind v4 utilities
|
|
859
|
+
palette generate or validate a semantic palette role contract
|
|
860
|
+
verify run automatable design-job steps and record evidence
|
|
861
|
+
icon-glyph-check render icons and prove font load and glyph paint
|
|
857
862
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
858
863
|
""")
|
|
859
864
|
return 0
|
|
865
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check"}:
|
|
866
|
+
from maggie_design_lint import main as lint_main
|
|
867
|
+
return lint_main(sys.argv[1:])
|
|
868
|
+
if len(sys.argv) > 1 and sys.argv[1] == "verify":
|
|
869
|
+
from maggie_design_verify import main as verify_main
|
|
870
|
+
return verify_main(sys.argv[2:])
|
|
871
|
+
if len(sys.argv) > 1 and sys.argv[1] == "icon-glyph-check":
|
|
872
|
+
from maggie_icon_glyph_check import main as glyph_main
|
|
873
|
+
return glyph_main(sys.argv[2:])
|
|
874
|
+
if len(sys.argv) > 1 and sys.argv[1] == "palette":
|
|
875
|
+
from maggie_palette import main as palette_main
|
|
876
|
+
return palette_main(sys.argv[2:])
|
|
860
877
|
if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
|
|
861
878
|
command = argparse.ArgumentParser(description="Record sanitized blog/service UI evidence from a read-only reference project.")
|
|
862
879
|
command.add_argument("--project", type=Path, default=Path.cwd())
|