cli.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. """Bambuddy administrative CLI.
  2. Invoked via ``python -m backend.app.cli <subcommand>``.
  3. Currently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk
  4. API key during install (see ``spoolbuddy/install/install.sh``).
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import asyncio
  9. import sys
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import async_sessionmaker
  12. from backend.app.core.auth import generate_api_key
  13. from backend.app.core.database import async_session as default_session_maker, init_db
  14. from backend.app.core.db_dialect import upsert_setting
  15. from backend.app.models.api_key import APIKey
  16. from backend.app.models.settings import Settings
  17. DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk"
  18. class KioskBootstrapError(RuntimeError):
  19. """Raised when an existing kiosk key would be silently overwritten."""
  20. async def kiosk_bootstrap(
  21. name: str,
  22. *,
  23. force: bool,
  24. session_maker: async_sessionmaker | None = None,
  25. ensure_schema: bool = True,
  26. ) -> str:
  27. """Create (or rotate) an API key for the SpoolBuddy kiosk and return it.
  28. The returned value is the one-time full key string; callers are responsible
  29. for writing it somewhere secure — it cannot be retrieved again.
  30. """
  31. if ensure_schema and session_maker is None:
  32. await init_db()
  33. maker = session_maker or default_session_maker
  34. async with maker() as db:
  35. existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()
  36. if existing and not force:
  37. raise KioskBootstrapError(
  38. f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
  39. )
  40. if existing:
  41. await db.delete(existing)
  42. await db.flush()
  43. full_key, key_hash, key_prefix = generate_api_key()
  44. row = APIKey(
  45. name=name,
  46. key_hash=key_hash,
  47. key_prefix=key_prefix,
  48. can_queue=False,
  49. can_control_printer=False,
  50. can_read_status=True,
  51. printer_ids=None,
  52. enabled=True,
  53. expires_at=None,
  54. )
  55. db.add(row)
  56. # Mark first-run setup as completed so the kiosk URL loads directly
  57. # instead of being force-redirected to /setup by AuthContext. Without
  58. # this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy
  59. # first-run wizard (touch-only Pi has no keyboard to complete it).
  60. # Users who want authentication enable it later from the admin UI; the
  61. # API key we just created is already valid so the kiosk keeps working.
  62. await upsert_setting(db, Settings, "setup_completed", "true")
  63. await db.commit()
  64. return full_key
  65. def main(argv: list[str] | None = None) -> int:
  66. parser = argparse.ArgumentParser(
  67. prog="python -m backend.app.cli",
  68. description="Bambuddy administrative commands",
  69. )
  70. sub = parser.add_subparsers(dest="command", required=True)
  71. kiosk = sub.add_parser(
  72. "kiosk-bootstrap",
  73. help="Create an API key for the SpoolBuddy kiosk",
  74. description=(
  75. "Create (or rotate with --force) an API key scoped for the SpoolBuddy "
  76. "kiosk. The full key is printed to stdout — capture it into "
  77. "spoolbuddy/.env as SPOOLBUDDY_API_KEY."
  78. ),
  79. )
  80. kiosk.add_argument(
  81. "--name",
  82. default=DEFAULT_KIOSK_KEY_NAME,
  83. help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
  84. )
  85. kiosk.add_argument(
  86. "--force",
  87. action="store_true",
  88. help="Rotate an existing key with the same name (deletes the old one)",
  89. )
  90. args = parser.parse_args(argv)
  91. if args.command == "kiosk-bootstrap":
  92. try:
  93. key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
  94. except KioskBootstrapError as exc:
  95. print(str(exc), file=sys.stderr)
  96. return 1
  97. print(key)
  98. return 0
  99. return 2
  100. if __name__ == "__main__":
  101. raise SystemExit(main())