diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 032672c..123ac99 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -186,10 +186,15 @@ def resolve_python_subprocess_command( """Resolve a Python executable for child processes. Priority: - 1. Current runtime environment inferred from installed modules. - 2. Project/install `.venv`. - 3. Current `sys.executable`. + 1. Project/install ``.venv``. + 2. Current runtime environment inferred from installed modules. + 3. Current ``sys.executable``. """ + current_root = root or repo_root() + venv_python = _python_executable_from_env_root(current_root / ".venv") + if venv_python: + return [venv_python] + for module_name in preferred_modules: env_root = _python_env_root_from_module(module_name) if env_root is None: @@ -198,26 +203,44 @@ def resolve_python_subprocess_command( if resolved: return [resolved] - current_root = root or repo_root() - venv_python = _python_executable_from_env_root(current_root / ".venv") - if venv_python: - return [venv_python] - return [sys.executable] +def _flocks_executable_from_venv(venv_root: Path) -> str | None: + """Return the flocks CLI entry point inside a virtual environment.""" + candidates = [ + venv_root / "Scripts" / "flocks.exe", + venv_root / "Scripts" / "flocks.cmd", + venv_root / "bin" / "flocks", + ] + for candidate in candidates: + if candidate.exists(): + return str(candidate.resolve()) + return None + + def resolve_flocks_cli_command(root: Path | None = None) -> list[str]: - """Resolve a command prefix that launches the `flocks` CLI reliably.""" + """Resolve a command prefix that launches the ``flocks`` CLI reliably. + + On Windows, always uses ``python.exe -m flocks.cli.main`` instead of + ``flocks.exe`` to avoid locking the console-script entry point, which + would prevent ``uv sync`` from replacing it during live upgrades. + """ + current_root = root or repo_root() + + if sys.platform == "win32": + venv_python = _python_executable_from_env_root(current_root / ".venv") + if venv_python: + return [venv_python, "-m", "flocks.cli.main"] + else: + venv_flocks = _flocks_executable_from_venv(current_root / ".venv") + if venv_flocks: + return [venv_flocks] + launcher = which("flocks") or which("flocks.exe") or which("flocks.cmd") if launcher and not launcher.startswith("/mnt/"): return [launcher] - argv0 = sys.argv[0] - if argv0: - argv0_path = Path(argv0) - if argv0_path.exists() and argv0_path.name.lower().startswith("flocks"): - return [str(argv0_path.resolve())] - return resolve_python_subprocess_command(root) + ["-m", "flocks.cli.main"] @@ -632,6 +655,8 @@ def start_backend(config: ServiceConfig, console) -> None: if runtime_record is not None: paths.backend_pid.unlink(missing_ok=True) + _run_legacy_task_migration(root, console) + command = resolve_flocks_cli_command(root) + [ "serve", "--host", diff --git a/flocks/server/app.py b/flocks/server/app.py index 95b50e5..4bf51d6 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -59,6 +59,14 @@ async def lifespan(app: FastAPI): except Exception as e: log.warning("updater.leftovers.cleanup_failed", {"error": str(e)}) + try: + from flocks.updater.updater import _get_repo_root, _refresh_global_cli_entry + + await asyncio.to_thread(_refresh_global_cli_entry, _get_repo_root()) + log.info("cli.global_entry.refreshed") + except Exception as e: + log.warning("cli.global_entry.refresh_failed", {"error": str(e)}) + try: init_observability() log.info("observability.initialized") diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index c0df2e8..ff772bf 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -2008,6 +2008,11 @@ async def perform_update( shutil.rmtree(tmp_dir, ignore_errors=True) _write_version_marker(latest_tag.lstrip("v")) + try: + _refresh_global_cli_entry(install_root) + except Exception as exc: + log.warning("updater.refresh_cli.failed", {"error": str(exc)}) + # ------------------------------------------------------------------ # # Step 7 – restart in-place (skipped when restart=False, e.g. CLI) # Send the "restarting" event while the proxy is still alive, then @@ -2101,15 +2106,67 @@ async def perform_update( return -def _build_restart_argv(install_root: Path | None = None) -> list[str]: +def _refresh_global_cli_entry(install_root: Path) -> None: + """Ensure the global ``flocks`` command points to the project ``.venv``. + + Handles migration from the legacy uv-tool-based global command to a + symlink (Unix) or .cmd wrapper (Windows). Safe to call repeatedly. """ - Reconstruct the argv for os.execv so the process restarts correctly. + link_dir = Path.home() / ".local" / "bin" + link_dir.mkdir(parents=True, exist_ok=True) - Handles two edge cases: - 1. __main__.py path → reconstruct ``-m module`` form - 2. --reload flags → strip them to avoid a second reloader + if sys.platform == "win32": + venv_python = install_root / ".venv" / "Scripts" / "python.exe" + if not venv_python.exists(): + return + wrapper = link_dir / "flocks.cmd" + content = f'@echo off\r\n"{venv_python}" -m flocks.cli.main %*' + try: + wrapper.write_text(content, encoding="oem") + except LookupError: + wrapper.write_text(content, encoding="utf-8") + stale_exe = link_dir / "flocks.exe" + if stale_exe.exists(): + try: + stale_exe.unlink(missing_ok=True) + except OSError: + try: + stale_exe.rename(stale_exe.with_suffix(".exe.bak")) + except OSError: + pass + else: + target = install_root / ".venv" / "bin" / "flocks" + if not target.exists(): + return + link = link_dir / "flocks" + link.unlink(missing_ok=True) + link.symlink_to(target) + + uv = shutil.which("uv") + if uv: + try: + result = subprocess.run( + [uv, "tool", "list"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and re.search(r"^flocks ", result.stdout, re.MULTILINE): + subprocess.run( + [uv, "tool", "uninstall", "flocks"], + capture_output=True, + timeout=30, + ) + except Exception: + pass + + +def _build_restart_argv(install_root: Path | None = None) -> list[str]: + """Reconstruct the argv for ``os.execv`` so the process restarts correctly. + + Always uses the project ``.venv`` Python to ensure the restarted process + runs in the same environment that ``uv sync`` just updated. """ - argv0 = sys.argv[0] rest = sys.argv[1:] clean_rest: list[str] = [] @@ -2126,34 +2183,17 @@ def _build_restart_argv(install_root: Path | None = None) -> list[str]: continue clean_rest.append(arg) + repo_root = install_root or _get_repo_root() if sys.platform == "win32": - repo_root = install_root or _get_repo_root() - venv_python = _windows_upgrade_python_path(repo_root) - if not venv_python.exists(): - raise FileNotFoundError(f"Windows restart runtime is missing: {venv_python}") - log.info("updater.restart.force_venv", {"python": str(venv_python)}) - return [str(venv_python), "-m", "flocks.cli.main"] + clean_rest - - if argv0.endswith("__main__.py"): - pkg_dir = Path(argv0).parent - parts: list[str] = [] - current = pkg_dir - while (current / "__init__.py").exists(): - parts.insert(0, current.name) - current = current.parent - - if parts: - module = ".".join(parts) - log.info( - "updater.restart.module_mode", - { - "module": module, - "reload_stripped": len(rest) - len(clean_rest), - }, - ) - return [sys.executable, "-m", module] + clean_rest + venv_python = repo_root / ".venv" / "Scripts" / "python.exe" + else: + venv_python = repo_root / ".venv" / "bin" / "python" + + if not venv_python.exists(): + raise FileNotFoundError(f"Restart runtime is missing: {venv_python}") - return [sys.executable, argv0] + clean_rest + log.info("updater.restart.force_venv", {"python": str(venv_python)}) + return [str(venv_python), "-m", "flocks.cli.main"] + clean_rest def _resolve_windows_restart_command(argv0: str, orig_argv: list[str]) -> list[str] | None: diff --git a/npm-wrapper/README.md b/npm-wrapper/README.md index 468b763..66954ec 100644 --- a/npm-wrapper/README.md +++ b/npm-wrapper/README.md @@ -2,7 +2,7 @@ npm wrapper for [Flocks](https://github.com/flocks-ai/flocks) — AI-Native SecOps platform. -Flocks is a Python package. This wrapper detects `uvx`, `pipx`, or a globally installed `flocks` binary and delegates to it. +Flocks is a Python package. This wrapper detects a globally installed `flocks` binary, `uvx`, or `pipx` and delegates to it. ## Quick start @@ -25,11 +25,10 @@ Install `uv` (recommended): curl -LsSf https://astral.sh/uv/install.sh | sh ``` -Or install Flocks directly: +Or install Flocks directly via the install script: ```bash -uv tool install flocks # recommended -pipx install flocks # alternative +curl -fsSL https://raw.githubusercontent.com/AgentFlocks/Flocks/main/install.sh | bash ``` ## Skill registry diff --git a/npm-wrapper/bin/flocks.mjs b/npm-wrapper/bin/flocks.mjs index 908abfc..0685fd0 100755 --- a/npm-wrapper/bin/flocks.mjs +++ b/npm-wrapper/bin/flocks.mjs @@ -6,9 +6,9 @@ * launchers and delegates to the real `flocks` CLI. * * Install preference order: - * 1. uvx flocks — uv is the recommended Python launcher - * 2. pipx run flocks — pipx fallback - * 3. flocks — globally installed via pip + * 1. flocks — globally installed (symlink / wrapper → .venv) + * 2. uvx flocks — uv's tool runner (for PyPI-published packages) + * 3. pipx run flocks — pipx fallback * * Usage: * npx @flocks-ai/flocks [command] [options] @@ -37,33 +37,32 @@ function run(launcher, launcherArgs) { process.exit(result.status ?? 1) } -// 1. Try uvx (uv's tool runner — zero-install, like npx but for Python) +// 1. Try globally installed flocks (symlink / wrapper pointing to .venv) +if (hasCommand("flocks")) { + run("flocks", []) +} + +// 2. Try uvx (uv's tool runner — for PyPI-published packages) if (hasCommand("uvx")) { run("uvx", ["flocks"]) } -// 2. Try pipx run +// 3. Try pipx run if (hasCommand("pipx")) { run("pipx", ["run", "flocks"]) } -// 3. Try globally installed flocks binary -if (hasCommand("flocks")) { - run("flocks", []) -} - // 4. Nothing found — guide the user console.error(` - Error: Flocks requires Python (uv or pipx). + Error: Flocks requires Python. Quick install options: - • Install uv (recommended): - curl -LsSf https://astral.sh/uv/install.sh | sh - Then retry: npx @flocks-ai/flocks + • Run the install script (recommended): + curl -fsSL https://raw.githubusercontent.com/AgentFlocks/Flocks/main/install.sh | bash - • Or install directly: - uv tool install flocks - pipx install flocks + • Or install uv and use npx: + curl -LsSf https://astral.sh/uv/install.sh | sh + npx @flocks-ai/flocks See: https://github.com/flocks-ai/flocks `) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 2dd800c..57329e9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -418,6 +418,10 @@ function Get-FlocksProcessIds { } $isMatch = [Regex]::IsMatch($commandLine, "flocks\.server\.app") + if (-not $isMatch -and $escapedProjectRoot) { + $escapedVenvDir = [Regex]::Escape((Join-Path $ProjectRoot ".venv")) + $isMatch = [Regex]::IsMatch($commandLine, $escapedVenvDir) -and [Regex]::IsMatch($commandLine, "flocks") + } if (-not $isMatch -and $escapedProjectRoot) { $isMatch = [Regex]::IsMatch($commandLine, $escapedProjectRoot) -and [Regex]::IsMatch($commandLine, "(uv tool|uv sync|npm(\.cmd)? run preview|vite preview)") } @@ -744,25 +748,59 @@ function Invoke-InstallerCommandWithLockRetry { function Install-FlocksCli { Write-Info "Installing the global flocks CLI..." - Push-Location $RootDir - try { - Invoke-InstallerCommandWithLockRetry ` - -Description "Global flocks CLI installation" ` - -FilePath "uv" ` - -ArgumentList @("tool", "install", "--editable", $RootDir, "--force", "--default-index", $script:UvDefaultIndex) ` - -WorkingDirectory $RootDir ` - -StreamOutput + $linkDir = Join-Path $HOME ".local\bin" + + if (Test-Command "uv") { + $savedEA = $ErrorActionPreference + try { + $ErrorActionPreference = "SilentlyContinue" + $toolList = (& uv tool list 2>&1) | Where-Object { $_ -is [string] -or $_ -isnot [System.Management.Automation.ErrorRecord] } + if ("$toolList" -match 'flocks') { + Write-Info "Removing legacy uv tool installation..." + & uv tool uninstall flocks 2>&1 | Out-Null + } + } + catch { + Write-Warning "Could not clean up legacy uv tool install. Continuing anyway." + } + finally { + $ErrorActionPreference = $savedEA + } } - finally { - Pop-Location + $staleExe = Join-Path $linkDir "flocks.exe" + if (Test-Path $staleExe) { + Write-Info "Removing stale flocks.exe to avoid shadowing new wrapper..." + try { + Remove-Item -Force $staleExe -ErrorAction Stop + } + catch { + $backupExe = Join-Path $linkDir "flocks.exe.bak" + try { + Move-Item -Force $staleExe $backupExe -ErrorAction Stop + Write-Info "Could not delete flocks.exe (locked); renamed to flocks.exe.bak" + } + catch { + Write-Warning "Could not remove or rename flocks.exe — it may shadow the new flocks.cmd wrapper. Stop any running flocks process and re-run the installer." + } + } } - $toolBin = (& uv tool dir --bin 2>$null).Trim() - if (-not [string]::IsNullOrWhiteSpace($toolBin) -and (Test-Path $toolBin)) { - Ensure-UserPathEntry $toolBin + $venvPython = Join-Path $RootDir ".venv\Scripts\python.exe" + if (-not (Test-Path $venvPython)) { + Fail "Expected .venv runtime not found: $venvPython - run 'uv sync' first." } + if (-not (Test-Path $linkDir)) { + New-Item -ItemType Directory -Path $linkDir -Force | Out-Null + } + + $wrapperPath = Join-Path $linkDir "flocks.cmd" + $wrapperContent = "@echo off`r`n`"$venvPython`" -m flocks.cli.main %*" + [System.IO.File]::WriteAllText($wrapperPath, $wrapperContent, [System.Text.Encoding]::Default) + + Ensure-UserPathEntry $linkDir Refresh-Path + if (-not (Test-Command "flocks")) { Fail "The flocks CLI finished installing, but it is still not available. Check PATH and retry." } diff --git a/scripts/install.sh b/scripts/install.sh index ed846c4..1f8c6f8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -508,6 +508,9 @@ list_flocks_process_ids() { *flocks.server.app*|*uvicorn*flocks.server.app*) printf '%s\n' "$pid" ;; + *"$ROOT_DIR/.venv"*flocks*) + printf '%s\n' "$pid" + ;; *"$ROOT_DIR"*uv\ tool*|*"$ROOT_DIR"*preview*) printf '%s\n' "$pid" ;; @@ -573,20 +576,25 @@ run_with_lock_retry() { } install_flocks_cli() { - local tool_bin - info "Installing the global flocks CLI..." - ( - cd "$ROOT_DIR" - run_with_lock_retry "Global flocks CLI installation" uv tool install --editable "$ROOT_DIR" --force --default-index "$UV_DEFAULT_INDEX" - ) - tool_bin="$(uv tool dir --bin 2>/dev/null | tr -d '\r' || true)" - if [[ -n "$tool_bin" ]]; then - append_path "$tool_bin" - ensure_path_persisted "$tool_bin" + if has_cmd uv && uv tool list 2>/dev/null | grep -q '^flocks '; then + info "Removing legacy uv tool installation..." + uv tool uninstall flocks 2>/dev/null || true + fi + + local target="$ROOT_DIR/.venv/bin/flocks" + if [[ ! -x "$target" ]]; then + fail "Expected CLI entry point not found: $target — run 'uv sync' first." fi + local link_dir="$HOME/.local/bin" + mkdir -p "$link_dir" + ln -sf "$target" "$link_dir/flocks" + + append_path "$link_dir" + ensure_path_persisted "$link_dir" + has_cmd flocks || fail "The flocks CLI finished installing, but it is still not available. Check PATH and retry." } diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index cc8a721..3563e55 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -236,7 +236,29 @@ def get(self, _url): service_manager.wait_for_http(["http://127.0.0.1:5173"], "WebUI", attempts=2, delay=0.0) -def test_resolve_python_subprocess_command_prefers_module_env( +def test_resolve_python_subprocess_command_prefers_venv( + monkeypatch, + tmp_path: Path, +) -> None: + venv_python = tmp_path / ".venv" / "bin" / "python" + venv_python.parent.mkdir(parents=True) + venv_python.write_text("", encoding="utf-8") + + tool_env = tmp_path / "tool-env" + tool_python = tool_env / "bin" / "python" + tool_python.parent.mkdir(parents=True) + tool_python.write_text("", encoding="utf-8") + + monkeypatch.setattr( + service_manager, + "_python_env_root_from_module", + lambda module_name: tool_env if module_name == "uvicorn" else None, + ) + + assert service_manager.resolve_python_subprocess_command(tmp_path) == [str(venv_python)] + + +def test_resolve_python_subprocess_command_falls_back_to_module_env( monkeypatch, tmp_path: Path, ) -> None: @@ -254,26 +276,40 @@ def test_resolve_python_subprocess_command_prefers_module_env( assert service_manager.resolve_python_subprocess_command(tmp_path) == [str(python_exe)] -def test_resolve_python_subprocess_command_falls_back_to_repo_venv( - monkeypatch, - tmp_path: Path, -) -> None: - venv_python = tmp_path / ".venv" / "bin" / "python" +def test_resolve_flocks_cli_command_prefers_venv_entry_point_unix(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(service_manager.sys, "platform", "darwin") + venv_flocks = tmp_path / ".venv" / "bin" / "flocks" + venv_flocks.parent.mkdir(parents=True) + venv_flocks.write_text("", encoding="utf-8") + + monkeypatch.setattr(service_manager, "which", lambda name: "/usr/local/bin/flocks" if name == "flocks" else None) + + assert service_manager.resolve_flocks_cli_command(tmp_path) == [str(venv_flocks.resolve())] + + +def test_resolve_flocks_cli_command_uses_python_module_on_windows(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(service_manager.sys, "platform", "win32") + venv_python = tmp_path / ".venv" / "Scripts" / "python.exe" venv_python.parent.mkdir(parents=True) venv_python.write_text("", encoding="utf-8") - monkeypatch.setattr(service_manager, "_python_env_root_from_module", lambda _module_name: None) + monkeypatch.setattr(service_manager, "which", lambda name: r"C:\tools\flocks.exe" if name == "flocks" else None) - assert service_manager.resolve_python_subprocess_command(tmp_path) == [str(venv_python)] + assert service_manager.resolve_flocks_cli_command(tmp_path) == [ + str(venv_python), + "-m", + "flocks.cli.main", + ] -def test_resolve_flocks_cli_command_prefers_launcher(monkeypatch) -> None: +def test_resolve_flocks_cli_command_falls_back_to_which(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(service_manager, "which", lambda name: "/usr/local/bin/flocks" if name == "flocks" else None) - assert service_manager.resolve_flocks_cli_command() == ["/usr/local/bin/flocks"] + assert service_manager.resolve_flocks_cli_command(tmp_path) == ["/usr/local/bin/flocks"] def test_resolve_flocks_cli_command_falls_back_to_python_module(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(service_manager, "_flocks_executable_from_venv", lambda _venv_root: None) monkeypatch.setattr(service_manager, "which", lambda _name: None) monkeypatch.setattr(service_manager.sys, "argv", ["python"]) monkeypatch.setattr(service_manager, "resolve_python_subprocess_command", lambda root=None: ["/env/bin/python"]) diff --git a/tests/scripts/test_install_script_process_cleanup.py b/tests/scripts/test_install_script_process_cleanup.py index 84e5e5e..81ca303 100644 --- a/tests/scripts/test_install_script_process_cleanup.py +++ b/tests/scripts/test_install_script_process_cleanup.py @@ -20,7 +20,7 @@ def test_bash_installer_stops_processes_before_retrying_locked_operations() -> N assert "list_flocks_process_ids()" in script assert "is_lock_error_output()" in script assert 'run_with_lock_retry "Python backend dependency installation" uv sync --group dev' in script - assert 'run_with_lock_retry "Global flocks CLI installation" uv tool install --editable "$ROOT_DIR" --force' in script + assert 'ln -sf "$target" "$link_dir/flocks"' in script assert "os\\ error\\ 5" in script assert 'pkill -f "uv sync"' not in script assert 'pkill -f "npm run preview"' not in script @@ -45,7 +45,7 @@ def test_powershell_installer_stops_processes_before_retrying_locked_operations( assert "$result = Invoke-NativeCommandOrFail" in script assert "$null = Invoke-NativeCommandOrFail" in script assert '-Description "Python backend dependency installation"' in script - assert '-Description "Global flocks CLI installation"' in script + assert "flocks.cmd" in script assert "Failed to update Windows PE resources" in script assert '& $ScriptBlock 2>&1' not in script assert "Stop-FlocksProcesses -Aggressive" not in script diff --git a/tests/updater/test_updater.py b/tests/updater/test_updater.py index 51eec9e..e7c8d27 100644 --- a/tests/updater/test_updater.py +++ b/tests/updater/test_updater.py @@ -469,28 +469,81 @@ def test_build_restart_argv_uses_windows_venv_python( ] -def test_build_restart_argv_restores_module_mode_on_non_windows( +def test_build_restart_argv_uses_venv_python_on_non_windows( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - package_dir = tmp_path / "flocks" - package_dir.mkdir() - (package_dir / "__init__.py").write_text("", encoding="utf-8") - main_path = package_dir / "__main__.py" - main_path.write_text("", encoding="utf-8") + venv_python = tmp_path / ".venv" / "bin" / "python" + venv_python.parent.mkdir(parents=True) + venv_python.write_text("", encoding="utf-8") monkeypatch.setattr(updater.sys, "platform", "darwin") monkeypatch.setattr(updater.sys, "executable", "/usr/bin/python3") - monkeypatch.setattr(updater.sys, "argv", [str(main_path), "start", "--reload"]) + monkeypatch.setattr(updater.sys, "argv", ["/usr/local/bin/flocks", "start", "--reload"]) - assert updater._build_restart_argv() == [ - "/usr/bin/python3", + assert updater._build_restart_argv(tmp_path) == [ + str(venv_python), "-m", - "flocks", + "flocks.cli.main", "start", ] +def test_refresh_global_cli_entry_creates_symlink_on_unix( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(updater.sys, "platform", "darwin") + monkeypatch.setattr(updater.Path, "home", lambda: tmp_path / "home") + monkeypatch.setattr(updater.shutil, "which", lambda _name: None) + + install_root = tmp_path / "project" + venv_flocks = install_root / ".venv" / "bin" / "flocks" + venv_flocks.parent.mkdir(parents=True) + venv_flocks.write_text("#!/usr/bin/env python\n", encoding="utf-8") + + updater._refresh_global_cli_entry(install_root) + + link = tmp_path / "home" / ".local" / "bin" / "flocks" + assert link.is_symlink() + assert link.resolve() == venv_flocks.resolve() + + +def test_refresh_global_cli_entry_creates_cmd_wrapper_on_windows( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(updater.sys, "platform", "win32") + monkeypatch.setattr(updater.Path, "home", lambda: tmp_path / "home") + monkeypatch.setattr(updater.shutil, "which", lambda _name: None) + + install_root = tmp_path / "project" + venv_python = install_root / ".venv" / "Scripts" / "python.exe" + venv_python.parent.mkdir(parents=True) + venv_python.write_text("", encoding="utf-8") + + updater._refresh_global_cli_entry(install_root) + + wrapper = tmp_path / "home" / ".local" / "bin" / "flocks.cmd" + assert wrapper.exists() + content = wrapper.read_text(encoding="ascii") + assert str(venv_python) in content + assert "-m flocks.cli.main %*" in content + + +def test_refresh_global_cli_entry_noop_when_venv_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(updater.sys, "platform", "darwin") + monkeypatch.setattr(updater.Path, "home", lambda: tmp_path / "home") + + updater._refresh_global_cli_entry(tmp_path / "nonexistent") + + link_dir = tmp_path / "home" / ".local" / "bin" + assert not (link_dir / "flocks").exists() + + @pytest.mark.asyncio async def test_validate_windows_restart_runtime_requires_venv_python(tmp_path: Path) -> None: assert await updater._validate_windows_restart_runtime(tmp_path) == ( @@ -1155,6 +1208,8 @@ async def fake_sleep(_seconds) -> None: lambda *_args, **_kwargs: events.append("replace"), ) monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}")) + monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) + monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: ["/usr/bin/python3", "-m", "flocks.cli.main", "start"]) monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) monkeypatch.setattr(updater, "_rollback_failed_update", lambda *_args: events.append("rollback")) monkeypatch.setattr(updater, "rollback_upgrade_handover", lambda *_args: events.append("rollback_handover")) @@ -1676,6 +1731,7 @@ async def fake_validate_windows_restart_runtime(_install_root: Path) -> str | No ) monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None) monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) + monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main", "start"]) monkeypatch.setattr(updater, "_validate_windows_restart_runtime", fake_validate_windows_restart_runtime) monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) @@ -1801,6 +1857,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): monkeypatch.setattr(updater, "_build_uv_sync_env", lambda: None) monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None) monkeypatch.setattr(updater, "_write_version_marker", lambda _v: events.append("marker")) + monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr( updater, "_build_restart_argv", @@ -1871,6 +1928,7 @@ async def fake_validate(_install_root: Path) -> str | None: ) monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None) monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) + monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main"]) monkeypatch.setattr(updater, "_validate_windows_restart_runtime", fake_validate) monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) diff --git a/uv.lock b/uv.lock index ed4c25f..eb9de1c 100644 --- a/uv.lock +++ b/uv.lock @@ -469,7 +469,7 @@ wheels = [ [[package]] name = "flocks" -version = "2026.4.7" +version = "2026.4.9" source = { editable = "." } dependencies = [ { name = "aiofiles" },