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.
Files changed (73) hide show
  1. porterminal/__init__.py +288 -0
  2. porterminal/__main__.py +8 -0
  3. porterminal/app.py +381 -0
  4. porterminal/application/__init__.py +1 -0
  5. porterminal/application/ports/__init__.py +7 -0
  6. porterminal/application/ports/connection_port.py +34 -0
  7. porterminal/application/services/__init__.py +13 -0
  8. porterminal/application/services/management_service.py +279 -0
  9. porterminal/application/services/session_service.py +249 -0
  10. porterminal/application/services/tab_service.py +286 -0
  11. porterminal/application/services/terminal_service.py +426 -0
  12. porterminal/asgi.py +38 -0
  13. porterminal/cli/__init__.py +19 -0
  14. porterminal/cli/args.py +91 -0
  15. porterminal/cli/display.py +157 -0
  16. porterminal/composition.py +208 -0
  17. porterminal/config.py +195 -0
  18. porterminal/container.py +65 -0
  19. porterminal/domain/__init__.py +91 -0
  20. porterminal/domain/entities/__init__.py +16 -0
  21. porterminal/domain/entities/output_buffer.py +73 -0
  22. porterminal/domain/entities/session.py +86 -0
  23. porterminal/domain/entities/tab.py +71 -0
  24. porterminal/domain/ports/__init__.py +12 -0
  25. porterminal/domain/ports/pty_port.py +80 -0
  26. porterminal/domain/ports/session_repository.py +58 -0
  27. porterminal/domain/ports/tab_repository.py +75 -0
  28. porterminal/domain/services/__init__.py +18 -0
  29. porterminal/domain/services/environment_sanitizer.py +61 -0
  30. porterminal/domain/services/rate_limiter.py +63 -0
  31. porterminal/domain/services/session_limits.py +104 -0
  32. porterminal/domain/services/tab_limits.py +54 -0
  33. porterminal/domain/values/__init__.py +25 -0
  34. porterminal/domain/values/environment_rules.py +156 -0
  35. porterminal/domain/values/rate_limit_config.py +21 -0
  36. porterminal/domain/values/session_id.py +20 -0
  37. porterminal/domain/values/shell_command.py +37 -0
  38. porterminal/domain/values/tab_id.py +24 -0
  39. porterminal/domain/values/terminal_dimensions.py +45 -0
  40. porterminal/domain/values/user_id.py +25 -0
  41. porterminal/infrastructure/__init__.py +20 -0
  42. porterminal/infrastructure/cloudflared.py +295 -0
  43. porterminal/infrastructure/config/__init__.py +9 -0
  44. porterminal/infrastructure/config/shell_detector.py +84 -0
  45. porterminal/infrastructure/config/yaml_loader.py +34 -0
  46. porterminal/infrastructure/network.py +43 -0
  47. porterminal/infrastructure/registry/__init__.py +5 -0
  48. porterminal/infrastructure/registry/user_connection_registry.py +104 -0
  49. porterminal/infrastructure/repositories/__init__.py +9 -0
  50. porterminal/infrastructure/repositories/in_memory_session.py +70 -0
  51. porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
  52. porterminal/infrastructure/server.py +161 -0
  53. porterminal/infrastructure/web/__init__.py +7 -0
  54. porterminal/infrastructure/web/websocket_adapter.py +78 -0
  55. porterminal/logging_setup.py +48 -0
  56. porterminal/pty/__init__.py +46 -0
  57. porterminal/pty/env.py +97 -0
  58. porterminal/pty/manager.py +163 -0
  59. porterminal/pty/protocol.py +84 -0
  60. porterminal/pty/unix.py +162 -0
  61. porterminal/pty/windows.py +131 -0
  62. porterminal/static/assets/app-BQiuUo6Q.css +32 -0
  63. porterminal/static/assets/app-YNN_jEhv.js +71 -0
  64. porterminal/static/icon.svg +34 -0
  65. porterminal/static/index.html +139 -0
  66. porterminal/static/manifest.json +31 -0
  67. porterminal/static/sw.js +66 -0
  68. porterminal/updater.py +257 -0
  69. ptn-0.1.4.dist-info/METADATA +191 -0
  70. ptn-0.1.4.dist-info/RECORD +73 -0
  71. ptn-0.1.4.dist-info/WHEEL +4 -0
  72. ptn-0.1.4.dist-info/entry_points.txt +2 -0
  73. 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
@@ -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
+ ...