1package agent
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11)
12
13// runExtCommand dispatches `zot ext ...` subcommands. Returns
14// (handled=true, err) if rawArgs starts with "ext"; otherwise
15// (handled=false, nil) so the main router falls through to the
16// regular flag parser.
17func runExtCommand(rawArgs []string) (handled bool, err error) {
18 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
19 return false, nil
20 }
21 if len(rawArgs) == 1 {
22 printExtHelp()
23 return true, nil
24 }
25 switch rawArgs[1] {
26 case "list":
27 return true, extList()
28 case "logs":
29 return true, extLogs(rawArgs[2:])
30 case "enable":
31 return true, extToggle(rawArgs[2:], true)
32 case "disable":
33 return true, extToggle(rawArgs[2:], false)
34 case "remove", "rm":
35 return true, extRemove(rawArgs[2:])
36 case "install":
37 return true, extInstall(rawArgs[2:])
38 case "help", "-h", "--help":
39 printExtHelp()
40 return true, nil
41 default:
42 printExtHelp()
43 return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1])
44 }
45}
46
47func printExtHelp() {
48 fmt.Fprintln(os.Stderr, `zot ext — manage extensions
49
50usage:
51 zot ext list list installed extensions and their state
52 zot ext logs <name> [-f] cat / tail an extension's stderr log
53 zot ext enable <name> re-enable a disabled extension
54 zot ext disable <name> disable without removing
55 zot ext remove <name> delete an extension directory
56 zot ext install <path|git-url> copy / clone an extension into $ZOT_HOME/extensions/
57
58extensions live under:
59 $ZOT_HOME/extensions/<name>/extension.json (global)
60 ./.zot/extensions/<name>/extension.json (project-local)`)
61}
62
63// extList walks both the global and project-local extension dirs and
64// prints a one-row-per-extension table.
65func extList() error {
66 type row struct {
67 Scope string
68 Name string
69 Version string
70 Enabled string
71 Language string
72 Dir string
73 }
74 var rows []row
75 for scope, dir := range extensionDirs() {
76 entries, err := os.ReadDir(dir)
77 if err != nil {
78 continue
79 }
80 for _, e := range entries {
81 if !e.IsDir() {
82 continue
83 }
84 extDir := filepath.Join(dir, e.Name())
85 mfPath := filepath.Join(extDir, "extension.json")
86 raw, err := os.ReadFile(mfPath)
87 if err != nil {
88 continue
89 }
90 var m struct {
91 Name string `json:"name"`
92 Version string `json:"version"`
93 Language string `json:"language"`
94 Enabled *bool `json:"enabled"`
95 }
96 if err := json.Unmarshal(raw, &m); err != nil {
97 continue
98 }
99 enabled := "yes"
100 if m.Enabled != nil && !*m.Enabled {
101 enabled = "no"
102 }
103 rows = append(rows, row{
104 Scope: scope, Name: m.Name, Version: m.Version,
105 Enabled: enabled, Language: m.Language, Dir: extDir,
106 })
107 }
108 }
109 if len(rows) == 0 {
110 fmt.Fprintln(os.Stderr, "no extensions installed")
111 fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
112 return nil
113 }
114 fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", "scope", "name", "version", "enabled", "language", "dir")
115 for _, r := range rows {
116 fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n",
117 r.Scope, r.Name, dashIfEmpty(r.Version),
118 r.Enabled, dashIfEmpty(r.Language), r.Dir)
119 }
120 return nil
121}
122
123// extLogs locates the named extension's log file and either cats or
124// tails it (-f).
125func extLogs(args []string) error {
126 if len(args) == 0 {
127 return fmt.Errorf("usage: zot ext logs <name> [-f]")
128 }
129 name := args[0]
130 follow := false
131 for _, a := range args[1:] {
132 if a == "-f" || a == "--follow" {
133 follow = true
134 }
135 }
136 logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
137 if _, err := os.Stat(logPath); err != nil {
138 return fmt.Errorf("no log for %q at %s", name, logPath)
139 }
140 if !follow {
141 f, err := os.Open(logPath)
142 if err != nil {
143 return err
144 }
145 defer f.Close()
146 _, err = io.Copy(os.Stdout, f)
147 return err
148 }
149 cmd := exec.Command("tail", "-F", logPath)
150 cmd.Stdout = os.Stdout
151 cmd.Stderr = os.Stderr
152 return cmd.Run()
153}
154
155// extToggle flips the enabled flag in an extension's manifest.
156func extToggle(args []string, enabled bool) error {
157 if len(args) == 0 {
158 verb := "enable"
159 if !enabled {
160 verb = "disable"
161 }
162 return fmt.Errorf("usage: zot ext %s <name>", verb)
163 }
164 name := args[0]
165 dir, err := findExtensionDir(name)
166 if err != nil {
167 return err
168 }
169 mfPath := filepath.Join(dir, "extension.json")
170 raw, err := os.ReadFile(mfPath)
171 if err != nil {
172 return err
173 }
174 var generic map[string]any
175 if err := json.Unmarshal(raw, &generic); err != nil {
176 return fmt.Errorf("parse manifest: %w", err)
177 }
178 generic["enabled"] = enabled
179 out, err := json.MarshalIndent(generic, "", " ")
180 if err != nil {
181 return err
182 }
183 if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
184 return err
185 }
186 state := "enabled"
187 if !enabled {
188 state = "disabled"
189 }
190 fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
191 return nil
192}
193
194// extRemove deletes an extension's directory after a confirmation
195// prompt (skip with --yes).
196func extRemove(args []string) error {
197 if len(args) == 0 {
198 return fmt.Errorf("usage: zot ext remove <name> [--yes]")
199 }
200 name := args[0]
201 yes := false
202 for _, a := range args[1:] {
203 if a == "--yes" || a == "-y" {
204 yes = true
205 }
206 }
207 dir, err := findExtensionDir(name)
208 if err != nil {
209 return err
210 }
211 if !yes {
212 fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
213 var resp string
214 _, _ = fmt.Scanln(&resp)
215 if !strings.EqualFold(strings.TrimSpace(resp), "y") {
216 fmt.Fprintln(os.Stderr, "aborted")
217 return nil
218 }
219 }
220 if err := os.RemoveAll(dir); err != nil {
221 return err
222 }
223 fmt.Fprintf(os.Stderr, "removed %s\n", dir)
224 return nil
225}
226
227// extInstall copies a local directory or shallow-clones a git URL
228// into $ZOT_HOME/extensions/. Validates the destination contains an
229// extension.json before reporting success.
230func extInstall(args []string) error {
231 if len(args) == 0 {
232 return fmt.Errorf("usage: zot ext install <path|git-url>")
233 }
234 src := args[0]
235 dest := filepath.Join(ZotHome(), "extensions")
236 if err := os.MkdirAll(dest, 0o755); err != nil {
237 return err
238 }
239
240 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
241 // Git clone path. Pick the destination name from the repo basename.
242 name := strings.TrimSuffix(filepath.Base(src), ".git")
243 out := filepath.Join(dest, name)
244 if _, err := os.Stat(out); err == nil {
245 return fmt.Errorf("destination %s already exists; remove it first", out)
246 }
247 cmd := exec.Command("git", "clone", "--depth", "1", src, out)
248 cmd.Stdout = os.Stderr
249 cmd.Stderr = os.Stderr
250 if err := cmd.Run(); err != nil {
251 return fmt.Errorf("git clone: %w", err)
252 }
253 if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
254 _ = os.RemoveAll(out)
255 return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back")
256 }
257 fmt.Fprintf(os.Stderr, "installed %s\n", out)
258 return nil
259 }
260
261 // Local path: must be a directory containing extension.json.
262 info, err := os.Stat(src)
263 if err != nil {
264 return err
265 }
266 if !info.IsDir() {
267 return fmt.Errorf("not a directory: %s", src)
268 }
269 if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
270 return fmt.Errorf("source lacks extension.json")
271 }
272 // Resolve to an absolute, cleaned path before deriving the install
273 // name. Otherwise relative sources like "." or "./" collapse to a
274 // basename of ".", and the destination wrongly resolves to the
275 // extensions/ parent directory (which zot creates on first run),
276 // triggering a false "already exists" failure.
277 absSrc, err := filepath.Abs(src)
278 if err != nil {
279 return err
280 }
281 name := filepath.Base(absSrc)
282 if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
283 return fmt.Errorf("cannot derive extension name from %q", src)
284 }
285 out := filepath.Join(dest, name)
286 if _, err := os.Stat(out); err == nil {
287 return fmt.Errorf("destination %s already exists; remove it first", out)
288 }
289 if err := copyDir(absSrc, out); err != nil {
290 return err
291 }
292 fmt.Fprintf(os.Stderr, "installed %s\n", out)
293 return nil
294}
295
296func extensionDirs() map[string]string {
297 out := map[string]string{}
298 if h := ZotHome(); h != "" {
299 out["global"] = filepath.Join(h, "extensions")
300 }
301 if cwd, err := os.Getwd(); err == nil {
302 out["project"] = filepath.Join(cwd, ".zot", "extensions")
303 }
304 return out
305}
306
307func findExtensionDir(name string) (string, error) {
308 for _, dir := range extensionDirs() {
309 candidate := filepath.Join(dir, name)
310 if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
311 return candidate, nil
312 }
313 }
314 return "", fmt.Errorf("extension %q not found", name)
315}
316
317func dashIfEmpty(s string) string {
318 if s == "" {
319 return "-"
320 }
321 return s
322}
323
324// copyDir does a recursive copy of src to dst preserving file mode
325// bits. Used by `zot ext install <local-path>`.
326//
327// Entries matched by the source's root .gitignore are skipped, and
328// .git itself is always skipped. This keeps non-portable, regeneratable
329// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
330// out of the installed copy so the extension stays functional at its new
331// location.
332func copyDir(src, dst string) error {
333 ig := loadGitignore(src)
334 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
335 if err != nil {
336 return err
337 }
338 rel, err := filepath.Rel(src, path)
339 if err != nil {
340 return err
341 }
342 if rel != "." {
343 name := filepath.Base(rel)
344 if info.IsDir() && name == ".git" {
345 return filepath.SkipDir
346 }
347 if ig.match(filepath.ToSlash(rel), info.IsDir()) {
348 if info.IsDir() {
349 return filepath.SkipDir
350 }
351 return nil
352 }
353 }
354 target := filepath.Join(dst, rel)
355 if info.IsDir() {
356 return os.MkdirAll(target, info.Mode())
357 }
358 in, err := os.Open(path)
359 if err != nil {
360 return err
361 }
362 defer in.Close()
363 out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
364 if err != nil {
365 return err
366 }
367 defer out.Close()
368 _, err = io.Copy(out, in)
369 return err
370 })
371}
372
373// gitignore is a minimal .gitignore matcher. It supports the common
374// patterns used in real extension repos: blank lines, comments (#),
375// negation (!), directory-only patterns (trailing /), anchored
376// patterns (leading /), and the * / ? / [..] wildcards via
377// filepath.Match. It intentionally does not implement ** globstar or
378// nested per-directory .gitignore files; the goal is to drop obvious
379// non-portable directories, not to be a faithful git reimplementation.
380type gitignore struct {
381 rules []gitignoreRule
382}
383
384type gitignoreRule struct {
385 pattern string
386 negate bool
387 dirOnly bool
388 anchored bool
389}
390
391func loadGitignore(root string) *gitignore {
392 data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
393 if err != nil {
394 return &gitignore{}
395 }
396 return loadGitignoreFromString(string(data))
397}
398
399func loadGitignoreFromString(data string) *gitignore {
400 g := &gitignore{}
401 for _, line := range strings.Split(data, "\n") {
402 line = strings.TrimRight(line, "\r")
403 trimmed := strings.TrimSpace(line)
404 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
405 continue
406 }
407 r := gitignoreRule{pattern: trimmed}
408 if strings.HasPrefix(r.pattern, "!") {
409 r.negate = true
410 r.pattern = r.pattern[1:]
411 }
412 if strings.HasSuffix(r.pattern, "/") {
413 r.dirOnly = true
414 r.pattern = strings.TrimSuffix(r.pattern, "/")
415 }
416 if strings.HasPrefix(r.pattern, "/") {
417 r.anchored = true
418 r.pattern = strings.TrimPrefix(r.pattern, "/")
419 }
420 if r.pattern == "" {
421 continue
422 }
423 g.rules = append(g.rules, r)
424 }
425 return g
426}
427
428// match reports whether the slash-separated relative path should be
429// ignored. Later rules win, so a trailing negation can re-include a
430// previously ignored path.
431func (g *gitignore) match(rel string, isDir bool) bool {
432 ignored := false
433 for _, r := range g.rules {
434 if r.dirOnly && !isDir {
435 continue
436 }
437 if r.matchPath(rel) {
438 ignored = !r.negate
439 }
440 }
441 return ignored
442}
443
444func (r gitignoreRule) matchPath(rel string) bool {
445 if r.anchored || strings.Contains(r.pattern, "/") {
446 if ok, _ := filepath.Match(r.pattern, rel); ok {
447 return true
448 }
449 // Anchored directory pattern also matches everything beneath it.
450 return strings.HasPrefix(rel, r.pattern+"/")
451 }
452 // Unanchored: match the basename of any path component.
453 base := rel
454 if i := strings.LastIndex(rel, "/"); i >= 0 {
455 base = rel[i+1:]
456 }
457 if ok, _ := filepath.Match(r.pattern, base); ok {
458 return true
459 }
460 // Match a directory component anywhere in the path.
461 for _, part := range strings.Split(rel, "/") {
462 if ok, _ := filepath.Match(r.pattern, part); ok {
463 return true
464 }
465 }
466 return false
467}