From 76fceee115c276dfa0512c9dc7b39f9b6a3d58ae Mon Sep 17 00:00:00 2001 From: David Young Date: Mon, 19 Oct 2026 16:23:58 -0600 Subject: [PATCH] Add sibling command for installing and updating sibling projects `sibling` asks for a git URL, clones the repo into ~/.local/share/siblings/, 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 --- README.md | 14 ++ install.sh | 3 +- sibling | 122 +++++++++++++++++ skills/sibling-project-format.md | 219 ++++++++++++++++++++++++++++++ skills/sibling-project-updates.md | 68 ++++++++-- update | 1 + updates.d/71-siblings.sh | 27 ++++ 7 files changed, 445 insertions(+), 9 deletions(-) create mode 100755 sibling create mode 100644 skills/sibling-project-format.md create mode 100755 updates.d/71-siblings.sh diff --git a/README.md b/README.md index 034645a..f39c870 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/install.sh b/install.sh index b4761fb..bbc3084 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/sibling b/sibling new file mode 100755 index 0000000..cf21917 --- /dev/null +++ b/sibling @@ -0,0 +1,122 @@ +#!/bin/bash +# +# Purpose : Install and manage CoreDesktop sibling projects +# +# A sibling is a git repository cloned into ~/.local/share/siblings/. +# 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 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 diff --git a/skills/sibling-project-format.md b/skills/sibling-project-format.md new file mode 100644 index 0000000..83238b1 --- /dev/null +++ b/skills/sibling-project-format.md @@ -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 ` (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/`, 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 ` + +This only deletes `~/.local/share/siblings/`. 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//`), 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 (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/" + +# Everything that update.sh also needs to keep current +bash "$SELF/update.sh" + +echo " installed." +``` + +`update.sh`: + +```bash +#!/bin/bash +# +# Purpose : Keep 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/" "$HOME/.local/bin/" +``` + +## 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 ` diff --git a/skills/sibling-project-updates.md b/skills/sibling-project-updates.md index ade5d42..f4b30b9 100644 --- a/skills/sibling-project-updates.md +++ b/skills/sibling-project-updates.md @@ -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/` 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 # same, without the prompt +sibling list # installed siblings, their URL and current commit +sibling remove # deletes ~/.local/share/siblings/ +``` + +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-.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 diff --git a/update b/update index 3b1324e..42f04ad 100755 --- a/update +++ b/update @@ -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 diff --git a/updates.d/71-siblings.sh b/updates.d/71-siblings.sh new file mode 100755 index 0000000..4e02bd9 --- /dev/null +++ b/updates.d/71-siblings.sh @@ -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 +}