@topy-ai/maggie 0.7.52 → 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 -54
  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 +17 -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 +36 -6
  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 +9 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +75 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +136 -4
  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]:
@@ -375,6 +375,12 @@ APP_PATTERN_ACCEPTANCE = {
375
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"],
376
376
  "usage-hero": ["plan name, used and remaining quota are visible without scrolling", "the over-limit state names the next action"],
377
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"],
378
384
  }
379
385
 
380
386
 
@@ -1005,6 +1011,7 @@ commands:
1005
1011
  pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
1006
1012
  layer-check flag unlayered CSS that overrides Tailwind v4 utilities
1007
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)
1008
1015
  palette generate or validate a semantic palette role contract
1009
1016
  verify run automatable design-job steps and record evidence
1010
1017
  icon-glyph-check render icons and prove font load and glyph paint
@@ -1012,7 +1019,7 @@ commands:
1012
1019
  reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
1013
1020
  """)
1014
1021
  return 0
1015
- if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check"}:
1022
+ if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check", "textinput-check"}:
1016
1023
  from maggie_design_lint import main as lint_main
1017
1024
  return lint_main(sys.argv[1:])
1018
1025
  if len(sys.argv) > 1 and sys.argv[1] == "verify":
@@ -7,7 +7,10 @@ pressable in JSX/TSX. React Native Web renders ``Pressable`` and the
7
7
  remove control becomes ``<button>`` inside ``<button>``: invalid DOM, and taps
8
8
  land on the wrong control.
9
9
 
10
- ``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities.
10
+ ``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities,
11
+ and warns about ``width: max-content`` (or ``w-max``) strips that can push a
12
+ 390 px page sideways. ``textinput-check`` flags React Native Web multiline
13
+ inputs without a height and grow-only auto-grow.
11
14
  Tailwind v4 emits utilities inside ``@layer utilities``; any unlayered rule
12
15
  wins over every layer regardless of specificity, so an unlayered
13
16
  ``h1 { font-family }`` beats ``font-*`` and ``.ph { display: inline-block }``
@@ -239,12 +242,41 @@ def _rule_findings(source: str, start: int, end: int, layered: bool, display: st
239
242
  index = close + 1
240
243
 
241
244
 
245
+ MAX_CONTENT_RULE = re.compile(r"([^{}]+)\{([^{}]*\b(?:min-)?width\s*:\s*max-content[^{}]*)\}", re.I)
246
+ MAX_CONTENT_CLASS = re.compile(r"class(?:Name)?\s*=\s*[\"'`{]([^\"'`}]*\bw-max\b[^\"'`}]*)", re.I)
247
+ OVERFLOW_GUARD = re.compile(r"\b(?:max-width|overflow(?:-x)?)\s*:", re.I)
248
+
249
+
250
+ def overflow_findings(project: Path, css_sources: dict[Path, str], markup: list[Path]) -> list[dict[str, object]]:
251
+ """width: max-content on a tab strip or toolbar makes a phone page scroll sideways unless it can shrink or scroll."""
252
+ findings: list[dict[str, object]] = []
253
+ for path, source in css_sources.items():
254
+ for match in MAX_CONTENT_RULE.finditer(source):
255
+ body = match.group(2)
256
+ if OVERFLOW_GUARD.search(body):
257
+ continue
258
+ selector = " ".join(match.group(1).split())[-80:]
259
+ findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start(2)) + 1, "selector": selector, "kind": "max-content", "severity": "warning",
260
+ "message": "width: max-content without max-width/overflow can push a 390 px page sideways; give the strip overflow-x: auto inside a min-width: 0 parent, or max-width: 100%"})
261
+ for path in markup:
262
+ source = path.read_text(encoding="utf-8", errors="replace")
263
+ for match in MAX_CONTENT_CLASS.finditer(source):
264
+ classes = match.group(1)
265
+ if re.search(r"\b(?:overflow-x-auto|overflow-auto|overflow-x-scroll|max-w-[\w\[\]%-]+)\b", classes):
266
+ continue
267
+ findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start()) + 1, "selector": classes.strip()[:80], "kind": "max-content", "severity": "warning",
268
+ "message": "w-max without overflow-x-auto or a max-w-* can make the page scroll sideways at 390 px"})
269
+ return findings
270
+
271
+
242
272
  def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, object]:
243
273
  files = iter_files(paths or [project], (".css", ".pcss", ".postcss"))
244
274
  sources = {path.resolve(): _strip_css_comments(path.read_text(encoding="utf-8", errors="replace")) for path in files}
275
+ markup = iter_files(paths or [project], (".tsx", ".jsx", ".astro", ".vue", ".svelte", ".html"))
276
+ overflow = overflow_findings(project, sources, markup)
245
277
  tailwind_v4 = force_v4 or any(TAILWIND_V4_IMPORT.search(source) for source in sources.values())
246
278
  if not tailwind_v4:
247
- return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": []}
279
+ return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": overflow, "warnings": len(overflow)}
248
280
  layered_files: set[Path] = set()
249
281
  for path, source in sources.items():
250
282
  for target in LAYERED_IMPORT.findall(source):
@@ -252,6 +284,7 @@ def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, o
252
284
  findings: list[dict[str, object]] = []
253
285
  for path, source in sources.items():
254
286
  _rule_findings(source, 0, len(source), path in layered_files, _display(path, project), findings)
287
+ findings += overflow
255
288
  errors = [item for item in findings if item["severity"] == "error"]
256
289
  return {"schemaVersion": "maggie-design-layer-check.v1", "passed": not errors, "status": "checked", "checkedFiles": len(files), "errors": len(errors), "warnings": len(findings) - len(errors), "findings": findings}
257
290
 
@@ -311,6 +344,40 @@ def safe_area_check(project: Path, paths: list[Path]) -> dict[str, object]:
311
344
  "recommendation": "Wrap the app once in SafeAreaProvider; in modals read const insets = useSafeAreaInsets() and pad the header/footer. Verify on a notched iPhone (or simulator) — web screenshots cannot show the inset."}
312
345
 
313
346
 
347
+ TEXTINPUT_TAG = re.compile(r"<TextInput\b(?:[^<>]|=>|\{[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[^{}]*)*\})*?/?>", re.S)
348
+ TEXTINPUT_ALLOW = "maggie-allow-textinput"
349
+
350
+
351
+ def textinput_check(project: Path, paths: list[Path]) -> dict[str, object]:
352
+ """React Native Web renders a multiline TextInput as a 2-row textarea; flag inputs without a height and grow-only auto-grow."""
353
+ project = project.resolve()
354
+ files = iter_files(paths or [project], (".tsx", ".jsx"))
355
+ findings: list[dict[str, object]] = []
356
+ for path in files:
357
+ raw = path.read_text(encoding="utf-8", errors="replace")
358
+ if "TextInput" not in raw or TEXTINPUT_ALLOW in raw:
359
+ continue
360
+ source = _strip_jsx_comments(raw)
361
+ display = _display(path, project)
362
+ for tag in TEXTINPUT_TAG.finditer(source):
363
+ text = tag.group(0)
364
+ if not re.search(r"\bmultiline\b(?!\s*=\s*\{\s*false\s*\})", text):
365
+ continue
366
+ line = _line(source, tag.start())
367
+ sized = re.search(r"\bnumberOfLines\s*=|\brows\s*=|\b(?:height|minHeight)\s*:", text)
368
+ style_ref = re.search(r"\bstyle\s*=\s*\{\s*(?:\[)?\s*(?:styles|s)\.(\w+)", text)
369
+ if not sized and style_ref and re.search(rf"\b{style_ref.group(1)}\s*:\s*\{{[^{{}}]*\b(?:height|minHeight)\s*:", source):
370
+ sized = True
371
+ if not sized:
372
+ findings.append({"file": display, "line": line, "kind": "multiline-without-height", "severity": "error",
373
+ "message": "multiline TextInput without numberOfLines or an explicit height renders a 2-row textarea on web (placeholder high, buttons misaligned); set one line of height and grow by measuring"})
374
+ if "onContentSizeChange" in text and "scrollHeight" not in source:
375
+ findings.append({"file": display, "line": line, "kind": "grow-only-autogrow", "severity": "warning",
376
+ "message": "onContentSizeChange never reports a smaller size on web, so this composer grows but never shrinks; on web collapse to the minimum, read scrollHeight, then set the height"})
377
+ errors = [item for item in findings if item["severity"] == "error"]
378
+ return {"schemaVersion": "maggie-design-textinput-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings}
379
+
380
+
314
381
  def main(argv: list[str] | None = None) -> int:
315
382
  parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
316
383
  sub = parser.add_subparsers(dest="command", required=True)
@@ -325,9 +392,14 @@ def main(argv: list[str] | None = None) -> int:
325
392
  safe = sub.add_parser("safe-area-check", help="flag SafeAreaView inside Expo Router modal routes and React Native Modal (.tsx/.jsx)")
326
393
  safe.add_argument("--project", type=Path, default=Path.cwd())
327
394
  safe.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
395
+ textinput = sub.add_parser("textinput-check", help="flag multiline TextInput without a height and grow-only auto-grow on React Native Web (.tsx/.jsx)")
396
+ textinput.add_argument("--project", type=Path, default=Path.cwd())
397
+ textinput.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
328
398
  args = parser.parse_args(argv)
329
399
  try:
330
- if args.command == "safe-area-check":
400
+ if args.command == "textinput-check":
401
+ report = textinput_check(args.project, args.path)
402
+ elif args.command == "safe-area-check":
331
403
  report = safe_area_check(args.project, args.path)
332
404
  elif args.command == "pressable-check":
333
405
  report = pressable_check(args.project, args.path, args.pressable)