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.
- checksums.yaml +4 -4
- data/README.md +143 -35
- data/docs/index.html +70 -23
- data/lib/harbinger/analyzers/database_detector.rb +13 -3
- data/lib/harbinger/analyzers/docker_compose_detector.rb +121 -0
- data/lib/harbinger/analyzers/go_detector.rb +86 -0
- data/lib/harbinger/analyzers/mongo_detector.rb +104 -0
- data/lib/harbinger/analyzers/mysql_detector.rb +8 -1
- data/lib/harbinger/analyzers/node_detector.rb +109 -0
- data/lib/harbinger/analyzers/postgres_detector.rb +6 -0
- data/lib/harbinger/analyzers/python_detector.rb +110 -0
- data/lib/harbinger/analyzers/rails_analyzer.rb +5 -1
- data/lib/harbinger/analyzers/redis_detector.rb +98 -0
- data/lib/harbinger/analyzers/ruby_detector.rb +9 -1
- data/lib/harbinger/analyzers/rust_detector.rb +116 -0
- data/lib/harbinger/cli.rb +453 -149
- data/lib/harbinger/eol_fetcher.rb +19 -10
- data/lib/harbinger/exporters/base_exporter.rb +100 -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 +11 -1
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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:
|
|
633
|
+
versions: versions
|
|
355
634
|
)
|
|
356
635
|
end
|
|
357
636
|
end
|
|
358
637
|
|
|
359
|
-
def
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|