@topy-ai/maggie 0.7.53 → 0.7.55
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 -50
- package/README.md +80 -64
- package/bin/maggie.js +61 -9
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
- package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
- package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
- package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
- package/bundled-skills/maggie-blog/SKILL.md +15 -0
- package/bundled-skills/maggie-booking/SKILL.md +20 -12
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +37 -2
- package/bundled-skills/maggie-deployment/SKILL.md +104 -1
- package/bundled-skills/maggie-deployment/references/vps.md +5 -0
- package/bundled-skills/maggie-design/SKILL.md +81 -0
- package/bundled-skills/maggie-feedback/SKILL.md +10 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
- package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
- package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
- package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +28 -0
- package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
- package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
- package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
- package/bundled-skills/maggie-social-share/SKILL.md +50 -0
- package/bundled-tools/clis/maggie.py +63 -3
- package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
- package/bundled-tools/clis/maggie_booking.py +9 -8
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
- package/bundled-tools/clis/maggie_design.py +7 -1
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +131 -1
- package/bundled-tools/clis/maggie_feedback.py +41 -13
- package/bundled-tools/clis/maggie_host_runtime.py +458 -0
- package/bundled-tools/clis/maggie_live_urls.py +147 -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 +84 -1
- package/bundled-tools/clis/maggie_ops.py +2 -0
- package/bundled-tools/clis/maggie_ops_routines.py +352 -0
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_query_ideas.py +154 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
- package/bundled-tools/clis/maggie_social_share.py +223 -2
- package/bundled-tools/clis/maggie_version_sources.py +93 -0
- package/bundled-tools/integrations/maggie-api-pull.md +49 -0
- package/package.json +1 -1
- 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,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Record where API Pull deliveries went live, from the site's sitemap.
|
|
3
|
+
|
|
4
|
+
A delivered post is useful to other channels (social posts, internal links)
|
|
5
|
+
only once its live URL is known. The site should report it when it publishes;
|
|
6
|
+
this matcher fills the gaps from the sitemap with two rules:
|
|
7
|
+
|
|
8
|
+
* every post without a live URL is matched, including one already tracked
|
|
9
|
+
with an empty value (a slug missing from the record is read from its
|
|
10
|
+
delivered address);
|
|
11
|
+
* additive only: a record that already has a live URL keeps it, and a record
|
|
12
|
+
that matches nothing in this run is left as it was (never marked unmatched),
|
|
13
|
+
so the match can run on a schedule;
|
|
14
|
+
* the original page first: when translated copies share the slug
|
|
15
|
+
(``/blog/x`` and ``/zh-hans/blog/x``), the URL without a locale segment wins.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import xml.etree.ElementTree as ET
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from urllib.parse import urlsplit
|
|
27
|
+
from urllib.request import Request, urlopen
|
|
28
|
+
|
|
29
|
+
LOCALE_SEGMENT = re.compile(r"^[a-z]{2}(?:[-_][a-z0-9]{2,8})?$", re.I)
|
|
30
|
+
MAX_SITEMAPS = 50
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def now() -> str:
|
|
34
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch(location: str) -> str:
|
|
38
|
+
if not location.startswith(("http://", "https://")):
|
|
39
|
+
return Path(location).read_text(encoding="utf-8")
|
|
40
|
+
with urlopen(Request(location, headers={"User-Agent": "MaggieLiveUrls/1.0"}), timeout=20) as response:
|
|
41
|
+
return response.read(20_000_000).decode("utf-8", "replace")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sitemap_urls(location: str) -> list[str]:
|
|
45
|
+
"""Every <loc> of a sitemap, following sitemap indexes (bounded)."""
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
urls: list[str] = []
|
|
48
|
+
queue = [location]
|
|
49
|
+
while queue and len(seen) < MAX_SITEMAPS:
|
|
50
|
+
current = queue.pop(0)
|
|
51
|
+
if current in seen:
|
|
52
|
+
continue
|
|
53
|
+
seen.add(current)
|
|
54
|
+
root = ET.fromstring(fetch(current))
|
|
55
|
+
tag = root.tag.rsplit("}", 1)[-1]
|
|
56
|
+
locs = [element.text.strip() for element in root.iter() if element.tag.rsplit("}", 1)[-1] == "loc" and element.text]
|
|
57
|
+
if tag == "sitemapindex":
|
|
58
|
+
queue.extend(locs)
|
|
59
|
+
else:
|
|
60
|
+
urls.extend(locs)
|
|
61
|
+
return list(dict.fromkeys(urls))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def has_locale_segment(url: str) -> bool:
|
|
65
|
+
parts = [part for part in urlsplit(url).path.split("/") if part]
|
|
66
|
+
return len(parts) > 1 and bool(LOCALE_SEGMENT.match(parts[0]))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def best_match(slug: str, urls: list[str]) -> str | None:
|
|
70
|
+
candidates = [url for url in urls if [part for part in urlsplit(url).path.split("/") if part][-1:] == [slug]]
|
|
71
|
+
if not candidates:
|
|
72
|
+
return None
|
|
73
|
+
# Original before translated copies, then the shortest path.
|
|
74
|
+
return sorted(candidates, key=lambda url: (has_locale_segment(url), len(urlsplit(url).path), url))[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def slug_from(address: object) -> str:
|
|
78
|
+
path = urlsplit(str(address or "")).path.rstrip("/")
|
|
79
|
+
return path.rsplit("/", 1)[-1] if path else ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def match(state: dict, urls: list[str]) -> dict:
|
|
83
|
+
records = state.get("content", {}) if isinstance(state.get("content"), dict) else {}
|
|
84
|
+
matched, kept, missing = [], [], []
|
|
85
|
+
for key, record in records.items():
|
|
86
|
+
if not isinstance(record, dict):
|
|
87
|
+
continue
|
|
88
|
+
# A post already tracked with an empty (or blank) URL still needs one.
|
|
89
|
+
if str(record.get("live_url") or "").strip():
|
|
90
|
+
kept.append({"remote_content_id": record.get("remote_content_id") or key, "live_url": record["live_url"], "source": record.get("live_url_source", "")})
|
|
91
|
+
continue
|
|
92
|
+
slug = str(record.get("slug") or "").strip("/") or slug_from(record.get("canonical_url"))
|
|
93
|
+
url = best_match(slug, urls) if slug else None
|
|
94
|
+
if url:
|
|
95
|
+
record["live_url"] = url
|
|
96
|
+
record["live_url_source"] = "sitemap"
|
|
97
|
+
record["live_url_matched_at"] = now()
|
|
98
|
+
matched.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug, "live_url": url})
|
|
99
|
+
else:
|
|
100
|
+
missing.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug})
|
|
101
|
+
return {"matched": matched, "kept": kept, "notFound": missing}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
106
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
107
|
+
run = sub.add_parser("match", help="fill missing live URLs of delivered posts from the sitemap (additive)")
|
|
108
|
+
run.add_argument("--project", default=".")
|
|
109
|
+
run.add_argument("--state", help="lifecycle state (default: .maggie/ai-cmo-lifecycle.json)")
|
|
110
|
+
run.add_argument("--sitemap", required=True, help="sitemap URL or file (indexes are followed)")
|
|
111
|
+
run.add_argument("--write", action="store_true", help="save the new live URLs into the state file")
|
|
112
|
+
run.add_argument("--report-body", help="write a report-state body for the newly matched posts (send with `maggie api report-state --body-file`)")
|
|
113
|
+
record = sub.add_parser("record", help="record a live URL the site reported when it published a delivered post")
|
|
114
|
+
record.add_argument("--project", default=".")
|
|
115
|
+
record.add_argument("--state")
|
|
116
|
+
record.add_argument("--content-id", required=True)
|
|
117
|
+
record.add_argument("--url", required=True)
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
state_path = Path(args.state) if args.state else Path(args.project) / ".maggie" / "ai-cmo-lifecycle.json"
|
|
120
|
+
try:
|
|
121
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
122
|
+
if args.command == "record":
|
|
123
|
+
if urlsplit(args.url).scheme not in {"http", "https"}:
|
|
124
|
+
raise ValueError("--url must be an absolute http(s) URL")
|
|
125
|
+
target = state.get("content", {}).get(args.content_id)
|
|
126
|
+
if not isinstance(target, dict):
|
|
127
|
+
raise ValueError(f"no delivered post {args.content_id} in {state_path}")
|
|
128
|
+
# The site's own report is authoritative and replaces a sitemap guess.
|
|
129
|
+
target.update({"live_url": args.url, "live_url_source": "site", "live_url_matched_at": now()})
|
|
130
|
+
state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
131
|
+
print(json.dumps({"status": "recorded", "remote_content_id": args.content_id, "live_url": args.url}, indent=2))
|
|
132
|
+
return 0
|
|
133
|
+
result = match(state, sitemap_urls(args.sitemap))
|
|
134
|
+
if args.write:
|
|
135
|
+
state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
136
|
+
if args.report_body and result["matched"]:
|
|
137
|
+
body = {"items": [{"content_id": item["remote_content_id"], "url": item["live_url"], "status": "published"} for item in result["matched"]][:500]}
|
|
138
|
+
Path(args.report_body).write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
|
|
139
|
+
print(json.dumps({"status": "matched" if args.write else "dry-run", **{key: value for key, value in result.items()}, "counts": {key: len(value) for key, value in result.items()}}, indent=2, ensure_ascii=False))
|
|
140
|
+
return 0
|
|
141
|
+
except (OSError, ValueError, json.JSONDecodeError, ET.ParseError) as error:
|
|
142
|
+
print(f"maggie live-urls: {error}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
raise SystemExit(main())
|
|
@@ -17,6 +17,7 @@ from localization_runner import generate, process_adapter
|
|
|
17
17
|
|
|
18
18
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
19
|
import maggie_email_localization as email_localization
|
|
20
|
+
import maggie_localization_keys as localization_keys
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def generate_job(args: argparse.Namespace) -> int:
|
|
@@ -399,6 +400,7 @@ def main() -> int:
|
|
|
399
400
|
for name in ("stale", "glossary"):
|
|
400
401
|
command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
|
|
401
402
|
email_localization.add_parsers(sub)
|
|
403
|
+
localization_keys.add_parsers(sub)
|
|
402
404
|
args = parser.parse_args()
|
|
403
405
|
try:
|
|
404
406
|
if args.command == "generate": return generate_job(args)
|
|
@@ -411,6 +413,7 @@ def main() -> int:
|
|
|
411
413
|
if args.command == "publish": return publish(args.job, args.confirm)
|
|
412
414
|
if args.command == "stale": return stale_jobs(args.project)
|
|
413
415
|
if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
|
|
416
|
+
if args.command in ("key-check", "fragments"): return localization_keys.run(args)
|
|
414
417
|
return glossary(args.project)
|
|
415
418
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
416
419
|
print(f"ERROR: {error}", file=sys.stderr); return 1
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Translation keys for generated pages: text keys are site-wide, positions are not.
|
|
2
|
+
|
|
3
|
+
A translation stored under its source text applies to every page that shows
|
|
4
|
+
that text. That is right for a shared header, wrong for generic phrases on
|
|
5
|
+
generated pages ("your practice" is a clinic on one page and a firm on
|
|
6
|
+
another). `key-check` reads per-page translation rows and lists the texts that
|
|
7
|
+
need position keys. `fragments` reads rendered HTML and lists text split
|
|
8
|
+
around an inline span (a styled brand name) into pieces too short to translate
|
|
9
|
+
on their own ("Can", "No.", "What").
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from html.parser import HTMLParser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
INLINE = {"span", "strong", "em", "b", "i", "a", "mark", "small", "sup", "sub", "u"}
|
|
20
|
+
BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "td", "th", "dt", "dd", "figcaption", "blockquote", "button", "label", "summary", "caption"}
|
|
21
|
+
SKIP = {"script", "style", "noscript", "template", "svg", "code", "pre"}
|
|
22
|
+
WORD = re.compile(r"[\w'’-]+", re.UNICODE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_rows(path: Path) -> list[dict]:
|
|
26
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
rows = value.get("rows", []) if isinstance(value, dict) else value
|
|
28
|
+
if not isinstance(rows, list):
|
|
29
|
+
raise ValueError("rows must be a list of {page, position?, text, translation}")
|
|
30
|
+
return [row for row in rows if isinstance(row, dict)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def key_check(rows: list[dict], locale: str | None = None) -> dict:
|
|
34
|
+
"""Source texts whose translation differs between pages cannot share a text key."""
|
|
35
|
+
by_text: dict[str, dict[str, set[str]]] = {}
|
|
36
|
+
for row in rows:
|
|
37
|
+
if locale and row.get("locale") not in (None, locale):
|
|
38
|
+
continue
|
|
39
|
+
text = " ".join(str(row.get("text") or "").split())
|
|
40
|
+
translation = " ".join(str(row.get("translation") or "").split())
|
|
41
|
+
if not text or not translation:
|
|
42
|
+
continue
|
|
43
|
+
by_text.setdefault(text, {}).setdefault(translation, set()).add(str(row.get("page") or "?"))
|
|
44
|
+
conflicts = []
|
|
45
|
+
for text, translations in sorted(by_text.items()):
|
|
46
|
+
if len(translations) > 1:
|
|
47
|
+
pages = sorted({page for group in translations.values() for page in group})
|
|
48
|
+
conflicts.append({"text": text, "translations": {translation: sorted(group)[:5] for translation, group in translations.items()}, "pages": len(pages)})
|
|
49
|
+
shared = sum(1 for translations in by_text.values() if len({page for group in translations.values() for page in group}) > 1)
|
|
50
|
+
return {
|
|
51
|
+
"valid": not conflicts,
|
|
52
|
+
"texts": len(by_text),
|
|
53
|
+
"sharedAcrossPages": shared,
|
|
54
|
+
"needPositionKeys": len(conflicts),
|
|
55
|
+
"conflicts": conflicts[:200],
|
|
56
|
+
"advice": "Store these under per-page position keys (render the source page, then key each node by page + position with a source hash); keep text keys only as a fallback for genuinely shared text." if conflicts else "",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FragmentParser(HTMLParser):
|
|
61
|
+
"""Collect, per block element, the run of text pieces and inline spans."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, brand: re.Pattern[str]) -> None:
|
|
64
|
+
super().__init__(convert_charrefs=True)
|
|
65
|
+
self.brand = brand
|
|
66
|
+
self.stack: list[str] = []
|
|
67
|
+
self.blocks: list[list[tuple[str, str]]] = [] # (kind, text): kind is "text" or "inline"
|
|
68
|
+
self.inline_depth = 0
|
|
69
|
+
self.inline_text = ""
|
|
70
|
+
self.skip = 0
|
|
71
|
+
self.findings: list[dict] = []
|
|
72
|
+
|
|
73
|
+
def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001
|
|
74
|
+
if tag in SKIP:
|
|
75
|
+
self.skip += 1
|
|
76
|
+
return
|
|
77
|
+
if tag in BLOCK:
|
|
78
|
+
self.blocks.append([])
|
|
79
|
+
elif tag in INLINE and self.blocks:
|
|
80
|
+
if self.inline_depth == 0:
|
|
81
|
+
self.inline_text = ""
|
|
82
|
+
self.inline_depth += 1
|
|
83
|
+
|
|
84
|
+
def handle_endtag(self, tag: str) -> None:
|
|
85
|
+
if tag in SKIP:
|
|
86
|
+
self.skip = max(0, self.skip - 1)
|
|
87
|
+
return
|
|
88
|
+
if tag in INLINE and self.inline_depth:
|
|
89
|
+
self.inline_depth -= 1
|
|
90
|
+
if self.inline_depth == 0 and self.blocks:
|
|
91
|
+
self.blocks[-1].append(("inline", self.inline_text))
|
|
92
|
+
elif tag in BLOCK and self.blocks:
|
|
93
|
+
self.finish(self.blocks.pop())
|
|
94
|
+
|
|
95
|
+
def handle_data(self, data: str) -> None:
|
|
96
|
+
if self.skip or not self.blocks:
|
|
97
|
+
return
|
|
98
|
+
if self.inline_depth:
|
|
99
|
+
self.inline_text += data
|
|
100
|
+
elif data.strip():
|
|
101
|
+
self.blocks[-1].append(("text", data))
|
|
102
|
+
|
|
103
|
+
def finish(self, parts: list[tuple[str, str]]) -> None:
|
|
104
|
+
if not any(kind == "inline" and self.brand.search(text) for kind, text in parts):
|
|
105
|
+
return
|
|
106
|
+
sentence = " ".join(" ".join(text.split()) for _, text in parts).strip()
|
|
107
|
+
for index, (kind, text) in enumerate(parts):
|
|
108
|
+
if kind != "text":
|
|
109
|
+
continue
|
|
110
|
+
neighbours = [parts[i] for i in (index - 1, index + 1) if 0 <= i < len(parts)]
|
|
111
|
+
if not any(other_kind == "inline" and self.brand.search(other_text) for other_kind, other_text in neighbours):
|
|
112
|
+
continue
|
|
113
|
+
words = WORD.findall(text)
|
|
114
|
+
if 1 <= len(words) <= 2:
|
|
115
|
+
self.findings.append({"fragment": " ".join(text.split()), "words": len(words), "sentence": sentence[:200]})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fragments(paths: list[Path], brand: str) -> dict:
|
|
119
|
+
pattern = re.compile(re.escape(brand), re.I)
|
|
120
|
+
findings = []
|
|
121
|
+
for path in paths:
|
|
122
|
+
parser = FragmentParser(pattern)
|
|
123
|
+
parser.feed(path.read_text(encoding="utf-8", errors="replace"))
|
|
124
|
+
findings.extend({"file": str(path), **item} for item in parser.findings)
|
|
125
|
+
return {
|
|
126
|
+
"valid": not findings,
|
|
127
|
+
"files": len(paths),
|
|
128
|
+
"fragments": findings[:500],
|
|
129
|
+
"advice": f"Translate the whole sentence around {brand!r} as one unit (keep the span as a placeholder such as {{brand}}) instead of storing these short pieces." if findings else "",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def add_parsers(sub: argparse._SubParsersAction) -> None:
|
|
134
|
+
keys = sub.add_parser("key-check", help="list source texts translated differently on different pages (they need per-page position keys)")
|
|
135
|
+
keys.add_argument("rows", type=Path, help='JSON rows [{"page", "position"?, "text", "translation", "locale"?}]')
|
|
136
|
+
keys.add_argument("--locale")
|
|
137
|
+
split = sub.add_parser("fragments", help="find text split around an inline brand span into pieces too short to translate")
|
|
138
|
+
split.add_argument("--html", type=Path, nargs="+", required=True, help="rendered HTML files or directories (searched for *.html)")
|
|
139
|
+
split.add_argument("--brand", required=True, help="the inline span's text, e.g. the brand name")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run(args: argparse.Namespace) -> int:
|
|
143
|
+
if args.command == "key-check":
|
|
144
|
+
result = key_check(load_rows(args.rows), args.locale)
|
|
145
|
+
else:
|
|
146
|
+
files: list[Path] = []
|
|
147
|
+
for item in args.html:
|
|
148
|
+
files.extend(sorted(item.rglob("*.html")) if item.is_dir() else [item])
|
|
149
|
+
result = fragments(files, args.brand)
|
|
150
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
151
|
+
return 0 if result["valid"] else 1
|
|
@@ -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())
|
|
@@ -28,6 +28,16 @@ made from: ``eas.json`` and ``package.json`` usually changed since. ``plan
|
|
|
28
28
|
``eas fingerprint:compare``, publish the update only when it matches, and
|
|
29
29
|
always put the working copies back (verified by hash), even on failure. ``ota``
|
|
30
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.
|
|
31
41
|
"""
|
|
32
42
|
|
|
33
43
|
from __future__ import annotations
|
|
@@ -180,6 +190,9 @@ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, pro
|
|
|
180
190
|
package = _load(app / "package.json") if (app / "package.json").is_file() else {}
|
|
181
191
|
ignore = (app / ".fingerprintignore").read_text(encoding="utf-8") if (app / ".fingerprintignore").is_file() else ""
|
|
182
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
|
|
183
196
|
return {
|
|
184
197
|
"schemaVersion": SCHEMA,
|
|
185
198
|
"appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
|
|
@@ -304,10 +317,70 @@ def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str
|
|
|
304
317
|
report["steps"] = steps
|
|
305
318
|
|
|
306
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
|
+
|
|
307
380
|
def main(argv: list[str] | None = None) -> int:
|
|
308
381
|
parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
309
382
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
310
|
-
for name in ("preflight", "plan", "ota"):
|
|
383
|
+
for name in ("preflight", "plan", "ota", "ota-guard"):
|
|
311
384
|
command = sub.add_parser(name)
|
|
312
385
|
command.add_argument("--project", type=Path, default=Path("."))
|
|
313
386
|
command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
|
|
@@ -319,6 +392,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
319
392
|
command.add_argument("--output", type=Path)
|
|
320
393
|
if name in {"plan", "ota"}:
|
|
321
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")
|
|
322
401
|
if name == "ota":
|
|
323
402
|
command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
|
|
324
403
|
command.add_argument("--message", required=True)
|
|
@@ -341,6 +420,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
341
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}
|
|
342
421
|
if drift is not None:
|
|
343
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)
|
|
344
427
|
elif args.command == "ota":
|
|
345
428
|
if report["status"] == "failed":
|
|
346
429
|
raise ValueError("preflight failed: " + "; ".join(report["errors"]))
|
|
@@ -335,6 +335,8 @@ def main() -> int:
|
|
|
335
335
|
favicon.add_argument("--declared-url")
|
|
336
336
|
favicon.add_argument("--timeout", type=int, default=15)
|
|
337
337
|
favicon.set_defaults(func=command_favicon_check)
|
|
338
|
+
from maggie_ops_routines import add_parsers as add_routine_parsers
|
|
339
|
+
add_routine_parsers(sub)
|
|
338
340
|
record = sub.add_parser("record", help="record an explicitly authorised operation")
|
|
339
341
|
record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
|
|
340
342
|
record.add_argument("--dry-run", action="store_true")
|