#!/usr/bin/env bash set -euo pipefail # ============================================================================= # setup-create-tui.sh — Scaffoldea aplicación TUI en Go con DevFactory # Genera un TUI completo para gestionar scripts, comandos, Makefile y builds # ============================================================================= # --- Colores --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } log_ok() { echo -e "${GREEN}[OK]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_step() { echo -e "${CYAN}[STEP]${NC} $1"; } # --- Parámetros --- MODULE_NAME="${1:-}" TARGET_PATH="${2:-.}" DEVFACTORY_PATH="$HOME/.local_agentes/backend" DEVFACTORY_MODULE="github.com/lucasdataproyects/devfactory" # --- Validar nombre --- if [[ -z "$MODULE_NAME" ]]; then log_error "Uso: setup-create-tui.sh [path]" echo "STATUS: ERROR" exit 1 fi # Normalizar nombre a kebab-case MODULE_NAME=$(echo "$MODULE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') PROJECT_DIR="$TARGET_PATH/$MODULE_NAME" GO_MODULE="github.com/lucasdataproyects/$MODULE_NAME" # --- Check estado existente --- if [[ -f "$PROJECT_DIR/go.mod" ]]; then log_warn "El proyecto TUI $MODULE_NAME ya existe en $PROJECT_DIR" echo "STATUS: CONFIGURED" exit 0 fi # --- Verificar dependencias --- log_step "Verificando dependencias..." if ! command -v go &>/dev/null; then log_error "Go no está instalado. Instala Go 1.22+" echo "STATUS: ERROR" exit 1 fi GO_VERSION=$(go version | grep -oP '\d+\.\d+' | head -1) log_ok "Go $GO_VERSION encontrado" if [[ ! -d "$DEVFACTORY_PATH" ]]; then log_error "DevFactory no encontrado en $DEVFACTORY_PATH — es necesario para los componentes TUI" echo "STATUS: ERROR" exit 1 fi log_ok "DevFactory encontrado en $DEVFACTORY_PATH" # --- Crear estructura --- log_step "Creando estructura del proyecto TUI '$MODULE_NAME'..." mkdir -p "$PROJECT_DIR"/{app,views,config} # ============================================================================= # go.mod # ============================================================================= log_step "Generando go.mod..." cat > "$PROJECT_DIR/go.mod" << EOF module $GO_MODULE go 1.22.2 require $DEVFACTORY_MODULE v0.0.0 replace $DEVFACTORY_MODULE => $DEVFACTORY_PATH EOF # ============================================================================= # go.work # ============================================================================= log_step "Generando go.work con DevFactory local..." cat > "$PROJECT_DIR/go.work" << EOF go 1.22.2 use ( . $DEVFACTORY_PATH ) EOF log_ok "go.work enlazado a DevFactory" # ============================================================================= # config/config.go # ============================================================================= log_step "Generando config/config.go..." cat > "$PROJECT_DIR/config/config.go" << 'GOEOF' package config import ( "path/filepath" ) // CommandEntry representa un comando frecuente configurable. type CommandEntry struct { Name string Command string Description string } // BuildConfig representa una configuración de build. type BuildConfig struct { Name string Command string Description string } // Config contiene la configuración del TUI. type Config struct { RootDir string Commands []CommandEntry BuildConfigs []BuildConfig } // New crea una configuración con defaults sensatos. func New(dir string) Config { absDir, err := filepath.Abs(dir) if err != nil { absDir = dir } return Config{ RootDir: absDir, Commands: []CommandEntry{ {Name: "Go Build", Command: "go build ./...", Description: "Compilar todos los paquetes"}, {Name: "Go Test", Command: "go test ./... -v", Description: "Ejecutar todos los tests"}, {Name: "Go Vet", Command: "go vet ./...", Description: "Análisis estático del código"}, {Name: "Git Status", Command: "git status", Description: "Estado del repositorio"}, {Name: "Git Log", Command: "git log --oneline -10", Description: "Últimos 10 commits"}, }, BuildConfigs: []BuildConfig{ {Name: "Debug", Command: "go build -gcflags='-N -l' -o build/debug .", Description: "Build con símbolos de debug"}, {Name: "Release", Command: "go build -trimpath -ldflags='-s -w' -o build/release .", Description: "Build optimizado para producción"}, {Name: "Linux AMD64", Command: "GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o build/app-linux-amd64 .", Description: "Cross-compile Linux 64-bit"}, {Name: "Windows AMD64", Command: "GOOS=windows GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o build/app-windows-amd64.exe .", Description: "Cross-compile Windows 64-bit"}, }, } } GOEOF # ============================================================================= # views/keys.go — Constantes de navegación compartidas # ============================================================================= log_step "Generando views/keys.go..." cat > "$PROJECT_DIR/views/keys.go" << 'GOEOF' package views // Constantes de teclas para navegación. const ( KeyQuit = "ctrl+c" KeyEsc = "esc" KeyBack = "0" ) // IsBack retorna true si la tecla es de retroceso (Esc o 0). func IsBack(key string) bool { return key == KeyEsc || key == KeyBack } GOEOF # ============================================================================= # views/exec.go — Tipos de mensaje y comandos async # ============================================================================= log_step "Generando views/exec.go..." cat > "$PROJECT_DIR/views/exec.go" << 'GOEOF' package views import ( "os" "os/exec" "path/filepath" "regexp" "strings" tea "github.com/charmbracelet/bubbletea" ) // ExecResultMsg se envía cuando un comando termina de ejecutarse. type ExecResultMsg struct { Output string Error string ExitCode int Label string } // ScriptEntry representa un script descubierto. type ScriptEntry struct { Name string Path string Description string } // DiscoverScriptsMsg contiene los scripts descubiertos. type DiscoverScriptsMsg struct { Scripts []ScriptEntry } // MakeTarget representa un target de Makefile. type MakeTarget struct { Name string Description string } // DiscoverMakeTargetsMsg contiene los targets descubiertos. type DiscoverMakeTargetsMsg struct { Targets []MakeTarget } // ExecCommand ejecuta un comando shell de forma asíncrona. func ExecCommand(label, command, workDir string) tea.Cmd { return func() tea.Msg { cmd := exec.Command("bash", "-c", command) cmd.Dir = workDir out, err := cmd.CombinedOutput() exitCode := 0 errMsg := "" if err != nil { errMsg = err.Error() if exitErr, ok := err.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else { exitCode = 1 } } return ExecResultMsg{ Output: string(out), Error: errMsg, ExitCode: exitCode, Label: label, } } } // DiscoverScripts busca archivos .sh en el directorio raíz. func DiscoverScripts(rootDir string) tea.Cmd { return func() tea.Msg { var scripts []ScriptEntry skipDirs := map[string]bool{ ".git": true, "node_modules": true, "vendor": true, "build": true, ".venv": true, "__pycache__": true, } _ = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if info.IsDir() { if skipDirs[info.Name()] { return filepath.SkipDir } return nil } if strings.HasSuffix(info.Name(), ".sh") { relPath, _ := filepath.Rel(rootDir, path) desc := extractScriptDescription(path) scripts = append(scripts, ScriptEntry{ Name: relPath, Path: path, Description: desc, }) } return nil }) return DiscoverScriptsMsg{Scripts: scripts} } } func extractScriptDescription(path string) string { data, err := os.ReadFile(path) if err != nil { return "" } lines := strings.Split(string(data), "\n") limit := len(lines) if limit > 10 { limit = 10 } for _, line := range lines[:limit] { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "# ") && !strings.HasPrefix(trimmed, "#!") { return strings.TrimPrefix(trimmed, "# ") } } return "" } // DiscoverMakeTargets parsea el Makefile buscando targets. func DiscoverMakeTargets(rootDir string) tea.Cmd { return func() tea.Msg { makefilePath := filepath.Join(rootDir, "Makefile") data, err := os.ReadFile(makefilePath) if err != nil { return DiscoverMakeTargetsMsg{Targets: nil} } var targets []MakeTarget lines := strings.Split(string(data), "\n") targetRegex := regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:`) var lastComment string for _, line := range lines { if strings.HasPrefix(line, "## ") { lastComment = strings.TrimPrefix(line, "## ") } else if match := targetRegex.FindStringSubmatch(line); match != nil { target := match[1] if target != ".PHONY" { desc := lastComment lastComment = "" parts := strings.SplitN(desc, ":", 2) if len(parts) == 2 { desc = strings.TrimSpace(parts[1]) } targets = append(targets, MakeTarget{ Name: target, Description: desc, }) } } else { lastComment = "" } } return DiscoverMakeTargetsMsg{Targets: targets} } } GOEOF # ============================================================================= # views/menu.go # ============================================================================= log_step "Generando views/menu.go..." cat > "$PROJECT_DIR/views/menu.go" << GOEOF package views import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "$DEVFACTORY_MODULE/tui" ) // View identifica la vista activa. type View int const ( ViewMenu View = iota ViewScripts ViewCommands ViewMakefiles ViewBuilds ) // MenuModel es el menú principal del TUI. type MenuModel struct { list tui.ListModel done bool } // NewMenu crea el menú principal con las 4 opciones. func NewMenu() MenuModel { items := []tui.ListItem{ {Title: "Scripts", Description: "Buscar y ejecutar scripts .sh del repositorio", Value: ViewScripts}, {Title: "Comandos", Description: "Ejecutar comandos frecuentes configurados", Value: ViewCommands}, {Title: "Make", Description: "Ejecutar targets del Makefile", Value: ViewMakefiles}, {Title: "Builds", Description: "Gestionar configuraciones de build", Value: ViewBuilds}, } list := tui.NewList(items) list = list.WithHeight(10).WithWidth(60) return MenuModel{list: list} } // SelectedView retorna la vista seleccionada. func (m MenuModel) SelectedView() View { item := m.list.SelectedItem() if item == nil { return ViewMenu } if v, ok := item.Value.(View); ok { return v } return ViewMenu } // Init implementa tea.Model. func (m MenuModel) Init() tea.Cmd { return nil } // Update implementa tea.Model. func (m MenuModel) Update(msg tea.Msg) (MenuModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "enter", " ": m.list.Select() m.done = true return m, nil } } updated, cmd := m.list.Update(msg) m.list = updated.(tui.ListModel) return m, cmd } // View implementa tea.Model. func (m MenuModel) View() string { titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("6")). MarginBottom(1) title := titleStyle.Render("Menú Principal") return title + "\n" + m.list.View() } // IsDone retorna true si el usuario seleccionó una opción. func (m MenuModel) IsDone() bool { return m.done } // Reset limpia el estado de selección. func (m *MenuModel) Reset() { m.done = false } GOEOF # ============================================================================= # views/scripts.go # ============================================================================= log_step "Generando views/scripts.go..." cat > "$PROJECT_DIR/views/scripts.go" << GOEOF package views import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "$DEVFACTORY_MODULE/tui" ) type scriptsState int const ( scriptsLoading scriptsState = iota scriptsList scriptsRunning scriptsOutput ) // ScriptsModel gestiona la vista de scripts. type ScriptsModel struct { state scriptsState list tui.FilteredListModel spinner tui.SpinnerModel rootDir string output string exitCode int runLabel string scrollOff int viewHeight int } // NewScripts crea la vista de scripts. func NewScripts(rootDir string) ScriptsModel { list := tui.NewFilteredList(nil, "Buscar script...") list.ListModel = list.ListModel.WithHeight(15).WithWidth(60) return ScriptsModel{ state: scriptsLoading, list: list, spinner: tui.NewSpinner("Buscando scripts..."), rootDir: rootDir, viewHeight: 20, } } // Init lanza el descubrimiento de scripts. func (m ScriptsModel) Init() tea.Cmd { return tea.Batch(m.spinner.Init(), DiscoverScripts(m.rootDir)) } // Update implementa tea.Model. func (m ScriptsModel) Update(msg tea.Msg) (ScriptsModel, tea.Cmd) { switch msg := msg.(type) { case DiscoverScriptsMsg: items := make([]tui.ListItem, len(msg.Scripts)) for i, s := range msg.Scripts { items[i] = tui.ListItem{ Title: s.Name, Description: s.Description, Value: s.Path, } } m.list.ListModel.SetItems(items) m.list = tui.NewFilteredList(items, "Buscar script...") m.list.ListModel = m.list.ListModel.WithHeight(15).WithWidth(60) m.state = scriptsList return m, nil case ExecResultMsg: m.output = msg.Output if msg.Error != "" && msg.Output == "" { m.output = msg.Error } m.exitCode = msg.ExitCode m.runLabel = msg.Label m.state = scriptsOutput m.scrollOff = 0 return m, nil case tea.KeyMsg: switch m.state { case scriptsList: switch msg.String() { case "enter": item := m.list.SelectedItem() if item != nil { path := item.Value.(string) m.state = scriptsRunning m.spinner.SetMessage("Ejecutando " + item.Title + "...") return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, "bash "+path, m.rootDir)) } } case scriptsOutput: switch msg.String() { case "up", "k": if m.scrollOff > 0 { m.scrollOff-- } return m, nil case "down", "j": lines := strings.Split(m.output, "\n") if m.scrollOff < len(lines)-m.viewHeight { m.scrollOff++ } return m, nil } } case tea.WindowSizeMsg: m.viewHeight = msg.Height - 6 } if m.state == scriptsLoading || m.state == scriptsRunning { updated, cmd := m.spinner.Update(msg) m.spinner = updated.(tui.SpinnerModel) return m, cmd } if m.state == scriptsList { updated, cmd := m.list.Update(msg) m.list = updated.(tui.FilteredListModel) return m, cmd } return m, nil } // View implementa tea.Model. func (m ScriptsModel) View() string { titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) switch m.state { case scriptsLoading: return titleStyle.Render("Scripts") + "\n\n" + m.spinner.View() case scriptsList: return titleStyle.Render("Scripts") + "\n\n" + m.list.View() case scriptsRunning: return titleStyle.Render("Scripts") + "\n\n" + m.spinner.View() case scriptsOutput: header := titleStyle.Render("Scripts > " + m.runLabel) statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) if m.exitCode != 0 { statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) } status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) lines := strings.Split(m.output, "\n") end := m.scrollOff + m.viewHeight if end > len(lines) { end = len(lines) } visible := lines if m.scrollOff < len(lines) { visible = lines[m.scrollOff:end] } content := strings.Join(visible, "\n") return header + " " + status + "\n\n" + content } return "" } // HandleBack retorna true si la tecla es back y la vista puede retroceder. func (m *ScriptsModel) HandleBack() bool { if m.state == scriptsOutput { m.state = scriptsList m.output = "" m.scrollOff = 0 return false } if m.state == scriptsList { return true } return false } GOEOF # ============================================================================= # views/commands.go # ============================================================================= log_step "Generando views/commands.go..." cat > "$PROJECT_DIR/views/commands.go" << GOEOF package views import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "$GO_MODULE/config" "$DEVFACTORY_MODULE/tui" ) type commandsState int const ( commandsList commandsState = iota commandsRunning commandsOutput ) // CommandsModel gestiona la vista de comandos frecuentes. type CommandsModel struct { state commandsState list tui.ListModel spinner tui.SpinnerModel rootDir string output string exitCode int runLabel string scrollOff int viewHeight int } // NewCommands crea la vista de comandos frecuentes. func NewCommands(cfg config.Config) CommandsModel { items := make([]tui.ListItem, len(cfg.Commands)) for i, c := range cfg.Commands { items[i] = tui.ListItem{ Title: c.Name, Description: c.Description + " — " + c.Command, Value: c.Command, } } list := tui.NewList(items) list = list.WithHeight(12).WithWidth(60) return CommandsModel{ state: commandsList, list: list, spinner: tui.NewSpinner("Ejecutando..."), rootDir: cfg.RootDir, viewHeight: 20, } } // Init implementa tea.Model. func (m CommandsModel) Init() tea.Cmd { return nil } // Update implementa tea.Model. func (m CommandsModel) Update(msg tea.Msg) (CommandsModel, tea.Cmd) { switch msg := msg.(type) { case ExecResultMsg: m.output = msg.Output if msg.Error != "" && msg.Output == "" { m.output = msg.Error } m.exitCode = msg.ExitCode m.runLabel = msg.Label m.state = commandsOutput m.scrollOff = 0 return m, nil case tea.KeyMsg: switch m.state { case commandsList: if msg.String() == "enter" { item := m.list.SelectedItem() if item != nil { cmd := item.Value.(string) m.state = commandsRunning m.spinner.SetMessage("Ejecutando " + item.Title + "...") return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, cmd, m.rootDir)) } } case commandsOutput: switch msg.String() { case "up", "k": if m.scrollOff > 0 { m.scrollOff-- } return m, nil case "down", "j": lines := strings.Split(m.output, "\n") if m.scrollOff < len(lines)-m.viewHeight { m.scrollOff++ } return m, nil } } case tea.WindowSizeMsg: m.viewHeight = msg.Height - 6 } if m.state == commandsRunning { updated, cmd := m.spinner.Update(msg) m.spinner = updated.(tui.SpinnerModel) return m, cmd } if m.state == commandsList { updated, cmd := m.list.Update(msg) m.list = updated.(tui.ListModel) return m, cmd } return m, nil } // View implementa tea.Model. func (m CommandsModel) View() string { titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) switch m.state { case commandsList: return titleStyle.Render("Comandos") + "\n\n" + m.list.View() case commandsRunning: return titleStyle.Render("Comandos") + "\n\n" + m.spinner.View() case commandsOutput: header := titleStyle.Render("Comandos > " + m.runLabel) statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) if m.exitCode != 0 { statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) } status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) lines := strings.Split(m.output, "\n") end := m.scrollOff + m.viewHeight if end > len(lines) { end = len(lines) } visible := lines if m.scrollOff < len(lines) { visible = lines[m.scrollOff:end] } content := strings.Join(visible, "\n") return header + " " + status + "\n\n" + content } return "" } // HandleBack retorna true si debe volver al menú. func (m *CommandsModel) HandleBack() bool { if m.state == commandsOutput { m.state = commandsList m.output = "" m.scrollOff = 0 return false } if m.state == commandsList { return true } return false } GOEOF # ============================================================================= # views/makefiles.go # ============================================================================= log_step "Generando views/makefiles.go..." cat > "$PROJECT_DIR/views/makefiles.go" << GOEOF package views import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "$DEVFACTORY_MODULE/tui" ) type makeState int const ( makeLoading makeState = iota makeList makeRunning makeOutput makeEmpty ) // MakefilesModel gestiona la vista de Makefile targets. type MakefilesModel struct { state makeState list tui.FilteredListModel spinner tui.SpinnerModel rootDir string output string exitCode int runLabel string scrollOff int viewHeight int } // NewMakefiles crea la vista de Makefile targets. func NewMakefiles(rootDir string) MakefilesModel { list := tui.NewFilteredList(nil, "Buscar target...") list.ListModel = list.ListModel.WithHeight(15).WithWidth(60) return MakefilesModel{ state: makeLoading, list: list, spinner: tui.NewSpinner("Buscando targets..."), rootDir: rootDir, viewHeight: 20, } } // Init lanza el descubrimiento de targets. func (m MakefilesModel) Init() tea.Cmd { return tea.Batch(m.spinner.Init(), DiscoverMakeTargets(m.rootDir)) } // Update implementa tea.Model. func (m MakefilesModel) Update(msg tea.Msg) (MakefilesModel, tea.Cmd) { switch msg := msg.(type) { case DiscoverMakeTargetsMsg: if len(msg.Targets) == 0 { m.state = makeEmpty return m, nil } items := make([]tui.ListItem, len(msg.Targets)) for i, t := range msg.Targets { items[i] = tui.ListItem{ Title: t.Name, Description: t.Description, Value: t.Name, } } m.list = tui.NewFilteredList(items, "Buscar target...") m.list.ListModel = m.list.ListModel.WithHeight(15).WithWidth(60) m.state = makeList return m, nil case ExecResultMsg: m.output = msg.Output if msg.Error != "" && msg.Output == "" { m.output = msg.Error } m.exitCode = msg.ExitCode m.runLabel = msg.Label m.state = makeOutput m.scrollOff = 0 return m, nil case tea.KeyMsg: switch m.state { case makeList: if msg.String() == "enter" { item := m.list.SelectedItem() if item != nil { target := item.Value.(string) m.state = makeRunning m.spinner.SetMessage("Ejecutando make " + target + "...") return m, tea.Batch(m.spinner.Init(), ExecCommand("make "+target, "make "+target, m.rootDir)) } } case makeOutput: switch msg.String() { case "up", "k": if m.scrollOff > 0 { m.scrollOff-- } return m, nil case "down", "j": lines := strings.Split(m.output, "\n") if m.scrollOff < len(lines)-m.viewHeight { m.scrollOff++ } return m, nil } } case tea.WindowSizeMsg: m.viewHeight = msg.Height - 6 } if m.state == makeLoading || m.state == makeRunning { updated, cmd := m.spinner.Update(msg) m.spinner = updated.(tui.SpinnerModel) return m, cmd } if m.state == makeList { updated, cmd := m.list.Update(msg) m.list = updated.(tui.FilteredListModel) return m, cmd } return m, nil } // View implementa tea.Model. func (m MakefilesModel) View() string { titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) switch m.state { case makeLoading: return titleStyle.Render("Make") + "\n\n" + m.spinner.View() case makeEmpty: return titleStyle.Render("Make") + "\n\n" + warnStyle.Render("No se encontró Makefile en "+m.rootDir) case makeList: return titleStyle.Render("Make") + "\n\n" + m.list.View() case makeRunning: return titleStyle.Render("Make") + "\n\n" + m.spinner.View() case makeOutput: header := titleStyle.Render("Make > " + m.runLabel) statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) if m.exitCode != 0 { statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) } status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) lines := strings.Split(m.output, "\n") end := m.scrollOff + m.viewHeight if end > len(lines) { end = len(lines) } visible := lines if m.scrollOff < len(lines) { visible = lines[m.scrollOff:end] } content := strings.Join(visible, "\n") return header + " " + status + "\n\n" + content } return "" } // HandleBack retorna true si debe volver al menú. func (m *MakefilesModel) HandleBack() bool { if m.state == makeOutput { m.state = makeList m.output = "" m.scrollOff = 0 return false } if m.state == makeList || m.state == makeEmpty { return true } return false } GOEOF # ============================================================================= # views/builds.go # ============================================================================= log_step "Generando views/builds.go..." cat > "$PROJECT_DIR/views/builds.go" << GOEOF package views import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "$GO_MODULE/config" "$DEVFACTORY_MODULE/tui" ) type buildsState int const ( buildsList buildsState = iota buildsRunning buildsOutput ) // BuildsModel gestiona la vista de builds. type BuildsModel struct { state buildsState list tui.ListModel spinner tui.SpinnerModel rootDir string output string exitCode int runLabel string startTime time.Time elapsed time.Duration scrollOff int viewHeight int } // NewBuilds crea la vista de builds. func NewBuilds(cfg config.Config) BuildsModel { items := make([]tui.ListItem, len(cfg.BuildConfigs)) for i, b := range cfg.BuildConfigs { items[i] = tui.ListItem{ Title: b.Name, Description: b.Description, Value: b.Command, } } list := tui.NewList(items) list = list.WithHeight(12).WithWidth(60) return BuildsModel{ state: buildsList, list: list, spinner: tui.NewSpinner("Building..."), rootDir: cfg.RootDir, viewHeight: 20, } } // Init implementa tea.Model. func (m BuildsModel) Init() tea.Cmd { return nil } // Update implementa tea.Model. func (m BuildsModel) Update(msg tea.Msg) (BuildsModel, tea.Cmd) { switch msg := msg.(type) { case ExecResultMsg: m.elapsed = time.Since(m.startTime) m.output = msg.Output if msg.Error != "" && msg.Output == "" { m.output = msg.Error } m.exitCode = msg.ExitCode m.runLabel = msg.Label m.state = buildsOutput m.scrollOff = 0 return m, nil case tea.KeyMsg: switch m.state { case buildsList: if msg.String() == "enter" { item := m.list.SelectedItem() if item != nil { cmd := item.Value.(string) m.state = buildsRunning m.startTime = time.Now() m.spinner.SetMessage("Building " + item.Title + "...") return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, cmd, m.rootDir)) } } case buildsOutput: switch msg.String() { case "up", "k": if m.scrollOff > 0 { m.scrollOff-- } return m, nil case "down", "j": lines := strings.Split(m.output, "\n") if m.scrollOff < len(lines)-m.viewHeight { m.scrollOff++ } return m, nil } } case tea.WindowSizeMsg: m.viewHeight = msg.Height - 6 } if m.state == buildsRunning { updated, cmd := m.spinner.Update(msg) m.spinner = updated.(tui.SpinnerModel) return m, cmd } if m.state == buildsList { updated, cmd := m.list.Update(msg) m.list = updated.(tui.ListModel) return m, cmd } return m, nil } // View implementa tea.Model. func (m BuildsModel) View() string { titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) switch m.state { case buildsList: return titleStyle.Render("Builds") + "\n\n" + m.list.View() case buildsRunning: return titleStyle.Render("Builds") + "\n\n" + m.spinner.View() case buildsOutput: header := titleStyle.Render("Builds > " + m.runLabel) statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) if m.exitCode != 0 { statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) } status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode)) elapsed := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fmt.Sprintf("(%s)", m.elapsed.Round(time.Millisecond))) lines := strings.Split(m.output, "\n") end := m.scrollOff + m.viewHeight if end > len(lines) { end = len(lines) } visible := lines if m.scrollOff < len(lines) { visible = lines[m.scrollOff:end] } content := strings.Join(visible, "\n") return header + " " + status + " " + elapsed + "\n\n" + content } return "" } // HandleBack retorna true si debe volver al menú. func (m *BuildsModel) HandleBack() bool { if m.state == buildsOutput { m.state = buildsList m.output = "" m.scrollOff = 0 return false } if m.state == buildsList { return true } return false } GOEOF # ============================================================================= # app/model.go — Modelo principal # ============================================================================= log_step "Generando app/model.go..." cat > "$PROJECT_DIR/app/model.go" << GOEOF package app import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "$GO_MODULE/config" "$GO_MODULE/views" "$DEVFACTORY_MODULE/tui" ) // Model es el modelo principal de la aplicación TUI. type Model struct { tui.BaseModel config config.Config currentView views.View menu views.MenuModel scripts views.ScriptsModel commands views.CommandsModel makefiles views.MakefilesModel builds views.BuildsModel width int height int scriptsInit bool makeInit bool } // New crea el modelo principal. func New(cfg config.Config) Model { return Model{ BaseModel: tui.NewBaseModel(), config: cfg, currentView: views.ViewMenu, menu: views.NewMenu(), scripts: views.NewScripts(cfg.RootDir), commands: views.NewCommands(cfg), makefiles: views.NewMakefiles(cfg.RootDir), builds: views.NewBuilds(cfg), } } // Init implementa tea.Model. func (m Model) Init() tea.Cmd { return nil } // Update implementa tea.Model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.SetSize(msg.Width, msg.Height) case tea.KeyMsg: key := msg.String() // Ctrl+C siempre sale if key == views.KeyQuit { return m, tea.Quit } // Manejar back (Esc/0) if views.IsBack(key) { switch m.currentView { case views.ViewMenu: return m, tea.Quit case views.ViewScripts: if m.scripts.HandleBack() { m.currentView = views.ViewMenu m.menu.Reset() return m, nil } return m, nil case views.ViewCommands: if m.commands.HandleBack() { m.currentView = views.ViewMenu m.menu.Reset() return m, nil } return m, nil case views.ViewMakefiles: if m.makefiles.HandleBack() { m.currentView = views.ViewMenu m.menu.Reset() return m, nil } return m, nil case views.ViewBuilds: if m.builds.HandleBack() { m.currentView = views.ViewMenu m.menu.Reset() return m, nil } return m, nil } } } // Delegar al sub-modelo activo var cmd tea.Cmd switch m.currentView { case views.ViewMenu: m.menu, cmd = m.menu.Update(msg) if m.menu.IsDone() { selected := m.menu.SelectedView() m.currentView = selected m.menu.Reset() switch selected { case views.ViewScripts: if !m.scriptsInit { m.scriptsInit = true return m, m.scripts.Init() } case views.ViewMakefiles: if !m.makeInit { m.makeInit = true return m, m.makefiles.Init() } } return m, nil } case views.ViewScripts: m.scripts, cmd = m.scripts.Update(msg) case views.ViewCommands: m.commands, cmd = m.commands.Update(msg) case views.ViewMakefiles: m.makefiles, cmd = m.makefiles.Update(msg) case views.ViewBuilds: m.builds, cmd = m.builds.Update(msg) } return m, cmd } // View implementa tea.Model. func (m Model) View() string { headerStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("0")). Background(lipgloss.Color("6")). Padding(0, 1). Width(m.width) header := headerStyle.Render(fmt.Sprintf(" TUI Manager — %s", m.config.RootDir)) var content string switch m.currentView { case views.ViewMenu: content = m.menu.View() case views.ViewScripts: content = m.scripts.View() case views.ViewCommands: content = m.commands.View() case views.ViewMakefiles: content = m.makefiles.View() case views.ViewBuilds: content = m.builds.View() } statusStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). Width(m.width) var hints string switch m.currentView { case views.ViewMenu: hints = "↑↓: navegar | Enter: seleccionar | Esc: salir" default: hints = "↑↓: navegar | Enter: ejecutar | /: filtrar | Esc/0: volver" } statusBar := statusStyle.Render(hints) return header + "\n\n" + content + "\n\n" + statusBar } GOEOF # ============================================================================= # main.go # ============================================================================= log_step "Generando main.go..." cat > "$PROJECT_DIR/main.go" << GOEOF package main import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" "$GO_MODULE/app" "$GO_MODULE/config" ) func main() { dir := "." if len(os.Args) > 1 { dir = os.Args[1] } cfg := config.New(dir) model := app.New(cfg) p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } GOEOF # ============================================================================= # Makefile # ============================================================================= log_step "Generando Makefile..." cat > "$PROJECT_DIR/Makefile" << MKEOF .PHONY: run build test clean install help MODULE_NAME := $MODULE_NAME BUILD_DIR := build ## run: Ejecutar el TUI en modo desarrollo run: go run . . ## build: Compilar el binario build: @mkdir -p \$(BUILD_DIR) go build -trimpath -ldflags='-s -w' -o \$(BUILD_DIR)/\$(MODULE_NAME) . @echo "Built \$(BUILD_DIR)/\$(MODULE_NAME)" ## test: Ejecutar tests test: go test ./... -v ## clean: Limpiar artefactos clean: rm -rf \$(BUILD_DIR) ## install: Instalar en ~/.local/bin install: build cp \$(BUILD_DIR)/\$(MODULE_NAME) \$(HOME)/.local/bin/\$(MODULE_NAME) @echo "Installed to \$(HOME)/.local/bin/\$(MODULE_NAME)" ## tidy: go mod tidy tidy: go mod tidy ## help: Mostrar esta ayuda help: @grep -E '^## ' Makefile | sed 's/## //' | column -t -s ':' MKEOF # ============================================================================= # .gitignore # ============================================================================= cat > "$PROJECT_DIR/.gitignore" << 'EOF' build/ *.exe *.dll *.so *.dylib EOF # ============================================================================= # go mod tidy # ============================================================================= log_step "Ejecutando go mod tidy..." cd "$PROJECT_DIR" go mod tidy 2>/dev/null || log_warn "go mod tidy falló — revisa el go.work" # ============================================================================= # Resumen # ============================================================================= echo "" log_ok "Proyecto TUI '$MODULE_NAME' creado en $PROJECT_DIR" echo "" echo -e "${CYAN}Estructura:${NC}" echo " $MODULE_NAME/" echo " ├── main.go — Entry point (fullscreen TUI)" echo " ├── app/" echo " │ └── model.go — Modelo principal y navegación" echo " ├── views/" echo " │ ├── keys.go — Constantes de teclas" echo " │ ├── exec.go — Comandos async y descubrimiento" echo " │ ├── menu.go — Menú principal (4 opciones)" echo " │ ├── scripts.go — Buscar y ejecutar scripts .sh" echo " │ ├── commands.go — Comandos frecuentes" echo " │ ├── makefiles.go — Targets del Makefile" echo " │ └── builds.go — Configuraciones de build" echo " ├── config/" echo " │ └── config.go — Configuración (editable)" echo " ├── Makefile — run, build, test, install" echo " └── go.work — Enlace a DevFactory" echo "" echo -e "${CYAN}Comandos:${NC}" echo " make run — Ejecutar el TUI" echo " make build — Compilar binario" echo " make install — Instalar en ~/.local/bin" echo " make test — Ejecutar tests" echo "" echo -e "${CYAN}Navegación:${NC}" echo " ↑↓ — Moverse por las listas" echo " Enter — Seleccionar / ejecutar" echo " / — Filtrar (en scripts y make)" echo " Esc / 0 — Volver atrás" echo " Esc (menú) — Salir del TUI" echo "" echo "STATUS: READY"