# 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//` | Optional desktop app installers | run on demand | | `lib/` | Shared library (`common.sh`) | sourced at build time | | `config/` | Gitignored user config files | `~/.config//` (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 ` shows a tool's help, e.g. `pos help communication telegram` (all words joined with dashes → `pos-communication-telegram --help`). `pos ` or `pos --help` shows a category's subcommands (derived from the `pos--*` 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 the `pos-*` filenames in `bin/` (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 in `pos --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 through `tee` for 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 --ls` loses the `tee` log 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-- && chmod +x bin/pos--`. ### 1. Create the script ```bash #!/usr/bin/env bash set -euo pipefail source "$(dirname "$0")/../lib/common.sh" usage() { cat < EOF exit 0 } case "${1:-}" in -h|--help|"") usage ;; esac # --- script logic --- ``` **Conventions:** - Shebang: `#!/usr/bin/env bash` - Strict mode: `set -euo pipefail` - `--help` flag: accept `-h` / `--help` via `case` pattern - Shared library: always source `common.sh` for colors, logging, spinners - Exit codes: `0` success, `1` error - No shared lib? Inline fallbacks: ```bash log() { echo "[+] $*"; } warn() { echo "[!] $*"; } err() { echo "ERROR: $*" >&2; exit 1; } ``` If you skip `common.sh`, `make gen` adds the tool to the "Scripts that do NOT source common.sh" list in `DOC/AGENT_Context_Project.md` automatically. ### 2. Make it discoverable - The dispatcher auto-discovers executable `bin/pos-*` files — no registration needed. The file **must be executable** (`chmod +x`, committed as mode `100755`); the dispatcher and `install.sh` skip non-executables. - `pos --help` (and bare `pos `) is derived from the `pos--*` filenames too — a new tool appears in its category's help automatically, with no registration (see [The `pos` CLI](#the-pos-cli)). - **Add the `# POS:` header** (single source of truth for the docs) right after the shebang/strict-mode lines: ```bash # POS: — one-line description rendered by `make gen` # POS_FLAGS: --flag1 --flag2 # ONLY for flag-style tools ``` The description feeds the dispatch table, bin tree and file table in `DOC/AGENT_Context_Project.md`; `POS_FLAGS` feeds flag completion in `completions/pos.bash`. Both update via `make gen`. - Optionally add an EXAMPLES line in `bin/pos` `usage()` to showcase the tool in `pos --help`. - If the command **reads stdin** (prompts/selection), add it to `INTERACTIVE_CMDS` in `bin/pos` — see [The `pos` CLI](#the-pos-cli). ### 3. Add system dependencies Add package names to the `PACKAGES` array in `preinstall.sh`: ```bash 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 || err "… install from "` 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 to `postinstall.sh`. If it contains secrets, add to `.gitignore` and document in `DOC/`. - **Runtime tool config set by the user:** `~/.config/linux_post_install/.env` with `chmod 600`. Load it with env-var precedence (flags > environment > file). Patterns: `pos-docker-compose` (`compose.env`) and `pos-communication-telegram` (`telegram.env`, token masked in `config` output). 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.md` generated sections (bin tree, dispatch table, no-common.sh list, line-count table) and the `completions/pos.bash` flags block are produced by `make gen` — do **not** hand-edit between the `GEN:START`/`GEN:END` markers. - Root `README.md`: only if the `pos` category list in the help text changes. ### 7. Test ```bash chmod +x bin/your-tool bash -n bin/your-tool shellcheck bin/your-tool ./bin/your-tool --help bin/pos help # confirm dispatch works bin/pos --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//.sh`. ### 1. Create the installer ```bash #!/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//.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` (or `flatpak list` / file existence) before installing **and** uninstalling - Every app **must** provide an `uninstall_()` function and dispatch on `uninstall` via the `case` above — `apps/install.sh --uninstall` depends on it - APT packages → `sudo apt install -y` inside `spawn`, remove with `sudo apt purge -y` + `sudo apt autoremove -y` - Repo-based apps (apt repo added at install) → also remove the `.list` file and keyring in uninstall - Official scripts → `curl ... | sh` inside `spawn` - Flatpak → `flatpak install -y flathub ` inside `spawn`, remove with `flatpak uninstall -y ` - `.deb` files → download to temp, `sudo apt install -y ./file.deb` inside `spawn` - File/AppImage installs → remove the installed files, symlinks, and desktop entries in uninstall - `usermod` for groups → print re-login reminder --- ## Editing an Existing Tool 1. Find the script in `bin/` 2. Understand its contract (args, output, exit codes) 3. Make the change — keep it idempotent 4. Update `DOC/POS.md` (or the relevant doc) if behaviour changed 5. Run `shellcheck` on the modified file --- ## Best Practices ### Idempotency Check before creating, use `>>` with grep guards, don't overwrite user configs. ### Error Handling ```bash set -euo pipefail command -v docker &>/dev/null || { echo "docker not found"; exit 1; } [[ -n "${1:-}" ]] || { echo "Usage: my-tool "; 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: ```bash 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/.env` - `chmod 600` for sensitive files - Mask secrets in `config` output (see `pos-communication-telegram`'s `mask_token`) - Validate input before shell commands - Use `sudo` only where needed ### Naming - CLI tools: `bin/pos--` - Legacy wrappers: `bin/wr-*` - App installers: `apps//.sh` - Features: `features/.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/.sh`. 1. Create `features/.sh` following the CLI tool template (shebang, `set -euo pipefail`, `--help`). 2. Nothing else is registered — `./install.sh --feature` auto-discovers it, copies it to `/usr/local/bin/`, asks before overwriting an existing file, and sets its flag. 3. 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`): ```bash 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): ```bash 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/.service` — `postinstall.sh` copies it to `/etc/systemd/system/` and enables it automatically. ```ini [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 1. Place the file in `config/` 2. Add copy logic to `postinstall.sh`: ```bash 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](https://github.com/tailscale-dev/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//.env) — created on first deploy, NEVER overwritten ``` On first `pos docker compose up `: 1. Template `.env` is copied to `/srv//.env` 2. Matching keys from global config are filled in 3. If `TS_AUTHKEY` is still empty, you're prompted to enter it 4. After that, the per-service `.env` is **never touched** — not even by `update` ### Layout | Path | Purpose | Mutability | |------|---------|------------| | `/usr/local/share/linux_post_install/scale-tail/services//` | ScaleTail templates (git repo) | Read-only | | `~/.config/linux_post_install/compose.env` | Your global defaults | Edit via `config set` or `config edit` | | `/srv//` | Active deployment | Per-service `.env` preserved forever | ### Key Commands | Command | Behaviour | |---------|-----------| | `pos docker compose up ` | Deploys to `$SERVICES_BASE//`, creates `config/` + `data/`, generates `.env` from global defaults | | `pos docker compose down ` | 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 ```bash # 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 ```