ptn 0.1.4__py3-none-any.whl → 0.2.5__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 (33) hide show
  1. porterminal/__init__.py +19 -3
  2. porterminal/_version.py +34 -0
  3. porterminal/app.py +8 -4
  4. porterminal/application/services/terminal_service.py +116 -28
  5. porterminal/asgi.py +8 -3
  6. porterminal/cli/args.py +50 -0
  7. porterminal/composition.py +13 -5
  8. porterminal/config.py +54 -70
  9. porterminal/container.py +0 -11
  10. porterminal/domain/__init__.py +0 -2
  11. porterminal/domain/entities/output_buffer.py +0 -4
  12. porterminal/domain/ports/__init__.py +1 -2
  13. porterminal/domain/ports/pty_port.py +0 -29
  14. porterminal/domain/ports/tab_repository.py +0 -5
  15. porterminal/infrastructure/config/__init__.py +0 -2
  16. porterminal/infrastructure/config/shell_detector.py +1 -0
  17. porterminal/infrastructure/repositories/in_memory_tab.py +0 -4
  18. porterminal/infrastructure/server.py +10 -3
  19. porterminal/static/assets/app-By4EXMHC.js +72 -0
  20. porterminal/static/assets/app-DQePboVd.css +32 -0
  21. porterminal/static/index.html +16 -25
  22. porterminal/updater.py +115 -168
  23. ptn-0.2.5.dist-info/METADATA +148 -0
  24. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/RECORD +27 -29
  25. porterminal/infrastructure/config/yaml_loader.py +0 -34
  26. porterminal/static/assets/app-BQiuUo6Q.css +0 -32
  27. porterminal/static/assets/app-YNN_jEhv.js +0 -71
  28. porterminal/static/manifest.json +0 -31
  29. porterminal/static/sw.js +0 -66
  30. ptn-0.1.4.dist-info/METADATA +0 -191
  31. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/WHEEL +0 -0
  32. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/entry_points.txt +0 -0
  33. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/licenses/LICENSE +0 -0
porterminal/config.py CHANGED
@@ -1,12 +1,14 @@
1
1
  """Configuration loading and validation using Pydantic."""
2
2
 
3
+ import os
3
4
  import shutil
4
- import sys
5
5
  from pathlib import Path
6
6
 
7
7
  import yaml
8
8
  from pydantic import BaseModel, Field, field_validator
9
9
 
10
+ from porterminal.infrastructure.config import ShellDetector
11
+
10
12
 
11
13
  class ServerConfig(BaseModel):
12
14
  """Server configuration."""
@@ -37,67 +39,6 @@ class ShellConfig(BaseModel):
37
39
  raise ValueError(f"Shell executable not found: {v}")
38
40
 
39
41
 
40
- def detect_available_shells() -> list[ShellConfig]:
41
- """Auto-detect available shells based on the platform."""
42
- shells = []
43
-
44
- if sys.platform == "win32":
45
- # Windows shells
46
- candidates = [
47
- ("PowerShell", "powershell", "powershell.exe", ["-NoLogo"]),
48
- ("PowerShell 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
49
- ("CMD", "cmd", "cmd.exe", []),
50
- ("WSL", "wsl", "wsl.exe", []),
51
- ("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
52
- ]
53
- else:
54
- # Unix-like shells (Linux, macOS)
55
- candidates = [
56
- ("Bash", "bash", "bash", ["--login"]),
57
- ("Zsh", "zsh", "zsh", ["--login"]),
58
- ("Fish", "fish", "fish", []),
59
- ("Sh", "sh", "sh", []),
60
- ]
61
-
62
- for name, shell_id, command, args in candidates:
63
- # Check if shell exists
64
- shell_path = shutil.which(command)
65
- if shell_path or Path(command).exists():
66
- shells.append(
67
- ShellConfig(
68
- name=name,
69
- id=shell_id,
70
- command=shell_path or command,
71
- args=args,
72
- )
73
- )
74
-
75
- return shells
76
-
77
-
78
- def get_default_shell_id() -> str:
79
- """Get the default shell ID for the current platform."""
80
- if sys.platform == "win32":
81
- # Prefer PowerShell 7, then PowerShell, then CMD
82
- if shutil.which("pwsh"):
83
- return "pwsh"
84
- if shutil.which("powershell"):
85
- return "powershell"
86
- return "cmd"
87
- elif sys.platform == "darwin":
88
- # macOS defaults to zsh
89
- if shutil.which("zsh"):
90
- return "zsh"
91
- return "bash"
92
- else:
93
- # Linux - prefer bash
94
- if shutil.which("bash"):
95
- return "bash"
96
- if shutil.which("zsh"):
97
- return "zsh"
98
- return "sh"
99
-
100
-
101
42
  class TerminalConfig(BaseModel):
102
43
  """Terminal configuration."""
103
44
 
@@ -118,7 +59,7 @@ class ButtonConfig(BaseModel):
118
59
  """Custom button configuration."""
119
60
 
120
61
  label: str
121
- send: str
62
+ send: str | list[str | int] = "" # string or list of strings/ints (ints = wait ms)
122
63
 
123
64
 
124
65
  class CloudflareConfig(BaseModel):
@@ -128,6 +69,13 @@ class CloudflareConfig(BaseModel):
128
69
  access_aud: str = ""
129
70
 
130
71
 
72
+ class UpdateConfig(BaseModel):
73
+ """Update checker configuration."""
74
+
75
+ notify_on_startup: bool = True # Show "update available" on startup
76
+ check_interval: int = Field(default=86400, ge=0) # Seconds between checks (0 = always)
77
+
78
+
131
79
  class Config(BaseModel):
132
80
  """Application configuration."""
133
81
 
@@ -135,13 +83,46 @@ class Config(BaseModel):
135
83
  terminal: TerminalConfig = Field(default_factory=TerminalConfig)
136
84
  buttons: list[ButtonConfig] = Field(default_factory=list)
137
85
  cloudflare: CloudflareConfig = Field(default_factory=CloudflareConfig)
86
+ update: UpdateConfig = Field(default_factory=UpdateConfig)
87
+
88
+
89
+ def find_config_file(cwd: Path | None = None) -> Path | None:
90
+ """Find config file in standard locations.
138
91
 
92
+ Search order:
93
+ 1. PORTERMINAL_CONFIG_PATH env var (if set)
94
+ 2. ptn.yaml in cwd
95
+ 3. .ptn/ptn.yaml in cwd
96
+ 4. ~/.ptn/ptn.yaml (user home directory)
97
+ """
98
+ # Check env var first
99
+ if env_path := os.environ.get("PORTERMINAL_CONFIG_PATH"):
100
+ return Path(env_path)
139
101
 
140
- def load_config(config_path: Path | str = "config.yaml") -> Config:
102
+ base = cwd or Path.cwd()
103
+
104
+ # Search order: cwd first, then home
105
+ candidates = [
106
+ base / "ptn.yaml",
107
+ base / ".ptn" / "ptn.yaml",
108
+ Path.home() / ".ptn" / "ptn.yaml",
109
+ ]
110
+
111
+ for path in candidates:
112
+ if path.exists():
113
+ return path
114
+
115
+ return None # No config found, use defaults
116
+
117
+
118
+ def load_config(config_path: Path | str | None = None) -> Config:
141
119
  """Load configuration from YAML file."""
142
- config_path = Path(config_path)
120
+ if config_path is None:
121
+ config_path = find_config_file()
143
122
 
144
- if not config_path.exists():
123
+ detector = ShellDetector()
124
+
125
+ if config_path is None or not Path(config_path).exists():
145
126
  data = {}
146
127
  else:
147
128
  with open(config_path, encoding="utf-8") as f:
@@ -162,10 +143,13 @@ def load_config(config_path: Path | str = "config.yaml") -> Config:
162
143
  except Exception:
163
144
  pass
164
145
 
165
- # If no valid shells from config, auto-detect
146
+ # If no valid shells from config, auto-detect using ShellDetector
166
147
  if not valid_shells:
167
- detected = detect_available_shells()
168
- terminal_data["shells"] = [s.model_dump() for s in detected]
148
+ detected = detector.detect_shells()
149
+ terminal_data["shells"] = [
150
+ {"id": s.id, "name": s.name, "command": s.command, "args": list(s.args)}
151
+ for s in detected
152
+ ]
169
153
  else:
170
154
  terminal_data["shells"] = valid_shells
171
155
 
@@ -173,7 +157,7 @@ def load_config(config_path: Path | str = "config.yaml") -> Config:
173
157
  default_shell = terminal_data.get("default_shell", "")
174
158
  shell_ids = [s.get("id") or s.get("name", "").lower() for s in terminal_data.get("shells", [])]
175
159
  if not default_shell or default_shell not in shell_ids:
176
- terminal_data["default_shell"] = get_default_shell_id()
160
+ terminal_data["default_shell"] = detector.get_default_shell_id()
177
161
  # Make sure the default shell is in the list
178
162
  if terminal_data["default_shell"] not in shell_ids and terminal_data.get("shells"):
179
163
  terminal_data["default_shell"] = terminal_data["shells"][0].get("id", "")
porterminal/container.py CHANGED
@@ -52,14 +52,3 @@ class Container:
52
52
 
53
53
  # Working directory
54
54
  cwd: str | None = None
55
-
56
- def get_shell(self, shell_id: str | None = None) -> ShellCommand | None:
57
- """Get shell by ID or default."""
58
- target_id = shell_id or self.default_shell_id
59
-
60
- for shell in self.available_shells:
61
- if shell.id == target_id:
62
- return shell
63
-
64
- # Return first available if target not found
65
- return self.available_shells[0] if self.available_shells else None
@@ -15,7 +15,6 @@ from .entities import (
15
15
 
16
16
  # Ports
17
17
  from .ports import (
18
- PTYFactory,
19
18
  PTYPort,
20
19
  SessionRepository,
21
20
  TabRepository,
@@ -87,5 +86,4 @@ __all__ = [
87
86
  "SessionRepository",
88
87
  "TabRepository",
89
88
  "PTYPort",
90
- "PTYFactory",
91
89
  ]
@@ -67,7 +67,3 @@ class OutputBuffer:
67
67
  """Clear the buffer."""
68
68
  self._buffer.clear()
69
69
  self._size = 0
70
-
71
- def __len__(self) -> int:
72
- """Return number of chunks in buffer."""
73
- return len(self._buffer)
@@ -1,6 +1,6 @@
1
1
  """Domain ports - interfaces for infrastructure to implement."""
2
2
 
3
- from .pty_port import PTYFactory, PTYPort
3
+ from .pty_port import PTYPort
4
4
  from .session_repository import SessionRepository
5
5
  from .tab_repository import TabRepository
6
6
 
@@ -8,5 +8,4 @@ __all__ = [
8
8
  "SessionRepository",
9
9
  "TabRepository",
10
10
  "PTYPort",
11
- "PTYFactory",
12
11
  ]
@@ -2,7 +2,6 @@
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
 
5
- from ..values.shell_command import ShellCommand
6
5
  from ..values.terminal_dimensions import TerminalDimensions
7
6
 
8
7
 
@@ -50,31 +49,3 @@ class PTYPort(ABC):
50
49
  def dimensions(self) -> TerminalDimensions:
51
50
  """Get current terminal dimensions."""
52
51
  ...
53
-
54
-
55
- class PTYFactory(ABC):
56
- """Abstract interface for PTY creation.
57
-
58
- Infrastructure provides platform-specific factory.
59
- """
60
-
61
- @abstractmethod
62
- def create(
63
- self,
64
- shell: ShellCommand,
65
- dimensions: TerminalDimensions,
66
- environment: dict[str, str],
67
- working_directory: str | None = None,
68
- ) -> PTYPort:
69
- """Create and spawn a new PTY.
70
-
71
- Args:
72
- shell: Shell command to run.
73
- dimensions: Initial terminal dimensions.
74
- environment: Sanitized environment variables.
75
- working_directory: Optional working directory.
76
-
77
- Returns:
78
- PTY port implementation.
79
- """
80
- ...
@@ -68,8 +68,3 @@ class TabRepository(ABC):
68
68
  def count_for_user(self, user_id: UserId) -> int:
69
69
  """Get tab count for a user."""
70
70
  ...
71
-
72
- @abstractmethod
73
- def all_tabs(self) -> list[Tab]:
74
- """Get all tabs (for cleanup iteration)."""
75
- ...
@@ -1,9 +1,7 @@
1
1
  """Configuration infrastructure - loading and detection."""
2
2
 
3
3
  from .shell_detector import ShellDetector
4
- from .yaml_loader import YAMLConfigLoader
5
4
 
6
5
  __all__ = [
7
- "YAMLConfigLoader",
8
6
  "ShellDetector",
9
7
  ]
@@ -53,6 +53,7 @@ class ShellDetector:
53
53
  ("PS", "powershell", "powershell.exe", ["-NoLogo"]),
54
54
  ("CMD", "cmd", "cmd.exe", []),
55
55
  ("WSL", "wsl", "wsl.exe", []),
56
+ ("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
56
57
  ]
57
58
  return [
58
59
  ("Bash", "bash", "bash", ["--login"]),
@@ -118,7 +118,3 @@ class InMemoryTabRepository(TabRepository):
118
118
  def count_for_user(self, user_id: UserId) -> int:
119
119
  """Get tab count for a user."""
120
120
  return len(self._user_tabs.get(str(user_id), set()))
121
-
122
- def all_tabs(self) -> list[Tab]:
123
- """Get all tabs."""
124
- return list(self._tabs.values())
@@ -13,6 +13,12 @@ from rich.console import Console
13
13
  console = Console()
14
14
 
15
15
 
16
+ def _is_icmp_warning(line: str) -> bool:
17
+ """Check if line is a harmless ICMP/ping warning from cloudflared."""
18
+ lower = line.lower()
19
+ return "icmp" in lower or "ping_group" in lower or "ping group" in lower
20
+
21
+
16
22
  def wait_for_server(host: str, port: int, timeout: int = 30) -> bool:
17
23
  """Wait for the server to be ready and verify it's Porterminal.
18
24
 
@@ -137,8 +143,8 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
137
143
  url = match.group(0)
138
144
  break
139
145
 
140
- # Also check for errors
141
- if "error" in line.lower():
146
+ # Also check for errors (ignore ICMP/ping warnings - harmless)
147
+ if "error" in line.lower() and not _is_icmp_warning(line):
142
148
  console.print(f"[red]Cloudflared error:[/red] {line.strip()}")
143
149
 
144
150
  return process, url
@@ -155,7 +161,8 @@ def drain_process_output(process: subprocess.Popen) -> None:
155
161
  if not line:
156
162
  break
157
163
  line = line.strip()
158
- if line and "error" in line.lower():
164
+ # Print errors, but ignore harmless ICMP/ping warnings
165
+ if line and "error" in line.lower() and not _is_icmp_warning(line):
159
166
  console.print(f"[red]{line}[/red]")
160
167
  except (OSError, ValueError):
161
168
  pass