From 9b9f1d5fdd4238936e6789fe93d210fe7f9ee686 Mon Sep 17 00:00:00 2001 From: David Young Date: Wed, 28 Oct 2026 14:35:07 -0600 Subject: [PATCH] harmonize with coredesktop --- README.md | 34 ++++++- claude.sh | 34 +++++++ configure | 156 ++++++++++++++++++++++++++++++ configure.d/10-files.sh | 109 +++++++++++++++++++++ configure.d/30-claude.sh | 24 +++++ configure.d/31-opencode.sh | 21 ++++ configure.d/32-hermes.sh | 25 +++++ configure.d/lib/nextcloud.sh | 27 ++++++ install.sh | 40 +++++++- migrations/1793218938.sh | 4 + packages.conf | 25 +++++ sandbox/agent | 127 ++++++++++++++++++++++++ sandbox/bin/hermes | 1 + sandbox/bin/hermes-agent | 1 + sandbox/bin/opencode | 1 + sandbox/bin/unsandboxed | 1 + sandbox/config/base.json | 85 ++++++++++++++++ sandbox/config/hermes.json | 26 +++++ sandbox/config/opencode.json | 21 ++++ sandbox/hermes-bridge | 81 ++++++++++++++++ sandbox/setup.sh | 84 ++++++++++++++++ shell/aliases | 31 ++++++ shell/bashrc | 42 ++++++++ shell/functions | 78 +++++++++++++++ shell/setup.sh | 13 +++ shell/starship.toml | 37 +++++++ skills/sibling-project-format.md | 7 +- skills/sibling-project-updates.md | 149 ++++++++++++++++++++++++++++ update | 12 +++ updates.d/10-pacman.sh | 6 +- updates.d/12-migrations.sh | 15 +++ updates.d/70-claude.sh | 14 +++ updates.d/72-sandbox.sh | 31 ++++++ utils.sh | 23 +++++ version | 68 +++++++++++++ 35 files changed, 1445 insertions(+), 8 deletions(-) create mode 100755 claude.sh create mode 100755 configure create mode 100644 configure.d/10-files.sh create mode 100644 configure.d/30-claude.sh create mode 100644 configure.d/31-opencode.sh create mode 100644 configure.d/32-hermes.sh create mode 100644 configure.d/lib/nextcloud.sh create mode 100755 migrations/1793218938.sh create mode 100755 sandbox/agent create mode 120000 sandbox/bin/hermes create mode 120000 sandbox/bin/hermes-agent create mode 120000 sandbox/bin/opencode create mode 120000 sandbox/bin/unsandboxed create mode 100644 sandbox/config/base.json create mode 100644 sandbox/config/hermes.json create mode 100644 sandbox/config/opencode.json create mode 100755 sandbox/hermes-bridge create mode 100644 sandbox/setup.sh create mode 100644 shell/aliases create mode 100644 shell/bashrc create mode 100644 shell/functions create mode 100644 shell/setup.sh create mode 100644 shell/starship.toml create mode 100644 skills/sibling-project-updates.md create mode 100644 updates.d/12-migrations.sh create mode 100755 updates.d/70-claude.sh create mode 100644 updates.d/72-sandbox.sh create mode 100755 version diff --git a/README.md b/README.md index 6763e2d..444c74b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ wget -qO- https://gitea.young.computer/david/MiniServer/raw/branch/main/install. update ``` -This pulls the latest MiniServer into `~/.local/share/MiniServer`, then runs each step in `updates.d/` in order: the Arch keyring and system packages (plus any packages newly added to `packages.conf`), AUR packages, pm2, Cockpit, sibling projects, the firewall, cache cleanup, and a reboot check. If a step fails, the others still run and the failed ones are listed at the end. +This pulls the latest MiniServer into `~/.local/share/MiniServer`, then runs each step in `updates.d/` in order: the Arch keyring and system packages (plus any packages newly added to `packages.conf`), one-time migrations, AUR packages, pm2, Cockpit, Claude Code, sibling projects, the agent sandbox and Hermes Agent, the firewall, cache cleanup, and a reboot check. If a step fails, the others still run and the failed ones are listed at the end. + +`update` won't start with less than 10 GiB free on `/`, since running out part way through can leave the system half-upgraded. `UPDATE_FORCE=1 update` skips that check. + +A change that existing servers need to catch up on (something `install.sh` now does differently) goes in `migrations/$(date +%s).sh`. Each migration runs once per server, on the next `update`. + +`version` shows which MiniServer commit this server is on, whether a newer one is available, and when it last updated. ## Refresh @@ -20,6 +26,30 @@ refresh Re-runs `install.sh` to pick up anything new in the installer (services, config). Run `update` afterwards. +## Shell + +MiniServer hooks [shell/bashrc](shell/bashrc) into `~/.bashrc`: a starship prompt, `z` to jump to directories you've visited (zoxide), fzf key bindings (Ctrl-R history, Ctrl-T files, Alt-C directories), larger shared history, and the aliases and functions in [shell/aliases](shell/aliases) and [shell/functions](shell/functions). Anything in `~/.bashrc` after that line overrides it, and your own `~/.config/starship.toml` replaces MiniServer's prompt. + +## AI agents + +MiniServer installs Claude Code, opencode and Hermes Agent. To sign in to them, and to mount your Federated Computer Core's Nextcloud files at `/mnt/`: + +``` +configure +``` + +It lists what still needs setting up. `configure ` sets up one item, and `configure --list` shows their names. + +The Nextcloud mount is made by root at the first use after each boot, owned by you. It uses a Nextcloud app password (revocable under Settings > Security in Nextcloud), kept in `/etc/davfs2/secrets`, which only root can read. + +### Sandbox + +opencode and Hermes run inside Anthropic's sandbox runtime (`srt`), which uses bubblewrap. An agent can write only to the directory it was started in (plus `/tmp` and package caches), can't read credentials such as `~/.ssh`, `~/.gnupg` or other agents' sign-ins, and can reach only the domains in [sandbox/config/](sandbox/config/). Start an agent from a project directory, not from `~`. Claude Code can't run inside `srt`, because bubblewrap sandboxes can't be nested, so MiniServer turns on Claude Code's own sandbox with the same domain list instead. + +This works because `shell/bashrc` puts `sandbox/bin` first on `PATH`, so typing `opencode` or `hermes` runs the sandboxed version. `unsandboxed ` runs the agent without the sandbox. To allow more, add files in the same format as the ones in `sandbox/config/` to `~/.config/miniserver/sandbox/`: `base.json` for every agent or `.json` for one. Lists in your files add to MiniServer's. + +If Hermes' gateway service (`hermes-gateway.service`) is installed, it runs in the sandbox too. MiniServer turns on lingering for your user so the gateway keeps running when nobody is logged in. + ## Sibling projects A sibling project is another git repository that builds on MiniServer. To install one: @@ -32,7 +62,7 @@ It asks for the repository's git URL, clones it into `~/.local/share/siblings/`, Use `sibling list` to see what's installed and `sibling remove {name}` to uninstall one. -To make your own repository work as a sibling project, see [skills/sibling-project-format.md](skills/sibling-project-format.md). +To make your own repository work as a sibling project, see [skills/sibling-project-format.md](skills/sibling-project-format.md). For full control over how it updates, see [skills/sibling-project-updates.md](skills/sibling-project-updates.md). ## Firewall diff --git a/claude.sh b/claude.sh new file mode 100755 index 0000000..ba8290d --- /dev/null +++ b/claude.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Purpose : Install Claude Code using the official native installer + +set -euo pipefail + +# Check if Claude Code is already installed +if command -v claude &>/dev/null; then + echo "Claude Code is already installed: $(claude --version)" +else + echo "Installing Claude Code..." + curl -fsSL https://claude.ai/install.sh | bash +fi + +# The native installer places the binary in ~/.local/bin +LINE='export PATH="$HOME/.local/bin:$PATH"' +if grep -qF "$LINE" ~/.bashrc; then + echo "~/.local/bin already on PATH in .bashrc" +else + echo "$LINE" >> ~/.bashrc + echo "~/.local/bin added to PATH in .bashrc" +fi + +# sandbox/setup.sh turns on Claude Code's sandbox in this file, so it must exist +SETTINGS_FILE="$HOME/.claude/settings.json" +if [ -f "$SETTINGS_FILE" ]; then + echo "Claude Code settings already exist at $SETTINGS_FILE" +else + echo "Writing Claude Code settings to $SETTINGS_FILE..." + mkdir -p "$(dirname "$SETTINGS_FILE")" + echo '{}' > "$SETTINGS_FILE" +fi + +echo "Done. Run 'configure claude' to log in." diff --git a/configure b/configure new file mode 100755 index 0000000..b4dd539 --- /dev/null +++ b/configure @@ -0,0 +1,156 @@ +#!/bin/bash +# +# Purpose : Set up the things MiniServer installs that need your input: +# mounting your Nextcloud files and signing in to the AI agents. +# +# Each file in configure.d/ is one item. It sets TITLE and DESCRIPTION and +# defines is_configured and run_configure. It can also define is_installed; +# the item is left out when that fails. To add an item, add a file there. + +# Wrapped in { ...; exit; } so bash reads the whole script up front; update +# replaces this file while it runs. +{ + +MINISERVER="$HOME/.local/share/MiniServer" +PATH="$HOME/.local/bin:$PATH" + +# Run an agent's real command rather than its sandboxed one in sandbox/bin: +# setting an agent up writes outside its sandbox +unsandboxed() { + "$MINISERVER/sandbox/bin/unsandboxed" "$@" +} + +# Ask a yes/no question; the default is no +confirm() { + local answer + read -rp "$1 [y/N] " answer + [[ "$answer" == [yY]* ]] +} + +# Ask a yes/no question; the default is yes +confirm_yes() { + local answer + read -rp "$1 [Y/n] " answer + [[ "$answer" != [nN]* ]] +} + +usage() { + cat << EOF +Usage: configure [item] + + configure Show what's set up and choose what to set up + configure Set up one item + configure --list Show what's set up, and the item names + +EOF +} + +# configure.d/30-claude.sh -> claude +item_name() { + local name + name="$(basename "$1" .sh)" + echo "${name#[0-9][0-9]-}" +} + +# Prints "||<description>"; state is done, todo or absent +item_info() { + ( + source "$1" + if declare -f is_installed > /dev/null && ! is_installed &> /dev/null; then + echo "absent||" + elif is_configured &> /dev/null; then + echo "done|$TITLE|$DESCRIPTION" + else + echo "todo|$TITLE|$DESCRIPTION" + fi + ) +} + +configure_item() { + ( + source "$1" + echo + echo -e "\e[1m$TITLE\e[0m" + run_configure + ) + local status=$? + if [ $status -ne 0 ]; then + echo -e "\e[31m$(item_name "$1") was not set up.\e[0m" + fi + return $status +} + +# Fill ITEMS, STATES, TITLES and DESCRIPTIONS with the installed items +load_items() { + local file state title description + ITEMS=() STATES=() TITLES=() DESCRIPTIONS=() + for file in "$MINISERVER"/configure.d/*.sh; do + [ -f "$file" ] || continue + IFS='|' read -r state title description < <(item_info "$file") + [ "$state" = absent ] && continue + ITEMS+=("$file") STATES+=("$state") TITLES+=("$title") DESCRIPTIONS+=("$description") + done +} + +show_items() { + local i mark + for i in "${!ITEMS[@]}"; do + if [ "${STATES[$i]}" = done ]; then + mark="\e[32mdone\e[0m " + else + mark="\e[33mto do\e[0m" + fi + printf " %2d) $mark %-22s %s\n" $((i + 1)) "${TITLES[$i]}" "${DESCRIPTIONS[$i]}" + done +} + +case "$1" in + "") ;; + -h|--help) + usage + exit 0 ;; + -l|--list) + load_items + for i in "${!ITEMS[@]}"; do + printf '%-6s %-14s %s\n' "${STATES[$i]/todo/to do}" "$(item_name "${ITEMS[$i]}")" "${TITLES[$i]}" + done + exit 0 ;; + *) + for file in "$MINISERVER"/configure.d/*.sh; do + if [ "$(item_name "$file")" = "$1" ]; then + configure_item "$file" + exit + fi + done + echo "No item named $1. These are: configure --list" + exit 2 ;; +esac + +while true; do + load_items + echo + echo "MiniServer configuration" + echo + show_items + echo + echo " a) Set up everything still to do" + echo " q) Quit" + echo + read -rp "Choose: " choice || exit 0 + + case "$choice" in + q|Q|"") + exit 0 ;; + a|A) + for i in "${!ITEMS[@]}"; do + [ "${STATES[$i]}" = todo ] && configure_item "${ITEMS[$i]}" + done ;; + *) + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#ITEMS[@]} )); then + configure_item "${ITEMS[$((choice - 1))]}" + else + echo "Choose a number from the list, a or q." + fi ;; + esac +done +} diff --git a/configure.d/10-files.sh b/configure.d/10-files.sh new file mode 100644 index 0000000..6efa462 --- /dev/null +++ b/configure.d/10-files.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +TITLE="Nextcloud files" +DESCRIPTION="Mount your Core's Nextcloud files" + +# What you entered last time, so running this again starts from it +FILES_CONF="$HOME/.config/miniserver/files.conf" +DAVFS_SECRETS="/etc/davfs2/secrets" +source "$MINISERVER/configure.d/lib/nextcloud.sh" +[ -f "$FILES_CONF" ] && source "$FILES_CONF" + +is_installed() { + command -v mount.davfs +} + +# CORE_DOMAIN and CORE_EMAIL come from files.conf; the rest follow +core_names() { + CORE_USER="${CORE_EMAIL%@*}" + NEXTCLOUD="https://nextcloud.$CORE_DOMAIN" + WEBDAV="$NEXTCLOUD/remote.php/dav/files/$CORE_USER" + MOUNT="/mnt/$CORE_DOMAIN" +} + +# The fstab line is added last, after the password is saved +is_configured() { + [ -n "$CORE_DOMAIN" ] || return 1 + core_names + grep -qs " $MOUNT davfs " /etc/fstab +} + +save_conf() { + mkdir -p "$(dirname "$FILES_CONF")" + { + printf 'CORE_DOMAIN=%q\n' "$CORE_DOMAIN" + printf 'CORE_EMAIL=%q\n' "$CORE_EMAIL" + } > "$FILES_CONF" +} + +# Mount your Nextcloud files at /mnt/<domain> with davfs2. On a server nobody +# may be logged in, so root mounts it, owned by you, the first time something +# uses it after boot (x-systemd.automount); nofail keeps boot going if +# Nextcloud can't be reached. The password is in davfs2's own secrets file, +# which only root can read, so sandboxed agents can't either. +setup_files() { + local secret unit + + secret="${APP_PASSWORD//\\/\\\\}" + secret="${secret//\"/\\\"}" + sudo sed -i "\\#^$MOUNT #d" "$DAVFS_SECRETS" || return 1 + printf '%s %s "%s"\n' "$MOUNT" "$CORE_USER" "$secret" | sudo tee -a "$DAVFS_SECRETS" > /dev/null || return 1 + sudo chmod 600 "$DAVFS_SECRETS" || return 1 + + sudo install -d "$MOUNT" || return 1 + if ! grep -qs " $MOUNT davfs " /etc/fstab; then + echo "$WEBDAV $MOUNT davfs rw,uid=$(id -u),gid=$(id -g),_netdev,nofail,x-systemd.automount 0 0" \ + | sudo tee -a /etc/fstab > /dev/null || return 1 + fi + sudo systemctl daemon-reload || return 1 + + # Start the automount now rather than at the next boot, then use it once so + # a wrong address or password shows up here. Unmount first when running + # again, so a new password is used. + unit="$(systemd-escape --path "$MOUNT")" + if ! sudo systemctl stop "$unit.mount"; then + echo "$MOUNT is in use; close what's using it and run: configure files" + return 1 + fi + sudo systemctl restart "$unit.automount" || return 1 + if ! ls "$MOUNT" > /dev/null; then + echo "Couldn't mount $WEBDAV; see: journalctl -u $unit.mount" + return 1 + fi + echo "Your Nextcloud files are at $MOUNT." +} + +run_configure() { + local url tries + + read -erp "Your Superpanel's address (e.g. https://superpanel.example.com): " \ + -i "${CORE_DOMAIN:+https://superpanel.$CORE_DOMAIN}" url + url="${url#*://}" + url="${url%%/*}" + CORE_DOMAIN="${url#superpanel.}" + [ -n "$CORE_DOMAIN" ] || { echo "That isn't an address."; return 1; } + + read -erp "Your Core email: " -i "${CORE_EMAIL:-$USER@$CORE_DOMAIN}" CORE_EMAIL + [[ "$CORE_EMAIL" == *@* ]] || { echo "That isn't an email address."; return 1; } + core_names + save_conf + + # The mount gets an app password, not your Core password + for tries in 1 2 3; do + read -rsp "Your Core password: " PASSWORD + echo + nextcloud_app_password "MiniServer on $HOSTNAME" + case $? in + 0) break ;; + 2) echo "Nextcloud says that password is wrong." ;; + *) echo "Couldn't reach Nextcloud at $NEXTCLOUD."; unset PASSWORD; return 1 ;; + esac + done + unset PASSWORD + [ -n "$APP_PASSWORD" ] || return 1 + + setup_files + local status=$? + unset APP_PASSWORD + return $status +} diff --git a/configure.d/30-claude.sh b/configure.d/30-claude.sh new file mode 100644 index 0000000..9d6c952 --- /dev/null +++ b/configure.d/30-claude.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +TITLE="Claude Code" +DESCRIPTION="Sign in to Anthropic" + +is_installed() { + command -v claude +} + +is_configured() { + claude auth status +} + +run_configure() { + echo " 1) Claude subscription (Pro, Max, Team or Enterprise)" + echo " 2) Anthropic Console (pay for API usage)" + local choice + read -rp "How do you pay for Claude? [1] " choice + case "$choice" in + ""|1) claude auth login ;; + 2) claude auth login --console ;; + *) echo "Choose 1 or 2."; return 1 ;; + esac +} diff --git a/configure.d/31-opencode.sh b/configure.d/31-opencode.sh new file mode 100644 index 0000000..789bb45 --- /dev/null +++ b/configure.d/31-opencode.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +TITLE="opencode" +DESCRIPTION="Sign in to AI providers" + +is_installed() { + [ -x /usr/bin/opencode ] +} + +# Signed in to at least one provider +is_configured() { + jq -e 'length > 0' "$HOME/.local/share/opencode/auth.json" +} + +run_configure() { + echo "Choose a provider and sign in or paste its API key. You can pick the model inside opencode with /models." + unsandboxed opencode auth login || return 1 + while confirm "Sign in to another provider?"; do + unsandboxed opencode auth login || return 1 + done +} diff --git a/configure.d/32-hermes.sh b/configure.d/32-hermes.sh new file mode 100644 index 0000000..ba8a4a2 --- /dev/null +++ b/configure.d/32-hermes.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +TITLE="Hermes Agent" +DESCRIPTION="Choose a model provider and tools" + +is_installed() { + [ -x "$HOME/.local/bin/hermes" ] +} + +# Signed in with OAuth (e.g. Nous Portal), or an API key is set +is_configured() { + [ -s "$HOME/.hermes/auth.json" ] || grep -qE '^[A-Z_]*API_KEY=.+' "$HOME/.hermes/.env" +} + +run_configure() { + echo " 1) Nous Portal (one sign-in covers the model, web search, images and more)" + echo " 2) Another provider (OpenRouter, OpenAI, Anthropic or your own endpoint)" + local choice + read -rp "Which provider? [1] " choice + case "$choice" in + ""|1) unsandboxed hermes setup --portal ;; + 2) unsandboxed hermes setup ;; + *) echo "Choose 1 or 2."; return 1 ;; + esac +} diff --git a/configure.d/lib/nextcloud.sh b/configure.d/lib/nextcloud.sh new file mode 100644 index 0000000..c3a2167 --- /dev/null +++ b/configure.d/lib/nextcloud.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# +# Purpose : Nextcloud app passwords for configure.d items. Source this file. + +# Basic auth header for curl, read from stdin (-H @-) so the password isn't +# in the process list +auth_header() { + printf 'Authorization: Basic %s\n' "$(printf '%s:%s' "$1" "$2" | base64 -w0)" +} + +# Create a Nextcloud app password, so what MiniServer sets up doesn't keep +# your Core password. It shows in Nextcloud under Settings > Security with the +# name given, where it can be revoked. Uses CORE_USER, PASSWORD and NEXTCLOUD; +# sets APP_PASSWORD. Returns 2 if Nextcloud says the password is wrong. +nextcloud_app_password() { + local name="$1" response code + response="$(auth_header "$CORE_USER" "$PASSWORD" | curl -s -w '\n%{http_code}' -H @- \ + -H 'OCS-APIRequest: true' -A "$name" \ + "$NEXTCLOUD/ocs/v2.php/core/getapppassword?format=json")" + code="${response##*$'\n'}" + case "$code" in + 200) APP_PASSWORD="$(jq -r '.ocs.data.apppassword // empty' <<< "${response%$'\n'*}")" + [ -n "$APP_PASSWORD" ] ;; + 401) return 2 ;; + *) return 1 ;; + esac +} diff --git a/install.sh b/install.sh index 4d44009..dd001a6 100755 --- a/install.sh +++ b/install.sh @@ -76,11 +76,13 @@ cd ~/.local/share/MiniServer # Source utility functions (provides install_packages, install_script and other helpers) source utils.sh -# Install the refresh, update and sibling management scripts to the user's bin +# Install the refresh, update, sibling, version and configure scripts to the user's bin mkdir -p "$HOME/.local/bin" install_script "$HOME/.local/share/MiniServer/refresh" "$HOME/.local/bin/refresh" install_script "$HOME/.local/share/MiniServer/update" "$HOME/.local/bin/update" install_script "$HOME/.local/share/MiniServer/sibling" "$HOME/.local/bin/sibling" +install_script "$HOME/.local/share/MiniServer/version" "$HOME/.local/bin/version" +install_script "$HOME/.local/share/MiniServer/configure" "$HOME/.local/bin/configure" # Replaced by refresh and update rm -f "$HOME/.local/bin/miniserverrefresh" "$HOME/.local/bin/miniserverupdate" @@ -122,6 +124,12 @@ install_packages "${SERVERS[@]}" echo "Installing utilities..." install_packages "${UTILITIES[@]}" +echo "Installing shell tools..." +install_packages "${SHELL_TOOLS[@]}" + +echo "Installing AI agents and their sandbox..." +install_packages "${AGENTS[@]}" + if ! command -v pm2 &> /dev/null; then npm install pm2 -g --prefix "$HOME/.local" fi @@ -150,6 +158,36 @@ if ! systemctl is-enabled --quiet sddm; then sudo systemctl enable sddm fi +# Install Claude Code +echo "Installing Claude Code..." +bash ~/.local/share/MiniServer/claude.sh + +# Run AI agents (opencode, hermes) in a sandbox, and turn on Claude Code's own +echo "Setting up sandboxed AI agents..." +bash ~/.local/share/MiniServer/sandbox/setup.sh + +# Prompt, zoxide, fzf key bindings, aliases and functions +echo "Configuring the shell..." +bash ~/.local/share/MiniServer/shell/setup.sh + +# Run one-time migrations not yet recorded on this machine. On a fresh install +# they repeat setup done above, which is safe because each one is idempotent. +echo "Running migrations..." +run_migrations + +# Offer configure only if something is still to do, as refresh runs this too. +# This script is usually piped into bash, so ask on the terminal, not stdin. +if ~/.local/bin/configure --list | grep -q '^to do'; then + echo + echo "configure mounts your Nextcloud files and signs you in to the AI agents." + if { : < /dev/tty; } 2> /dev/null && read -rp "Run configure now? [Y/n] " answer < /dev/tty \ + && [[ "$answer" != [nN]* ]]; then + ~/.local/bin/configure < /dev/tty || true + else + echo "To do it later, run: configure" + fi +fi + echo "" read -rp "A reboot is required to complete setup. Reboot now? [y/N] " response </dev/tty case "$response" in diff --git a/migrations/1793218938.sh b/migrations/1793218938.sh new file mode 100755 index 0000000..15b8fa6 --- /dev/null +++ b/migrations/1793218938.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Hook MiniServer's shell setup into ~/.bashrc +bash "$HOME/.local/share/MiniServer/shell/setup.sh" diff --git a/packages.conf b/packages.conf index 90cb707..37638ce 100644 --- a/packages.conf +++ b/packages.conf @@ -32,3 +32,28 @@ UTILITIES=( unzip ) +# Shell: prompt, completion, directory jumping, fuzzy finding (see shell/) +SHELL_TOOLS=( + bash-completion + bat + eza + fzf + starship + zoxide +) + +# AI agents and their sandbox (see sandbox/). Claude Code and Hermes Agent +# install themselves; srt, the sandbox runtime, comes from npm. +AGENTS=( + # srt confines agents with bubblewrap, relays their network with socat and + # scans for files to protect with ripgrep + bubblewrap + socat + ripgrep + jq + # Hermes' Matrix support builds python-olm, which needs clang++ and cmake + clang + opencode + # pipx apps installed with its uv backend, as Hermes does, need uv to upgrade + uv +) diff --git a/sandbox/agent b/sandbox/agent new file mode 100755 index 0000000..170bc10 --- /dev/null +++ b/sandbox/agent @@ -0,0 +1,127 @@ +#!/bin/bash +# +# Purpose : Run an AI agent inside Anthropic's sandbox runtime (srt). srt uses +# bubblewrap to limit which files the agent can read and write, and +# removes its network; socat links it to a proxy outside the sandbox +# that only lets through the domains in its config. +# +# sandbox/bin/<agent> links here and shell/bashrc puts sandbox/bin first on +# PATH, so typing an agent's name runs it sandboxed. `unsandboxed <agent>` runs +# the agent's real command instead. +# +# The config is sandbox/config/base.json, then sandbox/config/<agent>.json, +# then your own ~/.config/miniserver/sandbox/base.json and <agent>.json, in +# the same format. Later files add to the lists in earlier ones. Domains in +# MINISERVER_SANDBOX_DOMAINS (space-separated) are allowed too; setup.sh uses +# it for Hermes' gateway, whose messaging server differs on each machine. + +# Wrapped in { ...; exit; } so bash reads the whole script up front; update +# replaces this file (git pull) while agents are running. +{ + +SANDBOX="$HOME/.local/share/MiniServer/sandbox" +USER_CONFIG="$HOME/.config/miniserver/sandbox" +name="$(basename "$0")" + +# The agent's real command: the first one on PATH outside sandbox/bin +real_command() { + local dir bin_dir IFS=: + bin_dir="$(realpath "$SANDBOX/bin")" + for dir in $PATH; do + [ -n "$dir" ] && [ "$(realpath "$dir" 2> /dev/null)" != "$bin_dir" ] || continue + if [ -x "$dir/$1" ] && [ ! -d "$dir/$1" ]; then + echo "$dir/$1" + return 0 + fi + done + return 1 +} + +if [ "$name" = "unsandboxed" ]; then + if [ $# -eq 0 ]; then + echo "Usage: unsandboxed <agent> [arguments]" + exit 2 + fi + agent="$1" + shift + real="$(real_command "$agent")" || { echo "$agent isn't installed."; exit 127; } + exec "$real" "$@" +fi + +agent="$name" +real="$(real_command "$agent")" || { echo "$agent isn't installed."; exit 127; } + +# Commands that share another agent's sandbox config +case "$agent" in + hermes-agent) profile=hermes ;; + *) profile="$agent" ;; +esac + +srt="$(command -v srt || echo "$HOME/.local/bin/srt")" +if [ ! -x "$srt" ]; then + echo "The sandbox (srt) isn't installed; run update. To run $agent without it: unsandboxed $agent" + exit 1 +fi + +# The agent can change anything in the directory it starts in +case "$PWD" in + "$HOME"|/) + echo "Start $agent from a project directory. In the sandbox it can change anything in the directory it starts in, and this is $PWD." + exit 1 ;; +esac + +configs=("$SANDBOX/config/base.json") +[ -f "$SANDBOX/config/$profile.json" ] && configs+=("$SANDBOX/config/$profile.json") +ours=("${configs[@]}") +for file in "$USER_CONFIG/base.json" "$USER_CONFIG/$profile.json"; do + [ -f "$file" ] && configs+=("$file") +done + +# Objects are merged and lists joined, so later files only add to earlier ones +MERGE=' +def merge(a; b): + reduce (b | to_entries[]) as $e (a; + .[$e.key] |= ( + if type == "object" and ($e.value | type) == "object" then merge(.; $e.value) + elif type == "array" and ($e.value | type) == "array" then . + $e.value | unique + else $e.value end)); +reduce .[] as $config ({}; merge(.; $config))' + +# srt runs in place of this script (exec below), so a stop signal, e.g. from +# systemd, reaches it and it passes it on to the agent. That leaves nothing to +# remove the settings file afterwards, so it's named after this process, and +# files whose process has ended are removed here. +SETTINGS_DIR="${XDG_RUNTIME_DIR:-/tmp}/miniserver-sandbox" +mkdir -p -m 700 "$SETTINGS_DIR" || exit 1 +for old in "$SETTINGS_DIR"/*.json; do + pid="${old%.json}" + pid="${pid##*.}" + [[ "$pid" =~ ^[0-9]+$ ]] && ! kill -0 "$pid" 2> /dev/null && rm -f "$old" +done + +# Outside the sandbox's writable paths, so the agent can't change its own rules +settings="$SETTINGS_DIR/$agent.$$.json" +if ! jq -s --arg domains "$MINISERVER_SANDBOX_DOMAINS" \ + "$MERGE"' | .network.allowedDomains += ($domains | split(" ") | map(select(. != "")))' \ + "${configs[@]}" > "$settings"; then + echo "Couldn't read the sandbox config: ${configs[*]}" + exit 1 +fi + +# Bubblewrap can only make writable a directory that exists, and the agent +# can't create one itself in a parent it may not write to +while IFS= read -r path; do + path="${path/#\~/$HOME}" + case "$path" in "$HOME"/*) mkdir -p "$path" ;; esac +done < <(jq -rs "$MERGE | .filesystem.allowWrite[]? | strings" "${ours[@]}") + +# srt sets TMPDIR to /tmp/claude, which nothing creates, unless told otherwise; +# temp files then fail, and GTK aborts. /tmp is writable in every profile. +export CLAUDE_CODE_TMPDIR=/tmp + +# Hermes' terminal reaches its gateway, in another sandbox, through a relay +case "$profile" in + hermes) exec "$srt" --settings "$settings" -- bash "$SANDBOX/hermes-bridge" "$real" "$@" ;; +esac +exec "$srt" --settings "$settings" -- "$real" "$@" +} diff --git a/sandbox/bin/hermes b/sandbox/bin/hermes new file mode 120000 index 0000000..b1d7999 --- /dev/null +++ b/sandbox/bin/hermes @@ -0,0 +1 @@ +../agent \ No newline at end of file diff --git a/sandbox/bin/hermes-agent b/sandbox/bin/hermes-agent new file mode 120000 index 0000000..b1d7999 --- /dev/null +++ b/sandbox/bin/hermes-agent @@ -0,0 +1 @@ +../agent \ No newline at end of file diff --git a/sandbox/bin/opencode b/sandbox/bin/opencode new file mode 120000 index 0000000..b1d7999 --- /dev/null +++ b/sandbox/bin/opencode @@ -0,0 +1 @@ +../agent \ No newline at end of file diff --git a/sandbox/bin/unsandboxed b/sandbox/bin/unsandboxed new file mode 120000 index 0000000..b1d7999 --- /dev/null +++ b/sandbox/bin/unsandboxed @@ -0,0 +1 @@ +../agent \ No newline at end of file diff --git a/sandbox/config/base.json b/sandbox/config/base.json new file mode 100644 index 0000000..22afa94 --- /dev/null +++ b/sandbox/config/base.json @@ -0,0 +1,85 @@ +{ + "network": { + "allowedDomains": [ + "anthropic.com", + "*.anthropic.com", + "openai.com", + "*.openai.com", + "chatgpt.com", + "*.chatgpt.com", + "openrouter.ai", + "*.openrouter.ai", + "nousresearch.com", + "*.nousresearch.com", + "generativelanguage.googleapis.com", + "api.mistral.ai", + "api.deepseek.com", + "api.x.ai", + "api.groq.com", + "api.together.xyz", + "*.githubcopilot.com", + + "github.com", + "*.github.com", + "*.githubusercontent.com", + "gitea.federated.computer", + "gitea.young.computer", + "registry.npmjs.org", + "registry.yarnpkg.com", + "pypi.org", + "files.pythonhosted.org", + "crates.io", + "*.crates.io", + "rubygems.org", + "*.rubygems.org", + "proxy.golang.org", + "sum.golang.org", + "archlinux.org", + "*.archlinux.org" + ], + "deniedDomains": [] + }, + "filesystem": { + "denyRead": [ + "~/.ssh", + "~/.gnupg", + "~/.password-store", + "~/.local/share/keyrings", + "~/.netrc", + "~/.git-credentials", + "~/.aws", + "~/.azure", + "~/.config/gcloud", + "~/.kube", + "~/.docker", + "~/.config/gh", + "~/.config/tea", + "~/.config/borg", + "~/.config/Bitwarden", + "~/.config/Element", + "~/.mozilla", + "~/.config/chromium", + "~/.config/google-chrome", + "~/.config/BraveSoftware", + "~/.config/net.imput.helium", + "~/.var/app", + "~/.davfs2", + "~/.claude", + "~/.claude.json", + "~/.hermes", + "~/.config/opencode", + "~/.local/share/opencode" + ], + "allowRead": [], + "allowWrite": [ + ".", + "/tmp", + "~/.npm", + "~/.cache/pip", + "~/.cargo/registry", + "~/.cargo/git", + "~/go/pkg/mod" + ], + "denyWrite": [] + } +} diff --git a/sandbox/config/hermes.json b/sandbox/config/hermes.json new file mode 100644 index 0000000..b541e2a --- /dev/null +++ b/sandbox/config/hermes.json @@ -0,0 +1,26 @@ +{ + "network": { + "allowedDomains": [], + "allowAllUnixSockets": true + }, + "filesystem": { + "denyRead": [ + "/run/user", + "/run/dbus", + "/run/docker.sock", + "/run/podman", + "/run/tailscale", + "/run/ssh-unix-local", + "/tmp/.X11-unix", + "/tmp/tmux-*", + "/tmp/ssh-*", + "/tmp/*/SingletonSocket" + ], + "allowRead": [ + "~/.hermes" + ], + "allowWrite": [ + "~/.hermes" + ] + } +} diff --git a/sandbox/config/opencode.json b/sandbox/config/opencode.json new file mode 100644 index 0000000..d976b2a --- /dev/null +++ b/sandbox/config/opencode.json @@ -0,0 +1,21 @@ +{ + "network": { + "allowedDomains": [ + "opencode.ai", + "*.opencode.ai", + "models.dev" + ] + }, + "filesystem": { + "allowRead": [ + "~/.config/opencode", + "~/.local/share/opencode" + ], + "allowWrite": [ + "~/.config/opencode", + "~/.local/share/opencode", + "~/.local/state/opencode", + "~/.cache/opencode" + ] + } +} diff --git a/sandbox/hermes-bridge b/sandbox/hermes-bridge new file mode 100755 index 0000000..1cc0120 --- /dev/null +++ b/sandbox/hermes-bridge @@ -0,0 +1,81 @@ +#!/bin/bash +# +# Purpose : Run Hermes inside its sandbox (sandbox/agent runs this there) and +# connect its terminal to its gateway. +# +# Hermes' gateway runs the agent; `hermes` in a terminal only talks to it, over +# the gateway's control socket and then a WebSocket on 127.0.0.1:<port>. Each +# sandbox has its own network, so that port can't be reached from another one. +# The gateway's sandbox relays the port to API_SOCKET, and a terminal's sandbox +# listens on the same port and relays to API_SOCKET, so `hermes` reaches the +# gateway as if they shared a network. +# +# Usage: hermes-bridge <hermes command> [arguments] + +real="$1" +shift +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +API_SOCKET="$HERMES_HOME/miniserver-api.sock" + +# The gateway's answer to `identify` on its control socket, or nothing if no +# gateway is running +identify() { + local control="$HERMES_HOME/gateway.sock" + [ -S "$control" ] || control="$(cat "$HERMES_HOME/gateway.sock.path" 2> /dev/null)" + [ -S "$control" ] || return 1 + printf '{"protocol":1,"verb":"identify","id":1}\n' \ + | socat -t 5 - "UNIX-CONNECT:$control" 2> /dev/null | grep . +} + +# The port the gateway's API listens on, once the gateway is ready. Only an +# IPv4 loopback origin is relayed, which is what Hermes uses by default. +api_port() { + local origin + origin="$(identify | jq -r 'select(.result.state == "ready") | .result.api_origin // empty' 2> /dev/null)" + [[ "$origin" =~ ^http://127\.0\.0\.1:([0-9]+)$ ]] || return 1 + echo "${BASH_REMATCH[1]}" +} + +if [ "$1" = "gateway" ] && [ "$2" = "run" ]; then + # A terminal finds no relay, rather than the last gateway's, until this one + # is up + rm -f "$API_SOCKET" + # The relay waits for the gateway, which runs in this process once exec'd; + # the sandbox ends both together + ( + for _ in $(seq 480); do + if port="$(api_port)"; then + exec socat "UNIX-LISTEN:$API_SOCKET,unlink-early,mode=600,fork" "TCP:127.0.0.1:$port" + fi + sleep 0.25 + done + echo "hermes-bridge: the gateway's API didn't come up; terminals can't reach it" >&2 + ) & + exec "$real" "$@" +fi + +# A terminal: relay the gateway's port, if a gateway is running or its service +# is turned on, waiting for one that is still starting. Without one, hermes +# starts its own gateway inside this sandbox, which needs no relay. +if [ -e "$HOME/.config/systemd/user/default.target.wants/hermes-gateway.service" ] \ + || identify > /dev/null; then + [ -S "$API_SOCKET" ] && api_port > /dev/null || echo "Waiting for Hermes' gateway..." >&2 + for _ in $(seq 240); do + [ -S "$API_SOCKET" ] && port="$(api_port)" && break + port="" + sleep 0.25 + done + if [ -n "$port" ]; then + socat "TCP-LISTEN:$port,bind=127.0.0.1,reuseaddr,fork" "UNIX-CONNECT:$API_SOCKET" & + # Wait for the listener (state 0A, LISTEN, in /proc/net/tcp) so hermes' + # first connection doesn't fail + hex="$(printf ':%04X ' "$port")" + for _ in $(seq 50); do + grep -q "$hex.* 0A " /proc/net/tcp && break + sleep 0.05 + done + else + echo "hermes-bridge: Hermes' gateway is running but its API isn't reachable; try: systemctl --user restart hermes-gateway" >&2 + fi +fi +exec "$real" "$@" diff --git a/sandbox/setup.sh b/sandbox/setup.sh new file mode 100644 index 0000000..9723c6f --- /dev/null +++ b/sandbox/setup.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# +# Purpose : Set up sandboxed AI agents (see sandbox/agent): install or update +# Anthropic's sandbox runtime (srt) and Hermes Agent, and turn on +# Claude Code's built-in sandbox. Run by install.sh and on every +# update (updates.d/72-sandbox.sh). + +MINISERVER="$HOME/.local/share/MiniServer" +STATUS=0 + +# npm's global prefix is under /usr, so install to ~/.local; srt lands in ~/.local/bin +echo "Installing the sandbox runtime (srt)..." +npm install --global --prefix "$HOME/.local" --no-fund --no-audit @anthropic-ai/sandbox-runtime || STATUS=1 + +# --non-interactive skips Hermes' setup questions and its background gateway +# service; `configure hermes` chooses a model provider afterwards +if [ ! -x "$HOME/.local/bin/hermes" ]; then + echo "Installing Hermes Agent..." + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive || STATUS=1 +fi + +# Claude Code runs every command it starts in its own sandbox, also built on +# bubblewrap and socat; it can't run inside srt because bubblewrap can't be +# nested. Anything already in the sandbox section of your settings wins over +# these defaults. +CLAUDE_SETTINGS="$HOME/.claude/settings.json" +if [ -f "$CLAUDE_SETTINGS" ]; then + domains="$(jq '.network.allowedDomains' "$MINISERVER/sandbox/config/base.json")" + tmp="$(mktemp "$CLAUDE_SETTINGS.XXXXXX")" + if jq --argjson domains "$domains" \ + '.sandbox = ({enabled: true, network: {allowedDomains: $domains}} * (.sandbox // {}))' \ + "$CLAUDE_SETTINGS" > "$tmp"; then + if cmp -s "$tmp" "$CLAUDE_SETTINGS"; then + rm -f "$tmp" + else + echo "Turning on Claude Code's sandbox..." + mv -f "$tmp" "$CLAUDE_SETTINGS" + fi + else + echo "Couldn't read $CLAUDE_SETTINGS; Claude Code's sandbox was not turned on." + rm -f "$tmp" + STATUS=1 + fi +fi + +# Hermes' gateway acts on messages from outside this machine, so it runs in +# the sandbox too: a systemd drop-in starts it through sandbox/bin/hermes. It +# also needs to reach the Matrix server in Hermes' settings, which differs on +# each machine; for other messaging services, add their domains to +# ~/.config/miniserver/sandbox/hermes.json. +GATEWAY_UNIT="$HOME/.config/systemd/user/hermes-gateway.service" +if [ -f "$GATEWAY_UNIT" ]; then + if [ -x "$HOME/.local/bin/srt" ]; then + homeserver="$(sed -nE "s#^MATRIX_HOMESERVER=[\"']?https?://([^/:\"']+).*#\1#p" "$HOME/.hermes/.env" 2> /dev/null | head -n 1)" + dropin="$GATEWAY_UNIT.d/miniserver-sandbox.conf" + tmp="$(mktemp)" + cat > "$tmp" << EOF +# Written by MiniServer (sandbox/setup.sh): run Hermes' gateway in the sandbox +[Service] +Environment="MINISERVER_SANDBOX_DOMAINS=$homeserver" +ExecStart= +ExecStart=%h/.local/share/MiniServer/sandbox/bin/hermes gateway run +EOF + if cmp -s "$tmp" "$dropin"; then + rm -f "$tmp" + else + echo "Running Hermes' gateway in the sandbox..." + mkdir -p "$(dirname "$dropin")" && mv -f "$tmp" "$dropin" && chmod 644 "$dropin" \ + && systemctl --user daemon-reload || STATUS=1 + fi + else + echo "Hermes' gateway isn't sandboxed yet: the sandbox (srt) isn't installed." + STATUS=1 + fi + + # A server is rarely logged in to, and user services stop at logout unless + # the user lingers + if [ "$(loginctl show-user "$USER" --property=Linger --value 2> /dev/null)" != yes ]; then + echo "Keeping Hermes' gateway running while you're logged out..." + sudo loginctl enable-linger "$USER" || STATUS=1 + fi +fi + +exit $STATUS diff --git a/shell/aliases b/shell/aliases new file mode 100644 index 0000000..28a00c9 --- /dev/null +++ b/shell/aliases @@ -0,0 +1,31 @@ +# MiniServer aliases, sourced by shell/bashrc + +# Files +if command -v eza &> /dev/null; then + alias ls='eza --group-directories-first --icons=auto' + alias ll='eza -lh --group-directories-first --icons=auto' + alias la='eza -lha --group-directories-first --icons=auto' + alias lt='eza --tree --level=2 --long --icons=auto --git' +fi + +# Fuzzy-find a file with a preview, or open the one you pick in your editor +alias ff="fzf --preview 'bat --style=numbers --color=always {}'" +alias eff='${EDITOR:-nvim} "$(ff)"' + +alias decompress='tar -xzf' + +# Directories +alias ..='cd ..' +alias ...='cd ../..' +alias ....='cd ../../..' + +# Tools +alias g='git' +alias lg='lazygit' +alias d='docker' +alias ld='lazydocker' + +# Git +alias gcm='git commit -m' +alias gcam='git commit -a -m' +alias gcad='git commit -a --amend' diff --git a/shell/bashrc b/shell/bashrc new file mode 100644 index 0000000..1289c17 --- /dev/null +++ b/shell/bashrc @@ -0,0 +1,42 @@ +# MiniServer shell setup, sourced from ~/.bashrc (added by shell/setup.sh). +# Anything in ~/.bashrc after the line that sources this file overrides it. + +# Only for interactive shells +[[ $- != *i* ]] && return + +MINISERVER_SHELL="$HOME/.local/share/MiniServer/shell" + +# AI agents such as opencode and hermes run sandboxed (see sandbox/agent), so +# sandbox/bin must come before the directories holding their real commands +SANDBOX_BIN="$HOME/.local/share/MiniServer/sandbox/bin" +PATH="$SANDBOX_BIN:${PATH//$SANDBOX_BIN:/}" + +# History: keep a lot of it, skip duplicates, and don't let shells overwrite +# each other's history on exit +shopt -s histappend +HISTCONTROL=ignoreboth +HISTSIZE=32768 +HISTFILESIZE="$HISTSIZE" + +if [[ ! -v BASH_COMPLETION_VERSINFO && -f /usr/share/bash-completion/bash_completion ]]; then + source /usr/share/bash-completion/bash_completion +fi + +source "$MINISERVER_SHELL/aliases" +source "$MINISERVER_SHELL/functions" + +# Prompt. Uses MiniServer's starship config unless you have your own. +if [[ $TERM != "dumb" ]] && command -v starship &> /dev/null; then + [[ -f "$HOME/.config/starship.toml" ]] || export STARSHIP_CONFIG="$MINISERVER_SHELL/starship.toml" + eval "$(starship init bash)" +fi + +# z <part of a path> jumps to a directory you've visited before +if command -v zoxide &> /dev/null; then + eval "$(zoxide init bash)" +fi + +# Ctrl-R fuzzy history search, Ctrl-T to insert a file path, Alt-C to cd +if [[ -f /usr/share/fzf/key-bindings.bash ]]; then + source /usr/share/fzf/key-bindings.bash +fi diff --git a/shell/functions b/shell/functions new file mode 100644 index 0000000..40ba801 --- /dev/null +++ b/shell/functions @@ -0,0 +1,78 @@ +# MiniServer shell functions, sourced by shell/bashrc + +# n opens Neovim in the current directory, or on the files given +n() { if [ "$#" -eq 0 ]; then command nvim . ; else command nvim "$@"; fi; } + +# open a file or URL with its default app, detached from the terminal +open() ( + xdg-open "$@" > /dev/null 2>&1 & +) + +# compress <dir> makes <dir>.tar.gz (decompress is an alias for tar -xzf) +compress() { tar -czf "${1%/}.tar.gz" "${1%/}"; } + +# SSH port forwarding +# fip <host> <port>... forward each localhost port to the same port on host +# dip <port>... stop forwarding those ports +# lip list active forwards +fip() { + (( $# < 2 )) && echo "Usage: fip <host> <port1> [port2] ..." && return 1 + local host="$1" + shift + for port in "$@"; do + ssh -f -N -L "${port}:localhost:${port}" "$host" && echo "Forwarding localhost:$port -> $host:$port" + done +} + +dip() { + (( $# == 0 )) && echo "Usage: dip <port1> [port2] ..." && return 1 + for port in "$@"; do + pkill -f "ssh.*-L ${port}:localhost:${port}" && echo "Stopped forwarding port $port" || echo "No forwarding on port $port" + done +} + +lip() { + pgrep -af "ssh.*-L [0-9]+:localhost:[0-9]+" || echo "No active forwards" +} + +# Git worktrees +# ga <branch> create ../<repo>--<branch> on a new branch and cd into it +# gd from inside that worktree, remove it and its branch +ga() { + if [[ -z "$1" ]]; then + echo "Usage: ga [branch name]" + return 1 + fi + + local branch="$1" + local base="$(basename "$PWD")" + local wt_path="../${base}--${branch}" + + git worktree add -b "$branch" "$wt_path" || return 1 + command -v mise &> /dev/null && mise trust "$wt_path" + cd "$wt_path" +} + +gd() { + local cwd base branch root worktree answer + + cwd="$(pwd)" + worktree="$(basename "$cwd")" + + # split on first `--` + root="${worktree%%--*}" + branch="${worktree#*--}" + + # Protect against accidentally nuking a non-worktree directory + if [[ "$root" == "$worktree" ]]; then + echo "Not in a worktree made by ga (expected a <repo>--<branch> directory)." + return 1 + fi + + read -rp "Remove worktree $worktree and branch $branch? [y/N] " answer + [[ $answer == [yY]* ]] || return 0 + + cd "../$root" + git worktree remove "$cwd" --force || return 1 + git branch -D "$branch" +} diff --git a/shell/setup.sh b/shell/setup.sh new file mode 100644 index 0000000..43c3f89 --- /dev/null +++ b/shell/setup.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# +# Purpose : Hook MiniServer's shell setup (prompt, zoxide, fzf key bindings, +# aliases and functions in shell/) into ~/.bashrc + +LINE='[[ -f "$HOME/.local/share/MiniServer/shell/bashrc" ]] && source "$HOME/.local/share/MiniServer/shell/bashrc"' + +if grep -qF "$LINE" ~/.bashrc 2>/dev/null; then + echo "MiniServer shell setup already in .bashrc" +else + echo "$LINE" >> ~/.bashrc + echo "MiniServer shell setup added to .bashrc" +fi diff --git a/shell/starship.toml b/shell/starship.toml new file mode 100644 index 0000000..e6a7871 --- /dev/null +++ b/shell/starship.toml @@ -0,0 +1,37 @@ +add_newline = true +command_timeout = 200 +format = "[$hostname$directory$git_branch$git_status]($style)$character" + +[character] +error_symbol = "[✗](bold cyan)" +success_symbol = "[❯](bold cyan)" + +[hostname] +ssh_only = false +format = "[$hostname]($style):" +style = "bold cyan" + +[directory] +truncation_length = 0 +truncate_to_repo = false +repo_root_style = "bold cyan" +repo_root_format = "[$repo_root]($repo_root_style)[$path]($style)[$read_only]($read_only_style) " + +[git_branch] +format = "[$branch]($style) " +style = "italic cyan" + +[git_status] +format = '[$all_status]($style)' +style = "cyan" +ahead = "⇡${count} " +diverged = "⇕⇡${ahead_count}⇣${behind_count} " +behind = "⇣${count} " +conflicted = " " +up_to_date = " " +untracked = "? " +modified = " " +stashed = "" +staged = "" +renamed = "" +deleted = "" diff --git a/skills/sibling-project-format.md b/skills/sibling-project-format.md index 2005fdf..fcc5dc4 100644 --- a/skills/sibling-project-format.md +++ b/skills/sibling-project-format.md @@ -17,8 +17,9 @@ files at its top level: | `update.sh` | Runs on every `update`, right after the repo has been pulled | | `firewall.conf` | Read on `sibling add`, `sibling remove` and every `update` to open the project's ports | -Don't also copy a hook into MiniServer's `updates.d/` for the same project, or -it updates twice. +If you want full control over how the project updates, see +`sibling-project-updates.md` (custom `updates.d` hooks) instead. Use one +approach or the other, not both, or the project updates twice. ## What MiniServer Does @@ -36,7 +37,7 @@ it updates twice. ### `update` -MiniServer runs its own update hooks in order (`10-pacman.sh` … `30-pm2.sh`). +MiniServer runs its own update hooks in order (`10-pacman.sh` … `70-claude.sh`). Next, `updates.d/71-siblings.sh` takes each sibling in alphabetical order and: 1. Runs `git pull --ff-only --quiet` in its folder. diff --git a/skills/sibling-project-updates.md b/skills/sibling-project-updates.md new file mode 100644 index 0000000..27df3cd --- /dev/null +++ b/skills/sibling-project-updates.md @@ -0,0 +1,149 @@ +# Skill: Supporting the updates.d Update Architecture in a Sibling Project + +## Overview + +There are two ways for a sibling project to plug into MiniServer: + +1. **The `sibling` command (preferred).** The user runs `sibling` and gives it + the project's git URL. The repo is cloned into + `~/.local/share/siblings/<name>` and its `install.sh` (if any) is run once. + On every `update`, `updates.d/71-siblings.sh` pulls each sibling and runs its + `update.sh` (if any). The sibling only needs those two optional scripts. +2. **A custom hook.** MiniServer's `update` script sources every `*.sh` file + found in `~/.local/share/MiniServer/updates.d/` in filename order. A sibling + project can install its own numbered shell script into that directory during + its own install step, for full control over how and when it updates. + +## Using the `sibling` Command + +See `sibling-project-format.md` for the full rules, templates and testing. +In short, give the repo these optional files at its top level: + +| File | When it runs | +|--------------|---------------------------------------------------| +| `install.sh` | Once, from the repo folder, when the user runs `sibling add` (and again if they re-run it) | +| `update.sh` | On every `update`, from the repo folder, after the repo has been pulled | + +Both run with `bash`, and a non-zero exit is reported as a failure. +`update.sh` should be idempotent. If you have no `update.sh`, `update` just pulls the repo. + +Commands: + +```bash +sibling # asks for the git URL +sibling add <url> # same, without the prompt +sibling list # installed siblings, their URL and current commit +sibling remove <name> # deletes ~/.local/share/siblings/<name> +``` + +The rest of this document covers the custom-hook approach. + +## How It Works + +When a user runs `update`: + +1. The MiniServer repo at `~/.local/share/MiniServer` is pulled. +2. All `*.sh` files in `updates.d/` are **sourced**, each in its own subshell, + in lexicographic order. +3. Each script can see `$MINISERVER`, but nothing defined by earlier hooks — + source `utils.sh` yourself if you need its helpers. A `cd`, `set -e` or `exit` + in one hook can't affect the others. +4. A hook that exits non-zero is listed in the summary at the end of `update`. + +Because the scripts are sourced from within the cloned repo directory, any file +placed there by a sibling project's installer will **survive `git pull`** — git +does not delete untracked files on pull. + +## Naming Convention + +Existing MiniServer hooks use a numeric prefix to control execution order: + +| File | Purpose | +|-----------------------|---------------------------------------------| +| `10-pacman.sh` | Arch system update | +| `12-migrations.sh` | One-time migrations from `migrations/` | +| `20-aur.sh` | AUR packages | +| `30-pm2.sh` | pm2 | +| `40-cockpit.sh` | Cockpit | +| `70-claude.sh` | Claude Code setup | +| `71-siblings.sh` | Projects installed with `sibling` | +| `72-sandbox.sh` | Sandbox runtime and Hermes Agent | +| `80-firewall.sh` | Ports from every `firewall.conf` | +| `95-cleanup.sh` | pacman cache cleanup | +| `99-reboot-check.sh` | Reboot-needed notice | + +Sibling projects should use an unused number from **73–79** so they run after +the core updates but before MiniServer's end-of-run hooks (80+). + +## What to Add to a Sibling Project + +### 1. Create the hook script + +Add a file like `updates.d/73-myproject.sh` to the sibling repo: + +```bash +#!/bin/bash + +echo "Updating MyProject..." +git -C "$HOME/.local/share/myproject" pull + +# Optional: use MiniServer helpers if needed +# source "$MINISERVER/utils.sh" +# install_packages "${MY_PACKAGES[@]}" +``` + +### 2. Install the hook during the sibling's install step + +In the sibling's `install.sh`, copy the hook into MiniServer's `updates.d/`: + +```bash +echo "Registering MyProject update hook..." +cp updates.d/73-myproject.sh ~/.local/share/MiniServer/updates.d/ +``` + +This is a one-time step that wires the sibling into every future `update` +run on the machine. + +### Optional: only update when installed + +Instead of running code at the top level, a hook can define `run_update` and, +optionally, `is_installed`. `update` calls `run_update` only when `is_installed` +is missing or returns true, so the hook is skipped on machines where the project +isn't installed. Return non-zero from `run_update` to report a failure: + +```bash +#!/bin/bash + +is_installed() { + [ -d "$HOME/.local/share/myproject" ] +} + +run_update() { + echo "Updating MyProject..." + git -C "$HOME/.local/share/myproject" pull --ff-only +} +``` + +## Available Helpers + +Hooks don't inherit functions from earlier hooks, so source `utils.sh` for its +helpers. Do this before defining your own `is_installed`, because `utils.sh` has +a pacman-specific function with the same name: + +```bash +source "$MINISERVER/utils.sh" + +# Install pacman packages (skips already-installed ones) +install_packages "${MY_PACMAN_PACKAGES[@]}" + +# Remove packages +remove_packages "${MY_REMOVE_PACKAGES[@]}" +``` + +## Checklist for Sibling Project Authors + +- [ ] Create `updates.d/7X-<project>.sh` in the sibling repo +- [ ] Hook script echoes its name so progress is visible during `update` +- [ ] Install step copies the hook to `~/.local/share/MiniServer/updates.d/` +- [ ] Numeric prefix is an unused number in 73–79 +- [ ] Script is idempotent — safe to run multiple times without side effects diff --git a/update b/update index f07452d..e21a20e 100755 --- a/update +++ b/update @@ -49,12 +49,24 @@ mkdir -p "$HOME/.local/bin" "$MINISERVER/updates.d" install_script "$MINISERVER/update" ~/.local/bin/update install_script "$MINISERVER/refresh" ~/.local/bin/refresh install_script "$MINISERVER/sibling" ~/.local/bin/sibling +install_script "$MINISERVER/version" ~/.local/bin/version +install_script "$MINISERVER/configure" ~/.local/bin/configure if [[ -z "$UPDATE_REEXECED" ]]; then export UPDATE_REEXECED=1 exec ~/.local/bin/update fi +# An upgrade that runs out of disk space part way through can leave the system +# half-upgraded. Set UPDATE_FORCE=1 to skip this check. +MIN_FREE_GIB=10 +FREE_KIB="$(df -k --output=avail / | tail -n 1 | tr -d ' ')" +if [[ -z "$UPDATE_FORCE" && "$FREE_KIB" =~ ^[0-9]+$ ]] && (( FREE_KIB < MIN_FREE_GIB * 1024 * 1024 )); then + echo "Only $(( FREE_KIB / 1024 / 1024 )) GiB free on /; updating needs at least $MIN_FREE_GIB GiB." + echo "Free up space (sudo paccache -rk1 clears old cached packages), or run: UPDATE_FORCE=1 update" + exit 1 +fi + # Ask for the password once, then keep sudo's cached credentials fresh until # update exits, so long-running hooks don't stop for a password again. sudo -v || exit 1 diff --git a/updates.d/10-pacman.sh b/updates.d/10-pacman.sh index 4355eef..97d69c0 100755 --- a/updates.d/10-pacman.sh +++ b/updates.d/10-pacman.sh @@ -25,7 +25,9 @@ if initramfs_build_failed; then fi echo "Checking for new packages..." -install_packages "${SERVERS[@]}" -install_packages "${UTILITIES[@]}" +install_packages "${SERVERS[@]}" || STATUS=1 +install_packages "${UTILITIES[@]}" || STATUS=1 +install_packages "${SHELL_TOOLS[@]}" || STATUS=1 +install_packages "${AGENTS[@]}" || STATUS=1 exit $STATUS diff --git a/updates.d/12-migrations.sh b/updates.d/12-migrations.sh new file mode 100644 index 0000000..b90c9c9 --- /dev/null +++ b/updates.d/12-migrations.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Run one-time changes from migrations/ that haven't run on this machine yet. +# This is how machines installed before a change to install.sh catch up, so a +# change that should reach existing machines needs a migration as well. To add +# one, create migrations/$(date +%s).sh; it runs with bash, once, on success. +# +# Runs after 10-pacman.sh so migrations install packages against fresh +# package databases. + +MINISERVER="$HOME/.local/share/MiniServer" + +source "$MINISERVER/utils.sh" + +run_migrations diff --git a/updates.d/70-claude.sh b/updates.d/70-claude.sh new file mode 100755 index 0000000..c021514 --- /dev/null +++ b/updates.d/70-claude.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +MINISERVER="$HOME/.local/share/MiniServer" + +# claude.sh has run if Claude Code is installed, ~/.local/bin is on PATH in +# .bashrc, and its settings file exists +if command -v claude &>/dev/null \ + && grep -qF 'export PATH="$HOME/.local/bin:$PATH"' ~/.bashrc 2>/dev/null \ + && [ -f "$HOME/.claude/settings.json" ]; then + echo "Claude Code already set up." +else + echo "Setting up Claude Code..." + bash "$MINISERVER/claude.sh" +fi diff --git a/updates.d/72-sandbox.sh b/updates.d/72-sandbox.sh new file mode 100644 index 0000000..1dc666d --- /dev/null +++ b/updates.d/72-sandbox.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +MINISERVER="$HOME/.local/share/MiniServer" + +run_update() { + local status=0 gateway=0 + bash "$MINISERVER/sandbox/setup.sh" || status=1 + + # Hermes update asks a running gateway to finish its work and restart, but + # that signal (SIGUSR1) reaches srt, not the gateway inside its sandbox, so + # the update would wait half an hour. Stop the gateway for the update instead; + # starting it afterwards also loads new sandbox rules and dependencies. + if systemctl --user is-active --quiet hermes-gateway.service; then + gateway=1 + systemctl --user stop hermes-gateway.service || status=1 + fi + + # The real command, not the sandboxed one in sandbox/bin: updating Hermes + # writes outside what its sandbox allows. python-olm, for Matrix, bundles a + # CMake file too old for CMake 4 unless told which policies to use. + if [ -x "$HOME/.local/bin/hermes" ]; then + echo "Updating Hermes Agent..." + CMAKE_POLICY_VERSION_MINIMUM=3.5 "$HOME/.local/bin/hermes" update < /dev/null || status=1 + fi + + if [ "$gateway" = 1 ]; then + echo "Starting Hermes' gateway..." + systemctl --user start hermes-gateway.service || status=1 + fi + return $status +} diff --git a/utils.sh b/utils.sh index 5beee42..47b07e3 100755 --- a/utils.sh +++ b/utils.sh @@ -56,6 +56,29 @@ enable_services() { done } +# Run each script in migrations/ that hasn't succeeded on this machine yet, in +# order, and record it once it does. Stops at the first failure so later +# migrations can rely on earlier ones; it runs again on the next update. +run_migrations() { + local dir="${MINISERVER:-$HOME/.local/share/MiniServer}/migrations" + local state="$HOME/.local/state/miniserver/migrations" + local file name + + mkdir -p "$state" + for file in "$dir"/*.sh; do + [ -f "$file" ] || continue + name="$(basename "$file")" + [ -f "$state/$name" ] && continue + + echo "Running migration $name..." + if ! bash "$file"; then + echo "Migration $name failed; it will run again on the next update." + return 1 + fi + touch "$state/$name" + done +} + # Output of the last update's pacman -Su, written by updates.d/10-pacman.sh PACMAN_UPDATE_LOG="$HOME/.local/state/miniserver/pacman-update.log" diff --git a/version b/version new file mode 100755 index 0000000..8336611 --- /dev/null +++ b/version @@ -0,0 +1,68 @@ +#!/bin/bash +# +# Purpose : Show which version of MiniServer this machine is running +# +# MiniServer is a rolling release, so its version is the date of the commit +# the local checkout is on, plus the short hash to tell same-day commits apart, +# e.g. 2026.10.22 (c5f0651). The kernel is shown separately because it comes +# from Arch, not MiniServer. +# +# version --check prints the newer version and exits 0 if one is available, +# exits 1 if this machine is up to date, or 2 if it couldn't check. + +MINISERVER="$HOME/.local/share/MiniServer" + +source "$MINISERVER/utils.sh" + +if ! git -C "$MINISERVER" rev-parse --git-dir &> /dev/null; then + echo "MiniServer isn't installed in $MINISERVER." + exit 2 +fi + +# Version string for a commit, e.g. 2026.10.22 (c5f0651) +describe() { + git -C "$MINISERVER" log -1 --format='%cd (%h)' --date=format:%Y.%m.%d "$1" +} + +# Print the newer version if the remote has commits this checkout doesn't. +# Returns 1 if up to date, 2 if the remote couldn't be reached. +newer_version() { + GIT_TERMINAL_PROMPT=0 timeout 30 git -C "$MINISERVER" fetch --quiet &> /dev/null || return 2 + local behind + behind="$(git -C "$MINISERVER" rev-list --count 'HEAD..@{upstream}' 2> /dev/null)" || return 2 + (( behind > 0 )) || return 1 + describe '@{upstream}' +} + +if [ "$1" = "--check" ]; then + newer_version + exit +fi + +version="$(describe HEAD)" + +# Local edits to the checkout mean this isn't exactly the version above +if [ -n "$(git -C "$MINISERVER" status --porcelain --untracked-files=no)" ]; then + version="$version, modified" +fi + +if latest="$(newer_version)"; then + version="$version, $latest is available (run update)" +fi + +kernel="$(uname -r)" +# If the running kernel's modules are gone, a newer kernel was installed +if [ ! -d "/usr/lib/modules/$kernel" ]; then + kernel="$kernel (newer kernel installed, reboot to use it)" +fi + +# 10-pacman.sh rewrites this log on every update +if [ -f "$PACMAN_UPDATE_LOG" ]; then + last_update="$(date -r "$PACMAN_UPDATE_LOG" '+%Y-%m-%d %H:%M')" +else + last_update="never" +fi + +echo "MiniServer $version" +echo "Kernel $kernel" +echo "Last update $last_update"