diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 691c6d37..9d8f65cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: - "**/Cargo.lock" - "rust-toolchain.toml" - "scripts/install.sh" + - "scripts/install-cli.sh" + - "scripts/install-completions.sh" + - "scripts/tests/verify-completion-install.sh" - "scripts/run-with-capabilities.sh" - "scripts/tests/setup-ublk-access.sh" - "scripts/tests/verify-capability-runner.sh" @@ -24,6 +27,9 @@ on: - "**/Cargo.lock" - "rust-toolchain.toml" - "scripts/install.sh" + - "scripts/install-cli.sh" + - "scripts/install-completions.sh" + - "scripts/tests/verify-completion-install.sh" - "scripts/run-with-capabilities.sh" - "scripts/tests/setup-ublk-access.sh" - "scripts/tests/verify-capability-runner.sh" @@ -63,3 +69,16 @@ jobs: run: sudo scripts/tests/setup-ublk-access.sh "$(id -un)" "$(id -gn)" - name: Unit tests run: make test-unit PROFILE=debug + + completion-install: + name: Completion installer checks + runs-on: ubuntu-22.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Shell syntax + run: bash -n scripts/install-completions.sh scripts/install-cli.sh scripts/install.sh scripts/tests/verify-completion-install.sh + - name: Functional checks + run: bash scripts/tests/verify-completion-install.sh diff --git a/Makefile b/Makefile index 116b30c0..16055570 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,7 @@ TARGET_PROFILE_DIR = $${CARGO_TARGET_DIR:-$$(pwd)/target}/$(PROFILE) build-server build-server-release \ build-snapshot-image \ build-aenv build-aenv-release install-aenv uninstall-aenv \ + check-completion-install \ build-ublk install-ublk \ fmt clippy \ mutants coverage \ @@ -91,12 +92,17 @@ build-aenv-release: install-aenv: build-aenv-release $(AENV_INSTALL_SUDO) install -d "$(AENV_INSTALL_DIR)" $(AENV_INSTALL_SUDO) install -m 0755 "$${CARGO_TARGET_DIR:-$$(pwd)/target}/release/aenv" "$(AENV_INSTALL_DIR)/aenv" + $(AENV_INSTALL_SUDO) scripts/install-completions.sh install --prefix="$(AENV_INSTALL_PREFIX)" --binary="$(AENV_INSTALL_DIR)/aenv" @echo "Installed aenv to $(AENV_INSTALL_DIR)/aenv" uninstall-aenv: + $(AENV_INSTALL_SUDO) scripts/install-completions.sh uninstall --prefix="$(AENV_INSTALL_PREFIX)" --binary="$(AENV_INSTALL_DIR)/aenv" $(AENV_INSTALL_SUDO) rm -f "$(AENV_INSTALL_DIR)/aenv" @echo "Removed $(AENV_INSTALL_DIR)/aenv" +check-completion-install: + bash scripts/tests/verify-completion-install.sh + fmt: $(CARGO) fmt --all -- --check @@ -119,6 +125,7 @@ test-unit: $(CAPABILITY_TEST_ENV) $(CAPABILITY_RUNNER) $(CARGO) test -p uvm-ublk -p uvm-ublk-daemon --lib bash scripts/tests/verify-capability-runner.sh bash scripts/tests/verify-install-service.sh + bash scripts/tests/verify-completion-install.sh test-integration: test-agent-integration test-envd test-ublk diff --git a/docs/src/getting-started/aenv-cli.md b/docs/src/getting-started/aenv-cli.md index 53104f5b..8e3d9283 100644 --- a/docs/src/getting-started/aenv-cli.md +++ b/docs/src/getting-started/aenv-cli.md @@ -274,3 +274,15 @@ aenv snapshot list --sandbox-id The table output includes an `IMAGE REF` column (`-` when no image was published); JSON output includes the optional `imageRef` field. To delete a snapshot, use `aenv template delete ` or `aenv template delete ` — snapshots share the same underlying store as templates and are deleted through the same command. + +## Automatic shell completion installation + +The CLI installers install completion loaders into standard shell completion directories. `make install-aenv`, `scripts/install-cli.sh`, and the full `scripts/install.sh` installer manage only the completion files and never modify `.bashrc`, `.zshrc`, or Fish configuration files. `make uninstall-aenv` removes files created by the Make-based install while preserving unmanaged files. + +For a user-local Make install, use `AENV_INSTALL_PREFIX=~/.local AENV_INSTALL_SUDO= make install-aenv`. If Zsh does not find the installed user completion directory, add this before `compinit`: + +```zsh +fpath=(~/.local/share/zsh/site-functions $fpath) +autoload -Uz compinit +compinit +``` diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 18cbb437..8e9b4934 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -165,6 +165,103 @@ fi echo "Installed: ${DEST}" +install_completion_files() { + local marker='# managed by aenv completion installer' + local prefix home user_mode=0 quoted_binary + local bash_path zsh_path fish_path + prefix="$(dirname "$INSTALL_DIR")" + home="${HOME:-}" + canonical_dir() { + local path="$1" parent name + [[ "$path" == /* ]] || path="$PWD/$path" + if [[ -d "$path" ]]; then + (cd "$path" && pwd -P) + return + fi + parent="${path%/*}" + name="${path##*/}" + [[ "$parent" == "$path" ]] && parent="." + parent="$(cd "$parent" 2>/dev/null && pwd -P)" || return 1 + printf '%s/%s\n' "$parent" "$name" + } + prefix="$(canonical_dir "$prefix")" || { echo "warning: could not resolve completion prefix; skipping" >&2; return 0; } + home_real="" + if [[ -n "$home" ]]; then + home_real="$(canonical_dir "$home")" || home_real="" + fi + if [[ -n "$home_real" && ( "$prefix" == "$home_real" || "$prefix" == "$home_real"/* ) ]]; then + user_mode=1 + fi + if ((user_mode)); then + [[ -n "$home" ]] || { echo "warning: HOME is unset; skipping completion setup" >&2; return 0; } + bash_path="$home/.local/share/bash-completion/completions/aenv" + zsh_path="$home/.local/share/zsh/site-functions/_aenv" + fish_path="$home/.config/fish/completions/aenv.fish" + else + bash_path="$prefix/share/bash-completion/completions/aenv" + zsh_path="$prefix/share/zsh/site-functions/_aenv" + fish_path="$prefix/share/fish/vendor_completions.d/aenv.fish" + fi + if [[ "$DEST" == *"'"* || "$DEST" == *$'\n'* || "$DEST" == *$'\r'* ]]; then + echo "warning: completion binary path contains unsupported characters; skipping" >&2 + return 0 + fi + quoted_binary="'$DEST'" + + put_loader() { + local path="$1" body="$2" dir tmp + dir="${path%/*}" + if [[ -L "$path" ]]; then + echo "warning: refusing to replace symlink ${path}" >&2 + return 0 + fi + if [[ -e "$path" ]] && ! grep -Fqx "$marker" "$path" 2>/dev/null; then + echo "warning: leaving unmanaged completion file ${path} untouched" >&2 + return 0 + fi + local runner=() + if ! (mkdir -p "$dir" 2>/dev/null && [[ -w "$dir" ]]); then + runner=(run_privileged) + fi + "${runner[@]}" mkdir -p "$dir" || { echo "warning: could not create ${dir}" >&2; return 0; } + tmp="$("${runner[@]}" mktemp "$dir/.aenv-completion.XXXXXX")" || { + echo "warning: could not stage ${path}" >&2 + return 0 + } + if [[ "$path" == "$zsh_path" ]]; then + if ! printf '%s\n%s\n' "$body" "$marker" | "${runner[@]}" tee "$tmp" >/dev/null; then + "${runner[@]}" rm -f "$tmp" + echo "warning: could not stage ${path}" >&2 + return 0 + fi + else + if ! printf '%s\n%s\n' "$marker" "$body" | "${runner[@]}" tee "$tmp" >/dev/null; then + "${runner[@]}" rm -f "$tmp" + echo "warning: could not stage ${path}" >&2 + return 0 + fi + fi + if ! "${runner[@]}" chmod 0644 "$tmp" || ! "${runner[@]}" mv -f "$tmp" "$path"; then + "${runner[@]}" rm -f "$tmp" + echo "warning: could not install ${path}" >&2 + fi + } + + put_loader "$bash_path" "if [[ -x $quoted_binary ]]; then source <($quoted_binary completion bash); fi" + zsh_body="#compdef aenv +if [[ -x $quoted_binary ]]; then eval \"\$($quoted_binary completion zsh)\"; fi" + put_loader "$zsh_path" "$zsh_body" + put_loader "$fish_path" "if test -x $quoted_binary; $quoted_binary completion fish | source; end" + if ((user_mode)); then + echo "If Zsh does not find the completion, add this before compinit:" + echo " fpath=(~/.local/share/zsh/site-functions \$fpath)" + echo " autoload -Uz compinit" + echo " compinit" + fi +} + +install_completion_files + if ! command -v aenv &>/dev/null; then echo "" echo "Note: ${INSTALL_DIR} is not on your PATH." diff --git a/scripts/install-completions.sh b/scripts/install-completions.sh new file mode 100755 index 00000000..e29c1cf0 --- /dev/null +++ b/scripts/install-completions.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Install or remove aenv completion loaders without editing shell startup files. +set -euo pipefail + +MARKER='# managed by aenv completion installer' + +usage() { + printf 'Usage: %s --prefix= --binary=\n' "$0" >&2 +} + +action="${1:-}" +shift || true +prefix="" +binary="" +while (($#)); do + case "$1" in + --prefix=*) prefix="${1#--prefix=}" ;; + --binary=*) binary="${1#--binary=}" ;; + *) usage; exit 2 ;; + esac + shift +done + +if [[ "$action" != install && "$action" != uninstall ]] || [[ -z "$prefix" || -z "$binary" ]]; then + usage + exit 2 +fi + +home="${HOME:-}" +user_mode=0 +canonical_dir() { + local path="$1" parent name + [[ "$path" == /* ]] || path="$PWD/$path" + if [[ -d "$path" ]]; then + (cd "$path" && pwd -P) + return + fi + parent="${path%/*}" + name="${path##*/}" + [[ "$parent" == "$path" ]] && parent="." + parent="$(cd "$parent" 2>/dev/null && pwd -P)" || return 1 + printf '%s/%s\n' "$parent" "$name" +} + +prefix_real="$(canonical_dir "$prefix")" || { + printf 'warning: could not resolve completion prefix %s; skipping\n' "$prefix" >&2 + exit 0 +} +home_real="" +if [[ -n "$home" ]]; then + home_real="$(canonical_dir "$home")" || home_real="" +fi +if [[ -n "$home_real" && ( "$prefix_real" == "$home_real" || "$prefix_real" == "$home_real"/* ) ]]; then + user_mode=1 +fi + +if ((user_mode)); then + [[ -n "$home" ]] || { printf 'warning: HOME is unset; skipping completion setup\n' >&2; exit 0; } + bash_path="$home/.local/share/bash-completion/completions/aenv" + zsh_path="$home/.local/share/zsh/site-functions/_aenv" + fish_path="$home/.config/fish/completions/aenv.fish" +else + bash_path="$prefix/share/bash-completion/completions/aenv" + zsh_path="$prefix/share/zsh/site-functions/_aenv" + fish_path="$prefix/share/fish/vendor_completions.d/aenv.fish" +fi + +if [[ "$binary" == *"'"* || "$binary" == *$'\n'* || "$binary" == *$'\r'* ]]; then + printf 'warning: completion binary path contains unsupported characters; skipping\n' >&2 + exit 0 +fi +quoted_binary="'$binary'" + +write_loader() { + local path="$1" body="$2" dir tmp + dir="${path%/*}" + mkdir -p "$dir" 2>/dev/null || { + printf 'warning: could not create completion directory %s\n' "$dir" >&2 + return 0 + } + if [[ -L "$path" ]]; then + printf 'warning: refusing to replace symlink %s\n' "$path" >&2 + return 0 + fi + if [[ -e "$path" ]] && ! grep -Fqx "$MARKER" "$path" 2>/dev/null; then + printf 'warning: leaving unmanaged completion file %s untouched\n' "$path" >&2 + return 0 + fi + tmp="$(mktemp "$dir/.aenv-completion.XXXXXX")" || { + printf 'warning: could not stage completion file %s\n' "$path" >&2 + return 0 + } + if [[ "$path" == "$zsh_path" ]]; then + if ! printf '%s\n%s\n' "$body" "$MARKER" >"$tmp"; then + rm -f "$tmp" + printf 'warning: could not stage completion file %s\n' "$path" >&2 + return 0 + fi + else + if ! printf '%s\n%s\n' "$MARKER" "$body" >"$tmp"; then + rm -f "$tmp" + printf 'warning: could not stage completion file %s\n' "$path" >&2 + return 0 + fi + fi + if ! { chmod 0644 "$tmp" && mv -f "$tmp" "$path"; }; then + rm -f "$tmp" + printf 'warning: could not install completion file %s\n' "$path" >&2 + fi +} + +remove_loader() { + local path="$1" + [[ -e "$path" ]] || return 0 + if [[ -L "$path" ]] || ! grep -Fqx "$MARKER" "$path" 2>/dev/null; then + printf 'warning: leaving unmanaged completion file %s untouched\n' "$path" >&2 + return 0 + fi + rm -f "$path" || printf 'warning: could not remove completion file %s\n' "$path" >&2 +} + +if [[ "$action" == install ]]; then + write_loader "$bash_path" "if [[ -x $quoted_binary ]]; then source <($quoted_binary completion bash); fi" + zsh_body="#compdef aenv +if [[ -x $quoted_binary ]]; then eval \"\$($quoted_binary completion zsh)\"; fi" + write_loader "$zsh_path" "$zsh_body" + write_loader "$fish_path" "if test -x $quoted_binary; $quoted_binary completion fish | source; end" + if ((user_mode)); then + printf '\nIf Zsh does not find the completion, add this before compinit:\n' + printf " fpath=(~/.local/share/zsh/site-functions \$fpath)\n" + printf ' autoload -Uz compinit\n compinit\n' + fi +else + remove_loader "$bash_path" + remove_loader "$zsh_path" + remove_loader "$fish_path" +fi diff --git a/scripts/install.sh b/scripts/install.sh index a2dbce88..389ef541 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -170,6 +170,58 @@ echo "Downloading aenv CLI ..." download_release_asset "aenv-linux-${ARCH_TAG}" "$tmp_cli" sudo install -m 0755 "$tmp_cli" "${INSTALL_DIR}/aenv" +install_completion_files() { + local marker='# managed by aenv completion installer' + local binary="${INSTALL_DIR}/aenv" quoted_binary + local bash_path="/usr/local/share/bash-completion/completions/aenv" + local zsh_path="/usr/local/share/zsh/site-functions/_aenv" + local fish_path="/usr/local/share/fish/vendor_completions.d/aenv.fish" + quoted_binary="'$binary'" + + put_loader() { + local path="$1" body="$2" dir tmp + dir="${path%/*}" + if [[ -L "$path" ]]; then + echo "warning: refusing to replace symlink ${path}" >&2 + return 0 + fi + if [[ -e "$path" ]] && ! grep -Fqx "$marker" "$path" 2>/dev/null; then + echo "warning: leaving unmanaged completion file ${path} untouched" >&2 + return 0 + fi + sudo mkdir -p "$dir" || { echo "warning: could not create ${dir}" >&2; return 0; } + tmp="$(sudo mktemp "$dir/.aenv-completion.XXXXXX")" || { + echo "warning: could not stage ${path}" >&2 + return 0 + } + if [[ "$path" == "$zsh_path" ]]; then + if ! printf '%s\n%s\n' "$body" "$marker" | sudo tee "$tmp" >/dev/null; then + sudo rm -f "$tmp" + echo "warning: could not stage ${path}" >&2 + return 0 + fi + else + if ! printf '%s\n%s\n' "$marker" "$body" | sudo tee "$tmp" >/dev/null; then + sudo rm -f "$tmp" + echo "warning: could not stage ${path}" >&2 + return 0 + fi + fi + if ! sudo chmod 0644 "$tmp" || ! sudo mv -f "$tmp" "$path"; then + sudo rm -f "$tmp" + echo "warning: could not install ${path}" >&2 + fi + } + + put_loader "$bash_path" "if [[ -x $quoted_binary ]]; then source <($quoted_binary completion bash); fi" + zsh_body="#compdef aenv +if [[ -x $quoted_binary ]]; then eval \"\$($quoted_binary completion zsh)\"; fi" + put_loader "$zsh_path" "$zsh_body" + put_loader "$fish_path" "if test -x $quoted_binary; $quoted_binary completion fish | source; end" +} + +install_completion_files + # --------------------------------------------------------------------------- # 2. Install the server # --------------------------------------------------------------------------- diff --git a/scripts/tests/verify-completion-install.sh b/scripts/tests/verify-completion-install.sh new file mode 100755 index 00000000..6a36b62c --- /dev/null +++ b/scripts/tests/verify-completion-install.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_root/scripts/install-completions.sh" +tmp_root="$(mktemp -d)" +trap 'rm -rf "$tmp_root"' EXIT + +home="$tmp_root/home" +bin="$tmp_root/bin" +prefix="$tmp_root/prefix" +mkdir -p "$home" "$bin" "$prefix" + +printf '#!/usr/bin/env bash\necho completion\n' > "$bin/aenv-v1" +printf '#!/usr/bin/env bash\necho newer-completion\n' > "$bin/aenv-v2" +chmod 0755 "$bin/aenv-v1" "$bin/aenv-v2" + +bash_file="$home/.local/share/bash-completion/completions/aenv" +zsh_file="$home/.local/share/zsh/site-functions/_aenv" +fish_file="$home/.config/fish/completions/aenv.fish" +marker='# managed by aenv completion installer' + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +assert_file() { [[ -f "$1" ]] || fail "expected file $1"; } +assert_absent() { [[ ! -e "$1" ]] || fail "expected $1 to be absent"; } +assert_contains() { grep -Fq -- "$2" "$1" || fail "expected $1 to contain: $2"; } +file_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + +echo '==> user install creates standard loaders' +HOME="$home" bash "$helper" install --prefix="$home/.local" --binary="$bin/aenv-v1" >/dev/null +assert_file "$bash_file" +assert_file "$zsh_file" +assert_file "$fish_file" +assert_contains "$bash_file" "$marker" +assert_contains "$zsh_file" '#compdef aenv' +[[ "$(sed -n '1p' "$zsh_file")" == '#compdef aenv' ]] || fail 'zsh #compdef must remain first' +[[ "$(file_mode "$bash_file")" == 644 ]] || fail 'Bash loader mode must be 0644' +[[ "$(file_mode "$zsh_file")" == 644 ]] || fail 'Zsh loader mode must be 0644' +[[ "$(file_mode "$fish_file")" == 644 ]] || fail 'Fish loader mode must be 0644' +[[ ! -e "$home/.zshrc" ]] || fail 'installer must not create .zshrc' + +if command -v zsh >/dev/null 2>&1; then + zsh -n "$zsh_file" +fi +if command -v fish >/dev/null 2>&1; then + fish -n "$fish_file" +fi +bash -n "$bash_file" + +echo '==> unmanaged files are preserved' +printf '# user completion\n' > "$bash_file" +printf '# user zsh completion\n' > "$zsh_file" +printf '# user fish completion\n' > "$fish_file" +HOME="$home" bash "$helper" install --prefix="$home/.local" --binary="$bin/aenv-v2" >/dev/null +assert_contains "$bash_file" '# user completion' +assert_contains "$zsh_file" '# user zsh completion' +assert_contains "$fish_file" '# user fish completion' + +echo '==> uninstall preserves unmanaged files' +HOME="$home" bash "$helper" uninstall --prefix="$home/.local" --binary="$bin/aenv-v2" >/dev/null +assert_contains "$bash_file" '# user completion' +assert_contains "$zsh_file" '# user zsh completion' +assert_contains "$fish_file" '# user fish completion' + +echo '==> managed files are upgraded' +rm -f "$bash_file" "$zsh_file" "$fish_file" +HOME="$home" bash "$helper" install --prefix="$home/.local" --binary="$bin/aenv-v1" >/dev/null +HOME="$home" bash "$helper" install --prefix="$home/.local" --binary="$bin/aenv-v2" >/dev/null +assert_contains "$bash_file" "$bin/aenv-v2" +[[ "$(file_mode "$bash_file")" == 644 ]] || fail 'upgraded loader mode must be 0644' + +echo '==> uninstall removes only managed files' +HOME="$home" bash "$helper" uninstall --prefix="$home/.local" --binary="$bin/aenv-v2" >/dev/null +assert_absent "$bash_file" +assert_absent "$zsh_file" +assert_absent "$fish_file" + +echo '==> symlink destinations are preserved' +symlink_target="$tmp_root/user-completion" +printf '# user target\n' > "$symlink_target" +ln -s "$symlink_target" "$bash_file" +HOME="$home" bash "$helper" install --prefix="$home/.local" --binary="$bin/aenv-v1" >/dev/null +[[ -L "$bash_file" ]] || fail 'completion symlink was replaced' +assert_contains "$symlink_target" '# user target' +rm -f "$bash_file" + +echo '==> staging failures leave no temporary files' +fake_tools="$tmp_root/fake-tools" +mkdir -p "$fake_tools" +printf '#!/usr/bin/env bash\nexit 1\n' > "$fake_tools/mktemp" +chmod 0755 "$fake_tools/mktemp" +HOME="$home" PATH="$fake_tools:$PATH" bash "$helper" install --prefix="$home/.local" --binary="$bin/aenv-v1" >/dev/null +shopt -s nullglob +leftovers=( + "$home/.local/share/bash-completion/completions"/.aenv-completion.* + "$home/.local/share/zsh/site-functions"/.aenv-completion.* + "$home/.config/fish/completions"/.aenv-completion.* +) +[[ "${#leftovers[@]}" -eq 0 ]] || fail 'staging failure left a temporary completion file' + +echo '==> system paths use the prefix share directories' +HOME="$home" bash "$helper" install --prefix="$prefix" --binary="$bin/aenv-v1" >/dev/null +assert_file "$prefix/share/bash-completion/completions/aenv" +assert_file "$prefix/share/zsh/site-functions/_aenv" +assert_file "$prefix/share/fish/vendor_completions.d/aenv.fish" +HOME="$home" bash "$helper" uninstall --prefix="$prefix" --binary="$bin/aenv-v1" >/dev/null +assert_absent "$prefix/share/bash-completion/completions/aenv" + +echo '==> path traversal cannot select user mode' +outside="$tmp_root/outside" +mkdir -p "$outside" +HOME="$home" bash "$helper" install --prefix="$home/../outside" --binary="$bin/aenv-v1" >/dev/null +assert_file "$outside/share/bash-completion/completions/aenv" +assert_absent "$home/.local/share/bash-completion/completions/aenv" +HOME="$home" bash "$helper" uninstall --prefix="$home/../outside" --binary="$bin/aenv-v1" >/dev/null +assert_absent "$outside/share/bash-completion/completions/aenv" + +echo '==> completion installation checks passed'