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())