ptn 0.1.4__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 (73) hide show
  1. porterminal/__init__.py +288 -0
  2. porterminal/__main__.py +8 -0
  3. porterminal/app.py +381 -0
  4. porterminal/application/__init__.py +1 -0
  5. porterminal/application/ports/__init__.py +7 -0
  6. porterminal/application/ports/connection_port.py +34 -0
  7. porterminal/application/services/__init__.py +13 -0
  8. porterminal/application/services/management_service.py +279 -0
  9. porterminal/application/services/session_service.py +249 -0
  10. porterminal/application/services/tab_service.py +286 -0
  11. porterminal/application/services/terminal_service.py +426 -0
  12. porterminal/asgi.py +38 -0
  13. porterminal/cli/__init__.py +19 -0
  14. porterminal/cli/args.py +91 -0
  15. porterminal/cli/display.py +157 -0
  16. porterminal/composition.py +208 -0
  17. porterminal/config.py +195 -0
  18. porterminal/container.py +65 -0
  19. porterminal/domain/__init__.py +91 -0
  20. porterminal/domain/entities/__init__.py +16 -0
  21. porterminal/domain/entities/output_buffer.py +73 -0
  22. porterminal/domain/entities/session.py +86 -0
  23. porterminal/domain/entities/tab.py +71 -0
  24. porterminal/domain/ports/__init__.py +12 -0
  25. porterminal/domain/ports/pty_port.py +80 -0
  26. porterminal/domain/ports/session_repository.py +58 -0
  27. porterminal/domain/ports/tab_repository.py +75 -0
  28. porterminal/domain/services/__init__.py +18 -0
  29. porterminal/domain/services/environment_sanitizer.py +61 -0
  30. porterminal/domain/services/rate_limiter.py +63 -0
  31. porterminal/domain/services/session_limits.py +104 -0
  32. porterminal/domain/services/tab_limits.py +54 -0
  33. porterminal/domain/values/__init__.py +25 -0
  34. porterminal/domain/values/environment_rules.py +156 -0
  35. porterminal/domain/values/rate_limit_config.py +21 -0
  36. porterminal/domain/values/session_id.py +20 -0
  37. porterminal/domain/values/shell_command.py +37 -0
  38. porterminal/domain/values/tab_id.py +24 -0
  39. porterminal/domain/values/terminal_dimensions.py +45 -0
  40. porterminal/domain/values/user_id.py +25 -0
  41. porterminal/infrastructure/__init__.py +20 -0
  42. porterminal/infrastructure/cloudflared.py +295 -0
  43. porterminal/infrastructure/config/__init__.py +9 -0
  44. porterminal/infrastructure/config/shell_detector.py +84 -0
  45. porterminal/infrastructure/config/yaml_loader.py +34 -0
  46. porterminal/infrastructure/network.py +43 -0
  47. porterminal/infrastructure/registry/__init__.py +5 -0
  48. porterminal/infrastructure/registry/user_connection_registry.py +104 -0
  49. porterminal/infrastructure/repositories/__init__.py +9 -0
  50. porterminal/infrastructure/repositories/in_memory_session.py +70 -0
  51. porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
  52. porterminal/infrastructure/server.py +161 -0
  53. porterminal/infrastructure/web/__init__.py +7 -0
  54. porterminal/infrastructure/web/websocket_adapter.py +78 -0
  55. porterminal/logging_setup.py +48 -0
  56. porterminal/pty/__init__.py +46 -0
  57. porterminal/pty/env.py +97 -0
  58. porterminal/pty/manager.py +163 -0
  59. porterminal/pty/protocol.py +84 -0
  60. porterminal/pty/unix.py +162 -0
  61. porterminal/pty/windows.py +131 -0
  62. porterminal/static/assets/app-BQiuUo6Q.css +32 -0
  63. porterminal/static/assets/app-YNN_jEhv.js +71 -0
  64. porterminal/static/icon.svg +34 -0
  65. porterminal/static/index.html +139 -0
  66. porterminal/static/manifest.json +31 -0
  67. porterminal/static/sw.js +66 -0
  68. porterminal/updater.py +257 -0
  69. ptn-0.1.4.dist-info/METADATA +191 -0
  70. ptn-0.1.4.dist-info/RECORD +73 -0
  71. ptn-0.1.4.dist-info/WHEEL +4 -0
  72. ptn-0.1.4.dist-info/entry_points.txt +2 -0
  73. ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,34 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges">
2
+ <rect width="16" height="16" fill="#1e1e1e"/>
3
+ <rect x="1" y="2" width="1" height="1" fill="#FFFFFF"/>
4
+ <rect x="2" y="2" width="1" height="1" fill="#FFFFFF"/>
5
+ <rect x="3" y="2" width="1" height="1" fill="#FFFFFF"/>
6
+ <rect x="2" y="3" width="1" height="1" fill="#FFFFFF"/>
7
+ <rect x="3" y="3" width="1" height="1" fill="#FFFFFF"/>
8
+ <rect x="4" y="3" width="1" height="1" fill="#FFFFFF"/>
9
+ <rect x="3" y="4" width="1" height="1" fill="#FFFFFF"/>
10
+ <rect x="4" y="4" width="1" height="1" fill="#FFFFFF"/>
11
+ <rect x="5" y="4" width="1" height="1" fill="#FFFFFF"/>
12
+ <rect x="4" y="5" width="1" height="1" fill="#FFFFFF"/>
13
+ <rect x="5" y="5" width="1" height="1" fill="#FFFFFF"/>
14
+ <rect x="6" y="5" width="1" height="1" fill="#FFFFFF"/>
15
+ <rect x="3" y="6" width="1" height="1" fill="#FFFFFF"/>
16
+ <rect x="4" y="6" width="1" height="1" fill="#FFFFFF"/>
17
+ <rect x="5" y="6" width="1" height="1" fill="#FFFFFF"/>
18
+ <rect x="2" y="7" width="1" height="1" fill="#FFFFFF"/>
19
+ <rect x="3" y="7" width="1" height="1" fill="#FFFFFF"/>
20
+ <rect x="4" y="7" width="1" height="1" fill="#FFFFFF"/>
21
+ <rect x="1" y="8" width="1" height="1" fill="#FFFFFF"/>
22
+ <rect x="2" y="8" width="1" height="1" fill="#FFFFFF"/>
23
+ <rect x="3" y="8" width="1" height="1" fill="#FFFFFF"/>
24
+ <rect x="8" y="8" width="1" height="1" fill="#FFFFFF"/>
25
+ <rect x="9" y="8" width="1" height="1" fill="#FFFFFF"/>
26
+ <rect x="10" y="8" width="1" height="1" fill="#FFFFFF"/>
27
+ <rect x="11" y="8" width="1" height="1" fill="#FFFFFF"/>
28
+ <rect x="12" y="8" width="1" height="1" fill="#FFFFFF"/>
29
+ <rect x="8" y="9" width="1" height="1" fill="#FFFFFF"/>
30
+ <rect x="9" y="9" width="1" height="1" fill="#FFFFFF"/>
31
+ <rect x="10" y="9" width="1" height="1" fill="#FFFFFF"/>
32
+ <rect x="11" y="9" width="1" height="1" fill="#FFFFFF"/>
33
+ <rect x="12" y="9" width="1" height="1" fill="#FFFFFF"/>
34
+ </svg>
@@ -0,0 +1,139 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="apple-mobile-web-app-title" content="Terminal">
9
+ <meta name="theme-color" content="#1e1e1e">
10
+ <meta name="mobile-web-app-capable" content="yes">
11
+ <meta name="application-name" content="Terminal">
12
+ <meta name="msapplication-TileColor" content="#1e1e1e">
13
+ <meta name="msapplication-tap-highlight" content="no">
14
+ <meta name="format-detection" content="telephone=no">
15
+ <title>Terminal</title>
16
+
17
+ <!-- PWA -->
18
+ <link rel="manifest" href="/static/manifest.json">
19
+ <link rel="icon" type="image/svg+xml" href="/static/icon.svg">
20
+ <link rel="apple-touch-icon" href="/static/icon-192.png">
21
+ <script type="module" crossorigin src="/static/assets/app-YNN_jEhv.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/static/assets/app-BQiuUo6Q.css">
23
+ </head>
24
+ <body>
25
+ <div id="app">
26
+ <!-- Tab Bar with Shell Selector -->
27
+ <div id="tab-bar">
28
+ <span id="connection-dot"></span>
29
+ <!-- Tabs rendered by JS -->
30
+ <div id="shell-selector">
31
+ <select id="shell-select"><option>...</option></select>
32
+ <button id="btn-info" title="Help">ⓘ</button>
33
+ <button id="btn-textview" title="Plain Text View">≡</button>
34
+ <button id="btn-shutdown" title="Shutdown">⏻</button>
35
+ </div>
36
+ </div>
37
+
38
+ <!-- Terminal Area -->
39
+ <div id="terminal-container">
40
+ <div id="terminal"></div>
41
+ </div>
42
+
43
+ <!-- Bottom Toolbar - Two rows -->
44
+ <div id="toolbar">
45
+ <div class="toolbar-row">
46
+ <button class="tool-btn" id="btn-escape">Esc</button>
47
+ <button class="tool-btn arrow" data-key="ArrowLeft">←</button>
48
+ <button class="tool-btn arrow" data-key="ArrowDown">↓</button>
49
+ <button class="tool-btn arrow" data-key="ArrowUp">↑</button>
50
+ <button class="tool-btn arrow" data-key="ArrowRight">→</button>
51
+ <button class="tool-btn icon" data-key="Tab">⇥</button>
52
+ <button class="tool-btn" data-key="Home">Home</button>
53
+ <button class="tool-btn" data-key="End">End</button>
54
+ <button class="tool-btn icon" id="btn-backspace">⌫</button>
55
+ <button class="tool-btn" data-key="Delete">Del</button>
56
+ <button class="tool-btn icon enter" data-key="Enter">↵</button>
57
+ </div>
58
+ <div class="toolbar-row">
59
+ <button class="tool-btn modifier" id="btn-ctrl">Ctl</button>
60
+ <button class="tool-btn modifier" id="btn-alt">Alt</button>
61
+ <button class="tool-btn modifier" id="btn-shift">Sft</button>
62
+ <button class="tool-btn" data-key="ShiftTab">Sft⇥</button>
63
+ <button class="tool-btn danger" data-key="Ctrl+C">^C</button>
64
+ <button class="tool-btn icon" id="btn-paste">⎘</button>
65
+ <button class="tool-btn" data-key="1">1</button>
66
+ <button class="tool-btn" data-key="2">2</button>
67
+ <button class="tool-btn" data-key="3">3</button>
68
+ <button class="tool-btn" data-key="@">@</button>
69
+ <button class="tool-btn" data-key="/">/</button>
70
+ <button class="tool-btn" data-key="\Enter">\↵</button>
71
+ </div>
72
+ </div>
73
+
74
+ </div>
75
+
76
+ <!-- Floating Copy Button (iOS) -->
77
+ <button id="copy-button">Copy</button>
78
+
79
+ <!-- Connection Lost Overlay -->
80
+ <div id="disconnect-overlay" class="hidden">
81
+ <div id="disconnect-content">
82
+ <div id="disconnect-icon">○</div>
83
+ <div id="disconnect-text">Connection Lost</div>
84
+ <button id="disconnect-retry">Retry</button>
85
+ </div>
86
+ </div>
87
+
88
+ <!-- Help Popup -->
89
+ <div id="help-overlay" class="hidden">
90
+ <div id="help-content">
91
+ <div id="help-header">
92
+ <span>Touch Controls</span>
93
+ <button id="help-close">×</button>
94
+ </div>
95
+ <div id="help-body">
96
+ <div class="help-section">
97
+ <div class="help-title">Swipe</div>
98
+ <div class="help-item"><span class="help-key">←</span> ↑ Last command</div>
99
+ <div class="help-item"><span class="help-key">→</span> ↓ Next command</div>
100
+ </div>
101
+ <div class="help-section">
102
+ <div class="help-title">Selection</div>
103
+ <div class="help-item"><span class="help-key">Long press</span> Start selection</div>
104
+ <div class="help-item"><span class="help-key">Drag</span> Extend selection</div>
105
+ <div class="help-item"><span class="help-key">Double tap</span> Select word</div>
106
+ <div class="help-item"><span class="help-key">Tap</span> Clear selection</div>
107
+ </div>
108
+ <div class="help-section">
109
+ <div class="help-title">Zoom</div>
110
+ <div class="help-item"><span class="help-key">Pinch</span> Change font size</div>
111
+ </div>
112
+ <div class="help-section">
113
+ <div class="help-title">Modifiers (Ctl, Alt, Sft)</div>
114
+ <div class="help-item"><span class="help-key">Tap</span> Sticky (one use)</div>
115
+ <div class="help-item"><span class="help-key">Double tap</span> Lock on/off</div>
116
+ </div>
117
+ <div class="help-section">
118
+ <div class="help-title">Tabs</div>
119
+ <div class="help-item"><span class="help-key">Hold ×</span> Close tab</div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- Plain Text View Popup -->
126
+ <div id="textview-overlay" class="hidden">
127
+ <div id="textview-content">
128
+ <div id="textview-header">
129
+ <span id="textview-title">Text</span>
130
+ <button class="textview-zoom-btn" id="textview-zoom-out">−</button>
131
+ <button class="textview-zoom-btn" id="textview-zoom-in">+</button>
132
+ <button id="textview-close">×</button>
133
+ </div>
134
+ <pre id="textview-body"></pre>
135
+ </div>
136
+ </div>
137
+
138
+ </body>
139
+ </html>
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "Porterminal",
3
+ "short_name": "Terminal",
4
+ "description": "Mobile terminal via Cloudflare Tunnel",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "orientation": "any",
8
+ "background_color": "#1e1e1e",
9
+ "theme_color": "#252526",
10
+ "icons": [
11
+ {
12
+ "src": "/static/icon.svg",
13
+ "sizes": "any",
14
+ "type": "image/svg+xml",
15
+ "purpose": "any maskable"
16
+ }
17
+ ],
18
+ "categories": ["developer", "utilities"],
19
+ "shortcuts": [
20
+ {
21
+ "name": "PowerShell",
22
+ "url": "/?shell=powershell",
23
+ "description": "Open PowerShell terminal"
24
+ },
25
+ {
26
+ "name": "CMD",
27
+ "url": "/?shell=cmd",
28
+ "description": "Open Command Prompt"
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Service Worker for Porterminal PWA
3
+ * Caches UI assets for offline access
4
+ */
5
+
6
+ const CACHE_NAME = 'porterminal-v1';
7
+ const STATIC_ASSETS = [
8
+ '/',
9
+ '/static/index.html',
10
+ '/static/manifest.json',
11
+ '/static/icon.svg',
12
+ ];
13
+
14
+ // Install - cache static assets
15
+ self.addEventListener('install', (event) => {
16
+ event.waitUntil(
17
+ caches.open(CACHE_NAME).then((cache) => {
18
+ return cache.addAll(STATIC_ASSETS);
19
+ })
20
+ );
21
+ self.skipWaiting();
22
+ });
23
+
24
+ // Activate - clean old caches
25
+ self.addEventListener('activate', (event) => {
26
+ event.waitUntil(
27
+ caches.keys().then((keys) => {
28
+ return Promise.all(
29
+ keys.filter((key) => key !== CACHE_NAME)
30
+ .map((key) => caches.delete(key))
31
+ );
32
+ })
33
+ );
34
+ self.clients.claim();
35
+ });
36
+
37
+ // Fetch - serve from cache, fallback to network
38
+ self.addEventListener('fetch', (event) => {
39
+ const url = new URL(event.request.url);
40
+
41
+ // Don't cache WebSocket or API requests
42
+ if (url.pathname.startsWith('/ws') || url.pathname.startsWith('/api')) {
43
+ return;
44
+ }
45
+
46
+ event.respondWith(
47
+ caches.match(event.request).then((cached) => {
48
+ // Return cached version or fetch from network
49
+ return cached || fetch(event.request).then((response) => {
50
+ // Cache successful responses
51
+ if (response.ok && event.request.method === 'GET') {
52
+ const clone = response.clone();
53
+ caches.open(CACHE_NAME).then((cache) => {
54
+ cache.put(event.request, clone);
55
+ });
56
+ }
57
+ return response;
58
+ });
59
+ }).catch(() => {
60
+ // Offline fallback
61
+ if (event.request.destination === 'document') {
62
+ return caches.match('/');
63
+ }
64
+ })
65
+ );
66
+ });
porterminal/updater.py ADDED
@@ -0,0 +1,257 @@
1
+ """Update functionality for Porterminal."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from urllib.error import URLError
10
+ from urllib.request import Request, urlopen
11
+
12
+ from porterminal import __version__
13
+
14
+ PACKAGE_NAME = "porterminal"
15
+ PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
16
+ CACHE_DIR = Path.home() / ".cache" / PACKAGE_NAME
17
+ CACHE_FILE = CACHE_DIR / "update_check.json"
18
+ CACHE_TTL = 86400 # 24 hours
19
+
20
+
21
+ def parse_version(version: str) -> tuple[int, ...]:
22
+ """Parse version string into comparable tuple.
23
+
24
+ Handles PEP 440 versions like "0.1.0", "1.0.0a1", "2.0.0.post1".
25
+
26
+ Args:
27
+ version: Version string.
28
+
29
+ Returns:
30
+ Tuple of integers for comparison (ignores pre/post/dev).
31
+ """
32
+ version = version.lstrip("v")
33
+ # Extract just the release numbers (before any pre/post/dev markers)
34
+ base = version.split("a")[0].split("b")[0].split("rc")[0]
35
+ base = base.split(".dev")[0].split(".post")[0].split("+")[0]
36
+ parts = []
37
+ for p in base.split("."):
38
+ try:
39
+ parts.append(int(p))
40
+ except ValueError:
41
+ break
42
+ return tuple(parts) if parts else (0,)
43
+
44
+
45
+ def get_latest_version() -> str | None:
46
+ """Fetch the latest version from PyPI.
47
+
48
+ Returns:
49
+ Latest version string or None if fetch failed.
50
+ """
51
+ try:
52
+ request = Request(PYPI_URL, headers={"User-Agent": f"{PACKAGE_NAME}/{__version__}"})
53
+ with urlopen(request, timeout=5) as response:
54
+ data = json.loads(response.read().decode())
55
+ return data["info"]["version"]
56
+ except (URLError, json.JSONDecodeError, KeyError, TimeoutError):
57
+ return None
58
+
59
+
60
+ def get_cached_version() -> str | None:
61
+ """Get cached latest version if still valid.
62
+
63
+ Returns:
64
+ Cached version string or None if cache expired/missing.
65
+ """
66
+ if not CACHE_FILE.exists():
67
+ return None
68
+ try:
69
+ data = json.loads(CACHE_FILE.read_text())
70
+ if time.time() - data.get("timestamp", 0) < CACHE_TTL:
71
+ return data.get("version")
72
+ except (json.JSONDecodeError, KeyError):
73
+ pass
74
+ return None
75
+
76
+
77
+ def cache_version(version: str) -> None:
78
+ """Cache the latest version.
79
+
80
+ Args:
81
+ version: Version string to cache.
82
+ """
83
+ try:
84
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
85
+ CACHE_FILE.write_text(
86
+ json.dumps(
87
+ {
88
+ "version": version,
89
+ "timestamp": time.time(),
90
+ }
91
+ )
92
+ )
93
+ except OSError:
94
+ pass # Ignore cache write failures
95
+
96
+
97
+ def check_for_updates(use_cache: bool = True) -> tuple[bool, str | None]:
98
+ """Check if a newer version is available.
99
+
100
+ Args:
101
+ use_cache: Whether to use cached version check.
102
+
103
+ Returns:
104
+ Tuple of (update_available, latest_version).
105
+ """
106
+ # Try cache first
107
+ if use_cache:
108
+ latest = get_cached_version()
109
+ if latest:
110
+ try:
111
+ return parse_version(latest) > parse_version(__version__), latest
112
+ except (ValueError, TypeError):
113
+ pass
114
+
115
+ # Fetch from PyPI
116
+ latest = get_latest_version()
117
+ if latest is None:
118
+ return False, None
119
+
120
+ # Cache the result
121
+ cache_version(latest)
122
+
123
+ try:
124
+ return parse_version(latest) > parse_version(__version__), latest
125
+ except (ValueError, TypeError):
126
+ return False, latest
127
+
128
+
129
+ def detect_install_method() -> str:
130
+ """Detect how porterminal was installed.
131
+
132
+ Returns:
133
+ One of: 'uv', 'pipx', 'pip'
134
+ """
135
+ executable = sys.executable
136
+ file_path = str(Path(__file__).resolve())
137
+
138
+ # Check for uv tool install
139
+ uv_patterns = [
140
+ "/.local/share/uv/tools/",
141
+ "/uv/tools/",
142
+ "\\uv\\tools\\",
143
+ ]
144
+ for pattern in uv_patterns:
145
+ if pattern in executable or pattern in file_path:
146
+ return "uv"
147
+
148
+ # Check for pipx install
149
+ pipx_patterns = [
150
+ "/pipx/venvs/",
151
+ "/.local/share/pipx/",
152
+ "/.local/pipx/",
153
+ "\\pipx\\venvs\\",
154
+ ]
155
+ for pattern in pipx_patterns:
156
+ if pattern in executable or pattern in file_path:
157
+ return "pipx"
158
+
159
+ # Default to pip
160
+ return "pip"
161
+
162
+
163
+ def get_upgrade_command() -> str:
164
+ """Get the appropriate upgrade command for the installation method.
165
+
166
+ Returns:
167
+ Shell command string to upgrade porterminal.
168
+ """
169
+ method = detect_install_method()
170
+ commands = {
171
+ "uv": f"uv tool upgrade {PACKAGE_NAME}",
172
+ "pipx": f"pipx upgrade {PACKAGE_NAME}",
173
+ "pip": f"pip install --upgrade {PACKAGE_NAME}",
174
+ }
175
+ return commands.get(method, commands["pip"])
176
+
177
+
178
+ def update_package() -> bool:
179
+ """Update porterminal to the latest version.
180
+
181
+ Returns:
182
+ True if update succeeded, False otherwise.
183
+ """
184
+ method = detect_install_method()
185
+
186
+ # Check if update is available first
187
+ has_update, latest = check_for_updates(use_cache=False)
188
+ if not has_update:
189
+ if latest:
190
+ print(f"Already at latest version ({__version__})")
191
+ else:
192
+ print("Could not check for updates (network error)")
193
+ return True
194
+
195
+ print(f"Updating {PACKAGE_NAME} {__version__} → {latest}")
196
+
197
+ try:
198
+ if method == "uv":
199
+ if not shutil.which("uv"):
200
+ print("uv not found, falling back to pip")
201
+ method = "pip"
202
+ else:
203
+ cmd = ["uv", "tool", "upgrade", PACKAGE_NAME]
204
+
205
+ if method == "pipx":
206
+ if not shutil.which("pipx"):
207
+ print("pipx not found, falling back to pip")
208
+ method = "pip"
209
+ else:
210
+ cmd = ["pipx", "upgrade", PACKAGE_NAME]
211
+
212
+ if method == "pip":
213
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME]
214
+
215
+ result = subprocess.run(cmd, timeout=120)
216
+
217
+ if result.returncode == 0:
218
+ print(f"Successfully updated to {latest}")
219
+ print("Restart porterminal to use the new version")
220
+ return True
221
+ else:
222
+ print(f"Update failed (exit code {result.returncode})")
223
+ print(f"Try manually: {get_upgrade_command()}")
224
+ return False
225
+
226
+ except subprocess.TimeoutExpired:
227
+ print("Update timed out")
228
+ print(f"Try manually: {get_upgrade_command()}")
229
+ return False
230
+ except FileNotFoundError as e:
231
+ print(f"Command not found: {e}")
232
+ print(f"Try manually: {get_upgrade_command()}")
233
+ return False
234
+
235
+
236
+ def print_update_notice(latest: str) -> None:
237
+ """Print a styled update notice.
238
+
239
+ Args:
240
+ latest: Latest available version.
241
+ """
242
+ from rich.console import Console
243
+ from rich.panel import Panel
244
+
245
+ console = Console(stderr=True)
246
+ upgrade_cmd = get_upgrade_command()
247
+
248
+ console.print()
249
+ console.print(
250
+ Panel(
251
+ f"[yellow]Update available:[/yellow] {__version__} → [green]{latest}[/green]\n"
252
+ f"[dim]Run:[/dim] [cyan]{upgrade_cmd}[/cyan]",
253
+ title="[bold]Porterminal[/bold]",
254
+ border_style="yellow",
255
+ padding=(0, 2),
256
+ )
257
+ )