From 50de548e4953a40540665e8f18bac5c3123e8618 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 14:17:52 -0400 Subject: [PATCH] fix(install.sh): arrow-style output with child noise collapsed on a terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/install.sh | 174 ++++++++++++++---- .../scripts/install/test_install_sh_output.py | 69 +++++++ 2 files changed, 208 insertions(+), 35 deletions(-) create mode 100644 tests/scripts/install/test_install_sh_output.py diff --git a/scripts/install.sh b/scripts/install.sh index 611e376a99..d920bc2232 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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. + "$@" &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 ` 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 ." 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=$? diff --git a/tests/scripts/install/test_install_sh_output.py b/tests/scripts/install/test_install_sh_output.py new file mode 100644 index 0000000000..4987555ead --- /dev/null +++ b/tests/scripts/install/test_install_sh_output.py @@ -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()