This commit is contained in:
2026-04-12 12:34:25 -03:00
parent 54e7cfebd9
commit 2e5a304181
5 changed files with 1399 additions and 243 deletions

0
init/__init__.py Normal file
View File

171
init/cli.py Normal file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
spr init — CLI room scaffolding wizard
Interactive terminal wizard that walks through layers 0-6,
generating cfg/<room>/ for a new soleprint room.
Usage:
python -m init.cli myroom # Interactive wizard
python -m init.cli myroom --from sample # Clone existing room as variant
"""
import argparse
import logging
import sys
from init.core import (
CFG_DIR,
BACKEND_FRAMEWORKS,
FRONTEND_FRAMEWORKS,
clone_room,
generate_layer0,
generate_layer1,
generate_layer2,
generate_layer3,
generate_layer4,
generate_layer5,
generate_layer6,
next_free_port,
)
log = logging.getLogger("init")
# ---------------------------------------------------------------------------
# Terminal prompts
# ---------------------------------------------------------------------------
def ask(prompt: str, default: str = "") -> str:
try:
if default:
val = input(f"{prompt} [{default}]: ").strip()
return val or default
return input(f"{prompt}: ").strip()
except EOFError:
return default or ""
def ask_yn(prompt: str, default: bool = True) -> bool:
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:
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
log.warning(" Choose one of: %s", choices_str)
except EOFError:
return default or choices[0]
# ---------------------------------------------------------------------------
# Wizard
# ---------------------------------------------------------------------------
def wizard(room: str):
room_dir = CFG_DIR / room
if room_dir.exists():
log.error("'%s' already exists in cfg/", room)
sys.exit(1)
port = next_free_port()
log.info("\n=== Soleprint Room Setup: %s ===", room)
log.info(" Target: cfg/%s/", 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 = generate_layer0(room_dir, room, port, is_managed)
# Layer 1: docker
if ask_yn("\nContinue to Layer 1 (Docker)?"):
generate_layer1(room_dir, room, port)
else:
log.info("Done! Build with: python build.py --cfg %s", room)
return
app_info = None
# Layer 2: managed app
if is_managed:
if ask_yn("\nContinue to Layer 2 (Managed App)?"):
app_name = ask("App name", room)
backend_path = ask("Backend repo path")
backend_framework = ask_choice("Backend framework?", BACKEND_FRAMEWORKS, "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?", FRONTEND_FRAMEWORKS, "nextjs")
app_info = generate_layer2(
room_dir, room, config,
app_name, backend_path, backend_framework,
frontend_path if has_frontend else None,
frontend_framework,
)
else:
log.info("Done! Build with: python build.py --cfg %s", room)
return
# Layer 3: link
if ask_yn("\nContinue to Layer 3 (Link - DB Bridge)?", default=False):
generate_layer3(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)?"):
generate_layer4(room_dir, room, app_name)
# Layer 5: tester
if ask_yn("\nContinue to Layer 5 (Test Suite)?", default=False):
url = ask("Test target URL", "http://localhost:8000")
generate_layer5(room_dir, room, url)
# 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):
generate_layer6(room_dir, room, app_info["app_name"])
log.info("Ready! Build with: python build.py --cfg %s", room)
def main():
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(
description="Soleprint room scaffolding wizard",
usage="python -m init.cli <room> [--from <source>]",
)
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()

823
init/core.py Normal file
View File

@@ -0,0 +1,823 @@
"""
init.core — Room generation logic
Templates + file generation for soleprint rooms.
No interactive I/O — called by CLI and web wizard with data already collected.
"""
import json
import logging
import shutil
from pathlib import Path
log = logging.getLogger("init")
SPR_ROOT = Path(__file__).resolve().parent.parent
CFG_DIR = SPR_ROOT / "cfg"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def write_file(path: Path, content: str):
"""Write file, creating parent dirs."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
log.info(" + %s", path.relative_to(SPR_ROOT))
def next_free_port() -> int:
"""Scan existing rooms to find next free hub port."""
used = set()
if not CFG_DIR.exists():
return 12000
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
def list_rooms() -> list[str]:
"""Return names of existing rooms in cfg/."""
if not CFG_DIR.exists():
return []
return sorted(
d.name for d in CFG_DIR.iterdir()
if d.is_dir() and (d / "config.json").exists()
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
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",
]
BACKEND_FRAMEWORKS = ["django", "fastapi", "express", "other"]
FRONTEND_FRAMEWORKS = ["nextjs", "react", "vue", "other"]
# ---------------------------------------------------------------------------
# Config builder
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
def tpl_soleprint_compose() -> str:
return """\
# Soleprint Services - Docker Compose
#
# Usage:
# cd gen/<room>/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 tpl_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 tpl_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
TPL_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"]
""",
}
TPL_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 tpl_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 tpl_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 tpl_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 tpl_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 tpl_link_requirements() -> str:
return """\
fastapi>=0.100.0
uvicorn>=0.23.0
psycopg2-binary>=2.9.0
sqlalchemy>=2.0.0
"""
def tpl_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 tpl_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 tpl_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 tpl_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 tpl_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 tpl_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 tpl_tester_environments(url: str) -> str:
return json.dumps({
"environments": [
{"name": "local", "url": url, "auth_type": "none"}
]
}, indent=2) + "\n"
def tpl_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 tpl_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 '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
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 tpl_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}}
"""
# ---------------------------------------------------------------------------
# Layer generators — pure functions, no I/O prompts
# ---------------------------------------------------------------------------
def generate_layer0(room_dir: Path, room: str, port: int, is_managed: bool) -> dict:
"""Layer 0: config.json + data/*.json. Returns the config dict."""
log.info("-- Layer 0: Config + Data --")
managed = {"name": room} if is_managed else None
config = make_config(room, port, managed)
write_file(room_dir / "config.json", json.dumps(config, indent=2) + "\n")
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 generate_layer1(room_dir: Path, room: str, port: int):
"""Layer 1: soleprint docker-compose + .env."""
log.info("-- Layer 1: Docker --")
spr_dir = room_dir / "soleprint"
write_file(spr_dir / "docker-compose.yml", tpl_soleprint_compose())
write_file(spr_dir / ".env", tpl_soleprint_env(room, port))
def generate_layer2(
room_dir: Path, room: str, config: dict,
app_name: str, backend_path: str, backend_framework: str,
frontend_path: str | None = None, frontend_framework: str | None = None,
) -> dict:
"""Layer 2: Managed app scaffolding. Returns app info dict."""
log.info("-- Layer 2: Managed App --")
has_frontend = bool(frontend_path)
# Update config 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", tpl_managed_compose(room, app_name, has_frontend))
write_file(room_dir / "Dockerfile.backend", TPL_DOCKERFILE_BACKEND[backend_framework])
if has_frontend and frontend_framework:
write_file(room_dir / "Dockerfile.frontend", TPL_DOCKERFILE_FRONTEND[frontend_framework])
# .env
backend_port = next_free_port() + 100
frontend_port = backend_port + 10 if has_frontend else None
write_file(room_dir / ".env", tpl_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()
log.info(" + %s", (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 generate_layer3(room_dir: Path, room: str, backend_framework: str):
"""Layer 3: Link (DB bridge) scaffolding."""
log.info("-- Layer 3: Link --")
link_dir = room_dir / "link"
write_file(link_dir / "main.py", tpl_link_main())
write_file(link_dir / "adapters" / "__init__.py", "")
write_file(link_dir / "requirements.txt", tpl_link_requirements())
write_file(link_dir / "Dockerfile", tpl_link_dockerfile())
write_file(link_dir / "docker-compose.yml", tpl_link_compose(room))
if backend_framework == "django":
write_file(link_dir / "adapters" / "django.py", tpl_link_adapter_django())
elif backend_framework in ("fastapi", "other"):
write_file(link_dir / "adapters" / "sqlalchemy.py", tpl_link_adapter_fastapi())
def generate_layer4(room_dir: Path, room: str, app_name: str | None):
"""Layer 4: ctrl/ scripts."""
log.info("-- Layer 4: Scripts --")
ctrl_dir = room_dir / "ctrl"
write_file(ctrl_dir / "start.sh", tpl_ctrl_start(room, app_name))
write_file(ctrl_dir / "stop.sh", tpl_ctrl_stop(room, app_name))
write_file(ctrl_dir / "status.sh", tpl_ctrl_status(room))
write_file(ctrl_dir / "logs.sh", tpl_ctrl_logs(room))
def generate_layer5(room_dir: Path, room: str, test_url: str = "http://localhost:8000"):
"""Layer 5: Test suite scaffolding."""
log.info("-- Layer 5: Test Suite --")
tester_dir = room_dir / "soleprint" / "station" / "tools" / "tester"
write_file(tester_dir / "environments.json", tpl_tester_environments(test_url))
write_file(tester_dir / "tests" / "__init__.py", "")
write_file(tester_dir / "tests" / "base.py", tpl_tester_base())
def generate_layer6(room_dir: Path, room: str, app_name: str):
"""Layer 6: Nginx sidebar injection."""
log.info("-- Layer 6: Nginx --")
spr_dir = room_dir / "soleprint"
write_file(spr_dir / "nginx" / "local.conf", tpl_nginx_conf(room, app_name))
write_file(spr_dir / "docker-compose.nginx.yml", tpl_nginx_compose(room))
log.info(" /etc/hosts: 127.0.0.1 %s.spr.local.ar %s.local.ar", room, room)
# ---------------------------------------------------------------------------
# Clone
# ---------------------------------------------------------------------------
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():
log.error("source room '%s' not found in cfg/", source_name)
return False
if target_dir.exists():
log.error("'%s' already exists in cfg/", target_name)
return False
log.info("=== Cloning %s -> %s ===", source_name, target_name)
shutil.copytree(source_dir, target_dir)
log.info(" Copied cfg/%s/ -> cfg/%s/", source_name, 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")
log.info(" Patched config.json: hub_port=%d", new_port)
# Patch all .env files
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)
log.info(" Patched %s", env_file.relative_to(SPR_ROOT))
# Patch nginx configs
for conf_file in target_dir.rglob("*.conf"):
content = conf_file.read_text()
content = content.replace(source_name, target_name)
conf_file.write_text(content)
log.info(" Patched %s", conf_file.relative_to(SPR_ROOT))
log.info("Done! Build with: python build.py --cfg %s", target_name)
return True

267
init/web.py Normal file
View File

@@ -0,0 +1,267 @@
#!/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()

844
init/wizard.html Normal file
View File

@@ -0,0 +1,844 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soleprint - Room Setup</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface-alt: #1a2744;
--primary: #e94560;
--primary-hover: #c73e54;
--accent: #4ecca3;
--text: #eaeaea;
--text-muted: #8892b0;
--border: #0f3460;
--danger: #e94560;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.layout {
display: grid;
grid-template-columns: 280px 1fr 300px;
min-height: 100vh;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; }
.sidebar, .preview-panel { display: none; }
}
/* Sidebar — layer navigation */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
padding: 1.5rem 0;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar h1 {
font-size: 1.1rem;
padding: 0 1.2rem 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.5rem;
}
.sidebar h1 span { color: var(--text-muted); font-weight: 400; font-size: 0.85rem; }
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 1.2rem;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
font-size: 0.9rem;
}
.nav-item:hover { background: var(--surface-alt); }
.nav-item.active {
background: var(--surface-alt);
border-left-color: var(--primary);
color: var(--text);
}
.nav-item.done { color: var(--accent); }
.nav-item .num {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
flex-shrink: 0;
}
.nav-item.done .num { background: var(--accent); color: var(--bg); }
.nav-item.active .num { background: var(--primary); color: white; }
/* Main content */
.main {
padding: 2rem 3rem;
max-width: 800px;
overflow-y: auto;
}
.main h2 {
font-size: 1.4rem;
margin-bottom: 0.3rem;
}
.main .subtitle {
color: var(--text-muted);
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
/* Form elements */
.field { margin-bottom: 1.2rem; }
.field label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
.field input, .field select {
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 0.95rem;
}
.field input:focus, .field select:focus {
outline: none;
border-color: var(--primary);
}
.field .hint {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.2rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
/* Checkbox grid for component selection */
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
.check-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.7rem;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: border-color 0.15s;
}
.check-item:hover { border-color: var(--primary); }
.check-item input { width: auto; accent-color: var(--primary); }
.check-item .name { flex: 1; }
.check-item .status {
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--border);
color: var(--text-muted);
}
.check-item .status.live { background: #1a3a2a; color: var(--accent); }
.check-item .status.ready { background: #1a3a2a; color: var(--accent); }
.section-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 1.5rem 0 0.6rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border);
}
/* Buttons */
.actions {
display: flex;
gap: 0.8rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.btn {
padding: 0.6rem 1.4rem;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-secondary { background: var(--border); color: var(--text); }
.btn-secondary:hover { background: #1a4a7a; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Preview panel */
.preview-panel {
background: var(--surface);
border-left: 1px solid var(--border);
padding: 1.2rem;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.preview-panel h3 {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tree {
font-family: "Consolas", "Monaco", monospace;
font-size: 0.8rem;
line-height: 1.5;
}
.tree .folder { color: #82aaff; }
.tree .file { color: var(--text-muted); }
/* Status toast */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 0.8rem 1.5rem;
border-radius: 6px;
font-size: 0.9rem;
display: none;
z-index: 100;
}
.toast.success { display: block; background: #1a3a2a; border: 1px solid var(--accent); color: var(--accent); }
.toast.error { display: block; background: #3a1a1a; border: 1px solid var(--danger); color: var(--danger); }
/* Layer sections hidden by default */
.layer { display: none; }
.layer.active { display: block; }
/* Clone mode */
.clone-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<div class="layout">
<!-- Sidebar -->
<nav class="sidebar">
<h1>Room Setup <span>soleprint</span></h1>
<div class="nav-item active" data-layer="0">
<span class="num">0</span> Config + Data
</div>
<div class="nav-item" data-layer="1">
<span class="num">1</span> Docker
</div>
<div class="nav-item" data-layer="2">
<span class="num">2</span> Managed App
</div>
<div class="nav-item" data-layer="3">
<span class="num">3</span> Link (DB Bridge)
</div>
<div class="nav-item" data-layer="4">
<span class="num">4</span> Scripts
</div>
<div class="nav-item" data-layer="5">
<span class="num">5</span> Systems
</div>
<div class="nav-item" data-layer="6">
<span class="num">6</span> Nginx
</div>
<div class="nav-item" data-layer="clone" style="margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 1rem;">
<span class="num">~</span> Clone Room
</div>
</nav>
<!-- Main content -->
<main class="main" id="main">
<!-- Layer 0: Config -->
<section class="layer active" id="layer-0">
<h2>Room Configuration</h2>
<p class="subtitle">Basic identity and data registry for your room</p>
<div class="field">
<label for="roomName">Room Name</label>
<input type="text" id="roomName" placeholder="myroom">
<div class="hint">Unique identifier — becomes cfg/&lt;name&gt;/</div>
</div>
<div class="row">
<div class="field">
<label for="roomType">Room Type</label>
<select id="roomType">
<option value="standalone">Standalone</option>
<option value="managed">Managed (wraps an app)</option>
</select>
</div>
<div class="field">
<label for="hubPort">Hub Port</label>
<input type="number" id="hubPort" value="12000">
</div>
</div>
<div class="section-label">Data Registry</div>
<p class="hint" style="margin-bottom: 0.8rem;">
Select which items to include in your room's data files.
All available components are shown — uncheck what you don't need.
</p>
<div id="dataRegistry">
<!-- Populated by JS from /api/defaults -->
</div>
<div class="actions">
<button class="btn btn-primary" onclick="goLayer(1)">Continue to Docker</button>
</div>
</section>
<!-- Layer 1: Docker -->
<section class="layer" id="layer-1">
<h2>Docker Configuration</h2>
<p class="subtitle">Soleprint runtime — docker-compose and environment</p>
<p style="color: var(--text-muted); line-height: 1.6;">
Generates <code>soleprint/docker-compose.yml</code> and <code>soleprint/.env</code>
with your room name, port, and network settings. These are auto-filled from Layer 0.
</p>
<div class="field" style="margin-top: 1rem;">
<label>Generated files</label>
<div class="tree">
<div class="file">soleprint/docker-compose.yml</div>
<div class="file">soleprint/.env</div>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goLayer(0)">Back</button>
<button class="btn btn-primary" onclick="goLayer(2)">Continue to Managed App</button>
</div>
</section>
<!-- Layer 2: Managed App -->
<section class="layer" id="layer-2">
<h2>Managed Application</h2>
<p class="subtitle">The app soleprint wraps — repos, framework, Docker setup</p>
<div id="managedNotice" style="color: var(--text-muted); padding: 1rem; background: var(--surface-alt); border-radius: 4px;">
Room type is "standalone" — no managed app needed.
<a href="#" onclick="document.getElementById('roomType').value='managed'; updateManagedVisibility(); goLayer(2); return false;" style="color: var(--primary);">Switch to managed?</a>
</div>
<div id="managedFields" style="display: none;">
<div class="row">
<div class="field">
<label for="appName">App Name</label>
<input type="text" id="appName" placeholder="myapp">
</div>
<div class="field">
<label for="backendFramework">Backend Framework</label>
<select id="backendFramework"></select>
</div>
</div>
<div class="field">
<label for="backendPath">Backend Repo Path</label>
<input type="text" id="backendPath" placeholder="/home/user/wdir/myapp-api">
</div>
<div class="row">
<div class="field">
<label for="frontendPath">Frontend Repo Path</label>
<input type="text" id="frontendPath" placeholder="(leave empty to skip)">
</div>
<div class="field">
<label for="frontendFramework">Frontend Framework</label>
<select id="frontendFramework">
<option value="">(no frontend)</option>
</select>
</div>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goLayer(1)">Back</button>
<button class="btn btn-primary" onclick="goLayer(3)">Continue to Link</button>
</div>
</section>
<!-- Layer 3: Link -->
<section class="layer" id="layer-3">
<h2>Link — DB Bridge</h2>
<p class="subtitle">Read access to the managed app's database for soleprint tools</p>
<p style="color: var(--text-muted); line-height: 1.6;">
Scaffolds a FastAPI service with adapter stubs matching your backend framework.
Used by databrowse, datagen, and tester to access app data.
</p>
<div class="field" style="margin-top: 1rem;">
<label>Generated files</label>
<div class="tree">
<div class="file">link/main.py</div>
<div class="file">link/adapters/</div>
<div class="file">link/Dockerfile</div>
<div class="file">link/docker-compose.yml</div>
<div class="file">link/requirements.txt</div>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goLayer(2)">Back</button>
<button class="btn btn-primary" onclick="goLayer(4)">Continue to Scripts</button>
</div>
</section>
<!-- Layer 4: Scripts -->
<section class="layer" id="layer-4">
<h2>Control Scripts</h2>
<p class="subtitle">start, stop, status, logs — generated for your room's services</p>
<div class="field" style="margin-top: 1rem;">
<label>Generated files</label>
<div class="tree">
<div class="file">ctrl/start.sh</div>
<div class="file">ctrl/stop.sh</div>
<div class="file">ctrl/status.sh</div>
<div class="file">ctrl/logs.sh</div>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goLayer(3)">Back</button>
<button class="btn btn-primary" onclick="goLayer(5)">Continue to Systems</button>
</div>
</section>
<!-- Layer 5: Systems (checkbox-driven) -->
<section class="layer" id="layer-5">
<h2>System Extensions</h2>
<p class="subtitle">Select which tools, monitors, and connectors to scaffold for this room</p>
<div class="section-label">Tester</div>
<div class="field">
<label>
<input type="checkbox" id="enableTester" checked> Enable contract test suite
</label>
</div>
<div class="field" id="testerUrlField">
<label for="testUrl">Test Target URL</label>
<input type="text" id="testUrl" value="http://localhost:8000">
</div>
<div class="section-label">Available Components</div>
<p class="hint" style="margin-bottom: 0.8rem;">
These are registered in your data/*.json files (configured in Layer 0).
Scaffolding for custom shunts, monitors, and tools can be added after generation.
</p>
<div id="systemComponents">
<!-- Populated by JS -->
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goLayer(4)">Back</button>
<button class="btn btn-primary" onclick="goLayer(6)">Continue to Nginx</button>
</div>
</section>
<!-- Layer 6: Nginx -->
<section class="layer" id="layer-6">
<h2>Nginx — Sidebar Injection</h2>
<p class="subtitle">Reverse proxy that injects the soleprint sidebar into your app's frontend</p>
<div id="nginxNotice" style="color: var(--text-muted); padding: 1rem; background: var(--surface-alt); border-radius: 4px;">
Requires a managed app with a frontend. Configure in Layer 2.
</div>
<div id="nginxFields" style="display: none;">
<div class="field" style="margin-top: 1rem;">
<label>Generated files</label>
<div class="tree">
<div class="file">soleprint/nginx/local.conf</div>
<div class="file">soleprint/docker-compose.nginx.yml</div>
</div>
</div>
<div class="field">
<label>Required /etc/hosts entry</label>
<code id="hostsEntry" style="color: var(--accent); font-size: 0.85rem;"></code>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goLayer(5)">Back</button>
<button class="btn btn-primary" onclick="generate()" id="generateBtn">Generate Room</button>
</div>
</section>
<!-- Clone -->
<section class="layer" id="layer-clone">
<h2>Clone Existing Room</h2>
<p class="subtitle">Create a variant of an existing room with new ports and names</p>
<div class="row">
<div class="field">
<label for="cloneSource">Source Room</label>
<select id="cloneSource"></select>
</div>
<div class="field">
<label for="cloneTarget">New Room Name</label>
<input type="text" id="cloneTarget" placeholder="myroom-karen">
</div>
</div>
<div class="hint">
Copies the entire cfg/ tree, patches all names and ports.
Shares source code repos but gets its own DB, containers, and hostname.
</div>
<div class="actions">
<button class="btn btn-primary" onclick="cloneRoom()">Clone</button>
</div>
</section>
</main>
<!-- Preview panel -->
<aside class="preview-panel">
<h3>Preview</h3>
<div class="tree" id="previewTree">
<span class="file" style="color: var(--text-muted);">Configure room to see preview</span>
</div>
<h3 style="margin-top: 1.5rem;">Selected Layers</h3>
<div id="selectedLayers" style="font-size: 0.85rem; color: var(--text-muted); line-height: 1.8;"></div>
</aside>
</div>
<div class="toast" id="toast"></div>
<script>
let defaults = {};
let currentLayer = 0;
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
const resp = await fetch("/api/defaults");
defaults = await resp.json();
document.getElementById("hubPort").value = defaults.next_port;
// Populate framework selects
const bf = document.getElementById("backendFramework");
defaults.backend_frameworks.forEach(f => {
bf.add(new Option(f, f));
});
const ff = document.getElementById("frontendFramework");
ff.innerHTML = '<option value="">(no frontend)</option>';
defaults.frontend_frameworks.forEach(f => {
ff.add(new Option(f, f));
});
// Populate data registry (Layer 0)
renderDataRegistry();
// Populate system components (Layer 5)
renderSystemComponents();
// Populate clone source
const roomsResp = await fetch("/api/rooms");
const roomsData = await roomsResp.json();
const cs = document.getElementById("cloneSource");
roomsData.rooms.forEach(r => cs.add(new Option(r, r)));
// Event listeners
document.getElementById("roomType").addEventListener("change", updateManagedVisibility);
document.getElementById("roomName").addEventListener("input", updatePreview);
document.getElementById("roomType").addEventListener("change", updatePreview);
document.querySelectorAll(".nav-item").forEach(item => {
item.addEventListener("click", () => {
const layer = item.dataset.layer;
goLayer(layer === "clone" ? "clone" : parseInt(layer));
});
});
updatePreview();
}
// ---------------------------------------------------------------------------
// Data registry (Layer 0)
// ---------------------------------------------------------------------------
function renderDataRegistry() {
const container = document.getElementById("dataRegistry");
const categories = {
"Connectors (Artery)": ["veins", "shunts", "pulses"],
"Documentation (Atlas)": ["books", "templates", "depots"],
"Tools (Station)": ["tools", "monitors", "desks"],
"Other": ["rooms", "tables", "plexuses"],
};
let html = "";
for (const [cat, files] of Object.entries(categories)) {
html += `<div class="section-label">${cat}</div><div class="check-grid">`;
for (const file of files) {
const items = (defaults.data[file] || {}).items || [];
for (const item of items) {
const statusClass = (item.status === "live" || item.status === "ready") ? item.status : "";
html += `
<label class="check-item">
<input type="checkbox" checked data-file="${file}" data-item='${JSON.stringify(item)}'>
<span class="name">${item.title || item.name}</span>
${item.status ? `<span class="status ${statusClass}">${item.status}</span>` : ""}
</label>`;
}
if (items.length === 0) {
html += `<span style="color: var(--text-muted); font-size: 0.85rem; padding: 0.3rem;">No ${file} configured</span>`;
}
}
html += "</div>";
}
container.innerHTML = html;
}
// ---------------------------------------------------------------------------
// System components (Layer 5)
// ---------------------------------------------------------------------------
function renderSystemComponents() {
const container = document.getElementById("systemComponents");
const sections = {
"Shunts (Mock APIs)": "shunts",
"Monitors": "monitors",
"Tools": "tools",
};
let html = "";
for (const [label, file] of Object.entries(sections)) {
const items = (defaults.data[file] || {}).items || [];
if (items.length === 0) continue;
html += `<div class="section-label">${label}</div><div class="check-grid">`;
for (const item of items) {
const desc = item.description ? ` title="${item.description}"` : "";
html += `
<label class="check-item"${desc}>
<input type="checkbox" checked data-system="${file}" data-name="${item.name}">
<span class="name">${item.title || item.name}</span>
</label>`;
}
html += "</div>";
}
container.innerHTML = html || '<p style="color: var(--text-muted);">No system components available.</p>';
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
function goLayer(n) {
const id = typeof n === "string" ? n : n.toString();
document.querySelectorAll(".layer").forEach(el => el.classList.remove("active"));
document.querySelectorAll(".nav-item").forEach(el => el.classList.remove("active"));
const section = document.getElementById(`layer-${id}`);
if (section) section.classList.add("active");
const nav = document.querySelector(`.nav-item[data-layer="${id}"]`);
if (nav) nav.classList.add("active");
if (typeof n === "number") currentLayer = n;
updateManagedVisibility();
updateNginxVisibility();
updatePreview();
}
function updateManagedVisibility() {
const isManaged = document.getElementById("roomType").value === "managed";
document.getElementById("managedNotice").style.display = isManaged ? "none" : "block";
document.getElementById("managedFields").style.display = isManaged ? "block" : "none";
}
function updateNginxVisibility() {
const isManaged = document.getElementById("roomType").value === "managed";
const hasFrontend = !!document.getElementById("frontendPath").value;
const show = isManaged && hasFrontend;
document.getElementById("nginxNotice").style.display = show ? "none" : "block";
document.getElementById("nginxFields").style.display = show ? "block" : "none";
const room = document.getElementById("roomName").value || "myroom";
document.getElementById("hostsEntry").textContent = `127.0.0.1 ${room}.spr.local.ar ${room}.local.ar`;
}
// ---------------------------------------------------------------------------
// Preview
// ---------------------------------------------------------------------------
function updatePreview() {
const room = document.getElementById("roomName").value || "myroom";
const isManaged = document.getElementById("roomType").value === "managed";
const appName = document.getElementById("appName").value || room;
const hasFrontend = !!document.getElementById("frontendPath").value;
let tree = `<div class="folder">cfg/${room}/</div>`;
tree += `<div class="file"> config.json</div>`;
tree += `<div class="folder"> data/</div>`;
tree += `<div class="folder"> soleprint/</div>`;
tree += `<div class="file"> docker-compose.yml</div>`;
tree += `<div class="file"> .env</div>`;
if (isManaged) {
tree += `<div class="file"> docker-compose.yml</div>`;
tree += `<div class="file"> Dockerfile.backend</div>`;
if (hasFrontend) tree += `<div class="file"> Dockerfile.frontend</div>`;
tree += `<div class="file"> .env</div>`;
tree += `<div class="folder"> ${appName}/</div>`;
tree += `<div class="folder"> link/</div>`;
if (hasFrontend) {
tree += `<div class="folder"> soleprint/nginx/</div>`;
}
}
tree += `<div class="folder"> ctrl/</div>`;
document.getElementById("previewTree").innerHTML = tree;
// Selected layers summary
const layers = getSelectedLayers();
document.getElementById("selectedLayers").innerHTML = layers
.map(l => `Layer ${l}`)
.join("<br>");
}
function getSelectedLayers() {
const isManaged = document.getElementById("roomType").value === "managed";
const hasFrontend = !!document.getElementById("frontendPath").value;
const layers = [0, 1];
if (isManaged) { layers.push(2); layers.push(3); }
layers.push(4);
if (document.getElementById("enableTester").checked) layers.push(5);
if (isManaged && hasFrontend) layers.push(6);
return layers;
}
// ---------------------------------------------------------------------------
// Collect data selections from checkboxes
// ---------------------------------------------------------------------------
function collectDataSelections() {
const selections = {};
document.querySelectorAll('#dataRegistry input[type="checkbox"]').forEach(cb => {
const file = cb.dataset.file;
if (!selections[file]) selections[file] = [];
if (cb.checked) {
selections[file].push(JSON.parse(cb.dataset.item));
}
});
return selections;
}
// ---------------------------------------------------------------------------
// Generate
// ---------------------------------------------------------------------------
async function generate() {
const room = document.getElementById("roomName").value.trim();
if (!room) { showToast("error", "Room name is required"); return; }
const isManaged = document.getElementById("roomType").value === "managed";
const hasFrontend = !!document.getElementById("frontendPath").value;
const body = {
room: room,
room_type: document.getElementById("roomType").value,
port: parseInt(document.getElementById("hubPort").value) || 0,
layers: getSelectedLayers(),
data_selections: collectDataSelections(),
};
if (isManaged) {
body.layer2 = {
app_name: document.getElementById("appName").value || room,
backend_path: document.getElementById("backendPath").value,
backend_framework: document.getElementById("backendFramework").value,
frontend_path: document.getElementById("frontendPath").value || null,
frontend_framework: document.getElementById("frontendFramework").value || null,
};
}
if (document.getElementById("enableTester").checked) {
body.layer5 = {
test_url: document.getElementById("testUrl").value,
};
}
try {
const resp = await fetch("/api/generate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
if (resp.ok) {
const result = await resp.json();
showToast("success", `Room "${result.room}" created at ${result.path}. Build: ${result.build_command}`);
} else {
const err = await resp.json();
showToast("error", err.detail || "Generation failed");
}
} catch (e) {
showToast("error", `Error: ${e.message}`);
}
}
async function cloneRoom() {
const source = document.getElementById("cloneSource").value;
const target = document.getElementById("cloneTarget").value.trim();
if (!target) { showToast("error", "Target room name required"); return; }
try {
const resp = await fetch("/api/clone", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({source, target}),
});
if (resp.ok) {
const result = await resp.json();
showToast("success", `Cloned ${result.source} -> ${result.target}. Build: ${result.build_command}`);
} else {
const err = await resp.json();
showToast("error", err.detail || "Clone failed");
}
} catch (e) {
showToast("error", `Error: ${e.message}`);
}
}
// ---------------------------------------------------------------------------
// Toast
// ---------------------------------------------------------------------------
function showToast(type, msg) {
const toast = document.getElementById("toast");
toast.className = `toast ${type}`;
toast.textContent = msg;
setTimeout(() => { toast.className = "toast"; }, 6000);
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
init();
</script>
</body>
</html>