@topy-ai/maggie 0.7.36 → 0.7.37

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.
@@ -0,0 +1,1080 @@
1
+ #!/usr/bin/env python3
2
+ """Install and validate the MaggieDash Booking workspace and host boundary."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import secrets
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+
18
+ REPO_ROOT = Path(__file__).resolve().parents[2]
19
+ # Source checkouts keep contracts and the delivery board at repository root;
20
+ # npm packages keep the same artifacts under bundled-contracts. Resolve both
21
+ # layouts so the published CLI uses the exact same Booking gates as the repo.
22
+ PACKAGED_CONTRACTS_ROOT = REPO_ROOT / "bundled-contracts"
23
+ CONTRACTS_ROOT = PACKAGED_CONTRACTS_ROOT if PACKAGED_CONTRACTS_ROOT.is_dir() else REPO_ROOT / "contracts"
24
+ DEFAULT_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-host-adapter-v1.json"
25
+ DEFAULT_ACCESS_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-access-v1.json"
26
+ DEFAULT_EMAIL_TEMPLATES_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-email-templates-v1.json"
27
+ DEFAULT_OPS_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-ops-evidence-v1.json"
28
+ DEFAULT_STRIPE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "stripe-booking-capabilities-v1.json"
29
+ EXECUTION_BOARD = (
30
+ CONTRACTS_ROOT / "maggiedash" / "execution-board.json"
31
+ if PACKAGED_CONTRACTS_ROOT.is_dir()
32
+ else REPO_ROOT / "docs" / "maggiedash-booking" / "EXECUTION-BOARD.json"
33
+ )
34
+
35
+
36
+ def load_json(path: Path) -> dict:
37
+ try:
38
+ value = json.loads(path.read_text(encoding="utf-8"))
39
+ except (OSError, json.JSONDecodeError) as error:
40
+ raise RuntimeError(f"could not read JSON: {path}: {error}") from error
41
+ if not isinstance(value, dict):
42
+ raise RuntimeError(f"JSON object required: {path}")
43
+ return value
44
+
45
+
46
+ def load_gate_json(path: Path, label: str) -> tuple[dict | None, str | None]:
47
+ try:
48
+ return load_json(path), None
49
+ except RuntimeError as error:
50
+ return None, f"{label}: {error}"
51
+
52
+
53
+ def load_execution_board(path: Path = EXECUTION_BOARD) -> dict:
54
+ board = load_json(path)
55
+ if board.get("schemaVersion") != "maggiedash-booking-execution-board.v1":
56
+ raise RuntimeError("unsupported Booking execution board schema")
57
+ if not isinstance(board.get("tasks"), list):
58
+ raise RuntimeError("Booking execution board tasks must be a list")
59
+ return board
60
+
61
+
62
+ def execution_board_checks(board: dict) -> list[str]:
63
+ errors: list[str] = []
64
+ tasks = board.get("tasks") or []
65
+ allowed_statuses = {"planned", "in_progress", "needs-evidence", "blocked", "passed"}
66
+ required_fields = {"id", "epic", "title", "repository", "dependsOn", "status", "plan", "implementation", "tests", "docs", "acceptance"}
67
+ ids: set[str] = set()
68
+ for task in tasks:
69
+ if not isinstance(task, dict):
70
+ errors.append("task must be an object")
71
+ continue
72
+ task_id = task.get("id")
73
+ if not task_id or task_id in ids: errors.append(f"duplicate or missing task id: {task_id or 'unknown'}")
74
+ ids.add(str(task_id))
75
+ missing = sorted(required_fields - set(task))
76
+ if missing: errors.append(f"{task_id}: missing fields {','.join(missing)}")
77
+ if task.get("status") not in allowed_statuses: errors.append(f"{task_id}: unsupported status {task.get('status')}")
78
+ for field in ("dependsOn", "plan", "implementation", "tests", "docs", "acceptance"):
79
+ if not isinstance(task.get(field), list) or not task.get(field): errors.append(f"{task_id}: {field} must be a non-empty list")
80
+ for task in tasks:
81
+ if not isinstance(task, dict): continue
82
+ for dependency in task.get("dependsOn", []):
83
+ if dependency not in ids and not (isinstance(dependency, str) and dependency.startswith("BK-0") and int(dependency.split("-")[1]) <= 16):
84
+ errors.append(f"{task.get('id')}: unknown dependency {dependency}")
85
+ if board.get("workflow") != ["task", "plan", "implement", "test", "update-docs", "record-progress"]:
86
+ errors.append("workflow must be task -> plan -> implement -> test -> update-docs -> record-progress")
87
+ return errors
88
+
89
+
90
+ def board_result(board: dict) -> dict:
91
+ errors = execution_board_checks(board)
92
+ tasks = [item for item in board.get("tasks", []) if isinstance(item, dict)]
93
+ unfinished = [item.get("id") for item in tasks if item.get("status") != "passed"]
94
+ return {
95
+ "status": "passed" if not errors and not unfinished else "blocked",
96
+ "tasks": len(tasks),
97
+ "passed": len(tasks) - len(unfinished),
98
+ "unfinished": unfinished,
99
+ "errors": errors,
100
+ }
101
+
102
+
103
+ def task_ready(task: dict, by_id: dict[str, dict]) -> bool:
104
+ for dependency in task.get("dependsOn", []):
105
+ dependency_task = by_id.get(dependency)
106
+ if dependency_task and dependency_task.get("status") != "passed": return False
107
+ return task.get("status") in {"planned", "in_progress", "needs-evidence"}
108
+
109
+
110
+ def command_tasks(args: argparse.Namespace) -> int:
111
+ board_path = Path(args.board).expanduser().resolve() if args.board else EXECUTION_BOARD
112
+ board = load_execution_board(board_path)
113
+ errors = execution_board_checks(board)
114
+ tasks = [item for item in board.get("tasks", []) if isinstance(item, dict)]
115
+ by_id = {item.get("id"): item for item in tasks}
116
+ if args.task_command == "validate":
117
+ result = {"status": "passed" if not errors else "failed", "board": str(board_path), "tasks": len(tasks), "errors": errors}
118
+ print(json.dumps(result, indent=2, ensure_ascii=False))
119
+ return 0 if not errors else 1
120
+ if args.task_command == "show":
121
+ task = by_id.get(args.task_id)
122
+ if not task:
123
+ print(json.dumps({"status": "not-found", "task": args.task_id}, indent=2, ensure_ascii=False))
124
+ return 1
125
+ print(json.dumps({**task, "ready": task_ready(task, by_id)}, indent=2, ensure_ascii=False))
126
+ return 0
127
+ if args.task_command == "list":
128
+ selected = [item for item in tasks if not args.status or item.get("status") == args.status]
129
+ print(json.dumps({"status": "passed" if not errors else "failed", "board": str(board_path), "items": [{"id": item.get("id"), "title": item.get("title"), "status": item.get("status"), "ready": task_ready(item, by_id), "dependsOn": item.get("dependsOn", [])} for item in selected], "errors": errors}, indent=2, ensure_ascii=False))
130
+ return 0 if not errors else 1
131
+ raise RuntimeError(f"unknown task command: {args.task_command}")
132
+
133
+
134
+ def stripe_contract_checks(contract: dict, max_age_days: int = 120) -> list[str]:
135
+ errors: list[str] = []
136
+ schema_version = contract.get("schemaVersion") or (contract.get("properties") or {}).get("schemaVersion", {}).get("const")
137
+ provider = contract.get("provider") or (contract.get("properties") or {}).get("provider", {}).get("const")
138
+ scope = contract.get("scope") or (contract.get("properties") or {}).get("scope", {}).get("const")
139
+ if schema_version != "maggiedash-stripe-booking-capabilities.v1": errors.append("schemaVersion must be maggiedash-stripe-booking-capabilities.v1")
140
+ if provider != "stripe": errors.append("provider must be stripe")
141
+ if scope != "booking-and-business-operations": errors.append("scope must cover booking-and-business-operations")
142
+ policy = contract.get("x-api-version-policy") or contract.get("apiVersionPolicy") or {}
143
+ for field in ("accountScoped", "pinServerVersion", "upgradeInSandbox", "avoidChargesForNewFlows"):
144
+ if policy.get(field) is not True: errors.append(f"Stripe API version policy {field} must be true")
145
+ apis = contract.get("x-api-matrix") or contract.get("apis") or []
146
+ api_ids = {item.get("id") for item in apis if isinstance(item, dict)}
147
+ required_apis = {"customers", "checkout_sessions", "payment_intents", "setup_intents", "refunds", "events", "promotion_codes", "coupons", "customer_balance_transactions", "subscriptions", "invoices", "customer_portal_sessions", "tax", "terminal", "connect"}
148
+ errors.extend(f"missing API matrix entry: {item}" for item in sorted(required_apis - api_ids))
149
+ events = contract.get("x-webhook-events") or contract.get("webhookEvents") or []
150
+ event_ids = {item.get("eventType") for item in events if isinstance(item, dict)}
151
+ required_events = {"checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "payment_intent.processing", "payment_intent.payment_failed", "payment_intent.succeeded", "payment_intent.requires_action", "payment_intent.canceled", "refund.created", "refund.updated", "refund.failed"}
152
+ errors.extend(f"missing webhook event: {item}" for item in sorted(required_events - event_ids))
153
+ native = contract.get("x-native-capabilities") or contract.get("nativeCapabilities") or {}
154
+ if native.get("giftCards") != "not-native": errors.append("giftCards must remain not-native")
155
+ if native.get("referrals") != "not-native": errors.append("referrals must remain not-native")
156
+ if native.get("discountCodes") != "native": errors.append("discountCodes must be native")
157
+ verified = contract.get("x-last-verified-at") or contract.get("lastVerifiedAt")
158
+ try:
159
+ age_days = (datetime.now(timezone.utc).date() - datetime.fromisoformat(str(verified)).date()).days
160
+ if age_days < 0 or age_days > max_age_days: errors.append(f"Stripe contract verification is {age_days} days old")
161
+ except (TypeError, ValueError):
162
+ errors.append("lastVerifiedAt must be an ISO date")
163
+ return errors
164
+
165
+
166
+ def contract_checks(contract: dict) -> list[str]:
167
+ errors: list[str] = []
168
+ schema_version = contract.get("schemaVersion")
169
+ api_prefix = contract.get("apiPrefix")
170
+ auth = contract.get("auth") or {}
171
+ endpoint_ids = contract.get("endpointIds")
172
+ if isinstance(contract.get("properties"), dict):
173
+ properties = contract["properties"]
174
+ schema_version = (properties.get("schemaVersion") or {}).get("const")
175
+ api_prefix = (properties.get("apiPrefix") or {}).get("const")
176
+ auth_properties = (properties.get("auth") or {}).get("properties") or {}
177
+ auth = {
178
+ "managerSession": (auth_properties.get("managerSession") or {}).get("const"),
179
+ "emailPassword": (auth_properties.get("emailPassword") or {}).get("const"),
180
+ "passkeys": (auth_properties.get("passkeys") or {}).get("const"),
181
+ }
182
+ endpoint_ids = [item.get("id") for item in contract.get("x-endpoints", []) if isinstance(item, dict) and item.get("id")]
183
+ if schema_version != "maggiedash-booking-host-adapter.v1": errors.append("schemaVersion must be maggiedash-booking-host-adapter.v1")
184
+ if api_prefix != "/api/maggie/booking": errors.append("apiPrefix must be /api/maggie/booking")
185
+ if auth.get("managerSession") != "host-session-cookie": errors.append("managerSession must use the host session cookie")
186
+ if auth.get("emailPassword") is not True: errors.append("emailPassword must be enabled")
187
+ if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
188
+ required = contract.get("requiredCapabilities")
189
+ if required is None and isinstance(contract.get("properties"), dict): required = ["bookingOverview", "bookingCalendar", "bookingLifecycle"]
190
+ if not isinstance(required, list) or not required: errors.append("requiredCapabilities must be a non-empty list")
191
+ if not isinstance(endpoint_ids, list) or len(endpoint_ids) != len(set(endpoint_ids)): errors.append("endpointIds must be unique")
192
+ if not endpoint_ids or not all(str(value).startswith("booking.") for value in endpoint_ids): errors.append("endpointIds must use the booking namespace")
193
+ if isinstance(contract.get("properties"), dict):
194
+ endpoints = [item for item in contract.get("x-endpoints", []) if isinstance(item, dict)]
195
+ if not endpoints: errors.append("detailed host contract must declare x-endpoints")
196
+ if len(endpoint_ids or []) != len(set(endpoint_ids or [])): errors.append("detailed host endpoint IDs must be unique")
197
+ if any(not isinstance(item.get("serviceMethod"), str) or not item.get("serviceMethod") for item in endpoints): errors.append("detailed host endpoints must declare serviceMethod")
198
+ else:
199
+ service_methods = contract.get("serviceMethods")
200
+ if not isinstance(service_methods, dict) or set(service_methods) != set(endpoint_ids or []): errors.append("serviceMethods must map every endpoint exactly once")
201
+ elif not all(isinstance(value, str) and value for value in service_methods.values()): errors.append("serviceMethods values must be non-empty strings")
202
+ customer_methods = contract.get("customerServiceMethods")
203
+ if customer_methods != ["readPublicCatalog", "readPublicAvailability", "createCustomerHold", "startCustomerCheckout", "readCustomerPaymentReturn", "readCustomerManage", "cancelCustomerBooking", "rescheduleCustomerBooking", "joinCustomerWaitlist"]: errors.append("customerServiceMethods must declare the nine public customer methods")
204
+ return errors
205
+
206
+
207
+ def access_contract_checks(contract: dict) -> list[str]:
208
+ errors: list[str] = []
209
+ properties = contract.get("properties") if isinstance(contract.get("properties"), dict) else {}
210
+ if properties:
211
+ schema_version = (properties.get("schemaVersion") or {}).get("const")
212
+ api_prefix = (properties.get("apiPrefix") or {}).get("const")
213
+ auth_properties = (properties.get("auth") or {}).get("properties") or {}
214
+ auth = {key: (auth_properties.get(key) or {}).get("const") for key in ("administratorSession", "emailPassword", "passkeys", "resendInvitation")}
215
+ roles = (properties.get("roles") or {}).get("const")
216
+ permissions = (properties.get("permissions") or {}).get("const") or []
217
+ endpoint_items = [item for item in contract.get("x-endpoints", []) if isinstance(item, dict)]
218
+ endpoint_ids = [item.get("id") for item in endpoint_items]
219
+ routes = {item.get("id"): f"{item.get('method')} {item.get('route')}" for item in endpoint_items if item.get("id")}
220
+ policy_properties = (properties.get("policy") or {}).get("properties") or {}
221
+ policy = {key: (policy_properties.get(key) or {}).get("const") for key in ("tenantScoped", "disabledAccountsStayDisabled", "passwordMinimumLength")}
222
+ else:
223
+ schema_version = contract.get("schemaVersion")
224
+ api_prefix = contract.get("apiPrefix")
225
+ auth = contract.get("auth") or {}
226
+ roles = contract.get("roles")
227
+ permissions = contract.get("permissions") or []
228
+ endpoint_ids = contract.get("endpointIds") or [item.get("id") for item in contract.get("x-endpoints", []) if isinstance(item, dict)]
229
+ routes = contract.get("canonicalRoutes") or {}
230
+ policy = contract.get("policy") or {}
231
+ if schema_version != "maggiedash-booking-access.v1": errors.append("schemaVersion must be maggiedash-booking-access.v1")
232
+ if api_prefix != "/api/maggie/booking/access.json": errors.append("apiPrefix must be /api/maggie/booking/access.json")
233
+ if auth.get("administratorSession") != "host-session-cookie": errors.append("administratorSession must use the host session cookie")
234
+ if auth.get("emailPassword") is not True: errors.append("emailPassword must be enabled")
235
+ if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
236
+ if auth.get("resendInvitation") is not True: errors.append("resendInvitation must be enabled")
237
+ if roles != ["manager", "staff", "finance"]: errors.append("roles must be manager, staff, finance")
238
+ for permission in ("calendar.read", "bookings.manage", "payments.manage", "refunds.manage", "audit.read"):
239
+ if permission not in permissions: errors.append(f"missing permission: {permission}")
240
+ if endpoint_ids != ["booking.access.read", "booking.access.create", "booking.access.update", "booking.access.revoke"]: errors.append("access endpoint IDs are incomplete or out of order")
241
+ expected = {"booking.access.read": "GET /access.json", "booking.access.create": "POST /access.json", "booking.access.update": "PATCH /access.json", "booking.access.revoke": "DELETE /access.json"}
242
+ if routes != expected: errors.append("access canonical routes are incomplete")
243
+ if policy.get("tenantScoped") is not True or policy.get("disabledAccountsStayDisabled") is not True or policy.get("passwordMinimumLength") != 12: errors.append("access safety policy is incomplete")
244
+ return errors
245
+
246
+
247
+ def email_templates_contract_checks(contract: dict) -> list[str]:
248
+ errors: list[str] = []
249
+ properties = contract.get("properties") if isinstance(contract.get("properties"), dict) else {}
250
+ if properties:
251
+ schema_version = (properties.get("schemaVersion") or {}).get("const")
252
+ api_prefix = (properties.get("apiPrefix") or {}).get("const")
253
+ auth_properties = (properties.get("auth") or {}).get("properties") or {}
254
+ auth = {key: (auth_properties.get(key) or {}).get("const") for key in ("managerSession", "emailPassword", "passkeys")}
255
+ policy_properties = (properties.get("policy") or {}).get("properties") or {}
256
+ policy = {key: (policy_properties.get(key) or {}).get("const") for key in ("tenantScoped", "allowedRoles", "variableSyntax", "maxHtmlLength", "auditRequired", "idempotencyRequiredForMutations", "syntax", "providerCredentialsRemainServerOnly", "recipientDataNeverReturned")}
257
+ roles = policy.get("allowedRoles")
258
+ endpoint_items = [item for item in contract.get("endpoints", []) if isinstance(item, dict)]
259
+ endpoint_ids = [item.get("id") for item in endpoint_items]
260
+ routes = {item.get("id"): f"{item.get('method')} {item.get('route')}" for item in endpoint_items if item.get("id")}
261
+ else:
262
+ schema_version = contract.get("schemaVersion")
263
+ api_prefix = contract.get("apiPrefix")
264
+ auth = contract.get("auth") or {}
265
+ policy = contract.get("policy") or {}
266
+ roles = contract.get("roles")
267
+ endpoint_ids = contract.get("endpointIds")
268
+ routes = contract.get("canonicalRoutes") or {}
269
+ if schema_version != "maggiedash-booking-email-templates.v1": errors.append("schemaVersion must be maggiedash-booking-email-templates.v1")
270
+ if api_prefix != "/api/maggie/booking/email-templates.json": errors.append("apiPrefix must be /api/maggie/booking/email-templates.json")
271
+ if auth.get("managerSession") != "host-session-cookie": errors.append("managerSession must use the host session cookie")
272
+ if auth.get("emailPassword") is not True: errors.append("emailPassword must be enabled")
273
+ if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
274
+ if roles != ["owner", "admin", "manager"]: errors.append("roles must be owner, admin, manager")
275
+ events = contract.get("events")
276
+ if not isinstance(events, list) or len(events) != 7: errors.append("exactly seven Booking email events are required")
277
+ event_ids = [item.get("id") for item in events if isinstance(item, dict)] if isinstance(events, list) else []
278
+ expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "booking.waitlist.available"]
279
+ if event_ids != expected_events: errors.append("email event IDs are incomplete or out of order")
280
+ if len(event_ids) != len(set(event_ids)): errors.append("email event IDs must be unique")
281
+ allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "siteName"}
282
+ for event in events if isinstance(events, list) else []:
283
+ variables = event.get("variables") if isinstance(event, dict) else None
284
+ if not isinstance(variables, list) or not variables: errors.append(f"{event.get('id', 'unknown')}: variables must be a non-empty list")
285
+ if any(variable not in allowed for variable in variables or []): errors.append(f"{event.get('id', 'unknown')}: unsupported template variable")
286
+ if endpoint_ids != ["booking.email-templates.read", "booking.email-templates.draft", "booking.email-templates.publish", "booking.email-templates.rollback"]: errors.append("email template endpoint IDs are incomplete or out of order")
287
+ expected_routes = {
288
+ "booking.email-templates.read": "GET /email-templates.json",
289
+ "booking.email-templates.draft": "PATCH /email-templates.json?action=draft",
290
+ "booking.email-templates.publish": "PATCH /email-templates.json?action=publish",
291
+ "booking.email-templates.rollback": "PATCH /email-templates.json?action=rollback",
292
+ }
293
+ if routes != expected_routes: errors.append("email template canonical routes are incomplete")
294
+ if policy.get("tenantScoped") is not True or policy.get("auditRequired") is not True or policy.get("idempotencyRequiredForMutations") is not True: errors.append("email template tenant, audit, and idempotency policies are required")
295
+ syntax = policy.get("syntax") or policy.get("variableSyntax")
296
+ if syntax not in ("{{variable}}", "{{name}}") or policy.get("maxHtmlLength") != 50000: errors.append("email template syntax or length policy is incorrect")
297
+ if policy.get("providerCredentialsRemainServerOnly") is not True or policy.get("recipientDataNeverReturned") is not True: errors.append("email template provider and recipient privacy policy is incomplete")
298
+ return errors
299
+
300
+
301
+ def customer_contract_checks(contract: dict) -> list[str]:
302
+ errors: list[str] = []
303
+ properties = contract.get("properties") if isinstance(contract.get("properties"), dict) else {}
304
+ if properties:
305
+ schema_version = (properties.get("schemaVersion") or {}).get("const")
306
+ api_prefix = (properties.get("apiPrefix") or {}).get("const")
307
+ security = properties.get("security") or {}
308
+ security_properties = security.get("properties") or {}
309
+ auth = security_properties.get("auth", {}).get("const")
310
+ noindex = security_properties.get("noindex", {}).get("const")
311
+ endpoints = [item for item in contract.get("x-endpoints", []) if isinstance(item, dict)]
312
+ ids = [item.get("id") for item in endpoints]
313
+ else:
314
+ schema_version = contract.get("schemaVersion")
315
+ api_prefix = contract.get("apiPrefix")
316
+ auth = contract.get("auth")
317
+ noindex = contract.get("noindex")
318
+ ids = [str(item) for item in contract.get("endpointIds", [])]
319
+ response_policy = contract.get("responsePolicy") if isinstance(contract.get("responsePolicy"), dict) else {}
320
+ canonical = contract.get("canonicalRoutes") or {}
321
+ endpoints = []
322
+ for item in ids:
323
+ route = str(canonical.get(item, ""))
324
+ method, _, relative = route.partition(" ")
325
+ endpoints.append({"id": item, "method": method, "route": relative, "idempotent": method == "GET" or item.endswith((".create", ".start", ".cancel", "-cancel", ".reschedule", "-reschedule", ".join"))})
326
+ if schema_version != "maggiedash-booking-customer-surface.v1": errors.append("schemaVersion must be maggiedash-booking-customer-surface.v1")
327
+ if api_prefix != "/api/maggie/booking/public": errors.append("apiPrefix must be /api/maggie/booking/public")
328
+ page_path = (properties.get("pagePath") or {}).get("const") if properties else contract.get("pagePath")
329
+ if page_path != "/_maggie/booking/book": errors.append("pagePath must be /_maggie/booking/book")
330
+ if auth != "public-noindex": errors.append("customer auth must be public-noindex")
331
+ if noindex is not True: errors.append("customer surface must be noindex")
332
+ if properties == {} and response_policy.get("mutationPolicy") != "all-public-mutations-require-idempotency-key-and-replay-original-result": errors.append("customer mutationPolicy must require idempotent replay")
333
+ if properties == {} and set(response_policy.get("holdInputPolicy", [])) != {"valid-email", "booking-update-consent"}: errors.append("customer holdInputPolicy must require valid email and booking-update-consent")
334
+ if len(endpoints) != 9: errors.append("customer surface must declare nine endpoints")
335
+ if len(ids) != len(set(ids)): errors.append("customer endpoint IDs must be unique")
336
+ if not all(str(item).startswith("booking.public.") for item in ids): errors.append("customer endpoint IDs must use the booking.public namespace")
337
+ for endpoint in endpoints:
338
+ if endpoint.get("method") not in {"GET", "POST"}: errors.append(f"{endpoint.get('id')}: customer method is unsupported")
339
+ if not str(endpoint.get("route", "")).startswith("/"): errors.append(f"{endpoint.get('id')}: customer route must be relative")
340
+ if endpoint.get("idempotent") is not True: errors.append(f"{endpoint.get('id')}: customer route must be idempotent")
341
+ return errors
342
+
343
+
344
+ def command_contract(args: argparse.Namespace) -> int:
345
+ path = Path(args.contract).expanduser().resolve()
346
+ contract = load_json(path)
347
+ errors = contract_checks(contract)
348
+ endpoint_items = contract.get("endpointIds") or [item.get("id") for item in contract.get("x-endpoints", []) if isinstance(item, dict) and item.get("id")]
349
+ result = {"status": "passed" if not errors else "failed", "contract": str(path), "schemaVersion": contract.get("schemaVersion"), "endpointCount": len(endpoint_items), "errors": errors}
350
+ print(json.dumps(result, indent=2, ensure_ascii=False))
351
+ return 0 if not errors else 1
352
+
353
+
354
+ def command_access_contract(args: argparse.Namespace) -> int:
355
+ path = Path(args.contract).expanduser().resolve()
356
+ contract = load_json(path)
357
+ errors = access_contract_checks(contract)
358
+ properties = contract.get("properties") if isinstance(contract.get("properties"), dict) else {}
359
+ endpoint_items = contract.get("endpointIds") or [item.get("id") for item in contract.get("x-endpoints", []) if isinstance(item, dict) and item.get("id")]
360
+ result = {"status": "passed" if not errors else "failed", "contract": str(path), "schemaVersion": contract.get("schemaVersion") or (properties.get("schemaVersion") or {}).get("const"), "endpointCount": len(endpoint_items), "errors": errors}
361
+ print(json.dumps(result, indent=2, ensure_ascii=False))
362
+ return 0 if not errors else 1
363
+
364
+
365
+ def command_email_templates_contract(args: argparse.Namespace) -> int:
366
+ path = Path(args.contract).expanduser().resolve()
367
+ contract = load_json(path)
368
+ errors = email_templates_contract_checks(contract)
369
+ properties = contract.get("properties") if isinstance(contract.get("properties"), dict) else {}
370
+ endpoint_items = contract.get("endpointIds") or [item.get("id") for item in contract.get("endpoints", []) if isinstance(item, dict) and item.get("id")]
371
+ result = {"status": "passed" if not errors else "failed", "contract": str(path), "schemaVersion": contract.get("schemaVersion") or (properties.get("schemaVersion") or {}).get("const"), "eventCount": len(contract.get("events") or []), "endpointCount": len(endpoint_items), "errors": errors}
372
+ print(json.dumps(result, indent=2, ensure_ascii=False))
373
+ return 0 if not errors else 1
374
+
375
+
376
+ def project_path(args: argparse.Namespace) -> Path:
377
+ return Path(args.project).expanduser().resolve()
378
+
379
+
380
+ def middleware_has_booking_rewrites(text: str) -> bool:
381
+ """Accept both TypeScript regex source (`\\/`) and plain path markers."""
382
+ normalized = text.replace("\\/", "/")
383
+ return all(marker in normalized for marker in ("/_maggie/booking", "/_maggie/login", "/_maggie/register"))
384
+
385
+
386
+ ENV_FILES = (
387
+ ".env",
388
+ ".env.local",
389
+ ".env.production",
390
+ ".env.production.local",
391
+ ".env.maggie-booking",
392
+ ".env.maggie-booking.local",
393
+ )
394
+
395
+ REQUIRED_HOST_DEPENDENCIES = (
396
+ "@astrojs/react",
397
+ "@phosphor-icons/react",
398
+ "@tailwindcss/vite",
399
+ "pg",
400
+ "react",
401
+ "react-dom",
402
+ "stripe",
403
+ "tailwindcss",
404
+ )
405
+
406
+ # Keep the host install path reproducible without taking ownership of the
407
+ # host's Astro version. Existing dependencies are left untouched; only
408
+ # missing packages are added by `--install-dependencies`.
409
+ HOST_DEPENDENCY_RANGES = {
410
+ "@astrojs/react": "^6.0.0",
411
+ "@phosphor-icons/react": "^2.1.10",
412
+ "@tailwindcss/vite": "^4.0.0",
413
+ "pg": "^8.0.0",
414
+ "react": "^19.0.0",
415
+ "react-dom": "^19.0.0",
416
+ "stripe": "^22.0.0",
417
+ "tailwindcss": "^4.0.0",
418
+ }
419
+
420
+
421
+ def missing_host_dependencies(root: Path) -> list[str]:
422
+ """Return only host packages absent from both dependency sections."""
423
+ try:
424
+ package = json.loads((root / "package.json").read_text(encoding="utf-8"))
425
+ except (OSError, json.JSONDecodeError):
426
+ return list(REQUIRED_HOST_DEPENDENCIES)
427
+ installed = set(package.get("dependencies") or {}) | set(package.get("devDependencies") or {})
428
+ return sorted(name for name in REQUIRED_HOST_DEPENDENCIES if name not in installed)
429
+
430
+
431
+ def host_package_manager(root: Path) -> tuple[str, list[str]]:
432
+ """Select the package manager already declared by the host project."""
433
+ lockfiles = (
434
+ ("pnpm-lock.yaml", "pnpm"),
435
+ ("yarn.lock", "yarn"),
436
+ ("bun.lockb", "bun"),
437
+ ("bun.lock", "bun"),
438
+ ("package-lock.json", "npm"),
439
+ )
440
+ manager = next((name for filename, name in lockfiles if (root / filename).is_file()), "npm")
441
+ if shutil.which(manager) is None:
442
+ raise RuntimeError(f"{manager} is required to install Booking host dependencies but is not available on PATH")
443
+ packages = [f"{name}@{HOST_DEPENDENCY_RANGES[name]}" for name in missing_host_dependencies(root)]
444
+ if manager == "npm":
445
+ return manager, ["npm", "install", "--save", "--no-audit", "--no-fund", *packages]
446
+ if manager == "pnpm":
447
+ return manager, ["pnpm", "add", *packages]
448
+ if manager == "yarn":
449
+ return manager, ["yarn", "add", *packages]
450
+ return manager, ["bun", "add", *packages]
451
+
452
+
453
+ def install_host_dependencies(root: Path, runner=None) -> dict:
454
+ """Install only missing Astro Booking packages through the host manager."""
455
+ missing = missing_host_dependencies(root)
456
+ if not missing:
457
+ return {"status": "unchanged", "missing": [], "packageManager": None, "command": []}
458
+ manager, command = host_package_manager(root)
459
+ execute = runner or subprocess.run
460
+ completed = execute(command, cwd=root, check=False)
461
+ if completed.returncode != 0:
462
+ raise RuntimeError(f"{manager} failed while installing {len(missing)} missing Booking host dependencies")
463
+ return {"status": "installed", "missing": missing, "packageManager": manager, "command": [manager, "add" if manager != "npm" else "install", *[f"{name}@{HOST_DEPENDENCY_RANGES[name]}" for name in missing]]}
464
+
465
+
466
+ def _meaningful_env_value(value: str) -> bool:
467
+ """Return whether a dotenv value is configured without exposing it."""
468
+ normalized = value.strip().strip('"').strip("'").strip()
469
+ if not normalized:
470
+ return False
471
+ if normalized.startswith("<") and normalized.endswith(">"):
472
+ return False
473
+ if normalized in {"...", "changeme", "change-me", "your-value", "your_value"}:
474
+ return False
475
+ return True
476
+
477
+
478
+ def _read_env_keys(root: Path, environment: dict[str, str] | None = None) -> dict[str, list[str]]:
479
+ """Find configured keys while keeping values out of all reports."""
480
+ sources: dict[str, list[str]] = {}
481
+ current = environment if environment is not None else os.environ
482
+ for key, value in current.items():
483
+ if _meaningful_env_value(str(value)):
484
+ sources.setdefault(key, []).append("process")
485
+ assignment = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*(?:#.*)?$")
486
+ for relative in ENV_FILES:
487
+ path = root / relative
488
+ if not path.is_file():
489
+ continue
490
+ try:
491
+ lines = path.read_text(encoding="utf-8").splitlines()
492
+ except OSError:
493
+ continue
494
+ for line in lines:
495
+ match = assignment.match(line)
496
+ if not match or not _meaningful_env_value(match.group(2)):
497
+ continue
498
+ key = match.group(1)
499
+ sources.setdefault(key, []).append(relative)
500
+ return sources
501
+
502
+
503
+ def _read_env_values(root: Path, environment: dict[str, str] | None = None) -> dict[str, str]:
504
+ """Read values only for local validation; callers must never serialize them."""
505
+ values: dict[str, str] = {}
506
+ current = environment if environment is not None else os.environ
507
+ for key, value in current.items():
508
+ if _meaningful_env_value(str(value)):
509
+ values[key] = str(value).strip().strip('"').strip("'").strip()
510
+ assignment = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*(?:#.*)?$")
511
+ for relative in ENV_FILES:
512
+ path = root / relative
513
+ if not path.is_file():
514
+ continue
515
+ try:
516
+ lines = path.read_text(encoding="utf-8").splitlines()
517
+ except OSError:
518
+ continue
519
+ for line in lines:
520
+ match = assignment.match(line)
521
+ if not match or not _meaningful_env_value(match.group(2)):
522
+ continue
523
+ values.setdefault(match.group(1), match.group(2).strip().strip('"').strip("'").strip())
524
+ return values
525
+
526
+
527
+ def setup_check(check_id: str, label: str, keys: list[str], configured: bool, *, required: bool = True, detail: str = "", status_override: str | None = None) -> dict:
528
+ return {
529
+ "id": check_id,
530
+ "label": label,
531
+ "status": status_override or ("passed" if configured else ("missing" if required else "optional")),
532
+ "required": required,
533
+ "keys": keys,
534
+ **({"detail": detail} if detail else {}),
535
+ }
536
+
537
+
538
+ def booking_setup_result(root: Path, environment: dict[str, str] | None = None) -> dict:
539
+ """Build a safe first-run checklist; never connects to the database or providers."""
540
+ sources = _read_env_keys(root, environment)
541
+ values = _read_env_values(root, environment)
542
+ has = lambda key: bool(sources.get(key))
543
+ try:
544
+ package = json.loads((root / "package.json").read_text(encoding="utf-8"))
545
+ except (OSError, json.JSONDecodeError):
546
+ package = {}
547
+ installed_dependencies = set((package.get("dependencies") or {})) | set((package.get("devDependencies") or {}))
548
+ missing_dependencies = sorted(set(REQUIRED_HOST_DEPENDENCIES) - installed_dependencies)
549
+ has_sender = has("EMAIL_FROM") or has("RESEND_FROM_EMAIL")
550
+ token_present = has("BOOKING_TOKEN_SECRET")
551
+ token_valid = token_present and len(values.get("BOOKING_TOKEN_SECRET", "")) >= 32
552
+ stripe_secret = has("STRIPE_SECRET_KEY")
553
+ stripe_webhook = has("STRIPE_WEBHOOK_SECRET")
554
+ stripe_complete = stripe_secret and stripe_webhook
555
+ stripe_partial = stripe_secret != stripe_webhook
556
+ workspace = (root / "_maggie" / "booking" / "booking.tsx").is_file()
557
+ middleware = root / "src" / "middleware.ts"
558
+ middleware_text = middleware.read_text(encoding="utf-8") if middleware.is_file() else ""
559
+ middleware_ready = "maggie-auto-rewrite-v1" in middleware_text or middleware_has_booking_rewrites(middleware_text)
560
+ checks = [
561
+ {"id": "workspace", "label": "Booking workspace", "status": "passed" if workspace else "missing", "required": True},
562
+ {"id": "middleware", "label": "/_maggie route integration", "status": "passed" if middleware_ready else "missing", "required": True},
563
+ {"id": "dependencies", "label": "Astro Booking host dependencies", "status": "passed" if not missing_dependencies else "missing", "required": True, "missing": missing_dependencies, "detail": "Install the missing packages before building the host."},
564
+ setup_check("database", "PostgreSQL connection", ["DATABASE_URL"], has("DATABASE_URL"), detail="Used by manager routes, public booking, schema command, and worker."),
565
+ setup_check("site", "Public site URL", ["PUBLIC_SITE_URL"], has("PUBLIC_SITE_URL"), detail="Used for login links, booking links, and email URLs."),
566
+ setup_check("booking-token", "Public booking token secret", ["BOOKING_TOKEN_SECRET"], token_valid, detail="Must be at least 32 characters; used to sign public manage-booking links." if token_valid or not token_present else "The configured secret is too short; use at least 32 characters.", status_override="passed" if token_valid else "invalid" if token_present else None),
567
+ setup_check("resend", "Resend email delivery", ["RESEND_API_KEY", "EMAIL_FROM|RESEND_FROM_EMAIL"], has("RESEND_API_KEY") and has_sender, detail="Required for password setup/reset, booking notifications, and reminders."),
568
+ setup_check("stripe", "Stripe online payments", ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"], stripe_complete, required=False, detail="Required only for deposit/full online payment flows; webhook URL is /api/maggie/booking/webhooks/stripe." if not stripe_partial else "Both Stripe secret and webhook secret are required for online payments.", status_override="passed" if stripe_complete else "degraded" if stripe_partial else "optional"),
569
+ {"id": "schema", "label": "Booking database schema", "status": "not-verified", "required": True, "detail": "The CLI does not connect to PostgreSQL. Apply the schema after backup, then verify with the host runtime checks."},
570
+ {"id": "owner", "label": "First owner account", "status": "not-verified", "required": True, "detail": "Open /_maggie/register once the schema is applied. Registration closes after the first owner."},
571
+ {"id": "worker", "label": "Resend worker schedule", "status": "not-verified", "required": True, "detail": "Run the installed worker from a single-flight scheduler; setup does not create a cron or systemd job."},
572
+ ]
573
+ missing_required = [item["id"] for item in checks if item.get("required") and item.get("status") in {"missing", "invalid"}]
574
+ verification_pending = [item["id"] for item in checks if item.get("status") == "not-verified"]
575
+ missing_config = [item["id"] for item in checks if item.get("status") in {"missing", "invalid"}]
576
+ degraded_config = [item["id"] for item in checks if item.get("status") == "degraded"]
577
+ next_steps = []
578
+ if missing_config:
579
+ next_steps.append("Add the missing keys to the host secret store or .env file; values are never printed by this command.")
580
+ if "booking-token" in missing_config:
581
+ next_steps.append("Regenerate BOOKING_TOKEN_SECRET with at least 32 characters before enabling public customer manage links.")
582
+ if "stripe" in degraded_config:
583
+ next_steps.append("Configure both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET before accepting online payments.")
584
+ if missing_dependencies:
585
+ next_steps.append("Install missing host packages with the project package manager: maggie booking install --project . --confirm --install-dependencies")
586
+ if any(item["id"] == "schema" and item["status"] == "not-verified" for item in checks):
587
+ next_steps.append("After backup, run: maggie booking install --project . --confirm --bootstrap")
588
+ if any(item["id"] == "owner" and item["status"] == "not-verified" for item in checks):
589
+ next_steps.append("Open /_maggie/register and create the first owner, then sign in at /_maggie/login")
590
+ if any(item["id"] == "worker" and item["status"] == "not-verified" for item in checks):
591
+ next_steps.append("Configure a single-flight scheduler for: node scripts/maggie-booking-worker.mjs --write --confirm")
592
+ if not has("STRIPE_SECRET_KEY") or not has("STRIPE_WEBHOOK_SECRET"):
593
+ next_steps.append("If taking online payments, configure Stripe test keys and POST /api/maggie/booking/webhooks/stripe before live mode.")
594
+ status = "needs-configuration" if missing_required else "configured-pending-verification"
595
+ return {
596
+ "status": status,
597
+ "project": str(root),
598
+ "checks": checks,
599
+ "configuredKeys": sorted(key for key in sources if key in {"DATABASE_URL", "PUBLIC_SITE_URL", "BOOKING_TOKEN_SECRET", "RESEND_API_KEY", "EMAIL_FROM", "RESEND_FROM_EMAIL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"}),
600
+ "verificationPending": verification_pending,
601
+ "paymentConfiguration": "online-payments" if has("STRIPE_SECRET_KEY") and has("STRIPE_WEBHOOK_SECRET") else "no-payment-or-incomplete-stripe",
602
+ "next": next_steps,
603
+ "note": "Read-only checklist. It does not print secrets, connect to PostgreSQL, apply migrations, create an owner, configure Stripe, send email, or deploy.",
604
+ }
605
+
606
+
607
+ def command_setup(args: argparse.Namespace) -> int:
608
+ result = booking_setup_result(project_path(args))
609
+ print(json.dumps(result, indent=2, ensure_ascii=False))
610
+ return 0 if result["status"] == "configured-pending-verification" else 1
611
+
612
+
613
+ def ensure_bootstrap_token_secret(root: Path) -> bool:
614
+ """Create the local public-booking secret without ever returning its value.
615
+
616
+ The host worker and public customer routes both read BOOKING_TOKEN_SECRET
617
+ from the host environment. A reviewed `--bootstrap` install should not
618
+ leave a fresh host unusable because an implementation-only secret was
619
+ forgotten. Existing configured values are never replaced; an existing
620
+ short value fails closed instead.
621
+ """
622
+ values = _read_env_values(root)
623
+ existing = values.get("BOOKING_TOKEN_SECRET")
624
+ if existing:
625
+ if len(existing) < 32:
626
+ raise RuntimeError("BOOKING_TOKEN_SECRET is configured but shorter than 32 characters; refusing to replace it")
627
+ return False
628
+
629
+ env_path = root / ".env"
630
+ try:
631
+ text = env_path.read_text(encoding="utf-8") if env_path.is_file() else ""
632
+ except OSError as error:
633
+ raise RuntimeError(f"could not read host environment file: {env_path}: {error}") from error
634
+ secret = secrets.token_urlsafe(48)
635
+ assignment = f"BOOKING_TOKEN_SECRET={secret}"
636
+ pattern = re.compile(r"^\s*BOOKING_TOKEN_SECRET\s*=.*$", re.MULTILINE)
637
+ if pattern.search(text):
638
+ updated = pattern.sub(assignment, text, count=1)
639
+ else:
640
+ separator = "" if not text or text.endswith("\n") else "\n"
641
+ updated = text + separator + "\n# Generated by Maggie Booking bootstrap; keep this value in the host secret store.\n" + assignment + "\n"
642
+ try:
643
+ env_path.write_text(updated, encoding="utf-8")
644
+ if not env_path.exists():
645
+ raise RuntimeError(f"host environment file was not created: {env_path}")
646
+ except OSError as error:
647
+ raise RuntimeError(f"could not write host environment file: {env_path}: {error}") from error
648
+ print(f"BOOKING_TOKEN_SECRET generated in {env_path} (value intentionally hidden)")
649
+ return True
650
+
651
+
652
+ def command_install(args: argparse.Namespace) -> int:
653
+ """Install Booking, optionally adding missing host packages and schema."""
654
+ if (args.bootstrap or args.install_dependencies) and (args.dry_run or args.diff):
655
+ print("INSTALL_MUTATION_INCOMPATIBLE: --bootstrap and --install-dependencies cannot be combined with --dry-run or --diff.", file=sys.stderr)
656
+ return 2
657
+ if not args.dry_run and not args.diff and not args.confirm:
658
+ print("CONFIRMATION_REQUIRED: add --confirm before installing Booking.", file=sys.stderr)
659
+ return 2
660
+ root = project_path(args)
661
+ dash = Path(__file__).with_name("maggie_dash.py")
662
+ command = [sys.executable, str(dash), "install", "--project", str(root), "--host", args.host]
663
+ if args.source:
664
+ command.extend(["--source", args.source])
665
+ if args.ref:
666
+ command.extend(["--ref", args.ref])
667
+ if args.force:
668
+ command.append("--force")
669
+ if args.dry_run:
670
+ command.append("--dry-run")
671
+ if args.diff:
672
+ command.append("--diff")
673
+ if args.existing_dir:
674
+ command.extend(["--existing-dir", args.existing_dir])
675
+ if args.confirm:
676
+ command.append("--confirm")
677
+ completed = subprocess.run(command, cwd=root, check=False)
678
+ if completed.returncode != 0:
679
+ return completed.returncode
680
+ if args.install_dependencies:
681
+ try:
682
+ dependency_result = install_host_dependencies(root)
683
+ except RuntimeError as error:
684
+ print(f"DEPENDENCY_INSTALL_FAILED: {error}", file=sys.stderr)
685
+ return 1
686
+ print(json.dumps({"status": "host-dependencies", **dependency_result}, ensure_ascii=False))
687
+ if not args.bootstrap:
688
+ return 0
689
+ try:
690
+ ensure_bootstrap_token_secret(root)
691
+ except RuntimeError as error:
692
+ print(f"BOOTSTRAP_FAILED: {error}", file=sys.stderr)
693
+ return 1
694
+ schema_script = root / "scripts" / "maggie-booking-schema.mjs"
695
+ if not schema_script.is_file():
696
+ print(f"BOOTSTRAP_FAILED: installed host schema script is missing: {schema_script}", file=sys.stderr)
697
+ return 1
698
+ schema = subprocess.run(["node", str(schema_script), "--apply", "--confirm"], cwd=root, check=False)
699
+ return schema.returncode
700
+
701
+
702
+ def command_inspect(args: argparse.Namespace) -> int:
703
+ root = project_path(args)
704
+ installed = root / "_maggie" / "booking"
705
+ installed_entry = installed / "booking.tsx"
706
+ state = root / ".maggie" / "dash-install.json"
707
+ state_data = load_json(state) if state.is_file() else {}
708
+ middleware = root / "src" / "middleware.ts"
709
+ middleware_text = middleware.read_text(encoding="utf-8") if middleware.is_file() else ""
710
+ middleware_integration = (
711
+ state_data.get("hostFramework") != "astro"
712
+ or (
713
+ middleware.is_file()
714
+ and (
715
+ "maggie-auto-rewrite-v1" in middleware_text
716
+ or middleware_has_booking_rewrites(middleware_text)
717
+ )
718
+ )
719
+ )
720
+ contract = root / "maggiedash" / "contracts" / "booking-host-adapter-v1.json"
721
+ access_contract = root / "maggiedash" / "contracts" / "booking-access-v1.json"
722
+ email_templates_contract = root / "maggiedash" / "contracts" / "booking-email-templates-v1.json"
723
+ customer_contract = root / "maggiedash" / "contracts" / "booking-customer-surface-v1.json"
724
+ stripe_contract = root / "maggiedash" / "contracts" / "stripe-booking-capabilities-v1.json"
725
+ service_types = root / "maggiedash" / "backend" / "booking-adapter-contract.d.ts"
726
+ service_validator = root / "maggiedash" / "scripts" / "validate-booking-service-contract.mjs"
727
+ manager_methods_validator = root / "maggiedash" / "scripts" / "validate-booking-postgres-manager-methods.mjs"
728
+ payment_adapter = root / "maggiedash" / "backend" / "booking" / "postgres-payment-adapter.mjs"
729
+ payment_validator = root / "maggiedash" / "scripts" / "validate-booking-postgres-payment-adapter.mjs"
730
+ customer_adapter = root / "maggiedash" / "backend" / "booking" / "postgres-customer-adapter.mjs"
731
+ customer_validator = root / "maggiedash" / "scripts" / "validate-booking-postgres-customer-adapter.mjs"
732
+ backup_restore_validator = root / "maggiedash" / "scripts" / "validate-booking-postgres-backup-restore.integration.mjs"
733
+ core_repository = root / "maggiedash" / "backend" / "booking" / "postgres-core-repository.mjs"
734
+ if not contract.exists(): contract = Path(args.contract).expanduser().resolve()
735
+ checks = {
736
+ "project": str(root),
737
+ "bookingWorkspace": installed.is_dir(),
738
+ "bookingEntry": installed_entry.is_file(),
739
+ "installState": state.is_file(),
740
+ "middlewareIntegration": middleware_integration,
741
+ "hostContract": contract.is_file(),
742
+ "accessContract": access_contract.is_file(),
743
+ "emailTemplatesContract": email_templates_contract.is_file(),
744
+ "serviceAdapterTypes": service_types.is_file(),
745
+ "serviceContractValidator": service_validator.is_file(),
746
+ "postgresManagerMethodsValidator": manager_methods_validator.is_file(),
747
+ "postgresPaymentAdapter": payment_adapter.is_file(),
748
+ "postgresPaymentValidator": payment_validator.is_file(),
749
+ "postgresCustomerAdapter": customer_adapter.is_file(),
750
+ "postgresCustomerValidator": customer_validator.is_file(),
751
+ "postgresBackupRestoreValidator": backup_restore_validator.is_file(),
752
+ "postgresCoreRepository": core_repository.is_file(),
753
+ "customerSurfaceContract": customer_contract.is_file(),
754
+ "stripeContract": stripe_contract.is_file(),
755
+ "schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file(),
756
+ "runtimeContract": (root / "maggiedash" / "contracts" / "booking-runtime-v1.json").is_file(),
757
+ "browserEvidenceContract": (root / "maggiedash" / "contracts" / "booking-browser-evidence-v1.json").is_file(),
758
+ "runtimePlanValidator": (root / "maggiedash" / "scripts" / "validate-booking-runtime-plan.mjs").is_file(),
759
+ "customerFlowValidator": (root / "maggiedash" / "scripts" / "validate-booking-customer-flow.mjs").is_file(),
760
+ "customerUIValidator": (root / "maggiedash" / "scripts" / "validate-booking-customer-ui.mjs").is_file(),
761
+ "customerReferenceHostValidator": (root / "maggiedash" / "scripts" / "validate-booking-customer-reference-host.mjs").is_file(),
762
+ "customerReferenceHost": (root / "maggiedash" / "backend" / "booking" / "customer-reference-host.mjs").is_file(),
763
+ "customerPageTemplate": (root / "maggiedash" / "adapters" / "astro" / "customer-booking-page.astro").is_file(),
764
+ "worker": (root / "maggiedash" / "backend" / "booking" / "run-booking-jobs.mjs").is_file(),
765
+ "opsTemplates": (root / "maggiedash" / "ops").is_dir(),
766
+ "runtimeWorkflow": (root / ".github" / "workflows" / "booking-runtime.yml").is_file(),
767
+ "browserWorkflow": (root / ".github" / "workflows" / "booking-browser-evidence.yml").is_file(),
768
+ "opsEvidenceWorkflow": (root / ".github" / "workflows" / "booking-ops-evidence.yml").is_file(),
769
+ }
770
+ errors = [name for name, passed in checks.items() if name != "project" and not passed]
771
+ errors.extend(f"host:{warning}" for warning in state_data.get("hostWarnings", []) if warning)
772
+ if contract.is_file(): errors.extend(f"contract:{item}" for item in contract_checks(load_json(contract)))
773
+ if access_contract.is_file(): errors.extend(f"accessContract:{item}" for item in access_contract_checks(load_json(access_contract)))
774
+ if email_templates_contract.is_file(): errors.extend(f"emailTemplatesContract:{item}" for item in email_templates_contract_checks(load_json(email_templates_contract)))
775
+ if customer_contract.is_file(): errors.extend(f"customerContract:{item}" for item in customer_contract_checks(load_json(customer_contract)))
776
+ if stripe_contract.is_file(): errors.extend(f"stripe:{item}" for item in stripe_contract_checks(load_json(stripe_contract)))
777
+ result = {"status": "passed" if not errors else "needs-attention", "checks": checks, "errors": errors, "next": ["maggie dash install --project . --confirm", "maggie booking runtime-validate --project . --evidence .maggie/booking-runtime.json"] if errors else ["maggie booking release-gate --project ."]}
778
+ print(json.dumps(result, indent=2, ensure_ascii=False))
779
+ return 0 if not errors else 1
780
+
781
+
782
+ def runtime_result(evidence: dict, contract: dict) -> dict:
783
+ observations = evidence.get("endpoints") if isinstance(evidence.get("endpoints"), list) else evidence.get("observations")
784
+ if not isinstance(observations, list): observations = []
785
+ if isinstance(contract.get("properties"), dict):
786
+ required = {str(item.get("id")) for item in contract.get("x-endpoints", []) if isinstance(item, dict) and item.get("id")}
787
+ endpoint_map = {str(item.get("id")): item for item in contract.get("x-endpoints", []) if isinstance(item, dict) and item.get("id")}
788
+ api_prefix = ((contract.get("properties") or {}).get("apiPrefix") or {}).get("const", "/api/maggie/booking")
789
+ else:
790
+ required = {str(value) for value in contract.get("endpointIds", [])}
791
+ endpoint_map = {}
792
+ api_prefix = contract.get("apiPrefix", "/api/maggie/booking")
793
+ seen = {str(item.get("id")) for item in observations if isinstance(item, dict)}
794
+ errors: list[str] = []
795
+ if evidence.get("schemaVersion") != "maggiedash-booking-runtime.v1": errors.append("runtime schemaVersion is unsupported")
796
+ if not str(evidence.get("baseUrl", "")).startswith("https://"): errors.append("runtime baseUrl must use HTTPS")
797
+ generated_at = evidence.get("generatedAt")
798
+ try:
799
+ datetime.fromisoformat(str(generated_at).replace("Z", "+00:00"))
800
+ except (TypeError, ValueError):
801
+ errors.append("runtime generatedAt must be an ISO timestamp")
802
+ if not isinstance(evidence.get("tenant"), str) or not evidence.get("tenant"): errors.append("runtime tenant must identify the dedicated test tenant")
803
+ if len(observations) != len(required): errors.append(f"expected {len(required)} endpoint observations, got {len(observations)}")
804
+ if len(seen) != len([item for item in observations if isinstance(item, dict)]): errors.append("endpoint observations must be unique")
805
+ for item in observations:
806
+ if not isinstance(item, dict): errors.append("observation must be an object"); continue
807
+ item_id = str(item.get("id", "unknown"))
808
+ if item_id not in required: errors.append(f"unknown endpoint observation: {item_id}")
809
+ for field in ("id", "method", "route", "status", "requiredFieldsPresent"):
810
+ if field not in item: errors.append(f"{item_id}: missing {field}")
811
+ expected = endpoint_map.get(item_id)
812
+ if expected:
813
+ expected_route = f"{api_prefix}{expected.get('route', '')}"
814
+ if item.get("method") != expected.get("method"): errors.append(f"{item_id}: method differs from contract")
815
+ if item.get("route") != expected_route: errors.append(f"{item_id}: route differs from contract")
816
+ if item.get("status") not in range(200, 300): errors.append(f"{item_id}: status is not 2xx")
817
+ if item.get("requiredFieldsPresent") is not True: errors.append(f"{item_id}: required fields are incomplete")
818
+ for key in item:
819
+ if re.search(r"body|payload|cookie|token|secret|signature|authorization|email|phone", str(key), re.IGNORECASE): errors.append(f"{item_id}: sensitive evidence field is present: {key}")
820
+ missing = sorted(required - seen)
821
+ errors.extend(f"missing endpoint observation: {item}" for item in missing)
822
+ return {"status": "passed" if not errors and bool(observations) else "failed", "checked": len(observations), "required": len(required), "missing": missing, "errors": errors}
823
+
824
+
825
+ def browser_result(evidence: dict, artifact_root: Path) -> dict:
826
+ """Validate sanitized browser evidence without importing a browser runtime."""
827
+ errors: list[str] = []
828
+ if evidence.get("schemaVersion") != "maggiedash-booking-browser-evidence.v1": errors.append("browser schemaVersion is unsupported")
829
+ if not str(evidence.get("baseUrl", "")).startswith("https://"): errors.append("browser baseUrl must use HTTPS")
830
+ generated_at = evidence.get("generatedAt")
831
+ try:
832
+ datetime.fromisoformat(str(generated_at).replace("Z", "+00:00"))
833
+ except (TypeError, ValueError):
834
+ errors.append("browser generatedAt must be an ISO timestamp")
835
+ viewport_map = {("mobile", 390, 844), ("tablet", 768, 1024), ("desktop", 1440, 1000)}
836
+ actual_viewports = evidence.get("viewports") if isinstance(evidence.get("viewports"), list) else []
837
+ observed_viewports = {(item.get("name"), item.get("width"), item.get("height")) for item in actual_viewports if isinstance(item, dict)}
838
+ for viewport in sorted(viewport_map - observed_viewports):
839
+ errors.append(f"missing viewport {viewport[0]} {viewport[1]}x{viewport[2]}")
840
+ routes = ["/_maggie/booking/", "/_maggie/booking/calendar", "/_maggie/booking/bookings", "/_maggie/booking/customers", "/_maggie/booking/waitlist", "/_maggie/booking/locations", "/_maggie/booking/catalog", "/_maggie/booking/staff", "/_maggie/booking/resources", "/_maggie/booking/schedule", "/_maggie/booking/payments", "/_maggie/booking/notifications", "/_maggie/booking/reports", "/_maggie/booking/settings", "/_maggie/booking/audit"]
841
+ screens = evidence.get("screens") if isinstance(evidence.get("screens"), list) else []
842
+ for route in routes:
843
+ for viewport in ("mobile", "tablet", "desktop"):
844
+ item = next((row for row in screens if isinstance(row, dict) and row.get("route") == route and row.get("viewport") == viewport), None)
845
+ if not item: errors.append(f"missing browser screen {viewport}:{route}"); continue
846
+ if not isinstance(item.get("httpStatus"), int) or not 200 <= item["httpStatus"] < 400: errors.append(f"browser screen is not successful {viewport}:{route}")
847
+ if item.get("ready") is not True: errors.append(f"browser screen is not ready {viewport}:{route}")
848
+ screenshot = item.get("screenshot")
849
+ if not isinstance(screenshot, str) or Path(screenshot).is_absolute() or ".." in Path(screenshot).parts: errors.append(f"unsafe browser screenshot path {viewport}:{route}")
850
+ elif not (artifact_root / screenshot).is_file(): errors.append(f"missing browser screenshot {screenshot}")
851
+ if evidence.get("consoleErrors") != []: errors.append("browser consoleErrors is not empty")
852
+ if evidence.get("failedRequests") != []: errors.append("browser failedRequests is not empty")
853
+ if evidence.get("layoutErrors") != []: errors.append("browser layoutErrors is not empty")
854
+ return {"status": "passed" if not errors else "failed", "screens": len(screens), "required": len(routes) * 3, "errors": errors}
855
+
856
+
857
+ def command_runtime_validate(args: argparse.Namespace) -> int:
858
+ evidence = load_json(Path(args.evidence).expanduser().resolve())
859
+ contract = load_json(Path(args.contract).expanduser().resolve())
860
+ result = runtime_result(evidence, contract)
861
+ print(json.dumps(result, indent=2, ensure_ascii=False))
862
+ return 0 if result["status"] == "passed" else 1
863
+
864
+
865
+ def ops_evidence_result(evidence: dict, contract: dict, require_host: bool = False) -> dict:
866
+ errors: list[str] = []
867
+ if evidence.get("schemaVersion") != contract.get("schemaVersion"): errors.append("ops evidence schemaVersion is unsupported")
868
+ if not evidence.get("generatedAt") or not isinstance(evidence.get("environment"), str) or not isinstance(evidence.get("tenant"), str): errors.append("generatedAt, environment, and tenant are required")
869
+ try:
870
+ datetime.fromisoformat(str(evidence.get("generatedAt")).replace("Z", "+00:00"))
871
+ except (TypeError, ValueError):
872
+ errors.append("ops generatedAt must be an ISO timestamp")
873
+ if require_host and evidence.get("environment") in {"fixture", "test", "local"}: errors.append("host evidence cannot use a fixture/local environment")
874
+ checks = evidence.get("checks") if isinstance(evidence.get("checks"), dict) else {}
875
+ def check_object(name: str) -> dict:
876
+ value = checks.get(name)
877
+ return value if isinstance(value, dict) else {}
878
+ required_fields = contract.get("requiredCheckFields") or {
879
+ "backup_restore": ["status", "backupVerifiedAt", "restoreVerifiedAt", "rowCountVerified"],
880
+ "alert_routing": ["status", "alertCodes", "criticalDeliveryVerified"],
881
+ "recovery_rehearsal": ["status", "rollbackWriteGuardVerified", "workerRestartVerified", "outboxReplayVerified"],
882
+ }
883
+ for check in contract.get("requiredChecks", []):
884
+ check_data = check_object(check)
885
+ if check_data.get("status") != "passed": errors.append(f"{check} must be passed")
886
+ for field in required_fields.get(check, []):
887
+ if field not in check_data: errors.append(f"{check}.{field} is required")
888
+ routed = check_object("alert_routing").get("alertCodes")
889
+ if not isinstance(routed, list) or not set(contract.get("requiredAlertCodes", [])).issubset(set(routed)): errors.append("alert routing does not cover every stable alert code")
890
+ for field in ("backupVerifiedAt", "restoreVerifiedAt"):
891
+ try:
892
+ datetime.fromisoformat(str(check_object("backup_restore").get(field)).replace("Z", "+00:00"))
893
+ except (TypeError, ValueError):
894
+ errors.append(f"backup_restore.{field} must be an ISO timestamp")
895
+ if check_object("backup_restore").get("rowCountVerified") is not True: errors.append("backup_restore.rowCountVerified must be true")
896
+ if check_object("alert_routing").get("criticalDeliveryVerified") is not True: errors.append("alert_routing.criticalDeliveryVerified must be true")
897
+ for field in ("rollbackWriteGuardVerified", "workerRestartVerified", "outboxReplayVerified"):
898
+ if check_object("recovery_rehearsal").get(field) is not True: errors.append(f"recovery_rehearsal.{field} must be true")
899
+ safe_fields = set(contract.get("safeFields", []))
900
+ def walk(value: object, path: str = "") -> None:
901
+ if isinstance(value, dict):
902
+ for key, child in value.items():
903
+ if key not in safe_fields: errors.append(f"unsafe ops evidence field: {path + key}")
904
+ walk(child, f"{path}{key}.")
905
+ elif isinstance(value, list):
906
+ for index, child in enumerate(value): walk(child, f"{path}{index}.")
907
+ walk(evidence)
908
+ return {"status": "passed" if not errors else "failed", "checks": sorted(checks), "requiredAlertCodes": len(contract.get("requiredAlertCodes", [])), "errors": errors}
909
+
910
+
911
+ def command_ops_validate(args: argparse.Namespace) -> int:
912
+ evidence = load_json(Path(args.evidence).expanduser().resolve())
913
+ contract = load_json(Path(args.contract).expanduser().resolve())
914
+ result = ops_evidence_result(evidence, contract, require_host=args.require_host)
915
+ print(json.dumps(result, indent=2, ensure_ascii=False))
916
+ return 0 if result["status"] == "passed" else 1
917
+
918
+
919
+ def command_release_gate(args: argparse.Namespace) -> int:
920
+ root = project_path(args)
921
+ contract = Path(args.contract).expanduser().resolve()
922
+ contract_result = contract_checks(load_json(contract))
923
+ access_path = root / "maggiedash" / "contracts" / "booking-access-v1.json"
924
+ access_result = access_contract_checks(load_json(access_path)) if access_path.is_file() else ["installed Booking access contract is missing"]
925
+ email_templates_path = root / "maggiedash" / "contracts" / "booking-email-templates-v1.json"
926
+ email_templates_result = email_templates_contract_checks(load_json(email_templates_path)) if email_templates_path.is_file() else ["installed Booking email templates contract is missing"]
927
+ stripe_path = root / "maggiedash" / "contracts" / "stripe-booking-capabilities-v1.json"
928
+ stripe_errors = stripe_contract_checks(load_json(stripe_path)) if stripe_path.is_file() else ["installed Stripe capability contract is missing"]
929
+ checks = {"contract": not contract_result, "emailTemplatesContract": not email_templates_result, "stripeContract": not stripe_errors, "serviceAdapterTypes": (root / "maggiedash" / "backend" / "booking-adapter-contract.d.ts").is_file(), "serviceContractValidator": (root / "maggiedash" / "scripts" / "validate-booking-service-contract.mjs").is_file(), "postgresManagerMethodsValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-manager-methods.mjs").is_file(), "postgresPaymentAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-payment-adapter.mjs").is_file(), "postgresPaymentValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-payment-adapter.mjs").is_file(), "postgresCustomerAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-customer-adapter.mjs").is_file(), "postgresCustomerValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-customer-adapter.mjs").is_file(), "postgresBackupRestoreValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-backup-restore.integration.mjs").is_file(), "postgresCoreRepository": (root / "maggiedash" / "backend" / "booking" / "postgres-core-repository.mjs").is_file(), "workspace": (root / "_maggie" / "booking" / "booking.tsx").is_file(), "schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file()}
930
+ errors = list(contract_result)
931
+ errors.extend(f"access:{item}" for item in access_result)
932
+ errors.extend(f"emailTemplates:{item}" for item in email_templates_result)
933
+ errors.extend(f"stripe:{item}" for item in stripe_errors)
934
+ errors.extend(name for name, passed in checks.items() if not passed)
935
+ runtime_status = "not-provided"
936
+ if args.evidence:
937
+ runtime_evidence, runtime_error = load_gate_json(Path(args.evidence).expanduser().resolve(), "runtime evidence could not be read")
938
+ if runtime_error:
939
+ runtime_status = "failed"
940
+ errors.append(runtime_error)
941
+ else:
942
+ runtime_status = runtime_result(runtime_evidence, load_json(contract)).get("status", "failed")
943
+ if runtime_status != "passed": errors.append("runtime evidence failed")
944
+ elif args.require_runtime:
945
+ errors.append("runtime evidence is required")
946
+ browser_status = "not-provided"
947
+ if args.browser_evidence:
948
+ browser_path = Path(args.browser_evidence).expanduser().resolve()
949
+ browser_evidence, browser_error = load_gate_json(browser_path, "browser evidence could not be read")
950
+ if browser_error:
951
+ browser_status = "failed"
952
+ errors.append(browser_error)
953
+ else:
954
+ browser_status = browser_result(browser_evidence, browser_path.parent).get("status", "failed")
955
+ if browser_status != "passed": errors.append("browser evidence failed")
956
+ elif args.require_browser:
957
+ errors.append("browser evidence is required")
958
+ ops_status = "not-provided"
959
+ if args.ops_evidence:
960
+ ops_path = Path(args.ops_evidence).expanduser().resolve()
961
+ ops_contract_path = Path(args.ops_contract).expanduser().resolve()
962
+ ops_evidence, ops_error = load_gate_json(ops_path, "ops evidence could not be read")
963
+ ops_contract, ops_contract_error = load_gate_json(ops_contract_path, "ops evidence contract could not be read")
964
+ if ops_error or ops_contract_error:
965
+ ops_status = "failed"
966
+ if ops_error: errors.append(ops_error)
967
+ if ops_contract_error: errors.append(ops_contract_error)
968
+ else:
969
+ ops_status = ops_evidence_result(ops_evidence, ops_contract, require_host=args.require_ops).get("status", "failed")
970
+ if ops_status != "passed": errors.append("ops evidence failed")
971
+ elif args.require_ops:
972
+ errors.append("ops evidence is required")
973
+ board_status = "not-provided"
974
+ board_report = None
975
+ if args.board or args.require_board:
976
+ board_path = Path(args.board).expanduser().resolve() if args.board else EXECUTION_BOARD
977
+ board_report = board_result(load_execution_board(board_path))
978
+ board_report["board"] = str(board_path)
979
+ board_status = board_report["status"]
980
+ if board_status != "passed": errors.append("execution board is not complete")
981
+ result = {"status": "passed" if not errors else "blocked", "project": str(root), "checks": {**checks, "accessContract": not access_result}, "runtime": runtime_status, "browser": browser_status, "ops": ops_status, "board": board_report, "errors": errors, "note": "This gate does not run migrations, create bookings, capture payments, or deploy."}
982
+ print(json.dumps(result, indent=2, ensure_ascii=False))
983
+ return 0 if not errors else 1
984
+
985
+
986
+ def command_stripe_audit(args: argparse.Namespace) -> int:
987
+ root = project_path(args)
988
+ candidate = Path(args.contract).expanduser().resolve() if args.contract else root / "maggiedash" / "contracts" / "stripe-booking-capabilities-v1.json"
989
+ if not candidate.is_file() and DEFAULT_STRIPE_CONTRACT.is_file(): candidate = DEFAULT_STRIPE_CONTRACT
990
+ contract = load_json(candidate)
991
+ errors = stripe_contract_checks(contract, max_age_days=args.max_age_days)
992
+ result = {"status": "passed" if not errors else "failed", "contract": str(candidate), "lastVerifiedAt": contract.get("x-last-verified-at") or contract.get("lastVerifiedAt"), "apiCount": len(contract.get("x-api-matrix") or contract.get("apis") or []), "webhookEventCount": len(contract.get("x-webhook-events") or contract.get("webhookEvents") or []), "nativeCapabilities": contract.get("x-native-capabilities") or contract.get("nativeCapabilities") or {}, "errors": errors}
993
+ print(json.dumps(result, indent=2, ensure_ascii=False))
994
+ return 0 if not errors else 1
995
+
996
+
997
+ def parser() -> argparse.ArgumentParser:
998
+ parser = argparse.ArgumentParser(prog="maggie booking", description=__doc__)
999
+ sub = parser.add_subparsers(dest="command", required=True)
1000
+ install = sub.add_parser("install", help="install Booking and the framework host scaffold")
1001
+ install.add_argument("--project", default=".")
1002
+ install.add_argument("--source", help="local MaggieDash checkout or Git URL")
1003
+ install.add_argument("--ref", default="main")
1004
+ install.add_argument("--host", choices=["auto", "astro", "none"], default="auto")
1005
+ install.add_argument("--force", action="store_true")
1006
+ install.add_argument("--dry-run", action="store_true")
1007
+ install.add_argument("--diff", action="store_true")
1008
+ install.add_argument("--existing-dir")
1009
+ install.add_argument("--install-dependencies", action="store_true", help="add missing Astro Booking packages with the host package manager")
1010
+ install.add_argument("--bootstrap", action="store_true", help="apply the installed host Booking schema after installation")
1011
+ install.add_argument("--confirm", action="store_true")
1012
+ install.set_defaults(func=command_install)
1013
+ contract = sub.add_parser("contract", help="validate the provider-neutral Booking contract")
1014
+ contract.add_argument("--contract", default=str(DEFAULT_CONTRACT))
1015
+ contract.set_defaults(func=command_contract)
1016
+ access_contract = sub.add_parser("access-contract", help="validate the Booking administrator access contract")
1017
+ access_contract.add_argument("--contract", default=str(DEFAULT_ACCESS_CONTRACT))
1018
+ access_contract.set_defaults(func=command_access_contract)
1019
+ email_templates_contract = sub.add_parser("email-templates-contract", help="validate the tenant-scoped Booking email template contract")
1020
+ email_templates_contract.add_argument("--contract", default=str(DEFAULT_EMAIL_TEMPLATES_CONTRACT))
1021
+ email_templates_contract.set_defaults(func=command_email_templates_contract)
1022
+ inspect = sub.add_parser("inspect", help="inspect Booking install artifacts and contracts")
1023
+ inspect.add_argument("--project", default=".")
1024
+ inspect.add_argument("--contract", default=str(DEFAULT_CONTRACT))
1025
+ inspect.set_defaults(func=command_inspect)
1026
+ setup = sub.add_parser("setup", help="show the safe first-run Booking setup checklist")
1027
+ setup.add_argument("--project", default=".")
1028
+ setup.set_defaults(func=command_setup)
1029
+ runtime = sub.add_parser("runtime-validate", help="validate sanitized host endpoint evidence")
1030
+ runtime.add_argument("--project", default=".")
1031
+ runtime.add_argument("--contract", default=str(DEFAULT_CONTRACT))
1032
+ runtime.add_argument("--evidence", required=True)
1033
+ runtime.set_defaults(func=command_runtime_validate)
1034
+ ops = sub.add_parser("ops-validate", help="validate sanitized host backup, alert, and recovery evidence")
1035
+ ops.add_argument("--project", default=".")
1036
+ ops.add_argument("--contract", default=str(DEFAULT_OPS_EVIDENCE_CONTRACT))
1037
+ ops.add_argument("--evidence", required=True)
1038
+ ops.add_argument("--require-host", action="store_true", help="reject fixture/local evidence")
1039
+ ops.set_defaults(func=command_ops_validate)
1040
+ gate = sub.add_parser("release-gate", help="run the read-only Booking release gate")
1041
+ gate.add_argument("--project", default=".")
1042
+ gate.add_argument("--contract", default=str(DEFAULT_CONTRACT))
1043
+ gate.add_argument("--evidence")
1044
+ gate.add_argument("--require-runtime", action="store_true")
1045
+ gate.add_argument("--browser-evidence")
1046
+ gate.add_argument("--require-browser", action="store_true")
1047
+ gate.add_argument("--ops-evidence", help="sanitized backup, alert, and recovery evidence JSON")
1048
+ gate.add_argument("--ops-contract", default=str(DEFAULT_OPS_EVIDENCE_CONTRACT))
1049
+ gate.add_argument("--require-ops", action="store_true", help="fail unless host operational evidence is provided and valid")
1050
+ gate.add_argument("--board", help="execution board JSON to inspect")
1051
+ gate.add_argument("--require-board", action="store_true", help="fail unless every execution-board task is passed")
1052
+ gate.set_defaults(func=command_release_gate)
1053
+ stripe = sub.add_parser("stripe-audit", help="validate the current Stripe booking capability matrix")
1054
+ stripe.add_argument("--project", default=".")
1055
+ stripe.add_argument("--contract")
1056
+ stripe.add_argument("--max-age-days", type=int, default=120)
1057
+ stripe.set_defaults(func=command_stripe_audit)
1058
+ tasks = sub.add_parser("tasks", help="inspect the executable Booking delivery board")
1059
+ tasks_sub = tasks.add_subparsers(dest="task_command", required=True)
1060
+ tasks_validate = tasks_sub.add_parser("validate", help="validate task dependencies and required fields")
1061
+ tasks_validate.add_argument("--board")
1062
+ tasks_validate.set_defaults(func=command_tasks)
1063
+ tasks_list = tasks_sub.add_parser("list", help="list Booking delivery tasks")
1064
+ tasks_list.add_argument("--status", choices=["planned", "in_progress", "needs-evidence", "blocked", "passed"])
1065
+ tasks_list.add_argument("--board")
1066
+ tasks_list.set_defaults(func=command_tasks)
1067
+ tasks_show = tasks_sub.add_parser("show", help="show one Booking delivery task")
1068
+ tasks_show.add_argument("task_id")
1069
+ tasks_show.add_argument("--board")
1070
+ tasks_show.set_defaults(func=command_tasks)
1071
+ return parser
1072
+
1073
+
1074
+ if __name__ == "__main__":
1075
+ try:
1076
+ arguments = parser().parse_args()
1077
+ raise SystemExit(arguments.func(arguments))
1078
+ except RuntimeError as error:
1079
+ print(f"BOOKING_CHECK_FAILED: {error}", file=sys.stderr)
1080
+ raise SystemExit(1)