@topy-ai/maggie 0.7.49 → 0.7.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +3 -3
- package/README.md +58 -8
- package/bin/maggie.js +33 -14
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +19 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +50 -0
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +29 -0
- package/bundled-contracts/maggie-design/mobile-app-evidence-v1.schema.json +54 -0
- package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +137 -16
- package/bundled-skills/catalog.json +8 -0
- package/bundled-skills/maggie-assistant-tools/SKILL.md +93 -0
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +54 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +56 -0
- package/bundled-skills/maggie-assistant-tools/references/product-release.md +57 -0
- package/bundled-skills/maggie-deployment/SKILL.md +13 -0
- package/bundled-skills/maggie-design/SKILL.md +55 -21
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +66 -0
- package/bundled-skills/maggie-feedback/SKILL.md +20 -3
- package/bundled-skills/maggie-marketplace/SKILL.md +28 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +122 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +30 -0
- package/bundled-tools/clis/maggie_app_collect.py +236 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +302 -0
- package/bundled-tools/clis/maggie_bridge_host.py +212 -0
- package/bundled-tools/clis/maggie_design.py +190 -29
- package/bundled-tools/clis/maggie_feedback.py +89 -37
- package/bundled-tools/clis/maggie_mobile.py +221 -0
- package/bundled-tools/clis/maggie_product_release.py +303 -0
- package/bundled-tools/clis/maggie_qa_app_preflight.py +248 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +262 -0
- package/bundled-tools/runtime/assistant_approval.py +111 -0
- package/bundled-tools/runtime/cdp_client.py +246 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
459
|
-
|
|
460
|
-
if
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if not
|
|
477
|
-
|
|
478
|
-
if
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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",
|
|
669
|
-
summary_parser.add_argument("--
|
|
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())
|