@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.
Files changed (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -45
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +30 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +52 -5
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +11 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +142 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +154 -8
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. package/references/provider-adapter-contract.md +16 -3
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env python3
2
+ """Deployment operations that were run by hand and went wrong (``maggie deployment ...``).
3
+
4
+ - ``cloudflare-preflight``: refuse ``wrangler deploy`` when CLOUDFLARE_API_TOKEN
5
+ or CLOUDFLARE_ACCOUNT_ID is empty. Wrangler then silently uses its cached
6
+ OAuth login — possibly another Cloudflare account — uploads a stray Worker
7
+ there and fails with "Can't infer zone from route ... [code: 10082]".
8
+ Checks the config's ``account_id`` and, with ``--verify-zone``, that the
9
+ token's account owns the zone of every custom-domain route (read-only API
10
+ calls).
11
+ - ``file-parity``: before a surgical multi-host copy, hash the files about to
12
+ be replaced on every host and compare them with the copies at the commit
13
+ that is deployed (and across hosts), instead of ad-hoc md5sum loops.
14
+ - ``warm-check``: send warm-up requests first, then judge latency; the first
15
+ requests after a restart (8-16 s) are worker start-up, not a regression.
16
+ - ``vps-release``: one command for the SSR Node release done by hand: git
17
+ archive of a commit → upload → install → build **as the deploy user** with
18
+ the server env file → switch ``current`` → restart → health/smoke with
19
+ warm-up → prune to N releases → a roadmap line. Dry run unless ``--confirm``.
20
+ Building with ``sudo`` leaves root-owned ``dist``/cache files that the
21
+ unprivileged prune cannot remove; the release refuses that and names the
22
+ one-time fix.
23
+
24
+ Values of secrets are never printed; only variable names and digests.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import shlex
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+ from urllib.error import HTTPError, URLError
41
+ from urllib.parse import quote
42
+ from urllib.request import Request, urlopen
43
+
44
+ CF_REQUIRED = ("CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID")
45
+ CF_NEAR_MISS = re.compile(r"^(?:CF_|CLOUDFLARE_)(?:API_)?(?:TOKEN|KEY|ACCOUNT(?:_ID)?|ACCOUNTID)$")
46
+ OAUTH_CACHES = ("~/.config/.wrangler/config/default.toml", "~/.wrangler/config/default.toml", "~/Library/Preferences/.wrangler/config/default.toml")
47
+ SAFE_REF = re.compile(r"^[A-Za-z0-9._/@{}~^:-]{1,200}$")
48
+ SAFE_PATH = re.compile(r"^[A-Za-z0-9._/@+-][A-Za-z0-9._/@+ -]{0,300}$")
49
+
50
+
51
+ def _env_file(path: Path | None) -> dict[str, str]:
52
+ values: dict[str, str] = {}
53
+ if not path or not path.is_file():
54
+ return values
55
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
56
+ line = line.strip()
57
+ if not line or line.startswith("#") or "=" not in line:
58
+ continue
59
+ key, value = line.removeprefix("export ").split("=", 1)
60
+ values[key.strip()] = value.strip().strip("\"'")
61
+ return values
62
+
63
+
64
+ def _strip_jsonc(text: str) -> str:
65
+ out, i, in_string = [], 0, False
66
+ while i < len(text):
67
+ char = text[i]
68
+ if in_string:
69
+ out.append(char)
70
+ if char == "\\" and i + 1 < len(text):
71
+ out.append(text[i + 1]); i += 2; continue
72
+ if char == '"':
73
+ in_string = False
74
+ elif char == '"':
75
+ in_string = True; out.append(char)
76
+ elif text.startswith("//", i):
77
+ while i < len(text) and text[i] != "\n":
78
+ i += 1
79
+ continue
80
+ elif text.startswith("/*", i):
81
+ end = text.find("*/", i + 2)
82
+ i = len(text) if end < 0 else end + 2
83
+ continue
84
+ else:
85
+ out.append(char)
86
+ i += 1
87
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
88
+
89
+
90
+ def wrangler_config(project: Path, config: Path | None, env_name: str | None) -> dict:
91
+ """account_id and route patterns/zones from wrangler.json(c) or wrangler.toml (minimal TOML reading)."""
92
+ candidates = [config] if config else [project / name for name in ("wrangler.jsonc", "wrangler.json", "wrangler.toml")]
93
+ path = next((p for p in candidates if p and p.is_file()), None)
94
+ if path is None:
95
+ raise ValueError("no wrangler.jsonc / wrangler.json / wrangler.toml found; pass --config")
96
+ text = path.read_text(encoding="utf-8")
97
+ routes: list[dict] = []
98
+ if path.suffix in {".json", ".jsonc"}:
99
+ data = json.loads(_strip_jsonc(text))
100
+ section = data.get("env", {}).get(env_name, {}) if env_name else data
101
+ account = section.get("account_id") or data.get("account_id")
102
+ raw = section.get("routes") or ([section["route"]] if section.get("route") else [])
103
+ for route in raw:
104
+ routes.append(route if isinstance(route, dict) else {"pattern": route})
105
+ else:
106
+ account_match = re.search(r'^\s*account_id\s*=\s*"([^"]+)"', text, re.M)
107
+ account = account_match.group(1) if account_match else None
108
+ for match in re.finditer(r'\{\s*pattern\s*=\s*"([^"]+)"([^}]*)\}', text):
109
+ extra = match.group(2)
110
+ zone = re.search(r'zone_name\s*=\s*"([^"]+)"', extra)
111
+ routes.append({"pattern": match.group(1), "custom_domain": "custom_domain = true" in extra.replace(" ", " "), **({"zone_name": zone.group(1)} if zone else {})})
112
+ for match in re.finditer(r'^\s*route\s*=\s*"([^"]+)"', text, re.M):
113
+ routes.append({"pattern": match.group(1)})
114
+ for route in routes:
115
+ if not route.get("zone_name"):
116
+ host = str(route.get("pattern", "")).split("/")[0].lstrip("*.")
117
+ parts = host.split(".")
118
+ route["zone_guess"] = ".".join(parts[-2:]) if len(parts) >= 2 else host
119
+ return {"path": path.name, "accountId": account, "routes": routes}
120
+
121
+
122
+ def _cf_get(token: str, path: str) -> dict:
123
+ request = Request("https://api.cloudflare.com/client/v4" + path, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
124
+ try:
125
+ with urlopen(request, timeout=20) as response: # noqa: S310 - fixed Cloudflare API host
126
+ return json.loads(response.read())
127
+ except HTTPError as error:
128
+ return {"success": False, "status": error.code}
129
+ except (URLError, OSError, ValueError) as error:
130
+ return {"success": False, "error": str(error)[:120]}
131
+
132
+
133
+ def cloudflare_preflight(project: Path, config: Path | None, env_name: str | None, env_file: Path | None, verify_zone: bool, environ: dict | None = None, home: Path | None = None, api=_cf_get) -> dict:
134
+ environ = dict(os.environ if environ is None else environ)
135
+ file_values = _env_file(env_file)
136
+ errors: list[str] = []
137
+ warnings: list[str] = []
138
+ resolved = {}
139
+ for name in CF_REQUIRED:
140
+ value = (environ.get(name) or "").strip()
141
+ source = "environment" if value else None
142
+ if not value and file_values.get(name, "").strip():
143
+ value, source = file_values[name].strip(), "env-file"
144
+ resolved[name] = value
145
+ if not value:
146
+ errors.append(f"{name} is empty: wrangler would fall back to its cached OAuth login (maybe another account) — set it before deploying")
147
+ else:
148
+ resolved[name + ":source"] = source
149
+ near = sorted(key for key in set(file_values) | set(environ) if CF_NEAR_MISS.match(key) and key not in CF_REQUIRED and (file_values.get(key) or environ.get(key)))
150
+ if near and any(not resolved[name] for name in CF_REQUIRED):
151
+ warnings.append("found " + ", ".join(near) + " but wrangler reads " + " and ".join(CF_REQUIRED) + "; the deploy script may read the wrong names")
152
+ home = home or Path.home()
153
+ caches = [path for path in OAUTH_CACHES if (home / path.removeprefix("~/")).is_file()]
154
+ if caches:
155
+ warnings.append("wrangler has a cached OAuth login (" + caches[0] + "); it is used whenever the token is missing. Keep CLOUDFLARE_API_TOKEN set for deploys, or run `wrangler logout` on deploy machines")
156
+ cfg = wrangler_config(project, config, env_name)
157
+ account = resolved.get("CLOUDFLARE_ACCOUNT_ID")
158
+ if not cfg["accountId"]:
159
+ warnings.append(f"{cfg['path']} has no account_id; set it so wrangler cannot deploy into another account")
160
+ elif account and cfg["accountId"] != account:
161
+ errors.append(f"{cfg['path']} account_id differs from CLOUDFLARE_ACCOUNT_ID; the deploy would target another account")
162
+ zones = sorted({route.get("zone_name") or route.get("zone_guess") for route in cfg["routes"] if route.get("zone_name") or route.get("zone_guess")})
163
+ zone_checks = []
164
+ if verify_zone and not errors:
165
+ token = resolved["CLOUDFLARE_API_TOKEN"]
166
+ verify = api(token, "/user/tokens/verify")
167
+ if not verify.get("success"):
168
+ errors.append("CLOUDFLARE_API_TOKEN did not verify (expired, revoked or a different token type)")
169
+ for zone in zones:
170
+ found = api(token, f"/zones?name={quote(zone)}&account.id={quote(account or '')}")
171
+ owned = bool(found.get("success") and found.get("result"))
172
+ zone_checks.append({"zone": zone, "ownedByAccount": owned})
173
+ if not owned:
174
+ errors.append(f"zone {zone} is not in account {account[-6:] if account else '?'} for this token: routes on it fail with code 10082")
175
+ elif zones:
176
+ warnings.append("zone ownership not verified; add --verify-zone (read-only API calls) before the first deploy to a domain")
177
+ return {
178
+ "schemaVersion": "maggie-cloudflare-preflight.v1",
179
+ "config": cfg["path"],
180
+ "variables": {name: bool(resolved[name]) for name in CF_REQUIRED},
181
+ "sources": {name: resolved.get(name + ":source") for name in CF_REQUIRED},
182
+ "routes": [route.get("pattern") for route in cfg["routes"]],
183
+ "zones": zone_checks or [{"zone": zone, "ownedByAccount": None} for zone in zones],
184
+ "status": "failed" if errors else ("warning" if warnings else "passed"),
185
+ "errors": errors,
186
+ "warnings": warnings,
187
+ }
188
+
189
+
190
+ # --- hosts --------------------------------------------------------------------------
191
+
192
+ def run_on(host: str, script: str, ssh_args: list[str], stdin: bytes | None = None, timeout: int = 1800) -> subprocess.CompletedProcess:
193
+ """Run a bash script on a host over ssh stdin ("local" runs it here, for tests and single-host setups)."""
194
+ if host == "local":
195
+ return subprocess.run(["bash", "-c", script], input=stdin, capture_output=True, timeout=timeout, check=False)
196
+ payload = script.encode() if stdin is None else stdin
197
+ command = ["ssh", "-o", "BatchMode=yes", *ssh_args, host]
198
+ command += ["bash -s"] if stdin is None else ["bash -c " + shlex.quote(script)]
199
+ return subprocess.run(command, input=payload, capture_output=True, timeout=timeout, check=False)
200
+
201
+
202
+ def file_parity(hosts: list[str], root: str, paths: list[str], repo: Path, ref: str, subdir: str, ssh_args: list[str]) -> dict:
203
+ if not SAFE_REF.match(ref) or ref.startswith("-"):
204
+ raise ValueError("--ref must be a git revision")
205
+ for path in paths:
206
+ if not SAFE_PATH.match(path) or ".." in path.split("/"):
207
+ raise ValueError(f"unsafe path {path!r}")
208
+ expected = {}
209
+ for path in paths:
210
+ blob = subprocess.run(["git", "-C", str(repo), "show", f"{ref}:{(subdir.strip('/') + '/' if subdir else '')}{path}"], capture_output=True, check=False)
211
+ expected[path] = hashlib.sha256(blob.stdout).hexdigest() if blob.returncode == 0 else None
212
+ script = "set -u\ncd " + shlex.quote(root) + " || exit 3\n" + "".join(
213
+ f"if [ -f {shlex.quote(p)} ]; then printf '%s %s\\n' \"$(sha256sum < {shlex.quote(p)} | cut -c1-64)\" {shlex.quote(p)}; else printf 'MISSING %s\\n' {shlex.quote(p)}; fi\n" for p in paths)
214
+ observed: dict[str, dict[str, str | None]] = {}
215
+ errors: list[str] = []
216
+ for host in hosts:
217
+ result = run_on(host, script, ssh_args, timeout=120)
218
+ if result.returncode:
219
+ errors.append(f"{host}: could not read {root} (exit {result.returncode})")
220
+ continue
221
+ observed[host] = {}
222
+ for line in result.stdout.decode().splitlines():
223
+ digest, _, path = line.partition(" ")
224
+ observed[host][path] = None if digest == "MISSING" else digest
225
+ rows = []
226
+ for path in paths:
227
+ row = {"path": path, "expected": (expected[path] or "")[:12] or None, "hosts": {}}
228
+ digests = set()
229
+ for host, files in observed.items():
230
+ digest = files.get(path)
231
+ digests.add(digest)
232
+ state = "missing" if digest is None else ("match" if digest == expected[path] else "differs")
233
+ row["hosts"][host] = state
234
+ if expected[path] is None:
235
+ row["hosts"][host] = "new-file" if digest is None else "unexpected"
236
+ if state == "differs" and expected[path]:
237
+ errors.append(f"{host}: {path} differs from {ref}; someone changed it on the host — copying over it would hide that")
238
+ if state == "missing" and expected[path]:
239
+ errors.append(f"{host}: {path} is missing although it exists at {ref}")
240
+ if len(digests) > 1:
241
+ errors.append(f"{path}: hosts disagree with each other")
242
+ rows.append(row)
243
+ return {"schemaVersion": "maggie-file-parity.v1", "ref": ref, "root": root, "files": rows, "passed": not errors, "errors": sorted(set(errors))}
244
+
245
+
246
+ def timed_get(url: str, timeout: float = 30.0) -> tuple[int, float]:
247
+ started = time.monotonic()
248
+ try:
249
+ with urlopen(Request(url, headers={"User-Agent": "maggie-warm-check", "Cache-Control": "no-cache"}), timeout=timeout) as response: # noqa: S310
250
+ response.read(65536)
251
+ status = response.status
252
+ except HTTPError as error:
253
+ status = error.code
254
+ except (URLError, OSError):
255
+ status = 0
256
+ return status, round((time.monotonic() - started) * 1000, 1)
257
+
258
+
259
+ def warm_check(urls: list[str], warm: int, samples: int, budget_ms: float, fetch=timed_get) -> dict:
260
+ """Warm each URL (one request per worker you expect), then judge latency on later samples only."""
261
+ results = []
262
+ errors = []
263
+ for url in urls:
264
+ warmups = [fetch(url) for _ in range(warm)]
265
+ measured = [fetch(url) for _ in range(samples)]
266
+ times = sorted(ms for status, ms in measured if 200 <= status < 400)
267
+ statuses = sorted({status for status, _ in measured})
268
+ median = times[len(times) // 2] if times else None
269
+ row = {"url": url, "warmupMs": [ms for _, ms in warmups], "statuses": statuses, "medianMs": median, "maxMs": times[-1] if times else None}
270
+ if not times or any(not 200 <= s < 400 for s in statuses):
271
+ errors.append(f"{url}: status {statuses} after warm-up")
272
+ elif budget_ms and median > budget_ms:
273
+ errors.append(f"{url}: median {median} ms after {warm} warm-up request(s), budget {budget_ms} ms")
274
+ results.append(row)
275
+ return {"schemaVersion": "maggie-warm-check.v1", "warm": warm, "samples": samples, "results": results, "passed": not errors, "errors": errors}
276
+
277
+
278
+ # --- vps release -----------------------------------------------------------------------
279
+
280
+ REMOTE_RELEASE = r'''set -euo pipefail
281
+ RELEASE_ROOT=__ROOT__
282
+ RELEASE_NAME=__NAME__
283
+ ENV_FILE=__ENV__
284
+ REL="$RELEASE_ROOT/releases/$RELEASE_NAME"
285
+ CURRENT="$RELEASE_ROOT/current"
286
+ KEEP=__KEEP__
287
+ say() { printf '{"step":"%s","detail":"%s"}\n' "$1" "${2:-}"; }
288
+ [ "$(id -u)" != 0 ] || { say refused "do not release as root: builds as root leave root-owned files the prune cannot remove"; exit 4; }
289
+ [ -d "$REL" ] || { say refused "release folder missing"; exit 4; }
290
+ [ -r "$ENV_FILE" ] || { say refused "the deploy user cannot read $ENV_FILE; make it group-readable by the deploy group (chgrp deploy, chmod 640) instead of building with sudo"; exit 4; }
291
+ PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
292
+ cd "$REL"
293
+ ( set -a; . "$ENV_FILE"; set +a; __INSTALL__ ) >"$REL/.maggie-install.log" 2>&1 || { say failed "install (see $REL/.maggie-install.log)"; exit 5; }
294
+ say installed
295
+ ( set -a; . "$ENV_FILE"; set +a; __BUILD__ ) >"$REL/.maggie-build.log" 2>&1 || { say failed "build (see $REL/.maggie-build.log)"; exit 5; }
296
+ say built
297
+ FOREIGN="$(find "$REL" ! -user "$(id -un)" -print -quit 2>/dev/null || true)"
298
+ [ -z "$FOREIGN" ] || { say failed "files not owned by $(id -un) appeared in the release ($FOREIGN)"; exit 5; }
299
+ ln -sfn "$REL" "$RELEASE_ROOT/.current.tmp" && mv -T "$RELEASE_ROOT/.current.tmp" "$CURRENT"
300
+ say switched "$RELEASE_NAME"
301
+ __RESTART__ || { say failed "restart; current points at the new release, roll back with: ln -sfn $PREVIOUS $CURRENT"; exit 6; }
302
+ say restarted
303
+ __HEALTH__
304
+ pruned=0; blocked=""
305
+ for candidate in $(ls -1dt "$RELEASE_ROOT"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP + 1))); do
306
+ [ "$candidate" = "$REL" ] && continue
307
+ [ "$KEEP" -ge 2 ] && [ "$candidate" = "$PREVIOUS" ] && continue
308
+ if [ -n "$(find "$candidate" ! -user "$(id -un)" -print -quit 2>/dev/null)" ]; then blocked="$blocked $(basename "$candidate")"; continue; fi
309
+ rm -rf -- "$candidate"; pruned=$((pruned + 1))
310
+ done
311
+ say pruned "$pruned"
312
+ [ -z "$blocked" ] || say prune-blocked "root-owned files from an earlier sudo build in:$blocked — one-time fix: sudo chown -R $(id -un): $RELEASE_ROOT/releases/<name>"
313
+ '''
314
+
315
+ HEALTH_SNIPPET = r'''for url in __URLS__; do
316
+ ok=""
317
+ for attempt in $(seq 1 30); do
318
+ status="$(curl -s -o /dev/null -w '%{http_code}' "$url" || printf 000)"
319
+ case "$status" in 2??|3??) ok=1; break ;; 000|502|503|504) sleep 2 ;; *) break ;; esac
320
+ done
321
+ [ -n "$ok" ] || { say failed "health $url returned $status; roll back with: ln -sfn $PREVIOUS $CURRENT and restart"; exit 7; }
322
+ for warm in $(seq 1 __WARM__); do curl -s -o /dev/null "$url" || true; done
323
+ ms="$(curl -s -o /dev/null -w '%{time_total}' "$url" || printf 0)"
324
+ say healthy "$url ${ms}s after __WARM__ warm-up request(s)"
325
+ done
326
+ '''
327
+
328
+
329
+ def release_name(repo: Path, ref: str, now: datetime | None = None) -> tuple[str, str]:
330
+ sha = subprocess.run(["git", "-C", str(repo), "rev-parse", "--short=10", f"{ref}^{{commit}}"], capture_output=True, text=True, check=False)
331
+ if sha.returncode:
332
+ raise ValueError(f"{ref} is not a commit in {repo}")
333
+ stamp = (now or datetime.now(timezone.utc)).strftime("%Y%m%dT%H%M%SZ")
334
+ return f"{stamp}-{sha.stdout.strip()}", sha.stdout.strip()
335
+
336
+
337
+ def remote_release_script(root: str, name: str, env_file: str, keep: int, install: str, build: str, restart: str, health_urls: list[str], warm: int) -> str:
338
+ health = HEALTH_SNIPPET.replace("__URLS__", " ".join(shlex.quote(u) for u in health_urls)).replace("__WARM__", str(int(warm))) if health_urls else 'say healthy "no --health-url given"\n'
339
+ return (REMOTE_RELEASE.replace("__ROOT__", shlex.quote(root)).replace("__NAME__", shlex.quote(name)).replace("__ENV__", shlex.quote(env_file))
340
+ .replace("__KEEP__", str(int(keep))).replace("__INSTALL__", install).replace("__BUILD__", build).replace("__RESTART__", restart).replace("__HEALTH__", health))
341
+
342
+
343
+ def vps_release(args: argparse.Namespace, ssh_args: list[str]) -> dict:
344
+ repo = Path(args.repo).resolve()
345
+ if not SAFE_REF.match(args.ref) or args.ref.startswith("-"):
346
+ raise ValueError("--ref must be a git revision")
347
+ if not 1 <= args.keep <= 5:
348
+ raise ValueError("--keep must be 1-5")
349
+ name, sha = release_name(repo, args.ref)
350
+ restart = args.restart or f"sudo -n systemctl restart {shlex.quote(args.service)}"
351
+ script = remote_release_script(args.release_root, name, args.env_file, args.keep, args.install, args.build, restart, args.health_url, args.warm)
352
+ tree = f"{args.ref}:{args.subdir.strip('/')}" if args.subdir else args.ref
353
+ plan = {
354
+ "schemaVersion": "maggie-vps-release.v1",
355
+ "host": args.host, "release": name, "commit": sha, "releaseRoot": args.release_root,
356
+ "steps": [f"git archive {tree} → {args.release_root}/releases/{name}", f"install as the deploy user: {args.install}", f"build with {args.env_file}: {args.build}",
357
+ "refuse root-owned files", "switch current atomically", f"restart: {restart}", f"health {args.health_url or '-'} with {args.warm} warm-up request(s)", f"prune to {args.keep} release(s)"],
358
+ "executed": False,
359
+ }
360
+ if not args.confirm:
361
+ plan["remoteScript"] = script
362
+ plan["note"] = "dry run: nothing was uploaded; re-run with --confirm"
363
+ return plan
364
+ archive = subprocess.run(["git", "-C", str(repo), "archive", "--format=tar", tree], capture_output=True, check=False)
365
+ if archive.returncode:
366
+ raise ValueError(f"git archive {tree} failed")
367
+ target = f"{args.release_root.rstrip('/')}/releases/{name}"
368
+ unpack = f"set -e; test ! -e {shlex.quote(target)}; mkdir -p {shlex.quote(target)}; tar -x -C {shlex.quote(target)}"
369
+ uploaded = run_on(args.host, unpack, ssh_args, stdin=archive.stdout, timeout=600)
370
+ if uploaded.returncode:
371
+ raise ValueError(f"upload failed (exit {uploaded.returncode}): {uploaded.stderr.decode(errors='replace')[-300:]}")
372
+ result = run_on(args.host, script, ssh_args, timeout=args.timeout)
373
+ steps = []
374
+ for line in result.stdout.decode(errors="replace").splitlines():
375
+ try:
376
+ steps.append(json.loads(line))
377
+ except json.JSONDecodeError:
378
+ continue
379
+ plan.update(executed=True, exitCode=result.returncode, log=steps, status="released" if result.returncode == 0 else "failed")
380
+ if result.returncode == 0:
381
+ health = "; ".join(step["detail"] for step in steps if step["step"] == "healthy")
382
+ plan["roadmapLine"] = f"{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC — released {sha} as {name} on {args.host}; {health or 'no health URL'}; pruned {next((s['detail'] for s in steps if s['step'] == 'pruned'), '0')}"
383
+ else:
384
+ plan["stderr"] = result.stderr.decode(errors="replace")[-500:]
385
+ return plan
386
+
387
+
388
+ def main(argv: list[str] | None = None) -> int:
389
+ parser = argparse.ArgumentParser(prog="maggie deployment", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
390
+ sub = parser.add_subparsers(dest="command", required=True)
391
+ cf = sub.add_parser("cloudflare-preflight")
392
+ cf.add_argument("--project", type=Path, default=Path("."))
393
+ cf.add_argument("--config", type=Path)
394
+ cf.add_argument("--env", dest="env_name", help="wrangler environment, e.g. production")
395
+ cf.add_argument("--env-file", type=Path, help="file the deploy script loads (names only are reported)")
396
+ cf.add_argument("--verify-zone", action="store_true", help="read-only Cloudflare API calls: token verify + zone lookup in the account")
397
+ ssh_parent = argparse.ArgumentParser(add_help=False)
398
+ ssh_parent.add_argument("--ssh-key")
399
+ ssh_parent.add_argument("--ssh-port", default="22")
400
+ fp = sub.add_parser("file-parity", parents=[ssh_parent])
401
+ fp.add_argument("--host", action="append", required=True, help="ssh target; repeat per host (\"local\" = this machine)")
402
+ fp.add_argument("--root", required=True, help="deployed folder on the hosts, e.g. /srv/api/current")
403
+ fp.add_argument("--path", action="append", required=True, help="file relative to --root that the deploy will copy")
404
+ fp.add_argument("--repo", default=".")
405
+ fp.add_argument("--subdir", default="", help="folder in the repo that maps to --root")
406
+ fp.add_argument("--ref", default="HEAD", help="commit currently deployed (the copies the hosts should hold)")
407
+ wc = sub.add_parser("warm-check")
408
+ wc.add_argument("--url", action="append", required=True)
409
+ wc.add_argument("--warm", type=int, default=3, help="warm-up requests per URL (one per worker process)")
410
+ wc.add_argument("--samples", type=int, default=5)
411
+ wc.add_argument("--budget-ms", type=float, default=0)
412
+ vr = sub.add_parser("vps-release", parents=[ssh_parent])
413
+ vr.add_argument("--host", required=True, help="deploy user's ssh target (never root)")
414
+ vr.add_argument("--release-root", required=True)
415
+ vr.add_argument("--service", required=True)
416
+ vr.add_argument("--env-file", required=True, help="server env file, readable by the deploy user")
417
+ vr.add_argument("--repo", default=".")
418
+ vr.add_argument("--subdir", default="", help="app folder inside the repo")
419
+ vr.add_argument("--ref", default="HEAD")
420
+ vr.add_argument("--install", default="npm ci")
421
+ vr.add_argument("--build", default="npm run build")
422
+ vr.add_argument("--restart", help="default: sudo -n systemctl restart SERVICE")
423
+ vr.add_argument("--health-url", action="append", default=[])
424
+ vr.add_argument("--warm", type=int, default=3)
425
+ vr.add_argument("--keep", type=int, default=2)
426
+ vr.add_argument("--timeout", type=int, default=1800)
427
+ vr.add_argument("--confirm", action="store_true")
428
+ for command in (cf, fp, wc, vr):
429
+ command.add_argument("--output", type=Path)
430
+ args = parser.parse_args(argv)
431
+ ssh_args = []
432
+ if getattr(args, "ssh_key", None):
433
+ ssh_args += ["-i", os.path.expanduser(args.ssh_key)]
434
+ if getattr(args, "ssh_port", "22") != "22":
435
+ ssh_args += ["-p", args.ssh_port]
436
+ try:
437
+ if args.command == "cloudflare-preflight":
438
+ report = cloudflare_preflight(args.project.resolve(), args.config, args.env_name, args.env_file, args.verify_zone)
439
+ failed = report["status"] == "failed"
440
+ elif args.command == "file-parity":
441
+ report = file_parity(args.host, args.root, args.path, Path(args.repo).resolve(), args.ref, args.subdir, ssh_args)
442
+ failed = not report["passed"]
443
+ elif args.command == "warm-check":
444
+ report = warm_check(args.url, max(0, args.warm), max(1, args.samples), args.budget_ms)
445
+ failed = not report["passed"]
446
+ else:
447
+ report = vps_release(args, ssh_args)
448
+ failed = report.get("status") == "failed"
449
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
450
+ print(f"BLOCKED: maggie deployment {args.command}: {error}", file=sys.stderr)
451
+ return 2
452
+ text = json.dumps(report, indent=2, ensure_ascii=False)
453
+ if args.output:
454
+ args.output.parent.mkdir(parents=True, exist_ok=True)
455
+ args.output.write_text(text + "\n", encoding="utf-8")
456
+ print(text)
457
+ return 1 if failed else 0
458
+
459
+
460
+ if __name__ == "__main__":
461
+ raise SystemExit(main())
@@ -208,7 +208,7 @@ APP_CAPABILITY_STATES = {
208
208
  "notifications": ["permission", "granted", "denied"],
209
209
  "media-library": ["permission", "granted", "denied", "empty"],
210
210
  }
211
- APP_PATTERNS = ("tabbed-modal", "editor-sheet-assistant", "usage-hero", "site-switcher")
211
+ APP_PATTERNS = ("tabbed-modal", "editor-sheet-assistant", "usage-hero", "site-switcher", "background-job", "chat-composer", "hash-tabs", "right-drawer", "two-column-sheet", "screen-pager")
212
212
 
213
213
 
214
214
  def parse_app_capabilities(value: str | None) -> list[str]:
@@ -356,6 +356,7 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
356
356
  for name in capabilities:
357
357
  if name != "camera":
358
358
  scenarios.append({"id": f"{name}-permission", "viewport": "mobile", "assertions": [f"{name} permission is explained before the system prompt", "denied permission leaves an actionable fallback"]})
359
+ scenarios.append({"id": "ios-safe-area", "viewport": "notched iPhone (device or simulator)", "assertions": ["header and Close sit below the status bar / Dynamic Island in every modal and sheet", "insets come from useSafeAreaInsets(), not SafeAreaView inside a modal (`maggie design safe-area-check`)", "web screenshots cannot prove this; record a device screenshot"]})
359
360
  for pattern in patterns:
360
361
  scenarios.append({"id": f"pattern-{pattern}", "viewport": "all", "assertions": APP_PATTERN_ACCEPTANCE[pattern]})
361
362
  if auth_mode != "none":
@@ -374,6 +375,12 @@ APP_PATTERN_ACCEPTANCE = {
374
375
  "editor-sheet-assistant": ["'Ask the assistant' turns the same sheet into a short contextual chat", "writes appear as an approval card; nothing changes until approved", "dismissing the chat restores the unsaved edit"],
375
376
  "usage-hero": ["plan name, used and remaining quota are visible without scrolling", "the over-limit state names the next action"],
376
377
  "site-switcher": ["favicon falls back to the site initial", "search appears from 5 sites", "the current site is marked and keyboard reachable"],
378
+ "background-job": ["start returns at once and the item shows a spinner on its preview", "the same action becomes Stop while the job runs", "starting another item asks 'Stop it and start this one?' (409 busy + replace)", "one toast when the job finishes or fails; reloading the page shows the running job", "a stale job expires and the item is usable again"],
379
+ "chat-composer": ["the empty composer is one line tall with the placeholder and buttons vertically centred", "it grows with the text up to a maximum and shrinks again when text is deleted", "send and attach buttons stay aligned to the last line"],
380
+ "hash-tabs": ["the active tab is in the URL hash and survives reload and back/forward", "a hidden panel is measured and paged only after its tab is shown", "no page scroll at 1536x687 or 390 px on any tab"],
381
+ "right-drawer": ["details and long forms open in a drawer from the right that owns its scroll", "Escape and the close button return focus to the opener", "the page behind does not scroll while the drawer is open"],
382
+ "two-column-sheet": ["a large modal shows sections in two columns from 1024 px and one column below", "a quick-nav lists the sections and scrolls the sheet to each", "the sheet body is the only scroll owner"],
383
+ "screen-pager": ["the page size fits the visible rows of the viewport", "it re-measures on resize and when its tab is shown", "it never pages data the server already pages"],
377
384
  }
378
385
 
379
386
 
@@ -1003,6 +1010,8 @@ commands:
1003
1010
  markup-check validate optional style attributes, content keys, and entities
1004
1011
  pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
1005
1012
  layer-check flag unlayered CSS that overrides Tailwind v4 utilities
1013
+ safe-area-check flag SafeAreaView inside iOS modal routes / RN Modal
1014
+ textinput-check flag multiline TextInput without a height / grow-only auto-grow (RN Web)
1006
1015
  palette generate or validate a semantic palette role contract
1007
1016
  verify run automatable design-job steps and record evidence
1008
1017
  icon-glyph-check render icons and prove font load and glyph paint
@@ -1010,7 +1019,7 @@ commands:
1010
1019
  reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
1011
1020
  """)
1012
1021
  return 0
1013
- if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check"}:
1022
+ if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check", "textinput-check"}:
1014
1023
  from maggie_design_lint import main as lint_main
1015
1024
  return lint_main(sys.argv[1:])
1016
1025
  if len(sys.argv) > 1 and sys.argv[1] == "verify":