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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/fs"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Core
|
|
9
|
+
module Molecules
|
|
10
|
+
# Standardized prompt cache management for ace-* gems
|
|
11
|
+
# Provides consistent session directory creation, prompt saving, and metadata management
|
|
12
|
+
#
|
|
13
|
+
# This is a stateless utility class - all methods are class methods.
|
|
14
|
+
class PromptCacheManager
|
|
15
|
+
# Custom exception for prompt cache operations
|
|
16
|
+
class PromptCacheError < StandardError; end
|
|
17
|
+
class << self
|
|
18
|
+
# Create a new session directory with standardized structure
|
|
19
|
+
# @param gem_name [String] Name of the gem (e.g., 'ace-review', 'ace-docs')
|
|
20
|
+
# @param operation [String] Operation name (e.g., 'review', 'analyze-consistency')
|
|
21
|
+
# @param project_root [String, nil] Optional project root (auto-detected if nil)
|
|
22
|
+
# @param timestamp_formatter [Proc, nil] Optional custom timestamp formatter
|
|
23
|
+
# @return [String] Path to created session directory
|
|
24
|
+
def create_session(gem_name, operation, project_root: nil, timestamp_formatter: nil)
|
|
25
|
+
raise ArgumentError, "gem_name cannot be nil or empty" if gem_name.nil? || gem_name.strip.empty?
|
|
26
|
+
raise ArgumentError, "operation cannot be nil or empty" if operation.nil? || operation.strip.empty?
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
root = project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
30
|
+
timestamp = timestamp_formatter ? timestamp_formatter.call(Time.now) : Time.now.strftime("%Y%m%d-%H%M%S")
|
|
31
|
+
session_name = "#{operation}-#{timestamp}"
|
|
32
|
+
session_dir = File.join(base_cache_path(root, gem_name), session_name)
|
|
33
|
+
FileUtils.mkdir_p(session_dir)
|
|
34
|
+
session_dir
|
|
35
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
36
|
+
raise PromptCacheError, "Failed to create session directory: #{e.message}"
|
|
37
|
+
rescue => e
|
|
38
|
+
raise PromptCacheError, "Unexpected error creating session: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Save system prompt to session directory
|
|
43
|
+
# @param content [String] Prompt content
|
|
44
|
+
# @param session_dir [String] Session directory path
|
|
45
|
+
# @return [String] Path to saved file
|
|
46
|
+
def save_system_prompt(content, session_dir)
|
|
47
|
+
save_prompt(content, session_dir, "system.prompt.md")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Save user prompt to session directory
|
|
51
|
+
# @param content [String] Prompt content
|
|
52
|
+
# @param session_dir [String] Session directory path
|
|
53
|
+
# @return [String] Path to saved file
|
|
54
|
+
def save_user_prompt(content, session_dir)
|
|
55
|
+
save_prompt(content, session_dir, "user.prompt.md")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Save metadata to session directory
|
|
59
|
+
# @param metadata [Hash] Metadata hash
|
|
60
|
+
# @param session_dir [String] Session directory path
|
|
61
|
+
# @param validate [Boolean] Whether to validate metadata schema (default: true)
|
|
62
|
+
# @return [String] Path to saved file
|
|
63
|
+
def save_metadata(metadata, session_dir, validate: true)
|
|
64
|
+
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
65
|
+
raise ArgumentError, "session_dir cannot be nil or empty" if session_dir.nil? || session_dir.strip.empty?
|
|
66
|
+
|
|
67
|
+
validate_metadata(metadata) if validate
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
metadata_path = File.join(session_dir, "metadata.yml")
|
|
71
|
+
File.write(metadata_path, YAML.dump(metadata))
|
|
72
|
+
metadata_path
|
|
73
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
74
|
+
raise PromptCacheError, "Failed to save metadata to #{metadata_path}: #{e.message}"
|
|
75
|
+
rescue => e
|
|
76
|
+
raise PromptCacheError, "Unexpected error saving metadata: #{e.message}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Validate metadata schema
|
|
81
|
+
# @param metadata [Hash] Metadata hash to validate
|
|
82
|
+
# @raise [ArgumentError] If required fields are missing
|
|
83
|
+
def validate_metadata(metadata)
|
|
84
|
+
required_fields = %w[timestamp gem operation]
|
|
85
|
+
missing = required_fields - metadata.keys
|
|
86
|
+
unless missing.empty?
|
|
87
|
+
raise ArgumentError, "Missing required metadata fields: #{missing.join(", ")}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Validate field types
|
|
91
|
+
unless metadata["timestamp"].is_a?(String) || metadata["timestamp"].is_a?(Time)
|
|
92
|
+
raise ArgumentError, "Metadata 'timestamp' must be a String or Time"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unless metadata["gem"].is_a?(String) && !metadata["gem"].strip.empty?
|
|
96
|
+
raise ArgumentError, "Metadata 'gem' must be a non-empty String"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
unless metadata["operation"].is_a?(String) && !metadata["operation"].strip.empty?
|
|
100
|
+
raise ArgumentError, "Metadata 'operation' must be a non-empty String"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Get base cache path for a gem
|
|
107
|
+
# @param project_root [String] Project root path
|
|
108
|
+
# @param gem_name [String] Name of the gem
|
|
109
|
+
# @return [String] Base cache path (.ace-local/{short-gem}/sessions/)
|
|
110
|
+
def base_cache_path(project_root, gem_name)
|
|
111
|
+
short_name = gem_name.to_s.sub(/\Aace-/, "")
|
|
112
|
+
cache_path = File.join(project_root, ".ace-local", short_name, "sessions")
|
|
113
|
+
FileUtils.mkdir_p(cache_path) unless Dir.exist?(cache_path)
|
|
114
|
+
cache_path
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Save a prompt file
|
|
118
|
+
# @param content [String] Prompt content
|
|
119
|
+
# @param session_dir [String] Session directory path
|
|
120
|
+
# @param filename [String] Filename for the prompt
|
|
121
|
+
# @return [String] Path to saved file
|
|
122
|
+
def save_prompt(content, session_dir, filename)
|
|
123
|
+
raise ArgumentError, "content cannot be nil" if content.nil?
|
|
124
|
+
raise ArgumentError, "session_dir cannot be nil or empty" if session_dir.nil? || session_dir.strip.empty?
|
|
125
|
+
raise ArgumentError, "filename cannot be nil or empty" if filename.nil? || filename.strip.empty?
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
prompt_path = File.join(session_dir, filename)
|
|
129
|
+
File.write(prompt_path, content)
|
|
130
|
+
prompt_path
|
|
131
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
132
|
+
raise PromptCacheError, "Failed to save prompt to #{prompt_path}: #{e.message}"
|
|
133
|
+
rescue => e
|
|
134
|
+
raise PromptCacheError, "Unexpected error saving prompt: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "../models/config_templates"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Core
|
|
9
|
+
class ConfigDiff
|
|
10
|
+
def initialize(global: false, file: nil, one_line: false)
|
|
11
|
+
@global = global
|
|
12
|
+
@file = file
|
|
13
|
+
@one_line = one_line
|
|
14
|
+
@diffs = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
if @file
|
|
19
|
+
diff_file(@file)
|
|
20
|
+
else
|
|
21
|
+
diff_all_configs
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
print_results
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def diff_gem(gem_name)
|
|
28
|
+
# Normalize gem name (support both "ace-bundle" and "bundle")
|
|
29
|
+
gem_name = gem_name.start_with?("ace-") ? gem_name : "ace-#{gem_name}"
|
|
30
|
+
|
|
31
|
+
unless ConfigTemplates.gem_exists?(gem_name)
|
|
32
|
+
puts "Error: Gem '#{gem_name}' not found or has no example configurations"
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
diff_gem_configs(gem_name)
|
|
37
|
+
print_results
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def config_directory
|
|
43
|
+
@global ? File.expand_path("~/.ace") : ".ace"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def diff_all_configs
|
|
47
|
+
ConfigTemplates.all_gems.each do |gem_name|
|
|
48
|
+
diff_gem_configs(gem_name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def diff_gem_configs(gem_name)
|
|
53
|
+
source_dir = ConfigTemplates.example_dir_for(gem_name)
|
|
54
|
+
|
|
55
|
+
return unless source_dir && File.exist?(source_dir)
|
|
56
|
+
|
|
57
|
+
Dir.glob("#{source_dir}/**/*").each do |source_file|
|
|
58
|
+
next if File.directory?(source_file)
|
|
59
|
+
|
|
60
|
+
relative_path = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir))
|
|
61
|
+
target_file = File.join(config_directory, relative_path.to_s)
|
|
62
|
+
|
|
63
|
+
compare_files(source_file, target_file, gem_name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def diff_file(file_path)
|
|
68
|
+
# Determine which gem this file belongs to
|
|
69
|
+
if file_path.start_with?(config_directory)
|
|
70
|
+
relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(config_directory))
|
|
71
|
+
parts = relative_path.to_s.split(File::SEPARATOR)
|
|
72
|
+
|
|
73
|
+
if parts.any?
|
|
74
|
+
config_subdir = parts.first
|
|
75
|
+
gem_name = "ace-#{config_subdir}"
|
|
76
|
+
|
|
77
|
+
if ConfigTemplates.gem_exists?(gem_name)
|
|
78
|
+
source_dir = ConfigTemplates.example_dir_for(gem_name)
|
|
79
|
+
relative_file = parts[1..-1].join(File::SEPARATOR)
|
|
80
|
+
source_file = File.join(source_dir, relative_file)
|
|
81
|
+
|
|
82
|
+
if File.exist?(source_file)
|
|
83
|
+
compare_files(source_file, file_path, gem_name)
|
|
84
|
+
else
|
|
85
|
+
puts "No example file found for #{file_path}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
puts "File #{file_path} is not in a configuration directory"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def compare_files(source_file, target_file, gem_name)
|
|
95
|
+
@diffs << if !File.exist?(target_file)
|
|
96
|
+
{
|
|
97
|
+
gem: gem_name,
|
|
98
|
+
file: target_file,
|
|
99
|
+
status: :missing,
|
|
100
|
+
source: source_file
|
|
101
|
+
}
|
|
102
|
+
elsif files_differ?(source_file, target_file)
|
|
103
|
+
{
|
|
104
|
+
gem: gem_name,
|
|
105
|
+
file: target_file,
|
|
106
|
+
status: :different,
|
|
107
|
+
source: source_file,
|
|
108
|
+
diff_output: get_diff_output(source_file, target_file)
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
{
|
|
112
|
+
gem: gem_name,
|
|
113
|
+
file: target_file,
|
|
114
|
+
status: :same,
|
|
115
|
+
source: source_file
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def files_differ?(file1, file2)
|
|
121
|
+
File.read(file1) != File.read(file2)
|
|
122
|
+
rescue
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def get_diff_output(source_file, target_file)
|
|
127
|
+
# Use system diff command
|
|
128
|
+
output, _status = Open3.capture2("diff", "-u", target_file, source_file)
|
|
129
|
+
output
|
|
130
|
+
rescue
|
|
131
|
+
"Unable to generate diff"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def print_results
|
|
135
|
+
if @one_line
|
|
136
|
+
print_one_line_summary
|
|
137
|
+
else
|
|
138
|
+
print_detailed_diffs
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def print_one_line_summary
|
|
143
|
+
@diffs.each do |diff|
|
|
144
|
+
case diff[:status]
|
|
145
|
+
when :missing
|
|
146
|
+
puts "MISSING: #{diff[:file]}"
|
|
147
|
+
when :different
|
|
148
|
+
puts "CHANGED: #{diff[:file]}"
|
|
149
|
+
when :same
|
|
150
|
+
puts "SAME: #{diff[:file]}" if @verbose
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
puts "\nSummary:"
|
|
155
|
+
puts " Missing: #{@diffs.count { |d| d[:status] == :missing }}"
|
|
156
|
+
puts " Changed: #{@diffs.count { |d| d[:status] == :different }}"
|
|
157
|
+
puts " Same: #{@diffs.count { |d| d[:status] == :same }}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def print_detailed_diffs
|
|
161
|
+
missing = @diffs.select { |d| d[:status] == :missing }
|
|
162
|
+
changed = @diffs.select { |d| d[:status] == :different }
|
|
163
|
+
|
|
164
|
+
if missing.any?
|
|
165
|
+
puts "Missing configuration files:"
|
|
166
|
+
missing.each do |diff|
|
|
167
|
+
puts " #{diff[:file]}"
|
|
168
|
+
puts " -> Example: #{diff[:source]}"
|
|
169
|
+
end
|
|
170
|
+
puts
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if changed.any?
|
|
174
|
+
puts "Changed configuration files:"
|
|
175
|
+
changed.each do |diff|
|
|
176
|
+
puts "\n#{diff[:file]}:"
|
|
177
|
+
puts diff[:diff_output]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if missing.empty? && changed.empty?
|
|
182
|
+
puts "All configuration files match the examples."
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "../models/config_templates"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Core
|
|
9
|
+
class ConfigInitializer
|
|
10
|
+
def initialize(force: false, dry_run: false, global: false, verbose: false)
|
|
11
|
+
@force = force
|
|
12
|
+
@dry_run = dry_run
|
|
13
|
+
@global = global
|
|
14
|
+
@verbose = verbose
|
|
15
|
+
@copied_files = []
|
|
16
|
+
@skipped_files = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def init_all
|
|
20
|
+
puts "Initializing all ace-* gem configurations..." if @verbose
|
|
21
|
+
|
|
22
|
+
ConfigTemplates.all_gems.each do |gem_name|
|
|
23
|
+
init_gem(gem_name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
print_summary
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def init_gem(gem_name)
|
|
30
|
+
gem_name = normalize_gem_name(gem_name)
|
|
31
|
+
|
|
32
|
+
unless ConfigTemplates.gem_exists?(gem_name)
|
|
33
|
+
puts "Warning: No configuration found for #{gem_name}"
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
puts "\nInitializing #{gem_name}..." if @verbose
|
|
38
|
+
|
|
39
|
+
source_dir = ConfigTemplates.example_dir_for(gem_name)
|
|
40
|
+
target_dir = target_directory
|
|
41
|
+
|
|
42
|
+
unless File.exist?(source_dir)
|
|
43
|
+
puts "Warning: No .ace-defaults directory found for #{gem_name}"
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Show config docs on first run if config is missing
|
|
48
|
+
show_config_docs_if_needed(gem_name, target_dir)
|
|
49
|
+
|
|
50
|
+
# Copy files from .ace-defaults to .ace
|
|
51
|
+
copy_config_files(source_dir, target_dir, gem_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalize_gem_name(name)
|
|
57
|
+
# Handle both "ace-core" and "core" formats
|
|
58
|
+
name.start_with?("ace-") ? name : "ace-#{name}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def target_directory
|
|
62
|
+
@global ? File.expand_path("~/.ace") : ".ace"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def show_config_docs_if_needed(gem_name, target_dir)
|
|
66
|
+
# Check if any config files exist for this gem
|
|
67
|
+
config_subdir = gem_name.sub("ace-", "")
|
|
68
|
+
|
|
69
|
+
# Look for any existing config files in the expected location
|
|
70
|
+
existing_configs = Dir.glob("#{target_dir}/#{config_subdir}/**/*").reject { |f| File.directory?(f) }
|
|
71
|
+
|
|
72
|
+
# Only show docs if this is the first time (no existing config)
|
|
73
|
+
if existing_configs.empty? && !@dry_run
|
|
74
|
+
docs_file = ConfigTemplates.docs_file_for(gem_name)
|
|
75
|
+
if File.exist?(docs_file)
|
|
76
|
+
puts "\n#{File.read(docs_file)}\n"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def copy_config_files(source_dir, target_dir, gem_name)
|
|
82
|
+
Dir.glob("#{source_dir}/**/*").each do |source_file|
|
|
83
|
+
next if File.directory?(source_file)
|
|
84
|
+
|
|
85
|
+
# Calculate relative path from source_dir
|
|
86
|
+
relative_path = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir))
|
|
87
|
+
|
|
88
|
+
# Build target path - files already include their subdirectory
|
|
89
|
+
target_file = File.join(target_dir, relative_path.to_s)
|
|
90
|
+
|
|
91
|
+
copy_file(source_file, target_file)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def copy_file(source, target)
|
|
96
|
+
if File.exist?(target) && !@force
|
|
97
|
+
@skipped_files << target
|
|
98
|
+
puts " Skipped: #{target} (already exists)" if @verbose
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if @dry_run
|
|
103
|
+
puts " Would copy: #{source} -> #{target}"
|
|
104
|
+
else
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
106
|
+
FileUtils.cp(source, target)
|
|
107
|
+
@copied_files << target
|
|
108
|
+
puts " Copied: #{target}" if @verbose
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def print_summary
|
|
113
|
+
return if @dry_run
|
|
114
|
+
|
|
115
|
+
puts "\nConfiguration initialization complete:"
|
|
116
|
+
puts " Files copied: #{@copied_files.size}"
|
|
117
|
+
puts " Files skipped: #{@skipped_files.size}"
|
|
118
|
+
|
|
119
|
+
if @skipped_files.any? && !@force
|
|
120
|
+
puts "\nUse --force to overwrite existing files"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../molecules/env_loader"
|
|
4
|
+
require "ace/support/config"
|
|
5
|
+
require "ace/support/fs"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Core
|
|
9
|
+
module Organisms
|
|
10
|
+
# Complete environment variable management
|
|
11
|
+
class EnvironmentManager
|
|
12
|
+
attr_reader :root_path, :config
|
|
13
|
+
|
|
14
|
+
# Initialize environment manager
|
|
15
|
+
# @param root_path [String] Project root path
|
|
16
|
+
# @param config [Models::Config, nil] Configuration to use
|
|
17
|
+
def initialize(root_path: Dir.pwd, config: nil)
|
|
18
|
+
@root_path = Ace::Support::Fs::Atoms::PathExpander.expand(root_path)
|
|
19
|
+
@config = config || load_config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Load environment variables based on configuration
|
|
23
|
+
# @param overwrite [Boolean] Whether to overwrite existing vars
|
|
24
|
+
# @return [Hash] Variables that were loaded
|
|
25
|
+
def load
|
|
26
|
+
return {} unless should_load_dotenv?
|
|
27
|
+
|
|
28
|
+
dotenv_files = get_dotenv_files
|
|
29
|
+
loaded_vars = {}
|
|
30
|
+
|
|
31
|
+
dotenv_files.each do |file|
|
|
32
|
+
filepath = resolve_dotenv_path(file)
|
|
33
|
+
next unless filepath
|
|
34
|
+
|
|
35
|
+
vars = Molecules::EnvLoader.load_file(filepath)
|
|
36
|
+
loaded_vars.merge!(vars) if vars && !vars.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set all loaded variables
|
|
40
|
+
Molecules::EnvLoader.set_environment(loaded_vars, overwrite: false)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Save current environment to .env file
|
|
44
|
+
# @param filepath [String] Path to save to
|
|
45
|
+
# @param keys [Array<String>] Specific keys to save (nil = all)
|
|
46
|
+
# @return [Hash] Variables that were saved
|
|
47
|
+
def save(filepath = ".env", keys: nil)
|
|
48
|
+
filepath = File.join(root_path, filepath) unless Ace::Support::Fs::Atoms::PathExpander.absolute?(filepath)
|
|
49
|
+
|
|
50
|
+
vars_to_save = if keys
|
|
51
|
+
ENV.to_h.select { |k, _| keys.include?(k) }
|
|
52
|
+
else
|
|
53
|
+
ENV.to_h
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Molecules::EnvLoader.save_file(vars_to_save, filepath)
|
|
57
|
+
vars_to_save
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get specific environment variable with fallback
|
|
61
|
+
# @param key [String] Variable name
|
|
62
|
+
# @param default [Object] Default value if not found
|
|
63
|
+
# @return [String, Object] Variable value or default
|
|
64
|
+
def get(key, default = nil)
|
|
65
|
+
ENV.fetch(key, default)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Set environment variable
|
|
69
|
+
# @param key [String] Variable name
|
|
70
|
+
# @param value [String] Variable value
|
|
71
|
+
# @return [String] The value that was set
|
|
72
|
+
def set(key, value)
|
|
73
|
+
ENV[key] = value.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check if variable exists
|
|
77
|
+
# @param key [String] Variable name
|
|
78
|
+
# @return [Boolean] true if variable exists
|
|
79
|
+
def key?(key)
|
|
80
|
+
ENV.key?(key)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# List all .env files that would be loaded
|
|
84
|
+
# @return [Array<String>] Paths to .env files
|
|
85
|
+
def list_dotenv_files
|
|
86
|
+
return [] unless should_load_dotenv?
|
|
87
|
+
|
|
88
|
+
dotenv_files = get_dotenv_files
|
|
89
|
+
dotenv_files.map { |file| resolve_dotenv_path(file) }.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Load configuration
|
|
95
|
+
# @return [Models::Config] Configuration
|
|
96
|
+
def load_config
|
|
97
|
+
resolver = ::Ace::Support::Config.create(
|
|
98
|
+
config_dir: ".ace",
|
|
99
|
+
defaults_dir: ".ace-defaults"
|
|
100
|
+
)
|
|
101
|
+
resolver.resolve
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if dotenv should be loaded
|
|
105
|
+
# @return [Boolean] true if should load
|
|
106
|
+
def should_load_dotenv?
|
|
107
|
+
config.get("ace", "environment", "load_dotenv") != false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get list of dotenv files from config
|
|
111
|
+
# @return [Array<String>] Dotenv file names
|
|
112
|
+
def get_dotenv_files
|
|
113
|
+
files = config.get("ace", "environment", "dotenv_files")
|
|
114
|
+
return [".env.local", ".env"] if files.nil?
|
|
115
|
+
|
|
116
|
+
files.is_a?(Array) ? files : [files]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resolve dotenv file path
|
|
120
|
+
# @param filename [String] Dotenv filename
|
|
121
|
+
# @return [String, nil] Full path if exists
|
|
122
|
+
def resolve_dotenv_path(filename)
|
|
123
|
+
# Try as absolute path first
|
|
124
|
+
if Ace::Support::Fs::Atoms::PathExpander.absolute?(filename)
|
|
125
|
+
expanded = Ace::Support::Fs::Atoms::PathExpander.expand(filename)
|
|
126
|
+
return expanded if File.exist?(expanded)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Try relative to root
|
|
130
|
+
filepath = File.join(root_path, filename)
|
|
131
|
+
return filepath if File.exist?(filepath)
|
|
132
|
+
|
|
133
|
+
# Try in .ace directory
|
|
134
|
+
filepath = File.join(root_path, ".ace", filename)
|
|
135
|
+
return filepath if File.exist?(filepath)
|
|
136
|
+
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|