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
porterminal/__init__.py CHANGED
@@ -19,7 +19,7 @@ import subprocess
19
19
  import sys
20
20
  import time
21
21
  from pathlib import Path
22
- from threading import Thread
22
+ from threading import Event, Thread
23
23
 
24
24
  from rich.console import Console
25
25
 
@@ -287,9 +287,15 @@ def main() -> int:
287
287
  if tunnel_process is not None:
288
288
  Thread(target=drain_process_output, args=(tunnel_process,), daemon=True).start()
289
289
 
290
- # Wait for Ctrl+C or process exit
290
+ # Use an event for responsive Ctrl+C handling on Windows
291
+ shutdown_event = Event()
292
+
293
+ def signal_handler(signum: int, frame: object) -> None:
294
+ shutdown_event.set()
295
+
296
+ old_handler = signal.signal(signal.SIGINT, signal_handler)
291
297
  try:
292
- while True:
298
+ while not shutdown_event.is_set():
293
299
  if server_process is not None and server_process.poll() is not None:
294
300
  code = server_process.returncode
295
301
  if code == 0 or code < 0:
@@ -304,9 +310,11 @@ def main() -> int:
304
310
  else:
305
311
  console.print(f"\n[yellow]Tunnel stopped (exit code {code})[/yellow]")
306
312
  break
307
- time.sleep(1)
313
+ shutdown_event.wait(0.1)
314
+ finally:
315
+ signal.signal(signal.SIGINT, old_handler)
308
316
 
309
- except KeyboardInterrupt:
317
+ if shutdown_event.is_set():
310
318
  console.print("\n[dim]Shutting down...[/dim]")
311
319
 
312
320
  # Cleanup - terminate gracefully, then kill if needed
porterminal/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.3.2'
32
- __version_tuple__ = version_tuple = (0, 3, 2)
31
+ __version__ = version = '0.4.6'
32
+ __version_tuple__ = version_tuple = (0, 4, 6)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -36,6 +36,23 @@ class ManagementService:
36
36
  self._get_shell = shell_provider
37
37
  self._default_dims = default_dimensions
38
38
 
39
+ async def _send_error(
40
+ self,
41
+ connection: ConnectionPort,
42
+ response_type: str,
43
+ request_id: str,
44
+ error: str,
45
+ ) -> None:
46
+ """Send an error response to a connection."""
47
+ await connection.send_message(
48
+ {
49
+ "type": response_type,
50
+ "request_id": request_id,
51
+ "success": False,
52
+ "error": error,
53
+ }
54
+ )
55
+
39
56
  async def handle_message(
40
57
  self,
41
58
  user_id: UserId,
@@ -76,13 +93,8 @@ class ManagementService:
76
93
  # Get shell
77
94
  shell = self._get_shell(shell_id)
78
95
  if not shell:
79
- await connection.send_message(
80
- {
81
- "type": "create_tab_response",
82
- "request_id": request_id,
83
- "success": False,
84
- "error": "Invalid shell",
85
- }
96
+ await self._send_error(
97
+ connection, "create_tab_response", request_id, "Invalid shell"
86
98
  )
87
99
  return
88
100
 
@@ -127,14 +139,7 @@ class ManagementService:
127
139
 
128
140
  except ValueError as e:
129
141
  logger.warning("Tab creation failed: %s", e)
130
- await connection.send_message(
131
- {
132
- "type": "create_tab_response",
133
- "request_id": request_id,
134
- "success": False,
135
- "error": str(e),
136
- }
137
- )
142
+ await self._send_error(connection, "create_tab_response", request_id, str(e))
138
143
 
139
144
  async def _handle_close_tab(
140
145
  self,
@@ -147,27 +152,13 @@ class ManagementService:
147
152
  tab_id = message.get("tab_id")
148
153
 
149
154
  if not tab_id:
150
- await connection.send_message(
151
- {
152
- "type": "close_tab_response",
153
- "request_id": request_id,
154
- "success": False,
155
- "error": "Missing tab_id",
156
- }
157
- )
155
+ await self._send_error(connection, "close_tab_response", request_id, "Missing tab_id")
158
156
  return
159
157
 
160
158
  # Get tab and session info before closing
161
159
  tab = self._tab_service.get_tab(tab_id)
162
160
  if not tab:
163
- await connection.send_message(
164
- {
165
- "type": "close_tab_response",
166
- "request_id": request_id,
167
- "success": False,
168
- "error": "Tab not found",
169
- }
170
- )
161
+ await self._send_error(connection, "close_tab_response", request_id, "Tab not found")
171
162
  return
172
163
 
173
164
  session_id = tab.session_id
@@ -175,13 +166,8 @@ class ManagementService:
175
166
  # Close the tab
176
167
  closed_tab = self._tab_service.close_tab(tab_id, user_id)
177
168
  if not closed_tab:
178
- await connection.send_message(
179
- {
180
- "type": "close_tab_response",
181
- "request_id": request_id,
182
- "success": False,
183
- "error": "Failed to close tab",
184
- }
169
+ await self._send_error(
170
+ connection, "close_tab_response", request_id, "Failed to close tab"
185
171
  )
186
172
  return
187
173
 
@@ -222,26 +208,16 @@ class ManagementService:
222
208
  new_name = message.get("name")
223
209
 
224
210
  if not tab_id or not new_name:
225
- await connection.send_message(
226
- {
227
- "type": "rename_tab_response",
228
- "request_id": request_id,
229
- "success": False,
230
- "error": "Missing tab_id or name",
231
- }
211
+ await self._send_error(
212
+ connection, "rename_tab_response", request_id, "Missing tab_id or name"
232
213
  )
233
214
  return
234
215
 
235
216
  # Rename the tab
236
217
  tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
237
218
  if not tab:
238
- await connection.send_message(
239
- {
240
- "type": "rename_tab_response",
241
- "request_id": request_id,
242
- "success": False,
243
- "error": "Failed to rename tab",
244
- }
219
+ await self._send_error(
220
+ connection, "rename_tab_response", request_id, "Failed to rename tab"
245
221
  )
246
222
  return
247
223
 
@@ -2,14 +2,11 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- import os
6
5
  import uuid
7
6
  from collections.abc import Awaitable, Callable
8
7
  from datetime import UTC, datetime
9
8
 
10
9
  from porterminal.domain import (
11
- EnvironmentRules,
12
- EnvironmentSanitizer,
13
10
  PTYPort,
14
11
  Session,
15
12
  SessionId,
@@ -32,17 +29,13 @@ class SessionService:
32
29
  def __init__(
33
30
  self,
34
31
  repository: SessionRepository[PTYPort],
35
- pty_factory: Callable[
36
- [ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
37
- ],
32
+ pty_factory: Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort],
38
33
  limit_checker: SessionLimitChecker | None = None,
39
- environment_sanitizer: EnvironmentSanitizer | None = None,
40
34
  working_directory: str | None = None,
41
35
  ) -> None:
42
36
  self._repository = repository
43
37
  self._pty_factory = pty_factory
44
38
  self._limit_checker = limit_checker or SessionLimitChecker()
45
- self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
46
39
  self._cwd = working_directory
47
40
  self._running = False
48
41
  self._cleanup_task: asyncio.Task | None = None
@@ -108,9 +101,8 @@ class SessionService:
108
101
  if not limit_result.allowed:
109
102
  raise ValueError(limit_result.reason)
110
103
 
111
- # Create PTY with sanitized environment
112
- env = self._sanitizer.sanitize(dict(os.environ))
113
- pty = self._pty_factory(shell, dimensions, env, self._cwd)
104
+ # Create PTY (environment sanitization handled by PTY layer)
105
+ pty = self._pty_factory(shell, dimensions, self._cwd)
114
106
 
115
107
  # Create session (starts with 0 clients, caller adds via add_client())
116
108
  now = datetime.now(UTC)
@@ -3,7 +3,9 @@
3
3
  import asyncio
4
4
  import logging
5
5
  import re
6
+ import time
6
7
  from contextlib import suppress
8
+ from dataclasses import dataclass
7
9
  from datetime import UTC, datetime
8
10
  from typing import Any
9
11
 
@@ -19,6 +21,20 @@ from ..ports.connection_port import ConnectionPort
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
 
24
+
25
+ @dataclass
26
+ class ConnectionFlowState:
27
+ """Per-connection flow control state.
28
+
29
+ Implements xterm.js recommended watermark-based flow control.
30
+ When client sends 'pause', we stop sending to that connection.
31
+ When client sends 'ack', we resume sending.
32
+ """
33
+
34
+ paused: bool = False
35
+ pause_time: float | None = None
36
+
37
+
22
38
  # Terminal response sequences that should NOT be written to PTY.
23
39
  # These are responses from the terminal emulator to queries from applications.
24
40
  # If written to PTY, they get echoed back and displayed as garbage.
@@ -31,11 +47,20 @@ TERMINAL_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]*c|\x1b\[[\d;]*R")
31
47
  # Constants
32
48
  HEARTBEAT_INTERVAL = 30 # seconds
33
49
  HEARTBEAT_TIMEOUT = 300 # 5 minutes
34
- PTY_READ_INTERVAL = 0.008 # ~120Hz polling
35
- OUTPUT_BATCH_INTERVAL = 0.016 # ~60Hz output (batch writes for smoother rendering)
50
+
51
+ # Adaptive PTY read interval: fast when data flowing, slow when idle
52
+ PTY_READ_INTERVAL_MIN = 0.001 # 1ms when data is flowing (high throughput)
53
+ PTY_READ_INTERVAL_MAX = 0.008 # 8ms when idle (save CPU)
54
+ PTY_READ_BURST_THRESHOLD = 5 # Consecutive reads with data before going fast
55
+
56
+ # Tiered batch intervals: faster for interactive, slower for bulk
57
+ OUTPUT_BATCH_INTERVAL_INTERACTIVE = 0.004 # 4ms for small data (<256 bytes)
58
+ OUTPUT_BATCH_INTERVAL_BULK = 0.016 # 16ms for larger data
59
+ OUTPUT_BATCH_SIZE_THRESHOLD = 256 # Bytes - threshold for interactive vs bulk
36
60
  OUTPUT_BATCH_MAX_SIZE = 16384 # Flush if batch exceeds 16KB
37
- INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for small interactive data
61
+ INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for very small data
38
62
  MAX_INPUT_SIZE = 4096
63
+ FLOW_PAUSE_TIMEOUT = 5.0 # seconds - auto-resume if client stops sending ACKs (was 15s)
39
64
 
40
65
 
41
66
  class AsyncioClock:
@@ -65,6 +90,8 @@ class TerminalService:
65
90
  self._session_read_tasks: dict[str, asyncio.Task[None]] = {}
66
91
  # Per-session locks to prevent race between buffer replay and broadcast
67
92
  self._session_locks: dict[str, asyncio.Lock] = {}
93
+ # Per-connection flow control state (watermark-based backpressure)
94
+ self._flow_state: dict[ConnectionPort, ConnectionFlowState] = {}
68
95
 
69
96
  # -------------------------------------------------------------------------
70
97
  # Multi-client connection tracking
@@ -72,9 +99,7 @@ class TerminalService:
72
99
 
73
100
  def _get_session_lock(self, session_id: str) -> asyncio.Lock:
74
101
  """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]
102
+ return self._session_locks.setdefault(session_id, asyncio.Lock())
78
103
 
79
104
  def _cleanup_session_lock(self, session_id: str) -> None:
80
105
  """Remove session lock when no longer needed."""
@@ -82,13 +107,17 @@ class TerminalService:
82
107
 
83
108
  def _register_connection(self, session_id: str, connection: ConnectionPort) -> int:
84
109
  """Register a connection for a session. Returns connection count."""
85
- if session_id not in self._session_connections:
86
- self._session_connections[session_id] = set()
87
- self._session_connections[session_id].add(connection)
88
- return len(self._session_connections[session_id])
110
+ connections = self._session_connections.setdefault(session_id, set())
111
+ connections.add(connection)
112
+ # Initialize flow control state for this connection
113
+ self._flow_state[connection] = ConnectionFlowState()
114
+ return len(connections)
89
115
 
90
116
  def _unregister_connection(self, session_id: str, connection: ConnectionPort) -> int:
91
117
  """Unregister a connection. Returns remaining count."""
118
+ # Clean up flow control state
119
+ self._flow_state.pop(connection, None)
120
+
92
121
  if session_id not in self._session_connections:
93
122
  return 0
94
123
  self._session_connections[session_id].discard(connection)
@@ -98,12 +127,27 @@ class TerminalService:
98
127
  return count
99
128
 
100
129
  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)."""
130
+ """Send data to connections, respecting flow control.
131
+
132
+ Skips paused connections (client overwhelmed) but auto-resumes
133
+ after FLOW_PAUSE_TIMEOUT to prevent permanent pause from dead clients.
134
+ """
135
+ current_time = time.time()
102
136
  for conn in connections:
137
+ flow = self._flow_state.get(conn)
138
+ if flow and flow.paused:
139
+ # Check timeout - auto-resume if client stopped responding
140
+ if flow.pause_time and (current_time - flow.pause_time) > FLOW_PAUSE_TIMEOUT:
141
+ flow.paused = False
142
+ flow.pause_time = None
143
+ logger.debug("Auto-resumed paused connection after timeout")
144
+ else:
145
+ continue # Skip paused connection
146
+
103
147
  try:
104
148
  await conn.send_output(data)
105
- except Exception:
106
- pass # Connection cleanup handled elsewhere
149
+ except Exception as e:
150
+ logger.debug("Failed to send output to connection: %s", e)
107
151
 
108
152
  async def _broadcast_output(self, session_id: str, data: bytes) -> None:
109
153
  """Broadcast PTY output to all connections for a session.
@@ -264,6 +308,7 @@ class TerminalService:
264
308
  batch_buffer: list[bytes] = []
265
309
  batch_size = 0
266
310
  last_flush_time = asyncio.get_running_loop().time()
311
+ consecutive_data_reads = 0 # Track consecutive reads with data for adaptive sleep
267
312
 
268
313
  async def flush_batch() -> None:
269
314
  """Flush batched data with lock protection."""
@@ -295,6 +340,10 @@ class TerminalService:
295
340
  data = session.pty_handle.read(4096)
296
341
  if data:
297
342
  session.touch(datetime.now(UTC))
343
+ # Track consecutive reads with data for adaptive sleep
344
+ consecutive_data_reads = min(
345
+ consecutive_data_reads + 1, PTY_READ_BURST_THRESHOLD
346
+ )
298
347
 
299
348
  # Small data (interactive): flush immediately for responsiveness
300
349
  if len(data) < INTERACTIVE_THRESHOLD and not batch_buffer:
@@ -312,6 +361,9 @@ class TerminalService:
312
361
  # Flush if batch is large enough
313
362
  if batch_size >= OUTPUT_BATCH_MAX_SIZE:
314
363
  await flush_batch()
364
+ else:
365
+ # No data - reset burst counter
366
+ consecutive_data_reads = 0
315
367
 
316
368
  except Exception as e:
317
369
  logger.error("PTY read error session_id=%s: %s", session.id, e)
@@ -319,12 +371,25 @@ class TerminalService:
319
371
  await self._broadcast_output(session_id, f"\r\n[PTY error: {e}]\r\n".encode())
320
372
  break
321
373
 
374
+ # Tiered batch interval: faster for small batches, slower for large
375
+ batch_interval = (
376
+ OUTPUT_BATCH_INTERVAL_INTERACTIVE
377
+ if batch_size < OUTPUT_BATCH_SIZE_THRESHOLD
378
+ else OUTPUT_BATCH_INTERVAL_BULK
379
+ )
380
+
322
381
  # Check if we should flush based on time
323
382
  current_time = asyncio.get_running_loop().time()
324
- if batch_buffer and (current_time - last_flush_time) >= OUTPUT_BATCH_INTERVAL:
383
+ if batch_buffer and (current_time - last_flush_time) >= batch_interval:
325
384
  await flush_batch()
326
385
 
327
- await asyncio.sleep(PTY_READ_INTERVAL)
386
+ # Adaptive sleep: fast when data flowing, slow when idle
387
+ sleep_time = (
388
+ PTY_READ_INTERVAL_MIN
389
+ if consecutive_data_reads >= PTY_READ_BURST_THRESHOLD
390
+ else PTY_READ_INTERVAL_MAX
391
+ )
392
+ await asyncio.sleep(sleep_time)
328
393
 
329
394
  # Flush any remaining data
330
395
  await flush_batch()
@@ -358,7 +423,7 @@ class TerminalService:
358
423
  if isinstance(message, bytes):
359
424
  await self._handle_binary_input(session, message, rate_limiter, connection)
360
425
  elif isinstance(message, dict):
361
- await self._handle_json_message(session, message, rate_limiter, connection)
426
+ await self._handle_json_message(session, message, connection)
362
427
 
363
428
  async def _handle_binary_input(
364
429
  self,
@@ -400,7 +465,6 @@ class TerminalService:
400
465
  self,
401
466
  session: Session[PTYPort],
402
467
  message: dict[str, Any],
403
- rate_limiter: TokenBucketRateLimiter,
404
468
  connection: ConnectionPort,
405
469
  ) -> None:
406
470
  """Handle JSON control message."""
@@ -408,13 +472,27 @@ class TerminalService:
408
472
 
409
473
  if msg_type == "resize":
410
474
  await self._handle_resize(session, message, connection)
411
- elif msg_type == "input":
412
- await self._handle_json_input(session, message, rate_limiter, connection)
413
475
  elif msg_type == "ping":
414
476
  await connection.send_message({"type": "pong"})
415
477
  session.touch(datetime.now(UTC))
416
478
  elif msg_type == "pong":
417
479
  session.touch(datetime.now(UTC))
480
+ elif msg_type == "pause":
481
+ # Client is overwhelmed - stop sending data to this connection
482
+ flow = self._flow_state.get(connection)
483
+ if flow:
484
+ flow.paused = True
485
+ flow.pause_time = time.time()
486
+ # Send confirmation so client knows pause was received
487
+ await connection.send_message({"type": "pause_ack"})
488
+ logger.debug("Connection paused (client overwhelmed) session_id=%s", session.id)
489
+ elif msg_type == "ack":
490
+ # Client caught up - resume sending data
491
+ flow = self._flow_state.get(connection)
492
+ if flow and flow.paused:
493
+ flow.paused = False
494
+ flow.pause_time = None
495
+ logger.debug("Connection resumed (client caught up) session_id=%s", session.id)
418
496
  else:
419
497
  logger.warning("Unknown message type session_id=%s type=%s", session.id, msg_type)
420
498
 
@@ -475,40 +553,3 @@ class TerminalService:
475
553
  new_dims.cols,
476
554
  new_dims.rows,
477
555
  )
478
-
479
- async def _handle_json_input(
480
- self,
481
- session: Session[PTYPort],
482
- message: dict[str, Any],
483
- rate_limiter: TokenBucketRateLimiter,
484
- connection: ConnectionPort,
485
- ) -> None:
486
- """Handle JSON-encoded terminal input."""
487
- data = message.get("data", "")
488
-
489
- if len(data) > self._max_input_size:
490
- await connection.send_message(
491
- {
492
- "type": "error",
493
- "message": "Input too large",
494
- }
495
- )
496
- return
497
-
498
- if data:
499
- input_bytes = data.encode("utf-8")
500
- # Filter terminal response sequences
501
- filtered = TERMINAL_RESPONSE_PATTERN.sub(b"", input_bytes)
502
- if not filtered:
503
- return
504
-
505
- if rate_limiter.try_acquire(len(filtered)):
506
- session.pty_handle.write(filtered)
507
- session.touch(datetime.now(UTC))
508
- else:
509
- await connection.send_message(
510
- {
511
- "type": "error",
512
- "message": "Rate limit exceeded",
513
- }
514
- )
porterminal/cli/args.py CHANGED
@@ -22,7 +22,6 @@ def parse_args() -> argparse.Namespace:
22
22
  formatter_class=argparse.RawDescriptionHelpFormatter,
23
23
  )
24
24
  parser.add_argument(
25
- "-V",
26
25
  "--version",
27
26
  action="version",
28
27
  version=f"%(prog)s {__version__}",
@@ -34,6 +33,7 @@ def parse_args() -> argparse.Namespace:
34
33
  help="Starting directory for the shell (default: current directory)",
35
34
  )
36
35
  parser.add_argument(
36
+ "-n",
37
37
  "--no-tunnel",
38
38
  action="store_true",
39
39
  help="Start server only, without Cloudflare tunnel",
@@ -45,12 +45,13 @@ def parse_args() -> argparse.Namespace:
45
45
  help="Show detailed startup logs",
46
46
  )
47
47
  parser.add_argument(
48
- "-U",
48
+ "-u",
49
49
  "--update",
50
50
  action="store_true",
51
51
  help="Update to the latest version",
52
52
  )
53
53
  parser.add_argument(
54
+ "-c",
54
55
  "--check-update",
55
56
  action="store_true",
56
57
  help="Check if a newer version is available",
@@ -62,9 +63,13 @@ def parse_args() -> argparse.Namespace:
62
63
  help="Run in background and return immediately",
63
64
  )
64
65
  parser.add_argument(
66
+ "-i",
65
67
  "--init",
66
- action="store_true",
67
- help="Create .ptn/ptn.yaml config file in current directory",
68
+ nargs="?",
69
+ const=True,
70
+ default=False,
71
+ metavar="URL_OR_PATH",
72
+ help="Create .ptn/ptn.yaml config (optionally from URL or file path)",
68
73
  )
69
74
  parser.add_argument(
70
75
  "-p",
@@ -106,8 +111,8 @@ def parse_args() -> argparse.Namespace:
106
111
  sys.exit(0 if success else 1)
107
112
 
108
113
  if args.init:
109
- _init_config()
110
- sys.exit(0)
114
+ _init_config(args.init if args.init is not True else None)
115
+ # Continue to launch ptn after creating config
111
116
 
112
117
  if args.default_password:
113
118
  _toggle_password_requirement()
@@ -116,45 +121,89 @@ def parse_args() -> argparse.Namespace:
116
121
  return args
117
122
 
118
123
 
119
- DEFAULT_CONFIG = """\
120
- # ptn configuration file
121
- # Docs: https://github.com/lyehe/porterminal/blob/master/docs/configuration.md
122
-
123
- # Custom buttons (appear in third toolbar row)
124
- buttons:
125
- - label: "git"
126
- send: "git status\\r"
127
- - label: "build"
128
- send: "npm run build\\r"
129
- # Multi-step button with delays (ms):
130
- # - label: "deploy"
131
- # send:
132
- # - "npm run build"
133
- # - 100
134
- # - "\\r"
135
-
136
- # Terminal settings (optional)
137
- # terminal:
138
- # default_shell: bash
139
- # cols: 120
140
- # rows: 30
141
- """
142
-
143
-
144
- def _init_config() -> None:
145
- """Create .ptn/ptn.yaml in current directory."""
124
+ def _init_config(source: str | None = None) -> None:
125
+ """Create .ptn/ptn.yaml in current directory.
126
+
127
+ Args:
128
+ source: Optional URL or file path to use as config source.
129
+ If None, auto-discovers scripts and creates default config.
130
+ """
146
131
  from pathlib import Path
132
+ from urllib.error import URLError
133
+ from urllib.request import urlopen
134
+
135
+ import yaml
136
+
137
+ from porterminal.cli.script_discovery import discover_scripts
147
138
 
148
- config_dir = Path.cwd() / ".ptn"
139
+ cwd = Path.cwd()
140
+ config_dir = cwd / ".ptn"
149
141
  config_file = config_dir / "ptn.yaml"
150
142
 
143
+ # If source is provided, fetch/copy it
144
+ if source:
145
+ config_dir.mkdir(exist_ok=True)
146
+
147
+ if source.startswith(("http://", "https://")):
148
+ # Download from URL
149
+ try:
150
+ print(f"Downloading config from {source}...")
151
+ with urlopen(source, timeout=10) as response:
152
+ content = response.read().decode("utf-8")
153
+ config_file.write_text(content)
154
+ print(f"Created: {config_file}")
155
+ except (URLError, OSError, TimeoutError) as e:
156
+ print(f"Error downloading config: {e}")
157
+ return
158
+ else:
159
+ # Copy from local file
160
+ source_path = Path(source).expanduser().resolve()
161
+ if not source_path.exists():
162
+ print(f"Error: File not found: {source_path}")
163
+ return
164
+ try:
165
+ content = source_path.read_text(encoding="utf-8")
166
+ config_file.write_text(content)
167
+ print(f"Created: {config_file} (from {source_path})")
168
+ except OSError as e:
169
+ print(f"Error reading config: {e}")
170
+ return
171
+ return
172
+
173
+ # No source - use auto-discovery
151
174
  if config_file.exists():
152
175
  print(f"Config already exists: {config_file}")
153
176
  return
154
177
 
178
+ # Build config with default buttons (row 1: AI coding tools)
179
+ config: dict = {
180
+ "buttons": [
181
+ {"label": "new", "send": ["/new", 100, "\r"]},
182
+ {"label": "init", "send": ["/init", 100, "\r"]},
183
+ {"label": "resume", "send": ["/resume", 100, "\r"]},
184
+ {"label": "compact", "send": ["/compact", 100, "\r"]},
185
+ {"label": "claude", "send": ["claude", 100, "\r"]},
186
+ {"label": "codex", "send": ["codex", 100, "\r"]},
187
+ ]
188
+ }
189
+
190
+ # Auto-discover project scripts and add to row 2
191
+ discovered = discover_scripts(cwd)
192
+ if discovered:
193
+ config["buttons"].extend(discovered)
194
+
155
195
  config_dir.mkdir(exist_ok=True)
156
- config_file.write_text(DEFAULT_CONFIG)
196
+
197
+ # Write YAML with comment header
198
+ header = "# ptn configuration file\n# Docs: https://github.com/lyehe/porterminal\n\n"
199
+ yaml_content = yaml.safe_dump(config, default_flow_style=False, sort_keys=False)
200
+ config_file.write_text(header + yaml_content)
201
+
157
202
  print(f"Created: {config_file}")
203
+ if discovered:
204
+ print(
205
+ f"Discovered {len(discovered)} project script(s): {', '.join(b['label'] for b in discovered)}"
206
+ )
158
207
 
159
208
 
160
209
  def _toggle_password_requirement() -> None: