tescmd 0.1.2__py3-none-any.whl → 0.2.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 +8 -1
- tescmd/api/errors.py +8 -0
- tescmd/api/vehicle.py +19 -1
- tescmd/cache/response_cache.py +3 -2
- tescmd/cli/auth.py +30 -2
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +44 -0
- tescmd/cli/setup.py +230 -22
- tescmd/cli/vehicle.py +464 -1
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +19 -0
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/protocol/session.py +10 -3
- tescmd/telemetry/__init__.py +19 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fields.py +248 -0
- tescmd/telemetry/flatbuf.py +162 -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 +293 -0
- tescmd/telemetry/tailscale.py +300 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/licenses/LICENSE +0 -0
tescmd/__init__.py
CHANGED
tescmd/api/client.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
from typing import TYPE_CHECKING, Any
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
@@ -185,5 +186,11 @@ class TeslaFleetClient:
|
|
|
185
186
|
status_code=response.status_code,
|
|
186
187
|
)
|
|
187
188
|
|
|
188
|
-
|
|
189
|
+
try:
|
|
190
|
+
result: dict[str, Any] = response.json()
|
|
191
|
+
except json.JSONDecodeError as exc:
|
|
192
|
+
raise TeslaAPIError(
|
|
193
|
+
f"Invalid JSON response: {response.text[:200]}",
|
|
194
|
+
status_code=response.status_code,
|
|
195
|
+
) from exc
|
|
189
196
|
return result
|
tescmd/api/errors.py
CHANGED
|
@@ -74,3 +74,11 @@ class MissingScopesError(AuthError):
|
|
|
74
74
|
|
|
75
75
|
class KeyNotEnrolledError(TeslaAPIError):
|
|
76
76
|
"""Vehicle rejected the command — key not enrolled."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TunnelError(ConfigError):
|
|
80
|
+
"""Tunnel provider not available or setup failed."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TailscaleError(TunnelError):
|
|
84
|
+
"""Tailscale not installed, not running, or Funnel setup failed."""
|
tescmd/api/vehicle.py
CHANGED
|
@@ -132,11 +132,29 @@ class VehicleAPI:
|
|
|
132
132
|
return result
|
|
133
133
|
|
|
134
134
|
async def fleet_telemetry_config_create(self, *, config: dict[str, Any]) -> dict[str, Any]:
|
|
135
|
-
"""Create or update fleet telemetry server configuration.
|
|
135
|
+
"""Create or update fleet telemetry server configuration.
|
|
136
|
+
|
|
137
|
+
.. deprecated::
|
|
138
|
+
Tesla now requires the Vehicle Command HTTP Proxy for this
|
|
139
|
+
endpoint. Use :meth:`fleet_telemetry_config_create_jws` instead.
|
|
140
|
+
"""
|
|
136
141
|
data = await self._client.post("/api/1/vehicles/fleet_telemetry_config", json=config)
|
|
137
142
|
result: dict[str, Any] = data.get("response", {})
|
|
138
143
|
return result
|
|
139
144
|
|
|
145
|
+
async def fleet_telemetry_config_create_jws(
|
|
146
|
+
self, *, vins: list[str], token: str
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""Create fleet telemetry config via the JWS-signed endpoint.
|
|
149
|
+
|
|
150
|
+
*token* is a compact JWS signed with the Tesla.SS256 algorithm
|
|
151
|
+
(Schnorr/P-256). See :func:`tescmd.crypto.schnorr.sign_fleet_telemetry_config`.
|
|
152
|
+
"""
|
|
153
|
+
payload = {"vins": vins, "token": token}
|
|
154
|
+
data = await self._client.post("/api/1/vehicles/fleet_telemetry_config_jws", json=payload)
|
|
155
|
+
result: dict[str, Any] = data.get("response", {})
|
|
156
|
+
return result
|
|
157
|
+
|
|
140
158
|
async def fleet_telemetry_config_delete(self, vin: str) -> dict[str, Any]:
|
|
141
159
|
"""Remove fleet telemetry configuration from a vehicle."""
|
|
142
160
|
data = await self._client.delete(f"/api/1/vehicles/{vin}/fleet_telemetry_config")
|
tescmd/cache/response_cache.py
CHANGED
|
@@ -196,10 +196,11 @@ class ResponseCache:
|
|
|
196
196
|
|
|
197
197
|
def _write_entry(self, path: Path, data: dict[str, Any], ttl: int) -> None:
|
|
198
198
|
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
now = time.time()
|
|
199
200
|
entry = {
|
|
200
201
|
"data": data,
|
|
201
|
-
"created_at":
|
|
202
|
-
"expires_at":
|
|
202
|
+
"created_at": now,
|
|
203
|
+
"expires_at": now + ttl,
|
|
203
204
|
}
|
|
204
205
|
path.write_text(json.dumps(entry), encoding="utf-8")
|
|
205
206
|
|
tescmd/cli/auth.py
CHANGED
|
@@ -20,7 +20,13 @@ 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:
|
|
@@ -182,11 +188,23 @@ async def _cmd_status(app_ctx: AppContext) -> None:
|
|
|
182
188
|
region: str = meta.get("region", "unknown")
|
|
183
189
|
has_refresh = store.refresh_token is not None
|
|
184
190
|
|
|
185
|
-
# Decode the JWT to show the *actual* granted scopes
|
|
191
|
+
# Decode the JWT to show the *actual* granted scopes and audience
|
|
186
192
|
token_scopes: list[str] | None = None
|
|
193
|
+
audience: str | None = None
|
|
187
194
|
access_token = store.access_token
|
|
188
195
|
if access_token:
|
|
189
196
|
token_scopes = decode_jwt_scopes(access_token)
|
|
197
|
+
jwt_payload = decode_jwt_payload(access_token)
|
|
198
|
+
if jwt_payload is not None:
|
|
199
|
+
aud_raw = jwt_payload.get("aud")
|
|
200
|
+
if isinstance(aud_raw, list) and aud_raw:
|
|
201
|
+
audience = aud_raw[0] if len(aud_raw) == 1 else ", ".join(str(a) for a in aud_raw)
|
|
202
|
+
elif isinstance(aud_raw, str):
|
|
203
|
+
audience = aud_raw
|
|
204
|
+
# Also check for 'ou' (origin URL) — Tesla-specific claim
|
|
205
|
+
ou = jwt_payload.get("ou")
|
|
206
|
+
if isinstance(ou, str) and ou:
|
|
207
|
+
audience = ou
|
|
190
208
|
|
|
191
209
|
if formatter.format == "json":
|
|
192
210
|
data: dict[str, object] = {
|
|
@@ -198,6 +216,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
|
|
|
198
216
|
}
|
|
199
217
|
if token_scopes is not None:
|
|
200
218
|
data["token_scopes"] = token_scopes
|
|
219
|
+
if audience is not None:
|
|
220
|
+
data["audience"] = audience
|
|
201
221
|
formatter.output(data, command="auth.status")
|
|
202
222
|
else:
|
|
203
223
|
formatter.rich.info("Authenticated: yes")
|
|
@@ -211,6 +231,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
|
|
|
211
231
|
formatter.rich.info(
|
|
212
232
|
f" [yellow]Warning: requested but not granted: {not_granted}[/yellow]"
|
|
213
233
|
)
|
|
234
|
+
if audience is not None:
|
|
235
|
+
formatter.rich.info(f"Audience: {audience}")
|
|
214
236
|
formatter.rich.info(f"Region: {region}")
|
|
215
237
|
formatter.rich.info(f"Refresh token: {'yes' if has_refresh else 'no'}")
|
|
216
238
|
|
|
@@ -484,6 +506,12 @@ def _interactive_setup(
|
|
|
484
506
|
info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
|
|
485
507
|
info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
|
|
486
508
|
info(" Allowed Returned URL: (leave empty)")
|
|
509
|
+
info("")
|
|
510
|
+
info(
|
|
511
|
+
" [dim]For telemetry streaming, add your Tailscale hostname"
|
|
512
|
+
" as an additional origin:[/dim]"
|
|
513
|
+
)
|
|
514
|
+
info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
|
|
487
515
|
info(" Click Next.")
|
|
488
516
|
info("")
|
|
489
517
|
|
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
|
@@ -15,6 +15,7 @@ from tescmd.api.errors import (
|
|
|
15
15
|
RegistrationRequiredError,
|
|
16
16
|
SessionError,
|
|
17
17
|
TierError,
|
|
18
|
+
TunnelError,
|
|
18
19
|
VehicleAsleepError,
|
|
19
20
|
)
|
|
20
21
|
from tescmd.output.formatter import OutputFormatter
|
|
@@ -287,6 +288,9 @@ def _handle_known_error(
|
|
|
287
288
|
if isinstance(exc, SessionError):
|
|
288
289
|
_handle_session_error(exc, formatter, cmd_name)
|
|
289
290
|
return True
|
|
291
|
+
if isinstance(exc, TunnelError):
|
|
292
|
+
_handle_tunnel_error(exc, formatter, cmd_name)
|
|
293
|
+
return True
|
|
290
294
|
|
|
291
295
|
# Keyring failures (e.g. headless Linux with no keyring daemon)
|
|
292
296
|
try:
|
|
@@ -599,3 +603,43 @@ def _handle_keyring_error(
|
|
|
599
603
|
formatter.rich.info(
|
|
600
604
|
"[dim]Tokens will be stored in plaintext with restricted file permissions.[/dim]"
|
|
601
605
|
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _handle_tunnel_error(
|
|
609
|
+
exc: TunnelError,
|
|
610
|
+
formatter: OutputFormatter,
|
|
611
|
+
cmd_name: str,
|
|
612
|
+
) -> None:
|
|
613
|
+
"""Show a friendly error for tunnel-related failures.
|
|
614
|
+
|
|
615
|
+
Only shows install guidance when no provider is found on PATH.
|
|
616
|
+
TailscaleError means Tailscale IS installed but hit an operational
|
|
617
|
+
error — install guidance would be confusing.
|
|
618
|
+
"""
|
|
619
|
+
message = str(exc) or "No tunnel provider available for telemetry streaming."
|
|
620
|
+
|
|
621
|
+
if formatter.format == "json":
|
|
622
|
+
formatter.output_error(
|
|
623
|
+
code="tunnel_error",
|
|
624
|
+
message=message,
|
|
625
|
+
command=cmd_name,
|
|
626
|
+
)
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
formatter.rich.error(message)
|
|
630
|
+
|
|
631
|
+
# Only show install guidance when no provider was found at all.
|
|
632
|
+
if "No tunnel provider" not in message:
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
formatter.rich.info("")
|
|
636
|
+
formatter.rich.info("Telemetry streaming requires Tailscale Funnel:")
|
|
637
|
+
formatter.rich.info("")
|
|
638
|
+
formatter.rich.info(" 1. Install Tailscale: [cyan]https://tailscale.com/download[/cyan]")
|
|
639
|
+
formatter.rich.info(" 2. Authenticate: [cyan]tailscale up[/cyan]")
|
|
640
|
+
formatter.rich.info(
|
|
641
|
+
" 3. Enable Funnel in your tailnet ACL:"
|
|
642
|
+
" [cyan]https://login.tailscale.com/admin/acls[/cyan]"
|
|
643
|
+
)
|
|
644
|
+
formatter.rich.info("")
|
|
645
|
+
formatter.rich.info("Install telemetry deps: [cyan]pip install tescmd[telemetry][/cyan]")
|