@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.
- package/README-zh-TW.md +28 -4
- package/README.md +93 -60
- package/bin/maggie.js +67 -12
- 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/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- 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-blog/SKILL.md +8 -0
- package/bundled-skills/maggie-booking/SKILL.md +66 -14
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +28 -2
- package/bundled-skills/maggie-deployment/SKILL.md +48 -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 +98 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +27 -2
- 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 +65 -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 +56 -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 +24 -12
- 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_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
- package/bundled-tools/clis/maggie_design.py +15 -2
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +205 -3
- package/bundled-tools/clis/maggie_feedback.py +138 -28
- package/bundled-tools/clis/maggie_live_urls.py +138 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +219 -4
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- 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 +174 -7
- package/bundled-tools/integrations/maggie-api-pull.md +22 -0
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- 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
|
|
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
|
-
|
|
224
|
-
|
|
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 == "
|
|
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)
|