tescmd 0.1.2__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 (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/cli/setup.py ADDED
@@ -0,0 +1,786 @@
1
+ """CLI command for the tiered onboarding wizard (``tescmd setup``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import click
10
+
11
+ from tescmd._internal.async_utils import run_async
12
+ from tescmd.cli._options import global_options
13
+ from tescmd.models.config import AppSettings
14
+
15
+ if TYPE_CHECKING:
16
+ from tescmd.cli.main import AppContext
17
+ from tescmd.output.formatter import OutputFormatter
18
+
19
+ # Callable signature for ``formatter.rich.info`` (and mocks in tests).
20
+ _InfoFn = Callable[..., object]
21
+
22
+ TIER_READONLY = "readonly"
23
+ TIER_FULL = "full"
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Click command
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ @click.command("setup")
32
+ @global_options
33
+ def setup_cmd(app_ctx: AppContext) -> None:
34
+ """Interactive setup wizard for first-time configuration."""
35
+ run_async(_cmd_setup(app_ctx))
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Main wizard
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ async def _cmd_setup(app_ctx: AppContext) -> None:
44
+ """Run the tiered onboarding wizard."""
45
+ formatter = app_ctx.formatter
46
+ settings = AppSettings()
47
+
48
+ # Phase 0: Welcome + tier selection
49
+ tier = _prompt_tier(formatter, settings)
50
+ if not tier:
51
+ return
52
+
53
+ # Phase 1: Domain setup via GitHub Pages (must happen before developer
54
+ # portal because Tesla requires the Allowed Origin URL to match the
55
+ # registration domain)
56
+ domain = _domain_setup(formatter, settings)
57
+ if not domain:
58
+ return
59
+
60
+ # Re-read settings after potential .env changes
61
+ settings = AppSettings()
62
+
63
+ # Phase 2: Developer portal walkthrough (credentials — uses domain for
64
+ # the Allowed Origin URL instructions)
65
+ client_id, client_secret = _developer_portal_setup(formatter, app_ctx, settings, domain=domain)
66
+ if not client_id:
67
+ return
68
+
69
+ # Re-read settings again
70
+ settings = AppSettings()
71
+
72
+ # Phase 3: Key generation + deployment (full tier only)
73
+ if tier == TIER_FULL:
74
+ _key_setup(formatter, settings, domain)
75
+
76
+ # Phase 3.5: Key enrollment (full tier only)
77
+ if tier == TIER_FULL:
78
+ await _enrollment_step(formatter, app_ctx, settings)
79
+
80
+ # Phase 4: Fleet API partner registration
81
+ await _registration_step(formatter, app_ctx, settings, client_id, client_secret, domain)
82
+
83
+ # Phase 5: OAuth login
84
+ await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret)
85
+
86
+ # Phase 6: Summary
87
+ _print_next_steps(formatter, tier)
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Phase 0: Tier selection
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
96
+ """Ask the user which tier they want and persist the choice."""
97
+ info = formatter.rich.info
98
+
99
+ # If already configured, offer to keep or change
100
+ existing_tier = settings.setup_tier
101
+ if existing_tier in (TIER_READONLY, TIER_FULL):
102
+ info(f"Setup tier: [cyan]{existing_tier}[/cyan] (previously configured)")
103
+ info("")
104
+
105
+ if existing_tier == TIER_FULL:
106
+ return existing_tier
107
+
108
+ # Offer upgrade from readonly → full
109
+ try:
110
+ answer = input("Upgrade to full control? [y/N] ").strip()
111
+ except (EOFError, KeyboardInterrupt):
112
+ info("")
113
+ return ""
114
+
115
+ if answer.lower() != "y":
116
+ return existing_tier
117
+
118
+ tier = TIER_FULL
119
+ else:
120
+ info("")
121
+ info("[bold cyan]Welcome to tescmd![/bold cyan]")
122
+ info("")
123
+ info("How would you like to use tescmd?")
124
+ info("")
125
+ info(
126
+ " [bold]1.[/bold] [cyan]Read-only[/cyan]"
127
+ " — view vehicle data, location, battery status"
128
+ )
129
+ info(" (Requires: Tesla Developer app + domain for registration)")
130
+ info("")
131
+ info(
132
+ " [bold]2.[/bold] [cyan]Full control[/cyan]"
133
+ " — read data + lock/unlock, charge, climate, etc."
134
+ )
135
+ info(" (Requires: all of the above + EC key pair deployed to your domain)")
136
+ info(
137
+ " [dim]Enables Fleet Telemetry streaming — up to 97% cost"
138
+ " reduction vs polling the REST API.[/dim]"
139
+ )
140
+ info("")
141
+
142
+ try:
143
+ choice = input("Choose [1] or [2] (default: 1): ").strip()
144
+ except (EOFError, KeyboardInterrupt):
145
+ info("")
146
+ return ""
147
+
148
+ tier = TIER_FULL if choice == "2" else TIER_READONLY
149
+
150
+ # Persist tier
151
+ from tescmd.cli.auth import _write_env_value
152
+
153
+ _write_env_value("TESLA_SETUP_TIER", tier)
154
+ info(f"[green]Tier set to: {tier}[/green]")
155
+ info("")
156
+
157
+ return tier
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Phase 2: Developer portal walkthrough
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def _developer_portal_setup(
166
+ formatter: OutputFormatter,
167
+ app_ctx: AppContext,
168
+ settings: AppSettings,
169
+ *,
170
+ domain: str = "",
171
+ ) -> tuple[str, str]:
172
+ """Walk through Tesla Developer Portal setup if credentials are missing."""
173
+ info = formatter.rich.info
174
+
175
+ client_id = settings.client_id
176
+ client_secret = settings.client_secret
177
+
178
+ if client_id:
179
+ info(f"Client ID: [cyan]{client_id[:8]}...[/cyan] (already configured)")
180
+ return (client_id, client_secret or "")
181
+
182
+ info("[bold]Phase 2: Tesla Developer Portal Setup[/bold]")
183
+ info("")
184
+
185
+ # Delegate to the existing interactive setup wizard, passing the domain
186
+ # so the portal instructions show the correct Allowed Origin URL
187
+ port = 8085
188
+ redirect_uri = f"http://localhost:{port}/callback"
189
+ from tescmd.cli.auth import _interactive_setup
190
+
191
+ return _interactive_setup(formatter, port, redirect_uri, domain=domain)
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Phase 1: Domain setup
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
200
+ """Set up a domain via GitHub Pages or manual entry."""
201
+ info = formatter.rich.info
202
+
203
+ if settings.domain:
204
+ info(f"Domain: [cyan]{settings.domain}[/cyan] (already configured)")
205
+ info("")
206
+ return settings.domain
207
+
208
+ info("[bold]Phase 1: Domain Setup[/bold]")
209
+ info("")
210
+ info(
211
+ "Tesla requires a registered domain for Fleet API access."
212
+ " The easiest approach is a free GitHub Pages site."
213
+ )
214
+ info("")
215
+
216
+ # Try automated GitHub Pages setup
217
+ from tescmd.deploy.github_pages import is_gh_authenticated, is_gh_available
218
+
219
+ if is_gh_available() and is_gh_authenticated():
220
+ return _automated_domain_setup(formatter, settings)
221
+
222
+ # Fall back to manual domain entry
223
+ return _manual_domain_setup(formatter)
224
+
225
+
226
+ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
227
+ """Offer to auto-create a GitHub Pages site."""
228
+ info = formatter.rich.info
229
+
230
+ from tescmd.deploy.github_pages import (
231
+ create_pages_repo,
232
+ get_gh_username,
233
+ get_pages_domain,
234
+ )
235
+
236
+ username = get_gh_username()
237
+ suggested_domain = f"{username}.github.io".lower()
238
+
239
+ info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
240
+ info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
241
+ info("")
242
+
243
+ try:
244
+ answer = input(f"Create/use {suggested_domain} as your domain? [Y/n] ").strip()
245
+ except (EOFError, KeyboardInterrupt):
246
+ info("")
247
+ return ""
248
+
249
+ if answer.lower() == "n":
250
+ return _manual_domain_setup(formatter)
251
+
252
+ info("Creating GitHub Pages repo...")
253
+ repo_name = create_pages_repo(username)
254
+ domain = get_pages_domain(repo_name)
255
+
256
+ # Persist domain and repo to .env
257
+ from tescmd.cli.auth import _write_env_value
258
+
259
+ _write_env_value("TESLA_DOMAIN", domain)
260
+ _write_env_value("TESLA_GITHUB_REPO", repo_name)
261
+
262
+ info(f"[green]Domain configured: {domain}[/green]")
263
+ info(f"[green]GitHub repo: {repo_name}[/green]")
264
+ info("")
265
+
266
+ return domain
267
+
268
+
269
+ def _manual_domain_setup(formatter: OutputFormatter) -> str:
270
+ """Prompt for a domain manually."""
271
+ from tescmd.cli.auth import _prompt_for_domain
272
+
273
+ return _prompt_for_domain(formatter)
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Phase 3: Key generation + deployment
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -> None:
282
+ """Generate keys and deploy to GitHub Pages (full tier only)."""
283
+ info = formatter.rich.info
284
+
285
+ info("[bold]Phase 3: EC Key Generation & Deployment[/bold]")
286
+ info("")
287
+
288
+ key_dir = Path(settings.config_dir).expanduser() / "keys"
289
+
290
+ from tescmd.crypto.keys import (
291
+ generate_ec_key_pair,
292
+ get_key_fingerprint,
293
+ has_key_pair,
294
+ load_public_key_pem,
295
+ )
296
+
297
+ # Generate keys if needed
298
+ if has_key_pair(key_dir):
299
+ info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
300
+ else:
301
+ info("Generating EC P-256 key pair...")
302
+ generate_ec_key_pair(key_dir)
303
+ info("[green]Key pair generated.[/green]")
304
+ info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
305
+
306
+ info("")
307
+
308
+ # Deploy key via GitHub Pages if gh is available
309
+ from tescmd.deploy.github_pages import (
310
+ deploy_public_key,
311
+ get_key_url,
312
+ is_gh_authenticated,
313
+ is_gh_available,
314
+ validate_key_url,
315
+ wait_for_pages_deployment,
316
+ )
317
+
318
+ github_repo = settings.github_repo
319
+ if not github_repo:
320
+ info("[yellow]No GitHub repo configured for key deployment.[/yellow]")
321
+ info(" Run [cyan]tescmd key deploy[/cyan] to deploy your public key.")
322
+ info("")
323
+ return
324
+
325
+ if not (is_gh_available() and is_gh_authenticated()):
326
+ info("[yellow]GitHub CLI not available or not authenticated.[/yellow]")
327
+ info(" Run [cyan]tescmd key deploy[/cyan] after setting up gh CLI.")
328
+ info("")
329
+ return
330
+
331
+ # Check if key is already deployed
332
+ if validate_key_url(domain):
333
+ info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
334
+ info("")
335
+ return
336
+
337
+ info("Deploying public key to GitHub Pages...")
338
+ pem = load_public_key_pem(key_dir)
339
+ deploy_public_key(pem, github_repo)
340
+
341
+ info("[green]Key committed and pushed.[/green]")
342
+ info("Waiting for GitHub Pages to publish...")
343
+
344
+ deployed = wait_for_pages_deployment(domain)
345
+ if deployed:
346
+ info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
347
+ else:
348
+ info(
349
+ "[yellow]Key deployed but not yet accessible."
350
+ " GitHub Pages may still be building.[/yellow]"
351
+ )
352
+ info(" Run [cyan]tescmd key validate[/cyan] to check later.")
353
+ info("")
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Phase 3.5: Key enrollment
358
+ # ---------------------------------------------------------------------------
359
+
360
+
361
+ async def _enrollment_step(
362
+ formatter: OutputFormatter,
363
+ app_ctx: AppContext,
364
+ settings: AppSettings,
365
+ ) -> None:
366
+ """Guide the user through key enrollment via the Tesla app portal."""
367
+ import webbrowser
368
+
369
+ from tescmd.crypto.keys import get_key_fingerprint, has_key_pair
370
+ from tescmd.deploy.github_pages import get_key_url, validate_key_url
371
+
372
+ info = formatter.rich.info
373
+ key_dir = Path(settings.config_dir).expanduser() / "keys"
374
+
375
+ if not has_key_pair(key_dir):
376
+ return # No keys to enroll
377
+
378
+ domain = settings.domain
379
+ if not domain:
380
+ info("[yellow]No domain configured — skipping enrollment.[/yellow]")
381
+ info(" Run [cyan]tescmd key enroll[/cyan] after setting TESLA_DOMAIN.")
382
+ info("")
383
+ return
384
+
385
+ info("[bold]Phase 3.5: Key Enrollment[/bold]")
386
+ info("")
387
+ info(" Your key is generated and deployed. To control a vehicle, the key")
388
+ info(" must also be enrolled via the Tesla app.")
389
+ info("")
390
+
391
+ # Verify the public key is accessible
392
+ key_url = get_key_url(domain)
393
+ key_accessible = validate_key_url(domain)
394
+ if not key_accessible:
395
+ info(f" [yellow]Public key not accessible at {key_url}[/yellow]")
396
+ info(" Enrollment requires the key to be live. Skipping for now.")
397
+ info(" After deploying, run [cyan]tescmd key enroll[/cyan].")
398
+ info("")
399
+ return
400
+
401
+ fingerprint = get_key_fingerprint(key_dir)
402
+ enroll_url = f"https://tesla.com/_ak/{domain}"
403
+
404
+ info(f" Domain: {domain}")
405
+ info(f" Fingerprint: {fingerprint[:8]}…")
406
+ info(f" Public key: [green]accessible[/green] at {key_url}")
407
+ info("")
408
+
409
+ try:
410
+ answer = input(" Open enrollment URL in your browser? [Y/n] ").strip()
411
+ except (EOFError, KeyboardInterrupt):
412
+ info("")
413
+ return
414
+
415
+ if answer.lower() not in ("n", "no"):
416
+ info("")
417
+ info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
418
+ webbrowser.open(enroll_url)
419
+ info("")
420
+
421
+ info(" " + "━" * 49)
422
+ info(" [bold yellow]ACTION REQUIRED: Add virtual key in the Tesla app[/bold yellow]")
423
+ info("")
424
+ info(f" Enrollment URL: {enroll_url}")
425
+ info("")
426
+ info(" 1. Open the URL above [bold]on your phone[/bold]")
427
+ info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
428
+ info(" 3. The Tesla app shows an [bold]Add Virtual Key[/bold] prompt")
429
+ info(" 4. Approve it")
430
+ info("")
431
+ info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
432
+ info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
433
+ info(" " + "━" * 49)
434
+ info("")
435
+ info(" After approving, try: [cyan]tescmd charge status --wake[/cyan]")
436
+ info("")
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Phase 4: Fleet API registration
441
+ # ---------------------------------------------------------------------------
442
+
443
+
444
+ async def _registration_step(
445
+ formatter: OutputFormatter,
446
+ app_ctx: AppContext,
447
+ settings: AppSettings,
448
+ client_id: str,
449
+ client_secret: str,
450
+ domain: str,
451
+ ) -> None:
452
+ """Register with the Tesla Fleet API."""
453
+ info = formatter.rich.info
454
+
455
+ if not client_secret:
456
+ info("[yellow]Skipping Fleet API registration (no client secret).[/yellow]")
457
+ info(" Run [cyan]tescmd auth register[/cyan] after adding TESLA_CLIENT_SECRET.")
458
+ info("")
459
+ return
460
+
461
+ info("[bold]Phase 4: Fleet API Registration[/bold]")
462
+ info("")
463
+
464
+ # Pre-check: Tesla requires the public key to be accessible before
465
+ # registration will succeed (HTTP 424 otherwise).
466
+ key_ready = _precheck_public_key(formatter, settings, domain)
467
+ if not key_ready:
468
+ info("[yellow]Skipping registration — public key not accessible.[/yellow]")
469
+ info(" Run [cyan]tescmd auth register[/cyan] once the key is live.")
470
+ info("")
471
+ return
472
+
473
+ from tescmd.auth.oauth import register_partner_account
474
+
475
+ region = app_ctx.region or settings.region
476
+
477
+ info(f"Registering with Fleet API ({region} region)...")
478
+ try:
479
+ _result, _scopes = await register_partner_account(
480
+ client_id=client_id,
481
+ client_secret=client_secret,
482
+ domain=domain,
483
+ region=region,
484
+ )
485
+ info("[green]Registration successful.[/green]")
486
+ except Exception as exc:
487
+ status_code = getattr(exc, "status_code", None)
488
+ exc_text = str(exc)
489
+
490
+ if status_code == 412 or "must match registered allowed origin" in exc_text:
491
+ _remediate_412(info, domain)
492
+ elif status_code == 424 or "Public key download failed" in exc_text:
493
+ _remediate_424(info, domain)
494
+ else:
495
+ info(f"[yellow]Registration failed:[/yellow] {exc}")
496
+ info(" Run [cyan]tescmd auth register[/cyan] to retry.")
497
+ info("")
498
+
499
+
500
+ def _precheck_public_key(
501
+ formatter: OutputFormatter,
502
+ settings: AppSettings,
503
+ domain: str,
504
+ ) -> bool:
505
+ """Verify the public key is accessible; offer to deploy if not.
506
+
507
+ Returns True when the key is confirmed live (or was already live),
508
+ False when the user declines or deployment/validation fails.
509
+ """
510
+ info = formatter.rich.info
511
+
512
+ from tescmd.deploy.github_pages import get_key_url, validate_key_url
513
+
514
+ info("Checking public key availability...")
515
+
516
+ if validate_key_url(domain):
517
+ info(f" Public key: [green]accessible[/green] at {get_key_url(domain)}")
518
+ info("")
519
+ return True
520
+
521
+ info(f" Public key: [yellow]not found[/yellow] at {get_key_url(domain)}")
522
+ info("")
523
+ info(" Tesla requires your public key to be accessible before registration will succeed.")
524
+ info("")
525
+
526
+ # Offer to automate key generation + deployment
527
+ try:
528
+ answer = input("Generate and deploy the public key now? [Y/n] ").strip()
529
+ except (EOFError, KeyboardInterrupt):
530
+ info("")
531
+ return False
532
+
533
+ if answer.lower() == "n":
534
+ _remediate_424(info, domain)
535
+ return False
536
+
537
+ # Automate: generate (if needed) + deploy + wait
538
+ return _auto_deploy_key(formatter, settings, domain)
539
+
540
+
541
+ def _auto_deploy_key(
542
+ formatter: OutputFormatter,
543
+ settings: AppSettings,
544
+ domain: str,
545
+ ) -> bool:
546
+ """Generate a key pair (if needed), deploy to GitHub Pages, and wait.
547
+
548
+ Returns True when the key is confirmed accessible, False otherwise.
549
+ """
550
+ info = formatter.rich.info
551
+ key_dir = Path(settings.config_dir).expanduser() / "keys"
552
+
553
+ from tescmd.crypto.keys import (
554
+ generate_ec_key_pair,
555
+ get_key_fingerprint,
556
+ has_key_pair,
557
+ load_public_key_pem,
558
+ )
559
+
560
+ # 1. Generate keys if needed
561
+ if has_key_pair(key_dir):
562
+ info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
563
+ else:
564
+ info("Generating EC P-256 key pair...")
565
+ generate_ec_key_pair(key_dir)
566
+ info(f"[green]Key pair generated.[/green] Fingerprint: {get_key_fingerprint(key_dir)}")
567
+ info("")
568
+
569
+ # 2. Deploy to GitHub Pages
570
+ from tescmd.deploy.github_pages import (
571
+ deploy_public_key,
572
+ get_key_url,
573
+ is_gh_authenticated,
574
+ is_gh_available,
575
+ validate_key_url,
576
+ wait_for_pages_deployment,
577
+ )
578
+
579
+ github_repo = settings.github_repo
580
+ if not github_repo:
581
+ info("[yellow]No GitHub repo configured for key deployment.[/yellow]")
582
+ info(" Run [cyan]tescmd key deploy[/cyan] to deploy your public key.")
583
+ info("")
584
+ return False
585
+
586
+ if not (is_gh_available() and is_gh_authenticated()):
587
+ info("[yellow]GitHub CLI not available or not authenticated.[/yellow]")
588
+ info(" Run [cyan]tescmd key deploy[/cyan] after setting up gh CLI.")
589
+ info("")
590
+ return False
591
+
592
+ # Already deployed?
593
+ if validate_key_url(domain):
594
+ info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
595
+ info("")
596
+ return True
597
+
598
+ info("Deploying public key to GitHub Pages...")
599
+ pem = load_public_key_pem(key_dir)
600
+ deploy_public_key(pem, github_repo)
601
+
602
+ info("[green]Key committed and pushed.[/green]")
603
+ info("Waiting for GitHub Pages to publish...")
604
+
605
+ deployed = wait_for_pages_deployment(domain)
606
+ if deployed:
607
+ info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
608
+ info("")
609
+ return True
610
+
611
+ info(
612
+ "[yellow]Key deployed but not yet accessible. GitHub Pages may still be building.[/yellow]"
613
+ )
614
+ info(
615
+ " Run [cyan]tescmd key validate[/cyan] to check, then [cyan]tescmd auth register[/cyan]."
616
+ )
617
+ info("")
618
+ return False
619
+
620
+
621
+ def _remediate_412(info: _InfoFn, domain: str) -> None:
622
+ """Print remediation steps for HTTP 412 (origin mismatch)."""
623
+ info("[yellow]Registration failed (HTTP 412): origin mismatch.[/yellow]")
624
+ info("")
625
+ info("[bold]How to fix:[/bold]")
626
+ info(
627
+ " The [cyan]Allowed Origin URL[/cyan] in your Tesla Developer"
628
+ " app must match your registration domain."
629
+ )
630
+ info("")
631
+ info(" 1. Go to [cyan]https://developer.tesla.com[/cyan]")
632
+ info(" 2. Open your application")
633
+ info(" 3. Set [cyan]Allowed Origin URL[/cyan] to:")
634
+ info(f" [bold]https://{domain}[/bold]")
635
+ info(" 4. Save, then re-run [cyan]tescmd setup[/cyan]")
636
+
637
+
638
+ def _remediate_424(info: _InfoFn, domain: str) -> None:
639
+ """Print remediation steps for HTTP 424 (public key not found)."""
640
+ from tescmd.deploy.github_pages import WELL_KNOWN_PATH
641
+
642
+ key_url = f"https://{domain}/{WELL_KNOWN_PATH}"
643
+
644
+ info("[yellow]Registration failed (HTTP 424): public key not found.[/yellow]")
645
+ info("")
646
+ info("[bold]How to fix:[/bold]")
647
+ info(" Tesla tried to download your public key during registration but got a 404.")
648
+ info(f" Expected URL: [cyan]{key_url}[/cyan]")
649
+ info("")
650
+ info(" 1. Generate a key pair (if you haven't already):")
651
+ info(" [cyan]tescmd key generate[/cyan]")
652
+ info("")
653
+ info(" 2. Deploy the public key to your domain:")
654
+ info(" [cyan]tescmd key deploy[/cyan]")
655
+ info("")
656
+ info(" 3. Verify the key is accessible:")
657
+ info(" [cyan]tescmd key validate[/cyan]")
658
+ info("")
659
+ info(
660
+ " 4. Once the key is live, re-run [cyan]tescmd setup[/cyan]"
661
+ " (or [cyan]tescmd auth register[/cyan])."
662
+ )
663
+ info("")
664
+ info(
665
+ "[dim]If you just deployed the key, GitHub Pages may still"
666
+ " be building. Wait a minute and try again.[/dim]"
667
+ )
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # Phase 5: OAuth login
672
+ # ---------------------------------------------------------------------------
673
+
674
+
675
+ async def _oauth_login_step(
676
+ formatter: OutputFormatter,
677
+ app_ctx: AppContext,
678
+ settings: AppSettings,
679
+ client_id: str,
680
+ client_secret: str,
681
+ ) -> None:
682
+ """Run the OAuth2 login flow."""
683
+ info = formatter.rich.info
684
+
685
+ from tescmd.auth.token_store import TokenStore
686
+ from tescmd.models.auth import DEFAULT_SCOPES
687
+
688
+ store = TokenStore(
689
+ profile=app_ctx.profile,
690
+ token_file=settings.token_file,
691
+ config_dir=settings.config_dir,
692
+ )
693
+
694
+ if store.has_token:
695
+ # Check whether the stored scopes cover what we need.
696
+ # A readonly→full upgrade requires vehicle_cmds + vehicle_charging_cmds
697
+ # that the original readonly token may not have.
698
+ stored_scopes = set((store.metadata or {}).get("scopes", []))
699
+ required_scopes = set(DEFAULT_SCOPES)
700
+ missing = required_scopes - stored_scopes
701
+
702
+ if not missing:
703
+ info("Already logged in with required scopes. Skipping OAuth flow.")
704
+ info("")
705
+ return
706
+
707
+ info("[bold]Phase 5: OAuth Login[/bold]")
708
+ info("")
709
+ info("[yellow]Your existing token is missing scopes needed for full control:[/yellow]")
710
+ for scope in sorted(missing):
711
+ info(f" - {scope}")
712
+ info("")
713
+ info("Re-authenticating to request all required scopes...")
714
+ info("")
715
+ else:
716
+ info("[bold]Phase 5: OAuth Login[/bold]")
717
+ info("")
718
+
719
+ port = 8085
720
+ redirect_uri = f"http://localhost:{port}/callback"
721
+
722
+ info("Opening your browser to sign in to Tesla...")
723
+ info(
724
+ "When prompted, click [cyan]Select All[/cyan] and then"
725
+ " [cyan]Allow[/cyan] to grant tescmd access."
726
+ )
727
+ info("[dim]If the browser doesn't open, visit the URL printed below.[/dim]")
728
+
729
+ from tescmd.auth.oauth import login_flow
730
+
731
+ region = app_ctx.region or settings.region
732
+
733
+ await login_flow(
734
+ client_id=client_id,
735
+ client_secret=client_secret,
736
+ redirect_uri=redirect_uri,
737
+ scopes=DEFAULT_SCOPES,
738
+ port=port,
739
+ token_store=store,
740
+ region=region,
741
+ )
742
+
743
+ info("[bold green]Login successful![/bold green]")
744
+ info("")
745
+
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # Phase 6: Next steps
749
+ # ---------------------------------------------------------------------------
750
+
751
+
752
+ def _print_next_steps(formatter: OutputFormatter, tier: str) -> None:
753
+ """Print a summary of what the user can do next."""
754
+ info = formatter.rich.info
755
+
756
+ info("[bold cyan]Setup complete![/bold cyan]")
757
+ info("")
758
+ info("Try these commands:")
759
+ info(" [cyan]tescmd vehicle list[/cyan] — list your vehicles")
760
+ info(" [cyan]tescmd vehicle data[/cyan] — view detailed vehicle data")
761
+ info(" [cyan]tescmd vehicle location[/cyan] — view vehicle location")
762
+ info("")
763
+
764
+ if tier == TIER_FULL:
765
+ info("[bold]For vehicle commands:[/bold]")
766
+ info(" If you haven't already, enroll your key on each vehicle:")
767
+ info(" [cyan]tescmd key enroll[/cyan]")
768
+ info("")
769
+ info(" Once enrolled, try:")
770
+ info(" [cyan]tescmd vehicle wake[/cyan] — wake up your vehicle")
771
+ info("")
772
+ info("[bold]For real-time streaming data:[/bold]")
773
+ info(
774
+ " Fleet Telemetry can replace REST polling with server-push,"
775
+ " cutting API costs by up to 97%."
776
+ )
777
+ info(
778
+ " See: [cyan]https://developer.tesla.com/docs/fleet-api"
779
+ "/getting-started/fleet-telemetry[/cyan]"
780
+ )
781
+ info("")
782
+ else:
783
+ info(
784
+ "[dim]Upgrade to full control later by running [cyan]tescmd setup[/cyan] again.[/dim]"
785
+ )
786
+ info("")