cli.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  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.models.api_key import APIKey
  15. DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk"
  16. class KioskBootstrapError(RuntimeError):
  17. """Raised when an existing kiosk key would be silently overwritten."""
  18. async def kiosk_bootstrap(
  19. name: str,
  20. *,
  21. force: bool,
  22. session_maker: async_sessionmaker | None = None,
  23. ensure_schema: bool = True,
  24. ) -> str:
  25. """Create (or rotate) an API key for the SpoolBuddy kiosk and return it.
  26. The returned value is the one-time full key string; callers are responsible
  27. for writing it somewhere secure — it cannot be retrieved again.
  28. """
  29. if ensure_schema and session_maker is None:
  30. await init_db()
  31. maker = session_maker or default_session_maker
  32. async with maker() as db:
  33. existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()
  34. if existing and not force:
  35. raise KioskBootstrapError(
  36. f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
  37. )
  38. if existing:
  39. await db.delete(existing)
  40. await db.flush()
  41. full_key, key_hash, key_prefix = generate_api_key()
  42. row = APIKey(
  43. name=name,
  44. key_hash=key_hash,
  45. key_prefix=key_prefix,
  46. can_queue=False,
  47. can_control_printer=False,
  48. can_read_status=True,
  49. printer_ids=None,
  50. enabled=True,
  51. expires_at=None,
  52. )
  53. db.add(row)
  54. await db.commit()
  55. return full_key
  56. def main(argv: list[str] | None = None) -> int:
  57. parser = argparse.ArgumentParser(
  58. prog="python -m backend.app.cli",
  59. description="Bambuddy administrative commands",
  60. )
  61. sub = parser.add_subparsers(dest="command", required=True)
  62. kiosk = sub.add_parser(
  63. "kiosk-bootstrap",
  64. help="Create an API key for the SpoolBuddy kiosk",
  65. description=(
  66. "Create (or rotate with --force) an API key scoped for the SpoolBuddy "
  67. "kiosk. The full key is printed to stdout — capture it into "
  68. "spoolbuddy/.env as SPOOLBUDDY_API_KEY."
  69. ),
  70. )
  71. kiosk.add_argument(
  72. "--name",
  73. default=DEFAULT_KIOSK_KEY_NAME,
  74. help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
  75. )
  76. kiosk.add_argument(
  77. "--force",
  78. action="store_true",
  79. help="Rotate an existing key with the same name (deletes the old one)",
  80. )
  81. args = parser.parse_args(argv)
  82. if args.command == "kiosk-bootstrap":
  83. try:
  84. key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
  85. except KioskBootstrapError as exc:
  86. print(str(exc), file=sys.stderr)
  87. return 1
  88. print(key)
  89. return 0
  90. return 2
  91. if __name__ == "__main__":
  92. raise SystemExit(main())