v0.2.47 b325477

extcmd.go RawHistoryFolder

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