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.
- appfactory/__init__.py +3 -0
- appfactory/ai.py +275 -0
- appfactory/animation.py +164 -0
- appfactory/app.py +679 -0
- appfactory/approvals.py +149 -0
- appfactory/asc.py +705 -0
- appfactory/asc_cli.py +222 -0
- appfactory/aso.py +604 -0
- appfactory/bin/maestro-live +227 -0
- appfactory/build.py +114 -0
- appfactory/cli.py +263 -0
- appfactory/config.py +376 -0
- appfactory/cpp.py +56 -0
- appfactory/data/generic_localizations.json +3980 -0
- appfactory/data/playbook.md +236 -0
- appfactory/data/setup.html +117 -0
- appfactory/deliver.py +340 -0
- appfactory/design/__init__.py +317 -0
- appfactory/design/brand.py +220 -0
- appfactory/design/dc.py +238 -0
- appfactory/design/export.py +65 -0
- appfactory/design/main_app.py +433 -0
- appfactory/design/mascot_boards.py +184 -0
- appfactory/design/onboarding.py +878 -0
- appfactory/design/pricing.py +80 -0
- appfactory/design/receipt.py +116 -0
- appfactory/design/research.py +460 -0
- appfactory/design/rules.py +78 -0
- appfactory/design/screens.py +472 -0
- appfactory/design/theme.py +172 -0
- appfactory/firebase.py +116 -0
- appfactory/gates.py +1530 -0
- appfactory/github.py +69 -0
- appfactory/growth.py +113 -0
- appfactory/icon.py +115 -0
- appfactory/ideas.py +428 -0
- appfactory/issues.py +136 -0
- appfactory/legal.py +228 -0
- appfactory/localize.py +138 -0
- appfactory/maestro.py +330 -0
- appfactory/mascot.py +201 -0
- appfactory/metadata.py +439 -0
- appfactory/options.py +318 -0
- appfactory/orchestrator.py +144 -0
- appfactory/pipeline.py +271 -0
- appfactory/preview.py +454 -0
- appfactory/proc.py +24 -0
- appfactory/revenuecat.py +171 -0
- appfactory/sbp.py +158 -0
- appfactory/screenshot.py +329 -0
- appfactory/server.py +1674 -0
- appfactory/setup_gui.py +268 -0
- appfactory/setup_tools.py +115 -0
- appfactory/signing.py +287 -0
- appfactory/spec.py +470 -0
- appfactory/store_setup.py +1677 -0
- appfactory/storekit.py +213 -0
- appfactory/supabase.py +310 -0
- appfactory/team.py +172 -0
- appfactory/templates/docs/CHECKLIST.md +86 -0
- appfactory/templates/docs/aso-research.md +60 -0
- appfactory/templates/docs/onboarding-plan.md +53 -0
- appfactory/templates/swiftui-subscription/.gitignore +6 -0
- appfactory/templates/swiftui-subscription/.maestro/config.yaml +11 -0
- appfactory/templates/swiftui-subscription/.maestro/flows/smoke_onboarding_paywall.yaml +89 -0
- appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +4 -0
- appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +4 -0
- appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/Contents.json +1 -0
- appfactory/templates/swiftui-subscription/Resources/Assets.xcassets/Mascot/Contents.json +4 -0
- appfactory/templates/swiftui-subscription/Resources/InfoPlist.xcstrings +132 -0
- appfactory/templates/swiftui-subscription/Resources/Localizable.xcstrings +7507 -0
- appfactory/templates/swiftui-subscription/Scripts/sim_store_prep.sh +59 -0
- appfactory/templates/swiftui-subscription/Scripts/sync_strings.py +46 -0
- appfactory/templates/swiftui-subscription/Scripts/translations.py +464 -0
- appfactory/templates/swiftui-subscription/Sources/App/App.swift +116 -0
- appfactory/templates/swiftui-subscription/Sources/App/AppConfig.swift +179 -0
- appfactory/templates/swiftui-subscription/Sources/App/LaunchOptions.swift +52 -0
- appfactory/templates/swiftui-subscription/Sources/Core/AIService.swift +91 -0
- appfactory/templates/swiftui-subscription/Sources/Core/Analytics.swift +193 -0
- appfactory/templates/swiftui-subscription/Sources/Core/AnalyticsGate.swift +77 -0
- appfactory/templates/swiftui-subscription/Sources/Core/AuthProvider.swift +118 -0
- appfactory/templates/swiftui-subscription/Sources/Core/LocalUsageGate.swift +21 -0
- appfactory/templates/swiftui-subscription/Sources/Core/Proxy.swift +147 -0
- appfactory/templates/swiftui-subscription/Sources/Core/RatingPolicy.swift +171 -0
- appfactory/templates/swiftui-subscription/Sources/Core/StoreManager.swift +512 -0
- appfactory/templates/swiftui-subscription/Sources/Core/SupabaseAuth.swift +292 -0
- appfactory/templates/swiftui-subscription/Sources/Credits/CreditManager.swift +80 -0
- appfactory/templates/swiftui-subscription/Sources/Credits/TopUpSheet.swift +245 -0
- appfactory/templates/swiftui-subscription/Sources/Design/Controls.swift +426 -0
- appfactory/templates/swiftui-subscription/Sources/Design/MascotView.swift +393 -0
- appfactory/templates/swiftui-subscription/Sources/Design/Motion.swift +282 -0
- appfactory/templates/swiftui-subscription/Sources/DesignSystem.swift +193 -0
- appfactory/templates/swiftui-subscription/Sources/Health/HealthStore.swift +270 -0
- appfactory/templates/swiftui-subscription/Sources/Main/GenerationFlow.swift +414 -0
- appfactory/templates/swiftui-subscription/Sources/Main/GenerationStore.swift +137 -0
- appfactory/templates/swiftui-subscription/Sources/Main/RootTabView.swift +443 -0
- appfactory/templates/swiftui-subscription/Sources/Model/BirthYears.swift +44 -0
- appfactory/templates/swiftui-subscription/Sources/Model/Units.swift +72 -0
- appfactory/templates/swiftui-subscription/Sources/Model/UserProfile.swift +118 -0
- appfactory/templates/swiftui-subscription/Sources/Onboarding/OnboardingModel.swift +133 -0
- appfactory/templates/swiftui-subscription/Sources/Onboarding/OnboardingSteps.swift +230 -0
- appfactory/templates/swiftui-subscription/Sources/Onboarding/OnboardingView.swift +192 -0
- appfactory/templates/swiftui-subscription/Sources/Onboarding/Screens/StepScreens.swift +363 -0
- appfactory/templates/swiftui-subscription/Sources/Paywall/HardPaywallView.swift +241 -0
- appfactory/templates/swiftui-subscription/Sources/Paywall/OfferPaywallView.swift +211 -0
- appfactory/templates/swiftui-subscription/Sources/Paywall/PaywallComponents.swift +257 -0
- appfactory/templates/swiftui-subscription/Sources/Paywall/PaywallPersonalization.swift +58 -0
- appfactory/templates/swiftui-subscription/Sources/Paywall/PaywallPricing.swift +302 -0
- appfactory/templates/swiftui-subscription/Tests/AnalyticsGateTests.swift +52 -0
- appfactory/templates/swiftui-subscription/Tests/BillingTests.swift +113 -0
- appfactory/templates/swiftui-subscription/Tests/HealthTests.swift +52 -0
- appfactory/templates/swiftui-subscription/Tests/LocalizationCompletenessTests.swift +121 -0
- appfactory/templates/swiftui-subscription/Tests/OnboardingTests.swift +186 -0
- appfactory/templates/swiftui-subscription/Tests/PaywallTests.swift +191 -0
- appfactory/templates/swiftui-subscription/Tests/RatingPolicyTests.swift +131 -0
- appfactory/templates/swiftui-subscription/UITests/LayoutAudit.swift +379 -0
- appfactory/templates/swiftui-subscription/UITests/LayoutAuditRuleTests.swift +56 -0
- appfactory/templates/swiftui-subscription/UITests/LayoutAuditUITests.swift +66 -0
- appfactory/templates/swiftui-subscription/UITests/LocalizationUITests.swift +36 -0
- appfactory/templates/swiftui-subscription/UITests/OnboardingFlowUITests.swift +115 -0
- appfactory/templates/swiftui-subscription/backend/CONTRACT.md +201 -0
- appfactory/templates/swiftui-subscription/backend/EVAL.md +50 -0
- appfactory/templates/swiftui-subscription/backend/PRIVACY.md +74 -0
- appfactory/templates/swiftui-subscription/backend/README.md +72 -0
- appfactory/templates/swiftui-subscription/backend/eval/.gitignore +3 -0
- appfactory/templates/swiftui-subscription/backend/eval/data/cases.json +9 -0
- appfactory/templates/swiftui-subscription/backend/eval/prepare.ts +68 -0
- appfactory/templates/swiftui-subscription/backend/eval/run.ts +188 -0
- appfactory/templates/swiftui-subscription/backend/eval/score.ts +106 -0
- appfactory/templates/swiftui-subscription/backend/eval/score_app.ts +28 -0
- appfactory/templates/swiftui-subscription/backend/eval/score_test.ts +119 -0
- appfactory/templates/swiftui-subscription/backend/legal/build.ts +158 -0
- appfactory/templates/swiftui-subscription/backend/legal/build_test.ts +89 -0
- appfactory/templates/swiftui-subscription/backend/legal/verify.ts +47 -0
- appfactory/templates/swiftui-subscription/backend/smoke/smoke.ts +136 -0
- appfactory/templates/swiftui-subscription/marketing/preview/README.md +16 -0
- appfactory/templates/swiftui-subscription/marketing/screenshots/build.mjs +188 -0
- appfactory/templates/swiftui-subscription/marketing/screenshots/config.json +6 -0
- appfactory/templates/swiftui-subscription/marketing/screenshots/package.json +7 -0
- appfactory/templates/swiftui-subscription/project.yml +162 -0
- appfactory/templates/swiftui-subscription/store/CHECKLIST.md +88 -0
- appfactory/templates/swiftui-subscription/store/metadata/listing.json +209 -0
- appfactory/templates/swiftui-subscription/store/pricing.md +96 -0
- appfactory/templates/swiftui-subscription/store/privacy/privacy-policy.en.md +130 -0
- appfactory/templates/swiftui-subscription/store/privacy/support.en.md +21 -0
- appfactory/templates/swiftui-subscription/store/privacy/terms.en.md +105 -0
- appfactory/templates/swiftui-subscription/store/review-notes.md +59 -0
- appfactory/templates/swiftui-subscription/supabase/.gitignore +2 -0
- appfactory/templates/swiftui-subscription/supabase/config.toml +48 -0
- appfactory/templates/swiftui-subscription/supabase/deno.json +9 -0
- appfactory/templates/swiftui-subscription/supabase/deno.lock +101 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/analysis.ts +119 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/app.gen.ts +14 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/config.ts +64 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/credits.ts +172 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/credits_rc.ts +64 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/entitlement.ts +58 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/http.ts +48 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/llm.ts +176 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/locale.ts +108 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/rc.ts +100 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/store.ts +132 -0
- appfactory/templates/swiftui-subscription/supabase/functions/_shared/usage.ts +132 -0
- appfactory/templates/swiftui-subscription/supabase/functions/ai-proxy/index.ts +146 -0
- appfactory/templates/swiftui-subscription/supabase/functions/analyze/handler.ts +204 -0
- appfactory/templates/swiftui-subscription/supabase/functions/analyze/index.ts +25 -0
- appfactory/templates/swiftui-subscription/supabase/functions/delete-account/handler.ts +37 -0
- appfactory/templates/swiftui-subscription/supabase/functions/delete-account/index.ts +18 -0
- appfactory/templates/swiftui-subscription/supabase/functions/legal/content.gen.ts +8 -0
- appfactory/templates/swiftui-subscription/supabase/functions/legal/handler.ts +77 -0
- appfactory/templates/swiftui-subscription/supabase/functions/legal/index.ts +6 -0
- appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/fixtures/cancellation.json +14 -0
- appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/fixtures/initial_purchase.json +15 -0
- appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/fixtures/trial_started.json +14 -0
- appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/index.ts +18 -0
- appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/map.ts +36 -0
- appfactory/templates/swiftui-subscription/supabase/functions/rc-webhook/map_test.ts +21 -0
- appfactory/templates/swiftui-subscription/supabase/functions/usage-status/handler.ts +34 -0
- appfactory/templates/swiftui-subscription/supabase/functions/usage-status/index.ts +22 -0
- appfactory/templates/swiftui-subscription/supabase/migrations/0001_core.sql +118 -0
- appfactory/templates/swiftui-subscription/supabase/migrations/0002_analytics.sql +105 -0
- appfactory/templates/swiftui-subscription/supabase/migrations/0003_consent.sql +25 -0
- appfactory/templates/swiftui-subscription/supabase/migrations/0004_calendar_caps.sql +50 -0
- appfactory/templates/swiftui-subscription/supabase/migrations/0005_events_revenue.sql +61 -0
- appfactory/templates/swiftui-subscription/supabase/migrations_credits/0001_ai_usage.sql +11 -0
- appfactory/templates/swiftui-subscription/supabase/migrations_credits/0002_events.sql +14 -0
- appfactory/templates/swiftui-subscription/supabase/migrations_credits/0003_credits.sql +84 -0
- appfactory/templates/swiftui-subscription/supabase/migrations_credits/0004_adops.sql +43 -0
- appfactory/templates/swiftui-subscription/supabase/tests/analyze_test.ts +375 -0
- appfactory/templates/swiftui-subscription/supabase/tests/calendar_caps_db_test.ts +71 -0
- appfactory/templates/swiftui-subscription/supabase/tests/db_harness.ts +76 -0
- appfactory/templates/swiftui-subscription/supabase/tests/db_test.ts +215 -0
- appfactory/templates/swiftui-subscription/supabase/tests/delete_account_test.ts +39 -0
- appfactory/templates/swiftui-subscription/supabase/tests/fakes.ts +153 -0
- appfactory/templates/swiftui-subscription/supabase/tests/legal_test.ts +60 -0
- appfactory/templates/swiftui-subscription/supabase/tests/shared_test.ts +355 -0
- appfactory/templates/team/TEAM.md +118 -0
- appfactory/templates/team/backend.md +15 -0
- appfactory/templates/team/ios.md +20 -0
- appfactory/templates/team/store.md +20 -0
- appfactory-0.1.0.dist-info/METADATA +121 -0
- appfactory-0.1.0.dist-info/RECORD +205 -0
- appfactory-0.1.0.dist-info/WHEEL +4 -0
- appfactory-0.1.0.dist-info/entry_points.txt +3 -0
- appfactory-0.1.0.dist-info/licenses/LICENSE +21 -0
appfactory/__init__.py
ADDED
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
|
+
}
|
appfactory/animation.py
ADDED
|
@@ -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")
|