diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index cb72df1..6e03f88 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -47,6 +47,8 @@ jobs: run: go test -count=1 ./... - name: Run race tests run: go test -race -count=1 ./... + - name: Test release packaging + run: ./scripts/test-package-release.sh cross-build: name: Build ${{ matrix.goos }} ${{ matrix.goarch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..abb5b39 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,202 @@ +--- +name: Release + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + name: Validate tag and tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out tagged commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Validate strict annotated semantic tag + run: | + tag="${GITHUB_REF_NAME}" + if [[ ! "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Tag '${tag}' is not a strict semantic version (expected vX.Y.Z)." + exit 1 + fi + + tag_ref="refs/tags/${tag}" + tag_type="$(git cat-file -t "$tag_ref" 2>/dev/null || true)" + if [[ "$tag_type" != "tag" ]]; then + echo "::error::Tag '${tag}' must be an annotated tag." + exit 1 + fi + + tag_commit="$(git rev-list -n 1 "$tag_ref")" + head_commit="$(git rev-parse HEAD)" + if [[ "$tag_commit" != "${GITHUB_SHA}" || "$head_commit" != "${GITHUB_SHA}" ]]; then + echo "::error::Tag '${tag}' (${tag_commit}) and HEAD (${head_commit}) must both resolve to event commit '${GITHUB_SHA}'." + exit 1 + fi + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.26" + + - name: Verify module files + run: | + go mod tidy -diff + go mod verify + + - name: Verify formatting and whitespace + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + printf '%s\n' '::error::These files are not gofmt-clean:' "$unformatted" + exit 1 + fi + git diff --check + + - name: Verify release scripts + run: | + bash -n scripts/package-release.sh + bash -n scripts/test-package-release.sh + + - name: Run vet + run: go vet ./... + + - name: Run unit tests + run: go test -count=1 ./... + + - name: Run race tests + run: go test -race -count=1 ./... + + codeql: + name: CodeQL SAST Analysis + needs: validate + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + security-events: write + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + build-mode: autobuild + languages: go + + - name: Analyze Go SAST + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + category: /language:go + + package: + name: Package release distributions + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.26" + + - name: Build and package distributions (Windows requires sh on PATH) + run: | + ./scripts/package-release.sh "${GITHUB_REF_NAME}" dist + + - name: Upload package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-artifacts + path: dist/* + if-no-files-found: error + retention-days: 1 + + attest: + name: Attest build provenance + needs: [validate, codeql, package] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Download package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-artifacts + path: dist + + - name: Attest archives and checksum manifest + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + dist/*.tar.gz + dist/*.zip + dist/SHA256SUMS + + publish: + name: Publish GitHub release + needs: [validate, codeql, package, attest] + if: github.repository == 'z-shell/zi-setup' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-artifacts + path: dist + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF_NAME}" + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release ${tag} already exists; uploading only missing assets." + existing_assets="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')" + for asset in dist/*; do + name="${asset##*/}" + if grep -Fxq "$name" <<<"$existing_assets"; then + echo "Asset ${name} already exists; leaving it unchanged." + else + gh release upload "$tag" "$asset" --repo "$GITHUB_REPOSITORY" + fi + done + else + gh release create "$tag" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "zi-setup ${tag}" \ + --notes "Windows distributions require a POSIX sh on PATH, such as Git Bash or MSYS2, to execute the setup engine." \ + --generate-notes + fi diff --git a/cmd/zi-setup/main.go b/cmd/zi-setup/main.go index 8fde6ce..c7c4714 100644 --- a/cmd/zi-setup/main.go +++ b/cmd/zi-setup/main.go @@ -18,18 +18,19 @@ import ( var version = "dev" type options struct { - enginePath string - shellPath string - plain bool - headless bool - profile string - apply bool - yes bool - theme string - noColor bool - ascii bool - showVersion bool - inputs engine.Inputs + enginePath string + engineEvents bool + shellPath string + plain bool + headless bool + profile string + apply bool + yes bool + theme string + noColor bool + ascii bool + showVersion bool + inputs engine.Inputs } func main() { @@ -43,17 +44,17 @@ func run(arguments []string) int { return 2 } if options.showVersion { - fmt.Println("zi-setup " + version) + fmt.Printf("zi-setup %s\nengine %s\n", version, engine.BundledEngineRevision) return 0 } - client := engine.Client{EnginePath: options.enginePath, ShellPath: options.shellPath} + client := engine.Client{EnginePath: options.enginePath, ShellPath: options.shellPath, Events: options.engineEvents} workspace, err := client.NewWorkspace(options.inputs) if err != nil { fmt.Fprintln(os.Stderr, presentation.SafeText(err.Error())) return 2 } defer workspace.Close() - session := workflow.New(workspace) + session := workflow.New(workspace, workflow.Options{Ref: options.inputs.Ref, SkipZshrc: options.inputs.SkipZshrc}) ctx := context.Background() linear := useLinear(options, term.IsTerminal(int(os.Stdin.Fd())), term.IsTerminal(int(os.Stdout.Fd()))) if linear { @@ -87,6 +88,7 @@ func parseFlags(arguments []string) (options, error) { flags := flag.NewFlagSet("zi-setup", flag.ContinueOnError) flags.SetOutput(os.Stderr) flags.StringVar(&result.enginePath, "engine", os.Getenv("ZI_SETUP_ENGINE"), "path to public/sh/setup.sh") + flags.BoolVar(&result.engineEvents, "engine-events", false, "enable zi-setup-event-v1 for an external engine") flags.StringVar(&result.shellPath, "shell", "sh", "POSIX shell used to invoke the engine") flags.BoolVar(&result.plain, "plain", false, "use linear interactive output") flags.BoolVar(&result.headless, "headless", false, "run without terminal control; requires --profile") @@ -116,9 +118,6 @@ func parseFlags(arguments []string) (options, error) { if result.showVersion { return result, nil } - if result.enginePath == "" { - return options{}, fmt.Errorf("--engine or ZI_SETUP_ENGINE is required for the local pilot") - } switch result.profile { case "", "loader", "annex": default: diff --git a/cmd/zi-setup/main_test.go b/cmd/zi-setup/main_test.go index ad3bcba..8056e9d 100644 --- a/cmd/zi-setup/main_test.go +++ b/cmd/zi-setup/main_test.go @@ -2,6 +2,17 @@ package main import "testing" +func TestParseFlagsUsesBundledEngineByDefault(t *testing.T) { + t.Setenv("ZI_SETUP_ENGINE", "") + got, err := parseFlags(nil) + if err != nil { + t.Fatal(err) + } + if got.enginePath != "" { + t.Fatalf("engine path = %q, want bundled engine", got.enginePath) + } +} + func TestUseLinearHonorsCommandLineIntent(t *testing.T) { t.Parallel() tests := []struct { diff --git a/docs/README.md b/docs/README.md index d1acdc8..2eb5cb4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,21 @@ Zi Setup is a terminal client for the versioned guided-setup engine in [`z-shell This pilot supports `loader` and `annex`. A discovered `zunit` profile is shown only as preserved compatibility content. -## Local pilot +## Install + +Versioned releases provide archives for Linux, macOS, and Windows on amd64 and arm64. Each release includes `SHA256SUMS` and GitHub artifact attestations. Windows execution requires a POSIX `sh` on `PATH`, such as Git Bash or MSYS2. + +Download the archive for your platform from [GitHub Releases](https://github.com/z-shell/zi-setup/releases), verify it against `SHA256SUMS`, then place `zi-setup` on your `PATH`. + +The binary includes a checksum-verified snapshot of the authoritative setup engine, so the normal invocation is: + +```sh +zi-setup +``` + +Use `--version` to print both the client version and bundled `z-shell/src` revision. + +## Build from source Build the client: @@ -12,7 +26,13 @@ Build the client: go build -o bin/zi-setup ./cmd/zi-setup ``` -Run it against a local checkout of the engine: +Run with the bundled engine: + +```sh +bin/zi-setup +``` + +Override it with a local engine checkout when developing the engine contract: ```sh bin/zi-setup --engine /path/to/src/public/sh/setup.sh @@ -22,7 +42,6 @@ Use `--plain` for a linear keyboard interaction. For a disposable test home, pas ```sh bin/zi-setup --plain \ - --engine /path/to/src/public/sh/setup.sh \ --home /tmp/zi-setup-home \ --config-home /tmp/zi-setup-config/zi \ --zshrc /tmp/zi-setup-home/.zshrc @@ -30,6 +49,10 @@ bin/zi-setup --plain \ The setup engine remains the sole owner of discovery, planning, generated Zsh, precondition checks, file changes, checkout operations, and receipts. +The terminal UI offers only engine-backed choices: the `loader` or `annex` profile, the Zi Git ref, and whether setup should update `.zshrc`. The discovered `zunit` profile remains visible only as preserved compatibility content. Plain and headless modes expose the same choices as flags. + +An external engine defaults to phase-level progress for backward compatibility. Pass `--engine-events` only when that engine implements `zi-setup-event-v1`. + ## Verification ```sh @@ -40,6 +63,6 @@ go vet ./... See [architecture.md](architecture.md) for the pilot boundary and test strategy. -## Current limits +## Engine provenance -The pilot requires an explicit local `setup.sh` engine path. Release packaging, a verified engine-bundle bootstrap, streaming apply events, and additional capability choices remain separate delivery work. +The embedded files are copied byte-for-byte from a documented `z-shell/src` commit. Their SHA-256 values are compiled into the client and verified before extraction. `scripts/sync-engine.sh /path/to/src FULL_COMMIT_SHA` reads every asset from that exact commit, even when the source checkout has local changes. Maintainers then update the pinned revision and reported hashes in `internal/engine/bundle.go` and run the full checks. diff --git a/docs/architecture.md b/docs/architecture.md index 1ed5455..a3ccbef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,9 +9,12 @@ The client uses only these versioned contracts: - `zi-setup-describe-v1` - `zi-setup-plan-v1` - `zi-setup-result-v1` +- `zi-setup-event-v1` Human-readable engine output is captured for an optional sanitized details view. It never controls a decision. +Release binaries embed an exact `z-shell/src` asset snapshot. The client verifies pinned SHA-256 values before extracting that snapshot into its private temporary workspace. An explicit `--engine` path remains available for development and compatibility testing. The embedded copy does not move setup behavior into Go; `setup.sh` remains the authority. + ## Packages - `internal/contract` reads and validates fixed artifact paths. @@ -26,11 +29,14 @@ Review stores the exact `plan.id`. Both checkout and files phases pass it to the Going backward discards the plan and creates a new one. No plan artifact is edited in place. +When supported, apply receives an event directory. The engine atomically publishes numbered event subdirectories while an operation runs. The client validates each directory as `zi-setup-event-v1` and may present its phase, operation, status, and detail. Missing events use the existing phase-level presentation. Process completion remains authoritative: cancellation can interrupt event publication itself, so the client tolerates a missing terminal event when its context was cancelled. Human output is never interpreted as progress. + ## Safety - Every engine argument is a distinct process argument. - Artifact versions, IDs, order files, and restricted tokens are validated. - Display text has terminal control bytes escaped before rendering. - Temporary roots use private OS-created directories. +- Embedded engine assets are pinned to a source commit and verified before execution. - Plain and TUI modes use the same workflow object and engine arguments. - Tests use fake engines or disposable homes. Development never targets the maintainer's real shell configuration. diff --git a/internal/contract/event_test.go b/internal/contract/event_test.go new file mode 100644 index 0000000..183f238 --- /dev/null +++ b/internal/contract/event_test.go @@ -0,0 +1,45 @@ +package contract + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadApplyEvent(t *testing.T) { + t.Parallel() + root := t.TempDir() + values := map[string]string{ + "format": "zi-setup-event-v1", "phase": "checkout", "operation": "checkout-sync", + "status": "started", "detail": "fetching the requested ref", + } + for name, value := range values { + if err := os.WriteFile(filepath.Join(root, name), []byte(value+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + event, err := ReadApplyEvent(root) + if err != nil { + t.Fatal(err) + } + if event.Operation != "checkout-sync" || event.Status != "started" { + t.Fatalf("event = %#v", event) + } +} + +func TestReadApplyEventRejectsUnknownStatus(t *testing.T) { + t.Parallel() + root := t.TempDir() + values := map[string]string{ + "format": "zi-setup-event-v1", "phase": "files", "operation": "write-files", + "status": "almost-done", "detail": "fixture", + } + for name, value := range values { + if err := os.WriteFile(filepath.Join(root, name), []byte(value+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + if _, err := ReadApplyEvent(root); err == nil { + t.Fatal("unknown event status was accepted") + } +} diff --git a/internal/contract/read.go b/internal/contract/read.go index 6346273..05bc35d 100644 --- a/internal/contract/read.go +++ b/internal/contract/read.go @@ -17,6 +17,7 @@ const ( describeFormat = "zi-setup-describe-v1" planFormat = "zi-setup-plan-v1" resultFormat = "zi-setup-result-v1" + eventFormat = "zi-setup-event-v1" metadataLimit = 64 << 10 contentLimit = 8 << 20 ) @@ -499,3 +500,41 @@ func ReadResult(path string) (Result, error) { } return result, nil } + +func ReadApplyEvent(path string) (ApplyEvent, error) { + r, err := openReader(path) + if err != nil { + return ApplyEvent{}, err + } + defer r.close() + event := ApplyEvent{} + for name, destination := range map[string]*string{ + "format": &event.Format, + "phase": &event.Phase, + "operation": &event.Operation, + "status": &event.Status, + "detail": &event.Detail, + } { + *destination, err = r.text(name) + if err != nil { + return ApplyEvent{}, err + } + } + if event.Format != eventFormat { + return ApplyEvent{}, fmt.Errorf("unsupported event format %q", event.Format) + } + if err := requireOneOf("phase", event.Phase, "checkout", "files"); err != nil { + return ApplyEvent{}, err + } + if !idPattern.MatchString(event.Operation) { + return ApplyEvent{}, fmt.Errorf("operation has invalid id %q", event.Operation) + } + expectedOperation := map[string]string{"checkout": "checkout-sync", "files": "write-files"}[event.Phase] + if event.Operation != expectedOperation { + return ApplyEvent{}, fmt.Errorf("phase %q event has operation %q, expected %q", event.Phase, event.Operation, expectedOperation) + } + if err := requireOneOf("status", event.Status, "started", "succeeded", "failed"); err != nil { + return ApplyEvent{}, err + } + return event, nil +} diff --git a/internal/contract/types.go b/internal/contract/types.go index 4d9e219..5f19a8d 100644 --- a/internal/contract/types.go +++ b/internal/contract/types.go @@ -121,3 +121,12 @@ type Result struct { Error *ResultError ReceiptPath string } + +type ApplyEvent struct { + Sequence int + Format string + Phase string + Operation string + Status string + Detail string +} diff --git a/internal/engine/bundle.go b/internal/engine/bundle.go new file mode 100644 index 0000000..b629298 --- /dev/null +++ b/internal/engine/bundle.go @@ -0,0 +1,67 @@ +package engine + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const BundledEngineRevision = "af663e2253e8d6dbdf9a36bd0a9fd374b0215fb7" + +var bundledHashes = map[string]string{ + "public/sh/setup.sh": "3fe433ed5b2fa9b3e12239b0bc4db75eea1bf4d8714220817c0575b3aa3875a2", + "public/setup/profiles.tsv": "fff8d1c340fb1e87c76f80cac2224e28761ccc7e2b117839b7be6f5311a1ac11", + "public/zsh/init.zsh": "c979e39748d1d86ace17a61ff2b1bf6e1224a43291c25bf7fad985d1e9e11af1", + "public/checksum.txt": "4715c5a857d18f149a9578a1d7bbdacb3c18e3dc6ef5fdb75c5eb265a78d3503", +} + +//go:embed bundled/public/sh/setup.sh bundled/public/setup/profiles.tsv bundled/public/zsh/init.zsh bundled/public/checksum.txt +var bundledFiles embed.FS + +func extractBundledEngine(root string, inputs *Inputs) (string, error) { + if err := verifyBundle(bundledFiles); err != nil { + return "", err + } + engineRoot := filepath.Join(root, "bundled-engine") + for name := range bundledHashes { + data, err := fs.ReadFile(bundledFiles, "bundled/"+name) + if err != nil { + return "", fmt.Errorf("read bundled %s: %w", name, err) + } + destination := filepath.Join(engineRoot, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return "", fmt.Errorf("create bundled engine directory: %w", err) + } + mode := os.FileMode(0o600) + if name == "public/sh/setup.sh" { + mode = 0o700 + } + if err := os.WriteFile(destination, data, mode); err != nil { + return "", fmt.Errorf("write bundled %s: %w", name, err) + } + } + inputs.Init = filepath.Join(engineRoot, "public", "zsh", "init.zsh") + inputs.Profiles = filepath.Join(engineRoot, "public", "setup", "profiles.tsv") + inputs.Checksum = filepath.Join(engineRoot, "public", "checksum.txt") + return filepath.Join(engineRoot, "public", "sh", "setup.sh"), nil +} + +func verifyBundle(bundle fs.FS) error { + for name, expected := range bundledHashes { + data, err := fs.ReadFile(bundle, "bundled/"+name) + if err != nil { + return fmt.Errorf("read bundled %s: %w", name, err) + } + digest := sha256.Sum256(data) + actual := hex.EncodeToString(digest[:]) + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("bundled %s checksum mismatch: got %s, want %s", name, actual, expected) + } + } + return nil +} diff --git a/internal/engine/bundle_test.go b/internal/engine/bundle_test.go new file mode 100644 index 0000000..9c009ab --- /dev/null +++ b/internal/engine/bundle_test.go @@ -0,0 +1,154 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/z-shell/zi-setup/internal/contract" +) + +func TestBundledEngineDescribesAndPlansDisposableHome(t *testing.T) { + home := t.TempDir() + fakeBin := t.TempDir() + if err := os.WriteFile(filepath.Join(fakeBin, "zsh"), []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + workspace, err := (Client{TempParent: t.TempDir()}).NewWorkspace(Inputs{ + Home: home, + ConfigHome: filepath.Join(home, ".config", "zi"), + ZiHome: filepath.Join(home, ".local", "share", "zi"), + Zshrc: filepath.Join(home, ".zshrc"), + SkipZshrc: true, + }) + if err != nil { + t.Fatal(err) + } + defer workspace.Close() + describe, _, err := workspace.Describe(context.Background()) + if err != nil { + t.Fatal(err) + } + if describe.Format != "zi-setup-describe-v1" { + t.Fatalf("describe format = %q", describe.Format) + } + plan, _, err := workspace.Plan(context.Background(), "loader") + if err != nil { + t.Fatal(err) + } + if plan.Format != "zi-setup-plan-v1" || plan.Meta.Ref != "main" || !plan.Meta.SkipZshrc { + t.Fatalf("plan = %#v", plan) + } +} + +func TestBundledEngineAppliesBothPhasesWithEvents(t *testing.T) { + home := t.TempDir() + fakeBin := filepath.Join(home, "bin") + if err := os.Mkdir(fakeBin, 0o700); err != nil { + t.Fatal(err) + } + fakeGit := `#!/bin/sh +set -eu +case "$1" in +check-ref-format) + [ "$2" = --branch ] + printf '%s\n' "$3" + ;; +clone) + destination= + for argument do destination=$argument; done + mkdir -p "$destination/.git" + printf '%s\n' '# fixture' >"$destination/zi.zsh" + ;; +-C) + shift 2 + case "$1 $2 ${3:-}" in + 'remote get-url origin') printf '%s\n' https://github.com/z-shell/zi.git ;; + 'rev-parse HEAD ') printf '%040d\n' 1 ;; + 'symbolic-ref --quiet --short') printf '%s\n' main ;; + *) exit 64 ;; + esac + ;; +*) exit 64 ;; +esac +` + if err := os.WriteFile(filepath.Join(fakeBin, "git"), []byte(fakeGit), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + + workspace, err := (Client{TempParent: home}).NewWorkspace(Inputs{ + Home: home, + ConfigHome: filepath.Join(home, ".config", "zi"), + ZiHome: filepath.Join(home, ".local", "share", "zi"), + Zshrc: filepath.Join(home, ".zshrc"), + SkipZshrc: true, + }) + if err != nil { + t.Fatal(err) + } + defer workspace.Close() + if _, _, err := workspace.Plan(context.Background(), "loader"); err != nil { + t.Fatal(err) + } + + var events []contract.ApplyEvent + for _, phase := range []string{"checkout", "files"} { + result, _, err := workspace.Apply(context.Background(), phase, func(event contract.ApplyEvent) { + events = append(events, event) + }) + if err != nil { + t.Fatalf("apply %s: %v", phase, err) + } + if result.Status != "succeeded" || result.Phase != phase { + t.Fatalf("%s result = %#v", phase, result) + } + } + if len(events) != 4 { + t.Fatalf("events = %#v", events) + } + for offset := 0; offset < len(events); offset += 2 { + if events[offset].Sequence != 1 || events[offset].Status != "started" || events[offset+1].Sequence != 2 || events[offset+1].Status != "succeeded" { + t.Fatalf("phase events = %#v", events[offset:offset+2]) + } + } + if _, err := os.Stat(filepath.Join(home, ".config", "zi", "init.zsh")); err != nil { + t.Fatalf("installed init.zsh: %v", err) + } +} + +func TestBundledEngineIsPinnedAndExtractedPrivately(t *testing.T) { + t.Parallel() + root := t.TempDir() + var inputs Inputs + path, err := extractBundledEngine(root, &inputs) + if err != nil { + t.Fatal(err) + } + for _, candidate := range []string{path, inputs.Init, inputs.Profiles, inputs.Checksum} { + info, err := os.Stat(candidate) + if err != nil { + t.Fatalf("stat %s: %v", candidate, err) + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { + t.Fatalf("%s mode = %s", candidate, info.Mode()) + } + if filepath.Clean(candidate) != candidate { + t.Fatalf("unclean extracted path %q", candidate) + } + } +} + +func TestBundledEngineVerificationRejectsTampering(t *testing.T) { + t.Parallel() + fixture := fstest.MapFS{} + for name := range bundledHashes { + fixture["bundled/"+name] = &fstest.MapFile{Data: []byte("tampered\n")} + } + if err := verifyBundle(fixture); err == nil { + t.Fatal("tampered bundle passed verification") + } +} diff --git a/internal/engine/bundled/public/checksum.txt b/internal/engine/bundled/public/checksum.txt new file mode 100644 index 0000000..d6520d0 --- /dev/null +++ b/internal/engine/bundled/public/checksum.txt @@ -0,0 +1,6 @@ +6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh +e322f6aea1c7878bdf6d12032fbb83528f7bbccb4738748335373d0c8380cbe5 public/sh/install.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/internal/engine/bundled/public/setup/profiles.tsv b/internal/engine/bundled/public/setup/profiles.tsv new file mode 100644 index 0000000..04dc5fc --- /dev/null +++ b/internal/engine/bundled/public/setup/profiles.tsv @@ -0,0 +1,3 @@ +# label verified z-a-meta-plugins revision +annexes 74bd8a8bc3bcff8398420ff5a38758dec5b90e2b +zunit 74bd8a8bc3bcff8398420ff5a38758dec5b90e2b diff --git a/internal/engine/bundled/public/sh/setup.sh b/internal/engine/bundled/public/sh/setup.sh new file mode 100644 index 0000000..2edb70f --- /dev/null +++ b/internal/engine/bundled/public/sh/setup.sh @@ -0,0 +1,1601 @@ +#!/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 + +PROGRAM="${0##*/}" +SCRIPT_DIR="$( + unset CDPATH + cd "$(dirname "$0")" 2>/dev/null && pwd +)" || exit 1 +ROOT="$( + unset CDPATH + cd "${SCRIPT_DIR}/../.." 2>/dev/null && pwd +)" || exit 1 + +DIE_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="" +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}" + 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] [--events DIR] +EOF +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + die "sha256sum or shasum is required" + fi +} + +is_absolute_path() { + case "${1-}" in + /*) return 0 ;; + *) return 1 ;; + esac +} + +path_exists() { + [ -e "$1" ] || [ -L "$1" ] +} + +validate_text_path() { + case "$2" in + *" +"* | *" "*) die "$1 must not contain a newline or tab" ;; + 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._/-]*) + die "invalid ref '$1': use ASCII letters, digits, '.', '_', '/', or '-', with no leading '-' or '..'" + ;; + esac + command git check-ref-format --branch "$1" >/dev/null 2>&1 || + die "invalid ref '$1': not a valid branch name" +} + +zsh_single_quote() { + _zsh_quote_value="$(printf '%s' "$1" | command sed "s/'/'\\\\''/g")" || + die 'failed to serialize a Zsh string' + printf "'%s'" "${_zsh_quote_value}" +} + +zi_home_has_installation() { + [ -f "$1/bin/zi.zsh" ] || + [ -d "$1/plugins" ] || + [ -d "$1/snippets" ] || + [ -d "$1/completions" ] || + [ -d "$1/zmodules" ] +} + +resolve_zi_home() { + if [ -n "${ZI_HOME-}" ]; then + printf '%s\n' "${ZI_HOME}" + return + fi + if is_absolute_path "${XDG_DATA_HOME-}"; then + _resolve_data="${XDG_DATA_HOME}" + else + _resolve_data="${HOME}/.local/share" + fi + _resolve_legacy="${HOME}/.zi" + _resolve_xdg="${_resolve_data}/zi" + _resolve_legacy_present=0 + _resolve_xdg_present=0 + zi_home_has_installation "${_resolve_legacy}" && _resolve_legacy_present=1 + zi_home_has_installation "${_resolve_xdg}" && _resolve_xdg_present=1 + if [ "${_resolve_legacy_present}" -eq 1 ] && [ "${_resolve_xdg_present}" -eq 1 ]; then + if [ -f "${_resolve_xdg}/bin/zi.zsh" ] && [ ! -f "${_resolve_legacy}/bin/zi.zsh" ]; then + printf '%s\n' "${_resolve_xdg}" + else + die "both legacy and XDG Zi homes exist; pass --zi-home to select one" + fi + elif [ "${_resolve_legacy_present}" -eq 1 ]; then + printf '%s\n' "${_resolve_legacy}" + else + printf '%s\n' "${_resolve_xdg}" + fi +} + +current_hash() { + if [ -e "$1" ]; then + sha256_file "$1" + else + printf '%s\n' missing + fi +} + +receipt_value() { + _receipt_key="$1" + _receipt_file="$2" + [ -f "${_receipt_file}" ] || return 1 + [ "$(sed -n '1p' "${_receipt_file}")" = 'format=zi-setup-receipt-v1' ] || return 1 + sed -n "s/^${_receipt_key}=//p" "${_receipt_file}" | sed -n '1p' +} + +profile_revision() { + _profile_label="$1" + _profile_file="$2" + _profile_value="$(awk -v label="${_profile_label}" ' + $1 == label { if (seen++) exit 2; print $2 } + ' "${_profile_file}")" || die "duplicate profile label ${_profile_label}" + case "${_profile_value}" in + "" | *[!0-9a-f]*) die "profile label ${_profile_label} is missing a pinned revision" ;; + esac + [ "${#_profile_value}" -eq 40 ] || die "profile label ${_profile_label} must use a full 40-character revision" + printf '%s\n' "${_profile_value}" +} + +artifact_hash() ( + _artifact_dir="$1" + _artifact_raw="$(mktemp "${TMPDIR:-/tmp}/zi-setup-hash-raw.XXXXXX")" || exit 1 + _artifact_list="$(mktemp "${TMPDIR:-/tmp}/zi-setup-hash-list.XXXXXX")" || { + command rm -f "${_artifact_raw}" + exit 1 + } + _artifact_manifest="$(mktemp "${TMPDIR:-/tmp}/zi-setup-hash-manifest.XXXXXX")" || { + command rm -f "${_artifact_raw}" "${_artifact_list}" + exit 1 + } + trap 'rm -f "${_artifact_raw}" "${_artifact_list}" "${_artifact_manifest}"' EXIT INT TERM + cd "${_artifact_dir}" || exit 1 + find . -type f ! -name plan.id -print >"${_artifact_raw}" || exit 1 + LC_ALL=C sort "${_artifact_raw}" >"${_artifact_list}" || exit 1 + while IFS= read -r _artifact_file; do + _artifact_file_hash="$(sha256_file "${_artifact_file}")" || exit 1 + printf '%s %s\n' "${_artifact_file_hash}" "${_artifact_file}" >>"${_artifact_manifest}" || exit 1 + done <"${_artifact_list}" + sha256_file "${_artifact_manifest}" +) + +plan_value() { + _plan_key="$1" + _plan_dir="$2" + sed -n "s/^${_plan_key}=//p" "${_plan_dir}/plan.meta" | sed -n '1p' +} + +write_managed_block() { + _managed_config_home="$1" + _managed_entry_text="$(zsh_single_quote "${_managed_config_home}/setup.zsh")" || + die 'failed to serialize the setup entrypoint' + command cat <>> zi setup >>> +source ${_managed_entry_text} +# <<< zi setup <<< +EOF +} + +write_setup_entrypoint() { + _entry_config_home="$1" + _entry_pre_text="$(zsh_single_quote "${_entry_config_home}/setup/pre.zsh")" || + die 'failed to serialize the pre-setup path' + _entry_init_text="$(zsh_single_quote "${_entry_config_home}/init.zsh")" || + die 'failed to serialize the init path' + _entry_shell_text="$(zsh_single_quote "${_entry_config_home}/setup/shell.zsh")" || + die 'failed to serialize the shell-setup path' + command cat < https://wiki.zshell.dev/ecosystem/category/-annexes +zicompinit # <- https://wiki.zshell.dev/docs/guides/commands +EOF +} + +write_legacy_annex() { + command cat <<'EOF' +zi light-mode for \ + z-shell/z-a-meta-plugins \ + @annexes # <- https://wiki.zshell.dev/ecosystem/category/-annexes +# examples here -> https://wiki.zshell.dev/community/gallery/collection +zicompinit # <- https://wiki.zshell.dev/docs/guides/commands +EOF +} + +write_legacy_zunit() { + command cat <<'EOF' +zi light-mode for \ + z-shell/z-a-meta-plugins \ + @annexes @zunit +EOF +} + +extract_legacy_direct_values() { + _extract_file="$1" + LEGACY_DIRECT_FOUND=0 + [ -f "${_extract_file}" ] || return 0 + _extract_source_count="$(grep -Ec '^[[:space:]]*source "[^"\\]*/zi\.zsh"$' "${_extract_file}" 2>/dev/null || true)" + _extract_clone_count="$(grep -Ec '^[[:space:]]*command git clone .*--branch "[A-Za-z0-9._/-]+" https://github.com/z-shell/zi ' "${_extract_file}" 2>/dev/null || true)" + [ "${_extract_source_count}" -ne 0 ] && [ "${_extract_clone_count}" -ne 0 ] || return 0 + [ "${_extract_source_count}" -eq 1 ] || die 'legacy direct profile has more than one candidate source line' + [ "${_extract_clone_count}" -eq 1 ] || die 'legacy direct profile ref cannot be decoded unambiguously' + _extract_source="$(grep -E '^[[:space:]]*source "[^"\\]*/zi\.zsh"$' "${_extract_file}")" + _extract_path="${_extract_source#*source \"}" + _extract_path="${_extract_path%/zi.zsh\"}" + # These patterns intentionally match literal shell syntax without evaluating it. + # shellcheck disable=SC2016 + case "${_extract_path}" in + *'`'* | *'$('* | *'${'*) die 'legacy direct profile path cannot be decoded unambiguously' ;; + '$HOME') _extract_path="${HOME}" ;; + '$HOME'/*) _extract_path="${HOME}${_extract_path#\$HOME}" ;; + /*) ;; + *) die 'legacy direct profile path is neither absolute nor rooted at $HOME' ;; + esac + _extract_bin="${_extract_path##*/}" + _extract_home="${_extract_path%/*}" + case "${_extract_bin}" in "" | . | .. | */*) die 'legacy direct profile bin name is ambiguous' ;; esac + is_absolute_path "${_extract_home}" || die 'legacy direct profile home is not absolute after decoding' + + _extract_clone="$(grep -E '^[[:space:]]*command git clone .*--branch "[A-Za-z0-9._/-]+" https://github.com/z-shell/zi ' "${_extract_file}")" + _extract_ref_part="${_extract_clone#*--branch \"}" + _extract_ref="${_extract_ref_part%%\"*}" + validate_ref "${_extract_ref}" + + LEGACY_DIRECT_FOUND=1 + LEGACY_DIRECT_HOME="${_extract_home}" + LEGACY_DIRECT_BIN="${_extract_bin}" + LEGACY_DIRECT_REF="${_extract_ref}" +} + +strip_exact_block() { + _strip_source="$1" + _strip_template="$2" + _strip_output="$3" + _strip_result="$4" + awk -v result="${_strip_result}" ' + FNR == NR { template[++template_count] = $0; next } + { source[++source_count] = $0 } + END { + matches = 0 + start = 0 + for (i = 1; i <= source_count - template_count + 1; i++) { + equal = 1 + for (j = 1; j <= template_count; j++) { + if (source[i + j - 1] != template[j]) { equal = 0; break } + } + if (equal) { matches++; start = i } + } + print matches > result + for (i = 1; i <= source_count; i++) { + if (matches == 1 && i >= start && i < start + template_count) continue + print source[i] + } + } + ' "${_strip_template}" "${_strip_source}" >"${_strip_output}" +} + +replace_managed_block() { + _replace_source="$1" + _replace_block="$2" + _replace_output="$3" + awk -v block_file="${_replace_block}" ' + $0 == "# >>> zi setup >>>" { + while ((getline block_line < block_file) > 0) print block_line + close(block_file) + skipping = 1 + next + } + skipping && $0 == "# <<< zi setup <<<" { skipping = 0; next } + !skipping { print } + ' "${_replace_source}" >"${_replace_output}" +} + +extract_managed_block() { + awk ' + $0 == "# >>> zi setup >>>" { copying = 1 } + copying { print } + $0 == "# <<< zi setup <<<" { copying = 0 } + ' "$1" >"$2" +} + +diff_target() { + _diff_path="$1" + _diff_content="$2" + _diff_empty="$3" + if [ -e "${_diff_path}" ]; then + diff -u "${_diff_path}" "${_diff_content}" || [ "$?" -eq 1 ] + else + : >"${_diff_empty}" + diff -u "${_diff_empty}" "${_diff_content}" || [ "$?" -eq 1 ] + fi +} + +add_target() { + _target_plan="$1" + _target_id="$2" + _target_path="$3" + _target_content="$4" + _target_mode="$5" + _target_receipt="$6" + _target_dir="${_target_plan}/targets/${_target_id}" + command mkdir -p "${_target_dir}" + command cp "${_target_content}" "${_target_dir}/content" + printf '%s\n' "${_target_path}" >"${_target_dir}/path" + printf '%s\n' "${_target_mode}" >"${_target_dir}/mode" + _target_kind='missing' + if [ -L "${_target_path}" ]; then + _target_kind='symlink' + elif [ -e "${_target_path}" ]; then + _target_kind='file' + fi + printf '%s\n' "${_target_kind}" >"${_target_dir}/kind" + _target_expected="$(current_hash "${_target_path}")" + printf '%s\n' "${_target_expected}" >"${_target_dir}/expected" + + if [ "${_target_kind}" = file ] && [ "${_target_id}" != zshrc ]; then + _target_desired="$(sha256_file "${_target_content}")" + if [ "${_target_expected}" != "${_target_desired}" ]; then + _target_owned="$(receipt_value "target.${_target_id}" "${_target_receipt}" 2>/dev/null || true)" + [ "${_target_owned}" = "${_target_expected}" ] || + die "refusing unmanaged target ${_target_path}; move it aside or restore a valid receipt" + fi + fi + printf '%s\n' "${_target_id}" >>"${_target_plan}/targets/order" +} + +write_shell_fragment() { + _shell_profile="$1" + _shell_direct="$2" + _shell_profiles="$3" + _shell_output="$4" + { + printf '%s\n' '# Generated by Zi guided setup. Edit choices through a new plan.' + if [ "${_shell_direct}" -eq 1 ] || [ "${_shell_profile}" != loader ]; then + command cat <<'EOF' +autoload -Uz _zi +(( ${+_comps} )) && _comps[zi]=_zi +EOF + fi + case "${_shell_profile}" in + loader) + printf '%s\n' ': # no post-loader recipe selected' + ;; + annex | zunit) + _shell_revision="$(profile_revision annexes "${_shell_profiles}")" + printf '%s\n' "zi ice ver'${_shell_revision}'" + # The trailing backslashes are literal continuation markers in generated Zsh. + # shellcheck disable=SC1003 + printf '%s\n' 'zi light-mode for \' ' z-shell/z-a-meta-plugins \' + if [ "${_shell_profile}" = annex ]; then + printf '%s\n' ' @annexes # <- https://wiki.zshell.dev/ecosystem/category/-annexes' + else + _shell_zunit_revision="$(profile_revision zunit "${_shell_profiles}")" + [ "${_shell_zunit_revision}" = "${_shell_revision}" ] || + die "annexes and zunit must share one verified meta-plugins revision" + printf '%s\n' ' @annexes @zunit' + fi + printf '%s\n' 'zicompinit # <- https://wiki.zshell.dev/docs/guides/commands' + ;; + esac + } >"${_shell_output}" +} + +# 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 + PLAN_ZI_HOME="${ZI_HOME-}" + PLAN_HOME_EXPLICIT=0 + [ -z "${ZI_HOME-}" ] || PLAN_HOME_EXPLICIT=1 + PLAN_BIN="${ZI_BIN_DIR_NAME:-bin}" + PLAN_BIN_EXPLICIT=0 + [ -z "${ZI_BIN_DIR_NAME-}" ] || PLAN_BIN_EXPLICIT=1 + REF_EXPLICIT=0 + CONFIG_HOME="" + ZSHRC_PATH="" + INIT_SOURCE="${ROOT}/public/zsh/init.zsh" + PROFILES_FILE="${ROOT}/public/setup/profiles.tsv" + CHECKSUM_FILE="${ROOT}/public/checksum.txt" + SKIP_ZSHRC=0 + + while [ "$#" -gt 0 ]; do + case "$1" in + --plan) + [ "$#" -ge 2 ] || die '--plan requires a directory' + PLAN_DIR="$2" + shift 2 + ;; + --profile) + [ "$#" -ge 2 ] || die '--profile requires a value' + PROFILE="$2" + shift 2 + ;; + --ref) + [ "$#" -ge 2 ] || die '--ref requires a value' + REF="$2" + REF_EXPLICIT=1 + shift 2 + ;; + --zi-home) + [ "$#" -ge 2 ] || die '--zi-home requires a directory' + PLAN_ZI_HOME="$2" + PLAN_HOME_EXPLICIT=1 + shift 2 + ;; + --zi-bin-dir) + [ "$#" -ge 2 ] || die '--zi-bin-dir requires a name' + PLAN_BIN="$2" + PLAN_BIN_EXPLICIT=1 + shift 2 + ;; + --config-home) + [ "$#" -ge 2 ] || die '--config-home requires a directory' + CONFIG_HOME="$2" + shift 2 + ;; + --zshrc) + [ "$#" -ge 2 ] || die '--zshrc requires a file' + ZSHRC_PATH="$2" + shift 2 + ;; + --init) + [ "$#" -ge 2 ] || die '--init requires a file' + INIT_SOURCE="$2" + shift 2 + ;; + --profiles) + [ "$#" -ge 2 ] || die '--profiles requires a file' + PROFILES_FILE="$2" + shift 2 + ;; + --checksum) + [ "$#" -ge 2 ] || die '--checksum requires a file' + CHECKSUM_FILE="$2" + shift 2 + ;; + --skip-zshrc) + SKIP_ZSHRC=1 + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) die "unknown plan option $1" ;; + esac + done + + [ -n "${PLAN_DIR}" ] || die '--plan is required' + case "${PROFILE}" in loader | annex | zunit) ;; *) die "unsupported profile ${PROFILE}" ;; esac + if [ -z "${CONFIG_HOME}" ]; then + if is_absolute_path "${XDG_CONFIG_HOME-}"; then + CONFIG_HOME="${XDG_CONFIG_HOME}/zi" + else + CONFIG_HOME="${HOME}/.config/zi" + fi + fi + is_absolute_path "${CONFIG_HOME}" || die '--config-home must be absolute' + if [ -z "${ZSHRC_PATH}" ]; then + PLAN_ZDOTDIR="${ZDOTDIR:-${HOME}}" + is_absolute_path "${PLAN_ZDOTDIR}" || die 'ZDOTDIR must be absolute when set' + ZSHRC_PATH="${PLAN_ZDOTDIR}/.zshrc" + fi + is_absolute_path "${ZSHRC_PATH}" || die '--zshrc must be absolute' + validate_text_path '--config-home' "${CONFIG_HOME}" + validate_text_path '--zshrc' "${ZSHRC_PATH}" + 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="" + LEGACY_DIRECT_REF="" + if [ "${SKIP_ZSHRC}" -eq 0 ]; then + extract_legacy_direct_values "${ZSHRC_PATH}" + fi + if [ "${LEGACY_DIRECT_FOUND}" -eq 1 ]; then + [ "${PLAN_HOME_EXPLICIT}" -eq 1 ] || PLAN_ZI_HOME="${LEGACY_DIRECT_HOME}" + [ "${PLAN_BIN_EXPLICIT}" -eq 1 ] || PLAN_BIN="${LEGACY_DIRECT_BIN}" + [ "${REF_EXPLICIT}" -eq 1 ] || REF="${LEGACY_DIRECT_REF}" + fi + validate_ref "${REF}" + [ -n "${PLAN_ZI_HOME}" ] || PLAN_ZI_HOME="$(resolve_zi_home)" + is_absolute_path "${PLAN_ZI_HOME}" || die '--zi-home must be absolute' + case "${PLAN_BIN}" in "" | . | .. | */*) die '--zi-bin-dir must be one directory name' ;; esac + validate_text_path '--zi-home' "${PLAN_ZI_HOME}" + validate_text_path '--zi-bin-dir' "${PLAN_BIN}" + CHECKOUT_PATH="${PLAN_ZI_HOME}/${PLAN_BIN}" + [ -r "${INIT_SOURCE}" ] || die "cannot read init asset ${INIT_SOURCE}" + [ -r "${PROFILES_FILE}" ] || die "cannot read profile table ${PROFILES_FILE}" + [ -r "${CHECKSUM_FILE}" ] || die "cannot read checksum file ${CHECKSUM_FILE}" + + EXPECTED_INIT="$(awk '$2 == "public/zsh/init.zsh" {print $1}' "${CHECKSUM_FILE}")" + [ -n "${EXPECTED_INIT}" ] || die 'checksum file has no public/zsh/init.zsh entry' + [ "$(sha256_file "${INIT_SOURCE}")" = "${EXPECTED_INIT}" ] || die 'init asset does not match the published checksum' + 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" \ + "${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" + # The single-quoted portions preserve the literal Zsh parameter text. + # shellcheck disable=SC2016 + command sed 's|: "${ZI\[STREAM\]:=main}"|: "${ZI[STREAM]:='"${REF}"'}"|' "${INIT_SOURCE}" >"${INIT_CONTENT}" + PRE_CONTENT="${PLAN_WORK}/pre.zsh" + PLAN_ZI_HOME_TEXT="$(zsh_single_quote "${PLAN_ZI_HOME}")" || die 'failed to serialize the Zi home' + CHECKOUT_PATH_TEXT="$(zsh_single_quote "${CHECKOUT_PATH}")" || die 'failed to serialize the checkout path' + REF_TEXT="$(zsh_single_quote "${REF}")" || die 'failed to serialize the ref' + { + printf '%s\n' '# Generated by Zi guided setup. Edit choices through a new plan.' + printf '%s\n' 'typeset -gA ZI' + printf 'ZI[HOME_DIR]=%s\n' "${PLAN_ZI_HOME_TEXT}" + printf 'ZI[BIN_DIR]=%s\n' "${CHECKOUT_PATH_TEXT}" + printf 'ZI[STREAM]=%s\n' "${REF_TEXT}" + } >"${PRE_CONTENT}" + + ENTRY_CONTENT="${PLAN_WORK}/setup.zsh" + write_setup_entrypoint "${CONFIG_HOME}" >"${ENTRY_CONTENT}" + MANAGED_BLOCK="${PLAN_WORK}/managed-block" + write_managed_block "${CONFIG_HOME}" >"${MANAGED_BLOCK}" + DIRECT_TEMPLATE="${PLAN_WORK}/legacy-direct" + LOADER_TEMPLATE="${PLAN_WORK}/legacy-loader" + LOADER_EXPLICIT_TEMPLATE="${PLAN_WORK}/legacy-loader-explicit" + ANNEX_TEMPLATE="${PLAN_WORK}/legacy-annex" + ZUNIT_TEMPLATE="${PLAN_WORK}/legacy-zunit" + if [ "${LEGACY_DIRECT_FOUND}" -eq 1 ]; then + write_legacy_direct "${LEGACY_DIRECT_HOME}" "${LEGACY_DIRECT_BIN}" "${LEGACY_DIRECT_REF}" >"${DIRECT_TEMPLATE}" + else + write_legacy_direct "${PLAN_ZI_HOME}" "${PLAN_BIN}" "${REF}" >"${DIRECT_TEMPLATE}" + fi + write_legacy_loader >"${LOADER_TEMPLATE}" + write_legacy_loader_explicit "${PLAN_ZI_HOME}" "${PLAN_BIN}" >"${LOADER_EXPLICIT_TEMPLATE}" + write_legacy_annex >"${ANNEX_TEMPLATE}" + write_legacy_zunit >"${ZUNIT_TEMPLATE}" + + DIRECT_MIGRATED=0 + EFFECTIVE_PROFILE="${PROFILE}" + ZSHRC_CONTENT="${PLAN_WORK}/zshrc" + if [ "${SKIP_ZSHRC}" -eq 0 ]; then + CURRENT_ZSHRC="${PLAN_WORK}/zshrc-current" + if [ -e "${ZSHRC_PATH}" ]; then command cp "${ZSHRC_PATH}" "${CURRENT_ZSHRC}"; else : >"${CURRENT_ZSHRC}"; fi + START_COUNT="$(grep -c '^# >>> zi setup >>>$' "${CURRENT_ZSHRC}" 2>/dev/null || true)" + END_COUNT="$(grep -c '^# <<< zi setup <<<$' "${CURRENT_ZSHRC}" 2>/dev/null || true)" + if [ "${START_COUNT}" -ne 0 ] || [ "${END_COUNT}" -ne 0 ]; then + [ "${START_COUNT}" -eq 1 ] && [ "${END_COUNT}" -eq 1 ] || die 'managed .zshrc markers are ambiguous' + CURRENT_BLOCK="${PLAN_WORK}/current-block" + extract_managed_block "${CURRENT_ZSHRC}" "${CURRENT_BLOCK}" + CURRENT_BLOCK_HASH="$(sha256_file "${CURRENT_BLOCK}")" + RECEIPT_BLOCK_HASH="$(receipt_value zshrc.block "${RECEIPT_PATH}" 2>/dev/null || true)" + if ! cmp -s "${CURRENT_BLOCK}" "${MANAGED_BLOCK}" && [ "${CURRENT_BLOCK_HASH}" != "${RECEIPT_BLOCK_HASH}" ]; then + diff_target "${CURRENT_BLOCK}" "${MANAGED_BLOCK}" "${PLAN_WORK}/empty" >&2 || true + die 'managed .zshrc block changed outside Zi setup; apply the printed patch manually or restore the receipt state' + fi + replace_managed_block "${CURRENT_ZSHRC}" "${MANAGED_BLOCK}" "${ZSHRC_CONTENT}" + else + WORKING_ZSHRC="${PLAN_WORK}/zshrc-working" + command cp "${CURRENT_ZSHRC}" "${WORKING_ZSHRC}" + for ENTRY in \ + "direct:${DIRECT_TEMPLATE}" \ + "loader:${LOADER_TEMPLATE}" \ + "loader-explicit:${LOADER_EXPLICIT_TEMPLATE}" \ + "annex:${ANNEX_TEMPLATE}" \ + "zunit:${ZUNIT_TEMPLATE}"; do + ENTRY_NAME="${ENTRY%%:*}" + ENTRY_TEMPLATE="${ENTRY#*:}" + STRIPPED="${PLAN_WORK}/zshrc-stripped" + STRIP_RESULT="${PLAN_WORK}/strip-result" + strip_exact_block "${WORKING_ZSHRC}" "${ENTRY_TEMPLATE}" "${STRIPPED}" "${STRIP_RESULT}" + MATCHES="$(cat "${STRIP_RESULT}")" + [ "${MATCHES}" -le 1 ] || die "legacy ${ENTRY_NAME} block appears more than once" + if [ "${MATCHES}" -eq 1 ]; then + command mv "${STRIPPED}" "${WORKING_ZSHRC}" + case "${ENTRY_NAME}" in + direct) DIRECT_MIGRATED=1 ;; + annex) [ "${EFFECTIVE_PROFILE}" = zunit ] || EFFECTIVE_PROFILE=annex ;; + zunit) EFFECTIVE_PROFILE=zunit ;; + esac + fi + done + if grep -E '^[[:space:]]*(source|\.)[[:space:]]+[^#]*(zi|init|zinit)\.zsh(["'"'"'[:space:]]|$)' "${WORKING_ZSHRC}" >/dev/null 2>&1 || + grep -E '^[[:space:]]*command git clone .*github\.com/z-shell/zi([. ]|$)' "${WORKING_ZSHRC}" >/dev/null 2>&1 || + grep -E '^[^#]*z-shell/z-a-meta-plugins([[:space:]]|$)' "${WORKING_ZSHRC}" >/dev/null 2>&1; then + die 'unrecognised Zi integration remains in .zshrc; refusing to initialise Zi twice' + fi + command cp "${WORKING_ZSHRC}" "${ZSHRC_CONTENT}" + if [ -s "${ZSHRC_CONTENT}" ]; then printf '\n' >>"${ZSHRC_CONTENT}"; fi + command cat "${MANAGED_BLOCK}" >>"${ZSHRC_CONTENT}" + fi + fi + + SHELL_CONTENT="${PLAN_WORK}/shell.zsh" + write_shell_fragment "${EFFECTIVE_PROFILE}" "${DIRECT_MIGRATED}" "${PROFILES_FILE}" "${SHELL_CONTENT}" + + command cat >"${PLAN_WORK}/artifact/plan.meta" </dev/null || true)" + case "${CHECKOUT_ORIGIN}" in + https://github.com/z-shell/zi | https://github.com/z-shell/zi.git | git@github.com:z-shell/zi | git@github.com:z-shell/zi.git) ;; + *) die "${CHECKOUT_PATH} is not a z-shell/zi checkout" ;; + esac + [ -f "${CHECKOUT_PATH}/zi.zsh" ] || die "${CHECKOUT_PATH} has no zi.zsh" + CHECKOUT_HEAD="$(command git -C "${CHECKOUT_PATH}" rev-parse HEAD)" || die 'cannot read checkout HEAD' + CHECKOUT_BRANCH="$(command git -C "${CHECKOUT_PATH}" symbolic-ref --quiet --short HEAD)" || die 'detached Zi checkout requires manual remediation' + printf '%s\n' existing >"${PLAN_WORK}/artifact/checkout/kind" + printf '%s\n' "${CHECKOUT_HEAD}" >"${PLAN_WORK}/artifact/checkout/head" + printf '%s\n' "${CHECKOUT_BRANCH}" >"${PLAN_WORK}/artifact/checkout/current-ref" + printf '%s\n' "${CHECKOUT_ORIGIN}" >"${PLAN_WORK}/artifact/checkout/origin" + elif [ -e "${CHECKOUT_PATH}" ]; then + die "${CHECKOUT_PATH} exists but is not a Zi checkout" + else + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/kind" + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/head" + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/current-ref" + printf '%s\n' missing >"${PLAN_WORK}/artifact/checkout/origin" + fi + printf '%s\n' "${REF}" >"${PLAN_WORK}/artifact/checkout/requested-ref" + + 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}" + add_target "${PLAN_WORK}/artifact" entry "${CONFIG_HOME}/setup.zsh" "${ENTRY_CONTENT}" 600 "${RECEIPT_PATH}" + if [ "${SKIP_ZSHRC}" -eq 0 ]; then + add_target "${PLAN_WORK}/artifact" zshrc "${ZSHRC_PATH}" "${ZSHRC_CONTENT}" preserve "${RECEIPT_PATH}" + printf '%s\n' "$(sha256_file "${MANAGED_BLOCK}")" >"${PLAN_WORK}/artifact/targets/zshrc/block-hash" + fi + + 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}" + printf '%s\n' "Plan SHA256: ${PLAN_ID}" + printf '%s\n' "Checkout phase: $(cat "${PLAN_DIR}/checkout/kind") ${CHECKOUT_PATH} -> ${REF}" + while IFS= read -r TARGET_ID; do + TARGET_PATH="$(cat "${PLAN_DIR}/targets/${TARGET_ID}/path")" + diff_target "${TARGET_PATH}" "${PLAN_DIR}/targets/${TARGET_ID}/content" "${PLAN_WORK}/empty" || true + done <"${PLAN_DIR}/targets/order" +} + +nearest_existing_parent() { + _parent_path="$1" + while [ ! -d "${_parent_path}" ]; do + _parent_next="$(dirname "${_parent_path}")" + [ "${_parent_next}" != "${_parent_path}" ] || break + _parent_path="${_parent_next}" + done + printf '%s\n' "${_parent_path}" +} + +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 +} + +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 + ACTIVE_LOCK="" + fi + if [ -n "${RESULT_WORK}" ] && [ -d "${RESULT_WORK}" ]; then + 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 + 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" + 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 + 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}" +} + +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' + if [ -n "${_validate_expect}" ]; then + [ "${_validate_expect}" = "${_validate_actual}" ] || die 'plan does not match --expect' + fi + printf '%s\n' "${_validate_actual}" +} + +apply_checkout() { + _checkout_plan="$1" + _checkout_path="$(plan_value checkout_path "${_checkout_plan}")" + _checkout_ref="$(cat "${_checkout_plan}/checkout/requested-ref")" + _checkout_kind="$(cat "${_checkout_plan}/checkout/kind")" + _checkout_head="$(cat "${_checkout_plan}/checkout/head")" + _checkout_current_ref="$(cat "${_checkout_plan}/checkout/current-ref")" + _checkout_origin="$(cat "${_checkout_plan}/checkout/origin")" + _checkout_parent="$(dirname "${_checkout_path}")" + _checkout_existing_parent="$(nearest_existing_parent "${_checkout_parent}")" + 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 + event_start checkout-sync "synchronizing checkout" + + case "${_checkout_kind}" in + missing) + ! path_exists "${_checkout_path}" || die "checkout appeared after planning: ${_checkout_path}" + command mkdir -p "${_checkout_parent}" + _checkout_tmp="${_checkout_path}.zi-setup-new.$$" + ! 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}" + fi + command mv "${_checkout_tmp}" "${_checkout_path}" + ;; + existing) + [ -d "${_checkout_path}/.git" ] || die 'checkout disappeared after planning' + [ "$(command git -C "${_checkout_path}" rev-parse HEAD)" = "${_checkout_head}" ] || die 'checkout HEAD changed after planning' + [ "$(command git -C "${_checkout_path}" symbolic-ref --quiet --short HEAD)" = "${_checkout_current_ref}" ] || die 'checkout ref changed after planning' + [ "$(command git -C "${_checkout_path}" remote get-url origin 2>/dev/null || true)" = "${_checkout_origin}" ] || die 'checkout origin changed after planning' + [ -f "${_checkout_path}/zi.zsh" ] || die 'checkout zi.zsh disappeared after planning' + 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' + } + ;; + *) die "invalid checkout kind ${_checkout_kind}" ;; + esac + printf '%s\n' "Checkout phase applied: ${_checkout_path}" + event_finish succeeded "checkout completed" +} + +validate_target_precondition() { + _validate_target_plan="$1" + _validate_target_id="$2" + _validate_target_dir="${_validate_target_plan}/targets/${_validate_target_id}" + _validate_target_path="$(cat "${_validate_target_dir}/path")" + _validate_target_kind="$(cat "${_validate_target_dir}/kind")" + _validate_target_expected="$(cat "${_validate_target_dir}/expected")" + if [ "${_validate_target_kind}" = symlink ] || [ -L "${_validate_target_path}" ]; then + if [ "${_validate_target_id}" = zshrc ]; then + diff_target "${_validate_target_path}" "${_validate_target_dir}/content" "${_validate_target_plan}/targets/.empty" >&2 || true + fi + die "refusing symlink target ${_validate_target_path}; apply the printed patch to its target manually" + fi + _validate_target_actual="$(current_hash "${_validate_target_path}")" + [ "${_validate_target_actual}" = "${_validate_target_expected}" ] || + die "target changed after planning: ${_validate_target_path}" + _validate_target_parent="$(nearest_existing_parent "$(dirname "${_validate_target_path}")")" + [ -w "${_validate_target_parent}" ] || die "target parent is not writable: ${_validate_target_parent}" +} + +install_target() { + _install_plan="$1" + _install_id="$2" + _install_dir="${_install_plan}/targets/${_install_id}" + _install_path="$(cat "${_install_dir}/path")" + _install_mode="$(cat "${_install_dir}/mode")" + _install_parent="$(dirname "${_install_path}")" + command mkdir -p "${_install_parent}" + _install_tmp="$(mktemp "${_install_path}.zi-setup.XXXXXX")" || die "cannot stage ${_install_path}" + if [ -e "${_install_path}" ]; then + command cp -p "${_install_path}" "${_install_tmp}" + fi + command cp "${_install_dir}/content" "${_install_tmp}" + case "${_install_mode}" in + 755) command chmod 755 "${_install_tmp}" ;; + 600) command chmod 600 "${_install_tmp}" ;; + preserve) [ -e "${_install_path}" ] || command chmod 600 "${_install_tmp}" ;; + *) + command rm -f "${_install_tmp}" + die "invalid target mode ${_install_mode}" + ;; + esac + command mv "${_install_tmp}" "${_install_path}" +} + +apply_files() { + _files_plan="$1" + _files_plan_id="$2" + _files_config="$(plan_value config_home "${_files_plan}")" + _files_receipt="$(plan_value receipt_path "${_files_plan}")" + _files_config_parent="$(nearest_existing_parent "$(dirname "${_files_config}")")" + 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 + event_start write-files "writing 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" + + command mkdir -p "$(dirname "${_files_receipt}")" + _files_receipt_tmp="$(mktemp "${_files_receipt}.zi-setup.XXXXXX")" || die 'cannot stage receipt' + { + printf '%s\n' 'format=zi-setup-receipt-v1' + printf 'plan=%s\n' "${_files_plan_id}" + printf 'profile=%s\n' "$(plan_value profile "${_files_plan}")" + while IFS= read -r _files_id; do + _files_path="$(cat "${_files_plan}/targets/${_files_id}/path")" + printf 'target.%s=%s\n' "${_files_id}" "$(sha256_file "${_files_path}")" + done <"${_files_plan}/targets/order" + if [ -f "${_files_plan}/targets/zshrc/block-hash" ]; then + printf 'zshrc.block=%s\n' "$(cat "${_files_plan}/targets/zshrc/block-hash")" + fi + case "$(plan_value profile "${_files_plan}")" in + annex | zunit) printf '%s\n' 'deferred-recipes=first-shell-start' ;; + *) printf '%s\n' 'deferred-recipes=none' ;; + esac + } >"${_files_receipt_tmp}" + command chmod 600 "${_files_receipt_tmp}" + command mv "${_files_receipt_tmp}" "${_files_receipt}" + printf '%s\n' "Files phase applied. Receipt: ${_files_receipt}" + event_finish succeeded "files completed" +} + +apply_command() { + DIE_STATUS=2 + APPLY_PLAN="" + APPLY_PHASE="" + APPLY_EXPECT="" + APPLY_RESULT="" + APPLY_EVENTS="" + while [ "$#" -gt 0 ]; do + case "$1" in + --plan) + [ "$#" -ge 2 ] || die '--plan requires a directory' + APPLY_PLAN="$2" + shift 2 + ;; + --phase) + [ "$#" -ge 2 ] || die '--phase requires checkout or files' + APPLY_PHASE="$2" + shift 2 + ;; + --expect) + [ "$#" -ge 2 ] || die '--expect requires a hash' + APPLY_EXPECT="$2" + shift 2 + ;; + --result) + [ "$#" -ge 2 ] || die '--result requires a directory' + APPLY_RESULT="$2" + shift 2 + ;; + --events) + [ "$#" -ge 2 ] || die '--events requires a directory' + APPLY_EVENTS="$2" + shift 2 + ;; + --help | -h) + usage + exit 0 + ;; + *) die "unknown apply option $1" ;; + esac + done + [ -n "${APPLY_PLAN}" ] || die '--plan is required' + case "${APPLY_PHASE}" in checkout | files) ;; *) die '--phase must be checkout or files' ;; esac + 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 + 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 + 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 ] || { + usage >&2 + exit 2 +} +COMMAND="$1" +shift +case "${COMMAND}" in +describe) describe_command "$@" ;; +plan) plan_command "$@" ;; +apply) apply_command "$@" ;; +--help | -h | help) usage ;; +*) + usage >&2 + die "unknown command ${COMMAND}" + ;; +esac diff --git a/internal/engine/bundled/public/zsh/init.zsh b/internal/engine/bundled/public/zsh/init.zsh new file mode 100644 index 0000000..ce998e1 --- /dev/null +++ b/internal/engine/bundled/public/zsh/init.zsh @@ -0,0 +1,339 @@ +#!/usr/bin/env zsh +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et +# +# Zi Loader — bootstrap and source the Zi plugin manager. +# +# Execution profile: startup-file. This is sourced early in .zshrc and +# deliberately makes phase-owned global effects; it is not a caller-preserving +# sourced library. +# +# Sourcing this file only defines zzinit(). Nothing is cloned, sourced, or +# written until zzinit() is called: +# +# if [[ -n ${XDG_CONFIG_HOME:-} && $XDG_CONFIG_HOME == /* ]]; then +# ZI_LOADER_CONFIG_HOME="$XDG_CONFIG_HOME/zi" +# else +# ZI_LOADER_CONFIG_HOME="$HOME/.config/zi" +# fi +# if [[ -r "$ZI_LOADER_CONFIG_HOME/init.zsh" ]]; then +# source "$ZI_LOADER_CONFIG_HOME/init.zsh" && zzinit +# fi +# unset ZI_LOADER_CONFIG_HOME +# +# Documented global effects of zzinit(): +# - sources zi.zsh, which owns its own documented global effects +# - registers the zi completion in _comps when compinit has already run +# - appends to module_path and loads zi/zpmod when the module is built +# - on first run, clones ZI[REPOSITORY] into ZI[BIN_DIR] +# - unsets its own helper functions on success +# +# All helper functions and zzinit() itself are removed after a successful run. +# On failure they are kept so the user can read the diagnostics and retry. + +# ── Zi Configuration ────────────────────────────────────────────────────────── + +typeset -ghA ZI + +# Settings this loader must know before Zi exists, because they decide what to +# clone and where. See https://wiki.zshell.dev/docs/guides/customization +: "${ZI[REPOSITORY]:=https://github.com/z-shell/zi.git}" +: "${ZI[STREAM]:=main}" + +# The loader needs HOME_DIR and BIN_DIR before Zi exists so it knows where to +# find or clone zi.zsh. Mirror Zi's home-resolution contract exactly, without +# creating directories: explicit values win, a recognized legacy home stays +# active, and fresh installs use an absolute XDG data base or its fallback. +# Cache and config remain unset here and are resolved by zi.zsh itself. +() { + builtin emulate -L zsh + + local data_base legacy_home xdg_home marker requested_bin="${ZI[BIN_DIR]}" + integer legacy_present=0 xdg_present=0 + + if [[ -n $XDG_DATA_HOME && $XDG_DATA_HOME == /* ]]; then + data_base="$XDG_DATA_HOME" + else + data_base="${HOME}/.local/share" + fi + legacy_home="${HOME}/.zi" + xdg_home="${data_base}/zi" + + if [[ -z ${ZI[HOME_DIR]} ]]; then + for marker in bin/zi.zsh plugins snippets completions zmodules; do + if [[ -e "${legacy_home}/${marker}" ]]; then + legacy_present=1 + break + fi + done + for marker in bin/zi.zsh plugins snippets completions zmodules; do + if [[ -e "${xdg_home}/${marker}" ]]; then + xdg_present=1 + break + fi + done + + if (( legacy_present && xdg_present )); then + if [[ $requested_bin == "${xdg_home}/bin" || $requested_bin == "${xdg_home}/bin/"* ]] || + [[ -z $requested_bin && -e "${xdg_home}/bin/zi.zsh" && ! -e "${legacy_home}/bin/zi.zsh" ]]; then + ZI[HOME_DIR]="$xdg_home" + ZI[HOME_LAYOUT]=ambiguous-xdg + else + ZI[HOME_DIR]="$legacy_home" + ZI[HOME_LAYOUT]=ambiguous-legacy + fi + elif (( legacy_present )); then + ZI[HOME_DIR]="$legacy_home" + ZI[HOME_LAYOUT]=legacy + else + ZI[HOME_DIR]="$xdg_home" + ZI[HOME_LAYOUT]=xdg + fi + elif [[ -z ${ZI[HOME_LAYOUT]} ]]; then + ZI[HOME_LAYOUT]=explicit + fi + + [[ -n ${ZI[BIN_DIR]} ]] || ZI[BIN_DIR]="${ZI[HOME_DIR]}/bin" +} + +# Retained for compatibility: user configuration and third-party plugins read +# this value directly, so it must be defined rather than merely defaulted +# inside zi.zsh. +: "${ZI[MUTE_WARNINGS]:=0}" + +# NOTE ON DEFAULTS +# Every other ZI[...] key is owned by zi.zsh. Do not duplicate defaults here; +# set a value in .zshrc before this file is sourced if you want to override it. +# The full set zi.zsh honours includes: +# +# Paths CACHE_DIR CONFIG_DIR COMPLETIONS_DIR PLUGINS_DIR SNIPPETS_DIR +# SERVICES_DIR THEMES_DIR ZMODULES_DIR MAN_DIR LOG_DIR MAIL_DIR +# CDPATH_DIR ZCOMPDUMP_PATH ZPFX +# Behaviour OPTIMIZE_OUT_DISK_ACCESSES COMPINIT_OPTS INTERNAL_ALIASES +# PKG_OWNER +# +# Reference: https://wiki.zshell.dev/docs/guides/customization#customizing-paths + +# Loader behaviour toggles. +# +# ZI[LOADER_HISTORY] 1 (default) applies the history defaults below. Set to 0 +# before sourcing to leave HISTFILE, SAVEHIST, and HISTSIZE +# entirely to your own configuration. +: "${ZI[LOADER_HISTORY]:=1}" + +# History defaults. These are a convenience for the installer's minimal .zshrc +# and are unrelated to loading Zi; ZI[LOADER_HISTORY]=0 disables them. +if [[ ${ZI[LOADER_HISTORY]} == 1 ]]; then + : "${HISTFILE:=${XDG_STATE_HOME:-$HOME/.local/state}/zsh/history}" + [[ -e "$HISTFILE" ]] || { command mkdir -p "${HISTFILE:h}" && command touch "$HISTFILE"; } + [[ -w "$HISTFILE" ]] && typeset -gx SAVEHIST=440000 HISTSIZE=441000 +fi + +# ── Bootstrap Helpers ───────────────────────────────────────────────────────── + +# Report a loader failure on stderr. +_zi_err() { + builtin emulate -L zsh + builtin print -u2 -P "%F{160}▓▒░ Zi loader: %f%b$1" + return 0 +} + +# Fetch content from a URL to stdout. +_zi_fetch() { + builtin emulate -L zsh + if (( $+commands[curl] )); then + command curl -fsSL "$1" + elif (( $+commands[wget] )); then + command wget -qO- "$1" + else + _zi_err "neither curl nor wget is available; cannot download." + return 255 + fi +} + +# Reject a stream name that git would not accept as a branch, before it reaches +# `git clone --branch` and produces an unattributed git error. +_zi_check_stream() { + builtin emulate -L zsh + local stream="${ZI[STREAM]}" + if [[ -z $stream ]]; then + _zi_err "ZI[STREAM] is empty; set it to a branch or tag name." + return 1 + fi + if [[ $stream == -* || $stream == *[[:space:]]* ]]; then + _zi_err "ZI[STREAM] is not a valid ref name: ${(qqq)stream}" + return 1 + fi + if (( $+commands[git] )) && + ! command git check-ref-format --allow-onelevel "$stream" 2>/dev/null; then + _zi_err "ZI[STREAM] is not a valid ref name: ${(qqq)stream}" + return 1 + fi + return 0 +} + +# Clone the Zi repository if it is not already present. +_zi_setup() { + builtin emulate -L zsh + builtin autoload colors; colors + local -a git_refs + local tmp_dir show_process process_url + integer clone_status=0 + + [[ -f "${ZI[BIN_DIR]}/zi.zsh" ]] && return 0 + + if (( ! $+commands[git] )); then + _zi_err "git is required to install Zi but was not found in PATH." + return 1 + fi + _zi_check_stream || return 1 + + # A private, unpredictable directory. The progress filter is downloaded and + # then executed, so it must never live at a fixed, world-writable path where + # another user could pre-place a file for us to run. + tmp_dir="$(command mktemp -d "${TMPDIR:-/tmp}/zi-loader.XXXXXX" 2>/dev/null)" || { + _zi_err "could not create a private temporary directory." + return 1 + } + show_process="${tmp_dir}/git-process-output.zsh" + process_url="https://raw.githubusercontent.com/z-shell/zi/main/lib/zsh/git-process-output.zsh" + + # The filter is cosmetic. If it cannot be fetched, fall back to plain output + # rather than failing the install. + if _zi_fetch "$process_url" > "$show_process" && [[ -s $show_process ]]; then + command chmod u+x "$show_process" + else + show_process="" + fi + + (( $+commands[clear] )) && command clear + builtin print -P "%F{33}▓▒░ %F{160}Installing interactive & feature-rich plugin manager (%F{33}z-shell/zi%F{160})%f%b…\n" + + if command mkdir -p "${ZI[BIN_DIR]}"; then + # --filter=blob:none keeps the clone small without making it shallow. + # zi.zsh derives ZI[VERSION] from `git describe --tags`, which a --depth=1 + # clone would degrade to a bare short SHA. + command git clone --verbose --progress \ + --filter=blob:none --single-branch \ + --branch "${ZI[STREAM]}" "${ZI[REPOSITORY]}" "${ZI[BIN_DIR]}" \ + |& { [[ -n $show_process ]] && command "$show_process" || command cat; } + clone_status=${pipestatus[1]} + else + _zi_err "could not create ${(qqq)ZI[BIN_DIR]}" + clone_status=1 + fi + + command rm -rf -- "$tmp_dir" + + if (( clone_status != 0 )) || [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then + builtin print -P "%F{160}▓▒░ The clone has failed…%f%b" + builtin print -P "%F{160}▓▒░ %F{33} Please report the issue: %F{226}https://github.com/z-shell/zi/issues/new%f%b" + return 1 + fi + + # Scope the permission fix to the clone. ZI[HOME_DIR] also holds plugins, + # snippets, and other user data that this loader does not own. + command chmod -R go-w "${ZI[BIN_DIR]}" + git_refs=("${(f@)$(builtin cd -q "${ZI[BIN_DIR]}" && command git log --color --graph --abbrev-commit \ + --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' | command head -5)}") + builtin print + builtin print -P "%F{33}▓▒░ %F{34}Successfully installed %F{160}(%F{33}z-shell/zi%F{160})%f%b\n" + builtin print -rl -- "${git_refs[@]}" + return 0 +} + +# Source zi.zsh, bootstrapping first if needed. +# +# zi.zsh's top level makes intentional global changes: it sets AUTO_CD when +# ZI[CDPATH_DIR] exists and marks path, manpath, cdpath, mailpath, fpath, and +# logpath as exported and unique. Wrapping the source in `emulate -L zsh` would +# localize those to this function and silently discard them, so instead only +# the options that would corrupt zi.zsh's own parsing are neutralized, then +# restored to the caller's values. +_zi_source() { + local -A caller_opts + local opt + local -a guard=( + sh_word_split ksh_arrays ksh_glob glob_subst rc_expand_param + err_exit err_return no_unset warn_create_global + ) + integer src_status + + if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then + _zi_setup || return 1 + # Guard: if setup reported success but zi.zsh is still missing, do not recurse. + if [[ ! -f "${ZI[BIN_DIR]}/zi.zsh" ]]; then + _zi_err "setup reported success but ${(qqq)ZI[BIN_DIR]}/zi.zsh is missing." + return 1 + fi + fi + + for opt in "${guard[@]}"; do + caller_opts[$opt]="${options[$opt]}" + done + builtin setopt no_sh_word_split no_ksh_arrays no_ksh_glob no_glob_subst \ + no_rc_expand_param no_err_exit no_err_return unset no_warn_create_global + + builtin source "${ZI[BIN_DIR]}/zi.zsh" + src_status=$? + + for opt in "${guard[@]}"; do + options[$opt]="${caller_opts[$opt]}" + done + + (( src_status == 0 )) || _zi_err "sourcing zi.zsh failed with status ${src_status}." + return "$src_status" +} + +# Load the zpmod module if it has been built. +_zi_pmod() { + builtin emulate -L zsh + local module_dir="${ZI[ZMODULES_DIR]:-${ZI[HOME_DIR]}/zmodules}/zpmod/Src" + [[ -f "${module_dir}/zi/zpmod.so" ]] || return 0 + + typeset -gU module_path + module_path+=( "$module_dir" ) + if ! zmodload zi/zpmod 2>/dev/null; then + [[ ${ZI[MUTE_WARNINGS]} == 1 ]] || + _zi_err "zpmod.so is present but zmodload zi/zpmod failed; rebuild it with \`zi module build\`, then run \`zzinit\` again." + return 1 + fi + return 0 +} + +# Register the Zi completion if the completion system is already active. +_zi_comps() { + builtin emulate -L zsh + (( ${+_comps} )) || return 0 + (( ${+_comps[zi]} )) || _comps[zi]=_zi + return 0 +} + +# ── Entry Point ─────────────────────────────────────────────────────────────── + +zzinit() { + integer status_source status_comps status_pmod + + # Sourcing Zi is the only hard prerequisite. Completion registration and the + # optional module are independent: neither should be skipped because the + # other reported a problem. + _zi_source + status_source=$? + if (( status_source != 0 )); then + _zi_err "Zi was not loaded. Fix the problem above, then run \`zzinit\` again." + return "$status_source" + fi + + _zi_comps + status_comps=$? + _zi_pmod + status_pmod=$? + + # Zi is usable at this point, but an optional stage reported a problem. Keep + # the helpers so the diagnostics above can be acted on and zzinit re-run. + (( status_comps == 0 && status_pmod == 0 )) || return 1 + + # Helpers are only removed on success so a failed run stays retryable. + unset -f _zi_err _zi_fetch _zi_check_stream _zi_setup _zi_source _zi_comps _zi_pmod zzinit 2>/dev/null + return 0 +} diff --git a/internal/engine/client.go b/internal/engine/client.go index 361bfe8..5688209 100644 --- a/internal/engine/client.go +++ b/internal/engine/client.go @@ -8,8 +8,10 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" + "time" "github.com/z-shell/zi-setup/internal/contract" ) @@ -33,6 +35,7 @@ type Client struct { EnginePath string ShellPath string TempParent string + Events bool } type Output struct { @@ -64,24 +67,18 @@ type Workspace struct { sequence int planPath string plan contract.Plan + events bool +} + +func (w *Workspace) Configure(ref string, skipZshrc bool) error { + w.inputs.Ref = ref + w.inputs.SkipZshrc = skipZshrc + w.planPath = "" + w.plan = contract.Plan{} + return nil } func (c Client) NewWorkspace(inputs Inputs) (*Workspace, error) { - if c.EnginePath == "" { - return nil, errors.New("engine path is required") - } - enginePath, err := filepath.Abs(c.EnginePath) - if err != nil { - return nil, fmt.Errorf("resolve engine path: %w", err) - } - info, err := os.Stat(enginePath) - if err != nil { - return nil, fmt.Errorf("stat engine: %w", err) - } - if !info.Mode().IsRegular() { - return nil, fmt.Errorf("engine is not a regular file: %s", enginePath) - } - c.EnginePath = enginePath if c.ShellPath == "" { c.ShellPath = "sh" } @@ -93,7 +90,31 @@ func (c Client) NewWorkspace(inputs Inputs) (*Workspace, error) { os.RemoveAll(root) return nil, fmt.Errorf("restrict artifact root: %w", err) } - return &Workspace{client: c, inputs: inputs, root: root}, nil + bundled := c.EnginePath == "" + if bundled { + c.EnginePath, err = extractBundledEngine(root, &inputs) + if err != nil { + os.RemoveAll(root) + return nil, fmt.Errorf("prepare bundled engine: %w", err) + } + } else { + enginePath, resolveErr := filepath.Abs(c.EnginePath) + if resolveErr != nil { + os.RemoveAll(root) + return nil, fmt.Errorf("resolve engine path: %w", resolveErr) + } + info, statErr := os.Stat(enginePath) + if statErr != nil { + os.RemoveAll(root) + return nil, fmt.Errorf("stat engine: %w", statErr) + } + if !info.Mode().IsRegular() { + os.RemoveAll(root) + return nil, fmt.Errorf("engine is not a regular file: %s", enginePath) + } + c.EnginePath = enginePath + } + return &Workspace{client: c, inputs: inputs, root: root, events: bundled || c.Events}, nil } func (w *Workspace) Close() error { @@ -139,7 +160,7 @@ func (w *Workspace) Plan(ctx context.Context, profile string) (contract.Plan, Ou return plan, output, nil } -func (w *Workspace) Apply(ctx context.Context, phase string) (contract.Result, Output, error) { +func (w *Workspace) Apply(ctx context.Context, phase string, onEvent func(contract.ApplyEvent)) (contract.Result, Output, error) { if w.planPath == "" || w.plan.ID == "" { return contract.Result{}, Output{}, errors.New("no reviewed plan is available") } @@ -148,7 +169,12 @@ func (w *Workspace) Apply(ctx context.Context, phase string) (contract.Result, O } resultPath := w.nextPath("result-" + phase) args := []string{"apply", "--plan", w.planPath, "--phase", phase, "--expect", w.plan.ID, "--result", resultPath} - output, runErr := w.run(ctx, args) + var eventPath string + if w.events { + eventPath = w.nextPath("events-" + phase) + args = append(args, "--events", eventPath) + } + output, runErr := w.runWithEvents(ctx, args, eventPath, onEvent) result, readErr := contract.ReadResult(resultPath) if readErr != nil { if runErr != nil { @@ -207,28 +233,133 @@ func appendValue(args []string, name, value string) []string { } func (w *Workspace) run(ctx context.Context, args []string) (Output, error) { + return w.runWithEvents(ctx, args, "", nil) +} + +func (w *Workspace) runWithEvents(ctx context.Context, args []string, eventPath string, onEvent func(contract.ApplyEvent)) (Output, error) { commandArgs := append([]string{w.client.EnginePath}, args...) cmd := exec.CommandContext(ctx, w.client.ShellPath, commandArgs...) cmd.Env = environment(w.inputs.Home) var stdout, stderr limitedBuffer cmd.Stdout = &stdout cmd.Stderr = &stderr - err := cmd.Run() + err := cmd.Start() + if err == nil { + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + ticker := time.NewTicker(40 * time.Millisecond) + defer ticker.Stop() + nextEvent := 1 + for { + select { + case err = <-done: + if eventPath != "" { + finalNext, eventErr := readEvents(eventPath, nextEvent, onEvent) + if eventErr == nil && err == nil && finalNext != 3 { + eventErr = fmt.Errorf("successful apply published %d events, expected 2", finalNext-1) + } + if eventErr == nil && err != nil && ctx.Err() == nil && finalNext == 2 { + eventErr = errors.New("apply published a started event without a terminal event") + } + if eventErr != nil { + err = fmt.Errorf("read apply events: %w", eventErr) + } + } + goto finished + case <-ticker.C: + if eventPath == "" { + continue + } + var eventErr error + nextEvent, eventErr = readEvents(eventPath, nextEvent, onEvent) + if eventErr != nil { + _ = cmd.Process.Kill() + <-done + err = fmt.Errorf("read apply events: %w", eventErr) + goto finished + } + } + } + } + +finished: output := Output{Stdout: stdout.String(), Stderr: stderr.String()} if err == nil { return output, nil } exitCode := 1 var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = exitErr.ExitCode() - } else if ctx.Err() != nil { + if ctx.Err() != nil { exitCode = 6 + } else if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() } output.ExitCode = exitCode return output, &CommandError{Command: args[0], ExitCode: exitCode, Stderr: strings.TrimSpace(output.Stderr), Err: err} } +func readEvents(path string, next int, onEvent func(contract.ApplyEvent)) (int, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return next, nil + } + if err != nil { + return next, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return next, fmt.Errorf("event root must be a directory, got %s", info.Mode()) + } + entries, err := os.ReadDir(path) + if err != nil { + return next, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".tmp-") { + continue + } + if !entry.IsDir() { + return next, fmt.Errorf("unexpected event entry %q", entry.Name()) + } + names = append(names, entry.Name()) + } + sort.Strings(names) + for _, name := range names { + sequence, parseErr := strconv.Atoi(name) + if parseErr != nil || len(name) != 6 || fmt.Sprintf("%06d", sequence) != name || sequence < 1 { + return next, fmt.Errorf("invalid event sequence %q", name) + } + if sequence < next { + continue + } + if sequence != next { + return next, fmt.Errorf("event sequence jumped from %06d to %s", next, name) + } + event, readErr := contract.ReadApplyEvent(filepath.Join(path, name)) + if readErr != nil { + return next, fmt.Errorf("read event %s: %w", name, readErr) + } + event.Sequence = sequence + switch sequence { + case 1: + if event.Status != "started" { + return next, fmt.Errorf("first event has status %q, expected started", event.Status) + } + case 2: + if event.Status != "succeeded" && event.Status != "failed" { + return next, fmt.Errorf("terminal event has status %q", event.Status) + } + default: + return next, fmt.Errorf("unexpected event %06d after terminal event", sequence) + } + if onEvent != nil { + onEvent(event) + } + next++ + } + return next, nil +} + func environment(home string) []string { if home == "" { return os.Environ() diff --git a/internal/engine/client_test.go b/internal/engine/client_test.go index b07dc57..4ca53a1 100644 --- a/internal/engine/client_test.go +++ b/internal/engine/client_test.go @@ -2,12 +2,126 @@ package engine import ( "context" + "errors" "os" "path/filepath" "strings" "testing" + + "github.com/z-shell/zi-setup/internal/contract" ) +func TestReadEventsDeliversAtomicDirectoriesInSequence(t *testing.T) { + t.Parallel() + root := t.TempDir() + for sequence, status := range []string{"started", "succeeded"} { + path := filepath.Join(root, "00000"+string(rune('1'+sequence))) + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{ + "format": "zi-setup-event-v1", "phase": "checkout", "operation": "checkout-sync", + "status": status, "detail": "fixture", + } { + if err := os.WriteFile(filepath.Join(path, name), []byte(value+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + } + var events []contract.ApplyEvent + next, err := readEvents(root, 1, func(event contract.ApplyEvent) { events = append(events, event) }) + if err != nil { + t.Fatal(err) + } + if next != 3 || len(events) != 2 || events[0].Sequence != 1 || events[1].Status != "succeeded" { + t.Fatalf("next = %d, events = %#v", next, events) + } +} + +func TestRunWithEventsRejectsMissingTerminalEvent(t *testing.T) { + t.Parallel() + home := t.TempDir() + enginePath := filepath.Join(home, "setup.sh") + if err := os.WriteFile(enginePath, []byte("# fixture\n"), 0o600); err != nil { + t.Fatal(err) + } + shellPath := filepath.Join(home, "fake-shell") + fixture := `#!/bin/sh +set -eu +shift +events= +while [ "$#" -gt 0 ]; do + if [ "$1" = --events ]; then events=$2; shift 2; else shift; fi +done +event=$events/000001 +mkdir -p "$event" +printf '%s\n' zi-setup-event-v1 >"$event/format" +printf '%s\n' checkout >"$event/phase" +printf '%s\n' checkout-sync >"$event/operation" +printf '%s\n' started >"$event/status" +printf '%s\n' started >"$event/detail" +exit 5 +` + if err := os.WriteFile(shellPath, []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + workspace, err := (Client{EnginePath: enginePath, ShellPath: shellPath, TempParent: home}).NewWorkspace(Inputs{Home: home}) + if err != nil { + t.Fatal(err) + } + defer workspace.Close() + events := filepath.Join(home, "events") + _, err = workspace.runWithEvents(context.Background(), []string{"apply", "--events", events}, events, nil) + if err == nil || !strings.Contains(err.Error(), "started event without a terminal event") { + t.Fatalf("missing terminal event error = %v", err) + } +} + +func TestRunWithEventsAllowsMissingTerminalOnContextCancellation(t *testing.T) { + t.Parallel() + home := t.TempDir() + enginePath := filepath.Join(home, "setup.sh") + if err := os.WriteFile(enginePath, []byte("# fixture\n"), 0o600); err != nil { + t.Fatal(err) + } + shellPath := filepath.Join(home, "fake-shell") + fixture := `#!/bin/sh +set -eu +shift +events= +while [ "$#" -gt 0 ]; do + if [ "$1" = --events ]; then events=$2; shift 2; else shift; fi +done +event=$events/000001 +mkdir -p "$event" +printf '%s\n' zi-setup-event-v1 >"$event/format" +printf '%s\n' checkout >"$event/phase" +printf '%s\n' checkout-sync >"$event/operation" +printf '%s\n' started >"$event/status" +printf '%s\n' started >"$event/detail" +while :; do :; done +` + if err := os.WriteFile(shellPath, []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + workspace, err := (Client{EnginePath: enginePath, ShellPath: shellPath, TempParent: home}).NewWorkspace(Inputs{Home: home}) + if err != nil { + t.Fatal(err) + } + defer workspace.Close() + events := filepath.Join(home, "events") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _, err = workspace.runWithEvents(ctx, []string{"apply", "--events", events}, events, func(contract.ApplyEvent) { cancel() }) + var commandErr *CommandError + if !errors.As(err, &commandErr) || commandErr.ExitCode != 6 { + t.Fatalf("cancellation error = %v", err) + } + if strings.Contains(err.Error(), "started event without a terminal event") { + t.Fatalf("cancellation treated missing terminal event as a contract error: %v", err) + } +} + func TestWorkspacePassesExactPlanHashToBothPhases(t *testing.T) { t.Parallel() home := t.TempDir() @@ -47,8 +161,15 @@ func TestWorkspacePassesExactPlanHashToBothPhases(t *testing.T) { if plan.ID != strings.Repeat("a", 64) || plan.Meta.Profile != "annex" { t.Fatalf("plan = %#v", plan) } + if err := workspace.Configure("release/ref", true); err != nil { + t.Fatal(err) + } + plan, _, err = workspace.Plan(context.Background(), "annex") + if err != nil { + t.Fatal(err) + } for _, phase := range []string{"checkout", "files"} { - result, _, err := workspace.Apply(context.Background(), phase) + result, _, err := workspace.Apply(context.Background(), phase, nil) if err != nil { t.Fatalf("apply %s: %v", phase, err) } @@ -64,7 +185,7 @@ func TestWorkspacePassesExactPlanHashToBothPhases(t *testing.T) { if strings.Count(text, "--expect="+plan.ID) != 2 { t.Fatalf("exact plan hash was not passed twice:\n%s", text) } - for _, value := range []string{"--config-home=" + filepath.Join(home, "config home"), "--zshrc=" + filepath.Join(home, "dot files", ".zshrc"), "--ref=feature/ref"} { + for _, value := range []string{"--config-home=" + filepath.Join(home, "config home"), "--zshrc=" + filepath.Join(home, "dot files", ".zshrc"), "--ref=feature/ref", "--ref=release/ref", "--skip-zshrc"} { if !strings.Contains(text, value) { t.Errorf("invocation log missing %q:\n%s", value, text) } @@ -99,7 +220,7 @@ func TestWorkspaceRejectsMismatchedResultPhase(t *testing.T) { if _, _, err := workspace.Plan(context.Background(), "loader"); err != nil { t.Fatal(err) } - if _, _, err := workspace.Apply(context.Background(), "checkout"); err == nil || !strings.Contains(err.Error(), `checkout apply returned a "files" result`) { + if _, _, err := workspace.Apply(context.Background(), "checkout", nil); err == nil || !strings.Contains(err.Error(), `checkout apply returned a "files" result`) { t.Fatalf("phase mismatch error = %v", err) } } diff --git a/internal/plain/run.go b/internal/plain/run.go index 314990b..e596ba7 100644 --- a/internal/plain/run.go +++ b/internal/plain/run.go @@ -68,7 +68,14 @@ func Run(ctx context.Context, session *workflow.Session, options Options) error return fmt.Errorf("plan was not approved") } } - applyErr := session.ApplyReviewedPlan(ctx) + applyErr := session.ApplyReviewedPlan(ctx, func(event contract.ApplyEvent) { + fmt.Fprintf(options.Output, "[%s] %s %s: %s\n", + presentation.SafeText(event.Phase), + presentation.SafeText(event.Operation), + presentation.SafeText(event.Status), + presentation.SafeText(event.Detail), + ) + }) if applyErr == nil { if err := session.VerifyReopen(ctx); err != nil { applyErr = fmt.Errorf("verify reopen: %w", err) diff --git a/internal/plain/run_test.go b/internal/plain/run_test.go index d9a3103..9d23c15 100644 --- a/internal/plain/run_test.go +++ b/internal/plain/run_test.go @@ -47,6 +47,9 @@ func TestRunRequiresExactPlanApproval(t *testing.T) { if !test.yes && !strings.Contains(output.String(), "Type apply "+planID) { t.Fatalf("output does not bind approval to plan id:\n%s", output.String()) } + if !test.wantErr && !strings.Contains(output.String(), "[checkout] checkout-sync started: synchronizing checkout") { + t.Fatalf("output does not include structured progress:\n%s", output.String()) + } }) } } @@ -56,6 +59,8 @@ type plainEngine struct { phases []string } +func (f *plainEngine) Configure(string, bool) error { return nil } + func (f *plainEngine) Describe(context.Context) (contract.Describe, engine.Output, error) { return contract.Describe{ Format: "zi-setup-describe-v1", @@ -71,7 +76,14 @@ func (f *plainEngine) Plan(context.Context, string) (contract.Plan, engine.Outpu }, engine.Output{}, nil } -func (f *plainEngine) Apply(_ context.Context, phase string) (contract.Result, engine.Output, error) { +func (f *plainEngine) Apply(_ context.Context, phase string, onEvent func(contract.ApplyEvent)) (contract.Result, engine.Output, error) { f.phases = append(f.phases, phase) + operation := "checkout-sync" + if phase == "files" { + operation = "write-files" + } + if onEvent != nil { + onEvent(contract.ApplyEvent{Format: "zi-setup-event-v1", Phase: phase, Operation: operation, Status: "started", Detail: "synchronizing checkout"}) + } return contract.Result{Format: "zi-setup-result-v1", PlanID: f.planID, Phase: phase, Status: "succeeded"}, engine.Output{}, nil } diff --git a/internal/tui/model.go b/internal/tui/model.go index 30915d6..06626d3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,7 +3,9 @@ package tui import ( "context" "fmt" + "regexp" "strings" + "unicode/utf8" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" @@ -13,6 +15,8 @@ import ( "github.com/z-shell/zi-setup/internal/workflow" ) +var refPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) + type Options struct { Theme string NoColor bool @@ -37,6 +41,11 @@ type Model struct { showDetails bool err error applicationErr error + refDraft string + refErr error + refEditing bool + applyEvents chan contract.ApplyEvent + lastEvent *contract.ApplyEvent } type discoveryDone struct { @@ -51,6 +60,10 @@ type applyDone struct { session *workflow.Session err error } +type applyEventMsg struct { + event contract.ApplyEvent + ok bool +} func New(ctx context.Context, session *workflow.Session, options Options) *Model { child, cancel := context.WithCancel(ctx) @@ -66,7 +79,15 @@ func New(ctx context.Context, session *workflow.Session, options Options) *Model viewport: view, width: 80, height: 24, + refDraft: session.Ref, + } +} + +func validateRef(value string) error { + if value == "" || strings.HasPrefix(value, "-") || strings.Contains(value, "..") || !refPattern.MatchString(value) { + return fmt.Errorf("use letters, digits, '.', '_', '/', or '-' with no leading '-' or '..'") } + return nil } func Run(ctx context.Context, session *workflow.Session, options Options) error { @@ -99,7 +120,35 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height m.resize() case tea.KeyPressMsg: - command = m.handleKey(msg.String()) + if msg.String() == "ctrl+c" { + command = m.handleKey(msg.String()) + } else if m.refEditing { + switch msg.String() { + case "enter": + if m.refErr = validateRef(m.refDraft); m.refErr == nil { + m.session.SetChoices(m.refDraft, m.session.SkipZshrc) + m.refEditing = false + } + case "esc": + m.refDraft = m.session.Ref + m.refErr = nil + m.refEditing = false + case "backspace": + if m.refDraft != "" { + _, size := utf8.DecodeLastRuneInString(m.refDraft) + m.refDraft = m.refDraft[:len(m.refDraft)-size] + } + m.refErr = validateRef(m.refDraft) + default: + text := msg.Key().Text + if text != "" && len(m.refDraft)+len(text) <= 256 && refPattern.MatchString(text) { + m.refDraft += text + } + m.refErr = validateRef(m.refDraft) + } + } else { + command = m.handleKey(msg.String()) + } case discoveryDone: m.publishSession(msg.session) m.busy = false @@ -123,6 +172,16 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.applicationErr = msg.err m.confirm = false m.viewport.GotoTop() + case applyEventMsg: + if msg.ok { + event := msg.event + m.lastEvent = &event + m.busyLabel = event.Detail + if m.busyLabel == "" { + m.busyLabel = event.Operation + " " + event.Status + } + command = waitApplyEvent(m.applyEvents) + } } m.refresh() if !m.busy { @@ -162,10 +221,13 @@ func (m *Model) planCmd(profile string) tea.Cmd { } } -func (m *Model) applyCmd() tea.Cmd { +func (m *Model) applyCmd(events chan<- contract.ApplyEvent) tea.Cmd { next := m.session.Clone() return func() tea.Msg { - err := next.ApplyReviewedPlan(m.ctx) + defer close(events) + err := next.ApplyReviewedPlan(m.ctx, func(event contract.ApplyEvent) { + events <- event + }) if err == nil { err = next.VerifyReopen(m.ctx) } @@ -173,6 +235,13 @@ func (m *Model) applyCmd() tea.Cmd { } } +func waitApplyEvent(events <-chan contract.ApplyEvent) tea.Cmd { + return func() tea.Msg { + event, ok := <-events + return applyEventMsg{event: event, ok: ok} + } +} + func (m *Model) publishSession(next *workflow.Session) { if next != nil { *m.session = *next @@ -216,6 +285,12 @@ func (m *Model) handleKey(key string) tea.Cmd { return m.planCmd(choice.ID) } } + case "i": + m.session.SetChoices(m.session.Ref, !m.session.SkipZshrc) + case "r": + m.refDraft = m.session.Ref + m.refErr = nil + m.refEditing = true } case workflow.StageReview: if m.confirm { @@ -225,7 +300,9 @@ func (m *Model) handleKey(key string) tea.Cmd { m.applying = true m.busyLabel = "Applying checkout, then files" m.err = nil - return m.applyCmd() + m.lastEvent = nil + m.applyEvents = make(chan contract.ApplyEvent, 32) + return tea.Batch(m.applyCmd(m.applyEvents), waitApplyEvent(m.applyEvents)) case "n", "esc": m.confirm = false } @@ -325,7 +402,10 @@ func (m *Model) footer() string { } switch m.session.Stage { case workflow.StageChoose: - return "↑/↓ choose enter review d details q quit" + if m.refEditing { + return "type Zi ref enter save esc cancel" + } + return "↑/↓ choose i integration r ref enter review d details q quit" case workflow.StageReview: if m.confirm { return "y/enter apply exact plan n/esc cancel" @@ -340,7 +420,11 @@ func (m *Model) footer() string { func (m *Model) body() string { if m.busy { - return "\n" + m.styles.active.Render(m.busyLabel) + "\n\nThe current operation has no fabricated percentage." + body := "\n" + m.styles.active.Render(m.busyLabel) + "\n\nThe current operation has no fabricated percentage." + if m.lastEvent != nil { + body += fmt.Sprintf("\n\n%s %s %s", presentation.SafeText(m.lastEvent.Phase), presentation.SafeText(m.lastEvent.Operation), presentation.SafeText(m.lastEvent.Status)) + } + return body } if m.showDetails { return m.detailsBody() @@ -380,6 +464,21 @@ func (m *Model) chooseBody() string { } out.WriteString(line + "\n\n") } + out.WriteString(m.styles.heading.Render("Setup choices") + "\n") + integration := "update .zshrc with the managed Zi block" + if m.session.SkipZshrc { + integration = "install only; leave .zshrc unchanged" + } + fmt.Fprintf(&out, "Integration %s\n", integration) + if m.refEditing { + fmt.Fprintf(&out, "Zi ref: > %s_\n", presentation.SafeText(m.refDraft)) + if m.refErr != nil { + out.WriteString(m.styles.error.Render(m.refErr.Error()) + "\n") + } + } else { + fmt.Fprintf(&out, "Zi ref %s\n", presentation.SafeText(m.session.Ref)) + } + out.WriteString("\n") out.WriteString(m.styles.heading.Render("Discovered inputs") + "\n") for _, fact := range m.session.Describe.Facts { fmt.Fprintf(&out, "%-18s %s %s/%s\n", fact.ID, presentation.SafeText(fact.Value), fact.Source, fact.Confidence) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 3665d88..680e37b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -25,6 +25,16 @@ func TestModelCompletesCompactKeyboardFlow(t *testing.T) { if content := model.View().Content; !strings.Contains(content, "zi> setup") || !strings.Contains(content, "Choose a starting point") { t.Fatalf("compact choose view missing content:\n%s", content) } + model.Update(tea.KeyPressMsg{Code: 'i'}) + if !session.SkipZshrc || !strings.Contains(model.View().Content, "leave .zshrc unchanged") { + t.Fatalf("integration choice was not updated:\n%s", model.View().Content) + } + model.Update(tea.KeyPressMsg{Code: 'r'}) + model.refDraft = "v2.1.0" + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if session.Ref != "v2.1.0" { + t.Fatalf("ref = %q", session.Ref) + } model.cursor = 1 planCommand := model.handleKey("enter") if planCommand == nil { @@ -34,6 +44,9 @@ func TestModelCompletesCompactKeyboardFlow(t *testing.T) { if session.Stage != workflow.StageReview || session.Plan.ID == "" { t.Fatalf("review state = %s, plan = %#v", session.Stage, session.Plan) } + if fake.ref != "v2.1.0" || !fake.skipZshrc { + t.Fatalf("engine choices = ref %q, skip-zshrc %v", fake.ref, fake.skipZshrc) + } model.handleKey("tab") if model.tab != 1 || !strings.Contains(model.View().Content, "Generated Zsh") { t.Fatalf("generated view was not selected:\n%s", model.View().Content) @@ -55,7 +68,14 @@ func TestModelCompletesCompactKeyboardFlow(t *testing.T) { if content := model.footer(); !strings.Contains(content, "not interruptible") { t.Fatalf("busy footer does not disclose cancellation boundary: %q", content) } - model.Update(applyCommand()) + batch, ok := applyCommand().(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatalf("apply command returned %T", applyCommand()) + } + model.Update(batch[0]()) + for eventCommand := batch[1]; eventCommand != nil; { + _, eventCommand = model.Update(eventCommand()) + } if model.applying { t.Fatal("completed apply remained marked in flight") } @@ -65,13 +85,40 @@ func TestModelCompletesCompactKeyboardFlow(t *testing.T) { if fake.phases != "checkout,files" { t.Fatalf("phase order = %s", fake.phases) } + if model.lastEvent == nil || model.lastEvent.Operation != "write-files" || model.lastEvent.Status != "succeeded" { + t.Fatalf("last event = %#v", model.lastEvent) + } if content := model.View().Content; !strings.Contains(content, "reopen: no content changes") { t.Fatalf("result view missing verification:\n%s", content) } } +func TestRefEditorAcceptsOnlyPrintableRefText(t *testing.T) { + t.Parallel() + session := workflow.New(&modelEngine{}) + model := New(context.Background(), session, Options{NoColor: true}) + model.refEditing = true + model.refDraft = "main" + model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + if model.refDraft != "main" { + t.Fatalf("special key changed ref to %q", model.refDraft) + } + model.Update(tea.KeyPressMsg{Code: '/', Text: "/feature"}) + if model.refDraft != "main/feature" { + t.Fatalf("printable text produced ref %q", model.refDraft) + } +} + type modelEngine struct { - phases string + phases string + ref string + skipZshrc bool +} + +func (f *modelEngine) Configure(ref string, skipZshrc bool) error { + f.ref = ref + f.skipZshrc = skipZshrc + return nil } func (f *modelEngine) Describe(context.Context) (contract.Describe, engine.Output, error) { @@ -98,7 +145,7 @@ func (f *modelEngine) Plan(context.Context, string) (contract.Plan, engine.Outpu }, engine.Output{}, nil } -func (f *modelEngine) Apply(_ context.Context, phase string) (contract.Result, engine.Output, error) { +func (f *modelEngine) Apply(_ context.Context, phase string, onEvent func(contract.ApplyEvent)) (contract.Result, engine.Output, error) { if f.phases != "" { f.phases += "," } @@ -107,6 +154,10 @@ func (f *modelEngine) Apply(_ context.Context, phase string) (contract.Result, e if phase == "files" { operation = "write-files" } + if onEvent != nil { + onEvent(contract.ApplyEvent{Format: "zi-setup-event-v1", Phase: phase, Operation: operation, Status: "started", Detail: "working"}) + onEvent(contract.ApplyEvent{Format: "zi-setup-event-v1", Phase: phase, Operation: operation, Status: "succeeded", Detail: "complete"}) + } return contract.Result{ Format: "zi-setup-result-v1", PlanID: strings.Repeat("b", 64), diff --git a/internal/workflow/session.go b/internal/workflow/session.go index 4c69635..1c3aff3 100644 --- a/internal/workflow/session.go +++ b/internal/workflow/session.go @@ -20,9 +20,15 @@ const ( ) type Engine interface { + Configure(string, bool) error Describe(context.Context) (contract.Describe, engine.Output, error) Plan(context.Context, string) (contract.Plan, engine.Output, error) - Apply(context.Context, string) (contract.Result, engine.Output, error) + Apply(context.Context, string, func(contract.ApplyEvent)) (contract.Result, engine.Output, error) +} + +type Options struct { + Ref string + SkipZshrc bool } type PhaseOutcome struct { @@ -38,15 +44,25 @@ type Session struct { DiscoveryOutput engine.Output DiscoveryErr error SelectedProfile string + Ref string + SkipZshrc bool Plan contract.Plan PlanOutput engine.Output Checkout *PhaseOutcome Files *PhaseOutcome VerificationPlan *contract.Plan + Events []contract.ApplyEvent } -func New(setupEngine Engine) *Session { - return &Session{engine: setupEngine, Stage: StageDiscover} +func New(setupEngine Engine, options ...Options) *Session { + selection := Options{Ref: "main"} + if len(options) != 0 { + selection = options[0] + if selection.Ref == "" { + selection.Ref = "main" + } + } + return &Session{engine: setupEngine, Stage: StageDiscover, Ref: selection.Ref, SkipZshrc: selection.SkipZshrc} } // Clone returns an independent presentation-state copy that shares the engine. @@ -54,6 +70,7 @@ func New(setupEngine Engine) *Session { // command completes. func (s *Session) Clone() *Session { clone := *s + clone.Events = append([]contract.ApplyEvent(nil), s.Events...) return &clone } @@ -95,6 +112,9 @@ func (s *Session) BuildPlan(ctx context.Context) error { if s.SelectedProfile == "" { return errors.New("a selectable profile is required") } + if err := s.engine.Configure(s.Ref, s.SkipZshrc); err != nil { + return err + } plan, output, err := s.engine.Plan(ctx, s.SelectedProfile) s.PlanOutput = output if err != nil { @@ -108,6 +128,20 @@ func (s *Session) BuildPlan(ctx context.Context) error { return nil } +func (s *Session) SetChoices(ref string, skipZshrc bool) { + if ref == "" { + ref = "main" + } + s.Ref = ref + s.SkipZshrc = skipZshrc + s.Plan = contract.Plan{} + s.PlanOutput = engine.Output{} + s.Checkout = nil + s.Files = nil + s.VerificationPlan = nil + s.Stage = StageChoose +} + func (s *Session) BackToChoose() { s.Plan = contract.Plan{} s.PlanOutput = engine.Output{} @@ -117,12 +151,21 @@ func (s *Session) BackToChoose() { s.Stage = StageChoose } -func (s *Session) ApplyReviewedPlan(ctx context.Context) error { +func (s *Session) ApplyReviewedPlan(ctx context.Context, observers ...func(contract.ApplyEvent)) error { if s.Plan.ID == "" || s.Stage != StageReview { return errors.New("a reviewed plan is required") } s.Stage = StageApply - checkout, output, err := s.engine.Apply(ctx, "checkout") + s.Events = nil + onEvent := func(event contract.ApplyEvent) { + s.Events = append(s.Events, event) + for _, observer := range observers { + if observer != nil { + observer(event) + } + } + } + checkout, output, err := s.engine.Apply(ctx, "checkout", onEvent) s.Checkout = &PhaseOutcome{Result: checkout, Output: output, Err: err} if err != nil || checkout.Status != "succeeded" { s.Stage = StageResult @@ -131,7 +174,7 @@ func (s *Session) ApplyReviewedPlan(ctx context.Context) error { } return errors.New("checkout phase did not succeed") } - files, output, err := s.engine.Apply(ctx, "files") + files, output, err := s.engine.Apply(ctx, "files", onEvent) s.Files = &PhaseOutcome{Result: files, Output: output, Err: err} s.Stage = StageResult if err != nil { diff --git a/internal/workflow/session_test.go b/internal/workflow/session_test.go index 1276255..5ba699e 100644 --- a/internal/workflow/session_test.go +++ b/internal/workflow/session_test.go @@ -114,6 +114,8 @@ type fakeEngine struct { plans int } +func (f *fakeEngine) Configure(string, bool) error { return nil } + func (f *fakeEngine) Describe(context.Context) (contract.Describe, engine.Output, error) { return contract.Describe{ Format: "zi-setup-describe-v1", @@ -135,7 +137,7 @@ func (f *fakeEngine) Plan(_ context.Context, profile string) (contract.Plan, eng }, engine.Output{}, nil } -func (f *fakeEngine) Apply(_ context.Context, phase string) (contract.Result, engine.Output, error) { +func (f *fakeEngine) Apply(_ context.Context, phase string, _ func(contract.ApplyEvent)) (contract.Result, engine.Output, error) { f.applyCalls = append(f.applyCalls, phase) result := contract.Result{Format: "zi-setup-result-v1", PlanID: strings.Repeat("b", 64), Phase: phase, Status: "succeeded"} if phase == f.failPhase { diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..4562d39 --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +# scripts/package-release.sh +# Packages reproducible cross-platform release distributions for zi-setup. +# Supported targets: +# - linux/amd64 (.tar.gz) +# - linux/arm64 (.tar.gz) +# - darwin/amd64 (.tar.gz) +# - darwin/arm64 (.tar.gz) +# - windows/amd64 (.zip, requires sh on PATH) +# - windows/arm64 (.zip, requires sh on PATH) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + cat <&2 +Usage: $0 [output-dir] [target-os] [target-arch] + +Arguments: + Strict semantic version tag (e.g. v1.2.3). Required. + Must match ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ + [output-dir] Output directory for archives and checksums (default: dist) + [target-os] Optional target OS (linux, darwin, windows) + [target-arch] Optional target architecture (amd64, arm64) + +When [target-os] and [target-arch] are omitted, all 6 supported targets are packaged +and SHA256SUMS is generated. +EOF + exit 1 +} + +if [ $# -lt 1 ] || [ -z "${1:-}" ]; then + echo "Error: version is required." >&2 + usage +fi + +VERSION="$1" +if [[ ! "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Error: version '${VERSION}' does not follow strict semantic format vX.Y.Z (no dev fallback permitted)." >&2 + exit 1 +fi + +OUTPUT_DIR="${2:-dist}" +TARGET_OS="${3:-}" +TARGET_ARCH="${4:-}" + +# Ensure output directory exists and resolve absolute path +mkdir -p "${OUTPUT_DIR}" +OUTPUT_DIR="$(cd "${OUTPUT_DIR}" && pwd)" + +# Verify required files in repository +if [ ! -f "${REPO_ROOT}/LICENSE" ]; then + echo "Error: LICENSE file not found at ${REPO_ROOT}/LICENSE" >&2 + exit 1 +fi + +if [ ! -f "${REPO_ROOT}/docs/README.md" ]; then + echo "Error: README not found at ${REPO_ROOT}/docs/README.md" >&2 + exit 1 +fi + +# Determine SOURCE_DATE_EPOCH for reproducible timestamps +if [ -z "${SOURCE_DATE_EPOCH:-}" ]; then + SOURCE_DATE_EPOCH="$(git -C "${REPO_ROOT}" log -1 --format=%ct 2>/dev/null || echo 1704067200)" +fi + +# Format timestamp for touch -t: YYYYMMDDhhmm.ss +if date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d%H%M.%S >/dev/null 2>&1; then + TOUCH_TIME="$(date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d%H%M.%S)" +elif python3 -c "import datetime, timezone; print(datetime.datetime.fromtimestamp(${SOURCE_DATE_EPOCH}, tz=datetime.timezone.utc).strftime('%Y%m%d%H%M.%S'))" >/dev/null 2>&1; then + TOUCH_TIME="$(python3 -c "import datetime, timezone; print(datetime.datetime.fromtimestamp(${SOURCE_DATE_EPOCH}, tz=datetime.timezone.utc).strftime('%Y%m%d%H%M.%S'))")" +else + TOUCH_TIME="202401010000.00" +fi + +package_target() { + local os="$1" + local arch="$2" + local ext=".tar.gz" + local bin_name="zi-setup" + local win_suffix="" + + if [ "$os" = "windows" ]; then + ext=".zip" + bin_name="zi-setup.exe" + win_suffix="_requires-sh-on-path" + fi + + local archive_name="zi-setup_${VERSION}_${os}_${arch}${win_suffix}${ext}" + local archive_path="${OUTPUT_DIR}/${archive_name}" + + echo "==> Packaging ${archive_name} (${os}/${arch})..." + + local stage_dir + stage_dir="$(mktemp -d)" + + # Build binary: Go 1.26, CGO_ENABLED=0, -trimpath, blank build ID, inject main.version + ( + cd "${REPO_ROOT}" + CGO_ENABLED=0 GOOS="${os}" GOARCH="${arch}" go build \ + -trimpath \ + -ldflags="-buildid= -X main.version=${VERSION}" \ + -o "${stage_dir}/${bin_name}" \ + ./cmd/zi-setup + ) + + # Copy LICENSE and docs/README.md stored as README.md + cp "${REPO_ROOT}/LICENSE" "${stage_dir}/LICENSE" + cp "${REPO_ROOT}/docs/README.md" "${stage_dir}/README.md" + + # Normalize permissions + chmod 0755 "${stage_dir}/${bin_name}" + chmod 0644 "${stage_dir}/LICENSE" "${stage_dir}/README.md" + + # Normalize timestamps + touch -t "${TOUCH_TIME}" "${stage_dir}/LICENSE" "${stage_dir}/README.md" "${stage_dir}/${bin_name}" + + # Create deterministic archive + rm -f "${archive_path}" + if [ "$os" = "windows" ]; then + ( + cd "${stage_dir}" + # -X: strip extra file attributes (UID/GID, variable timestamps) + # -q: quiet + zip -q -X "${archive_path}" LICENSE README.md "${bin_name}" + ) + else + local tar_flags=() + if tar --version 2>&1 | grep -q "GNU tar"; then + tar_flags+=(--owner=0 --group=0 --numeric-owner) + fi + ( + cd "${stage_dir}" + # Provide files in explicit sorted order: LICENSE README.md zi-setup + # gzip -n suppresses filename and timestamp in gzip header + tar "${tar_flags[@]}" -cf - LICENSE README.md "${bin_name}" | gzip -n > "${archive_path}" + ) + fi + + rm -rf "${stage_dir}" +} + +ALL_TARGETS=( + "linux amd64" + "linux arm64" + "darwin amd64" + "darwin arm64" + "windows amd64" + "windows arm64" +) + +if [ -n "${TARGET_OS}" ] && [ -n "${TARGET_ARCH}" ]; then + package_target "${TARGET_OS}" "${TARGET_ARCH}" +else + for target in "${ALL_TARGETS[@]}"; do + # shellcheck disable=SC2086 + package_target $target + done + + # Generate sorted SHA-256 checksums manifest + echo "==> Generating SHA256SUMS manifest..." + ( + cd "${OUTPUT_DIR}" + sha256sum -- *.tar.gz *.zip | LC_ALL=C sort -k2,2 > SHA256SUMS + sha256sum --check SHA256SUMS + ) + echo "==> Successfully packaged all targets and generated ${OUTPUT_DIR}/SHA256SUMS" +fi diff --git a/scripts/sync-engine.sh b/scripts/sync-engine.sh new file mode 100755 index 0000000..6e277fe --- /dev/null +++ b/scripts/sync-engine.sh @@ -0,0 +1,72 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + printf '%s\n' 'usage: scripts/sync-engine.sh /path/to/z-shell/src FULL_COMMIT_SHA' >&2 + exit 2 +fi + +source_root=$1 +revision=$2 +destination=internal/engine/bundled/public +case "${revision}" in +????????????????????????????????????????) ;; +*) + printf '%s\n' 'FULL_COMMIT_SHA must contain exactly 40 hexadecimal characters' >&2 + exit 2 + ;; +esac +case "${revision}" in *[!0-9a-f]*) + printf '%s\n' 'FULL_COMMIT_SHA must contain exactly 40 lowercase hexadecimal characters' >&2 + exit 2 + ;; +esac + +resolved=$(git -C "${source_root}" rev-parse --verify "${revision}^{commit}" 2>/dev/null) || { + printf 'cannot resolve source commit %s\n' "${revision}" >&2 + exit 2 +} +[ "${resolved}" = "${revision}" ] || { + printf 'source commit resolved to %s, expected %s\n' "${resolved}" "${revision}" >&2 + exit 2 +} + +work=$(mktemp -d "${TMPDIR:-/tmp}/zi-setup-engine.XXXXXXXX") +trap 'rm -rf "${work}"' EXIT INT TERM HUP +umask 077 + +for path in \ + public/sh/setup.sh \ + public/setup/profiles.tsv \ + public/zsh/init.zsh \ + public/checksum.txt +do + git -C "${source_root}" cat-file -e "${revision}:${path}" 2>/dev/null || { + printf 'source commit does not contain %s\n' "${path}" >&2 + exit 2 + } + mkdir -p "${work}/$(dirname "${path}")" + git -C "${source_root}" show "${revision}:${path}" >"${work}/${path}" +done + +mkdir -p "${destination}/sh" "${destination}/setup" "${destination}/zsh" +cp "${work}/public/sh/setup.sh" "${destination}/sh/setup.sh" +cp "${work}/public/setup/profiles.tsv" "${destination}/setup/profiles.tsv" +cp "${work}/public/zsh/init.zsh" "${destination}/zsh/init.zsh" +cp "${work}/public/checksum.txt" "${destination}/checksum.txt" + +printf 'Copied engine assets from %s.\n' "${revision}" +printf '%s\n' 'Update BundledEngineRevision and bundledHashes in internal/engine/bundle.go with:' +if command -v sha256sum >/dev/null 2>&1; then + sha256sum \ + "${destination}/sh/setup.sh" \ + "${destination}/setup/profiles.tsv" \ + "${destination}/zsh/init.zsh" \ + "${destination}/checksum.txt" +else + shasum -a 256 \ + "${destination}/sh/setup.sh" \ + "${destination}/setup/profiles.tsv" \ + "${destination}/zsh/init.zsh" \ + "${destination}/checksum.txt" +fi diff --git a/scripts/test-package-release.sh b/scripts/test-package-release.sh new file mode 100755 index 0000000..9a19ba8 --- /dev/null +++ b/scripts/test-package-release.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +# scripts/test-package-release.sh +# Comprehensive dry-run test suite for release packaging. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PKG_SCRIPT="${SCRIPT_DIR}/package-release.sh" + +echo "==> [1/6] Validating shell syntax..." +bash -n "${PKG_SCRIPT}" +bash -n "${BASH_SOURCE[0]}" +echo " Shell syntax OK." + +echo "==> [2/6] Testing invalid version rejection..." +# No argument +if "${PKG_SCRIPT}" 2>/dev/null; then + echo "FAIL: Expected failure with no version argument" >&2 + exit 1 +fi + +# Invalid versions (no dev fallback allowed) +for bad_ver in "dev" "1.0.0" "v1.2" "v01.0.0" "v1.0.0-beta" "release"; do + if "${PKG_SCRIPT}" "${bad_ver}" 2>/dev/null; then + echo "FAIL: Expected failure for invalid version '${bad_ver}'" >&2 + exit 1 + fi +done +echo " Invalid versions properly rejected." + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "${TEST_DIR}"' EXIT + +TEST_VERSION="v9.9.9" +OUT1="${TEST_DIR}/run1" +OUT2="${TEST_DIR}/run2" +export SOURCE_DATE_EPOCH=1704067200 + +echo "==> [3/6] Running dry-run package (Run 1)..." +"${PKG_SCRIPT}" "${TEST_VERSION}" "${OUT1}" + +EXPECTED_FILES=( + "zi-setup_${TEST_VERSION}_linux_amd64.tar.gz" + "zi-setup_${TEST_VERSION}_linux_arm64.tar.gz" + "zi-setup_${TEST_VERSION}_darwin_amd64.tar.gz" + "zi-setup_${TEST_VERSION}_darwin_arm64.tar.gz" + "zi-setup_${TEST_VERSION}_windows_amd64_requires-sh-on-path.zip" + "zi-setup_${TEST_VERSION}_windows_arm64_requires-sh-on-path.zip" + "SHA256SUMS" +) + +echo "==> [4/6] Verifying generated archives and manifest..." +for f in "${EXPECTED_FILES[@]}"; do + if [ ! -f "${OUT1}/${f}" ]; then + echo "FAIL: Missing expected output file ${f}" >&2 + exit 1 + fi +done + +# Verify sorted SHA256SUMS +( + cd "${OUT1}" + sha256sum --check SHA256SUMS >/dev/null + # Verify sorted order + if ! LC_ALL=C sort -c -k2,2 SHA256SUMS; then + echo "FAIL: SHA256SUMS is not sorted by filename" >&2 + exit 1 + fi +) +echo " Checksum manifest verified and sorted." + +# Verify contents of a tar.gz archive +EXTRACT_LINUX="${TEST_DIR}/extracted_linux" +mkdir -p "${EXTRACT_LINUX}" +tar -xzf "${OUT1}/zi-setup_${TEST_VERSION}_linux_amd64.tar.gz" -C "${EXTRACT_LINUX}" + +if [ ! -f "${EXTRACT_LINUX}/zi-setup" ] || [ ! -x "${EXTRACT_LINUX}/zi-setup" ]; then + echo "FAIL: Linux archive missing executable zi-setup binary" >&2 + exit 1 +fi +if ! cmp -s "${REPO_ROOT}/LICENSE" "${EXTRACT_LINUX}/LICENSE"; then + echo "FAIL: Extracted LICENSE does not match repository LICENSE" >&2 + exit 1 +fi +if ! cmp -s "${REPO_ROOT}/docs/README.md" "${EXTRACT_LINUX}/README.md"; then + echo "FAIL: Extracted README.md does not match repository docs/README.md" >&2 + exit 1 +fi + +# Verify version injected into binary +bin_ver="$("${EXTRACT_LINUX}/zi-setup" -version)" +if [[ "$bin_ver" != "zi-setup ${TEST_VERSION}"* ]]; then + echo "FAIL: Expected 'zi-setup ${TEST_VERSION}', got '${bin_ver}'" >&2 + exit 1 +fi + +# Verify contents of a windows zip archive +EXTRACT_WIN="${TEST_DIR}/extracted_windows" +mkdir -p "${EXTRACT_WIN}" +if command -v unzip >/dev/null 2>&1; then + unzip -q "${OUT1}/zi-setup_${TEST_VERSION}_windows_amd64_requires-sh-on-path.zip" -d "${EXTRACT_WIN}" +else + python3 -m zipfile -e "${OUT1}/zi-setup_${TEST_VERSION}_windows_amd64_requires-sh-on-path.zip" "${EXTRACT_WIN}" +fi + +if [ ! -f "${EXTRACT_WIN}/zi-setup.exe" ]; then + echo "FAIL: Windows archive missing zi-setup.exe" >&2 + exit 1 +fi +if ! cmp -s "${REPO_ROOT}/LICENSE" "${EXTRACT_WIN}/LICENSE"; then + echo "FAIL: Extracted Windows LICENSE does not match repository LICENSE" >&2 + exit 1 +fi +if ! cmp -s "${REPO_ROOT}/docs/README.md" "${EXTRACT_WIN}/README.md"; then + echo "FAIL: Extracted Windows README.md does not match repository docs/README.md" >&2 + exit 1 +fi +echo " Archive contents and version injection verified." + +echo "==> [5/6] Verifying reproducibility across runs (Run 2)..." +"${PKG_SCRIPT}" "${TEST_VERSION}" "${OUT2}" + +for f in "${EXPECTED_FILES[@]}"; do + if ! cmp -s "${OUT1}/${f}" "${OUT2}/${f}"; then + echo "FAIL: Non-deterministic output detected for ${f}" >&2 + diff <(sha256sum "${OUT1}/${f}") <(sha256sum "${OUT2}/${f}") || true + exit 1 + fi +done +echo " Reproducibility verified: byte-for-byte identical output across independent runs." + +echo "==> [6/6] All release packaging tests passed successfully!"