v0.1.68 9b4f4da

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	name := filepath.Base(src)
273	out := filepath.Join(dest, name)
274	if _, err := os.Stat(out); err == nil {
275		return fmt.Errorf("destination %s already exists; remove it first", out)
276	}
277	if err := copyDir(src, out); err != nil {
278		return err
279	}
280	fmt.Fprintf(os.Stderr, "installed %s\n", out)
281	return nil
282}
283
284func extensionDirs() map[string]string {
285	out := map[string]string{}
286	if h := ZotHome(); h != "" {
287		out["global"] = filepath.Join(h, "extensions")
288	}
289	if cwd, err := os.Getwd(); err == nil {
290		out["project"] = filepath.Join(cwd, ".zot", "extensions")
291	}
292	return out
293}
294
295func findExtensionDir(name string) (string, error) {
296	for _, dir := range extensionDirs() {
297		candidate := filepath.Join(dir, name)
298		if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
299			return candidate, nil
300		}
301	}
302	return "", fmt.Errorf("extension %q not found", name)
303}
304
305func dashIfEmpty(s string) string {
306	if s == "" {
307		return "-"
308	}
309	return s
310}
311
312// copyDir does a recursive copy of src to dst preserving file mode
313// bits. Used by `zot ext install <local-path>`.
314func copyDir(src, dst string) error {
315	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
316		if err != nil {
317			return err
318		}
319		rel, err := filepath.Rel(src, path)
320		if err != nil {
321			return err
322		}
323		target := filepath.Join(dst, rel)
324		if info.IsDir() {
325			return os.MkdirAll(target, info.Mode())
326		}
327		in, err := os.Open(path)
328		if err != nil {
329			return err
330		}
331		defer in.Close()
332		out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
333		if err != nil {
334			return err
335		}
336		defer out.Close()
337		_, err = io.Copy(out, in)
338		return err
339	})
340}

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