From 535d8f3485581b35ab0d368d4179b362eb6dea32 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 24 Aug 2026 14:36:09 +0200 Subject: [PATCH] Regenerate mise wrappers that still print mise's output to stdout (#8041) omarchy-mise-install gained --quiet on its `mise use -g` line so a wrapper no longer prints mise's "tools: pkg@version" ahead of the tool's own output. That only changes wrappers written from then on, and the migration that installed the current ones is already marked complete, so every wrapper already on disk keeps polluting stdout: `claude --version` still answers with two lines, and a wrapper for a protocol-speaking command answers with a line its caller cannot parse. Rewrite them through omarchy-mise-install so the template stays in one place. Wrappers were written by four different generated forms over time and only the ones a later migration happened to regenerate moved forward, so all four are recognized. The whole file has to match one of them exactly, which leaves a wrapper someone has added a line to alone and makes a second run a no-op. Claude-Session: https://claude.ai/code/session_01Mv1FyKG2VGZRdtknQ7YjU8 Co-authored-by: Claude Opus 5 (1M context) --- migrations/1787573629.sh | 69 ++++++++ .../mise-wrapper-quiet-migration-test.sh | 160 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 migrations/1787573629.sh create mode 100755 test/shell.d/mise-wrapper-quiet-migration-test.sh diff --git a/migrations/1787573629.sh b/migrations/1787573629.sh new file mode 100644 index 00000000..a01cc9c6 --- /dev/null +++ b/migrations/1787573629.sh @@ -0,0 +1,69 @@ +echo "Regenerate mise wrappers that still print mise's own output to stdout" + +# omarchy-mise-install gained `--quiet` on its `mise use -g` line so a wrapper +# stops printing mise's "tools: pkg@version" ahead of the tool's own output. +# That only changes wrappers written from then on, and the migration that +# installed the current ones is already marked complete, so every wrapper on +# disk keeps polluting stdout -- `claude --version` still answers with two +# lines, and a wrapper for a protocol-speaking command answers with a line its +# caller cannot parse. Rewrite them through omarchy-mise-install so the +# template stays in one place. + +# Every generated form that predates --quiet, rebuilt from the package and bin +# the file itself names. Wrappers were written by all of these over time and +# only the ones a later migration happened to regenerate moved forward, so a +# machine can still be carrying any of them. +stale_template() { + local form=$1 package=$2 bin=$3 + + case $form in + cooldown-export) + printf '#!/bin/bash\nexport MISE_MINIMUM_RELEASE_AGE=0\nmise use -g "%s" || exit 1\nexec mise x "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + bail-on-failure) + printf '#!/bin/bash\nmise use -g "%s" || exit 1\nexec mise x "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + mise-exec) + printf '#!/bin/bash\nmise use -g "%s"\nexec mise exec "%s" -- "%s" "$@"' "$package" "$package" "$bin" ;; + bare-exec) + printf '#!/bin/bash\nmise use -g "%s"\nexec "%s" "$@"' "$package" "$bin" ;; + esac +} + +bin_dir="$HOME/.local/bin" + +[[ -d $bin_dir ]] || exit 0 + +for wrapper in "$bin_dir"/*; do + [[ -f $wrapper && ! -L $wrapper && -r $wrapper ]] || continue + + # A generated wrapper is four short lines. ~/.local/bin also holds real + # binaries -- uv and uvx land here from the Python dev env -- so check the + # size before reading rather than pulling a 30MB executable into memory to + # discover it is not a wrapper. + (($(stat -c%s "$wrapper") <= 1024)) || continue + + contents=$(<"$wrapper") + + package=$(sed -n 's/^mise use -g "\(.*\)"\( || exit 1\)*$/\1/p' <<<"$contents") + bin=$(sed -n \ + -e 's/^exec mise x ".*" -- "\(.*\)" "\$@"$/\1/p' \ + -e 's/^exec mise exec ".*" -- "\(.*\)" "\$@"$/\1/p' \ + -e 's/^exec "\(.*\)" "\$@"$/\1/p' <<<"$contents") + + [[ -n $package && -n $bin ]] || continue + + # The whole file has to be one of those forms exactly. A wrapper someone has + # added a line to is left as it is rather than silently regenerated without + # that line, and one already carrying --quiet matches nothing here, which is + # what makes re-running this a no-op. + stale=0 + for form in cooldown-export bail-on-failure mise-exec bare-exec; do + if [[ $contents == "$(stale_template "$form" "$package" "$bin")" ]]; then + stale=1 + break + fi + done + + ((stale)) || continue + + omarchy-mise-install "$package" "${wrapper##*/}" "$bin" +done diff --git a/test/shell.d/mise-wrapper-quiet-migration-test.sh b/test/shell.d/mise-wrapper-quiet-migration-test.sh new file mode 100755 index 00000000..ff207d0f --- /dev/null +++ b/test/shell.d/mise-wrapper-quiet-migration-test.sh @@ -0,0 +1,160 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration="$ROOT/migrations/1787573629.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +home="$test_dir/home" +bin_dir="$home/.local/bin" +mkdir -p "$bin_dir" + +# The migration calls omarchy-mise-install to rewrite a wrapper, so the real +# one has to be reachable: this proves the template it writes today, not a +# copy of it that could drift. +run_migration() { + HOME="$home" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration" >/dev/null +} + +write_stale_wrapper() { + local command=$1 package=$2 bin=$3 + + cat >"$bin_dir/$command" <"$bin_dir/$command" <"$bin_dir/mise-exec-era" <<'EOF' +#!/bin/bash +mise use -g "npm:some/tool" +exec mise exec "npm:some/tool" -- "tool-bin" "$@" +EOF +cat >"$bin_dir/bare-exec-era" <<'EOF' +#!/bin/bash +mise use -g "aqua:some/other" +exec "other-bin" "$@" +EOF +chmod +x "$bin_dir/mise-exec-era" "$bin_dir/bare-exec-era" + +run_migration + +grep -qF 'mise use -g --quiet "claude" || exit 1' "$bin_dir/claude" || + fail "migration adds --quiet to a stale wrapper" +pass "migration adds --quiet to a stale wrapper" + +grep -qF 'mise use -g --quiet "github:can1357/oh-my-pi" || exit 1' "$bin_dir/omp" || + fail "migration keeps a wrapper's package when the command name differs" +grep -qF 'exec mise x "github:can1357/oh-my-pi" -- "omp" "$@"' "$bin_dir/omp" || + fail "migration keeps a wrapper's bin name when the command name differs" +pass "migration preserves package and bin names" + +grep -qF 'exec mise x "npm:@kitlangton/ghui" -- "ghui" "$@"' "$bin_dir/ghui" || + fail "migration preserves a scoped npm package name" +pass "migration preserves a scoped npm package name" + +grep -qF 'mise use -g --quiet "github:someone/custom-tool" || exit 1' "$bin_dir/custom-tool" || + fail "migration rewrites a wrapper on the pre-export template" +grep -qF 'export MISE_MINIMUM_RELEASE_AGE=0' "$bin_dir/custom-tool" || + fail "migration brings a pre-export wrapper up to the current template" +pass "migration rewrites wrappers on the pre-export template" + +grep -qF 'mise use -g --quiet "npm:some/tool" || exit 1' "$bin_dir/mise-exec-era" || + fail "migration rewrites a wrapper on the mise-exec template" +grep -qF 'exec mise x "npm:some/tool" -- "tool-bin" "$@"' "$bin_dir/mise-exec-era" || + fail "migration keeps the bin name from a mise-exec wrapper" +grep -qF 'mise use -g --quiet "aqua:some/other" || exit 1' "$bin_dir/bare-exec-era" || + fail "migration rewrites a wrapper on the bare-exec template" +grep -qF 'exec mise x "aqua:some/other" -- "other-bin" "$@"' "$bin_dir/bare-exec-era" || + fail "migration keeps the bin name from a bare-exec wrapper" +pass "migration rewrites every generated form that predates --quiet" + +[[ -x $bin_dir/claude ]] || fail "migration leaves the rewritten wrapper executable" +pass "migration leaves the rewritten wrapper executable" + +# Running twice must not touch an already-quiet wrapper. +before=$(cat "$bin_dir/claude") +run_migration +[[ $(cat "$bin_dir/claude") == "$before" ]] || fail "migration is idempotent" +pass "migration is idempotent" + +# Anything the generator did not write is the user's own file. +cat >"$bin_dir/hand-written" <<'EOF' +#!/bin/bash +export MISE_MINIMUM_RELEASE_AGE=0 +mise use -g "something" || exit 1 +echo "and then something else entirely" +EOF +cat >"$bin_dir/mismatched" <<'EOF' +#!/bin/bash +export MISE_MINIMUM_RELEASE_AGE=0 +mise use -g "one-package" || exit 1 +exec mise x "another-package" -- "bin" "$@" +EOF +# A generated wrapper someone added a line to. Regenerating would drop that +# line, so the exact-match check has to leave the whole file alone. +cat >"$bin_dir/customized" <<'EOF' +#!/bin/bash +export MISE_MINIMUM_RELEASE_AGE=0 +export SOME_TOKEN=abc123 +mise use -g "customized" || exit 1 +exec mise x "customized" -- "customized" "$@" +EOF +printf '#!/bin/bash\necho hi\n' >"$bin_dir/unrelated" +# uv and uvx land in ~/.local/bin from the Python dev env. A wrapper is a few +# short lines, so a real binary must be skipped on size, never read in whole. +head -c 5000000 /dev/urandom >"$bin_dir/uv" +chmod +x "$bin_dir/uv" +ln -s "$bin_dir/claude" "$bin_dir/linked" + +hand_written_before=$(cat "$bin_dir/hand-written") +mismatched_before=$(cat "$bin_dir/mismatched") +unrelated_before=$(cat "$bin_dir/unrelated") +customized_before=$(cat "$bin_dir/customized") + +run_migration + +[[ $(cat "$bin_dir/hand-written") == "$hand_written_before" ]] || + fail "migration leaves a hand-written script that calls mise alone" +[[ $(cat "$bin_dir/mismatched") == "$mismatched_before" ]] || + fail "migration leaves a wrapper whose two lines disagree alone" +[[ $(cat "$bin_dir/unrelated") == "$unrelated_before" ]] || + fail "migration leaves an unrelated script alone" +[[ $(cat "$bin_dir/customized") == "$customized_before" ]] || + fail "migration leaves a generated wrapper a user has added a line to alone" +[[ -L $bin_dir/linked ]] || fail "migration leaves a symlink alone" +[[ $(stat -c%s "$bin_dir/uv") -eq 5000000 ]] || fail "migration leaves a native binary alone" +pass "migration only rewrites wrappers it recognizes" + +# A machine with no ~/.local/bin at all must not fail the run. +empty_home="$test_dir/empty-home" +mkdir -p "$empty_home" +HOME="$empty_home" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration" >/dev/null || + fail "migration succeeds when ~/.local/bin is missing" +pass "migration succeeds when ~/.local/bin is missing"