From aea21d713c08ad97919a4fadcaa925f8d3b7393e Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 19:14:56 +0100 Subject: [PATCH] feat: formulario de flags y filtro launcher en pipeline_launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parsea flags de -help de cada pipeline para mostrar formulario de argumentos antes de ejecutar. Filtra pipelines por tag 'launcher'. Corrige selección en historial delegando enter al list antes de leer item. --- apps/pipeline_launcher/go.mod | 3 +- apps/pipeline_launcher/go.sum | 2 + apps/pipeline_launcher/views/history.go | 3 + apps/pipeline_launcher/views/pipelines.go | 171 ++++++++++++++++++++-- apps/pipeline_launcher/views/runner.go | 62 +++++++- 5 files changed, 227 insertions(+), 14 deletions(-) diff --git a/apps/pipeline_launcher/go.mod b/apps/pipeline_launcher/go.mod index 0efb6cda..50d59d93 100644 --- a/apps/pipeline_launcher/go.mod +++ b/apps/pipeline_launcher/go.mod @@ -4,14 +4,15 @@ go 1.22.2 require ( fn-registry v0.0.0 + github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.9.1 github.com/lucasdataproyects/devfactory v0.0.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.18.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/apps/pipeline_launcher/go.sum b/apps/pipeline_launcher/go.sum index 7278f31d..61ff79b5 100644 --- a/apps/pipeline_launcher/go.sum +++ b/apps/pipeline_launcher/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= diff --git a/apps/pipeline_launcher/views/history.go b/apps/pipeline_launcher/views/history.go index df19bccd..6e1d00e8 100644 --- a/apps/pipeline_launcher/views/history.go +++ b/apps/pipeline_launcher/views/history.go @@ -106,6 +106,9 @@ func (m HistoryModel) Update(msg tea.Msg) (HistoryModel, tea.Cmd) { m.spinner = tui.NewSpinner("Loading history...") return m, tea.Batch(m.spinner.Init(), m.loadHistory()) case "enter": + // Delegate enter to list first so it selects the cursor item + updated, _ := m.list.Update(msg) + m.list = updated.(tui.FilteredListModel) if item := m.list.SelectedItem(); item != nil { e := item.Value.(ops.Execution) m.detail = formatExecution(e) diff --git a/apps/pipeline_launcher/views/pipelines.go b/apps/pipeline_launcher/views/pipelines.go index a75b6f74..190e2c6a 100644 --- a/apps/pipeline_launcher/views/pipelines.go +++ b/apps/pipeline_launcher/views/pipelines.go @@ -7,6 +7,7 @@ import ( ops "fn-registry/fn_operations" "fn-registry/registry" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lucasdataproyects/devfactory/tui" @@ -17,12 +18,14 @@ type pipelinesState int const ( pipelinesLoading pipelinesState = iota pipelinesList + pipelinesArgs pipelinesRunning pipelinesOutput ) type pipelinesLoadedMsg []registry.Function type pipelineFinishedMsg RunResult +type pipelineFlagsMsg []PipelineFlag // PipelinesModel lists and launches pipelines. type PipelinesModel struct { @@ -31,6 +34,10 @@ type PipelinesModel struct { spinner tui.SpinnerModel styles tui.Styles pipelines []registry.Function + selectedFn *registry.Function + flags []PipelineFlag + inputs []textinput.Model + focusIdx int output string lastResult *RunResult scrollOff int @@ -66,10 +73,67 @@ func (m PipelinesModel) loadPipelines() tea.Cmd { if err != nil { return pipelinesLoadedMsg(nil) } - return pipelinesLoadedMsg(fns) + // Only show pipelines tagged with "launcher" + var launchable []registry.Function + for _, f := range fns { + for _, t := range f.Tags { + if t == "launcher" { + launchable = append(launchable, f) + break + } + } + } + return pipelinesLoadedMsg(launchable) } } +// buildInputs creates a textinput for each flag, pre-filled with defaults. +func (m *PipelinesModel) buildInputs() tea.Cmd { + m.inputs = make([]textinput.Model, len(m.flags)) + for i, f := range m.flags { + ti := textinput.New() + ti.CharLimit = 256 + ti.Width = 40 + if f.Default != "" { + ti.SetValue(f.Default) + } + if f.Required { + ti.Placeholder = "(requerido)" + } + m.inputs[i] = ti + } + m.focusIdx = 0 + if len(m.inputs) > 0 { + m.inputs[0].Focus() + return textinput.Blink + } + return nil +} + +func (m *PipelinesModel) focusInput(idx int) tea.Cmd { + if idx < 0 || idx >= len(m.inputs) { + return nil + } + for i := range m.inputs { + m.inputs[i].Blur() + } + m.focusIdx = idx + m.inputs[idx].Focus() + return textinput.Blink +} + +// collectArgs builds CLI args from the form inputs. +func (m PipelinesModel) collectArgs() []string { + var args []string + for i, f := range m.flags { + val := strings.TrimSpace(m.inputs[i].Value()) + if val != "" { + args = append(args, "--"+f.Name, val) + } + } + return args +} + func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) { switch msg := msg.(type) { case pipelinesLoadedMsg: @@ -86,19 +150,23 @@ func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) { m.state = pipelinesList return m, nil + case pipelineFlagsMsg: + m.flags = []PipelineFlag(msg) + cmd := m.buildInputs() + return m, cmd + case pipelineFinishedMsg: result := RunResult(msg) m.lastResult = &result - // Build output var sb strings.Builder if result.Status == ops.ExecSuccess { sb.WriteString("[OK] ") } else { sb.WriteString("[FAIL] ") } - sb.WriteString(fmt.Sprintf("Pipeline: %s\n", result.PipelineID)) - sb.WriteString(fmt.Sprintf("Execution: %s\n", result.ExecID)) - sb.WriteString(fmt.Sprintf("Duration: %dms\n", result.DurationMs)) + fmt.Fprintf(&sb, "Pipeline: %s\n", result.PipelineID) + fmt.Fprintf(&sb, "Execution: %s\n", result.ExecID) + fmt.Fprintf(&sb, "Duration: %dms\n", result.DurationMs) sb.WriteString("\n--- stdout ---\n") if result.Stdout != "" { sb.WriteString(result.Stdout) @@ -110,7 +178,7 @@ func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) { sb.WriteString(result.Stderr) } if result.Err != nil { - sb.WriteString(fmt.Sprintf("\n--- error ---\n%v", result.Err)) + fmt.Fprintf(&sb, "\n--- error ---\n%v", result.Err) } m.output = sb.String() m.state = pipelinesOutput @@ -126,12 +194,42 @@ func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) { m.spinner = tui.NewSpinner("Loading pipelines...") return m, tea.Batch(m.spinner.Init(), m.loadPipelines()) case "enter": + updated, _ := m.list.Update(msg) + m.list = updated.(tui.FilteredListModel) if item := m.list.SelectedItem(); item != nil { fn := item.Value.(registry.Function) - m.state = pipelinesRunning - m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", fn.Name)) - return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(&fn)) + m.selectedFn = &fn + m.flags = nil + m.inputs = nil + m.state = pipelinesArgs + root := m.registryRoot + fnCopy := fn + return m, func() tea.Msg { + return pipelineFlagsMsg(GetPipelineFlags(&fnCopy, root)) + } } + return m, nil + } + case pipelinesArgs: + switch msg.String() { + case "tab", "down": + cmd := m.focusInput((m.focusIdx + 1) % max(len(m.inputs), 1)) + return m, cmd + case "shift+tab", "up": + idx := m.focusIdx - 1 + if idx < 0 { + idx = max(len(m.inputs)-1, 0) + } + cmd := m.focusInput(idx) + return m, cmd + case "ctrl+enter", "ctrl+s": + args := m.collectArgs() + m.state = pipelinesRunning + m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", m.selectedFn.Name)) + return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(m.selectedFn, args)) + case "esc": + m.state = pipelinesList + return m, nil } case pipelinesOutput: switch msg.String() { @@ -157,16 +255,20 @@ func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) { var listModel tea.Model listModel, cmd = m.list.Update(msg) m.list = listModel.(tui.FilteredListModel) + case pipelinesArgs: + if m.focusIdx >= 0 && m.focusIdx < len(m.inputs) { + m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg) + } } return m, cmd } -func (m PipelinesModel) runPipelineCmd(fn *registry.Function) tea.Cmd { +func (m PipelinesModel) runPipelineCmd(fn *registry.Function, args []string) tea.Cmd { regRoot := m.registryRoot opsDB := m.opsDB fnCopy := *fn return func() tea.Msg { - result := RunPipeline(&fnCopy, regRoot, opsDB) + result := RunPipeline(&fnCopy, regRoot, opsDB, args) return pipelineFinishedMsg(result) } } @@ -174,6 +276,9 @@ func (m PipelinesModel) runPipelineCmd(fn *registry.Function) tea.Cmd { // HandleBack retrocede un nivel. Retorna true si ya en estado base. func (m *PipelinesModel) HandleBack() bool { switch m.state { + case pipelinesArgs: + m.state = pipelinesList + return false case pipelinesOutput: m.state = pipelinesList return false @@ -192,6 +297,8 @@ func (m PipelinesModel) View() string { } help := m.styles.Muted.Render(" Enter: launch │ r: refresh │ /: filter") return m.list.View() + "\n" + help + case pipelinesArgs: + return m.renderArgsForm() case pipelinesRunning: return m.spinner.View() case pipelinesOutput: @@ -200,6 +307,48 @@ func (m PipelinesModel) View() string { return "" } +func (m PipelinesModel) renderArgsForm() string { + header := m.styles.Header.Render(m.selectedFn.Name) + + var parts []string + parts = append(parts, header, "") + + if len(m.flags) == 0 { + parts = append(parts, m.styles.Muted.Render(" Loading flags...")) + } else if len(m.inputs) == 0 { + parts = append(parts, m.styles.Muted.Render(" No flags available. Ctrl+S to run.")) + } else { + for i, f := range m.flags { + marker := " " + if f.Required { + marker = m.styles.Error.Render("* ") + } + + name := fmt.Sprintf("--%-16s", f.Name) + cursor := " " + if i == m.focusIdx { + cursor = m.styles.Info.Render("> ") + } + + label := fmt.Sprintf("%s%s%s", cursor, marker, m.styles.Label.Render(name)) + input := m.inputs[i].View() + + desc := f.Desc + if f.Default != "" { + desc += m.styles.Muted.Render(fmt.Sprintf(" (default: %s)", f.Default)) + } + + parts = append(parts, label+input) + parts = append(parts, " "+m.styles.Muted.Render(desc)) + } + } + + parts = append(parts, "") + parts = append(parts, m.styles.Muted.Render(" ↑/↓: navigate │ Ctrl+S: run │ Esc: cancel")) + + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + func (m PipelinesModel) renderOutput() string { lines := splitLines(m.output) maxLines := 20 diff --git a/apps/pipeline_launcher/views/runner.go b/apps/pipeline_launcher/views/runner.go index 3dfdae22..9f41f592 100644 --- a/apps/pipeline_launcher/views/runner.go +++ b/apps/pipeline_launcher/views/runner.go @@ -5,12 +5,69 @@ import ( "fmt" "os/exec" "path/filepath" + "regexp" + "strings" "time" ops "fn-registry/fn_operations" "fn-registry/registry" ) +// PipelineFlag describes a CLI flag parsed from -help output. +type PipelineFlag struct { + Name string // e.g. "project" + Type string // e.g. "string" + Desc string // description text + Default string // default value, empty if none + Required bool // true if no default +} + +var flagLineRe = regexp.MustCompile(`^\s+-(\S+)\s+(\S+)$`) +var defaultRe = regexp.MustCompile(`\(default "(.*)"\)`) + +// GetPipelineFlags runs `go run . -help` and parses the flag output. +func GetPipelineFlags(fn *registry.Function, registryRoot string) []PipelineFlag { + absPath := filepath.Join(registryRoot, fn.FilePath) + dir := filepath.Dir(absPath) + + cmd := exec.Command("go", "run", ".", "-help") + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Run() // -help exits with code 2, ignore error + + return parseFlags(stderr.String()) +} + +func parseFlags(output string) []PipelineFlag { + var flags []PipelineFlag + lines := strings.Split(output, "\n") + + for i := 0; i < len(lines); i++ { + m := flagLineRe.FindStringSubmatch(lines[i]) + if m == nil { + continue + } + f := PipelineFlag{Name: m[1], Type: m[2]} + + // Next line is the description + if i+1 < len(lines) { + desc := strings.TrimSpace(lines[i+1]) + if dm := defaultRe.FindStringSubmatch(desc); dm != nil { + f.Default = dm[1] + f.Desc = strings.TrimSpace(defaultRe.ReplaceAllString(desc, "")) + } else { + f.Desc = desc + } + i++ + } + + f.Required = f.Default == "" && !strings.Contains(strings.ToLower(f.Desc), "opcional") + flags = append(flags, f) + } + return flags +} + // RunResult holds the outcome of a pipeline execution. type RunResult struct { Stdout string @@ -23,13 +80,14 @@ type RunResult struct { } // RunPipeline executes a pipeline as a subprocess and records the execution. -func RunPipeline(fn *registry.Function, registryRoot string, opsDB *ops.DB) RunResult { +func RunPipeline(fn *registry.Function, registryRoot string, opsDB *ops.DB, args []string) RunResult { absPath := filepath.Join(registryRoot, fn.FilePath) dir := filepath.Dir(absPath) startedAt := time.Now().UTC() - cmd := exec.Command("go", "run", ".") + cmdArgs := append([]string{"run", "."}, args...) + cmd := exec.Command("go", cmdArgs...) cmd.Dir = dir var stdout, stderr bytes.Buffer