diff --git a/.github/workflows/check-linux.yml b/.github/workflows/check-linux.yml index 7c027d3..27389ba 100644 --- a/.github/workflows/check-linux.yml +++ b/.github/workflows/check-linux.yml @@ -64,5 +64,3 @@ jobs: zmodload zi/zpmod zpmod source-study -l shell: zsh {0} - - name: "⚙️ Check: init.zsh sync drift" - run: sh public/sh/sync-init.sh --local ./public/zsh/init.zsh --remote ./public/zsh/init.zsh --checksum-url ./public/checksum.txt diff --git a/.github/workflows/loader-drift.yml b/.github/workflows/loader-drift.yml new file mode 100644 index 0000000..7cca30e --- /dev/null +++ b/.github/workflows/loader-drift.yml @@ -0,0 +1,47 @@ +--- +name: Loader Drift +# Verify that the published loader endpoint serves the same bytes as the +# loader source on main. +# +# The former "init.zsh sync drift" step in check-linux.yml passed +# --local and --remote as the same checked-out file, so it could never +# detect drift. The meaningful comparison is repository source against the +# published artifact, which only exists after a deployment. +on: + workflow_run: + workflows: ["GitHub Pages"] + types: [completed] + schedule: + # Daily, offset off the hour to avoid the scheduler peak. + - cron: "17 6 * * *" + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + published-loader: + name: Published loader matches source + if: github.repository == 'z-shell/src' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: ⤵️ Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + - name: "⚙️ Check: published loader endpoint" + run: | + sh ./public/sh/sync-init.sh \ + --local ./public/zsh/init.zsh \ + --remote https://init.zshell.dev \ + --checksum-url ./public/checksum.txt + - name: "⚙️ Check: raw main loader endpoint" + run: | + sh ./public/sh/sync-init.sh \ + --local ./public/zsh/init.zsh \ + --checksum-url ./public/checksum.txt diff --git a/docs/README.md b/docs/README.md index a7c209e..3d48ca3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,42 @@ - **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/) +### Loader configuration + +`public/zsh/init.zsh` defines `zzinit()`. Sourcing the file only declares the +function and applies defaults; nothing is cloned, sourced, or written until +`zzinit` is called. + +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 | + +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 +recognized legacy `$HOME/.zi` installation stays active. Otherwise the loader +uses `${XDG_DATA_HOME}/zi` when `XDG_DATA_HOME` is absolute, or +`$HOME/.local/share/zi` when it is unset, empty, or relative. When both homes +contain Zi data, an explicit or unique existing `BIN_DIR` identity selects the +matching home; otherwise the conservative fallback is the legacy home. No +automatic move or merge occurs. + +`ZI[CACHE_DIR]`, `ZI[CONFIG_DIR]`, and every other Zi path are owned and +derived by `zi.zsh`. Set one in `.zshrc` before sourcing the loader to override +it; do not add a duplicate default to the loader. See the +[customization guide](https://wiki.zshell.dev/docs/guides/customization#customizing-paths). + +One loader-only toggle exists: + +| Setting | Default | Purpose | +| --------------------- | ------- | ----------------------------------------------------------- | +| `ZI[LOADER_HISTORY]` | `1` | Set to `0` to leave `HISTFILE`/`SAVEHIST`/`HISTSIZE` alone | + ### Maintainer — Verify and Sync Loader Check whether the local `public/zsh/init.zsh` matches the canonical GitHub raw `main` copy: diff --git a/public/checksum.txt b/public/checksum.txt index 2957c73..ae73b25 100644 --- a/public/checksum.txt +++ b/public/checksum.txt @@ -1,4 +1,4 @@ 6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh -6ee1ced1b73ab9d6f8a17e26505dd5de588b2a0af2832603ae04eb2866763ed9 public/sh/install.sh +6e753a0314ba9f2e4f0a2c429798ca3767af4fb94c669e6923d161d6af7aa745 public/sh/install.sh 08cc893ceb982fc99d17db1966c6c30790cc571e16e4f5392352d995f5252952 public/sh/sync-init.sh -424d66dca9bc26dd225afa45ccf15e42c8cf1f9e5e68fa51b469969bb5d51901 public/zsh/init.zsh +e5db3eb099daf0098bed263f4f1960adc9d3d0074a19260fae7ab3df8bf08573 public/zsh/init.zsh diff --git a/public/sh/install.sh b/public/sh/install.sh index bd911bc..6edb7d9 100755 --- a/public/sh/install.sh +++ b/public/sh/install.sh @@ -102,8 +102,27 @@ fetch_to_file() { return 1 } +is_absolute_path() { + case "${1-}" in + /*) return 0 ;; + *) return 1 ;; + esac +} + +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 - ZI_CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/zi" + if is_absolute_path "${XDG_CONFIG_HOME-}"; then + ZI_CONFIG_DIR="${XDG_CONFIG_HOME}/zi" + else + ZI_CONFIG_DIR="${HOME}/.config/zi" + fi loader_tmp="${WORKDIR}/init.zsh.tmp" command mkdir -p "${ZI_CONFIG_DIR}" set +e @@ -124,7 +143,30 @@ if [ "${AOPT}" = loader ]; then fi if [ -z "${ZI_HOME-}" ]; then - ZI_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/zi" + 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 @@ -133,7 +175,7 @@ fi if ! test -d "${ZI_HOME}"; then command mkdir -p "${ZI_HOME}" - command chmod go-w "${ZI_HOME}" + command chmod 700 "${ZI_HOME}" fi if ! command -v git >/dev/null 2>&1; then @@ -207,7 +249,7 @@ MAIN_PROFILE() { if [[ ! -f ${ZI_HOME}/${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}" && command chmod go-rwX "${ZI_HOME}" - command git clone -q --depth=1 --branch "${BOPT}" https://github.com/z-shell/zi "${ZI_HOME}/${ZI_BIN_DIR_NAME}" && \\ + command git clone -q --filter=blob:none --single-branch --branch "${BOPT}" https://github.com/z-shell/zi "${ZI_HOME}/${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 @@ -221,9 +263,15 @@ EOF fi if [ "${AOPT}" = loader ] && [ "${ZOPT}" != skip ]; then command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" -if [[ -r "\${XDG_CONFIG_HOME:-\${HOME}/.config}/zi/init.zsh" ]]; then - source "\${XDG_CONFIG_HOME:-\${HOME}/.config}/zi/init.zsh" && zzinit +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 printf '%s\n' "▓▒░ Loader added" fi diff --git a/public/zsh/init.zsh b/public/zsh/init.zsh index c72db20..0847867 100755 --- a/public/zsh/init.zsh +++ b/public/zsh/init.zsh @@ -4,35 +4,140 @@ # # Zi Loader — bootstrap and source the Zi plugin manager. # -# Sourced early in .zshrc. Defines zzinit() which clones Zi on first run, -# sources zi.zsh, registers completions, and loads zpmod if built. -# All helper functions are cleaned up after zzinit() returns. +# Execution profile: startup-file. This is sourced early in .zshrc and +# deliberately makes phase-owned global effects; it is not a caller-preserving +# sourced library. +# +# Sourcing this file only defines zzinit(). Nothing is cloned, sourced, or +# written until zzinit() is called: +# +# 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 +# +# Documented global effects of zzinit(): +# - sources zi.zsh, which owns its own documented global effects +# - registers the zi completion in _comps when compinit has already run +# - appends to module_path and loads zi/zpmod when the module is built +# - on first run, clones ZI[REPOSITORY] into ZI[BIN_DIR] +# - unsets its own helper functions on success +# +# All helper functions and zzinit() itself are removed after a successful run. +# On failure they are kept so the user can read the diagnostics and retry. # ── Zi Configuration ────────────────────────────────────────────────────────── typeset -ghA ZI -# https://wiki.zshell.dev/docs/guides/customization +# Settings this loader must know before Zi exists, because they decide what to +# clone and where. See https://wiki.zshell.dev/docs/guides/customization : "${ZI[REPOSITORY]:=https://github.com/z-shell/zi.git}" : "${ZI[STREAM]:=main}" -: "${ZI[HOME_DIR]:=${XDG_DATA_HOME:-$HOME/.local/share}/zi}" -: "${ZI[BIN_DIR]:=${ZI[HOME_DIR]}/bin}" -: "${ZI[CACHE_DIR]:=${XDG_CACHE_HOME:-$HOME/.cache}/zi}" -: "${ZI[CONFIG_DIR]:=${XDG_CONFIG_HOME:-$HOME/.config}/zi}" - -# https://wiki.zshell.dev/community/zsh_plugin_standard#global-parameter-with-prefix -: "${ZPFX:=${ZI[HOME_DIR]}/polaris}" -: "${ZI[ZMODULES_DIR]:=${ZI[HOME_DIR]}/zmodules}" -: "${ZI[ZCOMPDUMP_PATH]:=${ZI[CACHE_DIR]}/.zcompdump}" + +# The loader needs HOME_DIR and BIN_DIR before Zi exists so it knows where to +# find or clone zi.zsh. Mirror Zi's home-resolution contract exactly, without +# creating directories: explicit values win, a recognized legacy home stays +# active, and fresh installs use an absolute XDG data base or its fallback. +# Cache and config remain unset here and are resolved by zi.zsh itself. +() { + builtin emulate -L zsh + + local data_base legacy_home xdg_home marker requested_bin="${ZI[BIN_DIR]}" + integer legacy_present=0 xdg_present=0 + + if [[ -n $XDG_DATA_HOME && $XDG_DATA_HOME == /* ]]; then + data_base="$XDG_DATA_HOME" + else + data_base="${HOME}/.local/share" + fi + legacy_home="${HOME}/.zi" + xdg_home="${data_base}/zi" + + if [[ -z ${ZI[HOME_DIR]} ]]; then + for marker in bin/zi.zsh plugins snippets completions zmodules; do + if [[ -e "${legacy_home}/${marker}" ]]; then + legacy_present=1 + break + fi + done + for marker in bin/zi.zsh plugins snippets completions zmodules; do + if [[ -e "${xdg_home}/${marker}" ]]; then + xdg_present=1 + break + fi + done + + if (( legacy_present && xdg_present )); then + if [[ $requested_bin == "${xdg_home}/bin" || $requested_bin == "${xdg_home}/bin/"* ]] || + [[ -z $requested_bin && -e "${xdg_home}/bin/zi.zsh" && ! -e "${legacy_home}/bin/zi.zsh" ]]; then + ZI[HOME_DIR]="$xdg_home" + ZI[HOME_LAYOUT]=ambiguous-xdg + else + ZI[HOME_DIR]="$legacy_home" + ZI[HOME_LAYOUT]=ambiguous-legacy + fi + elif (( legacy_present )); then + ZI[HOME_DIR]="$legacy_home" + ZI[HOME_LAYOUT]=legacy + else + ZI[HOME_DIR]="$xdg_home" + ZI[HOME_LAYOUT]=xdg + fi + elif [[ -z ${ZI[HOME_LAYOUT]} ]]; then + ZI[HOME_LAYOUT]=explicit + fi + + [[ -n ${ZI[BIN_DIR]} ]] || ZI[BIN_DIR]="${ZI[HOME_DIR]}/bin" +} + +# Retained for compatibility: user configuration and third-party plugins read +# this value directly, so it must be defined rather than merely defaulted +# inside zi.zsh. : "${ZI[MUTE_WARNINGS]:=0}" -# History defaults -: "${HISTFILE:=${XDG_STATE_HOME:-$HOME/.local/state}/zsh/history}" -[[ -e "$HISTFILE" ]] || { command mkdir -p "${HISTFILE:h}"; command touch "$HISTFILE"; } -[[ -w "$HISTFILE" ]] && typeset -gx SAVEHIST=440000 HISTSIZE=441000 +# NOTE ON DEFAULTS +# Every other ZI[...] key is owned by zi.zsh. Do not duplicate defaults here; +# set a value in .zshrc before this file is sourced if you want to override it. +# The full set zi.zsh honours includes: +# +# Paths CACHE_DIR CONFIG_DIR COMPLETIONS_DIR PLUGINS_DIR SNIPPETS_DIR +# SERVICES_DIR THEMES_DIR ZMODULES_DIR MAN_DIR LOG_DIR MAIL_DIR +# CDPATH_DIR ZCOMPDUMP_PATH ZPFX +# Behaviour OPTIMIZE_OUT_DISK_ACCESSES COMPINIT_OPTS INTERNAL_ALIASES +# PKG_OWNER +# +# Reference: https://wiki.zshell.dev/docs/guides/customization#customizing-paths + +# Loader behaviour toggles. +# +# ZI[LOADER_HISTORY] 1 (default) applies the history defaults below. Set to 0 +# before sourcing to leave HISTFILE, SAVEHIST, and HISTSIZE +# entirely to your own configuration. +: "${ZI[LOADER_HISTORY]:=1}" + +# History defaults. These are a convenience for the installer's minimal .zshrc +# and are unrelated to loading Zi; ZI[LOADER_HISTORY]=0 disables them. +if [[ ${ZI[LOADER_HISTORY]} == 1 ]]; then + : "${HISTFILE:=${XDG_STATE_HOME:-$HOME/.local/state}/zsh/history}" + [[ -e "$HISTFILE" ]] || { command mkdir -p "${HISTFILE:h}" && command touch "$HISTFILE"; } + [[ -w "$HISTFILE" ]] && typeset -gx SAVEHIST=440000 HISTSIZE=441000 +fi # ── Bootstrap Helpers ───────────────────────────────────────────────────────── +# Report a loader failure on stderr. +_zi_err() { + builtin emulate -L zsh + builtin print -u2 -P "%F{160}▓▒░ Zi loader: %f%b$1" + return 0 +} + # Fetch content from a URL to stdout. _zi_fetch() { builtin emulate -L zsh @@ -41,96 +146,191 @@ _zi_fetch() { elif (( $+commands[wget] )); then command wget -qO- "$1" else + _zi_err "neither curl nor wget is available; cannot download." return 255 fi } -# Clone Zi repository if it doesn't exist. +# Reject a stream name that git would not accept as a branch, before it reaches +# `git clone --branch` and produces an unattributed git error. +_zi_check_stream() { + builtin emulate -L zsh + local stream="${ZI[STREAM]}" + if [[ -z $stream ]]; then + _zi_err "ZI[STREAM] is empty; set it to a branch or tag name." + return 1 + fi + if [[ $stream == -* || $stream == *[[:space:]]* ]]; then + _zi_err "ZI[STREAM] is not a valid ref name: ${(qqq)stream}" + return 1 + fi + if (( $+commands[git] )) && + ! command git check-ref-format --allow-onelevel "$stream" 2>/dev/null; then + _zi_err "ZI[STREAM] is not a valid ref name: ${(qqq)stream}" + return 1 + fi + return 0 +} + +# Clone the Zi repository if it is not already present. _zi_setup() { builtin emulate -L zsh builtin autoload colors; colors local -a git_refs local tmp_dir show_process process_url + integer clone_status=0 - if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - tmp_dir="${TMPDIR:-/tmp}/zi" - [[ -d "$tmp_dir" ]] || command mkdir -p "$tmp_dir" + [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]] && return 0 - show_process="${tmp_dir}/git-process.zsh" - process_url="https://raw.githubusercontent.com/z-shell/zi/main/public/zsh/git-process-output.zsh" + if (( ! $+commands[git] )); then + _zi_err "git is required to install Zi but was not found in PATH." + return 1 + fi + _zi_check_stream || return 1 - if [[ ! -f "$show_process" ]]; then - if _zi_fetch "$process_url" > "${tmp_dir}/git-process.zsh"; then - command chmod a+x "${tmp_dir}/git-process.zsh" - else - return 1 - fi - fi + # A private, unpredictable directory. The progress filter is downloaded and + # then executed, so it must never live at a fixed, world-writable path where + # another user could pre-place a file for us to run. + tmp_dir="$(command mktemp -d "${TMPDIR:-/tmp}/zi-loader.XXXXXX" 2>/dev/null)" || { + _zi_err "could not create a private temporary directory." + return 1 + } + show_process="${tmp_dir}/git-process-output.zsh" + process_url="https://raw.githubusercontent.com/z-shell/zi/main/lib/zsh/git-process-output.zsh" - (( $+commands[clear] )) && command clear - builtin print -P "%F{33}▓▒░ %F{160}Installing interactive & feature-rich plugin manager (%F{33}z-shell/zi%F{160})%f%b…\n" - command mkdir -p "${ZI[BIN_DIR]}" && \ - command git clone --verbose --progress --branch \ - "${ZI[STREAM]}" "${ZI[REPOSITORY]}" "${ZI[BIN_DIR]}" \ - |& { command "$show_process" || command cat; } - - if [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - command chmod -R go-w "${ZI[HOME_DIR]}" - git_refs=("${(f@)$(builtin cd -q "${ZI[BIN_DIR]}" && command git log --color --graph --abbrev-commit \ - --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' | command head -5)}") - builtin print - builtin print -P "%F{33}▓▒░ %F{34}Successfully installed %F{160}(%F{33}z-shell/zi%F{160})%f%b\n" - builtin print -rl -- "${git_refs[@]}" - else - builtin print -P "%F{160}▓▒░ The clone has failed…%f%b" - builtin print -P "%F{160}▓▒░ %F{33} Please report the issue: %F{226}https://github.com/z-shell/zi/issues/new%f%b" - return 1 - fi + # The filter is cosmetic. If it cannot be fetched, fall back to plain output + # rather than failing the install. + if _zi_fetch "$process_url" > "$show_process" && [[ -s $show_process ]]; then + command chmod u+x "$show_process" + else + show_process="" + fi + + (( $+commands[clear] )) && command clear + builtin print -P "%F{33}▓▒░ %F{160}Installing interactive & feature-rich plugin manager (%F{33}z-shell/zi%F{160})%f%b…\n" + + if command mkdir -p "${ZI[BIN_DIR]}"; then + # --filter=blob:none keeps the clone small without making it shallow. + # zi.zsh derives ZI[VERSION] from `git describe --tags`, which a --depth=1 + # clone would degrade to a bare short SHA. + command git clone --verbose --progress \ + --filter=blob:none --single-branch \ + --branch "${ZI[STREAM]}" "${ZI[REPOSITORY]}" "${ZI[BIN_DIR]}" \ + |& { [[ -n $show_process ]] && command "$show_process" || command cat; } + clone_status=${pipestatus[1]} + else + _zi_err "could not create ${(qqq)ZI[BIN_DIR]}" + clone_status=1 fi + + command rm -rf -- "$tmp_dir" + + if (( clone_status != 0 )) || [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then + builtin print -P "%F{160}▓▒░ The clone has failed…%f%b" + builtin print -P "%F{160}▓▒░ %F{33} Please report the issue: %F{226}https://github.com/z-shell/zi/issues/new%f%b" + return 1 + fi + + # Scope the permission fix to the clone. ZI[HOME_DIR] also holds plugins, + # snippets, and other user data that this loader does not own. + command chmod -R go-w "${ZI[BIN_DIR]}" + git_refs=("${(f@)$(builtin cd -q "${ZI[BIN_DIR]}" && command git log --color --graph --abbrev-commit \ + --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' | command head -5)}") + builtin print + builtin print -P "%F{33}▓▒░ %F{34}Successfully installed %F{160}(%F{33}z-shell/zi%F{160})%f%b\n" + builtin print -rl -- "${git_refs[@]}" return 0 } # Source zi.zsh, bootstrapping first if needed. +# +# zi.zsh's top level makes intentional global changes: it sets AUTO_CD when +# ZI[CDPATH_DIR] exists and marks path, manpath, cdpath, mailpath, fpath, and +# logpath as exported and unique. Wrapping the source in `emulate -L zsh` would +# localize those to this function and silently discard them, so instead only +# the options that would corrupt zi.zsh's own parsing are neutralized, then +# restored to the caller's values. _zi_source() { - builtin emulate -L zsh - if [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - builtin source "${ZI[BIN_DIR]}/zi.zsh" - else + local -A caller_opts + local opt + local -a guard=( + sh_word_split ksh_arrays ksh_glob glob_subst rc_expand_param + err_exit err_return no_unset warn_create_global + ) + integer src_status + + if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then _zi_setup || return 1 - # Guard: if setup succeeded but zi.zsh is still missing, don't recurse - if [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then - builtin source "${ZI[BIN_DIR]}/zi.zsh" - else + # Guard: if setup reported success but zi.zsh is still missing, do not recurse. + if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then + _zi_err "setup reported success but ${(qqq)ZI[BIN_DIR]}/zi.zsh is missing." return 1 fi fi + + for opt in "${guard[@]}"; do + caller_opts[$opt]="${options[$opt]}" + done + builtin setopt no_sh_word_split no_ksh_arrays no_ksh_glob no_glob_subst \ + no_rc_expand_param no_err_exit no_err_return unset no_warn_create_global + + builtin source "${ZI[BIN_DIR]}/zi.zsh" + src_status=$? + + for opt in "${guard[@]}"; do + options[$opt]="${caller_opts[$opt]}" + done + + (( src_status == 0 )) || _zi_err "sourcing zi.zsh failed with status ${src_status}." + return "$src_status" } -# Load zpmod module if built. +# Load the zpmod module if it has been built. _zi_pmod() { builtin emulate -L zsh - if [[ -f "${ZI[ZMODULES_DIR]}/zpmod/Src/zi/zpmod.so" ]]; then - module_path+=( "${ZI[ZMODULES_DIR]}/zpmod/Src" ) - zmodload zi/zpmod 2>/dev/null + local module_dir="${ZI[ZMODULES_DIR]:-${ZI[HOME_DIR]}/zmodules}/zpmod/Src" + [[ -f "${module_dir}/zi/zpmod.so" ]] || return 0 + + typeset -gU module_path + module_path+=( "$module_dir" ) + if ! zmodload zi/zpmod 2>/dev/null; then + [[ ${ZI[MUTE_WARNINGS]} == 1 ]] || + _zi_err "zpmod.so is present but zmodload zi/zpmod failed; rebuild it with \`zi module build\`." + return 1 fi return 0 } -# Register Zi completion if the completion system is active. +# Register the Zi completion if the completion system is already active. _zi_comps() { builtin emulate -L zsh - if (( ${+_comps} )); then - (( ${+_comps[zi]} )) || _comps[zi]=_zi - fi + (( ${+_comps} )) || return 0 + (( ${+_comps[zi]} )) || _comps[zi]=_zi return 0 } # ── Entry Point ─────────────────────────────────────────────────────────────── zzinit() { - { - _zi_source && _zi_comps && _zi_pmod - } always { - unset -f _zi_fetch _zi_setup _zi_source _zi_comps _zi_pmod zzinit 2>/dev/null - } + integer status_source status_comps status_pmod + + # Sourcing Zi is the only hard prerequisite. Completion registration and the + # optional module are independent: neither should be skipped because the + # other reported a problem. + _zi_source + status_source=$? + if (( status_source != 0 )); then + _zi_err "Zi was not loaded. Fix the problem above, then run \`zzinit\` again." + return "$status_source" + fi + + _zi_comps + status_comps=$? + _zi_pmod + status_pmod=$? + + # Helpers are only removed on success so a failed run stays retryable. + unset -f _zi_err _zi_fetch _zi_check_stream _zi_setup _zi_source _zi_comps _zi_pmod zzinit 2>/dev/null + + (( status_comps == 0 && status_pmod == 0 )) } diff --git a/tests/installers.sh b/tests/installers.sh index 48a0a27..f27d812 100755 --- a/tests/installers.sh +++ b/tests/installers.sh @@ -97,6 +97,188 @@ test_init_defaults_are_single_arguments() { pass "init defaults preserve argument and value boundaries" } +test_init_preserves_caller_options() { + values_log="${TMP_ROOT}/init-option-values" + + # zi.zsh's top level deliberately sets AUTO_CD and marks the path arrays + # exported/unique. Wrapping the source in `emulate -L zsh` would localize + # those to the loader's own function and silently discard them, so the loader + # guards only the options that would corrupt parsing and restores the + # caller's values afterwards. + zsh -f -c ' + typeset -ghA ZI + ZI[HOME_DIR]="$3/opt-home" + ZI[BIN_DIR]="$3/opt-home/bin" + ZI[CDPATH_DIR]="$3/opt-home/cd_path" + command mkdir -p "${ZI[BIN_DIR]}" "${ZI[CDPATH_DIR]}" + + # A stand-in for zi.zsh that reproduces the two behaviours that matter: + # it needs default word splitting, and its global effects must survive. + print -r -- " + typeset -g SAW_SPLIT=\${options[sh_word_split]} + typeset -g SAW_KSHARRAYS=\${options[ksh_arrays]} + builtin setopt auto_cd + typeset -gxU path PATH + " > "${ZI[BIN_DIR]}/zi.zsh" + + setopt sh_word_split ksh_arrays + source "$1" + zzinit + + { + print -r -- "caller_split:${options[sh_word_split]}" + print -r -- "caller_ksharrays:${options[ksh_arrays]}" + print -r -- "inner_split:${SAW_SPLIT}" + print -r -- "inner_ksharrays:${SAW_KSHARRAYS}" + print -r -- "autocd:${options[autocd]}" + } >"$2" + ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" "${TMP_ROOT}" + + # The caller keeps the options it had. + contains "${values_log}" 'caller_split:on' + contains "${values_log}" 'caller_ksharrays:on' + # zi.zsh is sourced with sane parsing options regardless of the caller. + contains "${values_log}" 'inner_split:off' + contains "${values_log}" 'inner_ksharrays:off' + # zi.zsh's intentional global effect survives the loader. + contains "${values_log}" 'autocd:on' + pass "loader guards parsing options without discarding Zi's global effects" +} + +test_init_history_opt_out() { + values_log="${TMP_ROOT}/init-history-values" + history_home="${TMP_ROOT}/history-home" + command mkdir -p "${history_home}" + + zsh -f -c ' + export HOME="$3" + export XDG_STATE_HOME="$3/state" + typeset -ghA ZI + ZI[LOADER_HISTORY]=0 + source "$1" + { + print -r -- "histfile:${HISTFILE:-}" + print -r -- "statedir:$([[ -d $XDG_STATE_HOME/zsh ]] && print present || print absent)" + } >"$2" + ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" "${history_home}" + + contains "${values_log}" 'histfile:' + contains "${values_log}" 'statedir:absent' + pass "ZI[LOADER_HISTORY]=0 suppresses history defaults and filesystem writes" +} + +test_init_rejects_invalid_stream() { + values_log="${TMP_ROOT}/init-stream-values" + err_log="${TMP_ROOT}/init-stream-err" + + # An option-like ZI[STREAM] must never reach `git clone --branch`. + zsh -f -c ' + typeset -ghA ZI + ZI[HOME_DIR]="$4/stream-home" + ZI[BIN_DIR]="$4/stream-home/bin" + ZI[STREAM]="--upload-pack=touch $4/stream-pwned" + source "$1" + zzinit 2>"$3" + print -r -- "status:$?" >"$2" + print -r -- "retryable:${+functions[zzinit]}" >>"$2" + ' zsh "${ROOT}/public/zsh/init.zsh" "${values_log}" "${err_log}" "${TMP_ROOT}" + + contains "${values_log}" 'status:1' + # A failed run keeps zzinit defined so the user can fix and retry. + contains "${values_log}" 'retryable:1' + contains "${err_log}" 'not a valid ref name' + [ -e "${TMP_ROOT}/stream-pwned" ] && fail "invalid ZI[STREAM] reached git clone" + pass "invalid ZI[STREAM] is rejected before reaching git" +} + +test_init_progress_filter_url() { + # The loader downloads this file and executes it. A 404 previously aborted + # every clean install with no diagnostic, so the path is asserted here. + contains "${ROOT}/public/zsh/init.zsh" \ + 'https://raw.githubusercontent.com/z-shell/zi/main/lib/zsh/git-process-output.zsh' + pass "progress filter URL points at the published path" +} + +test_init_uses_private_tempdir() { + # A fixed ${TMPDIR}/zi path would let another user pre-place the executable + # that the loader downloads, chmods, and runs. + if grep -F '${TMPDIR:-/tmp}/zi"' "${ROOT}/public/zsh/init.zsh" >/dev/null 2>&1; then + fail "loader uses a predictable temporary directory" + fi + contains "${ROOT}/public/zsh/init.zsh" 'mktemp -d' + pass "loader downloads the progress filter into a private temporary directory" +} + +test_init_path_resolution() { + values_log="${TMP_ROOT}/init-path-values" + cases_root="${TMP_ROOT}/init path cases" + command mkdir -p "${cases_root}" + + zsh -f -c ' + run_case() ( + builtin emulate -LR zsh + local label="$1" root="$3/$1" expected_bin + command mkdir -p "$root/home" "$root/zdotdir" + typeset -gx HOME="$root/home" + typeset -gx ZDOTDIR="$root/zdotdir" + unset XDG_DATA_HOME XDG_CACHE_HOME XDG_CONFIG_HOME + typeset -ghA ZI + ZI=([LOADER_HISTORY]=0) + + case "$label" in + fresh-spaces) + typeset -gx XDG_DATA_HOME="$root/data root" + ;; + empty) + typeset -gx XDG_DATA_HOME="" + ;; + relative) + typeset -gx XDG_DATA_HOME="relative data" + command mkdir -p "$ZDOTDIR/.zi/plugins" + ;; + legacy-only) + command mkdir -p "$HOME/.zi/plugins" + ;; + xdg-only) + typeset -gx XDG_DATA_HOME="$root/data" + command mkdir -p "$XDG_DATA_HOME/zi/plugins" + ;; + both-external) + typeset -gx XDG_DATA_HOME="$root/data" + command mkdir -p "$HOME/.zi/plugins" "$XDG_DATA_HOME/zi/plugins" + ;; + both-xdg-source) + typeset -gx XDG_DATA_HOME="$root/data" + command mkdir -p "$HOME/.zi/plugins" "$XDG_DATA_HOME/zi/bin" "$XDG_DATA_HOME/zi/plugins" + command touch "$XDG_DATA_HOME/zi/bin/zi.zsh" + ZI[BIN_DIR]="$XDG_DATA_HOME/zi/bin" + ;; + explicit) + typeset -gx XDG_DATA_HOME="$root/data" + ZI[HOME_DIR]="$root/explicit home" + ;; + esac + + source "$2" + print -r -- "$label|${ZI[HOME_DIR]}|${ZI[BIN_DIR]}|${ZI[HOME_LAYOUT]}|${ZI[CACHE_DIR]:-}|${ZI[CONFIG_DIR]:-}" + ) + + for label in fresh-spaces empty relative legacy-only xdg-only both-external both-xdg-source explicit; do + run_case "$label" "$1" "$2" + done + ' zsh "${ROOT}/public/zsh/init.zsh" "${cases_root}" >"${values_log}" + + contains "${values_log}" "fresh-spaces|${cases_root}/fresh-spaces/data root/zi|${cases_root}/fresh-spaces/data root/zi/bin|xdg||" + contains "${values_log}" "empty|${cases_root}/empty/home/.local/share/zi|${cases_root}/empty/home/.local/share/zi/bin|xdg||" + contains "${values_log}" "relative|${cases_root}/relative/home/.local/share/zi|${cases_root}/relative/home/.local/share/zi/bin|xdg||" + contains "${values_log}" "legacy-only|${cases_root}/legacy-only/home/.zi|${cases_root}/legacy-only/home/.zi/bin|legacy||" + contains "${values_log}" "xdg-only|${cases_root}/xdg-only/data/zi|${cases_root}/xdg-only/data/zi/bin|xdg||" + contains "${values_log}" "both-external|${cases_root}/both-external/home/.zi|${cases_root}/both-external/home/.zi/bin|ambiguous-legacy||" + contains "${values_log}" "both-xdg-source|${cases_root}/both-xdg-source/data/zi|${cases_root}/both-xdg-source/data/zi/bin|ambiguous-xdg||" + contains "${values_log}" "explicit|${cases_root}/explicit/explicit home|${cases_root}/explicit/explicit home/bin|explicit||" + pass "loader mirrors the core home resolver and leaves cache and config to Zi" +} + write_fake_tools() { FAKE_BIN="${TMP_ROOT}/bin" command mkdir -p "${FAKE_BIN}" @@ -237,7 +419,7 @@ test_loader_install() { # shellcheck disable=SC2016 contains "${config}/zi/init.zsh" ': "${ZI[STREAM]:=feature/test}"' # shellcheck disable=SC2016 - contains "${home}/.zshrc" 'source "${XDG_CONFIG_HOME:-${HOME}/.config}/zi/init.zsh" && zzinit' + contains "${home}/.zshrc" 'source "${ZI_LOADER_CONFIG_HOME}/init.zsh" && zzinit' [ -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" } @@ -258,6 +440,82 @@ test_xdg_data_home_install() { pass "XDG data home install creates parent directories" } +test_legacy_home_install() { + home="${TMP_ROOT}/legacy-home" + data="${TMP_ROOT}/legacy-data" + command mkdir -p "${home}/.zi/plugins" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null + + [ -f "${home}/.zi/bin/zi.zsh" ] || fail "installer did not retain the legacy Zi home" + [ ! -e "${data}/zi/bin/zi.zsh" ] || fail "installer created a parallel XDG installation" + pass "legacy-only install remains in the legacy Zi home" +} + +test_relative_xdg_fallback_install() { + home="${TMP_ROOT}/relative-home" + work="${TMP_ROOT}/relative-work" + command mkdir -p "${home}" "${work}" + + ( + cd "${work}" || exit 1 + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_CONFIG_HOME="relative-config" \ + XDG_DATA_HOME="relative-data" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -a loader -i skip >/dev/null + ) + + [ -f "${home}/.local/share/zi/bin/zi.zsh" ] || fail "relative XDG data value did not use fallback" + [ -f "${home}/.config/zi/init.zsh" ] || fail "relative XDG config value did not use fallback" + [ ! -e "${work}/relative-data" ] || fail "relative XDG data path was created" + [ ! -e "${work}/relative-config" ] || fail "relative XDG config path was created" + pass "relative XDG installer values use specification fallbacks" +} + +test_both_present_install_identity() { + home="${TMP_ROOT}/both-home" + data="${TMP_ROOT}/both-data" + command mkdir -p "${home}/.zi/plugins" "${data}/zi/bin/.git" + printf '%s\n' '# fake zi.zsh' >"${data}/zi/bin/zi.zsh" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null + + [ ! -e "${home}/.zi/bin" ] || fail "installer ignored the existing XDG source identity" + [ -f "${data}/zi/bin/zi.zsh" ] || fail "installer did not retain the XDG source identity" + pass "both-present installer selection follows existing source identity" +} + +test_explicit_home_install() { + home="${TMP_ROOT}/explicit-home" + data="${TMP_ROOT}/explicit-data" + explicit="${TMP_ROOT}/explicit Zi root" + command mkdir -p "${home}" "${data}/zi/plugins" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_HOME="${explicit}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null + + [ -f "${explicit}/bin/zi.zsh" ] || fail "explicit ZI_HOME was not preserved" + pass "explicit installer home wins and preserves spaces" +} + test_standalone_zpmod_delegation() { standalone_dir="${TMP_ROOT}/standalone" home="${TMP_ROOT}/zpmod-home" @@ -378,9 +636,19 @@ test_sync_init() { check_syntax check_checksums test_init_defaults_are_single_arguments +test_init_preserves_caller_options +test_init_history_opt_out +test_init_rejects_invalid_stream +test_init_progress_filter_url +test_init_uses_private_tempdir +test_init_path_resolution write_fake_tools test_loader_install test_xdg_data_home_install +test_legacy_home_install +test_relative_xdg_fallback_install +test_both_present_install_identity +test_explicit_home_install test_standalone_zpmod_delegation test_update_valid_zi_clone test_update_rejects_foreign_repo