detached 035a5cd

fix: resolve extensions by manifest name

commit
035a5cdd5f8f9946cdaab90b915e64ea7488f508
author
patriceckhart · · 2026-09-15 16:09 UTC
parent
0f485af
signature
unsigned
3 files · +153 -4
M docs/extensions.md +9 -0
@@ -813,6 +813,15 @@ zot ext disable <name> disable without removing
813813 zot ext logs <name> [-f] cat / tail the extension's stderr
814814 ```
815815
816+`remove`, `enable`, and `disable` accept the manifest name shown by
817+`zot ext list` or the installed directory name. For example, an extension
818+named `todo` installed in `zot-todo/` can be removed with either name.
819+Both global and project-local installations are searched. If a name matches
820+multiple installations (including a manifest name matching another directory
821+name), the command reports the matching paths and makes no changes. Use a
822+unique directory name to disambiguate where possible. `logs` uses the
823+manifest name.
824+
816825 `zot ext doctor` runs the same discovery path as zot startup, but reports
817826 what happened instead of changing the fail-soft runtime behavior. It shows
818827 manifest errors, disabled or shadowed extensions, subprocess load errors,
@@ -813,6 +813,15 @@ zot ext disable <name> disable without removing
813 zot ext logs <name> [-f] cat / tail the extension's stderr813 zot ext logs <name> [-f] cat / tail the extension's stderr
814 ```814 ```
815 815
816+`remove`, `enable`, and `disable` accept the manifest name shown by
817+`zot ext list` or the installed directory name. For example, an extension
818+named `todo` installed in `zot-todo/` can be removed with either name.
819+Both global and project-local installations are searched. If a name matches
820+multiple installations (including a manifest name matching another directory
821+name), the command reports the matching paths and makes no changes. Use a
822+unique directory name to disambiguate where possible. `logs` uses the
823+manifest name.
824+
816 `zot ext doctor` runs the same discovery path as zot startup, but reports825 `zot ext doctor` runs the same discovery path as zot startup, but reports
817 what happened instead of changing the fail-soft runtime behavior. It shows826 what happened instead of changing the fail-soft runtime behavior. It shows
818 manifest errors, disabled or shadowed extensions, subprocess load errors,827 manifest errors, disabled or shadowed extensions, subprocess load errors,
M packages/agent/extcmd.go +43 -4
@@ -547,13 +547,52 @@ func extensionDirs() map[string]string {
547547 }
548548
549549 func findExtensionDir(name string) (string, error) {
550+ // Scan installed entries rather than joining user input onto a root.
551+ // Manifest names and directory names are both accepted, but neither
552+ // takes precedence when they identify different installations.
553+ matches := map[string]bool{}
550554 for _, dir := range extensionDirs() {
551- candidate := filepath.Join(dir, name)
552- if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
553- return candidate, nil
555+ entries, err := os.ReadDir(dir)
556+ if os.IsNotExist(err) {
557+ continue
558+ }
559+ if err != nil {
560+ return "", fmt.Errorf("read extensions directory %s: %w", dir, err)
561+ }
562+ for _, entry := range entries {
563+ if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
564+ continue
565+ }
566+ candidate := filepath.Join(dir, entry.Name())
567+ raw, err := os.ReadFile(filepath.Join(candidate, "extension.json"))
568+ if os.IsNotExist(err) {
569+ continue
570+ }
571+ if err != nil {
572+ return "", fmt.Errorf("read extension manifest in %s: %w", candidate, err)
573+ }
574+ var manifest struct {
575+ Name string `json:"name"`
576+ }
577+ manifestMatch := json.Unmarshal(raw, &manifest) == nil && manifest.Name != "" && manifest.Name == name
578+ if entry.Name() == name || manifestMatch {
579+ matches[candidate] = true
580+ }
554581 }
555582 }
556- return "", fmt.Errorf("extension %q not found", name)
583+ paths := make([]string, 0, len(matches))
584+ for path := range matches {
585+ paths = append(paths, path)
586+ }
587+ sort.Strings(paths)
588+ switch len(paths) {
589+ case 0:
590+ return "", fmt.Errorf("extension %q not found", name)
591+ case 1:
592+ return paths[0], nil
593+ default:
594+ return "", fmt.Errorf("extension %q is ambiguous, matches: %s", name, strings.Join(paths, ", "))
595+ }
557596 }
558597
559598 func dashIfEmpty(s string) string {
@@ -547,13 +547,52 @@ func extensionDirs() map[string]string {
547 }547 }
548 548
549 func findExtensionDir(name string) (string, error) {549 func findExtensionDir(name string) (string, error) {
550+ // Scan installed entries rather than joining user input onto a root.
551+ // Manifest names and directory names are both accepted, but neither
552+ // takes precedence when they identify different installations.
553+ matches := map[string]bool{}
550 for _, dir := range extensionDirs() {554 for _, dir := range extensionDirs() {
551- candidate := filepath.Join(dir, name)555+ entries, err := os.ReadDir(dir)
552- if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {556+ if os.IsNotExist(err) {
553- return candidate, nil557+ continue
558+ }
559+ if err != nil {
560+ return "", fmt.Errorf("read extensions directory %s: %w", dir, err)
561+ }
562+ for _, entry := range entries {
563+ if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
564+ continue
565+ }
566+ candidate := filepath.Join(dir, entry.Name())
567+ raw, err := os.ReadFile(filepath.Join(candidate, "extension.json"))
568+ if os.IsNotExist(err) {
569+ continue
570+ }
571+ if err != nil {
572+ return "", fmt.Errorf("read extension manifest in %s: %w", candidate, err)
573+ }
574+ var manifest struct {
575+ Name string `json:"name"`
576+ }
577+ manifestMatch := json.Unmarshal(raw, &manifest) == nil && manifest.Name != "" && manifest.Name == name
578+ if entry.Name() == name || manifestMatch {
579+ matches[candidate] = true
580+ }
554 }581 }
555 }582 }
556- return "", fmt.Errorf("extension %q not found", name)583+ paths := make([]string, 0, len(matches))
584+ for path := range matches {
585+ paths = append(paths, path)
586+ }
587+ sort.Strings(paths)
588+ switch len(paths) {
589+ case 0:
590+ return "", fmt.Errorf("extension %q not found", name)
591+ case 1:
592+ return paths[0], nil
593+ default:
594+ return "", fmt.Errorf("extension %q is ambiguous, matches: %s", name, strings.Join(paths, ", "))
595+ }
557 }596 }
558 597
559 func dashIfEmpty(s string) string {598 func dashIfEmpty(s string) string {
A packages/agent/extresolve_test.go +101 -0
@@ -0,0 +1,101 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestFindExtensionDir(t *testing.T) {
11+ for _, scope := range []string{"global", "project"} {
12+ t.Run(scope, func(t *testing.T) {
13+ home := t.TempDir()
14+ project := t.TempDir()
15+ t.Setenv("ZOT_HOME", home)
16+ t.Chdir(project)
17+ root := filepath.Join(home, "extensions")
18+ if scope == "project" {
19+ root = filepath.Join(project, ".zot", "extensions")
20+ }
21+ dir := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`)
22+ for _, name := range []string{"todo", "zot-todo"} {
23+ got, err := findExtensionDir(name)
24+ if err != nil || got != dir {
25+ t.Fatalf("findExtensionDir(%q) = %q, %v, want %q", name, got, err, dir)
26+ }
27+ }
28+ if _, err := findExtensionDir("missing"); err == nil {
29+ t.Fatal("expected missing extension error")
30+ }
31+ if err := extToggle([]string{"todo"}, false); err != nil {
32+ t.Fatal(err)
33+ }
34+ raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
35+ if err != nil || !strings.Contains(string(raw), `"enabled": false`) {
36+ t.Fatalf("disable did not update manifest: %s, %v", raw, err)
37+ }
38+ if err := extToggle([]string{"todo"}, true); err != nil {
39+ t.Fatal(err)
40+ }
41+ if err := extRemove([]string{"todo", "--yes"}); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, err := os.Stat(dir); !os.IsNotExist(err) {
45+ t.Fatalf("extension was not removed: %v", err)
46+ }
47+ })
48+ }
49+}
50+
51+func TestFindExtensionDirAmbiguous(t *testing.T) {
52+ for _, collision := range []string{"manifest", "directory", "cross-scope"} {
53+ t.Run(collision, func(t *testing.T) {
54+ home := t.TempDir()
55+ project := t.TempDir()
56+ t.Setenv("ZOT_HOME", home)
57+ t.Chdir(project)
58+ root := filepath.Join(home, "extensions")
59+ first := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`)
60+ secondName := "other-todo"
61+ if collision == "directory" {
62+ secondName = "todo"
63+ }
64+ if collision == "cross-scope" {
65+ root = filepath.Join(project, ".zot", "extensions")
66+ secondName = "zot-todo"
67+ }
68+ second := writeResolverExtension(t, root, secondName, `{"name":"todo"}`)
69+ if err := extRemove([]string{"todo", "--yes"}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
70+ t.Fatalf("expected ambiguity error, got %v", err)
71+ }
72+ for _, dir := range []string{first, second} {
73+ if _, err := os.Stat(dir); err != nil {
74+ t.Fatalf("ambiguous removal modified %s: %v", dir, err)
75+ }
76+ }
77+ })
78+ }
79+}
80+
81+func TestFindExtensionDirRejectsTraversal(t *testing.T) {
82+ home := t.TempDir()
83+ t.Setenv("ZOT_HOME", home)
84+ t.Chdir(t.TempDir())
85+ writeResolverExtension(t, home, "outside", `{"name":"outside"}`)
86+ if _, err := findExtensionDir("../outside"); err == nil {
87+ t.Fatal("resolved directory outside extension roots")
88+ }
89+}
90+
91+func writeResolverExtension(t *testing.T, root, dir, manifest string) string {
92+ t.Helper()
93+ path := filepath.Join(root, dir)
94+ if err := os.MkdirAll(path, 0o755); err != nil {
95+ t.Fatal(err)
96+ }
97+ if err := os.WriteFile(filepath.Join(path, "extension.json"), []byte(manifest), 0o644); err != nil {
98+ t.Fatal(err)
99+ }
100+ return path
101+}
@@ -0,0 +1,101 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestFindExtensionDir(t *testing.T) {
11+ for _, scope := range []string{"global", "project"} {
12+ t.Run(scope, func(t *testing.T) {
13+ home := t.TempDir()
14+ project := t.TempDir()
15+ t.Setenv("ZOT_HOME", home)
16+ t.Chdir(project)
17+ root := filepath.Join(home, "extensions")
18+ if scope == "project" {
19+ root = filepath.Join(project, ".zot", "extensions")
20+ }
21+ dir := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`)
22+ for _, name := range []string{"todo", "zot-todo"} {
23+ got, err := findExtensionDir(name)
24+ if err != nil || got != dir {
25+ t.Fatalf("findExtensionDir(%q) = %q, %v, want %q", name, got, err, dir)
26+ }
27+ }
28+ if _, err := findExtensionDir("missing"); err == nil {
29+ t.Fatal("expected missing extension error")
30+ }
31+ if err := extToggle([]string{"todo"}, false); err != nil {
32+ t.Fatal(err)
33+ }
34+ raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
35+ if err != nil || !strings.Contains(string(raw), `"enabled": false`) {
36+ t.Fatalf("disable did not update manifest: %s, %v", raw, err)
37+ }
38+ if err := extToggle([]string{"todo"}, true); err != nil {
39+ t.Fatal(err)
40+ }
41+ if err := extRemove([]string{"todo", "--yes"}); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, err := os.Stat(dir); !os.IsNotExist(err) {
45+ t.Fatalf("extension was not removed: %v", err)
46+ }
47+ })
48+ }
49+}
50+
51+func TestFindExtensionDirAmbiguous(t *testing.T) {
52+ for _, collision := range []string{"manifest", "directory", "cross-scope"} {
53+ t.Run(collision, func(t *testing.T) {
54+ home := t.TempDir()
55+ project := t.TempDir()
56+ t.Setenv("ZOT_HOME", home)
57+ t.Chdir(project)
58+ root := filepath.Join(home, "extensions")
59+ first := writeResolverExtension(t, root, "zot-todo", `{"name":"todo"}`)
60+ secondName := "other-todo"
61+ if collision == "directory" {
62+ secondName = "todo"
63+ }
64+ if collision == "cross-scope" {
65+ root = filepath.Join(project, ".zot", "extensions")
66+ secondName = "zot-todo"
67+ }
68+ second := writeResolverExtension(t, root, secondName, `{"name":"todo"}`)
69+ if err := extRemove([]string{"todo", "--yes"}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
70+ t.Fatalf("expected ambiguity error, got %v", err)
71+ }
72+ for _, dir := range []string{first, second} {
73+ if _, err := os.Stat(dir); err != nil {
74+ t.Fatalf("ambiguous removal modified %s: %v", dir, err)
75+ }
76+ }
77+ })
78+ }
79+}
80+
81+func TestFindExtensionDirRejectsTraversal(t *testing.T) {
82+ home := t.TempDir()
83+ t.Setenv("ZOT_HOME", home)
84+ t.Chdir(t.TempDir())
85+ writeResolverExtension(t, home, "outside", `{"name":"outside"}`)
86+ if _, err := findExtensionDir("../outside"); err == nil {
87+ t.Fatal("resolved directory outside extension roots")
88+ }
89+}
90+
91+func writeResolverExtension(t *testing.T, root, dir, manifest string) string {
92+ t.Helper()
93+ path := filepath.Join(root, dir)
94+ if err := os.MkdirAll(path, 0o755); err != nil {
95+ t.Fatal(err)
96+ }
97+ if err := os.WriteFile(filepath.Join(path, "extension.json"), []byte(manifest), 0o644); err != nil {
98+ t.Fatal(err)
99+ }
100+ return path
101+}

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 476

↑↓ moveenter openesc close