start_local.py 10.7 KB
from __future__ import annotations

import argparse
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Sequence

from scripts.dev.repair_local_postgres import ensure_local_postgres_ready
from services.shared.config.base import (
    PROJECT_ROOT,
    resolve_env_file_candidates,
    resolve_preferred_env_file,
)
from utils.runtime_paths import ensure_runtime_dirs


FRONTEND_DIR = PROJECT_ROOT / "apps" / "web_ui"
FRONTEND_BUILD_INDEX = PROJECT_ROOT / "static" / "frontend" / "index.html"
PLACEHOLDER_VALUES = {
    "your_db_host",
    "your_db_user",
    "your_db_password",
    "your_db_name",
}
LOCAL_INVALID_DB_HOSTS = {"db"}
DEFAULT_FRONTEND_HOST = "127.0.0.1"
DEFAULT_FRONTEND_PORT = 9527


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Start BettaFish with a local-first development workflow.",
    )
    parser.add_argument(
        "--env-file",
        help="Optional env file override. Defaults to .env.local first, then .env.",
    )
    parser.add_argument(
        "--frontend-mode",
        choices=("dev", "build", "skip"),
        default="dev",
        help=(
            "How to handle the frontend. "
            "'dev' starts the Vite HMR frontend for local development, "
            "'build' generates deployment-compatible static files for Flask or Docker to serve, "
            "'skip' starts only the backend API."
        ),
    )
    parser.add_argument(
        "--build-frontend",
        action="store_true",
        help="Compatibility alias for --frontend-mode build.",
    )
    parser.add_argument(
        "--skip-frontend-check",
        action="store_true",
        help="Do not warn when deployment-compatible assets in static/frontend are missing in non-dev modes.",
    )
    parser.add_argument(
        "--frontend-host",
        default=DEFAULT_FRONTEND_HOST,
        help=f"Host used by the Vite dev server. Default: {DEFAULT_FRONTEND_HOST}.",
    )
    parser.add_argument(
        "--frontend-port",
        type=int,
        default=DEFAULT_FRONTEND_PORT,
        help=f"Port used by the Vite dev server. Default: {DEFAULT_FRONTEND_PORT}.",
    )
    args = parser.parse_args()

    if args.build_frontend:
        args.frontend_mode = "build"

    return args


def read_env_values(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    if not path.exists():
        return values

    for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        values[key.strip()] = value.strip()
    return values


def resolve_runtime_env_file(explicit: str | None) -> Path | None:
    candidates = resolve_env_file_candidates(explicit_env=explicit)
    existing = [path for path in candidates if path.exists()]
    if existing:
        return resolve_preferred_env_file(tuple(existing))
    return None


def validate_local_database_env(values: dict[str, str]) -> list[str]:
    issues: list[str] = []

    for key in ("DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"):
        value = values.get(key, "").strip()
        if not value or value in PLACEHOLDER_VALUES:
            issues.append(f"{key} is still using a placeholder value.")

    db_host = values.get("DB_HOST", "").strip().lower()
    if db_host in LOCAL_INVALID_DB_HOSTS:
        issues.append("DB_HOST still points to the Docker service name 'db'.")

    return issues


def ensure_npm_available() -> bool:
    if shutil.which("npm") is not None:
        return True

    print(
        "npm was not found in PATH. Install Node.js before starting the frontend.",
        file=sys.stderr,
    )
    return False


def ensure_frontend_dependencies() -> int:
    if not ensure_npm_available():
        return 1

    node_modules = FRONTEND_DIR / "node_modules"
    if node_modules.exists():
        return 0

    print("apps/web_ui/node_modules is missing. Running npm install ...")
    install_result = subprocess.run(
        ["npm", "--prefix", "apps/web_ui", "install"],
        cwd=PROJECT_ROOT,
    )
    return install_result.returncode


def ensure_frontend_build() -> int:
    dependency_code = ensure_frontend_dependencies()
    if dependency_code != 0:
        return dependency_code

    print("Building frontend assets for Flask static hosting ...")
    build_result = subprocess.run(
        ["npm", "--prefix", "apps/web_ui", "run", "build"],
        cwd=PROJECT_ROOT,
    )
    return build_result.returncode


def try_prepare_local_postgres(env_file: Path) -> None:
    try:
        result = ensure_local_postgres_ready(env_file)
    except Exception as exc:
        print(
            f"Warning: automatic PostgreSQL repair failed: {exc}",
            file=sys.stderr,
        )
        print(
            "The platform will keep starting, but database-backed flows may fail.",
            file=sys.stderr,
        )
        return

    if result.status == "failed":
        print(f"Warning: {result.message}", file=sys.stderr)
        print(
            "The platform will keep starting, but database-backed flows may fail.",
            file=sys.stderr,
        )
        return

    if result.status in {"repaired", "ready"}:
        print(result.message)


def frontend_dev_url(host: str, port: int) -> str:
    return f"http://{host}:{port}"


def build_runtime_env(env_file: Path, *, frontend_url: str | None) -> dict[str, str]:
    env = os.environ.copy()
    env["BETTAFISH_ENV_FILE"] = str(env_file)
    env.setdefault("PYTHONIOENCODING", "utf-8")
    env.setdefault("PYTHONUTF8", "1")
    env.setdefault("PYTHONUNBUFFERED", "1")

    if frontend_url:
        env["BETTAFISH_FRONTEND_DEV_URL"] = frontend_url.rstrip("/")
    else:
        env.pop("BETTAFISH_FRONTEND_DEV_URL", None)

    return env


def create_process(command: Sequence[str], *, env: dict[str, str]) -> subprocess.Popen[bytes]:
    kwargs: dict[str, object] = {
        "cwd": PROJECT_ROOT,
        "env": env,
    }

    if sys.platform == "win32":
        kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP

    return subprocess.Popen(command, **kwargs)


def stop_process(process: subprocess.Popen[bytes], *, name: str) -> None:
    if process.poll() is not None:
        return

    print(f"Stopping {name} (pid={process.pid}) ...")

    if sys.platform == "win32":
        subprocess.run(
            ["taskkill", "/PID", str(process.pid), "/T", "/F"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            check=False,
        )
        return

    process.terminate()
    try:
        process.wait(timeout=5)
    except subprocess.TimeoutExpired:
        process.kill()


def run_backend_only(env: dict[str, str]) -> int:
    print("Starting backend API: python -m apps.web_api")
    result = subprocess.run(
        [sys.executable, "-m", "apps.web_api"],
        cwd=PROJECT_ROOT,
        env=env,
    )
    return result.returncode


def run_dev_stack(env: dict[str, str], *, host: str, port: int) -> int:
    frontend_url = frontend_dev_url(host, port)

    print("Starting backend API: python -m apps.web_api")
    backend_process = create_process(
        [sys.executable, "-m", "apps.web_api"],
        env=env,
    )

    print(
        "Starting frontend dev server: "
        f"npm --prefix apps/web_ui run dev -- --host {host} --port {port} --strictPort"
    )
    frontend_process = create_process(
        [
            "npm",
            "--prefix",
            "apps/web_ui",
            "run",
            "dev",
            "--",
            "--host",
            host,
            "--port",
            str(port),
            "--strictPort",
        ],
        env=env,
    )

    print("")
    print("BettaFish local dev stack is starting.")
    print(f"Frontend (Vite HMR): {frontend_url}")
    print("Backend API: http://127.0.0.1:5000")
    print("Press Ctrl+C to stop both processes.")
    print("")

    try:
        while True:
            backend_code = backend_process.poll()
            frontend_code = frontend_process.poll()

            if backend_code is not None:
                if frontend_process.poll() is None:
                    stop_process(frontend_process, name="frontend dev server")
                return backend_code

            if frontend_code is not None:
                if backend_process.poll() is None:
                    stop_process(backend_process, name="backend API")
                return frontend_code

            time.sleep(0.5)
    except KeyboardInterrupt:
        print("")
        print("Stopping local dev stack ...")
        stop_process(frontend_process, name="frontend dev server")
        stop_process(backend_process, name="backend API")
        return 130


def main() -> int:
    args = parse_args()
    env_file = resolve_runtime_env_file(args.env_file)

    if env_file is None:
        print(
            "No runtime env file was found. Copy .env.local.example to .env.local "
            "or .env.example to .env before retrying.",
            file=sys.stderr,
        )
        return 1

    env_values = read_env_values(env_file)
    issues = validate_local_database_env(env_values)
    if issues:
        print("The current env file is not ready for local startup:", file=sys.stderr)
        for issue in issues:
            print(f"- {issue}", file=sys.stderr)
        print(
            "Recommended fix: start from .env.local.example and fill in the real values.",
            file=sys.stderr,
        )
        return 1

    if args.frontend_mode == "build":
        build_code = ensure_frontend_build()
        if build_code != 0:
            return build_code
    elif args.frontend_mode == "dev":
        dependency_code = ensure_frontend_dependencies()
        if dependency_code != 0:
            return dependency_code
    elif not args.skip_frontend_check and not FRONTEND_BUILD_INDEX.exists():
        print(
            "Tip: static/frontend/index.html is missing. "
            "Use --frontend-mode build if you want Flask or Docker to serve deployment-compatible static frontend files."
        )

    try_prepare_local_postgres(env_file)
    ensure_runtime_dirs()

    frontend_url = None
    if args.frontend_mode == "dev":
        frontend_url = frontend_dev_url(args.frontend_host, args.frontend_port)

    env = build_runtime_env(
        env_file,
        frontend_url=frontend_url,
    )

    try:
        env_label = env_file.relative_to(PROJECT_ROOT)
    except ValueError:
        env_label = env_file

    print(f"Using env file: {env_label}")

    if args.frontend_mode == "dev":
        return run_dev_stack(
            env,
            host=args.frontend_host,
            port=args.frontend_port,
        )

    return run_backend_only(env)


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