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.
- porterminal/__init__.py +63 -11
- porterminal/_version.py +2 -2
- porterminal/app.py +25 -1
- porterminal/application/ports/__init__.py +2 -0
- porterminal/application/ports/connection_registry_port.py +46 -0
- porterminal/application/services/management_service.py +30 -55
- porterminal/application/services/session_service.py +3 -11
- porterminal/application/services/terminal_service.py +97 -56
- porterminal/cli/args.py +91 -30
- porterminal/cli/display.py +18 -16
- porterminal/cli/script_discovery.py +112 -0
- porterminal/composition.py +8 -7
- porterminal/config.py +12 -2
- porterminal/container.py +4 -0
- porterminal/domain/__init__.py +0 -9
- porterminal/domain/entities/output_buffer.py +56 -1
- porterminal/domain/entities/tab.py +11 -10
- porterminal/domain/services/__init__.py +0 -2
- porterminal/domain/values/__init__.py +0 -4
- porterminal/domain/values/environment_rules.py +3 -0
- porterminal/infrastructure/auth.py +131 -0
- porterminal/infrastructure/cloudflared.py +18 -12
- porterminal/infrastructure/config/shell_detector.py +407 -1
- porterminal/infrastructure/repositories/in_memory_session.py +1 -4
- porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
- porterminal/infrastructure/server.py +28 -3
- porterminal/pty/env.py +16 -78
- porterminal/pty/manager.py +6 -4
- porterminal/static/assets/app-DlWNJWFE.js +87 -0
- porterminal/static/assets/app-xPAM7YhQ.css +1 -0
- porterminal/static/index.html +14 -2
- porterminal/updater.py +13 -5
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/METADATA +84 -23
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/RECORD +37 -34
- porterminal/static/assets/app-By4EXMHC.js +0 -72
- porterminal/static/assets/app-DQePboVd.css +0 -32
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
"""Shell detection for available shells on the system."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
3
8
|
import shutil
|
|
9
|
+
import subprocess
|
|
4
10
|
import sys
|
|
5
11
|
from pathlib import Path
|
|
6
12
|
|
|
7
13
|
from porterminal.domain import ShellCommand
|
|
8
14
|
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
9
17
|
|
|
10
18
|
class ShellDetector:
|
|
11
19
|
"""Detect available shells on the current platform."""
|
|
@@ -13,6 +21,9 @@ class ShellDetector:
|
|
|
13
21
|
def detect_shells(self) -> list[ShellCommand]:
|
|
14
22
|
"""Auto-detect available shells.
|
|
15
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
|
+
|
|
16
27
|
Returns:
|
|
17
28
|
List of detected shell configurations.
|
|
18
29
|
"""
|
|
@@ -31,6 +42,12 @@ class ShellDetector:
|
|
|
31
42
|
)
|
|
32
43
|
)
|
|
33
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
|
+
|
|
34
51
|
return shells
|
|
35
52
|
|
|
36
53
|
def get_default_shell_id(self) -> str:
|
|
@@ -48,13 +65,20 @@ class ShellDetector:
|
|
|
48
65
|
List of (name, id, command, args) tuples.
|
|
49
66
|
"""
|
|
50
67
|
if sys.platform == "win32":
|
|
51
|
-
|
|
68
|
+
# Get Windows Terminal profiles first, then hardcoded defaults
|
|
69
|
+
wt_profiles = self._get_windows_terminal_profiles()
|
|
70
|
+
hardcoded = [
|
|
52
71
|
("PS 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
|
|
53
72
|
("PS", "powershell", "powershell.exe", ["-NoLogo"]),
|
|
54
73
|
("CMD", "cmd", "cmd.exe", []),
|
|
55
74
|
("WSL", "wsl", "wsl.exe", []),
|
|
56
75
|
("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
|
|
57
76
|
]
|
|
77
|
+
# Merge WT profiles with hardcoded (dedupe by command)
|
|
78
|
+
merged = self._merge_candidates(wt_profiles, hardcoded)
|
|
79
|
+
# Add VS dev shells (no deduplication - they're unique due to args)
|
|
80
|
+
vs_shells = self._get_visual_studio_shells()
|
|
81
|
+
return merged + vs_shells
|
|
58
82
|
return [
|
|
59
83
|
("Bash", "bash", "bash", ["--login"]),
|
|
60
84
|
("Zsh", "zsh", "zsh", ["--login"]),
|
|
@@ -62,6 +86,307 @@ class ShellDetector:
|
|
|
62
86
|
("Sh", "sh", "sh", []),
|
|
63
87
|
]
|
|
64
88
|
|
|
89
|
+
def _get_windows_terminal_profiles(self) -> list[tuple[str, str, str, list[str]]]:
|
|
90
|
+
"""Read shell profiles from Windows Terminal settings.json.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of (name, id, command, args) tuples from Windows Terminal.
|
|
94
|
+
"""
|
|
95
|
+
settings_path = Path(
|
|
96
|
+
os.environ.get("LOCALAPPDATA", ""),
|
|
97
|
+
"Packages",
|
|
98
|
+
"Microsoft.WindowsTerminal_8wekyb3d8bbwe",
|
|
99
|
+
"LocalState",
|
|
100
|
+
"settings.json",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not settings_path.exists():
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
# Read and parse JSON (WT uses JSON with comments, strip them)
|
|
108
|
+
content = settings_path.read_text(encoding="utf-8")
|
|
109
|
+
content = self._strip_json_comments(content)
|
|
110
|
+
data = json.loads(content)
|
|
111
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
112
|
+
logger.warning("Failed to read Windows Terminal settings: %s", e)
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
profiles = []
|
|
116
|
+
profile_list = data.get("profiles", {}).get("list", [])
|
|
117
|
+
|
|
118
|
+
for profile in profile_list:
|
|
119
|
+
name = profile.get("name", "")
|
|
120
|
+
commandline = profile.get("commandline", "")
|
|
121
|
+
|
|
122
|
+
if not name or not commandline:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Parse commandline into command and args
|
|
126
|
+
command, args = self._parse_commandline(commandline)
|
|
127
|
+
if not command:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Expand environment variables in command path
|
|
131
|
+
command = os.path.expandvars(command)
|
|
132
|
+
|
|
133
|
+
# Create a slug ID from the name
|
|
134
|
+
shell_id = self._slugify(name)
|
|
135
|
+
|
|
136
|
+
# Shorten display name
|
|
137
|
+
short_name = self._abbreviate_name(name)
|
|
138
|
+
|
|
139
|
+
profiles.append((short_name, shell_id, command, args))
|
|
140
|
+
|
|
141
|
+
return profiles
|
|
142
|
+
|
|
143
|
+
def _get_visual_studio_shells(self) -> list[tuple[str, str, str, list[str]]]:
|
|
144
|
+
"""Detect Visual Studio Developer Command Prompts and PowerShells.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of (name, id, command, args) tuples for VS dev shells.
|
|
148
|
+
"""
|
|
149
|
+
vswhere = Path(
|
|
150
|
+
os.environ.get("ProgramFiles(x86)", ""),
|
|
151
|
+
"Microsoft Visual Studio",
|
|
152
|
+
"Installer",
|
|
153
|
+
"vswhere.exe",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not vswhere.exists():
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
[str(vswhere), "-all", "-prerelease", "-format", "json"],
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=True,
|
|
164
|
+
timeout=5,
|
|
165
|
+
)
|
|
166
|
+
vs_installs = json.loads(result.stdout) if result.stdout.strip() else []
|
|
167
|
+
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError) as e:
|
|
168
|
+
logger.warning("Failed to run vswhere: %s", e)
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
shells = []
|
|
172
|
+
for vs_info in vs_installs:
|
|
173
|
+
vs_path = Path(vs_info.get("installationPath", ""))
|
|
174
|
+
instance_id = vs_info.get("instanceId", "")
|
|
175
|
+
# Extract VS version and edition from path
|
|
176
|
+
# e.g., "C:\Program Files\Microsoft Visual Studio\2022\Community"
|
|
177
|
+
edition = vs_path.name # Community, Professional, Enterprise
|
|
178
|
+
year = vs_path.parent.name # 2022, 2019, etc.
|
|
179
|
+
|
|
180
|
+
# Developer Command Prompt (CMD)
|
|
181
|
+
vsdevcmd = vs_path / "Common7" / "Tools" / "VsDevCmd.bat"
|
|
182
|
+
if vsdevcmd.exists():
|
|
183
|
+
name = f"Dev CMD {year}"
|
|
184
|
+
shell_id = f"devcmd-{year}-{edition.lower()}"
|
|
185
|
+
# cmd.exe /k "path\to\VsDevCmd.bat"
|
|
186
|
+
shells.append(
|
|
187
|
+
(
|
|
188
|
+
name,
|
|
189
|
+
shell_id,
|
|
190
|
+
"cmd.exe",
|
|
191
|
+
["/k", str(vsdevcmd)],
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Developer PowerShell - find DevShell.dll (location varies by VS version)
|
|
196
|
+
devshell_dll = None
|
|
197
|
+
for dll_path in [
|
|
198
|
+
vs_path / "Common7" / "Tools" / "Microsoft.VisualStudio.DevShell.dll",
|
|
199
|
+
vs_path
|
|
200
|
+
/ "Common7"
|
|
201
|
+
/ "Tools"
|
|
202
|
+
/ "vsdevshell"
|
|
203
|
+
/ "Microsoft.VisualStudio.DevShell.dll",
|
|
204
|
+
]:
|
|
205
|
+
if dll_path.exists():
|
|
206
|
+
devshell_dll = dll_path
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
if devshell_dll and instance_id:
|
|
210
|
+
name = f"Dev PS {year}"
|
|
211
|
+
shell_id = f"devps-{year}-{edition.lower()}"
|
|
212
|
+
# Use forward slashes to avoid backslash escape issues (PowerShell accepts both)
|
|
213
|
+
dll_str = str(devshell_dll).replace("\\", "/")
|
|
214
|
+
cmd = f"Import-Module '{dll_str}'; Enter-VsDevShell {instance_id} -SkipAutomaticLocation"
|
|
215
|
+
shells.append(
|
|
216
|
+
(
|
|
217
|
+
name,
|
|
218
|
+
shell_id,
|
|
219
|
+
"powershell.exe",
|
|
220
|
+
["-NoExit", "-Command", cmd],
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return shells
|
|
225
|
+
|
|
226
|
+
def _strip_json_comments(self, content: str) -> str:
|
|
227
|
+
"""Strip comments from JSON content while preserving strings.
|
|
228
|
+
|
|
229
|
+
Handles:
|
|
230
|
+
- Single-line comments: // comment
|
|
231
|
+
- Multi-line comments: /* comment */
|
|
232
|
+
- Preserves // inside quoted strings (e.g., URLs)
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
content: JSON content with possible comments
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
JSON content without comments
|
|
239
|
+
"""
|
|
240
|
+
result = []
|
|
241
|
+
i = 0
|
|
242
|
+
in_string = False
|
|
243
|
+
escape_next = False
|
|
244
|
+
|
|
245
|
+
while i < len(content):
|
|
246
|
+
char = content[i]
|
|
247
|
+
|
|
248
|
+
if escape_next:
|
|
249
|
+
result.append(char)
|
|
250
|
+
escape_next = False
|
|
251
|
+
i += 1
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if char == "\\" and in_string:
|
|
255
|
+
result.append(char)
|
|
256
|
+
escape_next = True
|
|
257
|
+
i += 1
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
if char == '"' and not escape_next:
|
|
261
|
+
in_string = not in_string
|
|
262
|
+
result.append(char)
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
if not in_string:
|
|
267
|
+
# Check for single-line comment
|
|
268
|
+
if content[i : i + 2] == "//":
|
|
269
|
+
# Skip to end of line
|
|
270
|
+
while i < len(content) and content[i] != "\n":
|
|
271
|
+
i += 1
|
|
272
|
+
continue
|
|
273
|
+
# Check for multi-line comment
|
|
274
|
+
if content[i : i + 2] == "/*":
|
|
275
|
+
i += 2
|
|
276
|
+
while i < len(content) - 1 and content[i : i + 2] != "*/":
|
|
277
|
+
i += 1
|
|
278
|
+
i += 2 # Skip */
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
result.append(char)
|
|
282
|
+
i += 1
|
|
283
|
+
|
|
284
|
+
return "".join(result)
|
|
285
|
+
|
|
286
|
+
def _parse_commandline(self, commandline: str) -> tuple[str, list[str]]:
|
|
287
|
+
"""Parse a commandline string into command and args.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
commandline: The command line string (e.g., 'cmd.exe /k "vcvars64.bat"')
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Tuple of (command, args_list)
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
# Use shlex to properly handle quoted arguments
|
|
297
|
+
parts = shlex.split(commandline, posix=False)
|
|
298
|
+
if not parts:
|
|
299
|
+
return "", []
|
|
300
|
+
return parts[0], parts[1:]
|
|
301
|
+
except ValueError:
|
|
302
|
+
# Fallback: simple split on first space
|
|
303
|
+
parts = commandline.split(None, 1)
|
|
304
|
+
if not parts:
|
|
305
|
+
return "", []
|
|
306
|
+
return parts[0], parts[1:] if len(parts) > 1 else []
|
|
307
|
+
|
|
308
|
+
def _abbreviate_name(self, name: str) -> str:
|
|
309
|
+
"""Abbreviate a shell name for display.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
name: Full shell name (e.g., "Windows PowerShell")
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Abbreviated name (e.g., "WinPS")
|
|
316
|
+
"""
|
|
317
|
+
# Common abbreviations
|
|
318
|
+
abbrevs = {
|
|
319
|
+
"Windows PowerShell": "WinPS",
|
|
320
|
+
"Command Prompt": "CMD",
|
|
321
|
+
"PowerShell": "PS",
|
|
322
|
+
"Developer Command Prompt": "DevCMD",
|
|
323
|
+
"Developer PowerShell": "DevPS",
|
|
324
|
+
"Azure Cloud Shell": "Azure",
|
|
325
|
+
"Git Bash": "GitBash",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Check for exact or prefix match
|
|
329
|
+
for full, short in abbrevs.items():
|
|
330
|
+
if name == full or name.startswith(full):
|
|
331
|
+
# Append suffix if there's more (e.g., "for VS 2022")
|
|
332
|
+
suffix = name[len(full) :].strip()
|
|
333
|
+
if suffix:
|
|
334
|
+
# Extract version/year if present
|
|
335
|
+
parts = suffix.split()
|
|
336
|
+
for part in parts:
|
|
337
|
+
if part.isdigit() and len(part) == 4: # Year like 2022
|
|
338
|
+
return f"{short} {part}"
|
|
339
|
+
return short
|
|
340
|
+
|
|
341
|
+
# Fallback: first 8 chars if name is long
|
|
342
|
+
if len(name) > 10:
|
|
343
|
+
return name[:8].strip()
|
|
344
|
+
return name
|
|
345
|
+
|
|
346
|
+
def _slugify(self, name: str) -> str:
|
|
347
|
+
"""Convert a profile name to a slug ID.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
name: Profile name (e.g., "Developer PowerShell for VS 2022")
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Slug ID (e.g., "developer-powershell-for-vs-2022")
|
|
354
|
+
"""
|
|
355
|
+
# Lowercase, replace non-alphanumeric with hyphens, collapse multiple hyphens
|
|
356
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower())
|
|
357
|
+
return slug.strip("-")
|
|
358
|
+
|
|
359
|
+
def _merge_candidates(
|
|
360
|
+
self,
|
|
361
|
+
primary: list[tuple[str, str, str, list[str]]],
|
|
362
|
+
secondary: list[tuple[str, str, str, list[str]]],
|
|
363
|
+
) -> list[tuple[str, str, str, list[str]]]:
|
|
364
|
+
"""Merge two candidate lists, deduplicating by command executable.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
primary: First list (takes priority)
|
|
368
|
+
secondary: Second list (skipped if command already in primary)
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Merged and deduplicated list
|
|
372
|
+
"""
|
|
373
|
+
result = list(primary)
|
|
374
|
+
seen_commands = set()
|
|
375
|
+
|
|
376
|
+
# Track commands from primary (normalize to lowercase basename)
|
|
377
|
+
for _, _, command, _ in primary:
|
|
378
|
+
cmd_name = Path(command).name.lower()
|
|
379
|
+
seen_commands.add(cmd_name)
|
|
380
|
+
|
|
381
|
+
# Add secondary items if command not already seen
|
|
382
|
+
for item in secondary:
|
|
383
|
+
cmd_name = Path(item[2]).name.lower()
|
|
384
|
+
if cmd_name not in seen_commands:
|
|
385
|
+
result.append(item)
|
|
386
|
+
seen_commands.add(cmd_name)
|
|
387
|
+
|
|
388
|
+
return result
|
|
389
|
+
|
|
65
390
|
def _get_windows_default(self) -> str:
|
|
66
391
|
"""Get default shell ID for Windows."""
|
|
67
392
|
if shutil.which("pwsh.exe"):
|
|
@@ -72,14 +397,95 @@ class ShellDetector:
|
|
|
72
397
|
|
|
73
398
|
def _get_macos_default(self) -> str:
|
|
74
399
|
"""Get default shell ID for macOS."""
|
|
400
|
+
# Check user's configured shell from $SHELL
|
|
401
|
+
user_shell = self._get_user_shell_id()
|
|
402
|
+
if user_shell:
|
|
403
|
+
return user_shell
|
|
404
|
+
# Fallback to zsh (macOS default since Catalina)
|
|
75
405
|
if shutil.which("zsh"):
|
|
76
406
|
return "zsh"
|
|
77
407
|
return "bash"
|
|
78
408
|
|
|
79
409
|
def _get_linux_default(self) -> str:
|
|
80
410
|
"""Get default shell ID for Linux."""
|
|
411
|
+
# Check user's configured shell from $SHELL
|
|
412
|
+
user_shell = self._get_user_shell_id()
|
|
413
|
+
if user_shell:
|
|
414
|
+
return user_shell
|
|
415
|
+
# Fallback
|
|
81
416
|
if shutil.which("bash"):
|
|
82
417
|
return "bash"
|
|
83
418
|
if shutil.which("zsh"):
|
|
84
419
|
return "zsh"
|
|
85
420
|
return "sh"
|
|
421
|
+
|
|
422
|
+
def _get_user_shell_id(self) -> str | None:
|
|
423
|
+
"""Get shell ID from user's $SHELL environment variable.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Shell ID if $SHELL is set and valid, None otherwise.
|
|
427
|
+
For unknown shells, returns the executable name as the ID.
|
|
428
|
+
"""
|
|
429
|
+
shell_path = os.environ.get("SHELL", "")
|
|
430
|
+
if not shell_path:
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
path = Path(shell_path)
|
|
434
|
+
|
|
435
|
+
# Validate shell exists
|
|
436
|
+
if not path.exists() and not shutil.which(shell_path):
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
# Extract shell name from path (e.g., /usr/bin/fish -> fish)
|
|
440
|
+
shell_name = path.name.lower()
|
|
441
|
+
|
|
442
|
+
# Map common shell names to canonical IDs (for consistency)
|
|
443
|
+
shell_map = {
|
|
444
|
+
"bash": "bash",
|
|
445
|
+
"zsh": "zsh",
|
|
446
|
+
"fish": "fish",
|
|
447
|
+
"sh": "sh",
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# Return known ID or use executable name for unknown shells
|
|
451
|
+
return shell_map.get(shell_name, shell_name)
|
|
452
|
+
|
|
453
|
+
def _create_shell_from_env(self) -> ShellCommand | None:
|
|
454
|
+
"""Create a ShellCommand from user's $SHELL environment variable.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
ShellCommand if $SHELL is set and valid, None otherwise.
|
|
458
|
+
"""
|
|
459
|
+
shell_path = os.environ.get("SHELL", "")
|
|
460
|
+
if not shell_path:
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
path = Path(shell_path)
|
|
464
|
+
|
|
465
|
+
# Validate shell exists
|
|
466
|
+
if not path.exists() and not shutil.which(shell_path):
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
shell_name = path.name.lower()
|
|
470
|
+
|
|
471
|
+
# Known shells with their display names and args
|
|
472
|
+
known_shells = {
|
|
473
|
+
"bash": ("Bash", ["--login"]),
|
|
474
|
+
"zsh": ("Zsh", ["--login"]),
|
|
475
|
+
"fish": ("Fish", []),
|
|
476
|
+
"sh": ("Sh", []),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if shell_name in known_shells:
|
|
480
|
+
display_name, args = known_shells[shell_name]
|
|
481
|
+
else:
|
|
482
|
+
# Unknown shell - use capitalized name, no special args
|
|
483
|
+
display_name = shell_name.capitalize()
|
|
484
|
+
args = []
|
|
485
|
+
|
|
486
|
+
return ShellCommand(
|
|
487
|
+
id=shell_name,
|
|
488
|
+
name=display_name,
|
|
489
|
+
command=shell_path,
|
|
490
|
+
args=tuple(args),
|
|
491
|
+
)
|
|
@@ -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
|
-
|
|
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)."""
|
|
@@ -76,15 +76,22 @@ def start_server(host: str, port: int, *, verbose: bool = False) -> subprocess.P
|
|
|
76
76
|
"--no-access-log", # Disable access logging
|
|
77
77
|
]
|
|
78
78
|
|
|
79
|
+
# On Windows, use CREATE_NEW_PROCESS_GROUP to prevent Ctrl+C from propagating
|
|
80
|
+
# to the child process - we handle cleanup ourselves
|
|
81
|
+
kwargs = {}
|
|
82
|
+
if sys.platform == "win32":
|
|
83
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
84
|
+
|
|
79
85
|
if verbose:
|
|
80
86
|
# Let output go directly to console
|
|
81
|
-
process = subprocess.Popen(cmd)
|
|
87
|
+
process = subprocess.Popen(cmd, **kwargs)
|
|
82
88
|
else:
|
|
83
89
|
process = subprocess.Popen(
|
|
84
90
|
cmd,
|
|
85
91
|
stdout=subprocess.PIPE,
|
|
86
92
|
stderr=subprocess.STDOUT,
|
|
87
93
|
text=True,
|
|
94
|
+
**kwargs,
|
|
88
95
|
)
|
|
89
96
|
|
|
90
97
|
return process
|
|
@@ -120,6 +127,11 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
|
|
|
120
127
|
# Point to a non-existent config to force quick tunnel mode
|
|
121
128
|
env["TUNNEL_CONFIG"] = os.devnull
|
|
122
129
|
|
|
130
|
+
# On Windows, use CREATE_NEW_PROCESS_GROUP to prevent Ctrl+C from propagating
|
|
131
|
+
kwargs = {}
|
|
132
|
+
if sys.platform == "win32":
|
|
133
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
134
|
+
|
|
123
135
|
process = subprocess.Popen(
|
|
124
136
|
cmd,
|
|
125
137
|
stdout=subprocess.PIPE,
|
|
@@ -127,6 +139,7 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
|
|
|
127
139
|
text=True,
|
|
128
140
|
bufsize=1,
|
|
129
141
|
env=env,
|
|
142
|
+
**kwargs,
|
|
130
143
|
)
|
|
131
144
|
|
|
132
145
|
# Parse URL from cloudflared output (flexible pattern for different Cloudflare domains)
|
|
@@ -151,7 +164,7 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
|
|
|
151
164
|
|
|
152
165
|
|
|
153
166
|
def drain_process_output(process: subprocess.Popen) -> None:
|
|
154
|
-
"""Drain process output silently (only print errors).
|
|
167
|
+
"""Drain process output silently (only print errors and security warnings).
|
|
155
168
|
|
|
156
169
|
Args:
|
|
157
170
|
process: Subprocess to drain output from.
|
|
@@ -161,8 +174,20 @@ def drain_process_output(process: subprocess.Popen) -> None:
|
|
|
161
174
|
if not line:
|
|
162
175
|
break
|
|
163
176
|
line = line.strip()
|
|
177
|
+
if not line:
|
|
178
|
+
continue
|
|
179
|
+
# Always print security warnings and related messages
|
|
180
|
+
if any(
|
|
181
|
+
kw in line.lower()
|
|
182
|
+
for kw in (
|
|
183
|
+
"security warning",
|
|
184
|
+
"authentication attempts",
|
|
185
|
+
"url may have been leaked",
|
|
186
|
+
)
|
|
187
|
+
):
|
|
188
|
+
console.print(f"[bold red]{line}[/bold red]")
|
|
164
189
|
# Print errors, but ignore harmless ICMP/ping warnings
|
|
165
|
-
|
|
190
|
+
elif "error" in line.lower() and not _is_icmp_warning(line):
|
|
166
191
|
console.print(f"[red]{line}[/red]")
|
|
167
192
|
except (OSError, ValueError):
|
|
168
193
|
pass
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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:
|
|
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"
|