detached 090c470

feat(ext): improve installation and dev builds

commit
090c470fac0621bfd2540d674d3a25a25e82d321
author
brice · · 2026-08-05 03:22 UTC
parent
bce908a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +916 -206
M CONTRIBUTING.md +5 -1
@@ -68,9 +68,13 @@ make build
6868 Useful commands:
6969
7070 ```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
7273 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
7376 make test # run all tests with the race detector
77+make test-fast # run all tests without the race detector
7478 make lint # run go vet and verify gofmt
7579 ```
7680
@@ -68,9 +68,13 @@ make build
68 Useful commands:68 Useful commands:
69 69
70 ```sh70 ```sh
71-make build # compile ./bin/zot71+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 application73 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 detector76 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 gofmt78 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
412 LDFLAGS := -s -w -X main.version=$(VERSION)
513
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=...)'
730
831 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)
1034
35+# Install the current checkout. With the default VERSION this is a dev build.
1136 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)
1342
1443 test:
15- go test -race ./...
44+ $(GO) test -race ./...
1645
17-run: build
18- ./bin/zot
46+test-fast:
47+ $(GO) test ./...
48+
49+vet:
50+ $(GO) vet ./...
1951
20-lint:
21- go vet ./...
52+fmt-check:
2253 @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1)
2354
55+lint: vet fmt-check
56+
57+check: test-fast lint
58+
2459 fmt:
2560 gofmt -w .
2661
62+run: build
63+ "$(BIN_DIR)/$(BINARY)" $(ARGS)
64+
2765 clean:
28- rm -rf bin
66+ rm -rf "$(BIN_DIR)"
2967
3068 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 by1+# 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.03+.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 run14+.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/zot32+ @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/zot37+ $(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: build46+test-fast:
18- ./bin/zot47+ $(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 bin66+ rm -rf "$(BIN_DIR)"
29 67
30 release:68 release:
31- @mkdir -p bin69+ @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/zot70+ 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/zot71+ exit 1; \
34- GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-amd64 ./cmd/zot72+ fi
35- GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-arm64 ./cmd/zot73+ @mkdir -p "$(BIN_DIR)"
36- GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-windows-amd64.exe ./cmd/zot74+ 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`
6464 ```bash
6565 git clone https://github.com/patriceckhart/zot
6666 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
6971 ```
7072
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+
7177 ### Prebuilt binaries
7278
7379 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
182188
183189 ## Changelog on update
184190
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.
186192
187193 ## Usage
188194
@@ -922,6 +928,7 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens
922928
923929 ```bash
924930 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
925932 zot ext list # show installed extensions
926933 zot ext doctor # diagnose load, registration, and conflict issues
927934 zot ext logs <name> [-f] # cat or tail the extension's stderr log
@@ -930,6 +937,13 @@ zot ext disable <name> # disable without removing
930937 zot ext remove <name> # delete an extension directory
931938 ```
932939
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+
933947 `zot ext doctor` keeps normal extension startup fail-soft, but gives
934948 you an explicit troubleshooting view: manifest errors, disabled or
935949 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
946960 - Extensions without a `.git/` directory (installed by `zot ext install ./local-path`) are skipped — there is no remote to pull from.
947961 - 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.
948962 - 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.
950964
951965 ### Theme-only extensions
952966
@@ -1026,11 +1040,15 @@ This means additional messaging backends (Discord, Slack, Signal, and similar) c
10261040 ## Development
10271041
10281042 ```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
10301045 make test # go test -race ./...
1046+make test-fast # go test ./...
10311047 make lint # go vet + gofmt check
10321048 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
10341052 ```
10351053
10361054 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 ```bash64 ```bash
65 git clone https://github.com/patriceckhart/zot65 git clone https://github.com/patriceckhart/zot
66 cd zot66 cd zot
67-make build # produces ./bin/zot67+make help # list the important developer commands
68-make install # into $GOPATH/bin68+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 binaries77 ### 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 update189 ## 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 ## Usage193 ## Usage
188 194
@@ -922,6 +928,7 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens
922 928
923 ```bash929 ```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 extensions932 zot ext list # show installed extensions
926 zot ext doctor # diagnose load, registration, and conflict issues933 zot ext doctor # diagnose load, registration, and conflict issues
927 zot ext logs <name> [-f] # cat or tail the extension's stderr log934 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 directory937 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 gives947 `zot ext doctor` keeps normal extension startup fail-soft, but gives
934 you an explicit troubleshooting view: manifest errors, disabled or948 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 extensions965 ### Theme-only extensions
952 966
@@ -1026,11 +1040,15 @@ This means additional messaging backends (Discord, Slack, Signal, and similar) c
1026 ## Development1040 ## Development
1027 1041
1028 ```bash1042 ```bash
1029-make build # build ./bin/zot1043+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 check1047 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 arm641049+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 (
1515 // local builds. Defaults make `zot --version` print something sensible
1616 // when built without ldflags.
1717 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"
2221 commit = ""
2322 date = ""
2423 )
@@ -44,18 +43,39 @@ func main() {
4443
4544 // resolvedVersion falls back to the module version embedded by Go when zot is
4645 // 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.
4847 func resolvedVersion(linkedVersion string) string {
4948 info, _ := debug.ReadBuildInfo()
5049 return resolvedVersionFromBuildInfo(linkedVersion, info)
5150 }
5251
5352 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" {
5554 return linkedVersion
5655 }
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) {
5857 return linkedVersion
5958 }
6059 return strings.TrimPrefix(info.Main.Version, "v")
6160 }
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 sensible15 // 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 / untagged18+ // 0.0.0-dev is the placeholder for local / untagged builds.
19- // builds. The first published GitHub release will be tagged19+ // 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 is44 // resolvedVersion falls back to the module version embedded by Go when zot is
46 // installed with "go install ...@version". Release archives still use the45 // 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 linkedVersion54 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 linkedVersion57 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) {
1818 moduleVersion: "v0.2.94",
1919 want: "0.2.94",
2020 },
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+ },
2133 {
2234 name: "release linker version wins",
2335 linkedVersion: "0.2.95",
@@ -30,6 +42,12 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) {
3042 moduleVersion: "(devel)",
3143 want: "0.0.0",
3244 },
45+ {
46+ name: "local dev build",
47+ linkedVersion: "0.0.0-dev",
48+ moduleVersion: "(devel)",
49+ want: "0.0.0-dev",
50+ },
3351 {
3452 name: "missing build info",
3553 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.
8181 The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those:
8282
8383 ```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
8986
9087 # or load straight from the repo for one zot session
9188 zot --ext path/to/zot/examples/extensions/hello
9289 ```
9390
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+
94103 Nothing is auto-installed and nothing reaches out to the network without your explicit action.
95104
96105 ## Layout & discovery
@@ -593,10 +602,11 @@ Reply with `shutdown_ack` and then exit.
593602 ## Managing extensions from the CLI
594603
595604 ```
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
600610 zot ext enable <name> re-enable a disabled extension
601611 zot ext disable <name> disable without removing
602612 zot ext logs <name> [-f] cat / tail the extension's stderr
@@ -610,7 +620,9 @@ warnings, and each extension's stderr log path.
610620
611621 `zot ext install <path>` does a recursive copy; `<git-url>` does a
612622 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.
614626
615627 ## Loading an extension for one run
616628
@@ -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 ```bash83 ```bash
84-# go-based examples need a build first84+# 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 session87 # or load straight from the repo for one zot session
91 zot --ext path/to/zot/examples/extensions/hello88 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 & discovery105 ## Layout & discovery
@@ -593,10 +602,11 @@ Reply with `shutdown_ack` and then exit.
593 ## Managing extensions from the CLI602 ## Managing extensions from the CLI
594 603
595 ```604 ```
596-zot ext list list installed extensions and their state605+zot ext list list installed extensions and their state
597-zot ext doctor diagnose load, registration, and conflict issues606+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 directory608+ copy / clone into $ZOT_HOME/extensions/
609+zot ext remove <name> delete an extension directory
600 zot ext enable <name> re-enable a disabled extension610 zot ext enable <name> re-enable a disabled extension
601 zot ext disable <name> disable without removing611 zot ext disable <name> disable without removing
602 zot ext logs <name> [-f] cat / tail the extension's stderr612 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 a621 `zot ext install <path>` does a recursive copy; `<git-url>` does a
612 shallow clone. Both validate that the destination contains an622 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 run627 ## 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
3232
3333 # Or install it globally (run this from the extension directory):
3434 cd /path/to/zot/examples/extensions/tasked-phases
35-zot ext install .
35+zot ext install --build=go .
3636 ```
3737
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+
3843 Then use `/phases` or ask the model to create a spec and phased checklist with
3944 `tasked_phases`. The extension keys its durable state by the host-reported
4045 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-phases34 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 with43 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-reported44 `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) {
423423 )
424424 section("extensions",
425425 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"},
427427 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
428428 row{"zot ext help", "show all extension subcommands"},
429429 )
@@ -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 {
3636 }
3737
3838 func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) {
39- version = semverOnly(version)
40- if version == "" || version == "dev" {
39+ if isDevVersion(version) {
4140 return ChangelogInfo{}, nil
4241 }
42+ version = semverOnly(version)
4343
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
5547 }
48+ url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag)
5649 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
5750 if err != nil {
5851 return ChangelogInfo{}, err
@@ -159,20 +152,17 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo {
159152
160153 // ShouldShowChangelog reports whether the running binary version
161154 // 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).
165158 func ShouldShowChangelog(currentVersion string, cfg Config) bool {
166- currentVersion = semverOnly(currentVersion)
167- if currentVersion == "" || currentVersion == "dev" {
159+ if isDevVersion(currentVersion) {
168160 return false
169161 }
162+ currentVersion = semverOnly(currentVersion)
170163 if cfg.LastChangelogShown == "" {
171164 return false
172165 }
173- if currentVersion == "0.0.0" {
174- return true
175- }
176166 return semverOnly(cfg.LastChangelogShown) != currentVersion
177167 }
178168
@@ -194,10 +184,10 @@ func MarkChangelogShown(version string) error {
194184 // correctly trigger the dialog while THIS launch (which is also
195185 // "first-ever") doesn't.
196186 func SeedChangelogVersion(version string) {
197- version = semverOnly(version)
198- if version == "" || version == "dev" {
187+ if isDevVersion(version) {
199188 return
200189 }
190+ version = semverOnly(version)
201191 cfg, err := LoadConfig()
202192 if err != nil {
203193 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{}, nil40 return ChangelogInfo{}, nil
42 }41 }
42+ version = semverOnly(version)
43 43
44- // For local dev builds (0.0.0), fetch the latest release instead44+ tag := version
45- // of a tagged one so developers always see the newest changelog.45+ if !strings.HasPrefix(tag, "v") {
46- var url string46+ 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{}, err51 return ChangelogInfo{}, err
@@ -159,20 +152,17 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo {
159 152
160 // ShouldShowChangelog reports whether the running binary version153 // 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 on155+// Returns false on development builds and on the first-ever launch (no
163-// the first-ever launch (no LastChangelogShown stored — we don't156+// 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 false160 return false
169 }161 }
162+ currentVersion = semverOnly(currentVersion)
170 if cfg.LastChangelogShown == "" {163 if cfg.LastChangelogShown == "" {
171 return false164 return false
172 }165 }
173- if currentVersion == "0.0.0" {
174- return true
175- }
176 return semverOnly(cfg.LastChangelogShown) != currentVersion166 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 also184 // 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 return188 return
200 }189 }
190+ version = semverOnly(version)
201 cfg, err := LoadConfig()191 cfg, err := LoadConfig()
202 if err != nil {192 if err != nil {
203 return193 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 {
18341834 return nil
18351835 }
18361836
1837+ devBuild := isDevVersion(version)
1838+ if devBuild {
1839+ fmt.Fprintln(os.Stderr, "zot:", devVersionNotice)
1840+ }
18371841 term := tui.NewProcTerm()
18381842
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+ }()
18531860
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+ }
18861889
18871890 initialCfg, _ := LoadConfig()
18881891 fastMode := r.FastMode
@@ -2063,11 +2066,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
20632066 ResolveSubagent: resolveSubagent,
20642067 ChangelogChan: changelogCh,
20652068 OnChangelogDismiss: func() {
2066- // For dev builds (0.0.0) store the actual release version
2069+ // For development builds, store the actual release version
20672070 // so the same changelog doesn't show again next launch.
20682071 // For real builds, store the binary version.
20692072 v := version
2070- if v == "0.0.0" {
2073+ if devBuild {
20712074 if iv != nil && iv.ChangelogVersion() != "" {
20722075 v = iv.ChangelogVersion()
20732076 }
@@ -1834,55 +1834,58 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1834 return nil1834 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 the1843+ var updateCh chan modes.UpdateInfo
1840- // http response eventually arrives (usually <1s on cached DNS). Map1844+ 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 last1861+ // Changelog: when the running version differs from the last
1855- // version whose release notes the user dismissed, fetch the1862+ // version whose release notes the user dismissed, fetch the
1856- // release body from GitHub and have the TUI show it once. On1863+ // release body from GitHub and have the TUI show it once. On
1857- // first-ever launch (no prior LastChangelogShown), seed the1864+ // first-ever launch (no prior LastChangelogShown), seed the
1858- // stored version silently — don't dump release notes at someone1865+ // 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- return1873+ return
1867- }1874+ }
1868- if !ShouldShowChangelog(version, cfg) {1875+ if !ShouldShowChangelog(version, cfg) {
1869- return1876+ return
1870- }1877+ }
1871- info := <-FetchChangelogAsync(version)1878+ info := <-FetchChangelogAsync(version)
1872- if info.Body == "" {1879+ if info.Body == "" {
1873- return1880+ return
1874- }1881+ }
1875- // For dev builds (0.0.0), skip if the latest release was1882+ 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- return1885+ 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.FastMode1891 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 version2069+ // 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 := version2072 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
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "io"
89 "os"
910 "os/exec"
1011 "path/filepath"
12+ "runtime"
1113 "sort"
1214 "strings"
1315 "time"
@@ -62,8 +64,10 @@ usage:
6264 zot ext logs <name> [-f] cat / tail an extension's stderr log
6365 zot ext enable <name> re-enable a disabled extension
6466 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
6771
6872 extensions live under:
6973 $ZOT_HOME/extensions/<name>/extension.json (global)
@@ -464,37 +468,26 @@ func extRemove(args []string) error {
464468 }
465469
466470 // 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.
469475 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
472479 }
473- src := args[0]
480+ src := opts.source
474481 dest := filepath.Join(ZotHome(), "extensions")
475482 if err := os.MkdirAll(dest, 0o755); err != nil {
476483 return err
477484 }
478485
479486 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")
491489 }
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)
498491 }
499492
500493 // Local path: must be a directory containing extension.json.
@@ -505,33 +498,257 @@ func extInstall(args []string) error {
505498 if !info.IsDir() {
506499 return fmt.Errorf("not a directory: %s", src)
507500 }
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.
516503 absSrc, err := filepath.Abs(src)
517504 if err != nil {
518505 return err
519506 }
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)
523510 }
524- out := filepath.Join(dest, name)
511+ out := filepath.Join(dest, manifest.Name)
525512 if _, err := os.Stat(out); err == nil {
526513 return fmt.Errorf("destination %s already exists; remove it first", out)
527514 }
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 {
529531 return err
530532 }
533+ if err := os.Rename(staged, out); err != nil {
534+ return fmt.Errorf("finalize install: %w", err)
535+ }
531536 fmt.Fprintf(os.Stderr, "installed %s\n", out)
532537 return nil
533538 }
534539
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+
535752 func extensionDirs() map[string]string {
536753 out := map[string]string{}
537754 if h := ZotHome(); h != "" {
@@ -569,6 +786,10 @@ func dashIfEmpty(s string) string {
569786 // out of the installed copy so the extension stays functional at its new
570787 // location.
571788 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 {
572793 ig := loadGitignore(src)
573794 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
574795 if err != nil {
@@ -578,12 +799,13 @@ func copyDir(src, dst string) error {
578799 if err != nil {
579800 return err
580801 }
802+ relSlash := filepath.ToSlash(rel)
581803 if rel != "." {
582804 name := filepath.Base(rel)
583805 if info.IsDir() && name == ".git" {
584806 return filepath.SkipDir
585807 }
586- if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
808+ if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) {
587809 if info.IsDir() {
588810 return filepath.SkipDir
589811 }
@@ -609,6 +831,22 @@ func copyDir(src, dst string) error {
609831 })
610832 }
611833
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+
612850 // gitignore matching lives in packages/ignore so the @-file picker in
613851 // packages/agent/modes can share it without an import cycle. These
614852 // 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 log64 zot ext logs <name> [-f] cat / tail an extension's stderr log
63 zot ext enable <name> re-enable a disabled extension65 zot ext enable <name> re-enable a disabled extension
64 zot ext disable <name> disable without removing66 zot ext disable <name> disable without removing
65- zot ext remove <name> delete an extension directory67+ 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 URL470 // extInstall copies a local directory or shallow-clones a git URL
467-// into $ZOT_HOME/extensions/. Validates the destination contains an471+// 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 err483 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 err505 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 err531 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 nil537 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 new786 // 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 err800 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.SkipDir806 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.SkipDir810 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 in850 // gitignore matching lives in packages/ignore so the @-file picker in
613 // packages/agent/modes can share it without an import cycle. These851 // 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
33 import (
44 "bytes"
55 "os"
6+ osexec "os/exec"
67 "path/filepath"
8+ "runtime"
79 "strings"
810 "testing"
911
1012 "github.com/patriceckhart/zot/packages/agent/extensions"
1113 )
1214
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.
1618 func TestExtInstallDotSource(t *testing.T) {
1719 home := t.TempDir()
1820 t.Setenv("ZOT_HOME", home)
@@ -27,7 +29,7 @@ func TestExtInstallDotSource(t *testing.T) {
2729 if err := os.MkdirAll(src, 0o755); err != nil {
2830 t.Fatal(err)
2931 }
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 {
3133 t.Fatal(err)
3234 }
3335
@@ -50,10 +52,8 @@ func TestExtInstallDotSource(t *testing.T) {
5052 }
5153 }
5254
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.
5757 func TestExtInstallNamedDir(t *testing.T) {
5858 home := t.TempDir()
5959 t.Setenv("ZOT_HOME", home)
@@ -62,7 +62,7 @@ func TestExtInstallNamedDir(t *testing.T) {
6262 if err := os.MkdirAll(src, 0o755); err != nil {
6363 t.Fatal(err)
6464 }
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 {
6666 t.Fatal(err)
6767 }
6868
@@ -74,9 +74,277 @@ func TestExtInstallNamedDir(t *testing.T) {
7474 }
7575 }
7676
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+
80348 func TestCopyDirRespectsGitignore(t *testing.T) {
81349 src := t.TempDir()
82350 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 the15+// TestExtInstallDotSource verifies that `zot ext install .` resolves the
14-// extension name from the resolved directory name rather than collapsing16+// 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 normal56+// 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 directories77+func TestExtInstallThemeOnly(t *testing.T) {
78-// listed in the source .gitignore (e.g. .venv, node_modules) are not78+ 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) {
600600 // after it closes stdout before completing the hello handshake.
601601 const extensionExitGrace = time.Second
602602
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+
603620 // spawn launches the subprocess, hooks up pipes, logs stderr, and
604621 // runs the synchronous portion of the hello handshake. Asynchronous
605622 // frames are processed in a goroutine started here.
@@ -615,27 +632,9 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
615632 ext.logFile = logFile
616633 fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339))
617634
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)
639638 cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...)
640639 cmd.Dir = ext.Dir
641640 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.Second601 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, and620 // spawn launches the subprocess, hooks up pipes, logs stderr, and
604 // runs the synchronous portion of the hello handshake. Asynchronous621 // 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 = logFile632 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.Dir639 cmd.Dir = ext.Dir
641 cmd.Stderr = logFile640 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:
339339
340340 ```bash
341341 zot ext install ./weather # copy into $ZOT_HOME/extensions/
342+zot ext install --build=go ./weather # explicitly build local Go source
342343 zot --ext ./weather # run from disk for one zot session (no install)
343344 zot --ext . # cwd is the extension dir
344345 zot ext list # show installed extensions
@@ -352,9 +353,12 @@ zot ext remove weather
352353 For TS / Python extensions, no build step is needed — edit the source
353354 in place and relaunch zot.
354355
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.
358362
359363 ## Manual debug
360364
@@ -387,10 +391,10 @@ logged to `ext-<name>.log`.
387391 if they prefer JS-flavored ergonomics; **Python** for one-off
388392 scripts.
389393 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).
394398
395399 Don't try to write a full SDK or framework on top of the protocol
396400 unless the user asked for one — the wire format is small enough
@@ -339,6 +339,7 @@ for line in sys.stdin:
339 339
340 ```bash340 ```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 dir344 zot --ext . # cwd is the extension dir
344 zot ext list # show installed extensions345 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 source353 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 after356+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 debug363 ## Manual debug
360 364
@@ -387,10 +391,10 @@ logged to `ext-<name>.log`.
387 if they prefer JS-flavored ergonomics; **Python** for one-off391 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 an395+ 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 protocol399 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 enough400 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"
2727 // the HTML redirect) because the JSON response is stable and small.
2828 const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest"
2929
30+const devVersionNotice = "dev version detected; skipping update check"
31+
3032 // UpdateInfo describes the result of an update check. Zero-value means
3133 // "no update available, no error, don't show anything".
3234 type UpdateInfo struct {
@@ -36,6 +38,15 @@ type UpdateInfo struct {
3638 URL string // release page url for the changelog link
3739 }
3840
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+
3950 // updateCache is the on-disk structure written to $ZOT_HOME.
4051 type updateCache struct {
4152 CheckedAt time.Time `json:"checked_at"`
@@ -54,8 +65,9 @@ type updateCache struct {
5465 // banner renderer skips the display when Available is false, so a
5566 // network failure silently no-ops; we never block startup on this.
5667 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) {
5971 return UpdateInfo{}
6072 }
6173
@@ -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 means32 // 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 link38 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 a65 // 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 (
3434 // 5. Extracts the zot binary from the archive.
3535 // 6. Atomically replaces the running binary with the new one.
3636 //
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.
4040 func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) {
4141 if len(rawArgs) == 0 || rawArgs[0] != "update" {
4242 return false, nil
@@ -68,9 +68,8 @@ usage:
6868 notes:
6969 * The binary must be writable by the current user. On a system-wide
7070 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.
7473 * Honours $GITHUB_TOKEN if set, so private-repo releases work.
7574 * After the binary is installed, every extension under
7675 $ZOT_HOME/extensions/ that is a git checkout is fast-forward
@@ -87,8 +86,8 @@ notes:
8786 // download. Useful as a sanity probe and as something the user can
8887 // pipe into scripts.
8988 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)
9291 return nil
9392 }
9493 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -109,8 +108,8 @@ func runUpdateCheck(version string) error {
109108
110109 // runUpdate is the meat of `zot update`.
111110 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")
114113 }
115114 current := versionOnly(version)
116115
@@ -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 there37+// Refuses to operate on development builds because there is no meaningful
38-// is no meaningful "is newer" comparison and we'd happily downgrade a38+// "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, nil42 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-wide69 * 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 from71+ * Development builds are refused. They typically come from a local
72- a local source build and shouldn't be silently replaced with a72+ 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 under74 * After the binary is installed, every extension under
76 $ZOT_HOME/extensions/ that is a git checkout is fast-forward75 $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 can86 // 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 nil91 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 {
749749
750750 func checkZotfileRequirements(zf zotfileLoaded, version string) error {
751751 if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" {
752- if versionOnly(version) == "0.0.0" {
752+ if isDevVersion(version) {
753753 return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min)
754754 }
755755 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close