- bin/pos: usage() CATEGORIES now auto-derived from pos-* filenames (kill the stale cheat-sheet bug class); only EXAMPLES stays hand-written - # POS: / # POS_FLAGS: headers on all 15 tools = single source of truth for generated docs; template updated - scripts/gen-docs.sh: regenerates GEN-marker sections (bin tree, dispatch table, no-common.sh list, line-count table, completion flags) - scripts/check-sync.sh: bash -n + exec bits + doc/code drift + smoke - Makefile: make gen / make check; scripts/install-hooks.sh: opt-in hook - DEV.md + AGENT_Context: add-a-tool flow is now header + make check
17 KiB
Development Guide
How this repo works, how to add features, and what to keep in mind when editing.
Architecture
Installation Phases
install.sh
│
┌───────────┼───────────┐
▼ ▼ ▼
preinstall.sh bin/* postinstall.sh
(packages) → /usr/local/bin (config + services)
| Phase | Script | Responsibility |
|---|---|---|
| Pre | preinstall.sh |
System packages, apt repos, global binaries (yt-dlp) |
| Install | install.sh |
Copies bin/* → /usr/local/bin/ (chmod 755), lib/common.sh → /usr/local/bin/common.sh |
| Post | postinstall.sh |
User config (SSH keys, PATH, bash completion), systemd services |
Each phase is independent and runs only if the corresponding script exists.
Directory Layout
| Directory | Purpose | Installed To |
|---|---|---|
bin/ |
Daily-use CLI tools and wrappers | /usr/local/bin/ |
apps/<category>/ |
Optional desktop app installers | run on demand |
lib/ |
Shared library (common.sh) |
sourced at build time |
config/ |
Gitignored user config files | ~/.config/<app>/ (via postinstall) |
compose/ |
ScaleTail templates (git submodule) | /usr/local/share/linux_post_install/scale-tail |
systemd/ |
Systemd unit files | /etc/systemd/system/ (via postinstall) |
The pos CLI
bin/pos is a smart dispatcher. It scans its own directory for executable pos-* files and uses variable-length argument matching:
pos docker compose up jellyfin
→ tries pos-docker-compose-up-jellyfin (not found)
→ tries pos-docker-compose-up (not found)
→ finds pos-docker-compose → runs with args "up jellyfin"
All non-interactive commands log to ~/.local/share/linux_post_install/logs/.
pos help <full command> shows a tool's help, e.g. pos help communication telegram (all words joined with dashes → pos-communication-telegram --help). pos <category> or pos <category> --help shows a category's subcommands (derived from the pos-<category>-* filenames in bin/ — no script execution, so it works even for root-only/interactive tools like system-firewall).
When adding a command, bin/pos itself has one thing to keep in sync:
- The usage text (
usage()function) — the CATEGORIES block is auto-derived from thepos-*filenames inbin/(no manual edit, can't drift). The EXAMPLES block is the only hand-maintained part: add a line there only if you want the tool showcased inpos --help. INTERACTIVE_CMDS(space-separated list above the dispatch loop) — commands that read stdin (password prompts, selection menus:media-mp4,system-backup,usb-server) must be added here. Everything else is piped throughteefor logging, which would hang or swallow an interactive prompt. sudo's own password prompt is unaffected — it reads from/dev/tty. Trade-off: it's all-or-nothing per script — adding a flag-style tool with any prompting subcommand (e.g.usb-server --share) means every subcommand of that script skips output logging (e.g.usb server --lsloses theteelog too).
Shared Library (lib/common.sh)
Sourced by most scripts. Key functions:
| Function | Purpose |
|---|---|
log "msg" |
Green [+] status message |
warn "msg" |
Yellow [!] warning |
err "msg" |
Red ERROR: + exit 1 |
ok "msg" |
Green OK prefix |
section "title" |
Cyan-bordered section header |
step N T "msg" |
Numbered step header |
run cmd |
Executes command, respects $DRY_RUN |
spawn "msg" cmd |
Animated braille spinner + elapsed time |
timer_start / timer_stop |
Elapsed time tracking |
confirm "prompt" |
y/N prompt with optional default |
Adding a New CLI Tool
Start from the template: cp templates/pos-tool.sh bin/pos-<category>-<command> && chmod +x bin/pos-<category>-<command>.
1. Create the script
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/../lib/common.sh"
usage() {
cat <<EOF
Usage: my-tool <argument>
EOF
exit 0
}
case "${1:-}" in
-h|--help|"") usage ;;
esac
# --- script logic ---
Conventions:
- Shebang:
#!/usr/bin/env bash - Strict mode:
set -euo pipefail --helpflag: accept-h/--helpviacasepattern- Shared library: always source
common.shfor colors, logging, spinners - Exit codes:
0success,1error - No shared lib? Inline fallbacks:
If you skip
log() { echo "[+] $*"; } warn() { echo "[!] $*"; } err() { echo "ERROR: $*" >&2; exit 1; }common.sh,make genadds the tool to the "Scripts that do NOT source common.sh" list inDOC/AGENT_Context_Project.mdautomatically.
2. Make it discoverable
- The dispatcher auto-discovers executable
bin/pos-*files — no registration needed. The file must be executable (chmod +x, committed as mode100755); the dispatcher andinstall.shskip non-executables. pos <category> --help(and barepos <category>) is derived from thepos-<category>-*filenames too — a new tool appears in its category's help automatically, with no registration (see TheposCLI).- Add the
# POS:header (single source of truth for the docs) right after the shebang/strict-mode lines:The description feeds the dispatch table, bin tree and file table in# POS: <category> <command> — one-line description rendered by `make gen` # POS_FLAGS: --flag1 --flag2 # ONLY for flag-style toolsDOC/AGENT_Context_Project.md;POS_FLAGSfeeds flag completion incompletions/pos.bash. Both update viamake gen. - Optionally add an EXAMPLES line in
bin/posusage()to showcase the tool inpos --help. - If the command reads stdin (prompts/selection), add it to
INTERACTIVE_CMDSinbin/pos— see TheposCLI.
3. Add system dependencies
Add package names to the PACKAGES array in preinstall.sh:
PACKAGES=(
...
your-package
)
Not in apt? If the dependency ships as a manual installer (no package — e.g. usbsrv, the USB Redirector server), do not put it in PACKAGES (that would break preinstall.sh). Instead, add a command -v <binary> || err "… install from <URL>" guard in the tool itself and note the manual install in usage()/DOC/POS.md.
4. Config files (if needed)
Two kinds of config, don't mix them up:
- Machine defaults shipped by the installer: place the file in
config/and add copy logic topostinstall.sh. If it contains secrets, add to.gitignoreand document inDOC/. - Runtime tool config set by the user:
~/.config/linux_post_install/<tool>.envwithchmod 600. Load it with env-var precedence (flags > environment > file). Patterns:pos-docker-compose(compose.env) andpos-communication-telegram(telegram.env, token masked inconfigoutput). Never store tokens in the repo.
5. Add SSH keys (if needed)
Place public keys in config/authorized_keys (one per line). postinstall.sh reads this file automatically.
6. Update the docs
DOC/POS.md: add the command to the section table + a detail block (commands, behavior, configuration). This is the one hand-written doc.DOC/AGENT_Context_Project.mdgenerated sections (bin tree, dispatch table, no-common.sh list, line-count table) and thecompletions/pos.bashflags block are produced bymake gen— do not hand-edit between theGEN:START/GEN:ENDmarkers.- Root
README.md: only if theposcategory list in the help text changes.
7. Test
chmod +x bin/your-tool
bash -n bin/your-tool
shellcheck bin/your-tool
./bin/your-tool --help
bin/pos help <full command> # confirm dispatch works
bin/pos <category> --help # confirm category listing includes the new tool (first tool in a new category)
make gen # regenerate doc tables + completion flags
make check # full self-consistency gate (syntax, exec bits, doc/code sync, smoke)
make check is the definition of done — the same check runs as a pre-commit hook once you've run make hook.
Adding an Optional App
Start from the template: cp templates/app.sh apps/<category>/<name>.sh.
1. Create the installer
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/../../lib/common.sh"
install_myapp() {
command -v myapp &>/dev/null && { log "myapp already installed"; return 0; }
spawn "Installing myapp" sudo apt install -y myapp
}
uninstall_myapp() {
command -v myapp &>/dev/null || { log "myapp not installed"; return 0; }
spawn "Removing myapp" sudo apt purge -y myapp
spawn "Cleaning up dependencies" sudo apt autoremove -y
}
case "${1:-}" in
uninstall) uninstall_myapp ;;
*) install_myapp ;;
esac
Place it in apps/<category>/<name>.sh. It auto-appears in the picker — no registration needed.
Categories: browsers, development, media, networking, remote-access, system, utilities
Docs: add a row to the catalog table in DOC/APPS.md (name, category, purpose, install method).
2. Conventions
- Idempotent: check
command -v(orflatpak list/ file existence) before installing and uninstalling - Every app must provide an
uninstall_<name>()function and dispatch onuninstallvia thecaseabove —apps/install.sh --uninstalldepends on it - APT packages →
sudo apt install -yinsidespawn, remove withsudo apt purge -y+sudo apt autoremove -y - Repo-based apps (apt repo added at install) → also remove the
.listfile and keyring in uninstall - Official scripts →
curl ... | shinsidespawn - Flatpak →
flatpak install -y flathub <app-id>insidespawn, remove withflatpak uninstall -y <app-id> .debfiles → download to temp,sudo apt install -y ./file.debinsidespawn- File/AppImage installs → remove the installed files, symlinks, and desktop entries in uninstall
usermodfor groups → print re-login reminder
Editing an Existing Tool
- Find the script in
bin/ - Understand its contract (args, output, exit codes)
- Make the change — keep it idempotent
- Update
DOC/POS.md(or the relevant doc) if behaviour changed - Run
shellcheckon the modified file
Best Practices
Idempotency
Check before creating, use >> with grep guards, don't overwrite user configs.
Error Handling
set -euo pipefail
command -v docker &>/dev/null || { echo "docker not found"; exit 1; }
[[ -n "${1:-}" ]] || { echo "Usage: my-tool <arg>"; exit 1; }
Portability
Targets Debian and Ubuntu. Use apt, assume bash at /usr/bin/env bash, check tools with command -v.
Dry-run Support
Scripts support --dry-run. Use the run() helper:
run() {
if [ "$DRY_RUN" -eq 1 ]; then
log "(dry-run) $*"
else
"$@"
fi
}
run sudo apt install -y git
Security
- Never hardcode secrets — put them in
config/(gitignored) or, for runtime tool config,~/.config/linux_post_install/<tool>.env chmod 600for sensitive files- Mask secrets in
configoutput (seepos-communication-telegram'smask_token) - Validate input before shell commands
- Use
sudoonly where needed
Naming
- CLI tools:
bin/pos-<category>-<command> - Legacy wrappers:
bin/wr-* - App installers:
apps/<category>/<name>.sh - Features:
features/<name>.sh - Lowercase with hyphens
Features & Flags
features/ holds scripts the user is likely to customize (e.g. autostart.sh). Unlike bin/ (synced on every install), features are installed on demand and never overwritten without asking.
Adding a Feature
Start from the template: cp templates/feature.sh features/<name>.sh.
- Create
features/<name>.shfollowing the CLI tool template (shebang,set -euo pipefail,--help). - Nothing else is registered —
./install.sh --featureauto-discovers it, copies it to/usr/local/bin/, asks before overwriting an existing file, and sets its flag. - If the feature backs a systemd service, gate the service on the flag in
postinstall.sh(see below).
Flag System
System-wide flag store at /usr/local/share/linux_post_install/flags/ (presence = set, content = optional value). Sourced via lib/flags.sh (or the installed /usr/local/bin/flags.sh):
source "$(dirname "$0")/lib/flags.sh" 2>/dev/null || source "$(dirname "$0")/flags.sh"
flag_set autostart # green flag
flag_set app "2.1" # green flag with a value
flag_is_set autostart # test (0/1) — the primitive consumers use
flag_value app # → "2.1"
flag_list # names of all set flags
flag_clear autostart
Writes use run + sudo, so they respect --dry-run. CLI equivalents: flag-reader, flag-set, flag-clear.
Example — service gated on a flag (in postinstall.sh's systemd loop):
if [ "$svc_name" = "myapp.service" ] && ! flag_is_set myapp; then
warn "myapp feature not installed — skipping myapp.service"
continue
fi
Working with Systemd
Create systemd/<name>.service — postinstall.sh copies it to /etc/systemd/system/ and enables it automatically.
[Unit]
Description=My Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/your-script.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
Working with Config Files
- Place the file in
config/ - Add copy logic to
postinstall.sh:
if [ -f config/your-config.conf ]; then
mkdir -p "$HOME/.config/your-app"
cp config/your-config.conf "$HOME/.config/your-app/your-config.conf"
chmod 600 "$HOME/.config/your-app/your-config.conf"
fi
Docker Compose / ScaleTail
The installer clones ScaleTail templates to /usr/local/share/linux_post_install/scale-tail/ — 119+ self-hosted services with a Tailscale sidecar pattern. Each service gets a tail-xxxxx.ts.net URL via network_mode: service:tailscale.
Config Strategy — Three Layers
Values cascade from least to most specific:
Template .env (per-service defaults from ScaleTail)
↓
Global config (~/.config/linux_post_install/compose.env)
↓
Per-service .env (/srv/<service>/.env) — created on first deploy, NEVER overwritten
On first pos docker compose up <service>:
- Template
.envis copied to/srv/<service>/.env - Matching keys from global config are filled in
- If
TS_AUTHKEYis still empty, you're prompted to enter it - After that, the per-service
.envis never touched — not even byupdate
Layout
| Path | Purpose | Mutability |
|---|---|---|
/usr/local/share/linux_post_install/scale-tail/services/<name>/ |
ScaleTail templates (git repo) | Read-only |
~/.config/linux_post_install/compose.env |
Your global defaults | Edit via config set or config edit |
/srv/<service>/ |
Active deployment | Per-service .env preserved forever |
Key Commands
| Command | Behaviour |
|---|---|
pos docker compose up <service> |
Deploys to $SERVICES_BASE/<service>/, creates config/ + data/, generates .env from global defaults |
pos docker compose down <service> |
Stops the stack |
pos docker compose update |
git pull templates + refreshes compose.yaml for all deployed services (.env untouched) |
pos docker compose config set K=V |
Sets a global default in ~/.config/linux_post_install/compose.env |
pos docker compose config show |
Displays current global config and SERVICES_BASE |
pos docker compose config edit |
Opens global config in $EDITOR |
Global Config Keys
| Key | Required | Default | Purpose |
|---|---|---|---|
TS_AUTHKEY |
Yes | — | Tailscale auth key for sidecar networking |
TZ |
No | Europe/Amsterdam |
Timezone for services |
DNS_SERVER |
No | 9.9.9.9 |
Custom DNS server |
SERVICES_BASE |
No | /srv |
Root directory for all deployments |
Commit Guidelines
- Use conventional prefixes:
feat:,fix:,docs:,refactor:,chore: - Explain why, not just what
- One logical change per commit
feat: add pos-disk-usage for monitoring disk space
fix: pos-network-ip fails when no default route exists
docs: add example output for pos-network-scan
Useful Commands
# Syntax check a single script
bash -n bin/my-script
# ShellCheck linting
shellcheck bin/my-script
# Check all scripts
for f in bin/* apps/*/*.sh lib/common.sh install.sh preinstall.sh postinstall.sh; do
bash -n "$f" || echo "FAIL: $f"
done
# Init submodule
git submodule update --init
# Pull latest ScaleTail templates
git submodule update --remote compose/scale-tail
# Test install in Docker
docker run --rm -it -v $PWD:/repo ubuntu:22.04 bash
# inside: cd /repo && ./install.sh
# Test app installers
./apps/install.sh
./apps/install.sh --all
./apps/install.sh docker vscode