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
@@ -0,0 +1,34 @@
1
+ """Connection port - interface for terminal network connections."""
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class ConnectionPort(Protocol):
7
+ """Protocol for terminal I/O over network connection.
8
+
9
+ Presentation layer (e.g., WebSocket adapter) implements this.
10
+ """
11
+
12
+ async def send_output(self, data: bytes) -> None:
13
+ """Send terminal output to client."""
14
+ ...
15
+
16
+ async def send_message(self, message: dict) -> None:
17
+ """Send JSON control message to client."""
18
+ ...
19
+
20
+ async def receive(self) -> dict | bytes:
21
+ """Receive message from client (binary or JSON).
22
+
23
+ Returns:
24
+ bytes for terminal input, dict for control messages.
25
+ """
26
+ ...
27
+
28
+ async def close(self, code: int = 1000, reason: str = "") -> None:
29
+ """Close the connection."""
30
+ ...
31
+
32
+ def is_connected(self) -> bool:
33
+ """Check if connection is still open."""
34
+ ...
@@ -0,0 +1,13 @@
1
+ """Application services - use case implementations."""
2
+
3
+ from .management_service import ManagementService
4
+ from .session_service import SessionService
5
+ from .tab_service import TabService
6
+ from .terminal_service import TerminalService
7
+
8
+ __all__ = [
9
+ "ManagementService",
10
+ "SessionService",
11
+ "TabService",
12
+ "TerminalService",
13
+ ]
@@ -0,0 +1,279 @@
1
+ """Management service - handles tab management requests via WebSocket."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+
6
+ from porterminal.application.ports import ConnectionPort
7
+ from porterminal.application.services.session_service import SessionService
8
+ from porterminal.application.services.tab_service import TabService
9
+ from porterminal.domain import (
10
+ ShellCommand,
11
+ TerminalDimensions,
12
+ UserId,
13
+ )
14
+ from porterminal.infrastructure.registry import UserConnectionRegistry
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ManagementService:
20
+ """Service for handling management WebSocket messages.
21
+
22
+ Handles tab creation, closure, and rename requests.
23
+ Broadcasts state updates to other connections.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ session_service: SessionService,
29
+ tab_service: TabService,
30
+ connection_registry: UserConnectionRegistry,
31
+ shell_provider: Callable[[str | None], ShellCommand | None],
32
+ default_dimensions: TerminalDimensions,
33
+ ) -> None:
34
+ self._session_service = session_service
35
+ self._tab_service = tab_service
36
+ self._registry = connection_registry
37
+ self._get_shell = shell_provider
38
+ self._default_dims = default_dimensions
39
+
40
+ async def handle_message(
41
+ self,
42
+ user_id: UserId,
43
+ connection: ConnectionPort,
44
+ message: dict,
45
+ ) -> None:
46
+ """Handle an incoming management message.
47
+
48
+ Args:
49
+ user_id: User sending the message.
50
+ connection: Connection that received the message.
51
+ message: Message dict with 'type' and request data.
52
+ """
53
+ msg_type = message.get("type")
54
+
55
+ if msg_type == "create_tab":
56
+ await self._handle_create_tab(user_id, connection, message)
57
+ elif msg_type == "close_tab":
58
+ await self._handle_close_tab(user_id, connection, message)
59
+ elif msg_type == "rename_tab":
60
+ await self._handle_rename_tab(user_id, connection, message)
61
+ elif msg_type == "ping":
62
+ await connection.send_message({"type": "pong"})
63
+ else:
64
+ logger.warning("Unknown management message type: %s", msg_type)
65
+
66
+ async def _handle_create_tab(
67
+ self,
68
+ user_id: UserId,
69
+ connection: ConnectionPort,
70
+ message: dict,
71
+ ) -> None:
72
+ """Handle tab creation request."""
73
+ request_id = message.get("request_id", "")
74
+ shell_id = message.get("shell_id")
75
+
76
+ try:
77
+ # Get shell
78
+ shell = self._get_shell(shell_id)
79
+ if not shell:
80
+ await connection.send_message(
81
+ {
82
+ "type": "create_tab_response",
83
+ "request_id": request_id,
84
+ "success": False,
85
+ "error": "Invalid shell",
86
+ }
87
+ )
88
+ return
89
+
90
+ # Create session
91
+ session = await self._session_service.create_session(
92
+ user_id=user_id,
93
+ shell=shell,
94
+ dimensions=self._default_dims,
95
+ )
96
+ session.add_client()
97
+
98
+ # Create tab
99
+ tab = self._tab_service.create_tab(
100
+ user_id=user_id,
101
+ session_id=session.id,
102
+ shell_id=shell.id,
103
+ )
104
+
105
+ logger.info(
106
+ "Management created tab user_id=%s tab_id=%s session_id=%s",
107
+ user_id,
108
+ tab.tab_id,
109
+ session.session_id,
110
+ )
111
+
112
+ # Send response to requester
113
+ await connection.send_message(
114
+ {
115
+ "type": "create_tab_response",
116
+ "request_id": request_id,
117
+ "success": True,
118
+ "tab": tab.to_dict(),
119
+ }
120
+ )
121
+
122
+ # Broadcast update to OTHER connections
123
+ await self._registry.broadcast(
124
+ user_id,
125
+ self._tab_service.build_tab_state_update("add", tab),
126
+ exclude=connection,
127
+ )
128
+
129
+ except ValueError as e:
130
+ logger.warning("Tab creation failed: %s", e)
131
+ await connection.send_message(
132
+ {
133
+ "type": "create_tab_response",
134
+ "request_id": request_id,
135
+ "success": False,
136
+ "error": str(e),
137
+ }
138
+ )
139
+
140
+ async def _handle_close_tab(
141
+ self,
142
+ user_id: UserId,
143
+ connection: ConnectionPort,
144
+ message: dict,
145
+ ) -> None:
146
+ """Handle tab close request."""
147
+ request_id = message.get("request_id", "")
148
+ tab_id = message.get("tab_id")
149
+
150
+ if not tab_id:
151
+ await connection.send_message(
152
+ {
153
+ "type": "close_tab_response",
154
+ "request_id": request_id,
155
+ "success": False,
156
+ "error": "Missing tab_id",
157
+ }
158
+ )
159
+ return
160
+
161
+ # Get tab and session info before closing
162
+ tab = self._tab_service.get_tab(tab_id)
163
+ if not tab:
164
+ await connection.send_message(
165
+ {
166
+ "type": "close_tab_response",
167
+ "request_id": request_id,
168
+ "success": False,
169
+ "error": "Tab not found",
170
+ }
171
+ )
172
+ return
173
+
174
+ session_id = tab.session_id
175
+
176
+ # Close the tab
177
+ closed_tab = self._tab_service.close_tab(tab_id, user_id)
178
+ if not closed_tab:
179
+ await connection.send_message(
180
+ {
181
+ "type": "close_tab_response",
182
+ "request_id": request_id,
183
+ "success": False,
184
+ "error": "Failed to close tab",
185
+ }
186
+ )
187
+ return
188
+
189
+ # Destroy the session (which will stop the PTY)
190
+ await self._session_service.destroy_session(session_id)
191
+
192
+ logger.info(
193
+ "Management closed tab user_id=%s tab_id=%s",
194
+ user_id,
195
+ tab_id,
196
+ )
197
+
198
+ # Send response to requester
199
+ await connection.send_message(
200
+ {
201
+ "type": "close_tab_response",
202
+ "request_id": request_id,
203
+ "success": True,
204
+ }
205
+ )
206
+
207
+ # Broadcast update to OTHER connections
208
+ await self._registry.broadcast(
209
+ user_id,
210
+ self._tab_service.build_tab_state_update("remove", closed_tab, reason="user"),
211
+ exclude=connection,
212
+ )
213
+
214
+ async def _handle_rename_tab(
215
+ self,
216
+ user_id: UserId,
217
+ connection: ConnectionPort,
218
+ message: dict,
219
+ ) -> None:
220
+ """Handle tab rename request."""
221
+ request_id = message.get("request_id", "")
222
+ tab_id = message.get("tab_id")
223
+ new_name = message.get("name")
224
+
225
+ if not tab_id or not new_name:
226
+ await connection.send_message(
227
+ {
228
+ "type": "rename_tab_response",
229
+ "request_id": request_id,
230
+ "success": False,
231
+ "error": "Missing tab_id or name",
232
+ }
233
+ )
234
+ return
235
+
236
+ # Rename the tab
237
+ tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
238
+ if not tab:
239
+ await connection.send_message(
240
+ {
241
+ "type": "rename_tab_response",
242
+ "request_id": request_id,
243
+ "success": False,
244
+ "error": "Failed to rename tab",
245
+ }
246
+ )
247
+ return
248
+
249
+ logger.info(
250
+ "Management renamed tab user_id=%s tab_id=%s new_name=%s",
251
+ user_id,
252
+ tab_id,
253
+ new_name,
254
+ )
255
+
256
+ # Send response to requester
257
+ await connection.send_message(
258
+ {
259
+ "type": "rename_tab_response",
260
+ "request_id": request_id,
261
+ "success": True,
262
+ "tab": tab.to_dict(),
263
+ }
264
+ )
265
+
266
+ # Broadcast update to OTHER connections
267
+ await self._registry.broadcast(
268
+ user_id,
269
+ self._tab_service.build_tab_state_update("update", tab),
270
+ exclude=connection,
271
+ )
272
+
273
+ def build_state_sync(self, user_id: UserId) -> dict:
274
+ """Build initial state sync message for a user.
275
+
276
+ Returns:
277
+ Message dict with all tabs for the user.
278
+ """
279
+ return self._tab_service.build_tab_state_sync(user_id)
@@ -0,0 +1,249 @@
1
+ """Session service - session lifecycle management."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import uuid
7
+ from collections.abc import Awaitable, Callable
8
+ from datetime import UTC, datetime
9
+
10
+ from porterminal.domain import (
11
+ EnvironmentRules,
12
+ EnvironmentSanitizer,
13
+ PTYPort,
14
+ Session,
15
+ SessionId,
16
+ SessionLimitChecker,
17
+ ShellCommand,
18
+ TerminalDimensions,
19
+ UserId,
20
+ )
21
+ from porterminal.domain.ports import SessionRepository
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class SessionService:
27
+ """Service for managing terminal sessions.
28
+
29
+ Handles session creation, reconnection, destruction, and cleanup.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ repository: SessionRepository[PTYPort],
35
+ pty_factory: Callable[
36
+ [ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
37
+ ],
38
+ limit_checker: SessionLimitChecker | None = None,
39
+ environment_sanitizer: EnvironmentSanitizer | None = None,
40
+ working_directory: str | None = None,
41
+ ) -> None:
42
+ self._repository = repository
43
+ self._pty_factory = pty_factory
44
+ self._limit_checker = limit_checker or SessionLimitChecker()
45
+ self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
46
+ self._cwd = working_directory
47
+ self._running = False
48
+ self._cleanup_task: asyncio.Task | None = None
49
+ self._on_session_destroyed: Callable[[SessionId, UserId], Awaitable[None]] | None = None
50
+
51
+ def set_on_session_destroyed(
52
+ self, callback: Callable[[SessionId, UserId], Awaitable[None]]
53
+ ) -> None:
54
+ """Set async callback to be invoked when a session is destroyed.
55
+
56
+ Used for cascading cleanup (e.g., closing associated tabs and broadcasting).
57
+ """
58
+ self._on_session_destroyed = callback
59
+
60
+ async def start(self) -> None:
61
+ """Start the session service (cleanup loop)."""
62
+ self._running = True
63
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
64
+ logger.info("Session service started")
65
+
66
+ async def stop(self) -> None:
67
+ """Stop the session service and cleanup all sessions."""
68
+ self._running = False
69
+
70
+ if self._cleanup_task:
71
+ self._cleanup_task.cancel()
72
+ try:
73
+ await self._cleanup_task
74
+ except asyncio.CancelledError:
75
+ pass
76
+
77
+ # Close all sessions
78
+ for session in self._repository.all_sessions():
79
+ await self.destroy_session(session.id)
80
+
81
+ logger.info("Session service stopped")
82
+
83
+ async def create_session(
84
+ self,
85
+ user_id: UserId,
86
+ shell: ShellCommand,
87
+ dimensions: TerminalDimensions,
88
+ ) -> Session[PTYPort]:
89
+ """Create a new terminal session.
90
+
91
+ Args:
92
+ user_id: User requesting the session.
93
+ shell: Shell command to run.
94
+ dimensions: Initial terminal dimensions.
95
+
96
+ Returns:
97
+ Created session.
98
+
99
+ Raises:
100
+ ValueError: If session limits exceeded.
101
+ """
102
+ # Check limits
103
+ limit_result = self._limit_checker.can_create_session(
104
+ user_id,
105
+ self._repository.count_for_user(user_id),
106
+ self._repository.count(),
107
+ )
108
+ if not limit_result.allowed:
109
+ raise ValueError(limit_result.reason)
110
+
111
+ # Create PTY with sanitized environment
112
+ env = self._sanitizer.sanitize(dict(os.environ))
113
+ pty = self._pty_factory(shell, dimensions, env, self._cwd)
114
+
115
+ # Create session (starts with 0 clients, caller adds via add_client())
116
+ now = datetime.now(UTC)
117
+ session = Session(
118
+ id=SessionId(str(uuid.uuid4())),
119
+ user_id=user_id,
120
+ shell_id=shell.id,
121
+ dimensions=dimensions,
122
+ created_at=now,
123
+ last_activity=now,
124
+ pty_handle=pty,
125
+ )
126
+
127
+ self._repository.add(session)
128
+ logger.info(
129
+ "Session created session_id=%s user_id=%s shell=%s",
130
+ session.id,
131
+ user_id,
132
+ shell.id,
133
+ )
134
+
135
+ return session
136
+
137
+ async def reconnect_session(
138
+ self,
139
+ session_id: SessionId,
140
+ user_id: UserId,
141
+ ) -> Session[PTYPort] | None:
142
+ """Reconnect to an existing session.
143
+
144
+ Args:
145
+ session_id: Session to reconnect to.
146
+ user_id: User requesting reconnection.
147
+
148
+ Returns:
149
+ Session if reconnection successful, None otherwise.
150
+ """
151
+ session = self._repository.get(session_id)
152
+ if not session:
153
+ logger.warning("Reconnect failed: session not found session_id=%s", session_id)
154
+ return None
155
+
156
+ # Check ownership
157
+ limit_result = self._limit_checker.can_reconnect(session, user_id)
158
+ if not limit_result.allowed:
159
+ logger.warning(
160
+ "Reconnect denied: %s session_id=%s user_id=%s",
161
+ limit_result.reason,
162
+ session_id,
163
+ user_id,
164
+ )
165
+ return None
166
+
167
+ # Check if PTY is still alive
168
+ if not session.pty_handle.is_alive():
169
+ logger.warning("Reconnect failed: PTY dead session_id=%s", session_id)
170
+ await self.destroy_session(session_id)
171
+ return None
172
+
173
+ session.add_client()
174
+ session.touch(datetime.now(UTC))
175
+ logger.info("Session reconnected session_id=%s user_id=%s", session_id, user_id)
176
+
177
+ return session
178
+
179
+ def disconnect_session(self, session_id: SessionId) -> None:
180
+ """Remove a client from session (but keep alive for reconnection)."""
181
+ session = self._repository.get(session_id)
182
+ if session:
183
+ remaining = session.remove_client()
184
+ session.touch(datetime.now(UTC))
185
+ logger.info(
186
+ "Client disconnected session_id=%s remaining_clients=%d",
187
+ session_id,
188
+ remaining,
189
+ )
190
+
191
+ async def destroy_session(self, session_id: SessionId) -> None:
192
+ """Destroy a session completely."""
193
+ session = self._repository.remove(session_id)
194
+ if session:
195
+ # Invoke cascade callback (e.g., to close associated tabs)
196
+ if self._on_session_destroyed:
197
+ try:
198
+ await self._on_session_destroyed(session_id, session.user_id)
199
+ except Exception as e:
200
+ logger.warning(
201
+ "Error in session destroyed callback session_id=%s: %s",
202
+ session_id,
203
+ e,
204
+ )
205
+
206
+ try:
207
+ session.pty_handle.close()
208
+ except Exception as e:
209
+ logger.warning("Error closing PTY session_id=%s: %s", session_id, e)
210
+
211
+ session.clear_buffer()
212
+ logger.info("Session destroyed session_id=%s", session_id)
213
+
214
+ def get_session(self, session_id: str) -> Session[PTYPort] | None:
215
+ """Get session by ID string."""
216
+ return self._repository.get_by_id_str(session_id)
217
+
218
+ def get_user_sessions(self, user_id: UserId) -> list[Session[PTYPort]]:
219
+ """Get all sessions for a user."""
220
+ return self._repository.get_by_user(user_id)
221
+
222
+ def session_count(self) -> int:
223
+ """Get total session count."""
224
+ return self._repository.count()
225
+
226
+ async def _cleanup_loop(self) -> None:
227
+ """Background task to cleanup stale sessions."""
228
+ while self._running:
229
+ await asyncio.sleep(60)
230
+ await self._cleanup_stale_sessions()
231
+
232
+ async def _cleanup_stale_sessions(self) -> None:
233
+ """Check and cleanup stale sessions."""
234
+ now = datetime.now(UTC)
235
+
236
+ for session in self._repository.all_sessions():
237
+ should_cleanup, reason = self._limit_checker.should_cleanup_session(
238
+ session,
239
+ now,
240
+ session.pty_handle.is_alive(),
241
+ )
242
+
243
+ if should_cleanup:
244
+ logger.info(
245
+ "Cleaning up session session_id=%s reason=%s",
246
+ session.id,
247
+ reason,
248
+ )
249
+ await self.destroy_session(session.id)