#!/usr/bin/env python3 """Générateur de la config app mobile OTO Enterprise OS (Sprint 5 · Mobile). Livrable Mobile de la roadmap Sprint 5 (l.56-57 « Rebuild Expo 54 + submit App Store #32 + Play Store »). Le worker ne BUILD ni ne SUBMIT (VPS/stores · #8) : il produit la CONFIG versionnable de l'app compagnon, surface mobile des 5 portails rôle ERPNext. Transforme (rbac_50_roles.json + mobile_spec.json + portails_spec.json + seo_spec.json) en : - app_config.json -> objet Expo `app.config` (thème dark+doré #4, locales seo, identifiants de store `null` · a_confirmer) - eas_build.json -> objet `eas.json` (profils build/submit, credentials `null`) - role_navigation.json -> 1 onglet par portail, gated par la surface RBAC exacte - store_listing.json -> fiche store FR/EN/ES (sous-titre, descriptions, mots-clés) dérivée : portails/langues injectés depuis les sources (#6) - MANIFEST.json -> traçabilité (comptes, résumé par portail, marque, a_confirmer) Sous-commandes : build [-o OUT] -> écrit les 4 fichiers ci-dessus validate [-o OUT] -> (re)génère en mémoire, valide vs mobile.schema.json + invariants Sortie déterministe (tri stable, aucun horodatage) -> diffable + re-générable. Anti-invention (#6) : onglets = portails métier ; rôles = surface RBAC EXACTE du portail (mêmes valeurs que les Has Role des Workspaces) ; identifiants de store/build = `null` (a_confirmer) — jamais fabriqués ; marque/devises/langues sourcées. Ce worker n'écrit JAMAIS sur le VPS ni ne soumet aux stores (#8). """ from __future__ import annotations import argparse import json import os import sys _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _HERE) from mobilelib import builder, deps # noqa: E402 _DEFAULT_OUT = os.path.join(_HERE, "out") def _eprint(*args) -> None: print(*args, file=sys.stderr) def _write_json(path: str, data) -> None: with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) fh.write("\n") def _build_bundle() -> dict: contract = deps.load_contract() spec = deps.load_spec() portails_spec = deps.load_portails_spec() langs, default_lang = deps.load_langs() return builder.build_bundle(contract, spec, portails_spec, langs, default_lang) def _resolve_path(root: dict, dotted: str): """Descend `root` selon un chemin pointé (ex. 'app_config.expo.ios.bundleIdentifier'). Retourne (trouvé: bool, valeur). `trouvé=False` si un segment n'existe pas. """ cur = root for seg in dotted.split("."): if not isinstance(cur, dict) or seg not in cur: return False, None cur = cur[seg] return True, cur def _validate_bundle(bundle: dict) -> list[str]: errors: list[str] = [] schema = deps.load_json(deps.SCHEMA_PATH) errors.extend(deps.validate(bundle, schema)) contract = deps.load_contract() spec = deps.load_spec() portails_spec = deps.load_portails_spec() langs, default_lang = deps.load_langs() app = bundle["app_config"]["expo"] eas = bundle["eas_build"] nav = bundle["role_navigation"] manifest = bundle["manifest"] b = deps.branding roles_map = deps.roles_by_portail(contract) labels = {p["key"]: p["label"] for p in portails_spec["portails"]} icons = {p["key"]: p["icon"] for p in portails_spec["portails"]} # Invariant 1 · onglets = portails métier RBAC, dans l'ordre de la spec. nav_keys = [n["portail"] for n in nav] if nav_keys != spec["portail_order"]: errors.append(f"ordre des onglets != portail_order spec: {nav_keys}.") if set(nav_keys) != set(contract["portails_business"]): errors.append("onglets != portails_business du contrat RBAC.") for n in nav: key = n["portail"] # Invariant 2 · rôles autorisés = surface RBAC EXACTE du portail (sécurité). expected_roles = sorted(roles_map.get(key, [])) if n["roles_allowed"] != expected_roles: errors.append(f"[{key}] roles_allowed != rôles RBAC du portail.") if not expected_roles: errors.append(f"[{key}] portail sans rôle (surface RBAC vide ?).") if n["n_roles"] != len(expected_roles): errors.append(f"[{key}] n_roles incohérent.") # Invariant 3 · label + icône = portails_spec (source unique · mise en page). if n["label"] != labels.get(key): errors.append(f"[{key}] label != portails_spec.") if n["icon"] != icons.get(key): errors.append(f"[{key}] icône != portails_spec.") # Invariant 4 · route déterministe. if n["route"] != f"/{key}": errors.append(f"[{key}] route non déterministe: {n['route']!r}.") # Invariant 5 · langues = seo_spec (source unique), aucune langue inventée. if list(langs) != ["fr", "en", "es"] or default_lang != "fr": errors.append(f"langues seo inattendues: {langs}/{default_lang}.") if sorted(app["locales"].keys()) != sorted(langs): errors.append("app_config.locales != langues seo.") if app["extra"]["defaultLocale"] != default_lang: errors.append("extra.defaultLocale != langue par défaut seo.") # Invariant 6 · tokens de marque verbatim (CLAUDE.md #4). if app["backgroundColor"] != b.COLOR_BG or app["splash"]["backgroundColor"] != b.COLOR_BG: errors.append("backgroundColor != #4 (#0a0a12).") if app["primaryColor"] != b.COLOR_ACCENT: errors.append("primaryColor != #4 (#f0b429).") if app["userInterfaceStyle"] != "dark": errors.append("userInterfaceStyle != dark (thème luxury #4).") brand = manifest["brand"] if brand["fond"]["valeur"] != b.COLOR_BG or brand["accent"]["valeur"] != b.COLOR_ACCENT: errors.append("manifest.brand != CLAUDE.md #4.") # Invariant 7 · devises = USD + DOP (CLAUDE.md #10). if app["extra"]["devises"] != [b.DEVISE_PRIMAIRE, b.DEVISE_SECONDAIRE]: errors.append("extra.devises != [USD, DOP] (#10).") # Invariant 8 · Expo SDK majeur = spec (54) ; orientation portrait. if app["extra"]["expoSdkMajor"] != spec["app"]["expo_sdk_major"]: errors.append("extra.expoSdkMajor != spec (Expo 54).") if app["orientation"] != spec["app"]["orientation"]: errors.append("orientation != spec.") # Invariant 9 · ANTI-INVENTION : chaque champ `a_confirmer` reste `null` dans la # sortie (aucun identifiant de store/build/credential fabriqué · #6/#8). for entry in spec["a_confirmer"]: champ = entry["champ"] found, value = _resolve_path(bundle, champ) if not found: errors.append(f"a_confirmer: champ absent du bundle: {champ}.") elif value is not None: errors.append(f"a_confirmer: {champ} doit rester null (jamais fabriqué #6/#8).") if manifest["identifiants_statut"] != "a_confirmer": errors.append("manifest.identifiants_statut != a_confirmer.") # Invariant 10 · profils EAS = spec ; credentials submit `null`. if sorted(eas["build"].keys()) != sorted(spec["eas"]["build_profiles"]): errors.append("eas.build profils != spec.build_profiles.") # Invariant 11 · comptes du manifeste cohérents. c = manifest["counts"] checks = { "onglets": len(nav), "portails": len(nav), "roles_couverts": sum(n["n_roles"] for n in nav), "langues": len(langs), "identifiants_a_confirmer": len(spec["a_confirmer"]), } for k, v in checks.items(): if c.get(k) != v: errors.append(f"counts.{k} incohérent ({c.get(k)} != {v}).") # Invariant 12 · fiche store : couvre EXACTEMENT les langues seo (aucune inventée). sl = bundle["store_listing"] sl_spec = spec["store_listing"] if sorted(sl["content"].keys()) != sorted(langs): errors.append("store_listing.content != langues seo (FR/EN/ES).") if sl["langues"] != list(langs): errors.append("store_listing.langues != langues seo.") if sl["limits"] != sl_spec["limits"]: errors.append("store_listing.limits != spec.store_listing.limits.") # Invariant 13 · chaque champ de fiche respecte sa limite de caractères store # (App Store / Play — voir limits_source) et n'est jamais vide. for lang, fields in sl["content"].items(): for champ, limit in sl_spec["limits"].items(): val = fields.get(champ) if not val: errors.append(f"store_listing[{lang}].{champ} vide/absent.") elif len(val) > limit: errors.append( f"store_listing[{lang}].{champ} dépasse la limite store {limit} " f"caractères (={len(val)})." ) # Invariant 14 · ANTI-DÉRIVE : l'énumération des portails et les langues injectées # dans chaque description longue == la surface RÉELLE (labels role_navigation · # langues seo) — la copie marketing ne peut pas mentir sur les portails/langues. portails_str = " · ".join(n["label"] for n in nav) langues_str = "/".join(lang.upper() for lang in langs) for lang, fields in sl["content"].items(): full = fields.get("full_description", "") if portails_str not in full: errors.append(f"store_listing[{lang}].full_description n'injecte pas les portails réels.") if langues_str not in full: errors.append(f"store_listing[{lang}].full_description n'injecte pas les langues réelles.") if "{" in full or "}" in full: errors.append(f"store_listing[{lang}].full_description : placeholder non substitué.") # Invariant 15 · déterminisme : deux builds successifs identiques. again = _build_bundle() if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False): errors.append("build non déterministe (deux passes divergent).") return errors def cmd_build(args: argparse.Namespace) -> int: bundle = _build_bundle() errors = _validate_bundle(bundle) if errors: _eprint("❌ Bundle invalide — génération refusée (anti-régression) :") for e in errors: _eprint(f" - {e}") return 1 out = os.path.abspath(args.out) os.makedirs(out, exist_ok=True) _write_json(os.path.join(out, "app_config.json"), bundle["app_config"]) _write_json(os.path.join(out, "eas_build.json"), bundle["eas_build"]) _write_json(os.path.join(out, "role_navigation.json"), bundle["role_navigation"]) _write_json(os.path.join(out, "store_listing.json"), bundle["store_listing"]) _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) c = bundle["manifest"]["counts"] print(f"✅ Config app mobile générée dans {out}") print( f" {c['onglets']} onglets (portails) · {c['roles_couverts']} rôles couverts · " f"{c['langues']} langues · {c['identifiants_a_confirmer']} identifiants a_confirmer" ) for m in bundle["manifest"]["portails"]: print(f" - {m['label']} ({m['icon']}) : {m['nb_roles']} rôles") return 0 def cmd_validate(args: argparse.Namespace) -> int: bundle = _build_bundle() errors = _validate_bundle(bundle) if errors: _eprint("❌ Validation KO :") for e in errors: _eprint(f" - {e}") return 1 c = bundle["manifest"]["counts"] print( f"✅ Validation OK — {c['onglets']} onglets, {c['roles_couverts']} rôles, " f"schéma + 15 invariants verts (identifiants a_confirmer · fiche store dérivée)." ) return 0 def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( description="Générateur de la config app mobile OTO Enterprise OS (Expo/EAS + navigation par rôle)." ) sub = p.add_subparsers(dest="cmd", required=True) pb = sub.add_parser("build", help="génère app_config.json / eas_build.json / role_navigation.json / MANIFEST.json") pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)") pb.set_defaults(func=cmd_build) pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire") pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)") pv.set_defaults(func=cmd_validate) args = p.parse_args(argv) return args.func(args) if __name__ == "__main__": raise SystemExit(main())