From 14234e2531f2e42bad5dd2a9de332502d08926a8 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Wed, 28 Jan 2026 16:52:11 +0000 Subject: [PATCH] Bump version to 1.1.2 --- .dockerignore | 1 - README.md | 8 ++++++-- pyproject.toml | 2 +- src/webterm/config.py | 3 +++ src/webterm/docker_watcher.py | 19 +++++++++++++++++-- src/webterm/local_server.py | 15 +++++++++------ src/webterm/session_manager.py | 13 +++++++++++-- tests/test_config_manifest.py | 3 +++ tests/test_docker_watcher.py | 29 ++++++++++++++++++++++++----- tests/test_local_server_unit.py | 1 + 10 files changed, 75 insertions(+), 19 deletions(-) diff --git a/.dockerignore b/.dockerignore index a3957aa..27829b9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,5 +26,4 @@ tests docs examples Dockerfile -README.md LICENSE diff --git a/README.md b/README.md index 4c5501b..d582a71 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ webterm --docker-watch When a container starts with the label, it automatically appears in the dashboard. When it stops, it's removed. Label values: -- `webterm-command: auto` - Runs `docker exec -it /bin/bash` +- `webterm-command: auto` - Runs `docker exec -it /bin/bash` (override with `WEBTERM_DOCKER_AUTO_COMMAND`) - `webterm-command: ` - Runs the specified command +- `webterm-theme: ` - Sets the terminal theme for that container (xterm, monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo) Example docker-compose.yaml: @@ -112,18 +113,20 @@ services: image: myapp:latest labels: webterm-command: auto # Opens bash in container + webterm-theme: monokai logs: image: myapp:latest labels: webterm-command: docker logs -f myapp # Shows logs + webterm-theme: nord ``` **Requires**: Docker socket access (`-v /var/run/docker.sock:/var/run/docker.sock`) ### Docker Compose Integration -Point to a docker-compose file; services with the label `webterm-command` become tiles: +Point to a docker-compose file; services with the label `webterm-command` become tiles (and `webterm-theme` applies there too): ```yaml services: @@ -131,6 +134,7 @@ services: image: postgres labels: webterm-command: docker exec -it db psql + webterm-theme: gruvbox ``` Start with: diff --git a/pyproject.toml b/pyproject.toml index e05c12e..7067fd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "webterm" -version = "1.1.1" +version = "1.1.2" description = "Serve terminal sessions over the web" authors = ["Will McGugan "] license = "MIT" diff --git a/src/webterm/config.py b/src/webterm/config.py index 640a66b..18c7e64 100644 --- a/src/webterm/config.py +++ b/src/webterm/config.py @@ -25,6 +25,7 @@ class App(BaseModel): color: str = "" command: ExpandVarsStr = "" terminal: bool = False + theme: str | None = None class Config(BaseModel): @@ -136,6 +137,7 @@ def load_compose_manifest(manifest_path: Path) -> list[App]: command = _extract_label(labels, "webterm-command") if not command: continue + theme = _extract_label(labels, "webterm-theme") slug = slugify(name) apps.append( App( @@ -145,6 +147,7 @@ def load_compose_manifest(manifest_path: Path) -> list[App]: path=service.get("working_dir", "./"), color="", terminal=True, + theme=theme, ) ) return apps diff --git a/src/webterm/docker_watcher.py b/src/webterm/docker_watcher.py index daef186..66b53a4 100644 --- a/src/webterm/docker_watcher.py +++ b/src/webterm/docker_watcher.py @@ -10,6 +10,7 @@ import asyncio import contextlib import json import logging +import os from typing import TYPE_CHECKING, Callable from .docker_stats import get_docker_socket_path @@ -20,9 +21,15 @@ if TYPE_CHECKING: log = logging.getLogger("webterm") LABEL_NAME = "webterm-command" +THEME_LABEL = "webterm-theme" +AUTO_COMMAND_ENV = "WEBTERM_DOCKER_AUTO_COMMAND" DEFAULT_COMMAND = "/bin/bash" +def _get_auto_command() -> str: + return os.environ.get(AUTO_COMMAND_ENV, DEFAULT_COMMAND) + + class DockerWatcher: """Watch Docker events and manage terminal sessions dynamically.""" @@ -120,9 +127,16 @@ class DockerWatcher: if label_value.lower() == "auto": container_name = self._get_container_name(container) - return f"docker exec -it {container_name} {DEFAULT_COMMAND}" + return f"docker exec -it {container_name} {_get_auto_command()}" return label_value + def _get_container_theme(self, container: dict) -> str | None: + labels = container.get("Labels", {}) + value = labels.get(THEME_LABEL) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + def _get_container_name(self, container: dict) -> str: """Get container name (without leading /).""" names = container.get("Names", []) @@ -139,6 +153,7 @@ class DockerWatcher: slug = self._container_to_slug(container) name = self._get_container_name(container) command = self._get_container_command(container) + theme = self._get_container_theme(container) container_id = container.get("Id", "") if slug in self._managed_containers: @@ -147,7 +162,7 @@ class DockerWatcher: log.info("Adding container: %s (slug=%s, cmd=%s)", name, slug, command) self._managed_containers[slug] = container_id - self._session_manager.add_app(name, command, slug, terminal=True) + self._session_manager.add_app(name, command, slug, terminal=True, theme=theme) if self._on_container_added: self._on_container_added(slug, name, command) diff --git a/src/webterm/local_server.py b/src/webterm/local_server.py index 2ac598f..de41ef4 100644 --- a/src/webterm/local_server.py +++ b/src/webterm/local_server.py @@ -188,16 +188,18 @@ class LocalServer: def app_count(self) -> int: return len(self.session_manager.apps) - def add_app(self, name: str, command: str, slug: str = "") -> None: + def add_app(self, name: str, command: str, slug: str = "", theme: str | None = None) -> None: slug = slug or generate().lower() - self.session_manager.add_app(name, command, slug=slug) + self.session_manager.add_app(name, command, slug=slug, theme=theme) - def add_terminal(self, name: str, command: str, slug: str = "") -> None: + def add_terminal( + self, name: str, command: str, slug: str = "", theme: str | None = None + ) -> None: if constants.WINDOWS: log.warning("Sorry, webterm does not currently support terminals on Windows") return slug = slug or generate().lower() - self.session_manager.add_app(name, command, slug=slug, terminal=True) + self.session_manager.add_app(name, command, slug=slug, terminal=True, theme=theme) async def run(self) -> None: try: @@ -968,9 +970,10 @@ class LocalServer: page_title = available_app.name if available_app else "Webterm" # Build data attributes for terminal configuration + theme = available_app.theme or self.theme data_attrs = ( f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" ' - f'data-scrollback="1000" data-theme="{self.theme}"' + f'data-scrollback="1000" data-theme="{theme}"' ) font_family = self.font_family or "var(--webterm-mono)" # Escape quotes for HTML attribute @@ -978,7 +981,7 @@ class LocalServer: data_attrs += f' data-font-family="{escaped_font}"' # Get theme background color (fallback to black if unknown theme) - theme_bg = THEME_BACKGROUNDS.get(self.theme.lower(), "#000000") + theme_bg = THEME_BACKGROUNDS.get(theme.lower(), "#000000") html_content = f""" diff --git a/src/webterm/session_manager.py b/src/webterm/session_manager.py index 063df3a..06626aa 100644 --- a/src/webterm/session_manager.py +++ b/src/webterm/session_manager.py @@ -35,7 +35,14 @@ class SessionManager: self.sessions: dict[SessionID, Session] = {} self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict() - def add_app(self, name: str, command: str, slug: str, terminal: bool = False) -> None: + def add_app( + self, + name: str, + command: str, + slug: str, + terminal: bool = False, + theme: str | None = None, + ) -> None: """Add a new app Args: @@ -44,7 +51,9 @@ class SessionManager: slug: Slug used in URL, or blank to auto-generate on server. """ slug = slug or generate().lower() - new_app = config.App(name=name, slug=slug, path="./", command=command, terminal=terminal) + new_app = config.App( + name=name, slug=slug, path="./", command=command, terminal=terminal, theme=theme + ) self.apps.append(new_app) self.apps_by_slug[slug] = new_app diff --git a/tests/test_config_manifest.py b/tests/test_config_manifest.py index 31e61be..42a27da 100644 --- a/tests/test_config_manifest.py +++ b/tests/test_config_manifest.py @@ -27,6 +27,7 @@ def test_load_compose_manifest_reads_label(): svc1: labels: webterm-command: echo svc1 + webterm-theme: nord svc2: labels: - webterm-command=echo svc2 @@ -42,3 +43,5 @@ def test_load_compose_manifest_reads_label(): commands = {a.command for a in apps} assert slugs == {"svc1", "svc2"} assert "echo svc1" in commands and "echo svc2" in commands + svc1 = next(app for app in apps if app.slug == "svc1") + assert svc1.theme == "nord" diff --git a/tests/test_docker_watcher.py b/tests/test_docker_watcher.py index 8061e1b..9f5fe7d 100644 --- a/tests/test_docker_watcher.py +++ b/tests/test_docker_watcher.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from webterm.docker_watcher import DEFAULT_COMMAND, LABEL_NAME, DockerWatcher +from webterm.docker_watcher import LABEL_NAME, THEME_LABEL, DockerWatcher, _get_auto_command @pytest.fixture @@ -56,7 +56,7 @@ class TestDockerWatcher: def test_get_container_command_auto(self, docker_watcher): """Test command generation when label is 'auto'.""" container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}} - expected = f"docker exec -it my-container {DEFAULT_COMMAND}" + expected = f"docker exec -it my-container {_get_auto_command()}" assert docker_watcher._get_container_command(container) == expected def test_get_container_command_custom(self, docker_watcher): @@ -67,13 +67,25 @@ class TestDockerWatcher: } assert docker_watcher._get_container_command(container) == "docker logs -f my-container" + def test_get_container_theme(self, docker_watcher): + container = {"Labels": {THEME_LABEL: "nord"}} + assert docker_watcher._get_container_theme(container) == "nord" + + def test_get_container_theme_blank(self, docker_watcher): + container = {"Labels": {THEME_LABEL: " "}} + assert docker_watcher._get_container_theme(container) is None + @pytest.mark.asyncio async def test_add_container(self, session_manager): """Test adding a container.""" on_added = MagicMock() watcher = DockerWatcher(session_manager, on_container_added=on_added) - container = {"Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto"}} + container = { + "Id": "abc123", + "Names": ["/test-container"], + "Labels": {LABEL_NAME: "auto", THEME_LABEL: "monokai"}, + } await watcher._add_container(container) @@ -84,6 +96,7 @@ class TestDockerWatcher: assert "docker exec -it test-container" in call_args[0][1] # command assert call_args[0][2] == "test-container" # slug assert call_args[1]["terminal"] is True + assert call_args[1]["theme"] == "monokai" # Should call callback on_added.assert_called_once_with("test-container", "test-container", call_args[0][1]) @@ -222,8 +235,8 @@ class TestDockerWatcherIntegration: ("labels", "expected"), [ ({"webterm-command": "echo hi"}, "echo hi"), - ({"webterm-command": "auto"}, f"docker exec -it my-container {DEFAULT_COMMAND}"), - ({"other": "value"}, f"docker exec -it my-container {DEFAULT_COMMAND}"), + ({"webterm-command": "auto"}, f"docker exec -it my-container {_get_auto_command()}"), + ({"other": "value"}, f"docker exec -it my-container {_get_auto_command()}"), ], ) def test_get_container_command_variants(docker_watcher, labels, expected): @@ -231,6 +244,12 @@ def test_get_container_command_variants(docker_watcher, labels, expected): assert docker_watcher._get_container_command(container) == expected +def test_auto_command_env_override(monkeypatch, docker_watcher): + monkeypatch.setenv("WEBTERM_DOCKER_AUTO_COMMAND", "/bin/sh") + container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}} + assert docker_watcher._get_container_command(container) == "docker exec -it my-container /bin/sh" + + @pytest.mark.asyncio @pytest.mark.parametrize( ("status", "body", "expected"), diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py index 0bfdb53..d661b0c 100644 --- a/tests/test_local_server_unit.py +++ b/tests/test_local_server_unit.py @@ -464,6 +464,7 @@ class TestLocalServerMoreCoverage: assert "data-session-websocket-url" in resp.text assert "data-font-size" in resp.text assert "data-scrollback" in resp.text + assert 'data-theme="xterm"' in resp.text assert "Known" in resp.text @pytest.mark.asyncio