ptn 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- porterminal/__init__.py +288 -0
- porterminal/__main__.py +8 -0
- porterminal/app.py +381 -0
- porterminal/application/__init__.py +1 -0
- porterminal/application/ports/__init__.py +7 -0
- porterminal/application/ports/connection_port.py +34 -0
- porterminal/application/services/__init__.py +13 -0
- porterminal/application/services/management_service.py +279 -0
- porterminal/application/services/session_service.py +249 -0
- porterminal/application/services/tab_service.py +286 -0
- porterminal/application/services/terminal_service.py +426 -0
- porterminal/asgi.py +38 -0
- porterminal/cli/__init__.py +19 -0
- porterminal/cli/args.py +91 -0
- porterminal/cli/display.py +157 -0
- porterminal/composition.py +208 -0
- porterminal/config.py +195 -0
- porterminal/container.py +65 -0
- porterminal/domain/__init__.py +91 -0
- porterminal/domain/entities/__init__.py +16 -0
- porterminal/domain/entities/output_buffer.py +73 -0
- porterminal/domain/entities/session.py +86 -0
- porterminal/domain/entities/tab.py +71 -0
- porterminal/domain/ports/__init__.py +12 -0
- porterminal/domain/ports/pty_port.py +80 -0
- porterminal/domain/ports/session_repository.py +58 -0
- porterminal/domain/ports/tab_repository.py +75 -0
- porterminal/domain/services/__init__.py +18 -0
- porterminal/domain/services/environment_sanitizer.py +61 -0
- porterminal/domain/services/rate_limiter.py +63 -0
- porterminal/domain/services/session_limits.py +104 -0
- porterminal/domain/services/tab_limits.py +54 -0
- porterminal/domain/values/__init__.py +25 -0
- porterminal/domain/values/environment_rules.py +156 -0
- porterminal/domain/values/rate_limit_config.py +21 -0
- porterminal/domain/values/session_id.py +20 -0
- porterminal/domain/values/shell_command.py +37 -0
- porterminal/domain/values/tab_id.py +24 -0
- porterminal/domain/values/terminal_dimensions.py +45 -0
- porterminal/domain/values/user_id.py +25 -0
- porterminal/infrastructure/__init__.py +20 -0
- porterminal/infrastructure/cloudflared.py +295 -0
- porterminal/infrastructure/config/__init__.py +9 -0
- porterminal/infrastructure/config/shell_detector.py +84 -0
- porterminal/infrastructure/config/yaml_loader.py +34 -0
- porterminal/infrastructure/network.py +43 -0
- porterminal/infrastructure/registry/__init__.py +5 -0
- porterminal/infrastructure/registry/user_connection_registry.py +104 -0
- porterminal/infrastructure/repositories/__init__.py +9 -0
- porterminal/infrastructure/repositories/in_memory_session.py +70 -0
- porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
- porterminal/infrastructure/server.py +161 -0
- porterminal/infrastructure/web/__init__.py +7 -0
- porterminal/infrastructure/web/websocket_adapter.py +78 -0
- porterminal/logging_setup.py +48 -0
- porterminal/pty/__init__.py +46 -0
- porterminal/pty/env.py +97 -0
- porterminal/pty/manager.py +163 -0
- porterminal/pty/protocol.py +84 -0
- porterminal/pty/unix.py +162 -0
- porterminal/pty/windows.py +131 -0
- porterminal/static/assets/app-BQiuUo6Q.css +32 -0
- porterminal/static/assets/app-YNN_jEhv.js +71 -0
- porterminal/static/icon.svg +34 -0
- porterminal/static/index.html +139 -0
- porterminal/static/manifest.json +31 -0
- porterminal/static/sw.js +66 -0
- porterminal/updater.py +257 -0
- ptn-0.1.4.dist-info/METADATA +191 -0
- ptn-0.1.4.dist-info/RECORD +73 -0
- ptn-0.1.4.dist-info/WHEEL +4 -0
- ptn-0.1.4.dist-info/entry_points.txt +2 -0
- ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
porterminal/config.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Configuration loading and validation using Pydantic."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ServerConfig(BaseModel):
|
|
12
|
+
"""Server configuration."""
|
|
13
|
+
|
|
14
|
+
host: str = "127.0.0.1"
|
|
15
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ShellConfig(BaseModel):
|
|
19
|
+
"""Shell configuration."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
id: str
|
|
23
|
+
command: str
|
|
24
|
+
args: list[str] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
@field_validator("command")
|
|
27
|
+
@classmethod
|
|
28
|
+
def validate_command_exists(cls, v: str) -> str:
|
|
29
|
+
"""Validate shell executable exists."""
|
|
30
|
+
# Check if it's a full path
|
|
31
|
+
path = Path(v)
|
|
32
|
+
if path.exists():
|
|
33
|
+
return v
|
|
34
|
+
# Check if it's in PATH
|
|
35
|
+
if shutil.which(v):
|
|
36
|
+
return v
|
|
37
|
+
raise ValueError(f"Shell executable not found: {v}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detect_available_shells() -> list[ShellConfig]:
|
|
41
|
+
"""Auto-detect available shells based on the platform."""
|
|
42
|
+
shells = []
|
|
43
|
+
|
|
44
|
+
if sys.platform == "win32":
|
|
45
|
+
# Windows shells
|
|
46
|
+
candidates = [
|
|
47
|
+
("PowerShell", "powershell", "powershell.exe", ["-NoLogo"]),
|
|
48
|
+
("PowerShell 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
|
|
49
|
+
("CMD", "cmd", "cmd.exe", []),
|
|
50
|
+
("WSL", "wsl", "wsl.exe", []),
|
|
51
|
+
("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
|
|
52
|
+
]
|
|
53
|
+
else:
|
|
54
|
+
# Unix-like shells (Linux, macOS)
|
|
55
|
+
candidates = [
|
|
56
|
+
("Bash", "bash", "bash", ["--login"]),
|
|
57
|
+
("Zsh", "zsh", "zsh", ["--login"]),
|
|
58
|
+
("Fish", "fish", "fish", []),
|
|
59
|
+
("Sh", "sh", "sh", []),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
for name, shell_id, command, args in candidates:
|
|
63
|
+
# Check if shell exists
|
|
64
|
+
shell_path = shutil.which(command)
|
|
65
|
+
if shell_path or Path(command).exists():
|
|
66
|
+
shells.append(
|
|
67
|
+
ShellConfig(
|
|
68
|
+
name=name,
|
|
69
|
+
id=shell_id,
|
|
70
|
+
command=shell_path or command,
|
|
71
|
+
args=args,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return shells
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_default_shell_id() -> str:
|
|
79
|
+
"""Get the default shell ID for the current platform."""
|
|
80
|
+
if sys.platform == "win32":
|
|
81
|
+
# Prefer PowerShell 7, then PowerShell, then CMD
|
|
82
|
+
if shutil.which("pwsh"):
|
|
83
|
+
return "pwsh"
|
|
84
|
+
if shutil.which("powershell"):
|
|
85
|
+
return "powershell"
|
|
86
|
+
return "cmd"
|
|
87
|
+
elif sys.platform == "darwin":
|
|
88
|
+
# macOS defaults to zsh
|
|
89
|
+
if shutil.which("zsh"):
|
|
90
|
+
return "zsh"
|
|
91
|
+
return "bash"
|
|
92
|
+
else:
|
|
93
|
+
# Linux - prefer bash
|
|
94
|
+
if shutil.which("bash"):
|
|
95
|
+
return "bash"
|
|
96
|
+
if shutil.which("zsh"):
|
|
97
|
+
return "zsh"
|
|
98
|
+
return "sh"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TerminalConfig(BaseModel):
|
|
102
|
+
"""Terminal configuration."""
|
|
103
|
+
|
|
104
|
+
default_shell: str = ""
|
|
105
|
+
cols: int = Field(default=120, ge=40, le=500)
|
|
106
|
+
rows: int = Field(default=30, ge=10, le=200)
|
|
107
|
+
shells: list[ShellConfig] = Field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
def get_shell(self, shell_id: str) -> ShellConfig | None:
|
|
110
|
+
"""Get shell config by ID."""
|
|
111
|
+
for shell in self.shells:
|
|
112
|
+
if shell.id == shell_id:
|
|
113
|
+
return shell
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ButtonConfig(BaseModel):
|
|
118
|
+
"""Custom button configuration."""
|
|
119
|
+
|
|
120
|
+
label: str
|
|
121
|
+
send: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class CloudflareConfig(BaseModel):
|
|
125
|
+
"""Cloudflare Access configuration."""
|
|
126
|
+
|
|
127
|
+
team_domain: str = ""
|
|
128
|
+
access_aud: str = ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Config(BaseModel):
|
|
132
|
+
"""Application configuration."""
|
|
133
|
+
|
|
134
|
+
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
135
|
+
terminal: TerminalConfig = Field(default_factory=TerminalConfig)
|
|
136
|
+
buttons: list[ButtonConfig] = Field(default_factory=list)
|
|
137
|
+
cloudflare: CloudflareConfig = Field(default_factory=CloudflareConfig)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_config(config_path: Path | str = "config.yaml") -> Config:
|
|
141
|
+
"""Load configuration from YAML file."""
|
|
142
|
+
config_path = Path(config_path)
|
|
143
|
+
|
|
144
|
+
if not config_path.exists():
|
|
145
|
+
data = {}
|
|
146
|
+
else:
|
|
147
|
+
with open(config_path, encoding="utf-8") as f:
|
|
148
|
+
data = yaml.safe_load(f) or {}
|
|
149
|
+
|
|
150
|
+
# Auto-detect shells if not specified or empty
|
|
151
|
+
terminal_data = data.get("terminal", {})
|
|
152
|
+
shells_data = terminal_data.get("shells", [])
|
|
153
|
+
|
|
154
|
+
# Filter out shells that don't exist on this system
|
|
155
|
+
valid_shells = []
|
|
156
|
+
for shell in shells_data:
|
|
157
|
+
try:
|
|
158
|
+
# Validate the shell exists
|
|
159
|
+
cmd = shell.get("command", "")
|
|
160
|
+
if shutil.which(cmd) or Path(cmd).exists():
|
|
161
|
+
valid_shells.append(shell)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# If no valid shells from config, auto-detect
|
|
166
|
+
if not valid_shells:
|
|
167
|
+
detected = detect_available_shells()
|
|
168
|
+
terminal_data["shells"] = [s.model_dump() for s in detected]
|
|
169
|
+
else:
|
|
170
|
+
terminal_data["shells"] = valid_shells
|
|
171
|
+
|
|
172
|
+
# Auto-detect default shell if not specified or invalid
|
|
173
|
+
default_shell = terminal_data.get("default_shell", "")
|
|
174
|
+
shell_ids = [s.get("id") or s.get("name", "").lower() for s in terminal_data.get("shells", [])]
|
|
175
|
+
if not default_shell or default_shell not in shell_ids:
|
|
176
|
+
terminal_data["default_shell"] = get_default_shell_id()
|
|
177
|
+
# Make sure the default shell is in the list
|
|
178
|
+
if terminal_data["default_shell"] not in shell_ids and terminal_data.get("shells"):
|
|
179
|
+
terminal_data["default_shell"] = terminal_data["shells"][0].get("id", "")
|
|
180
|
+
|
|
181
|
+
data["terminal"] = terminal_data
|
|
182
|
+
|
|
183
|
+
return Config.model_validate(data)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Global config instance (loaded on import)
|
|
187
|
+
_config: Config | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_config() -> Config:
|
|
191
|
+
"""Get the global config instance."""
|
|
192
|
+
global _config
|
|
193
|
+
if _config is None:
|
|
194
|
+
_config = load_config()
|
|
195
|
+
return _config
|
porterminal/container.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Dependency container - holds all wired dependencies."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from porterminal.application.services import (
|
|
8
|
+
ManagementService,
|
|
9
|
+
SessionService,
|
|
10
|
+
TabService,
|
|
11
|
+
TerminalService,
|
|
12
|
+
)
|
|
13
|
+
from porterminal.domain import PTYPort, ShellCommand, TerminalDimensions
|
|
14
|
+
from porterminal.domain.ports import SessionRepository, TabRepository
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from porterminal.infrastructure.registry import UserConnectionRegistry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Container:
|
|
22
|
+
"""Immutable dependency container.
|
|
23
|
+
|
|
24
|
+
All dependencies are wired at startup and cannot be modified.
|
|
25
|
+
This ensures thread-safety and predictable behavior.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Services
|
|
29
|
+
session_service: SessionService
|
|
30
|
+
tab_service: TabService
|
|
31
|
+
terminal_service: TerminalService
|
|
32
|
+
management_service: ManagementService
|
|
33
|
+
|
|
34
|
+
# Repositories
|
|
35
|
+
session_repository: SessionRepository
|
|
36
|
+
tab_repository: TabRepository
|
|
37
|
+
|
|
38
|
+
# Registry for broadcasting
|
|
39
|
+
connection_registry: "UserConnectionRegistry"
|
|
40
|
+
|
|
41
|
+
# Factories
|
|
42
|
+
pty_factory: Callable[[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort]
|
|
43
|
+
|
|
44
|
+
# Configuration
|
|
45
|
+
available_shells: list[ShellCommand]
|
|
46
|
+
default_shell_id: str
|
|
47
|
+
server_host: str
|
|
48
|
+
server_port: int
|
|
49
|
+
default_cols: int
|
|
50
|
+
default_rows: int
|
|
51
|
+
buttons: list[dict]
|
|
52
|
+
|
|
53
|
+
# Working directory
|
|
54
|
+
cwd: str | None = None
|
|
55
|
+
|
|
56
|
+
def get_shell(self, shell_id: str | None = None) -> ShellCommand | None:
|
|
57
|
+
"""Get shell by ID or default."""
|
|
58
|
+
target_id = shell_id or self.default_shell_id
|
|
59
|
+
|
|
60
|
+
for shell in self.available_shells:
|
|
61
|
+
if shell.id == target_id:
|
|
62
|
+
return shell
|
|
63
|
+
|
|
64
|
+
# Return first available if target not found
|
|
65
|
+
return self.available_shells[0] if self.available_shells else None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Pure domain layer - no infrastructure dependencies."""
|
|
2
|
+
|
|
3
|
+
# Value Objects
|
|
4
|
+
# Entities
|
|
5
|
+
from .entities import (
|
|
6
|
+
CLEAR_SCREEN_SEQUENCE,
|
|
7
|
+
MAX_SESSIONS_PER_USER,
|
|
8
|
+
MAX_TABS_PER_USER,
|
|
9
|
+
MAX_TOTAL_SESSIONS,
|
|
10
|
+
OUTPUT_BUFFER_MAX_BYTES,
|
|
11
|
+
OutputBuffer,
|
|
12
|
+
Session,
|
|
13
|
+
Tab,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Ports
|
|
17
|
+
from .ports import (
|
|
18
|
+
PTYFactory,
|
|
19
|
+
PTYPort,
|
|
20
|
+
SessionRepository,
|
|
21
|
+
TabRepository,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Services
|
|
25
|
+
from .services import (
|
|
26
|
+
Clock,
|
|
27
|
+
EnvironmentSanitizer,
|
|
28
|
+
SessionLimitChecker,
|
|
29
|
+
SessionLimitConfig,
|
|
30
|
+
SessionLimitResult,
|
|
31
|
+
TabLimitChecker,
|
|
32
|
+
TabLimitConfig,
|
|
33
|
+
TabLimitResult,
|
|
34
|
+
TokenBucketRateLimiter,
|
|
35
|
+
)
|
|
36
|
+
from .values import (
|
|
37
|
+
DEFAULT_BLOCKED_VARS,
|
|
38
|
+
DEFAULT_SAFE_VARS,
|
|
39
|
+
MAX_COLS,
|
|
40
|
+
MAX_ROWS,
|
|
41
|
+
MIN_COLS,
|
|
42
|
+
MIN_ROWS,
|
|
43
|
+
EnvironmentRules,
|
|
44
|
+
RateLimitConfig,
|
|
45
|
+
SessionId,
|
|
46
|
+
ShellCommand,
|
|
47
|
+
TabId,
|
|
48
|
+
TerminalDimensions,
|
|
49
|
+
UserId,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
# Values
|
|
54
|
+
"TerminalDimensions",
|
|
55
|
+
"MIN_COLS",
|
|
56
|
+
"MAX_COLS",
|
|
57
|
+
"MIN_ROWS",
|
|
58
|
+
"MAX_ROWS",
|
|
59
|
+
"SessionId",
|
|
60
|
+
"UserId",
|
|
61
|
+
"TabId",
|
|
62
|
+
"ShellCommand",
|
|
63
|
+
"RateLimitConfig",
|
|
64
|
+
"EnvironmentRules",
|
|
65
|
+
"DEFAULT_SAFE_VARS",
|
|
66
|
+
"DEFAULT_BLOCKED_VARS",
|
|
67
|
+
# Entities
|
|
68
|
+
"Session",
|
|
69
|
+
"MAX_SESSIONS_PER_USER",
|
|
70
|
+
"MAX_TOTAL_SESSIONS",
|
|
71
|
+
"OutputBuffer",
|
|
72
|
+
"OUTPUT_BUFFER_MAX_BYTES",
|
|
73
|
+
"CLEAR_SCREEN_SEQUENCE",
|
|
74
|
+
"Tab",
|
|
75
|
+
"MAX_TABS_PER_USER",
|
|
76
|
+
# Services
|
|
77
|
+
"TokenBucketRateLimiter",
|
|
78
|
+
"Clock",
|
|
79
|
+
"EnvironmentSanitizer",
|
|
80
|
+
"SessionLimitChecker",
|
|
81
|
+
"SessionLimitConfig",
|
|
82
|
+
"SessionLimitResult",
|
|
83
|
+
"TabLimitChecker",
|
|
84
|
+
"TabLimitConfig",
|
|
85
|
+
"TabLimitResult",
|
|
86
|
+
# Ports
|
|
87
|
+
"SessionRepository",
|
|
88
|
+
"TabRepository",
|
|
89
|
+
"PTYPort",
|
|
90
|
+
"PTYFactory",
|
|
91
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Domain entities - objects with identity and lifecycle."""
|
|
2
|
+
|
|
3
|
+
from .output_buffer import CLEAR_SCREEN_SEQUENCE, OUTPUT_BUFFER_MAX_BYTES, OutputBuffer
|
|
4
|
+
from .session import MAX_SESSIONS_PER_USER, MAX_TOTAL_SESSIONS, Session
|
|
5
|
+
from .tab import MAX_TABS_PER_USER, Tab
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Session",
|
|
9
|
+
"MAX_SESSIONS_PER_USER",
|
|
10
|
+
"MAX_TOTAL_SESSIONS",
|
|
11
|
+
"OutputBuffer",
|
|
12
|
+
"OUTPUT_BUFFER_MAX_BYTES",
|
|
13
|
+
"CLEAR_SCREEN_SEQUENCE",
|
|
14
|
+
"Tab",
|
|
15
|
+
"MAX_TABS_PER_USER",
|
|
16
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Output buffer entity for session reconnection."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
# Business rules
|
|
7
|
+
OUTPUT_BUFFER_MAX_BYTES = 1_000_000 # 1MB
|
|
8
|
+
|
|
9
|
+
# Terminal escape sequence for clear screen (ED2)
|
|
10
|
+
CLEAR_SCREEN_SEQUENCE = b"\x1b[2J"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class OutputBuffer:
|
|
15
|
+
"""Output buffer for session reconnection.
|
|
16
|
+
|
|
17
|
+
Pure domain logic for buffering terminal output.
|
|
18
|
+
No async, no WebSocket - just data management.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
max_bytes: int = OUTPUT_BUFFER_MAX_BYTES
|
|
22
|
+
_buffer: deque[bytes] = field(default_factory=deque)
|
|
23
|
+
_size: int = 0
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def size(self) -> int:
|
|
27
|
+
"""Current buffer size in bytes."""
|
|
28
|
+
return self._size
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_empty(self) -> bool:
|
|
32
|
+
"""Check if buffer is empty."""
|
|
33
|
+
return self._size == 0
|
|
34
|
+
|
|
35
|
+
def add(self, data: bytes) -> None:
|
|
36
|
+
"""Add data to the buffer.
|
|
37
|
+
|
|
38
|
+
Handles clear screen detection and size limits.
|
|
39
|
+
When clear screen is detected, only keep content AFTER the last clear sequence.
|
|
40
|
+
"""
|
|
41
|
+
# Check for clear screen sequence
|
|
42
|
+
if CLEAR_SCREEN_SEQUENCE in data:
|
|
43
|
+
# Clear old buffer
|
|
44
|
+
self.clear()
|
|
45
|
+
# Find the LAST occurrence of clear screen and only keep content after it
|
|
46
|
+
last_clear_pos = data.rfind(CLEAR_SCREEN_SEQUENCE)
|
|
47
|
+
data_after_clear = data[last_clear_pos + len(CLEAR_SCREEN_SEQUENCE) :]
|
|
48
|
+
# Only add if there's meaningful content after clear
|
|
49
|
+
if data_after_clear:
|
|
50
|
+
self._buffer.append(data_after_clear)
|
|
51
|
+
self._size += len(data_after_clear)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
self._buffer.append(data)
|
|
55
|
+
self._size += len(data)
|
|
56
|
+
|
|
57
|
+
# Trim if over limit
|
|
58
|
+
while self._size > self.max_bytes and self._buffer:
|
|
59
|
+
removed = self._buffer.popleft()
|
|
60
|
+
self._size -= len(removed)
|
|
61
|
+
|
|
62
|
+
def get_all(self) -> bytes:
|
|
63
|
+
"""Get all buffered output as single bytes object."""
|
|
64
|
+
return b"".join(self._buffer)
|
|
65
|
+
|
|
66
|
+
def clear(self) -> None:
|
|
67
|
+
"""Clear the buffer."""
|
|
68
|
+
self._buffer.clear()
|
|
69
|
+
self._size = 0
|
|
70
|
+
|
|
71
|
+
def __len__(self) -> int:
|
|
72
|
+
"""Return number of chunks in buffer."""
|
|
73
|
+
return len(self._buffer)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Session entity - pure domain representation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from ..values.session_id import SessionId
|
|
8
|
+
from ..values.terminal_dimensions import TerminalDimensions
|
|
9
|
+
from ..values.user_id import UserId
|
|
10
|
+
from .output_buffer import OutputBuffer
|
|
11
|
+
|
|
12
|
+
# Generic type for PTY handle - infrastructure provides concrete type
|
|
13
|
+
PTYHandle = TypeVar("PTYHandle")
|
|
14
|
+
|
|
15
|
+
# Business rule constants
|
|
16
|
+
MAX_SESSIONS_PER_USER = 10
|
|
17
|
+
MAX_TOTAL_SESSIONS = 100
|
|
18
|
+
RECONNECT_WINDOW_SECONDS = 0 # 0 = unlimited
|
|
19
|
+
SESSION_MAX_DURATION_SECONDS = 0 # 0 = unlimited
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Session[PTYHandle]:
|
|
24
|
+
"""Terminal session entity.
|
|
25
|
+
|
|
26
|
+
This is the domain representation of a session.
|
|
27
|
+
It does NOT hold WebSocket or any infrastructure references.
|
|
28
|
+
The PTYHandle is a generic type provided by infrastructure.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
id: SessionId
|
|
32
|
+
user_id: UserId
|
|
33
|
+
shell_id: str
|
|
34
|
+
dimensions: TerminalDimensions
|
|
35
|
+
created_at: datetime
|
|
36
|
+
last_activity: datetime
|
|
37
|
+
|
|
38
|
+
# PTY handle is generic - concrete type provided by infrastructure
|
|
39
|
+
pty_handle: PTYHandle
|
|
40
|
+
|
|
41
|
+
# Output buffer for reconnection
|
|
42
|
+
output_buffer: OutputBuffer = field(default_factory=OutputBuffer)
|
|
43
|
+
|
|
44
|
+
# Connection state (managed by application layer)
|
|
45
|
+
# Tracks number of connected clients (supports multi-client sessions)
|
|
46
|
+
connected_clients: int = 0
|
|
47
|
+
|
|
48
|
+
def add_client(self) -> int:
|
|
49
|
+
"""Add a client connection, return new count."""
|
|
50
|
+
self.connected_clients += 1
|
|
51
|
+
return self.connected_clients
|
|
52
|
+
|
|
53
|
+
def remove_client(self) -> int:
|
|
54
|
+
"""Remove a client connection, return new count."""
|
|
55
|
+
self.connected_clients = max(0, self.connected_clients - 1)
|
|
56
|
+
return self.connected_clients
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_connected(self) -> bool:
|
|
60
|
+
"""Check if any clients connected."""
|
|
61
|
+
return self.connected_clients > 0
|
|
62
|
+
|
|
63
|
+
def touch(self, now: datetime) -> None:
|
|
64
|
+
"""Update last activity timestamp."""
|
|
65
|
+
self.last_activity = now
|
|
66
|
+
|
|
67
|
+
def update_dimensions(self, dimensions: TerminalDimensions) -> None:
|
|
68
|
+
"""Update terminal dimensions."""
|
|
69
|
+
self.dimensions = dimensions
|
|
70
|
+
|
|
71
|
+
def add_output(self, data: bytes) -> None:
|
|
72
|
+
"""Add output to buffer for reconnection."""
|
|
73
|
+
self.output_buffer.add(data)
|
|
74
|
+
|
|
75
|
+
def get_buffered_output(self) -> bytes:
|
|
76
|
+
"""Get all buffered output."""
|
|
77
|
+
return self.output_buffer.get_all()
|
|
78
|
+
|
|
79
|
+
def clear_buffer(self) -> None:
|
|
80
|
+
"""Clear the output buffer."""
|
|
81
|
+
self.output_buffer.clear()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def session_id(self) -> str:
|
|
85
|
+
"""Get session ID as string (compatibility helper)."""
|
|
86
|
+
return str(self.id)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Tab entity - lightweight reference to a session."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from ..values.session_id import SessionId
|
|
7
|
+
from ..values.tab_id import TabId
|
|
8
|
+
from ..values.user_id import UserId
|
|
9
|
+
|
|
10
|
+
# Business rule constants
|
|
11
|
+
MAX_TABS_PER_USER = 20
|
|
12
|
+
TAB_NAME_MIN_LENGTH = 1
|
|
13
|
+
TAB_NAME_MAX_LENGTH = 50
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Tab:
|
|
18
|
+
"""Terminal tab entity.
|
|
19
|
+
|
|
20
|
+
Lightweight entity that references a session.
|
|
21
|
+
Does NOT hold PTY or any infrastructure references.
|
|
22
|
+
|
|
23
|
+
Invariants:
|
|
24
|
+
- Tab always references exactly one Session
|
|
25
|
+
- Tab is owned by exactly one User (same user who owns the Session)
|
|
26
|
+
- Tab name is 1-50 characters, non-empty
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
id: TabId
|
|
30
|
+
user_id: UserId
|
|
31
|
+
session_id: SessionId
|
|
32
|
+
shell_id: str
|
|
33
|
+
name: str
|
|
34
|
+
created_at: datetime
|
|
35
|
+
last_accessed: datetime
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not (TAB_NAME_MIN_LENGTH <= len(self.name) <= TAB_NAME_MAX_LENGTH):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
|
|
41
|
+
f"characters, got {len(self.name)}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def tab_id(self) -> str:
|
|
46
|
+
"""String representation of tab ID."""
|
|
47
|
+
return str(self.id)
|
|
48
|
+
|
|
49
|
+
def touch(self, now: datetime) -> None:
|
|
50
|
+
"""Update last accessed timestamp."""
|
|
51
|
+
self.last_accessed = now
|
|
52
|
+
|
|
53
|
+
def rename(self, new_name: str) -> None:
|
|
54
|
+
"""Rename the tab with validation."""
|
|
55
|
+
if not (TAB_NAME_MIN_LENGTH <= len(new_name) <= TAB_NAME_MAX_LENGTH):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
|
|
58
|
+
f"characters, got {len(new_name)}"
|
|
59
|
+
)
|
|
60
|
+
self.name = new_name
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
"""Convert to dictionary for JSON serialization."""
|
|
64
|
+
return {
|
|
65
|
+
"id": str(self.id),
|
|
66
|
+
"session_id": str(self.session_id),
|
|
67
|
+
"shell_id": self.shell_id,
|
|
68
|
+
"name": self.name,
|
|
69
|
+
"created_at": self.created_at.isoformat(),
|
|
70
|
+
"last_accessed": self.last_accessed.isoformat(),
|
|
71
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Domain ports - interfaces for infrastructure to implement."""
|
|
2
|
+
|
|
3
|
+
from .pty_port import PTYFactory, PTYPort
|
|
4
|
+
from .session_repository import SessionRepository
|
|
5
|
+
from .tab_repository import TabRepository
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SessionRepository",
|
|
9
|
+
"TabRepository",
|
|
10
|
+
"PTYPort",
|
|
11
|
+
"PTYFactory",
|
|
12
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""PTY port - interface for PTY operations."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from ..values.shell_command import ShellCommand
|
|
6
|
+
from ..values.terminal_dimensions import TerminalDimensions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PTYPort(ABC):
|
|
10
|
+
"""Abstract interface for PTY operations.
|
|
11
|
+
|
|
12
|
+
Infrastructure provides platform-specific implementations.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def spawn(self) -> None:
|
|
17
|
+
"""Spawn the PTY process."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def read(self, size: int = 4096) -> bytes:
|
|
22
|
+
"""Read from PTY (non-blocking).
|
|
23
|
+
|
|
24
|
+
Returns empty bytes if no data available.
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def write(self, data: bytes) -> None:
|
|
30
|
+
"""Write to PTY."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def resize(self, dimensions: TerminalDimensions) -> None:
|
|
35
|
+
"""Resize PTY to new dimensions."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def is_alive(self) -> bool:
|
|
40
|
+
"""Check if PTY process is still running."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
"""Close PTY and cleanup resources."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def dimensions(self) -> TerminalDimensions:
|
|
51
|
+
"""Get current terminal dimensions."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PTYFactory(ABC):
|
|
56
|
+
"""Abstract interface for PTY creation.
|
|
57
|
+
|
|
58
|
+
Infrastructure provides platform-specific factory.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def create(
|
|
63
|
+
self,
|
|
64
|
+
shell: ShellCommand,
|
|
65
|
+
dimensions: TerminalDimensions,
|
|
66
|
+
environment: dict[str, str],
|
|
67
|
+
working_directory: str | None = None,
|
|
68
|
+
) -> PTYPort:
|
|
69
|
+
"""Create and spawn a new PTY.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
shell: Shell command to run.
|
|
73
|
+
dimensions: Initial terminal dimensions.
|
|
74
|
+
environment: Sanitized environment variables.
|
|
75
|
+
working_directory: Optional working directory.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
PTY port implementation.
|
|
79
|
+
"""
|
|
80
|
+
...
|