@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
@@ -50,6 +50,12 @@ HOSTED_TYPE_FALLBACK = {"improvement": "feature"}
50
50
  REVIEW_MARKER = "REVIEW:"
51
51
  FIX_SUBJECT_RE = re.compile(r"^(?:fix|bugfix|hotfix)(?:\([^)]*\))?!?:\s*|^(?:fix|fixes|fixed)\b\s*", re.IGNORECASE)
52
52
  FEATURE_SUBJECT_RE = re.compile(r"^(?:feat|perf|refactor)(?:\([^)]*\))?!?:\s*", re.IGNORECASE)
53
+ CHORE_SUBJECT_RE = re.compile(r"^(?:docs|chore|test|tests|ci|style|build|release|wip)(?:\([^)]*\))?!?:\s*", re.IGNORECASE)
54
+ # Plain-sentence subjects ("Inbox: usage hero replaces the greeting") carry no prefix; classify by keywords.
55
+ PLAIN_BUG_RE = re.compile(r"\b(?:fix(?:es|ed)?|bug|broken|crash(?:es|ed)?|regression|revert(?:s|ed)?|repair(?:s|ed)?|prevent(?:s|ed)?|no longer|stop(?:s|ped)? \w+ing|correct(?:s|ed)?|handle(?:s|d)? (?:missing|empty|null|error))\b", re.IGNORECASE)
56
+ PLAIN_FEATURE_RE = re.compile(r"\b(?:add(?:s|ed)?|new|support(?:s|ed)?|introduc(?:e|es|ed)|implement(?:s|ed)?|enable(?:s|d)?|allow(?:s|ed)?|improve(?:s|d)?|replace(?:s|d)?|redesign(?:s|ed)?|refactor(?:s|ed)?|faster|speed(?:s)? up|show(?:s|n)?|hide(?:s)?|move(?:s|d)?|rename(?:s|d)?|polish(?:es|ed)?|simplif(?:y|ies|ied)|update(?:s|d)?|switch(?:es|ed)?|use(?:s)?)\b", re.IGNORECASE)
57
+ PLAIN_CHORE_RE = re.compile(r"^(?:bump|release|version|merge branch|wip\b|docs?\b|readme|changelog|format|lint|typo)", re.IGNORECASE)
58
+ MERGE_PR_RE = re.compile(r"^Merge pull request #\d+ from [^/\s]+/(\S+)", re.IGNORECASE)
53
59
 
54
60
 
55
61
  def now() -> str:
@@ -453,48 +459,91 @@ def collect_batch(args: argparse.Namespace) -> int:
453
459
  return 0
454
460
 
455
461
 
462
+ def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
463
+ """Return (type, text) for one commit subject, or ``None`` to skip it."""
464
+ merge = MERGE_PR_RE.match(subject)
465
+ if merge:
466
+ subject = merge.group(1).replace("-", " ").replace("_", " ").replace("/", ": ", 1)
467
+ if FIX_SUBJECT_RE.match(subject):
468
+ return "bug", FIX_SUBJECT_RE.sub("", subject, count=1)
469
+ if FEATURE_SUBJECT_RE.match(subject):
470
+ return "improvement", FEATURE_SUBJECT_RE.sub("", subject, count=1)
471
+ if CHORE_SUBJECT_RE.match(subject) or PLAIN_CHORE_RE.match(subject) or subject.lower().startswith("merge "):
472
+ return ("general", subject) if include_all else None
473
+ if PLAIN_BUG_RE.search(subject):
474
+ return "bug", subject
475
+ if PLAIN_FEATURE_RE.search(subject):
476
+ return "improvement", subject
477
+ return ("general", subject) if include_all else None
478
+
479
+
480
+ def _summary_repos(values: list[str]) -> list[tuple[str, Path]]:
481
+ """Parse ``--repo [LABEL=]PATH`` values; unlabeled repositories become repo1, repo2, ..."""
482
+ repos = []
483
+ for index, value in enumerate(values or ["."], start=1):
484
+ label, sep, path = value.partition("=")
485
+ if not sep or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,40}", label):
486
+ label, path = f"repo{index}", value
487
+ repos.append((label, Path(path).expanduser().resolve()))
488
+ if len({label for label, _ in repos}) != len(repos):
489
+ raise ValueError("--repo labels must be unique")
490
+ return repos
491
+
492
+
456
493
  def session_summary(args: argparse.Namespace) -> int:
457
494
  """Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
458
- repo = Path(args.repo).expanduser().resolve()
459
- since = str(args.since).strip()
460
- if not since or since.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", since):
495
+ since = str(args.since or "").strip()
496
+ since_date = str(getattr(args, "since_date", "") or "").strip()
497
+ if bool(since) == bool(since_date):
498
+ raise ValueError("pass exactly one of --since REV or --since-date YYYY-MM-DD[THH:MM]")
499
+ if since and (since.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", since)):
461
500
  raise ValueError("--since must be a git revision such as a tag, branch, or commit")
501
+ if since_date and not re.fullmatch(r"\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?)?", since_date):
502
+ raise ValueError("--since-date must look like 2026-10-12 or 2026-10-12T08:00")
462
503
  batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
463
504
  if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
464
505
  raise ValueError("batch ID is invalid")
465
- completed = subprocess.run(
466
- ["git", "-C", str(repo), "log", "--no-merges", "--format=%h%x1f%s", f"{since}..HEAD"],
467
- capture_output=True, text=True, check=False,
468
- )
469
- if completed.returncode:
470
- raise ValueError("git log failed; check --repo and --since")
506
+ repos = _summary_repos(args.repo)
507
+ include_all = bool(getattr(args, "all", False))
471
508
  items = []
472
509
  skipped = 0
473
- for line in completed.stdout.splitlines():
474
- sha, _, subject = line.partition("\x1f")
475
- subject = safe_text(subject)[:200]
476
- if not sha or not subject:
477
- continue
478
- if FIX_SUBJECT_RE.match(subject):
479
- kind, text = "bug", FIX_SUBJECT_RE.sub("", subject, count=1)
480
- elif FEATURE_SUBJECT_RE.match(subject):
481
- kind, text = "improvement", FEATURE_SUBJECT_RE.sub("", subject, count=1)
482
- else:
483
- skipped += 1
484
- continue
485
- text = text[:1].upper() + text[1:] if text else subject
486
- items.append({
487
- "type": kind,
488
- "summary": text,
489
- "expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
490
- "actual": f"{REVIEW_MARKER} what happened before commit {sha}",
491
- "fixed": True,
492
- "resolution": text,
493
- "validation": f"{REVIEW_MARKER} the test or check that verified commit {sha}",
494
- "evidence": [f"commit:{sha}"],
495
- })
496
- if len(items) >= 49:
497
- break
510
+ per_repo = {}
511
+ for label, repo in repos:
512
+ command = ["git", "-C", str(repo), "log", "--format=%h%x1f%s"]
513
+ if not getattr(args, "merges", False):
514
+ command.append("--no-merges")
515
+ command += [f"--since={since_date}", "HEAD"] if since_date else [f"{since}..HEAD"]
516
+ completed = subprocess.run(command, capture_output=True, text=True, check=False)
517
+ if completed.returncode:
518
+ raise ValueError(f"git log failed for {label}; check --repo and --since")
519
+ counted = 0
520
+ for line in completed.stdout.splitlines():
521
+ sha, _, subject = line.partition("\x1f")
522
+ subject = safe_text(subject)[:200]
523
+ if not sha or not subject:
524
+ continue
525
+ classified = classify_subject(subject, include_all)
526
+ if classified is None:
527
+ skipped += 1
528
+ continue
529
+ kind, text = classified
530
+ text = text[:1].upper() + text[1:] if text else subject
531
+ commit = f"{label}@{sha}" if len(repos) > 1 else sha
532
+ item = {
533
+ "type": kind,
534
+ "summary": text,
535
+ "expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
536
+ "actual": f"{REVIEW_MARKER} what happened before commit {commit}",
537
+ "validation": f"{REVIEW_MARKER} the test or check that verified commit {commit}",
538
+ "evidence": [f"commit:{commit}"],
539
+ }
540
+ if kind in {"bug", "improvement"}:
541
+ item.update({"fixed": True, "resolution": text})
542
+ items.append(item)
543
+ counted += 1
544
+ if len(items) >= 49:
545
+ break
546
+ per_repo[label] = counted
498
547
  items.append({
499
548
  "type": "feature",
500
549
  "summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
@@ -518,7 +567,7 @@ def session_summary(args: argparse.Namespace) -> int:
518
567
  raise ValueError("output already exists; choose another --output or --batch-id")
519
568
  output.parent.mkdir(parents=True, exist_ok=True)
520
569
  output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
521
- print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "skippedCommits": skipped, "output": str(output), "submitted": False}, ensure_ascii=False))
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))
522
571
  return 0
523
572
 
524
573
 
@@ -665,8 +714,11 @@ def main() -> int:
665
714
  batch_parser.add_argument("--batch-file", required=True, help="batch JSON file, or - to read JSON from stdin")
666
715
  summary_parser = sub.add_parser("session-summary", help="draft batch items from git commit subjects for review")
667
716
  summary_parser.add_argument("--project", default=argparse.SUPPRESS)
668
- summary_parser.add_argument("--since", required=True, help="git revision; commits after it are summarized")
669
- summary_parser.add_argument("--repo", default=".", help="git repository to read (default: current directory)")
717
+ summary_parser.add_argument("--since", default="", help="git revision; commits after it are summarized")
718
+ 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)")
720
+ summary_parser.add_argument("--all", action="store_true", help="also draft unclassified and chore subjects as general items")
721
+ summary_parser.add_argument("--merges", action="store_true", help="include merge commits (PR branch names are classified)")
670
722
  summary_parser.add_argument("--batch-id", default="")
671
723
  summary_parser.add_argument("--skill", default="")
672
724
  summary_parser.add_argument("--run-id", default="")
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """Expo / EAS release preflight for the Maggie mobile app lane (``maggie mobile``).
3
+
4
+ ``preflight`` reads ``app.json`` (or ``npx expo config --json`` output for a
5
+ dynamic ``app.config.*``), ``eas.json`` and ``package.json`` and fails before
6
+ a build is queued — builds spend EAS quota even when the later submission
7
+ fails. It checks, per build profile and platform:
8
+
9
+ - identifiers (iOS bundle identifier, Android package);
10
+ - non-interactive ``--auto-submit``: iOS needs ``submit.<profile>.ios.ascAppId``
11
+ (otherwise the build is queued and the submission exits 1), Android needs a
12
+ service-account key path;
13
+ - EAS Update: an update URL, a channel on the build profile, and a runtime
14
+ version policy. ``appVersion``/``nativeVersion`` with
15
+ ``cli.appVersionSource: remote`` and a version string that never changes
16
+ lets every future binary accept any JS update, including one that needs new
17
+ native code — ``fingerprint`` is recommended;
18
+ - export compliance (``usesNonExemptEncryption``) and app.json formatting
19
+ that ``eas update:configure`` would rewrite.
20
+
21
+ ``plan`` prints the build / submit / update commands with a cost notice and
22
+ never runs them. Nothing is uploaded and no credentials are read.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import re
30
+ import sys
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+
34
+ SCHEMA = "maggie-mobile-release-preflight.v1"
35
+ PLATFORMS = ("ios", "android")
36
+ VERSION_POLICIES = {"appVersion", "nativeVersion", "sdkVersion"}
37
+
38
+
39
+ def _load(path: Path) -> dict:
40
+ try:
41
+ value = json.loads(path.read_text(encoding="utf-8"))
42
+ except json.JSONDecodeError as error:
43
+ raise ValueError(f"{path.name} is not valid JSON: {error.msg}") from error
44
+ if not isinstance(value, dict):
45
+ raise ValueError(f"{path.name} must be a JSON object")
46
+ return value
47
+
48
+
49
+ def load_expo_config(app_dir: Path, config_json: Path | None) -> tuple[dict, str, str | None]:
50
+ """Return (expo config, source label, raw app.json text when static)."""
51
+ if config_json:
52
+ data = _load(config_json)
53
+ return data.get("expo", data), "expo config --json", None
54
+ app_json = app_dir / "app.json"
55
+ if app_json.is_file():
56
+ raw = app_json.read_text(encoding="utf-8")
57
+ data = _load(app_json)
58
+ return data.get("expo", data), "app.json", raw
59
+ if any((app_dir / name).is_file() for name in ("app.config.ts", "app.config.js", "app.config.mjs")):
60
+ raise ValueError("dynamic app.config.* found: run `npx expo config --json --type public > expo-config.json` and pass --config-json expo-config.json")
61
+ raise ValueError("no app.json or app.config.* in the app folder; pass --app-dir")
62
+
63
+
64
+ def runtime_policy(expo: dict, platform: str) -> tuple[object, str]:
65
+ platform_value = expo.get(platform, {}).get("runtimeVersion") if isinstance(expo.get(platform), dict) else None
66
+ if platform_value is not None:
67
+ return platform_value, f"expo.{platform}.runtimeVersion"
68
+ return expo.get("runtimeVersion"), "expo.runtimeVersion"
69
+
70
+
71
+ def check_profile(expo: dict, eas: dict, package: dict, profile: str, platforms: list[str], auto_submit: bool, raw_app_json: str | None) -> tuple[list[str], list[str], dict]:
72
+ errors: list[str] = []
73
+ warnings: list[str] = []
74
+ facts: dict = {"profile": profile, "platforms": platforms}
75
+ builds = eas.get("build", {}) if isinstance(eas.get("build"), dict) else {}
76
+ build = builds.get(profile)
77
+ if not isinstance(build, dict):
78
+ errors.append(f"eas.json has no build profile {profile!r}")
79
+ build = {}
80
+ extends = build.get("extends")
81
+ if isinstance(extends, str) and isinstance(builds.get(extends), dict):
82
+ build = {**builds[extends], **build}
83
+ cli = eas.get("cli", {}) if isinstance(eas.get("cli"), dict) else {}
84
+ version_source = cli.get("appVersionSource", "local")
85
+ auto_increment = bool(build.get("autoIncrement"))
86
+ facts.update({"appVersionSource": version_source, "autoIncrement": auto_increment, "version": expo.get("version"), "channel": build.get("channel")})
87
+ if "version" not in cli:
88
+ warnings.append("eas.json cli.version is not pinned; pin a minimum eas-cli version (e.g. \">= 16.0.0\") so CI and local builds agree")
89
+ if version_source == "local" and auto_increment:
90
+ warnings.append("autoIncrement with appVersionSource local edits app.json on every build; prefer appVersionSource remote")
91
+
92
+ deps = {**package.get("dependencies", {}), **package.get("devDependencies", {})}
93
+ updates = expo.get("updates", {}) if isinstance(expo.get("updates"), dict) else {}
94
+ uses_updates = "expo-updates" in deps or bool(updates.get("url"))
95
+ facts["easUpdate"] = uses_updates
96
+ if uses_updates:
97
+ if not str(updates.get("url", "")).startswith("https://u.expo.dev/"):
98
+ errors.append("expo-updates is installed but expo.updates.url is not an EAS Update URL")
99
+ if not build.get("channel"):
100
+ errors.append(f"build profile {profile!r} has no channel; binaries built from it cannot receive EAS Update")
101
+ for platform in platforms:
102
+ policy, where = runtime_policy(expo, platform)
103
+ name = policy.get("policy") if isinstance(policy, dict) else None
104
+ facts.setdefault("runtimeVersion", {})[platform] = name or (policy if isinstance(policy, str) else None)
105
+ if policy is None:
106
+ errors.append(f"{platform}: no runtimeVersion; set {{\"policy\": \"fingerprint\"}} at expo.runtimeVersion")
107
+ elif name in VERSION_POLICIES and version_source == "remote":
108
+ errors.append(f"{platform}: {where} uses policy {name} while build numbers increment remotely and expo.version stays {expo.get('version')!r}; every future binary would accept any JS update, including one that needs new native code. Use {{\"policy\": \"fingerprint\"}}")
109
+ elif name in VERSION_POLICIES:
110
+ warnings.append(f"{platform}: {where} policy {name} relies on bumping the version for every native change; fingerprint is safer")
111
+ elif isinstance(policy, str):
112
+ warnings.append(f"{platform}: {where} is a fixed string; you must change it by hand whenever native code changes")
113
+ elif name not in {"fingerprint", None}:
114
+ warnings.append(f"{platform}: unknown runtimeVersion policy {name!r}")
115
+ if where.startswith(f"expo.{platform}") and expo.get("runtimeVersion") is None:
116
+ warnings.append(f"{platform}: runtimeVersion is set only under expo.{platform} (as `eas update:configure` writes it); keep one top-level policy so platforms cannot drift")
117
+
118
+ for platform in platforms:
119
+ section = expo.get(platform, {}) if isinstance(expo.get(platform), dict) else {}
120
+ if platform == "ios":
121
+ if not section.get("bundleIdentifier"):
122
+ errors.append("ios: expo.ios.bundleIdentifier is missing")
123
+ encryption = section.get("config", {}).get("usesNonExemptEncryption") if isinstance(section.get("config"), dict) else None
124
+ plist = section.get("infoPlist", {}).get("ITSAppUsesNonExemptEncryption") if isinstance(section.get("infoPlist"), dict) else None
125
+ if encryption is None and plist is None:
126
+ warnings.append("ios: declare expo.ios.config.usesNonExemptEncryption so App Store Connect stops asking export-compliance questions on every build")
127
+ if platform == "android" and not section.get("package"):
128
+ errors.append("android: expo.android.package is missing")
129
+
130
+ submit = eas.get("submit", {}).get(profile, {}) if isinstance(eas.get("submit"), dict) else {}
131
+ if auto_submit:
132
+ if not isinstance(submit, dict) or not submit:
133
+ errors.append(f"--auto-submit needs eas.json submit.{profile}; a non-interactive build would be queued (spending quota) and the submission would fail")
134
+ submit = {}
135
+ if "ios" in platforms and not str(submit.get("ios", {}).get("ascAppId", "")).strip():
136
+ errors.append(f"ios: set submit.{profile}.ios.ascAppId (the numeric App Store Connect app ID); without it a non-interactive --auto-submit build is queued and then exits with 'Set ascAppId in the submit profile'")
137
+ if "android" in platforms:
138
+ android = submit.get("android", {}) if isinstance(submit.get("android"), dict) else {}
139
+ if not android.get("serviceAccountKeyPath"):
140
+ warnings.append(f"android: submit.{profile}.android.serviceAccountKeyPath is not set; provide it or EXPO_ANDROID_SERVICE_ACCOUNT_KEY_PATH, and keep the key file out of git")
141
+ if android.get("track", "internal") not in {"internal", "alpha", "beta", "production"}:
142
+ errors.append("android: submit track must be internal, alpha, beta or production")
143
+ if raw_app_json and "\t" in raw_app_json:
144
+ warnings.append("app.json is tab-indented; `eas update:configure` rewrites it with spaces (a large diff) — prefer editing runtimeVersion and updates by hand")
145
+ return errors, warnings, facts
146
+
147
+
148
+ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, profile: str, platforms: list[str], auto_submit: bool) -> dict:
149
+ project = project.resolve()
150
+ app = (project / app_dir).resolve() if app_dir and not app_dir.is_absolute() else (app_dir.resolve() if app_dir else project)
151
+ expo, source, raw = load_expo_config(app, config_json)
152
+ eas_path = app / "eas.json"
153
+ if not eas_path.is_file():
154
+ raise ValueError("eas.json not found; run `npx eas-cli build:configure` once, then review the file")
155
+ eas = _load(eas_path)
156
+ package = _load(app / "package.json") if (app / "package.json").is_file() else {}
157
+ errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw)
158
+ return {
159
+ "schemaVersion": SCHEMA,
160
+ "appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
161
+ "configSource": source,
162
+ "facts": facts,
163
+ "status": "failed" if errors else ("warning" if warnings else "passed"),
164
+ "errors": errors,
165
+ "warnings": warnings,
166
+ "costNotice": cost_notice(platforms, profile),
167
+ "checkedAt": datetime.now(timezone.utc).isoformat(),
168
+ }
169
+
170
+
171
+ def cost_notice(platforms: list[str], profile: str) -> str:
172
+ return (f"`eas build --profile {profile}` queues {len(platforms)} cloud build(s) ({', '.join(platforms)}); each uses EAS build quota or paid "
173
+ "build minutes even if a later step fails. EAS Update publishes are counted against monthly update users. Confirm before running.")
174
+
175
+
176
+ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel: str | None) -> list[dict]:
177
+ platform_arg = "all" if len(platforms) == 2 else platforms[0]
178
+ commands = [{"step": "preflight", "command": f"maggie mobile preflight --profile {profile} --platform {platform_arg}" + (" --auto-submit" if auto_submit else ""), "spends": False}]
179
+ build = f"npx eas-cli build -p {platform_arg} --profile {profile} --non-interactive" + (" --auto-submit" if auto_submit else "")
180
+ commands.append({"step": "build", "command": build, "spends": True})
181
+ if not auto_submit:
182
+ for platform in platforms:
183
+ commands.append({"step": f"submit-{platform}", "command": f"npx eas-cli submit -p {platform} --profile {profile} --latest --non-interactive", "spends": False})
184
+ if channel:
185
+ environment = f" --environment {profile}" if profile in {"development", "preview", "production"} else ""
186
+ commands.append({"step": "ota-update", "command": f"npx eas-cli update --channel {channel}{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)"})
187
+ return commands
188
+
189
+
190
+ def main(argv: list[str] | None = None) -> int:
191
+ parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
192
+ sub = parser.add_subparsers(dest="command", required=True)
193
+ for name in ("preflight", "plan"):
194
+ command = sub.add_parser(name)
195
+ command.add_argument("--project", type=Path, default=Path("."))
196
+ command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
197
+ command.add_argument("--config-json", type=Path, help="output of `npx expo config --json --type public` for app.config.*")
198
+ command.add_argument("--profile", default="production")
199
+ command.add_argument("--platform", choices=("ios", "android", "all"), default="all")
200
+ command.add_argument("--auto-submit", action="store_true", help="the build will run with --auto-submit --non-interactive")
201
+ command.add_argument("--output", type=Path)
202
+ args = parser.parse_args(argv)
203
+ platforms = list(PLATFORMS) if args.platform == "all" else [args.platform]
204
+ try:
205
+ report = preflight(args.project, args.app_dir, args.config_json, args.profile, platforms, args.auto_submit)
206
+ except (OSError, ValueError) as error:
207
+ print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
208
+ return 2
209
+ if args.command == "plan":
210
+ 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), "costNotice": report["costNotice"], "executed": False}
211
+ text = json.dumps(report, indent=2, ensure_ascii=False)
212
+ if args.output:
213
+ args.output.parent.mkdir(parents=True, exist_ok=True)
214
+ args.output.write_text(text + "\n", encoding="utf-8")
215
+ print(text)
216
+ status = report["status"] if "status" in report else report["preflight"]["status"]
217
+ return 1 if status == "failed" else 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ raise SystemExit(main())