@topy-ai/maggie 0.7.52 → 0.7.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README-zh-TW.md +28 -4
  2. package/README.md +93 -60
  3. package/bin/maggie.js +67 -12
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  12. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  13. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  14. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  15. package/bundled-references/provider-adapter-contract.md +16 -3
  16. package/bundled-skills/catalog.json +1 -1
  17. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  18. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  19. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  20. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  21. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  23. package/bundled-skills/maggie-booking/SKILL.md +66 -14
  24. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  25. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  26. package/bundled-skills/maggie-deployment/SKILL.md +48 -0
  27. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  28. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  29. package/bundled-skills/maggie-design/SKILL.md +98 -3
  30. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  31. package/bundled-skills/maggie-feedback/SKILL.md +27 -2
  32. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  33. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  34. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  35. package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
  36. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  38. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  39. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  40. package/bundled-skills/maggie-social-share/SKILL.md +56 -4
  41. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  42. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  43. package/bundled-tools/clis/maggie_booking.py +24 -12
  44. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  45. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  46. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  47. package/bundled-tools/clis/maggie_dash.py +173 -3
  48. package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
  49. package/bundled-tools/clis/maggie_design.py +15 -2
  50. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  51. package/bundled-tools/clis/maggie_design_lint.py +205 -3
  52. package/bundled-tools/clis/maggie_feedback.py +138 -28
  53. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  54. package/bundled-tools/clis/maggie_localization.py +3 -0
  55. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  56. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  57. package/bundled-tools/clis/maggie_mobile.py +219 -4
  58. package/bundled-tools/clis/maggie_product_release.py +49 -1
  59. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  60. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  61. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  62. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  63. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  64. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  65. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  66. package/bundled-tools/clis/maggie_social_share.py +174 -7
  67. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  68. package/bundled-tools/runtime/assistant_approval.py +98 -0
  69. package/package.json +1 -1
  70. package/references/provider-adapter-contract.md +16 -3
  71. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  72. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  73. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  74. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  75. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  76. package/bundled-references/maggiedash-booking/README.md +0 -288
  77. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  78. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  79. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ """Generate page pictures with a chosen engine, and plan a large run first.
3
+
4
+ Engines:
5
+
6
+ * ``codex`` — the local ``codex`` CLI's image tool on the signed-in ChatGPT
7
+ plan. No per-image charge, but it draws from the plan's quota and is slow
8
+ (about 75 s per picture observed). Accepts reference pictures (``--image``).
9
+ * ``gemini`` — Gemini image generation with the project's own
10
+ ``GEMINI_API_KEY`` (billed to that Google account). Faster (about 19 s at 2K
11
+ observed). No reference pictures in this tool.
12
+
13
+ ``engine-plan`` states the time and cost of each engine for a run before it
14
+ starts; ``generate`` draws one picture and never runs without ``--confirm``.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import base64
20
+ import json
21
+ import math
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ from pathlib import Path
29
+ from urllib.error import HTTPError, URLError
30
+ from urllib.request import Request, urlopen
31
+
32
+ GEMINI_MODEL = "gemini-3.1-flash-image"
33
+ GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
34
+ RATIOS = ("21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16")
35
+ # Observed seconds per picture (one worker); override with --seconds-codex / --seconds-gemini.
36
+ OBSERVED_SECONDS = {"codex": 75.0, "gemini": 19.0}
37
+
38
+
39
+ def parse_size(value: str) -> tuple[int, int]:
40
+ try:
41
+ width, height = (int(part) for part in value.lower().split("x"))
42
+ except ValueError as error:
43
+ raise ValueError("--size must look like 2400x1000") from error
44
+ if width < 64 or height < 64:
45
+ raise ValueError("--size is too small")
46
+ return width, height
47
+
48
+
49
+ def nearest_ratio(width: int, height: int) -> str:
50
+ target = width / height
51
+ return min(RATIOS, key=lambda ratio: abs(math.log((int(ratio.split(":")[0]) / int(ratio.split(":")[1])) / target)))
52
+
53
+
54
+ def image_size(width: int, height: int) -> str:
55
+ longest = max(width, height)
56
+ return "4K" if longest > 2600 else "2K" if longest > 1300 else "1K"
57
+
58
+
59
+ def engine_plan(count: int, width: int, height: int, workers: int, references: bool, seconds: dict[str, float], gemini_unit_cost: float | None, currency: str) -> dict:
60
+ engines = []
61
+ for engine in ("codex", "gemini"):
62
+ per = seconds[engine]
63
+ wall = math.ceil(count / max(1, workers)) * per
64
+ row = {"engine": engine, "secondsPerPicture": per, "workers": workers, "estimatedMinutes": round(wall / 60, 1)}
65
+ if engine == "codex":
66
+ row.update({"billing": "ChatGPT plan quota (no per-picture charge)", "quotaPictures": count, "cost": None, "referenceImages": True})
67
+ else:
68
+ row.update({"billing": "the project's GEMINI_API_KEY (its Google account)", "aspectRatio": nearest_ratio(width, height), "imageSize": image_size(width, height),
69
+ "cost": round(count * gemini_unit_cost, 2) if gemini_unit_cost is not None else None, "currency": currency if gemini_unit_cost is not None else None,
70
+ "costNote": None if gemini_unit_cost is not None else "pass --gemini-unit-cost from the current Gemini price list to state the cost", "referenceImages": False})
71
+ engines.append(row)
72
+ usable = [row for row in engines if not (references and not row["referenceImages"])]
73
+ fastest = min(usable, key=lambda row: row["estimatedMinutes"])["engine"] if usable else None
74
+ return {"schemaVersion": "maggie-media-engine-plan.v1", "pictures": count, "size": f"{width}x{height}", "needsReferenceImages": references, "engines": engines, "fastest": fastest,
75
+ "confirmBeforeRunning": True, "note": "State this plan to the person and wait for a yes before a large run."}
76
+
77
+
78
+ def gemini_generate(prompt: str, width: int, height: int, model: str, timeout: float) -> bytes:
79
+ key = os.environ.get("GEMINI_API_KEY", "")
80
+ if not key:
81
+ raise RuntimeError("--engine gemini needs GEMINI_API_KEY in the environment")
82
+ endpoint = os.environ.get("GEMINI_API_ENDPOINT") or GEMINI_ENDPOINT.format(model=model)
83
+ body = json.dumps({"contents": [{"role": "user", "parts": [{"text": prompt}]}],
84
+ "generationConfig": {"responseModalities": ["IMAGE"], "imageConfig": {"aspectRatio": nearest_ratio(width, height), "imageSize": image_size(width, height)}}}).encode()
85
+ for attempt in range(1, 5):
86
+ request = Request(endpoint, data=body, method="POST", headers={"content-type": "application/json", "x-goog-api-key": key})
87
+ status, data = 0, {}
88
+ try:
89
+ with urlopen(request, timeout=timeout) as response:
90
+ status, data = response.status, json.loads(response.read() or b"{}")
91
+ except HTTPError as error:
92
+ status = error.code
93
+ try:
94
+ data = json.loads(error.read() or b"{}")
95
+ except json.JSONDecodeError:
96
+ data = {}
97
+ except (URLError, TimeoutError, OSError) as error:
98
+ data = {"error": {"message": type(error).__name__}}
99
+ parts = (((data.get("candidates") or [{}])[0].get("content") or {}).get("parts") or [])
100
+ image = next((part["inlineData"]["data"] for part in parts if isinstance(part, dict) and part.get("inlineData", {}).get("data")), None)
101
+ if 200 <= status < 300 and image:
102
+ return base64.b64decode(image)
103
+ if attempt == 4 or (400 <= status < 500 and status != 429):
104
+ reason = (data.get("error") or {}).get("message") or ((data.get("candidates") or [{}])[0].get("finishReason")) or f"HTTP {status}"
105
+ raise RuntimeError(f"Gemini: {str(reason)[:300]}")
106
+ time.sleep(float(os.environ.get("MAGGIE_MEDIA_RETRY_SECONDS", attempt * 15)))
107
+ raise RuntimeError("Gemini: no image")
108
+
109
+
110
+ def codex_generate(prompt: str, width: int, height: int, images: list[Path], timeout: float) -> bytes:
111
+ if not shutil.which("codex"):
112
+ raise RuntimeError("--engine codex needs the codex CLI on PATH (signed in to ChatGPT)")
113
+ shape = "wide landscape (about 3:2 or wider)" if width >= height * 1.4 else "landscape" if width > height else "portrait" if width < height else "square"
114
+ with tempfile.TemporaryDirectory(prefix="maggie-media-") as work:
115
+ reference = "Use the attached image only as a reference for layout and content. " if images else ""
116
+ instruction = f"Use your image generation tool to create exactly one image, {shape}. {reference}{prompt} Save the final image into the current directory as out.png. Reply only with the saved path."
117
+ # `-i` takes several files, so `--` ends the list before the prompt.
118
+ argv = ["codex", "exec", "--skip-git-repo-check", "--sandbox", "workspace-write", *[item for path in images for item in ("-i", str(path.resolve()))], *(["--"] if images else []), instruction]
119
+ subprocess.run(argv, cwd=work, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=timeout, check=False)
120
+ out = Path(work) / "out.png"
121
+ if not out.is_file():
122
+ raise RuntimeError("codex did not save out.png")
123
+ return out.read_bytes()
124
+
125
+
126
+ def main(argv: list[str] | None = None) -> int:
127
+ parser = argparse.ArgumentParser(prog="maggie media", description=__doc__.splitlines()[0])
128
+ sub = parser.add_subparsers(dest="command", required=True)
129
+ plan = sub.add_parser("engine-plan", help="time and cost of each engine for a run, before it starts")
130
+ plan.add_argument("--count", type=int, required=True)
131
+ plan.add_argument("--size", default="1600x1200")
132
+ plan.add_argument("--workers", type=int, default=1)
133
+ plan.add_argument("--reference-images", action="store_true", help="the run redraws screenshots (codex only)")
134
+ plan.add_argument("--seconds-codex", type=float, default=OBSERVED_SECONDS["codex"])
135
+ plan.add_argument("--seconds-gemini", type=float, default=OBSERVED_SECONDS["gemini"])
136
+ plan.add_argument("--gemini-unit-cost", type=float, help="price of one picture at this size from the current Gemini price list")
137
+ plan.add_argument("--currency", default="USD")
138
+ gen = sub.add_parser("generate", help="draw one picture with the chosen engine")
139
+ gen.add_argument("--engine", choices=("codex", "gemini"), default="codex")
140
+ gen.add_argument("--prompt", required=True)
141
+ gen.add_argument("--size", default="1600x1200")
142
+ gen.add_argument("--image", type=Path, action="append", default=[], help="reference picture (codex only, repeatable)")
143
+ gen.add_argument("--output", type=Path, required=True, help="PNG path to write")
144
+ gen.add_argument("--model", default=GEMINI_MODEL)
145
+ gen.add_argument("--timeout", type=float, default=600)
146
+ gen.add_argument("--confirm", action="store_true")
147
+ args = parser.parse_args(argv)
148
+ try:
149
+ width, height = parse_size(args.size)
150
+ if args.command == "engine-plan":
151
+ print(json.dumps(engine_plan(args.count, width, height, args.workers, args.reference_images, {"codex": args.seconds_codex, "gemini": args.seconds_gemini}, args.gemini_unit_cost, args.currency), indent=2))
152
+ return 0
153
+ if not args.confirm:
154
+ print("CONFIRMATION_REQUIRED: generating a picture uses plan quota (codex) or a paid API (gemini); rerun with --confirm", file=sys.stderr)
155
+ return 2
156
+ for path in args.image:
157
+ if not path.is_file():
158
+ raise ValueError(f"--image {path} not found")
159
+ if args.engine == "gemini" and args.image:
160
+ raise ValueError("--image needs the codex engine")
161
+ started = time.monotonic()
162
+ data = gemini_generate(args.prompt, width, height, args.model, args.timeout) if args.engine == "gemini" else codex_generate(args.prompt, width, height, args.image, args.timeout)
163
+ args.output.parent.mkdir(parents=True, exist_ok=True)
164
+ args.output.write_bytes(data)
165
+ print(json.dumps({"engine": args.engine, "output": str(args.output), "bytes": len(data), "seconds": round(time.monotonic() - started, 1), "aspectRatio": nearest_ratio(width, height) if args.engine == "gemini" else None, "imageSize": image_size(width, height) if args.engine == "gemini" else None,
166
+ "next": "resize/crop to the exact size and convert to WebP before use"}, indent=2))
167
+ return 0
168
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
169
+ print(f"BLOCKED: maggie media: {error}", file=sys.stderr)
170
+ return 1
171
+
172
+
173
+ if __name__ == "__main__":
174
+ raise SystemExit(main())
@@ -20,19 +20,42 @@ 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.
31
+
32
+ ``ota-guard`` checks the runtime an update would target against the runtimes
33
+ of builds people have installed (a runtimes file kept with the app) and
34
+ refuses when none matches. In a pnpm monorepo the fingerprint also hashes
35
+ pnpm's virtual-store paths (``node_modules/.pnpm/<pkg>_<peer hash>``), so a
36
+ dependency change in any workspace package can move the runtime;
37
+ ``ignorePaths`` and ``fileHookTransform`` cannot help because native module
38
+ folders are added by their real path. With ``--publish --confirm`` it runs
39
+ ``eas update`` after a match and checks that the published runtime is the one
40
+ that was checked.
23
41
  """
24
42
 
25
43
  from __future__ import annotations
26
44
 
27
45
  import argparse
46
+ import hashlib
28
47
  import json
48
+ import os
29
49
  import re
50
+ import shlex
51
+ import subprocess
30
52
  import sys
31
53
  from datetime import datetime, timezone
32
54
  from pathlib import Path
33
55
 
34
56
  SCHEMA = "maggie-mobile-release-preflight.v1"
35
57
  PLATFORMS = ("ios", "android")
58
+ BUILD_CONFIG_FILES = ("eas.json", "package.json")
36
59
  VERSION_POLICIES = {"appVersion", "nativeVersion", "sdkVersion"}
37
60
 
38
61
 
@@ -167,6 +190,9 @@ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, pro
167
190
  package = _load(app / "package.json") if (app / "package.json").is_file() else {}
168
191
  ignore = (app / ".fingerprintignore").read_text(encoding="utf-8") if (app / ".fingerprintignore").is_file() else ""
169
192
  errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw, ignore)
193
+ if is_pnpm_monorepo(app) and any(runtime_policy(expo, platform)[0] == {"policy": "fingerprint"} or runtime_policy(expo, platform)[0] == "fingerprint" for platform in platforms):
194
+ warnings.append("pnpm monorepo with a fingerprint runtime: pnpm's virtual-store paths are part of the fingerprint, so a dependency change in another workspace package can move the runtime. Publish updates through `maggie mobile ota-guard --runtimes FILE` and set an explicit runtimeVersion (plus a native-change check) at the next native build")
195
+ facts["pnpmMonorepo"] = True
170
196
  return {
171
197
  "schemaVersion": SCHEMA,
172
198
  "appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
@@ -185,7 +211,33 @@ def cost_notice(platforms: list[str], profile: str) -> str:
185
211
  "build minutes even if a later step fails. EAS Update publishes are counted against monthly update users. Confirm before running.")
186
212
 
187
213
 
188
- def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel: str | None, environment: str | None = None) -> list[dict]:
214
+ def git_root(path: Path) -> Path:
215
+ completed = subprocess.run(["git", "-C", str(path), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
216
+ if completed.returncode:
217
+ raise ValueError("the app folder is not inside a git repository; --build-ref needs git")
218
+ return Path(completed.stdout.strip()).resolve()
219
+
220
+
221
+ def build_config_drift(app: Path, build_ref: str) -> list[dict]:
222
+ """Config files whose working copy differs from the build commit's copy (git object paths, never contents)."""
223
+ if build_ref.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", build_ref):
224
+ raise ValueError("--build-ref must be a git revision such as a tag or commit")
225
+ root = git_root(app)
226
+ files = []
227
+ for name in BUILD_CONFIG_FILES:
228
+ path = app / name
229
+ rel = path.resolve().relative_to(root).as_posix()
230
+ shown = subprocess.run(["git", "-C", str(root), "show", f"{build_ref}:{rel}"], capture_output=True, check=False)
231
+ if shown.returncode:
232
+ if name == "eas.json":
233
+ raise ValueError(f"{rel} does not exist at {build_ref}; is that the commit the store build was made from?")
234
+ continue
235
+ current = path.read_bytes() if path.is_file() else b""
236
+ files.append({"file": rel, "differs": current != shown.stdout, "buildBytes": shown.stdout, "workingSha256": hashlib.sha256(current).hexdigest()})
237
+ return files
238
+
239
+
240
+ 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
241
  environment = environment or (profile if profile in {"development", "preview", "production"} else "production")
190
242
  platform_arg = "all" if len(platforms) == 2 else platforms[0]
191
243
  commands = [{"step": "preflight", "command": f"maggie mobile preflight --profile {profile} --platform {platform_arg}" + (" --auto-submit" if auto_submit else ""), "spends": False}]
@@ -194,16 +246,141 @@ def plan_commands(profile: str, platforms: list[str], auto_submit: bool, channel
194
246
  if not auto_submit:
195
247
  for platform in platforms:
196
248
  commands.append({"step": f"submit-{platform}", "command": f"npx eas-cli submit -p {platform} --profile {profile} --latest --non-interactive", "spends": False})
249
+ swapped = [item["file"] for item in (drift or []) if item["differs"]]
250
+ if channel and build_ref:
251
+ 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,
252
+ "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")})
253
+ return commands
197
254
  if channel:
198
255
  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
256
  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
257
  return commands
201
258
 
202
259
 
260
+ def eas_command() -> list[str]:
261
+ """The EAS CLI invocation; MAGGIE_MOBILE_EAS overrides it (tests, a pinned eas-cli)."""
262
+ return shlex.split(os.environ.get("MAGGIE_MOBILE_EAS", "npx eas-cli"))
263
+
264
+
265
+ def fingerprint_hashes(stdout: str) -> tuple[str, str] | None:
266
+ """(build hash, project hash) from `eas fingerprint:compare --json`, or None when the output is not that."""
267
+ start = stdout.find("{")
268
+ try:
269
+ data = json.loads(stdout[start:]) if start >= 0 else {}
270
+ return str(data["fingerprint1"]["hash"]), str(data["fingerprint2"]["hash"])
271
+ except (ValueError, KeyError, TypeError):
272
+ return None
273
+
274
+
275
+ def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str, platform_arg: str, message: str, confirm: bool) -> dict:
276
+ """Fingerprint and publish an update with the build's own config files; always restore the working copies."""
277
+ if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", build_id):
278
+ raise ValueError("--build-id must be the EAS build id of the installed build")
279
+ if not message.strip():
280
+ raise ValueError("--message is required")
281
+ drift = build_config_drift(app, build_ref)
282
+ root = git_root(app)
283
+ # fingerprint:compare exits 0 even when the fingerprints differ; --json prints both, so compare the hashes.
284
+ compare = eas_command() + ["fingerprint:compare", "--build-id", build_id, "--json", "--non-interactive"]
285
+ update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform_arg, "--message", message, "--non-interactive"]
286
+ report = {"buildRef": build_ref, "swapped": [item["file"] for item in drift if item["differs"]], "commands": [shlex.join(compare), shlex.join(update)], "executed": False}
287
+ if not confirm:
288
+ report["status"] = "dry-run"
289
+ report["note"] = "nothing was changed; re-run with --confirm to swap, compare, publish and restore"
290
+ return report
291
+ originals = {item["file"]: (root / item["file"]).read_bytes() for item in drift if item["differs"]}
292
+ steps = []
293
+ try:
294
+ for item in drift:
295
+ if item["differs"]:
296
+ (root / item["file"]).write_bytes(item["buildBytes"])
297
+ result = subprocess.run(compare, cwd=app, capture_output=True, text=True, check=False)
298
+ hashes = fingerprint_hashes(result.stdout)
299
+ steps.append({"step": "fingerprint-compare", "exit": result.returncode, "match": bool(hashes) and hashes[0] == hashes[1]})
300
+ if result.returncode or not hashes:
301
+ report.update(status="failed", reason="fingerprint:compare did not return two fingerprints; nothing was published")
302
+ return report
303
+ if hashes[0] != hashes[1]:
304
+ 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")
305
+ return report
306
+ result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
307
+ steps.append({"step": "update", "exit": result.returncode})
308
+ report["executed"] = True
309
+ report["status"] = "published" if result.returncode == 0 else "failed"
310
+ if result.returncode:
311
+ report["reason"] = "eas update failed; see its output"
312
+ return report
313
+ finally:
314
+ for rel, data in originals.items():
315
+ (root / rel).write_bytes(data)
316
+ report["restored"] = {rel: hashlib.sha256((root / rel).read_bytes()).hexdigest() == hashlib.sha256(data).hexdigest() for rel, data in originals.items()}
317
+ report["steps"] = steps
318
+
319
+
320
+ def is_pnpm_monorepo(app: Path) -> bool:
321
+ for folder in (app, *app.parents):
322
+ if (folder / "pnpm-workspace.yaml").is_file():
323
+ return True
324
+ if (folder / ".git").exists():
325
+ break
326
+ return False
327
+
328
+
329
+ def load_runtimes(path: Path, platform: str) -> list[dict]:
330
+ """`maggie-mobile-runtimes.v1` ({"ios": [{"build", "eas_build_id", "runtime"}], "android": [...]})."""
331
+ data = _load(path)
332
+ builds = data.get(platform) or []
333
+ if not isinstance(builds, list) or not all(isinstance(item, dict) and item.get("runtime") for item in builds):
334
+ raise ValueError(f"{path} has no `{platform}` list of {{build, runtime}} entries")
335
+ return builds
336
+
337
+
338
+ def fingerprint_command(platform: str) -> list[str]:
339
+ return shlex.split(os.environ.get("MAGGIE_MOBILE_FINGERPRINT", f"npx expo-updates fingerprint:generate --platform {platform}"))
340
+
341
+
342
+ def ota_guard(app: Path, runtimes: Path, platform: str, runtime: str | None, publish: bool, channel: str | None, environment: str, message: str | None, confirm: bool) -> dict:
343
+ builds = load_runtimes(runtimes, platform)
344
+ report: dict = {"schemaVersion": "maggie-mobile-ota-guard.v1", "platform": platform, "installedBuilds": [{"build": item.get("build"), "runtime": item["runtime"]} for item in builds], "executed": False}
345
+ if not runtime:
346
+ result = subprocess.run(fingerprint_command(platform), cwd=app, capture_output=True, text=True, check=False)
347
+ try:
348
+ runtime = str(json.loads(result.stdout[result.stdout.find("{"):])["hash"]) if result.returncode == 0 else None
349
+ except (ValueError, KeyError):
350
+ runtime = None
351
+ if not runtime:
352
+ return {**report, "status": "failed", "reason": "could not compute the fingerprint (expo-updates fingerprint:generate)"}
353
+ report["runtime"] = runtime
354
+ match = next((item for item in builds if item["runtime"] == runtime), None)
355
+ if not match:
356
+ report.update(status="failed", reason=f"no installed build has runtime {runtime}; an update published now would reach nobody",
357
+ fix=["the change needs a native build (new native module, permission, native config): build, then add its runtime to the runtimes file",
358
+ "or only install paths moved (a dependency change elsewhere in the monorepo): publish from a temporary worktree with the build's lockfile and workspace file, after a frozen install, and confirm the fingerprint equals the build's runtime",
359
+ "next native build: set an explicit runtimeVersion and keep a native-change check, so unrelated workspace changes cannot move it"])
360
+ return report
361
+ report["matchedBuild"] = match.get("build")
362
+ if not publish:
363
+ return {**report, "status": "passed"}
364
+ if not (channel and message):
365
+ raise ValueError("--publish needs an EAS Update channel on the profile and --message")
366
+ update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform, "--message", message, "--non-interactive"]
367
+ report["command"] = shlex.join(update)
368
+ if not confirm:
369
+ return {**report, "status": "dry-run", "note": "runtime matches; re-run with --confirm to publish"}
370
+ result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
371
+ published = re.search(r"Runtime version\s+([0-9a-f]{8,})", result.stdout or "")
372
+ report.update(executed=True, publishedRuntime=published.group(1) if published else None)
373
+ if result.returncode:
374
+ return {**report, "status": "failed", "reason": "eas update failed; see its output"}
375
+ if not published or published.group(1) != runtime:
376
+ return {**report, "status": "failed", "reason": "the published runtime differs from the checked one; check `eas update:list`"}
377
+ return {**report, "status": "published"}
378
+
379
+
203
380
  def main(argv: list[str] | None = None) -> int:
204
381
  parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
205
382
  sub = parser.add_subparsers(dest="command", required=True)
206
- for name in ("preflight", "plan"):
383
+ for name in ("preflight", "plan", "ota", "ota-guard"):
207
384
  command = sub.add_parser(name)
208
385
  command.add_argument("--project", type=Path, default=Path("."))
209
386
  command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
@@ -213,6 +390,18 @@ def main(argv: list[str] | None = None) -> int:
213
390
  command.add_argument("--auto-submit", action="store_true", help="the build will run with --auto-submit --non-interactive")
214
391
  command.add_argument("--environment", help="EAS environment for updates (default: the profile when it is development/preview/production)")
215
392
  command.add_argument("--output", type=Path)
393
+ if name in {"plan", "ota"}:
394
+ command.add_argument("--build-ref", help="git commit or tag the installed store build was made from")
395
+ if name == "ota-guard":
396
+ command.add_argument("--runtimes", type=Path, required=True, help="maggie-mobile-runtimes.v1 JSON: installed builds' runtimes per platform")
397
+ command.add_argument("--runtime", help="use this runtime instead of computing the fingerprint")
398
+ command.add_argument("--publish", action="store_true", help="publish the update after a match")
399
+ command.add_argument("--message")
400
+ command.add_argument("--confirm", action="store_true")
401
+ if name == "ota":
402
+ command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
403
+ command.add_argument("--message", required=True)
404
+ command.add_argument("--confirm", action="store_true", help="actually swap, compare, publish and restore; without it nothing changes")
216
405
  args = parser.parse_args(argv)
217
406
  platforms = list(PLATFORMS) if args.platform == "all" else [args.platform]
218
407
  try:
@@ -220,14 +409,40 @@ def main(argv: list[str] | None = None) -> int:
220
409
  except (OSError, ValueError) as error:
221
410
  print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
222
411
  return 2
223
- if args.command == "plan":
224
- report = {"schemaVersion": "maggie-mobile-release-plan.v1", "preflight": {key: report[key] for key in ("status", "errors", "warnings")}, "commands": plan_commands(args.profile, platforms, args.auto_submit, report["facts"].get("channel") if report["facts"].get("easUpdate") else None, args.environment), "costNotice": report["costNotice"], "executed": False}
412
+ project = args.project.resolve()
413
+ 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)
414
+ channel = report["facts"].get("channel") if report["facts"].get("easUpdate") else None
415
+ environment = args.environment or (args.profile if args.profile in {"development", "preview", "production"} else "production")
416
+ platform_arg = "all" if len(platforms) == 2 else platforms[0]
417
+ try:
418
+ if args.command == "plan":
419
+ drift = build_config_drift(app, args.build_ref) if args.build_ref else None
420
+ 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}
421
+ if drift is not None:
422
+ report["buildConfig"] = [{"file": item["file"], "differsFromBuild": item["differs"]} for item in drift]
423
+ elif args.command == "ota-guard":
424
+ if args.platform == "all":
425
+ raise ValueError("--platform ios or android: runtimes differ per platform")
426
+ report = ota_guard(app, (project / args.runtimes) if not args.runtimes.is_absolute() else args.runtimes, args.platform, args.runtime, args.publish, channel, environment, args.message, args.confirm)
427
+ elif args.command == "ota":
428
+ if report["status"] == "failed":
429
+ raise ValueError("preflight failed: " + "; ".join(report["errors"]))
430
+ if not channel:
431
+ raise ValueError("the profile has no EAS Update channel")
432
+ if not args.build_ref:
433
+ raise ValueError("--build-ref is required: the commit the installed build was made from")
434
+ report = {"schemaVersion": "maggie-mobile-ota.v1", **ota(app, args.build_ref, args.build_id, channel, environment, platform_arg, args.message, args.confirm)}
435
+ except (OSError, ValueError) as error:
436
+ print(f"BLOCKED: maggie mobile {args.command}: {error}", file=sys.stderr)
437
+ return 2
225
438
  text = json.dumps(report, indent=2, ensure_ascii=False)
226
439
  if args.output:
227
440
  args.output.parent.mkdir(parents=True, exist_ok=True)
228
441
  args.output.write_text(text + "\n", encoding="utf-8")
229
442
  print(text)
230
443
  status = report["status"] if "status" in report else report["preflight"]["status"]
444
+ if report.get("restored") and not all(report["restored"].values()):
445
+ return 1
231
446
  return 1 if status == "failed" else 0
232
447
 
233
448
 
@@ -27,6 +27,7 @@ import json
27
27
  import re
28
28
  import shutil
29
29
  import subprocess
30
+ import time
30
31
  import sys
31
32
  import tempfile
32
33
  from datetime import date, datetime, timezone
@@ -264,6 +265,44 @@ def review_status(reviews_path: Path, package_version: str | None, chatgpt_plugi
264
265
  return {"schemaVersion": "maggie-product-review-status.v1", "passed": not errors, "reviews": rows, "errors": errors, "warnings": warnings}
265
266
 
266
267
 
268
+ STAGED_ERROR = re.compile(r"cannot publish over (?:the )?previously (?:staged|published) version", re.I)
269
+
270
+
271
+ def npm_view(package: str, npm_bin: str) -> dict:
272
+ completed = subprocess.run([npm_bin, "view", package, "dist-tags", "versions", "--json"], capture_output=True, text=True, check=False, timeout=60)
273
+ if completed.returncode:
274
+ return {}
275
+ try:
276
+ value = json.loads(completed.stdout or "{}")
277
+ except json.JSONDecodeError:
278
+ return {}
279
+ return value if isinstance(value, dict) else {}
280
+
281
+
282
+ def published(package: str, version: str, tag: str, wait: int, interval: float, publish_log: Path | None, view=None, sleep=time.sleep) -> dict:
283
+ """Wait until `version` is the registry's `tag` (latest). A staged publish can lag for minutes."""
284
+ npm_bin = shutil.which("npm") or "npm"
285
+ view = view or (lambda: npm_view(package, npm_bin))
286
+ log_says_staged = bool(publish_log and publish_log.is_file() and STAGED_ERROR.search(publish_log.read_text(encoding="utf-8", errors="replace")))
287
+ deadline = time.monotonic() + wait
288
+ checks = 0
289
+ while True:
290
+ checks += 1
291
+ data = view()
292
+ tags = data.get("dist-tags") or {}
293
+ versions = data.get("versions") or []
294
+ listed = version in (versions if isinstance(versions, list) else [versions])
295
+ current = tags.get(tag)
296
+ if current == version:
297
+ return {"passed": True, "package": package, "version": version, "tag": tag, "state": "live", "checks": checks,
298
+ "note": "the earlier publish attempt was already accepted (staged), not a failure" if log_says_staged else None}
299
+ if time.monotonic() >= deadline:
300
+ state = "staged" if listed or log_says_staged else "missing"
301
+ return {"passed": False, "package": package, "version": version, "tag": tag, "state": state, "registryTag": current, "checks": checks,
302
+ "note": "still propagating: check again before reporting it" if state == "staged" else "not on the registry: the publish did not happen"}
303
+ sleep(interval)
304
+
305
+
267
306
  def main(argv: list[str] | None = None) -> int:
268
307
  parser = argparse.ArgumentParser(prog="maggie product", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
269
308
  sub = parser.add_subparsers(dest="command", required=True)
@@ -281,9 +320,18 @@ def main(argv: list[str] | None = None) -> int:
281
320
  review.add_argument("--reviews", type=Path, required=True, help="maggie-product-reviews.v1 JSON")
282
321
  review.add_argument("--package", type=Path)
283
322
  review.add_argument("--chatgpt-plugin", type=Path, help="ChatGPT app plugin.json with review.test_cases")
323
+ pub = sub.add_parser("published", help="wait until a just-published version is the registry's latest (staged publishes lag)")
324
+ pub.add_argument("--package-name", required=True)
325
+ pub.add_argument("--version", required=True)
326
+ pub.add_argument("--tag", default="latest")
327
+ pub.add_argument("--wait", type=int, default=600, help="seconds to keep checking (default 600)")
328
+ pub.add_argument("--interval", type=float, default=20)
329
+ pub.add_argument("--publish-log", type=Path, help="output of the publish command; 'Cannot publish over previously staged version' means it was already accepted")
284
330
  args = parser.parse_args(argv)
285
331
  try:
286
- if args.command == "release-check":
332
+ if args.command == "published":
333
+ report = published(args.package_name, args.version, args.tag, args.wait, args.interval, args.publish_log)
334
+ elif args.command == "release-check":
287
335
  report = release_check(args.package.resolve(), args.server_json, args.changelog, args.pack)
288
336
  elif args.command == "smoke":
289
337
  report = smoke(args.package.resolve(), args.timeout)