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,37 @@
1
+ """Shell command specification value object."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class ShellCommand:
8
+ """Shell command specification (value object).
9
+
10
+ Note: Validation that the command exists on the filesystem
11
+ is NOT done here - that's an infrastructure concern.
12
+ """
13
+
14
+ id: str
15
+ name: str
16
+ command: str
17
+ args: tuple[str, ...] # Immutable tuple instead of list
18
+
19
+ def __post_init__(self) -> None:
20
+ if not self.id:
21
+ raise ValueError("Shell id cannot be empty")
22
+ if not self.command:
23
+ raise ValueError("Shell command cannot be empty")
24
+
25
+ def to_command_list(self) -> list[str]:
26
+ """Build command + args list for process spawning."""
27
+ return [self.command, *self.args]
28
+
29
+ @classmethod
30
+ def from_dict(cls, data: dict) -> "ShellCommand":
31
+ """Create from dictionary (e.g., from config)."""
32
+ return cls(
33
+ id=data["id"],
34
+ name=data.get("name", data["id"]),
35
+ command=data["command"],
36
+ args=tuple(data.get("args", [])),
37
+ )
@@ -0,0 +1,24 @@
1
+ """Tab ID value object."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class TabId:
8
+ """Strongly-typed tab identifier.
9
+
10
+ Generated as UUID string, same pattern as SessionId.
11
+ Immutable and hashable for use as dict keys.
12
+ """
13
+
14
+ value: str
15
+
16
+ def __post_init__(self) -> None:
17
+ if not self.value or not isinstance(self.value, str):
18
+ raise ValueError("TabId cannot be empty")
19
+
20
+ def __str__(self) -> str:
21
+ return self.value
22
+
23
+ def __hash__(self) -> int:
24
+ return hash(self.value)
@@ -0,0 +1,45 @@
1
+ """Terminal dimensions value object with validation."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ # Business rule constants
6
+ MIN_COLS = 40
7
+ MAX_COLS = 500
8
+ MIN_ROWS = 10
9
+ MAX_ROWS = 200
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class TerminalDimensions:
14
+ """Immutable terminal dimensions with validation.
15
+
16
+ Invariants:
17
+ - cols in [40, 500]
18
+ - rows in [10, 200]
19
+ """
20
+
21
+ cols: int
22
+ rows: int
23
+
24
+ def __post_init__(self) -> None:
25
+ if not (MIN_COLS <= self.cols <= MAX_COLS):
26
+ raise ValueError(f"cols must be {MIN_COLS}-{MAX_COLS}, got {self.cols}")
27
+ if not (MIN_ROWS <= self.rows <= MAX_ROWS):
28
+ raise ValueError(f"rows must be {MIN_ROWS}-{MAX_ROWS}, got {self.rows}")
29
+
30
+ @classmethod
31
+ def clamped(cls, cols: int, rows: int) -> "TerminalDimensions":
32
+ """Create dimensions with clamping instead of raising."""
33
+ return cls(
34
+ cols=max(MIN_COLS, min(cols, MAX_COLS)),
35
+ rows=max(MIN_ROWS, min(rows, MAX_ROWS)),
36
+ )
37
+
38
+ @classmethod
39
+ def default(cls) -> "TerminalDimensions":
40
+ """Create default dimensions (120x30)."""
41
+ return cls(cols=120, rows=30)
42
+
43
+ def resize(self, cols: int, rows: int) -> "TerminalDimensions":
44
+ """Return new dimensions with clamping (immutable)."""
45
+ return TerminalDimensions.clamped(cols, rows)
@@ -0,0 +1,25 @@
1
+ """User ID value object."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class UserId:
8
+ """Strongly-typed user identifier."""
9
+
10
+ value: str
11
+
12
+ def __post_init__(self) -> None:
13
+ if not self.value or not isinstance(self.value, str):
14
+ raise ValueError("UserId cannot be empty")
15
+
16
+ def __str__(self) -> str:
17
+ return self.value
18
+
19
+ def __hash__(self) -> int:
20
+ return hash(self.value)
21
+
22
+ @classmethod
23
+ def local_user(cls) -> "UserId":
24
+ """Create default local user ID."""
25
+ return cls("local-user")
@@ -0,0 +1,20 @@
1
+ """Infrastructure utilities for Porterminal."""
2
+
3
+ from .cloudflared import CloudflaredInstaller
4
+ from .network import find_available_port, is_port_available
5
+ from .registry import UserConnectionRegistry
6
+ from .repositories import InMemorySessionRepository, InMemoryTabRepository
7
+ from .server import drain_process_output, start_cloudflared, start_server, wait_for_server
8
+
9
+ __all__ = [
10
+ "CloudflaredInstaller",
11
+ "is_port_available",
12
+ "find_available_port",
13
+ "start_server",
14
+ "wait_for_server",
15
+ "start_cloudflared",
16
+ "drain_process_output",
17
+ "InMemorySessionRepository",
18
+ "InMemoryTabRepository",
19
+ "UserConnectionRegistry",
20
+ ]
@@ -0,0 +1,295 @@
1
+ """Cloudflared installation and management."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import urllib.request
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+
14
+ console = Console()
15
+
16
+
17
+ class CloudflaredInstaller:
18
+ """Platform-specific cloudflared installer."""
19
+
20
+ @staticmethod
21
+ def is_installed() -> bool:
22
+ """Check if cloudflared is installed."""
23
+ return shutil.which("cloudflared") is not None
24
+
25
+ @staticmethod
26
+ def install() -> bool:
27
+ """Auto-install cloudflared on the current platform.
28
+
29
+ Returns:
30
+ True if installation succeeded, False otherwise.
31
+ """
32
+ console.print("[cyan]Installing cloudflared...[/cyan]")
33
+
34
+ if sys.platform == "win32":
35
+ return CloudflaredInstaller._install_windows()
36
+ elif sys.platform == "linux":
37
+ return CloudflaredInstaller._install_linux()
38
+ elif sys.platform == "darwin":
39
+ return CloudflaredInstaller._install_macos()
40
+ else:
41
+ console.print(f"[yellow]Auto-install not supported on {sys.platform}[/yellow]")
42
+ return False
43
+
44
+ @staticmethod
45
+ def _find_cloudflared_windows() -> str | None:
46
+ """Find cloudflared.exe in common Windows install locations."""
47
+ common_paths = [
48
+ Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "cloudflared",
49
+ Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "cloudflared",
50
+ Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "cloudflared",
51
+ Path(os.environ.get("LOCALAPPDATA", "")) / "cloudflared",
52
+ Path.home() / "AppData" / "Local" / "Microsoft" / "WinGet" / "Packages",
53
+ ]
54
+
55
+ for path in common_paths:
56
+ if not path.exists():
57
+ continue
58
+ # Direct exe in folder
59
+ exe = path / "cloudflared.exe"
60
+ if exe.exists():
61
+ return str(path)
62
+ # Search in subdirectories (for WinGet packages folder)
63
+ for exe in path.rglob("cloudflared.exe"):
64
+ return str(exe.parent)
65
+
66
+ return None
67
+
68
+ @staticmethod
69
+ def _install_windows() -> bool:
70
+ """Install cloudflared on Windows."""
71
+ # Try winget first (preferred)
72
+ if shutil.which("winget"):
73
+ try:
74
+ result = subprocess.run(
75
+ [
76
+ "winget",
77
+ "install",
78
+ "--id",
79
+ "Cloudflare.cloudflared",
80
+ "-e",
81
+ "--silent",
82
+ "--accept-source-agreements",
83
+ ],
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=120,
87
+ )
88
+ if result.returncode == 0 or "already installed" in result.stdout.lower():
89
+ console.print("[green]✓ Installed via winget[/green]")
90
+
91
+ # Try to find and add to PATH for current session
92
+ install_path = CloudflaredInstaller._find_cloudflared_windows()
93
+ if install_path:
94
+ os.environ["PATH"] = install_path + os.pathsep + os.environ.get("PATH", "")
95
+ console.print(f"[dim]Added to PATH: {install_path}[/dim]")
96
+ # Return True regardless - winget succeeded, may just need shell restart
97
+ return True
98
+ except (subprocess.TimeoutExpired, OSError) as e:
99
+ console.print(f"[dim]winget failed: {e}[/dim]")
100
+
101
+ # Fallback: direct download
102
+ try:
103
+ import zipfile
104
+
105
+ url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.zip"
106
+ install_dir = Path.home() / ".cloudflared" / "bin"
107
+ install_dir.mkdir(parents=True, exist_ok=True)
108
+
109
+ console.print("[dim]Downloading from GitHub...[/dim]")
110
+
111
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
112
+ tmp_path = tmp.name
113
+ urllib.request.urlretrieve(url, tmp_path)
114
+
115
+ with zipfile.ZipFile(tmp_path, "r") as zf:
116
+ zf.extractall(install_dir)
117
+
118
+ os.unlink(tmp_path)
119
+
120
+ exe_path = install_dir / "cloudflared.exe"
121
+ if exe_path.exists():
122
+ os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
123
+ console.print(f"[green]✓ Installed to {install_dir}[/green]")
124
+ return True
125
+
126
+ except (OSError, urllib.error.URLError) as e:
127
+ console.print(f"[red]Download failed: {e}[/red]")
128
+
129
+ return False
130
+
131
+ @staticmethod
132
+ def _find_cloudflared_unix() -> str | None:
133
+ """Find cloudflared in common Unix install locations."""
134
+ common_paths = [
135
+ Path("/usr/bin"),
136
+ Path("/usr/local/bin"),
137
+ Path("/opt/homebrew/bin"),
138
+ Path.home() / ".local" / "bin",
139
+ Path.home() / "bin",
140
+ ]
141
+
142
+ for path in common_paths:
143
+ exe = path / "cloudflared"
144
+ if exe.exists() and os.access(exe, os.X_OK):
145
+ return str(path)
146
+
147
+ return None
148
+
149
+ @staticmethod
150
+ def _install_linux() -> bool:
151
+ """Install cloudflared on Linux."""
152
+ # Determine architecture
153
+ machine = platform.machine().lower()
154
+ if machine in ("x86_64", "amd64"):
155
+ arch = "amd64"
156
+ elif machine in ("aarch64", "arm64"):
157
+ arch = "arm64"
158
+ elif machine.startswith("arm"):
159
+ arch = "arm"
160
+ else:
161
+ arch = "amd64" # Default fallback
162
+
163
+ # Try package managers first
164
+ pkg_managers = [
165
+ (["apt-get", "install", "-y", "cloudflared"], "apt"),
166
+ (["yum", "install", "-y", "cloudflared"], "yum"),
167
+ (["dnf", "install", "-y", "cloudflared"], "dnf"),
168
+ ]
169
+
170
+ for cmd, name in pkg_managers:
171
+ if shutil.which(cmd[0]):
172
+ try:
173
+ # Add Cloudflare repo first for apt
174
+ if name == "apt":
175
+ subprocess.run(
176
+ [
177
+ "bash",
178
+ "-c",
179
+ "curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null && "
180
+ "echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main' | sudo tee /etc/apt/sources.list.d/cloudflared.list && "
181
+ "sudo apt-get update",
182
+ ],
183
+ capture_output=True,
184
+ timeout=60,
185
+ )
186
+ result = subprocess.run(
187
+ ["sudo"] + cmd,
188
+ capture_output=True,
189
+ text=True,
190
+ timeout=120,
191
+ )
192
+ if result.returncode == 0:
193
+ console.print(f"[green]✓ Installed via {name}[/green]")
194
+
195
+ # Check if now in PATH
196
+ if shutil.which("cloudflared"):
197
+ return True
198
+
199
+ # Try to find and add to PATH
200
+ install_path = CloudflaredInstaller._find_cloudflared_unix()
201
+ if install_path:
202
+ os.environ["PATH"] = (
203
+ install_path + os.pathsep + os.environ.get("PATH", "")
204
+ )
205
+ console.print(f"[dim]Added to PATH: {install_path}[/dim]")
206
+ # Return True regardless - package manager succeeded
207
+ return True
208
+ except (subprocess.TimeoutExpired, OSError) as e:
209
+ console.print(f"[dim]{name} failed: {e}[/dim]")
210
+
211
+ # Fallback: direct binary download
212
+ try:
213
+ url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}"
214
+ install_dir = Path.home() / ".local" / "bin"
215
+ install_dir.mkdir(parents=True, exist_ok=True)
216
+ bin_path = install_dir / "cloudflared"
217
+
218
+ console.print("[dim]Downloading from GitHub...[/dim]")
219
+ urllib.request.urlretrieve(url, bin_path)
220
+
221
+ # Make executable
222
+ os.chmod(bin_path, 0o755)
223
+
224
+ # Add to PATH for this session
225
+ os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
226
+ console.print(f"[green]✓ Installed to {bin_path}[/green]")
227
+ return True
228
+
229
+ except (OSError, urllib.error.URLError) as e:
230
+ console.print(f"[red]Download failed: {e}[/red]")
231
+
232
+ return False
233
+
234
+ @staticmethod
235
+ def _install_macos() -> bool:
236
+ """Install cloudflared on macOS."""
237
+ # Try Homebrew first
238
+ if shutil.which("brew"):
239
+ try:
240
+ result = subprocess.run(
241
+ ["brew", "install", "cloudflared"],
242
+ capture_output=True,
243
+ text=True,
244
+ timeout=120,
245
+ )
246
+ if result.returncode == 0:
247
+ console.print("[green]✓ Installed via Homebrew[/green]")
248
+
249
+ # Check if now in PATH
250
+ if shutil.which("cloudflared"):
251
+ return True
252
+
253
+ # Try to find and add to PATH
254
+ install_path = CloudflaredInstaller._find_cloudflared_unix()
255
+ if install_path:
256
+ os.environ["PATH"] = install_path + os.pathsep + os.environ.get("PATH", "")
257
+ console.print(f"[dim]Added to PATH: {install_path}[/dim]")
258
+ # Return True regardless - Homebrew succeeded
259
+ return True
260
+ except (subprocess.TimeoutExpired, OSError) as e:
261
+ console.print(f"[dim]Homebrew failed: {e}[/dim]")
262
+
263
+ # Fallback: direct download
264
+ try:
265
+ import tarfile
266
+
267
+ machine = platform.machine().lower()
268
+ arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
269
+
270
+ url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-{arch}.tgz"
271
+ install_dir = Path.home() / ".local" / "bin"
272
+ install_dir.mkdir(parents=True, exist_ok=True)
273
+
274
+ console.print("[dim]Downloading from GitHub...[/dim]")
275
+
276
+ with tempfile.NamedTemporaryFile(suffix=".tgz", delete=False) as tmp:
277
+ tmp_path = tmp.name
278
+ urllib.request.urlretrieve(url, tmp_path)
279
+
280
+ with tarfile.open(tmp_path, "r:gz") as tf:
281
+ tf.extractall(install_dir)
282
+
283
+ os.unlink(tmp_path)
284
+
285
+ bin_path = install_dir / "cloudflared"
286
+ if bin_path.exists():
287
+ os.chmod(bin_path, 0o755)
288
+ os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
289
+ console.print(f"[green]✓ Installed to {bin_path}[/green]")
290
+ return True
291
+
292
+ except (OSError, urllib.error.URLError) as e:
293
+ console.print(f"[red]Download failed: {e}[/red]")
294
+
295
+ return False
@@ -0,0 +1,9 @@
1
+ """Configuration infrastructure - loading and detection."""
2
+
3
+ from .shell_detector import ShellDetector
4
+ from .yaml_loader import YAMLConfigLoader
5
+
6
+ __all__ = [
7
+ "YAMLConfigLoader",
8
+ "ShellDetector",
9
+ ]
@@ -0,0 +1,84 @@
1
+ """Shell detection for available shells on the system."""
2
+
3
+ import shutil
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from porterminal.domain import ShellCommand
8
+
9
+
10
+ class ShellDetector:
11
+ """Detect available shells on the current platform."""
12
+
13
+ def detect_shells(self) -> list[ShellCommand]:
14
+ """Auto-detect available shells.
15
+
16
+ Returns:
17
+ List of detected shell configurations.
18
+ """
19
+ candidates = self._get_platform_candidates()
20
+ shells = []
21
+
22
+ for name, shell_id, command, args in candidates:
23
+ shell_path = shutil.which(command)
24
+ if shell_path or Path(command).exists():
25
+ shells.append(
26
+ ShellCommand(
27
+ id=shell_id,
28
+ name=name,
29
+ command=shell_path or command,
30
+ args=tuple(args),
31
+ )
32
+ )
33
+
34
+ return shells
35
+
36
+ def get_default_shell_id(self) -> str:
37
+ """Get the default shell ID for current platform."""
38
+ if sys.platform == "win32":
39
+ return self._get_windows_default()
40
+ elif sys.platform == "darwin":
41
+ return self._get_macos_default()
42
+ return self._get_linux_default()
43
+
44
+ def _get_platform_candidates(self) -> list[tuple[str, str, str, list[str]]]:
45
+ """Get shell candidates for current platform.
46
+
47
+ Returns:
48
+ List of (name, id, command, args) tuples.
49
+ """
50
+ if sys.platform == "win32":
51
+ return [
52
+ ("PS 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
53
+ ("PS", "powershell", "powershell.exe", ["-NoLogo"]),
54
+ ("CMD", "cmd", "cmd.exe", []),
55
+ ("WSL", "wsl", "wsl.exe", []),
56
+ ]
57
+ return [
58
+ ("Bash", "bash", "bash", ["--login"]),
59
+ ("Zsh", "zsh", "zsh", ["--login"]),
60
+ ("Fish", "fish", "fish", []),
61
+ ("Sh", "sh", "sh", []),
62
+ ]
63
+
64
+ def _get_windows_default(self) -> str:
65
+ """Get default shell ID for Windows."""
66
+ if shutil.which("pwsh.exe"):
67
+ return "pwsh"
68
+ if shutil.which("powershell.exe"):
69
+ return "powershell"
70
+ return "cmd"
71
+
72
+ def _get_macos_default(self) -> str:
73
+ """Get default shell ID for macOS."""
74
+ if shutil.which("zsh"):
75
+ return "zsh"
76
+ return "bash"
77
+
78
+ def _get_linux_default(self) -> str:
79
+ """Get default shell ID for Linux."""
80
+ if shutil.which("bash"):
81
+ return "bash"
82
+ if shutil.which("zsh"):
83
+ return "zsh"
84
+ return "sh"
@@ -0,0 +1,34 @@
1
+ """YAML configuration loader."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ class YAMLConfigLoader:
10
+ """Load configuration from YAML files."""
11
+
12
+ def __init__(self, config_path: Path | str = "config.yaml") -> None:
13
+ self._config_path = Path(config_path)
14
+
15
+ def load(self) -> dict[str, Any]:
16
+ """Load raw configuration data from YAML.
17
+
18
+ Returns:
19
+ Configuration dictionary, empty dict if file not found.
20
+ """
21
+ if not self._config_path.exists():
22
+ return {}
23
+
24
+ with open(self._config_path, encoding="utf-8") as f:
25
+ return yaml.safe_load(f) or {}
26
+
27
+ def reload(self) -> dict[str, Any]:
28
+ """Reload configuration from file."""
29
+ return self.load()
30
+
31
+ @property
32
+ def path(self) -> Path:
33
+ """Get configuration file path."""
34
+ return self._config_path
@@ -0,0 +1,43 @@
1
+ """Network utilities for Porterminal."""
2
+
3
+ import socket
4
+
5
+
6
+ def is_port_available(host: str, port: int) -> bool:
7
+ """Check if host:port is available to bind.
8
+
9
+ Args:
10
+ host: Host address to check.
11
+ port: Port number to check.
12
+
13
+ Returns:
14
+ True if port is available, False otherwise.
15
+ """
16
+ try:
17
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
18
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
19
+ sock.bind((host, port))
20
+ return True
21
+ except OSError:
22
+ return False
23
+
24
+
25
+ def find_available_port(host: str, preferred_port: int, tries: int = 25) -> int:
26
+ """Find an available port, starting at preferred_port and incrementing.
27
+
28
+ Args:
29
+ host: Host address to bind.
30
+ preferred_port: Preferred port to start searching from.
31
+ tries: Number of consecutive ports to try.
32
+
33
+ Returns:
34
+ Available port number.
35
+ """
36
+ for offset in range(tries):
37
+ candidate = preferred_port + offset
38
+ if is_port_available(host, candidate):
39
+ return candidate
40
+ # Fallback: ask OS for an ephemeral port
41
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
42
+ sock.bind((host, 0))
43
+ return sock.getsockname()[1]
@@ -0,0 +1,5 @@
1
+ """Infrastructure registries for connection tracking."""
2
+
3
+ from .user_connection_registry import UserConnectionRegistry
4
+
5
+ __all__ = ["UserConnectionRegistry"]