Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:

Expand Down
6 changes: 5 additions & 1 deletion docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 18 additions & 5 deletions lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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())
Expand Down
120 changes: 120 additions & 0 deletions lib/stack/global_command.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 29 additions & 8 deletions lib/stack/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -599,6 +613,8 @@ def wizard():

nl()

command = _install_global_command(stck)

# ── Done ──────────────────────────────────────────────────────────

clear()
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 5 additions & 8 deletions tests/e2e/install/test_first_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
10 changes: 9 additions & 1 deletion tests/e2e/remote
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading