ace-support-core 0.29.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.
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "organisms/config_initializer"
5
+ require_relative "organisms/config_diff"
6
+ require_relative "models/config_templates"
7
+
8
+ module Ace
9
+ module Core
10
+ # Framework CLI for ace-framework binary.
11
+ # Uses a separate class name to avoid collision with Ace::Core::CLI module
12
+ # (which provides shared CLI infrastructure).
13
+ class FrameworkCLI
14
+ def self.start(argv)
15
+ new.run(argv)
16
+ end
17
+
18
+ def run(argv)
19
+ return show_help if argv.empty?
20
+
21
+ command = argv.shift
22
+
23
+ case command
24
+ when "init"
25
+ run_init(argv)
26
+ when "diff"
27
+ run_diff(argv)
28
+ when "list"
29
+ run_list(argv)
30
+ when "version", "--version"
31
+ show_version
32
+ when "help", "--help", "-h"
33
+ show_help
34
+ else
35
+ puts "Unknown command: #{command}"
36
+ puts ""
37
+ show_help
38
+ exit 1
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def run_init(argv)
45
+ options = {}
46
+
47
+ parser = OptionParser.new do |opts|
48
+ opts.banner = <<~BANNER.chomp
49
+ NAME
50
+ ace-framework init - Initialize configuration for ace-* gems
51
+
52
+ USAGE
53
+ ace-framework init [GEM] [OPTIONS]
54
+
55
+ OPTIONS
56
+ BANNER
57
+ opts.on("--force", "Overwrite existing files") { options[:force] = true }
58
+ opts.on("--dry-run", "Show what would be done") { options[:dry_run] = true }
59
+ opts.on("--global", "Use ~/.ace instead of ./.ace") { options[:global] = true }
60
+ opts.on("--verbose", "Show verbose output") { options[:verbose] = true }
61
+ opts.on("-h", "--help", "Show this help") {
62
+ puts opts
63
+ exit
64
+ }
65
+ end
66
+
67
+ parser.parse!(argv)
68
+ gem_name = argv.shift
69
+
70
+ initializer = ConfigInitializer.new(**options)
71
+
72
+ if gem_name
73
+ initializer.init_gem(gem_name)
74
+ else
75
+ initializer.init_all
76
+ end
77
+ end
78
+
79
+ def run_diff(argv)
80
+ options = {}
81
+
82
+ parser = OptionParser.new do |opts|
83
+ opts.banner = <<~BANNER.chomp
84
+ NAME
85
+ ace-framework diff - Compare configs with examples
86
+
87
+ USAGE
88
+ ace-framework diff [GEM] [OPTIONS]
89
+
90
+ OPTIONS
91
+ BANNER
92
+ opts.on("--global", "Compare global configs") { options[:global] = true }
93
+ opts.on("--local", "Compare local configs (default)") { options[:local] = true }
94
+ opts.on("--file PATH", "Compare specific file") { |f| options[:file] = f }
95
+ opts.on("--one-line", "One-line summary per file") { options[:one_line] = true }
96
+ opts.on("-h", "--help", "Show this help") {
97
+ puts opts
98
+ exit
99
+ }
100
+ end
101
+
102
+ parser.parse!(argv)
103
+ gem_name = argv.shift
104
+
105
+ differ = ConfigDiff.new(**options)
106
+
107
+ if gem_name
108
+ differ.diff_gem(gem_name)
109
+ else
110
+ differ.run
111
+ end
112
+ end
113
+
114
+ def run_list(argv)
115
+ verbose = false
116
+
117
+ parser = OptionParser.new do |opts|
118
+ opts.banner = <<~BANNER.chomp
119
+ NAME
120
+ ace-framework list - List available ace-* gems with example configs
121
+
122
+ USAGE
123
+ ace-framework list [OPTIONS]
124
+
125
+ OPTIONS
126
+ BANNER
127
+ opts.on("--verbose", "Show detailed information") { verbose = true }
128
+ opts.on("-h", "--help", "Show this help") {
129
+ puts opts
130
+ exit
131
+ }
132
+ end
133
+
134
+ parser.parse!(argv)
135
+
136
+ puts "Available ace-* gems with example configurations:\n\n"
137
+
138
+ if ConfigTemplates.all_gems.empty?
139
+ puts "No ace-* gems with example configurations found."
140
+ return
141
+ end
142
+
143
+ ConfigTemplates.all_gems.each do |gem_name|
144
+ info = ConfigTemplates.gem_info[gem_name]
145
+ source_label = case info[:source]
146
+ when :local then "[local]"
147
+ when :gem then "[gem]"
148
+ when :both then "[local+gem]"
149
+ end
150
+
151
+ puts " #{gem_name} #{source_label}"
152
+
153
+ if verbose
154
+ puts " Path: #{info[:path]}"
155
+ puts " Gem: #{info[:gem_path]}" if info[:gem_path]
156
+ example_dir = ConfigTemplates.example_dir_for(gem_name)
157
+ if example_dir && File.exist?(example_dir)
158
+ example_files = Dir.glob("#{example_dir}/**/*").reject { |f| File.directory?(f) }
159
+ puts " Example files: #{example_files.size}"
160
+ end
161
+ end
162
+ end
163
+
164
+ puts "\nUse 'ace-framework init [GEM]' to initialize a specific gem's configuration"
165
+ puts "Use 'ace-framework init' to initialize all configurations"
166
+ end
167
+
168
+ def show_version
169
+ puts "ace-framework #{Ace::Core::VERSION}"
170
+ end
171
+
172
+ def show_help
173
+ puts <<~HELP
174
+ NAME
175
+ ace-framework - Configuration management for ace-* gems
176
+
177
+ USAGE
178
+ ace-framework COMMAND [OPTIONS]
179
+
180
+ COMMANDS
181
+ init [GEM] Initialize configuration for specific gem or all
182
+ diff [GEM] Compare configs with examples
183
+ list List available ace-* gems with example configs
184
+ version Show version
185
+ help Show this help
186
+
187
+ Run 'ace-framework COMMAND --help' for more information on a command.
188
+ HELP
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/config"
4
+ require "ace/support/fs"
5
+
6
+ module Ace
7
+ module Core
8
+ # Public API for configuration discovery across the project hierarchy
9
+ # Wraps ace-config with ace-specific defaults (.ace config directory)
10
+ class ConfigDiscovery
11
+ attr_reader :start_path
12
+
13
+ # Initialize config discovery
14
+ # @param start_path [String] Starting path for discovery (default: current directory)
15
+ def initialize(start_path: nil)
16
+ @start_path = start_path || Dir.pwd
17
+ @finder = ::Ace::Support::Config::Molecules::ConfigFinder.new(
18
+ config_dir: ".ace",
19
+ defaults_dir: ".ace-defaults",
20
+ use_traversal: true,
21
+ start_path: @start_path
22
+ )
23
+ end
24
+
25
+ # Find the first matching config file in the cascade
26
+ # @param filename [String] Config filename to find
27
+ # @return [String, nil] Path to the config file or nil if not found
28
+ def find_config_file(filename)
29
+ @finder.find_file(filename)
30
+ end
31
+
32
+ # Find all matching config files in cascade order
33
+ # @param filename [String] Config filename to find
34
+ # @return [Array<String>] All matching file paths in priority order
35
+ def find_all_config_files(filename)
36
+ @finder.find_all_files(filename)
37
+ end
38
+
39
+ # Get the project root directory
40
+ # @return [String, nil] Project root path or nil if not in a project
41
+ def project_root
42
+ ::Ace::Support::Fs::Molecules::ProjectRootFinder.find(start_path: @start_path)
43
+ end
44
+
45
+ # Check if we're in a project
46
+ # @return [Boolean] true if project root is found
47
+ def in_project?
48
+ !project_root.nil?
49
+ end
50
+
51
+ # Get all configuration search paths in order
52
+ # @return [Array<String>] Ordered list of config directories being searched
53
+ def config_search_paths
54
+ @finder.search_paths
55
+ end
56
+
57
+ # Get relative path from project root
58
+ # @param path [String] Path to make relative
59
+ # @return [String, nil] Relative path or nil if not in project
60
+ def relative_path(path)
61
+ root = project_root
62
+ return nil unless root
63
+
64
+ expanded = File.expand_path(path)
65
+ return nil unless expanded.start_with?(root)
66
+
67
+ require "pathname"
68
+ Pathname.new(expanded).relative_path_from(Pathname.new(root)).to_s
69
+ end
70
+
71
+ # Load configuration from the cascade for a specific file
72
+ # @param filename [String] Config file to load
73
+ # @param resolve_paths [Boolean] Whether to resolve relative paths (default: true)
74
+ # @return [Hash, nil] Merged configuration or nil if no files found
75
+ def load_config(filename, resolve_paths: true)
76
+ files = find_all_config_files(filename)
77
+ return nil if files.empty?
78
+
79
+ require "yaml"
80
+ config = {}
81
+
82
+ # Load in reverse order so higher priority overwrites lower
83
+ files.reverse_each do |file|
84
+ file_config = YAML.load_file(file)
85
+ if file_config.is_a?(Hash)
86
+ # Resolve relative paths if requested
87
+ if resolve_paths
88
+ base_dir = File.dirname(file)
89
+ # Determine project root for resolving plain paths
90
+ proj_root = project_root
91
+ file_config = resolve_relative_paths(file_config, base_dir, proj_root)
92
+ end
93
+ config = ::Ace::Support::Config::Atoms::DeepMerger.merge(config, file_config)
94
+ end
95
+ rescue => e
96
+ warn "Error loading config from #{file}: #{e.message}"
97
+ end
98
+
99
+ config
100
+ end
101
+
102
+ # Class method shortcuts
103
+ class << self
104
+ # Find a config file from current directory
105
+ # @param filename [String] Config filename to find
106
+ # @return [String, nil] Path to config file
107
+ def find(filename)
108
+ new.find_config_file(filename)
109
+ end
110
+
111
+ # Find all config files from current directory
112
+ # @param filename [String] Config filename to find
113
+ # @return [Array<String>] All matching file paths
114
+ def find_all(filename)
115
+ new.find_all_config_files(filename)
116
+ end
117
+
118
+ # Get project root from current directory
119
+ # @return [String, nil] Project root path
120
+ def project_root
121
+ new.project_root
122
+ end
123
+
124
+ # Load merged configuration
125
+ # @param filename [String] Config file to load
126
+ # @return [Hash, nil] Merged configuration
127
+ def load(filename)
128
+ new.load_config(filename)
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Recursively resolve relative paths in a configuration structure
135
+ # @param obj [Object] Configuration object (hash, array, or value)
136
+ # @param base_dir [String] Base directory to resolve paths against
137
+ # @param project_root [String] Project root directory for plain paths
138
+ # @return [Object] Configuration with resolved paths
139
+ def resolve_relative_paths(obj, base_dir, project_root = nil)
140
+ case obj
141
+ when Hash
142
+ obj.transform_values { |v| resolve_relative_paths(v, base_dir, project_root) }
143
+ when Array
144
+ obj.map { |v| resolve_relative_paths(v, base_dir, project_root) }
145
+ when String
146
+ # Check if this looks like a relative path with dots
147
+ if obj.start_with?("./", "../")
148
+ # Resolve relative to the config file's directory
149
+ File.expand_path(File.join(base_dir, obj))
150
+ elsif project_root && looks_like_project_path?(obj)
151
+ # Plain paths that look like project directories/files
152
+ # are resolved relative to project root
153
+ File.join(project_root, obj)
154
+ else
155
+ obj
156
+ end
157
+ else
158
+ obj
159
+ end
160
+ end
161
+
162
+ # Check if a string looks like a project-relative path
163
+ # @param str [String] String to check
164
+ # @return [Boolean] true if it looks like a project path
165
+ def looks_like_project_path?(str)
166
+ # Don't treat absolute paths, URLs, or special values as project paths
167
+ return false if str.start_with?("/", "http://", "https://", "~")
168
+ return false if str.include?(":") # URLs, Windows paths
169
+
170
+ # Check if it looks like a path (contains slash or common project directories)
171
+ # or matches common project directory names
172
+ str.include?("/") || str.match?(/^(ace-|lib|src|bin|test|spec|app|config|vendor|node_modules)/)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Core
5
+ # Base error class for all ace-core errors
6
+ class Error < StandardError; end
7
+
8
+ # Raised when configuration file is invalid (ace-specific)
9
+ class ConfigInvalidError < Error; end
10
+
11
+ # Raised when environment file parsing fails (ace-specific)
12
+ class EnvParseError < Error; end
13
+ end
14
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rubygems"
5
+
6
+ module Ace
7
+ module Core
8
+ class ConfigTemplates
9
+ class << self
10
+ def all_gems
11
+ gem_info.keys.sort
12
+ end
13
+
14
+ def gem_exists?(gem_name)
15
+ gem_info.key?(gem_name)
16
+ end
17
+
18
+ def example_dir_for(gem_name)
19
+ info = gem_info[gem_name]
20
+ return nil unless info
21
+
22
+ # Prefer local path for development
23
+ path = (info[:source] == :gem) ? info[:path] : (info[:path] || info[:gem_path])
24
+ resolve_defaults_dir(path)
25
+ end
26
+
27
+ def gem_info
28
+ @gem_info ||= build_gem_info
29
+ end
30
+
31
+ def build_gem_info
32
+ gems = {}
33
+
34
+ # 1. Look in the parent directory (monorepo/development)
35
+ parent_dir = File.expand_path("../../../../../../", __FILE__)
36
+ Dir.glob("#{parent_dir}/ace-*").each do |dir|
37
+ next unless File.directory?(dir)
38
+ gem_name = File.basename(dir)
39
+ if has_example_dir?(dir)
40
+ gems[gem_name] = {source: :local, path: dir}
41
+ end
42
+ end
43
+
44
+ # 2. Look for installed RubyGems
45
+ begin
46
+ Gem::Specification.each do |spec|
47
+ next unless spec.name.start_with?("ace-")
48
+
49
+ gem_path = spec.gem_dir
50
+ if has_example_dir?(gem_path)
51
+ if gems.key?(spec.name)
52
+ gems[spec.name][:source] = :both
53
+ gems[spec.name][:gem_path] = gem_path
54
+ else
55
+ gems[spec.name] = {source: :gem, path: gem_path}
56
+ end
57
+ end
58
+ end
59
+ rescue
60
+ # If we can't access installed gems, just use local ones
61
+ end
62
+
63
+ gems
64
+ end
65
+
66
+ def docs_file_for(gem_name)
67
+ info = gem_info[gem_name]
68
+ return nil unless info
69
+
70
+ # Prefer local path for development
71
+ path = (info[:source] == :gem) ? info[:path] : (info[:path] || info[:gem_path])
72
+ File.join(path, "docs", "config.md")
73
+ end
74
+
75
+ private
76
+
77
+ def resolve_defaults_dir(gem_path)
78
+ File.join(gem_path, ".ace-defaults")
79
+ end
80
+
81
+ def has_example_dir?(gem_dir)
82
+ Dir.exist?(File.join(gem_dir, ".ace-defaults"))
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/env_parser"
4
+ require "ace/support/fs"
5
+ require_relative "../errors"
6
+
7
+ module Ace
8
+ module Core
9
+ module Molecules
10
+ # .env file loading and environment variable management
11
+ class EnvLoader
12
+ # Load .env file and return parsed variables
13
+ # @param filepath [String] Path to .env file
14
+ # @return [Hash] Parsed environment variables
15
+ # @raise [EnvParseError] if parsing fails
16
+ def self.load_file(filepath)
17
+ filepath = Ace::Support::Fs::Atoms::PathExpander.expand(filepath)
18
+
19
+ unless File.exist?(filepath)
20
+ return {}
21
+ end
22
+
23
+ content = File.read(filepath)
24
+ Atoms::EnvParser.parse(content)
25
+ rescue IOError, SystemCallError => e
26
+ raise EnvParseError, "Failed to read .env file #{filepath}: #{e.message}"
27
+ end
28
+
29
+ # Load .env file and set environment variables
30
+ # @param filepath [String] Path to .env file
31
+ # @param overwrite [Boolean] Whether to overwrite existing vars
32
+ # @return [Hash] Variables that were set
33
+ def self.load_and_set(filepath, overwrite: true)
34
+ vars = load_file(filepath)
35
+ set_environment(vars, overwrite: overwrite)
36
+ end
37
+
38
+ # Set environment variables from hash
39
+ # @param vars [Hash] Variables to set
40
+ # @param overwrite [Boolean] Whether to overwrite existing
41
+ # @return [Hash] Variables that were actually set
42
+ def self.set_environment(vars, overwrite: true)
43
+ set_vars = {}
44
+
45
+ vars.each do |key, value|
46
+ next unless Atoms::EnvParser.valid_key?(key)
47
+ next if !overwrite && ENV.key?(key)
48
+
49
+ ENV[key] = value.to_s
50
+ set_vars[key] = value
51
+ end
52
+
53
+ set_vars
54
+ end
55
+
56
+ # Save environment variables to .env file
57
+ # @param vars [Hash] Variables to save
58
+ # @param filepath [String] Path to .env file
59
+ def self.save_file(vars, filepath)
60
+ content = Atoms::EnvParser.format(vars)
61
+
62
+ # Create directory if it doesn't exist
63
+ dir = File.dirname(filepath)
64
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
65
+
66
+ File.write(filepath, content)
67
+ rescue IOError, SystemCallError => e
68
+ raise EnvParseError, "Failed to save .env file #{filepath}: #{e.message}"
69
+ end
70
+
71
+ # Load multiple .env files in order
72
+ # @param filepaths [Array<String>] Paths to .env files
73
+ # @param overwrite [Boolean] Whether to overwrite existing
74
+ # @return [Hash] All variables that were set
75
+ def self.load_multiple(*filepaths, overwrite: true)
76
+ all_vars = {}
77
+
78
+ filepaths.flatten.each do |filepath|
79
+ vars = load_file(filepath)
80
+ all_vars.merge!(vars) if vars && !vars.empty?
81
+ end
82
+
83
+ set_environment(all_vars, overwrite: overwrite)
84
+ end
85
+
86
+ # Find and load .env files in standard locations
87
+ # @param root [String] Project root directory
88
+ # @return [Hash] Variables that were loaded
89
+ def self.auto_load(root = Dir.pwd)
90
+ root = Ace::Support::Fs::Atoms::PathExpander.expand(root)
91
+
92
+ # Standard .env file locations in priority order
93
+ env_files = [
94
+ File.join(root, ".env.local"),
95
+ File.join(root, ".env")
96
+ ]
97
+
98
+ # Load files that exist
99
+ existing = env_files.select { |f| File.exist?(f) }
100
+ load_multiple(*existing, overwrite: false) unless existing.empty?
101
+ end
102
+
103
+ # Load .env files from cascade without setting ENV
104
+ # @param search_paths [Array<String>, nil] Optional search paths
105
+ # @return [Hash] Merged variables from all .env files
106
+ def self.load_cascade(search_paths: nil)
107
+ require_relative "../config_discovery"
108
+
109
+ discovery = ConfigDiscovery.new(start_path: search_paths&.first)
110
+ merged_vars = {}
111
+
112
+ # Find all .env files in cascade (returns in priority order)
113
+ env_files = discovery.find_all_config_files(".env")
114
+
115
+ # Load each file and merge (later files override earlier)
116
+ env_files.reverse_each do |filepath|
117
+ next unless File.exist?(filepath)
118
+
119
+ file_vars = load_file(filepath)
120
+ merged_vars.merge!(file_vars) if file_vars && !file_vars.empty?
121
+ end
122
+
123
+ merged_vars
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end