cli.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  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. can_manage_library=False,
  52. # SpoolBuddy kiosk writes NFC scans / scale readings / system
  53. # commands via the /spoolbuddy/* routes — all gated by
  54. # can_manage_inventory now, so the bundled key must opt in.
  55. can_manage_inventory=True,
  56. printer_ids=None,
  57. enabled=True,
  58. expires_at=None,
  59. )
  60. db.add(row)
  61. # Mark first-run setup as completed so the kiosk URL loads directly
  62. # instead of being force-redirected to /setup by AuthContext. Without
  63. # this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy
  64. # first-run wizard (touch-only Pi has no keyboard to complete it).
  65. # Users who want authentication enable it later from the admin UI; the
  66. # API key we just created is already valid so the kiosk keeps working.
  67. await upsert_setting(db, Settings, "setup_completed", "true")
  68. await db.commit()
  69. return full_key
  70. def main(argv: list[str] | None = None) -> int:
  71. parser = argparse.ArgumentParser(
  72. prog="python -m backend.app.cli",
  73. description="Bambuddy administrative commands",
  74. )
  75. sub = parser.add_subparsers(dest="command", required=True)
  76. kiosk = sub.add_parser(
  77. "kiosk-bootstrap",
  78. help="Create an API key for the SpoolBuddy kiosk",
  79. description=(
  80. "Create (or rotate with --force) an API key scoped for the SpoolBuddy "
  81. "kiosk. The full key is printed to stdout — capture it into "
  82. "spoolbuddy/.env as SPOOLBUDDY_API_KEY."
  83. ),
  84. )
  85. kiosk.add_argument(
  86. "--name",
  87. default=DEFAULT_KIOSK_KEY_NAME,
  88. help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
  89. )
  90. kiosk.add_argument(
  91. "--force",
  92. action="store_true",
  93. help="Rotate an existing key with the same name (deletes the old one)",
  94. )
  95. args = parser.parse_args(argv)
  96. if args.command == "kiosk-bootstrap":
  97. try:
  98. key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
  99. except KioskBootstrapError as exc:
  100. print(str(exc), file=sys.stderr)
  101. return 1
  102. print(key)
  103. return 0
  104. return 2
  105. if __name__ == "__main__":
  106. raise SystemExit(main())