ptn 0.2.5__py3-none-any.whl → 0.4.2__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 (39) hide show
  1. porterminal/__init__.py +63 -11
  2. porterminal/_version.py +2 -2
  3. porterminal/app.py +25 -1
  4. porterminal/application/ports/__init__.py +2 -0
  5. porterminal/application/ports/connection_registry_port.py +46 -0
  6. porterminal/application/services/management_service.py +30 -55
  7. porterminal/application/services/session_service.py +3 -11
  8. porterminal/application/services/terminal_service.py +97 -56
  9. porterminal/cli/args.py +91 -30
  10. porterminal/cli/display.py +18 -16
  11. porterminal/cli/script_discovery.py +112 -0
  12. porterminal/composition.py +8 -7
  13. porterminal/config.py +12 -2
  14. porterminal/container.py +4 -0
  15. porterminal/domain/__init__.py +0 -9
  16. porterminal/domain/entities/output_buffer.py +56 -1
  17. porterminal/domain/entities/tab.py +11 -10
  18. porterminal/domain/services/__init__.py +0 -2
  19. porterminal/domain/values/__init__.py +0 -4
  20. porterminal/domain/values/environment_rules.py +3 -0
  21. porterminal/infrastructure/auth.py +131 -0
  22. porterminal/infrastructure/cloudflared.py +18 -12
  23. porterminal/infrastructure/config/shell_detector.py +407 -1
  24. porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  25. porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  26. porterminal/infrastructure/server.py +28 -3
  27. porterminal/pty/env.py +16 -78
  28. porterminal/pty/manager.py +6 -4
  29. porterminal/static/assets/app-DlWNJWFE.js +87 -0
  30. porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  31. porterminal/static/index.html +14 -2
  32. porterminal/updater.py +13 -5
  33. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/METADATA +84 -23
  34. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/RECORD +37 -34
  35. porterminal/static/assets/app-By4EXMHC.js +0 -72
  36. porterminal/static/assets/app-DQePboVd.css +0 -32
  37. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
  38. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
  39. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/licenses/LICENSE +0 -0
@@ -14,8 +14,6 @@ from porterminal.application.services import (
14
14
  from porterminal.config import find_config_file
15
15
  from porterminal.container import Container
16
16
  from porterminal.domain import (
17
- EnvironmentRules,
18
- EnvironmentSanitizer,
19
17
  PTYPort,
20
18
  SessionLimitChecker,
21
19
  ShellCommand,
@@ -29,7 +27,7 @@ from porterminal.infrastructure.repositories import InMemorySessionRepository, I
29
27
 
30
28
  def create_pty_factory(
31
29
  cwd: str | None = None,
32
- ) -> Callable[[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort]:
30
+ ) -> Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort]:
33
31
  """Create a PTY factory function.
34
32
 
35
33
  This bridges the domain PTYPort interface with the existing
@@ -40,7 +38,6 @@ def create_pty_factory(
40
38
  def factory(
41
39
  shell: ShellCommand,
42
40
  dimensions: TerminalDimensions,
43
- environment: dict[str, str],
44
41
  working_directory: str | None = None,
45
42
  ) -> PTYPort:
46
43
  # Use provided cwd or factory default
@@ -60,6 +57,7 @@ def create_pty_factory(
60
57
  )
61
58
 
62
59
  # Create manager (which implements PTY operations)
60
+ # Environment sanitization is handled internally by SecurePTYManager
63
61
  manager = SecurePTYManager(
64
62
  backend=backend,
65
63
  shell_config=legacy_shell,
@@ -68,8 +66,6 @@ def create_pty_factory(
68
66
  cwd=effective_cwd,
69
67
  )
70
68
 
71
- # Spawn with environment (manager handles sanitization internally,
72
- # but we pass our sanitized env to be safe)
73
69
  manager.spawn()
74
70
 
75
71
  return PTYManagerAdapter(manager, dimensions)
@@ -112,6 +108,7 @@ class PTYManagerAdapter:
112
108
  def create_container(
113
109
  config_path: Path | str | None = None,
114
110
  cwd: str | None = None,
111
+ password_hash: bytes | None = None,
115
112
  ) -> Container:
116
113
  """Create the dependency container with all wired dependencies.
117
114
 
@@ -121,6 +118,7 @@ def create_container(
121
118
  Args:
122
119
  config_path: Path to config file, or None to search standard locations.
123
120
  cwd: Working directory for PTY sessions.
121
+ password_hash: Bcrypt hash of password for authentication (None = no auth).
124
122
 
125
123
  Returns:
126
124
  Fully wired dependency container.
@@ -141,6 +139,7 @@ def create_container(
141
139
  # Get config values with defaults
142
140
  server_data = config_data.get("server", {})
143
141
  terminal_data = config_data.get("terminal", {})
142
+ security_data = config_data.get("security", {})
144
143
 
145
144
  server_host = server_data.get("host", "127.0.0.1")
146
145
  server_port = server_data.get("port", 8000)
@@ -148,6 +147,7 @@ def create_container(
148
147
  default_rows = terminal_data.get("rows", 30)
149
148
  default_shell_id = terminal_data.get("default_shell") or detector.get_default_shell_id()
150
149
  buttons = config_data.get("buttons", [])
150
+ max_auth_attempts = security_data.get("max_auth_attempts", 5)
151
151
 
152
152
  # Use configured shells if provided, otherwise use detected
153
153
  configured_shells = terminal_data.get("shells", [])
@@ -169,7 +169,6 @@ def create_container(
169
169
  repository=session_repository,
170
170
  pty_factory=pty_factory,
171
171
  limit_checker=SessionLimitChecker(),
172
- environment_sanitizer=EnvironmentSanitizer(EnvironmentRules()),
173
172
  working_directory=cwd,
174
173
  )
175
174
 
@@ -213,4 +212,6 @@ def create_container(
213
212
  default_rows=default_rows,
214
213
  buttons=buttons,
215
214
  cwd=cwd,
215
+ password_hash=password_hash,
216
+ max_auth_attempts=max_auth_attempts,
216
217
  )
porterminal/config.py CHANGED
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import yaml
8
8
  from pydantic import BaseModel, Field, field_validator
9
9
 
10
+ from porterminal.domain.values import MAX_COLS, MAX_ROWS, MIN_COLS, MIN_ROWS
10
11
  from porterminal.infrastructure.config import ShellDetector
11
12
 
12
13
 
@@ -43,8 +44,8 @@ class TerminalConfig(BaseModel):
43
44
  """Terminal configuration."""
44
45
 
45
46
  default_shell: str = ""
46
- cols: int = Field(default=120, ge=40, le=500)
47
- rows: int = Field(default=30, ge=10, le=200)
47
+ cols: int = Field(default=120, ge=MIN_COLS, le=MAX_COLS)
48
+ rows: int = Field(default=30, ge=MIN_ROWS, le=MAX_ROWS)
48
49
  shells: list[ShellConfig] = Field(default_factory=list)
49
50
 
50
51
  def get_shell(self, shell_id: str) -> ShellConfig | None:
@@ -60,6 +61,7 @@ class ButtonConfig(BaseModel):
60
61
 
61
62
  label: str
62
63
  send: str | list[str | int] = "" # string or list of strings/ints (ints = wait ms)
64
+ row: int = Field(default=1, ge=1, le=10) # toolbar row (1-10)
63
65
 
64
66
 
65
67
  class CloudflareConfig(BaseModel):
@@ -76,6 +78,13 @@ class UpdateConfig(BaseModel):
76
78
  check_interval: int = Field(default=86400, ge=0) # Seconds between checks (0 = always)
77
79
 
78
80
 
81
+ class SecurityConfig(BaseModel):
82
+ """Security configuration."""
83
+
84
+ require_password: bool = False # Prompt for password at startup
85
+ max_auth_attempts: int = Field(default=5, ge=1, le=100)
86
+
87
+
79
88
  class Config(BaseModel):
80
89
  """Application configuration."""
81
90
 
@@ -84,6 +93,7 @@ class Config(BaseModel):
84
93
  buttons: list[ButtonConfig] = Field(default_factory=list)
85
94
  cloudflare: CloudflareConfig = Field(default_factory=CloudflareConfig)
86
95
  update: UpdateConfig = Field(default_factory=UpdateConfig)
96
+ security: SecurityConfig = Field(default_factory=SecurityConfig)
87
97
 
88
98
 
89
99
  def find_config_file(cwd: Path | None = None) -> Path | None:
porterminal/container.py CHANGED
@@ -52,3 +52,7 @@ class Container:
52
52
 
53
53
  # Working directory
54
54
  cwd: str | None = None
55
+
56
+ # Security
57
+ password_hash: bytes | None = None
58
+ max_auth_attempts: int = 5
@@ -1,6 +1,5 @@
1
1
  """Pure domain layer - no infrastructure dependencies."""
2
2
 
3
- # Value Objects
4
3
  # Entities
5
4
  from .entities import (
6
5
  CLEAR_SCREEN_SEQUENCE,
@@ -23,7 +22,6 @@ from .ports import (
23
22
  # Services
24
23
  from .services import (
25
24
  Clock,
26
- EnvironmentSanitizer,
27
25
  SessionLimitChecker,
28
26
  SessionLimitConfig,
29
27
  SessionLimitResult,
@@ -33,13 +31,10 @@ from .services import (
33
31
  TokenBucketRateLimiter,
34
32
  )
35
33
  from .values import (
36
- DEFAULT_BLOCKED_VARS,
37
- DEFAULT_SAFE_VARS,
38
34
  MAX_COLS,
39
35
  MAX_ROWS,
40
36
  MIN_COLS,
41
37
  MIN_ROWS,
42
- EnvironmentRules,
43
38
  RateLimitConfig,
44
39
  SessionId,
45
40
  ShellCommand,
@@ -60,9 +55,6 @@ __all__ = [
60
55
  "TabId",
61
56
  "ShellCommand",
62
57
  "RateLimitConfig",
63
- "EnvironmentRules",
64
- "DEFAULT_SAFE_VARS",
65
- "DEFAULT_BLOCKED_VARS",
66
58
  # Entities
67
59
  "Session",
68
60
  "MAX_SESSIONS_PER_USER",
@@ -75,7 +67,6 @@ __all__ = [
75
67
  # Services
76
68
  "TokenBucketRateLimiter",
77
69
  "Clock",
78
- "EnvironmentSanitizer",
79
70
  "SessionLimitChecker",
80
71
  "SessionLimitConfig",
81
72
  "SessionLimitResult",
@@ -9,6 +9,11 @@ OUTPUT_BUFFER_MAX_BYTES = 1_000_000 # 1MB
9
9
  # Terminal escape sequence for clear screen (ED2)
10
10
  CLEAR_SCREEN_SEQUENCE = b"\x1b[2J"
11
11
 
12
+ # Alternate screen buffer sequences (DEC Private Mode)
13
+ # Used by vim, htop, less, tmux, etc.
14
+ ALT_SCREEN_ENTER = (b"\x1b[?47h", b"\x1b[?1047h", b"\x1b[?1049h")
15
+ ALT_SCREEN_EXIT = (b"\x1b[?47l", b"\x1b[?1047l", b"\x1b[?1049l")
16
+
12
17
 
13
18
  @dataclass
14
19
  class OutputBuffer:
@@ -16,12 +21,21 @@ class OutputBuffer:
16
21
 
17
22
  Pure domain logic for buffering terminal output.
18
23
  No async, no WebSocket - just data management.
24
+
25
+ Handles alternate screen buffer (used by vim, htop, less, etc.):
26
+ - On alt-screen enter: snapshots normal buffer, clears for alt content
27
+ - On alt-screen exit: restores normal buffer, discards alt content
19
28
  """
20
29
 
21
30
  max_bytes: int = OUTPUT_BUFFER_MAX_BYTES
22
31
  _buffer: deque[bytes] = field(default_factory=deque)
23
32
  _size: int = 0
24
33
 
34
+ # Alt-screen state
35
+ _in_alt_screen: bool = False
36
+ _normal_snapshot: deque[bytes] | None = None
37
+ _normal_snapshot_size: int = 0
38
+
25
39
  @property
26
40
  def size(self) -> int:
27
41
  """Current buffer size in bytes."""
@@ -32,12 +46,53 @@ class OutputBuffer:
32
46
  """Check if buffer is empty."""
33
47
  return self._size == 0
34
48
 
49
+ @property
50
+ def in_alt_screen(self) -> bool:
51
+ """Check if currently in alternate screen mode."""
52
+ return self._in_alt_screen
53
+
54
+ def _enter_alt_screen(self) -> None:
55
+ """Handle alt-screen entry: snapshot normal buffer."""
56
+ if self._in_alt_screen:
57
+ return # Already in alt-screen, ignore nested
58
+ self._in_alt_screen = True
59
+ self._normal_snapshot = self._buffer.copy()
60
+ self._normal_snapshot_size = self._size
61
+ self._clear_buffer()
62
+
63
+ def _exit_alt_screen(self) -> None:
64
+ """Handle alt-screen exit: restore normal buffer."""
65
+ if not self._in_alt_screen:
66
+ return # Not in alt-screen, ignore
67
+ self._in_alt_screen = False
68
+ if self._normal_snapshot is not None:
69
+ self._buffer = self._normal_snapshot
70
+ self._size = self._normal_snapshot_size
71
+ self._normal_snapshot = None
72
+ self._normal_snapshot_size = 0
73
+
74
+ def _clear_buffer(self) -> None:
75
+ """Clear the buffer contents only."""
76
+ self._buffer.clear()
77
+ self._size = 0
78
+
35
79
  def add(self, data: bytes) -> None:
36
80
  """Add data to the buffer.
37
81
 
38
- Handles clear screen detection and size limits.
82
+ Handles alt-screen transitions, clear screen detection, and size limits.
39
83
  When clear screen is detected, only keep content AFTER the last clear sequence.
40
84
  """
85
+ # Check alt-screen transitions FIRST
86
+ for pattern in ALT_SCREEN_EXIT:
87
+ if pattern in data:
88
+ self._exit_alt_screen()
89
+ break
90
+ else:
91
+ for pattern in ALT_SCREEN_ENTER:
92
+ if pattern in data:
93
+ self._enter_alt_screen()
94
+ return # Don't buffer alt-screen enter data
95
+
41
96
  # Check for clear screen sequence
42
97
  if CLEAR_SCREEN_SEQUENCE in data:
43
98
  # Clear old buffer
@@ -13,6 +13,15 @@ TAB_NAME_MIN_LENGTH = 1
13
13
  TAB_NAME_MAX_LENGTH = 50
14
14
 
15
15
 
16
+ def _validate_tab_name(name: str) -> None:
17
+ """Validate tab name length."""
18
+ if not (TAB_NAME_MIN_LENGTH <= len(name) <= TAB_NAME_MAX_LENGTH):
19
+ raise ValueError(
20
+ f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
21
+ f"characters, got {len(name)}"
22
+ )
23
+
24
+
16
25
  @dataclass
17
26
  class Tab:
18
27
  """Terminal tab entity.
@@ -35,11 +44,7 @@ class Tab:
35
44
  last_accessed: datetime
36
45
 
37
46
  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
- )
47
+ _validate_tab_name(self.name)
43
48
 
44
49
  @property
45
50
  def tab_id(self) -> str:
@@ -52,11 +57,7 @@ class Tab:
52
57
 
53
58
  def rename(self, new_name: str) -> None:
54
59
  """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
+ _validate_tab_name(new_name)
60
61
  self.name = new_name
61
62
 
62
63
  def to_dict(self) -> dict:
@@ -1,6 +1,5 @@
1
1
  """Domain services - pure business logic operations."""
2
2
 
3
- from .environment_sanitizer import EnvironmentSanitizer
4
3
  from .rate_limiter import Clock, TokenBucketRateLimiter
5
4
  from .session_limits import SessionLimitChecker, SessionLimitConfig, SessionLimitResult
6
5
  from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
@@ -8,7 +7,6 @@ from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
8
7
  __all__ = [
9
8
  "TokenBucketRateLimiter",
10
9
  "Clock",
11
- "EnvironmentSanitizer",
12
10
  "SessionLimitChecker",
13
11
  "SessionLimitConfig",
14
12
  "SessionLimitResult",
@@ -1,6 +1,5 @@
1
1
  """Domain value objects - immutable data structures."""
2
2
 
3
- from .environment_rules import DEFAULT_BLOCKED_VARS, DEFAULT_SAFE_VARS, EnvironmentRules
4
3
  from .rate_limit_config import RateLimitConfig
5
4
  from .session_id import SessionId
6
5
  from .shell_command import ShellCommand
@@ -19,7 +18,4 @@ __all__ = [
19
18
  "TabId",
20
19
  "ShellCommand",
21
20
  "RateLimitConfig",
22
- "EnvironmentRules",
23
- "DEFAULT_SAFE_VARS",
24
- "DEFAULT_BLOCKED_VARS",
25
21
  ]
@@ -22,12 +22,15 @@ DEFAULT_SAFE_VARS: frozenset[str] = frozenset(
22
22
  "HOMEPATH",
23
23
  "LOCALAPPDATA",
24
24
  "APPDATA",
25
+ "PROGRAMDATA",
25
26
  "PROGRAMFILES",
26
27
  "PROGRAMFILES(X86)",
27
28
  "COMMONPROGRAMFILES",
28
29
  # System info
29
30
  "COMPUTERNAME",
30
31
  "USERNAME",
32
+ "USER",
33
+ "LOGNAME",
31
34
  "USERDOMAIN",
32
35
  "OS",
33
36
  "PROCESSOR_ARCHITECTURE",
@@ -0,0 +1,131 @@
1
+ """Authentication utilities for WebSocket connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ from typing import TYPE_CHECKING
10
+
11
+ import bcrypt
12
+
13
+ if TYPE_CHECKING:
14
+ from porterminal.application.ports import ConnectionPort
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _shutdown_server() -> None:
20
+ """Trigger server shutdown due to auth failure."""
21
+ import time
22
+
23
+ # Print plain text - parent's drain_process_output handles formatting
24
+ print("", flush=True)
25
+ print("SECURITY WARNING", flush=True)
26
+ print("Max authentication attempts exceeded.", flush=True)
27
+ print("Your URL may have been leaked. Investigate before restarting.", flush=True)
28
+ print("", flush=True)
29
+
30
+ logger.warning(
31
+ "SECURITY: Max authentication attempts exceeded. "
32
+ "Shutting down server to prevent brute force attack."
33
+ )
34
+
35
+ # Delay to ensure message is visible before shutdown
36
+ time.sleep(1)
37
+ os.kill(os.getpid(), signal.SIGTERM)
38
+
39
+
40
+ async def authenticate_connection(
41
+ connection: ConnectionPort,
42
+ password_hash: bytes,
43
+ max_attempts: int = 5,
44
+ timeout_seconds: int = 30,
45
+ ) -> bool:
46
+ """Authenticate a WebSocket connection with password.
47
+
48
+ Sends auth_required, waits for auth message, validates password.
49
+ Returns True if authenticated, False otherwise.
50
+
51
+ Args:
52
+ connection: WebSocket connection adapter
53
+ password_hash: bcrypt hash of the expected password
54
+ max_attempts: Maximum number of password attempts
55
+ timeout_seconds: Timeout for receiving auth message
56
+
57
+ Returns:
58
+ True if successfully authenticated, False otherwise
59
+ """
60
+ await connection.send_message({"type": "auth_required"})
61
+
62
+ attempts = 0
63
+ while attempts < max_attempts:
64
+ try:
65
+ message = await asyncio.wait_for(connection.receive(), timeout=timeout_seconds)
66
+ except TimeoutError:
67
+ await connection.send_message(
68
+ {
69
+ "type": "auth_failed",
70
+ "attempts_remaining": 0,
71
+ "error": "Authentication timeout",
72
+ }
73
+ )
74
+ return False
75
+
76
+ if not isinstance(message, dict) or message.get("type") != "auth":
77
+ await connection.send_message(
78
+ {
79
+ "type": "error",
80
+ "error": "Authentication required",
81
+ }
82
+ )
83
+ continue
84
+
85
+ password = message.get("password", "")
86
+ if bcrypt.checkpw(password.encode(), password_hash):
87
+ await connection.send_message({"type": "auth_success"})
88
+ return True
89
+
90
+ attempts += 1
91
+ remaining = max_attempts - attempts
92
+ await connection.send_message(
93
+ {
94
+ "type": "auth_failed",
95
+ "attempts_remaining": remaining,
96
+ "error": "Invalid password" if remaining > 0 else "Too many failed attempts",
97
+ }
98
+ )
99
+
100
+ # Max attempts exhausted - shutdown to prevent brute force
101
+ _shutdown_server()
102
+ return False
103
+
104
+
105
+ async def validate_auth_message(
106
+ connection: ConnectionPort,
107
+ password_hash: bytes,
108
+ timeout_seconds: int = 10,
109
+ ) -> bool:
110
+ """Validate a single auth message from a connection.
111
+
112
+ For terminal WebSocket where we expect auth as first message.
113
+
114
+ Args:
115
+ connection: WebSocket connection adapter
116
+ password_hash: bcrypt hash of the expected password
117
+ timeout_seconds: Timeout for receiving auth message
118
+
119
+ Returns:
120
+ True if valid, False otherwise
121
+ """
122
+ try:
123
+ message = await asyncio.wait_for(connection.receive(), timeout=timeout_seconds)
124
+ except TimeoutError:
125
+ return False
126
+
127
+ if not isinstance(message, dict) or message.get("type") != "auth":
128
+ return False
129
+
130
+ password = message.get("password", "")
131
+ return bcrypt.checkpw(password.encode(), password_hash)
@@ -17,6 +17,13 @@ console = Console()
17
17
  class CloudflaredInstaller:
18
18
  """Platform-specific cloudflared installer."""
19
19
 
20
+ @staticmethod
21
+ def _add_to_path(path: str | Path) -> None:
22
+ """Add directory to PATH for current process."""
23
+ path_str = str(path)
24
+ os.environ["PATH"] = path_str + os.pathsep + os.environ.get("PATH", "")
25
+ console.print(f"[dim]Added to PATH: {path_str}[/dim]")
26
+
20
27
  @staticmethod
21
28
  def is_installed() -> bool:
22
29
  """Check if cloudflared is installed."""
@@ -91,8 +98,7 @@ class CloudflaredInstaller:
91
98
  # Try to find and add to PATH for current session
92
99
  install_path = CloudflaredInstaller._find_cloudflared_windows()
93
100
  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]")
101
+ CloudflaredInstaller._add_to_path(install_path)
96
102
  # Return True regardless - winget succeeded, may just need shell restart
97
103
  return True
98
104
  except (subprocess.TimeoutExpired, OSError) as e:
@@ -119,7 +125,7 @@ class CloudflaredInstaller:
119
125
 
120
126
  exe_path = install_dir / "cloudflared.exe"
121
127
  if exe_path.exists():
122
- os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
128
+ CloudflaredInstaller._add_to_path(install_dir)
123
129
  console.print(f"[green]✓ Installed to {install_dir}[/green]")
124
130
  return True
125
131
 
@@ -172,12 +178,16 @@ class CloudflaredInstaller:
172
178
  try:
173
179
  # Add Cloudflare repo first for apt
174
180
  if name == "apt":
181
+ # Use "any" distribution - works on all Debian-based systems
182
+ # (Ubuntu, Debian, Mint, Pop!_OS, etc.) without codename detection
183
+ # See: https://pkg.cloudflare.com/
175
184
  subprocess.run(
176
185
  [
177
186
  "bash",
178
187
  "-c",
188
+ "sudo mkdir -p --mode=0755 /usr/share/keyrings && "
179
189
  "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 && "
190
+ "echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list && "
181
191
  "sudo apt-get update",
182
192
  ],
183
193
  capture_output=True,
@@ -199,10 +209,7 @@ class CloudflaredInstaller:
199
209
  # Try to find and add to PATH
200
210
  install_path = CloudflaredInstaller._find_cloudflared_unix()
201
211
  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]")
212
+ CloudflaredInstaller._add_to_path(install_path)
206
213
  # Return True regardless - package manager succeeded
207
214
  return True
208
215
  except (subprocess.TimeoutExpired, OSError) as e:
@@ -222,7 +229,7 @@ class CloudflaredInstaller:
222
229
  os.chmod(bin_path, 0o755)
223
230
 
224
231
  # Add to PATH for this session
225
- os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
232
+ CloudflaredInstaller._add_to_path(install_dir)
226
233
  console.print(f"[green]✓ Installed to {bin_path}[/green]")
227
234
  return True
228
235
 
@@ -253,8 +260,7 @@ class CloudflaredInstaller:
253
260
  # Try to find and add to PATH
254
261
  install_path = CloudflaredInstaller._find_cloudflared_unix()
255
262
  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]")
263
+ CloudflaredInstaller._add_to_path(install_path)
258
264
  # Return True regardless - Homebrew succeeded
259
265
  return True
260
266
  except (subprocess.TimeoutExpired, OSError) as e:
@@ -285,7 +291,7 @@ class CloudflaredInstaller:
285
291
  bin_path = install_dir / "cloudflared"
286
292
  if bin_path.exists():
287
293
  os.chmod(bin_path, 0o755)
288
- os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
294
+ CloudflaredInstaller._add_to_path(install_dir)
289
295
  console.print(f"[green]✓ Installed to {bin_path}[/green]")
290
296
  return True
291
297