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