@topy-ai/maggie 0.7.49 → 0.7.51
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 +58 -8
- package/bin/maggie.js +33 -14
- 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 +55 -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 +122 -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 +190 -29
- package/bundled-tools/clis/maggie_feedback.py +89 -37
- package/bundled-tools/clis/maggie_mobile.py +221 -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,57 @@ 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
|
+
for pattern in patterns:
|
|
360
|
+
scenarios.append({"id": f"pattern-{pattern}", "viewport": "all", "assertions": APP_PATTERN_ACCEPTANCE[pattern]})
|
|
361
|
+
if auth_mode != "none":
|
|
362
|
+
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"]})
|
|
363
|
+
qa = {"schemaVersion": "maggie-mobile-app-qa.v1", "route": route, "auth": plan["auth"], "scenarios": scenarios}
|
|
264
364
|
(output_dir / "plan.json").write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
265
365
|
(output_dir / "qa-scenarios.json").write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
266
366
|
(output_dir / "validation.json").write_text(json.dumps(evidence, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
267
367
|
(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))
|
|
368
|
+
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
369
|
return 0
|
|
270
370
|
|
|
271
371
|
|
|
372
|
+
APP_PATTERN_ACCEPTANCE = {
|
|
373
|
+
"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"],
|
|
374
|
+
"editor-sheet-assistant": ["'Ask the assistant' turns the same sheet into a short contextual chat", "writes appear as an approval card; nothing changes until approved", "dismissing the chat restores the unsaved edit"],
|
|
375
|
+
"usage-hero": ["plan name, used and remaining quota are visible without scrolling", "the over-limit state names the next action"],
|
|
376
|
+
"site-switcher": ["favicon falls back to the site initial", "search appears from 5 sites", "the current site is marked and keyboard reachable"],
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
272
380
|
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."""
|
|
381
|
+
"""Validate signed-in app evidence and responsive screenshots against the plan."""
|
|
274
382
|
project = project.resolve(); plan_path = plan_path.resolve()
|
|
275
383
|
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 = []
|
|
384
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []; warnings = []
|
|
277
385
|
if plan.get("workflow") != "maggie-design" or plan.get("mode") != "app-view-init": errors.append("invalid mobile app surface plan")
|
|
278
386
|
if plan.get("publicWrite") is not False: errors.append("publicWrite must remain false before approval")
|
|
279
|
-
|
|
387
|
+
shell_plan = plan.get("shell", {})
|
|
388
|
+
if not shell_plan.get("oneScrollOwner") and not isinstance(shell_plan.get("scrollOwners"), dict): errors.append("app surface must define its scroll owners")
|
|
280
389
|
if not plan.get("auth", {}).get("credentialPolicy"): errors.append("app surface must define a credential policy")
|
|
281
390
|
try: require_design_contract(project)
|
|
282
391
|
except ValueError as error: errors.append(str(error))
|
|
392
|
+
plan_viewports = {name: list(plan.get("viewports", {}).get(name) or REVIEW_VIEWPORTS[name]) for name in ("desktop", "tablet", "mobile")}
|
|
283
393
|
evidence = {}
|
|
284
394
|
evidence_path = runtime_evidence.resolve() if runtime_evidence else plan_path.parent / "runtime-evidence.json"
|
|
285
395
|
if evidence_path.is_file():
|
|
@@ -287,23 +397,60 @@ def validate_app_surface(project: Path, plan_path: Path, rendered_dir: Path | No
|
|
|
287
397
|
except json.JSONDecodeError: errors.append("runtime evidence is not valid JSON")
|
|
288
398
|
else:
|
|
289
399
|
errors.append("runtime evidence file is required")
|
|
400
|
+
evidence_viewports = evidence.get("viewports", {}) if isinstance(evidence.get("viewports"), dict) else {}
|
|
401
|
+
viewport_checks = {}
|
|
290
402
|
if evidence:
|
|
291
403
|
if evidence.get("schemaVersion") != "maggie-mobile-app-evidence.v1": errors.append("runtime evidence schemaVersion is invalid")
|
|
292
404
|
if evidence.get("route") != plan.get("route"): errors.append("runtime evidence route does not match plan")
|
|
293
405
|
auth = evidence.get("auth", {})
|
|
294
406
|
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
407
|
shell = evidence.get("shell", {})
|
|
296
|
-
if
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
408
|
+
if "scrollOwnerCount" in shell:
|
|
409
|
+
warnings.append("shell.scrollOwnerCount is ignored; per-viewport scrollOwners are authoritative")
|
|
410
|
+
for name, (width, height) in plan_viewports.items():
|
|
411
|
+
entry = evidence_viewports.get(name)
|
|
412
|
+
check = {"planViewport": [width, height], "expectedScrollOwners": expected_scroll_owners(plan, width)}
|
|
413
|
+
viewport_checks[name] = check
|
|
414
|
+
if not isinstance(entry, dict):
|
|
415
|
+
errors.append(f"runtime evidence has no {name} viewport entry; collect it with `maggie design app-collect`"); continue
|
|
416
|
+
if [entry.get("width"), entry.get("height")] != [width, height]:
|
|
417
|
+
errors.append(f"{name} evidence viewport {entry.get('width')}x{entry.get('height')} does not match plan viewport {width}x{height}")
|
|
418
|
+
owners = entry.get("scrollOwners")
|
|
419
|
+
check["scrollOwners"] = owners
|
|
420
|
+
if not isinstance(owners, int):
|
|
421
|
+
errors.append(f"{name} evidence must record scrollOwners")
|
|
422
|
+
elif owners != check["expectedScrollOwners"]:
|
|
423
|
+
errors.append(f"{name} has {owners} scroll owners; the plan allows {check['expectedScrollOwners']} at {width} px")
|
|
424
|
+
for key in ("fixedHeader", "fixedFooter"):
|
|
425
|
+
value = entry.get(key, shell.get(key))
|
|
426
|
+
if value is not True:
|
|
427
|
+
errors.append(f"runtime evidence must prove fixed shell ({key}) at {name}")
|
|
428
|
+
capability_states = evidence.get("capabilities", {}) if isinstance(evidence.get("capabilities"), dict) else {}
|
|
429
|
+
required = plan.get("capabilities")
|
|
430
|
+
if not isinstance(required, dict): # plans created before capability flags
|
|
431
|
+
required = {"camera": {"requiredStates": plan.get("camera", {}).get("requiredStates", [])}}
|
|
432
|
+
for name, spec in required.items():
|
|
433
|
+
states = capability_states.get(name, {}).get("states", []) if isinstance(capability_states.get(name), dict) else []
|
|
434
|
+
if name == "camera" and not states and isinstance(evidence.get("camera"), dict):
|
|
435
|
+
states = evidence["camera"].get("states", [])
|
|
436
|
+
missing = sorted(set(spec.get("requiredStates", [])) - set(states))
|
|
437
|
+
if missing:
|
|
438
|
+
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
439
|
screenshot_status = {}
|
|
301
|
-
for name
|
|
440
|
+
for name, (width, height) in plan_viewports.items():
|
|
302
441
|
path = (rendered_dir / f"{name}.png") if rendered_dir else plan_path.parent / "screenshots" / f"{name}.png"
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
442
|
+
size = png_size(path) if path.is_file() else None
|
|
443
|
+
entry = evidence_viewports.get(name) if isinstance(evidence_viewports.get(name), dict) else {}
|
|
444
|
+
scale = entry.get("deviceScaleFactor", 1) or 1
|
|
445
|
+
full_page = entry.get("fullPage") is True
|
|
446
|
+
screenshot_status[name] = {"path": str(path), "validPng": size is not None, "size": list(size) if size else None, "deviceScaleFactor": scale}
|
|
447
|
+
if size is None:
|
|
448
|
+
errors.append(f"missing valid {name} screenshot"); continue
|
|
449
|
+
expected_w, expected_h = round(width * scale), round(height * scale)
|
|
450
|
+
if size[0] != expected_w or (size[1] < expected_h if full_page else size[1] != expected_h):
|
|
451
|
+
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")
|
|
452
|
+
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()}
|
|
453
|
+
result["checks"]["cameraStates"] = not any("camera state" in error for error in errors)
|
|
307
454
|
if confirm:
|
|
308
455
|
plan_path.parent.joinpath("validation.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
309
456
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
@@ -859,6 +1006,7 @@ commands:
|
|
|
859
1006
|
palette generate or validate a semantic palette role contract
|
|
860
1007
|
verify run automatable design-job steps and record evidence
|
|
861
1008
|
icon-glyph-check render icons and prove font load and glyph paint
|
|
1009
|
+
app-collect render an app route per viewport and write runtime evidence
|
|
862
1010
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
863
1011
|
""")
|
|
864
1012
|
return 0
|
|
@@ -868,6 +1016,9 @@ commands:
|
|
|
868
1016
|
if len(sys.argv) > 1 and sys.argv[1] == "verify":
|
|
869
1017
|
from maggie_design_verify import main as verify_main
|
|
870
1018
|
return verify_main(sys.argv[2:])
|
|
1019
|
+
if len(sys.argv) > 1 and sys.argv[1] == "app-collect":
|
|
1020
|
+
from maggie_app_collect import main as app_collect_main
|
|
1021
|
+
return app_collect_main(sys.argv[2:])
|
|
871
1022
|
if len(sys.argv) > 1 and sys.argv[1] == "icon-glyph-check":
|
|
872
1023
|
from maggie_icon_glyph_check import main as glyph_main
|
|
873
1024
|
return glyph_main(sys.argv[2:])
|
|
@@ -924,11 +1075,21 @@ commands:
|
|
|
924
1075
|
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
925
1076
|
command.add_argument("--route", required=True)
|
|
926
1077
|
command.add_argument("--auth-mode", choices=["email-password", "existing-session", "none"], default="email-password")
|
|
1078
|
+
command.add_argument("--capabilities", help="none, or a comma list of camera,location,notifications,media-library (default camera)")
|
|
1079
|
+
command.add_argument("--camera", choices=["required", "none"], help="shorthand: --camera none drops camera states")
|
|
1080
|
+
command.add_argument("--pane", action="append", default=[], help="intentional multi-pane layout NAME:MIN_WIDTH:SCROLL_OWNERS, e.g. list-detail:1024:2")
|
|
1081
|
+
command.add_argument("--viewport", action="append", default=[], help="override a plan viewport, e.g. tablet=768x1024")
|
|
1082
|
+
command.add_argument("--pattern", action="append", default=[], choices=list(APP_PATTERNS), help="attach app UI pattern acceptance rules")
|
|
927
1083
|
command.add_argument("--force", action="store_true")
|
|
928
1084
|
command.add_argument("--confirm", action="store_true")
|
|
929
1085
|
args = command.parse_args(sys.argv[2:])
|
|
930
1086
|
try:
|
|
931
|
-
|
|
1087
|
+
capabilities = parse_app_capabilities(args.capabilities)
|
|
1088
|
+
if args.camera == "none":
|
|
1089
|
+
capabilities = [name for name in capabilities if name != "camera"]
|
|
1090
|
+
elif args.camera == "required" and "camera" not in capabilities:
|
|
1091
|
+
capabilities = ["camera"] + capabilities
|
|
1092
|
+
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
1093
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
933
1094
|
print(f"BLOCKED: maggie-design app-init: {error}", file=sys.stderr); return 1
|
|
934
1095
|
if len(sys.argv) > 1 and sys.argv[1] == "app-validate":
|