fix(install.sh): arrow-style output with child noise collapsed on a terminal
The pm-era installer printed "[hermes]" lines between raw git, uv and pm output. Restore the pre-pm installer's look (→ ✓ ⚠ ✗, banner on the full ladder) and route every child command through run_logged: on a terminal it shows one status line rewritten with the command's newest output line (git clone phases via --progress), appends everything to $HERMES_HOME/logs/install.log, and on failure prints the last 20 lines plus the log path. CI, --verbose, HERMES_INSTALL_VERBOSE and a non-terminal stdout (the Hermes-Setup --json driver, E2E transcripts) keep the full stream, so their parsers see what they saw before. Errors stay on stderr.
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
# --stage NAME [--json] run one stage
|
||||
# --non-interactive skip stages that need input
|
||||
# --include-desktop build the desktop app too (products stage)
|
||||
# --verbose stream every child command's output (the default
|
||||
# off a terminal and in CI)
|
||||
set -u
|
||||
|
||||
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
|
||||
@@ -28,6 +30,7 @@ WANT_MANIFEST=false
|
||||
JSON=false
|
||||
NON_INTERACTIVE=false
|
||||
INCLUDE_DESKTOP=false
|
||||
VERBOSE=false
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -53,11 +56,12 @@ while [ $# -gt 0 ]; do
|
||||
echo "$1 no longer skips the browser install; pm manages browser dependencies. Remove this flag and use --non-interactive to skip setup prompts." >&2
|
||||
exit 1 ;;
|
||||
--include-desktop|-IncludeDesktop) INCLUDE_DESKTOP=true; shift ;;
|
||||
--verbose|-Verbose) VERBOSE=true; shift ;;
|
||||
-h|--help)
|
||||
echo "Usage: install.sh [--branch NAME] [--commit SHA] [--dir PATH]"
|
||||
echo " [--hermes-home PATH]"
|
||||
echo " [--manifest] [--stage NAME] [--json]"
|
||||
echo " [--non-interactive] [--include-desktop]"
|
||||
echo " [--non-interactive] [--include-desktop] [--verbose]"
|
||||
exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -66,8 +70,92 @@ done
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HERMES_HOME/hermes-agent}"
|
||||
export HERMES_HOME
|
||||
|
||||
log() { printf "\033[1;34m[hermes]\033[0m %s\n" "$1"; }
|
||||
fail() { STAGE_REASON="$1"; printf "\033[1;31m[hermes]\033[0m %s\n" "$1" >&2; exit 1; }
|
||||
INSTALL_LOG="$HERMES_HOME/logs/install.log"
|
||||
|
||||
# Same glyphs as the pre-pm installer. Colour only on a terminal, so CI
|
||||
# transcripts and the Hermes-Setup driver read plain text.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_RED=$'\033[0;31m' C_GREEN=$'\033[0;32m' C_YELLOW=$'\033[0;33m'
|
||||
C_CYAN=$'\033[0;36m' C_MAGENTA=$'\033[0;35m' C_BOLD=$'\033[1m'
|
||||
C_DIM=$'\033[2m' C_NC=$'\033[0m'
|
||||
else
|
||||
C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_MAGENTA="" C_BOLD="" C_DIM="" C_NC=""
|
||||
fi
|
||||
|
||||
log() { printf '%s→%s %s\n' "$C_CYAN" "$C_NC" "$1"; }
|
||||
log_success() { printf '%s✓%s %s\n' "$C_GREEN" "$C_NC" "$1"; }
|
||||
log_warn() { printf '%s⚠%s %s\n' "$C_YELLOW" "$C_NC" "$1"; }
|
||||
log_error() { printf '%s✗%s %s\n' "$C_RED" "$C_NC" "$1" >&2; }
|
||||
fail() { STAGE_REASON="$1"; log_error "$1"; exit 1; }
|
||||
|
||||
print_banner() {
|
||||
printf '\n%s%s' "$C_MAGENTA" "$C_BOLD"
|
||||
printf '%s\n' "┌─────────────────────────────────────────────────────────┐"
|
||||
printf '%s\n' "│ ☤ Hermes Agent Installer │"
|
||||
printf '%s\n' "├─────────────────────────────────────────────────────────┤"
|
||||
printf '%s\n' "│ An open source AI agent by Nous Research. │"
|
||||
printf '%s\n' "└─────────────────────────────────────────────────────────┘"
|
||||
printf '%s\n' "$C_NC"
|
||||
}
|
||||
|
||||
# Interactive runs collapse child-process output (git, uv, pm, the builds)
|
||||
# into one status line. CI, --verbose and a non-terminal stdout -- the
|
||||
# Hermes-Setup --json driver, E2E transcripts -- keep the full stream those
|
||||
# readers parse.
|
||||
quiet_output() {
|
||||
[ "$VERBOSE" = true ] && return 1
|
||||
if [ -n "${CI:-}" ] || [ -n "${GITHUB_ACTIONS:-}" ] || [ -n "${HERMES_INSTALL_VERBOSE:-}" ]; then
|
||||
return 1
|
||||
fi
|
||||
[ -t 1 ]
|
||||
}
|
||||
|
||||
status_line() {
|
||||
local text=" $1" width=$(( $2 - 1 ))
|
||||
[ "${#text}" -le "$width" ] || text="${text:0:width}"
|
||||
printf '\r\033[K%s%s%s' "$C_DIM" "$text" "$C_NC"
|
||||
}
|
||||
|
||||
# run_logged [--may-fail] LABEL CMD...: run CMD. Quiet mode shows LABEL with
|
||||
# CMD's latest output line rewritten in place, appends everything to
|
||||
# $INSTALL_LOG and, on failure, prints the tail and the log path; --may-fail
|
||||
# is for probes whose failure the caller handles (no report). Otherwise LABEL
|
||||
# is logged and the output streams untouched. Returns CMD's exit status.
|
||||
run_logged() {
|
||||
local may_fail=false
|
||||
if [ "$1" = --may-fail ]; then may_fail=true; shift; fi
|
||||
local label="$1"; shift
|
||||
if ! quiet_output || ! { mkdir -p "${INSTALL_LOG%/*}" && : >> "$INSTALL_LOG"; } 2>/dev/null; then
|
||||
log "$label"
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
local start cols rc line shown
|
||||
start=$(( $(wc -l < "$INSTALL_LOG") + 1 ))
|
||||
cols="$(tput cols 2>/dev/null)" || cols=80
|
||||
[ "${cols:-0}" -gt 20 ] 2>/dev/null || cols=80
|
||||
printf '==> %s (%s)\n' "$label" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$INSTALL_LOG"
|
||||
status_line "$label" "$cols"
|
||||
# stdin closed: under `curl | bash` it is the script itself, and nothing
|
||||
# run here may prompt behind a status line.
|
||||
"$@" </dev/null 2>&1 | {
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line="${line%$'\r'}"
|
||||
printf '%s\n' "$line" >&3
|
||||
# git and uv redraw progress with bare CRs; show the newest.
|
||||
shown="${line##*$'\r'}"
|
||||
[ -z "$shown" ] || status_line "$label: $shown" "$cols"
|
||||
done
|
||||
} 3>>"$INSTALL_LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
printf '\r\033[K'
|
||||
if [ "$rc" -ne 0 ] && [ "$may_fail" = false ]; then
|
||||
log_error "$label failed (exit $rc). Last output:"
|
||||
tail -n +"$(( start + 1 ))" "$INSTALL_LOG" | tail -n 20 | sed 's/^/ /' >&2
|
||||
printf ' full log: %s\n' "$INSTALL_LOG" >&2
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# --- BEGIN GENERATED: bootstrap pins (scripts/gen-bootstrap-pins.py) ---
|
||||
# Derived from pm/lock.json. DO NOT EDIT BY HAND:
|
||||
@@ -154,7 +242,7 @@ ensure_uv() {
|
||||
UV_CMD="$_path_uv"
|
||||
return 0
|
||||
fi
|
||||
log "uv on PATH (${_path_version:-does not run}) is older than the pinned $UV_PIN_VERSION; staging the pin"
|
||||
log_warn "uv on PATH (${_path_version:-does not run}) is older than the pinned $UV_PIN_VERSION; staging the pin"
|
||||
fi
|
||||
local _target
|
||||
if ! _target="$(uv_bootstrap_target)"; then
|
||||
@@ -167,7 +255,7 @@ ensure_uv() {
|
||||
local _entry="$_store/uv-$UV_PIN_VERSION-$_target"
|
||||
UV_CMD="$_entry/uv"
|
||||
if [ ! -x "$UV_CMD" ]; then
|
||||
log "staging pinned uv $UV_PIN_VERSION ($_target) into the pm store"
|
||||
log "Downloading uv $UV_PIN_VERSION ($_target)"
|
||||
local _tmp
|
||||
# no-tmp: ok — last-resort fallback when mktemp itself is missing
|
||||
_tmp="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-uv-bootstrap.$$")"
|
||||
@@ -220,7 +308,7 @@ ensure_uv() {
|
||||
if ! "$UV_CMD" --version >/dev/null 2>&1; then
|
||||
fail "pinned uv staged but does not run on this host"
|
||||
fi
|
||||
log "uv ready ($("$UV_CMD" --version 2>/dev/null))"
|
||||
log_success "uv ready ($("$UV_CMD" --version 2>/dev/null))"
|
||||
}
|
||||
|
||||
check_platform() {
|
||||
@@ -321,7 +409,7 @@ emit_manifest() {
|
||||
stage_prerequisites() {
|
||||
command -v git >/dev/null 2>&1 || fail "git is required. Install it with your system package manager."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required. Install it with your system package manager."
|
||||
log "prerequisites ok (git, curl)"
|
||||
log_success "prerequisites ok (git, curl)"
|
||||
}
|
||||
|
||||
stage_repository() {
|
||||
@@ -332,17 +420,17 @@ stage_repository() {
|
||||
if [ -d "$INSTALL_DIR/.git" ] && ! git -C "$INSTALL_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||
local broken
|
||||
broken="${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)"
|
||||
log "$INSTALL_DIR has no commits (interrupted clone); moving it aside to $broken"
|
||||
log_warn "$INSTALL_DIR has no commits (interrupted clone); moving it aside to $broken"
|
||||
mv "$INSTALL_DIR" "$broken" || fail "cannot move $INSTALL_DIR aside"
|
||||
fi
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
log "updating $INSTALL_DIR"
|
||||
log "Updating $INSTALL_DIR ($BRANCH)"
|
||||
# An explicit HERMES_REPO_URL names the source for reruns too, not
|
||||
# just the first clone.
|
||||
if [ -n "${HERMES_REPO_URL:-}" ]; then
|
||||
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" || fail "cannot point origin at $REPO_URL"
|
||||
fi
|
||||
git -C "$INSTALL_DIR" fetch origin "$BRANCH" || fail "git fetch failed"
|
||||
run_logged "Fetching origin/$BRANCH" git -C "$INSTALL_DIR" fetch origin "$BRANCH" || fail "git fetch failed"
|
||||
local stamp
|
||||
stamp="$(date -u +%Y%m%d-%H%M%S)"
|
||||
# Park local work BEFORE switching branches: checkout refuses a dirty
|
||||
@@ -354,15 +442,17 @@ stage_repository() {
|
||||
# index-level conflict state keeps the working-tree changes for
|
||||
# the stash below (#4735).
|
||||
if [ -n "$(git -C "$INSTALL_DIR" ls-files --unmerged)" ]; then
|
||||
log "clearing unmerged index entries from a previous conflict"
|
||||
log_warn "clearing unmerged index entries from a previous conflict"
|
||||
git -C "$INSTALL_DIR" reset -q || fail "cannot clear the unmerged index in $INSTALL_DIR"
|
||||
fi
|
||||
git -C "$INSTALL_DIR" stash push --include-untracked -m "hermes-install-autostash-$stamp" \
|
||||
run_logged "Stashing local changes" \
|
||||
git -C "$INSTALL_DIR" stash push --include-untracked -m "hermes-install-autostash-$stamp" \
|
||||
|| fail "could not stash local changes in $INSTALL_DIR; commit or move them aside, then rerun"
|
||||
log "local changes stashed as hermes-install-autostash-$stamp"
|
||||
log_warn "local changes stashed as hermes-install-autostash-$stamp"
|
||||
fi
|
||||
git -C "$INSTALL_DIR" checkout "$BRANCH" || fail "git checkout failed"
|
||||
if ! git -C "$INSTALL_DIR" merge --ff-only "origin/$BRANCH"; then
|
||||
run_logged "Checking out $BRANCH" git -C "$INSTALL_DIR" checkout "$BRANCH" || fail "git checkout failed"
|
||||
if ! run_logged --may-fail "Fast-forwarding to origin/$BRANCH" \
|
||||
git -C "$INSTALL_DIR" merge --ff-only "origin/$BRANCH"; then
|
||||
# A release cut off the main line, a force-pushed remote, or the
|
||||
# user's own commits cannot fast-forward. Every stage below reads
|
||||
# files only the new tree has (pm/), so an install left on the old
|
||||
@@ -382,11 +472,12 @@ stage_repository() {
|
||||
rescue_ref="refs/hermes-update-backups/$rescue_kind-$BRANCH-$stamp-$prior"
|
||||
git -C "$INSTALL_DIR" update-ref "$rescue_ref" HEAD \
|
||||
|| fail "cannot back up $dropped local commit(s); refusing to reset"
|
||||
log "$dropped commit(s) not on origin/$BRANCH backed up to $rescue_ref"
|
||||
log_warn "$dropped commit(s) not on origin/$BRANCH backed up to $rescue_ref"
|
||||
log "List them with: git -C \"$INSTALL_DIR\" log origin/$BRANCH..$rescue_ref"
|
||||
fi
|
||||
git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH" || fail "git reset failed"
|
||||
log "not fast-forwardable; reset to origin/$BRANCH"
|
||||
run_logged "Resetting to origin/$BRANCH" git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH" \
|
||||
|| fail "git reset failed"
|
||||
log_warn "not fast-forwardable; reset to origin/$BRANCH"
|
||||
fi
|
||||
else
|
||||
# `mv <clone> <existing dir>` nests the checkout INSIDE it as
|
||||
@@ -399,16 +490,21 @@ stage_repository() {
|
||||
fail "$INSTALL_DIR exists and is not a Hermes git checkout. Move it aside, or install elsewhere with --dir <path>."
|
||||
fi
|
||||
fi
|
||||
log "cloning $REPO_URL ($BRANCH) into $INSTALL_DIR"
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
local staged attempt cloned=false
|
||||
local staged attempt label cloned=false progress=()
|
||||
# Phase lines ("Receiving objects: 42%") feed the status line; git
|
||||
# prints none to a pipe unless asked.
|
||||
if quiet_output; then progress=(--progress); fi
|
||||
staged="$(mktemp -d "$(dirname "$INSTALL_DIR")/.hermes-clone-XXXXXX")" || fail "cannot stage clone"
|
||||
for attempt in 1 2 3; do
|
||||
# Treeless: every commit and release tag (runtime identity is the
|
||||
# nearest reachable release; --commit pins and branch switches
|
||||
# still resolve), trees and blobs fetched on demand, so the
|
||||
# download stays close to a --depth 1 clone.
|
||||
if git clone --filter=tree:0 --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
|
||||
label="Cloning $REPO_URL ($BRANCH) into $INSTALL_DIR"
|
||||
[ "$attempt" = 1 ] || label="$label (attempt $attempt of 3)"
|
||||
if run_logged "$label" git clone ${progress[@]+"${progress[@]}"} \
|
||||
--filter=tree:0 --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
|
||||
cloned=true
|
||||
break
|
||||
fi
|
||||
@@ -418,11 +514,12 @@ stage_repository() {
|
||||
if [ "$cloned" = false ]; then
|
||||
# The checkout step is where throttled downloads die: clone the
|
||||
# graph alone, then retry materializing the tree separately.
|
||||
log "direct clone failed; trying deferred checkout"
|
||||
if git clone --filter=tree:0 --no-checkout \
|
||||
--branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
|
||||
log_warn "direct clone failed; trying deferred checkout"
|
||||
if run_logged "Cloning history" git clone ${progress[@]+"${progress[@]}"} \
|
||||
--filter=tree:0 --no-checkout --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
|
||||
for attempt in 1 2; do
|
||||
if git -C "$staged/tree" reset --hard HEAD; then
|
||||
if run_logged "Checking out files (attempt $attempt of 2)" \
|
||||
git -C "$staged/tree" reset --hard HEAD; then
|
||||
cloned=true
|
||||
break
|
||||
fi
|
||||
@@ -439,6 +536,7 @@ stage_repository() {
|
||||
fail "cannot publish cloned checkout"
|
||||
fi
|
||||
rmdir "$staged"
|
||||
log_success "Hermes Agent cloned"
|
||||
fi
|
||||
if [ -n "$INSTALL_COMMIT" ]; then
|
||||
# A pin must come from the branch being installed: the complete
|
||||
@@ -446,7 +544,8 @@ stage_repository() {
|
||||
# next plain rerun "update" onto a different line.
|
||||
git -C "$INSTALL_DIR" merge-base --is-ancestor "$INSTALL_COMMIT" "origin/$BRANCH" 2>/dev/null \
|
||||
|| fail "commit $INSTALL_COMMIT is not on branch $BRANCH"
|
||||
git -C "$INSTALL_DIR" checkout "$INSTALL_COMMIT" || fail "could not pin commit $INSTALL_COMMIT"
|
||||
run_logged "Pinning $INSTALL_COMMIT" git -C "$INSTALL_DIR" checkout "$INSTALL_COMMIT" \
|
||||
|| fail "could not pin commit $INSTALL_COMMIT"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -454,7 +553,7 @@ stage_venv() {
|
||||
# Keep the installer stage protocol; PM alone creates dependency environments.
|
||||
local boot_py
|
||||
bootstrap_python
|
||||
log "bootstrap Python ready; PM prepares the dependency environment"
|
||||
log_success "bootstrap Python ready; PM prepares the dependency environment"
|
||||
}
|
||||
|
||||
# Tool-only bootstrap: acquire uv and Python before PM's own dependencies exist.
|
||||
@@ -476,7 +575,8 @@ bootstrap_python() {
|
||||
# This interpreter only boots PM; PM still owns the exact runtime pin.
|
||||
if ! boot_py="$(UV_SYSTEM_PYTHON=1 UV_NO_PROJECT=1 "$UV_CMD" python find --managed-python "$_py" 2>/dev/null)" \
|
||||
&& ! boot_py="$("$UV_CMD" python find --system --no-project "$_py" 2>/dev/null)"; then
|
||||
"$UV_CMD" python install --no-bin --no-registry "$_py" || fail "bootstrap Python installation failed"
|
||||
run_logged "Downloading Python $_py" "$UV_CMD" python install --no-bin --no-registry "$_py" \
|
||||
|| fail "bootstrap Python installation failed"
|
||||
boot_py="$(UV_SYSTEM_PYTHON=1 UV_NO_PROJECT=1 "$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed"
|
||||
fi
|
||||
boot_py="${boot_py%$'\r'}"
|
||||
@@ -488,8 +588,10 @@ bootstrap_python() {
|
||||
bootstrap_pm() {
|
||||
local boot_py
|
||||
bootstrap_python
|
||||
log "delegating python + venv + tools to pm (hash-verified via uv.lock)"
|
||||
(cd "$INSTALL_DIR" && "$boot_py" -m pm.cli install) || fail "pm install failed"
|
||||
(cd "$INSTALL_DIR" && run_logged "Installing dependencies (hash-verified via uv.lock)" \
|
||||
"$boot_py" -m pm.cli install) \
|
||||
|| fail "pm install failed"
|
||||
log_success "dependencies installed"
|
||||
}
|
||||
|
||||
stage_python_deps() {
|
||||
@@ -515,7 +617,7 @@ append_shell_path() {
|
||||
fi
|
||||
mkdir -p "$(dirname "$rc")"
|
||||
printf '\n# Hermes Agent command\n%s\n' "$line" >> "$rc" || fail "cannot update PATH in $rc"
|
||||
log "added ~/.local/bin to PATH in $rc"
|
||||
log_success "added ~/.local/bin to PATH in $rc"
|
||||
}
|
||||
|
||||
wire_shell_path() {
|
||||
@@ -555,10 +657,11 @@ stage_products() {
|
||||
if [ "$INCLUDE_DESKTOP" = true ] || desktop_product_present; then
|
||||
args+=(--desktop)
|
||||
fi
|
||||
(cd "$INSTALL_DIR" && "$boot_py" -I -B -X utf8 hermes_cli/source_completion.py "${args[@]}") \
|
||||
(cd "$INSTALL_DIR" && run_logged "Building the hermes command and apps" \
|
||||
"$boot_py" -I -B -X utf8 hermes_cli/source_completion.py "${args[@]}") \
|
||||
|| fail "app products or command publication failed"
|
||||
wire_shell_path
|
||||
log "app products and hermes command ready"
|
||||
log_success "app products and hermes command ready"
|
||||
}
|
||||
|
||||
stage_desktop() {
|
||||
@@ -580,7 +683,7 @@ stage_config() {
|
||||
if [ ! -f "$HERMES_HOME/config.yaml" ] && [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then
|
||||
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
log "config prepared in $HERMES_HOME"
|
||||
log_success "config prepared in $HERMES_HOME"
|
||||
}
|
||||
|
||||
# Interactive stages read the terminal, not stdin: under `curl | bash` stdin
|
||||
@@ -615,7 +718,7 @@ stage_complete() {
|
||||
"$commit" "$BRANCH" "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$INSTALL_DIR/.hermes-bootstrap-complete.tmp"
|
||||
mv -f "$INSTALL_DIR/.hermes-bootstrap-complete.tmp" "$INSTALL_DIR/.hermes-bootstrap-complete"
|
||||
fi
|
||||
log "install complete. Run: hermes"
|
||||
log_success "Hermes Agent install complete. Run: hermes"
|
||||
}
|
||||
|
||||
run_stage() (
|
||||
@@ -668,6 +771,7 @@ if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
|
||||
|
||||
# No --stage: run the whole ladder — the same authoritative list the
|
||||
# manifest prints, so --include-desktop inserts desktop here too.
|
||||
print_banner
|
||||
for s in $(stage_names); do
|
||||
run_stage "$s"
|
||||
rc=$?
|
||||
|
||||
69
tests/scripts/install/test_install_sh_output.py
Normal file
69
tests/scripts/install/test_install_sh_output.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""install.sh child output: one status line on a terminal, the full stream in CI."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.platforms("posix")
|
||||
INSTALL_SH = Path(__file__).resolve().parents[3] / "scripts" / "install.sh"
|
||||
NOISY = "echo noisy-first; echo noisy-second >&2; exit {code}"
|
||||
|
||||
|
||||
def _env(tmp_path: Path, **extra: str) -> dict[str, str]:
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("CI", "GITHUB_ACTIONS", "HERMES_INSTALL_VERBOSE")}
|
||||
env.update(HOME=str(tmp_path), HERMES_HOME=str(tmp_path / "home"), NO_COLOR="1", TERM="dumb", **extra)
|
||||
return env
|
||||
|
||||
|
||||
def _script(code: int) -> str:
|
||||
return (f"source {shlex.quote(INSTALL_SH.as_posix())} --manifest\n"
|
||||
f"run_logged 'Noisy step' sh -c {shlex.quote(NOISY.format(code=code))}\n"
|
||||
"echo \"rc=$?\"\n")
|
||||
|
||||
|
||||
def _on_terminal(tmp_path: Path, code: int) -> tuple[str, str]:
|
||||
"""Run under a pseudo-terminal; return the transcript as the screen shows it."""
|
||||
controller, terminal = pty.openpty()
|
||||
proc = subprocess.Popen(["bash", "-c", _script(code)], stdin=terminal, stdout=terminal, stderr=terminal,
|
||||
env=_env(tmp_path), close_fds=True)
|
||||
os.close(terminal)
|
||||
chunks = []
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(controller, 4096)
|
||||
except OSError: # EIO: the child closed the terminal
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
os.close(controller)
|
||||
assert proc.wait(timeout=30) == 0
|
||||
raw = b"".join(chunks).decode("utf-8", "replace").replace("\r\n", "\n")
|
||||
# A carriage return redraws its line: the screen keeps what follows the last one.
|
||||
screen = "\n".join(re.sub(r"\x1b\[K", "", line).rsplit("\r", 1)[-1] for line in raw.split("\n"))
|
||||
return screen, (tmp_path / "home" / "logs" / "install.log").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_terminal_run_keeps_output_in_the_log_and_shows_it_only_on_failure(tmp_path):
|
||||
screen, log = _on_terminal(tmp_path / "ok", 0)
|
||||
assert "noisy" not in screen and "rc=0" in screen
|
||||
assert "noisy-first" in log and "noisy-second" in log
|
||||
|
||||
screen, log = _on_terminal(tmp_path / "failed", 3)
|
||||
assert "rc=3" in screen
|
||||
assert "Noisy step failed (exit 3)" in screen
|
||||
assert "noisy-first" in screen and "noisy-second" in screen
|
||||
assert str(tmp_path / "failed" / "home" / "logs" / "install.log") in screen
|
||||
|
||||
|
||||
def test_ci_streams_every_line_and_writes_no_log(tmp_path):
|
||||
result = subprocess.run(["bash", "-c", _script(0)], env=_env(tmp_path, CI="true"),
|
||||
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "noisy-first" in result.stdout and "noisy-second" in result.stderr
|
||||
assert "Noisy step" in result.stdout
|
||||
assert not (tmp_path / "home" / "logs" / "install.log").exists()
|
||||
Reference in New Issue
Block a user