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

View File

@@ -1,949 +0,0 @@
#!/usr/bin/env python3
"""
spr init - Room scaffolding wizard
Creates a new soleprint room in cfg/<room>/ 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/<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 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 '</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 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 <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()