feat: add feature-flag subsystem with on-demand features install

- Move autostart.sh from bin/ to features/ so re-installs never reset it
- Add lib/flags.sh (flag_set/clear/is_set/value/list/status) + flag-reader/set/clear CLIs
- install.sh --feature copies features/* to /usr/local/bin with overwrite prompt, sets flags
- postinstall.sh enables autostart.service only when the autostart flag is set
- Document features & flags in README, DEV.md, AGENT_Context_Project.md
This commit is contained in:
Your Name
2026-08-03 02:40:51 -04:00
parent cf7ba2d58a
commit 2a2b4d81d1
10 changed files with 295 additions and 8 deletions
+33 -7
View File
@@ -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 <phase>|--steps <spec>]
User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip <phase>|--steps <spec>]
├─ 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 <phase>|--steps <spec>]
|------|---------|
| `--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 <phase>` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
| `--steps <spec>` | 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 <name> [value]`, `flag_clear <name>`, `flag_is_set <name>`, `flag_value <name>`, `flag_list`, `flag_status <name>`.
- 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-<cat>-<cmd>`, add deps in `preinstall.sh` |
| Add a new app installer | Create `apps/<name>.sh` (auto-discovered) |
| Add a systemd service | Create `systemd/<name>.service` (auto-installed by postinstall) |
| Add a feature | Create `features/<name>.sh` (installed on demand via `./install.sh --feature`) |
| Add a systemd service | Create `systemd/<name>.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` |
+39
View File
@@ -238,10 +238,49 @@ run sudo apt install -y git
- 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
1. Create `features/<name>.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/<name>.service` — `postinstall.sh` copies it to `/etc/systemd/system/` and enables it automatically.
+29
View File
@@ -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 <phase>` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint` |
| `--steps <spec>` | 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`.
---
Executable
+21
View File
@@ -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 <<EOF
Usage: flag-clear <name>
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"
+58
View File
@@ -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 <<EOF
Usage: flag-reader [OPTIONS] [name ...]
Show feature flag state. Flags live in $FLAGS_DIR
flag-reader list all flags with status
flag-reader <name>... status per flag (exit 0 if all are set)
flag-reader --raw <name> 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"
Executable
+21
View File
@@ -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 <<EOF
Usage: flag-set <name> [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"
+27 -1
View File
@@ -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 <phase> 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 ───────────────────────────────────────
+60
View File
@@ -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 <name> [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 <name> — remove a flag
flag_clear() {
run sudo rm -f "$FLAGS_DIR/$1"
}
# flag_is_set <name> — 0 if flag is set, 1 otherwise
flag_is_set() {
[ -f "$FLAGS_DIR/$1" ]
}
# flag_value <name> — 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 <name> — 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
}
+7
View File
@@ -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