@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.
Files changed (58) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +49 -15
  3. package/bin/maggie.js +6 -2
  4. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  5. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  6. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  7. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  8. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  9. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  10. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  11. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  12. package/bundled-marketplace/README.md +17 -0
  13. package/bundled-marketplace/catalog.json +56 -7
  14. package/bundled-references/browser-inspection.md +25 -0
  15. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  16. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  17. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  18. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  19. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  20. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  21. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  22. package/bundled-references/maggiedash-booking/README.md +23 -6
  23. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  24. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  25. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  26. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  27. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  28. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  29. package/bundled-references/provider-adapter-contract.md +33 -0
  30. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  31. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  32. package/bundled-skills/maggie-deployment/SKILL.md +53 -1
  33. package/bundled-skills/maggie-deployment/references/vps.md +41 -0
  34. package/bundled-skills/maggie-design/SKILL.md +99 -0
  35. package/bundled-skills/maggie-feedback/SKILL.md +38 -1
  36. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  38. package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
  39. package/bundled-skills/maggie-social-share/SKILL.md +37 -5
  40. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
  41. package/bundled-skills/maggie-template/SKILL.md +57 -3
  42. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  43. package/bundled-tools/clis/maggie_booking.py +199 -26
  44. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  45. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  46. package/bundled-tools/clis/maggie_deployment.py +249 -1
  47. package/bundled-tools/clis/maggie_design.py +9 -0
  48. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  49. package/bundled-tools/clis/maggie_feedback.py +106 -3
  50. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  51. package/bundled-tools/clis/maggie_palette.py +280 -0
  52. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  53. package/bundled-tools/clis/maggie_social_queue.py +338 -0
  54. package/bundled-tools/clis/maggie_social_share.py +42 -9
  55. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  56. package/package.json +1 -1
  57. package/references/browser-inspection.md +25 -0
  58. 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())