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.
- porterminal/__init__.py +13 -5
- porterminal/_version.py +2 -2
- porterminal/application/services/management_service.py +28 -52
- porterminal/application/services/session_service.py +3 -11
- porterminal/application/services/terminal_service.py +97 -56
- porterminal/cli/args.py +84 -35
- porterminal/cli/display.py +18 -16
- porterminal/cli/script_discovery.py +266 -0
- porterminal/composition.py +2 -7
- porterminal/config.py +4 -2
- 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/domain/values/rate_limit_config.py +3 -3
- porterminal/infrastructure/cloudflared.py +13 -11
- porterminal/infrastructure/config/shell_detector.py +113 -24
- porterminal/infrastructure/repositories/in_memory_session.py +1 -4
- porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
- 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 +2 -2
- porterminal/updater.py +13 -5
- {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/METADATA +54 -16
- {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/RECORD +32 -37
- porterminal/static/assets/app-BkHv5qu0.css +0 -32
- porterminal/static/assets/app-CaIGfw7i.js +0 -72
- porterminal/static/assets/app-D9ELFbEO.js +0 -72
- porterminal/static/assets/app-DF3nl_io.js +0 -72
- porterminal/static/assets/app-DQePboVd.css +0 -32
- porterminal/static/assets/app-DoBiVkTD.js +0 -72
- porterminal/static/assets/app-azbHOsRw.css +0 -32
- porterminal/static/assets/app-nMNFwMa6.css +0 -32
- {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/WHEEL +0 -0
- {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/entry_points.txt +0 -0
- {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/licenses/LICENSE +0 -0
porterminal/cli/display.py
CHANGED
|
@@ -58,6 +58,14 @@ def get_caution() -> str:
|
|
|
58
58
|
return CAUTION_DEFAULT
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _apply_gradient(lines: list[str], colors: list[str]) -> list[str]:
|
|
62
|
+
"""Apply color gradient to text lines."""
|
|
63
|
+
return [
|
|
64
|
+
f"[{colors[min(i, len(colors) - 1)]}]{line}[/{colors[min(i, len(colors) - 1)]}]"
|
|
65
|
+
for i, line in enumerate(lines)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
61
69
|
def get_qr_code(url: str) -> str:
|
|
62
70
|
"""Generate QR code as ASCII string.
|
|
63
71
|
|
|
@@ -112,21 +120,15 @@ def display_startup_screen(
|
|
|
112
120
|
else:
|
|
113
121
|
status = "[yellow]●[/yellow] LOCAL MODE"
|
|
114
122
|
|
|
115
|
-
# Build logo with
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
tagline_lines = TAGLINE.split("\n")
|
|
125
|
-
tagline_colors = ["bright_magenta", "magenta"]
|
|
126
|
-
tagline_colored = []
|
|
127
|
-
for i, line in enumerate(tagline_lines):
|
|
128
|
-
color = tagline_colors[i] if i < len(tagline_colors) else tagline_colors[-1]
|
|
129
|
-
tagline_colored.append(f"[{color}]{line}[/{color}]")
|
|
123
|
+
# Build logo and tagline with gradients
|
|
124
|
+
logo_colored = _apply_gradient(
|
|
125
|
+
LOGO.strip().split("\n"),
|
|
126
|
+
["bold bright_cyan", "bright_cyan", "cyan", "bright_blue", "blue"],
|
|
127
|
+
)
|
|
128
|
+
tagline_colored = _apply_gradient(
|
|
129
|
+
TAGLINE.split("\n"),
|
|
130
|
+
["bright_magenta", "magenta"],
|
|
131
|
+
)
|
|
130
132
|
|
|
131
133
|
# Left side content
|
|
132
134
|
left_lines = [
|
|
@@ -136,7 +138,7 @@ def display_startup_screen(
|
|
|
136
138
|
*tagline_colored,
|
|
137
139
|
"",
|
|
138
140
|
f"[bold yellow]{get_caution()}[/bold yellow]",
|
|
139
|
-
"[
|
|
141
|
+
"[bright_red]Use -p for password protection if your screen is exposed[/bright_red]",
|
|
140
142
|
status,
|
|
141
143
|
f"[bold cyan]{url}[/bold cyan]",
|
|
142
144
|
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Auto-discover project scripts for config initialization."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
# Pattern for safe script names (alphanumeric, hyphens, underscores only)
|
|
11
|
+
_SAFE_NAME = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
12
|
+
|
|
13
|
+
# Maximum buttons to return from each discovery function
|
|
14
|
+
_MAX_BUTTONS = 6
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_safe_name(name: str) -> bool:
|
|
18
|
+
"""Check if script name contains only safe characters."""
|
|
19
|
+
return bool(_SAFE_NAME.match(name)) and len(name) <= 50
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_file(base: Path, filenames: list[str]) -> Path | None:
|
|
23
|
+
"""Find the first existing file from a list of candidates."""
|
|
24
|
+
for filename in filenames:
|
|
25
|
+
path = base / filename
|
|
26
|
+
if path.exists():
|
|
27
|
+
return path
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_buttons(
|
|
32
|
+
tasks: dict,
|
|
33
|
+
priority: list[str],
|
|
34
|
+
command_prefix: str,
|
|
35
|
+
*,
|
|
36
|
+
priority_only: bool = False,
|
|
37
|
+
) -> list[dict]:
|
|
38
|
+
"""Build button configs from tasks dict with priority ordering.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
tasks: Dict of task names to their definitions
|
|
42
|
+
priority: List of task names to prioritize
|
|
43
|
+
command_prefix: Command prefix (e.g., "deno task", "task", "just")
|
|
44
|
+
priority_only: If True, only include tasks from priority list
|
|
45
|
+
"""
|
|
46
|
+
buttons = []
|
|
47
|
+
priority_set = set(priority)
|
|
48
|
+
|
|
49
|
+
# Add priority tasks first
|
|
50
|
+
for name in priority:
|
|
51
|
+
if name in tasks and _is_safe_name(name):
|
|
52
|
+
buttons.append({"label": name, "send": f"{command_prefix} {name}\r", "row": 2})
|
|
53
|
+
|
|
54
|
+
# Add remaining tasks (unless priority_only is set)
|
|
55
|
+
if not priority_only:
|
|
56
|
+
for name in tasks:
|
|
57
|
+
if name not in priority_set and _is_safe_name(name) and len(buttons) < _MAX_BUTTONS:
|
|
58
|
+
buttons.append({"label": name, "send": f"{command_prefix} {name}\r", "row": 2})
|
|
59
|
+
|
|
60
|
+
return buttons[:_MAX_BUTTONS]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def discover_scripts(cwd: Path | None = None) -> list[dict]:
|
|
64
|
+
"""Discover project scripts in current directory.
|
|
65
|
+
|
|
66
|
+
Returns list of button configs: [{"label": "build", "send": "npm run build\\r", "row": 2}]
|
|
67
|
+
Only includes scripts explicitly defined in project files.
|
|
68
|
+
"""
|
|
69
|
+
base = cwd or Path.cwd()
|
|
70
|
+
buttons = []
|
|
71
|
+
|
|
72
|
+
# Check each project type (only those with explicit scripts)
|
|
73
|
+
# Order matters: first match wins for deduplication
|
|
74
|
+
buttons.extend(_discover_npm_scripts(base)) # Also handles Bun
|
|
75
|
+
buttons.extend(_discover_deno_tasks(base))
|
|
76
|
+
buttons.extend(_discover_python_scripts(base))
|
|
77
|
+
buttons.extend(_discover_makefile_targets(base))
|
|
78
|
+
buttons.extend(_discover_just_recipes(base))
|
|
79
|
+
buttons.extend(_discover_taskfile_tasks(base))
|
|
80
|
+
|
|
81
|
+
# Dedupe by label, keep first occurrence
|
|
82
|
+
unique: dict[str, dict] = {}
|
|
83
|
+
for btn in buttons:
|
|
84
|
+
unique.setdefault(btn["label"], btn)
|
|
85
|
+
return list(unique.values())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _discover_npm_scripts(base: Path) -> list[dict]:
|
|
89
|
+
"""Extract scripts from package.json.
|
|
90
|
+
|
|
91
|
+
Uses 'bun run' if bun.lockb exists, otherwise 'npm run'.
|
|
92
|
+
Only includes scripts from the priority list (build, dev, start, etc.).
|
|
93
|
+
"""
|
|
94
|
+
pkg_file = base / "package.json"
|
|
95
|
+
if not pkg_file.exists():
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(pkg_file.read_text(encoding="utf-8"))
|
|
100
|
+
scripts = data.get("scripts", {})
|
|
101
|
+
|
|
102
|
+
# Detect package manager: bun if bun.lockb exists
|
|
103
|
+
runner = "bun run" if (base / "bun.lockb").exists() else "npm run"
|
|
104
|
+
priority = ["build", "dev", "start", "test", "lint", "format", "watch"]
|
|
105
|
+
|
|
106
|
+
return _build_buttons(scripts, priority, runner, priority_only=True)
|
|
107
|
+
except Exception:
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _discover_python_scripts(base: Path) -> list[dict]:
|
|
112
|
+
"""Extract scripts from pyproject.toml.
|
|
113
|
+
|
|
114
|
+
Checks [project.scripts] (PEP 621) first, then [tool.poetry.scripts].
|
|
115
|
+
Takes up to 4 from each source, deduplicates, and caps at 6 total.
|
|
116
|
+
"""
|
|
117
|
+
toml_file = base / "pyproject.toml"
|
|
118
|
+
if not toml_file.exists():
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
data = tomllib.loads(toml_file.read_text(encoding="utf-8"))
|
|
123
|
+
buttons = []
|
|
124
|
+
|
|
125
|
+
# Check [project.scripts] (PEP 621) - take up to 4
|
|
126
|
+
project_scripts = data.get("project", {}).get("scripts", {})
|
|
127
|
+
for name in list(project_scripts.keys())[:4]:
|
|
128
|
+
if _is_safe_name(name):
|
|
129
|
+
buttons.append({"label": name, "send": f"{name}\r", "row": 2})
|
|
130
|
+
|
|
131
|
+
# Check [tool.poetry.scripts] - take up to 4, skip duplicates
|
|
132
|
+
existing_labels = {b["label"] for b in buttons}
|
|
133
|
+
poetry_scripts = data.get("tool", {}).get("poetry", {}).get("scripts", {})
|
|
134
|
+
for name in list(poetry_scripts.keys())[:4]:
|
|
135
|
+
if _is_safe_name(name) and name not in existing_labels:
|
|
136
|
+
buttons.append({"label": name, "send": f"{name}\r", "row": 2})
|
|
137
|
+
|
|
138
|
+
return buttons[:_MAX_BUTTONS]
|
|
139
|
+
except Exception:
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _discover_makefile_targets(base: Path) -> list[dict]:
|
|
144
|
+
"""Extract targets from Makefile."""
|
|
145
|
+
makefile = base / "Makefile"
|
|
146
|
+
if not makefile.exists():
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
content = makefile.read_text(encoding="utf-8")
|
|
151
|
+
# Match target definitions: "target:" at start of line
|
|
152
|
+
# Regex excludes targets starting with . (internal targets like .PHONY)
|
|
153
|
+
pattern = r"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:"
|
|
154
|
+
targets = re.findall(pattern, content, re.MULTILINE)
|
|
155
|
+
|
|
156
|
+
# Convert list to dict for _build_buttons compatibility
|
|
157
|
+
targets_dict = {t: True for t in targets}
|
|
158
|
+
priority = ["build", "test", "run", "clean", "install", "dev", "lint", "all"]
|
|
159
|
+
|
|
160
|
+
return _build_buttons(targets_dict, priority, "make")
|
|
161
|
+
except Exception:
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _discover_deno_tasks(base: Path) -> list[dict]:
|
|
166
|
+
"""Extract tasks from deno.json or deno.jsonc."""
|
|
167
|
+
deno_file = _find_file(base, ["deno.json", "deno.jsonc"])
|
|
168
|
+
if not deno_file:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
content = deno_file.read_text(encoding="utf-8")
|
|
173
|
+
if deno_file.suffix == ".jsonc":
|
|
174
|
+
content = _strip_json_comments(content)
|
|
175
|
+
|
|
176
|
+
tasks = json.loads(content).get("tasks", {})
|
|
177
|
+
priority = ["build", "dev", "start", "test", "lint", "format", "check"]
|
|
178
|
+
|
|
179
|
+
return _build_buttons(tasks, priority, "deno task")
|
|
180
|
+
except Exception:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _strip_json_comments(content: str) -> str:
|
|
185
|
+
"""Strip comments from JSON content (for .jsonc files)."""
|
|
186
|
+
result = []
|
|
187
|
+
i = 0
|
|
188
|
+
in_string = False
|
|
189
|
+
escape_next = False
|
|
190
|
+
|
|
191
|
+
while i < len(content):
|
|
192
|
+
char = content[i]
|
|
193
|
+
|
|
194
|
+
if escape_next:
|
|
195
|
+
result.append(char)
|
|
196
|
+
escape_next = False
|
|
197
|
+
i += 1
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if char == "\\" and in_string:
|
|
201
|
+
result.append(char)
|
|
202
|
+
escape_next = True
|
|
203
|
+
i += 1
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if char == '"' and not escape_next:
|
|
207
|
+
in_string = not in_string
|
|
208
|
+
result.append(char)
|
|
209
|
+
i += 1
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
if not in_string:
|
|
213
|
+
# Single-line comment
|
|
214
|
+
if content[i : i + 2] == "//":
|
|
215
|
+
while i < len(content) and content[i] != "\n":
|
|
216
|
+
i += 1
|
|
217
|
+
continue
|
|
218
|
+
# Multi-line comment
|
|
219
|
+
if content[i : i + 2] == "/*":
|
|
220
|
+
i += 2
|
|
221
|
+
while i < len(content) - 1 and content[i : i + 2] != "*/":
|
|
222
|
+
i += 1
|
|
223
|
+
i += 2
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
result.append(char)
|
|
227
|
+
i += 1
|
|
228
|
+
|
|
229
|
+
return "".join(result)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _discover_just_recipes(base: Path) -> list[dict]:
|
|
233
|
+
"""Extract recipes from justfile."""
|
|
234
|
+
justfile = _find_file(base, ["justfile", "Justfile", ".justfile"])
|
|
235
|
+
if not justfile:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
content = justfile.read_text(encoding="utf-8")
|
|
240
|
+
# Match recipe definitions: "recipe:" or "recipe arg:" at start of line
|
|
241
|
+
# Exclude private recipes (starting with _) and recipes with @ prefix
|
|
242
|
+
pattern = r"^([a-zA-Z][a-zA-Z0-9_-]*)\s*(?:[^:]*)?:"
|
|
243
|
+
recipes = re.findall(pattern, content, re.MULTILINE)
|
|
244
|
+
|
|
245
|
+
# Convert list to dict for _build_buttons compatibility
|
|
246
|
+
recipes_dict = {r: True for r in recipes}
|
|
247
|
+
priority = ["build", "test", "run", "dev", "check", "lint", "fmt", "clean"]
|
|
248
|
+
|
|
249
|
+
return _build_buttons(recipes_dict, priority, "just")
|
|
250
|
+
except Exception:
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _discover_taskfile_tasks(base: Path) -> list[dict]:
|
|
255
|
+
"""Extract tasks from Taskfile.yml."""
|
|
256
|
+
taskfile = _find_file(base, ["Taskfile.yml", "Taskfile.yaml", "taskfile.yml", "taskfile.yaml"])
|
|
257
|
+
if not taskfile:
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
tasks = yaml.safe_load(taskfile.read_text(encoding="utf-8")).get("tasks", {})
|
|
262
|
+
priority = ["build", "test", "run", "dev", "lint", "fmt", "clean", "default"]
|
|
263
|
+
|
|
264
|
+
return _build_buttons(tasks, priority, "task")
|
|
265
|
+
except Exception:
|
|
266
|
+
return []
|
porterminal/composition.py
CHANGED
|
@@ -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,
|
|
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)
|
|
@@ -173,7 +169,6 @@ def create_container(
|
|
|
173
169
|
repository=session_repository,
|
|
174
170
|
pty_factory=pty_factory,
|
|
175
171
|
limit_checker=SessionLimitChecker(),
|
|
176
|
-
environment_sanitizer=EnvironmentSanitizer(EnvironmentRules()),
|
|
177
172
|
working_directory=cwd,
|
|
178
173
|
)
|
|
179
174
|
|
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=
|
|
47
|
-
rows: int = Field(default=30, ge=
|
|
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):
|
porterminal/domain/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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",
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
DEFAULT_RATE =
|
|
7
|
-
DEFAULT_BURST =
|
|
5
|
+
# Defaults: 1KB/s sustained, 16KB burst (allows reasonable paste operations)
|
|
6
|
+
DEFAULT_RATE = 1000.0
|
|
7
|
+
DEFAULT_BURST = 16384
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@dataclass(frozen=True, slots=True)
|