Skip to content

Commit ed8cedb

Browse files
jdrumgooleclaude
andcommitted
First-run onboarding: interactive setup + user-level config
Bump 0.5.0 → 0.6.0. Fixes the cold install path. \`pip install solisdash && solisdash\` now walks the user through MongoDB URI + SolisCloud API URL + Key ID / Key Secret on the terminal, persists everything to \`~/.config/solisdash/.env\` (or \`$XDG_CONFIG_HOME/solisdash/.env\`), and auto-generates a \`SESSION_SECRET\` so they never have to pick random bytes by hand. Re-runs detect the saved config and skip the prompts. - \`solisdash/config.py\`: \`Settings\` now reads two \`.env\` files with the second overriding the first — user config first, project \`.env\` second. New \`user_config_dir()\` / \`user_config_path()\` helpers honour \`XDG_CONFIG_HOME\` then fall back to \`~/.config/solisdash/\`. - \`solisdash/cli.py\`: - \`interactive_setup()\` runs before uvicorn boots. Returns \`"skip"\` (already configured or non-TTY with Mongo set), \`"configured"\` (prompts ran, config written), or \`"aborted"\` (non-TTY with Mongo unset — print a clear message, exit 2). - \`_prompt()\` / \`_read_kv()\` / \`_write_kv()\` are the underlying helpers. \`_write_kv\` is atomic (tmp + replace) and chmods the file to 0600 so secrets don't leak to other users on shared boxes. - \`_apply_env()\` updates \`os.environ\` and clears the \`lru_cache\`d settings so the running process sees the new values without a restart. - SESSION_SECRET is auto-generated even when Mongo is already configured but the secret is missing — no point asking the user for random bytes. - Default Mongo URI is \`mongodb://localhost:27017/\`; default Solis API URL is the EU base. - KeyboardInterrupt during setup exits 130 cleanly. Tests (+17 in \`tests/test_cli_onboarding.py\`): \`_prompt\` defaults + secret mode, \`_read_kv\` / \`_write_kv\` round-trip + merge + permissions, \`_apply_env\` cache invalidation, and four \`interactive_setup\` paths (already-set, no-TTY abort, defaults flow, key-id-then-secret flow, mongo-set-no-secret silent path). Live smoke: - \`cd /tmp && env -i HOME=X solisdash --no-window\` → exit 2 with a one-line message telling the user to set SOLIS_MONGODB_URI. - \`SOLIS_MONGODB_URI=mongodb://localhost:27017/ solisdash --no-window\` → SESSION_SECRET generated to \`~/.config/solisdash/.env\`, server comes up and serves /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 48e1e96 commit ed8cedb

6 files changed

Lines changed: 452 additions & 6 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "solisdash"
3-
version = "0.5.0"
3+
version = "0.6.0"
44
description = "Self-hosted dashboard for the SolisCloud (Ginlong Solis) platform"
55
readme = "README.md"
66
requires-python = ">=3.10"

‎src/solisdash/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.5.0"
1+
__version__ = "0.6.0"

‎src/solisdash/cli.py‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import asyncio
1616
import getpass
1717
import logging
18+
import os
19+
import secrets
1820
import socket
1921
import sys
2022
import threading
@@ -32,6 +34,8 @@
3234
log = logging.getLogger("solisdash.cli")
3335

3436
REPO_URL = "https://github.com/jdrumgoole/solisdash"
37+
DEFAULT_MONGO_URI = "mongodb://localhost:27017/"
38+
DEFAULT_API_URL = "https://www.soliscloud.com:13333"
3539

3640

3741
def find_free_port(host: str = "127.0.0.1") -> int:
@@ -186,6 +190,12 @@ def cmd_run(args: argparse.Namespace) -> int:
186190
level=logging.DEBUG if args.debug else logging.INFO,
187191
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
188192
)
193+
try:
194+
if interactive_setup() == "aborted":
195+
return 2
196+
except KeyboardInterrupt:
197+
print("\nSetup cancelled.", file=sys.stderr)
198+
return 130
189199
port = args.port or find_free_port(args.host)
190200
server = UvicornServerThread(
191201
host=args.host,
@@ -215,6 +225,162 @@ def cmd_run(args: argparse.Namespace) -> int:
215225
return 0
216226

217227

228+
# --- first-run interactive onboarding --------------------------------------
229+
230+
231+
def _is_tty() -> bool:
232+
"""True when both stdin and stdout are connected to a real terminal.
233+
234+
The onboarding prompts only fire when both ends are interactive — under
235+
systemd / ssh-no-tty / CI we bail with a friendly message instead.
236+
"""
237+
return sys.stdin.isatty() and sys.stdout.isatty()
238+
239+
240+
def _prompt(
241+
label: str,
242+
*,
243+
default: str = "",
244+
secret: bool = False,
245+
input_fn: Callable[[str], str] = input,
246+
getpass_fn: Callable[[str], str] = getpass.getpass,
247+
) -> str:
248+
"""One labelled prompt with optional default and secret-input handling.
249+
250+
`input_fn` / `getpass_fn` are injected so tests can stub them.
251+
"""
252+
prompt = f" {label}"
253+
if default and not secret:
254+
prompt += f" [{default}]"
255+
prompt += ": "
256+
raw = getpass_fn(prompt) if secret else input_fn(prompt).strip()
257+
return raw or default
258+
259+
260+
def _read_kv(path: Path) -> dict[str, str]:
261+
"""Parse a simple `.env`-style file. Empty / comment lines are skipped."""
262+
if not path.exists():
263+
return {}
264+
out: dict[str, str] = {}
265+
for line in path.read_text().splitlines():
266+
stripped = line.strip()
267+
if not stripped or stripped.startswith("#") or "=" not in stripped:
268+
continue
269+
key, _, value = stripped.partition("=")
270+
out[key.strip()] = value.strip()
271+
return out
272+
273+
274+
def _write_kv(path: Path, updates: dict[str, str]) -> None:
275+
"""Merge `updates` into `path` and write atomically.
276+
277+
Existing comments / formatting are not preserved — this is the first-run
278+
writer; if the user later hand-edits the file we leave it alone.
279+
"""
280+
path.parent.mkdir(parents=True, exist_ok=True)
281+
merged = _read_kv(path)
282+
merged.update({k: v for k, v in updates.items() if v})
283+
body = "\n".join(f"{k}={v}" for k, v in merged.items())
284+
tmp = path.with_suffix(path.suffix + ".tmp")
285+
tmp.write_text(body + "\n", encoding="utf-8")
286+
tmp.replace(path)
287+
# Stash the secret away from group / other readers.
288+
try:
289+
path.chmod(0o600)
290+
except OSError:
291+
pass # best-effort; Windows / unusual filesystems
292+
293+
294+
def _apply_env(updates: dict[str, str]) -> None:
295+
"""Update `os.environ` and clear the lru_cached settings."""
296+
from solisdash.config import get_settings
297+
298+
for k, v in updates.items():
299+
if v:
300+
os.environ[k] = v
301+
get_settings.cache_clear()
302+
303+
304+
def interactive_setup(
305+
*,
306+
is_tty_fn: Callable[[], bool] = _is_tty,
307+
input_fn: Callable[[str], str] = input,
308+
getpass_fn: Callable[[str], str] = getpass.getpass,
309+
out: Callable[[str], None] = print,
310+
) -> str:
311+
"""Walk the user through first-run config when SOLIS_MONGODB_URI is missing.
312+
313+
Returns one of:
314+
- `"skip"` — nothing was missing, or non-TTY (server can still
315+
come up; the web `/setup` wizard will handle it).
316+
- `"configured"`— prompts ran, values written to user config.
317+
- `"aborted"` — non-TTY *and* missing Mongo. Caller should exit non-zero.
318+
"""
319+
from solisdash.config import get_settings, user_config_path
320+
321+
settings = get_settings()
322+
if settings.SOLIS_MONGODB_URI:
323+
# Already configured — silently fill in a SESSION_SECRET if missing.
324+
if not settings.SESSION_SECRET:
325+
secret_only = {"SESSION_SECRET": secrets.token_urlsafe(32)}
326+
_write_kv(user_config_path(), secret_only)
327+
_apply_env(secret_only)
328+
log.info(
329+
"Generated SESSION_SECRET and saved to %s", user_config_path()
330+
)
331+
return "skip"
332+
333+
if not is_tty_fn():
334+
out(
335+
"Solisdash isn't configured yet and stdin isn't a terminal — "
336+
"can't prompt interactively. Set SOLIS_MONGODB_URI (and "
337+
"ideally SOLIS_KEY_ID / SOLIS_KEYSECRET / SESSION_SECRET) in "
338+
f"{user_config_path()} or via environment variables."
339+
)
340+
return "aborted"
341+
342+
config_path = user_config_path()
343+
out("")
344+
out("Solisdash first-run setup.")
345+
out("Press Enter to accept the default in brackets. Values get saved")
346+
out(f"to {config_path}. Re-run `solisdash` to start with the saved config.")
347+
out("")
348+
349+
updates: dict[str, str] = {}
350+
updates["SOLIS_MONGODB_URI"] = _prompt(
351+
"MongoDB connection URI",
352+
default=DEFAULT_MONGO_URI,
353+
input_fn=input_fn,
354+
getpass_fn=getpass_fn,
355+
)
356+
updates["SOLIS_API_URL"] = _prompt(
357+
"SolisCloud API URL",
358+
default=DEFAULT_API_URL,
359+
input_fn=input_fn,
360+
getpass_fn=getpass_fn,
361+
)
362+
updates["SOLIS_KEY_ID"] = _prompt(
363+
"SolisCloud Key ID (Account → Basic Settings → API Management, "
364+
"blank to skip and configure later)",
365+
input_fn=input_fn,
366+
getpass_fn=getpass_fn,
367+
)
368+
if updates["SOLIS_KEY_ID"]:
369+
updates["SOLIS_KEYSECRET"] = _prompt(
370+
"SolisCloud Key Secret",
371+
secret=True,
372+
input_fn=input_fn,
373+
getpass_fn=getpass_fn,
374+
)
375+
updates["SESSION_SECRET"] = settings.SESSION_SECRET or secrets.token_urlsafe(32)
376+
377+
_write_kv(config_path, updates)
378+
_apply_env(updates)
379+
out("")
380+
out(f"Saved to {config_path}")
381+
return "configured"
382+
383+
218384
# --- subcommand: add-user --------------------------------------------------
219385

220386

‎src/solisdash/config.py‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,44 @@
1-
"""Typed settings loaded from environment / .env."""
1+
"""Typed settings loaded from environment / .env files."""
22

33
from __future__ import annotations
44

5+
import os
56
from functools import lru_cache
7+
from pathlib import Path
68

79
from pydantic import Field
810
from pydantic_settings import BaseSettings, SettingsConfigDict
911

1012

13+
def user_config_dir() -> Path:
14+
"""Per-user config directory.
15+
16+
Honours `XDG_CONFIG_HOME` on every platform — macOS often doesn't, but
17+
one consistent path beats the platform-specific tangle.
18+
"""
19+
base = os.environ.get("XDG_CONFIG_HOME")
20+
return Path(base) / "solisdash" if base else Path.home() / ".config" / "solisdash"
21+
22+
23+
def user_config_path() -> Path:
24+
"""`.env` the `solisdash` CLI writes first-run configuration into."""
25+
return user_config_dir() / ".env"
26+
27+
1128
class Settings(BaseSettings):
12-
"""Process-wide configuration. Read from environment or `.env`."""
29+
"""Process-wide configuration. Read from environment or `.env`.
30+
31+
Two `.env` files are honoured, with the second overriding the first:
32+
33+
1. `~/.config/solisdash/.env` — the CLI writes first-run config here,
34+
so installs from PyPI have a home for it.
35+
2. `./.env` in the current directory — dev-checkout convention.
36+
"""
1337

14-
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
38+
model_config = SettingsConfigDict(
39+
env_file=(str(user_config_path()), ".env"),
40+
extra="ignore",
41+
)
1542

1643
# SolisCloud
1744
SOLIS_KEY_ID: str = ""

0 commit comments

Comments
 (0)