main
1package apt
2
3import (
4 "fmt"
5 "os/exec"
6 "strings"
7 "time"
8
9 "github.com/charmbracelet/bubbles/progress"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
12)
13
14type progressModel struct {
15 progress progress.Model
16 current int
17 total int
18 done bool
19 spinner int
20 packages []string
21 currentPackage string
22}
23
24var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
25
26func (m progressModel) Init() tea.Cmd {
27 return tea.Tick(time.Millisecond*100, func(time.Time) tea.Msg {
28 return tickMsg{}
29 })
30}
31
32func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33 switch msg := msg.(type) {
34 case tea.KeyMsg:
35 return m, tea.Quit
36 case tickMsg:
37 if !m.done {
38 m.spinner = (m.spinner + 1) % len(spinnerFrames)
39 return m, tea.Tick(time.Millisecond*150, func(time.Time) tea.Msg {
40 return tickMsg{}
41 })
42 }
43 return m, nil
44 case progressMsg:
45 m.current = msg.current
46 m.total = msg.total
47 m.currentPackage = msg.currentPackage
48 if m.current >= m.total {
49 m.done = true
50 return m, tea.Quit
51 }
52 return m, nil
53 }
54 return m, nil
55}
56
57func (m progressModel) View() string {
58 if m.done {
59 // Completion style with apt theme (debian red)
60 checkmark := lipgloss.NewStyle().
61 Foreground(lipgloss.Color("#d70751")).
62 Bold(true).
63 Render("✓")
64
65 title := lipgloss.NewStyle().
66 Foreground(lipgloss.Color("#ffffff")).
67 Bold(true).
68 Render("APT Packages Installed")
69
70 stats := lipgloss.NewStyle().
71 Foreground(lipgloss.Color("#666666")).
72 Render(fmt.Sprintf("(%d packages)", len(m.packages)))
73
74 return fmt.Sprintf("\n %s %s %s\n\n", checkmark, title, stats)
75 }
76
77 // Progress bar styling with apt-inspired colors (debian red theme)
78 percentage := float64(m.current) / float64(m.total)
79
80 // Styled spinner
81 spinner := lipgloss.NewStyle().
82 Foreground(lipgloss.Color("#d70751")).
83 Bold(true).
84 Render(spinnerFrames[m.spinner])
85
86 // Styled title
87 title := lipgloss.NewStyle().
88 Foreground(lipgloss.Color("#ffffff")).
89 Bold(true).
90 Render("Installing APT Packages")
91
92 // Progress bar with custom styling
93 progressBar := m.progress.ViewAs(percentage)
94
95 // Styled stats
96 stats := lipgloss.NewStyle().
97 Foreground(lipgloss.Color("#888888")).
98 Render(fmt.Sprintf("%d packages (%.1f%%)", len(m.packages), percentage*100))
99
100 // Current package indicator
101 packageIndicator := ""
102 if m.currentPackage != "" {
103 packageIndicator = lipgloss.NewStyle().
104 Foreground(lipgloss.Color("#a50e4e")).
105 Render(fmt.Sprintf(" • installing %s...", m.currentPackage))
106 }
107
108 return fmt.Sprintf("\n %s %s\n %s\n %s%s\n",
109 spinner, title, progressBar, stats, packageIndicator)
110}
111
112type progressMsg struct {
113 current int
114 total int
115 currentPackage string
116}
117
118type tickMsg struct{}
119
120// Install installs the specified apt packages with progress display
121func Install(packages ...string) error {
122 if len(packages) == 0 {
123 return nil // Nothing to install
124 }
125
126 // Check if apt is available
127 if _, err := exec.LookPath("apt"); err != nil {
128 return fmt.Errorf("apt not found: %w", err)
129 }
130
131 // Setup enhanced progress bar with apt-inspired colors (debian red theme)
132 p := progress.New(
133 progress.WithScaledGradient("#d70751", "#a50e4e"),
134 progress.WithWidth(52),
135 progress.WithoutPercentage(),
136 )
137
138 model := progressModel{
139 progress: p,
140 total: 100, // Using percentage-based progress
141 packages: packages,
142 }
143
144 program := tea.NewProgram(model)
145 var installErr error
146
147 go func() {
148 // Update package lists first (5% of progress)
149 program.Send(progressMsg{
150 current: 0,
151 total: 100,
152 currentPackage: "updating package lists",
153 })
154
155 updateCmd := exec.Command("sudo", "apt", "update", "-qq")
156 if err := updateCmd.Run(); err != nil {
157 installErr = fmt.Errorf("updating package lists: %w", err)
158 program.Quit()
159 return
160 }
161
162 // Start installation of all packages at once
163 program.Send(progressMsg{
164 current: 5,
165 total: 100,
166 currentPackage: fmt.Sprintf("installing %s", strings.Join(packages, ", ")),
167 })
168
169 // Install all packages in a single command
170 args := append([]string{"apt", "install", "-y", "-qq"}, packages...)
171 installCmd := exec.Command("sudo", args...)
172
173 // Start the command
174 if err := installCmd.Start(); err != nil {
175 installErr = fmt.Errorf("starting apt install: %w", err)
176 program.Quit()
177 return
178 }
179
180 // Estimate progress based on time (apt installs usually take 30-90 seconds)
181 // We'll simulate progress from 5% to 95% over an estimated time
182 estimatedDuration := time.Duration(len(packages)) * 8 * time.Second // ~8 seconds per package
183 startTime := time.Now()
184
185 // Progress ticker
186 ticker := time.NewTicker(500 * time.Millisecond)
187 defer ticker.Stop()
188
189 done := make(chan error, 1)
190 go func() {
191 done <- installCmd.Wait()
192 }()
193
194 for {
195 select {
196 case err := <-done:
197 if err != nil {
198 installErr = fmt.Errorf("installing packages: %w", err)
199 program.Quit()
200 return
201 }
202 // Signal completion
203 program.Send(progressMsg{
204 current: 100,
205 total: 100,
206 })
207 return
208
209 case <-ticker.C:
210 elapsed := time.Since(startTime)
211 progress := float64(elapsed) / float64(estimatedDuration)
212 if progress > 0.9 {
213 progress = 0.9 // Cap at 90% until actually done
214 }
215
216 currentPercent := int(5 + (progress * 85)) // 5% to 90%
217
218 program.Send(progressMsg{
219 current: currentPercent,
220 total: 100,
221 currentPackage: fmt.Sprintf("installing %s", strings.Join(packages, ", ")),
222 })
223 }
224 }
225 }()
226
227 _, err := program.Run()
228 if err != nil {
229 return fmt.Errorf("running progress: %w", err)
230 }
231
232 if installErr != nil {
233 return installErr
234 }
235
236 return nil
237}
238
239// InstallList is a convenience function for installing from a slice
240func InstallList(packages []string) error {
241 return Install(packages...)
242}
243
244// CheckInstalled checks if packages are already installed
245func CheckInstalled(packages ...string) ([]string, error) {
246 var missing []string
247
248 for _, pkg := range packages {
249 cmd := exec.Command("dpkg", "-l", pkg)
250 if err := cmd.Run(); err != nil {
251 missing = append(missing, pkg)
252 }
253 }
254
255 return missing, nil
256}
257
258// InstallMissing only installs packages that aren't already installed
259func InstallMissing(packages ...string) error {
260 missing, err := CheckInstalled(packages...)
261 if err != nil {
262 return fmt.Errorf("checking installed packages: %w", err)
263 }
264
265 if len(missing) == 0 {
266 return nil // All packages already installed
267 }
268
269 return Install(missing...)
270}
271
272// GetPackageList returns a formatted list of packages for display
273func GetPackageList(packages []string) string {
274 return strings.Join(packages, ", ")
275}