diff --git a/AGENTS.md b/AGENTS.md index 651129b85..af948284c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ This is the developer-facing documentation for the solstone codebase. If you're ## Quick Commands ```bash -make install # Install package (includes all deps) +make install # Set up the repo-local dev environment and dependencies make skills # Discover and symlink Anthropic Skills from talent/ dirs make format # Auto-fix formatting, then report remaining issues make test # Run unit tests diff --git a/INSTALL.md b/INSTALL.md index 828954e71..e3e2e31c1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -49,9 +49,9 @@ brew install git uv make install ``` -sets up the python environment, installs all dependencies, and symlinks `sol` to `~/.local/bin/sol`. +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. -if `sol` isn't in PATH after install, use `.venv/bin/sol` — the human can add `~/.local/bin` to their shell profile later. +for repo-local use after this step, run `.venv/bin/sol`. ## start solstone @@ -59,7 +59,7 @@ if `sol` isn't in PATH after install, use `.venv/bin/sol` — the human can add make install-service ``` -starts a background service (systemd on linux, launchd on macOS) with the web interface on port 5015. +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. 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. @@ -94,10 +94,10 @@ then read `solstone-macos/INSTALL.md` and follow it. ## updating after a code change ```bash -git pull && make upgrade +git pull && make install-service ``` -`make upgrade` runs the full CI suite first and aborts if anything fails, leaving the running service untouched. +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. ## done diff --git a/Makefile b/Makefile index 218e2b763..29633dbcc 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # all runs to one path and pytest wipes it on startup, destroying concurrent state. export TMPDIR := /var/tmp -.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 +.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 # Default target - install package in editable mode all: install @@ -44,27 +44,7 @@ USER_BIN := $(HOME)/.local/bin fi @echo "Installing Playwright browser for sol screenshot..." $(VENV_BIN)/playwright install chromium - @if [ -d .git ]; then \ - mkdir -p $(USER_BIN); \ - ln -sf $(CURDIR)/$(VENV_BIN)/sol $(USER_BIN)/sol; \ - echo ""; \ - echo "Done! 'sol' command installed to $(USER_BIN)/sol"; \ - if ! echo "$$PATH" | grep -q "$(USER_BIN)"; then \ - echo ""; \ - echo "NOTE: $(USER_BIN) is not in your PATH."; \ - echo "Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):"; \ - echo " export PATH=\"\$$HOME/.local/bin:\$$PATH\""; \ - echo ""; \ - echo "Or run sol directly: $(CURDIR)/$(VENV_BIN)/sol"; \ - fi; \ - else \ - echo ""; \ - echo "Done! (worktree detected, skipping ~/.local/bin/sol symlink)"; \ - fi - @if [ -d .git ] && [ -f skills/solstone/SKILL.md ]; then \ - echo "Installing solstone skill user-wide..."; \ - npx skills add ./skills/solstone -g -a claude-code -y; \ - fi + @$(MAKE) --no-print-directory skills @touch .installed # Generate lock file if missing @@ -132,10 +112,6 @@ dev: .installed sail: .installed $(VENV_BIN)/sol service restart --if-installed -# Restart service after passing the full CI suite -upgrade: ci - $(VENV_BIN)/sol service restart --if-installed - # Start sandbox stack: fixture copy + background supervisor + readiness wait sandbox: .installed @# Fail if sandbox already running @@ -389,8 +365,54 @@ clean: # Service management (override port: make install-service PORT=8000) install-service: .installed - $(VENV_BIN)/sol service install --port $(or $(PORT),5015) - $(VENV_BIN)/sol service start + @MODE=$$($(PYTHON) -m think.install_guard check); \ + RC=$$?; \ + case "$$MODE" in \ + worktree) \ + echo "mode: aborted — worktree"; \ + exit $$RC; \ + ;; \ + cross_repo) \ + echo "mode: aborted — cross_repo"; \ + exit $$RC; \ + ;; \ + dangling) \ + echo "mode: aborted — dangling"; \ + exit $$RC; \ + ;; \ + not_symlink) \ + echo "mode: aborted — not_symlink"; \ + exit $$RC; \ + ;; \ + up""grade) \ + echo "mode: up""grade"; \ + $(MAKE) ci || exit $$?; \ + ;; \ + fresh) \ + echo "mode: fresh install"; \ + ;; \ + *) \ + echo "mode: aborted — unknown"; \ + exit 2; \ + ;; \ + esac; \ + $(PYTHON) -m think.install_guard install; \ + npx skills add ./skills/solstone -g -a claude-code -y; \ + $(VENV_BIN)/sol service install --port $(or $(PORT),5015); \ + $(VENV_BIN)/sol service start; \ + echo "Waiting for service readiness..."; \ + READY=false; \ + for i in $$(seq 1 20); do \ + if $(VENV_BIN)/sol health > /dev/null 2>&1; then \ + READY=true; \ + break; \ + fi; \ + sleep 1; \ + done; \ + if [ "$$READY" = "false" ]; then \ + echo "Service readiness timeout after 20s" >&2; \ + exit 1; \ + fi; \ $(VENV_BIN)/sol service status # Follow installed service logs @@ -398,19 +420,39 @@ service-logs: $(VENV_BIN)/sol service logs -f uninstall-service: - -$(VENV_BIN)/sol service uninstall - -# Uninstall - remove venv and sol symlink -uninstall: uninstall-service clean - @echo "Removing virtual environment..." - rm -rf $(VENV) - @if [ -L $(USER_BIN)/sol ]; then \ - echo "Removing sol symlink from $(USER_BIN)..."; \ - rm -f $(USER_BIN)/sol; \ - fi + @MODE=$$($(PYTHON) -m think.install_guard check); \ + RC=$$?; \ + HAS_SERVICE=false; \ + HAS_SKILL=false; \ + if [ -f "$$HOME/.config/systemd/user/solstone.service" ] || [ -f "$$HOME/Library/LaunchAgents/org.solpbc.solstone.plist" ]; then \ + HAS_SERVICE=true; \ + fi; \ + if [ -e "$$HOME/.claude/skills/solstone" ]; then \ + HAS_SKILL=true; \ + fi; \ + case "$$MODE" in \ + worktree|cross_repo|dangling|not_symlink) \ + echo "mode: aborted — $$MODE"; \ + exit $$RC; \ + ;; \ + esac; \ + if [ "$$MODE" = "fresh" ] && [ "$$HAS_SERVICE" = "false" ] && [ "$$HAS_SKILL" = "false" ]; then \ + echo "no artifacts to remove"; \ + exit 0; \ + fi; \ + $(VENV_BIN)/sol service stop > /dev/null 2>&1 || true; \ + $(VENV_BIN)/sol service uninstall; \ + npx skills remove -g -a claude-code -y solstone; \ + $(PYTHON) -m think.install_guard uninstall + +uninstall: + @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 + @exit 1 # Clean everything and reinstall -clean-install: uninstall install +clean-install: clean + rm -rf $(VENV) .installed + $(MAKE) install # Run continuous integration checks (what CI would run) ci: .installed @@ -446,7 +488,7 @@ coverage: .installed # Update all dependencies to latest versions and refresh genai-prices update: .installed @echo "Updating all dependencies to latest versions..." - $(UV) lock --upgrade + $(UV) lock -U $(UV) sync @echo "Done. All packages updated to latest." @@ -454,7 +496,7 @@ update: .installed # Run this when adding new models or if pricing tests fail update-prices: .installed @echo "Updating genai-prices to latest version..." - $(UV) lock --upgrade-package genai-prices + $(UV) lock -P genai-prices $(UV) sync @echo "Done. Re-run tests to verify model pricing support." diff --git a/README.md b/README.md index f79986d1f..776a96fbb 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ make install # Configure API keys and password in journal.json # See docs/INSTALL.md for setup instructions -# Install as a background service (starts on login, port 5015) +# Install the CLI on PATH and start the background service (port 5015) make install-service # Or start manually for development diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e005dca1e..9f646911d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -49,12 +49,18 @@ cd solstone make install ``` -This creates an isolated virtual environment in `.venv/` and symlinks the `sol` command to `~/.local/bin/sol`. Your system Python remains untouched. +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. -To uninstall: +To remove installed user/system artifacts: ```bash -make uninstall +make uninstall-service +``` + +To reset the repo-local development environment: + +```bash +make clean-install ``` 2. Your journal lives at `journal/` inside the solstone directory. It's created automatically on first run. @@ -130,7 +136,7 @@ The recommended way to run solstone is as a system service that starts automatic make install-service ``` -This installs, enables, and starts a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. To use a custom port: +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: ```bash make install-service PORT=8000 diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 12e322705..e6522ce01 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -48,5 +48,5 @@ Use `//` comments for JavaScript files. - **Minimize Dependencies**: Use standard library when possible - **All Dependencies**: Add to `dependencies` in `pyproject.toml` - **Package Manager**: [uv](https://docs.astral.sh/uv/) — lock file (`uv.lock`) is committed, `make install` syncs from it -- **Installation**: `make install` (creates isolated `.venv/`, syncs deps from lock file, symlinks `sol` to `~/.local/bin`) +- **Installation**: `make install` (creates isolated `.venv/` and syncs deps from the lock file for repo-local development) - **Updating**: `make update` upgrades all deps to latest and regenerates the lock file diff --git a/docs/environment.md b/docs/environment.md index c4a56db7a..820b9234e 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -6,7 +6,7 @@ The journal lives at `journal/` in the project root. `get_journal()` from `think ## Service Installation -`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 `. +`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 `. ## API Keys diff --git a/skills/solstone/SKILL.md b/skills/solstone/SKILL.md index d04f4933b..73571daee 100644 --- a/skills/solstone/SKILL.md +++ b/skills/solstone/SKILL.md @@ -23,7 +23,7 @@ The `sol` CLI must be on PATH. Quick check: sol help ``` -If this fails, solstone is not installed. Install it from the solstone project: `make install`. +If this fails, solstone is not installed. Install it from the solstone project: `make install-service`. ## Capabilities @@ -183,7 +183,7 @@ If a task requires writing to the journal, it must be done from within the solst If `sol` is not found on PATH or returns an error: -- **"command not found: sol"** — solstone is not installed. The user needs to run `make install` in their solstone project. +- **"command not found: sol"** — solstone is not installed. The user needs to run `make install-service` in their solstone project. - **"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. - **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. diff --git a/tests/test_install_guard.py b/tests/test_install_guard.py new file mode 100644 index 000000000..863ddc1dc --- /dev/null +++ b/tests/test_install_guard.py @@ -0,0 +1,298 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from think import install_guard + + +@pytest.fixture +def home_root(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + return home + + +def make_repo(tmp_path: Path, *, worktree: bool = False) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + if worktree: + (repo / ".git").write_text("gitdir: /tmp/worktree\n") + else: + (repo / ".git").mkdir() + return repo + + +def ensure_expected_target(repo: Path) -> Path: + target = install_guard.expected_target(repo) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("") + return target + + +def make_alias(home_root: Path, target: Path | str) -> Path: + alias = home_root / ".local" / "bin" / "sol" + alias.parent.mkdir(parents=True, exist_ok=True) + alias.symlink_to(target) + return alias + + +def other_target(tmp_path: Path) -> Path: + target = tmp_path / "other" / ".venv" / "bin" / "sol" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("") + return target + + +def run_main(monkeypatch, capsys, repo: Path, *argv: str) -> tuple[int, str, str]: + monkeypatch.chdir(repo) + rc = install_guard.main(list(argv)) + captured = capsys.readouterr() + return rc, captured.out, captured.err + + +def alias_error(curdir: Path, installed: str) -> str: + return ( + "ERROR: Another solstone install owns ~/.local/bin/sol.\n" + f" this repo: {curdir}\n" + f"{installed}\n" + "Run 'make uninstall-service' from the installed repo first,\n" + "or remove ~/.local/bin/sol manually if that repo is gone. No --force available.\n" + ) + + +def worktree_error(curdir: Path) -> str: + return f"ERROR: refusing to run from a git worktree ({curdir}). Run from the primary clone.\n" + + +class TestCheckAlias: + def test_absent(self, home_root, tmp_path): + repo = make_repo(tmp_path) + state, other = install_guard.check_alias(repo) + assert state is install_guard.AliasState.ABSENT + assert other is None + + def test_owned(self, home_root, tmp_path): + repo = make_repo(tmp_path) + target = ensure_expected_target(repo) + make_alias(home_root, target) + state, other = install_guard.check_alias(repo) + assert state is install_guard.AliasState.OWNED + assert other == target + + def test_cross_repo(self, home_root, tmp_path): + repo = make_repo(tmp_path) + target = other_target(tmp_path) + make_alias(home_root, target) + state, other = install_guard.check_alias(repo) + assert state is install_guard.AliasState.CROSS_REPO + assert other == target + + def test_dangling(self, home_root, tmp_path): + repo = make_repo(tmp_path) + target = tmp_path / "missing" / ".venv" / "bin" / "sol" + make_alias(home_root, target) + state, other = install_guard.check_alias(repo) + assert state is install_guard.AliasState.DANGLING + assert other == target + + def test_not_symlink(self, home_root, tmp_path): + repo = make_repo(tmp_path) + alias = install_guard.alias_path() + alias.parent.mkdir(parents=True, exist_ok=True) + alias.write_text("not a symlink") + state, other = install_guard.check_alias(repo) + assert state is install_guard.AliasState.NOT_SYMLINK + assert other is None + + def test_worktree(self, home_root, tmp_path): + repo = make_repo(tmp_path, worktree=True) + state, other = install_guard.check_alias(repo) + assert state is install_guard.AliasState.WORKTREE + assert other is None + + def test_worktree_takes_precedence(self, home_root, tmp_path): + repo = make_repo(tmp_path, worktree=True) + target = ensure_expected_target(repo) + make_alias(home_root, target) + state, other = install_guard.check_alias(repo) + assert state is install_guard.AliasState.WORKTREE + assert other is None + + +class TestErrorFormat: + def test_worktree(self, home_root, tmp_path, capsys): + repo = make_repo(tmp_path, worktree=True).resolve() + rc = install_guard.cmd_check(repo) + captured = capsys.readouterr() + assert rc == 1 + assert captured.out == "worktree\n" + assert captured.err == worktree_error(repo) + + def test_cross_repo(self, home_root, tmp_path, capsys): + repo = make_repo(tmp_path).resolve() + target = other_target(tmp_path).resolve() + make_alias(home_root, target) + rc = install_guard.cmd_check(repo) + captured = capsys.readouterr() + assert rc == 1 + assert captured.out == "cross_repo\n" + assert captured.err == alias_error(repo, f" installed: {target}") + + def test_dangling(self, home_root, tmp_path, capsys): + repo = make_repo(tmp_path).resolve() + target = (tmp_path / "missing" / ".venv" / "bin" / "sol").resolve() + make_alias(home_root, target) + rc = install_guard.cmd_check(repo) + captured = capsys.readouterr() + assert rc == 1 + assert captured.out == "dangling\n" + assert captured.err == alias_error( + repo, f" installed: dangling: {target} does not exist" + ) + + def test_not_symlink(self, home_root, tmp_path, capsys): + repo = make_repo(tmp_path).resolve() + alias = install_guard.alias_path() + alias.parent.mkdir(parents=True, exist_ok=True) + alias.write_text("not a symlink") + rc = install_guard.cmd_check(repo) + captured = capsys.readouterr() + assert rc == 1 + assert captured.out == "not_symlink\n" + assert captured.err == alias_error(repo, " installed: not a symlink") + + +class TestInstall: + 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 err == "" + assert alias.is_symlink() + assert alias.resolve() == install_guard.expected_target(repo).resolve() + + def test_rewrites_owned_symlink(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path) + original = ensure_expected_target(repo) + alias = make_alias(home_root, original) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + assert rc == 0 + assert out == "installed\n" + assert err == "" + assert alias.is_symlink() + assert alias.resolve() == original.resolve() + + def test_refuses_cross_repo(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path).resolve() + target = other_target(tmp_path).resolve() + alias = make_alias(home_root, target) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + assert rc == 1 + assert out == "" + assert err == alias_error(repo, f" installed: {target}") + assert alias.is_symlink() + assert alias.resolve() == target + + def test_refuses_dangling(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path).resolve() + target = (tmp_path / "missing" / ".venv" / "bin" / "sol").resolve() + alias = make_alias(home_root, target) + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + assert rc == 1 + assert out == "" + assert err == alias_error( + repo, f" installed: dangling: {target} does not exist" + ) + assert alias.is_symlink() + assert Path(os.readlink(alias)).name == "sol" + + def test_refuses_not_symlink(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path).resolve() + alias = install_guard.alias_path() + alias.parent.mkdir(parents=True, exist_ok=True) + alias.write_text("not a symlink") + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + assert rc == 1 + assert out == "" + assert err == alias_error(repo, " installed: not a symlink") + assert alias.read_text() == "not a symlink" + + def test_refuses_worktree(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path, worktree=True).resolve() + rc, out, err = run_main(monkeypatch, capsys, repo, "install") + assert rc == 1 + assert out == "" + assert err == worktree_error(repo) + assert not install_guard.alias_path().exists() + + +class TestUninstall: + def test_removes_owned_alias(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path) + target = ensure_expected_target(repo) + alias = make_alias(home_root, target) + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") + assert rc == 0 + assert out == "removed\n" + assert err == "" + assert not alias.exists() + assert not alias.is_symlink() + + def test_noop_on_absent(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path) + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") + assert rc == 0 + assert out == "absent\n" + assert err == "" + assert not install_guard.alias_path().exists() + + def test_refuses_cross_repo(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path).resolve() + target = other_target(tmp_path).resolve() + alias = make_alias(home_root, target) + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") + assert rc == 1 + assert out == "" + assert err == alias_error(repo, f" installed: {target}") + assert alias.is_symlink() + assert alias.resolve() == target + + def test_refuses_dangling(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path).resolve() + target = (tmp_path / "missing" / ".venv" / "bin" / "sol").resolve() + alias = make_alias(home_root, target) + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") + assert rc == 1 + assert out == "" + assert err == alias_error( + repo, f" installed: dangling: {target} does not exist" + ) + assert alias.is_symlink() + + def test_refuses_not_symlink(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path).resolve() + alias = install_guard.alias_path() + alias.parent.mkdir(parents=True, exist_ok=True) + alias.write_text("not a symlink") + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") + assert rc == 1 + assert out == "" + assert err == alias_error(repo, " installed: not a symlink") + assert alias.read_text() == "not a symlink" + + def test_refuses_worktree(self, home_root, tmp_path, monkeypatch, capsys): + repo = make_repo(tmp_path, worktree=True).resolve() + rc, out, err = run_main(monkeypatch, capsys, repo, "uninstall") + assert rc == 1 + assert out == "" + assert err == worktree_error(repo) + assert not install_guard.alias_path().exists() diff --git a/think/install_guard.py b/think/install_guard.py new file mode 100644 index 000000000..affe653d2 --- /dev/null +++ b/think/install_guard.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Guard user-level sol alias ownership.""" + +from __future__ import annotations + +import os +import sys +from enum import Enum +from pathlib import Path + + +class AliasState(Enum): + WORKTREE = "worktree" + ABSENT = "absent" + OWNED = "owned" + CROSS_REPO = "cross_repo" + DANGLING = "dangling" + NOT_SYMLINK = "not_symlink" + + +def alias_path() -> Path: + return Path.home() / ".local" / "bin" / "sol" + + +def expected_target(curdir: Path) -> Path: + return curdir / ".venv" / "bin" / "sol" + + +def check_alias(curdir: Path) -> tuple[AliasState, Path | None]: + if (curdir / ".git").is_file(): + return AliasState.WORKTREE, None + + alias = alias_path() + if not alias.exists() and not alias.is_symlink(): + return AliasState.ABSENT, None + + if alias.is_symlink(): + target = Path(os.readlink(alias)) + if not target.is_absolute(): + target = alias.parent / target + target = target.resolve() + if not target.exists(): + return AliasState.DANGLING, target + if target == expected_target(curdir).resolve(): + return AliasState.OWNED, target + return AliasState.CROSS_REPO, target + + return AliasState.NOT_SYMLINK, None + + +def format_error( + state: AliasState, + curdir: Path, + _alias: Path, + other_target: Path | None, +) -> str: + if state is AliasState.WORKTREE: + return ( + f"ERROR: refusing to run from a git worktree ({curdir}). " + "Run from the primary clone." + ) + + if state is AliasState.CROSS_REPO: + installed = f" installed: {other_target}" + elif state is AliasState.DANGLING: + installed = f" installed: dangling: {other_target} does not exist" + else: + installed = " installed: not a symlink" + + return "\n".join( + [ + "ERROR: Another solstone install owns ~/.local/bin/sol.", + f" this repo: {curdir}", + installed, + "Run 'make uninstall-service' from the installed repo first,", + "or remove ~/.local/bin/sol manually if that repo is gone. No --force available.", + ] + ) + + +def _print_error( + state: AliasState, + curdir: Path, + alias: Path, + other_target: Path | None, +) -> None: + sys.stderr.write(format_error(state, curdir, alias, other_target) + "\n") + + +def cmd_check(curdir: Path) -> int: + alias = alias_path() + state, other_target = check_alias(curdir) + + if state is AliasState.ABSENT: + print("fresh") + return 0 + if state is AliasState.OWNED: + print("upgrade") + return 0 + + print(state.value) + _print_error(state, curdir, alias, other_target) + return 1 + + +def cmd_install(curdir: Path) -> int: + alias = alias_path() + state, other_target = check_alias(curdir) + + if state is AliasState.WORKTREE: + _print_error(state, curdir, alias, other_target) + return 1 + if state is AliasState.ABSENT: + alias.parent.mkdir(parents=True, exist_ok=True) + alias.symlink_to(expected_target(curdir)) + print("installed") + return 0 + if state is AliasState.OWNED: + alias.unlink() + alias.symlink_to(expected_target(curdir)) + print("installed") + return 0 + + _print_error(state, curdir, alias, other_target) + return 1 + + +def cmd_uninstall(curdir: Path) -> int: + alias = alias_path() + state, other_target = check_alias(curdir) + + if state is AliasState.WORKTREE: + _print_error(state, curdir, alias, other_target) + return 1 + if state is AliasState.ABSENT: + print("absent") + return 0 + if state is AliasState.OWNED: + alias.unlink() + print("removed") + return 0 + + _print_error(state, curdir, alias, other_target) + return 1 + + +def main(argv: list[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + if len(argv) != 1 or argv[0] not in {"check", "install", "uninstall"}: + sys.stderr.write( + "usage: python -m think.install_guard \n" + ) + return 2 + + curdir = Path.cwd().resolve() + if argv[0] == "check": + return cmd_check(curdir) + if argv[0] == "install": + return cmd_install(curdir) + return cmd_uninstall(curdir) + + +if __name__ == "__main__": + sys.exit(main())