Add sibling command for installing and updating sibling projects

`sibling` asks for a git URL, clones the repo into
~/.local/share/siblings/<name>, runs its install.sh and confirms the
install. `sibling list` and `sibling remove` manage installed siblings.

updates.d/71-siblings.sh pulls every sibling during `update` and runs
its update.sh, reporting failures in the update summary.

Document the sibling contract in skills/sibling-project-format.md and
update the custom-hook guide to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 16:23:58 -06:00
co-authored by Claude Opus 5.5
parent 78f88fcefd
commit 76fceee115
7 changed files with 445 additions and 9 deletions
+14
View File
@@ -119,6 +119,20 @@ refresh
This will download the latest repo and run through the original installation script adding any "new" applications or services. You should run "update" after running "refresh".
### Sibling projects
A sibling project is another git repository that builds on CoreDesktop. To install one, run in the terminal:
```
sibling
```
It asks for the repository's git URL, clones it into ~/.local/share/siblings/, runs the project's "install.sh" (if it has one), and confirms the install. After that, every "update" pulls the latest version of each sibling and runs its "update.sh" (if it has one).
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).
## mDNS Networking
If you want to use mDNS networking on your LAN (the ability to log in to other local machines using the format "machine.local"), then you should replace /etc/nsswitch.conf with the one in this repository and add /etc/mdns.allow. These two files are formatted to avoid any conflicts with systemd-resolved that may cause nss-mdns not to work. See: https://wiki.archlinux.org/title/Avahi#Hostname_resolution (section 4.2) for more information.
+2 -1
View File
@@ -53,10 +53,11 @@ cd ~/.local/share/coredesktop
# Source utility functions (provides install_packages and other helpers)
source utils.sh
# Install the refresh and update management scripts to the user's bin
# Install the refresh, update and sibling management scripts to the user's bin
mkdir -p ~/.local/bin
install_script ~/.local/share/coredesktop/refresh ~/.local/bin/refresh
install_script ~/.local/share/coredesktop/update ~/.local/bin/update
install_script ~/.local/share/coredesktop/sibling ~/.local/bin/sibling
# Source the package list (defines SYSTEM_UTILS, DEV_TOOLS, DESKTOP, etc. arrays)
if [ ! -f "packages.conf" ]; then
Executable
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
#
# Purpose : Install and manage CoreDesktop sibling projects
#
# A sibling is a git repository cloned into ~/.local/share/siblings/<name>.
# If it has an install.sh, that runs once when the sibling is added.
# Every `update` pulls each sibling and runs its update.sh, if it has one
# (see updates.d/71-siblings.sh).
SIBLINGS="$HOME/.local/share/siblings"
usage() {
cat << EOF
Usage: sibling [command]
add [url] Install a sibling project from a git repository (default)
list Show installed sibling projects
remove <name> Uninstall a sibling project
EOF
}
# Derive a directory name from a git URL, e.g.
# https://host/user/my-project.git -> my-project
sibling_name() {
local name="${1%/}"
name="${name##*/}"
name="${name##*:}"
echo "${name%.git}"
}
add_sibling() {
local url="$1" name dir
if [ -z "$url" ]; then
read -rp "Git repository URL for the sibling project: " url
fi
if [ -z "$url" ]; then
echo "No URL given."
return 1
fi
name="$(sibling_name "$url")"
if [[ -z "$name" || "$name" == .* ]]; then
echo "Could not work out a project name from: $url"
return 1
fi
dir="$SIBLINGS/$name"
mkdir -p "$SIBLINGS"
if [ -d "$dir/.git" ]; then
local existing
existing="$(git -C "$dir" remote get-url origin 2>/dev/null)"
if [ "$existing" != "$url" ]; then
echo "A different sibling named '$name' is already installed from: $existing"
return 1
fi
echo "$name is already installed; pulling the latest version..."
git -C "$dir" pull --ff-only || return 1
else
echo "Cloning $name..."
git clone "$url" "$dir" || return 1
fi
if [ -f "$dir/install.sh" ]; then
echo "Running $name's install.sh..."
if ! (cd "$dir" && bash install.sh); then
echo "$name's install.sh failed. Fix the problem and run 'sibling add $url' again."
return 1
fi
fi
# Confirm the install
if [ -d "$dir/.git" ] && git -C "$dir" rev-parse --verify --quiet HEAD > /dev/null; then
echo "Installed $name at $dir ($(git -C "$dir" log -1 --format='%h %s'))."
echo "'update' will keep it up to date."
else
echo "$name does not look installed correctly at $dir."
return 1
fi
}
list_siblings() {
local dir found=0
for dir in "$SIBLINGS"/*/; do
[ -d "$dir/.git" ] || continue
found=1
printf '%-24s %s (%s)\n' "$(basename "$dir")" \
"$(git -C "$dir" remote get-url origin 2>/dev/null)" \
"$(git -C "$dir" log -1 --format='%h, %cr' 2>/dev/null)"
done
[ $found -eq 1 ] || echo "No sibling projects installed."
}
remove_sibling() {
local name="$1"
if [[ -z "$name" || "$name" == */* || "$name" == .* ]]; then
usage
return 1
fi
if [ ! -d "$SIBLINGS/$name" ]; then
echo "No sibling named '$name' is installed."
return 1
fi
read -rp "Remove $SIBLINGS/$name? [y/N] " response
case "$response" in
[yY][eE][sS]|[yY])
rm -rf "${SIBLINGS:?}/$name"
echo "Removed $name. Anything its install.sh set up outside that folder is still there."
;;
*)
echo "Nothing removed."
;;
esac
}
case "$1" in
""|add) add_sibling "$2" ;;
list|ls) list_siblings ;;
remove|rm) remove_sibling "$2" ;;
-h|--help|help) usage ;;
*) usage; exit 1 ;;
esac
+219
View File
@@ -0,0 +1,219 @@
# Skill: Formatting a Repository as a CoreDesktop Sibling Project
## Overview
A sibling project is a git repository that builds on CoreDesktop. Users install
one with CoreDesktop's `sibling` command, and from then on CoreDesktop's
`update` command keeps it up to date.
Follow this document when you are working in a sibling repository and need it
to install and update correctly under CoreDesktop. A repository needs no
special files to be installable. It becomes useful by adding two optional
scripts at its top level:
| File | When it runs |
|--------------|--------------------------------------------------------------|
| `install.sh` | Once, when the user runs `sibling add`, and again if they re-run it |
| `update.sh` | On every `update`, right after the repo has been pulled |
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 CoreDesktop Does
### `sibling add <url>` (or `sibling`, which asks for the URL)
1. Works out the project name from the last part of the URL, dropping `.git`:
`git@gitea.federated.computer:david/my-tools.git` → `my-tools`.
2. Clones the repo into `~/.local/share/siblings/<name>`, or, if that folder
already holds a clone of the same URL, runs `git pull --ff-only` there.
3. If `install.sh` exists, runs `bash install.sh` with the repo folder as the
working directory. Its input and output are the user's terminal.
4. Reports success only if every step above succeeded.
### `update`
CoreDesktop 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.
2. If the pull succeeded and `update.sh` exists, runs `bash update.sh` with the
repo folder as the working directory.
A failed pull or a non-zero exit from `update.sh` is printed and listed in
`update`'s "Done, with errors in: …" summary. Other siblings still update.
CoreDesktop's end-of-run hooks (GNOME settings, firmware, cleanup, reboot
check) run after all siblings.
### `sibling remove <name>`
This only deletes `~/.local/share/siblings/<name>`. CoreDesktop has no uninstall
step for siblings, so anything `install.sh` set up elsewhere stays.
## Rules for the Repository
### Layout
- Put `install.sh` and `update.sh` at the top level of the repo. Other names or
locations are ignored.
- Both scripts are run with `bash`, so they don't need to be executable, but a
`#!/bin/bash` line keeps them runnable by hand.
- Locate files relative to the script, not to a hard-coded path. The user may
also clone the repo somewhere else and run `install.sh` by hand.
```bash
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
```
### Environment
- The scripts run as separate `bash` processes, so they inherit **no**
variables or functions from CoreDesktop. In particular `$COREDESKTOP` is
**not** set. Use the path directly:
```bash
COREDESKTOP="$HOME/.local/share/coredesktop"
```
- CoreDesktop's helpers are available by sourcing `utils.sh`:
| Function | What it does |
|--------------------------|-----------------------------------------------------|
| `install_packages ...` | Installs pacman/AUR packages via `yay`, skipping installed ones |
| `remove_packages ...` | Removes installed pacman/AUR packages |
| `remove_flatpaks ...` | Uninstalls flatpaks that are installed |
| `enable_services ...` | Enables systemd services not yet enabled |
| `install_script src dst` | Copies a file atomically and makes it executable |
| `is_installed pkg` | True if a pacman package is installed |
- `sudo` prompts for a password as normal. CoreDesktop does not grant
passwordless sudo to siblings.
### `install.sh`
- **Exit non-zero on failure.** `set -e` near the top is the simplest way.
`sibling` then tells the user it failed and how to retry.
- **Make it safe to re-run.** Re-running `sibling add` is how users repair an
install, so skip work that is already done instead of failing on it.
- It may prompt the user, since it runs in their terminal.
- Install commands into `~/.local/bin` (already on `PATH` under CoreDesktop)
with `install_script`.
### `update.sh`
- **Make it idempotent.** It runs on every `update`, usually with nothing to do.
- **Don't prompt.** Users expect `update` to run through without questions,
apart from `sudo` passwords.
- **Exit non-zero only for real failures**, so that `update`'s error summary
stays meaningful.
- **Print what it's doing.** CoreDesktop prints only the project's name before
it runs.
- The repo has already been pulled, so `update.sh` always runs its newest
version. Use it to apply changes made since the last update: new packages,
updated commands in `~/.local/bin`, config migrations.
- It's fine to omit `update.sh` if pulling the repo is enough, for example when
`~/.local/bin` holds symlinks into the repo.
### Keep pulls working
`update` pulls with `--ff-only` and never merges or resets. A pull fails if:
- **History on the default branch is rewritten** (force-pushed). Never do this.
- **Tracked files are edited in place** in the clone and a new commit touches
them. Scripts must not write into tracked files. Write generated files,
caches and per-machine state outside the repo (e.g. `~/.config/<name>/`), or
into paths listed in `.gitignore`.
### Authentication
`sibling` clones with plain `git`. A private repo works only if the user can
already clone it (SSH key, credential helper, or a password typed at the
prompt). Every later `update` needs the same access.
## Templates
Share the install logic between the two scripts so that packages or commands
added later reach existing machines through `update`, not just new installs.
`install.sh`:
```bash
#!/bin/bash
#
# Purpose : Install <project> (run by CoreDesktop's `sibling add`)
set -e
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COREDESKTOP="$HOME/.local/share/coredesktop"
source "$COREDESKTOP/utils.sh"
# One-time setup: prompts, accounts, initial config...
mkdir -p "$HOME/.config/<project>"
# Everything that update.sh also needs to keep current
bash "$SELF/update.sh"
echo "<project> installed."
```
`update.sh`:
```bash
#!/bin/bash
#
# Purpose : Keep <project> current (run by CoreDesktop's `update`)
set -e
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COREDESKTOP="$HOME/.local/share/coredesktop"
source "$COREDESKTOP/utils.sh"
PACKAGES=(
jq
ripgrep
)
install_packages "${PACKAGES[@]}"
mkdir -p "$HOME/.local/bin"
install_script "$SELF/bin/<project>" "$HOME/.local/bin/<project>"
```
## Testing
Test in a throwaway home folder so nothing touches the real one. Point
`sibling` at a local clone; it accepts any URL or path that `git clone` does.
```bash
export HOME="$(mktemp -d)"
mkdir -p "$HOME/.local/share"
ln -s /path/to/real/coredesktop "$HOME/.local/share/coredesktop"
# Install
bash "$HOME/.local/share/coredesktop/sibling" add /path/to/sibling-repo
# Commit a change in /path/to/sibling-repo, then run the update hook the way
# `update` does
( source "$HOME/.local/share/coredesktop/updates.d/71-siblings.sh"; is_installed && run_update )
# Re-run the install to check it is safe to repeat
bash "$HOME/.local/share/coredesktop/sibling" add /path/to/sibling-repo
```
Scripts that call `sudo`, `yay` or `systemctl` still change the real system
even with a temporary `HOME`. Test those on a machine or VM you don't mind
changing.
## Checklist
- [ ] The repo can be cloned by URL by the people who will install it
- [ ] `install.sh` (optional) is at the top level, exits non-zero on failure,
and is safe to re-run
- [ ] `update.sh` (optional) is at the top level, idempotent, doesn't prompt,
and prints what it's doing
- [ ] Scripts find their own files relative to the script and set
`COREDESKTOP="$HOME/.local/share/coredesktop"` themselves
- [ ] Nothing writes to tracked files in the clone, and the default branch is
never force-pushed
- [ ] The project doesn't also copy a hook into CoreDesktop's `updates.d/`
- [ ] The README tells users to install with
`sibling add <url>`
+60 -8
View File
@@ -2,10 +2,41 @@
## Overview
CoreDesktop's `update` script sources every `*.sh` file found in
`~/.local/share/coredesktop/updates.d/` in filename order. A sibling project
hooks into this system by installing a numbered shell script into that directory
during its own install step.
There are two ways for a sibling project to plug into CoreDesktop:
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.** CoreDesktop's `update` script sources every `*.sh` file
found in `~/.local/share/coredesktop/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
@@ -41,20 +72,21 @@ Existing coredesktop hooks use a numeric prefix to control execution order:
| `60-pipx.sh` | pipx packages |
| `65-migrate.sh` | Migrate backup tool |
| `70-claude.sh` | Claude Code setup |
| `71-siblings.sh` | Projects installed with `sibling` |
| `80-gnome-extensions.sh` | GNOME extensions |
| `85-gnome-settings.sh` | GNOME extension settings |
| `90-firmware.sh` | Firmware update check |
| `95-cleanup.sh` | Flatpak/pacman cache cleanup |
| `99-reboot-check.sh` | Reboot-needed notice |
Sibling projects should use an unused number from **71–79** so they run after
Sibling projects should use an unused number from **72–79** so they run after
the core updates but before coredesktop's end-of-run hooks (80+).
## What to Add to a Sibling Project
### 1. Create the hook script
Add a file like `updates.d/70-myproject.sh` to the sibling repo:
Add a file like `updates.d/72-myproject.sh` to the sibling repo:
```bash
#!/bin/bash
@@ -73,12 +105,32 @@ In the sibling's `install.sh`, copy the hook into coredesktop's `updates.d/`:
```bash
echo "Registering MyProject update hook..."
cp updates.d/70-myproject.sh ~/.local/share/coredesktop/updates.d/
cp updates.d/72-myproject.sh ~/.local/share/coredesktop/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
@@ -100,5 +152,5 @@ remove_packages "${MY_REMOVE_PACKAGES[@]}"
- [ ] 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/coredesktop/updates.d/`
- [ ] Numeric prefix is an unused number in 71–79
- [ ] Numeric prefix is an unused number in 72–79
- [ ] Script is idempotent — safe to run multiple times without side effects
+1
View File
@@ -41,6 +41,7 @@ fi
mkdir -p "$COREDESKTOP/updates.d"
install_script "$COREDESKTOP/update" ~/.local/bin/update
install_script "$COREDESKTOP/refresh" ~/.local/bin/refresh
install_script "$COREDESKTOP/sibling" ~/.local/bin/sibling
if [[ -z "$UPDATE_REEXECED" ]]; then
export UPDATE_REEXECED=1
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Sibling projects installed with the `sibling` command
SIBLINGS="$HOME/.local/share/siblings"
is_installed() {
compgen -G "$SIBLINGS/*/.git" > /dev/null
}
run_update() {
echo "Updating sibling projects..."
local dir name failed=0
for dir in "$SIBLINGS"/*/; do
[ -d "$dir/.git" ] || continue
name="$(basename "$dir")"
echo " $name"
if ! git -C "$dir" pull --ff-only --quiet; then
echo " Could not pull $name."
failed=1
continue
fi
if [ -f "$dir/update.sh" ]; then
(cd "$dir" && bash update.sh) || { echo " $name's update.sh failed."; failed=1; }
fi
done
return $failed
}