sxn 0.2.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/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
- data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
- data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
- data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
- data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
- data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
- data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
- data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
- data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
- data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
- data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
- data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
- data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +121 -0
- data/.simplecov +51 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +329 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +54 -0
- data/Steepfile +50 -0
- data/bin/sxn +6 -0
- data/lib/sxn/CLI.rb +275 -0
- data/lib/sxn/commands/init.rb +137 -0
- data/lib/sxn/commands/projects.rb +350 -0
- data/lib/sxn/commands/rules.rb +435 -0
- data/lib/sxn/commands/sessions.rb +300 -0
- data/lib/sxn/commands/worktrees.rb +416 -0
- data/lib/sxn/commands.rb +13 -0
- data/lib/sxn/config/config_cache.rb +295 -0
- data/lib/sxn/config/config_discovery.rb +242 -0
- data/lib/sxn/config/config_validator.rb +562 -0
- data/lib/sxn/config.rb +259 -0
- data/lib/sxn/core/config_manager.rb +290 -0
- data/lib/sxn/core/project_manager.rb +307 -0
- data/lib/sxn/core/rules_manager.rb +306 -0
- data/lib/sxn/core/session_manager.rb +336 -0
- data/lib/sxn/core/worktree_manager.rb +281 -0
- data/lib/sxn/core.rb +13 -0
- data/lib/sxn/database/errors.rb +29 -0
- data/lib/sxn/database/session_database.rb +691 -0
- data/lib/sxn/database.rb +24 -0
- data/lib/sxn/errors.rb +76 -0
- data/lib/sxn/rules/base_rule.rb +367 -0
- data/lib/sxn/rules/copy_files_rule.rb +346 -0
- data/lib/sxn/rules/errors.rb +28 -0
- data/lib/sxn/rules/project_detector.rb +871 -0
- data/lib/sxn/rules/rules_engine.rb +485 -0
- data/lib/sxn/rules/setup_commands_rule.rb +307 -0
- data/lib/sxn/rules/template_rule.rb +262 -0
- data/lib/sxn/rules.rb +148 -0
- data/lib/sxn/runtime_validations.rb +96 -0
- data/lib/sxn/security/secure_command_executor.rb +364 -0
- data/lib/sxn/security/secure_file_copier.rb +478 -0
- data/lib/sxn/security/secure_path_validator.rb +258 -0
- data/lib/sxn/security.rb +15 -0
- data/lib/sxn/templates/common/gitignore.liquid +99 -0
- data/lib/sxn/templates/common/session-info.md.liquid +58 -0
- data/lib/sxn/templates/errors.rb +36 -0
- data/lib/sxn/templates/javascript/README.md.liquid +59 -0
- data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
- data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
- data/lib/sxn/templates/rails/database.yml.liquid +31 -0
- data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
- data/lib/sxn/templates/template_engine.rb +346 -0
- data/lib/sxn/templates/template_processor.rb +279 -0
- data/lib/sxn/templates/template_security.rb +410 -0
- data/lib/sxn/templates/template_variables.rb +713 -0
- data/lib/sxn/templates.rb +28 -0
- data/lib/sxn/ui/output.rb +103 -0
- data/lib/sxn/ui/progress_bar.rb +91 -0
- data/lib/sxn/ui/prompt.rb +116 -0
- data/lib/sxn/ui/table.rb +183 -0
- data/lib/sxn/ui.rb +12 -0
- data/lib/sxn/version.rb +5 -0
- data/lib/sxn.rb +63 -0
- data/rbs_collection.lock.yaml +180 -0
- data/rbs_collection.yaml +39 -0
- data/scripts/test.sh +31 -0
- data/sig/external/liquid.rbs +116 -0
- data/sig/external/thor.rbs +99 -0
- data/sig/external/tty.rbs +71 -0
- data/sig/sxn/cli.rbs +46 -0
- data/sig/sxn/commands/init.rbs +38 -0
- data/sig/sxn/commands/projects.rbs +72 -0
- data/sig/sxn/commands/rules.rbs +95 -0
- data/sig/sxn/commands/sessions.rbs +62 -0
- data/sig/sxn/commands/worktrees.rbs +82 -0
- data/sig/sxn/commands.rbs +6 -0
- data/sig/sxn/config/config_cache.rbs +67 -0
- data/sig/sxn/config/config_discovery.rbs +64 -0
- data/sig/sxn/config/config_validator.rbs +64 -0
- data/sig/sxn/config.rbs +74 -0
- data/sig/sxn/core/config_manager.rbs +67 -0
- data/sig/sxn/core/project_manager.rbs +52 -0
- data/sig/sxn/core/rules_manager.rbs +54 -0
- data/sig/sxn/core/session_manager.rbs +59 -0
- data/sig/sxn/core/worktree_manager.rbs +50 -0
- data/sig/sxn/core.rbs +87 -0
- data/sig/sxn/database/errors.rbs +37 -0
- data/sig/sxn/database/session_database.rbs +151 -0
- data/sig/sxn/database.rbs +83 -0
- data/sig/sxn/errors.rbs +89 -0
- data/sig/sxn/rules/base_rule.rbs +137 -0
- data/sig/sxn/rules/copy_files_rule.rbs +65 -0
- data/sig/sxn/rules/errors.rbs +33 -0
- data/sig/sxn/rules/project_detector.rbs +115 -0
- data/sig/sxn/rules/rules_engine.rbs +118 -0
- data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
- data/sig/sxn/rules/template_rule.rbs +44 -0
- data/sig/sxn/rules.rbs +287 -0
- data/sig/sxn/runtime_validations.rbs +16 -0
- data/sig/sxn/security/secure_command_executor.rbs +63 -0
- data/sig/sxn/security/secure_file_copier.rbs +79 -0
- data/sig/sxn/security/secure_path_validator.rbs +30 -0
- data/sig/sxn/security.rbs +128 -0
- data/sig/sxn/templates/errors.rbs +43 -0
- data/sig/sxn/templates/template_engine.rbs +50 -0
- data/sig/sxn/templates/template_processor.rbs +44 -0
- data/sig/sxn/templates/template_security.rbs +62 -0
- data/sig/sxn/templates/template_variables.rbs +103 -0
- data/sig/sxn/templates.rbs +104 -0
- data/sig/sxn/ui/output.rbs +50 -0
- data/sig/sxn/ui/progress_bar.rbs +39 -0
- data/sig/sxn/ui/prompt.rbs +38 -0
- data/sig/sxn/ui/table.rbs +43 -0
- data/sig/sxn/ui.rbs +63 -0
- data/sig/sxn/version.rbs +5 -0
- data/sig/sxn.rbs +29 -0
- metadata +635 -0
data/lib/sxn/commands.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "commands/init"
|
4
|
+
require_relative "commands/sessions"
|
5
|
+
require_relative "commands/projects"
|
6
|
+
require_relative "commands/worktrees"
|
7
|
+
require_relative "commands/rules"
|
8
|
+
|
9
|
+
module Sxn
|
10
|
+
# Commands namespace for all CLI command implementations
|
11
|
+
module Commands
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,295 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "fileutils"
|
5
|
+
require "digest"
|
6
|
+
|
7
|
+
module Sxn
|
8
|
+
module Config
|
9
|
+
# Caches discovered configurations with TTL and file change invalidation
|
10
|
+
#
|
11
|
+
# Features:
|
12
|
+
# - Time-based cache expiration (TTL)
|
13
|
+
# - File modification time checking for cache invalidation
|
14
|
+
# - Atomic cache file operations
|
15
|
+
# - Cache storage in .sxn/.cache/config.json
|
16
|
+
class ConfigCache
|
17
|
+
CACHE_DIR = ".sxn/.cache"
|
18
|
+
CACHE_FILE = "config.json"
|
19
|
+
DEFAULT_TTL = 300 # 5 minutes in seconds
|
20
|
+
|
21
|
+
attr_reader :cache_dir, :cache_file_path, :ttl
|
22
|
+
|
23
|
+
def initialize(cache_dir: nil, ttl: DEFAULT_TTL)
|
24
|
+
@cache_dir = cache_dir || File.join(Dir.pwd, CACHE_DIR)
|
25
|
+
@cache_file_path = File.join(@cache_dir, CACHE_FILE)
|
26
|
+
@ttl = ttl
|
27
|
+
@write_mutex = Mutex.new
|
28
|
+
ensure_cache_directory
|
29
|
+
end
|
30
|
+
|
31
|
+
# Get cached configuration or nil if invalid/missing
|
32
|
+
# @param config_files [Array<String>] List of config file paths to check
|
33
|
+
# @return [Hash, nil] Cached configuration or nil
|
34
|
+
def get(config_files)
|
35
|
+
return nil unless cache_exists?
|
36
|
+
|
37
|
+
cache_data = load_cache
|
38
|
+
return nil unless cache_data
|
39
|
+
|
40
|
+
return nil unless cache_valid?(cache_data, config_files)
|
41
|
+
|
42
|
+
cache_data["config"]
|
43
|
+
rescue StandardError => e
|
44
|
+
warn "Warning: Failed to load cache: #{e.message}"
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
|
48
|
+
# Store configuration in cache
|
49
|
+
# @param config [Hash] Configuration to cache
|
50
|
+
# @param config_files [Array<String>] List of config file paths
|
51
|
+
# @return [Boolean] Success status
|
52
|
+
def set(config, config_files)
|
53
|
+
cache_data = {
|
54
|
+
"config" => config,
|
55
|
+
"cached_at" => Time.now.to_f,
|
56
|
+
"config_files" => build_file_metadata(config_files),
|
57
|
+
"cache_version" => 1
|
58
|
+
}
|
59
|
+
|
60
|
+
save_cache(cache_data)
|
61
|
+
true
|
62
|
+
rescue StandardError => e
|
63
|
+
warn "Warning: Failed to save cache: #{e.message}"
|
64
|
+
false
|
65
|
+
end
|
66
|
+
|
67
|
+
# Invalidate the cache by removing the cache file
|
68
|
+
# @return [Boolean] Success status
|
69
|
+
def invalidate
|
70
|
+
return true unless cache_exists?
|
71
|
+
|
72
|
+
File.delete(cache_file_path)
|
73
|
+
true
|
74
|
+
rescue StandardError => e
|
75
|
+
warn "Warning: Failed to invalidate cache: #{e.message}"
|
76
|
+
false
|
77
|
+
end
|
78
|
+
|
79
|
+
# Check if cache is valid without loading the full configuration
|
80
|
+
# @param config_files [Array<String>] List of config file paths to check
|
81
|
+
# @return [Boolean] True if cache is valid
|
82
|
+
def valid?(config_files)
|
83
|
+
return false unless cache_exists?
|
84
|
+
|
85
|
+
cache_data = load_cache
|
86
|
+
return false unless cache_data
|
87
|
+
|
88
|
+
cache_valid?(cache_data, config_files)
|
89
|
+
rescue StandardError
|
90
|
+
false
|
91
|
+
end
|
92
|
+
|
93
|
+
# Get cache statistics
|
94
|
+
# @param config_files [Array<String>] List of config file paths to check validity against
|
95
|
+
# @return [Hash] Cache statistics
|
96
|
+
def stats(config_files = [])
|
97
|
+
return { exists: false, valid: false } unless cache_exists?
|
98
|
+
|
99
|
+
cache_data = load_cache
|
100
|
+
return { exists: true, valid: false, invalid: true } unless cache_data
|
101
|
+
|
102
|
+
is_valid = cache_valid?(cache_data, config_files || [])
|
103
|
+
|
104
|
+
{
|
105
|
+
exists: true,
|
106
|
+
valid: is_valid,
|
107
|
+
cached_at: Time.at(cache_data["cached_at"]),
|
108
|
+
age_seconds: Time.now.to_f - cache_data["cached_at"],
|
109
|
+
file_count: cache_data["config_files"]&.length || 0,
|
110
|
+
cache_version: cache_data["cache_version"]
|
111
|
+
}
|
112
|
+
rescue StandardError
|
113
|
+
{ exists: true, valid: false, invalid: true, error: true }
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
# Ensure cache directory exists
|
119
|
+
def ensure_cache_directory
|
120
|
+
return if Dir.exist?(cache_dir)
|
121
|
+
|
122
|
+
# Thread-safe directory creation
|
123
|
+
FileUtils.mkdir_p(cache_dir)
|
124
|
+
rescue SystemCallError
|
125
|
+
# Directory might have been created by another thread/process
|
126
|
+
# Only re-raise if directory still doesn't exist
|
127
|
+
raise unless Dir.exist?(cache_dir)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Check if cache file exists
|
131
|
+
# @return [Boolean] True if cache file exists
|
132
|
+
def cache_exists?
|
133
|
+
File.exist?(cache_file_path)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Load cache data from file
|
137
|
+
# @return [Hash, nil] Cache data or nil if invalid
|
138
|
+
def load_cache
|
139
|
+
return nil unless cache_exists?
|
140
|
+
|
141
|
+
content = File.read(cache_file_path)
|
142
|
+
JSON.parse(content)
|
143
|
+
rescue JSON::ParserError => e
|
144
|
+
warn "Warning: Invalid cache file JSON: #{e.message}"
|
145
|
+
nil
|
146
|
+
end
|
147
|
+
|
148
|
+
# Save cache data to file atomically
|
149
|
+
# @param cache_data [Hash] Cache data to save
|
150
|
+
def save_cache(cache_data)
|
151
|
+
@write_mutex.synchronize do
|
152
|
+
# Ensure cache directory exists before any file operations
|
153
|
+
ensure_cache_directory
|
154
|
+
|
155
|
+
# Use a more unique temp file name to avoid collisions
|
156
|
+
temp_file = "#{cache_file_path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
157
|
+
|
158
|
+
begin
|
159
|
+
File.write(temp_file, JSON.pretty_generate(cache_data))
|
160
|
+
rescue SystemCallError => e
|
161
|
+
# Directory might have been removed, recreate and retry once
|
162
|
+
ensure_cache_directory
|
163
|
+
begin
|
164
|
+
File.write(temp_file, JSON.pretty_generate(cache_data))
|
165
|
+
rescue SystemCallError
|
166
|
+
# If still failing, give up gracefully
|
167
|
+
warn "Warning: Failed to write cache: #{e.message}"
|
168
|
+
return false
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Retry logic for rename operation in case of race conditions
|
173
|
+
retries = 0
|
174
|
+
begin
|
175
|
+
# Ensure the cache directory still exists before rename
|
176
|
+
ensure_cache_directory
|
177
|
+
|
178
|
+
# Use atomic rename with proper error handling
|
179
|
+
File.rename(temp_file, cache_file_path)
|
180
|
+
rescue Errno::ENOENT
|
181
|
+
retries += 1
|
182
|
+
return false unless retries <= 3
|
183
|
+
|
184
|
+
# Directory or temp file might have issues, recreate and retry
|
185
|
+
ensure_cache_directory
|
186
|
+
|
187
|
+
# If temp file is missing, recreate it
|
188
|
+
unless File.exist?(temp_file)
|
189
|
+
begin
|
190
|
+
File.write(temp_file, JSON.pretty_generate(cache_data))
|
191
|
+
rescue SystemCallError
|
192
|
+
# Can't recreate, give up
|
193
|
+
return false
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
retry
|
198
|
+
|
199
|
+
# Give up after 3 retries, but don't crash - caching is optional
|
200
|
+
# Don't warn here as it's expected in concurrent scenarios
|
201
|
+
rescue SystemCallError
|
202
|
+
# Handle other system errors gracefully - don't warn as it's expected
|
203
|
+
return false
|
204
|
+
ensure
|
205
|
+
# Clean up temp file if it exists
|
206
|
+
FileUtils.rm_f(temp_file) if temp_file && File.exist?(temp_file)
|
207
|
+
end
|
208
|
+
|
209
|
+
true
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Check if cache is still valid
|
214
|
+
# @param cache_data [Hash] Loaded cache data
|
215
|
+
# @param config_files [Array<String>] Current config file paths
|
216
|
+
# @return [Boolean] True if cache is valid
|
217
|
+
def cache_valid?(cache_data, config_files)
|
218
|
+
# Check TTL expiration
|
219
|
+
return false if ttl_expired?(cache_data)
|
220
|
+
|
221
|
+
# Check if any config files have changed
|
222
|
+
return false if files_changed?(cache_data, config_files)
|
223
|
+
|
224
|
+
true
|
225
|
+
end
|
226
|
+
|
227
|
+
# Check if cache has expired based on TTL
|
228
|
+
# @param cache_data [Hash] Cache data
|
229
|
+
# @return [Boolean] True if cache has expired
|
230
|
+
def ttl_expired?(cache_data)
|
231
|
+
cached_at = cache_data["cached_at"]
|
232
|
+
return true unless cached_at
|
233
|
+
|
234
|
+
Time.now.to_f - cached_at > ttl
|
235
|
+
end
|
236
|
+
|
237
|
+
# Check if any config files have changed
|
238
|
+
# @param cache_data [Hash] Cache data
|
239
|
+
# @param config_files [Array<String>] Current config file paths
|
240
|
+
# @return [Boolean] True if files have changed
|
241
|
+
def files_changed?(cache_data, config_files)
|
242
|
+
cached_files = cache_data["config_files"] || {}
|
243
|
+
current_files = build_file_metadata(config_files)
|
244
|
+
|
245
|
+
# Quick check: different number of files
|
246
|
+
return true if cached_files.keys.sort != current_files.keys.sort
|
247
|
+
|
248
|
+
# Check each file's metadata
|
249
|
+
current_files.each do |file_path, metadata|
|
250
|
+
cached_metadata = cached_files[file_path]
|
251
|
+
return true unless cached_metadata
|
252
|
+
|
253
|
+
# Check if mtime or size changed
|
254
|
+
return true if metadata["mtime"] != cached_metadata["mtime"]
|
255
|
+
return true if metadata["size"] != cached_metadata["size"]
|
256
|
+
|
257
|
+
# Always check checksum for content changes (most reliable method)
|
258
|
+
return true if metadata["checksum"] != cached_metadata["checksum"]
|
259
|
+
end
|
260
|
+
|
261
|
+
false
|
262
|
+
end
|
263
|
+
|
264
|
+
# Build metadata for config files
|
265
|
+
# @param config_files [Array<String>] List of config file paths
|
266
|
+
# @return [Hash] File metadata hash
|
267
|
+
def build_file_metadata(config_files)
|
268
|
+
metadata = {}
|
269
|
+
|
270
|
+
config_files.each do |file_path|
|
271
|
+
next unless File.exist?(file_path)
|
272
|
+
|
273
|
+
stat = File.stat(file_path)
|
274
|
+
metadata[file_path] = {
|
275
|
+
"mtime" => stat.mtime.to_f,
|
276
|
+
"size" => stat.size,
|
277
|
+
"checksum" => file_checksum(file_path)
|
278
|
+
}
|
279
|
+
end
|
280
|
+
|
281
|
+
metadata
|
282
|
+
end
|
283
|
+
|
284
|
+
# Calculate file checksum for additional validation
|
285
|
+
# @param file_path [String] Path to file
|
286
|
+
# @return [String] SHA256 checksum
|
287
|
+
def file_checksum(file_path)
|
288
|
+
Digest::SHA256.file(file_path).hexdigest
|
289
|
+
rescue StandardError
|
290
|
+
# If we can't read the file, use a placeholder
|
291
|
+
"unreadable"
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
@@ -0,0 +1,242 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "pathname"
|
5
|
+
|
6
|
+
module Sxn
|
7
|
+
module Config
|
8
|
+
# Handles hierarchical configuration discovery and loading
|
9
|
+
#
|
10
|
+
# Configuration precedence (highest to lowest):
|
11
|
+
# 1. Command-line flags
|
12
|
+
# 2. Environment variables (SXN_*)
|
13
|
+
# 3. Local project config (.sxn/config.yml)
|
14
|
+
# 4. Workspace config (.sxn-workspace/config.yml)
|
15
|
+
# 5. Global user config (~/.sxn/config.yml)
|
16
|
+
# 6. System defaults
|
17
|
+
class ConfigDiscovery
|
18
|
+
CONFIG_FILE_NAME = "config.yml"
|
19
|
+
LOCAL_CONFIG_DIR = ".sxn"
|
20
|
+
WORKSPACE_CONFIG_DIR = ".sxn-workspace"
|
21
|
+
GLOBAL_CONFIG_DIR = File.expand_path("~/.sxn")
|
22
|
+
ENV_PREFIX = "SXN_"
|
23
|
+
|
24
|
+
attr_reader :start_directory
|
25
|
+
|
26
|
+
def initialize(start_directory = Dir.pwd)
|
27
|
+
@start_directory = Pathname.new(start_directory).expand_path
|
28
|
+
end
|
29
|
+
|
30
|
+
# Discover and load configuration from all sources
|
31
|
+
# @param cli_options [Hash] Command-line options
|
32
|
+
# @return [Hash] Merged configuration
|
33
|
+
def discover_config(cli_options = {})
|
34
|
+
config_sources = load_all_configs
|
35
|
+
merge_configs(config_sources, cli_options)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Find all configuration files in the hierarchy
|
39
|
+
# @return [Array<String>] Paths to configuration files
|
40
|
+
def find_config_files
|
41
|
+
config_files = []
|
42
|
+
|
43
|
+
# Local project config (.sxn/config.yml)
|
44
|
+
local_config = find_local_config
|
45
|
+
config_files << local_config if local_config
|
46
|
+
|
47
|
+
# Workspace config (.sxn-workspace/config.yml)
|
48
|
+
workspace_config = find_workspace_config
|
49
|
+
config_files << workspace_config if workspace_config
|
50
|
+
|
51
|
+
# Global user config (~/.sxn/config.yml)
|
52
|
+
global_config = find_global_config
|
53
|
+
config_files << global_config if global_config
|
54
|
+
|
55
|
+
config_files
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# Load configurations from all sources
|
61
|
+
# @return [Hash] Hash of config sources
|
62
|
+
def load_all_configs
|
63
|
+
{
|
64
|
+
system_defaults: load_system_defaults,
|
65
|
+
global_config: load_global_config,
|
66
|
+
workspace_config: load_workspace_config,
|
67
|
+
local_config: load_local_config,
|
68
|
+
env_config: load_env_config
|
69
|
+
}
|
70
|
+
end
|
71
|
+
|
72
|
+
# Find local project config by walking up directory tree
|
73
|
+
# @return [String, nil] Path to local config file
|
74
|
+
def find_local_config
|
75
|
+
current_dir = start_directory
|
76
|
+
|
77
|
+
loop do
|
78
|
+
config_path = current_dir.join(LOCAL_CONFIG_DIR, CONFIG_FILE_NAME)
|
79
|
+
return config_path.to_s if config_path.exist?
|
80
|
+
|
81
|
+
parent = current_dir.parent
|
82
|
+
break if parent == current_dir # Reached filesystem root
|
83
|
+
|
84
|
+
current_dir = parent
|
85
|
+
end
|
86
|
+
|
87
|
+
nil
|
88
|
+
end
|
89
|
+
|
90
|
+
# Find workspace config by walking up directory tree
|
91
|
+
# @return [String, nil] Path to workspace config file
|
92
|
+
def find_workspace_config
|
93
|
+
current_dir = start_directory
|
94
|
+
|
95
|
+
loop do
|
96
|
+
config_path = current_dir.join(WORKSPACE_CONFIG_DIR, CONFIG_FILE_NAME)
|
97
|
+
return config_path.to_s if config_path.exist?
|
98
|
+
|
99
|
+
parent = current_dir.parent
|
100
|
+
break if parent == current_dir # Reached filesystem root
|
101
|
+
|
102
|
+
current_dir = parent
|
103
|
+
end
|
104
|
+
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
# Find global user config
|
109
|
+
# @return [String, nil] Path to global config file
|
110
|
+
def find_global_config
|
111
|
+
global_config_path = File.join(GLOBAL_CONFIG_DIR, CONFIG_FILE_NAME)
|
112
|
+
File.exist?(global_config_path) ? global_config_path : nil
|
113
|
+
end
|
114
|
+
|
115
|
+
# Load system default configuration
|
116
|
+
# @return [Hash] Default configuration
|
117
|
+
def load_system_defaults
|
118
|
+
{
|
119
|
+
"version" => 1,
|
120
|
+
"sessions_folder" => ".sessions",
|
121
|
+
"current_session" => nil,
|
122
|
+
"projects" => {},
|
123
|
+
"settings" => {
|
124
|
+
"auto_cleanup" => true,
|
125
|
+
"max_sessions" => 10,
|
126
|
+
"worktree_cleanup_days" => 30,
|
127
|
+
"default_rules" => {
|
128
|
+
"templates" => []
|
129
|
+
}
|
130
|
+
}
|
131
|
+
}
|
132
|
+
end
|
133
|
+
|
134
|
+
# Load global user configuration
|
135
|
+
# @return [Hash] Global configuration
|
136
|
+
def load_global_config
|
137
|
+
config_path = find_global_config
|
138
|
+
return {} unless config_path
|
139
|
+
|
140
|
+
load_yaml_file(config_path)
|
141
|
+
rescue StandardError => e
|
142
|
+
warn "Warning: Failed to load global config #{config_path}: #{e.message}"
|
143
|
+
{}
|
144
|
+
end
|
145
|
+
|
146
|
+
# Load workspace configuration
|
147
|
+
# @return [Hash] Workspace configuration
|
148
|
+
def load_workspace_config
|
149
|
+
config_path = find_workspace_config
|
150
|
+
return {} unless config_path
|
151
|
+
|
152
|
+
load_yaml_file(config_path)
|
153
|
+
rescue StandardError => e
|
154
|
+
warn "Warning: Failed to load workspace config #{config_path}: #{e.message}"
|
155
|
+
{}
|
156
|
+
end
|
157
|
+
|
158
|
+
# Load local project configuration
|
159
|
+
# @return [Hash] Local configuration
|
160
|
+
def load_local_config
|
161
|
+
config_path = find_local_config
|
162
|
+
return {} unless config_path
|
163
|
+
|
164
|
+
load_yaml_file(config_path)
|
165
|
+
rescue StandardError => e
|
166
|
+
warn "Warning: Failed to load local config #{config_path}: #{e.message}"
|
167
|
+
{}
|
168
|
+
end
|
169
|
+
|
170
|
+
# Load environment variable configuration
|
171
|
+
# @return [Hash] Environment configuration
|
172
|
+
def load_env_config
|
173
|
+
env_config = {}
|
174
|
+
|
175
|
+
ENV.each do |key, value|
|
176
|
+
next unless key.start_with?(ENV_PREFIX)
|
177
|
+
|
178
|
+
# Convert SXN_SESSIONS_FOLDER to sessions_folder
|
179
|
+
config_key = key[ENV_PREFIX.length..].downcase
|
180
|
+
|
181
|
+
# Parse boolean values
|
182
|
+
parsed_value = case value.downcase
|
183
|
+
when "true" then true
|
184
|
+
when "false" then false
|
185
|
+
else value
|
186
|
+
end
|
187
|
+
|
188
|
+
env_config[config_key] = parsed_value
|
189
|
+
end
|
190
|
+
|
191
|
+
env_config
|
192
|
+
end
|
193
|
+
|
194
|
+
# Load and parse YAML file safely
|
195
|
+
# @param file_path [String] Path to YAML file
|
196
|
+
# @return [Hash] Parsed YAML content
|
197
|
+
def load_yaml_file(file_path)
|
198
|
+
content = File.read(file_path)
|
199
|
+
YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
|
200
|
+
rescue Psych::SyntaxError => e
|
201
|
+
raise ConfigurationError, "Invalid YAML in #{file_path}: #{e.message}"
|
202
|
+
rescue StandardError => e
|
203
|
+
raise ConfigurationError, "Failed to load config file #{file_path}: #{e.message}"
|
204
|
+
end
|
205
|
+
|
206
|
+
# Merge configurations with proper precedence
|
207
|
+
# @param configs [Hash] Hash of configuration sources
|
208
|
+
# @param cli_options [Hash] Command-line options
|
209
|
+
# @return [Hash] Merged configuration
|
210
|
+
def merge_configs(configs, cli_options)
|
211
|
+
# Start with system defaults and merge up the precedence chain
|
212
|
+
result = (configs[:system_defaults] || {}).dup
|
213
|
+
|
214
|
+
# Deep merge each config level, skipping nil values
|
215
|
+
deep_merge!(result, configs[:global_config]) if configs[:global_config]
|
216
|
+
deep_merge!(result, configs[:workspace_config]) if configs[:workspace_config]
|
217
|
+
deep_merge!(result, configs[:local_config]) if configs[:local_config]
|
218
|
+
deep_merge!(result, configs[:env_config]) if configs[:env_config]
|
219
|
+
deep_merge!(result, cli_options)
|
220
|
+
|
221
|
+
result
|
222
|
+
end
|
223
|
+
|
224
|
+
# Deep merge configuration hashes
|
225
|
+
# @param target [Hash] Target hash to merge into
|
226
|
+
# @param source [Hash] Source hash to merge from
|
227
|
+
def deep_merge!(target, source)
|
228
|
+
return target unless source.is_a?(Hash)
|
229
|
+
|
230
|
+
source.each do |key, value|
|
231
|
+
if target[key].is_a?(Hash) && value.is_a?(Hash)
|
232
|
+
deep_merge!(target[key], value)
|
233
|
+
else
|
234
|
+
target[key] = value
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
target
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|