From 7384fbfffcadebf7d3e606b54cec8ab24f5e5977 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:17:36 +0800 Subject: [PATCH] fix(packaging): keep source clients in lockstep --- CONTRIBUTING.md | 13 +++++++++++ README.md | 37 +++++++++++++++++------------- docs/cli-reference.md | 2 +- docs/quick-start.md | 34 +++++++++++++++++++--------- nanobot/cli/tui_launcher.py | 24 +++++++++++++++----- nanobot/cli/webui.py | 8 +++---- nanobot/cli/webui_support.py | 6 +++++ scripts/install.ps1 | 9 ++++---- scripts/install.sh | 9 ++++---- tests/cli/test_tui_launcher.py | 40 +++++++++++++++++++++++++++++++++ tests/cli/test_webui_support.py | 38 +++++++++++++++++++++++++++++++ 11 files changed, 175 insertions(+), 45 deletions(-) create mode 100644 tests/cli/test_webui_support.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c7183418..d41dd8f18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,6 +136,19 @@ GitHub Actions' free tier: If your change genuinely needs to step outside this, please call it out explicitly in the PR description so it can be discussed before merge. +## Release Packaging Contract + +A stable install must never combine Python from one version with a TUI from another. Publish in +this order: + +1. Set the package version and publish the matching GitHub release tag (`vX.Y.Z`). +2. Wait for `tui-release.yml` to upload every supported native TUI and checksum to that release. +3. Only then publish the same `X.Y.Z` package to PyPI. + +The wheel contains the built WebUI. The native TUI stays a platform-specific release sidecar so +users download only the binary for their machine. Source checkouts use an editable Python install, +run `tui/` with Bun, and rebuild stale `webui/` assets locally. + ## Questions? If you have questions, ideas, or half-formed insights, you are warmly welcome here. diff --git a/README.md b/README.md index 1745b9a9b..a3637eb0b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,12 @@ nanobot is a self-hosted personal AI agent runtime. It can: Pick **one** install method: -Prerequisites: Python 3.11 or newer. Git is only needed for a source install. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it. +| Track | Install with | Update with | What runs | +|---|---|---|---| +| Stable | installer, `uv`, or pip | the same package tool | one released Python/WebUI/TUI version | +| Current source | editable Git checkout | `git pull --ff-only` + editable dependency sync | Python, WebUI, and TUI from that checkout | + +Prerequisites: Python 3.11 or newer. Git and [Bun](https://bun.sh/) are only needed for a source install. Published packages include the WebUI and fetch a checksummed, version-matched TUI on first use. If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path. @@ -97,7 +102,7 @@ irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | i The default command installs or upgrades `nanobot-ai` from PyPI. On a fresh local desktop, it then starts `nanobot webui` so you can configure the first provider and model in **Settings → Models**. SSH, headless, existing-config, and older-release paths keep the terminal setup wizard. The installer avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. It also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`. -To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install. +To preview the plan without changing your environment, pass `--dry-run`. ```bash curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run @@ -107,16 +112,6 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install. & ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run ``` -To install the current `main` branch instead, pass `--dev`: - -```bash -curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev -``` - -```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev -``` - If you prefer to inspect the script first, open [`scripts/install.sh`](./scripts/install.sh) or [`scripts/install.ps1`](./scripts/install.ps1). **Install with `uv`** @@ -135,15 +130,27 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c **Install from source** -`bun` or `npm` must be available. From an activated virtual environment: +Clone the repository and install it in editable mode. Bun is required because the source +checkout runs the matching TUI directly instead of downloading an older release binary. ```bash git clone https://github.com/HKUDS/nanobot.git cd nanobot -python -m pip install . +python -m venv .venv ``` -On Windows, if pip reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install. Contributors who need an editable checkout should follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`webui/README.md`](./webui/README.md). +Activate it with `source .venv/bin/activate` on macOS/Linux or +`.venv\Scripts\Activate.ps1` in Windows PowerShell, then run: + +```bash +python -m pip install -e . +``` + +After that, the normal commands are identical to a stable install. `nanobot agent` runs the TUI +from this checkout, and `nanobot webui` rebuilds stale frontend assets automatically. A later +`git pull --ff-only` updates the Python, TUI, and WebUI source together; rerun +`python -m pip install -e .` when Python dependencies change. Contributors should also read +[`CONTRIBUTING.md`](./CONTRIBUTING.md). Verify the install: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 229ff643d..2b652e43b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -127,7 +127,7 @@ The default `--theme auto` mode probes the terminal's real foreground and backgr `Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message to the composer. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback when a terminal cannot distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable. Use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen. -Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`. +Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. An editable source checkout requires Bun and always runs its own `tui/` source; it never silently falls back to a release binary. Non-interactive input/output, `--logs`, and `--no-markdown` automatically retain the classic prompt so existing scripts and diagnostic workflows do not acquire terminal control sequences or silently ignore their options. diff --git a/docs/quick-start.md b/docs/quick-start.md index e6a28d1ce..6a2e19967 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -12,7 +12,7 @@ These repository docs follow current `main`. The recommended installer uses the - Access to one supported AI provider, company endpoint, or local model server. - The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key. -Git is only needed for a source install. The published package already contains the WebUI. A current-source install needs `bun` or `npm` so its WebUI bundle can be built. +Git and [Bun](https://bun.sh/) are only needed for a source install. The published package already contains the WebUI and fetches a checksummed, version-matched TUI on first use. ## 1. Install nanobot @@ -30,7 +30,7 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install. irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex ``` -The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release unless you explicitly pass `--dev`. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below. +The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below. If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1). @@ -155,18 +155,28 @@ If pip reports `externally-managed-environment`, use the recommended installer, **Current source** -`bun` or `npm` must be available. Activate a virtual environment first, then run: +Clone the repository and install it in editable mode. Bun is required so the checkout can run +its matching native TUI instead of mixing current Python with an older release binary. ```bash git clone https://github.com/HKUDS/nanobot.git cd nanobot -python -m pip install . +python -m venv .venv +``` + +Activate it with `source .venv/bin/activate` on macOS/Linux or +`.venv\Scripts\Activate.ps1` in Windows PowerShell, then run: + +```bash +python -m pip install -e . nanobot webui ``` -On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install. - -The source path follows current `main` and can be newer than the published package. A non-editable install triggers the build hook that bundles the current WebUI. For editable Python or frontend development, follow [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../webui/README.md`](../webui/README.md). +The source path follows current `main` and can be newer than the published package. The editable +install keeps Python pointed at the checkout; `nanobot agent` runs `tui/` with Bun, and +`nanobot webui` automatically rebuilds `webui/` when its bundled assets are stale. All normal +commands remain the same as a stable install. For development details, follow +[`../CONTRIBUTING.md`](../CONTRIBUTING.md). If the package is installed but the shell cannot find `nanobot`, use the runner that owns the installation. The recommended installer prints the exact command to reuse. Common forms are: @@ -225,11 +235,15 @@ python -m pip install -U nanobot-ai For a source checkout: ```bash -git pull -python -m pip install . +git pull --ff-only +python -m pip install -e . ``` -Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings. +Because the install is editable, normal source changes are visible immediately. Re-running the +install synchronizes any changed Python dependencies; the TUI and WebUI refresh their own +dependencies/assets when launched. Then check `nanobot --version`. Run +`nanobot onboard --refresh` when you want to add newly introduced default fields while preserving +existing settings. ## If the First Reply Fails diff --git a/nanobot/cli/tui_launcher.py b/nanobot/cli/tui_launcher.py index 8ab03e47c..697aa943c 100644 --- a/nanobot/cli/tui_launcher.py +++ b/nanobot/cli/tui_launcher.py @@ -120,15 +120,20 @@ def _resolve_tui_command() -> list[str]: "on that platform; use the classic prompt until the upstream runtime supports it" ) asset = f"nanobot-tui-{system}-{machine}{suffix}" + source_dir = _source_checkout_tui_dir() + if source_dir is not None: + bun = shutil.which("bun") + if not bun: + raise TuiUnavailableError( + "this source checkout requires Bun to run its matching TUI; " + "install Bun, then run `nanobot agent` again" + ) + return _resolve_source_tui_command(source_dir, bun) + packaged = Path(__file__).resolve().parents[1] / "tui" / "bin" / asset if packaged.is_file(): return [str(packaged)] - source_dir = Path(__file__).resolve().parents[2] / "tui" - bun = shutil.which("bun") - if bun and (source_dir / "package.json").is_file(): - return _resolve_source_tui_command(source_dir, bun) - downloaded = _download_release_tui(asset) if downloaded is not None: return [str(downloaded)] @@ -139,6 +144,15 @@ def _resolve_tui_command() -> list[str]: ) +def _source_checkout_tui_dir() -> Path | None: + """Return this checkout's TUI source, never a neighboring unrelated directory.""" + project_root = Path(__file__).resolve().parents[2] + source_dir = project_root / "tui" + if (project_root / "pyproject.toml").is_file() and (source_dir / "package.json").is_file(): + return source_dir + return None + + def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]: dependency = source_dir / "node_modules" / "@opentui" / "core" try: diff --git a/nanobot/cli/webui.py b/nanobot/cli/webui.py index 4aa1b0ed0..bf4cd95ab 100644 --- a/nanobot/cli/webui.py +++ b/nanobot/cli/webui.py @@ -218,6 +218,10 @@ def webui( ) webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes) + _prepare_webui_bundle_for_gateway( + runtime_config, + mode="skip" if dev else webui_bundle_mode, + ) config_arg = str(config_path) workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None @@ -236,10 +240,6 @@ def webui( def ensure_shared_gateway(*, client_lease: GatewayClientLease) -> None: """Start or refresh the one managed gateway shared by local clients.""" - _prepare_webui_bundle_for_gateway( - runtime_config, - mode="skip" if dev else webui_bundle_mode, - ) result = client_lease.ensure_on_demand_gateway(start_options) restarted = False restart_attempted = False diff --git a/nanobot/cli/webui_support.py b/nanobot/cli/webui_support.py index 9edf8b9b2..961a229cb 100644 --- a/nanobot/cli/webui_support.py +++ b/nanobot/cli/webui_support.py @@ -24,6 +24,7 @@ from nanobot.webui.build import ( BuildMode, WebUIBuildError, ensure_webui_bundle, + inspect_webui_bundle, ) if TYPE_CHECKING: @@ -191,6 +192,11 @@ def _prepare_webui_bundle_for_gateway( return typer.confirm(message, default=True) try: + # A source checkout is the development product. Every gateway entrypoint + # keeps its browser client in lockstep with Python; only Vite mode skips + # the production bundle intentionally. + if mode != "skip" and inspect_webui_bundle().source_available: + mode = "auto" ensure_webui_bundle( mode=mode, confirm=_confirm if mode == "prompt" else None, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index bbbca7104..68b98f7e2 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -8,7 +8,6 @@ param( $ErrorActionPreference = "Stop" $Package = "nanobot-ai" -$MainSource = "https://github.com/HKUDS/nanobot/archive/refs/heads/main.zip" $InstallTarget = $Package $InstallSource = "PyPI" $script:NanobotRunner = $null @@ -38,11 +37,12 @@ function Show-InstallFailureHint { } function Show-Usage { - Write-Host "Usage: install.ps1 [-Dev|--dev] [-DryRun|--dry-run]" + Write-Host "Usage: install.ps1 [-DryRun|--dry-run]" Write-Host "" Write-Host "By default this installs or upgrades nanobot-ai from PyPI." - Write-Host "Use --dev to install from the current main branch on GitHub." Write-Host "Use --dry-run to print what would happen without installing or starting setup." + Write-Host "" + Write-Host "For current main, clone the repository and run 'python -m pip install -e .'." } function Test-Python { @@ -245,8 +245,7 @@ foreach ($Arg in $RemainingArgs) { } if ($Dev) { - $InstallTarget = $MainSource - $InstallSource = "GitHub main" + Fail "--dev installed an untracked main snapshot and is no longer supported; clone the repository and run 'python -m pip install -e .' instead." } $Python = Find-Python diff --git a/scripts/install.sh b/scripts/install.sh index 0cb616664..030051be7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,6 @@ set -eu package="nanobot-ai" -main_source="https://github.com/HKUDS/nanobot/archive/refs/heads/main.zip" install_target="$package" install_source="PyPI" dry_run="0" @@ -32,11 +31,12 @@ install_failure_hint() { usage() { cat <<'EOF' -Usage: install.sh [--dev] [--dry-run] +Usage: install.sh [--dry-run] By default this installs or upgrades nanobot-ai from PyPI. -Use --dev to install from the current main branch on GitHub. Use --dry-run to print what would happen without installing or starting setup. + +For current main, clone the repository and run `python -m pip install -e .`. EOF } @@ -200,8 +200,7 @@ PY while [ "$#" -gt 0 ]; do case "$1" in --dev) - install_target="$main_source" - install_source="GitHub main" + fail "--dev installed an untracked main snapshot and is no longer supported; clone the repository and run 'python -m pip install -e .' instead" ;; --dry-run) dry_run="1" diff --git a/tests/cli/test_tui_launcher.py b/tests/cli/test_tui_launcher.py index 7ff2af17c..3bb3206b4 100644 --- a/tests/cli/test_tui_launcher.py +++ b/tests/cli/test_tui_launcher.py @@ -6,6 +6,7 @@ from types import SimpleNamespace import pytest import typer +import nanobot.cli.tui_launcher as tui_launcher from nanobot.cli.agent import agent from nanobot.cli.tui_launcher import ( TuiSessionError, @@ -146,6 +147,45 @@ def test_windows_arm64_fails_instead_of_using_the_classic_prompt( _resolve_tui_command() +def test_source_checkout_does_not_fall_back_to_a_release_tui_without_bun( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source_dir = tmp_path / "tui" + source_dir.mkdir() + monkeypatch.delenv("NANOBOT_TUI_BIN", raising=False) + monkeypatch.setattr( + "nanobot.cli.tui_launcher._source_checkout_tui_dir", + lambda: source_dir, + ) + monkeypatch.setattr("nanobot.cli.tui_launcher.shutil.which", lambda _name: None) + monkeypatch.setattr( + "nanobot.cli.tui_launcher._download_release_tui", + lambda _asset: pytest.fail("a source checkout must not download a release TUI"), + ) + + with pytest.raises(TuiUnavailableError, match="source checkout requires Bun"): + _resolve_tui_command() + + +def test_source_checkout_requires_project_and_tui_markers( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + module_path = project_root / "nanobot" / "cli" / "tui_launcher.py" + source_dir = project_root / "tui" + module_path.parent.mkdir(parents=True) + source_dir.mkdir() + monkeypatch.setattr(tui_launcher, "__file__", str(module_path)) + + assert tui_launcher._source_checkout_tui_dir() is None + (source_dir / "package.json").write_text("{}", encoding="utf-8") + assert tui_launcher._source_checkout_tui_dir() is None + (project_root / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + assert tui_launcher._source_checkout_tui_dir() == source_dir + + def test_interactive_agent_uses_native_tui( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/cli/test_webui_support.py b/tests/cli/test_webui_support.py new file mode 100644 index 000000000..94116f5b6 --- /dev/null +++ b/tests/cli/test_webui_support.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +from nanobot.cli.webui_support import _prepare_webui_bundle_for_gateway +from nanobot.config.schema import Config + + +def test_source_checkout_rebuilds_the_webui_from_every_gateway_entrypoint(monkeypatch) -> None: + modes: list[str] = [] + monkeypatch.setattr( + "nanobot.cli.webui_support.inspect_webui_bundle", + lambda: SimpleNamespace(source_available=True), + ) + monkeypatch.setattr("nanobot.cli.webui_support._webui_channel_enabled", lambda _config: True) + monkeypatch.setattr( + "nanobot.cli.webui_support.ensure_webui_bundle", + lambda **kwargs: modes.append(kwargs["mode"]), + ) + + _prepare_webui_bundle_for_gateway(Config(), mode="warn") + + assert modes == ["auto"] + + +def test_vite_mode_does_not_build_the_source_webui_bundle(monkeypatch) -> None: + modes: list[str] = [] + monkeypatch.setattr( + "nanobot.cli.webui_support.inspect_webui_bundle", + lambda: SimpleNamespace(source_available=True), + ) + monkeypatch.setattr("nanobot.cli.webui_support._webui_channel_enabled", lambda _config: True) + monkeypatch.setattr( + "nanobot.cli.webui_support.ensure_webui_bundle", + lambda **kwargs: modes.append(kwargs["mode"]), + ) + + _prepare_webui_bundle_for_gateway(Config(), mode="skip") + + assert modes == ["skip"]