1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "sort"
12 "strings"
13 "time"
14
15 "github.com/patriceckhart/zot/packages/agent/extensions"
16 "github.com/patriceckhart/zot/packages/agent/extproto"
17 "github.com/patriceckhart/zot/packages/ignore"
18)
19
20// runExtCommand dispatches `zot ext ...` subcommands. Returns
21// (handled=true, err) if rawArgs starts with "ext"; otherwise
22// (handled=false, nil) so the main router falls through to the
23// regular flag parser.
24func runExtCommand(rawArgs []string, version string) (handled bool, err error) {
25 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
26 return false, nil
27 }
28 if len(rawArgs) == 1 {
29 printExtHelp()
30 return true, nil
31 }
32 switch rawArgs[1] {
33 case "list":
34 return true, extList()
35 case "doctor":
36 return true, extDoctor(version)
37 case "logs":
38 return true, extLogs(rawArgs[2:])
39 case "enable":
40 return true, extToggle(rawArgs[2:], true)
41 case "disable":
42 return true, extToggle(rawArgs[2:], false)
43 case "remove", "rm":
44 return true, extRemove(rawArgs[2:])
45 case "install":
46 return true, extInstall(rawArgs[2:])
47 case "help", "-h", "--help":
48 printExtHelp()
49 return true, nil
50 default:
51 printExtHelp()
52 return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1])
53 }
54}
55
56func printExtHelp() {
57 fmt.Fprintln(os.Stderr, `zot ext — manage extensions
58
59usage:
60 zot ext list list installed extensions and their state
61 zot ext doctor diagnose installed extensions
62 zot ext logs <name> [-f] cat / tail an extension's stderr log
63 zot ext enable <name> re-enable a disabled extension
64 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
68extensions live under:
69 $ZOT_HOME/extensions/<name>/extension.json (global)
70 ./.zot/extensions/<name>/extension.json (project-local)`)
71}
72
73// extList walks both the global and project-local extension dirs and
74// prints a one-row-per-extension table.
75func extList() error {
76 type row struct {
77 Scope string
78 Name string
79 Version string
80 Enabled string
81 Language string
82 Dir string
83 }
84 var rows []row
85 for scope, dir := range extensionDirs() {
86 entries, err := os.ReadDir(dir)
87 if err != nil {
88 continue
89 }
90 for _, e := range entries {
91 if !e.IsDir() {
92 continue
93 }
94 extDir := filepath.Join(dir, e.Name())
95 mfPath := filepath.Join(extDir, "extension.json")
96 raw, err := os.ReadFile(mfPath)
97 if err != nil {
98 continue
99 }
100 var m struct {
101 Name string `json:"name"`
102 Version string `json:"version"`
103 Language string `json:"language"`
104 Enabled *bool `json:"enabled"`
105 }
106 if err := json.Unmarshal(raw, &m); err != nil {
107 continue
108 }
109 enabled := "yes"
110 if m.Enabled != nil && !*m.Enabled {
111 enabled = "no"
112 }
113 rows = append(rows, row{
114 Scope: scope, Name: m.Name, Version: m.Version,
115 Enabled: enabled, Language: m.Language, Dir: extDir,
116 })
117 }
118 }
119 if len(rows) == 0 {
120 fmt.Fprintln(os.Stderr, "no extensions installed")
121 fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
122 return nil
123 }
124 fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", "scope", "name", "version", "enabled", "language", "dir")
125 for _, r := range rows {
126 fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n",
127 r.Scope, r.Name, dashIfEmpty(r.Version),
128 r.Enabled, dashIfEmpty(r.Language), r.Dir)
129 }
130 return nil
131}
132
133type extDoctorHooks struct{}
134
135func (extDoctorHooks) Notify(string, string, string) {}
136func (extDoctorHooks) Submit(string) {}
137func (extDoctorHooks) SubmitSlash(string) {}
138func (extDoctorHooks) Insert(string) {}
139func (extDoctorHooks) Display(string, string) {}
140func (extDoctorHooks) ClearNotes(string) {}
141func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {}
142func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}
143func (extDoctorHooks) ClosePanel(string, string) {}
144
145type extDoctorStaticRow struct {
146 Scope string
147 Name string
148 Version string
149 Enabled bool
150 Dir string
151 Exec string
152 Theme bool
153 Skills bool
154 Manifest string
155 Error string
156 Shadowed bool
157}
158
159// extDoctor diagnoses extension discovery and registration without changing
160// normal fail-soft extension behavior.
161func extDoctor(version string) error {
162 rows := scanExtDoctorStatic()
163 if len(rows) == 0 {
164 fmt.Fprintln(os.Stdout, "no extensions installed")
165 fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
166 return nil
167 }
168
169 cwd, _ := os.Getwd()
170 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
171 defer cancel()
172 mgr := extensions.New(ZotHome(), cwd, version, "", "", extDoctorHooks{})
173 errs := mgr.Discover(ctx)
174 mgr.WaitForReady(3 * time.Second)
175 diags := mgr.Diagnostics()
176 mgr.Stop(500 * time.Millisecond)
177
178 diagByDir := map[string]extensions.ExtensionDiagnostic{}
179 for _, d := range diags {
180 diagByDir[d.Dir] = d
181 }
182
183 fmt.Fprintln(os.Stdout, "zot extension doctor")
184 fmt.Fprintln(os.Stdout)
185 for _, row := range rows {
186 printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir])
187 }
188 if len(errs) > 0 {
189 fmt.Fprintln(os.Stdout, "load errors:")
190 for _, err := range errs {
191 fmt.Fprintf(os.Stdout, " ! %v\n", err)
192 }
193 }
194 return nil
195}
196
197func scanExtDoctorStatic() []extDoctorStaticRow {
198 type scanDir struct {
199 scope string
200 dir string
201 }
202 var dirs []scanDir
203 if cwd, err := os.Getwd(); err == nil {
204 dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zot", "extensions")})
205 }
206 if h := ZotHome(); h != "" {
207 dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")})
208 }
209
210 seen := map[string]bool{}
211 var rows []extDoctorStaticRow
212 for _, sd := range dirs {
213 entries, err := os.ReadDir(sd.dir)
214 if err != nil {
215 continue
216 }
217 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
218 for _, e := range entries {
219 if !e.IsDir() {
220 continue
221 }
222 extDir := filepath.Join(sd.dir, e.Name())
223 row := extDoctorStaticRow{
224 Scope: sd.scope,
225 Name: e.Name(),
226 Enabled: true,
227 Dir: extDir,
228 Manifest: filepath.Join(extDir, "extension.json"),
229 Theme: extensions.HasExtensionTheme(extDir),
230 }
231 if seen[e.Name()] {
232 row.Shadowed = true
233 } else {
234 seen[e.Name()] = true
235 }
236 raw, err := os.ReadFile(row.Manifest)
237 if err != nil {
238 row.Error = "missing extension.json"
239 rows = append(rows, row)
240 continue
241 }
242 var m struct {
243 Name string `json:"name"`
244 Version string `json:"version"`
245 Exec string `json:"exec"`
246 Skills []string `json:"skills"`
247 Enabled *bool `json:"enabled"`
248 }
249 if err := json.Unmarshal(raw, &m); err != nil {
250 row.Error = "parse manifest: " + err.Error()
251 rows = append(rows, row)
252 continue
253 }
254 if m.Name != "" {
255 row.Name = m.Name
256 } else {
257 row.Error = "manifest: name is required"
258 }
259 row.Version = m.Version
260 row.Exec = m.Exec
261 row.Skills = len(m.Skills) > 0
262 if m.Enabled != nil {
263 row.Enabled = *m.Enabled
264 }
265 if row.Exec == "" && !row.Theme && !row.Skills && row.Error == "" {
266 row.Error = "manifest: exec, theme, or skills is required"
267 }
268 rows = append(rows, row)
269 }
270 }
271 return rows
272}
273
274func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) {
275 status := "ok"
276 switch {
277 case row.Error != "":
278 status = "error"
279 case row.Shadowed:
280 status = "shadowed"
281 case !row.Enabled:
282 status = "disabled"
283 case row.Exec == "" && row.Theme && !row.Skills:
284 status = "theme-only"
285 case row.Exec == "" && row.Skills && !row.Theme:
286 status = "skill-only"
287 case row.Exec == "" && row.Theme && row.Skills:
288 status = "static-only"
289 case diag.Name == "":
290 status = "not loaded"
291 case diag.ReadyTimedOut:
292 status = "ready-timeout"
293 case diag.AutoReady:
294 status = "auto-ready"
295 case !diag.Ready:
296 status = "not ready"
297 case len(diag.Messages) > 0:
298 status = "warning"
299 }
300 fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir)
301 if row.Version != "" {
302 fmt.Fprintf(w, " version: %s\n", row.Version)
303 }
304 if row.Error != "" {
305 if row.Exec != "" {
306 fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name))
307 }
308 fmt.Fprintf(w, " error: %s\n", row.Error)
309 return
310 }
311 if row.Shadowed {
312 fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins")
313 return
314 }
315 if !row.Enabled {
316 fmt.Fprintln(w, " note: disabled in extension.json")
317 return
318 }
319 if row.Exec == "" && (row.Theme || row.Skills) {
320 fmt.Fprintf(w, " type: %s\n", status)
321 return
322 }
323 if row.Exec != "" {
324 fmt.Fprintf(w, " exec: %s\n", row.Exec)
325 }
326 logPath := diag.LogPath
327 if logPath == "" && row.Exec != "" {
328 logPath = extDoctorLogPath(row.Name)
329 }
330 if logPath != "" {
331 fmt.Fprintf(w, " log: %s\n", logPath)
332 }
333 if len(diag.Commands) > 0 {
334 fmt.Fprintln(w, " commands:")
335 for _, c := range diag.Commands {
336 state := "active"
337 if !c.Active {
338 state = "shadowed"
339 }
340 fmt.Fprintf(w, " /%s (%s)\n", c.Name, state)
341 }
342 }
343 if len(diag.Tools) > 0 {
344 fmt.Fprintln(w, " tools:")
345 for _, t := range diag.Tools {
346 state := "active"
347 if !t.Active {
348 state = "shadowed"
349 }
350 fmt.Fprintf(w, " %s (%s)\n", t.Name, state)
351 }
352 }
353 for _, msg := range diag.Messages {
354 fmt.Fprintf(w, " warning: %s\n", msg)
355 }
356}
357
358func extDoctorLogPath(name string) string {
359 if name == "" || ZotHome() == "" {
360 return ""
361 }
362 return filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
363}
364
365// extLogs locates the named extension's log file and either cats or
366// tails it (-f).
367func extLogs(args []string) error {
368 if len(args) == 0 {
369 return fmt.Errorf("usage: zot ext logs <name> [-f]")
370 }
371 name := args[0]
372 follow := false
373 for _, a := range args[1:] {
374 if a == "-f" || a == "--follow" {
375 follow = true
376 }
377 }
378 logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
379 if _, err := os.Stat(logPath); err != nil {
380 return fmt.Errorf("no log for %q at %s", name, logPath)
381 }
382 if !follow {
383 f, err := os.Open(logPath)
384 if err != nil {
385 return err
386 }
387 defer f.Close()
388 _, err = io.Copy(os.Stdout, f)
389 return err
390 }
391 cmd := exec.Command("tail", "-F", logPath)
392 cmd.Stdout = os.Stdout
393 cmd.Stderr = os.Stderr
394 return cmd.Run()
395}
396
397// extToggle flips the enabled flag in an extension's manifest.
398func extToggle(args []string, enabled bool) error {
399 if len(args) == 0 {
400 verb := "enable"
401 if !enabled {
402 verb = "disable"
403 }
404 return fmt.Errorf("usage: zot ext %s <name>", verb)
405 }
406 name := args[0]
407 dir, err := findExtensionDir(name)
408 if err != nil {
409 return err
410 }
411 mfPath := filepath.Join(dir, "extension.json")
412 raw, err := os.ReadFile(mfPath)
413 if err != nil {
414 return err
415 }
416 var generic map[string]any
417 if err := json.Unmarshal(raw, &generic); err != nil {
418 return fmt.Errorf("parse manifest: %w", err)
419 }
420 generic["enabled"] = enabled
421 out, err := json.MarshalIndent(generic, "", " ")
422 if err != nil {
423 return err
424 }
425 if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
426 return err
427 }
428 state := "enabled"
429 if !enabled {
430 state = "disabled"
431 }
432 fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
433 return nil
434}
435
436// extRemove deletes an extension's directory after a confirmation
437// prompt (skip with --yes).
438func extRemove(args []string) error {
439 if len(args) == 0 {
440 return fmt.Errorf("usage: zot ext remove <name> [--yes]")
441 }
442 name := args[0]
443 yes := false
444 for _, a := range args[1:] {
445 if a == "--yes" || a == "-y" {
446 yes = true
447 }
448 }
449 dir, err := findExtensionDir(name)
450 if err != nil {
451 return err
452 }
453 if !yes {
454 fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
455 var resp string
456 _, _ = fmt.Scanln(&resp)
457 if !strings.EqualFold(strings.TrimSpace(resp), "y") {
458 fmt.Fprintln(os.Stderr, "aborted")
459 return nil
460 }
461 }
462 if err := os.RemoveAll(dir); err != nil {
463 return err
464 }
465 fmt.Fprintf(os.Stderr, "removed %s\n", dir)
466 return nil
467}
468
469// extInstall copies a local directory or shallow-clones a git URL
470// into $ZOT_HOME/extensions/. Validates the destination contains an
471// extension.json before reporting success.
472func extInstall(args []string) error {
473 if len(args) == 0 {
474 return fmt.Errorf("usage: zot ext install <path|git-url>")
475 }
476 src := args[0]
477 dest := filepath.Join(ZotHome(), "extensions")
478 if err := os.MkdirAll(dest, 0o755); err != nil {
479 return err
480 }
481
482 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
483 // Git clone path. Pick the destination name from the repo basename.
484 name := strings.TrimSuffix(filepath.Base(src), ".git")
485 out := filepath.Join(dest, name)
486 if _, err := os.Stat(out); err == nil {
487 return fmt.Errorf("destination %s already exists; remove it first", out)
488 }
489 cmd := exec.Command("git", "clone", "--depth", "1", src, out)
490 cmd.Stdout = os.Stderr
491 cmd.Stderr = os.Stderr
492 if err := cmd.Run(); err != nil {
493 return fmt.Errorf("git clone: %w", err)
494 }
495 if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
496 _ = os.RemoveAll(out)
497 return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back")
498 }
499 fmt.Fprintf(os.Stderr, "installed %s\n", out)
500 return nil
501 }
502
503 // Local path: must be a directory containing extension.json.
504 info, err := os.Stat(src)
505 if err != nil {
506 return err
507 }
508 if !info.IsDir() {
509 return fmt.Errorf("not a directory: %s", src)
510 }
511 if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
512 return fmt.Errorf("source lacks extension.json")
513 }
514 // Resolve to an absolute, cleaned path before deriving the install
515 // name. Otherwise relative sources like "." or "./" collapse to a
516 // basename of ".", and the destination wrongly resolves to the
517 // extensions/ parent directory (which zot creates on first run),
518 // triggering a false "already exists" failure.
519 absSrc, err := filepath.Abs(src)
520 if err != nil {
521 return err
522 }
523 name := filepath.Base(absSrc)
524 if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
525 return fmt.Errorf("cannot derive extension name from %q", src)
526 }
527 out := filepath.Join(dest, name)
528 if _, err := os.Stat(out); err == nil {
529 return fmt.Errorf("destination %s already exists; remove it first", out)
530 }
531 if err := copyDir(absSrc, out); err != nil {
532 return err
533 }
534 fmt.Fprintf(os.Stderr, "installed %s\n", out)
535 return nil
536}
537
538func extensionDirs() map[string]string {
539 out := map[string]string{}
540 if h := ZotHome(); h != "" {
541 out["global"] = filepath.Join(h, "extensions")
542 }
543 if cwd, err := os.Getwd(); err == nil {
544 out["project"] = filepath.Join(cwd, ".zot", "extensions")
545 }
546 return out
547}
548
549func 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{}
554 for _, dir := range extensionDirs() {
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 }
581 }
582 }
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 }
596}
597
598func dashIfEmpty(s string) string {
599 if s == "" {
600 return "-"
601 }
602 return s
603}
604
605// copyDir does a recursive copy of src to dst preserving file mode
606// bits. Used by `zot ext install <local-path>`.
607//
608// Entries matched by the source's root .gitignore are skipped, and
609// .git itself is always skipped. This keeps non-portable, regeneratable
610// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
611// out of the installed copy so the extension stays functional at its new
612// location.
613func copyDir(src, dst string) error {
614 ig := loadGitignore(src)
615 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
616 if err != nil {
617 return err
618 }
619 rel, err := filepath.Rel(src, path)
620 if err != nil {
621 return err
622 }
623 if rel != "." {
624 name := filepath.Base(rel)
625 if info.IsDir() && name == ".git" {
626 return filepath.SkipDir
627 }
628 if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
629 if info.IsDir() {
630 return filepath.SkipDir
631 }
632 return nil
633 }
634 }
635 target := filepath.Join(dst, rel)
636 if info.IsDir() {
637 return os.MkdirAll(target, info.Mode())
638 }
639 in, err := os.Open(path)
640 if err != nil {
641 return err
642 }
643 defer in.Close()
644 out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
645 if err != nil {
646 return err
647 }
648 defer out.Close()
649 _, err = io.Copy(out, in)
650 return err
651 })
652}
653
654// gitignore matching lives in packages/ignore so the @-file picker in
655// packages/agent/modes can share it without an import cycle. These
656// thin aliases keep the existing call sites (and tests) terse.
657type gitignore = ignore.Gitignore
658
659func loadGitignore(root string) *gitignore { return ignore.Load(root) }
660
661func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }