diff --git a/README.md b/README.md index 2afb514..a79a07a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Baudbot is designed as shared engineering infrastructure, not a single-user desk | **CPU** | 2 vCPU | 4 vCPU | | **Disk** | 20 GB | 40 GB+ (repos, dependencies, Docker images) | +System package dependencies (installed by `baudbot install`): `git`, `curl`, `tmux`, `iptables`, `docker`, `gh`, `jq`, `sudo`. + ## Quick Start ```bash diff --git a/bin/baudbot b/bin/baudbot index a70512d..c87061a 100755 --- a/bin/baudbot +++ b/bin/baudbot @@ -18,9 +18,31 @@ if [ -z "${BAUDBOT_ROOT:-}" ]; then # Handle relative symlinks [[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT" done - BAUDBOT_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)" + + CANDIDATE_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)" + if [ -f "$CANDIDATE_ROOT/bin/baudbot" ] && [ -f "$CANDIDATE_ROOT/package.json" ]; then + BAUDBOT_ROOT="$CANDIDATE_ROOT" + elif [ -d /opt/baudbot/current/bin ]; then + BAUDBOT_ROOT="$(readlink -f /opt/baudbot/current 2>/dev/null || echo /opt/baudbot/current)" + else + BAUDBOT_ROOT="$CANDIDATE_ROOT" + fi fi +json_get_string_or_empty() { + local file="$1" + local key="$2" + [ -r "$file" ] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -er --arg k "$key" 'if (type == "object") and has($k) and (.[$k] | type == "string") then .[$k] else empty end' "$file" 2>/dev/null || true +} + +json_get_string_stdin_or_empty() { + local key="$1" + command -v jq >/dev/null 2>&1 || return 0 + jq -er --arg k "$key" 'if (type == "object") and has($k) and (.[$k] | type == "string") then .[$k] else empty end' 2>/dev/null || true +} + # Colors (disabled if not a terminal) if [ -t 1 ]; then BOLD='\033[1m' @@ -34,11 +56,15 @@ else fi version() { - if [ -f "$BAUDBOT_ROOT/package.json" ]; then - grep '"version"' "$BAUDBOT_ROOT/package.json" | head -1 | sed 's/.*: *"\(.*\)".*/\1/' - else - echo "unknown" + local package_json="$BAUDBOT_ROOT/package.json" + local pkg_version="" + + if [ -f "$package_json" ]; then + pkg_version="$(json_get_string_or_empty "$package_json" "version")" + [ -n "$pkg_version" ] && echo "$pkg_version" && return 0 fi + + echo "unknown" } version_sha() { @@ -73,7 +99,7 @@ version_sha() { # Runtime metadata fallback version_file="/home/${BAUDBOT_AGENT_USER:-baudbot_agent}/.pi/agent/baudbot-version.json" if [ -r "$version_file" ]; then - sha="$(grep -E '"sha"[[:space:]]*:' "$version_file" | head -1 | sed 's/.*"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" + sha="$(json_get_string_or_empty "$version_file" "sha")" [ -n "$sha" ] && echo "${sha:0:7}" && return 0 fi @@ -282,7 +308,6 @@ has_systemd() { print_deployed_version() { local agent_user="${BAUDBOT_AGENT_USER:-baudbot_agent}" local version_file="/home/$agent_user/.pi/agent/baudbot-version.json" - local version_json="" local short="" local sha="" local branch="" @@ -290,12 +315,22 @@ print_deployed_version() { local line="" if [ -r "$version_file" ]; then - version_json="$(cat "$version_file" 2>/dev/null || true)" + short="$(json_get_string_or_empty "$version_file" "short")" + sha="$(json_get_string_or_empty "$version_file" "sha")" + branch="$(json_get_string_or_empty "$version_file" "branch")" + deployed_at="$(json_get_string_or_empty "$version_file" "deployed_at")" elif [ "$(id -u)" -eq 0 ] && id "$agent_user" >/dev/null 2>&1; then - version_json="$(sudo -u "$agent_user" cat "$version_file" 2>/dev/null || true)" + local version_json="" + version_json="$(sudo -u "$agent_user" sh -c "cat '$version_file' 2>/dev/null" || true)" + if [ -n "$version_json" ]; then + short="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "short" 2>/dev/null || true)" + sha="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "sha" 2>/dev/null || true)" + branch="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "branch" 2>/dev/null || true)" + deployed_at="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "deployed_at" 2>/dev/null || true)" + fi fi - if [ -z "$version_json" ]; then + if [ -z "$short" ] && [ -z "$sha" ] && [ -z "$branch" ] && [ -z "$deployed_at" ]; then local release_target="" local release_sha="" @@ -309,11 +344,6 @@ print_deployed_version() { return 0 fi - short="$(printf '%s\n' "$version_json" | grep -E '"short"[[:space:]]*:' | head -1 | sed 's/.*"short"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" - sha="$(printf '%s\n' "$version_json" | grep -E '"sha"[[:space:]]*:' | head -1 | sed 's/.*"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" - branch="$(printf '%s\n' "$version_json" | grep -E '"branch"[[:space:]]*:' | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" - deployed_at="$(printf '%s\n' "$version_json" | grep -E '"deployed_at"[[:space:]]*:' | head -1 | sed 's/.*"deployed_at"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" - if [ -z "$short" ] && [ -n "$sha" ]; then short="${sha:0:7}" fi diff --git a/bin/ci/setup-arch.sh b/bin/ci/setup-arch.sh index 641519d..204ec67 100755 --- a/bin/ci/setup-arch.sh +++ b/bin/ci/setup-arch.sh @@ -7,8 +7,8 @@ set -euo pipefail -echo "=== [Arch] Installing git (needed to init test repo) ===" -pacman -Sy --noconfirm --needed git sudo 2>&1 | tail -3 +echo "=== [Arch] Installing base CI deps ===" +pacman -Sy --noconfirm --needed git jq sudo 2>&1 | tail -3 echo "=== Preparing source ===" useradd -m -s /bin/bash baudbot_admin diff --git a/bin/deploy.sh b/bin/deploy.sh index 209066b..e2c7dca 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -18,6 +18,10 @@ BAUDBOT_SRC="${BAUDBOT_SRC:-$(cd "$(dirname "$0")/.." && pwd)}" BAUDBOT_HOME="${BAUDBOT_HOME:-/home/baudbot_agent}" AGENT_USER="baudbot_agent" DRY_RUN=0 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# shellcheck source=bin/lib/json-common.sh +source "$SCRIPT_DIR/lib/json-common.sh" # Helper: run a command as baudbot_agent as_agent() { @@ -385,9 +389,9 @@ if [ "$DRY_RUN" -eq 0 ]; then GIT_SHA_SHORT=$(cd "$BAUDBOT_SRC" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_BRANCH=$(cd "$BAUDBOT_SRC" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") elif [ -f "$RELEASE_META_FILE" ]; then - GIT_SHA=$(grep '"sha"' "$RELEASE_META_FILE" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true) - GIT_SHA_SHORT=$(grep '"short"' "$RELEASE_META_FILE" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true) - GIT_BRANCH=$(grep '"branch"' "$RELEASE_META_FILE" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true) + GIT_SHA="$(json_get_string_or_empty "$RELEASE_META_FILE" "sha")" + GIT_SHA_SHORT="$(json_get_string_or_empty "$RELEASE_META_FILE" "short")" + GIT_BRANCH="$(json_get_string_or_empty "$RELEASE_META_FILE" "branch")" fi [ -n "$GIT_SHA" ] || GIT_SHA="unknown" diff --git a/bin/doctor.sh b/bin/doctor.sh index 89d4b0b..6356e7b 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -71,6 +71,12 @@ else fail "varlock not found" fi +if command -v jq &>/dev/null; then + pass "jq is installed" +else + fail "jq not found (required for shell JSON parsing)" +fi + if command -v docker &>/dev/null; then pass "docker is available" else diff --git a/bin/lib/json-common.sh b/bin/lib/json-common.sh new file mode 100644 index 0000000..c174195 --- /dev/null +++ b/bin/lib/json-common.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Shared JSON parsing helpers for shell scripts. +# +# jq is a required runtime dependency. +# +# Return codes: +# 0 => key found and value printed +# 1 => JSON parsed, but key missing/non-string +# 2 => JSON/file/tool error (including missing jq) + +_json_filter='if (type == "object") and has($k) and (.[$k] | type == "string") then .[$k] else empty end' + +json_require_jq() { + command -v jq >/dev/null 2>&1 +} + +json_get_string() { + local file="$1" + local key="$2" + + [ -n "$file" ] || return 2 + [ -n "$key" ] || return 2 + [ -r "$file" ] || return 2 + json_require_jq || return 2 + + jq -er --arg k "$key" "$_json_filter" "$file" 2>/dev/null + case "$?" in + 0) return 0 ;; + 1) return 1 ;; + *) return 2 ;; + esac +} + +json_get_string_stdin() { + local key="$1" + + [ -n "$key" ] || return 2 + json_require_jq || return 2 + + jq -er --arg k "$key" "$_json_filter" 2>/dev/null + case "$?" in + 0) return 0 ;; + 1) return 1 ;; + *) return 2 ;; + esac +} + +json_get_string_or_empty() { + local file="$1" + local key="$2" + + json_get_string "$file" "$key" 2>/dev/null || true +} diff --git a/bin/lib/json-common.test.sh b/bin/lib/json-common.test.sh new file mode 100644 index 0000000..829ae00 --- /dev/null +++ b/bin/lib/json-common.test.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Tests for bin/lib/json-common.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=bin/lib/json-common.sh +source "$SCRIPT_DIR/json-common.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + shift + local out + + TOTAL=$((TOTAL + 1)) + printf " %-45s " "$name" + + out="$(mktemp /tmp/baudbot-json-common-test-output.XXXXXX)" + if "$@" >"$out" 2>&1; then + echo "✓" + PASSED=$((PASSED + 1)) + else + echo "✗ FAILED" + tail -40 "$out" | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi + rm -f "$out" +} + +test_parses_string_key_with_whitespace_variations() { + ( + set -euo pipefail + local tmp file value + tmp="$(mktemp -d /tmp/baudbot-json-common-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + file="$tmp/meta.json" + cat > "$file" <<'EOF' +{ + "sha" : "abc123", + "short": "abc123", + "nested": { "x": 1 } +} +EOF + + value="$(json_get_string "$file" "sha")" + [ "$value" = "abc123" ] + ) +} + +test_missing_key_returns_nonzero() { + ( + set -euo pipefail + local tmp file + tmp="$(mktemp -d /tmp/baudbot-json-common-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + file="$tmp/meta.json" + printf '{"sha":"abc123"}\n' > "$file" + + if json_get_string "$file" "branch" >/dev/null 2>&1; then + return 1 + fi + + [ -z "$(json_get_string_or_empty "$file" "branch")" ] + ) +} + +test_malformed_json_returns_nonzero() { + ( + set -euo pipefail + local tmp file + tmp="$(mktemp -d /tmp/baudbot-json-common-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + file="$tmp/bad.json" + printf '{"sha": "abc123"\n' > "$file" + + if json_get_string "$file" "sha" >/dev/null 2>&1; then + return 1 + fi + ) +} + +test_stdin_parser_handles_whitespace() { + ( + set -euo pipefail + local value + + value="$(printf '{\n "deployed_at" : "2026-02-21T20:00:00Z"\n}\n' | json_get_string_stdin "deployed_at")" + [ "$value" = "2026-02-21T20:00:00Z" ] + ) +} + +echo "=== json-common tests ===" +echo "" + +run_test "parses string key with whitespace" test_parses_string_key_with_whitespace_variations +run_test "missing key returns nonzero" test_missing_key_returns_nonzero +run_test "malformed json returns nonzero" test_malformed_json_returns_nonzero +run_test "stdin parser handles whitespace" test_stdin_parser_handles_whitespace + +echo "" +echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/bin/rollback-release.sh b/bin/rollback-release.sh index 2f8bad6..8938044 100755 --- a/bin/rollback-release.sh +++ b/bin/rollback-release.sh @@ -41,6 +41,8 @@ EOF # shellcheck source=bin/lib/release-common.sh source "$SCRIPT_DIR/lib/release-common.sh" +# shellcheck source=bin/lib/json-common.sh +source "$SCRIPT_DIR/lib/json-common.sh" TARGET_SPEC="${1:-previous}" if [ "$#" -gt 0 ]; then @@ -159,10 +161,10 @@ run_restart_and_health() { local expected_sha local deployed_sha - expected_sha=$(grep '"sha"' "$TARGET_RELEASE/baudbot-release.json" 2>/dev/null | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true) + expected_sha="$(json_get_string_or_empty "$TARGET_RELEASE/baudbot-release.json" "sha")" [ -n "$expected_sha" ] || expected_sha="$(basename "$TARGET_RELEASE")" - deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "grep '\"sha\"' '$version_file' 2>/dev/null | head -1 | sed 's/.*\"sha\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/'")" + deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)" if [ -z "$deployed_sha" ]; then die "deployed version file missing or unreadable: $version_file" diff --git a/bin/security-audit.sh b/bin/security-audit.sh index 3da4169..6ffdb3a 100755 --- a/bin/security-audit.sh +++ b/bin/security-audit.sh @@ -14,6 +14,11 @@ set -euo pipefail BAUDBOT_HOME="${BAUDBOT_HOME:-/home/baudbot_agent}" # Source repo — auto-detect from this script's location, or use env override BAUDBOT_SRC="${BAUDBOT_SRC:-$(cd "$(dirname "$0")/.." && pwd)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# shellcheck source=bin/lib/json-common.sh +source "$SCRIPT_DIR/lib/json-common.sh" + DEEP=0 FIX=0 for arg in "$@"; do @@ -224,8 +229,10 @@ VERSION_FILE="$BAUDBOT_HOME/.pi/agent/baudbot-version.json" MANIFEST_FILE="$BAUDBOT_HOME/.pi/agent/baudbot-manifest.json" if [ -f "$VERSION_FILE" ]; then - deploy_sha=$(grep '"short"' "$VERSION_FILE" 2>/dev/null | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "?") - deploy_ts=$(grep '"deployed_at"' "$VERSION_FILE" 2>/dev/null | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "?") + deploy_sha="$(json_get_string_or_empty "$VERSION_FILE" "short")" + deploy_ts="$(json_get_string_or_empty "$VERSION_FILE" "deployed_at")" + [ -n "$deploy_sha" ] || deploy_sha="?" + [ -n "$deploy_ts" ] || deploy_ts="?" ok "Deployed version: $deploy_sha ($deploy_ts)" else finding "WARN" "No version stamp found — run deploy.sh" "" diff --git a/bin/update-release.sh b/bin/update-release.sh index cbc6eab..360a21a 100755 --- a/bin/update-release.sh +++ b/bin/update-release.sh @@ -58,6 +58,8 @@ die() { # shellcheck source=bin/lib/release-common.sh source "$SCRIPT_DIR/lib/release-common.sh" +# shellcheck source=bin/lib/json-common.sh +source "$SCRIPT_DIR/lib/json-common.sh" cleanup() { if [ -n "$CHECKOUT_DIR" ] && [ -d "$CHECKOUT_DIR" ]; then @@ -305,7 +307,7 @@ run_restart_and_health() { local version_file="$BAUDBOT_AGENT_HOME/.pi/agent/baudbot-version.json" local deployed_sha - deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "grep '\"sha\"' '$version_file' 2>/dev/null | head -1 | sed 's/.*\"sha\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/'")" + deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)" if [ -z "$deployed_sha" ]; then die "deployed version file missing or unreadable: $version_file" diff --git a/install.sh b/install.sh index 1ac0eae..91a4260 100755 --- a/install.sh +++ b/install.sh @@ -163,7 +163,7 @@ install_prereqs_ubuntu() { for attempt in $(seq 1 5); do if DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 update -qq \ - && DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 install -y -qq git curl tmux iptables docker.io gh sudo 2>&1 | tail -3; then + && DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 install -y -qq git curl tmux iptables docker.io gh jq sudo 2>&1 | tail -3; then return 0 fi @@ -179,10 +179,10 @@ install_prereqs_ubuntu() { } install_prereqs_arch() { - pacman -Syu --noconfirm --needed git curl tmux iptables docker github-cli sudo 2>&1 | tail -5 + pacman -Syu --noconfirm --needed git curl tmux iptables docker github-cli jq sudo 2>&1 | tail -5 } -info "Installing: git, curl, tmux, iptables, docker, gh, sudo" +info "Installing: git, curl, tmux, iptables, docker, gh, jq, sudo" "install_prereqs_$DISTRO" info "Prerequisites installed" diff --git a/test/shell-scripts.test.mjs b/test/shell-scripts.test.mjs index be571da..b5f04b3 100644 --- a/test/shell-scripts.test.mjs +++ b/test/shell-scripts.test.mjs @@ -31,4 +31,8 @@ describe("shell script test suites", () => { expect(() => runScript("bin/env.test.sh")).not.toThrow(); }); + it("json helper", () => { + expect(() => runScript("bin/lib/json-common.test.sh")).not.toThrow(); + }); + });