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:
@@ -21,6 +21,7 @@ _HANDLERS: dict[str, str] = {
|
|||||||
"backend": "backend:cmd_backend",
|
"backend": "backend:cmd_backend",
|
||||||
"cleanup": "cleanup:cmd_cleanup",
|
"cleanup": "cleanup:cmd_cleanup",
|
||||||
"commit": "commit:cmd_commit",
|
"commit": "commit:cmd_commit",
|
||||||
|
"doctor": "doctor:cmd_doctor",
|
||||||
"edit": "edit:cmd_edit",
|
"edit": "edit:cmd_edit",
|
||||||
"help": "help:cmd_help",
|
"help": "help:cmd_help",
|
||||||
"init": "init:cmd_init",
|
"init": "init:cmd_init",
|
||||||
@@ -53,6 +54,6 @@ COMMANDS = {name: _lazy(spec) for name, spec in _HANDLERS.items()}
|
|||||||
# gating it on the schema breaks preflight on a fresh CI runner where stdin
|
# gating it on the schema breaks preflight on a fresh CI runner where stdin
|
||||||
# isn't a TTY and the migration prompt can't be answered. `help` and `login`
|
# isn't a TTY and the migration prompt can't be answered. `help` and `login`
|
||||||
# likewise never touch the store.
|
# likewise never touch the store.
|
||||||
NO_MIGRATION_COMMANDS = frozenset({"backend", "help", "login"})
|
NO_MIGRATION_COMMANDS = frozenset({"backend", "doctor", "help", "login"})
|
||||||
|
|
||||||
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
|
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""`doctor` CLI command — validate host prerequisites for running
|
||||||
|
bot-bottle and report what's ready.
|
||||||
|
|
||||||
|
Fails (non-zero exit) only on the two hard requirements: a new-enough
|
||||||
|
Python and at least one usable backend. The config directory is a soft
|
||||||
|
check — `install.sh` creates it, but a missing one only warrants a note,
|
||||||
|
not a failure, since `start` provisions what it needs on first run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...backend import is_backend_available, known_backend_names
|
||||||
|
from ..constants import PROG
|
||||||
|
|
||||||
|
MIN_PYTHON = (3, 11)
|
||||||
|
CONFIG_DIR = ".bot-bottle"
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(label: str, detail: str) -> None:
|
||||||
|
print(f"ok: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _warn(label: str, detail: str) -> None:
|
||||||
|
print(f"warn: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(label: str, detail: str) -> None:
|
||||||
|
print(f"fail: {label}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_python() -> bool:
|
||||||
|
v = sys.version_info
|
||||||
|
detail = f"{v.major}.{v.minor}.{v.micro}"
|
||||||
|
if (v.major, v.minor) >= MIN_PYTHON:
|
||||||
|
_ok("python", detail)
|
||||||
|
return True
|
||||||
|
_fail("python", f"{detail}; need {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_backends() -> bool:
|
||||||
|
"""At least one backend must be available on this host. Report each
|
||||||
|
known backend so the operator sees why a missing one is missing."""
|
||||||
|
available = []
|
||||||
|
for name in known_backend_names():
|
||||||
|
if is_backend_available(name):
|
||||||
|
available.append(name)
|
||||||
|
if available:
|
||||||
|
_ok("backend", f"available: {', '.join(available)}")
|
||||||
|
return True
|
||||||
|
_fail(
|
||||||
|
"backend",
|
||||||
|
"no backend available; install Apple Container (macOS), "
|
||||||
|
"Firecracker (Linux), or Docker",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_config_dir() -> None:
|
||||||
|
config = Path.home() / CONFIG_DIR
|
||||||
|
if config.is_dir():
|
||||||
|
_ok("config", str(config))
|
||||||
|
else:
|
||||||
|
_warn("config", f"{config} does not exist yet (created on first use)")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_doctor(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog=f"{PROG} doctor",
|
||||||
|
description="Check host prerequisites for running bot-bottle.",
|
||||||
|
)
|
||||||
|
parser.parse_args(argv)
|
||||||
|
|
||||||
|
# Hard requirements gate the exit code; the config note is advisory.
|
||||||
|
required = [_check_python(), _check_backends()]
|
||||||
|
_check_config_dir()
|
||||||
|
return 0 if all(required) else 1
|
||||||
@@ -25,6 +25,7 @@ def cmd_help(argv: list[str] | None = None) -> int:
|
|||||||
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
|
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
|
||||||
w(" cleanup stop and remove all active bot-bottle containers\n")
|
w(" cleanup stop and remove all active bot-bottle containers\n")
|
||||||
w(" commit snapshot a running bottle's container state to a Docker image\n")
|
w(" commit snapshot a running bottle's container state to a Docker image\n")
|
||||||
|
w(" doctor check host prerequisites (Python, backend, config dir)\n")
|
||||||
w(" edit open an agent in vim for editing\n")
|
w(" edit open an agent in vim for editing\n")
|
||||||
w(" help show this command list\n")
|
w(" help show this command list\n")
|
||||||
w(" init interactively create a new agent and add it to bot-bottle.json\n")
|
w(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# PRD prd-new: Quick install script
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-25
|
||||||
|
- **Issue:** #197
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a proper Python package distribution (`pyproject.toml` with a
|
||||||
|
`bot-bottle` entry point) plus a thin `install.sh` bootstrapper, so users
|
||||||
|
can install bot-bottle with a single command instead of cloning the repo
|
||||||
|
and invoking `cli.py` directly. A new `bot-bottle doctor` subcommand
|
||||||
|
verifies host prerequisites after install.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
There is currently no install path for new users. The only way to run
|
||||||
|
bot-bottle is to clone the repo and invoke `./cli.py`. This blocks any
|
||||||
|
public demo: readers want `curl | sh` or `pipx install`, not a manual
|
||||||
|
clone-and-configure flow. There is also no single command that tells a
|
||||||
|
user whether their host is actually ready to run a bottle.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `curl -fsSL <raw-url>/install.sh | sh` leaves a working `bot-bottle`
|
||||||
|
command on PATH.
|
||||||
|
- Python-native users can install with `pipx install bot-bottle` or
|
||||||
|
`uv tool install bot-bottle` (once published) — or from a local
|
||||||
|
checkout today.
|
||||||
|
- `install.sh` validates prerequisites (Python ≥ 3.11), creates the
|
||||||
|
`~/.bot-bottle/` config tree, installs the package, and runs
|
||||||
|
`bot-bottle doctor`. It never installs Docker or a VM backend silently
|
||||||
|
and never uses `sudo`.
|
||||||
|
- `install.sh` is idempotent — safe to re-run.
|
||||||
|
- `bot-bottle doctor` reports Python version, backend availability, and
|
||||||
|
config-dir presence, exiting non-zero when a hard prerequisite is unmet.
|
||||||
|
- The package keeps **zero runtime pip dependencies** (stdlib-only,
|
||||||
|
matching the existing constraint in `AGENTS.md`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Bundling a Python runtime or producing a standalone binary.
|
||||||
|
- Automatic Docker / VM-backend installation.
|
||||||
|
- Plugin-architecture changes (issue #197 floats a containerized-plugin
|
||||||
|
direction; that's a separate feature).
|
||||||
|
- Publishing to a package index in this PR — the package *structure* is
|
||||||
|
the deliverable; publishing is a follow-up step.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Package structure (`pyproject.toml`)
|
||||||
|
|
||||||
|
Fill out the previously-stub `pyproject.toml` with project metadata, a
|
||||||
|
console-script entry point, and package-data for the non-Python assets the
|
||||||
|
runtime reads from inside the package:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "bot-bottle"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bot-bottle = "bot_bottle.cli:main"
|
||||||
|
```
|
||||||
|
|
||||||
|
`bot_bottle.cli:main` already exists (the `cli.py` shim calls it), so no
|
||||||
|
refactor of the entry point is needed. `package-data` ships the container
|
||||||
|
Dockerfiles, `egress_entrypoint.sh`, the firecracker netpool defaults, and
|
||||||
|
the macos-container init script so an installed wheel can still build its
|
||||||
|
sidecar/agent images.
|
||||||
|
|
||||||
|
### `install.sh`
|
||||||
|
|
||||||
|
A POSIX `sh` bootstrapper that:
|
||||||
|
|
||||||
|
1. Checks `python3` is present and ≥ 3.11; exits with a clear message
|
||||||
|
otherwise.
|
||||||
|
2. Creates `~/.bot-bottle/{agents,bottles,contrib}`.
|
||||||
|
3. Installs via `pipx` if available, else `python3 -m pip install --user`.
|
||||||
|
The spec defaults to the git URL and is overridable via
|
||||||
|
`BOT_BOTTLE_INSTALL_SPEC` (used by tests / local installs).
|
||||||
|
4. Locates the `bot-bottle` entry point (PATH or `~/.local/bin`).
|
||||||
|
5. Runs `bot-bottle doctor` and reports the result.
|
||||||
|
|
||||||
|
It is idempotent and never calls `sudo`.
|
||||||
|
|
||||||
|
### `bot-bottle doctor`
|
||||||
|
|
||||||
|
A new store-free subcommand (no DB migration required) that checks and
|
||||||
|
reports:
|
||||||
|
|
||||||
|
- **python** — interpreter version (hard requirement: ≥ 3.11).
|
||||||
|
- **backend** — at least one backend available on this host
|
||||||
|
(macos-container / firecracker / docker), reusing
|
||||||
|
`is_backend_available()` rather than hardcoding Docker, since the
|
||||||
|
default backend is now a VM backend. Hard requirement.
|
||||||
|
- **config** — whether `~/.bot-bottle/` exists (advisory only; `start`
|
||||||
|
provisions on first run).
|
||||||
|
|
||||||
|
Exits 0 when both hard requirements pass, non-zero otherwise.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
- Unit test `bot-bottle doctor` success/failure paths with backend
|
||||||
|
availability and Python version mocked.
|
||||||
|
- Unit test that `pyproject.toml` parses, declares the entry point and an
|
||||||
|
empty `dependencies` list, and that every `package-data` glob resolves
|
||||||
|
to a file that exists on disk (guards against drift).
|
||||||
|
- Unit test that `install.sh` is executable, POSIX-ish (`set -eu`), never
|
||||||
|
calls `sudo`, and runs `doctor` after install.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Should `version` be derived from a git tag at build time (e.g.
|
||||||
|
`hatch-vcs`) or kept static? Static (`0.1.0`) is simpler for now.
|
||||||
|
- Publishing target (PyPI vs. a self-hosted index) is deferred.
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# bot-bottle quick installer.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
|
||||||
|
#
|
||||||
|
# Python-native users can skip this entirely:
|
||||||
|
# pipx install bot-bottle # from a checkout or a published index
|
||||||
|
# uv tool install bot-bottle
|
||||||
|
#
|
||||||
|
# This script is a thin bootstrapper: it checks prerequisites, installs the
|
||||||
|
# package with pipx (falling back to pip --user), creates the config dir, and
|
||||||
|
# runs `bot-bottle doctor`. It is idempotent (safe to re-run) and never uses
|
||||||
|
# sudo. It does NOT install Docker or a VM backend for you — `doctor` reports
|
||||||
|
# what's missing after install.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
|
||||||
|
MIN_PYTHON_MAJOR=3
|
||||||
|
MIN_PYTHON_MINOR=11
|
||||||
|
|
||||||
|
say() {
|
||||||
|
printf 'bot-bottle install: %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
say "error: $*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prerequisites -----------------------------------------------------------
|
||||||
|
|
||||||
|
command -v python3 >/dev/null 2>&1 \
|
||||||
|
|| die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but was not found"
|
||||||
|
|
||||||
|
python3 - "$MIN_PYTHON_MAJOR" "$MIN_PYTHON_MINOR" <<'PY' || die "python3 ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR} or newer is required"
|
||||||
|
import sys
|
||||||
|
|
||||||
|
want = (int(sys.argv[1]), int(sys.argv[2]))
|
||||||
|
raise SystemExit(0 if sys.version_info[:2] >= want else 1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# --- config directories ------------------------------------------------------
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"${HOME}/.bot-bottle/agents" \
|
||||||
|
"${HOME}/.bot-bottle/bottles" \
|
||||||
|
"${HOME}/.bot-bottle/contrib"
|
||||||
|
|
||||||
|
# --- install -----------------------------------------------------------------
|
||||||
|
|
||||||
|
if command -v pipx >/dev/null 2>&1; then
|
||||||
|
say "installing with pipx"
|
||||||
|
pipx install --force "${PACKAGE_SPEC}"
|
||||||
|
else
|
||||||
|
say "pipx not found; installing with 'python3 -m pip install --user'"
|
||||||
|
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- locate the entry point --------------------------------------------------
|
||||||
|
|
||||||
|
if command -v bot-bottle >/dev/null 2>&1; then
|
||||||
|
BOT_BOTTLE_BIN="bot-bottle"
|
||||||
|
elif [ -x "${HOME}/.local/bin/bot-bottle" ]; then
|
||||||
|
BOT_BOTTLE_BIN="${HOME}/.local/bin/bot-bottle"
|
||||||
|
say "note: add ${HOME}/.local/bin to your PATH to run 'bot-bottle' directly"
|
||||||
|
else
|
||||||
|
die "bot-bottle was installed but is not on PATH; add ~/.local/bin to PATH and re-run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- verify ------------------------------------------------------------------
|
||||||
|
|
||||||
|
say "running '${BOT_BOTTLE_BIN} doctor'"
|
||||||
|
if "${BOT_BOTTLE_BIN}" doctor; then
|
||||||
|
say "done. Run '${BOT_BOTTLE_BIN} --help' to get started."
|
||||||
|
else
|
||||||
|
say "install completed, but 'doctor' reported unmet prerequisites (see above)."
|
||||||
|
say "resolve them, then re-run '${BOT_BOTTLE_BIN} doctor'."
|
||||||
|
fi
|
||||||
+38
-1
@@ -4,5 +4,42 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bot-bottle"
|
name = "bot-bottle"
|
||||||
version = "0.0.0"
|
version = "0.1.0"
|
||||||
|
description = "Self-hosted sandbox for running AI coding agents with egress controls"
|
||||||
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
authors = [{ name = "didericis" }]
|
||||||
|
keywords = ["ai", "agents", "sandbox", "security", "egress"]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Operating System :: MacOS",
|
||||||
|
]
|
||||||
|
# The package itself has no runtime pip dependencies (stdlib-only); the
|
||||||
|
# only language runtime is the Python interpreter. Keep this empty.
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://gitea.dideric.is/didericis/bot-bottle"
|
||||||
|
Source = "https://gitea.dideric.is/didericis/bot-bottle"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bot-bottle = "bot_bottle.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["bot_bottle*"]
|
||||||
|
|
||||||
|
# Non-Python assets the runtime reads from inside the package (container
|
||||||
|
# build contexts, entrypoints, netpool defaults). Keep in sync with the
|
||||||
|
# files shipped under bot_bottle/; test_pyproject.py asserts they exist.
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
bot_bottle = [
|
||||||
|
"egress_entrypoint.sh",
|
||||||
|
"contrib/claude/Dockerfile",
|
||||||
|
"contrib/codex/Dockerfile",
|
||||||
|
"contrib/pi/Dockerfile",
|
||||||
|
"backend/firecracker/netpool.defaults.env",
|
||||||
|
"backend/macos_container/nested-containers-init.sh",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user