tescmd 0.1.2__py3-none-any.whl → 0.3.1__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 +49 -5
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +13 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/api/vehicle.py +19 -1
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +11 -3
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +121 -11
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +70 -7
- 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 +244 -25
- 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 +156 -20
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/github_pages.py +8 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +24 -2
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -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 +18 -8
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +28 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +427 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/protos/__init__.py +4 -0
- tescmd/telemetry/protos/vehicle_alert.proto +31 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
- tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
- tescmd/telemetry/protos/vehicle_data.proto +768 -0
- tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
- tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
- tescmd/telemetry/protos/vehicle_error.proto +23 -0
- tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
- tescmd/telemetry/protos/vehicle_metric.proto +22 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
- tescmd/telemetry/server.py +300 -0
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +300 -0
- 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.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
- tescmd-0.3.1.dist-info/RECORD +128 -0
- tescmd-0.1.2.dist-info/RECORD +0 -81
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/auth.py
CHANGED
|
@@ -20,14 +20,20 @@ from tescmd.auth.oauth import (
|
|
|
20
20
|
)
|
|
21
21
|
from tescmd.auth.token_store import TokenStore
|
|
22
22
|
from tescmd.cli._options import global_options
|
|
23
|
-
from tescmd.models.auth import
|
|
23
|
+
from tescmd.models.auth import (
|
|
24
|
+
DEFAULT_SCOPES,
|
|
25
|
+
PARTNER_SCOPES,
|
|
26
|
+
TokenData,
|
|
27
|
+
decode_jwt_payload,
|
|
28
|
+
decode_jwt_scopes,
|
|
29
|
+
)
|
|
24
30
|
from tescmd.models.config import AppSettings
|
|
25
31
|
|
|
26
32
|
if TYPE_CHECKING:
|
|
27
33
|
from tescmd.cli.main import AppContext
|
|
28
34
|
from tescmd.output.formatter import OutputFormatter
|
|
29
35
|
|
|
30
|
-
DEVELOPER_PORTAL_URL = "https://developer.tesla.com"
|
|
36
|
+
DEVELOPER_PORTAL_URL = "https://developer.tesla.com/dashboard"
|
|
31
37
|
|
|
32
38
|
|
|
33
39
|
# ---------------------------------------------------------------------------
|
|
@@ -43,7 +49,12 @@ auth_group = click.Group("auth", help="Authentication commands")
|
|
|
43
49
|
|
|
44
50
|
|
|
45
51
|
@auth_group.command("login")
|
|
46
|
-
@click.option(
|
|
52
|
+
@click.option(
|
|
53
|
+
"--port",
|
|
54
|
+
type=int,
|
|
55
|
+
default=None,
|
|
56
|
+
help="Local callback port (default: 8085 or TESCMD_OAUTH_PORT)",
|
|
57
|
+
)
|
|
47
58
|
@click.option(
|
|
48
59
|
"--reconsent",
|
|
49
60
|
is_flag=True,
|
|
@@ -51,15 +62,20 @@ auth_group = click.Group("auth", help="Authentication commands")
|
|
|
51
62
|
help="Force Tesla to re-display the scope consent screen.",
|
|
52
63
|
)
|
|
53
64
|
@global_options
|
|
54
|
-
def login_cmd(app_ctx: AppContext, port: int, reconsent: bool) -> None:
|
|
65
|
+
def login_cmd(app_ctx: AppContext, port: int | None, reconsent: bool) -> None:
|
|
55
66
|
"""Log in via OAuth2 PKCE flow."""
|
|
56
67
|
run_async(_cmd_login(app_ctx, port, reconsent=reconsent))
|
|
57
68
|
|
|
58
69
|
|
|
59
|
-
async def _cmd_login(app_ctx: AppContext, port: int, *, reconsent: bool = False) -> None:
|
|
70
|
+
async def _cmd_login(app_ctx: AppContext, port: int | None, *, reconsent: bool = False) -> None:
|
|
71
|
+
from tescmd.models.auth import DEFAULT_PORT
|
|
72
|
+
|
|
60
73
|
formatter = app_ctx.formatter
|
|
61
74
|
settings = AppSettings()
|
|
62
75
|
|
|
76
|
+
if port is None:
|
|
77
|
+
port = DEFAULT_PORT
|
|
78
|
+
|
|
63
79
|
client_id = settings.client_id
|
|
64
80
|
client_secret = settings.client_secret
|
|
65
81
|
|
|
@@ -182,11 +198,23 @@ async def _cmd_status(app_ctx: AppContext) -> None:
|
|
|
182
198
|
region: str = meta.get("region", "unknown")
|
|
183
199
|
has_refresh = store.refresh_token is not None
|
|
184
200
|
|
|
185
|
-
# Decode the JWT to show the *actual* granted scopes
|
|
201
|
+
# Decode the JWT to show the *actual* granted scopes and audience
|
|
186
202
|
token_scopes: list[str] | None = None
|
|
203
|
+
audience: str | None = None
|
|
187
204
|
access_token = store.access_token
|
|
188
205
|
if access_token:
|
|
189
206
|
token_scopes = decode_jwt_scopes(access_token)
|
|
207
|
+
jwt_payload = decode_jwt_payload(access_token)
|
|
208
|
+
if jwt_payload is not None:
|
|
209
|
+
aud_raw = jwt_payload.get("aud")
|
|
210
|
+
if isinstance(aud_raw, list) and aud_raw:
|
|
211
|
+
audience = aud_raw[0] if len(aud_raw) == 1 else ", ".join(str(a) for a in aud_raw)
|
|
212
|
+
elif isinstance(aud_raw, str):
|
|
213
|
+
audience = aud_raw
|
|
214
|
+
# Also check for 'ou' (origin URL) — Tesla-specific claim
|
|
215
|
+
ou = jwt_payload.get("ou")
|
|
216
|
+
if isinstance(ou, str) and ou:
|
|
217
|
+
audience = ou
|
|
190
218
|
|
|
191
219
|
if formatter.format == "json":
|
|
192
220
|
data: dict[str, object] = {
|
|
@@ -198,6 +226,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
|
|
|
198
226
|
}
|
|
199
227
|
if token_scopes is not None:
|
|
200
228
|
data["token_scopes"] = token_scopes
|
|
229
|
+
if audience is not None:
|
|
230
|
+
data["audience"] = audience
|
|
201
231
|
formatter.output(data, command="auth.status")
|
|
202
232
|
else:
|
|
203
233
|
formatter.rich.info("Authenticated: yes")
|
|
@@ -211,6 +241,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
|
|
|
211
241
|
formatter.rich.info(
|
|
212
242
|
f" [yellow]Warning: requested but not granted: {not_granted}[/yellow]"
|
|
213
243
|
)
|
|
244
|
+
if audience is not None:
|
|
245
|
+
formatter.rich.info(f"Audience: {audience}")
|
|
214
246
|
formatter.rich.info(f"Region: {region}")
|
|
215
247
|
formatter.rich.info(f"Refresh token: {'yes' if has_refresh else 'no'}")
|
|
216
248
|
|
|
@@ -245,11 +277,24 @@ async def _cmd_refresh(app_ctx: AppContext) -> None:
|
|
|
245
277
|
scopes: list[str] = meta.get("scopes", DEFAULT_SCOPES)
|
|
246
278
|
region: str = meta.get("region", "na")
|
|
247
279
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
280
|
+
from tescmd.api.errors import AuthError
|
|
281
|
+
from tescmd.cli._client import reauth_on_expired_refresh
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
token_data = await refresh_access_token(
|
|
285
|
+
refresh_token=rt,
|
|
286
|
+
client_id=settings.client_id,
|
|
287
|
+
client_secret=settings.client_secret,
|
|
288
|
+
)
|
|
289
|
+
except AuthError as exc:
|
|
290
|
+
if "login_required" not in str(exc):
|
|
291
|
+
raise
|
|
292
|
+
await reauth_on_expired_refresh(store, settings)
|
|
293
|
+
if formatter.format == "json":
|
|
294
|
+
formatter.output({"status": "re-authenticated"}, command="auth.refresh")
|
|
295
|
+
else:
|
|
296
|
+
formatter.rich.info("")
|
|
297
|
+
return
|
|
253
298
|
|
|
254
299
|
store.save(
|
|
255
300
|
access_token=token_data.access_token,
|
|
@@ -423,12 +468,17 @@ def _interactive_setup(
|
|
|
423
468
|
redirect_uri: str,
|
|
424
469
|
*,
|
|
425
470
|
domain: str = "",
|
|
471
|
+
tailscale_hostname: str = "",
|
|
426
472
|
) -> tuple[str, str]:
|
|
427
473
|
"""Walk the user through first-time Tesla API credential setup.
|
|
428
474
|
|
|
429
475
|
When *domain* is provided (e.g. from the setup wizard), the developer
|
|
430
476
|
portal instructions show ``https://{domain}`` as the Allowed Origin URL.
|
|
431
477
|
Tesla's Fleet API requires the origin to match the registration domain.
|
|
478
|
+
|
|
479
|
+
When *tailscale_hostname* is provided (or auto-detected), the user is
|
|
480
|
+
offered the chance to start a Tailscale Funnel so Tesla can verify the
|
|
481
|
+
origin URL when the portal app config is saved.
|
|
432
482
|
"""
|
|
433
483
|
info = formatter.rich.info
|
|
434
484
|
origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
|
|
@@ -475,6 +525,47 @@ def _interactive_setup(
|
|
|
475
525
|
info(" Click Next.")
|
|
476
526
|
info("")
|
|
477
527
|
|
|
528
|
+
# Detect Tailscale and offer to start Funnel for origin URL verification
|
|
529
|
+
ts_hostname = tailscale_hostname
|
|
530
|
+
ts_funnel_started = False
|
|
531
|
+
if not ts_hostname:
|
|
532
|
+
try:
|
|
533
|
+
from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
|
|
534
|
+
|
|
535
|
+
if run_async(is_tailscale_serve_ready()):
|
|
536
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
537
|
+
|
|
538
|
+
ts_hostname = run_async(TailscaleManager().get_hostname())
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
if ts_hostname:
|
|
543
|
+
ts_origin = f"https://{ts_hostname}"
|
|
544
|
+
info("")
|
|
545
|
+
info(f"[green]Tailscale detected:[/green] {ts_hostname}")
|
|
546
|
+
info("")
|
|
547
|
+
info(f" Adding [cyan]{ts_origin}[/cyan] as an Allowed Origin URL")
|
|
548
|
+
info(" will let [cyan]tescmd serve[/cyan] stream telemetry without")
|
|
549
|
+
info(" extra portal changes later.")
|
|
550
|
+
info("")
|
|
551
|
+
try:
|
|
552
|
+
answer = input("Start Tailscale Funnel so Tesla can verify the URL? [Y/n] ").strip()
|
|
553
|
+
except (EOFError, KeyboardInterrupt):
|
|
554
|
+
answer = "n"
|
|
555
|
+
|
|
556
|
+
if answer.lower() != "n":
|
|
557
|
+
info("Starting Tailscale Funnel...")
|
|
558
|
+
try:
|
|
559
|
+
from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
|
|
560
|
+
|
|
561
|
+
run_async(_TsMgr().enable_funnel())
|
|
562
|
+
ts_funnel_started = True
|
|
563
|
+
info(f"[green]Funnel active — {ts_origin} is reachable.[/green]")
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
info(f"[yellow]Could not start Funnel: {exc}[/yellow]")
|
|
566
|
+
info("[dim]You can add the origin URL manually later.[/dim]")
|
|
567
|
+
ts_hostname = ""
|
|
568
|
+
|
|
478
569
|
# Step 3 — Client Details
|
|
479
570
|
info("[bold]Step 3 — Client Details[/bold]")
|
|
480
571
|
info(
|
|
@@ -482,8 +573,17 @@ def _interactive_setup(
|
|
|
482
573
|
" Machine-to-Machine[/cyan] (the default)"
|
|
483
574
|
)
|
|
484
575
|
info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
|
|
576
|
+
if ts_hostname:
|
|
577
|
+
info(f" [bold]Also add:[/bold] [cyan]https://{ts_hostname}[/cyan]")
|
|
485
578
|
info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
|
|
486
579
|
info(" Allowed Returned URL: (leave empty)")
|
|
580
|
+
info("")
|
|
581
|
+
if not ts_hostname:
|
|
582
|
+
info(
|
|
583
|
+
" [dim]For telemetry streaming, add your Tailscale hostname"
|
|
584
|
+
" as an additional origin:[/dim]"
|
|
585
|
+
)
|
|
586
|
+
info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
|
|
487
587
|
info(" Click Next.")
|
|
488
588
|
info("")
|
|
489
589
|
|
|
@@ -544,6 +644,16 @@ def _interactive_setup(
|
|
|
544
644
|
_write_env_file(client_id, client_secret)
|
|
545
645
|
info("[green]Credentials saved to .env[/green]")
|
|
546
646
|
|
|
647
|
+
# Clean up Tailscale Funnel if we started it during this session
|
|
648
|
+
if ts_funnel_started:
|
|
649
|
+
try:
|
|
650
|
+
from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
|
|
651
|
+
|
|
652
|
+
run_async(_TsMgr._run("tailscale", "funnel", "--bg", "off"))
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
info(f"[yellow]Warning: Failed to stop Tailscale Funnel: {exc}[/yellow]")
|
|
655
|
+
info("[dim]You may need to run 'tailscale funnel --bg off' manually.[/dim]")
|
|
656
|
+
|
|
547
657
|
info("")
|
|
548
658
|
return (client_id, client_secret)
|
|
549
659
|
|
tescmd/cli/energy.py
CHANGED
|
@@ -9,6 +9,7 @@ import click
|
|
|
9
9
|
from tescmd._internal.async_utils import run_async
|
|
10
10
|
from tescmd.cli._client import TTL_SLOW, cached_api_call, get_energy_api, invalidate_cache_for_site
|
|
11
11
|
from tescmd.cli._options import global_options
|
|
12
|
+
from tescmd.models.energy import SiteInfo
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from tescmd.cli.main import AppContext
|
|
@@ -66,6 +67,7 @@ async def _cmd_status(app_ctx: AppContext, site_id: int) -> None:
|
|
|
66
67
|
endpoint="energy.status",
|
|
67
68
|
fetch=lambda: api.site_info(site_id),
|
|
68
69
|
ttl=TTL_SLOW,
|
|
70
|
+
model_class=SiteInfo,
|
|
69
71
|
)
|
|
70
72
|
finally:
|
|
71
73
|
await client.close()
|
tescmd/cli/key.py
CHANGED
|
@@ -25,6 +25,7 @@ from tescmd.models.config import AppSettings
|
|
|
25
25
|
|
|
26
26
|
if TYPE_CHECKING:
|
|
27
27
|
from tescmd.cli.main import AppContext
|
|
28
|
+
from tescmd.output.formatter import OutputFormatter
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
# ---------------------------------------------------------------------------
|
|
@@ -95,22 +96,19 @@ async def _cmd_generate(app_ctx: AppContext, force: bool) -> None:
|
|
|
95
96
|
default=None,
|
|
96
97
|
help="GitHub repo (e.g. user/user.github.io). Auto-detected if omitted.",
|
|
97
98
|
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--method",
|
|
101
|
+
type=click.Choice(["auto", "github", "tailscale"]),
|
|
102
|
+
default="auto",
|
|
103
|
+
help="Deployment method (default: auto-detect).",
|
|
104
|
+
)
|
|
98
105
|
@global_options
|
|
99
|
-
def deploy_cmd(app_ctx: AppContext, repo: str | None) -> None:
|
|
100
|
-
"""Deploy the public key to GitHub Pages."""
|
|
101
|
-
run_async(_cmd_deploy(app_ctx, repo))
|
|
102
|
-
|
|
106
|
+
def deploy_cmd(app_ctx: AppContext, repo: str | None, method: str) -> None:
|
|
107
|
+
"""Deploy the public key to GitHub Pages or via Tailscale Funnel."""
|
|
108
|
+
run_async(_cmd_deploy(app_ctx, repo, method=method))
|
|
103
109
|
|
|
104
|
-
async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
|
|
105
|
-
from tescmd.deploy.github_pages import (
|
|
106
|
-
create_pages_repo,
|
|
107
|
-
deploy_public_key,
|
|
108
|
-
get_gh_username,
|
|
109
|
-
get_pages_domain,
|
|
110
|
-
is_gh_authenticated,
|
|
111
|
-
is_gh_available,
|
|
112
|
-
)
|
|
113
110
|
|
|
111
|
+
async def _cmd_deploy(app_ctx: AppContext, repo: str | None, *, method: str = "auto") -> None:
|
|
114
112
|
formatter = app_ctx.formatter
|
|
115
113
|
settings = AppSettings()
|
|
116
114
|
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
@@ -127,6 +125,142 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
|
|
|
127
125
|
formatter.rich.error("No key pair found. Run [cyan]tescmd key generate[/cyan] first.")
|
|
128
126
|
return
|
|
129
127
|
|
|
128
|
+
# Resolve method
|
|
129
|
+
resolved = _resolve_deploy_method(method, settings)
|
|
130
|
+
|
|
131
|
+
if resolved == "tailscale":
|
|
132
|
+
await _cmd_deploy_tailscale(formatter, key_dir)
|
|
133
|
+
elif resolved == "github":
|
|
134
|
+
await _cmd_deploy_github(formatter, settings, key_dir, repo)
|
|
135
|
+
else:
|
|
136
|
+
if formatter.format == "json":
|
|
137
|
+
formatter.output_error(
|
|
138
|
+
code="no_deploy_method",
|
|
139
|
+
message=(
|
|
140
|
+
"No deployment method available. Install 'gh' CLI for GitHub Pages"
|
|
141
|
+
" or Tailscale for Funnel hosting."
|
|
142
|
+
),
|
|
143
|
+
command="key.deploy",
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
formatter.rich.error(
|
|
147
|
+
"No deployment method available. Install [cyan]gh[/cyan] CLI"
|
|
148
|
+
" (GitHub Pages) or [cyan]tailscale[/cyan] (Funnel hosting)."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _resolve_deploy_method(method: str, settings: AppSettings) -> str | None:
|
|
153
|
+
"""Resolve 'auto' to a concrete method, or validate explicit choice."""
|
|
154
|
+
if method in ("tailscale", "github"):
|
|
155
|
+
return method
|
|
156
|
+
|
|
157
|
+
# Auto-detect: GitHub Pages (always-on) > Tailscale (stable hostname)
|
|
158
|
+
from tescmd.deploy.github_pages import is_gh_authenticated, is_gh_available
|
|
159
|
+
|
|
160
|
+
if is_gh_available() and is_gh_authenticated():
|
|
161
|
+
return "github"
|
|
162
|
+
|
|
163
|
+
from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
|
|
164
|
+
|
|
165
|
+
if run_async(is_tailscale_serve_ready()):
|
|
166
|
+
return "tailscale"
|
|
167
|
+
|
|
168
|
+
# Check saved hosting method as last resort
|
|
169
|
+
if settings.hosting_method == "tailscale":
|
|
170
|
+
return "tailscale"
|
|
171
|
+
if settings.hosting_method == "github":
|
|
172
|
+
return "github"
|
|
173
|
+
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _cmd_deploy_tailscale(
|
|
178
|
+
formatter: OutputFormatter,
|
|
179
|
+
key_dir: Path,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Deploy public key via Tailscale Funnel."""
|
|
182
|
+
from tescmd.deploy.tailscale_serve import (
|
|
183
|
+
deploy_public_key_tailscale,
|
|
184
|
+
get_key_url,
|
|
185
|
+
is_tailscale_serve_ready,
|
|
186
|
+
start_key_serving,
|
|
187
|
+
wait_for_tailscale_deployment,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Verify Tailscale is ready
|
|
191
|
+
if not await is_tailscale_serve_ready():
|
|
192
|
+
if formatter.format == "json":
|
|
193
|
+
formatter.output_error(
|
|
194
|
+
code="tailscale_not_ready",
|
|
195
|
+
message=(
|
|
196
|
+
"Tailscale is not available or Funnel is not enabled."
|
|
197
|
+
" Ensure Tailscale is running and Funnel is enabled in your tailnet ACL."
|
|
198
|
+
),
|
|
199
|
+
command="key.deploy",
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
formatter.rich.error(
|
|
203
|
+
"Tailscale is not available or Funnel is not enabled.\n"
|
|
204
|
+
" Ensure Tailscale is running and Funnel is enabled in your tailnet ACL."
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if formatter.format != "json":
|
|
209
|
+
formatter.rich.info("Deploying public key via Tailscale Funnel...")
|
|
210
|
+
|
|
211
|
+
pem = load_public_key_pem(key_dir)
|
|
212
|
+
await deploy_public_key_tailscale(pem)
|
|
213
|
+
hostname = await start_key_serving()
|
|
214
|
+
|
|
215
|
+
url = get_key_url(hostname)
|
|
216
|
+
if formatter.format != "json":
|
|
217
|
+
formatter.rich.info("[green]Tailscale serve + Funnel started.[/green]")
|
|
218
|
+
formatter.rich.info(f" URL: {url}")
|
|
219
|
+
formatter.rich.info("")
|
|
220
|
+
formatter.rich.info("Waiting for key to become accessible...")
|
|
221
|
+
|
|
222
|
+
deployed = await wait_for_tailscale_deployment(hostname)
|
|
223
|
+
|
|
224
|
+
if formatter.format == "json":
|
|
225
|
+
formatter.output(
|
|
226
|
+
{
|
|
227
|
+
"status": "deployed" if deployed else "pending",
|
|
228
|
+
"method": "tailscale",
|
|
229
|
+
"hostname": hostname,
|
|
230
|
+
"url": url,
|
|
231
|
+
"accessible": deployed,
|
|
232
|
+
},
|
|
233
|
+
command="key.deploy",
|
|
234
|
+
)
|
|
235
|
+
elif deployed:
|
|
236
|
+
formatter.rich.info("[green]Key is live and accessible.[/green]")
|
|
237
|
+
formatter.rich.info("")
|
|
238
|
+
formatter.rich.info(
|
|
239
|
+
"[yellow]Note:[/yellow] The key is served by the Tailscale daemon."
|
|
240
|
+
" If your machine is off or Tailscale stops,"
|
|
241
|
+
" Tesla cannot reach your public key."
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
formatter.rich.info("[yellow]Key deployed but not yet accessible.[/yellow]")
|
|
245
|
+
formatter.rich.info(" Run [cyan]tescmd key validate[/cyan] to check again later.")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def _cmd_deploy_github(
|
|
249
|
+
formatter: OutputFormatter,
|
|
250
|
+
settings: AppSettings,
|
|
251
|
+
key_dir: Path,
|
|
252
|
+
repo: str | None,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Deploy public key via GitHub Pages."""
|
|
255
|
+
from tescmd.deploy.github_pages import (
|
|
256
|
+
create_pages_repo,
|
|
257
|
+
deploy_public_key,
|
|
258
|
+
get_gh_username,
|
|
259
|
+
get_pages_domain,
|
|
260
|
+
is_gh_authenticated,
|
|
261
|
+
is_gh_available,
|
|
262
|
+
)
|
|
263
|
+
|
|
130
264
|
# Check gh CLI
|
|
131
265
|
if not is_gh_available():
|
|
132
266
|
if formatter.format == "json":
|
|
@@ -180,7 +314,7 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
|
|
|
180
314
|
formatter.rich.info("[green]Key deployed.[/green]")
|
|
181
315
|
formatter.rich.info(f" URL: {get_key_url(domain)}")
|
|
182
316
|
formatter.rich.info("")
|
|
183
|
-
formatter.rich.info("Waiting for GitHub Pages to publish
|
|
317
|
+
formatter.rich.info("Waiting for GitHub Pages to publish...")
|
|
184
318
|
|
|
185
319
|
deployed = wait_for_pages_deployment(domain)
|
|
186
320
|
|
|
@@ -188,6 +322,7 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
|
|
|
188
322
|
formatter.output(
|
|
189
323
|
{
|
|
190
324
|
"status": "deployed" if deployed else "pending",
|
|
325
|
+
"method": "github",
|
|
191
326
|
"repo": repo_name,
|
|
192
327
|
"domain": domain,
|
|
193
328
|
"url": get_key_url(domain),
|
tescmd/cli/main.py
CHANGED
|
@@ -6,6 +6,7 @@ import dataclasses
|
|
|
6
6
|
import logging
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
|
+
from dotenv import load_dotenv
|
|
9
10
|
|
|
10
11
|
from tescmd._internal.async_utils import run_async
|
|
11
12
|
from tescmd.api.errors import (
|
|
@@ -15,6 +16,7 @@ from tescmd.api.errors import (
|
|
|
15
16
|
RegistrationRequiredError,
|
|
16
17
|
SessionError,
|
|
17
18
|
TierError,
|
|
19
|
+
TunnelError,
|
|
18
20
|
VehicleAsleepError,
|
|
19
21
|
)
|
|
20
22
|
from tescmd.output.formatter import OutputFormatter
|
|
@@ -61,6 +63,13 @@ class AppContext:
|
|
|
61
63
|
return self._formatter
|
|
62
64
|
|
|
63
65
|
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Load .env into os.environ before Click resolves any envvar= options.
|
|
68
|
+
# This ensures TESLA_VIN, TESCMD_MCP_CLIENT_ID, etc. from .env files
|
|
69
|
+
# are visible to Click's envvar resolution.
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
load_dotenv(override=False)
|
|
72
|
+
|
|
64
73
|
# ---------------------------------------------------------------------------
|
|
65
74
|
# Root Click group
|
|
66
75
|
# ---------------------------------------------------------------------------
|
|
@@ -159,11 +168,14 @@ def _register_commands() -> None:
|
|
|
159
168
|
from tescmd.cli.climate import climate_group
|
|
160
169
|
from tescmd.cli.energy import energy_group
|
|
161
170
|
from tescmd.cli.key import key_group
|
|
171
|
+
from tescmd.cli.mcp_cmd import mcp_group
|
|
162
172
|
from tescmd.cli.media import media_group
|
|
163
173
|
from tescmd.cli.nav import nav_group
|
|
174
|
+
from tescmd.cli.openclaw import openclaw_group
|
|
164
175
|
from tescmd.cli.partner import partner_group
|
|
165
176
|
from tescmd.cli.raw import raw_group
|
|
166
177
|
from tescmd.cli.security import security_group
|
|
178
|
+
from tescmd.cli.serve import serve_cmd
|
|
167
179
|
from tescmd.cli.setup import setup_cmd
|
|
168
180
|
from tescmd.cli.sharing import sharing_group
|
|
169
181
|
from tescmd.cli.software import software_group
|
|
@@ -179,11 +191,14 @@ def _register_commands() -> None:
|
|
|
179
191
|
cli.add_command(climate_group)
|
|
180
192
|
cli.add_command(energy_group)
|
|
181
193
|
cli.add_command(key_group)
|
|
194
|
+
cli.add_command(mcp_group)
|
|
182
195
|
cli.add_command(media_group)
|
|
183
196
|
cli.add_command(nav_group)
|
|
197
|
+
cli.add_command(openclaw_group)
|
|
184
198
|
cli.add_command(partner_group)
|
|
185
199
|
cli.add_command(raw_group)
|
|
186
200
|
cli.add_command(security_group)
|
|
201
|
+
cli.add_command(serve_cmd)
|
|
187
202
|
cli.add_command(setup_cmd)
|
|
188
203
|
cli.add_command(sharing_group)
|
|
189
204
|
cli.add_command(status_cmd)
|
|
@@ -287,6 +302,9 @@ def _handle_known_error(
|
|
|
287
302
|
if isinstance(exc, SessionError):
|
|
288
303
|
_handle_session_error(exc, formatter, cmd_name)
|
|
289
304
|
return True
|
|
305
|
+
if isinstance(exc, TunnelError):
|
|
306
|
+
_handle_tunnel_error(exc, formatter, cmd_name)
|
|
307
|
+
return True
|
|
290
308
|
|
|
291
309
|
# Keyring failures (e.g. headless Linux with no keyring daemon)
|
|
292
310
|
try:
|
|
@@ -559,14 +577,19 @@ def _handle_session_error(
|
|
|
559
577
|
formatter.rich.error(message)
|
|
560
578
|
formatter.rich.info("")
|
|
561
579
|
formatter.rich.info("Possible causes:")
|
|
562
|
-
formatter.rich.info(" - Vehicle is temporarily unreachable")
|
|
563
|
-
formatter.rich.info(" -
|
|
564
|
-
formatter.rich.info(
|
|
580
|
+
formatter.rich.info(" - Vehicle is temporarily unreachable or asleep")
|
|
581
|
+
formatter.rich.info(" - Session expired — retry once and a fresh handshake will occur")
|
|
582
|
+
formatter.rich.info(
|
|
583
|
+
" - Local key pair is corrupted or was re-generated without re-enrollment"
|
|
584
|
+
)
|
|
565
585
|
formatter.rich.info("")
|
|
566
|
-
formatter.rich.info("Try
|
|
567
|
-
formatter.rich.info("
|
|
568
|
-
formatter.rich.info(" [cyan]
|
|
569
|
-
formatter.rich.info("
|
|
586
|
+
formatter.rich.info("Try:")
|
|
587
|
+
formatter.rich.info(" 1. Re-run the command (sessions auto-refresh)")
|
|
588
|
+
formatter.rich.info(" 2. Use [cyan]--verbose[/cyan] to see fault codes and signing details")
|
|
589
|
+
formatter.rich.info(" 3. If repeated failures, re-enroll:")
|
|
590
|
+
formatter.rich.info(
|
|
591
|
+
" [cyan]tescmd key generate --force && tescmd key deploy && tescmd key enroll[/cyan]"
|
|
592
|
+
)
|
|
570
593
|
|
|
571
594
|
|
|
572
595
|
def _handle_keyring_error(
|
|
@@ -599,3 +622,43 @@ def _handle_keyring_error(
|
|
|
599
622
|
formatter.rich.info(
|
|
600
623
|
"[dim]Tokens will be stored in plaintext with restricted file permissions.[/dim]"
|
|
601
624
|
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _handle_tunnel_error(
|
|
628
|
+
exc: TunnelError,
|
|
629
|
+
formatter: OutputFormatter,
|
|
630
|
+
cmd_name: str,
|
|
631
|
+
) -> None:
|
|
632
|
+
"""Show a friendly error for tunnel-related failures.
|
|
633
|
+
|
|
634
|
+
Only shows install guidance when no provider is found on PATH.
|
|
635
|
+
TailscaleError means Tailscale IS installed but hit an operational
|
|
636
|
+
error — install guidance would be confusing.
|
|
637
|
+
"""
|
|
638
|
+
message = str(exc) or "No tunnel provider available for telemetry streaming."
|
|
639
|
+
|
|
640
|
+
if formatter.format == "json":
|
|
641
|
+
formatter.output_error(
|
|
642
|
+
code="tunnel_error",
|
|
643
|
+
message=message,
|
|
644
|
+
command=cmd_name,
|
|
645
|
+
)
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
formatter.rich.error(message)
|
|
649
|
+
|
|
650
|
+
# Only show install guidance when no provider was found at all.
|
|
651
|
+
if "No tunnel provider" not in message:
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
formatter.rich.info("")
|
|
655
|
+
formatter.rich.info("Telemetry streaming requires Tailscale Funnel:")
|
|
656
|
+
formatter.rich.info("")
|
|
657
|
+
formatter.rich.info(" 1. Install Tailscale: [cyan]https://tailscale.com/download[/cyan]")
|
|
658
|
+
formatter.rich.info(" 2. Authenticate: [cyan]tailscale up[/cyan]")
|
|
659
|
+
formatter.rich.info(
|
|
660
|
+
" 3. Enable Funnel in your tailnet ACL:"
|
|
661
|
+
" [cyan]https://login.tailscale.com/admin/acls[/cyan]"
|
|
662
|
+
)
|
|
663
|
+
formatter.rich.info("")
|
|
664
|
+
formatter.rich.info("Telemetry deps are included with tescmd.")
|