#!/bin/bash # Apply a package list to the OpenWrt .config. # # The list is a plain text file of package names, whitespace separated, any # number per line. Section headers switch how the following packages are # applied, and a leading '-' on a name removes that package: # # ##built-in the packages after this are set to =y (default) # ##module ... are set to =m # ##remove ... are set to =n # -luci-app-ttyd set to =n regardless of the current section # pkg # comment text after a single # is ignored # # Existing CONFIG_PACKAGE_ lines are rewritten in place rather than appended, # so running this repeatedly does not grow .config. # # Usage: ./add-openwrt-packages.sh [options] # # -y, --yes do not ask for confirmation # -n, --dry-run show the resulting diff, change nothing # --no-defconfig do not run make defconfig afterwards # --no-backup do not keep a .config.bak # --strict exit non-zero if any listed package was not found # -h, --help show this help # # Run it from the OpenWrt root: ./helper/add-openwrt-packages.sh # # Author: Zhe Yuan set -euo pipefail umask 022 PKG_FILE="" ASSUME_YES=false DRY_RUN=false RUN_DEFCONFIG=true KEEP_BACKUP=true STRICT=false info() { echo "[INFO] $*"; } warn() { echo "[WARN] $*"; } err() { echo "[ERROR] $*" >&2; } usage() { sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//;s/^#$//'; } confirm() { local question="$1" default="$2" reply="" [ "$ASSUME_YES" = true ] && return 0 if [ "$default" = "yes" ]; then read -r -p "$question [Y/n]: " reply || reply="" case "${reply:-}" in [nN] | [nN][oO]) return 1 ;; *) return 0 ;; esac else read -r -p "$question [y/N]: " reply || reply="" case "${reply:-}" in [yY] | [yY][eE][sS]) return 0 ;; *) return 1 ;; esac fi } while [ $# -gt 0 ]; do case "$1" in -y|--yes) ASSUME_YES=true ;; -n|--dry-run) DRY_RUN=true ;; --no-defconfig) RUN_DEFCONFIG=false ;; --no-backup) KEEP_BACKUP=false ;; --strict) STRICT=true ;; -h|--help) usage; exit 0 ;; -) PKG_FILE="-" ;; -*) err "Unknown option: $1"; echo "Run '$0 --help' for usage." >&2; exit 1 ;; *) if [ -n "$PKG_FILE" ]; then err "Only one package list can be given." exit 1 fi PKG_FILE="$1" ;; esac shift done [ -t 0 ] || ASSUME_YES=true if [ -z "$PKG_FILE" ]; then err "No package list given." usage >&2 exit 1 fi if [ "$PKG_FILE" != "-" ] && [ ! -f "$PKG_FILE" ]; then err "Package list not found: $PKG_FILE" exit 1 fi if [ ! -d package ] || [ ! -f Makefile ] || [ ! -f feeds.conf.default ]; then err "Must run inside the OpenWrt root directory." err "From the OpenWrt root, run it as: ./helper/$(basename "$0")" exit 1 fi if [ ! -f .config ]; then info "No .config yet; running make defconfig first..." make defconfig >/dev/null fi PACKAGE_INDEX="tmp/.config-package.in" PACKAGE_INFO="tmp/.packageinfo" if [ ! -f "$PACKAGE_INDEX" ] || [ ! -f "$PACKAGE_INFO" ]; then info "Generating package metadata..." make defconfig >/dev/null fi # --- Parse the list ------------------------------------------------------ # Section headers are handled BEFORE stripping comments: the old # `sub(/#[^#].*/,"")` left a bare '#' behind, which was then reported as a # missing package named '#'. parse_list() { awk ' BEGIN { mode = "y" } { line = $0 if (line ~ /^[ \t]*##/) { if (line ~ /^[ \t]*##built-in/) { mode = "y"; next } if (line ~ /^[ \t]*##module/) { mode = "m"; next } if (line ~ /^[ \t]*##remove/) { mode = "n"; next } print "[WARN] unknown section header, ignored: " line > "/dev/stderr" next } sub(/#.*$/, "", line) n = split(line, t, /[ \t]+/) for (i = 1; i <= n; i++) { tok = t[i]; m = mode if (tok == "") continue if (substr(tok, 1, 1) == "-") { tok = substr(tok, 2); m = "n" } if (tok == "") continue print tok "\t" m } } ' } if [ "$PKG_FILE" = "-" ]; then ENTRIES="$(parse_list)" PKG_LABEL="(stdin)" else ENTRIES="$(parse_list < "$PKG_FILE")" PKG_LABEL="$PKG_FILE" fi if [ -z "$ENTRIES" ]; then err "No packages found in $PKG_LABEL." exit 1 fi # --- Read the current state once ----------------------------------------- declare -A CUR=() declare -A DEF=() while IFS= read -r line; do case "$line" in CONFIG_PACKAGE_*=*) CUR["${line%%=*}"]="${line#*=}" ;; "# CONFIG_PACKAGE_"*" is not set") s="${line#\# }" CUR["${s% is not set}"]="n" ;; CONFIG_DEFAULT_*=y) DEF["${line%%=*}"]="y" ;; esac done < .config # --- Verify and classify -------------------------------------------------- package_exists() { local pkg="$1" if [ -f "$PACKAGE_INDEX" ] && awk -v key="PACKAGE_$pkg" '$1 == "config" && $2 == key { found = 1; exit } END { exit !found }' "$PACKAGE_INDEX"; then return 0 fi if [ -f "$PACKAGE_INFO" ] && grep -Fqx "Package: $pkg" "$PACKAGE_INFO"; then return 0 fi return 1 } DESIRED="$(mktemp "${TMPDIR:-/tmp}/desired.XXXXXX")" NEWCFG="$(mktemp ./.config.new.XXXXXX)" trap 'rm -f "$DESIRED" "$NEWCFG"' EXIT declare -a MISSING=() SKIPPED=() PLANNED=() INVALID=() while IFS=$'\t' read -r pkg mode; do [ -z "$pkg" ] && continue if ! [[ "$pkg" =~ ^[A-Za-z0-9._+-]+$ ]]; then INVALID+=("$pkg") continue fi sym="CONFIG_PACKAGE_$pkg" cur="${CUR[$sym]:-}" # Already provided by the target defaults: nothing to write, no churn. if [ "$mode" = "y" ] && [ -n "${DEF[CONFIG_DEFAULT_$pkg]:-}" ] && [ "$cur" != "m" ]; then SKIPPED+=("$pkg (target default)") continue fi if [ "$cur" = "$mode" ]; then SKIPPED+=("$pkg (already =$mode)") continue fi # A removal of something that was never enabled needs no check. if [ "$mode" != "n" ] && ! package_exists "$pkg"; then MISSING+=("$pkg") continue fi printf '%s\t%s\n' "$sym" "$mode" >> "$DESIRED" PLANNED+=("$pkg: ${cur:-unset} -> $mode") done <<< "$ENTRIES" if [ ${#INVALID[@]} -gt 0 ]; then warn "Ignored ${#INVALID[@]} entries with invalid package names:" printf ' %s\n' "${INVALID[@]}" fi if [ ! -s "$DESIRED" ]; then info "Nothing to change; .config already matches $PKG_LABEL." if [ ${#MISSING[@]} -gt 0 ]; then warn "Packages not found (check feeds or spelling):" printf ' %s\n' "${MISSING[@]}" warn "Try: ./scripts/feeds update -a && ./scripts/feeds install -a" [ "$STRICT" = true ] && exit 1 fi exit 0 fi # --- Merge into .config --------------------------------------------------- # Existing symbols are rewritten where they already are, so the file neither # grows nor reorders; duplicates left behind by older versions collapse. awk -F'\t' ' FNR == NR { want[$1] = $2; order[++nk] = $1; next } { key = "" if ($0 ~ /^CONFIG_PACKAGE_[^=]+=/) { key = $0; sub(/=.*$/, "", key) } else if ($0 ~ /^# CONFIG_PACKAGE_[^ ]+ is not set$/) { # Extract by string, not by field: FS is a tab here, so $2 is empty # on .config lines and the symbol would never be matched. key = $0; sub(/^# /, "", key); sub(/ is not set$/, "", key) } if (key != "" && (key in want)) { if (!(key in done)) { done[key] = 1 if (want[key] == "n") print "# " key " is not set" else print key "=" want[key] } next } print } END { for (i = 1; i <= nk; i++) { k = order[i] if (k in done) continue if (want[k] == "n") print "# " k " is not set" else print k "=" want[k] } } ' "$DESIRED" .config > "$NEWCFG" echo echo "========================================" echo " Planned .config changes" echo "========================================" printf ' %s\n' "${PLANNED[@]}" echo " ---" echo " change: ${#PLANNED[@]} unchanged: ${#SKIPPED[@]} not found: ${#MISSING[@]}" echo "========================================" if [ ${#MISSING[@]} -gt 0 ]; then warn "Packages not found (check feeds or spelling):" printf ' %s\n' "${MISSING[@]}" warn "Try: ./scripts/feeds update -a && ./scripts/feeds install -a" fi if [ "$DRY_RUN" = true ]; then echo diff -u .config "$NEWCFG" || true echo info "Dry run complete; .config was not modified." [ "$STRICT" = true ] && [ ${#MISSING[@]} -gt 0 ] && exit 1 exit 0 fi echo if ! confirm "Apply these changes to .config?" yes; then info "Aborted by user." exit 0 fi [ "$KEEP_BACKUP" = true ] && cp -a .config .config.bak mv -f "$NEWCFG" .config chmod 644 .config trap 'rm -f "$DESIRED"' EXIT if [ "$RUN_DEFCONFIG" = true ]; then info "Running make defconfig..." make defconfig >/dev/null # defconfig silently reverts a removal whose package something else selects, # and can demote =y to =m. Without this check that stays invisible. declare -a REVERTED=() declare -A FINAL=() while IFS= read -r line; do case "$line" in CONFIG_PACKAGE_*=*) FINAL["${line%%=*}"]="${line#*=}" ;; "# CONFIG_PACKAGE_"*" is not set") s="${line#\# }" FINAL["${s% is not set}"]="n" ;; esac done < .config while IFS=$'\t' read -r sym mode; do got="${FINAL[$sym]:-unset}" [ "$got" = "$mode" ] || REVERTED+=("${sym#CONFIG_PACKAGE_}: wanted =$mode, got =$got") done < "$DESIRED" if [ ${#REVERTED[@]} -gt 0 ]; then warn "make defconfig changed ${#REVERTED[@]} of the requested states:" printf ' %s\n' "${REVERTED[@]}" warn "Usually a dependency pulled the package back in." fi fi echo echo "========================================" echo "OK Applied ${#PLANNED[@]} change(s) from $PKG_LABEL" [ "$KEEP_BACKUP" = true ] && echo " Backup: .config.bak" echo "========================================" if [ "$STRICT" = true ] && [ ${#MISSING[@]} -gt 0 ]; then exit 1 fi