@topy-ai/maggie 0.7.38 → 0.7.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +82 -6
- package/README.md +99 -7
- package/bin/maggie.js +30 -3
- package/bundled-contracts/maggie-clone/interaction-state-v1.schema.json +26 -0
- package/bundled-contracts/maggie-content/provenance-v1.schema.json +20 -0
- package/bundled-contracts/maggie-design/brand-kit-v1.schema.json +18 -0
- package/bundled-contracts/maggie-design/browser-interactions-v1.schema.json +27 -0
- package/bundled-contracts/maggie-design/css-utility-evidence-v1.schema.json +10 -0
- package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
- package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
- package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
- package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
- package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
- package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
- package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
- package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
- package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
- package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
- package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
- package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
- package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
- package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
- package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +14 -2
- package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
- package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
- package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
- package/bundled-contracts/maggiedash/execution-board.json +526 -26
- package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
- package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
- package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
- package/bundled-references/browser-inspection.md +21 -0
- package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
- package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
- package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
- package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
- package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
- package/bundled-references/maggiedash-booking/PRD.md +228 -0
- package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
- package/bundled-references/maggiedash-booking/README.md +271 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
- package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
- package/bundled-references/maggiedash-booking/TASKS.md +137 -0
- package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
- package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
- package/bundled-skills/maggie-blog/SKILL.md +12 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +39 -0
- package/bundled-skills/maggie-booking/SKILL.md +112 -18
- package/bundled-skills/maggie-clone/SKILL.md +13 -0
- package/bundled-skills/maggie-deployment/SKILL.md +6 -0
- package/bundled-skills/maggie-design/SKILL.md +64 -3
- package/bundled-skills/maggie-ops/SKILL.md +12 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +45 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
- package/bundled-tools/clis/maggie_analytics.py +43 -1
- package/bundled-tools/clis/maggie_booking.py +161 -26
- package/bundled-tools/clis/maggie_browser_audit.py +99 -3
- package/bundled-tools/clis/maggie_clone.py +46 -1
- package/bundled-tools/clis/maggie_contracts.py +393 -0
- package/bundled-tools/clis/maggie_dash.py +22 -6
- package/bundled-tools/clis/maggie_design.py +84 -12
- package/bundled-tools/clis/maggie_service_booking.py +50 -1
- package/bundled-tools/clis/maggie_workflows.py +387 -0
- package/bundled-tools/clis/site_audit.py +77 -1
- package/bundled-tools/integrations/analytics.md +14 -0
- package/bundled-tools/runtime/site_baseline.py +3 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +21 -0
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
10
|
import secrets
|
|
11
|
+
import shlex
|
|
11
12
|
import shutil
|
|
12
13
|
import subprocess
|
|
13
14
|
import sys
|
|
@@ -200,7 +201,7 @@ def contract_checks(contract: dict) -> list[str]:
|
|
|
200
201
|
if not isinstance(service_methods, dict) or set(service_methods) != set(endpoint_ids or []): errors.append("serviceMethods must map every endpoint exactly once")
|
|
201
202
|
elif not all(isinstance(value, str) and value for value in service_methods.values()): errors.append("serviceMethods values must be non-empty strings")
|
|
202
203
|
customer_methods = contract.get("customerServiceMethods")
|
|
203
|
-
if customer_methods != ["readPublicCatalog", "readPublicAvailability", "createCustomerHold", "startCustomerCheckout", "readCustomerPaymentReturn", "readCustomerManage", "cancelCustomerBooking", "rescheduleCustomerBooking", "joinCustomerWaitlist"]: errors.append("customerServiceMethods must declare the
|
|
204
|
+
if customer_methods != ["readPublicCatalog", "readPublicAvailability", "createCustomerHold", "startCustomerCheckout", "startCustomerPackageCheckout", "readCustomerPaymentReturn", "readCustomerPackagePaymentReturn", "readCustomerManage", "cancelCustomerBooking", "rescheduleCustomerBooking", "joinCustomerWaitlist"]: errors.append("customerServiceMethods must declare the eleven public customer methods")
|
|
204
205
|
return errors
|
|
205
206
|
|
|
206
207
|
|
|
@@ -235,7 +236,17 @@ def access_contract_checks(contract: dict) -> list[str]:
|
|
|
235
236
|
if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
|
|
236
237
|
if auth.get("resendInvitation") is not True: errors.append("resendInvitation must be enabled")
|
|
237
238
|
if roles != ["manager", "staff", "finance"]: errors.append("roles must be manager, staff, finance")
|
|
238
|
-
|
|
239
|
+
expected_permissions = (
|
|
240
|
+
"calendar.read", "bookings.read", "bookings.manage", "customers.read",
|
|
241
|
+
"waitlist.read", "waitlist.manage", "catalog.read", "catalog.manage",
|
|
242
|
+
"packages.read", "packages.manage", "staff.read", "staff.manage",
|
|
243
|
+
"resources.read", "resources.manage", "schedule.read", "schedule.manage",
|
|
244
|
+
"locations.read", "locations.manage", "payments.read", "payments.manage",
|
|
245
|
+
"refunds.manage", "notifications.read", "notifications.manage",
|
|
246
|
+
"settings.read", "settings.manage", "reports.read", "audit.read",
|
|
247
|
+
)
|
|
248
|
+
if permissions != list(expected_permissions): errors.append("access permissions do not match the complete route capability registry")
|
|
249
|
+
for permission in expected_permissions:
|
|
239
250
|
if permission not in permissions: errors.append(f"missing permission: {permission}")
|
|
240
251
|
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
252
|
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"}
|
|
@@ -273,12 +284,12 @@ def email_templates_contract_checks(contract: dict) -> list[str]:
|
|
|
273
284
|
if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
|
|
274
285
|
if roles != ["owner", "admin", "manager"]: errors.append("roles must be owner, admin, manager")
|
|
275
286
|
events = contract.get("events")
|
|
276
|
-
if not isinstance(events, list) or len(events) !=
|
|
287
|
+
if not isinstance(events, list) or len(events) != 9: errors.append("exactly nine Booking email events are required")
|
|
277
288
|
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"]
|
|
289
|
+
expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "package.purchase.confirmed", "package.purchase.refunded", "booking.waitlist.available"]
|
|
279
290
|
if event_ids != expected_events: errors.append("email event IDs are incomplete or out of order")
|
|
280
291
|
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"}
|
|
292
|
+
allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName"}
|
|
282
293
|
for event in events if isinstance(events, list) else []:
|
|
283
294
|
variables = event.get("variables") if isinstance(event, dict) else None
|
|
284
295
|
if not isinstance(variables, list) or not variables: errors.append(f"{event.get('id', 'unknown')}: variables must be a non-empty list")
|
|
@@ -331,7 +342,7 @@ def customer_contract_checks(contract: dict) -> list[str]:
|
|
|
331
342
|
if noindex is not True: errors.append("customer surface must be noindex")
|
|
332
343
|
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
344
|
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) !=
|
|
345
|
+
if len(endpoints) != 11: errors.append("customer surface must declare eleven endpoints")
|
|
335
346
|
if len(ids) != len(set(ids)): errors.append("customer endpoint IDs must be unique")
|
|
336
347
|
if not all(str(item).startswith("booking.public.") for item in ids): errors.append("customer endpoint IDs must use the booking.public namespace")
|
|
337
348
|
for endpoint in endpoints:
|
|
@@ -630,7 +641,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
630
641
|
setup_check("site", "Public site URL", ["PUBLIC_SITE_URL"], has("PUBLIC_SITE_URL"), detail="Used for login links, booking links, and email URLs."),
|
|
631
642
|
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),
|
|
632
643
|
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."),
|
|
633
|
-
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"),
|
|
644
|
+
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 and (stripe_secret or stripe_webhook) else "Both Stripe secret and webhook secret are required for online payments." if stripe_partial else "Optional at install: an owner or manager can connect Stripe from Booking Overview, or use the deployment secret store.", status_override="passed" if stripe_complete else "degraded" if stripe_partial else "optional"),
|
|
634
645
|
{"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."},
|
|
635
646
|
{"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."},
|
|
636
647
|
{"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."},
|
|
@@ -647,15 +658,19 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
647
658
|
if "stripe" in degraded_config:
|
|
648
659
|
next_steps.append("Configure both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET before accepting online payments.")
|
|
649
660
|
if missing_dependencies:
|
|
650
|
-
next_steps.append("
|
|
651
|
-
if any(item["id"] == "schema" and item["status"] == "not-verified" for item in checks):
|
|
661
|
+
next_steps.append("For a new host, install missing packages and apply the schema together: maggie booking install --project . --confirm --ready")
|
|
662
|
+
if not missing_dependencies and any(item["id"] == "schema" and item["status"] == "not-verified" for item in checks):
|
|
652
663
|
next_steps.append("After backup, run: maggie booking install --project . --confirm --bootstrap")
|
|
653
664
|
if any(item["id"] == "owner" and item["status"] == "not-verified" for item in checks):
|
|
654
665
|
next_steps.append("Open /_maggie/register and create the first owner, then sign in at /_maggie/login")
|
|
655
666
|
if any(item["id"] == "worker" and item["status"] == "not-verified" for item in checks):
|
|
656
667
|
next_steps.append("Configure a single-flight scheduler for: node scripts/maggie-booking-worker.mjs --write --confirm")
|
|
657
|
-
if
|
|
658
|
-
next_steps.append("
|
|
668
|
+
if stripe_partial:
|
|
669
|
+
next_steps.append("Complete both Stripe deployment keys before accepting online payments; then POST /api/maggie/booking/webhooks/stripe and verify the connection.")
|
|
670
|
+
elif not stripe_complete:
|
|
671
|
+
next_steps.append("After signing in, an owner or manager can connect Stripe from Booking Overview; alternatively add both deployment keys, configure the webhook URL, and verify the connection.")
|
|
672
|
+
else:
|
|
673
|
+
next_steps.append("After signing in, open Booking Overview and run Verify Stripe connection; env-key presence alone is not connected state.")
|
|
659
674
|
status = "needs-configuration" if missing_required else "configured-pending-verification"
|
|
660
675
|
return {
|
|
661
676
|
"status": status,
|
|
@@ -663,7 +678,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
663
678
|
"checks": checks,
|
|
664
679
|
"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"}),
|
|
665
680
|
"verificationPending": verification_pending,
|
|
666
|
-
"paymentConfiguration": "online-payments" if
|
|
681
|
+
"paymentConfiguration": "online-payments-pending-verification" if stripe_complete else "incomplete-deployment-stripe" if stripe_partial else "dashboard-or-host-secret-pending",
|
|
667
682
|
"next": next_steps,
|
|
668
683
|
"note": "Read-only checklist. It does not print secrets, connect to PostgreSQL, apply migrations, create an owner, configure Stripe, send email, or deploy.",
|
|
669
684
|
}
|
|
@@ -714,10 +729,101 @@ def ensure_bootstrap_token_secret(root: Path) -> bool:
|
|
|
714
729
|
return True
|
|
715
730
|
|
|
716
731
|
|
|
732
|
+
def write_worker_schedule(root: Path, scheduler: str, *, force: bool = False) -> dict:
|
|
733
|
+
"""Generate a host-owned single-flight schedule for the installed worker.
|
|
734
|
+
|
|
735
|
+
The CLI writes deployment artifacts only. It never enables a systemd unit,
|
|
736
|
+
edits the host crontab, starts a process, or prints environment values.
|
|
737
|
+
The host operator remains responsible for reviewing the unit, selecting an
|
|
738
|
+
unprivileged service user, and enabling it after migration/evidence gates.
|
|
739
|
+
"""
|
|
740
|
+
if scheduler not in {"systemd", "cron"}:
|
|
741
|
+
raise RuntimeError("worker scheduler must be systemd or cron")
|
|
742
|
+
script = root / "scripts" / "maggie-booking-worker.mjs"
|
|
743
|
+
if not script.is_file():
|
|
744
|
+
raise RuntimeError(f"installed Booking worker is missing: {script}")
|
|
745
|
+
output_dir = root / "deploy"
|
|
746
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
env_file = root / ".env"
|
|
748
|
+
worker_log = root / ".maggie" / "booking-worker.log"
|
|
749
|
+
worker_log.parent.mkdir(parents=True, exist_ok=True)
|
|
750
|
+
root_text = root.as_posix()
|
|
751
|
+
script_text = script.as_posix()
|
|
752
|
+
env_text = env_file.as_posix()
|
|
753
|
+
output: dict[str, str] = {}
|
|
754
|
+
if scheduler == "systemd":
|
|
755
|
+
service = output_dir / "maggie-booking-worker.service"
|
|
756
|
+
timer = output_dir / "maggie-booking-worker.timer"
|
|
757
|
+
service_text = f"""[Unit]
|
|
758
|
+
Description=Maggie Booking worker and Resend outbox delivery
|
|
759
|
+
After=network-online.target
|
|
760
|
+
Wants=network-online.target
|
|
761
|
+
|
|
762
|
+
[Service]
|
|
763
|
+
Type=oneshot
|
|
764
|
+
# Review and set an unprivileged deployment user before enabling this unit.
|
|
765
|
+
WorkingDirectory={root_text}
|
|
766
|
+
EnvironmentFile=-{env_text}
|
|
767
|
+
ExecStart=/usr/bin/env node {script_text} --write --confirm
|
|
768
|
+
NoNewPrivileges=true
|
|
769
|
+
PrivateTmp=true
|
|
770
|
+
ProtectSystem=strict
|
|
771
|
+
ProtectHome=true
|
|
772
|
+
ReadWritePaths={root_text}/.maggie
|
|
773
|
+
|
|
774
|
+
[Install]
|
|
775
|
+
WantedBy=multi-user.target
|
|
776
|
+
"""
|
|
777
|
+
timer_text = """[Unit]
|
|
778
|
+
Description=Run Maggie Booking worker every minute
|
|
779
|
+
|
|
780
|
+
[Timer]
|
|
781
|
+
OnBootSec=1min
|
|
782
|
+
OnUnitActiveSec=1min
|
|
783
|
+
AccuracySec=10s
|
|
784
|
+
Persistent=true
|
|
785
|
+
Unit=maggie-booking-worker.service
|
|
786
|
+
|
|
787
|
+
[Install]
|
|
788
|
+
WantedBy=timers.target
|
|
789
|
+
"""
|
|
790
|
+
for path, text in ((service, service_text), (timer, timer_text)):
|
|
791
|
+
if path.exists() and not force:
|
|
792
|
+
output[path.name] = "unchanged"
|
|
793
|
+
continue
|
|
794
|
+
path.write_text(text, encoding="utf-8")
|
|
795
|
+
output[path.name] = "written"
|
|
796
|
+
else:
|
|
797
|
+
cron = output_dir / "maggie-booking-worker.cron"
|
|
798
|
+
command = "cd {root} && {worker}".format(
|
|
799
|
+
root=shlex.quote(root_text),
|
|
800
|
+
worker=" ".join([
|
|
801
|
+
"/usr/bin/flock -n /tmp/maggie-booking-worker.lock",
|
|
802
|
+
"/usr/bin/env node",
|
|
803
|
+
shlex.quote(script_text),
|
|
804
|
+
"--write --confirm",
|
|
805
|
+
]),
|
|
806
|
+
)
|
|
807
|
+
cron_text = f"""# Maggie Booking worker; install with the deploy user's crontab after review.
|
|
808
|
+
# The worker performs its own PostgreSQL migration preflight and tenant lock.
|
|
809
|
+
* * * * * {command} >> {shlex.quote(worker_log.as_posix())} 2>&1
|
|
810
|
+
"""
|
|
811
|
+
if cron.exists() and not force:
|
|
812
|
+
output[cron.name] = "unchanged"
|
|
813
|
+
else:
|
|
814
|
+
cron.write_text(cron_text, encoding="utf-8")
|
|
815
|
+
output[cron.name] = "written"
|
|
816
|
+
return {"status": "generated", "scheduler": scheduler, "files": output, "note": "Review and enable the host scheduler after migration and release evidence; no service or crontab was changed."}
|
|
817
|
+
|
|
818
|
+
|
|
717
819
|
def command_install(args: argparse.Namespace) -> int:
|
|
718
820
|
"""Install Booking, optionally adding missing host packages and schema."""
|
|
719
|
-
|
|
720
|
-
|
|
821
|
+
schedule = getattr(args, "schedule", "none")
|
|
822
|
+
ready = bool(getattr(args, "ready", False))
|
|
823
|
+
install_dependencies = bool(getattr(args, "install_dependencies", False) or ready)
|
|
824
|
+
bootstrap = bool(getattr(args, "bootstrap", False) or ready)
|
|
825
|
+
if (bootstrap or install_dependencies or schedule != "none") and (args.dry_run or args.diff):
|
|
826
|
+
print("INSTALL_MUTATION_INCOMPATIBLE: --ready, --bootstrap, --install-dependencies, and --schedule cannot be combined with --dry-run or --diff.", file=sys.stderr)
|
|
721
827
|
return 2
|
|
722
828
|
if not args.dry_run and not args.diff and not args.confirm:
|
|
723
829
|
print("CONFIRMATION_REQUIRED: add --confirm before installing Booking.", file=sys.stderr)
|
|
@@ -742,26 +848,47 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
742
848
|
completed = subprocess.run(command, cwd=root, check=False)
|
|
743
849
|
if completed.returncode != 0:
|
|
744
850
|
return completed.returncode
|
|
745
|
-
if
|
|
851
|
+
if install_dependencies:
|
|
746
852
|
try:
|
|
747
853
|
dependency_result = install_host_dependencies(root)
|
|
748
854
|
except RuntimeError as error:
|
|
749
855
|
print(f"DEPENDENCY_INSTALL_FAILED: {error}", file=sys.stderr)
|
|
750
856
|
return 1
|
|
751
857
|
print(json.dumps({"status": "host-dependencies", **dependency_result}, ensure_ascii=False))
|
|
752
|
-
if
|
|
753
|
-
|
|
858
|
+
if bootstrap:
|
|
859
|
+
try:
|
|
860
|
+
ensure_bootstrap_token_secret(root)
|
|
861
|
+
except RuntimeError as error:
|
|
862
|
+
print(f"BOOTSTRAP_FAILED: {error}", file=sys.stderr)
|
|
863
|
+
return 1
|
|
864
|
+
schema_script = root / "scripts" / "maggie-booking-schema.mjs"
|
|
865
|
+
if not schema_script.is_file():
|
|
866
|
+
print(f"BOOTSTRAP_FAILED: installed host schema script is missing: {schema_script}", file=sys.stderr)
|
|
867
|
+
return 1
|
|
868
|
+
schema = subprocess.run(["node", str(schema_script), "--apply", "--confirm"], cwd=root, check=False)
|
|
869
|
+
if schema.returncode != 0:
|
|
870
|
+
return schema.returncode
|
|
871
|
+
if schedule != "none":
|
|
872
|
+
try:
|
|
873
|
+
print(json.dumps({"status": "worker-schedule", **write_worker_schedule(root, schedule, force=args.force)}, ensure_ascii=False))
|
|
874
|
+
except (OSError, RuntimeError) as error:
|
|
875
|
+
print(f"SCHEDULE_FAILED: {error}", file=sys.stderr)
|
|
876
|
+
return 1
|
|
877
|
+
return 0
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def command_worker_schedule(args: argparse.Namespace) -> int:
|
|
881
|
+
root = project_path(args)
|
|
882
|
+
if not args.confirm:
|
|
883
|
+
print("CONFIRMATION_REQUIRED: add --confirm before generating worker scheduler files.", file=sys.stderr)
|
|
884
|
+
return 2
|
|
754
885
|
try:
|
|
755
|
-
|
|
756
|
-
except RuntimeError as error:
|
|
757
|
-
print(f"
|
|
758
|
-
return 1
|
|
759
|
-
schema_script = root / "scripts" / "maggie-booking-schema.mjs"
|
|
760
|
-
if not schema_script.is_file():
|
|
761
|
-
print(f"BOOTSTRAP_FAILED: installed host schema script is missing: {schema_script}", file=sys.stderr)
|
|
886
|
+
result = write_worker_schedule(root, args.scheduler, force=args.force)
|
|
887
|
+
except (OSError, RuntimeError) as error:
|
|
888
|
+
print(f"SCHEDULE_FAILED: {error}", file=sys.stderr)
|
|
762
889
|
return 1
|
|
763
|
-
|
|
764
|
-
return
|
|
890
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
891
|
+
return 0
|
|
765
892
|
|
|
766
893
|
|
|
767
894
|
def command_inspect(args: argparse.Namespace) -> int:
|
|
@@ -1073,8 +1200,16 @@ def parser() -> argparse.ArgumentParser:
|
|
|
1073
1200
|
install.add_argument("--existing-dir")
|
|
1074
1201
|
install.add_argument("--install-dependencies", action="store_true", help="add missing Astro Booking packages with the host package manager")
|
|
1075
1202
|
install.add_argument("--bootstrap", action="store_true", help="apply the installed host Booking schema after installation")
|
|
1203
|
+
install.add_argument("--ready", action="store_true", help="install missing host packages and apply the Booking schema after installation")
|
|
1204
|
+
install.add_argument("--schedule", choices=["none", "systemd", "cron"], default="none", help="generate a reviewed worker scheduler artifact after installation")
|
|
1076
1205
|
install.add_argument("--confirm", action="store_true")
|
|
1077
1206
|
install.set_defaults(func=command_install)
|
|
1207
|
+
worker_schedule = sub.add_parser("worker-schedule", help="generate a reviewed systemd or cron Booking worker schedule")
|
|
1208
|
+
worker_schedule.add_argument("--project", default=".")
|
|
1209
|
+
worker_schedule.add_argument("--scheduler", choices=["systemd", "cron"], required=True)
|
|
1210
|
+
worker_schedule.add_argument("--force", action="store_true")
|
|
1211
|
+
worker_schedule.add_argument("--confirm", action="store_true")
|
|
1212
|
+
worker_schedule.set_defaults(func=command_worker_schedule)
|
|
1078
1213
|
contract = sub.add_parser("contract", help="validate the provider-neutral Booking contract")
|
|
1079
1214
|
contract.add_argument("--contract", default=str(DEFAULT_CONTRACT))
|
|
1080
1215
|
contract.set_defaults(func=command_contract)
|
|
@@ -13,6 +13,98 @@ from browser_behavior import validate_samples
|
|
|
13
13
|
from localization_runner import checkpoint
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
INTERACTION_ACTIONS = {
|
|
17
|
+
"click", "fill", "type", "select", "press", "wait",
|
|
18
|
+
"assert-visible", "assert-enabled", "assert-disabled", "assert-checked",
|
|
19
|
+
"assert-editable", "assert-text", "assert-style", "assert-no-request",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_interactions(path: Path | None) -> list[dict]:
|
|
24
|
+
if not path:
|
|
25
|
+
return []
|
|
26
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
if not isinstance(value, dict) or value.get("schemaVersion") != "maggie-browser-interactions.v1":
|
|
28
|
+
raise ValueError("interaction manifest schemaVersion must be maggie-browser-interactions.v1")
|
|
29
|
+
steps = value.get("steps")
|
|
30
|
+
if not isinstance(steps, list) or not steps:
|
|
31
|
+
raise ValueError("interaction manifest steps must be a non-empty array")
|
|
32
|
+
for step in steps:
|
|
33
|
+
if not isinstance(step, dict) or step.get("action") not in INTERACTION_ACTIONS:
|
|
34
|
+
raise ValueError("each interaction step must use a supported action")
|
|
35
|
+
if step.get("action") not in {"wait", "press", "assert-no-request"} and not str(step.get("selector") or "").strip():
|
|
36
|
+
raise ValueError("interaction step selector is required")
|
|
37
|
+
return steps
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def js_value(call, expression: str):
|
|
41
|
+
raw = call("js", expression).strip()
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(raw)
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
return raw.strip('"')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resource_urls(call) -> set[str]:
|
|
49
|
+
value = js_value(call, "JSON.stringify(performance.getEntriesByType('resource').map(entry => entry.name))")
|
|
50
|
+
return set(value if isinstance(value, list) else [])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_interactions(call, steps: list[dict]) -> list[dict]:
|
|
54
|
+
baseline = resource_urls(call)
|
|
55
|
+
evidence = []
|
|
56
|
+
for step in steps:
|
|
57
|
+
action = step["action"]
|
|
58
|
+
selector = str(step.get("selector") or "")
|
|
59
|
+
record = {"id": str(step.get("id") or f"step-{len(evidence) + 1}"), "action": action, "selector": selector, "status": "passed"}
|
|
60
|
+
try:
|
|
61
|
+
if action == "click":
|
|
62
|
+
call("click", selector)
|
|
63
|
+
elif action == "fill":
|
|
64
|
+
call("fill", selector, str(step.get("value") or ""))
|
|
65
|
+
elif action == "type":
|
|
66
|
+
call("click", selector)
|
|
67
|
+
call("type", str(step.get("text") or ""))
|
|
68
|
+
elif action == "select":
|
|
69
|
+
call("select", selector, str(step.get("value") or ""))
|
|
70
|
+
elif action == "press":
|
|
71
|
+
call("press", str(step.get("key") or "Enter"))
|
|
72
|
+
elif action == "wait":
|
|
73
|
+
call("wait", str(step.get("target") or "--load"))
|
|
74
|
+
elif action.startswith("assert-") and action in {"assert-visible", "assert-enabled", "assert-disabled", "assert-checked", "assert-editable"}:
|
|
75
|
+
call("is", action.removeprefix("assert-"), selector)
|
|
76
|
+
elif action == "assert-text":
|
|
77
|
+
expected = str(step.get("text") or "")
|
|
78
|
+
expression = f"(() => {{ const node = document.querySelector({json.dumps(selector)}); return Boolean(node && node.textContent.includes({json.dumps(expected)})); }})()"
|
|
79
|
+
if js_value(call, expression) is not True:
|
|
80
|
+
raise ValueError("text assertion failed")
|
|
81
|
+
elif action == "assert-style":
|
|
82
|
+
prop = str(step.get("property") or "")
|
|
83
|
+
expected = str(step.get("value") or "")
|
|
84
|
+
if not prop:
|
|
85
|
+
raise ValueError("style assertion property is required")
|
|
86
|
+
expression = f"(() => {{ const node = document.querySelector({json.dumps(selector)}); return node ? getComputedStyle(node)[{json.dumps(prop)}] : null; }})()"
|
|
87
|
+
actual = js_value(call, expression)
|
|
88
|
+
if actual != expected:
|
|
89
|
+
raise ValueError("style assertion failed")
|
|
90
|
+
elif action == "assert-no-request":
|
|
91
|
+
forbidden = str(step.get("origin") or step.get("url") or "")
|
|
92
|
+
if not forbidden:
|
|
93
|
+
raise ValueError("request assertion origin is required")
|
|
94
|
+
new_urls = sorted(url for url in resource_urls(call) - baseline if forbidden in url)
|
|
95
|
+
if new_urls:
|
|
96
|
+
raise ValueError("third-party request assertion failed")
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError("unsupported interaction action")
|
|
99
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
100
|
+
record["status"] = "failed"
|
|
101
|
+
record["error"] = str(error)
|
|
102
|
+
evidence.append(record)
|
|
103
|
+
break
|
|
104
|
+
evidence.append(record)
|
|
105
|
+
return evidence
|
|
106
|
+
|
|
107
|
+
|
|
16
108
|
def audit(args):
|
|
17
109
|
if urlparse(args.url).scheme not in {"http", "https", "file"}:
|
|
18
110
|
raise ValueError("URL must use http, https or file")
|
|
@@ -20,13 +112,15 @@ def audit(args):
|
|
|
20
112
|
raise ValueError("at least one --required selector is necessary")
|
|
21
113
|
output = args.output.resolve()
|
|
22
114
|
output.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
interactions = load_interactions(args.interactions)
|
|
23
116
|
def call(*command):
|
|
24
117
|
result = subprocess.run([str(args.browse), *command], capture_output=True, text=True, timeout=45)
|
|
25
118
|
if result.returncode:
|
|
26
119
|
raise ValueError("browser command failed: " + command[0])
|
|
27
120
|
return result.stdout
|
|
28
121
|
report = {"schemaVersion": "maggie-browser-audit.v1", "url": args.url,
|
|
29
|
-
"passed": False, "viewports": [], "evidence": "browser-captured"
|
|
122
|
+
"passed": False, "viewports": [], "evidence": "browser-captured",
|
|
123
|
+
"interactionManifest": str(args.interactions.resolve()) if args.interactions else None}
|
|
30
124
|
try:
|
|
31
125
|
for index, viewport in enumerate(args.viewport or ["390x844", "768x1024", "1440x900"]):
|
|
32
126
|
width, height = [int(value) for value in viewport.split("x")]
|
|
@@ -36,6 +130,7 @@ def audit(args):
|
|
|
36
130
|
call("goto", args.url)
|
|
37
131
|
selectors = list(dict.fromkeys(args.required + args.sticky))
|
|
38
132
|
call("js", "window.__maggieAuditSelectors=" + json.dumps(selectors))
|
|
133
|
+
interaction_evidence = run_interactions(call, interactions) if interactions else []
|
|
39
134
|
samples = []
|
|
40
135
|
for step, fraction in enumerate([0, 0.5, 0.9]):
|
|
41
136
|
call("js", f"window.scrollTo(0, (document.documentElement.scrollHeight-innerHeight)*{fraction})")
|
|
@@ -50,8 +145,8 @@ def audit(args):
|
|
|
50
145
|
raise ValueError("browser did not create screenshot")
|
|
51
146
|
finding = validate_samples(samples, args.required, args.sticky)
|
|
52
147
|
report["viewports"].append({"viewport": viewport, "samples": samples,
|
|
53
|
-
"screenshot": str(screenshot), **finding})
|
|
54
|
-
report["passed"] = all(item["passed"] for item in report["viewports"])
|
|
148
|
+
"screenshot": str(screenshot), "interactionEvidence": interaction_evidence, **finding})
|
|
149
|
+
report["passed"] = all(item["passed"] and all(step["status"] == "passed" for step in item.get("interactionEvidence", [])) for item in report["viewports"])
|
|
55
150
|
finally:
|
|
56
151
|
checkpoint(output / "report.json", report)
|
|
57
152
|
print(json.dumps(report, indent=2))
|
|
@@ -66,6 +161,7 @@ def main():
|
|
|
66
161
|
parser.add_argument("--viewport", action="append")
|
|
67
162
|
parser.add_argument("--required", action="append", default=[])
|
|
68
163
|
parser.add_argument("--sticky", action="append", default=[])
|
|
164
|
+
parser.add_argument("--interactions", type=Path, help="declarative interaction/assertion manifest")
|
|
69
165
|
args = parser.parse_args()
|
|
70
166
|
try:
|
|
71
167
|
return audit(args)
|
|
@@ -316,6 +316,48 @@ def command_verify(args):
|
|
|
316
316
|
return 0
|
|
317
317
|
|
|
318
318
|
|
|
319
|
+
def command_interactions(args):
|
|
320
|
+
"""Replay the shared browser-audit manifest for every clone target/viewport."""
|
|
321
|
+
root, manifest = read_manifest(args.project.resolve(), args.run_id)
|
|
322
|
+
interaction_path = args.interactions.resolve()
|
|
323
|
+
try:
|
|
324
|
+
interaction_manifest = json.loads(interaction_path.read_text(encoding="utf-8"))
|
|
325
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
326
|
+
raise ValueError(f"cannot read interaction manifest: {error}") from error
|
|
327
|
+
if interaction_manifest.get("schemaVersion") != "maggie-browser-interactions.v1" or not isinstance(interaction_manifest.get("steps"), list) or not interaction_manifest["steps"]:
|
|
328
|
+
raise ValueError("interaction manifest must be a non-empty maggie-browser-interactions.v1 document")
|
|
329
|
+
evidence_root = root / "interactions"
|
|
330
|
+
evidence_root.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
reports = []
|
|
332
|
+
states = []
|
|
333
|
+
audit_cli = Path(__file__).with_name("maggie_browser_audit.py")
|
|
334
|
+
for target in manifest["targets"]:
|
|
335
|
+
output = evidence_root / target["page_key"]
|
|
336
|
+
selectors = {str(step.get("selector")) for step in interaction_manifest["steps"] if step.get("selector")}
|
|
337
|
+
command = [sys.executable, str(audit_cli), target.get("capture_url", target["source_url"]), "--browse", str(args.browse), "--output", str(output), "--interactions", str(interaction_path), "--required", "body"]
|
|
338
|
+
for selector in sorted(selectors):
|
|
339
|
+
command.extend(["--required", selector])
|
|
340
|
+
for viewport in VIEWPORTS:
|
|
341
|
+
command.extend(["--viewport", f"{VIEWPORTS[viewport][0]}x{VIEWPORTS[viewport][1]}"])
|
|
342
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
343
|
+
report_path = output / "report.json"
|
|
344
|
+
reports.append({"pageKey": target["page_key"], "status": "passed" if completed.returncode == 0 else "failed", "report": str(report_path), "stderr": "browser audit failed" if completed.returncode else None})
|
|
345
|
+
try:
|
|
346
|
+
audit_report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
347
|
+
except (OSError, json.JSONDecodeError):
|
|
348
|
+
audit_report = {}
|
|
349
|
+
for viewport, viewport_report in zip(("desktop", "tablet", "mobile"), audit_report.get("viewports") or []):
|
|
350
|
+
for step in interaction_manifest["steps"]:
|
|
351
|
+
step_evidence = next((item for item in viewport_report.get("interactionEvidence", []) if item.get("id") == step.get("id")), {})
|
|
352
|
+
states.append({"viewport": viewport, "stepId": step["id"], "status": step_evidence.get("status", "failed"), "screenshot": viewport_report.get("screenshot"), "error": step_evidence.get("error")})
|
|
353
|
+
evidence = {"schemaVersion": "maggie-clone-interaction-state.v1", "sourceRunId": manifest["run_id"], "interactionManifest": str(interaction_path), "viewports": list(VIEWPORTS), "states": states, "redacted": True, "reports": reports, "passed": bool(reports) and bool(states) and all(item["status"] == "passed" for item in reports) and all(item["status"] == "passed" for item in states)}
|
|
354
|
+
save_json(evidence_root / "evidence.json", evidence)
|
|
355
|
+
manifest["interactionEvidence"] = str(evidence_root / "evidence.json")
|
|
356
|
+
save_json(root / "manifest.json", manifest)
|
|
357
|
+
print(json.dumps(evidence, indent=2, ensure_ascii=False))
|
|
358
|
+
return 0 if evidence["passed"] else 1
|
|
359
|
+
|
|
360
|
+
|
|
319
361
|
def command_status(args):
|
|
320
362
|
root = run_root(args.project.resolve(), args.run_id)
|
|
321
363
|
manifest_path = root / "manifest.json"
|
|
@@ -373,13 +415,16 @@ def main():
|
|
|
373
415
|
status_parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
374
416
|
status_parser.add_argument("--run-id", required=True)
|
|
375
417
|
status_parser.set_defaults(func=command_status)
|
|
376
|
-
commands = {"init": command_init, "capture": command_capture, "extract": command_extract, "assets": command_assets, "compare": command_compare, "verify": command_verify}
|
|
418
|
+
commands = {"init": command_init, "capture": command_capture, "extract": command_extract, "assets": command_assets, "compare": command_compare, "verify": command_verify, "interactions": command_interactions}
|
|
377
419
|
for name, func in commands.items():
|
|
378
420
|
cmd = sub.add_parser(name); cmd.add_argument("--project", type=Path, default=Path.cwd()); cmd.add_argument("--run-id", required=True); cmd.add_argument("--force", action="store_true")
|
|
379
421
|
if name == "init": cmd.add_argument("urls", nargs="+")
|
|
380
422
|
if name == "assets": cmd.add_argument("--max-bytes", type=int, default=20_000_000)
|
|
381
423
|
if name == "compare": cmd.add_argument("--local-url", required=True)
|
|
382
424
|
if name == "verify": cmd.add_argument("--allow-uncompared", action="store_true")
|
|
425
|
+
if name == "interactions":
|
|
426
|
+
cmd.add_argument("--browse", type=Path, required=True)
|
|
427
|
+
cmd.add_argument("--interactions", type=Path, required=True, help="shared maggie-browser-interactions.v1 manifest")
|
|
383
428
|
cmd.set_defaults(func=func)
|
|
384
429
|
args = parser.parse_args()
|
|
385
430
|
try: return int(args.func(args) or 0)
|