@topy-ai/maggie 0.7.37 → 0.7.40

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 (67) hide show
  1. package/README-zh-TW.md +65 -7
  2. package/README.md +80 -11
  3. package/bin/maggie.js +10 -2
  4. package/bundled-contracts/maggie-design/css-utility-evidence-v1.schema.json +10 -0
  5. package/bundled-contracts/maggie-design/dashboard-surface-v1.schema.json +11 -0
  6. package/bundled-contracts/maggie-design/progressive-enhancement-v1.schema.json +12 -0
  7. package/bundled-contracts/maggie-design/sample-surface-v1.schema.json +13 -0
  8. package/bundled-contracts/maggie-seo/privacy-origin-evidence-v1.schema.json +11 -0
  9. package/bundled-contracts/maggiedash/README.md +1 -1
  10. package/bundled-contracts/maggiedash/booking-access-v1.json +5 -4
  11. package/bundled-contracts/maggiedash/booking-customer-surface-v1.json +12 -1
  12. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +2 -0
  13. package/bundled-contracts/maggiedash/booking-host-adapter-v1.json +16 -2
  14. package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
  15. package/bundled-contracts/maggiedash/execution-board.json +529 -28
  16. package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
  17. package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -0
  18. package/bundled-references/maggiedash-booking/ARCHITECTURE.md +218 -0
  19. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +92 -0
  20. package/bundled-references/maggiedash-booking/DATA-FLOW.md +143 -0
  21. package/bundled-references/maggiedash-booking/DATA-MODEL.md +367 -0
  22. package/bundled-references/maggiedash-booking/DECISIONS.md +94 -0
  23. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +2387 -0
  24. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +314 -0
  25. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +227 -0
  26. package/bundled-references/maggiedash-booking/PAYMENT-GATEWAY.md +267 -0
  27. package/bundled-references/maggiedash-booking/PRD.md +228 -0
  28. package/bundled-references/maggiedash-booking/PROGRESS.md +2434 -0
  29. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +235 -0
  30. package/bundled-references/maggiedash-booking/README.md +271 -0
  31. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +152 -0
  32. package/bundled-references/maggiedash-booking/SECURITY-COMPLIANCE.md +158 -0
  33. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +542 -0
  34. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +129 -0
  35. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +107 -0
  36. package/bundled-references/maggiedash-booking/TASKS.md +137 -0
  37. package/bundled-references/maggiedash-booking/USER-JOURNEYS.md +224 -0
  38. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.excalidraw +1 -0
  39. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.mmd +16 -0
  40. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.png +0 -0
  41. package/bundled-references/maggiedash-booking/diagrams/booking-dfd.svg +1 -0
  42. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.mmd +19 -0
  43. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.png +0 -0
  44. package/bundled-references/maggiedash-booking/diagrams/booking-state-machine.svg +1 -0
  45. package/bundled-references/maggiedash-booking/diagrams/manager-journey.excalidraw +1 -0
  46. package/bundled-references/maggiedash-booking/diagrams/manager-journey.mmd +11 -0
  47. package/bundled-references/maggiedash-booking/diagrams/manager-journey.png +0 -0
  48. package/bundled-references/maggiedash-booking/diagrams/manager-journey.svg +1 -0
  49. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.mmd +20 -0
  50. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.png +0 -0
  51. package/bundled-references/maggiedash-booking/diagrams/payment-sequence.svg +1 -0
  52. package/bundled-references/maggiedash-booking/diagrams/system-context.excalidraw +1 -0
  53. package/bundled-references/maggiedash-booking/diagrams/system-context.mmd +10 -0
  54. package/bundled-references/maggiedash-booking/diagrams/system-context.png +0 -0
  55. package/bundled-references/maggiedash-booking/diagrams/system-context.svg +1 -0
  56. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +16 -0
  57. package/bundled-skills/maggie-booking/SKILL.md +103 -22
  58. package/bundled-skills/maggie-design/SKILL.md +35 -0
  59. package/bundled-skills/maggie-seo-geo/SKILL.md +12 -0
  60. package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
  61. package/bundled-tools/clis/maggie_booking.py +231 -31
  62. package/bundled-tools/clis/maggie_contracts.py +282 -0
  63. package/bundled-tools/clis/maggie_dash.py +28 -6
  64. package/bundled-tools/clis/maggie_design.py +29 -12
  65. package/bundled-tools/clis/maggie_service_booking.py +50 -1
  66. package/bundled-tools/clis/site_audit.py +49 -0
  67. package/package.json +1 -1
@@ -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:
@@ -403,6 +414,14 @@ REQUIRED_HOST_DEPENDENCIES = (
403
414
  "tailwindcss",
404
415
  )
405
416
 
417
+ ASTRO_ADAPTER_DEPENDENCIES = (
418
+ "@astrojs/node",
419
+ "@astrojs/cloudflare",
420
+ "@astrojs/netlify",
421
+ "@astrojs/vercel",
422
+ "@astrojs/deno",
423
+ )
424
+
406
425
  # Keep the host install path reproducible without taking ownership of the
407
426
  # host's Astro version. Existing dependencies are left untouched; only
408
427
  # missing packages are added by `--install-dependencies`.
@@ -415,17 +434,74 @@ HOST_DEPENDENCY_RANGES = {
415
434
  "react-dom": "^19.0.0",
416
435
  "stripe": "^22.0.0",
417
436
  "tailwindcss": "^4.0.0",
437
+ "@astrojs/node": "^9.5.0",
438
+ "@astrojs/cloudflare": "^12.0.0",
439
+ "@astrojs/netlify": "^6.0.0",
440
+ "@astrojs/vercel": "^8.0.0",
441
+ "@astrojs/deno": "^6.0.0",
418
442
  }
419
443
 
420
444
 
445
+ def astro_config_files(root: Path) -> list[Path]:
446
+ return sorted(root.glob("astro.config.*"))
447
+
448
+
449
+ def astro_adapter_dependency(root: Path) -> str | None:
450
+ """Choose the existing Astro adapter, or the portable Node default.
451
+
452
+ A host-owned config takes precedence. A fresh Astro project gets the Node
453
+ adapter because Booking includes server routes; existing Cloudflare,
454
+ Netlify, Vercel, or Deno deployments are never replaced or supplemented.
455
+ """
456
+ try:
457
+ package = json.loads((root / "package.json").read_text(encoding="utf-8"))
458
+ except (OSError, json.JSONDecodeError):
459
+ package = {}
460
+ installed = set(package.get("dependencies") or {}) | set(package.get("devDependencies") or {})
461
+ configs = astro_config_files(root)
462
+ if "astro" not in installed and not configs and not any(root.glob("src/**/*.astro")):
463
+ return None
464
+ for name in ASTRO_ADAPTER_DEPENDENCIES:
465
+ if name in installed:
466
+ return name
467
+ config_text = "\n".join(path.read_text(encoding="utf-8", errors="ignore") for path in configs if path.is_file())
468
+ for name in ASTRO_ADAPTER_DEPENDENCIES:
469
+ if name in config_text:
470
+ return name
471
+ if not configs:
472
+ return "@astrojs/node"
473
+ # A project-specific adapter may be imported from a private package or
474
+ # local module. Do not guess or install Node over that host decision.
475
+ if "adapter" in config_text:
476
+ return None
477
+ return "@astrojs/node"
478
+
479
+
421
480
  def missing_host_dependencies(root: Path) -> list[str]:
422
481
  """Return only host packages absent from both dependency sections."""
423
482
  try:
424
483
  package = json.loads((root / "package.json").read_text(encoding="utf-8"))
425
484
  except (OSError, json.JSONDecodeError):
426
- return list(REQUIRED_HOST_DEPENDENCIES)
485
+ package = {}
427
486
  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)
487
+ required = list(REQUIRED_HOST_DEPENDENCIES)
488
+ adapter = astro_adapter_dependency(root)
489
+ if adapter and adapter not in required:
490
+ required.append(adapter)
491
+ return sorted(name for name in required if name not in installed)
492
+
493
+
494
+ def dependency_range(name: str, root: Path) -> str:
495
+ if name != "@astrojs/node":
496
+ return HOST_DEPENDENCY_RANGES[name]
497
+ try:
498
+ package = json.loads((root / "package.json").read_text(encoding="utf-8"))
499
+ except (OSError, json.JSONDecodeError):
500
+ package = {}
501
+ version = str((package.get("dependencies") or {}).get("astro") or (package.get("devDependencies") or {}).get("astro") or "")
502
+ match = re.search(r"(?:^|[^0-9])([0-9]+)(?:\.[0-9]+)?(?:\.[0-9]+)?", version)
503
+ major = int(match.group(1)) if match else 5
504
+ return "^9.5.0" if major <= 5 else "^10.0.0" if major == 6 else "^11.0.0"
429
505
 
430
506
 
431
507
  def host_package_manager(root: Path) -> tuple[str, list[str]]:
@@ -440,7 +516,7 @@ def host_package_manager(root: Path) -> tuple[str, list[str]]:
440
516
  manager = next((name for filename, name in lockfiles if (root / filename).is_file()), "npm")
441
517
  if shutil.which(manager) is None:
442
518
  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)]
519
+ packages = [f"{name}@{dependency_range(name, root)}" for name in missing_host_dependencies(root)]
444
520
  if manager == "npm":
445
521
  return manager, ["npm", "install", "--save", "--no-audit", "--no-fund", *packages]
446
522
  if manager == "pnpm":
@@ -460,7 +536,7 @@ def install_host_dependencies(root: Path, runner=None) -> dict:
460
536
  completed = execute(command, cwd=root, check=False)
461
537
  if completed.returncode != 0:
462
538
  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]]}
539
+ return {"status": "installed", "missing": missing, "packageManager": manager, "command": [manager, "add" if manager != "npm" else "install", *[f"{name}@{dependency_range(name, root)}" for name in missing]]}
464
540
 
465
541
 
466
542
  def _meaningful_env_value(value: str) -> bool:
@@ -545,7 +621,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
545
621
  except (OSError, json.JSONDecodeError):
546
622
  package = {}
547
623
  installed_dependencies = set((package.get("dependencies") or {})) | set((package.get("devDependencies") or {}))
548
- missing_dependencies = sorted(set(REQUIRED_HOST_DEPENDENCIES) - installed_dependencies)
624
+ missing_dependencies = missing_host_dependencies(root)
549
625
  has_sender = has("EMAIL_FROM") or has("RESEND_FROM_EMAIL")
550
626
  token_present = has("BOOKING_TOKEN_SECRET")
551
627
  token_valid = token_present and len(values.get("BOOKING_TOKEN_SECRET", "")) >= 32
@@ -565,7 +641,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
565
641
  setup_check("site", "Public site URL", ["PUBLIC_SITE_URL"], has("PUBLIC_SITE_URL"), detail="Used for login links, booking links, and email URLs."),
566
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),
567
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."),
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"),
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"),
569
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."},
570
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."},
571
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."},
@@ -582,15 +658,19 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
582
658
  if "stripe" in degraded_config:
583
659
  next_steps.append("Configure both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET before accepting online payments.")
584
660
  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):
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):
587
663
  next_steps.append("After backup, run: maggie booking install --project . --confirm --bootstrap")
588
664
  if any(item["id"] == "owner" and item["status"] == "not-verified" for item in checks):
589
665
  next_steps.append("Open /_maggie/register and create the first owner, then sign in at /_maggie/login")
590
666
  if any(item["id"] == "worker" and item["status"] == "not-verified" for item in checks):
591
667
  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.")
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.")
594
674
  status = "needs-configuration" if missing_required else "configured-pending-verification"
595
675
  return {
596
676
  "status": status,
@@ -598,7 +678,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
598
678
  "checks": checks,
599
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"}),
600
680
  "verificationPending": verification_pending,
601
- "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",
602
682
  "next": next_steps,
603
683
  "note": "Read-only checklist. It does not print secrets, connect to PostgreSQL, apply migrations, create an owner, configure Stripe, send email, or deploy.",
604
684
  }
@@ -649,10 +729,101 @@ def ensure_bootstrap_token_secret(root: Path) -> bool:
649
729
  return True
650
730
 
651
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
+
652
819
  def command_install(args: argparse.Namespace) -> int:
653
820
  """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)
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)
656
827
  return 2
657
828
  if not args.dry_run and not args.diff and not args.confirm:
658
829
  print("CONFIRMATION_REQUIRED: add --confirm before installing Booking.", file=sys.stderr)
@@ -677,26 +848,47 @@ def command_install(args: argparse.Namespace) -> int:
677
848
  completed = subprocess.run(command, cwd=root, check=False)
678
849
  if completed.returncode != 0:
679
850
  return completed.returncode
680
- if args.install_dependencies:
851
+ if install_dependencies:
681
852
  try:
682
853
  dependency_result = install_host_dependencies(root)
683
854
  except RuntimeError as error:
684
855
  print(f"DEPENDENCY_INSTALL_FAILED: {error}", file=sys.stderr)
685
856
  return 1
686
857
  print(json.dumps({"status": "host-dependencies", **dependency_result}, ensure_ascii=False))
687
- if not args.bootstrap:
688
- 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
689
885
  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)
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)
697
889
  return 1
698
- schema = subprocess.run(["node", str(schema_script), "--apply", "--confirm"], cwd=root, check=False)
699
- return schema.returncode
890
+ print(json.dumps(result, ensure_ascii=False))
891
+ return 0
700
892
 
701
893
 
702
894
  def command_inspect(args: argparse.Namespace) -> int:
@@ -1008,8 +1200,16 @@ def parser() -> argparse.ArgumentParser:
1008
1200
  install.add_argument("--existing-dir")
1009
1201
  install.add_argument("--install-dependencies", action="store_true", help="add missing Astro Booking packages with the host package manager")
1010
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")
1011
1205
  install.add_argument("--confirm", action="store_true")
1012
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)
1013
1213
  contract = sub.add_parser("contract", help="validate the provider-neutral Booking contract")
1014
1214
  contract.add_argument("--contract", default=str(DEFAULT_CONTRACT))
1015
1215
  contract.set_defaults(func=command_contract)