ptn 0.3.2__py3-none-any.whl → 0.4.6__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 (40) hide show
  1. porterminal/__init__.py +13 -5
  2. porterminal/_version.py +2 -2
  3. porterminal/application/services/management_service.py +28 -52
  4. porterminal/application/services/session_service.py +3 -11
  5. porterminal/application/services/terminal_service.py +97 -56
  6. porterminal/cli/args.py +84 -35
  7. porterminal/cli/display.py +18 -16
  8. porterminal/cli/script_discovery.py +266 -0
  9. porterminal/composition.py +2 -7
  10. porterminal/config.py +4 -2
  11. porterminal/domain/__init__.py +0 -9
  12. porterminal/domain/entities/output_buffer.py +56 -1
  13. porterminal/domain/entities/tab.py +11 -10
  14. porterminal/domain/services/__init__.py +0 -2
  15. porterminal/domain/values/__init__.py +0 -4
  16. porterminal/domain/values/environment_rules.py +3 -0
  17. porterminal/domain/values/rate_limit_config.py +3 -3
  18. porterminal/infrastructure/cloudflared.py +13 -11
  19. porterminal/infrastructure/config/shell_detector.py +113 -24
  20. porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  21. porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  22. porterminal/pty/env.py +16 -78
  23. porterminal/pty/manager.py +6 -4
  24. porterminal/static/assets/app-DlWNJWFE.js +87 -0
  25. porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  26. porterminal/static/index.html +2 -2
  27. porterminal/updater.py +13 -5
  28. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/METADATA +54 -16
  29. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/RECORD +32 -37
  30. porterminal/static/assets/app-BkHv5qu0.css +0 -32
  31. porterminal/static/assets/app-CaIGfw7i.js +0 -72
  32. porterminal/static/assets/app-D9ELFbEO.js +0 -72
  33. porterminal/static/assets/app-DF3nl_io.js +0 -72
  34. porterminal/static/assets/app-DQePboVd.css +0 -32
  35. porterminal/static/assets/app-DoBiVkTD.js +0 -72
  36. porterminal/static/assets/app-azbHOsRw.css +0 -32
  37. porterminal/static/assets/app-nMNFwMa6.css +0 -32
  38. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/WHEEL +0 -0
  39. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/entry_points.txt +0 -0
  40. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -203,10 +209,7 @@ class CloudflaredInstaller:
203
209
  # Try to find and add to PATH
204
210
  install_path = CloudflaredInstaller._find_cloudflared_unix()
205
211
  if install_path:
206
- os.environ["PATH"] = (
207
- install_path + os.pathsep + os.environ.get("PATH", "")
208
- )
209
- console.print(f"[dim]Added to PATH: {install_path}[/dim]")
212
+ CloudflaredInstaller._add_to_path(install_path)
210
213
  # Return True regardless - package manager succeeded
211
214
  return True
212
215
  except (subprocess.TimeoutExpired, OSError) as e:
@@ -226,7 +229,7 @@ class CloudflaredInstaller:
226
229
  os.chmod(bin_path, 0o755)
227
230
 
228
231
  # Add to PATH for this session
229
- os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
232
+ CloudflaredInstaller._add_to_path(install_dir)
230
233
  console.print(f"[green]✓ Installed to {bin_path}[/green]")
231
234
  return True
232
235
 
@@ -257,8 +260,7 @@ class CloudflaredInstaller:
257
260
  # Try to find and add to PATH
258
261
  install_path = CloudflaredInstaller._find_cloudflared_unix()
259
262
  if install_path:
260
- os.environ["PATH"] = install_path + os.pathsep + os.environ.get("PATH", "")
261
- console.print(f"[dim]Added to PATH: {install_path}[/dim]")
263
+ CloudflaredInstaller._add_to_path(install_path)
262
264
  # Return True regardless - Homebrew succeeded
263
265
  return True
264
266
  except (subprocess.TimeoutExpired, OSError) as e:
@@ -289,7 +291,7 @@ class CloudflaredInstaller:
289
291
  bin_path = install_dir / "cloudflared"
290
292
  if bin_path.exists():
291
293
  os.chmod(bin_path, 0o755)
292
- os.environ["PATH"] = str(install_dir) + os.pathsep + os.environ.get("PATH", "")
294
+ CloudflaredInstaller._add_to_path(install_dir)
293
295
  console.print(f"[green]✓ Installed to {bin_path}[/green]")
294
296
  return True
295
297
 
@@ -21,6 +21,9 @@ class ShellDetector:
21
21
  def detect_shells(self) -> list[ShellCommand]:
22
22
  """Auto-detect available shells.
23
23
 
24
+ Detects platform-specific shells and includes the user's $SHELL
25
+ if it's not already in the list (supports any shell).
26
+
24
27
  Returns:
25
28
  List of detected shell configurations.
26
29
  """
@@ -39,6 +42,12 @@ class ShellDetector:
39
42
  )
40
43
  )
41
44
 
45
+ # Include user's $SHELL if not already detected (supports unknown shells)
46
+ user_shell = self._create_shell_from_env()
47
+ if user_shell and not any(s.id == user_shell.id for s in shells):
48
+ # Insert at beginning so user's preferred shell is first
49
+ shells.insert(0, user_shell)
50
+
42
51
  return shells
43
52
 
44
53
  def get_default_shell_id(self) -> str:
@@ -52,11 +61,19 @@ class ShellDetector:
52
61
  def _get_platform_candidates(self) -> list[tuple[str, str, str, list[str]]]:
53
62
  """Get shell candidates for current platform.
54
63
 
64
+ On Windows, discovers shells from:
65
+ - Windows Terminal profiles (includes WSL distros)
66
+ - Hardcoded common shells (PowerShell, CMD, Git Bash)
67
+ - Visual Studio Developer shells
68
+
69
+ Note: WSL distros are detected from Windows Terminal profiles only.
70
+ We don't probe inside WSL for individual shells to avoid crossing
71
+ environment boundaries.
72
+
55
73
  Returns:
56
74
  List of (name, id, command, args) tuples.
57
75
  """
58
76
  if sys.platform == "win32":
59
- # Get Windows Terminal profiles first, then hardcoded defaults
60
77
  wt_profiles = self._get_windows_terminal_profiles()
61
78
  hardcoded = [
62
79
  ("PS 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
@@ -65,15 +82,18 @@ class ShellDetector:
65
82
  ("WSL", "wsl", "wsl.exe", []),
66
83
  ("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
67
84
  ]
68
- # Merge WT profiles with hardcoded (dedupe by command)
69
85
  merged = self._merge_candidates(wt_profiles, hardcoded)
70
- # Add VS dev shells (no deduplication - they're unique due to args)
71
86
  vs_shells = self._get_visual_studio_shells()
72
87
  return merged + vs_shells
73
88
  return [
74
89
  ("Bash", "bash", "bash", ["--login"]),
75
90
  ("Zsh", "zsh", "zsh", ["--login"]),
76
91
  ("Fish", "fish", "fish", []),
92
+ ("Nu", "nu", "nu", []),
93
+ ("Ion", "ion", "ion", []),
94
+ ("Dash", "dash", "dash", []),
95
+ ("Ksh", "ksh", "ksh", ["--login"]),
96
+ ("Tcsh", "tcsh", "tcsh", []),
77
97
  ("Sh", "sh", "sh", []),
78
98
  ]
79
99
 
@@ -109,8 +129,20 @@ class ShellDetector:
109
129
  for profile in profile_list:
110
130
  name = profile.get("name", "")
111
131
  commandline = profile.get("commandline", "")
132
+ source = profile.get("source", "")
112
133
 
113
- if not name or not commandline:
134
+ if not name:
135
+ continue
136
+
137
+ # Handle WSL distro profiles (they use source instead of commandline)
138
+ if source == "Windows.Terminal.Wsl" and not commandline:
139
+ # Use wsl.exe -d <distro> to launch the specific distro
140
+ shell_id = self._slugify(name)
141
+ short_name = self._abbreviate_name(name)
142
+ profiles.append((short_name, shell_id, "wsl.exe", ["-d", name]))
143
+ continue
144
+
145
+ if not commandline:
114
146
  continue
115
147
 
116
148
  # Parse commandline into command and args
@@ -149,25 +181,20 @@ class ShellDetector:
149
181
 
150
182
  try:
151
183
  result = subprocess.run(
152
- [
153
- str(vswhere),
154
- "-all",
155
- "-prerelease",
156
- "-property",
157
- "installationPath",
158
- ],
184
+ [str(vswhere), "-all", "-prerelease", "-format", "json"],
159
185
  capture_output=True,
160
186
  text=True,
161
187
  timeout=5,
162
188
  )
163
- vs_paths = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
164
- except (subprocess.TimeoutExpired, OSError) as e:
189
+ vs_installs = json.loads(result.stdout) if result.stdout.strip() else []
190
+ except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError) as e:
165
191
  logger.warning("Failed to run vswhere: %s", e)
166
192
  return []
167
193
 
168
194
  shells = []
169
- for vs_path in vs_paths:
170
- vs_path = Path(vs_path)
195
+ for vs_info in vs_installs:
196
+ vs_path = Path(vs_info.get("installationPath", ""))
197
+ instance_id = vs_info.get("instanceId", "")
171
198
  # Extract VS version and edition from path
172
199
  # e.g., "C:\Program Files\Microsoft Visual Studio\2022\Community"
173
200
  edition = vs_path.name # Community, Professional, Enterprise
@@ -188,18 +215,32 @@ class ShellDetector:
188
215
  )
189
216
  )
190
217
 
191
- # Developer PowerShell
192
- launch_ps = vs_path / "Common7" / "Tools" / "Launch-VsDevShell.ps1"
193
- if launch_ps.exists():
218
+ # Developer PowerShell - find DevShell.dll (location varies by VS version)
219
+ devshell_dll = None
220
+ for dll_path in [
221
+ vs_path / "Common7" / "Tools" / "Microsoft.VisualStudio.DevShell.dll",
222
+ vs_path
223
+ / "Common7"
224
+ / "Tools"
225
+ / "vsdevshell"
226
+ / "Microsoft.VisualStudio.DevShell.dll",
227
+ ]:
228
+ if dll_path.exists():
229
+ devshell_dll = dll_path
230
+ break
231
+
232
+ if devshell_dll and instance_id:
194
233
  name = f"Dev PS {year}"
195
234
  shell_id = f"devps-{year}-{edition.lower()}"
196
- # powershell.exe -NoExit -Command "& 'path\to\Launch-VsDevShell.ps1'"
235
+ # Use forward slashes to avoid backslash escape issues (PowerShell accepts both)
236
+ dll_str = str(devshell_dll).replace("\\", "/")
237
+ cmd = f"Import-Module '{dll_str}'; Enter-VsDevShell {instance_id} -SkipAutomaticLocation"
197
238
  shells.append(
198
239
  (
199
240
  name,
200
241
  shell_id,
201
242
  "powershell.exe",
202
- ["-NoExit", "-Command", f"& '{launch_ps}'"],
243
+ ["-NoExit", "-Command", cmd],
203
244
  )
204
245
  )
205
246
 
@@ -405,16 +446,23 @@ class ShellDetector:
405
446
  """Get shell ID from user's $SHELL environment variable.
406
447
 
407
448
  Returns:
408
- Shell ID if $SHELL is set and matches a known shell, None otherwise.
449
+ Shell ID if $SHELL is set and valid, None otherwise.
450
+ For unknown shells, returns the executable name as the ID.
409
451
  """
410
452
  shell_path = os.environ.get("SHELL", "")
411
453
  if not shell_path:
412
454
  return None
413
455
 
456
+ path = Path(shell_path)
457
+
458
+ # Validate shell exists
459
+ if not path.exists() and not shutil.which(shell_path):
460
+ return None
461
+
414
462
  # Extract shell name from path (e.g., /usr/bin/fish -> fish)
415
- shell_name = Path(shell_path).name.lower()
463
+ shell_name = path.name.lower()
416
464
 
417
- # Map common shell names to IDs
465
+ # Map common shell names to canonical IDs (for consistency)
418
466
  shell_map = {
419
467
  "bash": "bash",
420
468
  "zsh": "zsh",
@@ -422,4 +470,45 @@ class ShellDetector:
422
470
  "sh": "sh",
423
471
  }
424
472
 
425
- return shell_map.get(shell_name)
473
+ # Return known ID or use executable name for unknown shells
474
+ return shell_map.get(shell_name, shell_name)
475
+
476
+ def _create_shell_from_env(self) -> ShellCommand | None:
477
+ """Create a ShellCommand from user's $SHELL environment variable.
478
+
479
+ Returns:
480
+ ShellCommand if $SHELL is set and valid, None otherwise.
481
+ """
482
+ shell_path = os.environ.get("SHELL", "")
483
+ if not shell_path:
484
+ return None
485
+
486
+ path = Path(shell_path)
487
+
488
+ # Validate shell exists
489
+ if not path.exists() and not shutil.which(shell_path):
490
+ return None
491
+
492
+ shell_name = path.name.lower()
493
+
494
+ # Known shells with their display names and args
495
+ known_shells = {
496
+ "bash": ("Bash", ["--login"]),
497
+ "zsh": ("Zsh", ["--login"]),
498
+ "fish": ("Fish", []),
499
+ "sh": ("Sh", []),
500
+ }
501
+
502
+ if shell_name in known_shells:
503
+ display_name, args = known_shells[shell_name]
504
+ else:
505
+ # Unknown shell - use capitalized name, no special args
506
+ display_name = shell_name.capitalize()
507
+ args = []
508
+
509
+ return ShellCommand(
510
+ id=shell_name,
511
+ name=display_name,
512
+ command=shell_path,
513
+ args=tuple(args),
514
+ )
@@ -38,10 +38,7 @@ class InMemorySessionRepository(SessionRepository[PTYHandle]):
38
38
  user_id = str(session.user_id)
39
39
 
40
40
  self._sessions[session_id] = session
41
-
42
- if user_id not in self._user_sessions:
43
- self._user_sessions[user_id] = set()
44
- self._user_sessions[user_id].add(session_id)
41
+ self._user_sessions.setdefault(user_id, set()).add(session_id)
45
42
 
46
43
  def remove(self, session_id: SessionId) -> Session[PTYHandle] | None:
47
44
  """Remove and return a session."""
@@ -44,16 +44,8 @@ class InMemoryTabRepository(TabRepository):
44
44
  session_id = str(tab.session_id)
45
45
 
46
46
  self._tabs[tab_id] = tab
47
-
48
- # Index by user
49
- if user_id not in self._user_tabs:
50
- self._user_tabs[user_id] = set()
51
- self._user_tabs[user_id].add(tab_id)
52
-
53
- # Index by session
54
- if session_id not in self._session_tabs:
55
- self._session_tabs[session_id] = set()
56
- self._session_tabs[session_id].add(tab_id)
47
+ self._user_tabs.setdefault(user_id, set()).add(tab_id)
48
+ self._session_tabs.setdefault(session_id, set()).add(tab_id)
57
49
 
58
50
  def update(self, tab: Tab) -> None:
59
51
  """Update an existing tab (name, last_accessed)."""
porterminal/pty/env.py CHANGED
@@ -1,94 +1,32 @@
1
- """Environment variable sanitization for PTY processes."""
1
+ """Environment variable sanitization for PTY processes.
2
+
3
+ This module re-exports the environment rules from the domain layer
4
+ and provides the build_safe_environment() function for PTY spawning.
5
+ """
2
6
 
3
7
  import os
4
8
 
5
- # Environment variables to allowlist (safe to pass to shell)
6
- SAFE_ENV_VARS: frozenset[str] = frozenset(
7
- {
8
- # System paths
9
- "PATH",
10
- "PATHEXT",
11
- "SYSTEMROOT",
12
- "WINDIR",
13
- "TEMP",
14
- "TMP",
15
- "COMSPEC",
16
- # User directories
17
- "HOME",
18
- "USERPROFILE",
19
- "HOMEDRIVE",
20
- "HOMEPATH",
21
- "LOCALAPPDATA",
22
- "APPDATA",
23
- "PROGRAMFILES",
24
- "PROGRAMFILES(X86)",
25
- "COMMONPROGRAMFILES",
26
- # System info
27
- "COMPUTERNAME",
28
- "USERNAME",
29
- "USERDOMAIN",
30
- "OS",
31
- "PROCESSOR_ARCHITECTURE",
32
- "NUMBER_OF_PROCESSORS",
33
- # Terminal
34
- "TERM",
35
- # Locale settings for proper text rendering
36
- "LANG",
37
- "LC_ALL",
38
- "LC_CTYPE",
39
- }
9
+ from porterminal.domain.values.environment_rules import (
10
+ DEFAULT_BLOCKED_VARS as BLOCKED_ENV_VARS,
40
11
  )
41
-
42
- # Environment variables to explicitly block (secrets)
43
- BLOCKED_ENV_VARS: frozenset[str] = frozenset(
44
- {
45
- # AWS
46
- "AWS_ACCESS_KEY_ID",
47
- "AWS_SECRET_ACCESS_KEY",
48
- "AWS_SESSION_TOKEN",
49
- # Azure
50
- "AZURE_CLIENT_SECRET",
51
- "AZURE_CLIENT_ID",
52
- # Git/GitHub/GitLab
53
- "GH_TOKEN",
54
- "GITHUB_TOKEN",
55
- "GITLAB_TOKEN",
56
- # Package managers
57
- "NPM_TOKEN",
58
- # AI APIs
59
- "ANTHROPIC_API_KEY",
60
- "OPENAI_API_KEY",
61
- "GOOGLE_API_KEY",
62
- # Payment
63
- "STRIPE_SECRET_KEY",
64
- # Database
65
- "DATABASE_URL",
66
- "DB_PASSWORD",
67
- # Generic secrets
68
- "SECRET_KEY",
69
- "API_KEY",
70
- "API_SECRET",
71
- "PRIVATE_KEY",
72
- }
12
+ from porterminal.domain.values.environment_rules import (
13
+ DEFAULT_SAFE_VARS as SAFE_ENV_VARS,
73
14
  )
74
15
 
16
+ # Re-export for backward compatibility
17
+ __all__ = ["SAFE_ENV_VARS", "BLOCKED_ENV_VARS", "build_safe_environment"]
18
+
75
19
 
76
20
  def build_safe_environment() -> dict[str, str]:
77
21
  """Build a sanitized environment for the PTY.
78
22
 
23
+ Uses allowlist approach - only SAFE_ENV_VARS are copied,
24
+ so BLOCKED_ENV_VARS can never be included.
25
+
79
26
  Returns:
80
27
  Dictionary of safe environment variables.
81
28
  """
82
- safe_env: dict[str, str] = {}
83
-
84
- # Copy only allowed variables
85
- for var in SAFE_ENV_VARS:
86
- if var in os.environ:
87
- safe_env[var] = os.environ[var]
88
-
89
- # Ensure blocked vars are not included (defense in depth)
90
- for var in BLOCKED_ENV_VARS:
91
- safe_env.pop(var, None)
29
+ safe_env = {var: os.environ[var] for var in SAFE_ENV_VARS if var in os.environ}
92
30
 
93
31
  # Set custom variables for audit trail
94
32
  safe_env["TERM"] = "xterm-256color"
@@ -5,6 +5,8 @@ import shutil
5
5
  from pathlib import Path
6
6
  from typing import TYPE_CHECKING
7
7
 
8
+ from porterminal.domain.values import MAX_COLS, MAX_ROWS, MIN_COLS, MIN_ROWS
9
+
8
10
  from .env import build_safe_environment
9
11
  from .protocol import PTYBackend
10
12
 
@@ -43,8 +45,8 @@ class SecurePTYManager:
43
45
  """
44
46
  self._backend = backend
45
47
  self.shell_config = shell_config
46
- self.cols = max(40, min(cols, 500))
47
- self.rows = max(10, min(rows, 200))
48
+ self.cols = max(MIN_COLS, min(cols, MAX_COLS))
49
+ self.rows = max(MIN_ROWS, min(rows, MAX_ROWS))
48
50
  self.cwd = cwd
49
51
  self._closed = False
50
52
 
@@ -133,8 +135,8 @@ class SecurePTYManager:
133
135
  if self._closed:
134
136
  return
135
137
 
136
- cols = max(40, min(cols, 500))
137
- rows = max(10, min(rows, 200))
138
+ cols = max(MIN_COLS, min(cols, MAX_COLS))
139
+ rows = max(MIN_ROWS, min(rows, MAX_ROWS))
138
140
 
139
141
  try:
140
142
  self._backend.resize(rows, cols)