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.
- porterminal/__init__.py +288 -0
- porterminal/__main__.py +8 -0
- porterminal/app.py +381 -0
- porterminal/application/__init__.py +1 -0
- porterminal/application/ports/__init__.py +7 -0
- porterminal/application/ports/connection_port.py +34 -0
- porterminal/application/services/__init__.py +13 -0
- porterminal/application/services/management_service.py +279 -0
- porterminal/application/services/session_service.py +249 -0
- porterminal/application/services/tab_service.py +286 -0
- porterminal/application/services/terminal_service.py +426 -0
- porterminal/asgi.py +38 -0
- porterminal/cli/__init__.py +19 -0
- porterminal/cli/args.py +91 -0
- porterminal/cli/display.py +157 -0
- porterminal/composition.py +208 -0
- porterminal/config.py +195 -0
- porterminal/container.py +65 -0
- porterminal/domain/__init__.py +91 -0
- porterminal/domain/entities/__init__.py +16 -0
- porterminal/domain/entities/output_buffer.py +73 -0
- porterminal/domain/entities/session.py +86 -0
- porterminal/domain/entities/tab.py +71 -0
- porterminal/domain/ports/__init__.py +12 -0
- porterminal/domain/ports/pty_port.py +80 -0
- porterminal/domain/ports/session_repository.py +58 -0
- porterminal/domain/ports/tab_repository.py +75 -0
- porterminal/domain/services/__init__.py +18 -0
- porterminal/domain/services/environment_sanitizer.py +61 -0
- porterminal/domain/services/rate_limiter.py +63 -0
- porterminal/domain/services/session_limits.py +104 -0
- porterminal/domain/services/tab_limits.py +54 -0
- porterminal/domain/values/__init__.py +25 -0
- porterminal/domain/values/environment_rules.py +156 -0
- porterminal/domain/values/rate_limit_config.py +21 -0
- porterminal/domain/values/session_id.py +20 -0
- porterminal/domain/values/shell_command.py +37 -0
- porterminal/domain/values/tab_id.py +24 -0
- porterminal/domain/values/terminal_dimensions.py +45 -0
- porterminal/domain/values/user_id.py +25 -0
- porterminal/infrastructure/__init__.py +20 -0
- porterminal/infrastructure/cloudflared.py +295 -0
- porterminal/infrastructure/config/__init__.py +9 -0
- porterminal/infrastructure/config/shell_detector.py +84 -0
- porterminal/infrastructure/config/yaml_loader.py +34 -0
- porterminal/infrastructure/network.py +43 -0
- porterminal/infrastructure/registry/__init__.py +5 -0
- porterminal/infrastructure/registry/user_connection_registry.py +104 -0
- porterminal/infrastructure/repositories/__init__.py +9 -0
- porterminal/infrastructure/repositories/in_memory_session.py +70 -0
- porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
- porterminal/infrastructure/server.py +161 -0
- porterminal/infrastructure/web/__init__.py +7 -0
- porterminal/infrastructure/web/websocket_adapter.py +78 -0
- porterminal/logging_setup.py +48 -0
- porterminal/pty/__init__.py +46 -0
- porterminal/pty/env.py +97 -0
- porterminal/pty/manager.py +163 -0
- porterminal/pty/protocol.py +84 -0
- porterminal/pty/unix.py +162 -0
- porterminal/pty/windows.py +131 -0
- porterminal/static/assets/app-BQiuUo6Q.css +32 -0
- porterminal/static/assets/app-YNN_jEhv.js +71 -0
- porterminal/static/icon.svg +34 -0
- porterminal/static/index.html +139 -0
- porterminal/static/manifest.json +31 -0
- porterminal/static/sw.js +66 -0
- porterminal/updater.py +257 -0
- ptn-0.1.4.dist-info/METADATA +191 -0
- ptn-0.1.4.dist-info/RECORD +73 -0
- ptn-0.1.4.dist-info/WHEEL +4 -0
- ptn-0.1.4.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|
porterminal/static/sw.js
ADDED
|
@@ -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
|
+
)
|