run-stack 0.5.1

One command to boot a full local stack in Docker: API, Vite apps, Expo mobile, desktop renderer, database, mail and a status dashboard.
Documentation
#!/usr/bin/env python3
"""Read and sync Metro port settings in a React Native / Expo app directory."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

PORT_KEYS = (
    "RCT_METRO_PORT",
    "METRO_PORT",
    "EXPO_DEV_SERVER_PORT",
    "REACT_NATIVE_PACKAGER_PORT",
)
METRO_SCRIPT = re.compile(
    r"\b(expo start|react-native start|metro start)\b.*?((?:--port|-p)\s+(\d+))?",
    re.IGNORECASE,
)
PORT_FLAG = re.compile(r"(--port|-p)\s+\d+")
METRO_CONFIG_PORT = re.compile(
    r"(port\s*:\s*|['\"]port['\"]\s*:\s*)(\d+)",
    re.IGNORECASE,
)
GRADLE_PORT = re.compile(r"^reactNativeDevServerPort=(\d+)\s*$", re.MULTILINE)
ENV_PORT = re.compile(
    r"^(" + "|".join(PORT_KEYS) + r")=(\d+)\s*$",
    re.MULTILINE,
)


def read_port(app_dir: Path) -> int | None:
    found: list[int] = []

    package_json = app_dir / "package.json"
    if package_json.is_file():
        try:
            scripts = json.loads(package_json.read_text(encoding="utf-8")).get(
                "scripts", {}
            )
        except (OSError, ValueError, TypeError):
            scripts = {}
        for body in scripts.values():
            if not isinstance(body, str):
                continue
            match = PORT_FLAG.search(body)
            if match and re.search(
                r"\b(expo start|react-native start|metro start|expo run)\b",
                body,
                re.IGNORECASE,
            ):
                found.append(int(match.group(0).split()[-1]))

    for name in ("metro.config.js", "metro.config.ts", "metro.config.cjs", "metro.config.mjs"):
        path = app_dir / name
        if not path.is_file():
            continue
        text = path.read_text(encoding="utf-8")
        match = METRO_CONFIG_PORT.search(text)
        if match:
            found.append(int(match.group(2)))

    gradle = app_dir / "android" / "gradle.properties"
    if gradle.is_file():
        match = GRADLE_PORT.search(gradle.read_text(encoding="utf-8"))
        if match:
            found.append(int(match.group(1)))

    for env_path in sorted(app_dir.glob(".env*")):
        if env_path.name.endswith(".example"):
            continue
        match = ENV_PORT.search(env_path.read_text(encoding="utf-8"))
        if match:
            found.append(int(match.group(2)))

    if not found:
        return None
    return found[0] if len(set(found)) == 1 else max(set(found), key=found.count)


def _set_port_flag(script: str, port: int) -> str:
    if PORT_FLAG.search(script):
        return PORT_FLAG.sub(f"--port {port}", script, count=1)
    if re.search(r"\b(expo start|react-native start|metro start)\b", script, re.IGNORECASE):
        return f"{script.rstrip()} --port {port}"
    return script


def sync_port(app_dir: Path, port: int) -> list[str]:
    changed: list[str] = []

    package_json = app_dir / "package.json"
    if package_json.is_file():
        data = json.loads(package_json.read_text(encoding="utf-8"))
        scripts = data.get("scripts", {})
        updated = False
        for name, body in list(scripts.items()):
            if not isinstance(body, str):
                continue
            if not re.search(
                r"\b(expo start|react-native start|metro start|expo run:ios|expo run:android)\b",
                body,
                re.IGNORECASE,
            ):
                continue
            new_body = _set_port_flag(body, port)
            if new_body != body:
                scripts[name] = new_body
                updated = True
        if updated:
            package_json.write_text(
                json.dumps(data, indent=2, ensure_ascii=False) + "\n",
                encoding="utf-8",
            )
            changed.append(str(package_json.relative_to(app_dir)))

    for name in ("metro.config.js", "metro.config.ts", "metro.config.cjs", "metro.config.mjs"):
        path = app_dir / name
        if not path.is_file():
            continue
        text = path.read_text(encoding="utf-8")
        new_text, count = METRO_CONFIG_PORT.subn(rf"\g<1>{port}", text, count=1)
        if count:
            path.write_text(new_text, encoding="utf-8")
            changed.append(str(path.relative_to(app_dir)))

    gradle = app_dir / "android" / "gradle.properties"
    if gradle.is_file():
        text = gradle.read_text(encoding="utf-8")
        if GRADLE_PORT.search(text):
            new_text = GRADLE_PORT.sub(f"reactNativeDevServerPort={port}", text)
        else:
            new_text = text.rstrip() + f"\nreactNativeDevServerPort={port}\n"
        if new_text != text:
            gradle.write_text(new_text, encoding="utf-8")
            changed.append(str(gradle.relative_to(app_dir)))

    env_path = app_dir / ".env.development.local"
    lines: list[str] = []
    if env_path.is_file():
        lines = env_path.read_text(encoding="utf-8").splitlines()
    keys = set(PORT_KEYS)
    kept = [line for line in lines if line.split("=", 1)[0].strip() not in keys]
    kept.extend(f"{key}={port}" for key in PORT_KEYS[:2])
    new_env = "\n".join(kept).rstrip() + "\n"
    old_env = env_path.read_text(encoding="utf-8") if env_path.is_file() else ""
    if new_env != old_env:
        env_path.write_text(new_env, encoding="utf-8")
        changed.append(str(env_path.relative_to(app_dir)))

    xcode_env = app_dir / "ios" / ".xcode.env.local"
    if (app_dir / "ios").is_dir():
        xcode_lines: list[str] = []
        if xcode_env.is_file():
            xcode_lines = [
                line
                for line in xcode_env.read_text(encoding="utf-8").splitlines()
                if not line.startswith("export RCT_METRO_PORT=")
            ]
        xcode_lines.append(f"export RCT_METRO_PORT={port}")
        xcode_body = "\n".join(xcode_lines).rstrip() + "\n"
        old_xcode = xcode_env.read_text(encoding="utf-8") if xcode_env.is_file() else ""
        if xcode_body != old_xcode:
            xcode_env.write_text(xcode_body, encoding="utf-8")
            changed.append(str(xcode_env.relative_to(app_dir)))

    return changed


def main() -> int:
    if len(sys.argv) < 3:
        print("usage: mobile-port.py read <app-dir>", file=sys.stderr)
        print("       mobile-port.py sync <app-dir> <port>", file=sys.stderr)
        return 1

    command = sys.argv[1]
    app_dir = Path(sys.argv[2]).resolve()
    if not app_dir.is_dir():
        print(f"error: not a directory: {app_dir}", file=sys.stderr)
        return 1

    if command == "read":
        port = read_port(app_dir)
        if port is not None:
            print(port)
        return 0

    if command == "sync":
        port = int(sys.argv[3])
        changed = sync_port(app_dir, port)
        for path in changed:
            print(path)
        return 0

    print(f"error: unknown command: {command}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    raise SystemExit(main())