diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 91c5fb51..d8d91e64 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -187,7 +187,8 @@ What it does, in order: - `users.toml`: family roster, gitignored. - `.stack/secrets.toml`: auto-generated passwords for service accounts. Treat like a password manager export. 7. **Brings up `messages` and `core`.** Pulls Synapse, Element and Postgres images (about 600 MB), starts them, creates Matrix accounts, seeds two default rooms (`#famchat`, `#famstack`). -8. **Prints a sign-in URL.** Something like `http://192.168.1.42:42030`. +8. **Installs the `famstack` command.** A small script in Homebrew's `bin` directory that runs this checkout's `./stack`, so `famstack status` works from any directory. If another command called `famstack` is already on your PATH, the installer leaves it alone and the final screen says `./stack` instead. +9. **Prints a sign-in URL.** Something like `http://192.168.1.42:42030`. Total time on a fresh Mac with a decent connection: 5 to 10 minutes. @@ -1042,7 +1043,7 @@ The backup stacklet does not cover this yet. It archives Paperless's files but n ./stack uninstall ``` -Stops every stacklet and deletes its data. Asks for confirmation. There is no undo. +Stops every stacklet and deletes its data. Asks for confirmation. There is no undo. It also removes the `famstack` command the installer put on your PATH, and nothing else of that name. For a single stacklet: diff --git a/docs/stack-reference.md b/docs/stack-reference.md index c197464c..ea96cbb2 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -1269,7 +1269,11 @@ One repo can power more than one stack instance. A stacklet definition state, data) is swappable. - **Repo root** — holds `stacklets/` and `lib/`. Discovered by walking - up from the current working directory. + up from the current working directory; outside any checkout, the + checkout the CLI runs from. That is how the installer's global + `famstack` command (in Homebrew's `bin`) works from any directory. It + names only the checkout, so `STACK_DIR` selects the instance through + it as it does through `./stack`. - **Instance dir** — holds `stack.toml`, `users.toml`, `.stack/`. Holds the data referenced by `[core].data_dir`. Defaults to the repo root. diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 529b6c56..0dd98aa8 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -24,6 +24,7 @@ from . import caddy from . import docker from . import doctor +from . import global_command from .commands import COMMANDS from .prompt import ORANGE, TEAL, GREEN, RED, DIM, BOLD, RESET from .stack import Stack @@ -598,12 +599,17 @@ def _reverse_dependency_order(stacklets: list[dict], include: set[str]) -> list[ # ── Repo discovery ──────────────────────────────────────────────────────── def find_repo_root() -> Path | None: - """Walk up from CWD to find the repo root (has stacklets/ dir).""" + """Walk up from CWD to find the repo root (has stacklets/ dir). + + Outside any checkout, the checkout this code runs from, so a global + command started from another directory still finds its stacklets. + """ here = Path.cwd() for candidate in [here, *here.parents]: if (candidate / "stacklets").is_dir(): return candidate - return None + own = Path(__file__).resolve().parents[2] + return own if (own / "stacklets").is_dir() else None def find_instance_dir() -> Path | None: @@ -1501,11 +1507,11 @@ def handle_uninstall(stck, args): print(f" {GREEN}\u2713{RESET} {s['name']} uninstalled") # Remove config files - for name in ("stack.toml", "users.toml"): - path = stck.root / name + for filename in ("stack.toml", "users.toml"): + path = stck.root / filename if path.exists(): path.unlink() - print(f" {GREEN}\u2713{RESET} Removed {name}") + print(f" {GREEN}\u2713{RESET} Removed {filename}") # Remove runtime state. With exclusions, wipe entry by entry so the # setup markers of the preserved stacklets (and anything else they @@ -1527,6 +1533,13 @@ def handle_uninstall(stck, args): shutil.rmtree(state_dir) print(f" {GREEN}\u2713{RESET} Removed .stack/") + # The installer puts the global command in place for the checkout's + # own instance, so only uninstalling that instance takes it away. A + # STACK_DIR instance (a test run, a sandbox) leaves it for the others. + if stck.instance_dir == stck.root and (bin_dir := global_command.bin_dir()): + if global_command.uninstall(stck.root, bin_dir): + print(f" {GREEN}\u2713{RESET} Removed the {global_command.NAME} command") + # Offer to remove data if stck.data.exists(): home = str(Path.home()) diff --git a/lib/stack/global_command.py b/lib/stack/global_command.py new file mode 100644 index 00000000..3660f331 --- /dev/null +++ b/lib/stack/global_command.py @@ -0,0 +1,120 @@ +"""The global `famstack` command on PATH, next to `./stack`. + +`./stack` finds its checkout with `dirname "$0"`, so a symlink to it +would look for `lib/` next to the symlink. The global command is a +small script that execs the checkout's launcher instead. + +It names only the checkout. Which instance a command runs against stays +the CLI's decision (`STACK_DIR`, else the checkout), so one script serves +every instance the caller selects. + +The name is fixed, not taken from `[core] name`: docs, hook messages +and support have to be able to state the command, and renaming the +product in one instance must not rename the command under it. + +A command of the same name that is not ours is never touched: install +leaves it in place, uninstall leaves it in place, and the caller falls +back to printing `./stack`. +""" + +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +from pathlib import Path + +NAME = "famstack" + +FREE = "free" # nothing answers to the name +OURS = "ours" # the script for this checkout answers to it +FOREIGN = "foreign" # something else does, or something else sits in the way + + +def script(checkout: Path) -> str: + """The script installed for `checkout`, byte for byte. + + Ownership is decided by comparing a file with this text, so the text + must only depend on the checkout. + """ + launcher = shlex.quote(str(Path(checkout).resolve() / "stack")) + return ( + "#!/bin/sh\n" + "# Installed by the stack installer; its uninstall removes it.\n" + f'exec {launcher} "$@"\n' + ) + + +def bin_dir() -> Path | None: + """Homebrew's bin directory, or None without Homebrew. + + The stack requires Homebrew, whose bin directory is on its users' + PATH and writable without sudo. The prefix differs between machines, + so it is asked for rather than assumed. + """ + brew = shutil.which("brew") + if not brew: + return None + try: + result = subprocess.run([brew, "--prefix"], capture_output=True, text=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): + return None + prefix = result.stdout.strip() + return Path(prefix) / "bin" if result.returncode == 0 and prefix else None + + +def state(checkout: Path, directory: Path, path: str | None = None) -> str: + """Who answers to the name: FREE, OURS or FOREIGN. + + `path` is the search path, defaulting to this process's PATH. A + command earlier on it shadows ours, so it counts as foreign even + when our script sits in `directory`. + """ + target = directory / NAME + found = shutil.which(NAME, path=path) + if found and os.path.realpath(found) != os.path.realpath(target): + return FOREIGN + if os.path.lexists(target): + return OURS if is_ours(target, checkout) else FOREIGN + return FREE + + +def is_ours(target: Path, checkout: Path) -> bool: + try: + return not target.is_symlink() and target.read_text() == script(checkout) + except (OSError, UnicodeDecodeError): + return False + + +def install(checkout: Path, directory: Path, path: str | None = None) -> bool: + """Put the command in `directory` for `checkout` if the name is free. + + Returns True when the command runs this checkout afterwards. Idempotent: + a second call finds the script in place and writes nothing. + """ + current = state(checkout, directory, path) + if current != FREE: + return current == OURS + target = directory / NAME + try: + target.write_text(script(checkout)) + target.chmod(0o755) + except OSError: + return False + return True + + +def uninstall(checkout: Path, directory: Path) -> bool: + """Remove the command from `directory` if it is the script for `checkout`. + + Returns True when something was removed. + """ + target = directory / NAME + if not is_ours(target, checkout): + return False + try: + target.unlink() + except OSError: + return False + return True diff --git a/lib/stack/installer.py b/lib/stack/installer.py index 1ec1c7d6..de38dd53 100644 --- a/lib/stack/installer.py +++ b/lib/stack/installer.py @@ -138,6 +138,20 @@ def _create_stack(): return create_stack(REPO_ROOT) +def _install_global_command(stck) -> str: + """Put `famstack` on PATH and return what to tell the admin to type. + + `famstack` when it now runs this checkout, `./stack` when another + command of that name is in the way or there is nowhere to put it. + The final screen prints whichever works on this Mac. + """ + from . import global_command + directory = global_command.bin_dir() + if directory and global_command.install(stck.root, directory): + return global_command.NAME + return "./stack" + + def _has_brew() -> bool: import shutil return shutil.which("brew") is not None @@ -599,6 +613,8 @@ def wizard(): nl() + command = _install_global_command(stck) + # ── Done ────────────────────────────────────────────────────────── clear() @@ -641,14 +657,19 @@ def wizard(): rule() heading("Add more to your stack") - out(f" {TEAL}stack up photos{RESET} Private photo library") - out(f" {TEAL}stack up docs{RESET} Document archive with OCR") - out(f" {TEAL}stack up ai{RESET} Local AI engine") - out(f" {TEAL}stack ai connect{RESET} AI on another machine or a hosted provider") - out(f" {TEAL}stack up code{RESET} Private git server") - out(f" {TEAL}stack up memory{RESET} Family wiki and curated knowledge") - nl() - out(f" {TEAL}stack status{RESET} See what's running") + more = [ + ("up photos", "Private photo library"), + ("up docs", "Document archive with OCR"), + ("up ai", "Local AI engine"), + ("ai connect", "AI on another machine or a hosted provider"), + ("up code", "Private git server"), + ("up memory", "Family wiki and curated knowledge"), + ] + width = max(len(f"{command} {args}") for args, _ in [*more, ("status", "")]) + 4 + for args, what in more: + out(f" {TEAL}{f'{command} {args}':<{width}}{RESET}{what}") + nl() + out(f" {TEAL}{f'{command} status':<{width}}{RESET}See what's running") nl() dim(" Service admin password is in .stack/secrets.toml") diff --git a/tests/e2e/install/test_first_install.py b/tests/e2e/install/test_first_install.py index ee57301e..18d833dc 100644 --- a/tests/e2e/install/test_first_install.py +++ b/tests/e2e/install/test_first_install.py @@ -409,21 +409,18 @@ def test_the_printed_commands_name_real_commands(install): assert not broken, "\n".join(broken) -@pytest.mark.xfail( - strict=True, - reason="nothing puts the printed command on PATH yet (workspace decision 0013)", -) def test_the_printed_commands_work_as_typed_in_a_new_terminal(install): """Typed exactly as printed, in a new login shell, each command runs. - `status` runs for real. The others would install services, so they - are asked for their help instead, which proves the command and the - stacklet resolve. + A new terminal opens in the home directory, not in the checkout, so + that is where they run. `status` runs for real. The others would + install services, so they are asked for their help instead, which + proves the command and the stacklet resolve. """ broken = [] for command in printed_commands(install): check = command if command.split()[1:] == ["status"] else f"{command} --help" - result = login_shell(check, CLONE) + result = login_shell(check, Path.home()) if result.returncode != 0: broken.append(f"{command!r}: {(result.stderr or result.stdout).strip()[-300:]}") assert not broken, "\n".join(broken) diff --git a/tests/e2e/remote b/tests/e2e/remote index 63357d50..040aa11c 100755 --- a/tests/e2e/remote +++ b/tests/e2e/remote @@ -201,7 +201,7 @@ run_install() { reset_install() { local clone="$base/install" data="$HOME/famstack-data" owns="$base/install-owns-data" local plist="$HOME/Library/LaunchAgents/dev.famstack.api.plist" - local pid ids net left="" + local pid ids net wrapper left="" # An installer outliving a dropped session would bring the stack back # up behind the reset. pexpect gave it its own session, so its pid is @@ -225,6 +225,13 @@ reset_install() { rm -f "$plist" fi + # The installer's global command. Only ours: it execs a launcher + # under the base dir. Anyone else's of that name stays. + wrapper="$(brew --prefix)/bin/famstack" + if [[ -f "$wrapper" ]] && grep -q "$base/" "$wrapper"; then + rm -f "$wrapper" + fi + if [[ -e "$data" && -f "$owns" ]]; then rm -rf "$data" || true fi @@ -242,6 +249,7 @@ reset_install() { [[ -e "$plist" ]] && left+=" $plist, which points outside this lane's paths"$'\n' launchctl list 2>/dev/null | grep -q 'dev\.famstack\.api$' && left+=" the loaded dev.famstack.api agent"$'\n' [[ -e "$clone" ]] && left+=" $clone"$'\n' + [[ -e "$wrapper" ]] && grep -q "$base/" "$wrapper" && left+=" $wrapper"$'\n' if [[ -n "$left" ]]; then printf 'remote: still on the Mac:\n%s' "$left" >&2 return 1 diff --git a/tests/unit/framework/test_global_command.py b/tests/unit/framework/test_global_command.py new file mode 100644 index 00000000..21eb61f9 --- /dev/null +++ b/tests/unit/framework/test_global_command.py @@ -0,0 +1,164 @@ +"""The product's command on PATH, next to `./stack`. + +After a fresh install an admin types `famstack status` in any terminal, +in any directory, and gets what `./stack status` gives in the checkout. +The installer puts a small script in Homebrew's bin directory for that. + +That directory is shared with everything else Homebrew users install, so +the rules are about ownership: a free name is taken, our own script is +left as it is, and anyone else's command of that name is never written +over or removed. Which instance a command runs against is not the +script's business: `STACK_DIR` reaches the CLI unchanged. + +Every test here uses a bin directory under tmp_path and a search path +made of tmp directories, so the machine's real PATH never decides a result. +""" + +import os +import subprocess +import sys +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +import pytest + +REPO = Path(__file__).parent.parent.parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from stack import global_command +from stack.global_command import FOREIGN, FREE, NAME, OURS + + +@pytest.fixture +def bin_dir(tmp_path): + d = tmp_path / "bin" + d.mkdir() + return d + + +def _foreign(directory: Path) -> Path: + """Somebody else's command of the same name.""" + other = directory / NAME + other.write_text("#!/bin/sh\necho somebody else's famstack\n") + other.chmod(0o755) + return other + + +# ── Installing ────────────────────────────────────────────────────────── + +class TestInstall: + + def test_a_free_name_is_taken(self, tmp_path, bin_dir): + assert global_command.state(REPO, bin_dir, path=str(bin_dir)) == FREE + assert global_command.install(REPO, bin_dir, path=str(bin_dir)) + assert global_command.state(REPO, bin_dir, path=str(bin_dir)) == OURS + assert os.access(bin_dir / NAME, os.X_OK) + + def test_installing_again_changes_nothing(self, bin_dir): + global_command.install(REPO, bin_dir, path=str(bin_dir)) + before = (bin_dir / NAME).stat() + assert global_command.install(REPO, bin_dir, path=str(bin_dir)) + after = (bin_dir / NAME).stat() + assert (after.st_mtime_ns, after.st_ino) == (before.st_mtime_ns, before.st_ino) + + def test_a_foreign_command_in_the_bin_dir_is_left_alone(self, bin_dir): + other = _foreign(bin_dir) + content = other.read_text() + assert global_command.state(REPO, bin_dir, path=str(bin_dir)) == FOREIGN + assert not global_command.install(REPO, bin_dir, path=str(bin_dir)) + assert other.read_text() == content + + def test_a_foreign_command_earlier_on_path_is_not_shadowed(self, tmp_path, bin_dir): + # Ours would never run, and would change what the name means for + # the user once their PATH changes. Leave the name to them. + earlier = tmp_path / "earlier" + earlier.mkdir() + _foreign(earlier) + path = os.pathsep.join([str(earlier), str(bin_dir)]) + assert global_command.state(REPO, bin_dir, path=path) == FOREIGN + assert not global_command.install(REPO, bin_dir, path=path) + assert not (bin_dir / NAME).exists() + + def test_the_command_of_another_checkout_is_foreign(self, tmp_path, bin_dir): + other_checkout = tmp_path / "other-checkout" + global_command.install(other_checkout, bin_dir, path=str(bin_dir)) + assert global_command.state(REPO, bin_dir, path=str(bin_dir)) == FOREIGN + assert not global_command.install(REPO, bin_dir, path=str(bin_dir)) + + +# ── Running it ────────────────────────────────────────────────────────── + +class TestRunning: + """The installed command is the checkout's `./stack`, from anywhere.""" + + def _run(self, bin_dir, cwd, *args, env=None): + global_command.install(REPO, bin_dir, path=str(bin_dir)) + return subprocess.run( + [str(bin_dir / NAME), *args], cwd=cwd, env={**os.environ, **(env or {})}, + capture_output=True, text=True, timeout=60, + ) + + def test_it_runs_the_checkout_from_outside_any_checkout(self, tmp_path, bin_dir): + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + result = self._run(bin_dir, elsewhere, "version", env={"STACK_DIR": ""}) + assert result.returncode == 0, result.stderr + assert "Can't find stack directory" not in result.stderr + + def test_stack_dir_still_selects_the_instance(self, tmp_path, bin_dir): + # A path that does not exist makes the CLI refuse by name, which + # proves the variable reached it through the script. + missing = tmp_path / "no-such-instance" + result = self._run(bin_dir, tmp_path, "list", env={"STACK_DIR": str(missing)}) + assert result.returncode != 0 + assert str(missing) in result.stderr + + +# ── Uninstalling ──────────────────────────────────────────────────────── + +def _instance(root: Path, instance_dir: Path | None = None): + from stack import Stack + from stack.output import CollectorOutput + + inst = instance_dir or root + inst.mkdir(parents=True, exist_ok=True) + # A product name other than the command's: the command is fixed. + (inst / "stack.toml").write_text('[core]\nname = "stack"\n') + (root / "stacklets").mkdir(parents=True, exist_ok=True) + return Stack(root=root, data=root / "data", instance_dir=inst, output=CollectorOutput()) + + +def _uninstall(stck, bin_dir, monkeypatch): + from stack.cli import handle_uninstall + + monkeypatch.setattr(sys, "argv", ["stack", "uninstall"]) + monkeypatch.setattr(global_command, "bin_dir", lambda: bin_dir) + with patch("stack.docker.all_project_ids", return_value=set()), \ + patch("builtins.input", return_value=""): + handle_uninstall(stck, Namespace(yes=True)) + + +class TestUninstall: + + def test_uninstall_removes_our_command(self, tmp_path, bin_dir, monkeypatch): + checkout = tmp_path / "checkout" + stck = _instance(checkout) + global_command.install(checkout, bin_dir, path=str(bin_dir)) + _uninstall(stck, bin_dir, monkeypatch) + assert not (bin_dir / NAME).exists() + + def test_uninstall_leaves_a_foreign_command(self, tmp_path, bin_dir, monkeypatch): + stck = _instance(tmp_path / "checkout") + other = _foreign(bin_dir) + _uninstall(stck, bin_dir, monkeypatch) + assert other.exists() + + def test_uninstalling_a_separate_instance_keeps_the_command(self, tmp_path, bin_dir, monkeypatch): + # A STACK_DIR instance (a test run, a sandbox) shares the checkout. + # The command still serves the checkout's own instance. + checkout = tmp_path / "checkout" + stck = _instance(checkout, instance_dir=tmp_path / "sandbox") + global_command.install(checkout, bin_dir, path=str(bin_dir)) + _uninstall(stck, bin_dir, monkeypatch) + assert global_command.is_ours(bin_dir / NAME, checkout)