From 154e8a8e153acbbbf3e23cd6abdca972fcd18609 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 25 Apr 2026 21:13:10 +0300 Subject: [PATCH 1/6] Update install script for home-directory defaults --- docs/docs/changelog.md | 1 + docs/docs/development.md | 11 +- docs/docs/installation.mdx | 36 +++--- install.sh | 231 +++++++++++++++++++++++++++++++++--- internal/upgrade/upgrade.go | 2 +- lets.yaml | 11 +- 6 files changed, 244 insertions(+), 48 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index ee3c374d..f20e8b07 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -5,6 +5,7 @@ title: Changelog ## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X) +* `[Changed]` Install script now installs to `$HOME/.lets/bin`, exposes `lets` through a user PATH symlink, and stops on old non-Homebrew `/usr/local/bin/lets` installs. Issue [#121](https://github.com/lets-cli/lets/issues/121) * `[Fixed]` Prevent `lets self upgrade` from overwriting Homebrew-managed installs. Issue [#338](https://github.com/lets-cli/lets/issues/338) ## [0.0.60](https://github.com/lets-cli/lets/releases/tag/v0.0.60) diff --git a/docs/docs/development.md b/docs/docs/development.md index bc29d3a3..0e192a5a 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -11,13 +11,14 @@ have stable `lets` version untouched. To build a binary: ```bash -go build -o lets-dev *.go +go build -o lets-dev ./cmd/lets ``` -To install in system +To install in your user `PATH`: ```bash -go build -o lets-dev *.go && sudo mv ./lets-dev /usr/local/bin/lets-dev +mkdir -p "$HOME/.local/bin" +go build -o "$HOME/.local/bin/lets-dev" ./cmd/lets ``` Or if you already have `lets` installed in your system: @@ -25,11 +26,11 @@ Or if you already have `lets` installed in your system: ```bash lets build-and-install [--path=] ``` -`path` - your custom executable $PATH, defaults to `/usr/local/bin` +`path` - your custom executable $PATH, defaults to `$HOME/.local/bin` After install - check version of lets - `lets-dev --version` - it should be development -It will install `lets-dev` to /usr/local/bin/lets-dev, or wherever you`ve specified in path, and set version to development with current tag and timestamp +It will install `lets-dev` to `$HOME/.local/bin/lets-dev`, or wherever you`ve specified in path, and set version to development with current tag and timestamp ## Test diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index ea77aa68..aa1d4dc5 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -20,39 +20,37 @@ import TabItem from '@theme/TabItem'; ```bash -curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- -b ~/bin +curl -fsSL https://lets-cli.org/install.sh | bash ``` -This will install **latest** `lets` binary to `~/bin` directory. +This installs the **latest** `lets` binary to `$HOME/.lets/bin/lets`. -To be able to run `lets` from any place in system add `$HOME/bin` to your `PATH` +The installer will make `lets` available in your `PATH` by creating a symlink in the first existing preferred +directory from this list: -Open one of these files +* `$HOME/.local/bin` +* `$HOME/bin` +* `$HOME/.bin` -```bash -vim ~/.profile # or vim ~/.bashrc or ~/.zshrc -``` +If none of these directories are in `PATH`, the installer creates `$HOME/.local/bin`, links `lets` there, and adds +`$HOME/.local/bin` to your shell profile. -Add the following line at the end of file, save file and restart the shell. +To install a specific version of `lets` (for example `v0.0.21`): ```bash -export PATH=$PATH:$HOME/bin +curl -fsSL https://lets-cli.org/install.sh | bash -s -- v0.0.21 ``` -You can change install location to any directory you want, probably to directory that is in your $PATH - -To install a specific version of `lets` (for example `v0.0.21`): +You can also use `LETS_VERSION`. If both `LETS_VERSION` and a positional version are provided, `LETS_VERSION` wins. ```bash -curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- v0.0.21 +curl -fsSL https://lets-cli.org/install.sh | LETS_VERSION=v0.0.21 bash ``` -To use `lets` globally in system you may want to install `lets` to `/usr/local/bin` - -> May require `sudo` +To install into a custom lets home directory, set `LETS_HOME`. The binary will be installed to `$LETS_HOME/bin/lets`. ```bash -curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- -b /usr/local/bin +curl -fsSL https://lets-cli.org/install.sh | LETS_HOME=$HOME/tools/lets bash ``` @@ -62,7 +60,7 @@ Download the version you need for your platform from [Lets Releases](https://git Once downloaded, the binary can be run from anywhere. -Ideally, you should install it somewhere in your PATH for easy use. `/usr/local/bin` is the most probable location. +Ideally, install it somewhere in your user `PATH`, such as `$HOME/.local/bin`. @@ -143,7 +141,7 @@ If your `lets` version is below `0.0.30` - use shell script and specify the late To update `lets` you can use shell script ``` -curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- -b $(dirname $(which lets)) +curl -fsSL https://lets-cli.org/install.sh | bash ``` Running script will update `lets` to **latest** version. diff --git a/install.sh b/install.sh index 0fcc1b34..465037cc 100755 --- a/install.sh +++ b/install.sh @@ -1,35 +1,30 @@ -#!/bin/sh +#!/usr/bin/env bash set -e -# Code generated by godownloader on 2020-02-05T22:19:37Z. DO NOT EDIT. -# + +LETS_HOME="${LETS_HOME:-$HOME/.lets}" +BIN_DIR="${LETS_HOME}/bin" +LETS_VERSION="${LETS_VERSION:-}" usage() { this=$1 cat </dev/null 2>&1 && pwd) || return 1 + source_path=$(readlink "$source_path") + case "$source_path" in + /*) ;; + *) source_path="${source_dir}/${source_path}" ;; + esac + done + + source_dir=$(cd -P "$(dirname "$source_path")" >/dev/null 2>&1 && pwd) || return 1 + echo "${source_dir}/$(basename "$source_path")" +} +is_homebrew_lets() { + resolved_path=$(resolve_path "$1") || return 1 + case "$resolved_path" in + */Cellar/lets/*) return 0 ;; + *) return 1 ;; + esac +} +check_old_usr_local_install() { + old_path="/usr/local/bin/lets" + + if [ ! -e "$old_path" ]; then + return + fi + + if is_homebrew_lets "$old_path"; then + log_info "detected Homebrew-managed ${old_path}; leaving it untouched" + return + fi + + log_crit "found old system-wide lets installation at ${old_path}" + log_crit "remove it before continuing:" + log_crit " sudo rm ${old_path}" + log_crit "then run this installer again" + exit 1 +} +dir_in_path() { + check_dir=$1 + + if [ -d "$check_dir" ]; then + check_dir=$(cd "$check_dir" >/dev/null 2>&1 && pwd) || return 1 + fi + + echo ":$PATH:" | grep -q ":$check_dir:" +} +try_symlink_in_path() { + binary_name=$1 + preferred_dirs=( + "$HOME/.local/bin" + "$HOME/bin" + "$HOME/.bin" + ) + + for dir in "${preferred_dirs[@]}"; do + if ! dir_in_path "$dir"; then + continue + fi + + mkdir -p "$dir" 2>/dev/null || continue + + symlink_path="${dir}/${binary_name}" + target_path="${BIN_DIR}/${binary_name}" + + if [ -L "$symlink_path" ]; then + rm -f "$symlink_path" + fi + + if ln -sf "$target_path" "$symlink_path" 2>/dev/null; then + log_info "created symlink: ${symlink_path} -> ${target_path}" + return 0 + fi + done + + return 1 +} +update_shell_profile() { + binary_name="lets" + + if try_symlink_in_path "$binary_name"; then + return + fi + + local_bin_dir="$HOME/.local/bin" + mkdir -p "$local_bin_dir" 2>/dev/null || true + symlink_path="${local_bin_dir}/${binary_name}" + target_path="${BIN_DIR}/${binary_name}" + + if [ -L "$symlink_path" ]; then + rm -f "$symlink_path" + fi + + if ln -sf "$target_path" "$symlink_path" 2>/dev/null; then + log_info "created symlink: ${symlink_path} -> ${target_path}" + else + log_err "could not create symlink in ${local_bin_dir}" + log_err "please add ${BIN_DIR} to your PATH manually:" + echo " export PATH=\"${BIN_DIR}:\$PATH\"" + return + fi + + default_shell="bash" + if [ "$(uname -s)" = "Darwin" ]; then + default_shell="zsh" + fi + + os_name=$(uname -s) + shell_name=$(basename "${SHELL:-$default_shell}") + shell_profile="" + path_export="" + + case "$shell_name" in + zsh) + shell_profile="$HOME/.zshrc" + path_export="export PATH=\"\$HOME/.local/bin:\$PATH\"" + ;; + bash) + if [ "$os_name" = "Darwin" ]; then + if [ -f "$HOME/.bash_profile" ]; then + shell_profile="$HOME/.bash_profile" + elif [ -f "$HOME/.bashrc" ]; then + shell_profile="$HOME/.bashrc" + else + shell_profile="$HOME/.bash_profile" + fi + else + if [ -f "$HOME/.bashrc" ]; then + shell_profile="$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + shell_profile="$HOME/.bash_profile" + else + shell_profile="$HOME/.bashrc" + fi + fi + path_export="export PATH=\"\$HOME/.local/bin:\$PATH\"" + ;; + fish) + shell_profile="$HOME/.config/fish/config.fish" + path_export="fish_add_path \"\$HOME/.local/bin\"" + ;; + *) + log_err "unknown shell: ${shell_name}" + log_err "please add ~/.local/bin to your PATH manually:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + return + ;; + esac + + if [ -f "$shell_profile" ] && grep -v '^[[:space:]]*#' "$shell_profile" 2>/dev/null | grep -qE 'PATH=.*\.local/bin|fish_add_path .*\.local/bin'; then + log_info "~/.local/bin already configured in ${shell_profile/#$HOME/\~}" + echo "" + log_info "to use lets immediately, run:" + echo " ${path_export}" + return + fi + + tilde_profile="${shell_profile/#$HOME/\~}" + echo "" + if [ -t 0 ]; then + read -r -p "Add ~/.local/bin to your PATH in ${tilde_profile}? [y/n] " -n 1 + echo "" + case "$REPLY" in + [Yy]) ;; + *) + log_info "skipped modifying shell config" + log_info "to use lets, add ~/.local/bin to your PATH manually:" + echo " ${path_export}" + return + ;; + esac + else + log_info "adding ~/.local/bin to PATH in ${tilde_profile}" + fi + + if [ ! -f "$shell_profile" ]; then + mkdir -p "$(dirname "$shell_profile")" + touch "$shell_profile" + fi + + { + echo "" + echo "# lets" + echo "$path_export" + } >>"$shell_profile" + + log_info "added ~/.local/bin to PATH in ${tilde_profile}" + echo "" + log_info "to use lets immediately, run:" + echo " ${path_export}" +} uname_os() { os=$(uname -s | tr '[:upper:]' '[:lower:]') case "$os" in @@ -365,6 +556,8 @@ uname_arch_check "$ARCH" parse_args "$@" +check_old_usr_local_install + get_binaries tag_to_version @@ -385,3 +578,5 @@ CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM} execute + +update_shell_profile diff --git a/internal/upgrade/upgrade.go b/internal/upgrade/upgrade.go index 86878cd6..28c193a6 100644 --- a/internal/upgrade/upgrade.go +++ b/internal/upgrade/upgrade.go @@ -89,7 +89,7 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { } func binaryPath() (string, error) { - // TODO after implementing $HOME/.lets/bin, deny upgrading in other places + // TODO decide whether self-upgrade should be limited to supported installer-managed paths. return os.Executable() } diff --git a/lets.yaml b/lets.yaml index cefd782d..613dca82 100644 --- a/lets.yaml +++ b/lets.yaml @@ -98,20 +98,21 @@ commands: --bin= Binary name (default: lets-dev) Example: lets build-and-install - lets build-and-install -p ~/bin - lets build-and-install -p ~/bin --bin=my-lets + lets build-and-install -p ~/.local/bin + lets build-and-install -p ~/.local/bin --bin=my-lets cmd: | VERSION=$(git describe) - PATH2LETSDEV="/usr/local/bin" + PATH2LETSDEV="${HOME}/.local/bin" BIN="${LETSOPT_BIN:-lets-dev}" if [[ -n ${LETSOPT_PATH} ]]; then PATH2LETSDEV=$LETSOPT_PATH fi + mkdir -p "${PATH2LETSDEV}" go build -ldflags="-X main.Version=${VERSION:1}-dev -X main.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o "${BIN}" ./cmd/lets && \ - sudo mv ./${BIN} $PATH2LETSDEV/${BIN} && \ - echo " - binary ${BIN} version $($PATH2LETSDEV/${BIN} --version) successfully installed in ${PATH2LETSDEV}" + mv ./${BIN} "${PATH2LETSDEV}/${BIN}" && \ + echo " - binary ${BIN} version $("${PATH2LETSDEV}/${BIN}" --version) successfully installed in ${PATH2LETSDEV}" build: description: Build lets from source code From 5d43671d177047476b79e8d5dfece84914c924d0 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 25 Apr 2026 21:30:44 +0300 Subject: [PATCH 2/6] Improve Homebrew detection for install and upgrade --- install.sh | 27 +++++++++++- internal/upgrade/notifier.go | 68 ++++++++++++++++++++++++++++++- internal/upgrade/notifier_test.go | 64 ++++++++++++++++++++++++++--- 3 files changed, 150 insertions(+), 9 deletions(-) diff --git a/install.sh b/install.sh index 465037cc..6b09164e 100755 --- a/install.sh +++ b/install.sh @@ -188,8 +188,33 @@ is_homebrew_lets() { resolved_path=$(resolve_path "$1") || return 1 case "$resolved_path" in */Cellar/lets/*) return 0 ;; - *) return 1 ;; esac + + if ! is_command brew; then + return 1 + fi + + brew_prefix=$(brew --prefix 2>/dev/null) || return 1 + brew_lets_prefix=$(brew --prefix lets 2>/dev/null) || return 1 + brew_lets_cellar=$(brew --cellar lets 2>/dev/null) || true + + case "$1" in + "${brew_prefix}/bin/lets") return 0 ;; + "${brew_lets_prefix}/bin/lets") return 0 ;; + esac + + case "$resolved_path" in + "${brew_prefix}/bin/lets") return 0 ;; + "${brew_lets_prefix}/bin/lets") return 0 ;; + esac + + if [ -n "$brew_lets_cellar" ]; then + case "$resolved_path" in + "${brew_lets_cellar}"/*) return 0 ;; + esac + fi + + return 1 } check_old_usr_local_install() { old_path="/usr/local/bin/lets" diff --git a/internal/upgrade/notifier.go b/internal/upgrade/notifier.go index f62bce6c..23649c3e 100644 --- a/internal/upgrade/notifier.go +++ b/internal/upgrade/notifier.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" "strings" "time" @@ -237,8 +238,71 @@ func parseStableVersion(version string) (*semver.Version, bool) { return parsed, true } -func isHomebrewInstall(path string) bool { - return strings.Contains(path, "/Cellar/lets/") +func isHomebrewInstall(binaryPath string) bool { + if binaryPath == "" { + return false + } + + paths := []string{filepath.Clean(binaryPath)} + if resolvedPath, err := filepath.EvalSymlinks(binaryPath); err == nil { + paths = append(paths, filepath.Clean(resolvedPath)) + } + + for _, path := range paths { + if strings.Contains(path, "/Cellar/lets/") { + return true + } + } + + brewPrefix, ok := homebrewOutput("--prefix") + if !ok { + return false + } + + letsPrefix, ok := homebrewOutput("--prefix", "lets") + if !ok { + return false + } + + letsCellar, _ := homebrewOutput("--cellar", "lets") + managedPaths := []string{ + filepath.Join(brewPrefix, "bin", "lets"), + filepath.Join(letsPrefix, "bin", "lets"), + } + + for _, path := range paths { + for _, managedPath := range managedPaths { + if path == filepath.Clean(managedPath) { + return true + } + } + + if letsCellar != "" && isPathInside(path, letsCellar) { + return true + } + } + + return false +} + +func homebrewOutput(args ...string) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + output, err := exec.CommandContext(ctx, "brew", args...).Output() + if err != nil { + return "", false + } + + value := strings.TrimSpace(string(output)) + return value, value != "" +} + +func isPathInside(path string, dir string) bool { + path = filepath.Clean(path) + dir = filepath.Clean(dir) + + return path == dir || strings.HasPrefix(path, dir+string(os.PathSeparator)) } func LogUpdateCheckError(err error) { diff --git a/internal/upgrade/notifier_test.go b/internal/upgrade/notifier_test.go index 795646f6..aafc439b 100644 --- a/internal/upgrade/notifier_test.go +++ b/internal/upgrade/notifier_test.go @@ -2,6 +2,7 @@ package upgrade import ( "context" + "os" "path/filepath" "testing" "time" @@ -209,10 +210,61 @@ func TestLetsStatePath(t *testing.T) { } func TestIsHomebrewInstall(t *testing.T) { - if !isHomebrewInstall("/opt/homebrew/Cellar/lets/0.0.1/bin/lets") { - t.Fatal("expected homebrew path to be detected") - } - if isHomebrewInstall("/usr/local/bin/lets") { - t.Fatal("did not expect generic install path to be detected as homebrew") - } + t.Run("detects cellar path without brew", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + if !isHomebrewInstall("/opt/homebrew/Cellar/lets/0.0.1/bin/lets") { + t.Fatal("expected homebrew path to be detected") + } + }) + + t.Run("ignores generic path without brew", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + if isHomebrewInstall("/usr/local/bin/lets") { + t.Fatal("did not expect generic install path to be detected as homebrew") + } + }) + + t.Run("detects brew prefix bin path", func(t *testing.T) { + fakeBrewDir := t.TempDir() + fakeBrewPath := filepath.Join(fakeBrewDir, "brew") + fakeBrew := `#!/bin/sh +case "$*" in + "--prefix") echo "/opt/homebrew" ;; + "--prefix lets") echo "/opt/homebrew/opt/lets" ;; + "--cellar lets") echo "/opt/homebrew/Cellar/lets" ;; + *) exit 1 ;; +esac +` + if err := os.WriteFile(fakeBrewPath, []byte(fakeBrew), 0o755); err != nil { + t.Fatalf("failed to write fake brew: %s", err) + } + t.Setenv("PATH", fakeBrewDir) + + if !isHomebrewInstall("/opt/homebrew/bin/lets") { + t.Fatal("expected brew bin path to be detected") + } + }) + + t.Run("detects brew opt path", func(t *testing.T) { + fakeBrewDir := t.TempDir() + fakeBrewPath := filepath.Join(fakeBrewDir, "brew") + fakeBrew := `#!/bin/sh +case "$*" in + "--prefix") echo "/opt/homebrew" ;; + "--prefix lets") echo "/opt/homebrew/opt/lets" ;; + "--cellar lets") echo "/opt/homebrew/Cellar/lets" ;; + *) exit 1 ;; +esac +` + if err := os.WriteFile(fakeBrewPath, []byte(fakeBrew), 0o755); err != nil { + t.Fatalf("failed to write fake brew: %s", err) + } + t.Setenv("PATH", fakeBrewDir) + + if !isHomebrewInstall("/opt/homebrew/opt/lets/bin/lets") { + t.Fatal("expected brew opt path to be detected") + } + }) } From 641114be858cf9ecf6dd0de46f9e23631ab756f3 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 25 Apr 2026 22:05:03 +0300 Subject: [PATCH 3/6] Polish install script logging and progress output --- install.sh | 252 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 207 insertions(+), 45 deletions(-) diff --git a/install.sh b/install.sh index 6b09164e..346040e3 100755 --- a/install.sh +++ b/install.sh @@ -42,9 +42,11 @@ parse_args() { # out preventing half-done work execute() { tmpdir=$(mktemp -d) - log_debug "downloading files into ${tmpdir}" + log_debug "Downloading files into ${tmpdir}" http_download "${tmpdir}/${TARBALL}" "${TARBALL_URL}" + log_message "Downloading checksum" http_download "${tmpdir}/${CHECKSUM}" "${CHECKSUM_URL}" + log_message "Verifying checksum" hash_sha256_verify "${tmpdir}/${TARBALL}" "${tmpdir}/${CHECKSUM}" srcdir="${tmpdir}" (cd "${tmpdir}" && untar "${TARBALL}") @@ -54,7 +56,7 @@ execute() { binexe="${binexe}.exe" fi install "${srcdir}/${binexe}" "${BIN_DIR}/" - log_info "installed ${BIN_DIR}/${binexe}" + log_info "Installed ${BIN_DIR}/${binexe}" done rm -rf "${tmpdir}" } @@ -65,20 +67,15 @@ get_binaries() { linux/386) BINARIES="lets" ;; linux/amd64) BINARIES="lets" ;; *) - log_crit "platform $PLATFORM is not supported. Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new" + log_crit "Platform $PLATFORM is not supported. Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new" exit 1 ;; esac } tag_to_version() { - if [ -z "${TAG}" ]; then - log_info "checking GitHub for latest tag" - else - log_info "checking GitHub for tag '${TAG}'" - fi REALTAG=$(github_release "$OWNER/$REPO" "${TAG}") && true if test -z "$REALTAG"; then - log_crit "unable to find '${TAG}' - use 'latest' or see https://github.com/${PREFIX}/releases for details" + log_crit "Unable to find '${TAG}' - use 'latest' or see https://github.com/${PREFIX}/releases for details" exit 1 fi # if version starts with 'v', remove it @@ -126,6 +123,35 @@ is_command() { echoerr() { echo "$@" 1>&2 } +supports_color() { + [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ] +} +brand_color_start() { + if supports_color; then + printf '\033[38;2;29;216;216m' + fi +} +color_reset() { + if supports_color; then + printf '\033[0m' + fi +} +warning_color() { + if supports_color; then + printf '\033[1;33m%s\033[0m\n' "$1" + return + fi + + printf '%s\n' "$1" +} +brand_color() { + if supports_color; then + printf '\033[38;2;29;216;216m%s\033[0m\n' "$1" + return + fi + + printf '%s\n' "$1" +} log_prefix() { echo "$0" } @@ -155,19 +181,27 @@ log_tag() { } log_debug() { log_priority 7 || return 0 - echoerr "$(log_prefix)" "$(log_tag 7)" "$@" + echoerr "$(log_prefix)" "$@" } log_info() { log_priority 6 || return 0 - echoerr "$(log_prefix)" "$(log_tag 6)" "$@" + echoerr "$(log_prefix)" "$@" +} +log_message() { + log_priority 6 || return 0 + echoerr "$(log_prefix)" "$@" } log_err() { log_priority 3 || return 0 - echoerr "$(log_prefix)" "$(log_tag 3)" "$@" + echoerr "$(log_prefix)" "$@" } log_crit() { log_priority 2 || return 0 - echoerr "$(log_prefix)" "$(log_tag 2)" "$@" + echoerr "$(log_prefix)" "$@" +} +log_warning_message() { + log_priority 4 || return 0 + echoerr "$(log_prefix)" "$(warning_color "$*")" } resolve_path() { source_path=$1 @@ -224,14 +258,13 @@ check_old_usr_local_install() { fi if is_homebrew_lets "$old_path"; then - log_info "detected Homebrew-managed ${old_path}; leaving it untouched" + log_info "Detected Homebrew-managed ${old_path}; leaving it untouched" return fi - log_crit "found old system-wide lets installation at ${old_path}" - log_crit "remove it before continuing:" - log_crit " sudo rm ${old_path}" - log_crit "then run this installer again" + log_warning_message "Found old system-wide lets installation at ${old_path}" + log_warning_message "Remove it before continuing by running: sudo rm ${old_path}" + log_warning_message "Then run this installer again" exit 1 } dir_in_path() { @@ -266,7 +299,7 @@ try_symlink_in_path() { fi if ln -sf "$target_path" "$symlink_path" 2>/dev/null; then - log_info "created symlink: ${symlink_path} -> ${target_path}" + log_info "Created symlink: ${symlink_path} -> ${target_path}" return 0 fi done @@ -290,10 +323,10 @@ update_shell_profile() { fi if ln -sf "$target_path" "$symlink_path" 2>/dev/null; then - log_info "created symlink: ${symlink_path} -> ${target_path}" + log_info "Created symlink: ${symlink_path} -> ${target_path}" else - log_err "could not create symlink in ${local_bin_dir}" - log_err "please add ${BIN_DIR} to your PATH manually:" + log_err "Could not create symlink in ${local_bin_dir}" + log_err "Please add ${BIN_DIR} to your PATH manually:" echo " export PATH=\"${BIN_DIR}:\$PATH\"" return fi @@ -338,17 +371,17 @@ update_shell_profile() { path_export="fish_add_path \"\$HOME/.local/bin\"" ;; *) - log_err "unknown shell: ${shell_name}" - log_err "please add ~/.local/bin to your PATH manually:" + log_err "Unknown shell: ${shell_name}" + log_err "Please add ~/.local/bin to your PATH manually:" echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" return ;; esac if [ -f "$shell_profile" ] && grep -v '^[[:space:]]*#' "$shell_profile" 2>/dev/null | grep -qE 'PATH=.*\.local/bin|fish_add_path .*\.local/bin'; then - log_info "~/.local/bin already configured in ${shell_profile/#$HOME/\~}" + log_info "Path ~/.local/bin already configured in ${shell_profile/#$HOME/\~}" echo "" - log_info "to use lets immediately, run:" + log_info "To use lets immediately, run:" echo " ${path_export}" return fi @@ -361,14 +394,14 @@ update_shell_profile() { case "$REPLY" in [Yy]) ;; *) - log_info "skipped modifying shell config" - log_info "to use lets, add ~/.local/bin to your PATH manually:" + log_info "Skipped modifying shell config" + log_info "To use lets, add ~/.local/bin to your PATH manually:" echo " ${path_export}" return ;; esac else - log_info "adding ~/.local/bin to PATH in ${tilde_profile}" + log_info "Adding ~/.local/bin to PATH in ${tilde_profile}" fi if [ ! -f "$shell_profile" ]; then @@ -382,9 +415,9 @@ update_shell_profile() { echo "$path_export" } >>"$shell_profile" - log_info "added ~/.local/bin to PATH in ${tilde_profile}" + log_info "Added ~/.local/bin to PATH in ${tilde_profile}" echo "" - log_info "to use lets immediately, run:" + log_info "To use lets immediately, run:" echo " ${path_export}" } uname_os() { @@ -425,7 +458,7 @@ uname_os_check() { solaris) return 0 ;; windows) return 0 ;; esac - log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" + log_crit "Uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" return 1 } uname_arch_check() { @@ -446,7 +479,7 @@ uname_arch_check() { s390x) return 0 ;; amd64p32) return 0 ;; esac - log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" + log_crit "Uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" return 1 } untar() { @@ -456,22 +489,142 @@ untar() { *.tar) tar --no-same-owner -xf "${tarball}" ;; *.zip) unzip "${tarball}" ;; *) - log_err "untar unknown archive format for ${tarball}" + log_err "Untar unknown archive format for ${tarball}" return 1 ;; esac } +unbuffered_sed() { + if echo | sed -u -e "" >/dev/null 2>&1; then + sed -nu "$@" + elif echo | sed -l -e "" >/dev/null 2>&1; then + sed -nl "$@" + else + local pad + pad="$(printf "\n%512s" "")" + sed -ne "s/$/\\${pad}/" "$@" + fi +} +print_progress() { + local bytes=$1 + local length=$2 + [ "$length" -gt 0 ] || return 0 + + local width=50 + local percent=$((bytes * 100 / length)) + [ "$percent" -gt 100 ] && percent=100 + local on=$((percent * width / 100)) + local off=$((width - on)) + local filled + local empty + + filled=$(printf "%*s" "$on" "") + filled=${filled// /■} + empty=$(printf "%*s" "$off" "") + empty=${empty// /・} + + printf "\r" >&4 + brand_color_start >&4 + printf "%s%s %3d%%" "$filled" "$empty" "$percent" >&4 + color_reset >&4 +} +print_logo() { + echoerr "" + brand_color_start >&2 + cat >&2 <&2 + echoerr "" +} +should_show_progress() { + [ -t 2 ] || return 1 + is_command mkfifo || return 1 + + case "$1" in + */releases/download/*.tar.gz | */releases/download/*.tgz | */releases/download/*.tar | */releases/download/*.zip) return 0 ;; + *) return 1 ;; + esac +} +http_download_curl_progress() { + local local_file=$1 + local source_url=$2 + local header=$3 + local tmp_dir=${TMPDIR:-/tmp} + local basename="${tmp_dir}/lets_install_$$" + local tracefile="${basename}.trace" + + exec 4>&2 + rm -f "$tracefile" + mkfifo "$tracefile" || { + exec 4>&- + return 1 + } + + printf "\033[?25l" >&4 + trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN + + if [ -z "$header" ]; then + curl --fail --trace-ascii "$tracefile" -sL -o "$local_file" "$source_url" & + else + curl --fail --trace-ascii "$tracefile" -sL -H "$header" -o "$local_file" "$source_url" & + fi + local curl_pid=$! + + unbuffered_sed \ + -e 'y/ACDEGHLNORTV/acdeghlnortv/' \ + -e '/^0000: content-length:/p' \ + -e '/^<= recv data/p' \ + "$tracefile" | + { + local length=0 + local bytes=0 + + while IFS=" " read -r -a line; do + [ "${#line[@]}" -lt 2 ] && continue + + local tag="${line[0]} ${line[1]}" + if [ "$tag" = "0000: content-length:" ]; then + length="${line[2]}" + length=$(echo "$length" | tr -d '\r') + bytes=0 + elif [ "$tag" = "<= recv" ]; then + local size="${line[3]}" + bytes=$((bytes + size)) + if [ "$length" -gt 0 ]; then + print_progress "$bytes" "$length" + fi + fi + done + } + + local ret=0 + wait "$curl_pid" || ret=$? + echo "" >&4 + return "$ret" +} http_download_curl() { local_file=$1 source_url=$2 header=$3 + + if should_show_progress "$source_url"; then + http_download_curl_progress "$@" + return + fi + if [ -z "$header" ]; then code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") else code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") fi if [ "$code" != "200" ]; then - log_debug "http_download_curl received HTTP status $code" + log_debug "Http_download_curl received HTTP status $code" return 1 fi return 0 @@ -487,7 +640,7 @@ http_download_wget() { fi } http_download() { - log_debug "http_download $2" + log_debug "Http_download $2" if is_command curl; then http_download_curl "$@" return @@ -495,7 +648,7 @@ http_download() { http_download_wget "$@" return fi - log_crit "http_download unable to find wget or curl" + log_crit "Http_download unable to find wget or curl" return 1 } http_copy() { @@ -531,7 +684,7 @@ hash_sha256() { hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 echo "$hash" | cut -d ' ' -f a else - log_crit "hash_sha256 unable to find command to compute sha-256 hash" + log_crit "Hash_sha256 unable to find command to compute sha-256 hash" return 1 fi } @@ -539,18 +692,18 @@ hash_sha256_verify() { TARGET=$1 checksums=$2 if [ -z "$checksums" ]; then - log_err "hash_sha256_verify checksum file not specified in arg2" + log_err "Hash_sha256_verify checksum file not specified in arg2" return 1 fi BASENAME=${TARGET##*/} want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) if [ -z "$want" ]; then - log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" + log_err "Hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" return 1 fi got=$(hash_sha256 "$TARGET") if [ "$want" != "$got" ]; then - log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" + log_err "Hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" return 1 fi } @@ -571,7 +724,7 @@ PREFIX="$OWNER/$REPO" # use in logging routines log_prefix() { - echo "$PREFIX" + brand_color "$PREFIX" } PLATFORM="${OS}/${ARCH}" GITHUB_DOWNLOAD=https://github.com/${OWNER}/${REPO}/releases/download @@ -585,15 +738,18 @@ check_old_usr_local_install get_binaries -tag_to_version - adjust_format adjust_os adjust_arch -log_info "found version: ${VERSION} for ${TAG}/${OS}/${ARCH}" +log_message "Detected platform: ${OS}/${ARCH}" +log_message "Fetching latest version..." + +tag_to_version + +log_message "Downloading version ${VERSION}" NAME=${PROJECT_NAME}_${OS}_${ARCH} TARBALL=${NAME}.${FORMAT} @@ -605,3 +761,9 @@ CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM} execute update_shell_profile + +print_logo + +log_message "CLI installed successfully!" +log_message "Run 'lets --help' to get started" +log_message "Visit https://lets-cli.org/docs for documentation" From 3b31dc877aa9e71c17e60dfd3ea96ce7ebdeb6bd Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 25 Apr 2026 22:14:31 +0300 Subject: [PATCH 4/6] Add no-prompt installer env and thread upgrade context --- docs/docs/installation.mdx | 8 ++++++++ install.sh | 4 +++- internal/upgrade/notifier.go | 22 ++++++++++++---------- internal/upgrade/notifier_test.go | 8 ++++---- internal/upgrade/upgrade.go | 2 +- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index aa1d4dc5..c66055c7 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -35,6 +35,14 @@ directory from this list: If none of these directories are in `PATH`, the installer creates `$HOME/.local/bin`, links `lets` there, and adds `$HOME/.local/bin` to your shell profile. +To skip interactive shell profile prompts, set `LETS_INSTALL_NO_PROMPT=1`. This is recommended for CI and other +non-interactive installs. When PATH setup is needed, the installer will use the same non-interactive behavior and +update the detected shell profile. + +```bash +curl -fsSL https://lets-cli.org/install.sh | LETS_INSTALL_NO_PROMPT=1 bash +``` + To install a specific version of `lets` (for example `v0.0.21`): ```bash diff --git a/install.sh b/install.sh index 346040e3..0a5b014d 100755 --- a/install.sh +++ b/install.sh @@ -4,6 +4,7 @@ set -e LETS_HOME="${LETS_HOME:-$HOME/.lets}" BIN_DIR="${LETS_HOME}/bin" LETS_VERSION="${LETS_VERSION:-}" +LETS_INSTALL_NO_PROMPT="${LETS_INSTALL_NO_PROMPT:-}" usage() { this=$1 @@ -17,6 +18,7 @@ Usage: $this [-d] [tag] If tag is missing, then the latest will be used. LETS_VERSION environment variable overrides [tag]. LETS_HOME sets installation home, Defaults to $HOME/.lets + LETS_INSTALL_NO_PROMPT skips interactive shell profile prompts. EOF exit 2 @@ -388,7 +390,7 @@ update_shell_profile() { tilde_profile="${shell_profile/#$HOME/\~}" echo "" - if [ -t 0 ]; then + if [ -t 0 ] && [ -z "$LETS_INSTALL_NO_PROMPT" ]; then read -r -p "Add ~/.local/bin to your PATH in ${tilde_profile}? [y/n] " -n 1 echo "" case "$REPLY" in diff --git a/internal/upgrade/notifier.go b/internal/upgrade/notifier.go index 23649c3e..66c121f5 100644 --- a/internal/upgrade/notifier.go +++ b/internal/upgrade/notifier.go @@ -95,12 +95,12 @@ func (n *UpdateNotifier) Check(ctx context.Context, currentVersion string) (*Upd now := n.now() if now.Sub(state.CheckedAt) < updateCheckInterval { log.Debugf("skip update check: next check at %s", state.CheckedAt.Add(updateCheckInterval)) - return n.noticeFromState(state, currentVersion, current, now), nil + return n.noticeFromState(ctx, state, currentVersion, current, now), nil } release, err := n.registry.GetLatestReleaseInfo(ctx) if err != nil { - return n.noticeFromState(state, currentVersion, current, now), err + return n.noticeFromState(ctx, state, currentVersion, current, now), err } state.CheckedAt = now @@ -111,7 +111,7 @@ func (n *UpdateNotifier) Check(ctx context.Context, currentVersion string) (*Upd return nil, err } - return n.noticeFromState(state, currentVersion, current, now), nil + return n.noticeFromState(ctx, state, currentVersion, current, now), nil } func (n *UpdateNotifier) MarkNotified(notice *UpdateNotice) error { @@ -134,6 +134,7 @@ func (n *UpdateNotifier) MarkNotified(notice *UpdateNotice) error { } func (n *UpdateNotifier) noticeFromState( + ctx context.Context, state notifierState, currentVersion string, current *semver.Version, @@ -154,7 +155,7 @@ func (n *UpdateNotifier) noticeFromState( command := "lets self upgrade" - if isHomebrewInstall(n.executablePath) { + if isHomebrewInstall(ctx, n.executablePath) { if !state.LatestPublishedAt.IsZero() && now.Sub(state.LatestPublishedAt) < homebrewNoticeDelay { return nil } @@ -238,7 +239,7 @@ func parseStableVersion(version string) (*semver.Version, bool) { return parsed, true } -func isHomebrewInstall(binaryPath string) bool { +func isHomebrewInstall(ctx context.Context, binaryPath string) bool { if binaryPath == "" { return false } @@ -254,17 +255,17 @@ func isHomebrewInstall(binaryPath string) bool { } } - brewPrefix, ok := homebrewOutput("--prefix") + brewPrefix, ok := homebrewOutput(ctx, "--prefix") if !ok { return false } - letsPrefix, ok := homebrewOutput("--prefix", "lets") + letsPrefix, ok := homebrewOutput(ctx, "--prefix", "lets") if !ok { return false } - letsCellar, _ := homebrewOutput("--cellar", "lets") + letsCellar, _ := homebrewOutput(ctx, "--cellar", "lets") managedPaths := []string{ filepath.Join(brewPrefix, "bin", "lets"), filepath.Join(letsPrefix, "bin", "lets"), @@ -285,8 +286,8 @@ func isHomebrewInstall(binaryPath string) bool { return false } -func homebrewOutput(args ...string) (string, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) +func homebrewOutput(ctx context.Context, args ...string) (string, bool) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() output, err := exec.CommandContext(ctx, "brew", args...).Output() @@ -295,6 +296,7 @@ func homebrewOutput(args ...string) (string, bool) { } value := strings.TrimSpace(string(output)) + return value, value != "" } diff --git a/internal/upgrade/notifier_test.go b/internal/upgrade/notifier_test.go index aafc439b..1a21211c 100644 --- a/internal/upgrade/notifier_test.go +++ b/internal/upgrade/notifier_test.go @@ -213,7 +213,7 @@ func TestIsHomebrewInstall(t *testing.T) { t.Run("detects cellar path without brew", func(t *testing.T) { t.Setenv("PATH", t.TempDir()) - if !isHomebrewInstall("/opt/homebrew/Cellar/lets/0.0.1/bin/lets") { + if !isHomebrewInstall(context.Background(), "/opt/homebrew/Cellar/lets/0.0.1/bin/lets") { t.Fatal("expected homebrew path to be detected") } }) @@ -221,7 +221,7 @@ func TestIsHomebrewInstall(t *testing.T) { t.Run("ignores generic path without brew", func(t *testing.T) { t.Setenv("PATH", t.TempDir()) - if isHomebrewInstall("/usr/local/bin/lets") { + if isHomebrewInstall(context.Background(), "/usr/local/bin/lets") { t.Fatal("did not expect generic install path to be detected as homebrew") } }) @@ -242,7 +242,7 @@ esac } t.Setenv("PATH", fakeBrewDir) - if !isHomebrewInstall("/opt/homebrew/bin/lets") { + if !isHomebrewInstall(context.Background(), "/opt/homebrew/bin/lets") { t.Fatal("expected brew bin path to be detected") } }) @@ -263,7 +263,7 @@ esac } t.Setenv("PATH", fakeBrewDir) - if !isHomebrewInstall("/opt/homebrew/opt/lets/bin/lets") { + if !isHomebrewInstall(context.Background(), "/opt/homebrew/opt/lets/bin/lets") { t.Fatal("expected brew opt path to be detected") } }) diff --git a/internal/upgrade/upgrade.go b/internal/upgrade/upgrade.go index 28c193a6..94153591 100644 --- a/internal/upgrade/upgrade.go +++ b/internal/upgrade/upgrade.go @@ -41,7 +41,7 @@ func NewBinaryUpgrader(reg registry.RepoRegistry, currentVersion string) (*Binar } func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { - if isHomebrewInstall(up.binaryPath) { + if isHomebrewInstall(ctx, up.binaryPath) { return fmt.Errorf("homebrew-managed lets install must be upgraded with %q", "brew upgrade lets-cli/tap/lets") } From 3f14061e3e2cef34645204f8e329a31d32c57c10 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 25 Apr 2026 22:29:35 +0300 Subject: [PATCH 5/6] Flatten installation docs --- docs/docs/installation.mdx | 169 ++++++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 70 deletions(-) diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index c66055c7..3ec322c1 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -4,20 +4,30 @@ title: Installation sidebar_label: Installation --- +## Homebrew -### Binary +```bash +brew tap lets-cli/tap +brew install lets-cli/tap/lets +``` + +## Arch + +Install the latest binary release from [AUR lets-bin](https://aur.archlinux.org/packages/lets-bin/). -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +If you use `yay` as your AUR helper: - - +```bash +yay -S lets-bin +``` + +You can also install the bleeding edge version from [AUR lets-git](https://aur.archlinux.org/packages/lets-git/). + +```bash +yay -S lets-git +``` + +## Shell Script ```bash curl -fsSL https://lets-cli.org/install.sh | bash @@ -35,9 +45,8 @@ directory from this list: If none of these directories are in `PATH`, the installer creates `$HOME/.local/bin`, links `lets` there, and adds `$HOME/.local/bin` to your shell profile. -To skip interactive shell profile prompts, set `LETS_INSTALL_NO_PROMPT=1`. This is recommended for CI and other -non-interactive installs. When PATH setup is needed, the installer will use the same non-interactive behavior and -update the detected shell profile. +For CI and other non-interactive installs, set `LETS_INSTALL_NO_PROMPT=1` to skip shell profile prompts. When PATH +setup is needed, the installer will use the same non-interactive behavior and update the detected shell profile. ```bash curl -fsSL https://lets-cli.org/install.sh | LETS_INSTALL_NO_PROMPT=1 bash @@ -61,8 +70,7 @@ To install into a custom lets home directory, set `LETS_HOME`. The binary will b curl -fsSL https://lets-cli.org/install.sh | LETS_HOME=$HOME/tools/lets bash ``` - - +## Binary (Manual) Download the version you need for your platform from [Lets Releases](https://github.com/lets-cli/lets/releases). @@ -70,99 +78,120 @@ Once downloaded, the binary can be run from anywhere. Ideally, install it somewhere in your user `PATH`, such as `$HOME/.local/bin`. - - +## GitHub Action +Use [lets-cli/lets-action](https://github.com/lets-cli/lets-action) in a GitHub Actions workflow: -### Package managers +```yaml +- name: Install Lets + uses: lets-cli/lets-action@v1.1 + with: + version: latest +``` - - +## Update -You can get binary release from https://aur.archlinux.org/packages/lets-bin/ +### Self Upgrade -If you are using `yay` as AUR helper: +Starting from version `0.0.30`, lets has a built-in self-upgrade command. + +It updates the binary located at `which lets`. ```bash -yay -S lets-bin +lets self upgrade ``` -Also you can get bleeding edge version from https://aur.archlinux.org/packages/lets-git/ +If your `lets` version is below `0.0.30`, use the shell script and specify the latest version. + +### Homebrew ```bash -yay -S lets-git +brew upgrade lets-cli/tap/lets ``` - - +### Arch + +AUR packages provide the latest version. -TODO +```bash +yay -Syu lets-bin +``` - - +For the bleeding edge package: ```bash -brew tap lets-cli/tap +yay -Syu lets-git ``` +### Shell Script + +Run the install script again to update `lets` to the latest version. + ```bash -brew install lets-cli/tap/lets +curl -fsSL https://lets-cli.org/install.sh | bash ``` - - +To update to a specific version: -## Update +```bash +curl -fsSL https://lets-cli.org/install.sh | LETS_VERSION=v0.0.21 bash +``` + +### Binary + +Download the latest version from [Lets Releases](https://github.com/lets-cli/lets/releases) and replace your existing +`lets` binary. - - +## Uninstall Lets -Starting from version `0.0.30` lets has a built-in self-upgrade command. +### Self Upgrade -It updates binary located at `which lets` +Self upgrade is not a separate install source. If your current binary came from Homebrew or Arch, use the package +manager uninstall instructions below. Otherwise, use the Shell Script or Binary instructions. +### Homebrew ```bash -lets self upgrade +brew uninstall lets-cli/tap/lets ``` -If your `lets` version is below `0.0.30` - use shell script and specify the latest version. +Optionally remove the tap after uninstalling: - - +```bash +brew untap lets-cli/tap +``` -To update `lets` you can use shell script +### Arch +```bash +yay -R lets-bin ``` -curl -fsSL https://lets-cli.org/install.sh | bash + +For the bleeding edge package: + +```bash +yay -R lets-git ``` -Running script will update `lets` to **latest** version. +### Shell Script - - +Remove the install script binary and common PATH symlinks: -You can download latest version from [Lets Releases](https://github.com/lets-cli/lets/releases). +```bash +rm -f "$HOME/.local/bin/lets" "$HOME/bin/lets" "$HOME/.bin/lets" +rm -rf "$HOME/.lets" +``` - - +If you installed with a custom `LETS_HOME`, remove that directory instead of `$HOME/.lets`. -AUR repository always provides **latest** version. +If the installer added `$HOME/.local/bin` to your shell profile, remove the `# lets` block from that profile. - - +### Binary + +Remove the binary from the location where you installed it. + +For example, if it is the active `lets` on your `PATH` and is not managed by Homebrew or Arch: + +```bash +rm -f "$(command -v lets)" +``` From abcbd1b69f894c926ac0129412d5998991a693cc Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sun, 26 Apr 2026 11:46:17 +0300 Subject: [PATCH 6/6] Guard self upgrade install paths --- docs/docs/changelog.md | 1 + docs/docs/installation.mdx | 3 ++ internal/upgrade/upgrade.go | 57 +++++++++++++++++++++++--- internal/upgrade/upgrade_test.go | 69 ++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index f20e8b07..bf4f82d1 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -7,6 +7,7 @@ title: Changelog * `[Changed]` Install script now installs to `$HOME/.lets/bin`, exposes `lets` through a user PATH symlink, and stops on old non-Homebrew `/usr/local/bin/lets` installs. Issue [#121](https://github.com/lets-cli/lets/issues/121) * `[Fixed]` Prevent `lets self upgrade` from overwriting Homebrew-managed installs. Issue [#338](https://github.com/lets-cli/lets/issues/338) +* `[Fixed]` `lets self upgrade` now updates symlink targets and refuses common system-managed install paths. ## [0.0.60](https://github.com/lets-cli/lets/releases/tag/v0.0.60) diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index 3ec322c1..5a01dbae 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -101,6 +101,9 @@ It updates the binary located at `which lets`. lets self upgrade ``` +Self upgrade is intended for installer-managed and manual user-owned installs. If `lets` was installed by Homebrew, +Arch, or another package manager, use that package manager instead. + If your `lets` version is below `0.0.30`, use the shell script and specify the latest version. ### Homebrew diff --git a/internal/upgrade/upgrade.go b/internal/upgrade/upgrade.go index 94153591..0fe17830 100644 --- a/internal/upgrade/upgrade.go +++ b/internal/upgrade/upgrade.go @@ -2,11 +2,14 @@ package upgrade import ( "context" + "errors" "fmt" "io" "os" "path" + "path/filepath" "runtime" + "strings" "github.com/lets-cli/lets/internal/upgrade/registry" log "github.com/sirupsen/logrus" @@ -41,8 +44,9 @@ func NewBinaryUpgrader(reg registry.RepoRegistry, currentVersion string) (*Binar } func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { - if isHomebrewInstall(ctx, up.binaryPath) { - return fmt.Errorf("homebrew-managed lets install must be upgraded with %q", "brew upgrade lets-cli/tap/lets") + binaryPath, err := selfUpgradeBinaryPath(ctx, up.binaryPath) + if err != nil { + return err } latestVersion, err := up.registry.GetLatestRelease(ctx) @@ -73,12 +77,12 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { return fmt.Errorf("failed to download release %s version %s: %w", packageName, latestVersion, err) } - err = backupExecutable(up.binaryPath, up.backupPath) + err = backupExecutable(binaryPath, up.backupPath) if err != nil { return err } - err = replaceBinaries(up.downloadPath, up.binaryPath, up.backupPath) + err = replaceBinaries(up.downloadPath, binaryPath, up.backupPath) if err != nil { return err } @@ -89,10 +93,53 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error { } func binaryPath() (string, error) { - // TODO decide whether self-upgrade should be limited to supported installer-managed paths. return os.Executable() } +func selfUpgradeBinaryPath(ctx context.Context, executablePath string) (string, error) { + if executablePath == "" { + return "", errors.New("failed to determine lets binary path") + } + + binaryPath := filepath.Clean(executablePath) + + resolvedPath := binaryPath + if resolved, err := filepath.EvalSymlinks(binaryPath); err == nil { + resolvedPath = filepath.Clean(resolved) + } + + if isHomebrewInstall(ctx, binaryPath) || isHomebrewInstall(ctx, resolvedPath) { + return "", fmt.Errorf("homebrew-managed lets install must be upgraded with %q", "brew upgrade lets-cli/tap/lets") + } + + if isSystemManagedInstallPath(binaryPath) || isSystemManagedInstallPath(resolvedPath) { + return "", fmt.Errorf( + "system-managed lets install at %s must be upgraded with its package manager or replaced manually", + binaryPath, + ) + } + + return resolvedPath, nil +} + +func isSystemManagedInstallPath(binaryPath string) bool { + binaryPath = filepath.Clean(binaryPath) + dir := filepath.Dir(binaryPath) + + switch dir { + case "/bin", "/sbin", "/usr/bin", "/usr/sbin", "/usr/local/bin", "/opt/local/bin", "/snap/bin": + return true + } + + for _, prefix := range []string{"/nix/store/", "/snap/"} { + if strings.HasPrefix(binaryPath, prefix) { + return true + } + } + + return false +} + func backupExecutable(executablePath string, backupPath string) error { errFmt := func(err error) error { return fmt.Errorf("failed to backup current lets binary: %w", err) diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go index af2a55dc..0cf67f99 100644 --- a/internal/upgrade/upgrade_test.go +++ b/internal/upgrade/upgrade_test.go @@ -146,6 +146,48 @@ func TestSelfUpgrade(t *testing.T) { } }) + t.Run("should self-upgrade symlink target", func(t *testing.T) { + currentVersion := "v0.0.1" + latestVersion := "v0.0.2" + + tempDir := t.TempDir() + targetPath := path.Join(tempDir, ".lets", "bin", "lets") + symlinkPath := path.Join(tempDir, ".local", "bin", "lets") + if err := os.MkdirAll(path.Dir(targetPath), 0o755); err != nil { + t.Fatalf("failed to create target dir: %s", err) + } + if err := os.MkdirAll(path.Dir(symlinkPath), 0o755); err != nil { + t.Fatalf("failed to create symlink dir: %s", err) + } + if err := os.WriteFile(targetPath, []byte(currentVersion), 0o755); err != nil { + t.Fatalf("failed to write target binary: %s", err) + } + if err := os.Symlink(targetPath, symlinkPath); err != nil { + t.Fatalf("failed to create binary symlink: %s", err) + } + + upgrader := &BinaryUpgrader{ + registry: &MockRegistry{latestVersion: latestVersion}, + currentVersion: currentVersion, + binaryPath: symlinkPath, + downloadPath: path.Join(tempDir, "lets.download"), + backupPath: path.Join(tempDir, "lets.backup"), + } + + err := upgrader.Upgrade(context.Background()) + if err != nil { + t.Fatalf("failed to upgrade symlink target: %s", err) + } + + if !testVersion(targetPath, latestVersion) { + t.Errorf("expected target version %s", latestVersion) + } + + if linkTarget, err := os.Readlink(symlinkPath); err != nil || linkTarget != targetPath { + t.Fatalf("expected symlink to remain pointed at %s, got %q, err %v", targetPath, linkTarget, err) + } + }) + t.Run("should not self-upgrade homebrew-managed binary", func(t *testing.T) { currentVersion := "v0.0.1" latestVersion := "v0.0.2" @@ -185,4 +227,31 @@ func TestSelfUpgrade(t *testing.T) { t.Fatalf("expected no downloaded binary, got err %v", err) } }) + + t.Run("should not self-upgrade system-managed binary", func(t *testing.T) { + currentVersion := "v0.0.1" + latestVersion := "v0.0.2" + + tempDir := t.TempDir() + upgrader := &BinaryUpgrader{ + registry: &MockRegistry{latestVersion: latestVersion}, + currentVersion: currentVersion, + binaryPath: "/usr/bin/lets", + downloadPath: path.Join(tempDir, "lets.download"), + backupPath: path.Join(tempDir, "lets.backup"), + } + + err := upgrader.Upgrade(context.Background()) + if err == nil { + t.Fatal("expected system-managed upgrade error") + } + + if !strings.Contains(err.Error(), "system-managed lets install") { + t.Fatalf("expected system-managed error, got %q", err.Error()) + } + + if _, err := os.Stat(upgrader.downloadPath); !os.IsNotExist(err) { + t.Fatalf("expected no downloaded binary, got err %v", err) + } + }) }