forked from Gitlink/gitlink-cli
238 lines
6.2 KiB
Go
238 lines
6.2 KiB
Go
package interactive
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
// Executor runs shortcut commands and captures their stdout output.
|
|
type Executor struct{}
|
|
|
|
// Execute runs the given shortcut with the provided arguments, capturing
|
|
// anything written to os.Stdout during execution and returning it as a string.
|
|
//
|
|
// It builds the RuntimeContext via common.NewRuntimeContext so that Client,
|
|
// Format, Owner and Repo are properly initialized — a bare
|
|
// `&RuntimeContext{Args: args}` would leave Client nil and panic as soon as a
|
|
// command calls ctx.CallAPI. A deferred recover guards against panics raised
|
|
// inside the command so the REPL is never left with stdout pointing at a
|
|
// closed pipe.
|
|
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (output string, err error) {
|
|
// Guard against panics inside the command: always restore stdout even if
|
|
// s.Run panics, and surface the panic as a regular error so the REPL can
|
|
// display it instead of hanging with a corrupted stdout.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
output = ""
|
|
err = fmt.Errorf("command panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
// Save original stdout
|
|
oldStdout := os.Stdout
|
|
|
|
// Create a pipe: writes go to w, reads come from r
|
|
r, w, perr := os.Pipe()
|
|
if perr != nil {
|
|
return "", fmt.Errorf("failed to create pipe: %w", perr)
|
|
}
|
|
|
|
// Redirect stdout to the write end of the pipe
|
|
os.Stdout = w
|
|
|
|
// Channel to signal that the goroutine has finished reading
|
|
done := make(chan struct{})
|
|
var buf bytes.Buffer
|
|
|
|
// Read from the pipe in a goroutine so that writes don't block
|
|
go func() {
|
|
io.Copy(&buf, r)
|
|
close(done)
|
|
}()
|
|
|
|
// Build a fully-initialized context (Client/Format/Owner/Repo).
|
|
ctx, cerr := common.NewRuntimeContext(args)
|
|
if cerr != nil {
|
|
// Restore stdout and drain the pipe before returning.
|
|
os.Stdout = oldStdout
|
|
w.Close()
|
|
<-done
|
|
return "", fmt.Errorf("failed to initialize runtime context: %w", cerr)
|
|
}
|
|
|
|
// Execute the shortcut's Run function. Recover protects the path so that
|
|
// a panic still leaves the deferred cleanup below runnable.
|
|
runErr := safeRun(s, ctx)
|
|
|
|
// Close the writer to signal EOF to the reader goroutine, wait for it to
|
|
// finish copying into the buffer, then restore stdout.
|
|
w.Close()
|
|
<-done
|
|
os.Stdout = oldStdout
|
|
|
|
return buf.String(), runErr
|
|
}
|
|
|
|
// safeRun invokes a shortcut's Run function and converts any panic into an
|
|
// error, so a panicking command cannot crash the executor goroutine or leave
|
|
// os.Stdout redirected at a closed pipe.
|
|
func safeRun(s *common.Shortcut, ctx *common.RuntimeContext) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("command panicked: %v", r)
|
|
}
|
|
}()
|
|
return s.Run(ctx)
|
|
}
|
|
|
|
// parseDirectCommand parses a direct command string like "issue +list --state open"
|
|
// into its components: group, cmd (without +), flagStr, and whether parsing succeeded.
|
|
func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return "", "", "", false
|
|
}
|
|
|
|
parts := strings.Fields(input)
|
|
if len(parts) < 2 {
|
|
return "", "", "", false
|
|
}
|
|
|
|
group = parts[0]
|
|
rawCmd := parts[1]
|
|
|
|
if !strings.HasPrefix(rawCmd, "+") {
|
|
return "", "", "", false
|
|
}
|
|
cmd = strings.TrimPrefix(rawCmd, "+")
|
|
|
|
if len(parts) > 2 {
|
|
flagStr = strings.Join(parts[2:], " ")
|
|
}
|
|
|
|
return group, cmd, flagStr, true
|
|
}
|
|
|
|
// parseFlagString parses a flag string like "--title hello -n 42" into a map.
|
|
// shortMap is used to expand short flag names to their long equivalents.
|
|
func parseFlagString(flagStr string, shortMap map[string]string) map[string]string {
|
|
result := make(map[string]string)
|
|
|
|
if flagStr == "" {
|
|
return result
|
|
}
|
|
|
|
parts := strings.Fields(flagStr)
|
|
|
|
for i := 0; i < len(parts); i++ {
|
|
part := parts[i]
|
|
|
|
if strings.HasPrefix(part, "--") {
|
|
// Long flag
|
|
flagPart := strings.TrimPrefix(part, "--")
|
|
|
|
if strings.Contains(flagPart, "=") {
|
|
// --flag=value format
|
|
kv := strings.SplitN(flagPart, "=", 2)
|
|
result[kv[0]] = kv[1]
|
|
} else if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
|
// --flag value format
|
|
result[flagPart] = parts[i+1]
|
|
i++
|
|
} else {
|
|
// --flag without value (boolean-like)
|
|
result[flagPart] = "true"
|
|
}
|
|
} else if strings.HasPrefix(part, "-") && len(part) > 1 {
|
|
// Short flag
|
|
shortName := strings.TrimPrefix(part, "-")
|
|
|
|
// Expand short name to long name if mapping exists
|
|
longName := shortName
|
|
if mapped, ok := shortMap[shortName]; ok {
|
|
longName = mapped
|
|
}
|
|
|
|
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
|
result[longName] = parts[i+1]
|
|
i++
|
|
} else {
|
|
result[longName] = "true"
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// findShortcut looks up a shortcut by group and command name in the registry.
|
|
func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) {
|
|
shortcuts, ok := all[group]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
for _, s := range shortcuts {
|
|
if s.Name == cmd {
|
|
return s, true
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// buildShortMap creates a mapping from short flag names to long flag names.
|
|
func buildShortMap(s *common.Shortcut) map[string]string {
|
|
m := make(map[string]string)
|
|
for _, f := range s.Flags {
|
|
if f.Short != "" {
|
|
m[f.Short] = f.Name
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// missingRequiredFlags returns the names (with -- prefix) of flags that are
|
|
// marked as required but not present in args.
|
|
func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string {
|
|
var missing []string
|
|
for _, f := range s.Flags {
|
|
if f.Required {
|
|
if _, ok := args[f.Name]; !ok {
|
|
missing = append(missing, "--"+f.Name)
|
|
}
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// formatCommandDisplay formats a command for display, e.g. "issue +create --title hello --body world".
|
|
func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string {
|
|
var b strings.Builder
|
|
b.WriteString(group)
|
|
b.WriteString(" +")
|
|
b.WriteString(s.Name)
|
|
|
|
// Sort flag names for deterministic output
|
|
keys := make([]string, 0, len(args))
|
|
for k := range args {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, k := range keys {
|
|
b.WriteString(" --")
|
|
b.WriteString(k)
|
|
b.WriteString(" ")
|
|
b.WriteString(args[k])
|
|
}
|
|
|
|
return b.String()
|
|
}
|