@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.
- package/README-zh-TW.md +48 -2
- package/README.md +66 -54
- 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 +17 -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 +36 -6
- 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 +9 -2
- package/bundled-tools/clis/maggie_design_lint.py +75 -3
- package/bundled-tools/clis/maggie_feedback.py +97 -15
- package/bundled-tools/clis/maggie_mobile.py +136 -4
- 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
|
@@ -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 == "
|
|
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)
|