From b659fdee61c8f5d0b1d43856cf8f8836b783c9be Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:16:15 +0100 Subject: [PATCH 1/2] feat(setup): add TUI engine interface contract --- docs/README.md | 2 + docs/zi-setup-tui-contract.md | 212 ++++++++++++++ public/checksum.txt | 2 +- public/sh/setup.sh | 516 +++++++++++++++++++++++++++++++++- tests/installers.sh | 192 +++++++++++++ 5 files changed, 910 insertions(+), 14 deletions(-) create mode 100644 docs/zi-setup-tui-contract.md diff --git a/docs/README.md b/docs/README.md index f3f7d5e..bac318c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,8 @@ `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. +The source-adjacent [Zi Setup TUI contract](zi-setup-tui-contract.md) defines the proposed machine interface and acceptance boundary for a future guided terminal client. The client offers `loader` and `annex`; `zunit` remains compatibility-only for existing installer output. + For a normal installation, run: ```sh diff --git a/docs/zi-setup-tui-contract.md b/docs/zi-setup-tui-contract.md new file mode 100644 index 0000000..7f36c0a --- /dev/null +++ b/docs/zi-setup-tui-contract.md @@ -0,0 +1,212 @@ +# Zi Setup TUI contract + +## Status and purpose + +This document defines the engine contract and next terminal-interface milestone after the guided setup planner shipped in `src` pull request 221. It refines the broader Zi Setup product proposal into a bounded terminal interface that delegates all installation and configuration changes to `public/sh/setup.sh`. + +The terminal interface is a client of the setup engine. It does not resolve Zi paths independently, generate startup files, edit `.zshrc`, install recipes, or reinterpret a plan. Normal shell startup remains independent of the interface. + +[Issue 222](https://github.com/z-shell/src/issues/222) records the interface trigger required by [ADR-0025](https://github.com/z-shell/.github/blob/main/decisions/0025-guided-setup-planner-first.md): the planner's manual commands and artifact layout require installer knowledge that blocks the intended guided onboarding experience. + +## Revised implementation brief + +Build a local Zi Setup TUI pilot that guides a user through discovery, profile selection, review, apply, and results. Use the existing `src` planner as the only authority for resolved paths, generated Zsh, preconditions, file content, checkout operations, and receipts. + +The pilot supports two selectable profiles: + +- `loader`, presented as **Zi only**. +- `annex`, presented as **Zi with annexes**. + +The `zunit` profile is not a user-facing setup choice. The engine keeps support for it so existing installer output can be migrated without data loss. When discovery or planning retains a legacy `zunit` configuration, the interface identifies it as preserved compatibility content and does not recommend it to new users. + +The pilot must complete one real flow in a disposable home: + +`Discover -> Choose -> Review -> Apply -> Result` + +The Review screen shows the exact plan identity, checkout operation, target paths, generated Zsh, file diffs, deferred first-start work, warnings, and manual steps. Apply invokes the engine with the reviewed plan hash. Reopening the interface against the resulting home must report no content changes. The new plan hash may differ because current-file preconditions are part of the artifact. + +Use Go with Bubble Tea v2, Lip Gloss v2, and only the Bubbles components needed by the pilot. The interface must remain usable at 80x24, support keyboard-only navigation, restore the terminal after errors and signals, honor `NO_COLOR`, and provide a linear `--plain` presentation. Rich previews use deterministic sample data and are clearly labelled as simulations. + +The pilot does not include arbitrary plugin selection, a marketplace, prompt replacement, removal of another manager, system package installation, font changes, `chsh`, a background service, current-shell mutation, or automatic migration of unknown Zsh configuration. + +## Ownership + +`src` owns the setup engine, versioned interface artifacts, profile mapping, generated Zsh, plan validation, application, and receipts. The eventual `zi-setup` package owns terminal interaction, presentation state, synthetic previews, accessibility modes, and process orchestration. + +The interface may display engine output for diagnostics, but it must not parse human-readable stdout or stderr to make decisions. It consumes only documented interface artifacts and exit statuses. + +## Engine interface + +### Design rules + +The interface uses directory artifacts rather than JSON written by portable shell. Each field has a fixed relative path and contains raw bytes or one restricted token. This avoids shell-level JSON escaping and keeps paths with spaces unambiguous. Artifact directories are private temporary directories chosen by the caller. + +Each new artifact begins with a `format` file containing its exact schema identifier. The existing plan artifact keeps its `format` key in `plan.meta`. Consumers reject unknown major versions. The output, plan, and result destinations must not exist, and their immediate parents must already exist and be writable. Writers publish each artifact by creating a private staging directory beside the destination and renaming the completed directory into place. + +The engine owns validation of all user-controlled values. The client passes arguments as an argument array and never evaluates engine output. + +### Commands + +The existing `plan` and `apply` commands remain the mutation boundary. The engine exposes these machine-facing artifacts: + +```text +setup.sh describe --output DIR [--zi-home DIR] [--zi-bin-dir NAME] + [--config-home DIR] [--zshrc FILE] [--profiles FILE] + [--skip-zshrc] +setup.sh plan --plan DIR [existing options] +setup.sh apply --plan DIR --phase checkout|files [--expect SHA256] + [--result DIR] +``` + +`describe` performs bounded read-only discovery. It does not source `.zshrc`, `init.zsh`, Zi, or plugins. It still publishes a describe artifact when all profiles are blocked, then exits with status 3. `plan` remains deterministic for the same filesystem inputs and selected options. `apply` continues to validate the complete plan and relevant preconditions before mutation. When `--result` is present, it publishes a result for validation failures, completed phases, operation failures, and cancellation. Omitting `--result` preserves the existing shell interface. + +Human-readable stdout and stderr remain available for direct shell use. Their wording is not part of the interface contract. + +### Describe artifact + +`zi-setup-describe-v1` contains: + +```text +format +facts/order +facts//value +facts//source +facts//confidence +profiles/order +profiles//selectable +profiles//reason +profiles//title +``` + +Fact IDs and profile IDs use lowercase ASCII letters, digits, and hyphens. `source` is one of `observed`, `inferred`, `confirmed`, or `unknown`. `confidence` is one of `certain`, `likely`, or `unknown`. `selectable` is `yes` or `no`. + +Version 1 writes facts in this order: `config-home`, `zi-home`, `checkout-path`, `zi-home-state`, `zshrc-path`, `zshrc-state`, `git`, `zsh`, `tty`, and `existing-profile`. `zi-home-state` is `selected` or `ambiguous`. `zshrc-state` is `skipped`, `symlink`, `file`, `other`, or `missing`. The `git` and `zsh` values are `available` or `missing`, `tty` is `yes` or `no`, and `existing-profile` is `none`, `loader`, `annex`, or `zunit`. `zi-home` and `checkout-path` contain `unknown` when both supported Zi homes exist without one safe identity. + +Version 1 lists `loader` and `annex` in that order. They are selectable when the Zi home is unambiguous and both Git and Zsh are available. If an exact legacy `zunit` block or a `zunit` receipt is detected, `zunit` appears with `selectable=no` and a reason explaining its compatibility-only status. The interface must not synthesize choices absent from this artifact. + +### Plan artifact + +The shipped `zi-setup-plan-v1` directory is a documented read contract for the fields below. Existing content and precondition files remain engine-owned. + +```text +plan.id +plan.meta +checkout/kind +checkout/head +checkout/current-ref +checkout/origin +checkout/requested-ref +targets/order +targets//path +targets//kind +targets//expected +targets//mode +targets//content +targets//block-hash # only when applicable +operations/order +operations//phase +operations//kind +operations//summary +operations//interruptible +warnings/order +warnings//severity +warnings//summary +warnings//remediation +``` + +`plan.meta` remains restricted `key=value` data whose values cannot contain newline or tab. Version 1 writes `format`, `profile`, `ref`, `config_home`, `checkout_path`, `receipt_path`, and `skip_zshrc`. Other files contain raw bytes unless their values are explicitly restricted tokens. + +Version 1 always lists `checkout-sync` and `write-files` operations. `checkout-sync` has phase `checkout`, kind `clone` or `fast-forward`, and `interruptible=no`. `write-files` has phase `files`, kind `write-files`, and `interruptible=no`. Warning severity is one of `info`, `warning`, or `critical`. The current conditional warning IDs are `deferred-first-start`, `legacy-zunit`, and `zshrc-skipped`. + +Operation summaries and warnings are display text. The client treats them as untrusted terminal content and strips or visibly escapes control sequences. Decisions use IDs and restricted fields, never summary text. + +The plan hash covers every artifact file except `plan.id`, as it does today. Any field change produces a new plan identity. The TUI stores the reviewed `plan.id` and always passes it through `--expect` for both phases. + +### Apply result artifact + +`zi-setup-result-v1` contains: + +```text +format +plan.id +phase +status +operations/order +operations//status +operations//detail +error/code # present on failure +error/operation # present when attributable +error/detail # present on failure +receipt/path # present after successful files phase +``` + +`status` and operation status are restricted tokens: `pending`, `running`, `succeeded`, `failed`, `cancelled`, or `unknown`. A completed version 1 artifact uses `succeeded`, `failed`, or `cancelled`. Error codes are stable ASCII identifiers including `unsupported-version`, `plan-changed`, `target-drift`, `checkout-drift`, `lock-held`, `network-failed`, `checkout-failed`, `write-failed`, and `cancelled`. + +`operations/order` contains the phase operation when execution reached or completed that operation, and is empty for a global failure. `error/operation` is omitted when the failure is not attributable to an operation, including unsupported versions, changed plan content, and a lock already held before the operation begins. + +The first pilot may publish the result only when a phase exits. Streaming operation events are deferred until real UI testing shows they are needed. The TUI displays the planned operation name while a phase runs and shows sanitized subprocess logs in an optional details view. + +### Exit status contract + +- `0`: requested operation succeeded; its result artifact is complete when `--result` was requested. +- `2`: invocation or unsupported interface version. +- `3`: discovery or planning cannot produce an actionable artifact. +- `4`: a plan, checkout, or file precondition changed. +- `5`: an apply operation began but did not complete. +- `6`: the user or supervising process cancelled the operation. + +The result artifact carries the specific reason. The exit status only selects the broad recovery path. + +## TUI states + +The TUI owns presentation state and maps it to engine actions as follows: + +| State | Engine interaction | Required presentation | +| -------------- | ------------------------ | ------------------------------------------------------------------------------------------------ | +| Discover | `describe` | Observed paths, Zsh availability, existing Zi evidence, uncertainties, and blockers | +| Choose | None | Zi only and Zi with annexes; preserve a detected legacy choice | +| Review | `plan` | Plan hash, checkout operation, exact paths, generated Zsh, diffs, warnings, and first-start work | +| Apply checkout | `apply --phase checkout` | Named operation, cancellation boundary, sanitized details, and result | +| Apply files | `apply --phase files` | Named operation, changed targets, receipt path, and result | +| Result | Optional new `plan` | Completed work, deferred first-start work, manual steps, and no-content-change verification | + +Going backward discards the old plan and creates a new one after choices change. A plan is never edited in place. Resizing and theme changes preserve the selected profile and current review position. + +## Acceptance matrix + +| Scenario | Expected engine result | Expected TUI behavior | +| ---------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Fresh home, Zi only | `loader` plan with missing checkout and new managed files | Review all paths, apply both phases, show receipt, then reopen with no content changes | +| Fresh home, annexes | `annex` plan with deferred first-start recipes | Explain deferred work before approval and repeat it on the result screen | +| Exact legacy loader block | Safe migration plan | Show removed legacy block and new managed block in the diff | +| Exact legacy `zunit` block | Compatibility migration retains `zunit` | Identify preserved legacy content; do not present `zunit` as a selectable recommendation | +| Unknown Zi integration | No actionable plan | Preserve files and offer engine-produced remediation or patch guidance | +| Symlinked `.zshrc` | Files phase refuses with patch guidance | Show manual action; never imply partial success | +| Target changes after review | Files phase reports `target-drift` | Return to Review and require a new plan | +| Checkout HEAD changes after review | Checkout phase reports `checkout-drift` | Preserve checkout and require a new plan | +| Plan content changes | Apply reports `plan-changed` | Refuse apply and discard the plan | +| Network failure during checkout | Checkout result is failed; files phase has not run | Show the failed operation and safe retry path | +| Checkout succeeds, files fail | Separate phase results expose partial application | Report checkout success and file failure without claiming rollback | +| No TTY or `--plain` | Same choices and plan identity as TUI | Render a linear review and require explicit apply input | +| 80x24 terminal | Same model and choices as wide layout | Use one pane with switchable preview sections | +| `NO_COLOR` set | Same content and navigation | Disable color while retaining readable focus markers | +| Interrupt before apply | No mutation | Restore the terminal and retain or discard the plan according to the user's choice | + +## Verification required for the milestone + +- Contract tests for every documented artifact path, restricted token, plan hash input, and exit status. +- Golden fixtures for fresh `loader`, fresh `annex`, retained legacy `zunit`, safe migration, ambiguous integration, symlink refusal, and drift failures. +- Proof that TUI and plain mode produce the same planner arguments and approve the same plan hash. +- Pseudo-terminal tests for navigation, resize, signals, terminal restoration, monochrome mode, and control-sequence sanitization. +- Disposable-home integration tests that apply both phases, inspect the receipt, start the generated configuration with the supported Zsh, and reopen with no content changes. +- Existing `sh tests/installers.sh` coverage on Linux and macOS, plus the current Windows installer checks. + +## Delivery sequence + +1. Record the ADR-0025 interface trigger on [issue 222](https://github.com/z-shell/src/issues/222). +2. Implement and test the versioned engine artifacts in `src` without adding TUI code. +3. Create the local Go package and implement Discover, Choose, and Review against fixtures and the engine contract. +4. Add checkout and files application with exact plan-hash approval and result handling. +5. Validate the full flow in disposable homes, then decide whether streaming events or additional capabilities have earned their maintenance cost. + +No push, pull request, release, or distribution bootstrap is part of this milestone unless separately authorized. diff --git a/public/checksum.txt b/public/checksum.txt index ed2a4e1..7e6e3df 100644 --- a/public/checksum.txt +++ b/public/checksum.txt @@ -1,6 +1,6 @@ 6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh e322f6aea1c7878bdf6d12032fbb83528f7bbccb4738748335373d0c8380cbe5 public/sh/install.sh -5fd4e53be0a80877afd504b6f707d998786c8ceb088b361c49eb5d4d401783eb public/sh/setup.sh +59307dc8e9aa43f35e137913cf41d8916acc6278a0e03ccb365a0ab01e003bae public/sh/setup.sh 08cc893ceb982fc99d17db1966c6c30790cc571e16e4f5392352d995f5252952 public/sh/sync-init.sh fff8d1c340fb1e87c76f80cac2224e28761ccc7e2b117839b7be6f5311a1ac11 public/setup/profiles.tsv c979e39748d1d86ace17a61ff2b1bf6e1224a43291c25bf7fad985d1e9e11af1 public/zsh/init.zsh diff --git a/public/sh/setup.sh b/public/sh/setup.sh index b653a14..b66d1fa 100755 --- a/public/sh/setup.sh +++ b/public/sh/setup.sh @@ -1,6 +1,9 @@ #!/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 +# The portable engine uses command predicates, captured probes, and exhaustive +# validated cases whose optional ShellCheck findings are tracked by tests. +# shellcheck disable=SC2249,SC2310,SC2312 set -eu @@ -14,19 +17,40 @@ ROOT="$( cd "${SCRIPT_DIR}/../.." 2>/dev/null && pwd )" || exit 1 +DIE_STATUS=1 +RESULT_ACTIVE=0 +RESULT_PUBLISHED=0 +RESULT_WORK="" +RESULT_DIR="" +RESULT_OPERATION="" +RESULT_ERROR_CODE="operation-failed" +RESULT_DETAIL="operation failed" +ACTIVE_LOCK="" + die() { - printf '%s\n' "${PROGRAM}: $*" >&2 - exit 1 + _die_detail="$*" + printf '%s\n' "${PROGRAM}: ${_die_detail}" >&2 + RESULT_DETAIL="${_die_detail}" + if [ "${RESULT_ACTIVE}" -eq 1 ] && [ "${RESULT_PUBLISHED}" -eq 0 ]; then + set +e + result_publish failed "${RESULT_ERROR_CODE}" "${RESULT_OPERATION}" "${RESULT_DETAIL}" + set -e + fi + exit "${DIE_STATUS}" } usage() { command cat <<'EOF' Usage: + setup.sh describe --output DIR [--zi-home DIR] [--zi-bin-dir NAME] + [--config-home DIR] [--zshrc FILE] [--profiles FILE] + [--skip-zshrc] 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] + [--result DIR] EOF } @@ -47,6 +71,10 @@ is_absolute_path() { esac } +path_exists() { + [ -e "$1" ] || [ -L "$1" ] +} + validate_text_path() { case "$2" in *" @@ -54,6 +82,63 @@ validate_text_path() { esac } +write_field() { + _field_root="$1" + _field_path="$2" + _field_value="$3" + command mkdir -p "${_field_root}/$(dirname "${_field_path}")" + printf '%s\n' "${_field_value}" >"${_field_root}/${_field_path}" +} + +add_fact() { + _fact_root="$1" + _fact_id="$2" + _fact_value="$3" + _fact_source="$4" + _fact_confidence="$5" + printf '%s\n' "${_fact_id}" >>"${_fact_root}/facts/order" + write_field "${_fact_root}" "facts/${_fact_id}/value" "${_fact_value}" + write_field "${_fact_root}" "facts/${_fact_id}/source" "${_fact_source}" + write_field "${_fact_root}" "facts/${_fact_id}/confidence" "${_fact_confidence}" +} + +add_profile() { + _profile_root="$1" + _profile_id="$2" + _profile_selectable="$3" + _profile_reason="$4" + _profile_title="$5" + printf '%s\n' "${_profile_id}" >>"${_profile_root}/profiles/order" + write_field "${_profile_root}" "profiles/${_profile_id}/selectable" "${_profile_selectable}" + write_field "${_profile_root}" "profiles/${_profile_id}/reason" "${_profile_reason}" + write_field "${_profile_root}" "profiles/${_profile_id}/title" "${_profile_title}" +} + +add_operation() { + _operation_root="$1" + _operation_id="$2" + _operation_phase="$3" + _operation_kind="$4" + _operation_summary="$5" + printf '%s\n' "${_operation_id}" >>"${_operation_root}/operations/order" + write_field "${_operation_root}" "operations/${_operation_id}/phase" "${_operation_phase}" + write_field "${_operation_root}" "operations/${_operation_id}/kind" "${_operation_kind}" + write_field "${_operation_root}" "operations/${_operation_id}/summary" "${_operation_summary}" + write_field "${_operation_root}" "operations/${_operation_id}/interruptible" no +} + +add_warning() { + _warning_root="$1" + _warning_id="$2" + _warning_severity="$3" + _warning_summary="$4" + _warning_remediation="$5" + printf '%s\n' "${_warning_id}" >>"${_warning_root}/warnings/order" + write_field "${_warning_root}" "warnings/${_warning_id}/severity" "${_warning_severity}" + write_field "${_warning_root}" "warnings/${_warning_id}/summary" "${_warning_summary}" + write_field "${_warning_root}" "warnings/${_warning_id}/remediation" "${_warning_remediation}" +} + validate_ref() { case "$1" in "" | -* | *..* | *[!A-Za-z0-9._/-]*) @@ -450,7 +535,210 @@ EOF } >"${_shell_output}" } +# Discovery deliberately uses command predicates and captured read-only probes; +# each failure is classified and handled by the surrounding branch. +describe_command() { + DIE_STATUS=2 + DESCRIBE_OUTPUT="" + DESCRIBE_ZI_HOME="${ZI_HOME-}" + DESCRIBE_BIN="${ZI_BIN_DIR_NAME:-bin}" + DESCRIBE_CONFIG_HOME="" + DESCRIBE_ZSHRC="" + DESCRIBE_PROFILES="${ROOT}/public/setup/profiles.tsv" + DESCRIBE_SKIP_ZSHRC=0 + + while [ "$#" -gt 0 ]; do + case "$1" in + --output) + [ "$#" -ge 2 ] || die '--output requires a directory' + DESCRIBE_OUTPUT="$2" + shift 2 + ;; + --zi-home) + [ "$#" -ge 2 ] || die '--zi-home requires a directory' + DESCRIBE_ZI_HOME="$2" + shift 2 + ;; + --zi-bin-dir) + [ "$#" -ge 2 ] || die '--zi-bin-dir requires a name' + DESCRIBE_BIN="$2" + shift 2 + ;; + --config-home) + [ "$#" -ge 2 ] || die '--config-home requires a directory' + DESCRIBE_CONFIG_HOME="$2" + shift 2 + ;; + --zshrc) + [ "$#" -ge 2 ] || die '--zshrc requires a file' + DESCRIBE_ZSHRC="$2" + shift 2 + ;; + --profiles) + [ "$#" -ge 2 ] || die '--profiles requires a file' + DESCRIBE_PROFILES="$2" + shift 2 + ;; + --skip-zshrc) + DESCRIBE_SKIP_ZSHRC=1 + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) die "unknown describe option $1" ;; + esac + done + + [ -n "${DESCRIBE_OUTPUT}" ] || die '--output is required' + if path_exists "${DESCRIBE_OUTPUT}"; then + die "describe output path already exists: ${DESCRIBE_OUTPUT}" + fi + case "${DESCRIBE_BIN}" in "" | . | .. | */*) die '--zi-bin-dir must be one directory name' ;; esac + validate_text_path '--output' "${DESCRIBE_OUTPUT}" + validate_text_path '--zi-bin-dir' "${DESCRIBE_BIN}" + if [ -z "${DESCRIBE_CONFIG_HOME}" ]; then + if is_absolute_path "${XDG_CONFIG_HOME-}"; then + DESCRIBE_CONFIG_HOME="${XDG_CONFIG_HOME}/zi" + else + DESCRIBE_CONFIG_HOME="${HOME}/.config/zi" + fi + fi + is_absolute_path "${DESCRIBE_CONFIG_HOME}" || die '--config-home must be absolute' + if [ -z "${DESCRIBE_ZSHRC}" ]; then + DESCRIBE_ZDOTDIR="${ZDOTDIR:-${HOME}}" + is_absolute_path "${DESCRIBE_ZDOTDIR}" || die 'ZDOTDIR must be absolute when set' + DESCRIBE_ZSHRC="${DESCRIBE_ZDOTDIR}/.zshrc" + fi + is_absolute_path "${DESCRIBE_ZSHRC}" || die '--zshrc must be absolute' + if [ -n "${DESCRIBE_ZI_HOME}" ]; then + is_absolute_path "${DESCRIBE_ZI_HOME}" || die '--zi-home must be absolute' + fi + validate_text_path '--config-home' "${DESCRIBE_CONFIG_HOME}" + validate_text_path '--zshrc' "${DESCRIBE_ZSHRC}" + validate_text_path '--zi-home' "${DESCRIBE_ZI_HOME}" + + DESCRIBE_PARENT="$(dirname "${DESCRIBE_OUTPUT}")" + [ -d "${DESCRIBE_PARENT}" ] || die "describe output parent does not exist: ${DESCRIBE_PARENT}" + [ -w "${DESCRIBE_PARENT}" ] || die "describe output parent is not writable: ${DESCRIBE_PARENT}" + [ -r "${DESCRIBE_PROFILES}" ] || { + DIE_STATUS=3 + die "cannot read profile table ${DESCRIBE_PROFILES}" + } + + DIE_STATUS=3 + DESCRIBE_WORK="$(mktemp -d "${DESCRIBE_PARENT}/.zi-setup-describe.XXXXXX")" || + die "cannot stage describe artifact in ${DESCRIBE_PARENT}" + trap 'rm -rf "${DESCRIBE_WORK:?}"' EXIT INT TERM + command mkdir -p "${DESCRIBE_WORK}/facts" "${DESCRIBE_WORK}/profiles" + : >"${DESCRIBE_WORK}/facts/order" + : >"${DESCRIBE_WORK}/profiles/order" + printf '%s\n' zi-setup-describe-v1 >"${DESCRIBE_WORK}/format" + + DESCRIBE_HOME_STATE=selected + if [ -z "${DESCRIBE_ZI_HOME}" ]; then + if is_absolute_path "${XDG_DATA_HOME-}"; then + DESCRIBE_DATA_HOME="${XDG_DATA_HOME}" + else + DESCRIBE_DATA_HOME="${HOME}/.local/share" + fi + DESCRIBE_LEGACY_HOME="${HOME}/.zi" + DESCRIBE_XDG_HOME="${DESCRIBE_DATA_HOME}/zi" + DESCRIBE_LEGACY_PRESENT=0 + DESCRIBE_XDG_PRESENT=0 + zi_home_has_installation "${DESCRIBE_LEGACY_HOME}" && DESCRIBE_LEGACY_PRESENT=1 + zi_home_has_installation "${DESCRIBE_XDG_HOME}" && DESCRIBE_XDG_PRESENT=1 + if [ "${DESCRIBE_LEGACY_PRESENT}" -eq 1 ] && [ "${DESCRIBE_XDG_PRESENT}" -eq 1 ]; then + if [ -f "${DESCRIBE_XDG_HOME}/bin/zi.zsh" ] && [ ! -f "${DESCRIBE_LEGACY_HOME}/bin/zi.zsh" ]; then + DESCRIBE_ZI_HOME="${DESCRIBE_XDG_HOME}" + else + DESCRIBE_HOME_STATE=ambiguous + DESCRIBE_ZI_HOME=unknown + fi + elif [ "${DESCRIBE_LEGACY_PRESENT}" -eq 1 ]; then + DESCRIBE_ZI_HOME="${DESCRIBE_LEGACY_HOME}" + else + DESCRIBE_ZI_HOME="${DESCRIBE_XDG_HOME}" + fi + fi + if [ "${DESCRIBE_HOME_STATE}" = selected ]; then + DESCRIBE_CHECKOUT="${DESCRIBE_ZI_HOME}/${DESCRIBE_BIN}" + else + DESCRIBE_CHECKOUT="unknown" + fi + + if command -v git >/dev/null 2>&1; then DESCRIBE_GIT="available"; else DESCRIBE_GIT="missing"; fi + if command -v zsh >/dev/null 2>&1; then DESCRIBE_ZSH="available"; else DESCRIBE_ZSH="missing"; fi + if [ -t 0 ] && [ -t 1 ]; then DESCRIBE_TTY="yes"; else DESCRIBE_TTY="no"; fi + if [ "${DESCRIBE_SKIP_ZSHRC}" -eq 1 ]; then + DESCRIBE_ZSHRC_STATE="skipped" + elif [ -L "${DESCRIBE_ZSHRC}" ]; then + DESCRIBE_ZSHRC_STATE="symlink" + elif [ -f "${DESCRIBE_ZSHRC}" ]; then + DESCRIBE_ZSHRC_STATE="file" + elif [ -e "${DESCRIBE_ZSHRC}" ]; then + DESCRIBE_ZSHRC_STATE="other" + else + DESCRIBE_ZSHRC_STATE="missing" + fi + + DESCRIBE_EXISTING_PROFILE="$(receipt_value profile "${DESCRIBE_CONFIG_HOME}/setup/receipt" 2>/dev/null || true)" + case "${DESCRIBE_EXISTING_PROFILE}" in loader | annex | zunit) ;; *) DESCRIBE_EXISTING_PROFILE=none ;; esac + DESCRIBE_LEGACY_ZUNIT=0 + if [ "${DESCRIBE_SKIP_ZSHRC}" -eq 0 ] && [ -f "${DESCRIBE_ZSHRC}" ]; then + DESCRIBE_ZUNIT_TEMPLATE="${DESCRIBE_WORK}/legacy-zunit" + DESCRIBE_STRIPPED="${DESCRIBE_WORK}/zshrc-stripped" + DESCRIBE_MATCH_RESULT="${DESCRIBE_WORK}/zunit-matches" + write_legacy_zunit >"${DESCRIBE_ZUNIT_TEMPLATE}" + strip_exact_block "${DESCRIBE_ZSHRC}" "${DESCRIBE_ZUNIT_TEMPLATE}" "${DESCRIBE_STRIPPED}" "${DESCRIBE_MATCH_RESULT}" + [ "$(cat "${DESCRIBE_MATCH_RESULT}")" -eq 0 ] || DESCRIBE_LEGACY_ZUNIT=1 + fi + if [ "${DESCRIBE_EXISTING_PROFILE}" = zunit ]; then DESCRIBE_LEGACY_ZUNIT=1; fi + + add_fact "${DESCRIBE_WORK}" config-home "${DESCRIBE_CONFIG_HOME}" inferred certain + add_fact "${DESCRIBE_WORK}" zi-home "${DESCRIBE_ZI_HOME}" inferred "$(if [ "${DESCRIBE_HOME_STATE}" = selected ]; then printf certain; else printf unknown; fi)" + add_fact "${DESCRIBE_WORK}" checkout-path "${DESCRIBE_CHECKOUT}" inferred "$(if [ "${DESCRIBE_HOME_STATE}" = selected ]; then printf certain; else printf unknown; fi)" + add_fact "${DESCRIBE_WORK}" zi-home-state "${DESCRIBE_HOME_STATE}" observed certain + add_fact "${DESCRIBE_WORK}" zshrc-path "${DESCRIBE_ZSHRC}" inferred certain + add_fact "${DESCRIBE_WORK}" zshrc-state "${DESCRIBE_ZSHRC_STATE}" observed certain + add_fact "${DESCRIBE_WORK}" git "${DESCRIBE_GIT}" observed certain + add_fact "${DESCRIBE_WORK}" zsh "${DESCRIBE_ZSH}" observed certain + add_fact "${DESCRIBE_WORK}" tty "${DESCRIBE_TTY}" observed certain + add_fact "${DESCRIBE_WORK}" existing-profile "${DESCRIBE_EXISTING_PROFILE}" observed certain + + DESCRIBE_SELECTABLE=yes + DESCRIBE_REASON="Ready to plan" + if [ "${DESCRIBE_HOME_STATE}" != selected ]; then + DESCRIBE_SELECTABLE=no + DESCRIBE_REASON="Both legacy and XDG Zi homes exist; choose --zi-home" + elif [ "${DESCRIBE_GIT}" != available ]; then + DESCRIBE_SELECTABLE=no + DESCRIBE_REASON="git is required to install or update Zi" + elif [ "${DESCRIBE_ZSH}" != available ]; then + DESCRIBE_SELECTABLE=no + DESCRIBE_REASON="zsh is required to use Zi" + fi + add_profile "${DESCRIBE_WORK}" loader "${DESCRIBE_SELECTABLE}" "${DESCRIBE_REASON}" "Zi only" + add_profile "${DESCRIBE_WORK}" annex "${DESCRIBE_SELECTABLE}" "${DESCRIBE_REASON}" "Zi with annexes" + if [ "${DESCRIBE_LEGACY_ZUNIT}" -eq 1 ]; then + add_profile "${DESCRIBE_WORK}" zunit no "Preserved only while migrating existing setup content" "Legacy zunit compatibility" + fi + + command rm -f "${DESCRIBE_WORK}/legacy-zunit" "${DESCRIBE_WORK}/zshrc-stripped" \ + "${DESCRIBE_WORK}/zunit-matches" + if path_exists "${DESCRIBE_OUTPUT}"; then + die "describe output path appeared while discovering: ${DESCRIBE_OUTPUT}" + fi + command mv "${DESCRIBE_WORK}" "${DESCRIBE_OUTPUT}" || die "cannot publish describe artifact ${DESCRIBE_OUTPUT}" + DESCRIBE_WORK="" + trap - EXIT INT TERM + printf '%s\n' "Describe artifact: ${DESCRIBE_OUTPUT}" + if [ "${DESCRIBE_SELECTABLE}" = no ]; then exit 3; fi +} + plan_command() { + DIE_STATUS=2 PLAN_DIR="" PROFILE=loader REF=main @@ -553,7 +841,14 @@ plan_command() { is_absolute_path "${ZSHRC_PATH}" || die '--zshrc must be absolute' validate_text_path '--config-home' "${CONFIG_HOME}" validate_text_path '--zshrc' "${ZSHRC_PATH}" + if path_exists "${PLAN_DIR}"; then + die "plan path already exists: ${PLAN_DIR}" + fi + PLAN_PARENT="$(dirname "${PLAN_DIR}")" + [ -d "${PLAN_PARENT}" ] || die "plan parent does not exist: ${PLAN_PARENT}" + [ -w "${PLAN_PARENT}" ] || die "plan parent is not writable: ${PLAN_PARENT}" + DIE_STATUS=3 LEGACY_DIRECT_FOUND=0 LEGACY_DIRECT_HOME="" LEGACY_DIRECT_BIN="" @@ -580,12 +875,13 @@ plan_command() { 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 + PLAN_WORK="$(mktemp -d "${PLAN_PARENT}/.zi-setup-plan.XXXXXX")" || die "cannot stage plan in ${PLAN_PARENT}" trap 'rm -rf "${PLAN_WORK:?}"' EXIT INT TERM - command mkdir -p "${PLAN_WORK}/artifact/checkout" "${PLAN_WORK}/artifact/targets" + command mkdir -p "${PLAN_WORK}/artifact/checkout" "${PLAN_WORK}/artifact/targets" \ + "${PLAN_WORK}/artifact/operations" "${PLAN_WORK}/artifact/warnings" : >"${PLAN_WORK}/artifact/targets/order" + : >"${PLAN_WORK}/artifact/operations/order" + : >"${PLAN_WORK}/artifact/warnings/order" RECEIPT_PATH="${CONFIG_HOME}/setup/receipt" INIT_CONTENT="${PLAN_WORK}/init.zsh" @@ -714,6 +1010,35 @@ EOF fi printf '%s\n' "${REF}" >"${PLAN_WORK}/artifact/checkout/requested-ref" + case "$(cat "${PLAN_WORK}/artifact/checkout/kind")" in + missing) PLAN_CHECKOUT_KIND=clone ;; + existing) PLAN_CHECKOUT_KIND=fast-forward ;; + *) die 'invalid planned checkout kind' ;; + esac + add_operation "${PLAN_WORK}/artifact" checkout-sync checkout "${PLAN_CHECKOUT_KIND}" \ + "Synchronize Zi checkout at ${CHECKOUT_PATH}" + add_operation "${PLAN_WORK}/artifact" write-files files write-files \ + "Write guided setup files under ${CONFIG_HOME}" + case "${EFFECTIVE_PROFILE}" in + annex) + add_warning "${PLAN_WORK}/artifact" deferred-first-start info \ + "Annex recipes are installed on the first shell start" \ + "Start Zsh after apply with network access available" + ;; + zunit) + add_warning "${PLAN_WORK}/artifact" legacy-zunit warning \ + "The legacy zunit recipe is retained for compatibility" \ + "Choose loader or annex in a later plan to remove the legacy recipe" + ;; + loader) ;; + *) die "invalid effective profile ${EFFECTIVE_PROFILE}" ;; + esac + if [ "${SKIP_ZSHRC}" -eq 1 ]; then + add_warning "${PLAN_WORK}/artifact" zshrc-skipped warning \ + "The plan does not update .zshrc" \ + "Source ${CONFIG_HOME}/setup.zsh from the intended Zsh startup file" + fi + 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}" @@ -723,9 +1048,15 @@ EOF printf '%s\n' "$(sha256_file "${MANAGED_BLOCK}")" >"${PLAN_WORK}/artifact/targets/zshrc/block-hash" fi + if path_exists "${PLAN_DIR}"; then + die "plan path appeared while planning: ${PLAN_DIR}" + fi + PLAN_ID="$(artifact_hash "${PLAN_WORK}/artifact")" + printf '%s\n' "${PLAN_ID}" >"${PLAN_WORK}/artifact/plan.id" + if path_exists "${PLAN_DIR}"; then + die "plan path appeared while planning: ${PLAN_DIR}" + 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 @@ -744,21 +1075,135 @@ nearest_existing_parent() { printf '%s\n' "${_parent_path}" } +result_init() { + _result_path="$1" + _result_plan="$2" + _result_phase="$3" + if path_exists "${_result_path}"; then + die "result path already exists: ${_result_path}" + fi + _result_parent="$(dirname "${_result_path}")" + [ -d "${_result_parent}" ] || die "result parent does not exist: ${_result_parent}" + [ -w "${_result_parent}" ] || die "result parent is not writable: ${_result_parent}" + RESULT_WORK="$(mktemp -d "${_result_parent}/.zi-setup-result.XXXXXX")" || + die "cannot stage result artifact in ${_result_parent}" + RESULT_DIR="${_result_path}" + RESULT_PHASE="${_result_phase}" + RESULT_PLAN_ID="$(cat "${_result_plan}/plan.id" 2>/dev/null || printf unknown)" + command mkdir -p "${RESULT_WORK}/operations" + printf '%s\n' zi-setup-result-v1 >"${RESULT_WORK}/format" + printf '%s\n' "${RESULT_PLAN_ID}" >"${RESULT_WORK}/plan.id" + printf '%s\n' "${RESULT_PHASE}" >"${RESULT_WORK}/phase" + : >"${RESULT_WORK}/operations/order" + RESULT_ACTIVE=1 + RESULT_PUBLISHED=0 + trap 'apply_exit "$?"' EXIT + trap 'apply_cancel' INT TERM HUP +} + +result_publish() { + _result_status="$1" + _result_code="$2" + _result_operation="$3" + _result_detail="$4" + [ "${RESULT_ACTIVE}" -eq 1 ] || return 0 + [ "${RESULT_PUBLISHED}" -eq 0 ] || return 0 + printf '%s\n' "${_result_status}" >"${RESULT_WORK}/status" || return 1 + if [ -n "${_result_operation}" ]; then + printf '%s\n' "${_result_operation}" >"${RESULT_WORK}/operations/order" || return 1 + case "${_result_status}" in + succeeded) _result_operation_status=succeeded ;; + cancelled) _result_operation_status=cancelled ;; + *) _result_operation_status=failed ;; + esac + write_field "${RESULT_WORK}" "operations/${_result_operation}/status" "${_result_operation_status}" + _result_write_status="$?" + [ "${_result_write_status}" -eq 0 ] || return 1 + write_field "${RESULT_WORK}" "operations/${_result_operation}/detail" "${_result_detail}" + _result_write_status="$?" + [ "${_result_write_status}" -eq 0 ] || return 1 + fi + if [ "${_result_status}" != succeeded ]; then + write_field "${RESULT_WORK}" error/code "${_result_code}" + _result_write_status="$?" + [ "${_result_write_status}" -eq 0 ] || return 1 + if [ -n "${_result_operation}" ]; then + write_field "${RESULT_WORK}" error/operation "${_result_operation}" + _result_write_status="$?" + [ "${_result_write_status}" -eq 0 ] || return 1 + fi + write_field "${RESULT_WORK}" error/detail "${_result_detail}" + _result_write_status="$?" + [ "${_result_write_status}" -eq 0 ] || return 1 + elif [ "${RESULT_PHASE}" = files ]; then + _result_receipt="$(plan_value receipt_path "${APPLY_PLAN}")" + write_field "${RESULT_WORK}" receipt/path "${_result_receipt}" + _result_write_status="$?" + [ "${_result_write_status}" -eq 0 ] || return 1 + fi + if path_exists "${RESULT_DIR}"; then return 1; fi + command mv "${RESULT_WORK}" "${RESULT_DIR}" || return 1 + RESULT_WORK="" + RESULT_PUBLISHED=1 +} + +apply_cleanup() { + if [ -n "${ACTIVE_LOCK}" ]; then + command rmdir "${ACTIVE_LOCK}" 2>/dev/null || true + ACTIVE_LOCK="" + fi + if [ -n "${RESULT_WORK}" ] && [ -d "${RESULT_WORK}" ]; then + command rm -rf "${RESULT_WORK}" + RESULT_WORK="" + fi +} + +apply_exit() { + _apply_exit_status="$1" + trap - EXIT INT TERM HUP + if [ "${RESULT_ACTIVE}" -eq 1 ] && [ "${RESULT_PUBLISHED}" -eq 0 ]; then + if [ "${_apply_exit_status}" -eq 0 ]; then _apply_exit_status=5; fi + set +e + result_publish failed "${RESULT_ERROR_CODE}" "${RESULT_OPERATION}" "${RESULT_DETAIL}" + set -e + fi + apply_cleanup + exit "${_apply_exit_status}" +} + +apply_cancel() { + trap - INT TERM HUP + RESULT_ERROR_CODE=cancelled + RESULT_DETAIL="apply was cancelled" + set +e + result_publish cancelled "${RESULT_ERROR_CODE}" "${RESULT_OPERATION}" "${RESULT_DETAIL}" + set -e + apply_cleanup + trap - EXIT + exit 6 +} + acquire_lock() { _lock_path="$1" command mkdir -p "$(dirname "${_lock_path}")" if ! command mkdir "${_lock_path}" 2>/dev/null; then + DIE_STATUS=4 + RESULT_ERROR_CODE=lock-held + RESULT_OPERATION="" 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" + DIE_STATUS=2 + RESULT_ERROR_CODE=unsupported-version [ -d "${_validate_plan}" ] || die "plan directory not found: ${_validate_plan}" [ "$(plan_value format "${_validate_plan}")" = zi-setup-plan-v1 ] || die 'unsupported plan format' + DIE_STATUS=4 + RESULT_ERROR_CODE=plan-changed _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' @@ -778,15 +1223,20 @@ apply_checkout() { _checkout_origin="$(cat "${_checkout_plan}/checkout/origin")" _checkout_parent="$(dirname "${_checkout_path}")" _checkout_existing_parent="$(nearest_existing_parent "${_checkout_parent}")" + DIE_STATUS=4 + RESULT_ERROR_CODE=checkout-drift [ -w "${_checkout_existing_parent}" ] || die "checkout parent is not writable: ${_checkout_existing_parent}" acquire_lock "${_checkout_path}.zi-setup.lock" + RESULT_OPERATION=checkout-sync case "${_checkout_kind}" in missing) - [ ! -e "${_checkout_path}" ] || die "checkout appeared after planning: ${_checkout_path}" + ! path_exists "${_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}" + ! path_exists "${_checkout_tmp}" || die "temporary checkout path exists: ${_checkout_tmp}" + DIE_STATUS=5 + RESULT_ERROR_CODE=network-failed 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}" @@ -799,7 +1249,10 @@ apply_checkout() { [ "$(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' + DIE_STATUS=5 + RESULT_ERROR_CODE=network-failed command git -C "${_checkout_path}" fetch origin "refs/heads/${_checkout_ref}" || die 'checkout fetch failed' + RESULT_ERROR_CODE=checkout-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' @@ -861,13 +1314,18 @@ apply_files() { _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}")")" + DIE_STATUS=4 + RESULT_ERROR_CODE=target-drift [ -w "${_files_config_parent}" ] || die "configuration parent is not writable: ${_files_config_parent}" acquire_lock "${_files_config}.zi-setup.lock" + RESULT_OPERATION=write-files while IFS= read -r _files_id; do validate_target_precondition "${_files_plan}" "${_files_id}" done <"${_files_plan}/targets/order" + DIE_STATUS=5 + RESULT_ERROR_CODE=write-failed while IFS= read -r _files_id; do install_target "${_files_plan}" "${_files_id}" done <"${_files_plan}/targets/order" @@ -896,9 +1354,11 @@ apply_files() { } apply_command() { + DIE_STATUS=2 APPLY_PLAN="" APPLY_PHASE="" APPLY_EXPECT="" + APPLY_RESULT="" while [ "$#" -gt 0 ]; do case "$1" in --plan) @@ -916,6 +1376,11 @@ apply_command() { APPLY_EXPECT="$2" shift 2 ;; + --result) + [ "$#" -ge 2 ] || die '--result requires a directory' + APPLY_RESULT="$2" + shift 2 + ;; --help | -h) usage exit 0 @@ -925,11 +1390,35 @@ apply_command() { done [ -n "${APPLY_PLAN}" ] || die '--plan is required' case "${APPLY_PHASE}" in checkout | files) ;; *) die '--phase must be checkout or files' ;; esac + if [ -n "${APPLY_RESULT}" ]; then + validate_text_path '--result' "${APPLY_RESULT}" + result_init "${APPLY_RESULT}" "${APPLY_PLAN}" "${APPLY_PHASE}" + else + trap 'apply_exit "$?"' EXIT + trap 'apply_cancel' INT TERM HUP + fi APPLY_PLAN_ID="$(validate_plan "${APPLY_PLAN}" "${APPLY_EXPECT}")" + DIE_STATUS=5 case "${APPLY_PHASE}" in - checkout) apply_checkout "${APPLY_PLAN}" ;; - files) apply_files "${APPLY_PLAN}" "${APPLY_PLAN_ID}" ;; + checkout) + RESULT_OPERATION=checkout-sync + apply_checkout "${APPLY_PLAN}" + ;; + files) + RESULT_OPERATION=write-files + apply_files "${APPLY_PLAN}" "${APPLY_PLAN_ID}" + ;; esac + RESULT_DETAIL="${APPLY_PHASE} phase completed" + set +e + result_publish succeeded "" "${RESULT_OPERATION}" "${RESULT_DETAIL}" + RESULT_PUBLISH_STATUS="$?" + set -e + if [ "${RESULT_PUBLISH_STATUS}" -ne 0 ]; then + DIE_STATUS=5 + RESULT_ERROR_CODE=write-failed + die "cannot publish result artifact ${APPLY_RESULT}" + fi } [ "$#" -gt 0 ] || { @@ -939,6 +1428,7 @@ apply_command() { COMMAND="$1" shift case "${COMMAND}" in +describe) describe_command "$@" ;; plan) plan_command "$@" ;; apply) apply_command "$@" ;; --help | -h | help) usage ;; diff --git a/tests/installers.sh b/tests/installers.sh index e76f780..2f9caf1 100755 --- a/tests/installers.sh +++ b/tests/installers.sh @@ -1,6 +1,9 @@ #!/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 +# Fixture assertions intentionally combine captured output with surrounding +# predicates so each failed read fails the same test expression. +# shellcheck disable=SC2310,SC2312 set -eu @@ -435,6 +438,10 @@ case "${cmd}" in dest="${arg}" done [ -n "${dest}" ] || { printf '%s\n' "installers.sh git test double: missing clone destination" >&2; exit 64; } + if [ "${ZI_SRC_TEST_FAKE_CLONE_FAIL:-0}" -ne 0 ]; then + printf '%s\n' "fatal: simulated clone failure" >&2 + exit 128 + fi mkdir -p "${dest}/.git" "${dest}/lib" printf '%s\n' '# fake zi.zsh' > "${dest}/zi.zsh" printf '%s\n' '# fake _zi completion' > "${dest}/lib/_zi" @@ -999,9 +1006,190 @@ test_zshrc_uses_short_entrypoint() { sh "${ROOT}/public/sh/install.sh" >/dev/null contains "${home2}/.config/zi/setup/pre.zsh" "ZI[HOME_DIR]='${sibling}'" + # shellcheck disable=SC2016 pass 'a sibling of $HOME remains an exact serialized path' } +test_setup_describe_contract() { + describe_home="${TMP_ROOT}/describe-home" + describe_config="${TMP_ROOT}/describe-config" + describe_data="${TMP_ROOT}/describe-data" + describe_output="${TMP_ROOT}/describe-output" + command mkdir -p "${describe_home}" + printf '%s\n' '# existing startup content' >"${describe_home}/.zshrc" + describe_before="$(sha256_file "${describe_home}/.zshrc")" + + HOME="${describe_home}" \ + ZDOTDIR="${describe_home}" \ + XDG_CONFIG_HOME="${describe_config}" \ + XDG_DATA_HOME="${describe_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" describe --output "${describe_output}" >/dev/null + + [ "$(cat "${describe_output}/format")" = zi-setup-describe-v1 ] || fail 'describe format is not versioned' + [ "$(cat "${describe_output}/facts/zshrc-state/value")" = file ] || fail 'describe did not observe .zshrc' + [ "$(cat "${describe_output}/facts/git/value")" = available ] || fail 'describe did not observe git' + [ "$(cat "${describe_output}/facts/zsh/value")" = available ] || fail 'describe did not observe zsh' + [ "$(cat "${describe_output}/profiles/loader/selectable")" = yes ] || fail 'loader is not selectable in a fresh home' + [ "$(cat "${describe_output}/profiles/annex/selectable")" = yes ] || fail 'annex is not selectable in a fresh home' + [ "$(sed -n '1p' "${describe_output}/profiles/order")" = loader ] || fail 'loader is not the first described profile' + [ "$(sed -n '2p' "${describe_output}/profiles/order")" = annex ] || fail 'annex is not the second described profile' + [ "$(wc -l <"${describe_output}/profiles/order" | tr -d ' ')" -eq 2 ] || fail 'fresh describe exposed an extra profile' + [ "$(sha256_file "${describe_home}/.zshrc")" = "${describe_before}" ] || fail 'describe changed .zshrc' + + dangling_output="${TMP_ROOT}/describe-dangling-output" + command ln -s "${TMP_ROOT}/describe-missing-target" "${dangling_output}" + set +e + HOME="${describe_home}" PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" describe --output "${dangling_output}" >/dev/null 2>&1 + dangling_status="$?" + set -e + [ "${dangling_status}" -eq 2 ] || fail "dangling output path exited ${dangling_status}, expected 2" + [ -L "${dangling_output}" ] || fail 'describe replaced a dangling output symlink' + + zunit_home="${TMP_ROOT}/describe-zunit-home" + zunit_output="${TMP_ROOT}/describe-zunit-output" + command mkdir -p "${zunit_home}" + command cat >"${zunit_home}/.zshrc" <<'EOF' +zi light-mode for \ + z-shell/z-a-meta-plugins \ + @annexes @zunit +EOF + HOME="${zunit_home}" \ + ZDOTDIR="${zunit_home}" \ + XDG_CONFIG_HOME="${TMP_ROOT}/describe-zunit-config" \ + XDG_DATA_HOME="${TMP_ROOT}/describe-zunit-data" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" describe --output "${zunit_output}" >/dev/null + contains "${zunit_output}/profiles/order" zunit + [ "$(cat "${zunit_output}/profiles/zunit/selectable")" = no ] || fail 'legacy zunit became selectable' + + ambiguous_home="${TMP_ROOT}/describe-ambiguous-home" + ambiguous_data="${TMP_ROOT}/describe-ambiguous-data" + ambiguous_output="${TMP_ROOT}/describe-ambiguous-output" + command mkdir -p "${ambiguous_home}/.zi/plugins" "${ambiguous_data}/zi/plugins" + set +e + HOME="${ambiguous_home}" \ + XDG_CONFIG_HOME="${TMP_ROOT}/describe-ambiguous-config" \ + XDG_DATA_HOME="${ambiguous_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" describe --output "${ambiguous_output}" >/dev/null + describe_status="$?" + set -e + [ "${describe_status}" -eq 3 ] || fail "ambiguous discovery exited ${describe_status}, expected 3" + [ "$(cat "${ambiguous_output}/facts/zi-home-state/value")" = ambiguous ] || fail 'ambiguous Zi homes were not represented' + [ "$(cat "${ambiguous_output}/profiles/loader/selectable")" = no ] || fail 'ambiguous Zi homes produced an actionable profile' + pass 'setup describe publishes bounded discovery and compatibility facts' +} + +test_setup_plan_interface_metadata() { + metadata_home="${TMP_ROOT}/metadata-home" + metadata_config="${TMP_ROOT}/metadata-config" + metadata_data="${TMP_ROOT}/metadata-data" + metadata_plan_one="${TMP_ROOT}/metadata-plan-one" + metadata_plan_two="${TMP_ROOT}/metadata-plan-two" + metadata_result="${TMP_ROOT}/metadata-result" + command mkdir -p "${metadata_home}" + + for metadata_plan in "${metadata_plan_one}" "${metadata_plan_two}"; do + HOME="${metadata_home}" \ + XDG_CONFIG_HOME="${metadata_config}" \ + XDG_DATA_HOME="${metadata_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${metadata_plan}" --profile annex --skip-zshrc >/dev/null + done + + [ "$(cat "${metadata_plan_one}/operations/order")" = "$(printf 'checkout-sync\nwrite-files')" ] || fail 'plan operation order is incomplete' + [ "$(cat "${metadata_plan_one}/operations/checkout-sync/phase")" = checkout ] || fail 'checkout operation phase is invalid' + [ "$(cat "${metadata_plan_one}/operations/write-files/phase")" = files ] || fail 'file operation phase is invalid' + [ "$(cat "${metadata_plan_one}/warnings/deferred-first-start/severity")" = info ] || fail 'annex warning severity is invalid' + [ "$(cat "${metadata_plan_one}/warnings/zshrc-skipped/severity")" = warning ] || fail 'skip warning severity is invalid' + [ "$(cat "${metadata_plan_one}/plan.id")" = "$(cat "${metadata_plan_two}/plan.id")" ] || fail 'identical filesystem inputs produced different plan identities' + + printf '%s\n' 'tampered summary' >"${metadata_plan_one}/operations/write-files/summary" + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${metadata_plan_one}" --phase files --result "${metadata_result}" >/dev/null 2>&1 + metadata_status="$?" + set -e + [ "${metadata_status}" -eq 4 ] || fail "tampered operation metadata exited ${metadata_status}, expected 4" + [ "$(cat "${metadata_result}/error/code")" = plan-changed ] || fail 'tampered operation metadata did not report plan-changed' + [ ! -e "${metadata_result}/error/operation" ] || fail 'global plan failure named an operation' + pass 'plan metadata is deterministic and covered by the reviewed hash' +} + +test_setup_apply_result_contract() { + result_home="${TMP_ROOT}/result-home" + result_config="${TMP_ROOT}/result-config" + result_data="${TMP_ROOT}/result-data" + result_plan="${TMP_ROOT}/result-plan" + result_output="${TMP_ROOT}/result-output" + command mkdir -p "${result_home}" + HOME="${result_home}" \ + XDG_CONFIG_HOME="${result_config}" \ + XDG_DATA_HOME="${result_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${result_plan}" --skip-zshrc >/dev/null + sh "${ROOT}/public/sh/setup.sh" apply --plan "${result_plan}" --phase files \ + --expect "$(cat "${result_plan}/plan.id")" --result "${result_output}" >/dev/null + [ "$(cat "${result_output}/format")" = zi-setup-result-v1 ] || fail 'apply result format is not versioned' + [ "$(cat "${result_output}/status")" = succeeded ] || fail 'successful files result is not succeeded' + [ "$(cat "${result_output}/operations/write-files/status")" = succeeded ] || fail 'files operation is not succeeded' + [ "$(cat "${result_output}/receipt/path")" = "${result_config}/zi/setup/receipt" ] || fail 'files result omitted the receipt path' + + drift_home="${TMP_ROOT}/result-drift-home" + drift_config="${TMP_ROOT}/result-drift-config" + drift_data="${TMP_ROOT}/result-drift-data" + drift_plan="${TMP_ROOT}/result-drift-plan" + drift_output="${TMP_ROOT}/result-drift-output" + 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' drift >"${drift_config}/zi/init.zsh" + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${drift_plan}" --phase files --result "${drift_output}" >/dev/null 2>&1 + drift_result_status="$?" + set -e + [ "${drift_result_status}" -eq 4 ] || fail "target drift exited ${drift_result_status}, expected 4" + [ "$(cat "${drift_output}/error/code")" = target-drift ] || fail 'target drift result has the wrong code' + [ "$(cat "${drift_output}/error/operation")" = write-files ] || fail 'target drift result omitted its operation' + + lock_home="${TMP_ROOT}/result-lock-home" + lock_config="${TMP_ROOT}/result-lock-config" + lock_data="${TMP_ROOT}/result-lock-data" + lock_plan="${TMP_ROOT}/result-lock-plan" + lock_output="${TMP_ROOT}/result-lock-output" + command mkdir -p "${lock_home}" + HOME="${lock_home}" XDG_CONFIG_HOME="${lock_config}" XDG_DATA_HOME="${lock_data}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${lock_plan}" --skip-zshrc >/dev/null + command mkdir -p "${lock_config}/zi.zi-setup.lock" + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${lock_plan}" --phase files --result "${lock_output}" >/dev/null 2>&1 + lock_result_status="$?" + set -e + [ "${lock_result_status}" -eq 4 ] || fail "held lock exited ${lock_result_status}, expected 4" + [ "$(cat "${lock_output}/error/code")" = lock-held ] || fail 'held lock result has the wrong code' + [ ! -e "${lock_output}/error/operation" ] || fail 'held lock named an operation that did not begin' + + network_home="${TMP_ROOT}/result-network-home" + network_config="${TMP_ROOT}/result-network-config" + network_data="${TMP_ROOT}/result-network-data" + network_plan="${TMP_ROOT}/result-network-plan" + network_output="${TMP_ROOT}/result-network-output" + command mkdir -p "${network_home}" + HOME="${network_home}" XDG_CONFIG_HOME="${network_config}" XDG_DATA_HOME="${network_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${network_plan}" --skip-zshrc >/dev/null + set +e + ZI_SRC_TEST_FAKE_CLONE_FAIL=1 PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" apply --plan "${network_plan}" --phase checkout --result "${network_output}" >/dev/null 2>&1 + network_result_status="$?" + set -e + [ "${network_result_status}" -eq 5 ] || fail "network failure exited ${network_result_status}, expected 5" + [ "$(cat "${network_output}/error/code")" = network-failed ] || fail 'network failure result has the wrong code' + [ "$(cat "${network_output}/error/operation")" = checkout-sync ] || fail 'network failure result omitted its operation' + pass 'apply publishes stable success and failure result artifacts' +} + test_setup_plan_tamper_is_rejected() { tamper_home="${TMP_ROOT}/tamper-home" tamper_config="${TMP_ROOT}/tamper-config" @@ -1271,6 +1459,7 @@ EOF 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 >>>' + # shellcheck disable=SC2016 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 @@ -1541,6 +1730,9 @@ test_skip_leaves_annex_out test_branch_option_rejects_refspec test_source_ref_rejects_refspec test_zshrc_uses_short_entrypoint +test_setup_describe_contract +test_setup_plan_interface_metadata +test_setup_apply_result_contract test_setup_plan_tamper_is_rejected test_setup_target_drift_is_transactional test_setup_symlinked_zshrc_is_refused From 70944a8f324d772a3a01f5f9f66b57dd01249949 Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:14:12 +0100 Subject: [PATCH 2/2] chore(installer): pin published skill contract --- .github/skills/zi-install/SKILL.md | 47 +++++++++++++++------------- contracts/installer-contract-v1.json | 2 +- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/.github/skills/zi-install/SKILL.md b/.github/skills/zi-install/SKILL.md index 0d5203e..25ad850 100644 --- a/.github/skills/zi-install/SKILL.md +++ b/.github/skills/zi-install/SKILL.md @@ -2,10 +2,10 @@ description: Install or update the Zi plugin manager on a user's machine on their behalf, non-interactively, through the official installer and its flags, then verify the result. Never write .zshrc or the Zi configuration home directly. metadata: github-path: .github/skills/zi-install - github-pinned: c26699d42c1bb9152fc2a4f92d1872fec2430166 - github-ref: c26699d42c1bb9152fc2a4f92d1872fec2430166 + github-pinned: ce74af22db3af827eed9558596275cdf3fb07505 + github-ref: ce74af22db3af827eed9558596275cdf3fb07505 github-repo: https://github.com/z-shell/.github - github-tree-sha: 698e0845b8cfdd546f24d96cef706febaf3caec4 + github-tree-sha: c0115d60db843d3e53af79fb6a56106736231bc0 name: zi-install --- @@ -15,30 +15,17 @@ Drive the official installer; never reproduce what it does. Do not write `.zshrc Canonical long-form user guidance lives in the [Z-Shell Wiki: Installation](https://wiki.zshell.dev/docs/getting_started/installation). -Machine clients use the versioned `setup.sh describe`, `plan`, and `apply` -artifacts documented in the source repository. Consume those artifact fields -and exit statuses directly; do not parse human-readable installer output to -make decisions. The direct agent-driven flow below continues to use -`install.sh` as its entrypoint. - ## Choose the profile -The installer entrypoint defaults to the `loader` profile. Recognized profiles: +The installer entrypoint defaults to the `loader` profile. Profiles offered for a new installation: | Profile | Command suffix | Effect on `.zshrc` | | ------------ | -------------- | -------------------------------------------------------------- | | Loader | `-a loader` | Adds the short managed block sourcing `setup.zsh` (default) | | Annex | `-a annex` | Adds the short managed block with recommended annexes deferred | -| ZUnit (legacy) | `-a zunit` | Preserves an existing ZUnit setup during migration | | Install only | `-i skip` | No `.zshrc` change; the user integrates Zi themselves | -Offer Loader or Annex for a new setup. Keep ZUnit only when updating an -installation that already uses it; do not recommend it as a new profile. Use -`-i skip` when the user manages their dotfiles elsewhere; then hand them the -block from the [installation page](https://wiki.zshell.dev/docs/getting_started/installation) -instead of editing anything. `-b ` selects a Zi branch or tag (defaults to -`main`). Note that the direct profile (`-a direct`) is deprecated and mapped to -`loader`. +Prefer Loader for a new setup. Never offer the `zunit` profile (`-a zunit`) for a new setup; it is compatibility-only and retained strictly so existing installations can be migrated without data loss. Use `-i skip` when the user manages their dotfiles elsewhere; then hand them the block from the [installation page](https://wiki.zshell.dev/docs/getting_started/installation) instead of editing anything. `-b ` selects a Zi branch or tag (defaults to `main`). Note that the direct profile (`-a direct`) is deprecated and mapped to `loader`. ## Resolve the environment first @@ -81,7 +68,7 @@ Report a failed fetch or a failed verification as a failed install; never procee Read the result, do not assume it: - exit 0 and `Successfully installed at `: proceed to verification. The closing `Successfully installed Zi.` banner confirms completion; -- `Zi installer: recipe installation is deferred to the first shell start.`: expected output when installing with `-a annex` or `-a zunit`; recipes install on first shell launch; +- `Zi installer: recipe installation is deferred to the first shell start.`: expected output when installing with `-a annex` (or compatibility `-a zunit`); recipes install on first shell launch; - `Zi installer: the direct zi.zsh profile is deprecated; using the guided loader profile.`: informative notice if `-a direct` was passed; - `managed .zshrc block changed outside Zi setup; apply the printed patch manually or restore the receipt state`: the managed block was modified; show the printed patch to the user and stop, do not overwrite; - `unrecognised Zi integration remains in .zshrc; refusing to initialise Zi twice`: an existing unmanaged Zi integration was detected; report it as a conflict and let the user decide; do not edit `.zshrc` to force it; @@ -96,7 +83,7 @@ Rerunning the installer is the update path: it fetches and fast-forwards the exi ## The managed .zshrc block -For integrated profiles (`loader`, `annex`, `zunit`), the installer writes or updates a short 3-line marker-delimited block in `${ZDOTDIR:-$HOME}/.zshrc`: +For integrated profiles (`loader`, `annex`, or compatibility `zunit`), the installer writes or updates a short 3-line marker-delimited block in `${ZDOTDIR:-$HOME}/.zshrc`: ```zsh # >>> zi setup >>> @@ -106,6 +93,24 @@ source '/absolute/path/to/config/zi/setup.zsh' User dotfiles stay readable and minimal. Implementation details, path checks, error handling, loader startup (`init.zsh && zzinit`), and post-load recipes (`setup/shell.zsh`) are encapsulated in the generated `setup.zsh` entrypoint. +## Machine interface + +For a source revision that provides `zi-setup-describe-v1` and `zi-setup-result-v1` (introduced by [z-shell/src#224](https://github.com/z-shell/src/pull/224)), use the versioned machine interface rather than parsing human stdout or stderr. Routine installs and older source revisions continue to use the verified `install.sh` flow above; do not assume the machine artifacts exist. + +Drive the engine only from a local `src` tree or a same-revision companion bundle containing `setup.sh`, `init.zsh`, `profiles.tsv`, and `checksum.txt` after verifying the published checksums. For a fetched bundle, pass the explicit `--profiles` path to `describe`, and the explicit `--init`, `--profiles`, and `--checksum` paths to `plan`. + +- **Directory artifacts:** Commands communicate through private directory artifacts with fixed relative paths containing raw bytes or restricted tokens (avoiding shell-level JSON escaping). Each destination must not exist, and its immediate parent must already exist and be writable: + - `setup.sh describe --output DIR`: Publishes a `zi-setup-describe-v1` artifact containing `facts/` and `profiles/` (`loader` and `annex`; legacy `zunit` is marked `selectable=no` if detected). Exits 3 if all profiles are blocked. + - `setup.sh plan --plan DIR`: Publishes a deterministic `zi-setup-plan-v1` artifact containing `plan.id`, `plan.meta`, `checkout/`, `targets/`, `operations/`, and `warnings/`. + - `setup.sh apply --plan DIR --phase checkout|files [--expect SHA256] [--result DIR]`: Applies the plan phase-by-phase. +- **Exact plan-id approval:** The plan hash covers all artifact files except `plan.id`. Clients must record the reviewed `plan.id` and pass it via `--expect` for both checkout and files phases. Changed artifact content produces `plan-changed`; changed live checkout or file preconditions produce `checkout-drift` or `target-drift`. +- **Result artifacts:** Passing `--result DIR` to `apply` publishes a `zi-setup-result-v1` artifact containing `format`, `plan.id`, `phase`, `status` (`succeeded`, `failed`, `cancelled`), and `operations/`. Failures also contain `error/code` and `error/detail`, plus `error/operation` when attributable to one operation. A successful files phase contains `receipt/path`. +- **Stable exit statuses and error codes:** + - Exit statuses: `0` (success), `2` (invocation or unsupported version), `3` (non-actionable discovery/plan), `4` (reviewed state or lock precondition changed, including plan, checkout, target drift, or lock contention), `5` (apply operation began but did not complete), `6` (cancelled). + - Error codes (`error/code`): stable ASCII identifiers including `unsupported-version`, `plan-changed`, `target-drift`, `checkout-drift`, `lock-held`, `network-failed`, `checkout-failed`, `write-failed`, and `cancelled`. +- **Untrusted display text:** Operation summaries and warnings are display text. Clients must treat them as untrusted terminal content and strip or visibly escape control sequences. Decisions must rely solely on IDs and restricted tokens, never on display text. +- **Do not parse human stdout/stderr:** Engine stdout and stderr are strictly for user presentation or diagnostics; their wording carries no compatibility promise. Never parse human stdout or stderr to make decisions; consume only documented directory artifacts and exit statuses. + ## Verify Start a fresh interactive shell, the way the user will, and ask Zi for its help text: @@ -136,4 +141,4 @@ For `-i skip`, verify only that `zi.zsh` exists beneath the directory the instal ## Report -State the profile used, the exact files created or changed, what was preserved, any installer messages or refusals verbatim, and the next step for the user: `exec zsh` after integrated installation (`loader`, `annex`, `zunit`); or for `-i skip`, first add the integration block from the [installation page](https://wiki.zshell.dev/docs/getting_started/installation) to their own `.zshrc`, then `exec zsh`. +State the profile used, the exact files created or changed, what was preserved, any installer messages or refusals verbatim, and the next step for the user: `exec zsh` after integrated installation (`loader`, `annex`, or compatibility `zunit`); or for `-i skip`, first add the integration block from the [installation page](https://wiki.zshell.dev/docs/getting_started/installation) to their own `.zshrc`, then `exec zsh`. diff --git a/contracts/installer-contract-v1.json b/contracts/installer-contract-v1.json index 89e3b5e..20c622d 100644 --- a/contracts/installer-contract-v1.json +++ b/contracts/installer-contract-v1.json @@ -59,7 +59,7 @@ "path": ".github/skills/zi-install/SKILL.md", "scope": "external", "evidence_status": "published", - "evidence": "https://github.com/z-shell/.github/blob/664d69c0f99929d7f60be0546d6f766745fc9611/.github/skills/zi-install/SKILL.md", + "evidence": "https://github.com/z-shell/.github/blob/ce74af22db3af827eed9558596275cdf3fb07505/.github/skills/zi-install/SKILL.md", "surfaces": [ "cli-profile-behavior", "output-status-messages",