feat: add quick install script and packaging (#197)

Give bot-bottle a real distribution path so new users can install
without cloning the repo:

- pyproject.toml: full project metadata, a `bot-bottle` console-script
  entry point (bot_bottle.cli:main), and package-data for the runtime
  assets (Dockerfiles, egress entrypoint, netpool defaults, macos init).
  Still zero runtime pip dependencies.
- install.sh: POSIX, sudo-free, idempotent bootstrapper — checks Python
  >= 3.11, creates ~/.bot-bottle/{agents,bottles,contrib}, installs via
  pipx (pip --user fallback), then runs `bot-bottle doctor`.
- `bot-bottle doctor`: new store-free subcommand reporting Python
  version, backend availability (reuses is_backend_available rather than
  hardcoding Docker), and config-dir presence. Exits non-zero when a hard
  prerequisite is unmet.
- PRD prd-new-install-script and unit tests for doctor, the packaging
  contract, and the install script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:16:50 +00:00
committed by didericis
parent 2644759b0d
commit 955cb3bcbd
9 changed files with 500 additions and 2 deletions
+77
View File
@@ -0,0 +1,77 @@
"""Unit: `bot-bottle doctor` host prerequisite checks (ADR 0004).
`doctor` is a store-free diagnostic — it must run on a fresh install
before any DB migration, and its exit code gates only the two hard
prerequisites (Python and an available backend). The config-dir check is
advisory and never affects the exit code.
"""
from __future__ import annotations
import io
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from bot_bottle.cli.commands import doctor
def _run(argv: list[str] | None = None) -> tuple[int, str]:
buf = io.StringIO()
with redirect_stdout(buf):
code = doctor.cmd_doctor(argv or [])
return code, buf.getvalue()
class TestDoctor(unittest.TestCase):
def test_passes_when_python_and_backend_ok(self):
with patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
code, out = _run()
self.assertEqual(0, code)
self.assertIn("ok: python", out)
self.assertIn("ok: backend: available: docker", out)
def test_fails_when_no_backend_available(self):
with patch.object(doctor, "known_backend_names", return_value=("docker", "firecracker")), \
patch.object(doctor, "is_backend_available", return_value=False):
code, out = _run()
self.assertEqual(1, code)
self.assertIn("fail: backend", out)
def test_fails_when_python_too_old(self):
# Force the version gate to fail without touching the interpreter.
with patch.object(doctor, "MIN_PYTHON", (99, 0)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
code, out = _run()
self.assertEqual(1, code)
self.assertIn("fail: python", out)
def test_missing_config_dir_is_advisory_not_fatal(self):
# A missing ~/.bot-bottle warns but must not fail. Point home at a
# fresh empty dir so the shared suite HOME (which other tests may
# populate) can't turn this into an "ok: config".
with tempfile.TemporaryDirectory() as tmp, \
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
code, out = _run()
self.assertEqual(0, code)
self.assertIn("warn: config", out)
def test_present_config_dir_reports_ok(self):
with tempfile.TemporaryDirectory() as tmp, \
patch.object(doctor.Path, "home", return_value=Path(tmp)), \
patch.object(doctor, "known_backend_names", return_value=("docker",)), \
patch.object(doctor, "is_backend_available", return_value=True):
(Path(tmp) / ".bot-bottle").mkdir()
code, out = _run()
self.assertEqual(0, code)
self.assertIn("ok: config", out)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
"""Unit: install.sh bootstrapper contract.
The installer is a thin, sudo-free, idempotent bootstrapper. These are
static checks on the script text (no network / no real install) so CI can
run them anywhere: it must be executable, fail-fast, never call sudo,
create the config tree, install the package, and verify with `doctor`.
"""
from __future__ import annotations
import os
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_SH = REPO_ROOT / "install.sh"
class TestInstallScript(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.text = INSTALL_SH.read_text()
def test_exists_and_executable(self):
self.assertTrue(INSTALL_SH.is_file())
self.assertTrue(os.access(INSTALL_SH, os.X_OK), "install.sh must be executable")
def test_posix_shebang_and_failfast(self):
first = self.text.splitlines()[0]
self.assertEqual("#!/bin/sh", first)
self.assertIn("set -eu", self.text)
def test_never_uses_sudo(self):
# Only executable lines matter; the header comment may mention sudo.
code = [
ln for ln in self.text.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
]
self.assertNotIn("sudo", "\n".join(code))
def test_creates_config_tree(self):
self.assertIn(".bot-bottle/agents", self.text)
self.assertIn(".bot-bottle/bottles", self.text)
def test_installs_via_pipx_with_pip_fallback(self):
self.assertIn("pipx install", self.text)
self.assertIn("pip install --user", self.text)
def test_runs_doctor_after_install(self):
self.assertIn("doctor", self.text)
def test_install_spec_is_overridable(self):
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
"""Unit: pyproject.toml packaging contract.
Guards the install/distribution surface: the console-script entry point,
the stdlib-only (empty) dependency list, and that every package-data glob
still points at a file that exists (so an installed wheel isn't missing a
Dockerfile or entrypoint the runtime reads).
"""
from __future__ import annotations
import tomllib
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = REPO_ROOT / "pyproject.toml"
class TestPyproject(unittest.TestCase):
@classmethod
def setUpClass(cls):
with PYPROJECT.open("rb") as fh:
cls.data = tomllib.load(fh)
def test_entry_point_targets_cli_main(self):
scripts = self.data["project"]["scripts"]
self.assertEqual("bot_bottle.cli:main", scripts["bot-bottle"])
def test_no_runtime_dependencies(self):
# AGENTS.md: the package has no runtime pip dependencies.
self.assertEqual([], self.data["project"]["dependencies"])
def test_requires_python_311(self):
self.assertEqual(">=3.11", self.data["project"]["requires-python"])
def test_package_data_files_exist(self):
pkg_data = self.data["tool"]["setuptools"]["package-data"]["bot_bottle"]
self.assertTrue(pkg_data, "expected package-data entries")
for rel in pkg_data:
path = REPO_ROOT / "bot_bottle" / rel
self.assertTrue(path.is_file(), f"package-data missing: {path}")
if __name__ == "__main__":
unittest.main()