tescmd 0.2.0__py3-none-any.whl → 0.4.0__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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +15 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +255 -106
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +6 -7
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +147 -58
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +21 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +529 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +700 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +78 -16
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- tescmd-0.4.0.dist-info/METADATA +300 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
- tescmd-0.2.0.dist-info/METADATA +0 -495
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
tescmd/deploy/github_pages.py
CHANGED
|
@@ -27,8 +27,15 @@ POLL_INTERVAL = 5 # seconds
|
|
|
27
27
|
# ---------------------------------------------------------------------------
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def _check_tool(name: str) -> None:
|
|
31
|
+
"""Raise FileNotFoundError if *name* is not on PATH."""
|
|
32
|
+
if shutil.which(name) is None:
|
|
33
|
+
raise FileNotFoundError(f"Required tool {name!r} is not installed or not on PATH")
|
|
34
|
+
|
|
35
|
+
|
|
30
36
|
def _run_gh(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
31
37
|
"""Run a ``gh`` CLI command and return the result."""
|
|
38
|
+
_check_tool("gh")
|
|
32
39
|
return subprocess.run(
|
|
33
40
|
["gh", *args],
|
|
34
41
|
capture_output=True,
|
|
@@ -44,6 +51,7 @@ def _run_git(
|
|
|
44
51
|
check: bool = True,
|
|
45
52
|
) -> subprocess.CompletedProcess[str]:
|
|
46
53
|
"""Run a ``git`` command and return the result."""
|
|
54
|
+
_check_tool("git")
|
|
47
55
|
return subprocess.run(
|
|
48
56
|
["git", *args],
|
|
49
57
|
capture_output=True,
|
|
@@ -215,12 +223,23 @@ def get_key_url(domain: str) -> str:
|
|
|
215
223
|
|
|
216
224
|
def validate_key_url(domain: str) -> bool:
|
|
217
225
|
"""Return True if the public key is accessible at the expected URL."""
|
|
226
|
+
return fetch_key_pem(domain) is not None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def fetch_key_pem(domain: str) -> str | None:
|
|
230
|
+
"""Fetch the public key PEM from the hosted ``.well-known`` URL.
|
|
231
|
+
|
|
232
|
+
Returns the PEM string (stripped), or ``None`` if the key is not
|
|
233
|
+
accessible or does not look like a PEM public key.
|
|
234
|
+
"""
|
|
218
235
|
url = get_key_url(domain)
|
|
219
236
|
try:
|
|
220
237
|
resp = httpx.get(url, follow_redirects=True, timeout=10)
|
|
221
|
-
|
|
238
|
+
if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
|
|
239
|
+
return resp.text.strip()
|
|
222
240
|
except httpx.HTTPError:
|
|
223
|
-
|
|
241
|
+
pass
|
|
242
|
+
return None
|
|
224
243
|
|
|
225
244
|
|
|
226
245
|
def wait_for_pages_deployment(
|
tescmd/deploy/tailscale_serve.py
CHANGED
|
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import logging
|
|
12
|
+
import threading
|
|
12
13
|
import time
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
|
|
15
17
|
import httpx
|
|
@@ -27,6 +29,71 @@ DEFAULT_DEPLOY_TIMEOUT = 60 # seconds (faster than GitHub Pages)
|
|
|
27
29
|
POLL_INTERVAL = 3 # seconds
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# In-process key server (used by interactive setup)
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _KeyRequestHandler(BaseHTTPRequestHandler):
|
|
38
|
+
"""Serve the root (200 OK) and the ``.well-known`` PEM path."""
|
|
39
|
+
|
|
40
|
+
server: KeyServer # type: ignore[assignment]
|
|
41
|
+
|
|
42
|
+
def do_GET(self) -> None:
|
|
43
|
+
if self.path == "/":
|
|
44
|
+
self._respond(200, "")
|
|
45
|
+
elif self.path == f"/{WELL_KNOWN_PATH}":
|
|
46
|
+
self._respond(
|
|
47
|
+
200,
|
|
48
|
+
self.server.pem_content,
|
|
49
|
+
content_type="application/x-pem-file",
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
self._respond(404, "Not found")
|
|
53
|
+
|
|
54
|
+
def _respond(
|
|
55
|
+
self,
|
|
56
|
+
status: int,
|
|
57
|
+
body: str,
|
|
58
|
+
content_type: str = "text/html; charset=utf-8",
|
|
59
|
+
) -> None:
|
|
60
|
+
encoded = body.encode()
|
|
61
|
+
self.send_response(status)
|
|
62
|
+
self.send_header("Content-Type", content_type)
|
|
63
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
64
|
+
self.end_headers()
|
|
65
|
+
self.wfile.write(encoded)
|
|
66
|
+
|
|
67
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
68
|
+
"""Silence default stderr logging."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class KeyServer(HTTPServer):
|
|
72
|
+
"""Ephemeral HTTP server that serves a PEM public key.
|
|
73
|
+
|
|
74
|
+
Runs in a daemon thread so the main process can continue interacting
|
|
75
|
+
with the user. Tailscale Funnel proxies external HTTPS traffic to
|
|
76
|
+
this local server.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, pem_content: str, port: int) -> None:
|
|
80
|
+
super().__init__(("127.0.0.1", port), _KeyRequestHandler)
|
|
81
|
+
self.pem_content = pem_content
|
|
82
|
+
self._thread: threading.Thread | None = None
|
|
83
|
+
|
|
84
|
+
def start(self) -> None:
|
|
85
|
+
"""Start serving in a background daemon thread."""
|
|
86
|
+
self._thread = threading.Thread(target=self.serve_forever, daemon=True)
|
|
87
|
+
self._thread.start()
|
|
88
|
+
|
|
89
|
+
def stop(self) -> None:
|
|
90
|
+
"""Shut down the server and wait for the thread to exit."""
|
|
91
|
+
self.shutdown()
|
|
92
|
+
self.server_close()
|
|
93
|
+
if self._thread is not None:
|
|
94
|
+
self._thread.join(timeout=5)
|
|
95
|
+
|
|
96
|
+
|
|
30
97
|
# ---------------------------------------------------------------------------
|
|
31
98
|
# Key file management
|
|
32
99
|
# ---------------------------------------------------------------------------
|
|
@@ -46,6 +113,7 @@ async def deploy_public_key_tailscale(
|
|
|
46
113
|
key_path = base / WELL_KNOWN_PATH
|
|
47
114
|
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
115
|
key_path.write_text(public_key_pem)
|
|
116
|
+
|
|
49
117
|
logger.info("Public key written to %s", key_path)
|
|
50
118
|
return key_path
|
|
51
119
|
|
|
@@ -56,7 +124,11 @@ async def deploy_public_key_tailscale(
|
|
|
56
124
|
|
|
57
125
|
|
|
58
126
|
async def start_key_serving(serve_dir: Path | None = None) -> str:
|
|
59
|
-
"""Start ``tailscale serve`` for ``.well-known
|
|
127
|
+
"""Start ``tailscale serve`` with Funnel for ``.well-known``.
|
|
128
|
+
|
|
129
|
+
Uses a single ``tailscale serve --bg --funnel --set-path / <dir>``
|
|
130
|
+
command so that the static-file handler and public Funnel access are
|
|
131
|
+
configured atomically on HTTPS port 443.
|
|
60
132
|
|
|
61
133
|
Returns the public hostname (e.g. ``machine.tailnet.ts.net``).
|
|
62
134
|
|
|
@@ -75,20 +147,20 @@ async def start_key_serving(serve_dir: Path | None = None) -> str:
|
|
|
75
147
|
await ts.check_available()
|
|
76
148
|
hostname = await ts.get_hostname()
|
|
77
149
|
|
|
78
|
-
# Serve the
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
#
|
|
82
|
-
await ts.
|
|
150
|
+
# Serve the entire base directory at / with Funnel enabled so that:
|
|
151
|
+
# - The origin URL (https://host/) returns 200
|
|
152
|
+
# - The key at /.well-known/appspecific/com.tesla.3p.public-key.pem is reachable
|
|
153
|
+
# Tesla verifies both during Developer Portal app configuration.
|
|
154
|
+
await ts.start_serve("/", str(base), funnel=True)
|
|
83
155
|
|
|
84
156
|
logger.info("Key serving started at https://%s/%s", hostname, WELL_KNOWN_PATH)
|
|
85
157
|
return hostname
|
|
86
158
|
|
|
87
159
|
|
|
88
160
|
async def stop_key_serving() -> None:
|
|
89
|
-
"""Remove the
|
|
161
|
+
"""Remove the key-serving handler."""
|
|
90
162
|
ts = TailscaleManager()
|
|
91
|
-
await ts.stop_serve("
|
|
163
|
+
await ts.stop_serve("/")
|
|
92
164
|
logger.info("Key serving stopped")
|
|
93
165
|
|
|
94
166
|
|
|
@@ -121,6 +193,22 @@ def get_key_url(hostname: str) -> str:
|
|
|
121
193
|
return f"https://{hostname}/{WELL_KNOWN_PATH}"
|
|
122
194
|
|
|
123
195
|
|
|
196
|
+
def fetch_tailscale_key_pem(hostname: str) -> str | None:
|
|
197
|
+
"""Fetch the public key PEM from a Tailscale Funnel ``.well-known`` URL.
|
|
198
|
+
|
|
199
|
+
Returns the PEM string (stripped), or ``None`` if the key is not
|
|
200
|
+
accessible or does not look like a PEM public key.
|
|
201
|
+
"""
|
|
202
|
+
url = get_key_url(hostname)
|
|
203
|
+
try:
|
|
204
|
+
resp = httpx.get(url, follow_redirects=True, timeout=10)
|
|
205
|
+
if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
|
|
206
|
+
return resp.text.strip()
|
|
207
|
+
except httpx.HTTPError:
|
|
208
|
+
pass
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
124
212
|
async def validate_tailscale_key_url(hostname: str) -> bool:
|
|
125
213
|
"""HTTP GET to verify key is accessible.
|
|
126
214
|
|