@topy-ai/maggie 0.7.52 → 0.7.53

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 (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -54
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +17 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +36 -6
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +9 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +75 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +136 -4
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. package/references/provider-adapter-contract.md +16 -3
@@ -459,6 +459,12 @@ def collect_batch(args: argparse.Namespace) -> int:
459
459
  return 0
460
460
 
461
461
 
462
+ AREA_SUBJECT_RE = re.compile(r"^([A-Za-z][A-Za-z0-9 /&._-]{0,30}):\s+(\S.*)$")
463
+ SCOPE_RE = re.compile(r"^[a-z]+\(([^)]{1,40})\)!?:", re.IGNORECASE)
464
+ SKILL_HINT_RE = re.compile(r"\b(?:maggie[\w-]*|skills?|cli|mcp|codex|claude)\b", re.IGNORECASE)
465
+ SUMMARY_ITEM_CAP = 49
466
+
467
+
462
468
  def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
463
469
  """Return (type, text) for one commit subject, or ``None`` to skip it."""
464
470
  merge = MERGE_PR_RE.match(subject)
@@ -474,22 +480,58 @@ def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
474
480
  return "bug", subject
475
481
  if PLAIN_FEATURE_RE.search(subject):
476
482
  return "improvement", subject
483
+ # "Area: sentence" is how many product teams describe a change; it is a
484
+ # change in that area even when the sentence has no recognised verb.
485
+ if AREA_SUBJECT_RE.match(subject):
486
+ return "improvement", subject
477
487
  return ("general", subject) if include_all else None
478
488
 
479
489
 
480
- def _summary_repos(values: list[str]) -> list[tuple[str, Path]]:
481
- """Parse ``--repo [LABEL=]PATH`` values; unlabeled repositories become repo1, repo2, ..."""
490
+ def subject_scope(subject: str) -> str:
491
+ """The conventional-commit scope or ``Area:`` prefix of a subject, lower-cased; ``""`` when absent."""
492
+ match = SCOPE_RE.match(subject)
493
+ if match:
494
+ return match.group(1).strip().lower()
495
+ if FIX_SUBJECT_RE.match(subject) or FEATURE_SUBJECT_RE.match(subject) or CHORE_SUBJECT_RE.match(subject):
496
+ return ""
497
+ area = AREA_SUBJECT_RE.match(subject)
498
+ return area.group(1).strip().lower() if area else ""
499
+
500
+
501
+ def _summary_repos(values: list[str], default_rev: str = "") -> list[tuple[str, Path, str]]:
502
+ """Parse ``--repo [LABEL=]PATH[@REV]`` values; unlabeled repositories become repo1, repo2, ..."""
482
503
  repos = []
483
504
  for index, value in enumerate(values or ["."], start=1):
484
505
  label, sep, path = value.partition("=")
485
506
  if not sep or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,40}", label):
486
507
  label, path = f"repo{index}", value
487
- repos.append((label, Path(path).expanduser().resolve()))
488
- if len({label for label, _ in repos}) != len(repos):
508
+ rev = default_rev
509
+ head, at, tail = path.rpartition("@")
510
+ if at and head and not Path(path).expanduser().exists():
511
+ path, rev = head, tail
512
+ if rev and (rev.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", rev)):
513
+ raise ValueError("revision must be a branch, tag or commit such as origin/main")
514
+ repos.append((label, Path(path).expanduser().resolve(), rev or "HEAD"))
515
+ if len({label for label, _, _ in repos}) != len(repos):
489
516
  raise ValueError("--repo labels must be unique")
490
517
  return repos
491
518
 
492
519
 
520
+ def _behind_upstream(repo: Path, rev: str) -> tuple[str, int] | None:
521
+ """When reading HEAD, report (upstream, commits HEAD lacks) from local refs; never fetches."""
522
+ if rev != "HEAD":
523
+ return None
524
+ upstream = subprocess.run(["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], capture_output=True, text=True, check=False)
525
+ if upstream.returncode or not upstream.stdout.strip():
526
+ return None
527
+ count = subprocess.run(["git", "-C", str(repo), "rev-list", "--count", "HEAD..@{u}"], capture_output=True, text=True, check=False)
528
+ try:
529
+ behind = int(count.stdout.strip() or 0)
530
+ except ValueError:
531
+ return None
532
+ return (upstream.stdout.strip(), behind) if behind else None
533
+
534
+
493
535
  def session_summary(args: argparse.Namespace) -> int:
494
536
  """Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
495
537
  since = str(args.since or "").strip()
@@ -503,32 +545,53 @@ def session_summary(args: argparse.Namespace) -> int:
503
545
  batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
504
546
  if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
505
547
  raise ValueError("batch ID is invalid")
506
- repos = _summary_repos(args.repo)
548
+ repos = _summary_repos(args.repo, str(getattr(args, "rev", "") or "").strip())
507
549
  include_all = bool(getattr(args, "all", False))
508
- items = []
550
+ skill_relevant = bool(getattr(args, "skill_relevant", False))
551
+ skill_hint = safe_text(args.skill).lower().removeprefix("maggie-")
552
+ candidates = []
509
553
  skipped = 0
554
+ project_commits = 0
555
+ seen = 0
510
556
  per_repo = {}
511
- for label, repo in repos:
557
+ warnings = []
558
+ for label, repo, rev in repos:
512
559
  command = ["git", "-C", str(repo), "log", "--format=%h%x1f%s"]
513
560
  if not getattr(args, "merges", False):
514
561
  command.append("--no-merges")
515
- command += [f"--since={since_date}", "HEAD"] if since_date else [f"{since}..HEAD"]
562
+ command += [f"--since={since_date}", rev] if since_date else [f"{since}..{rev}"]
516
563
  completed = subprocess.run(command, capture_output=True, text=True, check=False)
517
564
  if completed.returncode:
518
- raise ValueError(f"git log failed for {label}; check --repo and --since")
565
+ raise ValueError(f"git log failed for {label}; check --repo, --rev and --since")
566
+ lag = _behind_upstream(repo, rev)
567
+ if lag:
568
+ warnings.append(f"{label}: local HEAD is {lag[1]} commit(s) behind {lag[0]}; commits pushed from worktrees are missing. Re-run with --repo {label}=PATH@{lag[0]} (git fetch first).")
519
569
  counted = 0
520
570
  for line in completed.stdout.splitlines():
521
571
  sha, _, subject = line.partition("\x1f")
522
572
  subject = safe_text(subject)[:200]
523
573
  if not sha or not subject:
524
574
  continue
575
+ seen += 1
525
576
  classified = classify_subject(subject, include_all)
526
577
  if classified is None:
527
578
  skipped += 1
528
579
  continue
580
+ scope = subject_scope(subject)
581
+ if skill_relevant and not (SKILL_HINT_RE.search(subject) or (skill_hint and skill_hint in subject.lower())):
582
+ project_commits += 1
583
+ continue
529
584
  kind, text = classified
530
585
  text = text[:1].upper() + text[1:] if text else subject
531
586
  commit = f"{label}@{sha}" if len(repos) > 1 else sha
587
+ candidates.append({"kind": kind, "text": text, "commit": commit, "scope": scope or label})
588
+ counted += 1
589
+ per_repo[label] = counted
590
+ grouped = len(candidates) > SUMMARY_ITEM_CAP
591
+ items = []
592
+ if not grouped:
593
+ for candidate in candidates:
594
+ commit, text, kind = candidate["commit"], candidate["text"], candidate["kind"]
532
595
  item = {
533
596
  "type": kind,
534
597
  "summary": text,
@@ -540,10 +603,24 @@ def session_summary(args: argparse.Namespace) -> int:
540
603
  if kind in {"bug", "improvement"}:
541
604
  item.update({"fixed": True, "resolution": text})
542
605
  items.append(item)
543
- counted += 1
544
- if len(items) >= 49:
545
- break
546
- per_repo[label] = counted
606
+ else:
607
+ # Too many commits for one batch: one item per scope, so nothing is dropped silently.
608
+ scopes: dict[str, list[dict]] = {}
609
+ for candidate in candidates:
610
+ scopes.setdefault(candidate["scope"], []).append(candidate)
611
+ ordered = sorted(scopes.items(), key=lambda pair: (-len(pair[1]), pair[0]))
612
+ for scope, entries in ordered[:SUMMARY_ITEM_CAP]:
613
+ bugs = sum(1 for entry in entries if entry["kind"] == "bug")
614
+ items.append({
615
+ "type": "bug" if bugs * 2 > len(entries) else "improvement",
616
+ "summary": f"{scope}: {len(entries)} commit(s) ({bugs} fix) — keep only if a Maggie skill or CLI fell short",
617
+ "expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
618
+ "actual": f"{REVIEW_MARKER} summarise the {scope} commits that came from a skill gap",
619
+ "validation": f"{REVIEW_MARKER} the test or check that verified the {scope} commits",
620
+ "evidence": [f"commit:{entry['commit']}" for entry in entries[:20]],
621
+ })
622
+ if len(ordered) > SUMMARY_ITEM_CAP:
623
+ warnings.append(f"{len(ordered) - SUMMARY_ITEM_CAP} scope(s) did not fit in one batch; narrow --since-date or use --skill-relevant.")
547
624
  items.append({
548
625
  "type": "feature",
549
626
  "summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
@@ -557,6 +634,7 @@ def session_summary(args: argparse.Namespace) -> int:
557
634
  "phase": safe_text(args.phase) or "session-summary",
558
635
  "reviewRequired": True,
559
636
  "note": "Draft only. Remove items unrelated to Maggie skills or CLIs, replace every REVIEW: and delete the open-ask placeholder if unused. `maggie feedback batch` rejects unedited REVIEW: fields.",
637
+ "commits": {"seen": seen, "drafted": len(candidates), "skipped": skipped, "projectOnly": project_commits, "groupedByScope": grouped},
560
638
  "items": items,
561
639
  }
562
640
  project = Path(args.project).resolve()
@@ -567,7 +645,9 @@ def session_summary(args: argparse.Namespace) -> int:
567
645
  raise ValueError("output already exists; choose another --output or --batch-id")
568
646
  output.parent.mkdir(parents=True, exist_ok=True)
569
647
  output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
570
- print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "skippedCommits": skipped, "repositories": per_repo, "output": str(output), "submitted": False}, ensure_ascii=False))
648
+ if grouped:
649
+ warnings.append(f"{len(candidates)} commits exceed the {SUMMARY_ITEM_CAP}-item batch limit, so they were grouped into one item per scope.")
650
+ print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "commitsSeen": seen, "draftedCommits": len(candidates), "skippedCommits": skipped, "projectOnlyCommits": project_commits, "groupedByScope": grouped, "repositories": per_repo, "warnings": warnings, "output": str(output), "submitted": False}, ensure_ascii=False))
571
651
  return 0
572
652
 
573
653
 
@@ -716,7 +796,9 @@ def main() -> int:
716
796
  summary_parser.add_argument("--project", default=argparse.SUPPRESS)
717
797
  summary_parser.add_argument("--since", default="", help="git revision; commits after it are summarized")
718
798
  summary_parser.add_argument("--since-date", default="", help="ISO date/time; use across several repositories")
719
- summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH of a git repository; repeat for app, backend, edge (default: current directory)")
799
+ summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH[@REV] of a git repository; repeat for app, backend, edge (default: current directory at HEAD)")
800
+ summary_parser.add_argument("--rev", default="", help="revision to read in every repository without its own @REV, e.g. origin/main")
801
+ summary_parser.add_argument("--skill-relevant", action="store_true", help="keep only commits that mention Maggie, a skill, a CLI or MCP (or the --skill name); count the rest as project-only")
720
802
  summary_parser.add_argument("--all", action="store_true", help="also draft unclassified and chore subjects as general items")
721
803
  summary_parser.add_argument("--merges", action="store_true", help="include merge commits (PR branch names are classified)")
722
804
  summary_parser.add_argument("--batch-id", default="")
@@ -20,19 +20,32 @@ fails. It checks, per build profile and platform:
20
20
 
21
21
  ``plan`` prints the build / submit / update commands with a cost notice and
22
22
  never runs them. Nothing is uploaded and no credentials are read.
23
+
24
+ An OTA update must be fingerprinted with the config files the store build was
25
+ made from: ``eas.json`` and ``package.json`` usually changed since. ``plan
26
+ --build-ref <git ref>`` adds the swap steps, and ``ota --build-ref REF
27
+ --build-id ID`` performs them: put the build commit's copies in place, run
28
+ ``eas fingerprint:compare``, publish the update only when it matches, and
29
+ always put the working copies back (verified by hash), even on failure. ``ota``
30
+ is a dry run unless ``--confirm`` is given, because the update is published.
23
31
  """
24
32
 
25
33
  from __future__ import annotations
26
34
 
27
35
  import argparse
36
+ import hashlib
28
37
  import json
38
+ import os
29
39
  import re
40
+ import shlex
41
+ import subprocess
30
42
  import sys
31
43
  from datetime import datetime, timezone
32
44
  from pathlib import Path
33
45
 
34
46
  SCHEMA = "maggie-mobile-release-preflight.v1"
35
47
  PLATFORMS = ("ios", "android")
48
+ BUILD_CONFIG_FILES = ("eas.json", "package.json")
36
49
  VERSION_POLICIES = {"appVersion", "nativeVersion", "sdkVersion"}
37
50
 
38
51
 
@@ -185,7 +198,33 @@ def cost_notice(platforms: list[str], profile: str) -> str:
185
198
  "build minutes even if a later step fails. EAS Update publishes are counted against monthly update users. Confirm before running.")
186
199
 
187
200
 
188
- def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel: str | None, environment: str | None = None) -> list[dict]:
201
+ def git_root(path: Path) -> Path:
202
+ completed = subprocess.run(["git", "-C", str(path), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
203
+ if completed.returncode:
204
+ raise ValueError("the app folder is not inside a git repository; --build-ref needs git")
205
+ return Path(completed.stdout.strip()).resolve()
206
+
207
+
208
+ def build_config_drift(app: Path, build_ref: str) -> list[dict]:
209
+ """Config files whose working copy differs from the build commit's copy (git object paths, never contents)."""
210
+ if build_ref.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", build_ref):
211
+ raise ValueError("--build-ref must be a git revision such as a tag or commit")
212
+ root = git_root(app)
213
+ files = []
214
+ for name in BUILD_CONFIG_FILES:
215
+ path = app / name
216
+ rel = path.resolve().relative_to(root).as_posix()
217
+ shown = subprocess.run(["git", "-C", str(root), "show", f"{build_ref}:{rel}"], capture_output=True, check=False)
218
+ if shown.returncode:
219
+ if name == "eas.json":
220
+ raise ValueError(f"{rel} does not exist at {build_ref}; is that the commit the store build was made from?")
221
+ continue
222
+ current = path.read_bytes() if path.is_file() else b""
223
+ files.append({"file": rel, "differs": current != shown.stdout, "buildBytes": shown.stdout, "workingSha256": hashlib.sha256(current).hexdigest()})
224
+ return files
225
+
226
+
227
+ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel: str | None, environment: str | None = None, build_ref: str | None = None, drift: list[dict] | None = None) -> list[dict]:
189
228
  environment = environment or (profile if profile in {"development", "preview", "production"} else "production")
190
229
  platform_arg = "all" if len(platforms) == 2 else platforms[0]
191
230
  commands = [{"step": "preflight", "command": f"maggie mobile preflight --profile {profile} --platform {platform_arg}" + (" --auto-submit" if auto_submit else ""), "spends": False}]
@@ -194,16 +233,81 @@ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel
194
233
  if not auto_submit:
195
234
  for platform in platforms:
196
235
  commands.append({"step": f"submit-{platform}", "command": f"npx eas-cli submit -p {platform} --profile {profile} --latest --non-interactive", "spends": False})
236
+ swapped = [item["file"] for item in (drift or []) if item["differs"]]
237
+ if channel and build_ref:
238
+ commands.append({"step": "ota-from-build-config", "command": f"maggie mobile ota --profile {profile} --platform {platform_arg} --build-ref {build_ref} --build-id <installed build id> --message \"<what changed>\" --confirm", "spends": True,
239
+ "note": ("swaps " + ", ".join(swapped) + f" for the copies at {build_ref}, runs fingerprint:compare and the update, then restores the working copies" if swapped else f"the config files match {build_ref}; no swap needed")})
240
+ return commands
197
241
  if channel:
198
242
  commands.append({"step": "fingerprint-gate", "command": "npx eas-cli fingerprint:compare --build-id <installed build id>", "spends": False, "note": "must report no difference; editing eas.json, native config or dependencies after the build changes the fingerprint and the update would reach no installed build"})
199
243
  commands.append({"step": "ota-update", "command": f"npx eas-cli update --channel {channel} --environment {environment} --platform {platform_arg} --message \"<what changed>\" --non-interactive", "spends": True, "note": "only JS/asset changes; a native change needs a new build (the fingerprint policy enforces this)"})
200
244
  return commands
201
245
 
202
246
 
247
+ def eas_command() -> list[str]:
248
+ """The EAS CLI invocation; MAGGIE_MOBILE_EAS overrides it (tests, a pinned eas-cli)."""
249
+ return shlex.split(os.environ.get("MAGGIE_MOBILE_EAS", "npx eas-cli"))
250
+
251
+
252
+ def fingerprint_hashes(stdout: str) -> tuple[str, str] | None:
253
+ """(build hash, project hash) from `eas fingerprint:compare --json`, or None when the output is not that."""
254
+ start = stdout.find("{")
255
+ try:
256
+ data = json.loads(stdout[start:]) if start >= 0 else {}
257
+ return str(data["fingerprint1"]["hash"]), str(data["fingerprint2"]["hash"])
258
+ except (ValueError, KeyError, TypeError):
259
+ return None
260
+
261
+
262
+ def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str, platform_arg: str, message: str, confirm: bool) -> dict:
263
+ """Fingerprint and publish an update with the build's own config files; always restore the working copies."""
264
+ if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", build_id):
265
+ raise ValueError("--build-id must be the EAS build id of the installed build")
266
+ if not message.strip():
267
+ raise ValueError("--message is required")
268
+ drift = build_config_drift(app, build_ref)
269
+ root = git_root(app)
270
+ # fingerprint:compare exits 0 even when the fingerprints differ; --json prints both, so compare the hashes.
271
+ compare = eas_command() + ["fingerprint:compare", "--build-id", build_id, "--json", "--non-interactive"]
272
+ update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform_arg, "--message", message, "--non-interactive"]
273
+ report = {"buildRef": build_ref, "swapped": [item["file"] for item in drift if item["differs"]], "commands": [shlex.join(compare), shlex.join(update)], "executed": False}
274
+ if not confirm:
275
+ report["status"] = "dry-run"
276
+ report["note"] = "nothing was changed; re-run with --confirm to swap, compare, publish and restore"
277
+ return report
278
+ originals = {item["file"]: (root / item["file"]).read_bytes() for item in drift if item["differs"]}
279
+ steps = []
280
+ try:
281
+ for item in drift:
282
+ if item["differs"]:
283
+ (root / item["file"]).write_bytes(item["buildBytes"])
284
+ result = subprocess.run(compare, cwd=app, capture_output=True, text=True, check=False)
285
+ hashes = fingerprint_hashes(result.stdout)
286
+ steps.append({"step": "fingerprint-compare", "exit": result.returncode, "match": bool(hashes) and hashes[0] == hashes[1]})
287
+ if result.returncode or not hashes:
288
+ report.update(status="failed", reason="fingerprint:compare did not return two fingerprints; nothing was published")
289
+ return report
290
+ if hashes[0] != hashes[1]:
291
+ report.update(status="failed", reason="the fingerprint with the build's config files does not match the installed build; this change needs a new native build")
292
+ return report
293
+ result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
294
+ steps.append({"step": "update", "exit": result.returncode})
295
+ report["executed"] = True
296
+ report["status"] = "published" if result.returncode == 0 else "failed"
297
+ if result.returncode:
298
+ report["reason"] = "eas update failed; see its output"
299
+ return report
300
+ finally:
301
+ for rel, data in originals.items():
302
+ (root / rel).write_bytes(data)
303
+ report["restored"] = {rel: hashlib.sha256((root / rel).read_bytes()).hexdigest() == hashlib.sha256(data).hexdigest() for rel, data in originals.items()}
304
+ report["steps"] = steps
305
+
306
+
203
307
  def main(argv: list[str] | None = None) -> int:
204
308
  parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
205
309
  sub = parser.add_subparsers(dest="command", required=True)
206
- for name in ("preflight", "plan"):
310
+ for name in ("preflight", "plan", "ota"):
207
311
  command = sub.add_parser(name)
208
312
  command.add_argument("--project", type=Path, default=Path("."))
209
313
  command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
@@ -213,6 +317,12 @@ def main(argv: list[str] | None = None) -> int:
213
317
  command.add_argument("--auto-submit", action="store_true", help="the build will run with --auto-submit --non-interactive")
214
318
  command.add_argument("--environment", help="EAS environment for updates (default: the profile when it is development/preview/production)")
215
319
  command.add_argument("--output", type=Path)
320
+ if name in {"plan", "ota"}:
321
+ command.add_argument("--build-ref", help="git commit or tag the installed store build was made from")
322
+ if name == "ota":
323
+ command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
324
+ command.add_argument("--message", required=True)
325
+ command.add_argument("--confirm", action="store_true", help="actually swap, compare, publish and restore; without it nothing changes")
216
326
  args = parser.parse_args(argv)
217
327
  platforms = list(PLATFORMS) if args.platform == "all" else [args.platform]
218
328
  try:
@@ -220,14 +330,36 @@ def main(argv: list[str] | None = None) -> int:
220
330
  except (OSError, ValueError) as error:
221
331
  print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
222
332
  return 2
223
- if args.command == "plan":
224
- report = {"schemaVersion": "maggie-mobile-release-plan.v1", "preflight": {key: report[key] for key in ("status", "errors", "warnings")}, "commands": plan_commands(args.profile, platforms, args.auto_submit, report["facts"].get("channel") if report["facts"].get("easUpdate") else None, args.environment), "costNotice": report["costNotice"], "executed": False}
333
+ project = args.project.resolve()
334
+ app = (project / args.app_dir).resolve() if args.app_dir and not args.app_dir.is_absolute() else (args.app_dir.resolve() if args.app_dir else project)
335
+ channel = report["facts"].get("channel") if report["facts"].get("easUpdate") else None
336
+ environment = args.environment or (args.profile if args.profile in {"development", "preview", "production"} else "production")
337
+ platform_arg = "all" if len(platforms) == 2 else platforms[0]
338
+ try:
339
+ if args.command == "plan":
340
+ drift = build_config_drift(app, args.build_ref) if args.build_ref else None
341
+ report = {"schemaVersion": "maggie-mobile-release-plan.v1", "preflight": {key: report[key] for key in ("status", "errors", "warnings")}, "commands": plan_commands(args.profile, platforms, args.auto_submit, channel, args.environment, args.build_ref, drift), "costNotice": report["costNotice"], "executed": False}
342
+ if drift is not None:
343
+ report["buildConfig"] = [{"file": item["file"], "differsFromBuild": item["differs"]} for item in drift]
344
+ elif args.command == "ota":
345
+ if report["status"] == "failed":
346
+ raise ValueError("preflight failed: " + "; ".join(report["errors"]))
347
+ if not channel:
348
+ raise ValueError("the profile has no EAS Update channel")
349
+ if not args.build_ref:
350
+ raise ValueError("--build-ref is required: the commit the installed build was made from")
351
+ report = {"schemaVersion": "maggie-mobile-ota.v1", **ota(app, args.build_ref, args.build_id, channel, environment, platform_arg, args.message, args.confirm)}
352
+ except (OSError, ValueError) as error:
353
+ print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
354
+ return 2
225
355
  text = json.dumps(report, indent=2, ensure_ascii=False)
226
356
  if args.output:
227
357
  args.output.parent.mkdir(parents=True, exist_ok=True)
228
358
  args.output.write_text(text + "\n", encoding="utf-8")
229
359
  print(text)
230
360
  status = report["status"] if "status" in report else report["preflight"]["status"]
361
+ if report.get("restored") and not all(report["restored"].values()):
362
+ return 1
231
363
  return 1 if status == "failed" else 0
232
364
 
233
365
 
@@ -12,6 +12,13 @@ broken change or quietly damaged real data:
12
12
  (iframes, tabs, parallel viewports) refresh at once, and a local dev server
13
13
  that talks to a production API turns QA clicks into production writes.
14
14
 
15
+ Two more failures hid stale code: a dev server killed and replaced by another
16
+ session's server on the same port, and a watcher that kept serving the old
17
+ bundle after edits. ``--expect-symbol NAME`` fetches the served entry bundle
18
+ from ``--base-url`` and fails when it lacks a name from the edit just made;
19
+ the report also names the process holding the base URL's port and when it
20
+ started, so a server restarted by someone else is obvious.
21
+
15
22
  The command inspects running dev-server processes (Linux ``/proc``; other
16
23
  systems fall back to ``ps``), the app's environment files and auth code, and
17
24
  reports environment variable names and host classes only — never values,
@@ -28,7 +35,8 @@ import subprocess
28
35
  import sys
29
36
  from datetime import datetime, timezone
30
37
  from pathlib import Path
31
- from urllib.parse import urlsplit
38
+ from urllib.parse import urljoin, urlsplit
39
+ from urllib.request import Request, urlopen
32
40
 
33
41
  SCHEMA = "maggie-qa-app-preflight.v1"
34
42
  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)
@@ -78,6 +86,84 @@ def list_processes() -> list[dict]:
78
86
  return found
79
87
 
80
88
 
89
+ def process_started_at(pid: int) -> str | None:
90
+ """ISO start time of a Linux process from /proc/<pid>/stat and the boot time; None elsewhere."""
91
+ try:
92
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
93
+ ticks = int(stat.rsplit(")", 1)[1].split()[19])
94
+ boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines() if line.startswith("btime "))
95
+ return datetime.fromtimestamp(boot + ticks / os.sysconf("SC_CLK_TCK"), timezone.utc).isoformat(timespec="seconds")
96
+ except (OSError, ValueError, IndexError, StopIteration):
97
+ return None
98
+
99
+
100
+ def port_owner(port: int) -> dict | None:
101
+ """The process listening on a TCP port (Linux /proc); command line trimmed to the program and script."""
102
+ inodes = set()
103
+ for table in ("/proc/net/tcp", "/proc/net/tcp6"):
104
+ try:
105
+ lines = Path(table).read_text(encoding="utf-8").splitlines()[1:]
106
+ except OSError:
107
+ continue
108
+ for line in lines:
109
+ fields = line.split()
110
+ if len(fields) > 9 and fields[3] == "0A" and int(fields[1].rsplit(":", 1)[1], 16) == port:
111
+ inodes.add(fields[9])
112
+ if not inodes:
113
+ return None
114
+ for entry in Path("/proc").iterdir():
115
+ if not entry.name.isdigit():
116
+ continue
117
+ links = set()
118
+ try:
119
+ descriptors = list((entry / "fd").iterdir())
120
+ except OSError:
121
+ continue
122
+ for fd in descriptors:
123
+ try:
124
+ links.add(os.readlink(fd))
125
+ except OSError:
126
+ continue
127
+ if any(f"socket:[{inode}]" in links for inode in inodes):
128
+ try:
129
+ cmdline = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace").strip()
130
+ cwd = os.readlink(entry / "cwd")
131
+ except OSError:
132
+ cmdline, cwd = "", None
133
+ return {"pid": int(entry.name), "command": " ".join(cmdline.split()[:3])[:160], "cwd": cwd, "startedAt": process_started_at(int(entry.name))}
134
+ return {"pid": None, "command": "unknown (owned by another user)", "cwd": None, "startedAt": None}
135
+
136
+
137
+ SCRIPT_SRC = re.compile(r"<script[^>]+src=[\"']([^\"']+)[\"']", re.I)
138
+
139
+
140
+ def _fetch(url: str, limit: int) -> bytes:
141
+ request = Request(url, headers={"User-Agent": "maggie-qa-app-preflight"})
142
+ with urlopen(request, timeout=60) as response: # noqa: S310 - local dev server URL given by the operator
143
+ return response.read(limit)
144
+
145
+
146
+ def bundle_symbols(base_url: str, symbols: list[str], limit: int = 80_000_000) -> dict:
147
+ """Fetch the page at base_url and its same-origin scripts; report which expected names they contain."""
148
+ origin = urlsplit(base_url)
149
+ html = _fetch(base_url, 5_000_000).decode("utf-8", "replace")
150
+ scripts = []
151
+ for src in SCRIPT_SRC.findall(html):
152
+ url = urljoin(base_url, src)
153
+ if urlsplit(url).netloc == origin.netloc:
154
+ scripts.append(url)
155
+ found: dict[str, list[str]] = {symbol: [] for symbol in symbols}
156
+ fetched = []
157
+ for url in scripts[:12]:
158
+ body = _fetch(url, limit).decode("utf-8", "replace")
159
+ path = urlsplit(url).path
160
+ fetched.append({"path": path, "bytes": len(body)})
161
+ for symbol in symbols:
162
+ if symbol in body:
163
+ found[symbol].append(path)
164
+ return {"scripts": fetched, "found": {symbol: bool(paths) for symbol, paths in found.items()}, "foundIn": found}
165
+
166
+
81
167
  def find_app_dirs(project: Path) -> list[Path]:
82
168
  """Expo apps in the project: the root itself, or monorepo apps/* and packages/*."""
83
169
  candidates = [project] + sorted(path for pattern in ("apps/*", "packages/*") for path in project.glob(pattern) if path.is_dir())
@@ -158,7 +244,7 @@ def refresh_token_signals(app_dir: Path, limit: int = 4000) -> dict:
158
244
  return {"filesScanned": files, "refreshTokenFiles": refresh, "rotationHintFiles": rotation, "examples": examples}
159
245
 
160
246
 
161
- def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None) -> dict:
247
+ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None, expect_symbols: list[str] | None = None) -> dict:
162
248
  project = project.resolve()
163
249
  errors: list[str] = []
164
250
  warnings: list[str] = []
@@ -174,7 +260,7 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
174
260
  for item in (list_processes() if processes is None else processes):
175
261
  cwd = item.get("cwd")
176
262
  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}
263
+ record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command, "startedAt": item.get("startedAt") or (process_started_at(item["pid"]) if isinstance(item.get("pid"), int) else None)}
178
264
  if cwd and not Path(cwd).resolve().is_relative_to(project):
179
265
  record["scope"] = "other project"
180
266
  servers.append(record)
@@ -189,6 +275,35 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
189
275
  in_project = [server for server in servers if server.get("scope") != "other project"]
190
276
  if not in_project:
191
277
  warnings.append("no running Metro/Expo dev server found for this project; start one from the app folder without CI=1")
278
+ owner = None
279
+ if base_url:
280
+ parts = urlsplit(base_url)
281
+ port = parts.port or (443 if parts.scheme == "https" else 80)
282
+ if classify_host(base_url) == "local":
283
+ owner = port_owner(port)
284
+ if owner is None:
285
+ errors.append(f"nothing is listening on port {port}; the dev server was stopped or killed (check for exit 137) — start it from {rel(app_dir)}")
286
+ else:
287
+ owner = {**owner, "port": port, "cwd": rel(owner["cwd"]) if owner.get("cwd") else None}
288
+ known = {server["pid"] for server in in_project}
289
+ if owner["pid"] is not None and owner["pid"] not in known:
290
+ where = owner["cwd"] or "an unknown folder"
291
+ errors.append(f"port {port} is held by pid {owner['pid']} ({owner['command']}, started {owner['startedAt'] or 'unknown'}) from {where}, not by a dev server of {rel(app_dir)}; another session may have replaced it")
292
+ bundle = None
293
+ if expect_symbols:
294
+ if not base_url:
295
+ errors.append("--expect-symbol needs --base-url to fetch the served bundle")
296
+ else:
297
+ try:
298
+ bundle = bundle_symbols(base_url, expect_symbols)
299
+ except (OSError, ValueError) as error:
300
+ errors.append(f"could not fetch the served bundle from --base-url: {error}")
301
+ else:
302
+ if not bundle["scripts"]:
303
+ errors.append("the page at --base-url has no same-origin script, so the served bundle could not be checked")
304
+ missing = [symbol for symbol, ok in bundle["found"].items() if not ok]
305
+ if bundle["scripts"] and missing:
306
+ errors.append("the served bundle does not contain " + ", ".join(missing) + ": the dev server is serving old code (stale watcher, wrong folder or another session's server); restart it before QA")
192
307
  signals = refresh_token_signals(app_dir)
193
308
  rotating_tokens = rotating == "yes" or (rotating == "auto" and signals["refreshTokenFiles"] > 0)
194
309
  if rotating_tokens:
@@ -213,6 +328,8 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
213
328
  "appDir": rel(app_dir),
214
329
  "appCandidates": [rel(path) for path in apps],
215
330
  "devServers": servers,
331
+ "portOwner": owner,
332
+ "servedBundle": bundle,
216
333
  "refreshTokens": {"rotating": rotating_tokens, "source": rotating, **signals},
217
334
  "apiTargets": targets,
218
335
  "status": "failed" if errors else ("warning" if warnings else "passed"),
@@ -229,10 +346,11 @@ def main(argv: list[str] | None = None) -> int:
229
346
  parser.add_argument("--app-dir", type=Path, help="app folder inside a monorepo, e.g. apps/app")
230
347
  parser.add_argument("--base-url", help="URL the browser QA will open")
231
348
  parser.add_argument("--rotating-refresh-tokens", choices=("auto", "yes", "no"), default="auto")
349
+ parser.add_argument("--expect-symbol", action="append", default=[], metavar="NAME", help="a name from the edit just made (component, string, export); fails when the served bundle lacks it")
232
350
  parser.add_argument("--output", type=Path)
233
351
  args = parser.parse_args(argv)
234
352
  try:
235
- report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens)
353
+ report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens, expect_symbols=args.expect_symbol)
236
354
  except (OSError, ValueError) as error:
237
355
  print(f"BLOCKED: maggie qa app-preflight: {error}", file=sys.stderr)
238
356
  return 2