@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.
Files changed (33) hide show
  1. package/README-zh-TW.md +3 -3
  2. package/README.md +58 -8
  3. package/bin/maggie.js +33 -14
  4. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +19 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +50 -0
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +29 -0
  7. package/bundled-contracts/maggie-design/mobile-app-evidence-v1.schema.json +54 -0
  8. package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +137 -16
  9. package/bundled-skills/catalog.json +8 -0
  10. package/bundled-skills/maggie-assistant-tools/SKILL.md +93 -0
  11. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +54 -0
  12. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +56 -0
  13. package/bundled-skills/maggie-assistant-tools/references/product-release.md +57 -0
  14. package/bundled-skills/maggie-deployment/SKILL.md +13 -0
  15. package/bundled-skills/maggie-design/SKILL.md +55 -21
  16. package/bundled-skills/maggie-design/references/app-ui-patterns.md +66 -0
  17. package/bundled-skills/maggie-feedback/SKILL.md +20 -3
  18. package/bundled-skills/maggie-marketplace/SKILL.md +28 -0
  19. package/bundled-skills/maggie-mobile-app/SKILL.md +122 -0
  20. package/bundled-skills/maggie-qa-workflow/SKILL.md +30 -0
  21. package/bundled-tools/clis/maggie_app_collect.py +236 -0
  22. package/bundled-tools/clis/maggie_assistant_tools.py +302 -0
  23. package/bundled-tools/clis/maggie_bridge_host.py +212 -0
  24. package/bundled-tools/clis/maggie_design.py +190 -29
  25. package/bundled-tools/clis/maggie_feedback.py +89 -37
  26. package/bundled-tools/clis/maggie_mobile.py +221 -0
  27. package/bundled-tools/clis/maggie_product_release.py +303 -0
  28. package/bundled-tools/clis/maggie_qa_app_preflight.py +248 -0
  29. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  30. package/bundled-tools/clis/maggie_skill_catalog.py +262 -0
  31. package/bundled-tools/runtime/assistant_approval.py +111 -0
  32. package/bundled-tools/runtime/cdp_client.py +246 -0
  33. 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)