#!/usr/bin/env python3 """ spr init — Web wizard Tiny standalone FastAPI server for room setup. Run it, configure in browser, stop it. Usage: python -m init.web # http://localhost:9000 python -m init.web --port 9001 """ import argparse import json import logging from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.responses import HTMLResponse from pydantic import BaseModel from init.core import ( CFG_DIR, SPR_ROOT, BACKEND_FRAMEWORKS, FRONTEND_FRAMEWORKS, DATA_FILES, clone_room, generate_layer0, generate_layer1, generate_layer2, generate_layer3, generate_layer4, generate_layer5, generate_layer6, list_rooms, next_free_port, ) log = logging.getLogger("init") app = FastAPI(title="Soleprint Room Setup") WIZARD_HTML = Path(__file__).parent / "wizard.html" # --------------------------------------------------------------------------- # API models # --------------------------------------------------------------------------- class Layer2Data(BaseModel): app_name: str = "" backend_path: str = "" backend_framework: str = "django" frontend_path: str | None = None frontend_framework: str | None = None class Layer5Data(BaseModel): test_url: str = "http://localhost:8000" class GenerateRequest(BaseModel): room: str room_type: str = "standalone" # standalone | managed port: int = 0 layers: list[int] = [0, 1] # which layers to generate layer2: Layer2Data | None = None layer5: Layer5Data | None = None # data selections — which items to include in each data file data_selections: dict[str, list[dict]] | None = None class CloneRequest(BaseModel): source: str target: str # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.get("/", response_class=HTMLResponse) def index(): if WIZARD_HTML.exists(): return WIZARD_HTML.read_text() raise HTTPException(404, "wizard.html not found") @app.get("/api/rooms") def get_rooms(): """List existing rooms.""" return {"rooms": list_rooms()} @app.get("/api/defaults") def get_defaults(): """Return available components from standalone room's data/*.json.""" standalone_data = CFG_DIR / "standalone" / "data" defaults = {} for name in DATA_FILES: path = standalone_data / f"{name}.json" if path.exists(): try: defaults[name] = json.loads(path.read_text()) except json.JSONDecodeError: defaults[name] = {"items": []} else: defaults[name] = {"items": []} return { "data": defaults, "backend_frameworks": BACKEND_FRAMEWORKS, "frontend_frameworks": FRONTEND_FRAMEWORKS, "next_port": next_free_port(), } @app.post("/api/generate") def generate(req: GenerateRequest): """Generate room files for selected layers.""" room = req.room.strip() if not room: raise HTTPException(400, "Room name is required") room_dir = CFG_DIR / room if room_dir.exists(): raise HTTPException(409, f"Room '{room}' already exists in cfg/") port = req.port or next_free_port() is_managed = req.room_type == "managed" created = [] try: # Layer 0: always if 0 in req.layers: config = generate_layer0(room_dir, room, port, is_managed) # Apply data selections if provided if req.data_selections: data_dir = room_dir / "data" for name, items in req.data_selections.items(): path = data_dir / f"{name}.json" if path.exists(): path.write_text(json.dumps({"items": items}, indent=2) + "\n") created.append("config + data") else: # Need config for later layers config = generate_layer0(room_dir, room, port, is_managed) created.append("config + data") if 1 in req.layers: generate_layer1(room_dir, room, port) created.append("docker") app_info = None if 2 in req.layers and is_managed and req.layer2: l2 = req.layer2 has_frontend = bool(l2.frontend_path) app_info = generate_layer2( room_dir, room, config, l2.app_name or room, l2.backend_path, l2.backend_framework, l2.frontend_path if has_frontend else None, l2.frontend_framework if has_frontend else None, ) created.append("managed app") if 3 in req.layers and is_managed and app_info: generate_layer3(room_dir, room, app_info["backend_framework"]) created.append("link") app_name = app_info["app_name"] if app_info else None if 4 in req.layers: generate_layer4(room_dir, room, app_name) created.append("scripts") if 5 in req.layers: url = req.layer5.test_url if req.layer5 else "http://localhost:8000" generate_layer5(room_dir, room, url) created.append("tester") if 6 in req.layers and is_managed and app_info and app_info["has_frontend"]: generate_layer6(room_dir, room, app_info["app_name"]) created.append("nginx") except Exception as e: raise HTTPException(500, f"Generation failed: {e}") return { "room": room, "port": port, "created": created, "path": str(room_dir.relative_to(SPR_ROOT)), "build_command": f"python build.py --cfg {room}", } @app.post("/api/clone") def clone(req: CloneRequest): """Clone an existing room as a variant.""" if not req.source.strip() or not req.target.strip(): raise HTTPException(400, "Source and target room names required") ok = clone_room(req.source.strip(), req.target.strip()) if not ok: raise HTTPException(400, "Clone failed — check source exists and target doesn't") return { "source": req.source, "target": req.target, "path": f"cfg/{req.target}", "build_command": f"python build.py --cfg {req.target}", } @app.get("/api/preview") def preview(room: str = "myroom", room_type: str = "standalone"): """Return directory tree preview for a room configuration.""" is_managed = room_type == "managed" tree = [f"cfg/{room}/"] tree.append(" config.json") tree.append(" data/") for f in DATA_FILES: tree.append(f" {f}.json") tree.append(" soleprint/") tree.append(" docker-compose.yml") tree.append(" .env") if is_managed: tree.append(f" docker-compose.yml") tree.append(f" Dockerfile.backend") tree.append(f" .env") tree.append(f" {room}/") tree.append(f" dumps/") tree.append(" link/") tree.append(" main.py") tree.append(" adapters/") tree.append(" soleprint/") tree.append(" nginx/") tree.append(" local.conf") tree.append(" docker-compose.nginx.yml") tree.append(" ctrl/") tree.append(" start.sh") tree.append(" stop.sh") tree.append(" status.sh") tree.append(" logs.sh") return {"tree": tree} # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): import uvicorn logging.basicConfig(level=logging.INFO, format="%(message)s") parser = argparse.ArgumentParser(description="Soleprint room setup wizard (web)") parser.add_argument("--port", type=int, default=9000) parser.add_argument("--host", default="127.0.0.1") args = parser.parse_args() log.info("Room setup wizard: http://%s:%d", args.host, args.port) uvicorn.run(app, host=args.host, port=args.port, log_level="warning") if __name__ == "__main__": main()