diff --git a/muxplex/__pycache__/main.cpython-312.pyc b/muxplex/__pycache__/main.cpython-312.pyc index 1ebf0a8..517376b 100644 Binary files a/muxplex/__pycache__/main.cpython-312.pyc and b/muxplex/__pycache__/main.cpython-312.pyc differ diff --git a/muxplex/__pycache__/settings.cpython-312.pyc b/muxplex/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..eee7081 Binary files /dev/null and b/muxplex/__pycache__/settings.cpython-312.pyc differ diff --git a/muxplex/main.py b/muxplex/main.py index 80c6f67..7ad253d 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -14,6 +14,7 @@ import logging import os import pathlib import pwd +import subprocess import sys import time from typing import Literal @@ -25,7 +26,7 @@ from websockets.typing import Subprotocol from fastapi import FastAPI, Form, HTTPException, Request, WebSocket from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from starlette.responses import RedirectResponse from muxplex.auth import ( @@ -268,6 +269,18 @@ class HeartbeatPayload(BaseModel): last_interaction_at: float +class CreateSessionPayload(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_must_not_be_blank(cls, v: str) -> str: + stripped = v.strip() + if not stripped: + raise ValueError("name must not be empty or whitespace") + return stripped + + # --------------------------------------------------------------------------- # Frontend directory # --------------------------------------------------------------------------- @@ -323,6 +336,31 @@ async def get_sessions() -> list[dict]: return result +@app.post("/api/sessions") +async def create_session(payload: CreateSessionPayload) -> dict: + """Create a new tmux session using the new_session_template from settings. + + Substitutes {name} in the template with the validated payload name, then + runs the command as a fire-and-forget subprocess (stdout/stderr discarded). + + Returns {name: name} regardless of outcome. + Raises HTTP 422 if name is empty or whitespace (handled by Pydantic). + """ + name = payload.name + settings = load_settings() + command = settings["new_session_template"].replace("{name}", name) + try: + subprocess.Popen( + command, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + _log.warning("Failed to launch new-session command: %r", command) + return {"name": name} + + @app.post("/api/sessions/{name}/connect") async def connect_session(name: str) -> dict: """Connect to a tmux session via ttyd. diff --git a/muxplex/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc index 23862f0..909b7b5 100644 Binary files a/muxplex/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_settings.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_settings.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..2787a19 Binary files /dev/null and b/muxplex/tests/__pycache__/test_settings.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 0c9880c..a6049de 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -966,3 +966,57 @@ def test_patch_settings_ignores_unknown_keys(client, tmp_path, monkeypatch): assert response.status_code == 200 data = response.json() assert "unknown_key" not in data + + +# --------------------------------------------------------------------------- +# POST /api/sessions (create new session) +# --------------------------------------------------------------------------- + + +def test_create_session_returns_200_with_name(client, monkeypatch): + """POST /api/sessions with valid name returns 200 with {name: name}.""" + from unittest.mock import MagicMock + + mock_popen = MagicMock() + monkeypatch.setattr("muxplex.main.subprocess.Popen", mock_popen) + + response = client.post("/api/sessions", json={"name": "my-project"}) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "my-project" + + +def test_create_session_substitutes_name_in_template(client, tmp_path, monkeypatch): + """POST /api/sessions substitutes {name} with actual name in new_session_template.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text(json.dumps({"new_session_template": "echo {name}"})) + + popen_calls = [] + + def mock_popen(cmd, **kwargs): + popen_calls.append(cmd) + return object() + + monkeypatch.setattr("muxplex.main.subprocess.Popen", mock_popen) + + response = client.post("/api/sessions", json={"name": "my-project"}) + assert response.status_code == 200 + assert len(popen_calls) == 1 + assert popen_calls[0] == "echo my-project" + + +def test_create_session_rejects_empty_name(client): + """POST /api/sessions with empty name returns 422.""" + response = client.post("/api/sessions", json={"name": ""}) + assert response.status_code == 422 + + +def test_create_session_rejects_missing_name(client): + """POST /api/sessions with missing name returns 422.""" + response = client.post("/api/sessions", json={}) + assert response.status_code == 422