v0.3.46 b3121d6

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

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