Shared install / checkhealth framework for the monkey-* family (monkey-zsh, monkey-tmux, monkey-nvim, monkey-vim, monkey-sway, monkey-hyprland, monkey-wezterm, monkey-env).
Each consuming repo carries this repository under scripts/ as a
git subtree and keeps only its own
data: dependency specs, symlink lists, summary text, and a handful of hooks.
Nothing generic is duplicated per project.
| Path | Contents |
|---|---|
install.sh |
Installer entry point — sourced by a project's install.sh, then install_main |
checkhealth.sh |
Dependency-check entry point — sourced, then checkhealth_main |
lib/bootstrap.sh |
Shared sourcing head of both entry points: lib-dir resolution, fail-fast check, framework sourcing |
lib/common.sh |
Colors, indented log lines ([INFO] / [ OK ]), $HOME guard, WSL/native-binary probes, version comparison |
lib/retry.sh |
Retry with exponential backoff + per-attempt timeout (retry, timeout tiers) |
lib/tty.sh |
TIOCSTI injection into the controlling terminal (inject_tty, interactive-reader guard) |
lib/sudo.sh |
Native-sudo detection, per-run NOPASSWD drop-in (no keepalive) |
lib/pkg.sh |
Dispatch core: install_pkg orchestrator, install-strategy dispatcher, shared batch classifier |
lib/distro.sh |
Distro package managers (apt/pacman/zypper/dnf + AUR): pkg_name mapping, index refresh/probe, install_sys_pkg, build deps |
lib/brew.sh |
Homebrew: install_pkg's fallback manager, BREW_FIRST lane, the installer itself |
lib/toolchain.sh |
Language-ecosystem toolchains: rustup, go, npm, pip (ensure_rust, ensure_npm, …) |
lib/config.sh |
Symlink / dir / file checks (SYMLINKS, CONFIG_LINKS, CONFIG_HINTS, ADVISORY_SECTIONS) |
lib/env.sh |
PATH seeding / persistence (bin_dirs, export_path), shell env files, autostart block, XDG_RUNTIME_DIR repair |
lib/checks.sh |
Spec model, probes, checkhealth display sections (run_required_checks & friends) |
lib/missing.sh |
Missing-dependency installer passes (install_missing_*, batch collection, re-probe) |
lib/clipboard.sh |
Clipboard provider detection + install (CHECK_CLIPBOARD=required|warn|display) |
lib/clone.sh |
Network fetching: git clone / download helpers (clone_repo, fetch_url_or_clone) |
lib/kmscon.sh |
kmscon install & VT takeover (ensure_kmscon tty2, getty masking, launch-gui presence check) |
lib/output.sh |
Terminal output helpers: banner, section headers, platform block, install hints |
Neither entry point is meant to be executed on its own.
# first-time fetch:
git subtree add -P scripts https://github.com/QMonkey/monkey-scripts.git master
# later updates:
git subtree pull -P scripts --squash https://github.com/QMonkey/monkey-scripts.git mastergit clone — once this repository is committed into a project's repo (the
git subtree add above, then pushed), a regular clone of that project
already contains scripts/: consumers never clone or pull monkey-scripts
themselves. Refreshing scripts/ from upstream is the project maintainer's
git subtree pull; consumers pick it up with an ordinary git pull of the
project.
curl|bash — the user downloads only the project's install.sh, with no
checkout at all, so install.sh clones the project itself straight into
INSTALL_DIR (~/Documents/monkey-<name>) and runs the install.sh from that
clone, which carries its own scripts/. The installer and the framework it
loads therefore always come from the same revision, and nothing is fetched
from this repository directly. If INSTALL_DIR exists, is not empty and is not a git
clone, install.sh refuses to touch it and says so — there is no throwaway
fallback, so the checkout is always at the documented path. (An existing empty
directory is fine: that is what git clone itself accepts.) If the clone has no
scripts/ either, the project repo does not carry the subtree commit yet and
install.sh says so and stops.
checkhealth.sh never fetches anything: in a checkout that predates the
subtree commit (no scripts/ next to it) it fails fast and tells you to
update the checkout — git pull or re-clone — or to run the project's
install.sh, which bootstraps the framework itself.
A project's checkhealth.sh looks like this:
#!/usr/bin/env bash
set -euo pipefail
. "$(dirname "${BASH_SOURCE[0]:-$0}")/scripts/checkhealth.sh" || exit 1
PROJECT=monkey-zsh
REQUIRED_CHECKS=(...)
OPTIONAL_CHECKS=(...)
# ...data + hooks...
checkhealth_main "$@"Exit code: 1 if any required dependency is missing, 0 otherwise.
One spec string per dependency (see lib/checks.sh):
id|check|desc|install|ver_regex|fallback|token|group
id— binary (or sentinel) name; recorded inMISSING_*, used for package-name lookupscheck—bin(default) |ext|anyof:a b c|anyofext:a b c|ver:MINdesc— printed text (default:id)install—pkg(default) |pkg:name,name|npm:x|go:x|cargo:x|pip:x|brew:x|rustup|rustup-component:x|python-unversioned|nonever_regex— version regex (default:[0-9]+\.[0-9]+)fallback— binary accepted instead ofid(warn, not fail)token— version output must mention this (warn only)group— optional-checks display group (empty = ungrouped)
Spec lists: REQUIRED_CHECKS, EXTRA_SPECS (installed, never listed),
RECOMMENDED_CHECKS, OPTIONAL_CHECKS.
@header|Title— bold section title (embed${NC}in the title to end the bold span early)@note|text— indented note under the current title@clipboard— clipboard provider check (CHECK_CLIPBOARD=required)@config— inline "Config files" section (CONFIG_PHASE=required)@call|fn— hand the line to a project-defined check function
One fixed section order for every repo; the switches only move sections around (all optional):
| Switch | Meaning |
|---|---|
CONFIG_PHASE=required|early|end |
where the "Config files" section runs (default end) |
ADVISORY_PHASE=early|end |
where ADVISORY_SECTIONS run (default end) |
INSTALL_REQUIRED_PHASE=early|late |
where install_missing_required runs (default early; late = after Terminal capabilities, as in upstream zsh / monkey-tmux) |
INSTALL_OPTIONAL_PHASE=early|late |
where install_missing_optional runs (default early; late = listing first, then install, as in upstream zsh) |
CHECK_TERMINAL_CAPS=1 |
print the "Terminal capabilities" section |
TERMCAPS_STYLE=term|colorterm |
color probe: TERM=… vs COLORTERM=… |
CHECK_LANG=1 |
the LANG line under Terminal capabilities |
CHECK_CLIPBOARD=required|warn|display |
clipboard handling |
INSTALL_OPTIONAL=1 |
install missing optional tools too |
OPTIONAL_INSTALL_TITLE="…" |
header of the optional install batch (default Installing optional tools) |
OPTIONAL_TRAILING_BLANK=0 |
no spacer after the optional section |
OPTIONAL_ALL_PRESENT_MSG="…" |
printed under --install when nothing optional is missing |
OPTIONAL_DONE_MSG="…" |
printed after a non-empty optional install batch |
OPTIONAL_SECTION_TITLE/NOTE |
title / note of the optional listing section |
ADVISORY_SECTIONS=(…) |
standalone advisory sections (fonts, plugins, …) |
Config data:
CONFIG_LINKS=( "src|dst|desc|mode|name|hint" )— symlink checks.mode=link(default) orstrict(link must resolve into this repo);hintis the verbatim missing-fail message when a project's upstream text differs from the generic one (ln -sfvsln -sfn, literal placeholders,mkdirprefixes, …).CONFIG_HINTS=( "type|params|ok|incomplete|missing" )— free-form probes (path,any, …) rendered by the same machinery as advisory entries.
Hooks are plain functions the project defines after sourcing
scripts/checkhealth.sh — the later definition simply wins:
print_header_extra— probes between the title and Platform. Anything that must influence the exit status belongs incheckhealth_extrainstead:run_required_checksresetsREQUIRED_FAILURESafter the header ran.checkhealth_extra— last section before the summary; its failures count toward the exit status.install_missing_required/install_missing_optional— override the generic batch install (projects with verbatim upstream install steps carry them here).install_optional_bin— per-binary install table used by the optional batch.optional_hint— FAIL hint printed by the optional listing.install_pkg— package-install override.
A project's install.sh looks like this:
#!/usr/bin/env bash
set -euo pipefail
# Repository identity first — the bootstrap needs both values.
PROJECT=monkey-zsh
PROJECT_REPO=https://github.com/QMonkey/monkey-zsh.git
INSTALL_DIR="${INSTALL_DIR:-$HOME/Documents/monkey-zsh}"
_monkey_scripts="$(dirname "${BASH_SOURCE[0]:-$0}")/scripts"
if [ ! -f "$_monkey_scripts/install.sh" ]; then
_monkey_self="${BASH_SOURCE[0]:-$0}" # a real file next to a checkout?
_monkey_dir="$(dirname "$_monkey_self")"
if [ -f "$_monkey_self" ] && [ -d "$_monkey_dir/.git" ]; then
git -C "$_monkey_dir" pull --ff-only || true # pull the subtree in
_monkey_scripts="$_monkey_dir/scripts"
[ -f "$_monkey_scripts/install.sh" ] || {
echo "monkey-scripts missing from $_monkey_dir (no scripts/ subtree)." >&2
exit 1
}
else # curl|bash: no checkout at all
# Clone THIS project into INSTALL_DIR — where clone_project
# would have put it anyway — and run the installer from that checkout,
# so install.sh and scripts/ cannot drift apart.
if ! command -v git >/dev/null 2>&1; then
echo "git is required to clone $PROJECT — install it first (e.g. sudo apt-get install git), then re-run." >&2
exit 1
fi
if [ -d "$INSTALL_DIR/.git" ]; then
git -C "$INSTALL_DIR" pull --ff-only || true
elif [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR")" ]; then
echo "$INSTALL_DIR is not empty and is not a git clone." >&2
echo " move it aside, delete it, or set INSTALL_DIR elsewhere." >&2
exit 1
else
git clone "$PROJECT_REPO" "$INSTALL_DIR" || exit 1
fi
exec bash "$INSTALL_DIR/install.sh" "$@" </dev/null
fi
fi
. "$_monkey_scripts/install.sh"
install_step_tool() { install_zsh; echo ""; }
SUMMARY_LINES=(...)
install_main "$@"Both halves agree on INSTALL_DIR, which is all the seam between them needs:
the bootstrap had to clone before it could reach this framework, and it puts
the checkout exactly where the clone step would have put it, so that step
confirms and pulls (a no-op right after a clone) instead of cloning a second
copy. There is no marker variable and nothing to inherit — which also matters
for monkey-env, whose component installers run as child processes.
Hook order inside install_main (each hook prints its own trailing blank):
banner → OS info → setup_sudo → prepare → tool → post → clone
→ checkhealth → refresh PATH → autostart → symlinks (+step_symlinks)
→ persist PATH (PERSIST_POS=before_links runs it earlier) → after
→ completion summary
Step hooks (override after sourcing): install_step_prepare,
install_step_tool, install_step_post_tool, install_step_autostart,
install_step_symlinks, install_step_after, install_print_info.
lib/kmscon.sh exposes ensure_kmscon <tty[,tty...]> for projects that want
the kmscon console: it installs the kmscon package, writes
/etc/systemd/system/kmscon@.service (+ PAM), enables kmscon@ttyN and masks
the matching getty@ttyN for each listed VT (1–63; N>6 is enabled but has no
getty to mask). The enabled/masked set is asserted to EXACTLY equal the
requested list. The bare getty is deliberately kept on every VT NOT listed —
callers opt into a "full" replacement by listing tty1..tty6 themselves.
ensure_kmscon warns and skips (rc 0) when the environment cannot host kmscon
(no systemd, no /dev/dri, failed install) and returns non-zero only for
invalid arguments or a self-check mismatch — callers are expected to
|| warn "... continuing" and never abort the install. It warns on advisory
conditions too (an enabled display manager owning tty1). Call it
from install_step_autostart, before write_tty_autostart (sudo is
guaranteed there); guard it with is_wsl the same way. The flag parsing
(--with-kmscon [tty[,tty...]], default tty2) belongs to the calling
project's installer; an orchestrating meta-installer calls it once itself
instead of forwarding the flag to components. The generated autostart block
is kmscon-aware: inside a kmscon session it wraps the compositor in
kmscon-launch-gui (shipped by distro kmscon packages) instead of exec'ing
it directly.
Data switches:
| Switch | Meaning |
|---|---|
SYMLINKS=("src|dst" "src|dst|keep") |
links created by the symlink step — link_config <src> <dst> <mode>; keep = skip an existing target with an info line, silently skip a missing source |
ENSURE_DIRS / ENSURE_FILES |
directories (mkdir -p) / touch-files to create |
INSTALL_INFO=(…) |
extra lines right after the OS announcement |
SUMMARY_LINES=(…) |
completion-summary lines |
PERSIST_PATH=1 |
write go/bin & cargo/bin PATH exports to the detected shell profile (zsh → ~/.zprofile, bash → ~/.profile) |
PERSIST_POS=before_links|after_links |
where the persist step runs |
FINISH_INJECT=1 |
inject into the current terminal on finish |
LINUX_ONLY=1 |
refuse to run on macOS / unknown OS |
CHECKHEALTH_MODE=run|verify|none |
how the checkhealth step runs |
CHECKHEALTH_POS=tool|after_links |
where the checkhealth step runs |
BREW_FIRST=(pkg…) |
package names installed via Homebrew when available |
Unlike checkhealth.sh, fail() aborts the installer at the first fatal
step (MONKEY_FAIL_EXITS=true).
os_detect normalises /etc/os-release to one id per distro. Derivative
distros are mapped to their base id; nothing else is shared:
| id | /etc/os-release ids mapped here |
manager |
|---|---|---|
debian |
debian | apt |
ubuntu |
ubuntu, linuxmint, pop, elementary, zorin | apt |
arch |
arch, manjaro, endeavouros | pacman |
opensuse |
opensuse, leap, tumbleweed, microos, suse, sles | zypper |
centos |
centos, rhel, rocky, almalinux, ol | dnf |
fedora |
fedora | dnf |
macos |
darwin (uname) | homebrew |
OS holds the id, and it is what every package table keys on
(pkg_name in lib/pkg.sh) — so Ubuntu and Fedora carry their own
package names while only the commands are shared, e.g. debian | ubuntu)
for apt-get install and centos | fedora) for a plain dnf install. EPEL
stays a CentOS-only step, since Fedora has no EPEL. os_detect is the only
place that reads /etc/os-release: to give one id another's package names, add
it to that id's line above and every table follows.
- bash 3.2 compatible (macOS ships 3.2): no associative arrays, no
${x^^}, nomapfile, no[[ -v ]]; guard empty arrays with${arr[@]+"${arr[@]}"}. - Log lines are indented two spaces —
[INFO],[ OK ],[FAIL]. - No sudo keepalive: a per-run NOPASSWD drop-in (
SUDOERS_D_DIR) covers the whole batch and is removed on exit. - Output of the converted project scripts must stay byte-identical to each
project's upstream script; anything the generic pipeline cannot reproduce
is carried as verbatim project data or a hook (for example the
hintfield ofCONFIG_LINKS).