v0.11.3 6c26357

extcmd.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close