@topy-ai/maggie 0.7.50 → 0.7.52
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 +3 -3
- package/README.md +61 -8
- package/bin/maggie.js +17 -3
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +19 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +50 -0
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +29 -0
- package/bundled-contracts/maggie-design/mobile-app-evidence-v1.schema.json +54 -0
- package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +137 -16
- package/bundled-skills/catalog.json +8 -0
- package/bundled-skills/maggie-assistant-tools/SKILL.md +93 -0
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +54 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +56 -0
- package/bundled-skills/maggie-assistant-tools/references/product-release.md +57 -0
- package/bundled-skills/maggie-deployment/SKILL.md +13 -0
- package/bundled-skills/maggie-design/SKILL.md +68 -21
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +66 -0
- package/bundled-skills/maggie-feedback/SKILL.md +20 -3
- package/bundled-skills/maggie-marketplace/SKILL.md +28 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +139 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +30 -0
- package/bundled-tools/clis/maggie_app_collect.py +236 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +302 -0
- package/bundled-tools/clis/maggie_bridge_host.py +212 -0
- package/bundled-tools/clis/maggie_design.py +193 -30
- package/bundled-tools/clis/maggie_design_lint.py +68 -1
- package/bundled-tools/clis/maggie_feedback.py +89 -37
- package/bundled-tools/clis/maggie_mobile.py +235 -0
- package/bundled-tools/clis/maggie_product_release.py +303 -0
- package/bundled-tools/clis/maggie_qa_app_preflight.py +248 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +262 -0
- package/bundled-tools/runtime/assistant_approval.py +111 -0
- package/bundled-tools/runtime/cdp_client.py +246 -0
- package/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Readiness checks for a multi-tenant assistant bridge host (``maggie deployment bridge-*``).
|
|
3
|
+
|
|
4
|
+
A bridge host runs many users' assistant sessions (a coding/assistant CLI
|
|
5
|
+
plus MCP servers and skills) in one container on a VPS. The plan
|
|
6
|
+
(``maggie-bridge-host.v1``) declares isolation, network, capacity, recycling,
|
|
7
|
+
image retention and third-party account terms; three commands check it:
|
|
8
|
+
|
|
9
|
+
- ``bridge-plan``: validates the declared plan;
|
|
10
|
+
- ``bridge-collect-script``: prints a POSIX ``sh`` script that collects the
|
|
11
|
+
observed facts as JSON. Run it with ``ssh host 'sh -s' < collect.sh`` —
|
|
12
|
+
stdin avoids quoting JSON through ssh and zsh's lack of word splitting;
|
|
13
|
+
- ``bridge-check``: compares the observed JSON with the plan.
|
|
14
|
+
|
|
15
|
+
Lessons encoded here: tenant uids cannot reach their folders unless every
|
|
16
|
+
parent is ``0711`` (traverse, no listing), tenant folders are ``0700``, the
|
|
17
|
+
admin API listens on loopback only and is never proxied, idle sessions are
|
|
18
|
+
evicted LRU at a cap, sessions recycle when MCP, skills or the CLI update,
|
|
19
|
+
and images carry a label so only current + previous are kept, with a
|
|
20
|
+
health-checked switch that rolls back.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
import sys
|
|
30
|
+
from datetime import date, datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
SCHEMA = "maggie-bridge-host.v1"
|
|
34
|
+
SECRET_ENV = re.compile(r"TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE|CREDENTIAL|_KEY$", re.I)
|
|
35
|
+
LOOPBACK = re.compile(r"^(?:127\.0\.0\.1|\[::1\]|localhost):\d+$")
|
|
36
|
+
TERMS_MAX_AGE_DAYS = 180
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mode(value: object) -> int | None:
|
|
40
|
+
text = str(value or "").strip()
|
|
41
|
+
return int(text, 8) if re.fullmatch(r"0?[0-7]{3}", text) else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_plan(plan: dict, today: date | None = None) -> dict:
|
|
45
|
+
today = today or datetime.now(timezone.utc).date()
|
|
46
|
+
errors: list[str] = []
|
|
47
|
+
warnings: list[str] = []
|
|
48
|
+
if plan.get("schemaVersion") != SCHEMA:
|
|
49
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
50
|
+
if not str(plan.get("tenantsRoot", "")).startswith("/"):
|
|
51
|
+
errors.append("tenantsRoot must be an absolute path")
|
|
52
|
+
iso = plan.get("isolation", {}) if isinstance(plan.get("isolation"), dict) else {}
|
|
53
|
+
if iso.get("uidPerTenant") is not True:
|
|
54
|
+
errors.append("isolation.uidPerTenant must be true: one OS uid per tenant")
|
|
55
|
+
uid_range = iso.get("uidRange")
|
|
56
|
+
if not (isinstance(uid_range, list) and len(uid_range) == 2 and all(isinstance(v, int) for v in uid_range) and 10000 <= uid_range[0] < uid_range[1]):
|
|
57
|
+
errors.append("isolation.uidRange must be [first, last] at or above 10000 (clear of system and login users)")
|
|
58
|
+
if _mode(iso.get("tenantDirMode")) != 0o700:
|
|
59
|
+
errors.append("isolation.tenantDirMode must be 0700")
|
|
60
|
+
parent = _mode(iso.get("parentDirMode"))
|
|
61
|
+
if parent != 0o711:
|
|
62
|
+
errors.append("isolation.parentDirMode must be 0711: tenants traverse the parents without listing other tenants" + (" (0700 locks tenants out of their own folders)" if parent == 0o700 else ""))
|
|
63
|
+
if iso.get("cleanEnvironment") is not True:
|
|
64
|
+
errors.append("isolation.cleanEnvironment must be true: start each session with an allowlisted environment")
|
|
65
|
+
leaked = [name for name in iso.get("envAllowlist", []) or [] if SECRET_ENV.search(str(name))]
|
|
66
|
+
if leaked:
|
|
67
|
+
errors.append("isolation.envAllowlist passes secret-looking variables to tenant sessions: " + ", ".join(leaked))
|
|
68
|
+
if iso.get("ownerPinning") is not True:
|
|
69
|
+
errors.append("isolation.ownerPinning must be true: a session runs only for the tenant that owns it")
|
|
70
|
+
if iso.get("nonDumpable") is not True:
|
|
71
|
+
warnings.append("isolation.nonDumpable is not set; mark sessions non-dumpable after dropping to the tenant uid")
|
|
72
|
+
net = plan.get("network", {}) if isinstance(plan.get("network"), dict) else {}
|
|
73
|
+
if not LOOPBACK.match(str(net.get("adminBind", ""))):
|
|
74
|
+
errors.append("network.adminBind must be a loopback address:port")
|
|
75
|
+
if net.get("adminProxied") is not False:
|
|
76
|
+
errors.append("network.adminProxied must be false: the admin API is never exposed through the reverse proxy")
|
|
77
|
+
if net.get("proxy") and not LOOPBACK.match(str(net.get("publicBind", ""))):
|
|
78
|
+
errors.append("network.publicBind must be loopback when a reverse proxy fronts the host")
|
|
79
|
+
if net.get("adminBind") and net.get("adminBind") == net.get("publicBind"):
|
|
80
|
+
errors.append("admin and public APIs must listen on different ports")
|
|
81
|
+
cap = plan.get("capacity", {}) if isinstance(plan.get("capacity"), dict) else {}
|
|
82
|
+
if not isinstance(cap.get("maxSessions"), int) or cap["maxSessions"] < 1:
|
|
83
|
+
errors.append("capacity.maxSessions must be a positive integer")
|
|
84
|
+
if cap.get("eviction") not in {"lru-idle"}:
|
|
85
|
+
errors.append("capacity.eviction must be lru-idle: at the cap, stop the least recently used idle session")
|
|
86
|
+
if not isinstance(cap.get("idleTimeoutMinutes"), int) or not 1 <= cap["idleTimeoutMinutes"] <= 240:
|
|
87
|
+
warnings.append("capacity.idleTimeoutMinutes should stop idle sessions within 1-240 minutes")
|
|
88
|
+
recycle = plan.get("recycle", {}) if isinstance(plan.get("recycle"), dict) else {}
|
|
89
|
+
missing = sorted({"mcp", "skills", "cli"} - set(recycle.get("on", []) or []))
|
|
90
|
+
if missing:
|
|
91
|
+
errors.append("recycle.on must include " + ", ".join(missing) + ": sessions keep stale tools until recycled")
|
|
92
|
+
if recycle.get("busySessions") != "after-current-turn":
|
|
93
|
+
warnings.append("recycle.busySessions should be after-current-turn so an update never cuts a reply")
|
|
94
|
+
image = plan.get("image", {}) if isinstance(plan.get("image"), dict) else {}
|
|
95
|
+
if not re.fullmatch(r"[a-z0-9.-]+=[A-Za-z0-9._-]+", str(image.get("label", ""))):
|
|
96
|
+
errors.append("image.label must be key=value so cleanup can find this host's images")
|
|
97
|
+
if not isinstance(image.get("retain"), int) or image["retain"] < 2:
|
|
98
|
+
errors.append("image.retain must be at least 2 (current + previous for rollback)")
|
|
99
|
+
health = image.get("healthcheck", {}) if isinstance(image.get("healthcheck"), dict) else {}
|
|
100
|
+
if not str(health.get("path", "")).startswith("/") or not isinstance(health.get("attempts"), int):
|
|
101
|
+
errors.append("image.healthcheck needs a path and attempts")
|
|
102
|
+
if image.get("rollback") is not True:
|
|
103
|
+
errors.append("image.rollback must be true: a failed health check restores the previous container")
|
|
104
|
+
for key in ("readOnlyRootfs", "capDropAll", "noNewPrivileges"):
|
|
105
|
+
if image.get(key) is not True:
|
|
106
|
+
warnings.append(f"image.{key} is not set")
|
|
107
|
+
providers = plan.get("providers")
|
|
108
|
+
if not isinstance(providers, list) or not providers:
|
|
109
|
+
errors.append("providers must list each third-party AI account type the host runs, with a terms review")
|
|
110
|
+
for provider in providers if isinstance(providers, list) else []:
|
|
111
|
+
name = provider.get("name", "?")
|
|
112
|
+
reviewed = provider.get("termsReviewedAt")
|
|
113
|
+
if not reviewed or not provider.get("termsUrl"):
|
|
114
|
+
errors.append(f"provider {name}: record termsUrl and termsReviewedAt")
|
|
115
|
+
else:
|
|
116
|
+
age = (today - date.fromisoformat(str(reviewed)[:10])).days
|
|
117
|
+
if age > TERMS_MAX_AGE_DAYS:
|
|
118
|
+
warnings.append(f"provider {name}: terms reviewed {age} days ago; review again")
|
|
119
|
+
if provider.get("accountType") == "shared" and provider.get("allowsHostedMultiUser") is not True:
|
|
120
|
+
errors.append(f"provider {name}: a shared account may only serve several users when the terms allow hosted multi-user use")
|
|
121
|
+
if provider.get("accountType") not in {"customer-own", "shared"}:
|
|
122
|
+
errors.append(f"provider {name}: accountType must be customer-own or shared")
|
|
123
|
+
return {"schemaVersion": "maggie-bridge-host-plan-check.v1", "passed": not errors, "errors": errors, "warnings": warnings}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def collect_script(plan: dict) -> str:
|
|
127
|
+
"""A POSIX sh collector; values are quoted once here and the script is sent over ssh stdin."""
|
|
128
|
+
root = shlex.quote(str(plan.get("tenantsRoot", "/var/lib/bridge/tenants")))
|
|
129
|
+
label = shlex.quote(str(plan.get("image", {}).get("label", "")))
|
|
130
|
+
ports = " ".join(shlex.quote(str(plan.get("network", {}).get(key, "")).rsplit(":", 1)[-1]) for key in ("publicBind", "adminBind"))
|
|
131
|
+
return f"""#!/bin/sh
|
|
132
|
+
# maggie bridge-host collector: prints JSON facts only (modes, uids, listeners, image count).
|
|
133
|
+
# Run: ssh <host> 'sh -s' < collect.sh > observed.json
|
|
134
|
+
set -eu
|
|
135
|
+
root={root}
|
|
136
|
+
printf '{{"schemaVersion":"maggie-bridge-host-observed.v1","parents":['
|
|
137
|
+
sep=''
|
|
138
|
+
dir="$root"
|
|
139
|
+
while [ "$dir" != "/" ]; do
|
|
140
|
+
printf '%s{{"path":"%s","mode":"%s"}}' "$sep" "$dir" "$(stat -c %a "$dir")"; sep=','
|
|
141
|
+
dir=$(dirname "$dir")
|
|
142
|
+
done
|
|
143
|
+
printf '],"tenants":['
|
|
144
|
+
sep=''
|
|
145
|
+
for t in "$root"/*; do
|
|
146
|
+
[ -d "$t" ] || continue
|
|
147
|
+
printf '%s{{"name":"%s","mode":"%s","uid":%s}}' "$sep" "$(basename "$t")" "$(stat -c %a "$t")" "$(stat -c %u "$t")"; sep=','
|
|
148
|
+
done
|
|
149
|
+
printf '],"listeners":['
|
|
150
|
+
listeners=$(for port in {ports}; do ss -ltnH "sport = :$port" 2>/dev/null | awk '{{print $4}}'; done)
|
|
151
|
+
sep=''
|
|
152
|
+
for local in $listeners; do printf '%s"%s"' "$sep" "$local"; sep=','; done
|
|
153
|
+
printf '],"images":%s}}\\n' "$(docker images --filter label={label} --format '{{{{.ID}}}}' 2>/dev/null | sort -u | wc -l)"
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_observed(plan: dict, observed: dict) -> dict:
|
|
158
|
+
errors: list[str] = []
|
|
159
|
+
iso = plan.get("isolation", {})
|
|
160
|
+
first, last = (iso.get("uidRange") or [10000, 10**9])[:2]
|
|
161
|
+
parents = observed.get("parents", [])
|
|
162
|
+
for item in parents[1:]: # ancestors only need traverse (x) for other users
|
|
163
|
+
mode = _mode(item.get("mode"))
|
|
164
|
+
if mode is None or not mode & 0o001:
|
|
165
|
+
errors.append(f"parent {item.get('path')} is {item.get('mode')}: tenant uids cannot traverse it (others need x, e.g. 0711)")
|
|
166
|
+
if parents and _mode(parents[0].get("mode")) != _mode(iso.get("parentDirMode", "0711")):
|
|
167
|
+
errors.append(f"tenants root {parents[0].get('path')} is {parents[0].get('mode')}, plan says {iso.get('parentDirMode')}")
|
|
168
|
+
uids: dict[int, str] = {}
|
|
169
|
+
for tenant in observed.get("tenants", []):
|
|
170
|
+
name, uid = tenant.get("name"), tenant.get("uid")
|
|
171
|
+
if _mode(tenant.get("mode")) != 0o700:
|
|
172
|
+
errors.append(f"tenant {name} is {tenant.get('mode')}, not 0700")
|
|
173
|
+
if not isinstance(uid, int) or not first <= uid <= last:
|
|
174
|
+
errors.append(f"tenant {name} uid {uid} is outside {first}-{last}")
|
|
175
|
+
elif uid in uids:
|
|
176
|
+
errors.append(f"tenants {uids[uid]} and {name} share uid {uid}")
|
|
177
|
+
else:
|
|
178
|
+
uids[uid] = name
|
|
179
|
+
admin_port = str(plan.get("network", {}).get("adminBind", "")).rsplit(":", 1)[-1]
|
|
180
|
+
for listener in observed.get("listeners", []):
|
|
181
|
+
host, _, port = str(listener).rpartition(":")
|
|
182
|
+
if port == admin_port and host not in {"127.0.0.1", "[::1]", "::1"}:
|
|
183
|
+
errors.append(f"admin port {port} listens on {host or '*'}, not loopback")
|
|
184
|
+
retain = plan.get("image", {}).get("retain", 2)
|
|
185
|
+
if isinstance(observed.get("images"), int) and observed["images"] > retain:
|
|
186
|
+
errors.append(f"{observed['images']} labelled images on the host; keep {retain} (current + previous)")
|
|
187
|
+
return {"schemaVersion": "maggie-bridge-host-check.v1", "passed": not errors, "tenants": len(observed.get("tenants", [])), "errors": errors}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv: list[str] | None = None) -> int:
|
|
191
|
+
parser = argparse.ArgumentParser(prog="maggie deployment bridge", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
192
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
193
|
+
for name in ("bridge-plan", "bridge-collect-script", "bridge-check"):
|
|
194
|
+
command = sub.add_parser(name)
|
|
195
|
+
command.add_argument("--plan", type=Path, required=True)
|
|
196
|
+
if name == "bridge-check":
|
|
197
|
+
command.add_argument("--observed", type=Path, required=True)
|
|
198
|
+
args = parser.parse_args(argv)
|
|
199
|
+
try:
|
|
200
|
+
plan = json.loads(args.plan.read_text(encoding="utf-8"))
|
|
201
|
+
if args.command == "bridge-collect-script":
|
|
202
|
+
print(collect_script(plan), end=""); return 0
|
|
203
|
+
report = validate_plan(plan) if args.command == "bridge-plan" else check_observed(plan, json.loads(args.observed.read_text(encoding="utf-8")))
|
|
204
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
205
|
+
print(f"BLOCKED: maggie deployment {args.command}: {error}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
208
|
+
return 0 if report["passed"] else 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
raise SystemExit(main())
|
|
@@ -202,13 +202,83 @@ def validate_content_ui(project: Path, plan_path: Path, rendered_dir: Path | Non
|
|
|
202
202
|
output = plan_path.parent / "validation.json"; output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"); print(json.dumps(result, indent=2, ensure_ascii=False)); return 0 if not errors else 1
|
|
203
203
|
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
APP_CAPABILITY_STATES = {
|
|
206
|
+
"camera": ["permission", "ready", "gallery", "scan", "unavailable"],
|
|
207
|
+
"location": ["permission", "granted", "denied", "unavailable"],
|
|
208
|
+
"notifications": ["permission", "granted", "denied"],
|
|
209
|
+
"media-library": ["permission", "granted", "denied", "empty"],
|
|
210
|
+
}
|
|
211
|
+
APP_PATTERNS = ("tabbed-modal", "editor-sheet-assistant", "usage-hero", "site-switcher")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_app_capabilities(value: str | None) -> list[str]:
|
|
215
|
+
"""Parse ``--capabilities`` (``none`` or a comma list); ``None`` keeps the camera default."""
|
|
216
|
+
if value is None:
|
|
217
|
+
return ["camera"]
|
|
218
|
+
names = [item.strip() for item in value.split(",") if item.strip()]
|
|
219
|
+
if names == ["none"] or not names:
|
|
220
|
+
return []
|
|
221
|
+
unknown = sorted(set(names) - set(APP_CAPABILITY_STATES))
|
|
222
|
+
if unknown or "none" in names:
|
|
223
|
+
raise ValueError(f"unsupported app capability: {', '.join(unknown) or 'none mixed with others'} (use none or {', '.join(APP_CAPABILITY_STATES)})")
|
|
224
|
+
return sorted(set(names), key=names.index)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse_app_panes(values: list[str] | None) -> list[dict]:
|
|
228
|
+
"""Parse ``--pane NAME:MIN_WIDTH:SCROLL_OWNERS`` declarations of intentional multi-pane layouts."""
|
|
229
|
+
panes = []
|
|
230
|
+
for value in values or []:
|
|
231
|
+
match = re.fullmatch(r"([a-z0-9][a-z0-9-]*):(\d{3,4}):([2-4])", value.strip())
|
|
232
|
+
if not match:
|
|
233
|
+
raise ValueError(f"--pane must look like list-detail:1024:2 (name, min width px, 2-4 scroll owners): {value}")
|
|
234
|
+
panes.append({"name": match.group(1), "minWidth": int(match.group(2)), "scrollOwners": int(match.group(3))})
|
|
235
|
+
return panes
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_app_viewports(values: list[str] | None) -> dict[str, list[int]]:
|
|
239
|
+
viewports = {name: list(size) for name, size in REVIEW_VIEWPORTS.items()}
|
|
240
|
+
for value in values or []:
|
|
241
|
+
match = re.fullmatch(r"(desktop|tablet|mobile)=(\d{3,4})x(\d{3,4})", value.strip())
|
|
242
|
+
if not match:
|
|
243
|
+
raise ValueError(f"--viewport must look like tablet=768x1024: {value}")
|
|
244
|
+
viewports[match.group(1)] = [int(match.group(2)), int(match.group(3))]
|
|
245
|
+
return viewports
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def expected_scroll_owners(plan: dict, width: int) -> int:
|
|
249
|
+
"""Return the scroll owners a viewport may have: one, unless a declared pane layout applies."""
|
|
250
|
+
owners = plan.get("shell", {}).get("scrollOwners", {}) if isinstance(plan.get("shell"), dict) else {}
|
|
251
|
+
expected = int(owners.get("default", 1)) if isinstance(owners, dict) else 1
|
|
252
|
+
for pane in (owners.get("intentionalPanes", []) if isinstance(owners, dict) else []):
|
|
253
|
+
if isinstance(pane, dict) and isinstance(pane.get("minWidth"), int) and width >= pane["minWidth"]:
|
|
254
|
+
expected = max(expected, int(pane.get("scrollOwners", 1)))
|
|
255
|
+
return expected
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def png_size(path: Path) -> tuple[int, int] | None:
|
|
259
|
+
"""Return a PNG's IHDR width and height, or ``None`` when the file is not a PNG."""
|
|
260
|
+
try:
|
|
261
|
+
head = path.read_bytes()[:24]
|
|
262
|
+
except OSError:
|
|
263
|
+
return None
|
|
264
|
+
if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n" or head[12:16] != b"IHDR":
|
|
265
|
+
return None
|
|
266
|
+
return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, confirm: bool, capabilities: list[str] | None = None, panes: list[dict] | None = None, viewports: dict[str, list[int]] | None = None, patterns: list[str] | None = None) -> int:
|
|
206
270
|
"""Create a host-owned mobile app surface contract and QA manifest."""
|
|
207
271
|
project = project.resolve()
|
|
208
272
|
if not route.startswith("/") or "?" in route or "#" in route:
|
|
209
273
|
raise ValueError("route must be a path beginning with / and must not contain a query or hash")
|
|
210
274
|
if auth_mode not in {"email-password", "existing-session", "none"}:
|
|
211
275
|
raise ValueError("auth mode must be email-password, existing-session, or none")
|
|
276
|
+
capabilities = ["camera"] if capabilities is None else capabilities
|
|
277
|
+
panes = panes or []
|
|
278
|
+
patterns = patterns or []
|
|
279
|
+
unknown_patterns = sorted(set(patterns) - set(APP_PATTERNS))
|
|
280
|
+
if unknown_patterns:
|
|
281
|
+
raise ValueError(f"unsupported app pattern: {', '.join(unknown_patterns)} (use {', '.join(APP_PATTERNS)})")
|
|
212
282
|
contract = require_design_contract(project)
|
|
213
283
|
relative = route.strip("/")
|
|
214
284
|
candidates = [
|
|
@@ -218,6 +288,11 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
|
|
|
218
288
|
project / "src" / "pages" / f"{relative}.html",
|
|
219
289
|
project / "src" / "app" / relative,
|
|
220
290
|
project / "src" / "app" / relative / "page.tsx",
|
|
291
|
+
# Expo Router file routes (app/ or src/app/, with optional (group) folders resolved by the host).
|
|
292
|
+
project / "app" / f"{relative}.tsx",
|
|
293
|
+
project / "app" / relative / "index.tsx",
|
|
294
|
+
project / "src" / "app" / f"{relative}.tsx",
|
|
295
|
+
project / "src" / "app" / relative / "index.tsx",
|
|
221
296
|
]
|
|
222
297
|
source = next((path for path in candidates if path.is_file()), None)
|
|
223
298
|
digest = hashlib.sha256((str(project) + "\n" + route + "\n" + auth_mode).encode()).hexdigest()[:12]
|
|
@@ -226,7 +301,15 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
|
|
|
226
301
|
print((output_dir / "plan.json").read_text(encoding="utf-8"), end="")
|
|
227
302
|
return 0
|
|
228
303
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
-
viewports = {
|
|
304
|
+
viewports = viewports or {name: list(size) for name, size in REVIEW_VIEWPORTS.items()}
|
|
305
|
+
camera = "camera" in capabilities
|
|
306
|
+
capability_contract = {name: {"requiredStates": list(APP_CAPABILITY_STATES[name])} for name in capabilities}
|
|
307
|
+
required_steps = ["inspect-host-shell", "define-auth-gate"]
|
|
308
|
+
if capabilities:
|
|
309
|
+
required_steps.append("define-capability-states")
|
|
310
|
+
if camera:
|
|
311
|
+
required_steps.append("define-camera-states")
|
|
312
|
+
required_steps += ["capture-desktop-tablet-mobile", "signed-in-browser-qa", "accessibility-check", "route-validation", "explicit-approval"]
|
|
230
313
|
plan = {
|
|
231
314
|
"schemaVersion": "maggie-mobile-app-surface.v1",
|
|
232
315
|
"id": f"app-{digest}",
|
|
@@ -237,11 +320,17 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
|
|
|
237
320
|
"routeSource": str(source) if source else None,
|
|
238
321
|
"designContract": contract,
|
|
239
322
|
"auth": {"mode": auth_mode, "required": auth_mode != "none", "credentialPolicy": "use-host-auth-without-recording-credentials"},
|
|
240
|
-
"shell": {
|
|
241
|
-
|
|
242
|
-
|
|
323
|
+
"shell": {
|
|
324
|
+
"fixedHeader": True, "fixedFooter": True, "oneScrollOwner": not panes,
|
|
325
|
+
"scrollOwners": {"default": 1, "intentionalPanes": panes, "rule": "per-viewport evidence must match; more than one owner only where a declared pane layout applies"},
|
|
326
|
+
"cameraMayUseFullScreenViewport": camera, "hostShellSourceOfTruth": "existing homepage shell",
|
|
327
|
+
},
|
|
328
|
+
"capabilities": capability_contract,
|
|
329
|
+
"camera": {"requiredStates": list(APP_CAPABILITY_STATES["camera"]), "controls": ["close", "gallery", "shutter", "mode"], "fallback": "show an actionable unavailable state; never leave a black screen"} if camera else {"requiredStates": [], "notApplicable": "route declares no camera capability"},
|
|
330
|
+
"patterns": patterns,
|
|
331
|
+
"decisionCriticalUi": [],
|
|
243
332
|
"viewports": viewports,
|
|
244
|
-
"requiredSteps":
|
|
333
|
+
"requiredSteps": required_steps,
|
|
245
334
|
"provenance": {"hostOwnsAuthAndCamera": True, "designContractReused": True, "privateDataCopied": False},
|
|
246
335
|
"publicWrite": False,
|
|
247
336
|
"createdAt": datetime.now(timezone.utc).isoformat(),
|
|
@@ -250,36 +339,58 @@ def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, con
|
|
|
250
339
|
"schemaVersion": "maggie-mobile-app-evidence.v1",
|
|
251
340
|
"route": route,
|
|
252
341
|
"auth": plan["auth"],
|
|
253
|
-
"shell": plan["shell"],
|
|
254
|
-
"
|
|
255
|
-
"viewports": {name: {"screenshot": f"{name}.png", "captured": False} for name in viewports},
|
|
342
|
+
"shell": {key: plan["shell"][key] for key in ("fixedHeader", "fixedFooter")},
|
|
343
|
+
"capabilities": {name: {"states": []} for name in capabilities},
|
|
344
|
+
"viewports": {name: {"width": size[0], "height": size[1], "deviceScaleFactor": 1, "scrollOwners": None, "screenshot": f"{name}.png", "captured": False} for name, size in viewports.items()},
|
|
256
345
|
"status": "pending",
|
|
346
|
+
"collectWith": "maggie design app-collect",
|
|
257
347
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
{"id": "
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
348
|
+
scenarios = [{"id": "shell-scroll", "viewport": "mobile", "assertions": ["fixed header and footer remain usable", "exactly one scroll owner"]}]
|
|
349
|
+
for pane in panes:
|
|
350
|
+
scenarios.append({"id": f"panes-{pane['name']}", "viewport": "desktop", "assertions": [f"from {pane['minWidth']} px the {pane['name']} layout has {pane['scrollOwners']} independent scroll owners", f"below {pane['minWidth']} px the layout collapses to one scroll owner"]})
|
|
351
|
+
if camera:
|
|
352
|
+
scenarios += [
|
|
353
|
+
{"id": "camera-permission", "viewport": "mobile", "assertions": ["permission state is explained", "denied permission shows fallback"]},
|
|
354
|
+
{"id": "camera-capture", "viewport": "mobile", "assertions": ["gallery/shutter/mode/close controls are reachable", "a capture result reaches the next step of the flow"]},
|
|
355
|
+
]
|
|
356
|
+
for name in capabilities:
|
|
357
|
+
if name != "camera":
|
|
358
|
+
scenarios.append({"id": f"{name}-permission", "viewport": "mobile", "assertions": [f"{name} permission is explained before the system prompt", "denied permission leaves an actionable fallback"]})
|
|
359
|
+
scenarios.append({"id": "ios-safe-area", "viewport": "notched iPhone (device or simulator)", "assertions": ["header and Close sit below the status bar / Dynamic Island in every modal and sheet", "insets come from useSafeAreaInsets(), not SafeAreaView inside a modal (`maggie design safe-area-check`)", "web screenshots cannot prove this; record a device screenshot"]})
|
|
360
|
+
for pattern in patterns:
|
|
361
|
+
scenarios.append({"id": f"pattern-{pattern}", "viewport": "all", "assertions": APP_PATTERN_ACCEPTANCE[pattern]})
|
|
362
|
+
if auth_mode != "none":
|
|
363
|
+
scenarios.append({"id": "signed-in-route", "viewport": "desktop", "assertions": ["signed-in route does not redirect unexpectedly", "render one app instance at a time when refresh tokens rotate"]})
|
|
364
|
+
qa = {"schemaVersion": "maggie-mobile-app-qa.v1", "route": route, "auth": plan["auth"], "scenarios": scenarios}
|
|
264
365
|
(output_dir / "plan.json").write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
265
366
|
(output_dir / "qa-scenarios.json").write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
266
367
|
(output_dir / "validation.json").write_text(json.dumps(evidence, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
267
368
|
(output_dir / "screenshots").mkdir(exist_ok=True)
|
|
268
|
-
print(json.dumps({"plan": str(output_dir / "plan.json"), "qaScenarios": str(output_dir / "qa-scenarios.json"), "surface": "app", "phase": "ready", "publicWrite": False}, indent=2, ensure_ascii=False))
|
|
369
|
+
print(json.dumps({"plan": str(output_dir / "plan.json"), "qaScenarios": str(output_dir / "qa-scenarios.json"), "surface": "app", "capabilities": capabilities, "panes": panes, "patterns": patterns, "phase": "ready", "publicWrite": False}, indent=2, ensure_ascii=False))
|
|
269
370
|
return 0
|
|
270
371
|
|
|
271
372
|
|
|
373
|
+
APP_PATTERN_ACCEPTANCE = {
|
|
374
|
+
"tabbed-modal": ["full screen below 768 px, centred card from 768 px", "the active pill tab scrolls into view", "switching tabs resets the tab body scroll", "close and Escape return focus to the opener"],
|
|
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
|
+
"usage-hero": ["plan name, used and remaining quota are visible without scrolling", "the over-limit state names the next action"],
|
|
377
|
+
"site-switcher": ["favicon falls back to the site initial", "search appears from 5 sites", "the current site is marked and keyboard reachable"],
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
272
381
|
def validate_app_surface(project: Path, plan_path: Path, rendered_dir: Path | None, runtime_evidence: Path | None, confirm: bool) -> int:
|
|
273
|
-
"""Validate signed-in app evidence and responsive screenshots."""
|
|
382
|
+
"""Validate signed-in app evidence and responsive screenshots against the plan."""
|
|
274
383
|
project = project.resolve(); plan_path = plan_path.resolve()
|
|
275
384
|
if not plan_path.exists(): raise ValueError(f"app surface plan not found: {plan_path}")
|
|
276
|
-
plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []
|
|
385
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []; warnings = []
|
|
277
386
|
if plan.get("workflow") != "maggie-design" or plan.get("mode") != "app-view-init": errors.append("invalid mobile app surface plan")
|
|
278
387
|
if plan.get("publicWrite") is not False: errors.append("publicWrite must remain false before approval")
|
|
279
|
-
|
|
388
|
+
shell_plan = plan.get("shell", {})
|
|
389
|
+
if not shell_plan.get("oneScrollOwner") and not isinstance(shell_plan.get("scrollOwners"), dict): errors.append("app surface must define its scroll owners")
|
|
280
390
|
if not plan.get("auth", {}).get("credentialPolicy"): errors.append("app surface must define a credential policy")
|
|
281
391
|
try: require_design_contract(project)
|
|
282
392
|
except ValueError as error: errors.append(str(error))
|
|
393
|
+
plan_viewports = {name: list(plan.get("viewports", {}).get(name) or REVIEW_VIEWPORTS[name]) for name in ("desktop", "tablet", "mobile")}
|
|
283
394
|
evidence = {}
|
|
284
395
|
evidence_path = runtime_evidence.resolve() if runtime_evidence else plan_path.parent / "runtime-evidence.json"
|
|
285
396
|
if evidence_path.is_file():
|
|
@@ -287,23 +398,60 @@ def validate_app_surface(project: Path, plan_path: Path, rendered_dir: Path | No
|
|
|
287
398
|
except json.JSONDecodeError: errors.append("runtime evidence is not valid JSON")
|
|
288
399
|
else:
|
|
289
400
|
errors.append("runtime evidence file is required")
|
|
401
|
+
evidence_viewports = evidence.get("viewports", {}) if isinstance(evidence.get("viewports"), dict) else {}
|
|
402
|
+
viewport_checks = {}
|
|
290
403
|
if evidence:
|
|
291
404
|
if evidence.get("schemaVersion") != "maggie-mobile-app-evidence.v1": errors.append("runtime evidence schemaVersion is invalid")
|
|
292
405
|
if evidence.get("route") != plan.get("route"): errors.append("runtime evidence route does not match plan")
|
|
293
406
|
auth = evidence.get("auth", {})
|
|
294
407
|
if plan.get("auth", {}).get("required") and auth.get("signedIn") is not True: errors.append("signed-in evidence is required for this app route")
|
|
295
408
|
shell = evidence.get("shell", {})
|
|
296
|
-
if
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
409
|
+
if "scrollOwnerCount" in shell:
|
|
410
|
+
warnings.append("shell.scrollOwnerCount is ignored; per-viewport scrollOwners are authoritative")
|
|
411
|
+
for name, (width, height) in plan_viewports.items():
|
|
412
|
+
entry = evidence_viewports.get(name)
|
|
413
|
+
check = {"planViewport": [width, height], "expectedScrollOwners": expected_scroll_owners(plan, width)}
|
|
414
|
+
viewport_checks[name] = check
|
|
415
|
+
if not isinstance(entry, dict):
|
|
416
|
+
errors.append(f"runtime evidence has no {name} viewport entry; collect it with `maggie design app-collect`"); continue
|
|
417
|
+
if [entry.get("width"), entry.get("height")] != [width, height]:
|
|
418
|
+
errors.append(f"{name} evidence viewport {entry.get('width')}x{entry.get('height')} does not match plan viewport {width}x{height}")
|
|
419
|
+
owners = entry.get("scrollOwners")
|
|
420
|
+
check["scrollOwners"] = owners
|
|
421
|
+
if not isinstance(owners, int):
|
|
422
|
+
errors.append(f"{name} evidence must record scrollOwners")
|
|
423
|
+
elif owners != check["expectedScrollOwners"]:
|
|
424
|
+
errors.append(f"{name} has {owners} scroll owners; the plan allows {check['expectedScrollOwners']} at {width} px")
|
|
425
|
+
for key in ("fixedHeader", "fixedFooter"):
|
|
426
|
+
value = entry.get(key, shell.get(key))
|
|
427
|
+
if value is not True:
|
|
428
|
+
errors.append(f"runtime evidence must prove fixed shell ({key}) at {name}")
|
|
429
|
+
capability_states = evidence.get("capabilities", {}) if isinstance(evidence.get("capabilities"), dict) else {}
|
|
430
|
+
required = plan.get("capabilities")
|
|
431
|
+
if not isinstance(required, dict): # plans created before capability flags
|
|
432
|
+
required = {"camera": {"requiredStates": plan.get("camera", {}).get("requiredStates", [])}}
|
|
433
|
+
for name, spec in required.items():
|
|
434
|
+
states = capability_states.get(name, {}).get("states", []) if isinstance(capability_states.get(name), dict) else []
|
|
435
|
+
if name == "camera" and not states and isinstance(evidence.get("camera"), dict):
|
|
436
|
+
states = evidence["camera"].get("states", [])
|
|
437
|
+
missing = sorted(set(spec.get("requiredStates", [])) - set(states))
|
|
438
|
+
if missing:
|
|
439
|
+
errors.append(f"runtime evidence is missing a required {name} state: {', '.join(missing)}" if name != "camera" else f"runtime evidence is missing a required camera state: {', '.join(missing)}")
|
|
300
440
|
screenshot_status = {}
|
|
301
|
-
for name
|
|
441
|
+
for name, (width, height) in plan_viewports.items():
|
|
302
442
|
path = (rendered_dir / f"{name}.png") if rendered_dir else plan_path.parent / "screenshots" / f"{name}.png"
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
443
|
+
size = png_size(path) if path.is_file() else None
|
|
444
|
+
entry = evidence_viewports.get(name) if isinstance(evidence_viewports.get(name), dict) else {}
|
|
445
|
+
scale = entry.get("deviceScaleFactor", 1) or 1
|
|
446
|
+
full_page = entry.get("fullPage") is True
|
|
447
|
+
screenshot_status[name] = {"path": str(path), "validPng": size is not None, "size": list(size) if size else None, "deviceScaleFactor": scale}
|
|
448
|
+
if size is None:
|
|
449
|
+
errors.append(f"missing valid {name} screenshot"); continue
|
|
450
|
+
expected_w, expected_h = round(width * scale), round(height * scale)
|
|
451
|
+
if size[0] != expected_w or (size[1] < expected_h if full_page else size[1] != expected_h):
|
|
452
|
+
errors.append(f"{name} screenshot is {size[0]}x{size[1]} but the plan viewport is {width}x{height} at scale {scale} (expected {expected_w}x{expected_h}{' or taller' if full_page else ''}); capture at the real viewport, not a scaled frame")
|
|
453
|
+
result = {"schemaVersion": "maggie-mobile-app-validation.v1", "plan": str(plan_path), "route": plan.get("route"), "screenshots": screenshot_status, "viewports": viewport_checks, "runtimeEvidence": str(evidence_path), "checks": {"fixedShell": not any("fixed shell" in error for error in errors), "scrollOwners": not any("scroll owner" in error for error in errors), "viewportsMatchPlan": not any("does not match plan viewport" in error or "screenshot is" in error for error in errors), "signedInRoute": not any("signed-in" in error for error in errors), "capabilityStates": not any(" state" in error and "required" in error for error in errors), "publicWriteBlocked": plan.get("publicWrite") is False}, "status": "passed" if not errors else "failed", "errors": errors, "warnings": warnings, "validatedAt": datetime.now(timezone.utc).isoformat()}
|
|
454
|
+
result["checks"]["cameraStates"] = not any("camera state" in error for error in errors)
|
|
307
455
|
if confirm:
|
|
308
456
|
plan_path.parent.joinpath("validation.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
309
457
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
@@ -856,18 +1004,23 @@ commands:
|
|
|
856
1004
|
markup-check validate optional style attributes, content keys, and entities
|
|
857
1005
|
pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
|
|
858
1006
|
layer-check flag unlayered CSS that overrides Tailwind v4 utilities
|
|
1007
|
+
safe-area-check flag SafeAreaView inside iOS modal routes / RN Modal
|
|
859
1008
|
palette generate or validate a semantic palette role contract
|
|
860
1009
|
verify run automatable design-job steps and record evidence
|
|
861
1010
|
icon-glyph-check render icons and prove font load and glyph paint
|
|
1011
|
+
app-collect render an app route per viewport and write runtime evidence
|
|
862
1012
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
863
1013
|
""")
|
|
864
1014
|
return 0
|
|
865
|
-
if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check"}:
|
|
1015
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check"}:
|
|
866
1016
|
from maggie_design_lint import main as lint_main
|
|
867
1017
|
return lint_main(sys.argv[1:])
|
|
868
1018
|
if len(sys.argv) > 1 and sys.argv[1] == "verify":
|
|
869
1019
|
from maggie_design_verify import main as verify_main
|
|
870
1020
|
return verify_main(sys.argv[2:])
|
|
1021
|
+
if len(sys.argv) > 1 and sys.argv[1] == "app-collect":
|
|
1022
|
+
from maggie_app_collect import main as app_collect_main
|
|
1023
|
+
return app_collect_main(sys.argv[2:])
|
|
871
1024
|
if len(sys.argv) > 1 and sys.argv[1] == "icon-glyph-check":
|
|
872
1025
|
from maggie_icon_glyph_check import main as glyph_main
|
|
873
1026
|
return glyph_main(sys.argv[2:])
|
|
@@ -924,11 +1077,21 @@ commands:
|
|
|
924
1077
|
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
925
1078
|
command.add_argument("--route", required=True)
|
|
926
1079
|
command.add_argument("--auth-mode", choices=["email-password", "existing-session", "none"], default="email-password")
|
|
1080
|
+
command.add_argument("--capabilities", help="none, or a comma list of camera,location,notifications,media-library (default camera)")
|
|
1081
|
+
command.add_argument("--camera", choices=["required", "none"], help="shorthand: --camera none drops camera states")
|
|
1082
|
+
command.add_argument("--pane", action="append", default=[], help="intentional multi-pane layout NAME:MIN_WIDTH:SCROLL_OWNERS, e.g. list-detail:1024:2")
|
|
1083
|
+
command.add_argument("--viewport", action="append", default=[], help="override a plan viewport, e.g. tablet=768x1024")
|
|
1084
|
+
command.add_argument("--pattern", action="append", default=[], choices=list(APP_PATTERNS), help="attach app UI pattern acceptance rules")
|
|
927
1085
|
command.add_argument("--force", action="store_true")
|
|
928
1086
|
command.add_argument("--confirm", action="store_true")
|
|
929
1087
|
args = command.parse_args(sys.argv[2:])
|
|
930
1088
|
try:
|
|
931
|
-
|
|
1089
|
+
capabilities = parse_app_capabilities(args.capabilities)
|
|
1090
|
+
if args.camera == "none":
|
|
1091
|
+
capabilities = [name for name in capabilities if name != "camera"]
|
|
1092
|
+
elif args.camera == "required" and "camera" not in capabilities:
|
|
1093
|
+
capabilities = ["camera"] + capabilities
|
|
1094
|
+
return app_surface_init(args.project, args.route, args.auth_mode, args.force, args.confirm, capabilities, parse_app_panes(args.pane), parse_app_viewports(args.viewport), args.pattern)
|
|
932
1095
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
933
1096
|
print(f"BLOCKED: maggie-design app-init: {error}", file=sys.stderr); return 1
|
|
934
1097
|
if len(sys.argv) > 1 and sys.argv[1] == "app-validate":
|