#!/bin/bash # mkhint - Manage hint files for slackrepo scripts # # Usage: # ./mkhint --set-version VERSION --hintfile FILE Update existing hint file # ./mkhint --set-version VERSION --new FILE Create new hint file # ./mkhint --new FILE Create new hint file (no version) # ./mkhint --hintfile FILE Update hint, suggest latest version via nvchecker # ./mkhint --check [FILE...] Check all (or named) hints for upstream updates # ./mkhint --list List hint files # ./mkhint --clean Remove .bak files from HINT_DIR # ./mkhint --no-dl --hintfile FILE Update hint, skip downloads, add NODOWNLOAD=yes # ./mkhint --no-dl --new FILE Create hint with NODOWNLOAD=yes # ./mkhint --help Show this help set -e # Default configuration REPO_DIR="/var/lib/sbopkg/SBo-danix/" HINT_DIR="/etc/slackrepo/SBo-danix/hintfiles/" TMP_DIR="/tmp/mkhint" NVCHECKER_CONFIG="$HOME/.config/nvchecker/nvchecker.toml" # Deps that exist on Slackware stable but are unneeded on -current (system # package or newer already present). Listed one per line, '#' comments allowed. # Missing file = empty list = phantom-dep handling is a no-op. PHANTOM_DEPS_FILE="$HOME/.config/mkhint/phantom-deps" # Packages whose extra DOWNLOAD lines are driven by an upstream deps manifest # (e.g. neovim's cmake.deps/deps.txt). One entry per line: # # Missing file = empty list = feature is a no-op. BUNDLE_MANIFEST_FILE="$HOME/.config/mkhint/bundle-manifests" # Built-package repository (slackrepo output tree: //-*.txz). PACKAGES_DIR="/repo/" # ponytail: sourced config, defaults above win when file absent. A syntax-broken # config aborts under `set -e` — acceptable for a single-user tool. MKHINT_CONFIG="$HOME/.config/mkhint/config" [[ -f "$MKHINT_CONFIG" ]] && source "$MKHINT_CONFIG" # create the temp dir if not existing if [[ ! -d $TMP_DIR ]]; then mkdir $TMP_DIR fi readonly MKHINT_VERSION="1.2.6" # Variables VERSION="" HINT_FILE="" NEW_HINT_FILE="" DELETE_HINT_FILES=() MATCHED_PKGS=() REVIEW_PKGS=() LIST_PKGS=() SHOW_LIST="" RUN_REVIEW="" COMMAND="" NO_DL=0 FORCE=0 INFO_PKG="" # Show help message show_help() { cat <&2 exit 2 fi # Color only on a TTY, or when forced for tests. tput optional. local c_on="" c_off="" g_on="" if [[ -n "$MKHINT_FORCE_COLOR" || -t 1 ]]; then if command -v tput &>/dev/null && tput setaf 3 &>/dev/null; then c_on=$(tput setaf 3); g_on=$(tput setaf 2); c_off=$(tput sgr0) else c_on=$'\033[33m'; g_on=$'\033[32m'; c_off=$'\033[0m' fi fi echo "Hint files in: $HINT_DIR" echo "=======================================================" printf "%-40s %22s %22s %-20s %-6s %s\n" "File" "HintVer" "SBOVer" "Category" "DelReq" "Created" echo "-------------------------------------------------------" MATCHED_PKGS=() local count=0 matched=0 for file in "$HINT_DIR"/*.hint; do if [[ -f "$file" ]]; then local VER; VER=$(grep "^VERSION" "$file" |cut -d '"' -f2) # Skip hints with no VERSION set. [[ -z "$VER" ]] && continue local delreq="" grep -q '^DELREQUIRES="..*"' "$file" && delreq="✓" local name; name=$(basename "$file") local pkg="${name%.hint}" local info_file info_file=$(find "$REPO_DIR" -mindepth 2 -name "${pkg}.info" 2>/dev/null | head -1) local SBO_VER="" local category="" if [[ -f "$info_file" ]]; then SBO_VER=$(grep "^VERSION" "$info_file" | cut -d '"' -f2) category=$(basename "$(dirname "$(dirname "$info_file")")") fi local date; date=$(stat -c "%y" "$file" | cut -d'.' -f1) local dr; dr=$(_pad_glyph "$delreq" 6) local hv sv hv=$(printf "%22s" "$VER") sv=$(printf "%22s" "$SBO_VER") if [[ -n "$SBO_VER" && "$VER" == "$SBO_VER" ]]; then # equal → whole row yellow local row row=$(printf "%-40s %s %s %-20s %s %s" "$name" "$hv" "$sv" "$category" "$dr" "$date") printf "%s%s%s\n" "$c_on" "$row" "$c_off" MATCHED_PKGS+=("$pkg") matched=$((matched + 1)) else # differ (or no SBO_VER) → green the newer cell, row plain if [[ -n "$SBO_VER" ]]; then local newer newer=$(printf '%s\n%s\n' "$VER" "$SBO_VER" | sort -V | tail -1) if [[ "$newer" == "$VER" ]]; then hv="${g_on}${hv}${c_off}" else sv="${g_on}${sv}${c_off}" fi fi printf "%-40s %s %s %-20s %s %s\n" "$name" "$hv" "$sv" "$category" "$dr" "$date" fi count=$((count + 1)) fi done if [[ $count -eq 0 ]]; then echo " (no hint files found)" fi echo "=======================================================" echo "Total: $count file(s)" if [[ $matched -gt 0 && -n "$c_on" ]]; then echo "(yellow row = versions match; green = newer side)" fi } # Show hint side-by-side with its .info. Output to the given fd (default 1). _show_hint_diff() { local pkg="$1" local fd="${2:-1}" local hint="${HINT_DIR%/}/${pkg}.hint" local info info=$(find "$REPO_DIR" -mindepth 2 -name "${pkg}.info" 2>/dev/null | head -1) echo "" >&"$fd" echo "=== $pkg ===" >&"$fd" if command -v git &>/dev/null; then git diff --no-index --color=auto "$hint" "$info" >&"$fd" || true else diff -y --width="${COLUMNS:-160}" "$hint" "$info" >&"$fd" || true fi } # Diff one hint against its .info and prompt Keep/Delete/Skip. # Human-facing output goes to stderr; echoes only "deleted" or "kept" on stdout # so the caller can capture the result. _review_one_hint() { local pkg="$1" local hint="${HINT_DIR%/}/${pkg}.hint" _show_hint_diff "$pkg" 2 local ans read -r -p "Review $pkg: [K]eep / [D]elete / [S]kip (default Keep): " ans case "$ans" in [Dd]) _remove_hint "$hint" >&2 echo deleted ;; [Ss]|[Kk]|"") echo "Kept: $pkg" >&2 echo kept ;; *) echo "Unrecognised answer; keeping $pkg" >&2 echo kept ;; esac } # Review hints. With package args: review each named hint (any version), # validating existence first (exit 2 on a missing hint). With no args: review # MATCHED_PKGS (populated by list_hint_files). review_hint_files() { local -a pkgs if [[ $# -gt 0 ]]; then pkgs=("$@") local p for p in "${pkgs[@]}"; do if [[ ! -f "${HINT_DIR%/}/${p}.hint" ]]; then echo "Error: hint file not found: ${HINT_DIR%/}/${p}.hint" >&2 exit 2 fi done else if [[ ${#MATCHED_PKGS[@]} -eq 0 ]]; then echo "No hints match their SBo version; nothing to review." return 0 fi pkgs=("${MATCHED_PKGS[@]}") fi local deleted=0 kept=0 pkg result for pkg in "${pkgs[@]}"; do result=$(_review_one_hint "$pkg") case "$result" in deleted) deleted=$((deleted + 1)) ;; *) kept=$((kept + 1)) ;; esac done echo "" echo "Reviewed ${#pkgs[@]} hint(s): deleted $deleted, kept $kept." } # Validate wget availability check_wget() { if ! command -v wget &> /dev/null; then echo "Error: wget is not installed. Please install wget first." >&2 exit 4 fi } # Validate nvchecker toolchain availability check_nvchecker() { local missing=() command -v nvchecker &> /dev/null || missing+=("nvchecker") command -v nvtake &> /dev/null || missing+=("nvtake") command -v jq &> /dev/null || missing+=("jq") if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: required tool(s) not installed: ${missing[*]}" >&2 echo "Install nvchecker (provides nvchecker + nvtake) and jq." >&2 exit 4 fi if [[ ! -f "$NVCHECKER_CONFIG" ]]; then echo "Error: nvchecker config not found: $NVCHECKER_CONFIG" >&2 exit 2 fi } # Echo the newver-keyfile path declared in [__config__] of NVCHECKER_CONFIG _nvchecker_newver_path() { # Grab the `newver = "..."` value; tolerate spaces around = local line line=$(grep -E '^[[:space:]]*newver[[:space:]]*=' "$NVCHECKER_CONFIG" | head -1) [[ -z "$line" ]] && return 1 # extract the quoted path local path path=$(printf '%s\n' "$line" | sed -E 's/^[^"]*"([^"]*)".*/\1/') [[ -z "$path" ]] && return 1 # expand a leading ~ to $HOME path="${path/#\~/$HOME}" # nvchecker resolves a relative keyfile path against the config file's # directory (not the CWD), so do the same here. if [[ "$path" != /* ]]; then path="$(dirname "$NVCHECKER_CONFIG")/$path" fi printf '%s\n' "$path" } # Echo the latest version nvchecker found for a package, or return non-zero # Normalize an upstream version to our packaging convention: '-' is illegal in # a SlackBuild version (breaks PRGNAM parsing), so we store it as '_'. Compare # the normalized forms so e.g. upstream 2026-06-02 == packaged 2026_06_02. _normalize_version() { printf '%s\n' "${1//-/_}" } # Usage: latest=$(nvchecker_latest pkg) || handle "no version" nvchecker_latest() { local pkg="$1" local keyfile keyfile=$(_nvchecker_newver_path) || return 1 [[ -f "$keyfile" ]] || return 1 local ver ver=$(jq -r --arg p "$pkg" '.data[$p].version // empty' "$keyfile" 2>/dev/null) [[ -z "$ver" ]] && return 1 printf '%s\n' "$ver" } # download files download_file() { local url="$1" local dlfile="${TMP_DIR}/download" if [[ -f $dlfile ]]; then rm "$dlfile" fi # Download the file if [[ ! -z $1 ]]; then wget -O "$dlfile" "$url" || return 1 fi # calculate md5 local md5; md5=$(md5sum "$dlfile" | awk '{print $1}') rm "$dlfile" echo "$md5" } # ── phantom-dep handling (deps unneeded on -current) ────────────────────────── # Load PHANTOM_DEPS_FILE into the PHANTOM_DEPS array (one dep per line, '#' # comments and blank lines ignored). Missing file => empty array. PHANTOM_DEPS=() load_phantom_deps() { PHANTOM_DEPS=() [[ -f "$PHANTOM_DEPS_FILE" ]] || return 0 local line while IFS= read -r line; do line="${line%%#*}" # strip comment line="${line//[[:space:]]/}" # strip whitespace [[ -n "$line" ]] && PHANTOM_DEPS+=("$line") done < "$PHANTOM_DEPS_FILE" } # Read REQUIRES="..." from an .info file and echo the phantom deps it contains, # space-separated. Empty output if none. phantom_deps_in_info() { local info="$1" requires dep [[ -f "$info" ]] || return 0 requires=$(. "$info" >/dev/null 2>&1; echo "${REQUIRES:-}") local hits=() for dep in "${PHANTOM_DEPS[@]}"; do [[ " $requires " == *" $dep "* ]] && hits+=("$dep") done echo "${hits[*]}" } # Merge a set of deps into a hint file's DELREQUIRES (union, dedup), preserving # all other content. Creates a minimal hint if the file is absent. Backs up an # existing file to .bak first (mkhint convention). Args: hintpath dep... merge_delrequires() { local hint="$1"; shift local new_deps=("$@") [[ ${#new_deps[@]} -eq 0 ]] && return 0 local existing="" combined if [[ -f "$hint" ]]; then existing=$(. "$hint" >/dev/null 2>&1; echo "${DELREQUIRES:-}") fi # union existing + new, dedup, first-seen order combined=$(printf '%s\n' $existing "${new_deps[@]}" | awk 'NF && !seen[$0]++' | tr '\n' ' ') combined="${combined% }" if [[ -f "$hint" ]]; then # no change needed? (all new deps already present) → skip, don't churn .bak local cur_norm new_norm cur_norm=$(printf '%s\n' $existing | awk 'NF && !seen[$0]++' | sort | tr '\n' ' ') new_norm=$(printf '%s\n' $existing "${new_deps[@]}" | awk 'NF && !seen[$0]++' | sort | tr '\n' ' ') [[ "$cur_norm" == "$new_norm" ]] && return 0 cp "$hint" "${hint}.bak" if grep -q '^DELREQUIRES=' "$hint"; then sed -i "s#^DELREQUIRES=.*#DELREQUIRES=\"$combined\"#" "$hint" else printf 'DELREQUIRES="%s"\n' "$combined" >> "$hint" fi else printf 'DELREQUIRES="%s"\n' "$combined" > "$hint" fi } # Bulk sweep: for every package in REPO_DIR whose REQUIRES contains a phantom # dep, ensure its hint carries the matching DELREQUIRES. Idempotent. fix_current() { load_phantom_deps if [[ ${#PHANTOM_DEPS[@]} -eq 0 ]]; then echo "No phantom deps configured ($PHANTOM_DEPS_FILE). Nothing to do." return 0 fi echo "Sweeping for phantom deps: ${PHANTOM_DEPS[*]}" local info prgnam deps count=0 while IFS= read -r info; do prgnam=$(basename "$info" .info) deps=$(phantom_deps_in_info "$info") [[ -z "$deps" ]] && continue merge_delrequires "$HINT_DIR/$prgnam.hint" $deps echo " $prgnam <- DELREQUIRES: $deps" (( count++ )) || true done < <(find "$REPO_DIR" -mindepth 2 -maxdepth 3 -name '*.info' | sort) echo "Done. $count package(s) with phantom deps." } # ── bundled-dep manifest handling ───────────────────────────────────────────── # Reconcile a bundle package's extra DOWNLOAD lines against an upstream deps # manifest (opt-in via BUNDLE_MANIFEST_FILE). See docs spec 2026-07-07. # parse_manifest — print one download URL per line for each NAME_URL # entry. SHA256 lines ignored (hints use MD5SUM; we re-download and md5). parse_manifest() { local file="$1" [[ -f "$file" ]] || return 0 awk '/_URL[[:space:]]/ { print $2 }' "$file" } # _url_stem — reduce a download URL's basename to a comparison stem: # drop a trailing archive extension, then a trailing - or the version # is the whole tail after the name. Version = [vV]?[0-9]... or a 7+ hex sha. _url_stem() { local base="${1##*/}" base="${base%.tar.gz}"; base="${base%.tar.xz}"; base="${base%.tar.bz2}" base="${base%.zip}"; base="${base%.tgz}" # strip a trailing - (version starts with optional v then a digit, # or is a 7+ char hex sha) if [[ "$base" =~ ^(.+)-[vV]?[0-9].*$ ]]; then base="${BASH_REMATCH[1]}" elif [[ "$base" =~ ^(.+)-[0-9a-f]{7,}$ ]]; then base="${BASH_REMATCH[1]}" fi printf '%s\n' "$base" } # _url_version — extract the version token from a download URL's basename. # Dep names can themselves contain digits/dashes (lua-compat-5.3, win32yank-x64), # so no fixed regex can split name from version. When the URL is a github one we # know the repo name: strip a leading "-"/"_" prefix (case-insensitive, # since github is case-insensitive: LuaJIT vs luajit) and the rest is the version. # Otherwise fall back to a trailing-version / trailing-sha heuristic, then drop a # leading "v". Both sides of a matched pair reduce to "the part that isn't the # name", so equal versions in different URL shapes compare equal. # ponytail: name comes from the github repo. A non-github host whose basename is # "-" can't derive the name and may misparse; add # a manifest name hint if such a dep ever appears. _url_version() { local url="$1" local base="${url##*/}" base="${base%.tar.gz}"; base="${base%.tar.xz}"; base="${base%.tar.bz2}" base="${base%.zip}"; base="${base%.tgz}" local ver="$base" local rslug; rslug=$(_url_repo "$url") local repo="${rslug#*/}" # bare repo name, lowercased # A shared blob repo (neovim/deps/raw/.../opt/-) names the host, # not the dep, so its repo name is useless — fall through to the heuristic, # same exclusion match_dep_url makes. [[ "$rslug" == */deps ]] && repo="" local base_lc="${base,,}" if [[ -n "$repo" ]]; then # github: name is known. Strip a "[-_]" prefix if present; else the # whole basename is the version (bare-tag shape, e.g. "1.52.1-0", "v0.13"). if [[ "$base_lc" == "$repo-"* || "$base_lc" == "${repo}_"* ]]; then ver="${base:${#repo}+1}" else ver="$base" fi elif [[ "$base" =~ -([vV]?[0-9][^/]*)$ ]]; then ver="${BASH_REMATCH[1]}" # non-github: trailing version elif [[ "$base" =~ -([0-9a-f]{7,})$ ]]; then ver="${BASH_REMATCH[1]}" # non-github: trailing sha fi ver="${ver#[vV]}" printf '%s\n' "$(_normalize_version "$ver")" } # _url_repo — echo owner/repo for a github URL, empty otherwise. # Lowercased: github owners/repos are case-insensitive, and the same dep can # appear as JuliaStrings/utf8proc in one URL and juliastrings/utf8proc in another. _url_repo() { [[ "$1" =~ github\.com/([^/]+)/([^/]+) ]] || return 0 local r; printf -v r '%s/%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]%.git}" printf '%s\n' "${r,,}" } # match_dep_url — echo the manifest URL that # corresponds to hint_url, or nothing. Repo-path match first, then exact # basename-stem fallback (for blob hosts where the repo path is ambiguous). match_dep_url() { local hint_url="$1" list="$2" local hrepo; hrepo=$(_url_repo "$hint_url") local m mrepo # pass 1: repo-path (skip the shared-blob-host repo neovim/deps so a blob # URL never wins on repo path; it must match on stem instead) if [[ -n "$hrepo" && "$hrepo" != */deps ]]; then while IFS= read -r m; do [[ -z "$m" ]] && continue mrepo=$(_url_repo "$m") if [[ -n "$mrepo" && "$mrepo" != */deps && "$mrepo" == "$hrepo" ]]; then printf '%s\n' "$m"; return 0 fi done <<< "$list" fi # pass 2: exact stem local hstem; hstem=$(_url_stem "$hint_url") while IFS= read -r m; do [[ -z "$m" ]] && continue [[ "$(_url_stem "$m")" == "$hstem" ]] && { printf '%s\n' "$m"; return 0; } done <<< "$list" return 1 } # Loaded maps: pkg -> mode ("url"|"sha"); pkg -> mode-specific remainder. # url: remainder = the {VERSION} manifest URL template. # sha: remainder = " ...". declare -A BUNDLE_MODE=() declare -A BUNDLE_REST=() load_bundle_manifests() { BUNDLE_MODE=(); BUNDLE_REST=() [[ -f "$BUNDLE_MANIFEST_FILE" ]] || return 0 local line pkg mode rest while IFS= read -r line; do line="${line%%#*}" [[ -z "${line// }" ]] && continue read -r pkg mode rest <<< "$line" [[ -n "$pkg" && -n "$mode" && -n "$rest" ]] || continue BUNDLE_MODE["$pkg"]="$mode" BUNDLE_REST["$pkg"]="$rest" done < "$BUNDLE_MANIFEST_FILE" } # bundle_mode — echo the mode for pkg, empty if not listed. bundle_mode() { printf '%s\n' "${BUNDLE_MODE[$1]:-}"; } # manifest_url_for — echo the url-mode manifest URL for pkg with # {VERSION} substituted. Non-zero if pkg is not a url-mode entry. manifest_url_for() { local pkg="$1" ver="$2" [[ "${BUNDLE_MODE[$pkg]:-}" == "url" ]] || return 1 local tmpl="${BUNDLE_REST[$pkg]}" printf '%s\n' "${tmpl//\{VERSION\}/$ver}" } # True if pkg has any bundle manifest configured (either mode). pkg_has_manifest() { [[ -n "${BUNDLE_MODE[$1]:-}" ]] } # fetch_manifest — download the manifest to a temp file, echo its path. # Non-zero on wget failure. Caller cleans up (path is under TMP_DIR). fetch_manifest() { local url="$1" [[ -d "$TMP_DIR" ]] || mkdir -p "$TMP_DIR" local out="${TMP_DIR}/manifest" rm -f "$out" wget -O "$out" "$url" >&2 || return 1 [[ -s "$out" ]] || return 1 printf '%s\n' "$out" } # reconcile_bundle_deps # Dispatch on the package's configured bundle mode. url-mode uses the manifest # URL argument (as before); sha-mode ignores it and reads BUNDLE_REST. Keeps the # three existing call sites unchanged. Returns whatever the mode handler returns. reconcile_bundle_deps() { local pkg="$1" case "${BUNDLE_MODE[$pkg]:-url}" in sha) reconcile_bundle_deps_sha "$@" ;; *) reconcile_bundle_deps_url "$@" ;; esac } # reconcile_bundle_deps_sha # mode = report | apply. Compares each manifest name=path submodule's upstream # SHA (contents API) against the matching hint DOWNLOAD line's sha, rewrites + # re-md5s drifted lines. Reports all deps ((current) for unchanged). Returns 2 in # report mode when there are changes to apply, else 0. apply mode returns 0. reconcile_bundle_deps_sha() { local pkg="$1" hint="$2" _ignored="$3" mode="$4" local rest="${BUNDLE_REST[$pkg]}" local repo ver_tmpl read -r repo ver_tmpl rest <<< "$rest" local cur_ver; cur_ver=$(grep '^VERSION=' "$hint" | sed 's/VERSION="//;s/"$//') local ref="${ver_tmpl//\{VERSION\}/$cur_ver}" local -a urls md5s mapfile -t urls < <(parse_multiline_var "DOWNLOAD_x86_64" "$hint") (( ${#urls[@]} == 0 )) && mapfile -t urls < <(parse_multiline_var "DOWNLOAD" "$hint") mapfile -t md5s < <(parse_multiline_var "MD5SUM_x86_64" "$hint") (( ${#md5s[@]} == 0 )) && mapfile -t md5s < <(parse_multiline_var "MD5SUM" "$hint") local -a new_urls=("${urls[@]}") local -a changed_idx=() local -a report_lines=() local pair name path upstream_sha subrepo i for pair in $rest; do name="${pair%%=*}"; path="${pair#*=}" local resp; resp=$(_fetch_submodule_sha "$repo" "$path" "$ref") || { report_lines+=(" $name: submodule path not found at $ref"); continue; } upstream_sha="${resp%%$'\t'*}"; subrepo="${resp#*$'\t'}" local found=-1 hrepo for (( i=0; i<${#urls[@]}; i++ )); do hrepo=$(_url_repo "${urls[$i]}") [[ "$hrepo" == "$subrepo" ]] && { found=$i; break; } done if (( found < 0 )); then report_lines+=(" $name: no matching DOWNLOAD line (FYI)"); continue fi local cur_sha="" [[ "${urls[$found]}" =~ /archive/([0-9a-f]{40}) ]] && cur_sha="${BASH_REMATCH[1]}" if [[ "$cur_sha" == "$upstream_sha" ]]; then report_lines+=(" $name $(printf %.7s "$cur_sha") (current)") else new_urls[$found]="${urls[$found]//$cur_sha/$upstream_sha}" changed_idx+=("$found") report_lines+=(" $name $(printf %.7s "$cur_sha") -> $(printf %.7s "$upstream_sha")") fi done if [[ "$mode" == report ]]; then echo "" echo "$pkg bundled deps (sha):" printf '%s\n' "${report_lines[@]}" (( ${#changed_idx[@]} > 0 )) && return 2 return 0 fi (( ${#changed_idx[@]} == 0 )) && return 0 cp "$hint" "${hint}.bak" local -a new_md5s=("${md5s[@]}") local idx md5 for idx in "${changed_idx[@]}"; do echo "Downloading (bundled): ${new_urls[$idx]}" if md5=$(download_file "${new_urls[$idx]}"); then new_md5s[$idx]="$md5" else echo " download failed for ${new_urls[$idx]} — left as-is" new_urls[$idx]="${urls[$idx]}" fi done local var_dl var_md5 if grep -q '^DOWNLOAD_x86_64=' "$hint"; then var_dl="DOWNLOAD_x86_64"; var_md5="MD5SUM_x86_64" else var_dl="DOWNLOAD"; var_md5="MD5SUM"; fi local new_dl new_md5v new_dl=$(build_multiline_value new_urls); new_dl="${new_dl#\"}"; new_dl="${new_dl%\"}" new_md5v=$(build_multiline_value new_md5s); new_md5v="${new_md5v#\"}"; new_md5v="${new_md5v%\"}" perl -i -0pe 'BEGIN{$var=shift;$v=shift} s|^\Q$var\E="[^"]*(?:\\\n[^"]*)*"|$var."=\"".$v."\""|me' "$var_dl" "$new_dl" "$hint" perl -i -0pe 'BEGIN{$var=shift;$v=shift} s|^\Q$var\E="[^"]*(?:\\\n[^"]*)*"|$var."=\"".$v."\""|me' "$var_md5" "$new_md5v" "$hint" return 0 } # _github_token — echo a GitHub token from nvchecker's keyfile, or nothing. # nvchecker.toml has `keyfile = ""`; that file has `github = ""`. _github_token() { local kf kf=$(grep -E '^[[:space:]]*keyfile[[:space:]]*=' "$NVCHECKER_CONFIG" 2>/dev/null \ | head -1 | cut -d '"' -f2) [[ -n "$kf" ]] || return 0 [[ "$kf" != /* ]] && kf="$(dirname "$NVCHECKER_CONFIG")/$kf" [[ -f "$kf" ]] || return 0 grep -E '^[[:space:]]*github[[:space:]]*=' "$kf" 2>/dev/null \ | head -1 | cut -d '"' -f2 } # _fetch_submodule_sha — GET the contents API for a submodule # path at ref; echo "\t". Non-zero on fail. # Authed with _github_token when present (5000/h vs 60/h anon). _fetch_submodule_sha() { local repo="$1" path="$2" ref="$3" repo="${repo#github:}" local url="https://api.github.com/repos/${repo}/contents/${path}?ref=${ref}" [[ -d "$TMP_DIR" ]] || mkdir -p "$TMP_DIR" local out="${TMP_DIR}/api_resp" rm -f "$out" local tok; tok=$(_github_token) local -a auth=() [[ -n "$tok" ]] && auth=(--header="Authorization: Bearer $tok") wget "${auth[@]}" -O "$out" "$url" >&2 || return 1 [[ -s "$out" ]] || return 1 local sha suburl sha=$(jq -r '.sha // empty' "$out") suburl=$(jq -r '.submodule_git_url // empty' "$out") [[ -n "$sha" ]] || return 1 local sr="" if [[ "$suburl" =~ github\.com/([^/]+)/([^/]+) ]]; then printf -v sr '%s/%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]%.git}" sr="${sr,,}" fi printf '%s\t%s\n' "$sha" "$sr" } # _fetch_gitmodules_paths — echo each submodule "path" line from the # .gitmodules at ref (one per line). Non-zero on fetch fail. _fetch_gitmodules_paths() { local repo="${1#github:}" ref="$2" local url="https://raw.githubusercontent.com/${repo}/${ref}/.gitmodules" [[ -d "$TMP_DIR" ]] || mkdir -p "$TMP_DIR" local out="${TMP_DIR}/gitmodules_${ref}" rm -f "$out" wget -O "$out" "$url" >&2 || return 1 [[ -s "$out" ]] || return 1 grep -E '^[[:space:]]*path[[:space:]]*=' "$out" | sed 's/.*=[[:space:]]*//' } # detect_set_drift — print the full submodule inventory # roster. Diffs .gitmodules@old vs @new; tags each submodule bundled/(ignored), # with +/- change glyphs and ACTION (bundled removed) / review (new unbundled). # FYI only, never edits. Skips with a notice if a fetch fails. detect_set_drift() { local pkg="$1" oldv="$2" newv="$3" local rest="${BUNDLE_REST[$pkg]}" local repo; read -r repo _ rest <<< "$rest" local -A bundled=() local pair for pair in $rest; do bundled["${pair#*=}"]=1; done local -a new_paths old_paths local gm_new gm_old gm_new=$(_fetch_gitmodules_paths "$repo" "$newv") || { echo " $pkg: .gitmodules@$newv unavailable — inventory skipped"; return 0; } mapfile -t new_paths <<< "$gm_new" if [[ "$oldv" == "$newv" ]]; then old_paths=("${new_paths[@]}") else gm_old=$(_fetch_gitmodules_paths "$repo" "$oldv") || { echo " $pkg: .gitmodules@$oldv unavailable — inventory skipped"; return 0; } mapfile -t old_paths <<< "$gm_old" fi local -A in_old=() in_new=() local p; for p in "${old_paths[@]}"; do in_old["$p"]=1; done for p in "${new_paths[@]}"; do in_new["$p"]=1; done echo "" echo "$pkg submodule inventory $oldv -> $newv:" local glyph tag for p in "${new_paths[@]}"; do glyph=" "; [[ -z "${in_old[$p]:-}" ]] && glyph="+" if [[ -n "${bundled[$p]:-}" ]]; then tag="bundled" else tag="(ignored)"; [[ "$glyph" == "+" ]] && tag="review (new, not bundled)" fi printf ' %s %-46s %s\n' "$glyph" "$p" "$tag" done for p in "${old_paths[@]}"; do [[ -n "${in_new[$p]:-}" ]] && continue if [[ -n "${bundled[$p]:-}" ]]; then printf ' %s %-46s %s\n' "-" "$p" "ACTION (bundled, removed upstream)" else printf ' %s %-46s %s\n' "-" "$p" "(ignored, removed upstream)" fi done } # reconcile_bundle_deps_url # mode = report (print only) | apply (rewrite matched changed lines + md5). # Reconciles the DOWNLOAD line's extra URLs (index >= 1) against the manifest. # The primary URL (index 0) is never touched here. Guarded for set -e. # In report mode: returns 2 when there are changes to apply, 0 otherwise (all # current, no deps, fetch/parse fail) so a caller can skip the apply prompt. # apply mode always returns 0. reconcile_bundle_deps_url() { local pkg="$1" hint="$2" murl="$3" mode="$4" # Display label for a dep URL: _url_stem is basename-only, so for a github # "archive/vX.Y.Z.tar.gz" tarball (no repo name in the filename) it just # returns the bare version — fall back to the repo name in that case. local _dep_label _dep_label() { local s; s=$(_url_stem "$1") if [[ "$s" =~ ^[vV]?[0-9] ]]; then local r; r=$(_url_repo "$1") [[ -n "$r" ]] && { printf '%s\n' "${r#*/}"; return; } fi printf '%s\n' "$s" } local mfile if ! mfile=$(fetch_manifest "$murl"); then echo " $pkg: manifest unavailable ($murl) — bundled deps left as-is (retry with --force)" return 0 fi local -a manifest_urls mapfile -t manifest_urls < <(parse_manifest "$mfile") rm -f "$mfile" if [[ ${#manifest_urls[@]} -eq 0 ]]; then echo " $pkg: manifest empty/unrecognized — bundled deps left as-is" return 0 fi local mlist; printf -v mlist '%s\n' "${manifest_urls[@]}" local -a urls mapfile -t urls < <(parse_multiline_var "DOWNLOAD" "$hint") (( ${#urls[@]} <= 1 )) && { echo " $pkg: no bundled deps in DOWNLOAD"; return 0; } local -a md5s mapfile -t md5s < <(parse_multiline_var "MD5SUM" "$hint") # Track which manifest URLs got matched (for the FYI of unmatched ones). local -A matched_manifest=() local -a new_urls=("${urls[@]}") local -a changed_idx=() changed_to=() local -a changed_name=() changed_oldver=() changed_newver=() local i m # The primary hint URL (index 0) is never reconciled, but its manifest # counterpart must count as "matched" so the FYI doesn't always flag it. local _pm; _pm=$(match_dep_url "${urls[0]}" "$mlist") || _pm="" [[ -n "$_pm" ]] && matched_manifest["$_pm"]=1 for (( i=1; i<${#urls[@]}; i++ )); do m=$(match_dep_url "${urls[$i]}" "$mlist") || m="" if [[ -z "$m" ]]; then echo " $pkg: $(_dep_label "${urls[$i]}") — no manifest match (left as-is)" continue fi matched_manifest["$m"]=1 # Detect real drift by version, not URL string: the manifest and the # hint often carry the same version in different path shapes (bare-tag # archive vs SBo-fetched tree). Only a version change is a change. local hv mv hv=$(_url_version "${urls[$i]}") mv=$(_url_version "$m") if [[ -n "$mv" && "$hv" != "$mv" ]]; then changed_idx+=("$i"); changed_to+=("$m"); new_urls[$i]="$m" changed_name+=("$(_dep_label "${urls[$i]}")") changed_oldver+=("${hv:-?}"); changed_newver+=("$mv") fi done # Manifest-only deps FYI (manifest URLs never matched by any hint line). local -a extra=() for m in "${manifest_urls[@]}"; do [[ -z "${matched_manifest[$m]:-}" ]] && extra+=("$(_dep_label "$m")") done if [[ ${#extra[@]} -gt 0 && "$mode" == report ]]; then echo "$pkg: manifest has ${#extra[@]} deps not in hint:" printf '%s\n' "${extra[@]}" fi if [[ ${#changed_idx[@]} -eq 0 ]]; then [[ "$mode" == report ]] && echo " $pkg: bundled deps all current" return 0 fi if [[ "$mode" == report ]]; then echo "" echo "$pkg bundled deps changed upstream:" for (( i=0; i<${#changed_idx[@]}; i++ )); do printf ' %s %s -> %s\n' "${changed_name[$i]}" "${changed_oldver[$i]}" "${changed_newver[$i]}" done # report mode signals "there are changes to apply" via exit 2, so the # caller can skip the apply prompt when nothing changed. Every other # report exit (no change, no deps, fetch fail) returns 0. return 2 fi # apply mode: recompute md5 only for changed lines, rewrite DOWNLOAD+MD5SUM. # Back up first (mkhint convention) — only reached when there is something # to write, since the no-change case already returned above. cp "$hint" "${hint}.bak" local -a new_md5s=("${md5s[@]}") local idx md5 for idx in "${changed_idx[@]}"; do echo "Downloading (bundled): ${new_urls[$idx]}" if md5=$(download_file "${new_urls[$idx]}"); then new_md5s[$idx]="$md5" else echo " download failed for ${new_urls[$idx]} — left as-is" new_urls[$idx]="${urls[$idx]}" # revert this line fi done local new_dl new_md5v new_dl=$(build_multiline_value new_urls); new_dl="${new_dl#\"}"; new_dl="${new_dl%\"}" new_md5v=$(build_multiline_value new_md5s); new_md5v="${new_md5v#\"}"; new_md5v="${new_md5v%\"}" perl -i -0pe 'BEGIN{$v=shift} s|^DOWNLOAD="[^"]*(?:\\\n[^"]*)*"|DOWNLOAD="$v"|m' "$new_dl" "$hint" perl -i -0pe 'BEGIN{$v=shift} s|^MD5SUM="[^"]*(?:\\\n[^"]*)*"|MD5SUM="$v"|m' "$new_md5v" "$hint" } # ── end bundled-dep manifest handling ───────────────────────────────────────── # Create new hint file create_new_hint_file() { cd "$HINT_DIR" local file="$1" local normalized_file="${file}" if [[ "$file" != *.hint ]]; then normalized_file="${file}.hint" fi # search repository for .info file info=$(find "$REPO_DIR" -mindepth 2 -name "${normalized_file%.hint}.info") # Check if file exists if [[ ! -f "$normalized_file" ]]; then # the hint file we want to create doesn't exists, so we can check # the sbo repository for a .info file and use that as hint if [[ -n $info ]]; then cp "$info" "$normalized_file" # remove unwanted lines from hint file sed -i -e "/^PRGNAM=/d" \ -e "/^HOMEPAGE=/d" \ -e "/^MAINTAINER=/d" \ -e "/^EMAIL=/d" \ -e "s/^REQUIRES=/#REQUIRES=/" \ "${normalized_file}" if grep -q '^ARCH=' $normalized_file; then sed -i 's/^ARCH=.*/ARCH="x86_64"/' $normalized_file else echo 'ARCH="x86_64"' >> $normalized_file fi # auto-strip -current phantom deps present in the .info REQUIRES load_phantom_deps local phantom; phantom=$(phantom_deps_in_info "$info") if [[ -n "$phantom" ]]; then printf 'DELREQUIRES="%s"\n' "$phantom" >> "$normalized_file" echo "added DELREQUIRES=\"$phantom\" (unneeded on -current)." fi if [[ -n "$VERSION" ]]; then local old_version old_version=$(grep '^VERSION=' "$normalized_file" | sed 's/VERSION="//;s/"$//') # bump both '_' and '-' variants (see update_hint_file note) sed -i "s/${old_version}/${VERSION}/g" "$normalized_file" if [[ "$old_version" == *_* || "$VERSION" == *_* ]]; then sed -i "s/${old_version//_/-}/${VERSION//_/-}/g" "$normalized_file" fi update_checksums "$normalized_file" fi if [[ $NO_DL -eq 1 ]]; then add_nodownload "$normalized_file" fi echo "generated $normalized_file from $(basename $info)." echo "Check variables before using." load_bundle_manifests local _bm_pkg="${normalized_file%.hint}" _bm_pkg="${_bm_pkg##*/}" if pkg_has_manifest "$_bm_pkg"; then local _bm_ver; _bm_ver=$(grep '^VERSION=' "$normalized_file" | sed 's/VERSION="//;s/"$//') local _bm_url="" if [[ "$(bundle_mode "$_bm_pkg")" == "url" ]]; then _bm_url=$(manifest_url_for "$_bm_pkg" "$_bm_ver") || _bm_url="" fi echo "" echo "$_bm_pkg: bundled-dep manifest check" reconcile_bundle_deps "$_bm_pkg" "$normalized_file" "$_bm_url" report || true fi add_nvchecker_section "${normalized_file%.hint}" "$info" fi else echo "Hint file exists: $normalized_file" >&2 mv "$normalized_file" "${normalized_file}.bak" echo "Backed up to: ${normalized_file}.bak" >&2 # Create new hint file with empty variables cat > "$normalized_file" <&2 exit 1 fi # Find the category dir(s): REPO_DIR/*/pkg/ local -a matches=() local d for d in "${REPO_DIR%/}"/*/"$pkg"/; do [[ -d "$d" ]] && matches+=("$d") done if [[ ${#matches[@]} -eq 0 ]]; then echo "Error: package not found in ${REPO_DIR%/}: $pkg" >&2 exit 2 fi if [[ ${#matches[@]} -gt 1 ]]; then echo "Error: multiple matches for $pkg in ${REPO_DIR%/}:" >&2 printf ' %s\n' "${matches[@]}" >&2 exit 2 fi local dir="${matches[0]%/}" local category; category=$(basename "$(dirname "$dir")") # Green header on a TTY (same gate as --list). Yellow for the equal case. local g_on="" g_off="" y_on="" y_off="" if [[ -n "$MKHINT_FORCE_COLOR" || -t 1 ]]; then if command -v tput &>/dev/null && tput setaf 2 &>/dev/null; then g_on=$(tput setaf 2); g_off=$(tput sgr0) y_on=$(tput setaf 3); y_off=$(tput sgr0) else g_on=$'\033[32m'; g_off=$'\033[0m' y_on=$'\033[33m'; y_off=$'\033[0m' fi fi local header="${g_on}${category}/${pkg}${g_off}" # Version-compare row (body line 1, scrolls with README). Skip if the # .info has no VERSION. Reuses _normalize_version + sort -V like --list. local vrow="" local sbo_ver="" [[ -f "${dir}/${pkg}.info" ]] && \ sbo_ver=$(grep "^VERSION" "${dir}/${pkg}.info" | cut -d '"' -f2) if [[ -n "$sbo_ver" ]]; then local hint_ver="" [[ -f "${HINT_DIR%/}/${pkg}.hint" ]] && \ hint_ver=$(grep "^VERSION" "${HINT_DIR%/}/${pkg}.hint" | cut -d '"' -f2) if [[ -z "$hint_ver" ]]; then vrow="SBo: ${sbo_ver} (no hint)" else local sn hn sn=$(_normalize_version "$sbo_ver") hn=$(_normalize_version "$hint_ver") if [[ "$sn" == "$hn" ]]; then vrow="${y_on}SBo: ${sbo_ver} = Hint: ${hint_ver}${y_off}" else local newer; newer=$(printf '%s\n%s\n' "$sn" "$hn" | sort -V | tail -1) if [[ "$newer" == "$hn" ]]; then vrow="SBo: ${sbo_ver} < Hint: ${g_on}${hint_ver}${g_off}" else vrow="SBo: ${g_on}${sbo_ver}${g_off} > Hint: ${hint_ver}" fi fi fi fi local readme="${dir}/README" if [[ ! -f "$readme" ]]; then printf '%s\n' "$header" [[ -n "$vrow" ]] && printf '%s\n' "$vrow" echo "(no README)" exit 0 fi # Non-TTY, or README fits: print inline. Else page with header pinned. local rows; rows="${LINES:-}" [[ -z "$rows" ]] && command -v tput &>/dev/null && rows=$(tput lines 2>/dev/null) [[ -z "$rows" ]] && rows=24 local lines; lines=$(wc -l < "$readme") if [[ ! -t 1 || "$lines" -le "$rows" ]]; then printf '%s\n' "$header" [[ -n "$vrow" ]] && printf '%s\n' "$vrow" cat "$readme" exit 0 fi # Page: header as line 1 so it survives; pin it if pager is less. The # version row is body line 1 so it scrolls with the README. local pager="${PAGER:-less}" if [[ "$(basename "$pager")" == "less" ]]; then { printf '%s\n' "$header"; [[ -n "$vrow" ]] && printf '%s\n' "$vrow"; cat "$readme"; } | less -R --header=1 else { printf '%s\n' "$header"; [[ -n "$vrow" ]] && printf '%s\n' "$vrow"; cat "$readme"; } | $pager fi exit 0 } # Emit the TOML section label for a package: bare if the name is a valid # bare key ([A-Za-z0-9_] only), otherwise double-quoted. nvchecker (and TOML) # require quoting for names containing '.', '-', etc. _nvchecker_label() { local pkg="$1" if [[ "$pkg" =~ ^[A-Za-z0-9_]+$ ]]; then printf '[%s]' "$pkg" else printf '["%s"]' "$pkg" fi } # Return 0 if NVCHECKER_CONFIG already has a section for pkg (bare or quoted) _has_nvchecker_section() { local pkg="$1" [[ -f "$NVCHECKER_CONFIG" ]] || return 1 local label; label=$(_nvchecker_label "$pkg") # fixed-string match of the exact label at line start, trailing space allowed grep -qE "^$(printf '%s' "$label" | sed 's/[][\.*^$/]/\\&/g')[[:space:]]*$" \ "$NVCHECKER_CONFIG" } # Print the [pkg] section (header + body) from NVCHECKER_CONFIG: from the label # line until the next line starting with '[' or EOF. Empty if not found. _extract_nvchecker_section() { local pkg="$1" [[ -f "$NVCHECKER_CONFIG" ]] || return 0 local label; label=$(_nvchecker_label "$pkg") awk -v lbl="$label" ' $0==lbl { grab=1; print; next } grab && /^\[/ { exit } grab { print } ' "$NVCHECKER_CONFIG" } # Extract a package name from a language-registry download URL. # Args: . Prints the parsed name, or the fallback # (PRGNAM) when no host-specific pattern matches. _registry_name_from_url() { local url="$1" fallback="$2" case "$url" in *crates.io/api/v1/crates/*) # .../crates/NAME/NAME-VER.crate or .../crates/NAME/VER/download printf '%s' "${url#*crates.io/api/v1/crates/}" | cut -d/ -f1 return 0 ;; *rubygems.org/downloads/*) # /downloads/NAME-VER.gem local base="${url##*/downloads/}"; base="${base%.gem}" printf '%s' "${base%-*}" return 0 ;; *registry.npmjs.org/*) # /NAME/-/NAME-VER.tgz (NAME may be @scope/pkg) printf '%s' "${url#*registry.npmjs.org/}" | sed 's|/-/.*||' return 0 ;; *hackage.haskell.org/package/*) printf '%s' "${url#*hackage.haskell.org/package/}" | cut -d/ -f1 return 0 ;; esac printf '%s' "$fallback" } # Detect an nvchecker source from a package's DOWNLOAD/HOMEPAGE haystack. # Args: . Prints the TOML body (no [label] header). Empty # output means "unrecognised", the caller emits the commented stub. _detect_nvchecker_source() { local haystack="$1" pkg="$2" # ── owner/repo forges ───────────────────────────────────────────────── if [[ "$haystack" =~ (github\.com|gitlab\.com|bitbucket\.org|gitea\.com|codeberg\.org|pagure\.io)/([A-Za-z0-9._-]+)(/([A-Za-z0-9._-]+))? ]]; then local host="${BASH_REMATCH[1]}" local owner="${BASH_REMATCH[2]}" local repo="${BASH_REMATCH[4]}" repo="${repo%.git}"; owner="${owner%.git}" case "$host" in github.com) printf 'source = "github"\ngithub = "%s/%s"\nuse_latest_release = true\n# use_max_tag = true # if the repo publishes no Releases\n# prefix = "v" # uncomment if tags are v-prefixed\n' "$owner" "$repo" return 0 ;; gitlab.com) printf 'source = "gitlab"\ngitlab = "%s/%s"\nuse_max_tag = true\n# prefix = "v" # uncomment if tags are v-prefixed\n' "$owner" "$repo" return 0 ;; bitbucket.org) printf 'source = "bitbucket"\nbitbucket = "%s/%s"\nuse_max_tag = true\n# prefix = "v" # uncomment if tags are v-prefixed\n' "$owner" "$repo" return 0 ;; gitea.com) printf 'source = "gitea"\ngitea = "%s/%s"\nuse_max_tag = true\n# prefix = "v" # uncomment if tags are v-prefixed\n' "$owner" "$repo" return 0 ;; codeberg.org) printf 'source = "gitea"\ngitea = "%s/%s"\nhost = "codeberg.org"\nuse_max_tag = true\n# prefix = "v" # uncomment if tags are v-prefixed\n' "$owner" "$repo" return 0 ;; pagure.io) # pagure field is the repo only, on pagure.io the first path # segment IS the repo (no owner), so it lands in $owner. printf 'source = "pagure"\npagure = "%s"\nuse_max_tag = true\n# prefix = "v" # uncomment if tags are v-prefixed\n' "$owner" return 0 ;; esac fi # ── language registries ─────────────────────────────────────────────── local url; url=$(printf '%s' "$haystack" | grep -oE 'https?://[^ ]+' | head -1) local name case "$haystack" in *pypi.org*|*files.pythonhosted.org*) printf 'source = "pypi"\npypi = "%s"\n' "$pkg"; return 0 ;; *registry.npmjs.org*|*npmjs.com*) name=$(_registry_name_from_url "$url" "$pkg") printf 'source = "npm"\nnpm = "%s"\n' "$name"; return 0 ;; *rubygems.org*) name=$(_registry_name_from_url "$url" "$pkg") printf 'source = "gems"\ngems = "%s"\n' "$name"; return 0 ;; *crates.io*) name=$(_registry_name_from_url "$url" "$pkg") printf 'source = "cratesio"\ncratesio = "%s"\n' "$name"; return 0 ;; *metacpan.org*|*cpan.org*) printf 'source = "cpan"\ncpan = "%s"\n' "$pkg"; return 0 ;; *hackage.haskell.org*) name=$(_registry_name_from_url "$url" "$pkg") printf 'source = "hackage"\nhackage = "%s"\n' "$name"; return 0 ;; *packagist.org*) printf 'source = "packagist"\npackagist = "%s"\n' "$pkg"; return 0 ;; *cran.r-project.org*) printf 'source = "cran"\ncran = "%s"\n' "$pkg"; return 0 ;; esac return 0 # empty output → caller stubs } # Append an nvchecker [pkg] section to NVCHECKER_CONFIG, auto-detecting the # source from the package's .info DOWNLOAD/HOMEPAGE. No-op if section exists. add_nvchecker_section() { local pkg="$1" local info_file="$2" # Ensure config dir/file exist (do not create __config__; user owns that) mkdir -p "$(dirname "$NVCHECKER_CONFIG")" touch "$NVCHECKER_CONFIG" local label; label=$(_nvchecker_label "$pkg") # Already present: dump the existing section so the user sees what's set. if _has_nvchecker_section "$pkg"; then echo "nvchecker: ${label} already present in $NVCHECKER_CONFIG:" echo "────────────────────────────" _extract_nvchecker_section "$pkg" echo "────────────────────────────" return 0 fi local download="" homepage="" if [[ -f "$info_file" ]]; then download=$(grep -E '^(DOWNLOAD|DOWNLOAD_x86_64)=' "$info_file" | head -1) homepage=$(grep -E '^HOMEPAGE=' "$info_file" | head -1) fi local haystack="${download} ${homepage}" local body; body=$(_detect_nvchecker_source "$haystack" "$pkg") local section if [[ -n "$body" ]]; then section=$(printf '\n%s\n%s' "$label" "$body") else section=$(cat <> "$NVCHECKER_CONFIG" echo "nvchecker: added ${label} section to $NVCHECKER_CONFIG:" echo "────────────────────────────" printf '%s\n' "$label" if [[ -n "$body" ]]; then printf '%s\n' "$body" else echo "# TODO: configure nvchecker source for \"${pkg}\"" fi echo "────────────────────────────" } # Add NODOWNLOAD=yes after MD5SUM_x86_64 line if not already present add_nodownload() { local file="$1" if ! grep -q '^NODOWNLOAD=' "$file"; then sed -i '/^MD5SUM_x86_64=/a NODOWNLOAD=yes' "$file" fi } # Parse multiline variable value (handles \ continuation lines) # Prints each whitespace-separated token on its own line parse_multiline_var() { local varname="$1" local file="$2" # Join continuation lines, strip variable name and quotes, print one token per line awk -v var="${varname}" ' BEGIN { found=0; buf="" } !found && $0 ~ "^"var"=\"" { found=1 buf=$0 sub("^"var"=\"", "", buf) if (buf !~ /\\[[:space:]]*$/) { gsub(/"[[:space:]]*$/, "", buf) n=split(buf, arr, /[[:space:]]+/) for (k=1;k<=n;k++) if(arr[k]!="") print arr[k] found=0; buf="" } else { gsub(/\\[[:space:]]*$/, "", buf) } next } found { if ($0 ~ /\\[[:space:]]*$/) { line=$0; gsub(/\\[[:space:]]*$/, "", line) buf=buf" "line } else { line=$0; gsub(/"[[:space:]]*$/, "", line) buf=buf" "line n=split(buf, arr, /[[:space:]]+/) for (k=1;k<=n;k++) if(arr[k]!="") print arr[k] found=0; buf="" } } ' "$file" } # Prompt user for updated continuation URLs; returns updated URLs via nameref array # First URL is always kept as-is (already updated by version sed before this call) prompt_continuation_urls() { local -n _urls="$1" # nameref: array of current URLs local varname="$2" local i for (( i=1; i<${#_urls[@]}; i++ )); do local current="${_urls[$i]}" echo "" echo " ${varname} line $((i+1)) (current): $current" read -r -p " New URL (leave blank to keep): " new_url if [[ -n "$new_url" ]]; then _urls[$i]="$new_url" fi done } # Build multiline variable string for writing back to file # Usage: build_multiline_value urls_array -> prints quoted multiline value build_multiline_value() { local -n _arr="$1" local count=${#_arr[@]} local i for (( i=0; i&2 || true local latest latest=$(nvchecker_latest "$pkg") || { echo "Error: no nvchecker result for '$pkg'. Add/fix its [${pkg}] section in $NVCHECKER_CONFIG" >&2 return 1 } latest=$(_normalize_version "$latest") # Read current version from the hint file (best effort, for display) local hintpath="${HINT_DIR%/}/${pkg}.hint" local current="" [[ -f "$hintpath" ]] && current=$(grep '^VERSION=' "$hintpath" | sed 's/VERSION="//;s/"$//') local answer read -r -p "current ${current:-?}, latest ${latest}. Use ${latest}? [Y/n] (or type a version) " answer >&2 answer="${answer:-Y}" case "$answer" in [Yy]) printf '%s\n' "$latest" ;; [Nn]) return 1 ;; *) printf '%s\n' "$answer" ;; esac } # Download files and update MD5SUM/MD5SUM_x86_64 in hint file # update_checksums [skip_continuation_prompt] # skip_continuation_prompt=1 leaves continuation (index >= 1) URLs untouched and # does not prompt for them — used for manifest-listed packages, whose extra # lines Phase 2 (reconcile_bundle_deps) rewrites from the upstream manifest, so # hand-editing them here would be a pointless double-touch. update_checksums() { local file="$1" local skip_cont="${2:-0}" _process_download_var "DOWNLOAD" "MD5SUM" "$file" "$skip_cont" _process_download_var "DOWNLOAD_x86_64" "MD5SUM_x86_64" "$file" "$skip_cont" } # Process one DOWNLOAD/MD5SUM variable pair in a hint file _process_download_var() { local dl_var="$1" local md5_var="$2" local file="$3" local skip_cont="${4:-0}" # Read current URLs into array mapfile -t urls < <(parse_multiline_var "$dl_var" "$file") [[ ${#urls[@]} -eq 0 ]] && return [[ "${urls[0]}" == "UNSUPPORTED" || "${urls[0]}" == "UNTESTED" ]] && return # Read current md5sums into array (parallel to urls) mapfile -t md5s < <(parse_multiline_var "$md5_var" "$file") # Save original URLs for change detection after prompt local orig_urls=("${urls[@]}") # Prompt user to update continuation URLs if present. Skipped for # manifest-listed packages: Phase 2 (reconcile_bundle_deps) owns those lines. if (( ${#urls[@]} > 1 )); then if [[ "$skip_cont" == 1 ]]; then echo "" echo "Multiline ${dl_var} in $(basename "$file"): continuation URLs left to bundled-dep reconcile." else echo "" echo "Multiline ${dl_var} detected in $(basename "$file")." prompt_continuation_urls urls "$dl_var" fi fi # Download and calculate md5 for each URL local new_md5s=() local i for (( i=0; i<${#urls[@]}; i++ )); do local url="${urls[$i]}" if (( i == 0 )); then # Always re-download first URL echo "Downloading: $url" new_md5s+=( "$(download_file "$url")" ) else # Only re-download if URL changed from original if [[ "$url" != "${orig_urls[$i]}" ]]; then echo "Downloading (updated): $url" new_md5s+=( "$(download_file "$url")" ) else echo "Keeping existing md5 for: $url" new_md5s+=( "${md5s[$i]}" ) fi fi done # Rebuild and write back DOWNLOAD variable (may have updated continuation URLs) local new_dl_value new_dl_value=$(build_multiline_value urls) # Strip surrounding quotes — perl wraps them in the substitution new_dl_value="${new_dl_value#\"}" new_dl_value="${new_dl_value%\"}" perl -i -0pe 'BEGIN{$v=shift} s|^'"${dl_var}"'="[^"]*(?:\\\n[^"]*)*"|'"${dl_var}"'="$v"|m' \ "$new_dl_value" "$file" # Rebuild and write back MD5SUM variable local new_md5_value new_md5_value=$(build_multiline_value new_md5s) new_md5_value="${new_md5_value#\"}" new_md5_value="${new_md5_value%\"}" perl -i -0pe 'BEGIN{$v=shift} s|^'"${md5_var}"'="[^"]*(?:\\\n[^"]*)*"|'"${md5_var}"'="$v"|m' \ "$new_md5_value" "$file" } # Update existing hint file update_hint_file() { cd "$HINT_DIR" local file="$1" local new_version="$2" local old_version="" local normalized_file="${file}" if [[ "$file" != *.hint ]]; then normalized_file="${file}.hint" fi # Check if file exists if [[ ! -f "$normalized_file" ]]; then echo "Error: Hint file does not exist: $normalized_file" >&2 exit 2 fi # Force backup as precaution before modifying echo "Hint file exists: $normalized_file" >&2 cp "$normalized_file" "${normalized_file}.bak" echo "Backed up to: ${normalized_file}.bak" >&2 # Extract current version from hint file old_version=$(grep '^VERSION=' "$normalized_file" | sed 's/VERSION="//;s/"$//') # Use sed for global replacement of OLD_VERSION in all variables. # SlackBuild VERSION uses '_' but download URLs often carry the upstream # '-' form (dates like 2026-07-07, tags), so bump both variants — otherwise # a dated URL keeps pointing at the old tarball and its md5 goes stale. sed -i "s/${old_version}/${new_version}/g" "$normalized_file" if [[ "$old_version" == *_* || "$new_version" == *_* ]]; then sed -i "s/${old_version//_/-}/${new_version//_/-}/g" "$normalized_file" fi # For manifest-listed packages, skip the continuation-URL prompt — Phase 2 # (reconcile_bundle_deps) rewrites those lines from the upstream manifest. load_bundle_manifests local _pkg="${normalized_file%.hint}"; _pkg="${_pkg##*/}" local _skip_cont=0 if pkg_has_manifest "$_pkg"; then _skip_cont=1; fi update_checksums "$normalized_file" "$_skip_cont" if [[ $NO_DL -eq 1 ]]; then add_nodownload "$normalized_file" fi hf=$(cat $normalized_file) echo "Updated hint file: $normalized_file" echo "==========================================" echo -n "$hf" echo; echo "==========================================" } # True if a built package (.txz) for exists in the repo. pkg_in_repo() { local pkg="$1" compgen -G "${PACKAGES_DIR%/}/*/${pkg}/${pkg}-*.txz" >/dev/null 2>&1 } # Prompt to run `slackrepo `; no-op on empty list. run_slackrepo() { local action="$1"; shift [[ $# -eq 0 ]] && return 0 local answer read -r -p "Run 'slackrepo $action $*'? [Y/n] " answer answer="${answer:-Y}" if [[ "$answer" =~ ^[Yy]$ ]]; then slackrepo "$action" "$@" fi } # Single-package dispatch: update if already built, else build. prompt_slackrepo() { local pkg="$1" if pkg_in_repo "$pkg"; then run_slackrepo update "$pkg" else run_slackrepo build "$pkg" fi } # Remove a hint file and its .bak if present. No existence guard, no exit — # safe to call inside loops. Echoes what was removed. _remove_hint() { local full_path="$1" rm "$full_path" echo "Deleted: $full_path" local bak_path="${full_path}.bak" if [[ -f "$bak_path" ]]; then rm "$bak_path" echo "Deleted: $bak_path" fi } # Delete a hint file (and .bak if present) delete_hint_file() { local file="$1" local normalized_file="${file}" if [[ "$file" != *.hint ]]; then normalized_file="${file}.hint" fi local full_path="${HINT_DIR}/${normalized_file}" if [[ ! -f "$full_path" ]]; then echo "Error: Hint file does not exist: $full_path" >&2 exit 2 fi _remove_hint "$full_path" } # Clean .bak files from HINT_DIR clean_bak_files() { if [[ ! -d "$HINT_DIR" ]]; then echo "Error: Hint directory does not exist: $HINT_DIR" >&2 exit 2 fi local count=0 for bakfile in "$HINT_DIR"/*.bak; do if [[ -f "$bakfile" ]]; then rm "$bakfile" count=$((count + 1)) fi done echo "Removed $count .bak file(s) from $HINT_DIR" } # Bulk-check hint files for upstream updates and apply interactively. # Usage: check_updates [pkg...] (no args = all *.hint in HINT_DIR) check_updates() { check_nvchecker local -A prev_version=() # pkg -> version before this run's bump (Job B) if [[ ! -d "$HINT_DIR" ]]; then echo "Error: Hint directory does not exist: $HINT_DIR" >&2 exit 2 fi # Build the target package list local targets=() if [[ $# -gt 0 ]]; then targets=("$@") else local f for f in "$HINT_DIR"/*.hint; do [[ -f "$f" ]] || continue local b; b=$(basename "$f"); targets+=("${b%.hint}") done fi # Refresh nvchecker results. With exactly one explicit target, -e limits # the run to that entry; otherwise scan the whole config in one pass. echo "Running nvchecker..." if [[ ${#targets[@]} -eq 1 && $# -gt 0 ]]; then nvchecker -c "$NVCHECKER_CONFIG" -e "${targets[0]}" >&2 || true else nvchecker -c "$NVCHECKER_CONFIG" >&2 || true fi # Classify each target local outdated_pkgs=() outdated_old=() outdated_new=() outdated_flag=() local missing_sections=() local pkg for pkg in "${targets[@]}"; do local hintpath="${HINT_DIR%/}/${pkg}.hint" [[ -f "$hintpath" ]] || { echo "skip ${pkg}: no hint file"; continue; } local current; current=$(grep '^VERSION=' "$hintpath" | sed 's/VERSION="//;s/"$//') local latest if ! latest=$(nvchecker_latest "$pkg"); then if _has_nvchecker_section "$pkg"; then echo "skip ${pkg}: no nvchecker result" else echo "skip ${pkg}: no nvchecker section" missing_sections+=("$pkg") fi continue fi local latest_norm; latest_norm=$(_normalize_version "$latest") [[ "$current" == "$latest_norm" ]] && continue # up to date # determine direction with sort -V (compare normalized forms) local newest; newest=$(printf '%s\n%s\n' "$current" "$latest_norm" | sort -V | tail -1) local flag="update" [[ "$newest" == "$current" ]] && flag="?downgrade" outdated_pkgs+=("$pkg") outdated_old+=("$current") outdated_new+=("$latest_norm") outdated_flag+=("$flag") prev_version["$pkg"]="$current" # pre-bump version for Job B done # Offer to populate nvchecker.toml for packages with no section if [[ ${#missing_sections[@]} -gt 0 ]]; then echo "" echo "${#missing_sections[@]} package(s) have no nvchecker section: ${missing_sections[*]}" local answer read -r -p "Populate ${NVCHECKER_CONFIG} now? [Y/n] " answer answer="${answer:-Y}" if [[ "$answer" =~ ^[Yy]$ ]]; then local mp info for mp in "${missing_sections[@]}"; do info=$(find "$REPO_DIR" -mindepth 2 -name "${mp}.info" 2>/dev/null | head -1) if [[ -z "$info" ]]; then echo "skip ${mp}: no .info found in $REPO_DIR" continue fi add_nvchecker_section "$mp" "$info" done echo "" echo "Sections added. Review $NVCHECKER_CONFIG (fill any stubs), then re-run 'mkhint -C'." return 0 fi fi local updated=() if [[ ${#outdated_pkgs[@]} -eq 0 ]]; then echo "all up to date" else # Report echo "" echo "Updates available:" local i for (( i=0; i<${#outdated_pkgs[@]}; i++ )); do local note=""; [[ "${outdated_flag[$i]}" == "?downgrade" ]] && note=" (?downgrade)" printf " %-30s %s -> %s%s\n" "${outdated_pkgs[$i]}" "${outdated_old[$i]}" "${outdated_new[$i]}" "$note" done echo "" # Per-package confirm + update for (( i=0; i<${#outdated_pkgs[@]}; i++ )); do local p="${outdated_pkgs[$i]}" local note=""; [[ "${outdated_flag[$i]}" == "?downgrade" ]] && note=" (?downgrade)" local answer read -r -p "${p} ${outdated_old[$i]} -> ${outdated_new[$i]}${note}. Update? [Y/n] " answer answer="${answer:-Y}" if [[ "$answer" =~ ^[Yy]$ ]]; then update_hint_file "$p" "${outdated_new[$i]}" nvtake -c "$NVCHECKER_CONFIG" "$p" >&2 || true updated+=("$p") fi done # Split updated packages: existing → update, new → build. if [[ ${#updated[@]} -gt 0 ]]; then local -a existing=() fresh=() local up for up in "${updated[@]}"; do if pkg_in_repo "$up"; then existing+=("$up"); else fresh+=("$up"); fi done run_slackrepo update "${existing[@]}" run_slackrepo build "${fresh[@]}" fi fi # Phase 2: bundled-dep manifest reconcile for listed packages. load_bundle_manifests local was_updated for pkg in "${targets[@]}"; do pkg_has_manifest "$pkg" || continue local hintpath="${HINT_DIR%/}/${pkg}.hint" [[ -f "$hintpath" ]] || continue was_updated=0 local u for u in "${updated[@]}"; do [[ "$u" == "$pkg" ]] && was_updated=1; done if [[ $was_updated -eq 0 && $FORCE -ne 1 ]]; then continue fi local cur; cur=$(grep '^VERSION=' "$hintpath" | sed 's/VERSION="//;s/"$//') local murl="" if [[ "$(bundle_mode "$pkg")" == "url" ]]; then murl=$(manifest_url_for "$pkg" "$cur") || continue fi echo "" echo "Bundled-dep reconcile: $pkg (manifest @ $cur)" local rc=0 reconcile_bundle_deps "$pkg" "$hintpath" "$murl" report || rc=$? # rc==2 means the report found changes to apply; anything else (0, or a # real error) means nothing to prompt about. if [[ $rc -eq 2 ]]; then local ans read -r -p "Apply bundled-dep updates for $pkg? [Y/n] " ans ans="${ans:-Y}" if [[ "$ans" =~ ^[Yy]$ ]]; then reconcile_bundle_deps "$pkg" "$hintpath" "$murl" apply || true fi fi # Job B: submodule inventory / set-drift roster (sha-mode only). if [[ "$(bundle_mode "$pkg")" == "sha" ]]; then local _old_ver="${prev_version[$pkg]:-$cur}" _new_ver="$cur" detect_set_drift "$pkg" "$_old_ver" "$_new_ver" || true fi done } # Main function main() { local parsed parsed=$(getopt -o vV:f:n:i:lcCdNhRF \ --long version,set-version:,hintfile:,new:,info:,list,clean,check,delete,no-dl,help,review,fix-current,force \ -n 'mkhint' -- "$@") || { show_help; exit 1; } eval set -- "$parsed" while true; do case "$1" in --version|-v) echo "mkhint $MKHINT_VERSION" exit 0 ;; --set-version|-V) VERSION="$2" shift 2 ;; --hintfile|-f) HINT_FILE="$2" shift 2 ;; --new|-n) NEW_HINT_FILE="$2" shift 2 ;; --info|-i) COMMAND="info" INFO_PKG="$2" shift 2 ;; --list|-l) SHOW_LIST=1 shift ;; --review|-R) RUN_REVIEW=1 shift ;; --clean|-c) COMMAND="clean" shift ;; --check|-C) COMMAND="check" shift ;; --fix-current|-F) COMMAND="fix-current" shift ;; --delete|-d) COMMAND="delete" shift ;; --no-dl|-N) NO_DL=1 shift ;; --force) FORCE=1 shift ;; --help|-h) COMMAND="help" shift ;; --) shift break ;; *) echo "Unknown option: $1" >&2 show_help exit 1 ;; esac done # Collect remaining positional args. # When -R is active and no other command set, they are review targets; # otherwise they are delete targets. while [[ $# -gt 0 ]]; do if [[ -n "$RUN_REVIEW" && -z "$COMMAND" ]]; then REVIEW_PKGS+=("$1") elif [[ -n "$SHOW_LIST" && -z "$COMMAND" ]]; then LIST_PKGS+=("$1") else DELETE_HINT_FILES+=("$1") fi shift done if [[ -z "$COMMAND" ]]; then # Default to update hint file if VERSION and HINT_FILE are provided if [[ -n "$HINT_FILE" ]]; then COMMAND="update" elif [[ -n "$NEW_HINT_FILE" ]]; then COMMAND="new" elif [[ ${#DELETE_HINT_FILES[@]} -gt 0 ]]; then COMMAND="delete" fi fi if [[ $NO_DL -eq 1 && -z "$HINT_FILE" && -z "$NEW_HINT_FILE" ]]; then echo "Error: --no-dl requires --hintfile or --new" >&2 exit 1 fi if [[ "$COMMAND" == "check" && ( -n "$VERSION" || -n "$HINT_FILE" || -n "$NEW_HINT_FILE" ) ]]; then echo "Error: --check cannot be combined with --set-version/--hintfile/--new" >&2 exit 1 fi if [[ "$COMMAND" == "fix-current" && ( -n "$VERSION" || -n "$HINT_FILE" || -n "$NEW_HINT_FILE" ) ]]; then echo "Error: --fix-current cannot be combined with --set-version/--hintfile/--new" >&2 exit 1 fi if [[ "$COMMAND" == "info" && ( -n "$VERSION" || -n "$HINT_FILE" || -n "$NEW_HINT_FILE" ) ]]; then echo "Error: --info cannot be combined with --set-version/--hintfile/--new" >&2 exit 1 fi if [[ $FORCE -eq 1 && "$COMMAND" != "check" ]]; then echo "Error: --force is only valid with --check" >&2 exit 1 fi if [[ -n "$SHOW_LIST" && ${#LIST_PKGS[@]} -gt 0 ]]; then local p for p in "${LIST_PKGS[@]}"; do if [[ ! -f "${HINT_DIR%/}/${p}.hint" ]]; then echo "Error: hint file not found: ${HINT_DIR%/}/${p}.hint" >&2 exit 2 fi done for p in "${LIST_PKGS[@]}"; do _show_hint_diff "$p" done exit 0 fi if [[ -n "$SHOW_LIST" || -n "$RUN_REVIEW" ]]; then [[ -n "$SHOW_LIST" ]] && list_hint_files if [[ -n "$RUN_REVIEW" ]]; then if [[ ${#REVIEW_PKGS[@]} -gt 0 ]]; then review_hint_files "${REVIEW_PKGS[@]}" else # ensure MATCHED_PKGS is populated even when -l was not given [[ -z "$SHOW_LIST" ]] && list_hint_files >/dev/null review_hint_files fi fi exit $? fi case "$COMMAND" in help) show_help ;; clean) clean_bak_files ;; check) check_updates "${DELETE_HINT_FILES[@]}" ;; fix-current) fix_current ;; info) show_info "$INFO_PKG" ;; update) check_wget if [[ -z "$VERSION" ]]; then check_nvchecker VERSION=$(suggest_version "$HINT_FILE") || { echo "Aborted." >&2; exit 0; } check_nvchecker_take=1 fi update_hint_file "$HINT_FILE" "$VERSION" if [[ "${check_nvchecker_take:-0}" -eq 1 ]]; then nvtake -c "$NVCHECKER_CONFIG" "$HINT_FILE" >&2 || true fi load_bundle_manifests local _bm_pkg="${HINT_FILE%.hint}" _bm_pkg="${_bm_pkg##*/}" if pkg_has_manifest "$_bm_pkg"; then local _bm_hint="${HINT_DIR%/}/${_bm_pkg}.hint" local _bm_cur; _bm_cur=$(grep '^VERSION=' "$_bm_hint" | sed 's/VERSION="//;s/"$//') local _bm_url="" if [[ "$(bundle_mode "$_bm_pkg")" == "url" ]]; then _bm_url=$(manifest_url_for "$_bm_pkg" "$_bm_cur") || _bm_url="" fi echo "" echo "Bundled-dep reconcile: $_bm_pkg (manifest @ $_bm_cur)" local _bm_rc=0 reconcile_bundle_deps "$_bm_pkg" "$_bm_hint" "$_bm_url" report || _bm_rc=$? if [[ $_bm_rc -eq 2 ]]; then local _bm_ans read -r -p "Apply bundled-dep updates for $_bm_pkg? [Y/n] " _bm_ans _bm_ans="${_bm_ans:-Y}" if [[ "$_bm_ans" =~ ^[Yy]$ ]]; then reconcile_bundle_deps "$_bm_pkg" "$_bm_hint" "$_bm_url" apply || true fi fi fi prompt_slackrepo "$HINT_FILE" ;; new) if [[ -n "$VERSION" ]]; then check_wget fi create_new_hint_file "$NEW_HINT_FILE" ;; delete) for f in "${DELETE_HINT_FILES[@]}"; do delete_hint_file "$f" done ;; *) echo "Error: Unknown command: $COMMAND" >&2 show_help exit 1 ;; esac } # Only run main when executed directly; allow tests to source this file to # reach individual functions without triggering the CLI (and its `exit 1` # for a no-args invocation). if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ -n "${MKHINT_NOMAIN:-}" ]] || main "$@" fi