1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "runtime"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/bnema/zut/packages/agent/extensions"
18 "github.com/bnema/zut/packages/agent/extproto"
19 "github.com/bnema/zut/packages/ignore"
20)
21
22// runExtCommand dispatches `zut ext ...` subcommands. Returns
23// (handled=true, err) if rawArgs starts with "ext"; otherwise
24// (handled=false, nil) so the main router falls through to the
25// regular flag parser.
26func runExtCommand(ctx context.Context, rawArgs []string, version string) (handled bool, err error) {
27 if len(rawArgs) == 0 || rawArgs[0] != "ext" {
28 return false, nil
29 }
30 if len(rawArgs) == 1 {
31 printExtHelp()
32 return true, nil
33 }
34 switch rawArgs[1] {
35 case "list":
36 return true, extList()
37 case "doctor":
38 return true, extDoctor(version)
39 case "logs":
40 return true, extLogs(rawArgs[2:])
41 case "enable":
42 return true, extToggle(rawArgs[2:], true)
43 case "disable":
44 return true, extToggle(rawArgs[2:], false)
45 case "remove", "rm":
46 return true, extRemove(rawArgs[2:])
47 case "install":
48 return true, extInstallContext(ctx, rawArgs[2:])
49 case "help", "-h", "--help":
50 printExtHelp()
51 return true, nil
52 default:
53 printExtHelp()
54 return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1])
55 }
56}
57
58func printExtHelp() {
59 fmt.Fprintln(os.Stderr, `zut ext — manage extensions
60
61usage:
62 zut ext list list installed extensions and their state
63 zut ext doctor diagnose installed extensions
64 zut ext logs <name> [-f] cat / tail an extension's stderr log
65 zut ext enable <name> re-enable a disabled extension
66 zut ext disable <name> disable without removing
67 zut ext remove <name> delete an extension directory
68 zut ext install [--build=go] <path|git-url>
69 copy / clone and validate an extension
70 --build=go explicitly builds a local Go extension
71
72extensions live under:
73 $ZUT_HOME/extensions/<name>/extension.json (global)
74 ./.zut/extensions/<name>/extension.json (project-local)`)
75}
76
77// extList walks both the global and project-local extension dirs and
78// prints a one-row-per-extension table.
79func extList() error {
80 type row struct {
81 Scope string
82 Name string
83 Version string
84 Enabled string
85 Language string
86 Dir string
87 }
88 var rows []row
89 for scope, dir := range extensionDirs() {
90 entries, err := os.ReadDir(dir)
91 if err != nil {
92 continue
93 }
94 for _, e := range entries {
95 if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
96 continue
97 }
98 extDir := filepath.Join(dir, e.Name())
99 mfPath := filepath.Join(extDir, "extension.json")
100 raw, err := os.ReadFile(mfPath)
101 if err != nil {
102 continue
103 }
104 var m struct {
105 Name string `json:"name"`
106 Version string `json:"version"`
107 Language string `json:"language"`
108 Enabled *bool `json:"enabled"`
109 }
110 if err := json.Unmarshal(raw, &m); err != nil {
111 continue
112 }
113 enabled := "yes"
114 if m.Enabled != nil && !*m.Enabled {
115 enabled = "no"
116 }
117 rows = append(rows, row{
118 Scope: scope, Name: m.Name, Version: m.Version,
119 Enabled: enabled, Language: m.Language, Dir: extDir,
120 })
121 }
122 }
123 if len(rows) == 0 {
124 fmt.Fprintln(os.Stderr, "no extensions installed")
125 fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zut ext install <path|url>`")
126 return nil
127 }
128 fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", "scope", "name", "version", "enabled", "language", "dir")
129 for _, r := range rows {
130 fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n",
131 r.Scope, r.Name, dashIfEmpty(r.Version),
132 r.Enabled, dashIfEmpty(r.Language), r.Dir)
133 }
134 return nil
135}
136
137type extDoctorHooks struct{}
138
139func (extDoctorHooks) Notify(string, string, string) {}
140func (extDoctorHooks) Alert(string, extproto.AlertRequest) {}
141func (extDoctorHooks) Submit(string) {}
142func (extDoctorHooks) SubmitSlash(string) {}
143func (extDoctorHooks) Insert(string) {}
144func (extDoctorHooks) Display(string, string) {}
145func (extDoctorHooks) ClearNotes(string) {}
146func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {}
147func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}
148func (extDoctorHooks) ClosePanel(string, string) {}
149func (extDoctorHooks) SetStatus(string, string, string, string) {}
150func (extDoctorHooks) SetWidget(string, string, string, string, []string) {}
151func (extDoctorHooks) ClearWidget(string, string) {}
152
153type extDoctorStaticRow struct {
154 Scope string
155 Name string
156 Version string
157 Enabled bool
158 Dir string
159 Exec string
160 Theme bool
161 Manifest string
162 Error string
163 Shadowed bool
164}
165
166// extDoctor diagnoses extension discovery and registration without changing
167// normal fail-soft extension behavior.
168func extDoctor(version string) error {
169 rows := scanExtDoctorStatic()
170 if len(rows) == 0 {
171 fmt.Fprintln(os.Stdout, "no extensions installed")
172 fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zut ext install <path|url>`")
173 return nil
174 }
175
176 cwd, _ := os.Getwd()
177 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
178 defer cancel()
179 mgr := extensions.New(ZutHome(), cwd, version, "", "", extDoctorHooks{})
180 errs := mgr.Discover(ctx)
181 mgr.WaitForReady(3 * time.Second)
182 diags := mgr.Diagnostics()
183 mgr.Stop(500 * time.Millisecond)
184
185 diagByDir := map[string]extensions.ExtensionDiagnostic{}
186 for _, d := range diags {
187 diagByDir[d.Dir] = d
188 }
189
190 fmt.Fprintln(os.Stdout, "zut extension doctor")
191 fmt.Fprintln(os.Stdout)
192 for _, row := range rows {
193 printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir])
194 }
195 if len(errs) > 0 {
196 fmt.Fprintln(os.Stdout, "load errors:")
197 for _, err := range errs {
198 // Multiline diagnostics keep their line breaks; indent
199 // continuations so they read as one entry.
200 indented := strings.ReplaceAll(err.Error(), "\n", "\n ")
201 fmt.Fprintf(os.Stdout, " ! %s\n", indented)
202 }
203 }
204 return nil
205}
206
207func scanExtDoctorStatic() []extDoctorStaticRow {
208 type scanDir struct {
209 scope string
210 dir string
211 }
212 var dirs []scanDir
213 if cwd, err := os.Getwd(); err == nil {
214 dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zut", "extensions")})
215 }
216 if h := ZutHome(); h != "" {
217 dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")})
218 }
219
220 seen := map[string]bool{}
221 var rows []extDoctorStaticRow
222 for _, sd := range dirs {
223 entries, err := os.ReadDir(sd.dir)
224 if err != nil {
225 continue
226 }
227 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
228 for _, e := range entries {
229 if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
230 continue
231 }
232 extDir := filepath.Join(sd.dir, e.Name())
233 row := extDoctorStaticRow{
234 Scope: sd.scope,
235 Name: e.Name(),
236 Enabled: true,
237 Dir: extDir,
238 Manifest: filepath.Join(extDir, "extension.json"),
239 Theme: extensions.HasExtensionTheme(extDir),
240 }
241 if seen[e.Name()] {
242 row.Shadowed = true
243 } else {
244 seen[e.Name()] = true
245 }
246 raw, err := os.ReadFile(row.Manifest)
247 if err != nil {
248 row.Error = "missing extension.json"
249 rows = append(rows, row)
250 continue
251 }
252 var m struct {
253 Name string `json:"name"`
254 Version string `json:"version"`
255 Exec string `json:"exec"`
256 Enabled *bool `json:"enabled"`
257 }
258 if err := json.Unmarshal(raw, &m); err != nil {
259 row.Error = "parse manifest: " + err.Error()
260 rows = append(rows, row)
261 continue
262 }
263 if m.Name != "" {
264 row.Name = m.Name
265 } else {
266 row.Error = "manifest: name is required"
267 }
268 row.Version = m.Version
269 row.Exec = m.Exec
270 if m.Enabled != nil {
271 row.Enabled = *m.Enabled
272 }
273 if row.Exec == "" && !row.Theme && row.Error == "" {
274 row.Error = "manifest: exec is required"
275 }
276 rows = append(rows, row)
277 }
278 }
279 return rows
280}
281
282func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) {
283 status := "ok"
284 switch {
285 case row.Error != "":
286 status = "error"
287 case row.Shadowed:
288 status = "shadowed"
289 case !row.Enabled:
290 status = "disabled"
291 case row.Exec == "" && row.Theme:
292 status = "theme-only"
293 case diag.Name == "":
294 status = "not loaded"
295 case diag.ReadyTimedOut:
296 status = "ready-timeout"
297 case diag.AutoReady:
298 status = "auto-ready"
299 case !diag.Ready:
300 status = "not ready"
301 case len(diag.Messages) > 0:
302 status = "warning"
303 }
304 fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir)
305 if row.Version != "" {
306 fmt.Fprintf(w, " version: %s\n", row.Version)
307 }
308 if row.Error != "" {
309 if row.Exec != "" {
310 fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name))
311 }
312 fmt.Fprintf(w, " error: %s\n", row.Error)
313 return
314 }
315 if row.Shadowed {
316 fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins")
317 return
318 }
319 if !row.Enabled {
320 fmt.Fprintln(w, " note: disabled in extension.json")
321 return
322 }
323 if row.Exec == "" && row.Theme {
324 fmt.Fprintln(w, " type: theme-only")
325 return
326 }
327 if row.Exec != "" {
328 fmt.Fprintf(w, " exec: %s\n", row.Exec)
329 }
330 logPath := diag.LogPath
331 if logPath == "" && row.Exec != "" {
332 logPath = extDoctorLogPath(row.Name)
333 }
334 if logPath != "" {
335 fmt.Fprintf(w, " log: %s\n", logPath)
336 }
337 if len(diag.Commands) > 0 {
338 fmt.Fprintln(w, " commands:")
339 for _, c := range diag.Commands {
340 state := "active"
341 if !c.Active {
342 state = "shadowed"
343 }
344 fmt.Fprintf(w, " /%s (%s)\n", c.Name, state)
345 }
346 }
347 if len(diag.Tools) > 0 {
348 fmt.Fprintln(w, " tools:")
349 for _, t := range diag.Tools {
350 state := "active"
351 if !t.Active {
352 state = "shadowed"
353 }
354 fmt.Fprintf(w, " %s (%s)\n", t.Name, state)
355 }
356 }
357 for _, msg := range diag.Messages {
358 fmt.Fprintf(w, " warning: %s\n", msg)
359 }
360}
361
362func extDoctorLogPath(name string) string {
363 if name == "" || ZutHome() == "" {
364 return ""
365 }
366 return filepath.Join(ZutHome(), "logs", "ext-"+name+".log")
367}
368
369// extLogs locates the named extension's log file and either cats or
370// tails it (-f).
371func extLogs(args []string) error {
372 if len(args) == 0 {
373 return fmt.Errorf("usage: zut ext logs <name> [-f]")
374 }
375 name := args[0]
376 follow := false
377 for _, a := range args[1:] {
378 if a == "-f" || a == "--follow" {
379 follow = true
380 }
381 }
382 logPath := filepath.Join(ZutHome(), "logs", "ext-"+name+".log")
383 if _, err := os.Stat(logPath); err != nil {
384 return fmt.Errorf("no log for %q at %s", name, logPath)
385 }
386 if !follow {
387 f, err := os.Open(logPath)
388 if err != nil {
389 return err
390 }
391 defer f.Close()
392 _, err = io.Copy(os.Stdout, f)
393 return err
394 }
395 cmd := exec.Command("tail", "-F", logPath)
396 cmd.Stdout = os.Stdout
397 cmd.Stderr = os.Stderr
398 return cmd.Run()
399}
400
401// extToggle flips the enabled flag in an extension's manifest.
402func extToggle(args []string, enabled bool) error {
403 if len(args) == 0 {
404 verb := "enable"
405 if !enabled {
406 verb = "disable"
407 }
408 return fmt.Errorf("usage: zut ext %s <name>", verb)
409 }
410 name := args[0]
411 dir, err := findExtensionDir(name)
412 if err != nil {
413 return err
414 }
415 mfPath := filepath.Join(dir, "extension.json")
416 raw, err := os.ReadFile(mfPath)
417 if err != nil {
418 return err
419 }
420 var generic map[string]any
421 if err := json.Unmarshal(raw, &generic); err != nil {
422 return fmt.Errorf("parse manifest: %w", err)
423 }
424 generic["enabled"] = enabled
425 out, err := json.MarshalIndent(generic, "", " ")
426 if err != nil {
427 return err
428 }
429 if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
430 return err
431 }
432 state := "enabled"
433 if !enabled {
434 state = "disabled"
435 }
436 fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
437 return nil
438}
439
440// extRemove deletes an extension's directory after a confirmation
441// prompt (skip with --yes).
442func extRemove(args []string) error {
443 if len(args) == 0 {
444 return fmt.Errorf("usage: zut ext remove <name> [--yes]")
445 }
446 name := args[0]
447 yes := false
448 for _, a := range args[1:] {
449 if a == "--yes" || a == "-y" {
450 yes = true
451 }
452 }
453 dir, err := findExtensionDir(name)
454 if err != nil {
455 return err
456 }
457 if !yes {
458 fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
459 var resp string
460 _, _ = fmt.Scanln(&resp)
461 if !strings.EqualFold(strings.TrimSpace(resp), "y") {
462 fmt.Fprintln(os.Stderr, "aborted")
463 return nil
464 }
465 }
466 if err := os.RemoveAll(dir); err != nil {
467 return err
468 }
469 fmt.Fprintf(os.Stderr, "removed %s\n", dir)
470 return nil
471}
472
473// extInstall copies a local directory or shallow-clones a git URL
474// into $ZUT_HOME/extensions/. It stages the install and validates the
475// manifest plus any extension-local executable before reporting success.
476// Builds are never inferred or run implicitly; --build=go is an explicit
477// opt-in for local Go extensions.
478func extInstall(args []string) error {
479 return extInstallContext(context.Background(), args)
480}
481
482func extInstallContext(ctx context.Context, args []string) error {
483 opts, err := parseExtInstallArgs(args)
484 if err != nil {
485 return err
486 }
487 src := opts.source
488 dest := filepath.Join(ZutHome(), "extensions")
489 if err := os.MkdirAll(dest, 0o755); err != nil {
490 return err
491 }
492
493 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
494 if opts.builder != "" {
495 return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")
496 }
497 return installGitExtension(ctx, src, dest)
498 }
499
500 // Local path: must be a directory containing extension.json.
501 info, err := os.Stat(src)
502 if err != nil {
503 return err
504 }
505 if !info.IsDir() {
506 return fmt.Errorf("not a directory: %s", src)
507 }
508 // Resolve to an absolute, cleaned path before reading the manifest.
509 // This also makes sources like "." safe to install.
510 absSrc, err := filepath.Abs(src)
511 if err != nil {
512 return err
513 }
514 manifest, err := readExtensionManifest(absSrc)
515 if err != nil {
516 return fmt.Errorf("source extension: %w", err)
517 }
518 out := filepath.Join(dest, manifest.Name)
519 if _, err := os.Stat(out); err == nil {
520 return fmt.Errorf("destination %s already exists; remove it first", out)
521 }
522
523 stageRoot, err := os.MkdirTemp(dest, ".zut-extension-install-")
524 if err != nil {
525 return fmt.Errorf("create install staging directory: %w", err)
526 }
527 defer os.RemoveAll(stageRoot)
528 staged := filepath.Join(stageRoot, "extension")
529 if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil {
530 return fmt.Errorf("copy extension: %w", err)
531 }
532 if opts.builder != "" {
533 if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil {
534 return err
535 }
536 }
537 if err := validateExtensionExecutable(staged, manifest); err != nil {
538 return err
539 }
540 if err := os.Rename(staged, out); err != nil {
541 return fmt.Errorf("finalize install: %w", err)
542 }
543 fmt.Fprintf(os.Stderr, "installed %s\n", out)
544 return nil
545}
546
547type extInstallOptions struct {
548 source string
549 builder string
550}
551
552func parseExtInstallArgs(args []string) (extInstallOptions, error) {
553 if len(args) == 0 {
554 return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
555 }
556
557 var opts extInstallOptions
558 builderSet := false
559 for i := 0; i < len(args); i++ {
560 arg := args[i]
561 switch {
562 case arg == "--build":
563 if i+1 >= len(args) {
564 return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
565 }
566 i++
567 if builderSet {
568 return extInstallOptions{}, errors.New("extension builder specified more than once")
569 }
570 builderSet = true
571 opts.builder = strings.ToLower(strings.TrimSpace(args[i]))
572 if opts.builder == "" {
573 return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
574 }
575 case strings.HasPrefix(arg, "--build="):
576 if builderSet {
577 return extInstallOptions{}, errors.New("extension builder specified more than once")
578 }
579 builderSet = true
580 opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build=")))
581 if opts.builder == "" {
582 return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
583 }
584 case strings.HasPrefix(arg, "-"):
585 return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg)
586 case opts.source != "":
587 return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
588 default:
589 opts.source = arg
590 }
591 }
592 if opts.source == "" {
593 return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
594 }
595 if opts.builder != "" && opts.builder != "go" {
596 return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder)
597 }
598 return opts, nil
599}
600
601func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error {
602 if manifest.Exec == "" {
603 return errors.New("cannot build a theme-only extension; it does not declare an executable")
604 }
605 execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec)
606 if err != nil {
607 return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err)
608 }
609 stagedExec := filepath.Join(stagedDir, execRel)
610 if err := os.MkdirAll(filepath.Dir(stagedExec), 0o755); err != nil {
611 return fmt.Errorf("prepare Go build output: %w", err)
612 }
613
614 fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)
615 cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".")
616 cmd.Dir = sourceDir
617 cmd.Stdout = os.Stderr
618 cmd.Stderr = os.Stderr
619 if err := cmd.Run(); err != nil {
620 return fmt.Errorf("build extension with go: %w", err)
621 }
622 return nil
623}
624
625func extensionRelativeExecPath(dir, execName string) (string, error) {
626 normalized := filepath.FromSlash(execName)
627 if filepath.IsAbs(normalized) {
628 return "", fmt.Errorf("extension executable %q is absolute", execName)
629 }
630 resolved, local := extensions.ResolveExecPath(dir, execName)
631 if !local {
632 return "", fmt.Errorf("extension executable %q is a PATH command", execName)
633 }
634 rel, err := filepath.Rel(dir, resolved)
635 if err != nil {
636 return "", fmt.Errorf("resolve extension executable %q: %w", execName, err)
637 }
638 if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
639 return "", fmt.Errorf("extension executable %q escapes the extension directory", execName)
640 }
641 return rel, nil
642}
643
644func installGitExtension(ctx context.Context, src, dest string) error {
645 stageRoot, err := os.MkdirTemp(dest, ".zut-extension-clone-")
646 if err != nil {
647 return fmt.Errorf("create install staging directory: %w", err)
648 }
649 defer os.RemoveAll(stageRoot)
650
651 cloneDir := filepath.Join(stageRoot, "extension")
652 cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir)
653 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
654 cmd.Stdout = os.Stderr
655 cmd.Stderr = os.Stderr
656 if err := cmd.Run(); err != nil {
657 return fmt.Errorf("git clone: %w", err)
658 }
659 manifest, err := readExtensionManifest(cloneDir)
660 if err != nil {
661 return fmt.Errorf("cloned extension: %w", err)
662 }
663 out := filepath.Join(dest, manifest.Name)
664 if _, err := os.Stat(out); err == nil {
665 return fmt.Errorf("destination %s already exists; remove it first", out)
666 }
667 if err := validateExtensionExecutable(cloneDir, manifest); err != nil {
668 return err
669 }
670 if err := os.Rename(cloneDir, out); err != nil {
671 return fmt.Errorf("finalize install: %w", err)
672 }
673 fmt.Fprintf(os.Stderr, "installed %s\n", out)
674 return nil
675}
676
677func readExtensionManifest(dir string) (extensions.Manifest, error) {
678 raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
679 if err != nil {
680 if os.IsNotExist(err) {
681 return extensions.Manifest{}, errors.New("source lacks extension.json")
682 }
683 return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err)
684 }
685 var manifest extensions.Manifest
686 if err := json.Unmarshal(raw, &manifest); err != nil {
687 return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err)
688 }
689 if strings.TrimSpace(manifest.Name) == "" {
690 return extensions.Manifest{}, errors.New("manifest: name is required")
691 }
692 if manifest.Name != strings.TrimSpace(manifest.Name) {
693 return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)
694 }
695 if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) {
696 return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
697 }
698 if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {
699 return extensions.Manifest{}, errors.New("manifest: exec is required")
700 }
701 if manifest.Exec != "" {
702 resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
703 if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
704 rel, err := filepath.Rel(dir, resolved)
705 if err != nil {
706 return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err)
707 }
708 if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
709 return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec)
710 }
711 }
712 }
713 return manifest, nil
714}
715
716func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool {
717 required := map[string]bool{"extension.json": true}
718 if manifest.Exec == "" {
719 return required
720 }
721 resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
722 if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
723 return required
724 }
725 rel, err := filepath.Rel(dir, resolved)
726 if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
727 required[filepath.ToSlash(rel)] = true
728 }
729 return required
730}
731
732func validateExtensionExecutable(dir string, manifest extensions.Manifest) error {
733 if manifest.Exec == "" {
734 return nil
735 }
736 path, local := extensions.ResolveExecPath(dir, manifest.Exec)
737 if !local {
738 // Bare commands such as "go", "node", and "npx" are resolved
739 // from PATH when zut starts. They may be intentionally installed
740 // before the runtime is present on the current machine.
741 return nil
742 }
743 info, err := os.Stat(path)
744 if err != nil {
745 if os.IsNotExist(err) {
746 return fmt.Errorf("extension executable %q is missing; provide it before installing. Local Go extensions can opt in with `zut ext install --build=go <path>`", manifest.Exec)
747 }
748 return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err)
749 }
750 if info.IsDir() {
751 return fmt.Errorf("extension executable %q is a directory", manifest.Exec)
752 }
753 if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
754 return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec)
755 }
756 return nil
757}
758
759func extensionDirs() map[string]string {
760 out := map[string]string{}
761 if h := ZutHome(); h != "" {
762 out["global"] = filepath.Join(h, "extensions")
763 }
764 if cwd, err := os.Getwd(); err == nil {
765 out["project"] = filepath.Join(cwd, ".zut", "extensions")
766 }
767 return out
768}
769
770func findExtensionDir(name string) (string, error) {
771 for _, dir := range extensionDirs() {
772 candidate := filepath.Join(dir, name)
773 if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
774 return candidate, nil
775 }
776 }
777 return "", fmt.Errorf("extension %q not found", name)
778}
779
780func dashIfEmpty(s string) string {
781 if s == "" {
782 return "-"
783 }
784 return s
785}
786
787// copyDir does a recursive copy of src to dst preserving file mode
788// bits. Used by `zut ext install <local-path>`.
789//
790// Entries matched by the source's root .gitignore are skipped, and
791// .git itself is always skipped. This keeps non-portable, regeneratable
792// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
793// out of the installed copy so the extension stays functional at its new
794// location.
795func copyDir(src, dst string) error {
796 return copyDirWithRequired(src, dst, nil)
797}
798
799func copyDirWithRequired(src, dst string, required map[string]bool) error {
800 ig := loadGitignore(src)
801 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
802 if err != nil {
803 return err
804 }
805 rel, err := filepath.Rel(src, path)
806 if err != nil {
807 return err
808 }
809 relSlash := filepath.ToSlash(rel)
810 if rel != "." {
811 name := filepath.Base(rel)
812 if info.IsDir() && name == ".git" {
813 return filepath.SkipDir
814 }
815 if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) {
816 if info.IsDir() {
817 return filepath.SkipDir
818 }
819 return nil
820 }
821 }
822 target := filepath.Join(dst, rel)
823 if info.IsDir() {
824 return os.MkdirAll(target, info.Mode())
825 }
826 in, err := os.Open(path)
827 if err != nil {
828 return err
829 }
830 defer in.Close()
831 out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
832 if err != nil {
833 return err
834 }
835 defer out.Close()
836 _, err = io.Copy(out, in)
837 return err
838 })
839}
840
841func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool {
842 if required[rel] {
843 return true
844 }
845 if !isDir {
846 return false
847 }
848 prefix := strings.TrimSuffix(rel, "/") + "/"
849 for path := range required {
850 if strings.HasPrefix(path, prefix) {
851 return true
852 }
853 }
854 return false
855}
856
857// gitignore matching lives in packages/ignore so the @-file picker in
858// packages/agent/modes can share it without an import cycle. These
859// thin aliases keep the existing call sites (and tests) terse.
860type gitignore = ignore.Gitignore
861
862func loadGitignore(root string) *gitignore { return ignore.Load(root) }
863
864func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }