@topy-ai/maggie 0.7.51 → 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.
- package/README-zh-TW.md +48 -2
- package/README.md +66 -45
- package/bin/maggie.js +18 -7
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-booking/SKILL.md +47 -3
- package/bundled-skills/maggie-deployment/SKILL.md +21 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +30 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +17 -0
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +52 -5
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +26 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +15 -4
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
- package/bundled-tools/clis/maggie_design.py +11 -2
- package/bundled-tools/clis/maggie_design_lint.py +142 -3
- package/bundled-tools/clis/maggie_feedback.py +97 -15
- package/bundled-tools/clis/maggie_mobile.py +154 -8
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +52 -6
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
|
@@ -7,11 +7,21 @@ pressable in JSX/TSX. React Native Web renders ``Pressable`` and the
|
|
|
7
7
|
remove control becomes ``<button>`` inside ``<button>``: invalid DOM, and taps
|
|
8
8
|
land on the wrong control.
|
|
9
9
|
|
|
10
|
-
``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities
|
|
10
|
+
``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities,
|
|
11
|
+
and warns about ``width: max-content`` (or ``w-max``) strips that can push a
|
|
12
|
+
390 px page sideways. ``textinput-check`` flags React Native Web multiline
|
|
13
|
+
inputs without a height and grow-only auto-grow.
|
|
11
14
|
Tailwind v4 emits utilities inside ``@layer utilities``; any unlayered rule
|
|
12
15
|
wins over every layer regardless of specificity, so an unlayered
|
|
13
16
|
``h1 { font-family }`` beats ``font-*`` and ``.ph { display: inline-block }``
|
|
14
17
|
beats ``hidden`` and ``group-open:inline``.
|
|
18
|
+
|
|
19
|
+
``safe-area-check`` flags ``<SafeAreaView>`` used as the inset source inside an
|
|
20
|
+
Expo Router modal route (``presentation: "transparentModal"``, ``"modal"``,
|
|
21
|
+
``"fullScreenModal"``, ...) or a React Native ``<Modal>``. On iOS those
|
|
22
|
+
surfaces can measure a 0 top inset, so the header and Close button sit under
|
|
23
|
+
the notch or Dynamic Island; web screenshots cannot show it. Read insets with
|
|
24
|
+
``useSafeAreaInsets()`` from the root ``SafeAreaProvider`` instead.
|
|
15
25
|
"""
|
|
16
26
|
|
|
17
27
|
from __future__ import annotations
|
|
@@ -232,12 +242,41 @@ def _rule_findings(source: str, start: int, end: int, layered: bool, display: st
|
|
|
232
242
|
index = close + 1
|
|
233
243
|
|
|
234
244
|
|
|
245
|
+
MAX_CONTENT_RULE = re.compile(r"([^{}]+)\{([^{}]*\b(?:min-)?width\s*:\s*max-content[^{}]*)\}", re.I)
|
|
246
|
+
MAX_CONTENT_CLASS = re.compile(r"class(?:Name)?\s*=\s*[\"'`{]([^\"'`}]*\bw-max\b[^\"'`}]*)", re.I)
|
|
247
|
+
OVERFLOW_GUARD = re.compile(r"\b(?:max-width|overflow(?:-x)?)\s*:", re.I)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def overflow_findings(project: Path, css_sources: dict[Path, str], markup: list[Path]) -> list[dict[str, object]]:
|
|
251
|
+
"""width: max-content on a tab strip or toolbar makes a phone page scroll sideways unless it can shrink or scroll."""
|
|
252
|
+
findings: list[dict[str, object]] = []
|
|
253
|
+
for path, source in css_sources.items():
|
|
254
|
+
for match in MAX_CONTENT_RULE.finditer(source):
|
|
255
|
+
body = match.group(2)
|
|
256
|
+
if OVERFLOW_GUARD.search(body):
|
|
257
|
+
continue
|
|
258
|
+
selector = " ".join(match.group(1).split())[-80:]
|
|
259
|
+
findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start(2)) + 1, "selector": selector, "kind": "max-content", "severity": "warning",
|
|
260
|
+
"message": "width: max-content without max-width/overflow can push a 390 px page sideways; give the strip overflow-x: auto inside a min-width: 0 parent, or max-width: 100%"})
|
|
261
|
+
for path in markup:
|
|
262
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
263
|
+
for match in MAX_CONTENT_CLASS.finditer(source):
|
|
264
|
+
classes = match.group(1)
|
|
265
|
+
if re.search(r"\b(?:overflow-x-auto|overflow-auto|overflow-x-scroll|max-w-[\w\[\]%-]+)\b", classes):
|
|
266
|
+
continue
|
|
267
|
+
findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start()) + 1, "selector": classes.strip()[:80], "kind": "max-content", "severity": "warning",
|
|
268
|
+
"message": "w-max without overflow-x-auto or a max-w-* can make the page scroll sideways at 390 px"})
|
|
269
|
+
return findings
|
|
270
|
+
|
|
271
|
+
|
|
235
272
|
def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, object]:
|
|
236
273
|
files = iter_files(paths or [project], (".css", ".pcss", ".postcss"))
|
|
237
274
|
sources = {path.resolve(): _strip_css_comments(path.read_text(encoding="utf-8", errors="replace")) for path in files}
|
|
275
|
+
markup = iter_files(paths or [project], (".tsx", ".jsx", ".astro", ".vue", ".svelte", ".html"))
|
|
276
|
+
overflow = overflow_findings(project, sources, markup)
|
|
238
277
|
tailwind_v4 = force_v4 or any(TAILWIND_V4_IMPORT.search(source) for source in sources.values())
|
|
239
278
|
if not tailwind_v4:
|
|
240
|
-
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings":
|
|
279
|
+
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": overflow, "warnings": len(overflow)}
|
|
241
280
|
layered_files: set[Path] = set()
|
|
242
281
|
for path, source in sources.items():
|
|
243
282
|
for target in LAYERED_IMPORT.findall(source):
|
|
@@ -245,10 +284,100 @@ def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, o
|
|
|
245
284
|
findings: list[dict[str, object]] = []
|
|
246
285
|
for path, source in sources.items():
|
|
247
286
|
_rule_findings(source, 0, len(source), path in layered_files, _display(path, project), findings)
|
|
287
|
+
findings += overflow
|
|
248
288
|
errors = [item for item in findings if item["severity"] == "error"]
|
|
249
289
|
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": not errors, "status": "checked", "checkedFiles": len(files), "errors": len(errors), "warnings": len(findings) - len(errors), "findings": findings}
|
|
250
290
|
|
|
251
291
|
|
|
292
|
+
MODAL_PRESENTATIONS = ("transparentModal", "containedTransparentModal", "modal", "containedModal", "fullScreenModal", "formSheet")
|
|
293
|
+
SAFE_AREA_ALLOW = "maggie-allow-safe-area-view"
|
|
294
|
+
SCREEN_TAG = re.compile(r"<(?:Stack|Tabs|Drawer)\.Screen\b(?:[^<>]|=>|\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})*?/?>", re.S)
|
|
295
|
+
PRESENTATION = re.compile(r"presentation\s*:\s*[\"'](" + "|".join(MODAL_PRESENTATIONS) + r")[\"']")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _route_file(layout: Path, name: str) -> Path | None:
|
|
299
|
+
for candidate in (layout.parent / f"{name}.tsx", layout.parent / f"{name}.jsx", layout.parent / name / "index.tsx", layout.parent / name / "index.jsx"):
|
|
300
|
+
if candidate.is_file():
|
|
301
|
+
return candidate
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _line(source: str, index: int) -> int:
|
|
306
|
+
return source.count("\n", 0, index) + 1
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def safe_area_check(project: Path, paths: list[Path]) -> dict[str, object]:
|
|
310
|
+
project = project.resolve()
|
|
311
|
+
files = iter_files(paths or [project], (".tsx", ".jsx"))
|
|
312
|
+
modal_routes: dict[Path, str] = {}
|
|
313
|
+
for path in files:
|
|
314
|
+
source = _strip_jsx_comments(path.read_text(encoding="utf-8", errors="replace"))
|
|
315
|
+
for tag in SCREEN_TAG.finditer(source):
|
|
316
|
+
presentation = PRESENTATION.search(tag.group(0))
|
|
317
|
+
if not presentation:
|
|
318
|
+
continue
|
|
319
|
+
name = re.search(r"\bname\s*=\s*[\"']([^\"']+)[\"']", tag.group(0))
|
|
320
|
+
target = _route_file(path, name.group(1)) if name and path.stem == "_layout" else (path if not name else None)
|
|
321
|
+
if target:
|
|
322
|
+
modal_routes[target.resolve()] = presentation.group(1)
|
|
323
|
+
findings: list[dict[str, object]] = []
|
|
324
|
+
for path in files:
|
|
325
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
326
|
+
if SAFE_AREA_ALLOW in raw or "SafeAreaView" not in raw:
|
|
327
|
+
continue
|
|
328
|
+
source = _strip_jsx_comments(raw)
|
|
329
|
+
display = _display(path, project)
|
|
330
|
+
presentation = modal_routes.get(path.resolve())
|
|
331
|
+
if presentation:
|
|
332
|
+
match = re.search(r"<SafeAreaView\b", source)
|
|
333
|
+
if match:
|
|
334
|
+
findings.append({"file": display, "line": _line(source, match.start()), "kind": "modal-route", "presentation": presentation, "message": f"route presented as {presentation} uses <SafeAreaView>; on iOS it can measure a 0 top inset, so pad with useSafeAreaInsets() instead"})
|
|
335
|
+
continue
|
|
336
|
+
if re.search(r"import\s*\{[^}]*\bModal\b[^}]*\}\s*from\s*[\"']react-native[\"']", source):
|
|
337
|
+
for modal in re.finditer(r"<Modal\b", source):
|
|
338
|
+
end = source.find("</Modal>", modal.end())
|
|
339
|
+
inner = source[modal.end(): end if end >= 0 else len(source)]
|
|
340
|
+
safe = re.search(r"<SafeAreaView\b", inner)
|
|
341
|
+
if safe:
|
|
342
|
+
findings.append({"file": display, "line": _line(source, modal.end() + safe.start()), "kind": "rn-modal", "message": "<SafeAreaView> inside a React Native <Modal> can report a 0 top inset on iOS; use useSafeAreaInsets() padding"})
|
|
343
|
+
return {"schemaVersion": "maggie-design-safe-area-check.v1", "passed": not findings, "checkedFiles": len(files), "modalRoutes": sorted(_display(path, project) for path in modal_routes), "findings": findings,
|
|
344
|
+
"recommendation": "Wrap the app once in SafeAreaProvider; in modals read const insets = useSafeAreaInsets() and pad the header/footer. Verify on a notched iPhone (or simulator) — web screenshots cannot show the inset."}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
TEXTINPUT_TAG = re.compile(r"<TextInput\b(?:[^<>]|=>|\{[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[^{}]*)*\})*?/?>", re.S)
|
|
348
|
+
TEXTINPUT_ALLOW = "maggie-allow-textinput"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def textinput_check(project: Path, paths: list[Path]) -> dict[str, object]:
|
|
352
|
+
"""React Native Web renders a multiline TextInput as a 2-row textarea; flag inputs without a height and grow-only auto-grow."""
|
|
353
|
+
project = project.resolve()
|
|
354
|
+
files = iter_files(paths or [project], (".tsx", ".jsx"))
|
|
355
|
+
findings: list[dict[str, object]] = []
|
|
356
|
+
for path in files:
|
|
357
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
358
|
+
if "TextInput" not in raw or TEXTINPUT_ALLOW in raw:
|
|
359
|
+
continue
|
|
360
|
+
source = _strip_jsx_comments(raw)
|
|
361
|
+
display = _display(path, project)
|
|
362
|
+
for tag in TEXTINPUT_TAG.finditer(source):
|
|
363
|
+
text = tag.group(0)
|
|
364
|
+
if not re.search(r"\bmultiline\b(?!\s*=\s*\{\s*false\s*\})", text):
|
|
365
|
+
continue
|
|
366
|
+
line = _line(source, tag.start())
|
|
367
|
+
sized = re.search(r"\bnumberOfLines\s*=|\brows\s*=|\b(?:height|minHeight)\s*:", text)
|
|
368
|
+
style_ref = re.search(r"\bstyle\s*=\s*\{\s*(?:\[)?\s*(?:styles|s)\.(\w+)", text)
|
|
369
|
+
if not sized and style_ref and re.search(rf"\b{style_ref.group(1)}\s*:\s*\{{[^{{}}]*\b(?:height|minHeight)\s*:", source):
|
|
370
|
+
sized = True
|
|
371
|
+
if not sized:
|
|
372
|
+
findings.append({"file": display, "line": line, "kind": "multiline-without-height", "severity": "error",
|
|
373
|
+
"message": "multiline TextInput without numberOfLines or an explicit height renders a 2-row textarea on web (placeholder high, buttons misaligned); set one line of height and grow by measuring"})
|
|
374
|
+
if "onContentSizeChange" in text and "scrollHeight" not in source:
|
|
375
|
+
findings.append({"file": display, "line": line, "kind": "grow-only-autogrow", "severity": "warning",
|
|
376
|
+
"message": "onContentSizeChange never reports a smaller size on web, so this composer grows but never shrinks; on web collapse to the minimum, read scrollHeight, then set the height"})
|
|
377
|
+
errors = [item for item in findings if item["severity"] == "error"]
|
|
378
|
+
return {"schemaVersion": "maggie-design-textinput-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings}
|
|
379
|
+
|
|
380
|
+
|
|
252
381
|
def main(argv: list[str] | None = None) -> int:
|
|
253
382
|
parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
|
|
254
383
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -260,9 +389,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
260
389
|
layers.add_argument("--project", type=Path, default=Path.cwd())
|
|
261
390
|
layers.add_argument("--css", type=Path, action="append", default=[], help="CSS file or directory to scan; defaults to the project")
|
|
262
391
|
layers.add_argument("--tailwind-v4", action="store_true", help="check even when no @import \"tailwindcss\" entry is found")
|
|
392
|
+
safe = sub.add_parser("safe-area-check", help="flag SafeAreaView inside Expo Router modal routes and React Native Modal (.tsx/.jsx)")
|
|
393
|
+
safe.add_argument("--project", type=Path, default=Path.cwd())
|
|
394
|
+
safe.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
395
|
+
textinput = sub.add_parser("textinput-check", help="flag multiline TextInput without a height and grow-only auto-grow on React Native Web (.tsx/.jsx)")
|
|
396
|
+
textinput.add_argument("--project", type=Path, default=Path.cwd())
|
|
397
|
+
textinput.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
263
398
|
args = parser.parse_args(argv)
|
|
264
399
|
try:
|
|
265
|
-
if args.command == "
|
|
400
|
+
if args.command == "textinput-check":
|
|
401
|
+
report = textinput_check(args.project, args.path)
|
|
402
|
+
elif args.command == "safe-area-check":
|
|
403
|
+
report = safe_area_check(args.project, args.path)
|
|
404
|
+
elif args.command == "pressable-check":
|
|
266
405
|
report = pressable_check(args.project, args.path, args.pressable)
|
|
267
406
|
else:
|
|
268
407
|
report = layer_check(args.project, args.css, args.tailwind_v4)
|
|
@@ -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
|
|
481
|
-
"""
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}",
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -68,7 +81,7 @@ def runtime_policy(expo: dict, platform: str) -> tuple[object, str]:
|
|
|
68
81
|
return expo.get("runtimeVersion"), "expo.runtimeVersion"
|
|
69
82
|
|
|
70
83
|
|
|
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]:
|
|
84
|
+
def check_profile(expo: dict, eas: dict, package: dict, profile: str, platforms: list[str], auto_submit: bool, raw_app_json: str | None, app_dir_ignore_text: str = "") -> tuple[list[str], list[str], dict]:
|
|
72
85
|
errors: list[str] = []
|
|
73
86
|
warnings: list[str] = []
|
|
74
87
|
facts: dict = {"profile": profile, "platforms": platforms}
|
|
@@ -140,6 +153,17 @@ def check_profile(expo: dict, eas: dict, package: dict, profile: str, platforms:
|
|
|
140
153
|
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
154
|
if android.get("track", "internal") not in {"internal", "alpha", "beta", "production"}:
|
|
142
155
|
errors.append("android: submit track must be internal, alpha, beta or production")
|
|
156
|
+
for script_name, command in (package.get("scripts", {}) or {}).items():
|
|
157
|
+
text = str(command)
|
|
158
|
+
if re.search(r"\beas(?:-cli)?\s+update\b(?!:)", text):
|
|
159
|
+
if "--non-interactive" in text and "--environment" not in text:
|
|
160
|
+
errors.append(f"package.json script {script_name!r} runs `eas update --non-interactive` without --environment; EAS refuses it ('The --environment flag must be set'). Add --environment matching the channel")
|
|
161
|
+
if "fingerprint:compare" not in text and uses_updates:
|
|
162
|
+
warnings.append(f"package.json script {script_name!r} publishes an update without `eas fingerprint:compare --build-id <build>`; a fingerprint mismatch sends the update to a runtime no installed build has")
|
|
163
|
+
if uses_updates and any(isinstance(runtime_policy(expo, p)[0], dict) and runtime_policy(expo, p)[0].get("policy") == "fingerprint" for p in platforms):
|
|
164
|
+
ignore = app_dir_ignore_text
|
|
165
|
+
if "eas.json" not in ignore.split():
|
|
166
|
+
warnings.append("runtime policy fingerprint includes eas.json: editing eas.json after a build (for example adding ascAppId) changes the fingerprint and silently stops OTA updates reaching that build. Add eas.json to .fingerprintignore with the next native build, and run `eas fingerprint:compare --build-id <build>` before every update")
|
|
143
167
|
if raw_app_json and "\t" in raw_app_json:
|
|
144
168
|
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
169
|
return errors, warnings, facts
|
|
@@ -154,7 +178,8 @@ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, pro
|
|
|
154
178
|
raise ValueError("eas.json not found; run `npx eas-cli build:configure` once, then review the file")
|
|
155
179
|
eas = _load(eas_path)
|
|
156
180
|
package = _load(app / "package.json") if (app / "package.json").is_file() else {}
|
|
157
|
-
|
|
181
|
+
ignore = (app / ".fingerprintignore").read_text(encoding="utf-8") if (app / ".fingerprintignore").is_file() else ""
|
|
182
|
+
errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw, ignore)
|
|
158
183
|
return {
|
|
159
184
|
"schemaVersion": SCHEMA,
|
|
160
185
|
"appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
|
|
@@ -173,7 +198,34 @@ def cost_notice(platforms: list[str], profile: str) -> str:
|
|
|
173
198
|
"build minutes even if a later step fails. EAS Update publishes are counted against monthly update users. Confirm before running.")
|
|
174
199
|
|
|
175
200
|
|
|
176
|
-
def
|
|
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]:
|
|
228
|
+
environment = environment or (profile if profile in {"development", "preview", "production"} else "production")
|
|
177
229
|
platform_arg = "all" if len(platforms) == 2 else platforms[0]
|
|
178
230
|
commands = [{"step": "preflight", "command": f"maggie mobile preflight --profile {profile} --platform {platform_arg}" + (" --auto-submit" if auto_submit else ""), "spends": False}]
|
|
179
231
|
build = f"npx eas-cli build -p {platform_arg} --profile {profile} --non-interactive" + (" --auto-submit" if auto_submit else "")
|
|
@@ -181,16 +233,81 @@ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel
|
|
|
181
233
|
if not auto_submit:
|
|
182
234
|
for platform in platforms:
|
|
183
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
|
|
184
241
|
if channel:
|
|
185
|
-
|
|
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)"})
|
|
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"})
|
|
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)"})
|
|
187
244
|
return commands
|
|
188
245
|
|
|
189
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
|
+
|
|
190
307
|
def main(argv: list[str] | None = None) -> int:
|
|
191
308
|
parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
192
309
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
193
|
-
for name in ("preflight", "plan"):
|
|
310
|
+
for name in ("preflight", "plan", "ota"):
|
|
194
311
|
command = sub.add_parser(name)
|
|
195
312
|
command.add_argument("--project", type=Path, default=Path("."))
|
|
196
313
|
command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
|
|
@@ -198,7 +315,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
198
315
|
command.add_argument("--profile", default="production")
|
|
199
316
|
command.add_argument("--platform", choices=("ios", "android", "all"), default="all")
|
|
200
317
|
command.add_argument("--auto-submit", action="store_true", help="the build will run with --auto-submit --non-interactive")
|
|
318
|
+
command.add_argument("--environment", help="EAS environment for updates (default: the profile when it is development/preview/production)")
|
|
201
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")
|
|
202
326
|
args = parser.parse_args(argv)
|
|
203
327
|
platforms = list(PLATFORMS) if args.platform == "all" else [args.platform]
|
|
204
328
|
try:
|
|
@@ -206,14 +330,36 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
206
330
|
except (OSError, ValueError) as error:
|
|
207
331
|
print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
|
|
208
332
|
return 2
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
211
355
|
text = json.dumps(report, indent=2, ensure_ascii=False)
|
|
212
356
|
if args.output:
|
|
213
357
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
214
358
|
args.output.write_text(text + "\n", encoding="utf-8")
|
|
215
359
|
print(text)
|
|
216
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
|
|
217
363
|
return 1 if status == "failed" else 0
|
|
218
364
|
|
|
219
365
|
|