run-stack 0.7.0

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
"""Mac LaunchAgent: dashboard (Docker) asks this process to act on the host.

Must run on the Mac — a Linux container cannot execute xcrun, adb, or
`docker compose` against the developer's workspace the way run-stack does.
Started/stopped by ./run.sh up and ./run.sh down, not a terminal.
"""

import json
import os
import re
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "open-mobile.sh")
UP_SCRIPT = os.path.join(HERE, "up.sh")
PORT = int(os.environ.get("HOST_OPEN_PORT", "8091"))
# Which workspace this opener belongs to. Two stacks that picked the same port
# both answer /healthz, so the answer has to say who is answering.
RUN_DIR = os.environ.get("RUN_DIR") or os.path.dirname(HERE)
WORKSPACE = os.environ.get("RUN_WORKSPACE_DIR") or RUN_DIR
PACKAGE_DIR = os.environ.get("RUN_PACKAGE_DIR") or os.path.dirname(HERE)
SERVICE_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]*$")


def parse_open_output(out):
    url = None
    lines = []
    for line in out.splitlines():
        if line.startswith("URL="):
            url = line[4:].strip()
        else:
            lines.append(line)
    return {"ok": True, "detail": "\n".join(lines).strip(), "url": url}


def run_up(services):
    bad = [s for s in services if not SERVICE_NAME.match(s)]
    if bad:
        return {"ok": False, "detail": f"invalid service name: {bad[0]}"}, 400
    if not services:
        return {"ok": False, "detail": "no services to start"}, 400

    env = os.environ.copy()
    env["RUN_UP_BUILD"] = "0"
    env.setdefault("RUN_WORKSPACE_DIR", WORKSPACE)
    env.setdefault("RUN_PACKAGE_DIR", PACKAGE_DIR)
    env.setdefault("RUN_DIR", RUN_DIR)
    try:
        out = subprocess.check_output(
            ["bash", UP_SCRIPT, *services],
            stderr=subprocess.STDOUT,
            text=True,
            timeout=int(os.environ.get("HOST_OPEN_TIMEOUT", "3600")),
            env=env,
            cwd=WORKSPACE,
        )
        tail = "\n".join(out.splitlines()[-40:]).strip()
        return {
            "ok": True,
            "detail": f"Started {' '.join(services)}",
            "output": tail,
        }, 200
    except subprocess.CalledProcessError as exc:
        tail = "\n".join((exc.output or str(exc)).splitlines()[-40:]).strip()
        return {"ok": False, "detail": tail or "up failed", "output": tail}, 500
    except Exception as exc:
        return {"ok": False, "detail": str(exc)}, 500


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *args):
        pass

    def _json(self, code, payload):
        body = json.dumps(payload).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def _read_json(self):
        length = int(self.headers.get("Content-Length") or 0)
        raw = self.rfile.read(length) if length else b"{}"
        try:
            return json.loads(raw or b"{}")
        except ValueError:
            return None

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_GET(self):
        if self.path == "/healthz":
            return self._json(
                200,
                {"ok": True, "run_dir": RUN_DIR, "workspace": WORKSPACE},
            )
        self._json(404, {"ok": False, "detail": "not found"})

    def do_POST(self):
        path = self.path.strip("/")
        if path == "up":
            body = self._read_json()
            if body is None:
                return self._json(400, {"ok": False, "detail": "invalid JSON"})
            services = list(body.get("services") or [])
            if body.get("service"):
                services = [body["service"]] + services
            payload, code = run_up(services)
            return self._json(code, payload)

        platform = path.split("/")[-1]
        if platform not in ("ios", "android", "ios-device", "android-device"):
            return self._json(
                404,
                {
                    "ok": False,
                    "detail": "POST /up, /ios, /android, /ios-device or /android-device",
                },
            )
        env = os.environ.copy()
        try:
            out = subprocess.check_output(
                [SCRIPT, platform],
                stderr=subprocess.STDOUT,
                text=True,
                timeout=int(os.environ.get("HOST_OPEN_TIMEOUT", "3600")),
                env=env,
            )
            self._json(200, parse_open_output(out))
        except subprocess.CalledProcessError as exc:
            self._json(500, {"ok": False, "detail": (exc.output or str(exc)).strip()})
        except Exception as exc:
            self._json(500, {"ok": False, "detail": str(exc)})


if __name__ == "__main__":
    print(f"[host-open] listening on 127.0.0.1:{PORT}", flush=True)
    ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()