stackharbinger 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +39 -7
- data/docs/index.html +17 -13
- data/lib/harbinger/analyzers/database_detector.rb +13 -3
- data/lib/harbinger/analyzers/docker_compose_detector.rb +121 -0
- data/lib/harbinger/analyzers/mongo_detector.rb +104 -0
- data/lib/harbinger/analyzers/mysql_detector.rb +8 -1
- data/lib/harbinger/analyzers/postgres_detector.rb +6 -0
- data/lib/harbinger/analyzers/redis_detector.rb +98 -0
- data/lib/harbinger/analyzers/ruby_detector.rb +9 -1
- data/lib/harbinger/cli.rb +245 -54
- data/lib/harbinger/eol_fetcher.rb +19 -10
- data/lib/harbinger/exporters/base_exporter.rb +97 -0
- data/lib/harbinger/exporters/csv_exporter.rb +36 -0
- data/lib/harbinger/exporters/json_exporter.rb +21 -0
- data/lib/harbinger/version.rb +1 -1
- metadata +12 -3
data/lib/harbinger/cli.rb
CHANGED
|
@@ -9,8 +9,12 @@ 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"
|
|
12
14
|
require "harbinger/eol_fetcher"
|
|
13
15
|
require "harbinger/config_manager"
|
|
16
|
+
require "harbinger/exporters/json_exporter"
|
|
17
|
+
require "harbinger/exporters/csv_exporter"
|
|
14
18
|
|
|
15
19
|
module Harbinger
|
|
16
20
|
class CLI < Thor
|
|
@@ -23,7 +27,7 @@ module Harbinger
|
|
|
23
27
|
option :save, type: :boolean, aliases: "-s", desc: "Save project to config for dashboard"
|
|
24
28
|
option :recursive, type: :boolean, aliases: "-r", desc: "Recursively scan all subdirectories with Gemfiles"
|
|
25
29
|
def scan
|
|
26
|
-
project_path = options[:path] || Dir.pwd
|
|
30
|
+
project_path = File.expand_path(options[:path] || Dir.pwd)
|
|
27
31
|
|
|
28
32
|
unless File.directory?(project_path)
|
|
29
33
|
say "Error: #{project_path} is not a valid directory", :red
|
|
@@ -37,8 +41,11 @@ module Harbinger
|
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
|
|
40
|
-
desc "show", "Show EOL status for tracked projects"
|
|
41
|
-
|
|
44
|
+
desc "show [PROJECT]", "Show EOL status for tracked projects"
|
|
45
|
+
option :verbose, type: :boolean, aliases: "-v", desc: "Show project paths"
|
|
46
|
+
option :format, type: :string, enum: %w[table json csv], default: "table", desc: "Output format (table, json, csv)"
|
|
47
|
+
option :output, type: :string, aliases: "-o", desc: "Output file path"
|
|
48
|
+
def show(project_filter = nil)
|
|
42
49
|
config_manager = ConfigManager.new
|
|
43
50
|
projects = config_manager.list_projects
|
|
44
51
|
|
|
@@ -48,29 +55,79 @@ module Harbinger
|
|
|
48
55
|
return
|
|
49
56
|
end
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
# Filter by project name or path if specified
|
|
59
|
+
if project_filter
|
|
60
|
+
projects = projects.select do |name, data|
|
|
61
|
+
name.downcase.include?(project_filter.downcase) ||
|
|
62
|
+
data["path"]&.downcase&.include?(project_filter.downcase)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if projects.empty?
|
|
66
|
+
say "No projects matching '#{project_filter}'", :yellow
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Handle export formats
|
|
72
|
+
if options[:format] != "table"
|
|
73
|
+
export_data(projects, options[:format], options[:output])
|
|
74
|
+
return
|
|
75
|
+
end
|
|
53
76
|
|
|
54
77
|
fetcher = EolFetcher.new
|
|
55
78
|
rows = []
|
|
56
79
|
|
|
80
|
+
# Track which columns have data
|
|
81
|
+
has_ruby = false
|
|
82
|
+
has_rails = false
|
|
83
|
+
has_postgres = false
|
|
84
|
+
has_mysql = false
|
|
85
|
+
has_redis = false
|
|
86
|
+
has_mongo = false
|
|
87
|
+
|
|
57
88
|
projects.each do |name, data|
|
|
58
89
|
ruby_version = data["ruby"]
|
|
59
90
|
rails_version = data["rails"]
|
|
60
91
|
postgres_version = data["postgres"]
|
|
61
92
|
mysql_version = data["mysql"]
|
|
93
|
+
redis_version = data["redis"]
|
|
94
|
+
mongo_version = data["mongo"]
|
|
95
|
+
|
|
96
|
+
# Filter out gem-only database versions
|
|
97
|
+
postgres_version = nil if postgres_version&.include?("gem")
|
|
98
|
+
mysql_version = nil if mysql_version&.include?("gem")
|
|
99
|
+
redis_version = nil if redis_version&.include?("gem")
|
|
100
|
+
mongo_version = nil if mongo_version&.include?("gem")
|
|
101
|
+
|
|
102
|
+
# Skip projects with no matching products
|
|
103
|
+
ruby_present = ruby_version && !ruby_version.empty?
|
|
104
|
+
rails_present = rails_version && !rails_version.empty?
|
|
105
|
+
postgres_present = postgres_version && !postgres_version.empty?
|
|
106
|
+
mysql_present = mysql_version && !mysql_version.empty?
|
|
107
|
+
redis_present = redis_version && !redis_version.empty?
|
|
108
|
+
mongo_present = mongo_version && !mongo_version.empty?
|
|
109
|
+
|
|
110
|
+
next unless ruby_present || rails_present || postgres_present || mysql_present || redis_present || mongo_present
|
|
111
|
+
|
|
112
|
+
# Track which columns have data
|
|
113
|
+
has_ruby ||= ruby_present
|
|
114
|
+
has_rails ||= rails_present
|
|
115
|
+
has_postgres ||= postgres_present
|
|
116
|
+
has_mysql ||= mysql_present
|
|
117
|
+
has_redis ||= redis_present
|
|
118
|
+
has_mongo ||= mongo_present
|
|
62
119
|
|
|
63
120
|
# Determine worst EOL status
|
|
64
121
|
worst_status = :green
|
|
65
122
|
status_text = "✓ Current"
|
|
66
123
|
|
|
67
|
-
if
|
|
124
|
+
if ruby_present
|
|
68
125
|
ruby_eol = fetcher.eol_date_for("ruby", ruby_version)
|
|
69
126
|
if ruby_eol
|
|
70
127
|
days = days_until(ruby_eol)
|
|
71
128
|
status = eol_color(days)
|
|
72
129
|
worst_status = status if status_priority(status) > status_priority(worst_status)
|
|
73
|
-
if days
|
|
130
|
+
if days.negative?
|
|
74
131
|
status_text = "✗ Ruby EOL"
|
|
75
132
|
elsif days < 180
|
|
76
133
|
status_text = "⚠ Ruby ending soon"
|
|
@@ -78,13 +135,13 @@ module Harbinger
|
|
|
78
135
|
end
|
|
79
136
|
end
|
|
80
137
|
|
|
81
|
-
if
|
|
138
|
+
if rails_present
|
|
82
139
|
rails_eol = fetcher.eol_date_for("rails", rails_version)
|
|
83
140
|
if rails_eol
|
|
84
141
|
days = days_until(rails_eol)
|
|
85
142
|
status = eol_color(days)
|
|
86
143
|
worst_status = status if status_priority(status) > status_priority(worst_status)
|
|
87
|
-
if days
|
|
144
|
+
if days.negative?
|
|
88
145
|
status_text = "✗ Rails EOL"
|
|
89
146
|
elsif days < 180 && !status_text.include?("EOL")
|
|
90
147
|
status_text = "⚠ Rails ending soon"
|
|
@@ -92,13 +149,13 @@ module Harbinger
|
|
|
92
149
|
end
|
|
93
150
|
end
|
|
94
151
|
|
|
95
|
-
if
|
|
152
|
+
if postgres_present
|
|
96
153
|
postgres_eol = fetcher.eol_date_for("postgresql", postgres_version)
|
|
97
154
|
if postgres_eol
|
|
98
155
|
days = days_until(postgres_eol)
|
|
99
156
|
status = eol_color(days)
|
|
100
157
|
worst_status = status if status_priority(status) > status_priority(worst_status)
|
|
101
|
-
if days
|
|
158
|
+
if days.negative?
|
|
102
159
|
status_text = "✗ PostgreSQL EOL"
|
|
103
160
|
elsif days < 180 && !status_text.include?("EOL")
|
|
104
161
|
status_text = "⚠ PostgreSQL ending soon"
|
|
@@ -106,13 +163,13 @@ module Harbinger
|
|
|
106
163
|
end
|
|
107
164
|
end
|
|
108
165
|
|
|
109
|
-
if
|
|
166
|
+
if mysql_present
|
|
110
167
|
mysql_eol = fetcher.eol_date_for("mysql", mysql_version)
|
|
111
168
|
if mysql_eol
|
|
112
169
|
days = days_until(mysql_eol)
|
|
113
170
|
status = eol_color(days)
|
|
114
171
|
worst_status = status if status_priority(status) > status_priority(worst_status)
|
|
115
|
-
if days
|
|
172
|
+
if days.negative?
|
|
116
173
|
status_text = "✗ MySQL EOL"
|
|
117
174
|
elsif days < 180 && !status_text.include?("EOL")
|
|
118
175
|
status_text = "⚠ MySQL ending soon"
|
|
@@ -120,33 +177,99 @@ module Harbinger
|
|
|
120
177
|
end
|
|
121
178
|
end
|
|
122
179
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
180
|
+
if redis_present
|
|
181
|
+
redis_eol = fetcher.eol_date_for("redis", redis_version)
|
|
182
|
+
if redis_eol
|
|
183
|
+
days = days_until(redis_eol)
|
|
184
|
+
status = eol_color(days)
|
|
185
|
+
worst_status = status if status_priority(status) > status_priority(worst_status)
|
|
186
|
+
if days.negative?
|
|
187
|
+
status_text = "✗ Redis EOL"
|
|
188
|
+
elsif days < 180 && !status_text.include?("EOL")
|
|
189
|
+
status_text = "⚠ Redis ending soon"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
127
193
|
|
|
128
|
-
|
|
194
|
+
if mongo_present
|
|
195
|
+
mongo_eol = fetcher.eol_date_for("mongodb", mongo_version)
|
|
196
|
+
if mongo_eol
|
|
197
|
+
days = days_until(mongo_eol)
|
|
198
|
+
status = eol_color(days)
|
|
199
|
+
worst_status = status if status_priority(status) > status_priority(worst_status)
|
|
200
|
+
if days.negative?
|
|
201
|
+
status_text = "✗ MongoDB EOL"
|
|
202
|
+
elsif days < 180 && !status_text.include?("EOL")
|
|
203
|
+
status_text = "⚠ MongoDB ending soon"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
rows << {
|
|
209
|
+
name: name,
|
|
210
|
+
path: File.dirname(data["path"] || ""),
|
|
211
|
+
ruby: ruby_present ? ruby_version : "-",
|
|
212
|
+
rails: rails_present ? rails_version : "-",
|
|
213
|
+
postgres: postgres_present ? postgres_version : "-",
|
|
214
|
+
mysql: mysql_present ? mysql_version : "-",
|
|
215
|
+
redis: redis_present ? redis_version : "-",
|
|
216
|
+
mongo: mongo_present ? mongo_version : "-",
|
|
217
|
+
status: colorize_status(status_text, worst_status),
|
|
218
|
+
status_raw: status_text
|
|
219
|
+
}
|
|
129
220
|
end
|
|
130
221
|
|
|
222
|
+
if rows.empty?
|
|
223
|
+
say "No projects with detected versions.", :yellow
|
|
224
|
+
say "Use 'harbinger scan --save' to add projects", :cyan
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
say "Tracked Projects (#{rows.size})", :cyan
|
|
229
|
+
say "=" * 80, :cyan
|
|
230
|
+
|
|
131
231
|
# Sort by status priority (worst first), then by name
|
|
132
232
|
rows.sort_by! do |row|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
233
|
+
priority = if row[:status_raw].include?("✗")
|
|
234
|
+
0
|
|
235
|
+
elsif row[:status_raw].include?("⚠")
|
|
236
|
+
1
|
|
237
|
+
else
|
|
238
|
+
2
|
|
239
|
+
end
|
|
240
|
+
[priority, row[:name]]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Build dynamic headers and rows
|
|
244
|
+
headers = ["Project"]
|
|
245
|
+
headers << "Path" if options[:verbose]
|
|
246
|
+
headers << "Ruby" if has_ruby
|
|
247
|
+
headers << "Rails" if has_rails
|
|
248
|
+
headers << "PostgreSQL" if has_postgres
|
|
249
|
+
headers << "MySQL" if has_mysql
|
|
250
|
+
headers << "Redis" if has_redis
|
|
251
|
+
headers << "MongoDB" if has_mongo
|
|
252
|
+
headers << "Status"
|
|
253
|
+
|
|
254
|
+
table_rows = rows.map do |row|
|
|
255
|
+
table_row = [row[:name]]
|
|
256
|
+
table_row << row[:path] if options[:verbose]
|
|
257
|
+
table_row << row[:ruby] if has_ruby
|
|
258
|
+
table_row << row[:rails] if has_rails
|
|
259
|
+
table_row << row[:postgres] if has_postgres
|
|
260
|
+
table_row << row[:mysql] if has_mysql
|
|
261
|
+
table_row << row[:redis] if has_redis
|
|
262
|
+
table_row << row[:mongo] if has_mongo
|
|
263
|
+
table_row << row[:status]
|
|
264
|
+
table_row
|
|
142
265
|
end
|
|
143
266
|
|
|
144
267
|
table = TTY::Table.new(
|
|
145
|
-
header:
|
|
146
|
-
rows:
|
|
268
|
+
header: headers,
|
|
269
|
+
rows: table_rows
|
|
147
270
|
)
|
|
148
271
|
|
|
149
|
-
puts table.render(:unicode, padding: [0, 1])
|
|
272
|
+
puts table.render(:unicode, padding: [0, 1], resize: false)
|
|
150
273
|
|
|
151
274
|
say "\nUse 'harbinger scan --path <project>' to update a project", :cyan
|
|
152
275
|
end
|
|
@@ -156,7 +279,7 @@ module Harbinger
|
|
|
156
279
|
say "Updating EOL data...", :cyan
|
|
157
280
|
|
|
158
281
|
fetcher = EolFetcher.new
|
|
159
|
-
products = %w[ruby rails postgresql mysql]
|
|
282
|
+
products = %w[ruby rails postgresql mysql redis mongodb]
|
|
160
283
|
|
|
161
284
|
products.each do |product|
|
|
162
285
|
say "Fetching #{product}...", :white
|
|
@@ -172,6 +295,21 @@ module Harbinger
|
|
|
172
295
|
say "\nEOL data updated successfully!", :green
|
|
173
296
|
end
|
|
174
297
|
|
|
298
|
+
desc "remove PROJECT", "Remove a project from tracking"
|
|
299
|
+
def remove(project_name)
|
|
300
|
+
config_manager = ConfigManager.new
|
|
301
|
+
project = config_manager.get_project(project_name)
|
|
302
|
+
|
|
303
|
+
if project
|
|
304
|
+
config_manager.remove_project(project_name)
|
|
305
|
+
say "Removed '#{project_name}' (#{project["path"]})", :green
|
|
306
|
+
else
|
|
307
|
+
say "Project '#{project_name}' not found", :yellow
|
|
308
|
+
say "\nTracked projects:", :cyan
|
|
309
|
+
config_manager.list_projects.keys.sort.each { |name| say " #{name}" }
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
175
313
|
desc "rescan", "Re-scan all tracked projects and update versions"
|
|
176
314
|
option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for each project"
|
|
177
315
|
def rescan
|
|
@@ -212,17 +350,22 @@ module Harbinger
|
|
|
212
350
|
rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
|
|
213
351
|
postgres_detector = Analyzers::PostgresDetector.new(project_path)
|
|
214
352
|
mysql_detector = Analyzers::MysqlDetector.new(project_path)
|
|
353
|
+
redis_detector = Analyzers::RedisDetector.new(project_path)
|
|
354
|
+
mongo_detector = Analyzers::MongoDetector.new(project_path)
|
|
215
355
|
|
|
216
356
|
ruby_version = ruby_detector.detect
|
|
217
357
|
rails_version = rails_analyzer.detect
|
|
218
358
|
postgres_version = postgres_detector.detect
|
|
219
359
|
mysql_version = mysql_detector.detect
|
|
360
|
+
redis_version = redis_detector.detect
|
|
361
|
+
mongo_version = mongo_detector.detect
|
|
220
362
|
|
|
221
363
|
# Save to config
|
|
222
364
|
config_manager.save_project(
|
|
223
365
|
name: name,
|
|
224
366
|
path: project_path,
|
|
225
|
-
versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
|
|
367
|
+
versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
|
|
368
|
+
mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
|
|
226
369
|
)
|
|
227
370
|
end
|
|
228
371
|
|
|
@@ -230,7 +373,7 @@ module Harbinger
|
|
|
230
373
|
end
|
|
231
374
|
|
|
232
375
|
say "\n✓ Updated #{updated_count} project(s)", :green
|
|
233
|
-
say "✓ Removed #{removed_count} project(s) with missing directories", :yellow if removed_count
|
|
376
|
+
say "✓ Removed #{removed_count} project(s) with missing directories", :yellow if removed_count.positive?
|
|
234
377
|
say "\nView updated projects with: harbinger show", :cyan
|
|
235
378
|
end
|
|
236
379
|
|
|
@@ -244,9 +387,19 @@ module Harbinger
|
|
|
244
387
|
def scan_recursive(base_path)
|
|
245
388
|
say "Scanning #{base_path} recursively for Ruby projects...", :cyan
|
|
246
389
|
|
|
247
|
-
# Find all directories with Gemfiles
|
|
390
|
+
# Find all directories with Gemfiles, excluding common non-project directories
|
|
391
|
+
excluded_patterns = %w[
|
|
392
|
+
vendor/
|
|
393
|
+
node_modules/
|
|
394
|
+
tmp/
|
|
395
|
+
.git/
|
|
396
|
+
spec/fixtures/
|
|
397
|
+
test/fixtures/
|
|
398
|
+
]
|
|
399
|
+
|
|
248
400
|
gemfile_dirs = Dir.glob(File.join(base_path, "**/Gemfile"))
|
|
249
401
|
.map { |f| File.dirname(f) }
|
|
402
|
+
.reject { |dir| excluded_patterns.any? { |pattern| dir.include?("/#{pattern}") } }
|
|
250
403
|
.sort
|
|
251
404
|
|
|
252
405
|
if gemfile_dirs.empty?
|
|
@@ -264,10 +417,10 @@ module Harbinger
|
|
|
264
417
|
say "\n" unless index == gemfile_dirs.length - 1
|
|
265
418
|
end
|
|
266
419
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
420
|
+
return unless options[:save]
|
|
421
|
+
|
|
422
|
+
say "\n✓ Saved #{gemfile_dirs.length} project(s) to config", :green
|
|
423
|
+
say "View all tracked projects with: harbinger show", :cyan
|
|
271
424
|
end
|
|
272
425
|
|
|
273
426
|
def scan_single(project_path)
|
|
@@ -278,16 +431,22 @@ module Harbinger
|
|
|
278
431
|
rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
|
|
279
432
|
postgres_detector = Analyzers::PostgresDetector.new(project_path)
|
|
280
433
|
mysql_detector = Analyzers::MysqlDetector.new(project_path)
|
|
434
|
+
redis_detector = Analyzers::RedisDetector.new(project_path)
|
|
435
|
+
mongo_detector = Analyzers::MongoDetector.new(project_path)
|
|
281
436
|
|
|
282
437
|
ruby_version = ruby_detector.detect
|
|
283
438
|
rails_version = rails_analyzer.detect
|
|
284
439
|
postgres_version = postgres_detector.detect
|
|
285
440
|
mysql_version = mysql_detector.detect
|
|
441
|
+
redis_version = redis_detector.detect
|
|
442
|
+
mongo_version = mongo_detector.detect
|
|
286
443
|
|
|
287
444
|
ruby_present = ruby_detector.ruby_detected?
|
|
288
445
|
rails_present = rails_analyzer.rails_detected?
|
|
289
446
|
postgres_present = postgres_detector.database_detected?
|
|
290
447
|
mysql_present = mysql_detector.database_detected?
|
|
448
|
+
redis_present = redis_detector.redis_detected?
|
|
449
|
+
mongo_present = mongo_detector.mongo_detected?
|
|
291
450
|
|
|
292
451
|
# Display results
|
|
293
452
|
say "\nDetected versions:", :green
|
|
@@ -319,31 +478,42 @@ module Harbinger
|
|
|
319
478
|
say " MySQL: Present (version not detected)", :yellow
|
|
320
479
|
end
|
|
321
480
|
|
|
481
|
+
if redis_version
|
|
482
|
+
say " Redis: #{redis_version}", :white
|
|
483
|
+
elsif redis_present
|
|
484
|
+
say " Redis: Present (version not detected)", :yellow
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
if mongo_version
|
|
488
|
+
say " MongoDB: #{mongo_version}", :white
|
|
489
|
+
elsif mongo_present
|
|
490
|
+
say " MongoDB: Present (version not detected)", :yellow
|
|
491
|
+
end
|
|
492
|
+
|
|
322
493
|
# Fetch and display EOL dates
|
|
323
|
-
if ruby_version || rails_version || postgres_version || mysql_version
|
|
494
|
+
if ruby_version || rails_version || postgres_version || mysql_version || redis_version || mongo_version
|
|
324
495
|
say "\nFetching EOL data...", :cyan
|
|
325
496
|
fetcher = EolFetcher.new
|
|
326
497
|
|
|
327
|
-
if ruby_version
|
|
328
|
-
display_eol_info(fetcher, "Ruby", ruby_version)
|
|
329
|
-
end
|
|
498
|
+
display_eol_info(fetcher, "Ruby", ruby_version) if ruby_version
|
|
330
499
|
|
|
331
|
-
if rails_version
|
|
332
|
-
display_eol_info(fetcher, "Rails", rails_version)
|
|
333
|
-
end
|
|
500
|
+
display_eol_info(fetcher, "Rails", rails_version) if rails_version
|
|
334
501
|
|
|
335
502
|
if postgres_version && !postgres_version.include?("gem")
|
|
336
503
|
display_eol_info(fetcher, "PostgreSQL", postgres_version)
|
|
337
504
|
end
|
|
338
505
|
|
|
339
|
-
if mysql_version && !mysql_version.include?("gem")
|
|
340
|
-
|
|
341
|
-
|
|
506
|
+
display_eol_info(fetcher, "MySQL", mysql_version) if mysql_version && !mysql_version.include?("gem")
|
|
507
|
+
|
|
508
|
+
display_eol_info(fetcher, "Redis", redis_version) if redis_version && !redis_version.include?("gem")
|
|
509
|
+
|
|
510
|
+
display_eol_info(fetcher, "MongoDB", mongo_version) if mongo_version && !mongo_version.include?("gem")
|
|
342
511
|
end
|
|
343
512
|
|
|
344
513
|
# Save to config if --save flag is used
|
|
345
514
|
if options[:save] && !options[:recursive]
|
|
346
|
-
|
|
515
|
+
save_project_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version,
|
|
516
|
+
redis_version, mongo_version)
|
|
347
517
|
elsif options[:save] && options[:recursive]
|
|
348
518
|
# In recursive mode, save without the confirmation message for each project
|
|
349
519
|
config_manager = ConfigManager.new
|
|
@@ -351,19 +521,22 @@ module Harbinger
|
|
|
351
521
|
config_manager.save_project(
|
|
352
522
|
name: project_name,
|
|
353
523
|
path: project_path,
|
|
354
|
-
versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
|
|
524
|
+
versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
|
|
525
|
+
mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
|
|
355
526
|
)
|
|
356
527
|
end
|
|
357
528
|
end
|
|
358
529
|
|
|
359
|
-
def
|
|
530
|
+
def save_project_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version,
|
|
531
|
+
redis_version, mongo_version)
|
|
360
532
|
config_manager = ConfigManager.new
|
|
361
533
|
project_name = File.basename(project_path)
|
|
362
534
|
|
|
363
535
|
config_manager.save_project(
|
|
364
536
|
name: project_name,
|
|
365
537
|
path: project_path,
|
|
366
|
-
versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
|
|
538
|
+
versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
|
|
539
|
+
mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
|
|
367
540
|
)
|
|
368
541
|
|
|
369
542
|
say "\n✓ Saved to config as '#{project_name}'", :green
|
|
@@ -393,7 +566,7 @@ module Harbinger
|
|
|
393
566
|
end
|
|
394
567
|
|
|
395
568
|
def eol_color(days)
|
|
396
|
-
if days
|
|
569
|
+
if days.negative?
|
|
397
570
|
:red
|
|
398
571
|
elsif days < 180 # < 6 months
|
|
399
572
|
:yellow
|
|
@@ -403,7 +576,7 @@ module Harbinger
|
|
|
403
576
|
end
|
|
404
577
|
|
|
405
578
|
def eol_status(days)
|
|
406
|
-
if days
|
|
579
|
+
if days.negative?
|
|
407
580
|
"ALREADY EOL (#{days.abs} days ago)"
|
|
408
581
|
elsif days < 30
|
|
409
582
|
"ENDING SOON (#{days} days remaining)"
|
|
@@ -437,5 +610,23 @@ module Harbinger
|
|
|
437
610
|
text
|
|
438
611
|
end
|
|
439
612
|
end
|
|
613
|
+
|
|
614
|
+
def export_data(projects, format, output_path)
|
|
615
|
+
exporter = case format
|
|
616
|
+
when "json"
|
|
617
|
+
Exporters::JsonExporter.new(projects)
|
|
618
|
+
when "csv"
|
|
619
|
+
Exporters::CsvExporter.new(projects)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
result = exporter.export
|
|
623
|
+
|
|
624
|
+
if output_path
|
|
625
|
+
File.write(output_path, result)
|
|
626
|
+
say "Exported to #{output_path}", :green
|
|
627
|
+
else
|
|
628
|
+
puts result
|
|
629
|
+
end
|
|
630
|
+
end
|
|
440
631
|
end
|
|
441
632
|
end
|
|
@@ -18,16 +18,14 @@ module Harbinger
|
|
|
18
18
|
cache_file = cache_file_path(product)
|
|
19
19
|
|
|
20
20
|
# Return fresh cache if available
|
|
21
|
-
if cache_fresh?(cache_file)
|
|
22
|
-
return read_cache(cache_file)
|
|
23
|
-
end
|
|
21
|
+
return read_cache(cache_file) if cache_fresh?(cache_file)
|
|
24
22
|
|
|
25
23
|
# Try to fetch from API
|
|
26
24
|
begin
|
|
27
25
|
data = fetch_from_api(product)
|
|
28
26
|
write_cache(cache_file, data)
|
|
29
27
|
data
|
|
30
|
-
rescue StandardError
|
|
28
|
+
rescue StandardError
|
|
31
29
|
# Fall back to stale cache if API fails
|
|
32
30
|
read_cache(cache_file) if File.exist?(cache_file)
|
|
33
31
|
end
|
|
@@ -39,16 +37,27 @@ module Harbinger
|
|
|
39
37
|
|
|
40
38
|
# Extract major.minor from version (e.g., "3.2.1" -> "3.2")
|
|
41
39
|
version_parts = version.split(".")
|
|
42
|
-
major_minor = "#{version_parts[0]}.#{version_parts[1]}"
|
|
43
40
|
major = version_parts[0]
|
|
41
|
+
major_minor = version_parts[1] ? "#{major}.#{version_parts[1]}" : nil
|
|
44
42
|
|
|
45
|
-
# Try major.minor first (e.g., "8.0" for MySQL, "3.2" for Ruby)
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
# Try exact major.minor first (e.g., "8.0" for MySQL, "3.2" for Ruby)
|
|
44
|
+
if major_minor
|
|
45
|
+
entry = data.find { |item| item["cycle"] == major_minor }
|
|
46
|
+
return entry["eol"] if entry
|
|
47
|
+
end
|
|
48
48
|
|
|
49
|
-
#
|
|
49
|
+
# Try major only (e.g., "16" for PostgreSQL)
|
|
50
50
|
entry = data.find { |item| item["cycle"] == major }
|
|
51
|
-
|
|
51
|
+
return entry["eol"] if entry
|
|
52
|
+
|
|
53
|
+
# For major-only versions, find the latest minor version in that major series
|
|
54
|
+
# (e.g., version "7" should match "7.4" which is the latest 7.x)
|
|
55
|
+
matching_entries = data.select { |item| item["cycle"].to_s.start_with?("#{major}.") }
|
|
56
|
+
return nil if matching_entries.empty?
|
|
57
|
+
|
|
58
|
+
# Sort by cycle version and get the latest (highest minor version)
|
|
59
|
+
latest = matching_entries.max_by { |item| item["cycle"].to_s.split(".").map(&:to_i) }
|
|
60
|
+
latest ? latest["eol"] : nil
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
private
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "harbinger/eol_fetcher"
|
|
5
|
+
|
|
6
|
+
module Harbinger
|
|
7
|
+
module Exporters
|
|
8
|
+
# Base class for exporters that transform project data into various formats
|
|
9
|
+
class BaseExporter
|
|
10
|
+
COMPONENTS = %w[ruby rails postgres mysql redis mongo].freeze
|
|
11
|
+
PRODUCT_NAMES = {
|
|
12
|
+
"ruby" => "ruby",
|
|
13
|
+
"rails" => "rails",
|
|
14
|
+
"postgres" => "postgresql",
|
|
15
|
+
"mysql" => "mysql",
|
|
16
|
+
"redis" => "redis",
|
|
17
|
+
"mongo" => "mongodb"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(projects, fetcher: nil)
|
|
21
|
+
@projects = projects
|
|
22
|
+
@fetcher = fetcher || EolFetcher.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def export
|
|
26
|
+
raise NotImplementedError, "Subclasses must implement #export"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
protected
|
|
30
|
+
|
|
31
|
+
def build_export_data
|
|
32
|
+
@projects.filter_map do |name, data|
|
|
33
|
+
components = build_components(data)
|
|
34
|
+
next if components.empty?
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
name: name,
|
|
38
|
+
path: data["path"],
|
|
39
|
+
components: components,
|
|
40
|
+
overall_status: determine_overall_status(components)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_components(data)
|
|
48
|
+
COMPONENTS.filter_map do |component|
|
|
49
|
+
version = data[component]
|
|
50
|
+
next if version.nil? || version.empty?
|
|
51
|
+
next if version.include?("gem")
|
|
52
|
+
|
|
53
|
+
product = PRODUCT_NAMES[component]
|
|
54
|
+
eol_date = @fetcher.eol_date_for(product, version)
|
|
55
|
+
days = eol_date ? days_until(eol_date) : nil
|
|
56
|
+
status = days ? calculate_status(days) : "unknown"
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
name: component,
|
|
60
|
+
version: version,
|
|
61
|
+
eol_date: eol_date,
|
|
62
|
+
days_remaining: days,
|
|
63
|
+
status: status
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def calculate_status(days)
|
|
69
|
+
if days.negative?
|
|
70
|
+
"eol"
|
|
71
|
+
elsif days < 180
|
|
72
|
+
"warning"
|
|
73
|
+
else
|
|
74
|
+
"safe"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def determine_overall_status(components)
|
|
79
|
+
statuses = components.map { |c| c[:status] }
|
|
80
|
+
return "unknown" if statuses.empty? || statuses.all? { |s| s == "unknown" }
|
|
81
|
+
|
|
82
|
+
if statuses.include?("eol")
|
|
83
|
+
"eol"
|
|
84
|
+
elsif statuses.include?("warning")
|
|
85
|
+
"warning"
|
|
86
|
+
else
|
|
87
|
+
"safe"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def days_until(date_string)
|
|
92
|
+
eol_date = Date.parse(date_string)
|
|
93
|
+
(eol_date - Date.today).to_i
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|