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.
- porterminal/__init__.py +19 -3
- porterminal/_version.py +34 -0
- porterminal/app.py +8 -4
- porterminal/application/services/terminal_service.py +116 -28
- porterminal/asgi.py +8 -3
- porterminal/cli/args.py +50 -0
- porterminal/composition.py +13 -5
- porterminal/config.py +54 -70
- porterminal/container.py +0 -11
- porterminal/domain/__init__.py +0 -2
- porterminal/domain/entities/output_buffer.py +0 -4
- porterminal/domain/ports/__init__.py +1 -2
- porterminal/domain/ports/pty_port.py +0 -29
- porterminal/domain/ports/tab_repository.py +0 -5
- porterminal/infrastructure/config/__init__.py +0 -2
- porterminal/infrastructure/config/shell_detector.py +1 -0
- porterminal/infrastructure/repositories/in_memory_tab.py +0 -4
- porterminal/infrastructure/server.py +10 -3
- porterminal/static/assets/app-By4EXMHC.js +72 -0
- porterminal/static/assets/app-DQePboVd.css +32 -0
- porterminal/static/index.html +16 -25
- porterminal/updater.py +115 -168
- ptn-0.2.5.dist-info/METADATA +148 -0
- {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/RECORD +27 -29
- porterminal/infrastructure/config/yaml_loader.py +0 -34
- porterminal/static/assets/app-BQiuUo6Q.css +0 -32
- porterminal/static/assets/app-YNN_jEhv.js +0 -71
- porterminal/static/manifest.json +0 -31
- porterminal/static/sw.js +0 -66
- ptn-0.1.4.dist-info/METADATA +0 -191
- {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/WHEEL +0 -0
- {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/entry_points.txt +0 -0
- {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/licenses/LICENSE +0 -0
porterminal/__init__.py
CHANGED
|
@@ -8,7 +8,10 @@ This package provides:
|
|
|
8
8
|
- Configuration system with shell auto-detection
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
try:
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
except ImportError:
|
|
14
|
+
__version__ = "0.0.0-dev" # Fallback before first build
|
|
12
15
|
|
|
13
16
|
import os
|
|
14
17
|
import subprocess
|
|
@@ -139,6 +142,11 @@ def _run_in_background(args) -> int:
|
|
|
139
142
|
def main() -> int:
|
|
140
143
|
"""Main entry point."""
|
|
141
144
|
args = parse_args()
|
|
145
|
+
|
|
146
|
+
# Check for updates (notification only, never exec's)
|
|
147
|
+
from porterminal.updater import check_and_notify
|
|
148
|
+
|
|
149
|
+
check_and_notify()
|
|
142
150
|
verbose = args.verbose
|
|
143
151
|
|
|
144
152
|
# Handle background mode
|
|
@@ -257,10 +265,18 @@ def main() -> int:
|
|
|
257
265
|
try:
|
|
258
266
|
while True:
|
|
259
267
|
if server_process is not None and server_process.poll() is not None:
|
|
260
|
-
|
|
268
|
+
code = server_process.returncode
|
|
269
|
+
if code == 0 or code < 0:
|
|
270
|
+
console.print("\n[dim]Server stopped[/dim]")
|
|
271
|
+
else:
|
|
272
|
+
console.print(f"\n[yellow]Server stopped (exit code {code})[/yellow]")
|
|
261
273
|
break
|
|
262
274
|
if tunnel_process is not None and tunnel_process.poll() is not None:
|
|
263
|
-
|
|
275
|
+
code = tunnel_process.returncode
|
|
276
|
+
if code == 0 or code < 0:
|
|
277
|
+
console.print("\n[dim]Tunnel closed[/dim]")
|
|
278
|
+
else:
|
|
279
|
+
console.print(f"\n[yellow]Tunnel stopped (exit code {code})[/yellow]")
|
|
264
280
|
break
|
|
265
281
|
time.sleep(1)
|
|
266
282
|
|
porterminal/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.2.5'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 5)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
porterminal/app.py
CHANGED
|
@@ -13,6 +13,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
13
13
|
from fastapi.staticfiles import StaticFiles
|
|
14
14
|
from starlette.middleware.base import RequestResponseEndpoint
|
|
15
15
|
|
|
16
|
+
from . import __version__
|
|
16
17
|
from .composition import create_container
|
|
17
18
|
from .container import Container
|
|
18
19
|
from .domain import UserId
|
|
@@ -51,10 +52,10 @@ async def lifespan(app: FastAPI):
|
|
|
51
52
|
security_preflight_checks()
|
|
52
53
|
|
|
53
54
|
# Create DI container with all wired dependencies
|
|
54
|
-
config_path
|
|
55
|
+
# config_path=None uses find_config_file() to search standard locations
|
|
55
56
|
cwd = os.environ.get("PORTERMINAL_CWD")
|
|
56
57
|
|
|
57
|
-
container = create_container(config_path=
|
|
58
|
+
container = create_container(config_path=None, cwd=cwd)
|
|
58
59
|
app.state.container = container
|
|
59
60
|
|
|
60
61
|
# Wire up cascade: when session is destroyed, close associated tabs and broadcast
|
|
@@ -82,7 +83,7 @@ def create_app() -> FastAPI:
|
|
|
82
83
|
app = FastAPI(
|
|
83
84
|
title="Porterminal",
|
|
84
85
|
description="Web-based terminal accessible from phone via Cloudflare Tunnel",
|
|
85
|
-
version=
|
|
86
|
+
version=__version__,
|
|
86
87
|
lifespan=lifespan,
|
|
87
88
|
)
|
|
88
89
|
|
|
@@ -339,13 +340,16 @@ def create_app() -> FastAPI:
|
|
|
339
340
|
# Register connection for broadcasts
|
|
340
341
|
await connection_registry.register(user_id, connection)
|
|
341
342
|
|
|
342
|
-
# Send session info
|
|
343
|
+
# Send session info including current dimensions
|
|
344
|
+
# New clients should adapt to existing dimensions to prevent rendering issues
|
|
343
345
|
await connection.send_message(
|
|
344
346
|
{
|
|
345
347
|
"type": "session_info",
|
|
346
348
|
"session_id": session.session_id,
|
|
347
349
|
"shell": session.shell_id,
|
|
348
350
|
"tab_id": tab.tab_id,
|
|
351
|
+
"cols": session.dimensions.cols,
|
|
352
|
+
"rows": session.dimensions.rows,
|
|
349
353
|
}
|
|
350
354
|
)
|
|
351
355
|
|
|
@@ -63,11 +63,23 @@ class TerminalService:
|
|
|
63
63
|
# Multi-client support: track connections and read loops per session
|
|
64
64
|
self._session_connections: dict[str, set[ConnectionPort]] = {}
|
|
65
65
|
self._session_read_tasks: dict[str, asyncio.Task[None]] = {}
|
|
66
|
+
# Per-session locks to prevent race between buffer replay and broadcast
|
|
67
|
+
self._session_locks: dict[str, asyncio.Lock] = {}
|
|
66
68
|
|
|
67
69
|
# -------------------------------------------------------------------------
|
|
68
70
|
# Multi-client connection tracking
|
|
69
71
|
# -------------------------------------------------------------------------
|
|
70
72
|
|
|
73
|
+
def _get_session_lock(self, session_id: str) -> asyncio.Lock:
|
|
74
|
+
"""Get or create a lock for a session."""
|
|
75
|
+
if session_id not in self._session_locks:
|
|
76
|
+
self._session_locks[session_id] = asyncio.Lock()
|
|
77
|
+
return self._session_locks[session_id]
|
|
78
|
+
|
|
79
|
+
def _cleanup_session_lock(self, session_id: str) -> None:
|
|
80
|
+
"""Remove session lock when no longer needed."""
|
|
81
|
+
self._session_locks.pop(session_id, None)
|
|
82
|
+
|
|
71
83
|
def _register_connection(self, session_id: str, connection: ConnectionPort) -> int:
|
|
72
84
|
"""Register a connection for a session. Returns connection count."""
|
|
73
85
|
if session_id not in self._session_connections:
|
|
@@ -85,8 +97,21 @@ class TerminalService:
|
|
|
85
97
|
del self._session_connections[session_id]
|
|
86
98
|
return count
|
|
87
99
|
|
|
100
|
+
async def _send_to_connections(self, connections: list[ConnectionPort], data: bytes) -> None:
|
|
101
|
+
"""Send data to a list of connections (used with pre-snapshotted list)."""
|
|
102
|
+
for conn in connections:
|
|
103
|
+
try:
|
|
104
|
+
await conn.send_output(data)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass # Connection cleanup handled elsewhere
|
|
107
|
+
|
|
88
108
|
async def _broadcast_output(self, session_id: str, data: bytes) -> None:
|
|
89
|
-
"""Broadcast PTY output to all connections for a session.
|
|
109
|
+
"""Broadcast PTY output to all connections for a session.
|
|
110
|
+
|
|
111
|
+
Note: This is only used for error/status messages where the race
|
|
112
|
+
condition doesn't matter. For PTY data, use _send_to_connections
|
|
113
|
+
with a lock-protected snapshot.
|
|
114
|
+
"""
|
|
90
115
|
connections = self._session_connections.get(session_id, set())
|
|
91
116
|
dead: list[ConnectionPort] = []
|
|
92
117
|
for conn in list(connections): # Copy to avoid mutation during iteration
|
|
@@ -128,31 +153,41 @@ class TerminalService:
|
|
|
128
153
|
session_id = str(session.id)
|
|
129
154
|
clock = AsyncioClock()
|
|
130
155
|
rate_limiter = TokenBucketRateLimiter(self._rate_limit_config, clock)
|
|
156
|
+
lock = self._get_session_lock(session_id)
|
|
157
|
+
|
|
158
|
+
# Register atomically to prevent race with broadcast.
|
|
159
|
+
# Without this lock, a new client could register between add_output and
|
|
160
|
+
# broadcast, receiving the same data twice (once from buffer, once broadcast).
|
|
161
|
+
#
|
|
162
|
+
# Buffer snapshot and read loop start are also under lock to ensure:
|
|
163
|
+
# - Buffer is captured before any new data arrives
|
|
164
|
+
# - Only one read loop starts per session (prevents duplicate PTY reads)
|
|
165
|
+
# - I/O (send_output) happens OUTSIDE lock to avoid blocking other clients
|
|
166
|
+
buffered = None
|
|
167
|
+
async with lock:
|
|
168
|
+
connection_count = self._register_connection(session_id, connection)
|
|
169
|
+
is_first_client = connection_count == 1
|
|
131
170
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"Client connected session_id=%s connection_count=%d",
|
|
138
|
-
session_id,
|
|
139
|
-
connection_count,
|
|
140
|
-
)
|
|
171
|
+
logger.info(
|
|
172
|
+
"Client connected session_id=%s connection_count=%d",
|
|
173
|
+
session_id,
|
|
174
|
+
connection_count,
|
|
175
|
+
)
|
|
141
176
|
|
|
142
|
-
|
|
143
|
-
# First client starts the shared PTY read loop
|
|
177
|
+
# First client starts the shared PTY read loop (under lock to prevent duplicates)
|
|
144
178
|
if is_first_client:
|
|
145
179
|
self._start_broadcast_read_loop(session, session_id)
|
|
146
180
|
|
|
181
|
+
# Snapshot buffer while under lock (ensures consistency with broadcast)
|
|
147
182
|
# Note: session_info is sent by the caller (app.py) to include tab_id
|
|
148
|
-
|
|
149
|
-
# Replay buffered output to THIS connection only (not broadcast)
|
|
150
183
|
if not skip_buffer and not session.output_buffer.is_empty:
|
|
151
184
|
buffered = session.get_buffered_output()
|
|
152
|
-
# Don't clear buffer - other clients may need it too
|
|
153
|
-
if buffered:
|
|
154
|
-
await connection.send_output(buffered)
|
|
155
185
|
|
|
186
|
+
# Replay buffer OUTSIDE lock to avoid blocking other clients during I/O
|
|
187
|
+
if buffered:
|
|
188
|
+
await connection.send_output(buffered)
|
|
189
|
+
|
|
190
|
+
try:
|
|
156
191
|
# Start heartbeat for this connection
|
|
157
192
|
heartbeat_task = asyncio.create_task(self._heartbeat_loop(connection))
|
|
158
193
|
|
|
@@ -173,9 +208,10 @@ class TerminalService:
|
|
|
173
208
|
remaining,
|
|
174
209
|
)
|
|
175
210
|
|
|
176
|
-
# Last client: stop the read loop
|
|
211
|
+
# Last client: stop the read loop and cleanup lock
|
|
177
212
|
if remaining == 0:
|
|
178
213
|
await self._stop_broadcast_read_loop(session_id)
|
|
214
|
+
self._cleanup_session_lock(session_id)
|
|
179
215
|
|
|
180
216
|
def _start_broadcast_read_loop(
|
|
181
217
|
self,
|
|
@@ -212,6 +248,11 @@ class TerminalService:
|
|
|
212
248
|
- Small data (<64 bytes): flush immediately for interactive responsiveness
|
|
213
249
|
- Large data: batch for ~16ms to reduce WebSocket message frequency
|
|
214
250
|
- Flush if batch exceeds 16KB to prevent memory buildup
|
|
251
|
+
|
|
252
|
+
Thread safety:
|
|
253
|
+
- Uses session lock to prevent race between add_output/broadcast and
|
|
254
|
+
new client registration/buffer replay. Lock is held briefly during
|
|
255
|
+
buffer update and connection snapshot, not during actual I/O.
|
|
215
256
|
"""
|
|
216
257
|
# Check if PTY is alive at start
|
|
217
258
|
if not session.pty_handle.is_alive():
|
|
@@ -219,18 +260,29 @@ class TerminalService:
|
|
|
219
260
|
await self._broadcast_output(session_id, b"\r\n[PTY failed to start]\r\n")
|
|
220
261
|
return
|
|
221
262
|
|
|
263
|
+
lock = self._get_session_lock(session_id)
|
|
222
264
|
batch_buffer: list[bytes] = []
|
|
223
265
|
batch_size = 0
|
|
224
266
|
last_flush_time = asyncio.get_running_loop().time()
|
|
225
267
|
|
|
226
268
|
async def flush_batch() -> None:
|
|
269
|
+
"""Flush batched data with lock protection."""
|
|
227
270
|
nonlocal batch_buffer, batch_size, last_flush_time
|
|
228
|
-
if batch_buffer:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
271
|
+
if not batch_buffer:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
combined = b"".join(batch_buffer)
|
|
275
|
+
batch_buffer = []
|
|
276
|
+
batch_size = 0
|
|
277
|
+
last_flush_time = asyncio.get_running_loop().time()
|
|
278
|
+
|
|
279
|
+
# Acquire lock, add to buffer, snapshot connections, release lock
|
|
280
|
+
async with lock:
|
|
281
|
+
session.add_output(combined)
|
|
282
|
+
connections = list(self._session_connections.get(session_id, set()))
|
|
283
|
+
|
|
284
|
+
# Broadcast outside lock (I/O can be slow)
|
|
285
|
+
await self._send_to_connections(connections, combined)
|
|
234
286
|
|
|
235
287
|
def has_connections() -> bool:
|
|
236
288
|
return (
|
|
@@ -242,12 +294,16 @@ class TerminalService:
|
|
|
242
294
|
try:
|
|
243
295
|
data = session.pty_handle.read(4096)
|
|
244
296
|
if data:
|
|
245
|
-
session.add_output(data)
|
|
246
297
|
session.touch(datetime.now(UTC))
|
|
247
298
|
|
|
248
299
|
# Small data (interactive): flush immediately for responsiveness
|
|
249
300
|
if len(data) < INTERACTIVE_THRESHOLD and not batch_buffer:
|
|
250
|
-
|
|
301
|
+
# Acquire lock, add to buffer, snapshot connections
|
|
302
|
+
async with lock:
|
|
303
|
+
session.add_output(data)
|
|
304
|
+
connections = list(self._session_connections.get(session_id, set()))
|
|
305
|
+
# Broadcast outside lock
|
|
306
|
+
await self._send_to_connections(connections, data)
|
|
251
307
|
else:
|
|
252
308
|
# Batch larger data
|
|
253
309
|
batch_buffer.append(data)
|
|
@@ -351,7 +407,7 @@ class TerminalService:
|
|
|
351
407
|
msg_type = message.get("type")
|
|
352
408
|
|
|
353
409
|
if msg_type == "resize":
|
|
354
|
-
await self._handle_resize(session, message)
|
|
410
|
+
await self._handle_resize(session, message, connection)
|
|
355
411
|
elif msg_type == "input":
|
|
356
412
|
await self._handle_json_input(session, message, rate_limiter, connection)
|
|
357
413
|
elif msg_type == "ping":
|
|
@@ -366,8 +422,17 @@ class TerminalService:
|
|
|
366
422
|
self,
|
|
367
423
|
session: Session[PTYPort],
|
|
368
424
|
message: dict[str, Any],
|
|
425
|
+
connection: ConnectionPort,
|
|
369
426
|
) -> None:
|
|
370
|
-
"""Handle terminal resize message.
|
|
427
|
+
"""Handle terminal resize message.
|
|
428
|
+
|
|
429
|
+
Multi-client strategy:
|
|
430
|
+
- When multiple clients share a session, PTY dimensions are locked
|
|
431
|
+
- Only the first client (or when all clients agree) can resize
|
|
432
|
+
- New clients receive current dimensions and must adapt locally
|
|
433
|
+
- This prevents rendering artifacts from dimension mismatches
|
|
434
|
+
"""
|
|
435
|
+
session_id = str(session.id)
|
|
371
436
|
cols = int(message.get("cols", 120))
|
|
372
437
|
rows = int(message.get("rows", 30))
|
|
373
438
|
|
|
@@ -377,6 +442,29 @@ class TerminalService:
|
|
|
377
442
|
if session.dimensions == new_dims:
|
|
378
443
|
return
|
|
379
444
|
|
|
445
|
+
# Check if multiple clients are connected
|
|
446
|
+
connections = self._session_connections.get(session_id, set())
|
|
447
|
+
if len(connections) > 1:
|
|
448
|
+
# Multiple clients: reject resize, tell client to use current dimensions
|
|
449
|
+
logger.info(
|
|
450
|
+
"Resize rejected (multi-client) session_id=%s requested=%dx%d current=%dx%d",
|
|
451
|
+
session.id,
|
|
452
|
+
new_dims.cols,
|
|
453
|
+
new_dims.rows,
|
|
454
|
+
session.dimensions.cols,
|
|
455
|
+
session.dimensions.rows,
|
|
456
|
+
)
|
|
457
|
+
# Send current dimensions back so client can adapt
|
|
458
|
+
await connection.send_message(
|
|
459
|
+
{
|
|
460
|
+
"type": "resize_sync",
|
|
461
|
+
"cols": session.dimensions.cols,
|
|
462
|
+
"rows": session.dimensions.rows,
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
# Single client: allow resize
|
|
380
468
|
session.update_dimensions(new_dims)
|
|
381
469
|
session.pty_handle.resize(new_dims)
|
|
382
470
|
session.touch(datetime.now(UTC))
|
porterminal/asgi.py
CHANGED
|
@@ -17,15 +17,20 @@ def create_app_from_env():
|
|
|
17
17
|
|
|
18
18
|
This is called by uvicorn when using the --factory flag.
|
|
19
19
|
Environment variables:
|
|
20
|
-
PORTERMINAL_CONFIG_PATH: Path to config file (
|
|
20
|
+
PORTERMINAL_CONFIG_PATH: Path to config file (overrides search)
|
|
21
21
|
PORTERMINAL_CWD: Working directory for PTY sessions
|
|
22
|
+
|
|
23
|
+
Config search order (when env var not set):
|
|
24
|
+
1. ptn.yaml in cwd
|
|
25
|
+
2. .ptn/ptn.yaml in cwd
|
|
26
|
+
3. ~/.ptn/ptn.yaml
|
|
22
27
|
"""
|
|
23
28
|
from porterminal.app import create_app
|
|
24
29
|
|
|
25
|
-
config_path = os.environ.get("PORTERMINAL_CONFIG_PATH", "config.yaml")
|
|
26
30
|
cwd = os.environ.get("PORTERMINAL_CWD")
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
# config_path=None uses find_config_file() to search standard locations
|
|
33
|
+
container = create_container(config_path=None, cwd=cwd)
|
|
29
34
|
|
|
30
35
|
# Create app with container
|
|
31
36
|
# Note: The current app.py doesn't accept container yet,
|
porterminal/cli/args.py
CHANGED
|
@@ -61,6 +61,11 @@ def parse_args() -> argparse.Namespace:
|
|
|
61
61
|
action="store_true",
|
|
62
62
|
help="Run in background and return immediately",
|
|
63
63
|
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--init",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Create .ptn/ptn.yaml config file in current directory",
|
|
68
|
+
)
|
|
64
69
|
# Internal argument for background mode communication
|
|
65
70
|
parser.add_argument(
|
|
66
71
|
"--_url-file",
|
|
@@ -88,4 +93,49 @@ def parse_args() -> argparse.Namespace:
|
|
|
88
93
|
success = update_package()
|
|
89
94
|
sys.exit(0 if success else 1)
|
|
90
95
|
|
|
96
|
+
if args.init:
|
|
97
|
+
_init_config()
|
|
98
|
+
sys.exit(0)
|
|
99
|
+
|
|
91
100
|
return args
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
DEFAULT_CONFIG = """\
|
|
104
|
+
# ptn configuration file
|
|
105
|
+
# Docs: https://github.com/lyehe/porterminal/blob/master/docs/configuration.md
|
|
106
|
+
|
|
107
|
+
# Custom buttons (appear in third toolbar row)
|
|
108
|
+
buttons:
|
|
109
|
+
- label: "git"
|
|
110
|
+
send: "git status\\r"
|
|
111
|
+
- label: "build"
|
|
112
|
+
send: "npm run build\\r"
|
|
113
|
+
# Multi-step button with delays (ms):
|
|
114
|
+
# - label: "deploy"
|
|
115
|
+
# send:
|
|
116
|
+
# - "npm run build"
|
|
117
|
+
# - 100
|
|
118
|
+
# - "\\r"
|
|
119
|
+
|
|
120
|
+
# Terminal settings (optional)
|
|
121
|
+
# terminal:
|
|
122
|
+
# default_shell: bash
|
|
123
|
+
# cols: 120
|
|
124
|
+
# rows: 30
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _init_config() -> None:
|
|
129
|
+
"""Create .ptn/ptn.yaml in current directory."""
|
|
130
|
+
from pathlib import Path
|
|
131
|
+
|
|
132
|
+
config_dir = Path.cwd() / ".ptn"
|
|
133
|
+
config_file = config_dir / "ptn.yaml"
|
|
134
|
+
|
|
135
|
+
if config_file.exists():
|
|
136
|
+
print(f"Config already exists: {config_file}")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
config_dir.mkdir(exist_ok=True)
|
|
140
|
+
config_file.write_text(DEFAULT_CONFIG)
|
|
141
|
+
print(f"Created: {config_file}")
|
porterminal/composition.py
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
6
8
|
from porterminal.application.services import (
|
|
7
9
|
ManagementService,
|
|
8
10
|
SessionService,
|
|
9
11
|
TabService,
|
|
10
12
|
TerminalService,
|
|
11
13
|
)
|
|
14
|
+
from porterminal.config import find_config_file
|
|
12
15
|
from porterminal.container import Container
|
|
13
16
|
from porterminal.domain import (
|
|
14
17
|
EnvironmentRules,
|
|
@@ -19,7 +22,7 @@ from porterminal.domain import (
|
|
|
19
22
|
TabLimitChecker,
|
|
20
23
|
TerminalDimensions,
|
|
21
24
|
)
|
|
22
|
-
from porterminal.infrastructure.config import ShellDetector
|
|
25
|
+
from porterminal.infrastructure.config import ShellDetector
|
|
23
26
|
from porterminal.infrastructure.registry import UserConnectionRegistry
|
|
24
27
|
from porterminal.infrastructure.repositories import InMemorySessionRepository, InMemoryTabRepository
|
|
25
28
|
|
|
@@ -107,7 +110,7 @@ class PTYManagerAdapter:
|
|
|
107
110
|
|
|
108
111
|
|
|
109
112
|
def create_container(
|
|
110
|
-
config_path: Path | str =
|
|
113
|
+
config_path: Path | str | None = None,
|
|
111
114
|
cwd: str | None = None,
|
|
112
115
|
) -> Container:
|
|
113
116
|
"""Create the dependency container with all wired dependencies.
|
|
@@ -116,15 +119,20 @@ def create_container(
|
|
|
116
119
|
dependencies are created and wired together.
|
|
117
120
|
|
|
118
121
|
Args:
|
|
119
|
-
config_path: Path to config file.
|
|
122
|
+
config_path: Path to config file, or None to search standard locations.
|
|
120
123
|
cwd: Working directory for PTY sessions.
|
|
121
124
|
|
|
122
125
|
Returns:
|
|
123
126
|
Fully wired dependency container.
|
|
124
127
|
"""
|
|
125
128
|
# Load configuration
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
if config_path is None:
|
|
130
|
+
config_path = find_config_file()
|
|
131
|
+
|
|
132
|
+
config_data: dict = {}
|
|
133
|
+
if config_path is not None and Path(config_path).exists():
|
|
134
|
+
with open(config_path, encoding="utf-8") as f:
|
|
135
|
+
config_data = yaml.safe_load(f) or {}
|
|
128
136
|
|
|
129
137
|
# Detect shells
|
|
130
138
|
detector = ShellDetector()
|