diff --git a/ctrl/init.py b/ctrl/init.py new file mode 100644 index 0000000..7607526 --- /dev/null +++ b/ctrl/init.py @@ -0,0 +1,949 @@ +#!/usr/bin/env python3 +""" +spr init - Room scaffolding wizard + +Creates a new soleprint room in cfg// with layered, interactive setup. +Each layer is optional beyond Layer 0 (config + data). + +Usage: + python ctrl/init.py myroom # Interactive wizard + python ctrl/init.py myroom --from sample # Clone existing room as variant +""" + +import argparse +import json +import shutil +import sys +from pathlib import Path + +SPR_ROOT = Path(__file__).resolve().parent.parent +CFG_DIR = SPR_ROOT / "cfg" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def ask(prompt: str, default: str = "") -> str: + """Prompt user for input with optional default.""" + try: + if default: + val = input(f"{prompt} [{default}]: ").strip() + return val or default + return input(f"{prompt}: ").strip() + except EOFError: + if default: + return default + return "" + + +def ask_yn(prompt: str, default: bool = True) -> bool: + """Yes/no prompt.""" + suffix = "[Y/n]" if default else "[y/N]" + try: + val = input(f"{prompt} {suffix}: ").strip().lower() + except EOFError: + return default + if not val: + return default + return val in ("y", "yes") + + +def ask_choice(prompt: str, choices: list[str], default: str = "") -> str: + """Prompt with constrained choices.""" + choices_str = "/".join(choices) + try: + if default: + val = input(f"{prompt} [{choices_str}] ({default}): ").strip().lower() + return val if val in choices else default + while True: + val = input(f"{prompt} [{choices_str}]: ").strip().lower() + if val in choices: + return val + print(f" Choose one of: {choices_str}") + except EOFError: + if default: + return default + return choices[0] + + +def write_file(path: Path, content: str): + """Write file, creating parent dirs.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + print(f" + {path.relative_to(SPR_ROOT)}") + + +def next_free_port() -> int: + """Scan existing rooms to find next free hub port.""" + used = set() + for cfg in CFG_DIR.iterdir(): + config_path = cfg / "config.json" + if config_path.exists(): + try: + data = json.loads(config_path.read_text()) + port = data.get("framework", {}).get("hub_port") + if port: + used.add(int(port)) + except (json.JSONDecodeError, ValueError): + pass + port = 12000 + while port in used: + port += 10 + return port + + +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- + +SYSTEMS = [ + {"key": "data_flow", "name": "artery", "slug": "artery", "title": "Artery", "tagline": "Todo lo vital", "icon": ""}, + {"key": "documentation", "name": "atlas", "slug": "atlas", "title": "Atlas", "tagline": "Documentacion accionable", "icon": ""}, + {"key": "execution", "name": "station", "slug": "station", "title": "Station", "tagline": "Monitores, Entornos y Herramientas", "icon": ""}, +] + +COMPONENTS = { + "shared": { + "config": {"name": "room", "title": "Room", "description": "Runtime environment configuration", "plural": "rooms"}, + "data": {"name": "depot", "title": "Depot", "description": "Data storage / provisions", "plural": "depots"}, + }, + "data_flow": { + "connector": {"name": "vein", "title": "Vein", "description": "Stateless API connector", "plural": "veins"}, + "mock": {"name": "shunt", "title": "Shunt", "description": "Fake connector for testing", "plural": "shunts"}, + "composed": {"name": "pulse", "title": "Pulse", "description": "Composed data flow", "plural": "pulses", "formula": "Vein + Room + Depot"}, + "app": {"name": "plexus", "title": "Plexus", "description": "Full app with backend, frontend and DB", "plural": "plexus"}, + }, + "documentation": { + "pattern": {"name": "template", "title": "Template", "description": "Documentation pattern", "plural": "templates"}, + "library": {"name": "book", "title": "Book", "description": "Documentation library"}, + "composed": {"name": "book", "title": "Book", "description": "Composed documentation", "plural": "books", "formula": "Template + Depot"}, + }, + "execution": { + "utility": {"name": "tool", "title": "Tool", "description": "Execution utility", "plural": "tools"}, + "watcher": {"name": "monitor", "title": "Monitor", "description": "Service monitor", "plural": "monitors"}, + "container": {"name": "cabinet", "title": "Cabinet", "description": "Tool container", "plural": "cabinets"}, + "workspace": {"name": "desk", "title": "Desk", "description": "Execution workspace"}, + "composed": {"name": "desk", "title": "Desk", "description": "Composed execution bundle", "plural": "desks", "formula": "Cabinet + Room + Depots"}, + }, +} + +DATA_FILES = [ + "books", "depots", "desks", "monitors", "plexuses", + "pulses", "rooms", "shunts", "tables", "templates", "tools", "veins", +] + + +def make_config(room: str, port: int, managed: dict | None = None) -> dict: + cfg = { + "framework": { + "name": "soleprint", + "slug": "soleprint", + "version": "0.1.0", + "description": "Development workflow and documentation system", + "tagline": "Mapping development footprints", + "icon": "", + "hub_port": port, + }, + "systems": SYSTEMS, + "components": COMPONENTS, + } + if managed: + cfg["managed"] = managed + return cfg + + +def soleprint_compose() -> str: + return """\ +# Soleprint Services - Docker Compose +# +# Usage: +# cd gen//soleprint && docker compose up -d + +name: ${DEPLOYMENT_NAME} + +services: + soleprint: + build: + context: . + dockerfile: Dockerfile + container_name: ${DEPLOYMENT_NAME} + user: "${UID:-1000}:${GID:-1000}" + volumes: + - .:/app + ports: + - "${SOLEPRINT_PORT}:8000" + env_file: + - .env + environment: + - ARTERY_EXTERNAL_URL=/artery + - ATLAS_EXTERNAL_URL=/atlas + - STATION_EXTERNAL_URL=/station + networks: + - default + command: uvicorn run:app --host 0.0.0.0 --port 8000 --reload + +networks: + default: + external: true + name: ${NETWORK_NAME} +""" + + +def soleprint_env(room: str, port: int) -> str: + return f"""\ +# ============================================================================= +# {room} — Soleprint Configuration +# ============================================================================= + +DEPLOYMENT_NAME={room}_spr +NETWORK_NAME={room}_network +SOLEPRINT_PORT={port} + +# Google OAuth (configure if using Google vein) +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# GOOGLE_REDIRECT_URI=http://{room}.spr.local.ar/artery/google/oauth/callback + +AUTH_BYPASS=true +AUTH_SESSION_SECRET={room}-dev-secret-change-in-production +""" + + +def managed_compose(room: str, app_name: str, has_frontend: bool) -> str: + services = f"""\ +# {room} — Managed App Services + +name: {room}_app + +services: + backend: + build: + context: ./{app_name}/backend + dockerfile: ../../Dockerfile.backend + container_name: {room}_backend + env_file: + - .env + ports: + - "${{BACKEND_PORT}}:8000" + networks: + - default +""" + if has_frontend: + services += f""" + frontend: + build: + context: ./{app_name}/frontend + dockerfile: ../../Dockerfile.frontend + container_name: {room}_frontend + env_file: + - .env + ports: + - "${{FRONTEND_PORT}}:3000" + networks: + - default +""" + services += f""" +networks: + default: + external: true + name: {room}_network +""" + return services + + +DOCKERFILE_BACKEND = { + "django": """\ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] +""", + "fastapi": """\ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +""", + "express": """\ +FROM node:20-slim +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +EXPOSE 8000 +CMD ["node", "index.js"] +""", + "other": """\ +# Customize this Dockerfile for your backend framework +FROM python:3.12-slim +WORKDIR /app +COPY . . +EXPOSE 8000 +CMD ["echo", "Configure your start command"] +""", +} + +DOCKERFILE_FRONTEND = { + "nextjs": """\ +FROM node:20-slim +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev"] +""", + "react": """\ +FROM node:20-slim +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +EXPOSE 3000 +CMD ["npm", "start"] +""", + "vue": """\ +FROM node:20-slim +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev"] +""", + "other": """\ +# Customize this Dockerfile for your frontend framework +FROM node:20-slim +WORKDIR /app +COPY . . +EXPOSE 3000 +CMD ["echo", "Configure your start command"] +""", +} + + +def managed_env(room: str, backend_port: int, frontend_port: int | None) -> str: + lines = f"""\ +# ============================================================================= +# {room} — Managed App Configuration +# ============================================================================= + +BACKEND_PORT={backend_port} +""" + if frontend_port: + lines += f"FRONTEND_PORT={frontend_port}\n" + lines += f""" +# Database +DB_HOST={room}_db +DB_PORT=5432 +DB_NAME={room} +DB_USER=postgres +DB_PASSWORD=localdev123 +""" + return lines + + +def link_main() -> str: + return """\ +\"\"\" +Link — Database bridge for soleprint tools. + +Provides read access to the managed app's database so soleprint tools +(databrowse, datagen, tester) can inspect and manipulate data without +touching the app's source code. +\"\"\" + +from fastapi import FastAPI + +app = FastAPI(title="Link - DB Bridge") + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +# Import adapters here: +# from adapters.django import router as django_router +# app.include_router(django_router, prefix="/django") +""" + + +def link_adapter_django() -> str: + return """\ +\"\"\" +Django adapter — reads Django ORM metadata to provide schema + data access. +\"\"\" + +# from fastapi import APIRouter +# router = APIRouter() + +# TODO: Implement adapter for your Django models +# See cfg/amar/link/adapters/django.py for a working example +""" + + +def link_adapter_fastapi() -> str: + return """\ +\"\"\" +FastAPI/SQLAlchemy adapter — reads SQLAlchemy metadata for schema + data access. +\"\"\" + +# from fastapi import APIRouter +# router = APIRouter() + +# TODO: Implement adapter for your SQLAlchemy models +""" + + +def link_requirements() -> str: + return """\ +fastapi>=0.100.0 +uvicorn>=0.23.0 +psycopg2-binary>=2.9.0 +sqlalchemy>=2.0.0 +""" + + +def link_dockerfile() -> str: + return """\ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +""" + + +def link_compose(room: str) -> str: + return f"""\ +# Link — DB Bridge +name: {room}_link + +services: + link: + build: + context: . + dockerfile: Dockerfile + container_name: {room}_link + env_file: + - ../soleprint/.env + ports: + - "${{LINK_PORT:-8001}}:8000" + networks: + - default + +networks: + default: + external: true + name: {room}_network +""" + + +def ctrl_start(room: str, app_name: str | None) -> str: + lines = f"""\ +#!/bin/bash +# Start all {room} services +# Usage: ./ctrl/start.sh [-d] + +SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +DETACH="" +if [[ "$1" == "-d" ]]; then + DETACH="-d" +fi + +echo "=== Starting {room} services ===" + +# Start soleprint +echo "Starting soleprint..." +cd "$ROOT_DIR/soleprint" +docker compose up $DETACH & +""" + if app_name: + lines += f""" +# Start managed app +if [[ -f "$ROOT_DIR/{app_name}/docker-compose.yml" ]]; then + echo "Starting {app_name}..." + cd "$ROOT_DIR/{app_name}" + docker compose up $DETACH & +fi +""" + lines += """ +wait +echo "=== All services started ===" +""" + return lines + + +def ctrl_stop(room: str, app_name: str | None) -> str: + lines = f"""\ +#!/bin/bash +# Stop all {room} services + +SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Stopping {room} services ===" + +cd "$ROOT_DIR/soleprint" +docker compose down +""" + if app_name: + lines += f""" +if [[ -f "$ROOT_DIR/{app_name}/docker-compose.yml" ]]; then + cd "$ROOT_DIR/{app_name}" + docker compose down +fi +""" + lines += f""" +echo "=== All {room} services stopped ===" +""" + return lines + + +def ctrl_status(room: str) -> str: + return f"""\ +#!/bin/bash +# Show status of {room} services + +SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== {room} service status ===" +cd "$ROOT_DIR" +for d in */; do + if [[ -f "$d/docker-compose.yml" ]]; then + echo "" + echo "--- $d ---" + cd "$ROOT_DIR/$d" + docker compose ps + fi +done +""" + + +def ctrl_logs(room: str) -> str: + return f"""\ +#!/bin/bash +# Show logs for {room} services +# Usage: ./ctrl/logs.sh [service_name] + +SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$ROOT_DIR/soleprint" +if [[ -n "$1" ]]; then + docker compose logs -f "$1" +else + docker compose logs -f +fi +""" + + +def tester_environments(url: str) -> str: + return json.dumps({ + "environments": [ + { + "name": "local", + "url": url, + "auth_type": "none", + } + ] + }, indent=2) + "\n" + + +def tester_base() -> str: + return """\ +\"\"\" +Room-specific test base class. + +Import from core tester and extend as needed. +\"\"\" + +from station.tools.tester.base import ContractTestCase # noqa: F401 +""" + + +def nginx_conf(room: str, app_name: str) -> str: + return f"""\ +# {room} — Nginx Config for Docker +# +# Routes: +# {room}.spr.local.ar — frontend with soleprint sidebar +# {room}.local.ar — frontend without sidebar + +# {room}.spr.local.ar - frontend with soleprint sidebar +server {{ + listen 80; + server_name {room}.spr.local.ar; + + # Soleprint routes + location /spr/ {{ + proxy_pass http://soleprint:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} + + # Backend API + location /api/ {{ + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} + + # Frontend with sidebar injection + location / {{ + proxy_pass http://frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Accept-Encoding ""; + + # Inject sidebar + sub_filter '' ''; + sub_filter_once off; + sub_filter_types text/html; + }} +}} + +# {room}.local.ar - frontend without sidebar +server {{ + listen 80; + server_name {room}.local.ar; + + location /api/ {{ + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} + + location / {{ + proxy_pass http://frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + }} +}} +""" + + +def nginx_compose(room: str) -> str: + return f"""\ +# Nginx Reverse Proxy for {room} +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d +# +# Requires /etc/hosts entries: +# 127.0.0.1 {room}.spr.local.ar {room}.local.ar + +name: ${{DEPLOYMENT_NAME}}_nginx + +services: + nginx: + image: nginx:alpine + container_name: ${{DEPLOYMENT_NAME}}_nginx + ports: + - "${{NGINX_PORT:-80}}:80" + volumes: + - ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - default + depends_on: + - soleprint + restart: unless-stopped + +networks: + default: + name: ${{NETWORK_NAME}} +""" + + +# --------------------------------------------------------------------------- +# Layers +# --------------------------------------------------------------------------- + +def layer0_config(room_dir: Path, room: str, port: int, is_managed: bool) -> dict: + """Layer 0: config.json + data/*.json""" + print("\n-- Layer 0: Config + Data --") + + managed = None + if is_managed: + # Managed section filled in layer 2 + managed = {"name": room} + + config = make_config(room, port, managed) + write_file(room_dir / "config.json", json.dumps(config, indent=2) + "\n") + + # Data files — empty items + data_dir = room_dir / "data" + for name in DATA_FILES: + write_file(data_dir / f"{name}.json", json.dumps({"items": []}, indent=2) + "\n") + write_file(data_dir / "__init__.py", "") + + return config + + +def layer1_docker(room_dir: Path, room: str, port: int): + """Layer 1: soleprint docker-compose + .env""" + print("\n-- Layer 1: Docker --") + + spr_dir = room_dir / "soleprint" + write_file(spr_dir / "docker-compose.yml", soleprint_compose()) + write_file(spr_dir / ".env", soleprint_env(room, port)) + + +def layer2_managed(room_dir: Path, room: str, config: dict) -> dict: + """Layer 2: Managed app scaffolding. Returns app info.""" + print("\n-- Layer 2: Managed App --") + + app_name = ask("App name", room) + backend_path = ask("Backend repo path") + backend_framework = ask_choice( + "Backend framework?", ["django", "fastapi", "express", "other"], "django" + ) + frontend_path = ask("Frontend repo path [skip]") + has_frontend = bool(frontend_path) and frontend_path.lower() != "skip" + + frontend_framework = None + if has_frontend: + frontend_framework = ask_choice( + "Frontend framework?", ["nextjs", "react", "vue", "other"], "nextjs" + ) + + # Update config.json with managed section + repos = {"backend": backend_path} + if has_frontend: + repos["frontend"] = frontend_path + config["managed"] = {"name": app_name, "repos": repos} + write_file(room_dir / "config.json", json.dumps(config, indent=2) + "\n") + + # Docker files + write_file(room_dir / "docker-compose.yml", managed_compose(room, app_name, has_frontend)) + write_file(room_dir / "Dockerfile.backend", DOCKERFILE_BACKEND[backend_framework]) + if has_frontend: + write_file(room_dir / "Dockerfile.frontend", DOCKERFILE_FRONTEND[frontend_framework]) + + # .env + backend_port = next_free_port() + 100 # offset from soleprint ports + frontend_port = backend_port + 10 if has_frontend else None + write_file(room_dir / ".env", managed_env(room, backend_port, frontend_port)) + + # Dumps dir + dumps_dir = room_dir / app_name / "dumps" + dumps_dir.mkdir(parents=True, exist_ok=True) + (dumps_dir / ".gitkeep").touch() + print(f" + {(dumps_dir / '.gitkeep').relative_to(SPR_ROOT)}") + + return { + "app_name": app_name, + "backend_framework": backend_framework, + "has_frontend": has_frontend, + "frontend_framework": frontend_framework, + } + + +def layer3_link(room_dir: Path, room: str, backend_framework: str): + """Layer 3: Link (DB bridge) scaffolding.""" + print("\n-- Layer 3: Link --") + + link_dir = room_dir / "link" + write_file(link_dir / "main.py", link_main()) + write_file(link_dir / "adapters" / "__init__.py", "") + write_file(link_dir / "requirements.txt", link_requirements()) + write_file(link_dir / "Dockerfile", link_dockerfile()) + write_file(link_dir / "docker-compose.yml", link_compose(room)) + + # Framework-specific adapter scaffold + if backend_framework == "django": + write_file(link_dir / "adapters" / "django.py", link_adapter_django()) + elif backend_framework in ("fastapi", "other"): + write_file(link_dir / "adapters" / "sqlalchemy.py", link_adapter_fastapi()) + + +def layer4_scripts(room_dir: Path, room: str, app_name: str | None): + """Layer 4: ctrl/ scripts.""" + print("\n-- Layer 4: Scripts --") + + ctrl_dir = room_dir / "ctrl" + write_file(ctrl_dir / "start.sh", ctrl_start(room, app_name)) + write_file(ctrl_dir / "stop.sh", ctrl_stop(room, app_name)) + write_file(ctrl_dir / "status.sh", ctrl_status(room)) + write_file(ctrl_dir / "logs.sh", ctrl_logs(room)) + + +def layer5_tester(room_dir: Path, room: str): + """Layer 5: Test suite scaffolding.""" + print("\n-- Layer 5: Test Suite --") + + url = ask("Test target URL", "http://localhost:8000") + tester_dir = room_dir / "soleprint" / "station" / "tools" / "tester" + write_file(tester_dir / "environments.json", tester_environments(url)) + write_file(tester_dir / "tests" / "__init__.py", "") + write_file(tester_dir / "tests" / "base.py", tester_base()) + + +def layer6_nginx(room_dir: Path, room: str, app_name: str): + """Layer 6: Nginx sidebar injection.""" + print("\n-- Layer 6: Nginx --") + + spr_dir = room_dir / "soleprint" + write_file(spr_dir / "nginx" / "local.conf", nginx_conf(room, app_name)) + write_file(spr_dir / "docker-compose.nginx.yml", nginx_compose(room)) + print(f"\n Add to /etc/hosts: 127.0.0.1 {room}.spr.local.ar {room}.local.ar") + + +# --------------------------------------------------------------------------- +# Clone (--from) +# --------------------------------------------------------------------------- + +def clone_room(source_name: str, target_name: str): + """Clone an existing room as a variant with new ports/names.""" + source_dir = CFG_DIR / source_name + target_dir = CFG_DIR / target_name + + if not source_dir.exists(): + print(f"Error: source room '{source_name}' not found in cfg/") + sys.exit(1) + + if target_dir.exists(): + print(f"Error: '{target_name}' already exists in cfg/") + sys.exit(1) + + print(f"\n=== Cloning {source_name} -> {target_name} ===") + + # Copy the whole tree + shutil.copytree(source_dir, target_dir) + print(f" Copied cfg/{source_name}/ -> cfg/{target_name}/") + + # Patch config.json + config_path = target_dir / "config.json" + if config_path.exists(): + config = json.loads(config_path.read_text()) + new_port = next_free_port() + config["framework"]["hub_port"] = new_port + config_path.write_text(json.dumps(config, indent=2) + "\n") + print(f" Patched config.json: hub_port={new_port}") + + # Patch all .env files: replace source name with target name + for env_file in target_dir.rglob(".env"): + content = env_file.read_text() + content = content.replace(source_name, target_name) + env_file.write_text(content) + print(f" Patched {env_file.relative_to(SPR_ROOT)}") + + # Patch nginx configs + for nginx_conf_file in target_dir.rglob("*.conf"): + content = nginx_conf_file.read_text() + content = content.replace(source_name, target_name) + nginx_conf_file.write_text(content) + print(f" Patched {nginx_conf_file.relative_to(SPR_ROOT)}") + + print(f"\nDone! Review cfg/{target_name}/ and adjust ports/paths as needed.") + print(f"Build with: python build.py --cfg {target_name}") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def wizard(room: str): + """Interactive room setup wizard.""" + room_dir = CFG_DIR / room + + if room_dir.exists(): + print(f"Error: '{room}' already exists in cfg/") + sys.exit(1) + + port = next_free_port() + + print(f"\n=== Soleprint Room Setup: {room} ===") + print(f" Target: cfg/{room}/") + + room_type = ask_choice("Room type?", ["standalone", "managed"], "standalone") + port = int(ask("Hub port", str(port))) + is_managed = room_type == "managed" + + # Layer 0: always + config = layer0_config(room_dir, room, port, is_managed) + + # Layer 1: docker + if ask_yn("\nContinue to Layer 1 (Docker)?"): + layer1_docker(room_dir, room, port) + else: + print(f"\nDone! Build with: python build.py --cfg {room}") + return + + app_info = None + + # Layer 2: managed app (only if managed) + if is_managed: + if ask_yn("\nContinue to Layer 2 (Managed App)?"): + app_info = layer2_managed(room_dir, room, config) + else: + print(f"\nDone! Build with: python build.py --cfg {room}") + return + + # Layer 3: link + if ask_yn("\nContinue to Layer 3 (Link - DB Bridge)?", default=False): + layer3_link(room_dir, room, app_info["backend_framework"]) + + # Layer 4: scripts + app_name = app_info["app_name"] if app_info else None + if ask_yn("\nContinue to Layer 4 (Scripts)?"): + layer4_scripts(room_dir, room, app_name) + + # Layer 5: tester + if ask_yn("\nContinue to Layer 5 (Test Suite)?", default=False): + layer5_tester(room_dir, room) + + # Layer 6: nginx (only if managed with frontend) + if is_managed and app_info and app_info["has_frontend"]: + if ask_yn("\nContinue to Layer 6 (Nginx Sidebar Injection)?", default=False): + layer6_nginx(room_dir, room, app_info["app_name"]) + + print(f"\nReady! Build with: python build.py --cfg {room}") + + +def main(): + parser = argparse.ArgumentParser( + description="Soleprint room scaffolding wizard", + usage="python ctrl/init.py [--from ]", + ) + parser.add_argument("room", help="Name of the new room") + parser.add_argument("--from", dest="from_room", help="Clone from existing room") + + args = parser.parse_args() + + if args.from_room: + clone_room(args.from_room, args.room) + else: + wizard(args.room) + + +if __name__ == "__main__": + main()