Install Profile Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add profile save/load to the installer so configurations can be replicated across machines and re-runs are idempotent.

Architecture: A new bin/lib/profile.sh provides profile_save and profile_load. The prompt functions in bin/lib/prompt.sh gain auto-accept support (INSTALL_AUTO_ACCEPT=1). The dispatcher in bin/install.sh parses --profile, --yes, and --save-profile flags before dispatching to modules. Cron and aliases modules export their state variables so profile_save can capture them.

Tech Stack: Bash, bats-core (tests), existing bin/lib/ helpers

Design doc: docs/plans/2026-04-04-install-profile-design.md


Task 1: Add auto-accept support to bin/lib/prompt.sh

Files:

  • Modify: bin/lib/prompt.sh
  • Test: bin/tests/lib/test_prompt.bats

Step 1: Write failing tests

Add to bin/tests/lib/test_prompt.bats:

# --- INSTALL_AUTO_ACCEPT ---

@test "prompt_with_default auto-accepts existing value when INSTALL_AUTO_ACCEPT=1" {
    echo "MY_KEY=auto_val" > "$TEST_TMPDIR/.env"
    result="$(INSTALL_AUTO_ACCEPT=1 prompt_with_default "$TEST_TMPDIR/.env" "MY_KEY" "Enter value")"
    [ "$result" = "auto_val" ]
}

@test "prompt_with_default auto-accepts fallback when INSTALL_AUTO_ACCEPT=1 and key missing" {
    touch "$TEST_TMPDIR/.env"
    result="$(INSTALL_AUTO_ACCEPT=1 prompt_with_default "$TEST_TMPDIR/.env" "MY_KEY" "Enter value" "fb")"
    [ "$result" = "fb" ]
}

@test "prompt_with_default still prompts when INSTALL_AUTO_ACCEPT=1 but no default exists" {
    touch "$TEST_TMPDIR/.env"
    result="$(echo "typed" | INSTALL_AUTO_ACCEPT=1 prompt_with_default "$TEST_TMPDIR/.env" "MY_KEY" "Enter value")"
    [ "$result" = "typed" ]
}

@test "prompt_choice auto-accepts current value when INSTALL_AUTO_ACCEPT=1" {
    echo "ROLE=worker" > "$TEST_TMPDIR/.env"
    result="$(INSTALL_AUTO_ACCEPT=1 prompt_choice "$TEST_TMPDIR/.env" "ROLE" "Select role" "master:Master node" "worker:Worker node")"
    [ "$result" = "worker" ]
}

@test "prompt_yes_no auto-accepts default_y when INSTALL_AUTO_ACCEPT=1" {
    run bash -c 'source "'"$LIB_DIR"'/prompt.sh"; INSTALL_AUTO_ACCEPT=1 prompt_yes_no "Continue?" "default_y"'
    assert_success
}

@test "prompt_yes_no auto-accepts default_n when INSTALL_AUTO_ACCEPT=1" {
    run bash -c 'source "'"$LIB_DIR"'/prompt.sh"; INSTALL_AUTO_ACCEPT=1 prompt_yes_no "Continue?"'
    assert_failure
}

Step 2: Run tests to verify they fail

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/lib/test_prompt.bats Expected: FAIL — auto-accept not implemented yet.

Step 3: Implement auto-accept in prompt functions

In prompt_with_default, add early return after computing default:

    local default="${current:-$fallback}"

    # Auto-accept: return default without prompting
    if [[ "${INSTALL_AUTO_ACCEPT:-0}" == "1" ]] && [[ -n "$default" ]]; then
        printf '%s\n' "$default"
        return 0
    fi

In prompt_choice, add early return after finding current:

    # Auto-accept: return current value without prompting
    if [[ "${INSTALL_AUTO_ACCEPT:-0}" == "1" ]] && [[ -n "$current" ]]; then
        printf '%s\n' "$current"
        return 0
    fi

In prompt_yes_no, add early return after setting default:

    # Auto-accept: return default without prompting
    if [[ "${INSTALL_AUTO_ACCEPT:-0}" == "1" ]]; then
        if [[ "$default" == "default_y" ]]; then
            return 0
        else
            return 1
        fi
    fi

Step 4: Run tests to verify they pass

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/lib/test_prompt.bats Expected: All PASS

Step 5: Run full bats suite

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/ Expected: All PASS

Step 6: Commit

git add bin/lib/prompt.sh bin/tests/lib/test_prompt.bats
git commit -m "feat: add INSTALL_AUTO_ACCEPT support to prompt functions"

Task 2: Create bin/lib/profile.sh

Files:

  • Create: bin/lib/profile.sh
  • Test: bin/tests/lib/test_profile.bats

Step 1: Write failing tests

Create bin/tests/lib/test_profile.bats:

#!/usr/bin/env bats

setup() {
    load '../test_helper/common-setup'
    _common_setup
    source "$LIB_DIR/profile.sh"
    TEST_TMPDIR="$(mktemp -d)"
}

teardown() {
    rm -rf "$TEST_TMPDIR"
}

@test "profile.sh passes syntax check" {
    run bash -n "$LIB_DIR/profile.sh"
    assert_success
}

@test "profile_save writes non-sensitive keys" {
    # Set up a fake .env
    cat > "$TEST_TMPDIR/.env" <<'ENVEOF'
DJANGO_ENV=prod
DEPLOY_METHOD=bare
DJANGO_SECRET_KEY=supersecret
DJANGO_DEBUG=0
WEBHOOK_SECRET_CLUSTER=topsecret
CELERY_BROKER_URL=redis://localhost:6379/0
ENVEOF

    export PROJECT_DIR="$TEST_TMPDIR"
    profile_save "$TEST_TMPDIR/.install-profile" "test-profile"

    # Non-sensitive keys should be present
    grep -q "DJANGO_ENV=prod" "$TEST_TMPDIR/.install-profile"
    grep -q "DEPLOY_METHOD=bare" "$TEST_TMPDIR/.install-profile"
    grep -q "CELERY_BROKER_URL=redis://localhost:6379/0" "$TEST_TMPDIR/.install-profile"

    # Sensitive keys must NOT be present
    ! grep -q "DJANGO_SECRET_KEY" "$TEST_TMPDIR/.install-profile"
    ! grep -q "WEBHOOK_SECRET_CLUSTER" "$TEST_TMPDIR/.install-profile"
}

@test "profile_save writes metadata header" {
    cat > "$TEST_TMPDIR/.env" <<'ENVEOF'
DJANGO_ENV=dev
ENVEOF

    export PROJECT_DIR="$TEST_TMPDIR"
    profile_save "$TEST_TMPDIR/.install-profile" "my-profile"

    grep -q "# name: my-profile" "$TEST_TMPDIR/.install-profile"
    grep -q "# created:" "$TEST_TMPDIR/.install-profile"
    grep -q "# hostname:" "$TEST_TMPDIR/.install-profile"
    grep -q "# installer_version:" "$TEST_TMPDIR/.install-profile"
}

@test "profile_save captures installer state variables" {
    cat > "$TEST_TMPDIR/.env" <<'ENVEOF'
DJANGO_ENV=dev
ENVEOF

    export PROJECT_DIR="$TEST_TMPDIR"
    export CRON_SCHEDULE="*/5 * * * *"
    export CRON_AUTO_UPDATE=1
    export ALIAS_PREFIX=sm
    profile_save "$TEST_TMPDIR/.install-profile" "test"

    grep -q "CRON_SCHEDULE=" "$TEST_TMPDIR/.install-profile"
    grep -q "CRON_AUTO_UPDATE=1" "$TEST_TMPDIR/.install-profile"
    grep -q "ALIAS_PREFIX=sm" "$TEST_TMPDIR/.install-profile"

    unset CRON_SCHEDULE CRON_AUTO_UPDATE ALIAS_PREFIX
}

@test "profile_load writes values to .env" {
    cat > "$TEST_TMPDIR/.install-profile" <<'PROFEOF'
# server-maintanence install profile
# name: test
DJANGO_ENV=prod
DEPLOY_METHOD=docker
PROFEOF

    touch "$TEST_TMPDIR/.env"
    export PROJECT_DIR="$TEST_TMPDIR"
    profile_load "$TEST_TMPDIR/.install-profile"

    run grep "DJANGO_ENV=prod" "$TEST_TMPDIR/.env"
    assert_success
    run grep "DEPLOY_METHOD=docker" "$TEST_TMPDIR/.env"
    assert_success
}

@test "profile_load skips comments and blank lines" {
    cat > "$TEST_TMPDIR/.install-profile" <<'PROFEOF'
# server-maintanence install profile
# name: test

DJANGO_ENV=prod

# Celery
CELERY_TASK_ALWAYS_EAGER=0
PROFEOF

    touch "$TEST_TMPDIR/.env"
    export PROJECT_DIR="$TEST_TMPDIR"
    profile_load "$TEST_TMPDIR/.install-profile"

    run grep "DJANGO_ENV=prod" "$TEST_TMPDIR/.env"
    assert_success
    run grep "CELERY_TASK_ALWAYS_EAGER=0" "$TEST_TMPDIR/.env"
    assert_success
    # Comments should not appear in .env
    ! grep -q "^# name:" "$TEST_TMPDIR/.env"
}

@test "profile_load warns and skips sensitive keys if present" {
    cat > "$TEST_TMPDIR/.install-profile" <<'PROFEOF'
DJANGO_ENV=prod
DJANGO_SECRET_KEY=shouldnotload
PROFEOF

    touch "$TEST_TMPDIR/.env"
    export PROJECT_DIR="$TEST_TMPDIR"
    run bash -c 'source "'"$LIB_DIR"'/profile.sh"; export PROJECT_DIR="'"$TEST_TMPDIR"'"; profile_load "'"$TEST_TMPDIR"'/.install-profile"'
    assert_success
    assert_output --partial "WARN"

    ! grep -q "DJANGO_SECRET_KEY" "$TEST_TMPDIR/.env"
}

@test "profile_metadata reads metadata values" {
    cat > "$TEST_TMPDIR/.install-profile" <<'PROFEOF'
# server-maintanence install profile
# name: my-fleet-profile
# created: 2026-04-04T14:30:00
# hostname: web-01
DJANGO_ENV=prod
PROFEOF

    result="$(profile_metadata "$TEST_TMPDIR/.install-profile" "name")"
    [ "$result" = "my-fleet-profile" ]

    result="$(profile_metadata "$TEST_TMPDIR/.install-profile" "hostname")"
    [ "$result" = "web-01" ]
}

Step 2: Run tests to verify they fail

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/lib/test_profile.bats Expected: FAIL — profile.sh doesn’t exist.

Step 3: Write bin/lib/profile.sh

#!/usr/bin/env bash
#
# Install profile helpers — save/load installer configuration.
# Source this file — do not execute directly.
#
# Profiles store non-sensitive .env values and installer state variables
# (cron schedule, alias prefix, etc.) for reproducible installations.
#

[[ -n "${_LIB_PROFILE_LOADED:-}" ]] && return 0
_LIB_PROFILE_LOADED=1

_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_LIB_DIR/logging.sh"
source "$_LIB_DIR/dotenv.sh"

# Keys that must never appear in a profile
PROFILE_SENSITIVE_KEYS=(DJANGO_SECRET_KEY WEBHOOK_SECRET_CLUSTER)

# Installer state variables not stored in .env
PROFILE_STATE_KEYS=(CRON_SCHEDULE CRON_AUTO_UPDATE CRON_PUSH_TO_HUB ALIAS_PREFIX)

PROFILE_VERSION=1

# profile_save FILE [NAME]
#
# Read .env + shell state variables, write non-sensitive keys to FILE
# with a metadata header.
profile_save() {
    local file="$1"
    local name="${2:-}"
    local env_file="$PROJECT_DIR/.env"

    # Write metadata header
    {
        echo "# server-maintanence install profile"
        echo "# name: ${name:-$(basename "$file")}"
        echo "# created: $(date -u +%Y-%m-%dT%H:%M:%S%z 2>/dev/null || date +%Y-%m-%dT%H:%M:%S)"
        echo "# hostname: $(hostname 2>/dev/null || echo unknown)"
        echo "# installer_version: $PROFILE_VERSION"
        echo ""
    } > "$file"

    # Write non-sensitive keys from .env
    if [ -f "$env_file" ]; then
        while IFS= read -r line; do
            # Skip comments and blank lines
            [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue

            # Extract key
            local key="${line%%=*}"
            key="${key#"${key%%[![:space:]]*}"}"  # trim leading whitespace

            # Skip sensitive keys
            local sensitive=false
            for sk in "${PROFILE_SENSITIVE_KEYS[@]}"; do
                if [[ "$key" == "$sk" ]]; then
                    sensitive=true
                    break
                fi
            done
            $sensitive && continue

            echo "$line"
        done < "$env_file" >> "$file"
    fi

    # Write installer state variables (if set in environment)
    local has_state=false
    for sk in "${PROFILE_STATE_KEYS[@]}"; do
        if [[ -n "${!sk:-}" ]]; then
            if [[ "$has_state" == false ]]; then
                echo "" >> "$file"
                echo "# Installer state" >> "$file"
                has_state=true
            fi
            printf "%s=%s\n" "$sk" "${!sk}" >> "$file"
        fi
    done

    success "Profile saved to $file"
}

# profile_load FILE
#
# Read profile and write values to .env via dotenv_set.
# Sensitive keys are ignored with a warning.
# Also exports installer state variables into the shell environment.
profile_load() {
    local file="$1"
    local env_file="$PROJECT_DIR/.env"

    if [[ ! -f "$file" ]]; then
        error "Profile not found: $file"
        return 1
    fi

    dotenv_ensure_file

    info "Loading profile: $file"
    local name
    name="$(profile_metadata "$file" "name")"
    [[ -n "$name" ]] && info "Profile name: $name"

    while IFS= read -r line; do
        # Skip comments and blank lines
        [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue

        local key="${line%%=*}"
        local value="${line#*=}"

        # Skip sensitive keys with warning
        for sk in "${PROFILE_SENSITIVE_KEYS[@]}"; do
            if [[ "$key" == "$sk" ]]; then
                warn "Skipping sensitive key '$key' from profile"
                continue 2
            fi
        done

        # Check if this is an installer state variable
        local is_state=false
        for sk in "${PROFILE_STATE_KEYS[@]}"; do
            if [[ "$key" == "$sk" ]]; then
                is_state=true
                export "$key=$value"
                break
            fi
        done

        # Write to .env if not a state-only variable
        if [[ "$is_state" == false ]]; then
            dotenv_set "$env_file" "$key" "$value"
        fi
    done < "$file"

    success "Profile loaded"
}

# profile_metadata FILE KEY
#
# Read a metadata value from the profile header comments.
# Metadata lines look like: # key: value
profile_metadata() {
    local file="$1"
    local key="$2"

    grep -E "^# ${key}:" "$file" 2>/dev/null \
        | head -1 | sed "s/^# ${key}:[[:space:]]*//"
}

Step 4: Run tests to verify they pass

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/lib/test_profile.bats Expected: All PASS

Step 5: Run full suite

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/ Expected: All PASS

Step 6: Commit

git add bin/lib/profile.sh bin/tests/lib/test_profile.bats
git commit -m "feat: add install profile library for save/load configuration"

Task 3: Update bin/install.sh dispatcher with profile flags

Files:

  • Modify: bin/install.sh
  • Test: bin/tests/test_install.bats

Step 1: Write failing tests

Add to bin/tests/test_install.bats:

@test "install.sh help mentions --profile and --yes" {
    run "$BIN_DIR/install.sh" help
    assert_success
    assert_output --partial "--profile"
    assert_output --partial "--yes"
    assert_output --partial "--save-profile"
}

Step 2: Run test to verify it fails

Step 3: Update bin/install.sh

Add flag parsing before the case dispatcher. After the existing source lines at the top, add:

source "$SCRIPT_DIR/lib/prompt.sh"
source "$SCRIPT_DIR/lib/profile.sh"

# ── Flag parsing ─────────────────────────────────────────────────────────────

INSTALL_PROFILE_FILE=""
INSTALL_SAVE_PROFILE=""
INSTALL_PROFILE_NAME=""
export INSTALL_AUTO_ACCEPT="${INSTALL_AUTO_ACCEPT:-0}"

while [[ $# -gt 0 ]]; do
    case "$1" in
        --profile)
            [[ $# -lt 2 ]] && { error "--profile requires a file argument"; exit 1; }
            INSTALL_PROFILE_FILE="$2"
            shift 2
            ;;
        --yes|-y)
            export INSTALL_AUTO_ACCEPT=1
            shift
            ;;
        --save-profile)
            INSTALL_SAVE_PROFILE=1
            if [[ "${2:-}" != "" && "${2:-}" != -* ]]; then
                INSTALL_PROFILE_NAME="$2"
                shift
            fi
            shift
            ;;
        *)
            break
            ;;
    esac
done

# Resolve profile file path
if [[ -n "$INSTALL_PROFILE_FILE" ]]; then
    # Try as name first: .install-profile-<name>
    if [[ ! -f "$INSTALL_PROFILE_FILE" && -f "$PROJECT_DIR/.install-profile-$INSTALL_PROFILE_FILE" ]]; then
        INSTALL_PROFILE_FILE="$PROJECT_DIR/.install-profile-$INSTALL_PROFILE_FILE"
    fi
    profile_load "$INSTALL_PROFILE_FILE"
fi

Update show_usage to include new flags:

show_usage() {
    echo ""
    echo "Usage: install.sh [options] [step] [step-options]"
    echo ""
    echo "Options:"
    echo "  --profile FILE    Load saved profile (pre-fills prompts from FILE)"
    echo "  --yes, -y         Accept all defaults without prompting (secrets still prompted)"
    echo "  --save-profile [NAME]  Save configuration to profile after install"
    echo ""
    echo "Steps:"
    ...existing steps...
}

After the case dispatcher (at the very end of the file), add profile save logic:

# ── Post-install: save profile ───────────────────────────────────────────────

if [[ "${INSTALL_SAVE_PROFILE:-}" == "1" ]]; then
    local profile_path="$PROJECT_DIR/.install-profile"
    if [[ -n "$INSTALL_PROFILE_NAME" ]]; then
        profile_path="$PROJECT_DIR/.install-profile-$INSTALL_PROFILE_NAME"
    fi
    profile_save "$profile_path" "$INSTALL_PROFILE_NAME"
elif [[ "${1:-}" == "" && "${INSTALL_AUTO_ACCEPT:-0}" != "1" ]]; then
    # Full install mode: offer to save
    if prompt_yes_no "Save this configuration as a profile?"; then
        local pname
        pname=$(prompt_with_default /dev/null "" "Profile name" "$(hostname 2>/dev/null || echo default)")
        local profile_path="$PROJECT_DIR/.install-profile"
        [[ "$pname" != "$(hostname 2>/dev/null || echo default)" ]] && profile_path="$PROJECT_DIR/.install-profile-$pname"
        profile_save "$profile_path" "$pname"
    fi
fi

Note: The save-profile block at the end cannot use local since it’s not inside a function. Use plain variables or wrap in a function.

Step 4: Run tests

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/test_install.bats Expected: All PASS

Step 5: Commit

git add bin/install.sh bin/tests/test_install.bats
git commit -m "feat: add --profile, --yes, --save-profile flags to install.sh"

Task 4: Export state variables from cron and aliases modules

Files:

  • Modify: bin/install/cron.sh
  • Modify: bin/install/aliases.sh

Step 1: Update bin/install/cron.sh

After the cron schedule is chosen (around the line info "Using schedule: $CRON_SCHEDULE"), export the state:

export CRON_SCHEDULE

After the auto-update section, export:

export CRON_AUTO_UPDATE=1  # or 0 based on user choice

After the push-to-hub section, export:

export CRON_PUSH_TO_HUB=1  # or 0 based on user choice

The cron module currently sets CRON_SCHEDULE as a plain variable. Find where it’s set and ensure it’s exported. Also, the auto-update and push-to-hub sections use if [[ $REPLY =~ ^[Yy]$ ]] patterns — after those blocks, set and export the state variable.

Read bin/install/cron.sh fully to find the exact lines. Add export CRON_AUTO_UPDATE and export CRON_PUSH_TO_HUB after the relevant prompt_yes_no calls.

Step 2: Update bin/install/aliases.sh

After the prefix is determined (in the setup action path), export:

export ALIAS_PREFIX="$prefix"

Read bin/install/aliases.sh fully to find where prefix is finalized, and add the export there.

Step 3: Run bats tests

Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/ Expected: All PASS

Step 4: Commit

git add bin/install/cron.sh bin/install/aliases.sh
git commit -m "feat: export installer state variables for profile save"

Task 5: Add .install-profile* to .gitignore

Files:

  • Modify: .gitignore

Step 1: Add gitignore entry

Add to .gitignore:

# Install profiles (may contain environment-specific config)
.install-profile*

Step 2: Verify

touch .install-profile-test
git check-ignore .install-profile-test && echo "IGNORED" || echo "NOT IGNORED"
rm .install-profile-test

Expected: IGNORED

Step 3: Commit

git add .gitignore
git commit -m "chore: gitignore install profiles"

Task 6: Update documentation

Files:

  • Modify: bin/README.md — add profile section to install.sh docs
  • Modify: docs/Installation.md — add profile usage examples

Step 1: Update bin/README.md

Add a “Profiles” section under the install.sh documentation:

### Profiles

Save and load installer configurations for fleet consistency:

```bash
# Save after install
./bin/install.sh --save-profile prod-web

# Load on another machine (pre-fills all prompts)
./bin/install.sh --profile prod-web

# Fully automated (only prompts for secrets)
./bin/install.sh --profile prod-web --yes

Profiles are stored as .install-profile* files (gitignored). They contain all non-sensitive .env values plus installer state (cron schedule, alias prefix, etc.). Secrets (DJANGO_SECRET_KEY, WEBHOOK_SECRET_CLUSTER) are never saved to profiles.


**Step 2: Update `docs/Installation.md`**

Add a "Profiles" section with usage examples.

**Step 3: Commit**

```bash
git add bin/README.md docs/Installation.md
git commit -m "docs: add install profile usage documentation"

Task 7: Run full test suite and verify

Step 1: Run bats tests

bin/tests/test_helper/bats-core/bin/bats bin/tests/

Expected: All PASS

Step 2: Run pytest

uv run pytest

Expected: All PASS

Step 3: Syntax check all modified files

for f in bin/lib/profile.sh bin/lib/prompt.sh bin/install.sh bin/install/cron.sh bin/install/aliases.sh; do
    bash -n "$f" && echo "OK: $f" || echo "FAIL: $f"
done

Expected: All OK

Step 4: Smoke test

bin/install.sh help

Expected: Shows –profile, –yes, –save-profile in usage


This site uses Just the Docs, a documentation theme for Jekyll.