detached 5f266b1

Merge pull request #16 from bnema/fix/taskede-phases-install

feat: improve extension installation and development builds
commit
5f266b1951213db4168cafab7b40e2495a7e7038
author
bnema · · 2026-08-05 04:08 UTC
committer
GitHub · · 2026-08-05 04:08 UTC
merge of
f7d2559 + 2b65822 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
26 files · +1025 -226
M .github/workflows/ci.yml +2 -2
@@ -24,9 +24,9 @@ jobs:
2424 - uses: actions/checkout@v4
2525
2626 - name: set up go
27- uses: actions/setup-go@v5
27+ uses: actions/setup-go@v7
2828 with:
29- go-version: "1.23"
29+ go-version: "1.26"
3030 cache: true
3131
3232 - name: go vet
@@ -24,9 +24,9 @@ jobs:
24 - uses: actions/checkout@v424 - uses: actions/checkout@v4
25 25
26 - name: set up go26 - name: set up go
27- uses: actions/setup-go@v527+ uses: actions/setup-go@v7
28 with:28 with:
29- go-version: "1.23"29+ go-version: "1.26"
30 cache: true30 cache: true
31 31
32 - name: go vet32 - name: go vet
M .github/workflows/release.yml +2 -2
@@ -87,9 +87,9 @@ jobs:
8787 git push origin "${{ steps.bump.outputs.next }}"
8888
8989 - name: set up go
90- uses: actions/setup-go@v5
90+ uses: actions/setup-go@v7
9191 with:
92- go-version: "1.23"
92+ go-version: "1.26"
9393 cache: true
9494
9595 - name: run goreleaser
@@ -87,9 +87,9 @@ jobs:
87 git push origin "${{ steps.bump.outputs.next }}"87 git push origin "${{ steps.bump.outputs.next }}"
88 88
89 - name: set up go89 - name: set up go
90- uses: actions/setup-go@v590+ uses: actions/setup-go@v7
91 with:91 with:
92- go-version: "1.23"92+ go-version: "1.26"
93 cache: true93 cache: true
94 94
95 - name: run goreleaser95 - name: run goreleaser
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 +62 -19
@@ -1,36 +1,79 @@
1-# Local / untagged builds ship as 0.0.0. Release builds are driven by
2-# goreleaser which overrides VERSION from the git tag.
3-VERSION ?= 0.0.0
1+# Local and untagged builds report a development version. Release builds
2+# are driven by GoReleaser, which injects the Git tag and build metadata.
3+.DEFAULT_GOAL := help
4+
5+GO ?= go
6+PKG ?= ./cmd/zot
7+BINARY ?= zot
8+BIN_DIR ?= bin
9+DEV_VERSION ?= 0.0.0-dev
10+VERSION ?= $(DEV_VERSION)
11+GO_INSTALL_VERSION ?= latest
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 ./...
22- @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1)
52+fmt-check:
53+ @files="$$(gofmt -l . 2>&1)" || { printf '%s\n' "$$files" >&2; echo "gofmt failed" >&2; exit 1; }; \
54+ if [ -n "$$files" ]; then printf '%s\n' "$$files" >&2; echo "gofmt issues" >&2; exit 1; fi
55+
56+lint: vet fmt-check
57+
58+check: test-fast lint
2359
2460 fmt:
2561 gofmt -w .
2662
63+run: build
64+ "$(BIN_DIR)/$(BINARY)" $(ARGS)
65+
2766 clean:
28- rm -rf bin
67+ rm -rf "$(BIN_DIR)"
2968
3069 release:
31- @mkdir -p bin
32- GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-amd64 ./cmd/zot
33- GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-arm64 ./cmd/zot
34- GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-amd64 ./cmd/zot
35- GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-arm64 ./cmd/zot
36- GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-windows-amd64.exe ./cmd/zot
70+ @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ] || printf '%s\n' "$(VERSION)" | grep -Eq '(^|[.-])[0-9]{14}-[A-Za-z0-9]+'; then \
71+ echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \
72+ exit 1; \
73+ fi
74+ @mkdir -p "$(BIN_DIR)"
75+ GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-amd64" $(PKG)
76+ GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-arm64" $(PKG)
77+ GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-amd64" $(PKG)
78+ GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-arm64" $(PKG)
79+ GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-windows-amd64.exe" $(PKG)
@@ -1,36 +1,79 @@
1-# Local / untagged builds ship as 0.0.0. Release builds are driven 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 ./...53+ @files="$$(gofmt -l . 2>&1)" || { printf '%s\n' "$$files" >&2; echo "gofmt failed" >&2; exit 1; }; \
22- @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1)54+ if [ -n "$$files" ]; then printf '%s\n' "$$files" >&2; echo "gofmt issues" >&2; exit 1; fi
55+
56+lint: vet fmt-check
57+
58+check: test-fast lint
23 59
24 fmt:60 fmt:
25 gofmt -w .61 gofmt -w .
26 62
63+run: build
64+ "$(BIN_DIR)/$(BINARY)" $(ARGS)
65+
27 clean:66 clean:
28- rm -rf bin67+ rm -rf "$(BIN_DIR)"
29 68
30 release:69 release:
31- @mkdir -p bin70+ @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ] || printf '%s\n' "$(VERSION)" | grep -Eq '(^|[.-])[0-9]{14}-[A-Za-z0-9]+'; then \
32- GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-amd64 ./cmd/zot71+ 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/zot72+ exit 1; \
34- GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-amd64 ./cmd/zot73+ fi
35- GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-arm64 ./cmd/zot74+ @mkdir -p "$(BIN_DIR)"
36- GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-windows-amd64.exe ./cmd/zot75+ GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-amd64" $(PKG)
76+ GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-arm64" $(PKG)
77+ GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-amd64" $(PKG)
78+ GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-arm64" $(PKG)
79+ GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-windows-amd64.exe" $(PKG)
M README.md +24 -6
@@ -64,10 +64,16 @@ The installed binary reports the tagged module version and supports `zot update`
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 +13 -7
@@ -4,6 +4,7 @@ package main
44 import (
55 "fmt"
66 "os"
7+ "regexp"
78 "runtime/debug"
89 "strings"
910
@@ -15,10 +16,9 @@ import (
1516 // local builds. Defaults make `zot --version` print something sensible
1617 // when built without ldflags.
1718 var (
18- // 0.0.0 is the pre-release placeholder for local / untagged
19- // builds. The first published GitHub release will be tagged
20- // v0.0.1; everything before that ships as 0.0.0 from source.
21- version = "0.0.0"
19+ // 0.0.0-dev is the placeholder for local / untagged builds.
20+ // Release builds replace it with the Git tag through ldflags.
21+ version = "0.0.0-dev"
2222 commit = ""
2323 date = ""
2424 )
@@ -44,18 +44,24 @@ func main() {
4444
4545 // resolvedVersion falls back to the module version embedded by Go when zot is
4646 // installed with "go install ...@version". Release archives still use the
47-// version injected by GoReleaser, and local source builds remain 0.0.0.
47+// version injected by GoReleaser, and local source builds remain 0.0.0-dev.
4848 func resolvedVersion(linkedVersion string) string {
4949 info, _ := debug.ReadBuildInfo()
5050 return resolvedVersionFromBuildInfo(linkedVersion, info)
5151 }
5252
5353 func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string {
54- if linkedVersion != "" && linkedVersion != "0.0.0" {
54+ if linkedVersion != "" && linkedVersion != "0.0.0" && linkedVersion != "0.0.0-dev" {
5555 return linkedVersion
5656 }
57- if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" {
57+ if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" || isPseudoVersion(info.Main.Version) {
5858 return linkedVersion
5959 }
6060 return strings.TrimPrefix(info.Main.Version, "v")
6161 }
62+
63+var pseudoVersionPattern = regexp.MustCompile(`^v[0-9]+\.(0\.0-|[0-9]+\.[0-9]+-([^+]*\.)?0\.)[0-9]{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
64+
65+func isPseudoVersion(version string) bool {
66+ return pseudoVersionPattern.MatchString(version)
67+}
@@ -4,6 +4,7 @@ package main
4 import (4 import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7+ "regexp"
7 "runtime/debug"8 "runtime/debug"
8 "strings"9 "strings"
9 10
@@ -15,10 +16,9 @@ import (
15 // local builds. Defaults make `zot --version` print something sensible16 // local builds. Defaults make `zot --version` print something sensible
16 // when built without ldflags.17 // when built without ldflags.
17 var (18 var (
18- // 0.0.0 is the pre-release placeholder for local / untagged19+ // 0.0.0-dev is the placeholder for local / untagged builds.
19- // builds. The first published GitHub release will be tagged20+ // Release builds replace it with the Git tag through ldflags.
20- // v0.0.1; everything before that ships as 0.0.0 from source.21+ version = "0.0.0-dev"
21- version = "0.0.0"
22 commit = ""22 commit = ""
23 date = ""23 date = ""
24 )24 )
@@ -44,18 +44,24 @@ func main() {
44 44
45 // resolvedVersion falls back to the module version embedded by Go when zot is45 // resolvedVersion falls back to the module version embedded by Go when zot is
46 // installed with "go install ...@version". Release archives still use the46 // installed with "go install ...@version". Release archives still use the
47-// version injected by GoReleaser, and local source builds remain 0.0.0.47+// version injected by GoReleaser, and local source builds remain 0.0.0-dev.
48 func resolvedVersion(linkedVersion string) string {48 func resolvedVersion(linkedVersion string) string {
49 info, _ := debug.ReadBuildInfo()49 info, _ := debug.ReadBuildInfo()
50 return resolvedVersionFromBuildInfo(linkedVersion, info)50 return resolvedVersionFromBuildInfo(linkedVersion, info)
51 }51 }
52 52
53 func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string {53 func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) string {
54- if linkedVersion != "" && linkedVersion != "0.0.0" {54+ if linkedVersion != "" && linkedVersion != "0.0.0" && linkedVersion != "0.0.0-dev" {
55 return linkedVersion55 return linkedVersion
56 }56 }
57- if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" {57+ if info == nil || info.Main.Version == "" || info.Main.Version == "(devel)" || isPseudoVersion(info.Main.Version) {
58 return linkedVersion58 return linkedVersion
59 }59 }
60 return strings.TrimPrefix(info.Main.Version, "v")60 return strings.TrimPrefix(info.Main.Version, "v")
61 }61 }
62+
63+var pseudoVersionPattern = regexp.MustCompile(`^v[0-9]+\.(0\.0-|[0-9]+\.[0-9]+-([^+]*\.)?0\.)[0-9]{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
64+
65+func isPseudoVersion(version string) bool {
66+ return pseudoVersionPattern.MatchString(version)
67+}
M cmd/zot/main_test.go +41 -0
@@ -5,6 +5,29 @@ import (
55 "testing"
66 )
77
8+func TestIsPseudoVersion(t *testing.T) {
9+ for _, version := range []string{
10+ "v0.0.0-20260804145622-bce908a66c9a",
11+ "v0.2.94-0.20260804145622-bce908a66c9a",
12+ "v0.2.94-pre.0.20260804145622-bce908a66c9a",
13+ "v0.2.94-0.20260804145622-bce908a66c9a+incompatible",
14+ } {
15+ if !isPseudoVersion(version) {
16+ t.Errorf("isPseudoVersion(%q) = false, want true", version)
17+ }
18+ }
19+ for _, version := range []string{
20+ "v0.2.95-20260804145622-abcdef",
21+ "v0.2.94-0.2026080414562-bce908a66c9a",
22+ "v0.2.94-0.20260804145622",
23+ "0.2.94-0.20260804145622-bce908a66c9a",
24+ } {
25+ if isPseudoVersion(version) {
26+ t.Errorf("isPseudoVersion(%q) = true, want false", version)
27+ }
28+ }
29+}
30+
831 func TestResolvedVersionFromBuildInfo(t *testing.T) {
932 tests := []struct {
1033 name string
@@ -18,6 +41,18 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) {
1841 moduleVersion: "v0.2.94",
1942 want: "0.2.94",
2043 },
44+ {
45+ name: "go install release from dev placeholder",
46+ linkedVersion: "0.0.0-dev",
47+ moduleVersion: "v0.2.94",
48+ want: "0.2.94",
49+ },
50+ {
51+ name: "local pseudo-version keeps dev placeholder",
52+ linkedVersion: "0.0.0-dev",
53+ moduleVersion: "v0.2.94-0.20260804145622-bce908a66c9a",
54+ want: "0.0.0-dev",
55+ },
2156 {
2257 name: "release linker version wins",
2358 linkedVersion: "0.2.95",
@@ -30,6 +65,12 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) {
3065 moduleVersion: "(devel)",
3166 want: "0.0.0",
3267 },
68+ {
69+ name: "local dev build",
70+ linkedVersion: "0.0.0-dev",
71+ moduleVersion: "(devel)",
72+ want: "0.0.0-dev",
73+ },
3374 {
3475 name: "missing build info",
3576 linkedVersion: "0.0.0",
@@ -5,6 +5,29 @@ import (
5 "testing"5 "testing"
6 )6 )
7 7
8+func TestIsPseudoVersion(t *testing.T) {
9+ for _, version := range []string{
10+ "v0.0.0-20260804145622-bce908a66c9a",
11+ "v0.2.94-0.20260804145622-bce908a66c9a",
12+ "v0.2.94-pre.0.20260804145622-bce908a66c9a",
13+ "v0.2.94-0.20260804145622-bce908a66c9a+incompatible",
14+ } {
15+ if !isPseudoVersion(version) {
16+ t.Errorf("isPseudoVersion(%q) = false, want true", version)
17+ }
18+ }
19+ for _, version := range []string{
20+ "v0.2.95-20260804145622-abcdef",
21+ "v0.2.94-0.2026080414562-bce908a66c9a",
22+ "v0.2.94-0.20260804145622",
23+ "0.2.94-0.20260804145622-bce908a66c9a",
24+ } {
25+ if isPseudoVersion(version) {
26+ t.Errorf("isPseudoVersion(%q) = true, want false", version)
27+ }
28+ }
29+}
30+
8 func TestResolvedVersionFromBuildInfo(t *testing.T) {31 func TestResolvedVersionFromBuildInfo(t *testing.T) {
9 tests := []struct {32 tests := []struct {
10 name string33 name string
@@ -18,6 +41,18 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) {
18 moduleVersion: "v0.2.94",41 moduleVersion: "v0.2.94",
19 want: "0.2.94",42 want: "0.2.94",
20 },43 },
44+ {
45+ name: "go install release from dev placeholder",
46+ linkedVersion: "0.0.0-dev",
47+ moduleVersion: "v0.2.94",
48+ want: "0.2.94",
49+ },
50+ {
51+ name: "local pseudo-version keeps dev placeholder",
52+ linkedVersion: "0.0.0-dev",
53+ moduleVersion: "v0.2.94-0.20260804145622-bce908a66c9a",
54+ want: "0.0.0-dev",
55+ },
21 {56 {
22 name: "release linker version wins",57 name: "release linker version wins",
23 linkedVersion: "0.2.95",58 linkedVersion: "0.2.95",
@@ -30,6 +65,12 @@ func TestResolvedVersionFromBuildInfo(t *testing.T) {
30 moduleVersion: "(devel)",65 moduleVersion: "(devel)",
31 want: "0.0.0",66 want: "0.0.0",
32 },67 },
68+ {
69+ name: "local dev build",
70+ linkedVersion: "0.0.0-dev",
71+ moduleVersion: "(devel)",
72+ want: "0.0.0-dev",
73+ },
33 {74 {
34 name: "missing build info",75 name: "missing build info",
35 linkedVersion: "0.0.0",76 linkedVersion: "0.0.0",
M docs/extensions.md +28 -14
@@ -81,15 +81,26 @@ Restart `zot`, type `/hellopy`, the agent greets you. Done.
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
89-
90-# or load straight from the repo for one zot session
91-zot --ext path/to/zot/examples/extensions/hello
92-```
84+# install a Go example and explicitly build it in the staged install
85+zot ext install --build=go path/to/zot/examples/extensions/hello
86+
87+# or build it in the source tree for a one-run development load
88+cd path/to/zot/examples/extensions/hello
89+go build -o hello .
90+zot --ext .
91+```
92+
93+`zot ext install` never builds source code implicitly. For local installs it
94+validates `extension.json` and any executable path relative to the extension
95+directory, then stages the copy so a missing or ignored runtime artifact fails
96+with an explicit error instead of becoming a broken installed extension. For a
97+local Go extension, opt into the fixed builder explicitly with
98+`zot ext install --build=go <path>`. The builder runs `go build -trimpath` from
99+the source directory, writes the manifest-declared relative executable into the
100+staging directory, and validates it before installation. It may resolve Go
101+modules and access the network according to the user's Go environment. Bare
102+launchers such as `go`, `node`, and `npx` remain resolved from `PATH` when zot
103+starts; they cannot be used as the output of `--build=go`.
93104
94105 Nothing is auto-installed and nothing reaches out to the network without your explicit action.
95106
@@ -593,10 +604,11 @@ Reply with `shutdown_ack` and then exit.
593604 ## Managing extensions from the CLI
594605
595606 ```
596-zot ext list list installed extensions and their state
597-zot ext doctor diagnose load, registration, and conflict issues
598-zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
599-zot ext remove <name> delete an extension directory
607+zot ext list list installed extensions and their state
608+zot ext doctor diagnose load, registration, and conflict issues
609+zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
610+zot ext install --build=go <local-path> build local Go source, then install
611+zot ext remove <name> delete an extension directory
600612 zot ext enable <name> re-enable a disabled extension
601613 zot ext disable <name> disable without removing
602614 zot ext logs <name> [-f] cat / tail the extension's stderr
@@ -610,7 +622,9 @@ warnings, and each extension's stderr log path.
610622
611623 `zot ext install <path>` does a recursive copy; `<git-url>` does a
612624 shallow clone. Both validate that the destination contains an
613-`extension.json` and roll back if not.
625+`extension.json` and roll back if not. No build runs unless the local install
626+is invoked with the explicit `--build=go` option. That option currently supports
627+only local Go source paths; clone remote source locally before building it.
614628
615629 ## Loading an extension for one run
616630
@@ -81,15 +81,26 @@ Restart `zot`, type `/hellopy`, the agent greets you. Done.
81 The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those:81 The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those:
82 82
83 ```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-86+
87-# install (copies to $ZOT_HOME/extensions/hello/)87+# or build it in the source tree for a one-run development load
88-zot ext install path/to/zot/examples/extensions/hello88+cd path/to/zot/examples/extensions/hello
89-89+go build -o hello .
90-# or load straight from the repo for one zot session90+zot --ext .
91-zot --ext path/to/zot/examples/extensions/hello91+```
92-```92+
93+`zot ext install` never builds source code implicitly. For local installs it
94+validates `extension.json` and any executable path relative to the extension
95+directory, then stages the copy so a missing or ignored runtime artifact fails
96+with an explicit error instead of becoming a broken installed extension. For a
97+local Go extension, opt into the fixed builder explicitly with
98+`zot ext install --build=go <path>`. The builder runs `go build -trimpath` from
99+the source directory, writes the manifest-declared relative executable into the
100+staging directory, and validates it before installation. It may resolve Go
101+modules and access the network according to the user's Go environment. Bare
102+launchers such as `go`, `node`, and `npx` remain resolved from `PATH` when zot
103+starts; they cannot be used as the output of `--build=go`.
93 104
94 Nothing is auto-installed and nothing reaches out to the network without your explicit action.105 Nothing is auto-installed and nothing reaches out to the network without your explicit action.
95 106
@@ -593,10 +604,11 @@ Reply with `shutdown_ack` and then exit.
593 ## Managing extensions from the CLI604 ## Managing extensions from the CLI
594 605
595 ```606 ```
596-zot ext list list installed extensions and their state607+zot ext list list installed extensions and their state
597-zot ext doctor diagnose load, registration, and conflict issues608+zot ext doctor diagnose load, registration, and conflict issues
598-zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/609+zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
599-zot ext remove <name> delete an extension directory610+zot ext install --build=go <local-path> build local Go source, then install
611+zot ext remove <name> delete an extension directory
600 zot ext enable <name> re-enable a disabled extension612 zot ext enable <name> re-enable a disabled extension
601 zot ext disable <name> disable without removing613 zot ext disable <name> disable without removing
602 zot ext logs <name> [-f] cat / tail the extension's stderr614 zot ext logs <name> [-f] cat / tail the extension's stderr
@@ -610,7 +622,9 @@ warnings, and each extension's stderr log path.
610 622
611 `zot ext install <path>` does a recursive copy; `<git-url>` does a623 `zot ext install <path>` does a recursive copy; `<git-url>` does a
612 shallow clone. Both validate that the destination contains an624 shallow clone. Both validate that the destination contains an
613-`extension.json` and roll back if not.625+`extension.json` and roll back if not. No build runs unless the local install
626+is invoked with the explicit `--build=go` option. That option currently supports
627+only local Go source paths; clone remote source locally before building it.
614 628
615 ## Loading an extension for one run629 ## Loading an extension for one run
616 630
M examples/extensions/tasked-phases/README.md +6 -1
@@ -32,9 +32,14 @@ zot --no-ext --ext /path/to/zot/examples/extensions/tasked-phases
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 go.mod +1 -1
@@ -1,6 +1,6 @@
11 module github.com/patriceckhart/zot
22
3-go 1.25.0
3+go 1.26
44
55 require (
66 github.com/alecthomas/chroma/v2 v2.23.1
@@ -1,6 +1,6 @@
1 module github.com/patriceckhart/zot1 module github.com/patriceckhart/zot
2 2
3-go 1.25.03+go 1.26
4 4
5 require (5 require (
6 github.com/alecthomas/chroma/v2 v2.23.16 github.com/alecthomas/chroma/v2 v2.23.1
M packages/agent/args.go +2 -1
@@ -423,7 +423,8 @@ 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 <path|git-url>", "install from a local path or Git URL"},
427+ row{"zot ext install --build=go <path>", "build local Go source, then install"},
427428 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
428429 row{"zot ext help", "show all extension subcommands"},
429430 )
@@ -423,7 +423,8 @@ func PrintHelp(version string) {
423 )423 )
424 section("extensions",424 section("extensions",
425 row{"zot ext list", "list installed extensions"},425 row{"zot ext list", "list installed extensions"},
426- row{"zot ext install <path|url>", "install into $ZOT_HOME/extensions/"},426+ row{"zot ext install <path|git-url>", "install from a local path or Git URL"},
427+ row{"zot ext install --build=go <path>", "build local Go source, then install"},
427 row{"zot --ext ./path/to/ext", "load an extension for this run only"},428 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
428 row{"zot ext help", "show all extension subcommands"},429 row{"zot ext help", "show all extension subcommands"},
429 )430 )
M packages/agent/changelog.go +13 -23
@@ -36,23 +36,16 @@ func semverOnly(v string) string {
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 +62 -49
@@ -666,12 +666,22 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
666666
667667 // Run is the top-level entrypoint for the zot binary.
668668 func Run(rawArgs []string, version string) error {
669+ // Extension installs can invoke external build and clone processes.
670+ // Give those processes a cancellation context without changing signal
671+ // handling for the interactive modes below.
672+ extCtx := context.Background()
673+ stopExtContext := func() {}
674+ if len(rawArgs) > 0 && rawArgs[0] == "ext" {
675+ extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
676+ }
677+ defer stopExtContext()
678+
669679 // Subcommand router: `zot bot ...` is handled separately so the
670680 // generic flag parser doesn't reject "bot" as a positional arg.
671681 if handled, err := runBotCommand(rawArgs, version); handled {
672682 return err
673683 }
674- if handled, err := runExtCommand(rawArgs, version); handled {
684+ if handled, err := runExtCommand(extCtx, rawArgs, version); handled {
675685 return err
676686 }
677687 if handled, err := runUpdateCommand(rawArgs, version); handled {
@@ -1880,55 +1890,58 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18801890 return nil
18811891 }
18821892
1893+ devBuild := isDevVersion(version)
1894+ if devBuild {
1895+ fmt.Fprintln(os.Stderr, "zot:", devVersionNotice)
1896+ }
18831897 term := tui.NewProcTerm()
18841898
1885- // Kick off the async update check so the banner can appear when the
1886- // http response eventually arrives (usually <1s on cached DNS). Map
1887- // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
1888- updateCh := make(chan modes.UpdateInfo, 1)
1889- go func() {
1890- defer close(updateCh)
1891- src := <-CheckForUpdateAsync(ZotHome(), version)
1892- updateCh <- modes.UpdateInfo{
1893- Current: src.Current,
1894- Latest: src.Latest,
1895- Available: src.Available,
1896- URL: src.URL,
1897- }
1898- }()
1899+ var updateCh chan modes.UpdateInfo
1900+ var changelogCh chan modes.ChangelogPayload
1901+ if !devBuild {
1902+ // Kick off the async update check so the banner can appear when the
1903+ // http response eventually arrives (usually <1s on cached DNS). Map
1904+ // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
1905+ updateCh = make(chan modes.UpdateInfo, 1)
1906+ go func() {
1907+ defer close(updateCh)
1908+ src := <-CheckForUpdateAsync(ZotHome(), version)
1909+ updateCh <- modes.UpdateInfo{
1910+ Current: src.Current,
1911+ Latest: src.Latest,
1912+ Available: src.Available,
1913+ URL: src.URL,
1914+ }
1915+ }()
18991916
1900- // Changelog: when the running version differs from the last
1901- // version whose release notes the user dismissed, fetch the
1902- // release body from GitHub and have the TUI show it once. On
1903- // first-ever launch (no prior LastChangelogShown), seed the
1904- // stored version silently — don't dump release notes at someone
1905- // who just installed.
1906- changelogCh := make(chan modes.ChangelogPayload, 1)
1907- go func() {
1908- defer close(changelogCh)
1909- cfg, _ := LoadConfig()
1910- if cfg.LastChangelogShown == "" {
1911- SeedChangelogVersion(version)
1912- return
1913- }
1914- if !ShouldShowChangelog(version, cfg) {
1915- return
1916- }
1917- info := <-FetchChangelogAsync(version)
1918- if info.Body == "" {
1919- return
1920- }
1921- // For dev builds (0.0.0), skip if the latest release was
1922- // already shown (stored by the dismiss callback).
1923- if version == "0.0.0" && info.Version == cfg.LastChangelogShown {
1924- return
1925- }
1926- changelogCh <- modes.ChangelogPayload{
1927- Version: info.Version,
1928- Body: info.Body,
1929- URL: info.URL,
1930- }
1931- }()
1917+ // Changelog: when the running version differs from the last
1918+ // version whose release notes the user dismissed, fetch the
1919+ // release body from GitHub and have the TUI show it once. On
1920+ // first-ever launch (no prior LastChangelogShown), seed the
1921+ // stored version silently — don't dump release notes at someone
1922+ // who just installed.
1923+ changelogCh = make(chan modes.ChangelogPayload, 1)
1924+ go func() {
1925+ defer close(changelogCh)
1926+ cfg, _ := LoadConfig()
1927+ if cfg.LastChangelogShown == "" {
1928+ SeedChangelogVersion(version)
1929+ return
1930+ }
1931+ if !ShouldShowChangelog(version, cfg) {
1932+ return
1933+ }
1934+ info := <-FetchChangelogAsync(version)
1935+ if info.Body == "" {
1936+ return
1937+ }
1938+ changelogCh <- modes.ChangelogPayload{
1939+ Version: info.Version,
1940+ Body: info.Body,
1941+ URL: info.URL,
1942+ }
1943+ }()
1944+ }
19321945
19331946 initialCfg, _ := LoadConfig()
19341947 fastMode := r.FastMode
@@ -2105,11 +2118,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21052118 ResolveSubagent: resolveSubagent,
21062119 ChangelogChan: changelogCh,
21072120 OnChangelogDismiss: func() {
2108- // For dev builds (0.0.0) store the actual release version
2121+ // For development builds, store the actual release version
21092122 // so the same changelog doesn't show again next launch.
21102123 // For real builds, store the binary version.
21112124 v := version
2112- if v == "0.0.0" {
2125+ if devBuild {
21132126 if iv != nil && iv.ChangelogVersion() != "" {
21142127 v = iv.ChangelogVersion()
21152128 }
@@ -666,12 +666,22 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
666 666
667 // Run is the top-level entrypoint for the zot binary.667 // Run is the top-level entrypoint for the zot binary.
668 func Run(rawArgs []string, version string) error {668 func Run(rawArgs []string, version string) error {
669+ // Extension installs can invoke external build and clone processes.
670+ // Give those processes a cancellation context without changing signal
671+ // handling for the interactive modes below.
672+ extCtx := context.Background()
673+ stopExtContext := func() {}
674+ if len(rawArgs) > 0 && rawArgs[0] == "ext" {
675+ extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
676+ }
677+ defer stopExtContext()
678+
669 // Subcommand router: `zot bot ...` is handled separately so the679 // Subcommand router: `zot bot ...` is handled separately so the
670 // generic flag parser doesn't reject "bot" as a positional arg.680 // generic flag parser doesn't reject "bot" as a positional arg.
671 if handled, err := runBotCommand(rawArgs, version); handled {681 if handled, err := runBotCommand(rawArgs, version); handled {
672 return err682 return err
673 }683 }
674- if handled, err := runExtCommand(rawArgs, version); handled {684+ if handled, err := runExtCommand(extCtx, rawArgs, version); handled {
675 return err685 return err
676 }686 }
677 if handled, err := runUpdateCommand(rawArgs, version); handled {687 if handled, err := runUpdateCommand(rawArgs, version); handled {
@@ -1880,55 +1890,58 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1880 return nil1890 return nil
1881 }1891 }
1882 1892
1893+ devBuild := isDevVersion(version)
1894+ if devBuild {
1895+ fmt.Fprintln(os.Stderr, "zot:", devVersionNotice)
1896+ }
1883 term := tui.NewProcTerm()1897 term := tui.NewProcTerm()
1884 1898
1885- // Kick off the async update check so the banner can appear when the1899+ var updateCh chan modes.UpdateInfo
1886- // http response eventually arrives (usually <1s on cached DNS). Map1900+ var changelogCh chan modes.ChangelogPayload
1887- // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.1901+ if !devBuild {
1888- updateCh := make(chan modes.UpdateInfo, 1)1902+ // Kick off the async update check so the banner can appear when the
1889- go func() {1903+ // http response eventually arrives (usually <1s on cached DNS). Map
1890- defer close(updateCh)1904+ // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
1891- src := <-CheckForUpdateAsync(ZotHome(), version)1905+ updateCh = make(chan modes.UpdateInfo, 1)
1892- updateCh <- modes.UpdateInfo{1906+ go func() {
1893- Current: src.Current,1907+ defer close(updateCh)
1894- Latest: src.Latest,1908+ src := <-CheckForUpdateAsync(ZotHome(), version)
1895- Available: src.Available,1909+ updateCh <- modes.UpdateInfo{
1896- URL: src.URL,1910+ Current: src.Current,
1897- }1911+ Latest: src.Latest,
1898- }()1912+ Available: src.Available,
1913+ URL: src.URL,
1914+ }
1915+ }()
1899 1916
1900- // Changelog: when the running version differs from the last1917+ // Changelog: when the running version differs from the last
1901- // version whose release notes the user dismissed, fetch the1918+ // version whose release notes the user dismissed, fetch the
1902- // release body from GitHub and have the TUI show it once. On1919+ // release body from GitHub and have the TUI show it once. On
1903- // first-ever launch (no prior LastChangelogShown), seed the1920+ // first-ever launch (no prior LastChangelogShown), seed the
1904- // stored version silently — don't dump release notes at someone1921+ // stored version silently — don't dump release notes at someone
1905- // who just installed.1922+ // who just installed.
1906- changelogCh := make(chan modes.ChangelogPayload, 1)1923+ changelogCh = make(chan modes.ChangelogPayload, 1)
1907- go func() {1924+ go func() {
1908- defer close(changelogCh)1925+ defer close(changelogCh)
1909- cfg, _ := LoadConfig()1926+ cfg, _ := LoadConfig()
1910- if cfg.LastChangelogShown == "" {1927+ if cfg.LastChangelogShown == "" {
1911- SeedChangelogVersion(version)1928+ SeedChangelogVersion(version)
1912- return1929+ return
1913- }1930+ }
1914- if !ShouldShowChangelog(version, cfg) {1931+ if !ShouldShowChangelog(version, cfg) {
1915- return1932+ return
1916- }1933+ }
1917- info := <-FetchChangelogAsync(version)1934+ info := <-FetchChangelogAsync(version)
1918- if info.Body == "" {1935+ if info.Body == "" {
1919- return1936+ return
1920- }1937+ }
1921- // For dev builds (0.0.0), skip if the latest release was1938+ changelogCh <- modes.ChangelogPayload{
1922- // already shown (stored by the dismiss callback).1939+ Version: info.Version,
1923- if version == "0.0.0" && info.Version == cfg.LastChangelogShown {1940+ Body: info.Body,
1924- return1941+ URL: info.URL,
1925- }1942+ }
1926- changelogCh <- modes.ChangelogPayload{1943+ }()
1927- Version: info.Version,1944+ }
1928- Body: info.Body,
1929- URL: info.URL,
1930- }
1931- }()
1932 1945
1933 initialCfg, _ := LoadConfig()1946 initialCfg, _ := LoadConfig()
1934 fastMode := r.FastMode1947 fastMode := r.FastMode
@@ -2105,11 +2118,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2105 ResolveSubagent: resolveSubagent,2118 ResolveSubagent: resolveSubagent,
2106 ChangelogChan: changelogCh,2119 ChangelogChan: changelogCh,
2107 OnChangelogDismiss: func() {2120 OnChangelogDismiss: func() {
2108- // For dev builds (0.0.0) store the actual release version2121+ // For development builds, store the actual release version
2109 // so the same changelog doesn't show again next launch.2122 // so the same changelog doesn't show again next launch.
2110 // For real builds, store the binary version.2123 // For real builds, store the binary version.
2111 v := version2124 v := version
2112- if v == "0.0.0" {2125+ if devBuild {
2113 if iv != nil && iv.ChangelogVersion() != "" {2126 if iv != nil && iv.ChangelogVersion() != "" {
2114 v = iv.ChangelogVersion()2127 v = iv.ChangelogVersion()
2115 }2128 }
M packages/agent/extcmd.go +284 -42
@@ -3,11 +3,13 @@ package agent
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"
@@ -21,7 +23,7 @@ import (
2123 // (handled=true, err) if rawArgs starts with "ext"; otherwise
2224 // (handled=false, nil) so the main router falls through to the
2325 // regular flag parser.
24-func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
26+func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) {
2527 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
2628 return false, nil
2729 }
@@ -43,7 +45,7 @@ func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
4345 case "remove", "rm":
4446 return true, extRemove(rawArgs[2:])
4547 case "install":
46- return true, extInstall(rawArgs[2:])
48+ return true, extInstallContext(ctx, rawArgs[2:])
4749 case "help", "-h", "--help":
4850 printExtHelp()
4951 return true, nil
@@ -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)
@@ -88,7 +92,7 @@ func extList() error {
8892 continue
8993 }
9094 for _, e := range entries {
91- if !e.IsDir() {
95+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
9296 continue
9397 }
9498 extDir := filepath.Join(dir, e.Name())
@@ -219,7 +223,7 @@ func scanExtDoctorStatic() []extDoctorStaticRow {
219223 }
220224 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
221225 for _, e := range entries {
222- if !e.IsDir() {
226+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
223227 continue
224228 }
225229 extDir := filepath.Join(sd.dir, e.Name())
@@ -464,37 +468,30 @@ 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+ return extInstallContext(context.Background(), args)
477+}
478+
479+func extInstallContext(ctx context.Context, args []string) error {
480+ opts, err := parseExtInstallArgs(args)
481+ if err != nil {
482+ return err
472483 }
473- src := args[0]
484+ src := opts.source
474485 dest := filepath.Join(ZotHome(), "extensions")
475486 if err := os.MkdirAll(dest, 0o755); err != nil {
476487 return err
477488 }
478489
479490 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
480- // Git clone path. Pick the destination name from the repo basename.
481- name := strings.TrimSuffix(filepath.Base(src), ".git")
482- out := filepath.Join(dest, name)
483- if _, err := os.Stat(out); err == nil {
484- return fmt.Errorf("destination %s already exists; remove it first", out)
485- }
486- cmd := exec.Command("git", "clone", "--depth", "1", src, out)
487- cmd.Stdout = os.Stderr
488- cmd.Stderr = os.Stderr
489- if err := cmd.Run(); err != nil {
490- return fmt.Errorf("git clone: %w", err)
491- }
492- if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
493- _ = os.RemoveAll(out)
494- return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back")
491+ if opts.builder != "" {
492+ return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")
495493 }
496- fmt.Fprintf(os.Stderr, "installed %s\n", out)
497- return nil
494+ return installGitExtension(ctx, src, dest)
498495 }
499496
500497 // Local path: must be a directory containing extension.json.
@@ -505,33 +502,257 @@ func extInstall(args []string) error {
505502 if !info.IsDir() {
506503 return fmt.Errorf("not a directory: %s", src)
507504 }
508- if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
509- return fmt.Errorf("source lacks extension.json")
510- }
511- // Resolve to an absolute, cleaned path before deriving the install
512- // name. Otherwise relative sources like "." or "./" collapse to a
513- // basename of ".", and the destination wrongly resolves to the
514- // extensions/ parent directory (which zot creates on first run),
515- // triggering a false "already exists" failure.
505+ // Resolve to an absolute, cleaned path before reading the manifest.
506+ // This also makes sources like "." safe to install.
516507 absSrc, err := filepath.Abs(src)
517508 if err != nil {
518509 return err
519510 }
520- name := filepath.Base(absSrc)
521- if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
522- return fmt.Errorf("cannot derive extension name from %q", src)
511+ manifest, err := readExtensionManifest(absSrc)
512+ if err != nil {
513+ return fmt.Errorf("source extension: %w", err)
514+ }
515+ out := filepath.Join(dest, manifest.Name)
516+ if _, err := os.Stat(out); err == nil {
517+ return fmt.Errorf("destination %s already exists; remove it first", out)
518+ }
519+
520+ stageRoot, err := os.MkdirTemp(dest, ".zot-extension-install-")
521+ if err != nil {
522+ return fmt.Errorf("create install staging directory: %w", err)
523+ }
524+ defer os.RemoveAll(stageRoot)
525+ staged := filepath.Join(stageRoot, "extension")
526+ if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil {
527+ return fmt.Errorf("copy extension: %w", err)
528+ }
529+ if opts.builder != "" {
530+ if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil {
531+ return err
532+ }
533+ }
534+ if err := validateExtensionExecutable(staged, manifest); err != nil {
535+ return err
536+ }
537+ if err := os.Rename(staged, out); err != nil {
538+ return fmt.Errorf("finalize install: %w", err)
539+ }
540+ fmt.Fprintf(os.Stderr, "installed %s\n", out)
541+ return nil
542+}
543+
544+type extInstallOptions struct {
545+ source string
546+ builder string
547+}
548+
549+func parseExtInstallArgs(args []string) (extInstallOptions, error) {
550+ if len(args) == 0 {
551+ return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>")
552+ }
553+
554+ var opts extInstallOptions
555+ builderSet := false
556+ for i := 0; i < len(args); i++ {
557+ arg := args[i]
558+ switch {
559+ case arg == "--build":
560+ if i+1 >= len(args) {
561+ return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
562+ }
563+ i++
564+ if builderSet {
565+ return extInstallOptions{}, errors.New("extension builder specified more than once")
566+ }
567+ builderSet = true
568+ opts.builder = strings.ToLower(strings.TrimSpace(args[i]))
569+ if opts.builder == "" {
570+ return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
571+ }
572+ case strings.HasPrefix(arg, "--build="):
573+ if builderSet {
574+ return extInstallOptions{}, errors.New("extension builder specified more than once")
575+ }
576+ builderSet = true
577+ opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build=")))
578+ if opts.builder == "" {
579+ return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
580+ }
581+ case strings.HasPrefix(arg, "-"):
582+ return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg)
583+ case opts.source != "":
584+ return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>")
585+ default:
586+ opts.source = arg
587+ }
588+ }
589+ if opts.source == "" {
590+ return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>")
591+ }
592+ if opts.builder != "" && opts.builder != "go" {
593+ return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder)
594+ }
595+ return opts, nil
596+}
597+
598+func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error {
599+ if manifest.Exec == "" {
600+ return errors.New("cannot build a theme-only extension; it does not declare an executable")
601+ }
602+ execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec)
603+ if err != nil {
604+ return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err)
605+ }
606+ stagedExec := filepath.Join(stagedDir, execRel)
607+ if err := os.MkdirAll(filepath.Dir(stagedExec), 0o755); err != nil {
608+ return fmt.Errorf("prepare Go build output: %w", err)
609+ }
610+
611+ fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)
612+ cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".")
613+ cmd.Dir = sourceDir
614+ cmd.Stdout = os.Stderr
615+ cmd.Stderr = os.Stderr
616+ if err := cmd.Run(); err != nil {
617+ return fmt.Errorf("build extension with go: %w", err)
618+ }
619+ return nil
620+}
621+
622+func extensionRelativeExecPath(dir, execName string) (string, error) {
623+ normalized := filepath.FromSlash(execName)
624+ if filepath.IsAbs(normalized) {
625+ return "", fmt.Errorf("extension executable %q is absolute", execName)
626+ }
627+ resolved, local := extensions.ResolveExecPath(dir, execName)
628+ if !local {
629+ return "", fmt.Errorf("extension executable %q is a PATH command", execName)
630+ }
631+ rel, err := filepath.Rel(dir, resolved)
632+ if err != nil {
633+ return "", fmt.Errorf("resolve extension executable %q: %w", execName, err)
634+ }
635+ if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
636+ return "", fmt.Errorf("extension executable %q escapes the extension directory", execName)
637+ }
638+ return rel, nil
639+}
640+
641+func installGitExtension(ctx context.Context, src, dest string) error {
642+ stageRoot, err := os.MkdirTemp(dest, ".zot-extension-clone-")
643+ if err != nil {
644+ return fmt.Errorf("create install staging directory: %w", err)
645+ }
646+ defer os.RemoveAll(stageRoot)
647+
648+ cloneDir := filepath.Join(stageRoot, "extension")
649+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir)
650+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
651+ cmd.Stdout = os.Stderr
652+ cmd.Stderr = os.Stderr
653+ if err := cmd.Run(); err != nil {
654+ return fmt.Errorf("git clone: %w", err)
523655 }
524- out := filepath.Join(dest, name)
656+ manifest, err := readExtensionManifest(cloneDir)
657+ if err != nil {
658+ return fmt.Errorf("cloned extension: %w", err)
659+ }
660+ out := filepath.Join(dest, manifest.Name)
525661 if _, err := os.Stat(out); err == nil {
526662 return fmt.Errorf("destination %s already exists; remove it first", out)
527663 }
528- if err := copyDir(absSrc, out); err != nil {
664+ if err := validateExtensionExecutable(cloneDir, manifest); err != nil {
529665 return err
530666 }
667+ if err := os.Rename(cloneDir, out); err != nil {
668+ return fmt.Errorf("finalize install: %w", err)
669+ }
531670 fmt.Fprintf(os.Stderr, "installed %s\n", out)
532671 return nil
533672 }
534673
674+func readExtensionManifest(dir string) (extensions.Manifest, error) {
675+ raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
676+ if err != nil {
677+ if os.IsNotExist(err) {
678+ return extensions.Manifest{}, errors.New("source lacks extension.json")
679+ }
680+ return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err)
681+ }
682+ var manifest extensions.Manifest
683+ if err := json.Unmarshal(raw, &manifest); err != nil {
684+ return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err)
685+ }
686+ if strings.TrimSpace(manifest.Name) == "" {
687+ return extensions.Manifest{}, errors.New("manifest: name is required")
688+ }
689+ if manifest.Name != strings.TrimSpace(manifest.Name) {
690+ return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)
691+ }
692+ if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) {
693+ return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
694+ }
695+ if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {
696+ return extensions.Manifest{}, errors.New("manifest: exec is required")
697+ }
698+ if manifest.Exec != "" {
699+ resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
700+ if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
701+ rel, err := filepath.Rel(dir, resolved)
702+ if err != nil {
703+ return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err)
704+ }
705+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
706+ return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec)
707+ }
708+ }
709+ }
710+ return manifest, nil
711+}
712+
713+func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool {
714+ required := map[string]bool{"extension.json": true}
715+ if manifest.Exec == "" {
716+ return required
717+ }
718+ resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
719+ if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
720+ return required
721+ }
722+ rel, err := filepath.Rel(dir, resolved)
723+ if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
724+ required[filepath.ToSlash(rel)] = true
725+ }
726+ return required
727+}
728+
729+func validateExtensionExecutable(dir string, manifest extensions.Manifest) error {
730+ if manifest.Exec == "" {
731+ return nil
732+ }
733+ path, local := extensions.ResolveExecPath(dir, manifest.Exec)
734+ if !local {
735+ // Bare commands such as "go", "node", and "npx" are resolved
736+ // from PATH when zot starts. They may be intentionally installed
737+ // before the runtime is present on the current machine.
738+ return nil
739+ }
740+ info, err := os.Stat(path)
741+ if err != nil {
742+ if os.IsNotExist(err) {
743+ return fmt.Errorf("extension executable %q is missing; provide it before installing. Local Go extensions can opt in with `zot ext install --build=go <path>`", manifest.Exec)
744+ }
745+ return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err)
746+ }
747+ if info.IsDir() {
748+ return fmt.Errorf("extension executable %q is a directory", manifest.Exec)
749+ }
750+ if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
751+ return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec)
752+ }
753+ return nil
754+}
755+
535756 func extensionDirs() map[string]string {
536757 out := map[string]string{}
537758 if h := ZotHome(); h != "" {
@@ -569,6 +790,10 @@ func dashIfEmpty(s string) string {
569790 // out of the installed copy so the extension stays functional at its new
570791 // location.
571792 func copyDir(src, dst string) error {
793+ return copyDirWithRequired(src, dst, nil)
794+}
795+
796+func copyDirWithRequired(src, dst string, required map[string]bool) error {
572797 ig := loadGitignore(src)
573798 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
574799 if err != nil {
@@ -578,12 +803,13 @@ func copyDir(src, dst string) error {
578803 if err != nil {
579804 return err
580805 }
806+ relSlash := filepath.ToSlash(rel)
581807 if rel != "." {
582808 name := filepath.Base(rel)
583809 if info.IsDir() && name == ".git" {
584810 return filepath.SkipDir
585811 }
586- if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
812+ if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) {
587813 if info.IsDir() {
588814 return filepath.SkipDir
589815 }
@@ -609,6 +835,22 @@ func copyDir(src, dst string) error {
609835 })
610836 }
611837
838+func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool {
839+ if required[rel] {
840+ return true
841+ }
842+ if !isDir {
843+ return false
844+ }
845+ prefix := strings.TrimSuffix(rel, "/") + "/"
846+ for path := range required {
847+ if strings.HasPrefix(path, prefix) {
848+ return true
849+ }
850+ }
851+ return false
852+}
853+
612854 // gitignore matching lives in packages/ignore so the @-file picker in
613855 // packages/agent/modes can share it without an import cycle. These
614856 // thin aliases keep the existing call sites (and tests) terse.
@@ -3,11 +3,13 @@ package agent
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "os"9 "os"
9 "os/exec"10 "os/exec"
10 "path/filepath"11 "path/filepath"
12+ "runtime"
11 "sort"13 "sort"
12 "strings"14 "strings"
13 "time"15 "time"
@@ -21,7 +23,7 @@ import (
21 // (handled=true, err) if rawArgs starts with "ext"; otherwise23 // (handled=true, err) if rawArgs starts with "ext"; otherwise
22 // (handled=false, nil) so the main router falls through to the24 // (handled=false, nil) so the main router falls through to the
23 // regular flag parser.25 // regular flag parser.
24-func runExtCommand(rawArgs []string, version string) (handled bool, err error) {26+func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) {
25 if len(rawArgs) == 0 || rawArgs[0] != "ext" {27 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
26 return false, nil28 return false, nil
27 }29 }
@@ -43,7 +45,7 @@ func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
43 case "remove", "rm":45 case "remove", "rm":
44 return true, extRemove(rawArgs[2:])46 return true, extRemove(rawArgs[2:])
45 case "install":47 case "install":
46- return true, extInstall(rawArgs[2:])48+ return true, extInstallContext(ctx, rawArgs[2:])
47 case "help", "-h", "--help":49 case "help", "-h", "--help":
48 printExtHelp()50 printExtHelp()
49 return true, nil51 return true, nil
@@ -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)
@@ -88,7 +92,7 @@ func extList() error {
88 continue92 continue
89 }93 }
90 for _, e := range entries {94 for _, e := range entries {
91- if !e.IsDir() {95+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
92 continue96 continue
93 }97 }
94 extDir := filepath.Join(dir, e.Name())98 extDir := filepath.Join(dir, e.Name())
@@ -219,7 +223,7 @@ func scanExtDoctorStatic() []extDoctorStaticRow {
219 }223 }
220 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })224 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
221 for _, e := range entries {225 for _, e := range entries {
222- if !e.IsDir() {226+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
223 continue227 continue
224 }228 }
225 extDir := filepath.Join(sd.dir, e.Name())229 extDir := filepath.Join(sd.dir, e.Name())
@@ -464,37 +468,30 @@ func extRemove(args []string) error {
464 }468 }
465 469
466 // extInstall copies a local directory or shallow-clones a git 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+ return extInstallContext(context.Background(), args)
471- return fmt.Errorf("usage: zot ext install <path|git-url>")477+}
478+
479+func extInstallContext(ctx context.Context, args []string) error {
480+ opts, err := parseExtInstallArgs(args)
481+ if err != nil {
482+ return err
472 }483 }
473- src := args[0]484+ src := opts.source
474 dest := filepath.Join(ZotHome(), "extensions")485 dest := filepath.Join(ZotHome(), "extensions")
475 if err := os.MkdirAll(dest, 0o755); err != nil {486 if err := os.MkdirAll(dest, 0o755); err != nil {
476 return err487 return err
477 }488 }
478 489
479 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {490 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
480- // Git clone path. Pick the destination name from the repo basename.491+ if opts.builder != "" {
481- name := strings.TrimSuffix(filepath.Base(src), ".git")492+ return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")
482- out := filepath.Join(dest, name)
483- if _, err := os.Stat(out); err == nil {
484- return fmt.Errorf("destination %s already exists; remove it first", out)
485- }
486- cmd := exec.Command("git", "clone", "--depth", "1", src, out)
487- cmd.Stdout = os.Stderr
488- cmd.Stderr = os.Stderr
489- if err := cmd.Run(); err != nil {
490- return fmt.Errorf("git clone: %w", err)
491- }
492- if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
493- _ = os.RemoveAll(out)
494- return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back")
495 }493 }
496- fmt.Fprintf(os.Stderr, "installed %s\n", out)494+ return installGitExtension(ctx, src, dest)
497- return nil
498 }495 }
499 496
500 // Local path: must be a directory containing extension.json.497 // Local path: must be a directory containing extension.json.
@@ -505,33 +502,257 @@ func extInstall(args []string) error {
505 if !info.IsDir() {502 if !info.IsDir() {
506 return fmt.Errorf("not a directory: %s", src)503 return fmt.Errorf("not a directory: %s", src)
507 }504 }
508- if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {505+ // Resolve to an absolute, cleaned path before reading the manifest.
509- return fmt.Errorf("source lacks extension.json")506+ // This also makes sources like "." safe to install.
510- }
511- // Resolve to an absolute, cleaned path before deriving the install
512- // name. Otherwise relative sources like "." or "./" collapse to a
513- // basename of ".", and the destination wrongly resolves to the
514- // extensions/ parent directory (which zot creates on first run),
515- // triggering a false "already exists" failure.
516 absSrc, err := filepath.Abs(src)507 absSrc, err := filepath.Abs(src)
517 if err != nil {508 if err != nil {
518 return err509 return err
519 }510 }
520- name := filepath.Base(absSrc)511+ manifest, err := readExtensionManifest(absSrc)
521- if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {512+ if err != nil {
522- return fmt.Errorf("cannot derive extension name from %q", src)513+ return fmt.Errorf("source extension: %w", err)
514+ }
515+ out := filepath.Join(dest, manifest.Name)
516+ if _, err := os.Stat(out); err == nil {
517+ return fmt.Errorf("destination %s already exists; remove it first", out)
518+ }
519+
520+ stageRoot, err := os.MkdirTemp(dest, ".zot-extension-install-")
521+ if err != nil {
522+ return fmt.Errorf("create install staging directory: %w", err)
523+ }
524+ defer os.RemoveAll(stageRoot)
525+ staged := filepath.Join(stageRoot, "extension")
526+ if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil {
527+ return fmt.Errorf("copy extension: %w", err)
528+ }
529+ if opts.builder != "" {
530+ if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil {
531+ return err
532+ }
533+ }
534+ if err := validateExtensionExecutable(staged, manifest); err != nil {
535+ return err
536+ }
537+ if err := os.Rename(staged, out); err != nil {
538+ return fmt.Errorf("finalize install: %w", err)
539+ }
540+ fmt.Fprintf(os.Stderr, "installed %s\n", out)
541+ return nil
542+}
543+
544+type extInstallOptions struct {
545+ source string
546+ builder string
547+}
548+
549+func parseExtInstallArgs(args []string) (extInstallOptions, error) {
550+ if len(args) == 0 {
551+ return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>")
552+ }
553+
554+ var opts extInstallOptions
555+ builderSet := false
556+ for i := 0; i < len(args); i++ {
557+ arg := args[i]
558+ switch {
559+ case arg == "--build":
560+ if i+1 >= len(args) {
561+ return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
562+ }
563+ i++
564+ if builderSet {
565+ return extInstallOptions{}, errors.New("extension builder specified more than once")
566+ }
567+ builderSet = true
568+ opts.builder = strings.ToLower(strings.TrimSpace(args[i]))
569+ if opts.builder == "" {
570+ return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
571+ }
572+ case strings.HasPrefix(arg, "--build="):
573+ if builderSet {
574+ return extInstallOptions{}, errors.New("extension builder specified more than once")
575+ }
576+ builderSet = true
577+ opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build=")))
578+ if opts.builder == "" {
579+ return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
580+ }
581+ case strings.HasPrefix(arg, "-"):
582+ return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg)
583+ case opts.source != "":
584+ return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>")
585+ default:
586+ opts.source = arg
587+ }
588+ }
589+ if opts.source == "" {
590+ return extInstallOptions{}, errors.New("usage: zot ext install [--build=go] <path|git-url>")
591+ }
592+ if opts.builder != "" && opts.builder != "go" {
593+ return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder)
594+ }
595+ return opts, nil
596+}
597+
598+func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error {
599+ if manifest.Exec == "" {
600+ return errors.New("cannot build a theme-only extension; it does not declare an executable")
601+ }
602+ execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec)
603+ if err != nil {
604+ return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err)
605+ }
606+ stagedExec := filepath.Join(stagedDir, execRel)
607+ if err := os.MkdirAll(filepath.Dir(stagedExec), 0o755); err != nil {
608+ return fmt.Errorf("prepare Go build output: %w", err)
609+ }
610+
611+ fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)
612+ cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".")
613+ cmd.Dir = sourceDir
614+ cmd.Stdout = os.Stderr
615+ cmd.Stderr = os.Stderr
616+ if err := cmd.Run(); err != nil {
617+ return fmt.Errorf("build extension with go: %w", err)
618+ }
619+ return nil
620+}
621+
622+func extensionRelativeExecPath(dir, execName string) (string, error) {
623+ normalized := filepath.FromSlash(execName)
624+ if filepath.IsAbs(normalized) {
625+ return "", fmt.Errorf("extension executable %q is absolute", execName)
626+ }
627+ resolved, local := extensions.ResolveExecPath(dir, execName)
628+ if !local {
629+ return "", fmt.Errorf("extension executable %q is a PATH command", execName)
630+ }
631+ rel, err := filepath.Rel(dir, resolved)
632+ if err != nil {
633+ return "", fmt.Errorf("resolve extension executable %q: %w", execName, err)
634+ }
635+ if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
636+ return "", fmt.Errorf("extension executable %q escapes the extension directory", execName)
637+ }
638+ return rel, nil
639+}
640+
641+func installGitExtension(ctx context.Context, src, dest string) error {
642+ stageRoot, err := os.MkdirTemp(dest, ".zot-extension-clone-")
643+ if err != nil {
644+ return fmt.Errorf("create install staging directory: %w", err)
645+ }
646+ defer os.RemoveAll(stageRoot)
647+
648+ cloneDir := filepath.Join(stageRoot, "extension")
649+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir)
650+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
651+ cmd.Stdout = os.Stderr
652+ cmd.Stderr = os.Stderr
653+ if err := cmd.Run(); err != nil {
654+ return fmt.Errorf("git clone: %w", err)
523 }655 }
524- out := filepath.Join(dest, name)656+ manifest, err := readExtensionManifest(cloneDir)
657+ if err != nil {
658+ return fmt.Errorf("cloned extension: %w", err)
659+ }
660+ out := filepath.Join(dest, manifest.Name)
525 if _, err := os.Stat(out); err == nil {661 if _, err := os.Stat(out); err == nil {
526 return fmt.Errorf("destination %s already exists; remove it first", out)662 return fmt.Errorf("destination %s already exists; remove it first", out)
527 }663 }
528- if err := copyDir(absSrc, out); err != nil {664+ if err := validateExtensionExecutable(cloneDir, manifest); err != nil {
529 return err665 return err
530 }666 }
667+ if err := os.Rename(cloneDir, out); err != nil {
668+ return fmt.Errorf("finalize install: %w", err)
669+ }
531 fmt.Fprintf(os.Stderr, "installed %s\n", out)670 fmt.Fprintf(os.Stderr, "installed %s\n", out)
532 return nil671 return nil
533 }672 }
534 673
674+func readExtensionManifest(dir string) (extensions.Manifest, error) {
675+ raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
676+ if err != nil {
677+ if os.IsNotExist(err) {
678+ return extensions.Manifest{}, errors.New("source lacks extension.json")
679+ }
680+ return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err)
681+ }
682+ var manifest extensions.Manifest
683+ if err := json.Unmarshal(raw, &manifest); err != nil {
684+ return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err)
685+ }
686+ if strings.TrimSpace(manifest.Name) == "" {
687+ return extensions.Manifest{}, errors.New("manifest: name is required")
688+ }
689+ if manifest.Name != strings.TrimSpace(manifest.Name) {
690+ return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)
691+ }
692+ if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) {
693+ return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
694+ }
695+ if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {
696+ return extensions.Manifest{}, errors.New("manifest: exec is required")
697+ }
698+ if manifest.Exec != "" {
699+ resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
700+ if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
701+ rel, err := filepath.Rel(dir, resolved)
702+ if err != nil {
703+ return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err)
704+ }
705+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
706+ return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec)
707+ }
708+ }
709+ }
710+ return manifest, nil
711+}
712+
713+func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool {
714+ required := map[string]bool{"extension.json": true}
715+ if manifest.Exec == "" {
716+ return required
717+ }
718+ resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
719+ if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
720+ return required
721+ }
722+ rel, err := filepath.Rel(dir, resolved)
723+ if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
724+ required[filepath.ToSlash(rel)] = true
725+ }
726+ return required
727+}
728+
729+func validateExtensionExecutable(dir string, manifest extensions.Manifest) error {
730+ if manifest.Exec == "" {
731+ return nil
732+ }
733+ path, local := extensions.ResolveExecPath(dir, manifest.Exec)
734+ if !local {
735+ // Bare commands such as "go", "node", and "npx" are resolved
736+ // from PATH when zot starts. They may be intentionally installed
737+ // before the runtime is present on the current machine.
738+ return nil
739+ }
740+ info, err := os.Stat(path)
741+ if err != nil {
742+ if os.IsNotExist(err) {
743+ return fmt.Errorf("extension executable %q is missing; provide it before installing. Local Go extensions can opt in with `zot ext install --build=go <path>`", manifest.Exec)
744+ }
745+ return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err)
746+ }
747+ if info.IsDir() {
748+ return fmt.Errorf("extension executable %q is a directory", manifest.Exec)
749+ }
750+ if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
751+ return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec)
752+ }
753+ return nil
754+}
755+
535 func extensionDirs() map[string]string {756 func extensionDirs() map[string]string {
536 out := map[string]string{}757 out := map[string]string{}
537 if h := ZotHome(); h != "" {758 if h := ZotHome(); h != "" {
@@ -569,6 +790,10 @@ func dashIfEmpty(s string) string {
569 // out of the installed copy so the extension stays functional at its new790 // out of the installed copy so the extension stays functional at its new
570 // location.791 // location.
571 func copyDir(src, dst string) error {792 func copyDir(src, dst string) error {
793+ return copyDirWithRequired(src, dst, nil)
794+}
795+
796+func copyDirWithRequired(src, dst string, required map[string]bool) error {
572 ig := loadGitignore(src)797 ig := loadGitignore(src)
573 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {798 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
574 if err != nil {799 if err != nil {
@@ -578,12 +803,13 @@ func copyDir(src, dst string) error {
578 if err != nil {803 if err != nil {
579 return err804 return err
580 }805 }
806+ relSlash := filepath.ToSlash(rel)
581 if rel != "." {807 if rel != "." {
582 name := filepath.Base(rel)808 name := filepath.Base(rel)
583 if info.IsDir() && name == ".git" {809 if info.IsDir() && name == ".git" {
584 return filepath.SkipDir810 return filepath.SkipDir
585 }811 }
586- if ig.Match(filepath.ToSlash(rel), info.IsDir()) {812+ if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) {
587 if info.IsDir() {813 if info.IsDir() {
588 return filepath.SkipDir814 return filepath.SkipDir
589 }815 }
@@ -609,6 +835,22 @@ func copyDir(src, dst string) error {
609 })835 })
610 }836 }
611 837
838+func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool {
839+ if required[rel] {
840+ return true
841+ }
842+ if !isDir {
843+ return false
844+ }
845+ prefix := strings.TrimSuffix(rel, "/") + "/"
846+ for path := range required {
847+ if strings.HasPrefix(path, prefix) {
848+ return true
849+ }
850+ }
851+ return false
852+}
853+
612 // gitignore matching lives in packages/ignore so the @-file picker in854 // gitignore matching lives in packages/ignore so the @-file picker in
613 // packages/agent/modes can share it without an import cycle. These855 // packages/agent/modes can share it without an import cycle. These
614 // thin aliases keep the existing call sites (and tests) terse.856 // thin aliases keep the existing call sites (and tests) terse.
M packages/agent/extcmd_test.go +315 -12
@@ -3,16 +3,18 @@ package agent
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,312 @@ 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 separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"},
219+ {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"},
220+ {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"},
221+ {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"},
222+ } {
223+ t.Run(test.name, func(t *testing.T) {
224+ got, err := parseExtInstallArgs(test.args)
225+ if test.wantErr != "" {
226+ if err == nil || !strings.Contains(err.Error(), test.wantErr) {
227+ t.Fatalf("error = %v, want substring %q", err, test.wantErr)
228+ }
229+ return
230+ }
231+ if err != nil {
232+ t.Fatalf("parse failed: %v", err)
233+ }
234+ if got.source != "./ext" || got.builder != test.builder {
235+ t.Fatalf("options = %#v, want source ./ext and builder %q", got, test.builder)
236+ }
237+ })
238+ }
239+}
240+
241+func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) {
242+ home := t.TempDir()
243+ t.Setenv("ZOT_HOME", home)
244+ src := filepath.Join(t.TempDir(), "theme-source")
245+ if err := os.MkdirAll(src, 0o755); err != nil {
246+ t.Fatal(err)
247+ }
248+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil {
249+ t.Fatal(err)
250+ }
251+ if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil {
252+ t.Fatal(err)
253+ }
254+
255+ err := extInstall([]string{"--build=go", src})
256+ if err == nil || !strings.Contains(err.Error(), "theme-only") {
257+ t.Fatalf("error = %v, want theme-only builder error", err)
258+ }
259+ if _, statErr := os.Stat(filepath.Join(home, "extensions", "theme-only")); !os.IsNotExist(statErr) {
260+ t.Fatalf("failed build left a destination: %v", statErr)
261+ }
262+}
263+
264+func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) {
265+ home := t.TempDir()
266+ t.Setenv("ZOT_HOME", home)
267+ src := filepath.Join(t.TempDir(), "whitespace-name")
268+ if err := os.MkdirAll(src, 0o755); err != nil {
269+ t.Fatal(err)
270+ }
271+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":" whitespace-name ","exec":"go"}`), 0o644); err != nil {
272+ t.Fatal(err)
273+ }
274+
275+ err := extInstall([]string{src})
276+ if err == nil || !strings.Contains(err.Error(), "leading or trailing whitespace") {
277+ t.Fatalf("error = %v, want whitespace validation error", err)
278+ }
279+}
280+
281+func TestExtInstallRejectsDotManifestName(t *testing.T) {
282+ home := t.TempDir()
283+ t.Setenv("ZOT_HOME", home)
284+ src := filepath.Join(t.TempDir(), "dot-name")
285+ if err := os.MkdirAll(src, 0o755); err != nil {
286+ t.Fatal(err)
287+ }
288+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":".example","exec":"go"}`), 0o644); err != nil {
289+ t.Fatal(err)
290+ }
291+
292+ err := extInstall([]string{src})
293+ if err == nil || !strings.Contains(err.Error(), "invalid extension name") {
294+ t.Fatalf("error = %v, want invalid-name validation error", err)
295+ }
296+}
297+
298+func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) {
299+ home := t.TempDir()
300+ t.Setenv("ZOT_HOME", home)
301+ src := filepath.Join(t.TempDir(), "path-extension")
302+ if err := os.MkdirAll(src, 0o755); err != nil {
303+ t.Fatal(err)
304+ }
305+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"path-extension","exec":"node"}`), 0o644); err != nil {
306+ t.Fatal(err)
307+ }
308+
309+ err := extInstall([]string{"--build=go", src})
310+ if err == nil || !strings.Contains(err.Error(), "extension-relative executable path") {
311+ t.Fatalf("error = %v, want local executable requirement", err)
312+ }
313+ if _, statErr := os.Stat(filepath.Join(home, "extensions", "path-extension")); !os.IsNotExist(statErr) {
314+ t.Fatalf("failed build left a destination: %v", statErr)
315+ }
316+}
317+
318+func TestExtInstallRejectsNonExecutable(t *testing.T) {
319+ if runtime.GOOS == "windows" {
320+ t.Skip("Unix executable permissions are not enforced on Windows")
321+ }
322+
323+ home := t.TempDir()
324+ t.Setenv("ZOT_HOME", home)
325+ src := filepath.Join(t.TempDir(), "non-executable")
326+ if err := os.MkdirAll(src, 0o755); err != nil {
327+ t.Fatal(err)
328+ }
329+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"non-executable","exec":"./run.sh","language":"shell"}`), 0o644); err != nil {
330+ t.Fatal(err)
331+ }
332+ if err := os.WriteFile(filepath.Join(src, "run.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil {
333+ t.Fatal(err)
334+ }
335+
336+ err := extInstall([]string{src})
337+ if err == nil || !strings.Contains(err.Error(), "not executable") {
338+ t.Fatalf("install error = %v, want executable permission guidance", err)
339+ }
340+ if _, statErr := os.Stat(filepath.Join(home, "extensions", "non-executable")); !os.IsNotExist(statErr) {
341+ t.Fatalf("failed install left a destination: %v", statErr)
342+ }
343+}
344+
345+// TestExtInstallCopiesIgnoredExecutableAndUsesManifestName verifies that a
346+// declared runtime file is copied even when .gitignore matches it, and that
347+// the manifest name controls the installed directory.
348+func TestExtInstallCopiesIgnoredExecutableAndUsesManifestName(t *testing.T) {
349+ home := t.TempDir()
350+ t.Setenv("ZOT_HOME", home)
351+
352+ src := filepath.Join(t.TempDir(), "source-dir")
353+ if err := os.MkdirAll(src, 0o755); err != nil {
354+ t.Fatal(err)
355+ }
356+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil {
357+ t.Fatal(err)
358+ }
359+ if err := os.WriteFile(filepath.Join(src, ".gitignore"), []byte("tasked-phases\n"), 0o644); err != nil {
360+ t.Fatal(err)
361+ }
362+ if err := os.WriteFile(filepath.Join(src, "tasked-phases"), []byte("not really a binary"), 0o755); err != nil {
363+ t.Fatal(err)
364+ }
365+
366+ if err := extInstall([]string{src}); err != nil {
367+ t.Fatalf("install failed: %v", err)
368+ }
369+
370+ out := filepath.Join(home, "extensions", "tasked-phases")
371+ info, err := os.Stat(filepath.Join(out, "tasked-phases"))
372+ if err != nil {
373+ t.Fatalf("declared executable was not copied: %v", err)
374+ }
375+ if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
376+ t.Fatalf("copied executable mode = %o, want an execute bit", info.Mode().Perm())
377+ }
378+ if _, err := os.Stat(filepath.Join(home, "extensions", "source-dir")); !os.IsNotExist(err) {
379+ t.Fatalf("install used source directory name instead of manifest name: %v", err)
380+ }
381+}
382+
80383 func TestCopyDirRespectsGitignore(t *testing.T) {
81384 src := t.TempDir()
82385 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,312 @@ 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 separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"},
219+ {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"},
220+ {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"},
221+ {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"},
222+ } {
223+ t.Run(test.name, func(t *testing.T) {
224+ got, err := parseExtInstallArgs(test.args)
225+ if test.wantErr != "" {
226+ if err == nil || !strings.Contains(err.Error(), test.wantErr) {
227+ t.Fatalf("error = %v, want substring %q", err, test.wantErr)
228+ }
229+ return
230+ }
231+ if err != nil {
232+ t.Fatalf("parse failed: %v", err)
233+ }
234+ if got.source != "./ext" || got.builder != test.builder {
235+ t.Fatalf("options = %#v, want source ./ext and builder %q", got, test.builder)
236+ }
237+ })
238+ }
239+}
240+
241+func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) {
242+ home := t.TempDir()
243+ t.Setenv("ZOT_HOME", home)
244+ src := filepath.Join(t.TempDir(), "theme-source")
245+ if err := os.MkdirAll(src, 0o755); err != nil {
246+ t.Fatal(err)
247+ }
248+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil {
249+ t.Fatal(err)
250+ }
251+ if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil {
252+ t.Fatal(err)
253+ }
254+
255+ err := extInstall([]string{"--build=go", src})
256+ if err == nil || !strings.Contains(err.Error(), "theme-only") {
257+ t.Fatalf("error = %v, want theme-only builder error", err)
258+ }
259+ if _, statErr := os.Stat(filepath.Join(home, "extensions", "theme-only")); !os.IsNotExist(statErr) {
260+ t.Fatalf("failed build left a destination: %v", statErr)
261+ }
262+}
263+
264+func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) {
265+ home := t.TempDir()
266+ t.Setenv("ZOT_HOME", home)
267+ src := filepath.Join(t.TempDir(), "whitespace-name")
268+ if err := os.MkdirAll(src, 0o755); err != nil {
269+ t.Fatal(err)
270+ }
271+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":" whitespace-name ","exec":"go"}`), 0o644); err != nil {
272+ t.Fatal(err)
273+ }
274+
275+ err := extInstall([]string{src})
276+ if err == nil || !strings.Contains(err.Error(), "leading or trailing whitespace") {
277+ t.Fatalf("error = %v, want whitespace validation error", err)
278+ }
279+}
280+
281+func TestExtInstallRejectsDotManifestName(t *testing.T) {
282+ home := t.TempDir()
283+ t.Setenv("ZOT_HOME", home)
284+ src := filepath.Join(t.TempDir(), "dot-name")
285+ if err := os.MkdirAll(src, 0o755); err != nil {
286+ t.Fatal(err)
287+ }
288+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":".example","exec":"go"}`), 0o644); err != nil {
289+ t.Fatal(err)
290+ }
291+
292+ err := extInstall([]string{src})
293+ if err == nil || !strings.Contains(err.Error(), "invalid extension name") {
294+ t.Fatalf("error = %v, want invalid-name validation error", err)
295+ }
296+}
297+
298+func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) {
299+ home := t.TempDir()
300+ t.Setenv("ZOT_HOME", home)
301+ src := filepath.Join(t.TempDir(), "path-extension")
302+ if err := os.MkdirAll(src, 0o755); err != nil {
303+ t.Fatal(err)
304+ }
305+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"path-extension","exec":"node"}`), 0o644); err != nil {
306+ t.Fatal(err)
307+ }
308+
309+ err := extInstall([]string{"--build=go", src})
310+ if err == nil || !strings.Contains(err.Error(), "extension-relative executable path") {
311+ t.Fatalf("error = %v, want local executable requirement", err)
312+ }
313+ if _, statErr := os.Stat(filepath.Join(home, "extensions", "path-extension")); !os.IsNotExist(statErr) {
314+ t.Fatalf("failed build left a destination: %v", statErr)
315+ }
316+}
317+
318+func TestExtInstallRejectsNonExecutable(t *testing.T) {
319+ if runtime.GOOS == "windows" {
320+ t.Skip("Unix executable permissions are not enforced on Windows")
321+ }
322+
323+ home := t.TempDir()
324+ t.Setenv("ZOT_HOME", home)
325+ src := filepath.Join(t.TempDir(), "non-executable")
326+ if err := os.MkdirAll(src, 0o755); err != nil {
327+ t.Fatal(err)
328+ }
329+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"non-executable","exec":"./run.sh","language":"shell"}`), 0o644); err != nil {
330+ t.Fatal(err)
331+ }
332+ if err := os.WriteFile(filepath.Join(src, "run.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil {
333+ t.Fatal(err)
334+ }
335+
336+ err := extInstall([]string{src})
337+ if err == nil || !strings.Contains(err.Error(), "not executable") {
338+ t.Fatalf("install error = %v, want executable permission guidance", err)
339+ }
340+ if _, statErr := os.Stat(filepath.Join(home, "extensions", "non-executable")); !os.IsNotExist(statErr) {
341+ t.Fatalf("failed install left a destination: %v", statErr)
342+ }
343+}
344+
345+// TestExtInstallCopiesIgnoredExecutableAndUsesManifestName verifies that a
346+// declared runtime file is copied even when .gitignore matches it, and that
347+// the manifest name controls the installed directory.
348+func TestExtInstallCopiesIgnoredExecutableAndUsesManifestName(t *testing.T) {
349+ home := t.TempDir()
350+ t.Setenv("ZOT_HOME", home)
351+
352+ src := filepath.Join(t.TempDir(), "source-dir")
353+ if err := os.MkdirAll(src, 0o755); err != nil {
354+ t.Fatal(err)
355+ }
356+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil {
357+ t.Fatal(err)
358+ }
359+ if err := os.WriteFile(filepath.Join(src, ".gitignore"), []byte("tasked-phases\n"), 0o644); err != nil {
360+ t.Fatal(err)
361+ }
362+ if err := os.WriteFile(filepath.Join(src, "tasked-phases"), []byte("not really a binary"), 0o755); err != nil {
363+ t.Fatal(err)
364+ }
365+
366+ if err := extInstall([]string{src}); err != nil {
367+ t.Fatalf("install failed: %v", err)
368+ }
369+
370+ out := filepath.Join(home, "extensions", "tasked-phases")
371+ info, err := os.Stat(filepath.Join(out, "tasked-phases"))
372+ if err != nil {
373+ t.Fatalf("declared executable was not copied: %v", err)
374+ }
375+ if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
376+ t.Fatalf("copied executable mode = %o, want an execute bit", info.Mode().Perm())
377+ }
378+ if _, err := os.Stat(filepath.Join(home, "extensions", "source-dir")); !os.IsNotExist(err) {
379+ t.Fatalf("install used source directory name instead of manifest name: %v", err)
380+ }
381+}
382+
80 func TestCopyDirRespectsGitignore(t *testing.T) {383 func TestCopyDirRespectsGitignore(t *testing.T) {
81 src := t.TempDir()384 src := t.TempDir()
82 dst := filepath.Join(t.TempDir(), "out")385 dst := filepath.Join(t.TempDir(), "out")
M packages/agent/extensions/manager.go +21 -22
@@ -279,7 +279,7 @@ func (m *Manager) Discover(ctx context.Context) []error {
279279 continue // missing directory is fine
280280 }
281281 for _, e := range entries {
282- if !e.IsDir() {
282+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
283283 continue
284284 }
285285 if seenDirs[e.Name()] {
@@ -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
@@ -279,7 +279,7 @@ func (m *Manager) Discover(ctx context.Context) []error {
279 continue // missing directory is fine279 continue // missing directory is fine
280 }280 }
281 for _, e := range entries {281 for _, e := range entries {
282- if !e.IsDir() {282+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
283 continue283 continue
284 }284 }
285 if seenDirs[e.Name()] {285 if seenDirs[e.Name()] {
@@ -600,6 +600,23 @@ func (m *Manager) WaitForReady(grace time.Duration) {
600 // after it closes stdout before completing the hello handshake.600 // after it closes stdout before completing the hello handshake.
601 const extensionExitGrace = time.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
M packages/agent/lsp/client.go +3 -2
@@ -543,6 +543,7 @@ func (c *Client) Close() error {
543543 _ = c.stdin.Close()
544544 c.writeMu.Unlock()
545545 killed := false
546+ process := c.cmd.Process
546547 exited := make(chan error, 1)
547548 go func() { exited <- c.cmd.Wait() }()
548549 timer := time.NewTimer(500 * time.Millisecond)
@@ -550,8 +551,8 @@ func (c *Client) Close() error {
550551 case waitErr = <-exited:
551552 timer.Stop()
552553 case <-timer.C:
553- if c.cmd.Process != nil && c.cmd.ProcessState == nil {
554- killed = c.cmd.Process.Kill() == nil
554+ if process != nil {
555+ killed = process.Kill() == nil
555556 }
556557 waitErr = <-exited
557558 }
@@ -543,6 +543,7 @@ func (c *Client) Close() error {
543 _ = c.stdin.Close()543 _ = c.stdin.Close()
544 c.writeMu.Unlock()544 c.writeMu.Unlock()
545 killed := false545 killed := false
546+ process := c.cmd.Process
546 exited := make(chan error, 1)547 exited := make(chan error, 1)
547 go func() { exited <- c.cmd.Wait() }()548 go func() { exited <- c.cmd.Wait() }()
548 timer := time.NewTimer(500 * time.Millisecond)549 timer := time.NewTimer(500 * time.Millisecond)
@@ -550,8 +551,8 @@ func (c *Client) Close() error {
550 case waitErr = <-exited:551 case waitErr = <-exited:
551 timer.Stop()552 timer.Stop()
552 case <-timer.C:553 case <-timer.C:
553- if c.cmd.Process != nil && c.cmd.ProcessState == nil {554+ if process != nil {
554- killed = c.cmd.Process.Kill() == nil555+ killed = process.Kill() == nil
555 }556 }
556 waitErr = <-exited557 waitErr = <-exited
557 }558 }
A packages/agent/modes/update_banner_test.go +13 -0
@@ -0,0 +1,13 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+func TestRenderUpdateBannerOmitsUnavailableUpdate(t *testing.T) {
10+ if lines := renderUpdateBanner(tui.Theme{}, UpdateInfo{}, 80); len(lines) != 0 {
11+ t.Fatalf("banner = %#v, want no banner", lines)
12+ }
13+}
@@ -0,0 +1,13 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+func TestRenderUpdateBannerOmitsUnavailableUpdate(t *testing.T) {
10+ if lines := renderUpdateBanner(tui.Theme{}, UpdateInfo{}, 80); len(lines) != 0 {
11+ t.Fatalf("banner = %#v, want no banner", lines)
12+ }
13+}
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +11 -7
@@ -339,6 +339,7 @@ for line in sys.stdin:
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/subagents/profiles.go +5 -1
@@ -54,7 +54,11 @@ func Discover(cwd, userHome string) ([]*Profile, []error) {
5454 for _, loc := range searchDirs(cwd, userHome) {
5555 entries, err := os.ReadDir(loc.dir)
5656 if err != nil {
57- if !os.IsNotExist(err) {
57+ if os.IsNotExist(err) {
58+ if info, statErr := os.Stat(loc.dir); statErr == nil && !info.IsDir() {
59+ errs = append(errs, fmt.Errorf("read subagent directory %s: not a directory", loc.dir))
60+ }
61+ } else {
5862 errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err))
5963 }
6064 continue
@@ -54,7 +54,11 @@ func Discover(cwd, userHome string) ([]*Profile, []error) {
54 for _, loc := range searchDirs(cwd, userHome) {54 for _, loc := range searchDirs(cwd, userHome) {
55 entries, err := os.ReadDir(loc.dir)55 entries, err := os.ReadDir(loc.dir)
56 if err != nil {56 if err != nil {
57- if !os.IsNotExist(err) {57+ if os.IsNotExist(err) {
58+ if info, statErr := os.Stat(loc.dir); statErr == nil && !info.IsDir() {
59+ errs = append(errs, fmt.Errorf("read subagent directory %s: not a directory", loc.dir))
60+ }
61+ } else {
58 errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err))62 errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err))
59 }63 }
60 continue64 continue
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 +54 -0
@@ -0,0 +1,54 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "net/http"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestIsDevVersion(t *testing.T) {
13+ for _, version := range []string{"", "dev", "0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)", "0.0.0-dev+local"} {
14+ if !isDevVersion(version) {
15+ t.Errorf("isDevVersion(%q) = false, want true", version)
16+ }
17+ }
18+ for _, version := range []string{"0.0.1", "1.0.0", "0.0.0-rc1"} {
19+ if isDevVersion(version) {
20+ t.Errorf("isDevVersion(%q) = true, want false", version)
21+ }
22+ }
23+}
24+
25+type countingRoundTripper struct {
26+ calls int
27+}
28+
29+func (r *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
30+ r.calls++
31+ return nil, errors.New("unexpected network request")
32+}
33+
34+func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
35+ home := t.TempDir()
36+ transport := &countingRoundTripper{}
37+ previousTransport := http.DefaultTransport
38+ http.DefaultTransport = transport
39+ t.Cleanup(func() { http.DefaultTransport = previousTransport })
40+
41+ ctx, cancel := context.WithCancel(context.Background())
42+ cancel()
43+
44+ info := CheckForUpdate(ctx, home, "0.0.0-dev")
45+ if info != (UpdateInfo{}) {
46+ t.Fatalf("development check = %+v, want an empty result", info)
47+ }
48+ if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) {
49+ t.Fatalf("development check wrote a cache file: %v", err)
50+ }
51+ if transport.calls != 0 {
52+ t.Fatalf("development check made %d network requests, want 0", transport.calls)
53+ }
54+}
@@ -0,0 +1,54 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "net/http"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestIsDevVersion(t *testing.T) {
13+ for _, version := range []string{"", "dev", "0.0.0", "0.0.0-dev", "0.0.0-dev (abc123, today)", "0.0.0-dev+local"} {
14+ if !isDevVersion(version) {
15+ t.Errorf("isDevVersion(%q) = false, want true", version)
16+ }
17+ }
18+ for _, version := range []string{"0.0.1", "1.0.0", "0.0.0-rc1"} {
19+ if isDevVersion(version) {
20+ t.Errorf("isDevVersion(%q) = true, want false", version)
21+ }
22+ }
23+}
24+
25+type countingRoundTripper struct {
26+ calls int
27+}
28+
29+func (r *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
30+ r.calls++
31+ return nil, errors.New("unexpected network request")
32+}
33+
34+func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
35+ home := t.TempDir()
36+ transport := &countingRoundTripper{}
37+ previousTransport := http.DefaultTransport
38+ http.DefaultTransport = transport
39+ t.Cleanup(func() { http.DefaultTransport = previousTransport })
40+
41+ ctx, cancel := context.WithCancel(context.Background())
42+ cancel()
43+
44+ info := CheckForUpdate(ctx, home, "0.0.0-dev")
45+ if info != (UpdateInfo{}) {
46+ t.Fatalf("development check = %+v, want an empty result", info)
47+ }
48+ if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) {
49+ t.Fatalf("development check wrote a cache file: %v", err)
50+ }
51+ if transport.calls != 0 {
52+ t.Fatalf("development check made %d network requests, want 0", transport.calls)
53+ }
54+}
M packages/agent/updatecmd.go +9 -10
@@ -34,9 +34,9 @@ import (
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) {
M packages/core/session_snapshot_test.go +4 -1
@@ -508,10 +508,13 @@ func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T)
508508 if err != nil {
509509 t.Fatal(err)
510510 }
511- _, branchMessages, err := OpenSession(branchPath)
511+ branchSession, branchMessages, err := OpenSession(branchPath)
512512 if err != nil {
513513 t.Fatal(err)
514514 }
515+ if err := branchSession.Close(); err != nil {
516+ t.Fatal(err)
517+ }
515518 if len(branchMessages) != 3 {
516519 t.Fatalf("branch messages = %d, want 3", len(branchMessages))
517520 }
@@ -508,10 +508,13 @@ func TestSnapshotRemovesOrphanedToolResultsAndMapsCheckpointCount(t *testing.T)
508 if err != nil {508 if err != nil {
509 t.Fatal(err)509 t.Fatal(err)
510 }510 }
511- _, branchMessages, err := OpenSession(branchPath)511+ branchSession, branchMessages, err := OpenSession(branchPath)
512 if err != nil {512 if err != nil {
513 t.Fatal(err)513 t.Fatal(err)
514 }514 }
515+ if err := branchSession.Close(); err != nil {
516+ t.Fatal(err)
517+ }
515 if len(branchMessages) != 3 {518 if len(branchMessages) != 3 {
516 t.Fatalf("branch messages = %d, want 3", len(branchMessages))519 t.Fatalf("branch messages = %d, want 3", len(branchMessages))
517 }520 }

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