#!/usr/bin/env python3 """Local dev server for inspecting the l4d2web UI without a real deploy. Sets the same env vars the production systemd unit gets from /etc/left4me/*.env, runs alembic upgrade head, and starts Flask. First run also seeds an admin user + a tiny demo content set (one blueprint, one script overlay, one files overlay) so the editor is visible at all three call sites immediately. Does NOT mock l4d2ctl, so server-deploy actions (initialize/start/stop) still fail — they need real systemd + steamcmd. Blueprint + overlay CRUD work fine. Usage: scripts/dev-server.py [--port N] (default: 5051) Reset: rm -rf .tmp/dev-server (next run reseeds) """ import json import os import pathlib import sqlite3 import subprocess import sys from datetime import UTC, datetime REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent DEV_ROOT = REPO_ROOT / ".tmp" / "dev-server" DB_FILE = DEV_ROOT / "l4d2web.db" def seed_demo_content(): """Insert one blueprint, one script overlay, one files overlay, one server.""" overlay_root = DEV_ROOT / "overlays" conn = sqlite3.connect(DB_FILE) cur = conn.cursor() admin_id = cur.execute( "SELECT id FROM users WHERE admin = 1 LIMIT 1" ).fetchone()[0] now = datetime.now(UTC).isoformat() blueprint_id = None # captured below; needed for the server row config_lines = [ "// L4D2 dev demo — try the srccfg highlighter + autocomplete", "sv_cheats 0", "mp_gamemode coop", "z_difficulty Normal", "// Type sv_ on a new line to see the cvar popup:", "", ] cur.execute( "INSERT INTO blueprints " "(user_id, name, arguments, config, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?)", (admin_id, "demo-srccfg", json.dumps([]), json.dumps(config_lines), now, now), ) blueprint_id = cur.lastrowid script_text = ( "#!/usr/bin/env bash\n" "# L4D2 dev demo — try the bash highlighter\n" "set -euo pipefail\n" "\n" "for f in /overlay/*.cfg; do\n" ' echo "processing $f"\n' "done\n" ) cur.execute( "INSERT INTO overlays (name, path, type, user_id, script, " "last_build_status, created_at, updated_at) " "VALUES (?, ?, 'script', NULL, ?, '', ?, ?)", ("demo-bash", "_pending", script_text, now, now), ) script_id = cur.lastrowid cur.execute("UPDATE overlays SET path = ? WHERE id = ?", (str(script_id), script_id)) (overlay_root / str(script_id)).mkdir(parents=True, exist_ok=True) cur.execute( "INSERT INTO overlays (name, path, type, user_id, script, " "last_build_status, created_at, updated_at) " "VALUES (?, ?, 'files', NULL, '', '', ?, ?)", ("demo-files", "_pending", now, now), ) files_id = cur.lastrowid cur.execute("UPDATE overlays SET path = ? WHERE id = ?", (str(files_id), files_id)) files_dir = overlay_root / str(files_id) files_dir.mkdir(parents=True, exist_ok=True) (files_dir / "test.cfg").write_text( "// open this file in the editor modal — flip the language\n" "// dropdown to bash to see the runtime language switch.\n" "sv_cheats 0\n" ) # Server: links the blueprint above to a port. desired_state stays # "stopped" so the GUI shows it without trying to deploy via l4d2ctl. cur.execute( "INSERT INTO servers (user_id, blueprint_id, name, port, " "desired_state, actual_state, last_error, created_at, updated_at) " "VALUES (?, ?, 'demo-server', 27015, 'stopped', 'unknown', '', ?, ?)", (admin_id, blueprint_id, now, now), ) server_id = cur.lastrowid conn.commit() conn.close() print(f" blueprint id={blueprint_id} 'demo-srccfg' -> /blueprints/{blueprint_id}") print(f" overlay id={script_id} 'demo-bash' (script) -> /overlays/{script_id}") print(f" overlay id={files_id} 'demo-files' (files) -> /overlays/{files_id}") print(f" server id={server_id} 'demo-server' (:27015) -> /servers/{server_id}") def main(): port = 5051 if "--port" in sys.argv: port = int(sys.argv[sys.argv.index("--port") + 1]) DEV_ROOT.mkdir(parents=True, exist_ok=True) (DEV_ROOT / "overlays").mkdir(exist_ok=True) (DEV_ROOT / "instances").mkdir(exist_ok=True) # Production-equivalent env (systemd gets these from /etc/left4me/*.env). os.environ.update({ "SECRET_KEY": "local-dev-only-not-a-secret", "DATABASE_URL": f"sqlite:///{DB_FILE}", "LEFT4ME_ROOT": str(DEV_ROOT), "SESSION_COOKIE_SECURE": "0", # so http://127.0.0.1 cookies stick "JOB_WORKER_ENABLED": "false", # don't fire (sudo-requiring) l4d2ctl "LOG_CAPTURE_DIR": str(DEV_ROOT / "captures"), }) print(f"==> Migrating {DB_FILE} to head", flush=True) subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=REPO_ROOT / "l4d2web", check=True, stdout=subprocess.DEVNULL) user_count = sqlite3.connect(DB_FILE).execute( "SELECT COUNT(*) FROM users").fetchone()[0] admin_user = os.environ.get("LEFT4ME_DEV_ADMIN_USERNAME", "dev") admin_pw = os.environ.get("LEFT4ME_DEV_ADMIN_PASSWORD", "devdevdev") if user_count == 0: print(f"==> Seeding admin user '{admin_user}'", flush=True) subprocess.run( ["uv", "run", "flask", "--app", "l4d2web.app:create_app()", "create-user", admin_user, "--admin"], cwd=REPO_ROOT, check=True, env={**os.environ, "LEFT4ME_ADMIN_PASSWORD": admin_pw}, ) print("==> Seeding demo content", flush=True) seed_demo_content() print(f"\n admin: {admin_user} / {admin_pw}", flush=True) print(f" db: {DB_FILE}", flush=True) print(f" root: {DEV_ROOT}", flush=True) print(f"\n Ctrl-C to stop. Reset all state: rm -rf .tmp/dev-server\n", flush=True) print(f"==> Starting Flask on http://127.0.0.1:{port}", flush=True) # --debug enables: Python code reload on save, template auto-reload, # verbose error pages, the Werkzeug interactive debugger. Safe here # because we bind to 127.0.0.1 only. os.execvp("uv", ["uv", "run", "flask", "--app", "l4d2web.app:create_app()", "--debug", "run", "--port", str(port)]) if __name__ == "__main__": main()