@topy-ai/maggie 0.7.51 → 0.7.53
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 +48 -2
- package/README.md +66 -45
- package/bin/maggie.js +18 -7
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-booking/SKILL.md +47 -3
- package/bundled-skills/maggie-deployment/SKILL.md +21 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +30 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +17 -0
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +52 -5
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +26 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +15 -4
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
- package/bundled-tools/clis/maggie_design.py +11 -2
- package/bundled-tools/clis/maggie_design_lint.py +142 -3
- package/bundled-tools/clis/maggie_feedback.py +97 -15
- package/bundled-tools/clis/maggie_mobile.py +154 -8
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +52 -6
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
|
@@ -124,6 +124,66 @@ It warns, with environment variable names and host classes only, when:
|
|
|
124
124
|
|
|
125
125
|
Use `--rotating-refresh-tokens yes|no` when the code scan cannot tell.
|
|
126
126
|
|
|
127
|
+
After an edit, prove the browser is running it before judging the change:
|
|
128
|
+
`--base-url http://localhost:8081 --expect-symbol ComposerAutoGrow` fetches the
|
|
129
|
+
page's same-origin scripts and fails when the served bundle lacks the name (a
|
|
130
|
+
stale watcher, the wrong folder, or another session's server). For a local
|
|
131
|
+
`--base-url` the report also names the process listening on the port and its
|
|
132
|
+
start time; a server that was killed (exit 137) and replaced by another
|
|
133
|
+
session fails with "port … is held by pid …".
|
|
134
|
+
|
|
135
|
+
## Driving the browser during QA
|
|
136
|
+
|
|
137
|
+
These rules come from sessions that rediscovered each of them:
|
|
138
|
+
|
|
139
|
+
- **Find controls by role and accessible name, not by text.** Icon fonts put
|
|
140
|
+
private-use glyphs into `innerText` (a React Native Web tab reads
|
|
141
|
+
`"\nImage"` or `"Home"`), so exact-text matching fails. Match
|
|
142
|
+
`[role=tab]`, `[role=button]` with `aria-label`, or strip characters in
|
|
143
|
+
U+E000–U+F8FF before comparing.
|
|
144
|
+
- **Click through the DOM when the automated window is in the background.**
|
|
145
|
+
Synthetic mouse clicks wait for the window to be visible and time out;
|
|
146
|
+
`element.click()` (or dispatching the event) works in a background window.
|
|
147
|
+
Keep real input for checks that are about input itself (hover, drag).
|
|
148
|
+
- **Reuse the app tab.** Opening a new tab per check clutters the user's
|
|
149
|
+
browser and, with rotating refresh tokens, adds another app instance.
|
|
150
|
+
Navigate the existing tab, and close any tab you opened.
|
|
151
|
+
- **Phone width when the window cannot be resized.** Some browser extensions
|
|
152
|
+
ignore window resizing. A same-origin `<iframe>` 390 px wide reuses the
|
|
153
|
+
signed-in session and is fine for layout checks **one at a time**: create
|
|
154
|
+
it, measure, remove it, then the next. The "no parallel iframes" rule is
|
|
155
|
+
about several app instances refreshing a rotating token at once, not about
|
|
156
|
+
a single frame. `maggie design app-collect` and `maggie qa fit-check` set
|
|
157
|
+
the size through the DevTools protocol and need no iframe.
|
|
158
|
+
- **Long measurements run in the page, not in one call.** A loop over 30
|
|
159
|
+
routes inside one script call times out the browser tool. Start the loop in
|
|
160
|
+
the page (`window.__qa = {done: false, results: []}`; an async function that
|
|
161
|
+
pushes results), return immediately, then poll `window.__qa` with short
|
|
162
|
+
calls. Give each call a time budget of a few seconds.
|
|
163
|
+
|
|
164
|
+
## Check that dashboards fit the screen
|
|
165
|
+
|
|
166
|
+
When a dashboard must not scroll the page, check every route, tab and drawer
|
|
167
|
+
at a short laptop and a phone viewport:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
maggie qa fit-check --base-url http://localhost:4321 \
|
|
171
|
+
--route /merchant/bookings --route /merchant/settings \
|
|
172
|
+
--profile-dir .maggie/qa-profile --drawer-selector "[data-drawer-trigger]" \
|
|
173
|
+
--sidebar-selector "nav.sidebar" --output .maggie/qa-runs/fit.json
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Default viewports are 1536x687 (a laptop with browser chrome) and 390x844.
|
|
177
|
+
For each route it measures page overflow on both axes, then clicks every
|
|
178
|
+
visible `[role=tab]` (`--tab-selector`) and measures again, then opens each
|
|
179
|
+
drawer trigger on a fresh page and checks the dialog stays inside the
|
|
180
|
+
viewport. Hidden panels are measured only after their tab is active — a
|
|
181
|
+
panel measured while hidden reports zero, which also breaks screen-sized
|
|
182
|
+
pagers that do not re-measure on tab change. Labels ignore icon glyphs.
|
|
183
|
+
Writes are blocked unless `--allow-write <refresh path>` or `--allow-writes`;
|
|
184
|
+
use `--cdp-url` to attach to a signed-in Chrome. Exit 1 lists each failing
|
|
185
|
+
route, viewport and state, with the first element that is too wide.
|
|
186
|
+
|
|
127
187
|
## Check CORS for every method the client uses
|
|
128
188
|
|
|
129
189
|
Before a release that adds or changes API calls from a browser or webview,
|
|
@@ -205,6 +205,39 @@ does not claim keyboard accessibility, network asset correctness or semantic
|
|
|
205
205
|
content quality unless those checks run separately. Use the installed browser
|
|
206
206
|
path explicitly when Bun is not in PATH.
|
|
207
207
|
|
|
208
|
+
## Route checks: name the origin
|
|
209
|
+
|
|
210
|
+
Release checks that crawl routes (dynamic SEO, localization routes, sitemap
|
|
211
|
+
size) must take an explicit base URL — `--base-url` or a required
|
|
212
|
+
`SITE_ORIGIN` that fails when unset — and print the origin they checked. A
|
|
213
|
+
default such as `http://127.0.0.1:4321` silently hits another local project
|
|
214
|
+
on that port and produces false "foreign URL" and sitemap-size failures.
|
|
215
|
+
Prove which site answers first:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
maggie seo origin-check --base-url http://127.0.0.1:4321 --project .
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
It compares the page's canonical / `og:url` and the robots sitemap host with
|
|
222
|
+
`site` in `astro.config.*` (or `--expect-host`), fails when another site
|
|
223
|
+
answers, and prints `checked origin: …`.
|
|
224
|
+
|
|
225
|
+
## Personalised HTML fragments
|
|
226
|
+
|
|
227
|
+
Per-person partials (recently viewed, recommendations, "your bookings",
|
|
228
|
+
signed-in header fragments) are not public pages. Each must answer
|
|
229
|
+
`Cache-Control: private, no-store` and `X-Robots-Tag: noindex`, with no
|
|
230
|
+
`public`, `s-maxage` or CDN cache header. Global middleware that sets public
|
|
231
|
+
HTML caching for every `text/html` response must leave a route's private
|
|
232
|
+
header alone (check for an existing `Cache-Control` before setting one);
|
|
233
|
+
otherwise a CDN caches one person's fragment and serves it to everyone. The
|
|
234
|
+
public-session cache contract covers full pages; fragments follow this rule:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
maggie seo fragment-check --url http://127.0.0.1:4321/fragments/recently-viewed \
|
|
238
|
+
--url http://127.0.0.1:4321/fragments/recommendations --cookie-env QA_SESSION_COOKIE
|
|
239
|
+
```
|
|
240
|
+
|
|
208
241
|
## Automatic memory hook
|
|
209
242
|
|
|
210
243
|
Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
|
|
@@ -486,6 +486,29 @@ FAQ helper from the first category implementation; it is a known gap and must
|
|
|
486
486
|
remain marked incomplete until each FAQ record is independently AI-authored and
|
|
487
487
|
reviewed.
|
|
488
488
|
|
|
489
|
+
## Moving a business: clients, appointments, gift cards
|
|
490
|
+
|
|
491
|
+
Importing the provider catalogue is not enough to switch a real business.
|
|
492
|
+
Dry-run the exports first (nothing is written):
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
maggie service migrate-check --project . --timezone Europe/London --market uk \
|
|
496
|
+
--clients clients.csv --appointments appointments.csv --gift-cards gift-cards.csv \
|
|
497
|
+
--service "Lash lift" --service "Massage" --staff Jo --staff Sam
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
It recognises Fresha's (and other systems') column names by synonyms and
|
|
501
|
+
lists unmapped columns; de-duplicates clients by e-mail, then by phone in
|
|
502
|
+
E.164; keeps marketing consent only when the export says opted in; keeps
|
|
503
|
+
only future, not-cancelled appointments, converted with the venue's zone,
|
|
504
|
+
and reports clashes and unmatched services or staff instead of forcing them;
|
|
505
|
+
and keeps gift-card codes with their balance in an `IMPORT:` namespace. The
|
|
506
|
+
report holds counts, column names and row numbers only — no client values —
|
|
507
|
+
plus the undo key and plan. Every real import writes an import record first
|
|
508
|
+
so it can be undone (bookings and unused clients/staff deleted, untouched
|
|
509
|
+
imported codes voided), and an optional "we've moved" e-mail goes once, only
|
|
510
|
+
to imported clients with marketing consent, while online booking is live.
|
|
511
|
+
|
|
489
512
|
## Payments: Stripe live setup and gift vouchers
|
|
490
513
|
|
|
491
514
|
Service pages often lead to first-party payments. When the host takes money
|
|
@@ -494,9 +517,11 @@ read-only live setup runbook in `docs/maggiedash-booking/STRIPE-INTEGRATION.md`:
|
|
|
494
517
|
|
|
495
518
|
- verify live state with list/retrieve (`GET`) calls only; never use a create
|
|
496
519
|
call as a probe — a probe expected to fail can create a real live object;
|
|
497
|
-
- the owner installs webhook and
|
|
498
|
-
|
|
499
|
-
|
|
520
|
+
- the owner installs webhook signing secrets — and, on a platform that hosts
|
|
521
|
+
many businesses (Stripe Connect direct charges, see "Platform hosts" in
|
|
522
|
+
`STRIPE-INTEGRATION.md`), the Connect event-destination secret — with a
|
|
523
|
+
no-echo command (`read -rs … | <secret store>`); the agent never sees them.
|
|
524
|
+
A single business needs no Connect;
|
|
500
525
|
- thin-event destination signing secrets cannot be retrieved via the API;
|
|
501
526
|
roll them in the Dashboard if they were not captured;
|
|
502
527
|
- the Stripe Dashboard may be unusable under browser automation, so hand
|
|
@@ -22,7 +22,7 @@ and GEO. It is not a second content source of truth.
|
|
|
22
22
|
Create the reviewable channel contract before generating copy:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
maggie tool maggie_social_share.py plan content.json \
|
|
25
|
+
maggie tool maggie_social_share.py plan content.json --workspace-domain example.com \
|
|
26
26
|
--channels linkedin,facebook --output .maggie/social-share-plan.json
|
|
27
27
|
maggie tool maggie_social_share.py validate .maggie/social-share-plan.json
|
|
28
28
|
```
|
|
@@ -30,6 +30,20 @@ maggie tool maggie_social_share.py validate .maggie/social-share-plan.json
|
|
|
30
30
|
The CLI does not generate copy or publish. It validates identity, canonical
|
|
31
31
|
URL, channel limits, UTM links, approval state, and provider post state.
|
|
32
32
|
|
|
33
|
+
- **Own domain only.** A workspace shares articles published on its own
|
|
34
|
+
site. `plan` refuses a source whose canonical URL is not on
|
|
35
|
+
`--workspace-domain` (or a subdomain), and `validate` refuses an approved
|
|
36
|
+
draft without `workspace_domain`. Apply the same rule in source selection:
|
|
37
|
+
an account that manages several sites must not pick another site's
|
|
38
|
+
articles for this workspace's plan.
|
|
39
|
+
- **Image posts by default.** When a post has its own image (`image_url`),
|
|
40
|
+
each channel gets `publish_mode: "image"` with the link in the text;
|
|
41
|
+
`link_preview` is the default only without an image. A person may switch a
|
|
42
|
+
post to link preview (record `publish_mode_source: "user"`), and the
|
|
43
|
+
preview shows the card LinkedIn will show. `validate` fails an image post
|
|
44
|
+
whose copy lacks the link and an image silently dropped by link-preview
|
|
45
|
+
mode.
|
|
46
|
+
|
|
33
47
|
Before connecting providers or scheduling, check the adapter, connection,
|
|
34
48
|
campaign, and queue contracts (shapes and rules in
|
|
35
49
|
[queue and connections](references/queue-and-connections.md)):
|
|
@@ -68,7 +82,10 @@ maggie tool maggie_marketing_run.py validate .maggie/marketing-runs/<runId>.json
|
|
|
68
82
|
optional `not_before`, and local/remote status. Posting hours are slots;
|
|
69
83
|
do not persist a fixed `scheduled_at` per post (it goes stale and strands
|
|
70
84
|
overdue posts). Campaign ideas stay in the idea stage until written, and a
|
|
71
|
-
campaign starts only through the Start gate.
|
|
85
|
+
campaign starts only through the Start gate. A series or campaign created
|
|
86
|
+
only so it can be planned is deleted (or never committed) when the AI plan
|
|
87
|
+
fails, so a retry does not leave an empty draft per attempt; `campaigns`
|
|
88
|
+
fails a campaign with `plan_status: failed` and no ideas.
|
|
72
89
|
6. Publish only after the required approval and entitlement checks. Record the
|
|
73
90
|
publication attempt and provider ID; reconcile unknown outcomes against
|
|
74
91
|
readback and retry only with the original idempotency key.
|
|
@@ -101,8 +118,13 @@ idempotency, and delete/republish behavior.
|
|
|
101
118
|
decrypt check on every host that serves social routes.
|
|
102
119
|
- Provider avatar URLs are signed and expire; cache the bytes or refresh on
|
|
103
120
|
readback.
|
|
104
|
-
-
|
|
105
|
-
|
|
121
|
+
- Structured-output schemas fail on **complexity**, not only on large enums:
|
|
122
|
+
no enum inside repeated array items, small `maxItems`, and validate the
|
|
123
|
+
vocabulary server-side. Run `maggie tool maggie_social_share.py
|
|
124
|
+
schema-check plan-schema.json` before a release; see [provider adapter
|
|
125
|
+
contract](../../references/provider-adapter-contract.md#structured-model-output).
|
|
126
|
+
- Posted posts have a lifecycle (Post now, delete, edit and republish); see
|
|
127
|
+
[queue and connections](references/queue-and-connections.md#posted-post-lifecycle).
|
|
106
128
|
|
|
107
129
|
## Safety and commercial boundary
|
|
108
130
|
|
|
@@ -115,6 +115,34 @@ An adapter that publishes must declare, in addition to its scope set:
|
|
|
115
115
|
|
|
116
116
|
Adapters without `publishes` keep the scope-only shape.
|
|
117
117
|
|
|
118
|
+
## Posted-post lifecycle
|
|
119
|
+
|
|
120
|
+
**Post now.** Claim the item atomically before calling the provider
|
|
121
|
+
(`UPDATE ... SET status = 'publishing' WHERE id = $1 AND status IN
|
|
122
|
+
('queued','approved') RETURNING ...`); a second click or another worker gets
|
|
123
|
+
no row and does nothing. On a provider failure restore the previous status
|
|
124
|
+
(and its slot); on an unknown outcome (timeout) leave it `publishing` for
|
|
125
|
+
`reconcile`.
|
|
126
|
+
|
|
127
|
+
**Delete a published post.** Delete on the provider first, then mark the
|
|
128
|
+
item `deleted` with the time and who did it. A provider 404 means it is
|
|
129
|
+
already gone: mark it deleted. Never delete the local record while the
|
|
130
|
+
provider post still exists.
|
|
131
|
+
|
|
132
|
+
**Edit and republish.** Most providers cannot edit a post, so an edit is a
|
|
133
|
+
new post replacing the old one. Order matters — never take the old post down
|
|
134
|
+
when the new one cannot go out:
|
|
135
|
+
|
|
136
|
+
1. Pre-check everything: the source article is still published on the
|
|
137
|
+
workspace's own domain, the copy with the link fits the limit, the image
|
|
138
|
+
(if any) is present and accepted, the connection is healthy.
|
|
139
|
+
2. Publish the new post (same idempotency rules as any publish).
|
|
140
|
+
3. Only after the new post is confirmed, delete the old one; if that delete
|
|
141
|
+
fails, keep both recorded and retry the delete — do not roll back the new
|
|
142
|
+
post.
|
|
143
|
+
4. If step 2 fails, nothing changed: the old post stays and the item returns
|
|
144
|
+
to its previous state with the error.
|
|
145
|
+
|
|
118
146
|
## Publish reconciliation — `reconcile`
|
|
119
147
|
|
|
120
148
|
`reconcile` reads the publish-attempt log and a provider readback and returns
|
|
@@ -18,7 +18,7 @@ code or call a model.
|
|
|
18
18
|
a readable summary (never a bare UUID) and the right scope (account-level
|
|
19
19
|
writes are not filed under the open site).
|
|
20
20
|
- ``approval-selftest``: exercises the reference signed one-time approval
|
|
21
|
-
token (``tools/runtime/assistant_approval.py``).
|
|
21
|
+
token and standing workflow approvals (``tools/runtime/assistant_approval.py``).
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
@@ -34,8 +34,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
|
34
34
|
SCHEMA = "maggie-assistant-tool-registry.v1"
|
|
35
35
|
RISKS = ("read", "write-reversible", "write-spends", "write-public", "never")
|
|
36
36
|
RISK_ALIASES = {"R0": "read", "W1": "write-reversible", "W2": "write-spends", "W3": "write-public", "W4": "never"}
|
|
37
|
-
CHANNELS = ("in-app", "mcp", "chatgpt", "claude", "cli")
|
|
37
|
+
CHANNELS = ("in-app", "mcp", "chatgpt", "claude", "cli", "web-widget")
|
|
38
38
|
SCOPES = ("account", "workspace")
|
|
39
|
+
CARDS = ("place", "slot-picker", "booking", "voucher", "change-preview", "numbers", "draft", "list")
|
|
39
40
|
NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$|^[a-z][a-z0-9]+$")
|
|
40
41
|
SNAKE_RE = re.compile(r"\b[a-z][a-z0-9]*_[a-z0-9_]+\b")
|
|
41
42
|
THE_USER_RE = re.compile(r"\bthe user('s)?\b", re.I)
|
|
@@ -74,6 +75,31 @@ def validate_registry(registry: dict) -> dict:
|
|
|
74
75
|
for duplicate in sorted({name for name in names if names.count(name) > 1}):
|
|
75
76
|
errors.append(f"duplicate tool name {duplicate}")
|
|
76
77
|
counts = {risk: 0 for risk in RISKS}
|
|
78
|
+
servers = {}
|
|
79
|
+
for server in registry.get("mcpServers", []) or []:
|
|
80
|
+
if not isinstance(server, dict) or not server.get("name"):
|
|
81
|
+
errors.append("mcpServers entries need a name"); continue
|
|
82
|
+
name = str(server["name"])
|
|
83
|
+
servers[name] = server
|
|
84
|
+
if server.get("auth") not in {"none", "oauth"}:
|
|
85
|
+
errors.append(f"MCP server {name}: auth must be none (public, read-only) or oauth")
|
|
86
|
+
elif server["auth"] == "none":
|
|
87
|
+
limit = server.get("rateLimit") if isinstance(server.get("rateLimit"), dict) else {}
|
|
88
|
+
if not limit.get("perIpPerMinute") and not limit.get("perIpPerDay"):
|
|
89
|
+
errors.append(f"MCP server {name}: a public server needs rateLimit (perIpPerMinute and/or perIpPerDay)")
|
|
90
|
+
else:
|
|
91
|
+
if not str(server.get("resource", "")).startswith("https://"):
|
|
92
|
+
errors.append(f"MCP server {name}: resource must be the server's canonical https URL (the token audience)")
|
|
93
|
+
if server.get("audienceBinding", True) is not True and not str(server.get("audienceException", "")).strip():
|
|
94
|
+
errors.append(f"MCP server {name}: audience binding is on by default (RFC 8707); turning it off needs audienceException with the reason")
|
|
95
|
+
threads = registry.get("threads") if isinstance(registry.get("threads"), dict) else None
|
|
96
|
+
if threads and threads.get("storage") == "server":
|
|
97
|
+
if not isinstance(threads.get("retentionDays"), int) or threads["retentionDays"] < 1:
|
|
98
|
+
errors.append("threads.retentionDays is required for server-stored conversations")
|
|
99
|
+
if threads.get("deleteOnAccountDeletion") is not True:
|
|
100
|
+
errors.append("threads.deleteOnAccountDeletion must be true for server-stored conversations")
|
|
101
|
+
if threads.get("supportAccess") not in {"none", "with-consent"}:
|
|
102
|
+
errors.append("threads.supportAccess must be none or with-consent")
|
|
77
103
|
for tool in tools:
|
|
78
104
|
if not isinstance(tool, dict):
|
|
79
105
|
errors.append("each tool must be an object"); continue
|
|
@@ -122,13 +148,47 @@ def validate_registry(registry: dict) -> dict:
|
|
|
122
148
|
warnings.append(f"{where}: the name reads as destructive; app reviewers expect destructiveHint, so consider write-public")
|
|
123
149
|
if tool.get("audit", {}).get("scope") not in (None, tool.get("scope")):
|
|
124
150
|
errors.append(f"{where}: audit.scope must equal the tool scope (an account-level write is not audited under the open site)")
|
|
151
|
+
card = tool.get("card")
|
|
152
|
+
if card is not None and card not in CARDS:
|
|
153
|
+
errors.append(f"{where}: card must be one of {', '.join(CARDS)}")
|
|
154
|
+
if card == "change-preview" and risk == "read":
|
|
155
|
+
warnings.append(f"{where}: a change-preview card belongs to a write (it shows what will change before approval)")
|
|
156
|
+
for server in tool.get("mcpServers", []) or []:
|
|
157
|
+
if server not in servers:
|
|
158
|
+
errors.append(f"{where}: mcpServers lists {server!r}, which is not declared in registry.mcpServers")
|
|
159
|
+
elif servers[server].get("auth") == "none" and risk != "read":
|
|
160
|
+
errors.append(f"{where}: a public (unauthenticated) MCP server may only expose read tools")
|
|
161
|
+
elif servers[server].get("auth") == "none" and tool.get("scope") == "account":
|
|
162
|
+
errors.append(f"{where}: account-scoped tools need a signed-in caller; keep them off the public MCP server")
|
|
125
163
|
for item in registry.get("cantDo", []) or []:
|
|
126
164
|
if not isinstance(item, dict) or not str(item.get("text", "")).strip():
|
|
127
165
|
errors.append("cantDo entries need text (and ideally 'instead')")
|
|
128
166
|
continue
|
|
129
167
|
for problem in user_copy_problems(str(item.get("text", "")) + " " + str(item.get("instead", "")), name_set):
|
|
130
168
|
errors.append(f"cantDo '{str(item['text'])[:40]}': {problem}")
|
|
131
|
-
|
|
169
|
+
widget_tools = [t for t in tools if isinstance(t, dict) and "web-widget" in (t.get("channels") or [])]
|
|
170
|
+
if widget_tools:
|
|
171
|
+
# A visitor-facing assistant (an AI receptionist) answers strangers: no approvals are possible,
|
|
172
|
+
# so it only reads business facts, is off until the business turns it on, is capped and logged.
|
|
173
|
+
for tool in widget_tools:
|
|
174
|
+
if risk_of(tool) != "read":
|
|
175
|
+
errors.append(f"{tool.get('name')}: web-widget tools answer visitors and must be read-only; booking and payment stay in the normal booking sheet")
|
|
176
|
+
if tool.get("scope") == "account":
|
|
177
|
+
errors.append(f"{tool.get('name')}: web-widget tools act for one business (scope workspace), never on an account")
|
|
178
|
+
policy = registry.get("visitorPolicy") if isinstance(registry.get("visitorPolicy"), dict) else {}
|
|
179
|
+
if policy.get("enabledByDefault") is not False:
|
|
180
|
+
errors.append("visitorPolicy.enabledByDefault must be false: the business turns the receptionist on")
|
|
181
|
+
caps = policy.get("dailyCaps") if isinstance(policy.get("dailyCaps"), dict) else {}
|
|
182
|
+
if not caps.get("perVisitor") or not caps.get("perBusiness"):
|
|
183
|
+
errors.append("visitorPolicy.dailyCaps needs perVisitor and perBusiness (model spend and abuse)")
|
|
184
|
+
if policy.get("logAnswers") is not True:
|
|
185
|
+
errors.append("visitorPolicy.logAnswers must be true: the business can read every answer given in its name")
|
|
186
|
+
if policy.get("factsOnly") is not True:
|
|
187
|
+
errors.append("visitorPolicy.factsOnly must be true: answers come from the business's own facts, never general knowledge about prices or health")
|
|
188
|
+
if policy.get("cardDetails") != "never":
|
|
189
|
+
errors.append("visitorPolicy.cardDetails must be never: the chat never asks for or accepts card numbers")
|
|
190
|
+
public_tools = [t.get("name") for t in tools if isinstance(t, dict) and any(servers.get(s, {}).get("auth") == "none" for s in t.get("mcpServers", []) or [])]
|
|
191
|
+
return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts, "cards": sum(1 for t in tools if isinstance(t, dict) and t.get("card")), "publicMcpTools": len(public_tools)}}
|
|
132
192
|
|
|
133
193
|
|
|
134
194
|
def annotations_for(tool: dict) -> dict:
|
|
@@ -224,6 +284,8 @@ def audit_check(registry: dict, session: dict) -> dict:
|
|
|
224
284
|
expected_scope = "account" if tool.get("scope") == "account" else call.get("workspace")
|
|
225
285
|
if expected_scope and action.get("scope") != expected_scope:
|
|
226
286
|
errors.append(f"{call.get('tool')}: audited under {action.get('scope')!r}, expected {expected_scope!r}")
|
|
287
|
+
if action.get("channel") == "workflow" and not action.get("standingApprovalId"):
|
|
288
|
+
errors.append(f"{call.get('tool')}: a workflow run must record the standing approval id it ran under")
|
|
227
289
|
if call.get("ok") is False and not action.get("error"):
|
|
228
290
|
errors.append(f"{call.get('tool')}: failed call has no audit error")
|
|
229
291
|
return {"schemaVersion": "maggie-assistant-audit-check.v1", "passed": not errors, "errors": errors, "writes": sum(1 for c in calls if risk_of(tools.get(c.get("tool"), {})) not in {"read", ""})}
|
|
@@ -249,6 +311,25 @@ def approval_selftest() -> dict:
|
|
|
249
311
|
results["scopeBound"] = raises(lambda: approval.verify(secret, token, "key-1", "site-b", calls, now=1001))
|
|
250
312
|
results["tamperRejected"] = raises(lambda: approval.verify(secret, token[:-2] + ("AA" if not token.endswith("AA") else "BB"), "key-1", "site-a", calls, now=1001))
|
|
251
313
|
results["idempotencyKeyStable"] = approval.idempotency_key(issued["id"], calls[0]) == approval.idempotency_key(issued["id"], dict(calls[0]))
|
|
314
|
+
# Standing approval: a waitlist workflow may send at most 2 offer e-mails a day with one template.
|
|
315
|
+
standing, grant = approval.issue_standing(secret, "owner-1", "site-a", "waitlist-fill", [{"name": "waitlist_offer_send", "maxPerDay": 2, "fixedArgs": {"template": "waitlist_offer"}}], days=30, now=1000)
|
|
316
|
+
store = approval.StandingStore()
|
|
317
|
+
offer = {"name": "waitlist_offer_send", "args": {"template": "waitlist_offer", "clientId": "c1"}}
|
|
318
|
+
first = store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-13", now=1001)
|
|
319
|
+
results["standingAllowsInsideLimits"] = first["auditChannel"] == "workflow" and first["approvalId"] == grant["id"]
|
|
320
|
+
store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-13", now=1001)
|
|
321
|
+
def falls_back(fn) -> bool:
|
|
322
|
+
try:
|
|
323
|
+
fn(); return False
|
|
324
|
+
except approval.OutsideStandingApproval:
|
|
325
|
+
return True
|
|
326
|
+
results["standingDailyLimit"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-13", now=1001))
|
|
327
|
+
results["standingArgsBound"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", {"name": "waitlist_offer_send", "args": {"template": "promo"}}, "2026-10-14", now=1001))
|
|
328
|
+
results["standingOtherTool"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", {"name": "booking_cancel", "args": {}}, "2026-10-14", now=1001))
|
|
329
|
+
results["standingExpires"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-12-01", now=1000 + 31 * 86400))
|
|
330
|
+
store.revoke(grant["id"])
|
|
331
|
+
results["standingRevocable"] = falls_back(lambda: store.run(secret, standing, "owner-1", "site-a", "waitlist-fill", offer, "2026-10-15", now=1001))
|
|
332
|
+
results["standingNotAOneTimeToken"] = raises(lambda: approval.verify(secret, standing, "owner-1", "site-a", [offer], now=1001))
|
|
252
333
|
return {"schemaVersion": "maggie-assistant-approval-selftest.v1", "passed": all(results.values()), "checks": results}
|
|
253
334
|
|
|
254
335
|
|
|
@@ -260,6 +260,10 @@ def access_contract_checks(contract: dict) -> list[str]:
|
|
|
260
260
|
return errors
|
|
261
261
|
|
|
262
262
|
|
|
263
|
+
EMAIL_TEMPLATE_EVENTS_LEGACY = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "payment.no_show_fee", "package.purchase.confirmed", "package.purchase.refunded", "booking.waitlist.available"]
|
|
264
|
+
EMAIL_TEMPLATE_EVENTS = EMAIL_TEMPLATE_EVENTS_LEGACY[:-1] + ["payment.late_refund_pending", "business.booking_cancelled", "business.booking_rescheduled", "business.late_payment_refund_required", "booking.waitlist.available"]
|
|
265
|
+
|
|
266
|
+
|
|
263
267
|
def email_templates_contract_checks(contract: dict) -> list[str]:
|
|
264
268
|
errors: list[str] = []
|
|
265
269
|
properties = contract.get("properties") if isinstance(contract.get("properties"), dict) else {}
|
|
@@ -289,12 +293,13 @@ def email_templates_contract_checks(contract: dict) -> list[str]:
|
|
|
289
293
|
if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
|
|
290
294
|
if roles != ["owner", "admin", "manager"]: errors.append("roles must be owner, admin, manager")
|
|
291
295
|
events = contract.get("events")
|
|
292
|
-
if not isinstance(events, list) or len(events) != 10: errors.append("exactly ten Booking email events are required")
|
|
293
296
|
event_ids = [item.get("id") for item in events if isinstance(item, dict)] if isinstance(events, list) else []
|
|
294
|
-
|
|
295
|
-
|
|
297
|
+
# MaggieDash <= 0.2.12 installs ship the ten-event contract; 0.2.13 adds the
|
|
298
|
+
# late-payment and business notices. Both are valid installed contracts.
|
|
299
|
+
if event_ids not in (EMAIL_TEMPLATE_EVENTS, EMAIL_TEMPLATE_EVENTS_LEGACY):
|
|
300
|
+
errors.append(f"email template events must be the {len(EMAIL_TEMPLATE_EVENTS)}-event list (or the {len(EMAIL_TEMPLATE_EVENTS_LEGACY)}-event list of MaggieDash 0.2.12 and earlier), in order")
|
|
296
301
|
if len(event_ids) != len(set(event_ids)): errors.append("email event IDs must be unique")
|
|
297
|
-
allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName", "arrivalNote"}
|
|
302
|
+
allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName", "arrivalNote", "cancelledBy", "previousWhen"}
|
|
298
303
|
declared_variables = contract.get("variables")
|
|
299
304
|
event_variables = {variable for event in events if isinstance(event, dict) for variable in event.get("variables", []) if isinstance(variable, str)} if isinstance(events, list) else set()
|
|
300
305
|
if declared_variables is not None and (not isinstance(declared_variables, list) or set(declared_variables) != event_variables or len(declared_variables) != len(set(declared_variables))):
|
|
@@ -1445,6 +1450,12 @@ def parser() -> argparse.ArgumentParser:
|
|
|
1445
1450
|
|
|
1446
1451
|
|
|
1447
1452
|
if __name__ == "__main__":
|
|
1453
|
+
if sys.argv[1:2] == ["rules-vectors"]:
|
|
1454
|
+
from maggie_booking_rules import main as rules_main
|
|
1455
|
+
raise SystemExit(rules_main(sys.argv[2:]))
|
|
1456
|
+
if sys.argv[1:2] == ["connect-qa"]:
|
|
1457
|
+
from maggie_booking_connect_qa import main as connect_qa_main
|
|
1458
|
+
raise SystemExit(connect_qa_main(sys.argv[2:]))
|
|
1448
1459
|
try:
|
|
1449
1460
|
arguments = parser().parse_args()
|
|
1450
1461
|
raise SystemExit(arguments.func(arguments))
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Test-mode Stripe Connect QA for a booking platform (``maggie booking connect-qa``).
|
|
3
|
+
|
|
4
|
+
Without a test-mode connected account every Connect path (deposits on the
|
|
5
|
+
business's account, saved cards, no-show fees, packages, memberships,
|
|
6
|
+
refunds, card readers) can only be tested with simulated signed webhooks.
|
|
7
|
+
This command prepares a real end-to-end test run without changing anything:
|
|
8
|
+
|
|
9
|
+
- refuses live keys (``sk_live_``/``rk_live_``) and reports which variable
|
|
10
|
+
names are set (never values);
|
|
11
|
+
- with ``--detect``, lists the platform's **test-mode** connected accounts
|
|
12
|
+
(read-only ``GET /v1/accounts``) and names one that can take charges, or
|
|
13
|
+
explains how to create one in test mode;
|
|
14
|
+
- prints the ``stripe listen`` command that forwards both platform and Connect
|
|
15
|
+
events to the local webhook routes;
|
|
16
|
+
- lists the payment cases to run, with Stripe's test cards and the reader
|
|
17
|
+
simulator, so a QA run records each outcome.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from urllib.error import HTTPError, URLError
|
|
29
|
+
from urllib.request import Request, urlopen
|
|
30
|
+
|
|
31
|
+
KEY_NAMES = ("STRIPE_SECRET_KEY", "STRIPE_API_KEY")
|
|
32
|
+
WEBHOOK_SECRET_NAMES = ("STRIPE_CONNECT_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET")
|
|
33
|
+
EVENTS = [
|
|
34
|
+
"checkout.session.completed", "checkout.session.expired", "payment_intent.succeeded", "payment_intent.payment_failed",
|
|
35
|
+
"setup_intent.succeeded", "charge.refunded", "charge.dispute.created", "customer.subscription.created",
|
|
36
|
+
"customer.subscription.updated", "customer.subscription.deleted", "invoice.paid", "invoice.payment_failed",
|
|
37
|
+
"terminal.reader.action_succeeded", "terminal.reader.action_failed", "account.updated",
|
|
38
|
+
]
|
|
39
|
+
CASES = [
|
|
40
|
+
{"id": "deposit", "do": "Book a deposit service and pay with 4242 4242 4242 4242", "expect": "booking confirmed only after the Connect webhook; payment row on the connected account; amount, currency and account checked"},
|
|
41
|
+
{"id": "strong-authentication", "do": "Pay with 4000 0025 0000 3155 and complete 3-D Secure", "expect": "confirmed after authentication; the return page polls instead of confirming"},
|
|
42
|
+
{"id": "declined", "do": "Pay with 4000 0000 0000 0002", "expect": "booking stays pending_payment, hold expires, slot freed"},
|
|
43
|
+
{"id": "late-payment", "do": "Let the hold expire, then complete the Checkout session", "expect": "reinstated if the time is free, otherwise refunded in full with a client e-mail (rule late_payment)"},
|
|
44
|
+
{"id": "saved-card-no-show", "do": "Save a card (SetupIntent), mark the booking no-show, charge the no-show fee off-session", "expect": "fee charged on the connected account with the saved payment method; 4000 0027 6000 3184 asks for authentication and must notify the client"},
|
|
45
|
+
{"id": "package", "do": "Buy a treatment package", "expect": "package credits issued once per payment; redeeming one reduces the balance"},
|
|
46
|
+
{"id": "membership", "do": "Start a monthly membership, pause it, resume it", "expect": "subscription on the connected account; credits only in billed months (rule membership_credits)"},
|
|
47
|
+
{"id": "refund", "do": "Refund in full and partly from the dashboard", "expect": "refund on the connected account; charge.refunded reconciles the booking payment"},
|
|
48
|
+
{"id": "card-reader", "do": "Register the simulated reader (registration code simulated-wpe), send a sale to it, present a test card", "expect": "payment recorded only when the PaymentIntent succeeds; reader cancel leaves the sale open"},
|
|
49
|
+
{"id": "dispute", "do": "Pay with 4000 0000 0000 0259", "expect": "dispute shown to the business; no automatic refund"},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _env(env_file: Path | None) -> dict[str, str]:
|
|
54
|
+
values = dict(os.environ)
|
|
55
|
+
if env_file and env_file.is_file():
|
|
56
|
+
for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line and not line.startswith("#") and "=" in line:
|
|
59
|
+
key, value = line.removeprefix("export ").split("=", 1)
|
|
60
|
+
values.setdefault(key.strip(), value.strip().strip("\"'"))
|
|
61
|
+
return values
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_accounts(key: str) -> dict:
|
|
65
|
+
token = base64.b64encode(f"{key}:".encode()).decode()
|
|
66
|
+
request = Request("https://api.stripe.com/v1/accounts?limit=20", headers={"Authorization": f"Basic {token}"})
|
|
67
|
+
try:
|
|
68
|
+
with urlopen(request, timeout=20) as response: # noqa: S310 - fixed Stripe API host, GET only
|
|
69
|
+
return json.loads(response.read())
|
|
70
|
+
except HTTPError as error:
|
|
71
|
+
return {"error": f"Stripe answered {error.code}"}
|
|
72
|
+
except (URLError, OSError, ValueError) as error:
|
|
73
|
+
return {"error": str(error)[:120]}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def connect_qa(env_file: Path | None, connect_url: str, platform_url: str | None, detect: bool, lister=list_accounts) -> dict:
|
|
77
|
+
values = _env(env_file)
|
|
78
|
+
errors: list[str] = []
|
|
79
|
+
warnings: list[str] = []
|
|
80
|
+
key_name = next((name for name in KEY_NAMES if values.get(name)), None)
|
|
81
|
+
key = values.get(key_name, "") if key_name else ""
|
|
82
|
+
if not key:
|
|
83
|
+
errors.append("no Stripe secret key found (STRIPE_SECRET_KEY); use the platform's test-mode key")
|
|
84
|
+
elif key.startswith(("sk_live_", "rk_live_")):
|
|
85
|
+
errors.append(f"{key_name} is a live key; Connect QA runs only in test mode — switch to the sk_test_ key")
|
|
86
|
+
key = ""
|
|
87
|
+
elif not key.startswith(("sk_test_", "rk_test_")):
|
|
88
|
+
warnings.append(f"{key_name} does not look like a Stripe test key")
|
|
89
|
+
secrets = {name: bool(values.get(name)) for name in WEBHOOK_SECRET_NAMES}
|
|
90
|
+
if not any(secrets.values()):
|
|
91
|
+
warnings.append("no webhook signing secret is set; copy the whsec_ value printed by `stripe listen` into the Connect webhook secret variable")
|
|
92
|
+
accounts = None
|
|
93
|
+
ready = None
|
|
94
|
+
if detect and key:
|
|
95
|
+
listing = lister(key)
|
|
96
|
+
if "error" in listing:
|
|
97
|
+
errors.append(f"could not list connected accounts: {listing['error']}")
|
|
98
|
+
else:
|
|
99
|
+
accounts = [{"id": a.get("id"), "chargesEnabled": bool(a.get("charges_enabled")), "detailsSubmitted": bool(a.get("details_submitted")), "country": a.get("country")} for a in listing.get("data", [])]
|
|
100
|
+
ready = next((a["id"] for a in accounts if a["chargesEnabled"]), None)
|
|
101
|
+
if not accounts:
|
|
102
|
+
warnings.append("no test-mode connected account yet: in the Stripe Dashboard (test mode) open Connect → Accounts → Create, complete onboarding with test data, then re-run with --detect")
|
|
103
|
+
elif not ready:
|
|
104
|
+
warnings.append("test connected accounts exist but none can take charges yet; finish onboarding with Stripe's test data")
|
|
105
|
+
listen = "stripe listen --forward-connect-to " + connect_url + (f" --forward-to {platform_url}" if platform_url else "") + " --events " + ",".join(EVENTS)
|
|
106
|
+
return {
|
|
107
|
+
"schemaVersion": "maggiedash-booking-connect-qa.v1",
|
|
108
|
+
"mode": "test",
|
|
109
|
+
"keyVariable": key_name,
|
|
110
|
+
"webhookSecrets": secrets,
|
|
111
|
+
"connectedAccounts": accounts,
|
|
112
|
+
"testAccount": ready,
|
|
113
|
+
"commands": [listen, "stripe trigger checkout.session.completed --stripe-account <acct_test_id> # smoke the Connect route"],
|
|
114
|
+
"cases": CASES,
|
|
115
|
+
"status": "failed" if errors else ("warning" if warnings else "ready"),
|
|
116
|
+
"errors": errors,
|
|
117
|
+
"warnings": warnings,
|
|
118
|
+
"note": "read-only: nothing was created or charged; record each case's outcome with maggie qa record",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
parser = argparse.ArgumentParser(prog="maggie booking connect-qa", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
124
|
+
parser.add_argument("--project", type=Path, default=Path("."))
|
|
125
|
+
parser.add_argument("--env-file", type=Path, help="default: <project>/.env")
|
|
126
|
+
parser.add_argument("--connect-webhook-url", default="http://localhost:4321/api/stripe/connect-events")
|
|
127
|
+
parser.add_argument("--platform-webhook-url", help="platform (non-Connect) webhook route, if separate")
|
|
128
|
+
parser.add_argument("--detect", action="store_true", help="read-only: list test-mode connected accounts")
|
|
129
|
+
args = parser.parse_args(argv)
|
|
130
|
+
report = connect_qa(args.env_file or args.project / ".env", args.connect_webhook_url, args.platform_webhook_url, args.detect)
|
|
131
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
132
|
+
return 1 if report["status"] == "failed" else 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
raise SystemExit(main())
|