feat(ext): improve installation and dev builds
- commit
- 090c470fac0621bfd2540d674d3a25a25e82d321
- author
- brice · · 2026-08-05 03:22 UTC
- parent
- bce908a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +916 -206
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 +60 -18
| @@ -1,36 +1,78 @@ | |||
| 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 ./... | |
| 52 | + | fmt-check: | |
| 22 | 53 | @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1) | |
| 23 | 54 | ||
| 55 | + | lint: vet fmt-check | |
| 56 | + | ||
| 57 | + | check: test-fast lint | |
| 58 | + | ||
| 24 | 59 | fmt: | |
| 25 | 60 | gofmt -w . | |
| 26 | 61 | ||
| 62 | + | run: build | |
| 63 | + | "$(BIN_DIR)/$(BINARY)" $(ARGS) | |
| 64 | + | ||
| 27 | 65 | clean: | |
| 28 | - | rm -rf bin | |
| 66 | + | rm -rf "$(BIN_DIR)" | |
| 29 | 67 | ||
| 30 | 68 | 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 | |
| 69 | + | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ]; then \ | |
| 70 | + | echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \ | |
| 71 | + | exit 1; \ | |
| 72 | + | fi | |
| 73 | + | @mkdir -p "$(BIN_DIR)" | |
| 74 | + | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-amd64" ./cmd/zot | |
| 75 | + | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-arm64" ./cmd/zot | |
| 76 | + | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-amd64" ./cmd/zot | |
| 77 | + | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-arm64" ./cmd/zot | |
| 78 | + | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-windows-amd64.exe" ./cmd/zot | |
| @@ -1,36 +1,78 @@ | |||||
| 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 ./... | |||
| 22 | @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1) | 53 | @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1) | ||
| 23 | 54 | ||||
| 55 | + | lint: vet fmt-check | |||
| 56 | + | ||||
| 57 | + | check: test-fast lint | |||
| 58 | + | ||||
| 24 | fmt: | 59 | fmt: | ||
| 25 | gofmt -w . | 60 | gofmt -w . | ||
| 26 | 61 | ||||
| 62 | + | run: build | |||
| 63 | + | "$(BIN_DIR)/$(BINARY)" $(ARGS) | |||
| 64 | + | ||||
| 27 | clean: | 65 | clean: | ||
| 28 | - | rm -rf bin | 66 | + | rm -rf "$(BIN_DIR)" |
| 29 | 67 | ||||
| 30 | release: | 68 | release: | ||
| 31 | - | @mkdir -p bin | 69 | + | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ]; then \ |
| 32 | - | GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-amd64 ./cmd/zot | 70 | + | 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 | 71 | + | exit 1; \ |
| 34 | - | GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-amd64 ./cmd/zot | 72 | + | fi |
| 35 | - | GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-arm64 ./cmd/zot | 73 | + | @mkdir -p "$(BIN_DIR)" |
| 36 | - | GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-windows-amd64.exe ./cmd/zot | 74 | + | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-amd64" ./cmd/zot |
| 75 | + | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-arm64" ./cmd/zot | |||
| 76 | + | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-amd64" ./cmd/zot | |||
| 77 | + | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-arm64" ./cmd/zot | |||
| 78 | + | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-windows-amd64.exe" ./cmd/zot | |||
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 +27 -7
| @@ -15,10 +15,9 @@ import ( | |||
| 15 | 15 | // local builds. Defaults make `zot --version` print something sensible | |
| 16 | 16 | // when built without ldflags. | |
| 17 | 17 | 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" | |
| 18 | + | // 0.0.0-dev is the placeholder for local / untagged builds. | |
| 19 | + | // Release builds replace it with the Git tag through ldflags. | |
| 20 | + | version = "0.0.0-dev" | |
| 22 | 21 | commit = "" | |
| 23 | 22 | date = "" | |
| 24 | 23 | ) | |
| @@ -44,18 +43,39 @@ func main() { | |||
| 44 | 43 | ||
| 45 | 44 | // resolvedVersion falls back to the module version embedded by Go when zot is | |
| 46 | 45 | // installed with "go install ...@version". Release archives still use the | |
| 47 | - | // version injected by GoReleaser, and local source builds remain 0.0.0. | |
| 46 | + | // version injected by GoReleaser, and local source builds remain 0.0.0-dev. | |
| 48 | 47 | func resolvedVersion(linkedVersion string) string { | |
| 49 | 48 | info, _ := debug.ReadBuildInfo() | |
| 50 | 49 | return resolvedVersionFromBuildInfo(linkedVersion, info) | |
| 51 | 50 | } | |
| 52 | 51 | ||
| 53 | 52 | func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string { | |
| 54 | - | if linkedVersion != "" && linkedVersion != "0.0.0" { | |
| 53 | + | if linkedVersion != "" && linkedVersion != "0.0.0" && linkedVersion != "0.0.0-dev" { | |
| 55 | 54 | return linkedVersion | |
| 56 | 55 | } | |
| 57 | - | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" { | |
| 56 | + | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" || isPseudoVersion(info.Main.Version) { | |
| 58 | 57 | return linkedVersion | |
| 59 | 58 | } | |
| 60 | 59 | return strings.TrimPrefix(info.Main.Version, "v") | |
| 61 | 60 | } | |
| 61 | + | ||
| 62 | + | func isPseudoVersion(version string) bool { | |
| 63 | + | version = strings.TrimPrefix(version, "v") | |
| 64 | + | parts := strings.Split(version, "-") | |
| 65 | + | if len(parts) < 3 { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | timestamp := parts[len(parts)-2] | |
| 69 | + | if strings.HasPrefix(timestamp, "0.") { | |
| 70 | + | timestamp = timestamp[2:] | |
| 71 | + | } | |
| 72 | + | if len(timestamp) != 14 { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | for _, ch := range timestamp { | |
| 76 | + | if ch < '0' || ch > '9' { | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | return true | |
| 81 | + | } | |
| @@ -15,10 +15,9 @@ import ( | |||||
| 15 | // local builds. Defaults make `zot --version` print something sensible | 15 | // local builds. Defaults make `zot --version` print something sensible | ||
| 16 | // when built without ldflags. | 16 | // when built without ldflags. | ||
| 17 | var ( | 17 | var ( | ||
| 18 | - | // 0.0.0 is the pre-release placeholder for local / untagged | 18 | + | // 0.0.0-dev is the placeholder for local / untagged builds. |
| 19 | - | // builds. The first published GitHub release will be tagged | 19 | + | // Release builds replace it with the Git tag through ldflags. |
| 20 | - | // v0.0.1; everything before that ships as 0.0.0 from source. | 20 | + | version = "0.0.0-dev" |
| 21 | - | version = "0.0.0" | |||
| 22 | commit = "" | 21 | commit = "" | ||
| 23 | date = "" | 22 | date = "" | ||
| 24 | ) | 23 | ) | ||
| @@ -44,18 +43,39 @@ func main() { | |||||
| 44 | 43 | ||||
| 45 | // resolvedVersion falls back to the module version embedded by Go when zot is | 44 | // resolvedVersion falls back to the module version embedded by Go when zot is | ||
| 46 | // installed with "go install ...@version". Release archives still use the | 45 | // installed with "go install ...@version". Release archives still use the | ||
| 47 | - | // version injected by GoReleaser, and local source builds remain 0.0.0. | 46 | + | // version injected by GoReleaser, and local source builds remain 0.0.0-dev. |
| 48 | func resolvedVersion(linkedVersion string) string { | 47 | func resolvedVersion(linkedVersion string) string { | ||
| 49 | info, _ := debug.ReadBuildInfo() | 48 | info, _ := debug.ReadBuildInfo() | ||
| 50 | return resolvedVersionFromBuildInfo(linkedVersion, info) | 49 | return resolvedVersionFromBuildInfo(linkedVersion, info) | ||
| 51 | } | 50 | } | ||
| 52 | 51 | ||||
| 53 | func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string { | 52 | func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string { | ||
| 54 | - | if linkedVersion != "" && linkedVersion != "0.0.0" { | 53 | + | if linkedVersion != "" && linkedVersion != "0.0.0" && linkedVersion != "0.0.0-dev" { |
| 55 | return linkedVersion | 54 | return linkedVersion | ||
| 56 | } | 55 | } | ||
| 57 | - | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" { | 56 | + | if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" || isPseudoVersion(info.Main.Version) { |
| 58 | return linkedVersion | 57 | return linkedVersion | ||
| 59 | } | 58 | } | ||
| 60 | return strings.TrimPrefix(info.Main.Version, "v") | 59 | return strings.TrimPrefix(info.Main.Version, "v") | ||
| 61 | } | 60 | } | ||
| 61 | + | ||||
| 62 | + | func isPseudoVersion(version string) bool { | |||
| 63 | + | version = strings.TrimPrefix(version, "v") | |||
| 64 | + | parts := strings.Split(version, "-") | |||
| 65 | + | if len(parts) < 3 { | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | timestamp := parts[len(parts)-2] | |||
| 69 | + | if strings.HasPrefix(timestamp, "0.") { | |||
| 70 | + | timestamp = timestamp[2:] | |||
| 71 | + | } | |||
| 72 | + | if len(timestamp) != 14 { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | for _, ch := range timestamp { | |||
| 76 | + | if ch < '0' || ch > '9' { | |||
| 77 | + | return false | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | return true | |||
| 81 | + | } | |||
M cmd/zot/main_test.go +18 -0
| @@ -18,6 +18,18 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||
| 18 | 18 | moduleVersion: "v0.2.94", | |
| 19 | 19 | want: "0.2.94", | |
| 20 | 20 | }, | |
| 21 | + | { | |
| 22 | + | name: "go install release from dev placeholder", | |
| 23 | + | linkedVersion: "0.0.0-dev", | |
| 24 | + | moduleVersion: "v0.2.94", | |
| 25 | + | want: "0.2.94", | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "local pseudo-version keeps dev placeholder", | |
| 29 | + | linkedVersion: "0.0.0-dev", | |
| 30 | + | moduleVersion: "v0.2.94-0.20260804145622-bce908a66c9a", | |
| 31 | + | want: "0.0.0-dev", | |
| 32 | + | }, | |
| 21 | 33 | { | |
| 22 | 34 | name: "release linker version wins", | |
| 23 | 35 | linkedVersion: "0.2.95", | |
| @@ -30,6 +42,12 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||
| 30 | 42 | moduleVersion: "(devel)", | |
| 31 | 43 | want: "0.0.0", | |
| 32 | 44 | }, | |
| 45 | + | { | |
| 46 | + | name: "local dev build", | |
| 47 | + | linkedVersion: "0.0.0-dev", | |
| 48 | + | moduleVersion: "(devel)", | |
| 49 | + | want: "0.0.0-dev", | |
| 50 | + | }, | |
| 33 | 51 | { | |
| 34 | 52 | name: "missing build info", | |
| 35 | 53 | linkedVersion: "0.0.0", | |
| @@ -18,6 +18,18 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||||
| 18 | moduleVersion: "v0.2.94", | 18 | moduleVersion: "v0.2.94", | ||
| 19 | want: "0.2.94", | 19 | want: "0.2.94", | ||
| 20 | }, | 20 | }, | ||
| 21 | + | { | |||
| 22 | + | name: "go install release from dev placeholder", | |||
| 23 | + | linkedVersion: "0.0.0-dev", | |||
| 24 | + | moduleVersion: "v0.2.94", | |||
| 25 | + | want: "0.2.94", | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "local pseudo-version keeps dev placeholder", | |||
| 29 | + | linkedVersion: "0.0.0-dev", | |||
| 30 | + | moduleVersion: "v0.2.94-0.20260804145622-bce908a66c9a", | |||
| 31 | + | want: "0.0.0-dev", | |||
| 32 | + | }, | |||
| 21 | { | 33 | { | ||
| 22 | name: "release linker version wins", | 34 | name: "release linker version wins", | ||
| 23 | linkedVersion: "0.2.95", | 35 | linkedVersion: "0.2.95", | ||
| @@ -30,6 +42,12 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) { | |||||
| 30 | moduleVersion: "(devel)", | 42 | moduleVersion: "(devel)", | ||
| 31 | want: "0.0.0", | 43 | want: "0.0.0", | ||
| 32 | }, | 44 | }, | ||
| 45 | + | { | |||
| 46 | + | name: "local dev build", | |||
| 47 | + | linkedVersion: "0.0.0-dev", | |||
| 48 | + | moduleVersion: "(devel)", | |||
| 49 | + | want: "0.0.0-dev", | |||
| 50 | + | }, | |||
| 33 | { | 51 | { | ||
| 34 | name: "missing build info", | 52 | name: "missing build info", | ||
| 35 | linkedVersion: "0.0.0", | 53 | linkedVersion: "0.0.0", | ||
M docs/extensions.md +22 -10
| @@ -81,16 +81,25 @@ 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 | |
| 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 | |
| 89 | 86 | ||
| 90 | 87 | # or load straight from the repo for one zot session | |
| 91 | 88 | zot --ext path/to/zot/examples/extensions/hello | |
| 92 | 89 | ``` | |
| 93 | 90 | ||
| 91 | + | `zot ext install` never builds source code implicitly. For local installs it | |
| 92 | + | validates `extension.json` and any executable path relative to the extension | |
| 93 | + | directory, then stages the copy so a missing or ignored runtime artifact fails | |
| 94 | + | with an explicit error instead of becoming a broken installed extension. For a | |
| 95 | + | local Go extension, opt into the fixed builder explicitly with | |
| 96 | + | `zot ext install --build=go <path>`. The builder runs `go build -trimpath` from | |
| 97 | + | the source directory, writes the manifest-declared relative executable into the | |
| 98 | + | staging directory, and validates it before installation. It may resolve Go | |
| 99 | + | modules and access the network according to the user's Go environment. Bare | |
| 100 | + | launchers such as `go`, `node`, and `npx` remain resolved from `PATH` when zot | |
| 101 | + | starts; they cannot be used as the output of `--build=go`. | |
| 102 | + | ||
| 94 | 103 | Nothing is auto-installed and nothing reaches out to the network without your explicit action. | |
| 95 | 104 | ||
| 96 | 105 | ## Layout & discovery | |
| @@ -593,10 +602,11 @@ Reply with `shutdown_ack` and then exit. | |||
| 593 | 602 | ## Managing extensions from the CLI | |
| 594 | 603 | ||
| 595 | 604 | ``` | |
| 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 | |
| 605 | + | zot ext list list installed extensions and their state | |
| 606 | + | zot ext doctor diagnose load, registration, and conflict issues | |
| 607 | + | zot ext install [--build=go] <path|git-url> | |
| 608 | + | copy / clone into $ZOT_HOME/extensions/ | |
| 609 | + | zot ext remove <name> delete an extension directory | |
| 600 | 610 | zot ext enable <name> re-enable a disabled extension | |
| 601 | 611 | zot ext disable <name> disable without removing | |
| 602 | 612 | zot ext logs <name> [-f] cat / tail the extension's stderr | |
| @@ -610,7 +620,9 @@ warnings, and each extension's stderr log path. | |||
| 610 | 620 | ||
| 611 | 621 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | |
| 612 | 622 | shallow clone. Both validate that the destination contains an | |
| 613 | - | `extension.json` and roll back if not. | |
| 623 | + | `extension.json` and roll back if not. No build runs unless the local install | |
| 624 | + | is invoked with the explicit `--build=go` option. That option currently supports | |
| 625 | + | only local Go source paths; clone remote source locally before building it. | |
| 614 | 626 | ||
| 615 | 627 | ## Loading an extension for one run | |
| 616 | 628 | ||
| @@ -81,16 +81,25 @@ 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 | - | ||||
| 87 | - | # install (copies to $ZOT_HOME/extensions/hello/) | |||
| 88 | - | zot ext install path/to/zot/examples/extensions/hello | |||
| 89 | 86 | ||||
| 90 | # or load straight from the repo for one zot session | 87 | # or load straight from the repo for one zot session | ||
| 91 | zot --ext path/to/zot/examples/extensions/hello | 88 | zot --ext path/to/zot/examples/extensions/hello | ||
| 92 | ``` | 89 | ``` | ||
| 93 | 90 | ||||
| 91 | + | `zot ext install` never builds source code implicitly. For local installs it | |||
| 92 | + | validates `extension.json` and any executable path relative to the extension | |||
| 93 | + | directory, then stages the copy so a missing or ignored runtime artifact fails | |||
| 94 | + | with an explicit error instead of becoming a broken installed extension. For a | |||
| 95 | + | local Go extension, opt into the fixed builder explicitly with | |||
| 96 | + | `zot ext install --build=go <path>`. The builder runs `go build -trimpath` from | |||
| 97 | + | the source directory, writes the manifest-declared relative executable into the | |||
| 98 | + | staging directory, and validates it before installation. It may resolve Go | |||
| 99 | + | modules and access the network according to the user's Go environment. Bare | |||
| 100 | + | launchers such as `go`, `node`, and `npx` remain resolved from `PATH` when zot | |||
| 101 | + | starts; they cannot be used as the output of `--build=go`. | |||
| 102 | + | ||||
| 94 | Nothing is auto-installed and nothing reaches out to the network without your explicit action. | 103 | Nothing is auto-installed and nothing reaches out to the network without your explicit action. | ||
| 95 | 104 | ||||
| 96 | ## Layout & discovery | 105 | ## Layout & discovery | ||
| @@ -593,10 +602,11 @@ Reply with `shutdown_ack` and then exit. | |||||
| 593 | ## Managing extensions from the CLI | 602 | ## Managing extensions from the CLI | ||
| 594 | 603 | ||||
| 595 | ``` | 604 | ``` | ||
| 596 | - | zot ext list list installed extensions and their state | 605 | + | zot ext list list installed extensions and their state |
| 597 | - | zot ext doctor diagnose load, registration, and conflict issues | 606 | + | zot ext doctor diagnose load, registration, and conflict issues |
| 598 | - | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | 607 | + | zot ext install [--build=go] <path|git-url> |
| 599 | - | zot ext remove <name> delete an extension directory | 608 | + | copy / clone into $ZOT_HOME/extensions/ |
| 609 | + | zot ext remove <name> delete an extension directory | |||
| 600 | zot ext enable <name> re-enable a disabled extension | 610 | zot ext enable <name> re-enable a disabled extension | ||
| 601 | zot ext disable <name> disable without removing | 611 | zot ext disable <name> disable without removing | ||
| 602 | zot ext logs <name> [-f] cat / tail the extension's stderr | 612 | zot ext logs <name> [-f] cat / tail the extension's stderr | ||
| @@ -610,7 +620,9 @@ warnings, and each extension's stderr log path. | |||||
| 610 | 620 | ||||
| 611 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | 621 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | ||
| 612 | shallow clone. Both validate that the destination contains an | 622 | shallow clone. Both validate that the destination contains an | ||
| 613 | - | `extension.json` and roll back if not. | 623 | + | `extension.json` and roll back if not. No build runs unless the local install |
| 624 | + | is invoked with the explicit `--build=go` option. That option currently supports | |||
| 625 | + | only local Go source paths; clone remote source locally before building it. | |||
| 614 | 626 | ||||
| 615 | ## Loading an extension for one run | 627 | ## Loading an extension for one run | ||
| 616 | 628 | ||||
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 packages/agent/args.go +1 -1
| @@ -423,7 +423,7 @@ 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 [--build=go] <path|url>", "install; explicitly build local Go source"}, | |
| 427 | 427 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | |
| 428 | 428 | row{"zot ext help", "show all extension subcommands"}, | |
| 429 | 429 | ) | |
| @@ -423,7 +423,7 @@ 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 [--build=go] <path|url>", "install; explicitly build local Go source"}, |
| 427 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | 427 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | ||
| 428 | row{"zot ext help", "show all extension subcommands"}, | 428 | row{"zot ext help", "show all extension subcommands"}, | ||
| 429 | ) | 429 | ) | ||
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 +51 -48
| @@ -1834,55 +1834,58 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1834 | 1834 | return nil | |
| 1835 | 1835 | } | |
| 1836 | 1836 | ||
| 1837 | + | devBuild := isDevVersion(version) | |
| 1838 | + | if devBuild { | |
| 1839 | + | fmt.Fprintln(os.Stderr, "zot:", devVersionNotice) | |
| 1840 | + | } | |
| 1837 | 1841 | term := tui.NewProcTerm() | |
| 1838 | 1842 | ||
| 1839 | - | // Kick off the async update check so the banner can appear when the | |
| 1840 | - | // http response eventually arrives (usually <1s on cached DNS). Map | |
| 1841 | - | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | |
| 1842 | - | updateCh := make(chan modes.UpdateInfo, 1) | |
| 1843 | - | go func() { | |
| 1844 | - | defer close(updateCh) | |
| 1845 | - | src := <-CheckForUpdateAsync(ZotHome(), version) | |
| 1846 | - | updateCh <- modes.UpdateInfo{ | |
| 1847 | - | Current: src.Current, | |
| 1848 | - | Latest: src.Latest, | |
| 1849 | - | Available: src.Available, | |
| 1850 | - | URL: src.URL, | |
| 1851 | - | } | |
| 1852 | - | }() | |
| 1843 | + | var updateCh chan modes.UpdateInfo | |
| 1844 | + | var changelogCh chan modes.ChangelogPayload | |
| 1845 | + | if !devBuild { | |
| 1846 | + | // Kick off the async update check so the banner can appear when the | |
| 1847 | + | // http response eventually arrives (usually <1s on cached DNS). Map | |
| 1848 | + | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | |
| 1849 | + | updateCh = make(chan modes.UpdateInfo, 1) | |
| 1850 | + | go func() { | |
| 1851 | + | defer close(updateCh) | |
| 1852 | + | src := <-CheckForUpdateAsync(ZotHome(), version) | |
| 1853 | + | updateCh <- modes.UpdateInfo{ | |
| 1854 | + | Current: src.Current, | |
| 1855 | + | Latest: src.Latest, | |
| 1856 | + | Available: src.Available, | |
| 1857 | + | URL: src.URL, | |
| 1858 | + | } | |
| 1859 | + | }() | |
| 1853 | 1860 | ||
| 1854 | - | // Changelog: when the running version differs from the last | |
| 1855 | - | // version whose release notes the user dismissed, fetch the | |
| 1856 | - | // release body from GitHub and have the TUI show it once. On | |
| 1857 | - | // first-ever launch (no prior LastChangelogShown), seed the | |
| 1858 | - | // stored version silently — don't dump release notes at someone | |
| 1859 | - | // who just installed. | |
| 1860 | - | changelogCh := make(chan modes.ChangelogPayload, 1) | |
| 1861 | - | go func() { | |
| 1862 | - | defer close(changelogCh) | |
| 1863 | - | cfg, _ := LoadConfig() | |
| 1864 | - | if cfg.LastChangelogShown == "" { | |
| 1865 | - | SeedChangelogVersion(version) | |
| 1866 | - | return | |
| 1867 | - | } | |
| 1868 | - | if !ShouldShowChangelog(version, cfg) { | |
| 1869 | - | return | |
| 1870 | - | } | |
| 1871 | - | info := <-FetchChangelogAsync(version) | |
| 1872 | - | if info.Body == "" { | |
| 1873 | - | return | |
| 1874 | - | } | |
| 1875 | - | // For dev builds (0.0.0), skip if the latest release was | |
| 1876 | - | // already shown (stored by the dismiss callback). | |
| 1877 | - | if version == "0.0.0" && info.Version == cfg.LastChangelogShown { | |
| 1878 | - | return | |
| 1879 | - | } | |
| 1880 | - | changelogCh <- modes.ChangelogPayload{ | |
| 1881 | - | Version: info.Version, | |
| 1882 | - | Body: info.Body, | |
| 1883 | - | URL: info.URL, | |
| 1884 | - | } | |
| 1885 | - | }() | |
| 1861 | + | // Changelog: when the running version differs from the last | |
| 1862 | + | // version whose release notes the user dismissed, fetch the | |
| 1863 | + | // release body from GitHub and have the TUI show it once. On | |
| 1864 | + | // first-ever launch (no prior LastChangelogShown), seed the | |
| 1865 | + | // stored version silently — don't dump release notes at someone | |
| 1866 | + | // who just installed. | |
| 1867 | + | changelogCh = make(chan modes.ChangelogPayload, 1) | |
| 1868 | + | go func() { | |
| 1869 | + | defer close(changelogCh) | |
| 1870 | + | cfg, _ := LoadConfig() | |
| 1871 | + | if cfg.LastChangelogShown == "" { | |
| 1872 | + | SeedChangelogVersion(version) | |
| 1873 | + | return | |
| 1874 | + | } | |
| 1875 | + | if !ShouldShowChangelog(version, cfg) { | |
| 1876 | + | return | |
| 1877 | + | } | |
| 1878 | + | info := <-FetchChangelogAsync(version) | |
| 1879 | + | if info.Body == "" { | |
| 1880 | + | return | |
| 1881 | + | } | |
| 1882 | + | changelogCh <- modes.ChangelogPayload{ | |
| 1883 | + | Version: info.Version, | |
| 1884 | + | Body: info.Body, | |
| 1885 | + | URL: info.URL, | |
| 1886 | + | } | |
| 1887 | + | }() | |
| 1888 | + | } | |
| 1886 | 1889 | ||
| 1887 | 1890 | initialCfg, _ := LoadConfig() | |
| 1888 | 1891 | fastMode := r.FastMode | |
| @@ -2063,11 +2066,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2063 | 2066 | ResolveSubagent: resolveSubagent, | |
| 2064 | 2067 | ChangelogChan: changelogCh, | |
| 2065 | 2068 | OnChangelogDismiss: func() { | |
| 2066 | - | // For dev builds (0.0.0) store the actual release version | |
| 2069 | + | // For development builds, store the actual release version | |
| 2067 | 2070 | // so the same changelog doesn't show again next launch. | |
| 2068 | 2071 | // For real builds, store the binary version. | |
| 2069 | 2072 | v := version | |
| 2070 | - | if v == "0.0.0" { | |
| 2073 | + | if devBuild { | |
| 2071 | 2074 | if iv != nil && iv.ChangelogVersion() != "" { | |
| 2072 | 2075 | v = iv.ChangelogVersion() | |
| 2073 | 2076 | } | |
| @@ -1834,55 +1834,58 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1834 | return nil | 1834 | return nil | ||
| 1835 | } | 1835 | } | ||
| 1836 | 1836 | ||||
| 1837 | + | devBuild := isDevVersion(version) | |||
| 1838 | + | if devBuild { | |||
| 1839 | + | fmt.Fprintln(os.Stderr, "zot:", devVersionNotice) | |||
| 1840 | + | } | |||
| 1837 | term := tui.NewProcTerm() | 1841 | term := tui.NewProcTerm() | ||
| 1838 | 1842 | ||||
| 1839 | - | // Kick off the async update check so the banner can appear when the | 1843 | + | var updateCh chan modes.UpdateInfo |
| 1840 | - | // http response eventually arrives (usually <1s on cached DNS). Map | 1844 | + | var changelogCh chan modes.ChangelogPayload |
| 1841 | - | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | 1845 | + | if !devBuild { |
| 1842 | - | updateCh := make(chan modes.UpdateInfo, 1) | 1846 | + | // Kick off the async update check so the banner can appear when the |
| 1843 | - | go func() { | 1847 | + | // http response eventually arrives (usually <1s on cached DNS). Map |
| 1844 | - | defer close(updateCh) | 1848 | + | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. |
| 1845 | - | src := <-CheckForUpdateAsync(ZotHome(), version) | 1849 | + | updateCh = make(chan modes.UpdateInfo, 1) |
| 1846 | - | updateCh <- modes.UpdateInfo{ | 1850 | + | go func() { |
| 1847 | - | Current: src.Current, | 1851 | + | defer close(updateCh) |
| 1848 | - | Latest: src.Latest, | 1852 | + | src := <-CheckForUpdateAsync(ZotHome(), version) |
| 1849 | - | Available: src.Available, | 1853 | + | updateCh <- modes.UpdateInfo{ |
| 1850 | - | URL: src.URL, | 1854 | + | Current: src.Current, |
| 1851 | - | } | 1855 | + | Latest: src.Latest, |
| 1852 | - | }() | 1856 | + | Available: src.Available, |
| 1857 | + | URL: src.URL, | |||
| 1858 | + | } | |||
| 1859 | + | }() | |||
| 1853 | 1860 | ||||
| 1854 | - | // Changelog: when the running version differs from the last | 1861 | + | // Changelog: when the running version differs from the last |
| 1855 | - | // version whose release notes the user dismissed, fetch the | 1862 | + | // version whose release notes the user dismissed, fetch the |
| 1856 | - | // release body from GitHub and have the TUI show it once. On | 1863 | + | // release body from GitHub and have the TUI show it once. On |
| 1857 | - | // first-ever launch (no prior LastChangelogShown), seed the | 1864 | + | // first-ever launch (no prior LastChangelogShown), seed the |
| 1858 | - | // stored version silently — don't dump release notes at someone | 1865 | + | // stored version silently — don't dump release notes at someone |
| 1859 | - | // who just installed. | 1866 | + | // who just installed. |
| 1860 | - | changelogCh := make(chan modes.ChangelogPayload, 1) | 1867 | + | changelogCh = make(chan modes.ChangelogPayload, 1) |
| 1861 | - | go func() { | 1868 | + | go func() { |
| 1862 | - | defer close(changelogCh) | 1869 | + | defer close(changelogCh) |
| 1863 | - | cfg, _ := LoadConfig() | 1870 | + | cfg, _ := LoadConfig() |
| 1864 | - | if cfg.LastChangelogShown == "" { | 1871 | + | if cfg.LastChangelogShown == "" { |
| 1865 | - | SeedChangelogVersion(version) | 1872 | + | SeedChangelogVersion(version) |
| 1866 | - | return | 1873 | + | return |
| 1867 | - | } | 1874 | + | } |
| 1868 | - | if !ShouldShowChangelog(version, cfg) { | 1875 | + | if !ShouldShowChangelog(version, cfg) { |
| 1869 | - | return | 1876 | + | return |
| 1870 | - | } | 1877 | + | } |
| 1871 | - | info := <-FetchChangelogAsync(version) | 1878 | + | info := <-FetchChangelogAsync(version) |
| 1872 | - | if info.Body == "" { | 1879 | + | if info.Body == "" { |
| 1873 | - | return | 1880 | + | return |
| 1874 | - | } | 1881 | + | } |
| 1875 | - | // For dev builds (0.0.0), skip if the latest release was | 1882 | + | changelogCh <- modes.ChangelogPayload{ |
| 1876 | - | // already shown (stored by the dismiss callback). | 1883 | + | Version: info.Version, |
| 1877 | - | if version == "0.0.0" && info.Version == cfg.LastChangelogShown { | 1884 | + | Body: info.Body, |
| 1878 | - | return | 1885 | + | URL: info.URL, |
| 1879 | - | } | 1886 | + | } |
| 1880 | - | changelogCh <- modes.ChangelogPayload{ | 1887 | + | }() |
| 1881 | - | Version: info.Version, | 1888 | + | } |
| 1882 | - | Body: info.Body, | |||
| 1883 | - | URL: info.URL, | |||
| 1884 | - | } | |||
| 1885 | - | }() | |||
| 1886 | 1889 | ||||
| 1887 | initialCfg, _ := LoadConfig() | 1890 | initialCfg, _ := LoadConfig() | ||
| 1888 | fastMode := r.FastMode | 1891 | fastMode := r.FastMode | ||
| @@ -2063,11 +2066,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 2063 | ResolveSubagent: resolveSubagent, | 2066 | ResolveSubagent: resolveSubagent, | ||
| 2064 | ChangelogChan: changelogCh, | 2067 | ChangelogChan: changelogCh, | ||
| 2065 | OnChangelogDismiss: func() { | 2068 | OnChangelogDismiss: func() { | ||
| 2066 | - | // For dev builds (0.0.0) store the actual release version | 2069 | + | // For development builds, store the actual release version |
| 2067 | // so the same changelog doesn't show again next launch. | 2070 | // so the same changelog doesn't show again next launch. | ||
| 2068 | // For real builds, store the binary version. | 2071 | // For real builds, store the binary version. | ||
| 2069 | v := version | 2072 | v := version | ||
| 2070 | - | if v == "0.0.0" { | 2073 | + | if devBuild { |
| 2071 | if iv != nil && iv.ChangelogVersion() != "" { | 2074 | if iv != nil && iv.ChangelogVersion() != "" { | ||
| 2072 | v = iv.ChangelogVersion() | 2075 | v = iv.ChangelogVersion() | ||
| 2073 | } | 2076 | } | ||
M packages/agent/extcmd.go +276 -38
| @@ -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" | |
| @@ -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) | |
| @@ -464,37 +468,26 @@ 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 | + | opts, err := parseExtInstallArgs(args) | |
| 477 | + | if err != nil { | |
| 478 | + | return err | |
| 472 | 479 | } | |
| 473 | - | src := args[0] | |
| 480 | + | src := opts.source | |
| 474 | 481 | dest := filepath.Join(ZotHome(), "extensions") | |
| 475 | 482 | if err := os.MkdirAll(dest, 0o755); err != nil { | |
| 476 | 483 | return err | |
| 477 | 484 | } | |
| 478 | 485 | ||
| 479 | 486 | 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) | |
| 487 | + | if opts.builder != "" { | |
| 488 | + | return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there") | |
| 491 | 489 | } | |
| 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 | - | } | |
| 496 | - | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 497 | - | return nil | |
| 490 | + | return installGitExtension(src, dest) | |
| 498 | 491 | } | |
| 499 | 492 | ||
| 500 | 493 | // Local path: must be a directory containing extension.json. | |
| @@ -505,33 +498,257 @@ func extInstall(args []string) error { | |||
| 505 | 498 | if !info.IsDir() { | |
| 506 | 499 | return fmt.Errorf("not a directory: %s", src) | |
| 507 | 500 | } | |
| 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. | |
| 501 | + | // Resolve to an absolute, cleaned path before reading the manifest. | |
| 502 | + | // This also makes sources like "." safe to install. | |
| 516 | 503 | absSrc, err := filepath.Abs(src) | |
| 517 | 504 | if err != nil { | |
| 518 | 505 | return err | |
| 519 | 506 | } | |
| 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) | |
| 507 | + | manifest, err := readExtensionManifest(absSrc) | |
| 508 | + | if err != nil { | |
| 509 | + | return fmt.Errorf("source extension: %w", err) | |
| 523 | 510 | } | |
| 524 | - | out := filepath.Join(dest, name) | |
| 511 | + | out := filepath.Join(dest, manifest.Name) | |
| 525 | 512 | if _, err := os.Stat(out); err == nil { | |
| 526 | 513 | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 527 | 514 | } | |
| 528 | - | if err := copyDir(absSrc, out); err != nil { | |
| 515 | + | ||
| 516 | + | stageRoot, err := os.MkdirTemp(dest, ".zot-extension-install-") | |
| 517 | + | if err != nil { | |
| 518 | + | return fmt.Errorf("create install staging directory: %w", err) | |
| 519 | + | } | |
| 520 | + | defer os.RemoveAll(stageRoot) | |
| 521 | + | staged := filepath.Join(stageRoot, "extension") | |
| 522 | + | if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil { | |
| 523 | + | return fmt.Errorf("copy extension: %w", err) | |
| 524 | + | } | |
| 525 | + | if opts.builder != "" { | |
| 526 | + | if err := buildLocalExtension(opts.builder, absSrc, staged, manifest); err != nil { | |
| 527 | + | return err | |
| 528 | + | } | |
| 529 | + | } | |
| 530 | + | if err := validateExtensionExecutable(staged, manifest); err != nil { | |
| 529 | 531 | return err | |
| 530 | 532 | } | |
| 533 | + | if err := os.Rename(staged, out); err != nil { | |
| 534 | + | return fmt.Errorf("finalize install: %w", err) | |
| 535 | + | } | |
| 531 | 536 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 532 | 537 | return nil | |
| 533 | 538 | } | |
| 534 | 539 | ||
| 540 | + | type extInstallOptions struct { | |
| 541 | + | source string | |
| 542 | + | builder string | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | func parseExtInstallArgs(args []string) (extInstallOptions, error) { | |
| 546 | + | if len(args) == 0 { | |
| 547 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | var opts extInstallOptions | |
| 551 | + | builderSet := false | |
| 552 | + | for i := 0; i < len(args); i++ { | |
| 553 | + | arg := args[i] | |
| 554 | + | switch { | |
| 555 | + | case arg == "--build": | |
| 556 | + | if i+1 >= len(args) { | |
| 557 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |
| 558 | + | } | |
| 559 | + | i++ | |
| 560 | + | if builderSet { | |
| 561 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |
| 562 | + | } | |
| 563 | + | builderSet = true | |
| 564 | + | opts.builder = strings.ToLower(strings.TrimSpace(args[i])) | |
| 565 | + | if opts.builder == "" { | |
| 566 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |
| 567 | + | } | |
| 568 | + | case strings.HasPrefix(arg, "--build="): | |
| 569 | + | if builderSet { | |
| 570 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |
| 571 | + | } | |
| 572 | + | builderSet = true | |
| 573 | + | opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build="))) | |
| 574 | + | if opts.builder == "" { | |
| 575 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |
| 576 | + | } | |
| 577 | + | case strings.HasPrefix(arg, "-"): | |
| 578 | + | return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg) | |
| 579 | + | case opts.source != "": | |
| 580 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |
| 581 | + | default: | |
| 582 | + | opts.source = arg | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | if opts.source == "" { | |
| 586 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |
| 587 | + | } | |
| 588 | + | if opts.builder != "" && opts.builder != "go" { | |
| 589 | + | return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder) | |
| 590 | + | } | |
| 591 | + | return opts, nil | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | func buildLocalExtension(builder, sourceDir, stagedDir string, manifest extensions.Manifest) error { | |
| 595 | + | if manifest.Exec == "" { | |
| 596 | + | return errors.New("cannot build a theme-only extension; it does not declare an executable") | |
| 597 | + | } | |
| 598 | + | if builder != "go" { | |
| 599 | + | return fmt.Errorf("unsupported extension builder %q (currently supported: go)", builder) | |
| 600 | + | } | |
| 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.Command("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(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.Command("git", "clone", "--depth", "1", src, cloneDir) | |
| 650 | + | cmd.Stdout = os.Stderr | |
| 651 | + | cmd.Stderr = os.Stderr | |
| 652 | + | if err := cmd.Run(); err != nil { | |
| 653 | + | return fmt.Errorf("git clone: %w", err) | |
| 654 | + | } | |
| 655 | + | manifest, err := readExtensionManifest(cloneDir) | |
| 656 | + | if err != nil { | |
| 657 | + | return fmt.Errorf("cloned extension: %w", err) | |
| 658 | + | } | |
| 659 | + | out := filepath.Join(dest, manifest.Name) | |
| 660 | + | if _, err := os.Stat(out); err == nil { | |
| 661 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 662 | + | } | |
| 663 | + | if err := validateExtensionExecutable(cloneDir, manifest); err != nil { | |
| 664 | + | return err | |
| 665 | + | } | |
| 666 | + | if err := os.Rename(cloneDir, out); err != nil { | |
| 667 | + | return fmt.Errorf("finalize install: %w", err) | |
| 668 | + | } | |
| 669 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 670 | + | return nil | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | func readExtensionManifest(dir string) (extensions.Manifest, error) { | |
| 674 | + | raw, err := os.ReadFile(filepath.Join(dir, "extension.json")) | |
| 675 | + | if err != nil { | |
| 676 | + | if os.IsNotExist(err) { | |
| 677 | + | return extensions.Manifest{}, errors.New("source lacks extension.json") | |
| 678 | + | } | |
| 679 | + | return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err) | |
| 680 | + | } | |
| 681 | + | var manifest extensions.Manifest | |
| 682 | + | if err := json.Unmarshal(raw, &manifest); err != nil { | |
| 683 | + | return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err) | |
| 684 | + | } | |
| 685 | + | if strings.TrimSpace(manifest.Name) == "" { | |
| 686 | + | return extensions.Manifest{}, errors.New("manifest: name is required") | |
| 687 | + | } | |
| 688 | + | if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) { | |
| 689 | + | return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name) | |
| 690 | + | } | |
| 691 | + | if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) { | |
| 692 | + | return extensions.Manifest{}, errors.New("manifest: exec is required") | |
| 693 | + | } | |
| 694 | + | if manifest.Exec != "" { | |
| 695 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |
| 696 | + | if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |
| 697 | + | rel, err := filepath.Rel(dir, resolved) | |
| 698 | + | if err != nil { | |
| 699 | + | return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err) | |
| 700 | + | } | |
| 701 | + | if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 702 | + | return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec) | |
| 703 | + | } | |
| 704 | + | } | |
| 705 | + | } | |
| 706 | + | return manifest, nil | |
| 707 | + | } | |
| 708 | + | ||
| 709 | + | func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool { | |
| 710 | + | required := map[string]bool{"extension.json": true} | |
| 711 | + | if manifest.Exec == "" { | |
| 712 | + | return required | |
| 713 | + | } | |
| 714 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |
| 715 | + | if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |
| 716 | + | return required | |
| 717 | + | } | |
| 718 | + | rel, err := filepath.Rel(dir, resolved) | |
| 719 | + | if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |
| 720 | + | required[filepath.ToSlash(rel)] = true | |
| 721 | + | } | |
| 722 | + | return required | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | func validateExtensionExecutable(dir string, manifest extensions.Manifest) error { | |
| 726 | + | if manifest.Exec == "" { | |
| 727 | + | return nil | |
| 728 | + | } | |
| 729 | + | path, local := extensions.ResolveExecPath(dir, manifest.Exec) | |
| 730 | + | if !local { | |
| 731 | + | // Bare commands such as "go", "node", and "npx" are resolved | |
| 732 | + | // from PATH when zot starts. They may be intentionally installed | |
| 733 | + | // before the runtime is present on the current machine. | |
| 734 | + | return nil | |
| 735 | + | } | |
| 736 | + | info, err := os.Stat(path) | |
| 737 | + | if err != nil { | |
| 738 | + | if os.IsNotExist(err) { | |
| 739 | + | 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) | |
| 740 | + | } | |
| 741 | + | return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err) | |
| 742 | + | } | |
| 743 | + | if info.IsDir() { | |
| 744 | + | return fmt.Errorf("extension executable %q is a directory", manifest.Exec) | |
| 745 | + | } | |
| 746 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |
| 747 | + | return fmt.Errorf("extension executable %q is not executable; run chmod +x %s before installing", manifest.Exec, filepath.Base(path)) | |
| 748 | + | } | |
| 749 | + | return nil | |
| 750 | + | } | |
| 751 | + | ||
| 535 | 752 | func extensionDirs() map[string]string { | |
| 536 | 753 | out := map[string]string{} | |
| 537 | 754 | if h := ZotHome(); h != "" { | |
| @@ -569,6 +786,10 @@ func dashIfEmpty(s string) string { | |||
| 569 | 786 | // out of the installed copy so the extension stays functional at its new | |
| 570 | 787 | // location. | |
| 571 | 788 | func copyDir(src, dst string) error { | |
| 789 | + | return copyDirWithRequired(src, dst, nil) | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | func copyDirWithRequired(src, dst string, required map[string]bool) error { | |
| 572 | 793 | ig := loadGitignore(src) | |
| 573 | 794 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | |
| 574 | 795 | if err != nil { | |
| @@ -578,12 +799,13 @@ func copyDir(src, dst string) error { | |||
| 578 | 799 | if err != nil { | |
| 579 | 800 | return err | |
| 580 | 801 | } | |
| 802 | + | relSlash := filepath.ToSlash(rel) | |
| 581 | 803 | if rel != "." { | |
| 582 | 804 | name := filepath.Base(rel) | |
| 583 | 805 | if info.IsDir() && name == ".git" { | |
| 584 | 806 | return filepath.SkipDir | |
| 585 | 807 | } | |
| 586 | - | if ig.Match(filepath.ToSlash(rel), info.IsDir()) { | |
| 808 | + | if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) { | |
| 587 | 809 | if info.IsDir() { | |
| 588 | 810 | return filepath.SkipDir | |
| 589 | 811 | } | |
| @@ -609,6 +831,22 @@ func copyDir(src, dst string) error { | |||
| 609 | 831 | }) | |
| 610 | 832 | } | |
| 611 | 833 | ||
| 834 | + | func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool { | |
| 835 | + | if required[rel] { | |
| 836 | + | return true | |
| 837 | + | } | |
| 838 | + | if !isDir { | |
| 839 | + | return false | |
| 840 | + | } | |
| 841 | + | prefix := strings.TrimSuffix(rel, "/") + "/" | |
| 842 | + | for path := range required { | |
| 843 | + | if strings.HasPrefix(path, prefix) { | |
| 844 | + | return true | |
| 845 | + | } | |
| 846 | + | } | |
| 847 | + | return false | |
| 848 | + | } | |
| 849 | + | ||
| 612 | 850 | // gitignore matching lives in packages/ignore so the @-file picker in | |
| 613 | 851 | // packages/agent/modes can share it without an import cycle. These | |
| 614 | 852 | // 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" | ||
| @@ -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) | ||
| @@ -464,37 +468,26 @@ 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 | + | opts, err := parseExtInstallArgs(args) |
| 471 | - | return fmt.Errorf("usage: zot ext install <path|git-url>") | 477 | + | if err != nil { |
| 478 | + | return err | |||
| 472 | } | 479 | } | ||
| 473 | - | src := args[0] | 480 | + | src := opts.source |
| 474 | dest := filepath.Join(ZotHome(), "extensions") | 481 | dest := filepath.Join(ZotHome(), "extensions") | ||
| 475 | if err := os.MkdirAll(dest, 0o755); err != nil { | 482 | if err := os.MkdirAll(dest, 0o755); err != nil { | ||
| 476 | return err | 483 | return err | ||
| 477 | } | 484 | } | ||
| 478 | 485 | ||||
| 479 | if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") { | 486 | 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. | 487 | + | if opts.builder != "" { |
| 481 | - | name := strings.TrimSuffix(filepath.Base(src), ".git") | 488 | + | 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 | } | 489 | } | ||
| 492 | - | if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { | 490 | + | return installGitExtension(src, dest) |
| 493 | - | _ = os.RemoveAll(out) | |||
| 494 | - | return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back") | |||
| 495 | - | } | |||
| 496 | - | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |||
| 497 | - | return nil | |||
| 498 | } | 491 | } | ||
| 499 | 492 | ||||
| 500 | // Local path: must be a directory containing extension.json. | 493 | // Local path: must be a directory containing extension.json. | ||
| @@ -505,33 +498,257 @@ func extInstall(args []string) error { | |||||
| 505 | if !info.IsDir() { | 498 | if !info.IsDir() { | ||
| 506 | return fmt.Errorf("not a directory: %s", src) | 499 | return fmt.Errorf("not a directory: %s", src) | ||
| 507 | } | 500 | } | ||
| 508 | - | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | 501 | + | // Resolve to an absolute, cleaned path before reading the manifest. |
| 509 | - | return fmt.Errorf("source lacks extension.json") | 502 | + | // 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) | 503 | absSrc, err := filepath.Abs(src) | ||
| 517 | if err != nil { | 504 | if err != nil { | ||
| 518 | return err | 505 | return err | ||
| 519 | } | 506 | } | ||
| 520 | - | name := filepath.Base(absSrc) | 507 | + | manifest, err := readExtensionManifest(absSrc) |
| 521 | - | if name == "." || name == ".." || name == string(filepath.Separator) || name == "" { | 508 | + | if err != nil { |
| 522 | - | return fmt.Errorf("cannot derive extension name from %q", src) | 509 | + | return fmt.Errorf("source extension: %w", err) |
| 523 | } | 510 | } | ||
| 524 | - | out := filepath.Join(dest, name) | 511 | + | out := filepath.Join(dest, manifest.Name) |
| 525 | if _, err := os.Stat(out); err == nil { | 512 | if _, err := os.Stat(out); err == nil { | ||
| 526 | return fmt.Errorf("destination %s already exists; remove it first", out) | 513 | return fmt.Errorf("destination %s already exists; remove it first", out) | ||
| 527 | } | 514 | } | ||
| 528 | - | if err := copyDir(absSrc, out); err != nil { | 515 | + | |
| 516 | + | stageRoot, err := os.MkdirTemp(dest, ".zot-extension-install-") | |||
| 517 | + | if err != nil { | |||
| 518 | + | return fmt.Errorf("create install staging directory: %w", err) | |||
| 519 | + | } | |||
| 520 | + | defer os.RemoveAll(stageRoot) | |||
| 521 | + | staged := filepath.Join(stageRoot, "extension") | |||
| 522 | + | if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil { | |||
| 523 | + | return fmt.Errorf("copy extension: %w", err) | |||
| 524 | + | } | |||
| 525 | + | if opts.builder != "" { | |||
| 526 | + | if err := buildLocalExtension(opts.builder, absSrc, staged, manifest); err != nil { | |||
| 527 | + | return err | |||
| 528 | + | } | |||
| 529 | + | } | |||
| 530 | + | if err := validateExtensionExecutable(staged, manifest); err != nil { | |||
| 529 | return err | 531 | return err | ||
| 530 | } | 532 | } | ||
| 533 | + | if err := os.Rename(staged, out); err != nil { | |||
| 534 | + | return fmt.Errorf("finalize install: %w", err) | |||
| 535 | + | } | |||
| 531 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | 536 | fmt.Fprintf(os.Stderr, "installed %s\n", out) | ||
| 532 | return nil | 537 | return nil | ||
| 533 | } | 538 | } | ||
| 534 | 539 | ||||
| 540 | + | type extInstallOptions struct { | |||
| 541 | + | source string | |||
| 542 | + | builder string | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | func parseExtInstallArgs(args []string) (extInstallOptions, error) { | |||
| 546 | + | if len(args) == 0 { | |||
| 547 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | var opts extInstallOptions | |||
| 551 | + | builderSet := false | |||
| 552 | + | for i := 0; i < len(args); i++ { | |||
| 553 | + | arg := args[i] | |||
| 554 | + | switch { | |||
| 555 | + | case arg == "--build": | |||
| 556 | + | if i+1 >= len(args) { | |||
| 557 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |||
| 558 | + | } | |||
| 559 | + | i++ | |||
| 560 | + | if builderSet { | |||
| 561 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |||
| 562 | + | } | |||
| 563 | + | builderSet = true | |||
| 564 | + | opts.builder = strings.ToLower(strings.TrimSpace(args[i])) | |||
| 565 | + | if opts.builder == "" { | |||
| 566 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |||
| 567 | + | } | |||
| 568 | + | case strings.HasPrefix(arg, "--build="): | |||
| 569 | + | if builderSet { | |||
| 570 | + | return extInstallOptions{}, errors.New("extension builder specified more than once") | |||
| 571 | + | } | |||
| 572 | + | builderSet = true | |||
| 573 | + | opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build="))) | |||
| 574 | + | if opts.builder == "" { | |||
| 575 | + | return extInstallOptions{}, errors.New("--build requires a builder (currently: go)") | |||
| 576 | + | } | |||
| 577 | + | case strings.HasPrefix(arg, "-"): | |||
| 578 | + | return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg) | |||
| 579 | + | case opts.source != "": | |||
| 580 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |||
| 581 | + | default: | |||
| 582 | + | opts.source = arg | |||
| 583 | + | } | |||
| 584 | + | } | |||
| 585 | + | if opts.source == "" { | |||
| 586 | + | return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>") | |||
| 587 | + | } | |||
| 588 | + | if opts.builder != "" && opts.builder != "go" { | |||
| 589 | + | return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder) | |||
| 590 | + | } | |||
| 591 | + | return opts, nil | |||
| 592 | + | } | |||
| 593 | + | ||||
| 594 | + | func buildLocalExtension(builder, sourceDir, stagedDir string, manifest extensions.Manifest) error { | |||
| 595 | + | if manifest.Exec == "" { | |||
| 596 | + | return errors.New("cannot build a theme-only extension; it does not declare an executable") | |||
| 597 | + | } | |||
| 598 | + | if builder != "go" { | |||
| 599 | + | return fmt.Errorf("unsupported extension builder %q (currently supported: go)", builder) | |||
| 600 | + | } | |||
| 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.Command("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(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.Command("git", "clone", "--depth", "1", src, cloneDir) | |||
| 650 | + | cmd.Stdout = os.Stderr | |||
| 651 | + | cmd.Stderr = os.Stderr | |||
| 652 | + | if err := cmd.Run(); err != nil { | |||
| 653 | + | return fmt.Errorf("git clone: %w", err) | |||
| 654 | + | } | |||
| 655 | + | manifest, err := readExtensionManifest(cloneDir) | |||
| 656 | + | if err != nil { | |||
| 657 | + | return fmt.Errorf("cloned extension: %w", err) | |||
| 658 | + | } | |||
| 659 | + | out := filepath.Join(dest, manifest.Name) | |||
| 660 | + | if _, err := os.Stat(out); err == nil { | |||
| 661 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |||
| 662 | + | } | |||
| 663 | + | if err := validateExtensionExecutable(cloneDir, manifest); err != nil { | |||
| 664 | + | return err | |||
| 665 | + | } | |||
| 666 | + | if err := os.Rename(cloneDir, out); err != nil { | |||
| 667 | + | return fmt.Errorf("finalize install: %w", err) | |||
| 668 | + | } | |||
| 669 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |||
| 670 | + | return nil | |||
| 671 | + | } | |||
| 672 | + | ||||
| 673 | + | func readExtensionManifest(dir string) (extensions.Manifest, error) { | |||
| 674 | + | raw, err := os.ReadFile(filepath.Join(dir, "extension.json")) | |||
| 675 | + | if err != nil { | |||
| 676 | + | if os.IsNotExist(err) { | |||
| 677 | + | return extensions.Manifest{}, errors.New("source lacks extension.json") | |||
| 678 | + | } | |||
| 679 | + | return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err) | |||
| 680 | + | } | |||
| 681 | + | var manifest extensions.Manifest | |||
| 682 | + | if err := json.Unmarshal(raw, &manifest); err != nil { | |||
| 683 | + | return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err) | |||
| 684 | + | } | |||
| 685 | + | if strings.TrimSpace(manifest.Name) == "" { | |||
| 686 | + | return extensions.Manifest{}, errors.New("manifest: name is required") | |||
| 687 | + | } | |||
| 688 | + | if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) { | |||
| 689 | + | return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name) | |||
| 690 | + | } | |||
| 691 | + | if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) { | |||
| 692 | + | return extensions.Manifest{}, errors.New("manifest: exec is required") | |||
| 693 | + | } | |||
| 694 | + | if manifest.Exec != "" { | |||
| 695 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |||
| 696 | + | if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |||
| 697 | + | rel, err := filepath.Rel(dir, resolved) | |||
| 698 | + | if err != nil { | |||
| 699 | + | return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err) | |||
| 700 | + | } | |||
| 701 | + | if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |||
| 702 | + | return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec) | |||
| 703 | + | } | |||
| 704 | + | } | |||
| 705 | + | } | |||
| 706 | + | return manifest, nil | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool { | |||
| 710 | + | required := map[string]bool{"extension.json": true} | |||
| 711 | + | if manifest.Exec == "" { | |||
| 712 | + | return required | |||
| 713 | + | } | |||
| 714 | + | resolved, local := extensions.ResolveExecPath(dir, manifest.Exec) | |||
| 715 | + | if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) { | |||
| 716 | + | return required | |||
| 717 | + | } | |||
| 718 | + | rel, err := filepath.Rel(dir, resolved) | |||
| 719 | + | if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | |||
| 720 | + | required[filepath.ToSlash(rel)] = true | |||
| 721 | + | } | |||
| 722 | + | return required | |||
| 723 | + | } | |||
| 724 | + | ||||
| 725 | + | func validateExtensionExecutable(dir string, manifest extensions.Manifest) error { | |||
| 726 | + | if manifest.Exec == "" { | |||
| 727 | + | return nil | |||
| 728 | + | } | |||
| 729 | + | path, local := extensions.ResolveExecPath(dir, manifest.Exec) | |||
| 730 | + | if !local { | |||
| 731 | + | // Bare commands such as "go", "node", and "npx" are resolved | |||
| 732 | + | // from PATH when zot starts. They may be intentionally installed | |||
| 733 | + | // before the runtime is present on the current machine. | |||
| 734 | + | return nil | |||
| 735 | + | } | |||
| 736 | + | info, err := os.Stat(path) | |||
| 737 | + | if err != nil { | |||
| 738 | + | if os.IsNotExist(err) { | |||
| 739 | + | 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) | |||
| 740 | + | } | |||
| 741 | + | return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err) | |||
| 742 | + | } | |||
| 743 | + | if info.IsDir() { | |||
| 744 | + | return fmt.Errorf("extension executable %q is a directory", manifest.Exec) | |||
| 745 | + | } | |||
| 746 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |||
| 747 | + | return fmt.Errorf("extension executable %q is not executable; run chmod +x %s before installing", manifest.Exec, filepath.Base(path)) | |||
| 748 | + | } | |||
| 749 | + | return nil | |||
| 750 | + | } | |||
| 751 | + | ||||
| 535 | func extensionDirs() map[string]string { | 752 | func extensionDirs() map[string]string { | ||
| 536 | out := map[string]string{} | 753 | out := map[string]string{} | ||
| 537 | if h := ZotHome(); h != "" { | 754 | if h := ZotHome(); h != "" { | ||
| @@ -569,6 +786,10 @@ func dashIfEmpty(s string) string { | |||||
| 569 | // out of the installed copy so the extension stays functional at its new | 786 | // out of the installed copy so the extension stays functional at its new | ||
| 570 | // location. | 787 | // location. | ||
| 571 | func copyDir(src, dst string) error { | 788 | func copyDir(src, dst string) error { | ||
| 789 | + | return copyDirWithRequired(src, dst, nil) | |||
| 790 | + | } | |||
| 791 | + | ||||
| 792 | + | func copyDirWithRequired(src, dst string, required map[string]bool) error { | |||
| 572 | ig := loadGitignore(src) | 793 | ig := loadGitignore(src) | ||
| 573 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | 794 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | ||
| 574 | if err != nil { | 795 | if err != nil { | ||
| @@ -578,12 +799,13 @@ func copyDir(src, dst string) error { | |||||
| 578 | if err != nil { | 799 | if err != nil { | ||
| 579 | return err | 800 | return err | ||
| 580 | } | 801 | } | ||
| 802 | + | relSlash := filepath.ToSlash(rel) | |||
| 581 | if rel != "." { | 803 | if rel != "." { | ||
| 582 | name := filepath.Base(rel) | 804 | name := filepath.Base(rel) | ||
| 583 | if info.IsDir() && name == ".git" { | 805 | if info.IsDir() && name == ".git" { | ||
| 584 | return filepath.SkipDir | 806 | return filepath.SkipDir | ||
| 585 | } | 807 | } | ||
| 586 | - | if ig.Match(filepath.ToSlash(rel), info.IsDir()) { | 808 | + | if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) { |
| 587 | if info.IsDir() { | 809 | if info.IsDir() { | ||
| 588 | return filepath.SkipDir | 810 | return filepath.SkipDir | ||
| 589 | } | 811 | } | ||
| @@ -609,6 +831,22 @@ func copyDir(src, dst string) error { | |||||
| 609 | }) | 831 | }) | ||
| 610 | } | 832 | } | ||
| 611 | 833 | ||||
| 834 | + | func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool { | |||
| 835 | + | if required[rel] { | |||
| 836 | + | return true | |||
| 837 | + | } | |||
| 838 | + | if !isDir { | |||
| 839 | + | return false | |||
| 840 | + | } | |||
| 841 | + | prefix := strings.TrimSuffix(rel, "/") + "/" | |||
| 842 | + | for path := range required { | |||
| 843 | + | if strings.HasPrefix(path, prefix) { | |||
| 844 | + | return true | |||
| 845 | + | } | |||
| 846 | + | } | |||
| 847 | + | return false | |||
| 848 | + | } | |||
| 849 | + | ||||
| 612 | // gitignore matching lives in packages/ignore so the @-file picker in | 850 | // gitignore matching lives in packages/ignore so the @-file picker in | ||
| 613 | // packages/agent/modes can share it without an import cycle. These | 851 | // packages/agent/modes can share it without an import cycle. These | ||
| 614 | // thin aliases keep the existing call sites (and tests) terse. | 852 | // thin aliases keep the existing call sites (and tests) terse. | ||
M packages/agent/extcmd_test.go +280 -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,277 @@ 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 source", args: []string{"--build=go"}, wantErr: "usage:"}, | |
| 219 | + | {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"}, | |
| 220 | + | {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"}, | |
| 221 | + | } { | |
| 222 | + | t.Run(test.name, func(t *testing.T) { | |
| 223 | + | got, err := parseExtInstallArgs(test.args) | |
| 224 | + | if test.wantErr != "" { | |
| 225 | + | if err == nil || !strings.Contains(err.Error(), test.wantErr) { | |
| 226 | + | t.Fatalf("error = %v, want substring %q", err, test.wantErr) | |
| 227 | + | } | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | if err != nil { | |
| 231 | + | t.Fatalf("parse failed: %v", err) | |
| 232 | + | } | |
| 233 | + | if got.source != "./ext" || got.builder != test.builder { | |
| 234 | + | t.Fatalf("options = %#v, want source ./ext and builder %q", got, test.builder) | |
| 235 | + | } | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) { | |
| 241 | + | home := t.TempDir() | |
| 242 | + | t.Setenv("ZOT_HOME", home) | |
| 243 | + | src := filepath.Join(t.TempDir(), "theme-source") | |
| 244 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 245 | + | t.Fatal(err) | |
| 246 | + | } | |
| 247 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil { | |
| 248 | + | t.Fatal(err) | |
| 249 | + | } | |
| 250 | + | if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil { | |
| 251 | + | t.Fatal(err) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | err := extInstall([]string{"--build=go", src}) | |
| 255 | + | if err == nil || !strings.Contains(err.Error(), "theme-only") { | |
| 256 | + | t.Fatalf("error = %v, want theme-only builder error", err) | |
| 257 | + | } | |
| 258 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "theme-only")); !os.IsNotExist(statErr) { | |
| 259 | + | t.Fatalf("failed build left a destination: %v", statErr) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) { | |
| 264 | + | home := t.TempDir() | |
| 265 | + | t.Setenv("ZOT_HOME", home) | |
| 266 | + | src := filepath.Join(t.TempDir(), "path-extension") | |
| 267 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 268 | + | t.Fatal(err) | |
| 269 | + | } | |
| 270 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"path-extension","exec":"node"}`), 0o644); err != nil { | |
| 271 | + | t.Fatal(err) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | err := extInstall([]string{"--build=go", src}) | |
| 275 | + | if err == nil || !strings.Contains(err.Error(), "extension-relative executable path") { | |
| 276 | + | t.Fatalf("error = %v, want local executable requirement", err) | |
| 277 | + | } | |
| 278 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "path-extension")); !os.IsNotExist(statErr) { | |
| 279 | + | t.Fatalf("failed build left a destination: %v", statErr) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestExtInstallRejectsNonExecutable(t *testing.T) { | |
| 284 | + | if runtime.GOOS == "windows" { | |
| 285 | + | t.Skip("Unix executable permissions are not enforced on Windows") | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | home := t.TempDir() | |
| 289 | + | t.Setenv("ZOT_HOME", home) | |
| 290 | + | src := filepath.Join(t.TempDir(), "non-executable") | |
| 291 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 292 | + | t.Fatal(err) | |
| 293 | + | } | |
| 294 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"non-executable","exec":"./run.sh","language":"shell"}`), 0o644); err != nil { | |
| 295 | + | t.Fatal(err) | |
| 296 | + | } | |
| 297 | + | if err := os.WriteFile(filepath.Join(src, "run.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil { | |
| 298 | + | t.Fatal(err) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | err := extInstall([]string{src}) | |
| 302 | + | if err == nil || !strings.Contains(err.Error(), "not executable") { | |
| 303 | + | t.Fatalf("install error = %v, want executable permission guidance", err) | |
| 304 | + | } | |
| 305 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "non-executable")); !os.IsNotExist(statErr) { | |
| 306 | + | t.Fatalf("failed install left a destination: %v", statErr) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // TestExtInstallCopiesIgnoredExecutableAndUsesManifestName verifies that a | |
| 311 | + | // declared runtime file is copied even when .gitignore matches it, and that | |
| 312 | + | // the manifest name controls the installed directory. | |
| 313 | + | func TestExtInstallCopiesIgnoredExecutableAndUsesManifestName(t *testing.T) { | |
| 314 | + | home := t.TempDir() | |
| 315 | + | t.Setenv("ZOT_HOME", home) | |
| 316 | + | ||
| 317 | + | src := filepath.Join(t.TempDir(), "source-dir") | |
| 318 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 319 | + | t.Fatal(err) | |
| 320 | + | } | |
| 321 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil { | |
| 322 | + | t.Fatal(err) | |
| 323 | + | } | |
| 324 | + | if err := os.WriteFile(filepath.Join(src, ".gitignore"), []byte("tasked-phases\n"), 0o644); err != nil { | |
| 325 | + | t.Fatal(err) | |
| 326 | + | } | |
| 327 | + | if err := os.WriteFile(filepath.Join(src, "tasked-phases"), []byte("not really a binary"), 0o755); err != nil { | |
| 328 | + | t.Fatal(err) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | if err := extInstall([]string{src}); err != nil { | |
| 332 | + | t.Fatalf("install failed: %v", err) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | out := filepath.Join(home, "extensions", "tasked-phases") | |
| 336 | + | info, err := os.Stat(filepath.Join(out, "tasked-phases")) | |
| 337 | + | if err != nil { | |
| 338 | + | t.Fatalf("declared executable was not copied: %v", err) | |
| 339 | + | } | |
| 340 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |
| 341 | + | t.Fatalf("copied executable mode = %o, want an execute bit", info.Mode().Perm()) | |
| 342 | + | } | |
| 343 | + | if _, err := os.Stat(filepath.Join(home, "extensions", "source-dir")); !os.IsNotExist(err) { | |
| 344 | + | t.Fatalf("install used source directory name instead of manifest name: %v", err) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 80 | 348 | func TestCopyDirRespectsGitignore(t *testing.T) { | |
| 81 | 349 | src := t.TempDir() | |
| 82 | 350 | 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,277 @@ 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 source", args: []string{"--build=go"}, wantErr: "usage:"}, | |||
| 219 | + | {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"}, | |||
| 220 | + | {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"}, | |||
| 221 | + | } { | |||
| 222 | + | t.Run(test.name, func(t *testing.T) { | |||
| 223 | + | got, err := parseExtInstallArgs(test.args) | |||
| 224 | + | if test.wantErr != "" { | |||
| 225 | + | if err == nil || !strings.Contains(err.Error(), test.wantErr) { | |||
| 226 | + | t.Fatalf("error = %v, want substring %q", err, test.wantErr) | |||
| 227 | + | } | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | if err != nil { | |||
| 231 | + | t.Fatalf("parse failed: %v", err) | |||
| 232 | + | } | |||
| 233 | + | if got.source != "./ext" || got.builder != test.builder { | |||
| 234 | + | t.Fatalf("options = %#v, want source ./ext and builder %q", got, test.builder) | |||
| 235 | + | } | |||
| 236 | + | }) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) { | |||
| 241 | + | home := t.TempDir() | |||
| 242 | + | t.Setenv("ZOT_HOME", home) | |||
| 243 | + | src := filepath.Join(t.TempDir(), "theme-source") | |||
| 244 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 245 | + | t.Fatal(err) | |||
| 246 | + | } | |||
| 247 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil { | |||
| 248 | + | t.Fatal(err) | |||
| 249 | + | } | |||
| 250 | + | if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil { | |||
| 251 | + | t.Fatal(err) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | err := extInstall([]string{"--build=go", src}) | |||
| 255 | + | if err == nil || !strings.Contains(err.Error(), "theme-only") { | |||
| 256 | + | t.Fatalf("error = %v, want theme-only builder error", err) | |||
| 257 | + | } | |||
| 258 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "theme-only")); !os.IsNotExist(statErr) { | |||
| 259 | + | t.Fatalf("failed build left a destination: %v", statErr) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) { | |||
| 264 | + | home := t.TempDir() | |||
| 265 | + | t.Setenv("ZOT_HOME", home) | |||
| 266 | + | src := filepath.Join(t.TempDir(), "path-extension") | |||
| 267 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 268 | + | t.Fatal(err) | |||
| 269 | + | } | |||
| 270 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"path-extension","exec":"node"}`), 0o644); err != nil { | |||
| 271 | + | t.Fatal(err) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | err := extInstall([]string{"--build=go", src}) | |||
| 275 | + | if err == nil || !strings.Contains(err.Error(), "extension-relative executable path") { | |||
| 276 | + | t.Fatalf("error = %v, want local executable requirement", err) | |||
| 277 | + | } | |||
| 278 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "path-extension")); !os.IsNotExist(statErr) { | |||
| 279 | + | t.Fatalf("failed build left a destination: %v", statErr) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestExtInstallRejectsNonExecutable(t *testing.T) { | |||
| 284 | + | if runtime.GOOS == "windows" { | |||
| 285 | + | t.Skip("Unix executable permissions are not enforced on Windows") | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | home := t.TempDir() | |||
| 289 | + | t.Setenv("ZOT_HOME", home) | |||
| 290 | + | src := filepath.Join(t.TempDir(), "non-executable") | |||
| 291 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 292 | + | t.Fatal(err) | |||
| 293 | + | } | |||
| 294 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"non-executable","exec":"./run.sh","language":"shell"}`), 0o644); err != nil { | |||
| 295 | + | t.Fatal(err) | |||
| 296 | + | } | |||
| 297 | + | if err := os.WriteFile(filepath.Join(src, "run.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil { | |||
| 298 | + | t.Fatal(err) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | err := extInstall([]string{src}) | |||
| 302 | + | if err == nil || !strings.Contains(err.Error(), "not executable") { | |||
| 303 | + | t.Fatalf("install error = %v, want executable permission guidance", err) | |||
| 304 | + | } | |||
| 305 | + | if _, statErr := os.Stat(filepath.Join(home, "extensions", "non-executable")); !os.IsNotExist(statErr) { | |||
| 306 | + | t.Fatalf("failed install left a destination: %v", statErr) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // TestExtInstallCopiesIgnoredExecutableAndUsesManifestName verifies that a | |||
| 311 | + | // declared runtime file is copied even when .gitignore matches it, and that | |||
| 312 | + | // the manifest name controls the installed directory. | |||
| 313 | + | func TestExtInstallCopiesIgnoredExecutableAndUsesManifestName(t *testing.T) { | |||
| 314 | + | home := t.TempDir() | |||
| 315 | + | t.Setenv("ZOT_HOME", home) | |||
| 316 | + | ||||
| 317 | + | src := filepath.Join(t.TempDir(), "source-dir") | |||
| 318 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 319 | + | t.Fatal(err) | |||
| 320 | + | } | |||
| 321 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil { | |||
| 322 | + | t.Fatal(err) | |||
| 323 | + | } | |||
| 324 | + | if err := os.WriteFile(filepath.Join(src, ".gitignore"), []byte("tasked-phases\n"), 0o644); err != nil { | |||
| 325 | + | t.Fatal(err) | |||
| 326 | + | } | |||
| 327 | + | if err := os.WriteFile(filepath.Join(src, "tasked-phases"), []byte("not really a binary"), 0o755); err != nil { | |||
| 328 | + | t.Fatal(err) | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | if err := extInstall([]string{src}); err != nil { | |||
| 332 | + | t.Fatalf("install failed: %v", err) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | out := filepath.Join(home, "extensions", "tasked-phases") | |||
| 336 | + | info, err := os.Stat(filepath.Join(out, "tasked-phases")) | |||
| 337 | + | if err != nil { | |||
| 338 | + | t.Fatalf("declared executable was not copied: %v", err) | |||
| 339 | + | } | |||
| 340 | + | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |||
| 341 | + | t.Fatalf("copied executable mode = %o, want an execute bit", info.Mode().Perm()) | |||
| 342 | + | } | |||
| 343 | + | if _, err := os.Stat(filepath.Join(home, "extensions", "source-dir")); !os.IsNotExist(err) { | |||
| 344 | + | t.Fatalf("install used source directory name instead of manifest name: %v", err) | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 80 | func TestCopyDirRespectsGitignore(t *testing.T) { | 348 | func TestCopyDirRespectsGitignore(t *testing.T) { | ||
| 81 | src := t.TempDir() | 349 | src := t.TempDir() | ||
| 82 | dst := filepath.Join(t.TempDir(), "out") | 350 | dst := filepath.Join(t.TempDir(), "out") | ||
M packages/agent/extensions/manager.go +20 -21
| @@ -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 | |
| @@ -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 | ||
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/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 +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestIsDevVersion(t *testing.T) { | |
| 11 | + | for _, version := range []string{"", "dev", "0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)", "0.0.0-dev+local"} { | |
| 12 | + | if !isDevVersion(version) { | |
| 13 | + | t.Errorf("isDevVersion(%q) = false, want true", version) | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | for _, version := range []string{"0.0.1", "1.0.0", "0.0.0-rc1"} { | |
| 17 | + | if isDevVersion(version) { | |
| 18 | + | t.Errorf("isDevVersion(%q) = true, want false", version) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |
| 24 | + | home := t.TempDir() | |
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 26 | + | cancel() | |
| 27 | + | ||
| 28 | + | info := CheckForUpdate(ctx, home, "0.0.0-dev") | |
| 29 | + | if info != (UpdateInfo{}) { | |
| 30 | + | t.Fatalf("development check = %+v, want an empty result", info) | |
| 31 | + | } | |
| 32 | + | if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) { | |
| 33 | + | t.Fatalf("development check wrote a cache file: %v", err) | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestIsDevVersion(t *testing.T) { | |||
| 11 | + | for _, version := range []string{"", "dev", "0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)", "0.0.0-dev+local"} { | |||
| 12 | + | if !isDevVersion(version) { | |||
| 13 | + | t.Errorf("isDevVersion(%q) = false, want true", version) | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | for _, version := range []string{"0.0.1", "1.0.0", "0.0.0-rc1"} { | |||
| 17 | + | if isDevVersion(version) { | |||
| 18 | + | t.Errorf("isDevVersion(%q) = true, want false", version) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |||
| 24 | + | home := t.TempDir() | |||
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 26 | + | cancel() | |||
| 27 | + | ||||
| 28 | + | info := CheckForUpdate(ctx, home, "0.0.0-dev") | |||
| 29 | + | if info != (UpdateInfo{}) { | |||
| 30 | + | t.Fatalf("development check = %+v, want an empty result", info) | |||
| 31 | + | } | |||
| 32 | + | if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) { | |||
| 33 | + | t.Fatalf("development check wrote a cache file: %v", err) | |||
| 34 | + | } | |||
| 35 | + | } | |||
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) { | ||