diff --git a/AGENT_Context_Project.md b/AGENT_Context_Project.md index cb54417..49ed3be 100644 --- a/AGENT_Context_Project.md +++ b/AGENT_Context_Project.md @@ -29,7 +29,8 @@ Linux_post_install/ ├── postinstall.sh # Phase 3: PATH, bash completion, systemd services │ ├── lib/ -│ └── common.sh # Shared library (colors, logging, spinner, timer, run) +│ ├── common.sh # Shared library (colors, logging, spinner, timer, run) +│ └── flags.sh # Feature flag store (flag_set/clear/is_set/value/list/status) │ ├── bin/ # CLI tools — installed to /usr/local/bin/ │ ├── pos # Main dispatcher — smart arg matching to pos-* scripts @@ -44,11 +45,16 @@ Linux_post_install/ │ ├── pos-system-firewall # Interactive UFW manager (menu-driven, 284 lines) │ ├── pos-ssh-load-keys # Load SSH keys into ssh-agent │ ├── pos-vbox # Disposable Docker-based "VMs" -│ ├── autostart.sh # Boot-time script (via systemd) +│ ├── flag-reader # Inspect feature flags (list/status/--raw) +│ ├── flag-set # Set a feature flag (optionally with a value) +│ ├── flag-clear # Unset a feature flag │ ├── wr-* # Legacy wrappers → pos (backward compat) │ ├── mp3, mp4, vbox # Legacy convenience wrappers → pos │ └── ssh-load-all # Legacy wrapper → pos ssh load-keys │ +├── features/ # User-customizable scripts (installed via --feature) +│ └── autostart.sh # Boot-time script (via systemd, flag-gated) +│ ├── apps/ # Optional desktop app installers (by category) │ ├── install.sh # Interactive picker / orchestrator │ ├── browsers/ @@ -99,20 +105,23 @@ Linux_post_install/ ## 3. Installation Flow ``` -User runs: ./install.sh [--apps|--full|--dry-run|--skip |--steps ] +User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip |--steps ] │ ├─ Phase 1: preinstall.sh (requires root) │ └─ apt update + installs 25+ packages + yt-dlp + fail2ban │ ├─ Phase 2: install.sh (requires root) │ └─ Copies bin/* → /usr/local/bin/ (chmod 755) -│ └─ Copies lib/common.sh → /usr/local/bin/common.sh (chmod 644) +│ └─ Copies lib/common.sh + lib/flags.sh → /usr/local/bin/ (chmod 644) +│ └─ [if --feature] Copies features/* → /usr/local/bin/ (asks before overwriting), +│ then sets the matching feature flag │ ├─ Phase 3: postinstall.sh (runs as user) │ └─ Configures fail2ban (SSH jail: 5 retries, 1h ban) │ └─ PATH export in ~/.bashrc │ └─ Bash completion for pos CLI │ └─ Copies systemd/*.service → /etc/systemd/system/, enables them +│ (autostart.service only when the `autostart` flag is set) │ ├─ Phase 4: ScaleTail clone │ └─ Shallow-clones ScaleTail templates to /usr/local/share/linux_post_install/scale-tail @@ -129,6 +138,7 @@ User runs: ./install.sh [--apps|--full|--dry-run|--skip |--steps ] |------|---------| | `--apps` | Run interactive app picker after core install | | `--full` | Core install + all apps (non-interactive) | +| `--feature` | Install `features/` scripts to `/usr/local/bin/` (asks before overwriting), set their flags | | `--dry-run` | Preview without executing | | `--skip ` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` | | `--steps ` | Run only specific phases. Format: `1,3,4` or `1-3` | @@ -316,6 +326,15 @@ All `.service` files in `systemd/` are automatically copied to `/etc/systemd/sys - `~/.config/linux_post_install/compose.env` — Docker Compose global defaults - `~/.bashrc` — Modified by postinstall (PATH, bash completion) +### Feature Flags + +System-wide flag store at `/usr/local/share/linux_post_install/flags/`: +- One file per flag; **presence = set**, **file content = optional value** (dir 755, files 644). +- Library: `lib/flags.sh` (installed as `/usr/local/bin/flags.sh`) — `flag_set [value]`, `flag_clear `, `flag_is_set `, `flag_value `, `flag_list`, `flag_status `. +- CLI: `flag-reader` (list / status / `--raw`), `flag-set`, `flag-clear`. +- Set by `./install.sh --feature`; read by `postinstall.sh` to gate systemd enablement (e.g. `autostart.service` requires the `autostart` flag). +- Writes use `run` + `sudo`, so they respect `--dry-run`. `FLAGS_DIR` is env-overridable for tests. + --- ## 10. Coding Conventions @@ -396,10 +415,15 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | File | Lines | Purpose | |------|-------|---------| -| `install.sh` | 149 | Main orchestrator — 4 phases with CLI flags | +| `install.sh` | 175 | Main orchestrator — 4 phases with CLI flags, `--feature` block | | `preinstall.sh` | 52 | System packages + yt-dlp + fail2ban | -| `postinstall.sh` | 90 | fail2ban config, PATH, bash completion, systemd | +| `postinstall.sh` | 97 | fail2ban config, PATH, bash completion, systemd (flag-gated) | | `lib/common.sh` | 121 | Shared library | +| `lib/flags.sh` | 60 | Feature flag store (set/clear/is_set/value/list/status) | +| `bin/flag-reader` | 58 | Inspect flags (list/status/`--raw`) | +| `bin/flag-set` | 21 | Set a flag (optionally with a value) | +| `bin/flag-clear` | 21 | Unset a flag | +| `features/autostart.sh` | 14 | Boot-time feature (moved from `bin/`, flag-gated service) | | `bin/pos` | 145 | CLI dispatcher with smart arg matching + logging | | `bin/pos-docker-compose` | 363 | Largest script — full compose management | | `bin/pos-system-firewall` | 284 | Interactive UFW manager | @@ -417,7 +441,9 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` |------|---------------| | Add a new CLI tool | Create `bin/pos--`, add deps in `preinstall.sh` | | Add a new app installer | Create `apps/.sh` (auto-discovered) | -| Add a systemd service | Create `systemd/.service` (auto-installed by postinstall) | +| Add a feature | Create `features/.sh` (installed on demand via `./install.sh --feature`) | +| Add a systemd service | Create `systemd/.service` (auto-installed by postinstall; gate on a flag if it backs a feature) | +| Inspect/set feature flags | `flag-reader`, `flag-set`, `flag-clear` (or source `lib/flags.sh`) | | Modify package list | Edit `PACKAGES` array in `preinstall.sh` | | Change PATH or bash config | Edit `postinstall.sh` | | Modify fail2ban config | Edit jail.local section in `postinstall.sh` | diff --git a/DEV.md b/DEV.md index 1f7ca18..c5d9e80 100644 --- a/DEV.md +++ b/DEV.md @@ -238,10 +238,49 @@ run sudo apt install -y git - 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 + +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. diff --git a/README.md b/README.md index 3aa703c..be2eaa3 100755 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ cd Linux_post_install ./install.sh # core: packages + CLI + services + ScaleTail ./install.sh --apps # core + interactive app picker ./install.sh --full # core + all apps (non-interactive) +./install.sh --feature # core + install features/ scripts (prompts on overwrite) ``` **Flags:** @@ -35,6 +36,7 @@ cd Linux_post_install |------|-------------| | `--apps` | Run interactive app picker after core install | | `--full` | Core install + all apps (no prompts) | +| `--feature` | Install `features/` scripts to `/usr/local/bin/` (asks before overwriting), sets their feature flags | | `--dry-run` | Preview without executing anything | | `--skip ` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint` | | `--steps ` | Run specific phases only, e.g. `--steps 1,3` or `--steps 1-3` | @@ -51,6 +53,33 @@ cd Linux_post_install | 3 | `postinstall.sh` | Configures fail2ban, SSH agent, PATH, bash completion, systemd services | | 4 | ScaleTail clone | Downloads 119+ Docker Compose templates with Tailscale sidecar | | 5 (opt) | `apps/install.sh` | Installs desktop apps you select | +| opt | `./install.sh --feature` | Installs `features/` scripts (never overwrites without asking) | + +--- + +## Features & Flags + +`features/` holds scripts you're likely to customize (like `autostart.sh`), kept out of `bin/` so a plain re-install never resets them. + +```bash +./install.sh --feature # install features/ — asks before overwriting +``` + +- Each feature is copied to `/usr/local/bin/`; if the file already exists you're asked **"Overwrite? [y/N]"** — your existing config is kept by default. +- A successful install sets a **feature flag** at `/usr/local/share/linux_post_install/flags/` (presence = set, file content = optional value). +- Flags drive systemd: e.g. `autostart.service` is enabled only when the `autostart` flag is green. + +Inspect and manage flags: + +```bash +flag-reader # list all flags + status +flag-reader autostart # check one flag (exit 0 if set) +flag-reader --raw autostart # print the raw value only (script-friendly) +flag-set autostart prod # set a flag, optionally with a value +flag-clear autostart # unset a flag +``` + +Any project script can `source lib/flags.sh` (or the installed `/usr/local/bin/flags.sh`) and use `flag_set`, `flag_is_set`, `flag_value`, `flag_clear`. --- diff --git a/bin/flag-clear b/bin/flag-clear new file mode 100755 index 0000000..163e64b --- /dev/null +++ b/bin/flag-clear @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +source "$(dirname "$0")/../lib/flags.sh" 2>/dev/null || source "$(dirname "$0")/flags.sh" + +usage() { + cat < + +Unset a feature flag (remove it from $FLAGS_DIR). +Requires sudo. +EOF + exit 0 +} + +case "${1:-}" in + -h|--help|"") usage ;; +esac + +flag_clear "$1" +ok "flag cleared: $1" diff --git a/bin/flag-reader b/bin/flag-reader new file mode 100755 index 0000000..cebf9b6 --- /dev/null +++ b/bin/flag-reader @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +source "$(dirname "$0")/../lib/flags.sh" 2>/dev/null || source "$(dirname "$0")/flags.sh" + +usage() { + cat <... status per flag (exit 0 if all are set) + flag-reader --raw print the raw stored value only (script-friendly) +EOF + exit 0 +} + +RAW=0 +NAMES=() + +for arg in "$@"; do + case "$arg" in + -h|--help) usage ;; + --raw) RAW=1 ;; + -*) echo "ERROR: unknown option: $arg" >&2; exit 1 ;; + *) NAMES+=("$arg") ;; + esac +done + +if [ "$RAW" -eq 1 ]; then + [ "${#NAMES[@]}" -eq 1 ] || { echo "ERROR: --raw requires exactly one name" >&2; exit 1; } + flag_value "${NAMES[0]}" + exit 0 +fi + +if [ "${#NAMES[@]}" -eq 0 ]; then + if [ -d "$FLAGS_DIR" ] && [ -n "$(ls -A "$FLAGS_DIR" 2>/dev/null)" ]; then + for f in "$FLAGS_DIR"/*; do + [ -f "$f" ] || continue + flag_status "$(basename "$f")" + done + else + warn "no flags set" + fi + exit 0 +fi + +rc=0 +for name in "${NAMES[@]}"; do + if flag_is_set "$name"; then + ok "$(flag_status "$name")" + else + warn "$(flag_status "$name")" + rc=1 + fi +done +exit "$rc" diff --git a/bin/flag-set b/bin/flag-set new file mode 100755 index 0000000..1b25dc8 --- /dev/null +++ b/bin/flag-set @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +source "$(dirname "$0")/../lib/flags.sh" 2>/dev/null || source "$(dirname "$0")/flags.sh" + +usage() { + cat < [value] + +Set a feature flag (green), optionally storing a value. +Writes to $FLAGS_DIR (requires sudo). +EOF + exit 0 +} + +case "${1:-}" in + -h|--help|"") usage ;; +esac + +flag_set "$1" "${2:-}" +ok "flag set: $1" diff --git a/bin/autostart.sh b/features/autostart.sh similarity index 100% rename from bin/autostart.sh rename to features/autostart.sh diff --git a/install.sh b/install.sh index 021d2ad..86a7c64 100755 --- a/install.sh +++ b/install.sh @@ -12,9 +12,11 @@ if [ "$NO_COLOR" -eq 1 ]; then fi source "$(dirname "$0")/lib/common.sh" +source "$(dirname "$0")/lib/flags.sh" DRY_RUN=0 RUN_APPS=0 +RUN_FEATURES=0 SKIP_PHASES="" STEPS_SPEC="" @@ -27,6 +29,7 @@ Bootstrap a fresh Debian/Ubuntu install. Options: --apps Run interactive app picker after core install --full Core install + all optional apps (non-interactive) + --feature Install features/ scripts (prompts before overwriting) --dry-run Show what would be done without executing --skip Skip a phase (repeatable): preinstall, scripts, postinstall, scalepoint, apps @@ -47,6 +50,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --apps) RUN_APPS=1; shift ;; --full) RUN_APPS=2; shift ;; + --feature) RUN_FEATURES=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --skip) [ -z "${2:-}" ] && err "Missing value for --skip" @@ -108,7 +112,29 @@ if should_run 2 scripts; then count=$((count + 1)) done run sudo install -m 644 lib/common.sh /usr/local/bin/common.sh - ok "$count scripts + lib -> /usr/local/bin" + run sudo install -m 644 lib/flags.sh /usr/local/bin/flags.sh + + # ── Optional features ────────────────────────────────────── + if [ "$RUN_FEATURES" -eq 1 ]; then + for f in features/*; do + [ -f "$f" ] || continue + name=$(basename "$f") + flag_name="${name%.sh}" + dest="/usr/local/bin/$name" + if [ -e "$dest" ]; then + if confirm "Overwrite existing $dest?" n; then + run sudo install -m 755 "$f" "$dest" + else + log "Keeping existing $dest" + fi + else + run sudo install -m 755 "$f" "$dest" + fi + flag_set "$flag_name" + done + ok "features installed" + fi + ok "$count scripts + libs -> /usr/local/bin" fi # ── Phase 3: postinstall ─────────────────────────────────────── diff --git a/lib/flags.sh b/lib/flags.sh new file mode 100644 index 0000000..50f9a65 --- /dev/null +++ b/lib/flags.sh @@ -0,0 +1,60 @@ +# ── Feature flag store ───────────────────────────────────────── +# System-wide flags: one file per flag in $FLAGS_DIR. +# presence = flag set, file content = optional value. +# Sourced by scripts; reads are plain file ops, writes use sudo. +# Override FLAGS_DIR via environment for testing. +FLAGS_DIR="${FLAGS_DIR:-/usr/local/share/linux_post_install/flags}" + +# flag_set [value] — mark a flag as set (green), optionally with a value +flag_set() { + local name="$1" value="${2:-}" tmp + run sudo install -d -m 755 "$FLAGS_DIR" + if [ -n "$value" ]; then + tmp=$(mktemp) + printf '%s' "$value" > "$tmp" + run sudo install -m 644 "$tmp" "$FLAGS_DIR/$name" + rm -f "$tmp" + else + run sudo touch "$FLAGS_DIR/$name" + fi +} + +# flag_clear — remove a flag +flag_clear() { + run sudo rm -f "$FLAGS_DIR/$1" +} + +# flag_is_set — 0 if flag is set, 1 otherwise +flag_is_set() { + [ -f "$FLAGS_DIR/$1" ] +} + +# flag_value — print the stored value (empty when unset or valueless) +flag_value() { + local f="$FLAGS_DIR/$1" + [ -f "$f" ] && cat "$f" +} + +# flag_list — print the names of all set flags, one per line +flag_list() { + [ -d "$FLAGS_DIR" ] || return 0 + for f in "$FLAGS_DIR"/*; do + [ -f "$f" ] || continue + printf '%s\n' "$(basename "$f")" + done +} + +# flag_status — print a human-readable status line +flag_status() { + local name="$1" v + if flag_is_set "$name"; then + v=$(flag_value "$name") + if [ -n "$v" ]; then + echo "set: $name=$v" + else + echo "set: $name" + fi + else + echo "unset: $name" + fi +} diff --git a/postinstall.sh b/postinstall.sh index 3cafca0..6bd8d12 100755 --- a/postinstall.sh +++ b/postinstall.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail source "$(dirname "$0")/lib/common.sh" +source "$(dirname "$0")/lib/flags.sh" log "Running post-install..." @@ -79,6 +80,12 @@ if [ -d systemd ] && [ -n "$(ls -A systemd/*.service 2>/dev/null)" ]; then for svc in systemd/*.service; do svc_name=$(basename "$svc") + # autostart.service runs features/autostart.sh — enable only when + # the autostart feature flag is green (set by ./install.sh --feature) + if [ "$svc_name" = "autostart.service" ] && ! flag_is_set autostart; then + warn "autostart feature not installed — skipping autostart.service (run ./install.sh --feature)" + continue + fi run sudo systemctl enable --now "$svc_name" 2>/dev/null || \ run sudo systemctl enable "$svc_name" done