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
|
@@ -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,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]
|