|
1 | | -"""`solisdash` CLI: run the FastAPI app inside a native pywebview window. |
| 1 | +"""`solisdash` CLI: run the dashboard, or manage users. |
2 | 2 |
|
3 | | -The CLI is for "desktop app" runs on the developer's machine. For a |
4 | | -real deployment use `uv run python -m invoke start`; for tests use |
5 | | -`pytest`. This entry point keeps the same FastAPI app, just hides |
6 | | -the browser shell behind a Cocoa/GTK/EdgeWebView2 window with a |
7 | | -native menu and the sun icon. |
| 3 | +Subcommands: |
| 4 | +- (no subcommand) / `run` — open the dashboard inside a pywebview window |
| 5 | +- `add-user` — seed an account (the only path before login works) |
| 6 | +
|
| 7 | +Both end up calling the same `solisdash.app` and `solisdash.auth` code that |
| 8 | +`uv run python -m invoke …` uses; they're just here so anyone who installed |
| 9 | +the package from PyPI doesn't need a dev checkout to bootstrap. |
8 | 10 | """ |
9 | 11 |
|
10 | 12 | from __future__ import annotations |
11 | 13 |
|
12 | 14 | import argparse |
| 15 | +import asyncio |
| 16 | +import getpass |
13 | 17 | import logging |
14 | 18 | import socket |
15 | 19 | import sys |
16 | 20 | import threading |
17 | 21 | import time |
18 | 22 | import webbrowser |
| 23 | +from collections.abc import Callable |
19 | 24 | from importlib.resources import files |
20 | 25 | from pathlib import Path |
21 | 26 |
|
@@ -173,48 +178,10 @@ def launch_webview(base_url: str, *, debug: bool = False) -> None: |
173 | 178 | webview.start(**start_kwargs) # type: ignore[arg-type] |
174 | 179 |
|
175 | 180 |
|
176 | | -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: |
177 | | - parser = argparse.ArgumentParser( |
178 | | - prog="solisdash", |
179 | | - description="Run Solisdash inside a native window (pywebview).", |
180 | | - ) |
181 | | - parser.add_argument( |
182 | | - "--host", |
183 | | - default="127.0.0.1", |
184 | | - help="Address uvicorn binds to. Default: 127.0.0.1 (loopback only).", |
185 | | - ) |
186 | | - parser.add_argument( |
187 | | - "--port", |
188 | | - type=int, |
189 | | - default=0, |
190 | | - help="Port to bind. 0 (default) picks a free one.", |
191 | | - ) |
192 | | - parser.add_argument( |
193 | | - "--no-window", |
194 | | - action="store_true", |
195 | | - help="Run uvicorn without opening pywebview (useful for ssh / headless smoke).", |
196 | | - ) |
197 | | - parser.add_argument( |
198 | | - "--debug", |
199 | | - action="store_true", |
200 | | - help="Open pywebview's dev tools and use uvicorn debug logging.", |
201 | | - ) |
202 | | - parser.add_argument( |
203 | | - "--version", |
204 | | - action="version", |
205 | | - version=f"solisdash {__version__}", |
206 | | - ) |
207 | | - parser.add_argument( |
208 | | - "--timeout", |
209 | | - type=float, |
210 | | - default=15.0, |
211 | | - help="Seconds to wait for the app to become healthy before giving up.", |
212 | | - ) |
213 | | - return parser.parse_args(argv) |
| 181 | +# --- subcommand: run ------------------------------------------------------- |
214 | 182 |
|
215 | 183 |
|
216 | | -def main(argv: list[str] | None = None) -> int: |
217 | | - args = parse_args(argv) |
| 184 | +def cmd_run(args: argparse.Namespace) -> int: |
218 | 185 | logging.basicConfig( |
219 | 186 | level=logging.DEBUG if args.debug else logging.INFO, |
220 | 187 | format="%(asctime)s %(levelname)s %(name)s: %(message)s", |
@@ -248,5 +215,161 @@ def main(argv: list[str] | None = None) -> int: |
248 | 215 | return 0 |
249 | 216 |
|
250 | 217 |
|
| 218 | +# --- subcommand: add-user -------------------------------------------------- |
| 219 | + |
| 220 | + |
| 221 | +def _prompt_password(getpass_fn: Callable[[str], str] = getpass.getpass) -> str | None: |
| 222 | + """Prompt for a password twice; return the value or `None` on mismatch/empty. |
| 223 | +
|
| 224 | + Indirection on `getpass.getpass` is so tests can inject a stub. |
| 225 | + """ |
| 226 | + pw1 = getpass_fn("Password: ") |
| 227 | + pw2 = getpass_fn("Confirm: ") |
| 228 | + if pw1 != pw2: |
| 229 | + print("passwords do not match", file=sys.stderr) |
| 230 | + return None |
| 231 | + if not pw1: |
| 232 | + print("password must not be empty", file=sys.stderr) |
| 233 | + return None |
| 234 | + return pw1 |
| 235 | + |
| 236 | + |
| 237 | +def cmd_add_user(args: argparse.Namespace) -> int: |
| 238 | + """Create a user in the configured Mongo. Prompts for the password.""" |
| 239 | + from pymongo import AsyncMongoClient |
| 240 | + from pymongo.errors import DuplicateKeyError |
| 241 | + |
| 242 | + from solisdash.auth import ROLES, create_user |
| 243 | + from solisdash.config import get_settings |
| 244 | + from solisdash.db import ensure_indexes |
| 245 | + |
| 246 | + if args.role not in ROLES: |
| 247 | + print(f"role must be one of {ROLES}, got {args.role!r}", file=sys.stderr) |
| 248 | + return 2 |
| 249 | + |
| 250 | + settings = get_settings() |
| 251 | + if not settings.SOLIS_MONGODB_URI: |
| 252 | + print("SOLIS_MONGODB_URI is not set", file=sys.stderr) |
| 253 | + return 2 |
| 254 | + |
| 255 | + password = _prompt_password() |
| 256 | + if password is None: |
| 257 | + return 2 |
| 258 | + |
| 259 | + async def _go() -> int: |
| 260 | + client: AsyncMongoClient[dict[str, object]] = AsyncMongoClient( |
| 261 | + settings.SOLIS_MONGODB_URI |
| 262 | + ) |
| 263 | + try: |
| 264 | + db = client[settings.SOLIS_MONGODB_DB] |
| 265 | + await ensure_indexes(db) |
| 266 | + try: |
| 267 | + await create_user( |
| 268 | + db, username=args.username, password=password, role=args.role |
| 269 | + ) |
| 270 | + except DuplicateKeyError: |
| 271 | + print(f"user {args.username!r} already exists", file=sys.stderr) |
| 272 | + return 1 |
| 273 | + print(f"created {args.role} {args.username!r}") |
| 274 | + return 0 |
| 275 | + finally: |
| 276 | + await client.close() |
| 277 | + |
| 278 | + return asyncio.run(_go()) |
| 279 | + |
| 280 | + |
| 281 | +# --- argparse plumbing ----------------------------------------------------- |
| 282 | + |
| 283 | + |
| 284 | +def _add_run_args(p: argparse.ArgumentParser) -> None: |
| 285 | + p.add_argument( |
| 286 | + "--host", |
| 287 | + default="127.0.0.1", |
| 288 | + help="Address uvicorn binds to. Default: 127.0.0.1 (loopback only).", |
| 289 | + ) |
| 290 | + p.add_argument( |
| 291 | + "--port", |
| 292 | + type=int, |
| 293 | + default=0, |
| 294 | + help="Port to bind. 0 (default) picks a free one.", |
| 295 | + ) |
| 296 | + p.add_argument( |
| 297 | + "--no-window", |
| 298 | + action="store_true", |
| 299 | + help="Run uvicorn without opening pywebview (useful for ssh / headless smoke).", |
| 300 | + ) |
| 301 | + p.add_argument( |
| 302 | + "--debug", |
| 303 | + action="store_true", |
| 304 | + help="Open pywebview's dev tools and use uvicorn debug logging.", |
| 305 | + ) |
| 306 | + p.add_argument( |
| 307 | + "--timeout", |
| 308 | + type=float, |
| 309 | + default=15.0, |
| 310 | + help="Seconds to wait for the app to become healthy before giving up.", |
| 311 | + ) |
| 312 | + |
| 313 | + |
| 314 | +def _make_parser() -> argparse.ArgumentParser: |
| 315 | + parser = argparse.ArgumentParser( |
| 316 | + prog="solisdash", |
| 317 | + description=( |
| 318 | + "Solisdash CLI. With no subcommand, runs the dashboard in a " |
| 319 | + "native pywebview window." |
| 320 | + ), |
| 321 | + ) |
| 322 | + parser.add_argument( |
| 323 | + "--version", |
| 324 | + action="version", |
| 325 | + version=f"solisdash {__version__}", |
| 326 | + ) |
| 327 | + # The run-mode flags are also accepted at the top level so |
| 328 | + # `solisdash --port 9000` keeps working without an explicit subcommand. |
| 329 | + _add_run_args(parser) |
| 330 | + |
| 331 | + subparsers = parser.add_subparsers(dest="command", metavar="COMMAND") |
| 332 | + |
| 333 | + run_p = subparsers.add_parser( |
| 334 | + "run", |
| 335 | + help="Run the dashboard in a pywebview window (default).", |
| 336 | + description="Run the dashboard. Equivalent to `solisdash` with no subcommand.", |
| 337 | + ) |
| 338 | + _add_run_args(run_p) |
| 339 | + |
| 340 | + add_p = subparsers.add_parser( |
| 341 | + "add-user", |
| 342 | + help="Create a Solisdash account (prompts for password).", |
| 343 | + description=( |
| 344 | + "Create a Solisdash account in the configured MongoDB. Reads " |
| 345 | + "SOLIS_MONGODB_URI from environment / .env. Prompts for the " |
| 346 | + "password — it's never accepted on the command line." |
| 347 | + ), |
| 348 | + ) |
| 349 | + add_p.add_argument( |
| 350 | + "--username", required=True, help="Username for the new account." |
| 351 | + ) |
| 352 | + add_p.add_argument( |
| 353 | + "--role", |
| 354 | + default="user", |
| 355 | + choices=("admin", "user"), |
| 356 | + help="Account role (default: user).", |
| 357 | + ) |
| 358 | + |
| 359 | + return parser |
| 360 | + |
| 361 | + |
| 362 | +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: |
| 363 | + return _make_parser().parse_args(argv) |
| 364 | + |
| 365 | + |
| 366 | +def main(argv: list[str] | None = None) -> int: |
| 367 | + args = parse_args(argv) |
| 368 | + if args.command == "add-user": |
| 369 | + return cmd_add_user(args) |
| 370 | + # Both `solisdash` and `solisdash run` end up here. |
| 371 | + return cmd_run(args) |
| 372 | + |
| 373 | + |
251 | 374 | if __name__ == "__main__": |
252 | 375 | sys.exit(main()) |
0 commit comments