@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.
Files changed (95) hide show
  1. package/README-zh-TW.md +82 -6
  2. package/README.md +99 -7
  3. package/bin/maggie.js +30 -3
  4. package/bundled-contracts/maggie-clone/interaction-state-v1.schema.json +26 -0
  5. package/bundled-contracts/maggie-content/provenance-v1.schema.json +20 -0
  6. package/bundled-contracts/maggie-design/brand-kit-v1.schema.json +18 -0
  7. package/bundled-contracts/maggie-design/browser-interactions-v1.schema.json +27 -0
  8. package/bundled-contracts/maggie-design/css-utility-evidence-v1.schema.json +10 -0
  9. package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
  10. package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
  11. package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
  12. package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
  13. package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
  14. package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
  15. package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
  16. package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
  17. package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
  18. package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
  19. package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
  20. package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
  21. package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
  22. package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
  23. package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
  24. package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
  25. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
  26. package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +14 -2
  27. package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
  28. package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
  29. package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
  30. package/bundled-contracts/maggiedash/execution-board.json +526 -26
  31. package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
  32. package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
  33. package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
  34. package/bundled-references/browser-inspection.md +21 -0
  35. package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
  36. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
  37. package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
  38. package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
  39. package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
  40. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
  41. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
  42. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
  43. package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
  44. package/bundled-references/maggiedash-booking/PRD.md +228 -0
  45. package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
  46. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
  47. package/bundled-references/maggiedash-booking/README.md +271 -0
  48. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
  49. package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
  50. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
  51. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
  52. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
  53. package/bundled-references/maggiedash-booking/TASKS.md +137 -0
  54. package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
  55. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
  56. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
  57. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
  58. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
  59. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
  60. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
  61. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
  62. package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
  63. package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
  64. package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
  65. package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
  66. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
  67. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
  68. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
  69. package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
  70. package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
  71. package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
  72. package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
  73. package/bundled-skills/maggie-blog/SKILL.md +12 -0
  74. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +39 -0
  75. package/bundled-skills/maggie-booking/SKILL.md +112 -18
  76. package/bundled-skills/maggie-clone/SKILL.md +13 -0
  77. package/bundled-skills/maggie-deployment/SKILL.md +6 -0
  78. package/bundled-skills/maggie-design/SKILL.md +64 -3
  79. package/bundled-skills/maggie-ops/SKILL.md +12 -0
  80. package/bundled-skills/maggie-seo-geo/SKILL.md +45 -0
  81. package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
  82. package/bundled-tools/clis/maggie_analytics.py +43 -1
  83. package/bundled-tools/clis/maggie_booking.py +161 -26
  84. package/bundled-tools/clis/maggie_browser_audit.py +99 -3
  85. package/bundled-tools/clis/maggie_clone.py +46 -1
  86. package/bundled-tools/clis/maggie_contracts.py +393 -0
  87. package/bundled-tools/clis/maggie_dash.py +22 -6
  88. package/bundled-tools/clis/maggie_design.py +84 -12
  89. package/bundled-tools/clis/maggie_service_booking.py +50 -1
  90. package/bundled-tools/clis/maggie_workflows.py +387 -0
  91. package/bundled-tools/clis/site_audit.py +77 -1
  92. package/bundled-tools/integrations/analytics.md +14 -0
  93. package/bundled-tools/runtime/site_baseline.py +3 -0
  94. package/package.json +1 -1
  95. 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 nine public customer methods")
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
- for permission in ("calendar.read", "bookings.manage", "payments.manage", "refunds.manage", "audit.read"):
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) != 7: errors.append("exactly seven Booking email events are required")
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) != 9: errors.append("customer surface must declare nine 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("Install missing host packages with the project package manager: maggie booking install --project . --confirm --install-dependencies")
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 not has("STRIPE_SECRET_KEY") or not has("STRIPE_WEBHOOK_SECRET"):
658
- next_steps.append("If taking online payments, configure Stripe test keys and POST /api/maggie/booking/webhooks/stripe before live mode.")
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 has("STRIPE_SECRET_KEY") and has("STRIPE_WEBHOOK_SECRET") else "no-payment-or-incomplete-stripe",
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
- if (args.bootstrap or args.install_dependencies) and (args.dry_run or args.diff):
720
- print("INSTALL_MUTATION_INCOMPATIBLE: --bootstrap and --install-dependencies cannot be combined with --dry-run or --diff.", file=sys.stderr)
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 args.install_dependencies:
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 not args.bootstrap:
753
- return 0
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
- ensure_bootstrap_token_secret(root)
756
- except RuntimeError as error:
757
- print(f"BOOTSTRAP_FAILED: {error}", file=sys.stderr)
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
- schema = subprocess.run(["node", str(schema_script), "--apply", "--confirm"], cwd=root, check=False)
764
- return schema.returncode
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)