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/asgi.py ADDED
@@ -0,0 +1,38 @@
1
+ """ASGI application factory for uvicorn.
2
+
3
+ This module provides a factory function that uvicorn can use to create
4
+ the FastAPI application with proper dependency injection.
5
+
6
+ Usage:
7
+ uvicorn porterminal.asgi:create_app_from_env --factory
8
+ """
9
+
10
+ import os
11
+
12
+ from porterminal.composition import create_container
13
+
14
+
15
+ def create_app_from_env():
16
+ """Create FastAPI app from environment variables.
17
+
18
+ This is called by uvicorn when using the --factory flag.
19
+ Environment variables:
20
+ PORTERMINAL_CONFIG_PATH: Path to config file (default: config.yaml)
21
+ PORTERMINAL_CWD: Working directory for PTY sessions
22
+ """
23
+ from porterminal.app import create_app
24
+
25
+ config_path = os.environ.get("PORTERMINAL_CONFIG_PATH", "config.yaml")
26
+ cwd = os.environ.get("PORTERMINAL_CWD")
27
+
28
+ container = create_container(config_path=config_path, cwd=cwd)
29
+
30
+ # Create app with container
31
+ # Note: The current app.py doesn't accept container yet,
32
+ # so we just create the default app and store container in state
33
+ app = create_app()
34
+
35
+ # Store container in app state for handlers to access
36
+ app.state.container = container
37
+
38
+ return app
@@ -0,0 +1,19 @@
1
+ """CLI utilities for Porterminal."""
2
+
3
+ from .args import parse_args
4
+ from .display import (
5
+ LOGO,
6
+ TAGLINE,
7
+ display_startup_screen,
8
+ get_caution,
9
+ get_qr_code,
10
+ )
11
+
12
+ __all__ = [
13
+ "parse_args",
14
+ "display_startup_screen",
15
+ "get_qr_code",
16
+ "get_caution",
17
+ "LOGO",
18
+ "TAGLINE",
19
+ ]
@@ -0,0 +1,91 @@
1
+ """Command line argument parsing."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from porterminal import __version__
7
+
8
+
9
+ def parse_args() -> argparse.Namespace:
10
+ """Parse command line arguments.
11
+
12
+ Returns:
13
+ Parsed arguments namespace with:
14
+ - path: Starting directory for the shell (optional)
15
+ - no_tunnel: Whether to skip Cloudflare tunnel
16
+ - verbose: Whether to show detailed logs
17
+ - update: Whether to update to latest version
18
+ - check_update: Whether to check for updates
19
+ """
20
+ parser = argparse.ArgumentParser(
21
+ description="Porterminal - Web terminal via Cloudflare Tunnel",
22
+ formatter_class=argparse.RawDescriptionHelpFormatter,
23
+ )
24
+ parser.add_argument(
25
+ "-V",
26
+ "--version",
27
+ action="version",
28
+ version=f"%(prog)s {__version__}",
29
+ )
30
+ parser.add_argument(
31
+ "path",
32
+ nargs="?",
33
+ default=None,
34
+ help="Starting directory for the shell (default: current directory)",
35
+ )
36
+ parser.add_argument(
37
+ "--no-tunnel",
38
+ action="store_true",
39
+ help="Start server only, without Cloudflare tunnel",
40
+ )
41
+ parser.add_argument(
42
+ "-v",
43
+ "--verbose",
44
+ action="store_true",
45
+ help="Show detailed startup logs",
46
+ )
47
+ parser.add_argument(
48
+ "-U",
49
+ "--update",
50
+ action="store_true",
51
+ help="Update to the latest version",
52
+ )
53
+ parser.add_argument(
54
+ "--check-update",
55
+ action="store_true",
56
+ help="Check if a newer version is available",
57
+ )
58
+ parser.add_argument(
59
+ "-b",
60
+ "--background",
61
+ action="store_true",
62
+ help="Run in background and return immediately",
63
+ )
64
+ # Internal argument for background mode communication
65
+ parser.add_argument(
66
+ "--_url-file",
67
+ dest="url_file",
68
+ help=argparse.SUPPRESS,
69
+ )
70
+
71
+ args = parser.parse_args()
72
+
73
+ # Handle update commands early (before main app starts)
74
+ if args.check_update:
75
+ from porterminal.updater import check_for_updates, get_upgrade_command
76
+
77
+ has_update, latest = check_for_updates(use_cache=False)
78
+ if has_update:
79
+ print(f"Update available: {__version__} → {latest}")
80
+ print(f"Run: {get_upgrade_command()}")
81
+ else:
82
+ print(f"Already at latest version ({__version__})")
83
+ sys.exit(0)
84
+
85
+ if args.update:
86
+ from porterminal.updater import update_package
87
+
88
+ success = update_package()
89
+ sys.exit(0 if success else 1)
90
+
91
+ return args
@@ -0,0 +1,157 @@
1
+ """Display utilities for the startup screen."""
2
+
3
+ import io
4
+ import random
5
+ import sys
6
+
7
+ import qrcode
8
+ from rich.align import Align
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from porterminal import __version__
13
+
14
+ # Force UTF-8 for Windows console
15
+ if sys.platform == "win32":
16
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
17
+
18
+ console = Console(force_terminal=True)
19
+
20
+ LOGO = r"""
21
+ ██████ ██████ ██████ ██████ ██████ ██████ ██ ██ ██ ██ ██ ████ ██
22
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ███ ██ ███ ██ ██ ██ ██
23
+ ██████ ██ ██ ██████ ██ ████ ██████ ██ █ ██ ██ ██ █ ██ ██████ ██
24
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ██ ██
25
+ ██ ██████ ██ ██ ██ ██████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██████
26
+ """
27
+
28
+ TAGLINE = r"""
29
+ █ █ █ █▄▄ █▀▀ █▀▀ █▀█ █▀▄ █▀▀ █▀▀ █▀█ █▀█ █▀▄▀█ ▄▀█ █▄ █ █▄█ █ █ █ █▀▀ █▀█ █▀▀
30
+ ▀▄▀ █ █▄█ ██▄ █▄▄ █▄█ █▄▀ ██▄ █▀ █▀▄ █▄█ █ ▀ █ █▀█ █ ▀█ █ ▀▄▀▄▀ ██▄ █▀▄ ██▄
31
+ """.strip()
32
+
33
+ CAUTION_DEFAULT = "CAUTION: DO NOT VIBE CODE WHILE DRIVING"
34
+
35
+ CAUTION_EASTER_EGGS = [
36
+ "VIBE CODING ON THE TOILET IS FINE THO",
37
+ "DEPLOYING TO PROD FROM BED IS A LIFESTYLE",
38
+ "TOUCHING GRASS WHILE TOUCHING CODE",
39
+ "MOM SAID IT'S MY TURN ON THE SERVER",
40
+ "IT WORKS ON MY PHONE",
41
+ "404: WORK-LIFE BALANCE NOT FOUND",
42
+ "git commit -m 'fixed from toilet'",
43
+ "*HACKER VOICE* I'M IN (the bathroom)",
44
+ "THEY SAID REMOTE WORK. I DELIVERED.",
45
+ "TECHNICALLY THIS IS A STANDING DESK",
46
+ "SUDO MAKE ME A SANDWICH (I'M IN LINE)",
47
+ "MY OTHER TERMINAL IS A YACHT",
48
+ "REAL PROGRAMMERS CODE IN TRAFFIC JAMS",
49
+ "MERGE CONFLICTS RESOLVED AT 30,000 FT",
50
+ "PUSHED TO MAIN FROM THE CHECKOUT LINE",
51
+ ]
52
+
53
+
54
+ def get_caution() -> str:
55
+ """Get caution message with 1% chance of easter egg."""
56
+ if random.random() < 0.01:
57
+ return random.choice(CAUTION_EASTER_EGGS)
58
+ return CAUTION_DEFAULT
59
+
60
+
61
+ def get_qr_code(url: str) -> str:
62
+ """Generate QR code as ASCII string.
63
+
64
+ Args:
65
+ url: URL to encode in the QR code.
66
+
67
+ Returns:
68
+ ASCII art representation of the QR code.
69
+ """
70
+ qr = qrcode.QRCode(
71
+ version=1,
72
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
73
+ box_size=1,
74
+ border=1,
75
+ )
76
+ qr.add_data(url)
77
+ qr.make(fit=True)
78
+
79
+ buffer = io.StringIO()
80
+ qr.print_ascii(out=buffer, invert=True)
81
+ # Remove only truly empty lines (not whitespace-only lines which are part of QR)
82
+ lines = [line for line in buffer.getvalue().split("\n") if line]
83
+ # Strip trailing empty lines
84
+ while lines and not lines[-1]:
85
+ lines.pop()
86
+ return "\n".join(lines)
87
+
88
+
89
+ def display_startup_screen(
90
+ url: str,
91
+ is_tunnel: bool = True,
92
+ cwd: str | None = None,
93
+ ) -> None:
94
+ """Display the final startup screen with QR code.
95
+
96
+ Args:
97
+ url: Primary URL to display and encode in QR.
98
+ is_tunnel: Whether tunnel mode is active.
99
+ cwd: Current working directory to display.
100
+ """
101
+ console.clear()
102
+
103
+ # Build QR code
104
+ try:
105
+ qr_text = get_qr_code(url)
106
+ except Exception:
107
+ qr_text = "[QR code unavailable]"
108
+
109
+ # Status indicator
110
+ if is_tunnel:
111
+ status = "[green]●[/green] TUNNEL ACTIVE - SCAN THE QR CODE TO ACCESS YOUR TERMINAL"
112
+ else:
113
+ status = "[yellow]●[/yellow] LOCAL MODE"
114
+
115
+ # Build logo with gradient
116
+ logo_lines = LOGO.strip().split("\n")
117
+ colors = ["bold bright_cyan", "bright_cyan", "cyan", "bright_blue", "blue"]
118
+ logo_colored = []
119
+ for i, line in enumerate(logo_lines):
120
+ color = colors[i] if i < len(colors) else colors[-1]
121
+ logo_colored.append(f"[{color}]{line}[/{color}]")
122
+
123
+ # Build tagline with gradient
124
+ tagline_lines = TAGLINE.split("\n")
125
+ tagline_colors = ["bright_magenta", "magenta"]
126
+ tagline_colored = []
127
+ for i, line in enumerate(tagline_lines):
128
+ color = tagline_colors[i] if i < len(tagline_colors) else tagline_colors[-1]
129
+ tagline_colored.append(f"[{color}]{line}[/{color}]")
130
+
131
+ # Left side content
132
+ left_lines = [
133
+ *logo_colored,
134
+ f"[dim]v{__version__}[/dim]",
135
+ "",
136
+ *tagline_colored,
137
+ "",
138
+ f"[bold yellow]{get_caution()}[/bold yellow]",
139
+ "[bright_red]The URL is the only security. Use at your own risk.[/bright_red]",
140
+ status,
141
+ f"[bold cyan]{url}[/bold cyan]",
142
+ ]
143
+ if cwd:
144
+ left_lines.append(f"[dim]{cwd}[/dim]")
145
+ left_lines.append("[dim]Ctrl+C to stop[/dim]")
146
+
147
+ left_content = "\n".join(left_lines)
148
+
149
+ # Create side-by-side layout (logo left, QR right)
150
+ table = Table.grid(padding=(0, 4))
151
+ table.add_column(justify="left", vertical="middle")
152
+ table.add_column(justify="left", vertical="middle")
153
+ table.add_row(left_content, qr_text)
154
+
155
+ console.print()
156
+ console.print(Align.center(table))
157
+ console.print()
@@ -0,0 +1,208 @@
1
+ """Composition root - the ONLY place where dependencies are wired."""
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ from porterminal.application.services import (
7
+ ManagementService,
8
+ SessionService,
9
+ TabService,
10
+ TerminalService,
11
+ )
12
+ from porterminal.container import Container
13
+ from porterminal.domain import (
14
+ EnvironmentRules,
15
+ EnvironmentSanitizer,
16
+ PTYPort,
17
+ SessionLimitChecker,
18
+ ShellCommand,
19
+ TabLimitChecker,
20
+ TerminalDimensions,
21
+ )
22
+ from porterminal.infrastructure.config import ShellDetector, YAMLConfigLoader
23
+ from porterminal.infrastructure.registry import UserConnectionRegistry
24
+ from porterminal.infrastructure.repositories import InMemorySessionRepository, InMemoryTabRepository
25
+
26
+
27
+ def create_pty_factory(
28
+ cwd: str | None = None,
29
+ ) -> Callable[[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort]:
30
+ """Create a PTY factory function.
31
+
32
+ This bridges the domain PTYPort interface with the existing
33
+ infrastructure PTY implementation.
34
+ """
35
+ from porterminal.pty import SecurePTYManager, create_backend
36
+
37
+ def factory(
38
+ shell: ShellCommand,
39
+ dimensions: TerminalDimensions,
40
+ environment: dict[str, str],
41
+ working_directory: str | None = None,
42
+ ) -> PTYPort:
43
+ # Use provided cwd or factory default
44
+ effective_cwd = working_directory or cwd
45
+
46
+ # Create backend
47
+ backend = create_backend()
48
+
49
+ # Create shell config compatible with existing infrastructure
50
+ from porterminal.config import ShellConfig as LegacyShellConfig
51
+
52
+ legacy_shell = LegacyShellConfig(
53
+ name=shell.name,
54
+ id=shell.id,
55
+ command=shell.command,
56
+ args=list(shell.args),
57
+ )
58
+
59
+ # Create manager (which implements PTY operations)
60
+ manager = SecurePTYManager(
61
+ backend=backend,
62
+ shell_config=legacy_shell,
63
+ cols=dimensions.cols,
64
+ rows=dimensions.rows,
65
+ cwd=effective_cwd,
66
+ )
67
+
68
+ # Spawn with environment (manager handles sanitization internally,
69
+ # but we pass our sanitized env to be safe)
70
+ manager.spawn()
71
+
72
+ return PTYManagerAdapter(manager, dimensions)
73
+
74
+ return factory
75
+
76
+
77
+ class PTYManagerAdapter:
78
+ """Adapts SecurePTYManager to PTYPort interface."""
79
+
80
+ def __init__(self, manager, dimensions: TerminalDimensions) -> None:
81
+ self._manager = manager
82
+ self._dimensions = dimensions
83
+
84
+ def spawn(self) -> None:
85
+ """Already spawned in factory."""
86
+ pass
87
+
88
+ def read(self, size: int = 4096) -> bytes:
89
+ return self._manager.read(size)
90
+
91
+ def write(self, data: bytes) -> None:
92
+ self._manager.write(data)
93
+
94
+ def resize(self, dimensions: TerminalDimensions) -> None:
95
+ self._manager.resize(dimensions.cols, dimensions.rows)
96
+ self._dimensions = dimensions
97
+
98
+ def is_alive(self) -> bool:
99
+ return self._manager.is_alive()
100
+
101
+ def close(self) -> None:
102
+ self._manager.close()
103
+
104
+ @property
105
+ def dimensions(self) -> TerminalDimensions:
106
+ return self._dimensions
107
+
108
+
109
+ def create_container(
110
+ config_path: Path | str = "config.yaml",
111
+ cwd: str | None = None,
112
+ ) -> Container:
113
+ """Create the dependency container with all wired dependencies.
114
+
115
+ This is the composition root - the single place where all
116
+ dependencies are created and wired together.
117
+
118
+ Args:
119
+ config_path: Path to config file.
120
+ cwd: Working directory for PTY sessions.
121
+
122
+ Returns:
123
+ Fully wired dependency container.
124
+ """
125
+ # Load configuration
126
+ loader = YAMLConfigLoader(config_path)
127
+ config_data = loader.load()
128
+
129
+ # Detect shells
130
+ detector = ShellDetector()
131
+ shells = detector.detect_shells()
132
+
133
+ # Get config values with defaults
134
+ server_data = config_data.get("server", {})
135
+ terminal_data = config_data.get("terminal", {})
136
+
137
+ server_host = server_data.get("host", "127.0.0.1")
138
+ server_port = server_data.get("port", 8000)
139
+ default_cols = terminal_data.get("cols", 120)
140
+ default_rows = terminal_data.get("rows", 30)
141
+ default_shell_id = terminal_data.get("default_shell") or detector.get_default_shell_id()
142
+ buttons = config_data.get("buttons", [])
143
+
144
+ # Use configured shells if provided, otherwise use detected
145
+ configured_shells = terminal_data.get("shells", [])
146
+ if configured_shells:
147
+ shells = [ShellCommand.from_dict(s) for s in configured_shells]
148
+
149
+ # Create repositories
150
+ session_repository = InMemorySessionRepository()
151
+ tab_repository = InMemoryTabRepository()
152
+
153
+ # Create connection registry for broadcasting
154
+ connection_registry = UserConnectionRegistry()
155
+
156
+ # Create PTY factory
157
+ pty_factory = create_pty_factory(cwd)
158
+
159
+ # Create services
160
+ session_service = SessionService(
161
+ repository=session_repository,
162
+ pty_factory=pty_factory,
163
+ limit_checker=SessionLimitChecker(),
164
+ environment_sanitizer=EnvironmentSanitizer(EnvironmentRules()),
165
+ working_directory=cwd,
166
+ )
167
+
168
+ tab_service = TabService(
169
+ repository=tab_repository,
170
+ limit_checker=TabLimitChecker(),
171
+ )
172
+
173
+ terminal_service = TerminalService()
174
+
175
+ # Create a shell provider closure for ManagementService
176
+ def get_shell(shell_id: str | None) -> ShellCommand | None:
177
+ target_id = shell_id or default_shell_id
178
+ for shell in shells:
179
+ if shell.id == target_id:
180
+ return shell
181
+ return shells[0] if shells else None
182
+
183
+ management_service = ManagementService(
184
+ session_service=session_service,
185
+ tab_service=tab_service,
186
+ connection_registry=connection_registry,
187
+ shell_provider=get_shell,
188
+ default_dimensions=TerminalDimensions(default_cols, default_rows),
189
+ )
190
+
191
+ return Container(
192
+ session_service=session_service,
193
+ tab_service=tab_service,
194
+ terminal_service=terminal_service,
195
+ management_service=management_service,
196
+ session_repository=session_repository,
197
+ tab_repository=tab_repository,
198
+ connection_registry=connection_registry,
199
+ pty_factory=pty_factory,
200
+ available_shells=shells,
201
+ default_shell_id=default_shell_id,
202
+ server_host=server_host,
203
+ server_port=server_port,
204
+ default_cols=default_cols,
205
+ default_rows=default_rows,
206
+ buttons=buttons,
207
+ cwd=cwd,
208
+ )