diff --git a/.github/skills/zi-install/SKILL.md b/.github/skills/zi-install/SKILL.md index 25ad850..083ba70 100644 --- a/.github/skills/zi-install/SKILL.md +++ b/.github/skills/zi-install/SKILL.md @@ -102,14 +102,15 @@ Drive the engine only from a local `src` tree or a same-revision companion bundl - **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. + - `setup.sh apply --plan DIR --phase checkout|files [--expect SHA256] [--result DIR] [--events 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`. +- **Streaming event artifacts:** Passing optional `--events DIR` to `apply` publishes observational `zi-setup-event-v1` events as atomic numbered directories (`000001`, `000002`, ...) containing `format`, `phase`, `operation`, `status` (`started`, `succeeded`, `failed`), and `detail`. Each event is staged in a private hidden directory (`.tmp-event.*`) and renamed into place. Events provide operational progress, but process completion remains authoritative; do not parse stdout or stderr. Ordinary cancellation after an operation starts publishes a terminal `failed` event, though callers must observe the cancellation-publication limit: if a signal interrupts event publication itself, the terminal event may be omitted while cleanup and exit status 6 remain. - **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). + - Exit statuses: `0` (success), `2` (invocation, unsupported version, or event directory initialization/path refusal), `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, or event publication failed after start), `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. +- **Do not parse human stdout/stderr; process completion is authoritative:** 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. Process completion remains authoritative. ## Verify diff --git a/.github/workflows/win-install.yml b/.github/workflows/win-install.yml deleted file mode 100644 index 7d54d52..0000000 --- a/.github/workflows/win-install.yml +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: Windows Install - -on: - pull_request: - paths: - - "public/setup/**" - - "public/sh/**" - - "public/zsh/**" - - "tests/**" - - ".github/workflows/win-install.yml" - push: - paths: - - "public/setup/**" - - "public/sh/**" - - "public/zsh/**" - - "tests/**" - - ".github/workflows/win-install.yml" - workflow_dispatch: {} - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - shellcheck: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: ☑️ ShellCheck - uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 - with: - scandir: "./public/sh" - run-install: - runs-on: windows-latest - timeout-minutes: 30 - needs: [shellcheck] - steps: - - name: 🪟 Set CRLF (Windows) - run: | - git config --global core.autocrlf input - git config --global --add safe.directory /cygdrive/d/a/src/src - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: 🪟 Dependencies (Windows) - uses: egor-tensin/setup-cygwin@fca9069f92361187d4abfaa5d8a7490e435d8349 # v4.0.2 - with: - packages: curl git zsh - - name: 🪟 Run Install - run: | - set -eu - cd "$(cygpath -u "$GITHUB_WORKSPACE")" - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - sh ./tests/installers.sh - sh -x ./public/sh/install.sh -i skip - test -f "${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin/zi.zsh" - test ! -e ~/.zshrc - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - sh -x ./public/sh/install.sh -a annex - grep -Fq "z-shell/z-a-meta-plugins" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - sh -x ./public/sh/install.sh -a loader - grep -Fq "source '${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh'" ~/.zshrc - grep -Fq "zzinit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup.zsh" - command rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/zi" ~/.zi ~/.zshrc "${XDG_CONFIG_HOME:-$HOME/.config}/zi" - sh -x ./public/sh/install.sh -a zunit - grep -Fq "@zunit" "${XDG_CONFIG_HOME:-$HOME/.config}/zi/setup/shell.zsh" - # The Cygwin package is older than zpmod's required Zsh 5.8.1. - # Linux and macOS exercise the native module build. - shell: C:\tools\cygwin\bin\bash.exe --login -o igncr '{0}' - - name: 🪟 Smoke-test — verify zi.zsh present - run: | - ZI_BIN="${XDG_DATA_HOME:-${HOME}/.local/share}/zi/bin" - if [ ! -f "${ZI_BIN}/zi.zsh" ]; then - printf '%s\n' "FAIL: zi.zsh not found at ${ZI_BIN}/zi.zsh" - exit 1 - fi - printf '%s\n' "OK: zi.zsh found at ${ZI_BIN}/zi.zsh" - command rm -rf "${XDG_DATA_HOME:-${HOME}/.local/share}/zi" /home/runneradmin/.zi - shell: C:\tools\cygwin\bin\bash.exe --login -o igncr '{0}' diff --git a/contracts/installer-contract-v1.json b/contracts/installer-contract-v1.json index 20c622d..0abc673 100644 --- a/contracts/installer-contract-v1.json +++ b/contracts/installer-contract-v1.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "contract_version": 2, + "contract_version": 3, "evidence_reviewed": "2026-09-20", "renames": [], "surfaces": [ @@ -45,7 +45,7 @@ "path": "docs/getting_started/01_installation.mdx", "scope": "external", "evidence_status": "published", - "evidence": "https://github.com/z-shell/wiki/blob/293d23e6238b50bda15ac85b7f8fa9bdbc95be66/docs/getting_started/01_installation.mdx", + "evidence": "https://github.com/z-shell/wiki/blob/d4a2849d5e571bf20b170e04d356b065bb385e6f/docs/getting_started/01_installation.mdx", "surfaces": [ "cli-profile-behavior", "output-status-messages", @@ -59,7 +59,7 @@ "path": ".github/skills/zi-install/SKILL.md", "scope": "external", "evidence_status": "published", - "evidence": "https://github.com/z-shell/.github/blob/ce74af22db3af827eed9558596275cdf3fb07505/.github/skills/zi-install/SKILL.md", + "evidence": "https://github.com/z-shell/.github/blob/04fabd632012e00c825b50df0fd8f621d17c9f82/.github/skills/zi-install/SKILL.md", "surfaces": [ "cli-profile-behavior", "output-status-messages", @@ -112,7 +112,7 @@ "path": "README.md", "scope": "external", "evidence_status": "published", - "evidence": "https://github.com/z-shell/zi/blob/67fbb057fe05bc5664c0e558185a18b34a81defb/README.md", + "evidence": "https://github.com/z-shell/zi/blob/2fd78914149a94fe4fe12cf94c132a5736bd4506/README.md", "surfaces": ["cli-profile-behavior", "companion-asset-checksum"] } ] diff --git a/docs/README.md b/docs/README.md index 4c6ef45..3d1fd7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,7 +52,13 @@ profile installs Zi without changing `.zshrc`. The source-adjacent [Zi Setup TUI contract](zi-setup-tui-contract.md) defines the 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. +compatibility-only for existing installer output. Callers can optionally pass +`setup.sh apply --events DIR` to stream observational `zi-setup-event-v1` +atomic numbered directories (`000001`, `000002`, ...). Process completion +remains authoritative; do not parse stdout or stderr, and clients must account +for the cancellation-publication limit where a signal interrupting event +publication itself may omit the terminal event while cleanup and exit status 6 +remain. ## Repository layout @@ -79,7 +85,7 @@ sh public/sh/generate-checksums.sh git diff --exit-code -- public/checksum.txt ``` -GitHub Actions exercises the installer and loader on Linux, macOS, and Cygwin. +GitHub Actions exercises the installer and loader on Linux and macOS. Merges to `main` publish `public/` through GitHub Pages, and the loader-drift workflow verifies that the deployed loader matches its source and checksum. diff --git a/docs/zi-setup-tui-contract.md b/docs/zi-setup-tui-contract.md index 7f36c0a..b3e49df 100644 --- a/docs/zi-setup-tui-contract.md +++ b/docs/zi-setup-tui-contract.md @@ -55,10 +55,10 @@ setup.sh describe --output DIR [--zi-home DIR] [--zi-bin-dir NAME] [--skip-zshrc] setup.sh plan --plan DIR [existing options] setup.sh apply --plan DIR --phase checkout|files [--expect SHA256] - [--result DIR] + [--result DIR] [--events 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. +`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. When `--events` is present, it publishes machine-readable streaming events for active operations into a private directory. Omitting `--result` and `--events` 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. @@ -144,15 +144,44 @@ receipt/path # present after successful files phase `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. +When `--events` is omitted, the engine publishes results only at phase completion or failure. When `--events DIR` is provided, the engine additionally publishes machine-readable streaming events for each active operation as described below. + +### Streaming event artifact + +`zi-setup-event-v1` defines the streaming event directory contract published under `setup.sh apply ... --events DIR`: + +```text +format +phase +operation +status +detail +``` + +- `DIR` is created by the engine with mode `0700`. The path must be absolute, must not already exist, must not be a symlink, and must have a writable parent directory. Event initialization or invalid path failures are refused with exit status 2 before apply begins, whereas publication failures after an operation has started exit with status 5. No root-level files are written to `DIR`. +- Each event is published atomically as a directory under `DIR` named with a six-digit sequence (`000001`, `000002`, ...). Each event is staged under a hidden temporary directory (`.tmp-event.*`) inside `DIR`, all fields are written, and the completed directory is renamed into place. +- All event files are restricted single-line text ending in a newline: + - `format`: `zi-setup-event-v1` + - `phase`: `checkout` or `files` + - `operation`: the current stable plan operation ID (`checkout-sync` or `write-files`) + - `status`: `started`, `succeeded`, or `failed` + - `detail`: bounded static display text safe under artifact rules (no newlines, tabs, or control characters). +- Lifecycle: + - `started` is published immediately before executing the selected operation. + - `succeeded` is published only after that operation completes. + - `failed` is published from the existing error path when an event operation is active. + - Ordinary cancellation after an operation has `started` publishes `failed`, removes staging directories, and exits 6. In the narrow limit where a signal interrupts event publication itself, the terminal event may be omitted while cleanup and exit 6 remain. + - Duplicate terminal events (`succeeded` or `failed`) are prevented. + - If a failure occurs before an operation begins (e.g. invalid arguments, unsupported version, plan hash mismatch, or a held lock), no event operation is active and no event directories are published. +- Events are observational only: they do not change plan IDs, result contracts, exit codes, stdout, stderr, mutation order, or rollback behavior. ### Exit status contract - `0`: requested operation succeeded; its result artifact is complete when `--result` was requested. -- `2`: invocation or unsupported interface version. +- `2`: invocation, unsupported interface version, or event directory initialization/path refusal. - `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. +- `5`: an apply operation began but did not complete, or event publication failed after start. - `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. diff --git a/public/checksum.txt b/public/checksum.txt index 7e6e3df..d6520d0 100644 --- a/public/checksum.txt +++ b/public/checksum.txt @@ -1,6 +1,6 @@ 6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh e322f6aea1c7878bdf6d12032fbb83528f7bbccb4738748335373d0c8380cbe5 public/sh/install.sh -59307dc8e9aa43f35e137913cf41d8916acc6278a0e03ccb365a0ab01e003bae public/sh/setup.sh +3fe433ed5b2fa9b3e12239b0bc4db75eea1bf4d8714220817c0575b3aa3875a2 public/sh/setup.sh 08cc893ceb982fc99d17db1966c6c30790cc571e16e4f5392352d995f5252952 public/sh/sync-init.sh fff8d1c340fb1e87c76f80cac2224e28761ccc7e2b117839b7be6f5311a1ac11 public/setup/profiles.tsv c979e39748d1d86ace17a61ff2b1bf6e1224a43291c25bf7fad985d1e9e11af1 public/zsh/init.zsh diff --git a/public/index.html b/public/index.html index c72ea38..332fdca 100644 --- a/public/index.html +++ b/public/index.html @@ -290,6 +290,25 @@

Z-Shell / src

+
+ ❯ + setup.sh apply --events DIR +
+
+ Optional setup.sh apply --events DIR publishes + zi-setup-event-v1 atomic numbered directories. Process + completion is authoritative; do not parse stdout or stderr. Callers + must account for the cancellation-publication limit where a signal + interrupting publication may omit the terminal event while cleanup + and exit status 6 remain. +
❯ _
diff --git a/public/sh/setup.sh b/public/sh/setup.sh index b66d1fa..2edb70f 100755 --- a/public/sh/setup.sh +++ b/public/sh/setup.sh @@ -26,11 +26,28 @@ RESULT_OPERATION="" RESULT_ERROR_CODE="operation-failed" RESULT_DETAIL="operation failed" ACTIVE_LOCK="" +EVENTS_ACTIVE=0 +EVENTS_DIR="" +EVENTS_SEQ=0 +EVENTS_OPERATION="" +EVENTS_OPERATION_ACTIVE=0 +EVENTS_TERMINAL_PUBLISHED=0 +EVENTS_PUBLISHING=0 die() { _die_detail="$*" printf '%s\n' "${PROGRAM}: ${_die_detail}" >&2 RESULT_DETAIL="${_die_detail}" + if [ "${EVENTS_ACTIVE}" -eq 1 ] && [ "${EVENTS_OPERATION_ACTIVE}" -eq 1 ] && [ "${EVENTS_TERMINAL_PUBLISHED}" -eq 0 ] && [ "${EVENTS_PUBLISHING}" -eq 0 ]; then + set +e + case "${EVENTS_OPERATION}" in + checkout-sync) _event_fail_detail="checkout failed" ;; + write-files) _event_fail_detail="writing files failed" ;; + *) _event_fail_detail="operation failed" ;; + esac + event_finish failed "${_event_fail_detail}" + set -e + fi if [ "${RESULT_ACTIVE}" -eq 1 ] && [ "${RESULT_PUBLISHED}" -eq 0 ]; then set +e result_publish failed "${RESULT_ERROR_CODE}" "${RESULT_OPERATION}" "${RESULT_DETAIL}" @@ -50,7 +67,7 @@ Usage: [--zshrc FILE] [--init FILE] [--profiles FILE] [--checksum FILE] [--skip-zshrc] setup.sh apply --plan DIR --phase checkout|files [--expect SHA256] - [--result DIR] + [--result DIR] [--events DIR] EOF } @@ -1147,6 +1164,115 @@ result_publish() { RESULT_PUBLISHED=1 } +events_init() { + _events_path="$1" + [ -n "${_events_path}" ] || die '--events requires a directory' + while :; do + case "${_events_path}" in + /) break ;; + */) _events_path="${_events_path%/}" ;; + *) break ;; + esac + done + is_absolute_path "${_events_path}" || die '--events must be an absolute path' + validate_text_path '--events' "${_events_path}" + if [ -L "${_events_path}" ] || path_exists "${_events_path}"; then + die "events path already exists: ${_events_path}" + fi + _events_parent="$(dirname "${_events_path}")" + [ -d "${_events_parent}" ] || die "events parent does not exist: ${_events_parent}" + [ -w "${_events_parent}" ] || die "events parent is not writable: ${_events_parent}" + command mkdir -m 0700 "${_events_path}" 2>/dev/null || die "cannot create events directory: ${_events_path}" + command chmod 0700 "${_events_path}" 2>/dev/null || die "cannot set mode on events directory: ${_events_path}" + if [ -L "${_events_path}" ]; then + die "events path is a symlink: ${_events_path}" + fi + EVENTS_DIR="${_events_path}" + EVENTS_ACTIVE=1 + EVENTS_SEQ=0 + EVENTS_OPERATION="" + EVENTS_OPERATION_ACTIVE=0 + EVENTS_TERMINAL_PUBLISHED=0 + EVENTS_PUBLISHING=0 +} + +event_publish() { + _event_status="$1" + _event_detail="$2" + [ "${EVENTS_ACTIVE}" -eq 1 ] || return 0 + [ -n "${EVENTS_OPERATION}" ] || return 0 + if [ "${EVENTS_PUBLISHING}" -eq 1 ]; then + return 0 + fi + case "${_event_status}" in + succeeded | failed) + [ "${EVENTS_TERMINAL_PUBLISHED}" -eq 0 ] || return 0 + EVENTS_TERMINAL_PUBLISHED=1 + ;; + esac + + EVENTS_PUBLISHING=1 + EVENTS_SEQ=$((EVENTS_SEQ + 1)) + _event_stage="$(mktemp -d "${EVENTS_DIR}/.tmp-event.XXXXXX" 2>/dev/null)" || { + EVENTS_PUBLISHING=0 + EVENTS_TERMINAL_PUBLISHED=1 + DIE_STATUS=5 + die "cannot stage event in ${EVENTS_DIR}" + } + + if ! { + write_field "${_event_stage}" format zi-setup-event-v1 && + write_field "${_event_stage}" phase "${APPLY_PHASE}" && + write_field "${_event_stage}" operation "${EVENTS_OPERATION}" && + write_field "${_event_stage}" status "${_event_status}" && + write_field "${_event_stage}" detail "${_event_detail}" + }; then + command rm -rf "${_event_stage}" + EVENTS_PUBLISHING=0 + EVENTS_TERMINAL_PUBLISHED=1 + DIE_STATUS=5 + die "cannot write event fields in ${EVENTS_DIR}" + fi + command chmod 0700 "${_event_stage}" 2>/dev/null || true + + _seq_name="$(printf '%06d' "${EVENTS_SEQ}")" + _event_final="${EVENTS_DIR}/${_seq_name}" + if path_exists "${_event_final}"; then + command rm -rf "${_event_stage}" + EVENTS_PUBLISHING=0 + EVENTS_TERMINAL_PUBLISHED=1 + DIE_STATUS=5 + die "event destination already exists: ${_event_final}" + fi + + command mv "${_event_stage}" "${_event_final}" 2>/dev/null || { + command rm -rf "${_event_stage}" + EVENTS_PUBLISHING=0 + EVENTS_TERMINAL_PUBLISHED=1 + DIE_STATUS=5 + die "cannot publish event to ${_event_final}" + } + EVENTS_PUBLISHING=0 +} + +event_start() { + _start_operation="$1" + _start_detail="$2" + [ "${EVENTS_ACTIVE}" -eq 1 ] || return 0 + EVENTS_OPERATION="${_start_operation}" + EVENTS_OPERATION_ACTIVE=1 + event_publish started "${_start_detail}" +} + +event_finish() { + _finish_status="$1" + _finish_detail="$2" + [ "${EVENTS_ACTIVE}" -eq 1 ] || return 0 + [ "${EVENTS_OPERATION_ACTIVE}" -eq 1 ] || return 0 + event_publish "${_finish_status}" "${_finish_detail}" + EVENTS_OPERATION_ACTIVE=0 +} + apply_cleanup() { if [ -n "${ACTIVE_LOCK}" ]; then command rmdir "${ACTIVE_LOCK}" 2>/dev/null || true @@ -1156,11 +1282,24 @@ apply_cleanup() { command rm -rf "${RESULT_WORK}" RESULT_WORK="" fi + if [ -n "${EVENTS_DIR}" ] && [ -d "${EVENTS_DIR}" ]; then + command rm -rf "${EVENTS_DIR}"/.tmp-event.* 2>/dev/null || true + fi } apply_exit() { _apply_exit_status="$1" trap - EXIT INT TERM HUP + if [ "${EVENTS_ACTIVE}" -eq 1 ] && [ "${EVENTS_OPERATION_ACTIVE}" -eq 1 ] && [ "${EVENTS_TERMINAL_PUBLISHED}" -eq 0 ] && [ "${EVENTS_PUBLISHING}" -eq 0 ]; then + set +e + case "${EVENTS_OPERATION}" in + checkout-sync) _event_fail_detail="checkout failed" ;; + write-files) _event_fail_detail="writing files failed" ;; + *) _event_fail_detail="operation failed" ;; + esac + event_finish failed "${_event_fail_detail}" + set -e + fi if [ "${RESULT_ACTIVE}" -eq 1 ] && [ "${RESULT_PUBLISHED}" -eq 0 ]; then if [ "${_apply_exit_status}" -eq 0 ]; then _apply_exit_status=5; fi set +e @@ -1175,6 +1314,16 @@ apply_cancel() { trap - INT TERM HUP RESULT_ERROR_CODE=cancelled RESULT_DETAIL="apply was cancelled" + if [ "${EVENTS_ACTIVE}" -eq 1 ] && [ "${EVENTS_OPERATION_ACTIVE}" -eq 1 ] && [ "${EVENTS_TERMINAL_PUBLISHED}" -eq 0 ] && [ "${EVENTS_PUBLISHING}" -eq 0 ]; then + set +e + case "${EVENTS_OPERATION}" in + checkout-sync) _event_fail_detail="checkout failed" ;; + write-files) _event_fail_detail="writing files failed" ;; + *) _event_fail_detail="operation failed" ;; + esac + event_finish failed "${_event_fail_detail}" + set -e + fi set +e result_publish cancelled "${RESULT_ERROR_CODE}" "${RESULT_OPERATION}" "${RESULT_DETAIL}" set -e @@ -1228,6 +1377,7 @@ apply_checkout() { [ -w "${_checkout_existing_parent}" ] || die "checkout parent is not writable: ${_checkout_existing_parent}" acquire_lock "${_checkout_path}.zi-setup.lock" RESULT_OPERATION=checkout-sync + event_start checkout-sync "synchronizing checkout" case "${_checkout_kind}" in missing) @@ -1261,6 +1411,7 @@ apply_checkout() { *) die "invalid checkout kind ${_checkout_kind}" ;; esac printf '%s\n' "Checkout phase applied: ${_checkout_path}" + event_finish succeeded "checkout completed" } validate_target_precondition() { @@ -1319,6 +1470,7 @@ apply_files() { [ -w "${_files_config_parent}" ] || die "configuration parent is not writable: ${_files_config_parent}" acquire_lock "${_files_config}.zi-setup.lock" RESULT_OPERATION=write-files + event_start write-files "writing files" while IFS= read -r _files_id; do validate_target_precondition "${_files_plan}" "${_files_id}" @@ -1351,6 +1503,7 @@ apply_files() { command chmod 600 "${_files_receipt_tmp}" command mv "${_files_receipt_tmp}" "${_files_receipt}" printf '%s\n' "Files phase applied. Receipt: ${_files_receipt}" + event_finish succeeded "files completed" } apply_command() { @@ -1359,6 +1512,7 @@ apply_command() { APPLY_PHASE="" APPLY_EXPECT="" APPLY_RESULT="" + APPLY_EVENTS="" while [ "$#" -gt 0 ]; do case "$1" in --plan) @@ -1381,6 +1535,11 @@ apply_command() { APPLY_RESULT="$2" shift 2 ;; + --events) + [ "$#" -ge 2 ] || die '--events requires a directory' + APPLY_EVENTS="$2" + shift 2 + ;; --help | -h) usage exit 0 @@ -1397,6 +1556,9 @@ apply_command() { trap 'apply_exit "$?"' EXIT trap 'apply_cancel' INT TERM HUP fi + if [ -n "${APPLY_EVENTS}" ]; then + events_init "${APPLY_EVENTS}" + fi APPLY_PLAN_ID="$(validate_plan "${APPLY_PLAN}" "${APPLY_EXPECT}")" DIE_STATUS=5 case "${APPLY_PHASE}" in diff --git a/tests/installers.sh b/tests/installers.sh index 2f9caf1..d45ac28 100755 --- a/tests/installers.sh +++ b/tests/installers.sh @@ -442,6 +442,18 @@ case "${cmd}" in printf '%s\n' "fatal: simulated clone failure" >&2 exit 128 fi + if [ -n "${ZI_SRC_TEST_FAKE_GIT_HOLD:-}" ] || [ -n "${ZI_SRC_TEST_FAKE_CLONE_HOLD:-}" ]; then + _hold_val="${ZI_SRC_TEST_FAKE_GIT_HOLD:-${ZI_SRC_TEST_FAKE_CLONE_HOLD}}" + if [ -e "${_hold_val}" ]; then + while [ -e "${_hold_val}" ]; do + sleep 0.05 2>/dev/null || sleep 1 + done + elif [ "${_hold_val}" = "1" ]; then + sleep 1 + else + sleep "${_hold_val}" 2>/dev/null || sleep 1 + fi + 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" @@ -1190,6 +1202,237 @@ test_setup_apply_result_contract() { pass 'apply publishes stable success and failure result artifacts' } +test_setup_apply_events_contract() { + events_home="${TMP_ROOT}/events-home" + events_config="${TMP_ROOT}/events-config" + events_data="${TMP_ROOT}/events-data" + events_plan="${TMP_ROOT}/events-plan" + checkout_events="${TMP_ROOT}/events-checkout" + checkout_result="${TMP_ROOT}/events-checkout-result" + files_events="${TMP_ROOT}/events-files" + files_result="${TMP_ROOT}/events-files-result" + command mkdir -p "${events_home}" + + HOME="${events_home}" \ + XDG_CONFIG_HOME="${events_config}" \ + XDG_DATA_HOME="${events_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${events_plan}" --skip-zshrc >/dev/null + + # 1. Checkout phase success with streaming events + HOME="${events_home}" \ + XDG_CONFIG_HOME="${events_config}" \ + XDG_DATA_HOME="${events_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" apply --plan "${events_plan}" --phase checkout \ + --expect "$(cat "${events_plan}/plan.id")" \ + --result "${checkout_result}" \ + --events "${checkout_events}" >/dev/null + + case "$(ls -ld "${checkout_events}")" in "drwx------"*) ;; *) fail "checkout events dir mode is not 0700" ;; esac + [ -d "${checkout_events}/000001" ] || fail "checkout event 000001 missing" + [ -d "${checkout_events}/000002" ] || fail "checkout event 000002 missing" + [ ! -e "${checkout_events}/000003" ] || fail "extra checkout event published" + for entry in "${checkout_events}"/* "${checkout_events}"/.[!.]* "${checkout_events}"/..?*; do + [ -e "${entry}" ] || continue + [ -d "${entry}" ] || fail "root entry is not a directory: ${entry}" + done + for entry in "${checkout_events}"/.tmp-event.*; do + [ -e "${entry}" ] || continue + fail "temporary staging directory remained in checkout events dir: ${entry}" + done + [ "$(cat "${checkout_events}/000001/format")" = zi-setup-event-v1 ] || fail "checkout event 1 format invalid" + [ "$(cat "${checkout_events}/000001/phase")" = checkout ] || fail "checkout event 1 phase invalid" + [ "$(cat "${checkout_events}/000001/operation")" = checkout-sync ] || fail "checkout event 1 operation invalid" + [ "$(cat "${checkout_events}/000001/status")" = started ] || fail "checkout event 1 status invalid" + [ "$(cat "${checkout_events}/000001/detail")" = "synchronizing checkout" ] || fail "checkout event 1 detail invalid" + [ "$(cat "${checkout_events}/000002/format")" = zi-setup-event-v1 ] || fail "checkout event 2 format invalid" + [ "$(cat "${checkout_events}/000002/phase")" = checkout ] || fail "checkout event 2 phase invalid" + [ "$(cat "${checkout_events}/000002/operation")" = checkout-sync ] || fail "checkout event 2 operation invalid" + [ "$(cat "${checkout_events}/000002/status")" = succeeded ] || fail "checkout event 2 status invalid" + [ "$(cat "${checkout_events}/000002/detail")" = "checkout completed" ] || fail "checkout event 2 detail invalid" + [ "$(cat "${checkout_result}/status")" = succeeded ] || fail "checkout result status invalid" + + # 2. Files phase success with streaming events + HOME="${events_home}" \ + XDG_CONFIG_HOME="${events_config}" \ + XDG_DATA_HOME="${events_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" apply --plan "${events_plan}" --phase files \ + --expect "$(cat "${events_plan}/plan.id")" \ + --result "${files_result}" \ + --events "${files_events}" >/dev/null + + case "$(ls -ld "${files_events}")" in "drwx------"*) ;; *) fail "files events dir mode is not 0700" ;; esac + [ -d "${files_events}/000001" ] || fail "files event 000001 missing" + [ -d "${files_events}/000002" ] || fail "files event 000002 missing" + [ ! -e "${files_events}/000003" ] || fail "extra files event published" + for entry in "${files_events}"/* "${files_events}"/.[!.]* "${files_events}"/..?*; do + [ -e "${entry}" ] || continue + [ -d "${entry}" ] || fail "root entry is not a directory: ${entry}" + done + for entry in "${files_events}"/.tmp-event.*; do + [ -e "${entry}" ] || continue + fail "temporary staging directory remained in files events dir: ${entry}" + done + [ "$(cat "${files_events}/000001/format")" = zi-setup-event-v1 ] || fail "files event 1 format invalid" + [ "$(cat "${files_events}/000001/phase")" = files ] || fail "files event 1 phase invalid" + [ "$(cat "${files_events}/000001/operation")" = write-files ] || fail "files event 1 operation invalid" + [ "$(cat "${files_events}/000001/status")" = started ] || fail "files event 1 status invalid" + [ "$(cat "${files_events}/000001/detail")" = "writing files" ] || fail "files event 1 detail invalid" + [ "$(cat "${files_events}/000002/format")" = zi-setup-event-v1 ] || fail "files event 2 format invalid" + [ "$(cat "${files_events}/000002/phase")" = files ] || fail "files event 2 phase invalid" + [ "$(cat "${files_events}/000002/operation")" = write-files ] || fail "files event 2 operation invalid" + [ "$(cat "${files_events}/000002/status")" = succeeded ] || fail "files event 2 status invalid" + [ "$(cat "${files_events}/000002/detail")" = "files completed" ] || fail "files event 2 detail invalid" + [ "$(cat "${files_result}/status")" = succeeded ] || fail "files result status invalid" + + # 3. Network failure during checkout publishes started then failed, no duplicates + net_home="${TMP_ROOT}/events-net-home" + net_config="${TMP_ROOT}/events-net-config" + net_data="${TMP_ROOT}/events-net-data" + net_plan="${TMP_ROOT}/events-net-plan" + net_events="${TMP_ROOT}/events-net-events" + command mkdir -p "${net_home}" + HOME="${net_home}" XDG_CONFIG_HOME="${net_config}" XDG_DATA_HOME="${net_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${net_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 "${net_plan}" --phase checkout \ + --events "${net_events}" >/dev/null 2>&1 + net_status="$?" + set -e + [ "${net_status}" -eq 5 ] || fail "network failure exited ${net_status}, expected 5" + [ -d "${net_events}/000001" ] || fail "network failure event 000001 missing" + [ -d "${net_events}/000002" ] || fail "network failure event 000002 missing" + [ ! -e "${net_events}/000003" ] || fail "duplicate terminal event published on network failure" + [ "$(cat "${net_events}/000001/status")" = started ] || fail "network failure event 1 status is not started" + [ "$(cat "${net_events}/000002/status")" = failed ] || fail "network failure event 2 status is not failed" + [ "$(cat "${net_events}/000002/detail")" = "checkout failed" ] || fail "network failure event 2 detail is not checkout failed" + + # 4. Target drift during files publishes started then failed, no duplicates + drift_home="${TMP_ROOT}/events-drift-home" + drift_config="${TMP_ROOT}/events-drift-config" + drift_data="${TMP_ROOT}/events-drift-data" + drift_plan="${TMP_ROOT}/events-drift-plan" + drift_events="${TMP_ROOT}/events-drift-events" + 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 \ + --events "${drift_events}" >/dev/null 2>&1 + drift_status="$?" + set -e + [ "${drift_status}" -eq 4 ] || fail "target drift exited ${drift_status}, expected 4" + [ -d "${drift_events}/000001" ] || fail "drift event 000001 missing" + [ -d "${drift_events}/000002" ] || fail "drift event 000002 missing" + [ ! -e "${drift_events}/000003" ] || fail "duplicate terminal event published on drift failure" + [ "$(cat "${drift_events}/000001/status")" = started ] || fail "drift event 1 status is not started" + [ "$(cat "${drift_events}/000002/status")" = failed ] || fail "drift event 2 status is not failed" + [ "$(cat "${drift_events}/000002/detail")" = "writing files failed" ] || fail "drift event 2 detail is not writing files failed" + + # 5. Held lock before operation begins publishes no events + lock_home="${TMP_ROOT}/events-lock-home" + lock_config="${TMP_ROOT}/events-lock-config" + lock_data="${TMP_ROOT}/events-lock-data" + lock_plan="${TMP_ROOT}/events-lock-plan" + lock_events="${TMP_ROOT}/events-lock-events" + 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 \ + --events "${lock_events}" >/dev/null 2>&1 + lock_status="$?" + set -e + [ "${lock_status}" -eq 4 ] || fail "held lock exited ${lock_status}, expected 4" + [ -d "${lock_events}" ] || fail "events dir was not created for held lock" + [ -z "$(ls -A "${lock_events}")" ] || fail "held lock published events before operation began" + + # 6. Refusal of existing path, symlink, and invalid/non-absolute paths + existing_target="${TMP_ROOT}/events-existing-target" + command mkdir -p "${existing_target}" + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${events_plan}" --phase files \ + --events "${existing_target}" >/dev/null 2>&1 + exist_status="$?" + set -e + [ "${exist_status}" -eq 2 ] || fail "existing events dir exited ${exist_status}, expected 2" + + symlink_target="${TMP_ROOT}/events-symlink-target" + command ln -s "${TMP_ROOT}/events-missing" "${symlink_target}" + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${events_plan}" --phase files \ + --events "${symlink_target}" >/dev/null 2>&1 + symlink_status="$?" + set -e + [ "${symlink_status}" -eq 2 ] || fail "symlink events dir exited ${symlink_status}, expected 2" + + set +e + sh "${ROOT}/public/sh/setup.sh" apply --plan "${events_plan}" --phase files \ + --events "relative-events-dir" >/dev/null 2>&1 + relative_status="$?" + set -e + [ "${relative_status}" -eq 2 ] || fail "relative events dir exited ${relative_status}, expected 2" + + # 7. TERM regression for apply --events without --result + term_home="${TMP_ROOT}/events-term-home" + term_config="${TMP_ROOT}/events-term-config" + term_data="${TMP_ROOT}/events-term-data" + term_plan="${TMP_ROOT}/events-term-plan" + term_events="${TMP_ROOT}/events-term-events" + term_lock="${term_data}/zi/bin.zi-setup.lock" + command mkdir -p "${term_home}" + HOME="${term_home}" XDG_CONFIG_HOME="${term_config}" XDG_DATA_HOME="${term_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" plan --plan "${term_plan}" --skip-zshrc >/dev/null + + ZI_SRC_TEST_FAKE_GIT_HOLD=1 \ + ZI_SRC_TEST_FAKE_CLONE_HOLD=1 \ + HOME="${term_home}" XDG_CONFIG_HOME="${term_config}" XDG_DATA_HOME="${term_data}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/setup.sh" apply --plan "${term_plan}" --phase checkout \ + --events "${term_events}" >/dev/null 2>&1 & + apply_pid="$!" + + term_wait=0 + while [ ! -d "${term_events}/000001" ]; do + if ! kill -0 "${apply_pid}" 2>/dev/null; then + fail "apply process exited before publishing started" + fi + term_wait=$((term_wait + 1)) + if [ "${term_wait}" -ge 250 ]; then + kill -TERM "${apply_pid}" 2>/dev/null || true + wait "${apply_pid}" 2>/dev/null || true + fail "timed out waiting for term_events/000001" + fi + sleep 0.02 2>/dev/null || sleep 1 + done + + kill -TERM "${apply_pid}" 2>/dev/null || true + + set +e + wait "${apply_pid}" + term_status="$?" + set -e + + [ "${term_status}" -eq 6 ] || fail "TERM apply exited ${term_status}, expected 6" + [ -d "${term_events}/000002" ] || fail "TERM event 000002 missing" + [ "$(cat "${term_events}/000002/status")" = failed ] || fail "TERM event 2 status is not failed" + [ ! -e "${term_events}/000003" ] || fail "extra event published after TERM" + for entry in "${term_events}"/.tmp-event.*; do + [ -e "${entry}" ] || continue + fail "temporary staging directory remained in events dir after TERM: ${entry}" + done + [ ! -e "${term_lock}" ] || fail "lock remained after TERM cancellation: ${term_lock}" + + pass 'apply publishes versioned streaming events and enforces directory contract' +} + test_setup_plan_tamper_is_rejected() { tamper_home="${TMP_ROOT}/tamper-home" tamper_config="${TMP_ROOT}/tamper-config" @@ -1733,6 +1976,7 @@ test_zshrc_uses_short_entrypoint test_setup_describe_contract test_setup_plan_interface_metadata test_setup_apply_result_contract +test_setup_apply_events_contract test_setup_plan_tamper_is_rejected test_setup_target_drift_is_transactional test_setup_symlinked_zshrc_is_refused