linux observer
0
fork

Configure Feed

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

install: replace deploy/upgrade with smart install-service

Single install-service target detects fresh-install vs upgrade via a
~/.config/solstone-linux/.install-source marker and runs CI in upgrade mode.
Marker ownership guard hard-fails on cross-repo contamination and on
pre-hygiene installs with no marker; manual remediation is the documented
recovery path. Drop the --force flag and Unit-unchanged short-circuit from
cmd_install_service — the unit is content-equivalent across clones so
unconditional rewrite is the simpler model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+462 -83
+2 -2
AGENTS.md
··· 53 53 make test-only TEST=tests/test_config.py # Run specific test 54 54 make format # Auto-format with ruff 55 55 make ci # Lint + format check + tests 56 - make deploy # pipx install + install-service (first-time deploy on this machine) 57 - make upgrade # Run CI, then pipx reinstall + restart service 56 + make install-service # Smart install-or-upgrade: guards against cross-repo contamination; runs CI in upgrade mode 58 57 make service-restart # systemctl restart wrapper 59 58 make service-status # systemctl status wrapper 60 59 make service-logs # systemctl log tail wrapper ··· 105 104 - Captures: `~/.local/share/solstone-linux/captures/` 106 105 - State: `~/.local/share/solstone-linux/state/` 107 106 - Restore token: `~/.local/share/solstone-linux/config/restore_token` 107 + - Install source marker: `~/.config/solstone-linux/.install-source` (tracks which repo clone owns the pipx install) 108 108 109 109 ## Key Patterns 110 110
+4 -4
INSTALL.md
··· 41 41 sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire libpulse alsa-lib xdg-desktop-portal pipx 42 42 ``` 43 43 44 - 2. if not already cloned, clone into solstone's observers directory and deploy: 44 + 2. if not already cloned, clone into solstone's observers directory and install: 45 45 ``` 46 46 cd "$(sol root)/observers" 47 47 git clone https://github.com/solpbc/solstone-linux.git 48 48 cd solstone-linux 49 - make deploy 49 + make install-service 50 50 ``` 51 - `make deploy` installs with pipx using `--system-site-packages`, then installs and starts the user service. 51 + `make install-service` is a smart install-or-upgrade: detects fresh-install vs upgrade via a marker file, runs CI in upgrade mode, guards against cross-repo contamination. 52 52 53 53 3. run the interactive setup: 54 54 ``` ··· 64 64 ## updating after a code change 65 65 66 66 ``` 67 - git pull && make upgrade 67 + git pull && make install-service 68 68 ``` 69 69 70 70 ## notes
+17 -12
Makefile
··· 1 1 # solstone-linux Makefile 2 2 # Standalone Linux desktop observer for solstone 3 3 4 - .PHONY: install test test-only format ci clean clean-install versions all deploy upgrade service-restart service-status service-logs uninstall-service 4 + .PHONY: install test test-only format ci clean clean-install versions all install-service service-restart service-status service-logs uninstall-service 5 5 6 6 # Default target 7 7 all: install ··· 30 30 # Install package in editable mode with isolated venv 31 31 install: .installed 32 32 33 - deploy: 33 + install-service: .installed 34 34 @command -v pipx >/dev/null || { echo "pipx not found — install with: sudo dnf install pipx (or apt/brew equivalent)"; exit 1; } 35 + @$(PYTHON) -m solstone_linux.install_guard preinstall "$(CURDIR)"; rc=$$?; \ 36 + if [ $$rc -eq 2 ]; then exit 1; \ 37 + elif [ $$rc -eq 10 ]; then $(MAKE) ci; \ 38 + fi 35 39 # Editable installs (pipx install -e .) are deliberately avoided: pipx treats editable installs differently and system-site-packages behavior is unreliable with them. 36 40 pipx install --force $(PIPX_FLAGS) . 41 + $(PYTHON) -m solstone_linux.install_guard write "$(CURDIR)" 37 42 $(APP) install-service 38 - systemctl --user --no-pager status $(UNIT) | head 39 - 40 - upgrade: ci 41 - pipx install --force $(PIPX_FLAGS) . 42 - systemctl --user daemon-reload 43 - systemctl --user restart $(UNIT) 44 - systemctl --user --no-pager status $(UNIT) | head 43 + systemctl --user status $(UNIT) --no-pager -l | head -n 20 || true 45 44 46 45 service-restart: 47 46 systemctl --user restart $(UNIT) ··· 52 51 service-logs: 53 52 journalctl --user -u $(UNIT) -n 100 --no-pager -f 54 53 55 - uninstall-service: 56 - -systemctl --user disable --now $(UNIT) 57 - -rm -f $$HOME/.config/systemd/user/$(UNIT) 54 + uninstall-service: .installed 55 + @$(PYTHON) -m solstone_linux.install_guard preuninstall "$(CURDIR)"; rc=$$?; \ 56 + if [ $$rc -eq 2 ]; then exit 1; \ 57 + elif [ $$rc -eq 0 ]; then exit 0; \ 58 + fi 59 + -systemctl --user stop $(UNIT) 60 + -systemctl --user disable $(UNIT) 61 + -rm -f $(HOME)/.config/systemd/user/$(UNIT) 58 62 -systemctl --user daemon-reload 59 63 -pipx uninstall $(APP) 64 + $(PYTHON) -m solstone_linux.install_guard remove 60 65 61 66 # Venv tool shortcuts 62 67 PYTEST := $(VENV_BIN)/pytest
+1 -1
README.md
··· 28 28 ```bash 29 29 git clone https://github.com/solpbc/solstone-linux.git 30 30 cd solstone-linux 31 - make deploy 31 + make install-service 32 32 solstone-linux setup 33 33 ``` 34 34
+29 -41
src/solstone_linux/cli.py
··· 161 161 .read_text() 162 162 ) 163 163 unit = template.replace("{BINARY}", binary).replace("{PATH}", service_path) 164 - existing = unit_path.read_text() if unit_path.exists() else None 164 + unit_dir.mkdir(parents=True, exist_ok=True) 165 + unit_path.write_text(unit) 166 + print(f"Wrote {unit_path}") 165 167 166 - if existing == unit and not args.force: 167 - print("Unit unchanged; nothing to do") 168 - else: 169 - unit_dir.mkdir(parents=True, exist_ok=True) 170 - unit_path.write_text(unit) 171 - print(f"Wrote {unit_path}") 172 - 173 - # Reload, enable, restart, and show status 174 - try: 175 - subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 176 - subprocess.run( 177 - ["systemctl", "--user", "enable", "--now", "solstone-linux.service"], 178 - check=True, 179 - ) 180 - subprocess.run( 181 - ["systemctl", "--user", "restart", "solstone-linux.service"], 182 - check=True, 183 - ) 184 - subprocess.run( 185 - [ 186 - "systemctl", 187 - "--user", 188 - "--no-pager", 189 - "status", 190 - "solstone-linux.service", 191 - ], 192 - check=False, 193 - ) 194 - except FileNotFoundError: 195 - print("Warning: systemctl not found. Enable the service manually.") 196 - except subprocess.CalledProcessError as e: 197 - print(f"Warning: systemctl command failed: {e}") 168 + # Reload, enable, restart, and show status 169 + try: 170 + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 171 + subprocess.run( 172 + ["systemctl", "--user", "enable", "--now", "solstone-linux.service"], 173 + check=True, 174 + ) 175 + subprocess.run( 176 + ["systemctl", "--user", "restart", "solstone-linux.service"], 177 + check=True, 178 + ) 179 + subprocess.run( 180 + [ 181 + "systemctl", 182 + "--user", 183 + "--no-pager", 184 + "status", 185 + "solstone-linux.service", 186 + ], 187 + check=False, 188 + ) 189 + except FileNotFoundError: 190 + print("Warning: systemctl not found. Enable the service manually.") 191 + except subprocess.CalledProcessError as e: 192 + print(f"Warning: systemctl command failed: {e}") 198 193 199 194 icon_source = Path(__file__).resolve().parent / "icons" / "hicolor" 200 195 if icon_source.is_dir(): ··· 328 323 subparsers.add_parser("setup", help="Interactive configuration") 329 324 330 325 # install-service 331 - install_svc = subparsers.add_parser( 332 - "install-service", help="Install systemd user service" 333 - ) 334 - install_svc.add_argument( 335 - "--force", 336 - action="store_true", 337 - help="Always rewrite the unit file and restart the service, even if unchanged", 338 - ) 326 + subparsers.add_parser("install-service", help="Install systemd user service") 339 327 340 328 # status 341 329 subparsers.add_parser("status", help="Show capture and sync state")
+210
src/solstone_linux/install_guard.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + """Install ownership guard for pipx-managed service installs.""" 4 + 5 + from __future__ import annotations 6 + 7 + import sys 8 + from enum import Enum 9 + from pathlib import Path 10 + 11 + MARKER_REL = Path(".config/solstone-linux/.install-source") 12 + PIPX_BIN_REL = Path(".local/bin/solstone-linux") 13 + 14 + 15 + def marker_path() -> Path: 16 + return Path.home() / MARKER_REL 17 + 18 + 19 + def pipx_bin_path() -> Path: 20 + return Path.home() / PIPX_BIN_REL 21 + 22 + 23 + class State(str, Enum): 24 + ABSENT = "ABSENT" 25 + OWNED = "OWNED" 26 + CROSS_REPO = "CROSS_REPO" 27 + PARTIAL_OWNED = "PARTIAL_OWNED" 28 + UNKNOWN = "UNKNOWN" 29 + 30 + 31 + def _parse_marker() -> Path | None: 32 + try: 33 + raw = marker_path().read_text(encoding="utf-8") 34 + except OSError: 35 + return None 36 + 37 + stripped = raw.strip() 38 + if not stripped: 39 + return None 40 + 41 + lines = stripped.splitlines() 42 + if len(lines) != 1: 43 + return None 44 + 45 + candidate = Path(lines[0].strip()) 46 + if not candidate.is_absolute(): 47 + return None 48 + 49 + return candidate.resolve() 50 + 51 + 52 + def check(curdir: Path) -> tuple[State, Path | None]: 53 + resolved_curdir = curdir.resolve() 54 + marker = marker_path() 55 + pipx_bin_present = pipx_bin_path().exists() 56 + 57 + if not marker.exists(): 58 + if not pipx_bin_present: 59 + return (State.ABSENT, None) 60 + return (State.UNKNOWN, None) 61 + 62 + owner = _parse_marker() 63 + if owner is None: 64 + return (State.UNKNOWN, None) 65 + if owner != resolved_curdir: 66 + return (State.CROSS_REPO, owner) 67 + if pipx_bin_present: 68 + return (State.OWNED, owner) 69 + return (State.PARTIAL_OWNED, owner) 70 + 71 + 72 + def write_marker(curdir: Path) -> None: 73 + path = marker_path() 74 + path.parent.mkdir(parents=True, exist_ok=True) 75 + path.write_text(f"{curdir.resolve()}\n", encoding="utf-8") 76 + 77 + 78 + def remove_marker() -> None: 79 + marker_path().unlink(missing_ok=True) 80 + 81 + 82 + def _unknown_reason() -> str: 83 + if marker_path().exists(): 84 + return ".install-source marker is malformed" 85 + return "no .install-source marker — likely pre-hygiene install" 86 + 87 + 88 + def _print_cross_repo_error(curdir: Path, owner: Path | None, uninstall: bool) -> None: 89 + lines = [ 90 + "error: cross-repo contamination detected", 91 + f"current repo: {curdir.resolve()}", 92 + f"installed from: {owner}", 93 + "", 94 + "To recover, run from the installed repo:", 95 + " make uninstall-service", 96 + "Or manually:", 97 + ] 98 + if uninstall: 99 + lines.extend( 100 + [ 101 + " systemctl --user stop solstone-linux.service", 102 + " systemctl --user disable solstone-linux.service", 103 + " rm -f ~/.config/systemd/user/solstone-linux.service", 104 + ] 105 + ) 106 + lines.extend( 107 + [ 108 + " pipx uninstall solstone-linux", 109 + " rm ~/.config/solstone-linux/.install-source", 110 + ] 111 + ) 112 + print("\n".join(lines), file=sys.stderr) 113 + 114 + 115 + def _print_unknown_error(uninstall: bool) -> None: 116 + lines = [ 117 + f"error: installed: unknown ({_unknown_reason()})", 118 + "", 119 + "To recover:", 120 + ] 121 + if uninstall: 122 + lines.extend( 123 + [ 124 + " systemctl --user stop solstone-linux.service", 125 + " systemctl --user disable solstone-linux.service", 126 + " rm -f ~/.config/systemd/user/solstone-linux.service", 127 + ] 128 + ) 129 + lines.extend( 130 + [ 131 + " pipx uninstall solstone-linux", 132 + " rm -f ~/.config/solstone-linux/.install-source", 133 + "Then re-run make install-service.", 134 + ] 135 + ) 136 + print("\n".join(lines), file=sys.stderr) 137 + 138 + 139 + def _preinstall(curdir: Path) -> int: 140 + state, owner = check(curdir) 141 + if state is State.ABSENT: 142 + print("mode: fresh install") 143 + return 0 144 + if state is State.OWNED: 145 + print("mode: upgrade") 146 + return 10 147 + if state is State.PARTIAL_OWNED: 148 + print( 149 + "warning: .install-source marker present but pipx binary missing — reinstalling" 150 + ) 151 + print("mode: upgrade") 152 + return 10 153 + if state is State.CROSS_REPO: 154 + print("mode: aborted — cross-repo contamination") 155 + _print_cross_repo_error(curdir, owner, uninstall=False) 156 + return 2 157 + 158 + print("mode: aborted — unknown install state") 159 + _print_unknown_error(uninstall=False) 160 + return 2 161 + 162 + 163 + def _preuninstall(curdir: Path) -> int: 164 + state, owner = check(curdir) 165 + if state is State.ABSENT: 166 + print("no artifacts to remove") 167 + return 0 168 + if state in {State.OWNED, State.PARTIAL_OWNED}: 169 + return 10 170 + if state is State.CROSS_REPO: 171 + print("mode: aborted — cross-repo contamination") 172 + _print_cross_repo_error(curdir, owner, uninstall=True) 173 + return 2 174 + 175 + print("mode: aborted — unknown install state") 176 + _print_unknown_error(uninstall=True) 177 + return 2 178 + 179 + 180 + def main() -> int: 181 + if len(sys.argv) < 2: 182 + print( 183 + "usage: install_guard <preinstall|preuninstall|write|remove> [curdir]", 184 + file=sys.stderr, 185 + ) 186 + return 2 187 + 188 + command = sys.argv[1] 189 + if command == "remove": 190 + remove_marker() 191 + return 0 192 + 193 + if command in {"preinstall", "preuninstall", "write"}: 194 + if len(sys.argv) != 3: 195 + print(f"usage: install_guard {command} <curdir>", file=sys.stderr) 196 + return 2 197 + curdir = Path(sys.argv[2]) 198 + if command == "preinstall": 199 + return _preinstall(curdir) 200 + if command == "preuninstall": 201 + return _preuninstall(curdir) 202 + write_marker(curdir) 203 + return 0 204 + 205 + print(f"unknown command: {command}", file=sys.stderr) 206 + return 2 207 + 208 + 209 + if __name__ == "__main__": 210 + sys.exit(main())
+7 -23
tests/test_cli.py
··· 10 10 from solstone_linux.cli import cmd_install_service 11 11 12 12 13 - def _args(force: bool = False) -> argparse.Namespace: 14 - return argparse.Namespace(force=force) 13 + def _args() -> argparse.Namespace: 14 + return argparse.Namespace() 15 15 16 16 17 17 _REAL_IS_DIR = Path.is_dir ··· 98 98 ) 99 99 100 100 101 - def test_cmd_install_service_unchanged_is_noop(tmp_path: Path, capsys): 102 - binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" 103 - 104 - with patch.dict(os.environ, {"PATH": "/usr/local/bin:/usr/bin:/bin"}, clear=True): 105 - with patch("solstone_linux.cli.shutil.which", return_value=binary): 106 - with patch("solstone_linux.cli.Path.home", return_value=tmp_path): 107 - with patch("solstone_linux.cli.subprocess.run") as run_mock: 108 - with patch("solstone_linux.cli.Path.is_dir", return_value=False): 109 - assert cmd_install_service(_args()) == 0 110 - first_call_count = run_mock.call_count 111 - assert cmd_install_service(_args()) == 0 112 - 113 - captured = capsys.readouterr() 114 - assert "Unit unchanged; nothing to do" in captured.out 115 - assert run_mock.call_count == first_call_count 116 - 117 - 118 - def test_cmd_install_service_force_always_writes(tmp_path: Path): 101 + def test_cmd_install_service_always_rewrites(tmp_path: Path, capsys): 119 102 binary = "/home/user/.local/pipx/venvs/solstone-linux/bin/solstone-linux" 120 103 121 104 with patch.dict(os.environ, {"PATH": "/usr/local/bin:/usr/bin:/bin"}, clear=True): ··· 128 111 side_effect=_is_dir_without_icons, 129 112 ): 130 113 assert cmd_install_service(_args()) == 0 131 - first_call_count = run_mock.call_count 132 - assert cmd_install_service(_args(force=True)) == 0 114 + assert cmd_install_service(_args()) == 0 133 115 134 - assert run_mock.call_count == first_call_count + 4 116 + captured = capsys.readouterr() 117 + assert "nothing to do" not in captured.out.lower() 118 + assert run_mock.call_count == 8
+192
tests/test_install_guard.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import sys 5 + from pathlib import Path 6 + 7 + from solstone_linux import install_guard 8 + from solstone_linux.install_guard import State 9 + 10 + 11 + def _set_home(monkeypatch, tmp_path: Path) -> None: 12 + monkeypatch.setattr(install_guard.Path, "home", lambda: tmp_path) 13 + 14 + 15 + def _run_main(monkeypatch, *argv: str) -> int: 16 + monkeypatch.setattr(sys, "argv", ["install_guard", *argv]) 17 + return install_guard.main() 18 + 19 + 20 + def test_state_absent(tmp_path: Path, monkeypatch, capsys): 21 + _set_home(monkeypatch, tmp_path) 22 + curdir = tmp_path / "repo" 23 + curdir.mkdir() 24 + 25 + assert install_guard.check(curdir) == (State.ABSENT, None) 26 + 27 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 0 28 + captured = capsys.readouterr() 29 + assert captured.out == "mode: fresh install\n" 30 + assert captured.err == "" 31 + 32 + assert _run_main(monkeypatch, "preuninstall", str(curdir)) == 0 33 + captured = capsys.readouterr() 34 + assert captured.out == "no artifacts to remove\n" 35 + assert captured.err == "" 36 + 37 + 38 + def test_state_unknown_pre_hygiene(tmp_path: Path, monkeypatch, capsys): 39 + _set_home(monkeypatch, tmp_path) 40 + curdir = tmp_path / "repo" 41 + curdir.mkdir() 42 + install_guard.pipx_bin_path().parent.mkdir(parents=True) 43 + install_guard.pipx_bin_path().touch() 44 + 45 + assert install_guard.check(curdir) == (State.UNKNOWN, None) 46 + 47 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 48 + captured = capsys.readouterr() 49 + assert captured.out == "mode: aborted — unknown install state\n" 50 + assert ( 51 + "error: installed: unknown (no .install-source marker — likely pre-hygiene install)\n" 52 + in captured.err 53 + ) 54 + 55 + 56 + def test_state_owned_install_mode(tmp_path: Path, monkeypatch, capsys): 57 + _set_home(monkeypatch, tmp_path) 58 + curdir = tmp_path / "repo" 59 + curdir.mkdir() 60 + install_guard.write_marker(curdir) 61 + install_guard.pipx_bin_path().parent.mkdir(parents=True) 62 + install_guard.pipx_bin_path().touch() 63 + 64 + assert install_guard.check(curdir) == (State.OWNED, curdir.resolve()) 65 + 66 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 10 67 + captured = capsys.readouterr() 68 + assert captured.out == "mode: upgrade\n" 69 + assert captured.err == "" 70 + 71 + 72 + def test_state_owned_uninstall_mode(tmp_path: Path, monkeypatch, capsys): 73 + _set_home(monkeypatch, tmp_path) 74 + curdir = tmp_path / "repo" 75 + curdir.mkdir() 76 + install_guard.write_marker(curdir) 77 + install_guard.pipx_bin_path().parent.mkdir(parents=True) 78 + install_guard.pipx_bin_path().touch() 79 + 80 + assert _run_main(monkeypatch, "preuninstall", str(curdir)) == 10 81 + captured = capsys.readouterr() 82 + assert captured.out == "" 83 + assert captured.err == "" 84 + 85 + 86 + def test_state_cross_repo(tmp_path: Path, monkeypatch, capsys): 87 + _set_home(monkeypatch, tmp_path) 88 + curdir = tmp_path / "repo" 89 + other = tmp_path / "other" 90 + curdir.mkdir() 91 + other.mkdir() 92 + install_guard.write_marker(other) 93 + install_guard.pipx_bin_path().parent.mkdir(parents=True) 94 + install_guard.pipx_bin_path().touch() 95 + 96 + assert install_guard.check(curdir) == (State.CROSS_REPO, other.resolve()) 97 + 98 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 99 + captured = capsys.readouterr() 100 + assert captured.out == "mode: aborted — cross-repo contamination\n" 101 + assert "error: cross-repo contamination detected\n" in captured.err 102 + assert f"current repo: {curdir.resolve()}\n" in captured.err 103 + assert f"installed from: {other.resolve()}\n" in captured.err 104 + 105 + 106 + def test_state_partial_owned(tmp_path: Path, monkeypatch, capsys): 107 + _set_home(monkeypatch, tmp_path) 108 + curdir = tmp_path / "repo" 109 + curdir.mkdir() 110 + install_guard.write_marker(curdir) 111 + 112 + assert install_guard.check(curdir) == (State.PARTIAL_OWNED, curdir.resolve()) 113 + 114 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 10 115 + captured = capsys.readouterr() 116 + assert ( 117 + captured.out 118 + == "warning: .install-source marker present but pipx binary missing — reinstalling\nmode: upgrade\n" 119 + ) 120 + assert captured.err == "" 121 + 122 + 123 + def test_malformed_marker_empty(tmp_path: Path, monkeypatch, capsys): 124 + _set_home(monkeypatch, tmp_path) 125 + curdir = tmp_path / "repo" 126 + curdir.mkdir() 127 + install_guard.marker_path().parent.mkdir(parents=True) 128 + install_guard.marker_path().write_text("", encoding="utf-8") 129 + install_guard.pipx_bin_path().parent.mkdir(parents=True) 130 + install_guard.pipx_bin_path().touch() 131 + 132 + assert install_guard.check(curdir) == (State.UNKNOWN, None) 133 + 134 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 135 + captured = capsys.readouterr() 136 + assert ( 137 + "error: installed: unknown (.install-source marker is malformed)\n" 138 + in captured.err 139 + ) 140 + 141 + 142 + def test_malformed_marker_multiline(tmp_path: Path, monkeypatch, capsys): 143 + _set_home(monkeypatch, tmp_path) 144 + curdir = tmp_path / "repo" 145 + curdir.mkdir() 146 + install_guard.marker_path().parent.mkdir(parents=True) 147 + install_guard.marker_path().write_text("/one\n/two\n", encoding="utf-8") 148 + install_guard.pipx_bin_path().parent.mkdir(parents=True) 149 + install_guard.pipx_bin_path().touch() 150 + 151 + assert install_guard.check(curdir) == (State.UNKNOWN, None) 152 + 153 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 154 + captured = capsys.readouterr() 155 + assert ( 156 + "error: installed: unknown (.install-source marker is malformed)\n" 157 + in captured.err 158 + ) 159 + 160 + 161 + def test_malformed_marker_not_absolute_path(tmp_path: Path, monkeypatch, capsys): 162 + _set_home(monkeypatch, tmp_path) 163 + curdir = tmp_path / "repo" 164 + curdir.mkdir() 165 + install_guard.marker_path().parent.mkdir(parents=True) 166 + install_guard.marker_path().write_text("relative/path\n", encoding="utf-8") 167 + install_guard.pipx_bin_path().parent.mkdir(parents=True) 168 + install_guard.pipx_bin_path().touch() 169 + 170 + assert install_guard.check(curdir) == (State.UNKNOWN, None) 171 + 172 + assert _run_main(monkeypatch, "preinstall", str(curdir)) == 2 173 + captured = capsys.readouterr() 174 + assert ( 175 + "error: installed: unknown (.install-source marker is malformed)\n" 176 + in captured.err 177 + ) 178 + 179 + 180 + def test_write_and_remove_marker(tmp_path: Path, monkeypatch): 181 + _set_home(monkeypatch, tmp_path) 182 + curdir = tmp_path / "repo" 183 + curdir.mkdir() 184 + 185 + assert _run_main(monkeypatch, "write", str(curdir)) == 0 186 + assert ( 187 + install_guard.marker_path().read_text(encoding="utf-8") 188 + == f"{curdir.resolve()}\n" 189 + ) 190 + 191 + assert _run_main(monkeypatch, "remove") == 0 192 + assert not install_guard.marker_path().exists()