shell.go

  1package shell
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"os/exec"
  8	"path/filepath"
  9
 10	tea "github.com/charmbracelet/bubbletea"
 11	"github.com/charmbracelet/log"
 12	"github.com/charmbracelet/soft-serve/pkg/config"
 13	"github.com/charmbracelet/soft-serve/pkg/ssh/cmd"
 14	"github.com/charmbracelet/soft-serve/pkg/sshutils"
 15	"github.com/charmbracelet/soft-serve/pkg/ui/common"
 16	"github.com/charmbracelet/ssh"
 17	"github.com/muesli/termenv"
 18	"github.com/spf13/cobra"
 19)
 20
 21// Command returns a new shell command.
 22func Command(ctx context.Context, env termenv.Environ, isInteractive bool) *cobra.Command {
 23	cfg := config.FromContext(ctx)
 24	c := &cobra.Command{
 25		Short:            "Soft Serve is a self-hostable Git server for the command line.",
 26		SilenceUsage:     true,
 27		SilenceErrors:    true,
 28		TraverseChildren: true,
 29		RunE: func(cmd *cobra.Command, args []string) error {
 30			in := cmd.InOrStdin()
 31			out := cmd.OutOrStdout()
 32			if isInteractive && len(args) == 0 {
 33				// Run UI
 34				output := termenv.NewOutput(out, termenv.WithColorCache(true), termenv.WithEnvironment(env))
 35				c := common.NewCommon(ctx, output, 0, 0)
 36				c.SetValue(common.ConfigKey, cfg)
 37				m := NewUI(c, "")
 38				p := tea.NewProgram(m,
 39					tea.WithInput(in),
 40					tea.WithOutput(out),
 41					tea.WithAltScreen(),
 42					tea.WithoutCatchPanics(),
 43					tea.WithMouseCellMotion(),
 44					tea.WithContext(ctx),
 45				)
 46
 47				return startProgram(cmd.Context(), p)
 48			} else if len(args) > 0 {
 49				// Run custom command
 50				return startCommand(cmd, args)
 51			}
 52
 53			return fmt.Errorf("invalid command %v", args)
 54		},
 55	}
 56	c.CompletionOptions.DisableDefaultCmd = true
 57
 58	c.SetUsageTemplate(cmd.UsageTemplate)
 59	c.SetUsageFunc(cmd.UsageFunc)
 60	c.AddCommand(
 61		cmd.GitUploadPackCommand(),
 62		cmd.GitUploadArchiveCommand(),
 63		cmd.GitReceivePackCommand(),
 64		// TODO: write shell commands for these
 65		// cmd.RepoCommand(),
 66		// cmd.SettingsCommand(),
 67		// cmd.UserCommand(),
 68		// cmd.InfoCommand(),
 69		// cmd.PubkeyCommand(),
 70		// cmd.SetUsernameCommand(),
 71		// cmd.JWTCommand(),
 72		// cmd.TokenCommand(),
 73	)
 74
 75	if cfg.LFS.Enabled {
 76		c.AddCommand(
 77			cmd.GitLFSAuthenticateCommand(),
 78		)
 79
 80		if cfg.LFS.SSHEnabled {
 81			c.AddCommand(
 82				cmd.GitLFSTransfer(),
 83			)
 84		}
 85	}
 86
 87	c.SetContext(ctx)
 88
 89	return c
 90}
 91
 92func startProgram(ctx context.Context, p *tea.Program) (err error) {
 93	var windowChanges <-chan ssh.Window
 94	if s := sshutils.SessionFromContext(ctx); s != nil {
 95		_, windowChanges, _ = s.Pty()
 96	}
 97	ctx, cancel := context.WithCancel(ctx)
 98	go func() {
 99		for {
100			select {
101			case <-ctx.Done():
102				if p != nil {
103					p.Quit()
104					return
105				}
106			case w := <-windowChanges:
107				if p != nil {
108					p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
109				}
110			}
111		}
112	}()
113
114	_, err = p.Run()
115
116	// p.Kill() will force kill the program if it's still running,
117	// and restore the terminal to its original state in case of a
118	// tui crash
119	p.Kill()
120	cancel()
121
122	return
123}
124
125func startCommand(co *cobra.Command, args []string) error {
126	ctx := co.Context()
127	cfg := config.FromContext(ctx)
128	if len(args) == 0 {
129		return fmt.Errorf("no command specified")
130	}
131
132	cmdsDir := filepath.Join(cfg.DataPath, "commands")
133
134	var cmdArgs []string
135	if len(args) > 1 {
136		cmdArgs = args[1:]
137	}
138
139	cmdPath := filepath.Join(cmdsDir, args[0])
140
141	// if stat, err := os.Stat(cmdPath); errors.Is(err, fs.ErrNotExist) || stat.Mode()&0111 == 0 {
142	// 	log.Printf("command mode %s", stat.Mode().String())
143	// 	return fmt.Errorf("command not found: %s", args[0])
144	// }
145
146	cmdPath, err := filepath.Abs(cmdPath)
147	if err != nil {
148		return fmt.Errorf("could not get absolute path for command: %w", err)
149	}
150
151	cmd := exec.CommandContext(ctx, cmdPath, cmdArgs...)
152
153	cmd.Dir = cmdsDir
154	stdin, err := cmd.StdinPipe()
155	if err != nil {
156		return fmt.Errorf("could not get stdin pipe: %w", err)
157	}
158
159	stdout, err := cmd.StdoutPipe()
160	if err != nil {
161		return fmt.Errorf("could not get stdout pipe: %w", err)
162	}
163
164	stderr, err := cmd.StderrPipe()
165	if err != nil {
166		return fmt.Errorf("could not get stderr pipe: %w", err)
167	}
168
169	if err := cmd.Start(); err != nil {
170		return fmt.Errorf("could not start command: %w", err)
171	}
172
173	go io.Copy(stdin, co.InOrStdin())    // nolint: errcheck
174	go io.Copy(co.OutOrStdout(), stdout) // nolint: errcheck
175	go io.Copy(co.ErrOrStderr(), stderr) // nolint: errcheck
176
177	log.Infof("waiting for command to finish: %s", cmdPath)
178	if err := cmd.Wait(); err != nil {
179		return err
180	}
181
182	return nil
183}