diff --git a/CHANGELOG.md b/CHANGELOG.md index c61f06c..771ada4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added +- `agbox setup` asks `Apply? [Y]es / [n]o / [c]ustomize`. Customize goes through the + settings that vary between machines (profile, agents, agent mode, services, components, + user, hostname, timezone, git identity), then shows the updated plan. Answers are applied + like `--set` and saved to the config file. Runs with `--yes`, `--dry-run` or without a + terminal never ask. + +### Changed +- The `devtools` line in the plan only lists the GitHub CLI and Playwright when they are + enabled. + ## [0.1.0] - 2026-10-10 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 753721a..169bbf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ lib/config.sh schema, profiles, config parsing and validation lib/os.sh distro detection, host facts lib/modules.sh module registry and selection lib/preseed.sh `agbox preseed` +lib/wizard.sh the Apply?/customize prompt of `agbox setup` (answers become --set overrides) modules/*.sh one file per feature docs/ user docs (configuration.md's table is generated) tests/*.bats tests diff --git a/README.md b/README.md index 3fad7e5..4b70907 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ walk away. [→ docs/unattended-install.md](docs/unattended-install.md) agbox preseed --hostname home-server --ssh-key ~/.ssh/id_ed25519.pub -o preseed.cfg ``` -agbox shows its plan and asks before changing anything. When it's done, it prints the few -things only you can do (agent logins, `gh auth login`), then: +agbox shows its plan and asks before changing anything. Answer `c` to **customize**: a few +quick questions (profile, agents, permissions, services, components, user, hostname, +timezone, git identity), then the updated plan. When it's done, it prints the few things only +you can do (agent logins, `gh auth login`), then: ```console $ agbox doctor @@ -91,8 +93,9 @@ Useful flags: `--dry-run` (show every command, change nothing), `--only agents,m ## Configure -Zero config works. Everything is a setting: pass `--set` or edit `/etc/agbox/agbox.conf` -and re-run. +Zero config works. For the common choices, answer `c` at the `Apply?` prompt. Everything is a +setting: pass `--set` or edit `/etc/agbox/agbox.conf` and re-run. Your choices are saved +there, so the next run starts from them. ```bash agbox setup --set AGENTS="claude opencode" --set SERVICES=t3code --set PLAYWRIGHT=false diff --git a/bin/agbox b/bin/agbox index 8627bbe..3a0071f 100755 --- a/bin/agbox +++ b/bin/agbox @@ -19,6 +19,8 @@ source "$AGBOX_ROOT/lib/os.sh" source "$AGBOX_ROOT/lib/modules.sh" # shellcheck source=lib/preseed.sh source "$AGBOX_ROOT/lib/preseed.sh" +# shellcheck source=lib/wizard.sh +source "$AGBOX_ROOT/lib/wizard.sh" usage() { cat <&2 - elif ! confirm $'\nApply?'; then - die "aborted" + else + ask_apply + while [[ $WIZ_ANSWER == customize ]]; do + run_wizard + # Answers are overrides like --set: reload so profiles and validation apply to them. + OPT_SETS+=("${WIZARD_SETS[@]}") + init + print_plan "${SELECTED[@]}" + ask_apply + done + [[ $WIZ_ANSWER == yes ]] || die "aborted" fi preflight diff --git a/lib/config.sh b/lib/config.sh index 2f8c6fd..d5df0c9 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -211,6 +211,10 @@ _validate_words() { done } +# Pure validators, shared with the setup wizard. +is_valid_username() { [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; } +is_valid_hostname() { [[ $1 =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; } + validate_config() { local key for key in UPGRADE AUTO_UPDATES ZRAM PASSWORDLESS_SUDO FIREWALL TAILSCALE TAILSCALE_SSH \ @@ -222,12 +226,12 @@ validate_config() { _validate_words AGENTS "claude codex opencode" _validate_words SERVICES "t3code hermes" case $AGBOX_AGENT_MODE in yolo | sandboxed) ;; *) die "AGENT_MODE must be yolo or sandboxed" ;; esac - [[ $AGBOX_USER =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid USER '$AGBOX_USER'" + is_valid_username "$AGBOX_USER" || die "invalid USER '$AGBOX_USER'" [[ $AGBOX_USER != root ]] || die "USER must not be root: agents refuse to run unattended as root" if ! [[ $AGBOX_ZRAM_PERCENT =~ ^[0-9]+$ ]] || ((AGBOX_ZRAM_PERCENT < 10 || AGBOX_ZRAM_PERCENT > 150)); then die "ZRAM_PERCENT must be a number between 10 and 150" fi - [[ -z $AGBOX_HOSTNAME || $AGBOX_HOSTNAME =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || + [[ -z $AGBOX_HOSTNAME ]] || is_valid_hostname "$AGBOX_HOSTNAME" || die "invalid HOSTNAME '$AGBOX_HOSTNAME' (lowercase letters, digits and '-')" [[ -z $AGBOX_DOCKER_DATA_ROOT || $AGBOX_DOCKER_DATA_ROOT == /* ]] || die "DOCKER_DATA_ROOT must be an absolute path" diff --git a/lib/core.sh b/lib/core.sh index 29c8ebb..ffc5464 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -33,16 +33,25 @@ has_cmd() { command -v "$1" >/dev/null 2>&1; } is_root() { [[ $EUID -eq 0 ]]; } is_dry_run() { [[ ${AGBOX_DRY_RUN:-false} == true ]]; } +# Questions are read from the terminal, even under `curl | bash`. AGBOX_TTY points +# elsewhere only in tests, which feed answers from a file. +AGBOX_TTY=${AGBOX_TTY:-/dev/tty} + # Can we ask the operator a question? -is_interactive() { [[ ${AGBOX_YES:-false} != true ]] && [[ -r /dev/tty ]] && { : /dev/null; } - -# confirm "Question?" -> success on yes. Non-interactive runs answer yes. -confirm() { - is_interactive || return 0 - local reply - printf '%s [Y/n] ' "$1" >/dev/tty - read -r reply /dev/null; } + +# tty_printf FORMAT ARGS... : print a prompt where the operator sees it. +tty_printf() { + # shellcheck disable=SC2059 # FORMAT is the caller's format string + if [[ $AGBOX_TTY == /dev/tty ]]; then printf "$@" >/dev/tty; else printf "$@" >&2; fi +} + +# tty_read VAR : read one answer line. Fails on end of input. The descriptor stays open, +# so answers fed from a file are consumed in order. +_TTY_FD='' +tty_read() { + if [[ -z $_TTY_FD ]]; then exec {_TTY_FD}<"$AGBOX_TTY"; fi + IFS= read -r "$1" <&"$_TTY_FD" } # Render a command line for display, shell-quoted. diff --git a/lib/wizard.sh b/lib/wizard.sh new file mode 100644 index 0000000..cdba719 --- /dev/null +++ b/lib/wizard.sh @@ -0,0 +1,244 @@ +# shellcheck shell=bash +# The "customize" step of `agbox setup`: a few questions about the settings that differ from +# machine to machine. Answers become KEY=VALUE overrides, exactly like --set, so validation, +# --dry-run and the saved config all work unchanged. Nothing here changes the system. + +WIZ_ANSWER='' +WIZARD_SETS=() + +# ask_apply : the question after the plan. Sets WIZ_ANSWER to yes, no or customize. +# Non-interactive runs answer yes. +ask_apply() { + WIZ_ANSWER=yes + is_interactive || return 0 + local reply + while :; do + tty_printf '\nApply? [Y]es / [n]o / [c]ustomize: ' + if ! tty_read reply; then + tty_printf '\n' + WIZ_ANSWER=no + return 0 + fi + case ${reply,,} in + '' | y | yes) WIZ_ANSWER=yes ;; + n | no) WIZ_ANSWER=no ;; + c | customize) WIZ_ANSWER=customize ;; + *) continue ;; + esac + return 0 + done +} + +# run_wizard : ask, then leave the overrides in WIZARD_SETS (KEY=VALUE, only real changes). +run_wizard() { + WIZARD_SETS=() + tty_printf '\n%sCustomize%s %s(Enter keeps the current value, - clears a text one; Ctrl-C quits, nothing has changed yet)%s\n' \ + "$_C_BOLD" "$_C_RESET" "$_C_DIM" "$_C_RESET" + + _ask_choice "Profile" PROFILE \ + "homelab|machine on a network you trust" \ + "vps|public server: firewall on, key-only SSH" + _ask_words "Coding agents" AGENTS \ + "claude|Claude Code" \ + "codex|OpenAI Codex CLI" \ + "opencode|opencode" + if [[ -n $(_wizard_value AGENTS) ]]; then + _ask_choice "Agent permissions" AGENT_MODE \ + "yolo|no prompts, no sandbox: the machine is the sandbox" \ + "sandboxed|keep each agent's own prompts and sandbox" + fi + _ask_words "Always-on services" SERVICES \ + "t3code|web UI for coding agents, on your tailnet" \ + "hermes|Nous Research's Hermes Agent" + _ask_components + _ask_text "User the agents run as" USER '' _wizard_valid_user \ + "lowercase letters, digits, '-' and '_'; not root" + _ask_text "Hostname" HOSTNAME "keep $(uname -n)" is_valid_hostname \ + "lowercase letters, digits and '-'" + _ask_text "Timezone, e.g. Europe/Rome" TIMEZONE "keep $(_current_timezone)" _wizard_valid_timezone \ + "unknown timezone (see: timedatectl list-timezones)" + _ask_text "git user.name" GIT_NAME "keep current" + _ask_text "git user.email" GIT_EMAIL "keep current" _wizard_valid_email "expected name@domain" + + if ((${#WIZARD_SETS[@]} == 0)); then + tty_printf '\nNo changes.\n' + return 0 + fi + local kv + tty_printf '\n%sChanges:%s\n' "$_C_BOLD" "$_C_RESET" + for kv in "${WIZARD_SETS[@]}"; do tty_printf ' %s="%s"\n' "${kv%%=*}" "${kv#*=}"; done +} + +# ----------------------------------------------------------- pure helpers ---- + +# wizard_pick INPUT OPTION... -> the option INPUT names, by number or by name. Fails if none. +wizard_pick() { + local input=${1,,} opt + shift + if [[ $input =~ ^[1-9][0-9]*$ ]] && ((input <= $#)); then + printf '%s' "${!input}" + return 0 + fi + for opt in "$@"; do + if [[ $opt == "$input" ]]; then + printf '%s' "$opt" + return 0 + fi + done + return 1 +} + +# wizard_toggle "CURRENT WORDS" INPUT OPTION... -> CURRENT with each option named in INPUT +# (numbers or names, separated by spaces or commas) switched on or off, in OPTION order. +# Fails without output if INPUT names an unknown option. +wizard_toggle() { + local current=" $1 " opt out='' token + local -a tokens=() + read -ra tokens <<<"${2//,/ }" + shift 2 + for token in "${tokens[@]}"; do + opt=$(wizard_pick "$token" "$@") || return 1 + if [[ $current == *" $opt "* ]]; then current=${current/" $opt "/ }; else current+="$opt "; fi + done + for opt in "$@"; do + if [[ $current == *" $opt "* ]]; then out+="$opt "; fi + done + printf '%s' "${out% }" +} + +_wizard_valid_user() { is_valid_username "$1" && [[ $1 != root ]]; } +_wizard_valid_email() { [[ $1 =~ ^[^@[:space:]]+@[^@[:space:]]+$ ]]; } +_wizard_valid_timezone() { + [[ $1 =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+)*$ && -f /usr/share/zoneinfo/$1 ]] +} + +# --------------------------------------------------------------- prompts ---- + +# _wizard_set KEY VALUE : record an override, unless it's what the config already says. +_wizard_set() { + local var="AGBOX_$1" + if [[ $2 != "${!var}" ]]; then WIZARD_SETS+=("$1=$2"); fi +} + +# _wizard_value KEY -> the answer given so far, else the current value. +_wizard_value() { + local var="AGBOX_$1" value i + value=${!var} + for ((i = 0; i < ${#WIZARD_SETS[@]}; i++)); do + if [[ ${WIZARD_SETS[i]%%=*} == "$1" ]]; then value=${WIZARD_SETS[i]#*=}; fi + done + printf '%s' "$value" +} + +_wizard_eof() { + tty_printf '\n' + die "aborted: no answer" +} + +_wizard_warn() { tty_printf ' %s! %s%s\n' "$_C_YELLOW" "$1" "$_C_RESET"; } + +_current_timezone() { + local tz + tz=$(timedatectl show -p Timezone --value 2>/dev/null) || tz='' + if [[ -z $tz && -r /etc/timezone ]]; then tz=$("$BATS_TEST_TMPDIR/answers" + AGBOX_TTY="$BATS_TEST_TMPDIR/answers" run "$AGBOX" setup --set USER=agbox-dryrun-user + [ "$status" -ne 0 ] && [[ "$output" == *"error: aborted"* ]] + ! id agbox-dryrun-user 2>/dev/null +} + +@test "setup: customize updates the plan, then asks again" { + # profile, agents (toggle codex off), agent mode, services, components (gh off), user, + # hostname, timezone, git name, git email; then decline. + printf '%s\n' c vps 2 "" "" "" 4 "" "" "" "" "" "" n >"$BATS_TEST_TMPDIR/answers" + AGBOX_TTY="$BATS_TEST_TMPDIR/answers" run "$AGBOX" setup --set USER=agbox-dryrun-user + [ "$status" -ne 0 ] && [[ "$output" == *"error: aborted"* ]] + [[ "$output" == *"(homelab profile"* && "$output" == *"(vps profile"* ]] + [[ "$output" == *'AGENTS="claude"'* && "$output" == *'GH="false"'* ]] + [[ "$output" == *"coding agents (claude) in yolo mode"* ]] + [[ "$output" == *"✓ firewall"* ]] + [[ "$output" == *"devtools git defaults, Playwright"* ]] + [ "$(grep -c 'Apply?' <<<"$output")" -eq 2 ] + ! id agbox-dryrun-user 2>/dev/null +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash index 5080fb4..f02ea83 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -20,6 +20,8 @@ load_agbox() { source "$AGBOX_ROOT/lib/modules.sh" # shellcheck source=lib/preseed.sh source "$AGBOX_ROOT/lib/preseed.sh" + # shellcheck source=lib/wizard.sh + source "$AGBOX_ROOT/lib/wizard.sh" load_modules } diff --git a/tests/wizard.bats b/tests/wizard.bats new file mode 100644 index 0000000..38df3b1 --- /dev/null +++ b/tests/wizard.bats @@ -0,0 +1,117 @@ +#!/usr/bin/env bats +# The setup wizard: pure helpers, then the prompts fed from an answers file. + +setup() { + load test_helper + clean_env + load_agbox + AGBOX_USER=tester + export AGBOX_USER + load_config "" +} + +# answers LINE... : what the operator will type, one answer per line. +answers() { + printf '%s\n' "$@" >"$BATS_TEST_TMPDIR/answers" + AGBOX_TTY="$BATS_TEST_TMPDIR/answers" + _TTY_FD='' +} + +@test "wizard_pick: by number or name, case-insensitive" { + run wizard_pick 2 homelab vps + [ "$status" -eq 0 ] && [ "$output" = vps ] + run wizard_pick VPS homelab vps + [ "$status" -eq 0 ] && [ "$output" = vps ] +} + +@test "wizard_pick: rejects unknown names and out-of-range numbers" { + run wizard_pick 0 homelab vps + [ "$status" -eq 1 ] + run wizard_pick 3 homelab vps + [ "$status" -eq 1 ] + run wizard_pick 01 homelab vps + [ "$status" -eq 1 ] + run wizard_pick cloud homelab vps + [ "$status" -eq 1 ] && [ -z "$output" ] +} + +@test "wizard_toggle: switches options on and off, keeping option order" { + run wizard_toggle "claude codex" "3" claude codex opencode + [ "$output" = "claude codex opencode" ] + run wizard_toggle "claude codex" "1, codex" claude codex opencode + [ "$output" = "" ] + run wizard_toggle "opencode" "1" claude codex opencode + [ "$output" = "claude opencode" ] +} + +@test "wizard_toggle: empty input normalizes order and drops unknown words" { + run wizard_toggle "opencode bogus claude" "" claude codex opencode + [ "$status" -eq 0 ] && [ "$output" = "claude opencode" ] +} + +@test "wizard_toggle: fails on an unknown option" { + run wizard_toggle "claude" "1 9" claude codex opencode + [ "$status" -eq 1 ] && [ -z "$output" ] +} + +@test "ask_apply: yes, no and customize" { + answers "" + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = yes ] + answers N + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = no ] + answers what c + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = customize ] +} + +@test "ask_apply: end of input means no; --yes means yes without asking" { + answers + : >"$AGBOX_TTY" + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = no ] + AGBOX_YES=true + AGBOX_TTY=/nonexistent + ask_apply + [ "$WIZ_ANSWER" = yes ] +} + +@test "run_wizard: Enter everywhere changes nothing" { + answers "" "" "" "" "" "" "" "" "" "" + run_wizard 2>/dev/null + [ "${#WIZARD_SETS[@]}" -eq 0 ] +} + +@test "run_wizard: answers become overrides, invalid ones are asked again" { + answers \ + vps \ + "2 3" "" \ + sandboxed \ + t3code "" \ + "1,4" "" \ + root dev \ + Bad_Host box-1 \ + "" \ + "Ada Lovelace" \ + nope ada@example.com + run_wizard 2>"$BATS_TEST_TMPDIR/prompts" + local IFS='|' + [ "${WIZARD_SETS[*]}" = "PROFILE=vps|AGENTS=claude opencode|AGENT_MODE=sandboxed|SERVICES=t3code|DOCKER=false|GH=false|USER=dev|HOSTNAME=box-1|GIT_NAME=Ada Lovelace|GIT_EMAIL=ada@example.com" ] + grep -q "not root" "$BATS_TEST_TMPDIR/prompts" + grep -q "expected name@domain" "$BATS_TEST_TMPDIR/prompts" +} + +@test "run_wizard: no agents skips the permissions question; - clears a text value" { + AGBOX_GIT_NAME="Old Name" + answers "" "1 2" "" "" "" "" "" "" - "" + run_wizard 2>/dev/null + local IFS='|' + [ "${WIZARD_SETS[*]}" = "AGENTS=|GIT_NAME=" ] +} + +@test "run_wizard: running out of answers aborts" { + answers "" + run run_wizard + [ "$status" -ne 0 ] && [[ "$output" == *"aborted"* ]] +}