@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,303 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Release checks for a product's own CLI and MCP packages (``maggie product``).
|
|
3
|
+
|
|
4
|
+
Maggie's own release gates cover only ``@topy-ai/maggie``. These commands
|
|
5
|
+
apply the same discipline to the CLIs and MCP servers a product ships:
|
|
6
|
+
|
|
7
|
+
- ``release-check``: npm metadata (bin targets, ``files``, license,
|
|
8
|
+
repository/directory, engines, public access for scoped names, provenance),
|
|
9
|
+
version agreement with the changelog and an MCP registry ``server.json``
|
|
10
|
+
(``mcpName`` ↔ ``name``, versions, package identifier), and optionally the
|
|
11
|
+
packed file list (``--pack``: bins included, no ``.env``/keys/``.npmrc``);
|
|
12
|
+
- ``smoke``: pack, install the tarball into an empty folder, run each binary
|
|
13
|
+
with ``--version``/``--help`` and require the package version;
|
|
14
|
+
- ``mcp-check``: a remote MCP endpoint's OAuth protected-resource metadata
|
|
15
|
+
(RFC 9728) and its unauthenticated 401 challenge — read-only requests;
|
|
16
|
+
- ``review-status``: third-party review states (ChatGPT app, Claude
|
|
17
|
+
directory, MCP registry, App Store, Google Play) from a tracked JSON file,
|
|
18
|
+
including ChatGPT app test cases.
|
|
19
|
+
|
|
20
|
+
Nothing is published. Tokens are never read or printed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from datetime import date, datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from urllib.error import HTTPError, URLError
|
|
35
|
+
from urllib.parse import urlsplit
|
|
36
|
+
from urllib.request import Request, urlopen
|
|
37
|
+
|
|
38
|
+
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
|
|
39
|
+
SECRET_FILES = re.compile(r"(?:^|/)(?:\.env(?:\..+)?|\.npmrc|id_rsa|.*\.pem|.*\.key|.*\.p8|.*\.p12|service-account.*\.json)$", re.I)
|
|
40
|
+
REVIEW_SURFACES = ("chatgpt-app", "claude-directory", "mcp-registry", "app-store", "google-play", "npm")
|
|
41
|
+
REVIEW_STATES = ("draft", "submitted", "in-review", "changes-requested", "approved", "rejected", "live")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _json(path: Path) -> dict:
|
|
45
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise ValueError(f"{path.name} must be a JSON object")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def changelog_version(path: Path) -> str | None:
|
|
52
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
53
|
+
match = re.match(r"^#{1,3}\s*\[?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?", line)
|
|
54
|
+
if match:
|
|
55
|
+
return match.group(1)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def release_check(package_dir: Path, server_json: Path | None, changelog: Path | None, pack: bool) -> dict:
|
|
60
|
+
package_path = package_dir / "package.json"
|
|
61
|
+
if not package_path.is_file():
|
|
62
|
+
raise ValueError("package.json not found; pass --package <folder>")
|
|
63
|
+
package = _json(package_path)
|
|
64
|
+
errors: list[str] = []
|
|
65
|
+
warnings: list[str] = []
|
|
66
|
+
name, version = str(package.get("name", "")), str(package.get("version", ""))
|
|
67
|
+
if not re.fullmatch(r"(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*", name):
|
|
68
|
+
errors.append("package name is missing or invalid")
|
|
69
|
+
if not SEMVER.match(version):
|
|
70
|
+
errors.append("version is not semver")
|
|
71
|
+
if package.get("private") is True:
|
|
72
|
+
errors.append("package is private: true and cannot be published")
|
|
73
|
+
bins = package.get("bin", {})
|
|
74
|
+
if isinstance(bins, str):
|
|
75
|
+
bins = {name.split("/")[-1]: bins}
|
|
76
|
+
files = package.get("files")
|
|
77
|
+
if not isinstance(files, list) or not files:
|
|
78
|
+
warnings.append("no files whitelist; everything not in .npmignore is published")
|
|
79
|
+
files = None
|
|
80
|
+
for command, target in (bins or {}).items():
|
|
81
|
+
target_path = package_dir / str(target)
|
|
82
|
+
if not target_path.is_file():
|
|
83
|
+
warnings.append(f"bin {command} → {target} does not exist yet (build before publishing)")
|
|
84
|
+
elif not target_path.read_text(encoding="utf-8", errors="replace").startswith("#!"):
|
|
85
|
+
errors.append(f"bin {command} → {target} has no #! shebang; npx would not run it")
|
|
86
|
+
if files is not None and not any(str(target).lstrip("./").startswith(str(entry).rstrip("/").lstrip("./")) for entry in files):
|
|
87
|
+
errors.append(f"bin {command} → {target} is not covered by files")
|
|
88
|
+
if not package.get("license"):
|
|
89
|
+
errors.append("license is missing")
|
|
90
|
+
repository = package.get("repository")
|
|
91
|
+
if not repository:
|
|
92
|
+
warnings.append("repository is missing; npm provenance and the registry page link to it")
|
|
93
|
+
elif isinstance(repository, dict) and (package_dir / ".." / "..").resolve().joinpath("package.json").is_file() and not repository.get("directory"):
|
|
94
|
+
warnings.append("repository.directory is missing for a package inside a monorepo")
|
|
95
|
+
if not package.get("engines", {}).get("node"):
|
|
96
|
+
warnings.append("engines.node is missing")
|
|
97
|
+
if name.startswith("@") and package.get("publishConfig", {}).get("access") != "public":
|
|
98
|
+
errors.append("scoped package needs publishConfig.access public (or `npm publish --access public`)")
|
|
99
|
+
if not package.get("publishConfig", {}).get("provenance"):
|
|
100
|
+
warnings.append("publishConfig.provenance is not set; publish from CI with OIDC and --provenance so the registry shows where it was built")
|
|
101
|
+
if not (package_dir / "README.md").is_file():
|
|
102
|
+
errors.append("README.md is missing")
|
|
103
|
+
facts = {"name": name, "version": version, "bins": sorted(bins or {})}
|
|
104
|
+
if changelog:
|
|
105
|
+
top = changelog_version(changelog) if changelog.is_file() else None
|
|
106
|
+
facts["changelogVersion"] = top
|
|
107
|
+
if top != version:
|
|
108
|
+
errors.append(f"changelog top entry is {top}, package version is {version}")
|
|
109
|
+
mcp_name = package.get("mcpName")
|
|
110
|
+
if server_json:
|
|
111
|
+
server = _json(server_json)
|
|
112
|
+
facts["serverJson"] = {"name": server.get("name"), "version": server.get("version")}
|
|
113
|
+
if not mcp_name:
|
|
114
|
+
errors.append("package.json mcpName is required for the MCP registry to verify npm ownership")
|
|
115
|
+
elif server.get("name") != mcp_name:
|
|
116
|
+
errors.append(f"server.json name {server.get('name')!r} must equal package.json mcpName {mcp_name!r}")
|
|
117
|
+
if server.get("version") != version:
|
|
118
|
+
errors.append(f"server.json version {server.get('version')} differs from package version {version}")
|
|
119
|
+
packages = [item for item in server.get("packages", []) if isinstance(item, dict)]
|
|
120
|
+
npm = [item for item in packages if item.get("registryType", item.get("registry_type")) == "npm"]
|
|
121
|
+
if not npm:
|
|
122
|
+
errors.append("server.json lists no npm package")
|
|
123
|
+
for item in npm:
|
|
124
|
+
if item.get("identifier") != name:
|
|
125
|
+
errors.append(f"server.json package identifier {item.get('identifier')!r} differs from {name!r}")
|
|
126
|
+
if item.get("version") not in (None, version):
|
|
127
|
+
errors.append(f"server.json package version {item.get('version')} differs from {version}")
|
|
128
|
+
if mcp_name and not re.fullmatch(r"io\.github\.[A-Za-z0-9-]+/[A-Za-z0-9._-]+|[a-z0-9.-]+\.[a-z]{2,}/[A-Za-z0-9._-]+", str(mcp_name)):
|
|
129
|
+
warnings.append("mcpName should be a reverse-DNS namespace you can prove (io.github.<owner>/<name> or <domain>/<name>)")
|
|
130
|
+
if pack:
|
|
131
|
+
npm_bin = shutil.which("npm")
|
|
132
|
+
if not npm_bin:
|
|
133
|
+
errors.append("npm is required for --pack")
|
|
134
|
+
else:
|
|
135
|
+
completed = subprocess.run([npm_bin, "pack", "--dry-run", "--json", "--ignore-scripts"], cwd=package_dir, capture_output=True, text=True, check=False, timeout=180)
|
|
136
|
+
try:
|
|
137
|
+
listing = json.loads(completed.stdout or "[]")
|
|
138
|
+
packed = [entry["path"] for entry in listing[0].get("files", [])] if listing else []
|
|
139
|
+
except (json.JSONDecodeError, KeyError, IndexError):
|
|
140
|
+
packed = []
|
|
141
|
+
errors.append("npm pack --dry-run did not return a file list")
|
|
142
|
+
facts["packedFiles"] = len(packed)
|
|
143
|
+
for bad in [path for path in packed if SECRET_FILES.search(path)]:
|
|
144
|
+
errors.append(f"packed file looks like a secret: {bad}")
|
|
145
|
+
for command, target in (bins or {}).items():
|
|
146
|
+
if packed and str(target).lstrip("./") not in packed:
|
|
147
|
+
errors.append(f"bin {command} → {target} is not in the packed tarball")
|
|
148
|
+
return {"schemaVersion": "maggie-product-release-check.v1", "passed": not errors, "facts": facts, "errors": errors, "warnings": warnings, "published": False}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def smoke(package_dir: Path, timeout: int) -> dict:
|
|
152
|
+
npm_bin = shutil.which("npm")
|
|
153
|
+
if not npm_bin:
|
|
154
|
+
raise ValueError("npm is required for smoke")
|
|
155
|
+
package = _json(package_dir / "package.json")
|
|
156
|
+
version = str(package.get("version", ""))
|
|
157
|
+
bins = package.get("bin", {})
|
|
158
|
+
if isinstance(bins, str):
|
|
159
|
+
bins = {str(package.get("name", "")).split("/")[-1]: bins}
|
|
160
|
+
results, errors = [], []
|
|
161
|
+
with tempfile.TemporaryDirectory(prefix="maggie-product-smoke-") as directory:
|
|
162
|
+
work = Path(directory)
|
|
163
|
+
packed = subprocess.run([npm_bin, "pack", "--json", "--pack-destination", str(work)], cwd=package_dir, capture_output=True, text=True, check=False, timeout=600)
|
|
164
|
+
if packed.returncode:
|
|
165
|
+
raise ValueError("npm pack failed: " + packed.stderr[-300:])
|
|
166
|
+
tarball = work / json.loads(packed.stdout)[0]["filename"].split("/")[-1]
|
|
167
|
+
install_dir = work / "install"; install_dir.mkdir()
|
|
168
|
+
(install_dir / "package.json").write_text('{"name":"smoke","private":true}', encoding="utf-8")
|
|
169
|
+
installed = subprocess.run([npm_bin, "install", "--no-audit", "--no-fund", str(tarball)], cwd=install_dir, capture_output=True, text=True, check=False, timeout=timeout)
|
|
170
|
+
if installed.returncode:
|
|
171
|
+
raise ValueError("installing the packed tarball failed: " + installed.stderr[-300:])
|
|
172
|
+
for command in sorted(bins or {}):
|
|
173
|
+
executable = install_dir / "node_modules" / ".bin" / command
|
|
174
|
+
for flag in ("--version", "--help"):
|
|
175
|
+
run = subprocess.run([str(executable), flag], cwd=install_dir, capture_output=True, text=True, check=False, timeout=60)
|
|
176
|
+
ok = run.returncode == 0 and (flag != "--version" or version in run.stdout)
|
|
177
|
+
results.append({"bin": command, "flag": flag, "exitCode": run.returncode, "ok": ok})
|
|
178
|
+
if not ok:
|
|
179
|
+
errors.append(f"{command} {flag} failed or did not print {version}" if flag == "--version" else f"{command} {flag} exited {run.returncode}")
|
|
180
|
+
return {"schemaVersion": "maggie-product-smoke.v1", "passed": not errors and bool(results), "version": version, "results": results, "errors": errors or ([] if results else ["package declares no bin"])}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _get(url: str, method: str = "GET", body: bytes | None = None, headers: dict | None = None) -> tuple[int, dict, bytes]:
|
|
184
|
+
request = Request(url, data=body, method=method, headers={"User-Agent": "maggie-product-mcp-check/1", **(headers or {})})
|
|
185
|
+
try:
|
|
186
|
+
with urlopen(request, timeout=20) as response:
|
|
187
|
+
return response.status, dict(response.headers), response.read(200_000)
|
|
188
|
+
except HTTPError as error:
|
|
189
|
+
return error.code, dict(error.headers or {}), (error.read() or b"")[:200_000]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def mcp_check(mcp_url: str) -> dict:
|
|
193
|
+
parts = urlsplit(mcp_url)
|
|
194
|
+
if parts.scheme != "https" and parts.hostname not in {"localhost", "127.0.0.1"}:
|
|
195
|
+
raise ValueError("--mcp-url must be https (or localhost)")
|
|
196
|
+
origin = f"{parts.scheme}://{parts.netloc}"
|
|
197
|
+
errors, checks = [], {}
|
|
198
|
+
metadata = None
|
|
199
|
+
for candidate in (f"{origin}/.well-known/oauth-protected-resource{parts.path.rstrip('/')}", f"{origin}/.well-known/oauth-protected-resource"):
|
|
200
|
+
try:
|
|
201
|
+
status, _, body = _get(candidate)
|
|
202
|
+
except URLError as error:
|
|
203
|
+
raise ValueError(f"MCP host unreachable: {error.reason}") from error
|
|
204
|
+
checks[candidate.replace(origin, "")] = status
|
|
205
|
+
if status == 200:
|
|
206
|
+
try:
|
|
207
|
+
metadata = json.loads(body)
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
errors.append("protected-resource metadata is not JSON")
|
|
210
|
+
break
|
|
211
|
+
if metadata is None:
|
|
212
|
+
errors.append("no OAuth protected-resource metadata (RFC 9728) at /.well-known/oauth-protected-resource")
|
|
213
|
+
else:
|
|
214
|
+
if not str(metadata.get("resource", "")).startswith(origin):
|
|
215
|
+
errors.append("metadata resource must be the MCP server URL on this origin")
|
|
216
|
+
if not metadata.get("authorization_servers"):
|
|
217
|
+
errors.append("metadata authorization_servers is empty")
|
|
218
|
+
if "header" not in (metadata.get("bearer_methods_supported") or ["header"]):
|
|
219
|
+
errors.append("bearer_methods_supported must include header")
|
|
220
|
+
initialize = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "maggie-check", "version": "1"}}}).encode()
|
|
221
|
+
status, headers, _ = _get(mcp_url, "POST", initialize, {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"})
|
|
222
|
+
challenge = next((value for key, value in headers.items() if key.lower() == "www-authenticate"), "")
|
|
223
|
+
checks["unauthenticatedInitialize"] = status
|
|
224
|
+
if status != 401:
|
|
225
|
+
errors.append(f"unauthenticated initialize returned {status}; an OAuth-protected server answers 401")
|
|
226
|
+
elif "resource_metadata=" not in challenge:
|
|
227
|
+
errors.append("401 has no WWW-Authenticate Bearer resource_metadata=… pointer; clients cannot discover the authorization server")
|
|
228
|
+
return {"schemaVersion": "maggie-product-mcp-check.v1", "passed": not errors, "checks": checks, "errors": errors, "authenticatedCalls": False}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def review_status(reviews_path: Path, package_version: str | None, chatgpt_plugin: Path | None, today: date | None = None) -> dict:
|
|
232
|
+
data = _json(reviews_path)
|
|
233
|
+
today = today or datetime.now(timezone.utc).date()
|
|
234
|
+
errors, warnings, rows = [], [], []
|
|
235
|
+
for entry in data.get("reviews", []):
|
|
236
|
+
surface, state = entry.get("surface"), entry.get("status")
|
|
237
|
+
if surface not in REVIEW_SURFACES:
|
|
238
|
+
errors.append(f"unknown review surface {surface!r}"); continue
|
|
239
|
+
if state not in REVIEW_STATES:
|
|
240
|
+
errors.append(f"{surface}: unknown status {state!r}"); continue
|
|
241
|
+
row = {"surface": surface, "status": state, "version": entry.get("version")}
|
|
242
|
+
submitted = entry.get("submittedAt")
|
|
243
|
+
if state in {"submitted", "in-review"} and submitted:
|
|
244
|
+
days = (today - date.fromisoformat(str(submitted)[:10])).days
|
|
245
|
+
row["daysWaiting"] = days
|
|
246
|
+
if days > 14:
|
|
247
|
+
warnings.append(f"{surface}: {state} for {days} days; follow up or resubmit")
|
|
248
|
+
if state in {"changes-requested", "rejected"} and not entry.get("nextAction"):
|
|
249
|
+
errors.append(f"{surface}: {state} needs a nextAction")
|
|
250
|
+
if package_version and entry.get("version") and state in {"approved", "live"} and entry["version"] != package_version:
|
|
251
|
+
warnings.append(f"{surface}: approved/live version {entry['version']} differs from the release {package_version}")
|
|
252
|
+
rows.append(row)
|
|
253
|
+
if chatgpt_plugin:
|
|
254
|
+
plugin = _json(chatgpt_plugin)
|
|
255
|
+
cases = plugin.get("review", {}).get("test_cases", []) if isinstance(plugin.get("review"), dict) else []
|
|
256
|
+
positive = [case for case in cases if case.get("tools_triggered")]
|
|
257
|
+
negative = [case for case in cases if not case.get("tools_triggered")]
|
|
258
|
+
for case in cases:
|
|
259
|
+
missing = [key for key in ("description", "prompt", "expected_behavior") if not str(case.get(key, "")).strip()]
|
|
260
|
+
if missing:
|
|
261
|
+
errors.append(f"ChatGPT test case '{str(case.get('description', ''))[:40]}' misses {', '.join(missing)}")
|
|
262
|
+
if len(positive) < 5 or len(negative) < 3:
|
|
263
|
+
warnings.append(f"ChatGPT app review has {len(positive)} positive and {len(negative)} negative test cases; reviewers expect at least 5 and 3")
|
|
264
|
+
return {"schemaVersion": "maggie-product-review-status.v1", "passed": not errors, "reviews": rows, "errors": errors, "warnings": warnings}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main(argv: list[str] | None = None) -> int:
|
|
268
|
+
parser = argparse.ArgumentParser(prog="maggie product", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
269
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
270
|
+
check = sub.add_parser("release-check")
|
|
271
|
+
check.add_argument("--package", type=Path, default=Path("."), help="folder with package.json")
|
|
272
|
+
check.add_argument("--server-json", type=Path, help="MCP registry server.json")
|
|
273
|
+
check.add_argument("--changelog", type=Path)
|
|
274
|
+
check.add_argument("--pack", action="store_true", help="inspect `npm pack --dry-run` output")
|
|
275
|
+
smoke_parser = sub.add_parser("smoke")
|
|
276
|
+
smoke_parser.add_argument("--package", type=Path, default=Path("."))
|
|
277
|
+
smoke_parser.add_argument("--timeout", type=int, default=600)
|
|
278
|
+
mcp = sub.add_parser("mcp-check")
|
|
279
|
+
mcp.add_argument("--mcp-url", required=True)
|
|
280
|
+
review = sub.add_parser("review-status")
|
|
281
|
+
review.add_argument("--reviews", type=Path, required=True, help="maggie-product-reviews.v1 JSON")
|
|
282
|
+
review.add_argument("--package", type=Path)
|
|
283
|
+
review.add_argument("--chatgpt-plugin", type=Path, help="ChatGPT app plugin.json with review.test_cases")
|
|
284
|
+
args = parser.parse_args(argv)
|
|
285
|
+
try:
|
|
286
|
+
if args.command == "release-check":
|
|
287
|
+
report = release_check(args.package.resolve(), args.server_json, args.changelog, args.pack)
|
|
288
|
+
elif args.command == "smoke":
|
|
289
|
+
report = smoke(args.package.resolve(), args.timeout)
|
|
290
|
+
elif args.command == "mcp-check":
|
|
291
|
+
report = mcp_check(args.mcp_url)
|
|
292
|
+
else:
|
|
293
|
+
version = _json(args.package / "package.json").get("version") if args.package else None
|
|
294
|
+
report = review_status(args.reviews, version, args.chatgpt_plugin)
|
|
295
|
+
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
|
296
|
+
print(f"BLOCKED: maggie product {args.command}: {error}", file=sys.stderr)
|
|
297
|
+
return 2
|
|
298
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
299
|
+
return 0 if report["passed"] else 1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Preflight an app dev server before browser QA (``maggie qa app-preflight``).
|
|
3
|
+
|
|
4
|
+
Three failures made browser QA of an Expo / React Native Web app look like a
|
|
5
|
+
broken change or quietly damaged real data:
|
|
6
|
+
|
|
7
|
+
- a Metro/Expo dev server started with ``CI=1`` (or ``--no-watch``) never
|
|
8
|
+
rebuilds, so the browser keeps serving the old bundle;
|
|
9
|
+
- a dev server started from the monorepo root or the wrong ``apps/*`` folder
|
|
10
|
+
serves a different project;
|
|
11
|
+
- a signed-in SPA that rotates refresh tokens is revoked when two instances
|
|
12
|
+
(iframes, tabs, parallel viewports) refresh at once, and a local dev server
|
|
13
|
+
that talks to a production API turns QA clicks into production writes.
|
|
14
|
+
|
|
15
|
+
The command inspects running dev-server processes (Linux ``/proc``; other
|
|
16
|
+
systems fall back to ``ps``), the app's environment files and auth code, and
|
|
17
|
+
reports environment variable names and host classes only — never values,
|
|
18
|
+
tokens, or full URLs.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from urllib.parse import urlsplit
|
|
32
|
+
|
|
33
|
+
SCHEMA = "maggie-qa-app-preflight.v1"
|
|
34
|
+
DEV_SERVER = re.compile(r"(expo(?:/cli)?[^ ]*\s+start|@expo/cli.*\bstart\b|react-native\s+start|metro(?:\.js)?\s|node_modules/\.bin/metro|expo-router)", re.I)
|
|
35
|
+
API_KEY = re.compile(r"^(?:EXPO_PUBLIC_|NEXT_PUBLIC_|VITE_|PUBLIC_|REACT_APP_)?[A-Z0-9_]*(?:API|BACKEND|SERVER|GRAPHQL|SUPABASE)[A-Z0-9_]*(?:URL|BASE|ORIGIN|HOST|ENDPOINT)[A-Z0-9_]*$")
|
|
36
|
+
LOCAL_HOST = re.compile(r"^(?:localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|\[?::1\]?|[^.]+\.local|host\.docker\.internal)$", re.I)
|
|
37
|
+
NON_PROD_HOST = re.compile(r"(?:^|[.-])(?:dev|development|staging|stage|test|testing|qa|preview|sandbox|local)(?:[.-]|$)", re.I)
|
|
38
|
+
REFRESH = re.compile(r"refresh[_-]?token|refreshToken|grant_type\W+refresh_token", re.I)
|
|
39
|
+
ROTATION = re.compile(r"rotat|reuse|revok|one[-_ ]time", re.I)
|
|
40
|
+
SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs"}
|
|
41
|
+
SKIP_DIRS = {"node_modules", ".git", ".expo", "dist", "build", ".next", "ios", "android", ".maggie", "coverage", "web-build"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def list_processes() -> list[dict]:
|
|
45
|
+
"""Return dev-server-like processes with cmdline, cwd and CI flag (values never kept)."""
|
|
46
|
+
found: list[dict] = []
|
|
47
|
+
proc = Path("/proc")
|
|
48
|
+
if proc.is_dir():
|
|
49
|
+
for entry in proc.iterdir():
|
|
50
|
+
if not entry.name.isdigit():
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
cmdline = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace").strip()
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
if not DEV_SERVER.search(cmdline + " "):
|
|
57
|
+
continue
|
|
58
|
+
item = {"pid": int(entry.name), "cmdline": cmdline, "cwd": None, "ci": None}
|
|
59
|
+
try:
|
|
60
|
+
item["cwd"] = os.readlink(entry / "cwd")
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
env = dict(line.split("=", 1) for line in (entry / "environ").read_bytes().decode("utf-8", "replace").split("\0") if "=" in line)
|
|
65
|
+
item["ci"] = env.get("CI", "").lower() in {"1", "true", "yes"}
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
found.append(item)
|
|
69
|
+
return found
|
|
70
|
+
try: # macOS / BSD: `ps eww` shows the environment after the command for own processes
|
|
71
|
+
output = subprocess.run(["ps", "-Ewwo", "pid=,command="], capture_output=True, text=True, timeout=10, check=False).stdout
|
|
72
|
+
except (OSError, subprocess.SubprocessError):
|
|
73
|
+
return found
|
|
74
|
+
for line in output.splitlines():
|
|
75
|
+
pid, _, command = line.strip().partition(" ")
|
|
76
|
+
if pid.isdigit() and DEV_SERVER.search(command + " "):
|
|
77
|
+
found.append({"pid": int(pid), "cmdline": command, "cwd": None, "ci": bool(re.search(r"(?:^|\s)CI=(?:1|true|yes)(?:\s|$)", command, re.I))})
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_app_dirs(project: Path) -> list[Path]:
|
|
82
|
+
"""Expo apps in the project: the root itself, or monorepo apps/* and packages/*."""
|
|
83
|
+
candidates = [project] + sorted(path for pattern in ("apps/*", "packages/*") for path in project.glob(pattern) if path.is_dir())
|
|
84
|
+
apps = []
|
|
85
|
+
for path in candidates:
|
|
86
|
+
package = path / "package.json"
|
|
87
|
+
if (path / "app.json").is_file() or (path / "app.config.ts").is_file() or (path / "app.config.js").is_file():
|
|
88
|
+
try:
|
|
89
|
+
deps = json.loads(package.read_text(encoding="utf-8")) if package.is_file() else {}
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
deps = {}
|
|
92
|
+
names = {**deps.get("dependencies", {}), **deps.get("devDependencies", {})}
|
|
93
|
+
if "expo" in names or (path / "app.json").is_file():
|
|
94
|
+
apps.append(path)
|
|
95
|
+
return apps
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def classify_host(url: str) -> str:
|
|
99
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
100
|
+
if not host:
|
|
101
|
+
return "unknown"
|
|
102
|
+
if LOCAL_HOST.match(host):
|
|
103
|
+
return "local"
|
|
104
|
+
if NON_PROD_HOST.search(host):
|
|
105
|
+
return "non-production"
|
|
106
|
+
return "production-like"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def env_api_targets(app_dir: Path) -> list[dict]:
|
|
110
|
+
"""API base settings from env files and app.json extra; names and host class only."""
|
|
111
|
+
targets = []
|
|
112
|
+
for name in (".env", ".env.local", ".env.development", ".env.development.local"):
|
|
113
|
+
path = app_dir / name
|
|
114
|
+
if not path.is_file():
|
|
115
|
+
continue
|
|
116
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
117
|
+
key, sep, value = line.strip().partition("=")
|
|
118
|
+
key = key.removeprefix("export ").strip()
|
|
119
|
+
if sep and API_KEY.match(key):
|
|
120
|
+
value = value.strip().strip('"').strip("'")
|
|
121
|
+
if value.startswith(("http://", "https://")):
|
|
122
|
+
targets.append({"source": name, "key": key, "hostClass": classify_host(value)})
|
|
123
|
+
app_json = app_dir / "app.json"
|
|
124
|
+
if app_json.is_file():
|
|
125
|
+
try:
|
|
126
|
+
extra = json.loads(app_json.read_text(encoding="utf-8")).get("expo", {}).get("extra", {})
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
extra = {}
|
|
129
|
+
for key, value in (extra.items() if isinstance(extra, dict) else []):
|
|
130
|
+
if isinstance(value, str) and value.startswith(("http://", "https://")) and re.search(r"api|backend|server|base", key, re.I):
|
|
131
|
+
targets.append({"source": "app.json expo.extra", "key": key, "hostClass": classify_host(value)})
|
|
132
|
+
return targets
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def refresh_token_signals(app_dir: Path, limit: int = 4000) -> dict:
|
|
136
|
+
"""Look for refresh-token handling and rotation hints in the app's own source."""
|
|
137
|
+
files = refresh = rotation = 0
|
|
138
|
+
examples: list[str] = []
|
|
139
|
+
for root, dirs, names in os.walk(app_dir):
|
|
140
|
+
dirs[:] = [name for name in dirs if name not in SKIP_DIRS]
|
|
141
|
+
for name in names:
|
|
142
|
+
path = Path(root) / name
|
|
143
|
+
if path.suffix not in SOURCE_SUFFIXES:
|
|
144
|
+
continue
|
|
145
|
+
files += 1
|
|
146
|
+
if files > limit:
|
|
147
|
+
break
|
|
148
|
+
try:
|
|
149
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
150
|
+
except OSError:
|
|
151
|
+
continue
|
|
152
|
+
if REFRESH.search(text):
|
|
153
|
+
refresh += 1
|
|
154
|
+
if ROTATION.search(text):
|
|
155
|
+
rotation += 1
|
|
156
|
+
if len(examples) < 5:
|
|
157
|
+
examples.append(str(path.relative_to(app_dir)))
|
|
158
|
+
return {"filesScanned": files, "refreshTokenFiles": refresh, "rotationHintFiles": rotation, "examples": examples}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None) -> dict:
|
|
162
|
+
project = project.resolve()
|
|
163
|
+
errors: list[str] = []
|
|
164
|
+
warnings: list[str] = []
|
|
165
|
+
guidance: list[str] = []
|
|
166
|
+
apps = find_app_dirs(project)
|
|
167
|
+
if app_dir is None:
|
|
168
|
+
if len(apps) > 1:
|
|
169
|
+
errors.append("several app folders found (" + ", ".join(str(path.relative_to(project)) or "." for path in apps) + "); pass --app-dir so QA checks the right one")
|
|
170
|
+
app_dir = apps[0] if apps else project
|
|
171
|
+
app_dir = (project / app_dir).resolve() if not app_dir.is_absolute() else app_dir.resolve()
|
|
172
|
+
rel = lambda path: (str(Path(path).resolve().relative_to(project)) or ".") if Path(path).resolve().is_relative_to(project) else "outside the project"
|
|
173
|
+
servers = []
|
|
174
|
+
for item in (list_processes() if processes is None else processes):
|
|
175
|
+
cwd = item.get("cwd")
|
|
176
|
+
command = item.get("cmdline", "")
|
|
177
|
+
record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command}
|
|
178
|
+
if cwd and not Path(cwd).resolve().is_relative_to(project):
|
|
179
|
+
record["scope"] = "other project"
|
|
180
|
+
servers.append(record)
|
|
181
|
+
continue
|
|
182
|
+
if record["ciMode"]:
|
|
183
|
+
errors.append(f"dev server pid {record['pid']} was started with CI=1: Metro does not watch files, so edits never reach the browser; restart it without CI")
|
|
184
|
+
if record["noWatch"]:
|
|
185
|
+
errors.append(f"dev server pid {record['pid']} runs without file watching; restart it with watching enabled")
|
|
186
|
+
if cwd and Path(cwd).resolve() != app_dir:
|
|
187
|
+
errors.append(f"dev server pid {record['pid']} was started from {record['cwd']}, not {rel(app_dir)}: it serves a different project; restart it from the app folder")
|
|
188
|
+
servers.append(record)
|
|
189
|
+
in_project = [server for server in servers if server.get("scope") != "other project"]
|
|
190
|
+
if not in_project:
|
|
191
|
+
warnings.append("no running Metro/Expo dev server found for this project; start one from the app folder without CI=1")
|
|
192
|
+
signals = refresh_token_signals(app_dir)
|
|
193
|
+
rotating_tokens = rotating == "yes" or (rotating == "auto" and signals["refreshTokenFiles"] > 0)
|
|
194
|
+
if rotating_tokens:
|
|
195
|
+
warnings.append("the app stores refresh tokens; if they rotate, reusing one revokes the session")
|
|
196
|
+
guidance += [
|
|
197
|
+
"Render one app instance at a time: no parallel iframes, tabs or viewports of the signed-in app.",
|
|
198
|
+
"Park the parent/QA tab on a static same-origin asset (for example /favicon.ico) so it does not refresh the session while a viewport renders.",
|
|
199
|
+
"Collect responsive evidence with `maggie design app-collect`, which renders viewports sequentially in one tab.",
|
|
200
|
+
]
|
|
201
|
+
targets = env_api_targets(app_dir)
|
|
202
|
+
production = [target for target in targets if target["hostClass"] == "production-like"]
|
|
203
|
+
if production:
|
|
204
|
+
warnings.append("the local app talks to a production-like API (" + ", ".join(sorted({t["key"] for t in production})) + ")")
|
|
205
|
+
guidance += [
|
|
206
|
+
"Keep QA read-only: answer approval cards No, do not submit forms, and run `maggie design app-collect --read-only --allow-write <token refresh path>`.",
|
|
207
|
+
"Record the production-backed server in the QA run (`maggie qa start --api-base ... --production-api ...`) so mutating scenarios are refused.",
|
|
208
|
+
]
|
|
209
|
+
if base_url and classify_host(base_url) == "production-like":
|
|
210
|
+
warnings.append("--base-url is a production-like host; QA should target the local dev server")
|
|
211
|
+
return {
|
|
212
|
+
"schemaVersion": SCHEMA,
|
|
213
|
+
"appDir": rel(app_dir),
|
|
214
|
+
"appCandidates": [rel(path) for path in apps],
|
|
215
|
+
"devServers": servers,
|
|
216
|
+
"refreshTokens": {"rotating": rotating_tokens, "source": rotating, **signals},
|
|
217
|
+
"apiTargets": targets,
|
|
218
|
+
"status": "failed" if errors else ("warning" if warnings else "passed"),
|
|
219
|
+
"errors": errors,
|
|
220
|
+
"warnings": warnings,
|
|
221
|
+
"guidance": guidance,
|
|
222
|
+
"checkedAt": datetime.now(timezone.utc).isoformat(),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int:
|
|
227
|
+
parser = argparse.ArgumentParser(prog="maggie qa app-preflight", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
228
|
+
parser.add_argument("--project", type=Path, default=Path("."))
|
|
229
|
+
parser.add_argument("--app-dir", type=Path, help="app folder inside a monorepo, e.g. apps/app")
|
|
230
|
+
parser.add_argument("--base-url", help="URL the browser QA will open")
|
|
231
|
+
parser.add_argument("--rotating-refresh-tokens", choices=("auto", "yes", "no"), default="auto")
|
|
232
|
+
parser.add_argument("--output", type=Path)
|
|
233
|
+
args = parser.parse_args(argv)
|
|
234
|
+
try:
|
|
235
|
+
report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens)
|
|
236
|
+
except (OSError, ValueError) as error:
|
|
237
|
+
print(f"BLOCKED: maggie qa app-preflight: {error}", file=sys.stderr)
|
|
238
|
+
return 2
|
|
239
|
+
text = json.dumps(report, indent=2, ensure_ascii=False)
|
|
240
|
+
if args.output:
|
|
241
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
args.output.write_text(text + "\n", encoding="utf-8")
|
|
243
|
+
print(text)
|
|
244
|
+
return 1 if report["errors"] else 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
raise SystemExit(main())
|
|
@@ -615,6 +615,11 @@ def parser() -> argparse.ArgumentParser:
|
|
|
615
615
|
|
|
616
616
|
|
|
617
617
|
def main() -> int:
|
|
618
|
+
if "app-preflight" in sys.argv[1:3]:
|
|
619
|
+
from maggie_qa_app_preflight import main as app_preflight_main
|
|
620
|
+
argv = sys.argv[1:]
|
|
621
|
+
index = argv.index("app-preflight")
|
|
622
|
+
return app_preflight_main(argv[:index] + argv[index + 1:])
|
|
618
623
|
args = parser().parse_args()
|
|
619
624
|
try:
|
|
620
625
|
if args.command == "start": return start(args)
|