@topy-ai/maggie 0.7.47 → 0.7.48
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 +49 -15
- package/bin/maggie.js +6 -2
- 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/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/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-deployment/SKILL.md +53 -1
- package/bundled-skills/maggie-deployment/references/vps.md +41 -0
- package/bundled-skills/maggie-design/SKILL.md +99 -0
- package/bundled-skills/maggie-feedback/SKILL.md +38 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
- package/bundled-skills/maggie-social-share/SKILL.md +37 -5
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -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 +249 -1
- package/bundled-tools/clis/maggie_design.py +9 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_feedback.py +106 -3
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_social_queue.py +338 -0
- package/bundled-tools/clis/maggie_social_share.py +42 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Validate the Booking gift voucher lifecycle contract and host ledger fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONTRACT_SCHEMA = "maggiedash-booking-voucher-lifecycle.v1"
|
|
9
|
+
LEDGER_SCHEMA = "maggiedash-booking-voucher-ledger.v1"
|
|
10
|
+
STATES = ["pending_payment", "active", "exhausted", "expired", "voided", "refunded"]
|
|
11
|
+
ENTRY_TYPES = ["issue", "redeem", "undo_redeem", "void", "expire", "refund"]
|
|
12
|
+
PERMISSIONS = ["vouchers.read", "vouchers.sell", "vouchers.redeem", "vouchers.void", "vouchers.refund"]
|
|
13
|
+
NOTIFICATIONS = ["voucher.purchase.confirmed", "voucher.delivered", "voucher.redeemed", "voucher.refunded", "voucher.expiry_reminder"]
|
|
14
|
+
ENDPOINTS = ["booking.vouchers.read", "booking.vouchers.sell", "booking.vouchers.redeem", "booking.vouchers.undo", "booking.vouchers.void", "booking.vouchers.refund"]
|
|
15
|
+
# A ledger fixture is evidence, not a code store: the full code must never appear.
|
|
16
|
+
FORBIDDEN_KEYS = {"code", "voucherCode", "rawCode", "secret", "hmacSecret"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def voucher_contract_checks(contract: dict) -> list[str]:
|
|
20
|
+
errors: list[str] = []
|
|
21
|
+
if contract.get("schemaVersion") != CONTRACT_SCHEMA: errors.append(f"schemaVersion must be {CONTRACT_SCHEMA}")
|
|
22
|
+
if contract.get("status") != "contract": errors.append("status must be contract")
|
|
23
|
+
runtime = contract.get("runtime") or {}
|
|
24
|
+
if runtime.get("owner") != "host" or runtime.get("maggieDashRuntime") is not False: errors.append("voucher runtime must be host-owned; MaggieDash ships no voucher runtime")
|
|
25
|
+
if contract.get("apiPrefix") != "/api/maggie/booking/vouchers.json": errors.append("apiPrefix must be /api/maggie/booking/vouchers.json")
|
|
26
|
+
auth = contract.get("auth") or {}
|
|
27
|
+
if auth.get("managerSession") != "host-session-cookie" or auth.get("emailPassword") is not True or auth.get("passkeys") is not False: errors.append("auth must use the host email/password session")
|
|
28
|
+
if contract.get("permissions") != PERMISSIONS: errors.append("voucher permissions are incomplete or out of order")
|
|
29
|
+
money = contract.get("money") or {}
|
|
30
|
+
if money.get("unit") != "minor" or money.get("integerOnly") is not True: errors.append("voucher money must be integer minor units")
|
|
31
|
+
channels = contract.get("channels") or {}
|
|
32
|
+
online, till = channels.get("online") or {}, channels.get("till") or {}
|
|
33
|
+
if online.get("provider") != "stripe" or online.get("nativeGiftCards") is not False: errors.append("online vouchers must be funded by Stripe payments with a local ledger, not a native gift card")
|
|
34
|
+
if not till.get("paymentMethods"): errors.append("till vouchers must declare recorded payment methods")
|
|
35
|
+
code_format = contract.get("codeFormat") or {}
|
|
36
|
+
if (code_format.get("online") or {}).get("check") != "hmac-sha256": errors.append("online codes must carry an HMAC check segment")
|
|
37
|
+
if not (code_format.get("till") or {}).get("check"): errors.append("till codes must carry a check digit")
|
|
38
|
+
if "codeHash" not in str(code_format.get("storage", "")): errors.append("code storage must keep only a hash and last four characters")
|
|
39
|
+
if contract.get("states") != STATES: errors.append("voucher states are incomplete or out of order")
|
|
40
|
+
ledger = contract.get("ledger") or {}
|
|
41
|
+
if ledger.get("appendOnly") is not True: errors.append("voucher ledger must be append-only")
|
|
42
|
+
if ledger.get("entryTypes") != ENTRY_TYPES: errors.append("voucher ledger entry types are incomplete or out of order")
|
|
43
|
+
rules = contract.get("rules") or {}
|
|
44
|
+
for rule in ("issue", "partialRedemption", "undo", "void", "expiry", "refund", "fullRefund"):
|
|
45
|
+
if not str(rules.get(rule, "")).strip(): errors.append(f"voucher rule is missing: {rule}")
|
|
46
|
+
notifications = [item.get("id") for item in contract.get("notifications", []) if isinstance(item, dict)]
|
|
47
|
+
if notifications != NOTIFICATIONS: errors.append("voucher notification events are incomplete or out of order")
|
|
48
|
+
if contract.get("endpointIds") != ENDPOINTS: errors.append("voucher endpoint IDs are incomplete or out of order")
|
|
49
|
+
routes = contract.get("canonicalRoutes") or {}
|
|
50
|
+
if set(routes) != set(ENDPOINTS): errors.append("voucher canonical routes are incomplete")
|
|
51
|
+
policy = contract.get("policy") or {}
|
|
52
|
+
for field in ("tenantScoped", "auditRequired", "idempotencyRequiredForMutations", "fullCodeOnlyInDeliveryEmail", "providerCredentialsRemainServerOnly"):
|
|
53
|
+
if policy.get(field) is not True: errors.append(f"voucher policy {field} must be true")
|
|
54
|
+
return errors
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _time(value: object) -> datetime | None:
|
|
58
|
+
try:
|
|
59
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
return parsed if parsed.tzinfo else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _forbidden_paths(value: object, path: str = "") -> list[str]:
|
|
66
|
+
found: list[str] = []
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
for key, item in value.items():
|
|
69
|
+
child = f"{path}.{key}" if path else key
|
|
70
|
+
if key in FORBIDDEN_KEYS: found.append(child)
|
|
71
|
+
found.extend(_forbidden_paths(item, child))
|
|
72
|
+
elif isinstance(value, list):
|
|
73
|
+
for index, item in enumerate(value):
|
|
74
|
+
found.extend(_forbidden_paths(item, f"{path}[{index}]"))
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def replay_voucher(voucher: dict, now: datetime | None = None) -> tuple[dict, list[str]]:
|
|
79
|
+
"""Replay one voucher ledger and return its derived state plus rule violations."""
|
|
80
|
+
vid = str(voucher.get("id") or "unknown")
|
|
81
|
+
errors: list[str] = []
|
|
82
|
+
face = voucher.get("faceValue")
|
|
83
|
+
channel = voucher.get("channel")
|
|
84
|
+
if channel not in ("online", "till"): errors.append(f"{vid}: channel must be online or till")
|
|
85
|
+
if not isinstance(face, int) or isinstance(face, bool) or face <= 0: errors.append(f"{vid}: faceValue must be a positive integer in minor units")
|
|
86
|
+
expires_at = _time(voucher.get("expiresAt")) if voucher.get("expiresAt") else None
|
|
87
|
+
if voucher.get("expiresAt") and expires_at is None: errors.append(f"{vid}: expiresAt must be an ISO timestamp with a timezone")
|
|
88
|
+
entries = voucher.get("entries")
|
|
89
|
+
if not isinstance(entries, list) or not entries:
|
|
90
|
+
return {"id": vid, "balance": 0, "status": None}, errors + [f"{vid}: ledger entries are required"]
|
|
91
|
+
balance = funded = refunded = 0
|
|
92
|
+
paid = channel == "till"
|
|
93
|
+
voided = expired = False
|
|
94
|
+
redeems: dict[str, int] = {}
|
|
95
|
+
undone: set[str] = set()
|
|
96
|
+
redeemed_any = False
|
|
97
|
+
previous: datetime | None = None
|
|
98
|
+
seen_ids: set[str] = set()
|
|
99
|
+
for index, entry in enumerate(entries):
|
|
100
|
+
label = f"{vid}: entry {entry.get('id') or index}"
|
|
101
|
+
kind, amount, at = entry.get("type"), entry.get("amount"), _time(entry.get("at"))
|
|
102
|
+
entry_id = str(entry.get("id") or "")
|
|
103
|
+
if not entry_id or entry_id in seen_ids: errors.append(f"{label}: entry id must be present and unique")
|
|
104
|
+
seen_ids.add(entry_id)
|
|
105
|
+
if kind not in ENTRY_TYPES: errors.append(f"{label}: unsupported entry type"); continue
|
|
106
|
+
if at is None: errors.append(f"{label}: at must be an ISO timestamp with a timezone"); continue
|
|
107
|
+
if previous and at < previous: errors.append(f"{label}: ledger entries must be in chronological order")
|
|
108
|
+
previous = at
|
|
109
|
+
if kind != "issue" and kind not in ("void", "expire") and (not isinstance(amount, int) or isinstance(amount, bool) or amount <= 0):
|
|
110
|
+
errors.append(f"{label}: amount must be a positive integer"); continue
|
|
111
|
+
if kind != "issue" and not str(entry.get("idempotencyKey", "")).strip(): errors.append(f"{label}: mutations require an idempotencyKey")
|
|
112
|
+
if index == 0 and kind != "issue": errors.append(f"{label}: the first entry must be issue"); continue
|
|
113
|
+
if kind == "issue":
|
|
114
|
+
if index != 0: errors.append(f"{label}: only one issue entry is allowed"); continue
|
|
115
|
+
if amount != face: errors.append(f"{label}: issue amount must equal faceValue")
|
|
116
|
+
balance = funded = face if isinstance(face, int) else 0
|
|
117
|
+
if channel == "online":
|
|
118
|
+
paid = bool(entry.get("paymentRef"))
|
|
119
|
+
if not paid: errors.append(f"{label}: online issue requires the verified provider paymentRef")
|
|
120
|
+
continue
|
|
121
|
+
if voided: errors.append(f"{label}: no {kind} is allowed after void"); continue
|
|
122
|
+
if kind == "void":
|
|
123
|
+
if not str(entry.get("reason", "")).strip(): errors.append(f"{label}: void requires a reason")
|
|
124
|
+
voided = True
|
|
125
|
+
continue
|
|
126
|
+
if kind == "expire":
|
|
127
|
+
if expires_at is None or at < expires_at: errors.append(f"{label}: expire before expiresAt")
|
|
128
|
+
expired = True
|
|
129
|
+
continue
|
|
130
|
+
if kind == "redeem":
|
|
131
|
+
if expires_at is not None and at >= expires_at: errors.append(f"{label}: redemption at or after expiry is blocked"); continue
|
|
132
|
+
if not paid: errors.append(f"{label}: redemption before payment is blocked"); continue
|
|
133
|
+
if amount > balance: errors.append(f"{label}: redemption exceeds the remaining balance"); continue
|
|
134
|
+
balance -= amount
|
|
135
|
+
redeems[entry_id] = amount
|
|
136
|
+
redeemed_any = True
|
|
137
|
+
continue
|
|
138
|
+
if kind == "undo_redeem":
|
|
139
|
+
target = str(entry.get("reverses") or "")
|
|
140
|
+
if target not in redeems: errors.append(f"{label}: undo must reverse an earlier redeem entry"); continue
|
|
141
|
+
if target in undone: errors.append(f"{label}: a redemption can be undone only once"); continue
|
|
142
|
+
if amount != redeems[target]: errors.append(f"{label}: undo amount must equal the reversed redemption"); continue
|
|
143
|
+
undone.add(target)
|
|
144
|
+
balance += amount
|
|
145
|
+
continue
|
|
146
|
+
if kind == "refund":
|
|
147
|
+
if amount > balance: errors.append(f"{label}: refund exceeds the remaining balance"); continue
|
|
148
|
+
if refunded + amount > funded: errors.append(f"{label}: refund exceeds the funded amount"); continue
|
|
149
|
+
if channel == "online" and not entry.get("paymentRef"): errors.append(f"{label}: online refunds must reference the original provider payment")
|
|
150
|
+
if channel == "till" and not entry.get("refundMethod"): errors.append(f"{label}: till refunds must record refundMethod")
|
|
151
|
+
balance -= amount
|
|
152
|
+
refunded += amount
|
|
153
|
+
if balance < 0: errors.append(f"{vid}: balance became negative")
|
|
154
|
+
# Status is evaluated at the later of the fixture clock and the ledger's own last entry.
|
|
155
|
+
now = now or datetime.now().astimezone()
|
|
156
|
+
if previous and previous > now: now = previous
|
|
157
|
+
if voided: status = "voided"
|
|
158
|
+
elif not paid: status = "pending_payment"
|
|
159
|
+
elif refunded and balance == 0 and not redeemed_any: status = "refunded"
|
|
160
|
+
elif balance == 0: status = "exhausted"
|
|
161
|
+
elif expired or (expires_at is not None and now >= expires_at): status = "expired"
|
|
162
|
+
else: status = "active"
|
|
163
|
+
declared = voucher.get("status")
|
|
164
|
+
if declared is not None and declared != status: errors.append(f"{vid}: declared status {declared} does not match the ledger ({status})")
|
|
165
|
+
declared_balance = voucher.get("balance")
|
|
166
|
+
if declared_balance is not None and declared_balance != balance: errors.append(f"{vid}: declared balance {declared_balance} does not match the ledger ({balance})")
|
|
167
|
+
return {"id": vid, "balance": balance, "status": status, "refunded": refunded}, errors
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def voucher_ledger_checks(fixture: dict) -> tuple[list[dict], list[str]]:
|
|
171
|
+
errors: list[str] = []
|
|
172
|
+
if fixture.get("schemaVersion") != LEDGER_SCHEMA: errors.append(f"ledger schemaVersion must be {LEDGER_SCHEMA}")
|
|
173
|
+
errors.extend(f"ledger must not contain full codes or secrets: {path}" for path in _forbidden_paths(fixture))
|
|
174
|
+
now = _time(fixture.get("now")) if fixture.get("now") else None
|
|
175
|
+
if fixture.get("now") and now is None: errors.append("ledger now must be an ISO timestamp with a timezone")
|
|
176
|
+
vouchers = fixture.get("vouchers")
|
|
177
|
+
if not isinstance(vouchers, list) or not vouchers:
|
|
178
|
+
return [], errors + ["ledger must contain at least one voucher"]
|
|
179
|
+
results = []
|
|
180
|
+
for voucher in vouchers:
|
|
181
|
+
if not isinstance(voucher, dict): errors.append("voucher entries must be objects"); continue
|
|
182
|
+
result, voucher_errors = replay_voucher(voucher, now)
|
|
183
|
+
results.append(result)
|
|
184
|
+
errors.extend(voucher_errors)
|
|
185
|
+
return results, errors
|
|
@@ -58,6 +58,8 @@ def job_args(job: dict, marketplace: Path, template_id: str) -> argparse.Namespa
|
|
|
58
58
|
values.setdefault("tag", [])
|
|
59
59
|
values.setdefault("site_url", None)
|
|
60
60
|
values.setdefault("sample", None)
|
|
61
|
+
values.setdefault("display_name", None)
|
|
62
|
+
values.setdefault("display_description", None)
|
|
61
63
|
return argparse.Namespace(**values)
|
|
62
64
|
|
|
63
65
|
|
|
@@ -169,6 +171,7 @@ def plan_job(args: argparse.Namespace) -> int:
|
|
|
169
171
|
"options": {
|
|
170
172
|
"provider": args.provider, "brand": args.brand, "category": args.category,
|
|
171
173
|
"tag": args.tag, "site_url": args.site_url, "sample": str(args.sample) if args.sample else None,
|
|
174
|
+
"display_name": getattr(args, "display_name", None), "display_description": getattr(args, "display_description", None),
|
|
172
175
|
},
|
|
173
176
|
"history": [{"phase": "STARTED", "at": now()}],
|
|
174
177
|
}
|
|
@@ -222,6 +225,13 @@ def package_step(args: argparse.Namespace) -> int:
|
|
|
222
225
|
command.extend(("--site-url", options.site_url))
|
|
223
226
|
if options.sample:
|
|
224
227
|
command.extend(("--sample", str(options.sample)))
|
|
228
|
+
# The customer-facing style name is neutral and separate from source
|
|
229
|
+
# evidence; without an explicit name the importer derives one from the
|
|
230
|
+
# measured design and checks it against the source brand and catalog.
|
|
231
|
+
if options.display_name:
|
|
232
|
+
command.extend(("--display-name", options.display_name))
|
|
233
|
+
if options.display_description:
|
|
234
|
+
command.extend(("--display-description", options.display_description))
|
|
225
235
|
try:
|
|
226
236
|
result = subprocess.run(command, cwd=marketplace.parent)
|
|
227
237
|
template = marketplace / "templates" / template_id
|
|
@@ -363,6 +373,8 @@ def main() -> int:
|
|
|
363
373
|
plan_parser.add_argument("--category", action="append", default=[])
|
|
364
374
|
plan_parser.add_argument("--tag", action="append", default=[])
|
|
365
375
|
plan_parser.add_argument("--sample", type=Path)
|
|
376
|
+
plan_parser.add_argument("--display-name", help="neutral customer-facing style name; never the source brand")
|
|
377
|
+
plan_parser.add_argument("--display-description")
|
|
366
378
|
plan_parser.add_argument("--force", action="store_true")
|
|
367
379
|
plan_parser.set_defaults(func=plan_job)
|
|
368
380
|
for name, func, help_text in (
|
|
@@ -390,6 +402,8 @@ def main() -> int:
|
|
|
390
402
|
run_parser.add_argument("--category", action="append", default=[])
|
|
391
403
|
run_parser.add_argument("--tag", action="append", default=[])
|
|
392
404
|
run_parser.add_argument("--sample", type=Path)
|
|
405
|
+
run_parser.add_argument("--display-name", help="neutral customer-facing style name; never the source brand")
|
|
406
|
+
run_parser.add_argument("--display-description")
|
|
393
407
|
run_parser.add_argument("--force", action="store_true")
|
|
394
408
|
run_parser.set_defaults(func=run)
|
|
395
409
|
status_parser = sub.add_parser("status", help="inspect a URL-to-template job")
|
|
@@ -81,6 +81,238 @@ def credential_preflight_main(argv: list[str]) -> int:
|
|
|
81
81
|
return 0 if result["passed"] else 1
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
ENV_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}")
|
|
85
|
+
SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
|
86
|
+
DATABASE_URL_KEYS = ("DATABASE_URL", "POSTGRES_URL", "POSTGRES_PRISMA_URL", "PG_URL", "MYSQL_URL", "MONGODB_URI", "REDIS_URL")
|
|
87
|
+
DATABASE_HOST_KEYS = ("DB_HOST", "PGHOST", "POSTGRES_HOST", "MYSQL_HOST", "DATABASE_HOST")
|
|
88
|
+
LOCAL_DATABASE_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0", "db", "postgres", "host.docker.internal"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def env_fingerprint(value: str) -> str:
|
|
92
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fingerprint_manifest(env_file: Path, host: str, keys: list[str] | None = None) -> dict:
|
|
96
|
+
"""Return key names and SHA-256 fingerprints only; values never leave this function."""
|
|
97
|
+
values = parse_env_file(env_file)
|
|
98
|
+
selected = sorted(set(keys)) if keys else sorted(values)
|
|
99
|
+
return {
|
|
100
|
+
"schemaVersion": "maggie-env-fingerprints.v1",
|
|
101
|
+
"host": host,
|
|
102
|
+
"keys": {key: env_fingerprint(values[key]) for key in selected if values.get(key, "").strip()},
|
|
103
|
+
"valuesReturned": False,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_fingerprint_manifest(path: Path) -> dict:
|
|
108
|
+
"""Read a JSON manifest or the `KEY <sha256>` lines emitted by the no-echo shell snippet."""
|
|
109
|
+
text = path.read_text(encoding="utf-8")
|
|
110
|
+
try:
|
|
111
|
+
value = json.loads(text)
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
keys = {}
|
|
114
|
+
for line in text.splitlines():
|
|
115
|
+
parts = line.split()
|
|
116
|
+
if len(parts) == 2:
|
|
117
|
+
keys[parts[0]] = parts[1].lower()
|
|
118
|
+
value = {"schemaVersion": "maggie-env-fingerprints.v1", "host": path.stem, "keys": keys}
|
|
119
|
+
if not isinstance(value, dict) or not isinstance(value.get("keys"), dict):
|
|
120
|
+
raise ValueError(f"fingerprint manifest is invalid: {path.name}")
|
|
121
|
+
keys = {}
|
|
122
|
+
for key, digest in value["keys"].items():
|
|
123
|
+
if not ENV_KEY_RE.fullmatch(str(key)) or not SHA256_RE.fullmatch(str(digest)):
|
|
124
|
+
raise ValueError(f"fingerprint manifest {path.name} must contain only env names and SHA-256 hex digests")
|
|
125
|
+
keys[str(key)] = str(digest)
|
|
126
|
+
# The empty-string digest is what the snippet prints for an unset key.
|
|
127
|
+
empty = env_fingerprint("")
|
|
128
|
+
return {"host": str(value.get("host") or path.stem), "keys": {key: digest for key, digest in keys.items() if digest != empty}}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def secret_parity(hosts: list[dict], required: list[str], strict: bool = False) -> dict:
|
|
132
|
+
"""Compare env names and fingerprints across hosts; output carries short fingerprints only."""
|
|
133
|
+
errors: list[str] = []
|
|
134
|
+
warnings: list[str] = []
|
|
135
|
+
names = [host["host"] for host in hosts]
|
|
136
|
+
if len(hosts) < 2:
|
|
137
|
+
errors.append("at least two hosts are required for a parity check")
|
|
138
|
+
if len(set(names)) != len(names):
|
|
139
|
+
errors.append("host names must be unique")
|
|
140
|
+
required_names = sorted({name.strip() for name in required if name.strip()})
|
|
141
|
+
invalid = [name for name in required_names if not ENV_KEY_RE.fullmatch(name)]
|
|
142
|
+
if invalid:
|
|
143
|
+
errors.append("required names must be environment variable names")
|
|
144
|
+
union = sorted(set().union(*(host["keys"] for host in hosts)) | set(required_names)) if hosts else required_names
|
|
145
|
+
keys = []
|
|
146
|
+
for key in union:
|
|
147
|
+
present = {host["host"]: host["keys"][key] for host in hosts if key in host["keys"]}
|
|
148
|
+
missing = [name for name in names if name not in present]
|
|
149
|
+
distinct = sorted(set(present.values()))
|
|
150
|
+
status = "ok"
|
|
151
|
+
if missing:
|
|
152
|
+
status = "missing"
|
|
153
|
+
elif len(distinct) > 1:
|
|
154
|
+
status = "mismatch"
|
|
155
|
+
is_required = key in required_names
|
|
156
|
+
keys.append({
|
|
157
|
+
"key": key,
|
|
158
|
+
"required": is_required,
|
|
159
|
+
"status": status,
|
|
160
|
+
"missingOn": missing,
|
|
161
|
+
"fingerprints": {name: digest[:12] for name, digest in present.items()} if status == "mismatch" else {},
|
|
162
|
+
})
|
|
163
|
+
if status != "ok":
|
|
164
|
+
message = f"{key} is {status}" + (f" on {', '.join(missing)}" if missing else " across hosts")
|
|
165
|
+
(errors if is_required or strict else warnings).append(message)
|
|
166
|
+
return {
|
|
167
|
+
"schemaVersion": "maggie-secret-parity.v1",
|
|
168
|
+
"hosts": names,
|
|
169
|
+
"required": required_names,
|
|
170
|
+
"keys": keys,
|
|
171
|
+
"valuesReturned": False,
|
|
172
|
+
"passed": not errors,
|
|
173
|
+
"errors": errors,
|
|
174
|
+
"warnings": warnings,
|
|
175
|
+
"mutation": "not executed",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def secret_parity_main(argv: list[str]) -> int:
|
|
180
|
+
parser = argparse.ArgumentParser(
|
|
181
|
+
prog="maggie deployment secret-parity",
|
|
182
|
+
description="Compare env key names and SHA-256 fingerprints across hosts without printing values.",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument("--host", action="append", default=[], metavar="NAME=ENV_FILE", help="local env file for one host; repeat")
|
|
185
|
+
parser.add_argument("--manifest", action="append", default=[], help="fingerprint manifest produced on a host; repeat")
|
|
186
|
+
parser.add_argument("--required", action="append", default=[], help="required key name; repeat")
|
|
187
|
+
parser.add_argument("--strict", action="store_true", help="fail on optional keys that differ, not only required keys")
|
|
188
|
+
parser.add_argument("--output")
|
|
189
|
+
args = parser.parse_args(argv)
|
|
190
|
+
hosts = []
|
|
191
|
+
for item in args.host:
|
|
192
|
+
name, separator, path = item.partition("=")
|
|
193
|
+
if not separator or not name or not path:
|
|
194
|
+
parser.error("--host must be NAME=ENV_FILE")
|
|
195
|
+
hosts.append({"host": name, "keys": fingerprint_manifest(Path(path).expanduser(), name)["keys"]})
|
|
196
|
+
for path in args.manifest:
|
|
197
|
+
hosts.append(load_fingerprint_manifest(Path(path).expanduser()))
|
|
198
|
+
result = secret_parity(hosts, args.required, args.strict)
|
|
199
|
+
if args.output:
|
|
200
|
+
output = Path(args.output).resolve()
|
|
201
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
203
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
204
|
+
return 0 if result["passed"] else 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def secret_fingerprint_main(argv: list[str]) -> int:
|
|
208
|
+
parser = argparse.ArgumentParser(
|
|
209
|
+
prog="maggie deployment secret-fingerprint",
|
|
210
|
+
description="Write a host's env key names and SHA-256 fingerprints (never values) for secret-parity.",
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument("--env-file", required=True)
|
|
213
|
+
parser.add_argument("--host", required=True, help="stable host label, e.g. api-1")
|
|
214
|
+
parser.add_argument("--key", action="append", default=[], help="limit to these key names; repeat")
|
|
215
|
+
parser.add_argument("--output")
|
|
216
|
+
args = parser.parse_args(argv)
|
|
217
|
+
result = fingerprint_manifest(Path(args.env_file).expanduser(), args.host, args.key or None)
|
|
218
|
+
text = json.dumps(result, indent=2, ensure_ascii=False) + "\n"
|
|
219
|
+
if args.output:
|
|
220
|
+
output = Path(args.output).resolve()
|
|
221
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
output.write_text(text, encoding="utf-8")
|
|
223
|
+
print(text, end="")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def database_targets(values: dict[str, str]) -> list[dict]:
|
|
228
|
+
"""Extract database host/port/name per key; credentials are discarded."""
|
|
229
|
+
from urllib.parse import urlsplit
|
|
230
|
+
|
|
231
|
+
targets = []
|
|
232
|
+
for key, value in values.items():
|
|
233
|
+
if not value:
|
|
234
|
+
continue
|
|
235
|
+
if key in DATABASE_URL_KEYS or key.endswith("_DATABASE_URL") or key.endswith("_DB_URL"):
|
|
236
|
+
try:
|
|
237
|
+
parsed = urlsplit(value)
|
|
238
|
+
host = (parsed.hostname or "").lower()
|
|
239
|
+
port = parsed.port
|
|
240
|
+
except ValueError:
|
|
241
|
+
continue
|
|
242
|
+
if host:
|
|
243
|
+
targets.append({"key": key, "host": host, "port": port, "database": parsed.path.lstrip("/") or None})
|
|
244
|
+
elif key in DATABASE_HOST_KEYS:
|
|
245
|
+
targets.append({"key": key, "host": value.lower(), "port": None, "database": None})
|
|
246
|
+
return targets
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def environment_isolation(local_env: Path, production_env: Path | None = None, production_hosts: list[str] | None = None) -> dict:
|
|
250
|
+
"""Fail when a local env file points at the production database."""
|
|
251
|
+
errors: list[str] = []
|
|
252
|
+
warnings: list[str] = []
|
|
253
|
+
local = database_targets(parse_env_file(local_env)) if local_env.is_file() else []
|
|
254
|
+
production = database_targets(parse_env_file(production_env)) if production_env and production_env.is_file() else []
|
|
255
|
+
production += [{"key": "--production-db-host", "host": host.lower(), "port": None, "database": None} for host in production_hosts or [] if host]
|
|
256
|
+
if not local:
|
|
257
|
+
warnings.append("no database connection setting found in the local env file")
|
|
258
|
+
if not production:
|
|
259
|
+
warnings.append("no production database host provided; pass --production-env or --production-db-host")
|
|
260
|
+
findings = []
|
|
261
|
+
for item in local:
|
|
262
|
+
if item["host"] in LOCAL_DATABASE_HOSTS:
|
|
263
|
+
continue
|
|
264
|
+
for prod in production:
|
|
265
|
+
if item["host"] != prod["host"]:
|
|
266
|
+
continue
|
|
267
|
+
same_port = item["port"] is None or prod["port"] is None or item["port"] == prod["port"]
|
|
268
|
+
same_database = item["database"] is None or prod["database"] is None or item["database"] == prod["database"]
|
|
269
|
+
shared = same_port and same_database
|
|
270
|
+
findings.append({"localKey": item["key"], "productionKey": prod["key"], "host": item["host"], "sameDatabase": shared})
|
|
271
|
+
if shared:
|
|
272
|
+
errors.append(f"{item['key']} points at the production database host {item['host']}; local test data will reach live pages and workers")
|
|
273
|
+
else:
|
|
274
|
+
warnings.append(f"{item['key']} uses the production database server {item['host']} with a different database or port")
|
|
275
|
+
return {
|
|
276
|
+
"schemaVersion": "maggie-environment-isolation.v1",
|
|
277
|
+
"localTargets": [{key: item[key] for key in ("key", "host", "port")} for item in local],
|
|
278
|
+
"productionHosts": sorted({item["host"] for item in production}),
|
|
279
|
+
"findings": findings,
|
|
280
|
+
"credentialsReturned": False,
|
|
281
|
+
"passed": not errors,
|
|
282
|
+
"errors": errors,
|
|
283
|
+
"warnings": warnings,
|
|
284
|
+
"mutation": "not executed",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def environment_isolation_main(argv: list[str]) -> int:
|
|
289
|
+
parser = argparse.ArgumentParser(
|
|
290
|
+
prog="maggie deployment env-isolation",
|
|
291
|
+
description="Check that local development does not use the production database.",
|
|
292
|
+
)
|
|
293
|
+
parser.add_argument("--project", default=".")
|
|
294
|
+
parser.add_argument("--local-env", help="defaults to PROJECT/.env")
|
|
295
|
+
parser.add_argument("--production-env", help="production env file (read locally; values are not printed)")
|
|
296
|
+
parser.add_argument("--production-db-host", action="append", default=[], help="production database hostname; repeat")
|
|
297
|
+
parser.add_argument("--output")
|
|
298
|
+
args = parser.parse_args(argv)
|
|
299
|
+
project = Path(args.project).expanduser().resolve()
|
|
300
|
+
hosts = list(args.production_db_host)
|
|
301
|
+
if os.environ.get("MAGGIE_PRODUCTION_DB_HOST"):
|
|
302
|
+
hosts.append(os.environ["MAGGIE_PRODUCTION_DB_HOST"])
|
|
303
|
+
result = environment_isolation(
|
|
304
|
+
Path(args.local_env).expanduser().resolve() if args.local_env else project / ".env",
|
|
305
|
+
Path(args.production_env).expanduser().resolve() if args.production_env else None,
|
|
306
|
+
hosts,
|
|
307
|
+
)
|
|
308
|
+
if args.output:
|
|
309
|
+
output = Path(args.output).resolve()
|
|
310
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
312
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
313
|
+
return 0 if result["passed"] else 1
|
|
314
|
+
|
|
315
|
+
|
|
84
316
|
def _secret_names(payload: object) -> list[str]:
|
|
85
317
|
"""Extract only binding names from a Wrangler secret-list response."""
|
|
86
318
|
entries: object = payload
|
|
@@ -303,6 +535,14 @@ def preflight(
|
|
|
303
535
|
"commands": {key: scripts[key] for key in ("build", "test", "lint", "typecheck", "deploy") if key in scripts},
|
|
304
536
|
"next_action": "review target, environment, migrations, secrets, and rollback owner before execute",
|
|
305
537
|
}
|
|
538
|
+
isolation_warnings: list[str] = []
|
|
539
|
+
production_env = next((path for path in (project / ".env.production", project / ".env.prod") if path.is_file()), None)
|
|
540
|
+
production_host = os.environ.get("MAGGIE_PRODUCTION_DB_HOST", "").strip()
|
|
541
|
+
if (project / ".env").is_file() and (production_env or production_host):
|
|
542
|
+
isolation = environment_isolation(project / ".env", production_env, [production_host] if production_host else [])
|
|
543
|
+
result["environment_isolation"] = {"passed": isolation["passed"], "productionHosts": isolation["productionHosts"], "errors": isolation["errors"]}
|
|
544
|
+
result["checks"]["local_database_isolated"] = isolation["passed"]
|
|
545
|
+
isolation_warnings = [f"environment isolation: {message}" for message in isolation["errors"]]
|
|
306
546
|
if target in {"vps", "vps-with-cloudflare-dns"}:
|
|
307
547
|
result["provider"] = "vps"
|
|
308
548
|
result["architecture"] = "Astro Node standalone behind Nginx, managed by systemd, with release/current rollback layout"
|
|
@@ -357,12 +597,14 @@ def preflight(
|
|
|
357
597
|
result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
|
|
358
598
|
runners = sorted(deployment_dir.rglob("release-runner.sh")) if deployment_dir.exists() else []
|
|
359
599
|
result["release_runners"] = [release_runner_status(path) for path in runners[:25]]
|
|
360
|
-
result["warnings"] = [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
|
|
600
|
+
result["warnings"] = isolation_warnings + [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
|
|
361
601
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
362
602
|
if require_deployer:
|
|
363
603
|
result["blocking_checks"].append("deployer_delegation")
|
|
364
604
|
else:
|
|
365
605
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
|
|
606
|
+
if isolation_warnings:
|
|
607
|
+
result["warnings"] = isolation_warnings
|
|
366
608
|
return result
|
|
367
609
|
|
|
368
610
|
|
|
@@ -678,6 +920,12 @@ def main() -> int:
|
|
|
678
920
|
return credential_preflight_main(sys.argv[2:])
|
|
679
921
|
if sys.argv[1:2] == ["worker-secrets"]:
|
|
680
922
|
return worker_secret_preflight_main(sys.argv[2:])
|
|
923
|
+
if sys.argv[1:2] == ["secret-parity"]:
|
|
924
|
+
return secret_parity_main(sys.argv[2:])
|
|
925
|
+
if sys.argv[1:2] == ["secret-fingerprint"]:
|
|
926
|
+
return secret_fingerprint_main(sys.argv[2:])
|
|
927
|
+
if sys.argv[1:2] == ["env-isolation"]:
|
|
928
|
+
return environment_isolation_main(sys.argv[2:])
|
|
681
929
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
682
930
|
parser.add_argument("project", nargs="?", default=".")
|
|
683
931
|
parser.add_argument("--target", choices=("detected", "cloudflare", "vps", "vps-with-cloudflare-dns", "gcp", "aws"), default="detected")
|
|
@@ -854,9 +854,18 @@ 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
|
|
857
860
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
858
861
|
""")
|
|
859
862
|
return 0
|
|
863
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check"}:
|
|
864
|
+
from maggie_design_lint import main as lint_main
|
|
865
|
+
return lint_main(sys.argv[1:])
|
|
866
|
+
if len(sys.argv) > 1 and sys.argv[1] == "palette":
|
|
867
|
+
from maggie_palette import main as palette_main
|
|
868
|
+
return palette_main(sys.argv[2:])
|
|
860
869
|
if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
|
|
861
870
|
command = argparse.ArgumentParser(description="Record sanitized blog/service UI evidence from a read-only reference project.")
|
|
862
871
|
command.add_argument("--project", type=Path, default=Path.cwd())
|