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
@@ -0,0 +1,307 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_rule"
|
4
|
+
require_relative "errors"
|
5
|
+
require_relative "../security/secure_command_executor"
|
6
|
+
|
7
|
+
module Sxn
|
8
|
+
module Rules
|
9
|
+
# SetupCommandsRule executes project setup commands safely using the SecureCommandExecutor.
|
10
|
+
# It supports environment variables, conditional execution, and proper error handling.
|
11
|
+
#
|
12
|
+
# Configuration format:
|
13
|
+
# {
|
14
|
+
# "commands" => [
|
15
|
+
# {
|
16
|
+
# "command" => ["bundle", "install"],
|
17
|
+
# "env" => { "RAILS_ENV" => "development" },
|
18
|
+
# "timeout" => 120,
|
19
|
+
# "condition" => "file_missing:Gemfile.lock",
|
20
|
+
# "description" => "Install Ruby dependencies",
|
21
|
+
# "required" => true,
|
22
|
+
# "working_directory" => "."
|
23
|
+
# }
|
24
|
+
# ],
|
25
|
+
# "continue_on_failure" => false
|
26
|
+
# }
|
27
|
+
#
|
28
|
+
# @example Basic usage
|
29
|
+
# rule = SetupCommandsRule.new(
|
30
|
+
# "rails_setup",
|
31
|
+
# {
|
32
|
+
# "commands" => [
|
33
|
+
# { "command" => ["bundle", "install"] },
|
34
|
+
# { "command" => ["bin/rails", "db:create"] },
|
35
|
+
# { "command" => ["bin/rails", "db:migrate"] }
|
36
|
+
# ]
|
37
|
+
# },
|
38
|
+
# "/path/to/project",
|
39
|
+
# "/path/to/session"
|
40
|
+
# )
|
41
|
+
# rule.validate
|
42
|
+
# rule.apply
|
43
|
+
#
|
44
|
+
class SetupCommandsRule < BaseRule
|
45
|
+
# Supported condition types for conditional execution
|
46
|
+
CONDITION_TYPES = {
|
47
|
+
"file_exists" => :file_exists?,
|
48
|
+
"file_missing" => :file_missing?,
|
49
|
+
"directory_exists" => :directory_exists?,
|
50
|
+
"directory_missing" => :directory_missing?,
|
51
|
+
"command_available" => :command_available?,
|
52
|
+
"env_var_set" => :env_var_set?,
|
53
|
+
"always" => :always_true
|
54
|
+
}.freeze
|
55
|
+
|
56
|
+
# Default command timeout in seconds
|
57
|
+
DEFAULT_TIMEOUT = 60
|
58
|
+
|
59
|
+
# Maximum allowed timeout in seconds
|
60
|
+
MAX_TIMEOUT = 1800 # 30 minutes
|
61
|
+
|
62
|
+
# Initialize the setup commands rule
|
63
|
+
def initialize(arg1 = nil, arg2 = nil, arg3 = nil, arg4 = nil, dependencies: [])
|
64
|
+
super
|
65
|
+
@command_executor = Security::SecureCommandExecutor.new(@session_path, logger: logger)
|
66
|
+
@executed_commands = []
|
67
|
+
end
|
68
|
+
|
69
|
+
# Validate the rule configuration
|
70
|
+
|
71
|
+
# Apply the command execution operations
|
72
|
+
def apply(_context = {})
|
73
|
+
change_state!(APPLYING)
|
74
|
+
continue_on_failure = @config.fetch("continue_on_failure", false)
|
75
|
+
|
76
|
+
begin
|
77
|
+
@config["commands"].each_with_index do |command_config, index|
|
78
|
+
apply_command(command_config, index, continue_on_failure)
|
79
|
+
end
|
80
|
+
|
81
|
+
change_state!(APPLIED)
|
82
|
+
log(:info, "Successfully executed #{@executed_commands.size} commands")
|
83
|
+
true
|
84
|
+
rescue StandardError => e
|
85
|
+
@errors << e
|
86
|
+
change_state!(FAILED)
|
87
|
+
raise ApplicationError, "Failed to execute setup commands: #{e.message}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Get summary of executed commands
|
92
|
+
def execution_summary
|
93
|
+
@executed_commands.map do |cmd_result|
|
94
|
+
{
|
95
|
+
command: cmd_result[:command],
|
96
|
+
success: cmd_result[:result].success?,
|
97
|
+
duration: cmd_result[:result].duration,
|
98
|
+
exit_status: cmd_result[:result].exit_status
|
99
|
+
}
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
protected
|
104
|
+
|
105
|
+
# Validate rule-specific configuration
|
106
|
+
def validate_rule_specific!
|
107
|
+
raise ValidationError, "SetupCommandsRule requires 'commands' configuration" unless @config.key?("commands")
|
108
|
+
|
109
|
+
raise ValidationError, "SetupCommandsRule 'commands' must be an array" unless @config["commands"].is_a?(Array)
|
110
|
+
|
111
|
+
raise ValidationError, "SetupCommandsRule 'commands' cannot be empty" if @config["commands"].empty?
|
112
|
+
|
113
|
+
@config["commands"].each_with_index do |command_config, index|
|
114
|
+
validate_command_config!(command_config, index)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Validate global options
|
118
|
+
return unless @config.key?("continue_on_failure")
|
119
|
+
return if [true, false].include?(@config["continue_on_failure"])
|
120
|
+
|
121
|
+
raise ValidationError, "continue_on_failure must be true or false"
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
# Validate individual command configuration
|
127
|
+
def validate_command_config!(command_config, index)
|
128
|
+
raise ValidationError, "Command config #{index} must be a hash" unless command_config.is_a?(Hash)
|
129
|
+
|
130
|
+
raise ValidationError, "Command config #{index} must have a 'command' field" unless command_config.key?("command")
|
131
|
+
|
132
|
+
command = command_config["command"]
|
133
|
+
raise ValidationError, "Command config #{index} 'command' must be a non-empty array" unless command.is_a?(Array) && !command.empty?
|
134
|
+
|
135
|
+
# Validate that command is whitelisted
|
136
|
+
raise ValidationError, "Command config #{index}: command not whitelisted: #{command.first}" unless @command_executor.command_allowed?(command)
|
137
|
+
|
138
|
+
# Validate timeout
|
139
|
+
if command_config.key?("timeout")
|
140
|
+
timeout = command_config["timeout"]
|
141
|
+
unless timeout.is_a?(Integer) && timeout.positive? && timeout <= MAX_TIMEOUT
|
142
|
+
raise ValidationError, "Command config #{index}: timeout must be positive integer <= #{MAX_TIMEOUT}"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Validate environment variables
|
147
|
+
if command_config.key?("env")
|
148
|
+
env = command_config["env"]
|
149
|
+
raise ValidationError, "Command config #{index}: env must be a hash" unless env.is_a?(Hash)
|
150
|
+
|
151
|
+
env.each do |key, value|
|
152
|
+
raise ValidationError, "Command config #{index}: env keys and values must be strings" unless key.is_a?(String) && value.is_a?(String)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Validate condition
|
157
|
+
if command_config.key?("condition")
|
158
|
+
condition = command_config["condition"]
|
159
|
+
raise ValidationError, "Command config #{index}: invalid condition format: #{condition}" unless valid_condition?(condition)
|
160
|
+
end
|
161
|
+
|
162
|
+
# Validate working directory
|
163
|
+
return unless command_config.key?("working_directory")
|
164
|
+
|
165
|
+
working_dir = command_config["working_directory"]
|
166
|
+
raise ValidationError, "Command config #{index}: working_directory must be a string" unless working_dir.is_a?(String)
|
167
|
+
|
168
|
+
full_path = File.expand_path(working_dir, @session_path)
|
169
|
+
return if full_path.start_with?(@session_path)
|
170
|
+
|
171
|
+
raise ValidationError, "Command config #{index}: working_directory must be within session path"
|
172
|
+
end
|
173
|
+
|
174
|
+
# Check if condition format is valid
|
175
|
+
def valid_condition?(condition)
|
176
|
+
return true if condition.nil? || condition == "always"
|
177
|
+
|
178
|
+
condition.is_a?(String) && condition.include?(":") &&
|
179
|
+
CONDITION_TYPES.key?(condition.split(":", 2).first)
|
180
|
+
end
|
181
|
+
|
182
|
+
# Apply a single command operation
|
183
|
+
def apply_command(command_config, index, continue_on_failure)
|
184
|
+
command = command_config["command"]
|
185
|
+
description = command_config.fetch("description", command.join(" "))
|
186
|
+
|
187
|
+
log(:debug, "Evaluating command #{index}: #{description}")
|
188
|
+
|
189
|
+
# Check condition
|
190
|
+
unless should_execute_command?(command_config)
|
191
|
+
log(:info, "Skipping command due to condition: #{description}")
|
192
|
+
return
|
193
|
+
end
|
194
|
+
|
195
|
+
log(:info, "Executing command: #{description}")
|
196
|
+
|
197
|
+
begin
|
198
|
+
result = execute_command_safely(command_config)
|
199
|
+
|
200
|
+
@executed_commands << {
|
201
|
+
index: index,
|
202
|
+
command: command,
|
203
|
+
description: description,
|
204
|
+
result: result
|
205
|
+
}
|
206
|
+
|
207
|
+
track_change(:command_executed, command.join(" "), {
|
208
|
+
working_directory: determine_working_directory(command_config),
|
209
|
+
env: command_config.fetch("env", {}),
|
210
|
+
exit_status: result.exit_status,
|
211
|
+
duration: result.duration
|
212
|
+
})
|
213
|
+
|
214
|
+
if result.failure?
|
215
|
+
error_msg = "Command failed: #{description} (exit status: #{result.exit_status})"
|
216
|
+
|
217
|
+
error_msg += "\nSTDERR: #{result.stderr}" if result.stderr && !result.stderr.empty?
|
218
|
+
|
219
|
+
raise ApplicationError, error_msg unless continue_on_failure
|
220
|
+
|
221
|
+
log(:warn, error_msg)
|
222
|
+
|
223
|
+
else
|
224
|
+
log(:debug, "Command completed successfully", {
|
225
|
+
exit_status: result.exit_status,
|
226
|
+
duration: result.duration
|
227
|
+
})
|
228
|
+
end
|
229
|
+
rescue StandardError => e
|
230
|
+
error_msg = "Failed to execute command: #{description} - #{e.message}"
|
231
|
+
|
232
|
+
raise ApplicationError, error_msg unless continue_on_failure
|
233
|
+
|
234
|
+
log(:error, error_msg)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
# Execute a command with the security layer
|
239
|
+
def execute_command_safely(command_config)
|
240
|
+
command = command_config["command"]
|
241
|
+
env = command_config.fetch("env", {})
|
242
|
+
timeout = command_config.fetch("timeout", DEFAULT_TIMEOUT)
|
243
|
+
working_dir = determine_working_directory(command_config)
|
244
|
+
|
245
|
+
@command_executor.execute(
|
246
|
+
command,
|
247
|
+
env: env,
|
248
|
+
timeout: timeout,
|
249
|
+
chdir: working_dir
|
250
|
+
)
|
251
|
+
end
|
252
|
+
|
253
|
+
# Determine the working directory for command execution
|
254
|
+
def determine_working_directory(command_config)
|
255
|
+
if command_config.key?("working_directory")
|
256
|
+
File.expand_path(command_config["working_directory"], @session_path)
|
257
|
+
else
|
258
|
+
@session_path
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
# Check if command should be executed based on its condition
|
263
|
+
def should_execute_command?(command_config)
|
264
|
+
condition = command_config["condition"]
|
265
|
+
return true if condition.nil? || condition == "always"
|
266
|
+
|
267
|
+
condition_type, condition_arg = condition.split(":", 2)
|
268
|
+
method_name = CONDITION_TYPES[condition_type]
|
269
|
+
|
270
|
+
return true unless method_name
|
271
|
+
|
272
|
+
send(method_name, condition_arg)
|
273
|
+
end
|
274
|
+
|
275
|
+
# Condition evaluation methods
|
276
|
+
def file_exists?(path)
|
277
|
+
full_path = File.expand_path(path, @session_path)
|
278
|
+
File.exist?(full_path)
|
279
|
+
end
|
280
|
+
|
281
|
+
def file_missing?(path)
|
282
|
+
!file_exists?(path)
|
283
|
+
end
|
284
|
+
|
285
|
+
def directory_exists?(path)
|
286
|
+
full_path = File.expand_path(path, @session_path)
|
287
|
+
File.directory?(full_path)
|
288
|
+
end
|
289
|
+
|
290
|
+
def directory_missing?(path)
|
291
|
+
!directory_exists?(path)
|
292
|
+
end
|
293
|
+
|
294
|
+
def command_available?(command_name)
|
295
|
+
@command_executor.command_allowed?([command_name])
|
296
|
+
end
|
297
|
+
|
298
|
+
def env_var_set?(var_name)
|
299
|
+
ENV.key?(var_name) && !ENV[var_name].to_s.empty?
|
300
|
+
end
|
301
|
+
|
302
|
+
def always_true(_arg = nil)
|
303
|
+
true
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
@@ -0,0 +1,262 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_rule"
|
4
|
+
require_relative "errors"
|
5
|
+
require_relative "../templates/template_processor"
|
6
|
+
|
7
|
+
module Sxn
|
8
|
+
module Rules
|
9
|
+
# TemplateRule processes and applies template files using the secure template processor.
|
10
|
+
# It supports variable substitution, multiple template engines, and safe file generation.
|
11
|
+
#
|
12
|
+
# Configuration format:
|
13
|
+
# {
|
14
|
+
# "templates" => [
|
15
|
+
# {
|
16
|
+
# "source" => ".sxn/templates/session-info.md.liquid",
|
17
|
+
# "destination" => "README.md",
|
18
|
+
# "variables" => { "custom_var" => "value" },
|
19
|
+
# "engine" => "liquid",
|
20
|
+
# "required" => true,
|
21
|
+
# "overwrite" => false
|
22
|
+
# }
|
23
|
+
# ]
|
24
|
+
# }
|
25
|
+
#
|
26
|
+
# @example Basic usage
|
27
|
+
# rule = TemplateRule.new(
|
28
|
+
# "generate_docs",
|
29
|
+
# {
|
30
|
+
# "templates" => [
|
31
|
+
# {
|
32
|
+
# "source" => ".sxn/templates/CLAUDE.md.liquid",
|
33
|
+
# "destination" => "CLAUDE.md"
|
34
|
+
# }
|
35
|
+
# ]
|
36
|
+
# },
|
37
|
+
# "/path/to/project",
|
38
|
+
# "/path/to/session"
|
39
|
+
# )
|
40
|
+
# rule.validate
|
41
|
+
# rule.apply
|
42
|
+
#
|
43
|
+
class TemplateRule < BaseRule
|
44
|
+
# Supported template engines
|
45
|
+
SUPPORTED_ENGINES = %w[liquid].freeze
|
46
|
+
|
47
|
+
# Initialize the template rule
|
48
|
+
def initialize(arg1 = nil, arg2 = nil, arg3 = nil, arg4 = nil, dependencies: [])
|
49
|
+
super
|
50
|
+
@template_processor = Templates::TemplateProcessor.new
|
51
|
+
@template_variables = Templates::TemplateVariables.new
|
52
|
+
end
|
53
|
+
|
54
|
+
# Validate the rule configuration
|
55
|
+
|
56
|
+
# Apply the template processing operations
|
57
|
+
def apply
|
58
|
+
change_state!(APPLYING)
|
59
|
+
|
60
|
+
begin
|
61
|
+
@config["templates"].each_with_index do |template_config, index|
|
62
|
+
apply_template(template_config, index)
|
63
|
+
end
|
64
|
+
|
65
|
+
change_state!(APPLIED)
|
66
|
+
log(:info, "Successfully processed #{@config["templates"].size} templates")
|
67
|
+
true
|
68
|
+
rescue StandardError => e
|
69
|
+
@errors << e
|
70
|
+
change_state!(FAILED)
|
71
|
+
raise ApplicationError, "Failed to process templates: #{e.message}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
protected
|
76
|
+
|
77
|
+
# Validate rule-specific configuration
|
78
|
+
def validate_rule_specific!
|
79
|
+
raise ValidationError, "TemplateRule requires 'templates' configuration" unless @config.key?("templates")
|
80
|
+
|
81
|
+
raise ValidationError, "TemplateRule 'templates' must be an array" unless @config["templates"].is_a?(Array)
|
82
|
+
|
83
|
+
raise ValidationError, "TemplateRule 'templates' cannot be empty" if @config["templates"].empty?
|
84
|
+
|
85
|
+
@config["templates"].each_with_index do |template_config, index|
|
86
|
+
validate_template_config!(template_config, index)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
# Validate individual template configuration
|
93
|
+
def validate_template_config!(template_config, index)
|
94
|
+
raise ValidationError, "Template config #{index} must be a hash" unless template_config.is_a?(Hash)
|
95
|
+
|
96
|
+
unless template_config.key?("source") && template_config["source"].is_a?(String)
|
97
|
+
raise ValidationError, "Template config #{index} must have a 'source' string"
|
98
|
+
end
|
99
|
+
|
100
|
+
unless template_config.key?("destination") && template_config["destination"].is_a?(String)
|
101
|
+
raise ValidationError, "Template config #{index} must have a 'destination' string"
|
102
|
+
end
|
103
|
+
|
104
|
+
# Validate engine
|
105
|
+
if template_config.key?("engine")
|
106
|
+
engine = template_config["engine"]
|
107
|
+
unless SUPPORTED_ENGINES.include?(engine)
|
108
|
+
raise ValidationError,
|
109
|
+
"Template config #{index} has unsupported engine '#{engine}'. Supported: #{SUPPORTED_ENGINES.join(", ")}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Validate variables
|
114
|
+
if template_config.key?("variables")
|
115
|
+
variables = template_config["variables"]
|
116
|
+
raise ValidationError, "Template config #{index} 'variables' must be a hash" unless variables.is_a?(Hash)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Validate source template exists if required
|
120
|
+
source_path = File.join(@project_path, template_config["source"])
|
121
|
+
required = template_config.fetch("required", true)
|
122
|
+
|
123
|
+
raise ValidationError, "Required template file does not exist: #{template_config["source"]}" if required && !File.exist?(source_path)
|
124
|
+
|
125
|
+
# Validate destination path is safe
|
126
|
+
destination = template_config["destination"]
|
127
|
+
return unless destination.include?("..") || destination.start_with?("/")
|
128
|
+
|
129
|
+
raise ValidationError, "Template config #{index} destination path is not safe: #{destination}"
|
130
|
+
end
|
131
|
+
|
132
|
+
# Apply a single template operation
|
133
|
+
def apply_template(template_config, _index)
|
134
|
+
source = template_config["source"]
|
135
|
+
destination = template_config["destination"]
|
136
|
+
required = template_config.fetch("required", true)
|
137
|
+
overwrite = template_config.fetch("overwrite", false)
|
138
|
+
|
139
|
+
source_path = File.join(@project_path, source)
|
140
|
+
destination_path = File.join(@session_path, destination)
|
141
|
+
|
142
|
+
# Skip if source doesn't exist and is not required
|
143
|
+
unless File.exist?(source_path)
|
144
|
+
raise ApplicationError, "Required template file does not exist: #{source}" if required
|
145
|
+
|
146
|
+
log(:debug, "Skipping optional missing template: #{source}")
|
147
|
+
return
|
148
|
+
|
149
|
+
end
|
150
|
+
|
151
|
+
# Check if destination already exists
|
152
|
+
if File.exist?(destination_path) && !overwrite
|
153
|
+
log(:warn, "Destination file already exists, skipping: #{destination}")
|
154
|
+
return
|
155
|
+
end
|
156
|
+
|
157
|
+
log(:debug, "Processing template: #{source} -> #{destination}")
|
158
|
+
|
159
|
+
begin
|
160
|
+
# Prepare template variables
|
161
|
+
variables = build_template_variables(template_config)
|
162
|
+
|
163
|
+
# Validate template syntax first
|
164
|
+
template_content = File.read(source_path)
|
165
|
+
@template_processor.validate_syntax(template_content)
|
166
|
+
|
167
|
+
# Process the template
|
168
|
+
processed_content = @template_processor.process(template_content, variables)
|
169
|
+
|
170
|
+
# Create destination directory if needed
|
171
|
+
destination_dir = File.dirname(destination_path)
|
172
|
+
FileUtils.mkdir_p(destination_dir) unless File.directory?(destination_dir)
|
173
|
+
|
174
|
+
# Create backup if file exists and we're overwriting
|
175
|
+
backup_path = nil
|
176
|
+
if File.exist?(destination_path) && overwrite
|
177
|
+
backup_path = "#{destination_path}.backup.#{Time.now.to_i}"
|
178
|
+
FileUtils.cp(destination_path, backup_path)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Write processed content
|
182
|
+
File.write(destination_path, processed_content)
|
183
|
+
|
184
|
+
# Set appropriate permissions
|
185
|
+
File.chmod(0o644, destination_path)
|
186
|
+
|
187
|
+
track_change(:file_created, destination_path, {
|
188
|
+
source: source_path,
|
189
|
+
template: true,
|
190
|
+
backup_path: backup_path,
|
191
|
+
variables_used: extract_used_variables(template_content)
|
192
|
+
})
|
193
|
+
|
194
|
+
log(:debug, "Template processed successfully", {
|
195
|
+
source: source,
|
196
|
+
destination: destination,
|
197
|
+
size: processed_content.bytesize
|
198
|
+
})
|
199
|
+
rescue Templates::Errors::TemplateSyntaxError => e
|
200
|
+
raise ApplicationError, "Template syntax error in #{source}: #{e.message}"
|
201
|
+
rescue Templates::Errors::TemplateProcessingError => e
|
202
|
+
raise ApplicationError, "Template processing error for #{source}: #{e.message}"
|
203
|
+
rescue StandardError => e
|
204
|
+
raise ApplicationError, "Failed to process template #{source}: #{e.message}"
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Build variables hash for template processing
|
209
|
+
def build_template_variables(template_config)
|
210
|
+
# Start with system-generated variables
|
211
|
+
variables = @template_variables.build_variables
|
212
|
+
|
213
|
+
# Add any custom variables from configuration
|
214
|
+
if template_config.key?("variables")
|
215
|
+
custom_vars = template_config["variables"]
|
216
|
+
variables = deep_merge(variables, custom_vars)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Add template-specific metadata
|
220
|
+
variables[:template] = {
|
221
|
+
source: template_config["source"],
|
222
|
+
destination: template_config["destination"],
|
223
|
+
processed_at: Time.now.iso8601
|
224
|
+
}
|
225
|
+
|
226
|
+
variables
|
227
|
+
end
|
228
|
+
|
229
|
+
# Deep merge two hashes, with the second hash taking precedence
|
230
|
+
# Handles mixed symbol/string keys by preserving both when merging
|
231
|
+
def deep_merge(hash1, hash2)
|
232
|
+
result = hash1.dup
|
233
|
+
|
234
|
+
hash2.each do |key, value|
|
235
|
+
# Check if there's an existing key with the same string representation
|
236
|
+
existing_key = result.keys.find { |k| k.to_s == key.to_s }
|
237
|
+
|
238
|
+
if existing_key && result[existing_key].is_a?(Hash) && value.is_a?(Hash)
|
239
|
+
# Merge the hashes and set the new key (preserving the incoming key type)
|
240
|
+
merged_value = deep_merge(result[existing_key], value)
|
241
|
+
result.delete(existing_key) if existing_key != key # Remove old key if different
|
242
|
+
result[key] = merged_value
|
243
|
+
else
|
244
|
+
# For non-hash values or new keys, just set the value
|
245
|
+
result.delete(existing_key) if existing_key && existing_key != key
|
246
|
+
result[key] = value
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
result
|
251
|
+
end
|
252
|
+
|
253
|
+
# Extract variable names used in a template
|
254
|
+
def extract_used_variables(template_content)
|
255
|
+
@template_processor.extract_variables(template_content)
|
256
|
+
rescue StandardError => e
|
257
|
+
log(:warn, "Could not extract variables from template: #{e.message}")
|
258
|
+
[]
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|