diff --git a/INSTALL.md b/INSTALL.md index 08a19a3ee..7d46d0fb8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,6 +8,8 @@ the latest version of these instructions is at https://solstone.app/install ## before you begin +`make install-service` now auto-adds `~/.local/bin` to your shell `PATH` via the `userpath` library, updating `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish` as needed. if `~/.local/bin` was not already on `PATH`, restart your shell after install or run `exec $SHELL -l` before continuing. + check if solstone is already installed and running: ```bash diff --git a/pyproject.toml b/pyproject.toml index e0ad44536..023fe6295 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dependencies = [ "python-slugify", "rapidfuzz", "typer", + "userpath>=1.9.2,<2", # Audio processing "soundfile", diff --git a/tests/test_install_guard.py b/tests/test_install_guard.py index 863ddc1dc..cbfd3ca21 100644 --- a/tests/test_install_guard.py +++ b/tests/test_install_guard.py @@ -5,6 +5,7 @@ from __future__ import annotations import os from pathlib import Path +from unittest.mock import Mock import pytest @@ -170,12 +171,19 @@ class TestErrorFormat: class TestInstall: + @pytest.fixture(autouse=True) + def path_already_present(self, monkeypatch): + monkeypatch.setattr( + "think.install_guard.userpath.in_current_path", + lambda _path: True, + ) + def test_creates_symlink_on_absent(self, home_root, tmp_path, monkeypatch, capsys): repo = make_repo(tmp_path) rc, out, err = run_main(monkeypatch, capsys, repo, "install") alias = install_guard.alias_path() assert rc == 0 - assert out == "installed\n" + assert out == "installed\npath: ~/.local/bin already on PATH\n" assert err == "" assert alias.is_symlink() assert alias.resolve() == install_guard.expected_target(repo).resolve() @@ -186,11 +194,131 @@ class TestInstall: alias = make_alias(home_root, original) rc, out, err = run_main(monkeypatch, capsys, repo, "install") assert rc == 0 - assert out == "installed\n" + assert out == "installed\npath: ~/.local/bin already on PATH\n" assert err == "" assert alias.is_symlink() assert alias.resolve() == original.resolve() + def test_path_already_on_path_absent( + self, home_root, tmp_path, monkeypatch, capsys + ): + repo = make_repo(tmp_path) + append_mock = Mock(return_value=True) + monkeypatch.setattr("think.install_guard.userpath.append", append_mock) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + alias = install_guard.alias_path() + assert rc == 0 + assert out.endswith("path: ~/.local/bin already on PATH\n") + assert err == "" + assert alias.is_symlink() + assert alias.resolve() == install_guard.expected_target(repo).resolve() + append_mock.assert_not_called() + + def test_path_appended_restart_needed_absent( + self, home_root, tmp_path, monkeypatch, capsys + ): + repo = make_repo(tmp_path) + append_mock = Mock(return_value=True) + restart_mock = Mock(return_value=True) + monkeypatch.setattr( + "think.install_guard.userpath.in_current_path", + lambda _path: False, + ) + monkeypatch.setattr("think.install_guard.userpath.append", append_mock) + monkeypatch.setattr( + "think.install_guard.userpath.need_shell_restart", + restart_mock, + ) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + alias = install_guard.alias_path() + assert rc == 0 + assert ( + out == "installed\n" + "path: added ~/.local/bin to shell PATH — restart your shell or run 'exec $SHELL -l' to pick it up\n" + ) + assert err == "" + assert alias.is_symlink() + assert alias.resolve() == install_guard.expected_target(repo).resolve() + append_mock.assert_called_once_with( + str(alias.parent), + app_name="solstone", + all_shells=True, + ) + restart_mock.assert_called_once_with(str(alias.parent)) + + def test_path_appended_no_restart_owned( + self, home_root, tmp_path, monkeypatch, capsys + ): + repo = make_repo(tmp_path) + alias = make_alias(home_root, ensure_expected_target(repo)) + append_mock = Mock(return_value=True) + restart_mock = Mock(return_value=False) + monkeypatch.setattr( + "think.install_guard.userpath.in_current_path", + lambda _path: False, + ) + monkeypatch.setattr("think.install_guard.userpath.append", append_mock) + monkeypatch.setattr( + "think.install_guard.userpath.need_shell_restart", + restart_mock, + ) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + assert rc == 0 + assert out == "installed\npath: added ~/.local/bin to shell PATH\n" + assert err == "" + assert alias.is_symlink() + assert alias.resolve() == install_guard.expected_target(repo).resolve() + append_mock.assert_called_once_with( + str(alias.parent), + app_name="solstone", + all_shells=True, + ) + restart_mock.assert_called_once_with(str(alias.parent)) + + def test_path_append_returns_false(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path) + append_mock = Mock(return_value=False) + monkeypatch.setattr( + "think.install_guard.userpath.in_current_path", + lambda _path: False, + ) + monkeypatch.setattr("think.install_guard.userpath.append", append_mock) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + alias = install_guard.alias_path() + assert rc == 0 + assert ( + out + == 'installed\npath: could not auto-add ~/.local/bin to PATH — add this line to your shell rc manually: export PATH="$HOME/.local/bin:$PATH"\n' + ) + assert err == "" + assert alias.is_symlink() + assert alias.resolve() == install_guard.expected_target(repo).resolve() + append_mock.assert_called_once_with( + str(alias.parent), + app_name="solstone", + all_shells=True, + ) + + def test_path_unexpected_exception(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path) + append_mock = Mock(return_value=True) + monkeypatch.setattr( + "think.install_guard.userpath.in_current_path", + Mock(side_effect=RuntimeError("boom")), + ) + monkeypatch.setattr("think.install_guard.userpath.append", append_mock) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + alias = install_guard.alias_path() + assert rc == 0 + assert ( + out + == 'installed\npath: could not auto-add ~/.local/bin to PATH (RuntimeError: boom) — add this line to your shell rc manually: export PATH="$HOME/.local/bin:$PATH"\n' + ) + assert err == "" + assert alias.is_symlink() + assert alias.resolve() == install_guard.expected_target(repo).resolve() + append_mock.assert_not_called() + def test_refuses_cross_repo(self, home_root, tmp_path, monkeypatch, capsys): repo = make_repo(tmp_path).resolve() target = other_target(tmp_path).resolve() diff --git a/think/install_guard.py b/think/install_guard.py index affe653d2..82ba2740d 100644 --- a/think/install_guard.py +++ b/think/install_guard.py @@ -10,6 +10,8 @@ import sys from enum import Enum from pathlib import Path +import userpath + class AliasState(Enum): WORKTREE = "worktree" @@ -89,6 +91,29 @@ def _print_error( sys.stderr.write(format_error(state, curdir, alias, other_target) + "\n") +def _ensure_user_bin_on_path(user_bin: Path) -> None: + user_bin_str = str(user_bin) + try: + if userpath.in_current_path(user_bin_str): + print("path: ~/.local/bin already on PATH") + return + if userpath.append(user_bin_str, app_name="solstone", all_shells=True): + if userpath.need_shell_restart(user_bin_str): + print( + "path: added ~/.local/bin to shell PATH — restart your shell or run 'exec $SHELL -l' to pick it up" + ) + else: + print("path: added ~/.local/bin to shell PATH") + return + print( + 'path: could not auto-add ~/.local/bin to PATH — add this line to your shell rc manually: export PATH="$HOME/.local/bin:$PATH"' + ) + except Exception as exc: + print( + f'path: could not auto-add ~/.local/bin to PATH ({type(exc).__name__}: {exc}) — add this line to your shell rc manually: export PATH="$HOME/.local/bin:$PATH"' + ) + + def cmd_check(curdir: Path) -> int: alias = alias_path() state, other_target = check_alias(curdir) @@ -116,11 +141,13 @@ def cmd_install(curdir: Path) -> int: alias.parent.mkdir(parents=True, exist_ok=True) alias.symlink_to(expected_target(curdir)) print("installed") + _ensure_user_bin_on_path(alias.parent) return 0 if state is AliasState.OWNED: alias.unlink() alias.symlink_to(expected_target(curdir)) print("installed") + _ensure_user_bin_on_path(alias.parent) return 0 _print_error(state, curdir, alias, other_target) diff --git a/uv.lock b/uv.lock index d37afba6f..b524af286 100644 --- a/uv.lock +++ b/uv.lock @@ -3784,6 +3784,7 @@ dependencies = [ { name = "timefhuman" }, { name = "typer" }, { name = "tzlocal" }, + { name = "userpath" }, { name = "weasyprint" }, { name = "webrtcvad-wheels" }, { name = "websockets" }, @@ -3837,6 +3838,7 @@ requires-dist = [ { name = "timefhuman" }, { name = "typer" }, { name = "tzlocal" }, + { name = "userpath", specifier = ">=1.9.2,<2" }, { name = "weasyprint" }, { name = "webrtcvad-wheels", specifier = ">=2.0.12" }, { name = "websockets", specifier = ">=13.0" }, @@ -4315,6 +4317,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "userpath" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140, upload-time = "2024-02-29T21:39:08.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065, upload-time = "2024-02-29T21:39:07.551Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0"