Merge pull request #16 from bnema/fix/taskede-phases-install
feat: improve extension installation and development builds
26 files · +1025 -226
M .github/workflows/ci.yml +2 -2
| @@ -24,9 +24,9 @@ jobs: | |||
| 24 | 24 | - uses: actions/checkout@v4 | |
| 25 | 25 | ||
| 26 | 26 | - name: set up go | |
| 27 | - | uses: actions/setup-go@v5 | |
| 27 | + | uses: actions/setup-go@v7 | |
| 28 | 28 | with: | |
| 29 | - | go-version: "1.23" | |
| 29 | + | go-version: "1.26" | |
| 30 | 30 | cache: true | |
| 31 | 31 | ||
| 32 | 32 | - name: go vet | |
| @@ -24,9 +24,9 @@ jobs: | |||||
| 24 | - uses: actions/checkout@v4 | 24 | - uses: actions/checkout@v4 | ||
| 25 | 25 | ||||
| 26 | - name: set up go | 26 | - name: set up go | ||
| 27 | - | uses: actions/setup-go@v5 | 27 | + | uses: actions/setup-go@v7 |
| 28 | with: | 28 | with: | ||
| 29 | - | go-version: "1.23" | 29 | + | go-version: "1.26" |
| 30 | cache: true | 30 | cache: true | ||
| 31 | 31 | ||||
| 32 | - name: go vet | 32 | - name: go vet | ||
M .github/workflows/release.yml +2 -2
| @@ -87,9 +87,9 @@ jobs: | |||
| 87 | 87 | git push origin "${{ steps.bump.outputs.next }}" | |
| 88 | 88 | ||
| 89 | 89 | - name: set up go | |
| 90 | - | uses: actions/setup-go@v5 | |
| 90 | + | uses: actions/setup-go@v7 | |
| 91 | 91 | with: | |
| 92 | - | go-version: "1.23" | |
| 92 | + | go-version: "1.26" | |
| 93 | 93 | cache: true | |
| 94 | 94 | ||
| 95 | 95 | - name: run goreleaser | |
| @@ -87,9 +87,9 @@ jobs: | |||||
| 87 | git push origin "${{ steps.bump.outputs.next }}" | 87 | git push origin "${{ steps.bump.outputs.next }}" | ||
| 88 | 88 | ||||
| 89 | - name: set up go | 89 | - name: set up go | ||
| 90 | - | uses: actions/setup-go@v5 | 90 | + | uses: actions/setup-go@v7 |
| 91 | with: | 91 | with: | ||
| 92 | - | go-version: "1.23" | 92 | + | go-version: "1.26" |
| 93 | cache: true | 93 | cache: true | ||
| 94 | 94 | ||||
| 95 | - name: run goreleaser | 95 | - name: run goreleaser | ||
M CONTRIBUTING.md +5 -1
| @@ -68,9 +68,13 @@ make build | |||
| 68 | 68 | Useful commands: | |
| 69 | 69 | ||
| 70 | 70 | ```sh | |
| 71 | - | make build # compile ./bin/zot | |
| 71 | + | make help # list the important developer commands | |
| 72 | + | make build # compile ./bin/zot as 0.0.0-dev | |
| 72 | 73 | make run # build and start the interactive application | |
| 74 | + | make install # install the current checkout with go install | |
| 75 | + | make go-install # install a published module version | |
| 73 | 76 | make test # run all tests with the race detector | |
| 77 | + | make test-fast # run all tests without the race detector | |
| 74 | 78 | make lint # run go vet and verify gofmt | |
| 75 | 79 | ``` | |
| 76 | 80 | ||
| @@ -68,9 +68,13 @@ make build | |||||
| 68 | Useful commands: | 68 | Useful commands: | ||
| 69 | 69 | ||||
| 70 | ```sh | 70 | ```sh | ||
| 71 | - | make build # compile ./bin/zot | 71 | + | make help # list the important developer commands |
| 72 | + | make build # compile ./bin/zot as 0.0.0-dev | |||
| 72 | make run # build and start the interactive application | 73 | make run # build and start the interactive application | ||
| 74 | + | make install # install the current checkout with go install | |||
| 75 | + | make go-install # install a published module version | |||
| 73 | make test # run all tests with the race detector | 76 | make test # run all tests with the race detector | ||
| 77 | + | make test-fast # run all tests without the race detector | |||
| 74 | make lint # run go vet and verify gofmt | 78 | make lint # run go vet and verify gofmt | ||
| 75 | ``` | 79 | ``` | ||
| 76 | 80 | ||||
M Makefile +62 -19
| @@ -1,36 +1,79 @@ | |||
| 1 | - | # Local / untagged builds ship as 0.0.0. Release builds are driven by | |
| 2 | - | # goreleaser which overrides VERSION from the git tag. | |
| 3 | - | VERSION ?= 0.0.0 | |
| 1 | + | # Local and untagged builds report a development version. Release builds | |
| 2 | + | # are driven by GoReleaser, which injects the Git tag and build metadata. | |
| 3 | + | .DEFAULT_GOAL := help | |
| 4 | + | ||
| 5 | + | GO ?= go | |
| 6 | + | PKG ?= ./cmd/zot | |
| 7 | + | BINARY ?= zot | |
| 8 | + | BIN_DIR ?= bin | |
| 9 | + | DEV_VERSION ?= 0.0.0-dev | |
| 10 | + | VERSION ?= $(DEV_VERSION) | |
| 11 | + | GO_INSTALL_VERSION ?= latest | |
| 4 | 12 | LDFLAGS := -s -w -X main.version=$(VERSION) | |
| 5 | 13 | ||
| 6 | - | .PHONY: build install test lint fmt clean release run | |
| 14 | + | .PHONY: help build install go-install test test-fast vet lint fmt-check fmt check run clean release | |
| 15 | + | ||
| 16 | + | help: | |
| 17 | + | @printf '%s\n' \ | |
| 18 | + | 'build build ./bin/zot' \ | |
| 19 | + | 'install install the current checkout with go install' \ | |
| 20 | + | 'go-install install a published module version (GO_INSTALL_VERSION=latest)' \ | |
| 21 | + | 'test run all tests with the race detector' \ | |
| 22 | + | 'test-fast run all tests without the race detector' \ | |
| 23 | + | 'vet run go vet ./...' \ | |
| 24 | + | 'lint run vet and verify gofmt' \ | |
| 25 | + | 'fmt format Go source files' \ | |
| 26 | + | 'check run test-fast and lint' \ | |
| 27 | + | 'run build and run ./bin/zot (pass ARGS="...")' \ | |
| 28 | + | 'clean remove build output' \ | |
| 29 | + | 'release cross-build release binaries (set VERSION=...)' | |
| 7 | 30 | ||
| 8 | 31 | build: | |
| 9 | - | go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot ./cmd/zot | |
| 32 | + | @mkdir -p "$(BIN_DIR)" | |
| 33 | + | $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)" $(PKG) | |
| 10 | 34 | ||
| 35 | + | # Install the current checkout. With the default VERSION this is a dev build. | |
| 11 | 36 | install: | |
| 12 | - | go install -ldflags "$(LDFLAGS)" ./cmd/zot | |
| 37 | + | $(GO) install -trimpath -ldflags "$(LDFLAGS)" $(PKG) | |
| 38 | + | ||
| 39 | + | # Install a published/module version, rather than the current checkout. | |
| 40 | + | go-install: | |
| 41 | + | $(GO) install github.com/patriceckhart/zot/cmd/zot@$(GO_INSTALL_VERSION) | |
| 13 | 42 | ||
| 14 | 43 | test: | |
| 15 | - | go test -race ./... | |
| 44 | + | $(GO) test -race ./... | |
| 16 | 45 | ||
| 17 | - | run: build | |
| 18 | - | ./bin/zot | |
| 46 | + | test-fast: | |
| 47 | + | $(GO) test ./... | |
| 48 | + | ||
| 49 | + | vet: | |
| 50 | + | $(GO) vet ./... | |
| 19 | 51 | ||
| 20 | - | lint: | |
| 21 | - | go vet ./... | |
| 22 | - | @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1) | |
| 52 | + | fmt-check: | |
| 53 | + | @files="$$(gofmt -l . 2>&1)" || { printf '%s\n' "$$files" >&2; echo "gofmt failed" >&2; exit 1; }; \ | |
| 54 | + | if [ -n "$$files" ]; then printf '%s\n' "$$files" >&2; echo "gofmt issues" >&2; exit 1; fi | |
| 55 | + | ||
| 56 | + | lint: vet fmt-check | |
| 57 | + | ||
| 58 | + | check: test-fast lint | |
| 23 | 59 | ||
| 24 | 60 | fmt: | |
| 25 | 61 | gofmt -w . | |
| 26 | 62 | ||
| 63 | + | run: build | |
| 64 | + | "$(BIN_DIR)/$(BINARY)" $(ARGS) | |
| 65 | + | ||
| 27 | 66 | clean: | |
| 28 | - | rm -rf bin | |
| 67 | + | rm -rf "$(BIN_DIR)" | |
| 29 | 68 | ||
| 30 | 69 | release: | |
| 31 | - | @mkdir -p bin | |
| 32 | - | GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-amd64 ./cmd/zot | |
| 33 | - | GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-arm64 ./cmd/zot | |
| 34 | - | GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-amd64 ./cmd/zot | |
| 35 | - | GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-arm64 ./cmd/zot | |
| 36 | - | GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-windows-amd64.exe ./cmd/zot | |
| 70 | + | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ] || printf '%s\n' "$(VERSION)" | grep -Eq '(^|[.-])[0-9]{14}-[A-Za-z0-9]+'; then \ | |
| 71 | + | echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \ | |
| 72 | + | exit 1; \ | |
| 73 | + | fi | |
| 74 | + | @mkdir -p "$(BIN_DIR)" | |
| 75 | + | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-amd64" $(PKG) | |
| 76 | + | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-arm64" $(PKG) | |
| 77 | + | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-amd64" $(PKG) | |
| 78 | + | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-arm64" $(PKG) | |
| 79 | + | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-windows-amd64.exe" $(PKG) | |
| @@ -1,36 +1,79 @@ | |||||
| 1 | - | # Local / untagged builds ship as 0.0.0. Release builds are driven by | 1 | + | # Local and untagged builds report a development version. Release builds |
| 2 | - | # goreleaser which overrides VERSION from the git tag. | 2 | + | # are driven by GoReleaser, which injects the Git tag and build metadata. |
| 3 | - | VERSION ?= 0.0.0 | 3 | + | .DEFAULT_GOAL := help |
| 4 | + | ||||
| 5 | + | GO ?= go | |||
| 6 | + | PKG ?= ./cmd/zot | |||
| 7 | + | BINARY ?= zot | |||
| 8 | + | BIN_DIR ?= bin | |||
| 9 | + | DEV_VERSION ?= 0.0.0-dev | |||
| 10 | + | VERSION ?= $(DEV_VERSION) | |||
| 11 | + | GO_INSTALL_VERSION ?= latest | |||
| 4 | LDFLAGS := -s -w -X main.version=$(VERSION) | 12 | LDFLAGS := -s -w -X main.version=$(VERSION) | ||
| 5 | 13 | ||||
| 6 | - | .PHONY: build install test lint fmt clean release run | 14 | + | .PHONY: help build install go-install test test-fast vet lint fmt-check fmt check run clean release |
| 15 | + | ||||
| 16 | + | help: | |||
| 17 | + | @printf '%s\n' \ | |||
| 18 | + | 'build build ./bin/zot' \ | |||
| 19 | + | 'install install the current checkout with go install' \ | |||
| 20 | + | 'go-install install a published module version (GO_INSTALL_VERSION=latest)' \ | |||
| 21 | + | 'test run all tests with the race detector' \ | |||
| 22 | + | 'test-fast run all tests without the race detector' \ | |||
| 23 | + | 'vet run go vet ./...' \ | |||
| 24 | + | 'lint run vet and verify gofmt' \ | |||
| 25 | + | 'fmt format Go source files' \ | |||
| 26 | + | 'check run test-fast and lint' \ | |||
| 27 | + | 'run build and run ./bin/zot (pass ARGS="...")' \ | |||
| 28 | + | 'clean remove build output' \ | |||
| 29 | + | 'release cross-build release binaries (set VERSION=...)' | |||
| 7 | 30 | ||||
| 8 | build: | 31 | build: | ||
| 9 | - | go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot ./cmd/zot | 32 | + | @mkdir -p "$(BIN_DIR)" |
| 33 | + | $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)" $(PKG) | |||
| 10 | 34 | ||||
| 35 | + | # Install the current checkout. With the default VERSION this is a dev build. | |||
| 11 | install: | 36 | install: | ||
| 12 | - | go install -ldflags "$(LDFLAGS)" ./cmd/zot | 37 | + | $(GO) install -trimpath -ldflags "$(LDFLAGS)" $(PKG) |
| 38 | + | ||||
| 39 | + | # Install a published/module version, rather than the current checkout. | |||
| 40 | + | go-install: | |||
| 41 | + | $(GO) install github.com/patriceckhart/zot/cmd/zot@$(GO_INSTALL_VERSION) | |||
| 13 | 42 | ||||
| 14 | test: | 43 | test: | ||
| 15 | - | go test -race ./... | 44 | + | $(GO) test -race ./... |
| 16 | 45 | ||||
| 17 | - | run: build | 46 | + | test-fast: |
| 18 | - | ./bin/zot | 47 | + | $(GO) test ./... |
| 48 | + | ||||
| 49 | + | vet: | |||
| 50 | + | $(GO) vet ./... | |||
| 19 | 51 | ||||
| 20 | - | lint: | 52 | + | fmt-check: |
| 21 | - | go vet ./... | 53 | + | @files="$$(gofmt -l . 2>&1)" || { printf '%s\n' "$$files" >&2; echo "gofmt failed" >&2; exit 1; }; \ |
| 22 | - | @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1) | 54 | + | if [ -n "$$files" ]; then printf '%s\n' "$$files" >&2; echo "gofmt issues" >&2; exit 1; fi |
| 55 | + | ||||
| 56 | + | lint: vet fmt-check | |||
| 57 | + | ||||
| 58 | + | check: test-fast lint | |||
| 23 | 59 | ||||
| 24 | fmt: | 60 | fmt: | ||
| 25 | gofmt -w . | 61 | gofmt -w . | ||
| 26 | 62 | ||||
| 63 | + | run: build | |||
| 64 | + | "$(BIN_DIR)/$(BINARY)" $(ARGS) | |||
| 65 | + | ||||
| 27 | clean: | 66 | clean: | ||
| 28 | - | rm -rf bin | 67 | + | rm -rf "$(BIN_DIR)" |
| 29 | 68 | ||||
| 30 | release: | 69 | release: | ||
| 31 | - | @mkdir -p bin | 70 | + | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ] || printf '%s\n' "$(VERSION)" | grep -Eq '(^|[.-])[0-9]{14}-[A-Za-z0-9]+'; then \ |
| 32 | - | GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-amd64 ./cmd/zot | 71 | + | echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \ |
| 33 | - | GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-arm64 ./cmd/zot | 72 | + | exit 1; \ |
| 34 | - | GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-amd64 ./cmd/zot | 73 | + | fi |
| 35 | - | GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-arm64 ./cmd/zot | 74 | + | @mkdir -p "$(BIN_DIR)" |
| 36 | - | GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-windows-amd64.exe ./cmd/zot | 75 | + | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-amd64" $(PKG) |
| 76 | + | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-arm64" $(PKG) | |||
| 77 | + | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-amd64" $(PKG) | |||
| 78 | + | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-arm64" $(PKG) | |||
| 79 | + | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-windows-amd64.exe" $(PKG) | |||
M README.md +24 -6
| @@ -64,10 +64,16 @@ The installed binary reports the tagged module version and supports `zot update` | |||
| 64 | 64 | ```bash | |
| 65 | 65 | git clone https://github.com/patriceckhart/zot | |
| 66 | 66 | cd zot | |
| 67 | - | make build # produces ./bin/zot | |
| 68 | - | make install # into $GOPATH/bin | |
| 67 | + | make help # list the important developer commands | |
| 68 | + | make build # produces ./bin/zot (0.0.0-dev by default) | |
| 69 | + | make install # install the current checkout with go install | |
| 70 | + | make go-install # install the latest published module version | |
| 69 | 71 | ``` | |
| 70 | 72 | ||
| 73 | + | `make go-install GO_INSTALL_VERSION=v0.1.0` installs a specific published | |
| 74 | + | version. `make install` is different: it installs the current checkout as a | |
| 75 | + | development build. | |
| 76 | + | ||
| 71 | 77 | ### Prebuilt binaries | |
| 72 | 78 | ||
| 73 | 79 | Every release on the [releases page](https://github.com/patriceckhart/zot/releases) ships archives for Linux, macOS, and Windows on amd64 and arm64 (except windows/arm64), plus a `checksums.txt` file. Download, verify, `chmod +x`, and drop on your `$PATH`. | |
| @@ -182,7 +188,7 @@ Treat questions and discussions as requests for explanation. Do not edit files o | |||
| 182 | 188 | ||
| 183 | 189 | ## Changelog on update | |
| 184 | 190 | ||
| 185 | - | The first time you launch a newer zot binary, the TUI shows the GitHub release notes once in a dismissible overlay. Press any key to close. The version is recorded in `config.json`'s `last_changelog_shown` so the same release notes never reappear. Fresh installs don't see a changelog (no upgrade has happened yet). The fetch is best-effort: a network failure or a missing release page silently skips, with another attempt on the next launch. | |
| 191 | + | The first time you launch a newer zot binary, the TUI shows the GitHub release notes once in a dismissible overlay. Press any key to close. The version is recorded in `config.json`'s `last_changelog_shown` so the same release notes never reappear. Fresh installs don't see a changelog (no upgrade has happened yet). Development builds (`0.0.0-dev`) skip both the update and changelog requests and print `dev version detected; skipping update check`. For release builds, the fetch is best-effort: a network failure or a missing release page silently skips, with another attempt on the next launch. | |
| 186 | 192 | ||
| 187 | 193 | ## Usage | |
| 188 | 194 | ||
| @@ -922,6 +928,7 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens | |||
| 922 | 928 | ||
| 923 | 929 | ```bash | |
| 924 | 930 | zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/ | |
| 931 | + | zot ext install --build=go <path> # explicitly build and install local Go source | |
| 925 | 932 | zot ext list # show installed extensions | |
| 926 | 933 | zot ext doctor # diagnose load, registration, and conflict issues | |
| 927 | 934 | zot ext logs <name> [-f] # cat or tail the extension's stderr log | |
| @@ -930,6 +937,13 @@ zot ext disable <name> # disable without removing | |||
| 930 | 937 | zot ext remove <name> # delete an extension directory | |
| 931 | 938 | ``` | |
| 932 | 939 | ||
| 940 | + | For local installs, zot validates `extension.json` and any executable path | |
| 941 | + | relative to the extension directory before reporting success. It never runs a | |
| 942 | + | build implicitly. Use `zot ext install --build=go <path>` to explicitly build a | |
| 943 | + | local Go extension; other source-based extensions must provide their runtime | |
| 944 | + | artifact or launcher themselves. A failed validation or build leaves no | |
| 945 | + | partial installation behind. | |
| 946 | + | ||
| 933 | 947 | `zot ext doctor` keeps normal extension startup fail-soft, but gives | |
| 934 | 948 | you an explicit troubleshooting view: manifest errors, disabled or | |
| 935 | 949 | shadowed extensions, subprocess load errors, ready/auto-ready status, | |
| @@ -946,7 +960,7 @@ For development, point `zot --ext <path>` at a working directory and skip the in | |||
| 946 | 960 | - Extensions without a `.git/` directory (installed by `zot ext install ./local-path`) are skipped — there is no remote to pull from. | |
| 947 | 961 | - For the rest, zot stashes any dirty worktree state (including untracked runtime files like `todos.json` or `config.json`), runs `git pull --ff-only`, and pops the stash. If the pop produces conflicts, the conflict markers are left in place and you'll see a warning. | |
| 948 | 962 | - Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension. | |
| 949 | - | - zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, rebuild manually and use `/reload-ext`. | |
| 963 | + | - zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, run it explicitly, reinstall the extension, or use `/reload-ext` for a working-tree copy. | |
| 950 | 964 | ||
| 951 | 965 | ### Theme-only extensions | |
| 952 | 966 | ||
| @@ -1026,11 +1040,15 @@ This means additional messaging backends (Discord, Slack, Signal, and similar) c | |||
| 1026 | 1040 | ## Development | |
| 1027 | 1041 | ||
| 1028 | 1042 | ```bash | |
| 1029 | - | make build # build ./bin/zot | |
| 1043 | + | make help # list the important developer commands | |
| 1044 | + | make build # build ./bin/zot as 0.0.0-dev | |
| 1030 | 1045 | make test # go test -race ./... | |
| 1046 | + | make test-fast # go test ./... | |
| 1031 | 1047 | make lint # go vet + gofmt check | |
| 1032 | 1048 | make fmt # gofmt -w . | |
| 1033 | - | make release # cross-compile linux/darwin/windows on amd64 and arm64 | |
| 1049 | + | make install # install the current checkout with go install | |
| 1050 | + | make go-install # install the latest published module version | |
| 1051 | + | make release VERSION=0.1.0 # cross-compile release binaries | |
| 1034 | 1052 | ``` | |
| 1035 | 1053 | ||
| 1036 | 1054 | Source layout (single Go module, four packages under `packages/`): | |
| @@ -64,10 +64,16 @@ The installed binary reports the tagged module version and supports `zot update` | |||||
| 64 | ```bash | 64 | ```bash | ||
| 65 | git clone https://github.com/patriceckhart/zot | 65 | git clone https://github.com/patriceckhart/zot | ||
| 66 | cd zot | 66 | cd zot | ||
| 67 | - | make build # produces ./bin/zot | 67 | + | make help # list the important developer commands |
| 68 | - | make install # into $GOPATH/bin | 68 | + | make build # produces ./bin/zot (0.0.0-dev by default) |
| 69 | + | make install # install the current checkout with go install | |||
| 70 | + | make go-install # install the latest published module version | |||
| 69 | ``` | 71 | ``` | ||
| 70 | 72 | ||||
| 73 | + | `make go-install GO_INSTALL_VERSION=v0.1.0` installs a specific published | |||
| 74 | + | version. `make install` is different: it installs the current checkout as a | |||
| 75 | + | development build. | |||
| 76 | + | ||||
| 71 | ### Prebuilt binaries | 77 | ### Prebuilt binaries | ||
| 72 | 78 | ||||
| 73 | Every release on the [releases page](https://github.com/patriceckhart/zot/releases) ships archives for Linux, macOS, and Windows on amd64 and arm64 (except windows/arm64), plus a `checksums.txt` file. Download, verify, `chmod +x`, and drop on your `$PATH`. | 79 | Every release on the [releases page](https://github.com/patriceckhart/zot/releases) ships archives for Linux, macOS, and Windows on amd64 and arm64 (except windows/arm64), plus a `checksums.txt` file. Download, verify, `chmod +x`, and drop on your `$PATH`. | ||
| @@ -182,7 +188,7 @@ Treat questions and discussions as requests for explanation. Do not edit files o | |||||
| 182 | 188 | ||||
| 183 | ## Changelog on update | 189 | ## Changelog on update | ||
| 184 | 190 | ||||
| 185 | - | The first time you launch a newer zot binary, the TUI shows the GitHub release notes once in a dismissible overlay. Press any key to close. The version is recorded in `config.json`'s `last_changelog_shown` so the same release notes never reappear. Fresh installs don't see a changelog (no upgrade has happened yet). The fetch is best-effort: a network failure or a missing release page silently skips, with another attempt on the next launch. | 191 | + | The first time you launch a newer zot binary, the TUI shows the GitHub release notes once in a dismissible overlay. Press any key to close. The version is recorded in `config.json`'s `last_changelog_shown` so the same release notes never reappear. Fresh installs don't see a changelog (no upgrade has happened yet). Development builds (`0.0.0-dev`) skip both the update and changelog requests and print `dev version detected; skipping update check`. For release builds, the fetch is best-effort: a network failure or a missing release page silently skips, with another attempt on the next launch. |
| 186 | 192 | ||||
| 187 | ## Usage | 193 | ## Usage | ||
| 188 | 194 | ||||
| @@ -922,6 +928,7 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens | |||||
| 922 | 928 | ||||
| 923 | ```bash | 929 | ```bash | ||
| 924 | zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/ | 930 | zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/ | ||
| 931 | + | zot ext install --build=go <path> # explicitly build and install local Go source | |||
| 925 | zot ext list # show installed extensions | 932 | zot ext list # show installed extensions | ||
| 926 | zot ext doctor # diagnose load, registration, and conflict issues | 933 | zot ext doctor # diagnose load, registration, and conflict issues | ||
| 927 | zot ext logs <name> [-f] # cat or tail the extension's stderr log | 934 | zot ext logs <name> [-f] # cat or tail the extension's stderr log | ||
| @@ -930,6 +937,13 @@ zot ext disable <name> # disable without removing | |||||
| 930 | zot ext remove <name> # delete an extension directory | 937 | zot ext remove <name> # delete an extension directory | ||
| 931 | ``` | 938 | ``` | ||
| 932 | 939 | ||||
| 940 | + | For local installs, zot validates `extension.json` and any executable path | |||
| 941 | + | relative to the extension directory before reporting success. It never runs a | |||
| 942 | + | build implicitly. Use `zot ext install --build=go <path>` to explicitly build a | |||
| 943 | + | local Go extension; other source-based extensions must provide their runtime | |||
| 944 | + | artifact or launcher themselves. A failed validation or build leaves no | |||
| 945 | + | partial installation behind. | |||
| 946 | + | ||||
| 933 | `zot ext doctor` keeps normal extension startup fail-soft, but gives | 947 | `zot ext doctor` keeps normal extension startup fail-soft, but gives | ||
| 934 | you an explicit troubleshooting view: manifest errors, disabled or | 948 | you an explicit troubleshooting view: manifest errors, disabled or | ||
| 935 | shadowed extensions, subprocess load errors, ready/auto-ready status, | 949 | shadowed extensions, subprocess load errors, ready/auto-ready status, | ||
| @@ -946,7 +960,7 @@ For development, point `zot --ext <path>` at a working directory and skip the in | |||||
| 946 | - Extensions without a `.git/` directory (installed by `zot ext install ./local-path`) are skipped — there is no remote to pull from. | 960 | - Extensions without a `.git/` directory (installed by `zot ext install ./local-path`) are skipped — there is no remote to pull from. | ||
| 947 | - For the rest, zot stashes any dirty worktree state (including untracked runtime files like `todos.json` or `config.json`), runs `git pull --ff-only`, and pops the stash. If the pop produces conflicts, the conflict markers are left in place and you'll see a warning. | 961 | - For the rest, zot stashes any dirty worktree state (including untracked runtime files like `todos.json` or `config.json`), runs `git pull --ff-only`, and pops the stash. If the pop produces conflicts, the conflict markers are left in place and you'll see a warning. | ||
| 948 | - Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension. | 962 | - Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension. | ||
| 949 | - | - zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, rebuild manually and use `/reload-ext`. | 963 | + | - zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, run it explicitly, reinstall the extension, or use `/reload-ext` for a working-tree copy. |
| 950 | 964 | ||||
| 951 | ### Theme-only extensions | 965 | ### Theme-only extensions | ||
| 952 | 966 | ||||
| @@ -1026,11 +1040,15 @@ This means additional messaging backends (Discord, Slack, Signal, and similar) c | |||||
| 1026 | ## Development | 1040 | ## Development | ||
| 1027 | 1041 | ||||
| 1028 | ```bash | 1042 | ```bash | ||
| 1029 | - | make build # build ./bin/zot | 1043 | + | make help # list the important developer commands |
| 1044 | + | make build # build ./bin/zot as 0.0.0-dev | |||
| 1030 | make test # go test -race ./... | 1045 | make test # go test -race ./... | ||
| 1046 | + | make test-fast # go test ./... | |||
| 1031 | make lint # go vet + gofmt check | 1047 | make lint # go vet + gofmt check | ||
| 1032 | make fmt # gofmt -w . | 1048 | make fmt # gofmt -w . | ||
| 1033 | - | make release # cross-compile linux/darwin/windows on amd64 and arm64 | 1049 | + | make install # install the current checkout with go install |
| 1050 | + | make go-install # install the latest published module version | |||
| 1051 | + | make release VERSION=0.1.0 # cross-compile release binaries | |||
| 1034 | ``` | 1052 | ``` | ||
| 1035 | 1053 | ||||
| 1036 | Source layout (single Go module, four packages under `packages/`): | 1054 | Source layout (single Go module, four packages under `packages/`): | ||
M cmd/zot/main.go +13 -7
| @@ -4,6 +4,7 @@ package main | |||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | + | "regexp" | |
| 7 | 8 | "runtime/debug" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| @@ -15,10 +16,9 @@ import ( | |||
| 15 | 16 | // local builds. Defaults make `zot --version` print something sensible | |
| 16 | 17 | // when built without ldflags. | |
| 17 | 18 | var ( | |
| 18 | - | // 0.0.0 is the pre-release placeholder for local / untagged | |
| 19 | - | // builds. The first published GitHub release will be tagged | |
| 20 | - | // v0.0.1; everything before that ships as 0.0.0 from source. | |
| 21 | - | version = "0.0.0" | |
| 19 | + | // 0.0.0-dev is the placeholder for local / untagged builds. | |
| 20 | + | // Release builds replace it with the Git tag through ldflags. | |
| 21 | + | version = "0.0.0-dev" | |
| 22 | 22 | commit = "" | |
| 23 | 23 | date = "" | |
| 24 | 24 | ) | |
| @@ -44,18 +44,24 @@ func main() { | |||
| 44 | 44 | ||
| 45 | 45 | // resolvedVersion falls back to the module version embedded by Go when zot is | |
| 46 | 46 | // installed with "go install ...@version". Release archives still use the | |
| 47 | - | // version injected by GoReleaser, and local source builds remain 0.0.0. | |
| 47 | + | // version injected by GoReleaser, and local source builds remain 0.0.0-dev. | |
| 48 | 48 | func resolvedVersion(linkedVersion string) string { | |
| 49 | 49 | info, _ := debug.ReadBuildInfo() | |
| 50 | 50 | return resolvedVersionFromBuildInfo(linkedVersion, info) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | 53 | func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string { | |
| 54 | - | if linkedVersion != "" && linkedVersion != "0.0.0" { | |
| 54 | + | if linkedVersion != "" && linkedVersion != "0.0.0" && linkedVersion != "0.0.0-dev" { | |
| 55 | 55 | return linkedVersion | |
| 56 | 56 | } | |
| 57 | - | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" { | |
| 57 | + | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" || isPseudoVersion(info.Main.Version) { | |
| 58 | 58 | return linkedVersion | |
| 59 | 59 | } | |
| 60 | 60 | return strings.TrimPrefix(info.Main.Version, "v") | |
| 61 | 61 | } | |
| 62 | + | ||
| 63 | + | var pseudoVersionPattern = regexp.MustCompile(`^v[0-9]+\.(0\.0-|[0-9]+\.[0-9]+-([^+]*\.)?0\.)[0-9]{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) | |
| 64 | + | ||
| 65 | + | func isPseudoVersion(version string) bool { | |
| 66 | + | return pseudoVersionPattern.MatchString(version) | |
| 67 | + | } | |
| @@ -4,6 +4,7 @@ package main | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | + | "regexp" | |||
| 7 | "runtime/debug" | 8 | "runtime/debug" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| @@ -15,10 +16,9 @@ import ( | |||||
| 15 | // local builds. Defaults make `zot --version` print something sensible | 16 | // local builds. Defaults make `zot --version` print something sensible | ||
| 16 | // when built without ldflags. | 17 | // when built without ldflags. | ||
| 17 | var ( | 18 | var ( | ||
| 18 | - | // 0.0.0 is the pre-release placeholder for local / untagged | 19 | + | // 0.0.0-dev is the placeholder for local / untagged builds. |
| 19 | - | // builds. The first published GitHub release will be tagged | 20 | + | // Release builds replace it with the Git tag through ldflags. |
| 20 | - | // v0.0.1; everything before that ships as 0.0.0 from source. | 21 | + | version = "0.0.0-dev" |
| 21 | - | version = "0.0.0" | |||
| 22 | commit = "" | 22 | commit = "" | ||
| 23 | date = "" | 23 | date = "" | ||
| 24 | ) | 24 | ) | ||
| @@ -44,18 +44,24 @@ func main() { | |||||
| 44 | 44 | ||||
| 45 | // resolvedVersion falls back to the module version embedded by Go when zot is | 45 | // resolvedVersion falls back to the module version embedded by Go when zot is | ||
| 46 | // installed with "go install ...@version". Release archives still use the | 46 | // installed with "go install ...@version". Release archives still use the | ||
| 47 | - | // version injected by GoReleaser, and local source builds remain 0.0.0. | 47 | + | // version injected by GoReleaser, and local source builds remain 0.0.0-dev. |
| 48 | func resolvedVersion(linkedVersion string) string { | 48 | func resolvedVersion(linkedVersion string) string { | ||
| 49 | info, _ := debug.ReadBuildInfo() | 49 | info, _ := debug.ReadBuildInfo() | ||
| 50 | return resolvedVersionFromBuildInfo(linkedVersion, info) | 50 | return resolvedVersionFromBuildInfo(linkedVersion, info) | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string { | 53 | func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string { | ||
| 54 | - | if linkedVersion != "" && linkedVersion != "0.0.0" { | 54 | + | if linkedVersion != "" && linkedVersion != "0.0.0" && linkedVersion != "0.0.0-dev" { |
| 55 | return linkedVersion | 55 | return linkedVersion | ||
| 56 | } | 56 | } | ||
| 57 | - | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" { | 57 | + | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" || isPseudoVersion(info.Main.Version) { |
| 58 | return linkedVersion | 58 | return linkedVersion | ||
| 59 | } | 59 | } | ||
| 60 | return strings.TrimPrefix(info.Main.Version, "v") | 60 | return strings.TrimPrefix(info.Main.Version, "v") | ||
| 61 | } | 61 | } | ||
| 62 | + | ||||
| 63 | + | var pseudoVersionPattern = regexp.MustCompile(`^v[0-9]+\.(0\.0-|[0-9]+\.[0-9]+-([^+]*\.)?0\.)[0-9]{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) | |||
| 64 | + | ||||
| 65 | + | func isPseudoVersion(version string) bool { | |||
| 66 | + | return pseudoVersionPattern.MatchString(version) | |||
| 67 | + | } | |||
M cmd/zot/main_test.go +41 -0
| @@ -5,6 +5,29 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | + | func TestIsPseudoVersion(t *testing.T) { | |
| 9 | + | for _, version := range []string{ | |
| 10 | + | "v0.0.0-20260804145622-bce908a66c9a", | |
| 11 | + | "v0.2.94-0.20260804145622-bce908a66c9a", | |
| 12 | + | "v0.2.94-pre.0.20260804145622-bce908a66c9a", | |
| 13 | + | "v0.2.94-0.20260804145622-bce908a66c9a+incompatible", | |
| 14 | + | } { | |
| 15 | + | if !isPseudoVersion(version) { | |
| 16 | + | t.Errorf("isPseudoVersion(%q) = false, want true", version) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | for _, version := range []string{ | |
| 20 | + | "v0.2.95-20260804145622-abcdef", | |
| 21 | + | "v0.2.94-0.2026080414562-bce908a66c9a", | |
| 22 | + | "v0.2.94-0.20260804145622", | |
| 23 | + | "0.2.94-0.20260804145622-bce908a66c9a", | |
| 24 | + | } { | |
| 25 | + | if isPseudoVersion(version) { | |
| 26 | + | t.Errorf("isPseudoVersion(%q) = true, want false", version) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 8 | 31 | func TestResolvedVersionFromBuildInfo(t *testing.T) { | |
| 9 | 32 | tests := []struct { | |
| 10 | 33 | name string | |
| @@ -18,6 +41,18 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||
| 18 | 41 | moduleVersion: "v0.2.94", | |
| 19 | 42 | want: "0.2.94", | |
| 20 | 43 | }, | |
| 44 | + | { | |
| 45 | + | name: "go install release from dev placeholder", | |
| 46 | + | linkedVersion: "0.0.0-dev", | |
| 47 | + | moduleVersion: "v0.2.94", | |
| 48 | + | want: "0.2.94", | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "local pseudo-version keeps dev placeholder", | |
| 52 | + | linkedVersion: "0.0.0-dev", | |
| 53 | + | moduleVersion: "v0.2.94-0.20260804145622-bce908a66c9a", | |
| 54 | + | want: "0.0.0-dev", | |
| 55 | + | }, | |
| 21 | 56 | { | |
| 22 | 57 | name: "release linker version wins", | |
| 23 | 58 | linkedVersion: "0.2.95", | |
| @@ -30,6 +65,12 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||
| 30 | 65 | moduleVersion: "(devel)", | |
| 31 | 66 | want: "0.0.0", | |
| 32 | 67 | }, | |
| 68 | + | { | |
| 69 | + | name: "local dev build", | |
| 70 | + | linkedVersion: "0.0.0-dev", | |
| 71 | + | moduleVersion: "(devel)", | |
| 72 | + | want: "0.0.0-dev", | |
| 73 | + | }, | |
| 33 | 74 | { | |
| 34 | 75 | name: "missing build info", | |
| 35 | 76 | linkedVersion: "0.0.0", | |
| @@ -5,6 +5,29 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | + | func TestIsPseudoVersion(t *testing.T) { | |||
| 9 | + | for _, version := range []string{ | |||
| 10 | + | "v0.0.0-20260804145622-bce908a66c9a", | |||
| 11 | + | "v0.2.94-0.20260804145622-bce908a66c9a", | |||
| 12 | + | "v0.2.94-pre.0.20260804145622-bce908a66c9a", | |||
| 13 | + | "v0.2.94-0.20260804145622-bce908a66c9a+incompatible", | |||
| 14 | + | } { | |||
| 15 | + | if !isPseudoVersion(version) { | |||
| 16 | + | t.Errorf("isPseudoVersion(%q) = false, want true", version) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | for _, version := range []string{ | |||
| 20 | + | "v0.2.95-20260804145622-abcdef", | |||
| 21 | + | "v0.2.94-0.2026080414562-bce908a66c9a", | |||
| 22 | + | "v0.2.94-0.20260804145622", | |||
| 23 | + | "0.2.94-0.20260804145622-bce908a66c9a", | |||
| 24 | + | } { | |||
| 25 | + | if isPseudoVersion(version) { | |||
| 26 | + | t.Errorf("isPseudoVersion(%q) = true, want false", version) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 8 | func TestResolvedVersionFromBuildInfo(t *testing.T) { | 31 | func TestResolvedVersionFromBuildInfo(t *testing.T) { | ||
| 9 | tests := []struct { | 32 | tests := []struct { | ||
| 10 | name string | 33 | name string | ||
| @@ -18,6 +41,18 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||||
| 18 | moduleVersion: "v0.2.94", | 41 | moduleVersion: "v0.2.94", | ||
| 19 | want: "0.2.94", | 42 | want: "0.2.94", | ||
| 20 | }, | 43 | }, | ||
| 44 | + | { | |||
| 45 | + | name: "go install release from dev placeholder", | |||
| 46 | + | linkedVersion: "0.0.0-dev", | |||
| 47 | + | moduleVersion: "v0.2.94", | |||
| 48 | + | want: "0.2.94", | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "local pseudo-version keeps dev placeholder", | |||
| 52 | + | linkedVersion: "0.0.0-dev", | |||
| 53 | + | moduleVersion: "v0.2.94-0.20260804145622-bce908a66c9a", | |||
| 54 | + | want: "0.0.0-dev", | |||
| 55 | + | }, | |||
| 21 | { | 56 | { | ||
| 22 | name: "release linker version wins", | 57 | name: "release linker version wins", | ||
| 23 | linkedVersion: "0.2.95", | 58 | linkedVersion: "0.2.95", | ||
| @@ -30,6 +65,12 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||||
| 30 | moduleVersion: "(devel)", | 65 | moduleVersion: "(devel)", | ||
| 31 | want: "0.0.0", | 66 | want: "0.0.0", | ||
| 32 | }, | 67 | }, | ||
| 68 | + | { | |||
| 69 | + | name: "local dev build", | |||
| 70 | + | linkedVersion: "0.0.0-dev", | |||
| 71 | + | moduleVersion: "(devel)", | |||
| 72 | + | want: "0.0.0-dev", | |||
| 73 | + | }, | |||
| 33 | { | 74 | { | ||
| 34 | name: "missing build info", | 75 | name: "missing build info", | ||
| 35 | linkedVersion: "0.0.0", | 76 | linkedVersion: "0.0.0", | ||
M docs/extensions.md +28 -14
| @@ -81,15 +81,26 @@ Restart `zot`, type `/hellopy`, the agent greets you. Done. | |||
| 81 | 81 | The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those: | |
| 82 | 82 | ||
| 83 | 83 | ```bash | |
| 84 | - | # go-based examples need a build first | |
| 85 | - | cd path/to/zot/examples/extensions/hello && go build -o hello . | |
| 86 | - | ||
| 87 | - | # install (copies to $ZOT_HOME/extensions/hello/) | |
| 88 | - | zot ext install path/to/zot/examples/extensions/hello | |
| 89 | - | ||
| 90 | - | # or load straight from the repo for one zot session | |
| 91 | - | zot --ext path/to/zot/examples/extensions/hello | |
| 92 | - | ``` | |
| 84 | + | # install a Go example and explicitly build it in the staged install | |
| 85 | + | zot ext install --build=go path/to/zot/examples/extensions/hello | |
| 86 | + | ||
| 87 | + | # or build it in the source tree for a one-run development load | |
| 88 | + | cd path/to/zot/examples/extensions/hello | |
| 89 | + | go build -o hello . | |
| 90 | + | zot --ext . | |
| 91 | + | ``` | |
| 92 | + | ||
| 93 | + | `zot ext install` never builds source code implicitly. For local installs it | |
| 94 | + | validates `extension.json` and any executable path relative to the extension | |
| 95 | + | directory, then stages the copy so a missing or ignored runtime artifact fails | |
| 96 | + | with an explicit error instead of becoming a broken installed extension. For a | |
| 97 | + | local Go extension, opt into the fixed builder explicitly with | |
| 98 | + | `zot ext install --build=go <path>`. The builder runs `go build -trimpath` from | |
| 99 | + | the source directory, writes the manifest-declared relative executable into the | |
| 100 | + | staging directory, and validates it before installation. It may resolve Go | |
| 101 | + | modules and access the network according to the user's Go environment. Bare | |
| 102 | + | launchers such as `go`, `node`, and `npx` remain resolved from `PATH` when zot | |
| 103 | + | starts; they cannot be used as the output of `--build=go`. | |
| 93 | 104 | ||
| 94 | 105 | Nothing is auto-installed and nothing reaches out to the network without your explicit action. | |
| 95 | 106 | ||
| @@ -593,10 +604,11 @@ Reply with `shutdown_ack` and then exit. | |||
| 593 | 604 | ## Managing extensions from the CLI | |
| 594 | 605 | ||
| 595 | 606 | ``` | |
| 596 | - | zot ext list list installed extensions and their state | |
| 597 | - | zot ext doctor diagnose load, registration, and conflict issues | |
| 598 | - | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | |
| 599 | - | zot ext remove <name> delete an extension directory | |
| 607 | + | zot ext list list installed extensions and their state | |
| 608 | + | zot ext doctor diagnose load, registration, and conflict issues | |
| 609 | + | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | |
| 610 | + | zot ext install --build=go <local-path> build local Go source, then install | |
| 611 | + | zot ext remove <name> delete an extension directory | |
| 600 | 612 | zot ext enable <name> re-enable a disabled extension | |
| 601 | 613 | zot ext disable <name> disable without removing | |
| 602 | 614 | zot ext logs <name> [-f] cat / tail the extension's stderr | |
| @@ -610,7 +622,9 @@ warnings, and each extension's stderr log path. | |||
| 610 | 622 | ||
| 611 | 623 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | |
| 612 | 624 | shallow clone. Both validate that the destination contains an | |
| 613 | - | `extension.json` and roll back if not. | |
| 625 | + | `extension.json` and roll back if not. No build runs unless the local install | |
| 626 | + | is invoked with the explicit `--build=go` option. That option currently supports | |
| 627 | + | only local Go source paths; clone remote source locally before building it. | |
| 614 | 628 | ||
| 615 | 629 | ## Loading an extension for one run | |
| 616 | 630 | ||
| @@ -81,15 +81,26 @@ Restart `zot`, type `/hellopy`, the agent greets you. Done. | |||||
| 81 | The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those: | 81 | The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those: | ||
| 82 | 82 | ||||
| 83 | ```bash | 83 | ```bash | ||
| 84 | - | # go-based examples need a build first | 84 | + | # install a Go example and explicitly build it in the staged install |
| 85 | - | cd path/to/zot/examples/extensions/hello && go build -o hello . | 85 | + | zot ext install --build=go path/to/zot/examples/extensions/hello |
| 86 | - | 86 | + | ||
| 87 | - | # install (copies to $ZOT_HOME/extensions/hello/) | 87 | + | # or build it in the source tree for a one-run development load |
| 88 | - | zot ext install path/to/zot/examples/extensions/hello | 88 | + | cd path/to/zot/examples/extensions/hello |
| 89 | - | 89 | + | go build -o hello . | |
| 90 | - | # or load straight from the repo for one zot session | 90 | + | zot --ext . |
| 91 | - | zot --ext path/to/zot/examples/extensions/hello | 91 | + | ``` |
| 92 | - | ``` | 92 | + | |
| 93 | + | `zot ext install` never builds source code implicitly. For local installs it | |||
| 94 | + | validates `extension.json` and any executable path relative to the extension | |||
| 95 | + | directory, then stages the copy so a missing or ignored runtime artifact fails | |||
| 96 | + | with an explicit error instead of becoming a broken installed extension. For a | |||
| 97 | + | local Go extension, opt into the fixed builder explicitly with | |||
| 98 | + | `zot ext install --build=go <path>`. The builder runs `go build -trimpath` from | |||
| 99 | + | the source directory, writes the manifest-declared relative executable into the | |||
| 100 | + | staging directory, and validates it before installation. It may resolve Go | |||
| 101 | + | modules and access the network according to the user's Go environment. Bare | |||
| 102 | + | launchers such as `go`, `node`, and `npx` remain resolved from `PATH` when zot | |||
| 103 | + | starts; they cannot be used as the output of `--build=go`. | |||
| 93 | 104 | ||||
| 94 | Nothing is auto-installed and nothing reaches out to the network without your explicit action. | 105 | Nothing is auto-installed and nothing reaches out to the network without your explicit action. | ||
| 95 | 106 | ||||
| @@ -593,10 +604,11 @@ Reply with `shutdown_ack` and then exit. | |||||
| 593 | ## Managing extensions from the CLI | 604 | ## Managing extensions from the CLI | ||
| 594 | 605 | ||||
| 595 | ``` | 606 | ``` | ||
| 596 | - | zot ext list list installed extensions and their state | 607 | + | zot ext list list installed extensions and their state |
| 597 | - | zot ext doctor diagnose load, registration, and conflict issues | 608 | + | zot ext doctor diagnose load, registration, and conflict issues |
| 598 | - | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | 609 | + | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ |
| 599 | - | zot ext remove <name> delete an extension directory | 610 | + | zot ext install --build=go <local-path> build local Go source, then install |
| 611 | + | zot ext remove <name> delete an extension directory | |||
| 600 | zot ext enable <name> re-enable a disabled extension | 612 | zot ext enable <name> re-enable a disabled extension | ||
| 601 | zot ext disable <name> disable without removing | 613 | zot ext disable <name> disable without removing | ||
| 602 | zot ext logs <name> [-f] cat / tail the extension's stderr | 614 | zot ext logs <name> [-f] cat / tail the extension's stderr | ||
| @@ -610,7 +622,9 @@ warnings, and each extension's stderr log path. | |||||
| 610 | 622 | ||||
| 611 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | 623 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | ||
| 612 | shallow clone. Both validate that the destination contains an | 624 | shallow clone. Both validate that the destination contains an | ||
| 613 | - | `extension.json` and roll back if not. | 625 | + | `extension.json` and roll back if not. No build runs unless the local install |
| 626 | + | is invoked with the explicit `--build=go` option. That option currently supports | |||
| 627 | + | only local Go source paths; clone remote source locally before building it. | |||
| 614 | 628 | ||||
| 615 | ## Loading an extension for one run | 629 | ## Loading an extension for one run | ||
| 616 | 630 | ||||
M examples/extensions/tasked-phases/README.md +6 -1
| @@ -32,9 +32,14 @@ zot --no-ext --ext /path/to/zot/examples/extensions/tasked-phases | |||
| 32 | 32 | ||
| 33 | 33 | # Or install it globally (run this from the extension directory): | |
| 34 | 34 | cd /path/to/zot/examples/extensions/tasked-phases | |
| 35 | - | zot ext install . | |
| 35 | + | zot ext install --build=go . | |
| 36 | 36 | ``` | |
| 37 | 37 | ||
| 38 | + | `--build=go` explicitly builds the manifest-declared executable in the staged | |
| 39 | + | installation. If an incomplete copy was installed already, remove it with | |
| 40 | + | `zot ext remove tasked-phases --yes` before reinstalling. You can also build | |
| 41 | + | manually with `go build -o tasked-phases .` and then use `zot ext install .`. | |
| 42 | + | ||
| 38 | 43 | Then use `/phases` or ask the model to create a spec and phased checklist with | |
| 39 | 44 | `tasked_phases`. The extension keys its durable state by the host-reported | |
| 40 | 45 | project CWD. | |
| @@ -32,9 +32,14 @@ zot --no-ext --ext /path/to/zot/examples/extensions/tasked-phases | |||||
| 32 | 32 | ||||
| 33 | # Or install it globally (run this from the extension directory): | 33 | # Or install it globally (run this from the extension directory): | ||
| 34 | cd /path/to/zot/examples/extensions/tasked-phases | 34 | cd /path/to/zot/examples/extensions/tasked-phases | ||
| 35 | - | zot ext install . | 35 | + | zot ext install --build=go . |
| 36 | ``` | 36 | ``` | ||
| 37 | 37 | ||||
| 38 | + | `--build=go` explicitly builds the manifest-declared executable in the staged | |||
| 39 | + | installation. If an incomplete copy was installed already, remove it with | |||
| 40 | + | `zot ext remove tasked-phases --yes` before reinstalling. You can also build | |||
| 41 | + | manually with `go build -o tasked-phases .` and then use `zot ext install .`. | |||
| 42 | + | ||||
| 38 | Then use `/phases` or ask the model to create a spec and phased checklist with | 43 | Then use `/phases` or ask the model to create a spec and phased checklist with | ||
| 39 | `tasked_phases`. The extension keys its durable state by the host-reported | 44 | `tasked_phases`. The extension keys its durable state by the host-reported | ||
| 40 | project CWD. | 45 | project CWD. | ||
M go.mod +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | module github.com/patriceckhart/zot | |
| 2 | 2 | ||
| 3 | - | go 1.25.0 | |
| 3 | + | go 1.26 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | module github.com/patriceckhart/zot | 1 | module github.com/patriceckhart/zot | ||
| 2 | 2 | ||||
| 3 | - | go 1.25.0 | 3 | + | go 1.26 |
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/alecthomas/chroma/v2 v2.23.1 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | ||
M packages/agent/args.go +2 -1
| @@ -423,7 +423,8 @@ func PrintHelp(version string) { | |||
| 423 | 423 | ) | |
| 424 | 424 | section("extensions", | |
| 425 | 425 | row{"zot ext list", "list installed extensions"}, | |
| 426 | - | row{"zot ext install <path|url>", "install into $ZOT_HOME/extensions/"}, | |
| 426 | + | row{"zot ext install <path|git-url>", "install from a local path or Git URL"}, | |
| 427 | + | row{"zot ext install --build=go <path>", "build local Go source, then install"}, | |
| 427 | 428 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | |
| 428 | 429 | row{"zot ext help", "show all extension subcommands"}, | |
| 429 | 430 | ) | |
| @@ -423,7 +423,8 @@ func PrintHelp(version string) { | |||||
| 423 | ) | 423 | ) | ||
| 424 | section("extensions", | 424 | section("extensions", | ||
| 425 | row{"zot ext list", "list installed extensions"}, | 425 | row{"zot ext list", "list installed extensions"}, | ||
| 426 | - | row{"zot ext install <path|url>", "install into $ZOT_HOME/extensions/"}, | 426 | + | row{"zot ext install <path|git-url>", "install from a local path or Git URL"}, |
| 427 | + | row{"zot ext install --build=go <path>", "build local Go source, then install"}, | |||
| 427 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | 428 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | ||
| 428 | row{"zot ext help", "show all extension subcommands"}, | 429 | row{"zot ext help", "show all extension subcommands"}, | ||
| 429 | ) | 430 | ) | ||
M packages/agent/changelog.go +13 -23
| @@ -36,23 +36,16 @@ func semverOnly(v string) string { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | |
| 39 | - | version = semverOnly(version) | |
| 40 | - | if version == "" || version == "dev" { | |
| 39 | + | if isDevVersion(version) { | |
| 41 | 40 | return ChangelogInfo{}, nil | |
| 42 | 41 | } | |
| 42 | + | version = semverOnly(version) | |
| 43 | 43 | ||
| 44 | - | // For local dev builds (0.0.0), fetch the latest release instead | |
| 45 | - | // of a tagged one so developers always see the newest changelog. | |
| 46 | - | var url string | |
| 47 | - | if version == "0.0.0" { | |
| 48 | - | url = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | |
| 49 | - | } else { | |
| 50 | - | tag := version | |
| 51 | - | if !strings.HasPrefix(tag, "v") { | |
| 52 | - | tag = "v" + tag | |
| 53 | - | } | |
| 54 | - | url = fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |
| 44 | + | tag := version | |
| 45 | + | if !strings.HasPrefix(tag, "v") { | |
| 46 | + | tag = "v" + tag | |
| 55 | 47 | } | |
| 48 | + | url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |
| 56 | 49 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 57 | 50 | if err != nil { | |
| 58 | 51 | return ChangelogInfo{}, err | |
| @@ -159,20 +152,17 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo { | |||
| 159 | 152 | ||
| 160 | 153 | // ShouldShowChangelog reports whether the running binary version | |
| 161 | 154 | // differs from the last version whose changelog the user dismissed. | |
| 162 | - | // Returns false on dev builds (version "" / "dev" / "0.0.0") and on | |
| 163 | - | // the first-ever launch (no LastChangelogShown stored — we don't | |
| 164 | - | // dump release notes at someone who just installed). | |
| 155 | + | // Returns false on development builds and on the first-ever launch (no | |
| 156 | + | // LastChangelogShown stored — we don't dump release notes at someone who | |
| 157 | + | // just installed). | |
| 165 | 158 | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | |
| 166 | - | currentVersion = semverOnly(currentVersion) | |
| 167 | - | if currentVersion == "" || currentVersion == "dev" { | |
| 159 | + | if isDevVersion(currentVersion) { | |
| 168 | 160 | return false | |
| 169 | 161 | } | |
| 162 | + | currentVersion = semverOnly(currentVersion) | |
| 170 | 163 | if cfg.LastChangelogShown == "" { | |
| 171 | 164 | return false | |
| 172 | 165 | } | |
| 173 | - | if currentVersion == "0.0.0" { | |
| 174 | - | return true | |
| 175 | - | } | |
| 176 | 166 | return semverOnly(cfg.LastChangelogShown) != currentVersion | |
| 177 | 167 | } | |
| 178 | 168 | ||
| @@ -194,10 +184,10 @@ func MarkChangelogShown(version string) error { | |||
| 194 | 184 | // correctly trigger the dialog while THIS launch (which is also | |
| 195 | 185 | // "first-ever") doesn't. | |
| 196 | 186 | func SeedChangelogVersion(version string) { | |
| 197 | - | version = semverOnly(version) | |
| 198 | - | if version == "" || version == "dev" { | |
| 187 | + | if isDevVersion(version) { | |
| 199 | 188 | return | |
| 200 | 189 | } | |
| 190 | + | version = semverOnly(version) | |
| 201 | 191 | cfg, err := LoadConfig() | |
| 202 | 192 | if err != nil { | |
| 203 | 193 | return | |
| @@ -36,23 +36,16 @@ func semverOnly(v string) string { | |||||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | 38 | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | ||
| 39 | - | version = semverOnly(version) | 39 | + | if isDevVersion(version) { |
| 40 | - | if version == "" || version == "dev" { | |||
| 41 | return ChangelogInfo{}, nil | 40 | return ChangelogInfo{}, nil | ||
| 42 | } | 41 | } | ||
| 42 | + | version = semverOnly(version) | |||
| 43 | 43 | ||||
| 44 | - | // For local dev builds (0.0.0), fetch the latest release instead | 44 | + | tag := version |
| 45 | - | // of a tagged one so developers always see the newest changelog. | 45 | + | if !strings.HasPrefix(tag, "v") { |
| 46 | - | var url string | 46 | + | tag = "v" + tag |
| 47 | - | if version == "0.0.0" { | |||
| 48 | - | url = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | |||
| 49 | - | } else { | |||
| 50 | - | tag := version | |||
| 51 | - | if !strings.HasPrefix(tag, "v") { | |||
| 52 | - | tag = "v" + tag | |||
| 53 | - | } | |||
| 54 | - | url = fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |||
| 55 | } | 47 | } | ||
| 48 | + | url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |||
| 56 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | 49 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | ||
| 57 | if err != nil { | 50 | if err != nil { | ||
| 58 | return ChangelogInfo{}, err | 51 | return ChangelogInfo{}, err | ||
| @@ -159,20 +152,17 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo { | |||||
| 159 | 152 | ||||
| 160 | // ShouldShowChangelog reports whether the running binary version | 153 | // ShouldShowChangelog reports whether the running binary version | ||
| 161 | // differs from the last version whose changelog the user dismissed. | 154 | // differs from the last version whose changelog the user dismissed. | ||
| 162 | - | // Returns false on dev builds (version "" / "dev" / "0.0.0") and on | 155 | + | // Returns false on development builds and on the first-ever launch (no |
| 163 | - | // the first-ever launch (no LastChangelogShown stored — we don't | 156 | + | // LastChangelogShown stored — we don't dump release notes at someone who |
| 164 | - | // dump release notes at someone who just installed). | 157 | + | // just installed). |
| 165 | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | 158 | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | ||
| 166 | - | currentVersion = semverOnly(currentVersion) | 159 | + | if isDevVersion(currentVersion) { |
| 167 | - | if currentVersion == "" || currentVersion == "dev" { | |||
| 168 | return false | 160 | return false | ||
| 169 | } | 161 | } | ||
| 162 | + | currentVersion = semverOnly(currentVersion) | |||
| 170 | if cfg.LastChangelogShown == "" { | 163 | if cfg.LastChangelogShown == "" { | ||
| 171 | return false | 164 | return false | ||
| 172 | } | 165 | } | ||
| 173 | - | if currentVersion == "0.0.0" { | |||
| 174 | - | return true | |||
| 175 | - | } | |||
| 176 | return semverOnly(cfg.LastChangelogShown) != currentVersion | 166 | return semverOnly(cfg.LastChangelogShown) != currentVersion | ||
| 177 | } | 167 | } | ||
| 178 | 168 | ||||
| @@ -194,10 +184,10 @@ func MarkChangelogShown(version string) error { | |||||
| 194 | // correctly trigger the dialog while THIS launch (which is also | 184 | // correctly trigger the dialog while THIS launch (which is also | ||
| 195 | // "first-ever") doesn't. | 185 | // "first-ever") doesn't. | ||
| 196 | func SeedChangelogVersion(version string) { | 186 | func SeedChangelogVersion(version string) { | ||
| 197 | - | version = semverOnly(version) | 187 | + | if isDevVersion(version) { |
| 198 | - | if version == "" || version == "dev" { | |||
| 199 | return | 188 | return | ||
| 200 | } | 189 | } | ||
| 190 | + | version = semverOnly(version) | |||
| 201 | cfg, err := LoadConfig() | 191 | cfg, err := LoadConfig() | ||
| 202 | if err != nil { | 192 | if err != nil { | ||
| 203 | return | 193 | return | ||
A packages/agent/changelog_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestFetchChangelogSkipsDevVersions(t *testing.T) { | |
| 9 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 10 | + | cancel() | |
| 11 | + | ||
| 12 | + | for _, version := range []string{"0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)"} { | |
| 13 | + | info, err := FetchChangelog(ctx, version) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatalf("FetchChangelog(%q) error = %v", version, err) | |
| 16 | + | } | |
| 17 | + | if info != (ChangelogInfo{}) { | |
| 18 | + | t.Fatalf("FetchChangelog(%q) = %+v, want empty result", version, info) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestShouldShowChangelogSkipsDevVersions(t *testing.T) { | |
| 24 | + | cfg := Config{LastChangelogShown: "0.0.1"} | |
| 25 | + | for _, version := range []string{"0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)"} { | |
| 26 | + | if ShouldShowChangelog(version, cfg) { | |
| 27 | + | t.Errorf("ShouldShowChangelog(%q) = true, want false", version) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestFetchChangelogSkipsDevVersions(t *testing.T) { | |||
| 9 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 10 | + | cancel() | |||
| 11 | + | ||||
| 12 | + | for _, version := range []string{"0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)"} { | |||
| 13 | + | info, err := FetchChangelog(ctx, version) | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatalf("FetchChangelog(%q) error = %v", version, err) | |||
| 16 | + | } | |||
| 17 | + | if info != (ChangelogInfo{}) { | |||
| 18 | + | t.Fatalf("FetchChangelog(%q) = %+v, want empty result", version, info) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestShouldShowChangelogSkipsDevVersions(t *testing.T) { | |||
| 24 | + | cfg := Config{LastChangelogShown: "0.0.1"} | |||
| 25 | + | for _, version := range []string{"0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)"} { | |||
| 26 | + | if ShouldShowChangelog(version, cfg) { | |||
| 27 | + | t.Errorf("ShouldShowChangelog(%q) = true, want false", version) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | } | |||
M packages/agent/cli.go +62 -49
| @@ -666,12 +666,22 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||
| 666 | 666 | ||
| 667 | 667 | // Run is the top-level entrypoint for the zot binary. | |
| 668 | 668 | func Run(rawArgs []string, version string) error { | |
| 669 | + | // Extension installs can invoke external build and clone processes. | |
| 670 | + | // Give those processes a cancellation context without changing signal | |
| 671 | + | // handling for the interactive modes below. | |
| 672 | + | extCtx := context.Background() | |
| 673 | + | stopExtContext := func() {} | |
| 674 | + | if len(rawArgs) > 0 && rawArgs[0] == "ext" { | |
| 675 | + | extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 676 | + | } | |
| 677 | + | defer stopExtContext() | |
| 678 | + | ||
| 669 | 679 | // Subcommand router: `zot bot ...` is handled separately so the | |
| 670 | 680 | // generic flag parser doesn't reject "bot" as a positional arg. | |
| 671 | 681 | if handled, err := runBotCommand(rawArgs, version); handled { | |
| 672 | 682 | return err | |
| 673 | 683 | } | |
| 674 | - | if handled, err := runExtCommand(rawArgs, version); handled { | |
| 684 | + | if handled, err := runExtCommand(extCtx, rawArgs, version); handled { | |
| 675 | 685 | return err | |
| 676 | 686 | } | |
| 677 | 687 | if handled, err := runUpdateCommand(rawArgs, version); handled { | |
| @@ -1880,55 +1890,58 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1880 | 1890 | return nil | |
| 1881 | 1891 | } | |
| 1882 | 1892 | ||
| 1893 | + | devBuild := isDevVersion(version) | |
| 1894 | + | if devBuild { | |
| 1895 | + | fmt.Fprintln(os.Stderr, "zot:", devVersionNotice) | |
| 1896 | + | } | |
| 1883 | 1897 | term := tui.NewProcTerm() | |
| 1884 | 1898 | ||
| 1885 | - | // Kick off the async update check so the banner can appear when the | |
| 1886 | - | // http response eventually arrives (usually <1s on cached DNS). Map | |
| 1887 | - | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | |
| 1888 | - | updateCh := make(chan modes.UpdateInfo, 1) | |
| 1889 | - | go func() { | |
| 1890 | - | defer close(updateCh) | |
| 1891 | - | src := <-CheckForUpdateAsync(ZotHome(), version) | |
| 1892 | - | updateCh <- modes.UpdateInfo{ | |
| 1893 | - | Current: src.Current, | |
| 1894 | - | Latest: src.Latest, | |
| 1895 | - | Available: src.Available, | |
| 1896 | - | URL: src.URL, | |
| 1897 | - | } | |
| 1898 | - | }() | |
| 1899 | + | var updateCh chan modes.UpdateInfo | |
| 1900 | + | var changelogCh chan modes.ChangelogPayload | |
| 1901 | + | if !devBuild { | |
| 1902 | + | // Kick off the async update check so the banner can appear when the | |
| 1903 | + | // http response eventually arrives (usually <1s on cached DNS). Map | |
| 1904 | + | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | |
| 1905 | + | updateCh = make(chan modes.UpdateInfo, 1) | |
| 1906 | + | go func() { | |
| 1907 | + | defer close(updateCh) | |
| 1908 | + | src := <-CheckForUpdateAsync(ZotHome(), version) | |
| 1909 | + | updateCh <- modes.UpdateInfo{ | |
| 1910 | + | Current: src.Current, | |
| 1911 | + | Latest: src.Latest, | |
| 1912 | + | Available: src.Available, | |
| 1913 | + | URL: src.URL, | |
| 1914 | + | } | |
| 1915 | + | }() | |
| 1899 | 1916 | ||
| 1900 | - | // Changelog: when the running version differs from the last | |
| 1901 | - | // version whose release notes the user dismissed, fetch the | |
| 1902 | - | // release body from GitHub and have the TUI show it once. On | |
| 1903 | - | // first-ever launch (no prior LastChangelogShown), seed the | |
| 1904 | - | // stored version silently — don't dump release notes at someone | |
| 1905 | - | // who just installed. | |
| 1906 | - | changelogCh := make(chan modes.ChangelogPayload, 1) | |
| 1907 | - | go func() { | |
| 1908 | - | defer close(changelogCh) | |
| 1909 | - | cfg, _ := LoadConfig() | |
| 1910 | - | if cfg.LastChangelogShown == "" { | |
| 1911 | - | SeedChangelogVersion(version) | |
| 1912 | - | return | |
| 1913 | - | } | |
| 1914 | - | if !ShouldShowChangelog(version, cfg) { | |
| 1915 | - | return | |
| 1916 | - | } | |
| 1917 | - | info := <-FetchChangelogAsync(version) | |
| 1918 | - | if info.Body == "" { | |
| 1919 | - | return | |
| 1920 | - | } | |
| 1921 | - | // For dev builds (0.0.0), skip if the latest release was | |
| 1922 | - | // already shown (stored by the dismiss callback). | |
| 1923 | - | if version == "0.0.0" && info.Version == cfg.LastChangelogShown { | |
| 1924 | - | return | |
| 1925 | - | } | |
| 1926 | - | changelogCh <- modes.ChangelogPayload{ | |
| 1927 | - | Version: info.Version, | |
| 1928 | - | Body: info.Body, | |
| 1929 | - | URL: info.URL, | |
| 1930 | - | } | |
| 1931 | - | }() | |
| 1917 | + | // Changelog: when the running version differs from the last | |
| 1918 | + | // version whose release notes the user dismissed, fetch the | |
| 1919 | + | // release body from GitHub and have the TUI show it once. On | |
| 1920 | + | // first-ever launch (no prior LastChangelogShown), seed the | |
| 1921 | + | // stored version silently — don't dump release notes at someone | |
| 1922 | + | // who just installed. | |
| 1923 | + | changelogCh = make(chan modes.ChangelogPayload, 1) | |
| 1924 | + | go func() { | |
| 1925 | + | defer close(changelogCh) | |
| 1926 | + | cfg, _ := LoadConfig() | |
| 1927 | + | if cfg.LastChangelogShown == "" { | |
| 1928 | + | SeedChangelogVersion(version) | |
| 1929 | + | return | |
| 1930 | + | } | |
| 1931 | + | if !ShouldShowChangelog(version, cfg) { | |
| 1932 | + | return | |
| 1933 | + | } | |
| 1934 | + | info := <-FetchChangelogAsync(version) | |
| 1935 | + | if info.Body == "" { | |
| 1936 | + | return | |
| 1937 | + | } | |
| 1938 | + | changelogCh <- modes.ChangelogPayload{ | |
| 1939 | + | Version: info.Version, | |
| 1940 | + | Body: info.Body, | |
| 1941 | + | URL: info.URL, | |
| 1942 | + | } | |
| 1943 | + | }() | |
| 1944 | + | } | |
| 1932 | 1945 | ||
| 1933 | 1946 | initialCfg, _ := LoadConfig() | |
| 1934 | 1947 | fastMode := r.FastMode | |
| @@ -2105,11 +2118,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2105 | 2118 | ResolveSubagent: resolveSubagent, | |
| 2106 | 2119 | ChangelogChan: changelogCh, | |
| 2107 | 2120 | OnChangelogDismiss: func() { | |
| 2108 | - | // For dev builds (0.0.0) store the actual release version | |
| 2121 | + | // For development builds, store the actual release version | |
| 2109 | 2122 | // so the same changelog doesn't show again next launch. | |
| 2110 | 2123 | // For real builds, store the binary version. | |
| 2111 | 2124 | v := version | |
| 2112 | - | if v == "0.0.0" { | |
| 2125 | + | if devBuild { | |
| 2113 | 2126 | if iv != nil && iv.ChangelogVersion() != "" { | |
| 2114 | 2127 | v = iv.ChangelogVersion() | |
| 2115 | 2128 | } | |
| @@ -666,12 +666,22 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||||
| 666 | 666 | ||||
| 667 | // Run is the top-level entrypoint for the zot binary. | 667 | // Run is the top-level entrypoint for the zot binary. | ||
| 668 | func Run(rawArgs []string, version string) error { | 668 | func Run(rawArgs []string, version string) error { | ||
| 669 | + | // Extension installs can invoke external build and clone processes. | |||
| 670 | + | // Give those processes a cancellation context without changing signal | |||
| 671 | + | // handling for the interactive modes below. | |||
| 672 | + | extCtx := context.Background() | |||
| 673 | + | stopExtContext := func() {} | |||
| 674 | + | if len(rawArgs) > 0 && rawArgs[0] == "ext" { | |||
| 675 | + | extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |||
| 676 | + | } | |||
| 677 | + | defer stopExtContext() | |||
| 678 | + | ||||
| 669 | // Subcommand router: `zot bot ...` is handled separately so the | 679 | // Subcommand router: `zot bot ...` is handled separately so the | ||
| 670 | // generic flag parser doesn't reject "bot" as a positional arg. | 680 | // generic flag parser doesn't reject "bot" as a positional arg. | ||
| 671 | if handled, err := runBotCommand(rawArgs, version); handled { | 681 | if handled, err := runBotCommand(rawArgs, version); handled { | ||
| 672 | return err | 682 | return err | ||
| 673 | } | 683 | } | ||
| 674 | - | if handled, err := runExtCommand(rawArgs, version); handled { | 684 | + | if handled, err := runExtCommand(extCtx, rawArgs, version); handled { |
| 675 | return err | 685 | return err | ||
| 676 | } | 686 | } | ||
| 677 | if handled, err := runUpdateCommand(rawArgs, version); handled { | 687 | if handled, err := runUpdateCommand(rawArgs, version); handled { | ||
| @@ -1880,55 +1890,58 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1880 | return nil | 1890 | return nil | ||
| 1881 | } | 1891 | } | ||
| 1882 | 1892 | ||||
| 1893 | + | devBuild := isDevVersion(version) | |||
| 1894 | + | if devBuild { | |||
| 1895 | + | fmt.Fprintln(os.Stderr, "zot:", devVersionNotice) | |||
| 1896 | + | } | |||
| 1883 | term := tui.NewProcTerm() | 1897 | term := tui.NewProcTerm() | ||
| 1884 | 1898 | ||||
| 1885 | - | // Kick off the async update check so the banner can appear when the | 1899 | + | var updateCh chan modes.UpdateInfo |
| 1886 | - | // http response eventually arrives (usually <1s on cached DNS). Map | 1900 | + | var changelogCh chan modes.ChangelogPayload |
| 1887 | - | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | 1901 | + | if !devBuild { |
| 1888 | - | updateCh := make(chan modes.UpdateInfo, 1) | 1902 | + | // Kick off the async update check so the banner can appear when the |
| 1889 | - | go func() { | 1903 | + | // http response eventually arrives (usually <1s on cached DNS). Map |
| 1890 | - | defer close(updateCh) | 1904 | + | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. |
| 1891 | - | src := <-CheckForUpdateAsync(ZotHome(), version) | 1905 | + | updateCh = make(chan modes.UpdateInfo, 1) |
| 1892 | - | updateCh <- modes.UpdateInfo{ | 1906 | + | go func() { |
| 1893 | - | Current: src.Current, | 1907 | + | defer close(updateCh) |
| 1894 | - | Latest: src.Latest, | 1908 | + | src := <-CheckForUpdateAsync(ZotHome(), version) |
| 1895 | - | Available: src.Available, | 1909 | + | updateCh <- modes.UpdateInfo{ |
| 1896 | - | URL: src.URL, | 1910 | + | Current: src.Current, |
| 1897 | - | } | 1911 | + | Latest: src.Latest, |
| 1898 | - | }() | 1912 | + | Available: src.Available, |
| 1913 | + | URL: src.URL, | |||
| 1914 | + | } | |||
| 1915 | + | }() | |||
| 1899 | 1916 | ||||
| 1900 | - | // Changelog: when the running version differs from the last | 1917 | + | // Changelog: when the running version differs from the last |
| 1901 | - | // version whose release notes the user dismissed, fetch the | 1918 | + | // version whose release notes the user dismissed, fetch the |
| 1902 | - | // release body from GitHub and have the TUI show it once. On | 1919 | + | // release body from GitHub and have the TUI show it once. On |
| 1903 | - | // first-ever launch (no prior LastChangelogShown), seed the | 1920 | + | // first-ever launch (no prior LastChangelogShown), seed the |
| 1904 | - | // stored version silently — don't dump release notes at someone | 1921 | + | // stored version silently — don't dump release notes at someone |
| 1905 | - | // who just installed. | 1922 | + | // who just installed. |
| 1906 | - | changelogCh := make(chan modes.ChangelogPayload, 1) | 1923 | + | changelogCh = make(chan modes.ChangelogPayload, 1) |
| 1907 | - | go func() { | 1924 | + | go func() { |
| 1908 | - | defer close(changelogCh) | 1925 | + | defer close(changelogCh) |
| 1909 | - | cfg, _ := LoadConfig() | 1926 | + | cfg, _ := LoadConfig() |
| 1910 | - | if cfg.LastChangelogShown == "" { | 1927 | + | if cfg.LastChangelogShown == "" { |
| 1911 | - | SeedChangelogVersion(version) | 1928 | + | SeedChangelogVersion(version) |
| 1912 | - | return | 1929 | + | return |
| 1913 | - | } | 1930 | + | } |
| 1914 | - | if !ShouldShowChangelog(version, cfg) { | 1931 | + | if !ShouldShowChangelog(version, cfg) { |
| 1915 | - | return | 1932 | + | return |
| 1916 | - | } | 1933 | + | } |
| 1917 | - | info := <-FetchChangelogAsync(version) | 1934 | + | info := <-FetchChangelogAsync(version) |
| 1918 | - | if info.Body == "" { | 1935 | + | if info.Body == "" { |
| 1919 | - | return | 1936 | + | return |
| 1920 | - | } | 1937 | + | } |
| 1921 | - | // For dev builds (0.0.0), skip if the latest release was | 1938 | + | changelogCh <- modes.ChangelogPayload{ |
| 1922 | - | // already shown (stored by the dismiss callback). | 1939 | + | Version: info.Version, |
| 1923 | - | if version == "0.0.0" && info.Version == cfg.LastChangelogShown { | 1940 | + | Body: info.Body, |
| 1924 | - | return | 1941 | + | URL: info.URL, |
| 1925 | - | } | 1942 | + | } |
| 1926 | - | changelogCh <- modes.ChangelogPayload{ | 1943 | + | }() |
| 1927 | - | Version: info.Version, | 1944 | + | } |
| 1928 | - | Body: info.Body, | |||
| 1929 | - | URL: info.URL, | |||
| 1930 | - | } | |||
| 1931 | - | }() | |||
| 1932 | 1945 | ||||
| 1933 | initialCfg, _ := LoadConfig() | 1946 | initialCfg, _ := LoadConfig() | ||
| 1934 | fastMode := r.FastMode | 1947 | fastMode := r.FastMode | ||
| @@ -2105,11 +2118,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2105 | ResolveSubagent: resolveSubagent, | 2118 | ResolveSubagent: resolveSubagent, | ||
| 2106 | ChangelogChan: changelogCh, | 2119 | ChangelogChan: changelogCh, | ||
| 2107 | OnChangelogDismiss: func() { | 2120 | OnChangelogDismiss: func() { | ||
| 2108 | - | // For dev builds (0.0.0) store the actual release version | 2121 | + | // For development builds, store the actual release version |
| 2109 | // so the same changelog doesn't show again next launch. | 2122 | // so the same changelog doesn't show again next launch. | ||
| 2110 | // For real builds, store the binary version. | 2123 | // For real builds, store the binary version. | ||
| 2111 | v := version | 2124 | v := version | ||
| 2112 | - | if v == "0.0.0" { | 2125 | + | if devBuild { |
| 2113 | if iv != nil && iv.ChangelogVersion() != "" { | 2126 | if iv != nil && iv.ChangelogVersion() != "" { | ||
| 2114 | v = iv.ChangelogVersion() | 2127 | v = iv.ChangelogVersion() | ||
| 2115 | } | 2128 | } | ||
M packages/agent/extcmd.go +284 -42
| @@ -3,11 +3,13 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "os" | |
| 9 | 10 | "os/exec" | |
| 10 | 11 | "path/filepath" | |
| 12 | + | "runtime" | |
| 11 | 13 | "sort" | |
| 12 | 14 | "strings" | |
| 13 | 15 | "time" | |
| @@ -21,7 +23,7 @@ import ( | |||
| 21 | 23 | // (handled=true, err) if rawArgs starts with "ext"; otherwise | |
| 22 | 24 | // (handled=false, nil) so the main router falls through to the | |
| 23 | 25 | // regular flag parser. | |
| 24 | - | func runExtCommand(rawArgs []string, version string) (handled bool, err error) { | |
| 26 | + | func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) { | |
| 25 | 27 | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | |
| 26 | 28 | return false, nil | |
| 27 | 29 | } | |
| @@ -43,7 +45,7 @@ func runExtCommand(rawArgs []string, version string) (handled bool, err error) { | |||
| 43 | 45 | case "remove", "rm": | |
| 44 | 46 | return true, extRemove(rawArgs[2:]) | |
| 45 | 47 | case "install": | |
| 46 | - | return true, extInstall(rawArgs[2:]) | |
| 48 | + | return true, extInstallContext(ctx, rawArgs[2:]) | |
| 47 | 49 | case "help", "-h", "--help": | |
| 48 | 50 | printExtHelp() | |
| 49 | 51 | return true, nil | |
| @@ -62,8 +64,10 @@ usage: | |||
| 62 | 64 | zot ext logs <name> [-f] cat / tail an extension's stderr log | |
| 63 | 65 | zot ext enable <name> re-enable a disabled extension | |
| 64 | 66 | zot ext disable <name> disable without removing | |
| 65 | - | zot ext remove <name> delete an extension directory | |
| 66 | - | zot ext install <path|git-url> copy / clone an extension into $ZOT_HOME/extensions/ | |
| 67 | + | zot ext remove <name> delete an extension directory | |
| 68 | + | zot ext install [--build=go] <path|git-url> | |
| 69 | + | copy / clone and validate an extension | |
| 70 | + | --build=go explicitly builds a local Go extension | |
| 67 | 71 | ||
| 68 | 72 | extensions live under: | |
| 69 | 73 | $ZOT_HOME/extensions/<name>/extension.json (global) | |
| @@ -88,7 +92,7 @@ func extList() error { | |||
| 88 | 92 | continue | |
| 89 | 93 | } | |
| 90 | 94 | for _, e := range entries { | |
| 91 | - | if !e.IsDir() { | |
| 95 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { | |
| 92 | 96 | continue | |
| 93 | 97 | } | |
| 94 | 98 | extDir := filepath.Join(dir, e.Name()) | |
| @@ -219,7 +223,7 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||
| 219 | 223 | } | |
| 220 | 224 | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | |
| 221 | 225 | for _, e := range entries { | |
| 222 | - | if !e.IsDir() { | |
| 226 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { | |
| 223 | 227 | continue | |
| 224 | 228 | } | |
| 225 | 229 | extDir := filepath.Join(sd.dir, e.Name()) | |
| @@ -464,37 +468,30 @@ func extRemove(args []string) error { | |||
| 464 | 468 | } | |
| 465 | 469 | ||
| 466 | 470 | // extInstall copies a local directory or shallow-clones a git URL | |
| 467 | - | // into $ZOT_HOME/extensions/. Validates the destination contains an | |
| 468 | - | // extension.json before reporting success. | |
| 471 | + | // into $ZOT_HOME/extensions/. It stages the install and validates the | |
| 472 | + | // manifest plus any extension-local executable before reporting success. | |
| 473 | + | // Builds are never inferred or run implicitly; --build=go is an explicit | |
| 474 | + | // opt-in for local Go extensions. | |
| 469 | 475 | func extInstall(args []string) error { | |
| 470 | - | if len(args) == 0 { | |
| 471 | - | return fmt.Errorf("usage: zot ext install <path|git-url>") | |
| 476 | + | return extInstallContext(context.Background(), args) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | func extInstallContext(ctx context.Context, args []string) error { | |
| 480 | + | opts, err := parseExtInstallArgs(args) | |
| 481 | + | if err != nil { | |
| 482 | + | return err | |
| 472 | 483 | } | |
| 473 | - | src := args[0] | |
| 484 | + | src := opts.source | |
| 474 | 485 | dest := filepath.Join(ZotHome(), "extensions") | |
| 475 | 486 | if err := os.MkdirAll(dest, 0o755); err != nil { | |
| 476 | 487 | return err | |
| 477 | 488 | } | |
| 478 | 489 | ||
| 479 | 490 | if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") { | |
| 480 | - | // Git clone path. Pick the destination name from the repo basename. | |
| 481 | - | name := strings.TrimSuffix(filepath.Base(src), ".git") | |
| 482 | - | out := filepath.Join(dest, name) | |
| 483 | - | if _, err := os.Stat(out); err == nil { | |
| 484 | - | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 485 | - | } | |
| 486 | - | cmd := exec.Command("git", "clone", "--depth", "1", src, out) | |
| 487 | - | cmd.Stdout = os.Stderr | |
| 488 | - | cmd.Stderr = os.Stderr | |
| 489 | - | if err := cmd.Run(); err != nil { | |
| 490 | - | return fmt.Errorf("git clone: %w", err) | |
| 491 | - | } | |
| 492 | - | if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { | |
| 493 | - | _ = os.RemoveAll(out) | |
| 494 | - | return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back") | |
| 491 | + | if opts.builder != "" { | |
| 492 | + | return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there") | |
| 495 | 493 | } | |
| 496 | - | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 497 | - | return nil | |
| 494 | + | return installGitExtension(ctx, src, dest) | |
| 498 | 495 | } | |
| 499 | 496 | ||
| 500 | 497 | // Local path: must be a directory containing extension.json. | |
| @@ -505,33 +502,257 @@ func extInstall(args []string) error { | |||
| 505 | 502 | if !info.IsDir() { | |
| 506 | 503 | return fmt.Errorf("not a directory: %s", src) | |
| 507 | 504 | } | |
| 508 | - | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | |
| 509 | - | return fmt.Errorf("source lacks extension.json") | |
| 510 | - | } | |
| 511 | - | // Resolve to an absolute, cleaned path before deriving the install | |
| 512 | - | // name. Otherwise relative sources like "." or "./" collapse to a | |
| 513 | - | // basename of ".", and the destination wrongly resolves to the | |
| 514 | - | // extensions/ parent directory (which zot creates on first run), | |
| 515 | - | // triggering a false "already exists" failure. | |
| 505 | + | // Resolve to an absolute, cleaned path before reading the manifest. | |
| 506 | + | // This also makes sources like "." safe to install. | |
| 516 | 507 | absSrc, err := filepath.Abs(src) | |
| 517 | 508 | if err != nil { | |
| 518 | 509 | return err | |
| 519 | 510 | } | |
| 520 | - | name := filepath.Base(absSrc) | |
| 521 | - | if name == "." || name == ".." || name == string(filepath.Separator) || name == "" { | |
| 522 | - | return fmt.Errorf("cannot derive extension name from %q", src) | |
| 511 | + | manifest, err := readExtensionManifest(absSrc) | |
| 512 | + | if err != nil { | |
| 513 | + | return fmt.Errorf("source extension: %w", err) | |
| 514 | + | } | |
| 515 | + | out := filepath.Join(dest, manifest.Name) | |
| 516 | + | if _, err := os.Stat(out); err == nil { | |
| 517 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | stageRoot, err := os.MkdirTemp(dest, ".zot-extension-install-") | |
| 521 | + | if err != nil { | |
| 522 | + | return fmt.Errorf("create install staging directory: %w", err) | |
| 523 | + | } | |
| 524 | + | defer os.RemoveAll(stageRoot) | |
| 525 | + | staged := filepath.Join(stageRoot, "extension") | |
| 526 | + | if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil { | |
| 527 | + | return fmt.Errorf("copy extension: %w", err) | |
| 528 | + | } | |
| 529 | + | if opts.builder != "" { | |
| 530 | + | if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil { | |
| 531 | + | return err | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | if err := validateExtensionExecutable(staged, manifest); err != nil { | |
| 535 | + | return err | |
| 536 | + | } | |
| 537 | + | if err := os.Rename(staged, out); err != nil { | |
| 538 | + | return fmt.Errorf("finalize install: %w", err) | |
| 539 | + | } | |
| 540 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 541 | + | return nil | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | type extInstallOptions struct { | |
| 545 | + | source string | |
| 546 | + | builder string | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | func parseExtInstallArgs(args []string) (extInstallOptions, error) { | |
| 550 | + | if len(args) == 0 { | |
| 551 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | var opts extInstallOptions | |
| 555 | + | builderSet := false | |
| 556 | + | for i := 0; i < len(args); i++ { | |
| 557 | + | arg := args[i] | |
| 558 | + | switch { | |
| 559 | + | case arg == "--build": | |
| 560 | + | if i+1 >= len(args) { | |
| 561 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |
| 562 | + | } | |
| 563 | + | i++ | |
| 564 | + | if builderSet { | |
| 565 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |
| 566 | + | } | |
| 567 | + | builderSet = true | |
| 568 | + | opts.builder = strings.ToLower(strings.TrimSpace(args[i])) | |
| 569 | + | if opts.builder == "" { | |
| 570 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |
| 571 | + | } | |
| 572 | + | case strings.HasPrefix(arg, "--build="): | |
| 573 | + | if builderSet { | |
| 574 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |
| 575 | + | } | |
| 576 | + | builderSet = true | |
| 577 | + | opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build="))) | |
| 578 | + | if opts.builder == "" { | |
| 579 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |
| 580 | + | } | |
| 581 | + | case strings.HasPrefix(arg, "-"): | |
| 582 | + | return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg) | |
| 583 | + | case opts.source != "": | |
| 584 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |
| 585 | + | default: | |
| 586 | + | opts.source = arg | |
| 587 | + | } | |
| 588 | + | } | |
| 589 | + | if opts.source == "" { | |
| 590 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |
| 591 | + | } | |
| 592 | + | if opts.builder != "" && opts.builder != "go" { | |
| 593 | + | return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder) | |
| 594 | + | } | |
| 595 | + | return opts, nil | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error { | |
| 599 | + | if manifest.Exec == "" { | |
| 600 | + | return errors.New("cannot build a theme-only extension; it does not declare an executable") | |
| 601 | + | } | |
| 602 | + | execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec) | |
| 603 | + | if err != nil { | |
| 604 | + | return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err) | |
| 605 | + | } | |
| 606 | + | stagedExec := filepath.Join(stagedDir, execRel) | |
| 607 | + | if err := os.MkdirAll(filepath.Dir(stagedExec), 0o755); err != nil { | |
| 608 | + | return fmt.Errorf("prepare Go build output: %w", err) | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name) | |
| 612 | + | cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".") | |
| 613 | + | cmd.Dir = sourceDir | |
| 614 | + | cmd.Stdout = os.Stderr | |
| 615 | + | cmd.Stderr = os.Stderr | |
| 616 | + | if err := cmd.Run(); err != nil { | |
| 617 | + | return fmt.Errorf("build extension with go: %w", err) | |
| 618 | + | } | |
| 619 | + | return nil | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | func extensionRelativeExecPath(dir, execName string) (string, error) { | |
| 623 | + | normalized := filepath.FromSlash(execName) | |
| 624 | + | if filepath.IsAbs(normalized) { | |
| 625 | + | return "", fmt.Errorf("extension executable %q is absolute", execName) | |
| 626 | + | } | |
| 627 | + | resolved, local := extensions.ResolveExecPath(dir, execName) | |
| 628 | + | if !local { | |
| 629 | + | return "", fmt.Errorf("extension executable %q is a PATH command", execName) | |
| 630 | + | } | |
| 631 | + | rel, err := filepath.Rel(dir, resolved) | |
| 632 | + | if err != nil { | |
| 633 | + | return "", fmt.Errorf("resolve extension executable %q: %w", execName, err) | |
| 634 | + | } | |
| 635 | + | if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 636 | + | return "", fmt.Errorf("extension executable %q escapes the extension directory", execName) | |
| 637 | + | } | |
| 638 | + | return rel, nil | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | func installGitExtension(ctx context.Context, src, dest string) error { | |
| 642 | + | stageRoot, err := os.MkdirTemp(dest, ".zot-extension-clone-") | |
| 643 | + | if err != nil { | |
| 644 | + | return fmt.Errorf("create install staging directory: %w", err) | |
| 645 | + | } | |
| 646 | + | defer os.RemoveAll(stageRoot) | |
| 647 | + | ||
| 648 | + | cloneDir := filepath.Join(stageRoot, "extension") | |
| 649 | + | cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir) | |
| 650 | + | cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") | |
| 651 | + | cmd.Stdout = os.Stderr | |
| 652 | + | cmd.Stderr = os.Stderr | |
| 653 | + | if err := cmd.Run(); err != nil { | |
| 654 | + | return fmt.Errorf("git clone: %w", err) | |
| 523 | 655 | } | |
| 524 | - | out := filepath.Join(dest, name) | |
| 656 | + | manifest, err := readExtensionManifest(cloneDir) | |
| 657 | + | if err != nil { | |
| 658 | + | return fmt.Errorf("cloned extension: %w", err) | |
| 659 | + | } | |
| 660 | + | out := filepath.Join(dest, manifest.Name) | |
| 525 | 661 | if _, err := os.Stat(out); err == nil { | |
| 526 | 662 | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 527 | 663 | } | |
| 528 | - | if err := copyDir(absSrc, out); err != nil { | |
| 664 | + | if err := validateExtensionExecutable(cloneDir, manifest); err != nil { | |
| 529 | 665 | return err | |
| 530 | 666 | } | |
| 667 | + | if err := os.Rename(cloneDir, out); err != nil { | |
| 668 | + | return fmt.Errorf("finalize install: %w", err) | |
| 669 | + | } | |
| 531 | 670 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 532 | 671 | return nil | |
| 533 | 672 | } | |
| 534 | 673 | ||
| 674 | + | func readExtensionManifest(dir string) (extensions.Manifest, error) { | |
| 675 | + | raw, err := os.ReadFile(filepath.Join(dir, "extension.json")) | |
| 676 | + | if err != nil { | |
| 677 | + | if os.IsNotExist(err) { | |
| 678 | + | return extensions.Manifest{}, errors.New("source lacks extension.json") | |
| 679 | + | } | |
| 680 | + | return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err) | |
| 681 | + | } | |
| 682 | + | var manifest extensions.Manifest | |
| 683 | + | if err := json.Unmarshal(raw, &manifest); err != nil { | |
| 684 | + | return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err) | |
| 685 | + | } | |
| 686 | + | if strings.TrimSpace(manifest.Name) == "" { | |
| 687 | + | return extensions.Manifest{}, errors.New("manifest: name is required") | |
| 688 | + | } | |
| 689 | + | if manifest.Name != strings.TrimSpace(manifest.Name) { | |
| 690 | + | return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name) | |
| 691 | + | } | |
| 692 | + | if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) { | |
| 693 | + | return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name) | |
| 694 | + | } | |
| 695 | + | if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) { | |
| 696 | + | return extensions.Manifest{}, errors.New("manifest: exec is required") | |
| 697 | + | } | |
| 698 | + | if manifest.Exec != "" { | |
| 699 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |
| 700 | + | if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |
| 701 | + | rel, err := filepath.Rel(dir, resolved) | |
| 702 | + | if err != nil { | |
| 703 | + | return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err) | |
| 704 | + | } | |
| 705 | + | if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 706 | + | return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec) | |
| 707 | + | } | |
| 708 | + | } | |
| 709 | + | } | |
| 710 | + | return manifest, nil | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool { | |
| 714 | + | required := map[string]bool{"extension.json": true} | |
| 715 | + | if manifest.Exec == "" { | |
| 716 | + | return required | |
| 717 | + | } | |
| 718 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |
| 719 | + | if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |
| 720 | + | return required | |
| 721 | + | } | |
| 722 | + | rel, err := filepath.Rel(dir, resolved) | |
| 723 | + | if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 724 | + | required[filepath.ToSlash(rel)] = true | |
| 725 | + | } | |
| 726 | + | return required | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | func validateExtensionExecutable(dir string, manifest extensions.Manifest) error { | |
| 730 | + | if manifest.Exec == "" { | |
| 731 | + | return nil | |
| 732 | + | } | |
| 733 | + | path, local := extensions.ResolveExecPath(dir, manifest.Exec) | |
| 734 | + | if !local { | |
| 735 | + | // Bare commands such as "go", "node", and "npx" are resolved | |
| 736 | + | // from PATH when zot starts. They may be intentionally installed | |
| 737 | + | // before the runtime is present on the current machine. | |
| 738 | + | return nil | |
| 739 | + | } | |
| 740 | + | info, err := os.Stat(path) | |
| 741 | + | if err != nil { | |
| 742 | + | if os.IsNotExist(err) { | |
| 743 | + | return fmt.Errorf("extension executable %q is missing; provide it before installing. Local Go extensions can opt in with `zot ext install --build=go <path>`", manifest.Exec) | |
| 744 | + | } | |
| 745 | + | return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err) | |
| 746 | + | } | |
| 747 | + | if info.IsDir() { | |
| 748 | + | return fmt.Errorf("extension executable %q is a directory", manifest.Exec) | |
| 749 | + | } | |
| 750 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |
| 751 | + | return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec) | |
| 752 | + | } | |
| 753 | + | return nil | |
| 754 | + | } | |
| 755 | + | ||
| 535 | 756 | func extensionDirs() map[string]string { | |
| 536 | 757 | out := map[string]string{} | |
| 537 | 758 | if h := ZotHome(); h != "" { | |
| @@ -569,6 +790,10 @@ func dashIfEmpty(s string) string { | |||
| 569 | 790 | // out of the installed copy so the extension stays functional at its new | |
| 570 | 791 | // location. | |
| 571 | 792 | func copyDir(src, dst string) error { | |
| 793 | + | return copyDirWithRequired(src, dst, nil) | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | func copyDirWithRequired(src, dst string, required map[string]bool) error { | |
| 572 | 797 | ig := loadGitignore(src) | |
| 573 | 798 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | |
| 574 | 799 | if err != nil { | |
| @@ -578,12 +803,13 @@ func copyDir(src, dst string) error { | |||
| 578 | 803 | if err != nil { | |
| 579 | 804 | return err | |
| 580 | 805 | } | |
| 806 | + | relSlash := filepath.ToSlash(rel) | |
| 581 | 807 | if rel != "." { | |
| 582 | 808 | name := filepath.Base(rel) | |
| 583 | 809 | if info.IsDir() && name == ".git" { | |
| 584 | 810 | return filepath.SkipDir | |
| 585 | 811 | } | |
| 586 | - | if ig.Match(filepath.ToSlash(rel), info.IsDir()) { | |
| 812 | + | if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) { | |
| 587 | 813 | if info.IsDir() { | |
| 588 | 814 | return filepath.SkipDir | |
| 589 | 815 | } | |
| @@ -609,6 +835,22 @@ func copyDir(src, dst string) error { | |||
| 609 | 835 | }) | |
| 610 | 836 | } | |
| 611 | 837 | ||
| 838 | + | func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool { | |
| 839 | + | if required[rel] { | |
| 840 | + | return true | |
| 841 | + | } | |
| 842 | + | if !isDir { | |
| 843 | + | return false | |
| 844 | + | } | |
| 845 | + | prefix := strings.TrimSuffix(rel, "/") + "/" | |
| 846 | + | for path := range required { | |
| 847 | + | if strings.HasPrefix(path, prefix) { | |
| 848 | + | return true | |
| 849 | + | } | |
| 850 | + | } | |
| 851 | + | return false | |
| 852 | + | } | |
| 853 | + | ||
| 612 | 854 | // gitignore matching lives in packages/ignore so the @-file picker in | |
| 613 | 855 | // packages/agent/modes can share it without an import cycle. These | |
| 614 | 856 | // thin aliases keep the existing call sites (and tests) terse. | |
| @@ -3,11 +3,13 @@ package agent | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "io" | 8 | "io" | ||
| 8 | "os" | 9 | "os" | ||
| 9 | "os/exec" | 10 | "os/exec" | ||
| 10 | "path/filepath" | 11 | "path/filepath" | ||
| 12 | + | "runtime" | |||
| 11 | "sort" | 13 | "sort" | ||
| 12 | "strings" | 14 | "strings" | ||
| 13 | "time" | 15 | "time" | ||
| @@ -21,7 +23,7 @@ import ( | |||||
| 21 | // (handled=true, err) if rawArgs starts with "ext"; otherwise | 23 | // (handled=true, err) if rawArgs starts with "ext"; otherwise | ||
| 22 | // (handled=false, nil) so the main router falls through to the | 24 | // (handled=false, nil) so the main router falls through to the | ||
| 23 | // regular flag parser. | 25 | // regular flag parser. | ||
| 24 | - | func runExtCommand(rawArgs []string, version string) (handled bool, err error) { | 26 | + | func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) { |
| 25 | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | 27 | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | ||
| 26 | return false, nil | 28 | return false, nil | ||
| 27 | } | 29 | } | ||
| @@ -43,7 +45,7 @@ func runExtCommand(rawArgs []string, version string) (handled bool, err error) { | |||||
| 43 | case "remove", "rm": | 45 | case "remove", "rm": | ||
| 44 | return true, extRemove(rawArgs[2:]) | 46 | return true, extRemove(rawArgs[2:]) | ||
| 45 | case "install": | 47 | case "install": | ||
| 46 | - | return true, extInstall(rawArgs[2:]) | 48 | + | return true, extInstallContext(ctx, rawArgs[2:]) |
| 47 | case "help", "-h", "--help": | 49 | case "help", "-h", "--help": | ||
| 48 | printExtHelp() | 50 | printExtHelp() | ||
| 49 | return true, nil | 51 | return true, nil | ||
| @@ -62,8 +64,10 @@ usage: | |||||
| 62 | zot ext logs <name> [-f] cat / tail an extension's stderr log | 64 | zot ext logs <name> [-f] cat / tail an extension's stderr log | ||
| 63 | zot ext enable <name> re-enable a disabled extension | 65 | zot ext enable <name> re-enable a disabled extension | ||
| 64 | zot ext disable <name> disable without removing | 66 | zot ext disable <name> disable without removing | ||
| 65 | - | zot ext remove <name> delete an extension directory | 67 | + | zot ext remove <name> delete an extension directory |
| 66 | - | zot ext install <path|git-url> copy / clone an extension into $ZOT_HOME/extensions/ | 68 | + | zot ext install [--build=go] <path|git-url> |
| 69 | + | copy / clone and validate an extension | |||
| 70 | + | --build=go explicitly builds a local Go extension | |||
| 67 | 71 | ||||
| 68 | extensions live under: | 72 | extensions live under: | ||
| 69 | $ZOT_HOME/extensions/<name>/extension.json (global) | 73 | $ZOT_HOME/extensions/<name>/extension.json (global) | ||
| @@ -88,7 +92,7 @@ func extList() error { | |||||
| 88 | continue | 92 | continue | ||
| 89 | } | 93 | } | ||
| 90 | for _, e := range entries { | 94 | for _, e := range entries { | ||
| 91 | - | if !e.IsDir() { | 95 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { |
| 92 | continue | 96 | continue | ||
| 93 | } | 97 | } | ||
| 94 | extDir := filepath.Join(dir, e.Name()) | 98 | extDir := filepath.Join(dir, e.Name()) | ||
| @@ -219,7 +223,7 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||||
| 219 | } | 223 | } | ||
| 220 | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | 224 | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | ||
| 221 | for _, e := range entries { | 225 | for _, e := range entries { | ||
| 222 | - | if !e.IsDir() { | 226 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { |
| 223 | continue | 227 | continue | ||
| 224 | } | 228 | } | ||
| 225 | extDir := filepath.Join(sd.dir, e.Name()) | 229 | extDir := filepath.Join(sd.dir, e.Name()) | ||
| @@ -464,37 +468,30 @@ func extRemove(args []string) error { | |||||
| 464 | } | 468 | } | ||
| 465 | 469 | ||||
| 466 | // extInstall copies a local directory or shallow-clones a git URL | 470 | // extInstall copies a local directory or shallow-clones a git URL | ||
| 467 | - | // into $ZOT_HOME/extensions/. Validates the destination contains an | 471 | + | // into $ZOT_HOME/extensions/. It stages the install and validates the |
| 468 | - | // extension.json before reporting success. | 472 | + | // manifest plus any extension-local executable before reporting success. |
| 473 | + | // Builds are never inferred or run implicitly; --build=go is an explicit | |||
| 474 | + | // opt-in for local Go extensions. | |||
| 469 | func extInstall(args []string) error { | 475 | func extInstall(args []string) error { | ||
| 470 | - | if len(args) == 0 { | 476 | + | return extInstallContext(context.Background(), args) |
| 471 | - | return fmt.Errorf("usage: zot ext install <path|git-url>") | 477 | + | } |
| 478 | + | ||||
| 479 | + | func extInstallContext(ctx context.Context, args []string) error { | |||
| 480 | + | opts, err := parseExtInstallArgs(args) | |||
| 481 | + | if err != nil { | |||
| 482 | + | return err | |||
| 472 | } | 483 | } | ||
| 473 | - | src := args[0] | 484 | + | src := opts.source |
| 474 | dest := filepath.Join(ZotHome(), "extensions") | 485 | dest := filepath.Join(ZotHome(), "extensions") | ||
| 475 | if err := os.MkdirAll(dest, 0o755); err != nil { | 486 | if err := os.MkdirAll(dest, 0o755); err != nil { | ||
| 476 | return err | 487 | return err | ||
| 477 | } | 488 | } | ||
| 478 | 489 | ||||
| 479 | if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") { | 490 | if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") { | ||
| 480 | - | // Git clone path. Pick the destination name from the repo basename. | 491 | + | if opts.builder != "" { |
| 481 | - | name := strings.TrimSuffix(filepath.Base(src), ".git") | 492 | + | return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there") |
| 482 | - | out := filepath.Join(dest, name) | |||
| 483 | - | if _, err := os.Stat(out); err == nil { | |||
| 484 | - | return fmt.Errorf("destination %s already exists; remove it first", out) | |||
| 485 | - | } | |||
| 486 | - | cmd := exec.Command("git", "clone", "--depth", "1", src, out) | |||
| 487 | - | cmd.Stdout = os.Stderr | |||
| 488 | - | cmd.Stderr = os.Stderr | |||
| 489 | - | if err := cmd.Run(); err != nil { | |||
| 490 | - | return fmt.Errorf("git clone: %w", err) | |||
| 491 | - | } | |||
| 492 | - | if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { | |||
| 493 | - | _ = os.RemoveAll(out) | |||
| 494 | - | return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back") | |||
| 495 | } | 493 | } | ||
| 496 | - | fmt.Fprintf(os.Stderr, "installed %s\n", out) | 494 | + | return installGitExtension(ctx, src, dest) |
| 497 | - | return nil | |||
| 498 | } | 495 | } | ||
| 499 | 496 | ||||
| 500 | // Local path: must be a directory containing extension.json. | 497 | // Local path: must be a directory containing extension.json. | ||
| @@ -505,33 +502,257 @@ func extInstall(args []string) error { | |||||
| 505 | if !info.IsDir() { | 502 | if !info.IsDir() { | ||
| 506 | return fmt.Errorf("not a directory: %s", src) | 503 | return fmt.Errorf("not a directory: %s", src) | ||
| 507 | } | 504 | } | ||
| 508 | - | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | 505 | + | // Resolve to an absolute, cleaned path before reading the manifest. |
| 509 | - | return fmt.Errorf("source lacks extension.json") | 506 | + | // This also makes sources like "." safe to install. |
| 510 | - | } | |||
| 511 | - | // Resolve to an absolute, cleaned path before deriving the install | |||
| 512 | - | // name. Otherwise relative sources like "." or "./" collapse to a | |||
| 513 | - | // basename of ".", and the destination wrongly resolves to the | |||
| 514 | - | // extensions/ parent directory (which zot creates on first run), | |||
| 515 | - | // triggering a false "already exists" failure. | |||
| 516 | absSrc, err := filepath.Abs(src) | 507 | absSrc, err := filepath.Abs(src) | ||
| 517 | if err != nil { | 508 | if err != nil { | ||
| 518 | return err | 509 | return err | ||
| 519 | } | 510 | } | ||
| 520 | - | name := filepath.Base(absSrc) | 511 | + | manifest, err := readExtensionManifest(absSrc) |
| 521 | - | if name == "." || name == ".." || name == string(filepath.Separator) || name == "" { | 512 | + | if err != nil { |
| 522 | - | return fmt.Errorf("cannot derive extension name from %q", src) | 513 | + | return fmt.Errorf("source extension: %w", err) |
| 514 | + | } | |||
| 515 | + | out := filepath.Join(dest, manifest.Name) | |||
| 516 | + | if _, err := os.Stat(out); err == nil { | |||
| 517 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | stageRoot, err := os.MkdirTemp(dest, ".zot-extension-install-") | |||
| 521 | + | if err != nil { | |||
| 522 | + | return fmt.Errorf("create install staging directory: %w", err) | |||
| 523 | + | } | |||
| 524 | + | defer os.RemoveAll(stageRoot) | |||
| 525 | + | staged := filepath.Join(stageRoot, "extension") | |||
| 526 | + | if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil { | |||
| 527 | + | return fmt.Errorf("copy extension: %w", err) | |||
| 528 | + | } | |||
| 529 | + | if opts.builder != "" { | |||
| 530 | + | if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil { | |||
| 531 | + | return err | |||
| 532 | + | } | |||
| 533 | + | } | |||
| 534 | + | if err := validateExtensionExecutable(staged, manifest); err != nil { | |||
| 535 | + | return err | |||
| 536 | + | } | |||
| 537 | + | if err := os.Rename(staged, out); err != nil { | |||
| 538 | + | return fmt.Errorf("finalize install: %w", err) | |||
| 539 | + | } | |||
| 540 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |||
| 541 | + | return nil | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | type extInstallOptions struct { | |||
| 545 | + | source string | |||
| 546 | + | builder string | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | func parseExtInstallArgs(args []string) (extInstallOptions, error) { | |||
| 550 | + | if len(args) == 0 { | |||
| 551 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | var opts extInstallOptions | |||
| 555 | + | builderSet := false | |||
| 556 | + | for i := 0; i < len(args); i++ { | |||
| 557 | + | arg := args[i] | |||
| 558 | + | switch { | |||
| 559 | + | case arg == "--build": | |||
| 560 | + | if i+1 >= len(args) { | |||
| 561 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |||
| 562 | + | } | |||
| 563 | + | i++ | |||
| 564 | + | if builderSet { | |||
| 565 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |||
| 566 | + | } | |||
| 567 | + | builderSet = true | |||
| 568 | + | opts.builder = strings.ToLower(strings.TrimSpace(args[i])) | |||
| 569 | + | if opts.builder == "" { | |||
| 570 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |||
| 571 | + | } | |||
| 572 | + | case strings.HasPrefix(arg, "--build="): | |||
| 573 | + | if builderSet { | |||
| 574 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |||
| 575 | + | } | |||
| 576 | + | builderSet = true | |||
| 577 | + | opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build="))) | |||
| 578 | + | if opts.builder == "" { | |||
| 579 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |||
| 580 | + | } | |||
| 581 | + | case strings.HasPrefix(arg, "-"): | |||
| 582 | + | return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg) | |||
| 583 | + | case opts.source != "": | |||
| 584 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |||
| 585 | + | default: | |||
| 586 | + | opts.source = arg | |||
| 587 | + | } | |||
| 588 | + | } | |||
| 589 | + | if opts.source == "" { | |||
| 590 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |||
| 591 | + | } | |||
| 592 | + | if opts.builder != "" && opts.builder != "go" { | |||
| 593 | + | return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder) | |||
| 594 | + | } | |||
| 595 | + | return opts, nil | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error { | |||
| 599 | + | if manifest.Exec == "" { | |||
| 600 | + | return errors.New("cannot build a theme-only extension; it does not declare an executable") | |||
| 601 | + | } | |||
| 602 | + | execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec) | |||
| 603 | + | if err != nil { | |||
| 604 | + | return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err) | |||
| 605 | + | } | |||
| 606 | + | stagedExec := filepath.Join(stagedDir, execRel) | |||
| 607 | + | if err := os.MkdirAll(filepath.Dir(stagedExec), 0o755); err != nil { | |||
| 608 | + | return fmt.Errorf("prepare Go build output: %w", err) | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name) | |||
| 612 | + | cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".") | |||
| 613 | + | cmd.Dir = sourceDir | |||
| 614 | + | cmd.Stdout = os.Stderr | |||
| 615 | + | cmd.Stderr = os.Stderr | |||
| 616 | + | if err := cmd.Run(); err != nil { | |||
| 617 | + | return fmt.Errorf("build extension with go: %w", err) | |||
| 618 | + | } | |||
| 619 | + | return nil | |||
| 620 | + | } | |||
| 621 | + | ||||
| 622 | + | func extensionRelativeExecPath(dir, execName string) (string, error) { | |||
| 623 | + | normalized := filepath.FromSlash(execName) | |||
| 624 | + | if filepath.IsAbs(normalized) { | |||
| 625 | + | return "", fmt.Errorf("extension executable %q is absolute", execName) | |||
| 626 | + | } | |||
| 627 | + | resolved, local := extensions.ResolveExecPath(dir, execName) | |||
| 628 | + | if !local { | |||
| 629 | + | return "", fmt.Errorf("extension executable %q is a PATH command", execName) | |||
| 630 | + | } | |||
| 631 | + | rel, err := filepath.Rel(dir, resolved) | |||
| 632 | + | if err != nil { | |||
| 633 | + | return "", fmt.Errorf("resolve extension executable %q: %w", execName, err) | |||
| 634 | + | } | |||
| 635 | + | if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |||
| 636 | + | return "", fmt.Errorf("extension executable %q escapes the extension directory", execName) | |||
| 637 | + | } | |||
| 638 | + | return rel, nil | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | func installGitExtension(ctx context.Context, src, dest string) error { | |||
| 642 | + | stageRoot, err := os.MkdirTemp(dest, ".zot-extension-clone-") | |||
| 643 | + | if err != nil { | |||
| 644 | + | return fmt.Errorf("create install staging directory: %w", err) | |||
| 645 | + | } | |||
| 646 | + | defer os.RemoveAll(stageRoot) | |||
| 647 | + | ||||
| 648 | + | cloneDir := filepath.Join(stageRoot, "extension") | |||
| 649 | + | cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir) | |||
| 650 | + | cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") | |||
| 651 | + | cmd.Stdout = os.Stderr | |||
| 652 | + | cmd.Stderr = os.Stderr | |||
| 653 | + | if err := cmd.Run(); err != nil { | |||
| 654 | + | return fmt.Errorf("git clone: %w", err) | |||
| 523 | } | 655 | } | ||
| 524 | - | out := filepath.Join(dest, name) | 656 | + | manifest, err := readExtensionManifest(cloneDir) |
| 657 | + | if err != nil { | |||
| 658 | + | return fmt.Errorf("cloned extension: %w", err) | |||
| 659 | + | } | |||
| 660 | + | out := filepath.Join(dest, manifest.Name) | |||
| 525 | if _, err := os.Stat(out); err == nil { | 661 | if _, err := os.Stat(out); err == nil { | ||
| 526 | return fmt.Errorf("destination %s already exists; remove it first", out) | 662 | return fmt.Errorf("destination %s already exists; remove it first", out) | ||
| 527 | } | 663 | } | ||
| 528 | - | if err := copyDir(absSrc, out); err != nil { | 664 | + | if err := validateExtensionExecutable(cloneDir, manifest); err != nil { |
| 529 | return err | 665 | return err | ||
| 530 | } | 666 | } | ||
| 667 | + | if err := os.Rename(cloneDir, out); err != nil { | |||
| 668 | + | return fmt.Errorf("finalize install: %w", err) | |||
| 669 | + | } | |||
| 531 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | 670 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | ||
| 532 | return nil | 671 | return nil | ||
| 533 | } | 672 | } | ||
| 534 | 673 | ||||
| 674 | + | func readExtensionManifest(dir string) (extensions.Manifest, error) { | |||
| 675 | + | raw, err := os.ReadFile(filepath.Join(dir, "extension.json")) | |||
| 676 | + | if err != nil { | |||
| 677 | + | if os.IsNotExist(err) { | |||
| 678 | + | return extensions.Manifest{}, errors.New("source lacks extension.json") | |||
| 679 | + | } | |||
| 680 | + | return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err) | |||
| 681 | + | } | |||
| 682 | + | var manifest extensions.Manifest | |||
| 683 | + | if err := json.Unmarshal(raw, &manifest); err != nil { | |||
| 684 | + | return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err) | |||
| 685 | + | } | |||
| 686 | + | if strings.TrimSpace(manifest.Name) == "" { | |||
| 687 | + | return extensions.Manifest{}, errors.New("manifest: name is required") | |||
| 688 | + | } | |||
| 689 | + | if manifest.Name != strings.TrimSpace(manifest.Name) { | |||
| 690 | + | return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name) | |||
| 691 | + | } | |||
| 692 | + | if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) { | |||
| 693 | + | return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name) | |||
| 694 | + | } | |||
| 695 | + | if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) { | |||
| 696 | + | return extensions.Manifest{}, errors.New("manifest: exec is required") | |||
| 697 | + | } | |||
| 698 | + | if manifest.Exec != "" { | |||
| 699 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |||
| 700 | + | if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |||
| 701 | + | rel, err := filepath.Rel(dir, resolved) | |||
| 702 | + | if err != nil { | |||
| 703 | + | return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err) | |||
| 704 | + | } | |||
| 705 | + | if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |||
| 706 | + | return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec) | |||
| 707 | + | } | |||
| 708 | + | } | |||
| 709 | + | } | |||
| 710 | + | return manifest, nil | |||
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool { | |||
| 714 | + | required := map[string]bool{"extension.json": true} | |||
| 715 | + | if manifest.Exec == "" { | |||
| 716 | + | return required | |||
| 717 | + | } | |||
| 718 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |||
| 719 | + | if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |||
| 720 | + | return required | |||
| 721 | + | } | |||
| 722 | + | rel, err := filepath.Rel(dir, resolved) | |||
| 723 | + | if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |||
| 724 | + | required[filepath.ToSlash(rel)] = true | |||
| 725 | + | } | |||
| 726 | + | return required | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | func validateExtensionExecutable(dir string, manifest extensions.Manifest) error { | |||
| 730 | + | if manifest.Exec == "" { | |||
| 731 | + | return nil | |||
| 732 | + | } | |||
| 733 | + | path, local := extensions.ResolveExecPath(dir, manifest.Exec) | |||
| 734 | + | if !local { | |||
| 735 | + | // Bare commands such as "go", "node", and "npx" are resolved | |||
| 736 | + | // from PATH when zot starts. They may be intentionally installed | |||
| 737 | + | // before the runtime is present on the current machine. | |||
| 738 | + | return nil | |||
| 739 | + | } | |||
| 740 | + | info, err := os.Stat(path) | |||
| 741 | + | if err != nil { | |||
| 742 | + | if os.IsNotExist(err) { | |||
| 743 | + | return fmt.Errorf("extension executable %q is missing; provide it before installing. Local Go extensions can opt in with `zot ext install --build=go <path>`", manifest.Exec) | |||
| 744 | + | } | |||
| 745 | + | return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err) | |||
| 746 | + | } | |||
| 747 | + | if info.IsDir() { | |||
| 748 | + | return fmt.Errorf("extension executable %q is a directory", manifest.Exec) | |||
| 749 | + | } | |||
| 750 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |||
| 751 | + | return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec) | |||
| 752 | + | } | |||
| 753 | + | return nil | |||
| 754 | + | } | |||
| 755 | + | ||||
| 535 | func extensionDirs() map[string]string { | 756 | func extensionDirs() map[string]string { | ||
| 536 | out := map[string]string{} | 757 | out := map[string]string{} | ||
| 537 | if h := ZotHome(); h != "" { | 758 | if h := ZotHome(); h != "" { | ||
| @@ -569,6 +790,10 @@ func dashIfEmpty(s string) string { | |||||
| 569 | // out of the installed copy so the extension stays functional at its new | 790 | // out of the installed copy so the extension stays functional at its new | ||
| 570 | // location. | 791 | // location. | ||
| 571 | func copyDir(src, dst string) error { | 792 | func copyDir(src, dst string) error { | ||
| 793 | + | return copyDirWithRequired(src, dst, nil) | |||
| 794 | + | } | |||
| 795 | + | ||||
| 796 | + | func copyDirWithRequired(src, dst string, required map[string]bool) error { | |||
| 572 | ig := loadGitignore(src) | 797 | ig := loadGitignore(src) | ||
| 573 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | 798 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | ||
| 574 | if err != nil { | 799 | if err != nil { | ||
| @@ -578,12 +803,13 @@ func copyDir(src, dst string) error { | |||||
| 578 | if err != nil { | 803 | if err != nil { | ||
| 579 | return err | 804 | return err | ||
| 580 | } | 805 | } | ||
| 806 | + | relSlash := filepath.ToSlash(rel) | |||
| 581 | if rel != "." { | 807 | if rel != "." { | ||
| 582 | name := filepath.Base(rel) | 808 | name := filepath.Base(rel) | ||
| 583 | if info.IsDir() && name == ".git" { | 809 | if info.IsDir() && name == ".git" { | ||
| 584 | return filepath.SkipDir | 810 | return filepath.SkipDir | ||
| 585 | } | 811 | } | ||
| 586 | - | if ig.Match(filepath.ToSlash(rel), info.IsDir()) { | 812 | + | if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) { |
| 587 | if info.IsDir() { | 813 | if info.IsDir() { | ||
| 588 | return filepath.SkipDir | 814 | return filepath.SkipDir | ||
| 589 | } | 815 | } | ||
| @@ -609,6 +835,22 @@ func copyDir(src, dst string) error { | |||||
| 609 | }) | 835 | }) | ||
| 610 | } | 836 | } | ||
| 611 | 837 | ||||
| 838 | + | func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool { | |||
| 839 | + | if required[rel] { | |||
| 840 | + | return true | |||
| 841 | + | } | |||
| 842 | + | if !isDir { | |||
| 843 | + | return false | |||
| 844 | + | } | |||
| 845 | + | prefix := strings.TrimSuffix(rel, "/") + "/" | |||
| 846 | + | for path := range required { | |||
| 847 | + | if strings.HasPrefix(path, prefix) { | |||
| 848 | + | return true | |||
| 849 | + | } | |||
| 850 | + | } | |||
| 851 | + | return false | |||
| 852 | + | } | |||
| 853 | + | ||||
| 612 | // gitignore matching lives in packages/ignore so the @-file picker in | 854 | // gitignore matching lives in packages/ignore so the @-file picker in | ||
| 613 | // packages/agent/modes can share it without an import cycle. These | 855 | // packages/agent/modes can share it without an import cycle. These | ||
| 614 | // thin aliases keep the existing call sites (and tests) terse. | 856 | // thin aliases keep the existing call sites (and tests) terse. | ||
M packages/agent/extcmd_test.go +315 -12
| @@ -3,16 +3,18 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "os" | |
| 6 | + | osexec "os/exec" | |
| 6 | 7 | "path/filepath" | |
| 8 | + | "runtime" | |
| 7 | 9 | "strings" | |
| 8 | 10 | "testing" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | - | // TestExtInstallDotSource verifies that `zot ext install .` derives the | |
| 14 | - | // extension name from the resolved directory name rather than collapsing | |
| 15 | - | // to the extensions/ parent directory (the false "already exists" bug). | |
| 15 | + | // TestExtInstallDotSource verifies that `zot ext install .` resolves the | |
| 16 | + | // source directory correctly instead of collapsing it to the extensions/ | |
| 17 | + | // parent directory. | |
| 16 | 18 | func TestExtInstallDotSource(t *testing.T) { | |
| 17 | 19 | home := t.TempDir() | |
| 18 | 20 | t.Setenv("ZOT_HOME", home) | |
| @@ -27,7 +29,7 @@ func TestExtInstallDotSource(t *testing.T) { | |||
| 27 | 29 | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 28 | 30 | t.Fatal(err) | |
| 29 | 31 | } | |
| 30 | - | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi"}`), 0o644); err != nil { | |
| 32 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi","exec":"go"}`), 0o644); err != nil { | |
| 31 | 33 | t.Fatal(err) | |
| 32 | 34 | } | |
| 33 | 35 | ||
| @@ -50,10 +52,8 @@ func TestExtInstallDotSource(t *testing.T) { | |||
| 50 | 52 | } | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | - | // TestExtInstallRejectsParentName guards against deriving a name of ".." | |
| 54 | - | // from a source that resolves to a filesystem root edge case. A normal | |
| 55 | - | // directory always yields a real basename, so this just ensures the | |
| 56 | - | // guard logic does not crash for well-formed input. | |
| 55 | + | // TestExtInstallNamedDir verifies that a normal named source installs | |
| 56 | + | // successfully when its manifest uses a PATH-resolved executable. | |
| 57 | 57 | func TestExtInstallNamedDir(t *testing.T) { | |
| 58 | 58 | home := t.TempDir() | |
| 59 | 59 | t.Setenv("ZOT_HOME", home) | |
| @@ -62,7 +62,7 @@ func TestExtInstallNamedDir(t *testing.T) { | |||
| 62 | 62 | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 63 | 63 | t.Fatal(err) | |
| 64 | 64 | } | |
| 65 | - | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext"}`), 0o644); err != nil { | |
| 65 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext","exec":"go"}`), 0o644); err != nil { | |
| 66 | 66 | t.Fatal(err) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| @@ -74,9 +74,312 @@ func TestExtInstallNamedDir(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | - | // TestCopyDirRespectsGitignore verifies that non-portable directories | |
| 78 | - | // listed in the source .gitignore (e.g. .venv, node_modules) are not | |
| 79 | - | // copied during install, while tracked files are. | |
| 77 | + | func TestExtInstallThemeOnly(t *testing.T) { | |
| 78 | + | home := t.TempDir() | |
| 79 | + | t.Setenv("ZOT_HOME", home) | |
| 80 | + | ||
| 81 | + | src := filepath.Join(t.TempDir(), "theme-source") | |
| 82 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil { | |
| 86 | + | t.Fatal(err) | |
| 87 | + | } | |
| 88 | + | if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | if err := extInstall([]string{src}); err != nil { | |
| 93 | + | t.Fatalf("theme-only install failed: %v", err) | |
| 94 | + | } | |
| 95 | + | out := filepath.Join(home, "extensions", "theme-only") | |
| 96 | + | for _, file := range []string{"extension.json", "theme.json"} { | |
| 97 | + | if _, err := os.Stat(filepath.Join(out, file)); err != nil { | |
| 98 | + | t.Fatalf("installed theme missing %s: %v", file, err) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestExtInstallRejectsMissingExecutable(t *testing.T) { | |
| 104 | + | home := t.TempDir() | |
| 105 | + | t.Setenv("ZOT_HOME", home) | |
| 106 | + | ||
| 107 | + | src := filepath.Join(t.TempDir(), "tasked-phases") | |
| 108 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | err := extInstall([]string{src}) | |
| 116 | + | if err == nil || !strings.Contains(err.Error(), "--build=go") { | |
| 117 | + | t.Fatalf("install error = %v, want explicit Go builder guidance", err) | |
| 118 | + | } | |
| 119 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "tasked-phases")); !os.IsNotExist(statErr) { | |
| 120 | + | t.Fatalf("failed install left a destination: %v", statErr) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestExtInstallBuildGo(t *testing.T) { | |
| 125 | + | if _, err := osexec.LookPath("go"); err != nil { | |
| 126 | + | t.Skip("go is not installed") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | home := t.TempDir() | |
| 130 | + | t.Setenv("ZOT_HOME", home) | |
| 131 | + | src := filepath.Join(t.TempDir(), "go-extension") | |
| 132 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | mustWrite := func(name, body string) { | |
| 136 | + | t.Helper() | |
| 137 | + | if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | mustWrite("extension.json", `{"name":"built-go","exec":"./built-go","language":"go"}`) | |
| 142 | + | mustWrite("go.mod", "module example.com/built-go\n\ngo 1.25.0\n") | |
| 143 | + | mustWrite("main.go", "package main\n\nfunc main() {}\n") | |
| 144 | + | ||
| 145 | + | if err := extInstall([]string{"--build=go", src}); err != nil { | |
| 146 | + | t.Fatalf("build install failed: %v", err) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | installed := filepath.Join(home, "extensions", "built-go", "built-go") | |
| 150 | + | info, err := os.Stat(installed) | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatalf("built executable was not installed: %v", err) | |
| 153 | + | } | |
| 154 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |
| 155 | + | t.Fatalf("installed executable mode = %o, want an execute bit", info.Mode().Perm()) | |
| 156 | + | } | |
| 157 | + | if _, err := os.Stat(filepath.Join(src, "built-go")); !os.IsNotExist(err) { | |
| 158 | + | t.Fatalf("build wrote into the source directory: %v", err) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestExtInstallBuildFailureLeavesNoDestination(t *testing.T) { | |
| 163 | + | if _, err := osexec.LookPath("go"); err != nil { | |
| 164 | + | t.Skip("go is not installed") | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | home := t.TempDir() | |
| 168 | + | t.Setenv("ZOT_HOME", home) | |
| 169 | + | src := filepath.Join(t.TempDir(), "broken-go") | |
| 170 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"broken-go","exec":"./broken-go"}`), 0o644); err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | if err := os.WriteFile(filepath.Join(src, "go.mod"), []byte("module example.com/broken-go\n\ngo 1.25.0\n"), 0o644); err != nil { | |
| 177 | + | t.Fatal(err) | |
| 178 | + | } | |
| 179 | + | if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main\n\nfunc main( {\n"), 0o644); err != nil { | |
| 180 | + | t.Fatal(err) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | err := extInstall([]string{"--build=go", src}) | |
| 184 | + | if err == nil || !strings.Contains(err.Error(), "build extension with go") { | |
| 185 | + | t.Fatalf("build error = %v, want Go build failure", err) | |
| 186 | + | } | |
| 187 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "broken-go")); !os.IsNotExist(statErr) { | |
| 188 | + | t.Fatalf("failed build left a destination: %v", statErr) | |
| 189 | + | } | |
| 190 | + | entries, err := os.ReadDir(filepath.Join(home, "extensions")) | |
| 191 | + | if err != nil { | |
| 192 | + | t.Fatal(err) | |
| 193 | + | } | |
| 194 | + | for _, entry := range entries { | |
| 195 | + | if strings.HasPrefix(entry.Name(), ".zot-extension-install-") { | |
| 196 | + | t.Fatalf("failed build left staging directory %s", entry.Name()) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestExtInstallRejectsUnknownBuilder(t *testing.T) { | |
| 202 | + | err := extInstall([]string{"--build=make", t.TempDir()}) | |
| 203 | + | if err == nil || !strings.Contains(err.Error(), "unsupported extension builder") { | |
| 204 | + | t.Fatalf("error = %v, want unsupported builder error", err) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestParseExtInstallArgs(t *testing.T) { | |
| 209 | + | for _, test := range []struct { | |
| 210 | + | name string | |
| 211 | + | args []string | |
| 212 | + | builder string | |
| 213 | + | wantErr string | |
| 214 | + | }{ | |
| 215 | + | {name: "equals", args: []string{"--build=go", "./ext"}, builder: "go"}, | |
| 216 | + | {name: "separate", args: []string{"./ext", "--build", "go"}, builder: "go"}, | |
| 217 | + | {name: "missing builder", args: []string{"--build=", "./ext"}, wantErr: "requires a builder"}, | |
| 218 | + | {name: "missing separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"}, | |
| 219 | + | {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"}, | |
| 220 | + | {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"}, | |
| 221 | + | {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"}, | |
| 222 | + | } { | |
| 223 | + | t.Run(test.name, func(t *testing.T) { | |
| 224 | + | got, err := parseExtInstallArgs(test.args) | |
| 225 | + | if test.wantErr != "" { | |
| 226 | + | if err == nil || !strings.Contains(err.Error(), test.wantErr) { | |
| 227 | + | t.Fatalf("error = %v, want substring %q", err, test.wantErr) | |
| 228 | + | } | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | if err != nil { | |
| 232 | + | t.Fatalf("parse failed: %v", err) | |
| 233 | + | } | |
| 234 | + | if got.source != "./ext" || got.builder != test.builder { | |
| 235 | + | t.Fatalf("options = %#v, want source ./ext and builder %q", got, test.builder) | |
| 236 | + | } | |
| 237 | + | }) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) { | |
| 242 | + | home := t.TempDir() | |
| 243 | + | t.Setenv("ZOT_HOME", home) | |
| 244 | + | src := filepath.Join(t.TempDir(), "theme-source") | |
| 245 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 246 | + | t.Fatal(err) | |
| 247 | + | } | |
| 248 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil { | |
| 249 | + | t.Fatal(err) | |
| 250 | + | } | |
| 251 | + | if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil { | |
| 252 | + | t.Fatal(err) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | err := extInstall([]string{"--build=go", src}) | |
| 256 | + | if err == nil || !strings.Contains(err.Error(), "theme-only") { | |
| 257 | + | t.Fatalf("error = %v, want theme-only builder error", err) | |
| 258 | + | } | |
| 259 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "theme-only")); !os.IsNotExist(statErr) { | |
| 260 | + | t.Fatalf("failed build left a destination: %v", statErr) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) { | |
| 265 | + | home := t.TempDir() | |
| 266 | + | t.Setenv("ZOT_HOME", home) | |
| 267 | + | src := filepath.Join(t.TempDir(), "whitespace-name") | |
| 268 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 269 | + | t.Fatal(err) | |
| 270 | + | } | |
| 271 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":" whitespace-name ","exec":"go"}`), 0o644); err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | err := extInstall([]string{src}) | |
| 276 | + | if err == nil || !strings.Contains(err.Error(), "leading or trailing whitespace") { | |
| 277 | + | t.Fatalf("error = %v, want whitespace validation error", err) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func TestExtInstallRejectsDotManifestName(t *testing.T) { | |
| 282 | + | home := t.TempDir() | |
| 283 | + | t.Setenv("ZOT_HOME", home) | |
| 284 | + | src := filepath.Join(t.TempDir(), "dot-name") | |
| 285 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 286 | + | t.Fatal(err) | |
| 287 | + | } | |
| 288 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":".example","exec":"go"}`), 0o644); err != nil { | |
| 289 | + | t.Fatal(err) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | err := extInstall([]string{src}) | |
| 293 | + | if err == nil || !strings.Contains(err.Error(), "invalid extension name") { | |
| 294 | + | t.Fatalf("error = %v, want invalid-name validation error", err) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) { | |
| 299 | + | home := t.TempDir() | |
| 300 | + | t.Setenv("ZOT_HOME", home) | |
| 301 | + | src := filepath.Join(t.TempDir(), "path-extension") | |
| 302 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 303 | + | t.Fatal(err) | |
| 304 | + | } | |
| 305 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"path-extension","exec":"node"}`), 0o644); err != nil { | |
| 306 | + | t.Fatal(err) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | err := extInstall([]string{"--build=go", src}) | |
| 310 | + | if err == nil || !strings.Contains(err.Error(), "extension-relative executable path") { | |
| 311 | + | t.Fatalf("error = %v, want local executable requirement", err) | |
| 312 | + | } | |
| 313 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "path-extension")); !os.IsNotExist(statErr) { | |
| 314 | + | t.Fatalf("failed build left a destination: %v", statErr) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestExtInstallRejectsNonExecutable(t *testing.T) { | |
| 319 | + | if runtime.GOOS == "windows" { | |
| 320 | + | t.Skip("Unix executable permissions are not enforced on Windows") | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | home := t.TempDir() | |
| 324 | + | t.Setenv("ZOT_HOME", home) | |
| 325 | + | src := filepath.Join(t.TempDir(), "non-executable") | |
| 326 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 327 | + | t.Fatal(err) | |
| 328 | + | } | |
| 329 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"non-executable","exec":"./run.sh","language":"shell"}`), 0o644); err != nil { | |
| 330 | + | t.Fatal(err) | |
| 331 | + | } | |
| 332 | + | if err := os.WriteFile(filepath.Join(src, "run.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil { | |
| 333 | + | t.Fatal(err) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | err := extInstall([]string{src}) | |
| 337 | + | if err == nil || !strings.Contains(err.Error(), "not executable") { | |
| 338 | + | t.Fatalf("install error = %v, want executable permission guidance", err) | |
| 339 | + | } | |
| 340 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "non-executable")); !os.IsNotExist(statErr) { | |
| 341 | + | t.Fatalf("failed install left a destination: %v", statErr) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // TestExtInstallCopiesIgnoredExecutableAndUsesManifestName verifies that a | |
| 346 | + | // declared runtime file is copied even when .gitignore matches it, and that | |
| 347 | + | // the manifest name controls the installed directory. | |
| 348 | + | func TestExtInstallCopiesIgnoredExecutableAndUsesManifestName(t *testing.T) { | |
| 349 | + | home := t.TempDir() | |
| 350 | + | t.Setenv("ZOT_HOME", home) | |
| 351 | + | ||
| 352 | + | src := filepath.Join(t.TempDir(), "source-dir") | |
| 353 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 354 | + | t.Fatal(err) | |
| 355 | + | } | |
| 356 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil { | |
| 357 | + | t.Fatal(err) | |
| 358 | + | } | |
| 359 | + | if err := os.WriteFile(filepath.Join(src, ".gitignore"), []byte("tasked-phases\n"), 0o644); err != nil { | |
| 360 | + | t.Fatal(err) | |
| 361 | + | } | |
| 362 | + | if err := os.WriteFile(filepath.Join(src, "tasked-phases"), []byte("not really a binary"), 0o755); err != nil { | |
| 363 | + | t.Fatal(err) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | if err := extInstall([]string{src}); err != nil { | |
| 367 | + | t.Fatalf("install failed: %v", err) | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | out := filepath.Join(home, "extensions", "tasked-phases") | |
| 371 | + | info, err := os.Stat(filepath.Join(out, "tasked-phases")) | |
| 372 | + | if err != nil { | |
| 373 | + | t.Fatalf("declared executable was not copied: %v", err) | |
| 374 | + | } | |
| 375 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |
| 376 | + | t.Fatalf("copied executable mode = %o, want an execute bit", info.Mode().Perm()) | |
| 377 | + | } | |
| 378 | + | if _, err := os.Stat(filepath.Join(home, "extensions", "source-dir")); !os.IsNotExist(err) { | |
| 379 | + | t.Fatalf("install used source directory name instead of manifest name: %v", err) | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 80 | 383 | func TestCopyDirRespectsGitignore(t *testing.T) { | |
| 81 | 384 | src := t.TempDir() | |
| 82 | 385 | dst := filepath.Join(t.TempDir(), "out") | |
| @@ -3,16 +3,18 @@ package agent | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | + | osexec "os/exec" | |||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "runtime" | |||
| 7 | "strings" | 9 | "strings" | ||
| 8 | "testing" | 10 | "testing" | ||
| 9 | 11 | ||||
| 10 | "github.com/patriceckhart/zot/packages/agent/extensions" | 12 | "github.com/patriceckhart/zot/packages/agent/extensions" | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | - | // TestExtInstallDotSource verifies that `zot ext install .` derives the | 15 | + | // TestExtInstallDotSource verifies that `zot ext install .` resolves the |
| 14 | - | // extension name from the resolved directory name rather than collapsing | 16 | + | // source directory correctly instead of collapsing it to the extensions/ |
| 15 | - | // to the extensions/ parent directory (the false "already exists" bug). | 17 | + | // parent directory. |
| 16 | func TestExtInstallDotSource(t *testing.T) { | 18 | func TestExtInstallDotSource(t *testing.T) { | ||
| 17 | home := t.TempDir() | 19 | home := t.TempDir() | ||
| 18 | t.Setenv("ZOT_HOME", home) | 20 | t.Setenv("ZOT_HOME", home) | ||
| @@ -27,7 +29,7 @@ func TestExtInstallDotSource(t *testing.T) { | |||||
| 27 | if err := os.MkdirAll(src, 0o755); err != nil { | 29 | if err := os.MkdirAll(src, 0o755); err != nil { | ||
| 28 | t.Fatal(err) | 30 | t.Fatal(err) | ||
| 29 | } | 31 | } | ||
| 30 | - | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi"}`), 0o644); err != nil { | 32 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi","exec":"go"}`), 0o644); err != nil { |
| 31 | t.Fatal(err) | 33 | t.Fatal(err) | ||
| 32 | } | 34 | } | ||
| 33 | 35 | ||||
| @@ -50,10 +52,8 @@ func TestExtInstallDotSource(t *testing.T) { | |||||
| 50 | } | 52 | } | ||
| 51 | } | 53 | } | ||
| 52 | 54 | ||||
| 53 | - | // TestExtInstallRejectsParentName guards against deriving a name of ".." | 55 | + | // TestExtInstallNamedDir verifies that a normal named source installs |
| 54 | - | // from a source that resolves to a filesystem root edge case. A normal | 56 | + | // successfully when its manifest uses a PATH-resolved executable. |
| 55 | - | // directory always yields a real basename, so this just ensures the | |||
| 56 | - | // guard logic does not crash for well-formed input. | |||
| 57 | func TestExtInstallNamedDir(t *testing.T) { | 57 | func TestExtInstallNamedDir(t *testing.T) { | ||
| 58 | home := t.TempDir() | 58 | home := t.TempDir() | ||
| 59 | t.Setenv("ZOT_HOME", home) | 59 | t.Setenv("ZOT_HOME", home) | ||
| @@ -62,7 +62,7 @@ func TestExtInstallNamedDir(t *testing.T) { | |||||
| 62 | if err := os.MkdirAll(src, 0o755); err != nil { | 62 | if err := os.MkdirAll(src, 0o755); err != nil { | ||
| 63 | t.Fatal(err) | 63 | t.Fatal(err) | ||
| 64 | } | 64 | } | ||
| 65 | - | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext"}`), 0o644); err != nil { | 65 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext","exec":"go"}`), 0o644); err != nil { |
| 66 | t.Fatal(err) | 66 | t.Fatal(err) | ||
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| @@ -74,9 +74,312 @@ func TestExtInstallNamedDir(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | - | // TestCopyDirRespectsGitignore verifies that non-portable directories | 77 | + | func TestExtInstallThemeOnly(t *testing.T) { |
| 78 | - | // listed in the source .gitignore (e.g. .venv, node_modules) are not | 78 | + | home := t.TempDir() |
| 79 | - | // copied during install, while tracked files are. | 79 | + | t.Setenv("ZOT_HOME", home) |
| 80 | + | ||||
| 81 | + | src := filepath.Join(t.TempDir(), "theme-source") | |||
| 82 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | if err := extInstall([]string{src}); err != nil { | |||
| 93 | + | t.Fatalf("theme-only install failed: %v", err) | |||
| 94 | + | } | |||
| 95 | + | out := filepath.Join(home, "extensions", "theme-only") | |||
| 96 | + | for _, file := range []string{"extension.json", "theme.json"} { | |||
| 97 | + | if _, err := os.Stat(filepath.Join(out, file)); err != nil { | |||
| 98 | + | t.Fatalf("installed theme missing %s: %v", file, err) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestExtInstallRejectsMissingExecutable(t *testing.T) { | |||
| 104 | + | home := t.TempDir() | |||
| 105 | + | t.Setenv("ZOT_HOME", home) | |||
| 106 | + | ||||
| 107 | + | src := filepath.Join(t.TempDir(), "tasked-phases") | |||
| 108 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | err := extInstall([]string{src}) | |||
| 116 | + | if err == nil || !strings.Contains(err.Error(), "--build=go") { | |||
| 117 | + | t.Fatalf("install error = %v, want explicit Go builder guidance", err) | |||
| 118 | + | } | |||
| 119 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "tasked-phases")); !os.IsNotExist(statErr) { | |||
| 120 | + | t.Fatalf("failed install left a destination: %v", statErr) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestExtInstallBuildGo(t *testing.T) { | |||
| 125 | + | if _, err := osexec.LookPath("go"); err != nil { | |||
| 126 | + | t.Skip("go is not installed") | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | home := t.TempDir() | |||
| 130 | + | t.Setenv("ZOT_HOME", home) | |||
| 131 | + | src := filepath.Join(t.TempDir(), "go-extension") | |||
| 132 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 133 | + | t.Fatal(err) | |||
| 134 | + | } | |||
| 135 | + | mustWrite := func(name, body string) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil { | |||
| 138 | + | t.Fatal(err) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | mustWrite("extension.json", `{"name":"built-go","exec":"./built-go","language":"go"}`) | |||
| 142 | + | mustWrite("go.mod", "module example.com/built-go\n\ngo 1.25.0\n") | |||
| 143 | + | mustWrite("main.go", "package main\n\nfunc main() {}\n") | |||
| 144 | + | ||||
| 145 | + | if err := extInstall([]string{"--build=go", src}); err != nil { | |||
| 146 | + | t.Fatalf("build install failed: %v", err) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | installed := filepath.Join(home, "extensions", "built-go", "built-go") | |||
| 150 | + | info, err := os.Stat(installed) | |||
| 151 | + | if err != nil { | |||
| 152 | + | t.Fatalf("built executable was not installed: %v", err) | |||
| 153 | + | } | |||
| 154 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |||
| 155 | + | t.Fatalf("installed executable mode = %o, want an execute bit", info.Mode().Perm()) | |||
| 156 | + | } | |||
| 157 | + | if _, err := os.Stat(filepath.Join(src, "built-go")); !os.IsNotExist(err) { | |||
| 158 | + | t.Fatalf("build wrote into the source directory: %v", err) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestExtInstallBuildFailureLeavesNoDestination(t *testing.T) { | |||
| 163 | + | if _, err := osexec.LookPath("go"); err != nil { | |||
| 164 | + | t.Skip("go is not installed") | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | home := t.TempDir() | |||
| 168 | + | t.Setenv("ZOT_HOME", home) | |||
| 169 | + | src := filepath.Join(t.TempDir(), "broken-go") | |||
| 170 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 171 | + | t.Fatal(err) | |||
| 172 | + | } | |||
| 173 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"broken-go","exec":"./broken-go"}`), 0o644); err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | if err := os.WriteFile(filepath.Join(src, "go.mod"), []byte("module example.com/broken-go\n\ngo 1.25.0\n"), 0o644); err != nil { | |||
| 177 | + | t.Fatal(err) | |||
| 178 | + | } | |||
| 179 | + | if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main\n\nfunc main( {\n"), 0o644); err != nil { | |||
| 180 | + | t.Fatal(err) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | err := extInstall([]string{"--build=go", src}) | |||
| 184 | + | if err == nil || !strings.Contains(err.Error(), "build extension with go") { | |||
| 185 | + | t.Fatalf("build error = %v, want Go build failure", err) | |||
| 186 | + | } | |||
| 187 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "broken-go")); !os.IsNotExist(statErr) { | |||
| 188 | + | t.Fatalf("failed build left a destination: %v", statErr) | |||
| 189 | + | } | |||
| 190 | + | entries, err := os.ReadDir(filepath.Join(home, "extensions")) | |||
| 191 | + | if err != nil { | |||
| 192 | + | t.Fatal(err) | |||
| 193 | + | } | |||
| 194 | + | for _, entry := range entries { | |||
| 195 | + | if strings.HasPrefix(entry.Name(), ".zot-extension-install-") { | |||
| 196 | + | t.Fatalf("failed build left staging directory %s", entry.Name()) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestExtInstallRejectsUnknownBuilder(t *testing.T) { | |||
| 202 | + | err := extInstall([]string{"--build=make", t.TempDir()}) | |||
| 203 | + | if err == nil || !strings.Contains(err.Error(), "unsupported extension builder") { | |||
| 204 | + | t.Fatalf("error = %v, want unsupported builder error", err) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestParseExtInstallArgs(t *testing.T) { | |||
| 209 | + | for _, test := range []struct { | |||
| 210 | + | name string | |||
| 211 | + | args []string | |||
| 212 | + | builder string | |||
| 213 | + | wantErr string | |||
| 214 | + | }{ | |||
| 215 | + | {name: "equals", args: []string{"--build=go", "./ext"}, builder: "go"}, | |||
| 216 | + | {name: "separate", args: []string{"./ext", "--build", "go"}, builder: "go"}, | |||
| 217 | + | {name: "missing builder", args: []string{"--build=", "./ext"}, wantErr: "requires a builder"}, | |||
| 218 | + | {name: "missing separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"}, | |||
| 219 | + | {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"}, | |||
| 220 | + | {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"}, | |||
| 221 | + | {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"}, | |||
| 222 | + | } { | |||
| 223 | + | t.Run(test.name, func(t *testing.T) { | |||
| 224 | + | got, err := parseExtInstallArgs(test.args) | |||
| 225 | + | if test.wantErr != "" { | |||
| 226 | + | if err == nil || !strings.Contains(err.Error(), test.wantErr) { | |||
| 227 | + | t.Fatalf("error = %v, want substring %q", err, test.wantErr) | |||
| 228 | + | } | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | if err != nil { | |||
| 232 | + | t.Fatalf("parse failed: %v", err) | |||
| 233 | + | } | |||
| 234 | + | if got.source != "./ext" || got.builder != test.builder { | |||
| 235 | + | t.Fatalf("options = %#v, want source ./ext and builder %q", got, test.builder) | |||
| 236 | + | } | |||
| 237 | + | }) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) { | |||
| 242 | + | home := t.TempDir() | |||
| 243 | + | t.Setenv("ZOT_HOME", home) | |||
| 244 | + | src := filepath.Join(t.TempDir(), "theme-source") | |||
| 245 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 246 | + | t.Fatal(err) | |||
| 247 | + | } | |||
| 248 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil { | |||
| 249 | + | t.Fatal(err) | |||
| 250 | + | } | |||
| 251 | + | if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil { | |||
| 252 | + | t.Fatal(err) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | err := extInstall([]string{"--build=go", src}) | |||
| 256 | + | if err == nil || !strings.Contains(err.Error(), "theme-only") { | |||
| 257 | + | t.Fatalf("error = %v, want theme-only builder error", err) | |||
| 258 | + | } | |||
| 259 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "theme-only")); !os.IsNotExist(statErr) { | |||
| 260 | + | t.Fatalf("failed build left a destination: %v", statErr) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) { | |||
| 265 | + | home := t.TempDir() | |||
| 266 | + | t.Setenv("ZOT_HOME", home) | |||
| 267 | + | src := filepath.Join(t.TempDir(), "whitespace-name") | |||
| 268 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 269 | + | t.Fatal(err) | |||
| 270 | + | } | |||
| 271 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":" whitespace-name ","exec":"go"}`), 0o644); err != nil { | |||
| 272 | + | t.Fatal(err) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | err := extInstall([]string{src}) | |||
| 276 | + | if err == nil || !strings.Contains(err.Error(), "leading or trailing whitespace") { | |||
| 277 | + | t.Fatalf("error = %v, want whitespace validation error", err) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func TestExtInstallRejectsDotManifestName(t *testing.T) { | |||
| 282 | + | home := t.TempDir() | |||
| 283 | + | t.Setenv("ZOT_HOME", home) | |||
| 284 | + | src := filepath.Join(t.TempDir(), "dot-name") | |||
| 285 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 286 | + | t.Fatal(err) | |||
| 287 | + | } | |||
| 288 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":".example","exec":"go"}`), 0o644); err != nil { | |||
| 289 | + | t.Fatal(err) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | err := extInstall([]string{src}) | |||
| 293 | + | if err == nil || !strings.Contains(err.Error(), "invalid extension name") { | |||
| 294 | + | t.Fatalf("error = %v, want invalid-name validation error", err) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) { | |||
| 299 | + | home := t.TempDir() | |||
| 300 | + | t.Setenv("ZOT_HOME", home) | |||
| 301 | + | src := filepath.Join(t.TempDir(), "path-extension") | |||
| 302 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 303 | + | t.Fatal(err) | |||
| 304 | + | } | |||
| 305 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"path-extension","exec":"node"}`), 0o644); err != nil { | |||
| 306 | + | t.Fatal(err) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | err := extInstall([]string{"--build=go", src}) | |||
| 310 | + | if err == nil || !strings.Contains(err.Error(), "extension-relative executable path") { | |||
| 311 | + | t.Fatalf("error = %v, want local executable requirement", err) | |||
| 312 | + | } | |||
| 313 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "path-extension")); !os.IsNotExist(statErr) { | |||
| 314 | + | t.Fatalf("failed build left a destination: %v", statErr) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestExtInstallRejectsNonExecutable(t *testing.T) { | |||
| 319 | + | if runtime.GOOS == "windows" { | |||
| 320 | + | t.Skip("Unix executable permissions are not enforced on Windows") | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | home := t.TempDir() | |||
| 324 | + | t.Setenv("ZOT_HOME", home) | |||
| 325 | + | src := filepath.Join(t.TempDir(), "non-executable") | |||
| 326 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 327 | + | t.Fatal(err) | |||
| 328 | + | } | |||
| 329 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"non-executable","exec":"./run.sh","language":"shell"}`), 0o644); err != nil { | |||
| 330 | + | t.Fatal(err) | |||
| 331 | + | } | |||
| 332 | + | if err := os.WriteFile(filepath.Join(src, "run.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil { | |||
| 333 | + | t.Fatal(err) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | err := extInstall([]string{src}) | |||
| 337 | + | if err == nil || !strings.Contains(err.Error(), "not executable") { | |||
| 338 | + | t.Fatalf("install error = %v, want executable permission guidance", err) | |||
| 339 | + | } | |||
| 340 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "non-executable")); !os.IsNotExist(statErr) { | |||
| 341 | + | t.Fatalf("failed install left a destination: %v", statErr) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // TestExtInstallCopiesIgnoredExecutableAndUsesManifestName verifies that a | |||
| 346 | + | // declared runtime file is copied even when .gitignore matches it, and that | |||
| 347 | + | // the manifest name controls the installed directory. | |||
| 348 | + | func TestExtInstallCopiesIgnoredExecutableAndUsesManifestName(t *testing.T) { | |||
| 349 | + | home := t.TempDir() | |||
| 350 | + | t.Setenv("ZOT_HOME", home) | |||
| 351 | + | ||||
| 352 | + | src := filepath.Join(t.TempDir(), "source-dir") | |||
| 353 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 354 | + | t.Fatal(err) | |||
| 355 | + | } | |||
| 356 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil { | |||
| 357 | + | t.Fatal(err) | |||
| 358 | + | } | |||
| 359 | + | if err := os.WriteFile(filepath.Join(src, ".gitignore"), []byte("tasked-phases\n"), 0o644); err != nil { | |||
| 360 | + | t.Fatal(err) | |||
| 361 | + | } | |||
| 362 | + | if err := os.WriteFile(filepath.Join(src, "tasked-phases"), []byte("not really a binary"), 0o755); err != nil { | |||
| 363 | + | t.Fatal(err) | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | if err := extInstall([]string{src}); err != nil { | |||
| 367 | + | t.Fatalf("install failed: %v", err) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | out := filepath.Join(home, "extensions", "tasked-phases") | |||
| 371 | + | info, err := os.Stat(filepath.Join(out, "tasked-phases")) | |||
| 372 | + | if err != nil { | |||
| 373 | + | t.Fatalf("declared executable was not copied: %v", err) | |||
| 374 | + | } | |||
| 375 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |||
| 376 | + | t.Fatalf("copied executable mode = %o, want an execute bit", info.Mode().Perm()) | |||
| 377 | + | } | |||
| 378 | + | if _, err := os.Stat(filepath.Join(home, "extensions", "source-dir")); !os.IsNotExist(err) { | |||
| 379 | + | t.Fatalf("install used source directory name instead of manifest name: %v", err) | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 80 | func TestCopyDirRespectsGitignore(t *testing.T) { | 383 | func TestCopyDirRespectsGitignore(t *testing.T) { | ||
| 81 | src := t.TempDir() | 384 | src := t.TempDir() | ||
| 82 | dst := filepath.Join(t.TempDir(), "out") | 385 | dst := filepath.Join(t.TempDir(), "out") | ||
M packages/agent/extensions/manager.go +21 -22
| @@ -279,7 +279,7 @@ func (m *Manager) Discover(ctx context.Context) []error { | |||
| 279 | 279 | continue // missing directory is fine | |
| 280 | 280 | } | |
| 281 | 281 | for _, e := range entries { | |
| 282 | - | if !e.IsDir() { | |
| 282 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { | |
| 283 | 283 | continue | |
| 284 | 284 | } | |
| 285 | 285 | if seenDirs[e.Name()] { | |
| @@ -600,6 +600,23 @@ func (m *Manager) WaitForReady(grace time.Duration) { | |||
| 600 | 600 | // after it closes stdout before completing the hello handshake. | |
| 601 | 601 | const extensionExitGrace = time.Second | |
| 602 | 602 | ||
| 603 | + | // ResolveExecPath applies the manifest exec resolution rules. The bool is | |
| 604 | + | // true when exec names an absolute or extension-relative path; false means | |
| 605 | + | // the value is a bare command resolved through PATH. | |
| 606 | + | func ResolveExecPath(extDir, execName string) (string, bool) { | |
| 607 | + | normalized := filepath.FromSlash(execName) | |
| 608 | + | if filepath.IsAbs(normalized) { | |
| 609 | + | return normalized, true | |
| 610 | + | } | |
| 611 | + | if normalized == "." || normalized == ".." || | |
| 612 | + | strings.HasPrefix(normalized, "."+string(filepath.Separator)) || | |
| 613 | + | strings.HasPrefix(normalized, ".."+string(filepath.Separator)) || | |
| 614 | + | strings.ContainsRune(normalized, filepath.Separator) { | |
| 615 | + | return filepath.Join(extDir, normalized), true | |
| 616 | + | } | |
| 617 | + | return normalized, false | |
| 618 | + | } | |
| 619 | + | ||
| 603 | 620 | // spawn launches the subprocess, hooks up pipes, logs stderr, and | |
| 604 | 621 | // runs the synchronous portion of the hello handshake. Asynchronous | |
| 605 | 622 | // frames are processed in a goroutine started here. | |
| @@ -615,27 +632,9 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 615 | 632 | ext.logFile = logFile | |
| 616 | 633 | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | |
| 617 | 634 | ||
| 618 | - | // Exec resolution rules: | |
| 619 | - | // - absolute path: used as-is. | |
| 620 | - | // - starts with "." (./ or ../): resolved relative to ext.Dir. | |
| 621 | - | // - bare name (no path separator): looked up via $PATH so | |
| 622 | - | // "node", "npx", "python3", | |
| 623 | - | // "tsx" etc. work without | |
| 624 | - | // forcing absolute paths. | |
| 625 | - | // - other relative form (foo/bar): resolved relative to ext.Dir. | |
| 626 | - | execPath := ext.Manifest.Exec | |
| 627 | - | switch { | |
| 628 | - | case filepath.IsAbs(execPath): | |
| 629 | - | // keep | |
| 630 | - | case strings.HasPrefix(execPath, "."+string(filepath.Separator)) || | |
| 631 | - | strings.HasPrefix(execPath, ".."+string(filepath.Separator)) || | |
| 632 | - | execPath == "." || execPath == "..": | |
| 633 | - | execPath = filepath.Join(ext.Dir, execPath) | |
| 634 | - | case strings.ContainsRune(execPath, filepath.Separator): | |
| 635 | - | execPath = filepath.Join(ext.Dir, execPath) | |
| 636 | - | default: | |
| 637 | - | // bare name: leave as-is for exec.LookPath via exec.Command. | |
| 638 | - | } | |
| 635 | + | // Bare names (node, npx, python3, tsx, etc.) are looked up via PATH; | |
| 636 | + | // absolute and extension-relative paths are resolved by the helper. | |
| 637 | + | execPath, _ := ResolveExecPath(ext.Dir, ext.Manifest.Exec) | |
| 639 | 638 | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | |
| 640 | 639 | cmd.Dir = ext.Dir | |
| 641 | 640 | cmd.Stderr = logFile | |
| @@ -279,7 +279,7 @@ func (m *Manager) Discover(ctx context.Context) []error { | |||||
| 279 | continue // missing directory is fine | 279 | continue // missing directory is fine | ||
| 280 | } | 280 | } | ||
| 281 | for _, e := range entries { | 281 | for _, e := range entries { | ||
| 282 | - | if !e.IsDir() { | 282 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { |
| 283 | continue | 283 | continue | ||
| 284 | } | 284 | } | ||
| 285 | if seenDirs[e.Name()] { | 285 | if seenDirs[e.Name()] { | ||
| @@ -600,6 +600,23 @@ func (m *Manager) WaitForReady(grace time.Duration) { | |||||
| 600 | // after it closes stdout before completing the hello handshake. | 600 | // after it closes stdout before completing the hello handshake. | ||
| 601 | const extensionExitGrace = time.Second | 601 | const extensionExitGrace = time.Second | ||
| 602 | 602 | ||||
| 603 | + | // ResolveExecPath applies the manifest exec resolution rules. The bool is | |||
| 604 | + | // true when exec names an absolute or extension-relative path; false means | |||
| 605 | + | // the value is a bare command resolved through PATH. | |||
| 606 | + | func ResolveExecPath(extDir, execName string) (string, bool) { | |||
| 607 | + | normalized := filepath.FromSlash(execName) | |||
| 608 | + | if filepath.IsAbs(normalized) { | |||
| 609 | + | return normalized, true | |||
| 610 | + | } | |||
| 611 | + | if normalized == "." || normalized == ".." || | |||
| 612 | + | strings.HasPrefix(normalized, "."+string(filepath.Separator)) || | |||
| 613 | + | strings.HasPrefix(normalized, ".."+string(filepath.Separator)) || | |||
| 614 | + | strings.ContainsRune(normalized, filepath.Separator) { | |||
| 615 | + | return filepath.Join(extDir, normalized), true | |||
| 616 | + | } | |||
| 617 | + | return normalized, false | |||
| 618 | + | } | |||
| 619 | + | ||||
| 603 | // spawn launches the subprocess, hooks up pipes, logs stderr, and | 620 | // spawn launches the subprocess, hooks up pipes, logs stderr, and | ||
| 604 | // runs the synchronous portion of the hello handshake. Asynchronous | 621 | // runs the synchronous portion of the hello handshake. Asynchronous | ||
| 605 | // frames are processed in a goroutine started here. | 622 | // frames are processed in a goroutine started here. | ||
| @@ -615,27 +632,9 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||||
| 615 | ext.logFile = logFile | 632 | ext.logFile = logFile | ||
| 616 | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | 633 | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | ||
| 617 | 634 | ||||
| 618 | - | // Exec resolution rules: | 635 | + | // Bare names (node, npx, python3, tsx, etc.) are looked up via PATH; |
| 619 | - | // - absolute path: used as-is. | 636 | + | // absolute and extension-relative paths are resolved by the helper. |
| 620 | - | // - starts with "." (./ or ../): resolved relative to ext.Dir. | 637 | + | execPath, _ := ResolveExecPath(ext.Dir, ext.Manifest.Exec) |
| 621 | - | // - bare name (no path separator): looked up via $PATH so | |||
| 622 | - | // "node", "npx", "python3", | |||
| 623 | - | // "tsx" etc. work without | |||
| 624 | - | // forcing absolute paths. | |||
| 625 | - | // - other relative form (foo/bar): resolved relative to ext.Dir. | |||
| 626 | - | execPath := ext.Manifest.Exec | |||
| 627 | - | switch { | |||
| 628 | - | case filepath.IsAbs(execPath): | |||
| 629 | - | // keep | |||
| 630 | - | case strings.HasPrefix(execPath, "."+string(filepath.Separator)) || | |||
| 631 | - | strings.HasPrefix(execPath, ".."+string(filepath.Separator)) || | |||
| 632 | - | execPath == "." || execPath == "..": | |||
| 633 | - | execPath = filepath.Join(ext.Dir, execPath) | |||
| 634 | - | case strings.ContainsRune(execPath, filepath.Separator): | |||
| 635 | - | execPath = filepath.Join(ext.Dir, execPath) | |||
| 636 | - | default: | |||
| 637 | - | // bare name: leave as-is for exec.LookPath via exec.Command. | |||
| 638 | - | } | |||
| 639 | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | 638 | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | ||
| 640 | cmd.Dir = ext.Dir | 639 | cmd.Dir = ext.Dir | ||
| 641 | cmd.Stderr = logFile | 640 | cmd.Stderr = logFile | ||
M packages/agent/lsp/client.go +3 -2
| @@ -543,6 +543,7 @@ func (c *Client) Close() error { | |||
| 543 | 543 | _ = c.stdin.Close() | |
| 544 | 544 | c.writeMu.Unlock() | |
| 545 | 545 | killed := false | |
| 546 | + | process := c.cmd.Process | |
| 546 | 547 | exited := make(chan error, 1) | |
| 547 | 548 | go func() { exited <- c.cmd.Wait() }() | |
| 548 | 549 | timer := time.NewTimer(500 * time.Millisecond) | |
| @@ -550,8 +551,8 @@ func (c *Client) Close() error { | |||
| 550 | 551 | case waitErr = <-exited: | |
| 551 | 552 | timer.Stop() | |
| 552 | 553 | case <-timer.C: | |
| 553 | - | if c.cmd.Process != nil && c.cmd.ProcessState == nil { | |
| 554 | - | killed = c.cmd.Process.Kill() == nil | |
| 554 | + | if process != nil { | |
| 555 | + | killed = process.Kill() == nil | |
| 555 | 556 | } | |
| 556 | 557 | waitErr = <-exited | |
| 557 | 558 | } | |
| @@ -543,6 +543,7 @@ func (c *Client) Close() error { | |||||
| 543 | _ = c.stdin.Close() | 543 | _ = c.stdin.Close() | ||
| 544 | c.writeMu.Unlock() | 544 | c.writeMu.Unlock() | ||
| 545 | killed := false | 545 | killed := false | ||
| 546 | + | process := c.cmd.Process | |||
| 546 | exited := make(chan error, 1) | 547 | exited := make(chan error, 1) | ||
| 547 | go func() { exited <- c.cmd.Wait() }() | 548 | go func() { exited <- c.cmd.Wait() }() | ||
| 548 | timer := time.NewTimer(500 * time.Millisecond) | 549 | timer := time.NewTimer(500 * time.Millisecond) | ||
| @@ -550,8 +551,8 @@ func (c *Client) Close() error { | |||||
| 550 | case waitErr = <-exited: | 551 | case waitErr = <-exited: | ||
| 551 | timer.Stop() | 552 | timer.Stop() | ||
| 552 | case <-timer.C: | 553 | case <-timer.C: | ||
| 553 | - | if c.cmd.Process != nil && c.cmd.ProcessState == nil { | 554 | + | if process != nil { |
| 554 | - | killed = c.cmd.Process.Kill() == nil | 555 | + | killed = process.Kill() == nil |
| 555 | } | 556 | } | ||
| 556 | waitErr = <-exited | 557 | waitErr = <-exited | ||
| 557 | } | 558 | } | ||
A packages/agent/modes/update_banner_test.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestRenderUpdateBannerOmitsUnavailableUpdate(t *testing.T) { | |
| 10 | + | if lines := renderUpdateBanner(tui.Theme{}, UpdateInfo{}, 80); len(lines) != 0 { | |
| 11 | + | t.Fatalf("banner = %#v, want no banner", lines) | |
| 12 | + | } | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRenderUpdateBannerOmitsUnavailableUpdate(t *testing.T) { | |||
| 10 | + | if lines := renderUpdateBanner(tui.Theme{}, UpdateInfo{}, 80); len(lines) != 0 { | |||
| 11 | + | t.Fatalf("banner = %#v, want no banner", lines) | |||
| 12 | + | } | |||
| 13 | + | } | |||
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +11 -7
| @@ -339,6 +339,7 @@ for line in sys.stdin: | |||
| 339 | 339 | ||
| 340 | 340 | ```bash | |
| 341 | 341 | zot ext install ./weather # copy into $ZOT_HOME/extensions/ | |
| 342 | + | zot ext install --build=go ./weather # explicitly build local Go source | |
| 342 | 343 | zot --ext ./weather # run from disk for one zot session (no install) | |
| 343 | 344 | zot --ext . # cwd is the extension dir | |
| 344 | 345 | zot ext list # show installed extensions | |
| @@ -352,9 +353,12 @@ zot ext remove weather | |||
| 352 | 353 | For TS / Python extensions, no build step is needed — edit the source | |
| 353 | 354 | in place and relaunch zot. | |
| 354 | 355 | ||
| 355 | - | For Go, run `go build -o <name> .` in the extension directory after | |
| 356 | - | edits, then `zot ext install` (which copies the manifest + binary) | |
| 357 | - | or `zot --ext .` to test from the working tree. | |
| 356 | + | For Go, either run `zot ext install --build=go <path>` to explicitly build | |
| 357 | + | the local source into the staged install, or run `go build -o <name> .` | |
| 358 | + | first and then use `zot ext install <path>`. The default install never runs a | |
| 359 | + | build step; a missing local executable is rejected with an explicit builder | |
| 360 | + | hint instead of leaving a broken installed extension. The `--build=go` option | |
| 361 | + | is intentionally limited to local source paths and does not apply to git URLs. | |
| 358 | 362 | ||
| 359 | 363 | ## Manual debug | |
| 360 | 364 | ||
| @@ -387,10 +391,10 @@ logged to `ext-<name>.log`. | |||
| 387 | 391 | if they prefer JS-flavored ergonomics; **Python** for one-off | |
| 388 | 392 | scripts. | |
| 389 | 393 | 4. Write the extension dir (manifest + source). | |
| 390 | - | 5. For Go, build it. For TS / Python, mark the script executable. | |
| 391 | - | 6. Suggest `zot --ext <path>` for testing without committing to an | |
| 392 | - | install. | |
| 393 | - | 7. When happy, `zot ext install <path>`. | |
| 394 | + | 5. For Go, use `zot ext install --build=go <path>` or build it manually. | |
| 395 | + | For TS / Python, mark the script executable. | |
| 396 | + | 6. Suggest `zot --ext <path>` for testing without committing to an install. | |
| 397 | + | 7. When happy, `zot ext install <path>` (or `--build=go` for local Go source). | |
| 394 | 398 | ||
| 395 | 399 | Don't try to write a full SDK or framework on top of the protocol | |
| 396 | 400 | unless the user asked for one — the wire format is small enough | |
| @@ -339,6 +339,7 @@ for line in sys.stdin: | |||||
| 339 | 339 | ||||
| 340 | ```bash | 340 | ```bash | ||
| 341 | zot ext install ./weather # copy into $ZOT_HOME/extensions/ | 341 | zot ext install ./weather # copy into $ZOT_HOME/extensions/ | ||
| 342 | + | zot ext install --build=go ./weather # explicitly build local Go source | |||
| 342 | zot --ext ./weather # run from disk for one zot session (no install) | 343 | zot --ext ./weather # run from disk for one zot session (no install) | ||
| 343 | zot --ext . # cwd is the extension dir | 344 | zot --ext . # cwd is the extension dir | ||
| 344 | zot ext list # show installed extensions | 345 | zot ext list # show installed extensions | ||
| @@ -352,9 +353,12 @@ zot ext remove weather | |||||
| 352 | For TS / Python extensions, no build step is needed — edit the source | 353 | For TS / Python extensions, no build step is needed — edit the source | ||
| 353 | in place and relaunch zot. | 354 | in place and relaunch zot. | ||
| 354 | 355 | ||||
| 355 | - | For Go, run `go build -o <name> .` in the extension directory after | 356 | + | For Go, either run `zot ext install --build=go <path>` to explicitly build |
| 356 | - | edits, then `zot ext install` (which copies the manifest + binary) | 357 | + | the local source into the staged install, or run `go build -o <name> .` |
| 357 | - | or `zot --ext .` to test from the working tree. | 358 | + | first and then use `zot ext install <path>`. The default install never runs a |
| 359 | + | build step; a missing local executable is rejected with an explicit builder | |||
| 360 | + | hint instead of leaving a broken installed extension. The `--build=go` option | |||
| 361 | + | is intentionally limited to local source paths and does not apply to git URLs. | |||
| 358 | 362 | ||||
| 359 | ## Manual debug | 363 | ## Manual debug | ||
| 360 | 364 | ||||
| @@ -387,10 +391,10 @@ logged to `ext-<name>.log`. | |||||
| 387 | if they prefer JS-flavored ergonomics; **Python** for one-off | 391 | if they prefer JS-flavored ergonomics; **Python** for one-off | ||
| 388 | scripts. | 392 | scripts. | ||
| 389 | 4. Write the extension dir (manifest + source). | 393 | 4. Write the extension dir (manifest + source). | ||
| 390 | - | 5. For Go, build it. For TS / Python, mark the script executable. | 394 | + | 5. For Go, use `zot ext install --build=go <path>` or build it manually. |
| 391 | - | 6. Suggest `zot --ext <path>` for testing without committing to an | 395 | + | For TS / Python, mark the script executable. |
| 392 | - | install. | 396 | + | 6. Suggest `zot --ext <path>` for testing without committing to an install. |
| 393 | - | 7. When happy, `zot ext install <path>`. | 397 | + | 7. When happy, `zot ext install <path>` (or `--build=go` for local Go source). |
| 394 | 398 | ||||
| 395 | Don't try to write a full SDK or framework on top of the protocol | 399 | Don't try to write a full SDK or framework on top of the protocol | ||
| 396 | unless the user asked for one — the wire format is small enough | 400 | unless the user asked for one — the wire format is small enough | ||
M packages/agent/subagents/profiles.go +5 -1
| @@ -54,7 +54,11 @@ func Discover(cwd, userHome string) ([]*Profile, []error) { | |||
| 54 | 54 | for _, loc := range searchDirs(cwd, userHome) { | |
| 55 | 55 | entries, err := os.ReadDir(loc.dir) | |
| 56 | 56 | if err != nil { | |
| 57 | - | if !os.IsNotExist(err) { | |
| 57 | + | if os.IsNotExist(err) { | |
| 58 | + | if info, statErr := os.Stat(loc.dir); statErr == nil && !info.IsDir() { | |
| 59 | + | errs = append(errs, fmt.Errorf("read subagent directory %s: not a directory", loc.dir)) | |
| 60 | + | } | |
| 61 | + | } else { | |
| 58 | 62 | errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err)) | |
| 59 | 63 | } | |
| 60 | 64 | continue | |
| @@ -54,7 +54,11 @@ func Discover(cwd, userHome string) ([]*Profile, []error) { | |||||
| 54 | for _, loc := range searchDirs(cwd, userHome) { | 54 | for _, loc := range searchDirs(cwd, userHome) { | ||
| 55 | entries, err := os.ReadDir(loc.dir) | 55 | entries, err := os.ReadDir(loc.dir) | ||
| 56 | if err != nil { | 56 | if err != nil { | ||
| 57 | - | if !os.IsNotExist(err) { | 57 | + | if os.IsNotExist(err) { |
| 58 | + | if info, statErr := os.Stat(loc.dir); statErr == nil && !info.IsDir() { | |||
| 59 | + | errs = append(errs, fmt.Errorf("read subagent directory %s: not a directory", loc.dir)) | |||
| 60 | + | } | |||
| 61 | + | } else { | |||
| 58 | errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err)) | 62 | errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err)) | ||
| 59 | } | 63 | } | ||
| 60 | continue | 64 | continue | ||
M packages/agent/update.go +14 -2
| @@ -27,6 +27,8 @@ const updateCheckFile = "update-check.json" | |||
| 27 | 27 | // the HTML redirect) because the JSON response is stable and small. | |
| 28 | 28 | const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | |
| 29 | 29 | ||
| 30 | + | const devVersionNotice = "dev version detected; skipping update check" | |
| 31 | + | ||
| 30 | 32 | // UpdateInfo describes the result of an update check. Zero-value means | |
| 31 | 33 | // "no update available, no error, don't show anything". | |
| 32 | 34 | type UpdateInfo struct { | |
| @@ -36,6 +38,15 @@ type UpdateInfo struct { | |||
| 36 | 38 | URL string // release page url for the changelog link | |
| 37 | 39 | } | |
| 38 | 40 | ||
| 41 | + | func isDevVersion(version string) bool { | |
| 42 | + | version = strings.TrimPrefix(strings.TrimSpace(version), "v") | |
| 43 | + | if i := strings.IndexAny(version, " ("); i > 0 { | |
| 44 | + | version = version[:i] | |
| 45 | + | } | |
| 46 | + | return version == "" || version == "dev" || version == "0.0.0" || | |
| 47 | + | version == "0.0.0-dev" || strings.HasPrefix(version, "0.0.0-dev+") | |
| 48 | + | } | |
| 49 | + | ||
| 39 | 50 | // updateCache is the on-disk structure written to $ZOT_HOME. | |
| 40 | 51 | type updateCache struct { | |
| 41 | 52 | CheckedAt time.Time `json:"checked_at"` | |
| @@ -54,8 +65,9 @@ type updateCache struct { | |||
| 54 | 65 | // banner renderer skips the display when Available is false, so a | |
| 55 | 66 | // network failure silently no-ops; we never block startup on this. | |
| 56 | 67 | func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo { | |
| 57 | - | // Dev builds ("0.0.0") never have an update to offer. Skip. | |
| 58 | - | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | |
| 68 | + | // Development builds never have an update to offer. Skip without | |
| 69 | + | // reading the cache or touching the network. | |
| 70 | + | if isDevVersion(currentVersion) { | |
| 59 | 71 | return UpdateInfo{} | |
| 60 | 72 | } | |
| 61 | 73 | ||
| @@ -27,6 +27,8 @@ const updateCheckFile = "update-check.json" | |||||
| 27 | // the HTML redirect) because the JSON response is stable and small. | 27 | // the HTML redirect) because the JSON response is stable and small. | ||
| 28 | const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | 28 | const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | ||
| 29 | 29 | ||||
| 30 | + | const devVersionNotice = "dev version detected; skipping update check" | |||
| 31 | + | ||||
| 30 | // UpdateInfo describes the result of an update check. Zero-value means | 32 | // UpdateInfo describes the result of an update check. Zero-value means | ||
| 31 | // "no update available, no error, don't show anything". | 33 | // "no update available, no error, don't show anything". | ||
| 32 | type UpdateInfo struct { | 34 | type UpdateInfo struct { | ||
| @@ -36,6 +38,15 @@ type UpdateInfo struct { | |||||
| 36 | URL string // release page url for the changelog link | 38 | URL string // release page url for the changelog link | ||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
| 41 | + | func isDevVersion(version string) bool { | |||
| 42 | + | version = strings.TrimPrefix(strings.TrimSpace(version), "v") | |||
| 43 | + | if i := strings.IndexAny(version, " ("); i > 0 { | |||
| 44 | + | version = version[:i] | |||
| 45 | + | } | |||
| 46 | + | return version == "" || version == "dev" || version == "0.0.0" || | |||
| 47 | + | version == "0.0.0-dev" || strings.HasPrefix(version, "0.0.0-dev+") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 39 | // updateCache is the on-disk structure written to $ZOT_HOME. | 50 | // updateCache is the on-disk structure written to $ZOT_HOME. | ||
| 40 | type updateCache struct { | 51 | type updateCache struct { | ||
| 41 | CheckedAt time.Time `json:"checked_at"` | 52 | CheckedAt time.Time `json:"checked_at"` | ||
| @@ -54,8 +65,9 @@ type updateCache struct { | |||||
| 54 | // banner renderer skips the display when Available is false, so a | 65 | // banner renderer skips the display when Available is false, so a | ||
| 55 | // network failure silently no-ops; we never block startup on this. | 66 | // network failure silently no-ops; we never block startup on this. | ||
| 56 | func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo { | 67 | func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo { | ||
| 57 | - | // Dev builds ("0.0.0") never have an update to offer. Skip. | 68 | + | // Development builds never have an update to offer. Skip without |
| 58 | - | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | 69 | + | // reading the cache or touching the network. |
| 70 | + | if isDevVersion(currentVersion) { | |||
| 59 | return UpdateInfo{} | 71 | return UpdateInfo{} | ||
| 60 | } | 72 | } | ||
| 61 | 73 | ||||
A packages/agent/update_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "net/http" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestIsDevVersion(t *testing.T) { | |
| 13 | + | for _, version := range []string{"", "dev", "0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)", "0.0.0-dev+local"} { | |
| 14 | + | if !isDevVersion(version) { | |
| 15 | + | t.Errorf("isDevVersion(%q) = false, want true", version) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | for _, version := range []string{"0.0.1", "1.0.0", "0.0.0-rc1"} { | |
| 19 | + | if isDevVersion(version) { | |
| 20 | + | t.Errorf("isDevVersion(%q) = true, want false", version) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type countingRoundTripper struct { | |
| 26 | + | calls int | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (r *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { | |
| 30 | + | r.calls++ | |
| 31 | + | return nil, errors.New("unexpected network request") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |
| 35 | + | home := t.TempDir() | |
| 36 | + | transport := &countingRoundTripper{} | |
| 37 | + | previousTransport := http.DefaultTransport | |
| 38 | + | http.DefaultTransport = transport | |
| 39 | + | t.Cleanup(func() { http.DefaultTransport = previousTransport }) | |
| 40 | + | ||
| 41 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 42 | + | cancel() | |
| 43 | + | ||
| 44 | + | info := CheckForUpdate(ctx, home, "0.0.0-dev") | |
| 45 | + | if info != (UpdateInfo{}) { | |
| 46 | + | t.Fatalf("development check = %+v, want an empty result", info) | |
| 47 | + | } | |
| 48 | + | if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) { | |
| 49 | + | t.Fatalf("development check wrote a cache file: %v", err) | |
| 50 | + | } | |
| 51 | + | if transport.calls != 0 { | |
| 52 | + | t.Fatalf("development check made %d network requests, want 0", transport.calls) | |
| 53 | + | } | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "net/http" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestIsDevVersion(t *testing.T) { | |||
| 13 | + | for _, version := range []string{"", "dev", "0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)", "0.0.0-dev+local"} { | |||
| 14 | + | if !isDevVersion(version) { | |||
| 15 | + | t.Errorf("isDevVersion(%q) = false, want true", version) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | for _, version := range []string{"0.0.1", "1.0.0", "0.0.0-rc1"} { | |||
| 19 | + | if isDevVersion(version) { | |||
| 20 | + | t.Errorf("isDevVersion(%q) = true, want false", version) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | type countingRoundTripper struct { | |||
| 26 | + | calls int | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (r *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { | |||
| 30 | + | r.calls++ | |||
| 31 | + | return nil, errors.New("unexpected network request") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |||
| 35 | + | home := t.TempDir() | |||
| 36 | + | transport := &countingRoundTripper{} | |||
| 37 | + | previousTransport := http.DefaultTransport | |||
| 38 | + | http.DefaultTransport = transport | |||
| 39 | + | t.Cleanup(func() { http.DefaultTransport = previousTransport }) | |||
| 40 | + | ||||
| 41 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 42 | + | cancel() | |||
| 43 | + | ||||
| 44 | + | info := CheckForUpdate(ctx, home, "0.0.0-dev") | |||
| 45 | + | if info != (UpdateInfo{}) { | |||
| 46 | + | t.Fatalf("development check = %+v, want an empty result", info) | |||
| 47 | + | } | |||
| 48 | + | if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) { | |||
| 49 | + | t.Fatalf("development check wrote a cache file: %v", err) | |||
| 50 | + | } | |||
| 51 | + | if transport.calls != 0 { | |||
| 52 | + | t.Fatalf("development check made %d network requests, want 0", transport.calls) | |||
| 53 | + | } | |||
| 54 | + | } | |||
M packages/agent/updatecmd.go +9 -10
| @@ -34,9 +34,9 @@ import ( | |||
| 34 | 34 | // 5. Extracts the zot binary from the archive. | |
| 35 | 35 | // 6. Atomically replaces the running binary with the new one. | |
| 36 | 36 | // | |
| 37 | - | // Refuses to operate on dev builds (version == "0.0.0") because there | |
| 38 | - | // is no meaningful "is newer" comparison and we'd happily downgrade a | |
| 39 | - | // freshly-compiled local binary back to whatever's on GitHub. | |
| 37 | + | // Refuses to operate on development builds because there is no meaningful | |
| 38 | + | // "is newer" comparison and we'd happily downgrade a freshly-compiled local | |
| 39 | + | // binary back to whatever's on GitHub. | |
| 40 | 40 | func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) { | |
| 41 | 41 | if len(rawArgs) == 0 || rawArgs[0] != "update" { | |
| 42 | 42 | return false, nil | |
| @@ -68,9 +68,8 @@ usage: | |||
| 68 | 68 | notes: | |
| 69 | 69 | * The binary must be writable by the current user. On a system-wide | |
| 70 | 70 | install (e.g. /usr/local/bin/zot owned by root) re-run with sudo. | |
| 71 | - | * Dev builds (version 0.0.0) are refused. They typically come from | |
| 72 | - | a local source build and shouldn't be silently replaced with a | |
| 73 | - | release binary. | |
| 71 | + | * Development builds are refused. They typically come from a local | |
| 72 | + | source build and shouldn't be silently replaced with a release binary. | |
| 74 | 73 | * Honours $GITHUB_TOKEN if set, so private-repo releases work. | |
| 75 | 74 | * After the binary is installed, every extension under | |
| 76 | 75 | $ZOT_HOME/extensions/ that is a git checkout is fast-forward | |
| @@ -87,8 +86,8 @@ notes: | |||
| 87 | 86 | // download. Useful as a sanity probe and as something the user can | |
| 88 | 87 | // pipe into scripts. | |
| 89 | 88 | func runUpdateCheck(version string) error { | |
| 90 | - | if version == "" || version == "dev" || version == "0.0.0" { | |
| 91 | - | fmt.Println("zot: dev build (version 0.0.0) — `zot update` is disabled") | |
| 89 | + | if isDevVersion(version) { | |
| 90 | + | fmt.Println("zot:", devVersionNotice) | |
| 92 | 91 | return nil | |
| 93 | 92 | } | |
| 94 | 93 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| @@ -109,8 +108,8 @@ func runUpdateCheck(version string) error { | |||
| 109 | 108 | ||
| 110 | 109 | // runUpdate is the meat of `zot update`. | |
| 111 | 110 | func runUpdate(version string) error { | |
| 112 | - | if version == "" || version == "dev" || version == "0.0.0" { | |
| 113 | - | return errors.New("dev build (version 0.0.0): `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases") | |
| 111 | + | if isDevVersion(version) { | |
| 112 | + | return errors.New(devVersionNotice + "; `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases") | |
| 114 | 113 | } | |
| 115 | 114 | current := versionOnly(version) | |
| 116 | 115 | ||
| @@ -34,9 +34,9 @@ import ( | |||||
| 34 | // 5. Extracts the zot binary from the archive. | 34 | // 5. Extracts the zot binary from the archive. | ||
| 35 | // 6. Atomically replaces the running binary with the new one. | 35 | // 6. Atomically replaces the running binary with the new one. | ||
| 36 | // | 36 | // | ||
| 37 | - | // Refuses to operate on dev builds (version == "0.0.0") because there | 37 | + | // Refuses to operate on development builds because there is no meaningful |
| 38 | - | // is no meaningful "is newer" comparison and we'd happily downgrade a | 38 | + | // "is newer" comparison and we'd happily downgrade a freshly-compiled local |
| 39 | - | // freshly-compiled local binary back to whatever's on GitHub. | 39 | + | // binary back to whatever's on GitHub. |
| 40 | func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) { | 40 | func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) { | ||
| 41 | if len(rawArgs) == 0 || rawArgs[0] != "update" { | 41 | if len(rawArgs) == 0 || rawArgs[0] != "update" { | ||
| 42 | return false, nil | 42 | return false, nil | ||
| @@ -68,9 +68,8 @@ usage: | |||||
| 68 | notes: | 68 | notes: | ||
| 69 | * The binary must be writable by the current user. On a system-wide | 69 | * The binary must be writable by the current user. On a system-wide | ||
| 70 | install (e.g. /usr/local/bin/zot owned by root) re-run with sudo. | 70 | install (e.g. /usr/local/bin/zot owned by root) re-run with sudo. | ||
| 71 | - | * Dev builds (version 0.0.0) are refused. They typically come from | 71 | + | * Development builds are refused. They typically come from a local |
| 72 | - | a local source build and shouldn't be silently replaced with a | 72 | + | source build and shouldn't be silently replaced with a release binary. |
| 73 | - | release binary. | |||
| 74 | * Honours $GITHUB_TOKEN if set, so private-repo releases work. | 73 | * Honours $GITHUB_TOKEN if set, so private-repo releases work. | ||
| 75 | * After the binary is installed, every extension under | 74 | * After the binary is installed, every extension under | ||
| 76 | $ZOT_HOME/extensions/ that is a git checkout is fast-forward | 75 | $ZOT_HOME/extensions/ that is a git checkout is fast-forward | ||
| @@ -87,8 +86,8 @@ notes: | |||||
| 87 | // download. Useful as a sanity probe and as something the user can | 86 | // download. Useful as a sanity probe and as something the user can | ||
| 88 | // pipe into scripts. | 87 | // pipe into scripts. | ||
| 89 | func runUpdateCheck(version string) error { | 88 | func runUpdateCheck(version string) error { | ||
| 90 | - | if version == "" || version == "dev" || version == "0.0.0" { | 89 | + | if isDevVersion(version) { |
| 91 | - | fmt.Println("zot: dev build (version 0.0.0) — `zot update` is disabled") | 90 | + | fmt.Println("zot:", devVersionNotice) |
| 92 | return nil | 91 | return nil | ||
| 93 | } | 92 | } | ||
| 94 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | 93 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| @@ -109,8 +108,8 @@ func runUpdateCheck(version string) error { | |||||
| 109 | 108 | ||||
| 110 | // runUpdate is the meat of `zot update`. | 109 | // runUpdate is the meat of `zot update`. | ||
| 111 | func runUpdate(version string) error { | 110 | func runUpdate(version string) error { | ||
| 112 | - | if version == "" || version == "dev" || version == "0.0.0" { | 111 | + | if isDevVersion(version) { |
| 113 | - | return errors.New("dev build (version 0.0.0): `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases") | 112 | + | return errors.New(devVersionNotice + "; `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases") |
| 114 | } | 113 | } | ||
| 115 | current := versionOnly(version) | 114 | current := versionOnly(version) | ||
| 116 | 115 | ||||
M packages/agent/zotfile.go +1 -1
| @@ -749,7 +749,7 @@ func writeCanonicalTar(root string, w io.Writer, exclude ...string) error { | |||
| 749 | 749 | ||
| 750 | 750 | func checkZotfileRequirements(zf zotfileLoaded, version string) error { | |
| 751 | 751 | if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" { | |
| 752 | - | if versionOnly(version) == "0.0.0" { | |
| 752 | + | if isDevVersion(version) { | |
| 753 | 753 | return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min) | |
| 754 | 754 | } | |
| 755 | 755 | if versionLess(version, min) { | |
| @@ -749,7 +749,7 @@ func writeCanonicalTar(root string, w io.Writer, exclude ...string) error { | |||||
| 749 | 749 | ||||
| 750 | func checkZotfileRequirements(zf zotfileLoaded, version string) error { | 750 | func checkZotfileRequirements(zf zotfileLoaded, version string) error { | ||
| 751 | if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" { | 751 | if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" { | ||
| 752 | - | if versionOnly(version) == "0.0.0" { | 752 | + | if isDevVersion(version) { |
| 753 | return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min) | 753 | return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min) | ||
| 754 | } | 754 | } | ||
| 755 | if versionLess(version, min) { | 755 | if versionLess(version, min) { | ||
M packages/core/session_snapshot_test.go +4 -1
| @@ -508,10 +508,13 @@ func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T) | |||
| 508 | 508 | if err != nil { | |
| 509 | 509 | t.Fatal(err) | |
| 510 | 510 | } | |
| 511 | - | _, branchMessages, err := OpenSession(branchPath) | |
| 511 | + | branchSession, branchMessages, err := OpenSession(branchPath) | |
| 512 | 512 | if err != nil { | |
| 513 | 513 | t.Fatal(err) | |
| 514 | 514 | } | |
| 515 | + | if err := branchSession.Close(); err != nil { | |
| 516 | + | t.Fatal(err) | |
| 517 | + | } | |
| 515 | 518 | if len(branchMessages) != 3 { | |
| 516 | 519 | t.Fatalf("branch messages = %d, want 3", len(branchMessages)) | |
| 517 | 520 | } | |
| @@ -508,10 +508,13 @@ func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T) | |||||
| 508 | if err != nil { | 508 | if err != nil { | ||
| 509 | t.Fatal(err) | 509 | t.Fatal(err) | ||
| 510 | } | 510 | } | ||
| 511 | - | _, branchMessages, err := OpenSession(branchPath) | 511 | + | branchSession, branchMessages, err := OpenSession(branchPath) |
| 512 | if err != nil { | 512 | if err != nil { | ||
| 513 | t.Fatal(err) | 513 | t.Fatal(err) | ||
| 514 | } | 514 | } | ||
| 515 | + | if err := branchSession.Close(); err != nil { | |||
| 516 | + | t.Fatal(err) | |||
| 517 | + | } | |||
| 515 | if len(branchMessages) != 3 { | 518 | if len(branchMessages) != 3 { | ||
| 516 | t.Fatalf("branch messages = %d, want 3", len(branchMessages)) | 519 | t.Fatalf("branch messages = %d, want 3", len(branchMessages)) | ||
| 517 | } | 520 | } | ||