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,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Core
|
|
8
|
+
module Atoms
|
|
9
|
+
# Pure command execution functions with safety features
|
|
10
|
+
# Configuration is loaded from .ace-defaults/core/settings.yml
|
|
11
|
+
# and can be overridden at .ace/core/settings.yml (ADR-022)
|
|
12
|
+
module CommandExecutor
|
|
13
|
+
# Fallback timeout value if config is not available
|
|
14
|
+
DEFAULT_TIMEOUT = 30
|
|
15
|
+
|
|
16
|
+
# Maximum output size (1MB)
|
|
17
|
+
MAX_OUTPUT_SIZE = 1_048_576
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Get configured timeout from config cascade
|
|
22
|
+
# Falls back to DEFAULT_TIMEOUT if config is unavailable
|
|
23
|
+
# @return [Integer] Configured timeout in seconds
|
|
24
|
+
def configured_timeout
|
|
25
|
+
Ace::Core.get("core", "command_executor", "timeout") || DEFAULT_TIMEOUT
|
|
26
|
+
rescue
|
|
27
|
+
DEFAULT_TIMEOUT
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Execute a command with timeout and output capture
|
|
31
|
+
# @param command [String] Command to execute
|
|
32
|
+
# @param timeout [Integer] Timeout in seconds (defaults to config value)
|
|
33
|
+
# @param max_output [Integer] Maximum output size in bytes
|
|
34
|
+
# @param cwd [String] Working directory for command
|
|
35
|
+
# @return [Hash] {success: Boolean, stdout: String, stderr: String, exit_code: Integer, error: String}
|
|
36
|
+
def execute(command, timeout: nil, max_output: MAX_OUTPUT_SIZE, cwd: nil)
|
|
37
|
+
timeout ||= configured_timeout
|
|
38
|
+
return {success: false, error: "Command cannot be nil"} if command.nil?
|
|
39
|
+
return {success: false, error: "Command cannot be empty"} if command.strip.empty?
|
|
40
|
+
|
|
41
|
+
stdout_data = String.new
|
|
42
|
+
stderr_data = String.new
|
|
43
|
+
exit_status = nil
|
|
44
|
+
truncated = false
|
|
45
|
+
|
|
46
|
+
options = {}
|
|
47
|
+
options[:chdir] = cwd if cwd && Dir.exist?(cwd)
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
Timeout.timeout(timeout) do
|
|
51
|
+
Open3.popen3(command, options) do |stdin, stdout, stderr, wait_thr|
|
|
52
|
+
stdin.close
|
|
53
|
+
|
|
54
|
+
# Read output with size limits
|
|
55
|
+
stdout_reader = Thread.new do
|
|
56
|
+
Thread.current.report_on_exception = false
|
|
57
|
+
begin
|
|
58
|
+
stdout.each_char do |char|
|
|
59
|
+
if stdout_data.bytesize < max_output
|
|
60
|
+
stdout_data << char
|
|
61
|
+
else
|
|
62
|
+
truncated = true
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
rescue IOError
|
|
67
|
+
# Stream was closed, this is expected on timeout
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
stderr_reader = Thread.new do
|
|
72
|
+
Thread.current.report_on_exception = false
|
|
73
|
+
begin
|
|
74
|
+
stderr.each_char do |char|
|
|
75
|
+
if stderr_data.bytesize < max_output
|
|
76
|
+
stderr_data << char
|
|
77
|
+
else
|
|
78
|
+
truncated = true
|
|
79
|
+
break
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
rescue IOError
|
|
83
|
+
# Stream was closed, this is expected on timeout
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
stdout_reader.join
|
|
88
|
+
stderr_reader.join
|
|
89
|
+
|
|
90
|
+
exit_status = wait_thr.value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
result = {
|
|
95
|
+
success: exit_status.success?,
|
|
96
|
+
stdout: stdout_data,
|
|
97
|
+
stderr: stderr_data,
|
|
98
|
+
exit_code: exit_status.exitstatus
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
result[:warning] = "Output truncated (exceeded #{max_output} bytes)" if truncated
|
|
102
|
+
|
|
103
|
+
result
|
|
104
|
+
rescue Timeout::Error
|
|
105
|
+
{
|
|
106
|
+
success: false,
|
|
107
|
+
stdout: stdout_data,
|
|
108
|
+
stderr: stderr_data,
|
|
109
|
+
error: "Command timed out after #{timeout} seconds"
|
|
110
|
+
}
|
|
111
|
+
rescue Errno::ENOENT
|
|
112
|
+
{
|
|
113
|
+
success: false,
|
|
114
|
+
error: "Command not found: #{command.split.first}"
|
|
115
|
+
}
|
|
116
|
+
rescue => e
|
|
117
|
+
{
|
|
118
|
+
success: false,
|
|
119
|
+
stdout: stdout_data,
|
|
120
|
+
stderr: stderr_data,
|
|
121
|
+
error: "Command execution failed: #{e.message}"
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Execute command and return only stdout if successful
|
|
127
|
+
# @param command [String] Command to execute
|
|
128
|
+
# @param options [Hash] Execution options
|
|
129
|
+
# @return [String, nil] Command output or nil if failed
|
|
130
|
+
def capture(command, **options)
|
|
131
|
+
result = execute(command, **options)
|
|
132
|
+
result[:success] ? result[:stdout] : nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if a command is available in PATH
|
|
136
|
+
# @param command [String] Command name to check
|
|
137
|
+
# @return [Boolean] true if command is available
|
|
138
|
+
def available?(command)
|
|
139
|
+
return false if command.nil? || command.empty?
|
|
140
|
+
|
|
141
|
+
# Extract just the command name (first word)
|
|
142
|
+
cmd = command.split.first
|
|
143
|
+
|
|
144
|
+
# Check if command exists in PATH
|
|
145
|
+
ENV["PATH"].split(File::PATH_SEPARATOR).any? do |path|
|
|
146
|
+
executable = File.join(path, cmd)
|
|
147
|
+
File.executable?(executable) && !File.directory?(executable)
|
|
148
|
+
end
|
|
149
|
+
rescue
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Execute command with real-time output streaming
|
|
154
|
+
# @param command [String] Command to execute
|
|
155
|
+
# @param output_callback [Proc] Callback for output lines
|
|
156
|
+
# @param timeout [Integer] Timeout in seconds (defaults to config value)
|
|
157
|
+
# @param cwd [String] Working directory
|
|
158
|
+
# @return [Hash] {success: Boolean, exit_code: Integer, error: String}
|
|
159
|
+
def stream(command, output_callback: nil, timeout: nil, cwd: nil)
|
|
160
|
+
timeout ||= configured_timeout
|
|
161
|
+
return {success: false, error: "Command cannot be nil"} if command.nil?
|
|
162
|
+
|
|
163
|
+
options = {}
|
|
164
|
+
options[:chdir] = cwd if cwd && Dir.exist?(cwd)
|
|
165
|
+
|
|
166
|
+
exit_status = nil
|
|
167
|
+
|
|
168
|
+
begin
|
|
169
|
+
Timeout.timeout(timeout) do
|
|
170
|
+
Open3.popen2e(command, options) do |stdin, stdout_err, wait_thr|
|
|
171
|
+
stdin.close
|
|
172
|
+
|
|
173
|
+
stdout_err.each_line do |line|
|
|
174
|
+
output_callback&.call(line.chomp)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
exit_status = wait_thr.value
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
success: exit_status.success?,
|
|
183
|
+
exit_code: exit_status.exitstatus
|
|
184
|
+
}
|
|
185
|
+
rescue Timeout::Error
|
|
186
|
+
{
|
|
187
|
+
success: false,
|
|
188
|
+
error: "Command timed out after #{timeout} seconds"
|
|
189
|
+
}
|
|
190
|
+
rescue Errno::ENOENT
|
|
191
|
+
{
|
|
192
|
+
success: false,
|
|
193
|
+
error: "Command not found: #{command.split.first}"
|
|
194
|
+
}
|
|
195
|
+
rescue => e
|
|
196
|
+
{
|
|
197
|
+
success: false,
|
|
198
|
+
error: "Command execution failed: #{e.message}"
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Execute multiple commands in sequence
|
|
204
|
+
# @param commands [Array<String>] Commands to execute
|
|
205
|
+
# @param options [Hash] Execution options
|
|
206
|
+
# @return [Array<Hash>] Results for each command
|
|
207
|
+
def execute_batch(commands, **options)
|
|
208
|
+
return [] if commands.nil? || commands.empty?
|
|
209
|
+
|
|
210
|
+
commands.map do |command|
|
|
211
|
+
result = execute(command, **options)
|
|
212
|
+
result[:command] = command
|
|
213
|
+
result
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Build a safe command string with proper escaping
|
|
218
|
+
# @param command [String] Base command
|
|
219
|
+
# @param args [Array<String>] Arguments to add
|
|
220
|
+
# @return [String] Safe command string
|
|
221
|
+
def build_command(command, *args)
|
|
222
|
+
return nil if command.nil?
|
|
223
|
+
|
|
224
|
+
escaped_args = args.flatten.compact.map do |arg|
|
|
225
|
+
# Shell escape the argument
|
|
226
|
+
if arg.match?(/[^A-Za-z0-9_\-.,:\/@]/)
|
|
227
|
+
# Properly escape single quotes in shell arguments
|
|
228
|
+
"'#{arg.gsub("'", "'\\''")}'"
|
|
229
|
+
else
|
|
230
|
+
arg
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
[command, *escaped_args].join(" ")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
# Core functionality for ACE gems
|
|
5
|
+
module Core
|
|
6
|
+
# Atomic operations - pure functions with no side effects
|
|
7
|
+
# Atoms are the building blocks for more complex operations.
|
|
8
|
+
# See ADR-011 for ATOM architecture details.
|
|
9
|
+
module Atoms
|
|
10
|
+
# ConfigSummary displays effective configuration state to stderr.
|
|
11
|
+
#
|
|
12
|
+
# Only shows values that differ from defaults, filtering sensitive keys.
|
|
13
|
+
# Output format: "Config: key=value key2=value2" (space-separated)
|
|
14
|
+
#
|
|
15
|
+
# ## Sensitive Key Filtering
|
|
16
|
+
#
|
|
17
|
+
# Keys ending with sensitive patterns are filtered out:
|
|
18
|
+
# - token, password, secret, credential, key, api_key
|
|
19
|
+
#
|
|
20
|
+
# Keys containing these patterns but NOT ending with them are shown:
|
|
21
|
+
# - max_tokens (contains "tokens" but doesn't end with it) ✓ shown
|
|
22
|
+
# - keyboard_layout (contains "key" but doesn't end with it) ✓ shown
|
|
23
|
+
# - auth_token (ends with "token") ✗ filtered
|
|
24
|
+
# - api_key (ends with "key") ✗ filtered
|
|
25
|
+
#
|
|
26
|
+
# ## Usage
|
|
27
|
+
#
|
|
28
|
+
# ConfigSummary.display(
|
|
29
|
+
# command: "review",
|
|
30
|
+
# config: Gem.config, # Effective config
|
|
31
|
+
# defaults: Gem.default_config, # Defaults for diffing
|
|
32
|
+
# options: { verbose: true }, # Thor options hash
|
|
33
|
+
# quiet: false,
|
|
34
|
+
# summary_keys: %w[model preset] # Optional allowlist
|
|
35
|
+
# )
|
|
36
|
+
# # Output to stderr: "Config: model=gflash preset=pr"
|
|
37
|
+
#
|
|
38
|
+
class ConfigSummary
|
|
39
|
+
# Match keys ENDING with sensitive words (not containing them)
|
|
40
|
+
SENSITIVE_REGEX = /(_|^)(token|password|secret|credential|key|api_key)$/i
|
|
41
|
+
|
|
42
|
+
# Display configuration summary to stderr
|
|
43
|
+
#
|
|
44
|
+
# @param command [String] Command name for context
|
|
45
|
+
# @param config [Hash] Effective configuration (merged result)
|
|
46
|
+
# @param defaults [Hash] Default configuration to diff against
|
|
47
|
+
# @param options [Hash] CLI options (Thor options hash)
|
|
48
|
+
# @param quiet [Boolean] Suppress output if true
|
|
49
|
+
# @param summary_keys [Array<String>, nil] Allowlist of keys to include (nil = all non-sensitive)
|
|
50
|
+
# @return [nil]
|
|
51
|
+
#
|
|
52
|
+
# @example Basic usage
|
|
53
|
+
# ConfigSummary.display(
|
|
54
|
+
# command: "review",
|
|
55
|
+
# config: { model: "gflash", format: "markdown" },
|
|
56
|
+
# defaults: { "model" => "glite", "format" => "markdown" },
|
|
57
|
+
# options: { verbose: true }
|
|
58
|
+
# )
|
|
59
|
+
# # Outputs: "Config: model=gflash verbose=true"
|
|
60
|
+
#
|
|
61
|
+
# @example With quiet mode
|
|
62
|
+
# ConfigSummary.display(
|
|
63
|
+
# command: "review",
|
|
64
|
+
# config: { model: "gflash" },
|
|
65
|
+
# defaults: {},
|
|
66
|
+
# options: {},
|
|
67
|
+
# quiet: true
|
|
68
|
+
# )
|
|
69
|
+
# # No output
|
|
70
|
+
#
|
|
71
|
+
# @example With allowlist
|
|
72
|
+
# ConfigSummary.display(
|
|
73
|
+
# command: "review",
|
|
74
|
+
# config: { model: "gflash", format: "markdown" },
|
|
75
|
+
# defaults: {},
|
|
76
|
+
# options: { verbose: true },
|
|
77
|
+
# summary_keys: %w[model]
|
|
78
|
+
# )
|
|
79
|
+
# # Outputs: "Config: model=gflash"
|
|
80
|
+
# # (format and verbose not in allowlist)
|
|
81
|
+
#
|
|
82
|
+
def self.display(command:, config: {}, defaults: {}, options: {}, quiet: false, summary_keys: nil)
|
|
83
|
+
return if quiet
|
|
84
|
+
|
|
85
|
+
# Only display config when verbose mode is explicitly enabled
|
|
86
|
+
return unless options[:verbose]
|
|
87
|
+
|
|
88
|
+
summary = new(command, config, defaults, options, summary_keys).build
|
|
89
|
+
warn "Config: #{summary}" unless summary.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Display configuration summary only if help was NOT requested.
|
|
93
|
+
#
|
|
94
|
+
# This is the recommended method for commands that need to show config
|
|
95
|
+
# but want to avoid polluting --help output with configuration details.
|
|
96
|
+
#
|
|
97
|
+
# @param command [String] Command name for context
|
|
98
|
+
# @param config [Hash] Effective configuration (merged result)
|
|
99
|
+
# @param defaults [Hash] Default configuration to diff against
|
|
100
|
+
# @param options [Hash] CLI options (Thor options hash)
|
|
101
|
+
# @param quiet [Boolean] Suppress output if true
|
|
102
|
+
# @param summary_keys [Array<String>, nil] Allowlist of keys to include (nil = all non-sensitive)
|
|
103
|
+
# @return [nil]
|
|
104
|
+
#
|
|
105
|
+
# @example Usage in command
|
|
106
|
+
# def execute
|
|
107
|
+
# # Check for help BEFORE displaying config
|
|
108
|
+
# return show_help if help_requested?(options, args)
|
|
109
|
+
#
|
|
110
|
+
# # Now safe to display config
|
|
111
|
+
# ConfigSummary.display_if_needed(
|
|
112
|
+
# command: "review",
|
|
113
|
+
# config: Gem.config,
|
|
114
|
+
# defaults: Gem.default_config,
|
|
115
|
+
# options: options
|
|
116
|
+
# )
|
|
117
|
+
# end
|
|
118
|
+
#
|
|
119
|
+
def self.display_if_needed(command:, config: {}, defaults: {}, options: {}, quiet: false, summary_keys: nil, args: ARGV)
|
|
120
|
+
return if quiet
|
|
121
|
+
return if help_requested?(options, args)
|
|
122
|
+
|
|
123
|
+
display(command: command, config: config, defaults: defaults, options: options, summary_keys: summary_keys)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Check if help was requested via options or arguments.
|
|
127
|
+
#
|
|
128
|
+
# @param options [Hash] CLI options hash (from Thor)
|
|
129
|
+
# @param args [Array<String>] Command arguments (defaults to ARGV for CLI context)
|
|
130
|
+
# @return [Boolean] true if help was requested
|
|
131
|
+
#
|
|
132
|
+
# @note The `args` parameter defaults to ARGV for CLI usage convenience.
|
|
133
|
+
# In test or non-CLI contexts, pass an explicit array to avoid global state.
|
|
134
|
+
#
|
|
135
|
+
def self.help_requested?(options = {}, args = ARGV)
|
|
136
|
+
options[:help] ||
|
|
137
|
+
options[:h] ||
|
|
138
|
+
args.include?("--help") ||
|
|
139
|
+
args.include?("-h")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def initialize(command, config, defaults, options, summary_keys)
|
|
143
|
+
@command = command
|
|
144
|
+
@config = flatten_hash(config || {})
|
|
145
|
+
@defaults = flatten_hash(defaults || {})
|
|
146
|
+
@options = options || {}
|
|
147
|
+
@summary_keys = summary_keys&.map(&:to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Build the configuration summary string
|
|
151
|
+
#
|
|
152
|
+
# @return [String] Space-separated key=value pairs
|
|
153
|
+
def build
|
|
154
|
+
pairs = []
|
|
155
|
+
|
|
156
|
+
# 1. Add CLI options that were explicitly set (non-nil, non-false)
|
|
157
|
+
@options.each do |key, value|
|
|
158
|
+
next if value.nil? || value == false
|
|
159
|
+
next if sensitive_key?(key.to_s)
|
|
160
|
+
next if @summary_keys && !@summary_keys.include?(key.to_s)
|
|
161
|
+
pairs << format_pair(key, value)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# 2. Add config values that differ from defaults (if not already in options)
|
|
165
|
+
@config.each do |key, value|
|
|
166
|
+
# Skip if already shown from options (check both symbol and string)
|
|
167
|
+
next if @options.key?(key.to_sym) || @options.key?(key.to_s)
|
|
168
|
+
next if @defaults[key] == value
|
|
169
|
+
next if sensitive_key?(key)
|
|
170
|
+
next if @summary_keys && !@summary_keys.include?(key)
|
|
171
|
+
pairs << format_pair(key, value)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
pairs.sort.join(" ")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Flatten nested hash to dot notation
|
|
180
|
+
# { llm: { provider: "google" } } → { "llm.provider" => "google" }
|
|
181
|
+
#
|
|
182
|
+
# @param hash [Hash] Hash to flatten
|
|
183
|
+
# @param prefix [String, nil] Current key prefix
|
|
184
|
+
# @return [Hash] Flattened hash with dot-notation keys
|
|
185
|
+
def flatten_hash(hash, prefix = nil)
|
|
186
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
187
|
+
full_key = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
188
|
+
if value.is_a?(Hash)
|
|
189
|
+
result.merge!(flatten_hash(value, full_key))
|
|
190
|
+
else
|
|
191
|
+
result[full_key] = value
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Format a key-value pair for output
|
|
197
|
+
#
|
|
198
|
+
# @param key [String, Symbol] Key
|
|
199
|
+
# @param value [Object] Value
|
|
200
|
+
# @return [String] Formatted "key=value" string
|
|
201
|
+
def format_pair(key, value)
|
|
202
|
+
value_str = case value
|
|
203
|
+
when true then "true"
|
|
204
|
+
when Array then value.join(",")
|
|
205
|
+
else value.to_s
|
|
206
|
+
end
|
|
207
|
+
"#{key}=#{value_str}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Check if a key is sensitive (should be filtered)
|
|
211
|
+
#
|
|
212
|
+
# @param key [String, Symbol] Key to check
|
|
213
|
+
# @return [Boolean] true if key is sensitive
|
|
214
|
+
def sensitive_key?(key)
|
|
215
|
+
key.to_s.match?(SENSITIVE_REGEX)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Core
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure .env file parsing functions
|
|
7
|
+
module EnvParser
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Parse .env file content into hash
|
|
11
|
+
# @param content [String] .env file content
|
|
12
|
+
# @return [Hash] Parsed environment variables
|
|
13
|
+
def parse(content)
|
|
14
|
+
return {} if content.nil? || content.strip.empty?
|
|
15
|
+
|
|
16
|
+
result = {}
|
|
17
|
+
lines = content.lines.map(&:strip)
|
|
18
|
+
|
|
19
|
+
lines.each do |line|
|
|
20
|
+
# Skip empty lines and comments
|
|
21
|
+
next if line.empty? || line.start_with?("#")
|
|
22
|
+
|
|
23
|
+
# Parse KEY=VALUE format
|
|
24
|
+
if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\z/))
|
|
25
|
+
key = match[1]
|
|
26
|
+
value = match[2]
|
|
27
|
+
|
|
28
|
+
# Handle quoted values
|
|
29
|
+
value = unquote(value)
|
|
30
|
+
|
|
31
|
+
result[key] = value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Format hash as .env content
|
|
39
|
+
# @param env_hash [Hash] Environment variables
|
|
40
|
+
# @return [String] Formatted .env content
|
|
41
|
+
def format(env_hash)
|
|
42
|
+
return "" if env_hash.nil? || env_hash.empty?
|
|
43
|
+
|
|
44
|
+
env_hash.map do |key, value|
|
|
45
|
+
formatted_value = value.to_s.include?(" ") ? %("#{value}") : value.to_s
|
|
46
|
+
"#{key}=#{formatted_value}"
|
|
47
|
+
end.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validate environment variable name
|
|
51
|
+
# @param name [String] Variable name to validate
|
|
52
|
+
# @return [Boolean] true if valid
|
|
53
|
+
def valid_key?(name)
|
|
54
|
+
!name.nil? && name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Remove quotes from value if present
|
|
58
|
+
# @param value [String] Value that may be quoted
|
|
59
|
+
# @return [String] Unquoted value
|
|
60
|
+
def unquote(value)
|
|
61
|
+
return value unless value.is_a?(String)
|
|
62
|
+
|
|
63
|
+
# Handle double quotes
|
|
64
|
+
if value.start_with?('"') && value.end_with?('"')
|
|
65
|
+
value[1..-2].gsub('\\"', '"').gsub("\\n", "\n").gsub("\\\\", "\\")
|
|
66
|
+
# Handle single quotes
|
|
67
|
+
elsif value.start_with?("'") && value.end_with?("'")
|
|
68
|
+
value[1..-2]
|
|
69
|
+
else
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|