From ead4bd1aa4019f4468bad74c82d1e19c52eb3fe6 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 00:04:11 +0200 Subject: [PATCH] feat(scripts): add scripts/dev-server.py for local UI smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codifies the local-smoke setup so future inspection of the l4d2web UI on macOS is reproducible without a real deploy. Sets the production-equivalent env vars the systemd unit normally gets from /etc/left4me/*.env: - SECRET_KEY (dev placeholder) - DATABASE_URL → .tmp/dev-server/l4d2web.db (gitignored) - LEFT4ME_ROOT → .tmp/dev-server (so overlay-mkdir doesn't try /var/lib/left4me, which is read-only on macOS dev) - SESSION_COOKIE_SECURE=0 so cookies survive http://127.0.0.1 - JOB_WORKER_ENABLED=false so the background worker doesn't shell out to the sudo-requiring production l4d2ctl Runs alembic upgrade head. On first run, auto-seeds: - admin user 'dev' / 'devdevdev' (password chosen to satisfy the ≥8 char policy in l4d2web/auth.py:validate_new_password) - one blueprint with example srccfg content (exercises the highlighter + autocomplete) - one script overlay with bash (exercises the bash highlighter) - one files overlay with a test.cfg (exercises the files-editor modal + language dropdown) - one server linked to the blueprint (exercises the server detail page rendering, though deploy actions still fail) Starts Flask with --debug so code + template changes auto-reload. Stub l4d2ctl for server-deploy actions is deliberately out of scope; documented in the script's docstring. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/dev-server.py | 165 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100755 scripts/dev-server.py diff --git a/scripts/dev-server.py b/scripts/dev-server.py new file mode 100755 index 0000000..2099f00 --- /dev/null +++ b/scripts/dev-server.py @@ -0,0 +1,165 @@ +#!/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 + }) + + 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()