diff --git a/README.md b/README.md index 6146d04..d989bfb 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ Ever joined a project and had to check which package manager it uses before runn - ⬆️ **Self-upgrade** — `spm upgrade` updates spm to the latest release from GitHub - 🔎 **Interactive search** — `spm add` with no args lets you search and pick a package from the npm registry - 📋 **Package details** — press Enter on a search result to view metadata (license, downloads, stars, repo) and pick a specific version to install -- ✨ **Progress TUI** — `spm install` shows a live spinner with elapsed time and scrolling logs (use `--raw` for raw output) ### Built With @@ -177,9 +176,6 @@ spm install --notify # Combine vibes and notification spm install --vibes --notify -# Show raw package manager output (skip progress TUI) -spm install --raw - # Run a security audit on dependencies spm audit diff --git a/cmd/root.go b/cmd/root.go index 72c60d1..df5127d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,13 +9,11 @@ import ( "strings" "time" - "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/decampsrenan/spm/internal/audio" "github.com/decampsrenan/spm/internal/detector" "github.com/decampsrenan/spm/internal/ecosystem" - "github.com/decampsrenan/spm/internal/progress" "github.com/decampsrenan/spm/internal/prompt" "github.com/decampsrenan/spm/internal/resolver" "github.com/decampsrenan/spm/internal/runner" @@ -27,7 +25,6 @@ import ( var dryRun bool var vibes bool var notify bool -var rawOutput bool var rootCmd = &cobra.Command{ Use: "spm", @@ -163,7 +160,6 @@ func init() { rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Print command instead of executing it") rootCmd.PersistentFlags().BoolVar(&vibes, "vibes", false, "Play background music during install") rootCmd.PersistentFlags().BoolVar(¬ify, "notify", false, "Play a sound when the command finishes") - rootCmd.PersistentFlags().BoolVar(&rawOutput, "raw", false, "Show raw package manager output (skip progress TUI)") // Allow unknown flags to pass through to the underlying package manager // (e.g. spm add react --save-dev, spm dev --port 3000) rootCmd.FParseErrWhitelist.UnknownFlags = true @@ -352,14 +348,6 @@ func run(command string, extraArgs []string) error { args := resolver.Resolve(det.PM, command, extraArgs) - // Use progress TUI for install/add commands when stdout is a TTY and --raw is not set. - if command == "install" || command == "i" || command == "add" { - isTTY := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) - if isTTY && !rawOutput { - return progress.Run(args, dryRun, vibes, notify) - } - } - return runner.Run(args, dryRun, vibes && command == "install", notify) } diff --git a/go.mod b/go.mod index 6ac0106..71e3e7d 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,18 @@ module github.com/decampsrenan/spm go 1.25.8 require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.2 github.com/gopxl/beep/v2 v2.1.1 github.com/mattn/go-isatty v0.0.8 github.com/spf13/cobra v1.10.2 + golang.org/x/mod v0.34.0 + golang.org/x/sys v0.42.0 ) require ( - charm.land/bubbles/v2 v2.0.0 // indirect - charm.land/bubbletea/v2 v2.0.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect @@ -38,7 +40,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/mod v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect ) diff --git a/internal/progress/model.go b/internal/progress/model.go deleted file mode 100644 index cb47399..0000000 --- a/internal/progress/model.go +++ /dev/null @@ -1,163 +0,0 @@ -package progress - -import ( - "fmt" - "strings" - "time" - - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/decampsrenan/spm/internal/ui" -) - -const maxLogLines = 5 - -// model is the bubbletea model for the install progress TUI. -type model struct { - spinner spinner.Model - lines []string // ring buffer of last N output lines - startTime time.Time - done bool - exitCode int - err error - width int - msgCh <-chan tea.Msg -} - -// Messages - -type outputLineMsg string - -type doneMsg struct { - exitCode int - err error -} - -type timerTickMsg time.Time - -func newProgressModel(msgCh <-chan tea.Msg) model { - s := spinner.New() - s.Spinner = spinner.MiniDot - s.Style = lipgloss.NewStyle().Foreground(ui.ColorPrimary) - - return model{ - spinner: s, - startTime: time.Now(), - width: 80, - msgCh: msgCh, - } -} - -func (m model) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - listenCh(m.msgCh), - tickTimer(), - ) -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - - case tea.WindowSizeMsg: - m.width = msg.Width - return m, nil - - case outputLineMsg: - line := string(msg) - if line = strings.TrimSpace(line); line != "" { - m.addLine(line) - } - return m, listenCh(m.msgCh) - - case doneMsg: - m.done = true - m.exitCode = msg.exitCode - m.err = msg.err - return m, tea.Quit - - case timerTickMsg: - if m.done { - return m, nil - } - return m, tickTimer() - - case spinner.TickMsg: - if m.done { - return m, nil - } - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - return m, nil -} - -func (m model) View() tea.View { - var b strings.Builder - elapsed := time.Since(m.startTime).Truncate(100 * time.Millisecond) - - if m.done { - // Final summary - if m.exitCode == 0 { - status := ui.Success(fmt.Sprintf("Installed in %s", elapsed)) - b.WriteString(" " + status + "\n") - } else { - status := ui.Error(fmt.Sprintf("Failed in %s", elapsed)) - b.WriteString(" " + status + "\n") - } - } else { - // Spinner + status - status := fmt.Sprintf(" %s Installing...%s", - m.spinner.View(), - ui.Dim(fmt.Sprintf("%"+fmt.Sprintf("%d", max(1, m.width-24))+"s", elapsed)), - ) - b.WriteString(status + "\n") - } - - // Log lines — gradient: top lines are faded, bottom line is normal dim - if len(m.lines) > 0 { - b.WriteString("\n") - total := len(m.lines) - for i, line := range m.lines { - // Truncate to terminal width - display := line - maxLen := m.width - 4 - if maxLen > 0 && len(display) > maxLen { - display = display[:maxLen-1] + "…" - } - b.WriteString(" " + ui.DimGradient(display, i, total) + "\n") - } - } - - return tea.NewView(b.String()) -} - -// addLine appends a line to the ring buffer, keeping at most maxLogLines. -func (m *model) addLine(line string) { - if len(m.lines) >= maxLogLines { - m.lines = m.lines[1:] - } - m.lines = append(m.lines, line) -} - -// listenCh returns a command that waits for the next message from the channel. -func listenCh(ch <-chan tea.Msg) tea.Cmd { - return func() tea.Msg { - msg, ok := <-ch - if !ok { - return nil - } - return msg - } -} - -// tickTimer schedules a timer update every 100ms. -func tickTimer() tea.Cmd { - return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { - return timerTickMsg(t) - }) -} diff --git a/internal/progress/progress.go b/internal/progress/progress.go deleted file mode 100644 index 41a4b7d..0000000 --- a/internal/progress/progress.go +++ /dev/null @@ -1,143 +0,0 @@ -package progress - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "os/signal" - "syscall" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/decampsrenan/spm/internal/audio" - "github.com/decampsrenan/spm/internal/ui" -) - -const fadeDuration = 3 * time.Second - -// Run executes the given command with a progress TUI. -// It pipes stdout/stderr through a bubbletea model that shows a spinner, -// scrolling log lines, and elapsed time. -func Run(args []string, dryRun bool, vibes bool, notify bool) error { - if len(args) == 0 { - return fmt.Errorf("no command to run") - } - - if dryRun { - ui.Println(ui.Command(args)) - return nil - } - - bin, err := exec.LookPath(args[0]) - if err != nil { - return fmt.Errorf("%s not found in PATH: %w", args[0], err) - } - - // Start background music if requested. - var vibesProc *audio.VibesProcess - if vibes { - vibesProc, err = audio.StartVibes(fadeDuration) - if err != nil { - ui.Eprintln(ui.Warning(fmt.Sprintf("could not play music: %v", err))) - } - } - - // Set up subprocess with piped output. - cmd := exec.Command(bin, args[1:]...) - cmd.Stdin = os.Stdin - - r, w, err := os.Pipe() - if err != nil { - return fmt.Errorf("create pipe: %w", err) - } - cmd.Stdout = w - cmd.Stderr = w - - if err := cmd.Start(); err != nil { - w.Close() - r.Close() - return fmt.Errorf("start command: %w", err) - } - w.Close() // close write end in parent — child has its own fd - - // Single channel for ordered messages to the TUI. - msgCh := make(chan tea.Msg, 100) - - go func() { - scanner := bufio.NewScanner(r) - for scanner.Scan() { - msgCh <- outputLineMsg(scanner.Text()) - } - r.Close() - - // Wait for process after all output has been read. - waitErr := cmd.Wait() - exitCode := 0 - if waitErr != nil { - if exitErr, ok := waitErr.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } - } - msgCh <- doneMsg{exitCode: exitCode, err: waitErr} - close(msgCh) - }() - - // Run TUI. - m := newProgressModel(msgCh) - p := tea.NewProgram(m) - - // Handle signals. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(sigCh) - - go func() { - s := <-sigCh - if vibesProc != nil { - vibesProc.StopImmediately() - } - _ = cmd.Process.Kill() - p.Quit() - ui.DrainTerminalResponses() - if s == syscall.SIGTERM { - os.Exit(143) - } - os.Exit(130) - }() - - finalModel, err := p.Run() - ui.DrainTerminalResponses() - if err != nil { - return fmt.Errorf("progress TUI error: %w", err) - } - - // Extract exit code from final model. - fm, ok := finalModel.(model) - exitCode := 0 - if ok { - exitCode = fm.exitCode - } - - // Stop music. - if vibesProc != nil { - if notify { - vibesProc.StopImmediately() - } else { - vibesProc.FadeOutAndDetach() - } - } - - // Play notification sound. - if notify { - sound := audio.NotificationSound(exitCode == 0, vibes) - _ = audio.PlayNotification(sound) - } - - if exitCode != 0 { - os.Exit(exitCode) - } - - return nil -} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index b3b3cd1..4c9dca1 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,7 +1,6 @@ package ui import ( - "fmt" "image/color" "os" "strings" @@ -66,35 +65,6 @@ func Dim(msg string) string { return StyleDim.Render(msg) } -// DimGradient returns a dimmed message with gradient intensity. -// level 0 = most faded (top), level total-1 = normal dim (bottom). -func DimGradient(msg string, level, total int) string { - if total <= 1 { - return StyleDim.Render(msg) - } - t := float64(level) / float64(total-1) // 0.0 (faded) → 1.0 (normal) - - var r, g, b uint8 - if hasDarkBG { - // Dark mode: #374151 (faded) → #6B7280 (normal dim) - r = lerp(0x37, 0x6B, t) - g = lerp(0x41, 0x72, t) - b = lerp(0x51, 0x80, t) - } else { - // Light mode: #9CA3AF (faded) → #4B5563 (normal dim) - r = lerp(0x9C, 0x4B, t) - g = lerp(0xA3, 0x55, t) - b = lerp(0xAF, 0x63, t) - } - - c := lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", r, g, b)) - return lipgloss.NewStyle().Foreground(c).Render(msg) -} - -func lerp(a, b uint8, t float64) uint8 { - return uint8(float64(a) + (float64(b)-float64(a))*t) -} - // Command returns a styled command preview for dry-run output. func Command(args []string) string { label := StyleDim.Render("Would run: ") diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 23aa861..8ef27de 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -63,51 +63,3 @@ func TestPathContainsPath(t *testing.T) { t.Errorf("Path() should contain the path, got: %s", result) } } - -func TestDimGradientContainsMessage(t *testing.T) { - result := DimGradient("log line", 0, 5) - if !strings.Contains(result, "log line") { - t.Errorf("DimGradient() should contain the message, got: %s", result) - } -} - -func TestDimGradientSingleLine(t *testing.T) { - // With total <= 1, should fall back to normal Dim style. - result := DimGradient("only line", 0, 1) - if !strings.Contains(result, "only line") { - t.Errorf("DimGradient() with total=1 should contain the message, got: %s", result) - } -} - -func TestDimGradientAllLevels(t *testing.T) { - total := 5 - for i := 0; i < total; i++ { - result := DimGradient("line", i, total) - if !strings.Contains(result, "line") { - t.Errorf("DimGradient(level=%d) should contain the message, got: %s", i, result) - } - } -} - -func TestLerp(t *testing.T) { - tests := []struct { - a, b uint8 - t float64 - want uint8 - }{ - {0, 100, 0.0, 0}, - {0, 100, 1.0, 100}, - {0, 100, 0.5, 50}, - {100, 0, 0.5, 50}, // reverse direction - {0x37, 0x6B, 0.0, 0x37}, // dark mode red at start - {0x37, 0x6B, 1.0, 0x6B}, // dark mode red at end - {0x9C, 0x4B, 0.0, 0x9C}, // light mode red at start - {0x9C, 0x4B, 1.0, 0x4B}, // light mode red at end - } - for _, tt := range tests { - got := lerp(tt.a, tt.b, tt.t) - if got != tt.want { - t.Errorf("lerp(%d, %d, %.1f) = %d, want %d", tt.a, tt.b, tt.t, got, tt.want) - } - } -}