@topy-ai/maggie 0.7.52 → 0.7.54

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 (79) hide show
  1. package/README-zh-TW.md +28 -4
  2. package/README.md +93 -60
  3. package/bin/maggie.js +67 -12
  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/QA-TEST-PLAN.md +1 -1
  12. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  13. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  14. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  15. package/bundled-references/provider-adapter-contract.md +16 -3
  16. package/bundled-skills/catalog.json +1 -1
  17. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  18. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  19. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  20. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  21. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  23. package/bundled-skills/maggie-booking/SKILL.md +66 -14
  24. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  25. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  26. package/bundled-skills/maggie-deployment/SKILL.md +48 -0
  27. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  28. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  29. package/bundled-skills/maggie-design/SKILL.md +98 -3
  30. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  31. package/bundled-skills/maggie-feedback/SKILL.md +27 -2
  32. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  33. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  34. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  35. package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
  36. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  38. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  39. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  40. package/bundled-skills/maggie-social-share/SKILL.md +56 -4
  41. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  42. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  43. package/bundled-tools/clis/maggie_booking.py +24 -12
  44. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  45. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  46. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  47. package/bundled-tools/clis/maggie_dash.py +173 -3
  48. package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
  49. package/bundled-tools/clis/maggie_design.py +15 -2
  50. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  51. package/bundled-tools/clis/maggie_design_lint.py +205 -3
  52. package/bundled-tools/clis/maggie_feedback.py +138 -28
  53. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  54. package/bundled-tools/clis/maggie_localization.py +3 -0
  55. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  56. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  57. package/bundled-tools/clis/maggie_mobile.py +219 -4
  58. package/bundled-tools/clis/maggie_product_release.py +49 -1
  59. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  60. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  61. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  62. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  63. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  64. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  65. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  66. package/bundled-tools/clis/maggie_social_share.py +174 -7
  67. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  68. package/bundled-tools/runtime/assistant_approval.py +98 -0
  69. package/package.json +1 -1
  70. package/references/provider-adapter-contract.md +16 -3
  71. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  72. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  73. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  74. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  75. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  76. package/bundled-references/maggiedash-booking/README.md +0 -288
  77. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  78. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  79. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -0,0 +1,524 @@
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
+ - ``preview-auth-check``: before (or after) putting basic auth on a preview
24
+ host, find every workflow, script or webhook config that still sends
25
+ requests to it. Each must be repointed to production or carry credentials;
26
+ otherwise it fails on every push (a skills sync kept posting to a protected
27
+ preview for days and left a public page two versions behind).
28
+
29
+ Values of secrets are never printed; only variable names and digests.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import hashlib
36
+ import json
37
+ import os
38
+ import re
39
+ import shlex
40
+ import subprocess
41
+ import sys
42
+ import time
43
+ from datetime import datetime, timezone
44
+ from pathlib import Path
45
+ from urllib.error import HTTPError, URLError
46
+ from urllib.parse import quote
47
+ from urllib.request import Request, urlopen
48
+
49
+ CF_REQUIRED = ("CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID")
50
+ CF_NEAR_MISS = re.compile(r"^(?:CF_|CLOUDFLARE_)(?:API_)?(?:TOKEN|KEY|ACCOUNT(?:_ID)?|ACCOUNTID)$")
51
+ OAUTH_CACHES = ("~/.config/.wrangler/config/default.toml", "~/.wrangler/config/default.toml", "~/Library/Preferences/.wrangler/config/default.toml")
52
+ SAFE_REF = re.compile(r"^[A-Za-z0-9._/@{}~^:-]{1,200}$")
53
+ SAFE_PATH = re.compile(r"^[A-Za-z0-9._/@+-][A-Za-z0-9._/@+ -]{0,300}$")
54
+
55
+
56
+ def _env_file(path: Path | None) -> dict[str, str]:
57
+ values: dict[str, str] = {}
58
+ if not path or not path.is_file():
59
+ return values
60
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
61
+ line = line.strip()
62
+ if not line or line.startswith("#") or "=" not in line:
63
+ continue
64
+ key, value = line.removeprefix("export ").split("=", 1)
65
+ values[key.strip()] = value.strip().strip("\"'")
66
+ return values
67
+
68
+
69
+ def _strip_jsonc(text: str) -> str:
70
+ out, i, in_string = [], 0, False
71
+ while i < len(text):
72
+ char = text[i]
73
+ if in_string:
74
+ out.append(char)
75
+ if char == "\\" and i + 1 < len(text):
76
+ out.append(text[i + 1]); i += 2; continue
77
+ if char == '"':
78
+ in_string = False
79
+ elif char == '"':
80
+ in_string = True; out.append(char)
81
+ elif text.startswith("//", i):
82
+ while i < len(text) and text[i] != "\n":
83
+ i += 1
84
+ continue
85
+ elif text.startswith("/*", i):
86
+ end = text.find("*/", i + 2)
87
+ i = len(text) if end < 0 else end + 2
88
+ continue
89
+ else:
90
+ out.append(char)
91
+ i += 1
92
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
93
+
94
+
95
+ def wrangler_config(project: Path, config: Path | None, env_name: str | None) -> dict:
96
+ """account_id and route patterns/zones from wrangler.json(c) or wrangler.toml (minimal TOML reading)."""
97
+ candidates = [config] if config else [project / name for name in ("wrangler.jsonc", "wrangler.json", "wrangler.toml")]
98
+ path = next((p for p in candidates if p and p.is_file()), None)
99
+ if path is None:
100
+ raise ValueError("no wrangler.jsonc / wrangler.json / wrangler.toml found; pass --config")
101
+ text = path.read_text(encoding="utf-8")
102
+ routes: list[dict] = []
103
+ if path.suffix in {".json", ".jsonc"}:
104
+ data = json.loads(_strip_jsonc(text))
105
+ section = data.get("env", {}).get(env_name, {}) if env_name else data
106
+ account = section.get("account_id") or data.get("account_id")
107
+ raw = section.get("routes") or ([section["route"]] if section.get("route") else [])
108
+ for route in raw:
109
+ routes.append(route if isinstance(route, dict) else {"pattern": route})
110
+ else:
111
+ account_match = re.search(r'^\s*account_id\s*=\s*"([^"]+)"', text, re.M)
112
+ account = account_match.group(1) if account_match else None
113
+ for match in re.finditer(r'\{\s*pattern\s*=\s*"([^"]+)"([^}]*)\}', text):
114
+ extra = match.group(2)
115
+ zone = re.search(r'zone_name\s*=\s*"([^"]+)"', extra)
116
+ routes.append({"pattern": match.group(1), "custom_domain": "custom_domain = true" in extra.replace(" ", " "), **({"zone_name": zone.group(1)} if zone else {})})
117
+ for match in re.finditer(r'^\s*route\s*=\s*"([^"]+)"', text, re.M):
118
+ routes.append({"pattern": match.group(1)})
119
+ for route in routes:
120
+ if not route.get("zone_name"):
121
+ host = str(route.get("pattern", "")).split("/")[0].lstrip("*.")
122
+ parts = host.split(".")
123
+ route["zone_guess"] = ".".join(parts[-2:]) if len(parts) >= 2 else host
124
+ return {"path": path.name, "accountId": account, "routes": routes}
125
+
126
+
127
+ def _cf_get(token: str, path: str) -> dict:
128
+ request = Request("https://api.cloudflare.com/client/v4" + path, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
129
+ try:
130
+ with urlopen(request, timeout=20) as response: # noqa: S310 - fixed Cloudflare API host
131
+ return json.loads(response.read())
132
+ except HTTPError as error:
133
+ return {"success": False, "status": error.code}
134
+ except (URLError, OSError, ValueError) as error:
135
+ return {"success": False, "error": str(error)[:120]}
136
+
137
+
138
+ 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:
139
+ environ = dict(os.environ if environ is None else environ)
140
+ file_values = _env_file(env_file)
141
+ errors: list[str] = []
142
+ warnings: list[str] = []
143
+ resolved = {}
144
+ for name in CF_REQUIRED:
145
+ value = (environ.get(name) or "").strip()
146
+ source = "environment" if value else None
147
+ if not value and file_values.get(name, "").strip():
148
+ value, source = file_values[name].strip(), "env-file"
149
+ resolved[name] = value
150
+ if not value:
151
+ errors.append(f"{name} is empty: wrangler would fall back to its cached OAuth login (maybe another account) — set it before deploying")
152
+ else:
153
+ resolved[name + ":source"] = source
154
+ 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)))
155
+ if near and any(not resolved[name] for name in CF_REQUIRED):
156
+ warnings.append("found " + ", ".join(near) + " but wrangler reads " + " and ".join(CF_REQUIRED) + "; the deploy script may read the wrong names")
157
+ home = home or Path.home()
158
+ caches = [path for path in OAUTH_CACHES if (home / path.removeprefix("~/")).is_file()]
159
+ if caches:
160
+ 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")
161
+ cfg = wrangler_config(project, config, env_name)
162
+ account = resolved.get("CLOUDFLARE_ACCOUNT_ID")
163
+ if not cfg["accountId"]:
164
+ warnings.append(f"{cfg['path']} has no account_id; set it so wrangler cannot deploy into another account")
165
+ elif account and cfg["accountId"] != account:
166
+ errors.append(f"{cfg['path']} account_id differs from CLOUDFLARE_ACCOUNT_ID; the deploy would target another account")
167
+ 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")})
168
+ zone_checks = []
169
+ if verify_zone and not errors:
170
+ token = resolved["CLOUDFLARE_API_TOKEN"]
171
+ verify = api(token, "/user/tokens/verify")
172
+ if not verify.get("success"):
173
+ errors.append("CLOUDFLARE_API_TOKEN did not verify (expired, revoked or a different token type)")
174
+ for zone in zones:
175
+ found = api(token, f"/zones?name={quote(zone)}&account.id={quote(account or '')}")
176
+ owned = bool(found.get("success") and found.get("result"))
177
+ zone_checks.append({"zone": zone, "ownedByAccount": owned})
178
+ if not owned:
179
+ errors.append(f"zone {zone} is not in account {account[-6:] if account else '?'} for this token: routes on it fail with code 10082")
180
+ elif zones:
181
+ warnings.append("zone ownership not verified; add --verify-zone (read-only API calls) before the first deploy to a domain")
182
+ return {
183
+ "schemaVersion": "maggie-cloudflare-preflight.v1",
184
+ "config": cfg["path"],
185
+ "variables": {name: bool(resolved[name]) for name in CF_REQUIRED},
186
+ "sources": {name: resolved.get(name + ":source") for name in CF_REQUIRED},
187
+ "routes": [route.get("pattern") for route in cfg["routes"]],
188
+ "zones": zone_checks or [{"zone": zone, "ownedByAccount": None} for zone in zones],
189
+ "status": "failed" if errors else ("warning" if warnings else "passed"),
190
+ "errors": errors,
191
+ "warnings": warnings,
192
+ }
193
+
194
+
195
+ # --- hosts --------------------------------------------------------------------------
196
+
197
+ def run_on(host: str, script: str, ssh_args: list[str], stdin: bytes | None = None, timeout: int = 1800) -> subprocess.CompletedProcess:
198
+ """Run a bash script on a host over ssh stdin ("local" runs it here, for tests and single-host setups)."""
199
+ if host == "local":
200
+ return subprocess.run(["bash", "-c", script], input=stdin, capture_output=True, timeout=timeout, check=False)
201
+ payload = script.encode() if stdin is None else stdin
202
+ command = ["ssh", "-o", "BatchMode=yes", *ssh_args, host]
203
+ command += ["bash -s"] if stdin is None else ["bash -c " + shlex.quote(script)]
204
+ return subprocess.run(command, input=payload, capture_output=True, timeout=timeout, check=False)
205
+
206
+
207
+ def file_parity(hosts: list[str], root: str, paths: list[str], repo: Path, ref: str, subdir: str, ssh_args: list[str]) -> dict:
208
+ if not SAFE_REF.match(ref) or ref.startswith("-"):
209
+ raise ValueError("--ref must be a git revision")
210
+ for path in paths:
211
+ if not SAFE_PATH.match(path) or ".." in path.split("/"):
212
+ raise ValueError(f"unsafe path {path!r}")
213
+ expected = {}
214
+ for path in paths:
215
+ blob = subprocess.run(["git", "-C", str(repo), "show", f"{ref}:{(subdir.strip('/') + '/' if subdir else '')}{path}"], capture_output=True, check=False)
216
+ expected[path] = hashlib.sha256(blob.stdout).hexdigest() if blob.returncode == 0 else None
217
+ script = "set -u\ncd " + shlex.quote(root) + " || exit 3\n" + "".join(
218
+ 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)
219
+ observed: dict[str, dict[str, str | None]] = {}
220
+ errors: list[str] = []
221
+ for host in hosts:
222
+ result = run_on(host, script, ssh_args, timeout=120)
223
+ if result.returncode:
224
+ errors.append(f"{host}: could not read {root} (exit {result.returncode})")
225
+ continue
226
+ observed[host] = {}
227
+ for line in result.stdout.decode().splitlines():
228
+ digest, _, path = line.partition(" ")
229
+ observed[host][path] = None if digest == "MISSING" else digest
230
+ rows = []
231
+ for path in paths:
232
+ row = {"path": path, "expected": (expected[path] or "")[:12] or None, "hosts": {}}
233
+ digests = set()
234
+ for host, files in observed.items():
235
+ digest = files.get(path)
236
+ digests.add(digest)
237
+ state = "missing" if digest is None else ("match" if digest == expected[path] else "differs")
238
+ row["hosts"][host] = state
239
+ if expected[path] is None:
240
+ row["hosts"][host] = "new-file" if digest is None else "unexpected"
241
+ if state == "differs" and expected[path]:
242
+ errors.append(f"{host}: {path} differs from {ref}; someone changed it on the host — copying over it would hide that")
243
+ if state == "missing" and expected[path]:
244
+ errors.append(f"{host}: {path} is missing although it exists at {ref}")
245
+ if len(digests) > 1:
246
+ errors.append(f"{path}: hosts disagree with each other")
247
+ rows.append(row)
248
+ return {"schemaVersion": "maggie-file-parity.v1", "ref": ref, "root": root, "files": rows, "passed": not errors, "errors": sorted(set(errors))}
249
+
250
+
251
+ def timed_get(url: str, timeout: float = 30.0) -> tuple[int, float]:
252
+ started = time.monotonic()
253
+ try:
254
+ with urlopen(Request(url, headers={"User-Agent": "maggie-warm-check", "Cache-Control": "no-cache"}), timeout=timeout) as response: # noqa: S310
255
+ response.read(65536)
256
+ status = response.status
257
+ except HTTPError as error:
258
+ status = error.code
259
+ except (URLError, OSError):
260
+ status = 0
261
+ return status, round((time.monotonic() - started) * 1000, 1)
262
+
263
+
264
+ def warm_check(urls: list[str], warm: int, samples: int, budget_ms: float, fetch=timed_get) -> dict:
265
+ """Warm each URL (one request per worker you expect), then judge latency on later samples only."""
266
+ results = []
267
+ errors = []
268
+ for url in urls:
269
+ warmups = [fetch(url) for _ in range(warm)]
270
+ measured = [fetch(url) for _ in range(samples)]
271
+ times = sorted(ms for status, ms in measured if 200 <= status < 400)
272
+ statuses = sorted({status for status, _ in measured})
273
+ median = times[len(times) // 2] if times else None
274
+ row = {"url": url, "warmupMs": [ms for _, ms in warmups], "statuses": statuses, "medianMs": median, "maxMs": times[-1] if times else None}
275
+ if not times or any(not 200 <= s < 400 for s in statuses):
276
+ errors.append(f"{url}: status {statuses} after warm-up")
277
+ elif budget_ms and median > budget_ms:
278
+ errors.append(f"{url}: median {median} ms after {warm} warm-up request(s), budget {budget_ms} ms")
279
+ results.append(row)
280
+ return {"schemaVersion": "maggie-warm-check.v1", "warm": warm, "samples": samples, "results": results, "passed": not errors, "errors": errors}
281
+
282
+
283
+ # --- vps release -----------------------------------------------------------------------
284
+
285
+ REMOTE_RELEASE = r'''set -euo pipefail
286
+ RELEASE_ROOT=__ROOT__
287
+ RELEASE_NAME=__NAME__
288
+ ENV_FILE=__ENV__
289
+ REL="$RELEASE_ROOT/releases/$RELEASE_NAME"
290
+ CURRENT="$RELEASE_ROOT/current"
291
+ KEEP=__KEEP__
292
+ say() { printf '{"step":"%s","detail":"%s"}\n' "$1" "${2:-}"; }
293
+ [ "$(id -u)" != 0 ] || { say refused "do not release as root: builds as root leave root-owned files the prune cannot remove"; exit 4; }
294
+ [ -d "$REL" ] || { say refused "release folder missing"; exit 4; }
295
+ [ -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; }
296
+ PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
297
+ cd "$REL"
298
+ ( set -a; . "$ENV_FILE"; set +a; __INSTALL__ ) >"$REL/.maggie-install.log" 2>&1 || { say failed "install (see $REL/.maggie-install.log)"; exit 5; }
299
+ say installed
300
+ ( set -a; . "$ENV_FILE"; set +a; __BUILD__ ) >"$REL/.maggie-build.log" 2>&1 || { say failed "build (see $REL/.maggie-build.log)"; exit 5; }
301
+ say built
302
+ FOREIGN="$(find "$REL" ! -user "$(id -un)" -print -quit 2>/dev/null || true)"
303
+ [ -z "$FOREIGN" ] || { say failed "files not owned by $(id -un) appeared in the release ($FOREIGN)"; exit 5; }
304
+ ln -sfn "$REL" "$RELEASE_ROOT/.current.tmp" && mv -T "$RELEASE_ROOT/.current.tmp" "$CURRENT"
305
+ say switched "$RELEASE_NAME"
306
+ __RESTART__ || { say failed "restart; current points at the new release, roll back with: ln -sfn $PREVIOUS $CURRENT"; exit 6; }
307
+ say restarted
308
+ __HEALTH__
309
+ pruned=0; blocked=""
310
+ for candidate in $(ls -1dt "$RELEASE_ROOT"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP + 1))); do
311
+ [ "$candidate" = "$REL" ] && continue
312
+ [ "$KEEP" -ge 2 ] && [ "$candidate" = "$PREVIOUS" ] && continue
313
+ if [ -n "$(find "$candidate" ! -user "$(id -un)" -print -quit 2>/dev/null)" ]; then blocked="$blocked $(basename "$candidate")"; continue; fi
314
+ rm -rf -- "$candidate"; pruned=$((pruned + 1))
315
+ done
316
+ say pruned "$pruned"
317
+ [ -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>"
318
+ '''
319
+
320
+ HEALTH_SNIPPET = r'''for url in __URLS__; do
321
+ ok=""
322
+ for attempt in $(seq 1 30); do
323
+ status="$(curl -s -o /dev/null -w '%{http_code}' "$url" || printf 000)"
324
+ case "$status" in 2??|3??) ok=1; break ;; 000|502|503|504) sleep 2 ;; *) break ;; esac
325
+ done
326
+ [ -n "$ok" ] || { say failed "health $url returned $status; roll back with: ln -sfn $PREVIOUS $CURRENT and restart"; exit 7; }
327
+ for warm in $(seq 1 __WARM__); do curl -s -o /dev/null "$url" || true; done
328
+ ms="$(curl -s -o /dev/null -w '%{time_total}' "$url" || printf 0)"
329
+ say healthy "$url ${ms}s after __WARM__ warm-up request(s)"
330
+ done
331
+ '''
332
+
333
+
334
+ def release_name(repo: Path, ref: str, now: datetime | None = None) -> tuple[str, str]:
335
+ sha = subprocess.run(["git", "-C", str(repo), "rev-parse", "--short=10", f"{ref}^{{commit}}"], capture_output=True, text=True, check=False)
336
+ if sha.returncode:
337
+ raise ValueError(f"{ref} is not a commit in {repo}")
338
+ stamp = (now or datetime.now(timezone.utc)).strftime("%Y%m%dT%H%M%SZ")
339
+ return f"{stamp}-{sha.stdout.strip()}", sha.stdout.strip()
340
+
341
+
342
+ 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:
343
+ 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'
344
+ return (REMOTE_RELEASE.replace("__ROOT__", shlex.quote(root)).replace("__NAME__", shlex.quote(name)).replace("__ENV__", shlex.quote(env_file))
345
+ .replace("__KEEP__", str(int(keep))).replace("__INSTALL__", install).replace("__BUILD__", build).replace("__RESTART__", restart).replace("__HEALTH__", health))
346
+
347
+
348
+ def vps_release(args: argparse.Namespace, ssh_args: list[str]) -> dict:
349
+ repo = Path(args.repo).resolve()
350
+ if not SAFE_REF.match(args.ref) or args.ref.startswith("-"):
351
+ raise ValueError("--ref must be a git revision")
352
+ if not 1 <= args.keep <= 5:
353
+ raise ValueError("--keep must be 1-5")
354
+ name, sha = release_name(repo, args.ref)
355
+ restart = args.restart or f"sudo -n systemctl restart {shlex.quote(args.service)}"
356
+ script = remote_release_script(args.release_root, name, args.env_file, args.keep, args.install, args.build, restart, args.health_url, args.warm)
357
+ tree = f"{args.ref}:{args.subdir.strip('/')}" if args.subdir else args.ref
358
+ plan = {
359
+ "schemaVersion": "maggie-vps-release.v1",
360
+ "host": args.host, "release": name, "commit": sha, "releaseRoot": args.release_root,
361
+ "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}",
362
+ "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)"],
363
+ "executed": False,
364
+ }
365
+ if not args.confirm:
366
+ plan["remoteScript"] = script
367
+ plan["note"] = "dry run: nothing was uploaded; re-run with --confirm"
368
+ return plan
369
+ archive = subprocess.run(["git", "-C", str(repo), "archive", "--format=tar", tree], capture_output=True, check=False)
370
+ if archive.returncode:
371
+ raise ValueError(f"git archive {tree} failed")
372
+ target = f"{args.release_root.rstrip('/')}/releases/{name}"
373
+ unpack = f"set -e; test ! -e {shlex.quote(target)}; mkdir -p {shlex.quote(target)}; tar -x -C {shlex.quote(target)}"
374
+ uploaded = run_on(args.host, unpack, ssh_args, stdin=archive.stdout, timeout=600)
375
+ if uploaded.returncode:
376
+ raise ValueError(f"upload failed (exit {uploaded.returncode}): {uploaded.stderr.decode(errors='replace')[-300:]}")
377
+ result = run_on(args.host, script, ssh_args, timeout=args.timeout)
378
+ steps = []
379
+ for line in result.stdout.decode(errors="replace").splitlines():
380
+ try:
381
+ steps.append(json.loads(line))
382
+ except json.JSONDecodeError:
383
+ continue
384
+ plan.update(executed=True, exitCode=result.returncode, log=steps, status="released" if result.returncode == 0 else "failed")
385
+ if result.returncode == 0:
386
+ health = "; ".join(step["detail"] for step in steps if step["step"] == "healthy")
387
+ 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')}"
388
+ else:
389
+ plan["stderr"] = result.stderr.decode(errors="replace")[-500:]
390
+ return plan
391
+
392
+
393
+ SCAN_SUFFIXES = {".yml", ".yaml", ".json", ".jsonc", ".toml", ".sh", ".mjs", ".js", ".cjs", ".ts", ".py", ".env", ".example"}
394
+ SCAN_SKIP = {"node_modules", ".git", "dist", "build", ".next", ".astro", ".wrangler", ".turbo", ".maggie", "coverage"}
395
+ SENDS = re.compile(r"\b(?:curl|wget|fetch|axios|requests\.(?:post|put|patch|get)|httpx|Invoke-WebRequest|http\.(?:post|request))\b|-X\s*(?:POST|PUT|PATCH)|\bmethod\s*[:=]\s*[\"']?(?:POST|PUT|PATCH)\b|\b(?:webhook|callback|notify)_?url\b", re.I)
396
+ # Only basic-auth credentials get through a basic-auth gate; a bearer token does not.
397
+ BASIC_CREDENTIALS = re.compile(r"(?:\s-u\s|--user\b|authorization\s*[:=]\s*[\"']?basic\b|\bbasic\s+\$|PREVIEW_(?:USER|PASS|PASSWORD|AUTH|BASIC)|BASIC_AUTH|https?://\$\{?\w+\}?:\$)", re.I)
398
+ ASSIGN = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*[:=]")
399
+
400
+
401
+ def preview_auth_check(repo: Path, host: str, live: bool) -> dict:
402
+ """Workflows and scripts that send requests to a basic-auth protected preview host."""
403
+ host = host.lower().removeprefix("https://").removeprefix("http://").strip("/")
404
+ pattern = re.compile(rf"https?://{re.escape(host)}(?:[/:\s\"'`]|$)", re.I)
405
+ findings = []
406
+ for path in sorted(repo.rglob("*")):
407
+ if not path.is_file() or any(part in SCAN_SKIP for part in path.relative_to(repo).parts) or (path.suffix not in SCAN_SUFFIXES and path.name != "Dockerfile"):
408
+ continue
409
+ try:
410
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
411
+ except OSError:
412
+ continue
413
+ for number, line in enumerate(lines, 1):
414
+ if not pattern.search(line):
415
+ continue
416
+ window = "\n".join(lines[max(0, number - 6):number + 5])
417
+ uses = [number]
418
+ assigned = ASSIGN.match(line)
419
+ if assigned:
420
+ # The host is put in a variable (SITE: https://preview...); follow the variable.
421
+ name = re.escape(assigned.group(1))
422
+ uses = [index for index, other in enumerate(lines, 1) if index != number and re.search(rf"\$\{{?{name}\b|\benv\.{name}\b|process\.env\.{name}\b|os\.environ\S*{name}", other)]
423
+ window = "\n".join("\n".join(lines[max(0, index - 4):index + 3]) for index in uses)
424
+ if not SENDS.search(window):
425
+ continue
426
+ authed = bool(BASIC_CREDENTIALS.search(window))
427
+ findings.append({"file": str(path.relative_to(repo)), "line": number, "requestLines": uses[:5], "credentials": authed, "severity": "ok" if authed else "error",
428
+ "fix": None if authed else "repoint this to production, or send the preview's basic-auth credentials from a secret (a bearer token does not pass basic auth)"})
429
+ probe = None
430
+ if live:
431
+ try:
432
+ with urlopen(Request(f"https://{host}/", method="GET", headers={"User-Agent": "MaggieDeployment/1.0"}), timeout=15) as response:
433
+ probe = {"status": response.status, "basicAuth": False}
434
+ except HTTPError as error:
435
+ probe = {"status": error.code, "basicAuth": error.code == 401 and "basic" in (error.headers.get("WWW-Authenticate") or "").lower()}
436
+ except (URLError, TimeoutError, OSError) as error:
437
+ probe = {"status": None, "error": type(error).__name__}
438
+ errors = [item for item in findings if item["severity"] == "error"]
439
+ return {"schemaVersion": "maggie-deployment-preview-auth.v1", "host": host, "passed": not errors, "senders": findings, "unauthenticated": len(errors), "live": probe,
440
+ "note": "Every sender above must keep working once the preview asks for a password. Check the workflow runs after the change, and the public surfaces they feed."}
441
+
442
+
443
+ def main(argv: list[str] | None = None) -> int:
444
+ parser = argparse.ArgumentParser(prog="maggie deployment", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
445
+ sub = parser.add_subparsers(dest="command", required=True)
446
+ cf = sub.add_parser("cloudflare-preflight")
447
+ cf.add_argument("--project", type=Path, default=Path("."))
448
+ cf.add_argument("--config", type=Path)
449
+ cf.add_argument("--env", dest="env_name", help="wrangler environment, e.g. production")
450
+ cf.add_argument("--env-file", type=Path, help="file the deploy script loads (names only are reported)")
451
+ cf.add_argument("--verify-zone", action="store_true", help="read-only Cloudflare API calls: token verify + zone lookup in the account")
452
+ ssh_parent = argparse.ArgumentParser(add_help=False)
453
+ ssh_parent.add_argument("--ssh-key")
454
+ ssh_parent.add_argument("--ssh-port", default="22")
455
+ fp = sub.add_parser("file-parity", parents=[ssh_parent])
456
+ fp.add_argument("--host", action="append", required=True, help="ssh target; repeat per host (\"local\" = this machine)")
457
+ fp.add_argument("--root", required=True, help="deployed folder on the hosts, e.g. /srv/api/current")
458
+ fp.add_argument("--path", action="append", required=True, help="file relative to --root that the deploy will copy")
459
+ fp.add_argument("--repo", default=".")
460
+ fp.add_argument("--subdir", default="", help="folder in the repo that maps to --root")
461
+ fp.add_argument("--ref", default="HEAD", help="commit currently deployed (the copies the hosts should hold)")
462
+ wc = sub.add_parser("warm-check")
463
+ wc.add_argument("--url", action="append", required=True)
464
+ wc.add_argument("--warm", type=int, default=3, help="warm-up requests per URL (one per worker process)")
465
+ wc.add_argument("--samples", type=int, default=5)
466
+ wc.add_argument("--budget-ms", type=float, default=0)
467
+ vr = sub.add_parser("vps-release", parents=[ssh_parent])
468
+ vr.add_argument("--host", required=True, help="deploy user's ssh target (never root)")
469
+ vr.add_argument("--release-root", required=True)
470
+ vr.add_argument("--service", required=True)
471
+ vr.add_argument("--env-file", required=True, help="server env file, readable by the deploy user")
472
+ vr.add_argument("--repo", default=".")
473
+ vr.add_argument("--subdir", default="", help="app folder inside the repo")
474
+ vr.add_argument("--ref", default="HEAD")
475
+ vr.add_argument("--install", default="npm ci")
476
+ vr.add_argument("--build", default="npm run build")
477
+ vr.add_argument("--restart", help="default: sudo -n systemctl restart SERVICE")
478
+ vr.add_argument("--health-url", action="append", default=[])
479
+ vr.add_argument("--warm", type=int, default=3)
480
+ vr.add_argument("--keep", type=int, default=2)
481
+ vr.add_argument("--timeout", type=int, default=1800)
482
+ vr.add_argument("--confirm", action="store_true")
483
+ pa = sub.add_parser("preview-auth-check", help="find workflows/webhooks that send requests to a basic-auth protected preview host")
484
+ pa.add_argument("--host", required=True, help="the preview hostname, e.g. preview.example.com")
485
+ pa.add_argument("--repo", type=Path, action="append", default=[], help="repository to scan (repeatable; default .)")
486
+ pa.add_argument("--live", action="store_true", help="also request the host and report whether it asks for basic auth")
487
+ for command in (cf, fp, wc, vr, pa):
488
+ command.add_argument("--output", type=Path)
489
+ args = parser.parse_args(argv)
490
+ ssh_args = []
491
+ if getattr(args, "ssh_key", None):
492
+ ssh_args += ["-i", os.path.expanduser(args.ssh_key)]
493
+ if getattr(args, "ssh_port", "22") != "22":
494
+ ssh_args += ["-p", args.ssh_port]
495
+ try:
496
+ if args.command == "cloudflare-preflight":
497
+ report = cloudflare_preflight(args.project.resolve(), args.config, args.env_name, args.env_file, args.verify_zone)
498
+ failed = report["status"] == "failed"
499
+ elif args.command == "file-parity":
500
+ report = file_parity(args.host, args.root, args.path, Path(args.repo).resolve(), args.ref, args.subdir, ssh_args)
501
+ failed = not report["passed"]
502
+ elif args.command == "preview-auth-check":
503
+ reports = [preview_auth_check(repo.resolve(), args.host, args.live) for repo in (args.repo or [Path(".")])]
504
+ report = reports[0] if len(reports) == 1 else {"passed": all(item["passed"] for item in reports), "repositories": reports}
505
+ failed = not report["passed"]
506
+ elif args.command == "warm-check":
507
+ report = warm_check(args.url, max(0, args.warm), max(1, args.samples), args.budget_ms)
508
+ failed = not report["passed"]
509
+ else:
510
+ report = vps_release(args, ssh_args)
511
+ failed = report.get("status") == "failed"
512
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
513
+ print(f"BLOCKED: maggie deployment {args.command}: {error}", file=sys.stderr)
514
+ return 2
515
+ text = json.dumps(report, indent=2, ensure_ascii=False)
516
+ if args.output:
517
+ args.output.parent.mkdir(parents=True, exist_ok=True)
518
+ args.output.write_text(text + "\n", encoding="utf-8")
519
+ print(text)
520
+ return 1 if failed else 0
521
+
522
+
523
+ if __name__ == "__main__":
524
+ 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
 
@@ -1001,10 +1007,13 @@ commands:
1001
1007
  status show a design job and its per-step progress
1002
1008
  resume restart a failed design workflow
1003
1009
  author plan an original first-party page
1010
+ bulk many similar pages from one brief, facts file and validator
1004
1011
  markup-check validate optional style attributes, content keys, and entities
1005
1012
  pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
1006
1013
  layer-check flag unlayered CSS that overrides Tailwind v4 utilities
1007
1014
  safe-area-check flag SafeAreaView inside iOS modal routes / RN Modal
1015
+ textinput-check flag multiline TextInput without a height / grow-only auto-grow (RN Web)
1016
+ sheet-check flag a sheet close followed in the same tick by a toast/refresh/navigation (iOS freeze)
1008
1017
  palette generate or validate a semantic palette role contract
1009
1018
  verify run automatable design-job steps and record evidence
1010
1019
  icon-glyph-check render icons and prove font load and glyph paint
@@ -1012,7 +1021,7 @@ commands:
1012
1021
  reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
1013
1022
  """)
1014
1023
  return 0
1015
- if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check"}:
1024
+ if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check", "textinput-check", "sheet-check"}:
1016
1025
  from maggie_design_lint import main as lint_main
1017
1026
  return lint_main(sys.argv[1:])
1018
1027
  if len(sys.argv) > 1 and sys.argv[1] == "verify":
@@ -1176,6 +1185,10 @@ commands:
1176
1185
  except (OSError, ValueError, json.JSONDecodeError) as error:
1177
1186
  print(f"BLOCKED: maggie-design section-validate: {error}", file=sys.stderr)
1178
1187
  return 1
1188
+ if len(sys.argv) > 1 and sys.argv[1] == "bulk":
1189
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
1190
+ from maggie_design_bulk import main as bulk_main
1191
+ return bulk_main(sys.argv[2:])
1179
1192
  if len(sys.argv) > 1 and sys.argv[1] == "author":
1180
1193
  author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
1181
1194
  author.add_argument("--project", type=Path, default=Path.cwd())