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 fmt.Fprintf(os.Stdout, " ! %v\n", err)
199 }
200 }
201 return nil
202}
203
204func scanExtDoctorStatic() []extDoctorStaticRow {
205 type scanDir struct {
206 scope string
207 dir string
208 }
209 var dirs []scanDir
210 if cwd, err := os.Getwd(); err == nil {
211 dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zut", "extensions")})
212 }
213 if h := ZutHome(); h != "" {
214 dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")})
215 }
216
217 seen := map[string]bool{}
218 var rows []extDoctorStaticRow
219 for _, sd := range dirs {
220 entries, err := os.ReadDir(sd.dir)
221 if err != nil {
222 continue
223 }
224 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
225 for _, e := range entries {
226 if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
227 continue
228 }
229 extDir := filepath.Join(sd.dir, e.Name())
230 row := extDoctorStaticRow{
231 Scope: sd.scope,
232 Name: e.Name(),
233 Enabled: true,
234 Dir: extDir,
235 Manifest: filepath.Join(extDir, "extension.json"),
236 Theme: extensions.HasExtensionTheme(extDir),
237 }
238 if seen[e.Name()] {
239 row.Shadowed = true
240 } else {
241 seen[e.Name()] = true
242 }
243 raw, err := os.ReadFile(row.Manifest)
244 if err != nil {
245 row.Error = "missing extension.json"
246 rows = append(rows, row)
247 continue
248 }
249 var m struct {
250 Name string `json:"name"`
251 Version string `json:"version"`
252 Exec string `json:"exec"`
253 Enabled *bool `json:"enabled"`
254 }
255 if err := json.Unmarshal(raw, &m); err != nil {
256 row.Error = "parse manifest: " + err.Error()
257 rows = append(rows, row)
258 continue
259 }
260 if m.Name != "" {
261 row.Name = m.Name
262 } else {
263 row.Error = "manifest: name is required"
264 }
265 row.Version = m.Version
266 row.Exec = m.Exec
267 if m.Enabled != nil {
268 row.Enabled = *m.Enabled
269 }
270 if row.Exec == "" && !row.Theme && row.Error == "" {
271 row.Error = "manifest: exec is required"
272 }
273 rows = append(rows, row)
274 }
275 }
276 return rows
277}
278
279func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) {
280 status := "ok"
281 switch {
282 case row.Error != "":
283 status = "error"
284 case row.Shadowed:
285 status = "shadowed"
286 case !row.Enabled:
287 status = "disabled"
288 case row.Exec == "" && row.Theme:
289 status = "theme-only"
290 case diag.Name == "":
291 status = "not loaded"
292 case diag.ReadyTimedOut:
293 status = "ready-timeout"
294 case diag.AutoReady:
295 status = "auto-ready"
296 case !diag.Ready:
297 status = "not ready"
298 case len(diag.Messages) > 0:
299 status = "warning"
300 }
301 fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir)
302 if row.Version != "" {
303 fmt.Fprintf(w, " version: %s\n", row.Version)
304 }
305 if row.Error != "" {
306 if row.Exec != "" {
307 fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name))
308 }
309 fmt.Fprintf(w, " error: %s\n", row.Error)
310 return
311 }
312 if row.Shadowed {
313 fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins")
314 return
315 }
316 if !row.Enabled {
317 fmt.Fprintln(w, " note: disabled in extension.json")
318 return
319 }
320 if row.Exec == "" && row.Theme {
321 fmt.Fprintln(w, " type: theme-only")
322 return
323 }
324 if row.Exec != "" {
325 fmt.Fprintf(w, " exec: %s\n", row.Exec)
326 }
327 logPath := diag.LogPath
328 if logPath == "" && row.Exec != "" {
329 logPath = extDoctorLogPath(row.Name)
330 }
331 if logPath != "" {
332 fmt.Fprintf(w, " log: %s\n", logPath)
333 }
334 if len(diag.Commands) > 0 {
335 fmt.Fprintln(w, " commands:")
336 for _, c := range diag.Commands {
337 state := "active"
338 if !c.Active {
339 state = "shadowed"
340 }
341 fmt.Fprintf(w, " /%s (%s)\n", c.Name, state)
342 }
343 }
344 if len(diag.Tools) > 0 {
345 fmt.Fprintln(w, " tools:")
346 for _, t := range diag.Tools {
347 state := "active"
348 if !t.Active {
349 state = "shadowed"
350 }
351 fmt.Fprintf(w, " %s (%s)\n", t.Name, state)
352 }
353 }
354 for _, msg := range diag.Messages {
355 fmt.Fprintf(w, " warning: %s\n", msg)
356 }
357}
358
359func extDoctorLogPath(name string) string {
360 if name == "" || ZutHome() == "" {
361 return ""
362 }
363 return filepath.Join(ZutHome(), "logs", "ext-"+name+".log")
364}
365
366// extLogs locates the named extension's log file and either cats or
367// tails it (-f).
368func extLogs(args []string) error {
369 if len(args) == 0 {
370 return fmt.Errorf("usage: zut ext logs <name> [-f]")
371 }
372 name := args[0]
373 follow := false
374 for _, a := range args[1:] {
375 if a == "-f" || a == "--follow" {
376 follow = true
377 }
378 }
379 logPath := filepath.Join(ZutHome(), "logs", "ext-"+name+".log")
380 if _, err := os.Stat(logPath); err != nil {
381 return fmt.Errorf("no log for %q at %s", name, logPath)
382 }
383 if !follow {
384 f, err := os.Open(logPath)
385 if err != nil {
386 return err
387 }
388 defer f.Close()
389 _, err = io.Copy(os.Stdout, f)
390 return err
391 }
392 cmd := exec.Command("tail", "-F", logPath)
393 cmd.Stdout = os.Stdout
394 cmd.Stderr = os.Stderr
395 return cmd.Run()
396}
397
398// extToggle flips the enabled flag in an extension's manifest.
399func extToggle(args []string, enabled bool) error {
400 if len(args) == 0 {
401 verb := "enable"
402 if !enabled {
403 verb = "disable"
404 }
405 return fmt.Errorf("usage: zut ext %s <name>", verb)
406 }
407 name := args[0]
408 dir, err := findExtensionDir(name)
409 if err != nil {
410 return err
411 }
412 mfPath := filepath.Join(dir, "extension.json")
413 raw, err := os.ReadFile(mfPath)
414 if err != nil {
415 return err
416 }
417 var generic map[string]any
418 if err := json.Unmarshal(raw, &generic); err != nil {
419 return fmt.Errorf("parse manifest: %w", err)
420 }
421 generic["enabled"] = enabled
422 out, err := json.MarshalIndent(generic, "", " ")
423 if err != nil {
424 return err
425 }
426 if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
427 return err
428 }
429 state := "enabled"
430 if !enabled {
431 state = "disabled"
432 }
433 fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
434 return nil
435}
436
437// extRemove deletes an extension's directory after a confirmation
438// prompt (skip with --yes).
439func extRemove(args []string) error {
440 if len(args) == 0 {
441 return fmt.Errorf("usage: zut ext remove <name> [--yes]")
442 }
443 name := args[0]
444 yes := false
445 for _, a := range args[1:] {
446 if a == "--yes" || a == "-y" {
447 yes = true
448 }
449 }
450 dir, err := findExtensionDir(name)
451 if err != nil {
452 return err
453 }
454 if !yes {
455 fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
456 var resp string
457 _, _ = fmt.Scanln(&resp)
458 if !strings.EqualFold(strings.TrimSpace(resp), "y") {
459 fmt.Fprintln(os.Stderr, "aborted")
460 return nil
461 }
462 }
463 if err := os.RemoveAll(dir); err != nil {
464 return err
465 }
466 fmt.Fprintf(os.Stderr, "removed %s\n", dir)
467 return nil
468}
469
470// extInstall copies a local directory or shallow-clones a git URL
471// into $ZUT_HOME/extensions/. It stages the install and validates the
472// manifest plus any extension-local executable before reporting success.
473// Builds are never inferred or run implicitly; --build=go is an explicit
474// opt-in for local Go extensions.
475func extInstall(args []string) error {
476 return extInstallContext(context.Background(), args)
477}
478
479func extInstallContext(ctx context.Context, args []string) error {
480 opts, err := parseExtInstallArgs(args)
481 if err != nil {
482 return err
483 }
484 src := opts.source
485 dest := filepath.Join(ZutHome(), "extensions")
486 if err := os.MkdirAll(dest, 0o755); err != nil {
487 return err
488 }
489
490 if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
491 if opts.builder != "" {
492 return errors.New("--build=go is currently supported only for local extension paths; clone the extension locally and build it there")
493 }
494 return installGitExtension(ctx, src, dest)
495 }
496
497 // Local path: must be a directory containing extension.json.
498 info, err := os.Stat(src)
499 if err != nil {
500 return err
501 }
502 if !info.IsDir() {
503 return fmt.Errorf("not a directory: %s", src)
504 }
505 // Resolve to an absolute, cleaned path before reading the manifest.
506 // This also makes sources like "." safe to install.
507 absSrc, err := filepath.Abs(src)
508 if err != nil {
509 return err
510 }
511 manifest, err := readExtensionManifest(absSrc)
512 if err != nil {
513 return fmt.Errorf("source extension: %w", err)
514 }
515 out := filepath.Join(dest, manifest.Name)
516 if _, err := os.Stat(out); err == nil {
517 return fmt.Errorf("destination %s already exists; remove it first", out)
518 }
519
520 stageRoot, err := os.MkdirTemp(dest, ".zut-extension-install-")
521 if err != nil {
522 return fmt.Errorf("create install staging directory: %w", err)
523 }
524 defer os.RemoveAll(stageRoot)
525 staged := filepath.Join(stageRoot, "extension")
526 if err := copyDirWithRequired(absSrc, staged, manifestRuntimeFiles(absSrc, manifest)); err != nil {
527 return fmt.Errorf("copy extension: %w", err)
528 }
529 if opts.builder != "" {
530 if err := buildLocalExtension(ctx, absSrc, staged, manifest); err != nil {
531 return err
532 }
533 }
534 if err := validateExtensionExecutable(staged, manifest); err != nil {
535 return err
536 }
537 if err := os.Rename(staged, out); err != nil {
538 return fmt.Errorf("finalize install: %w", err)
539 }
540 fmt.Fprintf(os.Stderr, "installed %s\n", out)
541 return nil
542}
543
544type extInstallOptions struct {
545 source string
546 builder string
547}
548
549func parseExtInstallArgs(args []string) (extInstallOptions, error) {
550 if len(args) == 0 {
551 return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
552 }
553
554 var opts extInstallOptions
555 builderSet := false
556 for i := 0; i < len(args); i++ {
557 arg := args[i]
558 switch {
559 case arg == "--build":
560 if i+1 >= len(args) {
561 return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
562 }
563 i++
564 if builderSet {
565 return extInstallOptions{}, errors.New("extension builder specified more than once")
566 }
567 builderSet = true
568 opts.builder = strings.ToLower(strings.TrimSpace(args[i]))
569 if opts.builder == "" {
570 return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
571 }
572 case strings.HasPrefix(arg, "--build="):
573 if builderSet {
574 return extInstallOptions{}, errors.New("extension builder specified more than once")
575 }
576 builderSet = true
577 opts.builder = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(arg, "--build=")))
578 if opts.builder == "" {
579 return extInstallOptions{}, errors.New("--build requires a builder (currently: go)")
580 }
581 case strings.HasPrefix(arg, "-"):
582 return extInstallOptions{}, fmt.Errorf("unknown ext install option %q", arg)
583 case opts.source != "":
584 return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
585 default:
586 opts.source = arg
587 }
588 }
589 if opts.source == "" {
590 return extInstallOptions{}, errors.New("usage: zut ext install [--build=go] <path|git-url>")
591 }
592 if opts.builder != "" && opts.builder != "go" {
593 return extInstallOptions{}, fmt.Errorf("unsupported extension builder %q (currently supported: go)", opts.builder)
594 }
595 return opts, nil
596}
597
598func buildLocalExtension(ctx context.Context, sourceDir, stagedDir string, manifest extensions.Manifest) error {
599 if manifest.Exec == "" {
600 return errors.New("cannot build a theme-only extension; it does not declare an executable")
601 }
602 execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec)
603 if err != nil {
604 return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err)
605 }
606 stagedExec := filepath.Join(stagedDir, execRel)
607 if err := os.MkdirAll(filepath.Dir(stagedExec), 0o755); err != nil {
608 return fmt.Errorf("prepare Go build output: %w", err)
609 }
610
611 fmt.Fprintf(os.Stderr, "building %s with go\n", manifest.Name)
612 cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", stagedExec, ".")
613 cmd.Dir = sourceDir
614 cmd.Stdout = os.Stderr
615 cmd.Stderr = os.Stderr
616 if err := cmd.Run(); err != nil {
617 return fmt.Errorf("build extension with go: %w", err)
618 }
619 return nil
620}
621
622func extensionRelativeExecPath(dir, execName string) (string, error) {
623 normalized := filepath.FromSlash(execName)
624 if filepath.IsAbs(normalized) {
625 return "", fmt.Errorf("extension executable %q is absolute", execName)
626 }
627 resolved, local := extensions.ResolveExecPath(dir, execName)
628 if !local {
629 return "", fmt.Errorf("extension executable %q is a PATH command", execName)
630 }
631 rel, err := filepath.Rel(dir, resolved)
632 if err != nil {
633 return "", fmt.Errorf("resolve extension executable %q: %w", execName, err)
634 }
635 if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
636 return "", fmt.Errorf("extension executable %q escapes the extension directory", execName)
637 }
638 return rel, nil
639}
640
641func installGitExtension(ctx context.Context, src, dest string) error {
642 stageRoot, err := os.MkdirTemp(dest, ".zut-extension-clone-")
643 if err != nil {
644 return fmt.Errorf("create install staging directory: %w", err)
645 }
646 defer os.RemoveAll(stageRoot)
647
648 cloneDir := filepath.Join(stageRoot, "extension")
649 cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", src, cloneDir)
650 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
651 cmd.Stdout = os.Stderr
652 cmd.Stderr = os.Stderr
653 if err := cmd.Run(); err != nil {
654 return fmt.Errorf("git clone: %w", err)
655 }
656 manifest, err := readExtensionManifest(cloneDir)
657 if err != nil {
658 return fmt.Errorf("cloned extension: %w", err)
659 }
660 out := filepath.Join(dest, manifest.Name)
661 if _, err := os.Stat(out); err == nil {
662 return fmt.Errorf("destination %s already exists; remove it first", out)
663 }
664 if err := validateExtensionExecutable(cloneDir, manifest); err != nil {
665 return err
666 }
667 if err := os.Rename(cloneDir, out); err != nil {
668 return fmt.Errorf("finalize install: %w", err)
669 }
670 fmt.Fprintf(os.Stderr, "installed %s\n", out)
671 return nil
672}
673
674func readExtensionManifest(dir string) (extensions.Manifest, error) {
675 raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
676 if err != nil {
677 if os.IsNotExist(err) {
678 return extensions.Manifest{}, errors.New("source lacks extension.json")
679 }
680 return extensions.Manifest{}, fmt.Errorf("read extension.json: %w", err)
681 }
682 var manifest extensions.Manifest
683 if err := json.Unmarshal(raw, &manifest); err != nil {
684 return extensions.Manifest{}, fmt.Errorf("parse extension.json: %w", err)
685 }
686 if strings.TrimSpace(manifest.Name) == "" {
687 return extensions.Manifest{}, errors.New("manifest: name is required")
688 }
689 if manifest.Name != strings.TrimSpace(manifest.Name) {
690 return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name)
691 }
692 if strings.HasPrefix(manifest.Name, ".") || strings.ContainsAny(manifest.Name, `/\\`) {
693 return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
694 }
695 if manifest.Exec == "" && !extensions.HasExtensionTheme(dir) {
696 return extensions.Manifest{}, errors.New("manifest: exec is required")
697 }
698 if manifest.Exec != "" {
699 resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
700 if local && !filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
701 rel, err := filepath.Rel(dir, resolved)
702 if err != nil {
703 return extensions.Manifest{}, fmt.Errorf("manifest: resolve exec %q: %w", manifest.Exec, err)
704 }
705 if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
706 return extensions.Manifest{}, fmt.Errorf("manifest: exec %q escapes the extension directory", manifest.Exec)
707 }
708 }
709 }
710 return manifest, nil
711}
712
713func manifestRuntimeFiles(dir string, manifest extensions.Manifest) map[string]bool {
714 required := map[string]bool{"extension.json": true}
715 if manifest.Exec == "" {
716 return required
717 }
718 resolved, local := extensions.ResolveExecPath(dir, manifest.Exec)
719 if !local || filepath.IsAbs(filepath.FromSlash(manifest.Exec)) {
720 return required
721 }
722 rel, err := filepath.Rel(dir, resolved)
723 if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
724 required[filepath.ToSlash(rel)] = true
725 }
726 return required
727}
728
729func validateExtensionExecutable(dir string, manifest extensions.Manifest) error {
730 if manifest.Exec == "" {
731 return nil
732 }
733 path, local := extensions.ResolveExecPath(dir, manifest.Exec)
734 if !local {
735 // Bare commands such as "go", "node", and "npx" are resolved
736 // from PATH when zut starts. They may be intentionally installed
737 // before the runtime is present on the current machine.
738 return nil
739 }
740 info, err := os.Stat(path)
741 if err != nil {
742 if os.IsNotExist(err) {
743 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)
744 }
745 return fmt.Errorf("stat extension executable %q: %w", manifest.Exec, err)
746 }
747 if info.IsDir() {
748 return fmt.Errorf("extension executable %q is a directory", manifest.Exec)
749 }
750 if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
751 return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec)
752 }
753 return nil
754}
755
756func extensionDirs() map[string]string {
757 out := map[string]string{}
758 if h := ZutHome(); h != "" {
759 out["global"] = filepath.Join(h, "extensions")
760 }
761 if cwd, err := os.Getwd(); err == nil {
762 out["project"] = filepath.Join(cwd, ".zut", "extensions")
763 }
764 return out
765}
766
767func findExtensionDir(name string) (string, error) {
768 for _, dir := range extensionDirs() {
769 candidate := filepath.Join(dir, name)
770 if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
771 return candidate, nil
772 }
773 }
774 return "", fmt.Errorf("extension %q not found", name)
775}
776
777func dashIfEmpty(s string) string {
778 if s == "" {
779 return "-"
780 }
781 return s
782}
783
784// copyDir does a recursive copy of src to dst preserving file mode
785// bits. Used by `zut ext install <local-path>`.
786//
787// Entries matched by the source's root .gitignore are skipped, and
788// .git itself is always skipped. This keeps non-portable, regeneratable
789// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
790// out of the installed copy so the extension stays functional at its new
791// location.
792func copyDir(src, dst string) error {
793 return copyDirWithRequired(src, dst, nil)
794}
795
796func copyDirWithRequired(src, dst string, required map[string]bool) error {
797 ig := loadGitignore(src)
798 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
799 if err != nil {
800 return err
801 }
802 rel, err := filepath.Rel(src, path)
803 if err != nil {
804 return err
805 }
806 relSlash := filepath.ToSlash(rel)
807 if rel != "." {
808 name := filepath.Base(rel)
809 if info.IsDir() && name == ".git" {
810 return filepath.SkipDir
811 }
812 if ig.Match(relSlash, info.IsDir()) && !requiredCopyPath(required, relSlash, info.IsDir()) {
813 if info.IsDir() {
814 return filepath.SkipDir
815 }
816 return nil
817 }
818 }
819 target := filepath.Join(dst, rel)
820 if info.IsDir() {
821 return os.MkdirAll(target, info.Mode())
822 }
823 in, err := os.Open(path)
824 if err != nil {
825 return err
826 }
827 defer in.Close()
828 out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
829 if err != nil {
830 return err
831 }
832 defer out.Close()
833 _, err = io.Copy(out, in)
834 return err
835 })
836}
837
838func requiredCopyPath(required map[string]bool, rel string, isDir bool) bool {
839 if required[rel] {
840 return true
841 }
842 if !isDir {
843 return false
844 }
845 prefix := strings.TrimSuffix(rel, "/") + "/"
846 for path := range required {
847 if strings.HasPrefix(path, prefix) {
848 return true
849 }
850 }
851 return false
852}
853
854// gitignore matching lives in packages/ignore so the @-file picker in
855// packages/agent/modes can share it without an import cycle. These
856// thin aliases keep the existing call sites (and tests) terse.
857type gitignore = ignore.Gitignore
858
859func loadGitignore(root string) *gitignore { return ignore.Load(root) }
860
861func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) }