v0.3.75 64117f3

extcmd.go RawHistoryFolder

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) }

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