Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 74 additions & 10 deletions scripts/auto_update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
VERBOSE="${VERBOSE:-0}"
SKIP_SYSTEM="${SKIP_SYSTEM:-0}"
SCOPE="${SCOPE:-}" # Can be: system, user, project, all, or auto-detect if empty
SLOW_SECS="${SLOW_SECS:-10}" # announce the actual command after this many seconds
TAIL_LINES="${TAIL_LINES:-5}" # rolling output lines shown for slow commands (tty only)

log() {
printf "[auto-update] %s\n" "$*" >&2
Expand All @@ -23,20 +25,81 @@
fi
}

# Erase the rolling tail region (count lines) from the terminal.
_clear_tail() {
local count="$1"
[ "$count" -gt 0 ] || return 0

Check failure on line 31 in scripts/auto_update.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9s_TKNSQfmdtC0E86E&open=AZ9s_TKNSQfmdtC0E86E&pullRequest=108
printf '\033[%dA' "$count" >&2
for _ in $(seq 1 "$count"); do
printf '\033[2K\n' >&2
done
printf '\033[%dA' "$count" >&2
}

# Redraw the last TAIL_LINES lines of file $1, replacing $2 previously drawn
# lines. Echoes the new drawn-line count.
_draw_tail() {
local out="$1" drawn="$2"
local width
width="$(tput cols 2>/dev/null || echo "${COLUMNS:-120}")"
_clear_tail "$drawn"
local lines
lines="$(tail -n "$TAIL_LINES" "$out" 2>/dev/null | cut -c1-$((width - 6)) || true)"
if [ -z "$lines" ]; then

Check failure on line 48 in scripts/auto_update.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9s_TKNSQfmdtC0E86F&open=AZ9s_TKNSQfmdtC0E86F&pullRequest=108
echo 0
return 0
fi
printf '%s\n' "$lines" | sed 's/^/ │ /' >&2
printf '%s\n' "$lines" | wc -l
}

# Run a package-manager command: stdin detached (a hidden interactive prompt
# must fail fast, not hang on the terminal — a swallowed composer prompt once
# blocked upgrade-managed for hours), the real command line announced after
# SLOW_SECS, a rolling TAIL_LINES-line output tail on ttys, and the last
# output lines surfaced when the command fails. Failures stay non-fatal.
run_cmd() {
local desc="$1"
shift
if [ "$DRY_RUN" = "1" ]; then
log "DRY-RUN: $desc"
log " Command: $*"
else
log "$desc"
if [ "$VERBOSE" = "1" ]; then
"$@"
else
"$@" >/dev/null 2>&1 || true
return 0
fi

log "$desc"
if [ "$VERBOSE" = "1" ]; then
"$@" </dev/null || true
return 0
fi

local out
out="$(mktemp)"
"$@" </dev/null >"$out" 2>&1 &
local pid=$!

local elapsed=0 drawn=0
while kill -0 "$pid" 2>/dev/null; do
sleep 1
elapsed=$((elapsed + 1))
if [ "$elapsed" -eq "$SLOW_SECS" ]; then

Check failure on line 85 in scripts/auto_update.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9s_TKNSQfmdtC0E86G&open=AZ9s_TKNSQfmdtC0E86G&pullRequest=108
log " ↳ still running (${elapsed}s): $*"
fi
if [ "$elapsed" -ge "$SLOW_SECS" ] && [ -t 2 ]; then

Check failure on line 88 in scripts/auto_update.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9s_TKNSQfmdtC0E86H&open=AZ9s_TKNSQfmdtC0E86H&pullRequest=108

Check failure on line 88 in scripts/auto_update.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9s_TKNSQfmdtC0E86I&open=AZ9s_TKNSQfmdtC0E86I&pullRequest=108
drawn="$(_draw_tail "$out" "$drawn")"
fi
done

local rc=0
wait "$pid" || rc=$?
_clear_tail "$drawn" 2>/dev/null || true

if [ "$rc" -ne 0 ]; then

Check failure on line 97 in scripts/auto_update.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9s_TKNSQfmdtC0E86J&open=AZ9s_TKNSQfmdtC0E86J&pullRequest=108
log " ✗ $desc failed (exit $rc); last output:"
tail -n 20 "$out" | sed 's/^/ /' >&2
fi
rm -f "$out"
return 0
}

# ============================================================================
Expand Down Expand Up @@ -415,8 +478,8 @@
if ! detect_composer; then return; fi
log "Composer: Updating"

run_cmd "Composer: Self-update" composer self-update
run_cmd "Composer: Update global packages" composer global update
run_cmd "Composer: Self-update" composer self-update --no-interaction
run_cmd "Composer: Update global packages" composer global update --no-interaction

log "Composer: Complete"
}
Expand Down Expand Up @@ -543,7 +606,8 @@
go)
location="$(command -v go 2>/dev/null || echo "N/A")"
version="$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//' || echo "unknown")"
local gobin="$(go env GOBIN 2>/dev/null || echo "$(go env GOPATH 2>/dev/null)/bin")"
local gobin
gobin="$(go env GOBIN 2>/dev/null || echo "$(go env GOPATH 2>/dev/null)/bin")"
pkg_count="$([ -d "$gobin" ] && ls -1 "$gobin" 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
gup)
Expand Down Expand Up @@ -926,7 +990,7 @@

# Composer
if [ -f "./composer.json" ] && command -v composer >/dev/null 2>&1 && confirm_project_update "composer" "./composer.json"; then
run_cmd "Composer: Update project dependencies" composer update
run_cmd "Composer: Update project dependencies" composer update --no-interaction
fi
fi

Expand Down
147 changes: 147 additions & 0 deletions tests/test_auto_update_run_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Tests for auto_update.sh run_cmd: hidden-prompt protection, slow-command
notice, and failure output.

`composer global update` hung for hours inside `make upgrade-managed`: run_cmd
discarded stdout/stderr but left stdin attached to the terminal, so composer's
interactive prompt was invisible and waited forever. run_cmd must detach stdin,
announce long-running commands, and surface output of failed commands.
"""

from __future__ import annotations

import subprocess
import sys
import time
from pathlib import Path

import pytest

skip_on_windows = pytest.mark.skipif(
sys.platform == "win32", reason="Shell script tests require POSIX shell"
)

SCRIPT = Path(__file__).parent.parent / "scripts" / "auto_update.sh"


def _run_cmd(bash_code: str, timeout: int = 15) -> subprocess.CompletedProcess:
"""Extract log()/run_cmd() from auto_update.sh (sourcing it would execute
its CLI dispatch) and run bash_code against them."""
full_code = f"""
set -euo pipefail
DRY_RUN=0
VERBOSE=0
SLOW_SECS="${{SLOW_SECS:-10}}"
TAIL_LINES="${{TAIL_LINES:-5}}"
eval "$(sed -n '/^log()/,/^}}/p' "{SCRIPT}")"
eval "$(sed -n '/^run_cmd()/,/^}}/p' "{SCRIPT}")"
{bash_code}
"""
return subprocess.run(
["bash", "-c", full_code],
capture_output=True, text=True, timeout=timeout,
stdin=subprocess.PIPE, # a held-open stdin, like an attached terminal
)


@skip_on_windows
class TestRunCmdStdin:
def _assert_completes_with_held_stdin(self, bash_code: str) -> None:
"""Run bash_code with a stdin pipe that stays OPEN (like an attached
terminal). A run_cmd that leaks stdin makes `read` block forever."""
full_code = f"""
set -euo pipefail
DRY_RUN=0
VERBOSE=0
SLOW_SECS=10
TAIL_LINES=5
eval "$(sed -n '/^log()/,/^}}/p' "{SCRIPT}")"
eval "$(sed -n '/^run_cmd()/,/^}}/p' "{SCRIPT}")"
{bash_code}
"""
proc = subprocess.Popen(
["bash", "-c", full_code],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
pytest.fail("run_cmd blocked on the caller's stdin (composer hang)")
finally:
if proc.stdin:
proc.stdin.close()
assert proc.returncode == 0

def test_command_reading_stdin_does_not_hang(self):
"""A hidden interactive prompt must hit EOF instead of blocking on the
caller's terminal (the composer hang)."""
self._assert_completes_with_held_stdin(
'run_cmd "Prompting tool" bash -c \'read -r answer; echo done\''
)

def test_verbose_mode_also_detaches_stdin(self):
self._assert_completes_with_held_stdin(
'VERBOSE=1\n'
'run_cmd "Prompting tool" bash -c \'read -r answer\' || true'
)


@skip_on_windows
class TestRunCmdSlowNotice:
def test_slow_command_prints_actual_command(self):
result = _run_cmd("""
SLOW_SECS=1
run_cmd "Slow step" sleep 3
""")
assert "sleep 3" in result.stderr, (
f"slow-command notice must show the real command: {result.stderr}"
)

def test_fast_command_stays_quiet(self):
result = _run_cmd("""
SLOW_SECS=2
run_cmd "Fast step" true
""")
assert "running" not in result.stderr.lower().replace(
"[auto-update]", ""
).replace("fast step", "")
assert "true" not in [
line.strip() for line in result.stderr.splitlines()
]


@skip_on_windows
class TestRunCmdFailureOutput:
def test_failed_command_surfaces_output_and_exit_code(self):
result = _run_cmd("""
run_cmd "Broken step" bash -c 'echo boom-stdout; echo boom-stderr >&2; exit 3'
echo "SCRIPT_CONTINUED"
""")
# failures stay non-fatal (legacy || true semantics) ...
assert "SCRIPT_CONTINUED" in result.stdout
# ... but are no longer silent
assert "exit 3" in result.stderr
assert "boom-stdout" in result.stderr
assert "boom-stderr" in result.stderr

def test_successful_command_output_stays_quiet(self):
result = _run_cmd("""
run_cmd "Chatty step" bash -c 'echo lots-of-noise'
""")
assert "lots-of-noise" not in result.stderr
assert "lots-of-noise" not in result.stdout


@skip_on_windows
class TestRunCmdDryRun:
def test_dry_run_prints_command_without_running(self):
result = _run_cmd("""
DRY_RUN=1
marker="$(mktemp -u)"
run_cmd "Dry step" touch "$marker"
[ ! -e "$marker" ] && echo "NOT_EXECUTED"
""")
assert "NOT_EXECUTED" in result.stdout
assert "touch" in result.stderr
Loading