diff --git a/.gitignore b/.gitignore
index 1f38c45..fde996b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.DS_Store
__pycache__/
*.pyc
*.egg-info/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3c2495..772bfc1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.4.0] - 2026-02-02
+
+### Added
+
+- **Unique app name generation** — setup wizard generates a `tescmd-` application name to prevent collisions on the Tesla Developer Portal; reused on re-runs unless `--force` is passed
+- **In-process KeyServer** — ephemeral HTTP server (`KeyServer`) serves the PEM public key on localhost during interactive setup so Tailscale Funnel can proxy it without writing to the serve directory
+- **Key mismatch warning** — setup wizard detects when the remote public key (GitHub Pages or Tailscale) differs from the local key and warns before Phase 2, so the user knows a redeploy is coming
+- **`fetch_tailscale_key_pem()`** — synchronous helper in the deploy module to fetch the raw PEM from a Tailscale Funnel URL, mirroring `github_pages.fetch_key_pem()`
+- **`TailscaleManager.start_proxy()`** — reverse-proxy mode (`tailscale serve --bg http://127.0.0.1:`) for forwarding to a local HTTP server, distinct from the static-file `start_serve()`
+
+### Changed
+
+- **Setup phase reorder** — phases now run: keys → Fleet API registration → OAuth login → key enrollment (was: keys → enrollment → registration → OAuth); registration happens while credentials are fresh, enrollment is last so the user finishes in the Tesla app
+- **Credentials always required** — both Client ID and Client Secret are mandatory with retry loops (3 attempts each); empty input no longer silently skips setup
+- **Auto-save credentials** — `.env` file is written automatically after credential entry; removed the "Save to .env?" prompt
+- **`--force` regenerates app name** — passing `--force` to setup now generates a fresh `tescmd-` name instead of reusing the saved one
+- **Atomic Tailscale serve + Funnel** — `start_key_serving()` uses a single `tailscale serve --bg --funnel --set-path / ` command instead of separate serve + funnel calls
+- **`TailscaleManager.start_serve()` API** — added `port` and `funnel` keyword arguments for configurable HTTPS port and inline Funnel enablement
+- **Enrollment messaging** — streamlined to focus on QR code scanning; removed duplicate URL display and the "Open in browser?" prompt (browser opens automatically)
+- **GitHub Pages note** — clarified that Tailscale is used alongside GitHub Pages for telemetry streaming, not as a replacement
+- **Funnel cleanup uses `stop_funnel()`** — finally block in `_interactive_setup` now calls the proper `TailscaleManager.stop_funnel()` method instead of the low-level `_run()` static method, preserving state tracking and idempotency
+
+### Fixed
+
+- **CLI module HTTP isolation** — moved direct `httpx.get()` call out of `setup.py` into `tailscale_serve.fetch_tailscale_key_pem()` to comply with single-responsibility layering (CLI handles args + output, deploy modules handle HTTP)
+
+## [0.3.2] - 2026-02-02
+
+### Fixed
+
+- **OAuth URL printed for manual fallback** — `login_flow()` now prints the authorization URL before opening the browser so users can copy-paste it when `webbrowser.open()` fails
+- **422 "already registered" treated as success** — `register_partner_account()` now treats HTTP 422 with "already been taken" as idempotent success instead of raising `AuthError`; re-running setup or `auth register` shows "Already registered — no action needed"
+- **GitHub key comparison on re-deploy** — `_deploy_key_github()` fetches the remote public key and compares it to the local key; if they match, deployment is skipped; if they differ, the user is prompted before overwriting
+
## [0.3.1] - 2026-02-02
### Added
diff --git a/README.md b/README.md
index 0c1a31b..1a8a64e 100644
--- a/README.md
+++ b/README.md
@@ -12,43 +12,16 @@
-A Python CLI for querying and controlling Tesla vehicles via the Fleet API — built for both human operators and AI agents.
-
-## What It Does
-
-tescmd gives you full command-line access to Tesla's Fleet API: check battery and charge status, lock or unlock doors, control climate, open trunks, send navigation waypoints, manage Powerwalls, stream live telemetry, and more. It handles OAuth2 authentication, token refresh, key enrollment, command signing, and response caching so you don't have to. Every command works in both interactive (Rich tables) and scripted (JSON) modes, and an MCP server lets AI agents call any command as a tool.
-
-## Why tescmd?
-
-Tesla's Fleet API gives developers full access to vehicle data and commands, but working with it directly means juggling OAuth2 PKCE flows, token refresh, regional endpoints, key enrollment, and raw JSON responses.
-
-tescmd wraps all of that into a single command-line tool that handles authentication, token management, and output formatting so you can focus on what you actually want to do — check your battery, find your car, or control your vehicle.
-
-tescmd is designed to work as a tool that AI agents can invoke directly. Platforms like [OpenClaw](https://openclaw.ai/), [Claude Desktop](https://claude.ai), and other agent frameworks can call tescmd commands, parse the structured JSON output, and take actions on your behalf — "lock my car", "what's my battery at?", "start climate control". The deterministic JSON output, meaningful exit codes, cost-aware wake confirmation, and `--wake` opt-in flag make it safe for autonomous agent use without surprise billing.
+
+ The complete Python CLI for Tesla's Fleet API — built for humans and AI agents alike.
+
-## Features
+
+ Check your battery. Lock your doors. Stream live telemetry. Let Claude control your car.
+ Two commands to install. One wizard to set up. Every API endpoint at your fingertips.
+
-- **Vehicle state queries** — battery, range, charge status, climate, location, doors, windows, trunks, tire pressure, dashcam, sentry mode, and more
-- **Vehicle commands** — charge start/stop/limit/departure scheduling, climate on/off/set temp/seats/steering wheel, lock/unlock, sentry mode, trunk/frunk, windows, HomeLink, navigation waypoints, media playback, speed limits, PIN management
-- **Vehicle Command Protocol** — ECDH session management with HMAC-SHA256 signed commands via the `signed_command` endpoint; automatically used when keys are enrolled
-- **Key enrollment** — `tescmd key enroll ` sends your public key to the vehicle and guides you through Tesla app approval
-- **Tier enforcement** — readonly tier blocks write commands with clear guidance to upgrade
-- **Energy products** — Powerwall live status, site info, backup reserve, operation mode, storm mode, time-of-use settings, charging history, calendar history, grid import/export
-- **User & sharing** — account info, region, orders, feature flags, driver management, vehicle sharing invites
-- **Live Dashboard** — `tescmd serve` launches a full-screen TUI showing live telemetry data, MCP server info, tunnel URL, sink count, and cache stats — all in a scrollable, interactive terminal UI powered by Textual
-- **Fleet Telemetry streaming** — `tescmd serve` (or `tescmd vehicle telemetry stream`) receives push-based data from your vehicle via Tailscale Funnel — no polling, 99%+ cost reduction. Telemetry sessions produce a wide-format CSV log by default
-- **OpenClaw Bridge** — `tescmd serve --openclaw ws://...` streams filtered telemetry to an OpenClaw Gateway with configurable delta+throttle filtering per field; supports bidirectional command dispatch so bots can send vehicle commands back through the gateway
-- **Trigger subscriptions** — register conditions on any telemetry field (battery < 20%, speed > 80, location enters geofence) and get notified via OpenClaw push events or MCP polling; supports one-shot and persistent modes with cooldown
-- **MCP Server** — `tescmd serve` (or `tescmd mcp serve`) exposes all commands as MCP tools for Claude.ai, Claude Desktop, Claude Code, and other agent frameworks via OAuth 2.1
-- **Universal response caching** — all read commands are cached with tiered TTLs (1h for specs/warranty, 5m for fleet lists, 1m standard, 30s for location-dependent); bots can call tescmd as often as needed — within the TTL window, responses are instant and free
-- **Cost-aware wake** — prompts before sending billable wake API calls; `--wake` flag for scripts that accept the cost
-- **Guided OAuth2 setup** — `tescmd auth login` walks you through browser-based authentication with PKCE
-- **Key management** — generate EC keys, register via Tesla Developer Portal (remote) or BLE enrollment (proximity)
-- **Rich terminal output** — tables, panels, spinners powered by Rich; auto-detects TTY vs pipe
-- **Configurable display units** — switch between PSI/bar, °F/°C, and mi/km (defaults to US units)
-- **JSON output** — structured output for scripting and agent integration
-- **Multi-profile** — switch between vehicles, accounts, and regions with named profiles
-- **Agent-friendly** — deterministic JSON, meaningful exit codes, `--wake` opt-in, headless auth support
+---
## Quick Start
@@ -57,425 +30,209 @@ pip install tescmd
tescmd setup
```
-That's it. The interactive setup wizard walks you through everything: creating a Tesla Developer app, generating an EC key pair, hosting the public key (via GitHub Pages or Tailscale Funnel), registering with the Fleet API, authenticating via OAuth2, and enrolling your key on a vehicle. Each step checks prerequisites and offers remediation if something is missing.
-
-After setup completes, you can start using commands:
+The setup wizard handles everything — Tesla Developer app creation, key generation, public key hosting, Fleet API registration, OAuth2 authentication, and vehicle key enrollment. Then you're ready:
```bash
-tescmd charge status # Check battery and charging state
-tescmd vehicle info # Full vehicle data snapshot
-tescmd climate on --wake # Turn on climate (wakes vehicle if asleep)
+tescmd charge status # Battery and charging state
+tescmd climate on --wake # Turn on climate (wakes if asleep)
tescmd security lock --wake # Lock the car
-tescmd serve 5YJ3... # MCP + telemetry TUI dashboard + CSV log
-tescmd serve --no-mcp # Telemetry-only TUI dashboard
+tescmd nav waypoints "Home" "Work" # Multi-stop navigation
+tescmd serve 5YJ3... # Launch the live dashboard
```
-Every read command is cached — repeat calls within the TTL window are instant and free.
+---
-## Prerequisites
+## See It in Action
-| Requirement | Required | What it is | Why tescmd needs it |
-|---|---|---|---|
-| **Python 3.11+** | Yes | The programming language runtime that runs tescmd | tescmd is a Python package — you need Python installed to use it |
-| **pip** | Yes | Python's package installer (ships with Python) | Used to install tescmd and its dependencies via `pip install tescmd` |
-| **Tesla account** | Yes | A [tesla.com](https://www.tesla.com) account linked to a vehicle or energy product | tescmd authenticates via OAuth2 against your Tesla account to access the Fleet API |
-| **Git** | Yes | Version control tool ([git-scm.com](https://git-scm.com)) | Used during setup for key hosting via GitHub Pages |
-| **GitHub CLI** (`gh`) | Recommended | GitHub's command-line tool ([cli.github.com](https://cli.github.com)) — authenticate with `gh auth login` | Auto-creates a `*.github.io` site to host your public key at the `.well-known` path Tesla requires |
-| **Tailscale** | Recommended | Mesh VPN with public tunneling ([tailscale.com](https://tailscale.com)) — authenticate with `tailscale login` | Provides a public HTTPS URL for key hosting and Fleet Telemetry streaming with zero infrastructure setup |
+### Live TUI Dashboard
-### Self-Hosting with Tailscale (No Domain Required)
+`tescmd serve` launches a full-screen terminal dashboard with real-time telemetry, MCP server status, tunnel info, and connection metrics — powered by Textual.
-If you have **Tailscale** installed with Funnel enabled, you don't need a custom domain or GitHub Pages at all. Tailscale Funnel gives you a public HTTPS URL (`.tailnet.ts.net`) that serves both your public key and (optionally) Fleet Telemetry streaming — all from your local machine with zero infrastructure setup.
+
+
+
-```bash
-# Install Tailscale, enable Funnel in your tailnet ACL, then:
-tescmd setup # wizard auto-detects Tailscale and offers it as the hosting method
-```
+### AI Agent Integration
-This is the fastest path to a working setup: Tailscale handles TLS certificates, NAT traversal, and public DNS automatically. The tradeoff is that your machine needs to be running for Tesla to reach your key and for telemetry streaming to work. For always-on key hosting with offline machines, use GitHub Pages instead.
+Every command doubles as an MCP tool. Claude Desktop, Claude Code, and other agent frameworks can query your vehicle, send commands, and react to telemetry — all through structured JSON with built-in cost protection.
-Without either GitHub CLI or Tailscale, you'll need to manually host your public key at the Tesla-required `.well-known` path on your own domain.
+
+
+
-## Installation
+### Rich Terminal Output
-### From PyPI
+Formatted tables in your terminal, structured JSON when piped — tescmd auto-detects the right output for the context.
-```bash
-pip install tescmd
-```
+
+
+
-### From Source
+---
-```bash
-git clone https://github.com/oceanswave/tescmd.git
-cd tescmd
-pip install -e ".[dev]"
-```
+## What You Get
-## Configuration
+### Query & Control
-tescmd resolves settings in this order (highest priority first):
+Full read/write access to Tesla's Fleet API: battery, charge, climate, locks, trunks, windows, sentry, navigation, media, speed limits, PINs, Powerwalls, and more. Every read command is cached with smart TTLs — bots can call tescmd as often as they want and only pay for the first request.
-1. **CLI arguments** — `--vin`, `--region`, `--format`, `--units`, etc.
-2. **Environment variables** — `TESLA_VIN`, `TESLA_REGION`, etc. (`.env` files loaded automatically)
-3. **Defaults**
+### Fleet Telemetry Streaming
-### Environment Variables
+Your vehicle pushes data directly to your machine via Tailscale Funnel — no polling, no per-request charges. Choose from field presets (`driving`, `charging`, `all`) or subscribe to 120+ individual fields. Sessions produce a wide-format CSV log by default.
-Create a `.env` file in your working directory or `~/.config/tescmd/.env`:
+```bash
+tescmd serve 5YJ3... --fields driving # Speed, location, power
+tescmd serve 5YJ3... --fields all # Everything
+```
-```dotenv
-TESLA_CLIENT_ID=your-client-id
-TESLA_CLIENT_SECRET=your-client-secret
-TESLA_VIN=5YJ3E1EA1NF000000
-TESLA_REGION=na
+### MCP Server for AI Agents
-# Token storage (optional — defaults to OS keyring with file fallback)
-TESLA_TOKEN_FILE=~/.config/tescmd/tokens.json
+`tescmd serve` exposes every command as an MCP tool with OAuth 2.1 authentication. Agents get deterministic JSON output, meaningful exit codes, and a `--wake` opt-in flag so they never trigger billable wake calls by accident.
-# Cache settings (optional)
-TESLA_CACHE_ENABLED=true
-TESLA_CACHE_TTL=60
-TESLA_CACHE_DIR=~/.cache/tescmd
+### OpenClaw Bridge
-# Command protocol: auto | signed | unsigned (optional)
-TESLA_COMMAND_PROTOCOL=auto
+Stream filtered telemetry to an [OpenClaw](https://openclaw.ai/) Gateway with per-field delta and throttle filtering. Bots on the gateway can send commands back — lock doors, start charging, set climate — through bidirectional dispatch.
-# Display units (optional — defaults to US units)
-TESLA_TEMP_UNIT=F # F or C
-TESLA_DISTANCE_UNIT=mi # mi or km
-TESLA_PRESSURE_UNIT=psi # psi or bar
-```
+### Trigger Subscriptions
-## Token Storage
+Register conditions on any telemetry field — battery below 20%, speed above 80, location enters a geofence — and get notified via OpenClaw push events or MCP polling. Supports one-shot and persistent modes with cooldown.
-By default, tescmd stores OAuth tokens in the OS keyring (macOS Keychain, GNOME Keyring, Windows Credential Manager). On headless systems where no keyring daemon is available (Docker, CI, SSH sessions), tescmd automatically falls back to a file-based store at `~/.config/tescmd/tokens.json` with restricted permissions (`0600` on Unix, owner-only ACL on Windows).
+### Signed Vehicle Commands
-You can force file-based storage by setting `TESLA_TOKEN_FILE`:
+tescmd implements the [Vehicle Command Protocol](https://github.com/teslamotors/vehicle-command) with ECDH session management and HMAC-SHA256 signing. Once your key is enrolled, commands are signed transparently — no agent-side crypto needed.
-```bash
-export TESLA_TOKEN_FILE=~/.config/tescmd/tokens.json
-```
+---
-To transfer tokens between machines, use `auth export` and `auth import`:
+## Cost Protection Built In
-```bash
-# On source machine
-tescmd auth export > tokens.json
+Tesla's Fleet API is pay-per-use. A naive polling script can generate hundreds of dollars in monthly charges from a single vehicle. tescmd implements four layers of defense:
-# On target machine (Docker, CI, etc.)
-tescmd auth import < tokens.json
-```
+| Layer | What it does |
+|---|---|
+| **Tiered caching** | Specs cached 1h, fleet lists 5m, standard queries 1m, location 30s |
+| **Wake confirmation** | Prompts before billable wake calls; `--wake` flag for scripts |
+| **Smart wake state** | Tracks recent wake confirmations, skips redundant attempts |
+| **Write invalidation** | Write commands auto-invalidate the relevant cache scope |
-Check which backend is active with `tescmd status` — the output includes a `Token store` line showing `keyring` or the file path.
+Streaming telemetry via `tescmd serve` replaces polling entirely — flat cost regardless of data volume. See [API Costs](docs/api-costs.md) for the full breakdown.
-> **Security note:** File-based tokens are stored as plaintext JSON. The file is created with owner-only permissions, but treat it like any other credential file.
+---
## Commands
-| Group | Commands | Description |
-|---|---|---|
-| `setup` | *(interactive wizard)* | First-run configuration: client ID, secret, region, domain, key enrollment |
-| `auth` | `login`, `logout`, `status`, `refresh`, `register`, `export`, `import` | OAuth2 authentication lifecycle |
-| `vehicle` | `list`, `get`, `info`, `data`, `location`, `wake`, `rename`, `mobile-access`, `nearby-chargers`, `alerts`, `release-notes`, `service`, `drivers`, `calendar`, `subscriptions`, `upgrades`, `options`, `specs`, `warranty`, `fleet-status`, `low-power`, `accessory-power`, `telemetry {config,create,delete,errors,stream}` | Vehicle discovery, state queries, fleet telemetry streaming, power management |
-| `charge` | `status`, `start`, `stop`, `limit`, `limit-max`, `limit-std`, `amps`, `port-open`, `port-close`, `schedule`, `departure`, `precondition-add`, `precondition-remove`, `add-schedule`, `remove-schedule`, `clear-schedules`, `clear-preconditions`, `managed-amps`, `managed-location`, `managed-schedule` | Charge queries, control, scheduling, and fleet management |
-| `billing` | `history`, `sessions`, `invoice` | Supercharger billing history and invoices |
-| `climate` | `status`, `on`, `off`, `set`, `precondition`, `seat`, `seat-cool`, `wheel-heater`, `overheat`, `bioweapon`, `keeper`, `cop-temp`, `auto-seat`, `auto-wheel`, `wheel-level` | Climate, seat, and steering wheel control |
-| `security` | `status`, `lock`, `auto-secure`, `unlock`, `sentry`, `valet`, `valet-reset`, `remote-start`, `flash`, `honk`, `boombox`, `speed-limit`, `pin-to-drive`, `pin-reset`, `pin-clear-admin`, `speed-clear`, `speed-clear-admin`, `guest-mode`, `erase-data` | Security, access, and PIN management |
-| `trunk` | `open`, `close`, `frunk`, `window`, `sunroof`, `tonneau-open`, `tonneau-close`, `tonneau-stop` | Trunk, frunk, sunroof, tonneau, and window control |
-| `media` | `play-pause`, `next-track`, `prev-track`, `next-fav`, `prev-fav`, `volume-up`, `volume-down`, `adjust-volume` | Media playback control |
-| `nav` | `send`, `gps`, `supercharger`, `homelink`, `waypoints` | Navigation and HomeLink |
-| `software` | `status`, `schedule`, `cancel` | Software update management |
-| `energy` | `list`, `status`, `live`, `backup`, `mode`, `storm`, `tou`, `history`, `off-grid`, `grid-config`, `telemetry`, `calendar` | Energy product (Powerwall) management |
-| `user` | `me`, `region`, `orders`, `features` | User account information |
-| `sharing` | `add-driver`, `remove-driver`, `create-invite`, `redeem-invite`, `revoke-invite`, `list-invites` | Vehicle sharing and driver management |
-| `key` | `generate`, `deploy`, `validate`, `show`, `enroll`, `unenroll` | Key management and enrollment |
-| `partner` | `public-key`, `telemetry-error-vins`, `telemetry-errors` | Partner account endpoints (require client credentials) |
-| `serve` | *(unified server)* | Combined MCP + telemetry + OpenClaw TUI dashboard with trigger subscriptions |
-| `openclaw` | `bridge` | Standalone OpenClaw bridge with bidirectional command dispatch |
-| `mcp` | `serve` | Standalone MCP server exposing all commands as agent tools |
-| `cache` | `status`, `clear` | Response cache management |
-| `raw` | `get`, `post` | Arbitrary Fleet API endpoint access |
-
-Use `tescmd --help` for detailed usage on any command group. For API endpoints not yet covered by a dedicated command, use `raw get` or `raw post` as an escape hatch.
-
-### Global Flags
-
-These flags can be placed at the root level or after any subcommand:
-
-| Flag | Description |
+| Group | Description |
|---|---|
-| `--vin VIN` | Vehicle VIN (also accepted as a positional argument) |
-| `--profile NAME` | Config profile name |
-| `--format {rich,json,quiet}` | Force output format |
-| `--quiet` | Suppress normal output |
-| `--region {na,eu,cn}` | Tesla API region |
-| `--verbose` | Enable verbose logging |
-| `--no-cache` / `--fresh` | Bypass response cache for this invocation |
-| `--wake` | Auto-wake vehicle without confirmation (billable) |
-
-## Output Formats
-
-tescmd auto-detects the best output format:
-
-- **Rich** (default in TTY) — formatted tables, panels, colored status indicators
-- **JSON** (`--format json` or piped) — structured, parseable output
-- **Quiet** (`--quiet`) — minimal output on stderr, suitable for scripts that only check exit codes
+| `setup` | Interactive first-run wizard |
+| `auth` | OAuth2 login, logout, token management, export/import |
+| `vehicle` | State queries, wake, rename, telemetry streaming, fleet status |
+| `charge` | Charge control, scheduling, departure, fleet management |
+| `climate` | HVAC, seats, steering wheel, bioweapon defense, overheat protection |
+| `security` | Lock/unlock, sentry, valet, PINs, speed limits, remote start |
+| `trunk` | Trunk, frunk, windows, sunroof, tonneau |
+| `media` | Playback control, volume, favorites |
+| `nav` | Send destinations, GPS coordinates, multi-stop waypoints, HomeLink |
+| `software` | Update status, scheduling, cancellation |
+| `energy` | Powerwall status, backup reserve, storm mode, grid config, history |
+| `billing` | Supercharger billing history and invoices |
+| `user` | Account info, region, orders, feature flags |
+| `sharing` | Driver management, vehicle sharing invites |
+| `key` | Key generation, deployment, enrollment, validation |
+| `serve` | Combined MCP + telemetry + OpenClaw TUI dashboard |
+| `mcp` | Standalone MCP server |
+| `openclaw` | Standalone OpenClaw bridge |
+| `cache` | Cache status and management |
+| `raw` | Direct Fleet API endpoint access |
+
+Every command supports `--format json` for scripting and `--help` for detailed usage. See the [Command Reference](docs/commands.md) for the full list.
+
+---
-```bash
-# Human-friendly output
-tescmd vehicle list
-
-# JSON for scripting
-tescmd vehicle list --format json
-
-# Pipe-friendly (auto-switches to JSON)
-tescmd vehicle list | jq '.[0].vin'
-
-# Quiet mode (exit code only)
-tescmd vehicle wake --quiet && echo "Vehicle is awake"
-```
-
-### Display Units
-
-Rich output displays values in US units by default (°F, miles, PSI). Switch to metric with a single flag:
+## Installation
```bash
-tescmd --units metric charge status # All metric: °C, km, bar
-tescmd --units us charge status # All US: °F, mi, psi (default)
-```
-
-Or configure individual units via environment variables:
-
-```dotenv
-TESLA_TEMP_UNIT=C # F or C
-TESLA_DISTANCE_UNIT=km # mi or km
-TESLA_PRESSURE_UNIT=bar # psi or bar
+pip install tescmd
```
-| Dimension | US (default) | Metric | Env Variable |
-|---|---|---|---|
-| Temperature | °F | °C | `TESLA_TEMP_UNIT` |
-| Distance | mi | km | `TESLA_DISTANCE_UNIT` |
-| Pressure | psi | bar | `TESLA_PRESSURE_UNIT` |
-
-The `--units` flag overrides all three env vars at once. The Tesla API returns Celsius, miles, and bar — conversions happen in the display layer only.
+**Requirements:** Python 3.11+ and a [Tesla account](https://www.tesla.com) with a linked vehicle or energy product.
-## Tesla Fleet API Costs
+**Recommended:** [GitHub CLI](https://cli.github.com) (`gh`) for automated key hosting via GitHub Pages, or [Tailscale](https://tailscale.com) for zero-config key hosting and telemetry streaming via Funnel.
-Tesla's Fleet API is **pay-per-use** — every request returning a status code below 500 is billable, including 4xx errors like "vehicle asleep" (408) and rate limits (429). Wake requests are the most expensive category and are rate-limited to 3/min. There is no free tier (the $10/month credit is being discontinued).
-
-> **Official pricing:** [Tesla Fleet API — Billing and Limits](https://developer.tesla.com/docs/fleet-api/billing-and-limits)
-
-### Why This Matters
-
-A naive script that polls `vehicle_data` every 5 minutes generates **4-5 billable requests per check** (asleep error + wake + poll + data fetch). That's **1,000+ billable requests per day** from a single cron job. At roughly $1 per 500 data requests, monitoring one vehicle costs around $60/month before you even count wake requests (the most expensive tier).
-
-### Cost Example: Battery Check
-
-| | Without tescmd | With tescmd |
-|---|---|---|
-| Vehicle asleep, check battery | 408 error (billable) + wake (billable) + poll (billable) + data (billable) = **4+ requests** | Data attempt → 408 (billable) → prompt user → user wakes via Tesla app (free) → retry → data (billable) = **2 requests** |
-| Check battery again 30s later | Another 4+ requests | **0 requests** (cache hit) |
-| 10 checks in 1 minute | **40+ billable requests** | **1 billable request** + 9 cache hits |
-
-### How tescmd Reduces Costs
-
-tescmd implements four layers of cost protection:
-
-1. **Universal read-command cache** — **all** read commands are cached with tiered TTLs: static data (specs, warranty) cached for 1 hour, fleet lists for 5 minutes, standard queries for 1 minute, location-dependent data for 30 seconds. Bots can call tescmd as often as needed — within the TTL, responses are instant and free.
-2. **Smart wake state** — Tracks whether the vehicle was recently confirmed online (30s TTL). Skips redundant wake attempts.
-3. **Wake confirmation prompt** — Prompts before sending billable wake calls in interactive mode. JSON/piped mode returns a structured error with `--wake` guidance.
-4. **Write-command invalidation** — write commands automatically invalidate the relevant cache scope (vehicle or energy site) so subsequent reads reflect the new state.
+
+Install from source
```bash
-# First call: hits API, caches response
-tescmd charge status
-
-# Second call within 60s: instant cache hit, no API call
-tescmd charge status
-
-# All read commands are cached — even vehicle list, user info, billing, etc.
-tescmd vehicle list # cached 5 min
-tescmd user me # cached 1 hour
-tescmd vehicle specs # cached 1 hour
-tescmd billing history # cached 1 min
-
-# Bypass cache when you need fresh data
-tescmd charge status --fresh
-
-# Auto-wake without prompting (for scripts accepting the cost)
-tescmd charge status --wake
-
-# Manage cache
-tescmd cache status # entry counts, disk usage
-tescmd cache clear # clear all
-tescmd cache clear --vin VIN # clear for one vehicle
-tescmd cache clear --site 12345 # clear for an energy site
-tescmd cache clear --scope account # clear account-level entries
+git clone https://github.com/oceanswave/tescmd.git
+cd tescmd
+pip install -e ".[dev]"
```
-For the full cost breakdown with more examples, savings calculations, and Fleet Telemetry streaming comparison, see [docs/api-costs.md](docs/api-costs.md).
-
-Configure via environment variables:
+
-| Variable | Default | Description |
-|---|---|---|
-| `TESLA_CACHE_ENABLED` | `true` | Enable/disable the cache |
-| `TESLA_CACHE_TTL` | `60` | Time-to-live in seconds |
-| `TESLA_CACHE_DIR` | `~/.cache/tescmd` | Cache directory path |
+---
-## Fleet Telemetry Streaming
-
-Tesla's Fleet Telemetry lets your vehicle push real-time data directly to your server — no polling, no per-request charges. tescmd handles all the setup:
-
-```bash
-# Full-screen TUI with live telemetry + MCP server
-tescmd serve 5YJ3...
-
-# Telemetry-only mode (full-screen TUI, no MCP)
-tescmd serve 5YJ3... --no-mcp
+## Configuration
-# Select field presets
-tescmd serve 5YJ3... --fields driving # Speed, location, power
-tescmd serve 5YJ3... --fields charging # Battery, voltage, current
-tescmd serve 5YJ3... --fields all # Everything (120+ fields)
+tescmd resolves settings from CLI flags, environment variables (`.env` files loaded automatically), and defaults — in that order.
-# Override polling interval
-tescmd serve 5YJ3... --interval 5 # Every 5 seconds
+
+Environment variables
-# JSONL output for scripting (non-TTY / piped)
-tescmd serve 5YJ3... --no-mcp --format json | jq .
+```dotenv
+TESLA_CLIENT_ID=your-client-id
+TESLA_CLIENT_SECRET=your-client-secret
+TESLA_VIN=5YJ3E1EA1NF000000
+TESLA_REGION=na # na, eu, cn
-# Disable CSV log
-tescmd serve 5YJ3... --no-log
+# Display units (optional — defaults to US)
+TESLA_TEMP_UNIT=F # F or C
+TESLA_DISTANCE_UNIT=mi # mi or km
+TESLA_PRESSURE_UNIT=psi # psi or bar
-# Legacy Rich Live dashboard
-tescmd serve 5YJ3... --legacy-dashboard
+# Or switch everything at once:
+# tescmd --units metric charge status
```
-**Requires Tailscale** with Funnel enabled. The serve command starts a local WebSocket server, exposes it via Tailscale Funnel (handles TLS + NAT traversal), configures Tesla to push data to it, and renders a full-screen TUI with live telemetry data, server info (MCP URL, tunnel, sinks), unit conversion, and connection status. Press `q` to quit.
+See [docs/commands.md](docs/commands.md) for the full environment variable reference.
-By default, telemetry sessions write a wide-format CSV log to `~/.config/tescmd/logs/` with one row per frame and one column per subscribed field. Disable with `--no-log`.
+
-`tescmd vehicle telemetry stream` is an alias for `tescmd serve --no-mcp`.
+
+Token storage
-### Telemetry vs Polling Costs
-
-| Approach | 1 vehicle, 5-second interval, 24 hours | Monthly cost estimate |
-|---|---|---|
-| **Polling `vehicle_data`** | ~17,280 requests × $0.001 = **$17/day** | **$500+/month** |
-| **Fleet Telemetry streaming** | 1 config create + 1 config delete = **2 requests** | **< $0.01/month** |
-
-Fleet Telemetry streaming is a flat-cost alternative: you pay only for the initial config setup and teardown, regardless of how much data flows. The tradeoff is that you need Tailscale running on a machine to receive the push.
-
-## Key Enrollment & Vehicle Command Protocol
-
-Newer Tesla vehicles require commands to be signed using the [Vehicle Command Protocol](https://github.com/teslamotors/vehicle-command). tescmd handles this transparently:
-
-1. **Generate a key pair** — `tescmd key generate` creates an EC P-256 key pair
-2. **Enroll on vehicle** — `tescmd key enroll ` sends the public key to the vehicle; approve in the Tesla app
-3. **Commands are signed automatically** — once enrolled, tescmd uses ECDH sessions + HMAC-SHA256 to sign commands via the `signed_command` endpoint
-
-```bash
-# Generate EC key pair
-tescmd key generate
+Tokens are stored in the OS keyring by default (macOS Keychain, GNOME Keyring, Windows Credential Manager). On headless systems, tescmd falls back to a file-based store with restricted permissions. Transfer tokens between machines with `tescmd auth export` and `tescmd auth import`.
-# Enroll on a vehicle (interactive approval via Tesla app)
-tescmd key enroll 5YJ3E1EA1NF000000
+
-# Commands are now signed automatically
-tescmd security lock --wake
-```
+---
-The `command_protocol` setting controls routing:
+## Documentation
-| Value | Behavior |
+| | |
|---|---|
-| `auto` (default) | Use signed path when keys are enrolled; fall back to unsigned |
-| `signed` | Require signed commands (error if no keys) |
-| `unsigned` | Force legacy REST path (skip signing) |
-
-Set via `TESLA_COMMAND_PROTOCOL` environment variable or in your config.
-
-See [docs/vehicle-command-protocol.md](docs/vehicle-command-protocol.md) for the full protocol architecture.
-
-## Agent Integration
-
-tescmd is designed for use by AI agents and automation platforms. Agents like [Claude Code](https://github.com/anthropics/claude-code), Claude Desktop, and other LLM-powered tools can invoke tescmd commands, parse the structured JSON output, and act on your behalf.
-
-**Why tescmd works well as an agent tool:**
-
-- **Structured JSON output** — piped/non-TTY mode automatically emits parseable JSON with consistent schema
-- **Cost protection** — agents won't accidentally trigger billable wake calls without `--wake`; the default behavior is safe
-- **Cache-aware** — every read command is cached; repeated queries from an agent within the TTL window cost nothing
-- **Meaningful exit codes** — agents can branch on success/failure without parsing output
-- **Stateless invocations** — each command is self-contained; no session state to manage
-- **Signed commands** — when keys are enrolled, commands are signed transparently; no agent-side crypto needed
-
-**Example agent workflow:**
-
-```bash
-# Agent checks battery (cache hit if recent)
-tescmd charge status --format json
-
-# Agent decides to start charging
-tescmd charge start --wake --format json
-
-# Agent verifies the command took effect (cache was invalidated)
-tescmd charge status --format json --fresh
-```
-
-See [docs/bot-integration.md](docs/bot-integration.md) for the full JSON schema, exit code reference, and headless authentication setup.
+| [Setup Guide](docs/setup.md) | Step-by-step walkthrough of `tescmd setup` |
+| [Command Reference](docs/commands.md) | Detailed usage for every command |
+| [API Costs](docs/api-costs.md) | Cost breakdown, savings calculations, streaming comparison |
+| [Bot Integration](docs/bot-integration.md) | JSON schema, exit codes, headless auth |
+| [OpenClaw Bridge](docs/openclaw.md) | Gateway protocol, bidirectional commands, triggers, geofencing |
+| [MCP Server](docs/mcp.md) | Tool reference, OAuth 2.1, custom tools, trigger polling |
+| [Vehicle Command Protocol](docs/vehicle-command-protocol.md) | ECDH sessions and signed commands |
+| [Authentication](docs/authentication.md) | OAuth2 PKCE flow, token storage, scopes |
+| [Architecture](docs/architecture.md) | Layered design, module responsibilities |
+| [FAQ](docs/faq.md) | Common questions about costs, hosting, and configuration |
+| [Development](docs/development.md) | Contributing, testing, linting |
+
+---
## Development
```bash
-# Clone and install in dev mode
-git clone https://github.com/oceanswave/tescmd.git
-cd tescmd
+git clone https://github.com/oceanswave/tescmd.git && cd tescmd
pip install -e ".[dev]"
-
-# Run checks
-ruff check src/ tests/
-ruff format --check src/ tests/
-mypy src/
-pytest
-
-# Run a specific test
-pytest tests/cli/test_auth.py -v
-
-# Validate API coverage against Tesla Fleet API spec
-python scripts/validate_fleet_api.py
-```
-
-### API Coverage Validation
-
-tescmd ships a spec-driven validation utility that compares our implementation against the Tesla Fleet API. The canonical spec lives at `spec/fleet_api_spec.json` (sourced from Tesla's docs and Go SDK), and `scripts/validate_fleet_api.py` validates all API methods, parameters, and types using AST introspection.
-
-```bash
-python scripts/validate_fleet_api.py # Summary
-python scripts/validate_fleet_api.py --verbose # All endpoints
-python scripts/validate_fleet_api.py --json # Machine-readable
+pytest # 1600+ tests
+ruff check src/ tests/ && mypy src/
```
-Run this periodically or after modifying API methods to catch drift.
-
-See [docs/development.md](docs/development.md) for detailed contribution guidelines.
-
-## Documentation
-
-- [Setup Guide](docs/setup.md) — step-by-step walkthrough of `tescmd setup`
-- [FAQ](docs/faq.md) — common questions about tescmd, costs, hosting, and configuration
-- [Command Reference](docs/commands.md) — detailed usage for every command
-- [API Costs](docs/api-costs.md) — detailed cost breakdown and savings calculations
-- [Bot Integration](docs/bot-integration.md) — JSON schema, exit codes, telemetry streaming, headless auth
-- [OpenClaw Bridge](docs/openclaw.md) — gateway protocol, bidirectional commands, trigger subscriptions, geofencing
-- [MCP Server](docs/mcp.md) — tool reference, authentication, custom tools, trigger polling
-- [Architecture](docs/architecture.md) — layered design, module responsibilities, design decisions
-- [Vehicle Command Protocol](docs/vehicle-command-protocol.md) — ECDH sessions and signed commands
-- [Authentication](docs/authentication.md) — OAuth2 PKCE flow, token storage, scopes
-- [Development](docs/development.md) — contribution guidelines, testing, linting
+---
## Changelog
@@ -486,5 +243,5 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
MIT
-
+
diff --git a/docs/commands.md b/docs/commands.md
index 119ffb1..a54deb8 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -364,10 +364,10 @@ ACTION REQUIRED: Add virtual key in the Tesla app
Enrollment URL: https://tesla.com/_ak/yourdomain.github.io
- 1. Open the URL above on your phone
- 2. Tap Finish Setup on the web page
- 3. The Tesla app will show an Add Virtual Key prompt
- 4. Approve it
+ 1. Scan the QR code on the page above with your phone
+ 2. The Tesla app will show an Add Virtual Key prompt
+ 3. Approve it
+
```
**JSON mode:** Returns a single envelope with `"status": "ready"`, `enroll_url`, and instructions.
diff --git a/images/tescmd_mcp.png b/images/tescmd_mcp.png
new file mode 100644
index 0000000..4b265c9
Binary files /dev/null and b/images/tescmd_mcp.png differ
diff --git a/images/tescmd_serve.png b/images/tescmd_serve.png
new file mode 100644
index 0000000..bde9173
Binary files /dev/null and b/images/tescmd_serve.png differ
diff --git a/images/tescmd_waypoints.png b/images/tescmd_waypoints.png
new file mode 100644
index 0000000..f73d1be
Binary files /dev/null and b/images/tescmd_waypoints.png differ
diff --git a/pyproject.toml b/pyproject.toml
index f9f0fed..1695ff7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "tescmd"
-version = "0.3.2"
+version = "0.4.0"
description = "A Python CLI for querying and controlling Tesla vehicles via the Fleet API"
readme = "README.md"
license = "MIT"
diff --git a/src/tescmd/__init__.py b/src/tescmd/__init__.py
index 1b141a0..8f02c5a 100644
--- a/src/tescmd/__init__.py
+++ b/src/tescmd/__init__.py
@@ -1,3 +1,3 @@
"""tescmd — A Python CLI for querying and controlling Tesla vehicles via the Fleet API."""
-__version__ = "0.3.1"
+__version__ = "0.4.0"
diff --git a/src/tescmd/cli/auth.py b/src/tescmd/cli/auth.py
index a64bb13..cf2beda 100644
--- a/src/tescmd/cli/auth.py
+++ b/src/tescmd/cli/auth.py
@@ -3,8 +3,10 @@
from __future__ import annotations
import json
+import os
import sys
import time
+import uuid
import webbrowser
from pathlib import Path
from typing import TYPE_CHECKING
@@ -31,7 +33,9 @@
if TYPE_CHECKING:
from tescmd.cli.main import AppContext
+ from tescmd.deploy.tailscale_serve import KeyServer
from tescmd.output.formatter import OutputFormatter
+ from tescmd.telemetry.tailscale import TailscaleManager
DEVELOPER_PORTAL_URL = "https://developer.tesla.com/dashboard"
@@ -476,6 +480,8 @@ def _interactive_setup(
*,
domain: str = "",
tailscale_hostname: str = "",
+ full_tier: bool = False,
+ force: bool = False,
) -> tuple[str, str]:
"""Walk the user through first-time Tesla API credential setup.
@@ -483,9 +489,11 @@ def _interactive_setup(
portal instructions show ``https://{domain}`` as the Allowed Origin URL.
Tesla's Fleet API requires the origin to match the registration domain.
- When *tailscale_hostname* is provided (or auto-detected), the user is
- offered the chance to start a Tailscale Funnel so Tesla can verify the
- origin URL when the portal app config is saved.
+ When *full_tier* is True and *tailscale_hostname* is provided (or
+ auto-detected), the user is offered the chance to start a Tailscale
+ Funnel so Tesla can verify the origin URL when the portal app config
+ is saved. The Tailscale prompt appears **before** the browser is
+ opened so the developer portal steps (1-6) are uninterrupted.
"""
info = formatter.rich.info
origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
@@ -499,170 +507,222 @@ def _interactive_setup(
)
info("")
- # Offer to open the developer portal
- try:
- answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
- except (EOFError, KeyboardInterrupt):
- info("")
- return ("", "")
+ # --- Tailscale detection + Funnel prompt (before browser opens) ----
+ # Only shown during the full setup wizard — the standalone ``auth
+ # setup`` command is credentials-only and defaults full_tier=False.
+ ts_hostname = tailscale_hostname if full_tier else ""
+ ts_funnel_started = False
+ _ts_manager: TailscaleManager | None = None
+ _key_server: KeyServer | None = None
- if answer.strip().lower() != "n":
- webbrowser.open(DEVELOPER_PORTAL_URL)
- info("[dim]Browser opened.[/dim]")
+ if full_tier:
+ if not ts_hostname:
+ try:
+ from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
- info("")
- info(
- "Follow these steps to create a Fleet API application."
- " If you already have one, skip to the credentials prompt below."
- )
- info("")
+ if run_async(is_tailscale_serve_ready()):
+ from tescmd.telemetry.tailscale import TailscaleManager
- # Step 1 — Registration
- info("[bold]Step 1 — Registration[/bold]")
- info(" Select [cyan]Just for me[/cyan] and click Next.")
- info("")
+ ts_hostname = run_async(TailscaleManager().get_hostname())
+ except Exception:
+ pass
- # Step 2 — Application Details
- info("[bold]Step 2 — Application Details[/bold]")
- info(" Application Name: [cyan]tescmd[/cyan] (or anything you like)")
- info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
- info(
- " Purpose of Usage: [cyan]Query vehicle data and send commands from the terminal[/cyan]"
- )
- info(" Click Next.")
- info("")
+ if ts_hostname:
+ ts_origin = f"https://{ts_hostname}"
- # Detect Tailscale and offer to start Funnel for origin URL verification
- ts_hostname = tailscale_hostname
- ts_funnel_started = False
- if not ts_hostname:
+ info(f"[green]Tailscale detected:[/green] {ts_hostname}")
+ info("")
+ info(f" Adding [cyan]{ts_origin}[/cyan] as an Allowed Origin URL")
+ info(" will let [cyan]tescmd serve[/cyan] stream telemetry without")
+ info(" extra portal changes later.")
+ info("")
+ try:
+ answer = input(
+ "Start Tailscale Funnel so Tesla can verify the URL? [Y/n] "
+ ).strip()
+ except (EOFError, KeyboardInterrupt):
+ answer = "n"
+
+ if answer.lower() != "n":
+ info("Starting Tailscale Funnel...")
+ try:
+ from tescmd.crypto.keys import (
+ generate_ec_key_pair,
+ has_key_pair,
+ load_public_key_pem,
+ )
+ from tescmd.deploy.tailscale_serve import KeyServer
+ from tescmd.telemetry.tailscale import TailscaleManager
+
+ key_dir = Path(AppSettings().config_dir).expanduser() / "keys"
+
+ if not has_key_pair(key_dir):
+ info("Generating EC P-256 key pair...")
+ generate_ec_key_pair(key_dir)
+
+ pem = load_public_key_pem(key_dir)
+ _key_server = KeyServer(pem, port=0)
+ _local_port = _key_server.server_address[1]
+ _key_server.start()
+
+ _ts_manager = TailscaleManager()
+ run_async(_ts_manager.start_funnel(_local_port))
+
+ ts_funnel_started = True
+ info(f"[green]Funnel active — {ts_origin} is reachable.[/green]")
+ except Exception as exc:
+ if _key_server is not None:
+ _key_server.stop()
+ _key_server = None
+ _ts_manager = None
+ info(f"[yellow]Could not start Funnel: {exc}[/yellow]")
+ info("[dim]You can add the origin URL manually later.[/dim]")
+ ts_hostname = ""
+
+ # --- Open browser + credential prompts (with guaranteed cleanup) ---
+ try:
try:
- from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
-
- if run_async(is_tailscale_serve_ready()):
- from tescmd.telemetry.tailscale import TailscaleManager
+ answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
+ except (EOFError, KeyboardInterrupt):
+ info("")
+ return ("", "")
- ts_hostname = run_async(TailscaleManager().get_hostname())
- except Exception:
- pass
+ if answer.strip().lower() != "n":
+ webbrowser.open(DEVELOPER_PORTAL_URL)
+ info("[dim]Browser opened.[/dim]")
- if ts_hostname:
- ts_origin = f"https://{ts_hostname}"
info("")
- info(f"[green]Tailscale detected:[/green] {ts_hostname}")
+ info(
+ "Follow these steps to create a Fleet API application."
+ " If you already have one, skip to the credentials prompt below."
+ )
info("")
- info(f" Adding [cyan]{ts_origin}[/cyan] as an Allowed Origin URL")
- info(" will let [cyan]tescmd serve[/cyan] stream telemetry without")
- info(" extra portal changes later.")
+
+ # Step 1 — Create New Application
+ info("[bold]Step 1 — Create New Application[/bold]")
+ info(" Select [cyan]Just for me[/cyan] and click Next.")
info("")
- try:
- answer = input("Start Tailscale Funnel so Tesla can verify the URL? [Y/n] ").strip()
- except (EOFError, KeyboardInterrupt):
- answer = "n"
- if answer.lower() != "n":
- info("Starting Tailscale Funnel...")
- try:
- from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
-
- run_async(_TsMgr().enable_funnel())
- ts_funnel_started = True
- info(f"[green]Funnel active — {ts_origin} is reachable.[/green]")
- except Exception as exc:
- info(f"[yellow]Could not start Funnel: {exc}[/yellow]")
- info("[dim]You can add the origin URL manually later.[/dim]")
- ts_hostname = ""
-
- # Step 3 — Client Details
- info("[bold]Step 3 — Client Details[/bold]")
- info(
- " OAuth Grant Type: [cyan]Authorization Code and"
- " Machine-to-Machine[/cyan] (the default)"
- )
- info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
- if ts_hostname:
- info(f" [bold]Also add:[/bold] [cyan]https://{ts_hostname}[/cyan]")
- info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
- info(" Allowed Returned URL: (leave empty)")
- info("")
- if not ts_hostname:
+ # Step 2 — Application Details
+ #
+ # Generate a unique app name so the user doesn't collide with anyone
+ # else on the Tesla Developer Portal. Reuse a previously saved name
+ # if setup is being re-run.
+ saved_app_name = "" if force else os.environ.get("TESLA_APP_NAME", "")
+ app_name = saved_app_name or f"tescmd-{uuid.uuid4().hex[:8]}"
+
+ info("[bold]Step 2 — Application Details[/bold]")
+ info(f" Application Name: [cyan]{app_name}[/cyan]")
+ info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
info(
- " [dim]For telemetry streaming, add your Tailscale hostname"
- " as an additional origin:[/dim]"
+ " Purpose of Usage: [cyan]Query vehicle data and send commands"
+ " from the terminal[/cyan]"
)
- info(" [dim] https://.tailnet.ts.net[/dim]")
- info(" Click Next.")
- info("")
-
- # Step 4 — API & Scopes
- info("[bold]Step 4 — API & Scopes[/bold]")
- info(" Under [bold]Fleet API[/bold], check at least:")
- info(" [cyan]Vehicle Information[/cyan]")
- info(" [cyan]Vehicle Location[/cyan]")
- info(" [cyan]Vehicle Commands[/cyan]")
- info(" [cyan]Vehicle Charging Management[/cyan]")
- info(" Click Next.")
- info("")
-
- # Step 5 — Billing Details
- info("[bold]Step 5 — Billing Details[/bold]")
- info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
- info("")
-
- # Post-creation
- info("[bold]Step 6 — Copy your credentials[/bold]")
- info(
- " Open your dashboard:"
- " [link=https://developer.tesla.com/en_US/dashboard]"
- "developer.tesla.com/dashboard[/link]"
- )
- info(" Click [cyan]View Details[/cyan] on your app.")
- info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
- info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
- info("")
+ info(" Click Next.")
+ info("")
- # Prompt for Client ID
- try:
- client_id = input("Client ID: ").strip()
- except (EOFError, KeyboardInterrupt):
+ # Step 3 — Client Details
+ info("[bold]Step 3 — Client Details[/bold]")
+ info(
+ " OAuth Grant Type: [cyan]Authorization Code and"
+ " Machine-to-Machine[/cyan] (the default)"
+ )
+ info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
+ if ts_hostname:
+ info(f" [bold]Also add:[/bold] [cyan]https://{ts_hostname}[/cyan]")
+ info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
+ info(" Allowed Returned URL: (leave empty)")
+ info("")
+ if not ts_hostname:
+ info(
+ " [dim]For telemetry streaming, add your Tailscale hostname"
+ " as an additional origin:[/dim]"
+ )
+ info(" [dim] https://.tailnet.ts.net[/dim]")
+ info(" Click Next.")
info("")
- return ("", "")
- if not client_id:
- info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
- return ("", "")
+ # Step 4 — API & Scopes
+ info("[bold]Step 4 — API & Scopes[/bold]")
+ if full_tier:
+ info(" Under [bold]Fleet API[/bold], click [cyan]Select All[/cyan].")
+ else:
+ info(" Under [bold]Fleet API[/bold], check at least:")
+ info(" [cyan]Vehicle Information[/cyan]")
+ info(" [cyan]Vehicle Location[/cyan]")
+ info(" [cyan]Energy Information[/cyan]")
+ info(" [cyan]User Data[/cyan]")
+ info(" Click Next.")
+ info("")
- # Prompt for Client Secret (optional for public clients)
- try:
- client_secret = input("Client Secret (optional, press Enter to skip): ").strip()
- except (EOFError, KeyboardInterrupt):
+ # Step 5 — Billing Details
+ info("[bold]Step 5 — Billing Details (Optional)[/bold]")
+ info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
info("")
- return ("", "")
- # Offer to persist credentials to .env
- info("")
- try:
- save = input("Save credentials to .env file? [Y/n] ")
- except (EOFError, KeyboardInterrupt):
+ # Post-creation
+ info("[bold]Step 6 — Copy your credentials[/bold]")
+ info(
+ " Open your dashboard:"
+ " [link=https://developer.tesla.com/en_US/dashboard]"
+ "developer.tesla.com/dashboard[/link]"
+ )
+ info(" Click [cyan]View Details[/cyan] on your app.")
+ info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
+ info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
info("")
- return (client_id, client_secret)
- if save.strip().lower() != "n":
+ # Prompt for Client ID (required — retry on empty)
+ client_id = ""
+ for _ in range(3):
+ try:
+ client_id = input("Client ID: ").strip()
+ except (EOFError, KeyboardInterrupt):
+ info("")
+ return ("", "")
+ if client_id:
+ break
+ info("[yellow]Client ID is required.[/yellow]")
+
+ if not client_id:
+ info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
+ return ("", "")
+
+ # Prompt for Client Secret (required — retry on empty)
+ client_secret = ""
+ for _ in range(3):
+ try:
+ client_secret = input("Client Secret: ").strip()
+ except (EOFError, KeyboardInterrupt):
+ info("")
+ return ("", "")
+ if client_secret:
+ break
+ info("[yellow]Client Secret is required.[/yellow]")
+
+ if not client_secret:
+ info("[yellow]No Client Secret provided. Setup cancelled.[/yellow]")
+ return ("", "")
+
+ # Persist credentials to .env
+ info("")
_write_env_file(client_id, client_secret)
+ if not saved_app_name:
+ _write_env_value("TESLA_APP_NAME", app_name)
info("[green]Credentials saved to .env[/green]")
- # Clean up Tailscale Funnel if we started it during this session
- if ts_funnel_started:
- try:
- from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
-
- run_async(_TsMgr._run("tailscale", "funnel", "--bg", "off"))
- except Exception as exc:
- info(f"[yellow]Warning: Failed to stop Tailscale Funnel: {exc}[/yellow]")
- info("[dim]You may need to run 'tailscale funnel --bg off' manually.[/dim]")
-
- info("")
- return (client_id, client_secret)
+ info("")
+ return (client_id, client_secret)
+ finally:
+ if ts_funnel_started:
+ if _key_server is not None:
+ _key_server.stop()
+ if _ts_manager is not None:
+ try:
+ run_async(_ts_manager.stop_funnel())
+ except Exception as exc:
+ info(f"[yellow]Warning: Failed to stop Tailscale Funnel: {exc}[/yellow]")
+ info("[dim]You may need to run 'tailscale funnel --bg off' manually.[/dim]")
def _prompt_for_domain(formatter: OutputFormatter) -> str:
diff --git a/src/tescmd/cli/key.py b/src/tescmd/cli/key.py
index 3a26e77..ce87ce4 100644
--- a/src/tescmd/cli/key.py
+++ b/src/tescmd/cli/key.py
@@ -585,13 +585,12 @@ async def _cmd_enroll(
formatter.rich.info("")
formatter.rich.info(f" Enrollment URL: [link={enroll_url}]{enroll_url}[/link]")
formatter.rich.info("")
- formatter.rich.info(" 1. Open the URL above [bold]on your phone[/bold]")
- formatter.rich.info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
- formatter.rich.info(" 3. The Tesla app will show an [bold]Add Virtual Key[/bold] prompt")
- formatter.rich.info(" 4. Approve it")
+ formatter.rich.info(" 1. Scan the QR code on the page above with your phone[/bold]")
+ formatter.rich.info(" 2. The Tesla app will show an [bold]Add Virtual Key[/bold] prompt")
+ formatter.rich.info(" 3. Select all Scopes (if shown) and approve it")
formatter.rich.info("")
formatter.rich.info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
- formatter.rich.info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
+ formatter.rich.info(" [dim]and scan the QR code again.[/dim]")
formatter.rich.info("━" * 55)
formatter.rich.info("")
@@ -601,11 +600,11 @@ async def _cmd_enroll(
formatter.rich.info("")
formatter.rich.info("After approving in the Tesla app, try a command:")
- formatter.rich.info(" [cyan]tescmd security lock --wake[/cyan]")
+ formatter.rich.info(" [cyan]tescmd vehicle list[/cyan]")
formatter.rich.info(" [cyan]tescmd charge status --wake[/cyan]")
formatter.rich.info("")
formatter.rich.info(
- "[dim]Tip: This URL must be opened on your phone, not a desktop browser.[/dim]"
+ "[dim]Tip: The QR code must be scanned on your phone that has the Tesla app installed.[/dim]"
)
diff --git a/src/tescmd/cli/setup.py b/src/tescmd/cli/setup.py
index 41f13b8..4990221 100644
--- a/src/tescmd/cli/setup.py
+++ b/src/tescmd/cli/setup.py
@@ -29,10 +29,11 @@
@click.command("setup")
+@click.option("--force", is_flag=True, help="Reconfigure everything from scratch.")
@global_options
-def setup_cmd(app_ctx: AppContext) -> None:
+def setup_cmd(app_ctx: AppContext, force: bool) -> None:
"""Interactive setup wizard for first-time configuration."""
- run_async(_cmd_setup(app_ctx))
+ run_async(_cmd_setup(app_ctx, force=force))
# ---------------------------------------------------------------------------
@@ -40,29 +41,35 @@ def setup_cmd(app_ctx: AppContext) -> None:
# ---------------------------------------------------------------------------
-async def _cmd_setup(app_ctx: AppContext) -> None:
+async def _cmd_setup(app_ctx: AppContext, *, force: bool = False) -> None:
"""Run the tiered onboarding wizard."""
formatter = app_ctx.formatter
settings = AppSettings()
# Phase 0: Welcome + tier selection
- tier = _prompt_tier(formatter, settings)
+ tier = _prompt_tier(formatter, settings, force=force)
if not tier:
return
# Phase 1: Domain setup via GitHub Pages (must happen before developer
# portal because Tesla requires the Allowed Origin URL to match the
# registration domain)
- domain = _domain_setup(formatter, settings)
+ domain = _domain_setup(formatter, settings, force=force)
if not domain:
return
# Re-read settings after potential .env changes
settings = AppSettings()
+ # Early check: warn if remote key differs from local
+ if tier == TIER_FULL:
+ _check_key_mismatch(formatter, settings, domain)
+
# Phase 2: Developer portal walkthrough (credentials — uses domain for
# the Allowed Origin URL instructions)
- client_id, client_secret = _developer_portal_setup(formatter, app_ctx, settings, domain=domain)
+ client_id, client_secret = _developer_portal_setup(
+ formatter, app_ctx, settings, domain=domain, force=force, tier=tier
+ )
if not client_id:
return
@@ -71,17 +78,17 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
# Phase 3: Key generation + deployment (full tier only)
if tier == TIER_FULL:
- _key_setup(formatter, settings, domain)
+ _key_setup(formatter, settings, domain, force=force)
- # Phase 3.5: Key enrollment (full tier only)
- if tier == TIER_FULL:
- await _enrollment_step(formatter, app_ctx, settings)
-
- # Phase 4: Fleet API partner registration
+ # Phase 4: Fleet API partner registration (closes out the setup phases)
await _registration_step(formatter, app_ctx, settings, client_id, client_secret, domain)
# Phase 5: OAuth login
- await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret)
+ await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret, force=force)
+
+ # Phase 3.5: Key enrollment (full tier only — after login)
+ if tier == TIER_FULL:
+ await _enrollment_step(formatter, app_ctx, settings)
# Phase 6: Summary
_print_next_steps(formatter, tier)
@@ -92,13 +99,13 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
# ---------------------------------------------------------------------------
-def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
+def _prompt_tier(formatter: OutputFormatter, settings: AppSettings, *, force: bool = False) -> str:
"""Ask the user which tier they want and persist the choice."""
info = formatter.rich.info
# If already configured, offer to keep or change
existing_tier = settings.setup_tier
- if existing_tier in (TIER_READONLY, TIER_FULL):
+ if existing_tier in (TIER_READONLY, TIER_FULL) and not force:
info(f"Setup tier: [cyan]{existing_tier}[/cyan] (previously configured)")
info("")
@@ -157,6 +164,47 @@ def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
return tier
+# ---------------------------------------------------------------------------
+# Key mismatch warning (between Phase 1 and Phase 2)
+# ---------------------------------------------------------------------------
+
+
+def _check_key_mismatch(
+ formatter: OutputFormatter,
+ settings: AppSettings,
+ domain: str,
+) -> None:
+ """Warn early if the remote public key differs from the local key."""
+ info = formatter.rich.info
+ key_dir = Path(settings.config_dir).expanduser() / "keys"
+
+ from tescmd.crypto.keys import has_key_pair, load_public_key_pem
+
+ if not has_key_pair(key_dir):
+ return
+
+ pem = load_public_key_pem(key_dir)
+
+ # Fetch remote key (method-aware)
+ if settings.hosting_method == "tailscale":
+ from tescmd.deploy.tailscale_serve import fetch_tailscale_key_pem, get_key_url
+
+ url = get_key_url(domain)
+ remote_pem = fetch_tailscale_key_pem(domain)
+ else:
+ from tescmd.deploy.github_pages import fetch_key_pem, get_key_url
+
+ url = get_key_url(domain)
+ remote_pem = fetch_key_pem(domain)
+
+ if remote_pem is not None and remote_pem != pem.strip():
+ info("[yellow]The public key on your domain differs from your local key.[/yellow]")
+ info(f" Remote: {url}")
+ info(" This can happen after regenerating your key pair.")
+ info(" The key will be redeployed in Phase 3.")
+ info("")
+
+
# ---------------------------------------------------------------------------
# Phase 2: Developer portal walkthrough
# ---------------------------------------------------------------------------
@@ -168,6 +216,8 @@ def _developer_portal_setup(
settings: AppSettings,
*,
domain: str = "",
+ force: bool = False,
+ tier: str = "",
) -> tuple[str, str]:
"""Walk through Tesla Developer Portal setup if credentials are missing."""
info = formatter.rich.info
@@ -175,7 +225,7 @@ def _developer_portal_setup(
client_id = settings.client_id
client_secret = settings.client_secret
- if client_id:
+ if client_id and not force:
info(f"Client ID: [cyan]{client_id[:8]}...[/cyan] (already configured)")
return (client_id, client_secret or "")
@@ -196,7 +246,13 @@ def _developer_portal_setup(
from tescmd.cli.auth import _interactive_setup
return _interactive_setup(
- formatter, port, redirect_uri, domain=domain, tailscale_hostname=ts_hostname
+ formatter,
+ port,
+ redirect_uri,
+ domain=domain,
+ tailscale_hostname=ts_hostname,
+ full_tier=(tier == TIER_FULL),
+ force=force,
)
@@ -205,11 +261,13 @@ def _developer_portal_setup(
# ---------------------------------------------------------------------------
-def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
+def _domain_setup(
+ formatter: OutputFormatter, settings: AppSettings, *, force: bool = False
+) -> str:
"""Set up a domain via GitHub Pages, Tailscale Funnel, or manual entry."""
info = formatter.rich.info
- if settings.domain:
+ if settings.domain and not force:
info(f"Domain: [cyan]{settings.domain}[/cyan] (already configured)")
info("")
return settings.domain
@@ -256,10 +314,10 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
info("")
- info("[dim]Note: GitHub Pages provides always-on key hosting but cannot[/dim]")
- info("[dim]serve as a Fleet Telemetry server. If you plan to use telemetry[/dim]")
- info("[dim]streaming, choose Tailscale instead (install Tailscale, then[/dim]")
- info("[dim]re-run setup).[/dim]")
+ info("[dim]Note: GitHub Pages provides always-on key hosting. For Fleet[/dim]")
+ info("[dim]Telemetry streaming, Tailscale will also be used alongside[/dim]")
+ info("[dim]GitHub Pages — just add your Tailscale hostname as an extra[/dim]")
+ info("[dim]Allowed Origin URL in the developer portal.[/dim]")
info("")
try:
@@ -350,7 +408,9 @@ def _manual_domain_setup(formatter: OutputFormatter) -> str:
# ---------------------------------------------------------------------------
-def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -> None:
+def _key_setup(
+ formatter: OutputFormatter, settings: AppSettings, domain: str, *, force: bool = False
+) -> None:
"""Generate keys and deploy via the configured hosting method (full tier only)."""
info = formatter.rich.info
@@ -365,12 +425,12 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
has_key_pair,
)
- # Generate keys if needed
- if has_key_pair(key_dir):
+ # Generate keys if needed (force → overwrite existing keys)
+ if has_key_pair(key_dir) and not force:
info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
else:
info("Generating EC P-256 key pair...")
- generate_ec_key_pair(key_dir)
+ generate_ec_key_pair(key_dir, overwrite=force)
info("[green]Key pair generated.[/green]")
info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
@@ -380,9 +440,9 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
hosting = settings.hosting_method
if hosting == "tailscale":
- _deploy_key_tailscale(formatter, settings, key_dir, domain)
+ _deploy_key_tailscale(formatter, settings, key_dir, domain, force=force)
else:
- _deploy_key_github(formatter, settings, key_dir, domain)
+ _deploy_key_github(formatter, settings, key_dir, domain, force=force)
def _deploy_key_tailscale(
@@ -390,6 +450,8 @@ def _deploy_key_tailscale(
settings: AppSettings,
key_dir: Path,
domain: str,
+ *,
+ force: bool = False,
) -> None:
"""Deploy key via Tailscale Funnel."""
info = formatter.rich.info
@@ -404,7 +466,7 @@ def _deploy_key_tailscale(
)
# Check if key is already deployed
- if run_async(validate_tailscale_key_url(domain)):
+ if run_async(validate_tailscale_key_url(domain)) and not force:
info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
info("")
return
@@ -434,6 +496,8 @@ def _deploy_key_github(
settings: AppSettings,
key_dir: Path,
domain: str,
+ *,
+ force: bool = False,
) -> None:
"""Deploy key via GitHub Pages."""
info = formatter.rich.info
@@ -465,7 +529,7 @@ def _deploy_key_github(
pem = load_public_key_pem(key_dir)
remote_pem = fetch_key_pem(domain)
- if remote_pem is not None and remote_pem == pem.strip():
+ if remote_pem is not None and remote_pem == pem.strip() and not force:
info(f"Public key: [green]matches GitHub[/green] at {get_key_url(domain)}")
info("")
return
@@ -569,34 +633,24 @@ async def _enrollment_step(
info(f" Public key: [green]accessible[/green] at {key_url}")
info("")
- try:
- answer = input(" Open enrollment URL in your browser? [Y/n] ").strip()
- except (EOFError, KeyboardInterrupt):
- info("")
- return
-
- if answer.lower() not in ("n", "no"):
- info("")
- info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
- webbrowser.open(enroll_url)
- info("")
+ info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
+ webbrowser.open(enroll_url)
+ info("")
info(" " + "━" * 49)
info(" [bold yellow]ACTION REQUIRED: Add virtual key in the Tesla app[/bold yellow]")
info("")
- info(f" Enrollment URL: {enroll_url}")
- info("")
- info(" 1. Open the URL above [bold]on your phone[/bold]")
+ info(" 1. Scan the QR code with your phone (iOS / Android)")
info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
info(" 3. The Tesla app shows an [bold]Add Virtual Key[/bold] prompt")
info(" 4. Approve it")
info("")
- info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
- info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
+ info(
+ " [dim]If the prompt doesn't appear, force-quit the Tesla app,"
+ " scan the QR code again, and tap Finish Setup.[/dim]"
+ )
info(" " + "━" * 49)
info("")
- info(" After approving, try: [cyan]tescmd charge status --wake[/cyan]")
- info("")
# ---------------------------------------------------------------------------
@@ -917,6 +971,8 @@ async def _oauth_login_step(
settings: AppSettings,
client_id: str,
client_secret: str,
+ *,
+ force: bool = False,
) -> None:
"""Run the OAuth2 login flow."""
info = formatter.rich.info
@@ -930,7 +986,7 @@ async def _oauth_login_step(
config_dir=settings.config_dir,
)
- if store.has_token:
+ if store.has_token and not force:
# Check whether the stored scopes cover what we need.
# A readonly→full upgrade requires vehicle_cmds + vehicle_charging_cmds
# that the original readonly token may not have.
diff --git a/src/tescmd/deploy/tailscale_serve.py b/src/tescmd/deploy/tailscale_serve.py
index b0be5be..8b1d206 100644
--- a/src/tescmd/deploy/tailscale_serve.py
+++ b/src/tescmd/deploy/tailscale_serve.py
@@ -9,7 +9,9 @@
import asyncio
import logging
+import threading
import time
+from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import httpx
@@ -27,6 +29,71 @@
POLL_INTERVAL = 3 # seconds
+# ---------------------------------------------------------------------------
+# In-process key server (used by interactive setup)
+# ---------------------------------------------------------------------------
+
+
+class _KeyRequestHandler(BaseHTTPRequestHandler):
+ """Serve the root (200 OK) and the ``.well-known`` PEM path."""
+
+ server: KeyServer # type: ignore[assignment]
+
+ def do_GET(self) -> None:
+ if self.path == "/":
+ self._respond(200, "")
+ elif self.path == f"/{WELL_KNOWN_PATH}":
+ self._respond(
+ 200,
+ self.server.pem_content,
+ content_type="application/x-pem-file",
+ )
+ else:
+ self._respond(404, "Not found")
+
+ def _respond(
+ self,
+ status: int,
+ body: str,
+ content_type: str = "text/html; charset=utf-8",
+ ) -> None:
+ encoded = body.encode()
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(encoded)))
+ self.end_headers()
+ self.wfile.write(encoded)
+
+ def log_message(self, format: str, *args: object) -> None:
+ """Silence default stderr logging."""
+
+
+class KeyServer(HTTPServer):
+ """Ephemeral HTTP server that serves a PEM public key.
+
+ Runs in a daemon thread so the main process can continue interacting
+ with the user. Tailscale Funnel proxies external HTTPS traffic to
+ this local server.
+ """
+
+ def __init__(self, pem_content: str, port: int) -> None:
+ super().__init__(("127.0.0.1", port), _KeyRequestHandler)
+ self.pem_content = pem_content
+ self._thread: threading.Thread | None = None
+
+ def start(self) -> None:
+ """Start serving in a background daemon thread."""
+ self._thread = threading.Thread(target=self.serve_forever, daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ """Shut down the server and wait for the thread to exit."""
+ self.shutdown()
+ self.server_close()
+ if self._thread is not None:
+ self._thread.join(timeout=5)
+
+
# ---------------------------------------------------------------------------
# Key file management
# ---------------------------------------------------------------------------
@@ -46,6 +113,7 @@ async def deploy_public_key_tailscale(
key_path = base / WELL_KNOWN_PATH
key_path.parent.mkdir(parents=True, exist_ok=True)
key_path.write_text(public_key_pem)
+
logger.info("Public key written to %s", key_path)
return key_path
@@ -56,7 +124,11 @@ async def deploy_public_key_tailscale(
async def start_key_serving(serve_dir: Path | None = None) -> str:
- """Start ``tailscale serve`` for ``.well-known`` and enable Funnel.
+ """Start ``tailscale serve`` with Funnel for ``.well-known``.
+
+ Uses a single ``tailscale serve --bg --funnel --set-path / ``
+ command so that the static-file handler and public Funnel access are
+ configured atomically on HTTPS port 443.
Returns the public hostname (e.g. ``machine.tailnet.ts.net``).
@@ -75,20 +147,20 @@ async def start_key_serving(serve_dir: Path | None = None) -> str:
await ts.check_available()
hostname = await ts.get_hostname()
- # Serve the .well-known directory at /.well-known/
- await ts.start_serve("/.well-known/", str(well_known_dir))
-
- # Enable Funnel to make it publicly accessible
- await ts.enable_funnel()
+ # Serve the entire base directory at / with Funnel enabled so that:
+ # - The origin URL (https://host/) returns 200
+ # - The key at /.well-known/appspecific/com.tesla.3p.public-key.pem is reachable
+ # Tesla verifies both during Developer Portal app configuration.
+ await ts.start_serve("/", str(base), funnel=True)
logger.info("Key serving started at https://%s/%s", hostname, WELL_KNOWN_PATH)
return hostname
async def stop_key_serving() -> None:
- """Remove the ``.well-known`` serve handler."""
+ """Remove the key-serving handler."""
ts = TailscaleManager()
- await ts.stop_serve("/.well-known/")
+ await ts.stop_serve("/")
logger.info("Key serving stopped")
@@ -121,6 +193,22 @@ def get_key_url(hostname: str) -> str:
return f"https://{hostname}/{WELL_KNOWN_PATH}"
+def fetch_tailscale_key_pem(hostname: str) -> str | None:
+ """Fetch the public key PEM from a Tailscale Funnel ``.well-known`` URL.
+
+ Returns the PEM string (stripped), or ``None`` if the key is not
+ accessible or does not look like a PEM public key.
+ """
+ url = get_key_url(hostname)
+ try:
+ resp = httpx.get(url, follow_redirects=True, timeout=10)
+ if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
+ return resp.text.strip()
+ except httpx.HTTPError:
+ pass
+ return None
+
+
async def validate_tailscale_key_url(hostname: str) -> bool:
"""HTTP GET to verify key is accessible.
diff --git a/src/tescmd/openclaw/dispatcher.py b/src/tescmd/openclaw/dispatcher.py
index 3906f18..c828cd9 100644
--- a/src/tescmd/openclaw/dispatcher.py
+++ b/src/tescmd/openclaw/dispatcher.py
@@ -424,10 +424,17 @@ async def _handle_system_run(self, params: dict[str, Any]) -> dict[str, Any]:
Accepts both OpenClaw-style (``door.lock``) and API-style
(``door_lock``) method names via :data:`_METHOD_ALIASES`.
+
+ The target method can be specified as ``method`` or ``command``
+ (the latter mirrors the gateway protocol's field name).
"""
- method = params.get("method", "")
+ raw = params.get("method", "") or params.get("command", "")
+ # Normalize: bots may send a list like ["door.lock"] instead of a string
+ if isinstance(raw, list):
+ raw = raw[0] if raw else ""
+ method = str(raw).strip() if raw else ""
if not method:
- raise ValueError("system.run requires 'method' parameter")
+ raise ValueError("system.run requires 'method' (or 'command') parameter")
resolved = _METHOD_ALIASES.get(method, method)
if resolved == "system.run":
raise ValueError("system.run cannot invoke itself")
diff --git a/src/tescmd/openclaw/gateway.py b/src/tescmd/openclaw/gateway.py
index cc7f4e1..74d6bc6 100644
--- a/src/tescmd/openclaw/gateway.py
+++ b/src/tescmd/openclaw/gateway.py
@@ -280,8 +280,21 @@ async def connect(self) -> None:
so gateways that enforce authentication at the transport layer
accept the connection before the OpenClaw handshake begins.
+ If a receive loop is already running (e.g. from a previous
+ connection or a concurrent reconnect attempt), it is cancelled
+ before establishing the new connection to prevent duplicate
+ ``recv()`` calls on the same WebSocket.
+
Raises :class:`GatewayConnectionError` on failure.
"""
+ import contextlib
+
+ if self._recv_task is not None and not self._recv_task.done():
+ self._recv_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await self._recv_task
+ self._recv_task = None
+
await self._establish_connection()
if self._on_request is not None:
diff --git a/src/tescmd/telemetry/tailscale.py b/src/tescmd/telemetry/tailscale.py
index ff81dd1..4a14e28 100644
--- a/src/tescmd/telemetry/tailscale.py
+++ b/src/tescmd/telemetry/tailscale.py
@@ -112,27 +112,86 @@ async def check_funnel_available(self) -> bool:
# Serve management (static file hosting)
# ------------------------------------------------------------------
- async def start_serve(self, path: str, target: str | Path) -> None:
+ async def start_serve(
+ self,
+ path: str,
+ target: str | Path,
+ *,
+ port: int = 443,
+ funnel: bool = False,
+ ) -> None:
"""Serve a local directory at a URL path prefix.
- Runs: ``tailscale serve --bg --set-path ``
+ Runs: ``tailscale serve --bg [--https=] [--funnel] --set-path ``
+
+ When *port* differs from 443 the ``--https=`` flag is added so
+ Tailscale listens on the requested HTTPS port.
Args:
path: URL path prefix (e.g. ``/.well-known/``).
target: Local directory to serve.
+ port: HTTPS port to serve on (default ``443``).
+ funnel: Also enable Funnel (public access) for this handler.
"""
- returncode, stdout, stderr = await self._run(
- "tailscale",
- "serve",
- "--bg",
- "--set-path",
- path,
- str(target),
- )
+ cmd: list[str] = ["tailscale", "serve", "--bg"]
+ if port != 443:
+ cmd.append(f"--https={port}")
+ if funnel:
+ cmd.append("--funnel")
+ cmd.extend(["--set-path", path, str(target)])
+
+ returncode, stdout, stderr = await self._run(*cmd)
if returncode != 0:
msg = stderr.strip() or stdout.strip()
raise TailscaleError(f"Failed to start Tailscale serve: {msg}")
- logger.info("Tailscale serve started: %s -> %s", path, target)
+ logger.info("Tailscale serve started: %s -> %s (port %d)", path, target, port)
+
+ async def start_proxy(self, local_port: int, *, https_port: int = 443) -> None:
+ """Reverse-proxy an HTTPS port to a local HTTP server.
+
+ Runs: ``tailscale serve --bg [--https=] http://127.0.0.1:``
+
+ Unlike :meth:`start_serve` (which serves static files via
+ ``--set-path``), this sets up a reverse proxy so Tailscale
+ forwards traffic to a local HTTP server.
+
+ Args:
+ local_port: Port of the local HTTP server to proxy to.
+ https_port: Public-facing HTTPS port (default ``443``).
+ """
+ cmd: list[str] = ["tailscale", "serve", "--bg"]
+ if https_port != 443:
+ cmd.append(f"--https={https_port}")
+ cmd.append(f"http://127.0.0.1:{local_port}")
+
+ returncode, stdout, stderr = await self._run(*cmd)
+ if returncode != 0:
+ msg = stderr.strip() or stdout.strip()
+ raise TailscaleError(f"Failed to start Tailscale proxy: {msg}")
+ logger.info(
+ "Tailscale proxy started: https port %d -> http://127.0.0.1:%d",
+ https_port,
+ local_port,
+ )
+
+ async def stop_proxy(self, *, https_port: int = 443) -> None:
+ """Remove a reverse-proxy serve configuration for an HTTPS port.
+
+ Runs: ``tailscale serve --bg [--https=] off``
+ """
+ cmd: list[str] = ["tailscale", "serve", "--bg"]
+ if https_port != 443:
+ cmd.append(f"--https={https_port}")
+ cmd.append("off")
+
+ returncode, stdout, stderr = await self._run(*cmd)
+ if returncode != 0:
+ logger.warning(
+ "Failed to stop Tailscale proxy on port %d (may already be stopped): %s",
+ https_port,
+ stderr.strip() or stdout.strip(),
+ )
+ logger.info("Tailscale proxy stopped for HTTPS port %d", https_port)
async def stop_serve(self, path: str) -> None:
"""Remove a serve handler for a path.
@@ -155,21 +214,24 @@ async def stop_serve(self, path: str) -> None:
)
logger.info("Tailscale serve stopped for path: %s", path)
- async def enable_funnel(self) -> None:
- """Enable Funnel on port 443 (expose all serve handlers publicly).
+ async def enable_funnel(self, port: int = 443) -> None:
+ """Enable Funnel on the given *port* (expose all serve handlers publicly).
- Runs: ``tailscale funnel --bg 443``
+ Runs: ``tailscale funnel --bg ``
+
+ Args:
+ port: HTTPS port to expose via Funnel (default ``443``).
"""
returncode, stdout, stderr = await self._run(
"tailscale",
"funnel",
"--bg",
- "443",
+ str(port),
)
if returncode != 0:
msg = stderr.strip() or stdout.strip()
raise TailscaleError(f"Failed to enable Tailscale Funnel: {msg}")
- logger.info("Tailscale Funnel enabled on port 443")
+ logger.info("Tailscale Funnel enabled on port %d", port)
# ------------------------------------------------------------------
# Funnel management (port proxying for telemetry)
diff --git a/tests/cli/test_auth.py b/tests/cli/test_auth.py
index 3e2f548..99d214c 100644
--- a/tests/cli/test_auth.py
+++ b/tests/cli/test_auth.py
@@ -4,7 +4,10 @@
from unittest.mock import AsyncMock, MagicMock, patch
-from tescmd.cli.auth import _interactive_setup, _prompt_for_domain
+from tescmd.cli.auth import (
+ _interactive_setup,
+ _prompt_for_domain,
+)
def _make_formatter() -> MagicMock:
@@ -82,13 +85,14 @@ def test_persists_lowercased_value(self) -> None:
class TestInteractiveSetupTailscaleOrigin:
"""Tests for Tailscale detection + Funnel lifecycle in _interactive_setup."""
- def test_shows_concrete_tailscale_url_when_hostname_provided(self) -> None:
- """When tailscale_hostname is passed, Step 3 shows 'Also add' with concrete URL."""
+ def test_shows_concrete_tailscale_url(self) -> None:
+ """When tailscale_hostname is passed with full_tier, Step 3 shows the URL."""
formatter = _make_formatter()
- # Inputs: open browser=n, funnel prompt=n, client_id, secret, save=n
+ # Inputs: funnel=n, browser=n, client_id, secret
with (
- patch("builtins.input", side_effect=["n", "n", "test-id", "secret", "n"]),
+ patch("builtins.input", side_effect=["n", "n", "test-id", "secret"]),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
):
cid, cs = _interactive_setup(
formatter,
@@ -96,12 +100,13 @@ def test_shows_concrete_tailscale_url_when_hostname_provided(self) -> None:
"http://localhost:8085/callback",
domain="user.github.io",
tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
)
assert cid == "test-id"
assert cs == "secret"
calls = [str(c) for c in formatter.rich.info.call_args_list]
- # Should show the concrete Tailscale origin
+ # Should show the concrete Tailscale origin (port 443, no suffix)
assert any("Also add" in c and "https://mybox.tail99.ts.net" in c for c in calls)
# Should NOT show the generic placeholder
assert not any(".tailnet.ts.net" in c for c in calls)
@@ -109,10 +114,12 @@ def test_shows_concrete_tailscale_url_when_hostname_provided(self) -> None:
def test_generic_placeholder_when_no_tailscale(self) -> None:
"""Without Tailscale, Step 3 shows the generic placeholder hint."""
formatter = _make_formatter()
- # Inputs: open browser=n, client_id, secret, save=n
+ # full_tier=True but no tailscale available → auto-detect fails
+ # Inputs: browser=n, client_id, secret
with (
- patch("builtins.input", side_effect=["n", "test-id", "secret", "n"]),
+ patch("builtins.input", side_effect=["n", "test-id", "secret"]),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
patch(
"tescmd.deploy.tailscale_serve.is_tailscale_serve_ready",
new_callable=AsyncMock,
@@ -124,6 +131,7 @@ def test_generic_placeholder_when_no_tailscale(self) -> None:
8085,
"http://localhost:8085/callback",
domain="user.github.io",
+ full_tier=True,
)
calls = [str(c) for c in formatter.rich.info.call_args_list]
@@ -133,12 +141,13 @@ def test_generic_placeholder_when_no_tailscale(self) -> None:
assert not any("Also add" in c for c in calls)
def test_auto_detects_tailscale_when_no_hostname_passed(self) -> None:
- """When tailscale_hostname is empty, auto-detection finds Tailscale."""
+ """When tailscale_hostname is empty + full_tier, auto-detection finds Tailscale."""
formatter = _make_formatter()
- # Inputs: open browser=n, funnel prompt=n, client_id, secret, save=n
+ # Inputs: funnel=n, browser=n, client_id, secret
with (
- patch("builtins.input", side_effect=["n", "n", "test-id", "secret", "n"]),
+ patch("builtins.input", side_effect=["n", "n", "test-id", "secret"]),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
patch(
"tescmd.deploy.tailscale_serve.is_tailscale_serve_ready",
new_callable=AsyncMock,
@@ -155,6 +164,7 @@ def test_auto_detects_tailscale_when_no_hostname_passed(self) -> None:
8085,
"http://localhost:8085/callback",
domain="user.github.io",
+ full_tier=True,
)
calls = [str(c) for c in formatter.rich.info.call_args_list]
@@ -162,21 +172,30 @@ def test_auto_detects_tailscale_when_no_hostname_passed(self) -> None:
assert any("Also add" in c and "https://auto.tail99.ts.net" in c for c in calls)
def test_funnel_started_and_stopped(self) -> None:
- """When user accepts Funnel prompt, enable_funnel is called and cleanup runs."""
+ """When user accepts Funnel prompt, KeyServer starts and cleanup runs."""
formatter = _make_formatter()
- # Inputs: open browser=n, funnel=Y, client_id, secret, save=n
+ mock_server = MagicMock()
+ mock_server.server_address = ("127.0.0.1", 54321)
+ # Inputs: funnel=Y, browser=n, client_id, secret
with (
- patch("builtins.input", side_effect=["n", "Y", "test-id", "secret", "n"]),
+ patch("builtins.input", side_effect=["Y", "n", "test-id", "secret"]),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value="PEM-DATA"),
patch(
- "tescmd.telemetry.tailscale.TailscaleManager.enable_funnel",
+ "tescmd.deploy.tailscale_serve.KeyServer",
+ return_value=mock_server,
+ ) as mock_ks_cls,
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
new_callable=AsyncMock,
- ) as mock_enable,
+ return_value="https://mybox.tail99.ts.net",
+ ) as mock_start_funnel,
patch(
- "tescmd.telemetry.tailscale.TailscaleManager._run",
+ "tescmd.telemetry.tailscale.TailscaleManager.stop_funnel",
new_callable=AsyncMock,
- return_value=(0, "", ""),
- ) as mock_run,
+ ) as mock_stop_funnel,
):
_interactive_setup(
formatter,
@@ -184,27 +203,28 @@ def test_funnel_started_and_stopped(self) -> None:
"http://localhost:8085/callback",
domain="user.github.io",
tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
)
- mock_enable.assert_called_once()
- # Cleanup: _run called with funnel off
- mock_run.assert_called_once_with("tailscale", "funnel", "--bg", "off")
+ mock_ks_cls.assert_called_once_with("PEM-DATA", port=0)
+ mock_server.start.assert_called_once()
+ mock_start_funnel.assert_awaited_once_with(54321)
+ # Cleanup: KeyServer.stop() + stop_funnel()
+ mock_server.stop.assert_called_once()
+ mock_stop_funnel.assert_awaited_once()
def test_funnel_declined_no_cleanup(self) -> None:
"""When user declines Funnel prompt, no Funnel start/stop occurs."""
formatter = _make_formatter()
- # Inputs: open browser=n, funnel=n, client_id, secret, save=n
+ # Inputs: funnel=n, browser=n, client_id, secret
with (
- patch("builtins.input", side_effect=["n", "n", "test-id", "secret", "n"]),
+ patch("builtins.input", side_effect=["n", "n", "test-id", "secret"]),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
patch(
- "tescmd.telemetry.tailscale.TailscaleManager.enable_funnel",
- new_callable=AsyncMock,
- ) as mock_enable,
- patch(
- "tescmd.telemetry.tailscale.TailscaleManager._run",
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
new_callable=AsyncMock,
- ) as mock_run,
+ ) as mock_start_funnel,
):
_interactive_setup(
formatter,
@@ -212,20 +232,29 @@ def test_funnel_declined_no_cleanup(self) -> None:
"http://localhost:8085/callback",
domain="user.github.io",
tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
)
- mock_enable.assert_not_called()
- mock_run.assert_not_called()
+ mock_start_funnel.assert_not_called()
def test_funnel_error_falls_back_to_generic(self) -> None:
- """When Funnel start fails, falls back to generic placeholder."""
+ """When start_funnel fails, falls back to generic placeholder."""
formatter = _make_formatter()
- # Inputs: open browser=n, funnel=Y, client_id, secret, save=n
+ mock_server = MagicMock()
+ mock_server.server_address = ("127.0.0.1", 54321)
+ # Inputs: funnel=Y, browser=n, client_id, secret
with (
- patch("builtins.input", side_effect=["n", "Y", "test-id", "secret", "n"]),
+ patch("builtins.input", side_effect=["Y", "n", "test-id", "secret"]),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value="PEM-DATA"),
+ patch(
+ "tescmd.deploy.tailscale_serve.KeyServer",
+ return_value=mock_server,
+ ),
patch(
- "tescmd.telemetry.tailscale.TailscaleManager.enable_funnel",
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
new_callable=AsyncMock,
side_effect=Exception("Funnel not available"),
),
@@ -236,6 +265,7 @@ def test_funnel_error_falls_back_to_generic(self) -> None:
"http://localhost:8085/callback",
domain="user.github.io",
tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
)
calls = [str(c) for c in formatter.rich.info.call_args_list]
@@ -243,20 +273,32 @@ def test_funnel_error_falls_back_to_generic(self) -> None:
assert any("Could not start Funnel" in c for c in calls)
# Should show generic placeholder since ts_hostname was cleared
assert any(".tailnet.ts.net" in c for c in calls)
+ # KeyServer should have been stopped on failure
+ mock_server.stop.assert_called_once()
def test_funnel_cleanup_failure_warns_user(self) -> None:
"""When Funnel cleanup fails, a warning is shown to the user."""
formatter = _make_formatter()
- # Inputs: open browser=n, funnel=Y, client_id, secret, save=n
+ mock_server = MagicMock()
+ mock_server.server_address = ("127.0.0.1", 54321)
+ # Inputs: funnel=Y, browser=n, client_id, secret
with (
- patch("builtins.input", side_effect=["n", "Y", "test-id", "secret", "n"]),
+ patch("builtins.input", side_effect=["Y", "n", "test-id", "secret"]),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value="PEM-DATA"),
patch(
- "tescmd.telemetry.tailscale.TailscaleManager.enable_funnel",
+ "tescmd.deploy.tailscale_serve.KeyServer",
+ return_value=mock_server,
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
new_callable=AsyncMock,
+ return_value="https://mybox.tail99.ts.net",
),
patch(
- "tescmd.telemetry.tailscale.TailscaleManager._run",
+ "tescmd.telemetry.tailscale.TailscaleManager.stop_funnel",
new_callable=AsyncMock,
side_effect=Exception("network timeout"),
),
@@ -267,26 +309,88 @@ def test_funnel_cleanup_failure_warns_user(self) -> None:
"http://localhost:8085/callback",
domain="user.github.io",
tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
)
calls = [str(c) for c in formatter.rich.info.call_args_list]
assert any("Failed to stop Tailscale Funnel" in c for c in calls)
assert any("tailscale funnel --bg off" in c for c in calls)
+ # KeyServer.stop() should still have been called
+ mock_server.stop.assert_called_once()
+
+ def test_app_name_guid_generated_on_fresh_run(self) -> None:
+ """Fresh run generates tescmd- app name and shows it in Step 2."""
+ formatter = _make_formatter()
+ # full_tier=True with tailscale: funnel=n, browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["n", "n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch("tescmd.cli.auth._write_env_value") as mock_write_val,
+ patch.dict("os.environ", {}, clear=False),
+ patch("tescmd.cli.auth.uuid") as mock_uuid_mod,
+ ):
+ # Remove TESLA_APP_NAME if present
+ import os
+
+ os.environ.pop("TESLA_APP_NAME", None)
+ mock_uuid_mod.uuid4.return_value = MagicMock(hex="aabbccdd11223344")
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ # Step 2 should show the generated name
+ assert any("tescmd-aabbccdd" in c for c in calls)
+ # Should persist the app name
+ mock_write_val.assert_called_once_with("TESLA_APP_NAME", "tescmd-aabbccdd")
+
+ def test_app_name_reused_from_env_on_rerun(self) -> None:
+ """When TESLA_APP_NAME is already set, reuses it and does not re-save."""
+ formatter = _make_formatter()
+ # full_tier=True with tailscale: funnel=n, browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["n", "n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch("tescmd.cli.auth._write_env_value") as mock_write_val,
+ patch.dict("os.environ", {"TESLA_APP_NAME": "tescmd-existing1"}, clear=False),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ # Should show the existing app name
+ assert any("tescmd-existing1" in c for c in calls)
+ # Should NOT call _write_env_value since name was already saved
+ mock_write_val.assert_not_called()
def test_eof_on_funnel_prompt_skips_funnel(self) -> None:
"""EOFError on Funnel prompt skips Funnel but continues setup."""
formatter = _make_formatter()
- # Inputs: open browser=n, funnel=EOFError, client_id, secret, save=n
+ # Inputs: funnel=EOFError, browser=n, client_id, secret
with (
patch(
"builtins.input",
- side_effect=["n", EOFError, "test-id", "secret", "n"],
+ side_effect=[EOFError, "n", "test-id", "secret"],
),
patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
patch(
- "tescmd.telemetry.tailscale.TailscaleManager.enable_funnel",
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
new_callable=AsyncMock,
- ) as mock_enable,
+ ) as mock_start_funnel,
):
cid, _cs = _interactive_setup(
formatter,
@@ -294,8 +398,434 @@ def test_eof_on_funnel_prompt_skips_funnel(self) -> None:
"http://localhost:8085/callback",
domain="user.github.io",
tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
)
# Setup should continue past the Funnel prompt
assert cid == "test-id"
- mock_enable.assert_not_called()
+ mock_start_funnel.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# _interactive_setup — full_tier gating
+# ---------------------------------------------------------------------------
+
+
+class TestInteractiveSetupFullTierGating:
+ """Verify Tailscale prompt only appears when full_tier=True."""
+
+ def test_no_tailscale_prompt_without_full_tier(self) -> None:
+ """When full_tier=False, no Tailscale prompt even if hostname is provided."""
+ formatter = _make_formatter()
+ # Inputs: browser=n, client_id, secret (no funnel prompt)
+ with (
+ patch("builtins.input", side_effect=["n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ ):
+ cid, cs = _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=False,
+ )
+
+ assert cid == "test-id"
+ assert cs == "secret"
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ # Should NOT show Tailscale detection or funnel prompt
+ assert not any("Tailscale detected" in c for c in calls)
+ # Without full_tier, tailscale_hostname is not used for "Also add"
+ assert not any("Also add" in c for c in calls)
+
+ def test_no_tailscale_auto_detect_without_full_tier(self) -> None:
+ """When full_tier=False, auto-detection is not even attempted."""
+ formatter = _make_formatter()
+ # Inputs: browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch(
+ "tescmd.deploy.tailscale_serve.is_tailscale_serve_ready",
+ new_callable=AsyncMock,
+ return_value=True,
+ ) as mock_ts_ready,
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ full_tier=False,
+ )
+
+ # Auto-detection should not have been called
+ mock_ts_ready.assert_not_called()
+
+ def test_default_full_tier_is_false(self) -> None:
+ """Default full_tier=False — standalone auth command has no Tailscale prompt."""
+ formatter = _make_formatter()
+ # Inputs: browser=n, client_id, secret (no funnel prompt)
+ with (
+ patch("builtins.input", side_effect=["n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ ):
+ cid, _cs = _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ )
+
+ assert cid == "test-id"
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert not any("Tailscale detected" in c for c in calls)
+
+
+# ---------------------------------------------------------------------------
+# _interactive_setup — prompt ordering
+# ---------------------------------------------------------------------------
+
+
+class TestInteractiveSetupPromptOrder:
+ """Verify the Tailscale prompt comes before the browser prompt."""
+
+ def test_tailscale_prompt_before_browser(self) -> None:
+ """When full_tier=True + Tailscale, funnel prompt precedes browser prompt."""
+ formatter = _make_formatter()
+ prompts_seen: list[str] = []
+
+ def mock_input(prompt: str = "") -> str:
+ prompts_seen.append(prompt)
+ if "Funnel" in prompt:
+ return "n"
+ if "Developer Portal" in prompt:
+ return "n"
+ if "Client ID" in prompt:
+ return "test-id"
+ if "Client Secret" in prompt:
+ return "secret"
+ return ""
+
+ with (
+ patch("builtins.input", side_effect=mock_input),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ # Find the indices of key prompts
+ funnel_idx = next((i for i, p in enumerate(prompts_seen) if "Funnel" in p), None)
+ browser_idx = next(
+ (i for i, p in enumerate(prompts_seen) if "Developer Portal" in p), None
+ )
+ assert funnel_idx is not None, "Funnel prompt not found"
+ assert browser_idx is not None, "Browser prompt not found"
+ assert funnel_idx < browser_idx, (
+ f"Funnel prompt (idx={funnel_idx}) should come before "
+ f"browser prompt (idx={browser_idx})"
+ )
+
+ def test_steps_uninterrupted_when_full_tier(self) -> None:
+ """Steps 1-6 appear contiguously without Tailscale prompts between them."""
+ formatter = _make_formatter()
+ # Inputs: funnel=n, browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["n", "n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ # Find indices of Steps
+ step_indices = []
+ for i, c in enumerate(calls):
+ if "Step 1" in c:
+ step_indices.append(("Step 1", i))
+ elif "Step 2" in c:
+ step_indices.append(("Step 2", i))
+ elif "Step 3" in c:
+ step_indices.append(("Step 3", i))
+ elif "Step 4" in c:
+ step_indices.append(("Step 4", i))
+ elif "Step 5" in c:
+ step_indices.append(("Step 5", i))
+ elif "Step 6" in c:
+ step_indices.append(("Step 6", i))
+
+ assert len(step_indices) == 6, f"Expected 6 steps, found {len(step_indices)}"
+
+ # Verify no "Tailscale detected" or "Funnel" messages between Step 1 and Step 6
+ first_step_idx = step_indices[0][1]
+ last_step_idx = step_indices[-1][1]
+ between_steps = calls[first_step_idx : last_step_idx + 1]
+ assert not any("Tailscale detected" in c for c in between_steps)
+ assert not any("Start Tailscale Funnel" in c for c in between_steps)
+
+
+# ---------------------------------------------------------------------------
+# _interactive_setup — tier-aware scopes
+# ---------------------------------------------------------------------------
+
+
+class TestInteractiveSetupTierAwareScopes:
+ """Verify Step 4 scope list changes with full_tier flag."""
+
+ def test_step4_scopes_readonly_tier(self) -> None:
+ """When full_tier=False, Step 4 shows only readonly scopes."""
+ formatter = _make_formatter()
+ # Inputs: browser=n, client_id, secret (no funnel prompt for readonly)
+ with (
+ patch("builtins.input", side_effect=["n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ full_tier=False,
+ )
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ # Readonly scopes present
+ assert any("Vehicle Information" in c for c in calls)
+ assert any("Vehicle Location" in c for c in calls)
+ assert any("Energy Information" in c for c in calls)
+ assert any("User Data" in c for c in calls)
+ # Command scopes absent
+ assert not any("Vehicle Commands" in c for c in calls)
+ assert not any("Vehicle Charging Management" in c for c in calls)
+ assert not any("Energy Commands" in c for c in calls)
+
+ def test_step4_scopes_full_tier(self) -> None:
+ """When full_tier=True, Step 4 says 'Select All' instead of listing scopes."""
+ formatter = _make_formatter()
+ # Inputs: funnel=n, browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["n", "n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ # Should show "Select All" for full tier
+ assert any("Select All" in c for c in calls)
+ # Individual scope names should NOT appear
+ assert not any("Vehicle Information" in c for c in calls)
+ assert not any("Vehicle Commands" in c for c in calls)
+ assert not any("Vehicle Charging Management" in c for c in calls)
+ assert not any("Energy Commands" in c for c in calls)
+
+
+# ---------------------------------------------------------------------------
+# _interactive_setup — key serving
+# ---------------------------------------------------------------------------
+
+
+class TestInteractiveSetupKeyServing:
+ """Verify key generation and serving during Funnel start."""
+
+ def test_key_generated_during_funnel_start(self) -> None:
+ """When has_key_pair=False, generate_ec_key_pair is called."""
+ formatter = _make_formatter()
+ mock_server = MagicMock()
+ mock_server.server_address = ("127.0.0.1", 54321)
+ # Inputs: funnel=Y, browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["Y", "n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch("tescmd.crypto.keys.has_key_pair", return_value=False),
+ patch("tescmd.crypto.keys.generate_ec_key_pair") as mock_gen,
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value="PEM-DATA"),
+ patch(
+ "tescmd.deploy.tailscale_serve.KeyServer",
+ return_value=mock_server,
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
+ new_callable=AsyncMock,
+ return_value="https://mybox.tail99.ts.net",
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.stop_funnel",
+ new_callable=AsyncMock,
+ ),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ mock_gen.assert_called_once()
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert any("Generating" in c for c in calls)
+
+ def test_key_not_regenerated_when_exists(self) -> None:
+ """When has_key_pair=True, generate_ec_key_pair is NOT called."""
+ formatter = _make_formatter()
+ mock_server = MagicMock()
+ mock_server.server_address = ("127.0.0.1", 54321)
+ # Inputs: funnel=Y, browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["Y", "n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.generate_ec_key_pair") as mock_gen,
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value="PEM-DATA"),
+ patch(
+ "tescmd.deploy.tailscale_serve.KeyServer",
+ return_value=mock_server,
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
+ new_callable=AsyncMock,
+ return_value="https://mybox.tail99.ts.net",
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.stop_funnel",
+ new_callable=AsyncMock,
+ ),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ mock_gen.assert_not_called()
+
+ def test_cleanup_runs_on_eof_during_client_id(self) -> None:
+ """EOFError at Client ID prompt still triggers KeyServer.stop()."""
+ formatter = _make_formatter()
+ mock_server = MagicMock()
+ mock_server.server_address = ("127.0.0.1", 54321)
+ # Inputs: funnel=Y, browser=n, client_id=EOFError
+ with (
+ patch("builtins.input", side_effect=["Y", "n", EOFError]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value="PEM-DATA"),
+ patch(
+ "tescmd.deploy.tailscale_serve.KeyServer",
+ return_value=mock_server,
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
+ new_callable=AsyncMock,
+ return_value="https://mybox.tail99.ts.net",
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.stop_funnel",
+ new_callable=AsyncMock,
+ ),
+ ):
+ cid, cs = _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ assert cid == ""
+ assert cs == ""
+ mock_server.stop.assert_called_once()
+
+ def test_cleanup_runs_on_empty_client_id(self) -> None:
+ """Empty Client ID returns early but still triggers KeyServer.stop()."""
+ formatter = _make_formatter()
+ mock_server = MagicMock()
+ mock_server.server_address = ("127.0.0.1", 54321)
+ # Inputs: funnel=Y, browser=n, client_id="" x3 (exhausts retries)
+ with (
+ patch("builtins.input", side_effect=["Y", "n", "", "", ""]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value="PEM-DATA"),
+ patch(
+ "tescmd.deploy.tailscale_serve.KeyServer",
+ return_value=mock_server,
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.start_funnel",
+ new_callable=AsyncMock,
+ return_value="https://mybox.tail99.ts.net",
+ ),
+ patch(
+ "tescmd.telemetry.tailscale.TailscaleManager.stop_funnel",
+ new_callable=AsyncMock,
+ ),
+ ):
+ cid, cs = _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ tailscale_hostname="mybox.tail99.ts.net",
+ full_tier=True,
+ )
+
+ assert cid == ""
+ assert cs == ""
+ mock_server.stop.assert_called_once()
+
+ def test_step1_shows_create_new_application(self) -> None:
+ """Step 1 header says 'Create New Application', not 'Registration'."""
+ formatter = _make_formatter()
+ # Inputs: browser=n, client_id, secret
+ with (
+ patch("builtins.input", side_effect=["n", "test-id", "secret"]),
+ patch("tescmd.cli.auth.webbrowser"),
+ patch("tescmd.cli.auth._write_env_file"),
+ ):
+ _interactive_setup(
+ formatter,
+ 8085,
+ "http://localhost:8085/callback",
+ domain="user.github.io",
+ )
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert any("Create New Application" in c for c in calls)
+ # "Registration" should not appear as a Step 1 header
+ assert not any("Step 1" in c and "Registration" in c for c in calls)
diff --git a/tests/cli/test_setup.py b/tests/cli/test_setup.py
index 5abd654..e057b58 100644
--- a/tests/cli/test_setup.py
+++ b/tests/cli/test_setup.py
@@ -11,10 +11,13 @@
from tescmd.cli.setup import (
TIER_FULL,
TIER_READONLY,
+ _check_key_mismatch,
_cmd_setup,
_deploy_key_github,
_developer_portal_setup,
_domain_setup,
+ _key_setup,
+ _oauth_login_step,
_precheck_public_key,
_print_next_steps,
_prompt_tier,
@@ -213,6 +216,129 @@ def test_lowercases_github_username_in_domain(self, monkeypatch: pytest.MonkeyPa
mock_write.assert_any_call("TESLA_DOMAIN", "testuser.github.io")
+# ---------------------------------------------------------------------------
+# Key mismatch warning
+# ---------------------------------------------------------------------------
+
+
+class TestCheckKeyMismatch:
+ """Tests for _check_key_mismatch (runs between Phase 1 and Phase 2)."""
+
+ def test_no_warning_when_no_local_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """No warning when no local key pair exists."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+
+ with patch("tescmd.crypto.keys.has_key_pair", return_value=False):
+ _check_key_mismatch(formatter, settings, "user.github.io")
+
+ # No warning should have been printed
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert not any("differs" in c for c in calls)
+
+ def test_no_warning_when_keys_match(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """No warning when remote key matches local key."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+ local_pem = "-----BEGIN PUBLIC KEY-----\nMATCH\n-----END PUBLIC KEY-----\n"
+
+ with (
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value=local_pem),
+ patch("tescmd.deploy.github_pages.fetch_key_pem", return_value=local_pem.strip()),
+ ):
+ _check_key_mismatch(formatter, settings, "user.github.io")
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert not any("differs" in c for c in calls)
+
+ def test_warns_when_keys_differ_github(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Warning shown when remote GitHub key differs from local."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+ local_pem = "-----BEGIN PUBLIC KEY-----\nLOCAL\n-----END PUBLIC KEY-----\n"
+ remote_pem = "-----BEGIN PUBLIC KEY-----\nREMOTE\n-----END PUBLIC KEY-----"
+
+ with (
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value=local_pem),
+ patch("tescmd.deploy.github_pages.fetch_key_pem", return_value=remote_pem),
+ ):
+ _check_key_mismatch(formatter, settings, "user.github.io")
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert any("differs" in c for c in calls)
+ assert any("Phase 3" in c for c in calls)
+
+ def test_warns_when_keys_differ_tailscale(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Warning shown when remote Tailscale key differs from local."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_HOSTING_METHOD", "tailscale")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+ local_pem = "-----BEGIN PUBLIC KEY-----\nLOCAL\n-----END PUBLIC KEY-----\n"
+
+ remote_pem = "-----BEGIN PUBLIC KEY-----\nREMOTE\n-----END PUBLIC KEY-----"
+
+ with (
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value=local_pem),
+ patch(
+ "tescmd.deploy.tailscale_serve.fetch_tailscale_key_pem",
+ return_value=remote_pem,
+ ),
+ ):
+ _check_key_mismatch(formatter, settings, "mybox.tail99.ts.net")
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert any("differs" in c for c in calls)
+
+ def test_no_warning_when_remote_unreachable(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """No warning when remote key cannot be fetched."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+ local_pem = "-----BEGIN PUBLIC KEY-----\nLOCAL\n-----END PUBLIC KEY-----\n"
+
+ with (
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.load_public_key_pem", return_value=local_pem),
+ patch("tescmd.deploy.github_pages.fetch_key_pem", return_value=None),
+ ):
+ _check_key_mismatch(formatter, settings, "user.github.io")
+
+ calls = [str(c) for c in formatter.rich.info.call_args_list]
+ assert not any("differs" in c for c in calls)
+
+
# ---------------------------------------------------------------------------
# Phase 6: Next steps
# ---------------------------------------------------------------------------
@@ -323,6 +449,7 @@ async def test_full_flow_includes_key_setup(self, monkeypatch: pytest.MonkeyPatc
return_value=("test-id", "test-secret"),
),
patch("tescmd.cli.setup._domain_setup", return_value="user.github.io"),
+ patch("tescmd.cli.setup._check_key_mismatch"),
patch("tescmd.cli.setup._key_setup") as mock_key,
patch(
"tescmd.cli.setup._enrollment_step",
@@ -340,6 +467,48 @@ async def test_full_flow_includes_key_setup(self, monkeypatch: pytest.MonkeyPatc
await _cmd_setup(app_ctx)
mock_key.assert_called_once()
+ @pytest.mark.asyncio
+ async def test_full_flow_phase_order(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Verify phases run in order: keys → oauth → registration → enrollment."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_CLIENT_ID", "test-id")
+ monkeypatch.setenv("TESLA_CLIENT_SECRET", "test-secret")
+ monkeypatch.setenv("TESLA_DOMAIN", "user.github.io")
+
+ app_ctx = _make_app_ctx()
+ call_order: list[str] = []
+
+ def track_key(*_a: object, **_k: object) -> None:
+ call_order.append("key_setup")
+
+ async def track_oauth(*_a: object, **_k: object) -> None:
+ call_order.append("oauth")
+
+ async def track_registration(*_a: object, **_k: object) -> None:
+ call_order.append("registration")
+
+ async def track_enrollment(*_a: object, **_k: object) -> None:
+ call_order.append("enrollment")
+
+ with (
+ patch("tescmd.cli.setup._prompt_tier", return_value=TIER_FULL),
+ patch(
+ "tescmd.cli.setup._developer_portal_setup",
+ return_value=("test-id", "test-secret"),
+ ),
+ patch("tescmd.cli.setup._domain_setup", return_value="user.github.io"),
+ patch("tescmd.cli.setup._check_key_mismatch"),
+ patch("tescmd.cli.setup._key_setup", side_effect=track_key),
+ patch("tescmd.cli.setup._oauth_login_step", side_effect=track_oauth),
+ patch("tescmd.cli.setup._registration_step", side_effect=track_registration),
+ patch("tescmd.cli.setup._enrollment_step", side_effect=track_enrollment),
+ ):
+ await _cmd_setup(app_ctx)
+
+ assert call_order == ["key_setup", "registration", "oauth", "enrollment"]
+
# ---------------------------------------------------------------------------
# Phase 4: Registration — precheck and error remediation
@@ -967,10 +1136,13 @@ def test_passes_tailscale_hostname_from_settings(
formatter = app_ctx.formatter
with patch("tescmd.cli.auth._interactive_setup", return_value=("id", "secret")) as mock_is:
- _developer_portal_setup(formatter, app_ctx, settings, domain="mybox.tail99.ts.net")
+ _developer_portal_setup(
+ formatter, app_ctx, settings, domain="mybox.tail99.ts.net", tier="full"
+ )
_args, kwargs = mock_is.call_args
assert kwargs.get("tailscale_hostname") == "mybox.tail99.ts.net"
+ assert kwargs.get("full_tier") is True
def test_no_tailscale_hostname_when_github_hosting(
self, monkeypatch: pytest.MonkeyPatch
@@ -988,10 +1160,55 @@ def test_no_tailscale_hostname_when_github_hosting(
formatter = app_ctx.formatter
with patch("tescmd.cli.auth._interactive_setup", return_value=("id", "secret")) as mock_is:
- _developer_portal_setup(formatter, app_ctx, settings, domain="user.github.io")
+ _developer_portal_setup(
+ formatter, app_ctx, settings, domain="user.github.io", tier="readonly"
+ )
_args, kwargs = mock_is.call_args
assert kwargs.get("tailscale_hostname") == ""
+ assert kwargs.get("full_tier") is False
+
+ def test_full_tier_passed_through(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """When tier='full', full_tier=True is forwarded to _interactive_setup."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_DOMAIN", "user.github.io")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ app_ctx = _make_app_ctx()
+ formatter = app_ctx.formatter
+
+ with patch("tescmd.cli.auth._interactive_setup", return_value=("id", "secret")) as mock_is:
+ _developer_portal_setup(
+ formatter, app_ctx, settings, domain="user.github.io", tier="full"
+ )
+
+ _args, kwargs = mock_is.call_args
+ assert kwargs.get("full_tier") is True
+
+ def test_readonly_tier_passed_through(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """When tier='readonly', full_tier=False is forwarded to _interactive_setup."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_DOMAIN", "user.github.io")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ app_ctx = _make_app_ctx()
+ formatter = app_ctx.formatter
+
+ with patch("tescmd.cli.auth._interactive_setup", return_value=("id", "secret")) as mock_is:
+ _developer_portal_setup(
+ formatter, app_ctx, settings, domain="user.github.io", tier="readonly"
+ )
+
+ _args, kwargs = mock_is.call_args
+ assert kwargs.get("full_tier") is False
def test_skips_when_already_configured(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When client_id is already set, returns early without calling _interactive_setup."""
@@ -1015,3 +1232,230 @@ def test_skips_when_already_configured(self, monkeypatch: pytest.MonkeyPatch) ->
assert cid == "existing-id"
assert cs == "existing-secret"
mock_is.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# --force flag behavior
+# ---------------------------------------------------------------------------
+
+
+class TestForceFlag:
+ """Verify --force bypasses all 'already configured' guards."""
+
+ def test_force_reconfigures_tier_when_already_full(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """With force, a user with full tier gets re-prompted instead of auto-returning."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_SETUP_TIER", "full")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+
+ # Without force, full tier returns immediately
+ tier = _prompt_tier(formatter, settings, force=False)
+ assert tier == TIER_FULL
+
+ # With force, the user is re-prompted (choose "2" for full)
+ with (
+ patch("builtins.input", return_value="2"),
+ patch("tescmd.cli.auth._write_env_value"),
+ ):
+ tier = _prompt_tier(formatter, settings, force=True)
+ assert tier == TIER_FULL
+
+ def test_force_reconfigures_domain_when_already_set(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """With force, domain is re-prompted despite an existing value."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_DOMAIN", "existing.example.com")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+
+ # Without force, returns existing domain
+ domain = _domain_setup(formatter, settings, force=False)
+ assert domain == "existing.example.com"
+
+ # With force, falls through to the domain selection logic
+ with (
+ patch("tescmd.deploy.github_pages.is_gh_available", return_value=False),
+ patch(
+ "tescmd.cli.setup._is_tailscale_ready",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ patch(
+ "tescmd.cli.setup._manual_domain_setup",
+ return_value="new.example.com",
+ ) as mock_manual,
+ ):
+ domain = _domain_setup(formatter, settings, force=True)
+
+ assert domain == "new.example.com"
+ mock_manual.assert_called_once()
+
+ def test_force_reconfigures_credentials_when_already_set(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """With force, _interactive_setup is called despite existing client_id."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_CLIENT_ID", "existing-id")
+ monkeypatch.setenv("TESLA_CLIENT_SECRET", "existing-secret")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ app_ctx = _make_app_ctx()
+ formatter = app_ctx.formatter
+
+ with patch(
+ "tescmd.cli.auth._interactive_setup",
+ return_value=("new-id", "new-secret"),
+ ) as mock_is:
+ cid, cs = _developer_portal_setup(
+ formatter, app_ctx, settings, domain="user.github.io", force=True, tier="full"
+ )
+
+ assert cid == "new-id"
+ assert cs == "new-secret"
+ mock_is.assert_called_once()
+
+ def test_force_regenerates_keys(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """With force, keys are regenerated with overwrite=True."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_CONFIG_DIR", "/tmp/test-tescmd")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+
+ with (
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.get_key_fingerprint", return_value="abc123"),
+ patch("tescmd.crypto.keys.generate_ec_key_pair") as mock_gen,
+ patch("tescmd.cli.setup._deploy_key_github"),
+ ):
+ _key_setup(formatter, settings, "user.github.io", force=True)
+
+ mock_gen.assert_called_once()
+ _args, kwargs = mock_gen.call_args
+ assert kwargs.get("overwrite") is True
+
+ def test_force_does_not_regenerate_without_flag(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Without force, existing keys are kept."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TESLA_CONFIG_DIR", "/tmp/test-tescmd")
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+ formatter = _make_formatter()
+
+ with (
+ patch("tescmd.crypto.keys.has_key_pair", return_value=True),
+ patch("tescmd.crypto.keys.get_key_fingerprint", return_value="abc123"),
+ patch("tescmd.crypto.keys.generate_ec_key_pair") as mock_gen,
+ patch("tescmd.cli.setup._deploy_key_github"),
+ ):
+ _key_setup(formatter, settings, "user.github.io", force=False)
+
+ mock_gen.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_force_reauthenticates_oauth(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """With force, OAuth re-runs despite existing token with all scopes."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+
+ app_ctx = _make_app_ctx()
+ formatter = app_ctx.formatter
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+
+ mock_store = MagicMock()
+ mock_store.has_token = True
+ mock_store.metadata = {
+ "scopes": [
+ "openid",
+ "offline_access",
+ "user_data",
+ "vehicle_device_data",
+ "vehicle_cmds",
+ "vehicle_charging_cmds",
+ "vehicle_location",
+ "energy_device_data",
+ "energy_cmds",
+ ]
+ }
+
+ with (
+ patch("tescmd.auth.token_store.TokenStore", return_value=mock_store),
+ patch("tescmd.auth.oauth.login_flow", new_callable=AsyncMock) as mock_login,
+ ):
+ await _oauth_login_step(
+ formatter, app_ctx, settings, "test-id", "test-secret", force=True
+ )
+
+ mock_login.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_no_force_skips_oauth_when_scopes_present(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Without force, OAuth is skipped when token has all required scopes."""
+ for key in list(os.environ):
+ if key.startswith("TESLA_"):
+ monkeypatch.delenv(key, raising=False)
+
+ app_ctx = _make_app_ctx()
+ formatter = app_ctx.formatter
+
+ from tescmd.models.config import AppSettings
+
+ settings = AppSettings(_env_file=None) # type: ignore[call-arg]
+
+ mock_store = MagicMock()
+ mock_store.has_token = True
+ mock_store.metadata = {
+ "scopes": [
+ "openid",
+ "offline_access",
+ "user_data",
+ "vehicle_device_data",
+ "vehicle_cmds",
+ "vehicle_charging_cmds",
+ "vehicle_location",
+ "energy_device_data",
+ "energy_cmds",
+ ]
+ }
+
+ with (
+ patch("tescmd.auth.token_store.TokenStore", return_value=mock_store),
+ patch("tescmd.auth.oauth.login_flow", new_callable=AsyncMock) as mock_login,
+ ):
+ await _oauth_login_step(
+ formatter, app_ctx, settings, "test-id", "test-secret", force=False
+ )
+
+ mock_login.assert_not_called()
diff --git a/tests/deploy/test_tailscale_serve.py b/tests/deploy/test_tailscale_serve.py
index 1293b72..4f335a6 100644
--- a/tests/deploy/test_tailscale_serve.py
+++ b/tests/deploy/test_tailscale_serve.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import urllib.request
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
@@ -10,6 +11,7 @@
from tescmd.api.errors import TailscaleError
from tescmd.deploy import tailscale_serve as ts_serve
+from tescmd.deploy.tailscale_serve import KeyServer
if TYPE_CHECKING:
from pathlib import Path
@@ -51,7 +53,7 @@ async def test_creates_nested_parents(self, tmp_path: Path) -> None:
class TestStartKeyServing:
- async def test_starts_serve_and_funnel(self, tmp_path: Path) -> None:
+ async def test_starts_serve_with_funnel(self, tmp_path: Path) -> None:
# Create the expected directory structure
well_known = tmp_path / ".well-known" / "appspecific"
well_known.mkdir(parents=True)
@@ -68,15 +70,11 @@ async def test_starts_serve_and_funnel(self, tmp_path: Path) -> None:
patch.object(
ts_serve.TailscaleManager, "start_serve", new_callable=AsyncMock
) as mock_serve,
- patch.object(
- ts_serve.TailscaleManager, "enable_funnel", new_callable=AsyncMock
- ) as mock_funnel,
):
hostname = await ts_serve.start_key_serving(serve_dir=tmp_path)
assert hostname == "mybox.tail99.ts.net"
- mock_serve.assert_awaited_once()
- mock_funnel.assert_awaited_once()
+ mock_serve.assert_awaited_once_with("/", str(tmp_path), funnel=True)
async def test_raises_when_serve_dir_missing(self, tmp_path: Path) -> None:
empty_dir = tmp_path / "empty"
@@ -97,7 +95,7 @@ async def test_stops_serve(self) -> None:
ts_serve.TailscaleManager, "stop_serve", new_callable=AsyncMock
) as mock_stop:
await ts_serve.stop_key_serving()
- mock_stop.assert_awaited_once_with("/.well-known/")
+ mock_stop.assert_awaited_once_with("/")
# ---------------------------------------------------------------------------
@@ -268,3 +266,61 @@ async def test_timeout(self) -> None:
),
):
assert await ts_serve.wait_for_tailscale_deployment("bad.ts.net", timeout=10) is False
+
+
+# ---------------------------------------------------------------------------
+# KeyServer (in-process HTTP server)
+# ---------------------------------------------------------------------------
+
+PEM_CONTENT = "-----BEGIN PUBLIC KEY-----\ntest-key-data\n-----END PUBLIC KEY-----\n"
+
+
+class TestKeyServer:
+ def test_serves_root_200(self) -> None:
+ server = KeyServer(PEM_CONTENT, port=0)
+ server.start()
+ try:
+ port = server.server_address[1]
+ resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/")
+ assert resp.status == 200
+ finally:
+ server.stop()
+
+ def test_serves_wellknown_pem(self) -> None:
+ server = KeyServer(PEM_CONTENT, port=0)
+ server.start()
+ try:
+ port = server.server_address[1]
+ resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/{ts_serve.WELL_KNOWN_PATH}")
+ assert resp.status == 200
+ body = resp.read().decode()
+ assert body == PEM_CONTENT
+ assert resp.headers["Content-Type"] == "application/x-pem-file"
+ finally:
+ server.stop()
+
+ def test_returns_404_for_unknown(self) -> None:
+ server = KeyServer(PEM_CONTENT, port=0)
+ server.start()
+ try:
+ port = server.server_address[1]
+ with pytest.raises(urllib.error.HTTPError) as exc_info:
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/unknown")
+ assert exc_info.value.code == 404
+ finally:
+ server.stop()
+
+ def test_start_stop_lifecycle(self) -> None:
+ server = KeyServer(PEM_CONTENT, port=0)
+ server.start()
+ port = server.server_address[1]
+
+ # Server is responsive
+ resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/")
+ assert resp.status == 200
+
+ server.stop()
+
+ # Server is no longer responsive
+ with pytest.raises(urllib.error.URLError):
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=1)
diff --git a/tests/openclaw/test_dispatcher.py b/tests/openclaw/test_dispatcher.py
index 85ed31b..537ff04 100644
--- a/tests/openclaw/test_dispatcher.py
+++ b/tests/openclaw/test_dispatcher.py
@@ -808,6 +808,30 @@ async def test_system_run_missing_method_raises(self) -> None:
with pytest.raises(ValueError, match="requires 'method'"):
await d.dispatch({"method": "system.run", "params": {}})
+ @pytest.mark.asyncio
+ async def test_system_run_accepts_command_key(self) -> None:
+ """system.run accepts 'command' as alias for 'method' (matches gateway protocol)."""
+ ctx = _mock_app_ctx()
+ store = _store_with(Soc=72.0)
+ d = CommandDispatcher(vin="VIN1", app_ctx=ctx, telemetry_store=store)
+ result = await d.dispatch(
+ {"method": "system.run", "params": {"command": "battery.get", "params": {}}}
+ )
+ assert result is not None
+ assert "battery_level" in result
+
+ @pytest.mark.asyncio
+ async def test_system_run_accepts_list_method(self) -> None:
+ """system.run normalizes a list value like ['battery.get'] to a string."""
+ ctx = _mock_app_ctx()
+ store = _store_with(Soc=72.0)
+ d = CommandDispatcher(vin="VIN1", app_ctx=ctx, telemetry_store=store)
+ result = await d.dispatch(
+ {"method": "system.run", "params": {"method": ["battery.get"], "params": {}}}
+ )
+ assert result is not None
+ assert "battery_level" in result
+
def test_method_aliases_cover_key_commands(self) -> None:
assert "door_lock" in _METHOD_ALIASES
assert "door_unlock" in _METHOD_ALIASES
diff --git a/tests/openclaw/test_gateway.py b/tests/openclaw/test_gateway.py
index 62bb3f2..96b3add 100644
--- a/tests/openclaw/test_gateway.py
+++ b/tests/openclaw/test_gateway.py
@@ -458,6 +458,36 @@ async def _connect() -> None:
assert call_count == 3
+class TestConnectCancelsExistingRecvLoop:
+ @pytest.mark.asyncio
+ async def test_connect_cancels_old_recv_task(self) -> None:
+ """Calling connect() while a receive loop is running cancels the old task."""
+ gw = GatewayClient("ws://test:1234", on_request=AsyncMock())
+
+ # Create a real task that blocks forever (simulates running recv loop)
+ old_task = asyncio.create_task(asyncio.sleep(3600))
+ gw._recv_task = old_task
+
+ with patch.object(gw, "_establish_connection", new_callable=AsyncMock):
+ await gw.connect()
+
+ # Old task should have been cancelled and replaced
+ assert old_task.cancelled()
+ assert gw._recv_task is not old_task
+ assert gw._recv_task is not None
+
+ @pytest.mark.asyncio
+ async def test_connect_skips_cancel_when_no_task(self) -> None:
+ """connect() works fine when no previous recv task exists."""
+ gw = GatewayClient("ws://test:1234", on_request=AsyncMock())
+ assert gw._recv_task is None
+
+ with patch.object(gw, "_establish_connection", new_callable=AsyncMock):
+ await gw.connect()
+
+ assert gw._recv_task is not None
+
+
class _MockWebSocket:
"""Minimal async-iterable WebSocket mock for receive loop tests."""