Align prod schema and deploy tooling

This commit is contained in:
jjbrunton committed 2026-01-24 20:16:01 +00:00
1 parent d2db86ec7f
commit 7e2e723880
105 files changed
+19014 -131

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
---
name: ios-simulator-skill
version: 1.3.0
description: 21 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle management. Optimized for AI agents with minimal token output.
---
# iOS Simulator Skill
Build, test, and automate iOS applications using accessibility-driven navigation and structured data instead of pixel coordinates.
## Quick Start
```bash
# 1. Check environment
bash scripts/sim_health_check.sh
# 2. Launch app
python scripts/app_launcher.py --launch com.example.app
# 3. Map screen to see elements
python scripts/screen_mapper.py
# 4. Tap button
python scripts/navigator.py --find-text "Login" --tap
# 5. Enter text
python scripts/navigator.py --find-type TextField --enter-text "user@example.com"
```
All scripts support `--help` for detailed options and `--json` for machine-readable output.
## 21 Production Scripts
### Build & Development (2 scripts)
1. **build_and_test.py** - Build Xcode projects, run tests, parse results with progressive disclosure
- Build with live result streaming
- Parse errors and warnings from xcresult bundles
- Retrieve detailed build logs on demand
- Options: `--project`, `--scheme`, `--clean`, `--test`, `--verbose`, `--json`
2. **log_monitor.py** - Real-time log monitoring with intelligent filtering
- Stream logs or capture by duration
- Filter by severity (error/warning/info/debug)
- Deduplicate repeated messages
- Options: `--app`, `--severity`, `--follow`, `--duration`, `--output`, `--json`
### Navigation & Interaction (5 scripts)
3. **screen_mapper.py** - Analyze current screen and list interactive elements
- Element type breakdown
- Interactive button list
- Text field status
- Options: `--verbose`, `--hints`, `--json`
4. **navigator.py** - Find and interact with elements semantically
- Find by text (fuzzy matching)
- Find by element type
- Find by accessibility ID
- Enter text or tap elements
- Options: `--find-text`, `--find-type`, `--find-id`, `--tap`, `--enter-text`, `--json`
5. **gesture.py** - Perform swipes, scrolls, pinches, and complex gestures
- Directional swipes (up/down/left/right)
- Multi-swipe scrolling
- Pinch zoom
- Long press
- Pull to refresh
- Options: `--swipe`, `--scroll`, `--pinch`, `--long-press`, `--refresh`, `--json`
6. **keyboard.py** - Text input and hardware button control
- Type text (fast or slow)
- Special keys (return, delete, tab, space, arrows)
- Hardware buttons (home, lock, volume, screenshot)
- Key combinations
- Options: `--type`, `--key`, `--button`, `--slow`, `--clear`, `--dismiss`, `--json`
7. **app_launcher.py** - App lifecycle management
- Launch apps by bundle ID
- Terminate apps
- Install/uninstall from .app bundles
- Deep link navigation
- List installed apps
- Check app state
- Options: `--launch`, `--terminate`, `--install`, `--uninstall`, `--open-url`, `--list`, `--state`, `--json`
### Testing & Analysis (5 scripts)
8. **accessibility_audit.py** - Check WCAG compliance on current screen
- Critical issues (missing labels, empty buttons, no alt text)
- Warnings (missing hints, small touch targets)
- Info (missing IDs, deep nesting)
- Options: `--verbose`, `--output`, `--json`
9. **visual_diff.py** - Compare two screenshots for visual changes
- Pixel-by-pixel comparison
- Threshold-based pass/fail
- Generate diff images
- Options: `--threshold`, `--output`, `--details`, `--json`
10. **test_recorder.py** - Automatically document test execution
- Capture screenshots and accessibility trees per step
- Generate markdown reports with timing data
- Options: `--test-name`, `--output`, `--verbose`, `--json`
11. **app_state_capture.py** - Create comprehensive debugging snapshots
- Screenshot, UI hierarchy, app logs, device info
- Markdown summary for bug reports
- Options: `--app-bundle-id`, `--output`, `--log-lines`, `--json`
12. **sim_health_check.sh** - Verify environment is properly configured
- Check macOS, Xcode, simctl, IDB, Python
- List available and booted simulators
- Verify Python packages (Pillow)
### Advanced Testing & Permissions (4 scripts)
13. **clipboard.py** - Manage simulator clipboard for paste testing
- Copy text to clipboard
- Test paste flows without manual entry
- Options: `--copy`, `--test-name`, `--expected`, `--json`
14. **status_bar.py** - Override simulator status bar appearance
- Presets: clean (9:41, 100% battery), testing (11:11, 50%), low-battery (20%), airplane (offline)
- Custom time, network, battery, WiFi settings
- Options: `--preset`, `--time`, `--data-network`, `--battery-level`, `--clear`, `--json`
15. **push_notification.py** - Send simulated push notifications
- Simple mode (title + body + badge)
- Custom JSON payloads
- Test notification handling and deep links
- Options: `--bundle-id`, `--title`, `--body`, `--badge`, `--payload`, `--json`
16. **privacy_manager.py** - Grant, revoke, and reset app permissions
- 13 supported services (camera, microphone, location, contacts, photos, calendar, health, etc.)
- Batch operations (comma-separated services)
- Audit trail with test scenario tracking
- Options: `--bundle-id`, `--grant`, `--revoke`, `--reset`, `--list`, `--json`
### Device Lifecycle Management (5 scripts)
17. **simctl_boot.py** - Boot simulators with optional readiness verification
- Boot by UDID or device name
- Wait for device ready with timeout
- Batch boot operations (--all, --type)
- Performance timing
- Options: `--udid`, `--name`, `--wait-ready`, `--timeout`, `--all`, `--type`, `--json`
18. **simctl_shutdown.py** - Gracefully shutdown simulators
- Shutdown by UDID or device name
- Optional verification of shutdown completion
- Batch shutdown operations
- Options: `--udid`, `--name`, `--verify`, `--timeout`, `--all`, `--type`, `--json`
19. **simctl_create.py** - Create simulators dynamically
- Create by device type and iOS version
- List available device types and runtimes
- Custom device naming
- Returns UDID for CI/CD integration
- Options: `--device`, `--runtime`, `--name`, `--list-devices`, `--list-runtimes`, `--json`
20. **simctl_delete.py** - Permanently delete simulators
- Delete by UDID or device name
- Safety confirmation by default (skip with --yes)
- Batch delete operations
- Smart deletion (--old N to keep N per device type)
- Options: `--udid`, `--name`, `--yes`, `--all`, `--type`, `--old`, `--json`
21. **simctl_erase.py** - Factory reset simulators without deletion
- Preserve device UUID (faster than delete+create)
- Erase all, by type, or booted simulators
- Optional verification
- Options: `--udid`, `--name`, `--verify`, `--timeout`, `--all`, `--type`, `--booted`, `--json`
## Common Patterns
**Auto-UDID Detection**: Most scripts auto-detect the booted simulator if --udid is not provided.
**Device Name Resolution**: Use device names (e.g., "iPhone 16 Pro") instead of UDIDs - scripts resolve automatically.
**Batch Operations**: Many scripts support `--all` for all simulators or `--type iPhone` for device type filtering.
**Output Formats**: Default is concise human-readable output. Use `--json` for machine-readable output in CI/CD.
**Help**: All scripts support `--help` for detailed options and examples.
## Typical Workflow
1. Verify environment: `bash scripts/sim_health_check.sh`
2. Launch app: `python scripts/app_launcher.py --launch com.example.app`
3. Analyze screen: `python scripts/screen_mapper.py`
4. Interact: `python scripts/navigator.py --find-text "Button" --tap`
5. Verify: `python scripts/accessibility_audit.py`
6. Debug if needed: `python scripts/app_state_capture.py --app-bundle-id com.example.app`
## Requirements
- macOS 12+
- Xcode Command Line Tools
- Python 3
- IDB (optional, for interactive features)
## Documentation
- **SKILL.md** (this file) - Script reference and quick start
- **README.md** - Installation and examples
- **CLAUDE.md** - Architecture and implementation details
- **references/** - Deep documentation on specific topics
- **examples/** - Complete automation workflows
## Key Design Principles
**Semantic Navigation**: Find elements by meaning (text, type, ID) not pixel coordinates. Survives UI changes.
**Token Efficiency**: Concise default output (3-5 lines) with optional verbose and JSON modes for detailed results.
**Accessibility-First**: Built on standard accessibility APIs for reliability and compatibility.
**Zero Configuration**: Works immediately on any macOS with Xcode. No setup required.
**Structured Data**: Scripts output JSON or formatted text, not raw logs. Easy to parse and integrate.
**Auto-Learning**: Build system remembers your device preference. Configuration stored per-project.
---
Use these scripts directly or let Claude Code invoke them automatically when your request matches the skill description.
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
iOS Simulator Accessibility Audit
Scans the current simulator screen for accessibility compliance issues.
Optimized for minimal token output while maintaining functionality.
Usage: python scripts/accessibility_audit.py [options]
"""
import argparse
import json
import subprocess
import sys
from dataclasses import asdict, dataclass
from typing import Any
from common import flatten_tree, get_accessibility_tree, resolve_udid
@dataclass
class Issue:
"""Represents an accessibility issue."""
severity: str # critical, warning, info
rule: str
element_type: str
issue: str
fix: str
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return asdict(self)
class AccessibilityAuditor:
"""Performs accessibility audits on iOS simulator screens."""
# Critical rules that block users
CRITICAL_RULES = {
"missing_label": lambda e: e.get("type") in ["Button", "Link"] and not e.get("AXLabel"),
"empty_button": lambda e: e.get("type") == "Button"
and not (e.get("AXLabel") or e.get("AXValue")),
"image_no_alt": lambda e: e.get("type") == "Image" and not e.get("AXLabel"),
}
# Warnings that degrade UX
WARNING_RULES = {
"missing_hint": lambda e: e.get("type") in ["Slider", "TextField"] and not e.get("help"),
"missing_traits": lambda e: e.get("type") and not e.get("traits"),
}
# Info level suggestions
INFO_RULES = {
"no_identifier": lambda e: not e.get("AXUniqueId"),
"deep_nesting": lambda e: e.get("depth", 0) > 5,
}
def __init__(self, udid: str | None = None):
"""Initialize auditor with optional device UDID."""
self.udid = udid
def get_accessibility_tree(self) -> dict:
"""Fetch accessibility tree from simulator using shared utility."""
return get_accessibility_tree(self.udid, nested=True)
@staticmethod
def _is_small_target(element: dict) -> bool:
"""Check if touch target is too small (< 44x44 points)."""
frame = element.get("frame", {})
width = frame.get("width", 0)
height = frame.get("height", 0)
return width < 44 or height < 44
def _flatten_tree(self, node: dict, depth: int = 0) -> list[dict]:
"""Flatten nested accessibility tree for easier processing using shared utility."""
return flatten_tree(node, depth)
def audit_element(self, element: dict) -> list[Issue]:
"""Audit a single element for accessibility issues."""
issues = []
# Check critical rules
for rule_name, rule_func in self.CRITICAL_RULES.items():
if rule_func(element):
issues.append(
Issue(
severity="critical",
rule=rule_name,
element_type=element.get("type", "Unknown"),
issue=self._get_issue_description(rule_name),
fix=self._get_fix_suggestion(rule_name),
)
)
# Check warnings (skip if critical issues found)
if not issues:
for rule_name, rule_func in self.WARNING_RULES.items():
if rule_func(element):
issues.append(
Issue(
severity="warning",
rule=rule_name,
element_type=element.get("type", "Unknown"),
issue=self._get_issue_description(rule_name),
fix=self._get_fix_suggestion(rule_name),
)
)
# Check info level (only if verbose or no other issues)
if not issues:
for rule_name, rule_func in self.INFO_RULES.items():
if rule_func(element):
issues.append(
Issue(
severity="info",
rule=rule_name,
element_type=element.get("type", "Unknown"),
issue=self._get_issue_description(rule_name),
fix=self._get_fix_suggestion(rule_name),
)
)
return issues
def _get_issue_description(self, rule: str) -> str:
"""Get human-readable issue description."""
descriptions = {
"missing_label": "Interactive element missing accessibility label",
"empty_button": "Button has no text or label",
"image_no_alt": "Image missing alternative text",
"missing_hint": "Complex control missing hint",
"small_touch_target": "Touch target smaller than 44x44pt",
"missing_traits": "Element missing accessibility traits",
"no_identifier": "Missing accessibility identifier",
"deep_nesting": "Deeply nested (>5 levels)",
}
return descriptions.get(rule, "Accessibility issue")
def _get_fix_suggestion(self, rule: str) -> str:
"""Get fix suggestion for issue."""
fixes = {
"missing_label": "Add accessibilityLabel",
"empty_button": "Set button title or accessibilityLabel",
"image_no_alt": "Add accessibilityLabel with description",
"missing_hint": "Add accessibilityHint",
"small_touch_target": "Increase to minimum 44x44pt",
"missing_traits": "Set appropriate accessibilityTraits",
"no_identifier": "Add accessibilityIdentifier for testing",
"deep_nesting": "Simplify view hierarchy",
}
return fixes.get(rule, "Review accessibility")
def audit(self, verbose: bool = False) -> dict[str, Any]:
"""Perform full accessibility audit."""
# Get accessibility tree
tree = self.get_accessibility_tree()
# Flatten for processing
elements = self._flatten_tree(tree)
# Audit each element
all_issues = []
for element in elements:
issues = self.audit_element(element)
for issue in issues:
issue_dict = issue.to_dict()
# Add minimal element info for context
issue_dict["element"] = {
"type": element.get("type", "Unknown"),
"label": element.get("AXLabel", "")[:30] if element.get("AXLabel") else None,
}
all_issues.append(issue_dict)
# Count by severity
critical = len([i for i in all_issues if i["severity"] == "critical"])
warning = len([i for i in all_issues if i["severity"] == "warning"])
info = len([i for i in all_issues if i["severity"] == "info"])
# Build result (token-optimized)
result = {
"summary": {
"total": len(elements),
"issues": len(all_issues),
"critical": critical,
"warning": warning,
"info": info,
}
}
if verbose:
# Full details only if requested
result["issues"] = all_issues
else:
# Default: top issues only (token-efficient)
result["top_issues"] = self._get_top_issues(all_issues)
return result
def _get_top_issues(self, issues: list[dict]) -> list[dict]:
"""Get top 3 issues grouped by type (token-efficient)."""
if not issues:
return []
# Group by rule
grouped = {}
for issue in issues:
rule = issue["rule"]
if rule not in grouped:
grouped[rule] = {
"severity": issue["severity"],
"rule": rule,
"count": 0,
"fix": issue["fix"],
}
grouped[rule]["count"] += 1
# Sort by severity and count
severity_order = {"critical": 0, "warning": 1, "info": 2}
sorted_issues = sorted(
grouped.values(), key=lambda x: (severity_order[x["severity"]], -x["count"])
)
return sorted_issues[:3]
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Audit iOS simulator screen for accessibility issues"
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument("--output", help="Save JSON report to file")
parser.add_argument(
"--verbose", action="store_true", help="Include all issue details (increases output)"
)
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
# Perform audit
auditor = AccessibilityAuditor(udid=udid)
try:
result = auditor.audit(verbose=args.verbose)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
# Output results
if args.output:
# Save to file
with open(args.output, "w") as f:
json.dump(result, f, indent=2)
# Print minimal summary
summary = result["summary"]
print(f"Audit complete: {summary['issues']} issues ({summary['critical']} critical)")
print(f"Report saved to: {args.output}")
# Print to stdout (token-optimized by default)
elif args.verbose:
print(json.dumps(result, indent=2))
else:
# Ultra-compact output
summary = result["summary"]
print(f"Elements: {summary['total']}, Issues: {summary['issues']}")
print(
f"Critical: {summary['critical']}, Warning: {summary['warning']}, Info: {summary['info']}"
)
if result.get("top_issues"):
print("\nTop issues:")
for issue in result["top_issues"]:
print(
f" [{issue['severity']}] {issue['rule']} ({issue['count']}x) - {issue['fix']}"
)
# Exit with error if critical issues found
if result["summary"]["critical"] > 0:
sys.exit(1)
if __name__ == "__main__":
main()
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""
iOS App Launcher - App Lifecycle Control
Launches, terminates, and manages iOS apps in the simulator.
Handles deep links and app switching.
Usage: python scripts/app_launcher.py --launch com.example.app
"""
import argparse
import contextlib
import subprocess
import sys
import time
from common import build_simctl_command, resolve_udid
class AppLauncher:
"""Controls app lifecycle on iOS simulator."""
def __init__(self, udid: str | None = None):
"""Initialize app launcher."""
self.udid = udid
def launch(self, bundle_id: str, wait_for_debugger: bool = False) -> tuple[bool, int | None]:
"""
Launch an app.
Args:
bundle_id: App bundle identifier
wait_for_debugger: Wait for debugger attachment
Returns:
(success, pid) tuple
"""
cmd = build_simctl_command("launch", self.udid, bundle_id)
if wait_for_debugger:
cmd.insert(3, "--wait-for-debugger") # Insert after "launch" operation
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse PID from output if available
pid = None
if result.stdout:
# Output format: "com.example.app: <PID>"
parts = result.stdout.strip().split(":")
if len(parts) > 1:
with contextlib.suppress(ValueError):
pid = int(parts[1].strip())
return (True, pid)
except subprocess.CalledProcessError:
return (False, None)
def terminate(self, bundle_id: str) -> bool:
"""
Terminate an app.
Args:
bundle_id: App bundle identifier
Returns:
Success status
"""
cmd = build_simctl_command("terminate", self.udid, bundle_id)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def install(self, app_path: str) -> bool:
"""
Install an app.
Args:
app_path: Path to .app bundle
Returns:
Success status
"""
cmd = build_simctl_command("install", self.udid, app_path)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def uninstall(self, bundle_id: str) -> bool:
"""
Uninstall an app.
Args:
bundle_id: App bundle identifier
Returns:
Success status
"""
cmd = build_simctl_command("uninstall", self.udid, bundle_id)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def open_url(self, url: str) -> bool:
"""
Open URL (for deep linking).
Args:
url: URL to open (http://, myapp://, etc.)
Returns:
Success status
"""
cmd = build_simctl_command("openurl", self.udid, url)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def list_apps(self) -> list[dict[str, str]]:
"""
List installed apps.
Returns:
List of app info dictionaries
"""
cmd = build_simctl_command("listapps", self.udid)
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse plist output using plutil to convert to JSON
plist_data = result.stdout
# Use plutil to convert plist to JSON
convert_cmd = ["plutil", "-convert", "json", "-o", "-", "-"]
convert_result = subprocess.run(
convert_cmd, check=False, input=plist_data, capture_output=True, text=True
)
apps = []
if convert_result.returncode == 0:
import json
try:
data = json.loads(convert_result.stdout)
for bundle_id, app_info in data.items():
# Skip system internal apps that are hidden
if app_info.get("ApplicationType") == "Hidden":
continue
apps.append(
{
"bundle_id": bundle_id,
"name": app_info.get(
"CFBundleDisplayName", app_info.get("CFBundleName", bundle_id)
),
"path": app_info.get("Path", ""),
"version": app_info.get("CFBundleVersion", "Unknown"),
"type": app_info.get("ApplicationType", "User"),
}
)
except json.JSONDecodeError:
pass
return apps
except subprocess.CalledProcessError:
return []
def get_app_state(self, bundle_id: str) -> str:
"""
Get app state (running, suspended, etc.).
Args:
bundle_id: App bundle identifier
Returns:
State string or 'unknown'
"""
# Check if app is running by trying to get its PID
cmd = build_simctl_command("spawn", self.udid, "launchctl", "list")
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
if bundle_id in result.stdout:
return "running"
return "not running"
except subprocess.CalledProcessError:
return "unknown"
def restart_app(self, bundle_id: str, delay: float = 1.0) -> bool:
"""
Restart an app (terminate then launch).
Args:
bundle_id: App bundle identifier
delay: Delay between terminate and launch
Returns:
Success status
"""
# Terminate
self.terminate(bundle_id)
time.sleep(delay)
# Launch
success, _ = self.launch(bundle_id)
return success
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Control iOS app lifecycle")
# Actions
parser.add_argument("--launch", help="Launch app by bundle ID")
parser.add_argument("--terminate", help="Terminate app by bundle ID")
parser.add_argument("--restart", help="Restart app by bundle ID")
parser.add_argument("--install", help="Install app from .app path")
parser.add_argument("--uninstall", help="Uninstall app by bundle ID")
parser.add_argument("--open-url", help="Open URL (deep link)")
parser.add_argument("--list", action="store_true", help="List installed apps")
parser.add_argument("--state", help="Get app state by bundle ID")
# Options
parser.add_argument(
"--wait-for-debugger", action="store_true", help="Wait for debugger when launching"
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
launcher = AppLauncher(udid=udid)
# Execute requested action
if args.launch:
success, pid = launcher.launch(args.launch, args.wait_for_debugger)
if success:
if pid:
print(f"Launched {args.launch} (PID: {pid})")
else:
print(f"Launched {args.launch}")
else:
print(f"Failed to launch {args.launch}")
sys.exit(1)
elif args.terminate:
if launcher.terminate(args.terminate):
print(f"Terminated {args.terminate}")
else:
print(f"Failed to terminate {args.terminate}")
sys.exit(1)
elif args.restart:
if launcher.restart_app(args.restart):
print(f"Restarted {args.restart}")
else:
print(f"Failed to restart {args.restart}")
sys.exit(1)
elif args.install:
if launcher.install(args.install):
print(f"Installed {args.install}")
else:
print(f"Failed to install {args.install}")
sys.exit(1)
elif args.uninstall:
if launcher.uninstall(args.uninstall):
print(f"Uninstalled {args.uninstall}")
else:
print(f"Failed to uninstall {args.uninstall}")
sys.exit(1)
elif args.open_url:
if launcher.open_url(args.open_url):
print(f"Opened URL: {args.open_url}")
else:
print(f"Failed to open URL: {args.open_url}")
sys.exit(1)
elif args.list:
apps = launcher.list_apps()
if apps:
print(f"Installed apps ({len(apps)}):")
for app in apps[:10]: # Limit for token efficiency
print(f" {app['bundle_id']}: {app['name']} (v{app['version']})")
if len(apps) > 10:
print(f" ... and {len(apps) - 10} more")
else:
print("No apps found or failed to list")
elif args.state:
state = launcher.get_app_state(args.state)
print(f"{args.state}: {state}")
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
App State Capture for iOS Simulator
Captures complete app state including screenshot, accessibility tree, and logs.
Optimized for minimal token output.
Usage: python scripts/app_state_capture.py [options]
"""
import argparse
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from common import (
capture_screenshot,
count_elements,
get_accessibility_tree,
resolve_udid,
)
class AppStateCapture:
"""Captures comprehensive app state for debugging."""
def __init__(
self,
app_bundle_id: str | None = None,
udid: str | None = None,
inline: bool = False,
screenshot_size: str = "half",
):
"""
Initialize state capture.
Args:
app_bundle_id: Optional app bundle ID for log filtering
udid: Optional device UDID (uses booted if not specified)
inline: If True, return screenshots as base64 (for vision-based automation)
screenshot_size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
"""
self.app_bundle_id = app_bundle_id
self.udid = udid
self.inline = inline
self.screenshot_size = screenshot_size
def capture_screenshot(self, output_path: Path) -> bool:
"""Capture screenshot of current screen."""
cmd = ["xcrun", "simctl", "io"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(["screenshot", str(output_path)])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def capture_accessibility_tree(self, output_path: Path) -> dict:
"""Capture accessibility tree using shared utility."""
try:
# Use shared utility to fetch tree
tree = get_accessibility_tree(self.udid, nested=True)
# Save tree
with open(output_path, "w") as f:
json.dump(tree, f, indent=2)
# Return summary using shared utility
return {"captured": True, "element_count": count_elements(tree)}
except Exception as e:
return {"captured": False, "error": str(e)}
def capture_logs(self, output_path: Path, line_limit: int = 100) -> dict:
"""Capture recent app logs."""
if not self.app_bundle_id:
# Can't capture logs without app ID
return {"captured": False, "reason": "No app bundle ID specified"}
# Get app name from bundle ID (simplified)
app_name = self.app_bundle_id.split(".")[-1]
cmd = ["xcrun", "simctl", "spawn"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(
[
"log",
"show",
"--predicate",
f'process == "{app_name}"',
"--last",
"1m", # Last 1 minute
"--style",
"compact",
]
)
try:
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=5)
logs = result.stdout
# Limit lines for token efficiency
lines = logs.split("\n")
if len(lines) > line_limit:
lines = lines[-line_limit:]
# Save logs
with open(output_path, "w") as f:
f.write("\n".join(lines))
# Analyze for issues
warning_count = sum(1 for line in lines if "warning" in line.lower())
error_count = sum(1 for line in lines if "error" in line.lower())
return {
"captured": True,
"lines": len(lines),
"warnings": warning_count,
"errors": error_count,
}
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
return {"captured": False, "error": str(e)}
def capture_device_info(self) -> dict:
"""Get device information."""
cmd = ["xcrun", "simctl", "list", "devices", "booted"]
if self.udid:
# Specific device info
cmd = ["xcrun", "simctl", "list", "devices"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse output for device info (simplified)
lines = result.stdout.split("\n")
device_info = {}
for line in lines:
if "iPhone" in line or "iPad" in line:
# Extract device name and state
parts = line.strip().split("(")
if parts:
device_info["name"] = parts[0].strip()
if len(parts) > 2:
device_info["udid"] = parts[1].replace(")", "").strip()
device_info["state"] = parts[2].replace(")", "").strip()
break
return device_info
except subprocess.CalledProcessError:
return {}
def capture_all(
self, output_dir: str, log_lines: int = 100, app_name: str | None = None
) -> dict:
"""
Capture complete app state.
Args:
output_dir: Directory to save artifacts
log_lines: Number of log lines to capture
app_name: App name for semantic naming (for inline mode)
Returns:
Summary of captured state
"""
# Create output directory (only if not in inline mode)
output_path = Path(output_dir)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
if not self.inline:
capture_dir = output_path / f"app-state-{timestamp}"
capture_dir.mkdir(parents=True, exist_ok=True)
else:
capture_dir = None
summary = {
"timestamp": datetime.now().isoformat(),
"screenshot_mode": "inline" if self.inline else "file",
}
if capture_dir:
summary["output_dir"] = str(capture_dir)
# Capture screenshot using new unified utility
screenshot_result = capture_screenshot(
self.udid,
size=self.screenshot_size,
inline=self.inline,
app_name=app_name,
)
if self.inline:
# Inline mode: store base64
summary["screenshot"] = {
"mode": "inline",
"base64": screenshot_result["base64_data"],
"width": screenshot_result["width"],
"height": screenshot_result["height"],
"size_preset": self.screenshot_size,
}
else:
# File mode: save to disk
screenshot_path = capture_dir / "screenshot.png"
# Move temp file to target location
import shutil
shutil.move(screenshot_result["file_path"], screenshot_path)
summary["screenshot"] = {
"mode": "file",
"file": "screenshot.png",
"size_bytes": screenshot_result["size_bytes"],
}
# Capture accessibility tree
if not self.inline or capture_dir:
accessibility_path = (capture_dir or output_path) / "accessibility-tree.json"
else:
accessibility_path = None
if accessibility_path:
tree_info = self.capture_accessibility_tree(accessibility_path)
summary["accessibility"] = tree_info
# Capture logs (if app ID provided)
if self.app_bundle_id:
if not self.inline or capture_dir:
logs_path = (capture_dir or output_path) / "app-logs.txt"
else:
logs_path = None
if logs_path:
log_info = self.capture_logs(logs_path, log_lines)
summary["logs"] = log_info
# Get device info
device_info = self.capture_device_info()
if device_info:
summary["device"] = device_info
# Save device info (file mode only)
if capture_dir:
with open(capture_dir / "device-info.json", "w") as f:
json.dump(device_info, f, indent=2)
# Save summary (file mode only)
if capture_dir:
with open(capture_dir / "summary.json", "w") as f:
json.dump(summary, f, indent=2)
# Create markdown summary
self._create_summary_md(capture_dir, summary)
return summary
def _create_summary_md(self, capture_dir: Path, summary: dict) -> None:
"""Create markdown summary file."""
md_path = capture_dir / "summary.md"
with open(md_path, "w") as f:
f.write("# App State Capture\n\n")
f.write(f"**Timestamp:** {summary['timestamp']}\n\n")
if "device" in summary:
f.write("## Device\n")
device = summary["device"]
f.write(f"- Name: {device.get('name', 'Unknown')}\n")
f.write(f"- UDID: {device.get('udid', 'N/A')}\n")
f.write(f"- State: {device.get('state', 'Unknown')}\n\n")
f.write("## Screenshot\n")
f.write("![Current Screen](screenshot.png)\n\n")
if "accessibility" in summary:
acc = summary["accessibility"]
f.write("## Accessibility\n")
if acc.get("captured"):
f.write(f"- Elements: {acc.get('element_count', 0)}\n")
else:
f.write(f"- Error: {acc.get('error', 'Unknown')}\n")
f.write("\n")
if "logs" in summary:
logs = summary["logs"]
f.write("## Logs\n")
if logs.get("captured"):
f.write(f"- Lines: {logs.get('lines', 0)}\n")
f.write(f"- Warnings: {logs.get('warnings', 0)}\n")
f.write(f"- Errors: {logs.get('errors', 0)}\n")
else:
f.write(f"- {logs.get('reason', logs.get('error', 'Not captured'))}\n")
f.write("\n")
f.write("## Files\n")
f.write("- `screenshot.png` - Current screen\n")
f.write("- `accessibility-tree.json` - Full UI hierarchy\n")
if self.app_bundle_id:
f.write("- `app-logs.txt` - Recent app logs\n")
f.write("- `device-info.json` - Device details\n")
f.write("- `summary.json` - Complete capture metadata\n")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Capture complete app state for debugging")
parser.add_argument(
"--app-bundle-id", help="App bundle ID for log filtering (e.g., com.example.app)"
)
parser.add_argument(
"--output", default=".", help="Output directory (default: current directory)"
)
parser.add_argument(
"--log-lines", type=int, default=100, help="Number of log lines to capture (default: 100)"
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument(
"--inline",
action="store_true",
help="Return screenshots as base64 (inline mode for vision-based automation)",
)
parser.add_argument(
"--size",
choices=["full", "half", "quarter", "thumb"],
default="half",
help="Screenshot size for token optimization (default: half)",
)
parser.add_argument("--app-name", help="App name for semantic screenshot naming")
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
# Create capturer
capturer = AppStateCapture(
app_bundle_id=args.app_bundle_id,
udid=udid,
inline=args.inline,
screenshot_size=args.size,
)
# Capture state
try:
summary = capturer.capture_all(
output_dir=args.output, log_lines=args.log_lines, app_name=args.app_name
)
# Token-efficient output
if "output_dir" in summary:
print(f"State captured: {summary['output_dir']}/")
else:
# Inline mode
print(
f"State captured (inline mode): {summary['screenshot']['width']}x{summary['screenshot']['height']}"
)
# Report any issues found
if "logs" in summary and summary["logs"].get("captured"):
logs = summary["logs"]
if logs["errors"] > 0 or logs["warnings"] > 0:
print(f"Issues found: {logs['errors']} errors, {logs['warnings']} warnings")
if "accessibility" in summary and summary["accessibility"].get("captured"):
print(f"Elements: {summary['accessibility']['element_count']}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
Build and Test Automation for Xcode Projects
Ultra token-efficient build automation with progressive disclosure via xcresult bundles.
Features:
- Minimal default output (5-10 tokens)
- Progressive disclosure for error/warning/log details
- Native xcresult bundle support
- Clean modular architecture
Usage Examples:
# Build (minimal output)
python scripts/build_and_test.py --project MyApp.xcodeproj
# Output: Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
# Get error details
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
# Get warnings
python scripts/build_and_test.py --get-warnings xcresult-20251018-143052
# Get build log
python scripts/build_and_test.py --get-log xcresult-20251018-143052
# Get everything as JSON
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
# List recent builds
python scripts/build_and_test.py --list-xcresults
# Verbose mode (for debugging)
python scripts/build_and_test.py --project MyApp.xcodeproj --verbose
"""
import argparse
import sys
from pathlib import Path
# Import our modular components
from xcode import BuildRunner, OutputFormatter, XCResultCache, XCResultParser
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Build and test Xcode projects with progressive disclosure",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Build project (minimal output)
python scripts/build_and_test.py --project MyApp.xcodeproj
# Run tests
python scripts/build_and_test.py --project MyApp.xcodeproj --test
# Get error details from previous build
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
# Get all details as JSON
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
# List recent builds
python scripts/build_and_test.py --list-xcresults
""",
)
# Build/test mode arguments
build_group = parser.add_argument_group("Build/Test Options")
project_group = build_group.add_mutually_exclusive_group()
project_group.add_argument("--project", help="Path to .xcodeproj file")
project_group.add_argument("--workspace", help="Path to .xcworkspace file")
build_group.add_argument("--scheme", help="Build scheme (auto-detected if not specified)")
build_group.add_argument(
"--configuration",
default="Debug",
choices=["Debug", "Release"],
help="Build configuration (default: Debug)",
)
build_group.add_argument("--simulator", help="Simulator name (default: iPhone 15)")
build_group.add_argument("--clean", action="store_true", help="Clean before building")
build_group.add_argument("--test", action="store_true", help="Run tests")
build_group.add_argument("--suite", help="Specific test suite to run")
# Progressive disclosure arguments
disclosure_group = parser.add_argument_group("Progressive Disclosure Options")
disclosure_group.add_argument(
"--get-errors", metavar="XCRESULT_ID", help="Get error details from xcresult"
)
disclosure_group.add_argument(
"--get-warnings", metavar="XCRESULT_ID", help="Get warning details from xcresult"
)
disclosure_group.add_argument(
"--get-log", metavar="XCRESULT_ID", help="Get build log from xcresult"
)
disclosure_group.add_argument(
"--get-all", metavar="XCRESULT_ID", help="Get all details from xcresult"
)
disclosure_group.add_argument(
"--list-xcresults", action="store_true", help="List recent xcresult bundles"
)
# Output options
output_group = parser.add_argument_group("Output Options")
output_group.add_argument("--verbose", action="store_true", help="Show detailed output")
output_group.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
# Initialize cache
cache = XCResultCache()
# Handle list mode
if args.list_xcresults:
xcresults = cache.list()
if args.json:
import json
print(json.dumps(xcresults, indent=2))
elif not xcresults:
print("No xcresult bundles found")
else:
print(f"Recent XCResult bundles ({len(xcresults)}):")
print()
for xc in xcresults:
print(f" {xc['id']}")
print(f" Created: {xc['created']}")
print(f" Size: {xc['size_mb']} MB")
print()
return 0
# Handle retrieval modes
xcresult_id = args.get_errors or args.get_warnings or args.get_log or args.get_all
if xcresult_id:
xcresult_path = cache.get_path(xcresult_id)
if not xcresult_path or not xcresult_path.exists():
print(f"Error: XCResult bundle not found: {xcresult_id}", file=sys.stderr)
print("Use --list-xcresults to see available bundles", file=sys.stderr)
return 1
# Load cached stderr for progressive disclosure
cached_stderr = cache.get_stderr(xcresult_id)
parser = XCResultParser(xcresult_path, stderr=cached_stderr)
# Get errors
if args.get_errors:
errors = parser.get_errors()
if args.json:
import json
print(json.dumps(errors, indent=2))
else:
print(OutputFormatter.format_errors(errors))
return 0
# Get warnings
if args.get_warnings:
warnings = parser.get_warnings()
if args.json:
import json
print(json.dumps(warnings, indent=2))
else:
print(OutputFormatter.format_warnings(warnings))
return 0
# Get log
if args.get_log:
log = parser.get_build_log()
if log:
print(OutputFormatter.format_log(log))
else:
print("No build log available", file=sys.stderr)
return 1
return 0
# Get all
if args.get_all:
error_count, warning_count = parser.count_issues()
errors = parser.get_errors()
warnings = parser.get_warnings()
build_log = parser.get_build_log()
if args.json:
import json
data = {
"xcresult_id": xcresult_id,
"error_count": error_count,
"warning_count": warning_count,
"errors": errors,
"warnings": warnings,
"log_preview": build_log[:1000] if build_log else None,
}
print(json.dumps(data, indent=2))
else:
print(f"XCResult: {xcresult_id}")
print(f"Errors: {error_count}, Warnings: {warning_count}")
print()
if errors:
print(OutputFormatter.format_errors(errors, limit=10))
print()
if warnings:
print(OutputFormatter.format_warnings(warnings, limit=10))
print()
if build_log:
print("Build Log (last 30 lines):")
print(OutputFormatter.format_log(build_log, lines=30))
return 0
# Build/test mode
if not args.project and not args.workspace:
# Try to auto-detect in current directory
cwd = Path.cwd()
projects = list(cwd.glob("*.xcodeproj"))
workspaces = list(cwd.glob("*.xcworkspace"))
if workspaces:
args.workspace = str(workspaces[0])
elif projects:
args.project = str(projects[0])
else:
parser.error("No project or workspace specified and none found in current directory")
# Initialize builder
builder = BuildRunner(
project_path=args.project,
workspace_path=args.workspace,
scheme=args.scheme,
configuration=args.configuration,
simulator=args.simulator,
cache=cache,
)
# Execute build or test
if args.test:
success, xcresult_id, stderr = builder.test(test_suite=args.suite)
else:
success, xcresult_id, stderr = builder.build(clean=args.clean)
if not xcresult_id and not stderr:
print("Error: Build/test failed without creating xcresult or error output", file=sys.stderr)
return 1
# Save stderr to cache for progressive disclosure
if xcresult_id and stderr:
cache.save_stderr(xcresult_id, stderr)
# Parse results
xcresult_path = cache.get_path(xcresult_id) if xcresult_id else None
parser = XCResultParser(xcresult_path, stderr=stderr)
error_count, warning_count = parser.count_issues()
# Format output
status = "SUCCESS" if success else "FAILED"
# Generate hints for failed builds
hints = None
if not success:
errors = parser.get_errors()
hints = OutputFormatter.generate_hints(errors)
if args.verbose:
# Verbose mode with error/warning details
errors = parser.get_errors() if error_count > 0 else None
warnings = parser.get_warnings() if warning_count > 0 else None
output = OutputFormatter.format_verbose(
status=status,
error_count=error_count,
warning_count=warning_count,
xcresult_id=xcresult_id or "N/A",
errors=errors,
warnings=warnings,
)
print(output)
elif args.json:
# JSON mode
data = {
"success": success,
"xcresult_id": xcresult_id or None,
"error_count": error_count,
"warning_count": warning_count,
}
if hints:
data["hints"] = hints
import json
print(json.dumps(data, indent=2))
else:
# Minimal mode (default)
output = OutputFormatter.format_minimal(
status=status,
error_count=error_count,
warning_count=warning_count,
xcresult_id=xcresult_id or "N/A",
hints=hints,
)
print(output)
# Exit with appropriate code
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
iOS Simulator Clipboard Manager
Copy text to simulator clipboard for testing paste flows.
Optimized for minimal token output.
Usage: python scripts/clipboard.py --copy "text to copy"
"""
import argparse
import subprocess
import sys
from common import resolve_udid
class ClipboardManager:
"""Manages clipboard operations on iOS simulator."""
def __init__(self, udid: str | None = None):
"""Initialize clipboard manager.
Args:
udid: Optional device UDID (auto-detects booted simulator if None)
"""
self.udid = udid
def copy(self, text: str) -> bool:
"""
Copy text to simulator clipboard.
Args:
text: Text to copy to clipboard
Returns:
Success status
"""
cmd = ["xcrun", "simctl", "pbcopy"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.append(text)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Copy text to iOS simulator clipboard")
parser.add_argument("--copy", required=True, help="Text to copy to clipboard")
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument("--test-name", help="Test scenario name for tracking")
parser.add_argument("--expected", help="Expected behavior after paste")
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
# Create manager and copy text
manager = ClipboardManager(udid=udid)
if manager.copy(args.copy):
# Token-efficient output
output = f'Copied: "{args.copy}"'
if args.test_name:
output += f" (test: {args.test_name})"
print(output)
# Provide usage guidance
if args.expected:
print(f"Expected: {args.expected}")
print()
print("Next steps:")
print("1. Tap text field with: python scripts/navigator.py --find-type TextField --tap")
print("2. Paste with: python scripts/keyboard.py --key return")
print(" Or use Cmd+V gesture with: python scripts/keyboard.py --key cmd+v")
else:
print("Failed to copy text to clipboard")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,59 @@
"""
Common utilities shared across iOS simulator scripts.
This module centralizes genuinely reused code patterns to eliminate duplication
while respecting Jackson's Law - no over-abstraction, only truly shared logic.
Organization:
- device_utils: Device detection, command building, coordinate transformation
- idb_utils: IDB-specific operations (accessibility tree, element manipulation)
- cache_utils: Progressive disclosure caching for large outputs
- screenshot_utils: Screenshot capture with file and inline modes
"""
from .cache_utils import ProgressiveCache, get_cache
from .device_utils import (
build_idb_command,
build_simctl_command,
get_booted_device_udid,
get_device_screen_size,
resolve_udid,
transform_screenshot_coords,
)
from .idb_utils import (
count_elements,
flatten_tree,
get_accessibility_tree,
get_screen_size,
)
from .screenshot_utils import (
capture_screenshot,
format_screenshot_result,
generate_screenshot_name,
get_size_preset,
resize_screenshot,
)
__all__ = [
# cache_utils
"ProgressiveCache",
# device_utils
"build_idb_command",
"build_simctl_command",
# screenshot_utils
"capture_screenshot",
# idb_utils
"count_elements",
"flatten_tree",
"format_screenshot_result",
"generate_screenshot_name",
"get_accessibility_tree",
"get_booted_device_udid",
"get_cache",
"get_device_screen_size",
"get_screen_size",
"get_size_preset",
"resize_screenshot",
"resolve_udid",
"transform_screenshot_coords",
]
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Progressive disclosure cache for large outputs.
Implements cache system to support progressive disclosure pattern:
- Return concise summary with cache_id for large outputs
- User retrieves full details on demand via cache_id
- Reduces token usage by 96% for common queries
Cache directory: ~/.ios-simulator-skill/cache/
Cache expiration: Configurable per cache type (default 1 hour)
Used by:
- sim_list.py - Simulator listing progressive disclosure
- Future: build logs, UI trees, etc.
"""
import json
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
class ProgressiveCache:
"""Cache for progressive disclosure pattern.
Stores large outputs with timestamped IDs for on-demand retrieval.
Automatically cleans up expired entries.
"""
def __init__(self, cache_dir: str | None = None, max_age_hours: int = 1):
"""Initialize cache system.
Args:
cache_dir: Cache directory path (default: ~/.ios-simulator-skill/cache/)
max_age_hours: Max age for cache entries before expiration (default: 1 hour)
"""
if cache_dir is None:
cache_dir = str(Path("~/.ios-simulator-skill/cache").expanduser())
self.cache_dir = Path(cache_dir)
self.max_age_hours = max_age_hours
# Create cache directory if needed
self.cache_dir.mkdir(parents=True, exist_ok=True)
def save(self, data: dict[str, Any], cache_type: str) -> str:
"""Save data to cache and return cache_id.
Args:
data: Dictionary data to cache
cache_type: Type of cache ('simulator-list', 'build-log', 'ui-tree', etc.)
Returns:
Cache ID like 'sim-20251028-143052' for use in progressive disclosure
Example:
cache_id = cache.save({'devices': [...]}, 'simulator-list')
# Returns: 'sim-20251028-143052'
"""
# Generate cache_id with timestamp
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
cache_prefix = cache_type.split("-")[0] # e.g., 'sim' from 'simulator-list'
cache_id = f"{cache_prefix}-{timestamp}"
# Save to file
cache_file = self.cache_dir / f"{cache_id}.json"
with open(cache_file, "w") as f:
json.dump(
{
"cache_id": cache_id,
"cache_type": cache_type,
"created_at": datetime.now().isoformat(),
"data": data,
},
f,
indent=2,
)
return cache_id
def get(self, cache_id: str) -> dict[str, Any] | None:
"""Retrieve data from cache by cache_id.
Args:
cache_id: Cache ID from save() or list_entries()
Returns:
Cached data dictionary, or None if not found/expired
Example:
data = cache.get('sim-20251028-143052')
if data:
print(f"Found {len(data)} devices")
"""
cache_file = self.cache_dir / f"{cache_id}.json"
if not cache_file.exists():
return None
# Check if expired
if self._is_expired(cache_file):
cache_file.unlink() # Delete expired file
return None
try:
with open(cache_file) as f:
entry = json.load(f)
return entry.get("data")
except (OSError, json.JSONDecodeError):
return None
def list_entries(self, cache_type: str | None = None) -> list[dict[str, Any]]:
"""List available cache entries with metadata.
Args:
cache_type: Filter by type (e.g., 'simulator-list'), or None for all
Returns:
List of cache entries with id, type, created_at, age_seconds
Example:
entries = cache.list_entries('simulator-list')
for entry in entries:
print(f"{entry['id']} - {entry['age_seconds']}s old")
"""
entries = []
for cache_file in sorted(self.cache_dir.glob("*.json"), reverse=True):
# Check if expired
if self._is_expired(cache_file):
cache_file.unlink()
continue
try:
with open(cache_file) as f:
entry = json.load(f)
# Filter by type if specified
if cache_type and entry.get("cache_type") != cache_type:
continue
created_at = datetime.fromisoformat(entry.get("created_at", ""))
age_seconds = (datetime.now() - created_at).total_seconds()
entries.append(
{
"id": entry.get("cache_id"),
"type": entry.get("cache_type"),
"created_at": entry.get("created_at"),
"age_seconds": int(age_seconds),
}
)
except (OSError, json.JSONDecodeError, ValueError):
continue
return entries
def cleanup(self, max_age_hours: int | None = None) -> int:
"""Remove expired cache entries.
Args:
max_age_hours: Age threshold (default: uses instance max_age_hours)
Returns:
Number of entries deleted
Example:
deleted = cache.cleanup()
print(f"Deleted {deleted} expired cache entries")
"""
if max_age_hours is None:
max_age_hours = self.max_age_hours
deleted = 0
for cache_file in self.cache_dir.glob("*.json"):
if self._is_expired(cache_file, max_age_hours):
cache_file.unlink()
deleted += 1
return deleted
def clear(self, cache_type: str | None = None) -> int:
"""Clear all cache entries of a type.
Args:
cache_type: Type to clear (e.g., 'simulator-list'), or None to clear all
Returns:
Number of entries deleted
Example:
cleared = cache.clear('simulator-list')
print(f"Cleared {cleared} simulator list entries")
"""
deleted = 0
for cache_file in self.cache_dir.glob("*.json"):
if cache_type is None:
# Clear all
cache_file.unlink()
deleted += 1
else:
# Clear by type
try:
with open(cache_file) as f:
entry = json.load(f)
if entry.get("cache_type") == cache_type:
cache_file.unlink()
deleted += 1
except (OSError, json.JSONDecodeError):
pass
return deleted
def _is_expired(self, cache_file: Path, max_age_hours: int | None = None) -> bool:
"""Check if cache file is expired.
Args:
cache_file: Path to cache file
max_age_hours: Age threshold (default: uses instance max_age_hours)
Returns:
True if file is older than max_age_hours
"""
if max_age_hours is None:
max_age_hours = self.max_age_hours
try:
with open(cache_file) as f:
entry = json.load(f)
created_at = datetime.fromisoformat(entry.get("created_at", ""))
age = datetime.now() - created_at
return age > timedelta(hours=max_age_hours)
except (OSError, json.JSONDecodeError, ValueError):
return True
# Module-level cache instances (lazy-loaded)
_cache_instances: dict[str, ProgressiveCache] = {}
def get_cache(cache_dir: str | None = None) -> ProgressiveCache:
"""Get or create global cache instance.
Args:
cache_dir: Custom cache directory (uses default if None)
Returns:
ProgressiveCache instance
"""
# Use cache_dir as key, or 'default' if None
key = cache_dir or "default"
if key not in _cache_instances:
_cache_instances[key] = ProgressiveCache(cache_dir)
return _cache_instances[key]
@@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""
Shared device and simulator utilities.
Common patterns for interacting with simulators via xcrun simctl and IDB.
Standardizes command building and device targeting to prevent errors.
Follows Jackson's Law - only extracts genuinely reused patterns.
Used by:
- app_launcher.py (8 call sites) - App lifecycle commands
- Multiple scripts (15+ locations) - IDB command building
- navigator.py, gesture.py - Coordinate transformation
- test_recorder.py, app_state_capture.py - Auto-UDID detection
"""
import json
import re
import subprocess
def build_simctl_command(
operation: str,
udid: str | None = None,
*args,
) -> list[str]:
"""
Build xcrun simctl command with proper device handling.
Standardizes command building to prevent device targeting bugs.
Automatically uses "booted" if no UDID provided.
Used by:
- app_launcher.py: launch, terminate, install, uninstall, openurl, listapps, spawn
- Multiple scripts: generic simctl operations
Args:
operation: simctl operation (launch, terminate, install, etc.)
udid: Device UDID (uses 'booted' if None)
*args: Additional command arguments
Returns:
Complete command list ready for subprocess.run()
Examples:
# Launch app on booted simulator
cmd = build_simctl_command("launch", None, "com.app.bundle")
# Returns: ["xcrun", "simctl", "launch", "booted", "com.app.bundle"]
# Launch on specific device
cmd = build_simctl_command("launch", "ABC123", "com.app.bundle")
# Returns: ["xcrun", "simctl", "launch", "ABC123", "com.app.bundle"]
# Install app on specific device
cmd = build_simctl_command("install", "ABC123", "/path/to/app.app")
# Returns: ["xcrun", "simctl", "install", "ABC123", "/path/to/app.app"]
"""
cmd = ["xcrun", "simctl", operation]
# Add device (booted or specific UDID)
cmd.append(udid if udid else "booted")
# Add remaining arguments
cmd.extend(str(arg) for arg in args)
return cmd
def build_idb_command(
operation: str,
udid: str | None = None,
*args,
) -> list[str]:
"""
Build IDB command with proper device targeting.
Standardizes IDB command building across all scripts using IDB.
Handles device UDID consistently.
Used by:
- navigator.py: ui tap, ui text, ui describe-all
- gesture.py: ui swipe, ui tap
- keyboard.py: ui key, ui text, ui tap
- And more: 15+ locations
Args:
operation: IDB operation path (e.g., "ui tap", "ui text", "ui describe-all")
udid: Device UDID (omits --udid flag if None, IDB uses booted by default)
*args: Additional command arguments
Returns:
Complete command list ready for subprocess.run()
Examples:
# Tap on booted simulator
cmd = build_idb_command("ui tap", None, "200", "400")
# Returns: ["idb", "ui", "tap", "200", "400"]
# Tap on specific device
cmd = build_idb_command("ui tap", "ABC123", "200", "400")
# Returns: ["idb", "ui", "tap", "200", "400", "--udid", "ABC123"]
# Get accessibility tree
cmd = build_idb_command("ui describe-all", "ABC123", "--json", "--nested")
# Returns: ["idb", "ui", "describe-all", "--json", "--nested", "--udid", "ABC123"]
# Enter text
cmd = build_idb_command("ui text", None, "hello world")
# Returns: ["idb", "ui", "text", "hello world"]
"""
# Split operation into parts (e.g., "ui tap" -> ["ui", "tap"])
cmd = ["idb"] + operation.split()
# Add arguments
cmd.extend(str(arg) for arg in args)
# Add device targeting if specified (optional for IDB, uses booted by default)
if udid:
cmd.extend(["--udid", udid])
return cmd
def get_booted_device_udid() -> str | None:
"""
Auto-detect currently booted simulator UDID.
Queries xcrun simctl for booted devices and returns first match.
Returns:
UDID of booted simulator, or None if no simulator is booted.
Example:
udid = get_booted_device_udid()
if udid:
print(f"Booted simulator: {udid}")
else:
print("No simulator is currently booted")
"""
try:
result = subprocess.run(
["xcrun", "simctl", "list", "devices", "booted"],
capture_output=True,
text=True,
check=True,
)
# Parse output to find UDID
# Format: " iPhone 16 Pro (ABC123-DEF456) (Booted)"
for line in result.stdout.split("\n"):
# Look for UUID pattern in parentheses
match = re.search(r"\(([A-F0-9\-]{36})\)", line)
if match:
return match.group(1)
return None
except subprocess.CalledProcessError:
return None
def resolve_udid(udid_arg: str | None) -> str:
"""
Resolve device UDID with auto-detection fallback.
If udid_arg is provided, returns it immediately.
If None, attempts to auto-detect booted simulator.
Raises error if neither is available.
Args:
udid_arg: Explicit UDID from command line, or None
Returns:
Valid UDID string
Raises:
RuntimeError: If no UDID provided and no booted simulator found
Example:
try:
udid = resolve_udid(args.udid) # args.udid might be None
print(f"Using device: {udid}")
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
"""
if udid_arg:
return udid_arg
booted_udid = get_booted_device_udid()
if booted_udid:
return booted_udid
raise RuntimeError(
"No device UDID provided and no simulator is currently booted.\n"
"Boot a simulator or provide --udid explicitly:\n"
" xcrun simctl boot <device-name>\n"
" python scripts/script_name.py --udid <device-udid>"
)
def get_device_screen_size(udid: str) -> tuple[int, int]:
"""
Get actual screen dimensions for device via accessibility tree.
Queries IDB accessibility tree to determine actual device resolution.
Falls back to iPhone 14 defaults (390x844) if detection fails.
Args:
udid: Device UDID
Returns:
Tuple of (width, height) in pixels
Example:
width, height = get_device_screen_size("ABC123")
print(f"Device screen: {width}x{height}")
"""
try:
cmd = build_idb_command("ui describe-all", udid, "--json")
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse JSON response
data = json.loads(result.stdout)
tree = data[0] if isinstance(data, list) and len(data) > 0 else data
# Get frame size from root element
if tree and "frame" in tree:
frame = tree["frame"]
width = int(frame.get("width", 390))
height = int(frame.get("height", 844))
return (width, height)
# Fallback
return (390, 844)
except Exception:
# Graceful fallback to iPhone 14 Pro defaults
return (390, 844)
def resolve_device_identifier(identifier: str) -> str:
"""
Resolve device name or partial UDID to full UDID.
Supports multiple identifier formats:
- Full UDID: "ABC-123-DEF456..." (36 character UUID)
- Device name: "iPhone 16 Pro" (matches full name)
- Partial match: "iPhone 16" (matches first device containing this string)
- Special: "booted" (resolves to currently booted device)
Args:
identifier: Device UDID, name, or special value "booted"
Returns:
Full device UDID
Raises:
RuntimeError: If identifier cannot be resolved
Example:
udid = resolve_device_identifier("iPhone 16 Pro")
# Returns: "ABC123DEF456..."
udid = resolve_device_identifier("booted")
# Returns UDID of booted simulator
"""
# Handle "booted" special case
if identifier.lower() == "booted":
booted = get_booted_device_udid()
if booted:
return booted
raise RuntimeError(
"No simulator is currently booted. "
"Boot a simulator first: xcrun simctl boot <device-udid>"
)
# Check if already a full UDID (36 character UUID format)
if re.match(r"^[A-F0-9\-]{36}$", identifier, re.IGNORECASE):
return identifier.upper()
# Try to match by device name
simulators = list_simulators(state=None)
exact_matches = [s for s in simulators if s["name"].lower() == identifier.lower()]
if exact_matches:
return exact_matches[0]["udid"]
# Try partial match
partial_matches = [s for s in simulators if identifier.lower() in s["name"].lower()]
if partial_matches:
return partial_matches[0]["udid"]
# No match found
raise RuntimeError(
f"Device '{identifier}' not found. "
f"Use 'xcrun simctl list devices' to see available simulators."
)
def list_simulators(state: str | None = None) -> list[dict]:
"""
List iOS simulators with optional state filtering.
Queries xcrun simctl and returns structured list of simulators.
Optionally filters by state (available, booted, all).
Args:
state: Optional filter - "available", "booted", or None for all
Returns:
List of simulator dicts with keys:
- "name": Device name (e.g., "iPhone 16 Pro")
- "udid": Device UDID (36 char UUID)
- "state": Device state ("Booted", "Shutdown", "Unavailable")
- "runtime": iOS version (e.g., "iOS 18.0", "unavailable")
- "type": Device type ("iPhone", "iPad", "Apple Watch", etc.)
Example:
# List all simulators
all_sims = list_simulators()
print(f"Total simulators: {len(all_sims)}")
# List only available simulators
available = list_simulators(state="available")
for sim in available:
print(f"{sim['name']} ({sim['state']}) - {sim['udid']}")
# List only booted simulators
booted = list_simulators(state="booted")
for sim in booted:
print(f"Booted: {sim['name']}")
"""
try:
# Query simctl for device list
cmd = ["xcrun", "simctl", "list", "devices", "-j"]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
simulators = []
# Parse JSON response
# Format: {"devices": {"iOS 18.0": [{...}, {...}], "iOS 17.0": [...], ...}}
for ios_version, devices in data.get("devices", {}).items():
for device in devices:
sim = {
"name": device.get("name", "Unknown"),
"udid": device.get("udid", ""),
"state": device.get("state", "Unknown"),
"runtime": ios_version,
"type": _extract_device_type(device.get("name", "")),
}
simulators.append(sim)
# Apply state filtering
if state == "booted":
return [s for s in simulators if s["state"] == "Booted"]
if state == "available":
return [s for s in simulators if s["state"] == "Shutdown"] # Available to boot
if state is None:
return simulators
return [s for s in simulators if s["state"].lower() == state.lower()]
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
raise RuntimeError(f"Failed to list simulators: {e}") from e
def _extract_device_type(device_name: str) -> str:
"""
Extract device type from device name.
Parses device name to determine type (iPhone, iPad, Watch, etc.).
Args:
device_name: Full device name (e.g., "iPhone 16 Pro")
Returns:
Device type string
Example:
_extract_device_type("iPhone 16 Pro") # Returns "iPhone"
_extract_device_type("iPad Air") # Returns "iPad"
_extract_device_type("Apple Watch Series 9") # Returns "Watch"
"""
if "iPhone" in device_name:
return "iPhone"
if "iPad" in device_name:
return "iPad"
if "Watch" in device_name or "Apple Watch" in device_name:
return "Watch"
if "TV" in device_name or "Apple TV" in device_name:
return "TV"
return "Unknown"
def transform_screenshot_coords(
x: float,
y: float,
screenshot_width: int,
screenshot_height: int,
device_width: int,
device_height: int,
) -> tuple[int, int]:
"""
Transform screenshot coordinates to device coordinates.
Handles the case where a screenshot was downscaled (e.g., to 'half' size)
and needs to be transformed back to actual device pixel coordinates
for accurate tapping.
The transformation is linear:
device_x = (screenshot_x / screenshot_width) * device_width
device_y = (screenshot_y / screenshot_height) * device_height
Args:
x, y: Coordinates in the screenshot
screenshot_width, screenshot_height: Screenshot dimensions (e.g., 195, 422)
device_width, device_height: Actual device dimensions (e.g., 390, 844)
Returns:
Tuple of (device_x, device_y) in device pixels
Example:
# Screenshot taken at 'half' size: 195x422 (from 390x844 device)
device_x, device_y = transform_screenshot_coords(
100, 200, # Tap point in screenshot
195, 422, # Screenshot dimensions
390, 844 # Device dimensions
)
print(f"Tap at device coords: ({device_x}, {device_y})")
# Output: Tap at device coords: (200, 400)
"""
device_x = int((x / screenshot_width) * device_width)
device_y = int((y / screenshot_height) * device_height)
return (device_x, device_y)
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Shared IDB utility functions.
This module provides common IDB operations used across multiple scripts.
Follows Jackson's Law - only shared code that's truly reused, not speculative.
Used by:
- navigator.py - Accessibility tree navigation
- screen_mapper.py - UI element analysis
- accessibility_audit.py - WCAG compliance checking
- test_recorder.py - Test documentation
- app_state_capture.py - State snapshots
- gesture.py - Touch gesture operations
"""
import json
import subprocess
import sys
def get_accessibility_tree(udid: str | None = None, nested: bool = True) -> dict:
"""
Fetch accessibility tree from IDB.
The accessibility tree represents the complete UI hierarchy of the current
screen, with all element properties needed for semantic navigation.
Args:
udid: Device UDID (uses booted simulator if None)
nested: Include nested structure (default True). If False, returns flat array.
Returns:
Root element of accessibility tree as dict.
Structure: {
"type": "Window",
"AXLabel": "App Name",
"frame": {"x": 0, "y": 0, "width": 390, "height": 844},
"children": [...]
}
Raises:
SystemExit: If IDB command fails or returns invalid JSON
Example:
tree = get_accessibility_tree("UDID123")
# Root is Window element with all children nested
"""
cmd = ["idb", "ui", "describe-all", "--json"]
if nested:
cmd.append("--nested")
if udid:
cmd.extend(["--udid", udid])
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
tree_data = json.loads(result.stdout)
# IDB returns array format, extract first element (root)
if isinstance(tree_data, list) and len(tree_data) > 0:
return tree_data[0]
return tree_data
except subprocess.CalledProcessError as e:
print(f"Error: Failed to get accessibility tree: {e.stderr}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print("Error: Invalid JSON from idb", file=sys.stderr)
sys.exit(1)
def flatten_tree(node: dict, depth: int = 0, elements: list[dict] | None = None) -> list[dict]:
"""
Flatten nested accessibility tree into list of elements.
Converts the hierarchical accessibility tree into a flat list where each
element includes its depth for context.
Used by:
- navigator.py - Element finding
- screen_mapper.py - Element analysis
- accessibility_audit.py - Audit scanning
Args:
node: Root node of tree (typically from get_accessibility_tree)
depth: Current depth (used internally, start at 0)
elements: Accumulator list (used internally, start as None)
Returns:
Flat list of elements, each with "depth" key indicating nesting level.
Structure of each element: {
"type": "Button",
"AXLabel": "Login",
"frame": {...},
"depth": 2,
...
}
Example:
tree = get_accessibility_tree()
flat = flatten_tree(tree)
for elem in flat:
print(f"{' ' * elem['depth']}{elem.get('type')}: {elem.get('AXLabel')}")
"""
if elements is None:
elements = []
# Add current node with depth tracking
node_copy = node.copy()
node_copy["depth"] = depth
elements.append(node_copy)
# Process children recursively
for child in node.get("children", []):
flatten_tree(child, depth + 1, elements)
return elements
def count_elements(node: dict) -> int:
"""
Count total elements in tree (recursive).
Traverses entire tree counting all elements for reporting purposes.
Used by:
- test_recorder.py - Element counting per step
- screen_mapper.py - Summary statistics
Args:
node: Root node of tree
Returns:
Total element count including root and all descendants
Example:
tree = get_accessibility_tree()
total = count_elements(tree)
print(f"Screen has {total} elements")
"""
count = 1
for child in node.get("children", []):
count += count_elements(child)
return count
def get_screen_size(udid: str | None = None) -> tuple[int, int]:
"""
Get screen dimensions from accessibility tree.
Extracts the screen size from the root element's frame. Useful for
gesture calculations and coordinate normalization.
Used by:
- gesture.py - Gesture positioning
- Potentially: screenshot positioning, screen-aware scaling
Args:
udid: Device UDID (uses booted if None)
Returns:
(width, height) tuple. Defaults to (390, 844) if detection fails
or tree cannot be accessed.
Example:
width, height = get_screen_size()
center_x = width // 2
center_y = height // 2
"""
DEFAULT_WIDTH = 390 # iPhone 14
DEFAULT_HEIGHT = 844
try:
tree = get_accessibility_tree(udid, nested=False)
frame = tree.get("frame", {})
width = int(frame.get("width", DEFAULT_WIDTH))
height = int(frame.get("height", DEFAULT_HEIGHT))
return (width, height)
except Exception:
# Silently fall back to defaults if tree access fails
return (DEFAULT_WIDTH, DEFAULT_HEIGHT)
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""
Screenshot utilities with dual-mode support.
Provides unified screenshot handling with:
- File-based mode: Persistent artifacts for test documentation
- Inline base64 mode: Vision-based automation for agent analysis
- Size presets: Token optimization (full/half/quarter/thumb)
- Semantic naming: {appName}_{screenName}_{state}_{timestamp}.png
Supports resize operations via PIL (optional dependency).
Used by:
- test_recorder.py - Step-based screenshot recording
- app_state_capture.py - State snapshot captures
"""
import base64
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
# Try to import PIL for resizing, but make it optional
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
def generate_screenshot_name(
app_name: str | None = None,
screen_name: str | None = None,
state: str | None = None,
timestamp: str | None = None,
extension: str = "png",
) -> str:
"""Generate semantic screenshot filename.
Format: {appName}_{screenName}_{state}_{timestamp}.{ext}
Falls back to: screenshot_{timestamp}.{ext}
Args:
app_name: Application name (e.g., 'MyApp')
screen_name: Screen name (e.g., 'Login')
state: State description (e.g., 'Empty', 'Filled', 'Error')
timestamp: ISO timestamp (uses current time if None)
extension: File extension (default: 'png')
Returns:
Semantic filename ready for safe file creation
Example:
name = generate_screenshot_name('MyApp', 'Login', 'Empty')
# Returns: 'MyApp_Login_Empty_20251028-143052.png'
name = generate_screenshot_name()
# Returns: 'screenshot_20251028-143052.png'
"""
if timestamp is None:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
# Build semantic name
if app_name or screen_name or state:
parts = [app_name, screen_name, state]
parts = [p for p in parts if p] # Filter None/empty
name = "_".join(parts) + f"_{timestamp}"
else:
name = f"screenshot_{timestamp}"
return f"{name}.{extension}"
def get_size_preset(size: str = "half") -> tuple[float, float]:
"""Get scale factors for size preset.
Args:
size: 'full', 'half', 'quarter', 'thumb'
Returns:
Tuple of (scale_x, scale_y) for resizing
Example:
scale_x, scale_y = get_size_preset('half')
# Returns: (0.5, 0.5)
"""
presets = {
"full": (1.0, 1.0),
"half": (0.5, 0.5),
"quarter": (0.25, 0.25),
"thumb": (0.1, 0.1),
}
return presets.get(size, (0.5, 0.5))
def resize_screenshot(
input_path: str,
output_path: str | None = None,
size: str = "half",
quality: int = 85,
) -> tuple[str, int, int]:
"""Resize screenshot for token optimization.
Requires PIL (Pillow). Falls back gracefully without it.
Args:
input_path: Path to original screenshot
output_path: Output path (uses input_path if None)
size: 'full', 'half', 'quarter', 'thumb'
quality: JPEG quality (1-100, default: 85)
Returns:
Tuple of (output_path, width, height) of resized image
Raises:
FileNotFoundError: If input file doesn't exist
ValueError: If PIL not installed and size != 'full'
Example:
output, w, h = resize_screenshot(
'screenshot.png',
'screenshot_half.png',
'half'
)
print(f"Resized to {w}x{h}")
"""
input_file = Path(input_path)
if not input_file.exists():
raise FileNotFoundError(f"Screenshot not found: {input_path}")
# If full size, just copy
if size == "full":
if output_path:
import shutil
shutil.copy(input_path, output_path)
output_file = Path(output_path)
else:
output_file = input_file
# Get original dimensions
if HAS_PIL:
img = Image.open(str(output_file))
return (str(output_file), img.width, img.height)
return (str(output_file), 0, 0) # Dimensions unknown without PIL
# Need PIL to resize
if not HAS_PIL:
raise ValueError(
f"Size preset '{size}' requires PIL (Pillow). " "Install with: pip3 install pillow"
)
# Open original image
img = Image.open(str(input_file))
orig_w, orig_h = img.size
# Calculate new size
scale_x, scale_y = get_size_preset(size)
new_w = int(orig_w * scale_x)
new_h = int(orig_h * scale_y)
# Resize with high-quality resampling
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Determine output path
if output_path is None:
# Insert size marker before extension
stem = input_file.stem
suffix = input_file.suffix
output_path = str(input_file.parent / f"{stem}_{size}{suffix}")
# Save resized image
resized.save(output_path, quality=quality, optimize=True)
return (output_path, new_w, new_h)
def capture_screenshot(
udid: str,
output_path: str | None = None,
size: str = "half",
inline: bool = False,
app_name: str | None = None,
screen_name: str | None = None,
state: str | None = None,
) -> dict[str, Any]:
"""Capture screenshot with flexible output modes.
Supports both file-based (persistent artifacts) and inline base64 modes
(for vision-based automation).
Args:
udid: Device UDID
output_path: File path for file mode (generates semantic name if None)
size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
inline: If True, returns base64 data instead of saving to file
app_name: App name for semantic naming
screen_name: Screen name for semantic naming
state: State description for semantic naming
Returns:
Dict with mode-specific fields:
File mode:
{
'mode': 'file',
'file_path': str,
'size_bytes': int,
'width': int,
'height': int,
'size_preset': str
}
Inline mode:
{
'mode': 'inline',
'base64_data': str,
'mime_type': 'image/png',
'width': int,
'height': int,
'size_preset': str
}
Example:
# File mode
result = capture_screenshot('ABC123', app_name='MyApp')
print(f"Saved to: {result['file_path']}")
# Inline mode
result = capture_screenshot('ABC123', inline=True, size='half')
print(f"Screenshot: {result['width']}x{result['height']}")
print(f"Base64: {result['base64_data'][:50]}...")
"""
try:
# Capture raw screenshot to temp file
temp_path = "/tmp/ios_simulator_screenshot.png"
cmd = ["xcrun", "simctl", "io", udid, "screenshot", temp_path]
subprocess.run(cmd, capture_output=True, text=True, check=True)
if inline:
# Inline mode: resize and convert to base64
# Resize if needed
if size != "full" and HAS_PIL:
resized_path, width, height = resize_screenshot(temp_path, size=size)
else:
resized_path = temp_path
# Get dimensions via PIL if available
if HAS_PIL:
img = Image.open(resized_path)
width, height = img.size
else:
width, height = 390, 844 # Fallback to common device size
# Read and encode as base64
with open(resized_path, "rb") as f:
base64_data = base64.b64encode(f.read()).decode("utf-8")
# Clean up temp files
Path(temp_path).unlink(missing_ok=True)
if resized_path != temp_path:
Path(resized_path).unlink(missing_ok=True)
return {
"mode": "inline",
"base64_data": base64_data,
"mime_type": "image/png",
"width": width,
"height": height,
"size_preset": size,
}
# File mode: save to output path with semantic naming
if output_path is None:
output_path = generate_screenshot_name(app_name, screen_name, state)
# Resize if needed
if size != "full" and HAS_PIL:
final_path, width, height = resize_screenshot(temp_path, output_path, size)
else:
# Just move temp to output
import shutil
shutil.move(temp_path, output_path)
final_path = output_path
# Get dimensions via PIL if available
if HAS_PIL:
img = Image.open(final_path)
width, height = img.size
else:
width, height = 390, 844 # Fallback
# Get file size
size_bytes = Path(final_path).stat().st_size
return {
"mode": "file",
"file_path": final_path,
"size_bytes": size_bytes,
"width": width,
"height": height,
"size_preset": size,
}
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to capture screenshot: {e.stderr}") from e
except Exception as e:
raise RuntimeError(f"Screenshot capture error: {e!s}") from e
def format_screenshot_result(result: dict[str, Any]) -> str:
"""Format screenshot result for human-readable output.
Args:
result: Result dictionary from capture_screenshot()
Returns:
Formatted string for printing
Example:
result = capture_screenshot('ABC123', inline=True)
print(format_screenshot_result(result))
"""
if result["mode"] == "file":
return (
f"Screenshot: {result['file_path']}\n"
f"Dimensions: {result['width']}x{result['height']}\n"
f"Size: {result['size_bytes']} bytes"
)
return (
f"Screenshot (inline): {result['width']}x{result['height']}\n"
f"Base64 length: {len(result['base64_data'])} chars"
)
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""
iOS Gesture Controller - Swipes and Complex Gestures
Performs navigation gestures like swipes, scrolls, and pinches.
Token-efficient output for common navigation patterns.
This script handles touch gestures for iOS simulator automation. It provides
directional swipes, multi-swipe scrolling, pull-to-refresh, and pinch gestures.
Automatically detects screen size from the device for accurate gesture positioning.
Key Features:
- Directional swipes (up, down, left, right)
- Multi-swipe scrolling with customizable amount
- Pull-to-refresh gesture
- Pinch to zoom (in/out)
- Custom swipe between any two points
- Drag and drop simulation
- Auto-detects screen dimensions from device
Usage Examples:
# Simple directional swipe
python scripts/gesture.py --swipe up --udid <device-id>
# Scroll down multiple times
python scripts/gesture.py --scroll down --scroll-amount 3 --udid <device-id>
# Pull to refresh
python scripts/gesture.py --refresh --udid <device-id>
# Custom swipe coordinates
python scripts/gesture.py --swipe-from 100,500 --swipe-to 100,100 --udid <device-id>
# Pinch to zoom
python scripts/gesture.py --pinch out --udid <device-id>
# Long press at coordinates
python scripts/gesture.py --long-press 200,300 --duration 2.0 --udid <device-id>
Output Format:
Swiped up
Scrolled down (3x)
Performed pull to refresh
Gesture Details:
- Swipes use 70% of screen by default (configurable)
- Scrolls are multiple small 30% swipes with delays
- Start points are offset from edges for reliability
- Screen size auto-detected from accessibility tree root element
- Falls back to iPhone 14 dimensions (390x844) if detection fails
Technical Details:
- Uses `idb ui swipe x1 y1 x2 y2` for gesture execution
- Duration parameter converts to milliseconds for IDB
- Automatically fetches screen size on initialization
- Parses IDB accessibility tree to get root frame dimensions
- All coordinates calculated as fractions of screen size for device independence
"""
import argparse
import subprocess
import sys
import time
from common import (
get_device_screen_size,
get_screen_size,
resolve_udid,
transform_screenshot_coords,
)
class GestureController:
"""Performs gestures on iOS simulator."""
# Standard screen dimensions (will be detected if possible)
DEFAULT_WIDTH = 390 # iPhone 14
DEFAULT_HEIGHT = 844
def __init__(self, udid: str | None = None):
"""Initialize gesture controller."""
self.udid = udid
self.screen_size = self._get_screen_size()
def _get_screen_size(self) -> tuple[int, int]:
"""Try to detect screen size from device using shared utility."""
return get_screen_size(self.udid)
def swipe(self, direction: str, distance_ratio: float = 0.7) -> bool:
"""
Perform directional swipe.
Args:
direction: up, down, left, right
distance_ratio: How far to swipe (0.0-1.0 of screen)
Returns:
Success status
"""
width, height = self.screen_size
center_x = width // 2
center_y = height // 2
# Calculate swipe coordinates based on direction
if direction == "up":
start = (center_x, int(height * 0.7))
end = (center_x, int(height * (1 - distance_ratio + 0.3)))
elif direction == "down":
start = (center_x, int(height * 0.3))
end = (center_x, int(height * (distance_ratio - 0.3 + 0.3)))
elif direction == "left":
start = (int(width * 0.8), center_y)
end = (int(width * (1 - distance_ratio + 0.2)), center_y)
elif direction == "right":
start = (int(width * 0.2), center_y)
end = (int(width * (distance_ratio - 0.2 + 0.2)), center_y)
else:
return False
return self.swipe_between(start, end)
def swipe_between(
self, start: tuple[int, int], end: tuple[int, int], duration: float = 0.3
) -> bool:
"""
Swipe between two points.
Args:
start: Starting coordinates (x, y)
end: Ending coordinates (x, y)
duration: Swipe duration in seconds
Returns:
Success status
"""
cmd = ["idb", "ui", "swipe"]
cmd.extend([str(start[0]), str(start[1]), str(end[0]), str(end[1])])
# IDB doesn't support duration directly, but we can add delay
if duration != 0.3:
cmd.extend(["--duration", str(int(duration * 1000))])
if self.udid:
cmd.extend(["--udid", self.udid])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def scroll(self, direction: str, amount: int = 3) -> bool:
"""
Perform multiple small swipes to scroll.
Args:
direction: up, down
amount: Number of small swipes
Returns:
Success status
"""
for _ in range(amount):
if not self.swipe(direction, distance_ratio=0.3):
return False
time.sleep(0.2) # Small delay between swipes
return True
def tap_and_hold(self, x: int, y: int, duration: float = 2.0) -> bool:
"""
Long press at coordinates.
Args:
x, y: Coordinates
duration: Hold duration in seconds
Returns:
Success status
"""
# IDB doesn't have native long press, simulate with tap
# In real implementation, might need to use different approach
cmd = ["idb", "ui", "tap", str(x), str(y)]
if self.udid:
cmd.extend(["--udid", self.udid])
try:
subprocess.run(cmd, capture_output=True, check=True)
# Simulate hold with delay
time.sleep(duration)
return True
except subprocess.CalledProcessError:
return False
def pinch(self, direction: str = "out", center: tuple[int, int] | None = None) -> bool:
"""
Perform pinch gesture (zoom in/out).
Args:
direction: 'in' (zoom out) or 'out' (zoom in)
center: Center point for pinch
Returns:
Success status
"""
if not center:
width, height = self.screen_size
center = (width // 2, height // 2)
# Calculate pinch points
offset = 100 if direction == "out" else 50
if direction == "out":
# Zoom in - fingers move apart
start1 = (center[0] - 20, center[1] - 20)
end1 = (center[0] - offset, center[1] - offset)
start2 = (center[0] + 20, center[1] + 20)
end2 = (center[0] + offset, center[1] + offset)
else:
# Zoom out - fingers move together
start1 = (center[0] - offset, center[1] - offset)
end1 = (center[0] - 20, center[1] - 20)
start2 = (center[0] + offset, center[1] + offset)
end2 = (center[0] + 20, center[1] + 20)
# Perform two swipes simultaneously (simulated)
success1 = self.swipe_between(start1, end1)
success2 = self.swipe_between(start2, end2)
return success1 and success2
def drag_and_drop(self, start: tuple[int, int], end: tuple[int, int]) -> bool:
"""
Drag element from one position to another.
Args:
start: Starting coordinates
end: Ending coordinates
Returns:
Success status
"""
# Use slow swipe to simulate drag
return self.swipe_between(start, end, duration=1.0)
def refresh(self) -> bool:
"""Pull to refresh gesture."""
width, _ = self.screen_size
start = (width // 2, 100)
end = (width // 2, 400)
return self.swipe_between(start, end)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Perform gestures on iOS simulator")
# Gesture options
parser.add_argument(
"--swipe", choices=["up", "down", "left", "right"], help="Perform directional swipe"
)
parser.add_argument("--swipe-from", help="Custom swipe start coordinates (x,y)")
parser.add_argument("--swipe-to", help="Custom swipe end coordinates (x,y)")
parser.add_argument(
"--scroll", choices=["up", "down"], help="Scroll in direction (multiple small swipes)"
)
parser.add_argument(
"--scroll-amount", type=int, default=3, help="Number of scroll swipes (default: 3)"
)
parser.add_argument("--long-press", help="Long press at coordinates (x,y)")
parser.add_argument(
"--duration", type=float, default=2.0, help="Duration for long press in seconds"
)
parser.add_argument(
"--pinch", choices=["in", "out"], help="Pinch gesture (in=zoom out, out=zoom in)"
)
parser.add_argument("--refresh", action="store_true", help="Pull to refresh gesture")
# Coordinate transformation
parser.add_argument(
"--screenshot-coords",
action="store_true",
help="Interpret swipe coordinates as from a screenshot (requires --screenshot-width/height)",
)
parser.add_argument(
"--screenshot-width",
type=int,
help="Screenshot width for coordinate transformation",
)
parser.add_argument(
"--screenshot-height",
type=int,
help="Screenshot height for coordinate transformation",
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
controller = GestureController(udid=udid)
# Execute requested gesture
if args.swipe:
if controller.swipe(args.swipe):
print(f"Swiped {args.swipe}")
else:
print(f"Failed to swipe {args.swipe}")
sys.exit(1)
elif args.swipe_from and args.swipe_to:
# Custom swipe
start = tuple(map(int, args.swipe_from.split(",")))
end = tuple(map(int, args.swipe_to.split(",")))
# Handle coordinate transformation if requested
if args.screenshot_coords:
if not args.screenshot_width or not args.screenshot_height:
print(
"Error: --screenshot-coords requires --screenshot-width and --screenshot-height"
)
sys.exit(1)
device_w, device_h = get_device_screen_size(udid)
start = transform_screenshot_coords(
start[0],
start[1],
args.screenshot_width,
args.screenshot_height,
device_w,
device_h,
)
end = transform_screenshot_coords(
end[0],
end[1],
args.screenshot_width,
args.screenshot_height,
device_w,
device_h,
)
print("Transformed screenshot coords to device coords")
if controller.swipe_between(start, end):
print(f"Swiped from {start} to {end}")
else:
print("Failed to swipe")
sys.exit(1)
elif args.scroll:
if controller.scroll(args.scroll, args.scroll_amount):
print(f"Scrolled {args.scroll} ({args.scroll_amount}x)")
else:
print(f"Failed to scroll {args.scroll}")
sys.exit(1)
elif args.long_press:
coords = tuple(map(int, args.long_press.split(",")))
if controller.tap_and_hold(coords[0], coords[1], args.duration):
print(f"Long pressed at {coords} for {args.duration}s")
else:
print("Failed to long press")
sys.exit(1)
elif args.pinch:
if controller.pinch(args.pinch):
action = "Zoomed in" if args.pinch == "out" else "Zoomed out"
print(action)
else:
print(f"Failed to pinch {args.pinch}")
sys.exit(1)
elif args.refresh:
if controller.refresh():
print("Performed pull to refresh")
else:
print("Failed to refresh")
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
iOS Keyboard Controller - Text Entry and Hardware Buttons
Handles keyboard input, special keys, and hardware button simulation.
Token-efficient text entry and navigation control.
This script provides text input and hardware button control for iOS simulator
automation. It handles both typing text strings and pressing special keys like
return, delete, tab, etc. Also controls hardware buttons like home and lock.
Key Features:
- Type text strings into focused elements
- Press special keys (return, delete, tab, space, arrows)
- Hardware button simulation (home, lock, volume, screenshot)
- Character-by-character typing with delays (for animations)
- Multiple key press support
- iOS HID key code mapping for reliability
Usage Examples:
# Type text into focused field
python scripts/keyboard.py --type "hello@example.com" --udid <device-id>
# Press return key to submit
python scripts/keyboard.py --key return --udid <device-id>
# Press delete 3 times
python scripts/keyboard.py --key delete --key delete --key delete --udid <device-id>
# Press home button
python scripts/keyboard.py --button home --udid <device-id>
# Press lock button
python scripts/keyboard.py --button lock --udid <device-id>
# Type with delay between characters (for animations)
python scripts/keyboard.py --type "slow typing" --delay 0.1 --udid <device-id>
Output Format:
Typed: "hello@example.com"
Pressed return
Pressed home button
Special Keys Supported:
- return/enter: Submit forms, new lines (HID code 40)
- delete/backspace: Remove characters (HID code 42)
- tab: Navigate between fields (HID code 43)
- space: Space character (HID code 44)
- escape: Cancel/dismiss (HID code 41)
- up/down/left/right: Arrow keys (HID codes 82/81/80/79)
Hardware Buttons Supported:
- home: Return to home screen
- lock/power: Lock device
- volume-up/volume-down: Volume control
- ringer: Toggle mute
- screenshot: Capture screen
Technical Details:
- Uses `idb ui text` for typing text strings
- Uses `idb ui key <code>` for special keys with iOS HID codes
- HID codes from Apple's UIKeyboardHIDUsage specification
- Hardware buttons use `xcrun simctl` button actions
- Text entry works on currently focused element
- Special keys are integers (40=Return, 42=Delete, etc.)
"""
import argparse
import subprocess
import sys
import time
from common import resolve_udid
class KeyboardController:
"""Controls keyboard and hardware buttons on iOS simulator."""
# Special key mappings to iOS HID key codes
# See: https://developer.apple.com/documentation/uikit/uikeyboardhidusage
SPECIAL_KEYS = {
"return": 40,
"enter": 40,
"delete": 42,
"backspace": 42,
"tab": 43,
"space": 44,
"escape": 41,
"up": 82,
"down": 81,
"left": 80,
"right": 79,
}
# Hardware button mappings
HARDWARE_BUTTONS = {
"home": "HOME",
"lock": "LOCK",
"volume-up": "VOLUME_UP",
"volume-down": "VOLUME_DOWN",
"ringer": "RINGER",
"power": "LOCK", # Alias
"screenshot": "SCREENSHOT",
}
def __init__(self, udid: str | None = None):
"""Initialize keyboard controller."""
self.udid = udid
def type_text(self, text: str, delay: float = 0.0) -> bool:
"""
Type text into current focus.
Args:
text: Text to type
delay: Delay between characters (for slow typing effect)
Returns:
Success status
"""
if delay > 0:
# Type character by character with delay
for char in text:
if not self._type_single(char):
return False
time.sleep(delay)
return True
# Type all at once (efficient)
return self._type_single(text)
def _type_single(self, text: str) -> bool:
"""Type text using IDB."""
cmd = ["idb", "ui", "text", text]
if self.udid:
cmd.extend(["--udid", self.udid])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def press_key(self, key: str, count: int = 1) -> bool:
"""
Press a special key.
Args:
key: Key name (return, delete, tab, etc.)
count: Number of times to press
Returns:
Success status
"""
# Map key name to IDB key code
key_code = self.SPECIAL_KEYS.get(key.lower())
if not key_code:
# Try as literal integer key code
try:
key_code = int(key)
except ValueError:
return False
cmd = ["idb", "ui", "key", str(key_code)]
if self.udid:
cmd.extend(["--udid", self.udid])
try:
for _ in range(count):
subprocess.run(cmd, capture_output=True, check=True)
if count > 1:
time.sleep(0.1) # Small delay for multiple presses
return True
except subprocess.CalledProcessError:
return False
def press_key_sequence(self, keys: list[str]) -> bool:
"""
Press a sequence of keys.
Args:
keys: List of key names
Returns:
Success status
"""
cmd_base = ["idb", "ui", "key-sequence"]
# Map keys to codes
mapped_keys = []
for key in keys:
mapped = self.SPECIAL_KEYS.get(key.lower())
if mapped is None:
# Try as integer
try:
mapped = int(key)
except ValueError:
return False
mapped_keys.append(str(mapped))
cmd = cmd_base + mapped_keys
if self.udid:
cmd.extend(["--udid", self.udid])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def press_hardware_button(self, button: str) -> bool:
"""
Press hardware button.
Args:
button: Button name (home, lock, volume-up, etc.)
Returns:
Success status
"""
button_code = self.HARDWARE_BUTTONS.get(button.lower())
if not button_code:
return False
cmd = ["idb", "ui", "button", button_code]
if self.udid:
cmd.extend(["--udid", self.udid])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def clear_text(self, select_all: bool = True) -> bool:
"""
Clear text in current field.
Args:
select_all: Use Cmd+A to select all first
Returns:
Success status
"""
if select_all:
# Select all then delete
# Note: This might need adjustment for iOS keyboard shortcuts
success = self.press_key_combo(["cmd", "a"])
if success:
return self.press_key("delete")
else:
# Just delete multiple times
return self.press_key("delete", count=50)
return None
def press_key_combo(self, keys: list[str]) -> bool:
"""
Press key combination (like Cmd+A).
Args:
keys: List of keys to press together
Returns:
Success status
"""
# IDB doesn't directly support key combos
# This is a workaround - may need platform-specific handling
if "cmd" in keys or "command" in keys:
# Handle common shortcuts
if "a" in keys:
# Select all - might work with key sequence
return self.press_key_sequence(["command", "a"])
if "c" in keys:
return self.press_key_sequence(["command", "c"])
if "v" in keys:
return self.press_key_sequence(["command", "v"])
if "x" in keys:
return self.press_key_sequence(["command", "x"])
# Try as sequence
return self.press_key_sequence(keys)
def dismiss_keyboard(self) -> bool:
"""Dismiss on-screen keyboard."""
# Common ways to dismiss keyboard on iOS
# Try Done button first, then Return
success = self.press_key("return")
if not success:
# Try tapping outside (would need coordinate)
pass
return success
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Control keyboard and hardware buttons")
# Text input
parser.add_argument("--type", help="Type text into current focus")
parser.add_argument("--slow", action="store_true", help="Type slowly (character by character)")
# Special keys
parser.add_argument("--key", help="Press special key (return, delete, tab, space, etc.)")
parser.add_argument("--key-sequence", help="Press key sequence (comma-separated)")
parser.add_argument("--count", type=int, default=1, help="Number of times to press key")
# Hardware buttons
parser.add_argument(
"--button",
choices=["home", "lock", "volume-up", "volume-down", "ringer", "screenshot"],
help="Press hardware button",
)
# Other operations
parser.add_argument("--clear", action="store_true", help="Clear current text field")
parser.add_argument("--dismiss", action="store_true", help="Dismiss keyboard")
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
controller = KeyboardController(udid=udid)
# Execute requested action
if args.type:
delay = 0.1 if args.slow else 0.0
if controller.type_text(args.type, delay):
if args.slow:
print(f'Typed: "{args.type}" (slowly)')
else:
print(f'Typed: "{args.type}"')
else:
print("Failed to type text")
sys.exit(1)
elif args.key:
if controller.press_key(args.key, args.count):
if args.count > 1:
print(f"Pressed {args.key} ({args.count}x)")
else:
print(f"Pressed {args.key}")
else:
print(f"Failed to press {args.key}")
sys.exit(1)
elif args.key_sequence:
keys = args.key_sequence.split(",")
if controller.press_key_sequence(keys):
print(f"Pressed sequence: {' -> '.join(keys)}")
else:
print("Failed to press key sequence")
sys.exit(1)
elif args.button:
if controller.press_hardware_button(args.button):
print(f"Pressed {args.button} button")
else:
print(f"Failed to press {args.button}")
sys.exit(1)
elif args.clear:
if controller.clear_text():
print("Cleared text field")
else:
print("Failed to clear text")
sys.exit(1)
elif args.dismiss:
if controller.dismiss_keyboard():
print("Dismissed keyboard")
else:
print("Failed to dismiss keyboard")
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+486
View File
@@ -0,0 +1,486 @@
#!/usr/bin/env python3
"""
iOS Simulator Log Monitoring and Analysis
Real-time log streaming from iOS simulators with intelligent filtering, error detection,
and token-efficient summarization. Enhanced version of app_state_capture.py's log capture.
Features:
- Real-time log streaming from booted simulators
- Smart filtering by app bundle ID, subsystem, category, severity
- Error/warning classification and deduplication
- Duration-based or continuous follow mode
- Token-efficient summaries with full logs saved to file
- Integration with test_recorder and app_state_capture
Usage Examples:
# Monitor app logs in real-time (follow mode)
python scripts/log_monitor.py --app com.myapp.MyApp --follow
# Capture logs for specific duration
python scripts/log_monitor.py --app com.myapp.MyApp --duration 30s
# Extract errors and warnings only from last 5 minutes
python scripts/log_monitor.py --severity error,warning --last 5m
# Save logs to file
python scripts/log_monitor.py --app com.myapp.MyApp --duration 1m --output logs/
# Verbose output with full log lines
python scripts/log_monitor.py --app com.myapp.MyApp --verbose
"""
import argparse
import json
import re
import signal
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
class LogMonitor:
"""Monitor and analyze iOS simulator logs with intelligent filtering."""
def __init__(
self,
app_bundle_id: str | None = None,
device_udid: str | None = None,
severity_filter: list[str] | None = None,
):
"""
Initialize log monitor.
Args:
app_bundle_id: Filter logs by app bundle ID
device_udid: Device UDID (uses booted if not specified)
severity_filter: List of severities to include (error, warning, info, debug)
"""
self.app_bundle_id = app_bundle_id
self.device_udid = device_udid or "booted"
self.severity_filter = severity_filter or ["error", "warning", "info", "debug"]
# Log storage
self.log_lines: list[str] = []
self.errors: list[str] = []
self.warnings: list[str] = []
self.info_messages: list[str] = []
# Statistics
self.error_count = 0
self.warning_count = 0
self.info_count = 0
self.debug_count = 0
self.total_lines = 0
# Deduplication
self.seen_messages: set[str] = set()
# Process control
self.log_process: subprocess.Popen | None = None
self.interrupted = False
def parse_time_duration(self, duration_str: str) -> float:
"""
Parse duration string to seconds.
Args:
duration_str: Duration like "30s", "5m", "1h"
Returns:
Duration in seconds
"""
match = re.match(r"(\d+)([smh])", duration_str.lower())
if not match:
raise ValueError(
f"Invalid duration format: {duration_str}. Use format like '30s', '5m', '1h'"
)
value, unit = match.groups()
value = int(value)
if unit == "s":
return value
if unit == "m":
return value * 60
if unit == "h":
return value * 3600
return 0
def classify_log_line(self, line: str) -> str | None:
"""
Classify log line by severity.
Args:
line: Log line to classify
Returns:
Severity level (error, warning, info, debug) or None
"""
line_lower = line.lower()
# Error patterns
error_patterns = [
r"\berror\b",
r"\bfault\b",
r"\bfailed\b",
r"\bexception\b",
r"\bcrash\b",
r"❌",
]
# Warning patterns
warning_patterns = [r"\bwarning\b", r"\bwarn\b", r"\bdeprecated\b", r"⚠️"]
# Info patterns
info_patterns = [r"\binfo\b", r"\bnotice\b", r"ℹ️"]
for pattern in error_patterns:
if re.search(pattern, line_lower):
return "error"
for pattern in warning_patterns:
if re.search(pattern, line_lower):
return "warning"
for pattern in info_patterns:
if re.search(pattern, line_lower):
return "info"
return "debug"
def deduplicate_message(self, line: str) -> bool:
"""
Check if message is duplicate.
Args:
line: Log line
Returns:
True if this is a new message, False if duplicate
"""
# Create signature by removing timestamps and process IDs
signature = re.sub(r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}", "", line)
signature = re.sub(r"\[\d+\]", "", signature)
signature = re.sub(r"\s+", " ", signature).strip()
if signature in self.seen_messages:
return False
self.seen_messages.add(signature)
return True
def process_log_line(self, line: str):
"""
Process a single log line.
Args:
line: Log line to process
"""
if not line.strip():
return
self.total_lines += 1
self.log_lines.append(line)
# Classify severity
severity = self.classify_log_line(line)
# Skip if not in filter
if severity not in self.severity_filter:
return
# Deduplicate (for errors and warnings)
if severity in ["error", "warning"] and not self.deduplicate_message(line):
return
# Store by severity
if severity == "error":
self.error_count += 1
self.errors.append(line)
elif severity == "warning":
self.warning_count += 1
self.warnings.append(line)
elif severity == "info":
self.info_count += 1
if len(self.info_messages) < 20: # Keep only recent info
self.info_messages.append(line)
else: # debug
self.debug_count += 1
def stream_logs(
self,
follow: bool = False,
duration: float | None = None,
last_minutes: float | None = None,
) -> bool:
"""
Stream logs from simulator.
Args:
follow: Follow mode (continuous streaming)
duration: Capture duration in seconds
last_minutes: Show logs from last N minutes
Returns:
True if successful
"""
# Build log stream command
cmd = ["xcrun", "simctl", "spawn", self.device_udid, "log", "stream"]
# Add filters
if self.app_bundle_id:
# Filter by process name (extracted from bundle ID)
app_name = self.app_bundle_id.split(".")[-1]
cmd.extend(["--predicate", f'processImagePath CONTAINS "{app_name}"'])
# Add time filter for historical logs
if last_minutes:
start_time = datetime.now() - timedelta(minutes=last_minutes)
time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
cmd.extend(["--start", time_str])
# Setup signal handler for graceful interruption
def signal_handler(sig, frame):
self.interrupted = True
if self.log_process:
self.log_process.terminate()
signal.signal(signal.SIGINT, signal_handler)
try:
# Start log streaming process
self.log_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1, # Line buffered
)
# Track start time for duration
start_time = datetime.now()
# Process log lines
for line in iter(self.log_process.stdout.readline, ""):
if not line:
break
# Process the line
self.process_log_line(line.rstrip())
# Print in follow mode
if follow:
severity = self.classify_log_line(line)
if severity in self.severity_filter:
print(line.rstrip())
# Check duration
if duration and (datetime.now() - start_time).total_seconds() >= duration:
break
# Check if interrupted
if self.interrupted:
break
# Wait for process to finish
self.log_process.wait()
return True
except Exception as e:
print(f"Error streaming logs: {e}", file=sys.stderr)
return False
finally:
if self.log_process:
self.log_process.terminate()
def get_summary(self, verbose: bool = False) -> str:
"""
Get log summary.
Args:
verbose: Include full log details
Returns:
Formatted summary string
"""
lines = []
# Header
if self.app_bundle_id:
lines.append(f"Logs for: {self.app_bundle_id}")
else:
lines.append("Logs for: All processes")
# Statistics
lines.append(f"Total lines: {self.total_lines}")
lines.append(
f"Errors: {self.error_count}, Warnings: {self.warning_count}, Info: {self.info_count}"
)
# Top issues
if self.errors:
lines.append(f"\nTop Errors ({len(self.errors)}):")
for error in self.errors[:5]: # Show first 5
lines.append(f" ❌ {error[:120]}") # Truncate long lines
if self.warnings:
lines.append(f"\nTop Warnings ({len(self.warnings)}):")
for warning in self.warnings[:5]: # Show first 5
lines.append(f" ⚠️ {warning[:120]}")
# Verbose output
if verbose and self.log_lines:
lines.append("\n=== Recent Log Lines ===")
for line in self.log_lines[-50:]: # Last 50 lines
lines.append(line)
return "\n".join(lines)
def get_json_output(self) -> dict:
"""Get log results as JSON."""
return {
"app_bundle_id": self.app_bundle_id,
"device_udid": self.device_udid,
"statistics": {
"total_lines": self.total_lines,
"errors": self.error_count,
"warnings": self.warning_count,
"info": self.info_count,
"debug": self.debug_count,
},
"errors": self.errors[:20], # Limit to 20
"warnings": self.warnings[:20],
"sample_logs": self.log_lines[-50:], # Last 50 lines
}
def save_logs(self, output_dir: str) -> str:
"""
Save logs to file.
Args:
output_dir: Directory to save logs
Returns:
Path to saved log file
"""
# Create output directory
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
app_name = self.app_bundle_id.split(".")[-1] if self.app_bundle_id else "simulator"
log_file = output_path / f"{app_name}-{timestamp}.log"
# Write all log lines
with open(log_file, "w") as f:
f.write("\n".join(self.log_lines))
# Also save JSON summary
json_file = output_path / f"{app_name}-{timestamp}-summary.json"
with open(json_file, "w") as f:
json.dump(self.get_json_output(), f, indent=2)
return str(log_file)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Monitor and analyze iOS simulator logs",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Monitor app in real-time
python scripts/log_monitor.py --app com.myapp.MyApp --follow
# Capture logs for 30 seconds
python scripts/log_monitor.py --app com.myapp.MyApp --duration 30s
# Show errors/warnings from last 5 minutes
python scripts/log_monitor.py --severity error,warning --last 5m
# Save logs to file
python scripts/log_monitor.py --app com.myapp.MyApp --duration 1m --output logs/
""",
)
# Filtering options
parser.add_argument(
"--app", dest="app_bundle_id", help="App bundle ID to filter logs (e.g., com.myapp.MyApp)"
)
parser.add_argument("--device-udid", help="Device UDID (uses booted if not specified)")
parser.add_argument(
"--severity", help="Comma-separated severity levels (error,warning,info,debug)"
)
# Time options
time_group = parser.add_mutually_exclusive_group()
time_group.add_argument(
"--follow", action="store_true", help="Follow mode (continuous streaming)"
)
time_group.add_argument("--duration", help="Capture duration (e.g., 30s, 5m, 1h)")
time_group.add_argument(
"--last", dest="last_minutes", help="Show logs from last N minutes (e.g., 5m)"
)
# Output options
parser.add_argument("--output", help="Save logs to directory")
parser.add_argument("--verbose", action="store_true", help="Show detailed output")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
# Parse severity filter
severity_filter = None
if args.severity:
severity_filter = [s.strip().lower() for s in args.severity.split(",")]
# Initialize monitor
monitor = LogMonitor(
app_bundle_id=args.app_bundle_id,
device_udid=args.device_udid,
severity_filter=severity_filter,
)
# Parse duration
duration = None
if args.duration:
duration = monitor.parse_time_duration(args.duration)
# Parse last minutes
last_minutes = None
if args.last_minutes:
last_minutes = monitor.parse_time_duration(args.last_minutes) / 60
# Stream logs
print("Monitoring logs...", file=sys.stderr)
if args.app_bundle_id:
print(f"App: {args.app_bundle_id}", file=sys.stderr)
success = monitor.stream_logs(follow=args.follow, duration=duration, last_minutes=last_minutes)
if not success:
sys.exit(1)
# Save logs if requested
if args.output:
log_file = monitor.save_logs(args.output)
print(f"\nLogs saved to: {log_file}", file=sys.stderr)
# Output results
if not args.follow: # Don't show summary in follow mode
if args.json:
print(json.dumps(monitor.get_json_output(), indent=2))
else:
print("\n" + monitor.get_summary(verbose=args.verbose))
sys.exit(0)
if __name__ == "__main__":
main()
+453
View File
@@ -0,0 +1,453 @@
#!/usr/bin/env python3
"""
iOS Simulator Navigator - Smart Element Finder and Interactor
Finds and interacts with UI elements using accessibility data.
Prioritizes structured navigation over pixel-based interaction.
This script is the core automation tool for iOS simulator navigation. It finds
UI elements by text, type, or accessibility ID and performs actions on them
(tap, enter text). Uses semantic element finding instead of fragile pixel coordinates.
Key Features:
- Find elements by text (fuzzy or exact matching)
- Find elements by type (Button, TextField, etc.)
- Find elements by accessibility identifier
- Tap elements at their center point
- Enter text into text fields
- List all tappable elements on screen
- Automatic element caching for performance
Usage Examples:
# Find and tap a button by text
python scripts/navigator.py --find-text "Login" --tap --udid <device-id>
# Enter text into first text field
python scripts/navigator.py --find-type TextField --index 0 --enter-text "username" --udid <device-id>
# Tap element by accessibility ID
python scripts/navigator.py --find-id "submitButton" --tap --udid <device-id>
# List all interactive elements
python scripts/navigator.py --list --udid <device-id>
# Tap at specific coordinates (fallback)
python scripts/navigator.py --tap-at 200,400 --udid <device-id>
Output Format:
Tapped: Button "Login" at (320, 450)
Entered text in: TextField "Username"
Not found: text='Submit'
Navigation Priority (best to worst):
1. Find by accessibility label/text (most reliable)
2. Find by element type + index (good for forms)
3. Find by accessibility ID (precise but app-specific)
4. Tap at coordinates (last resort, fragile)
Technical Details:
- Uses IDB's accessibility tree via `idb ui describe-all --json --nested`
- Caches tree for multiple operations (call with force_refresh to update)
- Finds elements by parsing tree recursively
- Calculates tap coordinates from element frame center
- Uses `idb ui tap` for tapping, `idb ui text` for text entry
- Extracts data from AXLabel, AXValue, and AXUniqueId fields
"""
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
from common import (
flatten_tree,
get_accessibility_tree,
get_device_screen_size,
resolve_udid,
transform_screenshot_coords,
)
@dataclass
class Element:
"""Represents a UI element from accessibility tree."""
type: str
label: str | None
value: str | None
identifier: str | None
frame: dict[str, float]
traits: list[str]
enabled: bool = True
@property
def center(self) -> tuple[int, int]:
"""Calculate center point for tapping."""
x = int(self.frame["x"] + self.frame["width"] / 2)
y = int(self.frame["y"] + self.frame["height"] / 2)
return (x, y)
@property
def description(self) -> str:
"""Human-readable description."""
label = self.label or self.value or self.identifier or "Unnamed"
return f'{self.type} "{label}"'
class Navigator:
"""Navigates iOS apps using accessibility data."""
def __init__(self, udid: str | None = None):
"""Initialize navigator with optional device UDID."""
self.udid = udid
self._tree_cache = None
def get_accessibility_tree(self, force_refresh: bool = False) -> dict:
"""Get accessibility tree (cached for efficiency)."""
if self._tree_cache and not force_refresh:
return self._tree_cache
# Delegate to shared utility
self._tree_cache = get_accessibility_tree(self.udid, nested=True)
return self._tree_cache
def _flatten_tree(self, node: dict, elements: list[Element] | None = None) -> list[Element]:
"""Flatten accessibility tree into list of elements."""
if elements is None:
elements = []
# Create element from node
if node.get("type"):
element = Element(
type=node.get("type", "Unknown"),
label=node.get("AXLabel"),
value=node.get("AXValue"),
identifier=node.get("AXUniqueId"),
frame=node.get("frame", {}),
traits=node.get("traits", []),
enabled=node.get("enabled", True),
)
elements.append(element)
# Process children
for child in node.get("children", []):
self._flatten_tree(child, elements)
return elements
def find_element(
self,
text: str | None = None,
element_type: str | None = None,
identifier: str | None = None,
index: int = 0,
fuzzy: bool = True,
) -> Element | None:
"""
Find element by various criteria.
Args:
text: Text to search in label/value
element_type: Type of element (Button, TextField, etc.)
identifier: Accessibility identifier
index: Which matching element to return (0-based)
fuzzy: Use fuzzy matching for text
Returns:
Element if found, None otherwise
"""
tree = self.get_accessibility_tree()
elements = self._flatten_tree(tree)
matches = []
for elem in elements:
# Skip disabled elements
if not elem.enabled:
continue
# Check type
if element_type and elem.type != element_type:
continue
# Check identifier (exact match)
if identifier and elem.identifier != identifier:
continue
# Check text (in label or value)
if text:
elem_text = (elem.label or "") + " " + (elem.value or "")
if fuzzy:
if text.lower() not in elem_text.lower():
continue
elif text not in (elem.label, elem.value):
continue
matches.append(elem)
if matches and index < len(matches):
return matches[index]
return None
def tap(self, element: Element) -> bool:
"""Tap on an element."""
x, y = element.center
return self.tap_at(x, y)
def tap_at(self, x: int, y: int) -> bool:
"""Tap at specific coordinates."""
cmd = ["idb", "ui", "tap", str(x), str(y)]
if self.udid:
cmd.extend(["--udid", self.udid])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def enter_text(self, text: str, element: Element | None = None) -> bool:
"""
Enter text into element or current focus.
Args:
text: Text to enter
element: Optional element to tap first
Returns:
Success status
"""
# Tap element if provided
if element:
if not self.tap(element):
return False
# Small delay for focus
import time
time.sleep(0.5)
# Enter text
cmd = ["idb", "ui", "text", text]
if self.udid:
cmd.extend(["--udid", self.udid])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def find_and_tap(
self,
text: str | None = None,
element_type: str | None = None,
identifier: str | None = None,
index: int = 0,
) -> tuple[bool, str]:
"""
Find element and tap it.
Returns:
(success, message) tuple
"""
element = self.find_element(text, element_type, identifier, index)
if not element:
criteria = []
if text:
criteria.append(f"text='{text}'")
if element_type:
criteria.append(f"type={element_type}")
if identifier:
criteria.append(f"id={identifier}")
return (False, f"Not found: {', '.join(criteria)}")
if self.tap(element):
return (True, f"Tapped: {element.description} at {element.center}")
return (False, f"Failed to tap: {element.description}")
def find_and_enter_text(
self,
text_to_enter: str,
find_text: str | None = None,
element_type: str | None = "TextField",
identifier: str | None = None,
index: int = 0,
) -> tuple[bool, str]:
"""
Find element and enter text into it.
Returns:
(success, message) tuple
"""
element = self.find_element(find_text, element_type, identifier, index)
if not element:
return (False, "TextField not found")
if self.enter_text(text_to_enter, element):
return (True, f"Entered text in: {element.description}")
return (False, "Failed to enter text")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Navigate iOS apps using accessibility data")
# Finding options
parser.add_argument("--find-text", help="Find element by text (fuzzy match)")
parser.add_argument("--find-exact", help="Find element by exact text")
parser.add_argument("--find-type", help="Element type (Button, TextField, etc.)")
parser.add_argument("--find-id", help="Accessibility identifier")
parser.add_argument("--index", type=int, default=0, help="Which match to use (0-based)")
# Action options
parser.add_argument("--tap", action="store_true", help="Tap the found element")
parser.add_argument("--tap-at", help="Tap at coordinates (x,y)")
parser.add_argument("--enter-text", help="Enter text into element")
# Coordinate transformation
parser.add_argument(
"--screenshot-coords",
action="store_true",
help="Interpret tap coordinates as from a screenshot (requires --screenshot-width/height)",
)
parser.add_argument(
"--screenshot-width",
type=int,
help="Screenshot width for coordinate transformation",
)
parser.add_argument(
"--screenshot-height",
type=int,
help="Screenshot height for coordinate transformation",
)
# Other options
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument("--list", action="store_true", help="List all tappable elements")
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
navigator = Navigator(udid=udid)
# List mode
if args.list:
tree = navigator.get_accessibility_tree()
elements = navigator._flatten_tree(tree)
# Filter to tappable elements
tappable = [
e
for e in elements
if e.enabled and e.type in ["Button", "Link", "Cell", "TextField", "SecureTextField"]
]
print(f"Tappable elements ({len(tappable)}):")
for elem in tappable[:10]: # Limit output for tokens
print(f" {elem.type}: \"{elem.label or elem.value or 'Unnamed'}\" {elem.center}")
if len(tappable) > 10:
print(f" ... and {len(tappable) - 10} more")
sys.exit(0)
# Direct tap at coordinates
if args.tap_at:
coords = args.tap_at.split(",")
if len(coords) != 2:
print("Error: --tap-at requires x,y format")
sys.exit(1)
x, y = int(coords[0]), int(coords[1])
# Handle coordinate transformation if requested
if args.screenshot_coords:
if not args.screenshot_width or not args.screenshot_height:
print(
"Error: --screenshot-coords requires --screenshot-width and --screenshot-height"
)
sys.exit(1)
device_w, device_h = get_device_screen_size(udid)
x, y = transform_screenshot_coords(
x,
y,
args.screenshot_width,
args.screenshot_height,
device_w,
device_h,
)
print(
f"Transformed screenshot coords ({coords[0]}, {coords[1]}) "
f"to device coords ({x}, {y})"
)
if navigator.tap_at(x, y):
print(f"Tapped at ({x}, {y})")
else:
print(f"Failed to tap at ({x}, {y})")
sys.exit(1)
# Find and tap
elif args.tap:
text = args.find_text or args.find_exact
fuzzy = args.find_text is not None
success, message = navigator.find_and_tap(
text=text, element_type=args.find_type, identifier=args.find_id, index=args.index
)
print(message)
if not success:
sys.exit(1)
# Find and enter text
elif args.enter_text:
text = args.find_text or args.find_exact
success, message = navigator.find_and_enter_text(
text_to_enter=args.enter_text,
find_text=text,
element_type=args.find_type or "TextField",
identifier=args.find_id,
index=args.index,
)
print(message)
if not success:
sys.exit(1)
# Just find (no action)
else:
text = args.find_text or args.find_exact
fuzzy = args.find_text is not None
element = navigator.find_element(
text=text,
element_type=args.find_type,
identifier=args.find_id,
index=args.index,
fuzzy=fuzzy,
)
if element:
print(f"Found: {element.description} at {element.center}")
else:
print("Element not found")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
iOS Privacy & Permissions Manager
Grant/revoke app permissions for testing permission flows.
Supports 13+ services with audit trail tracking.
Usage: python scripts/privacy_manager.py --grant camera --bundle-id com.app
"""
import argparse
import subprocess
import sys
from datetime import datetime
from common import resolve_udid
class PrivacyManager:
"""Manages iOS app privacy and permissions."""
# Supported services
SUPPORTED_SERVICES = {
"camera": "Camera access",
"microphone": "Microphone access",
"location": "Location services",
"contacts": "Contacts access",
"photos": "Photos library access",
"calendar": "Calendar access",
"health": "Health data access",
"reminders": "Reminders access",
"motion": "Motion & fitness",
"keyboard": "Keyboard access",
"mediaLibrary": "Media library",
"calls": "Call history",
"siri": "Siri access",
}
def __init__(self, udid: str | None = None):
"""Initialize privacy manager.
Args:
udid: Optional device UDID (auto-detects booted simulator if None)
"""
self.udid = udid
def grant_permission(
self,
bundle_id: str,
service: str,
scenario: str | None = None,
step: int | None = None,
) -> bool:
"""
Grant permission for app.
Args:
bundle_id: App bundle ID
service: Service name (camera, microphone, location, etc.)
scenario: Test scenario name for audit trail
step: Step number in test scenario
Returns:
Success status
"""
if service not in self.SUPPORTED_SERVICES:
print(f"Error: Unknown service '{service}'")
print(f"Supported: {', '.join(self.SUPPORTED_SERVICES.keys())}")
return False
cmd = ["xcrun", "simctl", "privacy"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(["grant", service, bundle_id])
try:
subprocess.run(cmd, capture_output=True, check=True)
# Log audit entry
self._log_audit("grant", bundle_id, service, scenario, step)
return True
except subprocess.CalledProcessError:
return False
def revoke_permission(
self,
bundle_id: str,
service: str,
scenario: str | None = None,
step: int | None = None,
) -> bool:
"""
Revoke permission for app.
Args:
bundle_id: App bundle ID
service: Service name
scenario: Test scenario name for audit trail
step: Step number in test scenario
Returns:
Success status
"""
if service not in self.SUPPORTED_SERVICES:
print(f"Error: Unknown service '{service}'")
return False
cmd = ["xcrun", "simctl", "privacy"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(["revoke", service, bundle_id])
try:
subprocess.run(cmd, capture_output=True, check=True)
# Log audit entry
self._log_audit("revoke", bundle_id, service, scenario, step)
return True
except subprocess.CalledProcessError:
return False
def reset_permission(
self,
bundle_id: str,
service: str,
scenario: str | None = None,
step: int | None = None,
) -> bool:
"""
Reset permission to default.
Args:
bundle_id: App bundle ID
service: Service name
scenario: Test scenario name for audit trail
step: Step number in test scenario
Returns:
Success status
"""
if service not in self.SUPPORTED_SERVICES:
print(f"Error: Unknown service '{service}'")
return False
cmd = ["xcrun", "simctl", "privacy"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(["reset", service, bundle_id])
try:
subprocess.run(cmd, capture_output=True, check=True)
# Log audit entry
self._log_audit("reset", bundle_id, service, scenario, step)
return True
except subprocess.CalledProcessError:
return False
@staticmethod
def _log_audit(
action: str,
bundle_id: str,
service: str,
scenario: str | None = None,
step: int | None = None,
) -> None:
"""Log permission change to audit trail (for test tracking).
Args:
action: grant, revoke, or reset
bundle_id: App bundle ID
service: Service name
scenario: Test scenario name
step: Step number
"""
# Could write to file, but for now just log to stdout for transparency
timestamp = datetime.now().isoformat()
location = f" (step {step})" if step else ""
scenario_info = f" in {scenario}" if scenario else ""
print(
f"[Audit] {timestamp}: {action.upper()} {service} for {bundle_id}{scenario_info}{location}"
)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Manage iOS app privacy and permissions")
# Required
parser.add_argument("--bundle-id", required=True, help="App bundle ID (e.g., com.example.app)")
# Action (mutually exclusive)
action_group = parser.add_mutually_exclusive_group(required=True)
action_group.add_argument(
"--grant",
help="Grant permission (service name or comma-separated list)",
)
action_group.add_argument(
"--revoke", help="Revoke permission (service name or comma-separated list)"
)
action_group.add_argument(
"--reset",
help="Reset permission to default (service name or comma-separated list)",
)
action_group.add_argument(
"--list",
action="store_true",
help="List all supported services",
)
# Test tracking
parser.add_argument(
"--scenario",
help="Test scenario name for audit trail",
)
parser.add_argument("--step", type=int, help="Step number in test scenario")
# Device
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
args = parser.parse_args()
# List supported services
if args.list:
print("Supported Privacy Services:\n")
for service, description in PrivacyManager.SUPPORTED_SERVICES.items():
print(f" {service:<15} - {description}")
print()
print("Examples:")
print(" python scripts/privacy_manager.py --grant camera --bundle-id com.app")
print(" python scripts/privacy_manager.py --revoke location --bundle-id com.app")
print(" python scripts/privacy_manager.py --grant camera,photos --bundle-id com.app")
sys.exit(0)
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
manager = PrivacyManager(udid=udid)
# Parse service names (support comma-separated list)
if args.grant:
services = [s.strip() for s in args.grant.split(",")]
action = "grant"
action_fn = manager.grant_permission
elif args.revoke:
services = [s.strip() for s in args.revoke.split(",")]
action = "revoke"
action_fn = manager.revoke_permission
else: # reset
services = [s.strip() for s in args.reset.split(",")]
action = "reset"
action_fn = manager.reset_permission
# Execute action for each service
all_success = True
for service in services:
if service not in PrivacyManager.SUPPORTED_SERVICES:
print(f"Error: Unknown service '{service}'")
all_success = False
continue
success = action_fn(
args.bundle_id,
service,
scenario=args.scenario,
step=args.step,
)
if success:
description = PrivacyManager.SUPPORTED_SERVICES[service]
print(f"✓ {action.capitalize()} {service}: {description}")
else:
print(f"✗ Failed to {action} {service}")
all_success = False
if not all_success:
sys.exit(1)
# Summary
if len(services) > 1:
print(f"\nPermissions {action}ed: {', '.join(services)}")
if args.scenario:
print(f"Test scenario: {args.scenario}" + (f" (step {args.step})" if args.step else ""))
if __name__ == "__main__":
main()
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
iOS Push Notification Simulator
Send simulated push notifications to test notification handling.
Supports custom payloads and test tracking.
Usage: python scripts/push_notification.py --bundle-id com.app --title "Alert" --body "Message"
"""
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from common import resolve_udid
class PushNotificationSender:
"""Sends simulated push notifications to iOS simulator."""
def __init__(self, udid: str | None = None):
"""Initialize push notification sender.
Args:
udid: Optional device UDID (auto-detects booted simulator if None)
"""
self.udid = udid
def send(
self,
bundle_id: str,
payload: dict | str,
_test_name: str | None = None,
_expected_behavior: str | None = None,
) -> bool:
"""
Send push notification to app.
Args:
bundle_id: Target app bundle ID
payload: Push payload (dict or JSON string) or path to JSON file
test_name: Test scenario name for tracking
expected_behavior: Expected behavior after notification arrives
Returns:
Success status
"""
# Handle different payload formats
if isinstance(payload, str):
# Check if it's a file path
payload_path = Path(payload)
if payload_path.exists():
with open(payload_path) as f:
payload_data = json.load(f)
else:
# Try to parse as JSON string
try:
payload_data = json.loads(payload)
except json.JSONDecodeError:
print(f"Error: Invalid JSON payload: {payload}")
return False
else:
payload_data = payload
# Ensure payload has aps dictionary
if "aps" not in payload_data:
payload_data = {"aps": payload_data}
# Create temp file with payload
try:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(payload_data, f)
temp_payload_path = f.name
# Build simctl command
cmd = ["xcrun", "simctl", "push"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend([bundle_id, temp_payload_path])
# Send notification
subprocess.run(cmd, capture_output=True, text=True, check=True)
# Clean up temp file
Path(temp_payload_path).unlink()
return True
except subprocess.CalledProcessError as e:
print(f"Error sending push notification: {e.stderr}")
return False
except Exception as e:
print(f"Error: {e}")
return False
def send_simple(
self,
bundle_id: str,
title: str | None = None,
body: str | None = None,
badge: int | None = None,
sound: bool = True,
) -> bool:
"""
Send simple push notification with common parameters.
Args:
bundle_id: Target app bundle ID
title: Alert title
body: Alert body
badge: Badge number
sound: Whether to play sound
Returns:
Success status
"""
payload = {}
if title or body:
alert = {}
if title:
alert["title"] = title
if body:
alert["body"] = body
payload["alert"] = alert
if badge is not None:
payload["badge"] = badge
if sound:
payload["sound"] = "default"
# Wrap in aps
full_payload = {"aps": payload}
return self.send(bundle_id, full_payload)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Send simulated push notification to iOS app")
# Required
parser.add_argument(
"--bundle-id", required=True, help="Target app bundle ID (e.g., com.example.app)"
)
# Simple payload options
parser.add_argument("--title", help="Alert title (for simple notifications)")
parser.add_argument("--body", help="Alert body message")
parser.add_argument("--badge", type=int, help="Badge number")
parser.add_argument("--no-sound", action="store_true", help="Don't play notification sound")
# Custom payload
parser.add_argument(
"--payload",
help="Custom JSON payload file or inline JSON string",
)
# Test tracking
parser.add_argument("--test-name", help="Test scenario name for tracking")
parser.add_argument(
"--expected",
help="Expected behavior after notification",
)
# Device
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
sender = PushNotificationSender(udid=udid)
# Send notification
if args.payload:
# Custom payload mode
success = sender.send(args.bundle_id, args.payload)
else:
# Simple notification mode
success = sender.send_simple(
args.bundle_id,
title=args.title,
body=args.body,
badge=args.badge,
sound=not args.no_sound,
)
if success:
# Token-efficient output
output = "Push notification sent"
if args.test_name:
output += f" (test: {args.test_name})"
print(output)
if args.expected:
print(f"Expected: {args.expected}")
print()
print("Notification details:")
if args.title:
print(f" Title: {args.title}")
if args.body:
print(f" Body: {args.body}")
if args.badge:
print(f" Badge: {args.badge}")
print()
print("Verify notification handling:")
print("1. Check app log output: python scripts/log_monitor.py --app " + args.bundle_id)
print(
"2. Capture state: python scripts/app_state_capture.py --app-bundle-id "
+ args.bundle_id
)
else:
print("Failed to send push notification")
sys.exit(1)
if __name__ == "__main__":
main()
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
iOS Screen Mapper - Current Screen Analyzer
Maps the current screen's UI elements for navigation decisions.
Provides token-efficient summaries of available interactions.
This script analyzes the iOS simulator screen using IDB's accessibility tree
and provides a compact, actionable summary of what's currently visible and
interactive on the screen. Perfect for AI agents making navigation decisions.
Key Features:
- Token-efficient output (5-7 lines by default)
- Identifies buttons, text fields, navigation elements
- Counts interactive and focusable elements
- Progressive detail with --verbose flag
- Navigation hints with --hints flag
Usage Examples:
# Quick summary (default)
python scripts/screen_mapper.py --udid <device-id>
# Detailed element breakdown
python scripts/screen_mapper.py --udid <device-id> --verbose
# Include navigation suggestions
python scripts/screen_mapper.py --udid <device-id> --hints
# Full JSON output for parsing
python scripts/screen_mapper.py --udid <device-id> --json
Output Format (default):
Screen: LoginViewController (45 elements, 7 interactive)
Buttons: "Login", "Cancel", "Forgot Password"
TextFields: 2 (0 filled)
Navigation: NavBar: "Sign In"
Focusable: 7 elements
Technical Details:
- Uses IDB's accessibility tree via `idb ui describe-all --json --nested`
- Parses IDB's array format: [{ root element with children }]
- Identifies element types: Button, TextField, NavigationBar, TabBar, etc.
- Extracts labels from AXLabel, AXValue, and AXUniqueId fields
"""
import argparse
import json
import subprocess
import sys
from collections import defaultdict
from common import get_accessibility_tree, resolve_udid
class ScreenMapper:
"""
Analyzes current screen for navigation decisions.
This class fetches the iOS accessibility tree from IDB and analyzes it
to provide actionable summaries for navigation. It categorizes elements
by type, counts interactive elements, and identifies key UI patterns.
Attributes:
udid (Optional[str]): Device UDID to target, or None for booted device
INTERACTIVE_TYPES (Set[str]): Element types that users can interact with
Design Philosophy:
- Token efficiency: Provide minimal but complete information
- Progressive disclosure: Summary by default, details on request
- Navigation-focused: Highlight elements relevant for automation
"""
# Element types we care about for navigation
# These are the accessibility element types that indicate user interaction points
INTERACTIVE_TYPES = {
"Button",
"Link",
"TextField",
"SecureTextField",
"Cell",
"Switch",
"Slider",
"Stepper",
"SegmentedControl",
"TabBar",
"NavigationBar",
"Toolbar",
}
def __init__(self, udid: str | None = None):
"""
Initialize screen mapper.
Args:
udid: Optional device UDID. If None, uses booted simulator.
Example:
mapper = ScreenMapper(udid="656DC652-1C9F-4AB2-AD4F-F38E65976BDA")
mapper = ScreenMapper() # Uses booted device
"""
self.udid = udid
def get_accessibility_tree(self) -> dict:
"""
Fetch accessibility tree from iOS simulator via IDB.
Delegates to shared utility for consistent tree fetching across all scripts.
"""
return get_accessibility_tree(self.udid, nested=True)
def analyze_tree(self, node: dict, depth: int = 0) -> dict:
"""Analyze accessibility tree for navigation info."""
analysis = {
"elements_by_type": defaultdict(list),
"total_elements": 0,
"interactive_elements": 0,
"text_fields": [],
"buttons": [],
"navigation": {},
"screen_name": None,
"focusable": 0,
}
self._analyze_recursive(node, analysis, depth)
# Post-process for clean output
analysis["elements_by_type"] = dict(analysis["elements_by_type"])
return analysis
def _analyze_recursive(self, node: dict, analysis: dict, depth: int):
"""Recursively analyze tree nodes."""
elem_type = node.get("type")
label = node.get("AXLabel", "")
value = node.get("AXValue", "")
identifier = node.get("AXUniqueId", "")
# Count element
if elem_type:
analysis["total_elements"] += 1
# Track by type
if elem_type in self.INTERACTIVE_TYPES:
analysis["interactive_elements"] += 1
# Store concise info (label only, not full node)
elem_info = label or value or identifier or "Unnamed"
analysis["elements_by_type"][elem_type].append(elem_info)
# Special handling for common types
if elem_type == "Button":
analysis["buttons"].append(elem_info)
elif elem_type in ("TextField", "SecureTextField"):
analysis["text_fields"].append(
{"type": elem_type, "label": elem_info, "has_value": bool(value)}
)
elif elem_type == "NavigationBar":
analysis["navigation"]["nav_title"] = label or "Navigation"
elif elem_type == "TabBar":
# Count tab items
tab_count = len(node.get("children", []))
analysis["navigation"]["tab_count"] = tab_count
# Track focusable elements
if node.get("enabled", False) and elem_type in self.INTERACTIVE_TYPES:
analysis["focusable"] += 1
# Try to identify screen name from view controller
if not analysis["screen_name"] and identifier:
if "ViewController" in identifier or "Screen" in identifier:
analysis["screen_name"] = identifier
# Process children
for child in node.get("children", []):
self._analyze_recursive(child, analysis, depth + 1)
def format_summary(self, analysis: dict, verbose: bool = False) -> str:
"""Format analysis as token-efficient summary."""
lines = []
# Screen identification (1 line)
screen = analysis["screen_name"] or "Unknown Screen"
total = analysis["total_elements"]
interactive = analysis["interactive_elements"]
lines.append(f"Screen: {screen} ({total} elements, {interactive} interactive)")
# Buttons summary (1 line)
if analysis["buttons"]:
button_list = ", ".join(f'"{b}"' for b in analysis["buttons"][:5])
if len(analysis["buttons"]) > 5:
button_list += f" +{len(analysis['buttons']) - 5} more"
lines.append(f"Buttons: {button_list}")
# Text fields summary (1 line)
if analysis["text_fields"]:
field_count = len(analysis["text_fields"])
[f["type"] for f in analysis["text_fields"]]
filled = sum(1 for f in analysis["text_fields"] if f["has_value"])
lines.append(f"TextFields: {field_count} ({filled} filled)")
# Navigation summary (1 line)
nav_parts = []
if "nav_title" in analysis["navigation"]:
nav_parts.append(f"NavBar: \"{analysis['navigation']['nav_title']}\"")
if "tab_count" in analysis["navigation"]:
nav_parts.append(f"TabBar: {analysis['navigation']['tab_count']} tabs")
if nav_parts:
lines.append(f"Navigation: {', '.join(nav_parts)}")
# Focusable count (1 line)
lines.append(f"Focusable: {analysis['focusable']} elements")
# Verbose mode adds element type breakdown
if verbose:
lines.append("\nElements by type:")
for elem_type, items in analysis["elements_by_type"].items():
if items: # Only show types that exist
lines.append(f" {elem_type}: {len(items)}")
for item in items[:3]: # Show first 3
lines.append(f" - {item}")
if len(items) > 3:
lines.append(f" ... +{len(items) - 3} more")
return "\n".join(lines)
def get_navigation_hints(self, analysis: dict) -> list[str]:
"""Generate navigation hints based on screen analysis."""
hints = []
# Check for common patterns
if "Login" in str(analysis.get("buttons", [])):
hints.append("Login screen detected - find TextFields for credentials")
if analysis["text_fields"]:
unfilled = [f for f in analysis["text_fields"] if not f["has_value"]]
if unfilled:
hints.append(f"{len(unfilled)} empty text field(s) - may need input")
if not analysis["buttons"] and not analysis["text_fields"]:
hints.append("No interactive elements - try swiping or going back")
if "tab_count" in analysis.get("navigation", {}):
hints.append(f"Tab bar available with {analysis['navigation']['tab_count']} tabs")
return hints
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Map current screen UI elements")
parser.add_argument("--verbose", action="store_true", help="Show detailed element breakdown")
parser.add_argument("--json", action="store_true", help="Output raw JSON analysis")
parser.add_argument("--hints", action="store_true", help="Include navigation hints")
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
# Create mapper and analyze
mapper = ScreenMapper(udid=udid)
tree = mapper.get_accessibility_tree()
analysis = mapper.analyze_tree(tree)
# Output based on format
if args.json:
# Full JSON (verbose)
print(json.dumps(analysis, indent=2, default=str))
else:
# Token-efficient summary (default)
summary = mapper.format_summary(analysis, verbose=args.verbose)
print(summary)
# Add hints if requested
if args.hints:
hints = mapper.get_navigation_hints(analysis)
if hints:
print("\nHints:")
for hint in hints:
print(f" - {hint}")
if __name__ == "__main__":
main()
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
#
# iOS Simulator Testing Environment Health Check
#
# Verifies that all required tools and dependencies are properly installed
# and configured for iOS simulator testing.
#
# Usage: bash scripts/sim_health_check.sh [--help]
set -e
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check flags
SHOW_HELP=false
# Parse arguments
for arg in "$@"; do
case $arg in
--help|-h)
SHOW_HELP=true
shift
;;
esac
done
if [ "$SHOW_HELP" = true ]; then
cat <<EOF
iOS Simulator Testing - Environment Health Check
Verifies that your environment is properly configured for iOS simulator testing.
Usage: bash scripts/sim_health_check.sh [options]
Options:
--help, -h Show this help message
This script checks for:
- Xcode Command Line Tools installation
- iOS Simulator availability
- IDB (iOS Development Bridge) installation
- Available simulator devices
- Python 3 installation (for scripts)
Exit codes:
0 - All checks passed
1 - One or more checks failed (see output for details)
EOF
exit 0
fi
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} iOS Simulator Testing - Environment Health Check${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
CHECKS_PASSED=0
CHECKS_FAILED=0
# Function to print check status
check_passed() {
echo -e "${GREEN}✓${NC} $1"
((CHECKS_PASSED++))
}
check_failed() {
echo -e "${RED}✗${NC} $1"
((CHECKS_FAILED++))
}
check_warning() {
echo -e "${YELLOW}⚠${NC} $1"
}
# Check 1: macOS
echo -e "${BLUE}[1/8]${NC} Checking operating system..."
if [[ "$OSTYPE" == "darwin"* ]]; then
OS_VERSION=$(sw_vers -productVersion)
check_passed "macOS detected (version $OS_VERSION)"
else
check_failed "Not running on macOS (detected: $OSTYPE)"
echo " iOS Simulator testing requires macOS"
fi
echo ""
# Check 2: Xcode Command Line Tools
echo -e "${BLUE}[2/8]${NC} Checking Xcode Command Line Tools..."
if command -v xcrun &> /dev/null; then
XCODE_PATH=$(xcode-select -p 2>/dev/null || echo "not found")
if [ "$XCODE_PATH" != "not found" ]; then
XCODE_VERSION=$(xcodebuild -version 2>/dev/null | head -n 1 || echo "Unknown")
check_passed "Xcode Command Line Tools installed"
echo " Path: $XCODE_PATH"
echo " Version: $XCODE_VERSION"
else
check_failed "Xcode Command Line Tools path not set"
echo " Run: xcode-select --install"
fi
else
check_failed "xcrun command not found"
echo " Install Xcode Command Line Tools: xcode-select --install"
fi
echo ""
# Check 3: simctl availability
echo -e "${BLUE}[3/8]${NC} Checking simctl (Simulator Control)..."
if command -v xcrun &> /dev/null && xcrun simctl help &> /dev/null; then
check_passed "simctl is available"
else
check_failed "simctl not available"
echo " simctl comes with Xcode Command Line Tools"
fi
echo ""
# Check 4: IDB installation
echo -e "${BLUE}[4/8]${NC} Checking IDB (iOS Development Bridge)..."
if command -v idb &> /dev/null; then
IDB_PATH=$(which idb)
IDB_VERSION=$(idb --version 2>/dev/null || echo "Unknown")
check_passed "IDB is installed"
echo " Path: $IDB_PATH"
echo " Version: $IDB_VERSION"
else
check_warning "IDB not found in PATH"
echo " IDB is optional but provides advanced UI automation"
echo " Install: https://fbidb.io/docs/installation"
echo " Recommended: brew tap facebook/fb && brew install idb-companion"
fi
echo ""
# Check 5: Python 3 installation
echo -e "${BLUE}[5/8]${NC} Checking Python 3..."
if command -v python3 &> /dev/null; then
PYTHON_VERSION=$(python3 --version | cut -d' ' -f2)
check_passed "Python 3 is installed (version $PYTHON_VERSION)"
else
check_failed "Python 3 not found"
echo " Python 3 is required for testing scripts"
echo " Install: brew install python3"
fi
echo ""
# Check 6: Available simulators
echo -e "${BLUE}[6/8]${NC} Checking available iOS Simulators..."
if command -v xcrun &> /dev/null; then
SIMULATOR_COUNT=$(xcrun simctl list devices available 2>/dev/null | grep -c "iPhone\|iPad" || echo "0")
if [ "$SIMULATOR_COUNT" -gt 0 ]; then
check_passed "Found $SIMULATOR_COUNT available simulator(s)"
# Show first 5 simulators
echo ""
echo " Available simulators (showing up to 5):"
xcrun simctl list devices available 2>/dev/null | grep "iPhone\|iPad" | head -5 | while read -r line; do
echo " - $line"
done
else
check_warning "No simulators found"
echo " Create simulators via Xcode or simctl"
echo " Example: xcrun simctl create 'iPhone 15' 'iPhone 15'"
fi
else
check_failed "Cannot check simulators (simctl not available)"
fi
echo ""
# Check 7: Booted simulators
echo -e "${BLUE}[7/8]${NC} Checking booted simulators..."
if command -v xcrun &> /dev/null; then
BOOTED_SIMS=$(xcrun simctl list devices booted 2>/dev/null | grep -c "iPhone\|iPad" || echo "0")
if [ "$BOOTED_SIMS" -gt 0 ]; then
check_passed "$BOOTED_SIMS simulator(s) currently booted"
echo ""
echo " Booted simulators:"
xcrun simctl list devices booted 2>/dev/null | grep "iPhone\|iPad" | while read -r line; do
echo " - $line"
done
else
check_warning "No simulators currently booted"
echo " Boot a simulator to begin testing"
echo " Example: xcrun simctl boot <device-udid>"
echo " Or: open -a Simulator"
fi
else
check_failed "Cannot check booted simulators (simctl not available)"
fi
echo ""
# Check 8: Required Python packages (optional check)
echo -e "${BLUE}[8/8]${NC} Checking Python packages..."
if command -v python3 &> /dev/null; then
MISSING_PACKAGES=()
# Check for PIL/Pillow (for visual_diff.py)
if python3 -c "import PIL" 2>/dev/null; then
check_passed "Pillow (PIL) installed - visual diff available"
else
MISSING_PACKAGES+=("pillow")
check_warning "Pillow (PIL) not installed - visual diff won't work"
fi
if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
echo ""
echo " Install missing packages:"
echo " pip3 install ${MISSING_PACKAGES[*]}"
fi
else
check_warning "Cannot check Python packages (Python 3 not available)"
fi
echo ""
# Summary
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} Summary${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "Checks passed: ${GREEN}$CHECKS_PASSED${NC}"
if [ "$CHECKS_FAILED" -gt 0 ]; then
echo -e "Checks failed: ${RED}$CHECKS_FAILED${NC}"
echo ""
echo -e "${YELLOW}Action required:${NC} Fix the failed checks above before testing"
exit 1
else
echo ""
echo -e "${GREEN}✓ Environment is ready for iOS simulator testing${NC}"
echo ""
echo "Next steps:"
echo " 1. Boot a simulator: open -a Simulator"
echo " 2. Launch your app: xcrun simctl launch booted <bundle-id>"
echo " 3. Run accessibility audit: python scripts/accessibility_audit.py"
exit 0
fi
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""
iOS Simulator Listing with Progressive Disclosure
Lists available simulators with token-efficient summaries.
Full details available on demand via cache IDs.
Achieves 96% token reduction (57k→2k tokens) for common queries.
Usage Examples:
# Concise summary (default)
python scripts/sim_list.py
# Get full details for cached list
python scripts/sim_list.py --get-details <cache-id>
# Get recommendations
python scripts/sim_list.py --suggest
# Filter by device type
python scripts/sim_list.py --device-type iPhone
Output (default):
Simulator Summary [cache-sim-20251028-143052]
├─ Total: 47 devices
├─ Available: 31
└─ Booted: 1
✓ iPhone 16 Pro (iOS 18.1) [ABC-123...]
Use --get-details cache-sim-20251028-143052 for full list
Technical Details:
- Uses xcrun simctl list devices
- Caches results with 1-hour TTL
- Reduces output by 96% by default
- Token efficiency: summary = ~30 tokens, full list = ~1500 tokens
"""
import argparse
import json
import subprocess
import sys
from typing import Any
from common import get_cache
class SimulatorLister:
"""Lists iOS simulators with progressive disclosure."""
def __init__(self):
"""Initialize lister with cache."""
self.cache = get_cache()
def list_simulators(self) -> dict:
"""
Get list of all simulators.
Returns:
Dict with structure:
{
"devices": [...],
"runtimes": [...],
"total_devices": int,
"available_devices": int,
"booted_devices": [...]
}
"""
try:
result = subprocess.run(
["xcrun", "simctl", "list", "devices", "--json"],
capture_output=True,
text=True,
check=True,
)
return json.loads(result.stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError):
return {"devices": {}, "runtimes": []}
def parse_devices(self, sim_data: dict) -> list[dict]:
"""
Parse simulator data into flat list.
Returns:
List of device dicts with runtime info
"""
devices = []
devices_by_runtime = sim_data.get("devices", {})
for runtime_str, device_list in devices_by_runtime.items():
# Extract iOS version from runtime string
# Format: "iOS 18.1", "tvOS 18", etc.
runtime_name = runtime_str.replace(" Simulator", "").strip()
for device in device_list:
devices.append(
{
"name": device.get("name"),
"udid": device.get("udid"),
"state": device.get("state"),
"runtime": runtime_name,
"is_available": device.get("isAvailable", False),
}
)
return devices
def get_concise_summary(self, devices: list[dict]) -> dict:
"""
Generate concise summary with cache ID.
Returns 96% fewer tokens than full list.
"""
booted = [d for d in devices if d["state"] == "Booted"]
available = [d for d in devices if d["is_available"]]
iphone = [d for d in available if "iPhone" in d["name"]]
# Cache full list for later retrieval
cache_id = self.cache.save(
{
"devices": devices,
"timestamp": __import__("datetime").datetime.now().isoformat(),
},
"simulator-list",
)
return {
"cache_id": cache_id,
"summary": {
"total_devices": len(devices),
"available_devices": len(available),
"booted_devices": len(booted),
},
"quick_access": {
"booted": booted[:3] if booted else [],
"recommended_iphone": iphone[:3] if iphone else [],
},
}
def get_full_list(
self,
cache_id: str,
device_type: str | None = None,
runtime: str | None = None,
) -> list[dict] | None:
"""
Retrieve full simulator list from cache.
Args:
cache_id: Cache ID from concise summary
device_type: Filter by type (iPhone, iPad, etc.)
runtime: Filter by iOS version
Returns:
List of devices matching filters
"""
data = self.cache.get(cache_id)
if not data:
return None
devices = data.get("devices", [])
# Apply filters
if device_type:
devices = [d for d in devices if device_type in d["name"]]
if runtime:
devices = [d for d in devices if runtime.lower() in d["runtime"].lower()]
return devices
def suggest_simulators(self, limit: int = 4) -> list[dict]:
"""
Get simulator recommendations.
Returns:
List of recommended simulators (best candidates for building)
"""
all_sims = self.list_simulators()
devices = self.parse_devices(all_sims)
# Score devices for recommendations
scored = []
for device in devices:
score = 0
# Prefer booted
if device["state"] == "Booted":
score += 10
# Prefer available
if device["is_available"]:
score += 5
# Prefer recent iOS versions
ios_version = device["runtime"]
if "18" in ios_version:
score += 3
elif "17" in ios_version:
score += 2
# Prefer iPhones over other types
if "iPhone" in device["name"]:
score += 1
scored.append({"device": device, "score": score})
# Sort by score and return top N
scored.sort(key=lambda x: x["score"], reverse=True)
return [s["device"] for s in scored[:limit]]
def format_device(device: dict) -> str:
"""Format device for display."""
state_icon = "✓" if device["state"] == "Booted" else " "
avail_icon = "●" if device["is_available"] else "○"
name = device["name"]
runtime = device["runtime"]
udid_short = device["udid"][:8] + "..."
return f"{state_icon} {avail_icon} {name} ({runtime}) [{udid_short}]"
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="List iOS simulators with progressive disclosure")
parser.add_argument(
"--get-details",
metavar="CACHE_ID",
help="Get full details for cached simulator list",
)
parser.add_argument("--suggest", action="store_true", help="Get simulator recommendations")
parser.add_argument(
"--device-type",
help="Filter by device type (iPhone, iPad, Apple Watch, etc.)",
)
parser.add_argument("--runtime", help="Filter by iOS version (e.g., iOS-18, iOS-17)")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
lister = SimulatorLister()
# Get full list with details
if args.get_details:
devices = lister.get_full_list(
args.get_details, device_type=args.device_type, runtime=args.runtime
)
if devices is None:
print(f"Error: Cache ID not found or expired: {args.get_details}")
sys.exit(1)
if args.json:
print(json.dumps(devices, indent=2))
else:
print(f"Simulators ({len(devices)}):\n")
for device in devices:
print(f" {format_device(device)}")
# Get recommendations
elif args.suggest:
suggestions = lister.suggest_simulators()
if args.json:
print(json.dumps(suggestions, indent=2))
else:
print("Recommended Simulators:\n")
for i, device in enumerate(suggestions, 1):
print(f"{i}. {format_device(device)}")
# Default: concise summary
else:
all_sims = lister.list_simulators()
devices = lister.parse_devices(all_sims)
summary = lister.get_concise_summary(devices)
if args.json:
print(json.dumps(summary, indent=2))
else:
# Human-readable concise output
cache_id = summary["cache_id"]
s = summary["summary"]
q = summary["quick_access"]
print(f"Simulator Summary [{cache_id}]")
print(f"├─ Total: {s['total_devices']} devices")
print(f"├─ Available: {s['available_devices']}")
print(f"└─ Booted: {s['booted_devices']}")
if q["booted"]:
print()
for device in q["booted"]:
print(f" {format_device(device)}")
print()
print(f"Use --get-details {cache_id} for full list")
if __name__ == "__main__":
main()
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Boot iOS simulators and wait for readiness.
This script boots one or more simulators and optionally waits for them to reach
a ready state. It measures boot time and provides progress feedback.
Key features:
- Boot by UDID or device name
- Wait for device readiness with configurable timeout
- Measure boot performance
- Batch boot operations (boot all, boot by type)
- Progress reporting for CI/CD pipelines
"""
import argparse
import subprocess
import sys
import time
from typing import Optional
from common.device_utils import (
get_booted_device_udid,
list_simulators,
resolve_device_identifier,
)
class SimulatorBooter:
"""Boot iOS simulators with optional readiness waiting."""
def __init__(self, udid: str | None = None):
"""Initialize booter with optional device UDID."""
self.udid = udid
def boot(self, wait_ready: bool = False, timeout_seconds: int = 120) -> tuple[bool, str]:
"""
Boot simulator and optionally wait for readiness.
Args:
wait_ready: Wait for device to be ready before returning
timeout_seconds: Maximum seconds to wait for readiness
Returns:
(success, message) tuple
"""
if not self.udid:
return False, "Error: Device UDID not specified"
start_time = time.time()
# Check if already booted
try:
booted = get_booted_device_udid()
if booted == self.udid:
elapsed = time.time() - start_time
return True, (f"Device already booted: {self.udid} " f"[checked in {elapsed:.1f}s]")
except RuntimeError:
pass # No booted device, proceed with boot
# Execute boot command
try:
cmd = ["xcrun", "simctl", "boot", self.udid]
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
error = result.stderr.strip()
return False, f"Boot failed: {error}"
except subprocess.TimeoutExpired:
return False, "Boot command timed out"
except Exception as e:
return False, f"Boot error: {e}"
# Optionally wait for readiness
if wait_ready:
ready, wait_message = self._wait_for_ready(timeout_seconds)
elapsed = time.time() - start_time
if ready:
return True, (f"Device booted and ready: {self.udid} " f"[{elapsed:.1f}s total]")
return False, wait_message
elapsed = time.time() - start_time
return True, (
f"Device booted: {self.udid} [boot in {elapsed:.1f}s] "
"(use --wait-ready to wait for availability)"
)
def _wait_for_ready(self, timeout_seconds: int = 120) -> tuple[bool, str]:
"""
Wait for device to reach ready state.
Args:
timeout_seconds: Maximum seconds to wait
Returns:
(success, message) tuple
"""
start_time = time.time()
poll_interval = 0.5
checks = 0
while time.time() - start_time < timeout_seconds:
try:
checks += 1
# Check if device responds to simctl commands
result = subprocess.run(
["xcrun", "simctl", "spawn", self.udid, "launchctl", "list"],
check=False,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
elapsed = time.time() - start_time
return True, (
f"Device ready: {self.udid} " f"[{elapsed:.1f}s, {checks} checks]"
)
except (subprocess.TimeoutExpired, RuntimeError):
pass # Not ready yet
time.sleep(poll_interval)
elapsed = time.time() - start_time
return False, (
f"Boot timeout: Device did not reach ready state "
f"within {elapsed:.1f}s ({checks} checks)"
)
@staticmethod
def boot_all() -> tuple[int, int]:
"""
Boot all available simulators.
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state="available")
succeeded = 0
failed = 0
for sim in simulators:
booter = SimulatorBooter(udid=sim["udid"])
success, _message = booter.boot(wait_ready=False)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
@staticmethod
def boot_by_type(device_type: str) -> tuple[int, int]:
"""
Boot all simulators of a specific type.
Args:
device_type: Device type filter (e.g., "iPhone", "iPad")
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state="available")
succeeded = 0
failed = 0
for sim in simulators:
if device_type.lower() in sim["name"].lower():
booter = SimulatorBooter(udid=sim["udid"])
success, _message = booter.boot(wait_ready=False)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Boot iOS simulators and wait for readiness")
parser.add_argument(
"--udid",
help="Device UDID or name (required unless using --all or --type)",
)
parser.add_argument(
"--name",
help="Device name (alternative to --udid)",
)
parser.add_argument(
"--wait-ready",
action="store_true",
help="Wait for device to reach ready state",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Timeout for --wait-ready in seconds (default: 120)",
)
parser.add_argument(
"--all",
action="store_true",
help="Boot all available simulators",
)
parser.add_argument(
"--type",
help="Boot all simulators of a specific type (e.g., iPhone, iPad)",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
args = parser.parse_args()
# Handle batch operations
if args.all:
succeeded, failed = SimulatorBooter.boot_all()
if args.json:
import json
print(
json.dumps(
{
"action": "boot_all",
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Boot summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
if args.type:
succeeded, failed = SimulatorBooter.boot_by_type(args.type)
if args.json:
import json
print(
json.dumps(
{
"action": "boot_by_type",
"type": args.type,
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Boot {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
# Resolve device identifier
device_id = args.udid or args.name
if not device_id:
print("Error: Specify --udid, --name, --all, or --type", file=sys.stderr)
sys.exit(1)
try:
udid = resolve_device_identifier(device_id)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Boot device
booter = SimulatorBooter(udid=udid)
success, message = booter.boot(wait_ready=args.wait_ready, timeout_seconds=args.timeout)
if args.json:
import json
print(
json.dumps(
{
"action": "boot",
"device_id": device_id,
"udid": udid,
"success": success,
"message": message,
}
)
)
else:
print(message)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
Create iOS simulators dynamically.
This script creates new simulators with specified device type and iOS version.
Useful for CI/CD pipelines that need on-demand test device provisioning.
Key features:
- Create by device type (iPhone 16 Pro, iPad Air, etc.)
- Specify iOS version (17.0, 18.0, etc.)
- Custom device naming
- Return newly created device UDID
- List available device types and runtimes
"""
import argparse
import subprocess
import sys
from typing import Optional
from common.device_utils import list_simulators
class SimulatorCreator:
"""Create iOS simulators with specified configurations."""
def __init__(self):
"""Initialize simulator creator."""
pass
def create(
self,
device_type: str,
ios_version: str | None = None,
custom_name: str | None = None,
) -> tuple[bool, str, str | None]:
"""
Create new iOS simulator.
Args:
device_type: Device type (e.g., "iPhone 16 Pro", "iPad Air")
ios_version: iOS version (e.g., "18.0"). If None, uses latest.
custom_name: Custom device name. If None, uses default.
Returns:
(success, message, new_udid) tuple
"""
# Get available device types and runtimes
available_types = self._get_device_types()
if not available_types:
return False, "Failed to get available device types", None
# Normalize device type
device_type_id = None
for dt in available_types:
if device_type.lower() in dt["name"].lower():
device_type_id = dt["identifier"]
break
if not device_type_id:
return (
False,
f"Device type '{device_type}' not found. "
f"Use --list-devices for available types.",
None,
)
# Get available runtimes
available_runtimes = self._get_runtimes()
if not available_runtimes:
return False, "Failed to get available runtimes", None
# Resolve iOS version
runtime_id = None
if ios_version:
for rt in available_runtimes:
if ios_version in rt["name"]:
runtime_id = rt["identifier"]
break
if not runtime_id:
return (
False,
f"iOS version '{ios_version}' not found. "
f"Use --list-runtimes for available versions.",
None,
)
# Use latest runtime
elif available_runtimes:
runtime_id = available_runtimes[-1]["identifier"]
if not runtime_id:
return False, "No iOS runtime available", None
# Create device
try:
# Build device name
device_name = (
custom_name or f"{device_type_id.split('.')[-1]}-{ios_version or 'latest'}"
)
cmd = [
"xcrun",
"simctl",
"create",
device_name,
device_type_id,
runtime_id,
]
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
error = result.stderr.strip() or result.stdout.strip()
return False, f"Creation failed: {error}", None
# Extract UDID from output
new_udid = result.stdout.strip()
return (
True,
f"Device created: {device_name} ({device_type}) iOS {ios_version or 'latest'} "
f"UDID: {new_udid}",
new_udid,
)
except subprocess.TimeoutExpired:
return False, "Creation command timed out", None
except Exception as e:
return False, f"Creation error: {e}", None
@staticmethod
def _get_device_types() -> list[dict]:
"""
Get available device types.
Returns:
List of device type dicts with "name" and "identifier" keys
"""
try:
cmd = ["xcrun", "simctl", "list", "devicetypes", "-j"]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
import json
data = json.loads(result.stdout)
devices = []
for device in data.get("devicetypes", []):
devices.append(
{
"name": device.get("name", ""),
"identifier": device.get("identifier", ""),
}
)
return devices
except Exception:
return []
@staticmethod
def _get_runtimes() -> list[dict]:
"""
Get available iOS runtimes.
Returns:
List of runtime dicts with "name" and "identifier" keys
"""
try:
cmd = ["xcrun", "simctl", "list", "runtimes", "-j"]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
import json
data = json.loads(result.stdout)
runtimes = []
for runtime in data.get("runtimes", []):
# Only include iOS runtimes (skip watchOS, tvOS, etc.)
identifier = runtime.get("identifier", "")
if "iOS" in identifier or "iOS" in runtime.get("name", ""):
runtimes.append(
{
"name": runtime.get("name", ""),
"identifier": runtime.get("identifier", ""),
}
)
# Sort by version number (latest first)
runtimes.sort(key=lambda r: r.get("identifier", ""), reverse=True)
return runtimes
except Exception:
return []
@staticmethod
def list_device_types() -> list[dict]:
"""
List all available device types.
Returns:
List of device types with name and identifier
"""
return SimulatorCreator._get_device_types()
@staticmethod
def list_runtimes() -> list[dict]:
"""
List all available iOS runtimes.
Returns:
List of runtimes with name and identifier
"""
return SimulatorCreator._get_runtimes()
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Create iOS simulators dynamically")
parser.add_argument(
"--device",
required=False,
help="Device type (e.g., 'iPhone 16 Pro', 'iPad Air')",
)
parser.add_argument(
"--runtime",
help="iOS version (e.g., '18.0', '17.0'). Defaults to latest.",
)
parser.add_argument(
"--name",
help="Custom device name. Defaults to auto-generated.",
)
parser.add_argument(
"--list-devices",
action="store_true",
help="List all available device types",
)
parser.add_argument(
"--list-runtimes",
action="store_true",
help="List all available iOS runtimes",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
args = parser.parse_args()
creator = SimulatorCreator()
# Handle info queries
if args.list_devices:
devices = creator.list_device_types()
if args.json:
import json
print(json.dumps({"devices": devices}))
else:
print(f"Available device types ({len(devices)}):")
for dev in devices[:20]: # Show first 20
print(f" - {dev['name']}")
if len(devices) > 20:
print(f" ... and {len(devices) - 20} more")
sys.exit(0)
if args.list_runtimes:
runtimes = creator.list_runtimes()
if args.json:
import json
print(json.dumps({"runtimes": runtimes}))
else:
print(f"Available iOS runtimes ({len(runtimes)}):")
for rt in runtimes:
print(f" - {rt['name']}")
sys.exit(0)
# Create device
if not args.device:
print(
"Error: Specify --device, --list-devices, or --list-runtimes",
file=sys.stderr,
)
sys.exit(1)
success, message, new_udid = creator.create(
device_type=args.device,
ios_version=args.runtime,
custom_name=args.name,
)
if args.json:
import json
print(
json.dumps(
{
"action": "create",
"device_type": args.device,
"runtime": args.runtime,
"success": success,
"message": message,
"new_udid": new_udid,
}
)
)
else:
print(message)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""
Delete iOS simulators permanently.
This script permanently removes simulators and frees disk space.
Includes safety confirmation to prevent accidental deletion.
Key features:
- Delete by UDID or device name
- Confirmation required for safety
- Batch delete operations
- Report freed disk space estimate
"""
import argparse
import subprocess
import sys
from typing import Optional
from common.device_utils import (
list_simulators,
resolve_device_identifier,
)
class SimulatorDeleter:
"""Delete iOS simulators with safety confirmation."""
def __init__(self, udid: str | None = None):
"""Initialize with optional device UDID."""
self.udid = udid
def delete(self, confirm: bool = False) -> tuple[bool, str]:
"""
Delete simulator permanently.
Args:
confirm: Skip confirmation prompt (for batch operations)
Returns:
(success, message) tuple
"""
if not self.udid:
return False, "Error: Device UDID not specified"
# Safety confirmation
if not confirm:
try:
response = input(
f"Permanently delete simulator {self.udid}? " f"(type 'yes' to confirm): "
)
if response.lower() != "yes":
return False, "Deletion cancelled by user"
except KeyboardInterrupt:
return False, "Deletion cancelled"
# Execute delete command
try:
cmd = ["xcrun", "simctl", "delete", self.udid]
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
error = result.stderr.strip() or result.stdout.strip()
return False, f"Deletion failed: {error}"
return True, f"Device deleted: {self.udid} [disk space freed]"
except subprocess.TimeoutExpired:
return False, "Deletion command timed out"
except Exception as e:
return False, f"Deletion error: {e}"
@staticmethod
def delete_all(confirm: bool = False) -> tuple[int, int]:
"""
Delete all simulators permanently.
Args:
confirm: Skip confirmation prompt
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state=None)
if not confirm:
count = len(simulators)
try:
response = input(
f"Permanently delete ALL {count} simulators? " f"(type 'yes' to confirm): "
)
if response.lower() != "yes":
return 0, count
except KeyboardInterrupt:
return 0, count
succeeded = 0
failed = 0
for sim in simulators:
deleter = SimulatorDeleter(udid=sim["udid"])
success, _message = deleter.delete(confirm=True)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
@staticmethod
def delete_by_type(device_type: str, confirm: bool = False) -> tuple[int, int]:
"""
Delete all simulators of a specific type.
Args:
device_type: Device type filter (e.g., "iPhone", "iPad")
confirm: Skip confirmation prompt
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state=None)
matching = [s for s in simulators if device_type.lower() in s["name"].lower()]
if not matching:
return 0, 0
if not confirm:
count = len(matching)
try:
response = input(
f"Permanently delete {count} {device_type} simulators? "
f"(type 'yes' to confirm): "
)
if response.lower() != "yes":
return 0, count
except KeyboardInterrupt:
return 0, count
succeeded = 0
failed = 0
for sim in matching:
deleter = SimulatorDeleter(udid=sim["udid"])
success, _message = deleter.delete(confirm=True)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
@staticmethod
def delete_old(keep_count: int = 3, confirm: bool = False) -> tuple[int, int]:
"""
Delete older simulators, keeping most recent versions.
Useful for cleanup after testing multiple iOS versions.
Keeps the most recent N simulators of each type.
Args:
keep_count: Number of recent simulators to keep per type (default: 3)
confirm: Skip confirmation prompt
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state=None)
# Group by device type
by_type: dict[str, list] = {}
for sim in simulators:
dev_type = sim["type"]
if dev_type not in by_type:
by_type[dev_type] = []
by_type[dev_type].append(sim)
# Find candidates for deletion (older ones)
to_delete = []
for _dev_type, sims in by_type.items():
# Sort by runtime (iOS version) - keep newest
sorted_sims = sorted(sims, key=lambda s: s["runtime"], reverse=True)
# Mark older ones for deletion
to_delete.extend(sorted_sims[keep_count:])
if not to_delete:
return 0, 0
if not confirm:
count = len(to_delete)
try:
response = input(
f"Delete {count} older simulators, keeping {keep_count} per type? "
f"(type 'yes' to confirm): "
)
if response.lower() != "yes":
return 0, count
except KeyboardInterrupt:
return 0, count
succeeded = 0
failed = 0
for sim in to_delete:
deleter = SimulatorDeleter(udid=sim["udid"])
success, _message = deleter.delete(confirm=True)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Delete iOS simulators permanently")
parser.add_argument(
"--udid",
help="Device UDID or name (required unless using batch options)",
)
parser.add_argument(
"--name",
help="Device name (alternative to --udid)",
)
parser.add_argument(
"--yes",
action="store_true",
help="Skip confirmation prompt",
)
parser.add_argument(
"--all",
action="store_true",
help="Delete all simulators",
)
parser.add_argument(
"--type",
help="Delete all simulators of a specific type (e.g., iPhone)",
)
parser.add_argument(
"--old",
type=int,
metavar="KEEP_COUNT",
help="Delete older simulators, keeping this many per type (e.g., --old 3)",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
args = parser.parse_args()
# Handle batch operations
if args.all:
succeeded, failed = SimulatorDeleter.delete_all(confirm=args.yes)
if args.json:
import json
print(
json.dumps(
{
"action": "delete_all",
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Delete summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
if args.type:
succeeded, failed = SimulatorDeleter.delete_by_type(args.type, confirm=args.yes)
if args.json:
import json
print(
json.dumps(
{
"action": "delete_by_type",
"type": args.type,
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Delete {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
if args.old is not None:
succeeded, failed = SimulatorDeleter.delete_old(keep_count=args.old, confirm=args.yes)
if args.json:
import json
print(
json.dumps(
{
"action": "delete_old",
"keep_count": args.old,
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(
f"Delete old summary: {succeeded}/{total} succeeded, "
f"{failed} failed (kept {args.old} per type)"
)
sys.exit(0 if failed == 0 else 1)
# Delete single device
device_id = args.udid or args.name
if not device_id:
print("Error: Specify --udid, --name, --all, --type, or --old", file=sys.stderr)
sys.exit(1)
try:
udid = resolve_device_identifier(device_id)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Delete device
deleter = SimulatorDeleter(udid=udid)
success, message = deleter.delete(confirm=args.yes)
if args.json:
import json
print(
json.dumps(
{
"action": "delete",
"device_id": device_id,
"udid": udid,
"success": success,
"message": message,
}
)
)
else:
print(message)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Erase iOS simulators (factory reset).
This script performs a factory reset on simulators, returning them to
a clean state while preserving the device UUID. Much faster than
delete + create for CI/CD cleanup.
Key features:
- Erase by UDID or device name
- Preserve device UUID (faster than delete)
- Verify erase completion
- Batch erase operations (all, by type)
"""
import argparse
import subprocess
import sys
import time
from typing import Optional
from common.device_utils import (
list_simulators,
resolve_device_identifier,
)
class SimulatorEraser:
"""Erase iOS simulators with optional verification."""
def __init__(self, udid: str | None = None):
"""Initialize with optional device UDID."""
self.udid = udid
def erase(self, verify: bool = True, timeout_seconds: int = 30) -> tuple[bool, str]:
"""
Erase simulator and optionally verify completion.
Performs a factory reset, clearing all app data and settings
while preserving the simulator UUID.
Args:
verify: Wait for erase to complete and verify state
timeout_seconds: Maximum seconds to wait for verification
Returns:
(success, message) tuple
"""
if not self.udid:
return False, "Error: Device UDID not specified"
start_time = time.time()
# Execute erase command
try:
cmd = ["xcrun", "simctl", "erase", self.udid]
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
error = result.stderr.strip()
return False, f"Erase failed: {error}"
except subprocess.TimeoutExpired:
return False, "Erase command timed out"
except Exception as e:
return False, f"Erase error: {e}"
# Optionally verify erase completion
if verify:
ready, verify_message = self._verify_erase(timeout_seconds)
elapsed = time.time() - start_time
if ready:
return True, (
f"Device erased: {self.udid} " f"[factory reset complete, {elapsed:.1f}s]"
)
return False, verify_message
elapsed = time.time() - start_time
return True, (
f"Device erase initiated: {self.udid} [{elapsed:.1f}s] "
"(use --verify to wait for completion)"
)
def _verify_erase(self, timeout_seconds: int = 30) -> tuple[bool, str]:
"""
Verify erase has completed.
Polls device state to confirm erase finished successfully.
Args:
timeout_seconds: Maximum seconds to wait
Returns:
(success, message) tuple
"""
start_time = time.time()
poll_interval = 0.5
checks = 0
while time.time() - start_time < timeout_seconds:
try:
checks += 1
# Check if device can be queried (indicates boot status)
result = subprocess.run(
["xcrun", "simctl", "spawn", self.udid, "launchctl", "list"],
check=False,
capture_output=True,
text=True,
timeout=5,
)
# Device responding = erase likely complete
if result.returncode == 0:
elapsed = time.time() - start_time
return True, (
f"Erase verified: {self.udid} " f"[{elapsed:.1f}s, {checks} checks]"
)
except (subprocess.TimeoutExpired, RuntimeError):
pass # Not ready yet, keep polling
time.sleep(poll_interval)
elapsed = time.time() - start_time
return False, (
f"Erase verification timeout: Device did not respond "
f"within {elapsed:.1f}s ({checks} checks)"
)
@staticmethod
def erase_all() -> tuple[int, int]:
"""
Erase all simulators (factory reset).
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state=None)
succeeded = 0
failed = 0
for sim in simulators:
eraser = SimulatorEraser(udid=sim["udid"])
success, _message = eraser.erase(verify=False)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
@staticmethod
def erase_by_type(device_type: str) -> tuple[int, int]:
"""
Erase all simulators of a specific type.
Args:
device_type: Device type filter (e.g., "iPhone", "iPad")
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state=None)
succeeded = 0
failed = 0
for sim in simulators:
if device_type.lower() in sim["name"].lower():
eraser = SimulatorEraser(udid=sim["udid"])
success, _message = eraser.erase(verify=False)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
@staticmethod
def erase_booted() -> tuple[int, int]:
"""
Erase all currently booted simulators.
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state="booted")
succeeded = 0
failed = 0
for sim in simulators:
eraser = SimulatorEraser(udid=sim["udid"])
success, _message = eraser.erase(verify=False)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Erase iOS simulators (factory reset)")
parser.add_argument(
"--udid",
help="Device UDID or name (required unless using --all, --type, or --booted)",
)
parser.add_argument(
"--name",
help="Device name (alternative to --udid)",
)
parser.add_argument(
"--verify",
action="store_true",
help="Wait for erase to complete and verify state",
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="Timeout for --verify in seconds (default: 30)",
)
parser.add_argument(
"--all",
action="store_true",
help="Erase all simulators (factory reset)",
)
parser.add_argument(
"--type",
help="Erase all simulators of a specific type (e.g., iPhone)",
)
parser.add_argument(
"--booted",
action="store_true",
help="Erase all currently booted simulators",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
args = parser.parse_args()
# Handle batch operations
if args.all:
succeeded, failed = SimulatorEraser.erase_all()
if args.json:
import json
print(
json.dumps(
{
"action": "erase_all",
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Erase summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
if args.type:
succeeded, failed = SimulatorEraser.erase_by_type(args.type)
if args.json:
import json
print(
json.dumps(
{
"action": "erase_by_type",
"type": args.type,
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Erase {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
if args.booted:
succeeded, failed = SimulatorEraser.erase_booted()
if args.json:
import json
print(
json.dumps(
{
"action": "erase_booted",
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Erase booted summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
# Erase single device
device_id = args.udid or args.name
if not device_id:
print("Error: Specify --udid, --name, --all, --type, or --booted", file=sys.stderr)
sys.exit(1)
try:
udid = resolve_device_identifier(device_id)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Erase device
eraser = SimulatorEraser(udid=udid)
success, message = eraser.erase(verify=args.verify, timeout_seconds=args.timeout)
if args.json:
import json
print(
json.dumps(
{
"action": "erase",
"device_id": device_id,
"udid": udid,
"success": success,
"message": message,
}
)
)
else:
print(message)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
Shutdown iOS simulators with optional state verification.
This script shuts down one or more running simulators and optionally
verifies completion. Supports batch operations for efficient cleanup.
Key features:
- Shutdown by UDID or device name
- Verify shutdown completion with timeout
- Batch shutdown operations (all, by type)
- Progress reporting for CI/CD pipelines
"""
import argparse
import subprocess
import sys
import time
from typing import Optional
from common.device_utils import (
list_simulators,
resolve_device_identifier,
)
class SimulatorShutdown:
"""Shutdown iOS simulators with optional verification."""
def __init__(self, udid: str | None = None):
"""Initialize with optional device UDID."""
self.udid = udid
def shutdown(self, verify: bool = True, timeout_seconds: int = 30) -> tuple[bool, str]:
"""
Shutdown simulator and optionally verify completion.
Args:
verify: Wait for shutdown to complete
timeout_seconds: Maximum seconds to wait for shutdown
Returns:
(success, message) tuple
"""
if not self.udid:
return False, "Error: Device UDID not specified"
start_time = time.time()
# Check if already shutdown
simulators = list_simulators(state="booted")
if not any(s["udid"] == self.udid for s in simulators):
elapsed = time.time() - start_time
return True, (f"Device already shutdown: {self.udid} " f"[checked in {elapsed:.1f}s]")
# Execute shutdown command
try:
cmd = ["xcrun", "simctl", "shutdown", self.udid]
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
error = result.stderr.strip()
return False, f"Shutdown failed: {error}"
except subprocess.TimeoutExpired:
return False, "Shutdown command timed out"
except Exception as e:
return False, f"Shutdown error: {e}"
# Optionally verify shutdown
if verify:
ready, verify_message = self._verify_shutdown(timeout_seconds)
elapsed = time.time() - start_time
if ready:
return True, (f"Device shutdown confirmed: {self.udid} " f"[{elapsed:.1f}s total]")
return False, verify_message
elapsed = time.time() - start_time
return True, (
f"Device shutdown: {self.udid} [{elapsed:.1f}s] "
"(use --verify to wait for confirmation)"
)
def _verify_shutdown(self, timeout_seconds: int = 30) -> tuple[bool, str]:
"""
Verify device has fully shutdown.
Args:
timeout_seconds: Maximum seconds to wait
Returns:
(success, message) tuple
"""
start_time = time.time()
poll_interval = 0.5
checks = 0
while time.time() - start_time < timeout_seconds:
try:
checks += 1
# Check booted devices
simulators = list_simulators(state="booted")
if not any(s["udid"] == self.udid for s in simulators):
elapsed = time.time() - start_time
return True, (
f"Device shutdown verified: {self.udid} "
f"[{elapsed:.1f}s, {checks} checks]"
)
except RuntimeError:
pass # Error checking, retry
time.sleep(poll_interval)
elapsed = time.time() - start_time
return False, (
f"Shutdown verification timeout: Device did not fully shutdown "
f"within {elapsed:.1f}s ({checks} checks)"
)
@staticmethod
def shutdown_all() -> tuple[int, int]:
"""
Shutdown all booted simulators.
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state="booted")
succeeded = 0
failed = 0
for sim in simulators:
shutdown = SimulatorShutdown(udid=sim["udid"])
success, _message = shutdown.shutdown(verify=False)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
@staticmethod
def shutdown_by_type(device_type: str) -> tuple[int, int]:
"""
Shutdown all booted simulators of a specific type.
Args:
device_type: Device type filter (e.g., "iPhone", "iPad")
Returns:
(succeeded, failed) tuple with counts
"""
simulators = list_simulators(state="booted")
succeeded = 0
failed = 0
for sim in simulators:
if device_type.lower() in sim["name"].lower():
shutdown = SimulatorShutdown(udid=sim["udid"])
success, _message = shutdown.shutdown(verify=False)
if success:
succeeded += 1
else:
failed += 1
return succeeded, failed
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Shutdown iOS simulators with optional verification"
)
parser.add_argument(
"--udid",
help="Device UDID or name (required unless using --all or --type)",
)
parser.add_argument(
"--name",
help="Device name (alternative to --udid)",
)
parser.add_argument(
"--verify",
action="store_true",
help="Wait for shutdown to complete and verify state",
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="Timeout for --verify in seconds (default: 30)",
)
parser.add_argument(
"--all",
action="store_true",
help="Shutdown all booted simulators",
)
parser.add_argument(
"--type",
help="Shutdown all booted simulators of a specific type (e.g., iPhone)",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
args = parser.parse_args()
# Handle batch operations
if args.all:
succeeded, failed = SimulatorShutdown.shutdown_all()
if args.json:
import json
print(
json.dumps(
{
"action": "shutdown_all",
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(f"Shutdown summary: {succeeded}/{total} succeeded, " f"{failed} failed")
sys.exit(0 if failed == 0 else 1)
if args.type:
succeeded, failed = SimulatorShutdown.shutdown_by_type(args.type)
if args.json:
import json
print(
json.dumps(
{
"action": "shutdown_by_type",
"type": args.type,
"succeeded": succeeded,
"failed": failed,
"total": succeeded + failed,
}
)
)
else:
total = succeeded + failed
print(
f"Shutdown {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed"
)
sys.exit(0 if failed == 0 else 1)
# Resolve device identifier
device_id = args.udid or args.name
if not device_id:
print("Error: Specify --udid, --name, --all, or --type", file=sys.stderr)
sys.exit(1)
try:
udid = resolve_device_identifier(device_id)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Shutdown device
shutdown = SimulatorShutdown(udid=udid)
success, message = shutdown.shutdown(verify=args.verify, timeout_seconds=args.timeout)
if args.json:
import json
print(
json.dumps(
{
"action": "shutdown",
"device_id": device_id,
"udid": udid,
"success": success,
"message": message,
}
)
)
else:
print(message)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
Intelligent Simulator Selector
Suggests the best available iOS simulators based on:
- Recently used (from config)
- Latest iOS version
- Common models for testing
- Boot status
Usage Examples:
# Get suggestions for user selection
python scripts/simulator_selector.py --suggest
# List all available simulators
python scripts/simulator_selector.py --list
# Boot a specific simulator
python scripts/simulator_selector.py --boot "67A99DF0-27BD-4507-A3DE-B7D8C38F764A"
# Get suggestions as JSON for programmatic use
python scripts/simulator_selector.py --suggest --json
"""
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
# Try to import config from build_and_test if available
try:
from xcode.config import Config
except ImportError:
Config = None
class SimulatorInfo:
"""Information about an iOS simulator."""
def __init__(
self,
name: str,
udid: str,
ios_version: str,
status: str,
):
"""Initialize simulator info."""
self.name = name
self.udid = udid
self.ios_version = ios_version
self.status = status
self.reasons: list[str] = []
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"device": self.name,
"udid": self.udid,
"ios": self.ios_version,
"status": self.status,
"reasons": self.reasons,
}
class SimulatorSelector:
"""Intelligent simulator selection."""
# Common iPhone models ranked by testing priority
COMMON_MODELS = [
"iPhone 16 Pro",
"iPhone 16",
"iPhone 15 Pro",
"iPhone 15",
"iPhone SE (3rd generation)",
]
def __init__(self):
"""Initialize selector."""
self.simulators: list[SimulatorInfo] = []
self.config: dict | None = None
self.last_used_simulator: str | None = None
# Load config if available
if Config:
try:
config = Config.load()
self.last_used_simulator = config.get_preferred_simulator()
except Exception:
pass
def list_simulators(self) -> list[SimulatorInfo]:
"""
List all available simulators.
Returns:
List of SimulatorInfo objects
"""
try:
result = subprocess.run(
["xcrun", "simctl", "list", "devices", "--json"],
capture_output=True,
text=True,
check=True,
)
data = json.loads(result.stdout)
simulators = []
# Parse devices by iOS version
for runtime, devices in data.get("devices", {}).items():
# Extract iOS version from runtime (e.g., "com.apple.CoreSimulator.SimRuntime.iOS-18-0")
ios_version_match = re.search(r"iOS-(\d+-\d+)", runtime)
if not ios_version_match:
continue
ios_version = ios_version_match.group(1).replace("-", ".")
for device in devices:
name = device.get("name", "")
udid = device.get("udid", "")
is_available = device.get("isAvailable", False)
if not is_available or "iPhone" not in name:
continue
status = device.get("state", "").capitalize()
sim_info = SimulatorInfo(name, udid, ios_version, status)
simulators.append(sim_info)
self.simulators = simulators
return simulators
except subprocess.CalledProcessError as e:
print(f"Error listing simulators: {e.stderr}", file=sys.stderr)
return []
except json.JSONDecodeError as e:
print(f"Error parsing simulator list: {e}", file=sys.stderr)
return []
def get_suggestions(self, count: int = 4) -> list[SimulatorInfo]:
"""
Get top N suggested simulators.
Ranking factors:
1. Recently used (from config)
2. Latest iOS version
3. Common models
4. Boot status (Booted preferred)
Args:
count: Number of suggestions to return
Returns:
List of suggested SimulatorInfo objects
"""
if not self.simulators:
return []
# Score each simulator
scored = []
for sim in self.simulators:
score = self._score_simulator(sim)
scored.append((score, sim))
# Sort by score (descending)
scored.sort(key=lambda x: x[0], reverse=True)
# Return top N
suggestions = [sim for _, sim in scored[:count]]
# Add reasons to each suggestion
for i, sim in enumerate(suggestions, 1):
if i == 1:
sim.reasons.append("Recommended")
# Check if recently used
if self.last_used_simulator and self.last_used_simulator == sim.name:
sim.reasons.append("Recently used")
# Check if latest iOS
latest_ios = max(s.ios_version for s in self.simulators)
if sim.ios_version == latest_ios:
sim.reasons.append("Latest iOS")
# Check if common model
for j, model in enumerate(self.COMMON_MODELS):
if model in sim.name:
sim.reasons.append(f"#{j+1} common model")
break
# Check if booted
if sim.status == "Booted":
sim.reasons.append("Currently running")
return suggestions
def _score_simulator(self, sim: SimulatorInfo) -> float:
"""
Score a simulator for ranking.
Higher score = better recommendation.
Args:
sim: Simulator to score
Returns:
Score value
"""
score = 0.0
# Recently used gets highest priority (100 points)
if self.last_used_simulator and self.last_used_simulator == sim.name:
score += 100
# Latest iOS version (50 points)
latest_ios = max(s.ios_version for s in self.simulators)
if sim.ios_version == latest_ios:
score += 50
# Common models (30-20 points based on ranking)
for i, model in enumerate(self.COMMON_MODELS):
if model in sim.name:
score += 30 - (i * 2) # Higher ranking models get more points
break
# Currently booted (10 points)
if sim.status == "Booted":
score += 10
# iOS version number (minor factor for breaking ties)
ios_numeric = float(sim.ios_version.replace(".", ""))
score += ios_numeric * 0.1
return score
def boot_simulator(self, udid: str) -> bool:
"""
Boot a simulator.
Args:
udid: Simulator UDID
Returns:
True if successful, False otherwise
"""
try:
subprocess.run(
["xcrun", "simctl", "boot", udid],
capture_output=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"Error booting simulator: {e.stderr}", file=sys.stderr)
return False
def format_suggestions(suggestions: list[SimulatorInfo], json_format: bool = False) -> str:
"""
Format suggestions for output.
Args:
suggestions: List of suggestions
json_format: If True, output as JSON
Returns:
Formatted string
"""
if json_format:
data = {"suggestions": [s.to_dict() for s in suggestions]}
return json.dumps(data, indent=2)
if not suggestions:
return "No simulators available"
lines = ["Available Simulators:\n"]
for i, sim in enumerate(suggestions, 1):
lines.append(f"{i}. {sim.name} (iOS {sim.ios_version})")
if sim.reasons:
lines.append(f" {', '.join(sim.reasons)}")
lines.append(f" UDID: {sim.udid}")
lines.append("")
return "\n".join(lines)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Intelligent iOS simulator selector",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Get suggestions for user selection
python scripts/simulator_selector.py --suggest
# List all available simulators
python scripts/simulator_selector.py --list
# Boot a specific simulator
python scripts/simulator_selector.py --boot <UDID>
# Get suggestions as JSON
python scripts/simulator_selector.py --suggest --json
""",
)
parser.add_argument(
"--suggest",
action="store_true",
help="Get top simulator suggestions",
)
parser.add_argument(
"--list",
action="store_true",
help="List all available simulators",
)
parser.add_argument(
"--boot",
metavar="UDID",
help="Boot specific simulator by UDID",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
parser.add_argument(
"--count",
type=int,
default=4,
help="Number of suggestions (default: 4)",
)
args = parser.parse_args()
selector = SimulatorSelector()
if args.boot:
# Boot specific simulator
success = selector.boot_simulator(args.boot)
if success:
print(f"Booted simulator: {args.boot}")
return 0
return 1
if args.list:
# List all simulators
simulators = selector.list_simulators()
output = format_suggestions(simulators, args.json)
print(output)
return 0
if args.suggest:
# Get suggestions
selector.list_simulators()
suggestions = selector.get_suggestions(args.count)
output = format_suggestions(suggestions, args.json)
print(output)
return 0
# Default: show suggestions
selector.list_simulators()
suggestions = selector.get_suggestions(args.count)
output = format_suggestions(suggestions, args.json)
print(output)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
iOS Status Bar Controller
Override simulator status bar for clean screenshots and testing.
Control time, network, wifi, battery display.
Usage: python scripts/status_bar.py --preset clean
"""
import argparse
import subprocess
import sys
from common import resolve_udid
class StatusBarController:
"""Controls iOS simulator status bar appearance."""
# Preset configurations
PRESETS = {
"clean": {
"time": "9:41",
"data_network": "5g",
"wifi_mode": "active",
"battery_state": "charged",
"battery_level": 100,
},
"testing": {
"time": "11:11",
"data_network": "4g",
"wifi_mode": "active",
"battery_state": "discharging",
"battery_level": 50,
},
"low_battery": {
"time": "9:41",
"data_network": "5g",
"wifi_mode": "active",
"battery_state": "discharging",
"battery_level": 20,
},
"airplane": {
"time": "9:41",
"data_network": "none",
"wifi_mode": "failed",
"battery_state": "charged",
"battery_level": 100,
},
}
def __init__(self, udid: str | None = None):
"""Initialize status bar controller.
Args:
udid: Optional device UDID (auto-detects booted simulator if None)
"""
self.udid = udid
def override(
self,
time: str | None = None,
data_network: str | None = None,
wifi_mode: str | None = None,
battery_state: str | None = None,
battery_level: int | None = None,
) -> bool:
"""
Override status bar appearance.
Args:
time: Time in HH:MM format (e.g., "9:41")
data_network: Network type (none, 1x, 3g, 4g, 5g, lte, lte-a)
wifi_mode: WiFi state (active, searching, failed)
battery_state: Battery state (charging, charged, discharging)
battery_level: Battery percentage (0-100)
Returns:
Success status
"""
cmd = ["xcrun", "simctl", "status_bar"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.append("override")
# Add parameters if provided
if time:
cmd.extend(["--time", time])
if data_network:
cmd.extend(["--dataNetwork", data_network])
if wifi_mode:
cmd.extend(["--wifiMode", wifi_mode])
if battery_state:
cmd.extend(["--batteryState", battery_state])
if battery_level is not None:
cmd.extend(["--batteryLevel", str(battery_level)])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def clear(self) -> bool:
"""
Clear status bar override and restore defaults.
Returns:
Success status
"""
cmd = ["xcrun", "simctl", "status_bar"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.append("clear")
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Override iOS simulator status bar for screenshots and testing"
)
# Preset option
parser.add_argument(
"--preset",
choices=list(StatusBarController.PRESETS.keys()),
help="Use preset configuration (clean, testing, low-battery, airplane)",
)
# Custom options
parser.add_argument(
"--time",
help="Override time (HH:MM format, e.g., '9:41')",
)
parser.add_argument(
"--data-network",
choices=["none", "1x", "3g", "4g", "5g", "lte", "lte-a"],
help="Data network type",
)
parser.add_argument(
"--wifi-mode",
choices=["active", "searching", "failed"],
help="WiFi state",
)
parser.add_argument(
"--battery-state",
choices=["charging", "charged", "discharging"],
help="Battery state",
)
parser.add_argument(
"--battery-level",
type=int,
help="Battery level 0-100",
)
# Other options
parser.add_argument(
"--clear",
action="store_true",
help="Clear status bar override and restore defaults",
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
controller = StatusBarController(udid=udid)
# Clear mode
if args.clear:
if controller.clear():
print("Status bar override cleared - defaults restored")
else:
print("Failed to clear status bar override")
sys.exit(1)
# Preset mode
elif args.preset:
preset = StatusBarController.PRESETS[args.preset]
if controller.override(**preset):
print(f"Status bar: {args.preset} preset applied")
print(
f" Time: {preset['time']}, "
f"Network: {preset['data_network']}, "
f"Battery: {preset['battery_level']}%"
)
else:
print(f"Failed to apply {args.preset} preset")
sys.exit(1)
# Custom mode
elif any(
[
args.time,
args.data_network,
args.wifi_mode,
args.battery_state,
args.battery_level is not None,
]
):
if controller.override(
time=args.time,
data_network=args.data_network,
wifi_mode=args.wifi_mode,
battery_state=args.battery_state,
battery_level=args.battery_level,
):
output = "Status bar override applied:"
if args.time:
output += f" Time={args.time}"
if args.data_network:
output += f" Network={args.data_network}"
if args.battery_level is not None:
output += f" Battery={args.battery_level}%"
print(output)
else:
print("Failed to override status bar")
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""
Test Recorder for iOS Simulator Testing
Records test execution with automatic screenshots and documentation.
Optimized for minimal token output during execution.
Usage:
As a script: python scripts/test_recorder.py --test-name "Test Name" --output dir/
As a module: from scripts.test_recorder import TestRecorder
"""
import argparse
import json
import subprocess
import time
from datetime import datetime
from pathlib import Path
from common import (
capture_screenshot,
count_elements,
generate_screenshot_name,
get_accessibility_tree,
resolve_udid,
)
class TestRecorder:
"""Records test execution with screenshots and accessibility snapshots."""
def __init__(
self,
test_name: str,
output_dir: str = "test-artifacts",
udid: str | None = None,
inline: bool = False,
screenshot_size: str = "half",
app_name: str | None = None,
):
"""
Initialize test recorder.
Args:
test_name: Name of the test being recorded
output_dir: Directory for test artifacts
udid: Optional device UDID (uses booted if not specified)
inline: If True, return screenshots as base64 (for vision-based automation)
screenshot_size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
app_name: App name for semantic screenshot naming
"""
self.test_name = test_name
self.udid = udid
self.inline = inline
self.screenshot_size = screenshot_size
self.app_name = app_name
self.start_time = time.time()
self.steps: list[dict] = []
self.current_step = 0
# Create timestamped output directory
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
safe_name = test_name.lower().replace(" ", "-")
self.output_dir = Path(output_dir) / f"{safe_name}-{timestamp}"
self.output_dir.mkdir(parents=True, exist_ok=True)
# Create subdirectories (only if not in inline mode)
if not inline:
self.screenshots_dir = self.output_dir / "screenshots"
self.screenshots_dir.mkdir(exist_ok=True)
else:
self.screenshots_dir = None
self.accessibility_dir = self.output_dir / "accessibility"
self.accessibility_dir.mkdir(exist_ok=True)
# Token-efficient output
mode_str = "(inline mode)" if inline else ""
print(f"Recording: {test_name} {mode_str}")
print(f"Output: {self.output_dir}/")
def step(
self,
description: str,
screen_name: str | None = None,
state: str | None = None,
assertion: str | None = None,
metadata: dict | None = None,
):
"""
Record a test step with automatic screenshot.
Args:
description: Step description
screen_name: Screen name for semantic naming
state: State description for semantic naming
assertion: Optional assertion to verify
metadata: Optional metadata for the step
"""
self.current_step += 1
step_time = time.time() - self.start_time
# Capture screenshot using new utility
screenshot_result = capture_screenshot(
self.udid,
size=self.screenshot_size,
inline=self.inline,
app_name=self.app_name,
screen_name=screen_name or description,
state=state,
)
# Capture accessibility tree
accessibility_path = (
self.accessibility_dir
/ f"{self.current_step:03d}-{description.lower().replace(' ', '-')[:20]}.json"
)
element_count = self._capture_accessibility(accessibility_path)
# Store step data
step_data = {
"number": self.current_step,
"description": description,
"timestamp": step_time,
"element_count": element_count,
"accessibility": accessibility_path.name,
"screenshot_mode": screenshot_result["mode"],
"screenshot_size": self.screenshot_size,
}
# Handle screenshot data based on mode
if screenshot_result["mode"] == "file":
step_data["screenshot"] = screenshot_result["file_path"]
step_data["screenshot_name"] = Path(screenshot_result["file_path"]).name
else:
# Inline mode
step_data["screenshot_base64"] = screenshot_result["base64_data"]
step_data["screenshot_dimensions"] = (
screenshot_result["width"],
screenshot_result["height"],
)
if assertion:
step_data["assertion"] = assertion
step_data["assertion_passed"] = True
if metadata:
step_data["metadata"] = metadata
self.steps.append(step_data)
# Token-efficient output (single line)
status = "✓" if not assertion or step_data.get("assertion_passed") else "✗"
screenshot_info = (
f" [{screenshot_result['width']}x{screenshot_result['height']}]" if self.inline else ""
)
print(
f"{status} Step {self.current_step}: {description} ({step_time:.1f}s){screenshot_info}"
)
def _capture_screenshot(self, output_path: Path) -> bool:
"""Capture screenshot using simctl."""
cmd = ["xcrun", "simctl", "io"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(["screenshot", str(output_path)])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def _capture_accessibility(self, output_path: Path) -> int:
"""Capture accessibility tree and return element count."""
try:
# Use shared utility to fetch tree
tree = get_accessibility_tree(self.udid, nested=True)
# Save tree
with open(output_path, "w") as f:
json.dump(tree, f, indent=2)
# Count elements using shared utility
return count_elements(tree)
except Exception:
return 0
def generate_report(self) -> dict[str, str]:
"""
Generate markdown test report.
Returns:
Dictionary with paths to generated files
"""
duration = time.time() - self.start_time
report_path = self.output_dir / "report.md"
# Generate markdown
with open(report_path, "w") as f:
f.write(f"# Test Report: {self.test_name}\n\n")
f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"**Duration:** {duration:.1f} seconds\n")
f.write(f"**Steps:** {len(self.steps)}\n\n")
# Steps section
f.write("## Test Steps\n\n")
for step in self.steps:
f.write(
f"### Step {step['number']}: {step['description']} ({step['timestamp']:.1f}s)\n\n"
)
f.write(f"![Screenshot](screenshots/{step['screenshot']})\n\n")
if step.get("assertion"):
status = "✓" if step.get("assertion_passed") else "✗"
f.write(f"**Assertion:** {step['assertion']} {status}\n\n")
if step.get("metadata"):
f.write("**Metadata:**\n")
for key, value in step["metadata"].items():
f.write(f"- {key}: {value}\n")
f.write("\n")
f.write(f"**Accessibility Elements:** {step['element_count']}\n\n")
f.write("---\n\n")
# Summary
f.write("## Summary\n\n")
f.write(f"- Total steps: {len(self.steps)}\n")
f.write(f"- Duration: {duration:.1f}s\n")
f.write(f"- Screenshots: {len(self.steps)}\n")
f.write(f"- Accessibility snapshots: {len(self.steps)}\n")
# Save metadata JSON
metadata_path = self.output_dir / "metadata.json"
with open(metadata_path, "w") as f:
json.dump(
{
"test_name": self.test_name,
"duration": duration,
"steps": self.steps,
"timestamp": datetime.now().isoformat(),
},
f,
indent=2,
)
# Token-efficient output
print(f"Report: {report_path}")
return {
"markdown_path": str(report_path),
"metadata_path": str(metadata_path),
"output_dir": str(self.output_dir),
}
def main():
"""Main entry point for command-line usage."""
parser = argparse.ArgumentParser(
description="Record test execution with screenshots and documentation"
)
parser.add_argument("--test-name", required=True, help="Name of the test being recorded")
parser.add_argument(
"--output", default="test-artifacts", help="Output directory for test artifacts"
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument(
"--inline",
action="store_true",
help="Return screenshots as base64 (inline mode for vision-based automation)",
)
parser.add_argument(
"--size",
choices=["full", "half", "quarter", "thumb"],
default="half",
help="Screenshot size for token optimization (default: half)",
)
parser.add_argument("--app-name", help="App name for semantic screenshot naming")
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
import sys
sys.exit(1)
# Create recorder
TestRecorder(
test_name=args.test_name,
output_dir=args.output,
udid=udid,
inline=args.inline,
screenshot_size=args.size,
app_name=args.app_name,
)
print("Test recorder initialized. Use the following methods:")
print(' recorder.step("description") - Record a test step')
print(" recorder.generate_report() - Generate final report")
print()
print("Example:")
print(' recorder.step("Launch app", screen_name="Splash")')
print(
' recorder.step("Enter credentials", screen_name="Login", state="Empty", metadata={"user": "test"})'
)
print(' recorder.step("Verify login", assertion="Home screen visible")')
print(" recorder.generate_report()")
if __name__ == "__main__":
main()
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
Visual Diff Tool for iOS Simulator Screenshots
Compares two screenshots pixel-by-pixel to detect visual changes.
Optimized for minimal token output.
Usage: python scripts/visual_diff.py baseline.png current.png [options]
"""
import argparse
import json
import os
import sys
from pathlib import Path
try:
from PIL import Image, ImageChops, ImageDraw
except ImportError:
print("Error: Pillow not installed. Run: pip3 install pillow")
sys.exit(1)
class VisualDiffer:
"""Performs visual comparison between screenshots."""
def __init__(self, threshold: float = 0.01):
"""
Initialize differ with threshold.
Args:
threshold: Maximum acceptable difference ratio (0.01 = 1%)
"""
self.threshold = threshold
def compare(self, baseline_path: str, current_path: str) -> dict:
"""
Compare two images and return difference metrics.
Args:
baseline_path: Path to baseline image
current_path: Path to current image
Returns:
Dictionary with comparison results
"""
# Load images
try:
baseline = Image.open(baseline_path)
current = Image.open(current_path)
except FileNotFoundError as e:
print(f"Error: Image not found - {e}")
sys.exit(1)
except Exception as e:
print(f"Error: Failed to load image - {e}")
sys.exit(1)
# Verify dimensions match
if baseline.size != current.size:
return {
"error": "Image dimensions do not match",
"baseline_size": baseline.size,
"current_size": current.size,
}
# Convert to RGB if needed
if baseline.mode != "RGB":
baseline = baseline.convert("RGB")
if current.mode != "RGB":
current = current.convert("RGB")
# Calculate difference
diff = ImageChops.difference(baseline, current)
# Calculate metrics
total_pixels = baseline.size[0] * baseline.size[1]
diff_pixels = self._count_different_pixels(diff)
diff_percentage = (diff_pixels / total_pixels) * 100
# Determine pass/fail
passed = diff_percentage <= (self.threshold * 100)
return {
"dimensions": baseline.size,
"total_pixels": total_pixels,
"different_pixels": diff_pixels,
"difference_percentage": round(diff_percentage, 2),
"threshold_percentage": self.threshold * 100,
"passed": passed,
"verdict": "PASS" if passed else "FAIL",
}
def _count_different_pixels(self, diff_image: Image.Image) -> int:
"""Count number of pixels that are different."""
# Convert to grayscale for easier processing
diff_gray = diff_image.convert("L")
# Count non-zero pixels (different)
pixels = diff_gray.getdata()
return sum(1 for pixel in pixels if pixel > 10) # Threshold for noise
def generate_diff_image(self, baseline_path: str, current_path: str, output_path: str) -> None:
"""Generate highlighted difference image."""
baseline = Image.open(baseline_path).convert("RGB")
current = Image.open(current_path).convert("RGB")
# Create difference image
diff = ImageChops.difference(baseline, current)
# Enhance differences with red overlay
diff_enhanced = Image.new("RGB", baseline.size)
for x in range(baseline.size[0]):
for y in range(baseline.size[1]):
diff_pixel = diff.getpixel((x, y))
if sum(diff_pixel) > 30: # Threshold for visibility
# Highlight in red
diff_enhanced.putpixel((x, y), (255, 0, 0))
else:
# Keep original
diff_enhanced.putpixel((x, y), current.getpixel((x, y)))
diff_enhanced.save(output_path)
def generate_side_by_side(
self, baseline_path: str, current_path: str, output_path: str
) -> None:
"""Generate side-by-side comparison image."""
baseline = Image.open(baseline_path)
current = Image.open(current_path)
# Create combined image
width = baseline.size[0] * 2 + 10 # 10px separator
height = max(baseline.size[1], current.size[1])
combined = Image.new("RGB", (width, height), color=(128, 128, 128))
# Paste images
combined.paste(baseline, (0, 0))
combined.paste(current, (baseline.size[0] + 10, 0))
combined.save(output_path)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Compare screenshots for visual differences")
parser.add_argument("baseline", help="Path to baseline screenshot")
parser.add_argument("current", help="Path to current screenshot")
parser.add_argument(
"--output",
default=".",
help="Output directory for diff artifacts (default: current directory)",
)
parser.add_argument(
"--threshold",
type=float,
default=0.01,
help="Acceptable difference threshold (0.01 = 1%%, default: 0.01)",
)
parser.add_argument(
"--details", action="store_true", help="Show detailed output (increases tokens)"
)
args = parser.parse_args()
# Create output directory if needed
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
# Initialize differ
differ = VisualDiffer(threshold=args.threshold)
# Perform comparison
result = differ.compare(args.baseline, args.current)
# Handle dimension mismatch
if "error" in result:
print(f"Error: {result['error']}")
print(f"Baseline: {result['baseline_size']}")
print(f"Current: {result['current_size']}")
sys.exit(1)
# Generate artifacts
diff_image_path = output_dir / "diff.png"
comparison_image_path = output_dir / "side-by-side.png"
try:
differ.generate_diff_image(args.baseline, args.current, str(diff_image_path))
differ.generate_side_by_side(args.baseline, args.current, str(comparison_image_path))
except Exception as e:
print(f"Warning: Could not generate images - {e}")
# Output results (token-optimized)
if args.details:
# Detailed output
report = {
"summary": {
"baseline": args.baseline,
"current": args.current,
"threshold": args.threshold,
"passed": result["passed"],
},
"results": result,
"artifacts": {
"diff_image": str(diff_image_path),
"comparison_image": str(comparison_image_path),
},
}
print(json.dumps(report, indent=2))
else:
# Minimal output (default)
print(f"Difference: {result['difference_percentage']}% ({result['verdict']})")
if result["different_pixels"] > 0:
print(f"Changed pixels: {result['different_pixels']:,}")
print(f"Artifacts saved to: {output_dir}/")
# Save JSON report
report_path = output_dir / "diff-report.json"
with open(report_path, "w") as f:
json.dump(
{
"baseline": os.path.basename(args.baseline),
"current": os.path.basename(args.current),
"results": result,
"artifacts": {"diff": "diff.png", "comparison": "side-by-side.png"},
},
f,
indent=2,
)
# Exit with error if test failed
sys.exit(0 if result["passed"] else 1)
if __name__ == "__main__":
main()
@@ -0,0 +1,13 @@
"""
Xcode build automation module.
Provides structured, modular access to xcodebuild and xcresult functionality.
"""
from .builder import BuildRunner
from .cache import XCResultCache
from .config import Config
from .reporter import OutputFormatter
from .xcresult import XCResultParser
__all__ = ["BuildRunner", "Config", "OutputFormatter", "XCResultCache", "XCResultParser"]
@@ -0,0 +1,381 @@
"""
Xcode build execution.
Handles xcodebuild command construction and execution with xcresult generation.
"""
import re
import subprocess
import sys
from pathlib import Path
from .cache import XCResultCache
from .config import Config
class BuildRunner:
"""
Execute xcodebuild commands with xcresult bundle generation.
Handles scheme auto-detection, command construction, and build/test execution.
"""
def __init__(
self,
project_path: str | None = None,
workspace_path: str | None = None,
scheme: str | None = None,
configuration: str = "Debug",
simulator: str | None = None,
cache: XCResultCache | None = None,
):
"""
Initialize build runner.
Args:
project_path: Path to .xcodeproj
workspace_path: Path to .xcworkspace
scheme: Build scheme (auto-detected if not provided)
configuration: Build configuration (Debug/Release)
simulator: Simulator name
cache: XCResult cache (creates default if not provided)
"""
self.project_path = project_path
self.workspace_path = workspace_path
self.scheme = scheme
self.configuration = configuration
self.simulator = simulator
self.cache = cache or XCResultCache()
def auto_detect_scheme(self) -> str | None:
"""
Auto-detect build scheme from project/workspace.
Returns:
Detected scheme name or None
"""
cmd = ["xcodebuild", "-list"]
if self.workspace_path:
cmd.extend(["-workspace", self.workspace_path])
elif self.project_path:
cmd.extend(["-project", self.project_path])
else:
return None
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse schemes from output
in_schemes_section = False
for line in result.stdout.split("\n"):
line = line.strip()
if "Schemes:" in line:
in_schemes_section = True
continue
if in_schemes_section and line and not line.startswith("Build"):
# First scheme in list
return line
except subprocess.CalledProcessError as e:
print(f"Error auto-detecting scheme: {e}", file=sys.stderr)
return None
def get_simulator_destination(self) -> str:
"""
Get xcodebuild destination string.
Uses config preferences with fallback to auto-detection.
Priority:
1. --simulator CLI flag (self.simulator)
2. Config preferred_simulator
3. Config last_used_simulator
4. Auto-detect first iPhone
5. Generic iOS Simulator
Returns:
Destination string for -destination flag
"""
# Priority 1: CLI flag
if self.simulator:
return f"platform=iOS Simulator,name={self.simulator}"
# Priority 2-3: Config preferences
try:
# Determine project directory from project/workspace path
project_dir = None
if self.project_path:
project_dir = Path(self.project_path).parent
elif self.workspace_path:
project_dir = Path(self.workspace_path).parent
config = Config.load(project_dir=project_dir)
preferred = config.get_preferred_simulator()
if preferred:
# Check if preferred simulator exists
if self._simulator_exists(preferred):
return f"platform=iOS Simulator,name={preferred}"
print(f"Warning: Preferred simulator '{preferred}' not available", file=sys.stderr)
if config.should_fallback_to_any_iphone():
print("Falling back to auto-detection...", file=sys.stderr)
else:
# Strict mode: don't fallback
return f"platform=iOS Simulator,name={preferred}"
except Exception as e:
print(f"Warning: Could not load config: {e}", file=sys.stderr)
# Priority 4-5: Auto-detect
return self._auto_detect_simulator()
def _simulator_exists(self, name: str) -> bool:
"""
Check if simulator with given name exists and is available.
Args:
name: Simulator name (e.g., "iPhone 16 Pro")
Returns:
True if simulator exists and is available
"""
try:
result = subprocess.run(
["xcrun", "simctl", "list", "devices", "available", "iOS"],
capture_output=True,
text=True,
check=True,
)
# Check if simulator name appears in available devices
return any(name in line and "(" in line for line in result.stdout.split("\n"))
except subprocess.CalledProcessError:
return False
def _extract_simulator_name_from_destination(self, destination: str) -> str | None:
"""
Extract simulator name from destination string.
Args:
destination: Destination string (e.g., "platform=iOS Simulator,name=iPhone 16 Pro")
Returns:
Simulator name or None
"""
# Pattern: name=<simulator name>
match = re.search(r"name=([^,]+)", destination)
if match:
return match.group(1).strip()
return None
def _auto_detect_simulator(self) -> str:
"""
Auto-detect best available iOS simulator.
Returns:
Destination string for -destination flag
"""
try:
result = subprocess.run(
["xcrun", "simctl", "list", "devices", "available", "iOS"],
capture_output=True,
text=True,
check=True,
)
# Parse available simulators, prefer latest iPhone
# Looking for lines like: "iPhone 16 Pro (12345678-1234-1234-1234-123456789012) (Shutdown)"
for line in result.stdout.split("\n"):
if "iPhone" in line and "(" in line:
# Extract device name
name = line.split("(")[0].strip()
if name:
return f"platform=iOS Simulator,name={name}"
# Fallback to generic iOS Simulator if no iPhone found
return "generic/platform=iOS Simulator"
except subprocess.CalledProcessError as e:
print(f"Warning: Could not auto-detect simulator: {e}", file=sys.stderr)
return "generic/platform=iOS Simulator"
def build(self, clean: bool = False) -> tuple[bool, str, str]:
"""
Build the project.
Args:
clean: Perform clean build
Returns:
Tuple of (success: bool, xcresult_id: str, stderr: str)
"""
# Auto-detect scheme if needed
if not self.scheme:
self.scheme = self.auto_detect_scheme()
if not self.scheme:
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
return (False, "", "")
# Generate xcresult ID and path
xcresult_id = self.cache.generate_id()
xcresult_path = self.cache.get_path(xcresult_id)
# Build command
cmd = ["xcodebuild", "-quiet"] # Suppress verbose output
if clean:
cmd.append("clean")
cmd.append("build")
if self.workspace_path:
cmd.extend(["-workspace", self.workspace_path])
elif self.project_path:
cmd.extend(["-project", self.project_path])
else:
print("Error: No project or workspace specified", file=sys.stderr)
return (False, "", "")
cmd.extend(
[
"-scheme",
self.scheme,
"-configuration",
self.configuration,
"-destination",
self.get_simulator_destination(),
"-resultBundlePath",
str(xcresult_path),
]
)
# Execute build
try:
result = subprocess.run(
cmd, capture_output=True, text=True, check=False # Don't raise on non-zero exit
)
success = result.returncode == 0
# xcresult bundle should be created even on failure
if not xcresult_path.exists():
print("Warning: xcresult bundle was not created", file=sys.stderr)
return (success, "", result.stderr)
# Auto-update config with last used simulator (on success only)
if success:
try:
# Determine project directory from project/workspace path
project_dir = None
if self.project_path:
project_dir = Path(self.project_path).parent
elif self.workspace_path:
project_dir = Path(self.workspace_path).parent
config = Config.load(project_dir=project_dir)
destination = self.get_simulator_destination()
simulator_name = self._extract_simulator_name_from_destination(destination)
if simulator_name:
config.update_last_used_simulator(simulator_name)
config.save()
except Exception as e:
# Don't fail build if config update fails
print(f"Warning: Could not update config: {e}", file=sys.stderr)
return (success, xcresult_id, result.stderr)
except Exception as e:
print(f"Error executing build: {e}", file=sys.stderr)
return (False, "", str(e))
def test(self, test_suite: str | None = None) -> tuple[bool, str, str]:
"""
Run tests.
Args:
test_suite: Specific test suite to run
Returns:
Tuple of (success: bool, xcresult_id: str, stderr: str)
"""
# Auto-detect scheme if needed
if not self.scheme:
self.scheme = self.auto_detect_scheme()
if not self.scheme:
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
return (False, "", "")
# Generate xcresult ID and path
xcresult_id = self.cache.generate_id()
xcresult_path = self.cache.get_path(xcresult_id)
# Build command
cmd = ["xcodebuild", "-quiet", "test"]
if self.workspace_path:
cmd.extend(["-workspace", self.workspace_path])
elif self.project_path:
cmd.extend(["-project", self.project_path])
else:
print("Error: No project or workspace specified", file=sys.stderr)
return (False, "", "")
cmd.extend(
[
"-scheme",
self.scheme,
"-destination",
self.get_simulator_destination(),
"-resultBundlePath",
str(xcresult_path),
]
)
if test_suite:
cmd.extend(["-only-testing", test_suite])
# Execute tests
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
success = result.returncode == 0
# xcresult bundle should be created even on failure
if not xcresult_path.exists():
print("Warning: xcresult bundle was not created", file=sys.stderr)
return (success, "", result.stderr)
# Auto-update config with last used simulator (on success only)
if success:
try:
# Determine project directory from project/workspace path
project_dir = None
if self.project_path:
project_dir = Path(self.project_path).parent
elif self.workspace_path:
project_dir = Path(self.workspace_path).parent
config = Config.load(project_dir=project_dir)
destination = self.get_simulator_destination()
simulator_name = self._extract_simulator_name_from_destination(destination)
if simulator_name:
config.update_last_used_simulator(simulator_name)
config.save()
except Exception as e:
# Don't fail test if config update fails
print(f"Warning: Could not update config: {e}", file=sys.stderr)
return (success, xcresult_id, result.stderr)
except Exception as e:
print(f"Error executing tests: {e}", file=sys.stderr)
return (False, "", str(e))
@@ -0,0 +1,204 @@
"""
XCResult cache management.
Handles storage, retrieval, and lifecycle of xcresult bundles for progressive disclosure.
"""
import shutil
from datetime import datetime
from pathlib import Path
class XCResultCache:
"""
Manage xcresult bundle cache for progressive disclosure.
Stores xcresult bundles with timestamp-based IDs and provides
retrieval and cleanup operations.
"""
# Default cache directory
DEFAULT_CACHE_DIR = Path.home() / ".ios-simulator-skill" / "xcresults"
def __init__(self, cache_dir: Path | None = None):
"""
Initialize cache manager.
Args:
cache_dir: Custom cache directory (uses default if not specified)
"""
self.cache_dir = cache_dir or self.DEFAULT_CACHE_DIR
self.cache_dir.mkdir(parents=True, exist_ok=True)
def generate_id(self, prefix: str = "xcresult") -> str:
"""
Generate timestamped xcresult ID.
Args:
prefix: ID prefix (default: "xcresult")
Returns:
ID string like "xcresult-20251018-143052"
"""
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return f"{prefix}-{timestamp}"
def get_path(self, xcresult_id: str) -> Path:
"""
Get full path for xcresult ID.
Args:
xcresult_id: XCResult ID
Returns:
Path to xcresult bundle
"""
# Handle both with and without .xcresult extension
if xcresult_id.endswith(".xcresult"):
return self.cache_dir / xcresult_id
return self.cache_dir / f"{xcresult_id}.xcresult"
def exists(self, xcresult_id: str) -> bool:
"""
Check if xcresult bundle exists.
Args:
xcresult_id: XCResult ID
Returns:
True if bundle exists
"""
return self.get_path(xcresult_id).exists()
def save(self, source_path: Path, xcresult_id: str | None = None) -> str:
"""
Save xcresult bundle to cache.
Args:
source_path: Source xcresult bundle path
xcresult_id: Optional custom ID (generates if not provided)
Returns:
xcresult ID
"""
if not source_path.exists():
raise FileNotFoundError(f"Source xcresult not found: {source_path}")
# Generate ID if not provided
if not xcresult_id:
xcresult_id = self.generate_id()
# Get destination path
dest_path = self.get_path(xcresult_id)
# Copy xcresult bundle (it's a directory)
if dest_path.exists():
shutil.rmtree(dest_path)
shutil.copytree(source_path, dest_path)
return xcresult_id
def list(self, limit: int = 10) -> list[dict]:
"""
List recent xcresult bundles.
Args:
limit: Maximum number to return
Returns:
List of xcresult metadata dicts
"""
if not self.cache_dir.exists():
return []
results = []
for path in sorted(
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
)[:limit]:
# Calculate bundle size
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
results.append(
{
"id": path.stem,
"path": str(path),
"created": datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
"size_mb": round(size_bytes / (1024 * 1024), 2),
}
)
return results
def cleanup(self, keep_recent: int = 20) -> int:
"""
Clean up old xcresult bundles.
Args:
keep_recent: Number of recent bundles to keep
Returns:
Number of bundles removed
"""
if not self.cache_dir.exists():
return 0
# Get all bundles sorted by modification time
all_bundles = sorted(
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
)
# Remove old bundles
removed = 0
for bundle_path in all_bundles[keep_recent:]:
shutil.rmtree(bundle_path)
removed += 1
return removed
def get_size_mb(self, xcresult_id: str) -> float:
"""
Get size of xcresult bundle in MB.
Args:
xcresult_id: XCResult ID
Returns:
Size in MB
"""
path = self.get_path(xcresult_id)
if not path.exists():
return 0.0
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
return round(size_bytes / (1024 * 1024), 2)
def save_stderr(self, xcresult_id: str, stderr: str) -> None:
"""
Save stderr output alongside xcresult bundle.
Args:
xcresult_id: XCResult ID
stderr: stderr output from xcodebuild
"""
if not stderr:
return
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
stderr_path.write_text(stderr, encoding="utf-8")
def get_stderr(self, xcresult_id: str) -> str:
"""
Retrieve cached stderr output.
Args:
xcresult_id: XCResult ID
Returns:
stderr content or empty string if not found
"""
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
if not stderr_path.exists():
return ""
return stderr_path.read_text(encoding="utf-8")
@@ -0,0 +1,178 @@
"""
Configuration management for iOS Simulator Skill.
Handles loading, validation, and auto-updating of project-local config files.
"""
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
class Config:
"""
Project-local configuration with auto-learning.
Config file location: .claude/skills/<skill-directory-name>/config.json
The skill directory name is auto-detected from the installation location,
so configs work regardless of what users name the skill directory.
Auto-updates last_used_simulator after successful builds.
"""
DEFAULT_CONFIG = {
"device": {
"preferred_simulator": None,
"preferred_os_version": None,
"fallback_to_any_iphone": True,
"last_used_simulator": None,
"last_used_at": None,
}
}
def __init__(self, data: dict[str, Any], config_path: Path):
"""
Initialize config.
Args:
data: Config data dict
config_path: Path to config file
"""
self.data = data
self.config_path = config_path
@staticmethod
def load(project_dir: Path | None = None) -> "Config":
"""
Load config from project directory.
Args:
project_dir: Project root (defaults to cwd)
Returns:
Config instance (creates default if not found)
Note:
The skill directory name is auto-detected from the installation location,
so configs work regardless of what users name the skill directory.
"""
if project_dir is None:
project_dir = Path.cwd()
# Auto-detect skill directory name from actual installation location
# This file is at: skill/scripts/xcode/config.py
# Navigate up to skill/ directory and use its name
skill_root = Path(__file__).parent.parent.parent # xcode/ -> scripts/ -> skill/
skill_name = skill_root.name
config_path = project_dir / ".claude" / "skills" / skill_name / "config.json"
# Load existing config
if config_path.exists():
try:
with open(config_path) as f:
data = json.load(f)
# Merge with defaults (in case new fields added)
merged = Config._merge_with_defaults(data)
return Config(merged, config_path)
except json.JSONDecodeError as e:
print(f"Warning: Invalid JSON in {config_path}: {e}", file=sys.stderr)
print("Using default config", file=sys.stderr)
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
except Exception as e:
print(f"Warning: Could not load config: {e}", file=sys.stderr)
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
# Return default config (will be created on first save)
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
@staticmethod
def _merge_with_defaults(data: dict[str, Any]) -> dict[str, Any]:
"""
Merge user config with defaults.
Args:
data: User config data
Returns:
Merged config with all default fields
"""
merged = Config.DEFAULT_CONFIG.copy()
# Deep merge device section
if "device" in data:
merged["device"].update(data["device"])
return merged
def save(self) -> None:
"""
Save config to file atomically.
Uses temp file + rename for atomic writes.
Creates parent directories if needed.
"""
try:
# Create parent directories
self.config_path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: temp file + rename
temp_path = self.config_path.with_suffix(".tmp")
with open(temp_path, "w") as f:
json.dump(self.data, f, indent=2)
f.write("\n") # Trailing newline
# Atomic rename
temp_path.replace(self.config_path)
except Exception as e:
print(f"Warning: Could not save config: {e}", file=sys.stderr)
def update_last_used_simulator(self, name: str) -> None:
"""
Update last used simulator and timestamp.
Args:
name: Simulator name (e.g., "iPhone 16 Pro")
"""
self.data["device"]["last_used_simulator"] = name
self.data["device"]["last_used_at"] = datetime.utcnow().isoformat() + "Z"
def get_preferred_simulator(self) -> str | None:
"""
Get preferred simulator.
Returns:
Simulator name or None
Priority:
1. preferred_simulator (manual preference)
2. last_used_simulator (auto-learned)
3. None (use auto-detection)
"""
device = self.data.get("device", {})
# Manual preference takes priority
if device.get("preferred_simulator"):
return device["preferred_simulator"]
# Auto-learned preference
if device.get("last_used_simulator"):
return device["last_used_simulator"]
return None
def should_fallback_to_any_iphone(self) -> bool:
"""
Check if fallback to any iPhone is enabled.
Returns:
True if should fallback, False otherwise
"""
return self.data.get("device", {}).get("fallback_to_any_iphone", True)
@@ -0,0 +1,291 @@
"""
Build/test output formatting.
Provides multiple output formats with progressive disclosure support.
"""
import json
class OutputFormatter:
"""
Format build/test results for display.
Supports ultra-minimal default output, verbose mode, and JSON output.
"""
@staticmethod
def format_minimal(
status: str,
error_count: int,
warning_count: int,
xcresult_id: str,
test_info: dict | None = None,
hints: list[str] | None = None,
) -> str:
"""
Format ultra-minimal output (5-10 tokens).
Args:
status: Build status (SUCCESS/FAILED)
error_count: Number of errors
warning_count: Number of warnings
xcresult_id: XCResult bundle ID
test_info: Optional test results dict
hints: Optional list of actionable hints
Returns:
Minimal formatted string
Example:
Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
Tests: PASS (12/12 passed, 4.2s) [xcresult-20251018-143052]
"""
lines = []
if test_info:
# Test mode
total = test_info.get("total", 0)
passed = test_info.get("passed", 0)
failed = test_info.get("failed", 0)
duration = test_info.get("duration", 0.0)
test_status = "PASS" if failed == 0 else "FAIL"
lines.append(
f"Tests: {test_status} ({passed}/{total} passed, {duration:.1f}s) [{xcresult_id}]"
)
else:
# Build mode
lines.append(
f"Build: {status} ({error_count} errors, {warning_count} warnings) [{xcresult_id}]"
)
# Add hints if provided and build failed
if hints and status == "FAILED":
lines.append("")
lines.extend(hints)
return "\n".join(lines)
@staticmethod
def format_errors(errors: list[dict], limit: int = 10) -> str:
"""
Format error details.
Args:
errors: List of error dicts
limit: Maximum errors to show
Returns:
Formatted error list
"""
if not errors:
return "No errors found."
lines = [f"Errors ({len(errors)}):"]
lines.append("")
for i, error in enumerate(errors[:limit], 1):
message = error.get("message", "Unknown error")
location = error.get("location", {})
# Format location
loc_parts = []
if location.get("file"):
file_path = location["file"].replace("file://", "")
loc_parts.append(file_path)
if location.get("line"):
loc_parts.append(f"line {location['line']}")
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
lines.append(f"{i}. {message}")
lines.append(f" Location: {location_str}")
lines.append("")
if len(errors) > limit:
lines.append(f"... and {len(errors) - limit} more errors")
return "\n".join(lines)
@staticmethod
def format_warnings(warnings: list[dict], limit: int = 10) -> str:
"""
Format warning details.
Args:
warnings: List of warning dicts
limit: Maximum warnings to show
Returns:
Formatted warning list
"""
if not warnings:
return "No warnings found."
lines = [f"Warnings ({len(warnings)}):"]
lines.append("")
for i, warning in enumerate(warnings[:limit], 1):
message = warning.get("message", "Unknown warning")
location = warning.get("location", {})
# Format location
loc_parts = []
if location.get("file"):
file_path = location["file"].replace("file://", "")
loc_parts.append(file_path)
if location.get("line"):
loc_parts.append(f"line {location['line']}")
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
lines.append(f"{i}. {message}")
lines.append(f" Location: {location_str}")
lines.append("")
if len(warnings) > limit:
lines.append(f"... and {len(warnings) - limit} more warnings")
return "\n".join(lines)
@staticmethod
def format_log(log: str, lines: int = 50) -> str:
"""
Format build log (show last N lines).
Args:
log: Full build log
lines: Number of lines to show
Returns:
Formatted log excerpt
"""
if not log:
return "No build log available."
log_lines = log.strip().split("\n")
if len(log_lines) <= lines:
return log
# Show last N lines
excerpt = log_lines[-lines:]
return f"... (showing last {lines} lines of {len(log_lines)})\n\n" + "\n".join(excerpt)
@staticmethod
def format_json(data: dict) -> str:
"""
Format data as JSON.
Args:
data: Data to format
Returns:
Pretty-printed JSON string
"""
return json.dumps(data, indent=2)
@staticmethod
def generate_hints(errors: list[dict]) -> list[str]:
"""
Generate actionable hints based on error types.
Args:
errors: List of error dicts
Returns:
List of hint strings
"""
hints = []
error_types: set[str] = set()
# Collect error types
for error in errors:
error_type = error.get("type", "unknown")
error_types.add(error_type)
# Generate hints based on error types
if "provisioning" in error_types:
hints.append("Provisioning profile issue detected:")
hints.append(" • Ensure you have a valid provisioning profile for iOS Simulator")
hints.append(
' • For simulator builds, use CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO'
)
hints.append(" • Or specify simulator explicitly: --simulator 'iPhone 16 Pro'")
if "signing" in error_types:
hints.append("Code signing issue detected:")
hints.append(" • For simulator builds, code signing is not required")
hints.append(" • Ensure build settings target iOS Simulator, not physical device")
hints.append(" • Check destination: platform=iOS Simulator,name=<device>")
if not error_types or "build" in error_types:
# Generic hints when error type is unknown
if any("destination" in error.get("message", "").lower() for error in errors):
hints.append("Device selection issue detected:")
hints.append(" • List available simulators: xcrun simctl list devices available")
hints.append(" • Specify simulator: --simulator 'iPhone 16 Pro'")
return hints
@staticmethod
def format_verbose(
status: str,
error_count: int,
warning_count: int,
xcresult_id: str,
errors: list[dict] | None = None,
warnings: list[dict] | None = None,
test_info: dict | None = None,
) -> str:
"""
Format verbose output with error/warning details.
Args:
status: Build status
error_count: Error count
warning_count: Warning count
xcresult_id: XCResult ID
errors: Optional error list
warnings: Optional warning list
test_info: Optional test results
Returns:
Verbose formatted output
"""
lines = []
# Header
if test_info:
total = test_info.get("total", 0)
passed = test_info.get("passed", 0)
failed = test_info.get("failed", 0)
duration = test_info.get("duration", 0.0)
test_status = "PASS" if failed == 0 else "FAIL"
lines.append(f"Tests: {test_status}")
lines.append(f" Total: {total}")
lines.append(f" Passed: {passed}")
lines.append(f" Failed: {failed}")
lines.append(f" Duration: {duration:.1f}s")
else:
lines.append(f"Build: {status}")
lines.append(f"XCResult: {xcresult_id}")
lines.append("")
# Errors
if errors and len(errors) > 0:
lines.append(OutputFormatter.format_errors(errors, limit=5))
lines.append("")
# Warnings
if warnings and len(warnings) > 0:
lines.append(OutputFormatter.format_warnings(warnings, limit=5))
lines.append("")
# Summary
lines.append(f"Summary: {error_count} errors, {warning_count} warnings")
return "\n".join(lines)
@@ -0,0 +1,404 @@
"""
XCResult bundle parser.
Extracts structured data from xcresult bundles using xcresulttool.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
class XCResultParser:
"""
Parse xcresult bundles to extract build/test data.
Uses xcresulttool to extract structured JSON data from Apple's
xcresult bundle format.
"""
def __init__(self, xcresult_path: Path, stderr: str = ""):
"""
Initialize parser.
Args:
xcresult_path: Path to xcresult bundle
stderr: Optional stderr output for fallback parsing
"""
self.xcresult_path = xcresult_path
self.stderr = stderr
if xcresult_path and not xcresult_path.exists():
raise FileNotFoundError(f"XCResult bundle not found: {xcresult_path}")
def get_build_results(self) -> dict | None:
"""
Get build results as JSON.
Returns:
Parsed JSON dict or None on error
"""
return self._run_xcresulttool(["get", "build-results"])
def get_test_results(self) -> dict | None:
"""
Get test results summary as JSON.
Returns:
Parsed JSON dict or None on error
"""
return self._run_xcresulttool(["get", "test-results", "summary"])
def get_build_log(self) -> str | None:
"""
Get build log as plain text.
Returns:
Build log string or None on error
"""
result = self._run_xcresulttool(["get", "log", "--type", "build"], parse_json=False)
return result if result else None
def count_issues(self) -> tuple[int, int]:
"""
Count errors and warnings from build results.
Returns:
Tuple of (error_count, warning_count)
"""
error_count = 0
warning_count = 0
build_results = self.get_build_results()
if build_results:
try:
# Try top-level errors/warnings first (newer xcresult format)
if "errors" in build_results and isinstance(build_results.get("errors"), list):
error_count = len(build_results["errors"])
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
warning_count = len(build_results["warnings"])
# If not found, try legacy format: actions[0].buildResult.issues
if error_count == 0 and warning_count == 0:
actions = build_results.get("actions", {}).get("_values", [])
if actions:
build_result = actions[0].get("buildResult", {})
issues = build_result.get("issues", {})
# Count errors
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
error_count = len(error_summaries)
# Count warnings
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
warning_count = len(warning_summaries)
except (KeyError, IndexError, TypeError) as e:
print(f"Warning: Could not parse issue counts from xcresult: {e}", file=sys.stderr)
# If no errors found in xcresult but stderr available, count stderr errors
if error_count == 0 and self.stderr:
stderr_errors = self._parse_stderr_errors()
error_count = len(stderr_errors)
return (error_count, warning_count)
def get_errors(self) -> list[dict]:
"""
Get detailed error information.
Returns:
List of error dicts with message, file, line info
"""
build_results = self.get_build_results()
errors = []
# Try to get errors from xcresult
if build_results:
try:
# Try top-level errors first (newer xcresult format)
if "errors" in build_results and isinstance(build_results.get("errors"), list):
for error in build_results["errors"]:
errors.append(
{
"message": error.get("message", "Unknown error"),
"type": error.get("issueType", "error"),
"location": self._extract_location_from_url(error.get("sourceURL")),
}
)
# If not found, try legacy format: actions[0].buildResult.issues
if not errors:
actions = build_results.get("actions", {}).get("_values", [])
if actions:
build_result = actions[0].get("buildResult", {})
issues = build_result.get("issues", {})
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
for error in error_summaries:
errors.append(
{
"message": error.get("message", {}).get(
"_value", "Unknown error"
),
"type": error.get("issueType", {}).get("_value", "error"),
"location": self._extract_location(error),
}
)
except (KeyError, IndexError, TypeError) as e:
print(f"Warning: Could not parse errors from xcresult: {e}", file=sys.stderr)
# If no errors found in xcresult but stderr available, parse stderr
if not errors and self.stderr:
errors = self._parse_stderr_errors()
return errors
def get_warnings(self) -> list[dict]:
"""
Get detailed warning information.
Returns:
List of warning dicts with message, file, line info
"""
build_results = self.get_build_results()
if not build_results:
return []
warnings = []
try:
# Try top-level warnings first (newer xcresult format)
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
for warning in build_results["warnings"]:
warnings.append(
{
"message": warning.get("message", "Unknown warning"),
"type": warning.get("issueType", "warning"),
"location": self._extract_location_from_url(warning.get("sourceURL")),
}
)
# If not found, try legacy format: actions[0].buildResult.issues
if not warnings:
actions = build_results.get("actions", {}).get("_values", [])
if not actions:
return []
build_result = actions[0].get("buildResult", {})
issues = build_result.get("issues", {})
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
for warning in warning_summaries:
warnings.append(
{
"message": warning.get("message", {}).get("_value", "Unknown warning"),
"type": warning.get("issueType", {}).get("_value", "warning"),
"location": self._extract_location(warning),
}
)
except (KeyError, IndexError, TypeError) as e:
print(f"Warning: Could not parse warnings: {e}", file=sys.stderr)
return warnings
def _extract_location(self, issue: dict) -> dict:
"""
Extract file location from issue.
Args:
issue: Issue dict from xcresult
Returns:
Location dict with file, line, column
"""
location = {"file": None, "line": None, "column": None}
try:
doc_location = issue.get("documentLocationInCreatingWorkspace", {})
location["file"] = doc_location.get("url", {}).get("_value")
location["line"] = doc_location.get("startingLineNumber", {}).get("_value")
location["column"] = doc_location.get("startingColumnNumber", {}).get("_value")
except (KeyError, TypeError):
pass
return location
def _extract_location_from_url(self, source_url: str | None) -> dict:
"""
Extract file location from sourceURL (newer xcresult format).
Args:
source_url: Source URL like "file:///path/to/file.swift#StartingLineNumber=134&..."
Returns:
Location dict with file, line, column
"""
location = {"file": None, "line": None, "column": None}
if not source_url:
return location
try:
# Split URL and fragment
if "#" in source_url:
file_part, fragment = source_url.split("#", 1)
# Extract file path
location["file"] = file_part.replace("file://", "")
# Parse fragment parameters
params = {}
for param in fragment.split("&"):
if "=" in param:
key, value = param.split("=", 1)
params[key] = value
# Extract line and column
location["line"] = (
int(params.get("StartingLineNumber", 0)) + 1
if "StartingLineNumber" in params
else None
)
location["column"] = (
int(params.get("StartingColumnNumber", 0)) + 1
if "StartingColumnNumber" in params
else None
)
else:
# No fragment, just file path
location["file"] = source_url.replace("file://", "")
except (ValueError, AttributeError):
pass
return location
def _run_xcresulttool(self, args: list[str], parse_json: bool = True) -> Any | None:
"""
Run xcresulttool command.
Args:
args: Command arguments (after 'xcresulttool')
parse_json: Whether to parse output as JSON
Returns:
Parsed JSON dict, plain text, or None on error
"""
if not self.xcresult_path:
return None
cmd = ["xcrun", "xcresulttool"] + args + ["--path", str(self.xcresult_path)]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
if parse_json:
return json.loads(result.stdout)
return result.stdout
except subprocess.CalledProcessError as e:
print(f"Error running xcresulttool: {e}", file=sys.stderr)
print(f"stderr: {e.stderr}", file=sys.stderr)
return None
except json.JSONDecodeError as e:
print(f"Error parsing JSON from xcresulttool: {e}", file=sys.stderr)
return None
def _parse_stderr_errors(self) -> list[dict]:
"""
Parse common errors from stderr output as fallback.
Returns:
List of error dicts parsed from stderr
"""
errors = []
if not self.stderr:
return errors
# Pattern 0: Swift/Clang compilation errors (e.g., "/path/file.swift:135:59: error: message")
compilation_error_pattern = (
r"^(?P<file>[^:]+):(?P<line>\d+):(?P<column>\d+):\s*error:\s*(?P<message>.+?)$"
)
for match in re.finditer(compilation_error_pattern, self.stderr, re.MULTILINE):
errors.append(
{
"message": match.group("message").strip(),
"type": "compilation",
"location": {
"file": match.group("file"),
"line": int(match.group("line")),
"column": int(match.group("column")),
},
}
)
# Pattern 1: xcodebuild top-level errors (e.g., "xcodebuild: error: Unable to find...")
xcodebuild_error_pattern = r"xcodebuild:\s*error:\s*(?P<message>.*?)(?:\n\n|\Z)"
for match in re.finditer(xcodebuild_error_pattern, self.stderr, re.DOTALL):
message = match.group("message").strip()
# Clean up multi-line messages
message = " ".join(line.strip() for line in message.split("\n") if line.strip())
errors.append(
{
"message": message,
"type": "build",
"location": {"file": None, "line": None, "column": None},
}
)
# Pattern 2: Provisioning profile errors
provisioning_pattern = r"error:.*?provisioning profile.*?(?:doesn't|does not|cannot).*?(?P<message>.*?)(?:\n|$)"
for match in re.finditer(provisioning_pattern, self.stderr, re.IGNORECASE):
errors.append(
{
"message": f"Provisioning profile error: {match.group('message').strip()}",
"type": "provisioning",
"location": {"file": None, "line": None, "column": None},
}
)
# Pattern 3: Code signing errors
signing_pattern = r"error:.*?(?:code sign|signing).*?(?P<message>.*?)(?:\n|$)"
for match in re.finditer(signing_pattern, self.stderr, re.IGNORECASE):
errors.append(
{
"message": f"Code signing error: {match.group('message').strip()}",
"type": "signing",
"location": {"file": None, "line": None, "column": None},
}
)
# Pattern 4: Generic compilation errors (but not if already captured)
if not errors:
generic_error_pattern = r"^(?:\*\*\s)?(?:error|❌):\s*(?P<message>.*?)(?:\n|$)"
for match in re.finditer(generic_error_pattern, self.stderr, re.MULTILINE):
message = match.group("message").strip()
errors.append(
{
"message": message,
"type": "build",
"location": {"file": None, "line": None, "column": None},
}
)
# Pattern 5: Specific "No profiles" error
if "No profiles for" in self.stderr:
no_profile_pattern = r"No profiles for '(?P<bundle_id>.*?)' were found"
for match in re.finditer(no_profile_pattern, self.stderr):
errors.append(
{
"message": f"No provisioning profile found for bundle ID '{match.group('bundle_id')}'",
"type": "provisioning",
"location": {"file": None, "line": None, "column": None},
}
)
return errors
+149
View File
@@ -0,0 +1,149 @@
name: Deploy Supabase to Production
on:
push:
branches:
- master
paths:
- 'apps/supabase/**'
- 'terraform/**'
jobs:
deploy-database:
name: Deploy Migrations & Functions
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_REF: ckjcrkjpmhqhiucifukx
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
with:
version: latest
- name: Link to Production Project
run: supabase link --project-ref $SUPABASE_PROJECT_REF
- name: Apply Database Migrations
run: supabase db push
working-directory: apps/supabase
- name: Sync Edge Function Secrets
env:
# Add secrets from GitHub Secrets
REVENUECAT_WEBHOOK_SECRET: ${{ secrets.REVENUECAT_WEBHOOK_SECRET }}
REVENUECAT_API_KEY: ${{ secrets.REVENUECAT_API_KEY }}
REVENUECAT_ENTITLEMENT_ID: ${{ secrets.REVENUECAT_ENTITLEMENT_ID }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ADMIN_PRIVATE_KEY_JWK: ${{ secrets.ADMIN_PRIVATE_KEY_JWK }}
ADMIN_KEYS_JSON: ${{ secrets.ADMIN_KEYS_JSON }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
# Sync secrets to Supabase (only if they're set in GitHub)
secrets_to_set=""
[ -n "$REVENUECAT_WEBHOOK_SECRET" ] && secrets_to_set="$secrets_to_set REVENUECAT_WEBHOOK_SECRET=$REVENUECAT_WEBHOOK_SECRET"
[ -n "$REVENUECAT_API_KEY" ] && secrets_to_set="$secrets_to_set REVENUECAT_API_KEY=$REVENUECAT_API_KEY"
[ -n "$REVENUECAT_ENTITLEMENT_ID" ] && secrets_to_set="$secrets_to_set REVENUECAT_ENTITLEMENT_ID=$REVENUECAT_ENTITLEMENT_ID"
[ -n "$OPENROUTER_API_KEY" ] && secrets_to_set="$secrets_to_set OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
[ -n "$ADMIN_PRIVATE_KEY_JWK" ] && secrets_to_set="$secrets_to_set ADMIN_PRIVATE_KEY_JWK=$ADMIN_PRIVATE_KEY_JWK"
[ -n "$ADMIN_KEYS_JSON" ] && secrets_to_set="$secrets_to_set ADMIN_KEYS_JSON=$ADMIN_KEYS_JSON"
[ -n "$DISCORD_WEBHOOK_URL" ] && secrets_to_set="$secrets_to_set DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL"
if [ -n "$secrets_to_set" ]; then
echo "Syncing secrets to Supabase..."
supabase secrets set $secrets_to_set --project-ref $SUPABASE_PROJECT_REF
else
echo "No secrets to sync"
fi
- name: Deploy Edge Functions
run: |
# Deploy all edge functions (excluding _shared directory)
for dir in apps/supabase/functions/*/; do
function_name=$(basename "$dir")
# Skip _shared directory (it's a shared module, not a function)
if [ "$function_name" = "_shared" ]; then
echo "Skipping _shared (shared module)"
continue
fi
echo "Deploying function: $function_name"
supabase functions deploy "$function_name" --no-verify-jwt
done
- name: Remove Deleted Functions
run: |
# Get list of functions deployed on Supabase
deployed=$(supabase functions list --project-ref $SUPABASE_PROJECT_REF | tail -n +2 | awk '{print $1}')
# Get list of functions in the repo (excluding _shared)
local_functions=$(ls -d apps/supabase/functions/*/ 2>/dev/null | xargs -I {} basename {} | grep -v '^_shared$' || true)
# Delete any deployed functions that no longer exist in the repo
for func in $deployed; do
if ! echo "$local_functions" | grep -q "^${func}$"; then
echo "Deleting removed function: $func"
supabase functions delete "$func" --project-ref $SUPABASE_PROJECT_REF
fi
done
deploy-infrastructure:
name: Deploy Infrastructure (Terraform)
runs-on: ubuntu-latest
env:
TF_VAR_supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6"
# Non-Production Environment
- name: Terraform Init (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform init
- name: Terraform Format Check
working-directory: terraform/modules/supabase
run: terraform fmt -check
- name: Terraform Validate (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform validate
- name: Terraform Plan (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform plan -out=tfplan
- name: Terraform Apply (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform apply -auto-approve tfplan
# Production Environment
- name: Terraform Init (Prod)
working-directory: terraform/environments/prod
run: terraform init
- name: Terraform Validate (Prod)
working-directory: terraform/environments/prod
run: terraform validate
- name: Terraform Plan (Prod)
working-directory: terraform/environments/prod
run: terraform plan -out=tfplan
- name: Terraform Apply (Prod)
working-directory: terraform/environments/prod
run: terraform apply -auto-approve tfplan
+4 -1
View File
@@ -16,7 +16,10 @@ build/
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.production.local
apps/mobile/.env.prod
.secrets
# Supabase
.supabase/
+116 -1
View File
@@ -65,7 +65,38 @@ There are two Supabase projects configured via MCP servers:
When using Claude Code with MCP tools:
- Use `mcp__sauci-prod__*` tools for production database operations
- Use `mcp__sauci-non-prod__*` tools for development/staging database operations
- **Always verify which environment you're targeting before running migrations or destructive operations**
- **Always verify which environment you're targeting before running destructive operations**
### Database Migrations - IMPORTANT
**NEVER use MCP tools (`apply_migration`) for database migrations.** Always use the Supabase CLI instead.
**Why:** MCP migrations are tracked in the database but do NOT create local migration files. This means:
- Migration history is not committed to git
- Other developers won't get the schema changes
- `supabase db reset` won't include MCP-applied migrations
- Changes cannot be reproduced from scratch
**Correct workflow for schema changes:**
```bash
# 1. Create migration file locally
supabase migration new <descriptive_name>
# 2. Edit the migration file in apps/supabase/migrations/
# 3. Test locally
supabase db reset
# 4. Deploy to remote (link to correct project first)
supabase link --project-ref <project_ref>
supabase db push
```
**MCP tools ARE appropriate for:**
- Reading data (`execute_sql` for SELECT queries)
- Exploring schema (`list_tables`, `list_migrations`)
- Deploying edge functions (`deploy_edge_function`)
- Generating types (`generate_typescript_types`)
## Architecture
@@ -179,3 +210,87 @@ backgroundColor: 'rgba(18, 18, 18, 0.85)',
The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top.
**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle`
## Infrastructure as Code (Terraform)
Supabase project configuration is managed via Terraform in the `terraform/` directory. This includes auth settings, OAuth providers, email templates, SMTP config, rate limits, and more.
**Directory structure:**
```
terraform/
├── modules/
│ └── supabase/ # Shared configuration module
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── templates/ # Email templates
└── environments/
├── prod/ # Production (ckjcrkjpmhqhiucifukx)
└── non-prod/ # Non-production (itbzhrvlgvdmzbnhzhyx)
```
**When to update Terraform:**
- Changing auth settings (OAuth providers, JWT expiry, MFA settings)
- Modifying email templates or subjects
- Updating rate limits or security settings
- Changing SMTP configuration
- Modifying storage or API settings
**Workflow (always test in non-prod first):**
```bash
# 1. Apply to non-prod first
cd terraform/environments/non-prod
export TF_VAR_supabase_access_token=sbp_xxxxx
terraform plan
terraform apply
# 2. Verify changes work in non-prod
# 3. Apply to prod
cd terraform/environments/prod
terraform plan
terraform apply
```
**Important:** After making Terraform changes, remind the user to:
1. Commit the updated `terraform/` files
2. The GitHub Action will auto-apply on merge to master (non-prod first, then prod)
**Do NOT use Terraform for:**
- Database schema changes (use migrations)
- Edge function deployment (use CLI or MCP)
- Storage bucket creation (use migrations)
## Edge Function Secrets
Edge functions use secrets stored in Supabase. These are synced from GitHub Secrets during CI/CD deployment.
**Current secrets:**
| Secret | Used By | Description |
|--------|---------|-------------|
| `REVENUECAT_WEBHOOK_SECRET` | revenuecat-webhook | Webhook signature validation |
| `REVENUECAT_API_KEY` | sync-subscription | RevenueCat API access |
| `REVENUECAT_ENTITLEMENT_ID` | sync-subscription | Entitlement identifier |
| `OPENROUTER_API_KEY` | classify-message | AI classification API |
| `ADMIN_PRIVATE_KEY_JWK` | admin-decrypt-* | E2EE admin decryption |
| `ADMIN_KEYS_JSON` | admin-decrypt-*, migrate-e2ee | Multiple admin keys |
| `DISCORD_WEBHOOK_URL` | send-discord-notification | Discord notifications |
**Auto-provided by Supabase (don't set):**
- `SUPABASE_URL`
- `SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
**When adding a new edge function that needs secrets:**
1. Add the secret to the edge function code: `Deno.env.get("SECRET_NAME")`
2. Update `.github/workflows/deploy-supabase.yml` to include the new secret in the sync step
3. Update `apps/supabase/functions/.secrets.example` with the new secret
4. **Remind the user** to add the secret to GitHub Secrets (Settings → Secrets → Actions)
5. Update this table in CLAUDE.md
**Local development:**
```bash
cp apps/supabase/functions/.secrets.example apps/supabase/functions/.secrets
# Fill in values
supabase functions serve --env-file apps/supabase/functions/.secrets
```
@@ -201,6 +201,18 @@ export function ProfileScreen() {
{/* Debug Section - Dev only */}
{__DEV__ && (
<SettingsSection title="Debug" delay={400}>
<MenuItem
icon="server-outline"
label="Supabase Environment"
rightText={
process.env.EXPO_PUBLIC_SUPABASE_URL?.includes('ckjcrkjpmhqhiucifukx')
? 'Production'
: 'Non-Production'
}
onPress={() => {}}
showChevron={false}
/>
<View style={styles.divider} />
<MenuItem
icon="refresh-outline"
label="Reset Swipe Tutorial"
+1
View File
@@ -0,0 +1 @@
main
+1 -1
View File
@@ -8,7 +8,7 @@ max_rows = 1000
[db]
port = 54322
shadow_port = 54320
major_version = 15
major_version = 17
[studio]
enabled = true
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
# Edge Function Secrets
# Copy to .secrets and fill in values for local development
# These are synced to Supabase via GitHub Actions from GitHub Secrets
# RevenueCat - Subscription management
REVENUECAT_WEBHOOK_SECRET=whsec_xxxxx
REVENUECAT_API_KEY=sk_xxxxx
REVENUECAT_ENTITLEMENT_ID=Sauci Pro
# OpenRouter - AI message classification
OPENROUTER_API_KEY=sk-or-xxxxx
# Admin encryption keys - E2EE decryption (for admin tools)
ADMIN_PRIVATE_KEY_JWK={"kty":"EC","crv":"P-256",...}
ADMIN_KEYS_JSON=[{"kty":"EC","crv":"P-256",...}]
# Discord - Notifications
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxxxx
# Auto-provided by Supabase (don't set these):
# SUPABASE_URL
# SUPABASE_ANON_KEY
# SUPABASE_SERVICE_ROLE_KEY
@@ -0,0 +1,283 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const jsonHeaders = { ...corsHeaders, "Content-Type": "application/json" };
type DiscordEmbedField = {
name: string;
value: string;
inline?: boolean;
};
type DiscordEmbed = {
title?: string;
description?: string;
color?: number;
fields?: DiscordEmbedField[];
timestamp?: string;
};
type DiscordMessage = {
content?: string;
embeds?: DiscordEmbed[];
username?: string;
};
type NotificationPayload = Record<string, unknown>;
interface NotificationRequest {
event: string;
payload?: NotificationPayload;
}
const EVENT_COLORS: Record<string, number> = {
new_user: 0x2ecc71,
couple_paired: 0x3498db,
feedback_submitted: 0xf39c12,
};
function asString(value: unknown, fallback = "Unknown"): string {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}
if (value === null || value === undefined) {
return fallback;
}
return String(value);
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
if (maxLength <= 3) return value.slice(0, maxLength);
return `${value.slice(0, maxLength - 3)}...`;
}
function formatUserSummary(user: Record<string, unknown> | null | undefined): string {
if (!user) return "Unknown";
const name = asString(user.name, "Unknown");
const email = asString(user.email, "Unknown");
const id = asString(user.id, "Unknown");
return `Name: ${name}\nEmail: ${email}\nID: ${id}`;
}
function buildDiscordMessage(event: string, payload: NotificationPayload): DiscordMessage | null {
const timestamp = asString(payload.created_at, new Date().toISOString());
if (event === "new_user") {
const fields: DiscordEmbedField[] = [
{ name: "User ID", value: asString(payload.user_id), inline: false },
{ name: "Name", value: asString(payload.name), inline: true },
{ name: "Email", value: asString(payload.email), inline: true },
];
return {
content: "New user signup",
username: "Sauci Notifications",
embeds: [
{
title: "New user signup",
color: EVENT_COLORS.new_user,
fields,
timestamp,
},
],
};
}
if (event === "couple_paired") {
const profiles = Array.isArray(payload.profiles) ? payload.profiles : [];
const partnerFields = profiles.slice(0, 2).map((profile, index) => ({
name: `Partner ${index + 1}`,
value: formatUserSummary(asRecord(profile)),
inline: false,
}));
const fields: DiscordEmbedField[] = [
{ name: "Couple ID", value: asString(payload.couple_id), inline: false },
...partnerFields,
];
return {
content: "Couple paired",
username: "Sauci Notifications",
embeds: [
{
title: "Couple paired",
color: EVENT_COLORS.couple_paired,
fields,
timestamp: asString(payload.paired_at, timestamp),
},
],
};
}
if (event === "feedback_submitted") {
const user = asRecord(payload.user);
const description = truncate(asString(payload.description, "No description provided."), 900);
const fields: DiscordEmbedField[] = [
{ name: "Feedback ID", value: asString(payload.feedback_id), inline: false },
{ name: "Type", value: asString(payload.type), inline: true },
{ name: "Status", value: asString(payload.status, "new"), inline: true },
{ name: "Title", value: asString(payload.title), inline: false },
{ name: "User", value: formatUserSummary(user), inline: false },
];
const questionId = asString(payload.question_id, "");
if (questionId) {
fields.push({ name: "Question ID", value: questionId, inline: false });
}
const screenshotUrl = asString(payload.screenshot_url, "");
if (screenshotUrl) {
fields.push({ name: "Screenshot", value: screenshotUrl, inline: false });
}
return {
content: "Feedback submitted",
username: "Sauci Notifications",
embeds: [
{
title: "Feedback submitted",
description,
color: EVENT_COLORS.feedback_submitted,
fields,
timestamp,
},
],
};
}
return null;
}
async function enrichPayload(
supabase: ReturnType<typeof createClient>,
event: string,
payload: NotificationPayload
): Promise<NotificationPayload> {
const coupleId = asString(payload.couple_id, "");
if (event === "couple_paired" && coupleId && !Array.isArray(payload.profiles)) {
const { data: profiles } = await supabase
.from("profiles")
.select("id, name, email, created_at")
.eq("couple_id", coupleId)
.order("created_at", { ascending: true });
if (profiles) {
return { ...payload, profiles };
}
}
const userId = asString(payload.user_id, "");
if (event === "feedback_submitted" && userId && !payload.user) {
const { data: profile } = await supabase
.from("profiles")
.select("id, name, email")
.eq("id", userId)
.maybeSingle();
if (profile) {
return { ...payload, user: profile };
}
}
return payload;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: jsonHeaders,
});
}
const webhookUrl = Deno.env.get("DISCORD_WEBHOOK_URL");
if (!webhookUrl) {
console.warn("DISCORD_WEBHOOK_URL is not configured");
return new Response(JSON.stringify({ success: false, message: "Discord webhook not configured" }), {
status: 200,
headers: jsonHeaders,
});
}
let body: NotificationRequest;
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON payload" }), {
status: 400,
headers: jsonHeaders,
});
}
const event = asString(body.event, "");
if (!event) {
return new Response(JSON.stringify({ error: "Missing event" }), {
status: 400,
headers: jsonHeaders,
});
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const payload = await enrichPayload(supabase, event, asRecord(body.payload));
const message = buildDiscordMessage(event, payload);
if (!message) {
return new Response(JSON.stringify({ error: "Unsupported event" }), {
status: 400,
headers: jsonHeaders,
});
}
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Discord webhook error:", errorText);
return new Response(JSON.stringify({ error: "Failed to send Discord notification" }), {
status: 502,
headers: jsonHeaders,
});
}
return new Response(JSON.stringify({ success: true }), {
headers: jsonHeaders,
});
} catch (error) {
console.error("Error sending Discord notification:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: jsonHeaders,
});
}
});
@@ -11,7 +11,6 @@ CREATE TABLE redemption_codes (
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Code redemptions table
CREATE TABLE code_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -20,10 +19,8 @@ CREATE TABLE code_redemptions (
redeemed_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(code_id, user_id)
);
ALTER TABLE redemption_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE code_redemptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins with manage_codes permission can view redemption codes"
ON redemption_codes FOR SELECT
USING (has_permission('manage_codes'::text));
@@ -46,7 +43,6 @@ CREATE POLICY "Super admins can manage redemption codes"
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE POLICY "Users can view own redemptions"
ON code_redemptions FOR SELECT
USING (auth.uid() = user_id);
@@ -60,7 +56,6 @@ CREATE POLICY "Super admins can view all redemptions"
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE OR REPLACE FUNCTION public.redeem_code_by_email(p_email text, p_code text)
RETURNS jsonb
LANGUAGE plpgsql
@@ -133,6 +128,5 @@ BEGIN
RETURN jsonb_build_object('success', true, 'message', 'Code redeemed successfully! You now have premium access.');
END;
$function$;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO PUBLIC;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO anon, authenticated, service_role;
@@ -8,36 +8,29 @@ CREATE TABLE feature_interests (
-- Ensure a user can only express interest in a feature once
UNIQUE(user_id, feature_name)
);
-- Index for querying by feature
CREATE INDEX idx_feature_interests_feature_name ON feature_interests(feature_name);
-- Index for querying by user
CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id);
-- Enable RLS
ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY;
-- Users can insert their own interest
CREATE POLICY "Users can express interest in features"
ON feature_interests
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Users can view their own interests
CREATE POLICY "Users can view their own interests"
ON feature_interests
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Users can delete their own interests
CREATE POLICY "Users can remove their interest"
ON feature_interests
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- Grant service role full access for admin queries
GRANT ALL ON feature_interests TO service_role;;
GRANT ALL ON feature_interests TO service_role;
@@ -38,4 +38,4 @@ AS $$
FROM profiles p
LEFT JOIN auth.users au ON au.id = p.id
WHERE p.id = user_id;
$$;;
$$;
@@ -1,7 +1,5 @@
-- Add RLS policy for admins with gift_premium permission to insert subscriptions
CREATE POLICY "Admins with gift_premium can insert subscriptions"
ON public.subscriptions
FOR INSERT
WITH CHECK (has_permission('gift_premium'));
;
@@ -1,4 +1,3 @@
-- Create a function that admins with gift_premium permission can use to gift premium
-- This bypasses RLS issues by using SECURITY DEFINER and checking permissions internally
CREATE OR REPLACE FUNCTION admin_gift_premium(
@@ -52,7 +51,5 @@ BEGIN
RETURN v_subscription_id;
END;
$$;
-- Grant execute permission to authenticated users (the function checks permissions internally)
GRANT EXECUTE ON FUNCTION admin_gift_premium(UUID, TIMESTAMPTZ) TO authenticated;
;
@@ -14,35 +14,28 @@ CREATE TABLE notification_preferences (
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
);
-- Enable RLS
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
-- RLS policies: users can only manage their own preferences
CREATE POLICY "Users can view their own notification preferences"
ON notification_preferences FOR SELECT
USING (user_id = auth.uid());
USING (user_id = auth.uid());
CREATE POLICY "Users can insert their own notification preferences"
ON notification_preferences FOR INSERT
WITH CHECK (user_id = auth.uid());
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update their own notification preferences"
ON notification_preferences FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
WITH CHECK (user_id = auth.uid());
-- Super admins can view all preferences
CREATE POLICY "Super admins can view all notification preferences"
ON notification_preferences FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_notification_preferences_updated_at
BEFORE UPDATE ON notification_preferences
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to get notification preferences with defaults
-- Returns preferences if they exist, or creates a default row
CREATE OR REPLACE FUNCTION get_or_create_notification_preferences(p_user_id UUID)
@@ -68,7 +61,6 @@ BEGIN
RETURN v_prefs;
END;
$$;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
@@ -2,8 +2,7 @@
-- Notifies partner when a user answers questions (batched with 1-hour debounce)
-- Add last_active_at to profiles for "is user in app" check
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
-- Create pending activity notifications table
CREATE TABLE pending_activity_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -14,29 +13,23 @@ CREATE TABLE pending_activity_notifications (
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset)
);
);
-- Index for efficient cron queries
CREATE INDEX idx_pending_activity_notifications_notify_at
ON pending_activity_notifications(notify_at);
ON pending_activity_notifications(notify_at);
-- Enable RLS
ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's pending activity notifications"
ON pending_activity_notifications FOR SELECT
USING (couple_id = get_auth_user_couple_id());
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all pending activity notifications"
ON pending_activity_notifications FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_pending_activity_notifications_updated_at
BEFORE UPDATE ON pending_activity_notifications
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to queue partner activity notifications
-- Called via trigger when a response is inserted or updated
CREATE OR REPLACE FUNCTION queue_partner_activity_notification()
@@ -88,14 +81,12 @@ BEGIN
RETURN NEW;
END;
$$;
$$;
-- Create trigger on responses table
CREATE TRIGGER on_response_submitted
AFTER INSERT OR UPDATE OF answer ON responses
FOR EACH ROW
EXECUTE FUNCTION queue_partner_activity_notification();
EXECUTE FUNCTION queue_partner_activity_notification();
-- Schedule cron job (runs every 5 minutes)
SELECT cron.schedule(
'send-partner-activity-notifications',
@@ -107,4 +98,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -4,13 +4,11 @@
-- Add scheduling columns to question_packs
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS scheduled_release_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false;
ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false;
-- Index for efficient cron queries
CREATE INDEX idx_question_packs_scheduled_release
ON question_packs(scheduled_release_at)
WHERE scheduled_release_at IS NOT NULL AND is_public = false;
WHERE scheduled_release_at IS NOT NULL AND is_public = false;
-- Schedule cron job for processing scheduled releases (runs every 5 minutes)
SELECT cron.schedule(
'process-scheduled-releases',
@@ -22,4 +20,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -13,25 +13,20 @@ CREATE TABLE couple_streaks (
streak_celebrated_at INTEGER DEFAULT 0, -- Last milestone that was celebrated
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
);
-- Enable RLS
ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY;
ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's streaks"
ON couple_streaks FOR SELECT
USING (couple_id = get_auth_user_couple_id());
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all streaks"
ON couple_streaks FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_couple_streaks_updated_at
BEFORE UPDATE ON couple_streaks
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to update streak when a response is submitted
CREATE OR REPLACE FUNCTION update_couple_streak()
RETURNS TRIGGER
@@ -150,14 +145,12 @@ BEGIN
RETURN NEW;
END;
$$;
$$;
-- Create trigger on responses table
CREATE TRIGGER on_response_update_streak
AFTER INSERT OR UPDATE OF answer ON responses
FOR EACH ROW
EXECUTE FUNCTION update_couple_streak();
EXECUTE FUNCTION update_couple_streak();
-- Function to reset daily flags (called by cron at UTC midnight)
CREATE OR REPLACE FUNCTION reset_daily_streak_flags()
RETURNS void
@@ -172,8 +165,7 @@ BEGIN
user2_answered_today = false
WHERE last_active_date < CURRENT_DATE;
END;
$$;
$$;
-- Schedule cron job for daily flag reset (runs at 00:00 UTC)
SELECT cron.schedule(
'reset-daily-streak-flags',
@@ -181,8 +173,7 @@ SELECT cron.schedule(
$$
SELECT reset_daily_streak_flags();
$$
);
);
-- Schedule cron job for checking streak milestones (runs at 00:05 UTC)
SELECT cron.schedule(
'check-streak-milestones',
@@ -194,4 +185,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -0,0 +1,221 @@
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id uuid DEFAULT NULL::uuid)
RETURNS TABLE(id uuid, text text, partner_text text, is_two_part boolean, pack_id uuid, intensity integer, partner_answered boolean, allowed_couple_genders text[], target_user_genders text[])
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
v_gap_threshold INTEGER;
v_my_unanswered INTEGER;
v_partner_unanswered INTEGER;
v_net_gap INTEGER;
v_user_max_intensity INTEGER;
v_partner_max_intensity INTEGER;
v_effective_max_intensity INTEGER;
v_use_couple_intensity_gate BOOLEAN;
v_daily_limit INTEGER;
v_daily_count INTEGER;
v_today_start TIMESTAMPTZ;
v_tomorrow_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_couple_id, v_user_gender, v_user_max_intensity
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_partner_id, v_partner_gender, v_partner_max_intensity
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Toggle: gate questions by couple-effective intensity
SELECT COALESCE(ac.couple_intensity_gate_enabled, FALSE)
INTO v_use_couple_intensity_gate
FROM app_config ac
LIMIT 1;
v_use_couple_intensity_gate := COALESCE(v_use_couple_intensity_gate, FALSE);
v_effective_max_intensity := CASE
WHEN v_use_couple_intensity_gate AND v_partner_id IS NOT NULL THEN LEAST(v_user_max_intensity, v_partner_max_intensity)
ELSE v_user_max_intensity
END;
-- Check premium access (used for both answer gap and daily limit)
v_has_premium := public.has_premium_access(v_user_id);
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
FROM app_config ac
LIMIT 1;
IF v_daily_limit > 0 THEN
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
v_tomorrow_start := v_today_start + INTERVAL '1 day';
SELECT COUNT(*)::INTEGER INTO v_daily_count
FROM responses r
WHERE r.user_id = v_user_id
AND r.created_at >= v_today_start
AND r.created_at < v_tomorrow_start;
IF v_daily_count >= v_daily_limit THEN
RETURN;
END IF;
END IF;
END IF;
-- Check answer gap threshold using NET gap (only when not browsing a specific pack)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
FROM app_config ac
LIMIT 1;
IF v_gap_threshold > 0 THEN
WITH enabled_packs AS (
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
) THEN 1 ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_user_id
) THEN 1 ELSE 0 END), 0)::integer
INTO v_my_unanswered, v_partner_unanswered
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.couple_id = v_couple_id
AND r.user_id IN (v_user_id, v_partner_id)
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
AND (
NOT v_use_couple_intensity_gate
OR q.intensity IS NULL
OR q.intensity <= v_effective_max_intensity
);
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
IF v_net_gap >= v_gap_threshold THEN
RETURN;
END IF;
END IF;
END IF;
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
IF v_user_gender < v_partner_gender THEN
v_couple_type := v_user_gender || '+' || v_partner_gender;
ELSE
v_couple_type := v_partner_gender || '+' || v_user_gender;
END IF;
END IF;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
)
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered,
q.allowed_couple_genders,
q.target_user_genders
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND (
q.target_user_genders IS NULL
OR v_user_gender = ANY(q.target_user_genders)
OR EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
)
)
AND (
q.intensity IS NULL
OR q.intensity <= v_effective_max_intensity
)
ORDER BY
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) DESC,
q.intensity ASC,
q.id ASC; -- ADDED: Deterministic tiebreaker to ensure both partners see questions in same order
END;
$function$;
@@ -0,0 +1,467 @@
-- Sync migration: Make idempotent for production
-- Extensions
create extension if not exists "hypopg" with schema "extensions";
create extension if not exists "index_advisor" with schema "extensions";
-- Drop policy if exists (will be recreated at the end)
drop policy if exists "Anyone can view questions in visible packs" on "public"."questions";
-- Drop and recreate constraint (idempotent)
alter table "public"."question_packs" drop constraint if exists "question_packs_category_id_fkey";
-- Note: pack_question_stats view is NOT recreated here.
-- The original migration (20260111100000_add_inverse_of_to_questions.sql) creates it correctly.
-- Recreating it causes persistent diff noise due to pg_dump formatting differences.
-- Create tables if not exists
create table if not exists "public"."pack_topics" (
"pack_id" uuid not null,
"topic_id" uuid not null
);
alter table "public"."pack_topics" enable row level security;
create table if not exists "public"."topics" (
"id" uuid not null default gen_random_uuid(),
"name" text not null,
"description" text,
"icon" text,
"sort_order" integer default 0,
"created_at" timestamp with time zone default now()
);
alter table "public"."topics" enable row level security;
-- Add columns if not exists
alter table "public"."admin_users" add column if not exists "permissions" jsonb default '[]'::jsonb;
alter table "public"."admin_users" disable row level security;
alter table "public"."ai_config" add column if not exists "cherry_pick_ensure_intensity_distribution" boolean default true;
alter table "public"."ai_config" add column if not exists "council_selection_mode" text default 'whole_set'::text;
alter table "public"."ai_config" add column if not exists "heuristic_keyword_triggers" text;
alter table "public"."ai_config" add column if not exists "heuristic_min_text_length" integer not null default 12;
alter table "public"."ai_config" add column if not exists "heuristic_record_reason" boolean not null default false;
alter table "public"."ai_config" add column if not exists "heuristic_skip_if_no_alnum" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_skip_media_without_text" boolean not null default false;
alter table "public"."ai_config" add column if not exists "heuristic_use_default_keywords" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_use_default_whitelist" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_whitelist" text;
alter table "public"."ai_config" add column if not exists "heuristic_whitelist_max_length" integer not null default 30;
alter table "public"."ai_config" add column if not exists "heuristics_enabled" boolean not null default false;
-- Create indexes if not exists
CREATE INDEX IF NOT EXISTS idx_pack_topics_pack_id ON public.pack_topics USING btree (pack_id);
CREATE UNIQUE INDEX IF NOT EXISTS pack_topics_pkey ON public.pack_topics USING btree (pack_id, topic_id);
CREATE UNIQUE INDEX IF NOT EXISTS topics_name_key ON public.topics USING btree (name);
CREATE UNIQUE INDEX IF NOT EXISTS topics_pkey ON public.topics USING btree (id);
-- Add constraints if not exists (check existence first)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_pkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_pkey" PRIMARY KEY USING INDEX "pack_topics_pkey";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'topics_pkey') THEN
ALTER TABLE "public"."topics" ADD CONSTRAINT "topics_pkey" PRIMARY KEY USING INDEX "topics_pkey";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_config_council_selection_mode_check') THEN
ALTER TABLE "public"."ai_config" ADD CONSTRAINT "ai_config_council_selection_mode_check"
CHECK ((council_selection_mode = ANY (ARRAY['whole_set'::text, 'cherry_pick'::text])));
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_pack_id_fkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_pack_id_fkey"
FOREIGN KEY (pack_id) REFERENCES public.question_packs(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_topic_id_fkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_topic_id_fkey"
FOREIGN KEY (topic_id) REFERENCES public.topics(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'topics_name_key') THEN
ALTER TABLE "public"."topics" ADD CONSTRAINT "topics_name_key" UNIQUE USING INDEX "topics_name_key";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'question_packs_category_id_fkey') THEN
ALTER TABLE "public"."question_packs" ADD CONSTRAINT "question_packs_category_id_fkey"
FOREIGN KEY (category_id) REFERENCES public.categories(id) ON DELETE CASCADE;
END IF;
END $$;
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.get_profiles_with_auth_info()
RETURNS TABLE(id uuid, name text, email text, avatar_url text, is_premium boolean, couple_id uuid, created_at timestamp with time zone, last_sign_in_at timestamp with time zone, email_confirmed_at timestamp with time zone)
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
p.id,
COALESCE(p.name, au.raw_user_meta_data->>'name') as name,
COALESCE(p.email, au.email) as email,
p.avatar_url,
p.is_premium,
p.couple_id,
p.created_at,
au.last_sign_in_at,
au.email_confirmed_at
FROM profiles p
LEFT JOIN auth.users au ON au.id = p.id
ORDER BY p.created_at DESC;
$function$
;
CREATE OR REPLACE FUNCTION public.get_user_media_files(user_id uuid)
RETURNS TABLE(id uuid, name text, bucket_id text, created_at timestamp with time zone, metadata jsonb)
LANGUAGE sql
SECURITY DEFINER
AS $function$
SELECT
id,
name,
bucket_id,
created_at,
metadata
FROM storage.objects
WHERE bucket_id = 'chat-media'
AND owner = user_id
ORDER BY created_at DESC;
$function$
;
CREATE OR REPLACE FUNCTION public.get_user_storage_usage()
RETURNS TABLE(owner uuid, total_bytes bigint)
LANGUAGE sql
SECURITY DEFINER
AS $function$
SELECT
owner,
SUM((metadata->>'size')::bigint) as total_bytes
FROM storage.objects
WHERE bucket_id = 'chat-media'
AND owner IS NOT NULL
GROUP BY owner;
$function$
;
CREATE OR REPLACE FUNCTION public.cleanup_expired_videos()
RETURNS TABLE(deleted_count integer, deleted_paths text[])
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
expired_paths text[];
count int;
BEGIN
SELECT array_agg(media_path)
INTO expired_paths
FROM messages
WHERE media_type = 'video'
AND media_expires_at IS NOT NULL
AND media_expires_at < NOW()
AND media_expired = false
AND media_path IS NOT NULL;
WITH updated AS (
UPDATE messages
SET media_expired = true, media_path = NULL
WHERE media_type = 'video'
AND media_expires_at IS NOT NULL
AND media_expires_at < NOW()
AND media_expired = false
AND media_path IS NOT NULL
RETURNING 1
)
SELECT count(*) INTO count FROM updated;
RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]);
END;
$function$
;
CREATE OR REPLACE FUNCTION public.get_pack_unique_question_count(pack_uuid uuid)
RETURNS integer
LANGUAGE sql
STABLE
AS $function$
SELECT COUNT(*)::INTEGER
FROM questions
WHERE pack_id = pack_uuid
AND inverse_of IS NULL
AND deleted_at IS NULL;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
INSERT INTO public.profiles (id, created_at, updated_at, onboarding_completed)
VALUES (NEW.id, NOW(), NOW(), false);
RETURN NEW;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.notify_new_message()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
BEGIN
PERFORM net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-message-notification',
headers := jsonb_build_object(
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'match_id', NEW.match_id,
'sender_id', NEW.user_id
)
);
RETURN NEW;
END;
$function$
;
-- pack_question_stats view creation removed - see comment at top of file
grant delete on table "public"."pack_topics" to "anon";
grant insert on table "public"."pack_topics" to "anon";
grant references on table "public"."pack_topics" to "anon";
grant select on table "public"."pack_topics" to "anon";
grant trigger on table "public"."pack_topics" to "anon";
grant truncate on table "public"."pack_topics" to "anon";
grant update on table "public"."pack_topics" to "anon";
grant delete on table "public"."pack_topics" to "authenticated";
grant insert on table "public"."pack_topics" to "authenticated";
grant references on table "public"."pack_topics" to "authenticated";
grant select on table "public"."pack_topics" to "authenticated";
grant trigger on table "public"."pack_topics" to "authenticated";
grant truncate on table "public"."pack_topics" to "authenticated";
grant update on table "public"."pack_topics" to "authenticated";
grant delete on table "public"."pack_topics" to "service_role";
grant insert on table "public"."pack_topics" to "service_role";
grant references on table "public"."pack_topics" to "service_role";
grant select on table "public"."pack_topics" to "service_role";
grant trigger on table "public"."pack_topics" to "service_role";
grant truncate on table "public"."pack_topics" to "service_role";
grant update on table "public"."pack_topics" to "service_role";
grant delete on table "public"."topics" to "anon";
grant insert on table "public"."topics" to "anon";
grant references on table "public"."topics" to "anon";
grant select on table "public"."topics" to "anon";
grant trigger on table "public"."topics" to "anon";
grant truncate on table "public"."topics" to "anon";
grant update on table "public"."topics" to "anon";
grant delete on table "public"."topics" to "authenticated";
grant insert on table "public"."topics" to "authenticated";
grant references on table "public"."topics" to "authenticated";
grant select on table "public"."topics" to "authenticated";
grant trigger on table "public"."topics" to "authenticated";
grant truncate on table "public"."topics" to "authenticated";
grant update on table "public"."topics" to "authenticated";
grant delete on table "public"."topics" to "service_role";
grant insert on table "public"."topics" to "service_role";
grant references on table "public"."topics" to "service_role";
grant select on table "public"."topics" to "service_role";
grant trigger on table "public"."topics" to "service_role";
grant truncate on table "public"."topics" to "service_role";
grant update on table "public"."topics" to "service_role";
-- Policies: drop if exists then create
drop policy if exists "Admins can view couple pack settings" on "public"."couple_packs";
create policy "Admins can view couple pack settings"
on "public"."couple_packs"
as permissive
for select
to public
using ((public.is_super_admin() OR public.has_permission('view_users'::text)));
drop policy if exists "Admins can delete pack_topics" on "public"."pack_topics";
create policy "Admins can delete pack_topics"
on "public"."pack_topics"
as permissive
for delete
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can insert pack_topics" on "public"."pack_topics";
create policy "Admins can insert pack_topics"
on "public"."pack_topics"
as permissive
for insert
to authenticated
with check ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can update pack_topics" on "public"."pack_topics";
create policy "Admins can update pack_topics"
on "public"."pack_topics"
as permissive
for update
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Pack topics are viewable by authenticated users" on "public"."pack_topics";
create policy "Pack topics are viewable by authenticated users"
on "public"."pack_topics"
as permissive
for select
to authenticated
using (true);
drop policy if exists "Admins can delete topics" on "public"."topics";
create policy "Admins can delete topics"
on "public"."topics"
as permissive
for delete
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can insert topics" on "public"."topics";
create policy "Admins can insert topics"
on "public"."topics"
as permissive
for insert
to authenticated
with check ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can update topics" on "public"."topics";
create policy "Admins can update topics"
on "public"."topics"
as permissive
for update
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Topics are viewable by authenticated users" on "public"."topics";
create policy "Topics are viewable by authenticated users"
on "public"."topics"
as permissive
for select
to authenticated
using (true);
-- This policy was dropped at the start, recreate it
create policy "Anyone can view questions in visible packs"
on "public"."questions"
as permissive
for select
to public
using (((deleted_at IS NULL) AND (pack_id IN ( SELECT question_packs.id
FROM public.question_packs
WHERE (((question_packs.is_public = true) OR ((question_packs.is_premium = true) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.is_premium = true)))))) AND ((question_packs.category_id IS NULL) OR (EXISTS ( SELECT 1
FROM public.categories c
WHERE ((c.id = question_packs.category_id) AND (c.is_public = true))))))))));
-- Storage policies: drop if exists then create
drop policy if exists "Anyone can view avatars" on "storage"."objects";
create policy "Anyone can view avatars"
on "storage"."objects"
as permissive
for select
to public
using ((bucket_id = 'avatars'::text));
drop policy if exists "Users can delete own avatar" on "storage"."objects";
create policy "Users can delete own avatar"
on "storage"."objects"
as permissive
for delete
to public
using (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
drop policy if exists "Users can update own avatar" on "storage"."objects";
create policy "Users can update own avatar"
on "storage"."objects"
as permissive
for update
to public
using (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
drop policy if exists "Users can upload own avatar" on "storage"."objects";
create policy "Users can upload own avatar"
on "storage"."objects"
as permissive
for insert
to public
with check (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Run content seed against non-production database
#
# Usage: ./run-seed-nonprod.sh
#
# You'll be prompted for the database password.
# Get it from: Supabase Dashboard > Settings > Database > Database password
set -e
# Non-production database details
HOST="aws-0-eu-west-1.pooler.supabase.com"
PORT="6543"
USER="postgres.itbzhrvlgvdmzbnhzhyx"
DB="postgres"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SEED_FILE="$SCRIPT_DIR/content-seed.sql"
if [ ! -f "$SEED_FILE" ]; then
echo "Error: Seed file not found at $SEED_FILE"
exit 1
fi
echo "=== Running content seed against Sauci Non-Production ==="
echo "Host: $HOST"
echo "Database: $DB"
echo ""
echo "This will REPLACE all content (categories, topics, question_packs, questions, dare_packs, dares, pack_topics)"
echo ""
read -p "Continue? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
echo "Running seed file (2782 lines)..."
echo ""
psql "postgresql://$USER@$HOST:$PORT/$DB?sslmode=require" -f "$SEED_FILE"
echo ""
echo "=== Done! ==="
+185
View File
@@ -0,0 +1,185 @@
-- Sync production content to non-production
-- Generated from production database
BEGIN;
-- Clear existing content (in reverse dependency order)
DELETE FROM pack_topics;
DELETE FROM questions;
DELETE FROM dares;
DELETE FROM question_packs;
DELETE FROM dare_packs;
DELETE FROM topics;
DELETE FROM categories;
-- Insert categories
INSERT INTO categories (id, name, description, icon, sort_order, created_at, is_public) VALUES
('c1000000-0000-0000-0000-000000000002', 'Adventure & Travel', 'Explore the world together and create unforgettable memories', 'map-outline', 0, '2025-12-30 20:03:27.663523+00', true),
('fcb36b61-6081-4bf6-9267-1ba9ba75fc08', 'Quality Time', NULL, 'chatbubbles-outline', 1, '2026-01-10 13:27:22.867947+00', true),
('ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da', 'Social Life', 'Figure out how you both like to spend time with others. From quiet dinners to big parties.', 'people-outline', 2, '2026-01-10 18:11:08.278631+00', true),
('ecabc509-9fed-48cb-bd60-c5d830d2491d', 'Long Distance', 'Stay connected and build intimacy when you''re miles apart.', 'airplane-outline', 3, '2026-01-10 16:23:37.389445+00', true),
('9eb47dce-d343-4e24-8402-252f188db2f2', 'Getting Started', 'Discover your compatibility and establish boundaries together', 'flag-outline', 4, '2026-01-10 20:59:35.817421+00', true),
('c1000000-0000-0000-0000-000000000004', 'Romance & Sensuality', 'Deepen your romantic and sensual connection', 'heart-outline', 5, '2025-12-30 20:03:27.663523+00', true),
('1e52e0b2-f853-4b99-9ca8-7231adbc639e', 'Sensual Discovery', 'Discover playful touch and tender moments of connection.', 'compass-outline', 6, '2026-01-10 14:11:33.114458+00', true),
('c1000000-0000-0000-0000-000000000005', 'Fantasy Exploration', 'Discover and share your secret desires', 'moon-outline', 7, '2025-12-30 20:03:27.663523+00', false),
('c1000000-0000-0000-0000-000000000007', 'Bedroom Adventures', 'Explore new ways to connect intimately', 'bed-outline', 8, '2025-12-30 20:03:27.663523+00', false),
('c1000000-0000-0000-0000-000000000006', 'Spicy Challenges', 'Push boundaries with exciting dares and activities', 'flame-outline', 9, '2025-12-30 20:03:27.663523+00', false),
('fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', 'Toy Time', 'Discover playful accessories to spice up your romance.', 'gift-outline', 10, '2026-01-10 14:12:04.292251+00', false),
('d1000000-0000-0000-0000-000000000001', 'Roleplay', 'Step into character and explore fantasies together', 'color-wand-outline', 11, '2026-01-10 22:35:42.743811+00', true),
('46dc3730-703b-4f79-82e5-3e23d0a778d7', 'The Kink Lab', 'A curious, judgment-free space to experiment with new kinks, toys, and physical sensations.', 'flask-outline', 12, '2026-01-01 10:20:07.042855+00', true),
('57da058a-da7f-48d7-9738-30573ad9cb5d', 'Public Thrills', 'Take the excitement outside the bedroom. From subtle touches to risky adventures.', 'eye-outline', 13, '2026-01-10 16:32:50.303497+00', true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
is_public = EXCLUDED.is_public;
-- Insert topics
INSERT INTO topics (id, name, description, icon, sort_order, created_at) VALUES
('d62fe605-7cc7-4df1-803f-dc3c49ed389c', 'Oral', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('f0c04e96-7d0e-4e11-8a04-fd81a458ed0f', 'Exhibitionism', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('8b7c6e32-22bc-4218-b0c8-10cc24357be4', 'Power Play', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('1e9d6cf6-897f-47e6-acd7-50198872cee7', 'Teasing', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('02df8c3f-2db4-4be6-bcc2-d83b319c80b1', 'Adventure', NULL, NULL, 0, '2025-12-31 15:48:27.666812+00'),
('92490542-9e98-4f23-ad17-775ead57c4c5', 'Sensory Play', NULL, NULL, 0, '2026-01-01 09:26:13.984335+00'),
('e832c389-8ed7-4807-8942-2a7aca979aca', 'Denial', NULL, NULL, 0, '2026-01-01 09:26:13.984335+00'),
('e9cce268-e378-4a62-accf-1d719a574ba2', 'Public Play', NULL, NULL, 0, '2026-01-10 14:34:39.115419+00'),
('10a437f9-103b-404f-8af0-f578adbe3eb3', 'Toys', NULL, NULL, 0, '2026-01-10 16:03:38.586861+00'),
('70dac37c-93e2-43d7-9ea8-14c4a88fbbdd', 'Bondage', NULL, NULL, 0, '2026-01-10 16:05:40.363422+00'),
('d6bc3fcb-d095-4eac-b28b-dbd45fb769c3', 'Long Distance', NULL, NULL, 0, '2026-01-10 16:27:19.423618+00'),
('9fe84cc4-1826-4920-8d67-9f4ae8692e2b', 'Romance', NULL, NULL, 0, '2026-01-10 16:27:19.423618+00'),
('28cef553-ea0d-4dfa-8515-ddc29e003e73', 'Communication', NULL, NULL, 0, '2026-01-10 16:27:19.423618+00'),
('a4fe63e3-efb8-4351-89a8-3f4bfb531a45', 'Voyeurism', NULL, NULL, 0, '2026-01-10 16:27:46.907948+00'),
('66932305-7888-4272-8b53-7c550e96429a', 'Socializing', NULL, NULL, 0, '2026-01-10 18:12:45.879724+00'),
('2386f22d-5d13-444a-add1-5d7c21fb6fd2', 'Lifestyle', NULL, NULL, 0, '2026-01-10 18:12:45.879724+00'),
('fa537922-46b0-4e64-a69a-174a83898983', 'Role Play', NULL, NULL, 0, '2026-01-10 18:36:50.496586+00'),
('1e2fad8b-0543-4d30-bee2-59478d2872c0', 'Dirty Talk', NULL, NULL, 0, '2026-01-10 18:37:09.687791+00'),
('22eefbc4-49f4-40b5-bf4b-532116678fbd', 'Anal', NULL, NULL, 0, '2026-01-10 18:37:18.866239+00'),
('a3aabe8d-d7b4-4b01-bb45-425a1187dbe5', 'Sex Positions', NULL, NULL, 0, '2026-01-10 18:37:21.669201+00'),
('b001c5f4-50a5-42d4-bbf8-17ba325da5c3', 'Massage', NULL, NULL, 0, '2026-01-10 18:37:44.145124+00'),
('ac37e45d-ed51-4205-ab02-c2632824022f', 'Acts of Service', NULL, NULL, 0, '2026-01-10 21:54:22.972186+00'),
('1618b36a-9130-4e95-b4e5-ac8f3da2ed17', 'Medical Play', NULL, NULL, 0, '2026-01-11 16:15:47.190027+00'),
('4ccfd2d4-d670-42da-91e0-c7681099d9d3', 'Breeding', NULL, NULL, 0, '2026-01-11 16:15:47.190027+00')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order;
-- Insert question_packs
INSERT INTO question_packs (id, name, description, icon, is_premium, is_public, sort_order, created_at, category_id, is_explicit, min_intensity, max_intensity, avg_intensity, scheduled_release_at, release_notified) VALUES
('44295bd5-fda3-4ed6-8aa6-d5c7e811348e', 'Intimate Games', 'Playful activities for the bedroom', 'dice-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000007', true, 3, 5, 3.79, NULL, false),
('42c00d6c-ab1f-42ce-afb0-00839064263b', 'Romantic Gestures', 'Sweet and intimate ways to show love', 'flower-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', false, 2, 4, 2.63, NULL, false),
('d18f6d4a-4a32-4687-8938-2a53a7d82739', 'Starting Out', 'Perfect for beginners, this playful pack introduces simple, romantic ways to explore with toys.', 'rocket-outline', false, true, 0, '2026-01-10 14:12:51.542066+00', 'fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', true, 3, 5, 3.42, NULL, false),
('8edf2abc-dbb5-4a11-ac5c-15ee60aef3b4', 'Forbidden Fantasy Dates', 'Act out bold scenarios like doctor/patient or boss/assistant in immersive, role-play evenings. Dive into uninhibited storytelling for unforgettable nights.', 'color-wand-outline', false, false, 0, '2026-01-01 22:05:09.528276+00', '46dc3730-703b-4f79-82e5-3e23d0a778d7', true, 2, 5, 4.13, NULL, false),
('fd6f2a49-1057-4f30-aab4-7cd185764f7e', 'Staying Close', 'Real daily connection for when you''re apart. Video calls, voice notes, and shared moments.', 'link-outline', false, true, 0, '2026-01-10 16:23:43.207456+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', false, 1, 1, 1.00, NULL, false),
('877d8a39-5229-4263-bffc-d3043015603f', 'Finding Your Vibe', 'Discover what social activities you both enjoy - from quiet nights to big parties.', 'musical-notes-outline', false, true, 0, '2026-01-10 18:11:18.16816+00', 'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da', false, 1, 1, 1.00, NULL, false),
('91dacfed-46ae-462d-aae3-5e540fe4a973', 'Weekend Warriors', 'Local adventures for your days off', 'car-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000002', false, 1, 2, 1.02, NULL, false),
('d2000000-0000-0000-0000-000000000001', 'First Impressions', 'Light roleplay scenarios: strangers, flirty encounters, and playful power dynamics', 'sparkles-outline', false, true, 0, '2026-01-10 22:35:42.743811+00', 'd1000000-0000-0000-0000-000000000001', false, 2, 3, 2.77, NULL, false),
('8da8dcb1-0b9b-4828-9a30-199758b8ade5', 'Date Night Ideas', NULL, 'wine-outline', false, true, 0, '2026-01-10 13:28:59.92781+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, 1, 2, 1.05, NULL, false),
('dd143c55-c0a4-4884-a704-1d4d1325cbc2', 'Pleasure Experiments', 'Try something new together', 'flask-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000006', true, 2, 5, 3.33, NULL, false),
('f0f597b1-889c-4537-beeb-d0f459bb7907', 'Body Mapping', 'Discover where your partner loves to be touched', 'body-outline', false, true, 0, '2026-01-10 19:55:02.290269+00', '1e52e0b2-f853-4b99-9ca8-7231adbc639e', false, 2, 3, 2.18, NULL, false),
('3c17764d-dd11-4103-8a4f-ce6561fd2a1a', 'Boundary Discovery', 'Safely explore new territory together', 'key-outline', false, true, 0, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 2, 5, 3.40, NULL, false),
('d5d1b8d2-a868-4150-86bc-da3f8e3e8b00', 'Testing the Waters', 'Subtle thrills in public. Whispered words, secret touches, and hidden tension.', 'water-outline', false, true, 0, '2026-01-10 16:32:57.111957+00', '57da058a-da7f-48d7-9738-30573ad9cb5d', true, 2, 3, 2.27, NULL, false),
('c7244576-f40d-4cdf-804a-788d2b9c1fce', 'Kink Discovery', 'Figure out what you''re both into. Match on interests, then explore the ones that excite you both.', 'compass-outline', true, true, 1, '2026-01-10 18:28:28.212356+00', '46dc3730-703b-4f79-82e5-3e23d0a778d7', true, 2, 5, 4.22, NULL, false),
('bed94ed6-c065-4ff9-9d7f-02de6cade971', 'Sensory Play', 'Heighten your senses and sensations', 'eye-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000007', true, 3, 5, 3.73, NULL, false),
('1d2ed912-1429-4045-a608-35b8db63f9b4', 'Food & Culture', 'Culinary adventures and cultural experiences', 'restaurant-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000002', false, 1, 1, 1.00, NULL, false),
('40838dee-0af4-43c7-9d6c-6228d09f91bc', 'Dream Scenarios', 'The wildest what-ifs you can imagine', 'cloudy-outline', true, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 3, 5, 4.10, NULL, false),
('eb3cbe67-23d8-4c0e-9c6f-dc1583609a26', 'Sensual Touch', 'Explore physical connection and intimacy', 'sparkles-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', true, 2, 4, 2.94, NULL, false),
('374e10a6-6ae9-45c6-bee4-b8be0e617b79', 'Leveling Up Together', 'Take your toy play to the next level with remote-controlled fun, edging, temperature play, and creative combinations.', 'trending-up-outline', true, true, 1, '2026-01-10 16:02:28.36573+00', 'fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', true, 3, 5, 4.12, NULL, false),
('8a1e8650-a192-4f2e-86b0-0ec7bf6fb61c', 'Sensual Massage', 'Learn to relax and arouse your partner through intentional touch', 'hand-left-outline', false, true, 1, '2026-01-10 19:55:02.290269+00', '1e52e0b2-f853-4b99-9ca8-7231adbc639e', false, 2, 3, 2.67, NULL, false),
('19e4ddbd-a4aa-4b26-80bb-53b7187f0eba', 'Missing You', 'Flirty messages, longing, and keeping the spark alive from afar.', 'airplane-outline', false, true, 1, '2026-01-10 16:24:07.455539+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', false, 1, 3, 1.92, NULL, false),
('e11bdfea-ab07-46a9-952d-5c00c8b4ddbb', 'Everyday Moments', 'Small daily rituals and micro-moments that strengthen your bond.', 'sunny-outline', false, true, 1, '2026-01-10 20:21:32.792634+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, 1, 1, 1.00, NULL, false),
('d6128ed8-e209-43db-88eb-e9d0115ba8d7', 'Hosting', 'Find your style for having people over - from casual hangs to dinner parties.', 'home-outline', false, true, 1, '2026-01-10 18:16:36.025997+00', 'ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da', false, 1, 1, 1.00, NULL, false),
('7eabdfae-e70d-4be8-9b10-b43fee955312', 'Know Your Limits', 'Discover where you both stand on the basics - from oral to anal to toys and beyond', 'shield-outline', false, true, 1, '2026-01-10 20:59:45.895706+00', '9eb47dce-d343-4e24-8402-252f188db2f2', true, 3, 5, 3.74, NULL, false),
('b2df6b3b-4bb5-4baa-a29c-a3955187511c', 'Tease & Tension', 'Build anticipation and desire', 'flame-outline', false, true, 1, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000006', true, 2, 5, 3.43, NULL, false),
('d2000000-0000-0000-0000-000000000002', 'Fantasy Scenarios', 'Classic fantasy roleplay: professional encounters with a sexy twist', 'cloud-outline', false, true, 1, '2026-01-10 22:35:42.743811+00', 'd1000000-0000-0000-0000-000000000001', true, 3, 4, 3.63, NULL, false),
('42331f68-60f5-4768-af26-b42c4e96c867', 'Pushing Limits', 'Getting bolder. Hidden touches, risky locations, and the thrill of almost getting caught.', 'fitness-outline', false, true, 1, '2026-01-10 16:33:31.946182+00', '57da058a-da7f-48d7-9738-30573ad9cb5d', true, 2, 4, 3.40, NULL, false),
('8bf628d0-2a83-4a12-a66b-39b0d6ff1e86', 'Private Line', 'When the late night calls get explicit. Sexting, phone sex, and video intimacy.', 'chatbubble-outline', true, true, 2, '2026-01-10 16:26:51.369772+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', true, 2, 4, 3.38, NULL, false),
('0f2b3400-05d4-40db-b8fa-41c92ab877eb', 'Outdoor Explorers', 'Nature adventures and outdoor activities', 'map-outline', false, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000002', false, 1, 1, 1.00, NULL, false),
('f0a2e7e5-554c-49c4-aa15-42d83b016349', 'Dare to Play', 'Bold intimate dares for adventurous couples', 'dice-outline', true, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000006', true, 2, 5, 3.87, NULL, false),
('035c47a6-858c-46fb-8ee3-84889399dca0', 'Position Exploration', 'New ways to connect physically', 'sync-outline', true, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000007', true, 3, 5, 3.82, NULL, false),
('9049a67a-a103-4ea3-8405-87dcaefc57ab', 'All Eyes On Us', 'High risk, high reward. Sex in risky places and the thrill of being caught.', 'glasses-outline', true, true, 2, '2026-01-10 16:34:06.362741+00', '57da058a-da7f-48d7-9738-30573ad9cb5d', true, 4, 5, 4.66, NULL, false),
('379d6a44-60a3-4ec9-98cc-eda46547cb23', 'Role Play Ideas', 'Characters and scenarios to explore together', 'color-wand-outline', true, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 2, 5, 4.13, NULL, false),
('46288aa4-271c-44cf-bfe2-d25662d3f9f8', 'Date Night Sparks', 'Turn up the heat on your evenings out', 'wine-outline', false, true, 2, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', true, 2, 4, 2.76, NULL, false),
('f9a59905-50d3-442d-b799-ff64f5b6fd40', 'BDSM', 'A playful guide to exploring your BDSM curiosities together.', 'lock-closed-outline', false, false, 2, '2026-01-10 14:59:13.196109+00', '46dc3730-703b-4f79-82e5-3e23d0a778d7', true, 4, 5, 4.80, NULL, false),
('d2000000-0000-0000-0000-000000000003', 'Deep in Character', 'Advanced power dynamics and intense roleplay scenarios for experienced couples', 'people-outline', true, true, 2, '2026-01-10 22:35:42.743811+00', 'd1000000-0000-0000-0000-000000000001', true, 4, 5, 4.43, NULL, false),
('48150d71-bf95-49b0-924f-e9bcff63fc03', 'Relationship Essentials', 'The foundation of us: exploring the essentials of a healthy, happy relationship.', 'heart-half-outline', false, true, 2, '2026-01-04 14:10:46.042555+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, 1, 1, 1.00, NULL, false),
('d73e9818-4faa-4276-9a93-7c60cff9cdb1', 'The Deep End', 'Advanced toy play for experienced couples: bondage furniture, electro-stim, machines, extended edging, and intense power dynamics.', 'water-outline', true, true, 2, '2026-01-10 16:09:27.096652+00', 'fc86d5e1-5fd2-4f21-9d59-1a3e701aab02', true, 4, 5, 4.82, NULL, false),
('e5aefe29-1ada-4fe5-a4ee-25342917f02c', 'Secret Desires', 'Share your hidden fantasies with your partner', 'eye-off-outline', true, true, 3, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000005', true, 2, 4, 3.09, NULL, false),
('aea8bb8f-de02-41ad-bd1f-7616780dde30', 'Weekend Adventures', 'A playful weekend getaway with fun activities for two.', 'walk-outline', false, false, 3, '2026-01-10 14:09:45.123904+00', 'fcb36b61-6081-4bf6-9267-1ba9ba75fc08', false, NULL, NULL, NULL, NULL, false),
('07f0da41-8ef7-4a48-8ec9-cef2dabaee73', 'Love Notes', 'Express your deepest feelings', 'mail-outline', false, true, 3, '2025-12-30 20:04:38.43756+00', 'c1000000-0000-0000-0000-000000000004', true, 2, 4, 2.80, NULL, false),
('7951e8c4-c7d4-4460-a3f5-495ccf5ca0cb', 'Total Control', 'Remote toys, edging, and power dynamics from miles away.', 'hand-right-outline', true, true, 3, '2026-01-10 16:28:54.328298+00', 'ecabc509-9fed-48cb-bd60-c5d830d2491d', true, 4, 5, 4.76, NULL, false),
('1a138cfd-a8be-46c3-b5da-41e7b57e5ace', 'Language Exploration', 'Explore the power of words and how you express your affection, both in quiet moments and your most intimate moments.', 'chatbubbles-outline', true, false, 4, '2026-01-10 15:34:18.481318+00', 'c1000000-0000-0000-0000-000000000005', true, 3, 5, 3.93, NULL, false)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
is_premium = EXCLUDED.is_premium,
is_public = EXCLUDED.is_public,
sort_order = EXCLUDED.sort_order,
category_id = EXCLUDED.category_id,
is_explicit = EXCLUDED.is_explicit,
min_intensity = EXCLUDED.min_intensity,
max_intensity = EXCLUDED.max_intensity,
avg_intensity = EXCLUDED.avg_intensity;
-- Insert dare_packs
INSERT INTO dare_packs (id, name, description, icon, is_premium, is_public, is_explicit, sort_order, category_id, min_intensity, max_intensity, avg_intensity, created_at) VALUES
('a1b2c3d4-1111-1111-1111-111111111111', 'Romantic Gestures', 'Sweet and thoughtful dares to show your love', '💕', false, true, false, 1, NULL, 1, 3, 2.00, '2026-01-20 21:05:55.039767+00'),
('a1b2c3d4-2222-2222-2222-222222222222', 'Playful Teasing', 'Fun and flirty dares to keep things exciting', '😏', false, true, false, 2, NULL, 2, 4, 2.71, '2026-01-20 21:05:55.039767+00'),
('a1b2c3d4-3333-3333-3333-333333333333', 'Adventure Time', 'Push your boundaries with exciting challenges', '🔥', true, true, false, 3, NULL, 3, 5, 3.71, '2026-01-20 21:05:55.039767+00'),
('a1b2c3d4-4444-4444-4444-444444444444', 'Intimate Moments', 'Sensual dares for close connection', '🌙', true, true, true, 4, NULL, 3, 5, 4.00, '2026-01-20 21:05:55.039767+00')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
is_premium = EXCLUDED.is_premium,
is_public = EXCLUDED.is_public,
is_explicit = EXCLUDED.is_explicit,
sort_order = EXCLUDED.sort_order,
min_intensity = EXCLUDED.min_intensity,
max_intensity = EXCLUDED.max_intensity,
avg_intensity = EXCLUDED.avg_intensity;
-- Insert dares
INSERT INTO dares (id, pack_id, text, intensity, suggested_duration_hours, created_at) VALUES
('267ba4d9-f5cb-4f9c-9529-bb8a5a169634', 'a1b2c3d4-1111-1111-1111-111111111111', 'Write a love note and hide it somewhere your partner will find it', 1, 24, '2026-01-20 21:06:03.390752+00'),
('3d104d0d-604f-4e1e-814d-20a9036db051', 'a1b2c3d4-1111-1111-1111-111111111111', 'Give your partner a 10-minute massage', 1, 2, '2026-01-20 21:06:03.390752+00'),
('0ba8c19d-190f-4a2c-8804-8b4138b6e94f', 'a1b2c3d4-1111-1111-1111-111111111111', 'Cook or order your partner''s favorite meal', 2, 24, '2026-01-20 21:06:03.390752+00'),
('8b2c1546-3053-4de3-a232-ce655c5f72ee', 'a1b2c3d4-1111-1111-1111-111111111111', 'Plan a surprise date night', 2, 48, '2026-01-20 21:06:03.390752+00'),
('ee9ee19a-de01-481d-bb13-288bd0b11b62', 'a1b2c3d4-1111-1111-1111-111111111111', 'Slow dance together to your favorite song', 2, 4, '2026-01-20 21:06:03.390752+00'),
('898aac6b-da8e-4477-85b8-73d99b77cfc7', 'a1b2c3d4-1111-1111-1111-111111111111', 'Create a playlist of songs that remind you of your relationship', 3, 24, '2026-01-20 21:06:03.390752+00'),
('cc64bd86-4c88-4cdc-890a-58cbaca32f25', 'a1b2c3d4-1111-1111-1111-111111111111', 'Recreate your first date', 3, 72, '2026-01-20 21:06:03.390752+00'),
('f08f5a35-2229-4c6c-a6d3-daff27297df2', 'a1b2c3d4-2222-2222-2222-222222222222', 'Send a flirty text every hour for the next 4 hours', 2, 4, '2026-01-20 21:06:10.980367+00'),
('849734a1-2b5d-4d4f-8065-c096ea096e44', 'a1b2c3d4-2222-2222-2222-222222222222', 'Whisper something naughty in your partner''s ear in public', 2, 2, '2026-01-20 21:06:10.980367+00'),
('c1e99ac6-1424-40e1-9288-1995ef8953e2', 'a1b2c3d4-2222-2222-2222-222222222222', 'Give your partner 3 compliments about their appearance', 2, 1, '2026-01-20 21:06:10.980367+00'),
('7c861f12-cb07-4460-b348-b38997ff5176', 'a1b2c3d4-2222-2222-2222-222222222222', 'Send a suggestive photo (keep it tasteful!)', 3, 4, '2026-01-20 21:06:10.980367+00'),
('547d4e7d-3977-4ef4-aa9e-2c24f30bd910', 'a1b2c3d4-2222-2222-2222-222222222222', 'Tease your partner for 10 minutes without letting them touch you', 3, 1, '2026-01-20 21:06:10.980367+00'),
('bf94334a-d9f3-4952-81ba-1d0e9693f0f1', 'a1b2c3d4-2222-2222-2222-222222222222', 'Wear something you know your partner finds irresistible', 3, 12, '2026-01-20 21:06:10.980367+00'),
('860a0377-0f41-4b8d-81d8-2fec35df6b92', 'a1b2c3d4-2222-2222-2222-222222222222', 'Blindfold your partner and feed them dessert', 4, 2, '2026-01-20 21:06:10.980367+00'),
('ca311842-a4c7-49e3-8836-5a423599f352', 'a1b2c3d4-3333-3333-3333-333333333333', 'Try a new activity together that neither of you has done before', 3, 72, '2026-01-20 21:06:18.466438+00'),
('9825ea49-4024-447e-86c9-af4e29d73ed0', 'a1b2c3d4-3333-3333-3333-333333333333', 'Have a spontaneous photoshoot together', 3, 4, '2026-01-20 21:06:18.466438+00'),
('eebb8795-7372-4014-a4cc-d30c2fed9f32', 'a1b2c3d4-3333-3333-3333-333333333333', 'Play truth or dare for 30 minutes', 3, 1, '2026-01-20 21:06:18.466438+00'),
('b99a8825-7c50-4152-bc7b-5b00fa7bf53d', 'a1b2c3d4-3333-3333-3333-333333333333', 'Role-play as strangers meeting for the first time at a bar', 4, 4, '2026-01-20 21:06:18.466438+00'),
('396cd465-f0fd-4f92-a9c5-4e9ce7225b2a', 'a1b2c3d4-3333-3333-3333-333333333333', 'Spend an entire evening without using your phones', 4, 6, '2026-01-20 21:06:18.466438+00'),
('fe7e7bd1-8b87-4859-aac7-5b788b2a54ee', 'a1b2c3d4-3333-3333-3333-333333333333', 'Let your partner pick your outfit for the entire day', 4, 24, '2026-01-20 21:06:18.466438+00'),
('72cba008-ff85-4edb-9d5f-882bca4ff84f', 'a1b2c3d4-3333-3333-3333-333333333333', 'Be your partner''s servant for an hour - do whatever they ask', 5, 1, '2026-01-20 21:06:18.466438+00'),
('860a6797-b4c6-4271-a327-2e26101f8479', 'a1b2c3d4-4444-4444-4444-444444444444', 'Give your partner a sensual full-body massage with oils', 3, 2, '2026-01-20 21:06:26.01169+00'),
('158f04d8-fc3f-448c-856e-369de6b9d98b', 'a1b2c3d4-4444-4444-4444-444444444444', 'Take a bath or shower together', 3, 1, '2026-01-20 21:06:26.01169+00'),
('681890fc-c29f-44a2-8885-1d59d5e24399', 'a1b2c3d4-4444-4444-4444-444444444444', 'Kiss every part of your partner''s body', 4, 1, '2026-01-20 21:06:26.01169+00'),
('4fce4684-5118-48a0-9353-de6320b625dd', 'a1b2c3d4-4444-4444-4444-444444444444', 'Try a new position tonight', 4, 4, '2026-01-20 21:06:26.01169+00'),
('056689e4-3f48-4c11-ae58-0fde6e2cb2a4', 'a1b2c3d4-4444-4444-4444-444444444444', 'Describe your deepest fantasy to your partner', 4, 2, '2026-01-20 21:06:26.01169+00'),
('858acf47-8a71-4f6a-a909-0c07ee2a03b6', 'a1b2c3d4-4444-4444-4444-444444444444', 'Spend 30 minutes on foreplay only', 5, 1, '2026-01-20 21:06:26.01169+00'),
('930ef187-09e5-47fd-ad88-3f4a10a10a74', 'a1b2c3d4-4444-4444-4444-444444444444', 'Let your partner be completely in control tonight', 5, 4, '2026-01-20 21:06:26.01169+00')
ON CONFLICT (id) DO UPDATE SET
pack_id = EXCLUDED.pack_id,
text = EXCLUDED.text,
intensity = EXCLUDED.intensity,
suggested_duration_hours = EXCLUDED.suggested_duration_hours;
COMMIT;
-- Questions will be loaded separately due to large size
-- Run: supabase db execute --file seed-questions.sql
+381
View File
@@ -0,0 +1,381 @@
# Plan: Add New Question Types (Text, Audio, Photo, Who Is More Likely)
## Overview
Extend Sauci to support Candle-style question types while maintaining full backward compatibility with existing yes/no/maybe swipe questions.
## UI Simplification: Remove Swipe Gestures
**Remove all swipe gesture handling** from the question cards. Users will interact via buttons only:
- Simpler codebase (remove gesture handler complexity)
- More accessible
- Cleaner animations
- Buttons already exist and work well
### Files to Simplify:
- `apps/mobile/src/components/swipe/SwipeCard.native.tsx` - Remove gesture handlers, keep card display + buttons
- `apps/mobile/src/components/swipe/SwipeCard.web.tsx` - Remove PanResponder logic
- `apps/mobile/src/components/tutorials/SwipeTutorial.tsx` - Update or remove (no longer teaching swipe)
- `apps/mobile/src/lib/swipeTutorialSeen.ts` - Can be removed
## New Question Types
### 1. Text Answer Questions
- Example: "If you could be any animal, what would you be?"
- Text input field displayed inline on the card
- YES button disabled until text is entered
- Match when both partners submit text → chat shows both answers
### 2. Audio Questions
- Example: "Record a thoughtful message for your partner"
- Card has record/stop/preview states with waveform visualization
- Max 60 seconds (configurable)
- Match when both partners record → chat shows both with playback controls
### 3. Photo Questions
- Example: "Share the last picture you took of your partner that made you smile"
- Card shows Camera/Photo Library buttons, then preview after selection
- **Photos are permanent** (unlike expiring chat media)
- Match when both partners share → chat shows both photos
### 4. "Who Is More Likely" Questions
- Example: "Who is more likely to snore?"
- Shows both partner profile pictures as tappable buttons
- **Always creates match when both answered** → reveals what each person chose
- No skip option (must choose someone)
---
## Database Changes
### Migration: Add question_type support
```sql
-- 1. Question type enum
CREATE TYPE question_type AS ENUM ('swipe', 'text_answer', 'audio', 'photo', 'who_likely');
-- 2. Add columns to questions table
ALTER TABLE questions
ADD COLUMN question_type question_type NOT NULL DEFAULT 'swipe',
ADD COLUMN config JSONB DEFAULT '{}';
-- 3. Add response_data to responses table
ALTER TABLE responses
ADD COLUMN response_data JSONB DEFAULT NULL;
-- 4. Extend match_type enum
ALTER TYPE match_type ADD VALUE IF NOT EXISTS 'both_answered';
-- 5. Add response_summary to matches table (stores both answers for display)
ALTER TABLE matches
ADD COLUMN response_summary JSONB DEFAULT NULL;
-- 6. Create storage bucket for response media (audio + photos)
INSERT INTO storage.buckets (id, name, public)
VALUES ('response-media', 'response-media', false);
-- 7. RLS policies for response-media bucket (audio + photos)
```
---
## TypeScript Type Extensions
**packages/shared/src/types/index.ts:**
```typescript
// New question type
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
// Extend Question interface
export interface Question {
// ... existing fields ...
question_type: QuestionType;
config?: QuestionConfig;
}
// Question config by type
export interface QuestionConfig {
max_duration_seconds?: number; // For audio questions (default: 60)
}
// Response data by question type
export type ResponseData =
| { type: 'text_answer'; text: string }
| { type: 'audio'; media_path: string; duration_seconds: number }
| { type: 'photo'; media_path: string }
| { type: 'who_likely'; chosen_user_id: string };
// Extend Response interface
export interface Response {
// ... existing fields ...
response_data?: ResponseData | null;
}
// Extend Match interface
export interface Match {
// ... existing fields ...
response_summary?: Record<string, ResponseData> | null;
}
```
---
## Match Logic Per Question Type
| Type | YES/Confirm | NO | Skip | Match Condition |
|------|-------------|-----|------|-----------------|
| swipe | Record yes | Record no | No response | Both yes/maybe → yes_yes/yes_maybe/maybe_maybe |
| text_answer | Submit with text | Record no (blocks match) | No response | Both answered yes → both_answered |
| audio | Upload & submit | N/A | No response | Both recorded → both_answered |
| photo | Upload & submit | N/A | No response | Both shared → both_answered |
| who_likely | N/A | N/A | N/A | Both selected → both_answered |
**Key distinction:**
- `NO` = Records a "no" response, which **blocks** any future match on that question
- `Skip` = No response recorded, partner can still answer, match still possible if user returns later
---
## UI Components
### Card-Based UI (No Modals)
Each question type transforms the card itself rather than opening modals. The card adapts its content and buttons based on question type and current state.
---
### 1. Standard Swipe Questions (`question_type: 'swipe'`)
**Card content:**
- Question text
- Intensity indicator
**Buttons:** `YES` / `MAYBE` / `NO` + Skip
---
### 2. Text Answer Questions (`question_type: 'text_answer'`)
**Card content:**
- Question text
- **Text input field inline on the card**
- Intensity indicator
**Buttons:** `YES` (submit with text) / `NO` (decline) / Skip
**Behavior:**
- `YES` button is **disabled until text is entered**
- `NO` records a "no" response (blocks match)
- `Skip` moves on without recording any response
---
### 3. Audio Questions (`question_type: 'audio'`)
**State A - Ready to record:**
- Question text
- Waveform placeholder / microphone icon
- Recording duration limit indicator (e.g., "up to 60 seconds")
**Buttons:** `Record` / `Skip`
**State B - Recording:**
- Question text
- **Live waveform visualization**
- Recording timer (counting up)
**Buttons:** `Stop`
**State C - Preview:**
- Question text
- **Playback waveform with play/pause**
- Duration display
**Buttons:** `Confirm` / `Re-record` / `Cancel`
**Behavior:**
- `Record` starts audio capture, transitions to State B
- `Stop` ends recording, transitions to State C
- `Confirm` uploads audio and submits response
- `Re-record` discards recording, returns to State A
- `Cancel` discards recording, returns to State A (stays on question)
- `Skip` moves on without recording
**Storage:**
- Audio files stored in `response-media` bucket (same as photos)
- Format: AAC/M4A (native iOS/Android format, good compression)
- Max duration: 60 seconds (configurable per question via `config`)
---
### 4. Photo Questions (`question_type: 'photo'`)
**State A - Initial (no photo selected):**
- Question text
- Placeholder/prompt area
**Buttons:** `Camera` / `Skip` / `Photo Library`
**State B - Photo selected (preview):**
- Question text
- **Selected photo displayed in card**
**Buttons:** `Confirm` / `Retake` / `Cancel`
**Behavior:**
- `Confirm` uploads photo and submits response
- `Retake` goes back to State A (camera/picker choice)
- `Cancel` clears photo and returns to State A (stays on question)
- `Skip` (State A only) moves on without recording any response
---
### 5. Who Is More Likely (`question_type: 'who_likely'`)
**Card content:**
- Question text
- **Two large tappable partner avatar buttons**
**Buttons:** Partner avatars ARE the buttons (tap to select)
---
### Components to Create:
1. **`QuestionCard.tsx`** - Base card, handles `swipe` type (refactored from SwipeCard)
2. **`QuestionCardText.tsx`** - Card with inline text input
3. **`QuestionCardAudio.tsx`** - Card with record/preview states + waveform visualization
4. **`QuestionCardPhoto.tsx`** - Card with photo capture/preview states
5. **`QuestionCardWhoLikely.tsx`** - Card with partner avatar buttons
6. **`MatchCardTextAnswer.tsx`** - Display both text answers in match card
7. **`MatchCardAudio.tsx`** - Display both audio messages with playback controls
8. **`MatchCardPhoto.tsx`** - Display both photos in match card
9. **`MatchCardWhoLikely.tsx`** - Reveal who each person chose
### Components to Remove:
1. **`SwipeCard.native.tsx`** - Replaced by QuestionCard variants
2. **`SwipeCard.web.tsx`** - No longer needed
3. **`SwipeTutorial.tsx`** - No swipe gestures to teach
### Flow Changes:
**swipe.tsx** - Render correct card component by question type:
```typescript
switch (question.question_type) {
case 'text_answer':
return <QuestionCardText ... />
case 'audio':
return <QuestionCardAudio ... />
case 'photo':
return <QuestionCardPhoto ... />
case 'who_likely':
return <QuestionCardWhoLikely ... />
default: // 'swipe'
return <QuestionCard ... />
}
```
---
## Edge Function Changes
**submit-response/index.ts:**
```typescript
interface SubmitResponseBody {
question_id: string;
answer: Answer;
response_data?: ResponseData; // NEW
}
// After saving response, build match with response_summary
if (matchType) {
const responseSummary = {
[user.id]: currentResponse.response_data,
[partnerId]: partnerResponse.response_data,
};
await supabase.from("matches").upsert({
couple_id,
question_id,
match_type: matchType,
response_summary: responseSummary,
is_new: true,
});
}
```
---
## Implementation Order
### Phase 0: Remove Swipe Gestures & Create Base Card
1. Create `QuestionCard.tsx` from `SwipeCard.native.tsx` (remove gesture handlers, keep buttons + card styling)
2. Delete `SwipeCard.native.tsx` and `SwipeCard.web.tsx`
3. Update imports in `swipe.tsx` to use new QuestionCard
4. Delete `SwipeTutorial.tsx` and related tutorial tracking
5. Test that button interactions still work for existing swipe-type questions
### Phase 1: Foundation
1. Create database migration with all schema changes
2. Update shared TypeScript types
3. Regenerate Supabase types (`npm run generate:types`)
4. Update submit-response edge function to accept response_data
### Phase 2: Text Answer Questions
1. Create `QuestionCardText` component with inline text input
2. Add disabled state logic for YES button (enabled only when text entered)
3. Extend MatchCard to display both text answers
4. Add sample text_answer questions via admin
### Phase 3: Audio Questions
1. Install `expo-av` for audio recording/playback (if not already installed)
2. Create `useAudioRecorder` hook (record, stop, get duration, get file URI)
3. Create `QuestionCardAudio` component with three states (ready / recording / preview)
4. Create waveform visualization component (can be simplified bars initially)
5. Extend MatchCard with `MatchCardAudio` for dual playback
6. Add sample audio questions via admin
### Phase 4: Photo Questions
1. Create response-media storage bucket and policies (shared with audio)
2. Create `useResponseMediaUpload` hook (adapt from existing media hooks)
3. Create `QuestionCardPhoto` component with two states (picker / preview)
4. Extend MatchCard to display both photos
5. Add sample photo questions via admin
### Phase 5: Who Is More Likely Questions
1. Create `QuestionCardWhoLikely` component with partner avatar buttons
2. Update swipe.tsx to render correct card type via switch statement
3. Extend MatchCard to show choice reveal
4. Add sample who_likely questions via admin
### Phase 6: Admin Dashboard
1. Add question_type selector to question creation form
2. Add config options per question type (e.g., max_duration_seconds for audio)
---
## Key Files to Modify
| File | Changes |
|------|---------|
| `packages/shared/src/types/index.ts` | Add QuestionType, ResponseData types |
| `apps/supabase/migrations/` | New migration for schema changes |
| `apps/supabase/functions/submit-response/index.ts` | Handle response_data, build response_summary |
| `apps/mobile/app/(app)/swipe.tsx` | Route to correct card component by question_type |
| `apps/mobile/src/components/swipe/SwipeCard.native.tsx` | Rename to QuestionCard, remove gesture handlers |
| `apps/mobile/src/components/swipe/SwipeCard.web.tsx` | Delete (no longer needed) |
| `apps/mobile/src/components/tutorials/SwipeTutorial.tsx` | Delete or repurpose as button tutorial |
| `apps/mobile/src/features/chat/components/MatchCard.tsx` | Display response_summary by type |
| `apps/admin/src/pages/QuestionsPage.tsx` | Add question_type field |
---
## Verification
1. **Existing questions**: Tap YES/NO/MAYBE on a regular question → verify it still works
2. **Text answer flow**: Create text_answer question → tap YES → type answer → partner does same → verify match shows both texts
3. **Audio flow**: Create audio question → record message → partner does same → verify match plays both recordings
4. **Photo flow**: Create photo question → tap YES → select photo → partner does same → verify match shows both photos
5. **Who is more likely**: Create who_likely question → tap choice → partner does same → verify reveal works
6. **Edge cases**: Test NO button on new question types (should skip, no match)
7. **Gestures removed**: Verify swipe gestures no longer work (buttons only)
8. **Audio permissions**: Test microphone permission request flow on iOS/Android
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

+161 -46
View File
@@ -12,6 +12,7 @@
"packages/*"
],
"devDependencies": {
"pg": "^8.17.2",
"turbo": "^2.3.3",
"typescript": "^5.3.3"
},
@@ -4135,20 +4136,6 @@
}
}
},
"node_modules/@firebase/auth-compat/node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@firebase/auth-interop-types": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz",
@@ -13419,20 +13406,6 @@
}
}
},
"node_modules/firebase/node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/flow-enums-runtime": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
@@ -18554,6 +18527,103 @@
"node": ">=8"
}
},
"node_modules/pg": {
"version": "8.17.2",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz",
"integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.10.1",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz",
"integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -18829,6 +18899,49 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -20567,6 +20680,16 @@
"node": ">=6"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -21069,24 +21192,6 @@
}
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
@@ -22439,6 +22544,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+3 -2
View File
@@ -15,6 +15,7 @@
"typecheck": "turbo typecheck"
},
"devDependencies": {
"pg": "^8.17.2",
"turbo": "^2.3.3",
"typescript": "^5.3.3"
},
@@ -27,7 +28,7 @@
"react-native": "0.76.9"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0"
"@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.54.0"
}
}
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# Export questions from production and generate SQL for non-prod
# Usage: ./scripts/export-questions-from-prod.sh > apps/supabase/seed-questions.sql
# Production database connection string
# Get this from Supabase Dashboard > Settings > Database > Connection string
PROD_DB_URL="${PROD_DATABASE_URL:-}"
if [ -z "$PROD_DB_URL" ]; then
echo "Error: PROD_DATABASE_URL environment variable is not set" >&2
echo "Get it from: Supabase Dashboard > Settings > Database > Connection string (URI)" >&2
exit 1
fi
echo "-- Questions exported from production"
echo "-- Generated on $(date)"
echo ""
echo "BEGIN;"
echo ""
# Export questions as SQL INSERT statements
psql "$PROD_DB_URL" -t -A -c "
SELECT 'INSERT INTO questions (id, pack_id, text, intensity, partner_text, allowed_couple_genders, target_user_genders, required_props, deleted_at, inverse_of, created_at) VALUES (' ||
quote_literal(id) || ', ' ||
quote_literal(pack_id) || ', ' ||
quote_literal(text) || ', ' ||
COALESCE(intensity::text, 'NULL') || ', ' ||
COALESCE(quote_literal(partner_text), 'NULL') || ', ' ||
COALESCE(quote_literal(allowed_couple_genders::text), 'NULL') || ', ' ||
COALESCE(quote_literal(target_user_genders::text), 'NULL') || ', ' ||
COALESCE(quote_literal(required_props::text), 'NULL') || ', ' ||
COALESCE(quote_literal(deleted_at::text), 'NULL') || ', ' ||
'NULL, ' || -- inverse_of set to NULL initially
quote_literal(created_at::text) ||
') ON CONFLICT (id) DO UPDATE SET text = EXCLUDED.text, intensity = EXCLUDED.intensity, partner_text = EXCLUDED.partner_text, allowed_couple_genders = EXCLUDED.allowed_couple_genders, target_user_genders = EXCLUDED.target_user_genders, required_props = EXCLUDED.required_props;'
FROM questions
ORDER BY pack_id, intensity;
"
echo ""
echo "-- Update inverse_of references"
# Export inverse_of updates
psql "$PROD_DB_URL" -t -A -c "
SELECT 'UPDATE questions SET inverse_of = ' || quote_literal(inverse_of) || ' WHERE id = ' || quote_literal(id) || ';'
FROM questions
WHERE inverse_of IS NOT NULL;
"
echo ""
echo "-- Insert pack_topics"
# Export pack_topics
psql "$PROD_DB_URL" -t -A -c "
SELECT 'INSERT INTO pack_topics (pack_id, topic_id) VALUES (' ||
quote_literal(pack_id) || ', ' ||
quote_literal(topic_id) ||
') ON CONFLICT DO NOTHING;'
FROM pack_topics;
"
echo ""
echo "COMMIT;"
echo ""
echo "-- Done"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* Run content seed against non-production database using Node.js
*
* Usage:
* export NONPROD_DATABASE_PASSWORD="your-database-password"
* node scripts/run-seed-nonprod.mjs
*
* Get the password from: Supabase Dashboard > Settings > Database > Database password
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import pg from 'pg';
const { Client } = pg;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Non-production database connection
const NONPROD_PASSWORD = process.env.NONPROD_DATABASE_PASSWORD;
if (!NONPROD_PASSWORD) {
console.error('Error: NONPROD_DATABASE_PASSWORD environment variable is not set');
console.error('Get it from: Supabase Dashboard > Settings > Database > Database password');
process.exit(1);
}
const connectionString = `postgresql://postgres.itbzhrvlgvdmzbnhzhyx:${NONPROD_PASSWORD}@aws-0-eu-west-1.pooler.supabase.com:6543/postgres`;
async function main() {
console.log('=== Running content seed against Sauci Non-Production ===\n');
// Read the seed file
const seedFile = path.join(__dirname, '../apps/supabase/content-seed.sql');
if (!fs.existsSync(seedFile)) {
console.error(`Error: Seed file not found at ${seedFile}`);
console.error('Run the export from production first.');
process.exit(1);
}
const sql = fs.readFileSync(seedFile, 'utf8');
console.log(`Loaded seed file (${sql.split('\n').length} lines)`);
const client = new Client({ connectionString });
try {
console.log('Connecting to non-production database...');
await client.connect();
console.log('Connected!\n');
console.log('Executing seed SQL...');
await client.query(sql);
console.log('\n=== Content sync complete! ===');
// Verify the data
console.log('\nVerifying data:');
const tables = ['categories', 'topics', 'question_packs', 'dare_packs', 'questions', 'dares', 'pack_topics'];
for (const table of tables) {
const result = await client.query(`SELECT COUNT(*) as count FROM ${table}`);
console.log(` - ${table}: ${result.rows[0].count} rows`);
}
} catch (error) {
console.error('\nError:', error.message);
process.exit(1);
} finally {
await client.end();
}
}
main();
+220
View File
@@ -0,0 +1,220 @@
/**
* Script to sync content from production to non-production Supabase
*
* Usage: npx ts-node scripts/sync-content-to-nonprod.ts
*
* Make sure you have the following environment variables set:
* - PROD_SUPABASE_URL (or uses default)
* - PROD_SUPABASE_SERVICE_KEY
* - NONPROD_SUPABASE_URL (or uses default)
* - NONPROD_SUPABASE_SERVICE_KEY
*/
import { createClient, SupabaseClient } from '@supabase/supabase-js';
// Configuration
const PROD_URL = process.env.PROD_SUPABASE_URL || 'https://ckjcrkjpmhqhiucifukx.supabase.co';
const PROD_KEY = process.env.PROD_SUPABASE_SERVICE_KEY;
const NONPROD_URL = process.env.NONPROD_SUPABASE_URL || 'https://itbzhrvlgvdmzbnhzhyx.supabase.co';
const NONPROD_KEY = process.env.NONPROD_SUPABASE_SERVICE_KEY;
if (!PROD_KEY || !NONPROD_KEY) {
console.error('Missing required environment variables:');
if (!PROD_KEY) console.error(' - PROD_SUPABASE_SERVICE_KEY');
if (!NONPROD_KEY) console.error(' - NONPROD_SUPABASE_SERVICE_KEY');
process.exit(1);
}
const prodClient = createClient(PROD_URL, PROD_KEY);
const nonprodClient = createClient(NONPROD_URL, NONPROD_KEY);
async function fetchAll<T>(client: SupabaseClient, table: string, orderBy?: string): Promise<T[]> {
let allData: T[] = [];
let offset = 0;
const limit = 1000;
while (true) {
let query = client.from(table).select('*').range(offset, offset + limit - 1);
if (orderBy) {
query = query.order(orderBy);
}
const { data, error } = await query;
if (error) {
throw new Error(`Error fetching ${table}: ${error.message}`);
}
if (!data || data.length === 0) break;
allData = allData.concat(data as T[]);
offset += limit;
if (data.length < limit) break;
}
return allData;
}
async function clearTable(client: SupabaseClient, table: string) {
// Use RPC to bypass RLS for deletion
const { error } = await client.rpc('truncate_table', { table_name: table });
if (error) {
// If RPC doesn't exist, try direct delete
const { error: deleteError } = await client.from(table).delete().gte('id', '00000000-0000-0000-0000-000000000000');
if (deleteError) {
console.warn(`Warning: Could not clear ${table}: ${deleteError.message}`);
}
}
}
async function insertBatch<T extends Record<string, any>>(
client: SupabaseClient,
table: string,
data: T[],
batchSize = 100
): Promise<void> {
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize);
const { error } = await client.from(table).upsert(batch, { onConflict: 'id' });
if (error) {
console.error(`Error inserting into ${table} at batch ${i / batchSize}:`, error.message);
// Try inserting one by one to find the problematic row
for (const row of batch) {
const { error: singleError } = await client.from(table).upsert(row, { onConflict: 'id' });
if (singleError) {
console.error(` Failed row:`, row.id, singleError.message);
}
}
}
process.stdout.write(`\r Inserted ${Math.min(i + batchSize, data.length)}/${data.length}`);
}
console.log();
}
async function main() {
console.log('=== Content Sync: Production -> Non-Production ===\n');
try {
// 1. Fetch all content from production
console.log('Fetching content from production...');
console.log(' - Categories...');
const categories = await fetchAll(prodClient, 'categories', 'sort_order');
console.log(` Found ${categories.length} categories`);
console.log(' - Topics...');
const topics = await fetchAll(prodClient, 'topics', 'sort_order');
console.log(` Found ${topics.length} topics`);
console.log(' - Question Packs...');
const questionPacks = await fetchAll(prodClient, 'question_packs', 'sort_order');
console.log(` Found ${questionPacks.length} question packs`);
console.log(' - Dare Packs...');
const darePacks = await fetchAll(prodClient, 'dare_packs', 'sort_order');
console.log(` Found ${darePacks.length} dare packs`);
console.log(' - Questions...');
const questions = await fetchAll(prodClient, 'questions', 'pack_id');
console.log(` Found ${questions.length} questions`);
console.log(' - Dares...');
const dares = await fetchAll(prodClient, 'dares', 'pack_id');
console.log(` Found ${dares.length} dares`);
console.log(' - Pack Topics...');
const packTopics = await fetchAll(prodClient, 'pack_topics');
console.log(` Found ${packTopics.length} pack-topic associations`);
// 2. Clear existing content in non-prod (in reverse dependency order)
console.log('\nClearing existing content in non-production...');
console.log(' - pack_topics...');
await clearTable(nonprodClient, 'pack_topics');
console.log(' - questions...');
await clearTable(nonprodClient, 'questions');
console.log(' - dares...');
await clearTable(nonprodClient, 'dares');
console.log(' - question_packs...');
await clearTable(nonprodClient, 'question_packs');
console.log(' - dare_packs...');
await clearTable(nonprodClient, 'dare_packs');
console.log(' - topics...');
await clearTable(nonprodClient, 'topics');
console.log(' - categories...');
await clearTable(nonprodClient, 'categories');
// 3. Insert content into non-prod (in dependency order)
console.log('\nInserting content into non-production...');
console.log(' - Categories...');
await insertBatch(nonprodClient, 'categories', categories);
console.log(' - Topics...');
await insertBatch(nonprodClient, 'topics', topics);
console.log(' - Question Packs...');
await insertBatch(nonprodClient, 'question_packs', questionPacks);
console.log(' - Dare Packs...');
await insertBatch(nonprodClient, 'dare_packs', darePacks);
// Handle questions with inverse_of self-reference
// First insert all questions with inverse_of set to NULL
console.log(' - Questions (pass 1: without inverse_of)...');
const questionsWithoutInverse = questions.map((q: any) => ({ ...q, inverse_of: null }));
await insertBatch(nonprodClient, 'questions', questionsWithoutInverse);
// Then update the inverse_of references
const questionsWithInverse = (questions as any[]).filter(q => q.inverse_of !== null);
if (questionsWithInverse.length > 0) {
console.log(` - Questions (pass 2: updating ${questionsWithInverse.length} inverse_of references)...`);
for (const q of questionsWithInverse) {
const { error } = await nonprodClient
.from('questions')
.update({ inverse_of: q.inverse_of })
.eq('id', q.id);
if (error) {
console.error(` Failed to update inverse_of for ${q.id}:`, error.message);
}
}
console.log(` Updated ${questionsWithInverse.length} references`);
}
console.log(' - Dares...');
await insertBatch(nonprodClient, 'dares', dares);
console.log(' - Pack Topics...');
// pack_topics has composite primary key, need different upsert strategy
for (const pt of packTopics as any[]) {
const { error } = await nonprodClient
.from('pack_topics')
.upsert(pt, { onConflict: 'pack_id,topic_id' });
if (error) {
console.error(` Failed pack_topic:`, pt, error.message);
}
}
console.log(` Inserted ${(packTopics as any[]).length} pack-topic associations`);
console.log('\n=== Sync Complete! ===');
console.log(`
Summary:
- ${categories.length} categories
- ${topics.length} topics
- ${questionPacks.length} question packs
- ${darePacks.length} dare packs
- ${questions.length} questions
- ${dares.length} dares
- ${(packTopics as any[]).length} pack-topic associations
`);
} catch (error) {
console.error('Sync failed:', error);
process.exit(1);
}
}
main();
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
/**
* Sync content from production to non-production using direct PostgreSQL connection
*
* Usage:
* export PROD_DATABASE_URL="postgresql://postgres.xxx:password@aws-0-eu-west-2.pooler.supabase.com:6543/postgres"
* export NONPROD_DATABASE_URL="postgresql://postgres.xxx:password@aws-0-eu-west-1.pooler.supabase.com:6543/postgres"
* node scripts/sync-content.mjs
*
* Get connection strings from: Supabase Dashboard > Settings > Database > Connection string (Transaction pooler)
*/
import pg from 'pg';
const { Client } = pg;
const PROD_URL = process.env.PROD_DATABASE_URL;
const NONPROD_URL = process.env.NONPROD_DATABASE_URL;
if (!PROD_URL || !NONPROD_URL) {
console.error('Missing required environment variables:');
if (!PROD_URL) console.error(' - PROD_DATABASE_URL');
if (!NONPROD_URL) console.error(' - NONPROD_DATABASE_URL');
console.error('\nGet connection strings from: Supabase Dashboard > Settings > Database > Connection string (Transaction pooler)');
process.exit(1);
}
async function main() {
console.log('=== Content Sync: Production -> Non-Production ===\n');
const prodClient = new Client({ connectionString: PROD_URL });
const nonprodClient = new Client({ connectionString: NONPROD_URL });
try {
console.log('Connecting to databases...');
await prodClient.connect();
console.log(' - Connected to production');
await nonprodClient.connect();
console.log(' - Connected to non-production');
// Fetch all content from production
console.log('\nFetching content from production...');
const categories = (await prodClient.query('SELECT * FROM categories ORDER BY sort_order')).rows;
console.log(` - ${categories.length} categories`);
const topics = (await prodClient.query('SELECT * FROM topics ORDER BY sort_order')).rows;
console.log(` - ${topics.length} topics`);
const questionPacks = (await prodClient.query('SELECT * FROM question_packs ORDER BY sort_order')).rows;
console.log(` - ${questionPacks.length} question packs`);
const darePacks = (await prodClient.query('SELECT * FROM dare_packs ORDER BY sort_order')).rows;
console.log(` - ${darePacks.length} dare packs`);
const questions = (await prodClient.query('SELECT * FROM questions ORDER BY pack_id')).rows;
console.log(` - ${questions.length} questions`);
const dares = (await prodClient.query('SELECT * FROM dares ORDER BY pack_id')).rows;
console.log(` - ${dares.length} dares`);
const packTopics = (await prodClient.query('SELECT * FROM pack_topics')).rows;
console.log(` - ${packTopics.length} pack-topic associations`);
// Clear and insert in non-prod
console.log('\nClearing existing content in non-production...');
await nonprodClient.query('BEGIN');
await nonprodClient.query('DELETE FROM pack_topics');
await nonprodClient.query('DELETE FROM questions');
await nonprodClient.query('DELETE FROM dares');
await nonprodClient.query('DELETE FROM question_packs');
await nonprodClient.query('DELETE FROM dare_packs');
await nonprodClient.query('DELETE FROM topics');
await nonprodClient.query('DELETE FROM categories');
console.log(' - Cleared all content tables');
// Insert categories
console.log('\nInserting content into non-production...');
for (const cat of categories) {
await nonprodClient.query(
`INSERT INTO categories (id, name, description, icon, sort_order, created_at, is_public)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[cat.id, cat.name, cat.description, cat.icon, cat.sort_order, cat.created_at, cat.is_public]
);
}
console.log(` - Inserted ${categories.length} categories`);
// Insert topics
for (const topic of topics) {
await nonprodClient.query(
`INSERT INTO topics (id, name, description, icon, sort_order, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[topic.id, topic.name, topic.description, topic.icon, topic.sort_order, topic.created_at]
);
}
console.log(` - Inserted ${topics.length} topics`);
// Insert question_packs
for (const pack of questionPacks) {
await nonprodClient.query(
`INSERT INTO question_packs (id, name, description, icon, is_premium, is_public, sort_order, created_at, category_id, is_explicit, min_intensity, max_intensity, avg_intensity, scheduled_release_at, release_notified)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[pack.id, pack.name, pack.description, pack.icon, pack.is_premium, pack.is_public, pack.sort_order, pack.created_at, pack.category_id, pack.is_explicit, pack.min_intensity, pack.max_intensity, pack.avg_intensity, pack.scheduled_release_at, pack.release_notified]
);
}
console.log(` - Inserted ${questionPacks.length} question packs`);
// Insert dare_packs
for (const pack of darePacks) {
await nonprodClient.query(
`INSERT INTO dare_packs (id, name, description, icon, is_premium, is_public, is_explicit, sort_order, category_id, min_intensity, max_intensity, avg_intensity, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
[pack.id, pack.name, pack.description, pack.icon, pack.is_premium, pack.is_public, pack.is_explicit, pack.sort_order, pack.category_id, pack.min_intensity, pack.max_intensity, pack.avg_intensity, pack.created_at]
);
}
console.log(` - Inserted ${darePacks.length} dare packs`);
// Insert questions (first pass without inverse_of)
let questionCount = 0;
for (const q of questions) {
await nonprodClient.query(
`INSERT INTO questions (id, pack_id, text, intensity, partner_text, allowed_couple_genders, target_user_genders, required_props, deleted_at, inverse_of, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NULL, $10)`,
[q.id, q.pack_id, q.text, q.intensity, q.partner_text, q.allowed_couple_genders, q.target_user_genders, q.required_props, q.deleted_at, q.created_at]
);
questionCount++;
if (questionCount % 100 === 0) {
process.stdout.write(`\r - Inserting questions: ${questionCount}/${questions.length}`);
}
}
console.log(`\r - Inserted ${questions.length} questions`);
// Update inverse_of references
const questionsWithInverse = questions.filter(q => q.inverse_of !== null);
if (questionsWithInverse.length > 0) {
for (const q of questionsWithInverse) {
await nonprodClient.query(
'UPDATE questions SET inverse_of = $1 WHERE id = $2',
[q.inverse_of, q.id]
);
}
console.log(` - Updated ${questionsWithInverse.length} inverse_of references`);
}
// Insert dares
for (const dare of dares) {
await nonprodClient.query(
`INSERT INTO dares (id, pack_id, text, intensity, suggested_duration_hours, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[dare.id, dare.pack_id, dare.text, dare.intensity, dare.suggested_duration_hours, dare.created_at]
);
}
console.log(` - Inserted ${dares.length} dares`);
// Insert pack_topics
for (const pt of packTopics) {
await nonprodClient.query(
`INSERT INTO pack_topics (pack_id, topic_id) VALUES ($1, $2)`,
[pt.pack_id, pt.topic_id]
);
}
console.log(` - Inserted ${packTopics.length} pack-topic associations`);
await nonprodClient.query('COMMIT');
console.log('\n=== Sync Complete! ===');
console.log(`
Summary:
- ${categories.length} categories
- ${topics.length} topics
- ${questionPacks.length} question packs
- ${darePacks.length} dare packs
- ${questions.length} questions
- ${dares.length} dares
- ${packTopics.length} pack-topic associations
`);
} catch (error) {
console.error('\nSync failed:', error.message);
try {
await nonprodClient.query('ROLLBACK');
} catch (e) {}
process.exit(1);
} finally {
await prodClient.end();
await nonprodClient.end();
}
}
main();
+27
View File
@@ -0,0 +1,27 @@
# Terraform state files (use remote backend instead)
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude all .tfvars files (may contain secrets)
*.tfvars
*.tfvars.json
# Override files
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# CLI configuration files
.terraformrc
terraform.rc
# Local .terraform directories
.terraform/
# Lock file should be committed
!.terraform.lock.hcl
+25
View File
@@ -0,0 +1,25 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/supabase/supabase" {
version = "1.6.1"
constraints = "~> 1.0"
hashes = [
"h1:qQoXMVMdUp4Id9eKYPyTp52t6RchV38rXF10ayMeeNs=",
"zh:115437eb15451558b47984155e6e34fb18d85635f7d57a12f5933aa99231ca90",
"zh:1403a3be401cc746cbfbaac11f2b5f5f492b4a1142441b380895b20a4ca700f1",
"zh:4087e7238f2a1e930f3798c83c5544947f5096803d9132f0694e4036ee6228cc",
"zh:4d9effb75c41409700d325fd715d39cb135f958c7442e32d1686f13928e94de7",
"zh:575dcc18f686fa3aefd40e5f668e075dc2a4b628804b1f0eba0ba2bad299476d",
"zh:5a95fa37e0eb126721c2187dd389f63c09640082cd50cc521a0dfe898eea683c",
"zh:6e20a8faa09627a9ef0b7c9950af7da93c69d682fe3bfd930807db0b58aa59a1",
"zh:757a8da9f8fafcd2aa9ecb9f4cb2773567090313c736ee95d3fa41bd92535538",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:a815c20e132981abbda986b60445f9786bfd5dd3254bcb52d52e9e31cd105a04",
"zh:c665269e5474a7144ec88bb3849dacea2f1c720500e86f84279d4318806044a8",
"zh:da054f0bb9bb7432c4f5c66093262566e99403f5e4a5d72853873359472d7c1c",
"zh:de584088b3d86ec1889dd719406783f9f3698df0a91007741a156f313bf61aa7",
"zh:e83615654ce2c82b1a243c79cc53c29214e7fd81fb286a7e3befb35d5a1c3492",
"zh:ed2f7545a8217ab2eae2cf9447c6b7b1ec660764ae764c08b033f5e3800a5608",
]
}
+195
View File
@@ -0,0 +1,195 @@
# Terraform Infrastructure
This directory contains Terraform configuration for managing Supabase project settings as Infrastructure as Code (IaC).
## Directory Structure
```
terraform/
├── modules/
│ └── supabase/ # Shared Supabase configuration module
│ ├── main.tf # Resource definitions
│ ├── variables.tf # Module inputs
│ ├── outputs.tf # Module outputs
│ └── templates/ # Email templates
├── environments/
│ ├── prod/ # Production (ckjcrkjpmhqhiucifukx)
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── versions.tf
│ │ └── outputs.tf
│ └── non-prod/ # Non-production (itbzhrvlgvdmzbnhzhyx)
│ ├── main.tf
│ ├── variables.tf
│ ├── versions.tf
│ └── outputs.tf
└── README.md
```
## What's Managed
| Resource | Description |
|----------|-------------|
| `supabase_settings` | API config, Auth settings, Connection pooler |
**Not managed by Terraform** (managed via migrations/CLI):
- Database schema & migrations
- Edge functions
- Storage buckets
- RLS policies
## Initial Setup
### 1. Install Terraform
```bash
# macOS
brew install terraform
# Or download from https://terraform.io/downloads
```
### 2. Get Supabase Access Token
1. Go to https://supabase.com/dashboard/account/tokens
2. Create a new token
3. Save it securely
### 3. Initialize an Environment
```bash
cd terraform/environments/non-prod # or prod
export TF_VAR_supabase_access_token=sbp_xxxxx
terraform init
```
### 4. Import Existing Settings
For each environment, import the existing Supabase settings:
```bash
# Non-prod
cd terraform/environments/non-prod
terraform import module.supabase.supabase_settings.main itbzhrvlgvdmzbnhzhyx
# Prod
cd terraform/environments/prod
terraform import module.supabase.supabase_settings.main ckjcrkjpmhqhiucifukx
```
### 5. Verify Configuration
```bash
terraform plan
```
If the plan shows changes, update the module to match the current settings, then re-run plan until it shows no changes.
## Workflow: Making Changes
**Always test in non-prod first, then promote to prod.**
### 1. Make Changes to the Module
Edit files in `terraform/modules/supabase/` to change shared configuration.
### 2. Apply to Non-Prod
```bash
cd terraform/environments/non-prod
export TF_VAR_supabase_access_token=sbp_xxxxx
terraform plan # Review changes
terraform apply # Apply changes
```
### 3. Verify in Non-Prod
Test the changes in the non-prod environment.
### 4. Apply to Prod
```bash
cd terraform/environments/prod
export TF_VAR_supabase_access_token=sbp_xxxxx
terraform plan # Review changes
terraform apply # Apply changes
```
### 5. Commit Changes
```bash
git add terraform/
git commit -m "Update Supabase settings: <description>"
git push
```
## Environment-Specific Settings
Some settings differ between environments. These are controlled by the `is_production` variable in the module:
| Setting | Production | Non-Prod |
|---------|-----------|----------|
| SMTP | Resend (custom) | Default |
| Email rate limit | 25/hour | 3/hour |
## CI/CD
The GitHub Action (`.github/workflows/deploy-supabase.yml`) can be configured to:
- Run `terraform plan` on PRs
- Run `terraform apply` on merge to master
Required secret: `SUPABASE_ACCESS_TOKEN`
Update the workflow to use the new directory structure:
```yaml
- name: Apply to Non-Prod
working-directory: terraform/environments/non-prod
run: terraform apply -auto-approve
- name: Apply to Prod
working-directory: terraform/environments/prod
run: terraform apply -auto-approve
```
## Remote State (Recommended)
For team environments, configure a remote backend in each environment's `versions.tf`:
- **Terraform Cloud** (free for small teams)
- **S3 backend** (if you have AWS)
## Secrets
**Never commit secrets to git!**
- Use environment variables: `export TF_VAR_supabase_access_token=...`
- Use GitHub Secrets for CI/CD
- OAuth provider credentials are set in Supabase Dashboard, not Terraform
## Migration from Workspace-Based Setup
If you previously used Terraform workspaces, migrate as follows:
1. Export state from old setup:
```bash
cd terraform # old location
terraform workspace select production
terraform state pull > prod.tfstate
terraform workspace select non-production
terraform state pull > non-prod.tfstate
```
2. Import state to new environments:
```bash
cd environments/prod
terraform init
terraform state push ../../prod.tfstate
cd ../non-prod
terraform init
terraform state push ../../non-prod.tfstate
```
3. Verify with `terraform plan` in each environment
4. Delete old workspace-based files from terraform root
@@ -0,0 +1,27 @@
# Local .terraform directories
.terraform/
# Terraform state files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude override files as they are usually used to override resources locally
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Ignore CLI configuration files
.terraformrc
terraform.rc
# Sensitive variable files
*.tfvars
!terraform.tfvars.example
# Provider lock files (comment out if you want to track)
# .terraform.lock.hcl
+25
View File
@@ -0,0 +1,25 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/supabase/supabase" {
version = "1.6.1"
constraints = "~> 1.0"
hashes = [
"h1:qQoXMVMdUp4Id9eKYPyTp52t6RchV38rXF10ayMeeNs=",
"zh:115437eb15451558b47984155e6e34fb18d85635f7d57a12f5933aa99231ca90",
"zh:1403a3be401cc746cbfbaac11f2b5f5f492b4a1142441b380895b20a4ca700f1",
"zh:4087e7238f2a1e930f3798c83c5544947f5096803d9132f0694e4036ee6228cc",
"zh:4d9effb75c41409700d325fd715d39cb135f958c7442e32d1686f13928e94de7",
"zh:575dcc18f686fa3aefd40e5f668e075dc2a4b628804b1f0eba0ba2bad299476d",
"zh:5a95fa37e0eb126721c2187dd389f63c09640082cd50cc521a0dfe898eea683c",
"zh:6e20a8faa09627a9ef0b7c9950af7da93c69d682fe3bfd930807db0b58aa59a1",
"zh:757a8da9f8fafcd2aa9ecb9f4cb2773567090313c736ee95d3fa41bd92535538",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:a815c20e132981abbda986b60445f9786bfd5dd3254bcb52d52e9e31cd105a04",
"zh:c665269e5474a7144ec88bb3849dacea2f1c720500e86f84279d4318806044a8",
"zh:da054f0bb9bb7432c4f5c66093262566e99403f5e4a5d72853873359472d7c1c",
"zh:de584088b3d86ec1889dd719406783f9f3698df0a91007741a156f313bf61aa7",
"zh:e83615654ce2c82b1a243c79cc53c29214e7fd81fb286a7e3befb35d5a1c3492",
"zh:ed2f7545a8217ab2eae2cf9447c6b7b1ec660764ae764c08b033f5e3800a5608",
]
}
+13
View File
@@ -0,0 +1,13 @@
# Non-Production Environment (Staging/Development)
#
# Supabase project: itbzhrvlgvdmzbnhzhyx
# URL: https://itbzhrvlgvdmzbnhzhyx.supabase.co
#
# Use this environment to test Terraform changes before applying to production.
module "supabase" {
source = "../../modules/supabase"
project_ref = "itbzhrvlgvdmzbnhzhyx"
is_production = false
}
@@ -0,0 +1,14 @@
output "environment" {
description = "Environment name"
value = "non-production"
}
output "project_ref" {
description = "Supabase project reference"
value = module.supabase.project_ref
}
output "project_url" {
description = "Supabase project URL"
value = module.supabase.project_url
}
@@ -0,0 +1,5 @@
variable "supabase_access_token" {
description = "Supabase API access token"
type = string
sensitive = true
}
@@ -0,0 +1,32 @@
terraform {
required_version = ">= 1.0"
required_providers {
supabase = {
source = "supabase/supabase"
version = "~> 1.0"
}
}
# Store state remotely (recommended for team environments)
# Uncomment and configure one of these backends:
# Option 1: Terraform Cloud
# cloud {
# organization = "your-org"
# workspaces {
# name = "sauci-non-prod"
# }
# }
# Option 2: S3 backend
# backend "s3" {
# bucket = "your-terraform-state-bucket"
# key = "sauci/non-prod/terraform.tfstate"
# region = "us-east-1"
# }
}
provider "supabase" {
access_token = var.supabase_access_token
}
+27
View File
@@ -0,0 +1,27 @@
# Local .terraform directories
.terraform/
# Terraform state files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude override files as they are usually used to override resources locally
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Ignore CLI configuration files
.terraformrc
terraform.rc
# Sensitive variable files
*.tfvars
!terraform.tfvars.example
# Provider lock files (comment out if you want to track)
# .terraform.lock.hcl
+25
View File
@@ -0,0 +1,25 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/supabase/supabase" {
version = "1.6.1"
constraints = "~> 1.0"
hashes = [
"h1:qQoXMVMdUp4Id9eKYPyTp52t6RchV38rXF10ayMeeNs=",
"zh:115437eb15451558b47984155e6e34fb18d85635f7d57a12f5933aa99231ca90",
"zh:1403a3be401cc746cbfbaac11f2b5f5f492b4a1142441b380895b20a4ca700f1",
"zh:4087e7238f2a1e930f3798c83c5544947f5096803d9132f0694e4036ee6228cc",
"zh:4d9effb75c41409700d325fd715d39cb135f958c7442e32d1686f13928e94de7",
"zh:575dcc18f686fa3aefd40e5f668e075dc2a4b628804b1f0eba0ba2bad299476d",
"zh:5a95fa37e0eb126721c2187dd389f63c09640082cd50cc521a0dfe898eea683c",
"zh:6e20a8faa09627a9ef0b7c9950af7da93c69d682fe3bfd930807db0b58aa59a1",
"zh:757a8da9f8fafcd2aa9ecb9f4cb2773567090313c736ee95d3fa41bd92535538",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:a815c20e132981abbda986b60445f9786bfd5dd3254bcb52d52e9e31cd105a04",
"zh:c665269e5474a7144ec88bb3849dacea2f1c720500e86f84279d4318806044a8",
"zh:da054f0bb9bb7432c4f5c66093262566e99403f5e4a5d72853873359472d7c1c",
"zh:de584088b3d86ec1889dd719406783f9f3698df0a91007741a156f313bf61aa7",
"zh:e83615654ce2c82b1a243c79cc53c29214e7fd81fb286a7e3befb35d5a1c3492",
"zh:ed2f7545a8217ab2eae2cf9447c6b7b1ec660764ae764c08b033f5e3800a5608",
]
}
+11
View File
@@ -0,0 +1,11 @@
# Production Environment
#
# Supabase project: ckjcrkjpmhqhiucifukx
# URL: https://ckjcrkjpmhqhiucifukx.supabase.co
module "supabase" {
source = "../../modules/supabase"
project_ref = "ckjcrkjpmhqhiucifukx"
is_production = true
}
+14
View File
@@ -0,0 +1,14 @@
output "environment" {
description = "Environment name"
value = "production"
}
output "project_ref" {
description = "Supabase project reference"
value = module.supabase.project_ref
}
output "project_url" {
description = "Supabase project URL"
value = module.supabase.project_url
}
+5
View File
@@ -0,0 +1,5 @@
variable "supabase_access_token" {
description = "Supabase API access token"
type = string
sensitive = true
}
+32
View File
@@ -0,0 +1,32 @@
terraform {
required_version = ">= 1.0"
required_providers {
supabase = {
source = "supabase/supabase"
version = "~> 1.0"
}
}
# Store state remotely (recommended for team environments)
# Uncomment and configure one of these backends:
# Option 1: Terraform Cloud
# cloud {
# organization = "your-org"
# workspaces {
# name = "sauci-prod"
# }
# }
# Option 2: S3 backend
# backend "s3" {
# bucket = "your-terraform-state-bucket"
# key = "sauci/prod/terraform.tfstate"
# region = "us-east-1"
# }
}
provider "supabase" {
access_token = var.supabase_access_token
}
+275
View File
@@ -0,0 +1,275 @@
# Supabase Project Configuration
#
# This manages the configuration for Sauci Supabase projects.
# Uses Terraform workspaces to manage multiple environments:
# - production: ckjcrkjpmhqhiucifukx
# - non-production: itbzhrvlgvdmzbnhzhyx
#
# Usage:
# terraform workspace select production
# terraform plan
#
# To import settings for a new workspace:
# terraform workspace new <name>
# terraform import supabase_settings.main <project_ref>
# Project settings - API, Auth, Network, Storage configuration
resource "supabase_settings" "main" {
project_ref = local.project_ref
# API Settings
api = jsonencode({
db_schema = "public,graphql_public"
db_extra_search_path = "public, extensions"
max_rows = 1000
})
# Auth Settings
auth = jsonencode({
# Site URL and redirects
site_url = "https://sauci.app"
uri_allow_list = "app.sauci://**,app.sauci://(auth)/login"
# Signup and auth settings
disable_signup = false
external_anonymous_users_enabled = true
external_email_enabled = true
external_phone_enabled = false
# OAuth providers
external_google_enabled = true
external_google_client_id = "764866133492-57iilbc72t1urtmurgioci9tne5l6j67.apps.googleusercontent.com,764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com,764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com"
external_google_email_optional = false
external_google_skip_nonce_check = false
external_apple_enabled = true
external_apple_client_id = "com.sauci.app"
external_apple_email_optional = false
# Disabled OAuth providers (with email_optional settings)
external_azure_enabled = false
external_azure_email_optional = false
external_bitbucket_enabled = false
external_bitbucket_email_optional = false
external_discord_enabled = false
external_discord_email_optional = false
external_facebook_enabled = false
external_facebook_email_optional = false
external_figma_enabled = false
external_figma_email_optional = false
external_github_enabled = false
external_github_email_optional = false
external_gitlab_enabled = false
external_gitlab_email_optional = false
external_kakao_enabled = false
external_kakao_email_optional = false
external_keycloak_enabled = false
external_keycloak_email_optional = false
external_linkedin_oidc_enabled = false
external_linkedin_oidc_email_optional = false
external_notion_enabled = false
external_notion_email_optional = false
external_slack_enabled = false
external_slack_email_optional = false
external_slack_oidc_enabled = false
external_slack_oidc_email_optional = false
external_spotify_enabled = false
external_spotify_email_optional = false
external_twitch_enabled = false
external_twitch_email_optional = false
external_twitter_enabled = false
external_twitter_email_optional = false
external_workos_enabled = false
external_zoom_enabled = false
external_zoom_email_optional = false
external_web3_ethereum_enabled = false
external_web3_solana_enabled = false
# JWT settings
jwt_exp = 3600
refresh_token_rotation_enabled = true
security_refresh_token_reuse_interval = 10
# Session settings
sessions_inactivity_timeout = 0
sessions_single_per_user = false
sessions_timebox = 0
# API settings
api_max_request_duration = 10
db_max_pool_size = 10
db_max_pool_size_unit = "connections"
# Password settings
password_min_length = 6
password_hibp_enabled = false
# Rate limits (rate_limit_email_sent requires custom SMTP in production)
rate_limit_anonymous_users = 30
rate_limit_email_sent = local.is_production ? 25 : 3
rate_limit_otp = 30
rate_limit_sms_sent = 30
rate_limit_token_refresh = 150
rate_limit_verify = 30
rate_limit_web3 = 30
# MFA settings
mfa_max_enrolled_factors = 10
mfa_totp_enroll_enabled = true
mfa_totp_verify_enabled = true
mfa_phone_enroll_enabled = false
mfa_phone_verify_enabled = false
mfa_phone_max_frequency = 5
mfa_phone_otp_length = 6
mfa_phone_template = "Your code is {{ .Code }}"
mfa_web_authn_enroll_enabled = false
mfa_web_authn_verify_enabled = false
# SAML
saml_enabled = false
# Security settings
security_captcha_enabled = false
security_captcha_provider = "hcaptcha"
security_manual_linking_enabled = true
security_update_password_require_reauthentication = false
# Email/mailer settings
mailer_autoconfirm = false
mailer_allow_unverified_email_sign_ins = false
mailer_secure_email_change_enabled = true
mailer_otp_exp = 3600
mailer_otp_length = 8
# Mailer notifications
mailer_notifications_email_changed_enabled = false
mailer_notifications_identity_linked_enabled = false
mailer_notifications_identity_unlinked_enabled = false
mailer_notifications_mfa_factor_enrolled_enabled = false
mailer_notifications_mfa_factor_unenrolled_enabled = false
mailer_notifications_password_changed_enabled = false
mailer_notifications_phone_changed_enabled = false
# Email subjects
mailer_subjects_confirmation = "Confirm Your Signup"
mailer_subjects_email_change = "sauci@jbrunton.co.uk"
mailer_subjects_email_changed_notification = "Your email address has been changed"
mailer_subjects_identity_linked_notification = "A new identity has been linked"
mailer_subjects_identity_unlinked_notification = "An identity has been unlinked"
mailer_subjects_invite = "You have been invited"
mailer_subjects_magic_link = "Your Magic Link"
mailer_subjects_mfa_factor_enrolled_notification = "A new MFA factor has been enrolled"
mailer_subjects_mfa_factor_unenrolled_notification = "An MFA factor has been unenrolled"
mailer_subjects_password_changed_notification = "Your password has been changed"
mailer_subjects_phone_changed_notification = "Your phone number has been changed"
mailer_subjects_reauthentication = "Confirm Reauthentication"
mailer_subjects_recovery = "Reset Your Password"
# Email templates (see templates/ directory for full HTML)
mailer_templates_confirmation_content = file("${path.module}/templates/confirmation.html")
mailer_templates_email_change_content = file("${path.module}/templates/email_change.html")
mailer_templates_invite_content = file("${path.module}/templates/invite.html")
mailer_templates_magic_link_content = file("${path.module}/templates/magic_link.html")
mailer_templates_recovery_content = file("${path.module}/templates/recovery.html")
mailer_templates_reauthentication_content = "Enter the code: {{ .Token }}"
# Notification templates (simple HTML)
mailer_templates_email_changed_notification_content = <<-EOT
<h2>Your email address has been changed</h2>
<p>The email address for your account has been changed from {{ .OldEmail }} to {{ .Email }}.</p>
<p>If you did not make this change, please contact support.</p>
EOT
mailer_templates_identity_linked_notification_content = <<-EOT
<h2>A new identity has been linked</h2>
<p>A new identity ({{ .Provider }}) has been linked to your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_identity_unlinked_notification_content = <<-EOT
<h2>An identity has been unlinked</h2>
<p>An identity ({{ .Provider }}) has been unlinked from your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_mfa_factor_enrolled_notification_content = <<-EOT
<h2>A new MFA factor has been enrolled</h2>
<p>A new factor ({{ .FactorType }}) has been enrolled for your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_mfa_factor_unenrolled_notification_content = <<-EOT
<h2>An MFA factor has been unenrolled</h2>
<p>A factor ({{ .FactorType }}) has been unenrolled for your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_password_changed_notification_content = <<-EOT
<h2>Your password has been changed</h2>
<p>This is a confirmation that the password for your account {{ .Email }} has just been changed.</p>
<p>If you did not make this change, please contact support.</p>
EOT
mailer_templates_phone_changed_notification_content = <<-EOT
<h2>Your phone number has been changed</h2>
<p>The phone number for your account {{ .Email }} has been changed from {{ .OldPhone }} to {{ .Phone }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
# SMS settings
sms_autoconfirm = false
sms_max_frequency = 5
sms_otp_exp = 60
sms_otp_length = 6
sms_provider = "twilio"
sms_template = "Your code is {{ .Code }}"
# SMTP settings (only for production - requires custom SMTP configured)
smtp_admin_email = local.is_production ? "team@send.sauci.app" : null
smtp_host = local.is_production ? "smtp.resend.com" : null
smtp_max_frequency = local.is_production ? 1 : null
smtp_port = local.is_production ? "465" : null
smtp_sender_name = local.is_production ? "Sauci App" : null
smtp_user = local.is_production ? "resend" : null
# Hook settings (only include hooks available on current plan)
hook_after_user_created_enabled = false
hook_before_user_created_enabled = false
hook_custom_access_token_enabled = false
hook_send_email_enabled = false
hook_send_sms_enabled = false
# Note: hook_mfa_verification_attempt and hook_password_verification_attempt
# require Team plan or higher - omitted to avoid 402 errors
})
# Database Settings (empty - managed via migrations)
database = jsonencode({})
# Network Settings
network = jsonencode({
restrictions = ["0.0.0.0/0", "::/0"]
})
# Storage Settings
storage = jsonencode({
fileSizeLimit = 52428800 # 50MB
databasePoolMode = "recycled"
migrationVersion = "buckets-objects-grants-postgres"
capabilities = {
iceberg_catalog = true
list_v2 = true
}
features = {
imageTransformation = { enabled = true }
s3Protocol = { enabled = true }
icebergCatalog = {
enabled = true
maxCatalogs = 2
maxNamespaces = 10
maxTables = 10
}
vectorBuckets = {
enabled = false
maxBuckets = 10
maxIndexes = 5
}
}
external = {
upstreamTarget = "canary"
}
})
}
+264
View File
@@ -0,0 +1,264 @@
# Supabase Project Configuration Module
#
# This module manages Supabase project settings including API, Auth, Network, and Storage.
# Environment-specific settings (SMTP, rate limits) are controlled via input variables.
resource "supabase_settings" "main" {
project_ref = var.project_ref
# API Settings
api = jsonencode({
db_schema = "public,graphql_public"
db_extra_search_path = "public, extensions"
max_rows = 1000
})
# Auth Settings
auth = jsonencode({
# Site URL and redirects
site_url = "https://sauci.app"
uri_allow_list = "app.sauci://**,app.sauci://(auth)/login"
# Signup and auth settings
disable_signup = false
external_anonymous_users_enabled = true
external_email_enabled = true
external_phone_enabled = false
# OAuth providers
external_google_enabled = true
external_google_client_id = "764866133492-57iilbc72t1urtmurgioci9tne5l6j67.apps.googleusercontent.com,764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com,764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com"
external_google_email_optional = false
external_google_skip_nonce_check = false
external_apple_enabled = true
external_apple_client_id = "com.sauci.app"
external_apple_email_optional = false
# Disabled OAuth providers (with email_optional settings)
external_azure_enabled = false
external_azure_email_optional = false
external_bitbucket_enabled = false
external_bitbucket_email_optional = false
external_discord_enabled = false
external_discord_email_optional = false
external_facebook_enabled = false
external_facebook_email_optional = false
external_figma_enabled = false
external_figma_email_optional = false
external_github_enabled = false
external_github_email_optional = false
external_gitlab_enabled = false
external_gitlab_email_optional = false
external_kakao_enabled = false
external_kakao_email_optional = false
external_keycloak_enabled = false
external_keycloak_email_optional = false
external_linkedin_oidc_enabled = false
external_linkedin_oidc_email_optional = false
external_notion_enabled = false
external_notion_email_optional = false
external_slack_enabled = false
external_slack_email_optional = false
external_slack_oidc_enabled = false
external_slack_oidc_email_optional = false
external_spotify_enabled = false
external_spotify_email_optional = false
external_twitch_enabled = false
external_twitch_email_optional = false
external_twitter_enabled = false
external_twitter_email_optional = false
external_workos_enabled = false
external_zoom_enabled = false
external_zoom_email_optional = false
external_web3_ethereum_enabled = false
external_web3_solana_enabled = false
# JWT settings
jwt_exp = 3600
refresh_token_rotation_enabled = true
security_refresh_token_reuse_interval = 10
# Session settings
sessions_inactivity_timeout = 0
sessions_single_per_user = false
sessions_timebox = 0
# API settings
api_max_request_duration = 10
db_max_pool_size = 10
db_max_pool_size_unit = "connections"
# Password settings
password_min_length = 6
password_hibp_enabled = false
# Rate limits (rate_limit_email_sent requires custom SMTP in production)
rate_limit_anonymous_users = 30
rate_limit_email_sent = var.is_production ? 25 : 3
rate_limit_otp = 30
rate_limit_sms_sent = 30
rate_limit_token_refresh = 150
rate_limit_verify = 30
rate_limit_web3 = 30
# MFA settings
mfa_max_enrolled_factors = 10
mfa_totp_enroll_enabled = true
mfa_totp_verify_enabled = true
mfa_phone_enroll_enabled = false
mfa_phone_verify_enabled = false
mfa_phone_max_frequency = 5
mfa_phone_otp_length = 6
mfa_phone_template = "Your code is {{ .Code }}"
mfa_web_authn_enroll_enabled = false
mfa_web_authn_verify_enabled = false
# SAML
saml_enabled = false
# Security settings
security_captcha_enabled = false
security_captcha_provider = "hcaptcha"
security_manual_linking_enabled = true
security_update_password_require_reauthentication = false
# Email/mailer settings
mailer_autoconfirm = false
mailer_allow_unverified_email_sign_ins = false
mailer_secure_email_change_enabled = true
mailer_otp_exp = 3600
mailer_otp_length = 8
# Mailer notifications
mailer_notifications_email_changed_enabled = false
mailer_notifications_identity_linked_enabled = false
mailer_notifications_identity_unlinked_enabled = false
mailer_notifications_mfa_factor_enrolled_enabled = false
mailer_notifications_mfa_factor_unenrolled_enabled = false
mailer_notifications_password_changed_enabled = false
mailer_notifications_phone_changed_enabled = false
# Email subjects
mailer_subjects_confirmation = "Confirm Your Signup"
mailer_subjects_email_change = "sauci@jbrunton.co.uk"
mailer_subjects_email_changed_notification = "Your email address has been changed"
mailer_subjects_identity_linked_notification = "A new identity has been linked"
mailer_subjects_identity_unlinked_notification = "An identity has been unlinked"
mailer_subjects_invite = "You have been invited"
mailer_subjects_magic_link = "Your Magic Link"
mailer_subjects_mfa_factor_enrolled_notification = "A new MFA factor has been enrolled"
mailer_subjects_mfa_factor_unenrolled_notification = "An MFA factor has been unenrolled"
mailer_subjects_password_changed_notification = "Your password has been changed"
mailer_subjects_phone_changed_notification = "Your phone number has been changed"
mailer_subjects_reauthentication = "Confirm Reauthentication"
mailer_subjects_recovery = "Reset Your Password"
# Email templates (loaded from templates directory)
mailer_templates_confirmation_content = file("${path.module}/templates/confirmation.html")
mailer_templates_email_change_content = file("${path.module}/templates/email_change.html")
mailer_templates_invite_content = file("${path.module}/templates/invite.html")
mailer_templates_magic_link_content = file("${path.module}/templates/magic_link.html")
mailer_templates_recovery_content = file("${path.module}/templates/recovery.html")
mailer_templates_reauthentication_content = "Enter the code: {{ .Token }}"
# Notification templates (simple HTML)
mailer_templates_email_changed_notification_content = <<-EOT
<h2>Your email address has been changed</h2>
<p>The email address for your account has been changed from {{ .OldEmail }} to {{ .Email }}.</p>
<p>If you did not make this change, please contact support.</p>
EOT
mailer_templates_identity_linked_notification_content = <<-EOT
<h2>A new identity has been linked</h2>
<p>A new identity ({{ .Provider }}) has been linked to your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_identity_unlinked_notification_content = <<-EOT
<h2>An identity has been unlinked</h2>
<p>An identity ({{ .Provider }}) has been unlinked from your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_mfa_factor_enrolled_notification_content = <<-EOT
<h2>A new MFA factor has been enrolled</h2>
<p>A new factor ({{ .FactorType }}) has been enrolled for your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_mfa_factor_unenrolled_notification_content = <<-EOT
<h2>An MFA factor has been unenrolled</h2>
<p>A factor ({{ .FactorType }}) has been unenrolled for your account {{ .Email }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
mailer_templates_password_changed_notification_content = <<-EOT
<h2>Your password has been changed</h2>
<p>This is a confirmation that the password for your account {{ .Email }} has just been changed.</p>
<p>If you did not make this change, please contact support.</p>
EOT
mailer_templates_phone_changed_notification_content = <<-EOT
<h2>Your phone number has been changed</h2>
<p>The phone number for your account {{ .Email }} has been changed from {{ .OldPhone }} to {{ .Phone }}.</p>
<p>If you did not make this change, please contact support immediately.</p>
EOT
# SMS settings
sms_autoconfirm = false
sms_max_frequency = 5
sms_otp_exp = 60
sms_otp_length = 6
sms_provider = "twilio"
sms_template = "Your code is {{ .Code }}"
# SMTP settings (only for production - requires custom SMTP configured)
smtp_admin_email = var.is_production ? "team@send.sauci.app" : null
smtp_host = var.is_production ? "smtp.resend.com" : null
smtp_max_frequency = var.is_production ? 1 : null
smtp_port = var.is_production ? "465" : null
smtp_sender_name = var.is_production ? "Sauci App" : null
smtp_user = var.is_production ? "resend" : null
# Hook settings (only include hooks available on current plan)
hook_after_user_created_enabled = false
hook_before_user_created_enabled = false
hook_custom_access_token_enabled = false
hook_send_email_enabled = false
hook_send_sms_enabled = false
# Note: hook_mfa_verification_attempt and hook_password_verification_attempt
# require Team plan or higher - omitted to avoid 402 errors
})
# Database Settings (empty - managed via migrations)
database = jsonencode({})
# Network Settings
network = jsonencode({
restrictions = ["0.0.0.0/0", "::/0"]
})
# Storage Settings
storage = jsonencode({
fileSizeLimit = 52428800 # 50MB
databasePoolMode = "recycled"
migrationVersion = "buckets-objects-grants-postgres"
capabilities = {
iceberg_catalog = true
list_v2 = true
}
features = {
imageTransformation = { enabled = true }
s3Protocol = { enabled = true }
icebergCatalog = {
enabled = true
maxCatalogs = 2
maxNamespaces = 10
maxTables = 10
}
vectorBuckets = {
enabled = false
maxBuckets = 10
maxIndexes = 5
}
}
external = {
upstreamTarget = "canary"
}
})
}
+9
View File
@@ -0,0 +1,9 @@
output "project_ref" {
description = "Supabase project reference"
value = var.project_ref
}
output "project_url" {
description = "Supabase project URL"
value = "https://${var.project_ref}.supabase.co"
}
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>Confirm Your Email - Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#e94560">
<tr>
<td align="center" style="padding: 16px 40px; background-color: #e94560;">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#128140;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
Welcome to Sauci
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
You're one step away from deepening your connection. Confirm your email to start your journey together.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:180px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Confirm Email</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Confirm Email
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center;">
This link will expire in 24 hours.
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
If you didn't create an account, you can safely ignore this email.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>Confirm Email Change - Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#9993;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
Confirm Email Change
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
You requested to change your email address. Click the button below to confirm this change.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:200px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Confirm New Email</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Confirm New Email
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Info Notice -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 8px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #8888aa; text-align: center; line-height: 1.5;">
&#128233; After confirming, you'll use your new email address to sign in to Sauci.
</p>
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 24px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center;">
Didn't request this change?
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
If you didn't request an email change, please secure your account by resetting your password immediately.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>You're Invited to Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#128149;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
You've Been Invited
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
Someone special has invited you to join Sauci, the app designed to help couples deepen their connection and discover new things about each other.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:200px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Accept Invitation</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Accept Invitation
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Features Section -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#222240" style="background-color: #222240; border-radius: 12px;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 12px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; font-weight: 600; color: #9999aa; text-align: center; text-transform: uppercase; letter-spacing: 1px;">
What awaits you
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; color: #8888aa; text-align: center;">
&#128293; Thoughtful questions to spark conversations
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; color: #8888aa; text-align: center;">
&#128140; Discover mutual interests with your partner
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; color: #8888aa; text-align: center;">
&#128172; Private chats when you both match
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 24px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
This invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>Sign In to Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#10024;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
Your Magic Link
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
Click the button below to securely sign in to your Sauci account. No password needed.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:180px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Sign In to Sauci</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Sign In to Sauci
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Security Notice -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 8px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #8888aa; text-align: center; line-height: 1.5;">
&#128274; This link is unique to you and expires in 1 hour for your security.
</p>
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 24px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
If you didn't request this sign in link, you can safely ignore this email.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>Reset Your Password - Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#128272;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
Reset Your Password
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
We received a request to reset your password. Click the button below to choose a new one.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:180px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Reset Password</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Reset Password
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Security Notice -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 8px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #8888aa; text-align: center; line-height: 1.5;">
&#9200; This link expires in 1 hour. After resetting, you'll need to sign in again on all devices.
</p>
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 24px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center;">
Didn't request this?
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
If you didn't request a password reset, you can safely ignore this email. Your password won't change until you click the link above.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
variable "project_ref" {
description = "Supabase project reference ID"
type = string
}
variable "is_production" {
description = "Whether this is the production environment (enables SMTP, higher rate limits)"
type = bool
default = false
}
+10
View File
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.0"
required_providers {
supabase = {
source = "supabase/supabase"
version = "~> 1.0"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
output "workspace" {
description = "Current Terraform workspace"
value = terraform.workspace
}
output "project_ref" {
description = "Supabase project reference"
value = local.project_ref
}
output "project_url" {
description = "Supabase project URL"
value = "https://${local.project_ref}.supabase.co"
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
set -e
# Import existing Supabase production settings into Terraform state
# This script helps extract current configuration for IaC management
#
# Prerequisites:
# 1. Set SUPABASE_ACCESS_TOKEN environment variable
# 2. Run from the terraform directory
PROJECT_REF="ckjcrkjpmhqhiucifukx"
echo "=== Supabase Terraform Import Script ==="
echo ""
# Check for access token
if [ -z "$SUPABASE_ACCESS_TOKEN" ]; then
echo "Error: SUPABASE_ACCESS_TOKEN environment variable is not set"
echo ""
echo "Get your token from: https://supabase.com/dashboard/account/tokens"
echo "Then run: export SUPABASE_ACCESS_TOKEN=sbp_xxxxx"
exit 1
fi
# Export for Terraform
export TF_VAR_supabase_access_token="$SUPABASE_ACCESS_TOKEN"
echo "1. Initializing Terraform..."
terraform init
echo ""
echo "2. Importing production settings..."
echo " Project: $PROJECT_REF"
echo ""
# Import settings (will fail if already imported, that's OK)
terraform import supabase_settings.production "$PROJECT_REF" 2>/dev/null || echo " (Settings may already be imported)"
echo ""
echo "3. Showing current state..."
echo ""
terraform show
echo ""
echo "=== Import Complete ==="
echo ""
echo "Next steps:"
echo "1. Review the terraform show output above"
echo "2. Update main.tf to match your actual production settings"
echo "3. Run 'terraform plan' to verify no changes are needed"
echo "4. Commit the updated main.tf"
+206
View File
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>Confirm Your Email - Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#e94560">
<tr>
<td align="center" style="padding: 16px 40px; background-color: #e94560;">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#128140;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
Welcome to Sauci
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
You're one step away from deepening your connection. Confirm your email to start your journey together.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:180px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Confirm Email</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Confirm Email
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center;">
This link will expire in 24 hours.
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
If you didn't create an account, you can safely ignore this email.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>Confirm Email Change - Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#9993;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
Confirm Email Change
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
You requested to change your email address. Click the button below to confirm this change.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:200px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Confirm New Email</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Confirm New Email
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Info Notice -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 8px;">
<tr>
<td style="padding: 16px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #8888aa; text-align: center; line-height: 1.5;">
&#128233; After confirming, you'll use your new email address to sign in to Sauci.
</p>
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 24px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center;">
Didn't request this change?
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
If you didn't request an email change, please secure your account by resetting your password immediately.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>You're Invited to Sauci</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
table { border-collapse: collapse; }
td { font-family: Arial, sans-serif; }
</style>
<![endif]-->
<style>
body { margin: 0 !important; padding: 0 !important; }
table { border-spacing: 0 !important; border-collapse: collapse !important; }
td { padding: 0; }
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
a { text-decoration: none; }
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; word-spacing: normal; background-color: #0d0d1a;">
<div role="article" aria-roledescription="email" lang="en" style="text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; background-color: #0d0d1a;">
<!--[if mso]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#0d0d1a">
<tr>
<td align="center">
<![endif]-->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 auto;" bgcolor="#0d0d1a">
<tr>
<td align="center" valign="top" bgcolor="#0d0d1a" style="background-color: #0d0d1a;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="520" class="email-container" style="margin: 0 auto;">
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
<!-- Logo Section -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 0 20px 24px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" bgcolor="#e94560" style="background-color: #e94560; padding: 16px 60px; border-radius: 8px;">
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 32px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">Sauci</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Card -->
<tr>
<td class="content-padding" style="padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#1a1a2e" style="background-color: #1a1a2e; border-radius: 16px;">
<!-- Top Accent Bar -->
<tr>
<td height="4" bgcolor="#e94560" style="background-color: #e94560; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- Card Content -->
<tr>
<td style="padding: 40px 32px;" bgcolor="#1a1a2e">
<!-- Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" valign="middle" width="56" height="56" bgcolor="#2a2a3e" style="background-color: #2a2a3e; border-radius: 28px; font-size: 24px;">
&#128149;
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Heading -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h1 style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 24px; font-weight: 600; color: #ffffff; line-height: 1.3;">
You've Been Invited
</h1>
</td>
</tr>
</table>
<!-- Subtext -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; color: #9999aa; text-align: center; line-height: 1.6;">
Someone special has invited you to join Sauci, the app designed to help couples deepen their connection and discover new things about each other.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center" style="padding-bottom: 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:200px;" arcsize="25%" strokecolor="#e94560" fillcolor="#e94560">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:15px;font-weight:bold;">Accept Invitation</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{ .ConfirmationURL }}" target="_blank" style="display: inline-block; padding: 14px 40px; background-color: #e94560; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 15px; font-weight: 600; border-radius: 12px; text-decoration: none;">
Accept Invitation
</a>
<!--<![endif]-->
</td>
</tr>
</table>
<!-- Features Section -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#222240" style="background-color: #222240; border-radius: 12px;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 12px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; font-weight: 600; color: #9999aa; text-align: center; text-transform: uppercase; letter-spacing: 1px;">
What awaits you
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; color: #8888aa; text-align: center;">
&#128293; Thoughtful questions to spark conversations
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; color: #8888aa; text-align: center;">
&#128140; Discover mutual interests with your partner
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; color: #8888aa; text-align: center;">
&#128172; Private chats when you both match
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 24px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td height="1" bgcolor="#333344" style="background-color: #333344; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Alternative Link -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 13px; color: #666677; text-align: center; line-height: 1.6;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; text-align: center; word-break: break-all; line-height: 1.5;">
<a href="{{ .ConfirmationURL }}" style="color: #e94560; text-decoration: underline;">{{ .ConfirmationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" bgcolor="#0d0d1a" style="background-color: #0d0d1a; padding: 32px 20px;">
<p style="margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 12px; color: #555566; text-align: center;">
This invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email.
</p>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="40" style="font-size: 0; line-height: 0;" bgcolor="#0d0d1a">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more