belt 0.1.0 → 0.1.2

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.
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+ require 'fileutils'
6
+ require_relative '../route_dsl'
7
+ require_relative '../table_inference'
8
+ require_relative 'routes_command/schema_loader'
9
+ require_relative 'routes_command/route_inference'
10
+
11
+ module Belt
12
+ module CLI
13
+ class RoutesCommand
14
+ include SchemaLoader
15
+ include RouteInference
16
+
17
+ def self.run(args)
18
+ new(args).run
19
+ end
20
+
21
+ def initialize(args)
22
+ @options = {}
23
+ parse_options(args)
24
+ end
25
+
26
+ def run
27
+ routes_file = find_routes_file
28
+ abort 'Error: No routes file found. Expected infrastructure/routes.tf.rb' unless routes_file
29
+
30
+ dsl = load_routes(routes_file)
31
+ @table_inference = TableInference.new(@options[:tables_file])
32
+ routes = collect_routes(dsl)
33
+ routes = apply_grep(routes) if @options[:grep]
34
+
35
+ warn 'Warning: --output-dir has no effect without --namespace' if @options[:output_dir] && !@options[:namespace]
36
+
37
+ if @options[:namespace]
38
+ output_ruby(routes, @options[:namespace], routes_file)
39
+ elsif @options[:format] == 'json'
40
+ output = { routes: routes }
41
+ models = load_schema_models(routes_file)
42
+ output[:models] = models if models.any?
43
+ puts JSON.pretty_generate(output)
44
+ else
45
+ output_concise(routes)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def parse_options(args)
52
+ OptionParser.new do |opts|
53
+ opts.banner = 'Usage: belt routes [options]'
54
+
55
+ opts.on('-g', '--grep PATTERN', 'Filter routes matching pattern') do |pattern|
56
+ @options[:grep] = pattern
57
+ end
58
+
59
+ opts.on('-f', '--format FORMAT', 'Output format: concise (default), json') do |format|
60
+ @options[:format] = format
61
+ end
62
+
63
+ opts.on('--namespace NAMESPACE', 'Generate Ruby route files for NAMESPACE (or "all")') do |ns|
64
+ @options[:namespace] = ns
65
+ end
66
+
67
+ opts.on('--output-dir DIR', 'Output directory for generated files') do |dir|
68
+ @options[:output_dir] = dir
69
+ end
70
+
71
+ opts.on('--schema FILE', 'Path to schema.tf.rb for model definitions') do |file|
72
+ @options[:schema_file] = file
73
+ end
74
+
75
+ opts.on('--tables-file FILE', 'Path to Terraform file with DynamoDB table definitions') do |file|
76
+ @options[:tables_file] = file
77
+ end
78
+
79
+ opts.on('-h', '--help', 'Show this help') do
80
+ puts opts
81
+ exit
82
+ end
83
+ end.parse!(args)
84
+ end
85
+
86
+ def find_routes_file
87
+ path = 'infrastructure/routes.tf.rb'
88
+ File.exist?(path) ? path : nil
89
+ end
90
+
91
+ def load_routes(file)
92
+ # Reset schema builder for clean state
93
+ Belt.instance_variable_set(:@application, nil)
94
+
95
+ content = File.read(file)
96
+ if content.include?('Belt.application.routes.draw')
97
+ binding_context = binding
98
+ eval(content, binding_context, file) # rubocop:disable Security/Eval
99
+ else
100
+ Belt::RouteDSL.load_from_file(file)
101
+ end
102
+ end
103
+
104
+ def collect_routes(dsl)
105
+ routes = []
106
+ dsl.api_gateways.each do |gateway|
107
+ gateway.routes.each do |route|
108
+ routes << build_route_hash(route, gateway)
109
+ end
110
+ end
111
+ routes.sort_by { |r| route_specificity(r[:path], r[:verb]) }
112
+ end
113
+
114
+ def build_route_hash(route, gateway)
115
+ hash = {
116
+ name: extract_route_name(route.path),
117
+ verb: route.method,
118
+ path: normalize_path(route.path),
119
+ gateway: gateway.name,
120
+ lambda: route.lambda.to_s,
121
+ controller: infer_controller(route, gateway),
122
+ action: infer_action(route, gateway),
123
+ auth: route.auth.to_s,
124
+ tables: get_route_tables(route),
125
+ request_model: route.request_model.to_s,
126
+ response_model: route.response_model.to_s
127
+ }
128
+ rc = route.response_context.to_s
129
+ hash[:response_context] = rc unless rc.empty?
130
+ hash
131
+ end
132
+
133
+ def get_route_tables(route)
134
+ if route.tables.any?
135
+ route.tables.map(&:to_s)
136
+ else
137
+ @table_inference.infer_tables_from_route(route)
138
+ end
139
+ end
140
+
141
+ def extract_route_name(path)
142
+ segments = path.split('/').reject(&:empty?)
143
+ return 'root' if segments.empty?
144
+
145
+ segments.reject { |s| s.start_with?('{', ':') }
146
+ .map { |s| s.gsub('-', '_') }
147
+ .join('_')
148
+ end
149
+
150
+ def output_ruby(routes, namespace, _routes_file)
151
+ output_dir = @options[:output_dir] || File.join(Belt.root, 'lambda/lib/routes')
152
+ FileUtils.mkdir_p(output_dir)
153
+ puts "Writing to #{output_dir}/:"
154
+
155
+ if namespace == 'all'
156
+ generate_all_manifests(routes, output_dir)
157
+ else
158
+ # Generate gateway-based manifest (all routes for this gateway)
159
+ filtered = routes.select { |r| r[:gateway] == namespace }
160
+ # Fall back to lambda-based if no gateway match
161
+ filtered = routes.select { |r| r[:lambda] == namespace } if filtered.empty?
162
+ if filtered.empty?
163
+ warn "No routes found for namespace '#{namespace}' - skipping"
164
+ return
165
+ end
166
+ write_ruby_manifest(filtered, namespace, output_dir)
167
+ end
168
+ end
169
+
170
+ def generate_all_manifests(routes, output_dir)
171
+ # Gateway-based manifests (primary — used by main Lambda entry points)
172
+ by_gateway = routes.group_by { |r| r[:gateway] }
173
+ by_gateway.each { |gw, gw_routes| write_ruby_manifest(gw_routes, gw, output_dir) }
174
+
175
+ # Scoped lambda manifests (where lambda != gateway — separate Lambda functions)
176
+ scoped = routes.reject { |r| r[:lambda] == r[:gateway] }
177
+ by_lambda = scoped.group_by { |r| r[:lambda] }
178
+ by_lambda.each { |lam, lam_routes| write_ruby_manifest(lam_routes, lam, output_dir) }
179
+ end
180
+
181
+ def write_ruby_manifest(routes, name, output_dir)
182
+ output_file = File.join(output_dir, "#{name}_routes.rb")
183
+ content = generate_ruby_content(routes, name)
184
+ File.write(output_file, content)
185
+ puts " ✅ #{name}_routes.rb (#{routes.length} routes)"
186
+ end
187
+
188
+ def generate_ruby_content(routes, namespace)
189
+ constant_name = namespace.upcase
190
+ lines = [
191
+ '# frozen_string_literal: true',
192
+ '',
193
+ "# Auto-generated by: belt routes --namespace #{namespace}",
194
+ '# Do not edit manually',
195
+ '',
196
+ 'module Routes',
197
+ " #{constant_name} = ["
198
+ ]
199
+
200
+ routes.each_with_index do |route, index|
201
+ lines << ' {'
202
+ lines << " verb: #{route[:verb].inspect},"
203
+ lines << " path: #{route[:path].inspect},"
204
+ lines << " gateway: #{route[:gateway].inspect},"
205
+ lines << " lambda: #{route[:lambda].inspect},"
206
+ lines << " controller: #{route[:controller].inspect},"
207
+ lines << " action: #{route[:action].inspect},"
208
+ lines << " auth: #{route[:auth].inspect},"
209
+ tables_syms = route[:tables].map { |t| ":#{t}" }.join(', ')
210
+ lines << " tables: [#{tables_syms}]"
211
+ lines << " }#{',' if index < routes.length - 1}"
212
+ end
213
+
214
+ lines << ' ].freeze'
215
+ lines << 'end'
216
+ lines << ''
217
+ lines.join("\n")
218
+ end
219
+
220
+ def normalize_path(path)
221
+ path = "/#{path}" unless path.start_with?('/')
222
+ normalized = path.gsub(%r{/([a-zA-Z_][a-zA-Z0-9_]*?)/:id(/|$)}) do
223
+ resource = ::Regexp.last_match(1)
224
+ trailing = ::Regexp.last_match(2)
225
+ singular = singularize(resource)
226
+ "/#{resource}/{#{singular}_id}#{trailing}"
227
+ end
228
+ normalized.gsub(/:([a-zA-Z_][a-zA-Z0-9_]*)/) { "{#{::Regexp.last_match(1)}}" }
229
+ end
230
+
231
+ def apply_grep(routes)
232
+ pattern = Regexp.new(@options[:grep], Regexp::IGNORECASE)
233
+ routes.select do |r|
234
+ r[:path].match?(pattern) ||
235
+ r[:gateway].to_s.match?(pattern) ||
236
+ r[:lambda].match?(pattern) ||
237
+ r[:verb].match?(pattern) ||
238
+ r[:controller].match?(pattern) ||
239
+ r[:action].match?(pattern)
240
+ end
241
+ end
242
+
243
+ def output_concise(routes)
244
+ return puts('No routes defined.') if routes.empty?
245
+
246
+ multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
247
+ verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
248
+ path_w = [routes.map { |r| r[:path].length }.max, 4].max
249
+
250
+ if multi_gateway
251
+ output_concise_multi_gateway(routes, verb_w, path_w)
252
+ else
253
+ output_concise_single_gateway(routes, verb_w, path_w)
254
+ end
255
+ end
256
+
257
+ def output_concise_multi_gateway(routes, verb_w, path_w)
258
+ gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
259
+ lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
260
+
261
+ header = "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} " \
262
+ "#{'GATEWAY'.ljust(gw_w)} #{'LAMBDA'.ljust(lam_w)} CONTROLLER#ACTION"
263
+ puts header
264
+ puts '-' * (verb_w + path_w + gw_w + lam_w + 20)
265
+
266
+ routes.each do |r|
267
+ line = "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} " \
268
+ "#{r[:gateway].to_s.ljust(gw_w)} #{r[:lambda].ljust(lam_w)} " \
269
+ "#{r[:controller]}##{r[:action]}"
270
+ puts line
271
+ end
272
+ end
273
+
274
+ def output_concise_single_gateway(routes, verb_w, path_w)
275
+ puts "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} CONTROLLER#ACTION"
276
+ puts '-' * (verb_w + path_w + 30)
277
+
278
+ routes.each do |r|
279
+ puts "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} #{r[:controller]}##{r[:action]}"
280
+ end
281
+ end
282
+
283
+ def route_specificity(path, verb)
284
+ segments = path.split('/').reject(&:empty?)
285
+ param_count = segments.count { |s| s.start_with?('{') }
286
+ segment_count = segments.length
287
+ [param_count, -segment_count, path, verb_order(verb)]
288
+ end
289
+
290
+ def verb_order(verb)
291
+ { 'GET' => 0, 'POST' => 1, 'PUT' => 2, 'PATCH' => 3, 'DELETE' => 4 }[verb] || 99
292
+ end
293
+
294
+ def singularize(word)
295
+ if word.end_with?('ies')
296
+ "#{word[0..-4]}y"
297
+ elsif word.end_with?('ses') || word.end_with?('xes') || word.end_with?('zes')
298
+ word[0..-3]
299
+ elsif word.end_with?('s') && !word.end_with?('ss')
300
+ word[0..-2]
301
+ else
302
+ word
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -59,8 +59,8 @@ module Belt
59
59
  schema_content = File.read(SCHEMA_FILE)
60
60
 
61
61
  # Replace DSL wrapper with direct parser call
62
- # TerraDispatch.schema.draw do ... end → parser.instance_eval do ... end
63
- inner = schema_content.sub(/\ATerraDispatch\.schema\.draw do\n?/, '').sub(/\nend\s*\z/, '')
62
+ # Belt.application.schema.draw do ... end → parser.instance_eval do ... end
63
+ inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\nend\s*\z/, '')
64
64
  parser.instance_eval(inner, SCHEMA_FILE)
65
65
  parser.models
66
66
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'optparse'
5
+
6
+ module Belt
7
+ module CLI
8
+ class TasksCommand
9
+ def self.run(args)
10
+ new(args).run
11
+ end
12
+
13
+ # Check if a given command name looks like a rake task that exists
14
+ def self.rake_task?(name)
15
+ return false unless name.include?(':') || name.match?(/\A[a-z_]+\z/)
16
+ return false unless File.exist?('Rakefile') || File.exist?('rakefile') || File.exist?('Rakefile.rb')
17
+
18
+ # Only treat colon-namespaced commands as potential rake tasks to avoid
19
+ # ambiguity with belt's own commands
20
+ name.include?(':')
21
+ end
22
+
23
+ # Invoke a specific rake task by name
24
+ def self.invoke(task_name, args)
25
+ unless File.exist?('Rakefile') || File.exist?('rakefile') || File.exist?('Rakefile.rb')
26
+ abort "Error: No Rakefile found. Cannot run task '#{task_name}'."
27
+ end
28
+
29
+ cmd = ['bundle', 'exec', 'rake', task_name] + args
30
+ exec(*cmd)
31
+ end
32
+
33
+ def initialize(args)
34
+ @options = {}
35
+ parse_options(args)
36
+ end
37
+
38
+ def run
39
+ abort 'Error: No Rakefile found. Add a Rakefile to your project to discover tasks.' unless rakefile_available?
40
+
41
+ tasks = load_tasks
42
+ tasks = apply_grep(tasks) if @options[:grep]
43
+
44
+ if tasks.empty?
45
+ puts 'No rake tasks found.'
46
+ else
47
+ output_tasks(tasks)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def parse_options(args)
54
+ OptionParser.new do |opts|
55
+ opts.banner = 'Usage: belt tasks [options]'
56
+
57
+ opts.on('-g', '--grep PATTERN', 'Filter tasks matching pattern') do |pattern|
58
+ @options[:grep] = pattern
59
+ end
60
+
61
+ opts.on('-a', '--all', 'Show all tasks (including those without descriptions)') do
62
+ @options[:all] = true
63
+ end
64
+
65
+ opts.on('-h', '--help', 'Show this help') do
66
+ puts opts
67
+ exit
68
+ end
69
+ end.parse!(args)
70
+ end
71
+
72
+ def rakefile_available?
73
+ File.exist?('Rakefile') || File.exist?('rakefile') || File.exist?('Rakefile.rb')
74
+ end
75
+
76
+ def load_tasks
77
+ cmd = @options[:all] ? %w[bundle exec rake -T -A] : %w[bundle exec rake -T]
78
+ output, status = Open3.capture2(*cmd, err: File::NULL)
79
+
80
+ abort 'Error: Failed to load rake tasks. Ensure `bundle install` has been run.' unless status.success?
81
+
82
+ parse_task_output(output)
83
+ end
84
+
85
+ def parse_task_output(output)
86
+ output.lines.filter_map do |line|
87
+ match = line.match(/^rake\s+(\S+)\s*#\s*(.*)$/)
88
+ next unless match
89
+
90
+ { name: match[1], description: match[2].strip }
91
+ end
92
+ end
93
+
94
+ def apply_grep(tasks)
95
+ pattern = Regexp.new(@options[:grep], Regexp::IGNORECASE)
96
+ tasks.select do |t|
97
+ t[:name].match?(pattern) || t[:description].match?(pattern)
98
+ end
99
+ end
100
+
101
+ def output_tasks(tasks)
102
+ name_w = [tasks.map { |t| t[:name].length }.max, 4].max
103
+
104
+ tasks.each do |t|
105
+ puts "belt #{t[:name].ljust(name_w)} # #{t[:description]}"
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
data/lib/belt/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'version'
4
+ require_relative 'root'
4
5
  require_relative 'cli/env_resolver'
5
6
  require_relative 'cli/new_command'
6
7
  require_relative 'cli/generate_command'
@@ -10,13 +11,18 @@ require_relative 'cli/frontend_deploy_command'
10
11
  require_relative 'cli/views_command'
11
12
  require_relative 'cli/setup_command'
12
13
  require_relative 'cli/terraform_command'
14
+ require_relative 'cli/routes_command'
15
+ require_relative 'cli/tasks_command'
16
+ require_relative 'cli/console_command'
13
17
 
14
18
  module Belt
15
19
  module CLI
16
- COMMANDS = {
20
+ COMMANDS_DEFINITION = {
17
21
  'new' => Belt::CLI::NewCommand,
18
- 'generate' => Belt::CLI::GenerateCommand,
19
- 'g' => Belt::CLI::GenerateCommand,
22
+ %w[generate g] => Belt::CLI::GenerateCommand,
23
+ 'routes' => Belt::CLI::RoutesCommand,
24
+ %w[console c] => Belt::CLI::ConsoleCommand,
25
+ %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
20
26
  'setup' => Belt::CLI::SetupCommand,
21
27
  'deploy' => lambda { |args|
22
28
  subcommand = args.shift
@@ -27,10 +33,13 @@ module Belt
27
33
  exit 1
28
34
  end
29
35
  },
30
- '--version' => ->(_args) { puts "Belt #{Belt::VERSION}" },
31
- '-v' => ->(_args) { puts "Belt #{Belt::VERSION}" }
36
+ %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
32
37
  }.freeze
33
38
 
39
+ COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
40
+ Array(keys).each { |key| hash[key] = handler }
41
+ end.freeze
42
+
34
43
  TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
35
44
 
36
45
  def self.start(args)
@@ -46,7 +55,10 @@ module Belt
46
55
 
47
56
  handler = COMMANDS[command]
48
57
 
58
+ # If no built-in command matched, try running it as a rake task
49
59
  if handler.nil?
60
+ return Belt::CLI::TasksCommand.invoke(command, args) if Belt::CLI::TasksCommand.rake_task?(command)
61
+
50
62
  puts "Unknown command: #{command}\n\n#{usage}"
51
63
  exit 1
52
64
  end
@@ -68,6 +80,11 @@ module Belt
68
80
  generate frontend <react|vue|svelte> Scaffold a frontend app
69
81
  generate views <resource> [fields...] Generate React pages for REST actions
70
82
  generate environment <name> Create a new environment
83
+ routes [-g PATTERN] [-f json] Show route definitions
84
+ console Start an interactive console (IRB)
85
+ c Alias for console
86
+ tasks [-g PATTERN] [-a] List available rake tasks
87
+ -T [-g PATTERN] [-a] Alias for tasks
71
88
  setup state Create/select S3 state bucket
72
89
  setup tables <env> Generate DynamoDB tables from schema
73
90
  setup frontend <env> Generate S3 + CloudFront infrastructure
@@ -79,6 +96,10 @@ module Belt
79
96
  output <env> terraform output for environment
80
97
  --version Show Belt version
81
98
 
99
+ Rake Tasks:
100
+ Any rake task from your Gemfile dependencies can be run directly:
101
+ belt lambda:build_layer Run a rake task by name
102
+
82
103
  Environment:
83
104
  Set BELT_ENV to skip the <env> argument:
84
105
  export BELT_ENV=wups
@@ -92,6 +113,8 @@ module Belt
92
113
  belt setup frontend wups
93
114
  belt deploy frontend wups
94
115
  belt apply wups
116
+ belt tasks # list all rake tasks
117
+ belt lambda:build_layer # run a rake task directly
95
118
  USAGE
96
119
  end
97
120
  end
data/lib/belt/root.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ def self.root
5
+ @root ||= detect_root
6
+ end
7
+
8
+ def self.root=(path)
9
+ @root = path
10
+ end
11
+
12
+ def self.detect_root
13
+ dir = Dir.pwd
14
+ loop do
15
+ return dir if File.exist?(File.join(dir, 'infrastructure/routes.tf.rb'))
16
+
17
+ parent = File.dirname(dir)
18
+ break if parent == dir
19
+
20
+ dir = parent
21
+ end
22
+ Dir.pwd
23
+ end
24
+
25
+ private_class_method :detect_root
26
+ end