@topy-ai/maggie 0.7.48 → 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 +31 -4
- package/bin/maggie.js +17 -4
- 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-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-references/google-integrations-runbook.md +54 -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 +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -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-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- 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 +8 -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 +9 -2
- 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_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- 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 +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- 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/google-integrations-runbook.md +54 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Provider-neutral website notification operations checks.
|
|
3
|
+
|
|
4
|
+
Offline and read-only: validates a notification catalogue, compares it with
|
|
5
|
+
local templates and a provider template export, checks provider configuration
|
|
6
|
+
by environment variable name only, evaluates queue health snapshots, and plans
|
|
7
|
+
retention purges as a dry run. It never calls a provider API, never sends a
|
|
8
|
+
message, and never prints secret values or recipient addresses.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CATALOGUE_SCHEMA = "maggie-notification-catalogue.v1"
|
|
22
|
+
TEMPLATES_SCHEMA = "maggie-notification-templates.v1"
|
|
23
|
+
SNAPSHOT_SCHEMA = "maggie-notification-queue-snapshot.v1"
|
|
24
|
+
INVENTORY_SCHEMA = "maggie-notification-retention-inventory.v1"
|
|
25
|
+
|
|
26
|
+
SECTIONS = ("identity-security", "account", "business", "billing", "support", "marketing", "deliverability-ops")
|
|
27
|
+
CATEGORIES = ("transactional", "support", "marketing")
|
|
28
|
+
CONSENT = ("not-required", "opt-in-required", "operator-only")
|
|
29
|
+
AUDIENCES = ("visitor", "user", "merchant", "team", "operator")
|
|
30
|
+
CHANNELS = ("email", "in-app", "sms")
|
|
31
|
+
STATUSES = ("implemented", "provider-owned", "reserved", "missing")
|
|
32
|
+
DELIVERY = ("outbox", "direct", "provider")
|
|
33
|
+
QUEUES = ("user", "operations", "marketing")
|
|
34
|
+
RETENTION_KINDS = ("webhook-payload", "delivery-record", "rendered-snapshot", "template-variables")
|
|
35
|
+
ALIAS = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
|
36
|
+
VARIABLE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
37
|
+
LOCALE = re.compile(r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$")
|
|
38
|
+
PLACEHOLDER = re.compile(r"\{\{\{?\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}?\}\}")
|
|
39
|
+
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
40
|
+
|
|
41
|
+
# Resend-aware defaults. Each requirement lists accepted variable names; the
|
|
42
|
+
# preflight reports presence only and never reads or prints values, except the
|
|
43
|
+
# template map whose alias keys (not IDs) are compared with the catalogue.
|
|
44
|
+
RESEND_REQUIREMENTS = (
|
|
45
|
+
("api_key", ("RESEND_API_KEY",)),
|
|
46
|
+
("sender", ("RESEND_EMAIL", "RESEND_FROM", "RESEND_SENDER")),
|
|
47
|
+
("worker_secret", ("NOTIFICATION_WORKER_SECRET",)),
|
|
48
|
+
("support_recipients", ("NOTIFICATION_SUPPORT_RECIPIENTS",)),
|
|
49
|
+
("webhook_secret", ("RESEND_WEBHOOK_SECRET",)),
|
|
50
|
+
("template_map", ("RESEND_TEMPLATE_IDS_JSON",)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Operator alert templates. They are delivered on the operations queue/channel,
|
|
54
|
+
# never through the user or marketing notification queue they describe, so a
|
|
55
|
+
# failing provider cannot suppress its own alert.
|
|
56
|
+
ALERTS = {
|
|
57
|
+
"queue.pending_backlog": {"severity": "warning", "owner": "site operator", "recovery": "Check worker timer/last run, provider status, and rate limits; drain the queue before releasing new triggers.", "escalation": "critical after 2x threshold or 60 minutes"},
|
|
58
|
+
"queue.oldest_pending_age": {"severity": "critical", "owner": "site operator", "recovery": "Confirm the worker runs and claims rows; inspect the newest dispatcher error without printing recipients.", "escalation": "page the on-call developer"},
|
|
59
|
+
"queue.dead_letter_growth": {"severity": "critical", "owner": "site operator", "recovery": "Group dead letters by error code, fix the cause, then replay with a recorded reason.", "escalation": "page the on-call developer"},
|
|
60
|
+
"queue.expired_leases": {"severity": "warning", "owner": "developer", "recovery": "Worker crashed or exceeded lease time; check worker logs and lease duration.", "escalation": "critical when leases keep expiring across runs"},
|
|
61
|
+
"worker.stale_run": {"severity": "critical", "owner": "site operator", "recovery": "Restart the timer/cron and verify one successful run with a health snapshot.", "escalation": "page the on-call developer"},
|
|
62
|
+
"webhook.lag": {"severity": "warning", "owner": "developer", "recovery": "Check webhook endpoint reachability and signing secret; reconcile delivery state from provider readback.", "escalation": "critical when bounces/complaints cannot be suppressed"},
|
|
63
|
+
"deliverability.bounce_rate": {"severity": "warning", "owner": "marketing owner", "recovery": "Suppress hard bounces, review list sources, pause campaigns above threshold.", "escalation": "critical above provider suspension threshold"},
|
|
64
|
+
"deliverability.complaint_rate": {"severity": "critical", "owner": "marketing owner", "recovery": "Pause marketing sends, review consent evidence and frequency policy.", "escalation": "business owner"},
|
|
65
|
+
"deliverability.sender_domain": {"severity": "critical", "owner": "developer", "recovery": "Re-verify SPF/DKIM/DMARC records for the sending domain before resuming.", "escalation": "business owner"},
|
|
66
|
+
"templates.provider_drift": {"severity": "warning", "owner": "developer", "recovery": "Run templates drift, review the diff, then sync with an explicit apply.", "escalation": "block the next deploy"},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
DEFAULT_THRESHOLDS = {
|
|
70
|
+
"max_pending": 500,
|
|
71
|
+
"max_oldest_pending_minutes": 30,
|
|
72
|
+
"max_dead_letter": 0,
|
|
73
|
+
"max_expired_leases": 0,
|
|
74
|
+
"max_webhook_lag_minutes": 60,
|
|
75
|
+
"max_since_last_success_minutes": 30,
|
|
76
|
+
"max_bounce_rate": 0.04,
|
|
77
|
+
"max_complaint_rate": 0.001,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_json(path: str | Path) -> dict:
|
|
82
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
raise ValueError(f"{path}: expected a JSON object")
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_time(value: object, label: str) -> datetime:
|
|
89
|
+
if not isinstance(value, str) or not value:
|
|
90
|
+
raise ValueError(f"{label} must be an ISO timestamp")
|
|
91
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
92
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def emit(result: dict) -> int:
|
|
96
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
97
|
+
return 0 if result.get("passed") else 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- catalogue -------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def validate_catalogue(data: dict) -> dict:
|
|
103
|
+
errors: list[str] = []
|
|
104
|
+
warnings: list[str] = []
|
|
105
|
+
if data.get("schemaVersion") != CATALOGUE_SCHEMA:
|
|
106
|
+
errors.append(f"schemaVersion must be {CATALOGUE_SCHEMA}")
|
|
107
|
+
retention = data.get("retention") if isinstance(data.get("retention"), dict) else {}
|
|
108
|
+
if not retention:
|
|
109
|
+
errors.append("retention policy is required (webhook payloads, delivery records, snapshots, template variables)")
|
|
110
|
+
for kind in RETENTION_KINDS:
|
|
111
|
+
days = retention.get(kind, {}).get("days") if isinstance(retention.get(kind), dict) else None
|
|
112
|
+
if not isinstance(days, int) or days < 1:
|
|
113
|
+
errors.append(f"retention.{kind}.days must be a positive integer")
|
|
114
|
+
if isinstance(retention.get("webhook-payload"), dict) and retention["webhook-payload"].get("storeRawPayload") is True:
|
|
115
|
+
warnings.append("retention.webhook-payload stores raw provider payloads; store minimized fields (event, provider id, status, timestamps) instead")
|
|
116
|
+
entries = data.get("entries")
|
|
117
|
+
if not isinstance(entries, list) or not entries:
|
|
118
|
+
errors.append("entries must be a non-empty list")
|
|
119
|
+
entries = []
|
|
120
|
+
seen: set[str] = set()
|
|
121
|
+
coverage: dict[str, dict[str, int]] = {section: {status: 0 for status in STATUSES} for section in SECTIONS}
|
|
122
|
+
for index, entry in enumerate(entries):
|
|
123
|
+
label = f"entries[{index}]"
|
|
124
|
+
if not isinstance(entry, dict):
|
|
125
|
+
errors.append(f"{label} must be an object")
|
|
126
|
+
continue
|
|
127
|
+
alias = entry.get("alias")
|
|
128
|
+
if not isinstance(alias, str) or not ALIAS.match(alias):
|
|
129
|
+
errors.append(f"{label}.alias must match {ALIAS.pattern}")
|
|
130
|
+
else:
|
|
131
|
+
label = alias
|
|
132
|
+
if alias in seen:
|
|
133
|
+
errors.append(f"{alias}: duplicate alias")
|
|
134
|
+
seen.add(alias)
|
|
135
|
+
for field, allowed in (("section", SECTIONS), ("category", CATEGORIES), ("consent", CONSENT), ("audience", AUDIENCES), ("status", STATUSES), ("delivery", DELIVERY), ("queue", QUEUES)):
|
|
136
|
+
if entry.get(field) not in allowed:
|
|
137
|
+
errors.append(f"{label}.{field} must be one of {', '.join(allowed)}")
|
|
138
|
+
if not isinstance(entry.get("trigger"), str) or not entry["trigger"].strip():
|
|
139
|
+
errors.append(f"{label}.trigger is required")
|
|
140
|
+
channels = entry.get("channels")
|
|
141
|
+
if not isinstance(channels, list) or not channels or any(item not in CHANNELS for item in channels):
|
|
142
|
+
errors.append(f"{label}.channels must be a non-empty subset of {', '.join(CHANNELS)}")
|
|
143
|
+
channels = []
|
|
144
|
+
locales = entry.get("locales")
|
|
145
|
+
if not isinstance(locales, list) or not locales or any(not isinstance(item, str) or not LOCALE.match(item) for item in locales):
|
|
146
|
+
errors.append(f"{label}.locales must list BCP 47 locales")
|
|
147
|
+
if "markets" in entry and (not isinstance(entry["markets"], list) or not all(isinstance(item, str) and item for item in entry["markets"])):
|
|
148
|
+
errors.append(f"{label}.markets must be a list of market codes")
|
|
149
|
+
variables = entry.get("variables")
|
|
150
|
+
if not isinstance(variables, list) or any(not isinstance(item, str) or not VARIABLE.match(item) for item in variables):
|
|
151
|
+
errors.append(f"{label}.variables must list identifier names")
|
|
152
|
+
idempotency = entry.get("idempotencyKey")
|
|
153
|
+
if not isinstance(idempotency, str) or "{" not in idempotency:
|
|
154
|
+
errors.append(f"{label}.idempotencyKey must be a template with at least one {{placeholder}}")
|
|
155
|
+
cta = entry.get("cta")
|
|
156
|
+
if cta is not None:
|
|
157
|
+
route = cta.get("route") if isinstance(cta, dict) else None
|
|
158
|
+
if not isinstance(route, str) or not (route.startswith("/") or route.startswith("https://")):
|
|
159
|
+
errors.append(f"{label}.cta.route must be a site path or https URL")
|
|
160
|
+
elif route.startswith("https://") and "{" in route.split("/", 3)[2]:
|
|
161
|
+
errors.append(f"{label}.cta.route must not template the host")
|
|
162
|
+
if not isinstance(entry.get("retentionDays"), int) or entry.get("retentionDays", 0) < 1:
|
|
163
|
+
errors.append(f"{label}.retentionDays must be a positive integer")
|
|
164
|
+
if not isinstance(entry.get("provider"), str) or not entry["provider"].strip():
|
|
165
|
+
errors.append(f"{label}.provider is required (e.g. resend, local, auth-provider)")
|
|
166
|
+
category = entry.get("category")
|
|
167
|
+
if category == "marketing":
|
|
168
|
+
if entry.get("consent") != "opt-in-required":
|
|
169
|
+
errors.append(f"{label}: marketing messages require consent opt-in-required")
|
|
170
|
+
if entry.get("unsubscribe") is not True:
|
|
171
|
+
errors.append(f"{label}: marketing messages require unsubscribe and preference-centre links (unsubscribe: true)")
|
|
172
|
+
if entry.get("queue") != "marketing":
|
|
173
|
+
errors.append(f"{label}: marketing messages must use the marketing queue")
|
|
174
|
+
elif entry.get("consent") == "opt-in-required" and category == "transactional":
|
|
175
|
+
warnings.append(f"{label}: transactional message marked opt-in-required; confirm it is not marketing")
|
|
176
|
+
if entry.get("section") == "deliverability-ops" and entry.get("queue") != "operations":
|
|
177
|
+
errors.append(f"{label}: operational alerts must be delivered on the operations queue, outside the user/marketing queue they report on")
|
|
178
|
+
if entry.get("lead") is True and entry.get("status") == "implemented" and entry.get("delivery") != "outbox":
|
|
179
|
+
errors.append(f"{label}: external-recipient lead email (contact, waitlist, interest) must use durable outbox delivery with retry and dead-letter, not a direct send")
|
|
180
|
+
elif entry.get("delivery") == "direct" and entry.get("audience") in ("visitor", "merchant") and entry.get("status") == "implemented":
|
|
181
|
+
warnings.append(f"{label}: direct send to an external recipient has no retry/dead-letter evidence")
|
|
182
|
+
if entry.get("section") in coverage and entry.get("status") in STATUSES:
|
|
183
|
+
coverage[entry["section"]][entry["status"]] += 1
|
|
184
|
+
for section, counts in coverage.items():
|
|
185
|
+
if not sum(counts.values()):
|
|
186
|
+
warnings.append(f"section {section} has no catalogue entries")
|
|
187
|
+
missing = [entry.get("alias") for entry in entries if isinstance(entry, dict) and entry.get("status") == "missing"]
|
|
188
|
+
return {
|
|
189
|
+
"schemaVersion": "maggie-notification-catalogue-report.v1",
|
|
190
|
+
"passed": not errors,
|
|
191
|
+
"entries": len(entries),
|
|
192
|
+
"coverage": coverage,
|
|
193
|
+
"missing": missing,
|
|
194
|
+
"errors": errors,
|
|
195
|
+
"warnings": warnings,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --- templates drift --------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def load_local_templates(path: Path) -> dict[tuple[str, str], dict]:
|
|
202
|
+
templates: dict[tuple[str, str], dict] = {}
|
|
203
|
+
if path.is_dir():
|
|
204
|
+
for file in sorted(path.rglob("*")):
|
|
205
|
+
if not file.is_file():
|
|
206
|
+
continue
|
|
207
|
+
parts = file.name.split(".")
|
|
208
|
+
if len(parts) < 3 or not LOCALE.match(parts[-2]):
|
|
209
|
+
continue
|
|
210
|
+
alias = ".".join(parts[:-2])
|
|
211
|
+
key = (alias, parts[-2])
|
|
212
|
+
found = set(name.split(".")[0] for name in PLACEHOLDER.findall(file.read_text(encoding="utf-8", errors="replace")))
|
|
213
|
+
current = templates.setdefault(key, {"variables": set(), "version": None, "formats": set()})
|
|
214
|
+
current["variables"] |= found
|
|
215
|
+
current["formats"].add(parts[-1])
|
|
216
|
+
return templates
|
|
217
|
+
data = load_json(path)
|
|
218
|
+
if data.get("schemaVersion") != TEMPLATES_SCHEMA:
|
|
219
|
+
raise ValueError(f"local template manifest must use {TEMPLATES_SCHEMA}")
|
|
220
|
+
for item in data.get("templates", []):
|
|
221
|
+
key = (item.get("alias"), item.get("locale"))
|
|
222
|
+
templates[key] = {"variables": set(item.get("variables", [])), "version": item.get("version"), "formats": set(item.get("formats", []))}
|
|
223
|
+
return templates
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_provider_pages(paths: list[str]) -> tuple[dict[tuple[str, str], dict], list[str]]:
|
|
227
|
+
errors: list[str] = []
|
|
228
|
+
templates: dict[tuple[str, str], dict] = {}
|
|
229
|
+
for index, path in enumerate(paths):
|
|
230
|
+
page = load_json(path)
|
|
231
|
+
if index == len(paths) - 1 and page.get("has_more") is True:
|
|
232
|
+
errors.append("provider export is incomplete: the last page reports has_more=true; export every page before comparing")
|
|
233
|
+
for item in page.get("data", []):
|
|
234
|
+
alias = item.get("alias") or item.get("name")
|
|
235
|
+
locale = item.get("locale") or "default"
|
|
236
|
+
if isinstance(alias, str) and "." in alias and LOCALE.match(alias.rsplit(".", 1)[1]) and not item.get("locale"):
|
|
237
|
+
alias, locale = alias.rsplit(".", 1)
|
|
238
|
+
variables = item.get("variables", [])
|
|
239
|
+
names = {entry.get("key") if isinstance(entry, dict) else entry for entry in variables}
|
|
240
|
+
templates[(alias, locale)] = {"variables": {name for name in names if name}, "version": item.get("version") or item.get("updated_at")}
|
|
241
|
+
return templates, errors
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def templates_drift(catalogue: dict, local_path: Path | None, provider_paths: list[str], provider_name: str) -> dict:
|
|
245
|
+
errors: list[str] = []
|
|
246
|
+
findings: dict[str, list] = {"missingLocal": [], "missingProvider": [], "extraProvider": [], "extraLocal": [], "localeGaps": [], "variableMismatches": [], "versionDrift": []}
|
|
247
|
+
entries = [entry for entry in catalogue.get("entries", []) if isinstance(entry, dict)]
|
|
248
|
+
expected: dict[tuple[str, str], set[str]] = {}
|
|
249
|
+
provider_aliases: set[str] = set()
|
|
250
|
+
for entry in entries:
|
|
251
|
+
if entry.get("status") != "implemented" or "email" not in entry.get("channels", []):
|
|
252
|
+
continue
|
|
253
|
+
for locale in entry.get("locales", []):
|
|
254
|
+
expected[(entry["alias"], locale)] = set(entry.get("variables", []))
|
|
255
|
+
if entry.get("provider") == provider_name:
|
|
256
|
+
provider_aliases.add(entry["alias"])
|
|
257
|
+
catalogue_aliases = {entry.get("alias") for entry in entries}
|
|
258
|
+
local = load_local_templates(local_path) if local_path else {}
|
|
259
|
+
provider, page_errors = load_provider_pages(provider_paths) if provider_paths else ({}, [])
|
|
260
|
+
errors.extend(page_errors)
|
|
261
|
+
for (alias, locale), variables in sorted(expected.items()):
|
|
262
|
+
if local_path and (alias, locale) not in local:
|
|
263
|
+
findings["missingLocal"].append(f"{alias}.{locale}")
|
|
264
|
+
if provider_paths and alias in provider_aliases and (alias, locale) not in provider:
|
|
265
|
+
findings["missingProvider"].append(f"{alias}.{locale}")
|
|
266
|
+
for source, table in (("local", local), ("provider", provider)):
|
|
267
|
+
if (alias, locale) in table:
|
|
268
|
+
actual = table[(alias, locale)]["variables"]
|
|
269
|
+
undeclared, unused = sorted(actual - variables), sorted(variables - actual)
|
|
270
|
+
if undeclared:
|
|
271
|
+
findings["variableMismatches"].append({"template": f"{alias}.{locale}", "source": source, "undeclared": undeclared})
|
|
272
|
+
if unused and source == "local":
|
|
273
|
+
findings["variableMismatches"].append({"template": f"{alias}.{locale}", "source": source, "unusedDeclared": unused})
|
|
274
|
+
if (alias, locale) in local and (alias, locale) in provider:
|
|
275
|
+
local_version, provider_version = local[(alias, locale)].get("version"), provider[(alias, locale)].get("version")
|
|
276
|
+
if local_version and provider_version and str(local_version) != str(provider_version):
|
|
277
|
+
findings["versionDrift"].append({"template": f"{alias}.{locale}", "local": local_version, "provider": provider_version})
|
|
278
|
+
for alias, locale in sorted(provider):
|
|
279
|
+
if alias not in catalogue_aliases:
|
|
280
|
+
findings["extraProvider"].append(f"{alias}.{locale}")
|
|
281
|
+
for alias, locale in sorted(local):
|
|
282
|
+
if alias not in catalogue_aliases:
|
|
283
|
+
findings["extraLocal"].append(f"{alias}.{locale}")
|
|
284
|
+
expected_locales: dict[str, set[str]] = {}
|
|
285
|
+
for alias, locale in expected:
|
|
286
|
+
expected_locales.setdefault(alias, set()).add(locale)
|
|
287
|
+
for table_name, table in (("local", local), ("provider", provider)):
|
|
288
|
+
present: dict[str, set[str]] = {}
|
|
289
|
+
for alias, locale in table:
|
|
290
|
+
present.setdefault(alias, set()).add(locale)
|
|
291
|
+
for alias, locales in present.items():
|
|
292
|
+
gaps = sorted(expected_locales.get(alias, set()) - locales)
|
|
293
|
+
if gaps and (table_name == "local" or alias in provider_aliases):
|
|
294
|
+
findings["localeGaps"].append({"alias": alias, "source": table_name, "missingLocales": gaps})
|
|
295
|
+
drift = any(findings[key] for key in findings)
|
|
296
|
+
return {
|
|
297
|
+
"schemaVersion": "maggie-notification-template-drift.v1",
|
|
298
|
+
"passed": not drift and not errors,
|
|
299
|
+
"provider": provider_name,
|
|
300
|
+
"compared": {"catalogue": len(expected), "local": len(local), "provider": len(provider)},
|
|
301
|
+
"findings": findings,
|
|
302
|
+
"errors": errors,
|
|
303
|
+
"next": "Review the diff, then sync with the project's sync --dry-run and an explicit apply; never send real messages to test a template." if drift else "",
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- provider preflight -----------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def env_names(files: list[Path]) -> tuple[set[str], dict[str, str]]:
|
|
310
|
+
names = {name for name, value in os.environ.items() if value}
|
|
311
|
+
template_values: dict[str, str] = {}
|
|
312
|
+
for file in files:
|
|
313
|
+
if not file.is_file():
|
|
314
|
+
continue
|
|
315
|
+
for line in file.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
316
|
+
stripped = line.strip()
|
|
317
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
318
|
+
continue
|
|
319
|
+
name, value = stripped.removeprefix("export ").split("=", 1)
|
|
320
|
+
name, value = name.strip(), value.strip().strip("'\"")
|
|
321
|
+
if value:
|
|
322
|
+
names.add(name)
|
|
323
|
+
if name == "RESEND_TEMPLATE_IDS_JSON":
|
|
324
|
+
template_values[name] = value
|
|
325
|
+
if os.environ.get("RESEND_TEMPLATE_IDS_JSON") and "RESEND_TEMPLATE_IDS_JSON" not in template_values:
|
|
326
|
+
template_values["RESEND_TEMPLATE_IDS_JSON"] = os.environ["RESEND_TEMPLATE_IDS_JSON"]
|
|
327
|
+
return names, template_values
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def resend_preflight(project: Path, env_files: list[str], catalogue: dict | None, extra: list[str]) -> dict:
|
|
331
|
+
files = [project / item for item in env_files] if env_files else [project / ".env", project / ".env.production"]
|
|
332
|
+
names, template_values = env_names(files)
|
|
333
|
+
checks: dict[str, dict] = {}
|
|
334
|
+
errors: list[str] = []
|
|
335
|
+
warnings: list[str] = []
|
|
336
|
+
for check, accepted in RESEND_REQUIREMENTS:
|
|
337
|
+
present = [name for name in accepted if name in names]
|
|
338
|
+
checks[check] = {"present": bool(present), "names": list(accepted)}
|
|
339
|
+
if not present:
|
|
340
|
+
errors.append(f"{check}: set one of {', '.join(accepted)}")
|
|
341
|
+
for name in extra:
|
|
342
|
+
checks[name] = {"present": name in names, "names": [name]}
|
|
343
|
+
if name not in names:
|
|
344
|
+
errors.append(f"{name}: required variable is not set")
|
|
345
|
+
coverage: dict[str, object] = {}
|
|
346
|
+
if catalogue is not None:
|
|
347
|
+
expected = sorted(entry["alias"] for entry in catalogue.get("entries", []) if isinstance(entry, dict) and entry.get("provider") == "resend" and entry.get("status") == "implemented" and "email" in entry.get("channels", []))
|
|
348
|
+
mapped: set[str] = set()
|
|
349
|
+
raw = template_values.get("RESEND_TEMPLATE_IDS_JSON")
|
|
350
|
+
if raw:
|
|
351
|
+
try:
|
|
352
|
+
parsed = json.loads(raw)
|
|
353
|
+
mapped = {key.rsplit(".", 1)[0] if "." in key and LOCALE.match(key.rsplit(".", 1)[1]) else key for key in parsed} if isinstance(parsed, dict) else set()
|
|
354
|
+
except json.JSONDecodeError:
|
|
355
|
+
errors.append("template_map: RESEND_TEMPLATE_IDS_JSON is not valid JSON (value not shown)")
|
|
356
|
+
missing = [alias for alias in expected if alias not in mapped]
|
|
357
|
+
coverage = {"catalogueAliases": len(expected), "mappedAliases": len(mapped), "missing": missing, "unmapped": sorted(mapped - set(expected))}
|
|
358
|
+
if missing:
|
|
359
|
+
errors.append(f"template_map: {len(missing)} implemented Resend aliases have no template ID mapping")
|
|
360
|
+
warnings.append("Presence only: sending-domain verification, webhook reachability, timer health and authenticated synthetic delivery still need post-deploy evidence (see notifications health).")
|
|
361
|
+
return {"schemaVersion": "maggie-notification-provider-preflight.v1", "provider": "resend", "passed": not errors, "checks": checks, "templateCoverage": coverage, "errors": errors, "warnings": warnings, "valuesPrinted": False}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# --- queue health -----------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def queue_health(snapshot: dict, thresholds: dict, now: datetime) -> dict:
|
|
367
|
+
errors: list[str] = []
|
|
368
|
+
if snapshot.get("schemaVersion") != SNAPSHOT_SCHEMA:
|
|
369
|
+
errors.append(f"schemaVersion must be {SNAPSHOT_SCHEMA}")
|
|
370
|
+
if EMAIL.search(json.dumps(snapshot)):
|
|
371
|
+
errors.append("snapshot contains an email address; health evidence must be aggregate-only")
|
|
372
|
+
counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
|
|
373
|
+
alerts: list[dict] = []
|
|
374
|
+
|
|
375
|
+
def alert(code: str, observed: object, limit: object) -> None:
|
|
376
|
+
alerts.append({"code": code, "observed": observed, "threshold": limit, "channel": "operations", **ALERTS[code]})
|
|
377
|
+
|
|
378
|
+
pending = int(counts.get("pending", 0))
|
|
379
|
+
if pending > thresholds["max_pending"]:
|
|
380
|
+
alert("queue.pending_backlog", pending, thresholds["max_pending"])
|
|
381
|
+
dead = int(counts.get("deadLetter", 0))
|
|
382
|
+
if dead > thresholds["max_dead_letter"]:
|
|
383
|
+
alert("queue.dead_letter_growth", dead, thresholds["max_dead_letter"])
|
|
384
|
+
expired = int(snapshot.get("expiredLeases", 0))
|
|
385
|
+
if expired > thresholds["max_expired_leases"]:
|
|
386
|
+
alert("queue.expired_leases", expired, thresholds["max_expired_leases"])
|
|
387
|
+
ages: dict[str, float | None] = {}
|
|
388
|
+
for field, code, key in (("oldestPendingAt", "queue.oldest_pending_age", "max_oldest_pending_minutes"), ("lastWebhookAt", "webhook.lag", "max_webhook_lag_minutes"), ("lastSuccessfulRunAt", "worker.stale_run", "max_since_last_success_minutes")):
|
|
389
|
+
value = snapshot.get(field)
|
|
390
|
+
if value is None:
|
|
391
|
+
ages[field] = None
|
|
392
|
+
if field == "lastSuccessfulRunAt":
|
|
393
|
+
errors.append("lastSuccessfulRunAt is required; a timer can be active while the worker never succeeds")
|
|
394
|
+
continue
|
|
395
|
+
minutes = round((now - parse_time(value, field)).total_seconds() / 60, 1)
|
|
396
|
+
ages[field] = minutes
|
|
397
|
+
if field == "oldestPendingAt" and not pending:
|
|
398
|
+
continue
|
|
399
|
+
if field == "lastWebhookAt" and not int(counts.get("awaitingWebhook", 0)):
|
|
400
|
+
continue
|
|
401
|
+
if minutes > thresholds[key]:
|
|
402
|
+
alert(code, minutes, thresholds[key])
|
|
403
|
+
rates = snapshot.get("rates") if isinstance(snapshot.get("rates"), dict) else {}
|
|
404
|
+
if float(rates.get("bounce", 0)) > thresholds["max_bounce_rate"]:
|
|
405
|
+
alert("deliverability.bounce_rate", rates["bounce"], thresholds["max_bounce_rate"])
|
|
406
|
+
if float(rates.get("complaint", 0)) > thresholds["max_complaint_rate"]:
|
|
407
|
+
alert("deliverability.complaint_rate", rates["complaint"], thresholds["max_complaint_rate"])
|
|
408
|
+
if snapshot.get("senderDomainVerified") is False:
|
|
409
|
+
alert("deliverability.sender_domain", False, True)
|
|
410
|
+
critical = any(item["severity"] == "critical" for item in alerts)
|
|
411
|
+
return {
|
|
412
|
+
"schemaVersion": "maggie-notification-queue-health.v1",
|
|
413
|
+
"passed": not errors and not alerts,
|
|
414
|
+
"status": "failed" if errors or critical else ("degraded" if alerts else "healthy"),
|
|
415
|
+
"counts": {key: int(counts.get(key, 0)) for key in ("pending", "processing", "deadLetter", "awaitingWebhook")},
|
|
416
|
+
"ageMinutes": ages,
|
|
417
|
+
"thresholds": thresholds,
|
|
418
|
+
"alerts": alerts,
|
|
419
|
+
"errors": errors,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# --- retention dry run ------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
def retention_plan(catalogue: dict, inventory: dict, now: datetime) -> dict:
|
|
426
|
+
errors: list[str] = []
|
|
427
|
+
if inventory.get("schemaVersion") != INVENTORY_SCHEMA:
|
|
428
|
+
errors.append(f"inventory schemaVersion must be {INVENTORY_SCHEMA}")
|
|
429
|
+
if EMAIL.search(json.dumps(inventory)):
|
|
430
|
+
errors.append("inventory contains an email address; provide aggregate counts only")
|
|
431
|
+
policy = catalogue.get("retention") if isinstance(catalogue.get("retention"), dict) else {}
|
|
432
|
+
plan: dict[str, dict[str, int]] = {}
|
|
433
|
+
for index, row in enumerate(inventory.get("records", [])):
|
|
434
|
+
kind = row.get("kind")
|
|
435
|
+
if kind not in RETENTION_KINDS:
|
|
436
|
+
errors.append(f"records[{index}].kind must be one of {', '.join(RETENTION_KINDS)}")
|
|
437
|
+
continue
|
|
438
|
+
days = policy.get(kind, {}).get("days") if isinstance(policy.get(kind), dict) else None
|
|
439
|
+
if not isinstance(days, int):
|
|
440
|
+
errors.append(f"catalogue retention.{kind}.days is missing")
|
|
441
|
+
continue
|
|
442
|
+
bucket = plan.setdefault(kind, {"retentionDays": days, "keep": 0, "purge": 0, "heldForSupport": 0})
|
|
443
|
+
count = int(row.get("count", 0))
|
|
444
|
+
if row.get("legalHold") or row.get("openSupportCase"):
|
|
445
|
+
bucket["heldForSupport"] += count
|
|
446
|
+
elif now - parse_time(row.get("createdBefore") or row.get("createdAt"), f"records[{index}].createdAt") > timedelta(days=days):
|
|
447
|
+
bucket["purge"] += count
|
|
448
|
+
else:
|
|
449
|
+
bucket["keep"] += count
|
|
450
|
+
return {"schemaVersion": "maggie-notification-retention-plan.v1", "passed": not errors, "dryRun": True, "plan": plan, "errors": errors, "note": "Dry run only. Apply purges through the host's reviewed migration/job; suppression entries keep a hashed address only."}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# --- CLI -------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
def thresholds_from(args: argparse.Namespace) -> dict:
|
|
456
|
+
values = dict(DEFAULT_THRESHOLDS)
|
|
457
|
+
for key in DEFAULT_THRESHOLDS:
|
|
458
|
+
value = getattr(args, key, None)
|
|
459
|
+
if value is not None:
|
|
460
|
+
values[key] = value
|
|
461
|
+
return values
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def main(argv: list[str] | None = None) -> int:
|
|
465
|
+
parser = argparse.ArgumentParser(prog="maggie ops notifications", description=__doc__)
|
|
466
|
+
parser.add_argument("--project", default=".")
|
|
467
|
+
sub = parser.add_subparsers(dest="area", required=True)
|
|
468
|
+
|
|
469
|
+
catalogue = sub.add_parser("catalogue", help="validate a website notification catalogue")
|
|
470
|
+
catalogue.add_argument("action", choices=("validate",))
|
|
471
|
+
catalogue.add_argument("--catalogue", required=True)
|
|
472
|
+
|
|
473
|
+
templates = sub.add_parser("templates", help="compare catalogue, local templates and a provider export")
|
|
474
|
+
templates.add_argument("action", choices=("drift",))
|
|
475
|
+
templates.add_argument("--catalogue", required=True)
|
|
476
|
+
templates.add_argument("--local", help="template directory (<alias>.<locale>.<ext>) or maggie-notification-templates.v1 manifest")
|
|
477
|
+
templates.add_argument("--provider-export", action="append", default=[], help="provider list export page; repeat for every page in order")
|
|
478
|
+
templates.add_argument("--provider", default="resend")
|
|
479
|
+
|
|
480
|
+
preflight = sub.add_parser("preflight", help="check provider configuration by variable name only")
|
|
481
|
+
preflight.add_argument("--provider", choices=("resend",), default="resend")
|
|
482
|
+
preflight.add_argument("--env-file", action="append", default=[])
|
|
483
|
+
preflight.add_argument("--catalogue")
|
|
484
|
+
preflight.add_argument("--require", action="append", default=[])
|
|
485
|
+
|
|
486
|
+
health = sub.add_parser("health", help="evaluate an aggregate queue snapshot against thresholds")
|
|
487
|
+
health.add_argument("--snapshot", required=True)
|
|
488
|
+
health.add_argument("--now")
|
|
489
|
+
for key, value in DEFAULT_THRESHOLDS.items():
|
|
490
|
+
health.add_argument("--" + key.replace("_", "-"), dest=key, type=type(value))
|
|
491
|
+
|
|
492
|
+
retention = sub.add_parser("retention", help="dry-run retention purge plan from aggregate inventory")
|
|
493
|
+
retention.add_argument("action", choices=("plan",))
|
|
494
|
+
retention.add_argument("--catalogue", required=True)
|
|
495
|
+
retention.add_argument("--inventory", required=True)
|
|
496
|
+
retention.add_argument("--now")
|
|
497
|
+
|
|
498
|
+
sub.add_parser("alerts", help="print operator alert templates")
|
|
499
|
+
|
|
500
|
+
args = parser.parse_args(argv)
|
|
501
|
+
project = Path(args.project).resolve()
|
|
502
|
+
|
|
503
|
+
def path(value: str) -> Path:
|
|
504
|
+
candidate = Path(value)
|
|
505
|
+
return candidate if candidate.is_absolute() else project / candidate
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
if args.area == "catalogue":
|
|
509
|
+
return emit(validate_catalogue(load_json(path(args.catalogue))))
|
|
510
|
+
if args.area == "templates":
|
|
511
|
+
return emit(templates_drift(load_json(path(args.catalogue)), path(args.local) if args.local else None, [str(path(item)) for item in args.provider_export], args.provider))
|
|
512
|
+
if args.area == "preflight":
|
|
513
|
+
return emit(resend_preflight(project, args.env_file, load_json(path(args.catalogue)) if args.catalogue else None, args.require))
|
|
514
|
+
if args.area == "health":
|
|
515
|
+
now = parse_time(args.now, "--now") if args.now else datetime.now(timezone.utc)
|
|
516
|
+
return emit(queue_health(load_json(path(args.snapshot)), thresholds_from(args), now))
|
|
517
|
+
if args.area == "retention":
|
|
518
|
+
now = parse_time(args.now, "--now") if args.now else datetime.now(timezone.utc)
|
|
519
|
+
return emit(retention_plan(load_json(path(args.catalogue)), load_json(path(args.inventory)), now))
|
|
520
|
+
print(json.dumps({"schemaVersion": "maggie-notification-alerts.v1", "channel": "operations", "alerts": ALERTS}, indent=2))
|
|
521
|
+
return 0
|
|
522
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
523
|
+
print(json.dumps({"passed": False, "errors": [str(error)]}, indent=2))
|
|
524
|
+
return 2
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
if __name__ == "__main__":
|
|
528
|
+
raise SystemExit(main())
|
|
@@ -15,6 +15,8 @@ from seed_evidence import validate_manifest
|
|
|
15
15
|
from agent_runtime import preflight as agent_preflight
|
|
16
16
|
from restart_gate import validate as validate_restart_gate
|
|
17
17
|
from google_capabilities import validate_report as validate_google_capability_report
|
|
18
|
+
from google_remediation import classify as classify_google_failure
|
|
19
|
+
import google_ads
|
|
18
20
|
from maggie_favicon import check_favicon
|
|
19
21
|
from external_write import validate as validate_external_write
|
|
20
22
|
|
|
@@ -187,11 +189,63 @@ def command_google_capabilities(args: argparse.Namespace) -> int:
|
|
|
187
189
|
for item in result["providerResults"]
|
|
188
190
|
],
|
|
189
191
|
"failedChecks": result["errors"],
|
|
192
|
+
"remediation": result["remediation"],
|
|
190
193
|
"mutationsAllowed": False,
|
|
191
194
|
}, indent=2, ensure_ascii=False))
|
|
192
195
|
return 0 if result["passed"] else 1
|
|
193
196
|
|
|
194
197
|
|
|
198
|
+
def command_google_diagnose(args: argparse.Namespace) -> int:
|
|
199
|
+
result = classify_google_failure({"code": args.code or "", "reason": args.reason or ""}, status=args.http_status, provider=args.provider)
|
|
200
|
+
print(json.dumps({"schemaVersion": "maggie-google-remediation.v1", **result}, indent=2, ensure_ascii=False))
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def env_secret(name: str) -> str | None:
|
|
205
|
+
import os
|
|
206
|
+
value = os.environ.get(name, "").strip()
|
|
207
|
+
return value or None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def command_google_ads_preflight(args: argparse.Namespace) -> int:
|
|
211
|
+
result = google_ads.preflight(
|
|
212
|
+
customer_id=args.customer_id,
|
|
213
|
+
login_customer_id=args.login_customer_id,
|
|
214
|
+
access_token=env_secret(args.access_token_env),
|
|
215
|
+
developer_token=env_secret(args.developer_token_env),
|
|
216
|
+
api_version=args.api_version,
|
|
217
|
+
probe_keyword_planner=args.probe_keyword_planner,
|
|
218
|
+
)
|
|
219
|
+
if args.output:
|
|
220
|
+
write_json(Path(args.output).resolve(), result)
|
|
221
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
222
|
+
return 0 if result["passed"] else 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def command_google_ads_keyword_ideas(args: argparse.Namespace) -> int:
|
|
226
|
+
access_token = env_secret(args.access_token_env)
|
|
227
|
+
developer_token = env_secret(args.developer_token_env)
|
|
228
|
+
if not access_token or not developer_token:
|
|
229
|
+
raise ValueError(f"set {args.access_token_env} and {args.developer_token_env}; run google-ads-preflight first")
|
|
230
|
+
result = google_ads.keyword_ideas(
|
|
231
|
+
customer_id=args.customer_id,
|
|
232
|
+
login_customer_id=args.login_customer_id,
|
|
233
|
+
access_token=access_token,
|
|
234
|
+
developer_token=developer_token,
|
|
235
|
+
keywords=[item.strip() for item in args.keyword if item.strip()],
|
|
236
|
+
url=args.url,
|
|
237
|
+
language=args.language,
|
|
238
|
+
geo_targets=args.geo_target,
|
|
239
|
+
currency=args.currency,
|
|
240
|
+
max_pages=args.max_pages,
|
|
241
|
+
api_version=args.api_version,
|
|
242
|
+
)
|
|
243
|
+
if args.output:
|
|
244
|
+
write_json(Path(args.output).resolve(), result)
|
|
245
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
246
|
+
return 0 if result["passed"] else 1
|
|
247
|
+
|
|
248
|
+
|
|
195
249
|
def command_external_write_gate(args: argparse.Namespace) -> int:
|
|
196
250
|
evidence = read_json(Path(args.evidence).resolve())
|
|
197
251
|
result = validate_external_write(evidence)
|
|
@@ -218,6 +272,12 @@ def command_favicon_check(args: argparse.Namespace) -> int:
|
|
|
218
272
|
|
|
219
273
|
|
|
220
274
|
def main() -> int:
|
|
275
|
+
argv = sys.argv[1:]
|
|
276
|
+
positional = [item for index, item in enumerate(argv) if not item.startswith("-") and (index == 0 or argv[index - 1] != "--project")]
|
|
277
|
+
if positional and positional[0] == "notifications":
|
|
278
|
+
from maggie_notifications import main as notifications_main
|
|
279
|
+
argv.remove("notifications")
|
|
280
|
+
return notifications_main(argv)
|
|
221
281
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
222
282
|
parser.add_argument("--project", default=".")
|
|
223
283
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -239,6 +299,33 @@ def main() -> int:
|
|
|
239
299
|
google.add_argument("--report", required=True)
|
|
240
300
|
google.add_argument("--output")
|
|
241
301
|
google.set_defaults(func=command_google_capabilities)
|
|
302
|
+
diagnose = sub.add_parser("google-diagnose", help="map a redacted Google error code/reason to one prerequisite and safe next action")
|
|
303
|
+
diagnose.add_argument("--code", help="provider error code, e.g. SERVICE_DISABLED")
|
|
304
|
+
diagnose.add_argument("--reason", help="short redacted reason; never paste tokens or payloads")
|
|
305
|
+
diagnose.add_argument("--http-status", type=int)
|
|
306
|
+
diagnose.add_argument("--provider", choices=("gsc", "ga4", "gtm", "google-ads", "firebase"))
|
|
307
|
+
diagnose.set_defaults(func=command_google_diagnose)
|
|
308
|
+
for name, handler, help_text in (
|
|
309
|
+
("google-ads-preflight", command_google_ads_preflight, "read-only Google Ads prerequisite checks (scope, developer token, customer IDs, access, hierarchy, Keyword Planner readiness)"),
|
|
310
|
+
("google-ads-keyword-ideas", command_google_ads_keyword_ideas, "read-only KeywordPlanIdeaService request with normalized results"),
|
|
311
|
+
):
|
|
312
|
+
ads = sub.add_parser(name, help=help_text)
|
|
313
|
+
ads.add_argument("--customer-id", required=True)
|
|
314
|
+
ads.add_argument("--login-customer-id")
|
|
315
|
+
ads.add_argument("--access-token-env", default="GOOGLE_ADS_ACCESS_TOKEN", help="environment variable holding a short-lived OAuth access token")
|
|
316
|
+
ads.add_argument("--developer-token-env", default="GOOGLE_ADS_DEVELOPER_TOKEN")
|
|
317
|
+
ads.add_argument("--api-version", default=google_ads.DEFAULT_API_VERSION)
|
|
318
|
+
ads.add_argument("--output")
|
|
319
|
+
if name == "google-ads-preflight":
|
|
320
|
+
ads.add_argument("--probe-keyword-planner", action="store_true", help="spend one read-only one-result Keyword Planner request")
|
|
321
|
+
else:
|
|
322
|
+
ads.add_argument("--keyword", action="append", default=[])
|
|
323
|
+
ads.add_argument("--url")
|
|
324
|
+
ads.add_argument("--language", help="language constant ID, e.g. 1000 for English")
|
|
325
|
+
ads.add_argument("--geo-target", action="append", default=[], help="geo target constant ID, e.g. 2826 for the UK")
|
|
326
|
+
ads.add_argument("--currency", help="customer currency code for bid display (from preflight)")
|
|
327
|
+
ads.add_argument("--max-pages", type=int, default=1)
|
|
328
|
+
ads.set_defaults(func=handler)
|
|
242
329
|
external_write = sub.add_parser("external-write-gate", help="validate idempotent provider write/readback evidence")
|
|
243
330
|
external_write.add_argument("--evidence", required=True)
|
|
244
331
|
external_write.add_argument("--output")
|