From 2e5a30418183d6f073dbc72abb57aa5824c7ea76 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Sun, 12 Apr 2026 12:34:25 -0300 Subject: [PATCH] wizard --- init/__init__.py | 0 init/cli.py | 171 +++++++ ctrl/init.py => init/core.py | 360 +++++---------- init/web.py | 267 +++++++++++ init/wizard.html | 844 +++++++++++++++++++++++++++++++++++ 5 files changed, 1399 insertions(+), 243 deletions(-) create mode 100644 init/__init__.py create mode 100644 init/cli.py rename ctrl/init.py => init/core.py (65%) create mode 100644 init/web.py create mode 100644 init/wizard.html diff --git a/init/__init__.py b/init/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/init/cli.py b/init/cli.py new file mode 100644 index 0000000..a3c92a0 --- /dev/null +++ b/init/cli.py @@ -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// 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 [--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() diff --git a/ctrl/init.py b/init/core.py similarity index 65% rename from ctrl/init.py rename to init/core.py index 7607526..404c0f5 100644 --- a/ctrl/init.py +++ b/init/core.py @@ -1,21 +1,17 @@ -#!/usr/bin/env python3 """ -spr init - Room scaffolding wizard +init.core — Room generation logic -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 +Templates + file generation for soleprint rooms. +No interactive I/O — called by CLI and web wizard with data already collected. """ -import argparse import json +import logging import shutil -import sys from pathlib import Path +log = logging.getLogger("init") + SPR_ROOT = Path(__file__).resolve().parent.parent CFG_DIR = SPR_ROOT / "cfg" @@ -23,59 +19,18 @@ 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)}") + 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(): @@ -92,8 +47,18 @@ def next_free_port() -> int: 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() + ) + + # --------------------------------------------------------------------------- -# Templates +# Constants # --------------------------------------------------------------------------- SYSTEMS = [ @@ -132,6 +97,13 @@ DATA_FILES = [ "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 = { @@ -152,7 +124,11 @@ def make_config(room: str, port: int, managed: dict | None = None) -> dict: return cfg -def soleprint_compose() -> str: +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- + +def tpl_soleprint_compose() -> str: return """\ # Soleprint Services - Docker Compose # @@ -189,7 +165,7 @@ networks: """ -def soleprint_env(room: str, port: int) -> str: +def tpl_soleprint_env(room: str, port: int) -> str: return f"""\ # ============================================================================= # {room} — Soleprint Configuration @@ -209,7 +185,7 @@ AUTH_SESSION_SECRET={room}-dev-secret-change-in-production """ -def managed_compose(room: str, app_name: str, has_frontend: bool) -> str: +def tpl_managed_compose(room: str, app_name: str, has_frontend: bool) -> str: services = f"""\ # {room} — Managed App Services @@ -251,7 +227,7 @@ networks: return services -DOCKERFILE_BACKEND = { +TPL_DOCKERFILE_BACKEND = { "django": """\ FROM python:3.12-slim WORKDIR /app @@ -289,7 +265,7 @@ CMD ["echo", "Configure your start command"] """, } -DOCKERFILE_FRONTEND = { +TPL_DOCKERFILE_FRONTEND = { "nextjs": """\ FROM node:20-slim WORKDIR /app @@ -328,7 +304,7 @@ CMD ["echo", "Configure your start command"] } -def managed_env(room: str, backend_port: int, frontend_port: int | None) -> str: +def tpl_managed_env(room: str, backend_port: int, frontend_port: int | None) -> str: lines = f"""\ # ============================================================================= # {room} — Managed App Configuration @@ -349,7 +325,7 @@ DB_PASSWORD=localdev123 return lines -def link_main() -> str: +def tpl_link_main() -> str: return """\ \"\"\" Link — Database bridge for soleprint tools. @@ -375,7 +351,7 @@ def health(): """ -def link_adapter_django() -> str: +def tpl_link_adapter_django() -> str: return """\ \"\"\" Django adapter — reads Django ORM metadata to provide schema + data access. @@ -389,7 +365,7 @@ Django adapter — reads Django ORM metadata to provide schema + data access. """ -def link_adapter_fastapi() -> str: +def tpl_link_adapter_fastapi() -> str: return """\ \"\"\" FastAPI/SQLAlchemy adapter — reads SQLAlchemy metadata for schema + data access. @@ -402,7 +378,7 @@ FastAPI/SQLAlchemy adapter — reads SQLAlchemy metadata for schema + data acces """ -def link_requirements() -> str: +def tpl_link_requirements() -> str: return """\ fastapi>=0.100.0 uvicorn>=0.23.0 @@ -411,7 +387,7 @@ sqlalchemy>=2.0.0 """ -def link_dockerfile() -> str: +def tpl_link_dockerfile() -> str: return """\ FROM python:3.12-slim WORKDIR /app @@ -423,7 +399,7 @@ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] """ -def link_compose(room: str) -> str: +def tpl_link_compose(room: str) -> str: return f"""\ # Link — DB Bridge name: {room}_link @@ -448,7 +424,7 @@ networks: """ -def ctrl_start(room: str, app_name: str | None) -> str: +def tpl_ctrl_start(room: str, app_name: str | None) -> str: lines = f"""\ #!/bin/bash # Start all {room} services @@ -485,7 +461,7 @@ echo "=== All services started ===" return lines -def ctrl_stop(room: str, app_name: str | None) -> str: +def tpl_ctrl_stop(room: str, app_name: str | None) -> str: lines = f"""\ #!/bin/bash # Stop all {room} services @@ -511,7 +487,7 @@ echo "=== All {room} services stopped ===" return lines -def ctrl_status(room: str) -> str: +def tpl_ctrl_status(room: str) -> str: return f"""\ #!/bin/bash # Show status of {room} services @@ -532,7 +508,7 @@ done """ -def ctrl_logs(room: str) -> str: +def tpl_ctrl_logs(room: str) -> str: return f"""\ #!/bin/bash # Show logs for {room} services @@ -550,19 +526,15 @@ fi """ -def tester_environments(url: str) -> str: +def tpl_tester_environments(url: str) -> str: return json.dumps({ "environments": [ - { - "name": "local", - "url": url, - "auth_type": "none", - } + {"name": "local", "url": url, "auth_type": "none"} ] }, indent=2) + "\n" -def tester_base() -> str: +def tpl_tester_base() -> str: return """\ \"\"\" Room-specific test base class. @@ -574,7 +546,7 @@ from station.tools.tester.base import ContractTestCase # noqa: F401 """ -def nginx_conf(room: str, app_name: str) -> str: +def tpl_nginx_conf(room: str, app_name: str) -> str: return f"""\ # {room} — Nginx Config for Docker # @@ -651,7 +623,7 @@ server {{ """ -def nginx_compose(room: str) -> str: +def tpl_nginx_compose(room: str) -> str: return f"""\ # Nginx Reverse Proxy for {room} # @@ -684,22 +656,17 @@ networks: # --------------------------------------------------------------------------- -# Layers +# Layer generators — pure functions, no I/O prompts # --------------------------------------------------------------------------- -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} +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 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") @@ -708,34 +675,26 @@ def layer0_config(room_dir: Path, room: str, port: int, is_managed: bool) -> dic return config -def layer1_docker(room_dir: Path, room: str, port: int): - """Layer 1: soleprint docker-compose + .env""" - print("\n-- Layer 1: Docker --") +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", soleprint_compose()) - write_file(spr_dir / ".env", soleprint_env(room, port)) + write_file(spr_dir / "docker-compose.yml", tpl_soleprint_compose()) + write_file(spr_dir / ".env", tpl_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 --") +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 --") - 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" + has_frontend = bool(frontend_path) - frontend_framework = None - if has_frontend: - frontend_framework = ask_choice( - "Frontend framework?", ["nextjs", "react", "vue", "other"], "nextjs" - ) - - # Update config.json with managed section + # Update config with managed section repos = {"backend": backend_path} if has_frontend: repos["frontend"] = frontend_path @@ -743,21 +702,21 @@ def layer2_managed(room_dir: Path, room: str, config: dict) -> dict: 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]) + 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 # offset from soleprint ports + backend_port = next_free_port() + 100 frontend_port = backend_port + 10 if has_frontend else None - write_file(room_dir / ".env", managed_env(room, backend_port, frontend_port)) + 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() - print(f" + {(dumps_dir / '.gitkeep').relative_to(SPR_ROOT)}") + log.info(" + %s", (dumps_dir / ".gitkeep").relative_to(SPR_ROOT)) return { "app_name": app_name, @@ -767,58 +726,56 @@ def layer2_managed(room_dir: Path, room: str, config: dict) -> dict: } -def layer3_link(room_dir: Path, room: str, backend_framework: str): +def generate_layer3(room_dir: Path, room: str, backend_framework: str): """Layer 3: Link (DB bridge) scaffolding.""" - print("\n-- Layer 3: Link --") + log.info("-- Layer 3: Link --") link_dir = room_dir / "link" - write_file(link_dir / "main.py", link_main()) + write_file(link_dir / "main.py", tpl_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)) + 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)) - # Framework-specific adapter scaffold if backend_framework == "django": - write_file(link_dir / "adapters" / "django.py", link_adapter_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", link_adapter_fastapi()) + write_file(link_dir / "adapters" / "sqlalchemy.py", tpl_link_adapter_fastapi()) -def layer4_scripts(room_dir: Path, room: str, app_name: str | None): +def generate_layer4(room_dir: Path, room: str, app_name: str | None): """Layer 4: ctrl/ scripts.""" - print("\n-- Layer 4: Scripts --") + log.info("-- 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)) + 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 layer5_tester(room_dir: Path, room: str): +def generate_layer5(room_dir: Path, room: str, test_url: str = "http://localhost:8000"): """Layer 5: Test suite scaffolding.""" - print("\n-- Layer 5: Test Suite --") + log.info("-- 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 / "environments.json", tpl_tester_environments(test_url)) write_file(tester_dir / "tests" / "__init__.py", "") - write_file(tester_dir / "tests" / "base.py", tester_base()) + write_file(tester_dir / "tests" / "base.py", tpl_tester_base()) -def layer6_nginx(room_dir: Path, room: str, app_name: str): +def generate_layer6(room_dir: Path, room: str, app_name: str): """Layer 6: Nginx sidebar injection.""" - print("\n-- Layer 6: Nginx --") + log.info("-- 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") + 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 (--from) +# Clone # --------------------------------------------------------------------------- def clone_room(source_name: str, target_name: str): @@ -827,18 +784,17 @@ def clone_room(source_name: str, target_name: str): 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) + log.error("source room '%s' not found in cfg/", source_name) + return False if target_dir.exists(): - print(f"Error: '{target_name}' already exists in cfg/") - sys.exit(1) + log.error("'%s' already exists in cfg/", target_name) + return False - print(f"\n=== Cloning {source_name} -> {target_name} ===") + log.info("=== Cloning %s -> %s ===", source_name, target_name) - # Copy the whole tree shutil.copytree(source_dir, target_dir) - print(f" Copied cfg/{source_name}/ -> cfg/{target_name}/") + log.info(" Copied cfg/%s/ -> cfg/%s/", source_name, target_name) # Patch config.json config_path = target_dir / "config.json" @@ -847,103 +803,21 @@ def clone_room(source_name: str, target_name: str): 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}") + log.info(" Patched config.json: hub_port=%d", new_port) - # Patch all .env files: replace source name with target name + # 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) - print(f" Patched {env_file.relative_to(SPR_ROOT)}") + log.info(" Patched %s", env_file.relative_to(SPR_ROOT)) # Patch nginx configs - for nginx_conf_file in target_dir.rglob("*.conf"): - content = nginx_conf_file.read_text() + for conf_file in target_dir.rglob("*.conf"): + content = 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)}") + conf_file.write_text(content) + log.info(" Patched %s", 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() + log.info("Done! Build with: python build.py --cfg %s", target_name) + return True diff --git a/init/web.py b/init/web.py new file mode 100644 index 0000000..f078065 --- /dev/null +++ b/init/web.py @@ -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() diff --git a/init/wizard.html b/init/wizard.html new file mode 100644 index 0000000..fa01af6 --- /dev/null +++ b/init/wizard.html @@ -0,0 +1,844 @@ + + + + + + Soleprint - Room Setup + + + +
+ + + + +
+ + +
+

Room Configuration

+

Basic identity and data registry for your room

+ +
+ + +
Unique identifier — becomes cfg/<name>/
+
+ +
+
+ + +
+
+ + +
+
+ + +

+ Select which items to include in your room's data files. + All available components are shown — uncheck what you don't need. +

+
+ +
+ +
+ +
+
+ + +
+

Docker Configuration

+

Soleprint runtime — docker-compose and environment

+

+ Generates soleprint/docker-compose.yml and soleprint/.env + with your room name, port, and network settings. These are auto-filled from Layer 0. +

+
+ +
+
soleprint/docker-compose.yml
+
soleprint/.env
+
+
+
+ + +
+
+ + +
+

Managed Application

+

The app soleprint wraps — repos, framework, Docker setup

+ +
+ Room type is "standalone" — no managed app needed. + Switch to managed? +
+ + + +
+ + +
+
+ + +
+

Link — DB Bridge

+

Read access to the managed app's database for soleprint tools

+

+ Scaffolds a FastAPI service with adapter stubs matching your backend framework. + Used by databrowse, datagen, and tester to access app data. +

+
+ +
+
link/main.py
+
link/adapters/
+
link/Dockerfile
+
link/docker-compose.yml
+
link/requirements.txt
+
+
+
+ + +
+
+ + +
+

Control Scripts

+

start, stop, status, logs — generated for your room's services

+
+ +
+
ctrl/start.sh
+
ctrl/stop.sh
+
ctrl/status.sh
+
ctrl/logs.sh
+
+
+
+ + +
+
+ + +
+

System Extensions

+

Select which tools, monitors, and connectors to scaffold for this room

+ + +
+ +
+
+ + +
+ + +

+ These are registered in your data/*.json files (configured in Layer 0). + Scaffolding for custom shunts, monitors, and tools can be added after generation. +

+ +
+ +
+ +
+ + +
+
+ + +
+

Nginx — Sidebar Injection

+

Reverse proxy that injects the soleprint sidebar into your app's frontend

+ +
+ Requires a managed app with a frontend. Configure in Layer 2. +
+ + + +
+ + +
+
+ + +
+

Clone Existing Room

+

Create a variant of an existing room with new ports and names

+ +
+
+ + +
+
+ + +
+
+
+ Copies the entire cfg/ tree, patches all names and ports. + Shares source code repos but gets its own DB, containers, and hostname. +
+
+ +
+
+
+ + + +
+ +
+ + + +