ace-support-config 0.10.2 → 0.16.3

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.
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Support
7
+ module Config
8
+ module Molecules
9
+ class SetupDoctorReporter
10
+ COLORS = {
11
+ red: "\e[31m",
12
+ yellow: "\e[33m",
13
+ green: "\e[32m",
14
+ blue: "\e[34m",
15
+ cyan: "\e[36m",
16
+ reset: "\e[0m",
17
+ bold: "\e[1m"
18
+ }.freeze
19
+
20
+ ICONS = {
21
+ doctor: "đŸĨ",
22
+ stats: "📊",
23
+ success: "✅",
24
+ error: "❌",
25
+ warning: "âš ī¸",
26
+ info: "â„šī¸"
27
+ }.freeze
28
+
29
+ STATUS_GLYPHS = {
30
+ "pass" => "✓",
31
+ "warn" => "○",
32
+ "blocker" => "✗",
33
+ "skip" => "○",
34
+ "info" => "○"
35
+ }.freeze
36
+
37
+ STATUS_COLORS = {
38
+ "pass" => :green,
39
+ "warn" => :yellow,
40
+ "blocker" => :red,
41
+ "skip" => :yellow,
42
+ "info" => :cyan
43
+ }.freeze
44
+
45
+ def self.format_results(results, format: :terminal, hygiene: false, verbose: false, colors: true)
46
+ case format.to_sym
47
+ when :json
48
+ JSON.pretty_generate(results)
49
+ else
50
+ new(results, hygiene: hygiene, verbose: verbose, colors: colors).format_terminal
51
+ end
52
+ end
53
+
54
+ def initialize(results, hygiene:, verbose:, colors:)
55
+ @results = results
56
+ @hygiene = hygiene
57
+ @verbose = verbose
58
+ @colors = colors
59
+ end
60
+
61
+ def format_terminal
62
+ output = []
63
+ output << "\n#{colorize("#{ICONS[:doctor]} Setup Health Check", :bold)}"
64
+ output << "=" * 40
65
+ output.concat(format_overview)
66
+ output.concat(format_readiness)
67
+ output.concat(format_info)
68
+ output.concat(format_provider_pings)
69
+ output.concat(format_issues)
70
+ output << "=" * 40
71
+ output << format_summary_line
72
+ output << "\n#{colorize("Completed in #{format_duration(@results[:duration])}", :blue)}" if @results[:duration]
73
+ output << final_status_line
74
+ output.join("\n")
75
+ end
76
+
77
+ private
78
+
79
+ def format_overview
80
+ stats = @results[:stats] || {}
81
+ [
82
+ "",
83
+ colorize("#{ICONS[:stats]} Overview", :cyan),
84
+ "-" * 20,
85
+ " Health checks: #{stats[:health_checks] || health_checks.length}",
86
+ " Info checks: #{stats[:info_checks] || info_checks.length}",
87
+ " Utility providers checked: #{stats[:provider_targets] || provider_outcomes.length}",
88
+ " Hygiene findings: #{stats[:hygiene_findings] || 0}"
89
+ ]
90
+ end
91
+
92
+ def format_readiness
93
+ rows = health_checks.reject { |check| check[:id] == "probe-readiness" }
94
+ return [] if rows.empty?
95
+
96
+ output = ["", colorize("Readiness", :cyan), "-" * 20]
97
+ rows.each do |check|
98
+ output << " #{status_glyph(check[:status])} #{check[:message]}"
99
+ if check[:status] == "blocker" || check[:status] == "warn" || @verbose
100
+ Array(check[:details]).each { |detail| output << " - #{detail}" }
101
+ output << " Next: #{check[:next_action]}" if check[:next_action]
102
+ end
103
+ end
104
+ output
105
+ end
106
+
107
+ def format_info
108
+ return [] if info_checks.empty?
109
+
110
+ output = ["", colorize("Info", :cyan), "-" * 20]
111
+ info_checks.each do |check|
112
+ output << " #{status_glyph(check[:status])} #{check[:message]}"
113
+ if @verbose
114
+ Array(check[:details]).each { |detail| output << " - #{detail}" }
115
+ output << " Next: #{check[:next_action]}" if check[:next_action]
116
+ end
117
+ end
118
+ output
119
+ end
120
+
121
+ def format_provider_pings
122
+ check = health_checks.find { |row| row[:id] == "probe-readiness" }
123
+ return [] unless check
124
+
125
+ output = ["", colorize("Utility Provider Pings", :cyan), "-" * 20]
126
+ output << " #{status_glyph(check[:status])} #{check[:message]}"
127
+ details = provider_outcomes.any? ? provider_outcomes.map { |outcome| provider_outcome_line(outcome) } : Array(check[:details])
128
+ details.each { |detail| output << " #{detail}" }
129
+ output << " Next: #{check[:next_action]}" if check[:next_action]
130
+ output
131
+ end
132
+
133
+ def format_issues
134
+ blockers = health_checks.select { |check| check[:status] == "blocker" }
135
+ warnings = health_checks.select { |check| check[:status] == "warn" }
136
+ hygiene_warnings = hygiene_checks.reject { |check| check[:status] == "pass" }
137
+
138
+ if blockers.empty? && warnings.empty? && hygiene_warnings.empty?
139
+ return ["", colorize("#{ICONS[:success]} No issues found", :green)]
140
+ end
141
+
142
+ output = ["", colorize("Issues Found:", :yellow), "-" * 20]
143
+ output.concat(format_issue_group("#{ICONS[:error]} Blockers", blockers, :red, include_details: true))
144
+ output.concat(format_issue_group("#{ICONS[:warning]} Warnings", warnings, :yellow, include_details: true))
145
+ output.concat(format_hygiene_group(hygiene_warnings))
146
+ output
147
+ end
148
+
149
+ def format_issue_group(title, checks, color, include_details:)
150
+ return [] if checks.empty?
151
+
152
+ output = ["", colorize("#{title} (#{checks.length})", color)]
153
+ checks.each_with_index do |check, index|
154
+ output << "#{index + 1}. #{check[:message]}"
155
+ if include_details
156
+ Array(check[:details]).each { |detail| output << " - #{detail}" }
157
+ end
158
+ output << " Next: #{check[:next_action]}" if check[:next_action]
159
+ end
160
+ output
161
+ end
162
+
163
+ def format_hygiene_group(checks)
164
+ finding_count = @results.dig(:hygiene, :finding_count) || 0
165
+ return [] if finding_count.zero?
166
+
167
+ if !@hygiene && !@verbose
168
+ return [
169
+ "",
170
+ colorize("#{ICONS[:warning]} Hygiene", :yellow),
171
+ "1. Hygiene findings detected (#{finding_count}); rerun with --hygiene for details"
172
+ ]
173
+ end
174
+
175
+ output = ["", colorize("#{ICONS[:warning]} Hygiene (#{finding_count})", :yellow)]
176
+ issue_number = 1
177
+ checks.each do |check|
178
+ output << "#{issue_number}. #{check[:message]}"
179
+ Array(check[:details]).each { |detail| output << " - #{detail}" }
180
+ output << " Next: #{check[:next_action]}" if check[:next_action]
181
+ issue_number += 1
182
+ end
183
+ output
184
+ end
185
+
186
+ def format_summary_line
187
+ health = @results[:health] || {}
188
+ hygiene = @results[:hygiene] || {}
189
+ info = @results[:info] || {}
190
+ parts = []
191
+ parts << colorize("#{health[:blocker_count]} blockers", health[:blocker_count].to_i.positive? ? :red : :green)
192
+ parts << colorize("#{health[:warning_count]} warnings", health[:warning_count].to_i.positive? ? :yellow : :green)
193
+ parts << colorize("#{info[:count]} info", :cyan)
194
+ parts << colorize("#{hygiene[:finding_count]} hygiene findings", hygiene[:finding_count].to_i.positive? ? :yellow : :green)
195
+ parts.join(", ")
196
+ end
197
+
198
+ def final_status_line
199
+ if @results[:valid]
200
+ if @results.dig(:health, :warning_count).to_i.positive? || @results.dig(:hygiene, :finding_count).to_i.positive?
201
+ colorize("Setup check passed with warnings", :yellow)
202
+ else
203
+ colorize("Setup check passed", :green)
204
+ end
205
+ else
206
+ colorize("Setup check failed", :red)
207
+ end
208
+ end
209
+
210
+ def provider_outcome_line(outcome)
211
+ elapsed = outcome[:elapsed_ms] ? " in #{outcome[:elapsed_ms]}ms" : ""
212
+ glyph = outcome[:status] == "pass" ? status_glyph(outcome[:status]) : colorize("✗", :red)
213
+ suffix = if outcome[:status] == "pass"
214
+ elapsed
215
+ elsif outcome[:failure_type] == "timeout"
216
+ " timed out after #{outcome[:timeout_seconds]}s"
217
+ else
218
+ " failed"
219
+ end
220
+ "#{glyph} #{target_display(outcome)}#{suffix}"
221
+ end
222
+
223
+ def target_display(target)
224
+ label = target[:label].to_s
225
+ selector = target[:selector].to_s
226
+ label = selector if label.empty?
227
+ selector.empty? || selector == label ? label : "#{label} (#{selector})"
228
+ end
229
+
230
+ def status_glyph(status)
231
+ glyph = STATUS_GLYPHS.fetch(status.to_s, STATUS_GLYPHS["warn"])
232
+ colorize(glyph, STATUS_COLORS.fetch(status.to_s, :yellow))
233
+ end
234
+
235
+ def health_checks
236
+ @health_checks ||= checks.select { |check| check[:kind] == "health" }
237
+ end
238
+
239
+ def hygiene_checks
240
+ @hygiene_checks ||= checks.select { |check| check[:kind] == "hygiene" }
241
+ end
242
+
243
+ def info_checks
244
+ @info_checks ||= checks.select { |check| check[:kind] == "info" }
245
+ end
246
+
247
+ def provider_outcomes
248
+ @provider_outcomes ||= Array(health_checks.find { |row| row[:id] == "probe-readiness" }&.fetch(:outcomes, []))
249
+ end
250
+
251
+ def checks
252
+ @results[:checks] || []
253
+ end
254
+
255
+ def format_duration(duration)
256
+ return "0ms" unless duration
257
+
258
+ duration < 1 ? "#{(duration * 1000).round}ms" : "#{duration.round(2)}s"
259
+ end
260
+
261
+ def colorize(text, color)
262
+ return text unless @colors && COLORS[color]
263
+
264
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require_relative "../models/config_templates"
6
+
7
+ module Ace
8
+ module Support
9
+ module Config
10
+ module Organisms
11
+ class ConfigSynchronizer
12
+ PROJECT_ROOT_DIR = "project-root"
13
+ GITIGNORE_ACE_LOCAL_ENTRY = ".ace-local/"
14
+
15
+ def initialize(force: false, dry_run: false, global: false, verbose: false)
16
+ @force = force
17
+ @dry_run = dry_run
18
+ @global = global
19
+ @verbose = verbose
20
+ @copied_files = []
21
+ @skipped_files = []
22
+ end
23
+
24
+ def sync_all
25
+ puts "Syncing all ace-* gem configurations..." if @verbose
26
+
27
+ Models::ConfigTemplates.all_gems.each do |gem_name|
28
+ sync_gem(gem_name)
29
+ end
30
+
31
+ print_summary
32
+ end
33
+
34
+ def sync_gem(gem_name)
35
+ gem_name = normalize_gem_name(gem_name)
36
+
37
+ unless Models::ConfigTemplates.gem_exists?(gem_name)
38
+ puts "Warning: No configuration found for #{gem_name}"
39
+ return
40
+ end
41
+
42
+ puts "\nSyncing #{gem_name}..." if @verbose
43
+
44
+ source_dir = Models::ConfigTemplates.example_dir_for(gem_name)
45
+ target_dir = target_directory
46
+
47
+ unless File.exist?(source_dir)
48
+ puts "Warning: No .ace-defaults directory found for #{gem_name}"
49
+ return
50
+ end
51
+
52
+ show_config_docs_if_needed(gem_name, target_dir)
53
+ copy_config_files(source_dir, target_dir)
54
+ end
55
+
56
+ private
57
+
58
+ def normalize_gem_name(name)
59
+ name.start_with?("ace-") ? name : "ace-#{name}"
60
+ end
61
+
62
+ def target_directory
63
+ return File.expand_path("~/.ace") if @global
64
+
65
+ File.join(project_root, ".ace")
66
+ end
67
+
68
+ def show_config_docs_if_needed(gem_name, target_dir)
69
+ config_subdir = gem_name.sub("ace-", "")
70
+ existing_configs = Dir.glob("#{target_dir}/#{config_subdir}/**/*").reject { |f| File.directory?(f) }
71
+
72
+ return if existing_configs.any? || @dry_run
73
+
74
+ docs_file = Models::ConfigTemplates.docs_file_for(gem_name)
75
+ puts "\n#{File.read(docs_file)}\n" if docs_file && File.exist?(docs_file)
76
+ end
77
+
78
+ def copy_config_files(source_dir, target_dir)
79
+ glob_pattern = File.join(source_dir, "**", "*")
80
+
81
+ Dir.glob(glob_pattern, File::FNM_DOTMATCH).each do |source_file|
82
+ basename = File.basename(source_file)
83
+ next if basename == "." || basename == ".."
84
+ next if File.directory?(source_file)
85
+
86
+ relative_path = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir))
87
+ target_file = target_file_for(relative_path, target_dir)
88
+ next unless target_file
89
+
90
+ copy_file(source_file, target_file)
91
+ end
92
+ end
93
+
94
+ def copy_file(source, target)
95
+ if File.basename(target) == ".gitignore"
96
+ merge_gitignore(source, target)
97
+ return
98
+ end
99
+
100
+ if File.exist?(target) && !@force
101
+ @skipped_files << target
102
+ puts " Skipped: #{target} (already exists)" if @verbose
103
+ return
104
+ end
105
+
106
+ if @dry_run
107
+ puts " Would copy: #{source} -> #{target}"
108
+ else
109
+ FileUtils.mkdir_p(File.dirname(target))
110
+ FileUtils.cp(source, target)
111
+ @copied_files << target
112
+ puts " Copied: #{target}" if @verbose
113
+ end
114
+ end
115
+
116
+ def target_file_for(relative_path, target_dir)
117
+ relative_str = relative_path.to_s
118
+
119
+ if relative_str.start_with?("#{PROJECT_ROOT_DIR}/")
120
+ return nil if @global
121
+
122
+ return File.join(project_root, relative_str.delete_prefix("#{PROJECT_ROOT_DIR}/"))
123
+ end
124
+
125
+ File.join(target_dir, relative_str)
126
+ end
127
+
128
+ def merge_gitignore(source, target)
129
+ target_exists = File.exist?(target)
130
+
131
+ if target_exists
132
+ merge_gitignore_entry_if_missing(source, target)
133
+ return
134
+ end
135
+
136
+ if @dry_run
137
+ puts " Would copy: #{source} -> #{target}"
138
+ else
139
+ FileUtils.mkdir_p(File.dirname(target))
140
+ FileUtils.cp(source, target)
141
+ @copied_files << target
142
+ puts " Copied: #{target}" if @verbose
143
+ end
144
+ end
145
+
146
+ def merge_gitignore_entry_if_missing(source, target)
147
+ existing_content = File.read(target)
148
+ return if gitignore_entry_present?(existing_content, GITIGNORE_ACE_LOCAL_ENTRY)
149
+
150
+ line = File.readlines(source, chomp: true).find do |entry|
151
+ entry == GITIGNORE_ACE_LOCAL_ENTRY
152
+ end
153
+ return unless line
154
+
155
+ if @dry_run
156
+ puts " Would append: #{line} -> #{target}"
157
+ return
158
+ end
159
+
160
+ File.open(target, "a") do |file|
161
+ file.write("\n") unless existing_content.end_with?("\n") || existing_content.empty?
162
+ file.puts line
163
+ end
164
+
165
+ @copied_files << target
166
+ puts " Appended: #{line} -> #{target}" if @verbose
167
+ end
168
+
169
+ def gitignore_entry_present?(content, entry)
170
+ content.each_line.any? do |line|
171
+ normalized = line.strip
172
+ next false if normalized.empty? || normalized.start_with?("#")
173
+
174
+ normalized == entry
175
+ end
176
+ end
177
+
178
+ def project_root
179
+ @project_root ||= Ace::Support::Config.find_project_root(start_path: Dir.pwd) || Dir.pwd
180
+ end
181
+
182
+ def print_summary
183
+ return if @dry_run
184
+
185
+ puts "\nConfiguration sync complete:"
186
+ puts " Files copied: #{@copied_files.size}"
187
+ puts " Files skipped: #{@skipped_files.size}"
188
+
189
+ if @skipped_files.any? && !@force
190
+ puts "\nUse --force to overwrite existing files"
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end