stackharbinger 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/harbinger/cli.rb CHANGED
@@ -9,8 +9,16 @@ require "harbinger/analyzers/rails_analyzer"
9
9
  require "harbinger/analyzers/database_detector"
10
10
  require "harbinger/analyzers/postgres_detector"
11
11
  require "harbinger/analyzers/mysql_detector"
12
+ require "harbinger/analyzers/redis_detector"
13
+ require "harbinger/analyzers/mongo_detector"
14
+ require "harbinger/analyzers/python_detector"
15
+ require "harbinger/analyzers/node_detector"
16
+ require "harbinger/analyzers/rust_detector"
17
+ require "harbinger/analyzers/go_detector"
12
18
  require "harbinger/eol_fetcher"
13
19
  require "harbinger/config_manager"
20
+ require "harbinger/exporters/json_exporter"
21
+ require "harbinger/exporters/csv_exporter"
14
22
 
15
23
  module Harbinger
16
24
  class CLI < Thor
@@ -18,12 +26,71 @@ module Harbinger
18
26
  true
19
27
  end
20
28
 
29
+ # Ecosystem priority for determining primary language
30
+ ECOSYSTEM_PRIORITY = %w[ruby python rust go nodejs].freeze
31
+
32
+ # Ecosystem definitions with languages and databases
33
+ ECOSYSTEMS = {
34
+ "ruby" => {
35
+ name: "Ruby Ecosystem",
36
+ languages: ["ruby", "rails"],
37
+ databases: ["postgres", "mysql", "redis", "mongo"]
38
+ },
39
+ "python" => {
40
+ name: "Python Ecosystem",
41
+ languages: ["python"],
42
+ databases: ["postgres", "mysql", "redis", "mongo"]
43
+ },
44
+ "rust" => {
45
+ name: "Rust Ecosystem",
46
+ languages: ["rust"],
47
+ databases: ["postgres", "mysql", "redis", "mongo"]
48
+ },
49
+ "go" => {
50
+ name: "Go Ecosystem",
51
+ languages: ["go"],
52
+ databases: ["postgres", "mysql", "redis", "mongo"]
53
+ },
54
+ "nodejs" => {
55
+ name: "Node.js Ecosystem",
56
+ languages: ["nodejs"],
57
+ databases: ["postgres", "mysql", "redis", "mongo"]
58
+ }
59
+ }.freeze
60
+
61
+ # Component display names for table headers
62
+ COMPONENT_DISPLAY_NAMES = {
63
+ "ruby" => "Ruby",
64
+ "rails" => "Rails",
65
+ "python" => "Python",
66
+ "nodejs" => "Node.js",
67
+ "rust" => "Rust",
68
+ "go" => "Go",
69
+ "postgres" => "PostgreSQL",
70
+ "mysql" => "MySQL",
71
+ "redis" => "Redis",
72
+ "mongo" => "MongoDB"
73
+ }.freeze
74
+
75
+ # Product name mapping for EOL API lookups
76
+ PRODUCT_NAME_MAP = {
77
+ "ruby" => "ruby",
78
+ "rails" => "rails",
79
+ "postgres" => "postgresql",
80
+ "mysql" => "mysql",
81
+ "redis" => "redis",
82
+ "mongo" => "mongodb",
83
+ "python" => "python",
84
+ "nodejs" => "nodejs",
85
+ "rust" => "rust"
86
+ }.freeze
87
+
21
88
  desc "scan", "Scan a project directory and detect versions"
22
89
  option :path, type: :string, aliases: "-p", desc: "Path to project directory (defaults to current directory)"
23
90
  option :save, type: :boolean, aliases: "-s", desc: "Save project to config for dashboard"
24
91
  option :recursive, type: :boolean, aliases: "-r", desc: "Recursively scan all subdirectories with Gemfiles"
25
92
  def scan
26
- project_path = options[:path] || Dir.pwd
93
+ project_path = File.expand_path(options[:path] || Dir.pwd)
27
94
 
28
95
  unless File.directory?(project_path)
29
96
  say "Error: #{project_path} is not a valid directory", :red
@@ -37,8 +104,11 @@ module Harbinger
37
104
  end
38
105
  end
39
106
 
40
- desc "show", "Show EOL status for tracked projects"
41
- def show
107
+ desc "show [PROJECT]", "Show EOL status for tracked projects"
108
+ option :verbose, type: :boolean, aliases: "-v", desc: "Show project paths"
109
+ option :format, type: :string, enum: %w[table json csv], default: "table", desc: "Output format (table, json, csv)"
110
+ option :output, type: :string, aliases: "-o", desc: "Output file path"
111
+ def show(project_filter = nil)
42
112
  config_manager = ConfigManager.new
43
113
  projects = config_manager.list_projects
44
114
 
@@ -48,105 +118,52 @@ module Harbinger
48
118
  return
49
119
  end
50
120
 
51
- say "Tracked Projects (#{projects.size})", :cyan
52
- say "=" * 80, :cyan
53
-
54
- fetcher = EolFetcher.new
55
- rows = []
56
-
57
- projects.each do |name, data|
58
- ruby_version = data["ruby"]
59
- rails_version = data["rails"]
60
- postgres_version = data["postgres"]
61
- mysql_version = data["mysql"]
62
-
63
- # Determine worst EOL status
64
- worst_status = :green
65
- status_text = "✓ Current"
66
-
67
- if ruby_version && !ruby_version.empty?
68
- ruby_eol = fetcher.eol_date_for("ruby", ruby_version)
69
- if ruby_eol
70
- days = days_until(ruby_eol)
71
- status = eol_color(days)
72
- worst_status = status if status_priority(status) > status_priority(worst_status)
73
- if days < 0
74
- status_text = "✗ Ruby EOL"
75
- elsif days < 180
76
- status_text = "⚠ Ruby ending soon"
77
- end
78
- end
121
+ # Filter by project name or path if specified
122
+ if project_filter
123
+ projects = projects.select do |name, data|
124
+ name.downcase.include?(project_filter.downcase) ||
125
+ data["path"]&.downcase&.include?(project_filter.downcase)
79
126
  end
80
127
 
81
- if rails_version && !rails_version.empty?
82
- rails_eol = fetcher.eol_date_for("rails", rails_version)
83
- if rails_eol
84
- days = days_until(rails_eol)
85
- status = eol_color(days)
86
- worst_status = status if status_priority(status) > status_priority(worst_status)
87
- if days < 0
88
- status_text = "✗ Rails EOL"
89
- elsif days < 180 && !status_text.include?("EOL")
90
- status_text = "⚠ Rails ending soon"
91
- end
92
- end
93
- end
94
-
95
- if postgres_version && !postgres_version.empty? && !postgres_version.include?("gem")
96
- postgres_eol = fetcher.eol_date_for("postgresql", postgres_version)
97
- if postgres_eol
98
- days = days_until(postgres_eol)
99
- status = eol_color(days)
100
- worst_status = status if status_priority(status) > status_priority(worst_status)
101
- if days < 0
102
- status_text = "✗ PostgreSQL EOL"
103
- elsif days < 180 && !status_text.include?("EOL")
104
- status_text = "⚠ PostgreSQL ending soon"
105
- end
106
- end
128
+ if projects.empty?
129
+ say "No projects matching '#{project_filter}'", :yellow
130
+ return
107
131
  end
132
+ end
108
133
 
109
- if mysql_version && !mysql_version.empty? && !mysql_version.include?("gem")
110
- mysql_eol = fetcher.eol_date_for("mysql", mysql_version)
111
- if mysql_eol
112
- days = days_until(mysql_eol)
113
- status = eol_color(days)
114
- worst_status = status if status_priority(status) > status_priority(worst_status)
115
- if days < 0
116
- status_text = "✗ MySQL EOL"
117
- elsif days < 180 && !status_text.include?("EOL")
118
- status_text = "⚠ MySQL ending soon"
119
- end
120
- end
121
- end
134
+ # Handle export formats
135
+ if options[:format] != "table"
136
+ export_data(projects, options[:format], options[:output])
137
+ return
138
+ end
122
139
 
123
- ruby_display = ruby_version && !ruby_version.empty? ? ruby_version : "-"
124
- rails_display = rails_version && !rails_version.empty? ? rails_version : "-"
125
- postgres_display = postgres_version && !postgres_version.empty? ? postgres_version : "-"
126
- mysql_display = mysql_version && !mysql_version.empty? ? mysql_version : "-"
140
+ # Group projects by ecosystem
141
+ fetcher = EolFetcher.new
142
+ ecosystem_projects = group_projects_by_ecosystem(projects)
127
143
 
128
- rows << [name, ruby_display, rails_display, postgres_display, mysql_display, colorize_status(status_text, worst_status)]
144
+ # Check if any projects have a programming language
145
+ if ecosystem_projects.empty?
146
+ say "No projects with detected versions.", :yellow
147
+ say "Use 'harbinger scan --save' to add projects", :cyan
148
+ return
129
149
  end
130
150
 
131
- # Sort by status priority (worst first), then by name
132
- rows.sort_by! do |row|
133
- status = row[5] # Status is now in column 5 (0-indexed)
134
- priority = if status.include?("✗")
135
- 0
136
- elsif status.include?("⚠")
137
- 1
138
- else
139
- 2
140
- end
141
- [priority, row[0]]
142
- end
151
+ # Display header with total project count
152
+ total_projects = ecosystem_projects.values.sum(&:size)
153
+ say "Tracked Projects (#{total_projects})", :cyan
143
154
 
144
- table = TTY::Table.new(
145
- header: ["Project", "Ruby", "Rails", "PostgreSQL", "MySQL", "Status"],
146
- rows: rows
147
- )
155
+ # Render each ecosystem table
156
+ ECOSYSTEMS.keys.each do |ecosystem_key|
157
+ projects_in_ecosystem = ecosystem_projects[ecosystem_key]
158
+ next if projects_in_ecosystem.empty? # Hide empty tables
148
159
 
149
- puts table.render(:unicode, padding: [0, 1])
160
+ render_ecosystem_table(
161
+ ecosystem_key,
162
+ projects_in_ecosystem,
163
+ fetcher,
164
+ verbose: options[:verbose]
165
+ )
166
+ end
150
167
 
151
168
  say "\nUse 'harbinger scan --path <project>' to update a project", :cyan
152
169
  end
@@ -156,7 +173,7 @@ module Harbinger
156
173
  say "Updating EOL data...", :cyan
157
174
 
158
175
  fetcher = EolFetcher.new
159
- products = %w[ruby rails postgresql mysql]
176
+ products = %w[ruby rails postgresql mysql redis mongodb python nodejs rust go]
160
177
 
161
178
  products.each do |product|
162
179
  say "Fetching #{product}...", :white
@@ -172,6 +189,21 @@ module Harbinger
172
189
  say "\nEOL data updated successfully!", :green
173
190
  end
174
191
 
192
+ desc "remove PROJECT", "Remove a project from tracking"
193
+ def remove(project_name)
194
+ config_manager = ConfigManager.new
195
+ project = config_manager.get_project(project_name)
196
+
197
+ if project
198
+ config_manager.remove_project(project_name)
199
+ say "Removed '#{project_name}' (#{project["path"]})", :green
200
+ else
201
+ say "Project '#{project_name}' not found", :yellow
202
+ say "\nTracked projects:", :cyan
203
+ config_manager.list_projects.keys.sort.each { |name| say " #{name}" }
204
+ end
205
+ end
206
+
175
207
  desc "rescan", "Re-scan all tracked projects and update versions"
176
208
  option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for each project"
177
209
  def rescan
@@ -212,17 +244,40 @@ module Harbinger
212
244
  rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
213
245
  postgres_detector = Analyzers::PostgresDetector.new(project_path)
214
246
  mysql_detector = Analyzers::MysqlDetector.new(project_path)
247
+ redis_detector = Analyzers::RedisDetector.new(project_path)
248
+ mongo_detector = Analyzers::MongoDetector.new(project_path)
249
+ python_detector = Analyzers::PythonDetector.new(project_path)
250
+ node_detector = Analyzers::NodeDetector.new(project_path)
251
+ rust_detector = Analyzers::RustDetector.new(project_path)
252
+ go_detector = Analyzers::GoDetector.new(project_path)
215
253
 
216
254
  ruby_version = ruby_detector.detect
217
255
  rails_version = rails_analyzer.detect
218
256
  postgres_version = postgres_detector.detect
219
257
  mysql_version = mysql_detector.detect
258
+ redis_version = redis_detector.detect
259
+ mongo_version = mongo_detector.detect
260
+ python_version = python_detector.detect
261
+ nodejs_version = node_detector.detect
262
+ rust_version = rust_detector.detect
263
+ go_version = go_detector.detect
220
264
 
221
265
  # Save to config
222
266
  config_manager.save_project(
223
267
  name: name,
224
268
  path: project_path,
225
- versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version, mysql: mysql_version }.compact
269
+ versions: {
270
+ ruby: ruby_version,
271
+ rails: rails_version,
272
+ postgres: postgres_version,
273
+ mysql: mysql_version,
274
+ redis: redis_version,
275
+ mongo: mongo_version,
276
+ python: python_version,
277
+ nodejs: nodejs_version,
278
+ rust: rust_version,
279
+ go: go_version
280
+ }.compact
226
281
  )
227
282
  end
228
283
 
@@ -230,7 +285,7 @@ module Harbinger
230
285
  end
231
286
 
232
287
  say "\n✓ Updated #{updated_count} project(s)", :green
233
- say "✓ Removed #{removed_count} project(s) with missing directories", :yellow if removed_count > 0
288
+ say "✓ Removed #{removed_count} project(s) with missing directories", :yellow if removed_count.positive?
234
289
  say "\nView updated projects with: harbinger show", :cyan
235
290
  end
236
291
 
@@ -241,12 +296,194 @@ module Harbinger
241
296
 
242
297
  private
243
298
 
299
+ # Calculate EOL status for a single component (e.g., ruby, rails, postgres)
300
+ # Returns: { status: :red/:yellow/:green, text: "✗ Ruby EOL", days: -30 } or nil
301
+ def calculate_component_status(component, version, fetcher)
302
+ return nil unless version && !version.empty?
303
+
304
+ product_name = PRODUCT_NAME_MAP[component]
305
+ eol_date = fetcher.eol_date_for(product_name, version)
306
+ return nil unless eol_date
307
+
308
+ days = days_until(eol_date)
309
+ status = eol_color(days)
310
+
311
+ component_display = COMPONENT_DISPLAY_NAMES[component] || component.capitalize
312
+
313
+ text = if days.negative?
314
+ "✗ #{component_display} EOL"
315
+ elsif days < 180
316
+ "⚠ #{component_display} ending soon"
317
+ else
318
+ "✓ Current"
319
+ end
320
+
321
+ { status: status, text: text, days: days }
322
+ end
323
+
324
+ # Determine overall status for a project across specified components
325
+ # Returns: { status: :red/:yellow/:green, text: "✗ Ruby EOL" }
326
+ def determine_overall_status(project_data, components, fetcher)
327
+ worst_status = :green
328
+ status_text = "✓ Current"
329
+
330
+ components.each do |component|
331
+ version = project_data[component]
332
+ next unless version && !version.empty?
333
+
334
+ # Filter out gem-only database versions
335
+ if %w[postgres mysql redis mongo].include?(component) && version&.include?("gem")
336
+ next
337
+ end
338
+
339
+ component_status = calculate_component_status(component, version, fetcher)
340
+ next unless component_status
341
+
342
+ if status_priority(component_status[:status]) > status_priority(worst_status)
343
+ worst_status = component_status[:status]
344
+ status_text = component_status[:text]
345
+ end
346
+ end
347
+
348
+ { status: worst_status, text: status_text }
349
+ end
350
+
351
+ # Build a row hash for a single project with all its component versions
352
+ # Returns: { name: "project", path: "/path", ruby: "3.2.0", ..., status: colored_text }
353
+ def build_project_row(name, data, components, fetcher, verbose: false)
354
+ row = { name: name }
355
+ row[:path] = File.dirname(data["path"] || "") if verbose
356
+
357
+ # Add component versions
358
+ components.each do |component|
359
+ version = data[component]
360
+ # Filter out gem-only database versions
361
+ if %w[postgres mysql redis mongo].include?(component) && version&.include?("gem")
362
+ version = nil
363
+ end
364
+ row[component.to_sym] = (version && !version.empty?) ? version : "-"
365
+ end
366
+
367
+ # Calculate overall status
368
+ status_info = determine_overall_status(data, components, fetcher)
369
+ row[:status] = colorize_status(status_info[:text], status_info[:status])
370
+ row[:status_raw] = status_info[:text]
371
+
372
+ row
373
+ end
374
+
375
+ # Determine the primary ecosystem for a project based on detected languages
376
+ # Returns: "ruby", "python", "rust", "go", "nodejs", or nil
377
+ def determine_primary_ecosystem(data)
378
+ ECOSYSTEM_PRIORITY.each do |lang|
379
+ version = data[lang]
380
+ return lang if version && !version.empty?
381
+ end
382
+ nil # No language detected
383
+ end
384
+
385
+ # Group projects by their primary ecosystem
386
+ # Returns: { "ruby" => [[name, data], ...], "python" => [[name, data], ...] }
387
+ def group_projects_by_ecosystem(projects)
388
+ ecosystem_projects = Hash.new { |h, k| h[k] = [] }
389
+
390
+ projects.each do |name, data|
391
+ primary = determine_primary_ecosystem(data)
392
+
393
+ # Skip projects with no programming language detected
394
+ next unless primary
395
+
396
+ ecosystem_projects[primary] << [name, data]
397
+ end
398
+
399
+ ecosystem_projects
400
+ end
401
+
402
+ # Render a table for a single ecosystem with its projects
403
+ def render_ecosystem_table(ecosystem_key, projects, fetcher, verbose: false)
404
+ config = ECOSYSTEMS[ecosystem_key]
405
+ components = config[:languages] + config[:databases]
406
+
407
+ # Track which columns have data in this ecosystem
408
+ has_columns = Hash.new(false)
409
+ rows = []
410
+
411
+ projects.each do |name, data|
412
+ row = build_project_row(name, data, components, fetcher, verbose: verbose)
413
+
414
+ # Track columns with data
415
+ components.each do |component|
416
+ has_columns[component] = true if row[component.to_sym] != "-"
417
+ end
418
+
419
+ rows << row
420
+ end
421
+
422
+ return if rows.empty? # Skip empty ecosystems
423
+
424
+ # Sort by status priority (worst first)
425
+ rows.sort_by! do |row|
426
+ priority = if row[:status_raw].include?("✗")
427
+ 0
428
+ elsif row[:status_raw].include?("⚠")
429
+ 1
430
+ else
431
+ 2
432
+ end
433
+ [priority, row[:name]]
434
+ end
435
+
436
+ # Build dynamic headers
437
+ headers = ["Project"]
438
+ headers << "Path" if verbose
439
+
440
+ components.each do |component|
441
+ next unless has_columns[component]
442
+
443
+ headers << COMPONENT_DISPLAY_NAMES[component]
444
+ end
445
+
446
+ headers << "Status"
447
+
448
+ # Build table rows matching headers
449
+ table_rows = rows.map do |row|
450
+ table_row = [row[:name]]
451
+ table_row << row[:path] if verbose
452
+
453
+ components.each do |component|
454
+ next unless has_columns[component]
455
+
456
+ table_row << row[component.to_sym]
457
+ end
458
+
459
+ table_row << row[:status]
460
+ table_row
461
+ end
462
+
463
+ # Render the table
464
+ say "\n#{config[:name]} (#{rows.size})", :cyan
465
+ say "=" * 80, :cyan
466
+
467
+ table = TTY::Table.new(header: headers, rows: table_rows)
468
+ puts table.render(:unicode, padding: [0, 1], resize: false)
469
+ end
470
+
244
471
  def scan_recursive(base_path)
245
472
  say "Scanning #{base_path} recursively for Ruby projects...", :cyan
246
473
 
247
- # Find all directories with Gemfiles
474
+ # Find all directories with Gemfiles, excluding common non-project directories
475
+ excluded_patterns = %w[
476
+ vendor/
477
+ node_modules/
478
+ tmp/
479
+ .git/
480
+ spec/fixtures/
481
+ test/fixtures/
482
+ ]
483
+
248
484
  gemfile_dirs = Dir.glob(File.join(base_path, "**/Gemfile"))
249
485
  .map { |f| File.dirname(f) }
486
+ .reject { |dir| excluded_patterns.any? { |pattern| dir.include?("/#{pattern}") } }
250
487
  .sort
251
488
 
252
489
  if gemfile_dirs.empty?
@@ -264,10 +501,10 @@ module Harbinger
264
501
  say "\n" unless index == gemfile_dirs.length - 1
265
502
  end
266
503
 
267
- if options[:save]
268
- say "\n✓ Saved #{gemfile_dirs.length} project(s) to config", :green
269
- say "View all tracked projects with: harbinger show", :cyan
270
- end
504
+ return unless options[:save]
505
+
506
+ say "\n✓ Saved #{gemfile_dirs.length} project(s) to config", :green
507
+ say "View all tracked projects with: harbinger show", :cyan
271
508
  end
272
509
 
273
510
  def scan_single(project_path)
@@ -278,72 +515,114 @@ module Harbinger
278
515
  rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
279
516
  postgres_detector = Analyzers::PostgresDetector.new(project_path)
280
517
  mysql_detector = Analyzers::MysqlDetector.new(project_path)
518
+ redis_detector = Analyzers::RedisDetector.new(project_path)
519
+ mongo_detector = Analyzers::MongoDetector.new(project_path)
520
+ python_detector = Analyzers::PythonDetector.new(project_path)
521
+ node_detector = Analyzers::NodeDetector.new(project_path)
522
+ rust_detector = Analyzers::RustDetector.new(project_path)
523
+ go_detector = Analyzers::GoDetector.new(project_path)
281
524
 
282
525
  ruby_version = ruby_detector.detect
283
526
  rails_version = rails_analyzer.detect
284
527
  postgres_version = postgres_detector.detect
285
528
  mysql_version = mysql_detector.detect
529
+ redis_version = redis_detector.detect
530
+ mongo_version = mongo_detector.detect
531
+ python_version = python_detector.detect
532
+ nodejs_version = node_detector.detect
533
+ rust_version = rust_detector.detect
534
+ go_version = go_detector.detect
535
+
536
+ # Prepare data for ecosystem detection
537
+ data = {
538
+ "ruby" => ruby_version,
539
+ "rails" => rails_version,
540
+ "postgres" => postgres_version,
541
+ "mysql" => mysql_version,
542
+ "redis" => redis_version,
543
+ "mongo" => mongo_version,
544
+ "python" => python_version,
545
+ "nodejs" => nodejs_version,
546
+ "rust" => rust_version,
547
+ "go" => go_version
548
+ }
549
+
550
+ # Determine primary ecosystem
551
+ primary_ecosystem = determine_primary_ecosystem(data)
552
+
553
+ if primary_ecosystem.nil?
554
+ say "\nNo programming language detected", :yellow
555
+ say "This appears to be a database-only or infrastructure project", :yellow
556
+ return
557
+ end
286
558
 
287
- ruby_present = ruby_detector.ruby_detected?
288
- rails_present = rails_analyzer.rails_detected?
289
- postgres_present = postgres_detector.database_detected?
290
- mysql_present = mysql_detector.database_detected?
559
+ # Get components to display for this ecosystem
560
+ config = ECOSYSTEMS[primary_ecosystem]
561
+ components_to_display = config[:languages] + config[:databases]
291
562
 
292
563
  # Display results
293
564
  say "\nDetected versions:", :green
294
- if ruby_version
295
- say " Ruby: #{ruby_version}", :white
296
- elsif ruby_present
297
- say " Ruby: Present (version not specified - add .ruby-version or ruby declaration in Gemfile)", :yellow
298
- else
299
- say " Ruby: Not a Ruby project", :red
300
- end
301
565
 
302
- if rails_version
303
- say " Rails: #{rails_version}", :white
304
- elsif rails_present
305
- say " Rails: Present (version not found in Gemfile.lock)", :yellow
306
- else
307
- say " Rails: Not detected", :yellow
566
+ components_to_display.each do |component|
567
+ version = data[component]
568
+ display_name = COMPONENT_DISPLAY_NAMES[component]
569
+ detector_present = case component
570
+ when "ruby" then ruby_detector.ruby_detected?
571
+ when "rails" then rails_analyzer.rails_detected?
572
+ when "postgres" then postgres_detector.database_detected?
573
+ when "mysql" then mysql_detector.database_detected?
574
+ when "redis" then redis_detector.redis_detected?
575
+ when "mongo" then mongo_detector.mongo_detected?
576
+ when "python" then python_detector.python_detected?
577
+ when "nodejs" then node_detector.nodejs_detected?
578
+ when "rust" then rust_detector.rust_detected?
579
+ when "go" then go_detector.go_detected?
580
+ else false
581
+ end
582
+
583
+ if version && !version.empty?
584
+ say " #{display_name.ljust(12)} #{version}", :white
585
+ elsif detector_present
586
+ say " #{display_name.ljust(12)} Present (version not detected)", :yellow
587
+ end
308
588
  end
309
589
 
310
- if postgres_version
311
- say " PostgreSQL: #{postgres_version}", :white
312
- elsif postgres_present
313
- say " PostgreSQL: Present (version not detected)", :yellow
314
- end
590
+ # Fetch and display EOL dates
591
+ versions_to_check = components_to_display.filter_map do |component|
592
+ version = data[component]
593
+ next unless version && !version.empty?
594
+ # Filter out gem-only database versions
595
+ next if %w[postgres mysql redis mongo].include?(component) && version.include?("gem")
315
596
 
316
- if mysql_version
317
- say " MySQL: #{mysql_version}", :white
318
- elsif mysql_present
319
- say " MySQL: Present (version not detected)", :yellow
597
+ [component, version]
320
598
  end
321
599
 
322
- # Fetch and display EOL dates
323
- if ruby_version || rails_version || postgres_version || mysql_version
600
+ if versions_to_check.any?
324
601
  say "\nFetching EOL data...", :cyan
325
602
  fetcher = EolFetcher.new
326
603
 
327
- if ruby_version
328
- display_eol_info(fetcher, "Ruby", ruby_version)
329
- end
330
-
331
- if rails_version
332
- display_eol_info(fetcher, "Rails", rails_version)
333
- end
334
-
335
- if postgres_version && !postgres_version.include?("gem")
336
- display_eol_info(fetcher, "PostgreSQL", postgres_version)
337
- end
338
-
339
- if mysql_version && !mysql_version.include?("gem")
340
- display_eol_info(fetcher, "MySQL", mysql_version)
604
+ versions_to_check.each do |component, version|
605
+ display_name = COMPONENT_DISPLAY_NAMES[component]
606
+ display_eol_info(fetcher, display_name, version)
341
607
  end
342
608
  end
343
609
 
344
610
  # Save to config if --save flag is used
611
+ versions = {
612
+ ruby: ruby_version,
613
+ rails: rails_version,
614
+ postgres: postgres_version,
615
+ mysql: mysql_version,
616
+ redis: redis_version,
617
+ mongo: mongo_version,
618
+ python: python_version,
619
+ nodejs: nodejs_version,
620
+ rust: rust_version,
621
+ go: go_version
622
+ }.compact
623
+
345
624
  if options[:save] && !options[:recursive]
346
- save_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version)
625
+ save_project_to_config(project_path, versions)
347
626
  elsif options[:save] && options[:recursive]
348
627
  # In recursive mode, save without the confirmation message for each project
349
628
  config_manager = ConfigManager.new
@@ -351,19 +630,19 @@ module Harbinger
351
630
  config_manager.save_project(
352
631
  name: project_name,
353
632
  path: project_path,
354
- versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version, mysql: mysql_version }.compact
633
+ versions: versions
355
634
  )
356
635
  end
357
636
  end
358
637
 
359
- def save_to_config(project_path, ruby_version, rails_version, postgres_version = nil, mysql_version = nil)
638
+ def save_project_to_config(project_path, versions)
360
639
  config_manager = ConfigManager.new
361
640
  project_name = File.basename(project_path)
362
641
 
363
642
  config_manager.save_project(
364
643
  name: project_name,
365
644
  path: project_path,
366
- versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version, mysql: mysql_version }.compact
645
+ versions: versions
367
646
  )
368
647
 
369
648
  say "\n✓ Saved to config as '#{project_name}'", :green
@@ -371,7 +650,14 @@ module Harbinger
371
650
  end
372
651
 
373
652
  def display_eol_info(fetcher, product, version)
374
- product_key = product.downcase
653
+ # Map display name to EOL API key
654
+ product_key = case product.downcase
655
+ when "node.js" then "nodejs"
656
+ when "postgresql" then "postgresql"
657
+ when "mongodb" then "mongodb"
658
+ else product.downcase
659
+ end
660
+
375
661
  eol_date = fetcher.eol_date_for(product_key, version)
376
662
 
377
663
  if eol_date
@@ -393,7 +679,7 @@ module Harbinger
393
679
  end
394
680
 
395
681
  def eol_color(days)
396
- if days < 0
682
+ if days.negative?
397
683
  :red
398
684
  elsif days < 180 # < 6 months
399
685
  :yellow
@@ -403,7 +689,7 @@ module Harbinger
403
689
  end
404
690
 
405
691
  def eol_status(days)
406
- if days < 0
692
+ if days.negative?
407
693
  "ALREADY EOL (#{days.abs} days ago)"
408
694
  elsif days < 30
409
695
  "ENDING SOON (#{days} days remaining)"
@@ -437,5 +723,23 @@ module Harbinger
437
723
  text
438
724
  end
439
725
  end
726
+
727
+ def export_data(projects, format, output_path)
728
+ exporter = case format
729
+ when "json"
730
+ Exporters::JsonExporter.new(projects)
731
+ when "csv"
732
+ Exporters::CsvExporter.new(projects)
733
+ end
734
+
735
+ result = exporter.export
736
+
737
+ if output_path
738
+ File.write(output_path, result)
739
+ say "Exported to #{output_path}", :green
740
+ else
741
+ puts result
742
+ end
743
+ end
440
744
  end
441
745
  end