diff --git a/.gitignore b/.gitignore index 8d66968..3b94d65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ *.log tmp/ +# Python +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ + # Runtime files (generated by setup.sh) .env .assemblrr-config diff --git a/README.md b/README.md index 783ac7f..4789ce0 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,8 @@ After install, the CLI is on your `PATH` as `assemblrr`. Run `assemblrr "$cookie_jar" + curl -s --connect-timeout 5 -c "$cookie_jar" \ + -H "Referer: http://${API_HOST}:${qbit_port}" \ + -d "username=${user}&password=${pass}" \ + "http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true + [ -n "$(qbit_cookie_sid "$cookie_jar")" ] +} + +# Login with the old password, then set the new WebUI user/pass. +# Tries new (already changed) then the first-boot temp password if old fails. +qbit_change_credentials() { + local old_user="$1" + local old_pass="$2" + local new_user="$3" + local new_pass="$4" + local qbit_port=8081 + local cookie_jar="/tmp/qb_cookie_jar_chpass_$$_${qbit_port}" + local verify_jar="/tmp/qb_cookie_jar_chpass_v_$$_${qbit_port}" + + if [ -z "$new_user" ] || [ -z "$new_pass" ]; then + log_step_fail "qBittorrent: new credentials missing" + return 1 + fi + + if ! qbit_try_login "$old_user" "$old_pass" "$cookie_jar"; then + if qbit_try_login "$new_user" "$new_pass" "$cookie_jar"; then + qbit_apply_prefs "$cookie_jar" + rm -f "$cookie_jar" + log_step "qBittorrent: already using the new password" + return 0 + fi + local qbit_temp_pass + qbit_temp_pass=$(docker logs qbittorrent 2>&1 | awk -F': ' '/temporary password is provided for this session:/ { print $NF; exit }' || true) + if [ -z "$qbit_temp_pass" ] || ! qbit_try_login "admin" "$qbit_temp_pass" "$cookie_jar"; then + rm -f "$cookie_jar" + log_step_fail "qBittorrent: could not log in with the old password" + return 1 + fi + fi + + qbit_apply_prefs "$cookie_jar" \ + ",\"web_ui_username\":\"${new_user}\",\"web_ui_password\":\"${new_pass}\"" + rm -f "$cookie_jar" + + if qbit_try_login "$new_user" "$new_pass" "$verify_jar"; then + rm -f "$verify_jar" + log_step "qBittorrent: set login (${new_user})" + return 0 + fi + rm -f "$verify_jar" + log_step_fail "qBittorrent: password change did not stick" + return 1 +} + # Create or update a qBittorrent category with a save path qbit_create_category() { local service_name="$1" @@ -456,28 +517,264 @@ relax_quality_sizes() { return 1 } -# Record the preferred quality profile id/name for this install (setup choice). +# Item ids whose qualityProfileId still needs updating. +# stdin: JSON array of {id, qualityProfileId}. $1 = target id. $2 = all | any-only | from: +arr_quality_ids_needing_update() { + local pid="$1" + local mode="${2:-all}" + case "$mode" in + any-only) + jq -c --argjson pid "$pid" '[.[] | select((.qualityProfileId // 0) == 1 and .id != null) | .id]' + ;; + from:*) + local from="${mode#from:}" + jq -c --argjson old "$from" '[.[] | select((.qualityProfileId // 0) == $old and .id != null) | .id]' + ;; + *) + jq -c --argjson pid "$pid" '[.[] | select((.qualityProfileId // 0) != $pid and .id != null) | .id]' + ;; + esac +} + +# stdin: source quality-profile object. $1 = dest id. $2 = dest name. +arr_quality_profile_clone_json() { + local dest_id="$1" + local dest_name="$2" + jq --argjson id "$dest_id" --arg name "$dest_name" '.id = $id | .name = $name' +} + +# stdin: one root-folder object. $1 = target quality profile id. +arr_rootfolder_with_default_profile() { + local pid="$1" + jq --argjson pid "$pid" 'del(.unmappedFolders) | .defaultQualityProfileId = $pid' +} + +_arr_put_http() { + local port="$1" + local endpoint="$2" + local apikey="$3" + local data="$4" + curl -s -o /tmp/arr-put-body -w "%{http_code}" --connect-timeout 8 -X PUT \ + -H "Content-Type: application/json" \ + -d "$data" \ + "http://${API_HOST}:${port}${endpoint}?apikey=${apikey}" 2>/dev/null || echo "000" +} + +arr_set_root_folder_quality_profile() { + local service_name="$1" + local port="$2" + local apikey="$3" + local profile_id="$4" + local folders folder payload code ok=0 + + folders=$(api_get "$port" "/api/v3/rootfolder" "$apikey") + if [ -z "$folders" ] || [ "$folders" = "[]" ]; then + return 1 + fi + + while IFS= read -r folder; do + [ -z "$folder" ] && continue + local fid + fid=$(echo "$folder" | jq -r '.id // empty') + [ -n "$fid" ] || continue + payload=$(echo "$folder" | arr_rootfolder_with_default_profile "$profile_id") + [ -n "$payload" ] || continue + code=$(_arr_put_http "$port" "/api/v3/rootfolder/${fid}" "$apikey" "$payload") + if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then + ok=$((ok + 1)) + fi + done < <(echo "$folders" | jq -c '.[]' 2>/dev/null || true) + + [ "$ok" -gt 0 ] +} + +# $5 = movie | series. $6 = all | any-only (default all). +arr_set_items_quality_profile() { + local service_name="$1" + local port="$2" + local apikey="$3" + local profile_id="$4" + local kind="$5" + local mode="${6:-all}" + local list_path editor_path id_key + local items ids payload code + + if [ "$kind" = "series" ]; then + list_path="/api/v3/series" + editor_path="/api/v3/series/editor" + id_key="seriesIds" + else + list_path="/api/v3/movie" + editor_path="/api/v3/movie/editor" + id_key="movieIds" + fi + + items=$(api_get "$port" "$list_path" "$apikey") + if [ -z "$items" ] || [ "$items" = "[]" ]; then + return 0 + fi + ids=$(echo "$items" | arr_quality_ids_needing_update "$profile_id" "$mode") + if [ -z "$ids" ] || [ "$ids" = "[]" ]; then + return 0 + fi + + payload=$(jq -nc --argjson ids "$ids" --argjson pid "$profile_id" --arg key "$id_key" \ + '{($key): $ids, qualityProfileId: $pid}') + code=$(_arr_put_http "$port" "$editor_path" "$apikey" "$payload") + if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then + local n + n=$(echo "$ids" | jq 'length' 2>/dev/null || echo 0) + log_step "${service_name}: moved ${n} title(s) → quality profile ${profile_id}" + return 0 + fi + return 1 +} + +arr_set_importlist_quality_profile() { + local service_name="$1" + local port="$2" + local apikey="$3" + local profile_id="$4" + local lists list payload code ok=0 total=0 + + lists=$(api_get "$port" "/api/v3/importlist" "$apikey") + if [ -z "$lists" ] || [ "$lists" = "[]" ]; then + return 0 + fi + + while IFS= read -r list; do + [ -z "$list" ] && continue + local lid cur + lid=$(echo "$list" | jq -r '.id // empty') + cur=$(echo "$list" | jq -r '.qualityProfileId // empty') + [ -n "$lid" ] || continue + total=$((total + 1)) + if [ "$cur" = "$profile_id" ]; then + ok=$((ok + 1)) + continue + fi + payload=$(echo "$list" | jq --argjson pid "$profile_id" '.qualityProfileId = $pid') + code=$(_arr_put_http "$port" "/api/v3/importlist/${lid}" "$apikey" "$payload") + if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then + ok=$((ok + 1)) + fi + done < <(echo "$lists" | jq -c '.[]' 2>/dev/null || true) + + [ "$total" -eq 0 ] || [ "$ok" -gt 0 ] +} + +# *arr Add New defaults to quality profile id 1 (stock "Any"). There is no +# working "default profile" API on Sonarr (root-folder PUT is a no-op). +# Copy the assemblrr profile onto id 1 and drop the duplicate so the picker +# opens on the assemblrr profile. +arr_promote_quality_profile() { + local service_name="$1" + local port="$2" + local apikey="$3" + local src_id="$4" + local src_name="$5" + + [ -n "$src_id" ] && [ -n "$src_name" ] || return 1 + if [ "$src_id" = "1" ]; then + return 0 + fi + + local src dest tmp_name tmp_payload code + src=$(api_get "$port" "/api/v3/qualityprofile/${src_id}" "$apikey") + if [ -z "$src" ] || ! echo "$src" | jq -e '.id' >/dev/null 2>&1; then + return 1 + fi + + tmp_name="${src_name}.__assemblrr_tmp" + tmp_payload=$(echo "$src" | jq --arg n "$tmp_name" '.name = $n') + code=$(_arr_put_http "$port" "/api/v3/qualityprofile/${src_id}" "$apikey" "$tmp_payload") + if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then + return 1 + fi + + dest=$(echo "$src" | arr_quality_profile_clone_json 1 "$src_name") + code=$(_arr_put_http "$port" "/api/v3/qualityprofile/1" "$apikey" "$dest") + if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then + _arr_put_http "$port" "/api/v3/qualityprofile/${src_id}" "$apikey" "$src" >/dev/null || true + return 1 + fi + + local kind="movie" + [ "$service_name" = "Sonarr" ] && kind="series" + arr_set_items_quality_profile "$service_name" "$port" "$apikey" "1" "$kind" "from:${src_id}" || true + arr_set_importlist_quality_profile "$service_name" "$port" "$apikey" "1" || true + + local del + del=$(api_delete "$port" "/api/v3/qualityprofile/${src_id}" "$apikey" || echo "000") + if [ "$del" -ge 200 ] && [ "$del" -lt 300 ]; then + log_step "${service_name}: Add New default is ${src_name}" + return 0 + fi + log_step "${service_name}: Add New default is ${src_name} (left extra profile id ${src_id})" + return 0 +} + +# Apply the install's chosen quality profile to *arr (id 1 / library / import lists). +# Seerr uses the same lookup so the chain stays in lockstep. set_default_quality_profile() { local service_name="$1" local port="$2" local apikey="$3" local profile_func="$4" - local profile profile_id + local profile profile_id profile_name profile=$($profile_func "$apikey") profile_id="${profile%%:*}" - local profile_name="${profile##*:}" + profile_name="${profile##*:}" - if [ -z "$profile_id" ] || [ "$profile_id" = "1" ] && [ "$profile_name" = "Any" ]; then - log_step "${service_name}: preferred quality profile → Any" - return 0 + if [ -z "$profile_id" ]; then + log_step_fail "${service_name}: no quality profile to apply" + return 1 + fi + + local want_named=0 + if [ "$service_name" = "Radarr" ]; then + if [ "${SEERR_IS_4K:-false}" = "true" ] || [ "${SEERR_DEFAULT_PROFILE:-1}" != "1" ]; then + want_named=1 + fi + else + want_named=1 + fi + if [ "$want_named" = "1" ] && [ "$profile_id" = "1" ] && [ "$profile_name" = "Any" ]; then + log_step_fail "${service_name}: assemblrr quality profile not found (Recyclarr sync missing?)" + return 1 + fi + + if arr_promote_quality_profile "$service_name" "$port" "$apikey" "$profile_id" "$profile_name"; then + profile_id="1" fi if [ -n "${INSTALL_DIR:-}" ]; then mkdir -p "$INSTALL_DIR/config" echo "${profile_id}:${profile_name}" > "$INSTALL_DIR/config/.${service_name,,}-default-quality-profile" 2>/dev/null || true fi - log_step "${service_name}: preferred quality profile → ${profile_name} (id ${profile_id})" + + # Best-effort: some Radarr builds honor this. Sonarr has no such field. + arr_set_root_folder_quality_profile "$service_name" "$port" "$apikey" "$profile_id" || true + + local fail=0 + if [ "$service_name" = "Radarr" ]; then + if ! arr_set_items_quality_profile "$service_name" "$port" "$apikey" "$profile_id" "movie" "all"; then + log_step_fail "${service_name}: could not update movie quality profiles" + fail=1 + fi + else + if ! arr_set_items_quality_profile "$service_name" "$port" "$apikey" "$profile_id" "series" "any-only"; then + log_step_fail "${service_name}: could not update series still on Any" + fail=1 + fi + fi + arr_set_importlist_quality_profile "$service_name" "$port" "$apikey" "$profile_id" || true + + if [ "$fail" -ne 0 ]; then + return 1 + fi + log_step "${service_name}: default quality profile → ${profile_name} (id ${profile_id})" } # --- Quality profile lookup --- @@ -762,7 +1059,7 @@ wait_for_arr_indexer_sync() { } # POST indexers named in SELECTED_INDEXERS into Prowlarr (skip existing). -# Used by first-run wiring and `assemblrr config edit indexers`. +# Used by first-run wiring and `assemblrr config edit`. apply_selected_indexers() { local apikey="$1" local indexer_schemas @@ -938,7 +1235,7 @@ configure_prowlarr() { fi fi - # 2. Selected indexers (optional; can add later via `config edit indexers`) + # 2. Selected indexers (optional; can add later via `config edit`) apply_selected_indexers "$apikey" # 3) After fullSync, set min seeders on *arr indexers (best-effort) diff --git a/lib/bazarr.sh b/lib/bazarr.sh index b2a021b..5e5ec81 100644 --- a/lib/bazarr.sh +++ b/lib/bazarr.sh @@ -312,6 +312,21 @@ configure_bazarr() { ) fi + # First wire only: Bazarr hashes the password on save, so later wires + # must not resend it (that would MD5 the hash). Auth edit uses bazarr_set_auth. + local auth_type="" + auth_type=$(curl -s --connect-timeout 8 \ + "http://${API_HOST}:${BAZARR_PORT}/api/system/settings?apikey=${bazarr_key}" 2>/dev/null \ + | jq -r '.auth.type // empty' 2>/dev/null || true) + if { [ -z "$auth_type" ] || [ "$auth_type" = "null" ] || [ "$auth_type" = "None" ]; } \ + && [ -n "${AUTH_USERNAME:-}" ] && [ -n "${AUTH_PASSWORD:-}" ]; then + form_args+=( + -F "settings-auth-type=form" + -F "settings-auth-username=${AUTH_USERNAME}" + -F "settings-auth-password=${AUTH_PASSWORD}" + ) + fi + # Jellyfin refresh after subtitle downloads if [ "${MEDIA_SERVICE:-}" = "jellyfin" ]; then jf_key=$(cat "$INSTALL_DIR/secrets/jellyfin_api_key.txt" 2>/dev/null || echo "") @@ -415,3 +430,33 @@ configure_bazarr() { return 0 } + +# Set Bazarr form login. Always sends a fresh password (Bazarr MD5s it). +# Do not call this from the regular wire loop — only from auth edit. +bazarr_set_auth() { + local bazarr_key="" + if [ -z "${AUTH_USERNAME:-}" ] || [ -z "${AUTH_PASSWORD:-}" ]; then + log_step_fail "Bazarr: new credentials missing" + return 1 + fi + if ! bazarr_key=$(read_bazarr_api_key); then + return 1 + fi + if ! wait_for_bazarr "$bazarr_key"; then + return 1 + fi + + local http_code + http_code=$(_bazarr_post_form "$bazarr_key" \ + -F "settings-auth-type=form" \ + -F "settings-auth-username=${AUTH_USERNAME}" \ + -F "settings-auth-password=${AUTH_PASSWORD}") + if [ "$http_code" = "204" ] || [ "$http_code" = "200" ]; then + log_step "Bazarr: set login (${AUTH_USERNAME})" + return 0 + fi + local body="" + body=$(cat /tmp/bazarr-settings-body.txt 2>/dev/null | head -c 200 || true) + log_step_fail "Bazarr: failed to set login (HTTP ${http_code}${body:+: $body})" + return 1 +} diff --git a/lib/config_edit.sh b/lib/config_edit.sh index a783595..6d0da81 100644 --- a/lib/config_edit.sh +++ b/lib/config_edit.sh @@ -12,19 +12,19 @@ _config_edit_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=/dev/null [ -f "$_config_edit_dir/ui.sh" ] && source "$_config_edit_dir/ui.sh" -# id|aliases|description (pipe so empty aliases stay empty; bash IFS tab collapses) +# id|description _config_edit_catalog() { cat <<'EOF' -indexers||Prowlarr indexers (fzf) -providers|subtitles,subtitle-providers|Bazarr subtitle providers (fzf) -language|subtitle-language|Preferred subtitle language (fzf) -profile|quality,seerr-profile|Seerr default quality profile (movies) -opensubtitles|os|OpenSubtitles.com credentials -auth|login,credentials|Service login (Radarr / Sonarr / Prowlarr / qB) -timezone|tz|Container timezone -vpn||VPN on/off, provider, and credentials (restarts stack) -media|media-service|Jellyfin / Emby / Plex (restarts stack) -all|setup,wizard|Re-run the full setup wizard +indexers|Prowlarr indexers +providers|Bazarr subtitle providers +language|Preferred subtitle language +profile|Default movie quality (Radarr + Seerr) +opensubtitles|OpenSubtitles.com credentials +auth|Shared login for every service UI +timezone|Container timezone +vpn|VPN on/off, provider, and credentials (restarts stack) +media|Jellyfin / Emby / Plex (restarts stack) +all|Re-run the full setup wizard EOF } @@ -34,51 +34,30 @@ config_edit_section_ids() { config_edit_usage() { local cli="${APP_CLI_NAME:-assemblrr}" - echo "Usage: ${cli} config edit [section]" + echo "Usage: ${cli} config edit" echo - echo "Change one install setting without re-running the whole wizard." - echo "No argument opens a picker (fzf when available)." + echo "Opens a picker. Choose what to change there." echo - echo "Sections:" - local id aliases desc - while IFS='|' read -r id aliases desc; do + echo "In the picker:" + local id desc + while IFS='|' read -r id desc; do [ -z "$id" ] && continue - if [ -n "$aliases" ]; then - printf " %-14s %s\n" "$id" "$desc" - printf " %-14s (aliases: %s)\n" "" "$aliases" - else - printf " %-14s %s\n" "$id" "$desc" - fi + printf " %-14s %s\n" "$id" "$desc" done < <(_config_edit_catalog) - echo - echo "Examples:" - echo " ${cli} config edit # pick a section" - echo " ${cli} config edit indexers # reopen Prowlarr indexer fzf" - echo " ${cli} config edit profile # change Seerr movie default" - echo " ${cli} config edit all # full setup wizard" } -# Resolve user input (id or alias) to a canonical section id. Empty → "". +# Resolve picker output to a catalog id. Empty → "". config_edit_resolve() { local want="${1:-}" want=$(echo "$want" | tr '[:upper:]' '[:lower:]') [ -z "$want" ] && return 1 - local id aliases - while IFS='|' read -r id aliases _; do + local id + while IFS='|' read -r id _; do [ -z "$id" ] && continue if [ "$want" = "$id" ]; then echo "$id" return 0 fi - local a - local -a _aliases=() - IFS=',' read -ra _aliases <<< "$aliases" - for a in "${_aliases[@]}"; do - if [ "$want" = "$a" ]; then - echo "$id" - return 0 - fi - done done < <(_config_edit_catalog) return 1 } @@ -210,7 +189,7 @@ _config_edit_restart_stack() { _config_edit_pick() { local data selected - data=$(_config_edit_catalog | awk -F'|' '{printf "%s\t%s\n", $1, $3}') + data=$(_config_edit_catalog | awk -F'|' '{printf "%s\t%s\n", $1, $2}') if type _fzf_can_render >/dev/null 2>&1 && _fzf_can_render && _fzf_has_tty; then selected=$(echo "$data" | fzf \ --prompt="Edit which setting> " \ @@ -227,7 +206,7 @@ _config_edit_pick() { echo log_info "What do you want to change?" local i=1 id desc - while IFS='|' read -r id _ desc; do + while IFS='|' read -r id desc; do printf " %2d) %-14s %s\n" "$i" "$id" "$desc" i=$((i + 1)) done < <(_config_edit_catalog) @@ -344,24 +323,65 @@ _config_edit_opensubtitles() { _config_edit_auth() { _config_edit_load + local old_user old_pass + old_user=$(cat "$INSTALL_DIR/secrets/auth_username.txt" 2>/dev/null || echo "${AUTH_USERNAME:-}") + old_pass=$(cat "$INSTALL_DIR/secrets/auth_password.txt" 2>/dev/null || echo "${AUTH_PASSWORD:-}") + configure_auth local secrets_dir="$INSTALL_DIR/secrets" mkdir -p "$secrets_dir" - echo -n "${auth_username:-admin}" > "$secrets_dir/auth_username.txt" - echo -n "${auth_password:-}" > "$secrets_dir/auth_password.txt" - chmod 600 "$secrets_dir/auth_username.txt" "$secrets_dir/auth_password.txt" AUTH_USERNAME="${auth_username:-admin}" AUTH_PASSWORD="${auth_password:-}" - log_success "Auth credentials written to $secrets_dir" - qbit_set_credentials || true + + local fail=0 + qbit_change_credentials "$old_user" "$old_pass" "$AUTH_USERNAME" "$AUTH_PASSWORD" || fail=1 if [ -n "${RADARR_API_KEY:-}" ]; then - set_arr_auth "Radarr" "7878" "$RADARR_API_KEY" "v3" || true + set_arr_auth "Radarr" "7878" "$RADARR_API_KEY" "v3" || fail=1 + else + log_step_fail "Radarr: no API key — skipped" + fail=1 fi if [ -n "${SONARR_API_KEY:-}" ]; then - set_arr_auth "Sonarr" "8989" "$SONARR_API_KEY" "v3" || true + set_arr_auth "Sonarr" "8989" "$SONARR_API_KEY" "v3" || fail=1 + else + log_step_fail "Sonarr: no API key — skipped" + fail=1 fi if [ -n "${PROWLARR_API_KEY:-}" ]; then - set_arr_auth "Prowlarr" "9696" "$PROWLARR_API_KEY" "v1" || true + set_arr_auth "Prowlarr" "9696" "$PROWLARR_API_KEY" "v1" || fail=1 + else + log_step_fail "Prowlarr: no API key — skipped" + fail=1 + fi + + case "${MEDIA_SERVICE:-jellyfin}" in + jellyfin) + jellyfin_change_credentials "$old_user" "$old_pass" "$AUTH_USERNAME" "$AUTH_PASSWORD" || fail=1 + ;; + emby) + jellyfin_change_credentials "$old_user" "$old_pass" "$AUTH_USERNAME" "$AUTH_PASSWORD" 8096 "Emby" || fail=1 + ;; + plex) + log_warning "Plex account password is not this login — change it in Plex." + ;; + esac + + bazarr_set_auth || fail=1 + + if [ "${MEDIA_SERVICE:-jellyfin}" = "jellyfin" ] || [ "${MEDIA_SERVICE:-}" = "emby" ]; then + if type seerr_verify_login >/dev/null 2>&1; then + seerr_verify_login || true + fi + fi + + echo -n "$AUTH_USERNAME" > "$secrets_dir/auth_username.txt" + echo -n "$AUTH_PASSWORD" > "$secrets_dir/auth_password.txt" + chmod 600 "$secrets_dir/auth_username.txt" "$secrets_dir/auth_password.txt" + log_success "Auth credentials written to $secrets_dir" + + if [ "$fail" -ne 0 ]; then + log_warning "Some UIs did not take the new password. Fix those services, then: ${APP_CLI_NAME:-assemblrr} config apply" + return 1 fi } @@ -408,7 +428,11 @@ _config_edit_vpn() { : > "$secrets_dir/openvpn_user.txt" : > "$secrets_dir/openvpn_password.txt" fi - chmod 600 "$secrets_dir"/*.txt 2>/dev/null || true + chmod 600 \ + "$secrets_dir/openvpn_user.txt" \ + "$secrets_dir/openvpn_password.txt" \ + "$secrets_dir/wireguard_private_key.txt" \ + 2>/dev/null || true log_success "VPN secrets written to $secrets_dir" fi if [ "${VPN_ENABLED:-n}" = "y" ]; then @@ -440,7 +464,7 @@ _config_edit_media() { configure_jellyfin || true configure_jellyfin_notifications || true elif [ "$previous" != "$MEDIA_SERVICE" ]; then - log_info "Switched media server to ${MEDIA_SERVICE}. Finish any first-run UI setup, then re-run: ${APP_CLI_NAME:-assemblrr} config edit all" + log_info "Switched media server to ${MEDIA_SERVICE}. Finish any first-run UI setup, then run: ${APP_CLI_NAME:-assemblrr} config apply" fi } @@ -474,25 +498,28 @@ config_edit_run() { local section="" ui_set_mode edit - if [ -z "$raw" ]; then - _config_edit_load_picker - raw=$(_config_edit_pick) - raw=$(echo "${raw:-}" | head -1 | tr -d '[:space:]') - if [ -z "$raw" ]; then - echo "Cancelled." - return 0 - fi - fi - case "$raw" in --help|-h|help) config_edit_usage return 0 ;; + "") + ;; + *) + log_error "Just run '${APP_CLI_NAME:-assemblrr} config edit' and pick from the menu" + ;; esac + _config_edit_load_picker + raw=$(_config_edit_pick) + raw=$(echo "${raw:-}" | head -1 | tr -d '[:space:]') + if [ -z "$raw" ]; then + echo "Cancelled." + return 0 + fi + if ! section=$(config_edit_resolve "$raw"); then - log_error "Unknown config section: $raw\nRun '${APP_CLI_NAME:-assemblrr} config edit --help' for sections" + log_error "Unknown config section: $raw" fi case "$section" in diff --git a/lib/fzf-tui.sh b/lib/fzf-tui.sh index 7f08f56..5f2de9a 100644 --- a/lib/fzf-tui.sh +++ b/lib/fzf-tui.sh @@ -472,7 +472,7 @@ configure_indexers() { log_info "Prowlarr is not responding — cannot edit indexers." else log_info "Prowlarr is not responding — skipping optional indexer setup." - log_info "You can add indexers later with: ${APP_CLI_NAME:-assemblrr} config edit indexers" + log_info "You can add indexers later with: ${APP_CLI_NAME:-assemblrr} config edit" fi fi } @@ -523,7 +523,7 @@ configure_subtitle_providers() { log_info "Bazarr is not running — cannot edit providers." else log_info "Bazarr is not running — skipping optional provider setup." - log_info "You can set providers later with: ${APP_CLI_NAME:-assemblrr} config edit providers" + log_info "You can set providers later with: ${APP_CLI_NAME:-assemblrr} config edit" fi return 0 fi diff --git a/lib/jellyfin.sh b/lib/jellyfin.sh index 5757c49..c234581 100644 --- a/lib/jellyfin.sh +++ b/lib/jellyfin.sh @@ -382,6 +382,116 @@ configure_jellyfin_libraries() { [ "$critical_errors" -eq 0 ] } +# Change an existing Jellyfin/Emby user's password (and name if it changed). +# Usage: jellyfin_change_credentials [port] [label] +jellyfin_change_credentials() { + local old_user="$1" + local old_pass="$2" + local new_user="$3" + local new_pass="$4" + local jellyfin_port="${5:-8096}" + local label="${6:-Jellyfin}" + local auth_header='X-Emby-Authorization: MediaBrowser Client="assemblrr", Version="1.0", Device="config-edit", DeviceId="assemblrr-auth"' + + if [ -z "$new_user" ] || [ -z "$new_pass" ]; then + log_step_fail "${label}: new credentials missing" + return 1 + fi + + local public_info wizard_complete + public_info=$(curl -sf --connect-timeout 5 \ + "http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "") + wizard_complete=$(echo "$public_info" | jq -r '.StartupWizardCompleted // ""' 2>/dev/null || echo "") + if [ "$wizard_complete" != "True" ] && [ "$wizard_complete" != "true" ]; then + if [ "$label" = "Jellyfin" ] && type configure_jellyfin >/dev/null 2>&1; then + configure_jellyfin + return $? + fi + log_step_fail "${label}: startup wizard is still open — finish first boot, then retry" + return 1 + fi + + local auth_payload auth_response + auth_payload=$(jq -nc --arg u "$old_user" --arg p "$old_pass" '{Username:$u,Pw:$p}') + auth_response=$(curl -sf --connect-timeout 10 -X POST \ + -H "Content-Type: application/json" \ + -H "$auth_header" \ + -d "$auth_payload" \ + "http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "") + + if [ -z "$auth_response" ]; then + auth_payload=$(jq -nc --arg u "$new_user" --arg p "$new_pass" '{Username:$u,Pw:$p}') + auth_response=$(curl -sf --connect-timeout 10 -X POST \ + -H "Content-Type: application/json" \ + -H "$auth_header" \ + -d "$auth_payload" \ + "http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "") + if [ -n "$auth_response" ]; then + local already_name + already_name=$(echo "$auth_response" | jq -r '.User.Name // ""' 2>/dev/null || echo "") + if [ "$already_name" = "$new_user" ]; then + log_step "${label}: already using the new password" + return 0 + fi + # New password already works — only the display name still needs updating. + old_pass="$new_pass" + else + log_step_fail "${label}: could not log in with the old password" + return 1 + fi + fi + + local access_token user_id + access_token=$(echo "$auth_response" | jq -r '.AccessToken // ""' 2>/dev/null || echo "") + user_id=$(echo "$auth_response" | jq -r '.User.Id // ""' 2>/dev/null || echo "") + if [ -z "$access_token" ] || [ -z "$user_id" ]; then + log_step_fail "${label}: login response missing token" + return 1 + fi + + if [ "$old_pass" != "$new_pass" ]; then + local pw_payload pw_code + pw_payload=$(jq -nc --arg cur "$old_pass" --arg new "$new_pass" '{CurrentPw:$cur,NewPw:$new}') + pw_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \ + -H "Content-Type: application/json" \ + -H "X-Emby-Token: ${access_token}" \ + -d "$pw_payload" \ + "http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Password" 2>/dev/null || echo "000") + if [ "$pw_code" -lt 200 ] || [ "$pw_code" -ge 300 ]; then + log_step_fail "${label}: failed to set password (HTTP $pw_code)" + return 1 + fi + fi + + if [ "$old_user" != "$new_user" ]; then + local enc_name name_code + enc_name=$(jq -rn --arg n "$new_user" '$n | @uri' 2>/dev/null || echo "$new_user") + name_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \ + -H "X-Emby-Token: ${access_token}" \ + "http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Name?name=${enc_name}" \ + 2>/dev/null || echo "000") + if [ "$name_code" -lt 200 ] || [ "$name_code" -ge 300 ]; then + local user_obj patched + user_obj=$(curl -s --connect-timeout 8 \ + -H "X-Emby-Token: ${access_token}" \ + "http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null || echo "") + patched=$(echo "$user_obj" | jq --arg n "$new_user" '.Name = $n' 2>/dev/null || echo "") + name_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \ + -H "Content-Type: application/json" \ + -H "X-Emby-Token: ${access_token}" \ + -d "$patched" \ + "http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null || echo "000") + fi + if [ "$name_code" -lt 200 ] || [ "$name_code" -ge 300 ]; then + log_step_fail "${label}: password updated, username change failed (HTTP $name_code)" + return 1 + fi + fi + + log_step "${label}: set login (${new_user})" + return 0 +} + # --- Jellyfin notification connections (Radarr/Sonarr → Jellyfin) --- configure_jellyfin_notifications() { diff --git a/lib/prompts.sh b/lib/prompts.sh index 6134f2c..d79e6cf 100644 --- a/lib/prompts.sh +++ b/lib/prompts.sh @@ -70,11 +70,11 @@ configure_seerr_profile() { fi ui_intro \ - "What default quality profile would you like to set for Seerr (Movies)?" \ - "Change Seerr's default movie quality profile." - echo " 1) Ultra-HD (4K - Optimized for HEVC/x265)" - echo " 2) 1080p" - echo " 3) Any (Radarr Default)" + "What default quality profile should movies use (Radarr and Seerr)?" \ + "Change the default movie quality on Radarr and Seerr." + echo " 1) Ultra-HD (4K - assemblrr UHD Bluray + WEB)" + echo " 2) 1080p (assemblrr HD Bluray + WEB)" + echo " 3) Any" while true; do read -p "Choose your Seerr default profile [1]: " seerr_profile_choice @@ -187,7 +187,7 @@ configure_opensubtitles() { log_info "The website login accepts email or username; Bazarr/API require your profile username." log_info "Find it under your OpenSubtitles profile (not the signup email)." ui_note \ - "Other subtitle providers can be configured later with: ${APP_CLI_NAME:-assemblrr} config edit providers" \ + "Other subtitle providers can be configured later with: ${APP_CLI_NAME:-assemblrr} config edit" \ "" read -p "Do you have OpenSubtitles.com credentials? (y/N) [Default = n]: " opensubtitles_enabled opensubtitles_enabled=${opensubtitles_enabled:-n} @@ -375,11 +375,11 @@ prompt_vpn_credentials() { configure_auth() { ui_intro \ - "Set credentials for your service web UIs." \ - "Change the shared service login (Radarr, Sonarr, Prowlarr, qBittorrent)." + "Set one username and password for every service web UI." \ + "Change the shared login on every service UI." ui_note \ - "These will be used to log into Radarr, Prowlarr, and other services." \ - "Seerr / Jellyfin / Bazarr may still use the previous password until you sign in there or re-run wiring." + "Same login on Radarr, Sonarr, Prowlarr, qBittorrent, Jellyfin, and Bazarr. Seerr signs in with the Jellyfin (or Emby) user." \ + "Updates Radarr, Sonarr, Prowlarr, qBittorrent, Jellyfin, and Bazarr. Seerr signs in with the Jellyfin (or Emby) user." echo auth_username="" diff --git a/lib/seerr.sh b/lib/seerr.sh index d66f056..8867ce8 100644 --- a/lib/seerr.sh +++ b/lib/seerr.sh @@ -470,3 +470,16 @@ EOF return 1 fi } + +# Seerr has no separate password — it authenticates against Jellyfin/Emby. +seerr_verify_login() { + local seerr_cookie_jar="/tmp/seerr_cookie_jar_verify_$$" + if seerr_get_cookie "$seerr_cookie_jar" "false" || seerr_get_cookie "$seerr_cookie_jar" "true"; then + rm -f "$seerr_cookie_jar" + log_step "Seerr: session works with the new Jellyfin/Emby password" + return 0 + fi + rm -f "$seerr_cookie_jar" + log_step_fail "Seerr: could not open a session (it uses the Jellyfin/Emby login)" + return 1 +} diff --git a/lib/upgrade.sh b/lib/upgrade.sh index 46bfafb..2cd133e 100644 --- a/lib/upgrade.sh +++ b/lib/upgrade.sh @@ -400,7 +400,7 @@ upgrade_app() { if ASSEMBLRR_NONINTERACTIVE=1 bash "$INSTALL_DIR/config.sh"; then log_success "Service wiring completed" else - log_warning "Service wiring reported failures. Fix issues, then re-run: ${APP_CLI_NAME:-assemblrr} upgrade" + log_warning "Service wiring reported failures. Fix issues, then re-run: ${APP_CLI_NAME:-assemblrr} config apply" fi else log_warning "config.sh missing after upgrade — cannot wire services" diff --git a/scripts/seerr-gateway.py b/scripts/seerr-gateway.py index 20e258a..4947dbc 100755 --- a/scripts/seerr-gateway.py +++ b/scripts/seerr-gateway.py @@ -79,26 +79,57 @@ def configure_logging() -> None: ) +def _header(headers: Dict[str, str], name: str) -> str: + want = name.lower() + for key, value in headers.items(): + if key.lower() == want: + return value + return "" + + +def rewrite_location(value: str, client_host: str) -> str: + if not client_host or not value: + return value + _, host, port, _ = upstream_parts() + netlocs = [f"{host}:{port}" if port not in (80, 443) else host, "seerr:5055", "seerr"] + out = value + for netloc in netlocs: + out = out.replace(f"http://{netloc}", f"http://{client_host}") + out = out.replace(f"https://{netloc}", f"http://{client_host}") + return out + + def filter_request_headers(headers: List[Tuple[str, str]], client_addr: str) -> Dict[str, str]: out: Dict[str, str] = {} + incoming_host = "" for key, value in headers: lk = key.lower() - if lk in HOP_BY_HOP or lk == "host": + if lk in HOP_BY_HOP: + continue + if lk == "host": + incoming_host = value continue out[key] = value - # Prefer client-visible host for apps that build absolute URLs from X-Forwarded-*. - if "X-Forwarded-For" not in out and "x-forwarded-for" not in {k.lower() for k in out}: + # Browser Host so Seerr CSRF / redirects match what the user typed. + if incoming_host and not _header(out, "X-Forwarded-Host"): + out["X-Forwarded-Host"] = incoming_host + if not _header(out, "X-Forwarded-For"): out["X-Forwarded-For"] = client_addr - if "X-Forwarded-Proto" not in out and "x-forwarded-proto" not in {k.lower() for k in out}: + if not _header(out, "X-Forwarded-Proto"): out["X-Forwarded-Proto"] = "http" return out -def filter_response_headers(headers: List[Tuple[str, str]]) -> List[Tuple[str, str]]: +def filter_response_headers( + headers: List[Tuple[str, str]], + client_host: str = "", +) -> List[Tuple[str, str]]: result: List[Tuple[str, str]] = [] for key, value in headers: if key.lower() in HOP_BY_HOP: continue + if key.lower() == "location": + value = rewrite_location(value, client_host) result.append((key, value)) return result @@ -115,9 +146,14 @@ def open_upstream(method: str, path: str, headers: Dict[str, str], body: Optiona scheme, host, port, is_https = upstream_parts() conn_cls = HTTPSConnection if is_https else HTTPConnection conn = conn_cls(host, port, timeout=CONNECT_TIMEOUT) - # Host must match upstream service name so Seerr/Node accepts the request. hdrs = dict(headers) - hdrs["Host"] = f"{host}:{port}" if port not in (80, 443) else host + # Pass the browser Host through (Seerr's own reverse-proxy docs). Fall back + # to the upstream name only when the client did not send one. + client_host = _header(hdrs, "X-Forwarded-Host") + if client_host: + hdrs["Host"] = client_host + else: + hdrs["Host"] = f"{host}:{port}" if port not in (80, 443) else host if body is not None and "Content-Length" not in {k.title() for k in hdrs} and "content-length" not in { k.lower() for k in hdrs }: @@ -164,7 +200,7 @@ def proxy_once( conn, resp = open_upstream(method, path, headers, body) resp_body = resp.read() status = resp.status - resp_headers = filter_response_headers(resp.getheaders()) + resp_headers = filter_response_headers(resp.getheaders(), _header(headers, "X-Forwarded-Host")) return status, resp_headers, resp_body finally: if conn is not None: @@ -683,7 +719,9 @@ class GatewayHandler(BaseHTTPRequestHandler): try: conn, resp = open_upstream(method, path, headers, body) self.send_response(resp.status) - for key, value in filter_response_headers(resp.getheaders()): + for key, value in filter_response_headers( + resp.getheaders(), _header(headers, "X-Forwarded-Host") + ): if key.lower() == "content-length": continue self.send_header(key, value) @@ -736,6 +774,7 @@ class _MockSeerr(BaseHTTPRequestHandler): file_delete_status: int = 204 request_delete_status: int = 204 require_api_key: Optional[str] = "test-key" + last_request_headers: Dict[str, str] = {} def log_message(self, fmt: str, *args) -> None: # quiet return @@ -756,7 +795,14 @@ class _MockSeerr(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 path = self.path.split("?", 1)[0] + _MockSeerr.last_request_headers = {k: v for k, v in self.headers.items()} _MockSeerr.calls.append(f"GET {path}") + if path == "/redir": + self.send_response(302) + self.send_header("Location", "http://seerr:5055/login") + self.send_header("Content-Length", "0") + self.end_headers() + return if path == "/api/v1/settings/public": self._send(200, b'{"initialized":true}') return @@ -990,6 +1036,30 @@ def self_test() -> int: st, _, _ = http_call("GET", f"http://127.0.0.1:{gw_port}/api/v1/settings/public") check(st == 200, "passthrough public settings") + # Browser Host is forwarded; Location pointing at seerr:5055 is rewritten. + _MockSeerr.last_request_headers = {} + conn = HTTPConnection("127.0.0.1", gw_port, timeout=5) + conn.request("GET", "/api/v1/settings/public", headers={"Host": "localhost:5055"}) + resp = conn.getresponse() + resp.read() + conn.close() + got_host = _MockSeerr.last_request_headers.get("Host") or _MockSeerr.last_request_headers.get("host") + got_xfh = ( + _MockSeerr.last_request_headers.get("X-Forwarded-Host") + or _MockSeerr.last_request_headers.get("x-forwarded-host") + ) + check(got_host == "localhost:5055", f"upstream Host is the browser host (got {got_host})") + check(got_xfh == "localhost:5055", f"X-Forwarded-Host is the browser host (got {got_xfh})") + + conn = HTTPConnection("127.0.0.1", gw_port, timeout=5) + conn.request("GET", "/redir", headers={"Host": "localhost:5055"}) + resp = conn.getresponse() + loc = resp.getheader("Location") or "" + resp.read() + conn.close() + check(resp.status == 302, f"redir passthrough → 302 (got {resp.status})") + check(loc == "http://localhost:5055/login", f"Location rewritten off seerr:5055 (got {loc})") + # Purge disabled → only request DELETE PURGE_ON_DELETE_REQUEST = False _MockSeerr.calls = [] diff --git a/tests/run.sh b/tests/run.sh index 94436c5..dd00e30 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -36,6 +36,7 @@ run_scripts() { run_unit() { echo "Running unit tests..." for f in \ + "$SCRIPT_DIR/unit/test_arr_quality.sh" \ "$SCRIPT_DIR/unit/test_compose.sh" \ "$SCRIPT_DIR/unit/test_config_edit.sh" \ "$SCRIPT_DIR/unit/test_core.sh" \ diff --git a/tests/unit/test_arr_quality.sh b/tests/unit/test_arr_quality.sh new file mode 100644 index 0000000..0b9640d --- /dev/null +++ b/tests/unit/test_arr_quality.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Unit tests for *arr quality-profile apply helpers (no live stack) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../helpers.sh" +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/core.sh" +stub_log_error_no_exit +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/api.sh" +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/arr.sh" + +test_suite "arr_quality_ids_needing_update" +items='[{"id":1,"qualityProfileId":1},{"id":2,"qualityProfileId":6},{"id":3,"qualityProfileId":1}]' +assert_eq '[2]' "$(echo "$items" | arr_quality_ids_needing_update 1 all)" "all: skip already-on-target" +assert_eq '[1,3]' "$(echo "$items" | arr_quality_ids_needing_update 6 all)" "all: ids not on target" +assert_eq '[1,3]' "$(echo "$items" | arr_quality_ids_needing_update 6 any-only)" "any-only: only profile 1" +assert_eq '[2]' "$(echo "$items" | arr_quality_ids_needing_update 1 from:6)" "from:6 only that id" +assert_eq '[]' "$(echo '[]' | arr_quality_ids_needing_update 6 all)" "empty library" + +test_suite "arr_quality_profile_clone_json" +src='{"id":6,"name":"assemblrr WEB-1080p","upgradeAllowed":true,"cutoff":8}' +cloned=$(echo "$src" | arr_quality_profile_clone_json 1 "assemblrr WEB-1080p") +assert_eq "1" "$(echo "$cloned" | jq -r '.id')" "clone uses id 1" +assert_eq "assemblrr WEB-1080p" "$(echo "$cloned" | jq -r '.name')" "keeps name" +assert_eq "true" "$(echo "$cloned" | jq -r '.upgradeAllowed')" "keeps body" + +test_suite "arr_rootfolder_with_default_profile" +folder='{"id":1,"path":"/data/media/movies","unmappedFolders":[{"name":"x"}],"defaultQualityProfileId":1}' +out=$(echo "$folder" | arr_rootfolder_with_default_profile 6) +assert_eq "6" "$(echo "$out" | jq -r '.defaultQualityProfileId')" "sets defaultQualityProfileId" +assert_eq "null" "$(echo "$out" | jq -r '.unmappedFolders')" "drops unmappedFolders" +assert_eq "/data/media/movies" "$(echo "$out" | jq -r '.path')" "keeps path" + +test_suite "set_default_quality_profile applies, not just records" +src="$REPO_ROOT/lib/arr.sh" +assert_true "sets root-folder default" "grep -q defaultQualityProfileId \"$src\"" +assert_true "bulk-updates movies" "grep -q '/api/v3/movie/editor' \"$src\"" +assert_true "bulk-updates series" "grep -q '/api/v3/series/editor' \"$src\"" +assert_true "updates import lists" "grep -q '/api/v3/importlist/' \"$src\"" +assert_true "promotes assemblrr profile to id 1" "grep -q 'arr_promote_quality_profile' \"$src\"" +assert_true "writes qualityprofile/1" "grep -q '/api/v3/qualityprofile/1' \"$src\"" + +test_summary diff --git a/tests/unit/test_config_edit.sh b/tests/unit/test_config_edit.sh index 907e2d9..0aa6b38 100644 --- a/tests/unit/test_config_edit.sh +++ b/tests/unit/test_config_edit.sh @@ -24,11 +24,9 @@ assert_contains "$ids" "all" "lists all (full wizard)" test_suite "config_edit_resolve" assert_eq "indexers" "$(config_edit_resolve indexers)" "resolves indexers" -assert_eq "providers" "$(config_edit_resolve subtitles)" "alias subtitles → providers" -assert_eq "profile" "$(config_edit_resolve quality)" "alias quality → profile" -assert_eq "all" "$(config_edit_resolve wizard)" "alias wizard → all" assert_eq "all" "$(config_edit_resolve ALL)" "case-insensitive" assert_failure "unknown section" config_edit_resolve not-a-section +assert_failure "old CLI alias is not a shortcut" config_edit_resolve subtitles test_suite "config_set_kv" cfg="$tmp/.assemblrr-config" @@ -59,7 +57,25 @@ assert_eq "1" "$env_tz" "only one TZ= line in .env" test_suite "config_edit_usage" usage=$(config_edit_usage) -assert_contains "$usage" "config edit indexers" "usage mentions indexers example" -assert_contains "$usage" "Sections:" "usage lists sections" +assert_contains "$usage" "config edit" "usage is just config edit" +assert_not_contains "$usage" "config edit indexers" "no per-section CLI" +assert_contains "$usage" "In the picker:" "usage lists picker entries" + +test_suite "auth section is every UI" +auth_line=$(_config_edit_catalog | grep '^auth|' || true) +assert_contains "$auth_line" "every service UI" "auth catalog says every UI" +profile_line=$(_config_edit_catalog | grep '^profile|' || true) +assert_contains "$profile_line" "Radarr" "profile catalog mentions Radarr" + +test_suite "vpn edit chmods only vpn secret files" +vpn_fn=$(sed -n '/^_config_edit_vpn()/,/^}/p' "$REPO_ROOT/lib/config_edit.sh") +assert_not_contains "$vpn_fn" 'chmod 600 "$secrets_dir"/*.txt' "vpn edit does not chmod every secret" +assert_contains "$vpn_fn" "openvpn_user.txt" "vpn chmod names openvpn_user" +assert_contains "$vpn_fn" "wireguard_private_key.txt" "vpn chmod names wireguard key" + +test_suite "cli has config apply" +assert_true "config apply is a subcommand" "grep -q 'apply|wire)' \"$REPO_ROOT/bin/cli.sh\"" +assert_true "config apply runs config.sh" "grep -q 'ASSEMBLRR_NONINTERACTIVE=1 bash' \"$REPO_ROOT/bin/cli.sh\"" +assert_contains "$(grep 'config apply' "$REPO_ROOT/README.md" || true)" "config apply" "README lists config apply" test_summary