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.
- checksums.yaml +7 -0
- data/.ace-defaults/core/settings.yml +36 -0
- data/CHANGELOG.md +460 -0
- data/LICENSE +21 -0
- data/README.md +34 -0
- data/Rakefile +14 -0
- data/lib/ace/core/atoms/command_executor.rb +239 -0
- data/lib/ace/core/atoms/config_summary.rb +220 -0
- data/lib/ace/core/atoms/env_parser.rb +76 -0
- data/lib/ace/core/atoms/file_reader.rb +184 -0
- data/lib/ace/core/atoms/glob_expander.rb +175 -0
- data/lib/ace/core/atoms/process_terminator.rb +39 -0
- data/lib/ace/core/atoms/template_parser.rb +222 -0
- data/lib/ace/core/cli/config_summary_mixin.rb +55 -0
- data/lib/ace/core/cli.rb +192 -0
- data/lib/ace/core/config_discovery.rb +176 -0
- data/lib/ace/core/errors.rb +14 -0
- data/lib/ace/core/models/config_templates.rb +87 -0
- data/lib/ace/core/molecules/env_loader.rb +128 -0
- data/lib/ace/core/molecules/file_aggregator.rb +196 -0
- data/lib/ace/core/molecules/frontmatter_free_policy.rb +34 -0
- data/lib/ace/core/molecules/output_formatter.rb +433 -0
- data/lib/ace/core/molecules/prompt_cache_manager.rb +141 -0
- data/lib/ace/core/organisms/config_diff.rb +187 -0
- data/lib/ace/core/organisms/config_initializer.rb +125 -0
- data/lib/ace/core/organisms/environment_manager.rb +142 -0
- data/lib/ace/core/version.rb +7 -0
- data/lib/ace/core.rb +144 -0
- metadata +115 -0
data/lib/ace/core/cli.rb
ADDED
|
@@ -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
|