diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a376ff1..e20f992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: brew install shellcheck - name: shellcheck - run: shellcheck -S warning install.sh qbraid-code statusline.sh tests/statusline.sh + run: shellcheck -S warning install.sh qbraid-code statusline.sh tests/statusline.sh tests/extractors.sh - name: syntax run: | @@ -29,6 +29,9 @@ jobs: - name: statusline tests run: tests/statusline.sh + - name: extractor tests + run: tests/extractors.sh + - name: installer --help does not touch the machine run: bash install.sh --help diff --git a/install.sh b/install.sh index 53f5a72..2b8ec20 100755 --- a/install.sh +++ b/install.sh @@ -158,8 +158,22 @@ fi # ------------------------------------------------------------- 3. credential -json_str() { # json_str - printf '%s' "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//; s/\"$//" +# `set -o pipefail` is on, so a grep that matches NOTHING makes the whole +# pipeline non-zero, and `set -e` then kills the installer with no message at +# all. An absent field is a normal outcome here (an error body has no `name`), +# not an error, so pipefail is disabled inside the extracting subshell. +json_str() { # json_str -> the value, or empty + ( + set +o pipefail + printf '%s' "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//; s/\"$//" + ) +} + +json_num() { # json_num -> the value, or empty + ( + set +o pipefail + printf '%s' "$1" | grep -o "\"$2\":-\?[0-9.]*" | head -1 | sed 's/.*://' + ) } HTTP_STATUS="" @@ -167,12 +181,17 @@ API_BODY="" # Sets API_BODY and HTTP_STATUS in the CALLER (000 = could not connect). # It must not print the body: `x=$(api_get ...)` would run this in a subshell # and the status would never make it back, so every failure read as "rejected". -api_get() { # api_get - local url="$1" key="$2" tmp +api_get() { # api_get [org-id] + local url="$1" key="$2" org="${3:-}" tmp API_BODY="" tmp=$(mktemp) || { HTTP_STATUS="000"; return 0; } - HTTP_STATUS=$(curl -sS -m 25 -o "$tmp" -w '%{http_code}' "$url" -H "X-API-Key: $key" 2>/dev/null) \ - || HTTP_STATUS="000" + if [ -n "$org" ]; then + HTTP_STATUS=$(curl -sS -m 25 -o "$tmp" -w '%{http_code}' "$url" \ + -H "X-API-Key: $key" -H "X-Organization-Id: $org" 2>/dev/null) || HTTP_STATUS="000" + else + HTTP_STATUS=$(curl -sS -m 25 -o "$tmp" -w '%{http_code}' "$url" \ + -H "X-API-Key: $key" 2>/dev/null) || HTTP_STATUS="000" + fi API_BODY=$(cat "$tmp") rm -f "$tmp" } @@ -265,7 +284,7 @@ esac # ------------------------------------------------------- 4. organization check ORG_ID=$(json_str "$BALANCE" organizationId) -CREDITS_RAW=$(printf '%s' "$BALANCE" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') +CREDITS_RAW=$(json_num "$BALANCE" qbraidCredits) if [ -n "$CREDITS_RAW" ]; then CREDITS=$(awk -v c="$CREDITS_RAW" 'BEGIN { printf "%.0f", c }' 2>/dev/null) || CREDITS="$CREDITS_RAW" else @@ -279,7 +298,7 @@ fi # alongside so a bad parse cannot quietly point someone at the wrong org. ORG_NAME="" if [ -n "$ORG_ID" ]; then - api_get "$API_BASE/organizations/current" "$API_KEY" + api_get "$API_BASE/organizations/current" "$API_KEY" "$ORG_ID" ORG_DATA=$(printf '%s' "$API_BODY" | sed 's/.*"data"[[:space:]]*:[[:space:]]*{//') ORG_NAME=$(json_str "$ORG_DATA" name) fi @@ -314,7 +333,7 @@ MODEL="${QBRAID_CODE_MODEL:-}" if [ -z "$MODEL" ]; then # The list is fetched live so new gateway models appear without a release here. api_get "$GATEWAY_URL/v1/models" "$API_KEY" - MODEL_IDS=$(printf '%s' "$API_BODY" | grep -o '"id":"[^"]*"' | sed 's/"id":"//; s/"$//') + MODEL_IDS=$(set +o pipefail; printf '%s' "$API_BODY" | grep -o '"id":"[^"]*"' | sed 's/"id":"//; s/"$//') if [ -z "$MODEL_IDS" ]; then warn "could not list models — defaulting to claude-sonnet-4-6" MODEL="claude-sonnet-4-6" diff --git a/qbraid-code b/qbraid-code index 649c5a6..7df53d0 100755 --- a/qbraid-code +++ b/qbraid-code @@ -55,7 +55,8 @@ case "${1:-}" in BODY=$(cat "$TMP"); rm -f "$TMP" case "$STATUS" in 200) - RAW=$(printf '%s' "$BODY" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') + # pipefail + set -e would kill --doctor silently if the field is absent. + RAW=$(set +o pipefail; printf '%s' "$BODY" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') CREDITS=$(awk -v c="$RAW" 'BEGIN { printf "%.0f", c }' 2>/dev/null) || CREDITS="$RAW" echo "key: valid" echo "credits: ${CREDITS:-unknown}" diff --git a/tests/extractors.sh b/tests/extractors.sh new file mode 100755 index 0000000..b6719f0 --- /dev/null +++ b/tests/extractors.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Regression test for the silent-exit bug found by the first real install. +# +# install.sh runs under `set -euo pipefail`. Its JSON extractors are grep +# pipelines, and a grep that matches NOTHING makes the pipeline non-zero — +# so an absent field (a normal outcome, e.g. an error body with no `name`) +# killed the installer with no message at all, right after "key accepted". +# +# This test extracts the REAL function definitions from install.sh — not a +# copy that could drift — and calls them on bodies missing the field, under +# the same shell options install.sh uses. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +pass=0; fail=0 + +# Pull each function's source out of install.sh by its opening line. +extract_fn() { # extract_fn + awk -v fn="$1" ' + $0 ~ "^"fn"\\(\\) \\{" { inside = 1 } + inside { print } + inside && /^\}$/ { exit } + ' install.sh +} + +JSON_STR_SRC=$(extract_fn json_str) +JSON_NUM_SRC=$(extract_fn json_num) +if [ -z "$JSON_STR_SRC" ] || [ -z "$JSON_NUM_SRC" ]; then + echo " FAIL could not extract json_str/json_num from install.sh" + exit 1 +fi + +check() { # check