package agent import ( "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "strings" ) // runExtCommand dispatches `zot ext ...` subcommands. Returns // (handled=true, err) if rawArgs starts with "ext"; otherwise // (handled=false, nil) so the main router falls through to the // regular flag parser. func runExtCommand(rawArgs []string) (handled bool, err error) { if len(rawArgs) == 0 || rawArgs[0] != "ext" { return false, nil } if len(rawArgs) == 1 { printExtHelp() return true, nil } switch rawArgs[1] { case "list": return true, extList() case "logs": return true, extLogs(rawArgs[2:]) case "enable": return true, extToggle(rawArgs[2:], true) case "disable": return true, extToggle(rawArgs[2:], false) case "remove", "rm": return true, extRemove(rawArgs[2:]) case "install": return true, extInstall(rawArgs[2:]) case "help", "-h", "--help": printExtHelp() return true, nil default: printExtHelp() return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1]) } } func printExtHelp() { fmt.Fprintln(os.Stderr, `zot ext — manage extensions usage: zot ext list list installed extensions and their state zot ext logs [-f] cat / tail an extension's stderr log zot ext enable re-enable a disabled extension zot ext disable disable without removing zot ext remove delete an extension directory zot ext install copy / clone an extension into $ZOT_HOME/extensions/ extensions live under: $ZOT_HOME/extensions//extension.json (global) ./.zot/extensions//extension.json (project-local)`) } // extList walks both the global and project-local extension dirs and // prints a one-row-per-extension table. func extList() error { type row struct { Scope string Name string Version string Enabled string Language string Dir string } var rows []row for scope, dir := range extensionDirs() { entries, err := os.ReadDir(dir) if err != nil { continue } for _, e := range entries { if !e.IsDir() { continue } extDir := filepath.Join(dir, e.Name()) mfPath := filepath.Join(extDir, "extension.json") raw, err := os.ReadFile(mfPath) if err != nil { continue } var m struct { Name string `json:"name"` Version string `json:"version"` Language string `json:"language"` Enabled *bool `json:"enabled"` } if err := json.Unmarshal(raw, &m); err != nil { continue } enabled := "yes" if m.Enabled != nil && !*m.Enabled { enabled = "no" } rows = append(rows, row{ Scope: scope, Name: m.Name, Version: m.Version, Enabled: enabled, Language: m.Language, Dir: extDir, }) } } if len(rows) == 0 { fmt.Fprintln(os.Stderr, "no extensions installed") fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zot ext install `") return nil } fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", "scope", "name", "version", "enabled", "language", "dir") for _, r := range rows { fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", r.Scope, r.Name, dashIfEmpty(r.Version), r.Enabled, dashIfEmpty(r.Language), r.Dir) } return nil } // extLogs locates the named extension's log file and either cats or // tails it (-f). func extLogs(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: zot ext logs [-f]") } name := args[0] follow := false for _, a := range args[1:] { if a == "-f" || a == "--follow" { follow = true } } logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log") if _, err := os.Stat(logPath); err != nil { return fmt.Errorf("no log for %q at %s", name, logPath) } if !follow { f, err := os.Open(logPath) if err != nil { return err } defer f.Close() _, err = io.Copy(os.Stdout, f) return err } cmd := exec.Command("tail", "-F", logPath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // extToggle flips the enabled flag in an extension's manifest. func extToggle(args []string, enabled bool) error { if len(args) == 0 { verb := "enable" if !enabled { verb = "disable" } return fmt.Errorf("usage: zot ext %s ", verb) } name := args[0] dir, err := findExtensionDir(name) if err != nil { return err } mfPath := filepath.Join(dir, "extension.json") raw, err := os.ReadFile(mfPath) if err != nil { return err } var generic map[string]any if err := json.Unmarshal(raw, &generic); err != nil { return fmt.Errorf("parse manifest: %w", err) } generic["enabled"] = enabled out, err := json.MarshalIndent(generic, "", " ") if err != nil { return err } if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil { return err } state := "enabled" if !enabled { state = "disabled" } fmt.Fprintf(os.Stderr, "%s %s\n", state, name) return nil } // extRemove deletes an extension's directory after a confirmation // prompt (skip with --yes). func extRemove(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: zot ext remove [--yes]") } name := args[0] yes := false for _, a := range args[1:] { if a == "--yes" || a == "-y" { yes = true } } dir, err := findExtensionDir(name) if err != nil { return err } if !yes { fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir) var resp string _, _ = fmt.Scanln(&resp) if !strings.EqualFold(strings.TrimSpace(resp), "y") { fmt.Fprintln(os.Stderr, "aborted") return nil } } if err := os.RemoveAll(dir); err != nil { return err } fmt.Fprintf(os.Stderr, "removed %s\n", dir) return nil } // extInstall copies a local directory or shallow-clones a git URL // into $ZOT_HOME/extensions/. Validates the destination contains an // extension.json before reporting success. func extInstall(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: zot ext install ") } src := args[0] dest := filepath.Join(ZotHome(), "extensions") if err := os.MkdirAll(dest, 0o755); err != nil { return err } if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") { // Git clone path. Pick the destination name from the repo basename. name := strings.TrimSuffix(filepath.Base(src), ".git") out := filepath.Join(dest, name) if _, err := os.Stat(out); err == nil { return fmt.Errorf("destination %s already exists; remove it first", out) } cmd := exec.Command("git", "clone", "--depth", "1", src, out) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("git clone: %w", err) } if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { _ = os.RemoveAll(out) return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back") } fmt.Fprintf(os.Stderr, "installed %s\n", out) return nil } // Local path: must be a directory containing extension.json. info, err := os.Stat(src) if err != nil { return err } if !info.IsDir() { return fmt.Errorf("not a directory: %s", src) } if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { return fmt.Errorf("source lacks extension.json") } // Resolve to an absolute, cleaned path before deriving the install // name. Otherwise relative sources like "." or "./" collapse to a // basename of ".", and the destination wrongly resolves to the // extensions/ parent directory (which zot creates on first run), // triggering a false "already exists" failure. absSrc, err := filepath.Abs(src) if err != nil { return err } name := filepath.Base(absSrc) if name == "." || name == ".." || name == string(filepath.Separator) || name == "" { return fmt.Errorf("cannot derive extension name from %q", src) } out := filepath.Join(dest, name) if _, err := os.Stat(out); err == nil { return fmt.Errorf("destination %s already exists; remove it first", out) } if err := copyDir(absSrc, out); err != nil { return err } fmt.Fprintf(os.Stderr, "installed %s\n", out) return nil } func extensionDirs() map[string]string { out := map[string]string{} if h := ZotHome(); h != "" { out["global"] = filepath.Join(h, "extensions") } if cwd, err := os.Getwd(); err == nil { out["project"] = filepath.Join(cwd, ".zot", "extensions") } return out } func findExtensionDir(name string) (string, error) { for _, dir := range extensionDirs() { candidate := filepath.Join(dir, name) if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil { return candidate, nil } } return "", fmt.Errorf("extension %q not found", name) } func dashIfEmpty(s string) string { if s == "" { return "-" } return s } // copyDir does a recursive copy of src to dst preserving file mode // bits. Used by `zot ext install `. // // Entries matched by the source's root .gitignore are skipped, and // .git itself is always skipped. This keeps non-portable, regeneratable // directories (e.g. .venv with hardcoded rpaths, node_modules, target/) // out of the installed copy so the extension stays functional at its new // location. func copyDir(src, dst string) error { ig := loadGitignore(src) return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(src, path) if err != nil { return err } if rel != "." { name := filepath.Base(rel) if info.IsDir() && name == ".git" { return filepath.SkipDir } if ig.match(filepath.ToSlash(rel), info.IsDir()) { if info.IsDir() { return filepath.SkipDir } return nil } } target := filepath.Join(dst, rel) if info.IsDir() { return os.MkdirAll(target, info.Mode()) } in, err := os.Open(path) if err != nil { return err } defer in.Close() out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err }) } // gitignore is a minimal .gitignore matcher. It supports the common // patterns used in real extension repos: blank lines, comments (#), // negation (!), directory-only patterns (trailing /), anchored // patterns (leading /), and the * / ? / [..] wildcards via // filepath.Match. It intentionally does not implement ** globstar or // nested per-directory .gitignore files; the goal is to drop obvious // non-portable directories, not to be a faithful git reimplementation. type gitignore struct { rules []gitignoreRule } type gitignoreRule struct { pattern string negate bool dirOnly bool anchored bool } func loadGitignore(root string) *gitignore { data, err := os.ReadFile(filepath.Join(root, ".gitignore")) if err != nil { return &gitignore{} } return loadGitignoreFromString(string(data)) } func loadGitignoreFromString(data string) *gitignore { g := &gitignore{} for _, line := range strings.Split(data, "\n") { line = strings.TrimRight(line, "\r") trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } r := gitignoreRule{pattern: trimmed} if strings.HasPrefix(r.pattern, "!") { r.negate = true r.pattern = r.pattern[1:] } if strings.HasSuffix(r.pattern, "/") { r.dirOnly = true r.pattern = strings.TrimSuffix(r.pattern, "/") } if strings.HasPrefix(r.pattern, "/") { r.anchored = true r.pattern = strings.TrimPrefix(r.pattern, "/") } if r.pattern == "" { continue } g.rules = append(g.rules, r) } return g } // match reports whether the slash-separated relative path should be // ignored. Later rules win, so a trailing negation can re-include a // previously ignored path. func (g *gitignore) match(rel string, isDir bool) bool { ignored := false for _, r := range g.rules { if r.dirOnly && !isDir { continue } if r.matchPath(rel) { ignored = !r.negate } } return ignored } func (r gitignoreRule) matchPath(rel string) bool { if r.anchored || strings.Contains(r.pattern, "/") { if ok, _ := filepath.Match(r.pattern, rel); ok { return true } // Anchored directory pattern also matches everything beneath it. return strings.HasPrefix(rel, r.pattern+"/") } // Unanchored: match the basename of any path component. base := rel if i := strings.LastIndex(rel, "/"); i >= 0 { base = rel[i+1:] } if ok, _ := filepath.Match(r.pattern, base); ok { return true } // Match a directory component anywhere in the path. for _, part := range strings.Split(rel, "/") { if ok, _ := filepath.Match(r.pattern, part); ok { return true } } return false }