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 Manifest string
154 Error string
155 Shadowed bool
156}
157
158// extDoctor diagnoses extension discovery and registration without changing
159// normal fail-soft extension behavior.
160func extDoctor(version string) error {
161 rows := scanExtDoctorStatic()
162 if len(rows) == 0 {
163 fmt.Fprintln(os.Stdout, "no extensions installed")
164 fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
165 return nil
166 }
167
168 cwd, _ := os.Getwd()
169 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
170 defer cancel()
171 mgr := extensions.New(ZotHome(), cwd, version, "", "", extDoctorHooks{})
172 errs := mgr.Discover(ctx)
173 mgr.WaitForReady(3 * time.Second)
174 diags := mgr.Diagnostics()
175 mgr.Stop(500 * time.Millisecond)
176
177 diagByDir := map[string]extensions.ExtensionDiagnostic{}
178 for _, d := range diags {
179 diagByDir[d.Dir] = d
180 }
181
182 fmt.Fprintln(os.Stdout, "zot extension doctor")
183 fmt.Fprintln(os.Stdout)
184 for _, row := range rows {
185 printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir])
186 }
187 if len(errs) > 0 {
188 fmt.Fprintln(os.Stdout, "load errors:")
189 for _, err := range errs {
190 fmt.Fprintf(os.Stdout, " ! %v\n", err)
191 }
192 }
193 return nil
194}
195
196func scanExtDoctorStatic() []extDoctorStaticRow {
197 type scanDir struct {
198 scope string
199 dir string
200 }
201 var dirs []scanDir
202 if cwd, err := os.Getwd(); err == nil {
203 dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zot", "extensions")})
204 }
205 if h := ZotHome(); h != "" {
206 dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")})
207 }
208
209 seen := map[string]bool{}
210 var rows []extDoctorStaticRow
211 for _, sd := range dirs {
212 entries, err := os.ReadDir(sd.dir)
213 if err != nil {
214 continue
215 }
216 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
217 for _, e := range entries {
218 if !e.IsDir() {
219 continue
220 }
221 extDir := filepath.Join(sd.dir, e.Name())
222 row := extDoctorStaticRow{
223 Scope: sd.scope,
224 Name: e.Name(),
225 Enabled: true,
226 Dir: extDir,
227 Manifest: filepath.Join(extDir, "extension.json"),
228 Theme: extensions.HasExtensionTheme(extDir),
229 }
230 if seen[e.Name()] {
231 row.Shadowed = true
232 } else {
233 seen[e.Name()] = true
234 }
235 raw, err := os.ReadFile(row.Manifest)
236 if err != nil {
237 row.Error = "missing extension.json"
238 rows = append(rows, row)
239 continue
240 }
241 var m struct {
242 Name string `json:"name"`
243 Version string `json:"version"`
244 Exec string `json:"exec"`
245 Enabled *bool `json:"enabled"`
246 }
247 if err := json.Unmarshal(raw, &m); err != nil {
248 row.Error = "parse manifest: " + err.Error()
249 rows = append(rows, row)
250 continue
251 }
252 if m.Name != "" {
253 row.Name = m.Name
254 } else {
255 row.Error = "manifest: name is required"
256 }
257 row.Version = m.Version
258 row.Exec = m.Exec
259 if m.Enabled != nil {
260 row.Enabled = *m.Enabled
261 }
262 if row.Exec == "" && !row.Theme && row.Error == "" {
263 row.Error = "manifest: exec is required"
264 }
265 rows = append(rows, row)
266 }
267 }
268 return rows
269}
270
271func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) {
272 status := "ok"
273 switch {
274 case row.Error != "":
275 status = "error"
276 case row.Shadowed:
277 status = "shadowed"
278 case !row.Enabled:
279 status = "disabled"
280 case row.Exec == "" && row.Theme:
281 status = "theme-only"
282 case diag.Name == "":
283 status = "not loaded"
284 case diag.ReadyTimedOut:
285 status = "ready-timeout"
286 case diag.AutoReady:
287 status = "auto-ready"
288 case !diag.Ready:
289 status = "not ready"
290 case len(diag.Messages) > 0:
291 status = "warning"
292 }
293 fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir)
294 if row.Version != "" {
295 fmt.Fprintf(w, " version: %s\n", row.Version)
296 }
297 if row.Error != "" {
298 if row.Exec != "" {
299 fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name))
300 }
301 fmt.Fprintf(w, " error: %s\n", row.Error)
302 return
303 }
304 if row.Shadowed {
305 fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins")
306 return
307 }
308 if !row.Enabled {
309 fmt.Fprintln(w, " note: disabled in extension.json")
310 return
311 }
312 if row.Exec == "" && row.Theme {
313 fmt.Fprintln(w, " type: theme-only")
314 return
315 }
316 if row.Exec != "" {
317 fmt.Fprintf(w, " exec: %s\n", row.Exec)
318 }
319 logPath := diag.LogPath
320 if logPath == "" && row.Exec != "" {
321 logPath = extDoctorLogPath(row.Name)
322 }
323 if logPath != "" {
324 fmt.Fprintf(w, " log: %s\n", logPath)
325 }
326 if len(diag.Commands) > 0 {
327 fmt.Fprintln(w, " commands:")
328 for _, c := range diag.Commands {
329 state := "active"
330 if !c.Active {
331 state = "shadowed"
332 }
333 fmt.Fprintf(w, " /%s (%s)\n", c.Name, state)
334 }
335 }
336 if len(diag.Tools) > 0 {
337 fmt.Fprintln(w, " tools:")
338 for _, t := range diag.Tools {
339 state := "active"
340 if !t.Active {
341 state = "shadowed"
342 }
343 fmt.Fprintf(w, " %s (%s)\n", t.Name, state)
344 }
345 }
346 for _, msg := range diag.Messages {
347 fmt.Fprintf(w, " warning: %s\n", msg)
348 }
349}
350
351func extDoctorLogPath(name string) string {
352 if name == "" || ZotHome() == "" {
353 return ""
354 }
355 return filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
356}
357
358// extLogs locates the named extension's log file and either cats or
359// tails it (-f).
360func extLogs(args []string) error {
361 if len(args) == 0 {
362 return fmt.Errorf("usage: zot ext logs <name> [-f]")
363 }
364 name := args[0]
365 follow := false
366 for _, a := range args[1:] {
367 if a == "-f" || a == "--follow" {
368 follow = true
369 }
370 }
371 logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
372 if _, err := os.Stat(logPath); err != nil {
373 return fmt.Errorf("no log for %q at %s", name, logPath)
374 }
375 if !follow {
376 f, err := os.Open(logPath)
377 if err != nil {
378 return err
379 }
380 defer f.Close()
381 _, err = io.Copy(os.Stdout, f)
382 return err
383 }
384 cmd := exec.Command("tail", "-F", logPath)
385 cmd.Stdout = os.Stdout
386 cmd.Stderr = os.Stderr
387 return cmd.Run()
388}
389
390// extToggle flips the enabled flag in an extension's manifest.
391func extToggle(args []string, enabled bool) error {
392 if len(args) == 0 {
393 verb := "enable"
394 if !enabled {
395 verb = "disable"
396 }
397 return fmt.Errorf("usage: zot ext %s <name>", verb)
398 }
399 name := args[0]
400 dir, err := findExtensionDir(name)
401 if err != nil {
402 return err
403 }
404 mfPath := filepath.Join(dir, "extension.json")
405 raw, err := os.ReadFile(mfPath)
406 if err != nil {
407 return err
408 }
409 var generic map[string]any
410 if err := json.Unmarshal(raw, &generic); err != nil {
411 return fmt.Errorf("parse manifest: %w", err)
412 }
413 generic["enabled"] = enabled
414 out, err := json.MarshalIndent(generic, "", " ")
415 if err != nil {
416 return err
417 }
418 if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
419 return err
420 }
421 state := "enabled"
422 if !enabled {
423 state = "disabled"
424 }
425 fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
426 return nil
427}
428
429// extRemove deletes an extension's directory after a confirmation
430// prompt (skip with --yes).
431func extRemove(args []string) error {
432 if len(args) == 0 {
433 return fmt.Errorf("usage: zot ext remove <name> [--yes]")
434 }
435 name := args[0]
436 yes := false
437 for _, a := range args[1:] {
438 if a == "--yes" || a == "-y" {
439 yes = true
440 }
441 }
442 dir, err := findExtensionDir(name)
443 if err != nil {
444 return err
445 }
446 if !yes {
447 fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
448 var resp string
449 _, _ = fmt.Scanln(&resp)
450 if !strings.EqualFold(strings.TrimSpace(resp), "y") {
451 fmt.Fprintln(os.Stderr, "aborted")
452 return nil
453 }
454 }
455 if err := os.RemoveAll(dir); err != nil {
456 return err
457 }
458 fmt.Fprintf(os.Stderr, "removed %s\n", dir)
459 return nil
460}
461
462// extInstall copies a local directory or shallow-clones a git URL
463// into $ZOT_HOME/extensions/. Validates the destination contains an
464// extension.json before reporting success.
465func extInstall(args []string) error {
466 if len(args) == 0 {
467 return fmt.Errorf("usage: zot ext install <path|git-url>")
468 }
469 src := args[0]
470 dest := filepath.Join(ZotHome(), "extensions")
471 if err := os.MkdirAll(dest, 0o755); err != nil {
472 return err
473 }
474
475 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
476 // Git clone path. Pick the destination name from the repo basename.
477 name := strings.TrimSuffix(filepath.Base(src), ".git")
478 out := filepath.Join(dest, name)
479 if _, err := os.Stat(out); err == nil {
480 return fmt.Errorf("destination %s already exists; remove it first", out)
481 }
482 cmd := exec.Command("git", "clone", "--depth", "1", src, out)
483 cmd.Stdout = os.Stderr
484 cmd.Stderr = os.Stderr
485 if err := cmd.Run(); err != nil {
486 return fmt.Errorf("git clone: %w", err)
487 }
488 if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
489 _ = os.RemoveAll(out)
490 return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back")
491 }
492 fmt.Fprintf(os.Stderr, "installed %s\n", out)
493 return nil
494 }
495
496 // Local path: must be a directory containing extension.json.
497 info, err := os.Stat(src)
498 if err != nil {
499 return err
500 }
501 if !info.IsDir() {
502 return fmt.Errorf("not a directory: %s", src)
503 }
504 if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
505 return fmt.Errorf("source lacks extension.json")
506 }
507 // Resolve to an absolute, cleaned path before deriving the install
508 // name. Otherwise relative sources like "." or "./" collapse to a
509 // basename of ".", and the destination wrongly resolves to the
510 // extensions/ parent directory (which zot creates on first run),
511 // triggering a false "already exists" failure.
512 absSrc, err := filepath.Abs(src)
513 if err != nil {
514 return err
515 }
516 name := filepath.Base(absSrc)
517 if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
518 return fmt.Errorf("cannot derive extension name from %q", src)
519 }
520 out := filepath.Join(dest, name)
521 if _, err := os.Stat(out); err == nil {
522 return fmt.Errorf("destination %s already exists; remove it first", out)
523 }
524 if err := copyDir(absSrc, out); err != nil {
525 return err
526 }
527 fmt.Fprintf(os.Stderr, "installed %s\n", out)
528 return nil
529}
530
531func extensionDirs() map[string]string {
532 out := map[string]string{}
533 if h := ZotHome(); h != "" {
534 out["global"] = filepath.Join(h, "extensions")
535 }
536 if cwd, err := os.Getwd(); err == nil {
537 out["project"] = filepath.Join(cwd, ".zot", "extensions")
538 }
539 return out
540}
541
542func findExtensionDir(name string) (string, error) {
543 for _, dir := range extensionDirs() {
544 candidate := filepath.Join(dir, name)
545 if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
546 return candidate, nil
547 }
548 }
549 return "", fmt.Errorf("extension %q not found", name)
550}
551
552func dashIfEmpty(s string) string {
553 if s == "" {
554 return "-"
555 }
556 return s
557}
558
559// copyDir does a recursive copy of src to dst preserving file mode
560// bits. Used by `zot ext install <local-path>`.
561//
562// Entries matched by the source's root .gitignore are skipped, and
563// .git itself is always skipped. This keeps non-portable, regeneratable
564// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
565// out of the installed copy so the extension stays functional at its new
566// location.
567func copyDir(src, dst string) error {
568 ig := loadGitignore(src)
569 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
570 if err != nil {
571 return err
572 }
573 rel, err := filepath.Rel(src, path)
574 if err != nil {
575 return err
576 }
577 if rel != "." {
578 name := filepath.Base(rel)
579 if info.IsDir() && name == ".git" {
580 return filepath.SkipDir
581 }
582 if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
583 if info.IsDir() {
584 return filepath.SkipDir
585 }
586 return nil
587 }
588 }
589 target := filepath.Join(dst, rel)
590 if info.IsDir() {
591 return os.MkdirAll(target, info.Mode())
592 }
593 in, err := os.Open(path)
594 if err != nil {
595 return err
596 }
597 defer in.Close()
598 out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
599 if err != nil {
600 return err
601 }
602 defer out.Close()
603 _, err = io.Copy(out, in)
604 return err
605 })
606}
607
608// gitignore matching lives in packages/ignore so the @-file picker in
609// packages/agent/modes can share it without an import cycle. These
610// thin aliases keep the existing call sites (and tests) terse.
611type gitignore = ignore.Gitignore
612
613func loadGitignore(root string) *gitignore { return ignore.Load(root) }
614
615func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }