@topy-ai/maggie 0.7.52 → 0.7.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +48 -2
- package/README.md +66 -54
- 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 +17 -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 +36 -6
- 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 +9 -2
- package/bundled-tools/clis/maggie_design_lint.py +75 -3
- package/bundled-tools/clis/maggie_feedback.py +97 -15
- package/bundled-tools/clis/maggie_mobile.py +136 -4
- 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
|
@@ -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
|
|
|
@@ -185,7 +198,33 @@ def cost_notice(platforms: list[str], profile: str) -> str:
|
|
|
185
198
|
"build minutes even if a later step fails. EAS Update publishes are counted against monthly update users. Confirm before running.")
|
|
186
199
|
|
|
187
200
|
|
|
188
|
-
def
|
|
201
|
+
def git_root(path: Path) -> Path:
|
|
202
|
+
completed = subprocess.run(["git", "-C", str(path), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
|
|
203
|
+
if completed.returncode:
|
|
204
|
+
raise ValueError("the app folder is not inside a git repository; --build-ref needs git")
|
|
205
|
+
return Path(completed.stdout.strip()).resolve()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_config_drift(app: Path, build_ref: str) -> list[dict]:
|
|
209
|
+
"""Config files whose working copy differs from the build commit's copy (git object paths, never contents)."""
|
|
210
|
+
if build_ref.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", build_ref):
|
|
211
|
+
raise ValueError("--build-ref must be a git revision such as a tag or commit")
|
|
212
|
+
root = git_root(app)
|
|
213
|
+
files = []
|
|
214
|
+
for name in BUILD_CONFIG_FILES:
|
|
215
|
+
path = app / name
|
|
216
|
+
rel = path.resolve().relative_to(root).as_posix()
|
|
217
|
+
shown = subprocess.run(["git", "-C", str(root), "show", f"{build_ref}:{rel}"], capture_output=True, check=False)
|
|
218
|
+
if shown.returncode:
|
|
219
|
+
if name == "eas.json":
|
|
220
|
+
raise ValueError(f"{rel} does not exist at {build_ref}; is that the commit the store build was made from?")
|
|
221
|
+
continue
|
|
222
|
+
current = path.read_bytes() if path.is_file() else b""
|
|
223
|
+
files.append({"file": rel, "differs": current != shown.stdout, "buildBytes": shown.stdout, "workingSha256": hashlib.sha256(current).hexdigest()})
|
|
224
|
+
return files
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel: str | None, environment: str | None = None, build_ref: str | None = None, drift: list[dict] | None = None) -> list[dict]:
|
|
189
228
|
environment = environment or (profile if profile in {"development", "preview", "production"} else "production")
|
|
190
229
|
platform_arg = "all" if len(platforms) == 2 else platforms[0]
|
|
191
230
|
commands = [{"step": "preflight", "command": f"maggie mobile preflight --profile {profile} --platform {platform_arg}" + (" --auto-submit" if auto_submit else ""), "spends": False}]
|
|
@@ -194,16 +233,81 @@ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel
|
|
|
194
233
|
if not auto_submit:
|
|
195
234
|
for platform in platforms:
|
|
196
235
|
commands.append({"step": f"submit-{platform}", "command": f"npx eas-cli submit -p {platform} --profile {profile} --latest --non-interactive", "spends": False})
|
|
236
|
+
swapped = [item["file"] for item in (drift or []) if item["differs"]]
|
|
237
|
+
if channel and build_ref:
|
|
238
|
+
commands.append({"step": "ota-from-build-config", "command": f"maggie mobile ota --profile {profile} --platform {platform_arg} --build-ref {build_ref} --build-id <installed build id> --message \"<what changed>\" --confirm", "spends": True,
|
|
239
|
+
"note": ("swaps " + ", ".join(swapped) + f" for the copies at {build_ref}, runs fingerprint:compare and the update, then restores the working copies" if swapped else f"the config files match {build_ref}; no swap needed")})
|
|
240
|
+
return commands
|
|
197
241
|
if channel:
|
|
198
242
|
commands.append({"step": "fingerprint-gate", "command": "npx eas-cli fingerprint:compare --build-id <installed build id>", "spends": False, "note": "must report no difference; editing eas.json, native config or dependencies after the build changes the fingerprint and the update would reach no installed build"})
|
|
199
243
|
commands.append({"step": "ota-update", "command": f"npx eas-cli update --channel {channel} --environment {environment} --platform {platform_arg} --message \"<what changed>\" --non-interactive", "spends": True, "note": "only JS/asset changes; a native change needs a new build (the fingerprint policy enforces this)"})
|
|
200
244
|
return commands
|
|
201
245
|
|
|
202
246
|
|
|
247
|
+
def eas_command() -> list[str]:
|
|
248
|
+
"""The EAS CLI invocation; MAGGIE_MOBILE_EAS overrides it (tests, a pinned eas-cli)."""
|
|
249
|
+
return shlex.split(os.environ.get("MAGGIE_MOBILE_EAS", "npx eas-cli"))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def fingerprint_hashes(stdout: str) -> tuple[str, str] | None:
|
|
253
|
+
"""(build hash, project hash) from `eas fingerprint:compare --json`, or None when the output is not that."""
|
|
254
|
+
start = stdout.find("{")
|
|
255
|
+
try:
|
|
256
|
+
data = json.loads(stdout[start:]) if start >= 0 else {}
|
|
257
|
+
return str(data["fingerprint1"]["hash"]), str(data["fingerprint2"]["hash"])
|
|
258
|
+
except (ValueError, KeyError, TypeError):
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str, platform_arg: str, message: str, confirm: bool) -> dict:
|
|
263
|
+
"""Fingerprint and publish an update with the build's own config files; always restore the working copies."""
|
|
264
|
+
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", build_id):
|
|
265
|
+
raise ValueError("--build-id must be the EAS build id of the installed build")
|
|
266
|
+
if not message.strip():
|
|
267
|
+
raise ValueError("--message is required")
|
|
268
|
+
drift = build_config_drift(app, build_ref)
|
|
269
|
+
root = git_root(app)
|
|
270
|
+
# fingerprint:compare exits 0 even when the fingerprints differ; --json prints both, so compare the hashes.
|
|
271
|
+
compare = eas_command() + ["fingerprint:compare", "--build-id", build_id, "--json", "--non-interactive"]
|
|
272
|
+
update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform_arg, "--message", message, "--non-interactive"]
|
|
273
|
+
report = {"buildRef": build_ref, "swapped": [item["file"] for item in drift if item["differs"]], "commands": [shlex.join(compare), shlex.join(update)], "executed": False}
|
|
274
|
+
if not confirm:
|
|
275
|
+
report["status"] = "dry-run"
|
|
276
|
+
report["note"] = "nothing was changed; re-run with --confirm to swap, compare, publish and restore"
|
|
277
|
+
return report
|
|
278
|
+
originals = {item["file"]: (root / item["file"]).read_bytes() for item in drift if item["differs"]}
|
|
279
|
+
steps = []
|
|
280
|
+
try:
|
|
281
|
+
for item in drift:
|
|
282
|
+
if item["differs"]:
|
|
283
|
+
(root / item["file"]).write_bytes(item["buildBytes"])
|
|
284
|
+
result = subprocess.run(compare, cwd=app, capture_output=True, text=True, check=False)
|
|
285
|
+
hashes = fingerprint_hashes(result.stdout)
|
|
286
|
+
steps.append({"step": "fingerprint-compare", "exit": result.returncode, "match": bool(hashes) and hashes[0] == hashes[1]})
|
|
287
|
+
if result.returncode or not hashes:
|
|
288
|
+
report.update(status="failed", reason="fingerprint:compare did not return two fingerprints; nothing was published")
|
|
289
|
+
return report
|
|
290
|
+
if hashes[0] != hashes[1]:
|
|
291
|
+
report.update(status="failed", reason="the fingerprint with the build's config files does not match the installed build; this change needs a new native build")
|
|
292
|
+
return report
|
|
293
|
+
result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
|
|
294
|
+
steps.append({"step": "update", "exit": result.returncode})
|
|
295
|
+
report["executed"] = True
|
|
296
|
+
report["status"] = "published" if result.returncode == 0 else "failed"
|
|
297
|
+
if result.returncode:
|
|
298
|
+
report["reason"] = "eas update failed; see its output"
|
|
299
|
+
return report
|
|
300
|
+
finally:
|
|
301
|
+
for rel, data in originals.items():
|
|
302
|
+
(root / rel).write_bytes(data)
|
|
303
|
+
report["restored"] = {rel: hashlib.sha256((root / rel).read_bytes()).hexdigest() == hashlib.sha256(data).hexdigest() for rel, data in originals.items()}
|
|
304
|
+
report["steps"] = steps
|
|
305
|
+
|
|
306
|
+
|
|
203
307
|
def main(argv: list[str] | None = None) -> int:
|
|
204
308
|
parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
205
309
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
206
|
-
for name in ("preflight", "plan"):
|
|
310
|
+
for name in ("preflight", "plan", "ota"):
|
|
207
311
|
command = sub.add_parser(name)
|
|
208
312
|
command.add_argument("--project", type=Path, default=Path("."))
|
|
209
313
|
command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
|
|
@@ -213,6 +317,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
213
317
|
command.add_argument("--auto-submit", action="store_true", help="the build will run with --auto-submit --non-interactive")
|
|
214
318
|
command.add_argument("--environment", help="EAS environment for updates (default: the profile when it is development/preview/production)")
|
|
215
319
|
command.add_argument("--output", type=Path)
|
|
320
|
+
if name in {"plan", "ota"}:
|
|
321
|
+
command.add_argument("--build-ref", help="git commit or tag the installed store build was made from")
|
|
322
|
+
if name == "ota":
|
|
323
|
+
command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
|
|
324
|
+
command.add_argument("--message", required=True)
|
|
325
|
+
command.add_argument("--confirm", action="store_true", help="actually swap, compare, publish and restore; without it nothing changes")
|
|
216
326
|
args = parser.parse_args(argv)
|
|
217
327
|
platforms = list(PLATFORMS) if args.platform == "all" else [args.platform]
|
|
218
328
|
try:
|
|
@@ -220,14 +330,36 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
220
330
|
except (OSError, ValueError) as error:
|
|
221
331
|
print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
|
|
222
332
|
return 2
|
|
223
|
-
|
|
224
|
-
|
|
333
|
+
project = args.project.resolve()
|
|
334
|
+
app = (project / args.app_dir).resolve() if args.app_dir and not args.app_dir.is_absolute() else (args.app_dir.resolve() if args.app_dir else project)
|
|
335
|
+
channel = report["facts"].get("channel") if report["facts"].get("easUpdate") else None
|
|
336
|
+
environment = args.environment or (args.profile if args.profile in {"development", "preview", "production"} else "production")
|
|
337
|
+
platform_arg = "all" if len(platforms) == 2 else platforms[0]
|
|
338
|
+
try:
|
|
339
|
+
if args.command == "plan":
|
|
340
|
+
drift = build_config_drift(app, args.build_ref) if args.build_ref else None
|
|
341
|
+
report = {"schemaVersion": "maggie-mobile-release-plan.v1", "preflight": {key: report[key] for key in ("status", "errors", "warnings")}, "commands": plan_commands(args.profile, platforms, args.auto_submit, channel, args.environment, args.build_ref, drift), "costNotice": report["costNotice"], "executed": False}
|
|
342
|
+
if drift is not None:
|
|
343
|
+
report["buildConfig"] = [{"file": item["file"], "differsFromBuild": item["differs"]} for item in drift]
|
|
344
|
+
elif args.command == "ota":
|
|
345
|
+
if report["status"] == "failed":
|
|
346
|
+
raise ValueError("preflight failed: " + "; ".join(report["errors"]))
|
|
347
|
+
if not channel:
|
|
348
|
+
raise ValueError("the profile has no EAS Update channel")
|
|
349
|
+
if not args.build_ref:
|
|
350
|
+
raise ValueError("--build-ref is required: the commit the installed build was made from")
|
|
351
|
+
report = {"schemaVersion": "maggie-mobile-ota.v1", **ota(app, args.build_ref, args.build_id, channel, environment, platform_arg, args.message, args.confirm)}
|
|
352
|
+
except (OSError, ValueError) as error:
|
|
353
|
+
print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
|
|
354
|
+
return 2
|
|
225
355
|
text = json.dumps(report, indent=2, ensure_ascii=False)
|
|
226
356
|
if args.output:
|
|
227
357
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
228
358
|
args.output.write_text(text + "\n", encoding="utf-8")
|
|
229
359
|
print(text)
|
|
230
360
|
status = report["status"] if "status" in report else report["preflight"]["status"]
|
|
361
|
+
if report.get("restored") and not all(report["restored"].values()):
|
|
362
|
+
return 1
|
|
231
363
|
return 1 if status == "failed" else 0
|
|
232
364
|
|
|
233
365
|
|
|
@@ -12,6 +12,13 @@ broken change or quietly damaged real data:
|
|
|
12
12
|
(iframes, tabs, parallel viewports) refresh at once, and a local dev server
|
|
13
13
|
that talks to a production API turns QA clicks into production writes.
|
|
14
14
|
|
|
15
|
+
Two more failures hid stale code: a dev server killed and replaced by another
|
|
16
|
+
session's server on the same port, and a watcher that kept serving the old
|
|
17
|
+
bundle after edits. ``--expect-symbol NAME`` fetches the served entry bundle
|
|
18
|
+
from ``--base-url`` and fails when it lacks a name from the edit just made;
|
|
19
|
+
the report also names the process holding the base URL's port and when it
|
|
20
|
+
started, so a server restarted by someone else is obvious.
|
|
21
|
+
|
|
15
22
|
The command inspects running dev-server processes (Linux ``/proc``; other
|
|
16
23
|
systems fall back to ``ps``), the app's environment files and auth code, and
|
|
17
24
|
reports environment variable names and host classes only — never values,
|
|
@@ -28,7 +35,8 @@ import subprocess
|
|
|
28
35
|
import sys
|
|
29
36
|
from datetime import datetime, timezone
|
|
30
37
|
from pathlib import Path
|
|
31
|
-
from urllib.parse import urlsplit
|
|
38
|
+
from urllib.parse import urljoin, urlsplit
|
|
39
|
+
from urllib.request import Request, urlopen
|
|
32
40
|
|
|
33
41
|
SCHEMA = "maggie-qa-app-preflight.v1"
|
|
34
42
|
DEV_SERVER = re.compile(r"(expo(?:/cli)?[^ ]*\s+start|@expo/cli.*\bstart\b|react-native\s+start|metro(?:\.js)?\s|node_modules/\.bin/metro|expo-router)", re.I)
|
|
@@ -78,6 +86,84 @@ def list_processes() -> list[dict]:
|
|
|
78
86
|
return found
|
|
79
87
|
|
|
80
88
|
|
|
89
|
+
def process_started_at(pid: int) -> str | None:
|
|
90
|
+
"""ISO start time of a Linux process from /proc/<pid>/stat and the boot time; None elsewhere."""
|
|
91
|
+
try:
|
|
92
|
+
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
93
|
+
ticks = int(stat.rsplit(")", 1)[1].split()[19])
|
|
94
|
+
boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines() if line.startswith("btime "))
|
|
95
|
+
return datetime.fromtimestamp(boot + ticks / os.sysconf("SC_CLK_TCK"), timezone.utc).isoformat(timespec="seconds")
|
|
96
|
+
except (OSError, ValueError, IndexError, StopIteration):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def port_owner(port: int) -> dict | None:
|
|
101
|
+
"""The process listening on a TCP port (Linux /proc); command line trimmed to the program and script."""
|
|
102
|
+
inodes = set()
|
|
103
|
+
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
104
|
+
try:
|
|
105
|
+
lines = Path(table).read_text(encoding="utf-8").splitlines()[1:]
|
|
106
|
+
except OSError:
|
|
107
|
+
continue
|
|
108
|
+
for line in lines:
|
|
109
|
+
fields = line.split()
|
|
110
|
+
if len(fields) > 9 and fields[3] == "0A" and int(fields[1].rsplit(":", 1)[1], 16) == port:
|
|
111
|
+
inodes.add(fields[9])
|
|
112
|
+
if not inodes:
|
|
113
|
+
return None
|
|
114
|
+
for entry in Path("/proc").iterdir():
|
|
115
|
+
if not entry.name.isdigit():
|
|
116
|
+
continue
|
|
117
|
+
links = set()
|
|
118
|
+
try:
|
|
119
|
+
descriptors = list((entry / "fd").iterdir())
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
for fd in descriptors:
|
|
123
|
+
try:
|
|
124
|
+
links.add(os.readlink(fd))
|
|
125
|
+
except OSError:
|
|
126
|
+
continue
|
|
127
|
+
if any(f"socket:[{inode}]" in links for inode in inodes):
|
|
128
|
+
try:
|
|
129
|
+
cmdline = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace").strip()
|
|
130
|
+
cwd = os.readlink(entry / "cwd")
|
|
131
|
+
except OSError:
|
|
132
|
+
cmdline, cwd = "", None
|
|
133
|
+
return {"pid": int(entry.name), "command": " ".join(cmdline.split()[:3])[:160], "cwd": cwd, "startedAt": process_started_at(int(entry.name))}
|
|
134
|
+
return {"pid": None, "command": "unknown (owned by another user)", "cwd": None, "startedAt": None}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
SCRIPT_SRC = re.compile(r"<script[^>]+src=[\"']([^\"']+)[\"']", re.I)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _fetch(url: str, limit: int) -> bytes:
|
|
141
|
+
request = Request(url, headers={"User-Agent": "maggie-qa-app-preflight"})
|
|
142
|
+
with urlopen(request, timeout=60) as response: # noqa: S310 - local dev server URL given by the operator
|
|
143
|
+
return response.read(limit)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def bundle_symbols(base_url: str, symbols: list[str], limit: int = 80_000_000) -> dict:
|
|
147
|
+
"""Fetch the page at base_url and its same-origin scripts; report which expected names they contain."""
|
|
148
|
+
origin = urlsplit(base_url)
|
|
149
|
+
html = _fetch(base_url, 5_000_000).decode("utf-8", "replace")
|
|
150
|
+
scripts = []
|
|
151
|
+
for src in SCRIPT_SRC.findall(html):
|
|
152
|
+
url = urljoin(base_url, src)
|
|
153
|
+
if urlsplit(url).netloc == origin.netloc:
|
|
154
|
+
scripts.append(url)
|
|
155
|
+
found: dict[str, list[str]] = {symbol: [] for symbol in symbols}
|
|
156
|
+
fetched = []
|
|
157
|
+
for url in scripts[:12]:
|
|
158
|
+
body = _fetch(url, limit).decode("utf-8", "replace")
|
|
159
|
+
path = urlsplit(url).path
|
|
160
|
+
fetched.append({"path": path, "bytes": len(body)})
|
|
161
|
+
for symbol in symbols:
|
|
162
|
+
if symbol in body:
|
|
163
|
+
found[symbol].append(path)
|
|
164
|
+
return {"scripts": fetched, "found": {symbol: bool(paths) for symbol, paths in found.items()}, "foundIn": found}
|
|
165
|
+
|
|
166
|
+
|
|
81
167
|
def find_app_dirs(project: Path) -> list[Path]:
|
|
82
168
|
"""Expo apps in the project: the root itself, or monorepo apps/* and packages/*."""
|
|
83
169
|
candidates = [project] + sorted(path for pattern in ("apps/*", "packages/*") for path in project.glob(pattern) if path.is_dir())
|
|
@@ -158,7 +244,7 @@ def refresh_token_signals(app_dir: Path, limit: int = 4000) -> dict:
|
|
|
158
244
|
return {"filesScanned": files, "refreshTokenFiles": refresh, "rotationHintFiles": rotation, "examples": examples}
|
|
159
245
|
|
|
160
246
|
|
|
161
|
-
def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None) -> dict:
|
|
247
|
+
def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotating: str, processes: list[dict] | None = None, expect_symbols: list[str] | None = None) -> dict:
|
|
162
248
|
project = project.resolve()
|
|
163
249
|
errors: list[str] = []
|
|
164
250
|
warnings: list[str] = []
|
|
@@ -174,7 +260,7 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
|
|
|
174
260
|
for item in (list_processes() if processes is None else processes):
|
|
175
261
|
cwd = item.get("cwd")
|
|
176
262
|
command = item.get("cmdline", "")
|
|
177
|
-
record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command}
|
|
263
|
+
record = {"pid": item.get("pid"), "cwd": rel(cwd) if cwd else None, "ciMode": bool(item.get("ci")), "noWatch": "--no-watch" in command, "startedAt": item.get("startedAt") or (process_started_at(item["pid"]) if isinstance(item.get("pid"), int) else None)}
|
|
178
264
|
if cwd and not Path(cwd).resolve().is_relative_to(project):
|
|
179
265
|
record["scope"] = "other project"
|
|
180
266
|
servers.append(record)
|
|
@@ -189,6 +275,35 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
|
|
|
189
275
|
in_project = [server for server in servers if server.get("scope") != "other project"]
|
|
190
276
|
if not in_project:
|
|
191
277
|
warnings.append("no running Metro/Expo dev server found for this project; start one from the app folder without CI=1")
|
|
278
|
+
owner = None
|
|
279
|
+
if base_url:
|
|
280
|
+
parts = urlsplit(base_url)
|
|
281
|
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
|
282
|
+
if classify_host(base_url) == "local":
|
|
283
|
+
owner = port_owner(port)
|
|
284
|
+
if owner is None:
|
|
285
|
+
errors.append(f"nothing is listening on port {port}; the dev server was stopped or killed (check for exit 137) — start it from {rel(app_dir)}")
|
|
286
|
+
else:
|
|
287
|
+
owner = {**owner, "port": port, "cwd": rel(owner["cwd"]) if owner.get("cwd") else None}
|
|
288
|
+
known = {server["pid"] for server in in_project}
|
|
289
|
+
if owner["pid"] is not None and owner["pid"] not in known:
|
|
290
|
+
where = owner["cwd"] or "an unknown folder"
|
|
291
|
+
errors.append(f"port {port} is held by pid {owner['pid']} ({owner['command']}, started {owner['startedAt'] or 'unknown'}) from {where}, not by a dev server of {rel(app_dir)}; another session may have replaced it")
|
|
292
|
+
bundle = None
|
|
293
|
+
if expect_symbols:
|
|
294
|
+
if not base_url:
|
|
295
|
+
errors.append("--expect-symbol needs --base-url to fetch the served bundle")
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
bundle = bundle_symbols(base_url, expect_symbols)
|
|
299
|
+
except (OSError, ValueError) as error:
|
|
300
|
+
errors.append(f"could not fetch the served bundle from --base-url: {error}")
|
|
301
|
+
else:
|
|
302
|
+
if not bundle["scripts"]:
|
|
303
|
+
errors.append("the page at --base-url has no same-origin script, so the served bundle could not be checked")
|
|
304
|
+
missing = [symbol for symbol, ok in bundle["found"].items() if not ok]
|
|
305
|
+
if bundle["scripts"] and missing:
|
|
306
|
+
errors.append("the served bundle does not contain " + ", ".join(missing) + ": the dev server is serving old code (stale watcher, wrong folder or another session's server); restart it before QA")
|
|
192
307
|
signals = refresh_token_signals(app_dir)
|
|
193
308
|
rotating_tokens = rotating == "yes" or (rotating == "auto" and signals["refreshTokenFiles"] > 0)
|
|
194
309
|
if rotating_tokens:
|
|
@@ -213,6 +328,8 @@ def preflight(project: Path, app_dir: Path | None, base_url: str | None, rotatin
|
|
|
213
328
|
"appDir": rel(app_dir),
|
|
214
329
|
"appCandidates": [rel(path) for path in apps],
|
|
215
330
|
"devServers": servers,
|
|
331
|
+
"portOwner": owner,
|
|
332
|
+
"servedBundle": bundle,
|
|
216
333
|
"refreshTokens": {"rotating": rotating_tokens, "source": rotating, **signals},
|
|
217
334
|
"apiTargets": targets,
|
|
218
335
|
"status": "failed" if errors else ("warning" if warnings else "passed"),
|
|
@@ -229,10 +346,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
229
346
|
parser.add_argument("--app-dir", type=Path, help="app folder inside a monorepo, e.g. apps/app")
|
|
230
347
|
parser.add_argument("--base-url", help="URL the browser QA will open")
|
|
231
348
|
parser.add_argument("--rotating-refresh-tokens", choices=("auto", "yes", "no"), default="auto")
|
|
349
|
+
parser.add_argument("--expect-symbol", action="append", default=[], metavar="NAME", help="a name from the edit just made (component, string, export); fails when the served bundle lacks it")
|
|
232
350
|
parser.add_argument("--output", type=Path)
|
|
233
351
|
args = parser.parse_args(argv)
|
|
234
352
|
try:
|
|
235
|
-
report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens)
|
|
353
|
+
report = preflight(args.project, args.app_dir, args.base_url, args.rotating_refresh_tokens, expect_symbols=args.expect_symbol)
|
|
236
354
|
except (OSError, ValueError) as error:
|
|
237
355
|
print(f"BLOCKED: maggie qa app-preflight: {error}", file=sys.stderr)
|
|
238
356
|
return 2
|