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,713 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
require "time"
|
5
|
+
require "json"
|
6
|
+
require "yaml"
|
7
|
+
require "open3"
|
8
|
+
require "timeout"
|
9
|
+
|
10
|
+
module Sxn
|
11
|
+
module Templates
|
12
|
+
# TemplateVariables collects and prepares variables for template processing.
|
13
|
+
# It gathers context from sessions, git repositories, projects, and environment.
|
14
|
+
#
|
15
|
+
# Variable Categories:
|
16
|
+
# - session: Current session information (name, path, created_at, etc.)
|
17
|
+
# - git: Git repository information (branch, author, last_commit, etc.)
|
18
|
+
# - project: Project details (name, type, path, etc.)
|
19
|
+
# - environment: Runtime environment (ruby version, rails version, etc.)
|
20
|
+
# - user: User preferences and git configuration
|
21
|
+
#
|
22
|
+
# Example:
|
23
|
+
# collector = TemplateVariables.new(session, project)
|
24
|
+
# variables = collector.collect
|
25
|
+
# # => {
|
26
|
+
# # session: { name: "ATL-1234", path: "/path/to/session" },
|
27
|
+
# # git: { branch: "feature/cart", author: "John Doe" },
|
28
|
+
# # project: { name: "atlas-core", type: "rails" }
|
29
|
+
# # }
|
30
|
+
class TemplateVariables
|
31
|
+
# Git command timeout in seconds
|
32
|
+
GIT_TIMEOUT = 5
|
33
|
+
|
34
|
+
def initialize(session = nil, project = nil, config = nil)
|
35
|
+
@session = session
|
36
|
+
@project = project
|
37
|
+
@config = config
|
38
|
+
@cached_variables = {}
|
39
|
+
end
|
40
|
+
|
41
|
+
# Collect all template variables
|
42
|
+
#
|
43
|
+
# @return [Hash] Complete set of variables for template processing
|
44
|
+
def collect
|
45
|
+
return @cached_variables unless @cached_variables.empty?
|
46
|
+
|
47
|
+
# steep:ignore:start - Template variable collection uses metaprogramming
|
48
|
+
# The template system uses dynamic method calls and variable resolution that
|
49
|
+
# cannot be statically typed. Runtime validation ensures type safety.
|
50
|
+
@cached_variables = {
|
51
|
+
session: _collect_session_variables,
|
52
|
+
git: _collect_git_variables,
|
53
|
+
project: _collect_project_variables,
|
54
|
+
environment: _collect_environment_variables,
|
55
|
+
user: _collect_user_variables,
|
56
|
+
timestamp: _collect_timestamp_variables
|
57
|
+
}.compact
|
58
|
+
|
59
|
+
# Runtime validation of collected variables
|
60
|
+
validate_collected_variables(@cached_variables)
|
61
|
+
|
62
|
+
@cached_variables
|
63
|
+
end
|
64
|
+
|
65
|
+
# Alias for collect method to maintain backwards compatibility with tests
|
66
|
+
alias build_variables collect
|
67
|
+
|
68
|
+
# Refresh cached variables (useful for long-running processes)
|
69
|
+
def refresh!
|
70
|
+
@cached_variables = {}
|
71
|
+
collect
|
72
|
+
end
|
73
|
+
|
74
|
+
# Get variables for a specific category
|
75
|
+
#
|
76
|
+
# @param category [Symbol] The variable category (:session, :git, :project, etc.)
|
77
|
+
# @return [Hash] Variables for the specified category
|
78
|
+
def get_category(category)
|
79
|
+
collect[category] || {}
|
80
|
+
end
|
81
|
+
|
82
|
+
# Add custom variables that will be merged with collected variables
|
83
|
+
#
|
84
|
+
# @param custom_vars [Hash] Custom variables to add
|
85
|
+
def add_custom_variables(custom_vars)
|
86
|
+
return unless custom_vars.is_a?(Hash)
|
87
|
+
|
88
|
+
# Merge custom variables, with custom taking precedence
|
89
|
+
@cached_variables = collect.deep_merge(custom_vars)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Public aliases for collection methods (expected by tests)
|
93
|
+
def collect_session_variables
|
94
|
+
get_category(:session)
|
95
|
+
end
|
96
|
+
|
97
|
+
def collect_project_variables
|
98
|
+
get_category(:project)
|
99
|
+
end
|
100
|
+
|
101
|
+
def collect_git_variables
|
102
|
+
get_category(:git)
|
103
|
+
end
|
104
|
+
|
105
|
+
def collect_environment_variables
|
106
|
+
get_category(:environment)
|
107
|
+
end
|
108
|
+
|
109
|
+
def collect_user_variables
|
110
|
+
get_category(:user)
|
111
|
+
end
|
112
|
+
|
113
|
+
def collect_timestamp_variables
|
114
|
+
get_category(:timestamp)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Detect Ruby version
|
118
|
+
def detect_ruby_version
|
119
|
+
return "Unknown Ruby version" if RUBY_VERSION.nil?
|
120
|
+
|
121
|
+
RUBY_VERSION
|
122
|
+
rescue StandardError => e
|
123
|
+
"Unknown Ruby version: #{e.message}"
|
124
|
+
end
|
125
|
+
|
126
|
+
# Detect Rails version if available
|
127
|
+
def detect_rails_version
|
128
|
+
return nil unless rails_available?
|
129
|
+
|
130
|
+
result = collect_rails_version
|
131
|
+
result.is_a?(Hash) && result[:version] ? result[:version] : nil
|
132
|
+
rescue StandardError
|
133
|
+
nil
|
134
|
+
end
|
135
|
+
|
136
|
+
# Detect Node.js version if available
|
137
|
+
def detect_node_version
|
138
|
+
return nil unless node_available?
|
139
|
+
|
140
|
+
result = collect_node_version
|
141
|
+
result.is_a?(Hash) && result[:version] ? result[:version] : nil
|
142
|
+
rescue StandardError
|
143
|
+
nil
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
# Validate collected template variables
|
149
|
+
# Ensures all variable categories contain expected data types
|
150
|
+
def validate_collected_variables(variables)
|
151
|
+
return variables unless variables.is_a?(Hash)
|
152
|
+
|
153
|
+
variables.each do |category, data|
|
154
|
+
next unless data.is_a?(Hash)
|
155
|
+
|
156
|
+
# Validate each variable can be safely used in templates
|
157
|
+
data.each do |key, value|
|
158
|
+
next if value.nil?
|
159
|
+
|
160
|
+
# Ensure values are template-safe (can be converted to strings)
|
161
|
+
Sxn.logger&.warn("Template variable #{category}.#{key} cannot be safely stringified: #{value.class}") unless value.respond_to?(:to_s)
|
162
|
+
|
163
|
+
# Check for potentially problematic objects
|
164
|
+
if value.is_a?(Proc) || value.is_a?(Method)
|
165
|
+
Sxn.logger&.warn("Template variable #{category}.#{key} contains executable code - security risk")
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
variables
|
171
|
+
rescue StandardError => e
|
172
|
+
Sxn.logger&.error("Template variable validation failed: #{e.message}")
|
173
|
+
variables # Return variables anyway, but log the issue
|
174
|
+
end
|
175
|
+
|
176
|
+
# Collect session-related variables
|
177
|
+
def _collect_session_variables
|
178
|
+
return {} unless @session
|
179
|
+
|
180
|
+
session_vars = {
|
181
|
+
name: @session.name,
|
182
|
+
path: @session.path.to_s,
|
183
|
+
created_at: format_timestamp(@session.created_at),
|
184
|
+
updated_at: format_timestamp(@session.updated_at),
|
185
|
+
status: @session.status
|
186
|
+
}
|
187
|
+
|
188
|
+
# Add optional session fields if present
|
189
|
+
session_vars[:linear_task] = @session.linear_task if @session.respond_to?(:linear_task) && @session.linear_task
|
190
|
+
session_vars[:description] = @session.description if @session.respond_to?(:description) && @session.description
|
191
|
+
session_vars[:projects] = @session.projects if @session.respond_to?(:projects) && @session.projects
|
192
|
+
session_vars[:tags] = @session.tags if @session.respond_to?(:tags) && @session.tags
|
193
|
+
|
194
|
+
# Add worktree information if available
|
195
|
+
if @session.respond_to?(:worktrees) && @session.worktrees
|
196
|
+
session_vars[:worktrees] = @session.worktrees.map do |worktree|
|
197
|
+
{
|
198
|
+
name: worktree.name,
|
199
|
+
path: worktree.path.to_s,
|
200
|
+
branch: worktree.branch,
|
201
|
+
created_at: format_timestamp(worktree.created_at)
|
202
|
+
}
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
session_vars
|
207
|
+
rescue StandardError => e
|
208
|
+
{ error: "Failed to collect session variables: #{e.message}" }
|
209
|
+
end
|
210
|
+
|
211
|
+
# Collect git repository variables
|
212
|
+
def _collect_git_variables
|
213
|
+
# Determine git directory - prefer project path, fall back to session path
|
214
|
+
git_dir = find_git_directory
|
215
|
+
|
216
|
+
# Return with available: false if no git directory found
|
217
|
+
return { available: false } unless git_dir
|
218
|
+
|
219
|
+
git_vars = {}
|
220
|
+
|
221
|
+
git_vars[:available] = true
|
222
|
+
# Collect git information with timeout protection
|
223
|
+
git_vars.merge!(collect_git_branch_info(git_dir))
|
224
|
+
|
225
|
+
# Collect author info and structure it properly for templates
|
226
|
+
author_info = collect_git_author_info(git_dir)
|
227
|
+
if author_info[:author_name] || author_info[:author_email]
|
228
|
+
git_vars[:author] = {
|
229
|
+
name: author_info[:author_name],
|
230
|
+
email: author_info[:author_email]
|
231
|
+
}
|
232
|
+
end
|
233
|
+
|
234
|
+
git_vars.merge!(collect_git_commit_info(git_dir))
|
235
|
+
git_vars.merge!(collect_git_remote_info(git_dir))
|
236
|
+
git_vars.merge!(collect_git_status_info(git_dir))
|
237
|
+
|
238
|
+
git_vars
|
239
|
+
rescue StandardError => e
|
240
|
+
{ available: false, error: "Failed to collect git variables: #{e.message}" }
|
241
|
+
end
|
242
|
+
|
243
|
+
# Collect project-related variables
|
244
|
+
def _collect_project_variables
|
245
|
+
return {} unless @project
|
246
|
+
|
247
|
+
project_vars = {
|
248
|
+
name: @project.name,
|
249
|
+
path: @project.path.to_s,
|
250
|
+
type: detect_project_type(@project.path)
|
251
|
+
}
|
252
|
+
|
253
|
+
# Add language-specific information
|
254
|
+
case project_vars[:type]
|
255
|
+
when "rails"
|
256
|
+
project_vars.merge!(collect_rails_project_info)
|
257
|
+
when "javascript", "typescript", "nodejs"
|
258
|
+
project_vars.merge!(collect_js_project_info)
|
259
|
+
when "ruby"
|
260
|
+
project_vars.merge!(collect_ruby_project_info)
|
261
|
+
end
|
262
|
+
|
263
|
+
project_vars
|
264
|
+
rescue StandardError => e
|
265
|
+
{ error: "Failed to collect project variables: #{e.message}" }
|
266
|
+
end
|
267
|
+
|
268
|
+
# Collect environment variables
|
269
|
+
def _collect_environment_variables
|
270
|
+
env_vars = {}
|
271
|
+
|
272
|
+
# Ruby environment
|
273
|
+
env_vars[:ruby] = {
|
274
|
+
version: RUBY_VERSION,
|
275
|
+
platform: RUBY_PLATFORM,
|
276
|
+
patchlevel: RUBY_PATCHLEVEL
|
277
|
+
}
|
278
|
+
|
279
|
+
# Rails information if in Rails project
|
280
|
+
env_vars[:rails] = collect_rails_version if rails_available?
|
281
|
+
|
282
|
+
# Node.js information if available
|
283
|
+
env_vars[:node] = collect_node_version if node_available?
|
284
|
+
|
285
|
+
# Database information
|
286
|
+
env_vars[:database] = collect_database_info
|
287
|
+
|
288
|
+
# Operating system
|
289
|
+
env_vars[:os] = {
|
290
|
+
name: RbConfig::CONFIG["host_os"],
|
291
|
+
arch: RbConfig::CONFIG["host_cpu"]
|
292
|
+
}
|
293
|
+
|
294
|
+
env_vars
|
295
|
+
rescue StandardError => e
|
296
|
+
{ error: "Failed to collect environment variables: #{e.message}" }
|
297
|
+
end
|
298
|
+
|
299
|
+
# Collect user preferences and configuration
|
300
|
+
def _collect_user_variables
|
301
|
+
user_vars = {}
|
302
|
+
|
303
|
+
# Git user configuration
|
304
|
+
user_vars.merge!(collect_git_user_config)
|
305
|
+
|
306
|
+
# User preferences from sxn config
|
307
|
+
if @config
|
308
|
+
user_vars[:editor] = @config.default_editor if @config.respond_to?(:default_editor)
|
309
|
+
user_vars[:preferences] = @config.user_preferences if @config.respond_to?(:user_preferences)
|
310
|
+
end
|
311
|
+
|
312
|
+
# System user information
|
313
|
+
user_vars[:username] = ENV["USER"] || ENV.fetch("USERNAME", nil)
|
314
|
+
user_vars[:home] = Dir.home
|
315
|
+
|
316
|
+
user_vars.compact
|
317
|
+
rescue StandardError => e
|
318
|
+
{ error: "Failed to collect user variables: #{e.message}" }
|
319
|
+
end
|
320
|
+
|
321
|
+
# Collect timestamp variables for template generation
|
322
|
+
def _collect_timestamp_variables
|
323
|
+
now = Time.now
|
324
|
+
{
|
325
|
+
now: format_timestamp(now),
|
326
|
+
today: now.strftime("%Y-%m-%d"),
|
327
|
+
year: now.year,
|
328
|
+
month: now.month,
|
329
|
+
day: now.day,
|
330
|
+
iso8601: now.iso8601,
|
331
|
+
epoch: now.to_i
|
332
|
+
}
|
333
|
+
end
|
334
|
+
|
335
|
+
# Format timestamp for template display
|
336
|
+
def format_timestamp(timestamp)
|
337
|
+
return nil unless timestamp
|
338
|
+
|
339
|
+
timestamp = Time.parse(timestamp.to_s) unless timestamp.is_a?(Time)
|
340
|
+
timestamp.strftime("%Y-%m-%d %H:%M:%S %Z")
|
341
|
+
rescue StandardError
|
342
|
+
timestamp.to_s
|
343
|
+
end
|
344
|
+
|
345
|
+
# Find the git directory for the current context
|
346
|
+
def find_git_directory
|
347
|
+
candidates = []
|
348
|
+
|
349
|
+
# Try project path first
|
350
|
+
candidates << @project.path if @project&.path
|
351
|
+
|
352
|
+
# Try session path
|
353
|
+
candidates << @session.path if @session&.path
|
354
|
+
|
355
|
+
# Try current directory
|
356
|
+
candidates << Pathname.pwd
|
357
|
+
|
358
|
+
candidates.find { |path| git_repository?(path) }
|
359
|
+
end
|
360
|
+
|
361
|
+
# Check if directory is a git repository
|
362
|
+
def git_repository?(path)
|
363
|
+
return false unless path
|
364
|
+
|
365
|
+
path_str = path.to_s
|
366
|
+
|
367
|
+
# First check for .git directory
|
368
|
+
return true if File.exist?(File.join(path_str, ".git"))
|
369
|
+
|
370
|
+
# Then check with git command - if it fails (returns nil), not a git repo
|
371
|
+
execute_git_command(path, "rev-parse", "--git-dir") do |output|
|
372
|
+
return true if output && !output.strip.empty?
|
373
|
+
end
|
374
|
+
|
375
|
+
false
|
376
|
+
end
|
377
|
+
|
378
|
+
# Collect git branch information
|
379
|
+
def collect_git_branch_info(git_dir)
|
380
|
+
branch_info = {}
|
381
|
+
|
382
|
+
# Current branch
|
383
|
+
execute_git_command(git_dir, "branch", "--show-current") do |output|
|
384
|
+
branch_info[:branch] = output.strip
|
385
|
+
end
|
386
|
+
|
387
|
+
# Remote tracking branch
|
388
|
+
execute_git_command(git_dir, "rev-parse", "--abbrev-ref", "@{upstream}") do |output|
|
389
|
+
branch_info[:upstream] = output.strip
|
390
|
+
end
|
391
|
+
|
392
|
+
# Check if working directory is clean
|
393
|
+
execute_git_command(git_dir, "status", "--porcelain") do |output|
|
394
|
+
branch_info[:clean] = output.strip.empty?
|
395
|
+
branch_info[:has_changes] = !output.strip.empty?
|
396
|
+
end
|
397
|
+
|
398
|
+
branch_info
|
399
|
+
end
|
400
|
+
|
401
|
+
# Collect git author information
|
402
|
+
def collect_git_author_info(git_dir)
|
403
|
+
author_info = {}
|
404
|
+
|
405
|
+
# Author name and email from config
|
406
|
+
execute_git_command(git_dir, "config", "user.name") do |output|
|
407
|
+
author_info[:author_name] = output.strip
|
408
|
+
end
|
409
|
+
|
410
|
+
execute_git_command(git_dir, "config", "user.email") do |output|
|
411
|
+
author_info[:author_email] = output.strip
|
412
|
+
end
|
413
|
+
|
414
|
+
author_info
|
415
|
+
end
|
416
|
+
|
417
|
+
# Collect git commit information
|
418
|
+
def collect_git_commit_info(git_dir)
|
419
|
+
commit_info = {}
|
420
|
+
|
421
|
+
# Last commit information
|
422
|
+
execute_git_command(git_dir, "log", "-1", "--format=%H|%s|%an|%ae|%ai") do |output|
|
423
|
+
parts = output.strip.split("|", 5)
|
424
|
+
if parts.length >= 4
|
425
|
+
commit_info[:last_commit] = {
|
426
|
+
sha: parts[0],
|
427
|
+
message: parts[1],
|
428
|
+
author_name: parts[2],
|
429
|
+
author_email: parts[3],
|
430
|
+
date: parts[4]
|
431
|
+
}
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
# Short SHA
|
436
|
+
execute_git_command(git_dir, "rev-parse", "--short", "HEAD") do |output|
|
437
|
+
commit_info[:short_sha] = output.strip
|
438
|
+
end
|
439
|
+
|
440
|
+
commit_info
|
441
|
+
end
|
442
|
+
|
443
|
+
# Collect git remote information
|
444
|
+
def collect_git_remote_info(git_dir)
|
445
|
+
remote_info = {}
|
446
|
+
|
447
|
+
# Default remote (usually origin)
|
448
|
+
execute_git_command(git_dir, "remote") do |output|
|
449
|
+
remotes = output.strip.split("\n")
|
450
|
+
remote_info[:remotes] = remotes
|
451
|
+
remote_info[:default_remote] = remotes.include?("origin") ? "origin" : remotes.first
|
452
|
+
end
|
453
|
+
|
454
|
+
# Remote URL
|
455
|
+
if remote_info[:default_remote]
|
456
|
+
execute_git_command(git_dir, "remote", "get-url", remote_info[:default_remote]) do |output|
|
457
|
+
remote_info[:remote_url] = output.strip
|
458
|
+
end
|
459
|
+
end
|
460
|
+
|
461
|
+
remote_info
|
462
|
+
end
|
463
|
+
|
464
|
+
# Collect git status information
|
465
|
+
def collect_git_status_info(git_dir)
|
466
|
+
status_data = {}
|
467
|
+
|
468
|
+
# Count of modified, added, deleted files
|
469
|
+
execute_git_command(git_dir, "status", "--porcelain") do |output|
|
470
|
+
lines = output.strip.split("\n").reject(&:empty?)
|
471
|
+
status_data[:modified_files] = lines.select { |line| line.start_with?(" M", "MM") }.length
|
472
|
+
status_data[:added_files] = lines.select { |line| line.start_with?("A ", "AM") }.length
|
473
|
+
status_data[:deleted_files] = lines.select { |line| line.start_with?(" D", "AD") }.length
|
474
|
+
status_data[:untracked_files] = lines.select { |line| line.start_with?("??") }.length
|
475
|
+
status_data[:total_changes] = lines.length
|
476
|
+
|
477
|
+
# Add human-readable status
|
478
|
+
status_data[:status] = if lines.any?
|
479
|
+
"Has uncommitted changes"
|
480
|
+
else
|
481
|
+
"Clean working directory"
|
482
|
+
end
|
483
|
+
end
|
484
|
+
|
485
|
+
# Return with 'status' as nested object for template compatibility
|
486
|
+
{ status: status_data }
|
487
|
+
end
|
488
|
+
|
489
|
+
# Collect git user configuration
|
490
|
+
def collect_git_user_config
|
491
|
+
config = {}
|
492
|
+
|
493
|
+
execute_git_command(nil, "config", "--global", "user.name") do |output|
|
494
|
+
config[:git_name] = output.strip
|
495
|
+
end
|
496
|
+
|
497
|
+
execute_git_command(nil, "config", "--global", "user.email") do |output|
|
498
|
+
config[:git_email] = output.strip
|
499
|
+
end
|
500
|
+
|
501
|
+
config
|
502
|
+
end
|
503
|
+
|
504
|
+
# Execute git command with timeout and error handling
|
505
|
+
def execute_git_command(directory, *args)
|
506
|
+
cmd = ["git"] + args
|
507
|
+
options = {}
|
508
|
+
options[:chdir] = directory.to_s if directory
|
509
|
+
|
510
|
+
output = nil
|
511
|
+
|
512
|
+
begin
|
513
|
+
Open3.popen3(*cmd, **options) do |stdin, stdout, _stderr, wait_thr|
|
514
|
+
stdin.close
|
515
|
+
|
516
|
+
# Wait for command with timeout
|
517
|
+
if wait_thr.join(GIT_TIMEOUT)
|
518
|
+
if wait_thr.value.success?
|
519
|
+
output = stdout.read
|
520
|
+
yield output if block_given?
|
521
|
+
end
|
522
|
+
else
|
523
|
+
begin
|
524
|
+
Process.kill("TERM", wait_thr.pid)
|
525
|
+
rescue StandardError
|
526
|
+
nil
|
527
|
+
end
|
528
|
+
return nil
|
529
|
+
end
|
530
|
+
end
|
531
|
+
rescue StandardError
|
532
|
+
# Silently ignore git command failures - templates should still work
|
533
|
+
# even if git information is unavailable
|
534
|
+
return nil
|
535
|
+
end
|
536
|
+
|
537
|
+
output
|
538
|
+
end
|
539
|
+
|
540
|
+
# Detect project type based on file patterns
|
541
|
+
def detect_project_type(project_path)
|
542
|
+
return "unknown" unless project_path
|
543
|
+
|
544
|
+
path = Pathname.new(project_path)
|
545
|
+
|
546
|
+
# Rails detection
|
547
|
+
return "rails" if (path / "Gemfile").exist? &&
|
548
|
+
(path / "config" / "application.rb").exist?
|
549
|
+
|
550
|
+
# Ruby gem detection
|
551
|
+
return "ruby" if (path / "Gemfile").exist? || Dir.glob((path / "*.gemspec").to_s).any?
|
552
|
+
|
553
|
+
# Node.js/JavaScript detection
|
554
|
+
if (path / "package.json").exist?
|
555
|
+
package_json = JSON.parse((path / "package.json").read)
|
556
|
+
return "nextjs" if package_json.dig("dependencies", "next")
|
557
|
+
return "react" if package_json.dig("dependencies", "react")
|
558
|
+
return "typescript" if (path / "tsconfig.json").exist?
|
559
|
+
|
560
|
+
return "javascript"
|
561
|
+
end
|
562
|
+
|
563
|
+
"unknown"
|
564
|
+
rescue StandardError
|
565
|
+
"unknown"
|
566
|
+
end
|
567
|
+
|
568
|
+
# Collect Rails-specific project information
|
569
|
+
def collect_rails_project_info
|
570
|
+
rails_info = {}
|
571
|
+
|
572
|
+
# Database configuration
|
573
|
+
if @project&.path && (Pathname.new(@project.path) / "config" / "database.yml").exist?
|
574
|
+
begin
|
575
|
+
require "yaml"
|
576
|
+
db_config = YAML.load_file(Pathname.new(@project.path) / "config" / "database.yml")
|
577
|
+
rails_info[:database] = {
|
578
|
+
adapter: db_config.dig("development", "adapter"),
|
579
|
+
name: db_config.dig("development", "database")
|
580
|
+
}
|
581
|
+
rescue StandardError
|
582
|
+
# Ignore database config parsing errors
|
583
|
+
end
|
584
|
+
end
|
585
|
+
|
586
|
+
rails_info
|
587
|
+
end
|
588
|
+
|
589
|
+
# Collect JavaScript/Node.js project information
|
590
|
+
def collect_js_project_info
|
591
|
+
js_info = {}
|
592
|
+
|
593
|
+
if @project&.path && (Pathname.new(@project.path) / "package.json").exist?
|
594
|
+
begin
|
595
|
+
package_json = JSON.parse((Pathname.new(@project.path) / "package.json").read)
|
596
|
+
js_info[:package_manager] = detect_package_manager
|
597
|
+
js_info[:scripts] = package_json["scripts"] || {}
|
598
|
+
js_info[:dependencies] = package_json["dependencies"]&.keys || []
|
599
|
+
js_info[:dev_dependencies] = package_json["devDependencies"]&.keys || []
|
600
|
+
rescue StandardError
|
601
|
+
# Ignore package.json parsing errors
|
602
|
+
end
|
603
|
+
end
|
604
|
+
|
605
|
+
js_info
|
606
|
+
end
|
607
|
+
|
608
|
+
# Collect Ruby project information
|
609
|
+
def collect_ruby_project_info
|
610
|
+
ruby_info = {}
|
611
|
+
|
612
|
+
if @project&.path && (Pathname.new(@project.path) / "Gemfile").exist?
|
613
|
+
# Try to detect bundler version and gems, but don't fail if we can't
|
614
|
+
begin
|
615
|
+
ruby_info[:bundler_version] = `bundle version`.strip.split.last
|
616
|
+
rescue StandardError
|
617
|
+
# Ignore bundler detection errors
|
618
|
+
end
|
619
|
+
end
|
620
|
+
|
621
|
+
ruby_info
|
622
|
+
end
|
623
|
+
|
624
|
+
# Detect package manager for Node.js projects
|
625
|
+
def detect_package_manager
|
626
|
+
return "pnpm" if @project&.path && (Pathname.new(@project.path) / "pnpm-lock.yaml").exist?
|
627
|
+
return "yarn" if @project&.path && (Pathname.new(@project.path) / "yarn.lock").exist?
|
628
|
+
return "npm" if @project&.path && (Pathname.new(@project.path) / "package-lock.json").exist?
|
629
|
+
|
630
|
+
"npm" # default
|
631
|
+
end
|
632
|
+
|
633
|
+
# Check if Rails is available in the environment
|
634
|
+
def rails_available?
|
635
|
+
result = collect_rails_version
|
636
|
+
!result.empty?
|
637
|
+
rescue StandardError
|
638
|
+
false
|
639
|
+
end
|
640
|
+
|
641
|
+
# Collect Rails version information
|
642
|
+
def collect_rails_version
|
643
|
+
require "rails"
|
644
|
+
{ version: Rails::VERSION::STRING }
|
645
|
+
rescue LoadError
|
646
|
+
{}
|
647
|
+
end
|
648
|
+
|
649
|
+
# Check if Node.js is available
|
650
|
+
def node_available?
|
651
|
+
!!system("which node > /dev/null 2>&1")
|
652
|
+
rescue StandardError
|
653
|
+
false
|
654
|
+
end
|
655
|
+
|
656
|
+
# Collect Node.js version information
|
657
|
+
def collect_node_version
|
658
|
+
return {} unless node_available?
|
659
|
+
|
660
|
+
output = `node --version 2>/dev/null`.strip
|
661
|
+
version = output.gsub(/^v/, "")
|
662
|
+
return {} if version.empty?
|
663
|
+
|
664
|
+
{ version: version }
|
665
|
+
rescue StandardError
|
666
|
+
{}
|
667
|
+
end
|
668
|
+
|
669
|
+
# Collect database information
|
670
|
+
def collect_database_info
|
671
|
+
db_info = {}
|
672
|
+
|
673
|
+
# PostgreSQL
|
674
|
+
begin
|
675
|
+
pg_version = `psql --version 2>/dev/null`.strip
|
676
|
+
db_info[:postgresql] = pg_version.split.last if pg_version
|
677
|
+
rescue StandardError
|
678
|
+
# Ignore PostgreSQL detection errors
|
679
|
+
end
|
680
|
+
|
681
|
+
# MySQL
|
682
|
+
begin
|
683
|
+
mysql_version = `mysql --version 2>/dev/null`.strip
|
684
|
+
db_info[:mysql] = mysql_version.split.find { |part| part.match(/\d+\.\d+/) } if mysql_version
|
685
|
+
rescue StandardError
|
686
|
+
# Ignore MySQL detection errors
|
687
|
+
end
|
688
|
+
|
689
|
+
# SQLite
|
690
|
+
begin
|
691
|
+
sqlite_version = `sqlite3 --version 2>/dev/null`.strip
|
692
|
+
db_info[:sqlite3] = sqlite_version.split.first if sqlite_version
|
693
|
+
rescue StandardError
|
694
|
+
# Ignore SQLite detection errors
|
695
|
+
end
|
696
|
+
|
697
|
+
db_info
|
698
|
+
end
|
699
|
+
|
700
|
+
# Alias for collect to match expected interface
|
701
|
+
alias collect_all_variables collect
|
702
|
+
end
|
703
|
+
end
|
704
|
+
end
|
705
|
+
|
706
|
+
# Add deep_merge helper method to Hash class if not already present
|
707
|
+
class Hash
|
708
|
+
def deep_merge(other_hash)
|
709
|
+
merge(other_hash) do |_key, oldval, newval|
|
710
|
+
oldval.is_a?(Hash) && newval.is_a?(Hash) ? oldval.deep_merge(newval) : newval
|
711
|
+
end
|
712
|
+
end
|
713
|
+
end
|