diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e765f..367b6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed - Project resolution skips `.tool-versions` files that only pin other languages, preserving `go.mod` and parent Go pins. Bash/Zsh auto-switching tracks both the unrelated manifest and the selected Go manifest so edits in the same directory invalidate the prompt cache. Incomplete explicit Go entries still fail closed, and the first `go`/`golang` entry decides (a bare entry no longer lets a later line win). `.tool-versions` fields are no longer pathname-expanded, so `golang *` cannot select a version from file names in the working directory. +- Output and error messages that gos captures from file operations (with or without a privilege-escalation retry) keep their trailing newline when replayed, so the next progress or error line is no longer glued onto the command's last line (for example `...: Permission deniedError: ...`). - Windows uninstall validates ownership, removes only known installed files, and preserves unrelated contents instead of recursively deleting the target directory. Existing installations without a receipt remain supported, with their ambiguous `LICENSE` left in place. - Windows installs stage replacements on the destination filesystem and restore previous files on publication failure. Failed fresh installs remove partial payloads; failed restoration keeps recovery files and reports their location. - Windows PATH edits refresh a stale process even when the registry already contains gos, remove process entries on uninstall, and preserve registry value types and unrelated entries. Tests use registry substitutes and a disposable native Windows key. diff --git a/gos.sh b/gos.sh index 6ec1433..50b0e73 100755 --- a/gos.sh +++ b/gos.sh @@ -1178,6 +1178,19 @@ _gos_sudo_for() { GOS_SUDO_TARGET="$target" _gos_sudo_for_target "$@" } +# Replay captured stdout/stderr of a command run by _gos_sudo_for_target. +# Command substitution strips trailing newlines, so restore one per non-empty +# stream: otherwise the next progress or error line is glued onto the +# command's last line (e.g. "...: Permission deniedError: ..."). +_gos_sudo_replay() { + if [ -n "$1" ]; then + printf '%s\n' "$1" + fi + if [ -n "$2" ]; then + printf '%s\n' "$2" >&2 + fi +} + _gos_sudo_for_target() { local output status err err_file sudo_output sudo_status sudo_err @@ -1202,12 +1215,7 @@ _gos_sudo_for_target() { if [ "$status" -eq 0 ]; then rm -f "$err_file" - if [ -n "$output" ]; then - printf '%s' "$output" - fi - if [ -n "$err" ]; then - printf '%s' "$err" >&2 - fi + _gos_sudo_replay "$output" "$err" return 0 fi @@ -1224,28 +1232,18 @@ _gos_sudo_for_target() { sudo_err=$(<"$err_file") rm -f "$err_file" if [ "$sudo_status" -eq 0 ]; then - if [ -n "$sudo_output" ]; then - printf '%s' "$sudo_output" - fi - if [ -n "$sudo_err" ]; then - printf '%s' "$sudo_err" >&2 - fi + _gos_sudo_replay "$sudo_output" "$sudo_err" return 0 fi - printf '%s' "$err" >&2 - if [ -n "$sudo_err" ]; then - printf '%s' "$sudo_err" >&2 - fi + _gos_sudo_replay "" "$err" + _gos_sudo_replay "" "$sudo_err" return "$sudo_status" ;; esac fi rm -f "$err_file" - if [ -n "$output" ]; then - printf '%s' "$output" - fi - printf '%s' "$err" >&2 + _gos_sudo_replay "$output" "$err" return "$status" } diff --git a/tests/unit-versions.bash b/tests/unit-versions.bash index d81aab5..45a92d7 100644 --- a/tests/unit-versions.bash +++ b/tests/unit-versions.bash @@ -209,3 +209,64 @@ go_comparison_output="$( [ "$go_comparison_output" = "go-version-comparison-ok" ] \ || fail "Go version comparison failed: ${go_comparison_output}" pass "Go version comparison orders beta, rc, release, and patch versions" + +# _gos_sudo_for_target captures a command's stdout/stderr with command +# substitution, which strips trailing newlines. Replaying them must restore +# the line ending, or the next progress/error line is glued onto the +# command's last line (as seen in "...gos-backup.1234Activating go from ..."). +sudo_case="${test_root}/sudo-replay" +mkdir -p "${sudo_case}/bin" "${sudo_case}/target" +cat >"${sudo_case}/bin/sudo" <<'FAKE_REPLAY_SUDO' +#!/usr/bin/env bash +GOS_FAKE_UNDER_SUDO=1 exec "$@" +FAKE_REPLAY_SUDO +cat >"${sudo_case}/bin/denied-then-ok" <<'FAKE_DENIED_THEN_OK' +#!/usr/bin/env bash +if [ "${GOS_FAKE_UNDER_SUDO:-}" != 1 ]; then + echo "denied-then-ok: Permission denied" >&2 + exit 1 +fi +echo "moved" +echo "sudo-warning" >&2 +FAKE_DENIED_THEN_OK +cat >"${sudo_case}/bin/direct-ok" <<'FAKE_DIRECT_OK' +#!/usr/bin/env bash +echo "direct-output" +echo "direct-warning" >&2 +FAKE_DIRECT_OK +cat >"${sudo_case}/bin/always-denied" <<'FAKE_ALWAYS_DENIED' +#!/usr/bin/env bash +echo "always-denied: Permission denied" >&2 +exit 4 +FAKE_ALWAYS_DENIED +cat >"${sudo_case}/bin/plain-fail" <<'FAKE_PLAIN_FAIL' +#!/usr/bin/env bash +echo "partial-output" +echo "plain-fail: boom" >&2 +exit 3 +FAKE_PLAIN_FAIL +chmod +x "${sudo_case}/bin/"* +PATH="${sudo_case}/bin:${fake_bin}:${original_path}" \ + GOS_INSTALL_DIR="${sudo_case}/go" \ + GOS_CACHE_DIR="${sudo_case}/cache" \ + GOS_TEST_REAL_MV="$real_mv" \ + bash -c ' + set -euo pipefail + . "$1" + GOS_SUDO_TARGET="$2" _gos_sudo_for_target direct-ok + printf "after-direct\n" + printf "after-direct-err\n" >&2 + GOS_SUDO_TARGET="$2" _gos_sudo_for_target denied-then-ok + printf "after-retry\n" + printf "after-retry-err\n" >&2 + GOS_SUDO_TARGET="$2" _gos_sudo_for_target always-denied || printf "status=%s\n" "$?" + printf "after-denied-err\n" >&2 + GOS_SUDO_TARGET="$2" _gos_sudo_for_target plain-fail || printf "status=%s\n" "$?" + printf "after-fail-err\n" >&2 + ' bash "$sourceable_script" "${sudo_case}/target" \ + >"${sudo_case}/stdout" 2>"${sudo_case}/stderr" || fail "sudo replay harness failed: $(cat "${sudo_case}/stderr")" +expected_sudo_stdout="$(printf '%s\n' direct-output after-direct moved after-retry status=4 partial-output status=3)" +expected_sudo_stderr="$(printf '%s\n' direct-warning after-direct-err sudo-warning after-retry-err 'always-denied: Permission denied' 'always-denied: Permission denied' after-denied-err 'plain-fail: boom' after-fail-err)" +[ "$(cat "${sudo_case}/stdout")" = "$expected_sudo_stdout" ] || fail "sudo replay stdout lost line endings: $(cat "${sudo_case}/stdout")" +[ "$(cat "${sudo_case}/stderr")" = "$expected_sudo_stderr" ] || fail "sudo replay stderr lost line endings: $(cat "${sudo_case}/stderr")" +pass "sudo wrapper replays captured output with its line endings on every path"