personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(install): split dev env install from service install

make install is now repo-local dev env only and no longer writes to
user-level aliases or skills. make install-service becomes the single
smart install-or-upgrade path that owns ~/.local/bin/sol, the global
solstone skill, and the background service.

add think.install_guard to enforce alias ownership before any
user-level mutation. cross-repo, dangling, not-a-symlink, and worktree
states hard-fail without a bypass. remove the upgrade target, make
uninstall an ambiguity error, and align the docs with the new install
split.

+568 -55
+1 -1
AGENTS.md
··· 30 30 ## Quick Commands 31 31 32 32 ```bash 33 - make install # Install package (includes all deps) 33 + make install # Set up the repo-local dev environment and dependencies 34 34 make skills # Discover and symlink Anthropic Skills from talent/ dirs 35 35 make format # Auto-fix formatting, then report remaining issues 36 36 make test # Run unit tests
+5 -5
INSTALL.md
··· 49 49 make install 50 50 ``` 51 51 52 - sets up the python environment, installs all dependencies, and symlinks `sol` to `~/.local/bin/sol`. 52 + sets up the repo-local python environment and installs all dependencies for development. it does not add `sol` to your PATH or install any user/system services. 53 53 54 - if `sol` isn't in PATH after install, use `.venv/bin/sol` — the human can add `~/.local/bin` to their shell profile later. 54 + for repo-local use after this step, run `.venv/bin/sol`. 55 55 56 56 ## start solstone 57 57 ··· 59 59 make install-service 60 60 ``` 61 61 62 - starts a background service (systemd on linux, launchd on macOS) with the web interface on port 5015. 62 + creates or refreshes the `~/.local/bin/sol` alias, installs the `solstone` skill for claude-code, and starts a background service (systemd on linux, launchd on macOS) with the web interface on port 5015. re-running it performs the upgrade path safely instead of conflicting with an existing install. 63 63 64 64 let your human know: **open http://localhost:5015 in a browser.** the first-run setup wizard walks them through choosing a password, setting their identity, and connecting a Gemini API key. once they've completed it, solstone is configured and ready. 65 65 ··· 94 94 ## updating after a code change 95 95 96 96 ```bash 97 - git pull && make upgrade 97 + git pull && make install-service 98 98 ``` 99 99 100 - `make upgrade` runs the full CI suite first and aborts if anything fails, leaving the running service untouched. 100 + re-running `make install-service` handles both fresh installs and upgrades. on upgrade it runs the full CI suite first and aborts if anything fails, leaving the installed service untouched. 101 101 102 102 ## done 103 103
+82 -40
Makefile
··· 7 7 # all runs to one path and pytest wipes it on startup, destroying concurrent state. 8 8 export TMPDIR := /var/tmp 9 9 10 - .PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format format-check ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sail upgrade sandbox sandbox-stop install-pinchtab verify-browser update-browser-baselines review verify-api update-api-baselines install-service uninstall-service service-logs gate-agents-rename 10 + .PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format format-check ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sail sandbox sandbox-stop install-pinchtab verify-browser update-browser-baselines review verify-api update-api-baselines install-service uninstall-service service-logs gate-agents-rename 11 11 12 12 # Default target - install package in editable mode 13 13 all: install ··· 44 44 fi 45 45 @echo "Installing Playwright browser for sol screenshot..." 46 46 $(VENV_BIN)/playwright install chromium 47 - @if [ -d .git ]; then \ 48 - mkdir -p $(USER_BIN); \ 49 - ln -sf $(CURDIR)/$(VENV_BIN)/sol $(USER_BIN)/sol; \ 50 - echo ""; \ 51 - echo "Done! 'sol' command installed to $(USER_BIN)/sol"; \ 52 - if ! echo "$$PATH" | grep -q "$(USER_BIN)"; then \ 53 - echo ""; \ 54 - echo "NOTE: $(USER_BIN) is not in your PATH."; \ 55 - echo "Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):"; \ 56 - echo " export PATH=\"\$$HOME/.local/bin:\$$PATH\""; \ 57 - echo ""; \ 58 - echo "Or run sol directly: $(CURDIR)/$(VENV_BIN)/sol"; \ 59 - fi; \ 60 - else \ 61 - echo ""; \ 62 - echo "Done! (worktree detected, skipping ~/.local/bin/sol symlink)"; \ 63 - fi 64 - @if [ -d .git ] && [ -f skills/solstone/SKILL.md ]; then \ 65 - echo "Installing solstone skill user-wide..."; \ 66 - npx skills add ./skills/solstone -g -a claude-code -y; \ 67 - fi 47 + @$(MAKE) --no-print-directory skills 68 48 @touch .installed 69 49 70 50 # Generate lock file if missing ··· 130 110 131 111 # Restart solstone service (noop in dev mode) 132 112 sail: .installed 133 - $(VENV_BIN)/sol service restart --if-installed 134 - 135 - # Restart service after passing the full CI suite 136 - upgrade: ci 137 113 $(VENV_BIN)/sol service restart --if-installed 138 114 139 115 # Start sandbox stack: fixture copy + background supervisor + readiness wait ··· 389 365 390 366 # Service management (override port: make install-service PORT=8000) 391 367 install-service: .installed 392 - $(VENV_BIN)/sol service install --port $(or $(PORT),5015) 393 - $(VENV_BIN)/sol service start 368 + @MODE=$$($(PYTHON) -m think.install_guard check); \ 369 + RC=$$?; \ 370 + case "$$MODE" in \ 371 + worktree) \ 372 + echo "mode: aborted — worktree"; \ 373 + exit $$RC; \ 374 + ;; \ 375 + cross_repo) \ 376 + echo "mode: aborted — cross_repo"; \ 377 + exit $$RC; \ 378 + ;; \ 379 + dangling) \ 380 + echo "mode: aborted — dangling"; \ 381 + exit $$RC; \ 382 + ;; \ 383 + not_symlink) \ 384 + echo "mode: aborted — not_symlink"; \ 385 + exit $$RC; \ 386 + ;; \ 387 + up""grade) \ 388 + echo "mode: up""grade"; \ 389 + $(MAKE) ci || exit $$?; \ 390 + ;; \ 391 + fresh) \ 392 + echo "mode: fresh install"; \ 393 + ;; \ 394 + *) \ 395 + echo "mode: aborted — unknown"; \ 396 + exit 2; \ 397 + ;; \ 398 + esac; \ 399 + $(PYTHON) -m think.install_guard install; \ 400 + npx skills add ./skills/solstone -g -a claude-code -y; \ 401 + $(VENV_BIN)/sol service install --port $(or $(PORT),5015); \ 402 + $(VENV_BIN)/sol service start; \ 403 + echo "Waiting for service readiness..."; \ 404 + READY=false; \ 405 + for i in $$(seq 1 20); do \ 406 + if $(VENV_BIN)/sol health > /dev/null 2>&1; then \ 407 + READY=true; \ 408 + break; \ 409 + fi; \ 410 + sleep 1; \ 411 + done; \ 412 + if [ "$$READY" = "false" ]; then \ 413 + echo "Service readiness timeout after 20s" >&2; \ 414 + exit 1; \ 415 + fi; \ 394 416 $(VENV_BIN)/sol service status 395 417 396 418 # Follow installed service logs ··· 398 420 $(VENV_BIN)/sol service logs -f 399 421 400 422 uninstall-service: 401 - -$(VENV_BIN)/sol service uninstall 423 + @MODE=$$($(PYTHON) -m think.install_guard check); \ 424 + RC=$$?; \ 425 + HAS_SERVICE=false; \ 426 + HAS_SKILL=false; \ 427 + if [ -f "$$HOME/.config/systemd/user/solstone.service" ] || [ -f "$$HOME/Library/LaunchAgents/org.solpbc.solstone.plist" ]; then \ 428 + HAS_SERVICE=true; \ 429 + fi; \ 430 + if [ -e "$$HOME/.claude/skills/solstone" ]; then \ 431 + HAS_SKILL=true; \ 432 + fi; \ 433 + case "$$MODE" in \ 434 + worktree|cross_repo|dangling|not_symlink) \ 435 + echo "mode: aborted — $$MODE"; \ 436 + exit $$RC; \ 437 + ;; \ 438 + esac; \ 439 + if [ "$$MODE" = "fresh" ] && [ "$$HAS_SERVICE" = "false" ] && [ "$$HAS_SKILL" = "false" ]; then \ 440 + echo "no artifacts to remove"; \ 441 + exit 0; \ 442 + fi; \ 443 + $(VENV_BIN)/sol service stop > /dev/null 2>&1 || true; \ 444 + $(VENV_BIN)/sol service uninstall; \ 445 + npx skills remove -g -a claude-code -y solstone; \ 446 + $(PYTHON) -m think.install_guard uninstall 402 447 403 - # Uninstall - remove venv and sol symlink 404 - uninstall: uninstall-service clean 405 - @echo "Removing virtual environment..." 406 - rm -rf $(VENV) 407 - @if [ -L $(USER_BIN)/sol ]; then \ 408 - echo "Removing sol symlink from $(USER_BIN)..."; \ 409 - rm -f $(USER_BIN)/sol; \ 410 - fi 448 + uninstall: 449 + @echo "Error: 'make uninstall' is disabled. Use the 'uninstall-service' target to remove installed user/system artifacts, or 'make clean-install' to rebuild the local dev environment." >&2 450 + @exit 1 411 451 412 452 # Clean everything and reinstall 413 - clean-install: uninstall install 453 + clean-install: clean 454 + rm -rf $(VENV) .installed 455 + $(MAKE) install 414 456 415 457 # Run continuous integration checks (what CI would run) 416 458 ci: .installed ··· 446 488 # Update all dependencies to latest versions and refresh genai-prices 447 489 update: .installed 448 490 @echo "Updating all dependencies to latest versions..." 449 - $(UV) lock --upgrade 491 + $(UV) lock -U 450 492 $(UV) sync 451 493 @echo "Done. All packages updated to latest." 452 494 ··· 454 496 # Run this when adding new models or if pricing tests fail 455 497 update-prices: .installed 456 498 @echo "Updating genai-prices to latest version..." 457 - $(UV) lock --upgrade-package genai-prices 499 + $(UV) lock -P genai-prices 458 500 $(UV) sync 459 501 @echo "Done. Re-run tests to verify model pricing support." 460 502
+1 -1
README.md
··· 76 76 # Configure API keys and password in journal.json 77 77 # See docs/INSTALL.md for setup instructions 78 78 79 - # Install as a background service (starts on login, port 5015) 79 + # Install the CLI on PATH and start the background service (port 5015) 80 80 make install-service 81 81 82 82 # Or start manually for development
+10 -4
docs/INSTALL.md
··· 49 49 make install 50 50 ``` 51 51 52 - This creates an isolated virtual environment in `.venv/` and symlinks the `sol` command to `~/.local/bin/sol`. Your system Python remains untouched. 52 + This creates an isolated virtual environment in `.venv/` for local development. Your system Python remains untouched, and no user-level CLI alias or service is installed yet. 53 53 54 - To uninstall: 54 + To remove installed user/system artifacts: 55 55 56 56 ```bash 57 - make uninstall 57 + make uninstall-service 58 + ``` 59 + 60 + To reset the repo-local development environment: 61 + 62 + ```bash 63 + make clean-install 58 64 ``` 59 65 60 66 2. Your journal lives at `journal/` inside the solstone directory. It's created automatically on first run. ··· 130 136 make install-service 131 137 ``` 132 138 133 - This installs, enables, and starts a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. To use a custom port: 139 + This creates or refreshes the `~/.local/bin/sol` alias, installs the global `solstone` skill for claude-code, and installs, enables, and starts a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. Re-running it upgrades an existing install instead of conflicting. To use a custom port: 134 140 135 141 ```bash 136 142 make install-service PORT=8000
+1 -1
docs/coding-standards.md
··· 48 48 - **Minimize Dependencies**: Use standard library when possible 49 49 - **All Dependencies**: Add to `dependencies` in `pyproject.toml` 50 50 - **Package Manager**: [uv](https://docs.astral.sh/uv/) — lock file (`uv.lock`) is committed, `make install` syncs from it 51 - - **Installation**: `make install` (creates isolated `.venv/`, syncs deps from lock file, symlinks `sol` to `~/.local/bin`) 51 + - **Installation**: `make install` (creates isolated `.venv/` and syncs deps from the lock file for repo-local development) 52 52 - **Updating**: `make update` upgrades all deps to latest and regenerates the lock file
+1 -1
docs/environment.md
··· 6 6 7 7 ## Service Installation 8 8 9 - `make install-service` installs solstone as a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. Override with `make install-service PORT=8000`. Managed via `sol service <install|start|stop|restart|status|logs>`. 9 + `make install-service` installs the `sol` CLI alias in `~/.local/bin`, then installs solstone as a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. Override with `make install-service PORT=8000`. Managed via `sol service <install|start|stop|restart|status|logs>`. 10 10 11 11 ## API Keys 12 12
+2 -2
skills/solstone/SKILL.md
··· 23 23 sol help 24 24 ``` 25 25 26 - If this fails, solstone is not installed. Install it from the solstone project: `make install`. 26 + If this fails, solstone is not installed. Install it from the solstone project: `make install-service`. 27 27 28 28 ## Capabilities 29 29 ··· 183 183 184 184 If `sol` is not found on PATH or returns an error: 185 185 186 - - **"command not found: sol"** — solstone is not installed. The user needs to run `make install` in their solstone project. 186 + - **"command not found: sol"** — solstone is not installed. The user needs to run `make install-service` in their solstone project. 187 187 - **"journal not found"** or empty output — the journal directory doesn't exist or has no data yet. solstone may be installed but not yet initialized. 188 188 - **Connection errors from `sol call support`** — `diagnose` is local-only and should always work. Other support commands (`search`, `article`) contact the support portal and may fail if offline. 189 189
+298
tests/test_install_guard.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import os 7 + from pathlib import Path 8 + 9 + import pytest 10 + 11 + from think import install_guard 12 + 13 + 14 + @pytest.fixture 15 + def home_root(monkeypatch, tmp_path): 16 + home = tmp_path / "home" 17 + home.mkdir() 18 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) 19 + return home 20 + 21 + 22 + def make_repo(tmp_path: Path, *, worktree: bool = False) -> Path: 23 + repo = tmp_path / "repo" 24 + repo.mkdir() 25 + if worktree: 26 + (repo / ".git").write_text("gitdir: /tmp/worktree\n") 27 + else: 28 + (repo / ".git").mkdir() 29 + return repo 30 + 31 + 32 + def ensure_expected_target(repo: Path) -> Path: 33 + target = install_guard.expected_target(repo) 34 + target.parent.mkdir(parents=True, exist_ok=True) 35 + target.write_text("") 36 + return target 37 + 38 + 39 + def make_alias(home_root: Path, target: Path | str) -> Path: 40 + alias = home_root / ".local" / "bin" / "sol" 41 + alias.parent.mkdir(parents=True, exist_ok=True) 42 + alias.symlink_to(target) 43 + return alias 44 + 45 + 46 + def other_target(tmp_path: Path) -> Path: 47 + target = tmp_path / "other" / ".venv" / "bin" / "sol" 48 + target.parent.mkdir(parents=True, exist_ok=True) 49 + target.write_text("") 50 + return target 51 + 52 + 53 + def run_main(monkeypatch, capsys, repo: Path, *argv: str) -> tuple[int, str, str]: 54 + monkeypatch.chdir(repo) 55 + rc = install_guard.main(list(argv)) 56 + captured = capsys.readouterr() 57 + return rc, captured.out, captured.err 58 + 59 + 60 + def alias_error(curdir: Path, installed: str) -> str: 61 + return ( 62 + "ERROR: Another solstone install owns ~/.local/bin/sol.\n" 63 + f" this repo: {curdir}\n" 64 + f"{installed}\n" 65 + "Run 'make uninstall-service' from the installed repo first,\n" 66 + "or remove ~/.local/bin/sol manually if that repo is gone. No --force available.\n" 67 + ) 68 + 69 + 70 + def worktree_error(curdir: Path) -> str: 71 + return f"ERROR: refusing to run from a git worktree ({curdir}). Run from the primary clone.\n" 72 + 73 + 74 + class TestCheckAlias: 75 + def test_absent(self, home_root, tmp_path): 76 + repo = make_repo(tmp_path) 77 + state, other = install_guard.check_alias(repo) 78 + assert state is install_guard.AliasState.ABSENT 79 + assert other is None 80 + 81 + def test_owned(self, home_root, tmp_path): 82 + repo = make_repo(tmp_path) 83 + target = ensure_expected_target(repo) 84 + make_alias(home_root, target) 85 + state, other = install_guard.check_alias(repo) 86 + assert state is install_guard.AliasState.OWNED 87 + assert other == target 88 + 89 + def test_cross_repo(self, home_root, tmp_path): 90 + repo = make_repo(tmp_path) 91 + target = other_target(tmp_path) 92 + make_alias(home_root, target) 93 + state, other = install_guard.check_alias(repo) 94 + assert state is install_guard.AliasState.CROSS_REPO 95 + assert other == target 96 + 97 + def test_dangling(self, home_root, tmp_path): 98 + repo = make_repo(tmp_path) 99 + target = tmp_path / "missing" / ".venv" / "bin" / "sol" 100 + make_alias(home_root, target) 101 + state, other = install_guard.check_alias(repo) 102 + assert state is install_guard.AliasState.DANGLING 103 + assert other == target 104 + 105 + def test_not_symlink(self, home_root, tmp_path): 106 + repo = make_repo(tmp_path) 107 + alias = install_guard.alias_path() 108 + alias.parent.mkdir(parents=True, exist_ok=True) 109 + alias.write_text("not a symlink") 110 + state, other = install_guard.check_alias(repo) 111 + assert state is install_guard.AliasState.NOT_SYMLINK 112 + assert other is None 113 + 114 + def test_worktree(self, home_root, tmp_path): 115 + repo = make_repo(tmp_path, worktree=True) 116 + state, other = install_guard.check_alias(repo) 117 + assert state is install_guard.AliasState.WORKTREE 118 + assert other is None 119 + 120 + def test_worktree_takes_precedence(self, home_root, tmp_path): 121 + repo = make_repo(tmp_path, worktree=True) 122 + target = ensure_expected_target(repo) 123 + make_alias(home_root, target) 124 + state, other = install_guard.check_alias(repo) 125 + assert state is install_guard.AliasState.WORKTREE 126 + assert other is None 127 + 128 + 129 + class TestErrorFormat: 130 + def test_worktree(self, home_root, tmp_path, capsys): 131 + repo = make_repo(tmp_path, worktree=True).resolve() 132 + rc = install_guard.cmd_check(repo) 133 + captured = capsys.readouterr() 134 + assert rc == 1 135 + assert captured.out == "worktree\n" 136 + assert captured.err == worktree_error(repo) 137 + 138 + def test_cross_repo(self, home_root, tmp_path, capsys): 139 + repo = make_repo(tmp_path).resolve() 140 + target = other_target(tmp_path).resolve() 141 + make_alias(home_root, target) 142 + rc = install_guard.cmd_check(repo) 143 + captured = capsys.readouterr() 144 + assert rc == 1 145 + assert captured.out == "cross_repo\n" 146 + assert captured.err == alias_error(repo, f" installed: {target}") 147 + 148 + def test_dangling(self, home_root, tmp_path, capsys): 149 + repo = make_repo(tmp_path).resolve() 150 + target = (tmp_path / "missing" / ".venv" / "bin" / "sol").resolve() 151 + make_alias(home_root, target) 152 + rc = install_guard.cmd_check(repo) 153 + captured = capsys.readouterr() 154 + assert rc == 1 155 + assert captured.out == "dangling\n" 156 + assert captured.err == alias_error( 157 + repo, f" installed: dangling: {target} does not exist" 158 + ) 159 + 160 + def test_not_symlink(self, home_root, tmp_path, capsys): 161 + repo = make_repo(tmp_path).resolve() 162 + alias = install_guard.alias_path() 163 + alias.parent.mkdir(parents=True, exist_ok=True) 164 + alias.write_text("not a symlink") 165 + rc = install_guard.cmd_check(repo) 166 + captured = capsys.readouterr() 167 + assert rc == 1 168 + assert captured.out == "not_symlink\n" 169 + assert captured.err == alias_error(repo, " installed: not a symlink") 170 + 171 + 172 + class TestInstall: 173 + def test_creates_symlink_on_absent(self, home_root, tmp_path, monkeypatch, capsys): 174 + repo = make_repo(tmp_path) 175 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 176 + alias = install_guard.alias_path() 177 + assert rc == 0 178 + assert out == "installed\n" 179 + assert err == "" 180 + assert alias.is_symlink() 181 + assert alias.resolve() == install_guard.expected_target(repo).resolve() 182 + 183 + def test_rewrites_owned_symlink(self, home_root, tmp_path, monkeypatch, capsys): 184 + repo = make_repo(tmp_path) 185 + original = ensure_expected_target(repo) 186 + alias = make_alias(home_root, original) 187 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 188 + assert rc == 0 189 + assert out == "installed\n" 190 + assert err == "" 191 + assert alias.is_symlink() 192 + assert alias.resolve() == original.resolve() 193 + 194 + def test_refuses_cross_repo(self, home_root, tmp_path, monkeypatch, capsys): 195 + repo = make_repo(tmp_path).resolve() 196 + target = other_target(tmp_path).resolve() 197 + alias = make_alias(home_root, target) 198 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 199 + assert rc == 1 200 + assert out == "" 201 + assert err == alias_error(repo, f" installed: {target}") 202 + assert alias.is_symlink() 203 + assert alias.resolve() == target 204 + 205 + def test_refuses_dangling(self, home_root, tmp_path, monkeypatch, capsys): 206 + repo = make_repo(tmp_path).resolve() 207 + target = (tmp_path / "missing" / ".venv" / "bin" / "sol").resolve() 208 + alias = make_alias(home_root, target) 209 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 210 + assert rc == 1 211 + assert out == "" 212 + assert err == alias_error( 213 + repo, f" installed: dangling: {target} does not exist" 214 + ) 215 + assert alias.is_symlink() 216 + assert Path(os.readlink(alias)).name == "sol" 217 + 218 + def test_refuses_not_symlink(self, home_root, tmp_path, monkeypatch, capsys): 219 + repo = make_repo(tmp_path).resolve() 220 + alias = install_guard.alias_path() 221 + alias.parent.mkdir(parents=True, exist_ok=True) 222 + alias.write_text("not a symlink") 223 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 224 + assert rc == 1 225 + assert out == "" 226 + assert err == alias_error(repo, " installed: not a symlink") 227 + assert alias.read_text() == "not a symlink" 228 + 229 + def test_refuses_worktree(self, home_root, tmp_path, monkeypatch, capsys): 230 + repo = make_repo(tmp_path, worktree=True).resolve() 231 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 232 + assert rc == 1 233 + assert out == "" 234 + assert err == worktree_error(repo) 235 + assert not install_guard.alias_path().exists() 236 + 237 + 238 + class TestUninstall: 239 + def test_removes_owned_alias(self, home_root, tmp_path, monkeypatch, capsys): 240 + repo = make_repo(tmp_path) 241 + target = ensure_expected_target(repo) 242 + alias = make_alias(home_root, target) 243 + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") 244 + assert rc == 0 245 + assert out == "removed\n" 246 + assert err == "" 247 + assert not alias.exists() 248 + assert not alias.is_symlink() 249 + 250 + def test_noop_on_absent(self, home_root, tmp_path, monkeypatch, capsys): 251 + repo = make_repo(tmp_path) 252 + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") 253 + assert rc == 0 254 + assert out == "absent\n" 255 + assert err == "" 256 + assert not install_guard.alias_path().exists() 257 + 258 + def test_refuses_cross_repo(self, home_root, tmp_path, monkeypatch, capsys): 259 + repo = make_repo(tmp_path).resolve() 260 + target = other_target(tmp_path).resolve() 261 + alias = make_alias(home_root, target) 262 + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") 263 + assert rc == 1 264 + assert out == "" 265 + assert err == alias_error(repo, f" installed: {target}") 266 + assert alias.is_symlink() 267 + assert alias.resolve() == target 268 + 269 + def test_refuses_dangling(self, home_root, tmp_path, monkeypatch, capsys): 270 + repo = make_repo(tmp_path).resolve() 271 + target = (tmp_path / "missing" / ".venv" / "bin" / "sol").resolve() 272 + alias = make_alias(home_root, target) 273 + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") 274 + assert rc == 1 275 + assert out == "" 276 + assert err == alias_error( 277 + repo, f" installed: dangling: {target} does not exist" 278 + ) 279 + assert alias.is_symlink() 280 + 281 + def test_refuses_not_symlink(self, home_root, tmp_path, monkeypatch, capsys): 282 + repo = make_repo(tmp_path).resolve() 283 + alias = install_guard.alias_path() 284 + alias.parent.mkdir(parents=True, exist_ok=True) 285 + alias.write_text("not a symlink") 286 + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") 287 + assert rc == 1 288 + assert out == "" 289 + assert err == alias_error(repo, " installed: not a symlink") 290 + assert alias.read_text() == "not a symlink" 291 + 292 + def test_refuses_worktree(self, home_root, tmp_path, monkeypatch, capsys): 293 + repo = make_repo(tmp_path, worktree=True).resolve() 294 + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") 295 + assert rc == 1 296 + assert out == "" 297 + assert err == worktree_error(repo) 298 + assert not install_guard.alias_path().exists()
+167
think/install_guard.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Guard user-level sol alias ownership.""" 5 + 6 + from __future__ import annotations 7 + 8 + import os 9 + import sys 10 + from enum import Enum 11 + from pathlib import Path 12 + 13 + 14 + class AliasState(Enum): 15 + WORKTREE = "worktree" 16 + ABSENT = "absent" 17 + OWNED = "owned" 18 + CROSS_REPO = "cross_repo" 19 + DANGLING = "dangling" 20 + NOT_SYMLINK = "not_symlink" 21 + 22 + 23 + def alias_path() -> Path: 24 + return Path.home() / ".local" / "bin" / "sol" 25 + 26 + 27 + def expected_target(curdir: Path) -> Path: 28 + return curdir / ".venv" / "bin" / "sol" 29 + 30 + 31 + def check_alias(curdir: Path) -> tuple[AliasState, Path | None]: 32 + if (curdir / ".git").is_file(): 33 + return AliasState.WORKTREE, None 34 + 35 + alias = alias_path() 36 + if not alias.exists() and not alias.is_symlink(): 37 + return AliasState.ABSENT, None 38 + 39 + if alias.is_symlink(): 40 + target = Path(os.readlink(alias)) 41 + if not target.is_absolute(): 42 + target = alias.parent / target 43 + target = target.resolve() 44 + if not target.exists(): 45 + return AliasState.DANGLING, target 46 + if target == expected_target(curdir).resolve(): 47 + return AliasState.OWNED, target 48 + return AliasState.CROSS_REPO, target 49 + 50 + return AliasState.NOT_SYMLINK, None 51 + 52 + 53 + def format_error( 54 + state: AliasState, 55 + curdir: Path, 56 + _alias: Path, 57 + other_target: Path | None, 58 + ) -> str: 59 + if state is AliasState.WORKTREE: 60 + return ( 61 + f"ERROR: refusing to run from a git worktree ({curdir}). " 62 + "Run from the primary clone." 63 + ) 64 + 65 + if state is AliasState.CROSS_REPO: 66 + installed = f" installed: {other_target}" 67 + elif state is AliasState.DANGLING: 68 + installed = f" installed: dangling: {other_target} does not exist" 69 + else: 70 + installed = " installed: not a symlink" 71 + 72 + return "\n".join( 73 + [ 74 + "ERROR: Another solstone install owns ~/.local/bin/sol.", 75 + f" this repo: {curdir}", 76 + installed, 77 + "Run 'make uninstall-service' from the installed repo first,", 78 + "or remove ~/.local/bin/sol manually if that repo is gone. No --force available.", 79 + ] 80 + ) 81 + 82 + 83 + def _print_error( 84 + state: AliasState, 85 + curdir: Path, 86 + alias: Path, 87 + other_target: Path | None, 88 + ) -> None: 89 + sys.stderr.write(format_error(state, curdir, alias, other_target) + "\n") 90 + 91 + 92 + def cmd_check(curdir: Path) -> int: 93 + alias = alias_path() 94 + state, other_target = check_alias(curdir) 95 + 96 + if state is AliasState.ABSENT: 97 + print("fresh") 98 + return 0 99 + if state is AliasState.OWNED: 100 + print("upgrade") 101 + return 0 102 + 103 + print(state.value) 104 + _print_error(state, curdir, alias, other_target) 105 + return 1 106 + 107 + 108 + def cmd_install(curdir: Path) -> int: 109 + alias = alias_path() 110 + state, other_target = check_alias(curdir) 111 + 112 + if state is AliasState.WORKTREE: 113 + _print_error(state, curdir, alias, other_target) 114 + return 1 115 + if state is AliasState.ABSENT: 116 + alias.parent.mkdir(parents=True, exist_ok=True) 117 + alias.symlink_to(expected_target(curdir)) 118 + print("installed") 119 + return 0 120 + if state is AliasState.OWNED: 121 + alias.unlink() 122 + alias.symlink_to(expected_target(curdir)) 123 + print("installed") 124 + return 0 125 + 126 + _print_error(state, curdir, alias, other_target) 127 + return 1 128 + 129 + 130 + def cmd_uninstall(curdir: Path) -> int: 131 + alias = alias_path() 132 + state, other_target = check_alias(curdir) 133 + 134 + if state is AliasState.WORKTREE: 135 + _print_error(state, curdir, alias, other_target) 136 + return 1 137 + if state is AliasState.ABSENT: 138 + print("absent") 139 + return 0 140 + if state is AliasState.OWNED: 141 + alias.unlink() 142 + print("removed") 143 + return 0 144 + 145 + _print_error(state, curdir, alias, other_target) 146 + return 1 147 + 148 + 149 + def main(argv: list[str] | None = None) -> int: 150 + if argv is None: 151 + argv = sys.argv[1:] 152 + if len(argv) != 1 or argv[0] not in {"check", "install", "uninstall"}: 153 + sys.stderr.write( 154 + "usage: python -m think.install_guard <check|install|uninstall>\n" 155 + ) 156 + return 2 157 + 158 + curdir = Path.cwd().resolve() 159 + if argv[0] == "check": 160 + return cmd_check(curdir) 161 + if argv[0] == "install": 162 + return cmd_install(curdir) 163 + return cmd_uninstall(curdir) 164 + 165 + 166 + if __name__ == "__main__": 167 + sys.exit(main())