@@ -5,11 +5,16 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "strings"
89
10+ "github.com/charmbracelet/bubbles/spinner"
11+ tea "github.com/charmbracelet/bubbletea"
12+ "github.com/charmbracelet/lipgloss"
913 "github.com/dave/dst/decorator"
1014 "github.com/newrelic/go-easy-instrumentation/internal/comment"
1115 "github.com/newrelic/go-easy-instrumentation/parser"
1216 "github.com/spf13/cobra"
17+ "golang.org/x/term"
1318 "golang.org/x/tools/go/packages"
1419)
1520
@@ -20,12 +25,11 @@ const (
2025 defaultAppName = ""
2126 defaultOutputFilePath = ""
2227 defaultDiffFileName = "new-relic-instrumentation.diff"
23- defaultDebug = false
2428)
2529
2630var (
27- debug bool
28- diffFile string
31+ diffFile string
32+ excludeDirs string
2933)
3034
3135var instrumentCmd = & cobra.Command {
@@ -73,56 +77,277 @@ func setOutputFilePath(outputFilePath, applicationPath string) (string, error) {
7377
7478const LoadMode = packages .LoadSyntax | packages .NeedForTest
7579
76- func Instrument (packagePath string ) {
80+ // Bubble Tea Model
81+ type model struct {
82+ spinner spinner.Model
83+ stepDesc string
84+ totalSteps int
85+ currentStep int
86+ done bool
87+ err error
88+ packages []* decorator.Package
89+ pkgPath string
90+ sub chan tea.Msg
91+ outputFile string
92+ }
93+
94+ // Messages
95+ // Messages
96+ type progressMsg struct {
97+ desc string
98+ }
99+ type pkgLoadedMsg []* decorator.Package
100+ type errMsg error
101+ type completedMsg struct {}
102+
103+ func initialModel (pkgPath , outputFile string ) model {
104+ s := spinner .New ()
105+ s .Spinner = spinner .Dot
106+ s .Style = lipgloss .NewStyle ().Foreground (lipgloss .Color ("#1CE783" ))
107+ return model {
108+ spinner : s ,
109+ stepDesc : "Loading packages..." ,
110+ totalSteps : 8 ,
111+ pkgPath : pkgPath ,
112+ outputFile : outputFile ,
113+ }
114+ }
115+
116+ func (m model ) Init () tea.Cmd {
117+ return tea .Batch (
118+ m .spinner .Tick ,
119+ waitForNext (m .sub ),
120+ )
121+ }
122+
123+ func Instrument (packagePath string , patterns ... string ) {
77124 if packagePath == "" {
78125 cobra .CheckErr ("path argument cannot be empty" )
79126 }
80-
81127 if _ , err := os .Stat (packagePath ); err != nil {
82128 cobra .CheckErr (fmt .Errorf ("path argument \" %s\" is invalid: %v" , packagePath , err ))
83129 }
84-
85130 outputFile , err := setOutputFilePath (diffFile , packagePath )
86131 cobra .CheckErr (err )
87132 if debug {
88133 comment .EnableConsolePrinter (packagePath )
89134 }
90135
91- pkgs , err := decorator .Load (& packages.Config {Dir : packagePath , Mode : LoadMode , Tests : true }, defaultPackageName )
92- cobra .CheckErr (err )
136+ // If debug mode is enabled or no terminal is available (CI/CD), run in text mode (no TUI)
137+ if debug || ! term .IsTerminal (int (os .Stdout .Fd ())) {
138+ runTextMode (packagePath , patterns , outputFile )
139+ return
140+ }
141+
142+ // Normal TUI mode
143+ runTUIMode (packagePath , patterns , outputFile )
144+ }
145+
146+ // runTextMode runs the instrumentation pipeline with plain text output to stdout.
147+ // This is used when the TUI is unavailable (e.g. CI/CD, piped output) or when
148+ // the --debug flag is enabled. It delegates to instrumentPackages for the core
149+ // logic and handles printing status and exit on error.
150+ func runTextMode (packagePath string , patterns []string , outputFile string ) {
151+ fmt .Printf ("Instrumentation started for %s\n " , packagePath )
152+ fmt .Printf ("Output file: %s\n \n " , outputFile )
153+
154+ if err := instrumentPackages (packagePath , patterns , outputFile ); err != nil {
155+ fmt .Printf ("Error: %v\n " , err )
156+ os .Exit (1 )
157+ }
158+
159+ fmt .Printf ("\n Done! Changes written to: %s\n Tip: Apply these changes with: git apply %s\n " , outputFile , outputFile )
160+ }
161+
162+ // instrumentPackages loads Go packages from packagePath, runs the full
163+ // instrumentation pipeline, and writes the resulting diff to outputFile.
164+ // It returns an error rather than exiting the process, making it safe to
165+ // call from both runTextMode (which handles os.Exit) and from tests.
166+ func instrumentPackages (packagePath string , patterns []string , outputFile string ) error {
167+ loadPatterns := patterns
168+ if len (loadPatterns ) == 0 {
169+ loadPatterns = []string {defaultPackageName }
170+ }
171+
172+ fmt .Println (" -> Loading packages..." )
173+ pkgs , err := decorator .Load (& packages.Config {Dir : packagePath , Mode : LoadMode , Tests : true }, loadPatterns ... )
174+ if err != nil {
175+ return fmt .Errorf ("loading packages: %w" , err )
176+ }
93177
94178 manager := parser .NewInstrumentationManager (pkgs , defaultAppName , defaultAgentVariableName , outputFile , packagePath )
95- err = manager .CreateDiffFile ()
96- cobra .CheckErr (err )
97179
98- err = manager .DetectDependencyIntegrations ()
99- cobra .CheckErr (err )
180+ steps := []struct {
181+ desc string
182+ fn func () error
183+ }{
184+ {"Creating diff file" , manager .CreateDiffFile },
185+ {"Detecting dependencies" , manager .DetectDependencyIntegrations },
186+ {"Tracing package calls" , manager .TracePackageCalls },
187+ {"Scanning application" , manager .ScanApplication },
188+ {"Instrumenting application" , manager .InstrumentApplication },
189+ {"Resolving unit tests" , manager .ResolveUnitTests },
190+ {"Adding required modules" , manager .AddRequiredModules },
191+ {"Writing diff file" , func () error {
192+ comment .WriteAll ()
193+ return manager .WriteDiff (func (msg string ) {})
194+ }},
195+ }
100196
101- err = manager .TracePackageCalls ()
102- cobra .CheckErr (err )
197+ for _ , step := range steps {
198+ if err := step .fn (); err != nil {
199+ return fmt .Errorf ("%s: %w" , step .desc , err )
200+ }
201+ }
103202
104- err = manager . ScanApplication ()
105- cobra . CheckErr ( err )
203+ return nil
204+ }
106205
107- err = manager .InstrumentApplication ()
108- cobra .CheckErr (err )
206+ func runTUIMode (packagePath string , patterns []string , outputFile string ) {
207+ // Channel to receive updates from the worker
208+ updates := make (chan tea.Msg )
109209
110- err = manager .ResolveUnitTests ()
111- cobra .CheckErr (err )
210+ // Worker goroutine
211+ go func () {
212+ loadPatterns := patterns
213+ if len (loadPatterns ) == 0 {
214+ loadPatterns = []string {defaultPackageName }
215+ }
112216
113- err = manager .AddRequiredModules ()
114- cobra .CheckErr (err )
217+ pkgs , err := decorator .Load (& packages.Config {Dir : packagePath , Mode : LoadMode , Tests : true }, loadPatterns ... )
218+ if err != nil {
219+ updates <- errMsg (err )
220+ return
221+ }
115222
116- // write debug comments before writing diff so that
117- // diff file console log is still easy to see
118- comment .WriteAll ()
119- err = manager .WriteDiff ()
120- cobra .CheckErr (err )
223+ updates <- pkgLoadedMsg (pkgs )
224+
225+ manager := parser .NewInstrumentationManager (pkgs , defaultAppName , defaultAgentVariableName , outputFile , packagePath )
226+
227+ steps := []struct {
228+ desc string
229+ fn func () error
230+ }{
231+ {"Creating diff file" , manager .CreateDiffFile },
232+ {"Detecting dependencies" , manager .DetectDependencyIntegrations },
233+ {"Tracing package calls" , manager .TracePackageCalls },
234+ {"Scanning application" , manager .ScanApplication },
235+ {"Instrumenting application" , manager .InstrumentApplication },
236+ {"Resolving unit tests" , manager .ResolveUnitTests },
237+ {"Adding required modules" , manager .AddRequiredModules },
238+ {"Writing diff file" , func () error {
239+ comment .WriteAll ()
240+ // Pass a callback to WriteDiff to receive granular progress updates.
241+ // This callback updates the UI with the name of the file currently being written,
242+ // avoiding a "stalled" UI during this potentially long-running step.
243+ return manager .WriteDiff (func (msg string ) {
244+ updates <- progressMsg {desc : msg }
245+ })
246+ }},
247+ }
248+
249+ for _ , step := range steps {
250+ updates <- progressMsg {desc : step .desc }
251+ if err := step .fn (); err != nil {
252+ updates <- errMsg (err )
253+ return
254+ }
255+ }
256+
257+ updates <- completedMsg {}
258+ close (updates )
259+ }()
260+
261+ initialM := initialModel (packagePath , outputFile )
262+ initialM .sub = updates
263+
264+ finalModel , err := tea .NewProgram (initialM ).Run ()
265+ if err != nil {
266+ fmt .Printf ("Error running program: %v\n " , err )
267+ os .Exit (1 )
268+ }
269+
270+ if m , ok := finalModel .(model ); ok {
271+ if m .err != nil {
272+ os .Exit (1 )
273+ }
274+ if m .done {
275+ fmt .Printf ("\n Done! Changes written to: %s\n Tip: Apply these changes with: git apply %s\n " , m .outputFile , m .outputFile )
276+ }
277+ }
278+ }
279+
280+ func (m model ) Update (msg tea.Msg ) (tea.Model , tea.Cmd ) {
281+ switch msg := msg .(type ) {
282+ case tea.KeyMsg :
283+ if msg .Type == tea .KeyCtrlC {
284+ return m , tea .Quit
285+ }
286+ case spinner.TickMsg :
287+ var cmd tea.Cmd
288+ m .spinner , cmd = m .spinner .Update (msg )
289+ return m , cmd
290+ case pkgLoadedMsg :
291+ m .packages = msg
292+ m .stepDesc = "Starting instrumentation..."
293+ return m , waitForNext (m .sub )
294+ case progressMsg :
295+ m .currentStep ++
296+ m .stepDesc = msg .desc
297+ // We just update the description, no progress bar to update
298+ return m , waitForNext (m .sub )
299+ case errMsg :
300+ m .err = msg
301+ return m , tea .Quit
302+ case completedMsg :
303+ m .done = true
304+ return m , tea .Quit
305+ }
306+
307+ // If we are strictly in Init, we should return the initial batch.
308+ // But Update is called for every message.
309+ // If the message is none of the above (shouldn't happen usually), we return nil.
310+
311+ // Wait, we need to ensure the first waitForNext is called.
312+ // We can do it in a special "start" message or just include it in Init.
313+ return m , nil
314+ }
315+
316+ func waitForNext (sub chan tea.Msg ) tea.Cmd {
317+ if sub == nil {
318+ return nil
319+ }
320+ return func () tea.Msg {
321+ msg , ok := <- sub
322+ if ! ok {
323+ return nil
324+ }
325+ return msg
326+ }
327+ }
328+
329+ func (m model ) View () string {
330+ if m .err != nil {
331+ return fmt .Sprintf ("\n Error: %v\n " , m .err )
332+ }
333+
334+ pad := strings .Repeat (" " , padding (m .stepDesc , 30 ))
335+
336+ // Simple spinner view for all phases
337+ return fmt .Sprintf ("\n %s %s%s\n \n " , m .spinner .View (), m .stepDesc , pad )
338+ }
339+
340+ func padding (s string , width int ) int {
341+ l := len (s )
342+ if l > width {
343+ return 0
344+ }
345+ return width - l
121346}
122347
123348func init () {
124- instrumentCmd .Flags ().BoolVarP (& debug , "debug" , "D" , defaultDebug , "enable debugging output" )
125349 instrumentCmd .Flags ().StringVarP (& diffFile , "output" , "o" , defaultOutputFilePath , "specify diff output file path" )
350+ instrumentCmd .Flags ().StringVarP (& excludeDirs , "exclude" , "e" , "" , "comma-separated list of folders to exclude from instrumentation" )
126351 cobra .MarkFlagFilename (instrumentCmd .Flags (), "output" , ".diff" ) // for file completion
127352
128353 rootCmd .AddCommand (instrumentCmd )
0 commit comments