diff --git a/.github/workflows/check-linux.yml b/.github/workflows/check-linux.yml index e454aea..c731228 100644 --- a/.github/workflows/check-linux.yml +++ b/.github/workflows/check-linux.yml @@ -4,6 +4,7 @@ on: push: branches: [main] paths: + - "public/setup/**" - "public/sh/**" - "public/zsh/**" - "tests/**" @@ -62,7 +63,7 @@ jobs: set -eu sh -x ./public/sh/install.sh -a annex test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" - grep -q "z-shell/z-a-meta-plugins" ~/.zshrc + grep -q "z-shell/z-a-meta-plugins" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - name: "⚙️ Check: install.sh -a loader" run: | @@ -70,14 +71,15 @@ jobs: sh -x ./public/sh/install.sh -a loader test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" test -f "${XDG_CONFIG_HOME:-$HOME/.config}/zi/init.zsh" - grep -q "zzinit" ~/.zshrc + grep -Fq "source '${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh'" ~/.zshrc + grep -Fq "zzinit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh" command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - name: "⚙️ Check: install.sh -a zunit" run: | set -eu sh -x ./public/sh/install.sh -a zunit test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" - grep -q "@zunit" ~/.zshrc + grep -q "@zunit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - name: "⚙️ Check: install_zpmod.sh" run: sh -x ./public/sh/install_zpmod.sh diff --git a/.github/workflows/check-macos.yml b/.github/workflows/check-macos.yml index c5bf96a..b20292b 100644 --- a/.github/workflows/check-macos.yml +++ b/.github/workflows/check-macos.yml @@ -5,6 +5,7 @@ on: push: branches: [main] paths: + - "public/setup/**" - "public/sh/**" - "public/zsh/**" - "tests/**" @@ -62,7 +63,7 @@ jobs: set -eu sh -x ./public/sh/install.sh -a annex test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" - grep -q "z-shell/z-a-meta-plugins" ~/.zshrc + grep -q "z-shell/z-a-meta-plugins" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - name: "⚙️ Check: install.sh -a loader" run: | @@ -70,14 +71,15 @@ jobs: sh -x ./public/sh/install.sh -a loader test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" test -f "${XDG_CONFIG_HOME:-$HOME/.config}/zi/init.zsh" - grep -q "zzinit" ~/.zshrc + grep -Fq "source '${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh'" ~/.zshrc + grep -Fq "zzinit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh" command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - name: "⚙️ Check: install.sh -a zunit" run: | set -eu sh -x ./public/sh/install.sh -a zunit test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" - grep -q "@zunit" ~/.zshrc + grep -q "@zunit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - name: "⚙️ Check: install_zpmod.sh" run: sh -x ./public/sh/install_zpmod.sh diff --git a/.github/workflows/checksum.yml b/.github/workflows/checksum.yml index 25e6d72..c5d818a 100644 --- a/.github/workflows/checksum.yml +++ b/.github/workflows/checksum.yml @@ -3,9 +3,11 @@ name: Checksum on: push: paths: + - "public/setup/**" - "public/sh/generate-checksums.sh" - "public/sh/install_zpmod.sh" - "public/sh/install.sh" + - "public/sh/setup.sh" - "public/sh/sync-init.sh" - "public/zsh/init.zsh" - ".github/workflows/checksum.yml" diff --git a/.github/workflows/win-install.yml b/.github/workflows/win-install.yml index b9c9504..7d54d52 100644 --- a/.github/workflows/win-install.yml +++ b/.github/workflows/win-install.yml @@ -4,12 +4,14 @@ name: Windows Install on: pull_request: paths: + - "public/setup/**" - "public/sh/**" - "public/zsh/**" - "tests/**" - ".github/workflows/win-install.yml" push: paths: + - "public/setup/**" - "public/sh/**" - "public/zsh/**" - "tests/**" @@ -46,20 +48,28 @@ jobs: - name: 🪟 Dependencies (Windows) uses: egor-tensin/setup-cygwin@fca9069f92361187d4abfaa5d8a7490e435d8349 # v4.0.2 with: - platform: x64 packages: curl git zsh - name: 🪟 Run Install run: | + set -eu + cd "$(cygpath -u "$GITHUB_WORKSPACE")" + command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" sh ./tests/installers.sh - sh -x ./public/sh/install.sh -- -i skip - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" /home/runneradmin/.zi - sh -x ./public/sh/install.sh -- -a annex - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" /home/runneradmin/.zi - sh -x ./public/sh/install.sh -- -a loader - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" /home/runneradmin/.zi - sh -x ./public/sh/install.sh -- -a zunit - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" /home/runneradmin/.zi - sh -x ./public/sh/install.sh -- -a zpmod + sh -x ./public/sh/install.sh -i skip + test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" + test ! -e ~/.zshrc + command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" + sh -x ./public/sh/install.sh -a annex + grep -Fq "z-shell/z-a-meta-plugins" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" + command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" + sh -x ./public/sh/install.sh -a loader + grep -Fq "source '${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh'" ~/.zshrc + grep -Fq "zzinit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh" + command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" + sh -x ./public/sh/install.sh -a zunit + grep -Fq "@zunit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" + # The Cygwin package is older than zpmod's required Zsh 5.8.1. + # Linux and macOS exercise the native module build. shell: C:\tools\cygwin\bin\bash.exe --login -o igncr '{0}' - name: 🪟 Smoke-test — verify zi.zsh present run: | diff --git a/docs/README.md b/docs/README.md index 3d48ca3..f3f7d5e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,45 @@ - **Installer**: [get.zshell.dev](https://get.zshell.dev) - **jsDeliver CDN**: [cdn.jsdelivr.net/gh/z-shell/src@main/](https://cdn.jsdelivr.net/gh/z-shell/src@main/) +### Guided setup + +`public/sh/install.sh` now delegates installation to the POSIX `sh` setup planner. The default `loader` profile writes a reviewable plan, applies the Zi checkout as one phase, and applies loader configuration as a separate phase. The `annex` and `zunit` profiles add pinned recipes that run on the first shell start. + +For a normal installation, run: + +```sh +sh -c "$(curl -fsSL https://get.zshell.dev)" +``` + +The downloaded `install.sh` remains the only entry point; it retrieves and +verifies its planner assets automatically. To install an exact source revision, +use the same tag, branch, or commit for the script and `ZI_SRC_REF`: + +```sh +ref=v1.2.3 +curl -fsSL "https://raw.githubusercontent.com/z-shell/src/${ref}/public/sh/install.sh" | + ZI_SRC_REF="${ref}" sh +``` + +To inspect and apply a plan manually: + +```sh +sh public/sh/setup.sh plan --plan /tmp/zi-setup-plan --profile loader +plan_sha="$(cat /tmp/zi-setup-plan/plan.id)" +sh public/sh/setup.sh apply --plan /tmp/zi-setup-plan --phase checkout --expect "${plan_sha}" +sh public/sh/setup.sh apply --plan /tmp/zi-setup-plan --phase files --expect "${plan_sha}" +``` + +The files phase manages `init.zsh`, `setup.zsh`, `setup/pre.zsh`, `setup/shell.zsh`, and a marked `.zshrc` block. The user-facing block stays intentionally short: + +```zsh +# >>> zi setup >>> +source '/home/you/.config/zi/setup.zsh' +# <<< zi setup <<< +``` + +The generated `setup.zsh` entrypoint owns the startup sequence and diagnostics. The files phase validates every recorded target before writing any target. A symlinked `.zshrc`, an externally changed managed block, an unrecognized Zi startup block, or checkout drift is refused with remediation output instead of being overwritten. + ### Loader configuration `public/zsh/init.zsh` defines `zzinit()`. Sourcing the file only declares the @@ -61,13 +100,13 @@ function and applies defaults; nothing is cloned, sourced, or written until The loader owns only the settings that must exist before Zi does: -| Setting | Default | Purpose | -| ------------------- | -------------------------------------------- | ---------------------------- | -| `ZI[REPOSITORY]` | `https://github.com/z-shell/zi.git` | Clone source | -| `ZI[STREAM]` | `main` | Branch or tag to clone | -| `ZI[HOME_DIR]` | Legacy home, otherwise XDG data `zi` root | Working-directory root | -| `ZI[BIN_DIR]` | `${ZI[HOME_DIR]}/bin` | Where `zi.zsh` is cloned | -| `ZI[MUTE_WARNINGS]` | `0` | Loader warning control | +| Setting | Default | Purpose | +| ------------------- | ----------------------------------------- | ------------------------ | +| `ZI[REPOSITORY]` | `https://github.com/z-shell/zi.git` | Clone source | +| `ZI[STREAM]` | `main` | Branch or tag to clone | +| `ZI[HOME_DIR]` | Legacy home, otherwise XDG data `zi` root | Working-directory root | +| `ZI[BIN_DIR]` | `${ZI[HOME_DIR]}/bin` | Where `zi.zsh` is cloned | +| `ZI[MUTE_WARNINGS]` | `0` | Loader warning control | The loader mirrors Zi core's home-resolution contract because it must find or clone `zi.zsh` before core can run. An explicit `ZI[HOME_DIR]` wins. A @@ -85,9 +124,9 @@ it; do not add a duplicate default to the loader. See the One loader-only toggle exists: -| Setting | Default | Purpose | -| --------------------- | ------- | ----------------------------------------------------------- | -| `ZI[LOADER_HISTORY]` | `1` | Set to `0` to leave `HISTFILE`/`SAVEHIST`/`HISTSIZE` alone | +| Setting | Default | Purpose | +| -------------------- | ------- | ---------------------------------------------------------- | +| `ZI[LOADER_HISTORY]` | `1` | Set to `0` to leave `HISTFILE`/`SAVEHIST`/`HISTSIZE` alone | ### Maintainer — Verify and Sync Loader diff --git a/public/checksum.txt b/public/checksum.txt index 3d4d07d..ed2a4e1 100644 --- a/public/checksum.txt +++ b/public/checksum.txt @@ -1,4 +1,6 @@ 6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh -ef5ba08f64df01abbe752f6fbab13d231d863d03d41a71fe8eac85979b268de0 public/sh/install.sh +e322f6aea1c7878bdf6d12032fbb83528f7bbccb4738748335373d0c8380cbe5 public/sh/install.sh +5fd4e53be0a80877afd504b6f707d998786c8ceb088b361c49eb5d4d401783eb public/sh/setup.sh 08cc893ceb982fc99d17db1966c6c30790cc571e16e4f5392352d995f5252952 public/sh/sync-init.sh +fff8d1c340fb1e87c76f80cac2224e28761ccc7e2b117839b7be6f5311a1ac11 public/setup/profiles.tsv c979e39748d1d86ace17a61ff2b1bf6e1224a43291c25bf7fad985d1e9e11af1 public/zsh/init.zsh diff --git a/public/setup/profiles.tsv b/public/setup/profiles.tsv new file mode 100644 index 0000000..04dc5fc --- /dev/null +++ b/public/setup/profiles.tsv @@ -0,0 +1,3 @@ +# label verified z-a-meta-plugins revision +annexes 74bd8a8bc3bcff8398420ff5a38758dec5b90e2b +zunit 74bd8a8bc3bcff8398420ff5a38758dec5b90e2b diff --git a/public/sh/generate-checksums.sh b/public/sh/generate-checksums.sh index 318dc9c..b6adc8f 100755 --- a/public/sh/generate-checksums.sh +++ b/public/sh/generate-checksums.sh @@ -15,7 +15,10 @@ set -eu ROOT="$( unset CDPATH cd "$(dirname "$0")/../.." 2>/dev/null && pwd -)" || { printf '%s\n' "generate-checksums: cannot determine repository root" >&2; exit 1; } +)" || { + printf '%s\n' "generate-checksums: cannot determine repository root" >&2 + exit 1 +} CHECKSUM_FILE="${ROOT}/public/checksum.txt" @@ -31,15 +34,16 @@ sha256_file() { } # Clear (or create) the checksum file before writing fresh entries with `: > file`. -: > "${CHECKSUM_FILE}" +: >"${CHECKSUM_FILE}" for f in \ public/sh/install_zpmod.sh \ public/sh/install.sh \ + public/sh/setup.sh \ public/sh/sync-init.sh \ - public/zsh/init.zsh -do + public/setup/profiles.tsv \ + public/zsh/init.zsh; do hash="$(sha256_file "${ROOT}/${f}")" - printf '%s %s\n' "${hash}" "${f}" >> "${CHECKSUM_FILE}" + printf '%s %s\n' "${hash}" "${f}" >>"${CHECKSUM_FILE}" done printf '%s\n' "Checksums written to ${CHECKSUM_FILE}" diff --git a/public/sh/install.sh b/public/sh/install.sh index 574e9cf..9b5b8cf 100755 --- a/public/sh/install.sh +++ b/public/sh/install.sh @@ -4,468 +4,245 @@ set -eu -# Preserve whether the caller selected either checkout component. The loader -# stays dynamically resolved unless an explicit value must survive startup. -ZI_HOME_EXPLICIT=0 -[ -z "${ZI_HOME-}" ] || ZI_HOME_EXPLICIT=1 -ZI_BIN_DIR_NAME_EXPLICIT=0 -[ -z "${ZI_BIN_DIR_NAME-}" ] || ZI_BIN_DIR_NAME_EXPLICIT=1 ZOPT="" -AOPT="" -BOPT="main" +AOPT=loader +BOPT=main +BOPT_EXPLICIT=0 while getopts ":i:a:b:" opt; do case ${opt} in - i) - ZOPT="${ZOPT}${OPTARG}" - ;; - a) - AOPT="${AOPT}${OPTARG}" - ;; + i) ZOPT="${OPTARG}" ;; + a) AOPT="${OPTARG}" ;; b) BOPT="${OPTARG}" + BOPT_EXPLICIT=1 ;; \?) - echo "Invalid option: ${OPTARG}" 1>&2 + printf '%s\n' "Invalid option: ${OPTARG}" >&2 exit 1 ;; :) - echo "Invalid option: ${OPTARG} requires an argument" 1>&2 + printf '%s\n' "Invalid option: ${OPTARG} requires an argument" >&2 exit 1 ;; *) - echo "Invalid option: ${OPTARG}" 1>&2 + printf '%s\n' "Invalid option: ${OPTARG}" >&2 exit 1 ;; esac done shift $((OPTIND - 1)) -# Validate BOPT to prevent sed delimiter injection when building init.zsh. -# | is the sed delimiter used in the substitution; \ and & are special in -# sed replacement expressions. The *[\\]* pattern matches a single backslash. -case "${BOPT}" in -# [\\] is a bracket expression for a literal backslash. -*'|'* | *[\\]* | *'&'*) - printf '%s\n' "-- ERROR -- Invalid -b value: branch name must not contain '|', '\\', or '&'." >&2 +case "${ZOPT}" in "" | skip) ;; *) + printf '%s\n' "-- ERROR -- Unsupported -i profile: ${ZOPT}" >&2 exit 1 ;; esac - -case "${ZDOTDIR-}" in -"" | /*) ;; +case "${AOPT}" in +loader | annex | zunit | zpmod) ;; +direct) + printf '%s\n' 'Zi installer: the direct zi.zsh profile is deprecated; using the guided loader profile.' >&2 + AOPT=loader + ;; *) + printf '%s\n' "-- ERROR -- Unsupported -a profile: ${AOPT}" >&2 + exit 1 + ;; +esac +case "${ZDOTDIR-}" in "" | /*) ;; *) printf '%s\n' "-- ERROR -- ZDOTDIR must be an absolute path when set: ${ZDOTDIR}" >&2 exit 1 ;; esac - -case "${ZI_HOME-}" in -"" | /*) ;; -*) +case "${ZI_HOME-}" in "" | /*) ;; *) printf '%s\n' "-- ERROR -- ZI_HOME must be an absolute path when set: ${ZI_HOME}" >&2 exit 1 ;; esac +ZI_SRC_REF="${ZI_SRC_REF:-main}" +case "${ZI_SRC_REF}" in +"" | -* | *..* | *[!A-Za-z0-9._/-]*) + printf '%s\n' "-- ERROR -- Invalid ZI_SRC_REF: ${ZI_SRC_REF}" >&2 + exit 1 + ;; +esac +command git check-ref-format --branch "${ZI_SRC_REF}" >/dev/null 2>&1 || { + printf '%s\n' "-- ERROR -- ZI_SRC_REF is not a valid Git ref: ${ZI_SRC_REF}" >&2 + exit 1 +} +ZI_SRC_ASSET_ROOT="https://raw.githubusercontent.com/z-shell/src/${ZI_SRC_REF}/public" WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/zi-install.XXXXXX")" || exit 1 trap 'rm -rf "${WORKDIR:?}"' EXIT INT TERM SCRIPT_DIR="" -LOCAL_INIT_ZSH="" -LOCAL_INSTALL_ZPMOD="" case "$0" in -*/?*) - SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" || SCRIPT_DIR="" - ;; +*/?*) SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" || SCRIPT_DIR="" ;; *) ;; esac -if [ -n "${SCRIPT_DIR}" ]; then - if [ -f "${SCRIPT_DIR}/../zsh/init.zsh" ]; then - LOCAL_INIT_ZSH="${SCRIPT_DIR}/../zsh/init.zsh" - fi - if [ -f "${SCRIPT_DIR}/install_zpmod.sh" ]; then - LOCAL_INSTALL_ZPMOD="${SCRIPT_DIR}/install_zpmod.sh" - fi -fi - fetch_to_file() { - _dest="$1" + _fetch_dest="$1" shift - _has_fetcher=0 - - for _src; do - [ -n "${_src}" ] || continue - case "${_src}" in + _fetch_has_tool=0 + for _fetch_source; do + [ -n "${_fetch_source}" ] || continue + case "${_fetch_source}" in http://* | https://*) if command -v curl >/dev/null 2>&1; then - _has_fetcher=1 - if command curl -fsSL "${_src}" -o "${_dest}" 2>/dev/null; then - return 0 - fi + _fetch_has_tool=1 + command curl -fsSL "${_fetch_source}" -o "${_fetch_dest}" 2>/dev/null && return 0 elif command -v wget >/dev/null 2>&1; then - _has_fetcher=1 - if command wget -qO "${_dest}" "${_src}" 2>/dev/null; then - return 0 - fi + _fetch_has_tool=1 + command wget -qO "${_fetch_dest}" "${_fetch_source}" 2>/dev/null && return 0 fi ;; *) - if [ -r "${_src}" ]; then - command cp "${_src}" "${_dest}" - return 0 + if [ -r "${_fetch_source}" ]; then + command cp "${_fetch_source}" "${_fetch_dest}" && return 0 fi ;; esac done - - if [ "${_has_fetcher}" -eq 0 ]; then - printf '%s\n' "-- ERROR -- curl or wget is required to download installer assets" >&2 - fi + [ "${_fetch_has_tool}" -ne 0 ] || printf '%s\n' '-- ERROR -- curl or wget is required to download installer assets' >&2 return 1 } -is_absolute_path() { - case "${1-}" in - /*) return 0 ;; - *) return 1 ;; - esac -} - -zsh_single_quote() { - # Single-quoted Zsh text is inert; represent an embedded quote by ending the - # quote, escaping one literal quote, and reopening it. - printf "'" - printf '%s' "$1" | command sed "s/'/'\\\\''/g" - printf "'" -} - -zi_home_has_installation() { - [ -f "$1/bin/zi.zsh" ] || - [ -d "$1/plugins" ] || - [ -d "$1/snippets" ] || - [ -d "$1/completions" ] || - [ -d "$1/zmodules" ] -} - -if [ "${AOPT}" = loader ]; then - if is_absolute_path "${XDG_CONFIG_HOME-}"; then - ZI_CONFIG_DIR="${XDG_CONFIG_HOME}/zi" +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' else - ZI_CONFIG_DIR="${HOME}/.config/zi" - fi - loader_tmp="${WORKDIR}/init.zsh.tmp" - command mkdir -p "${ZI_CONFIG_DIR}" - set +e - fetch_to_file "${ZI_CONFIG_DIR}/init.zsh" \ - "${LOCAL_INIT_ZSH}" \ - "https://raw.githubusercontent.com/z-shell/src/main/public/zsh/init.zsh" \ - "https://raw.githubusercontent.com/z-shell/src/main/lib/zsh/init.zsh" - fetch_status=$? - set -e - if [ "${fetch_status}" -ne 0 ]; then - printf '%s\n' "-- ERROR -- failed to retrieve init.zsh" >&2 + printf '%s\n' '-- ERROR -- sha256sum or shasum is required' >&2 exit 1 fi - # shellcheck disable=SC2016 - command sed 's|: "${ZI\[STREAM\]:=main}"|: "${ZI[STREAM]:='"${BOPT}"'}"|' "${ZI_CONFIG_DIR}/init.zsh" >"${loader_tmp}" && - command mv "${loader_tmp}" "${ZI_CONFIG_DIR}/init.zsh" - command chmod go-w "${ZI_CONFIG_DIR}" && command chmod a+x "${ZI_CONFIG_DIR}/init.zsh" -fi - -if [ -z "${ZI_HOME-}" ]; then - if is_absolute_path "${XDG_DATA_HOME-}"; then - _zi_data_base="${XDG_DATA_HOME}" - else - _zi_data_base="${HOME}/.local/share" - fi - _zi_legacy_home="${HOME}/.zi" - _zi_xdg_home="${_zi_data_base}/zi" - _zi_legacy_present=0 - _zi_xdg_present=0 - zi_home_has_installation "${_zi_legacy_home}" && _zi_legacy_present=1 - zi_home_has_installation "${_zi_xdg_home}" && _zi_xdg_present=1 - - if [ "${_zi_legacy_present}" -eq 1 ] && [ "${_zi_xdg_present}" -eq 1 ]; then - if [ -f "${_zi_xdg_home}/bin/zi.zsh" ] && [ ! -f "${_zi_legacy_home}/bin/zi.zsh" ]; then - ZI_HOME="${_zi_xdg_home}" - else - ZI_HOME="${_zi_legacy_home}" - printf '%s\n' "Zi installer: both legacy and XDG homes were detected; retaining ${ZI_HOME}. Set ZI_HOME explicitly to select another root. No data was moved." >&2 - fi - elif [ "${_zi_legacy_present}" -eq 1 ]; then - ZI_HOME="${_zi_legacy_home}" - else - ZI_HOME="${_zi_xdg_home}" - fi -fi - -if [ -z "${ZI_BIN_DIR_NAME-}" ]; then - ZI_BIN_DIR_NAME="bin" -fi +} -ZI_LOADER_PATHS_EXPLICIT=0 -ZI_LOADER_HOME_TEXT="" -ZI_LOADER_BIN_TEXT="" -if [ "${AOPT}" = loader ] && - { [ "${ZI_HOME_EXPLICIT}" -eq 1 ] || [ "${ZI_BIN_DIR_NAME_EXPLICIT}" -eq 1 ]; }; then - ZI_LOADER_PATHS_EXPLICIT=1 - ZI_LOADER_HOME_TEXT="$(zsh_single_quote "${ZI_HOME}")" - ZI_LOADER_BIN_TEXT="$(zsh_single_quote "${ZI_HOME}/${ZI_BIN_DIR_NAME}")" -fi +verify_asset() { + _verify_file="$1" + _verify_name="$2" + _verify_checksum="$3" + _verify_expected="$(awk -v name="${_verify_name}" '$2 == name {print $1}' "${_verify_checksum}")" + [ -n "${_verify_expected}" ] || { + printf '%s\n' "-- ERROR -- checksum entry missing for ${_verify_name}" >&2 + exit 1 + } + [ "$(sha256_file "${_verify_file}")" = "${_verify_expected}" ] || { + printf '%s\n' "-- ERROR -- checksum verification failed for ${_verify_name}" >&2 + exit 1 + } +} -if ! test -d "${ZI_HOME}"; then - command mkdir -p "${ZI_HOME}" - command chmod 700 "${ZI_HOME}" +LOCAL_INIT="" +LOCAL_SETUP="" +LOCAL_PROFILES="" +LOCAL_CHECKSUM="" +LOCAL_ZPMOD="" +if [ -n "${SCRIPT_DIR}" ]; then + [ ! -f "${SCRIPT_DIR}/../zsh/init.zsh" ] || LOCAL_INIT="${SCRIPT_DIR}/../zsh/init.zsh" + [ ! -f "${SCRIPT_DIR}/setup.sh" ] || LOCAL_SETUP="${SCRIPT_DIR}/setup.sh" + [ ! -f "${SCRIPT_DIR}/../setup/profiles.tsv" ] || LOCAL_PROFILES="${SCRIPT_DIR}/../setup/profiles.tsv" + [ ! -f "${SCRIPT_DIR}/../checksum.txt" ] || LOCAL_CHECKSUM="${SCRIPT_DIR}/../checksum.txt" + [ ! -f "${SCRIPT_DIR}/install_zpmod.sh" ] || LOCAL_ZPMOD="${SCRIPT_DIR}/install_zpmod.sh" fi -if ! command -v git >/dev/null 2>&1; then - printf '%s\n' "▓▒░ Something went wrong: no git available, cannot proceed." - exit 1 +CHECKSUM_ASSET="${LOCAL_CHECKSUM}" +if [ -z "${CHECKSUM_ASSET}" ]; then + CHECKSUM_ASSET="${WORKDIR}/checksum.txt" + fetch_to_file "${CHECKSUM_ASSET}" \ + "${ZI_SRC_ASSET_ROOT}/checksum.txt" || { + printf '%s\n' '-- ERROR -- failed to retrieve checksum manifest' >&2 + exit 1 + } fi -# -b is used as a fetch refspec and inside generated Zsh: accept only a -# well-formed branch name (no ':', no leading '-', no '..'). -if ! command git check-ref-format --branch "${BOPT}" >/dev/null 2>&1; then - printf '%s\n' "-- ERROR -- Invalid -b value: '${BOPT}' is not a valid branch name." >&2 - exit 1 +INIT_ASSET="${LOCAL_INIT}" +if [ -z "${INIT_ASSET}" ]; then + INIT_ASSET="${WORKDIR}/init.zsh" + fetch_to_file "${INIT_ASSET}" \ + "${ZI_SRC_ASSET_ROOT}/zsh/init.zsh" || { + printf '%s\n' '-- ERROR -- failed to retrieve init.zsh' >&2 + exit 1 + } fi -# Get the download-progress bar tool -command mkdir -p /tmp/zi -cd /tmp/zi || exit 1 -set +e -fetch_to_file /tmp/zi/git-process-output.zsh \ - "" \ - "https://raw.githubusercontent.com/z-shell/zi/main/public/zsh/git-process-output.zsh" \ - "https://raw.githubusercontent.com/z-shell/zi/main/lib/zsh/git-process-output.zsh" -fetch_status=$? -set -e -if [ "${fetch_status}" -ne 0 ]; then - printf '%s\n' "-- ERROR -- failed to retrieve git-process-output.zsh" >&2 - exit 1 +SETUP_ASSET="${LOCAL_SETUP}" +if [ -z "${SETUP_ASSET}" ]; then + SETUP_ASSET="${WORKDIR}/setup.sh" + fetch_to_file "${SETUP_ASSET}" \ + "${ZI_SRC_ASSET_ROOT}/sh/setup.sh" || { + printf '%s\n' '-- ERROR -- failed to retrieve setup.sh' >&2 + exit 1 + } fi -command chmod a+x /tmp/zi/git-process-output.zsh -if test -d "${ZI_HOME}/${ZI_BIN_DIR_NAME}/.git"; then - _zi_valid=0 - if test -f "${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh"; then - # Canonical zi remote URLs (HTTPS and SSH, with and without .git suffix) - case "$(command git -C "${ZI_HOME}/${ZI_BIN_DIR_NAME}" remote get-url origin 2>/dev/null || true)" in - https://github.com/z-shell/zi | https://github.com/z-shell/zi.git | \ - git@github.com:z-shell/zi | git@github.com:z-shell/zi.git) - _zi_valid=1 - ;; - esac - fi - if [ "${_zi_valid}" -ne 1 ]; then - printf '%s\n' "▓▒░ ${ZI_HOME}/${ZI_BIN_DIR_NAME} contains a .git directory but does not appear to be a zi repository." >&2 - printf '%s\n' "▓▒░ Expected zi.zsh and a z-shell/zi remote origin. Unset ZI_HOME/ZI_BIN_DIR_NAME or remove the directory to install fresh." >&2 - exit 1 - fi - cd "${ZI_HOME}/${ZI_BIN_DIR_NAME}" || exit 1 - printf '%s\n' "▓▒░ Updating (z-shell/zi) plugin manager at ${ZI_HOME}/${ZI_BIN_DIR_NAME}" - # Match `zi self-update`: fetch the requested branch and fast-forward only. - # Local state is never discarded; refuse loudly when it cannot be advanced. - if ! command git fetch -q origin "refs/heads/${BOPT}"; then - printf '%s\n' "-- ERROR -- failed to fetch origin ${BOPT} into ${ZI_HOME}/${ZI_BIN_DIR_NAME}" >&2 +PROFILES_ASSET="${LOCAL_PROFILES}" +if [ -z "${PROFILES_ASSET}" ]; then + PROFILES_ASSET="${WORKDIR}/profiles.tsv" + fetch_to_file "${PROFILES_ASSET}" \ + "${ZI_SRC_ASSET_ROOT}/setup/profiles.tsv" || { + printf '%s\n' '-- ERROR -- failed to retrieve setup profile table' >&2 exit 1 - fi - if ! command git merge -q --ff-only FETCH_HEAD; then - printf '%s\n' "-- ERROR -- ${ZI_HOME}/${ZI_BIN_DIR_NAME} cannot be fast-forwarded to origin/${BOPT}; local state was left untouched:" >&2 - command git status --short --branch 2>/dev/null | head -20 >&2 - printf '%s\n' "-- ERROR -- resolve the checkout state shown above so HEAD can fast-forward to origin/${BOPT} (move local commits to another branch, or drop changes you do not need), then rerun the installer." >&2 - exit 1 - fi -else - cd "${ZI_HOME}" || exit 1 - printf '%s\n' "▓▒░ Installing (z-shell/zi) plugin manager at ${ZI_HOME}/${ZI_BIN_DIR_NAME}" - { git clone --progress --depth=1 --branch "${BOPT}" https://github.com/z-shell/zi.git "${ZI_BIN_DIR_NAME}" \ - 2>&1 | { /tmp/zi/git-process-output.zsh || cat; }; } 2>/dev/null - if [ -d "${ZI_HOME}/${ZI_BIN_DIR_NAME}" ] && [ -f "${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh" ]; then - printf '%s\n' "▓▒░ Successfully installed at ${ZI_HOME}/${ZI_BIN_DIR_NAME}" - else - printf '%s\n' "▓▒░ Something went wrong, couldn't install ZI at ${ZI_HOME}/${ZI_BIN_DIR_NAME}" - exit 1 - fi + } fi -# -# Modify .zshrc -# - -MAIN_PROFILE() { - THE_ZDOTDIR="${ZDOTDIR:-${HOME}}" - ZSHRC_INTEGRATED=0 - # Detect an existing Zi integration by a real source line. A comment that - # merely mentions the file name must not suppress the integration. - if grep -E '^[[:space:]]*(source|\.)[[:space:]]+[^#]*(zi|init|zinit)\.zsh(["'"'"'[:space:]]|$)' "${THE_ZDOTDIR}/.zshrc" >/dev/null 2>&1; then - printf '%s\n' "▓▒░ Seems that .zshrc already sources Zi - the integration block will not be added." - ZSHRC_INTEGRATED=1 - fi - # The .zshrc text refers to the home through $HOME; the installer itself - # keeps using the real path. - # shellcheck disable=SC2016 - case "${ZI_HOME}" in - "${HOME}") ZI_HOME_TEXT='$HOME' ;; - "${HOME}"/*) ZI_HOME_TEXT="\$HOME${ZI_HOME#"${HOME}"}" ;; - *) ZI_HOME_TEXT="${ZI_HOME}" ;; - esac - if [ "${ZOPT}" != skip ] && [ "${ZSHRC_INTEGRATED}" -eq 0 ] && [ "${AOPT}" != loader ]; then - printf '%s\n' "▓▒░ Updating ${THE_ZDOTDIR}/.zshrc" - command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" -if [[ ! -f ${ZI_HOME_TEXT}/${ZI_BIN_DIR_NAME}/zi.zsh ]]; then - print -P "%F{33}▓▒░ %F{160}Installing (%F{33}z-shell/zi%F{160})…%f" - command mkdir -p "${ZI_HOME_TEXT}" && command chmod go-rwX "${ZI_HOME_TEXT}" - command git clone -q --filter=blob:none --single-branch --branch "${BOPT}" https://github.com/z-shell/zi "${ZI_HOME_TEXT}/${ZI_BIN_DIR_NAME}" && \\ - print -P "%F{33}▓▒░ %F{34}Installation successful.%f%b" || \\ - print -P "%F{160}▓▒░ The clone has failed.%f%b" -fi -source "${ZI_HOME_TEXT}/${ZI_BIN_DIR_NAME}/zi.zsh" -autoload -Uz _zi -(( \${+_comps} )) && _comps[zi]=_zi -# examples here -> https://wiki.zshell.dev/ecosystem/category/-annexes -zicompinit # <- https://wiki.zshell.dev/docs/guides/commands -EOF - printf '%s\n' "▓▒░ Minimal configuration" +verify_asset "${INIT_ASSET}" public/zsh/init.zsh "${CHECKSUM_ASSET}" +verify_asset "${SETUP_ASSET}" public/sh/setup.sh "${CHECKSUM_ASSET}" +verify_asset "${PROFILES_ASSET}" public/setup/profiles.tsv "${CHECKSUM_ASSET}" + +PLAN_DIR="${WORKDIR}/plan" +if [ "${AOPT}" = zpmod ]; then PLAN_PROFILE=loader; else PLAN_PROFILE="${AOPT}"; fi + +create_plan() ( + set -- plan \ + --plan "${PLAN_DIR}" \ + --profile "${PLAN_PROFILE}" \ + --init "${INIT_ASSET}" \ + --profiles "${PROFILES_ASSET}" \ + --checksum "${CHECKSUM_ASSET}" + if [ "${BOPT_EXPLICIT}" -eq 1 ]; then set -- "$@" --ref "${BOPT}"; fi + if [ -n "${ZI_HOME-}" ]; then set -- "$@" --zi-home "${ZI_HOME}"; fi + if [ -n "${ZI_BIN_DIR_NAME-}" ]; then set -- "$@" --zi-bin-dir "${ZI_BIN_DIR_NAME}"; fi + if [ "${ZOPT}" = skip ]; then set -- "$@" --skip-zshrc; fi + sh "${SETUP_ASSET}" "$@" +) +create_plan + +PLAN_ID="$(cat "${PLAN_DIR}/plan.id")" +sh "${SETUP_ASSET}" apply --plan "${PLAN_DIR}" --phase checkout --expect "${PLAN_ID}" +sh "${SETUP_ASSET}" apply --plan "${PLAN_DIR}" --phase files --expect "${PLAN_ID}" + +CHECKOUT_PATH="$(sed -n 's/^checkout_path=//p' "${PLAN_DIR}/plan.meta")" +if [ "${AOPT}" = zpmod ]; then + ZPMOD_ASSET="${LOCAL_ZPMOD}" + if [ -z "${ZPMOD_ASSET}" ]; then + ZPMOD_ASSET="${WORKDIR}/install_zpmod.sh" + fetch_to_file "${ZPMOD_ASSET}" \ + "${ZI_SRC_ASSET_ROOT}/sh/install_zpmod.sh" || { + printf '%s\n' '-- ERROR -- failed to retrieve install_zpmod.sh' >&2 + exit 1 + } + verify_asset "${ZPMOD_ASSET}" public/sh/install_zpmod.sh "${CHECKSUM_ASSET}" fi - if [ "${AOPT}" = loader ] && [ "${ZOPT}" != skip ] && [ "${ZSHRC_INTEGRATED}" -eq 0 ]; then - command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" -if [[ -n \${XDG_CONFIG_HOME:-} && \${XDG_CONFIG_HOME} == /* ]]; then - ZI_LOADER_CONFIG_HOME="\${XDG_CONFIG_HOME}/zi" -else - ZI_LOADER_CONFIG_HOME="\${HOME}/.config/zi" + if [ "$#" -gt 0 ]; then sh "${ZPMOD_ASSET}" "$@"; else sh "${ZPMOD_ASSET}"; fi fi -EOF - if [ "${ZI_LOADER_PATHS_EXPLICIT}" -eq 1 ]; then - command printf '%s\n' \ - 'typeset -gA ZI' \ - "ZI[HOME_DIR]=${ZI_LOADER_HOME_TEXT}" \ - "ZI[BIN_DIR]=${ZI_LOADER_BIN_TEXT}" >>"${THE_ZDOTDIR}/.zshrc" - fi - command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" -if [[ -r "\${ZI_LOADER_CONFIG_HOME}/init.zsh" ]]; then - source "\${ZI_LOADER_CONFIG_HOME}/init.zsh" && zzinit -fi -unset ZI_LOADER_CONFIG_HOME -EOF - printf '%s\n' "▓▒░ Loader added" - fi -} - -ANNEX_PROFILE() { - if [ "${AOPT}" != annex ] && [ "${AOPT}" != zunit ]; then - printf '%s\n' "▓▒░ Skipped all annexes" - return 0 - fi - if [ "${ZOPT}" = skip ]; then - printf '%s\n' "▓▒░ .zshrc changes were skipped (-i skip); annexes were not configured" - return 0 - fi - # Rerunning the installer must not append the block a second time. - if grep -E '^[^#]*z-shell/z-a-meta-plugins([[:space:]]|$)' "${THE_ZDOTDIR}/.zshrc" >/dev/null 2>&1; then - printf '%s\n' "▓▒░ .zshrc already loads z-shell/z-a-meta-plugins - annex block not added again" - return 0 - fi - # The burst file holds only the recipe; zicompinit belongs to .zshrc and - # must not run in the non-interactive burst shell (compinit aborts there). - file="${WORKDIR}/temp-zsh-config" - if [ "${AOPT}" = annex ]; then - command cat <<-EOF >"${file}" -zi light-mode for \\ - z-shell/z-a-meta-plugins \\ - @annexes # <- https://wiki.zshell.dev/ecosystem/category/-annexes -EOF - printf '%s\n' "▓▒░ Installing annexes" - command cat "${file}" >>"${THE_ZDOTDIR}/.zshrc" - command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" -# examples here -> https://wiki.zshell.dev/community/gallery/collection -zicompinit # <- https://wiki.zshell.dev/docs/guides/commands -EOF - else - command cat <<-EOF >"${file}" -zi light-mode for \\ - z-shell/z-a-meta-plugins \\ - @annexes @zunit -EOF - printf '%s\n' "▓▒░ Installing annexes + zunit" - command cat "${file}" >>"${THE_ZDOTDIR}/.zshrc" - fi - ANNEX_BURST "${file}" -} - -ANNEX_BURST() { - # Install the annexes now without an interactive shell and with -f, so no - # user startup file (not even .zshenv) runs: source only zi.zsh and the - # fragment just written. A failure here must not abort an install whose - # .zshrc changes are already in place. - if zsh -f -c 'builtin source "$1" && builtin source "$2" && @zi-scheduler burst' zsh \ - "${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh" "$1"; then - return 0 - fi - printf '%s\n' "▓▒░ Annexes could not be installed now; they will be installed on the next shell start." >&2 - return 0 -} -ZPMOD_PROFILE() { - _zpmod_sh="" - if [ -n "${LOCAL_INSTALL_ZPMOD}" ]; then - _zpmod_sh="${LOCAL_INSTALL_ZPMOD}" - else - _zpmod_sh="${WORKDIR}/install_zpmod.sh" - set +e - fetch_to_file "${_zpmod_sh}" \ - "" \ - "https://raw.githubusercontent.com/z-shell/src/main/public/sh/install_zpmod.sh" \ - "https://raw.githubusercontent.com/z-shell/src/main/lib/sh/install_zpmod.sh" - fetch_status=$? - set -e - if [ "${fetch_status}" -ne 0 ]; then - printf '%s\n' "-- ERROR -- failed to download install_zpmod.sh" >&2 - exit 1 - fi - command chmod a+x "${_zpmod_sh}" - fi +printf '%s\n' "Successfully installed at ${CHECKOUT_PATH}" +if [ "${PLAN_PROFILE}" = annex ] || [ "${PLAN_PROFILE}" = zunit ]; then + printf '%s\n' 'Zi installer: recipe installation is deferred to the first shell start.' +fi - if [ "$#" -gt 0 ]; then - exec sh "${_zpmod_sh}" "$@" +if [ -d "${CHECKOUT_PATH}/.git" ]; then + git_refs="$(command git -C "${CHECKOUT_PATH}" log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit 2>/dev/null | head -5 || true)" + if [ -n "${git_refs}" ]; then + printf '%s\n' 'Latest changes:' + printf '%s\n' "${git_refs}" fi - exec sh "${_zpmod_sh}" -} - -CLOSE_PROFILE() { - git_refs="$( - command cd "${ZI_HOME}/${ZI_BIN_DIR_NAME}" || true - command git log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit | head -5 - )" - printf '%s\n' "▓▒░ Latest changes:" - printf '%s\n' "${git_refs}" -} +fi -MAIN() { - if [ "${AOPT}" = zpmod ]; then - if [ "$#" -gt 0 ]; then - ZPMOD_PROFILE "$@" - else - ZPMOD_PROFILE - fi - else - MAIN_PROFILE - ANNEX_PROFILE - CLOSE_PROFILE - fi - command cat <<-EOF -▓▒░ ■■■■■■■■■■■■■■■■■ Successfully installed ❮ ZI ❯ ■■■■■■■■■ -▓▒░ Wiki: https://wiki.zshell.dev -▓▒░ Issues: https://github.com/z-shell/zi/issues -▓▒░ Discussions: https://discussions.zshell.dev -▓▒░ ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ +command cat <<'EOF' +Successfully installed Zi. +Wiki: https://wiki.zshell.dev +Issues: https://github.com/z-shell/zi/issues +Discussions: https://discussions.zshell.dev EOF - exit 0 -} - -if [ "$#" -gt 0 ]; then - MAIN "$@" -else - MAIN -fi diff --git a/public/sh/setup.sh b/public/sh/setup.sh new file mode 100755 index 0000000..b653a14 --- /dev/null +++ b/public/sh/setup.sh @@ -0,0 +1,949 @@ +#!/usr/bin/env sh +# -*- mode: sh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=sh sw=2 ts=2 et + +set -eu + +PROGRAM="${0##*/}" +SCRIPT_DIR="$( + unset CDPATH + cd "$(dirname "$0")" 2>/dev/null && pwd +)" || exit 1 +ROOT="$( + unset CDPATH + cd "${SCRIPT_DIR}/../.." 2>/dev/null && pwd +)" || exit 1 + +die() { + printf '%s\n' "${PROGRAM}: $*" >&2 + exit 1 +} + +usage() { + command cat <<'EOF' +Usage: + setup.sh plan --plan DIR [--profile loader|annex|zunit] [--ref REF] + [--zi-home DIR] [--zi-bin-dir NAME] [--config-home DIR] + [--zshrc FILE] [--init FILE] [--profiles FILE] + [--checksum FILE] [--skip-zshrc] + setup.sh apply --plan DIR --phase checkout|files [--expect SHA256] +EOF +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + die "sha256sum or shasum is required" + fi +} + +is_absolute_path() { + case "${1-}" in + /*) return 0 ;; + *) return 1 ;; + esac +} + +validate_text_path() { + case "$2" in + *" +"* | *" "*) die "$1 must not contain a newline or tab" ;; + esac +} + +validate_ref() { + case "$1" in + "" | -* | *..* | *[!A-Za-z0-9._/-]*) + die "invalid ref '$1': use ASCII letters, digits, '.', '_', '/', or '-', with no leading '-' or '..'" + ;; + esac + command git check-ref-format --branch "$1" >/dev/null 2>&1 || + die "invalid ref '$1': not a valid branch name" +} + +zsh_single_quote() { + _zsh_quote_value="$(printf '%s' "$1" | command sed "s/'/'\\\\''/g")" || + die 'failed to serialize a Zsh string' + printf "'%s'" "${_zsh_quote_value}" +} + +zi_home_has_installation() { + [ -f "$1/bin/zi.zsh" ] || + [ -d "$1/plugins" ] || + [ -d "$1/snippets" ] || + [ -d "$1/completions" ] || + [ -d "$1/zmodules" ] +} + +resolve_zi_home() { + if [ -n "${ZI_HOME-}" ]; then + printf '%s\n' "${ZI_HOME}" + return + fi + if is_absolute_path "${XDG_DATA_HOME-}"; then + _resolve_data="${XDG_DATA_HOME}" + else + _resolve_data="${HOME}/.local/share" + fi + _resolve_legacy="${HOME}/.zi" + _resolve_xdg="${_resolve_data}/zi" + _resolve_legacy_present=0 + _resolve_xdg_present=0 + zi_home_has_installation "${_resolve_legacy}" && _resolve_legacy_present=1 + zi_home_has_installation "${_resolve_xdg}" && _resolve_xdg_present=1 + if [ "${_resolve_legacy_present}" -eq 1 ] && [ "${_resolve_xdg_present}" -eq 1 ]; then + if [ -f "${_resolve_xdg}/bin/zi.zsh" ] && [ ! -f "${_resolve_legacy}/bin/zi.zsh" ]; then + printf '%s\n' "${_resolve_xdg}" + else + die "both legacy and XDG Zi homes exist; pass --zi-home to select one" + fi + elif [ "${_resolve_legacy_present}" -eq 1 ]; then + printf '%s\n' "${_resolve_legacy}" + else + printf '%s\n' "${_resolve_xdg}" + fi +} + +current_hash() { + if [ -e "$1" ]; then + sha256_file "$1" + else + printf '%s\n' missing + fi +} + +receipt_value() { + _receipt_key="$1" + _receipt_file="$2" + [ -f "${_receipt_file}" ] || return 1 + [ "$(sed -n '1p' "${_receipt_file}")" = 'format=zi-setup-receipt-v1' ] || return 1 + sed -n "s/^${_receipt_key}=//p" "${_receipt_file}" | sed -n '1p' +} + +profile_revision() { + _profile_label="$1" + _profile_file="$2" + _profile_value="$(awk -v label="${_profile_label}" ' + $1 == label { if (seen++) exit 2; print $2 } + ' "${_profile_file}")" || die "duplicate profile label ${_profile_label}" + case "${_profile_value}" in + "" | *[!0-9a-f]*) die "profile label ${_profile_label} is missing a pinned revision" ;; + esac + [ "${#_profile_value}" -eq 40 ] || die "profile label ${_profile_label} must use a full 40-character revision" + printf '%s\n' "${_profile_value}" +} + +artifact_hash() ( + _artifact_dir="$1" + _artifact_raw="$(mktemp "${TMPDIR:-/tmp}/zi-setup-hash-raw.XXXXXX")" || exit 1 + _artifact_list="$(mktemp "${TMPDIR:-/tmp}/zi-setup-hash-list.XXXXXX")" || { + command rm -f "${_artifact_raw}" + exit 1 + } + _artifact_manifest="$(mktemp "${TMPDIR:-/tmp}/zi-setup-hash-manifest.XXXXXX")" || { + command rm -f "${_artifact_raw}" "${_artifact_list}" + exit 1 + } + trap 'rm -f "${_artifact_raw}" "${_artifact_list}" "${_artifact_manifest}"' EXIT INT TERM + cd "${_artifact_dir}" || exit 1 + find . -type f ! -name plan.id -print >"${_artifact_raw}" || exit 1 + LC_ALL=C sort "${_artifact_raw}" >"${_artifact_list}" || exit 1 + while IFS= read -r _artifact_file; do + _artifact_file_hash="$(sha256_file "${_artifact_file}")" || exit 1 + printf '%s %s\n' "${_artifact_file_hash}" "${_artifact_file}" >>"${_artifact_manifest}" || exit 1 + done <"${_artifact_list}" + sha256_file "${_artifact_manifest}" +) + +plan_value() { + _plan_key="$1" + _plan_dir="$2" + sed -n "s/^${_plan_key}=//p" "${_plan_dir}/plan.meta" | sed -n '1p' +} + +write_managed_block() { + _managed_config_home="$1" + _managed_entry_text="$(zsh_single_quote "${_managed_config_home}/setup.zsh")" || + die 'failed to serialize the setup entrypoint' + command cat <>> zi setup >>> +source ${_managed_entry_text} +# <<< zi setup <<< +EOF +} + +write_setup_entrypoint() { + _entry_config_home="$1" + _entry_pre_text="$(zsh_single_quote "${_entry_config_home}/setup/pre.zsh")" || + die 'failed to serialize the pre-setup path' + _entry_init_text="$(zsh_single_quote "${_entry_config_home}/init.zsh")" || + die 'failed to serialize the init path' + _entry_shell_text="$(zsh_single_quote "${_entry_config_home}/setup/shell.zsh")" || + die 'failed to serialize the shell-setup path' + command cat < https://wiki.zshell.dev/ecosystem/category/-annexes +zicompinit # <- https://wiki.zshell.dev/docs/guides/commands +EOF +} + +write_legacy_annex() { + command cat <<'EOF' +zi light-mode for \ + z-shell/z-a-meta-plugins \ + @annexes # <- https://wiki.zshell.dev/ecosystem/category/-annexes +# examples here -> https://wiki.zshell.dev/community/gallery/collection +zicompinit # <- https://wiki.zshell.dev/docs/guides/commands +EOF +} + +write_legacy_zunit() { + command cat <<'EOF' +zi light-mode for \ + z-shell/z-a-meta-plugins \ + @annexes @zunit +EOF +} + +extract_legacy_direct_values() { + _extract_file="$1" + LEGACY_DIRECT_FOUND=0 + [ -f "${_extract_file}" ] || return 0 + _extract_source_count="$(grep -Ec '^[[:space:]]*source "[^"\\]*/zi\.zsh"$' "${_extract_file}" 2>/dev/null || true)" + _extract_clone_count="$(grep -Ec '^[[:space:]]*command git clone .*--branch "[A-Za-z0-9._/-]+" https://github.com/z-shell/zi ' "${_extract_file}" 2>/dev/null || true)" + [ "${_extract_source_count}" -ne 0 ] && [ "${_extract_clone_count}" -ne 0 ] || return 0 + [ "${_extract_source_count}" -eq 1 ] || die 'legacy direct profile has more than one candidate source line' + [ "${_extract_clone_count}" -eq 1 ] || die 'legacy direct profile ref cannot be decoded unambiguously' + _extract_source="$(grep -E '^[[:space:]]*source "[^"\\]*/zi\.zsh"$' "${_extract_file}")" + _extract_path="${_extract_source#*source \"}" + _extract_path="${_extract_path%/zi.zsh\"}" + # These patterns intentionally match literal shell syntax without evaluating it. + # shellcheck disable=SC2016 + case "${_extract_path}" in + *'`'* | *'$('* | *'${'*) die 'legacy direct profile path cannot be decoded unambiguously' ;; + '$HOME') _extract_path="${HOME}" ;; + '$HOME'/*) _extract_path="${HOME}${_extract_path#\$HOME}" ;; + /*) ;; + *) die 'legacy direct profile path is neither absolute nor rooted at $HOME' ;; + esac + _extract_bin="${_extract_path##*/}" + _extract_home="${_extract_path%/*}" + case "${_extract_bin}" in "" | . | .. | */*) die 'legacy direct profile bin name is ambiguous' ;; esac + is_absolute_path "${_extract_home}" || die 'legacy direct profile home is not absolute after decoding' + + _extract_clone="$(grep -E '^[[:space:]]*command git clone .*--branch "[A-Za-z0-9._/-]+" https://github.com/z-shell/zi ' "${_extract_file}")" + _extract_ref_part="${_extract_clone#*--branch \"}" + _extract_ref="${_extract_ref_part%%\"*}" + validate_ref "${_extract_ref}" + + LEGACY_DIRECT_FOUND=1 + LEGACY_DIRECT_HOME="${_extract_home}" + LEGACY_DIRECT_BIN="${_extract_bin}" + LEGACY_DIRECT_REF="${_extract_ref}" +} + +strip_exact_block() { + _strip_source="$1" + _strip_template="$2" + _strip_output="$3" + _strip_result="$4" + awk -v result="${_strip_result}" ' + FNR == NR { template[++template_count] = $0; next } + { source[++source_count] = $0 } + END { + matches = 0 + start = 0 + for (i = 1; i <= source_count - template_count + 1; i++) { + equal = 1 + for (j = 1; j <= template_count; j++) { + if (source[i + j - 1] != template[j]) { equal = 0; break } + } + if (equal) { matches++; start = i } + } + print matches > result + for (i = 1; i <= source_count; i++) { + if (matches == 1 && i >= start && i < start + template_count) continue + print source[i] + } + } + ' "${_strip_template}" "${_strip_source}" >"${_strip_output}" +} + +replace_managed_block() { + _replace_source="$1" + _replace_block="$2" + _replace_output="$3" + awk -v block_file="${_replace_block}" ' + $0 == "# >>> zi setup >>>" { + while ((getline block_line < block_file) > 0) print block_line + close(block_file) + skipping = 1 + next + } + skipping && $0 == "# <<< zi setup <<<" { skipping = 0; next } + !skipping { print } + ' "${_replace_source}" >"${_replace_output}" +} + +extract_managed_block() { + awk ' + $0 == "# >>> zi setup >>>" { copying = 1 } + copying { print } + $0 == "# <<< zi setup <<<" { copying = 0 } + ' "$1" >"$2" +} + +diff_target() { + _diff_path="$1" + _diff_content="$2" + _diff_empty="$3" + if [ -e "${_diff_path}" ]; then + diff -u "${_diff_path}" "${_diff_content}" || [ "$?" -eq 1 ] + else + : >"${_diff_empty}" + diff -u "${_diff_empty}" "${_diff_content}" || [ "$?" -eq 1 ] + fi +} + +add_target() { + _target_plan="$1" + _target_id="$2" + _target_path="$3" + _target_content="$4" + _target_mode="$5" + _target_receipt="$6" + _target_dir="${_target_plan}/targets/${_target_id}" + command mkdir -p "${_target_dir}" + command cp "${_target_content}" "${_target_dir}/content" + printf '%s\n' "${_target_path}" >"${_target_dir}/path" + printf '%s\n' "${_target_mode}" >"${_target_dir}/mode" + _target_kind='missing' + if [ -L "${_target_path}" ]; then + _target_kind='symlink' + elif [ -e "${_target_path}" ]; then + _target_kind='file' + fi + printf '%s\n' "${_target_kind}" >"${_target_dir}/kind" + _target_expected="$(current_hash "${_target_path}")" + printf '%s\n' "${_target_expected}" >"${_target_dir}/expected" + + if [ "${_target_kind}" = file ] && [ "${_target_id}" != zshrc ]; then + _target_desired="$(sha256_file "${_target_content}")" + if [ "${_target_expected}" != "${_target_desired}" ]; then + _target_owned="$(receipt_value "target.${_target_id}" "${_target_receipt}" 2>/dev/null || true)" + [ "${_target_owned}" = "${_target_expected}" ] || + die "refusing unmanaged target ${_target_path}; move it aside or restore a valid receipt" + fi + fi + printf '%s\n' "${_target_id}" >>"${_target_plan}/targets/order" +} + +write_shell_fragment() { + _shell_profile="$1" + _shell_direct="$2" + _shell_profiles="$3" + _shell_output="$4" + { + printf '%s\n' '# Generated by Zi guided setup. Edit choices through a new plan.' + if [ "${_shell_direct}" -eq 1 ] || [ "${_shell_profile}" != loader ]; then + command cat <<'EOF' +autoload -Uz _zi +(( ${+_comps} )) && _comps[zi]=_zi +EOF + fi + case "${_shell_profile}" in + loader) + printf '%s\n' ': # no post-loader recipe selected' + ;; + annex | zunit) + _shell_revision="$(profile_revision annexes "${_shell_profiles}")" + printf '%s\n' "zi ice ver'${_shell_revision}'" + # The trailing backslashes are literal continuation markers in generated Zsh. + # shellcheck disable=SC1003 + printf '%s\n' 'zi light-mode for \' ' z-shell/z-a-meta-plugins \' + if [ "${_shell_profile}" = annex ]; then + printf '%s\n' ' @annexes # <- https://wiki.zshell.dev/ecosystem/category/-annexes' + else + _shell_zunit_revision="$(profile_revision zunit "${_shell_profiles}")" + [ "${_shell_zunit_revision}" = "${_shell_revision}" ] || + die "annexes and zunit must share one verified meta-plugins revision" + printf '%s\n' ' @annexes @zunit' + fi + printf '%s\n' 'zicompinit # <- https://wiki.zshell.dev/docs/guides/commands' + ;; + esac + } >"${_shell_output}" +} + +plan_command() { + PLAN_DIR="" + PROFILE=loader + REF=main + PLAN_ZI_HOME="${ZI_HOME-}" + PLAN_HOME_EXPLICIT=0 + [ -z "${ZI_HOME-}" ] || PLAN_HOME_EXPLICIT=1 + PLAN_BIN="${ZI_BIN_DIR_NAME:-bin}" + PLAN_BIN_EXPLICIT=0 + [ -z "${ZI_BIN_DIR_NAME-}" ] || PLAN_BIN_EXPLICIT=1 + REF_EXPLICIT=0 + CONFIG_HOME="" + ZSHRC_PATH="" + INIT_SOURCE="${ROOT}/public/zsh/init.zsh" + PROFILES_FILE="${ROOT}/public/setup/profiles.tsv" + CHECKSUM_FILE="${ROOT}/public/checksum.txt" + SKIP_ZSHRC=0 + + while [ "$#" -gt 0 ]; do + case "$1" in + --plan) + [ "$#" -ge 2 ] || die '--plan requires a directory' + PLAN_DIR="$2" + shift 2 + ;; + --profile) + [ "$#" -ge 2 ] || die '--profile requires a value' + PROFILE="$2" + shift 2 + ;; + --ref) + [ "$#" -ge 2 ] || die '--ref requires a value' + REF="$2" + REF_EXPLICIT=1 + shift 2 + ;; + --zi-home) + [ "$#" -ge 2 ] || die '--zi-home requires a directory' + PLAN_ZI_HOME="$2" + PLAN_HOME_EXPLICIT=1 + shift 2 + ;; + --zi-bin-dir) + [ "$#" -ge 2 ] || die '--zi-bin-dir requires a name' + PLAN_BIN="$2" + PLAN_BIN_EXPLICIT=1 + shift 2 + ;; + --config-home) + [ "$#" -ge 2 ] || die '--config-home requires a directory' + CONFIG_HOME="$2" + shift 2 + ;; + --zshrc) + [ "$#" -ge 2 ] || die '--zshrc requires a file' + ZSHRC_PATH="$2" + shift 2 + ;; + --init) + [ "$#" -ge 2 ] || die '--init requires a file' + INIT_SOURCE="$2" + shift 2 + ;; + --profiles) + [ "$#" -ge 2 ] || die '--profiles requires a file' + PROFILES_FILE="$2" + shift 2 + ;; + --checksum) + [ "$#" -ge 2 ] || die '--checksum requires a file' + CHECKSUM_FILE="$2" + shift 2 + ;; + --skip-zshrc) + SKIP_ZSHRC=1 + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) die "unknown plan option $1" ;; + esac + done + + [ -n "${PLAN_DIR}" ] || die '--plan is required' + case "${PROFILE}" in loader | annex | zunit) ;; *) die "unsupported profile ${PROFILE}" ;; esac + if [ -z "${CONFIG_HOME}" ]; then + if is_absolute_path "${XDG_CONFIG_HOME-}"; then + CONFIG_HOME="${XDG_CONFIG_HOME}/zi" + else + CONFIG_HOME="${HOME}/.config/zi" + fi + fi + is_absolute_path "${CONFIG_HOME}" || die '--config-home must be absolute' + if [ -z "${ZSHRC_PATH}" ]; then + PLAN_ZDOTDIR="${ZDOTDIR:-${HOME}}" + is_absolute_path "${PLAN_ZDOTDIR}" || die 'ZDOTDIR must be absolute when set' + ZSHRC_PATH="${PLAN_ZDOTDIR}/.zshrc" + fi + is_absolute_path "${ZSHRC_PATH}" || die '--zshrc must be absolute' + validate_text_path '--config-home' "${CONFIG_HOME}" + validate_text_path '--zshrc' "${ZSHRC_PATH}" + + LEGACY_DIRECT_FOUND=0 + LEGACY_DIRECT_HOME="" + LEGACY_DIRECT_BIN="" + LEGACY_DIRECT_REF="" + if [ "${SKIP_ZSHRC}" -eq 0 ]; then + extract_legacy_direct_values "${ZSHRC_PATH}" + fi + if [ "${LEGACY_DIRECT_FOUND}" -eq 1 ]; then + [ "${PLAN_HOME_EXPLICIT}" -eq 1 ] || PLAN_ZI_HOME="${LEGACY_DIRECT_HOME}" + [ "${PLAN_BIN_EXPLICIT}" -eq 1 ] || PLAN_BIN="${LEGACY_DIRECT_BIN}" + [ "${REF_EXPLICIT}" -eq 1 ] || REF="${LEGACY_DIRECT_REF}" + fi + validate_ref "${REF}" + [ -n "${PLAN_ZI_HOME}" ] || PLAN_ZI_HOME="$(resolve_zi_home)" + is_absolute_path "${PLAN_ZI_HOME}" || die '--zi-home must be absolute' + case "${PLAN_BIN}" in "" | . | .. | */*) die '--zi-bin-dir must be one directory name' ;; esac + validate_text_path '--zi-home' "${PLAN_ZI_HOME}" + validate_text_path '--zi-bin-dir' "${PLAN_BIN}" + CHECKOUT_PATH="${PLAN_ZI_HOME}/${PLAN_BIN}" + [ -r "${INIT_SOURCE}" ] || die "cannot read init asset ${INIT_SOURCE}" + [ -r "${PROFILES_FILE}" ] || die "cannot read profile table ${PROFILES_FILE}" + [ -r "${CHECKSUM_FILE}" ] || die "cannot read checksum file ${CHECKSUM_FILE}" + + EXPECTED_INIT="$(awk '$2 == "public/zsh/init.zsh" {print $1}' "${CHECKSUM_FILE}")" + [ -n "${EXPECTED_INIT}" ] || die 'checksum file has no public/zsh/init.zsh entry' + [ "$(sha256_file "${INIT_SOURCE}")" = "${EXPECTED_INIT}" ] || die 'init asset does not match the published checksum' + [ ! -e "${PLAN_DIR}" ] || die "plan path already exists: ${PLAN_DIR}" + + PLAN_WORK="$(mktemp -d "${TMPDIR:-/tmp}/zi-setup-plan.XXXXXX")" || exit 1 + trap 'rm -rf "${PLAN_WORK:?}"' EXIT INT TERM + command mkdir -p "${PLAN_WORK}/artifact/checkout" "${PLAN_WORK}/artifact/targets" + : >"${PLAN_WORK}/artifact/targets/order" + RECEIPT_PATH="${CONFIG_HOME}/setup/receipt" + + INIT_CONTENT="${PLAN_WORK}/init.zsh" + # The single-quoted portions preserve the literal Zsh parameter text. + # shellcheck disable=SC2016 + command sed 's|: "${ZI\[STREAM\]:=main}"|: "${ZI[STREAM]:='"${REF}"'}"|' "${INIT_SOURCE}" >"${INIT_CONTENT}" + PRE_CONTENT="${PLAN_WORK}/pre.zsh" + PLAN_ZI_HOME_TEXT="$(zsh_single_quote "${PLAN_ZI_HOME}")" || die 'failed to serialize the Zi home' + CHECKOUT_PATH_TEXT="$(zsh_single_quote "${CHECKOUT_PATH}")" || die 'failed to serialize the checkout path' + REF_TEXT="$(zsh_single_quote "${REF}")" || die 'failed to serialize the ref' + { + printf '%s\n' '# Generated by Zi guided setup. Edit choices through a new plan.' + printf '%s\n' 'typeset -gA ZI' + printf 'ZI[HOME_DIR]=%s\n' "${PLAN_ZI_HOME_TEXT}" + printf 'ZI[BIN_DIR]=%s\n' "${CHECKOUT_PATH_TEXT}" + printf 'ZI[STREAM]=%s\n' "${REF_TEXT}" + } >"${PRE_CONTENT}" + + ENTRY_CONTENT="${PLAN_WORK}/setup.zsh" + write_setup_entrypoint "${CONFIG_HOME}" >"${ENTRY_CONTENT}" + MANAGED_BLOCK="${PLAN_WORK}/managed-block" + write_managed_block "${CONFIG_HOME}" >"${MANAGED_BLOCK}" + DIRECT_TEMPLATE="${PLAN_WORK}/legacy-direct" + LOADER_TEMPLATE="${PLAN_WORK}/legacy-loader" + LOADER_EXPLICIT_TEMPLATE="${PLAN_WORK}/legacy-loader-explicit" + ANNEX_TEMPLATE="${PLAN_WORK}/legacy-annex" + ZUNIT_TEMPLATE="${PLAN_WORK}/legacy-zunit" + if [ "${LEGACY_DIRECT_FOUND}" -eq 1 ]; then + write_legacy_direct "${LEGACY_DIRECT_HOME}" "${LEGACY_DIRECT_BIN}" "${LEGACY_DIRECT_REF}" >"${DIRECT_TEMPLATE}" + else + write_legacy_direct "${PLAN_ZI_HOME}" "${PLAN_BIN}" "${REF}" >"${DIRECT_TEMPLATE}" + fi + write_legacy_loader >"${LOADER_TEMPLATE}" + write_legacy_loader_explicit "${PLAN_ZI_HOME}" "${PLAN_BIN}" >"${LOADER_EXPLICIT_TEMPLATE}" + write_legacy_annex >"${ANNEX_TEMPLATE}" + write_legacy_zunit >"${ZUNIT_TEMPLATE}" + + DIRECT_MIGRATED=0 + EFFECTIVE_PROFILE="${PROFILE}" + ZSHRC_CONTENT="${PLAN_WORK}/zshrc" + if [ "${SKIP_ZSHRC}" -eq 0 ]; then + CURRENT_ZSHRC="${PLAN_WORK}/zshrc-current" + if [ -e "${ZSHRC_PATH}" ]; then command cp "${ZSHRC_PATH}" "${CURRENT_ZSHRC}"; else : >"${CURRENT_ZSHRC}"; fi + START_COUNT="$(grep -c '^# >>> zi setup >>>$' "${CURRENT_ZSHRC}" 2>/dev/null || true)" + END_COUNT="$(grep -c '^# <<< zi setup <<<$' "${CURRENT_ZSHRC}" 2>/dev/null || true)" + if [ "${START_COUNT}" -ne 0 ] || [ "${END_COUNT}" -ne 0 ]; then + [ "${START_COUNT}" -eq 1 ] && [ "${END_COUNT}" -eq 1 ] || die 'managed .zshrc markers are ambiguous' + CURRENT_BLOCK="${PLAN_WORK}/current-block" + extract_managed_block "${CURRENT_ZSHRC}" "${CURRENT_BLOCK}" + CURRENT_BLOCK_HASH="$(sha256_file "${CURRENT_BLOCK}")" + RECEIPT_BLOCK_HASH="$(receipt_value zshrc.block "${RECEIPT_PATH}" 2>/dev/null || true)" + if ! cmp -s "${CURRENT_BLOCK}" "${MANAGED_BLOCK}" && [ "${CURRENT_BLOCK_HASH}" != "${RECEIPT_BLOCK_HASH}" ]; then + diff_target "${CURRENT_BLOCK}" "${MANAGED_BLOCK}" "${PLAN_WORK}/empty" >&2 || true + die 'managed .zshrc block changed outside Zi setup; apply the printed patch manually or restore the receipt state' + fi + replace_managed_block "${CURRENT_ZSHRC}" "${MANAGED_BLOCK}" "${ZSHRC_CONTENT}" + else + WORKING_ZSHRC="${PLAN_WORK}/zshrc-working" + command cp "${CURRENT_ZSHRC}" "${WORKING_ZSHRC}" + for ENTRY in \ + "direct:${DIRECT_TEMPLATE}" \ + "loader:${LOADER_TEMPLATE}" \ + "loader-explicit:${LOADER_EXPLICIT_TEMPLATE}" \ + "annex:${ANNEX_TEMPLATE}" \ + "zunit:${ZUNIT_TEMPLATE}"; do + ENTRY_NAME="${ENTRY%%:*}" + ENTRY_TEMPLATE="${ENTRY#*:}" + STRIPPED="${PLAN_WORK}/zshrc-stripped" + STRIP_RESULT="${PLAN_WORK}/strip-result" + strip_exact_block "${WORKING_ZSHRC}" "${ENTRY_TEMPLATE}" "${STRIPPED}" "${STRIP_RESULT}" + MATCHES="$(cat "${STRIP_RESULT}")" + [ "${MATCHES}" -le 1 ] || die "legacy ${ENTRY_NAME} block appears more than once" + if [ "${MATCHES}" -eq 1 ]; then + command mv "${STRIPPED}" "${WORKING_ZSHRC}" + case "${ENTRY_NAME}" in + direct) DIRECT_MIGRATED=1 ;; + annex) [ "${EFFECTIVE_PROFILE}" = zunit ] || EFFECTIVE_PROFILE=annex ;; + zunit) EFFECTIVE_PROFILE=zunit ;; + esac + fi + done + if grep -E '^[[:space:]]*(source|\.)[[:space:]]+[^#]*(zi|init|zinit)\.zsh(["'"'"'[:space:]]|$)' "${WORKING_ZSHRC}" >/dev/null 2>&1 || + grep -E '^[[:space:]]*command git clone .*github\.com/z-shell/zi([. ]|$)' "${WORKING_ZSHRC}" >/dev/null 2>&1 || + grep -E '^[^#]*z-shell/z-a-meta-plugins([[:space:]]|$)' "${WORKING_ZSHRC}" >/dev/null 2>&1; then + die 'unrecognised Zi integration remains in .zshrc; refusing to initialise Zi twice' + fi + command cp "${WORKING_ZSHRC}" "${ZSHRC_CONTENT}" + if [ -s "${ZSHRC_CONTENT}" ]; then printf '\n' >>"${ZSHRC_CONTENT}"; fi + command cat "${MANAGED_BLOCK}" >>"${ZSHRC_CONTENT}" + fi + fi + + SHELL_CONTENT="${PLAN_WORK}/shell.zsh" + write_shell_fragment "${EFFECTIVE_PROFILE}" "${DIRECT_MIGRATED}" "${PROFILES_FILE}" "${SHELL_CONTENT}" + + command cat >"${PLAN_WORK}/artifact/plan.meta" </dev/null || true)" + case "${CHECKOUT_ORIGIN}" in + https://github.com/z-shell/zi | https://github.com/z-shell/zi.git | git@github.com:z-shell/zi | git@github.com:z-shell/zi.git) ;; + *) die "${CHECKOUT_PATH} is not a z-shell/zi checkout" ;; + esac + [ -f "${CHECKOUT_PATH}/zi.zsh" ] || die "${CHECKOUT_PATH} has no zi.zsh" + CHECKOUT_HEAD="$(command git -C "${CHECKOUT_PATH}" rev-parse HEAD)" || die 'cannot read checkout HEAD' + CHECKOUT_BRANCH="$(command git -C "${CHECKOUT_PATH}" symbolic-ref --quiet --short HEAD)" || die 'detached Zi checkout requires manual remediation' + printf '%s\n' existing >"${PLAN_WORK}/artifact/checkout/kind" + printf '%s\n' "${CHECKOUT_HEAD}" >"${PLAN_WORK}/artifact/checkout/head" + printf '%s\n' "${CHECKOUT_BRANCH}" >"${PLAN_WORK}/artifact/checkout/current-ref" + printf '%s\n' "${CHECKOUT_ORIGIN}" >"${PLAN_WORK}/artifact/checkout/origin" + elif [ -e "${CHECKOUT_PATH}" ]; then + die "${CHECKOUT_PATH} exists but is not a Zi checkout" + else + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/kind" + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/head" + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/current-ref" + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/origin" + fi + printf '%s\n' "${REF}" >"${PLAN_WORK}/artifact/checkout/requested-ref" + + add_target "${PLAN_WORK}/artifact" init "${CONFIG_HOME}/init.zsh" "${INIT_CONTENT}" 755 "${RECEIPT_PATH}" + add_target "${PLAN_WORK}/artifact" pre "${CONFIG_HOME}/setup/pre.zsh" "${PRE_CONTENT}" 600 "${RECEIPT_PATH}" + add_target "${PLAN_WORK}/artifact" shell "${CONFIG_HOME}/setup/shell.zsh" "${SHELL_CONTENT}" 600 "${RECEIPT_PATH}" + add_target "${PLAN_WORK}/artifact" entry "${CONFIG_HOME}/setup.zsh" "${ENTRY_CONTENT}" 600 "${RECEIPT_PATH}" + if [ "${SKIP_ZSHRC}" -eq 0 ]; then + add_target "${PLAN_WORK}/artifact" zshrc "${ZSHRC_PATH}" "${ZSHRC_CONTENT}" preserve "${RECEIPT_PATH}" + printf '%s\n' "$(sha256_file "${MANAGED_BLOCK}")" >"${PLAN_WORK}/artifact/targets/zshrc/block-hash" + fi + + command mv "${PLAN_WORK}/artifact" "${PLAN_DIR}" + PLAN_ID="$(artifact_hash "${PLAN_DIR}")" + printf '%s\n' "${PLAN_ID}" >"${PLAN_DIR}/plan.id" + printf '%s\n' "Plan SHA256: ${PLAN_ID}" + printf '%s\n' "Checkout phase: $(cat "${PLAN_DIR}/checkout/kind") ${CHECKOUT_PATH} -> ${REF}" + while IFS= read -r TARGET_ID; do + TARGET_PATH="$(cat "${PLAN_DIR}/targets/${TARGET_ID}/path")" + diff_target "${TARGET_PATH}" "${PLAN_DIR}/targets/${TARGET_ID}/content" "${PLAN_WORK}/empty" || true + done <"${PLAN_DIR}/targets/order" +} + +nearest_existing_parent() { + _parent_path="$1" + while [ ! -d "${_parent_path}" ]; do + _parent_next="$(dirname "${_parent_path}")" + [ "${_parent_next}" != "${_parent_path}" ] || break + _parent_path="${_parent_next}" + done + printf '%s\n' "${_parent_path}" +} + +acquire_lock() { + _lock_path="$1" + command mkdir -p "$(dirname "${_lock_path}")" + if ! command mkdir "${_lock_path}" 2>/dev/null; then + die "lock is already held: ${_lock_path}" + fi + ACTIVE_LOCK="${_lock_path}" + trap 'rmdir "${ACTIVE_LOCK}" 2>/dev/null || true' EXIT INT TERM +} + +validate_plan() { + _validate_plan="$1" + _validate_expect="$2" + [ -d "${_validate_plan}" ] || die "plan directory not found: ${_validate_plan}" + [ "$(plan_value format "${_validate_plan}")" = zi-setup-plan-v1 ] || die 'unsupported plan format' + _validate_stored="$(cat "${_validate_plan}/plan.id" 2>/dev/null || true)" + _validate_actual="$(artifact_hash "${_validate_plan}")" + [ "${_validate_stored}" = "${_validate_actual}" ] || die 'plan artifact hash mismatch' + if [ -n "${_validate_expect}" ]; then + [ "${_validate_expect}" = "${_validate_actual}" ] || die 'plan does not match --expect' + fi + printf '%s\n' "${_validate_actual}" +} + +apply_checkout() { + _checkout_plan="$1" + _checkout_path="$(plan_value checkout_path "${_checkout_plan}")" + _checkout_ref="$(cat "${_checkout_plan}/checkout/requested-ref")" + _checkout_kind="$(cat "${_checkout_plan}/checkout/kind")" + _checkout_head="$(cat "${_checkout_plan}/checkout/head")" + _checkout_current_ref="$(cat "${_checkout_plan}/checkout/current-ref")" + _checkout_origin="$(cat "${_checkout_plan}/checkout/origin")" + _checkout_parent="$(dirname "${_checkout_path}")" + _checkout_existing_parent="$(nearest_existing_parent "${_checkout_parent}")" + [ -w "${_checkout_existing_parent}" ] || die "checkout parent is not writable: ${_checkout_existing_parent}" + acquire_lock "${_checkout_path}.zi-setup.lock" + + case "${_checkout_kind}" in + missing) + [ ! -e "${_checkout_path}" ] || die "checkout appeared after planning: ${_checkout_path}" + command mkdir -p "${_checkout_parent}" + _checkout_tmp="${_checkout_path}.zi-setup-new.$$" + [ ! -e "${_checkout_tmp}" ] || die "temporary checkout path exists: ${_checkout_tmp}" + if ! command git clone --depth=1 --single-branch --branch "${_checkout_ref}" https://github.com/z-shell/zi.git "${_checkout_tmp}"; then + command rm -rf "${_checkout_tmp}" + die "failed to clone Zi at ${_checkout_ref}" + fi + command mv "${_checkout_tmp}" "${_checkout_path}" + ;; + existing) + [ -d "${_checkout_path}/.git" ] || die 'checkout disappeared after planning' + [ "$(command git -C "${_checkout_path}" rev-parse HEAD)" = "${_checkout_head}" ] || die 'checkout HEAD changed after planning' + [ "$(command git -C "${_checkout_path}" symbolic-ref --quiet --short HEAD)" = "${_checkout_current_ref}" ] || die 'checkout ref changed after planning' + [ "$(command git -C "${_checkout_path}" remote get-url origin 2>/dev/null || true)" = "${_checkout_origin}" ] || die 'checkout origin changed after planning' + [ -f "${_checkout_path}/zi.zsh" ] || die 'checkout zi.zsh disappeared after planning' + command git -C "${_checkout_path}" fetch origin "refs/heads/${_checkout_ref}" || die 'checkout fetch failed' + command git -C "${_checkout_path}" merge --ff-only FETCH_HEAD || { + command git -C "${_checkout_path}" status --short --branch >&2 || true + die 'checkout cannot be fast-forwarded; local state was left untouched' + } + ;; + *) die "invalid checkout kind ${_checkout_kind}" ;; + esac + printf '%s\n' "Checkout phase applied: ${_checkout_path}" +} + +validate_target_precondition() { + _validate_target_plan="$1" + _validate_target_id="$2" + _validate_target_dir="${_validate_target_plan}/targets/${_validate_target_id}" + _validate_target_path="$(cat "${_validate_target_dir}/path")" + _validate_target_kind="$(cat "${_validate_target_dir}/kind")" + _validate_target_expected="$(cat "${_validate_target_dir}/expected")" + if [ "${_validate_target_kind}" = symlink ] || [ -L "${_validate_target_path}" ]; then + if [ "${_validate_target_id}" = zshrc ]; then + diff_target "${_validate_target_path}" "${_validate_target_dir}/content" "${_validate_target_plan}/targets/.empty" >&2 || true + fi + die "refusing symlink target ${_validate_target_path}; apply the printed patch to its target manually" + fi + _validate_target_actual="$(current_hash "${_validate_target_path}")" + [ "${_validate_target_actual}" = "${_validate_target_expected}" ] || + die "target changed after planning: ${_validate_target_path}" + _validate_target_parent="$(nearest_existing_parent "$(dirname "${_validate_target_path}")")" + [ -w "${_validate_target_parent}" ] || die "target parent is not writable: ${_validate_target_parent}" +} + +install_target() { + _install_plan="$1" + _install_id="$2" + _install_dir="${_install_plan}/targets/${_install_id}" + _install_path="$(cat "${_install_dir}/path")" + _install_mode="$(cat "${_install_dir}/mode")" + _install_parent="$(dirname "${_install_path}")" + command mkdir -p "${_install_parent}" + _install_tmp="$(mktemp "${_install_path}.zi-setup.XXXXXX")" || die "cannot stage ${_install_path}" + if [ -e "${_install_path}" ]; then + command cp -p "${_install_path}" "${_install_tmp}" + fi + command cp "${_install_dir}/content" "${_install_tmp}" + case "${_install_mode}" in + 755) command chmod 755 "${_install_tmp}" ;; + 600) command chmod 600 "${_install_tmp}" ;; + preserve) [ -e "${_install_path}" ] || command chmod 600 "${_install_tmp}" ;; + *) + command rm -f "${_install_tmp}" + die "invalid target mode ${_install_mode}" + ;; + esac + command mv "${_install_tmp}" "${_install_path}" +} + +apply_files() { + _files_plan="$1" + _files_plan_id="$2" + _files_config="$(plan_value config_home "${_files_plan}")" + _files_receipt="$(plan_value receipt_path "${_files_plan}")" + _files_config_parent="$(nearest_existing_parent "$(dirname "${_files_config}")")" + [ -w "${_files_config_parent}" ] || die "configuration parent is not writable: ${_files_config_parent}" + acquire_lock "${_files_config}.zi-setup.lock" + + while IFS= read -r _files_id; do + validate_target_precondition "${_files_plan}" "${_files_id}" + done <"${_files_plan}/targets/order" + + while IFS= read -r _files_id; do + install_target "${_files_plan}" "${_files_id}" + done <"${_files_plan}/targets/order" + + command mkdir -p "$(dirname "${_files_receipt}")" + _files_receipt_tmp="$(mktemp "${_files_receipt}.zi-setup.XXXXXX")" || die 'cannot stage receipt' + { + printf '%s\n' 'format=zi-setup-receipt-v1' + printf 'plan=%s\n' "${_files_plan_id}" + printf 'profile=%s\n' "$(plan_value profile "${_files_plan}")" + while IFS= read -r _files_id; do + _files_path="$(cat "${_files_plan}/targets/${_files_id}/path")" + printf 'target.%s=%s\n' "${_files_id}" "$(sha256_file "${_files_path}")" + done <"${_files_plan}/targets/order" + if [ -f "${_files_plan}/targets/zshrc/block-hash" ]; then + printf 'zshrc.block=%s\n' "$(cat "${_files_plan}/targets/zshrc/block-hash")" + fi + case "$(plan_value profile "${_files_plan}")" in + annex | zunit) printf '%s\n' 'deferred-recipes=first-shell-start' ;; + *) printf '%s\n' 'deferred-recipes=none' ;; + esac + } >"${_files_receipt_tmp}" + command chmod 600 "${_files_receipt_tmp}" + command mv "${_files_receipt_tmp}" "${_files_receipt}" + printf '%s\n' "Files phase applied. Receipt: ${_files_receipt}" +} + +apply_command() { + APPLY_PLAN="" + APPLY_PHASE="" + APPLY_EXPECT="" + while [ "$#" -gt 0 ]; do + case "$1" in + --plan) + [ "$#" -ge 2 ] || die '--plan requires a directory' + APPLY_PLAN="$2" + shift 2 + ;; + --phase) + [ "$#" -ge 2 ] || die '--phase requires checkout or files' + APPLY_PHASE="$2" + shift 2 + ;; + --expect) + [ "$#" -ge 2 ] || die '--expect requires a hash' + APPLY_EXPECT="$2" + shift 2 + ;; + --help | -h) + usage + exit 0 + ;; + *) die "unknown apply option $1" ;; + esac + done + [ -n "${APPLY_PLAN}" ] || die '--plan is required' + case "${APPLY_PHASE}" in checkout | files) ;; *) die '--phase must be checkout or files' ;; esac + APPLY_PLAN_ID="$(validate_plan "${APPLY_PLAN}" "${APPLY_EXPECT}")" + case "${APPLY_PHASE}" in + checkout) apply_checkout "${APPLY_PLAN}" ;; + files) apply_files "${APPLY_PLAN}" "${APPLY_PLAN_ID}" ;; + esac +} + +[ "$#" -gt 0 ] || { + usage >&2 + exit 2 +} +COMMAND="$1" +shift +case "${COMMAND}" in +plan) plan_command "$@" ;; +apply) apply_command "$@" ;; +--help | -h | help) usage ;; +*) + usage >&2 + die "unknown command ${COMMAND}" + ;; +esac diff --git a/tests/installers.sh b/tests/installers.sh index e76fbb0..e76f780 100755 --- a/tests/installers.sh +++ b/tests/installers.sh @@ -11,6 +11,10 @@ ROOT="$( TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/zi-test.XXXXXX")" || exit 1 trap 'rm -rf "${TMP_ROOT:?}"' EXIT INT TERM +# Keep fixture homes isolated from the caller's desktop environment. Individual +# tests set the XDG variables they exercise. +unset XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME ZDOTDIR ZI_HOME ZI_BIN_DIR_NAME + fail() { printf '%s\n' "not ok - $*" >&2 exit 1 @@ -49,6 +53,7 @@ sha256_file() { check_syntax() { sh -n "${ROOT}/public/sh/install.sh" sh -n "${ROOT}/public/sh/install_zpmod.sh" + sh -n "${ROOT}/public/sh/setup.sh" sh -n "${ROOT}/public/sh/sync-init.sh" command -v zsh >/dev/null 2>&1 || fail "zsh is required for init.zsh syntax checks" zsh -n "${ROOT}/public/zsh/init.zsh" @@ -353,11 +358,31 @@ while [ "$#" -gt 0 ]; do done [ -n "${url}" ] || { printf '%s\n' "curl test double: missing URL" >&2; exit 64; } +[ -z "${ZI_SRC_TEST_CURL_LOG:-}" ] || printf '%s\n' "${url}" >>"${ZI_SRC_TEST_CURL_LOG}" if [ -z "${out}" ] && [ "${remote_name}" -eq 1 ]; then out="${url##*/}" fi case "${url}" in + */public/sh/install.sh) + if [ -n "${out}" ]; then + cp "${ZI_SRC_TEST_ROOT}/public/sh/install.sh" "${out}" + else + cat "${ZI_SRC_TEST_ROOT}/public/sh/install.sh" + fi + ;; + */public/checksum.txt) + [ -n "${out}" ] || { printf '%s\n' "curl test double: missing output path" >&2; exit 64; } + cp "${ZI_SRC_TEST_ROOT}/public/checksum.txt" "${out}" + ;; + */public/sh/setup.sh) + [ -n "${out}" ] || { printf '%s\n' "curl test double: missing output path" >&2; exit 64; } + cp "${ZI_SRC_TEST_ROOT}/public/sh/setup.sh" "${out}" + ;; + */public/setup/profiles.tsv) + [ -n "${out}" ] || { printf '%s\n' "curl test double: missing output path" >&2; exit 64; } + cp "${ZI_SRC_TEST_ROOT}/public/setup/profiles.tsv" "${out}" + ;; */public/zsh/init.zsh) if [ -n "${out}" ]; then cp "${ZI_SRC_TEST_ROOT}/public/zsh/init.zsh" "${out}" @@ -433,6 +458,17 @@ case "${cmd}" in printf '%s\n' "## main...origin/main [ahead 1]" printf '%s\n' " M zi.zsh" ;; + rev-parse) + [ "${1:-}" = "HEAD" ] || { printf '%s\n' "installers.sh git test double: expected rev-parse HEAD" >&2; exit 65; } + printf '%s\n' "${ZI_SRC_TEST_FAKE_HEAD:-1111111111111111111111111111111111111111}" + ;; + symbolic-ref) + [ "${1:-}" = "--quiet" ] && [ "${2:-}" = "--short" ] && [ "${3:-}" = "HEAD" ] || { + printf '%s\n' "installers.sh git test double: unexpected symbolic-ref arguments" >&2 + exit 65 + } + printf '%s\n' "${ZI_SRC_TEST_FAKE_BRANCH:-main}" + ;; log) printf '%s\n' 'abcdef0 - fake zi commit (now) ' ;; @@ -500,12 +536,70 @@ test_loader_install() { # shellcheck disable=SC2016 contains "${config}/zi/init.zsh" ': "${ZI[STREAM]:=feature/test}"' - # shellcheck disable=SC2016 - contains "${home}/.zshrc" 'source "${ZI_LOADER_CONFIG_HOME}/init.zsh" && zzinit' + contains "${home}/.zshrc" "source '${config}/zi/setup.zsh'" + contains "${config}/zi/setup.zsh" "source '${config}/zi/setup/pre.zsh'" + contains "${config}/zi/setup.zsh" "source '${config}/zi/init.zsh'" + contains "${config}/zi/setup.zsh" '{ step=zzinit; zzinit; }' + zsh -n "${config}/zi/setup.zsh" [ -f "${data}/zi/bin/zi.zsh" ] || fail "loader install did not clone Zi into XDG data home" pass "loader install uses XDG paths and branch override" } +test_curl_pipe_install() { + home="${TMP_ROOT}/curl-pipe-home" + config="${TMP_ROOT}/curl-pipe-config" + data="${TMP_ROOT}/curl-pipe-data" + command mkdir -p "${home}" + + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + curl -fsSL https://raw.githubusercontent.com/z-shell/src/main/public/sh/install.sh | + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_CONFIG_HOME="${config}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh >/dev/null + + [ -f "${data}/zi/bin/zi.zsh" ] || fail "curl-piped installer did not clone Zi" + contains "${home}/.zshrc" "source '${config}/zi/setup.zsh'" + contains "${config}/zi/setup.zsh" "source '${config}/zi/init.zsh'" + pass "curl-piped install.sh fetches companion assets and installs Zi" +} + +test_curl_pipe_install_keeps_asset_ref() { + home="${TMP_ROOT}/curl-ref-home" + config="${TMP_ROOT}/curl-ref-config" + data="${TMP_ROOT}/curl-ref-data" + curl_log="${TMP_ROOT}/curl-ref-log" + src_ref="feature/208" + command mkdir -p "${home}" + + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + curl -fsSL "https://raw.githubusercontent.com/z-shell/src/${src_ref}/public/sh/install.sh" | + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_CONFIG_HOME="${config}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_REF="${src_ref}" \ + ZI_SRC_TEST_CURL_LOG="${curl_log}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh >/dev/null + + [ "$(wc -l <"${curl_log}" | tr -d ' ')" -eq 4 ] || fail "standalone installer fetched an unexpected number of companion assets" + if grep -F '/main/public/' "${curl_log}" >/dev/null 2>&1; then + fail "standalone installer mixed main assets with its requested source ref" + fi + contains "${curl_log}" "/${src_ref}/public/checksum.txt" + contains "${curl_log}" "/${src_ref}/public/zsh/init.zsh" + contains "${curl_log}" "/${src_ref}/public/sh/setup.sh" + contains "${curl_log}" "/${src_ref}/public/setup/profiles.tsv" + pass "curl-piped install.sh keeps companion assets on one source ref" +} + test_xdg_data_home_install() { home="${TMP_ROOT}/default-home" data="${TMP_ROOT}/missing-root/data-home" @@ -605,6 +699,12 @@ test_standalone_zpmod_delegation() { marker="${TMP_ROOT}/zpmod-marker" command mkdir -p "${standalone_dir}" "${home}" "${data}" command cp "${ROOT}/public/sh/install.sh" "${standalone_dir}/install.sh" + command cat >"${standalone_dir}/install_zpmod.sh" <<'EOF' +#!/usr/bin/env sh +set -eu +printf '%s\n' 'zpmod helper executed' >"${ZI_SRC_TEST_MARKER:?}" +EOF + command chmod +x "${standalone_dir}/install_zpmod.sh" HOME="${home}" \ ZDOTDIR="${home}" \ @@ -614,8 +714,8 @@ test_standalone_zpmod_delegation() { PATH="${FAKE_BIN}:${PATH}" \ sh "${standalone_dir}/install.sh" -a zpmod -i skip >/dev/null - contains "${marker}" 'zpmod fallback executed' - pass "standalone install.sh fetches zpmod helper" + contains "${marker}" 'zpmod helper executed' + pass "standalone install.sh delegates to its adjacent zpmod helper" } test_update_valid_zi_clone() { @@ -654,7 +754,7 @@ test_update_rejects_foreign_repo() { set -e [ "${exit_code}" -ne 0 ] || fail "install.sh should have rejected a foreign git repository" - contains "${err}" "does not appear to be a zi repository" + contains "${err}" "has no zi.zsh" pass "update path rejects an unrecognised git repository" } @@ -678,7 +778,7 @@ test_update_rejects_wrong_remote() { set -e [ "${exit_code}" -ne 0 ] || fail "install.sh should have rejected a repo with a non-zi remote" - contains "${err}" "does not appear to be a zi repository" + contains "${err}" "is not a z-shell/zi checkout" pass "update path rejects a repository with a non-zi remote origin" } @@ -698,8 +798,8 @@ test_update_fast_forwards_without_reset() { PATH="${FAKE_BIN}:${PATH}" \ sh "${ROOT}/public/sh/install.sh" -i skip -b feature/test >/dev/null - contains "${git_log}" 'fetch -q origin refs/heads/feature/test' - contains "${git_log}" 'merge -q --ff-only FETCH_HEAD' + contains "${git_log}" 'fetch origin refs/heads/feature/test' + contains "${git_log}" 'merge --ff-only FETCH_HEAD' if grep -E '^(clean|reset|pull)( |$)' "${git_log}" >/dev/null 2>&1; then fail "update path still discards local state (clean, reset, or pull was invoked)" fi @@ -745,10 +845,11 @@ test_zshrc_comment_does_not_suppress_integration() { PATH="${FAKE_BIN}:${PATH}" \ sh "${ROOT}/public/sh/install.sh" >/dev/null - contains "${home}/.zshrc" "source \"${data}/zi/bin/zi.zsh\"" + contains "${home}/.zshrc" '# >>> zi setup >>>' + contains "${home}/.zshrc" "source '${home}/.config/zi/setup.zsh'" pass "a comment mentioning init.zsh does not suppress the .zshrc integration" - # A real source line is still detected and nothing is appended. + # An unrecognised real source line is left untouched and blocks apply. home2="${TMP_ROOT}/probe-home-sourced" data2="${TMP_ROOT}/probe-data-sourced" command mkdir -p "${home2}" @@ -756,21 +857,29 @@ test_zshrc_comment_does_not_suppress_integration() { printf '%s\n' 'source "$HOME/.zi/bin/zi.zsh"' >"${home2}/.zshrc" before="$(sha256_file "${home2}/.zshrc")" + err2="${TMP_ROOT}/probe-home-sourced-err" + set +e HOME="${home2}" \ ZDOTDIR="${home2}" \ XDG_DATA_HOME="${data2}" \ ZI_SRC_TEST_ROOT="${ROOT}" \ PATH="${FAKE_BIN}:${PATH}" \ - sh "${ROOT}/public/sh/install.sh" >/dev/null + sh "${ROOT}/public/sh/install.sh" >/dev/null 2>"${err2}" + exit_code="$?" + set -e - [ "$(sha256_file "${home2}/.zshrc")" = "${before}" ] || fail "an existing source line did not suppress the integration block" - pass "an existing Zi source line keeps .zshrc unchanged" + [ "${exit_code}" -ne 0 ] || fail "an unrecognised Zi source line was accepted" + [ "$(sha256_file "${home2}/.zshrc")" = "${before}" ] || fail "an unrecognised Zi source line was modified" + contains "${err2}" 'unrecognised Zi integration remains' + pass "an unrecognised Zi source line is refused without mutation" } test_annex_rerun_is_idempotent() { home="${TMP_ROOT}/annex-home" data="${TMP_ROOT}/annex-data" zsh_log="${TMP_ROOT}/annex-zsh-log" + shell_file="${home}/.config/zi/setup/shell.zsh" + receipt="${home}/.config/zi/setup/receipt" command mkdir -p "${home}" for run in first second; do @@ -783,23 +892,16 @@ test_annex_rerun_is_idempotent() { sh "${ROOT}/public/sh/install.sh" -a annex >/dev/null || fail "annex install (${run} run) failed" done - meta_lines="$(grep -c 'z-shell/z-a-meta-plugins' "${home}/.zshrc")" - [ "${meta_lines}" -eq 1 ] || fail "annex block appended ${meta_lines} times across two runs" - contains "${home}/.zshrc" "source \"${data}/zi/bin/zi.zsh\"" - # The burst ran once, non-interactively, sourcing only zi.zsh and the fragment. - burst_lines="$(wc -l <"${zsh_log}" | tr -d ' ')" - [ "${burst_lines}" -eq 1 ] || fail "expected one annex burst, saw ${burst_lines}" - contains "${zsh_log}" '@zi-scheduler burst' - # Logged with argv0 so the pattern cannot be read as grep options. - contains "${zsh_log}" 'zsh -f -c ' - contains "${zsh_log}" "${data}/zi/bin/zi.zsh" - contains "${zsh_log}" 'temp-zsh-config' - # The annex-specific zicompinit reaches .zshrc (directly after the gallery - # comment), not only the one the default block writes. - if ! grep -A1 -F '# examples here -> https://wiki.zshell.dev/community/gallery/collection' "${home}/.zshrc" | grep -q '^zicompinit'; then - fail "annex profile did not write zicompinit after its gallery comment" - fi - pass "annex profile is idempotent across reruns and never starts an interactive shell" + marker_lines="$(grep -c '^# >>> zi setup >>>$' "${home}/.zshrc")" + [ "${marker_lines}" -eq 1 ] || fail "managed block appeared ${marker_lines} times across two runs" + meta_lines="$(grep -c 'z-shell/z-a-meta-plugins' "${shell_file}")" + [ "${meta_lines}" -eq 1 ] || fail "annex recipe appeared ${meta_lines} times across two runs" + contains "${shell_file}" "zi ice ver'74bd8a8bc3bcff8398420ff5a38758dec5b90e2b'" + contains "${shell_file}" '@annexes' + contains "${shell_file}" 'zicompinit' + contains "${receipt}" 'deferred-recipes=first-shell-start' + [ ! -e "${zsh_log}" ] || fail "annex install executed Zsh during apply" + pass "annex profile is idempotent and deferred to first shell start" } test_skip_leaves_annex_out() { @@ -817,7 +919,8 @@ test_skip_leaves_annex_out() { sh "${ROOT}/public/sh/install.sh" -i skip -a annex >/dev/null [ ! -e "${home}/.zshrc" ] || fail "-i skip -a annex modified .zshrc" - [ ! -e "${zsh_log}" ] || fail "-i skip -a annex still ran the annex burst" + contains "${home}/.config/zi/setup/shell.zsh" '@annexes' + [ ! -e "${zsh_log}" ] || fail "-i skip -a annex executed Zsh" pass "-i skip leaves .zshrc untouched even with an annex profile" } @@ -838,12 +941,35 @@ test_branch_option_rejects_refspec() { set -e [ "${exit_code}" -ne 0 ] || fail "install.sh accepted a refspec as -b" - contains "${err}" 'not a valid branch name' + contains "${err}" 'invalid ref' [ ! -e "${data}/zi/bin/zi.zsh" ] || fail "install proceeded after an invalid -b value" pass "-b rejects values that are not a branch name" } -test_zshrc_text_uses_home_variable() { +test_source_ref_rejects_refspec() { + home="${TMP_ROOT}/source-refspec-home" + data="${TMP_ROOT}/source-refspec-data" + err="${TMP_ROOT}/source-refspec-err" + command mkdir -p "${home}" + + set +e + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_REF='main:refs/heads/other' \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null 2>"${err}" + exit_code="$?" + set -e + + [ "${exit_code}" -ne 0 ] || fail "install.sh accepted a refspec as ZI_SRC_REF" + contains "${err}" 'Invalid ZI_SRC_REF' + [ ! -e "${data}/zi/bin/zi.zsh" ] || fail "install proceeded after an invalid ZI_SRC_REF" + pass "ZI_SRC_REF rejects values that are not a source ref" +} + +test_zshrc_uses_short_entrypoint() { home="${TMP_ROOT}/home-text home" sibling="${TMP_ROOT}/home-text home-sibling" command mkdir -p "${home}" @@ -855,9 +981,13 @@ test_zshrc_text_uses_home_variable() { PATH="${FAKE_BIN}:${PATH}" \ sh "${ROOT}/public/sh/install.sh" >/dev/null - # shellcheck disable=SC2016 - contains "${home}/.zshrc" 'source "$HOME/xdg data/zi/bin/zi.zsh"' - pass '.zshrc refers to a home under $HOME through the variable' + contains "${home}/.config/zi/setup/pre.zsh" "ZI[HOME_DIR]='${home}/xdg data/zi'" + contains "${home}/.zshrc" "source '${home}/.config/zi/setup.zsh'" + [ "$(wc -l <"${home}/.zshrc" | tr -d ' ')" -eq 3 ] || fail '.zshrc managed block is not three lines' + if grep -F 'ZI_LOADER_CONFIG_HOME' "${home}/.zshrc" >/dev/null 2>&1; then + fail '.zshrc exposes the internal loader configuration variable' + fi + pass 'the managed .zshrc block is one source line between ownership markers' home2="${TMP_ROOT}/home-text-2" command mkdir -p "${home2}" @@ -868,11 +998,298 @@ test_zshrc_text_uses_home_variable() { PATH="${FAKE_BIN}:${PATH}" \ sh "${ROOT}/public/sh/install.sh" >/dev/null - contains "${home2}/.zshrc" "source \"${sibling}/bin/zi.zsh\"" - if grep -F '$HOME' "${home2}/.zshrc" >/dev/null 2>&1; then - fail 'a sibling of $HOME was rewritten as if it were inside it' + contains "${home2}/.config/zi/setup/pre.zsh" "ZI[HOME_DIR]='${sibling}'" + pass 'a sibling of $HOME remains an exact serialized path' +} + +test_setup_plan_tamper_is_rejected() { + tamper_home="${TMP_ROOT}/tamper-home" + tamper_config="${TMP_ROOT}/tamper-config" + tamper_data="${TMP_ROOT}/tamper-data" + tamper_plan="${TMP_ROOT}/tamper-plan" + tamper_err="${TMP_ROOT}/tamper-err" + command mkdir -p "${tamper_home}" + + HOME="${tamper_home}" \ + XDG_CONFIG_HOME="${tamper_config}" \ + XDG_DATA_HOME="${tamper_data}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${tamper_plan}" --skip-zshrc >/dev/null + printf '%s\n' '# tampered' >>"${tamper_plan}/targets/pre/content" + + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${tamper_plan}" --phase files >/dev/null 2>"${tamper_err}" + tamper_status="$?" + set -e + + [ "${tamper_status}" -ne 0 ] || fail "setup.sh applied a tampered plan" + contains "${tamper_err}" 'plan artifact hash mismatch' + [ ! -e "${tamper_config}/zi" ] || fail "tampered plan created configuration files" + pass "setup rejects a plan whose exact content changed" +} + +test_setup_target_drift_is_transactional() { + drift_home="${TMP_ROOT}/drift-home" + drift_config="${TMP_ROOT}/drift-config" + drift_data="${TMP_ROOT}/drift-data" + drift_plan="${TMP_ROOT}/drift-plan" + drift_err="${TMP_ROOT}/drift-err" + command mkdir -p "${drift_home}" "${drift_config}/zi" + + HOME="${drift_home}" \ + XDG_CONFIG_HOME="${drift_config}" \ + XDG_DATA_HOME="${drift_data}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${drift_plan}" --skip-zshrc >/dev/null + printf '%s\n' '# appeared after planning' >"${drift_config}/zi/init.zsh" + + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${drift_plan}" --phase files >/dev/null 2>"${drift_err}" + drift_status="$?" + set -e + + [ "${drift_status}" -ne 0 ] || fail "setup.sh ignored target drift" + contains "${drift_err}" 'target changed after planning' + contains "${drift_config}/zi/init.zsh" '# appeared after planning' + [ ! -e "${drift_config}/zi/setup/pre.zsh" ] || fail "target drift caused a partial pre.zsh write" + [ ! -e "${drift_config}/zi/setup/shell.zsh" ] || fail "target drift caused a partial shell.zsh write" + [ ! -e "${drift_config}/zi/setup.zsh" ] || fail "target drift caused a partial setup.zsh write" + pass "file apply validates every precondition before mutation" +} + +test_setup_symlinked_zshrc_is_refused() { + symlink_home="${TMP_ROOT}/symlink-home" + symlink_config="${TMP_ROOT}/symlink-config" + symlink_data="${TMP_ROOT}/symlink-data" + symlink_plan="${TMP_ROOT}/symlink-plan" + symlink_target="${TMP_ROOT}/symlink-target-zshrc" + symlink_err="${TMP_ROOT}/symlink-err" + command mkdir -p "${symlink_home}" + printf '%s\n' '# real startup file' >"${symlink_target}" + command ln -s "${symlink_target}" "${symlink_home}/.zshrc" + symlink_before="$(sha256_file "${symlink_target}")" + + HOME="${symlink_home}" \ + ZDOTDIR="${symlink_home}" \ + XDG_CONFIG_HOME="${symlink_config}" \ + XDG_DATA_HOME="${symlink_data}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${symlink_plan}" >/dev/null + + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${symlink_plan}" --phase files >/dev/null 2>"${symlink_err}" + symlink_status="$?" + set -e + + [ "${symlink_status}" -ne 0 ] || fail "setup.sh replaced a symlinked .zshrc" + contains "${symlink_err}" 'refusing symlink target' + contains "${symlink_err}" '# >>> zi setup >>>' + [ -L "${symlink_home}/.zshrc" ] || fail "setup.sh replaced the .zshrc symlink itself" + [ "$(sha256_file "${symlink_target}")" = "${symlink_before}" ] || fail "setup.sh changed the symlink target" + [ ! -e "${symlink_config}/zi" ] || fail "symlink refusal caused partial configuration writes" + pass "symlinked .zshrc is refused with a patch and no mutation" +} + +test_setup_managed_block_drift_is_refused() { + managed_home="${TMP_ROOT}/managed-home" + managed_config="${TMP_ROOT}/managed-config" + managed_data="${TMP_ROOT}/managed-data" + managed_plan_one="${TMP_ROOT}/managed-plan-one" + managed_plan_two="${TMP_ROOT}/managed-plan-two" + managed_err="${TMP_ROOT}/managed-err" + command mkdir -p "${managed_home}" + + HOME="${managed_home}" \ + ZDOTDIR="${managed_home}" \ + XDG_CONFIG_HOME="${managed_config}" \ + XDG_DATA_HOME="${managed_data}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${managed_plan_one}" >/dev/null + sh "${ROOT}/public/sh/setup.sh" apply --plan "${managed_plan_one}" --phase files >/dev/null + command sed 's|/setup.zsh|/user-change.zsh|' \ + "${managed_home}/.zshrc" >"${managed_home}/.zshrc.changed" + command mv "${managed_home}/.zshrc.changed" "${managed_home}/.zshrc" + managed_before="$(sha256_file "${managed_home}/.zshrc")" + + set +e + HOME="${managed_home}" \ + ZDOTDIR="${managed_home}" \ + XDG_CONFIG_HOME="${managed_config}" \ + XDG_DATA_HOME="${managed_data}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${managed_plan_two}" >/dev/null 2>"${managed_err}" + managed_status="$?" + set -e + + [ "${managed_status}" -ne 0 ] || fail "setup.sh accepted an externally changed managed block" + contains "${managed_err}" 'managed .zshrc block changed outside Zi setup' + [ "$(sha256_file "${managed_home}/.zshrc")" = "${managed_before}" ] || fail "managed block refusal changed .zshrc" + [ ! -e "${managed_plan_two}" ] || fail "managed block refusal published a plan" + pass "managed .zshrc drift requires manual reconciliation" +} + +test_setup_checkout_head_drift_is_refused() { + checkout_home="${TMP_ROOT}/checkout-drift-home" + checkout_config="${TMP_ROOT}/checkout-drift-config" + checkout_data="${TMP_ROOT}/checkout-drift-data" + checkout_path="${checkout_data}/zi/bin" + checkout_plan="${TMP_ROOT}/checkout-drift-plan" + checkout_err="${TMP_ROOT}/checkout-drift-err" + checkout_log="${TMP_ROOT}/checkout-drift-log" + command mkdir -p "${checkout_home}" "${checkout_path}/.git" + printf '%s\n' '# fake zi.zsh' >"${checkout_path}/zi.zsh" + + HOME="${checkout_home}" \ + XDG_CONFIG_HOME="${checkout_config}" \ + XDG_DATA_HOME="${checkout_data}" \ + ZI_SRC_TEST_FAKE_HEAD=1111111111111111111111111111111111111111 \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${checkout_plan}" --skip-zshrc >/dev/null + : >"${checkout_log}" + + set +e + ZI_SRC_TEST_FAKE_HEAD=2222222222222222222222222222222222222222 \ + ZI_SRC_TEST_GIT_LOG="${checkout_log}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" apply --plan "${checkout_plan}" --phase checkout >/dev/null 2>"${checkout_err}" + checkout_status="$?" + set -e + + [ "${checkout_status}" -ne 0 ] || fail "setup.sh updated a checkout whose HEAD drifted" + contains "${checkout_err}" 'checkout HEAD changed after planning' + if grep -E '^(fetch|merge)( |$)' "${checkout_log}" >/dev/null 2>&1; then + fail "checkout drift reached a mutating git operation" + fi + pass "checkout apply validates the planned HEAD before fetch" +} + +write_legacy_direct_fixture() { + legacy_file="$1" + legacy_home="$2" + legacy_bin="$3" + legacy_ref="$4" + legacy_profile="$5" + command cat >"${legacy_file}" < https://wiki.zshell.dev/ecosystem/category/-annexes +zicompinit # <- https://wiki.zshell.dev/docs/guides/commands +EOF + case "${legacy_profile}" in + annex) + command cat >>"${legacy_file}" <<'EOF' +zi light-mode for \ + z-shell/z-a-meta-plugins \ + @annexes # <- https://wiki.zshell.dev/ecosystem/category/-annexes +# examples here -> https://wiki.zshell.dev/community/gallery/collection +zicompinit # <- https://wiki.zshell.dev/docs/guides/commands +EOF + ;; + zunit) + command cat >>"${legacy_file}" <<'EOF' +zi light-mode for \ + z-shell/z-a-meta-plugins \ + @annexes @zunit +EOF + ;; + *) fail "unknown legacy profile fixture ${legacy_profile}" ;; + esac +} + +test_setup_legacy_profiles_migrate() { + for legacy_profile in annex zunit; do + legacy_home_dir="${TMP_ROOT}/legacy-${legacy_profile}-home" + legacy_config="${TMP_ROOT}/legacy-${legacy_profile}-config" + legacy_data="${TMP_ROOT}/legacy-${legacy_profile}-data" + legacy_plan="${TMP_ROOT}/legacy-${legacy_profile}-plan" + legacy_checkout="${TMP_ROOT}/legacy ${legacy_profile}'s checkout" + legacy_bin="custom bin" + legacy_ref="feature/legacy-${legacy_profile}" + legacy_values="${TMP_ROOT}/legacy-${legacy_profile}-values" + command mkdir -p "${legacy_home_dir}" + write_legacy_direct_fixture "${legacy_home_dir}/.zshrc" "${legacy_checkout}" "${legacy_bin}" "${legacy_ref}" "${legacy_profile}" + + HOME="${legacy_home_dir}" \ + ZDOTDIR="${legacy_home_dir}" \ + XDG_CONFIG_HOME="${legacy_config}" \ + XDG_DATA_HOME="${legacy_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${legacy_plan}" >/dev/null + sh "${ROOT}/public/sh/setup.sh" apply --plan "${legacy_plan}" --phase files >/dev/null + + contains "${legacy_home_dir}/.zshrc" '# >>> zi setup >>>' + if grep -F 'command git clone' "${legacy_home_dir}/.zshrc" >/dev/null 2>&1; then + fail "legacy ${legacy_profile} direct block remained in .zshrc" + fi + if grep -F 'z-shell/z-a-meta-plugins' "${legacy_home_dir}/.zshrc" >/dev/null 2>&1; then + fail "legacy ${legacy_profile} recipe remained in .zshrc" + fi + contains "${legacy_config}/zi/setup/shell.zsh" "@${legacy_profile}" + contains "${legacy_config}/zi/setup/shell.zsh" "ver'74bd8a8bc3bcff8398420ff5a38758dec5b90e2b'" + zsh -f -c ' + source "$1" + print -r -- "home:${ZI[HOME_DIR]}" + print -r -- "bin:${ZI[BIN_DIR]}" + print -r -- "stream:${ZI[STREAM]}" + ' zsh "${legacy_config}/zi/setup/pre.zsh" >"${legacy_values}" + contains "${legacy_values}" "home:${legacy_checkout}" + contains "${legacy_values}" "bin:${legacy_checkout}/${legacy_bin}" + contains "${legacy_values}" "stream:${legacy_ref}" + done + pass "legacy direct, annex, and zunit profiles migrate without evaluation" +} + +test_setup_legacy_loader_discovers_checkout() { + loader_migration_home="${TMP_ROOT}/loader-migration-home" + loader_migration_config="${TMP_ROOT}/loader-migration-config" + loader_migration_data="${TMP_ROOT}/loader-migration-data" + loader_migration_plan="${TMP_ROOT}/loader-migration-plan" + loader_migration_err="${TMP_ROOT}/loader-migration-err" + command mkdir -p "${loader_migration_home}/.zi/bin/.git" + printf '%s\n' '# fake zi.zsh' >"${loader_migration_home}/.zi/bin/zi.zsh" + command cat >"${loader_migration_home}/.zshrc" <<'EOF' +if [[ -n ${XDG_CONFIG_HOME:-} && ${XDG_CONFIG_HOME} == /* ]]; then + ZI_LOADER_CONFIG_HOME="${XDG_CONFIG_HOME}/zi" +else + ZI_LOADER_CONFIG_HOME="${HOME}/.config/zi" +fi +if [[ -r "${ZI_LOADER_CONFIG_HOME}/init.zsh" ]]; then + source "${ZI_LOADER_CONFIG_HOME}/init.zsh" && zzinit +fi +unset ZI_LOADER_CONFIG_HOME +EOF + + HOME="${loader_migration_home}" \ + ZDOTDIR="${loader_migration_home}" \ + XDG_CONFIG_HOME="${loader_migration_config}" \ + XDG_DATA_HOME="${loader_migration_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${loader_migration_plan}" >/dev/null + contains "${loader_migration_plan}/plan.meta" "checkout_path=${loader_migration_home}/.zi/bin" + sh "${ROOT}/public/sh/setup.sh" apply --plan "${loader_migration_plan}" --phase files >/dev/null + contains "${loader_migration_home}/.zshrc" '# >>> zi setup >>>' + if grep -F 'if [[ -r "${ZI_LOADER_CONFIG_HOME}/init.zsh" ]]' "${loader_migration_home}/.zshrc" >/dev/null 2>&1; then + fail "legacy loader block remained after migration" fi - pass '.zshrc keeps a literal path for a home that only shares a prefix with $HOME' + + command mkdir -p "${loader_migration_data}/zi/bin/.git" + printf '%s\n' '# second fake zi.zsh' >"${loader_migration_data}/zi/bin/zi.zsh" + set +e + HOME="${loader_migration_home}" \ + ZDOTDIR="${loader_migration_home}" \ + XDG_CONFIG_HOME="${loader_migration_config}" \ + XDG_DATA_HOME="${loader_migration_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${TMP_ROOT}/loader-migration-conflict-plan" >/dev/null 2>"${loader_migration_err}" + loader_migration_status="$?" + set -e + + [ "${loader_migration_status}" -ne 0 ] || fail "setup.sh chose between ambiguous loader checkouts" + contains "${loader_migration_err}" 'both legacy and XDG Zi homes exist' + pass "legacy loader migration discovers one checkout and refuses conflicts" } test_sync_init() { @@ -923,14 +1340,15 @@ test_loader_default_paths_remain_dynamic() { sh "${ROOT}/public/sh/install.sh" -a loader >/dev/null if grep -F 'ZI[HOME_DIR]=' "${home}/.zshrc" >/dev/null 2>&1; then - fail "default loader install pinned a dynamically resolved home" + fail "managed .zshrc embedded a checkout path" fi - pass "default loader paths remain dynamically resolved" + contains "${config}/zi/setup/pre.zsh" "ZI[HOME_DIR]='${data}/zi'" + pass "default loader path is isolated in the generated pre fragment" } test_loader_carries_explicit_paths() { home="${TMP_ROOT}/loader-explicit-home" - config="${TMP_ROOT}/loader-explicit-config" + config="${TMP_ROOT}/loader explicit config's root" data="${TMP_ROOT}/loader-explicit-data" explicit="${TMP_ROOT}/loader explicit's root" bin_name="custom \$(touch pwned) ' bin" @@ -949,12 +1367,12 @@ test_loader_carries_explicit_paths() { sh "${ROOT}/public/sh/install.sh" -a loader >/dev/null [ -f "${explicit}/${bin_name}/zi.zsh" ] || fail "loader install did not use the explicit checkout path" - contains "${home}/.zshrc" 'typeset -gA ZI' + contains "${config}/zi/setup.zsh" 'typeset -gA ZI' ( cd "${runtime_work}" || exit 1 HOME="${home}" \ - XDG_CONFIG_HOME="${config}" \ + XDG_CONFIG_HOME="${TMP_ROOT}/unrelated-runtime-config" \ XDG_DATA_HOME="${data}" \ zsh -f -c ' source "$1" @@ -967,6 +1385,7 @@ test_loader_carries_explicit_paths() { contains "${values_log}" "home:${explicit}" contains "${values_log}" "bin:${explicit}/${bin_name}" contains "${values_log}" 'layout:explicit' + contains "${home}/.zshrc" "source '${TMP_ROOT}/loader explicit config'\\''s root/zi/setup.zsh'" [ ! -e "${runtime_work}/pwned" ] || fail "explicit loader path executed generated Zsh" [ ! -e "${data}/zi/bin/zi.zsh" ] || fail "loader startup cloned a second checkout" pass "loader carries explicit home and bin paths into startup safely" @@ -1099,6 +1518,8 @@ test_init_uses_private_tempdir test_init_path_resolution write_fake_tools test_loader_install +test_curl_pipe_install +test_curl_pipe_install_keeps_asset_ref test_loader_default_paths_remain_dynamic test_loader_carries_explicit_paths test_loader_carries_explicit_bin_name @@ -1118,6 +1539,14 @@ test_zshrc_comment_does_not_suppress_integration test_annex_rerun_is_idempotent test_skip_leaves_annex_out test_branch_option_rejects_refspec -test_zshrc_text_uses_home_variable +test_source_ref_rejects_refspec +test_zshrc_uses_short_entrypoint +test_setup_plan_tamper_is_rejected +test_setup_target_drift_is_transactional +test_setup_symlinked_zshrc_is_refused +test_setup_managed_block_drift_is_refused +test_setup_checkout_head_drift_is_refused +test_setup_legacy_profiles_migrate +test_setup_legacy_loader_discovers_checkout test_sync_init test_success_line_reports_exact_path