v0.2.20 6938d13

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
13// runExtCommand dispatches `zot ext ...` subcommands. Returns
14// (handled=true, err) if rawArgs starts with "ext"; otherwise
15// (handled=false, nil) so the main router falls through to the
16// regular flag parser.
17func runExtCommand(rawArgs []string) (handled bool, err error) {
18	if len(rawArgs) == 0 || rawArgs[0] != "ext" {
19		return false, nil
20	}
21	if len(rawArgs) == 1 {
22		printExtHelp()
23		return true, nil
24	}
25	switch rawArgs[1] {
26	case "list":
27		return true, extList()
28	case "logs":
29		return true, extLogs(rawArgs[2:])
30	case "enable":
31		return true, extToggle(rawArgs[2:], true)
32	case "disable":
33		return true, extToggle(rawArgs[2:], false)
34	case "remove", "rm":
35		return true, extRemove(rawArgs[2:])
36	case "install":
37		return true, extInstall(rawArgs[2:])
38	case "help", "-h", "--help":
39		printExtHelp()
40		return true, nil
41	default:
42		printExtHelp()
43		return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1])
44	}
45}
46
47func printExtHelp() {
48	fmt.Fprintln(os.Stderr, `zot ext — manage extensions
49
50usage:
51  zot ext list                    list installed extensions and their state
52  zot ext logs <name> [-f]        cat / tail an extension's stderr log
53  zot ext enable <name>           re-enable a disabled extension
54  zot ext disable <name>          disable without removing
55  zot ext remove <name>           delete an extension directory
56  zot ext install <path|git-url>  copy / clone an extension into $ZOT_HOME/extensions/
57
58extensions live under:
59  $ZOT_HOME/extensions/<name>/extension.json   (global)
60  ./.zot/extensions/<name>/extension.json      (project-local)`)
61}
62
63// extList walks both the global and project-local extension dirs and
64// prints a one-row-per-extension table.
65func extList() error {
66	type row struct {
67		Scope    string
68		Name     string
69		Version  string
70		Enabled  string
71		Language string
72		Dir      string
73	}
74	var rows []row
75	for scope, dir := range extensionDirs() {
76		entries, err := os.ReadDir(dir)
77		if err != nil {
78			continue
79		}
80		for _, e := range entries {
81			if !e.IsDir() {
82				continue
83			}
84			extDir := filepath.Join(dir, e.Name())
85			mfPath := filepath.Join(extDir, "extension.json")
86			raw, err := os.ReadFile(mfPath)
87			if err != nil {
88				continue
89			}
90			var m struct {
91				Name     string `json:"name"`
92				Version  string `json:"version"`
93				Language string `json:"language"`
94				Enabled  *bool  `json:"enabled"`
95			}
96			if err := json.Unmarshal(raw, &m); err != nil {
97				continue
98			}
99			enabled := "yes"
100			if m.Enabled != nil && !*m.Enabled {
101				enabled = "no"
102			}
103			rows = append(rows, row{
104				Scope: scope, Name: m.Name, Version: m.Version,
105				Enabled: enabled, Language: m.Language, Dir: extDir,
106			})
107		}
108	}
109	if len(rows) == 0 {
110		fmt.Fprintln(os.Stderr, "no extensions installed")
111		fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
112		return nil
113	}
114	fmt.Printf("%-12s  %-20s  %-10s  %-8s  %-10s  %s\n", "scope", "name", "version", "enabled", "language", "dir")
115	for _, r := range rows {
116		fmt.Printf("%-12s  %-20s  %-10s  %-8s  %-10s  %s\n",
117			r.Scope, r.Name, dashIfEmpty(r.Version),
118			r.Enabled, dashIfEmpty(r.Language), r.Dir)
119	}
120	return nil
121}
122
123// extLogs locates the named extension's log file and either cats or
124// tails it (-f).
125func extLogs(args []string) error {
126	if len(args) == 0 {
127		return fmt.Errorf("usage: zot ext logs <name> [-f]")
128	}
129	name := args[0]
130	follow := false
131	for _, a := range args[1:] {
132		if a == "-f" || a == "--follow" {
133			follow = true
134		}
135	}
136	logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
137	if _, err := os.Stat(logPath); err != nil {
138		return fmt.Errorf("no log for %q at %s", name, logPath)
139	}
140	if !follow {
141		f, err := os.Open(logPath)
142		if err != nil {
143			return err
144		}
145		defer f.Close()
146		_, err = io.Copy(os.Stdout, f)
147		return err
148	}
149	cmd := exec.Command("tail", "-F", logPath)
150	cmd.Stdout = os.Stdout
151	cmd.Stderr = os.Stderr
152	return cmd.Run()
153}
154
155// extToggle flips the enabled flag in an extension's manifest.
156func extToggle(args []string, enabled bool) error {
157	if len(args) == 0 {
158		verb := "enable"
159		if !enabled {
160			verb = "disable"
161		}
162		return fmt.Errorf("usage: zot ext %s <name>", verb)
163	}
164	name := args[0]
165	dir, err := findExtensionDir(name)
166	if err != nil {
167		return err
168	}
169	mfPath := filepath.Join(dir, "extension.json")
170	raw, err := os.ReadFile(mfPath)
171	if err != nil {
172		return err
173	}
174	var generic map[string]any
175	if err := json.Unmarshal(raw, &generic); err != nil {
176		return fmt.Errorf("parse manifest: %w", err)
177	}
178	generic["enabled"] = enabled
179	out, err := json.MarshalIndent(generic, "", "  ")
180	if err != nil {
181		return err
182	}
183	if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
184		return err
185	}
186	state := "enabled"
187	if !enabled {
188		state = "disabled"
189	}
190	fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
191	return nil
192}
193
194// extRemove deletes an extension's directory after a confirmation
195// prompt (skip with --yes).
196func extRemove(args []string) error {
197	if len(args) == 0 {
198		return fmt.Errorf("usage: zot ext remove <name> [--yes]")
199	}
200	name := args[0]
201	yes := false
202	for _, a := range args[1:] {
203		if a == "--yes" || a == "-y" {
204			yes = true
205		}
206	}
207	dir, err := findExtensionDir(name)
208	if err != nil {
209		return err
210	}
211	if !yes {
212		fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
213		var resp string
214		_, _ = fmt.Scanln(&resp)
215		if !strings.EqualFold(strings.TrimSpace(resp), "y") {
216			fmt.Fprintln(os.Stderr, "aborted")
217			return nil
218		}
219	}
220	if err := os.RemoveAll(dir); err != nil {
221		return err
222	}
223	fmt.Fprintf(os.Stderr, "removed %s\n", dir)
224	return nil
225}
226
227// extInstall copies a local directory or shallow-clones a git URL
228// into $ZOT_HOME/extensions/. Validates the destination contains an
229// extension.json before reporting success.
230func extInstall(args []string) error {
231	if len(args) == 0 {
232		return fmt.Errorf("usage: zot ext install <path|git-url>")
233	}
234	src := args[0]
235	dest := filepath.Join(ZotHome(), "extensions")
236	if err := os.MkdirAll(dest, 0o755); err != nil {
237		return err
238	}
239
240	if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
241		// Git clone path. Pick the destination name from the repo basename.
242		name := strings.TrimSuffix(filepath.Base(src), ".git")
243		out := filepath.Join(dest, name)
244		if _, err := os.Stat(out); err == nil {
245			return fmt.Errorf("destination %s already exists; remove it first", out)
246		}
247		cmd := exec.Command("git", "clone", "--depth", "1", src, out)
248		cmd.Stdout = os.Stderr
249		cmd.Stderr = os.Stderr
250		if err := cmd.Run(); err != nil {
251			return fmt.Errorf("git clone: %w", err)
252		}
253		if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
254			_ = os.RemoveAll(out)
255			return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back")
256		}
257		fmt.Fprintf(os.Stderr, "installed %s\n", out)
258		return nil
259	}
260
261	// Local path: must be a directory containing extension.json.
262	info, err := os.Stat(src)
263	if err != nil {
264		return err
265	}
266	if !info.IsDir() {
267		return fmt.Errorf("not a directory: %s", src)
268	}
269	if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
270		return fmt.Errorf("source lacks extension.json")
271	}
272	// Resolve to an absolute, cleaned path before deriving the install
273	// name. Otherwise relative sources like "." or "./" collapse to a
274	// basename of ".", and the destination wrongly resolves to the
275	// extensions/ parent directory (which zot creates on first run),
276	// triggering a false "already exists" failure.
277	absSrc, err := filepath.Abs(src)
278	if err != nil {
279		return err
280	}
281	name := filepath.Base(absSrc)
282	if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
283		return fmt.Errorf("cannot derive extension name from %q", src)
284	}
285	out := filepath.Join(dest, name)
286	if _, err := os.Stat(out); err == nil {
287		return fmt.Errorf("destination %s already exists; remove it first", out)
288	}
289	if err := copyDir(absSrc, out); err != nil {
290		return err
291	}
292	fmt.Fprintf(os.Stderr, "installed %s\n", out)
293	return nil
294}
295
296func extensionDirs() map[string]string {
297	out := map[string]string{}
298	if h := ZotHome(); h != "" {
299		out["global"] = filepath.Join(h, "extensions")
300	}
301	if cwd, err := os.Getwd(); err == nil {
302		out["project"] = filepath.Join(cwd, ".zot", "extensions")
303	}
304	return out
305}
306
307func findExtensionDir(name string) (string, error) {
308	for _, dir := range extensionDirs() {
309		candidate := filepath.Join(dir, name)
310		if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
311			return candidate, nil
312		}
313	}
314	return "", fmt.Errorf("extension %q not found", name)
315}
316
317func dashIfEmpty(s string) string {
318	if s == "" {
319		return "-"
320	}
321	return s
322}
323
324// copyDir does a recursive copy of src to dst preserving file mode
325// bits. Used by `zot ext install <local-path>`.
326//
327// Entries matched by the source's root .gitignore are skipped, and
328// .git itself is always skipped. This keeps non-portable, regeneratable
329// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
330// out of the installed copy so the extension stays functional at its new
331// location.
332func copyDir(src, dst string) error {
333	ig := loadGitignore(src)
334	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
335		if err != nil {
336			return err
337		}
338		rel, err := filepath.Rel(src, path)
339		if err != nil {
340			return err
341		}
342		if rel != "." {
343			name := filepath.Base(rel)
344			if info.IsDir() && name == ".git" {
345				return filepath.SkipDir
346			}
347			if ig.match(filepath.ToSlash(rel), info.IsDir()) {
348				if info.IsDir() {
349					return filepath.SkipDir
350				}
351				return nil
352			}
353		}
354		target := filepath.Join(dst, rel)
355		if info.IsDir() {
356			return os.MkdirAll(target, info.Mode())
357		}
358		in, err := os.Open(path)
359		if err != nil {
360			return err
361		}
362		defer in.Close()
363		out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
364		if err != nil {
365			return err
366		}
367		defer out.Close()
368		_, err = io.Copy(out, in)
369		return err
370	})
371}
372
373// gitignore is a minimal .gitignore matcher. It supports the common
374// patterns used in real extension repos: blank lines, comments (#),
375// negation (!), directory-only patterns (trailing /), anchored
376// patterns (leading /), and the * / ? / [..] wildcards via
377// filepath.Match. It intentionally does not implement ** globstar or
378// nested per-directory .gitignore files; the goal is to drop obvious
379// non-portable directories, not to be a faithful git reimplementation.
380type gitignore struct {
381	rules []gitignoreRule
382}
383
384type gitignoreRule struct {
385	pattern  string
386	negate   bool
387	dirOnly  bool
388	anchored bool
389}
390
391func loadGitignore(root string) *gitignore {
392	data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
393	if err != nil {
394		return &gitignore{}
395	}
396	return loadGitignoreFromString(string(data))
397}
398
399func loadGitignoreFromString(data string) *gitignore {
400	g := &gitignore{}
401	for _, line := range strings.Split(data, "\n") {
402		line = strings.TrimRight(line, "\r")
403		trimmed := strings.TrimSpace(line)
404		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
405			continue
406		}
407		r := gitignoreRule{pattern: trimmed}
408		if strings.HasPrefix(r.pattern, "!") {
409			r.negate = true
410			r.pattern = r.pattern[1:]
411		}
412		if strings.HasSuffix(r.pattern, "/") {
413			r.dirOnly = true
414			r.pattern = strings.TrimSuffix(r.pattern, "/")
415		}
416		if strings.HasPrefix(r.pattern, "/") {
417			r.anchored = true
418			r.pattern = strings.TrimPrefix(r.pattern, "/")
419		}
420		if r.pattern == "" {
421			continue
422		}
423		g.rules = append(g.rules, r)
424	}
425	return g
426}
427
428// match reports whether the slash-separated relative path should be
429// ignored. Later rules win, so a trailing negation can re-include a
430// previously ignored path.
431func (g *gitignore) match(rel string, isDir bool) bool {
432	ignored := false
433	for _, r := range g.rules {
434		if r.dirOnly && !isDir {
435			continue
436		}
437		if r.matchPath(rel) {
438			ignored = !r.negate
439		}
440	}
441	return ignored
442}
443
444func (r gitignoreRule) matchPath(rel string) bool {
445	if r.anchored || strings.Contains(r.pattern, "/") {
446		if ok, _ := filepath.Match(r.pattern, rel); ok {
447			return true
448		}
449		// Anchored directory pattern also matches everything beneath it.
450		return strings.HasPrefix(rel, r.pattern+"/")
451	}
452	// Unanchored: match the basename of any path component.
453	base := rel
454	if i := strings.LastIndex(rel, "/"); i >= 0 {
455		base = rel[i+1:]
456	}
457	if ok, _ := filepath.Match(r.pattern, base); ok {
458		return true
459	}
460	// Match a directory component anywhere in the path.
461	for _, part := range strings.Split(rel, "/") {
462		if ok, _ := filepath.Match(r.pattern, part); ok {
463			return true
464		}
465	}
466	return false
467}

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