@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.
- package/README-zh-TW.md +65 -7
- package/README.md +80 -11
- package/bin/maggie.js +10 -2
- 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-seo/privacy-origin-evidence-v1.schema.json +11 -0
- package/bundled-contracts/maggiedash/README.md +1 -1
- 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 +16 -2
- package/bundled-contracts/maggiedash/booking-runtime.v1.json +41 -0
- package/bundled-contracts/maggiedash/execution-board.json +529 -28
- package/bundled-contracts/maggiedash/host-capabilities-v1.schema.json +10 -0
- package/bundled-contracts/maggiedash/site-structure-v1.schema.json +13 -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-bootstrap/SKILL.md +16 -0
- package/bundled-skills/maggie-booking/SKILL.md +103 -22
- package/bundled-skills/maggie-design/SKILL.md +35 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +12 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +14 -0
- package/bundled-tools/clis/maggie_booking.py +231 -31
- package/bundled-tools/clis/maggie_contracts.py +282 -0
- package/bundled-tools/clis/maggie_dash.py +28 -6
- package/bundled-tools/clis/maggie_design.py +29 -12
- package/bundled-tools/clis/maggie_service_booking.py +50 -1
- package/bundled-tools/clis/site_audit.py +49 -0
- 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
|
|
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:
|
|
@@ -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
|
-
|
|
485
|
+
package = {}
|
|
427
486
|
installed = set(package.get("dependencies") or {}) | set(package.get("devDependencies") or {})
|
|
428
|
-
|
|
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}@{
|
|
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}@{
|
|
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 =
|
|
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("
|
|
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
|
|
593
|
-
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.")
|
|
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
|
|
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
|
-
|
|
655
|
-
|
|
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
|
|
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
|
|
688
|
-
|
|
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
|
-
|
|
691
|
-
except RuntimeError as error:
|
|
692
|
-
print(f"
|
|
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
|
-
|
|
699
|
-
return
|
|
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)
|