From c254f2d3ba3783472887ef578c46cceb0200c6d4 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Sun, 22 Feb 2026 20:13:07 +0100 Subject: [PATCH 01/38] refactor: extract shared `dbheader` package from pterm --- internal/orchestration/seed_orchestrator.go | 10 +++++----- internal/ui/dbheader/dbheader.go | 14 ++++++++++++++ internal/ui/tui/app.go | 9 +++------ 3 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 internal/ui/dbheader/dbheader.go diff --git a/internal/orchestration/seed_orchestrator.go b/internal/orchestration/seed_orchestrator.go index bfe52d0..be07e5c 100644 --- a/internal/orchestration/seed_orchestrator.go +++ b/internal/orchestration/seed_orchestrator.go @@ -2,6 +2,7 @@ package orchestration import ( "context" + "fmt" "time" "seedfast/cli/internal/auth" @@ -14,9 +15,9 @@ import ( "seedfast/cli/internal/manifest" "seedfast/cli/internal/seeding" "seedfast/cli/internal/sqlexec" + "seedfast/cli/internal/ui/dbheader" "github.com/jackc/pgx/v5/pgxpool" - "github.com/pterm/pterm" ) // sessionContext holds the results of the session preparation phase (auth, DSN @@ -84,14 +85,13 @@ func (o *SeedOrchestrator) Run(ctx context.Context, startTime time.Time) error { // Display database info (masked DSN + db name) - skip in CI/CD mode if !o.config.IsCICDMode() { - pterm.Println() + fmt.Println() // Show endpoint indicators (dev builds only - handled by build tags) showEndpointIndicators() - pterm.Println(pterm.NewStyle(pterm.FgLightCyan).Sprint("→ Database: ") + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Sprint(s.dbName)) - pterm.Println(pterm.NewStyle(pterm.FgLightCyan).Sprint("→ Connection: ") + pterm.NewStyle(pterm.FgLightBlue).Sprint(s.maskedDSN)) - pterm.Println() + fmt.Print(dbheader.Render(s.dbName, s.maskedDSN)) + fmt.Println() } // Step 7: Initialize state and components diff --git a/internal/ui/dbheader/dbheader.go b/internal/ui/dbheader/dbheader.go new file mode 100644 index 0000000..67523c8 --- /dev/null +++ b/internal/ui/dbheader/dbheader.go @@ -0,0 +1,14 @@ +package dbheader + +import "github.com/charmbracelet/lipgloss" + +// Render returns the styled database connection header as a string. +// The result includes two lines: Database name and masked connection DSN. +func Render(dbName, maskedDSN string) string { + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00BFFF")) + dbNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00CED1")).Bold(true) + dsnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6495ED")) + + return labelStyle.Render("→ Database: ") + dbNameStyle.Render(dbName) + "\n" + + labelStyle.Render("→ Connection: ") + dsnStyle.Render(maskedDSN) + "\n" +} diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 6d7e966..c2d4173 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -4,9 +4,10 @@ import ( "strings" "time" + "seedfast/cli/internal/ui/dbheader" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) // ExitState carries the final outcome of the TUI session back to the orchestrator. @@ -94,11 +95,7 @@ func NewAppModel( router := NewRouter(phaseMap) // Pre-render the DB info header so it's always visible at the top of View(). - labelStyle := lipgloss.NewStyle().Foreground(theme.Primary) - dbNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00CED1")).Bold(true) - dsnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6495ED")) - dbHeader := labelStyle.Render("→ Database: ") + dbNameStyle.Render(cfg.DBName) + "\n" + - labelStyle.Render("→ Connection: ") + dsnStyle.Render(cfg.DBInfo) + "\n" + dbHeader := dbheader.Render(cfg.DBName, cfg.DBInfo) vp := viewport.New(0, 0) vp.KeyMap = viewport.KeyMap{} // disable keyboard navigation From ad99d2e7d227044f2ad1b67c694c89324c84256c Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Sun, 22 Feb 2026 20:13:20 +0100 Subject: [PATCH 02/38] refactor: replace emoji start with braille spinner --- internal/orchestration/event_handler.go | 5 +++- internal/orchestration/header_spinner.go | 36 ++++++++++++++++-------- internal/ui/interactive_renderer.go | 1 - 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/internal/orchestration/event_handler.go b/internal/orchestration/event_handler.go index a3b2e24..8178e1f 100644 --- a/internal/orchestration/event_handler.go +++ b/internal/orchestration/event_handler.go @@ -124,7 +124,10 @@ func (eh *EventHandler) ProcessEvents(ctx context.Context) error { eh.seedingRenderer.Start() defer eh.seedingRenderer.Stop() - // Note: Header spinner is NOT started here - it will start after subscription_limit_info + // Start header spinner immediately for visual feedback (skip in CI/CD mode) + if !eh.config.IsCICDMode() { + eh.headerSpinner.Start() + } for ev := range eh.eventsChan { eh.logf("event type=%s payload_len=%d", ev.Type, len(ev.Message)) diff --git a/internal/orchestration/header_spinner.go b/internal/orchestration/header_spinner.go index dfc069b..9f6f0ff 100644 --- a/internal/orchestration/header_spinner.go +++ b/internal/orchestration/header_spinner.go @@ -6,24 +6,31 @@ import ( "time" "atomicgo.dev/cursor" + "github.com/charmbracelet/lipgloss" ) // HeaderSpinner manages the header spinner UI component. // Uses \r-based single-line updates for resize-safe rendering. type HeaderSpinner struct { - frames []string - idx int - stop chan struct{} - wg sync.WaitGroup - started bool - mu sync.Mutex + frames []string + message string + spinnerStyle lipgloss.Style + messageStyle lipgloss.Style + idx int + stop chan struct{} + wg sync.WaitGroup + started bool + mu sync.Mutex } -// NewHeaderSpinner creates a new HeaderSpinner with stick-style frames. +// NewHeaderSpinner creates a new HeaderSpinner with braille-style frames. func NewHeaderSpinner() *HeaderSpinner { return &HeaderSpinner{ - frames: []string{"|", "/", "-", "\\"}, // Stick-style frames - stop: make(chan struct{}), + frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + message: "Starting database seeding...", + spinnerStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#00BFFF")), + messageStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")), + stop: make(chan struct{}), } } @@ -42,7 +49,7 @@ func (hs *HeaderSpinner) Start() { hs.wg.Add(1) go func() { defer hs.wg.Done() - ticker := time.NewTicker(120 * time.Millisecond) + ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() for { @@ -52,7 +59,7 @@ func (hs *HeaderSpinner) Start() { hs.idx++ frame := hs.frames[hs.idx%len(hs.frames)] // \r overwrites current line; \033[K clears rest of LINE only - fmt.Printf("\r%s Seeding\033[K", frame) + fmt.Printf("\r%s %s\033[K", hs.spinnerStyle.Render(frame), hs.messageStyle.Render(hs.message)) hs.mu.Unlock() case <-hs.stop: @@ -89,3 +96,10 @@ func (hs *HeaderSpinner) IsStarted() bool { defer hs.mu.Unlock() return hs.started } + +// SetMessage changes the spinner message text. +func (hs *HeaderSpinner) SetMessage(msg string) { + hs.mu.Lock() + defer hs.mu.Unlock() + hs.message = msg +} diff --git a/internal/ui/interactive_renderer.go b/internal/ui/interactive_renderer.go index 7b381d9..52db59e 100644 --- a/internal/ui/interactive_renderer.go +++ b/internal/ui/interactive_renderer.go @@ -25,7 +25,6 @@ func NewInteractiveRenderer() *InteractiveRenderer { // Start initializes interactive UI func (r *InteractiveRenderer) Start() { r.startTime = time.Now() - fmt.Println("\n🚀 Starting database seeding...") fmt.Println() } From 30c1184adb71c3500fe9f0f40357f13821b33d3e Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Sun, 22 Feb 2026 20:15:31 +0100 Subject: [PATCH 03/38] chore: add no-commit rule for `docs/` to git-workflow --- .claude/skills/git-workflow/SKILL.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/skills/git-workflow/SKILL.md b/.claude/skills/git-workflow/SKILL.md index 22a2a40..a9bfc35 100644 --- a/.claude/skills/git-workflow/SKILL.md +++ b/.claude/skills/git-workflow/SKILL.md @@ -16,6 +16,7 @@ description: Git workflow conventions for Seedfast CLI. Use when creating branch - NEVER bundle unrelated concerns into a single commit - NEVER include AI attribution in commit messages (no "made with Claude", no "Co-Authored-By", etc.) - NEVER run `git add` or `git commit` unless explicitly requested by the user +- NEVER commit `docs/` directory files (plans, guides, architecture docs, bug reports). This is a public repo -- internal documentation must stay local only **Branch naming:** - Features: `feat/` or `feature/` @@ -133,6 +134,7 @@ independently. No behavior change. - A bug fix and a refactoring discovered during the fix are TWO commits - Do not bundle unrelated changes - **Tooling files are separate from code changes.** Skills (`.claude/skills/`), agent configs (`.claude/agents/`), and `CLAUDE.md` files are committed separately from application code. Use `docs:` or `chore:` prefix for these. +- **NEVER commit `docs/` files.** The `docs/` directory (plans, guides, architecture, bug reports) is local-only internal documentation. This is a public repository -- committing internal docs exposes implementation details. Always exclude `docs/` when staging. - **When multiple concerns exist in the working directory, consult the user with clear options before committing.** **Example -- correct way to consult:** @@ -398,6 +400,7 @@ gh pr create --base master --assignee @me --label '🐛 Bug' --title '...' --bod | AI attribution in commits | No "made with Claude", no "Co-Authored-By" | | Bundling unrelated changes | Separate concerns = separate commits | | Commits that break the build | Every commit must be a working snapshot | +| Committing `docs/` directory | Public repo -- internal docs must stay local | ## Troubleshooting From e161f87ba36e93cd22c0c26a762ea1704abf75dc Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Sun, 22 Feb 2026 20:17:01 +0100 Subject: [PATCH 04/38] chore: fix settings syntax, update framer MCP config --- .claude/settings.json | 2 +- .mcp.json | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 480860a..be42f2c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -70,7 +70,7 @@ "command": "which jq > /dev/null && jq -r '.tool_input.file_path // empty' | { read -r file_path; if [ -n \"$file_path\" ]; then jq '.' \"$file_path\" > \"${file_path}.tmp\" && mv \"${file_path}.tmp\" \"$file_path\"; fi; } || true" } ] - }, + } ], "PreToolUse": [ diff --git a/.mcp.json b/.mcp.json index 1f79c73..638d0f1 100644 --- a/.mcp.json +++ b/.mcp.json @@ -15,8 +15,13 @@ } }, "framer-mcp": { - "type": "sse", - "url": "https://mcp.unframer.co/sse?id=05813f6acf7a38e89e5f5b4ffa11c3e9443694b2fd0d54b25a6d689952704761&secret=xTdPkjJFZMB2gthxf15sEuz2OrKyUifU" + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://mcp.unframer.co/sse?id=05813f6acf7a38e89e5f5b4ffa11c3e9443694b2fd0d54b25a6d689952704761&secret=xTdPkjJFZMB2gthxf15sEuz2OrKyUifU" + ] }, "TalkToFigma": { "type": "stdio", From 4f14820a80721aa75f960b7a9770c27763c1e844 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 15:29:15 +0100 Subject: [PATCH 05/38] refactor: replace pterm with lipgloss in subscription display --- .../subscription_limit_display.go | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/internal/orchestration/subscription_limit_display.go b/internal/orchestration/subscription_limit_display.go index b542cdc..2727916 100644 --- a/internal/orchestration/subscription_limit_display.go +++ b/internal/orchestration/subscription_limit_display.go @@ -5,7 +5,7 @@ import ( "seedfast/cli/internal/config" - "github.com/pterm/pterm" + "github.com/charmbracelet/lipgloss" ) // SubscriptionLimitDisplay displays subscription limit information to the user. @@ -33,16 +33,16 @@ func (d *subscriptionLimitDisplay) ShowLimits(info *SubscriptionLimitInfo) { return } - style := pterm.NewStyle(pterm.FgGreen) + checkStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF88")) // Simple text format (no tables, no boxes) - pterm.Println(style.Sprint("✓") + fmt.Sprintf(" Tables to seed: %d", info.TablesInScope)) - pterm.Println(style.Sprint("✓") + fmt.Sprintf(" Plan allows: up to %d tables per seed", info.MaxTablesPerSeed)) + fmt.Println(checkStyle.Render("✓") + fmt.Sprintf(" Tables to seed: %d", info.TablesInScope)) + fmt.Println(checkStyle.Render("✓") + fmt.Sprintf(" Plan allows: up to %d tables per seed", info.MaxTablesPerSeed)) if info.RemainingThisPeriod != nil { - pterm.Println(style.Sprint("✓") + fmt.Sprintf(" Seedings remaining: %d this month", *info.RemainingThisPeriod)) + fmt.Println(checkStyle.Render("✓") + fmt.Sprintf(" Seedings remaining: %d this month", *info.RemainingThisPeriod)) } - pterm.Println() + fmt.Println() } // ShowScopeTooLargeWarning displays a warning when scope exceeds plan limits. @@ -55,23 +55,31 @@ func (d *subscriptionLimitDisplay) ShowScopeTooLargeWarning(info *SubscriptionLi return } - pterm.Println() - pterm.Println(pterm.NewStyle(pterm.FgYellow, pterm.Bold).Sprint("⚠️ Scope Exceeds Plan Limit")) - pterm.Println() + warningStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true) + cyanBold := lipgloss.NewStyle().Foreground(lipgloss.Color("#00BFFF")).Bold(true) + greenBold := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF88")).Bold(true) + bold := lipgloss.NewStyle().Bold(true) + lightBlue := lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEFA")) + lightMagenta := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF77FF")) + lightRed := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6666")) + + fmt.Println() + fmt.Println(warningStyle.Render("⚠️ Scope Exceeds Plan Limit")) + fmt.Println() // Colorful, user-friendly text scopeText := fmt.Sprintf( "Scope requires %s tables, but your plan allows max %s tables per seeding.", - pterm.NewStyle(pterm.FgCyan, pterm.Bold).Sprintf("%d", info.TablesInScope), - pterm.NewStyle(pterm.FgGreen, pterm.Bold).Sprintf("%d", info.MaxTablesPerSeed), + cyanBold.Render(fmt.Sprintf("%d", info.TablesInScope)), + greenBold.Render(fmt.Sprintf("%d", info.MaxTablesPerSeed)), ) - pterm.Println(scopeText) - pterm.Println() + fmt.Println(scopeText) + fmt.Println() // Colorful options with highlights - pterm.Println(pterm.NewStyle(pterm.Bold).Sprint("You can:")) - pterm.Println(" • " + pterm.NewStyle(pterm.FgLightBlue).Sprint("Refine your prompt to reduce the scope")) - pterm.Println(" • " + pterm.NewStyle(pterm.FgLightMagenta).Sprint("Expand subscription plan at https://seedfa.st/pricing")) - pterm.Println(" • " + pterm.NewStyle(pterm.FgLightRed).Sprint("Cancel current seeding")) - pterm.Println() + fmt.Println(bold.Render("You can:")) + fmt.Println(" • " + lightBlue.Render("Refine your prompt to reduce the scope")) + fmt.Println(" • " + lightMagenta.Render("Expand subscription plan at https://seedfa.st/pricing")) + fmt.Println(" • " + lightRed.Render("Cancel current seeding")) + fmt.Println() } From d88d17bdf2f56066e85945dbbab1f4bbd463874b Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 15:40:59 +0100 Subject: [PATCH 06/38] feat: add `configtui` Bubble Tea models for config commands Lightweight standalone package for login and connect TUI flows, separate from the seed TUI. Reuses existing spinner and text input components from the TUI framework. --- internal/ui/configtui/connect_model.go | 302 +++++++++++++++++++++++++ internal/ui/configtui/login_model.go | 288 +++++++++++++++++++++++ internal/ui/configtui/styles.go | 40 ++++ 3 files changed, 630 insertions(+) create mode 100644 internal/ui/configtui/connect_model.go create mode 100644 internal/ui/configtui/login_model.go create mode 100644 internal/ui/configtui/styles.go diff --git a/internal/ui/configtui/connect_model.go b/internal/ui/configtui/connect_model.go new file mode 100644 index 0000000..f6436d4 --- /dev/null +++ b/internal/ui/configtui/connect_model.go @@ -0,0 +1,302 @@ +package configtui + +import ( + "context" + "fmt" + "time" + + "seedfast/cli/internal/dsn" + "seedfast/cli/internal/ui/tui/components" + + tea "github.com/charmbracelet/bubbletea" +) + +// connectState represents the current phase of the connect TUI. +type connectState int + +const ( + connectStateInput connectState = iota + connectStateVerifying + connectStateDone +) + +// ConnectResult holds the outcome of the connect flow. +type ConnectResult struct { + Success bool + Cancelled bool + Err error + ErrType string // "parse", "connection", "config", "keychain_unavailable" +} + +// ConnectModel is a Bubble Tea model for the seedfast connect command. +type ConnectModel struct { + ctx context.Context + parseDSN func(string) (string, error) + pingDB func(ctx context.Context, dsn string) error + saveDSN func(string) error + + state connectState + textInput components.TextInputComponent + spinner components.SpinnerComponent + normalizedDSN string + + // Verification state: both must complete before transition. + connResult *connOutcome + timerDone bool + + result ConnectResult +} + +// connOutcome stores the connection verification result. +type connOutcome struct { + err error +} + +// NewConnectModel constructs a ConnectModel with all external dependencies injected. +func NewConnectModel( + ctx context.Context, + parseDSN func(string) (string, error), + pingDB func(ctx context.Context, dsn string) error, + saveDSN func(string) error, +) ConnectModel { + return ConnectModel{ + ctx: ctx, + parseDSN: parseDSN, + pingDB: pingDB, + saveDSN: saveDSN, + state: connectStateInput, + textInput: components.NewTextInput(theme, "postgres://user:pass@host:5432/db?sslmode=disable", "Enter Postgres DSN:", 0), + spinner: components.NewSpinner(theme, components.SpinnerStick, "verifying connection"), + } +} + +// Result returns the connect outcome after the program exits. +func (m ConnectModel) Result() ConnectResult { + return m.result +} + +// --- tea.Msg types --- + +type connPingResultMsg struct { + err error +} + +type connMinTimerMsg struct{} + +type connSaveResultMsg struct { + err error + errType string +} + +// --- tea.Model interface --- + +func (m ConnectModel) Init() tea.Cmd { + return m.textInput.Init() +} + +func (m ConnectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.state { + case connectStateInput: + return m.updateInput(msg) + case connectStateVerifying: + return m.updateVerifying(msg) + default: + return m, nil + } +} + +func (m ConnectModel) View() string { + switch m.state { + case connectStateInput: + return m.textInput.View() + case connectStateVerifying: + return m.spinner.View() + "\n" + default: + return "" + } +} + +// --- State: Input --- + +func (m ConnectModel) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var result *components.TextInputResult + m.textInput, cmd, result = m.textInput.Update(msg) + + if result == nil { + return m, cmd + } + + // Input is done. + if result.Cancelled { + m.state = connectStateDone + m.result = ConnectResult{Cancelled: true} + return m, tea.Quit + } + + rawDSN := result.Value + if rawDSN == "" { + m.state = connectStateDone + m.result = ConnectResult{Err: fmt.Errorf("DSN is required"), ErrType: "parse"} + return m, tea.Quit + } + + // Parse synchronously in Update. + normalizedDSN, err := m.parseDSN(rawDSN) + if err != nil { + m.state = connectStateDone + if parseErr, ok := err.(*dsn.ParseError); ok { + m.result = ConnectResult{ + Err: fmt.Errorf("Invalid DSN format: %s\n Please check your connection string and try again.", parseErr.Reason), + ErrType: "parse", + } + } else { + m.result = ConnectResult{ + Err: fmt.Errorf("Invalid database connection format\n Expected format: postgres://user:password@host:5432/database?sslmode=disable\n Please try again with a valid PostgreSQL connection string."), + ErrType: "parse", + } + } + return m, tea.Quit + } + + // Parse succeeded; transition to verifying. + m.normalizedDSN = normalizedDSN + m.state = connectStateVerifying + m.connResult = nil + m.timerDone = false + + return m, tea.Batch( + m.spinner.Init(), + m.asyncPing(), + m.minTimer(), + ) +} + +// --- State: Verifying --- + +func (m ConnectModel) updateVerifying(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.state = connectStateDone + m.result = ConnectResult{Cancelled: true} + return m, tea.Quit + } + + case connPingResultMsg: + m.connResult = &connOutcome{err: msg.err} + return m.tryFinishVerify() + + case connMinTimerMsg: + m.timerDone = true + return m.tryFinishVerify() + + case connSaveResultMsg: + m.state = connectStateDone + if msg.err != nil { + m.result = ConnectResult{Err: msg.err, ErrType: msg.errType} + } else { + m.result = ConnectResult{Success: true} + } + return m, tea.Quit + } + + // Forward to spinner. + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd +} + +// tryFinishVerify checks whether both ping and timer are done and transitions accordingly. +func (m ConnectModel) tryFinishVerify() (tea.Model, tea.Cmd) { + if m.connResult == nil || !m.timerDone { + return m, nil + } + + // Both done. Check connection result. + if m.connResult.err != nil { + m.state = connectStateDone + m.result = ConnectResult{Err: m.connResult.err, ErrType: "connection"} + return m, tea.Quit + } + + // Connection succeeded; save to keychain asynchronously. + return m, m.asyncSave() +} + +// --- tea.Cmd helpers --- + +func (m ConnectModel) asyncPing() tea.Cmd { + return func() tea.Msg { + err := m.pingDB(m.ctx, m.normalizedDSN) + return connPingResultMsg{err: err} + } +} + +func (m ConnectModel) minTimer() tea.Cmd { + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return connMinTimerMsg{} + }) +} + +func (m ConnectModel) asyncSave() tea.Cmd { + return func() tea.Msg { + err := m.saveDSN(m.normalizedDSN) + if err != nil { + // Detect keychain-unavailable errors by checking the error text. + // The keychain package returns specific errors when the OS keyring is unavailable. + errType := "config" + if isKeychainUnavailable(err) { + errType = "keychain_unavailable" + } + return connSaveResultMsg{err: err, errType: errType} + } + return connSaveResultMsg{} + } +} + +// isKeychainUnavailable returns true if the error indicates the OS keyring is not available. +func isKeychainUnavailable(err error) bool { + if err == nil { + return false + } + msg := err.Error() + // The keychain package surfaces these when no suitable backend is found. + return containsAny(msg, []string{ + "keyring", + "keychain", + "secure storage", + "not available", + }) +} + +// containsAny checks if s contains any of the substrings (case-insensitive via raw match). +func containsAny(s string, subs []string) bool { + for _, sub := range subs { + if len(s) >= len(sub) { + for i := 0; i <= len(s)-len(sub); i++ { + match := true + for j := 0; j < len(sub); j++ { + sc := s[i+j] + tc := sub[j] + // Simple ASCII lowercase comparison. + if sc >= 'A' && sc <= 'Z' { + sc += 32 + } + if tc >= 'A' && tc <= 'Z' { + tc += 32 + } + if sc != tc { + match = false + break + } + } + if match { + return true + } + } + } + } + return false +} diff --git a/internal/ui/configtui/login_model.go b/internal/ui/configtui/login_model.go new file mode 100644 index 0000000..794acdf --- /dev/null +++ b/internal/ui/configtui/login_model.go @@ -0,0 +1,288 @@ +package configtui + +import ( + "context" + "fmt" + "math/rand" + "time" + + "seedfast/cli/internal/ui/tui/components" + + tea "github.com/charmbracelet/bubbletea" +) + +// loginState represents the current phase of the login TUI. +type loginState int + +const ( + loginStateInit loginState = iota + loginStatePolling + loginStateDone +) + +// AuthService is the minimal interface the login model requires from auth.Service. +type AuthService interface { + WhoAmI(ctx context.Context) (string, bool, error) + StartLogin(ctx context.Context) (string, string, int, error) + PollLogin(ctx context.Context, deviceID string) (string, bool, error) + WarmCache(ctx context.Context) error + GetUserData(ctx context.Context) (map[string]interface{}, error) +} + +// LoginResult holds the outcome of the login flow. +type LoginResult struct { + AlreadyLoggedIn bool + Account string + Greeting string + Err error + ErrType string // "grpc", "config", "timeout" +} + +// LoginModel is a Bubble Tea model for the seedfast login command. +type LoginModel struct { + ctx context.Context + svc AuthService + openBrowserFn func(string) + saveAuthFn func(string) error // saves auth state with account name + + state loginState + authURL string + deviceID string + pollEvery int + spinner components.SpinnerComponent + result LoginResult +} + +// NewLoginModel constructs a LoginModel with all external dependencies injected. +func NewLoginModel(ctx context.Context, svc AuthService, openBrowserFn func(string), saveAuthFn func(string) error) LoginModel { + return LoginModel{ + ctx: ctx, + svc: svc, + openBrowserFn: openBrowserFn, + saveAuthFn: saveAuthFn, + state: loginStateInit, + spinner: components.NewSpinner(theme, components.SpinnerStick, "Waiting for verification"), + } +} + +// Result returns the login outcome after the program exits. +func (m LoginModel) Result() LoginResult { + return m.result +} + +// --- tea.Msg types --- + +type checkAuthResultMsg struct { + account string + loggedIn bool + err error +} + +type startLoginResultMsg struct { + authURL string + deviceID string + pollEvery int + err error +} + +type pollResultMsg struct { + account string +} + +type pollNotReadyMsg struct{} + +type pollErrorMsg struct { + err error +} + +type contextTimeoutMsg struct{} + +type greetingGeneratedMsg struct { + greeting string +} + +// --- tea.Model interface --- + +func (m LoginModel) Init() tea.Cmd { + return m.checkAuth() +} + +func (m LoginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.state = loginStateDone + return m, tea.Quit + } + + case checkAuthResultMsg: + if msg.err != nil { + m.state = loginStateDone + m.result = LoginResult{Err: msg.err, ErrType: "grpc"} + return m, tea.Quit + } + if msg.loggedIn { + m.state = loginStateDone + m.result = LoginResult{AlreadyLoggedIn: true, Account: msg.account} + return m, tea.Quit + } + // Not logged in yet; start the device login flow. + return m, m.startLogin() + + case startLoginResultMsg: + if msg.err != nil { + m.state = loginStateDone + m.result = LoginResult{Err: msg.err, ErrType: "grpc"} + return m, tea.Quit + } + m.authURL = msg.authURL + m.deviceID = msg.deviceID + m.pollEvery = msg.pollEvery + if m.pollEvery <= 0 { + m.pollEvery = 3 + } + m.state = loginStatePolling + + // Open browser (non-blocking, best-effort). + if m.openBrowserFn != nil { + m.openBrowserFn(m.authURL) + } + + // Start spinner, first poll, and context-timeout watcher. + return m, tea.Batch( + m.spinner.Init(), + m.pollOnce(), + m.watchContextTimeout(), + ) + + case pollResultMsg: + // Save auth state first. + if m.saveAuthFn != nil { + if err := m.saveAuthFn(msg.account); err != nil { + m.state = loginStateDone + m.result = LoginResult{Err: err, ErrType: "config"} + return m, tea.Quit + } + } + // Login succeeded. Warm cache, then generate greeting. + _ = m.svc.WarmCache(m.ctx) + return m, m.generateGreeting() + + case pollNotReadyMsg: + // Schedule the next poll after pollEvery seconds. + return m, tea.Tick(time.Duration(m.pollEvery)*time.Second, func(time.Time) tea.Msg { + return m.pollOnceSync() + }) + + case pollErrorMsg: + m.state = loginStateDone + m.result = LoginResult{Err: msg.err, ErrType: "grpc"} + return m, tea.Quit + + case greetingGeneratedMsg: + m.state = loginStateDone + m.result = LoginResult{Greeting: msg.greeting} + return m, tea.Quit + + case contextTimeoutMsg: + m.state = loginStateDone + m.result = LoginResult{Err: fmt.Errorf("login timed out"), ErrType: "timeout"} + return m, tea.Quit + } + + // Forward remaining messages to the spinner when polling. + if m.state == loginStatePolling { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m LoginModel) View() string { + switch m.state { + case loginStatePolling: + return StyledURL(m.authURL) + m.spinner.View() + "\n" + default: + return "" + } +} + +// --- tea.Cmd helpers --- + +func (m LoginModel) checkAuth() tea.Cmd { + return func() tea.Msg { + account, ok, err := m.svc.WhoAmI(m.ctx) + return checkAuthResultMsg{account: account, loggedIn: ok, err: err} + } +} + +func (m LoginModel) startLogin() tea.Cmd { + return func() tea.Msg { + authURL, deviceID, pollEvery, err := m.svc.StartLogin(m.ctx) + return startLoginResultMsg{authURL: authURL, deviceID: deviceID, pollEvery: pollEvery, err: err} + } +} + +func (m LoginModel) pollOnce() tea.Cmd { + return func() tea.Msg { + return m.pollOnceSync() + } +} + +func (m LoginModel) pollOnceSync() tea.Msg { + account, ok, err := m.svc.PollLogin(m.ctx, m.deviceID) + if err != nil { + return pollErrorMsg{err: err} + } + if ok { + return pollResultMsg{account: account} + } + return pollNotReadyMsg{} +} + +func (m LoginModel) watchContextTimeout() tea.Cmd { + return func() tea.Msg { + <-m.ctx.Done() + return contextTimeoutMsg{} + } +} + +func (m LoginModel) generateGreeting() tea.Cmd { + return func() tea.Msg { + greeting := buildGreeting(m.ctx, m.svc) + return greetingGeneratedMsg{greeting: greeting} + } +} + +// buildGreeting constructs a random greeting using user data from the auth service. +func buildGreeting(ctx context.Context, svc AuthService) string { + greetings := []string{ + "🎉 Welcome back, %s!", + "✨ Great to see you, %s!", + "🚀 You're all set, %s!", + "👋 Hello %s! Ready to seed?", + "💫 Successfully authenticated as %s", + "🌟 Welcome aboard, %s!", + "⚡ Logged in as %s - let's go!", + "✅ Authentication complete! Hi %s!", + "🎯 You're in, %s!", + "🔓 Access granted! Welcome %s!", + } + + userData, err := svc.GetUserData(ctx) + if err == nil && userData != nil { + if email, ok := userData["email"].(string); ok && email != "" { + idx := rand.Intn(len(greetings)) + return fmt.Sprintf(greetings[idx], email) + } + if userID, ok := userData["user_id"].(string); ok && userID != "" { + idx := rand.Intn(len(greetings)) + return fmt.Sprintf(greetings[idx], userID) + } + } + + return "✅ Login successful!" +} diff --git a/internal/ui/configtui/styles.go b/internal/ui/configtui/styles.go new file mode 100644 index 0000000..b8a607d --- /dev/null +++ b/internal/ui/configtui/styles.go @@ -0,0 +1,40 @@ +package configtui + +import ( + "fmt" + + "seedfast/cli/internal/ui/tui" +) + +// theme is the shared theme instance for configtui models. +var theme = tui.DefaultTheme() + +// StyledSuccess returns a success-styled string (e.g. checkmarks, confirmations). +func StyledSuccess(msg string) string { + return theme.Success.Render(msg) +} + +// StyledError returns an error-styled string. +func StyledError(msg string) string { + return theme.ErrorText.Render(msg) +} + +// StyledDim returns a muted/dim-styled string. +func StyledDim(msg string) string { + return theme.Dim.Render(msg) +} + +// StyledBold returns a bold-styled string. +func StyledBold(msg string) string { + return theme.Bold.Render(msg) +} + +// StyledTitle returns a title-styled string. +func StyledTitle(msg string) string { + return theme.Title.Render(msg) +} + +// StyledURL formats a URL for display in the terminal. +func StyledURL(url string) string { + return fmt.Sprintf("Open this link to complete login:\n%s\n\n", url) +} From e7c4f6cdc731cff47f8c6bf384e4d1d4e85dec7e Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 15:41:07 +0100 Subject: [PATCH 07/38] refactor: rewrite login, connect, dbinfo to Bubble Tea Replace goroutine spinners and manual ANSI in login and connect with configtui Bubble Tea models. Replace pterm in dbinfo with dbheader + fmt. All error messages and output preserved. --- cmd/connect.go | 131 ++++++++++++++------------------------- cmd/dbinfo.go | 32 +++++----- cmd/login.go | 165 +++++++++---------------------------------------- 3 files changed, 92 insertions(+), 236 deletions(-) diff --git a/cmd/connect.go b/cmd/connect.go index 5d5823a..9fc813a 100644 --- a/cmd/connect.go +++ b/cmd/connect.go @@ -4,20 +4,18 @@ package cmd import ( - "bufio" "context" - "errors" "fmt" "os" - "strings" "time" "seedfast/cli/internal/auth" "seedfast/cli/internal/dsn" friendlyerrors "seedfast/cli/internal/errors" "seedfast/cli/internal/keychain" - "seedfast/cli/internal/terminal" + "seedfast/cli/internal/ui/configtui" + tea "github.com/charmbracelet/bubbletea" "github.com/jackc/pgx/v5/pgxpool" "github.com/spf13/cobra" ) @@ -34,7 +32,6 @@ var connectCmd = &cobra.Command{ Short: "Connect to your PostgreSQL database", Long: `Connect to your PostgreSQL database and save the connection for seeding.`, RunE: func(cmd *cobra.Command, args []string) error { - // Enable verbose mode for all modules if --verbose is set if verboseConnect { os.Setenv("SEEDFAST_VERBOSE", "1") } @@ -45,104 +42,68 @@ var connectCmd = &cobra.Command{ fmt.Println(" Please run 'seedfast login' to authenticate.") return nil } - ctx := cmd.Context() - reader := bufio.NewReader(os.Stdin) - promptText := "Enter Postgres DSN (e.g., postgres://user:pass@host:5432/db?sslmode=disable): " - fmt.Print(promptText) - rawDSN, _ := reader.ReadString('\n') - rawDSN = strings.TrimSpace(rawDSN) - // Clear the prompt and user input from terminal - terminal.ClearPreviousLines(len(promptText) + len(rawDSN)) + ctx := cmd.Context() - if rawDSN == "" { - return errors.New("DSN is required") + parseFn := func(raw string) (string, error) { + return dsn.Parse(raw) } - // Parse and normalize the DSN to handle special characters - normalizedDSN, err := dsn.Parse(rawDSN) - if err != nil { - if parseErr, ok := err.(*dsn.ParseError); ok { - fmt.Printf("❌ Invalid DSN format: %s\n", parseErr.Reason) - fmt.Println(" Please check your connection string and try again.") - return nil // Return nil to avoid error-style output from cobra + pingFn := func(ctx context.Context, connDSN string) error { + ctxPing, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + pool, err := pgxpool.New(ctxPing, connDSN) + if err != nil { + return err } - fmt.Println("❌ Invalid database connection format") - fmt.Println(" Expected format: postgres://user:password@host:5432/database?sslmode=disable") - fmt.Println(" Please try again with a valid PostgreSQL connection string.") - return nil // Return nil to avoid error-style output from cobra + defer pool.Close() + return pool.Ping(ctxPing) } - // Start lightweight inline spinner (Windows-friendly) - startTime := time.Now() - done := make(chan struct{}) - spinnerStopped := make(chan struct{}) - stopped := false - stopSpinner := func() { - if !stopped { - close(done) - <-spinnerStopped - stopped = true + saveFn := func(normalizedDSN string) error { + km, err := keychain.GetManager() + if err != nil { + return err } + return km.SaveDBDSN(normalizedDSN) } - go func() { - defer close(spinnerStopped) - frames := []string{"-", "\\", "|", "/"} - i := 0 - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-done: - // Clear the line to remove any spinner remnants - fmt.Print("\r") - fmt.Print(strings.Repeat(" ", 60)) - fmt.Print("\r") - return - case <-ticker.C: - frame := frames[i%len(frames)] - i++ - fmt.Printf("\r%s verifying connection", frame) - } - } - }() - // Verify connection - ctxPing, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - pool, err := pgxpool.New(ctxPing, normalizedDSN) + model := configtui.NewConnectModel(ctx, parseFn, pingFn, saveFn) + p := tea.NewProgram(model) + finalModel, err := p.Run() if err != nil { - stopSpinner() - return friendlyerrors.WrapConnectionError(err) - } - defer pool.Close() - if err := pool.Ping(ctxPing); err != nil { - stopSpinner() - return friendlyerrors.WrapConnectionError(err) + return fmt.Errorf("connect UI error: %w", err) } - // Ensure spinner runs for at least 2 seconds for better UX - if elapsed := time.Since(startTime); elapsed < 2*time.Second { - time.Sleep(2*time.Second - elapsed) - } + result := finalModel.(configtui.ConnectModel).Result() - // Stop spinner and overwrite with success message - stopSpinner() + if result.Cancelled { + return nil + } - // Save normalized DSN securely in the OS keychain - km, err := keychain.GetManager() - if err != nil { - fmt.Println("❌ Secure storage is not available on this system.") - fmt.Println(" Keychain is only supported on macOS and Windows.") - fmt.Println(" Connection verified but not saved.") - return err + if result.Err != nil { + switch result.ErrType { + case "parse": + // Parse errors: print message and return nil (same as current behavior) + fmt.Printf("❌ %s\n", result.Err.Error()) + return nil + case "connection": + return friendlyerrors.WrapConnectionError(result.Err) + case "keychain_unavailable": + fmt.Println("❌ Secure storage is not available on this system.") + fmt.Println(" Keychain is only supported on macOS and Windows.") + fmt.Println(" Connection verified but not saved.") + return result.Err + default: // "config" + return friendlyerrors.WrapConfigError(result.Err) + } } - if err := km.SaveDBDSN(normalizedDSN); err != nil { - return friendlyerrors.WrapConfigError(err) + + if result.Success { + fmt.Println("✅ Database connection verified and saved!") + fmt.Println(" You're ready to run 'seedfast seed'") } - fmt.Println("✅ Database connection verified and saved!") - fmt.Println(" You're ready to run 'seedfast seed'") return nil }, } diff --git a/cmd/dbinfo.go b/cmd/dbinfo.go index 7ff117f..2b0e1af 100644 --- a/cmd/dbinfo.go +++ b/cmd/dbinfo.go @@ -4,6 +4,7 @@ package cmd import ( + "fmt" "os" "strings" @@ -11,8 +12,8 @@ import ( "seedfast/cli/internal/dsn" "seedfast/cli/internal/keychain" "seedfast/cli/internal/logging" + "seedfast/cli/internal/ui/dbheader" - "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -26,8 +27,8 @@ var dbinfoCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { st, err := auth.Load() if err != nil || !st.LoggedIn { - pterm.Println("❌ You need to be logged in to view database connection") - pterm.Println(" Please run 'seedfast login' to authenticate.") + fmt.Println("❌ You need to be logged in to view database connection") + fmt.Println(" Please run 'seedfast login' to authenticate.") return nil } @@ -43,21 +44,21 @@ var dbinfoCmd = &cobra.Command{ if strings.TrimSpace(rawDSN) == "" { km, err := keychain.GetManager() if err != nil { - pterm.Println("❌ Secure storage is not available on this system") - pterm.Println(" Keychain is only supported on macOS and Windows") + fmt.Println("❌ Secure storage is not available on this system") + fmt.Println(" Keychain is only supported on macOS and Windows") return err } rawDSN, err = km.LoadDBDSN() if err != nil { - pterm.Println("⚠️ No database connection configured") - pterm.Println(" Please run 'seedfast connect' to set up your database.") + fmt.Println("⚠️ No database connection configured") + fmt.Println(" Please run 'seedfast connect' to set up your database.") return nil } if strings.TrimSpace(rawDSN) == "" { - pterm.Println("⚠️ No database connection configured") - pterm.Println(" Please run 'seedfast connect' to set up your database.") + fmt.Println("⚠️ No database connection configured") + fmt.Println(" Please run 'seedfast connect' to set up your database.") return nil } } @@ -65,11 +66,11 @@ var dbinfoCmd = &cobra.Command{ // Parse and normalize the DSN normalizedDSN, err := dsn.Parse(rawDSN) if err != nil { - pterm.Println("❌ Invalid database connection string.") + fmt.Println("❌ Invalid database connection string.") if parseErr, ok := err.(*dsn.ParseError); ok { - pterm.Println(" " + parseErr.Error()) + fmt.Println(" " + parseErr.Error()) } - pterm.Println(" Please run 'seedfast connect' to reconfigure your database.") + fmt.Println(" Please run 'seedfast connect' to reconfigure your database.") return err } @@ -78,10 +79,9 @@ var dbinfoCmd = &cobra.Command{ dbName := dsn.ExtractDatabaseName(normalizedDSN) // Display database connection info (same format as seed command) - pterm.Println() - pterm.Println(pterm.NewStyle(pterm.FgLightCyan).Sprint("→ Database: ") + pterm.NewStyle(pterm.FgCyan, pterm.Bold).Sprint(dbName)) - pterm.Println(pterm.NewStyle(pterm.FgLightCyan).Sprint("→ Connection: ") + pterm.NewStyle(pterm.FgLightBlue).Sprint(maskedDSN)) - pterm.Println() + fmt.Println() + fmt.Print(dbheader.Render(dbName, maskedDSN)) + fmt.Println() return nil }, diff --git a/cmd/login.go b/cmd/login.go index 07956ce..203bfdf 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -6,18 +6,18 @@ package cmd import ( "context" "fmt" - "math/rand" "os" "os/exec" "runtime" - "sync" "time" "seedfast/cli/internal/auth" friendlyerrors "seedfast/cli/internal/errors" "seedfast/cli/internal/keychain" "seedfast/cli/internal/manifest" + "seedfast/cli/internal/ui/configtui" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) @@ -36,7 +36,6 @@ var loginCmd = &cobra.Command{ Long: `Sign in to your Seedfast account using your browser.`, RunE: func(cmd *cobra.Command, args []string) error { - // Enable verbose mode for all modules if --verbose is set if verboseLogin { os.Setenv("SEEDFAST_VERBOSE", "1") } @@ -52,80 +51,31 @@ var loginCmd = &cobra.Command{ } svc := auth.NewService(m.HTTPBaseURL(), m.HTTP) - // If already logged in with a valid token, short-circuit - if account, ok, _ := svc.WhoAmI(ctx); ok { - fmt.Printf("Already logged in as %s\n", account) - return nil + + // Auth save function + saveAuthFn := func(account string) error { + return auth.Save(auth.State{LoggedIn: true, Account: account}) } - authURL, deviceID, pollEvery, err := svc.StartLogin(ctx) + + model := configtui.NewLoginModel(ctx, svc, openBrowser, saveAuthFn) + p := tea.NewProgram(model) + finalModel, err := p.Run() if err != nil { - // Use WrapGrpcError for network errors instead of WrapAuthError - // because this is a connection issue, not an authentication issue - return friendlyerrors.WrapGrpcError(err) + return fmt.Errorf("login UI error: %w", err) } - fmt.Println("Open this link to complete login:") - fmt.Printf("%s\n\n", authURL) - - // Try to open the user's default browser automatically while still printing the link - openBrowser(authURL) - - // Spinner: stick-style to the left of the message; remove when done - spinnerText := "Waiting for verification" - frames := []string{"|", "/", "-", "\\"} - stopSpinner := make(chan struct{}) - var spinnerWG sync.WaitGroup - spinnerWG.Add(1) - go func() { - defer spinnerWG.Done() - i := 0 - for { - select { - case <-stopSpinner: - line := fmt.Sprintf("%s %s", frames[i%len(frames)], spinnerText) - // Clear the spinner line completely, then return - fmt.Printf("\r%*s\r", len(line), "") - return - case <-time.After(120 * time.Millisecond): - line := fmt.Sprintf("%s %s", frames[i%len(frames)], spinnerText) - fmt.Printf("\r%s", line) - i++ - } - } - }() - if pollEvery <= 0 { - pollEvery = 3 - } - ticker := time.NewTicker(time.Duration(pollEvery) * time.Second) - defer ticker.Stop() - - // Immediate attempt without noisy per-attempt logging - if account, ok, err := svc.PollLogin(ctx, deviceID); err == nil && ok { - if err := auth.Save(auth.State{LoggedIn: true, Account: account}); err != nil { - close(stopSpinner) - spinnerWG.Wait() - return friendlyerrors.WrapConfigError(err) - } + result := finalModel.(configtui.LoginModel).Result() - // Warm the cache for offline whoami support - _ = svc.WarmCache(ctx) - close(stopSpinner) - spinnerWG.Wait() - // Show friendly greeting with email - showLoginGreeting(ctx, svc) + // Handle result + if result.AlreadyLoggedIn { + fmt.Printf("Already logged in as %s\n", result.Account) return nil - } else if err != nil { - // If there's an error on first attempt, stop and report it - close(stopSpinner) - spinnerWG.Wait() - // Use WrapGrpcError for network errors instead of WrapAuthError - return friendlyerrors.WrapGrpcError(err) } - for { - select { - case <-ctx.Done(): - close(stopSpinner) - spinnerWG.Wait() + + if result.Err != nil { + switch result.ErrType { + case "timeout": + // Clear keychain and auth state on timeout if km, err := keychain.GetManager(); err == nil { _ = km.ClearAuth() } @@ -134,41 +84,24 @@ var loginCmd = &cobra.Command{ UserMessage: "❌ Login timed out", NextSteps: "Please run 'seedfast login' to try again", } - case <-ticker.C: - account, ok, err := svc.PollLogin(ctx, deviceID) - if err != nil { - close(stopSpinner) - spinnerWG.Wait() - // Use WrapGrpcError for network errors instead of WrapAuthError - return friendlyerrors.WrapGrpcError(err) - } - if !ok { - continue - } - - if err := auth.Save(auth.State{LoggedIn: true, Account: account}); err != nil { - close(stopSpinner) - spinnerWG.Wait() - return friendlyerrors.WrapConfigError(err) - } - - // Warm the cache for offline whoami support - _ = svc.WarmCache(ctx) - close(stopSpinner) - spinnerWG.Wait() - // Show friendly greeting with email - showLoginGreeting(ctx, svc) - return nil + case "config": + return friendlyerrors.WrapConfigError(result.Err) + default: // "grpc" + return friendlyerrors.WrapGrpcError(result.Err) } } + + if result.Greeting != "" { + fmt.Println(result.Greeting) + } + + return nil }, } func init() { rootCmd.AddCommand(loginCmd) loginCmd.Flags().BoolVarP(&verboseLogin, "verbose", "v", false, "Enable verbose debug output") - // Seed random number generator for greeting selection - rand.Seed(time.Now().UnixNano()) } // openBrowser attempts to open the provided URL in the user's default browser. @@ -190,41 +123,3 @@ func openBrowser(url string) { } _ = cmd.Start() } - -// showLoginGreeting displays a friendly greeting message with the user's email after login -func showLoginGreeting(ctx context.Context, svc *auth.Service) { - // Try to get user data with email - userData, err := svc.GetUserData(ctx) - if err == nil && userData != nil { - if email, ok := userData["email"].(string); ok && email != "" { - fmt.Println(getRandomLoginGreeting(email)) - return - } - // Fallback to user_id - if userID, ok := userData["user_id"].(string); ok && userID != "" { - fmt.Println(getRandomLoginGreeting(userID)) - return - } - } - // Generic success message if we can't get user data - fmt.Println("✅ Login successful!") -} - -// getRandomLoginGreeting returns a random greeting phrase with the user's identifier -func getRandomLoginGreeting(identifier string) string { - greetings := []string{ - "🎉 Welcome back, %s!", - "✨ Great to see you, %s!", - "🚀 You're all set, %s!", - "👋 Hello %s! Ready to seed?", - "💫 Successfully authenticated as %s", - "🌟 Welcome aboard, %s!", - "⚡ Logged in as %s - let's go!", - "✅ Authentication complete! Hi %s!", - "🎯 You're in, %s!", - "🔓 Access granted! Welcome %s!", - } - - idx := rand.Intn(len(greetings)) - return fmt.Sprintf(greetings[idx], identifier) -} From 273b7677f0880eacabde1cd53f3863f578f04faa Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 16:07:06 +0100 Subject: [PATCH 08/38] fix: display subscription limits on every scope arrival --- internal/orchestration/seed_state.go | 2 +- internal/orchestration/subscription_limit_handler.go | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/orchestration/seed_state.go b/internal/orchestration/seed_state.go index 1405f57..b559ffb 100644 --- a/internal/orchestration/seed_state.go +++ b/internal/orchestration/seed_state.go @@ -83,7 +83,7 @@ type SeedState struct { // Subscription limit information SubscriptionInfo *SubscriptionLimitInfo - SubscriptionInfoShown bool // true after first display, prevents duplicate on replan + SubscriptionInfoShown bool // true after first display (tracked for state, no longer gates display) ScopeTooLargeMode bool // true when SCOPE_TOO_LARGE requires replan // Workflow state diff --git a/internal/orchestration/subscription_limit_handler.go b/internal/orchestration/subscription_limit_handler.go index 3c8f936..265c075 100644 --- a/internal/orchestration/subscription_limit_handler.go +++ b/internal/orchestration/subscription_limit_handler.go @@ -147,11 +147,9 @@ func (h *subscriptionLimitHandler) handleSuccess(info *SubscriptionLimitInfo) er // Reset replan mode (if was set from previous SCOPE_TOO_LARGE) h.state.ScopeTooLargeMode = false - // Display limits only on first occurrence (skip duplicate on replan) - if !h.state.SubscriptionInfoShown { - h.display.ShowLimits(info) - h.state.SubscriptionInfoShown = true - } + // Display limits every time server sends subscription_limit_info + h.display.ShowLimits(info) + h.state.SubscriptionInfoShown = true return nil } From 1916aeb80668329fbeb0efa7f916c6ed61937d6a Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 16:29:26 +0100 Subject: [PATCH 09/38] fix: exit 0 for friendly errors in `seed` command --- cmd/seed.go | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/cmd/seed.go b/cmd/seed.go index 513f02c..ea5b359 100644 --- a/cmd/seed.go +++ b/cmd/seed.go @@ -76,16 +76,15 @@ func seedRunNew(cmd *cobra.Command, args []string) error { // Handle authentication errors silently (message already printed to stdout) if err == orchestration.ErrNotAuthenticated { - os.Exit(config.ExitCodeAuthError) + os.Exit(0) } // Handle FriendlyError as normal text (not red error output) if friendlyErr, ok := err.(*friendlyerrors.FriendlyError); ok { - fmt.Println(friendlyErr.Error()) - if friendlyErr.ExitCode != nil { - os.Exit(*friendlyErr.ExitCode) + if friendlyErr.UserMessage != "" { + fmt.Println(friendlyErr.Error()) } - os.Exit(getExitCode(err)) + os.Exit(0) } return err @@ -143,22 +142,6 @@ func resolveOutputMode() string { return "interactive" } -// getExitCode maps errors to appropriate exit codes -// Uses enhanced error detection from the errors package to distinguish: -// - Auth errors (2): Authentication failure, invalid/expired API keys -// - DB errors (3): Database connection or query failures -// - Quota errors (4): Subscription limits reached -// - Cancellation (5): User-initiated cancellation -// - Generic error (1): Other failures -func getExitCode(err error) int { - if err == nil { - return config.ExitCodeSuccess - } - - // Use enhanced error mapping from errors package - return friendlyerrors.GetExitCode(err) -} - func init() { rootCmd.AddCommand(seedCmd) From dc161d23ce7c7c18c60386697d0f7983d47bada4 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:27:40 +0100 Subject: [PATCH 10/38] refactor: delete dead `internal/secure` package Legacy compatibility layer for keychain operations that was never imported anywhere in the codebase. All callers use `internal/keychain` directly. --- internal/secure/secure.go | 98 --------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 internal/secure/secure.go diff --git a/internal/secure/secure.go b/internal/secure/secure.go deleted file mode 100644 index f5a6292..0000000 --- a/internal/secure/secure.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2025 Seedfast -// Licensed under the MIT License. See LICENSE file in the project root for details. - -// Package secure provides a legacy compatibility layer for keychain operations. -// This package now uses the centralized keychain manager from internal/keychain -// and exists primarily for backward compatibility. For new code, use internal/keychain directly. -// -// The package provides wrapper functions that delegate to the centralized keychain manager, -// ensuring existing code continues to work while encouraging migration to the new interface. -package secure - -import ( - "seedfast/cli/internal/keychain" -) - -// SaveAuthTokens stores access and refresh tokens in the OS keychain. -// This is a legacy wrapper that doesn't store expiration times. -// For new code, use keychain.Manager.SaveAuthTokens directly with expiration times. -func SaveAuthTokens(accessToken string, refreshToken string) error { - manager, err := keychain.GetManager() - if err != nil { - return err - } - // Pass empty strings for expiration times (legacy compatibility) - return manager.SaveAuthTokens(accessToken, refreshToken, "", "") -} - -// LoadAccessToken retrieves the access token from the keychain. -func LoadAccessToken() (string, error) { - manager, err := keychain.GetManager() - if err != nil { - return "", err - } - return manager.LoadAccessToken() -} - -// ClearAuth removes all auth-related secrets from the keychain. -func ClearAuth() error { - manager, err := keychain.GetManager() - if err != nil { - return err - } - return manager.ClearAuth() -} - -// SaveAuthStateBytes stores serialized auth state in the keychain. -func SaveAuthStateBytes(data []byte) error { - manager, err := keychain.GetManager() - if err != nil { - return err - } - return manager.SaveAuthState(data) -} - -// LoadAuthStateBytes retrieves serialized auth state from the keychain. -func LoadAuthStateBytes() ([]byte, error) { - manager, err := keychain.GetManager() - if err != nil { - return nil, err - } - return manager.LoadAuthState() -} - -// ClearAuthState removes the stored auth state from the keychain. -func ClearAuthState() error { - manager, err := keychain.GetManager() - if err != nil { - return err - } - return manager.ClearAuthState() -} - -// SaveDBDSN stores the database DSN in the keychain. -func SaveDBDSN(dsn string) error { - manager, err := keychain.GetManager() - if err != nil { - return err - } - return manager.SaveDBDSN(dsn) -} - -// LoadDBDSN retrieves the database DSN from the keychain. -func LoadDBDSN() (string, error) { - manager, err := keychain.GetManager() - if err != nil { - return "", err - } - return manager.LoadDBDSN() -} - -// ClearDB removes DB-related secrets from the keychain. -func ClearDB() error { - manager, err := keychain.GetManager() - if err != nil { - return err - } - return manager.ClearDB() -} From 0cf5c7e839a0fa7cc2cb7cee777125d6a996f492 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:27:51 +0100 Subject: [PATCH 11/38] refactor: remove unused methods and fields in orchestration Remove `DSNResolver.GetDatabaseName()` (orchestrator uses `dsn.ExtractDatabaseName()` directly), `EventHandler.logVerboseHumanPrompt()`, `AuthValidator.IsAuthenticated()` (all callers use `Validate()`), `DatabaseConnector.ConnectWithTimeout()` (only `Connect()` is used), and unused `SeedState.Area` and `SeedState.Frames` fields. --- internal/orchestration/auth_validator.go | 7 ----- internal/orchestration/database_connector.go | 32 -------------------- internal/orchestration/dsn_resolver.go | 23 -------------- internal/orchestration/event_handler.go | 13 -------- internal/orchestration/seed_state.go | 4 --- 5 files changed, 79 deletions(-) diff --git a/internal/orchestration/auth_validator.go b/internal/orchestration/auth_validator.go index ffeacb3..594a10e 100644 --- a/internal/orchestration/auth_validator.go +++ b/internal/orchestration/auth_validator.go @@ -100,10 +100,3 @@ func (av *AuthValidator) Validate(ctx context.Context) error { return nil } - -// IsAuthenticated returns true if the user is currently authenticated. -// This is a convenience method that doesn't return an error. -func (av *AuthValidator) IsAuthenticated(ctx context.Context) bool { - state, err := auth.Load() - return err == nil && state.LoggedIn -} diff --git a/internal/orchestration/database_connector.go b/internal/orchestration/database_connector.go index a007f91..41c0e3d 100644 --- a/internal/orchestration/database_connector.go +++ b/internal/orchestration/database_connector.go @@ -54,35 +54,3 @@ func (dc *DatabaseConnector) Connect(ctx context.Context, dsn string) (*pgxpool. return pool, nil } - -// ConnectWithTimeout establishes a connection pool with a custom timeout. -// This is useful for commands that need different timeout behavior (e.g., connect command). -func (dc *DatabaseConnector) ConnectWithTimeout(ctx context.Context, dsn string, timeout context.Context) (*pgxpool.Pool, error) { - // Parse DSN to get pool config - poolConfig, err := pgxpool.ParseConfig(dsn) - if err != nil { - return nil, err - } - - // Configure connection pool settings (same as Connect) - poolConfig.MaxConns = 10 - poolConfig.MinConns = 2 - poolConfig.MaxConnLifetime = time.Hour - poolConfig.MaxConnIdleTime = 30 * time.Minute - poolConfig.HealthCheckPeriod = time.Minute - poolConfig.ConnConfig.ConnectTimeout = 10 * time.Second - - // Create connection pool with custom timeout context - pool, err := pgxpool.NewWithConfig(timeout, poolConfig) - if err != nil { - return nil, err - } - - // Verify connection with ping using the timeout context - if err := pool.Ping(timeout); err != nil { - pool.Close() - return nil, err - } - - return pool, nil -} diff --git a/internal/orchestration/dsn_resolver.go b/internal/orchestration/dsn_resolver.go index 790dee7..36276a4 100644 --- a/internal/orchestration/dsn_resolver.go +++ b/internal/orchestration/dsn_resolver.go @@ -70,26 +70,3 @@ func (dr *DSNResolver) getRawDSN() (string, error) { NextSteps: "Please run 'seedfast connect' to set up your database connection", } } - -// GetDatabaseName extracts the database name from a DSN string. -// Used for display purposes in the UI. -func (dr *DSNResolver) GetDatabaseName(dsnStr string) string { - parsed, err := dsn.Parse(dsnStr) - if err != nil { - return "unknown" - } - - // Extract database name from parsed DSN (simplified version) - // Full implementation would parse the connection string properly - parts := strings.Split(parsed, "/") - if len(parts) > 0 { - dbPart := parts[len(parts)-1] - // Remove query parameters - if idx := strings.Index(dbPart, "?"); idx > 0 { - dbPart = dbPart[:idx] - } - return dbPart - } - - return "unknown" -} diff --git a/internal/orchestration/event_handler.go b/internal/orchestration/event_handler.go index 8178e1f..1491663 100644 --- a/internal/orchestration/event_handler.go +++ b/internal/orchestration/event_handler.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "strings" "sync/atomic" "time" @@ -1152,15 +1151,3 @@ func (eh *EventHandler) getProgressCounts() (completed, total int) { } return } - -// logVerboseHumanPrompt prints the final human prompt sent to backend when --verbose is enabled. -func (eh *EventHandler) logVerboseHumanPrompt(prompt string) { - if os.Getenv("SEEDFAST_VERBOSE") != "1" { - return - } - if prompt == "" { - fmt.Printf("[DEBUG] human prompt: (empty — approved as-is)\n") - return - } - fmt.Printf("[DEBUG] human prompt: %s\n", prompt) -} diff --git a/internal/orchestration/seed_state.go b/internal/orchestration/seed_state.go index b559ffb..fde4a53 100644 --- a/internal/orchestration/seed_state.go +++ b/internal/orchestration/seed_state.go @@ -46,11 +46,7 @@ type SeedState struct { ScopeShown bool DoneTables []string - // UI state (area printer for table progress) - Area *pterm.AreaPrinter - // Spinner animation - Frames []string FrameIdx int // Table state tracking From c674badb5ea4a2dd7946acacd5e35f9ae4e289b6 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:27:58 +0100 Subject: [PATCH 12/38] refactor: remove unused exports in auth and keychain Remove `auth.IsLoggedIn`, `auth.SetLoggedIn`, `auth.SetLoggedOut` (never called from anywhere) and `keychain.MustGetManager()` (dead code with dangerous panic pattern). --- internal/auth/state.go | 23 ----------------------- internal/keychain/manager.go | 10 ---------- 2 files changed, 33 deletions(-) diff --git a/internal/auth/state.go b/internal/auth/state.go index e037e9d..5973458 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -9,26 +9,3 @@ // persistence operations, supporting device-based authentication flows with // automatic token refresh and secure storage of credentials. package auth - -import ( - "context" -) - -// IsLoggedIn reports whether the user is considered logged in. -func IsLoggedIn(ctx context.Context) (bool, error) { - st, err := Load() - if err != nil { - return false, err - } - return st.LoggedIn, nil -} - -// SetLoggedIn marks the user as logged in by writing state to disk. -func SetLoggedIn(ctx context.Context, account string) error { - return Save(State{LoggedIn: true, Account: account}) -} - -// SetLoggedOut clears login state. -func SetLoggedOut(ctx context.Context) error { - return Clear() -} diff --git a/internal/keychain/manager.go b/internal/keychain/manager.go index a6c3c10..36390db 100644 --- a/internal/keychain/manager.go +++ b/internal/keychain/manager.go @@ -99,16 +99,6 @@ func GetManager() (*Manager, error) { return globalManager, nil } -// MustGetManager returns the global keychain manager instance. -// Panics if initialization fails. Use only when you're sure initialization will succeed. -func MustGetManager() *Manager { - manager, err := GetManager() - if err != nil { - panic(err) - } - return manager -} - // openRing opens the OS keyring using native platform backends. // For macOS: uses Keychain, with 'pass' as fallback // For Windows: uses Credential Manager From fb0a52c036aba108caeada9afbf6ef639f0f34d2 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:31:46 +0100 Subject: [PATCH 13/38] refactor: deduplicate API calls in seed workflow Single `auth.Service` instance shared across the orchestrator instead of 4 separate instantiations. `Validate()` now returns the access token directly, eliminating `getAccessToken()` and the redundant `WhoAmI` call. Saves 1-2 HTTP round-trips per run. --- internal/orchestration/auth_validator.go | 30 +++++---- internal/orchestration/seed_orchestrator.go | 69 +++------------------ 2 files changed, 21 insertions(+), 78 deletions(-) diff --git a/internal/orchestration/auth_validator.go b/internal/orchestration/auth_validator.go index ffeacb3..a1d59f0 100644 --- a/internal/orchestration/auth_validator.go +++ b/internal/orchestration/auth_validator.go @@ -8,20 +8,19 @@ import ( "os" "seedfast/cli/internal/auth" - "seedfast/cli/internal/manifest" "seedfast/cli/internal/ui" ) // AuthValidator handles authentication validation for seeding operations. // It checks if the user is logged in and has valid credentials. type AuthValidator struct { - manifestProvider *manifest.Manifest + authService *auth.Service } -// NewAuthValidator creates a new AuthValidator. -func NewAuthValidator(m *manifest.Manifest) *AuthValidator { +// NewAuthValidator creates a new AuthValidator with an injected auth.Service. +func NewAuthValidator(svc *auth.Service) *AuthValidator { return &AuthValidator{ - manifestProvider: m, + authService: svc, } } @@ -54,22 +53,22 @@ func outputAuthError(message, hint, errorCode string) { // Validate checks if the user is authenticated and has valid credentials. // Priority: 1) API key from SEEDFAST_API_KEY env var, 2) OAuth token from keychain +// Returns the access token (JWT for API key, OAuth token for interactive) on success. // Prints user-friendly message and returns sentinel error to signal authentication failure. -func (av *AuthValidator) Validate(ctx context.Context) error { +func (av *AuthValidator) Validate(ctx context.Context) (string, error) { // Priority #1: Check for API key in environment if apiKey, ok := auth.GetAPIKeyFromEnv(); ok { - svc := auth.NewService(av.manifestProvider.HTTPBaseURL(), av.manifestProvider.HTTP) - if err := svc.VerifyAPIKey(ctx, apiKey); err != nil { + jwt, _, err := av.authService.GetJWTFromAPIKey(ctx, apiKey) + if err != nil { // API key is invalid or expired outputAuthError( "Invalid or expired API key", "Please check your SEEDFAST_API_KEY environment variable", ErrorCodeInvalidAPIKey, ) - return ErrNotAuthenticated + return "", ErrNotAuthenticated } - // API key is valid - we'll get JWT later in getAccessToken() - return nil + return jwt, nil } // Priority #2: Fall back to OAuth token from keychain @@ -82,12 +81,11 @@ func (av *AuthValidator) Validate(ctx context.Context) error { ErrorCodeNotLoggedIn, ) // Return sentinel error so caller can detect this case - return ErrNotAuthenticated + return "", ErrNotAuthenticated } // Proactively validate and refresh token if needed - svc := auth.NewService(av.manifestProvider.HTTPBaseURL(), av.manifestProvider.HTTP) - _, err = svc.GetValidAccessToken(ctx) + token, err := av.authService.GetValidAccessToken(ctx) if err != nil { // Token refresh failed - session expired outputAuthError( @@ -95,10 +93,10 @@ func (av *AuthValidator) Validate(ctx context.Context) error { "Please run 'seedfast login' to sign in again", ErrorCodeSessionExpired, ) - return ErrNotAuthenticated + return "", ErrNotAuthenticated } - return nil + return token, nil } // IsAuthenticated returns true if the user is currently authenticated. diff --git a/internal/orchestration/seed_orchestrator.go b/internal/orchestration/seed_orchestrator.go index be07e5c..99b6fbf 100644 --- a/internal/orchestration/seed_orchestrator.go +++ b/internal/orchestration/seed_orchestrator.go @@ -10,7 +10,6 @@ import ( "seedfast/cli/internal/config" "seedfast/cli/internal/dsn" friendlyerrors "seedfast/cli/internal/errors" - "seedfast/cli/internal/keychain" "seedfast/cli/internal/logging" "seedfast/cli/internal/manifest" "seedfast/cli/internal/seeding" @@ -58,11 +57,12 @@ func NewSeedOrchestrator( m *manifest.Manifest, br bridge.Bridge, ) *SeedOrchestrator { + authSvc := auth.NewService(m.HTTPBaseURL(), m.HTTP) return &SeedOrchestrator{ config: cfg, manifestProvider: m, bridge: br, - authValidator: NewAuthValidator(m), + authValidator: NewAuthValidator(authSvc), dsnResolver: NewDSNResolver(), dbConnector: NewDatabaseConnector(cfg), signalHandler: NewSignalHandler(), @@ -184,8 +184,9 @@ func (o *SeedOrchestrator) Run(ctx context.Context, startTime time.Time) error { // // The caller is responsible for closing the returned pool and bridge. func (o *SeedOrchestrator) prepareSession(ctx context.Context) (*sessionContext, error) { - // Step 1: Validate authentication - if err := o.authValidator.Validate(ctx); err != nil { + // Step 1: Validate authentication and obtain access token + token, err := o.authValidator.Validate(ctx) + if err != nil { return nil, err } @@ -199,33 +200,14 @@ func (o *SeedOrchestrator) prepareSession(ctx context.Context) (*sessionContext, dbName := dsn.ExtractDatabaseName(normalizedDSN) maskedDSN := logging.Mask(normalizedDSN) - // Step 4: Get access token (from API key or keychain) - token, err := o.getAccessToken() - if err != nil { - return nil, err - } - - // Step 5: Validate token with backend (optional - skip if using API key) - // API keys return ephemeral tokens that are always valid within their TTL - // Only validate if token came from keychain (could be expired) - if _, isAPIKey := auth.GetAPIKeyFromEnv(); !isAPIKey { - svc := auth.NewService(o.manifestProvider.HTTPBaseURL(), o.manifestProvider.HTTP) - if _, ok, _ := svc.WhoAmI(ctx); !ok { - return nil, &friendlyerrors.FriendlyError{ - UserMessage: "❌ Your session has expired", - NextSteps: "Please run 'seedfast login' to sign in again", - } - } - } - - // Step 5b: Connect to gRPC bridge + // Step 4: Connect to gRPC bridge addr := o.manifestProvider.GRPCAddress() bridgeConnector := NewBridgeConnector(o.bridge) if err := bridgeConnector.ConnectAndInit(ctx, addr, token, dbName, normalizedDSN, o.config.Scope); err != nil { return nil, err } - // Step 6: Connect to database pool + // Step 5: Connect to database pool pool, err := o.dbConnector.Connect(ctx, normalizedDSN) if err != nil { // Close the bridge since we opened it but the pool failed. @@ -244,43 +226,6 @@ func (o *SeedOrchestrator) prepareSession(ctx context.Context) (*sessionContext, }, nil } -// getAccessToken retrieves the access token (JWT for API key, OAuth token for interactive). -// Priority: 1) API key from environment (exchange for JWT), 2) OAuth token from keychain -// Returns an error if token is not found or retrieval fails. -func (o *SeedOrchestrator) getAccessToken() (string, error) { - // Priority #1: Check for API key in environment and exchange for JWT - if apiKey, ok := auth.GetAPIKeyFromEnv(); ok { - svc := auth.NewService(o.manifestProvider.HTTPBaseURL(), o.manifestProvider.HTTP) - jwt, _, err := svc.GetJWTFromAPIKey(context.Background(), apiKey) - if err != nil { - return "", &friendlyerrors.FriendlyError{ - UserMessage: "❌ Failed to exchange API key for session token", - NextSteps: "Please check your SEEDFAST_API_KEY environment variable", - } - } - return jwt, nil - } - - // Priority #2: Get OAuth token from keychain - km, err := keychain.GetManager() - if err != nil { - return "", &friendlyerrors.FriendlyError{ - UserMessage: "❌ Not logged in", - NextSteps: "Please run 'seedfast login' to authenticate", - } - } - - token, err := km.LoadAccessToken() - if err != nil || token == "" { - return "", &friendlyerrors.FriendlyError{ - UserMessage: "❌ Not logged in", - NextSteps: "Please run 'seedfast login' to authenticate", - } - } - - return token, nil -} - // showEndpointIndicators is a build-tag specific function that shows // endpoint indicators in dev builds. The actual implementation is in // endpoint_indicators_dev.go and endpoint_indicators_prod.go (controlled by build tags). From f6c13ac28f38217e0bbcadd50f2de07330d8579d Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:54:21 +0100 Subject: [PATCH 14/38] perf: add 24-hour throttle to update check HTTP calls `FetchLatestVersion()` previously made an HTTP call on every CLI invocation. Now the cache stores `LastCheckTime` and skips the HTTP call when within `checkInterval` (24 hours), using the cached `LatestVersion` instead. The PersistentPreRun context timeout is increased from 2s to 5s to accommodate the 3-second HTTP timeout when a fetch does occur. --- cmd/root.go | 7 ++--- internal/updates/cache.go | 6 ++++- internal/updates/checker.go | 54 +++++++++++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index aecd0ab..1f94842 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,9 +47,10 @@ var rootCmd = &cobra.Command{ config.SetGlobalOutputMode(outputMode) } - // Check for updates synchronously with timeout to ensure notification appears before command output - // If timeout occurs, silently skip notification (don't block CLI) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + // Check for updates synchronously with timeout to ensure notification appears before command output. + // When the cache is fresh (within 24h), no HTTP call is made so this returns instantly. + // When stale, the 3-second HTTP timeout in FetchLatestVersion applies; 5 seconds provides margin. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() updates.CheckAndNotifyIfNeeded(ctx, cmd, Version) }, diff --git a/internal/updates/cache.go b/internal/updates/cache.go index 8d149ac..7a18255 100644 --- a/internal/updates/cache.go +++ b/internal/updates/cache.go @@ -18,6 +18,9 @@ type NotificationData struct { // LastNotificationTime is when we last showed the update notification LastNotificationTime time.Time `json:"last_notification_time"` + + // LastCheckTime is when we last fetched the version from GitHub + LastCheckTime time.Time `json:"last_check_time"` } const ( @@ -106,8 +109,9 @@ func LoadNotificationData() (*NotificationData, error) { return &NotificationData{}, nil } - // VALIDATION: Validate time + // VALIDATION: Validate times data.LastNotificationTime = validateTime(data.LastNotificationTime) + data.LastCheckTime = validateTime(data.LastCheckTime) // VALIDATION: Validate version format if !isValidVersion(data.LatestVersion) { diff --git a/internal/updates/checker.go b/internal/updates/checker.go index d686378..b87f5b6 100644 --- a/internal/updates/checker.go +++ b/internal/updates/checker.go @@ -15,6 +15,10 @@ import ( const ( // notificationInterval is how often to show notifications for non-critical commands notificationInterval = 4 * time.Hour + + // checkInterval is how often to make the HTTP call to check for new versions. + // Between checks, the cached LatestVersion is used instead of fetching from GitHub. + checkInterval = 24 * time.Hour ) // criticalCommands lists commands that should always show update notifications @@ -28,27 +32,38 @@ var criticalCommands = map[string]bool{ // // The function: // - Runs synchronously with context timeout (ensures notification appears before command output) -// - Fetches the latest version from GitHub (respects context cancellation) +// - Loads cached version data and skips the HTTP call if last check was within 24 hours +// - Fetches the latest version from GitHub only when cache is stale (respects context cancellation) // - Determines if a notification should be shown based on command and timing // - Shows notification if needed and updates the cache func CheckAndNotifyIfNeeded(ctx context.Context, cmd *cobra.Command, currentVersion string) { // Context may have timeout - if it expires, operations are cancelled // Errors are silently ignored - CLI continues to work - // Fetch latest version from Homebrew formula - latestVersion, err := FetchLatestVersion(ctx) - if err != nil { - // GitHub unavailable or network error - skip check - return - } - - // Load cached notification data + // Load cached notification data first to avoid unnecessary HTTP calls data, err := LoadNotificationData() if err != nil { - // Cache error - use empty data (will show notification) + // Cache error - use empty data (will trigger fetch) data = &NotificationData{} } + // Determine if we need to fetch from GitHub or can use cached version + latestVersion := data.LatestVersion + if !isCacheFresh(data) { + // Cache is stale or empty - fetch from GitHub + fetched, err := FetchLatestVersion(ctx) + if err != nil { + // GitHub unavailable or network error - skip check + return + } + latestVersion = fetched + + // Update cache with fetched version and check time + data.LatestVersion = latestVersion + data.LastCheckTime = time.Now() + SaveNotificationData(data) + } + // Determine if we should show notification if !shouldShowNotification(cmd, latestVersion, currentVersion, data) { return @@ -58,11 +73,28 @@ func CheckAndNotifyIfNeeded(ctx context.Context, cmd *cobra.Command, currentVers showNotification(latestVersion, currentVersion) // Update cache with new notification time - data.LatestVersion = latestVersion data.LastNotificationTime = time.Now() SaveNotificationData(data) } +// isCacheFresh returns true if the cached version data is recent enough to skip +// an HTTP call to GitHub. The cache is considered fresh if LastCheckTime is within +// checkInterval (24 hours) and a LatestVersion is available. +func isCacheFresh(data *NotificationData) bool { + if data.LatestVersion == "" { + return false + } + if data.LastCheckTime.IsZero() { + return false + } + elapsed := time.Since(data.LastCheckTime) + if elapsed < 0 { + // System time went backwards - treat as stale + return false + } + return elapsed < checkInterval +} + // shouldShowNotification determines if an update notification should be displayed. // // Returns true if: From 82fd0ddf7b36180a47b701aa3d41a5e45e8131f5 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:54:47 +0100 Subject: [PATCH 15/38] perf: parallelize HTTP calls in `--version` handler `GetVersion` and `GetCLIVersion` are now fetched concurrently using goroutines after the manifest is resolved, reducing `--version` latency by running both HTTP calls in parallel instead of sequentially. --- cmd/root.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 1f94842..85d0bc3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "context" "fmt" "os" + "sync" "time" "seedfast/cli/internal/backend" @@ -57,23 +58,37 @@ var rootCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { if showVersion { ctx := context.Background() - // Fetch manifest from server + // Fetch manifest from server (must complete before parallel calls) m, err := manifest.GetEndpoints(ctx) if err != nil { return friendlyerrors.WrapGrpcError(err) } be := backend.New(m.HTTPBaseURL(), m.HTTP) - backendVersion, err := be.GetVersion(ctx) - if err != nil { - backendVersion = "unknown" - } + + // Fetch backend version and latest CLI version in parallel + var backendVersion, latestCLIVersion string + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + v, err := be.GetVersion(ctx) + if err != nil { + v = "unknown" + } + backendVersion = v + }() + go func() { + defer wg.Done() + v, _ := be.GetCLIVersion(ctx) + latestCLIVersion = v + }() + wg.Wait() fmt.Printf("seedfast %s\nbackend %s\n", Version, backendVersion) - // Check for CLI updates - latestCLIVersion, err := be.GetCLIVersion(ctx) - if err == nil && latestCLIVersion != "" && latestCLIVersion != Version { + // Show update notification if a newer version is available + if latestCLIVersion != "" && latestCLIVersion != Version { fmt.Println() fmt.Println("┌──────────────────────────────────────────────────────────┐") fmt.Printf("│ ⚠️ A new version of seedfast CLI is available: %-7s │\n", latestCLIVersion) From 8621d22e578d53c06e8f264e58f449b6b6cd864e Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:54:57 +0100 Subject: [PATCH 16/38] refactor: extract shared `logging.IsVerbose()` helper Replaces 4 identical private `isVerbose()` functions across backend, manifest, auth, and keychain packages with a single exported function in the logging package. --- internal/auth/service.go | 59 +++++++--------------------- internal/auth/storage.go | 11 ++---- internal/backend/auth.go | 45 ++++++++++----------- internal/keychain/security_darwin.go | 12 ++---- internal/logging/verbose.go | 11 ++++++ internal/manifest/service.go | 9 +---- 6 files changed, 55 insertions(+), 92 deletions(-) create mode 100644 internal/logging/verbose.go diff --git a/internal/auth/service.go b/internal/auth/service.go index 692a516..2aef25a 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -15,6 +15,7 @@ import ( "seedfast/cli/internal/backend" "seedfast/cli/internal/keychain" + "seedfast/cli/internal/logging" "seedfast/cli/internal/manifest" ) @@ -40,7 +41,7 @@ func (s *Service) StartLogin(ctx context.Context) (authURL string, deviceID stri func (s *Service) PollLogin(ctx context.Context, deviceID string) (string, bool, error) { tokenPair, err := s.be.PollDeviceLink(ctx, deviceID) if err != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: PollDeviceLink failed: %v\n", err) } return "", false, err @@ -49,7 +50,7 @@ func (s *Service) PollLogin(ctx context.Context, deviceID string) (string, bool, return "", false, nil } - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: Got token pair, access_token length=%d, refresh_token length=%d\n", len(tokenPair.AccessToken), len(tokenPair.RefreshToken)) } @@ -57,7 +58,7 @@ func (s *Service) PollLogin(ctx context.Context, deviceID string) (string, bool, // Get keychain manager - this will initialize it if needed km, err := keychain.GetManager() if err != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: GetManager failed: %v\n", err) } return "", false, err @@ -70,43 +71,32 @@ func (s *Service) PollLogin(ctx context.Context, deviceID string) (string, bool, tokenPair.AccessExpiresAt, tokenPair.RefreshExpiresAt, ); err != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: SaveAuthTokens failed: %v\n", err) } return "", false, err } - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: Tokens saved to keychain\n") } - userID := "" + userID := "user" if userData, err := s.be.GetMe(ctx, tokenPair.AccessToken); err == nil && userData != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: GetMe succeeded, got user data\n") } - // Prefer email, then user_id, then id - if email, ok := userData["email"].(string); ok && email != "" { - userID = email - } else if uid, ok := userData["user_id"].(string); ok && uid != "" { - userID = uid - } else if id, ok := userData["id"].(string); ok && id != "" { - userID = id - } + userID = ExtractUserIdentifier(userData) } else if err != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: GetMe failed: %v\n", err) } } - // Persist minimal state; we store user_id in Account for display - if userID == "" { - userID = "user" - } - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: Using userID=%s\n", userID) } _ = Save(State{LoggedIn: true, Account: userID}) - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] PollLogin: State saved, returning success\n") } return userID, true, nil @@ -128,18 +118,7 @@ func (s *Service) WhoAmI(ctx context.Context) (string, bool, error) { // Try new /api/cli/me endpoint first (supports caching) userData, meErr := s.be.GetMe(ctx, token) if meErr == nil && userData != nil { - // Extract user identifier - prefer email, then user_id, then id - if email, ok := userData["email"].(string); ok && email != "" { - return email, true, nil - } - if uid, ok := userData["user_id"].(string); ok && uid != "" { - return uid, true, nil - } - if uid, ok := userData["id"].(string); ok && uid != "" { - return uid, true, nil - } - // If userData exists but no identifier, still consider it valid - return "user", true, nil + return ExtractUserIdentifier(userData), true, nil } // If we got an unauthorized error, try to refresh the token @@ -148,17 +127,7 @@ func (s *Service) WhoAmI(ctx context.Context) (string, bool, error) { // Retry with new token if newToken, err := km.LoadAccessToken(); err == nil && newToken != "" { if userData, err := s.be.GetMe(ctx, newToken); err == nil && userData != nil { - // Prefer email, then user_id, then id - if email, ok := userData["email"].(string); ok && email != "" { - return email, true, nil - } - if uid, ok := userData["user_id"].(string); ok && uid != "" { - return uid, true, nil - } - if uid, ok := userData["id"].(string); ok && uid != "" { - return uid, true, nil - } - return "user", true, nil + return ExtractUserIdentifier(userData), true, nil } } } else { diff --git a/internal/auth/storage.go b/internal/auth/storage.go index 8ec7aaa..fa594d0 100644 --- a/internal/auth/storage.go +++ b/internal/auth/storage.go @@ -9,16 +9,11 @@ package auth import ( "encoding/json" "fmt" - "os" "seedfast/cli/internal/keychain" + "seedfast/cli/internal/logging" ) -// isVerbose checks if verbose mode is enabled dynamically -func isVerbose() bool { - return os.Getenv("SEEDFAST_VERBOSE") == "1" -} - // State represents persisted authentication state for the current user. type State struct { LoggedIn bool `json:"logged_in"` @@ -27,7 +22,7 @@ type State struct { // Load reads the auth state from the keychain. Missing state yields zero value. func Load() (State, error) { - verbose := isVerbose() + verbose := logging.IsVerbose() if verbose { fmt.Printf("[DEBUG] auth.Load: Loading auth state from keychain\n") } @@ -78,7 +73,7 @@ func Load() (State, error) { // Save writes the auth state to the keychain. func Save(s State) error { - verbose := isVerbose() + verbose := logging.IsVerbose() if verbose { fmt.Printf("[DEBUG] auth.Save: Saving auth state - LoggedIn: %v, Account: %s\n", s.LoggedIn, s.Account) } diff --git a/internal/backend/auth.go b/internal/backend/auth.go index a41080b..7c28c47 100644 --- a/internal/backend/auth.go +++ b/internal/backend/auth.go @@ -14,13 +14,15 @@ import ( "os" "strings" "time" + + "seedfast/cli/internal/logging" ) // BeginDeviceLink fetches a magic link from /api/cli/get-link. // It initiates the device authorization flow by requesting a link and device code from the backend. // Returns the magic link URL, device ID/code, polling interval in seconds, and any error. func (h *HTTP) BeginDeviceLink(ctx context.Context) (string, string, int, error) { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] BeginDeviceLink: requesting %s%s\n", h.baseURL, h.endpoints.GetLink) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.baseURL+h.endpoints.GetLink, nil) @@ -45,7 +47,7 @@ func (h *HTTP) BeginDeviceLink(ctx context.Context) (string, string, int, error) return "", "", 0, err } - if isVerbose() { + if logging.IsVerbose() { rawJSON, _ := json.MarshalIndent(raw, "", " ") fmt.Fprintf(os.Stderr, "[DEBUG] BeginDeviceLink response: %s\n", string(rawJSON)) } @@ -56,7 +58,7 @@ func (h *HTTP) BeginDeviceLink(ctx context.Context) (string, string, int, error) } deviceID := extractDeviceID(raw, link) - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] BeginDeviceLink: link=%s, deviceID=%s\n", link, deviceID) } return link, deviceID, 3, nil @@ -221,7 +223,7 @@ func walkJSON(node any, access *string, refresh *string) { func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (*TokenPair, bool, error) { b, err := json.Marshal(body) if err != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Failed to marshal body: %v\n", err) } return nil, false, err @@ -229,7 +231,7 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.baseURL+h.endpoints.GetToken, strings.NewReader(string(b))) if err != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Failed to create request: %v\n", err) } return nil, false, err @@ -237,20 +239,20 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* h.setStandardHeaders(req) req.Header.Set("Content-Type", "application/json") - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: POST %s with body: %s\n", h.baseURL+h.endpoints.GetToken, string(b)) } resp, err := h.client.Do(req) if err != nil { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: HTTP request failed: %v\n", err) } return nil, false, err } defer resp.Body.Close() - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Response status: %d\n", resp.StatusCode) } @@ -269,7 +271,7 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* // Must have at least access token if accessToken == "" { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: No access token found in response\n") } return nil, false, errors.New("no access token in successful response") @@ -278,7 +280,7 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* // Extract access token expiration from X-Token-Expires-At header (no "Access" prefix for device login) accessExpiresAt := extractTokenExpirationFromHeader(resp.Header, "X-Token-Expires-At") - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Successfully got tokens\n") } @@ -291,7 +293,7 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* case http.StatusNoContent, http.StatusAccepted: // Pending authorization - this is expected, not an error - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Pending status %d (authorization not complete yet)\n", resp.StatusCode) } return nil, false, nil @@ -301,7 +303,7 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* bodyBytes, _ := io.ReadAll(resp.Body) bodyStr := string(bodyBytes) - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Bad Request status 400, body: %s\n", bodyStr) } @@ -317,7 +319,7 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* strings.Contains(errMsgLower, "pending") || strings.Contains(errMsgLower, "access token not found") { // Treat as pending, not an error - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Treating 400 '%s' as pending\n", errMsg) } return nil, false, nil @@ -327,7 +329,7 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* // This can happen after successful authorization when polling continues // Treat as pending to avoid false errors if strings.Contains(errMsgLower, "invalid link") { - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Treating 400 '%s' as pending (link consumed)\n", errMsg) } return nil, false, nil @@ -344,14 +346,14 @@ func (h *HTTP) tryVerifyPostJSON(ctx context.Context, body map[string]string) (* case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusUnsupportedMediaType: // These are real errors, not pending states bodyBytes, _ := io.ReadAll(resp.Body) - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Error status %d, body: %s\n", resp.StatusCode, string(bodyBytes)) } return nil, false, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes)) default: bodyBytes, _ := io.ReadAll(resp.Body) - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] tryVerifyPostJSON: Unexpected status %d, body: %s\n", resp.StatusCode, string(bodyBytes)) } return nil, false, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes)) @@ -406,7 +408,7 @@ func (h *HTTP) VerifyAPIKeyAndGetJWT(ctx context.Context, apiKey string) (string h.setStandardHeaders(req) req.Header.Set("X-API-Key", apiKey) - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] VerifyAPIKeyAndGetJWT: POST %s\n", h.baseURL+endpoint) } @@ -416,7 +418,7 @@ func (h *HTTP) VerifyAPIKeyAndGetJWT(ctx context.Context, apiKey string) (string } defer resp.Body.Close() - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] VerifyAPIKeyAndGetJWT: Response status: %d\n", resp.StatusCode) } @@ -441,7 +443,7 @@ func (h *HTTP) VerifyAPIKeyAndGetJWT(ctx context.Context, apiKey string) (string expiresAt = time.Now().Add(15 * time.Minute).Format(time.RFC3339) } - if isVerbose() { + if logging.IsVerbose() { fmt.Fprintf(os.Stderr, "[DEBUG] VerifyAPIKeyAndGetJWT: Successfully got JWT\n") fmt.Fprintf(os.Stderr, "[DEBUG] VerifyAPIKeyAndGetJWT: Token expires at: %s\n", expiresAt) } @@ -458,8 +460,3 @@ func (h *HTTP) VerifyAPIKeyAndGetJWT(ctx context.Context, apiKey string) (string return "", "", fmt.Errorf("API key verification failed (status %d): %s", resp.StatusCode, bodyStr) } } - -// isVerbose checks if verbose logging is enabled via SEEDFAST_VERBOSE environment variable -func isVerbose() bool { - return os.Getenv("SEEDFAST_VERBOSE") == "1" -} diff --git a/internal/keychain/security_darwin.go b/internal/keychain/security_darwin.go index 73cc774..b39223e 100644 --- a/internal/keychain/security_darwin.go +++ b/internal/keychain/security_darwin.go @@ -9,15 +9,11 @@ import ( "bytes" "encoding/hex" "fmt" - "os" "os/exec" "strings" -) -// isVerbose checks if verbose mode is enabled dynamically -func isVerbose() bool { - return os.Getenv("SEEDFAST_VERBOSE") == "1" -} + "seedfast/cli/internal/logging" +) // truncate returns first n characters of s, or entire s if shorter func truncate(s string, n int) string { @@ -54,7 +50,7 @@ func newSecurityBackend() (*securityBackend, error) { // Set stores a key-value pair in macOS keychain. func (s *securityBackend) Set(key, value string) error { - verbose := isVerbose() + verbose := logging.IsVerbose() if verbose { fmt.Printf("[DEBUG] security_darwin: Set() called for key '%s', value length: %d\n", key, len(value)) fmt.Printf("[DEBUG] security_darwin: Set() value content (first 100 chars): %q\n", truncate(value, 100)) @@ -96,7 +92,7 @@ func (s *securityBackend) Set(key, value string) error { // Get retrieves a value from macOS keychain. func (s *securityBackend) Get(key string) (string, error) { - verbose := isVerbose() + verbose := logging.IsVerbose() if verbose { fmt.Printf("[DEBUG] security_darwin: Get() called for key '%s'\n", key) } diff --git a/internal/logging/verbose.go b/internal/logging/verbose.go new file mode 100644 index 0000000..4aef797 --- /dev/null +++ b/internal/logging/verbose.go @@ -0,0 +1,11 @@ +// Copyright (c) 2025 Seedfast +// Licensed under the MIT License. See LICENSE file in the project root for details. + +package logging + +import "os" + +// IsVerbose checks if verbose logging is enabled via SEEDFAST_VERBOSE environment variable. +func IsVerbose() bool { + return os.Getenv("SEEDFAST_VERBOSE") == "1" +} diff --git a/internal/manifest/service.go b/internal/manifest/service.go index 2d58ead..f77a41c 100644 --- a/internal/manifest/service.go +++ b/internal/manifest/service.go @@ -6,10 +6,10 @@ package manifest import ( "context" "fmt" - "os" "strings" "seedfast/cli/internal/httperrors" + "seedfast/cli/internal/logging" ) // GetEndpoints returns the manifest endpoints, using the RAM cache if available. @@ -55,7 +55,7 @@ func GetEndpoints(ctx context.Context) (*Manifest, error) { manifest.SetHTTPBaseURL(originalHTTPBaseURL) // Debug output when verbose mode is enabled - if isVerbose() { + if logging.IsVerbose() { fmt.Printf("[DEBUG] manifest: Overriding gRPC URL: %s -> %s\n", originalAgent, manifest.GRPC.Agent) fmt.Printf("[DEBUG] manifest: HTTP Base URL preserved: %s\n", manifest.HTTPBaseURL()) fmt.Printf("[DEBUG] manifest: gRPC Address: %s\n", manifest.GRPCAddress()) @@ -85,8 +85,3 @@ func normalizeGrpcURL(rawURL string) string { } return "grpcs://" + rawURL } - -// isVerbose checks if verbose mode is enabled via environment variable. -func isVerbose() bool { - return os.Getenv("SEEDFAST_VERBOSE") == "1" -} From 623d2d56c0cd7570bd389217c00f7d12c7f81ba8 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:55:03 +0100 Subject: [PATCH 17/38] refactor: remove dead `ConnectWithTimeout` method --- internal/orchestration/database_connector.go | 32 -------------------- 1 file changed, 32 deletions(-) diff --git a/internal/orchestration/database_connector.go b/internal/orchestration/database_connector.go index a007f91..41c0e3d 100644 --- a/internal/orchestration/database_connector.go +++ b/internal/orchestration/database_connector.go @@ -54,35 +54,3 @@ func (dc *DatabaseConnector) Connect(ctx context.Context, dsn string) (*pgxpool. return pool, nil } - -// ConnectWithTimeout establishes a connection pool with a custom timeout. -// This is useful for commands that need different timeout behavior (e.g., connect command). -func (dc *DatabaseConnector) ConnectWithTimeout(ctx context.Context, dsn string, timeout context.Context) (*pgxpool.Pool, error) { - // Parse DSN to get pool config - poolConfig, err := pgxpool.ParseConfig(dsn) - if err != nil { - return nil, err - } - - // Configure connection pool settings (same as Connect) - poolConfig.MaxConns = 10 - poolConfig.MinConns = 2 - poolConfig.MaxConnLifetime = time.Hour - poolConfig.MaxConnIdleTime = 30 * time.Minute - poolConfig.HealthCheckPeriod = time.Minute - poolConfig.ConnConfig.ConnectTimeout = 10 * time.Second - - // Create connection pool with custom timeout context - pool, err := pgxpool.NewWithConfig(timeout, poolConfig) - if err != nil { - return nil, err - } - - // Verify connection with ping using the timeout context - if err := pool.Ping(timeout); err != nil { - pool.Close() - return nil, err - } - - return pool, nil -} From c49d8d7b666d9a4c4aaa3511162adfd81fc4ef02 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:55:09 +0100 Subject: [PATCH 18/38] refactor: extract `dsn.ResolveRawFromSources()` helper Centralizes DSN priority logic (SEEDFAST_DSN > DATABASE_URL > keychain) into a shared function. Both dbinfo command and DSNResolver now delegate to it instead of reimplementing. --- cmd/dbinfo.go | 41 ++++++-------------------- internal/dsn/resolver.go | 27 +++++++++++++++++ internal/orchestration/dsn_resolver.go | 30 +++++-------------- 3 files changed, 44 insertions(+), 54 deletions(-) diff --git a/cmd/dbinfo.go b/cmd/dbinfo.go index 7ff117f..faa2103 100644 --- a/cmd/dbinfo.go +++ b/cmd/dbinfo.go @@ -4,12 +4,8 @@ package cmd import ( - "os" - "strings" - "seedfast/cli/internal/auth" "seedfast/cli/internal/dsn" - "seedfast/cli/internal/keychain" "seedfast/cli/internal/logging" "github.com/pterm/pterm" @@ -31,35 +27,16 @@ var dbinfoCmd = &cobra.Command{ return nil } - // Try to get DSN from env vars first - rawDSN := "" - if env := os.Getenv("SEEDFAST_DSN"); strings.TrimSpace(env) != "" { - rawDSN = strings.TrimSpace(env) - } else if env := os.Getenv("DATABASE_URL"); strings.TrimSpace(env) != "" { - rawDSN = strings.TrimSpace(env) + rawDSN, err := dsn.ResolveRawFromSources() + if err != nil { + pterm.Println("❌ Secure storage is not available on this system") + pterm.Println(" Keychain is only supported on macOS and Windows") + return err } - - // Fallback to keychain - if strings.TrimSpace(rawDSN) == "" { - km, err := keychain.GetManager() - if err != nil { - pterm.Println("❌ Secure storage is not available on this system") - pterm.Println(" Keychain is only supported on macOS and Windows") - return err - } - - rawDSN, err = km.LoadDBDSN() - if err != nil { - pterm.Println("⚠️ No database connection configured") - pterm.Println(" Please run 'seedfast connect' to set up your database.") - return nil - } - - if strings.TrimSpace(rawDSN) == "" { - pterm.Println("⚠️ No database connection configured") - pterm.Println(" Please run 'seedfast connect' to set up your database.") - return nil - } + if rawDSN == "" { + pterm.Println("⚠️ No database connection configured") + pterm.Println(" Please run 'seedfast connect' to set up your database.") + return nil } // Parse and normalize the DSN diff --git a/internal/dsn/resolver.go b/internal/dsn/resolver.go index 1cd802e..f393e3f 100644 --- a/internal/dsn/resolver.go +++ b/internal/dsn/resolver.go @@ -4,7 +4,10 @@ package dsn import ( + "os" "strings" + + "seedfast/cli/internal/keychain" ) // DetectDBType detects the database type from a DSN string @@ -104,3 +107,27 @@ func ParseInfo(dsn string) (*DSNInfo, error) { return resolver.Parse(dsn) } + +// ResolveRawFromSources retrieves a raw DSN from environment variables or keychain. +// Priority: SEEDFAST_DSN > DATABASE_URL > keychain. +// Returns ("", nil) if no DSN is configured (caller decides the error message). +func ResolveRawFromSources() (string, error) { + if env := os.Getenv("SEEDFAST_DSN"); strings.TrimSpace(env) != "" { + return strings.TrimSpace(env), nil + } + if env := os.Getenv("DATABASE_URL"); strings.TrimSpace(env) != "" { + return strings.TrimSpace(env), nil + } + km, err := keychain.GetManager() + if err != nil { + return "", err + } + v, err := km.LoadDBDSN() + if err != nil { + return "", nil + } + if strings.TrimSpace(v) == "" { + return "", nil + } + return strings.TrimSpace(v), nil +} diff --git a/internal/orchestration/dsn_resolver.go b/internal/orchestration/dsn_resolver.go index 790dee7..30ed444 100644 --- a/internal/orchestration/dsn_resolver.go +++ b/internal/orchestration/dsn_resolver.go @@ -3,12 +3,10 @@ package orchestration import ( "context" "fmt" - "os" "strings" "seedfast/cli/internal/dsn" friendlyerrors "seedfast/cli/internal/errors" - "seedfast/cli/internal/keychain" ) // DSNResolver handles resolving the PostgreSQL DSN from various sources. @@ -46,29 +44,17 @@ func (dr *DSNResolver) Resolve(ctx context.Context) (string, error) { // getRawDSN retrieves the raw DSN from environment variables or keychain. // Returns an error if no DSN is configured. func (dr *DSNResolver) getRawDSN() (string, error) { - // Priority 1: SEEDFAST_DSN environment variable - if env := os.Getenv("SEEDFAST_DSN"); strings.TrimSpace(env) != "" { - return strings.TrimSpace(env), nil - } - - // Priority 2: DATABASE_URL environment variable - if env := os.Getenv("DATABASE_URL"); strings.TrimSpace(env) != "" { - return strings.TrimSpace(env), nil + raw, err := dsn.ResolveRawFromSources() + if err != nil { + return "", err } - - // Priority 3: Keychain storage - km, err := keychain.GetManager() - if err == nil { - if v, err := km.LoadDBDSN(); err == nil && strings.TrimSpace(v) != "" { - return strings.TrimSpace(v), nil + if raw == "" { + return "", &friendlyerrors.FriendlyError{ + UserMessage: "❌ No database connection configured", + NextSteps: "Please run 'seedfast connect' to set up your database connection", } } - - // No DSN found - return user-friendly error - return "", &friendlyerrors.FriendlyError{ - UserMessage: "❌ No database connection configured", - NextSteps: "Please run 'seedfast connect' to set up your database connection", - } + return raw, nil } // GetDatabaseName extracts the database name from a DSN string. From ecb1adf21424d2cb68d67f17c0beaf2375627c64 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:55:15 +0100 Subject: [PATCH 19/38] refactor: extract `auth.ExtractUserIdentifier()` helper Consolidates the email > user_id > id fallback pattern into a single function. Replaces 4 inline copies across auth service and me command. --- cmd/me.go | 15 ++------------- internal/auth/user.go | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 internal/auth/user.go diff --git a/cmd/me.go b/cmd/me.go index b55955a..c264154 100644 --- a/cmd/me.go +++ b/cmd/me.go @@ -65,19 +65,8 @@ var meCmd = &cobra.Command{ // Try to get full user data with email userData, err := svc.GetUserData(ctx) if err == nil && userData != nil { - // Try to extract email - if email, ok := userData["email"].(string); ok && email != "" { - fmt.Println(getMePhrase(email)) - return nil - } - // Fallback to user_id - if userID, ok := userData["user_id"].(string); ok && userID != "" { - fmt.Println(getMePhrase(userID)) - return nil - } - // Fallback to id - if id, ok := userData["id"].(string); ok && id != "" { - fmt.Println(getMePhrase(id)) + if identifier := auth.ExtractUserIdentifier(userData); identifier != "user" { + fmt.Println(getMePhrase(identifier)) return nil } } diff --git a/internal/auth/user.go b/internal/auth/user.go new file mode 100644 index 0000000..b3b9dc3 --- /dev/null +++ b/internal/auth/user.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025 Seedfast +// Licensed under the MIT License. See LICENSE file in the project root for details. + +package auth + +// ExtractUserIdentifier extracts a user identifier from user data. +// Priority: email > user_id > id. Returns "user" if none found. +func ExtractUserIdentifier(userData map[string]any) string { + if email, ok := userData["email"].(string); ok && email != "" { + return email + } + if uid, ok := userData["user_id"].(string); ok && uid != "" { + return uid + } + if id, ok := userData["id"].(string); ok && id != "" { + return id + } + return "user" +} From 933ee5b485c82466d7207267e788d19f3b6aedbd Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:55:32 +0100 Subject: [PATCH 20/38] refactor: replace local `maskDSN` with `logging.Mask` --- internal/mcpserver/handlers.go | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/internal/mcpserver/handlers.go b/internal/mcpserver/handlers.go index f750b2b..7df372d 100644 --- a/internal/mcpserver/handlers.go +++ b/internal/mcpserver/handlers.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "seedfast/cli/internal/logging" "seedfast/cli/internal/mcpserver/store" "seedfast/cli/internal/ui" ) @@ -127,7 +128,7 @@ func (s *Server) toolConnectionsTest(ctx context.Context, args json.RawMessage) // In a full implementation, we'd use pgxpool directly // Mask the DSN for display - maskedDSN := maskDSN(input.DSN) + maskedDSN := logging.Mask(input.DSN) return CallToolResult{ Content: []Content{NewTextContent(fmt.Sprintf( @@ -744,26 +745,6 @@ func (s *Server) toolPlanDelete(ctx context.Context, args json.RawMessage) (Call }, nil } -// maskDSN masks sensitive parts of a DSN for display. -func maskDSN(dsn string) string { - // Simple masking - replace password in common formats - // postgresql://user:password@host/db -> postgresql://user:****@host/db - if strings.Contains(dsn, "://") { - parts := strings.SplitN(dsn, "://", 2) - if len(parts) == 2 { - rest := parts[1] - if atIdx := strings.Index(rest, "@"); atIdx > 0 { - userPass := rest[:atIdx] - if colonIdx := strings.Index(userPass, ":"); colonIdx > 0 { - masked := parts[0] + "://" + userPass[:colonIdx+1] + "****" + rest[atIdx:] - return masked - } - } - } - } - return dsn -} - // getFloat safely extracts a float64 from a map. func getFloat(m map[string]interface{}, key string) float64 { if v, ok := m[key].(float64); ok { From 4e1907bcb22b11b545ead18001814da9dd7cb71d Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 17:59:47 +0100 Subject: [PATCH 21/38] refactor: convert `TokenManager` to standalone functions The `TokenManager` struct was stateless, holding only a constant 2-minute threshold, yet required a heap allocation on every token validation call. Replace the struct and methods with package-level functions and a constant. --- internal/auth/service.go | 3 +-- internal/backend/token_manager.go | 26 ++++++++------------------ 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/internal/auth/service.go b/internal/auth/service.go index 692a516..d046595 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -304,10 +304,9 @@ func (s *Service) GetValidAccessToken(ctx context.Context) (string, error) { // Proactive expiration check accessExpiresAt := km.LoadAccessTokenExpiresAt() - tokenManager := backend.NewTokenManager() // Check if token is expired or will expire soon (within 2 minutes) - if accessExpiresAt != "" && tokenManager.ShouldRefresh(accessExpiresAt) { + if accessExpiresAt != "" && backend.ShouldRefresh(accessExpiresAt) { // Token is expired or expiring soon - refresh proactively if refreshed, _ := s.RefreshAccessToken(ctx); refreshed { // Get the new token diff --git a/internal/backend/token_manager.go b/internal/backend/token_manager.go index 73d5f40..69b069b 100644 --- a/internal/backend/token_manager.go +++ b/internal/backend/token_manager.go @@ -9,41 +9,31 @@ import ( "time" ) -// TokenManager handles token lifecycle and expiration logic. -// It provides centralized logic for determining when tokens should be refreshed. -type TokenManager struct { - refreshThreshold time.Duration -} - -// NewTokenManager creates a new TokenManager with default threshold of 2 minutes. -func NewTokenManager() *TokenManager { - return &TokenManager{ - refreshThreshold: 2 * time.Minute, - } -} +// refreshThreshold is the duration before expiration at which a token should be refreshed. +const refreshThreshold = 2 * time.Minute // ShouldRefresh determines if a token should be refreshed based on its expiration time. // Returns false if expiresAt is empty (no expiration data) - relies on server validation. // Returns true if token is expired or will expire within the threshold. -func (tm *TokenManager) ShouldRefresh(expiresAt string) bool { +func ShouldRefresh(expiresAt string) bool { if expiresAt == "" { return false // No expiration data - rely on server validation } - parsedTime, err := tm.ParseExpiration(expiresAt) + parsedTime, err := ParseExpiration(expiresAt) if err != nil { // Invalid format - rely on server validation return false } // Check if token is expired or will expire soon - return time.Until(parsedTime) < tm.refreshThreshold + return time.Until(parsedTime) < refreshThreshold } // ParseExpiration safely parses an RFC3339 formatted time string. // Returns zero time and error if parsing fails. // Logs errors only in verbose mode. -func (tm *TokenManager) ParseExpiration(timeStr string) (time.Time, error) { +func ParseExpiration(timeStr string) (time.Time, error) { if timeStr == "" { return time.Time{}, fmt.Errorf("empty time string") } @@ -62,12 +52,12 @@ func (tm *TokenManager) ParseExpiration(timeStr string) (time.Time, error) { // IsExpired checks if a given expiration time has passed. // Returns false if timeStr is empty or invalid. -func (tm *TokenManager) IsExpired(timeStr string) bool { +func IsExpired(timeStr string) bool { if timeStr == "" { return false } - parsedTime, err := tm.ParseExpiration(timeStr) + parsedTime, err := ParseExpiration(timeStr) if err != nil { return false } From 0e58104418486fc438cb6373a83d6306be218808 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 18:01:21 +0100 Subject: [PATCH 22/38] refactor: clean up event dispatch in `ProcessEvents` Replace the if-chain with string() casts in ProcessEvents with a labeled switch statement. Use a labeled break to exit the for loop from within the switch for terminal events. Delete dead no-op line and unused strings import in ui_helpers.go. --- cmd/ui_helpers.go | 2 - internal/orchestration/event_handler.go | 71 ++++++++----------------- 2 files changed, 22 insertions(+), 51 deletions(-) diff --git a/cmd/ui_helpers.go b/cmd/ui_helpers.go index eaf3aa5..bff6e7d 100644 --- a/cmd/ui_helpers.go +++ b/cmd/ui_helpers.go @@ -6,7 +6,6 @@ package cmd import ( "fmt" "io" - "strings" "sync" "time" ) @@ -50,7 +49,6 @@ func startInlineSpinner(w io.Writer, text string, frames []string, interval time if len(line) > 2000 { line = line[:2000] } - _ = strings.TrimSpace("") i++ } } diff --git a/internal/orchestration/event_handler.go b/internal/orchestration/event_handler.go index 8178e1f..e3651fc 100644 --- a/internal/orchestration/event_handler.go +++ b/internal/orchestration/event_handler.go @@ -129,28 +129,24 @@ func (eh *EventHandler) ProcessEvents(ctx context.Context) error { eh.headerSpinner.Start() } +eventLoop: for ev := range eh.eventsChan { eh.logf("event type=%s payload_len=%d", ev.Type, len(ev.Message)) - // Handle transport lifecycle events raised by gRPC client - if string(ev.Type) == "stream_error" { + switch ev.Type { + case "stream_error": eh.handleStreamError(ev) - break - } + break eventLoop - if string(ev.Type) == "stream_closed" { + case "stream_closed": eh.handleStreamClosed(ev) - break - } + break eventLoop - // Handle subscription/quota blocks from server - if string(ev.Type) == "subscription_blocked" { + case "subscription_blocked": eh.handleSubscriptionBlocked(ev) - break - } + break eventLoop - // Handle subscription limit information from server - if string(ev.Type) == "subscription_limit_info" { + case "subscription_limit_info": // Stop header spinner before printing to prevent text collision during replan eh.headerSpinner.Stop() // Try typed proto Data first (new format), fall back to JSON (legacy format) @@ -165,70 +161,47 @@ func (eh *EventHandler) ProcessEvents(ctx context.Context) error { if !eh.config.IsCICDMode() { eh.headerSpinner.Start() } - continue - } - // Explicit workflow completion signal from server - if string(ev.Type) == "workflow_completed" { + case "workflow_completed": eh.handleWorkflowCompleted(ev) - break - } + break eventLoop - // plan_proposed event - if string(ev.Type) == "plan_proposed" { + case "plan_proposed": if err := eh.handlePlanProposed(ev); err != nil { eh.logf("ERROR: handlePlanProposed failed: %v", err) } - continue - } - // ask_human event - if string(ev.Type) == "ask_human" { + case "ask_human": if err := eh.handleAskHuman(ev); err != nil { eh.logf("ERROR: handleAskHuman failed: %v", err) } - continue - } - // session_ready informational event (ignored) - if string(ev.Type) == "session_ready" { - continue - } + case "session_ready": + // Informational event (ignored) - // table_started event - if string(ev.Type) == "table_started" { + case "table_started": if err := eh.handleTableStarted(ev); err != nil { eh.logf("ERROR: handleTableStarted failed: %v", err) } - continue - } - // table_done event - if string(ev.Type) == "table_done" { + case "table_done": if err := eh.handleTableDone(ev); err != nil { eh.logf("ERROR: handleTableDone failed: %v", err) } - continue - } - // table_failed event - if string(ev.Type) == "table_failed" { + case "table_failed": if err := eh.handleTableFailed(ev); err != nil { eh.logf("ERROR: handleTableFailed failed: %v", err) } - continue - } - // table_skipped event - if string(ev.Type) == "table_skipped" { + case "table_skipped": if err := eh.handleTableSkipped(ev); err != nil { eh.logf("ERROR: handleTableSkipped failed: %v", err) } - continue - } - // default: avoid mixing standard prints once area is active to prevent flicker - // Intentionally suppress legacy plan/progress rendering to avoid noisy output + default: + // Suppress legacy plan/progress rendering to avoid noisy output + } } // Close doneEvents channel to signal completion (use sync.Once to prevent double-close) From be4381c66fa045983056944e409828fe586d0bcd Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 18:02:10 +0100 Subject: [PATCH 23/38] refactor: reduce allocations in MCP server and flag validation Shrink MCP server initial scan buffer from 10MB to 64KB while keeping the 10MB max for large messages. Replace map-based output mode validation in `validateFlags` with a switch statement to avoid allocation. --- cmd/seed.go | 12 ++++-------- internal/mcpserver/server.go | 6 +++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/cmd/seed.go b/cmd/seed.go index 513f02c..9748d75 100644 --- a/cmd/seed.go +++ b/cmd/seed.go @@ -108,14 +108,10 @@ func validateFlags() error { // Validate output mode values if outputFlag != "" { - validModes := map[string]bool{ - "interactive": true, - "plain": true, - "json": true, - "ndjson": true, - "mcp": true, - } - if !validModes[outputFlag] { + switch outputFlag { + case "interactive", "plain", "json", "ndjson", "mcp": + // valid + default: return fmt.Errorf("invalid --output value: %s (must be: interactive, plain, json, ndjson, or mcp)", outputFlag) } } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index b75d2e8..6bdc823 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -46,9 +46,9 @@ func NewServer() *Server { // Run starts the MCP server with stdio transport. func (s *Server) Run(ctx context.Context) error { scanner := bufio.NewScanner(s.stdin) - // Increase buffer size for large messages - const maxScanTokenSize = 10 * 1024 * 1024 // 10MB - buf := make([]byte, maxScanTokenSize) + // Start with a 64KB buffer that can grow up to 10MB for large messages + const maxScanTokenSize = 10 * 1024 * 1024 // 10MB max + buf := make([]byte, 64*1024) // 64KB initial, grows as needed scanner.Buffer(buf, maxScanTokenSize) for scanner.Scan() { From 6c5e5a8b2350e3a212969044ad48fdd8dcc84a99 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 18:04:47 +0100 Subject: [PATCH 24/38] refactor: use shared HTTP client in manifest fetcher Replaces per-call http.Client creation with a package-level shared client. Enables TCP connection pooling across manifest fetch calls. --- internal/manifest/fetcher.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/manifest/fetcher.go b/internal/manifest/fetcher.go index d6ec993..8955855 100644 --- a/internal/manifest/fetcher.go +++ b/internal/manifest/fetcher.go @@ -37,6 +37,12 @@ var ( DisableTLS string // Set to "true" to disable TLS for localhost development (insecure) ) +// httpClient is a shared HTTP client for manifest fetching. +// Reusing the client enables TCP connection pooling across calls. +var httpClient = &http.Client{ + Timeout: 15 * time.Second, +} + // fetchFromServer retrieves the manifest from the server with signature verification. // If backendURL is provided, it will be used to construct the manifest URL. func fetchFromServer(ctx context.Context, backendURL string) (*Manifest, error) { @@ -50,8 +56,6 @@ func fetchFromServer(ctx context.Context, backendURL string) (*Manifest, error) manifestURL = defaultManifestURL } - client := &http.Client{Timeout: 15 * time.Second} - req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) @@ -61,7 +65,7 @@ func fetchFromServer(ctx context.Context, backendURL string) (*Manifest, error) req.Header.Set("User-Agent", "seedfast-cli/1.0") req.Header.Set("Accept", "application/json") - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("fetch manifest: %w", err) } From 74d1831f98393ed84207387781bf404cf92e87f1 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 18:04:53 +0100 Subject: [PATCH 25/38] refactor: migrate to `grpc.NewClient` from deprecated `DialContext` Replaces grpc.DialContext + WithBlock with grpc.NewClient which is non-blocking and the recommended API going forward. --- internal/bridge/grpcclient/client.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/bridge/grpcclient/client.go b/internal/bridge/grpcclient/client.go index c744c24..e202791 100644 --- a/internal/bridge/grpcclient/client.go +++ b/internal/bridge/grpcclient/client.go @@ -81,11 +81,8 @@ func (c *Client) Connect(ctx context.Context, addr string, accessToken string) e creds = credentials.NewTLS(tlsCfg) } - dctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - var err error - c.conn, err = grpc.DialContext(dctx, target, grpc.WithTransportCredentials(creds), grpc.WithBlock()) + c.conn, err = grpc.NewClient(target, grpc.WithTransportCredentials(creds)) if err != nil { return err } From 91508f8f805410b8ae368e37c827c57077f9adaa Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 18:07:38 +0100 Subject: [PATCH 26/38] fix: real database connection test in MCP server The `seedfast_connections_test` tool now verifies connectivity using `pgxpool` instead of returning a static success message. --- internal/mcpserver/handlers.go | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/mcpserver/handlers.go b/internal/mcpserver/handlers.go index f750b2b..f69b3fe 100644 --- a/internal/mcpserver/handlers.go +++ b/internal/mcpserver/handlers.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5/pgxpool" + "seedfast/cli/internal/mcpserver/store" "seedfast/cli/internal/ui" ) @@ -123,19 +125,29 @@ func (s *Server) toolConnectionsTest(ctx context.Context, args json.RawMessage) return CallToolResult{}, fmt.Errorf("dsn is required") } - // For now, we'll use the CLI to test the connection - // In a full implementation, we'd use pgxpool directly - - // Mask the DSN for display maskedDSN := maskDSN(input.DSN) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, input.DSN) + if err != nil { + return CallToolResult{ + Content: []Content{NewTextContent(fmt.Sprintf("Connection failed for %s: %v", maskedDSN, err))}, + IsError: true, + }, nil + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + return CallToolResult{ + Content: []Content{NewTextContent(fmt.Sprintf("Ping failed for %s: %v", maskedDSN, err))}, + IsError: true, + }, nil + } + return CallToolResult{ - Content: []Content{NewTextContent(fmt.Sprintf( - "Connection test requested for: %s\n"+ - "Note: Full connection testing requires database access.\n"+ - "Use 'seedfast connect' command directly for interactive testing.", - maskedDSN, - ))}, + Content: []Content{NewTextContent(fmt.Sprintf("Connection test successful for: %s", maskedDSN))}, }, nil } From 2ab9cddf6908714ab88fadf9d08a08cddad0ba7d Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:10:39 +0100 Subject: [PATCH 27/38] docs: add plan for TUI prompt wiring in production --- docs/plans/16-tui-prompt-wiring.md | 316 +++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/plans/16-tui-prompt-wiring.md diff --git a/docs/plans/16-tui-prompt-wiring.md b/docs/plans/16-tui-prompt-wiring.md new file mode 100644 index 0000000..ad3c356 --- /dev/null +++ b/docs/plans/16-tui-prompt-wiring.md @@ -0,0 +1,316 @@ +# Plan: Wire TUI Prompt Phase into Production CLI + +**Status:** Draft +**Created:** 2026-02-24 +**Branch:** feat/tui-prompt-wiring +**Parent:** feat/bubble-ui + +## Problem + +The production `seedfast seed` command renders the scope approval prompt ("Do you agree with this seeding scope?") using pterm in `internal/orchestration/event_handler.go` (lines 489-651). This code: + +1. Builds styled question text and bullet-point options via pterm +2. Spawns a mini Bubble Tea program (`scope_input.go`) for text input +3. Spawns a separate mini Bubble Tea program (`scope_selector.go`) for warning selection + +Meanwhile, the TUI framework already has `PromptPhase` (`internal/ui/tui/phases/prompt_phase.go`) that renders identical UI using lipgloss and handles all three flows (approval, rejection, custom scope) in a single Bubble Tea model. But it only runs in dev builds (`-tags dev`, `--output=tui`). + +### Current Architecture (Production) + +``` +handleAskHuman() +├── pterm.Println() -- render question + options (static text) +├── runScopeInput() -- mini tea.Program for text input +└── runScopeSelector() -- mini tea.Program for warning selection +``` + +Three separate rendering mechanisms compete for terminal control. The pterm output is fire-and-forget (no terminal ownership), while the two mini Bubble Tea programs each take/release terminal ownership independently. + +### TUI Architecture (Dev Only) + +``` +AppModel (single tea.Program) +└── PromptPhase + ├── View() -- renders question + options + text input + ├── Update() -- handles Enter/Ctrl+C/timeout + └── EventBridge -- sends response back to gRPC +``` + +One terminal owner, one event loop, one rendering pass. + +## Goal + +Replace the pterm-based prompt rendering in production `handleAskHuman` with TUI's `PromptPhase`, so that the scope approval UI is rendered consistently across production and dev builds. + +## Scope + +**In scope:** +- Prompt rendering (question text, bullet-point options, examples) +- Text input for approval/rejection/custom scope +- Warning selector for scope issues (dependency_empty, fk_feasibility) +- Auto-approve flow +- Scope-too-large replan flow +- Input timeout handling + +**Out of scope:** +- Other phases (Init, Plan, Seeding, Completed, Error) — remain pterm-based +- Full Phase 3 orchestrator wiring (replacing EventHandler entirely) +- CI/CD output modes (plain, json, ndjson, mcp) — unchanged + +## Key Gaps + +### 1. Build Tags Block Production Compilation + +TUI packages are gated behind `//go:build dev`: +- `internal/ui/tuirenderer/register_dev.go` — registers TUI renderer factory +- `internal/orchestration/seed_orchestrator_tui_dev.go` — `RunWithTUI` method + +The core TUI packages (`internal/ui/tui/`, `internal/ui/tui/phases/`, `internal/ui/tui/components/`) compile unconditionally but are never imported in production. + +### 2. AskHumanMsg Missing ScopeIssues + +`tui.AskHumanMsg` struct: +```go +type AskHumanMsg struct { + QuestionID string + QuestionText string + ContextTables []string + // Missing: ScopeIssues []*dbpb.ScopeIssue +} +``` + +`EventBridge.convertAskHuman()` drops `ScopeIssues` from the proto `UserQuestion`. Without this field, TUI PromptPhase cannot render the warning selector. + +### 3. PromptPhase Has No Warning Selector + +Production `handleAskHuman` has two UI paths: +- **No issues:** text input (approve/reject/custom scope) +- **Has issues:** `scopeSelector` (arrow-key selection with dynamic options) + +TUI `PromptPhase` only implements the first path. There is no selector component or warning rendering. + +### 4. handleAskHuman Bypasses SeedingRenderer + +The `SeedingRenderer` interface has `OnQuestion(questionText, options) (string, error)`, but `handleAskHuman` never calls it. It renders directly via pterm. The `InteractiveRenderer.OnQuestion()` implementation exists but is dead code. + +### 5. Production EventHandler Manages Terminal State + +`handleAskHuman` interacts with `HeaderSpinner`, cursor visibility, and pterm state: +- Stops header spinner before showing question +- Restarts spinner after auto-approve +- Manages `isExiting` atomic flag for Ctrl+C + +Any replacement must preserve these state transitions. + +## Approach + +### Option A: Standalone Prompt Runner (Recommended) + +Create a thin wrapper that runs `PromptPhase` as a standalone mini `tea.Program` — same pattern as existing `runScopeInput()` and `runScopeSelector()` but replacing both with a single program. + +**Why:** Minimal blast radius. Only `handleAskHuman` changes. EventHandler loop, HeaderSpinner, progress rendering, and all other phases remain untouched. The PromptPhase gets production exposure without requiring full Phase 3 wiring. + +#### Implementation Steps + +**Step 1: Extend `AskHumanMsg` with scope issues** + +File: `internal/ui/tui/events.go` + +```go +type AskHumanMsg struct { + QuestionID string + QuestionText string + ContextTables []string + ScopeIssues []*dbpb.ScopeIssue // NEW +} +``` + +File: `internal/ui/tui/bridge.go` — update `convertAskHuman`: + +```go +func (eb *EventBridge) convertAskHuman(ev seeding.Event) AskHumanMsg { + if q, ok := ev.Data.(*dbpb.UserQuestion); ok && q != nil { + return AskHumanMsg{ + QuestionID: q.QuestionId, + QuestionText: q.QuestionText, + ContextTables: q.ContextTables, + ScopeIssues: q.ScopeIssues, // NEW + } + } + return AskHumanMsg{} +} +``` + +**Step 2: Add warning selector to PromptPhase** + +File: `internal/ui/tui/phases/prompt_phase.go` + +Extend PromptPhase to handle scope issues: +- Parse `ScopeIssues` from `AskHumanMsg` (reuse `warningFormatResult` logic or create shared package) +- When issues present: render warning block + arrow-key selector (new `SelectorComponent` in `components/`) +- When no issues: current text input flow (unchanged) + +New file: `internal/ui/tui/components/selector.go` +- Arrow-key selection component (equivalent to `scope_selector.go` but as a reusable Bubble Tea component) +- Renders options with cursor indicator, navigation hint + +**Step 3: Create standalone prompt runner** + +New file: `internal/orchestration/prompt_runner.go` + +```go +// RunPrompt executes the TUI PromptPhase as a standalone Bubble Tea program. +// Replaces the combination of pterm rendering + runScopeInput + runScopeSelector. +// +// Returns: (answer string, approved bool, err error) +func RunPrompt(ctx context.Context, cfg PromptConfig) (string, bool, error) + +type PromptConfig struct { + QuestionID string + QuestionText string + ScopeIssues []*dbpb.ScopeIssue + AutoApprove bool + ScopeTooLarge bool + Timeout time.Duration +} +``` + +This function: +1. Creates a PromptPhase with the given config +2. Wraps it in a minimal AppModel (or a new PromptApp model) +3. Runs `tea.NewProgram(model).Run()` +4. Extracts the answer from the final model state + +**Step 4: Replace handleAskHuman rendering** + +File: `internal/orchestration/event_handler.go` + +Replace lines 590-651 (prompt rendering + scopeInput/scopeSelector calls) with: + +```go +answer, approved, err := RunPrompt(eh.ctx, PromptConfig{ + QuestionID: questionID, + QuestionText: question, + ScopeIssues: protoIssues, + AutoApprove: false, + ScopeTooLarge: eh.state.ScopeTooLargeMode, + Timeout: 5 * time.Minute, +}) +if err != nil { + // handle cancellation, timeout (same as current) +} +return eh.sendAnswer(questionID, answer) +``` + +Preserve: +- HeaderSpinner stop/start around the prompt +- `isExiting` atomic flag check +- CI/CD mode guard (skip prompt rendering) +- Scope display (contextTables) before the prompt + +**Step 5: Remove dead code** + +After wiring is complete: +- `scope_input.go` — replaced by PromptPhase text input +- `scope_selector.go` — replaced by PromptPhase selector component +- `InteractiveRenderer.OnQuestion()` — was already dead code +- pterm prompt rendering in `handleAskHuman` — replaced by RunPrompt +- Duplicate `scopeHintPhrases` in `scope_input.go` (already exists in `prompt_phase.go`) + +### Option B: Shared Rendering Function (Simpler but Incomplete) + +Extract only the visual rendering (question text + options) into a shared lipgloss-based function (like `dbheader.Render()`). Keep `scopeInput` and `scopeSelector` as-is. + +**Why not:** Doesn't eliminate the terminal ownership problem (3 mini programs). Doesn't leverage PromptPhase's integrated input handling. Half-measure that still needs Option A later. + +### Option C: Full Phase 3 Wiring (Too Large) + +Replace the entire EventHandler event loop with TUI's AppModel in production. + +**Why not:** Requires all 7 phases to be production-ready. Much larger blast radius. The prompt is the most user-facing piece — ship it first, validate, then expand. + +## Files Changed + +| File | Change | +|------|--------| +| `internal/ui/tui/events.go` | Add `ScopeIssues` field to `AskHumanMsg` | +| `internal/ui/tui/bridge.go` | Pass `ScopeIssues` in `convertAskHuman` | +| `internal/ui/tui/components/selector.go` | **NEW** — arrow-key selector component | +| `internal/ui/tui/phases/prompt_phase.go` | Add warning selector path, accept scope issues | +| `internal/orchestration/prompt_runner.go` | **NEW** — standalone prompt runner wrapping PromptPhase | +| `internal/orchestration/event_handler.go` | Replace pterm prompt rendering with `RunPrompt()` | + +## Files Removed (After Validation) + +| File | Reason | +|------|--------| +| `internal/orchestration/scope_input.go` | Replaced by PromptPhase text input | +| `internal/orchestration/scope_selector.go` | Replaced by PromptPhase selector | + +## Files NOT Changed + +| File | Reason | +|------|--------| +| `internal/orchestration/seed_orchestrator.go` | Event loop structure unchanged | +| `internal/orchestration/seed_orchestrator_tui_dev.go` | Dev TUI path unchanged | +| `internal/ui/tuirenderer/register_dev.go` | Full TUI renderer stays dev-only | +| `internal/ui/renderer_factory.go` | No new output mode added | +| `internal/orchestration/warning_formatter.go` | Reused as-is (or extracted to shared package) | +| CI/CD renderers (plain, json, ndjson, mcp) | Unchanged | + +## Dependency Considerations + +### Import Cycle Risk + +Production `orchestration` package would import `tui/phases` for PromptPhase. Current import graph: + +``` +orchestration → bridge, ui, seeding +tui/phases → tui, tui/components +tui → seeding (for EventBridge) +``` + +No cycle: `orchestration → tui/phases → tui → seeding` is a DAG. The `ui` package is not in the path. + +However, if PromptPhase needs `warningFormatResult` from `orchestration`, that creates a cycle. Solution: extract warning formatting into a shared package (`internal/warnings/` or `internal/ui/warnings/`). + +### lipgloss in Production Binary + +`lipgloss` is already in `go.mod` (transitive via `bubbles`). Adding direct usage in production path adds no new dependency, but slightly increases binary size for ANSI rendering. Acceptable tradeoff. + +## Testing + +1. **Build verification:** `go build ./...` and `go build -tags dev ./...` +2. **Unit tests:** PromptPhase with scope issues, selector navigation, timeout, cancellation +3. **Integration test:** `RunPrompt()` with mock bridge, verify response format +4. **Visual verification:** `seedfast seed` against real database — prompt must look identical +5. **Warning flow:** Trigger dependency_empty / fk_feasibility warnings, verify selector works +6. **Auto-approve:** `seedfast seed --scope "..."` still auto-approves without showing prompt +7. **Scope-too-large:** Trigger replan flow, verify "propose updated scope" UI +8. **CI/CD modes:** json/plain/ndjson/mcp must not show prompt UI +9. **Ctrl+C handling:** Cancel during prompt must exit cleanly (exit code 130) +10. **Input timeout:** 5-minute timeout must cancel session gracefully + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Terminal rendering differs between pterm and lipgloss | Low | Visual testing on Windows Terminal, PowerShell | +| PromptPhase as standalone program may behave differently than inside full TUI | Medium | Thorough testing of standalone runner | +| Warning formatter import cycle | Medium | Extract to shared package if needed | +| Cursor/terminal state leak from mini program | Low | Ensure tea.Program cleanup in RunPrompt | +| Regression in Ctrl+C handling | Medium | Test isExiting flag interaction with tea.KeyCtrlC | + +## Rollout + +1. Implement Steps 1-4 behind the existing code (new function, not yet called) +2. Add feature flag or A/B path in handleAskHuman for gradual rollout +3. Once validated, remove old pterm code path and dead files (Step 5) +4. This unblocks full Phase 3 wiring (future plan) by proving PromptPhase works in production + +## See Also + +- [10-tui-framework.md](./10-tui-framework.md) — TUI framework plan (Phase 3 pending) +- [14-replace-prod-dbheader-with-tui.md](./14-replace-prod-dbheader-with-tui.md) — DB header extraction (same pattern) +- [15-replace-emoji-start-with-braille-spinner.md](./15-replace-emoji-start-with-braille-spinner.md) — Braille spinner extraction +- [TUI Framework Developer Guide](../development/TUI_FRAMEWORK.md) — Component architecture From fdec42f10069c6f49730e2b05450a2f7d7803ffe Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:29:06 +0100 Subject: [PATCH 28/38] feat: extend AskHumanMsg with ScopeIssues and PromptWarnings Add ScopeIssues field to AskHumanMsg for structured scope issue passthrough from proto. Add PromptWarnings and PromptOption types to bridge orchestration warning data to TUI PromptPhase. Update convertAskHuman to populate ScopeIssues from proto UserQuestion. --- internal/ui/tui/bridge.go | 1 + internal/ui/tui/events.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/ui/tui/bridge.go b/internal/ui/tui/bridge.go index 556e137..02b853d 100644 --- a/internal/ui/tui/bridge.go +++ b/internal/ui/tui/bridge.go @@ -227,6 +227,7 @@ func (eb *EventBridge) convertAskHuman(ev seeding.Event) AskHumanMsg { QuestionID: q.QuestionId, QuestionText: q.QuestionText, ContextTables: q.ContextTables, + ScopeIssues: q.ScopeIssues, } } return AskHumanMsg{} diff --git a/internal/ui/tui/events.go b/internal/ui/tui/events.go index 5cc6bf0..13af7ec 100644 --- a/internal/ui/tui/events.go +++ b/internal/ui/tui/events.go @@ -2,6 +2,8 @@ package tui import ( "time" + + dbpb "seedfast/cli/internal/bridge/proto" ) // --- gRPC data events (from DataMessage) --- @@ -49,6 +51,7 @@ type AskHumanMsg struct { QuestionID string QuestionText string ContextTables []string + ScopeIssues []*dbpb.ScopeIssue // Structured scope issues from server } // --- gRPC progress events (from ProgressMessage) --- @@ -131,3 +134,19 @@ type CancellationSentMsg struct{} // TickMsg drives spinner/progress animation at regular intervals. type TickMsg time.Time + +// --- Standalone prompt types --- + +// PromptWarnings holds pre-parsed warning data for the prompt phase. +// Populated by orchestration (warning_formatter), consumed by PromptPhase. +type PromptWarnings struct { + WarningLines []string // Raw warning text lines (PromptPhase styles with lipgloss) + Options []PromptOption // Selector options (when empty, normal text input flow) + Prompt string // Override question text (e.g., "How would you like to proceed?") +} + +// PromptOption represents a selectable option in the warning selector. +type PromptOption struct { + Label string // Display text + Answer string // Answer to send back. Empty = fall through to text input. +} From 53da2371877dbb44b69f82342a5a7463468577e2 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:29:11 +0100 Subject: [PATCH 29/38] feat: add selector component for TUI prompt Arrow-key navigation selector following the TextInputComponent pattern. Supports PromptOption items with Label/Answer pairs, keyboard navigation, and cancel via Ctrl+C/Esc. --- internal/ui/tui/components/selector.go | 99 ++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 internal/ui/tui/components/selector.go diff --git a/internal/ui/tui/components/selector.go b/internal/ui/tui/components/selector.go new file mode 100644 index 0000000..bd01b71 --- /dev/null +++ b/internal/ui/tui/components/selector.go @@ -0,0 +1,99 @@ +package components + +import ( + "seedfast/cli/internal/ui/tui" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SelectorResult signals the selector is complete. +type SelectorResult struct { + Index int + Label string + Answer string + Cancelled bool +} + +// SelectorComponent renders a list of options with arrow-key navigation. +type SelectorComponent struct { + options []tui.PromptOption + cursor int + done bool + theme *tui.Theme +} + +func NewSelector(theme *tui.Theme, options []tui.PromptOption) SelectorComponent { + return SelectorComponent{ + options: options, + theme: theme, + } +} + +func (s SelectorComponent) Init() tea.Cmd { + return nil +} + +func (s SelectorComponent) Update(msg tea.Msg) (SelectorComponent, tea.Cmd, *SelectorResult) { + if s.done { + return s, nil, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp, tea.KeyShiftTab: + if s.cursor > 0 { + s.cursor-- + } + case tea.KeyDown, tea.KeyTab: + if s.cursor < len(s.options)-1 { + s.cursor++ + } + case tea.KeyEnter: + s.done = true + opt := s.options[s.cursor] + return s, nil, &SelectorResult{ + Index: s.cursor, + Label: opt.Label, + Answer: opt.Answer, + } + case tea.KeyCtrlC, tea.KeyEsc: + s.done = true + return s, nil, &SelectorResult{Cancelled: true} + } + } + return s, nil, nil +} + +func (s SelectorComponent) View() string { + if s.done { + return "" + } + + cursorStyle := lipgloss.NewStyle().Foreground(s.theme.Secondary) + activeStyle := lipgloss.NewStyle().Bold(true) + inactiveStyle := s.theme.Dim + hintStyle := s.theme.Dim + + var lines []string + for i, opt := range s.options { + if s.cursor == i { + lines = append(lines, cursorStyle.Render("> ")+activeStyle.Render(opt.Label)) + } else { + lines = append(lines, " "+inactiveStyle.Render(opt.Label)) + } + } + + lines = append(lines, "") + lines = append(lines, hintStyle.Render("Use arrow keys to navigate, Enter to select")) + + result := "\n" + for i, l := range lines { + if i > 0 { + result += "\n" + } + result += l + } + return result + "\n" +} From 0ad2f33cdd979a9c1d1378ca1c7e4dec67ff2db5 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:29:18 +0100 Subject: [PATCH 30/38] feat: add warning support to PromptPhase Accept optional PromptWarnings in constructor. When warnings with options are present, show styled warning lines and selector instead of text input. Selector "describe" option falls through to text input mode. Factory passes nil to preserve existing TUI behavior. --- internal/ui/tui/phases/factory.go | 2 +- internal/ui/tui/phases/prompt_phase.go | 71 +++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/internal/ui/tui/phases/factory.go b/internal/ui/tui/phases/factory.go index 628d390..ef0e816 100644 --- a/internal/ui/tui/phases/factory.go +++ b/internal/ui/tui/phases/factory.go @@ -39,7 +39,7 @@ func NewPhaseFactory(theme *tui.Theme, bridge *tui.EventBridge, autoApprove bool case tui.PhasePrompt: if msg, ok := data.(tui.AskHumanMsg); ok { - return NewPromptPhase(theme, bridge, &msg, autoApprove, scopeTooLarge) + return NewPromptPhase(theme, bridge, &msg, autoApprove, scopeTooLarge, nil) } case tui.PhaseSeeding: diff --git a/internal/ui/tui/phases/prompt_phase.go b/internal/ui/tui/phases/prompt_phase.go index f3c68e0..3fa9a9d 100644 --- a/internal/ui/tui/phases/prompt_phase.go +++ b/internal/ui/tui/phases/prompt_phase.go @@ -37,17 +37,20 @@ var scopeHintPhrases = []string{ type PromptPhase struct { theme *tui.Theme textInput components.TextInputComponent + selector components.SelectorComponent bridge *tui.EventBridge question *tui.AskHumanMsg autoApprove bool scopeTooLarge bool + hasWarnings bool + warnings *tui.PromptWarnings answered bool waiting bool answerEcho string spinner components.SpinnerComponent } -func NewPromptPhase(theme *tui.Theme, bridge *tui.EventBridge, question *tui.AskHumanMsg, autoApprove bool, scopeTooLarge bool) *PromptPhase { +func NewPromptPhase(theme *tui.Theme, bridge *tui.EventBridge, question *tui.AskHumanMsg, autoApprove bool, scopeTooLarge bool, warnings *tui.PromptWarnings) *PromptPhase { // Select placeholder based on mode. var placeholder string if scopeTooLarge { @@ -56,7 +59,7 @@ func NewPromptPhase(theme *tui.Theme, bridge *tui.EventBridge, question *tui.Ask placeholder = scopeHintPhrases[rand.Intn(len(scopeHintPhrases))] } - return &PromptPhase{ + p := &PromptPhase{ theme: theme, textInput: components.NewTextInput(theme, placeholder, "Your answer:", 5*time.Minute), bridge: bridge, @@ -64,7 +67,15 @@ func NewPromptPhase(theme *tui.Theme, bridge *tui.EventBridge, question *tui.Ask autoApprove: autoApprove, scopeTooLarge: scopeTooLarge, spinner: components.NewSpinner(theme, components.SpinnerBraille, "Processing..."), + warnings: warnings, } + + if warnings != nil && len(warnings.Options) > 0 { + p.hasWarnings = true + p.selector = components.NewSelector(theme, warnings.Options) + } + + return p } func (p *PromptPhase) ID() tui.PhaseID { return tui.PhasePrompt } @@ -127,6 +138,29 @@ func (p *PromptPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) return p, nil, nil } + // Warning selector mode: forward to selector component + if p.hasWarnings { + var sResult *components.SelectorResult + var cmd tea.Cmd + p.selector, cmd, sResult = p.selector.Update(msg) + + if sResult != nil { + p.answered = true + if sResult.Cancelled { + return p, p.bridge.SendCancellationCmd("user cancelled"), nil + } + if sResult.Answer == "" { + // Fall through to text input mode + p.hasWarnings = false + return p, p.textInput.Init(), nil + } + p.answerEcho = "Selected: " + sResult.Label + p.waiting = true + return p, p.bridge.SendHumanResponseCmd(p.question.QuestionID, false, sResult.Answer), nil + } + return p, cmd, nil + } + // Forward to text input var result *components.TextInputResult var cmd tea.Cmd @@ -194,6 +228,39 @@ func (p *PromptPhase) View() string { // Question text in yellow+bold questionStyle := lipgloss.NewStyle().Foreground(p.theme.Accent).Bold(true) + + // Warning selector mode: show warning lines + selector + if p.hasWarnings && p.warnings != nil { + warningStyle := lipgloss.NewStyle().Foreground(p.theme.Accent).Bold(true) + bulletStyle := lipgloss.NewStyle().Foreground(p.theme.Accent) + suggestionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00CED1")) + + for _, line := range p.warnings.WarningLines { + if strings.HasPrefix(line, "\u26a0") { + s += warningStyle.Render(line) + "\n" + } else if strings.HasPrefix(line, " \u2192") { + s += suggestionStyle.Render(line) + "\n" + } else if strings.HasPrefix(line, " \u25b8") { + s += bulletStyle.Render(" \u25b8") + line[len(" \u25b8"):] + "\n" + } else { + s += line + "\n" + } + } + s += "\n" + + // Show question prompt from warnings + promptText := "How would you like to proceed?" + if p.warnings.Prompt != "" { + promptText = p.warnings.Prompt + } + s += questionStyle.Render(promptText) + "\n" + + // Selector + s += p.selector.View() + return s + } + + // Standard mode: question + text input greenStyle := lipgloss.NewStyle().Foreground(p.theme.Secondary) redStyle := lipgloss.NewStyle().Foreground(p.theme.Error) dimStyle := p.theme.Dim From 7322ac33fc95e402b3a3b2959658ff5b566c9bfc Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:29:25 +0100 Subject: [PATCH 31/38] feat: wire handleAskHuman to TUI prompt runner Add RunPrompt standalone Bubble Tea program that wraps PromptPhase with promptActions to capture results without gRPC. Add buildPromptWarnings to convert warningFormatResult to TUI types. Replace pterm prompt rendering and runScopeInput/runScopeSelector calls in handleAskHuman with single RunPrompt call. --- internal/orchestration/event_handler.go | 70 +++----- internal/orchestration/prompt_runner.go | 230 ++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 47 deletions(-) create mode 100644 internal/orchestration/prompt_runner.go diff --git a/internal/orchestration/event_handler.go b/internal/orchestration/event_handler.go index 6d1d24c..2410323 100644 --- a/internal/orchestration/event_handler.go +++ b/internal/orchestration/event_handler.go @@ -15,6 +15,7 @@ import ( "seedfast/cli/internal/logging" "seedfast/cli/internal/seeding" "seedfast/cli/internal/ui" + "seedfast/cli/internal/ui/tui" "atomicgo.dev/cursor" "github.com/pterm/pterm" @@ -587,67 +588,42 @@ func (eh *EventHandler) handleAskHuman(ev seeding.Event) error { return nil } - // Determine question and options based on mode (replan vs normal) - var prompt string - var options []string - var warningBlock string - var warningResult warningFormatResult - + // Parse warnings for TUI prompt + var warnings *tui.PromptWarnings if eh.state.ScopeTooLargeMode { - // Replan mode (SCOPE_TOO_LARGE error) - prompt = "Would you mind proposing us the updated scope?" - options = []string{ - " • Describe the refined scope here", - " • Or cancel current seeding", - } + // Replan mode — no warnings, PromptPhase handles scopeTooLarge internally } else { - // Normal mode — prefer structured issues, fallback to regex parsing + var warningResult warningFormatResult if len(protoIssues) > 0 { warningResult = formatStructuredWarnings(protoIssues) } else if strings.TrimSpace(question) != "" { warningResult = formatQuestionWarnings(question) } - warningBlock = warningResult.WarningRendered - prompt = warningResult.Prompt - if prompt == "" { - prompt = "Do you agree with this seeding scope?" - } - - if !warningResult.HasIssues { - // No blocking issues — show normal approval options - dim := pterm.NewStyle(pterm.FgGray) - options = []string{ - " • Press " + pterm.NewStyle(pterm.FgGreen).Sprint("'Enter'") + " or type " + pterm.NewStyle(pterm.FgGreen).Sprint("yes") + " to accept and continue", - " • Type " + pterm.NewStyle(pterm.FgRed).Sprint("no") + " to reject", - " • Or describe what you want, for example:", - " " + dim.Sprint("\"use only English names and emails\""), - " " + dim.Sprint("\"seed 500 records per table\""), - " " + dim.Sprint("\"generate e-commerce data with realistic products\""), - } - } - // When HasIssues is true, options are nil — the selector UI handles it + warnings = buildPromptWarnings(warningResult) } - // Display warning block (if any blocking issues were detected in the question) - pterm.Println() - if warningBlock != "" { - pterm.Println(warningBlock) + // Run TUI prompt + result, err := RunPrompt(eh.ctx, PromptConfig{ + QuestionID: questionID, + QuestionText: question, + AutoApprove: false, // auto-approve already handled above + ScopeTooLarge: eh.state.ScopeTooLargeMode, + Warnings: warnings, + Timeout: 5 * time.Minute, + }) + if err != nil { + return err } - // Display question prompt - pterm.Println(pterm.NewStyle(pterm.FgYellow, pterm.Bold).Sprint(prompt)) - for _, opt := range options { - pterm.Println(opt) + if result.Cancelled { + eh.isExiting.Store(true) + return nil } - pterm.Println() - - // If blocking issues detected, use the selector UI instead of free text input - if warningResult.HasIssues { - return eh.handleWarningSelection(questionID, warningResult) + if result.TimedOut { + return eh.handleInputTimeout() } - // Normal flow: text input for approval/refinement - return eh.handleTextInput(questionID) + return eh.sendAnswer(questionID, result.Answer) } // selectorOption pairs a display label with the answer to send to the backend. diff --git a/internal/orchestration/prompt_runner.go b/internal/orchestration/prompt_runner.go new file mode 100644 index 0000000..fa11be5 --- /dev/null +++ b/internal/orchestration/prompt_runner.go @@ -0,0 +1,230 @@ +package orchestration + +import ( + "context" + "errors" + "strings" + "time" + + "seedfast/cli/internal/seeding" + "seedfast/cli/internal/ui/tui" + "seedfast/cli/internal/ui/tui/phases" + + tea "github.com/charmbracelet/bubbletea" +) + +// promptResult holds the outcome of a standalone prompt run. +type promptResult struct { + Answer string + Approved bool + Cancelled bool + TimedOut bool +} + +// Errors returned by RunPrompt. +var ( + errPromptCancelled = errors.New("prompt cancelled by user") + errPromptTimeout = errors.New("prompt input timeout") +) + +// PromptConfig configures a standalone prompt run. +type PromptConfig struct { + QuestionID string + QuestionText string + AutoApprove bool + ScopeTooLarge bool + Warnings *tui.PromptWarnings + Timeout time.Duration +} + +// promptActions captures responses from PromptPhase without sending to gRPC. +type promptActions struct { + resultCh chan promptResult +} + +func (pa *promptActions) SendHumanResponse(ctx context.Context, questionID string, responseData map[string]any) error { + result := promptResult{} + if answer, ok := responseData["answer"].(map[string]any); ok { + if raw, ok := answer["raw"].(string); ok { + result.Answer = raw + result.Approved = (raw == "") + } + } + select { + case pa.resultCh <- result: + default: + } + return nil +} + +func (pa *promptActions) SendCancellation(ctx context.Context, reason string) error { + result := promptResult{Cancelled: true} + if reason == "input timed out" { + result.TimedOut = true + result.Cancelled = false + } + select { + case pa.resultCh <- result: + default: + } + return nil +} + +// promptApp wraps a PromptPhase as a standalone tea.Model. +// It intercepts terminal messages (HumanResponseSentMsg, CancellationSentMsg) +// and quits the program instead of waiting for phase transitions. +type promptApp struct { + phase tui.Phase +} + +func (m promptApp) Init() tea.Cmd { + return m.phase.Init() +} + +func (m promptApp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case tui.HumanResponseSentMsg: + return m, tea.Quit + case tui.CancellationSentMsg: + return m, tea.Quit + } + + newPhase, cmd, _ := m.phase.Update(msg) + m.phase = newPhase + return m, cmd +} + +func (m promptApp) View() string { + return m.phase.View() +} + +// RunPrompt executes the TUI PromptPhase as a standalone Bubble Tea program. +// It replaces the combination of pterm rendering + runScopeInput + runScopeSelector +// with a single unified TUI program. +func RunPrompt(ctx context.Context, cfg PromptConfig) (promptResult, error) { + resultCh := make(chan promptResult, 1) + actions := &promptActions{resultCh: resultCh} + + // Create a dummy events channel (never sends - standalone mode) + events := make(chan seeding.Event) + bridge := tui.NewEventBridge(ctx, events, actions) + + question := &tui.AskHumanMsg{ + QuestionID: cfg.QuestionID, + QuestionText: cfg.QuestionText, + } + + theme := tui.DefaultTheme() + phase := phases.NewPromptPhase(theme, bridge, question, cfg.AutoApprove, cfg.ScopeTooLarge, cfg.Warnings) + + model := promptApp{phase: phase} + p := tea.NewProgram(model, tea.WithContext(ctx)) + + _, err := p.Run() + if err != nil { + return promptResult{}, err + } + + select { + case result := <-resultCh: + return result, nil + default: + // Program exited without sending result (e.g., context cancelled) + return promptResult{Cancelled: true}, nil + } +} + +// buildPromptWarnings converts orchestration's warningFormatResult to tui.PromptWarnings. +// This bridges the pterm-based warning parsing with the TUI rendering. +func buildPromptWarnings(result warningFormatResult) *tui.PromptWarnings { + if !result.HasIssues { + return nil + } + + // Build warning lines from the pterm-rendered text. + // Strip ANSI codes since TUI will re-render with lipgloss. + rawLines := stripAnsi(result.WarningRendered) + lines := splitNonEmpty(rawLines) + + // Build selector options (same logic as handleWarningSelection) + var options []tui.PromptOption + + depsStr := joinComma(result.DependencyTables) + affectedStr := joinComma(result.AffectedTables) + + if result.HasDependencyEmpty && depsStr != "" { + options = append(options, tui.PromptOption{ + Label: "Add missing dependencies to scope: " + depsStr, + Answer: "Add the missing dependencies to scope: " + depsStr, + }) + } + + if result.HasFeasibility && len(result.SuggestedResolutions) > 0 { + options = append(options, tui.PromptOption{ + Label: "Apply suggested fixes (adjust record counts)", + Answer: joinDot(result.SuggestedResolutions), + }) + } + + if len(result.AffectedTables) > 0 { + options = append(options, tui.PromptOption{ + Label: "Remove affected tables from scope: " + affectedStr, + Answer: "Remove the affected tables from scope: " + affectedStr, + }) + } + + // Always add "Describe manually" option (empty answer = fall through to text input) + options = append(options, tui.PromptOption{ + Label: "Describe what you want instead", + Answer: "", + }) + + return &tui.PromptWarnings{ + WarningLines: lines, + Options: options, + Prompt: result.Prompt, + } +} + +// --- helpers --- + +// stripAnsi removes ANSI escape sequences from a string. +func stripAnsi(s string) string { + result := make([]byte, 0, len(s)) + i := 0 + for i < len(s) { + if s[i] == '\033' && i+1 < len(s) && s[i+1] == '[' { + // Skip until 'm' + j := i + 2 + for j < len(s) && s[j] != 'm' { + j++ + } + if j < len(s) { + i = j + 1 + continue + } + } + result = append(result, s[i]) + i++ + } + return string(result) +} + +// splitNonEmpty splits a string by newlines and returns non-empty lines. +func splitNonEmpty(s string) []string { + var lines []string + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + lines = append(lines, line) + } + } + return lines +} + +func joinComma(ss []string) string { + return strings.Join(ss, ", ") +} + +func joinDot(ss []string) string { + return strings.Join(ss, ". ") +} From c28289869bc4ac0c1e1b20adf17914910a06dd2c Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:42:22 +0100 Subject: [PATCH 32/38] test: add tests for TUI prompt wiring Cover SelectorComponent navigation, PromptPhase warning mode, promptActions channel behavior, buildPromptWarnings option building, helper functions, promptApp delegation, and EventBridge ScopeIssues. --- internal/orchestration/prompt_runner_test.go | 659 +++++++++++++++++++ internal/ui/tui/bridge_test.go | 104 +++ internal/ui/tui/components/selector_test.go | 321 +++++++++ internal/ui/tui/phases/prompt_phase_test.go | 426 ++++++++++++ 4 files changed, 1510 insertions(+) create mode 100644 internal/orchestration/prompt_runner_test.go create mode 100644 internal/ui/tui/components/selector_test.go create mode 100644 internal/ui/tui/phases/prompt_phase_test.go diff --git a/internal/orchestration/prompt_runner_test.go b/internal/orchestration/prompt_runner_test.go new file mode 100644 index 0000000..372f4d2 --- /dev/null +++ b/internal/orchestration/prompt_runner_test.go @@ -0,0 +1,659 @@ +package orchestration + +import ( + "strings" + "testing" + + "seedfast/cli/internal/ui/tui" + + tea "github.com/charmbracelet/bubbletea" +) + +// ============================================================================= +// Category 3: promptActions tests +// ============================================================================= + +func TestPromptActions_SendHumanResponse_EmptyAnswer(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + responseData := map[string]any{ + "answer": map[string]any{"raw": ""}, + } + + err := pa.SendHumanResponse(nil, "q-1", responseData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := <-pa.resultCh + if result.Answer != "" { + t.Errorf("expected empty Answer, got %q", result.Answer) + } + if !result.Approved { + t.Error("expected Approved=true for empty answer") + } + if result.Cancelled { + t.Error("expected Cancelled=false") + } + if result.TimedOut { + t.Error("expected TimedOut=false") + } +} + +func TestPromptActions_SendHumanResponse_NonEmptyAnswer(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + responseData := map[string]any{ + "answer": map[string]any{"raw": "use English"}, + } + + err := pa.SendHumanResponse(nil, "q-1", responseData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := <-pa.resultCh + if result.Answer != "use English" { + t.Errorf("expected Answer='use English', got %q", result.Answer) + } + if result.Approved { + t.Error("expected Approved=false for non-empty answer") + } + if result.Cancelled { + t.Error("expected Cancelled=false") + } +} + +func TestPromptActions_SendHumanResponse_MissingAnswerKey(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + responseData := map[string]any{ + "other_key": 123, + } + + err := pa.SendHumanResponse(nil, "q-1", responseData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := <-pa.resultCh + if result.Answer != "" { + t.Errorf("expected empty Answer when key missing, got %q", result.Answer) + } + if result.Approved { + t.Error("expected Approved=false when answer key missing") + } + if result.Cancelled { + t.Error("expected Cancelled=false") + } +} + +func TestPromptActions_SendHumanResponse_MalformedAnswer(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + responseData := map[string]any{ + "answer": "not a map", + } + + err := pa.SendHumanResponse(nil, "q-1", responseData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := <-pa.resultCh + if result.Answer != "" { + t.Errorf("expected empty Answer for malformed data, got %q", result.Answer) + } + if result.Approved { + t.Error("expected Approved=false for malformed answer") + } +} + +func TestPromptActions_SendCancellation_UserCancelled(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + + err := pa.SendCancellation(nil, "user cancelled") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := <-pa.resultCh + if !result.Cancelled { + t.Error("expected Cancelled=true") + } + if result.TimedOut { + t.Error("expected TimedOut=false for user cancellation") + } +} + +func TestPromptActions_SendCancellation_InputTimedOut(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + + err := pa.SendCancellation(nil, "input timed out") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := <-pa.resultCh + if result.Cancelled { + t.Error("expected Cancelled=false for 'input timed out'") + } + if !result.TimedOut { + t.Error("expected TimedOut=true for 'input timed out'") + } +} + +func TestPromptActions_SendCancellation_OtherReason(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + + err := pa.SendCancellation(nil, "some other reason") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := <-pa.resultCh + if !result.Cancelled { + t.Error("expected Cancelled=true for other reason") + } + if result.TimedOut { + t.Error("expected TimedOut=false for other reason") + } +} + +func TestPromptActions_NonBlockingWhenChannelFull(t *testing.T) { + pa := &promptActions{resultCh: make(chan promptResult, 1)} + + // Fill the channel + pa.resultCh <- promptResult{Answer: "first"} + + // Second send should not block (non-blocking select) + err := pa.SendHumanResponse(nil, "q-1", map[string]any{ + "answer": map[string]any{"raw": "second"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Original value should still be in channel + result := <-pa.resultCh + if result.Answer != "first" { + t.Errorf("expected first value to remain, got %q", result.Answer) + } +} + +// ============================================================================= +// Category 4: buildPromptWarnings tests +// ============================================================================= + +func TestBuildPromptWarnings_NoIssues(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{HasIssues: false}) + if result != nil { + t.Error("expected nil when HasIssues=false") + } +} + +func TestBuildPromptWarnings_StripsAnsi(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + WarningRendered: "\033[33mWarning\033[0m text", + Prompt: "Question?", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + for _, line := range result.WarningLines { + if strings.Contains(line, "\033") { + t.Errorf("WarningLines should not contain ANSI codes, got %q", line) + } + } + // Check the actual content is preserved + found := false + for _, line := range result.WarningLines { + if strings.Contains(line, "Warning") && strings.Contains(line, "text") { + found = true + break + } + } + if !found { + t.Errorf("expected warning content to be preserved, got %v", result.WarningLines) + } +} + +func TestBuildPromptWarnings_DependencyEmptyOptions(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + HasDependencyEmpty: true, + DependencyTables: []string{"org.employees"}, + WarningRendered: "some warning", + Prompt: "How?", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + + // Should have at least 2 options: "Add missing deps" + "Describe" + if len(result.Options) < 2 { + t.Fatalf("expected at least 2 options, got %d", len(result.Options)) + } + + firstOpt := result.Options[0] + if !strings.HasPrefix(firstOpt.Label, "Add missing dependencies") { + t.Errorf("first option should start with 'Add missing dependencies', got %q", firstOpt.Label) + } + if !strings.Contains(firstOpt.Label, "org.employees") { + t.Errorf("first option should contain 'org.employees', got %q", firstOpt.Label) + } + if !strings.Contains(firstOpt.Answer, "org.employees") { + t.Errorf("first option answer should contain 'org.employees', got %q", firstOpt.Answer) + } + + // Last option is always "Describe" + lastOpt := result.Options[len(result.Options)-1] + if lastOpt.Label != "Describe what you want instead" { + t.Errorf("last option should be 'Describe what you want instead', got %q", lastOpt.Label) + } +} + +func TestBuildPromptWarnings_FeasibilityOptions(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + HasFeasibility: true, + SuggestedResolutions: []string{"Reduce X to 50"}, + WarningRendered: "some warning", + Prompt: "How?", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + + // Find the "Apply suggested fixes" option + found := false + for _, opt := range result.Options { + if opt.Label == "Apply suggested fixes (adjust record counts)" { + found = true + if opt.Answer != "Reduce X to 50" { + t.Errorf("expected Answer='Reduce X to 50', got %q", opt.Answer) + } + break + } + } + if !found { + t.Error("expected to find 'Apply suggested fixes (adjust record counts)' option") + } +} + +func TestBuildPromptWarnings_CombinedIssueOptions(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + HasDependencyEmpty: true, + HasFeasibility: true, + DependencyTables: []string{"t1"}, + AffectedTables: []string{"t2"}, + SuggestedResolutions: []string{"Fix it"}, + WarningRendered: "some warning", + Prompt: "How?", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + + // Should have exactly 4 options: + // 1. Add missing deps (t1) + // 2. Apply suggested fixes + // 3. Remove affected tables (t2) + // 4. Describe what you want instead + if len(result.Options) != 4 { + t.Fatalf("expected 4 options, got %d: %+v", len(result.Options), result.Options) + } + + if !strings.Contains(result.Options[0].Label, "Add missing dependencies") { + t.Errorf("option[0] should be 'Add missing dependencies', got %q", result.Options[0].Label) + } + if !strings.Contains(result.Options[0].Label, "t1") { + t.Errorf("option[0] should contain 't1', got %q", result.Options[0].Label) + } + if result.Options[1].Label != "Apply suggested fixes (adjust record counts)" { + t.Errorf("option[1] should be 'Apply suggested fixes', got %q", result.Options[1].Label) + } + if !strings.Contains(result.Options[2].Label, "Remove affected tables") { + t.Errorf("option[2] should contain 'Remove affected tables', got %q", result.Options[2].Label) + } + if !strings.Contains(result.Options[2].Label, "t2") { + t.Errorf("option[2] should contain 't2', got %q", result.Options[2].Label) + } + if result.Options[3].Label != "Describe what you want instead" { + t.Errorf("option[3] should be 'Describe what you want instead', got %q", result.Options[3].Label) + } + if result.Options[3].Answer != "" { + t.Errorf("option[3] answer should be empty, got %q", result.Options[3].Answer) + } +} + +func TestBuildPromptWarnings_DescribeAlwaysLast(t *testing.T) { + // Test with various combinations to ensure "Describe" is always last + cases := []warningFormatResult{ + {HasIssues: true, HasDependencyEmpty: true, DependencyTables: []string{"a"}, WarningRendered: "w", Prompt: "p"}, + {HasIssues: true, HasFeasibility: true, SuggestedResolutions: []string{"fix"}, WarningRendered: "w", Prompt: "p"}, + {HasIssues: true, WarningRendered: "w", Prompt: "p"}, + } + + for i, c := range cases { + result := buildPromptWarnings(c) + if result == nil { + t.Fatalf("case %d: expected non-nil result", i) + } + if len(result.Options) == 0 { + t.Fatalf("case %d: expected at least 1 option", i) + } + lastOpt := result.Options[len(result.Options)-1] + if lastOpt.Label != "Describe what you want instead" { + t.Errorf("case %d: last option should be 'Describe what you want instead', got %q", i, lastOpt.Label) + } + if lastOpt.Answer != "" { + t.Errorf("case %d: last option answer should be empty, got %q", i, lastOpt.Answer) + } + } +} + +func TestBuildPromptWarnings_PreservesPrompt(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + WarningRendered: "w", + Prompt: "How to fix?", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Prompt != "How to fix?" { + t.Errorf("expected Prompt='How to fix?', got %q", result.Prompt) + } +} + +func TestBuildPromptWarnings_EmptyDependencyTables(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + HasDependencyEmpty: true, + DependencyTables: []string{}, // empty + WarningRendered: "w", + Prompt: "p", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + + // Should NOT have "Add missing dependencies" option because depsStr is "" + for _, opt := range result.Options { + if strings.HasPrefix(opt.Label, "Add missing dependencies") { + t.Error("should NOT create 'Add missing dependencies' option when DependencyTables is empty") + } + } +} + +func TestBuildPromptWarnings_AffectedTablesOption(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + AffectedTables: []string{"a", "b"}, + WarningRendered: "w", + Prompt: "p", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + + found := false + for _, opt := range result.Options { + if strings.Contains(opt.Label, "Remove affected tables") && strings.Contains(opt.Label, "a, b") { + found = true + if !strings.Contains(opt.Answer, "a, b") { + t.Errorf("answer should contain 'a, b', got %q", opt.Answer) + } + break + } + } + if !found { + t.Error("expected 'Remove affected tables from scope: a, b' option") + } +} + +func TestBuildPromptWarnings_NoAffectedTables(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + AffectedTables: []string{}, // empty + WarningRendered: "w", + Prompt: "p", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + + for _, opt := range result.Options { + if strings.Contains(opt.Label, "Remove affected tables") { + t.Error("should NOT create 'Remove affected tables' option when AffectedTables is empty") + } + } +} + +func TestBuildPromptWarnings_SplitsNonEmptyLines(t *testing.T) { + result := buildPromptWarnings(warningFormatResult{ + HasIssues: true, + WarningRendered: "line1\n\nline2\n \nline3", + Prompt: "p", + }) + if result == nil { + t.Fatal("expected non-nil result") + } + + for _, line := range result.WarningLines { + if strings.TrimSpace(line) == "" { + t.Errorf("WarningLines should not contain blank lines, got %q", line) + } + } + if len(result.WarningLines) != 3 { + t.Errorf("expected 3 warning lines, got %d: %v", len(result.WarningLines), result.WarningLines) + } +} + +// ============================================================================= +// Category 5: Helper function tests +// ============================================================================= + +func TestStripAnsi_RemovesEscSequences(t *testing.T) { + input := "\033[33mhello\033[0m world" + got := stripAnsi(input) + if got != "hello world" { + t.Errorf("expected 'hello world', got %q", got) + } +} + +func TestStripAnsi_NoAnsi(t *testing.T) { + input := "plain text" + got := stripAnsi(input) + if got != "plain text" { + t.Errorf("expected 'plain text', got %q", got) + } +} + +func TestStripAnsi_Empty(t *testing.T) { + got := stripAnsi("") + if got != "" { + t.Errorf("expected empty string, got %q", got) + } +} + +func TestStripAnsi_MultipleCodes(t *testing.T) { + input := "\033[1m\033[33mbold yellow\033[0m" + got := stripAnsi(input) + if got != "bold yellow" { + t.Errorf("expected 'bold yellow', got %q", got) + } +} + +func TestStripAnsi_IncompleteEscape(t *testing.T) { + // Incomplete escape (no 'm' terminator) -- should not panic + input := "\033[no-m-here" + got := stripAnsi(input) + // The function should not panic; exact output depends on implementation + // but should contain the non-escape characters + if len(got) == 0 { + // The \033 and [ will be consumed looking for 'm', and since + // there's no 'm', the loop exits. The remaining characters from + // index 0 onward where \033 is found get appended including \033 byte. + // As long as no panic, we're good. + } + // Main check: no panic occurred +} + +func TestSplitNonEmpty_FiltersBlankLines(t *testing.T) { + got := splitNonEmpty("a\n\nb\n \nc") + if len(got) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(got), got) + } + if got[0] != "a" || got[1] != "b" || got[2] != "c" { + t.Errorf("expected [a, b, c], got %v", got) + } +} + +func TestSplitNonEmpty_EmptyInput(t *testing.T) { + got := splitNonEmpty("") + if got != nil { + t.Errorf("expected nil for empty input, got %v", got) + } +} + +func TestSplitNonEmpty_AllBlank(t *testing.T) { + got := splitNonEmpty("\n\n\n") + if got != nil { + t.Errorf("expected nil for all-blank input, got %v", got) + } +} + +func TestJoinComma(t *testing.T) { + got := joinComma([]string{"a", "b", "c"}) + if got != "a, b, c" { + t.Errorf("expected 'a, b, c', got %q", got) + } +} + +func TestJoinComma_Single(t *testing.T) { + got := joinComma([]string{"a"}) + if got != "a" { + t.Errorf("expected 'a', got %q", got) + } +} + +func TestJoinComma_Empty(t *testing.T) { + got := joinComma([]string{}) + if got != "" { + t.Errorf("expected empty string, got %q", got) + } +} + +func TestJoinDot(t *testing.T) { + got := joinDot([]string{"Fix A", "Fix B"}) + if got != "Fix A. Fix B" { + t.Errorf("expected 'Fix A. Fix B', got %q", got) + } +} + +func TestJoinDot_Single(t *testing.T) { + got := joinDot([]string{"Fix A"}) + if got != "Fix A" { + t.Errorf("expected 'Fix A', got %q", got) + } +} + +// ============================================================================= +// Category 7: promptApp tests +// ============================================================================= + +// mockPhase tracks calls for testing promptApp delegation. +type mockPhase struct { + initCalled bool + updateCalled bool + lastMsg tea.Msg + viewText string +} + +func (m *mockPhase) ID() tui.PhaseID { return tui.PhasePrompt } + +func (m *mockPhase) Init() tea.Cmd { + m.initCalled = true + return nil +} + +func (m *mockPhase) Update(msg tea.Msg) (tui.Phase, tea.Cmd, *tui.Transition) { + m.updateCalled = true + m.lastMsg = msg + return m, nil, nil +} + +func (m *mockPhase) View() string { + return m.viewText +} + +func TestPromptApp_HumanResponseSentMsg_Quits(t *testing.T) { + mock := &mockPhase{viewText: "mock view"} + app := promptApp{phase: mock} + + _, cmd := app.Update(tui.HumanResponseSentMsg{QuestionID: "q-1"}) + + if cmd == nil { + t.Fatal("expected non-nil cmd (tea.Quit) on HumanResponseSentMsg") + } + + // The phase's Update should NOT have been called + if mock.updateCalled { + t.Error("expected phase.Update NOT to be called for HumanResponseSentMsg") + } +} + +func TestPromptApp_CancellationSentMsg_Quits(t *testing.T) { + mock := &mockPhase{viewText: "mock view"} + app := promptApp{phase: mock} + + _, cmd := app.Update(tui.CancellationSentMsg{}) + + if cmd == nil { + t.Fatal("expected non-nil cmd (tea.Quit) on CancellationSentMsg") + } + + if mock.updateCalled { + t.Error("expected phase.Update NOT to be called for CancellationSentMsg") + } +} + +func TestPromptApp_OtherMsg_DelegatesToPhase(t *testing.T) { + mock := &mockPhase{viewText: "mock view"} + app := promptApp{phase: mock} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := app.Update(keyMsg) + + if !mock.updateCalled { + t.Error("expected phase.Update to be called for KeyMsg") + } + if km, ok := mock.lastMsg.(tea.KeyMsg); !ok || km.Type != tea.KeyEnter { + t.Errorf("expected lastMsg to be KeyMsg{Type: KeyEnter}, got %T", mock.lastMsg) + } + // Phase returns nil cmd, so promptApp should return nil + if cmd != nil { + t.Error("expected nil cmd when phase returns nil cmd") + } +} + +func TestPromptApp_View_DelegatesToPhase(t *testing.T) { + mock := &mockPhase{viewText: "test view content"} + app := promptApp{phase: mock} + + got := app.View() + if got != "test view content" { + t.Errorf("expected View to return phase's view, got %q", got) + } +} + +func TestPromptApp_Init_DelegatesToPhase(t *testing.T) { + mock := &mockPhase{viewText: "mock"} + app := promptApp{phase: mock} + + app.Init() + + if !mock.initCalled { + t.Error("expected phase.Init to be called") + } +} diff --git a/internal/ui/tui/bridge_test.go b/internal/ui/tui/bridge_test.go index 89bf391..525e9e3 100644 --- a/internal/ui/tui/bridge_test.go +++ b/internal/ui/tui/bridge_test.go @@ -511,6 +511,110 @@ func TestTableDoneWithoutRecords(t *testing.T) { } } +// --- Category 6: convertAskHuman ScopeIssues tests --- + +func TestConvertAskHuman_WithScopeIssues(t *testing.T) { + msg := listenOnce(t, seeding.Event{ + Type: seeding.EventType("ask_human"), + Data: &dbpb.UserQuestion{ + QuestionId: "q-scope", + QuestionText: "Resolve issues?", + ContextTables: []string{"users"}, + ScopeIssues: []*dbpb.ScopeIssue{ + { + IssueType: "dependency_empty", + AffectedTable: "pm.tasks", + DependencyTable: "org.employees", + Message: "pm.tasks depends on org.employees", + SuggestedResolution: "Add org.employees to scope", + }, + { + IssueType: "fk_feasibility", + AffectedTable: "hr.contracts", + DependencyTable: "hr.employees", + Message: "hr.contracts has too many records", + SuggestedResolution: "Reduce hr.contracts to 50", + }, + }, + }, + }) + + m, ok := msg.(AskHumanMsg) + if !ok { + t.Fatalf("expected AskHumanMsg, got %T", msg) + } + if m.QuestionID != "q-scope" { + t.Fatalf("expected QuestionID='q-scope', got %q", m.QuestionID) + } + if len(m.ScopeIssues) != 2 { + t.Fatalf("expected 2 ScopeIssues, got %d", len(m.ScopeIssues)) + } + + // Verify first issue + issue0 := m.ScopeIssues[0] + if issue0.IssueType != "dependency_empty" { + t.Errorf("issue[0].IssueType = %q, want 'dependency_empty'", issue0.IssueType) + } + if issue0.AffectedTable != "pm.tasks" { + t.Errorf("issue[0].AffectedTable = %q, want 'pm.tasks'", issue0.AffectedTable) + } + if issue0.DependencyTable != "org.employees" { + t.Errorf("issue[0].DependencyTable = %q, want 'org.employees'", issue0.DependencyTable) + } + if issue0.Message != "pm.tasks depends on org.employees" { + t.Errorf("issue[0].Message = %q, want 'pm.tasks depends on org.employees'", issue0.Message) + } + if issue0.SuggestedResolution != "Add org.employees to scope" { + t.Errorf("issue[0].SuggestedResolution = %q, want 'Add org.employees to scope'", issue0.SuggestedResolution) + } + + // Verify second issue + issue1 := m.ScopeIssues[1] + if issue1.IssueType != "fk_feasibility" { + t.Errorf("issue[1].IssueType = %q, want 'fk_feasibility'", issue1.IssueType) + } + if issue1.AffectedTable != "hr.contracts" { + t.Errorf("issue[1].AffectedTable = %q, want 'hr.contracts'", issue1.AffectedTable) + } +} + +func TestConvertAskHuman_NilScopeIssues(t *testing.T) { + msg := listenOnce(t, seeding.Event{ + Type: seeding.EventType("ask_human"), + Data: &dbpb.UserQuestion{ + QuestionId: "q-nil", + QuestionText: "Approve scope?", + ScopeIssues: nil, + }, + }) + + m, ok := msg.(AskHumanMsg) + if !ok { + t.Fatalf("expected AskHumanMsg, got %T", msg) + } + if m.ScopeIssues != nil { + t.Errorf("expected nil ScopeIssues, got %v", m.ScopeIssues) + } +} + +func TestConvertAskHuman_EmptyScopeIssues(t *testing.T) { + msg := listenOnce(t, seeding.Event{ + Type: seeding.EventType("ask_human"), + Data: &dbpb.UserQuestion{ + QuestionId: "q-empty", + ScopeIssues: []*dbpb.ScopeIssue{}, + }, + }) + + m, ok := msg.(AskHumanMsg) + if !ok { + t.Fatalf("expected AskHumanMsg, got %T", msg) + } + if len(m.ScopeIssues) != 0 { + t.Errorf("expected 0 ScopeIssues, got %d", len(m.ScopeIssues)) + } +} + func TestPlanProposedWithoutPreview(t *testing.T) { msg := listenOnce(t, seeding.Event{ Type: seeding.EventType("plan_proposed"), diff --git a/internal/ui/tui/components/selector_test.go b/internal/ui/tui/components/selector_test.go new file mode 100644 index 0000000..3dd759e --- /dev/null +++ b/internal/ui/tui/components/selector_test.go @@ -0,0 +1,321 @@ +package components + +import ( + "strings" + "testing" + + "seedfast/cli/internal/ui/tui" + + tea "github.com/charmbracelet/bubbletea" +) + +func testOptions() []tui.PromptOption { + return []tui.PromptOption{ + {Label: "Option A", Answer: "answer_a"}, + {Label: "Option B", Answer: "answer_b"}, + {Label: "Option C", Answer: ""}, + } +} + +func TestNewSelector_InitialState(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + if s.cursor != 0 { + t.Errorf("expected cursor=0, got %d", s.cursor) + } + if s.done { + t.Error("expected done=false") + } + if cmd := s.Init(); cmd != nil { + t.Error("expected Init() to return nil") + } +} + +func TestSelector_ArrowDown(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + s, _, result := s.Update(tea.KeyMsg{Type: tea.KeyDown}) + if result != nil { + t.Fatal("expected no result on arrow down") + } + if s.cursor != 1 { + t.Errorf("expected cursor=1, got %d", s.cursor) + } +} + +func TestSelector_ArrowUp(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + // Move down first, then up + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) + s, _, result := s.Update(tea.KeyMsg{Type: tea.KeyUp}) + if result != nil { + t.Fatal("expected no result on arrow up") + } + if s.cursor != 0 { + t.Errorf("expected cursor=0, got %d", s.cursor) + } +} + +func TestSelector_TabNavigation(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + // Tab moves down + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyTab}) + if s.cursor != 1 { + t.Errorf("after Tab: expected cursor=1, got %d", s.cursor) + } + + // ShiftTab moves up + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + if s.cursor != 0 { + t.Errorf("after ShiftTab: expected cursor=0, got %d", s.cursor) + } +} + +func TestSelector_BoundaryTop(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + // Already at 0, pressing up should stay at 0 + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyUp}) + if s.cursor != 0 { + t.Errorf("expected cursor to stay at 0, got %d", s.cursor) + } +} + +func TestSelector_BoundaryBottom(t *testing.T) { + theme := tui.DefaultTheme() + opts := testOptions() + s := NewSelector(theme, opts) + + // Move to last position + for i := 0; i < len(opts)-1; i++ { + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) + } + if s.cursor != len(opts)-1 { + t.Fatalf("expected cursor=%d, got %d", len(opts)-1, s.cursor) + } + + // Press down again; should stay at max + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) + if s.cursor != len(opts)-1 { + t.Errorf("expected cursor to stay at %d, got %d", len(opts)-1, s.cursor) + } +} + +func TestSelector_EnterSelectsCurrentOption(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + // Navigate to index 1 + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) + s, _, result := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if result == nil { + t.Fatal("expected a SelectorResult on Enter") + } + if result.Index != 1 { + t.Errorf("expected Index=1, got %d", result.Index) + } + if result.Label != "Option B" { + t.Errorf("expected Label='Option B', got %q", result.Label) + } + if result.Answer != "answer_b" { + t.Errorf("expected Answer='answer_b', got %q", result.Answer) + } + if result.Cancelled { + t.Error("expected Cancelled=false") + } +} + +func TestSelector_EnterSelectsFirstByDefault(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + _, _, result := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if result == nil { + t.Fatal("expected a SelectorResult on Enter") + } + if result.Index != 0 { + t.Errorf("expected Index=0, got %d", result.Index) + } + if result.Label != "Option A" { + t.Errorf("expected Label='Option A', got %q", result.Label) + } + if result.Answer != "answer_a" { + t.Errorf("expected Answer='answer_a', got %q", result.Answer) + } +} + +func TestSelector_CtrlCCancels(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + _, _, result := s.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + + if result == nil { + t.Fatal("expected a SelectorResult on Ctrl+C") + } + if !result.Cancelled { + t.Error("expected Cancelled=true") + } +} + +func TestSelector_EscCancels(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + _, _, result := s.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + if result == nil { + t.Fatal("expected a SelectorResult on Esc") + } + if !result.Cancelled { + t.Error("expected Cancelled=true") + } +} + +func TestSelector_IgnoresInputAfterDone(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + // Complete the selector + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !s.done { + t.Fatal("expected done=true after Enter") + } + + // Further input should be ignored + s, cmd, result := s.Update(tea.KeyMsg{Type: tea.KeyDown}) + if cmd != nil { + t.Error("expected nil cmd after done") + } + if result != nil { + t.Error("expected nil result after done") + } + // cursor should not have changed + if s.cursor != 0 { + t.Errorf("expected cursor to remain 0 after done, got %d", s.cursor) + } +} + +func TestSelector_ViewShowsCursorOnCurrentOption(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + view := s.View() + lines := strings.Split(view, "\n") + + // Find the line with Option A -- it should have the cursor indicator + foundCursor := false + for _, line := range lines { + if strings.Contains(line, "Option A") && strings.Contains(line, ">") { + foundCursor = true + break + } + } + if !foundCursor { + t.Error("expected '>' cursor indicator on Option A line") + } +} + +func TestSelector_ViewShowsCursorAfterMove(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + // Move to Option B + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) + view := s.View() + lines := strings.Split(view, "\n") + + // Option B line should have cursor + foundCursorB := false + for _, line := range lines { + if strings.Contains(line, "Option B") && strings.Contains(line, ">") { + foundCursorB = true + break + } + } + if !foundCursorB { + t.Error("expected '>' cursor indicator on Option B line after moving down") + } +} + +func TestSelector_ViewEmptyAfterDone(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + view := s.View() + if view != "" { + t.Errorf("expected empty view after done, got %q", view) + } +} + +func TestSelector_ViewContainsHint(t *testing.T) { + theme := tui.DefaultTheme() + s := NewSelector(theme, testOptions()) + + view := s.View() + if !strings.Contains(view, "Use arrow keys to navigate, Enter to select") { + t.Error("expected view to contain navigation hint text") + } +} + +func TestSelector_SingleOption(t *testing.T) { + theme := tui.DefaultTheme() + opts := []tui.PromptOption{ + {Label: "Only option", Answer: "only"}, + } + s := NewSelector(theme, opts) + + // Down does nothing + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) + if s.cursor != 0 { + t.Errorf("expected cursor=0 with single option, got %d", s.cursor) + } + + // Enter selects index 0 + _, _, result := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if result == nil { + t.Fatal("expected result on Enter") + } + if result.Index != 0 { + t.Errorf("expected Index=0, got %d", result.Index) + } + if result.Label != "Only option" { + t.Errorf("expected Label='Only option', got %q", result.Label) + } +} + +func TestSelector_EmptyAnswerOption(t *testing.T) { + theme := tui.DefaultTheme() + opts := []tui.PromptOption{ + {Label: "Real answer", Answer: "real"}, + {Label: "Describe manually", Answer: ""}, + } + s := NewSelector(theme, opts) + + // Navigate to the empty-answer option + s, _, _ = s.Update(tea.KeyMsg{Type: tea.KeyDown}) + _, _, result := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if result == nil { + t.Fatal("expected result on Enter") + } + if result.Cancelled { + t.Error("expected Cancelled=false for empty answer option") + } + if result.Answer != "" { + t.Errorf("expected empty Answer, got %q", result.Answer) + } + if result.Label != "Describe manually" { + t.Errorf("expected Label='Describe manually', got %q", result.Label) + } +} diff --git a/internal/ui/tui/phases/prompt_phase_test.go b/internal/ui/tui/phases/prompt_phase_test.go new file mode 100644 index 0000000..c40430d --- /dev/null +++ b/internal/ui/tui/phases/prompt_phase_test.go @@ -0,0 +1,426 @@ +package phases + +import ( + "context" + "strings" + "testing" + + "seedfast/cli/internal/seeding" + "seedfast/cli/internal/ui/tui" + "seedfast/cli/internal/ui/tui/components" + + tea "github.com/charmbracelet/bubbletea" +) + +// --- mock BridgeActions for PromptPhase tests --- + +type mockHumanResponse struct { + questionID string + data map[string]any +} + +type mockBridgeActions struct { + humanResponses []mockHumanResponse + cancellations []string +} + +func (m *mockBridgeActions) SendHumanResponse(_ context.Context, questionID string, responseData map[string]any) error { + m.humanResponses = append(m.humanResponses, mockHumanResponse{questionID, responseData}) + return nil +} + +func (m *mockBridgeActions) SendCancellation(_ context.Context, reason string) error { + m.cancellations = append(m.cancellations, reason) + return nil +} + +func newTestBridge(mock *mockBridgeActions) *tui.EventBridge { + ctx := context.Background() + events := make(chan seeding.Event) + return tui.NewEventBridge(ctx, events, mock) +} + +func newTestQuestion() *tui.AskHumanMsg { + return &tui.AskHumanMsg{ + QuestionID: "q-test", + QuestionText: "Test question?", + } +} + +func testWarnings() *tui.PromptWarnings { + return &tui.PromptWarnings{ + WarningLines: []string{"Warning header", " Issue 1 details"}, + Options: []tui.PromptOption{ + {Label: "Add missing deps", Answer: "Add the missing dependencies"}, + {Label: "Remove affected", Answer: "Remove affected tables"}, + {Label: "Describe what you want instead", Answer: ""}, + }, + Prompt: "How to fix?", + } +} + +// --- Tests --- + +func TestPromptPhase_NilWarnings_TextInputMode(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, nil) + + if p.hasWarnings { + t.Error("expected hasWarnings=false with nil warnings") + } + + view := p.View() + if !strings.Contains(view, "Test question?") { + t.Errorf("View should contain question text, got:\n%s", view) + } + if !strings.Contains(view, "'Enter'") { + t.Errorf("View should contain text input hint, got:\n%s", view) + } + if strings.Contains(view, "Use arrow keys") { + t.Error("View should NOT contain selector hint in text input mode") + } +} + +func TestPromptPhase_WithWarnings_SelectorMode(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := testWarnings() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + + if !p.hasWarnings { + t.Error("expected hasWarnings=true with non-nil warnings that have options") + } + + view := p.View() + if !strings.Contains(view, "Use arrow keys") { + t.Errorf("View should contain selector hint, got:\n%s", view) + } + // Should NOT contain the text input prompt hints + if strings.Contains(view, "'Enter'") && strings.Contains(view, "yes") && strings.Contains(view, "accept and continue") { + t.Error("View should NOT contain text input prompt hints in selector mode") + } +} + +func TestPromptPhase_WarningsView_RendersWarningLines(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := testWarnings() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + view := p.View() + + if !strings.Contains(view, "Warning header") { + t.Error("View should contain first warning line 'Warning header'") + } + if !strings.Contains(view, "Issue 1 details") { + t.Error("View should contain second warning line 'Issue 1 details'") + } +} + +func TestPromptPhase_WarningsView_UsesPromptText(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := testWarnings() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + view := p.View() + + if !strings.Contains(view, "How to fix?") { + t.Errorf("View should contain custom prompt 'How to fix?', got:\n%s", view) + } +} + +func TestPromptPhase_WarningsView_FallbackPrompt(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := &tui.PromptWarnings{ + WarningLines: []string{"Some warning"}, + Options: []tui.PromptOption{ + {Label: "Option", Answer: "answer"}, + }, + Prompt: "", // Empty prompt should use fallback + } + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + view := p.View() + + if !strings.Contains(view, "How would you like to proceed?") { + t.Errorf("View should contain fallback prompt, got:\n%s", view) + } +} + +func TestPromptPhase_SelectorSelection_SendsResponse(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := testWarnings() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + + // Press Enter to select first option ("Add missing deps" with Answer="Add the missing dependencies") + var phase tui.Phase = p + phase, cmd, _ := phase.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + pp := phase.(*PromptPhase) + if !pp.answered { + t.Error("expected answered=true after selection") + } + if !pp.waiting { + t.Error("expected waiting=true after selection") + } + + // Execute the returned cmd to trigger the mock call + if cmd == nil { + t.Fatal("expected non-nil cmd after selector selection") + } + msg := cmd() + + // Should produce HumanResponseSentMsg + sentMsg, ok := msg.(tui.HumanResponseSentMsg) + if !ok { + t.Fatalf("expected HumanResponseSentMsg, got %T", msg) + } + if sentMsg.Accepted { + t.Error("expected Accepted=false for selector option with non-empty answer") + } + if sentMsg.Answer != "Add the missing dependencies" { + t.Errorf("expected Answer='Add the missing dependencies', got %q", sentMsg.Answer) + } + + // Verify mock was called + if len(mock.humanResponses) != 1 { + t.Fatalf("expected 1 human response call, got %d", len(mock.humanResponses)) + } + if mock.humanResponses[0].questionID != "q-test" { + t.Errorf("expected questionID='q-test', got %q", mock.humanResponses[0].questionID) + } +} + +func TestPromptPhase_SelectorCancel_SendsCancellation(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := testWarnings() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + + // Press Ctrl+C to cancel + var phase tui.Phase = p + phase, cmd, _ := phase.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + + pp := phase.(*PromptPhase) + if !pp.answered { + t.Error("expected answered=true after cancellation") + } + + // Execute the returned cmd + if cmd == nil { + t.Fatal("expected non-nil cmd after cancellation") + } + msg := cmd() + + if _, ok := msg.(tui.CancellationSentMsg); !ok { + t.Fatalf("expected CancellationSentMsg, got %T", msg) + } + + // Verify mock was called + if len(mock.cancellations) != 1 { + t.Fatalf("expected 1 cancellation call, got %d", len(mock.cancellations)) + } + if mock.cancellations[0] != "user cancelled" { + t.Errorf("expected reason='user cancelled', got %q", mock.cancellations[0]) + } +} + +func TestPromptPhase_SelectorDescribeOption_FallsToTextInput(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := testWarnings() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + + // Navigate to last option (Answer="") which is the "Describe" option + var phase tui.Phase = p + phase, _, _ = phase.Update(tea.KeyMsg{Type: tea.KeyDown}) + phase, _, _ = phase.Update(tea.KeyMsg{Type: tea.KeyDown}) + phase, cmd, _ := phase.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + pp := phase.(*PromptPhase) + // After selecting the empty-answer option, hasWarnings should be false (text input mode) + if pp.hasWarnings { + t.Error("expected hasWarnings=false after selecting 'Describe' option") + } + + // cmd should be the textInput.Init() command (non-nil) + if cmd == nil { + t.Error("expected non-nil cmd (textInput.Init) after falling to text input") + } + + // View should now show text input, not selector + view := pp.View() + if strings.Contains(view, "Use arrow keys") { + t.Error("View should NOT contain selector hint after falling to text input") + } +} + +func TestPromptPhase_QuestionTextFallback(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + // Empty question text + question := &tui.AskHumanMsg{QuestionID: "q-1", QuestionText: ""} + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, nil) + view := p.View() + + if !strings.Contains(view, "Do you agree with this seeding scope?") { + t.Errorf("View should contain default question text, got:\n%s", view) + } +} + +func TestPromptPhase_QuestionTextFromServer(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := &tui.AskHumanMsg{QuestionID: "q-1", QuestionText: "Server says approve?"} + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, nil) + view := p.View() + + if !strings.Contains(view, "Server says approve?") { + t.Errorf("View should contain server question text, got:\n%s", view) + } +} + +func TestPromptPhase_AutoApprove_ImmediateSend(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, true, false, nil) + + // Init should send auto-approval + cmd := p.Init() + if cmd == nil { + t.Fatal("expected non-nil cmd from Init with autoApprove=true") + } + if !p.answered { + t.Error("expected answered=true after Init with autoApprove") + } + if !p.waiting { + t.Error("expected waiting=true after Init with autoApprove") + } + + view := p.View() + if !strings.Contains(view, "Auto-approving") { + t.Errorf("View should contain 'Auto-approving', got:\n%s", view) + } +} + +func TestPromptPhase_AutoApprove_NotWhenScopeTooLarge(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, true, true, nil) + + // Init should NOT auto-approve when scopeTooLarge=true + cmd := p.Init() + if p.answered { + t.Error("expected answered=false when scopeTooLarge=true") + } + if p.waiting { + t.Error("expected waiting=false when scopeTooLarge=true") + } + + // cmd should be textInput.Init (not nil) + if cmd == nil { + t.Error("expected non-nil cmd from Init (textInput.Init)") + } + + // Note: View() still shows "Auto-approving" because autoApprove is a struct + // field checked first in View(). The critical behavior is that Init() does + // NOT send the approval -- the autoApprove guard in Init is: + // if p.autoApprove && !p.scopeTooLarge + // So the response is never sent. View is cosmetic here; the observable + // behavior is that no mock call was made. + if len(mock.humanResponses) != 0 { + t.Errorf("expected no human responses sent when scopeTooLarge=true, got %d", len(mock.humanResponses)) + } +} + +func TestPromptPhase_ScopeTooLarge_SpecificView(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, true, nil) + view := p.View() + + if !strings.Contains(view, "Would you mind proposing us the updated scope?") { + t.Errorf("View should contain scopeTooLarge prompt, got:\n%s", view) + } + // Should NOT show the regular yes/no prompt + if strings.Contains(view, "Do you agree with this seeding scope?") { + t.Error("View should NOT contain regular question when scopeTooLarge=true") + } +} + +func TestPromptPhase_WarningsWithEmptyOptions(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + + // Warnings struct with empty options slice + warnings := &tui.PromptWarnings{ + WarningLines: []string{"Some warning"}, + Options: []tui.PromptOption{}, + Prompt: "Question?", + } + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + + // hasWarnings should be false because len(Options) == 0 + if p.hasWarnings { + t.Error("expected hasWarnings=false when Options slice is empty") + } + + // Should fall through to text input mode + view := p.View() + if strings.Contains(view, "Use arrow keys") { + t.Error("View should NOT contain selector hint with empty options") + } +} + +// TestPromptPhase_SelectorAnswerEcho verifies the answerEcho is set after a +// selector pick with a non-empty answer (for display in the answered view). +func TestPromptPhase_SelectorAnswerEcho(t *testing.T) { + mock := &mockBridgeActions{} + bridge := newTestBridge(mock) + question := newTestQuestion() + warnings := testWarnings() + + p := NewPromptPhase(tui.DefaultTheme(), bridge, question, false, false, warnings) + + // Select first option + var phase tui.Phase = p + phase, _, _ = phase.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + pp := phase.(*PromptPhase) + if !strings.Contains(pp.answerEcho, "Selected:") { + t.Errorf("expected answerEcho to contain 'Selected:', got %q", pp.answerEcho) + } + if !strings.Contains(pp.answerEcho, "Add missing deps") { + t.Errorf("expected answerEcho to contain label, got %q", pp.answerEcho) + } +} + +// Compile-time check that SelectorResult is used correctly. +var _ *components.SelectorResult From e41a058432155113705c424d58a530cf6c07a104 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:44:47 +0100 Subject: [PATCH 33/38] refactor: remove dead prompt code replaced by TUI --- internal/orchestration/event_handler.go | 108 ------------ internal/orchestration/scope_input.go | 128 -------------- internal/orchestration/scope_selector.go | 123 -------------- internal/orchestration/scope_selector_test.go | 159 ------------------ 4 files changed, 518 deletions(-) delete mode 100644 internal/orchestration/scope_input.go delete mode 100644 internal/orchestration/scope_selector.go delete mode 100644 internal/orchestration/scope_selector_test.go diff --git a/internal/orchestration/event_handler.go b/internal/orchestration/event_handler.go index 2410323..1c7c019 100644 --- a/internal/orchestration/event_handler.go +++ b/internal/orchestration/event_handler.go @@ -626,114 +626,6 @@ func (eh *EventHandler) handleAskHuman(ev seeding.Event) error { return eh.sendAnswer(questionID, result.Answer) } -// selectorOption pairs a display label with the answer to send to the backend. -// An empty answer means "fall through to free text input". -type selectorOption struct { - label string - answer string -} - -// handleWarningSelection runs the Bubble Tea selector for blocking issue resolution -// and sends the appropriate response to the backend. -// Options are built dynamically based on issue types (dependency_empty vs fk_feasibility). -func (eh *EventHandler) handleWarningSelection(questionID string, result warningFormatResult) error { - depsStr := strings.Join(result.DependencyTables, ", ") - affectedStr := strings.Join(result.AffectedTables, ", ") - - // Build options based on issue types - var options []selectorOption - - if result.HasDependencyEmpty && depsStr != "" { - options = append(options, selectorOption{ - label: "Add missing dependencies to scope: " + depsStr, - answer: "Add the missing dependencies to scope: " + depsStr, - }) - } - - if result.HasFeasibility && len(result.SuggestedResolutions) > 0 { - options = append(options, selectorOption{ - label: "Apply suggested fixes (adjust record counts)", - answer: strings.Join(result.SuggestedResolutions, ". "), - }) - } - - if affectedStr != "" { - options = append(options, selectorOption{ - label: "Remove affected tables from scope: " + affectedStr, - answer: "Remove the affected tables from scope: " + affectedStr, - }) - } - - // Always add free text as last option - options = append(options, selectorOption{ - label: "Describe the updated scope in your own words", - answer: "", - }) - - // Build label list for the selector UI - labels := make([]string, len(options)) - for i, opt := range options { - labels[i] = opt.label - } - - idx, err := runScopeSelector(eh.ctx, labels, 5*time.Minute) - if err != nil { - if errors.Is(err, errScopeSelectorCancelled) { - eh.isExiting.Store(true) - return nil - } - if errors.Is(err, errScopeSelectorTimeout) { - return eh.handleInputTimeout() - } - return err - } - - selected := options[idx] - - // Empty answer means free text input - if selected.answer == "" { - placeholder := randomScopeHint() - ans, textErr := runScopeInput(eh.ctx, placeholder, 5*time.Minute) - if textErr != nil { - if errors.Is(textErr, errScopeInputCancelled) { - eh.isExiting.Store(true) - return nil - } - if errors.Is(textErr, errScopeInputTimeout) { - return eh.handleInputTimeout() - } - return textErr - } - return eh.sendAnswer(questionID, ans) - } - - return eh.sendAnswer(questionID, selected.answer) -} - -// handleTextInput runs the Bubble Tea text input for normal approval/refinement flow. -func (eh *EventHandler) handleTextInput(questionID string) error { - var placeholder string - if eh.state.ScopeTooLargeMode { - placeholder = "describe the updated scope..." - } else { - placeholder = randomScopeHint() - } - - ans, err := runScopeInput(eh.ctx, placeholder, 5*time.Minute) - if err != nil { - if errors.Is(err, errScopeInputCancelled) { - eh.isExiting.Store(true) - return nil - } - if errors.Is(err, errScopeInputTimeout) { - return eh.handleInputTimeout() - } - return err - } - - return eh.sendAnswer(questionID, ans) -} - // sendAnswer sends the user's answer to the backend and restarts the UI spinner. func (eh *EventHandler) sendAnswer(questionID, ans string) error { var respObj map[string]any diff --git a/internal/orchestration/scope_input.go b/internal/orchestration/scope_input.go deleted file mode 100644 index 7b89a24..0000000 --- a/internal/orchestration/scope_input.go +++ /dev/null @@ -1,128 +0,0 @@ -package orchestration - -import ( - "context" - "errors" - "math/rand" - "strings" - "time" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -// scopeHintPhrases is a pool of example scope descriptions shown as -// placeholder ghost text on the first user prompt. Each phrase should -// be short and general enough to apply to many database schemas. -var scopeHintPhrases = []string{ - "use only English names and emails", - "seed 500 records per table", - "e-commerce data with realistic products", - "only the users and orders tables", - "HR schema with employees and departments", - "blog with posts, comments, and tags", - "10 records per table for quick testing", - "SaaS app with teams and billing", - "financial transactions and invoices", - "student records with courses and grades", - "restaurant with menu items and reviews", - "healthcare data with patients and visits", - "social media with posts and followers", - "inventory with products and suppliers", - "use European names and addresses", -} - -// randomScopeHint returns a random phrase from the hint pool. -func randomScopeHint() string { - return scopeHintPhrases[rand.Intn(len(scopeHintPhrases))] -} - -// Errors returned by the scope input. -var ( - errScopeInputTimeout = errors.New("user input timeout - session cancelled") - errScopeInputCancelled = errors.New("input cancelled by user") -) - -// timeoutMsg is sent when the input timeout expires. -type timeoutMsg struct{} - -// scopeInputModel is a Bubble Tea model for the scope input field -// with placeholder/ghost text support. -type scopeInputModel struct { - textInput textinput.Model - done bool - cancelled bool - timedOut bool - timeout time.Duration -} - -func newScopeInputModel(placeholder string, timeout time.Duration) scopeInputModel { - ti := textinput.New() - ti.Placeholder = placeholder - ti.Prompt = "Your answer: " - ti.Focus() - - return scopeInputModel{ - textInput: ti, - timeout: timeout, - } -} - -func (m scopeInputModel) Init() tea.Cmd { - return tea.Batch( - textinput.Blink, - tea.Tick(m.timeout, func(time.Time) tea.Msg { - return timeoutMsg{} - }), - ) -} - -func (m scopeInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case timeoutMsg: - m.timedOut = true - return m, tea.Quit - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - m.done = true - return m, tea.Quit - case tea.KeyCtrlC: - m.cancelled = true - return m, tea.Quit - } - } - - var cmd tea.Cmd - m.textInput, cmd = m.textInput.Update(msg) - return m, cmd -} - -func (m scopeInputModel) View() string { - if m.done || m.cancelled || m.timedOut { - return "" - } - return m.textInput.View() -} - -// runScopeInput runs a Bubble Tea text input with placeholder ghost text. -// Returns the user's input, or an error on timeout/cancellation. -func runScopeInput(ctx context.Context, placeholder string, timeout time.Duration) (string, error) { - model := newScopeInputModel(placeholder, timeout) - p := tea.NewProgram(model, tea.WithContext(ctx)) - - finalModel, err := p.Run() - if err != nil { - return "", err - } - - m := finalModel.(scopeInputModel) - if m.timedOut { - return "", errScopeInputTimeout - } - if m.cancelled { - return "", errScopeInputCancelled - } - - return strings.TrimSpace(m.textInput.Value()), nil -} diff --git a/internal/orchestration/scope_selector.go b/internal/orchestration/scope_selector.go deleted file mode 100644 index d038863..0000000 --- a/internal/orchestration/scope_selector.go +++ /dev/null @@ -1,123 +0,0 @@ -package orchestration - -import ( - "context" - "errors" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/pterm/pterm" -) - -var ( - errScopeSelectorCancelled = errors.New("selector cancelled by user") - errScopeSelectorTimeout = errors.New("selector timeout") -) - -// scopeSelectorModel is a lightweight Bubble Tea model for choosing -// between predefined options using arrow keys. -type scopeSelectorModel struct { - choices []string - cursor int - done bool - cancelled bool - timedOut bool - timeout time.Duration -} - -func newScopeSelectorModel(choices []string, timeout time.Duration) scopeSelectorModel { - return scopeSelectorModel{ - choices: choices, - cursor: 0, - timeout: timeout, - } -} - -func (m scopeSelectorModel) Init() tea.Cmd { - return tea.Tick(m.timeout, func(time.Time) tea.Msg { - return timeoutMsg{} - }) -} - -func (m scopeSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case timeoutMsg: - m.timedOut = true - return m, tea.Quit - case tea.KeyMsg: - switch msg.Type { - case tea.KeyUp, tea.KeyShiftTab: - if m.cursor > 0 { - m.cursor-- - } - case tea.KeyDown, tea.KeyTab: - if m.cursor < len(m.choices)-1 { - m.cursor++ - } - case tea.KeyEnter: - m.done = true - return m, tea.Quit - case tea.KeyCtrlC: - m.cancelled = true - return m, tea.Quit - } - } - return m, nil -} - -func (m scopeSelectorModel) View() string { - if m.done || m.cancelled || m.timedOut { - return "" - } - - var b []string - cursorStyle := pterm.NewStyle(pterm.FgGreen) - activeStyle := pterm.NewStyle(pterm.Bold) - inactiveStyle := pterm.NewStyle(pterm.FgGray) - - for i, choice := range m.choices { - if m.cursor == i { - b = append(b, cursorStyle.Sprint("> ")+activeStyle.Sprint(choice)) - } else { - b = append(b, " "+inactiveStyle.Sprint(choice)) - } - } - - hint := pterm.NewStyle(pterm.FgGray).Sprint("Use ↑↓ to navigate, Enter to select") - b = append(b, "") - b = append(b, hint) - - return "\n" + joinLines(b) + "\n" -} - -func joinLines(lines []string) string { - result := "" - for i, l := range lines { - if i > 0 { - result += "\n" - } - result += l - } - return result -} - -// runScopeSelector runs the Bubble Tea selector and returns the index of the user's choice. -func runScopeSelector(ctx context.Context, choices []string, timeout time.Duration) (int, error) { - model := newScopeSelectorModel(choices, timeout) - p := tea.NewProgram(model, tea.WithContext(ctx)) - - finalModel, err := p.Run() - if err != nil { - return 0, err - } - - m := finalModel.(scopeSelectorModel) - if m.timedOut { - return 0, errScopeSelectorTimeout - } - if m.cancelled { - return 0, errScopeSelectorCancelled - } - - return m.cursor, nil -} diff --git a/internal/orchestration/scope_selector_test.go b/internal/orchestration/scope_selector_test.go deleted file mode 100644 index 258026d..0000000 --- a/internal/orchestration/scope_selector_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package orchestration - -import ( - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestScopeSelectorModel_InitialState(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - - if len(m.choices) != 3 { - t.Fatalf("expected 3 choices, got %d", len(m.choices)) - } - if m.cursor != 0 { - t.Errorf("expected cursor=0, got %d", m.cursor) - } - if m.done || m.cancelled || m.timedOut { - t.Error("expected all flags false initially") - } -} - -func TestScopeSelectorModel_DynamicChoiceCount(t *testing.T) { - m := newScopeSelectorModel([]string{"Option A", "Option B", "Option C", "Option D"}, 5*time.Minute) - - if len(m.choices) != 4 { - t.Fatalf("expected 4 choices, got %d", len(m.choices)) - } - - // Navigate to last item - for i := 0; i < 3; i++ { - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - } - if m.cursor != 3 { - t.Errorf("expected cursor=3, got %d", m.cursor) - } - - // Should not go past last item - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - if m.cursor != 3 { - t.Errorf("expected cursor=3 (clamped), got %d", m.cursor) - } -} - -func TestScopeSelectorModel_NavigateDown(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - if m.cursor != 1 { - t.Errorf("expected cursor=1 after down, got %d", m.cursor) - } - - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - if m.cursor != 2 { - t.Errorf("expected cursor=2 after second down, got %d", m.cursor) - } - - // Should not go past last item - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - if m.cursor != 2 { - t.Errorf("expected cursor=2 (clamped), got %d", m.cursor) - } -} - -func TestScopeSelectorModel_NavigateUp(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - - // Move down first - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - - // Navigate up - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) - m = updated.(scopeSelectorModel) - if m.cursor != 1 { - t.Errorf("expected cursor=1 after up, got %d", m.cursor) - } - - // Should not go before first item - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) - m = updated.(scopeSelectorModel) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) - m = updated.(scopeSelectorModel) - if m.cursor != 0 { - t.Errorf("expected cursor=0 (clamped), got %d", m.cursor) - } -} - -func TestScopeSelectorModel_SelectWithEnter(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - - // Move to second option and select - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(scopeSelectorModel) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = updated.(scopeSelectorModel) - - if !m.done { - t.Error("expected done=true after Enter") - } - if m.cursor != 1 { - t.Errorf("expected cursor=1 at selection, got %d", m.cursor) - } -} - -func TestScopeSelectorModel_Cancel(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) - m = updated.(scopeSelectorModel) - - if !m.cancelled { - t.Error("expected cancelled=true after Ctrl+C") - } -} - -func TestScopeSelectorModel_Timeout(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - - updated, _ := m.Update(timeoutMsg{}) - m = updated.(scopeSelectorModel) - - if !m.timedOut { - t.Error("expected timedOut=true after timeout message") - } -} - -func TestScopeSelectorModel_TabNavigation(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) - m = updated.(scopeSelectorModel) - if m.cursor != 1 { - t.Errorf("expected cursor=1 after Tab, got %d", m.cursor) - } - - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - m = updated.(scopeSelectorModel) - if m.cursor != 0 { - t.Errorf("expected cursor=0 after Shift+Tab, got %d", m.cursor) - } -} - -func TestScopeSelectorModel_ViewHidden(t *testing.T) { - m := newScopeSelectorModel([]string{"Add deps", "Remove tables", "Free text"}, 5*time.Minute) - m.done = true - - if m.View() != "" { - t.Error("expected empty view when done") - } -} From 6cc701eab6a11dbbb6c4896a6d630495874a8cbe Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Tue, 24 Feb 2026 19:47:29 +0100 Subject: [PATCH 34/38] chore: remove plan doc from tracked files --- docs/plans/16-tui-prompt-wiring.md | 316 ----------------------------- 1 file changed, 316 deletions(-) delete mode 100644 docs/plans/16-tui-prompt-wiring.md diff --git a/docs/plans/16-tui-prompt-wiring.md b/docs/plans/16-tui-prompt-wiring.md deleted file mode 100644 index ad3c356..0000000 --- a/docs/plans/16-tui-prompt-wiring.md +++ /dev/null @@ -1,316 +0,0 @@ -# Plan: Wire TUI Prompt Phase into Production CLI - -**Status:** Draft -**Created:** 2026-02-24 -**Branch:** feat/tui-prompt-wiring -**Parent:** feat/bubble-ui - -## Problem - -The production `seedfast seed` command renders the scope approval prompt ("Do you agree with this seeding scope?") using pterm in `internal/orchestration/event_handler.go` (lines 489-651). This code: - -1. Builds styled question text and bullet-point options via pterm -2. Spawns a mini Bubble Tea program (`scope_input.go`) for text input -3. Spawns a separate mini Bubble Tea program (`scope_selector.go`) for warning selection - -Meanwhile, the TUI framework already has `PromptPhase` (`internal/ui/tui/phases/prompt_phase.go`) that renders identical UI using lipgloss and handles all three flows (approval, rejection, custom scope) in a single Bubble Tea model. But it only runs in dev builds (`-tags dev`, `--output=tui`). - -### Current Architecture (Production) - -``` -handleAskHuman() -├── pterm.Println() -- render question + options (static text) -├── runScopeInput() -- mini tea.Program for text input -└── runScopeSelector() -- mini tea.Program for warning selection -``` - -Three separate rendering mechanisms compete for terminal control. The pterm output is fire-and-forget (no terminal ownership), while the two mini Bubble Tea programs each take/release terminal ownership independently. - -### TUI Architecture (Dev Only) - -``` -AppModel (single tea.Program) -└── PromptPhase - ├── View() -- renders question + options + text input - ├── Update() -- handles Enter/Ctrl+C/timeout - └── EventBridge -- sends response back to gRPC -``` - -One terminal owner, one event loop, one rendering pass. - -## Goal - -Replace the pterm-based prompt rendering in production `handleAskHuman` with TUI's `PromptPhase`, so that the scope approval UI is rendered consistently across production and dev builds. - -## Scope - -**In scope:** -- Prompt rendering (question text, bullet-point options, examples) -- Text input for approval/rejection/custom scope -- Warning selector for scope issues (dependency_empty, fk_feasibility) -- Auto-approve flow -- Scope-too-large replan flow -- Input timeout handling - -**Out of scope:** -- Other phases (Init, Plan, Seeding, Completed, Error) — remain pterm-based -- Full Phase 3 orchestrator wiring (replacing EventHandler entirely) -- CI/CD output modes (plain, json, ndjson, mcp) — unchanged - -## Key Gaps - -### 1. Build Tags Block Production Compilation - -TUI packages are gated behind `//go:build dev`: -- `internal/ui/tuirenderer/register_dev.go` — registers TUI renderer factory -- `internal/orchestration/seed_orchestrator_tui_dev.go` — `RunWithTUI` method - -The core TUI packages (`internal/ui/tui/`, `internal/ui/tui/phases/`, `internal/ui/tui/components/`) compile unconditionally but are never imported in production. - -### 2. AskHumanMsg Missing ScopeIssues - -`tui.AskHumanMsg` struct: -```go -type AskHumanMsg struct { - QuestionID string - QuestionText string - ContextTables []string - // Missing: ScopeIssues []*dbpb.ScopeIssue -} -``` - -`EventBridge.convertAskHuman()` drops `ScopeIssues` from the proto `UserQuestion`. Without this field, TUI PromptPhase cannot render the warning selector. - -### 3. PromptPhase Has No Warning Selector - -Production `handleAskHuman` has two UI paths: -- **No issues:** text input (approve/reject/custom scope) -- **Has issues:** `scopeSelector` (arrow-key selection with dynamic options) - -TUI `PromptPhase` only implements the first path. There is no selector component or warning rendering. - -### 4. handleAskHuman Bypasses SeedingRenderer - -The `SeedingRenderer` interface has `OnQuestion(questionText, options) (string, error)`, but `handleAskHuman` never calls it. It renders directly via pterm. The `InteractiveRenderer.OnQuestion()` implementation exists but is dead code. - -### 5. Production EventHandler Manages Terminal State - -`handleAskHuman` interacts with `HeaderSpinner`, cursor visibility, and pterm state: -- Stops header spinner before showing question -- Restarts spinner after auto-approve -- Manages `isExiting` atomic flag for Ctrl+C - -Any replacement must preserve these state transitions. - -## Approach - -### Option A: Standalone Prompt Runner (Recommended) - -Create a thin wrapper that runs `PromptPhase` as a standalone mini `tea.Program` — same pattern as existing `runScopeInput()` and `runScopeSelector()` but replacing both with a single program. - -**Why:** Minimal blast radius. Only `handleAskHuman` changes. EventHandler loop, HeaderSpinner, progress rendering, and all other phases remain untouched. The PromptPhase gets production exposure without requiring full Phase 3 wiring. - -#### Implementation Steps - -**Step 1: Extend `AskHumanMsg` with scope issues** - -File: `internal/ui/tui/events.go` - -```go -type AskHumanMsg struct { - QuestionID string - QuestionText string - ContextTables []string - ScopeIssues []*dbpb.ScopeIssue // NEW -} -``` - -File: `internal/ui/tui/bridge.go` — update `convertAskHuman`: - -```go -func (eb *EventBridge) convertAskHuman(ev seeding.Event) AskHumanMsg { - if q, ok := ev.Data.(*dbpb.UserQuestion); ok && q != nil { - return AskHumanMsg{ - QuestionID: q.QuestionId, - QuestionText: q.QuestionText, - ContextTables: q.ContextTables, - ScopeIssues: q.ScopeIssues, // NEW - } - } - return AskHumanMsg{} -} -``` - -**Step 2: Add warning selector to PromptPhase** - -File: `internal/ui/tui/phases/prompt_phase.go` - -Extend PromptPhase to handle scope issues: -- Parse `ScopeIssues` from `AskHumanMsg` (reuse `warningFormatResult` logic or create shared package) -- When issues present: render warning block + arrow-key selector (new `SelectorComponent` in `components/`) -- When no issues: current text input flow (unchanged) - -New file: `internal/ui/tui/components/selector.go` -- Arrow-key selection component (equivalent to `scope_selector.go` but as a reusable Bubble Tea component) -- Renders options with cursor indicator, navigation hint - -**Step 3: Create standalone prompt runner** - -New file: `internal/orchestration/prompt_runner.go` - -```go -// RunPrompt executes the TUI PromptPhase as a standalone Bubble Tea program. -// Replaces the combination of pterm rendering + runScopeInput + runScopeSelector. -// -// Returns: (answer string, approved bool, err error) -func RunPrompt(ctx context.Context, cfg PromptConfig) (string, bool, error) - -type PromptConfig struct { - QuestionID string - QuestionText string - ScopeIssues []*dbpb.ScopeIssue - AutoApprove bool - ScopeTooLarge bool - Timeout time.Duration -} -``` - -This function: -1. Creates a PromptPhase with the given config -2. Wraps it in a minimal AppModel (or a new PromptApp model) -3. Runs `tea.NewProgram(model).Run()` -4. Extracts the answer from the final model state - -**Step 4: Replace handleAskHuman rendering** - -File: `internal/orchestration/event_handler.go` - -Replace lines 590-651 (prompt rendering + scopeInput/scopeSelector calls) with: - -```go -answer, approved, err := RunPrompt(eh.ctx, PromptConfig{ - QuestionID: questionID, - QuestionText: question, - ScopeIssues: protoIssues, - AutoApprove: false, - ScopeTooLarge: eh.state.ScopeTooLargeMode, - Timeout: 5 * time.Minute, -}) -if err != nil { - // handle cancellation, timeout (same as current) -} -return eh.sendAnswer(questionID, answer) -``` - -Preserve: -- HeaderSpinner stop/start around the prompt -- `isExiting` atomic flag check -- CI/CD mode guard (skip prompt rendering) -- Scope display (contextTables) before the prompt - -**Step 5: Remove dead code** - -After wiring is complete: -- `scope_input.go` — replaced by PromptPhase text input -- `scope_selector.go` — replaced by PromptPhase selector component -- `InteractiveRenderer.OnQuestion()` — was already dead code -- pterm prompt rendering in `handleAskHuman` — replaced by RunPrompt -- Duplicate `scopeHintPhrases` in `scope_input.go` (already exists in `prompt_phase.go`) - -### Option B: Shared Rendering Function (Simpler but Incomplete) - -Extract only the visual rendering (question text + options) into a shared lipgloss-based function (like `dbheader.Render()`). Keep `scopeInput` and `scopeSelector` as-is. - -**Why not:** Doesn't eliminate the terminal ownership problem (3 mini programs). Doesn't leverage PromptPhase's integrated input handling. Half-measure that still needs Option A later. - -### Option C: Full Phase 3 Wiring (Too Large) - -Replace the entire EventHandler event loop with TUI's AppModel in production. - -**Why not:** Requires all 7 phases to be production-ready. Much larger blast radius. The prompt is the most user-facing piece — ship it first, validate, then expand. - -## Files Changed - -| File | Change | -|------|--------| -| `internal/ui/tui/events.go` | Add `ScopeIssues` field to `AskHumanMsg` | -| `internal/ui/tui/bridge.go` | Pass `ScopeIssues` in `convertAskHuman` | -| `internal/ui/tui/components/selector.go` | **NEW** — arrow-key selector component | -| `internal/ui/tui/phases/prompt_phase.go` | Add warning selector path, accept scope issues | -| `internal/orchestration/prompt_runner.go` | **NEW** — standalone prompt runner wrapping PromptPhase | -| `internal/orchestration/event_handler.go` | Replace pterm prompt rendering with `RunPrompt()` | - -## Files Removed (After Validation) - -| File | Reason | -|------|--------| -| `internal/orchestration/scope_input.go` | Replaced by PromptPhase text input | -| `internal/orchestration/scope_selector.go` | Replaced by PromptPhase selector | - -## Files NOT Changed - -| File | Reason | -|------|--------| -| `internal/orchestration/seed_orchestrator.go` | Event loop structure unchanged | -| `internal/orchestration/seed_orchestrator_tui_dev.go` | Dev TUI path unchanged | -| `internal/ui/tuirenderer/register_dev.go` | Full TUI renderer stays dev-only | -| `internal/ui/renderer_factory.go` | No new output mode added | -| `internal/orchestration/warning_formatter.go` | Reused as-is (or extracted to shared package) | -| CI/CD renderers (plain, json, ndjson, mcp) | Unchanged | - -## Dependency Considerations - -### Import Cycle Risk - -Production `orchestration` package would import `tui/phases` for PromptPhase. Current import graph: - -``` -orchestration → bridge, ui, seeding -tui/phases → tui, tui/components -tui → seeding (for EventBridge) -``` - -No cycle: `orchestration → tui/phases → tui → seeding` is a DAG. The `ui` package is not in the path. - -However, if PromptPhase needs `warningFormatResult` from `orchestration`, that creates a cycle. Solution: extract warning formatting into a shared package (`internal/warnings/` or `internal/ui/warnings/`). - -### lipgloss in Production Binary - -`lipgloss` is already in `go.mod` (transitive via `bubbles`). Adding direct usage in production path adds no new dependency, but slightly increases binary size for ANSI rendering. Acceptable tradeoff. - -## Testing - -1. **Build verification:** `go build ./...` and `go build -tags dev ./...` -2. **Unit tests:** PromptPhase with scope issues, selector navigation, timeout, cancellation -3. **Integration test:** `RunPrompt()` with mock bridge, verify response format -4. **Visual verification:** `seedfast seed` against real database — prompt must look identical -5. **Warning flow:** Trigger dependency_empty / fk_feasibility warnings, verify selector works -6. **Auto-approve:** `seedfast seed --scope "..."` still auto-approves without showing prompt -7. **Scope-too-large:** Trigger replan flow, verify "propose updated scope" UI -8. **CI/CD modes:** json/plain/ndjson/mcp must not show prompt UI -9. **Ctrl+C handling:** Cancel during prompt must exit cleanly (exit code 130) -10. **Input timeout:** 5-minute timeout must cancel session gracefully - -## Risks - -| Risk | Severity | Mitigation | -|------|----------|------------| -| Terminal rendering differs between pterm and lipgloss | Low | Visual testing on Windows Terminal, PowerShell | -| PromptPhase as standalone program may behave differently than inside full TUI | Medium | Thorough testing of standalone runner | -| Warning formatter import cycle | Medium | Extract to shared package if needed | -| Cursor/terminal state leak from mini program | Low | Ensure tea.Program cleanup in RunPrompt | -| Regression in Ctrl+C handling | Medium | Test isExiting flag interaction with tea.KeyCtrlC | - -## Rollout - -1. Implement Steps 1-4 behind the existing code (new function, not yet called) -2. Add feature flag or A/B path in handleAskHuman for gradual rollout -3. Once validated, remove old pterm code path and dead files (Step 5) -4. This unblocks full Phase 3 wiring (future plan) by proving PromptPhase works in production - -## See Also - -- [10-tui-framework.md](./10-tui-framework.md) — TUI framework plan (Phase 3 pending) -- [14-replace-prod-dbheader-with-tui.md](./14-replace-prod-dbheader-with-tui.md) — DB header extraction (same pattern) -- [15-replace-emoji-start-with-braille-spinner.md](./15-replace-emoji-start-with-braille-spinner.md) — Braille spinner extraction -- [TUI Framework Developer Guide](../development/TUI_FRAMEWORK.md) — Component architecture From 92a105bbfcc5e31d9695d28c244a7db88586e2cb Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Wed, 25 Feb 2026 01:49:55 +0100 Subject: [PATCH 35/38] feat: migrate scope display from pterm to TUI Replace pterm-based scope rendering in handlePlanProposed and handleAskHuman with the TUI PlanPhase component via a new RunScopeDisplay function. Add configurable header support to PlanPhase for replan mode ("Previous seeding scope"). --- internal/orchestration/event_handler.go | 74 ++++--------------- .../orchestration/scope_display_runner.go | 51 +++++++++++++ internal/ui/tui/phases/plan_phase.go | 11 ++- 3 files changed, 77 insertions(+), 59 deletions(-) create mode 100644 internal/orchestration/scope_display_runner.go diff --git a/internal/orchestration/event_handler.go b/internal/orchestration/event_handler.go index 8e1e58d..0806c62 100644 --- a/internal/orchestration/event_handler.go +++ b/internal/orchestration/event_handler.go @@ -428,53 +428,21 @@ func (eh *EventHandler) handlePlanProposed(ev seeding.Event) error { if !eh.config.IsCICDMode() { // Add spacing after user's previous answer on replan if eh.state.ScopeShown { - pterm.Println() + fmt.Println() } - // Change header text based on mode - scopeHeader := "Seeding scope" - if eh.state.ScopeTooLargeMode { - scopeHeader = "Previous seeding scope" - } - pterm.Println(pterm.NewStyle(pterm.FgLightCyan, pterm.Bold).Sprint(scopeHeader)) - - if candidatePreview != "" { - pterm.Println(candidatePreview) - } else if len(tableSpecs) > 0 { - // Enhanced display with per-table row counts, aligned columns - maxNameLen := 0 - for _, spec := range tableSpecs { - if len(spec.Table) > maxNameLen { - maxNameLen = len(spec.Table) - } - } - items := make([]pterm.BulletListItem, 0, len(tableSpecs)) - for _, spec := range tableSpecs { - padding := strings.Repeat(" ", maxNameLen-len(spec.Table)+2) - countStr := ui.FormatNumber(int(spec.RecordsCount)) - text := spec.Table + padding + pterm.NewStyle(pterm.FgLightCyan).Sprint(countStr) + - pterm.NewStyle(pterm.FgGray).Sprint(" records") - items = append(items, pterm.BulletListItem{Level: 0, Text: text}) - } - _ = pterm.DefaultBulletList.WithItems(items).Render() - - // Show total row count summary - var totalRecords int32 - for _, spec := range tableSpecs { - totalRecords += spec.RecordsCount - } - if totalRecords > 0 { - dim := pterm.NewStyle(pterm.FgGray) - pterm.Println(dim.Sprintf(" Total: %s records", ui.FormatNumber(int(totalRecords)))) - } - } else if len(candidateTables) > 0 { - // Fallback: plain table list (no specs available) - items := make([]pterm.BulletListItem, 0, len(candidateTables)) - for _, s := range candidateTables { - items = append(items, pterm.BulletListItem{Level: 0, Text: s}) - } - _ = pterm.DefaultBulletList.WithItems(items).Render() + // Build table specs for scope display + specInputs := make([]TableSpecInput, len(tableSpecs)) + for i, spec := range tableSpecs { + specInputs[i] = TableSpecInput{Table: spec.Table, RecordsCount: spec.RecordsCount} } + + RunScopeDisplay(ScopeDisplayConfig{ + Tables: candidateTables, + PreviewText: candidatePreview, + TableSpecs: specInputs, + ScopeTooLarge: eh.state.ScopeTooLargeMode, + }) } // Always mark scope as shown (even in CI/CD mode) to prevent duplicate display eh.state.ScopeShown = true @@ -556,20 +524,10 @@ func (eh *EventHandler) handleAskHuman(ev seeding.Event) error { if !eh.state.ScopeShown && len(contextTables) > 0 { // Display scope list (skip in CI/CD mode to avoid polluting machine-readable output) if !eh.config.IsCICDMode() { - // Change header text based on mode - scopeHeader := "Seeding scope" - if eh.state.ScopeTooLargeMode { - scopeHeader = "Previous seeding scope" - } - pterm.Println(pterm.NewStyle(pterm.FgLightCyan, pterm.Bold).Sprint(scopeHeader)) - items := func(items []string) []pterm.BulletListItem { - var out []pterm.BulletListItem - for _, s := range items { - out = append(out, pterm.BulletListItem{Level: 0, Text: s}) - } - return out - }(contextTables) - _ = pterm.DefaultBulletList.WithItems(items).Render() + RunScopeDisplay(ScopeDisplayConfig{ + Tables: contextTables, + ScopeTooLarge: eh.state.ScopeTooLargeMode, + }) } // Always mark scope as shown (even in CI/CD mode) to prevent duplicate display eh.state.ScopeShown = true diff --git a/internal/orchestration/scope_display_runner.go b/internal/orchestration/scope_display_runner.go new file mode 100644 index 0000000..a579082 --- /dev/null +++ b/internal/orchestration/scope_display_runner.go @@ -0,0 +1,51 @@ +package orchestration + +import ( + "fmt" + + "seedfast/cli/internal/ui/tui" + "seedfast/cli/internal/ui/tui/phases" +) + +// ScopeDisplayConfig configures a standalone scope display. +type ScopeDisplayConfig struct { + Tables []string + PreviewText string + TableSpecs []TableSpecInput + ScopeTooLarge bool +} + +// TableSpecInput describes a single table for scope display. +type TableSpecInput struct { + Table string + RecordsCount int32 +} + +// RunScopeDisplay renders the seeding scope using the TUI PlanPhase component +// and prints the result to stdout. This is non-interactive: it renders once +// and returns immediately (no tea.Program needed for static display). +func RunScopeDisplay(cfg ScopeDisplayConfig) { + theme := tui.DefaultTheme() + + // Convert to TUI types + specs := make([]tui.TableSpec, len(cfg.TableSpecs)) + for i, s := range cfg.TableSpecs { + specs[i] = tui.TableSpec{Name: s.Table, RecordsCount: s.RecordsCount} + } + + plan := &tui.PlanProposedMsg{ + Tables: cfg.Tables, + PreviewText: cfg.PreviewText, + TableSpecs: specs, + } + + phase := phases.NewPlanPhase(theme, plan) + + // Override header for replan mode + if cfg.ScopeTooLarge { + phase.WithHeader("Previous seeding scope") + } + + // Just render and print - no tea.Program needed for static display + fmt.Print(phase.View()) +} diff --git a/internal/ui/tui/phases/plan_phase.go b/internal/ui/tui/phases/plan_phase.go index 1be9f08..f650c5a 100644 --- a/internal/ui/tui/phases/plan_phase.go +++ b/internal/ui/tui/phases/plan_phase.go @@ -17,6 +17,7 @@ type PlanPhase struct { bulletList components.BulletListComponent spinner components.SpinnerComponent plan *tui.PlanProposedMsg + header string // Configurable header text; defaults to "Seeding scope" showPlan bool showSpinner bool } @@ -37,11 +38,19 @@ func NewPlanPhase(theme *tui.Theme, plan *tui.PlanProposedMsg) *PlanPhase { bulletList: components.NewBulletList(theme, items), spinner: components.NewSpinner(theme, components.SpinnerBraille, ""), plan: plan, + header: "Seeding scope", showPlan: true, showSpinner: false, } } +// WithHeader returns the PlanPhase with a custom header text. +// Use this to override the default "Seeding scope" header (e.g., "Previous seeding scope"). +func (p *PlanPhase) WithHeader(header string) *PlanPhase { + p.header = header + return p +} + func (p *PlanPhase) ID() tui.PhaseID { return tui.PhasePlan } func (p *PlanPhase) Init() tea.Cmd { @@ -85,7 +94,7 @@ func (p *PlanPhase) View() string { var sb strings.Builder if p.showPlan && p.plan != nil { - sb.WriteString(p.theme.Title.Render("Seeding scope")) + sb.WriteString(p.theme.Title.Render(p.header)) sb.WriteString("\n\n") if p.plan.PreviewText != "" { From 8d6ceab01412424bbce0122b98739e2b8fffc224 Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Wed, 25 Feb 2026 01:55:59 +0100 Subject: [PATCH 36/38] test: scope display runner and plan phase --- .../scope_display_runner_test.go | 188 +++++++++++++++++ internal/ui/tui/phases/plan_phase_test.go | 190 ++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 internal/orchestration/scope_display_runner_test.go create mode 100644 internal/ui/tui/phases/plan_phase_test.go diff --git a/internal/orchestration/scope_display_runner_test.go b/internal/orchestration/scope_display_runner_test.go new file mode 100644 index 0000000..92b826f --- /dev/null +++ b/internal/orchestration/scope_display_runner_test.go @@ -0,0 +1,188 @@ +package orchestration + +import ( + "fmt" + "io" + "os" + "strings" + "testing" +) + +// captureStdout captures everything written to os.Stdout during fn execution. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() failed: %v", err) + } + os.Stdout = w + + // Run the function that writes to stdout + fn() + + // Close writer and restore stdout before reading + w.Close() + os.Stdout = origStdout + + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("reading captured stdout: %v", err) + } + r.Close() + + return string(out) +} + +func TestRunScopeDisplayWithTableSpecs(t *testing.T) { + cfg := ScopeDisplayConfig{ + Tables: []string{"users", "orders"}, + TableSpecs: []TableSpecInput{ + {Table: "users", RecordsCount: 50}, + {Table: "orders", RecordsCount: 200}, + }, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + for _, want := range []string{"Seeding scope", "users", "orders", "50", "200", "Total", "250"} { + if !strings.Contains(output, want) { + t.Errorf("output should contain %q, got:\n%s", want, output) + } + } +} + +func TestRunScopeDisplayWithPreviewText(t *testing.T) { + cfg := ScopeDisplayConfig{ + Tables: []string{"employees"}, + PreviewText: "HR schema only", + TableSpecs: []TableSpecInput{ + {Table: "employees", RecordsCount: 100}, + }, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + for _, want := range []string{"HR schema only", "employees", "100"} { + if !strings.Contains(output, want) { + t.Errorf("output should contain %q, got:\n%s", want, output) + } + } +} + +func TestRunScopeDisplayPlainTablesNoSpecs(t *testing.T) { + cfg := ScopeDisplayConfig{ + Tables: []string{"users", "orders"}, + TableSpecs: []TableSpecInput{}, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + // Header should still render + if !strings.Contains(output, "Seeding scope") { + t.Errorf("output should contain 'Seeding scope' header, got:\n%s", output) + } + + // No total line since totalRecords is 0 + if strings.Contains(output, "Total") { + t.Errorf("output should NOT contain 'Total' when TableSpecs is empty, got:\n%s", output) + } +} + +func TestRunScopeDisplayScopeTooLarge(t *testing.T) { + cfg := ScopeDisplayConfig{ + Tables: []string{"users"}, + TableSpecs: []TableSpecInput{ + {Table: "users", RecordsCount: 10}, + }, + ScopeTooLarge: true, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + if !strings.Contains(output, "Previous seeding scope") { + t.Errorf("output should contain 'Previous seeding scope' when ScopeTooLarge=true, got:\n%s", output) + } +} + +func TestRunScopeDisplayEmptyTables(t *testing.T) { + cfg := ScopeDisplayConfig{ + Tables: []string{}, + TableSpecs: []TableSpecInput{}, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + if !strings.Contains(output, "Seeding scope") { + t.Errorf("output should contain 'Seeding scope' header even with empty tables, got:\n%s", output) + } + if strings.Contains(output, "Total") { + t.Errorf("output should NOT contain 'Total' with empty tables, got:\n%s", output) + } +} + +func TestRunScopeDisplaySingleTable(t *testing.T) { + cfg := ScopeDisplayConfig{ + Tables: []string{"users"}, + TableSpecs: []TableSpecInput{ + {Table: "users", RecordsCount: 42}, + }, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + for _, want := range []string{"users", "42", "Total"} { + if !strings.Contains(output, want) { + t.Errorf("output should contain %q, got:\n%s", want, output) + } + } +} + +func TestRunScopeDisplayManyTables(t *testing.T) { + tables := make([]string, 12) + specs := make([]TableSpecInput, 12) + for i := 0; i < 12; i++ { + name := fmt.Sprintf("table_%02d", i+1) + tables[i] = name + specs[i] = TableSpecInput{Table: name, RecordsCount: 100} + } + + cfg := ScopeDisplayConfig{ + Tables: tables, + TableSpecs: specs, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + // All 12 table names should be present + for _, name := range tables { + if !strings.Contains(output, name) { + t.Errorf("output should contain table name %q, got:\n%s", name, output) + } + } + + // Total: 12 * 100 = 1200, formatted as "1,200" + if !strings.Contains(output, "1,200") { + t.Errorf("output should contain formatted total '1,200', got:\n%s", output) + } +} + +func TestRunScopeDisplayScopeTooLargeFalse(t *testing.T) { + cfg := ScopeDisplayConfig{ + Tables: []string{"users"}, + TableSpecs: []TableSpecInput{ + {Table: "users", RecordsCount: 10}, + }, + ScopeTooLarge: false, + } + + output := captureStdout(t, func() { RunScopeDisplay(cfg) }) + + if !strings.Contains(output, "Seeding scope") { + t.Errorf("output should contain default 'Seeding scope' header, got:\n%s", output) + } + if strings.Contains(output, "Previous seeding scope") { + t.Errorf("output should NOT contain 'Previous seeding scope' when ScopeTooLarge=false, got:\n%s", output) + } +} diff --git a/internal/ui/tui/phases/plan_phase_test.go b/internal/ui/tui/phases/plan_phase_test.go new file mode 100644 index 0000000..c602a21 --- /dev/null +++ b/internal/ui/tui/phases/plan_phase_test.go @@ -0,0 +1,190 @@ +package phases + +import ( + "strings" + "testing" + + "seedfast/cli/internal/ui/tui" +) + +func newTestPlan(tables []tui.TableSpec, preview string) *tui.PlanProposedMsg { + names := make([]string, len(tables)) + for i, ts := range tables { + names[i] = ts.Name + } + return &tui.PlanProposedMsg{ + Tables: names, + PreviewText: preview, + TableSpecs: tables, + } +} + +func TestPlanPhaseDefaultHeader(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 100}, + }, "") + + p := NewPlanPhase(theme, plan) + view := p.View() + + if !strings.Contains(view, "Seeding scope") { + t.Errorf("View() should contain default header 'Seeding scope', got:\n%s", view) + } + if strings.Contains(view, "Previous seeding scope") { + t.Errorf("View() should NOT contain 'Previous seeding scope' with default header, got:\n%s", view) + } +} + +func TestPlanPhaseWithHeaderOverride(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 100}, + }, "") + + p := NewPlanPhase(theme, plan).WithHeader("Previous seeding scope") + view := p.View() + + if !strings.Contains(view, "Previous seeding scope") { + t.Errorf("View() should contain overridden header 'Previous seeding scope', got:\n%s", view) + } +} + +func TestPlanPhaseViewContainsTableNames(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 50}, + {Name: "orders", RecordsCount: 200}, + }, "") + + p := NewPlanPhase(theme, plan) + view := p.View() + + if !strings.Contains(view, "users") { + t.Errorf("View() should contain 'users', got:\n%s", view) + } + if !strings.Contains(view, "orders") { + t.Errorf("View() should contain 'orders', got:\n%s", view) + } +} + +func TestPlanPhaseViewContainsRecordCounts(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 50}, + {Name: "orders", RecordsCount: 200}, + }, "") + + p := NewPlanPhase(theme, plan) + view := p.View() + + if !strings.Contains(view, "50") { + t.Errorf("View() should contain '50' for users record count, got:\n%s", view) + } + if !strings.Contains(view, "200") { + t.Errorf("View() should contain '200' for orders record count, got:\n%s", view) + } +} + +func TestPlanPhaseViewContainsTotalLine(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 50}, + {Name: "orders", RecordsCount: 200}, + }, "") + + p := NewPlanPhase(theme, plan) + view := p.View() + + if !strings.Contains(view, "Total") { + t.Errorf("View() should contain 'Total', got:\n%s", view) + } + if !strings.Contains(view, "250") { + t.Errorf("View() should contain '250' (sum of 50+200), got:\n%s", view) + } +} + +func TestPlanPhaseViewTotalLineFormatsLargeNumbers(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 1500}, + {Name: "orders", RecordsCount: 2500}, + }, "") + + p := NewPlanPhase(theme, plan) + view := p.View() + + // FormatNumber(4000) produces "4,000" + if !strings.Contains(view, "4,000") { + t.Errorf("View() should contain '4,000' (formatted total of 1500+2500), got:\n%s", view) + } +} + +func TestPlanPhaseViewWithPreviewText(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "employees", RecordsCount: 100}, + }, "Seed only HR schema") + + p := NewPlanPhase(theme, plan) + view := p.View() + + if !strings.Contains(view, "Seed only HR schema") { + t.Errorf("View() should contain preview text 'Seed only HR schema', got:\n%s", view) + } +} + +func TestPlanPhaseViewEmptyTableSpecs(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{}, "") + + p := NewPlanPhase(theme, plan) + view := p.View() + + if !strings.Contains(view, "Seeding scope") { + t.Errorf("View() should contain header 'Seeding scope' even with empty specs, got:\n%s", view) + } + if strings.Contains(view, "Total") { + t.Errorf("View() should NOT contain 'Total' when totalRecords is 0, got:\n%s", view) + } +} + +func TestPlanPhaseViewNoTotalWhenZeroRecords(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 0}, + }, "") + + p := NewPlanPhase(theme, plan) + view := p.View() + + if strings.Contains(view, "Total") { + t.Errorf("View() should NOT contain 'Total' when all record counts are 0, got:\n%s", view) + } +} + +func TestPlanPhaseID(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 10}, + }, "") + + p := NewPlanPhase(theme, plan) + if p.ID() != tui.PhasePlan { + t.Errorf("ID() = %v, want PhasePlan (%v)", p.ID(), tui.PhasePlan) + } +} + +func TestPlanPhaseWithHeaderReturnsSelf(t *testing.T) { + theme := tui.DefaultTheme() + plan := newTestPlan([]tui.TableSpec{ + {Name: "users", RecordsCount: 10}, + }, "") + + p := NewPlanPhase(theme, plan) + p2 := p.WithHeader("Custom") + + if p != p2 { + t.Error("WithHeader() should return the same pointer (fluent API)") + } +} From 96fa949799b36052e455d013e90fd8036791f6de Mon Sep 17 00:00:00 2001 From: Mikhail Shytsko Date: Wed, 4 Mar 2026 00:00:46 +0100 Subject: [PATCH 37/38] chore: untrack internal files from repository Remove .claude/ and .mcp.json from git tracking and add .claude/, .mcp.json, docs/ to .gitignore. Public repo must not contain internal documentation, skills, or config. --- .claude/agents/framer-designer.md | 56 --- .claude/agents/go-coder.md | 43 -- .claude/agents/go-tester.md | 61 --- .claude/agents/knowledge-base-manager.md | 50 -- .claude/agents/seeder-consultant.md | 45 -- .claude/memory.json | 0 .claude/rules/commits.md | 34 -- .claude/settings.json | 130 ------ .claude/skills/git-workflow/SKILL.md | 442 ------------------ .../seedfast-knowledge-management/SKILL.md | 276 ----------- .../reference.md | 196 -------- .gitignore | 8 +- .mcp.json | 192 -------- 13 files changed, 6 insertions(+), 1527 deletions(-) delete mode 100644 .claude/agents/framer-designer.md delete mode 100644 .claude/agents/go-coder.md delete mode 100644 .claude/agents/go-tester.md delete mode 100644 .claude/agents/knowledge-base-manager.md delete mode 100644 .claude/agents/seeder-consultant.md delete mode 100644 .claude/memory.json delete mode 100644 .claude/rules/commits.md delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/git-workflow/SKILL.md delete mode 100644 .claude/skills/seedfast-knowledge-management/SKILL.md delete mode 100644 .claude/skills/seedfast-knowledge-management/reference.md delete mode 100644 .mcp.json diff --git a/.claude/agents/framer-designer.md b/.claude/agents/framer-designer.md deleted file mode 100644 index 0188fef..0000000 --- a/.claude/agents/framer-designer.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: framer-designer -description: Framer MCP specialist. Manages website pages, CMS content, and design system using Framer MCP tools. Follows existing styles and patterns. ---- - -# Framer Designer Agent - -You are the website and design specialist for the Seedfast project, working through the Framer MCP integration. - -## Responsibilities - -- Read and analyze the current Framer project structure (pages, components, styles) -- Create and update CMS collection items (blog posts, changelog entries, feature pages) -- Maintain design consistency by analyzing existing styles, fonts, and layout patterns before making changes -- Build new pages and sections following established design system conventions -- Manage color styles and text styles to keep the design system coherent - -## Primary Tools - -All work is done through Framer MCP tools: -- `getProjectXml` -- always call first to understand project structure -- `getNodeXml` -- inspect specific pages/components in detail -- `updateXmlForNode` -- create/update page content and layout -- `getCMSCollections` / `getCMSItems` / `upsertCMSItem` -- manage CMS content -- `manageColorStyle` / `manageTextStyle` -- maintain design system tokens -- `searchFonts` -- find fonts matching project conventions -- `getComponentInsertUrlAndTypes` -- reuse existing components - -## Key Rules - -- ALWAYS call `getProjectXml` first in every session to understand current state -- ALWAYS analyze existing styles and patterns before creating anything new -- NEVER create new color/text styles without checking if a matching one already exists -- NEVER deviate from the established design system -- match existing spacing, typography, and color usage -- When creating CMS items, call `getCMSCollections` first to understand field structure -- Prefer reusing existing components (linked instances) over creating new ones -- Use detached components only when customization is explicitly required - -## Design System Workflow - -Before any visual change: -1. `getProjectXml` -- get full project overview -2. Inspect 2-3 similar existing pages/sections with `getNodeXml` -3. Note patterns: layout direction, spacing, font styles, color usage -4. Apply the same patterns to new content -5. Verify consistency after changes - -## Proactive Communication - -- Proactively consult knowledge-base-manager for business specifications, feature descriptions, and product positioning before writing any content -- Ask KB manager about current Seedfast capabilities, pricing tiers, and target audience before creating marketing or product pages -- Notify KB manager when new CMS content is published so docs can reference it -- Flag design inconsistencies found during analysis -- report to team lead immediately -- When creating blog posts or changelog entries, proactively request technical details from coder and tester -- If CMS structure seems incomplete or misaligned with product features, raise it with team lead before making changes -- Proactively share design system findings (fonts, colors, spacing patterns) with the team so everyone stays aligned \ No newline at end of file diff --git a/.claude/agents/go-coder.md b/.claude/agents/go-coder.md deleted file mode 100644 index 3a9b136..0000000 --- a/.claude/agents/go-coder.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: go-coder -description: Go implementation specialist. Implements fixes and features, verifies with go build/fmt/vet. Never runs tests. ---- - -# Go Coder Agent - -You are a Go implementation specialist for the Seedfast CLI project. - -## Responsibilities - -- Implement bug fixes and new features in Go -- Follow existing code patterns and conventions from CLAUDE.md -- Verify your work with `go build ./...`, `go fmt ./...`, `go vet ./...` - -## Boundaries - -- You do NOT run `go test` -- that is the tester's responsibility -- You do NOT modify documentation -- that is the KB manager's responsibility -- You do NOT modify the gRPC proto without consulting seeder-consultant first - -## Verification Checklist - -After every change: -1. `go build ./...` -- compiles without errors -2. `go fmt ./...` -- code is formatted -3. `go vet ./...` -- no static analysis issues - -## Code Conventions - -- Error wrapping: `fmt.Errorf("context: %w", err)` -- Context propagation: always pass `ctx context.Context` -- Friendly errors: use `friendlyerrors` package for user-facing messages -- Scope is plain text -- never parse or validate scope content -- Build tags: use `-tags dev` for dev-only features - -## Proactive Communication - -- Notify tester immediately when implementation is ready for testing -- Report blockers to team lead without waiting to be asked -- Ask seeder-consultant before changing gRPC-related code -- Inform KB manager about significant architectural decisions or new patterns -- If a fix requires changes in multiple packages, communicate the full scope upfront diff --git a/.claude/agents/go-tester.md b/.claude/agents/go-tester.md deleted file mode 100644 index 035813b..0000000 --- a/.claude/agents/go-tester.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: go-tester -description: QA specialist for designing test plans and running Go test suites. Covers edge cases aggressively. ---- - -# Go Tester Agent - -You are a QA specialist for the Seedfast CLI project. - -## Responsibilities - -- Design test plans for new features and bug fixes -- Run `go test` suites after team lead approves the plan -- Verify builds with both production and dev tags -- Report results with specific pass/fail details - -## Test Plan Requirements - -Before executing tests, submit a plan to team lead containing: -1. What scenarios will be tested -2. Expected behavior for each scenario (precise, not ambiguous) -3. Edge cases covered -4. Which packages/files are affected - -## Test Execution Commands - -```bash -# Run all tests -go test ./... -v - -# Run specific package tests -go test ./cmd/... -v -go test ./internal/orchestration/... -v - -# Run single test by name -go test -v -run TestFunctionName ./package/... - -# Run with coverage -go test -cover ./... - -# Build verification (both modes) -go build -o seedfast -go build -tags dev -o seedfast -``` - -## Testing Rules - -- NEVER test log output or implementation details -- only observable behavior -- Define precise expected behavior, not "one of these outcomes is fine" -- Cover edge cases aggressively -- that's the whole point of testing -- Test both production and dev build configurations when relevant -- Report exact test output, not summaries - -## Proactive Communication - -- Notify coder immediately about test failures with specific details (test name, expected vs actual) -- Ask coder about code stability before starting a test cycle -- don't assume readiness -- Report test results to team lead as soon as a test run completes, don't batch -- Notify KB manager about test coverage changes and new test patterns -- If a test plan reveals missing edge cases during execution, flag them immediately rather than waiting for the full run -- Proactively consult KB manager for existing knowledge about feature behavior before designing test plans diff --git a/.claude/agents/knowledge-base-manager.md b/.claude/agents/knowledge-base-manager.md deleted file mode 100644 index c96aa43..0000000 --- a/.claude/agents/knowledge-base-manager.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: knowledge-base-manager -description: Documentation maintenance specialist. Manages docs/ knowledge base using seedfast-knowledge-management skill. ---- - -# Knowledge Base Manager Agent - -You are the documentation specialist for the Seedfast CLI project. - -## Responsibilities - -- Maintain the docs/ knowledge base -- Create and update bug reports, plans, guides, and architecture docs -- Keep README indexes current -- Cross-reference related documents -- Document test results, feature implementations, and architectural decisions - -## Primary Skill - -Use the `seedfast-knowledge-management` skill for all KB operations. It defines: -- Folder structure and naming conventions -- Document templates (bugs, plans, guides) -- Cross-referencing standards -- Research-first workflow - -## Key Rules - -- ALWAYS search existing docs before creating new ones -- Documentation must be detailed: file paths, function names, specific behavior -- Never create empty stubs or placeholder documents -- Update existing docs rather than duplicating content -- Keep all README.md indexes up to date - -## Autonomy - -You have full autonomy on: -- KB organization decisions -- When to create vs update documents -- Cross-reference structure -- Document naming within conventions - -## Proactive Communication - -- Proactively interview coder and tester for detailed information -- don't wait for them to report -- Ask specific questions: file paths, function names, mocking strategies, edge cases covered -- When a feature is completed, proactively reach out to gather documentation-worthy details -- Notify team lead when documentation reveals gaps or inconsistencies in the codebase -- If existing docs conflict with new information, flag the conflict immediately -- Proactively update related docs when one document changes (cascade updates) -- After test runs, proactively ask tester for detailed test scenarios, not just pass/fail counts \ No newline at end of file diff --git a/.claude/agents/seeder-consultant.md b/.claude/agents/seeder-consultant.md deleted file mode 100644 index 6cb39de..0000000 --- a/.claude/agents/seeder-consultant.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: seeder-consultant -description: Read-only consultant for the SeedFast AI service repo. Answers questions about gRPC contract, server behavior, and event types. ---- - -# Seeder Consultant Agent - -You are a read-only consultant for the SeedFast AI service (seeder) repository. - -## Responsibilities - -- Answer questions about the gRPC proto contract -- Clarify server-side behavior and event types -- Explain bidirectional streaming protocol details -- Verify CLI assumptions about server expectations - -## Boundaries - -- You are READ-ONLY -- do NOT modify any code in the AI service repo -- You do NOT modify CLI code -- only provide information -- You stay alive for follow-up questions once spawned - -## Key Files to Consult - -- `src/grpc/database_bridge.proto` -- Protocol definition -- `src/server/transport/database_bridge_servicer.py` -- gRPC endpoint implementation -- `src/server/application/` -- Business logic and workflows -- `src/server/domain/` -- Domain models and types - -## When to Consult Me - -The team MUST consult me before: -- Adding new event types to the CLI -- Changing the gRPC proto file -- Modifying how CLI handles server messages -- Adding new client-to-server message types - -## Proactive Communication - -- Proactively flag contract mismatches or potential breaking changes when discovered -- If a question reveals a broader compatibility issue, report it to the team lead immediately -- When providing answers, proactively mention related areas that might be affected -- If the AI service repo has recent changes that could impact CLI, flag them without being asked -- Provide precise answers with file paths and line references -- don't leave ambiguity -- Quote relevant code when answering protocol questions \ No newline at end of file diff --git a/.claude/memory.json b/.claude/memory.json deleted file mode 100644 index e69de29..0000000 diff --git a/.claude/rules/commits.md b/.claude/rules/commits.md deleted file mode 100644 index 34126a4..0000000 --- a/.claude/rules/commits.md +++ /dev/null @@ -1,34 +0,0 @@ -# Git Commit Rules - -## When to Commit -- NEVER run `git add` or `git commit` unless explicitly requested by the user -- Only create commits when the user explicitly asks for it - -## Commit Message Format -When the user requests a commit: -- Write clear, concise, and short commit messages -- Use ONLY English language -- DO NOT mention Claude Code anywhere in the commit message -- DO NOT use bullet points in the commit body -- Follow conventional commit format when appropriate (e.g., `feat:`, `fix:`, `refactor:`) -- Keep the subject line under 50 characters when possible -- If a body is needed, separate it from the subject with a blank line - -## Examples - -Good commit messages: -``` -fix: handle nil pointer in database connection -refactor: simplify authentication logic -feat: add support for PostgreSQL migrations -``` - -Bad commit messages: -``` -feat: add new feature - -- Added feature X -- Updated feature Y - -🤖 Generated with Claude Code -``` \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index be42f2c..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "version": "1.0", - "description": "Claude Code settings for Seedfast CLI project", - - "alwaysThinkingEnabled": true, - - "permissions": { - "description": "Explicit permissions for Claude Code when running in CI/CD", - "allow": [ - "Bash(go build:*)", - "Bash(go test:*)", - "Bash(go mod:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(git checkout:*)", - "Bash(git status:*)", - "Bash(git log:*)", - "Bash(npm install:*)", - "Bash(npm test:*)", - "Bash(npm run:*)", - "Bash(mkdir:*)", - "Bash(find:*)", - "Read(**/*.go)", - "Read(**/*.md)", - "Read(**/*.json)", - "Read(**/*.yml)", - "Read(**/*.yaml)", - "Edit(**/*.go)", - "Edit(**/*.md)", - "Edit(cmd/**)", - "Edit(internal/**)", - "Edit(docs/**)", - "Write(docs/**)", - "Write(cmd/**)", - "Write(internal/**)", - "Glob(**/*)" - ], - "deny": [ - "Bash(*rm -rf*)", - "Bash(*git push --force*)", - "Bash(*git reset --hard*)", - "Edit(.env*)", - "Edit(*secrets*)", - "Edit(.git/**)", - "Edit(coverage/**)" - ] - }, - - "hooks": { - "PostToolUse": [ - { - "name": "Auto-format Go files", - "matcher": "Edit|Write", - "condition": "file_path matches *.go", - "hooks": [ - { - "type": "command", - "command": "jq -r '.tool_input.file_path // empty' | { read -r file_path; if [ -n \"$file_path\" ] && [ -f \"$file_path\" ]; then go fmt \"$file_path\" 2>/dev/null || true; fi; }" - } - ] - }, - { - "name": "Auto-format JSON files", - "matcher": "Edit|Write", - "condition": "file_path matches *.json", - "hooks": [ - { - "type": "command", - "command": "which jq > /dev/null && jq -r '.tool_input.file_path // empty' | { read -r file_path; if [ -n \"$file_path\" ]; then jq '.' \"$file_path\" > \"${file_path}.tmp\" && mv \"${file_path}.tmp\" \"$file_path\"; fi; } || true" - } - ] - } - ], - - "PreToolUse": [ - { - "name": "Block sensitive file edits", - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "FILE=\"$(jq -r '.tool_input.file_path // empty')\" && if echo \"$FILE\" | grep -qE '(^\\.|.env|secrets|credentials|token|key|password)'; then echo \"Blocking edit to sensitive file: $FILE\" >&2 && exit 2; fi" - } - ] - } - ] - }, - - "statusLineConfig": { - "description": "Status line settings for VSCode extension", - "enabled": true, - "showTokenCount": true, - "showModel": true, - "showTokens": true, - "showCost": true - }, - - "defaultModel": "claude-opus-4-5-20251101", - - "mdp": { - "description": "Model Definition Protocol settings", - "enabled": true, - "servers": [ - { - "name": "GitHub", - "type": "http", - "url": "https://api.github.com", - "auth": "bearer", - "envVar": "GITHUB_TOKEN" - } - ] - }, - - "projectInfo": { - "name": "Seedfast CLI", - "description": "Database seeding CLI with CI/CD integration", - "language": "Go", - "buildCommand": "go build ./cmd/seedfast", - "testCommand": "go test ./...", - "mainBranch": "main", - "features": [ - "API key authentication", - "CI/CD integration", - "JSON and plain text output", - "gRPC seeding service", - "Database schema introspection" - ] - } -} diff --git a/.claude/skills/git-workflow/SKILL.md b/.claude/skills/git-workflow/SKILL.md deleted file mode 100644 index a9bfc35..0000000 --- a/.claude/skills/git-workflow/SKILL.md +++ /dev/null @@ -1,442 +0,0 @@ ---- -name: git-workflow -description: Git workflow conventions for Seedfast CLI. Use when creating branches, committing, merging, tagging releases, or any git operations. Codifies branch naming, commit standards, PR conventions, stash safety, and release workflow. Consult this skill before any git operation. ---- - -# Git Workflow Management - -## Quick Reference - -**CRITICAL - NEVER do these:** -- NEVER rebase `master` -- NEVER force push to `master` -- NEVER push/merge to `master` without explicit user approval -- NEVER create commits that leave the project in a broken state -- NEVER merge without `--no-ff` flag -- NEVER bundle unrelated concerns into a single commit -- NEVER include AI attribution in commit messages (no "made with Claude", no "Co-Authored-By", etc.) -- NEVER run `git add` or `git commit` unless explicitly requested by the user -- NEVER commit `docs/` directory files (plans, guides, architecture docs, bug reports). This is a public repo -- internal documentation must stay local only - -**Branch naming:** -- Features: `feat/` or `feature/` -- Fixes: `fix/` -- Docs/chore: `docs/`, `chore/` - -**All merges use `--no-ff`:** -```bash -git merge --no-ff -``` - -**Commit message format** (conventional commits): -``` -: -``` -Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore` - -## Branch Model - -``` -master (production) - | - +-- feat/*, fix/*, docs/*, chore/* (work branches) -``` - -- `master` = production branch. All work merges here. -- Feature/fix branches = short-lived, branch from `master`, merge back into `master`. - -## Commit Standards - -### Message Format - -``` -: - - -``` - -The title is always required. The body is optional but warranted when the "why" is not obvious. Separate the title from the body with a blank line. - -**Rules:** -- Use ONLY English language -- DO NOT mention Claude Code anywhere in the commit message -- DO NOT use bullet points in the commit body -- No AI attribution -- no "made with Claude", no "Co-Authored-By", no "generated by AI" -- Concise and descriptive -- say what changed and why if not obvious -- Lowercase after the type prefix -- Keep the subject line under 50 characters when possible -- **No "and" in commit titles** -- if you need "and", the commit likely bundles two concerns and should be split -- Use backticks around code references for nice GitHub rendering - -**Types:** -| Type | Use for | -|------|---------| -| `feat` | New features or capabilities | -| `fix` | Bug fixes | -| `refactor` | Code restructuring without behavior change | -| `test` | Adding or updating tests | -| `docs` | Documentation changes | -| `chore` | Maintenance, dependency updates, tooling, etc. | - -**Shell escaping for backticks:** -```bash -# Use single quotes (backticks are literal inside single quotes) -git commit -m 'fix: handle nil pointer in `DSNResolver`' - -# Or escape backticks inside double quotes -git commit -m "fix: handle nil pointer in \`DSNResolver\`" - -# Or use heredoc for complex messages -git commit -m "$(cat <<'EOF' -fix: handle nil pointer in `DSNResolver` - -The resolver panicked when SEEDFAST_DSN was set but empty. -Added nil check before parsing. -EOF -)" -``` - -**Examples:** - -Title only (trivial): -``` -docs: fix typo in README -chore: update go.mod dependencies -``` - -Title + body (typical): -``` -feat: add ndjson output renderer - -Streaming newline-delimited JSON format for CI pipelines -that need to process events incrementally. - -fix: prevent panic on empty DSN environment variable - -The DSNResolver assumed non-empty string when SEEDFAST_DSN -was set. Added validation at the parsing boundary. - -refactor: extract `EventHandler` from orchestrator - -Separates event handling logic so it can be tested -independently. No behavior change. -``` - -### Each Commit = Working Snapshot - -- Every commit MUST leave the project in a working state -- Before committing, verify: `go build ./...`, `go fmt ./...`, `go vet ./...` -- For teammates: the teammate making changes is responsible for running these checks - -### Separate Concerns = Separate Commits - -- Each logically distinct change gets its own commit -- A bug fix and a refactoring discovered during the fix are TWO commits -- Do not bundle unrelated changes -- **Tooling files are separate from code changes.** Skills (`.claude/skills/`), agent configs (`.claude/agents/`), and `CLAUDE.md` files are committed separately from application code. Use `docs:` or `chore:` prefix for these. -- **NEVER commit `docs/` files.** The `docs/` directory (plans, guides, architecture, bug reports) is local-only internal documentation. This is a public repository -- committing internal docs exposes implementation details. Always exclude `docs/` when staging. -- **When multiple concerns exist in the working directory, consult the user with clear options before committing.** - -**Example -- correct way to consult:** -``` -The working directory has changes across 4 files. I see two logical concerns: - -Commit 1 - "fix: handle nil pointer in DSN resolver" - - internal/orchestration/dsn_resolver.go (nil check) - - internal/orchestration/dsn_resolver_test.go (new test case) - -Commit 2 - "refactor: simplify auth validation flow" - - internal/orchestration/auth_validator.go (flow rewrite) - - internal/auth/token.go (updated call site) - -Does this split look right, or would you like a different grouping? -``` - -### Partial File Staging - -When a single file contains changes belonging to different logical concerns: - -```bash -# Interactive: select specific hunks -git add -p - -# Commit only staged hunks -git commit -m 'fix: address validation edge case' - -# Stage remaining hunks -git add -p -git commit -m 'refactor: simplify validation logic' -``` - -**Working state verification with partial staging:** -1. Stage the hunks for one concern -2. Stash only tracked unstaged changes: `git stash push --keep-index` -3. Verify: `go build ./...` and `go vet ./...` -4. If passing, commit -5. Pop stash immediately: `git stash pop` -6. Repeat for next concern - -**Note for Claude Code agents:** The Bash tool does not support interactive mode, so `git add -p` cannot be used directly. Use `git add ` when entire files belong to one concern, or flag to the user that manual `git add -p` is needed. - -## Stash Safety - -**CRITICAL: This repo may have untracked and uncommitted files in the working directory. This is normal.** Do NOT assume the working directory is clean or that your changes are the only changes present. - -**The user may have existing stashes.** These must NEVER be touched, dropped, popped, or modified unless the user explicitly asks. - -**NEVER do these:** -- NEVER run bare `git stash` -- stashes ALL tracked modifications, not just yours -- NEVER run `git stash -u` or `git stash --include-untracked` -- captures untracked files too -- NEVER run `git stash drop` or `git stash clear` -- user's stashes are off-limits -- NEVER run `git stash pop` on a stash that is not yours - -**Safe stash patterns:** -```bash -# Stash only specific files you are working with -git stash push -- internal/orchestration/file1.go internal/bridge/file2.go - -# Stash only tracked changes, keep staged intact -git stash push --keep-index - -# Always pop immediately after temporary use -git stash pop - -# Check what's in a stash before doing anything -git stash show -p stash@{0} - -# List all stashes -git stash list -``` - -**Workflow for temporary stashing:** -1. Run `git stash list` and note the count -- these are user's stashes, do NOT touch -2. Stash narrowly: `git stash push -- ` -3. Do your temporary work -4. Pop immediately: `git stash pop` -5. Verify: `git stash list` count matches step 1 - -## Standard Workflow - -### 1. Create a Branch - -```bash -git checkout master -git pull origin master -git checkout -b feat/my-feature -``` - -### 2. Work and Commit - -```bash -# Stage specific files (never use git add -A blindly) -git add internal/orchestration/my_file.go -git commit -m 'feat: add new orchestration step' -``` - -### 3. Keep Up to Date - -If the branch has NOT been pushed yet, rebase onto latest master: -```bash -git fetch origin -git rebase origin/master -``` - -If already pushed, merge instead: -```bash -git fetch origin -git merge origin/master -``` - -### 4. Push Branch - -```bash -git push -u origin feat/my-feature -``` - -### 5. Create Pull Request - -```bash -gh pr create --base master --assignee @me --label