@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,280 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate and validate a semantic palette role contract for a template.
|
|
3
|
+
|
|
4
|
+
A template exposes colour roles instead of raw hex values in markup. The
|
|
5
|
+
signature palette is the template's own; variants are derived from it by
|
|
6
|
+
moving hue while keeping lightness, capping accent saturation, and forcing
|
|
7
|
+
the contrast rules that make the roles safe to swap without editing markup.
|
|
8
|
+
The command is deterministic and makes no network or model calls.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import colorsys
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "maggie-palette-roles-v1"
|
|
21
|
+
ROLES = ("canvas", "panel", "soft", "text", "accent", "accentText", "onAccent", "dark", "deep")
|
|
22
|
+
HEX = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
|
23
|
+
MIN_TEXT_CONTRAST = 4.5
|
|
24
|
+
DEFAULT_MAX_SATURATION = 0.65
|
|
25
|
+
DEFAULT_VARIANT_COUNT = 17
|
|
26
|
+
MIN_HUE_DISTANCE = 15.0
|
|
27
|
+
|
|
28
|
+
# Named hue anchors used for variants. Order is the output order.
|
|
29
|
+
HUE_ANCHORS = (
|
|
30
|
+
("rose", "Rose", 350), ("coral", "Coral", 10), ("terracotta", "Terracotta", 20),
|
|
31
|
+
("amber", "Amber", 36), ("ochre", "Ochre", 45), ("olive", "Olive", 65),
|
|
32
|
+
("moss", "Moss", 90), ("sage", "Sage", 130), ("jade", "Jade", 155),
|
|
33
|
+
("teal", "Teal", 178), ("lagoon", "Lagoon", 190), ("ocean", "Ocean", 205),
|
|
34
|
+
("cobalt", "Cobalt", 220), ("indigo", "Indigo", 240), ("iris", "Iris", 258),
|
|
35
|
+
("violet", "Violet", 275), ("plum", "Plum", 300), ("orchid", "Orchid", 322),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_hex(value: str) -> tuple[float, float, float]:
|
|
40
|
+
if not isinstance(value, str) or not HEX.match(value):
|
|
41
|
+
raise ValueError(f"colour must be #RRGGBB: {value!r}")
|
|
42
|
+
return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) # type: ignore[return-value]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_hex(rgb: tuple[float, float, float]) -> str:
|
|
46
|
+
return "#" + "".join(f"{round(max(0.0, min(1.0, channel)) * 255):02X}" for channel in rgb)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_hsl(value: str) -> tuple[float, float, float]:
|
|
50
|
+
hue, lightness, saturation = colorsys.rgb_to_hls(*parse_hex(value))
|
|
51
|
+
return hue * 360, saturation, lightness
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_hsl(hue: float, saturation: float, lightness: float) -> str:
|
|
55
|
+
return to_hex(colorsys.hls_to_rgb((hue % 360) / 360, max(0.0, min(1.0, lightness)), max(0.0, min(1.0, saturation))))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def luminance(value: str) -> float:
|
|
59
|
+
def linear(channel: float) -> float:
|
|
60
|
+
return channel / 12.92 if channel <= 0.03928 else ((channel + 0.055) / 1.055) ** 2.4
|
|
61
|
+
red, green, blue = (linear(channel) for channel in parse_hex(value))
|
|
62
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def contrast(first: str, second: str) -> float:
|
|
66
|
+
high, low = sorted((luminance(first), luminance(second)), reverse=True)
|
|
67
|
+
return (high + 0.05) / (low + 0.05)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hue_distance(first: float, second: float) -> float:
|
|
71
|
+
delta = abs(first - second) % 360
|
|
72
|
+
return min(delta, 360 - delta)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mix(first: str, second: str, amount: float) -> str:
|
|
76
|
+
a, b = parse_hex(first), parse_hex(second)
|
|
77
|
+
return to_hex(tuple(x + (y - x) * amount for x, y in zip(a, b))) # type: ignore[arg-type]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ensure_contrast(colour: str, background: str, minimum: float = MIN_TEXT_CONTRAST) -> str:
|
|
81
|
+
"""Move only lightness, away from the background, until contrast passes."""
|
|
82
|
+
if contrast(colour, background) >= minimum:
|
|
83
|
+
return colour
|
|
84
|
+
hue, saturation, lightness = to_hsl(colour)
|
|
85
|
+
step = -0.01 if luminance(background) > 0.18 else 0.01
|
|
86
|
+
candidate = colour
|
|
87
|
+
for _ in range(101):
|
|
88
|
+
lightness = max(0.0, min(1.0, lightness + step))
|
|
89
|
+
candidate = from_hsl(hue, saturation, lightness)
|
|
90
|
+
if contrast(candidate, background) >= minimum:
|
|
91
|
+
return candidate
|
|
92
|
+
return candidate
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pick_on_accent(accent: str, candidates: list[str]) -> str:
|
|
96
|
+
return max(candidates, key=lambda candidate: (round(contrast(candidate, accent), 4), candidate))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def complete_roles(roles: dict[str, str]) -> dict[str, str]:
|
|
100
|
+
"""Fill derived roles for a signature; supplied roles are never changed."""
|
|
101
|
+
missing = [name for name in ("canvas", "text", "accent") if not roles.get(name)]
|
|
102
|
+
if missing:
|
|
103
|
+
raise ValueError("signature needs canvas, text and accent; missing " + ", ".join(missing))
|
|
104
|
+
for name, value in roles.items():
|
|
105
|
+
if name not in ROLES:
|
|
106
|
+
raise ValueError(f"unknown palette role: {name}")
|
|
107
|
+
parse_hex(value)
|
|
108
|
+
filled = {name: roles[name].upper() for name in roles}
|
|
109
|
+
canvas, text, accent = filled["canvas"], filled["text"], filled["accent"]
|
|
110
|
+
filled.setdefault("panel", mix(canvas, text, 0.04))
|
|
111
|
+
filled.setdefault("soft", mix(canvas, accent, 0.12))
|
|
112
|
+
# The dark band is the text colour on light canvases and a deeper canvas on dark ones.
|
|
113
|
+
if luminance(canvas) > 0.18:
|
|
114
|
+
filled.setdefault("dark", text)
|
|
115
|
+
else:
|
|
116
|
+
canvas_hue, canvas_saturation, canvas_lightness = to_hsl(canvas)
|
|
117
|
+
filled.setdefault("dark", from_hsl(canvas_hue, canvas_saturation, canvas_lightness * 0.8))
|
|
118
|
+
deep_hue, deep_saturation, deep_lightness = to_hsl(filled["dark"])
|
|
119
|
+
filled.setdefault("deep", from_hsl(deep_hue, deep_saturation, deep_lightness * 0.7))
|
|
120
|
+
filled.setdefault("accentText", ensure_contrast(accent, canvas))
|
|
121
|
+
filled.setdefault("onAccent", pick_on_accent(accent, ["#FFFFFF", filled["deep"], text, canvas]))
|
|
122
|
+
return {name: filled[name] for name in ROLES}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def derive_variant(signature: dict[str, str], anchor: tuple[str, str, int], max_saturation: float) -> dict[str, object]:
|
|
126
|
+
identifier, name, target = anchor
|
|
127
|
+
accent_hue, accent_saturation, accent_lightness = to_hsl(signature["accent"])
|
|
128
|
+
shift = target - accent_hue
|
|
129
|
+
roles: dict[str, str] = {}
|
|
130
|
+
for role in ("canvas", "panel", "soft", "text", "dark", "deep"):
|
|
131
|
+
hue, saturation, lightness = to_hsl(signature[role])
|
|
132
|
+
# Neutrals follow the hue move but never gain chroma.
|
|
133
|
+
roles[role] = from_hsl(hue + shift, min(saturation, max_saturation), lightness)
|
|
134
|
+
roles["text"] = ensure_contrast(roles["text"], roles["canvas"])
|
|
135
|
+
roles["text"] = ensure_contrast(roles["text"], roles["panel"])
|
|
136
|
+
accent = from_hsl(target, min(accent_saturation, max_saturation), accent_lightness)
|
|
137
|
+
candidates = ["#FFFFFF", roles["deep"], roles["text"], roles["canvas"]]
|
|
138
|
+
on_accent = pick_on_accent(accent, candidates)
|
|
139
|
+
if contrast(on_accent, accent) < MIN_TEXT_CONTRAST:
|
|
140
|
+
accent = ensure_contrast(accent, on_accent)
|
|
141
|
+
on_accent = pick_on_accent(accent, candidates)
|
|
142
|
+
roles["accent"] = accent
|
|
143
|
+
roles["onAccent"] = on_accent
|
|
144
|
+
roles["accentText"] = ensure_contrast(accent, roles["canvas"])
|
|
145
|
+
return {"id": identifier, "name": name, "roles": {role: roles[role] for role in ROLES}}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def generate(roles: dict[str, str], *, template: str | None = None, name: str = "Signature",
|
|
149
|
+
count: int = DEFAULT_VARIANT_COUNT, max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
|
|
150
|
+
if not 0 < max_saturation <= 1:
|
|
151
|
+
raise ValueError("max saturation must be in (0, 1]")
|
|
152
|
+
if count < 0:
|
|
153
|
+
raise ValueError("count must not be negative")
|
|
154
|
+
signature = complete_roles(roles)
|
|
155
|
+
accent_hue = to_hsl(signature["accent"])[0]
|
|
156
|
+
anchors = [anchor for anchor in HUE_ANCHORS if hue_distance(anchor[2], accent_hue) >= MIN_HUE_DISTANCE]
|
|
157
|
+
variants = [derive_variant(signature, anchor, max_saturation) for anchor in anchors[:count]]
|
|
158
|
+
return {
|
|
159
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
160
|
+
"template": template,
|
|
161
|
+
"signature": {"id": "signature", "name": name, "roles": signature},
|
|
162
|
+
"variants": variants,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def validate(palette: dict[str, object], max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
|
|
167
|
+
errors: list[str] = []
|
|
168
|
+
warnings: list[str] = []
|
|
169
|
+
if palette.get("schemaVersion") != SCHEMA_VERSION:
|
|
170
|
+
errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
|
|
171
|
+
template = palette.get("template")
|
|
172
|
+
if template is not None and not isinstance(template, str):
|
|
173
|
+
errors.append("template must be a string or null")
|
|
174
|
+
entries: list[tuple[dict, bool]] = []
|
|
175
|
+
if isinstance(palette.get("signature"), dict):
|
|
176
|
+
entries.append((palette["signature"], False)) # type: ignore[arg-type]
|
|
177
|
+
else:
|
|
178
|
+
errors.append("signature must be an object")
|
|
179
|
+
if isinstance(palette.get("variants"), list):
|
|
180
|
+
entries.extend((variant, True) for variant in palette["variants"]) # type: ignore[union-attr]
|
|
181
|
+
else:
|
|
182
|
+
errors.append("variants must be a list")
|
|
183
|
+
seen: set[str] = set()
|
|
184
|
+
checked = 0
|
|
185
|
+
for entry, is_variant in entries:
|
|
186
|
+
if not isinstance(entry, dict):
|
|
187
|
+
errors.append("each palette entry must be an object")
|
|
188
|
+
continue
|
|
189
|
+
identifier = str(entry.get("id") or "")
|
|
190
|
+
label = identifier or "<missing id>"
|
|
191
|
+
if not identifier or not str(entry.get("name") or "").strip():
|
|
192
|
+
errors.append(f"{label}: id and name are required")
|
|
193
|
+
if identifier in seen:
|
|
194
|
+
errors.append(f"{label}: duplicate palette id")
|
|
195
|
+
seen.add(identifier)
|
|
196
|
+
roles = entry.get("roles")
|
|
197
|
+
if not isinstance(roles, dict):
|
|
198
|
+
errors.append(f"{label}: roles must be an object")
|
|
199
|
+
continue
|
|
200
|
+
missing = [role for role in ROLES if role not in roles]
|
|
201
|
+
invalid = [role for role in ROLES if role in roles and not (isinstance(roles[role], str) and HEX.match(roles[role]))]
|
|
202
|
+
if missing:
|
|
203
|
+
errors.append(f"{label}: missing roles " + ", ".join(missing))
|
|
204
|
+
if invalid:
|
|
205
|
+
errors.append(f"{label}: roles must be #RRGGBB: " + ", ".join(invalid))
|
|
206
|
+
if missing or invalid:
|
|
207
|
+
continue
|
|
208
|
+
checked += 1
|
|
209
|
+
for foreground, background in (("text", "canvas"), ("text", "panel"), ("accentText", "canvas"), ("onAccent", "accent")):
|
|
210
|
+
ratio = contrast(roles[foreground], roles[background])
|
|
211
|
+
if ratio < MIN_TEXT_CONTRAST:
|
|
212
|
+
errors.append(f"{label}: {foreground} on {background} is {ratio:.2f}:1; needs {MIN_TEXT_CONTRAST}:1")
|
|
213
|
+
saturation = to_hsl(roles["accent"])[1]
|
|
214
|
+
if is_variant and saturation > max_saturation + 0.01:
|
|
215
|
+
errors.append(f"{label}: accent saturation {saturation:.2f} exceeds cap {max_saturation:.2f}")
|
|
216
|
+
if contrast(roles["text"], roles["soft"]) < MIN_TEXT_CONTRAST:
|
|
217
|
+
warnings.append(f"{label}: text on soft is below {MIN_TEXT_CONTRAST}:1; avoid body copy on soft surfaces")
|
|
218
|
+
return {"schemaVersion": "maggie-palette-validation.v1", "passed": not errors, "checkedPalettes": checked, "errors": errors, "warnings": warnings}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_roles(args: argparse.Namespace) -> dict[str, str]:
|
|
222
|
+
roles: dict[str, str] = {}
|
|
223
|
+
if args.roles:
|
|
224
|
+
data = json.loads(args.roles.read_text(encoding="utf-8"))
|
|
225
|
+
if isinstance(data.get("signature"), dict):
|
|
226
|
+
data = data["signature"].get("roles") or {}
|
|
227
|
+
elif isinstance(data.get("roles"), dict):
|
|
228
|
+
data = data["roles"]
|
|
229
|
+
roles.update({key: value for key, value in data.items() if isinstance(value, str)})
|
|
230
|
+
for role in ROLES:
|
|
231
|
+
value = getattr(args, role, None)
|
|
232
|
+
if value:
|
|
233
|
+
roles[role] = value
|
|
234
|
+
return roles
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _emit(payload: dict[str, object], output: Path | None) -> None:
|
|
238
|
+
encoded = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
|
239
|
+
if output:
|
|
240
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
output.write_text(encoded, encoding="utf-8")
|
|
242
|
+
print(encoded, end="")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv: list[str] | None = None) -> int:
|
|
246
|
+
parser = argparse.ArgumentParser(prog="maggie design palette", description=__doc__.splitlines()[0])
|
|
247
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
248
|
+
generate_parser = sub.add_parser("generate", help="derive contrast-safe variants from a signature palette")
|
|
249
|
+
generate_parser.add_argument("--roles", type=Path, help="JSON roles object, or an existing palette file")
|
|
250
|
+
for role in ROLES:
|
|
251
|
+
generate_parser.add_argument(f"--{role}", dest=role, help=f"{role} colour as #RRGGBB")
|
|
252
|
+
generate_parser.add_argument("--template", help="template id recorded in the contract")
|
|
253
|
+
generate_parser.add_argument("--name", default="Signature", help="signature palette display name")
|
|
254
|
+
generate_parser.add_argument("--count", type=int, default=DEFAULT_VARIANT_COUNT)
|
|
255
|
+
generate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
|
|
256
|
+
generate_parser.add_argument("--output", type=Path)
|
|
257
|
+
validate_parser = sub.add_parser("validate", help="check roles, hex values, contrast and saturation cap")
|
|
258
|
+
validate_parser.add_argument("--palette", type=Path, required=True)
|
|
259
|
+
validate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
|
|
260
|
+
args = parser.parse_args(argv)
|
|
261
|
+
try:
|
|
262
|
+
if args.command == "generate":
|
|
263
|
+
palette = generate(_load_roles(args), template=args.template, name=args.name, count=args.count, max_saturation=args.max_saturation)
|
|
264
|
+
report = validate(palette, args.max_saturation)
|
|
265
|
+
if not report["passed"]:
|
|
266
|
+
print(json.dumps(report, indent=2), file=sys.stderr)
|
|
267
|
+
print("BLOCKED: maggie-design palette generate: the signature palette fails the contrast contract", file=sys.stderr)
|
|
268
|
+
return 1
|
|
269
|
+
_emit(palette, args.output)
|
|
270
|
+
return 0
|
|
271
|
+
report = validate(json.loads(args.palette.read_text(encoding="utf-8")), args.max_saturation)
|
|
272
|
+
_emit(report, None)
|
|
273
|
+
return 0 if report["passed"] else 1
|
|
274
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
275
|
+
print(f"BLOCKED: maggie-design palette {args.command}: {error}", file=sys.stderr)
|
|
276
|
+
return 1
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
raise SystemExit(main())
|
|
@@ -16,7 +16,9 @@ import re
|
|
|
16
16
|
import sys
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
|
+
from urllib.error import HTTPError, URLError
|
|
19
20
|
from urllib.parse import urlsplit, urlunsplit
|
|
21
|
+
from urllib.request import Request, urlopen
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
|
|
@@ -29,6 +31,9 @@ SECRET_RE = re.compile(
|
|
|
29
31
|
r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
|
|
30
32
|
)
|
|
31
33
|
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
|
|
34
|
+
CORS_SAFELISTED_METHODS = {"GET", "HEAD", "POST"}
|
|
35
|
+
DEFAULT_CORS_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
36
|
+
MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
32
37
|
QUERY_SECRET_RE = re.compile(r"(?i)([?&](?:token|key|secret|password|code|id_token|access_token)=)[^&\s]+")
|
|
33
38
|
|
|
34
39
|
|
|
@@ -149,6 +154,11 @@ def start(args: argparse.Namespace) -> int:
|
|
|
149
154
|
path = run_directory(project) / f"{run_id}.json"
|
|
150
155
|
if path.exists():
|
|
151
156
|
raise ValueError(f"QA run already exists: {path}")
|
|
157
|
+
api_base = safe_base_url(args.api_base) if getattr(args, "api_base", "") else ""
|
|
158
|
+
production_hosts = {host for host in (api_host(value) for value in getattr(args, "production_api", []) or []) if host}
|
|
159
|
+
targets_production = bool(api_base) and api_host(api_base) in production_hosts
|
|
160
|
+
allow_writes = bool(getattr(args, "allow_production_writes", False))
|
|
161
|
+
write_policy = "allowed" if not targets_production else ("allowed-by-consent" if allow_writes else "read-only")
|
|
152
162
|
scenarios = {
|
|
153
163
|
item["id"]: {
|
|
154
164
|
"id": item["id"],
|
|
@@ -164,6 +174,20 @@ def start(args: argparse.Namespace) -> int:
|
|
|
164
174
|
}
|
|
165
175
|
for item in records
|
|
166
176
|
}
|
|
177
|
+
blocked_writes = []
|
|
178
|
+
if write_policy == "read-only":
|
|
179
|
+
for item in records:
|
|
180
|
+
if item.get("mutates") is True:
|
|
181
|
+
scenario = scenarios[item["id"]]
|
|
182
|
+
scenario["status"] = "blocked"
|
|
183
|
+
scenario["mutates"] = True
|
|
184
|
+
scenario["history"].append({
|
|
185
|
+
"at": now(),
|
|
186
|
+
"phase": "guard",
|
|
187
|
+
"status": "blocked",
|
|
188
|
+
"summary": "Mutating scenario blocked: the API target is production and --allow-production-writes was not given",
|
|
189
|
+
})
|
|
190
|
+
blocked_writes.append(item["id"])
|
|
167
191
|
try:
|
|
168
192
|
manifest_label = str(manifest_path.relative_to(project))
|
|
169
193
|
except ValueError:
|
|
@@ -183,11 +207,22 @@ def start(args: argparse.Namespace) -> int:
|
|
|
183
207
|
"commit": safe_text(args.commit),
|
|
184
208
|
"release": safe_text(args.release),
|
|
185
209
|
"scenarioManifest": manifest_label,
|
|
210
|
+
"apiTarget": {
|
|
211
|
+
"baseUrl": api_base,
|
|
212
|
+
"production": targets_production,
|
|
213
|
+
"writePolicy": write_policy,
|
|
214
|
+
"interception": "network-layer required; page-level window.fetch stubs do not intercept bundled clients",
|
|
215
|
+
},
|
|
186
216
|
"scenarios": scenarios,
|
|
187
217
|
}
|
|
188
218
|
run["summary"] = calculate(run)
|
|
189
219
|
write_json(path, run)
|
|
190
|
-
|
|
220
|
+
report = {"runId": run_id, "path": str(path), "summary": run["summary"], "writePolicy": write_policy}
|
|
221
|
+
if blocked_writes:
|
|
222
|
+
report["blockedMutatingScenarios"] = blocked_writes
|
|
223
|
+
if not api_base:
|
|
224
|
+
report["warnings"] = ["--api-base was not given; declare the API the build talks to so writes against production can be blocked"]
|
|
225
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
191
226
|
return 0
|
|
192
227
|
|
|
193
228
|
|
|
@@ -217,6 +252,16 @@ def record(args: argparse.Namespace) -> int:
|
|
|
217
252
|
raise ValueError(f"phase must be one of: {', '.join(sorted(VALID_PHASES))}")
|
|
218
253
|
if phase in {"test", "retest"} and args.status not in VALID_STATUSES - {"pending"}:
|
|
219
254
|
raise ValueError("test and retest require status pass, fail, blocked or inconclusive")
|
|
255
|
+
if (
|
|
256
|
+
phase in {"test", "retest"}
|
|
257
|
+
and item.get("mutates") is True
|
|
258
|
+
and (run.get("apiTarget") or {}).get("writePolicy") == "read-only"
|
|
259
|
+
and args.status in {"pass", "fail"}
|
|
260
|
+
):
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"this mutating scenario targets a production API without consent; "
|
|
263
|
+
"rerun QA against a non-production API or start a new run with --allow-production-writes"
|
|
264
|
+
)
|
|
220
265
|
if phase == "fix" and not args.resolution:
|
|
221
266
|
raise ValueError("fix requires --resolution")
|
|
222
267
|
if phase == "fix" and item.get("status") != "fail":
|
|
@@ -417,6 +462,104 @@ def assertion_audit(args: argparse.Namespace) -> int:
|
|
|
417
462
|
return 0 if passed else 1
|
|
418
463
|
|
|
419
464
|
|
|
465
|
+
def api_host(value: object) -> str:
|
|
466
|
+
try:
|
|
467
|
+
parsed = urlsplit(str(value or "").strip())
|
|
468
|
+
except ValueError:
|
|
469
|
+
return ""
|
|
470
|
+
if not parsed.hostname:
|
|
471
|
+
return ""
|
|
472
|
+
return f"{parsed.hostname.lower()}:{parsed.port}" if parsed.port else parsed.hostname.lower()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def header_tokens(value: str | None) -> set[str]:
|
|
476
|
+
return {item.strip().lower() for item in (value or "").split(",") if item.strip()}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def preflight_method(url: str, origin: str, method: str, request_headers: list[str], timeout: float) -> dict:
|
|
480
|
+
"""Send one CORS preflight; never sends the method itself, so it cannot mutate data."""
|
|
481
|
+
headers = {"Origin": origin, "Access-Control-Request-Method": method, "User-Agent": "MaggieQA/1.0"}
|
|
482
|
+
if request_headers:
|
|
483
|
+
headers["Access-Control-Request-Headers"] = ",".join(request_headers)
|
|
484
|
+
status = 0
|
|
485
|
+
response_headers: dict[str, str] = {}
|
|
486
|
+
error = ""
|
|
487
|
+
try:
|
|
488
|
+
with urlopen(Request(url, headers=headers, method="OPTIONS"), timeout=timeout) as response:
|
|
489
|
+
status = response.status
|
|
490
|
+
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
|
491
|
+
except HTTPError as failure:
|
|
492
|
+
status = failure.code
|
|
493
|
+
response_headers = {key.lower(): value for key, value in failure.headers.items()}
|
|
494
|
+
except (URLError, TimeoutError, OSError) as failure:
|
|
495
|
+
error = safe_text(getattr(failure, "reason", failure), 200)
|
|
496
|
+
allow_origin = response_headers.get("access-control-allow-origin", "")
|
|
497
|
+
allow_methods = {item.upper() for item in header_tokens(response_headers.get("access-control-allow-methods"))}
|
|
498
|
+
allow_headers = header_tokens(response_headers.get("access-control-allow-headers"))
|
|
499
|
+
reasons = []
|
|
500
|
+
if error:
|
|
501
|
+
reasons.append(f"preflight request failed: {error}")
|
|
502
|
+
elif not 200 <= status < 300:
|
|
503
|
+
reasons.append(f"preflight returned HTTP {status}")
|
|
504
|
+
if not error and allow_origin not in {origin, "*"}:
|
|
505
|
+
reasons.append("Access-Control-Allow-Origin does not allow this origin")
|
|
506
|
+
if not error and method not in CORS_SAFELISTED_METHODS and method not in allow_methods and "*" not in allow_methods:
|
|
507
|
+
reasons.append(f"{method} is not in Access-Control-Allow-Methods")
|
|
508
|
+
missing_headers = sorted(header.lower() for header in request_headers if header.lower() not in allow_headers and "*" not in allow_headers)
|
|
509
|
+
if not error and missing_headers:
|
|
510
|
+
reasons.append("request headers not allowed: " + ", ".join(missing_headers))
|
|
511
|
+
return {
|
|
512
|
+
"method": method,
|
|
513
|
+
"status": status,
|
|
514
|
+
"allowed": not reasons,
|
|
515
|
+
"allowOrigin": allow_origin if allow_origin in {origin, "*"} else ("other" if allow_origin else ""),
|
|
516
|
+
"allowMethods": sorted(allow_methods),
|
|
517
|
+
"reasons": reasons,
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def cors_preflight(args: argparse.Namespace) -> int:
|
|
522
|
+
url = str(args.url).strip()
|
|
523
|
+
parsed = urlsplit(url)
|
|
524
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
525
|
+
raise ValueError("--url must be an absolute http(s) URL")
|
|
526
|
+
origin = safe_base_url(args.origin)
|
|
527
|
+
if not urlsplit(origin).hostname:
|
|
528
|
+
raise ValueError("--origin must be an absolute origin such as https://app.example.com")
|
|
529
|
+
methods = []
|
|
530
|
+
for value in args.method or list(DEFAULT_CORS_METHODS):
|
|
531
|
+
for item in str(value).split(","):
|
|
532
|
+
item = item.strip().upper()
|
|
533
|
+
if item and item not in methods:
|
|
534
|
+
if not re.fullmatch(r"[A-Z]{3,10}", item):
|
|
535
|
+
raise ValueError(f"invalid HTTP method: {item}")
|
|
536
|
+
methods.append(item)
|
|
537
|
+
request_headers = []
|
|
538
|
+
for value in args.request_header:
|
|
539
|
+
request_headers.extend(item.strip() for item in str(value).split(",") if item.strip())
|
|
540
|
+
results = [preflight_method(url, origin, method, request_headers, args.timeout) for method in methods]
|
|
541
|
+
blocked = [item["method"] for item in results if not item["allowed"]]
|
|
542
|
+
report = {
|
|
543
|
+
"schemaVersion": "maggie.qa-cors-preflight.v1",
|
|
544
|
+
"checkedAt": now(),
|
|
545
|
+
"url": safe_base_url(url) + (parsed.path or "/"),
|
|
546
|
+
"origin": origin,
|
|
547
|
+
"requestHeaders": [header.lower() for header in request_headers],
|
|
548
|
+
"methods": results,
|
|
549
|
+
"blocked": blocked,
|
|
550
|
+
"passed": not blocked,
|
|
551
|
+
"mutation": "not executed; only OPTIONS preflights were sent",
|
|
552
|
+
}
|
|
553
|
+
if args.output:
|
|
554
|
+
project = project_root(args.project)
|
|
555
|
+
output = Path(args.output)
|
|
556
|
+
if not output.is_absolute():
|
|
557
|
+
output = project / output
|
|
558
|
+
write_json(output, report)
|
|
559
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
560
|
+
return 0 if report["passed"] else 1
|
|
561
|
+
|
|
562
|
+
|
|
420
563
|
def parser() -> argparse.ArgumentParser:
|
|
421
564
|
root = argparse.ArgumentParser(description=__doc__)
|
|
422
565
|
root.add_argument("--project", default=".")
|
|
@@ -431,6 +574,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
431
574
|
start_parser.add_argument("--browser", default="chrome")
|
|
432
575
|
start_parser.add_argument("--commit", default="")
|
|
433
576
|
start_parser.add_argument("--release", default="")
|
|
577
|
+
start_parser.add_argument("--api-base", default="", help="API origin the build under test talks to")
|
|
578
|
+
start_parser.add_argument("--production-api", action="append", default=[], help="production API origin; repeat")
|
|
579
|
+
start_parser.add_argument("--allow-production-writes", action="store_true", help="explicit consent to run mutating scenarios against a production API")
|
|
434
580
|
|
|
435
581
|
record_parser = commands.add_parser("record")
|
|
436
582
|
record_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
@@ -453,6 +599,14 @@ def parser() -> argparse.ArgumentParser:
|
|
|
453
599
|
command.add_argument("--run", required=True)
|
|
454
600
|
if name == "summary": command.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
455
601
|
else: command.add_argument("--output", required=True)
|
|
602
|
+
cors_parser = commands.add_parser("cors-preflight", help="preflight every HTTP method a client uses through the public edge")
|
|
603
|
+
cors_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
604
|
+
cors_parser.add_argument("--url", required=True, help="public API URL as the browser reaches it")
|
|
605
|
+
cors_parser.add_argument("--origin", required=True, help="app origin that sends the requests")
|
|
606
|
+
cors_parser.add_argument("--method", action="append", default=[], help="method or comma list; default GET,POST,PUT,PATCH,DELETE")
|
|
607
|
+
cors_parser.add_argument("--request-header", action="append", default=[], help="non-safelisted request header the client sends, e.g. authorization,content-type")
|
|
608
|
+
cors_parser.add_argument("--timeout", type=float, default=10.0)
|
|
609
|
+
cors_parser.add_argument("--output")
|
|
456
610
|
assertion_parser = commands.add_parser("assertion-audit")
|
|
457
611
|
assertion_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
458
612
|
assertion_parser.add_argument("--assertions", required=True, help="runtime assertion manifest JSON")
|
|
@@ -467,6 +621,7 @@ def main() -> int:
|
|
|
467
621
|
if args.command == "record": return record(args)
|
|
468
622
|
if args.command == "summary": return summary(args)
|
|
469
623
|
if args.command == "assertion-audit": return assertion_audit(args)
|
|
624
|
+
if args.command == "cors-preflight": return cors_preflight(args)
|
|
470
625
|
return export_run(args)
|
|
471
626
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
472
627
|
print(f"QA workflow error: {safe_text(error)}", file=sys.stderr)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Validate and materialise safe Maggie scheduled-job definitions.
|
|
2
|
+
"""Validate and materialise safe Maggie scheduled-job definitions.
|
|
3
|
+
|
|
4
|
+
These jobs are the `infrastructure` schedule layer: when a worker wakes up.
|
|
5
|
+
They never carry a content-calendar publish time, a campaign flight, or a
|
|
6
|
+
measurement window; those layers are declared in a marketing run and checked
|
|
7
|
+
by `maggie_marketing_run.py` (see `maggie_marketing_run.py layers`).
|
|
8
|
+
"""
|
|
3
9
|
|
|
4
10
|
from __future__ import annotations
|
|
5
11
|
|
|
@@ -68,11 +74,15 @@ def validate(manifest: object, project: Path | None = None) -> dict:
|
|
|
68
74
|
errors.append(f"{prefix}.maxRetries must be between 0 and 5")
|
|
69
75
|
if job.get("overlapPolicy") != "skip-if-running":
|
|
70
76
|
errors.append(f"{prefix}.overlapPolicy must be skip-if-running")
|
|
77
|
+
if job.get("layer", "infrastructure") != "infrastructure":
|
|
78
|
+
errors.append(f"{prefix}.layer must be infrastructure; content calendars, campaign flights and measurement windows belong in a marketing run")
|
|
79
|
+
if any(key in job for key in ("scheduled_at", "scheduledAt", "publishAt", "publish_at")):
|
|
80
|
+
errors.append(f"{prefix} must not carry a publish time; a timer only wakes the worker")
|
|
71
81
|
if not job.get("lockKey"):
|
|
72
82
|
errors.append(f"{prefix}.lockKey is required")
|
|
73
83
|
if re.search(r"(?:API_KEY|SECRET|TOKEN|PASSWORD)\s*=|https?://[^ $]+:[^ $]+@", str(job.get("command", "")), re.I):
|
|
74
84
|
errors.append(f"{prefix}.command must not contain credentials")
|
|
75
|
-
return {"passed": not errors, "errors": errors, "jobs": len(jobs), "sourceChecks": source_checks, "mutation": "not executed"}
|
|
85
|
+
return {"passed": not errors, "errors": errors, "jobs": len(jobs), "sourceChecks": source_checks, "layer": "infrastructure", "mutation": "not executed"}
|
|
76
86
|
|
|
77
87
|
|
|
78
88
|
def write_units(output_dir: Path, manifest: dict) -> None:
|