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}