harmonize with coredesktop

This commit is contained in:
David Young
2026-10-28 14:35:07 -06:00
parent 866720c3c1
commit 9b9f1d5fdd
35 changed files with 1445 additions and 8 deletions
+32 -2
View File
@@ -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/<domain>`:
```
configure
```
It lists what still needs setting up. `configure <item>` 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 <agent>` 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 `<agent>.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
Executable
+34
View File
@@ -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."
Vendored Executable
+156
View File
@@ -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 <item> 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 "<state>|<title>|<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
}
+109
View File
@@ -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
}
+24
View File
@@ -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
}
+21
View File
@@ -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
}
+25
View File
@@ -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
}
+27
View File
@@ -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
}
+39 -1
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# Hook MiniServer's shell setup into ~/.bashrc
bash "$HOME/.local/share/MiniServer/shell/setup.sh"
+25
View File
@@ -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
)
Executable
+127
View File
@@ -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" "$@"
}
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+85
View File
@@ -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": []
}
}
+26
View File
@@ -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"
]
}
}
+21
View File
@@ -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"
]
}
}
+81
View File
@@ -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" "$@"
+84
View File
@@ -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
+31
View File
@@ -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'
+42
View File
@@ -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
+78
View File
@@ -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"
}
+13
View File
@@ -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
+37
View File
@@ -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 = ""
+4 -3
View File
@@ -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.
+149
View File
@@ -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
+12
View File
@@ -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
+4 -2
View File
@@ -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
+15
View File
@@ -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
+14
View File
@@ -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
+31
View File
@@ -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
}
+23
View File
@@ -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"
Executable
+68
View File
@@ -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"