From 77a0f9e17169d7a6b399c8f8d3a426bf1efc931b Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:26:57 +0200 Subject: [PATCH] feat(easy-docker): bootstrap jq dependency on startup --- scripts/easy-docker/config/jq-checksums.tsv | 10 ++ scripts/easy-docker/lib/checks/jq.sh | 6 + scripts/easy-docker/lib/core/json.sh | 26 ++++ scripts/easy-docker/lib/core/messages.sh | 10 ++ scripts/easy-docker/lib/install/jq/assets.sh | 144 ++++++++++++++++++ scripts/easy-docker/lib/install/jq/ensure.sh | 67 ++++++++ .../lib/install/jq/github_release.sh | 109 +++++++++++++ scripts/easy-docker/lib/install/jq/load.sh | 20 +++ .../lib/install/jq/package_manager.sh | 62 ++++++++ .../easy-docker/lib/install/jq/platform.sh | 52 +++++++ scripts/easy-docker/lib/load.sh | 4 + scripts/easy-docker/main.sh | 4 + tests/easy-docker/10_cli_smoke.bats | 20 +++ tests/easy-docker/45_jq_checks.bats | 134 ++++++++++++++++ 14 files changed, 668 insertions(+) create mode 100644 scripts/easy-docker/config/jq-checksums.tsv create mode 100755 scripts/easy-docker/lib/checks/jq.sh create mode 100755 scripts/easy-docker/lib/core/json.sh create mode 100755 scripts/easy-docker/lib/install/jq/assets.sh create mode 100755 scripts/easy-docker/lib/install/jq/ensure.sh create mode 100755 scripts/easy-docker/lib/install/jq/github_release.sh create mode 100755 scripts/easy-docker/lib/install/jq/load.sh create mode 100755 scripts/easy-docker/lib/install/jq/package_manager.sh create mode 100755 scripts/easy-docker/lib/install/jq/platform.sh create mode 100755 tests/easy-docker/45_jq_checks.bats diff --git a/scripts/easy-docker/config/jq-checksums.tsv b/scripts/easy-docker/config/jq-checksums.tsv new file mode 100644 index 00000000..89d34996 --- /dev/null +++ b/scripts/easy-docker/config/jq-checksums.tsv @@ -0,0 +1,10 @@ +# version asset_name sha256 +1.8.1 jq-linux-amd64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d +1.8.1 jq-linux64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d +1.8.1 jq-linux-arm64 6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4 +1.8.1 jq-linux-armhf ac304e50cf7cd24933d83dc7d0e4f79892a71a92fb02336d4ecaffa8933760bd +1.8.1 jq-macos-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f +1.8.1 jq-osx-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f +1.8.1 jq-macos-arm64 a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603 +1.8.1 jq-win64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334 +1.8.1 jq-windows-amd64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334 diff --git a/scripts/easy-docker/lib/checks/jq.sh b/scripts/easy-docker/lib/checks/jq.sh new file mode 100755 index 00000000..e259eb43 --- /dev/null +++ b/scripts/easy-docker/lib/checks/jq.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +jq_check_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=scripts/easy-docker/lib/install/jq/load.sh +source "${jq_check_dir}/../install/jq/load.sh" diff --git a/scripts/easy-docker/lib/core/json.sh b/scripts/easy-docker/lib/core/json.sh new file mode 100755 index 00000000..a053338f --- /dev/null +++ b/scripts/easy-docker/lib/core/json.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +get_easy_docker_jq_command() { + if command -v jq >/dev/null 2>&1; then + printf 'jq\n' + return 0 + fi + + if command -v jq.exe >/dev/null 2>&1; then + printf 'jq.exe\n' + return 0 + fi + + return 1 +} + +easy_docker_require_jq() { + get_easy_docker_jq_command >/dev/null 2>&1 +} + +easy_docker_run_jq() { + local jq_command="" + + jq_command="$(get_easy_docker_jq_command)" || return 127 + "${jq_command}" "$@" +} diff --git a/scripts/easy-docker/lib/core/messages.sh b/scripts/easy-docker/lib/core/messages.sh index a2fd3fde..fca2524a 100755 --- a/scripts/easy-docker/lib/core/messages.sh +++ b/scripts/easy-docker/lib/core/messages.sh @@ -27,3 +27,13 @@ print_docker_command_support_guidance() { echo "Update Docker to a recent version and ensure Compose v2 is available as 'docker compose'." echo "Standard 'docker' and 'docker compose' commands are required." } + +print_jq_install_guidance() { + print_manual_jq_install_guidance +} + +print_manual_jq_install_guidance() { + echo "Install jq first: https://jqlang.org/download/" + echo "On Windows, you can also use: winget install jqlang.jq" + echo "Ensure the 'jq' or 'jq.exe' command is available on PATH before running easy-docker." +} diff --git a/scripts/easy-docker/lib/install/jq/assets.sh b/scripts/easy-docker/lib/install/jq/assets.sh new file mode 100755 index 00000000..786ed3a6 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/assets.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +get_jq_checksums_path() { + local jq_lib_dir="" + + jq_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + printf '%s/../../../config/jq-checksums.tsv\n' "${jq_lib_dir}" +} + +get_pinned_jq_version() { + local checksums_path="" + local release_version="" + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + release_version="$( + awk -F '\t' ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + { + print $1 + exit + } + ' "${checksums_path}" + )" + if [ -z "${release_version}" ]; then + return 1 + fi + + printf '%s\n' "${release_version}" +} + +get_pinned_jq_asset_checksum() { + local release_version="${1}" + local asset_name="${2}" + local checksums_path="" + local expected_checksum="" + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + expected_checksum="$( + awk -F '\t' -v release_version="${release_version}" -v asset_name="${asset_name}" ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + $1 == release_version && $2 == asset_name { + print $3 + exit + } + ' "${checksums_path}" + )" + if [ -z "${expected_checksum}" ]; then + return 1 + fi + + printf '%s\n' "${expected_checksum}" +} + +sha256_verification_available_for_jq() { + command_exists sha256sum || + command_exists shasum || + command_exists openssl || + command_exists certutil +} + +compute_file_sha256_for_jq() { + local file_path="${1}" + local hash_input_path="${file_path}" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists openssl; then + openssl dgst -sha256 -r "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists certutil; then + if command_exists cygpath; then + hash_input_path="$(cygpath -w "${file_path}" 2>/dev/null || printf '%s' "${file_path}")" + fi + + certutil -hashfile "${hash_input_path}" SHA256 2>/dev/null | + awk 'NR == 2 { gsub(/ /, "", $0); print tolower($0); exit }' + return $? + fi + + return 1 +} + +verify_file_sha256_for_jq() { + local file_path="${1}" + local expected_checksum="${2}" + local actual_checksum="" + + actual_checksum="$(compute_file_sha256_for_jq "${file_path}" || true)" + if [ -z "${actual_checksum}" ]; then + return 1 + fi + + if [ "${actual_checksum}" != "$(printf '%s' "${expected_checksum}" | tr '[:upper:]' '[:lower:]')" ]; then + return 1 + fi + + return 0 +} + +get_jq_asset_candidates() { + local jq_os="${1}" + local jq_arch="${2}" + + case "${jq_os}:${jq_arch}" in + linux:amd64) + printf '%s\n%s\n' "jq-linux-amd64" "jq-linux64" + ;; + linux:arm64) + printf '%s\n' "jq-linux-arm64" + ;; + linux:armhf) + printf '%s\n' "jq-linux-armhf" + ;; + macos:amd64) + printf '%s\n%s\n' "jq-macos-amd64" "jq-osx-amd64" + ;; + macos:arm64) + printf '%s\n' "jq-macos-arm64" + ;; + windows:amd64) + printf '%s\n%s\n' "jq-windows-amd64.exe" "jq-win64.exe" + ;; + esac +} diff --git a/scripts/easy-docker/lib/install/jq/ensure.sh b/scripts/easy-docker/lib/install/jq/ensure.sh new file mode 100755 index 00000000..6033fcc8 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/ensure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +should_use_jq_github_fallback() { + local answer="" + + if [ ! -t 0 ]; then + echo "GitHub fallback prompt requires an interactive terminal." + return 1 + fi + + printf "Use GitHub binary fallback for jq? [y/N]: " + read -r answer + + case "${answer}" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_jq() { + local disable_installation_fallback="${1:-0}" + + if get_easy_docker_jq_command >/dev/null 2>&1; then + return 0 + fi + + echo "jq is not installed. Trying package manager installation..." + + if install_jq_with_package_manager; then + hash -r + fi + + if get_easy_docker_jq_command >/dev/null 2>&1; then + echo "jq was installed successfully." + return 0 + fi + + if [ "${disable_installation_fallback}" = "1" ]; then + echo "Installation fallback is disabled." + print_manual_jq_install_guidance + return 1 + fi + + if should_use_jq_github_fallback; then + echo "Trying pinned GitHub release fallback..." + if install_jq_from_github_release; then + hash -r + fi + else + echo "GitHub fallback was not selected." + print_manual_jq_install_guidance + return 1 + fi + + if get_easy_docker_jq_command >/dev/null 2>&1; then + echo "jq was installed successfully." + return 0 + fi + + echo "Automatic installation failed." + print_manual_jq_install_guidance + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/github_release.sh b/scripts/easy-docker/lib/install/jq/github_release.sh new file mode 100755 index 00000000..19dfab92 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/github_release.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +cleanup_jq_tmp_dir() { + local tmp_dir="${1:-}" + + if [ -n "${tmp_dir}" ] && [ -d "${tmp_dir}" ]; then + rm -rf "${tmp_dir}" + fi +} + +install_jq_from_github_release() { + local release_version="" + local checksums_path="" + local jq_os="" + local jq_arch="" + local asset_name="" + local asset_path="" + local download_url="" + local tmp_dir="" + local target_dir="" + local target_binary_name="jq" + local expected_checksum="" + + if ! command_exists curl; then + echo "curl is required for the GitHub fallback." + return 1 + fi + + if ! read -r jq_os jq_arch < <(detect_jq_platform); then + echo "Unsupported platform for automatic GitHub fallback." + return 1 + fi + + release_version="$(get_pinned_jq_version || true)" + if [ -z "${release_version}" ]; then + echo "Could not determine the pinned jq release version." + return 1 + fi + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + echo "Pinned jq checksum file is missing: ${checksums_path}" + return 1 + fi + + if ! sha256_verification_available_for_jq; then + echo "A SHA256 verification tool is required for the GitHub fallback." + return 1 + fi + + tmp_dir="$(mktemp -d 2>/dev/null || true)" + if [ -z "${tmp_dir}" ] || [ ! -d "${tmp_dir}" ]; then + echo "Failed to create temporary directory for jq installation." + return 1 + fi + + while IFS= read -r asset_name; do + expected_checksum="$(get_pinned_jq_asset_checksum "${release_version}" "${asset_name}" || true)" + if [ -z "${expected_checksum}" ]; then + continue + fi + + asset_path="${tmp_dir}/${asset_name}" + download_url="https://github.com/jqlang/jq/releases/download/jq-${release_version}/${asset_name}" + + if ! curl -fsSL "${download_url}" -o "${asset_path}"; then + continue + fi + + if ! verify_file_sha256_for_jq "${asset_path}" "${expected_checksum}"; then + echo "Checksum verification failed for ${asset_name}." + continue + fi + + if [[ "${asset_name}" == *.exe ]]; then + target_binary_name="jq.exe" + else + target_binary_name="jq" + fi + + if [ "${jq_os}" != "windows" ] && [ -w "/usr/local/bin" ]; then + target_dir="/usr/local/bin" + if copy_binary "${asset_path}" "${target_dir}/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ "${jq_os}" != "windows" ] && command_exists sudo; then + if copy_binary_with_privileges "${asset_path}" "/usr/local/bin/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ -n "${HOME:-}" ]; then + target_dir="${HOME}/.local/bin" + mkdir -p "${target_dir}" + if copy_binary "${asset_path}" "${target_dir}/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + done < <(get_jq_asset_candidates "${jq_os}" "${jq_arch}") + + cleanup_jq_tmp_dir "${tmp_dir}" + echo "No compatible, verified jq binary was installed from the pinned GitHub release." + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/load.sh b/scripts/easy-docker/lib/install/jq/load.sh new file mode 100755 index 00000000..b7c844d3 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/load.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +load_jq_install_modules() { + local jq_lib_dir="" + + jq_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/install/jq/platform.sh + source "${jq_lib_dir}/platform.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/assets.sh + source "${jq_lib_dir}/assets.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/package_manager.sh + source "${jq_lib_dir}/package_manager.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/github_release.sh + source "${jq_lib_dir}/github_release.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/ensure.sh + source "${jq_lib_dir}/ensure.sh" +} + +load_jq_install_modules diff --git a/scripts/easy-docker/lib/install/jq/package_manager.sh b/scripts/easy-docker/lib/install/jq/package_manager.sh new file mode 100755 index 00000000..26716bec --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/package_manager.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +install_jq_with_package_manager() { + local pm_attempted=0 + + if command_exists brew; then + pm_attempted=1 + if brew install jq; then + return 0 + fi + fi + + if command_exists apt-get; then + pm_attempted=1 + if run_with_privileges apt-get update && run_with_privileges apt-get install -y jq; then + return 0 + fi + fi + + if command_exists dnf; then + pm_attempted=1 + if run_with_privileges dnf install -y jq; then + return 0 + fi + fi + + if command_exists pacman; then + pm_attempted=1 + if run_with_privileges pacman -Sy --noconfirm jq; then + return 0 + fi + fi + + if command_exists zypper; then + pm_attempted=1 + if run_with_privileges zypper --non-interactive install jq; then + return 0 + fi + fi + + if command_exists winget; then + pm_attempted=1 + if winget install --id jqlang.jq -e --accept-source-agreements --accept-package-agreements; then + return 0 + fi + fi + + if command_exists choco; then + pm_attempted=1 + if choco install jq -y; then + return 0 + fi + fi + + if [ "${pm_attempted}" -eq 0 ]; then + echo "No supported package manager was found." + else + echo "Package manager installation did not succeed." + fi + + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/platform.sh b/scripts/easy-docker/lib/install/jq/platform.sh new file mode 100755 index 00000000..54426dc3 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/platform.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +detect_jq_platform() { + local raw_os="" + local raw_arch="" + local jq_os="" + local jq_arch="" + + raw_os="$(uname -s 2>/dev/null || echo unknown)" + raw_arch="$(uname -m 2>/dev/null || echo unknown)" + + case "${raw_os}" in + Linux*) + jq_os="linux" + ;; + Darwin*) + jq_os="macos" + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + jq_os="windows" + ;; + *) + return 1 + ;; + esac + + case "${raw_arch}" in + x86_64 | amd64) + jq_arch="amd64" + ;; + aarch64 | arm64) + jq_arch="arm64" + ;; + armv7l | armv7) + jq_arch="armhf" + ;; + *) + return 1 + ;; + esac + + if [ "${jq_os}" = "windows" ] && [ "${jq_arch}" != "amd64" ]; then + return 1 + fi + + if [ "${jq_os}" = "macos" ] && [ "${jq_arch}" = "armhf" ]; then + return 1 + fi + + printf '%s %s\n' "${jq_os}" "${jq_arch}" + return 0 +} diff --git a/scripts/easy-docker/lib/load.sh b/scripts/easy-docker/lib/load.sh index 3d76d77c..7a84d309 100755 --- a/scripts/easy-docker/lib/load.sh +++ b/scripts/easy-docker/lib/load.sh @@ -8,10 +8,14 @@ load_easy_docker_modules() { source "${lib_dir}/core/commands.sh" # shellcheck source=scripts/easy-docker/lib/core/messages.sh source "${lib_dir}/core/messages.sh" + # shellcheck source=scripts/easy-docker/lib/core/json.sh + source "${lib_dir}/core/json.sh" # shellcheck source=scripts/easy-docker/lib/install/gum/load.sh source "${lib_dir}/install/gum/load.sh" # shellcheck source=scripts/easy-docker/lib/checks/docker.sh source "${lib_dir}/checks/docker.sh" + # shellcheck source=scripts/easy-docker/lib/checks/jq.sh + source "${lib_dir}/checks/jq.sh" # shellcheck source=scripts/easy-docker/lib/ui/screens.sh source "${lib_dir}/ui/screens.sh" # shellcheck source=scripts/easy-docker/lib/app/screen.sh diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh index 16f171f3..88633c61 100755 --- a/scripts/easy-docker/main.sh +++ b/scripts/easy-docker/main.sh @@ -25,6 +25,10 @@ if ! ensure_docker; then exit 1 fi +if ! ensure_jq "${disable_installation_fallback}"; then + exit 1 +fi + trap 'leave_alt_screen; exit 0' INT TERM trap 'leave_alt_screen' EXIT diff --git a/tests/easy-docker/10_cli_smoke.bats b/tests/easy-docker/10_cli_smoke.bats index 318c7c6c..5e18ace5 100755 --- a/tests/easy-docker/10_cli_smoke.bats +++ b/tests/easy-docker/10_cli_smoke.bats @@ -84,3 +84,23 @@ write_passthrough_stub() { [[ "${output}" == *"docker is not installed."* ]] [[ "${output}" == *"Install Docker first:"* ]] } + +@test "missing jq stops after gum and docker dependencies succeed" { + write_stub gum 'exit 0' + # shellcheck disable=SC2016 + write_stub docker \ + 'if [ "${1:-}" = "compose" ] && [ "${2:-}" = "version" ]; then exit 0; fi' \ + 'if [ "${1:-}" = "info" ]; then exit 0; fi' \ + 'case "$*" in' \ + ' "ps --help"| "exec --help"| "inspect --help"| "cp --help"| "build --help"| "compose config --help"| "compose up --help"| "compose down --help"| "compose logs --help"| "compose exec --help"| "compose pull --help"| "compose ps --help") exit 0 ;;' \ + 'esac' \ + 'exit 0' + + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --no-installation-fallback + + [ "${status}" -eq 1 ] + [[ "${output}" == *"jq is not installed. Trying package manager installation..."* ]] + [[ "${output}" == *"No supported package manager was found."* ]] + [[ "${output}" == *"Installation fallback is disabled."* ]] + [[ "${output}" == *"Install jq first:"* ]] +} diff --git a/tests/easy-docker/45_jq_checks.bats b/tests/easy-docker/45_jq_checks.bats new file mode 100755 index 00000000..a2780cf1 --- /dev/null +++ b/tests/easy-docker/45_jq_checks.bats @@ -0,0 +1,134 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_jq_modules +} + +teardown() { + easy_docker_test_end +} + +@test "ensure_jq fails when jq is not installed" { + # shellcheck disable=SC2317 + get_easy_docker_jq_command() { + return 1 + } + + # shellcheck disable=SC2317 + install_jq_with_package_manager() { + echo "No supported package manager was found." + return 1 + } + + run ensure_jq 1 + + [ "${status}" -eq 1 ] + [[ "${output}" == *"jq is not installed. Trying package manager installation..."* ]] + [[ "${output}" == *"No supported package manager was found."* ]] + [[ "${output}" == *"Installation fallback is disabled."* ]] + [[ "${output}" == *"Install jq first:"* ]] +} + +@test "ensure_jq succeeds when jq is installed" { + # shellcheck disable=SC2317 + get_easy_docker_jq_command() { + printf '%s\n' "jq" + } + + run ensure_jq 0 + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "ensure_jq succeeds when only jq.exe is installed" { + # shellcheck disable=SC2317 + get_easy_docker_jq_command() { + printf '%s\n' "jq.exe" + } + + run ensure_jq 0 + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "should_use_jq_github_fallback rejects non-interactive terminals" { + local script_path="" + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + script_path="${EASY_DOCKER_TEST_TMPDIR}/run-should-use-jq-github-fallback" + easy_docker_test_write_executable "${script_path}" \ + "source \"${repo_root}/scripts/easy-docker/lib/install/jq/ensure.sh\"" \ + 'should_use_jq_github_fallback' + + run "${script_path}"