diff --git a/.github/workflows/runtime-tests.yml b/.github/workflows/runtime-tests.yml new file mode 100644 index 0000000..6fd1282 --- /dev/null +++ b/.github/workflows/runtime-tests.yml @@ -0,0 +1,44 @@ +name: Runtime tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Test runtime installer + run: bash tests/install-runtime-test.sh + + - name: Test runtime integration + run: bash tests/runtime-integration-test.sh + + - name: Check script syntax + shell: bash + run: | + for file in scripts/*.sh tests/*.sh; do + bash -n "$file" + done + for file in scripts/install-runtime.sh scripts/runtime-versions.sh; do + /bin/sh -n "$file" + done + python3 -m compileall -q scripts diff --git a/.gitignore b/.gitignore index b88fcf7..b301598 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/bin/ scripts/__pycache__/ demo/out*/ .DS_Store diff --git a/DESIGN.md b/DESIGN.md index 61bec6b..62da857 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -132,7 +132,33 @@ Two supporting conventions: - The settings interface always displays the message "Changes apply automatically when you return to the lazygit pane (hot reload)" to set user expectations. - `run-lazygit.sh` also runs the generator (idempotently) immediately before `exec`, ensuring cold starts and hot reloads see the same generated layer. There is one generation path and no competing source of truth. -## 6. Strategic Boundaries and Stop Signals +## 6. Reproducible Runtime Packaging + +The plugin treats lazygit and fzf as part of its tested runtime, not as mutable +system dependencies. A GitHub install runs `scripts/install-runtime.sh`, which +maps macOS/Linux and x86_64/ARM64 to pinned upstream release archives, verifies +repository-pinned SHA-256 digests, and writes both executables to the managed +plugin checkout's `bin/` directory. Runtime scripts resolve those files by +absolute path through `runtime-env.sh`; they never invoke a same-named binary +from the user's `PATH`. + +This is deliberately different from package-manager bootstrapping: + +- installation never invokes Homebrew, apt, dnf, pacman, or `sudo`; +- the same plugin version uses the same lazygit/fzf versions everywhere; +- key-conflict analysis and generated configuration target the binary that + actually runs; +- reinstalling the plugin atomically replaces its managed checkout and runtime; +- `plugin link` remains a development operation and requires running + `scripts/install-runtime.sh` manually because Herdr does not execute build + commands for linked plugins. + +The remaining host requirements are Herdr, Bash, Git, Python >= 3.7, standard +archive/hash utilities, and either curl or wget. Python is an explicit runtime +requirement because pane geometry, JSON handling, locking, timeout handling, +and key analysis use its standard library. + +## 7. Strategic Boundaries and Stop Signals **Out of scope** (unreachable through the lazygit approach and therefore fake integration): diff --git a/README.md b/README.md index ae91b7d..83cc3f7 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,15 @@ One of these CLIs installed and logged in: `claude`, `codex`, `opencode`, `gemin ## Install -Requires herdr >= 0.7.0. `lazygit` and `fzf` are checked at install time and installed via Homebrew if missing. +Requires herdr >= 0.7.0 plus `bash`, `git`, and Python >= 3.7 (`python3`) on `PATH`. During a GitHub install, the plugin downloads pinned private copies of lazygit 0.63.0 and fzf 0.74.0, verifies repository-pinned SHA-256 digests, and stores them under its managed `bin/` directory. It never invokes Homebrew, a system package manager, or `sudo`. The build also needs `curl` or `wget`, `tar`, and `sha256sum` or `shasum`. ```sh herdr plugin install crokily/herdr-lazygit -# Or link a local checkout during development -herdr plugin link /path/to/herdr-lazygit +# Local development: plugin link does not run [[build]], so prepare the runtime first. +cd /path/to/herdr-lazygit +/bin/sh scripts/install-runtime.sh +herdr plugin link "$PWD" ``` Then add keybindings to your herdr `config.toml`: @@ -70,18 +72,18 @@ Then add keybindings to your herdr `config.toml`: ```toml [[keys.command]] # lazygit: open in a split key = "prefix+g" -type = "shell" -command = "herdr plugin action invoke open --plugin herdr-lazygit" +type = "plugin_action" +command = "herdr-lazygit.open" [[keys.command]] # lazygit: open in its own tab key = "prefix+shift+g" -type = "shell" -command = "herdr plugin action invoke open-tab --plugin herdr-lazygit" +type = "plugin_action" +command = "herdr-lazygit.open-tab" ``` Run `herdr server reload-config`. `prefix+g` then behaves as: not open → open in a split; open but unfocused → focus; focused → close. -> **Note (herdr platform behavior):** an action's context always resolves from the pane that currently has **UI focus**, not from the process that invoked it. Invoking `herdr plugin action invoke …` from a background pane or script opens lazygit next to the user's focused pane, takes its cwd from that pane, and steals focus. Trigger these actions only through foreground keybindings. +> **Note (herdr platform behavior):** an action's context always resolves from the pane that currently has **UI focus**, not from a background process. The action opens lazygit next to the user's focused pane, takes its cwd from that pane, and focuses the new pane. Trigger these actions only through foreground keybindings. ## Reference @@ -124,9 +126,12 @@ To remap a plugin key, use the settings pane (stored in `keys.conf`). To remap a herdr-plugin.toml # plugin manifest lazygit-config.yml # bundled base config (factory layer) DESIGN.md # design doc: three-verb model, key rules, config layers +THIRD_PARTY_NOTICES.md # licenses for downloaded lazygit/fzf binaries +bin/ # generated private lazygit + fzf runtime (not committed) scripts/ - ensure-lazygit.sh # install-time: detect/install lazygit - ensure-fzf.sh # install-time: detect/install fzf (settings + commit UI) + install-runtime.sh # install-time: download + verify the private runtime + runtime-versions.sh # pinned lazygit/fzf versions + runtime-env.sh # resolve runtime tools by absolute path run-lazygit.sh # pane entrypoint: regenerate config layer, exec lazygit open-lazygit.sh # action: open in a split (idempotent open/focus/toggle) open-lazygit-tab.sh # action: open in a tab diff --git a/README.zh-CN.md b/README.zh-CN.md index d35a237..525ae4f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,13 +56,15 @@ ## 安装 -要求 herdr >= 0.7.0。`lazygit` 和 `fzf` 在插件安装时自动检测,缺失时通过 Homebrew 安装。 +要求 herdr >= 0.7.0,并确保 `bash`、`git`、Python >= 3.7(`python3`)在 `PATH` 上。通过 GitHub 安装时,插件会下载固定版本的私有 lazygit 0.63.0 与 fzf 0.74.0,用仓库内固定的 SHA-256 校验值验证后存入 Herdr 管理的插件 `bin/` 目录。安装过程不会调用 Homebrew、系统包管理器或 `sudo`。构建阶段还需要 `curl` 或 `wget`、`tar`,以及 `sha256sum` 或 `shasum`。 ```sh herdr plugin install crokily/herdr-lazygit -# 或本地开发时软链 -herdr plugin link /path/to/herdr-lazygit +# 本地开发:plugin link 不执行 [[build]],所以先准备私有 runtime +cd /path/to/herdr-lazygit +/bin/sh scripts/install-runtime.sh +herdr plugin link "$PWD" ``` 然后在 herdr 的 `config.toml` 里添加快捷键: @@ -70,18 +72,18 @@ herdr plugin link /path/to/herdr-lazygit ```toml [[keys.command]] # lazygit:分屏打开 key = "prefix+g" -type = "shell" -command = "herdr plugin action invoke open --plugin herdr-lazygit" +type = "plugin_action" +command = "herdr-lazygit.open" [[keys.command]] # lazygit:独立 tab 打开 key = "prefix+shift+g" -type = "shell" -command = "herdr plugin action invoke open-tab --plugin herdr-lazygit" +type = "plugin_action" +command = "herdr-lazygit.open-tab" ``` 执行 `herdr server reload-config` 后生效。`prefix+g` 的行为:未打开 → 分屏打开;已打开但未聚焦 → 聚焦;已聚焦 → 关闭。 -> **注意(herdr 平台行为)**:action 的上下文永远取自 herdr 当前 **UI 聚焦**的 pane,而不是发起调用的进程。从后台 pane 或脚本执行 `herdr plugin action invoke …` 会把 lazygit 开在用户聚焦 pane 旁边、使用那个 pane 的 cwd,并夺走焦点。只通过前台键位绑定触发这两个 action。 +> **注意(herdr 平台行为)**:action 的上下文永远取自 herdr 当前 **UI 聚焦**的 pane,而不是后台进程。它会把 lazygit 开在用户聚焦 pane 旁边、使用该 pane 的 cwd,并聚焦新 pane。只通过前台键位绑定触发这两个 action。 ## 细节备查 @@ -124,9 +126,12 @@ AI_CUSTOM_CMD="" herdr-plugin.toml # 插件 manifest lazygit-config.yml # 捆绑的基础配置(出厂层) DESIGN.md # 设计文档:三动词模型、选键规则、三层配置 +THIRD_PARTY_NOTICES.md # 下载的 lazygit/fzf 二进制许可证 +bin/ # 构建生成的私有 lazygit + fzf runtime(不提交) scripts/ - ensure-lazygit.sh # 安装时检测/安装 lazygit - ensure-fzf.sh # 安装时检测/安装 fzf(设置页 + commit UI) + install-runtime.sh # 安装时下载并校验私有 runtime + runtime-versions.sh # 固定的 lazygit/fzf 版本 + runtime-env.sh # 用绝对路径解析 runtime 工具 run-lazygit.sh # pane 入口:重新生成配置层后 exec lazygit open-lazygit.sh # action:分屏打开(幂等 open/focus/toggle) open-lazygit-tab.sh # action:tab 打开 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9c91351 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,57 @@ +# Third-party notices + +`herdr-lazygit` downloads the following pinned upstream binaries into its +Herdr-managed `bin/` directory during plugin installation. The binaries are not +committed to this repository. + +## lazygit 0.63.0 + +Source: + +MIT License + +Copyright (c) 2018 Jesse Duffield + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## fzf 0.74.0 + +Source: + +The MIT License (MIT) + +Copyright (c) 2013-2026 Junegunn Choi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 62b8a22..180431c 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -6,20 +6,16 @@ id = "herdr-lazygit" name = "herdr-lazygit" -version = "0.1.0" +version = "0.1.1" description = "lazygit in a herdr split pane, with AI-assisted commit messages via custom commands." min_herdr_version = "0.7.0" platforms = ["linux", "macos"] -# Install-time build step: make sure the `lazygit` binary is available -# (install via Homebrew when possible, otherwise print instructions and fail). +# Install the pinned, plugin-private lazygit + fzf runtime for this platform. +# The build downloads official release archives, verifies repository-pinned +# SHA-256 digests, and writes only under this managed checkout's bin/ directory. [[build]] -command = ["/bin/sh", "scripts/ensure-lazygit.sh"] - -# Install-time build step: make sure `fzf` is available (the settings and AI -# commit pane menus are built on it). -[[build]] -command = ["/bin/sh", "scripts/ensure-fzf.sh"] +command = ["/bin/sh", "scripts/install-runtime.sh"] # The lazygit pane. The [[actions]] below summon it; run-lazygit.sh assembles the # layered LG_CONFIG_FILE (bundled config + user override) and then execs lazygit. diff --git a/scripts/ai-commit-pane.sh b/scripts/ai-commit-pane.sh index c4e68fa..9471178 100755 --- a/scripts/ai-commit-pane.sh +++ b/scripts/ai-commit-pane.sh @@ -10,6 +10,9 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# shellcheck disable=SC1091 +. "$script_dir/runtime-env.sh" +FZF_BIN="$HERDR_LAZYGIT_FZF_BIN" AI_SH="$script_dir/ai-commit-msg.sh" repo="${1:-$(pwd)}" config_dir="${HERDR_PLUGIN_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/herdr-lazygit}" @@ -64,15 +67,8 @@ fi clear printf 'herdr-lazygit · AI Commit\nBackend: %s · Model: %s\n\n' "$backend_text" "$model" -if ! command -v fzf >/dev/null 2>&1; then - cat >&2 <<'EOF' -fzf is required for the AI commit candidate and message editor. - -Install it with: - brew install fzf - -Press any key to close... -EOF +if ! herdr_lazygit_require_runtime fzf; then + printf '\nPress any key to close...' >&2 IFS= read -rsn1 _ || true exit 1 fi @@ -171,7 +167,7 @@ preview_cmd='msg={}; if [ -n "$msg" ]; then printf "\033[1m✏ %s\033[0m\n\n" "$ fzf_out="" fzf_rc=0 -fzf_out="$(fzf --layout=reverse --no-multi --print-query \ +fzf_out="$("$FZF_BIN" --layout=reverse --no-multi --print-query \ --prompt='Commit message > ' \ --header='Enter=commit · type to edit/write · Esc=cancel' \ --wrap --gap 1 --gap-line --highlight-line \ diff --git a/scripts/ensure-fzf.sh b/scripts/ensure-fzf.sh deleted file mode 100755 index 66fc548..0000000 --- a/scripts/ensure-fzf.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/sh -# [[build]] step: make sure `fzf` is available (UI for Settings and the AI -# commit pane). Called by the [[build]] entry in herdr-plugin.toml as -# ["/bin/sh", "scripts/ensure-fzf.sh"] (the manifest entry is appended through -# builder-keymap coordination), so keep this POSIX sh-compatible (no bashisms). -# - fzf is on PATH -> skip -# - Homebrew exists -> brew install fzf -# - neither exists -> print installation instructions and fail the build -set -eu - -if command -v fzf >/dev/null 2>&1; then - echo "fzf found: $(command -v fzf)" - exit 0 -fi - -if command -v brew >/dev/null 2>&1; then - echo "fzf not found; installing with Homebrew..." - brew install fzf - echo "fzf installed: $(command -v fzf)" - exit 0 -fi - -cat >&2 <<'EOF' -error: fzf is not installed and Homebrew is not available. - -Install fzf manually, then re-run the plugin install: - - macOS: install Homebrew (https://brew.sh), then: brew install fzf - - Debian/Ubuntu: apt install fzf - - Arch: pacman -S fzf - - Fedora: dnf install fzf - - Any platform: download a release binary from - https://github.com/junegunn/fzf/releases - and place it on your PATH -EOF -exit 1 diff --git a/scripts/ensure-lazygit.sh b/scripts/ensure-lazygit.sh deleted file mode 100755 index 47145f1..0000000 --- a/scripts/ensure-lazygit.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -# [[build]] step: make sure the `lazygit` binary is available. -# Runs at plugin install time via ["/bin/sh", "scripts/ensure-lazygit.sh"], -# so this stays POSIX sh (no bashisms). -# - lazygit already on PATH -> nothing to do -# - Homebrew available -> brew install lazygit -# - neither -> print install instructions and fail the build -set -eu - -if command -v lazygit >/dev/null 2>&1; then - echo "lazygit found: $(command -v lazygit)" - exit 0 -fi - -if command -v brew >/dev/null 2>&1; then - echo "lazygit not found; installing with Homebrew..." - brew install lazygit - echo "lazygit installed: $(command -v lazygit)" - exit 0 -fi - -cat >&2 <<'EOF' -error: lazygit is not installed and Homebrew is not available. - -Install lazygit manually, then re-run the plugin install: - - macOS: install Homebrew (https://brew.sh), then: brew install lazygit - - Debian/Ubuntu: see https://github.com/jesseduffield/lazygit#ubuntu - - Arch: pacman -S lazygit - - Fedora: dnf copr enable atim/lazygit -y && dnf install lazygit - - Any platform: download a release binary from - https://github.com/jesseduffield/lazygit/releases - and place it on your PATH -EOF -exit 1 diff --git a/scripts/free-keys.py b/scripts/free-keys.py index cce1fb7..23ac36c 100755 --- a/scripts/free-keys.py +++ b/scripts/free-keys.py @@ -4,8 +4,8 @@ keys for the herdr-lazygit plugin. Data source: the keybinding section of the default configuration printed by -`lazygit --config` (following the installed lazygit version rather than a -hard-coded key table). Uses only the standard library and includes a small +the plugin-private `lazygit --config` (following the pinned runtime rather than +a hard-coded key table). Uses only the standard library and includes a small indentation-based parser for that output instead of PyYAML. Usage: @@ -42,10 +42,14 @@ → ; ✓ (free in all panels) => default ; """ +import os import re import subprocess import sys +PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +PRIVATE_LAZYGIT = os.path.join(PLUGIN_ROOT, "bin", "lazygit") + # customCommands context name -> keybinding section name. # subCommits / reflogCommits share keybinding.commits with commits; several # branch-sidebar contexts share keybinding.branches. @@ -107,13 +111,14 @@ def load_bindings(): flow lists [a, b], and block lists with `- item`. """ try: + lazygit = os.environ.get("HERDR_LAZYGIT_BIN") or PRIVATE_LAZYGIT out = subprocess.run( - ["lazygit", "--config"], + [lazygit, "--config"], capture_output=True, text=True, check=True, ).stdout except (OSError, subprocess.CalledProcessError) as e: - sys.stderr.write("free-keys.py: unable to run `lazygit --config` (%s); " - "run scripts/ensure-lazygit.sh first\n" % e) + sys.stderr.write("free-keys.py: unable to run the plugin lazygit runtime (%s); " + "run scripts/install-runtime.sh first\n" % e) sys.exit(2) sections = {} diff --git a/scripts/gen-config-layer.sh b/scripts/gen-config-layer.sh index 004953d..ae21698 100755 --- a/scripts/gen-config-layer.sh +++ b/scripts/gen-config-layer.sh @@ -34,6 +34,12 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# Use the same pinned lazygit binary as the pane when checking built-in key +# conflicts, including when this generator is run directly during development. +# shellcheck disable=SC1091 +. "$script_dir/runtime-env.sh" +# shellcheck disable=SC1091 +. "$script_dir/runtime-versions.sh" config_dir="${HERDR_PLUGIN_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/herdr-lazygit}" mkdir -p "$config_dir" @@ -105,12 +111,16 @@ valid_key "$k_settings" || { warn_bad_key KEY_SETTINGS "$k_settings" "$def_setti # generation also skips). Comparing the "# keys: ..." header marker is # independent of write time and eliminates this problem. It also covers deleting # keys.conf to restore defaults (the marker then becomes the default-key line). -# Changes to this script or free-keys.py still trigger regeneration via -nt. -marker="# keys: $k_commit $k_zoom $k_settings | layout: $layout_mode" +# The lazygit version participates in the marker because built-in key conflicts +# can change between releases. Script/runtime mtimes also invalidate a persisted +# generated layer after a plugin reinstall or local runtime rebuild. +marker="# keys: $k_commit $k_zoom $k_settings | layout: $layout_mode | lazygit: $LAZYGIT_VERSION" if [ -f "$out" ] \ && grep -qxF "$marker" "$out" 2>/dev/null \ && [ ! "$self" -nt "$out" ] \ - && [ ! "$script_dir/free-keys.py" -nt "$out" ]; then + && [ ! "$script_dir/free-keys.py" -nt "$out" ] \ + && [ ! "$script_dir/runtime-versions.sh" -nt "$out" ] \ + && [ ! "$HERDR_LAZYGIT_BIN" -nt "$out" ]; then exit 0 fi @@ -158,10 +168,10 @@ tmp="$out.tmp.$$" cat <<'EOF' # generated.yml — machine-generated by gen-config-layer.sh; do not edit EOF - # Marker line: record generation keys so the cache can detect "keys.conf was - # deleted but this file still contains nondefault keys." - printf '# keys: %s %s %s | layout: %s\n' \ - "$k_commit" "$k_zoom" "$k_settings" "$layout_mode" + # Marker line: record generation inputs so the cache detects deleted key + # overrides and lazygit runtime upgrades. + printf '# keys: %s %s %s | layout: %s | lazygit: %s\n' \ + "$k_commit" "$k_zoom" "$k_settings" "$layout_mode" "$LAZYGIT_VERSION" cat <<'EOF' # # Change keys through Settings (press the Settings key in lazygit), or edit diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh new file mode 100755 index 0000000..bec38f6 --- /dev/null +++ b/scripts/install-runtime.sh @@ -0,0 +1,242 @@ +#!/bin/sh +# herdr [[build]] step: install the plugin-private lazygit and fzf runtime. +# +# Herdr runs build commands in the managed checkout before registering it. We +# download pinned upstream release archives for the current OS/architecture, +# verify repository-pinned SHA-256 digests, and place the binaries under bin/. +# Nothing is installed globally and no package manager or sudo is invoked. +# +# Supported targets: +# macOS: x86_64, arm64 +# Linux: x86_64, arm64/aarch64 +# +# Build commands do not receive HERDR_PLUGIN_ROOT, so resolve the checkout from +# this script. Keep this file POSIX sh-compatible: Herdr invokes it with /bin/sh. +set -eu + +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +plugin_root=$(CDPATH= cd "$script_dir/.." && pwd) +# shellcheck disable=SC1091 +. "$script_dir/runtime-versions.sh" + +bin_dir="$plugin_root/bin" +lazygit_base_url="${HERDR_LAZYGIT_LAZYGIT_BASE_URL:-https://github.com/jesseduffield/lazygit/releases/download}" +fzf_base_url="${HERDR_LAZYGIT_FZF_BASE_URL:-https://github.com/junegunn/fzf/releases/download}" + +have() { command -v "$1" >/dev/null 2>&1; } +fatal() { + printf 'herdr-lazygit: %s\n' "$*" >&2 + exit 1 +} + +for command_name in bash git python3 tar; do + have "$command_name" || fatal "$command_name is required on PATH" +done +python3 -c 'import sys; raise SystemExit(sys.version_info < (3, 7))' \ + || fatal "python3 3.7 or newer is required" +if ! have curl && ! have wget; then + fatal "curl or wget is required to download the private runtime" +fi +if ! have sha256sum && ! have shasum; then + fatal "sha256sum or shasum is required to verify runtime downloads" +fi + +os_raw=$(uname -s 2>/dev/null || printf unknown) +arch_raw=$(uname -m 2>/dev/null || printf unknown) +case "$os_raw" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) fatal "unsupported operating system: $os_raw (supported: macOS, Linux)" ;; +esac +case "$arch_raw" in + x86_64|amd64) + lazygit_arch=x86_64 + fzf_arch=amd64 + ;; + arm64|aarch64) + lazygit_arch=arm64 + fzf_arch=arm64 + ;; + *) fatal "unsupported architecture: $arch_raw (supported: x86_64, arm64/aarch64)" ;; +esac + +lazygit_asset="lazygit_${LAZYGIT_VERSION}_${os}_${lazygit_arch}.tar.gz" +fzf_asset="fzf-${FZF_VERSION}-${os}_${fzf_arch}.tar.gz" + +# Digests copied from the upstream v0.63.0 and v0.74.0 checksum files. Keeping +# them in the repository means a compromised or corrupted archive cannot be +# trusted merely because a checksum sidecar came from the same download host. +expected_checksum() { + case "$1" in + lazygit:darwin:arm64) printf '%s\n' '60e6bf29a1501a57a9d078538aa576a1b4db45779db2e3dd6931a7207f560a9c' ;; + lazygit:darwin:x86_64) printf '%s\n' '304b1bf7f7bbb5a5d59e34145bce63d42733cd828e4fe41428ced9ee4dbfe942' ;; + lazygit:linux:arm64) printf '%s\n' 'aac147abf5ce43afe6ae8bcb14b0d479111975a189302d7a99386deca70d57f7' ;; + lazygit:linux:x86_64) printf '%s\n' 'cf5cfa3e116d7775f3600a51ec1d9ce7ba554a08b9566c7c2da83cb0023efabf' ;; + fzf:darwin:arm64) printf '%s\n' 'da60e8980e4239a0fc5f1fcfe873f243dfda93a6a13b696b00e1dc8584a77a87' ;; + fzf:darwin:amd64) printf '%s\n' 'e2c470f058ac18615f54c0bebe0fd2956f2aa8e306a11621783a00aaa386eedd' ;; + fzf:linux:arm64) printf '%s\n' 'bd9e6165ebdb702215d42368cbb95b8dd70a4e77ee97925adac8c31660e30ef7' ;; + fzf:linux:amd64) printf '%s\n' 'cf919f05b7581b4c744d764eaa704665d61dd6d3ca785f0df2351281dff60cda' ;; + *) return 1 ;; + esac +} + +fetch() { + url=$1 + destination=$2 + if have curl; then + curl -fsSL --retry 3 --retry-delay 1 -o "$destination" "$url" + else + wget -q -O "$destination" "$url" + fi +} + +sha256_of() { + if have sha256sum; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/herdr-lazygit-runtime.XXXXXX") \ + || fatal "could not create a temporary directory" +stage="" +backup="" +publish_complete=0 +had_lazygit=0 +had_fzf=0 +published_lazygit=0 +published_fzf=0 +cleanup() { + status=$? + rollback_failed=0 + trap - EXIT HUP INT TERM + + if [ "$publish_complete" -ne 1 ] && [ -n "$backup" ]; then + if [ "$published_lazygit" -eq 1 ]; then + if ! rm -f "$bin_dir/lazygit"; then + printf 'herdr-lazygit: failed to remove the partial lazygit update\n' >&2 + rollback_failed=1 + fi + fi + if [ "$published_fzf" -eq 1 ]; then + if ! rm -f "$bin_dir/fzf"; then + printf 'herdr-lazygit: failed to remove the partial fzf update\n' >&2 + rollback_failed=1 + fi + fi + if [ "$had_lazygit" -eq 1 ] && [ -f "$backup/lazygit" ]; then + if ! mv -f "$backup/lazygit" "$bin_dir/lazygit"; then + printf 'herdr-lazygit: failed to restore the previous lazygit binary\n' >&2 + rollback_failed=1 + fi + fi + if [ "$had_fzf" -eq 1 ] && [ -f "$backup/fzf" ]; then + if ! mv -f "$backup/fzf" "$bin_dir/fzf"; then + printf 'herdr-lazygit: failed to restore the previous fzf binary\n' >&2 + rollback_failed=1 + fi + fi + fi + + [ -z "$stage" ] || rm -rf "$stage" || : + if [ -n "$backup" ]; then + if [ "$publish_complete" -eq 1 ] || [ "$rollback_failed" -eq 0 ]; then + rm -rf "$backup" || : + else + printf 'herdr-lazygit: rollback incomplete; recovery binaries retained in %s\n' \ + "$backup" >&2 + fi + fi + rm -rf "$tmpdir" || : + exit "$status" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +# Keep executable staging on the destination filesystem. Hardened Linux hosts +# commonly mount /tmp with noexec; downloads and extraction can stay there, but +# smoke-testing the runtime must happen where the installed binaries will run. +mkdir -p "$bin_dir" || fatal "could not create runtime directory: $bin_dir" +for destination in "$bin_dir/lazygit" "$bin_dir/fzf"; do + if [ -L "$destination" ]; then + fatal "runtime destination is not a regular file: $destination" + elif [ -e "$destination" ]; then + [ -f "$destination" ] \ + || fatal "runtime destination is not a regular file: $destination" + fi +done +stage=$(mktemp -d "$bin_dir/.runtime-stage.XXXXXX") \ + || fatal "could not create runtime staging under $bin_dir" + +install_archive() { + tool=$1 + asset=$2 + url=$3 + expected=$4 + archive="$tmpdir/$asset" + extract_dir="$tmpdir/extract-$tool" + + printf 'herdr-lazygit: downloading %s\n' "$asset" + fetch "$url" "$archive" || fatal "failed to download $url" + + actual=$(sha256_of "$archive") || fatal "failed to hash $asset" + [ "$actual" = "$expected" ] \ + || fatal "checksum mismatch for $asset (expected $expected, got $actual)" + + mkdir -p "$extract_dir" + tar -xzf "$archive" -C "$extract_dir" \ + || fatal "failed to extract $asset" + [ -f "$extract_dir/$tool" ] \ + || fatal "$asset did not contain the expected $tool binary" + cp "$extract_dir/$tool" "$stage/$tool" + chmod 0755 "$stage/$tool" +} + +lazygit_checksum=$(expected_checksum "lazygit:$os:$lazygit_arch") \ + || fatal "no lazygit checksum for $os/$lazygit_arch" +fzf_checksum=$(expected_checksum "fzf:$os:$fzf_arch") \ + || fatal "no fzf checksum for $os/$fzf_arch" + +install_archive \ + lazygit "$lazygit_asset" \ + "$lazygit_base_url/v${LAZYGIT_VERSION}/$lazygit_asset" \ + "$lazygit_checksum" +install_archive \ + fzf "$fzf_asset" \ + "$fzf_base_url/v${FZF_VERSION}/$fzf_asset" \ + "$fzf_checksum" + +"$stage/lazygit" --version >/dev/null 2>&1 \ + || fatal "the downloaded lazygit binary cannot run on $os_raw/$arch_raw" +"$stage/fzf" --version >/dev/null 2>&1 \ + || fatal "the downloaded fzf binary cannot run on $os_raw/$arch_raw" + +# Replace both tools only after both downloads have verified, extracted, and +# executed successfully from the destination filesystem. Preserve the prior +# pair until publication completes so a failed second rename can roll back the +# first instead of leaving a mixed runtime. +backup=$(mktemp -d "$bin_dir/.runtime-backup.XXXXXX") \ + || fatal "could not create runtime rollback directory under $bin_dir" +if [ -f "$bin_dir/lazygit" ]; then + had_lazygit=1 + mv "$bin_dir/lazygit" "$backup/lazygit" +fi +if [ -f "$bin_dir/fzf" ]; then + had_fzf=1 + mv "$bin_dir/fzf" "$backup/fzf" +fi + +published_lazygit=1 +mv "$stage/lazygit" "$bin_dir/lazygit" +published_fzf=1 +mv "$stage/fzf" "$bin_dir/fzf" +[ -f "$bin_dir/lazygit" ] && [ -x "$bin_dir/lazygit" ] \ + || fatal "failed to publish the lazygit runtime binary" +[ -f "$bin_dir/fzf" ] && [ -x "$bin_dir/fzf" ] \ + || fatal "failed to publish the fzf runtime binary" +publish_complete=1 + +printf 'herdr-lazygit: installed private runtime in %s\n' "$bin_dir" +printf ' lazygit %s (%s/%s)\n' "$LAZYGIT_VERSION" "$os" "$lazygit_arch" +printf ' fzf %s (%s/%s)\n' "$FZF_VERSION" "$os" "$fzf_arch" diff --git a/scripts/layout-helper.py b/scripts/layout-helper.py index b92d117..75f0988 100644 --- a/scripts/layout-helper.py +++ b/scripts/layout-helper.py @@ -44,7 +44,8 @@ def rpc(method, params): def cli(*args): - out = subprocess.run(["herdr", *args], capture_output=True, text=True) + herdr = os.environ.get("HERDR_BIN_PATH", "herdr") + out = subprocess.run([herdr, *args], capture_output=True, text=True) return json.loads(out.stdout)["result"] diff --git a/scripts/open-ai-commit-pane.sh b/scripts/open-ai-commit-pane.sh index 7bc37f1..d732978 100755 --- a/scripts/open-ai-commit-pane.sh +++ b/scripts/open-ai-commit-pane.sh @@ -5,8 +5,8 @@ # pane: # - one GitCommit instance per tab # - open a COMMIT_COLS-wide pane to the right of the sidebar -# - explicitly pass the configuration directory and repository cwd to the -# new shell +# - explicitly pass the Herdr binary, configuration directory, and repository +# cwd to the new shell # - restore the sidebar/expanded width from invocation before the UI exits; # exit then closes the pane automatically # @@ -14,6 +14,7 @@ set -euo pipefail [ "${HERDR_ENV:-}" = "1" ] || { echo "open-ai-commit-pane.sh: not inside herdr" >&2; exit 1; } +herdr_bin="${HERDR_BIN_PATH:-herdr}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" helper="$script_dir/layout-helper.py" @@ -48,7 +49,7 @@ case "$COMMIT_COLS" in *[!0-9]*|'') COMMIT_COLS=70 ;; esac restore_cols="$SIDEBAR_COLS" if [ "$LAYOUT_MODE" = "expanded" ]; then restore_cols="$EXPAND_COLS" - tab_width="$(herdr pane layout --pane "$HERDR_PANE_ID" 2>/dev/null | python3 -c ' + tab_width="$("$herdr_bin" pane layout --pane "$HERDR_PANE_ID" 2>/dev/null | python3 -c ' import json, sys try: print(int(json.load(sys.stdin)["result"]["layout"]["area"]["width"])) @@ -63,7 +64,7 @@ except Exception: fi # --- Single instance: close an existing GitCommit pane in this tab ---------- -panes_json="$(herdr pane list 2>/dev/null || true)" +panes_json="$("$herdr_bin" pane list 2>/dev/null || true)" printf '%s' "$panes_json" | python3 -c ' import json, sys tab = sys.argv[1] @@ -74,20 +75,20 @@ except Exception: for p in panes: if p.get("tab_id") == tab and p.get("label") == "GitCommit": print(p["pane_id"]) -' "$HERDR_TAB_ID" | while read -r old; do herdr pane close "$old" >/dev/null 2>&1 || true; done +' "$HERDR_TAB_ID" | while read -r old; do "$herdr_bin" pane close "$old" >/dev/null 2>&1 || true; done # --- Open the AI commit pane and apply its geometry ------------------------- -new_pane="$(herdr pane split --pane "$HERDR_PANE_ID" --direction right --ratio 0.5 --focus 2>/dev/null \ +new_pane="$("$herdr_bin" pane split --pane "$HERDR_PANE_ID" --direction right --ratio 0.5 --focus 2>/dev/null \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["pane"]["pane_id"])')" [ -n "$new_pane" ] || { echo "open-ai-commit-pane.sh: pane split failed" >&2; exit 1; } -herdr pane rename "$new_pane" "GitCommit" >/dev/null 2>&1 || true +"$herdr_bin" pane rename "$new_pane" "GitCommit" >/dev/null 2>&1 || true python3 "$helper" place-diff "$HERDR_PANE_ID" "$new_pane" "$SIDEBAR_COLS" "$COMMIT_COLS" 2>/dev/null || true restore_cmd="python3 $(shq "$helper") set-region-width $(shq "$HERDR_PANE_ID") $(shq "$restore_cols")" -run_cmd="clear; cd $(shq "$repo") && HERDR_PLUGIN_CONFIG_DIR=$(shq "$config_dir") bash $(shq "$commit_sh") $(shq "$repo"); $restore_cmd >/dev/null 2>&1; exit" -if ! herdr pane run "$new_pane" "$run_cmd" >/dev/null; then - herdr pane close "$new_pane" >/dev/null 2>&1 || true +run_cmd="clear; export HERDR_BIN_PATH=$(shq "$herdr_bin") HERDR_PLUGIN_CONFIG_DIR=$(shq "$config_dir"); cd $(shq "$repo") && bash $(shq "$commit_sh") $(shq "$repo"); $restore_cmd >/dev/null 2>&1; exit" +if ! "$herdr_bin" pane run "$new_pane" "$run_cmd" >/dev/null; then + "$herdr_bin" pane close "$new_pane" >/dev/null 2>&1 || true python3 "$helper" set-region-width "$HERDR_PANE_ID" "$restore_cols" >/dev/null 2>&1 || true echo "open-ai-commit-pane.sh: pane run failed" >&2 exit 1 diff --git a/scripts/open-settings-pane.sh b/scripts/open-settings-pane.sh index 33dea11..0652316 100755 --- a/scripts/open-settings-pane.sh +++ b/scripts/open-settings-pane.sh @@ -15,6 +15,7 @@ set -euo pipefail [ "${HERDR_ENV:-}" = "1" ] || { echo "open-settings-pane.sh: not inside herdr" >&2; exit 1; } +herdr_bin="${HERDR_BIN_PATH:-herdr}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" helper="$script_dir/layout-helper.py" @@ -47,7 +48,7 @@ case "$SETTINGS_COLS" in *[!0-9]*|'') SETTINGS_COLS=70 ;; esac restore_cols="$SIDEBAR_COLS" if [ "$LAYOUT_MODE" = "expanded" ]; then restore_cols="$EXPAND_COLS" - tab_width="$(herdr pane layout --pane "$HERDR_PANE_ID" 2>/dev/null | python3 -c ' + tab_width="$("$herdr_bin" pane layout --pane "$HERDR_PANE_ID" 2>/dev/null | python3 -c ' import json, sys try: print(int(json.load(sys.stdin)["result"]["layout"]["area"]["width"])) @@ -62,7 +63,7 @@ except Exception: fi # --- Single instance: close an existing GitSettings pane in this tab -------- -panes_json="$(herdr pane list 2>/dev/null || true)" +panes_json="$("$herdr_bin" pane list 2>/dev/null || true)" printf '%s' "$panes_json" | python3 -c ' import json, sys tab = sys.argv[1] @@ -73,28 +74,28 @@ except Exception: for p in panes: if p.get("tab_id") == tab and p.get("label") == "GitSettings": print(p["pane_id"]) -' "$HERDR_TAB_ID" | while read -r old; do herdr pane close "$old" >/dev/null 2>&1 || true; done +' "$HERDR_TAB_ID" | while read -r old; do "$herdr_bin" pane close "$old" >/dev/null 2>&1 || true; done # --- Open the Settings pane and apply its geometry -------------------------- -new_pane="$(herdr pane split --pane "$HERDR_PANE_ID" --direction right --ratio 0.5 --focus 2>/dev/null \ +new_pane="$("$herdr_bin" pane split --pane "$HERDR_PANE_ID" --direction right --ratio 0.5 --focus 2>/dev/null \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["pane"]["pane_id"])')" [ -n "$new_pane" ] || { echo "open-settings-pane.sh: pane split failed" >&2; exit 1; } -herdr pane rename "$new_pane" "GitSettings" >/dev/null 2>&1 || true +"$herdr_bin" pane rename "$new_pane" "GitSettings" >/dev/null 2>&1 || true python3 "$helper" place-diff "$HERDR_PANE_ID" "$new_pane" "$SIDEBAR_COLS" "$SETTINGS_COLS" 2>/dev/null || true # Restore lazygit's width before exit. If settings-fzf.sh fails (for example, # fzf is missing), sleep so the message remains readable. The final exit makes # the pane disappear immediately after q/Esc. restore_cmd="python3 $(shq "$helper") set-region-width $(shq "$HERDR_PANE_ID") $(shq "$restore_cols")" -# Pass the configuration directory explicitly. pane run starts a fresh shell -# that does not inherit HERDR_PLUGIN_CONFIG_DIR from lazygit (the plugin pane). -# Without it, Settings writes to the fallback directory while lazygit reads the -# plugin configuration directory, so changes never take effect. -if ! herdr pane run "$new_pane" "clear; HERDR_PLUGIN_CONFIG_DIR=$(shq "$config_dir") bash $(shq "$settings_sh") || sleep 4; $restore_cmd >/dev/null 2>&1; exit" >/dev/null; then +# Pass the Herdr binary and configuration directory explicitly. pane run starts +# a fresh shell that does not inherit these values from lazygit (the plugin +# pane). Without them, Settings can write to the fallback directory and the +# restore helper can call the wrong Herdr binary. +if ! "$herdr_bin" pane run "$new_pane" "clear; export HERDR_BIN_PATH=$(shq "$herdr_bin") HERDR_PLUGIN_CONFIG_DIR=$(shq "$config_dir"); bash $(shq "$settings_sh") || sleep 4; $restore_cmd >/dev/null 2>&1; exit" >/dev/null; then # A failed run leaves an empty shell pane; close it and restore the sidebar so # the user is not left with debris. - herdr pane close "$new_pane" >/dev/null 2>&1 || true + "$herdr_bin" pane close "$new_pane" >/dev/null 2>&1 || true python3 "$helper" set-region-width "$HERDR_PANE_ID" "$restore_cols" >/dev/null 2>&1 || true echo "open-settings-pane.sh: pane run failed" >&2 exit 1 diff --git a/scripts/run-lazygit.sh b/scripts/run-lazygit.sh index 02a864e..8ca2dfe 100755 --- a/scripts/run-lazygit.sh +++ b/scripts/run-lazygit.sh @@ -26,6 +26,9 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" PLUGIN_ROOT="$(cd "$script_dir/.." && pwd)" export HERDR_LAZYGIT_ROOT="$PLUGIN_ROOT" +# shellcheck disable=SC1091 +. "$script_dir/runtime-env.sh" +herdr_lazygit_require_runtime lazygit # herdr injects HERDR_PLUGIN_CONFIG_DIR for plugin panes; fall back to a # sensible per-user dir so the script also works when run outside herdr. @@ -75,4 +78,4 @@ fi layers="$layers,$user_config" export LG_CONFIG_FILE="$layers" -exec lazygit +exec "$HERDR_LAZYGIT_BIN" diff --git a/scripts/runtime-env.sh b/scripts/runtime-env.sh new file mode 100755 index 0000000..ffe8bff --- /dev/null +++ b/scripts/runtime-env.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Resolve the plugin-private runtime installed by scripts/install-runtime.sh. +# Source this from runtime entrypoints; it deliberately does not modify PATH, so +# the plugin always invokes the tested binaries by absolute path. + +_herdr_lazygit_runtime_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +HERDR_LAZYGIT_RUNTIME_ROOT="${HERDR_LAZYGIT_ROOT:-${HERDR_PLUGIN_ROOT:-$(cd "$_herdr_lazygit_runtime_script_dir/.." && pwd)}}" +HERDR_LAZYGIT_BIN="$HERDR_LAZYGIT_RUNTIME_ROOT/bin/lazygit" +HERDR_LAZYGIT_FZF_BIN="$HERDR_LAZYGIT_RUNTIME_ROOT/bin/fzf" +export HERDR_LAZYGIT_RUNTIME_ROOT HERDR_LAZYGIT_BIN HERDR_LAZYGIT_FZF_BIN + +herdr_lazygit_require_runtime() { + local tool="${1:-}" path + case "$tool" in + lazygit) path="$HERDR_LAZYGIT_BIN" ;; + fzf) path="$HERDR_LAZYGIT_FZF_BIN" ;; + *) + printf 'runtime-env.sh: unknown runtime tool: %s\n' "$tool" >&2 + return 1 + ;; + esac + + if [ ! -x "$path" ]; then + cat >&2 </dev/null 2>&1; then - cat >&2 <<'EOF' -settings-fzf.sh: fzf is required for the settings menu. - -Install it with either command: - brew install fzf - bash "/scripts/ensure-fzf.sh" -EOF - exit 1 -fi - -# --------------------------------------------------------------------------- -# Constants and paths +# Constants, paths, and plugin-private runtime # --------------------------------------------------------------------------- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# shellcheck disable=SC1091 +. "$script_dir/runtime-env.sh" +herdr_lazygit_require_runtime fzf +FZF_BIN="$HERDR_LAZYGIT_FZF_BIN" SELF="$script_dir/settings-fzf.sh" AI_SH="$script_dir/ai-commit-msg.sh" GEN_SH="${HERDR_LAZYGIT_GEN_SH:-$script_dir/gen-config-layer.sh}" @@ -270,7 +260,7 @@ cmd_preview() { # --------------------------------------------------------------------------- flow_backend() { local line name out rc - line="$("$AI_SH" backends 2>/dev/null | fzf \ + line="$("$AI_SH" backends 2>/dev/null | "$FZF_BIN" \ --layout=reverse --no-multi --cycle --tabstop=12 \ --prompt='AI Backend > ' \ --header='Enter/double-click = select · Esc = back' \ @@ -293,7 +283,7 @@ flow_backend() { # --------------------------------------------------------------------------- flow_model() { local out rc query sel value setout - out="$("$AI_SH" models 2>/dev/null | fzf \ + out="$("$AI_SH" models 2>/dev/null | "$FZF_BIN" \ --layout=reverse --no-multi --print-query \ --prompt='AI Model > ' \ --header='Enter = select; type an unlisted ID and press Enter · Esc = back' \ @@ -451,7 +441,7 @@ Enter/double-click = edit · Esc/q = exit' header="$header ✔ $MSG" fi - item="$(menu_items | fzf \ + item="$(menu_items | "$FZF_BIN" \ --layout=reverse --no-multi --cycle \ --prompt='lazygit Settings > ' \ --header="$header" \ diff --git a/scripts/toggle-expand.sh b/scripts/toggle-expand.sh index 5e3ea1d..b705824 100755 --- a/scripts/toggle-expand.sh +++ b/scripts/toggle-expand.sh @@ -11,6 +11,7 @@ set -euo pipefail [ "${HERDR_ENV:-}" = "1" ] || { echo "toggle-expand.sh: not inside herdr" >&2; exit 1; } [ -n "${HERDR_PANE_ID:-}" ] || { echo "toggle-expand.sh: HERDR_PANE_ID is empty" >&2; exit 1; } +herdr_bin="${HERDR_BIN_PATH:-herdr}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" helper="$script_dir/layout-helper.py" @@ -50,7 +51,7 @@ if [ "$LAYOUT_MODE" = "sidebar" ]; then # After expanding, leave at least 20 columns for the tab's other workspace. # In a normal tab (>=100 columns), also keep lazygit at least 80 columns wide. # When a narrower tab cannot satisfy both constraints, preserve the workspace. - tab_width="$(herdr pane layout --pane "$HERDR_PANE_ID" 2>/dev/null | python3 -c ' + tab_width="$("$herdr_bin" pane layout --pane "$HERDR_PANE_ID" 2>/dev/null | python3 -c ' import json, sys try: print(int(json.load(sys.stdin)["result"]["layout"]["area"]["width"])) @@ -73,4 +74,4 @@ python3 "$helper" set-width "$HERDR_PANE_ID" "$target_cols" # lazygit stats and hot-reloads all configuration on focus-in; inject the event # directly to switch the layout immediately. -herdr pane send-text "$HERDR_PANE_ID" $'\x1b[I' >/dev/null +"$herdr_bin" pane send-text "$HERDR_PANE_ID" $'\x1b[I' >/dev/null diff --git a/tests/install-runtime-test.sh b/tests/install-runtime-test.sh new file mode 100755 index 0000000..a83b0b2 --- /dev/null +++ b/tests/install-runtime-test.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# Hermetic platform/checksum tests for scripts/install-runtime.sh. Fake release +# archives keep this fast while exercising all four supported target mappings. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/herdr-lazygit-runtime-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT +fake_bin="$tmp/fake-bin" +mkdir -p "$fake_bin" + +cat > "$fake_bin/curl" <<'EOF' +#!/bin/sh +set -eu +out="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o|--output) out=$2; shift 2 ;; + --retry|--retry-delay) shift 2 ;; + -*) shift ;; + *) url=$1; shift ;; + esac +done +[ -n "$out" ] && [ -n "$url" ] +printf '%s\n' "$url" >> "$FAKE_DOWNLOAD_LOG" +printf '%s\n' "$url" > "$out" +EOF + +cat > "$fake_bin/sha256sum" <<'EOF' +#!/bin/sh +set -eu +if [ "${FAKE_SHA_MISMATCH:-0}" = 1 ]; then + hash=0000000000000000000000000000000000000000000000000000000000000000 +else + case "${1##*/}" in + lazygit_0.63.0_darwin_arm64.tar.gz) hash=60e6bf29a1501a57a9d078538aa576a1b4db45779db2e3dd6931a7207f560a9c ;; + lazygit_0.63.0_darwin_x86_64.tar.gz) hash=304b1bf7f7bbb5a5d59e34145bce63d42733cd828e4fe41428ced9ee4dbfe942 ;; + lazygit_0.63.0_linux_arm64.tar.gz) hash=aac147abf5ce43afe6ae8bcb14b0d479111975a189302d7a99386deca70d57f7 ;; + lazygit_0.63.0_linux_x86_64.tar.gz) hash=cf5cfa3e116d7775f3600a51ec1d9ce7ba554a08b9566c7c2da83cb0023efabf ;; + fzf-0.74.0-darwin_amd64.tar.gz) hash=e2c470f058ac18615f54c0bebe0fd2956f2aa8e306a11621783a00aaa386eedd ;; + fzf-0.74.0-darwin_arm64.tar.gz) hash=da60e8980e4239a0fc5f1fcfe873f243dfda93a6a13b696b00e1dc8584a77a87 ;; + fzf-0.74.0-linux_amd64.tar.gz) hash=cf919f05b7581b4c744d764eaa704665d61dd6d3ca785f0df2351281dff60cda ;; + fzf-0.74.0-linux_arm64.tar.gz) hash=bd9e6165ebdb702215d42368cbb95b8dd70a4e77ee97925adac8c31660e30ef7 ;; + *) exit 2 ;; + esac +fi +printf '%s %s\n' "$hash" "$1" +EOF + +cat > "$fake_bin/tar" <<'EOF' +#!/bin/sh +set -eu +archive="" +destination="" +while [ "$#" -gt 0 ]; do + case "$1" in + -xzf) archive=$2; shift 2 ;; + -C) destination=$2; shift 2 ;; + *) shift ;; + esac +done +case "${archive##*/}" in + lazygit_*) tool=lazygit ;; + fzf-*) tool=fzf ;; + *) exit 2 ;; +esac +cat > "$destination/$tool" < "$fake_bin/uname" <<'EOF' +#!/bin/sh +set -eu +case "${1:-}" in + -s) printf '%s\n' "$FAKE_UNAME_S" ;; + -m) printf '%s\n' "$FAKE_UNAME_M" ;; + *) exit 2 ;; +esac +EOF + +cat > "$fake_bin/mv" <<'EOF' +#!/bin/sh +set -eu +if [ "${FAKE_MV_FAIL_FZF_PUBLISH:-0}" = 1 ] && [ "$#" -eq 2 ]; then + case "$1:$2" in + */.runtime-stage.*/fzf:*/bin/fzf) + : > "$FAKE_MV_FAILURE_MARKER" + exit 1 + ;; + esac +fi +if [ "${FAKE_MV_FAIL_LAZYGIT_RESTORE:-0}" = 1 ] && [ "$#" -eq 3 ] && [ "$1" = -f ]; then + case "$2:$3" in + */.runtime-backup.*/lazygit:*/bin/lazygit) + : > "$FAKE_MV_RESTORE_FAILURE_MARKER" + exit 1 + ;; + esac +fi +exec /bin/mv "$@" +EOF + +chmod +x "$fake_bin/curl" "$fake_bin/sha256sum" "$fake_bin/tar" "$fake_bin/uname" "$fake_bin/mv" + +make_checkout() { + local root=$1 + mkdir -p "$root/scripts" + cp "$repo_root/scripts/install-runtime.sh" "$root/scripts/" + cp "$repo_root/scripts/runtime-versions.sh" "$root/scripts/" +} + +run_target() { + local os=$1 arch=$2 expected_lazygit=$3 expected_fzf=$4 name root out log + name="${os}-${arch}" + root="$tmp/$name/checkout" + out="$root/bin" + log="$tmp/$name/downloads.log" + make_checkout "$root" + mkdir -p "$(dirname "$log")" + : > "$log" + + PATH="$fake_bin:$PATH" \ + FAKE_DOWNLOAD_LOG="$log" \ + FAKE_UNAME_S="$os" \ + FAKE_UNAME_M="$arch" \ + HERDR_LAZYGIT_LAZYGIT_BASE_URL='https://fixtures.invalid/lazygit' \ + HERDR_LAZYGIT_FZF_BASE_URL='https://fixtures.invalid/fzf' \ + /bin/sh "$root/scripts/install-runtime.sh" >/dev/null + + [ -x "$out/lazygit" ] + [ -x "$out/fzf" ] + "$out/lazygit" | grep -q '^lazygit test-runtime$' + "$out/fzf" | grep -q '^fzf test-runtime$' + grep -q "/$expected_lazygit$" "$log" + grep -q "/$expected_fzf$" "$log" +} + +run_target Darwin x86_64 \ + lazygit_0.63.0_darwin_x86_64.tar.gz fzf-0.74.0-darwin_amd64.tar.gz +run_target Darwin arm64 \ + lazygit_0.63.0_darwin_arm64.tar.gz fzf-0.74.0-darwin_arm64.tar.gz +run_target Linux x86_64 \ + lazygit_0.63.0_linux_x86_64.tar.gz fzf-0.74.0-linux_amd64.tar.gz +run_target Linux aarch64 \ + lazygit_0.63.0_linux_arm64.tar.gz fzf-0.74.0-linux_arm64.tar.gz + +# A real build must always keep its runtime inside the managed checkout, even +# when a shell happens to export test or legacy output-directory overrides. +production_root="$tmp/production-build" +escaped_root="$tmp/escaped-runtime" +production_log="$tmp/production-build.log" +make_checkout "$production_root" +: > "$production_log" +PATH="$fake_bin:$PATH" \ +FAKE_DOWNLOAD_LOG="$production_log" \ +FAKE_UNAME_S=Linux \ +FAKE_UNAME_M=x86_64 \ +HERDR_LAZYGIT_TEST_MODE=1 \ +HERDR_LAZYGIT_RUNTIME_BIN_DIR="$escaped_root/legacy" \ +HERDR_LAZYGIT_TEST_RUNTIME_BIN_DIR="$escaped_root/test" \ +HERDR_LAZYGIT_LAZYGIT_BASE_URL='https://fixtures.invalid/lazygit' \ +HERDR_LAZYGIT_FZF_BASE_URL='https://fixtures.invalid/fzf' \ + /bin/sh "$production_root/scripts/install-runtime.sh" >/dev/null +[ -x "$production_root/bin/lazygit" ] +[ -x "$production_root/bin/fzf" ] +[ ! -e "$escaped_root/legacy/lazygit" ] +[ ! -e "$escaped_root/test/lazygit" ] + +# Smoke tests must run from the destination filesystem rather than TMPDIR. The +# fake runtime returns EACCES-like status 126 only when executed below the +# synthetic noexec prefix; extraction there remains valid. +noexec_root="$tmp/noexec-build" +noexec_tmp="$tmp/noexec-tmp" +noexec_log="$tmp/noexec-build.log" +make_checkout "$noexec_root" +mkdir -p "$noexec_tmp" +: > "$noexec_log" +PATH="$fake_bin:$PATH" \ +TMPDIR="$noexec_tmp" \ +FAKE_DOWNLOAD_LOG="$noexec_log" \ +FAKE_NOEXEC_PREFIX="$noexec_tmp" \ +FAKE_UNAME_S=Linux \ +FAKE_UNAME_M=x86_64 \ +HERDR_LAZYGIT_LAZYGIT_BASE_URL='https://fixtures.invalid/lazygit' \ +HERDR_LAZYGIT_FZF_BASE_URL='https://fixtures.invalid/fzf' \ + /bin/sh "$noexec_root/scripts/install-runtime.sh" >/dev/null +[ -x "$noexec_root/bin/lazygit" ] +[ -x "$noexec_root/bin/fzf" ] + +# Unsupported targets fail before installing anything. +unsupported_root="$tmp/unsupported/checkout" +make_checkout "$unsupported_root" +if PATH="$fake_bin:$PATH" \ + FAKE_DOWNLOAD_LOG="$tmp/unsupported.log" \ + FAKE_UNAME_S=Linux \ + FAKE_UNAME_M=riscv64 \ + /bin/sh "$unsupported_root/scripts/install-runtime.sh" >/dev/null 2>&1; then + echo "expected unsupported architecture to fail" >&2 + exit 1 +fi +[ ! -e "$unsupported_root/bin/lazygit" ] + +# A checksum mismatch aborts before either staged tool reaches the destination. +mismatch_root="$tmp/mismatch/checkout" +mismatch_log="$tmp/mismatch.log" +make_checkout "$mismatch_root" +: > "$mismatch_log" +if PATH="$fake_bin:$PATH" \ + FAKE_DOWNLOAD_LOG="$mismatch_log" \ + FAKE_SHA_MISMATCH=1 \ + FAKE_UNAME_S=Linux \ + FAKE_UNAME_M=x86_64 \ + /bin/sh "$mismatch_root/scripts/install-runtime.sh" >/dev/null 2>&1; then + echo "expected checksum mismatch to fail" >&2 + exit 1 +fi +[ ! -e "$mismatch_root/bin/lazygit" ] +[ ! -e "$mismatch_root/bin/fzf" ] + +# A failed second publication must restore the complete previous pair and +# remove all transaction directories. +rollback_root="$tmp/rollback/checkout" +rollback_log="$tmp/rollback.log" +rollback_marker="$tmp/rollback-publication-reached" +make_checkout "$rollback_root" +mkdir -p "$rollback_root/bin" +cat > "$rollback_root/bin/lazygit" <<'EOF' +#!/bin/sh +printf '%s\n' old-lazygit +EOF +cat > "$rollback_root/bin/fzf" <<'EOF' +#!/bin/sh +printf '%s\n' old-fzf +EOF +chmod +x "$rollback_root/bin/lazygit" "$rollback_root/bin/fzf" +: > "$rollback_log" +if PATH="$fake_bin:$PATH" \ + FAKE_DOWNLOAD_LOG="$rollback_log" \ + FAKE_MV_FAIL_FZF_PUBLISH=1 \ + FAKE_MV_FAILURE_MARKER="$rollback_marker" \ + FAKE_UNAME_S=Linux \ + FAKE_UNAME_M=x86_64 \ + HERDR_LAZYGIT_LAZYGIT_BASE_URL='https://fixtures.invalid/lazygit' \ + HERDR_LAZYGIT_FZF_BASE_URL='https://fixtures.invalid/fzf' \ + /bin/sh "$rollback_root/scripts/install-runtime.sh" >/dev/null 2>&1; then + echo "expected second runtime publication to fail" >&2 + exit 1 +fi +[ -f "$rollback_marker" ] +[ "$("$rollback_root/bin/lazygit")" = old-lazygit ] +[ "$("$rollback_root/bin/fzf")" = old-fzf ] +[ -z "$(find "$rollback_root/bin" -mindepth 1 -maxdepth 1 -name '.*' -print)" ] + +# If restoring the old pair also fails, retain the rollback directory instead +# of deleting the only remaining recovery copy. +restore_failure_root="$tmp/restore-failure/checkout" +restore_failure_log="$tmp/restore-failure.log" +restore_failure_error="$tmp/restore-failure.err" +restore_failure_publish_marker="$tmp/restore-failure-publication-reached" +restore_failure_marker="$tmp/restore-failure-reached" +make_checkout "$restore_failure_root" +mkdir -p "$restore_failure_root/bin" +cp "$rollback_root/bin/lazygit" "$restore_failure_root/bin/lazygit" +cp "$rollback_root/bin/fzf" "$restore_failure_root/bin/fzf" +: > "$restore_failure_log" +if PATH="$fake_bin:$PATH" \ + FAKE_DOWNLOAD_LOG="$restore_failure_log" \ + FAKE_MV_FAIL_FZF_PUBLISH=1 \ + FAKE_MV_FAILURE_MARKER="$restore_failure_publish_marker" \ + FAKE_MV_FAIL_LAZYGIT_RESTORE=1 \ + FAKE_MV_RESTORE_FAILURE_MARKER="$restore_failure_marker" \ + FAKE_UNAME_S=Linux \ + FAKE_UNAME_M=x86_64 \ + HERDR_LAZYGIT_LAZYGIT_BASE_URL='https://fixtures.invalid/lazygit' \ + HERDR_LAZYGIT_FZF_BASE_URL='https://fixtures.invalid/fzf' \ + /bin/sh "$restore_failure_root/scripts/install-runtime.sh" \ + >/dev/null 2>"$restore_failure_error"; then + echo "expected publication and rollback restoration to fail" >&2 + exit 1 +fi +[ -f "$restore_failure_publish_marker" ] +[ -f "$restore_failure_marker" ] +retained_backup="$(find "$restore_failure_root/bin" -mindepth 1 -maxdepth 1 \ + -type d -name '.runtime-backup.*' -print -quit)" +[ -n "$retained_backup" ] +[ "$("$retained_backup/lazygit")" = old-lazygit ] +[ "$("$restore_failure_root/bin/fzf")" = old-fzf ] +reported_backup="$(sed -n \ + 's/^herdr-lazygit: rollback incomplete; recovery binaries retained in //p' \ + "$restore_failure_error")" +[ -n "$reported_backup" ] +[ "$(cd "$reported_backup" && pwd -P)" = "$(cd "$retained_backup" && pwd -P)" ] + +# Non-file destinations must fail instead of accepting mv's directory +# semantics and reporting an unusable installation as successful. +directory_root="$tmp/directory-destination/checkout" +directory_log="$tmp/directory-destination.log" +directory_error="$tmp/directory-destination.err" +make_checkout "$directory_root" +mkdir -p "$directory_root/bin/lazygit" +if PATH="$fake_bin:$PATH" \ + FAKE_DOWNLOAD_LOG="$directory_log" \ + FAKE_UNAME_S=Linux \ + FAKE_UNAME_M=x86_64 \ + /bin/sh "$directory_root/scripts/install-runtime.sh" >/dev/null 2>"$directory_error"; then + echo "expected a directory runtime destination to fail" >&2 + exit 1 +fi +grep -qF 'runtime destination is not a regular file:' "$directory_error" +[ ! -e "$directory_log" ] +[ -d "$directory_root/bin/lazygit" ] +[ ! -e "$directory_root/bin/fzf" ] + +printf 'install-runtime tests passed\n' diff --git a/tests/runtime-integration-test.sh b/tests/runtime-integration-test.sh new file mode 100755 index 0000000..109725f --- /dev/null +++ b/tests/runtime-integration-test.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Regression tests for runtime path propagation and generated-config caching. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/herdr-lazygit-integration-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT +python_bin="$(command -v python3)" + +# --------------------------------------------------------------------------- +# Fresh pane commands must preserve HERDR_BIN_PATH for their cleanup helpers. +# --------------------------------------------------------------------------- +fake_herdr_dir="$tmp/custom tools" +fake_herdr="$fake_herdr_dir/custom-herdr" +command_log="$tmp/pane-run.log" +mkdir -p "$fake_herdr_dir" +cat > "$fake_herdr" <<'EOF' +#!/bin/sh +set -eu +case "${1:-} ${2:-}" in + "pane list") + printf '%s\n' '{"result":{"panes":[]}}' + ;; + "pane split") + printf '%s\n' '{"result":{"pane":{"pane_id":"test-pane"}}}' + ;; + "pane layout") + printf '%s\n' '{"result":{"layout":{"area":{"width":160}}}}' + ;; + "pane run") + printf '%s\n' "${4:-}" >> "$FAKE_PANE_RUN_LOG" + ;; + "pane rename"|"pane close"|"pane send-text") + ;; + *) + printf 'unexpected fake herdr command: %s\n' "$*" >&2 + exit 2 + ;; +esac +EOF +chmod +x "$fake_herdr" +: > "$command_log" +quoted_fake_herdr="$("$python_bin" -c 'import shlex, sys; print(shlex.quote(sys.argv[1]))' "$fake_herdr")" + +common_env=( + HERDR_ENV=1 + HERDR_WORKSPACE_ID=test-workspace + HERDR_TAB_ID=test-tab + HERDR_PANE_ID=test-origin + HERDR_BIN_PATH="$fake_herdr" + HERDR_PLUGIN_CONFIG_DIR="$tmp/config" + HERDR_SOCKET_PATH="$tmp/nonexistent.sock" + FAKE_PANE_RUN_LOG="$command_log" +) + +env "${common_env[@]}" bash "$repo_root/scripts/open-settings-pane.sh" +env "${common_env[@]}" bash "$repo_root/scripts/open-ai-commit-pane.sh" +[ "$(wc -l < "$command_log" | tr -d ' ')" = 2 ] +while IFS= read -r pane_command; do + case "$pane_command" in + *"export HERDR_BIN_PATH=$quoted_fake_herdr "*) ;; + *) + printf 'pane command did not export HERDR_BIN_PATH: %s\n' "$pane_command" >&2 + exit 1 + ;; + esac +done < "$command_log" + +# --------------------------------------------------------------------------- +# Direct free-key analysis must resolve the plugin-private lazygit without a +# same-named binary on PATH or runtime-env.sh preloading its absolute path. +# --------------------------------------------------------------------------- +standalone_root="$tmp/standalone plugin" +empty_path="$tmp/empty-path" +mkdir -p "$standalone_root/scripts" "$standalone_root/bin" "$empty_path" +cp "$repo_root/scripts/free-keys.py" "$standalone_root/scripts/" +cat > "$standalone_root/bin/lazygit" <<'EOF' +#!/bin/sh +printf '%s\n' \ + 'keybinding:' \ + ' universal:' \ + ' quit: q' +EOF +chmod +x "$standalone_root/bin/lazygit" +( + unset HERDR_LAZYGIT_BIN HERDR_LAZYGIT_ROOT HERDR_PLUGIN_ROOT + PATH="$empty_path" "$python_bin" "$standalone_root/scripts/free-keys.py" check U global +) + +# --------------------------------------------------------------------------- +# Rebuilding the pinned lazygit binary must invalidate generated.yml even when +# keys and layout stay unchanged, because built-in key conflicts may differ. +# --------------------------------------------------------------------------- +runtime_root="$tmp/runtime-root" +runtime_bin="$runtime_root/bin" +config_dir="$tmp/generated-config" +mkdir -p "$runtime_bin" "$config_dir" + +write_fake_lazygit() { + local conflict_key=$1 + cat > "$runtime_bin/lazygit" <' "$config_dir/generated.yml" + +# Ensure -nt observes a newer binary even on filesystems with second precision. +sleep 1 +write_fake_lazygit X +HERDR_LAZYGIT_ROOT="$runtime_root" HERDR_PLUGIN_CONFIG_DIR="$config_dir" \ + bash "$repo_root/scripts/gen-config-layer.sh" +if grep -q 'testAction: ' "$config_dir/generated.yml"; then + echo 'generated.yml retained a stale lazygit key conflict' >&2 + exit 1 +fi +grep -q '| lazygit: 0.63.0$' "$config_dir/generated.yml" + +printf 'runtime integration tests passed\n'