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.
Files changed (65) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +15 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +255 -106
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/key.py +6 -7
  15. tescmd/cli/main.py +27 -8
  16. tescmd/cli/mcp_cmd.py +153 -0
  17. tescmd/cli/nav.py +3 -1
  18. tescmd/cli/openclaw.py +169 -0
  19. tescmd/cli/security.py +7 -1
  20. tescmd/cli/serve.py +923 -0
  21. tescmd/cli/setup.py +147 -58
  22. tescmd/cli/sharing.py +2 -0
  23. tescmd/cli/status.py +1 -1
  24. tescmd/cli/trunk.py +8 -17
  25. tescmd/cli/user.py +16 -1
  26. tescmd/cli/vehicle.py +135 -462
  27. tescmd/deploy/github_pages.py +21 -2
  28. tescmd/deploy/tailscale_serve.py +96 -8
  29. tescmd/mcp/__init__.py +7 -0
  30. tescmd/mcp/server.py +648 -0
  31. tescmd/models/auth.py +5 -2
  32. tescmd/openclaw/__init__.py +23 -0
  33. tescmd/openclaw/bridge.py +330 -0
  34. tescmd/openclaw/config.py +167 -0
  35. tescmd/openclaw/dispatcher.py +529 -0
  36. tescmd/openclaw/emitter.py +175 -0
  37. tescmd/openclaw/filters.py +123 -0
  38. tescmd/openclaw/gateway.py +700 -0
  39. tescmd/openclaw/telemetry_store.py +53 -0
  40. tescmd/output/rich_output.py +46 -14
  41. tescmd/protocol/commands.py +2 -2
  42. tescmd/protocol/encoder.py +16 -13
  43. tescmd/protocol/payloads.py +132 -11
  44. tescmd/protocol/session.py +8 -5
  45. tescmd/protocol/signer.py +3 -17
  46. tescmd/telemetry/__init__.py +9 -0
  47. tescmd/telemetry/cache_sink.py +154 -0
  48. tescmd/telemetry/csv_sink.py +180 -0
  49. tescmd/telemetry/dashboard.py +4 -4
  50. tescmd/telemetry/fanout.py +49 -0
  51. tescmd/telemetry/fields.py +308 -129
  52. tescmd/telemetry/mapper.py +239 -0
  53. tescmd/telemetry/server.py +26 -19
  54. tescmd/telemetry/setup.py +468 -0
  55. tescmd/telemetry/tailscale.py +78 -16
  56. tescmd/telemetry/tui.py +1716 -0
  57. tescmd/triggers/__init__.py +18 -0
  58. tescmd/triggers/manager.py +264 -0
  59. tescmd/triggers/models.py +93 -0
  60. tescmd-0.4.0.dist-info/METADATA +300 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
  62. tescmd-0.2.0.dist-info/METADATA +0 -495
  63. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
  64. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
  65. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
- return resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text
238
+ if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
239
+ return resp.text.strip()
222
240
  except httpx.HTTPError:
223
- return False
241
+ pass
242
+ return None
224
243
 
225
244
 
226
245
  def wait_for_pages_deployment(
@@ -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`` and enable Funnel.
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 .well-known directory at /.well-known/
79
- await ts.start_serve("/.well-known/", str(well_known_dir))
80
-
81
- # Enable Funnel to make it publicly accessible
82
- await ts.enable_funnel()
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 ``.well-known`` serve handler."""
161
+ """Remove the key-serving handler."""
90
162
  ts = TailscaleManager()
91
- await ts.stop_serve("/.well-known/")
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
 
tescmd/mcp/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """MCP (Model Context Protocol) server for tescmd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tescmd.mcp.server import create_mcp_server
6
+
7
+ __all__ = ["create_mcp_server"]