appfactory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. appfactory/__init__.py +3 -0
  2. appfactory/ai.py +275 -0
  3. appfactory/animation.py +164 -0
  4. appfactory/app.py +679 -0
  5. appfactory/approvals.py +149 -0
  6. appfactory/asc.py +705 -0
  7. appfactory/asc_cli.py +222 -0
  8. appfactory/aso.py +604 -0
  9. appfactory/bin/maestro-live +227 -0
  10. appfactory/build.py +114 -0
  11. appfactory/cli.py +263 -0
  12. appfactory/config.py +376 -0
  13. appfactory/cpp.py +56 -0
  14. appfactory/data/generic_localizations.json +3980 -0
  15. appfactory/data/playbook.md +236 -0
  16. appfactory/data/setup.html +117 -0
  17. appfactory/deliver.py +340 -0
  18. appfactory/design/__init__.py +317 -0
  19. appfactory/design/brand.py +220 -0
  20. appfactory/design/dc.py +238 -0
  21. appfactory/design/export.py +65 -0
  22. appfactory/design/main_app.py +433 -0
  23. appfactory/design/mascot_boards.py +184 -0
  24. appfactory/design/onboarding.py +878 -0
  25. appfactory/design/pricing.py +80 -0
  26. appfactory/design/receipt.py +116 -0
  27. appfactory/design/research.py +460 -0
  28. appfactory/design/rules.py +78 -0
  29. appfactory/design/screens.py +472 -0
  30. appfactory/design/theme.py +172 -0
  31. appfactory/firebase.py +116 -0
  32. appfactory/gates.py +1530 -0
  33. appfactory/github.py +69 -0
  34. appfactory/growth.py +113 -0
  35. appfactory/icon.py +115 -0
  36. appfactory/ideas.py +428 -0
  37. appfactory/issues.py +136 -0
  38. appfactory/legal.py +228 -0
  39. appfactory/localize.py +138 -0
  40. appfactory/maestro.py +330 -0
  41. appfactory/mascot.py +201 -0
  42. appfactory/metadata.py +439 -0
  43. appfactory/options.py +318 -0
  44. appfactory/orchestrator.py +144 -0
  45. appfactory/pipeline.py +271 -0
  46. appfactory/preview.py +454 -0
  47. appfactory/proc.py +24 -0
  48. appfactory/revenuecat.py +171 -0
  49. appfactory/sbp.py +158 -0
  50. appfactory/screenshot.py +329 -0
  51. appfactory/server.py +1674 -0
  52. appfactory/setup_gui.py +268 -0
  53. appfactory/setup_tools.py +115 -0
  54. appfactory/signing.py +287 -0
  55. appfactory/spec.py +470 -0
  56. appfactory/store_setup.py +1677 -0
  57. appfactory/storekit.py +213 -0
  58. appfactory/supabase.py +310 -0
  59. appfactory/team.py +172 -0
  60. appfactory/templates/docs/CHECKLIST.md +86 -0
  61. appfactory/templates/docs/aso-research.md +60 -0
  62. appfactory/templates/docs/onboarding-plan.md +53 -0
  63. appfactory/templates/swiftui-subscription/.gitignore +6 -0
  64. appfactory/templates/swiftui-subscription/.maestro/config.yaml +11 -0
  65. appfactory/templates/swiftui-subscription/.maestro/flows/smoke_onboarding_paywall.yaml +89 -0
  66. appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +4 -0
  67. appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +4 -0
  68. appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/Contents.json +1 -0
  69. appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/Mascot/Contents.json +4 -0
  70. appfactory/templates/swiftui-subscription/Resources/InfoPlist.xcstrings +132 -0
  71. appfactory/templates/swiftui-subscription/Resources/Localizable.xcstrings +7507 -0
  72. appfactory/templates/swiftui-subscription/Scripts/sim_store_prep.sh +59 -0
  73. appfactory/templates/swiftui-subscription/Scripts/sync_strings.py +46 -0
  74. appfactory/templates/swiftui-subscription/Scripts/translations.py +464 -0
  75. appfactory/templates/swiftui-subscription/Sources/App/App.swift +116 -0
  76. appfactory/templates/swiftui-subscription/Sources/App/AppConfig.swift +179 -0
  77. appfactory/templates/swiftui-subscription/Sources/App/LaunchOptions.swift +52 -0
  78. appfactory/templates/swiftui-subscription/Sources/Core/AIService.swift +91 -0
  79. appfactory/templates/swiftui-subscription/Sources/Core/Analytics.swift +193 -0
  80. appfactory/templates/swiftui-subscription/Sources/Core/AnalyticsGate.swift +77 -0
  81. appfactory/templates/swiftui-subscription/Sources/Core/AuthProvider.swift +118 -0
  82. appfactory/templates/swiftui-subscription/Sources/Core/LocalUsageGate.swift +21 -0
  83. appfactory/templates/swiftui-subscription/Sources/Core/Proxy.swift +147 -0
  84. appfactory/templates/swiftui-subscription/Sources/Core/RatingPolicy.swift +171 -0
  85. appfactory/templates/swiftui-subscription/Sources/Core/StoreManager.swift +512 -0
  86. appfactory/templates/swiftui-subscription/Sources/Core/SupabaseAuth.swift +292 -0
  87. appfactory/templates/swiftui-subscription/Sources/Credits/CreditManager.swift +80 -0
  88. appfactory/templates/swiftui-subscription/Sources/Credits/TopUpSheet.swift +245 -0
  89. appfactory/templates/swiftui-subscription/Sources/Design/Controls.swift +426 -0
  90. appfactory/templates/swiftui-subscription/Sources/Design/MascotView.swift +393 -0
  91. appfactory/templates/swiftui-subscription/Sources/Design/Motion.swift +282 -0
  92. appfactory/templates/swiftui-subscription/Sources/DesignSystem.swift +193 -0
  93. appfactory/templates/swiftui-subscription/Sources/Health/HealthStore.swift +270 -0
  94. appfactory/templates/swiftui-subscription/Sources/Main/GenerationFlow.swift +414 -0
  95. appfactory/templates/swiftui-subscription/Sources/Main/GenerationStore.swift +137 -0
  96. appfactory/templates/swiftui-subscription/Sources/Main/RootTabView.swift +443 -0
  97. appfactory/templates/swiftui-subscription/Sources/Model/BirthYears.swift +44 -0
  98. appfactory/templates/swiftui-subscription/Sources/Model/Units.swift +72 -0
  99. appfactory/templates/swiftui-subscription/Sources/Model/UserProfile.swift +118 -0
  100. appfactory/templates/swiftui-subscription/Sources/Onboarding/OnboardingModel.swift +133 -0
  101. appfactory/templates/swiftui-subscription/Sources/Onboarding/OnboardingSteps.swift +230 -0
  102. appfactory/templates/swiftui-subscription/Sources/Onboarding/OnboardingView.swift +192 -0
  103. appfactory/templates/swiftui-subscription/Sources/Onboarding/Screens/StepScreens.swift +363 -0
  104. appfactory/templates/swiftui-subscription/Sources/Paywall/HardPaywallView.swift +241 -0
  105. appfactory/templates/swiftui-subscription/Sources/Paywall/OfferPaywallView.swift +211 -0
  106. appfactory/templates/swiftui-subscription/Sources/Paywall/PaywallComponents.swift +257 -0
  107. appfactory/templates/swiftui-subscription/Sources/Paywall/PaywallPersonalization.swift +58 -0
  108. appfactory/templates/swiftui-subscription/Sources/Paywall/PaywallPricing.swift +302 -0
  109. appfactory/templates/swiftui-subscription/Tests/AnalyticsGateTests.swift +52 -0
  110. appfactory/templates/swiftui-subscription/Tests/BillingTests.swift +113 -0
  111. appfactory/templates/swiftui-subscription/Tests/HealthTests.swift +52 -0
  112. appfactory/templates/swiftui-subscription/Tests/LocalizationCompletenessTests.swift +121 -0
  113. appfactory/templates/swiftui-subscription/Tests/OnboardingTests.swift +186 -0
  114. appfactory/templates/swiftui-subscription/Tests/PaywallTests.swift +191 -0
  115. appfactory/templates/swiftui-subscription/Tests/RatingPolicyTests.swift +131 -0
  116. appfactory/templates/swiftui-subscription/UITests/LayoutAudit.swift +379 -0
  117. appfactory/templates/swiftui-subscription/UITests/LayoutAuditRuleTests.swift +56 -0
  118. appfactory/templates/swiftui-subscription/UITests/LayoutAuditUITests.swift +66 -0
  119. appfactory/templates/swiftui-subscription/UITests/LocalizationUITests.swift +36 -0
  120. appfactory/templates/swiftui-subscription/UITests/OnboardingFlowUITests.swift +115 -0
  121. appfactory/templates/swiftui-subscription/backend/CONTRACT.md +201 -0
  122. appfactory/templates/swiftui-subscription/backend/EVAL.md +50 -0
  123. appfactory/templates/swiftui-subscription/backend/PRIVACY.md +74 -0
  124. appfactory/templates/swiftui-subscription/backend/README.md +72 -0
  125. appfactory/templates/swiftui-subscription/backend/eval/.gitignore +3 -0
  126. appfactory/templates/swiftui-subscription/backend/eval/data/cases.json +9 -0
  127. appfactory/templates/swiftui-subscription/backend/eval/prepare.ts +68 -0
  128. appfactory/templates/swiftui-subscription/backend/eval/run.ts +188 -0
  129. appfactory/templates/swiftui-subscription/backend/eval/score.ts +106 -0
  130. appfactory/templates/swiftui-subscription/backend/eval/score_app.ts +28 -0
  131. appfactory/templates/swiftui-subscription/backend/eval/score_test.ts +119 -0
  132. appfactory/templates/swiftui-subscription/backend/legal/build.ts +158 -0
  133. appfactory/templates/swiftui-subscription/backend/legal/build_test.ts +89 -0
  134. appfactory/templates/swiftui-subscription/backend/legal/verify.ts +47 -0
  135. appfactory/templates/swiftui-subscription/backend/smoke/smoke.ts +136 -0
  136. appfactory/templates/swiftui-subscription/marketing/preview/README.md +16 -0
  137. appfactory/templates/swiftui-subscription/marketing/screenshots/build.mjs +188 -0
  138. appfactory/templates/swiftui-subscription/marketing/screenshots/config.json +6 -0
  139. appfactory/templates/swiftui-subscription/marketing/screenshots/package.json +7 -0
  140. appfactory/templates/swiftui-subscription/project.yml +162 -0
  141. appfactory/templates/swiftui-subscription/store/CHECKLIST.md +88 -0
  142. appfactory/templates/swiftui-subscription/store/metadata/listing.json +209 -0
  143. appfactory/templates/swiftui-subscription/store/pricing.md +96 -0
  144. appfactory/templates/swiftui-subscription/store/privacy/privacy-policy.en.md +130 -0
  145. appfactory/templates/swiftui-subscription/store/privacy/support.en.md +21 -0
  146. appfactory/templates/swiftui-subscription/store/privacy/terms.en.md +105 -0
  147. appfactory/templates/swiftui-subscription/store/review-notes.md +59 -0
  148. appfactory/templates/swiftui-subscription/supabase/.gitignore +2 -0
  149. appfactory/templates/swiftui-subscription/supabase/config.toml +48 -0
  150. appfactory/templates/swiftui-subscription/supabase/deno.json +9 -0
  151. appfactory/templates/swiftui-subscription/supabase/deno.lock +101 -0
  152. appfactory/templates/swiftui-subscription/supabase/functions/_shared/analysis.ts +119 -0
  153. appfactory/templates/swiftui-subscription/supabase/functions/_shared/app.gen.ts +14 -0
  154. appfactory/templates/swiftui-subscription/supabase/functions/_shared/config.ts +64 -0
  155. appfactory/templates/swiftui-subscription/supabase/functions/_shared/credits.ts +172 -0
  156. appfactory/templates/swiftui-subscription/supabase/functions/_shared/credits_rc.ts +64 -0
  157. appfactory/templates/swiftui-subscription/supabase/functions/_shared/entitlement.ts +58 -0
  158. appfactory/templates/swiftui-subscription/supabase/functions/_shared/http.ts +48 -0
  159. appfactory/templates/swiftui-subscription/supabase/functions/_shared/llm.ts +176 -0
  160. appfactory/templates/swiftui-subscription/supabase/functions/_shared/locale.ts +108 -0
  161. appfactory/templates/swiftui-subscription/supabase/functions/_shared/rc.ts +100 -0
  162. appfactory/templates/swiftui-subscription/supabase/functions/_shared/store.ts +132 -0
  163. appfactory/templates/swiftui-subscription/supabase/functions/_shared/usage.ts +132 -0
  164. appfactory/templates/swiftui-subscription/supabase/functions/ai-proxy/index.ts +146 -0
  165. appfactory/templates/swiftui-subscription/supabase/functions/analyze/handler.ts +204 -0
  166. appfactory/templates/swiftui-subscription/supabase/functions/analyze/index.ts +25 -0
  167. appfactory/templates/swiftui-subscription/supabase/functions/delete-account/handler.ts +37 -0
  168. appfactory/templates/swiftui-subscription/supabase/functions/delete-account/index.ts +18 -0
  169. appfactory/templates/swiftui-subscription/supabase/functions/legal/content.gen.ts +8 -0
  170. appfactory/templates/swiftui-subscription/supabase/functions/legal/handler.ts +77 -0
  171. appfactory/templates/swiftui-subscription/supabase/functions/legal/index.ts +6 -0
  172. appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/fixtures/cancellation.json +14 -0
  173. appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/fixtures/initial_purchase.json +15 -0
  174. appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/fixtures/trial_started.json +14 -0
  175. appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/index.ts +18 -0
  176. appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/map.ts +36 -0
  177. appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/map_test.ts +21 -0
  178. appfactory/templates/swiftui-subscription/supabase/functions/usage-status/handler.ts +34 -0
  179. appfactory/templates/swiftui-subscription/supabase/functions/usage-status/index.ts +22 -0
  180. appfactory/templates/swiftui-subscription/supabase/migrations/0001_core.sql +118 -0
  181. appfactory/templates/swiftui-subscription/supabase/migrations/0002_analytics.sql +105 -0
  182. appfactory/templates/swiftui-subscription/supabase/migrations/0003_consent.sql +25 -0
  183. appfactory/templates/swiftui-subscription/supabase/migrations/0004_calendar_caps.sql +50 -0
  184. appfactory/templates/swiftui-subscription/supabase/migrations/0005_events_revenue.sql +61 -0
  185. appfactory/templates/swiftui-subscription/supabase/migrations_credits/0001_ai_usage.sql +11 -0
  186. appfactory/templates/swiftui-subscription/supabase/migrations_credits/0002_events.sql +14 -0
  187. appfactory/templates/swiftui-subscription/supabase/migrations_credits/0003_credits.sql +84 -0
  188. appfactory/templates/swiftui-subscription/supabase/migrations_credits/0004_adops.sql +43 -0
  189. appfactory/templates/swiftui-subscription/supabase/tests/analyze_test.ts +375 -0
  190. appfactory/templates/swiftui-subscription/supabase/tests/calendar_caps_db_test.ts +71 -0
  191. appfactory/templates/swiftui-subscription/supabase/tests/db_harness.ts +76 -0
  192. appfactory/templates/swiftui-subscription/supabase/tests/db_test.ts +215 -0
  193. appfactory/templates/swiftui-subscription/supabase/tests/delete_account_test.ts +39 -0
  194. appfactory/templates/swiftui-subscription/supabase/tests/fakes.ts +153 -0
  195. appfactory/templates/swiftui-subscription/supabase/tests/legal_test.ts +60 -0
  196. appfactory/templates/swiftui-subscription/supabase/tests/shared_test.ts +355 -0
  197. appfactory/templates/team/TEAM.md +118 -0
  198. appfactory/templates/team/backend.md +15 -0
  199. appfactory/templates/team/ios.md +20 -0
  200. appfactory/templates/team/store.md +20 -0
  201. appfactory-0.1.0.dist-info/METADATA +121 -0
  202. appfactory-0.1.0.dist-info/RECORD +205 -0
  203. appfactory-0.1.0.dist-info/WHEEL +4 -0
  204. appfactory-0.1.0.dist-info/entry_points.txt +3 -0
  205. appfactory-0.1.0.dist-info/licenses/LICENSE +21 -0
appfactory/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AppFactory MCP — semi-autonomous SwiftUI iOS subscription app factory."""
2
+
3
+ __version__ = "0.1.0"
appfactory/ai.py ADDED
@@ -0,0 +1,275 @@
1
+ """ai_* / backend_deploy — AI backend deploy + configuration.
2
+
3
+ AI keys (fal.ai etc.) only go to Supabase secrets; they never enter the app.
4
+
5
+ Subscription mode (default stack): backend_deploy renders the spec-driven files, applies the
6
+ migrations (tracked in supabase_migrations.schema_migrations, so `supabase db push` agrees), writes
7
+ the secrets from app.spec.json + ~/.appfactory/config.toml + per-app outputs, turns on anonymous
8
+ sign-ins + Sign in with Apple + manual linking, builds the legal pages and deploys the function set.
9
+ Credits mode (legacy credits model): deploy_proxy (ai-proxy + FAL_KEY + legal).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ from . import config as cfg
19
+ from . import supabase as supa
20
+ from .proc import run
21
+
22
+
23
+ def deploy_proxy(app_dir: str | Path, project_ref: str, daily_limit: int = 20) -> dict[str, Any]:
24
+ """Deploy ai-proxy: migration + secrets + supabase functions deploy."""
25
+ app_dir = Path(app_dir).expanduser()
26
+ fn_dir = app_dir / "supabase" / "functions" / "ai-proxy"
27
+ if not fn_dir.exists():
28
+ return {"ok": False, "error": f"ai-proxy function not found: {fn_dir}"}
29
+
30
+ c = cfg.load_config()
31
+ fal_key = c.get("fal_key")
32
+ access_token = c.get("supabase_access_token")
33
+ if not fal_key:
34
+ return {"ok": False, "error": "fal_key missing (config_set)"}
35
+ if not access_token:
36
+ return {"ok": False, "error": "supabase_access_token missing"}
37
+
38
+ steps: dict[str, Any] = {}
39
+
40
+ # 1) ai_usage migration
41
+ try:
42
+ client = supa.SupabaseClient()
43
+ except supa.SupabaseError as e:
44
+ return {"ok": False, "error": str(e)}
45
+ mig = app_dir / "supabase" / "migrations" / "0001_ai_usage.sql"
46
+ if not mig.exists(): # scaffold before apply_backend_mode(credits)
47
+ mig = app_dir / "supabase" / "migrations_credits" / "0001_ai_usage.sql"
48
+ if mig.exists():
49
+ steps["migration"] = client.run_sql(project_ref, mig.read_text(encoding="utf-8"))
50
+
51
+ # 2) secrets (FAL_KEY + daily limit) — server-side, never enters the app
52
+ steps["secrets"] = client.set_secrets(project_ref, {
53
+ "FAL_KEY": fal_key,
54
+ "AI_DAILY_LIMIT": str(daily_limit),
55
+ })
56
+
57
+ # 2b) enable anonymous auth (the app JWT calls ai-proxy with this) — automatic
58
+ steps["anonymous_auth"] = client.enable_anonymous_auth(project_ref)
59
+
60
+ # 3) deploy function via supabase CLI (SUPABASE_ACCESS_TOKEN env)
61
+ env_run = os.environ.copy()
62
+ env_run["SUPABASE_ACCESS_TOKEN"] = access_token
63
+ import subprocess
64
+ try:
65
+ p = subprocess.run(
66
+ ["supabase", "functions", "deploy", "ai-proxy", "--project-ref", project_ref],
67
+ cwd=str(app_dir), capture_output=True, text=True, timeout=300, env=env_run,
68
+ )
69
+ steps["deploy"] = {"ok": p.returncode == 0, "stdout": p.stdout.strip()[-800:], "stderr": p.stderr.strip()[-800:]}
70
+ except FileNotFoundError:
71
+ return {"ok": False, "error": "supabase CLI not found"}
72
+ except subprocess.TimeoutExpired:
73
+ return {"ok": False, "error": "deploy timed out"}
74
+
75
+ # 4) legal (privacy/terms/support) edge function — REQUIRED for every app, public.
76
+ legal_dir = app_dir / "supabase" / "functions" / "legal"
77
+ if legal_dir.exists():
78
+ app_name = c.get("app_display_name") or "This App"
79
+ steps["legal_secrets"] = client.set_secrets(project_ref, {
80
+ "LEGAL_APP_NAME": app_name,
81
+ # RULE: support/contact email is support_email (not apple_id, which is only the login).
82
+ "LEGAL_SUPPORT_EMAIL": c.get("support_email") or c.get("apple_id") or "support@example.com",
83
+ "LEGAL_COMPANY": app_name,
84
+ })
85
+ try:
86
+ pl = subprocess.run(
87
+ ["supabase", "functions", "deploy", "legal", "--project-ref", project_ref, "--no-verify-jwt"],
88
+ cwd=str(app_dir), capture_output=True, text=True, timeout=300, env=env_run,
89
+ )
90
+ steps["legal_deploy"] = {"ok": pl.returncode == 0, "stderr": pl.stderr.strip()[-400:]}
91
+ except Exception as e: # noqa: BLE001
92
+ steps["legal_deploy"] = {"ok": False, "error": str(e)}
93
+
94
+ ok = steps["deploy"]["ok"]
95
+ base = f"https://{project_ref}.supabase.co/functions/v1"
96
+ return {
97
+ "ok": ok,
98
+ "function_url": f"{base}/ai-proxy",
99
+ "legal_urls": {"privacy": f"{base}/legal/privacy", "terms": f"{base}/legal/terms", "support": f"{base}/legal/support"},
100
+ "steps": steps,
101
+ }
102
+
103
+
104
+ def configure(providers: list[str] | None = None, models: dict[str, str] | None = None) -> dict[str, Any]:
105
+ """Report AI provider/model selection (currently fal.ai/Flux). Extensible later."""
106
+ return {
107
+ "ok": True,
108
+ "providers": providers or ["fal.ai"],
109
+ "models": models or {"image": "fal-ai/flux/schnell"},
110
+ "note": "Keys are written to server-side secrets via ai_deploy_proxy.",
111
+ }
112
+
113
+
114
+ # ---------------------------------------------------------------------------------------------
115
+ # Subscription-mode backend deploy (backend_deploy)
116
+ # ---------------------------------------------------------------------------------------------
117
+ import json as _json # noqa: E402
118
+ import re as _re # noqa: E402
119
+
120
+ from . import legal as legal_mod # noqa: E402
121
+ from . import spec as spec_mod # noqa: E402
122
+
123
+ _MIGRATION_TRACKING = (
124
+ "create schema if not exists supabase_migrations;\n"
125
+ "create table if not exists supabase_migrations.schema_migrations "
126
+ "(version text primary key, statements text[], name text);\n"
127
+ )
128
+
129
+
130
+ def _migrations(app_dir: Path) -> list[Path]:
131
+ return sorted((app_dir / "supabase" / "migrations").glob("*.sql"))
132
+
133
+
134
+ def _version(p: Path) -> str:
135
+ return p.name.split("_", 1)[0]
136
+
137
+
138
+ def _applied_versions(client: Any, ref: str) -> tuple[set[str] | None, str | None]:
139
+ r = client.run_sql(ref, _MIGRATION_TRACKING + "select version from supabase_migrations.schema_migrations;")
140
+ if not r.get("ok"):
141
+ return None, str(r.get("error"))[:300]
142
+ data = r.get("data")
143
+ rows = data if isinstance(data, list) else []
144
+ return {str(x.get("version")) for x in rows if isinstance(x, dict)}, None
145
+
146
+
147
+ def _record_sql(p: Path) -> str:
148
+ name = p.stem.split("_", 1)[1] if "_" in p.stem else p.stem
149
+ return (f"insert into supabase_migrations.schema_migrations (version, name) "
150
+ f"values ('{_version(p)}', '{name}') on conflict (version) do nothing;")
151
+
152
+
153
+ def plan_backend(app_dir: str | Path, spec: dict[str, Any] | None = None) -> dict[str, Any]:
154
+ """Offline: what backend_deploy would do (functions, migrations, secret NAMES, auth config)."""
155
+ app = Path(app_dir).expanduser()
156
+ sp = spec or spec_mod.load(app)
157
+ mode = supa.mode_of(sp)
158
+ c = cfg.load_config()
159
+ outs = cfg.app_outputs(app)
160
+ secrets, missing = supa.backend_secrets(sp, c, outs) if mode == "subscription" else ({}, [])
161
+ return {
162
+ "ok": True, "mode": mode,
163
+ "ai_enabled": spec_mod.ai_enabled(sp),
164
+ "functions": supa.functions_for(sp),
165
+ "public_functions": sorted(supa.PUBLIC_FUNCTIONS & set(supa.functions_for(sp))),
166
+ "migrations": [p.name for p in _migrations(app)],
167
+ "secret_names": sorted(secrets),
168
+ "missing_secrets": missing,
169
+ "auth_config": supa.auth_config(sp) if mode == "subscription" else {"external_anonymous_users_enabled": True},
170
+ }
171
+
172
+
173
+ def deploy_backend(app_dir: str | Path, project_ref: str, *, client: Any = None,
174
+ runner: Callable[..., dict[str, Any]] | None = None,
175
+ config: dict[str, Any] | None = None, dry_run: bool = True) -> dict[str, Any]:
176
+ """Deploy the spec's function set + migrations + secrets + auth config to one Supabase project.
177
+
178
+ LIVE side effects unless dry_run. Secrets are reported by NAME only. Writes the per-app outputs
179
+ (supabase_ref/url, legal_base, functions) and the verify marker .appfactory/verify/backend_deploy.json.
180
+ """
181
+ app = Path(app_dir).expanduser()
182
+ if not spec_mod.path(app).exists():
183
+ return {"ok": False, "error": f"{spec_mod.SPEC_FILE} not found in {app} (spec is required)"}
184
+ sp = spec_mod.load(app)
185
+ errs = spec_mod.validate(sp)
186
+ if errs:
187
+ return {"ok": False, "error": "invalid app.spec.json: " + "; ".join(errs)}
188
+ mode = supa.mode_of(sp)
189
+ if mode == "credits":
190
+ return deploy_proxy(app, project_ref)
191
+ plan = plan_backend(app, sp)
192
+ if dry_run:
193
+ return {**plan, "dry_run": True}
194
+ c = config if config is not None else cfg.load_config()
195
+ access_token = c.get("supabase_access_token")
196
+ if client is None:
197
+ try:
198
+ client = supa.SupabaseClient()
199
+ except supa.SupabaseError as e:
200
+ return {"ok": False, "error": str(e)}
201
+ runner = runner or run
202
+ outs = cfg.app_outputs(app)
203
+ secrets, missing = supa.backend_secrets(sp, c, outs)
204
+ hard_missing = [m for m in missing if m in supa.required_secrets(sp)]
205
+ steps: dict[str, Any] = {}
206
+
207
+ # 1) spec-driven files + legal pages (build refuses placeholders; the rest still deploys)
208
+ steps["render"] = supa.render_backend(app, sp)
209
+ legal_chk = legal_mod.check(app, sp)
210
+ lb = runner(["deno", "run", "--allow-read", "--allow-write", "backend/legal/build.ts"], cwd=str(app), timeout=120)
211
+ steps["legal_build"] = {"ok": lb.get("ok", False), "publishable": legal_chk["publishable"],
212
+ "blocking": legal_chk["blocking"],
213
+ "detail": (lb.get("stderr") or lb.get("error") or "")[-400:]}
214
+
215
+ # 2) migrations, in order, each once (tracked like the supabase CLI does)
216
+ applied, err = _applied_versions(client, project_ref)
217
+ mig_steps = []
218
+ if applied is None:
219
+ mig_steps.append({"ok": False, "error": f"could not read applied migrations: {err}"})
220
+ else:
221
+ for p in _migrations(app):
222
+ if _version(p) in applied:
223
+ mig_steps.append({"file": p.name, "ok": True, "skipped": "already applied"})
224
+ continue
225
+ r = client.run_sql(project_ref, p.read_text(encoding="utf-8") + "\n" + _record_sql(p))
226
+ mig_steps.append({"file": p.name, "ok": bool(r.get("ok")),
227
+ **({} if r.get("ok") else {"error": str(r.get("error"))[:300]})})
228
+ if not r.get("ok"):
229
+ break # later migrations depend on this one
230
+ steps["migrations"] = mig_steps
231
+
232
+ # 3) secrets (names only in the report)
233
+ sr = client.set_secrets(project_ref, secrets)
234
+ steps["secrets"] = {"ok": bool(sr.get("ok")), "names": sorted(secrets), "missing": missing,
235
+ **({} if sr.get("ok") else {"error": str(sr.get("error"))[:300]})}
236
+
237
+ # 4) auth: anonymous sign-ins + Sign in with Apple (native, client id = bundle id) + manual linking
238
+ ar = client.update_auth_config(project_ref, supa.auth_config(sp))
239
+ steps["auth"] = {"ok": bool(ar.get("ok")), **({} if ar.get("ok") else {"error": str(ar.get("error"))[:300]})}
240
+
241
+ # 5) functions via the supabase CLI (token only in the child env)
242
+ env_run = os.environ.copy()
243
+ if access_token:
244
+ env_run["SUPABASE_ACCESS_TOKEN"] = access_token
245
+ fn_steps = []
246
+ for name in supa.functions_for(sp):
247
+ cmd = ["supabase", "functions", "deploy", name, "--project-ref", project_ref, "--use-api"]
248
+ if name in supa.PUBLIC_FUNCTIONS:
249
+ cmd.append("--no-verify-jwt")
250
+ r = runner(cmd, cwd=str(app), timeout=300, env=env_run)
251
+ fn_steps.append({"function": name, "ok": bool(r.get("ok")),
252
+ **({} if r.get("ok") else {"error": (r.get("stderr") or r.get("error") or "")[-400:]})})
253
+ steps["functions"] = fn_steps
254
+
255
+ supabase_url = f"https://{project_ref}.supabase.co"
256
+ ok = (all(m.get("ok") for m in mig_steps) and steps["secrets"]["ok"] and steps["auth"]["ok"]
257
+ and all(f["ok"] for f in fn_steps) and not hard_missing)
258
+ cfg.update_app_outputs(app, supabase_ref=project_ref, supabase_url=supabase_url,
259
+ legal_base=legal_mod.base_url(supabase_url),
260
+ functions=[f["function"] for f in fn_steps if f["ok"]])
261
+ marker = app / ".appfactory" / "verify" / "backend_deploy.json"
262
+ marker.parent.mkdir(parents=True, exist_ok=True)
263
+ marker.write_text(_json.dumps({
264
+ "ok": ok, "mode": mode, "project_ref": project_ref, "ai_enabled": spec_mod.ai_enabled(sp),
265
+ "functions": [f["function"] for f in fn_steps if f["ok"]],
266
+ "secret_names": sorted(secrets), "missing_secrets": missing,
267
+ "legal_published": legal_chk["publishable"],
268
+ }, indent=2) + "\n", encoding="utf-8")
269
+ base = f"{supabase_url}/functions/v1"
270
+ return {
271
+ "ok": ok, "mode": mode, "steps": steps,
272
+ "missing_required_secrets": hard_missing,
273
+ "function_urls": {f: f"{base}/{f}" for f in supa.functions_for(sp)},
274
+ "legal_urls": {d: legal_mod.url(supabase_url, d) for d in ("privacy", "terms", "support")},
275
+ }
@@ -0,0 +1,164 @@
1
+ """Cute Lottie animations for apps — fetch from LottieFiles free library + recolor to app palette.
2
+
3
+ Pipeline (headless, no npm): LottieFiles public GraphQL (no auth, Lottie Simple License = commercial,
4
+ no attribution) → pick top-viewed match by keyword → recolor JSON to the app's DS.Palette (dominant→primary,
5
+ second→accent, preserve near-black/near-white) → write Resources/Animations/<slot>.json. lottie-spm renders it.
6
+ Colors in Lottie JSON: fill/stroke items ("ty":"fl"/"st") have c.k=[r,g,b,a] normalized 0..1; gradients
7
+ ("ty":"gf") have g.k.k=[stop,r,g,b,...]. We recurse shapes/groups.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ GRAPHQL = "https://graphql.lottiefiles.com/"
17
+ _SEARCH = """query Search($q: String!) {
18
+ searchPublicAnimations(query: $q, first: 8, orderBy: { column: VIEWS_COUNT, order: DESC }) {
19
+ edges { node { name slug jsonUrl lottieUrl bgColor } }
20
+ }
21
+ }"""
22
+
23
+ # Default keyword per slot (app can override via keyword arg for on-theme cuteness).
24
+ SLOT_KEYWORDS = {
25
+ "loading": "cute loading",
26
+ "success": "success celebration confetti",
27
+ "empty": "empty box cute",
28
+ "onboarding_hero": "cute welcome hero",
29
+ }
30
+
31
+
32
+ _UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
33
+
34
+
35
+ def _post_json(url: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
36
+ req = urllib.request.Request(
37
+ url, data=json.dumps(payload).encode(),
38
+ headers={"Content-Type": "application/json", "User-Agent": _UA, "Accept": "application/json"})
39
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted host)
40
+ return json.loads(r.read().decode())
41
+
42
+
43
+ def _get(url: str, timeout: int = 30) -> bytes:
44
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
45
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
46
+ return r.read()
47
+
48
+
49
+ def search(keyword: str) -> list[dict[str, Any]]:
50
+ """Top-viewed free animations for a keyword. Returns [{name, slug, jsonUrl, bgColor}]."""
51
+ d = _post_json(GRAPHQL, {"query": _SEARCH, "variables": {"q": keyword}})
52
+ edges = (((d.get("data") or {}).get("searchPublicAnimations") or {}).get("edges")) or []
53
+ return [e["node"] for e in edges if e.get("node", {}).get("jsonUrl")]
54
+
55
+
56
+ def _hex_to_rgb01(h: str) -> tuple[float, float, float]:
57
+ h = h.lstrip("#")
58
+ return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255)
59
+
60
+
61
+ def _is_neutral(rgb: tuple[float, float, float], eps: float = 0.14) -> bool:
62
+ """Near-black / near-white / near-gray — DON'T recolor (backgrounds, outlines, eyes)."""
63
+ r, g, b = rgb
64
+ mx, mn = max(r, g, b), min(r, g, b)
65
+ if mx <= eps or mn >= 1 - eps: # near black / near white
66
+ return True
67
+ return (mx - mn) <= eps # near gray (low saturation)
68
+
69
+
70
+ def _walk_colors(node: Any, apply):
71
+ """Recurse Lottie tree, calling apply([r,g,b]) on every solid fill/stroke color array (in place)."""
72
+ if isinstance(node, dict):
73
+ ty = node.get("ty")
74
+ if ty in ("fl", "st"): # fill / stroke
75
+ c = node.get("c")
76
+ if isinstance(c, dict) and c.get("a") == 0 and isinstance(c.get("k"), list) and len(c["k"]) >= 3:
77
+ apply(c["k"])
78
+ # gradients: g.k.k = [stop,r,g,b, stop,r,g,b, ...] — recolor RGB triplets in place
79
+ if ty == "gf" and isinstance(node.get("g"), dict):
80
+ gk = node["g"].get("k", {})
81
+ arr = gk.get("k") if isinstance(gk, dict) else None
82
+ if isinstance(arr, list):
83
+ for i in range(0, len(arr) - 3, 4):
84
+ apply(arr, i + 1) # rgb starts at offset+1
85
+ for v in node.values():
86
+ _walk_colors(v, apply)
87
+ elif isinstance(node, list):
88
+ for v in node:
89
+ _walk_colors(v, apply)
90
+
91
+
92
+ def recolor(anim: dict[str, Any], primary_hex: str, accent_hex: str) -> dict[str, Any]:
93
+ """Recolor in place: dominant color → primary, everything else non-neutral → accent.
94
+ Preserves near-black/white/gray. Simple + robust across arbitrary cute assets (no keypaths)."""
95
+ prim = _hex_to_rgb01(primary_hex)
96
+ acc = _hex_to_rgb01(accent_hex)
97
+
98
+ # Pass 1: find the dominant non-neutral color (by frequency).
99
+ freq: dict[tuple, int] = {}
100
+
101
+ def _tally(arr, off=0):
102
+ rgb = (round(arr[off], 3), round(arr[off + 1], 3), round(arr[off + 2], 3))
103
+ if not _is_neutral(rgb):
104
+ freq[rgb] = freq.get(rgb, 0) + 1
105
+
106
+ _walk_colors(anim, _tally)
107
+ dominant = max(freq, key=freq.get) if freq else None
108
+
109
+ # Pass 2: apply.
110
+ def _apply(arr, off=0):
111
+ rgb = (round(arr[off], 3), round(arr[off + 1], 3), round(arr[off + 2], 3))
112
+ if _is_neutral(rgb):
113
+ return
114
+ tgt = prim if (dominant and rgb == dominant) else acc
115
+ arr[off], arr[off + 1], arr[off + 2] = tgt
116
+
117
+ _walk_colors(anim, _apply)
118
+ return anim
119
+
120
+
121
+ def fetch_recolor(app_dir: str, slot: str, primary_hex: str, accent_hex: str,
122
+ keyword: str | None = None) -> dict[str, Any]:
123
+ """Fetch a cute free Lottie for `slot`, recolor to palette, write Resources/Animations/<slot>.json.
124
+
125
+ slot: loading | success | empty | onboarding_hero. keyword: on-theme override (else slot default).
126
+ primary_hex/accent_hex: app DS.Palette light hex (e.g. '006A63'). Returns {ok, path, source_name}.
127
+ """
128
+ if slot not in SLOT_KEYWORDS:
129
+ return {"ok": False, "error": f"bilinmeyen slot '{slot}' (loading|success|empty|onboarding_hero)"}
130
+ kw = keyword or SLOT_KEYWORDS[slot]
131
+ try:
132
+ hits = search(kw)
133
+ except Exception as e: # noqa: BLE001
134
+ return {"ok": False, "error": f"LottieFiles search failed: {e}"}
135
+ if not hits:
136
+ return {"ok": False, "error": f"no results for '{kw}' — try a different keyword"}
137
+ node = hits[0]
138
+ try:
139
+ anim = json.loads(_get(node["jsonUrl"]).decode())
140
+ except Exception as e: # noqa: BLE001
141
+ return {"ok": False, "error": f"JSON indirilemedi ({node.get('name')}): {e}"}
142
+
143
+ recolor(anim, primary_hex, accent_hex)
144
+
145
+ out_dir = Path(app_dir) / "Resources" / "Animations"
146
+ out_dir.mkdir(parents=True, exist_ok=True)
147
+ out = out_dir / f"{slot}.json"
148
+ out.write_text(json.dumps(anim, separators=(",", ":")), encoding="utf-8")
149
+ return {"ok": True, "path": str(out), "source_name": node.get("name"),
150
+ "slug": node.get("slug"), "keyword": kw}
151
+
152
+
153
+ if __name__ == "__main__": # self-check: recolor swaps non-neutral, preserves neutral
154
+ demo = {"layers": [{"shapes": [
155
+ {"ty": "fl", "c": {"a": 0, "k": [0.9, 0.1, 0.1, 1]}}, # red → dominant → primary
156
+ {"ty": "st", "c": {"a": 0, "k": [0.02, 0.02, 0.02, 1]}}, # near-black → preserved
157
+ {"ty": "fl", "c": {"a": 0, "k": [0.1, 0.1, 0.9, 1]}}, # blue → accent
158
+ ]}]}
159
+ recolor(demo, "006A63", "00649A") # primary teal, accent blue
160
+ fills = demo["layers"][0]["shapes"]
161
+ assert fills[0]["c"]["k"][:3] == list(_hex_to_rgb01("006A63")), fills[0]["c"]["k"]
162
+ assert fills[1]["c"]["k"][:3] == [0.02, 0.02, 0.02], "near-black must be preserved"
163
+ assert fills[2]["c"]["k"][:3] == list(_hex_to_rgb01("00649A")), fills[2]["c"]["k"]
164
+ print("recolor self-check OK")