belt 0.1.12 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 892f04b0175a10793ba63f3cf65182b3d3e380ab75446984004283b8b8cf7377
4
- data.tar.gz: 701534fd954f5d444783d3adb085d0582c4f9d1b92bf4518249e677add64c1b7
3
+ metadata.gz: dd7335772d9025878073df619babf32aff170b29d620420382c5f73a8eebff30
4
+ data.tar.gz: 8c0379aee3988b267d690d639c986bb827b4e0d403bf358b04ef86c5a64a4d60
5
5
  SHA512:
6
- metadata.gz: '0682c1d964357b0109baca143e7c43fb8688a375aa84af0ba3ba2c46fbf0d56932a011b8eef6a0027dcf6b289f3eb6bc4617bcecd8717812246133b107563233'
7
- data.tar.gz: 68d1e8b3a4311ca6bd3be6dbfeb48594efeb4afaf42500d27b7076e0068a8b21c8cd73a1e41a70c4ee6338e910dab96aaa8fdba09683c922027574141049a899
6
+ metadata.gz: 17a2e1f23c328abc880c19228234cac3fd6d4af78abbf704b2abbdc703990312115cd39ff12e492fa671d28a79cc8276a681778071fa094ab6dfdc7931360889
7
+ data.tar.gz: bc78f0e686cae19a4806ad21b3dbf9fc52adb768854e915dcbef155c593bf0317f771eb260478125ce10717e83e7dfa1e4230ebb9c123ad59bfcafb4578c3ed9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.13
4
+
5
+ ### Generator Extension API
6
+
7
+ - Gems can now register generators by placing a file at `lib/belt/generators/<name>_generator.rb`.
8
+ - Discovered automatically via `Gem.loaded_specs` — no manual registration needed.
9
+ - Available as `belt generate <name>` / `belt destroy <name>`.
10
+ - Generator contract: `.run(args)` (required), `.destroy(args)` (optional), `.description` (optional).
11
+ - Help output includes gem-provided generators under "Gem Generators" section.
12
+
3
13
  ## Unreleased
4
14
 
5
15
  ## 0.1.11
@@ -6,8 +6,8 @@ module Belt
6
6
  # Detects the primary namespace from the route definitions.
7
7
  # Used by generators to determine controller directory and route file naming.
8
8
  def detect_namespace
9
- routes_file = 'infrastructure/routes.tf.rb'
10
- if File.exist?(routes_file)
9
+ routes_file = find_routes_file_path
10
+ if routes_file && File.exist?(routes_file)
11
11
  match = File.read(routes_file).match(/namespace :(\w+)/)
12
12
  return match[1] if match
13
13
  end
@@ -39,6 +39,18 @@ module Belt
39
39
  def s3_safe_name(name)
40
40
  name.to_s.downcase.tr('_', '-')
41
41
  end
42
+
43
+ # Finds routes.tf.rb checking config/ first, then infrastructure/ (legacy).
44
+ def find_routes_file_path
45
+ candidates = ['config/routes.tf.rb', 'infrastructure/routes.tf.rb']
46
+ candidates.find { |f| File.exist?(f) }
47
+ end
48
+
49
+ # Finds schema.tf.rb checking config/ first, then infrastructure/ (legacy).
50
+ def find_schema_file_path
51
+ candidates = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb']
52
+ candidates.find { |f| File.exist?(f) }
53
+ end
42
54
  end
43
55
  end
44
56
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require_relative 'app_detection'
5
+ require_relative 'generator_registry'
5
6
  require_relative 'tables_command'
6
7
  require_relative '../inflector'
7
8
 
@@ -20,13 +21,22 @@ module Belt
20
21
  exit 0
21
22
  end
22
23
 
23
- unless GENERATORS.include?(generator)
24
+ unless GENERATORS.include?(generator) || GeneratorRegistry.generator_names.include?(generator)
24
25
  puts "Unknown generator: '#{generator}'"
25
- puts "Available: #{GENERATORS.uniq.join(', ')}"
26
+ puts "Available: #{(GENERATORS + GeneratorRegistry.generator_names).uniq.join(', ')}"
26
27
  puts "\nRun 'belt destroy --help' for usage information."
27
28
  exit 1
28
29
  end
29
30
 
31
+ # Delegate to gem-provided generator if not a built-in
32
+ unless GENERATORS.include?(generator)
33
+ klass = GeneratorRegistry.find(generator)
34
+ return klass.destroy(args) if klass.respond_to?(:destroy)
35
+
36
+ puts "Generator '#{generator}' does not support destroy."
37
+ exit 1
38
+ end
39
+
30
40
  # Normalize: resource is an alias for scaffold
31
41
  generator = 'scaffold' if generator == 'resource'
32
42
 
@@ -205,8 +215,8 @@ module Belt
205
215
  end
206
216
 
207
217
  def remove_routes
208
- routes_file = 'infrastructure/routes.tf.rb'
209
- return unless File.exist?(routes_file)
218
+ routes_file = find_routes_file_path
219
+ return unless routes_file && File.exist?(routes_file)
210
220
 
211
221
  content = File.read(routes_file)
212
222
  original = content.dup
@@ -245,8 +255,8 @@ module Belt
245
255
  end
246
256
 
247
257
  def remove_schema
248
- schema_file = 'infrastructure/schema.tf.rb'
249
- return unless File.exist?(schema_file)
258
+ schema_file = find_schema_file_path
259
+ return unless schema_file && File.exist?(schema_file)
250
260
 
251
261
  content = File.read(schema_file)
252
262
  original = content.dup
@@ -181,7 +181,7 @@ module Belt
181
181
  return str if str.empty?
182
182
  return str unless str.match?(/[\s\#"'\\$`]/)
183
183
 
184
- %("#{str.gsub('\\', '\\\\').gsub('"', '\\"')}")
184
+ %("#{str.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }}")
185
185
  end
186
186
  end
187
187
  end
@@ -7,6 +7,7 @@ require_relative 'environment_command'
7
7
  require_relative 'frontend_command'
8
8
  require_relative 'tables_command'
9
9
  require_relative 'views_command'
10
+ require_relative 'generator_registry'
10
11
  require_relative '../inflector'
11
12
 
12
13
  module Belt
@@ -37,8 +38,8 @@ module Belt
37
38
  Creates:
38
39
  lambda/models/<name>.rb Model with validations and fields
39
40
  lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
40
- infrastructure/routes.tf.rb Route entry added
41
- infrastructure/schema.tf.rb DynamoDB table schema added
41
+ config/routes.tf.rb Route entry added
42
+ config/schema.tf.rb DynamoDB table schema added
42
43
  lambda/lib/routes/<app>_routes.rb Route manifest updated
43
44
  frontend/src/pages/<names>/ React pages (if frontend exists)
44
45
 
@@ -84,13 +85,19 @@ module Belt
84
85
  exit 0
85
86
  end
86
87
 
87
- unless GENERATORS.include?(generator)
88
+ unless GENERATORS.include?(generator) || GeneratorRegistry.generator_names.include?(generator)
88
89
  puts "Unknown generator: '#{generator}'"
89
- puts "Available generators: #{(GENERATORS - ['resource']).join(', ')}"
90
+ puts "Available generators: #{all_generator_names.join(', ')}"
90
91
  puts "\nRun 'belt generate --help' for usage information."
91
92
  exit 1
92
93
  end
93
94
 
95
+ # Delegate to gem-provided generator if not a built-in
96
+ unless GENERATORS.include?(generator)
97
+ klass = GeneratorRegistry.find(generator)
98
+ return klass.run(args)
99
+ end
100
+
94
101
  # Normalize: resource is an alias for scaffold
95
102
  generator = 'scaffold' if generator == 'resource'
96
103
 
@@ -131,6 +138,10 @@ module Belt
131
138
  belt setup environment frontend views routes
132
139
  ].freeze
133
140
 
141
+ def self.all_generator_names
142
+ ((GENERATORS - ['resource']) + GeneratorRegistry.generator_names).uniq
143
+ end
144
+
134
145
  def self.validate_resource_name!(name, generator)
135
146
  errors = []
136
147
 
@@ -152,6 +163,8 @@ module Belt
152
163
  end
153
164
 
154
165
  def self.print_generate_help
166
+ gem_generators = GeneratorRegistry.discovered_generators
167
+
155
168
  puts <<~HELP
156
169
  Usage: belt generate <generator> <name> [field:type ...] [options]
157
170
  belt g <generator> <name> [field:type ...] [options]
@@ -167,6 +180,18 @@ module Belt
167
180
  Aliases:
168
181
  resource Same as scaffold
169
182
 
183
+ HELP
184
+
185
+ if gem_generators.any?
186
+ puts ' Gem Generators:'
187
+ gem_generators.each do |name, klass|
188
+ desc = klass.respond_to?(:description) ? klass.description : "Generate #{name} (from gem)"
189
+ puts format(' %<name>-14s %<desc>s', name: name, desc: desc)
190
+ end
191
+ puts
192
+ end
193
+
194
+ puts <<~HELP
170
195
  Field Types:
171
196
  #{FIELD_TYPES.join(', ')}
172
197
  (defaults to string if omitted)
@@ -254,13 +279,15 @@ module Belt
254
279
  conflicts << "lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
255
280
  end
256
281
 
257
- schema_file = 'infrastructure/schema.tf.rb'
258
- if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
282
+ schema_file = find_schema_file_path
283
+ if schema_file && File.exist?(schema_file) &&
284
+ File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
259
285
  conflicts << "#{schema_file} (model :#{@singular_name})"
260
286
  end
261
287
 
262
- routes_file = 'infrastructure/routes.tf.rb'
263
- if File.exist?(routes_file) && File.read(routes_file).match?(/resources :#{Regexp.escape(@resource_name)}\b/)
288
+ routes_file = find_routes_file_path
289
+ if routes_file && File.exist?(routes_file) &&
290
+ File.read(routes_file).match?(/resources :#{Regexp.escape(@resource_name)}\b/)
264
291
  conflicts << "#{routes_file} (resources :#{@resource_name})"
265
292
  end
266
293
 
@@ -277,8 +304,9 @@ module Belt
277
304
  conflicts = []
278
305
  conflicts << "lambda/models/#{@singular_name}.rb" if File.exist?("lambda/models/#{@singular_name}.rb")
279
306
 
280
- schema_file = 'infrastructure/schema.tf.rb'
281
- if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
307
+ schema_file = find_schema_file_path
308
+ if schema_file && File.exist?(schema_file) &&
309
+ File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
282
310
  conflicts << "#{schema_file} (model :#{@singular_name})"
283
311
  end
284
312
 
@@ -302,8 +330,8 @@ module Belt
302
330
  puts "\nFiles created/updated:"
303
331
  puts " lambda/models/#{@singular_name}.rb"
304
332
  puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
305
- puts ' infrastructure/routes.tf.rb (updated)'
306
- puts ' infrastructure/schema.tf.rb (updated)'
333
+ puts " #{find_routes_file_path || 'config/routes.tf.rb'} (updated)"
334
+ puts " #{find_schema_file_path || 'config/schema.tf.rb'} (updated)"
307
335
  puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
308
336
  puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
309
337
  end
@@ -327,8 +355,8 @@ module Belt
327
355
  end
328
356
 
329
357
  def inject_routes
330
- routes_file = 'infrastructure/routes.tf.rb'
331
- return unless File.exist?(routes_file)
358
+ routes_file = find_routes_file_path
359
+ return unless routes_file && File.exist?(routes_file)
332
360
 
333
361
  content = File.read(routes_file)
334
362
  tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
@@ -419,8 +447,8 @@ module Belt
419
447
  end
420
448
 
421
449
  def inject_schema
422
- schema_file = 'infrastructure/schema.tf.rb'
423
- return unless File.exist?(schema_file)
450
+ schema_file = find_schema_file_path
451
+ return unless schema_file && File.exist?(schema_file)
424
452
 
425
453
  content = File.read(schema_file)
426
454
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ # Discovers and manages generators provided by external gems.
6
+ #
7
+ # Gems register generators by placing a file at:
8
+ # lib/belt/generators/<name>_generator.rb
9
+ #
10
+ # Each generator class must:
11
+ # - Be named <Name>Generator (e.g., MessagingGenerator)
12
+ # - Live in the Belt::Generators module
13
+ # - Implement .run(args) for generation
14
+ # - Implement .destroy(args) for teardown (optional)
15
+ # - Implement .description for help text (optional)
16
+ #
17
+ # Example gem structure:
18
+ # belt-messaging/
19
+ # lib/
20
+ # belt/
21
+ # generators/
22
+ # messaging_generator.rb
23
+ #
24
+ # The generator is then available as:
25
+ # belt generate messaging
26
+ # belt destroy messaging
27
+ #
28
+ module GeneratorRegistry
29
+ GENERATOR_PATH = 'lib/belt/generators'
30
+
31
+ class << self
32
+ # Returns a hash of { "name" => generator_class } for all discovered generators.
33
+ def discovered_generators
34
+ @discovered_generators ||= discover_all
35
+ end
36
+
37
+ # Returns just the names of discovered generators.
38
+ def generator_names
39
+ discovered_generators.keys
40
+ end
41
+
42
+ # Loads and returns the generator class for a given name.
43
+ # Returns nil if not found.
44
+ def find(name)
45
+ discovered_generators[name]
46
+ end
47
+
48
+ # Reset cache (useful in tests)
49
+ def reset!
50
+ @discovered_generators = nil
51
+ end
52
+
53
+ private
54
+
55
+ def discover_all
56
+ generators = {}
57
+
58
+ Gem.loaded_specs.each_value do |spec|
59
+ generators_dir = File.join(spec.gem_dir, GENERATOR_PATH)
60
+ next unless File.directory?(generators_dir)
61
+
62
+ Dir.glob(File.join(generators_dir, '*_generator.rb')).each do |file|
63
+ name = File.basename(file, '.rb').sub(/_generator\z/, '')
64
+ klass = load_generator_class(file, name)
65
+ generators[name] = klass if klass
66
+ end
67
+ end
68
+
69
+ generators
70
+ end
71
+
72
+ def load_generator_class(file, name)
73
+ require file
74
+
75
+ class_name = "#{classify(name)}Generator"
76
+ Belt::Generators.const_get(class_name)
77
+ rescue LoadError, NameError => e
78
+ warn "[Belt] Warning: Failed to load generator '#{name}' from #{file}: #{e.message}"
79
+ nil
80
+ end
81
+
82
+ def classify(name)
83
+ name.split('_').map(&:capitalize).join
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ # Namespace for generator classes provided by external gems
90
+ module Generators
91
+ end
92
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require_relative 'app_detection'
6
+
7
+ module Belt
8
+ module CLI
9
+ # Reads config/lambda/*.yml files and produces a merged lambda_config hash
10
+ # suitable for Conveyor Belt's `lambda_config` variable.
11
+ #
12
+ # Each YAML file follows a database.yml-like pattern:
13
+ #
14
+ # # config/lambda/customer.yml
15
+ # default: &default
16
+ # timeout: 60
17
+ # memory_size: 512
18
+ # env_keys:
19
+ # - IMAGES_BUCKET_NAME
20
+ # - IMAGES_CLOUDFRONT_DOMAIN
21
+ #
22
+ # dev:
23
+ # <<: *default
24
+ # memory_size: 256
25
+ #
26
+ # prod:
27
+ # <<: *default
28
+ # timeout: 30
29
+ # memory_size: 1024
30
+ #
31
+ class LambdaConfigCommand
32
+ include AppDetection
33
+
34
+ SUPPORTED_KEYS = %w[
35
+ timeout memory_size env_vars env_keys
36
+ s3_buckets dynamodb_tables sns_triggers sqs_triggers
37
+ reserved_concurrency ephemeral_storage
38
+ ].freeze
39
+
40
+ def self.run(args)
41
+ options = {}
42
+
43
+ i = 0
44
+ while i < args.length
45
+ case args[i]
46
+ when '-e', '--environment'
47
+ i += 1
48
+ options[:environment] = args[i]
49
+ when '-f', '--format'
50
+ i += 1
51
+ options[:format] = args[i]
52
+ when '--lambda'
53
+ i += 1
54
+ options[:lambda] = args[i]
55
+ when '-h', '--help'
56
+ puts help_text
57
+ exit 0
58
+ end
59
+ i += 1
60
+ end
61
+
62
+ new(options).run
63
+ end
64
+
65
+ def self.help_text
66
+ <<~HELP
67
+ Read lambda config from config/lambda/*.yml files.
68
+
69
+ Usage: belt lambda-config [options]
70
+
71
+ Reads YAML config files and produces a merged configuration hash for the
72
+ specified environment (like database.yml in Rails).
73
+
74
+ Options:
75
+ -e, --environment ENV Target environment (default: dev)
76
+ -f, --format FORMAT Output format: json (default), terraform
77
+ --lambda NAME Only output config for a specific lambda
78
+ -h, --help Show this help
79
+
80
+ Examples:
81
+ belt lambda-config -e prod
82
+ belt lambda-config -e dev --format terraform
83
+ belt lambda-config --lambda customer -e prod
84
+
85
+ Config files live at config/lambda/<name>.yml. Each file can define
86
+ environment-specific overrides using YAML anchors:
87
+
88
+ # config/lambda/customer.yml
89
+ default: &default
90
+ timeout: 60
91
+ memory_size: 512
92
+ env_keys:
93
+ - IMAGES_BUCKET_NAME
94
+ - STRIPE_KEY
95
+
96
+ dev:
97
+ <<: *default
98
+ memory_size: 256
99
+
100
+ prod:
101
+ <<: *default
102
+ memory_size: 1024
103
+
104
+ Keys:
105
+ timeout Lambda timeout in seconds (default: 30)
106
+ memory_size Lambda memory in MB (default: 256)
107
+ env_keys List of env var names this lambda needs
108
+ env_vars Static env vars (key: value pairs)
109
+ s3_buckets S3 bucket access declarations
110
+ dynamodb_tables DynamoDB table access declarations
111
+ sns_triggers SNS topic triggers
112
+ sqs_triggers SQS queue triggers
113
+ reserved_concurrency Reserved concurrency limit
114
+ ephemeral_storage Ephemeral storage in MB (512-10240)
115
+ HELP
116
+ end
117
+
118
+ def initialize(options = {})
119
+ @environment = options[:environment] || ENV.fetch('BELT_ENV', 'dev')
120
+ @format = options[:format] || 'json'
121
+ @lambda_filter = options[:lambda]
122
+ @config_dir = 'config/lambda'
123
+ end
124
+
125
+ def run
126
+ abort "Error: #{@config_dir}/ not found. Create lambda config files there." unless Dir.exist?(@config_dir)
127
+
128
+ configs = load_all_configs
129
+ configs = configs.select { |name, _| name == @lambda_filter } if @lambda_filter
130
+
131
+ if configs.empty?
132
+ if @lambda_filter
133
+ abort "Error: No config found for lambda '#{@lambda_filter}' in #{@config_dir}/"
134
+ else
135
+ abort "Error: No .yml files found in #{@config_dir}/"
136
+ end
137
+ end
138
+
139
+ output(configs)
140
+ end
141
+
142
+ # Public API: load and merge all configs for a given environment.
143
+ # Used by other belt commands and potentially by the Conveyor Belt provider.
144
+ def self.load_configs(environment: 'dev', config_dir: 'config/lambda')
145
+ return {} unless Dir.exist?(config_dir)
146
+
147
+ configs = {}
148
+ Dir.glob(File.join(config_dir, '*.yml')).each do |file|
149
+ name = File.basename(file, '.yml')
150
+ raw = YAML.safe_load_file(file, aliases: true) || {}
151
+ configs[name] = resolve_environment(raw, environment)
152
+ end
153
+ configs
154
+ end
155
+
156
+ private
157
+
158
+ def load_all_configs
159
+ self.class.load_configs(environment: @environment, config_dir: @config_dir)
160
+ end
161
+
162
+ def self.resolve_environment(raw, environment)
163
+ # Merge: default < environment-specific
164
+ base = raw['default'] || {}
165
+ env_config = raw[environment] || {}
166
+ merged = deep_merge(base, env_config)
167
+
168
+ # Remove the YAML anchor/environment keys from output
169
+ merged.reject { |k, _| k == 'default' || !SUPPORTED_KEYS.include?(k) }
170
+ end
171
+ private_class_method :resolve_environment
172
+
173
+ def self.deep_merge(base, override)
174
+ base.merge(override) do |_key, old_val, new_val|
175
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
176
+ deep_merge(old_val, new_val)
177
+ else
178
+ new_val
179
+ end
180
+ end
181
+ end
182
+ private_class_method :deep_merge
183
+
184
+ def output(configs)
185
+ case @format
186
+ when 'json'
187
+ puts JSON.pretty_generate(configs)
188
+ when 'terraform'
189
+ output_terraform(configs)
190
+ else
191
+ abort "Unknown format: #{@format}. Use 'json' or 'terraform'."
192
+ end
193
+ end
194
+
195
+ # Output a Terraform-compatible lambda_config block.
196
+ # This can be piped to a .tfvars file or used by the provider directly.
197
+ def output_terraform(configs)
198
+ puts 'lambda_config = {'
199
+ configs.each_with_index do |(name, config), idx|
200
+ puts " #{name} = {"
201
+ puts " timeout = #{config['timeout']}" if config['timeout']
202
+ puts " memory_size = #{config['memory_size']}" if config['memory_size']
203
+ puts " reserved_concurrency = #{config['reserved_concurrency']}" if config['reserved_concurrency']
204
+ puts " ephemeral_storage = #{config['ephemeral_storage']}" if config['ephemeral_storage']
205
+
206
+ output_env_vars_tf(config) if config['env_vars'] || config['env_keys']
207
+ output_s3_buckets_tf(config['s3_buckets']) if config['s3_buckets']
208
+ output_dynamodb_tables_tf(config['dynamodb_tables']) if config['dynamodb_tables']
209
+ output_sns_triggers_tf(config['sns_triggers']) if config['sns_triggers']
210
+ output_sqs_triggers_tf(config['sqs_triggers']) if config['sqs_triggers']
211
+
212
+ puts " }#{'' unless idx < configs.size - 1}"
213
+ puts '' if idx < configs.size - 1
214
+ end
215
+ puts '}'
216
+ end
217
+
218
+ def output_env_vars_tf(config)
219
+ env_vars = config['env_vars'] || {}
220
+ env_keys = config['env_keys'] || []
221
+
222
+ # env_keys become placeholder entries that Terraform must fill
223
+ all_vars = env_vars.dup
224
+ env_keys.each { |k| all_vars[k] ||= '' }
225
+
226
+ return if all_vars.empty?
227
+
228
+ puts ''
229
+ puts ' env_vars = {'
230
+ all_vars.each do |key, value|
231
+ if value.empty?
232
+ puts " #{key} = var.#{key.downcase}"
233
+ else
234
+ puts " #{key} = #{value.inspect}"
235
+ end
236
+ end
237
+ puts ' }'
238
+ end
239
+
240
+ def output_s3_buckets_tf(buckets)
241
+ return if buckets.nil? || buckets.empty?
242
+
243
+ puts ''
244
+ puts ' s3_buckets = ['
245
+ buckets.each do |bucket|
246
+ puts ' {'
247
+ puts " bucket_arn = #{bucket['bucket_arn'] || 'TODO'}"
248
+ puts " permissions = #{bucket['permissions'].inspect}" if bucket['permissions']
249
+ puts ' }'
250
+ end
251
+ puts ' ]'
252
+ end
253
+
254
+ def output_dynamodb_tables_tf(tables)
255
+ return if tables.nil? || tables.empty?
256
+
257
+ puts ''
258
+ puts ' dynamodb_tables = ['
259
+ tables.each do |table|
260
+ puts ' {'
261
+ puts " table_arn = #{table['table_arn'] || 'TODO'}"
262
+ puts " permissions = #{table['permissions'].inspect}" if table['permissions']
263
+ puts " index_names = #{table['index_names'].inspect}" if table['index_names']
264
+ puts ' }'
265
+ end
266
+ puts ' ]'
267
+ end
268
+
269
+ def output_sns_triggers_tf(triggers)
270
+ return if triggers.nil? || triggers.empty?
271
+
272
+ puts ''
273
+ puts ' sns_triggers = ['
274
+ triggers.each do |trigger|
275
+ puts ' {'
276
+ puts " topic_arn = #{trigger['topic_arn'] || 'TODO'}"
277
+ puts " statement_id = #{trigger['statement_id'].inspect}" if trigger['statement_id']
278
+ puts ' }'
279
+ end
280
+ puts ' ]'
281
+ end
282
+
283
+ def output_sqs_triggers_tf(triggers)
284
+ return if triggers.nil? || triggers.empty?
285
+
286
+ puts ''
287
+ puts ' sqs_triggers = ['
288
+ triggers.each do |trigger|
289
+ puts ' {'
290
+ puts " queue_arn = #{trigger['queue_arn'] || 'TODO'}"
291
+ puts " batch_size = #{trigger['batch_size']}" if trigger['batch_size']
292
+ puts ' }'
293
+ end
294
+ puts ' ]'
295
+ end
296
+ end
297
+ end
298
+ end
@@ -122,6 +122,7 @@ module Belt
122
122
  #{@app_name}/lambda/lib/routes
123
123
  #{@app_name}/lambda/config
124
124
  #{@app_name}/lambda/spec
125
+ #{@app_name}/config/lambda
125
126
  #{@app_name}/infrastructure/modules/app
126
127
  ]
127
128
  end
@@ -136,8 +137,9 @@ module Belt
136
137
  'lambda/controllers/application_controller.rb.erb' =>
137
138
  "#{@app_name}/lambda/controllers/api/application_controller.rb",
138
139
  'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
139
- 'infrastructure/routes.tf.rb.erb' => "#{@app_name}/infrastructure/routes.tf.rb",
140
- 'infrastructure/schema.tf.rb.erb' => "#{@app_name}/infrastructure/schema.tf.rb",
140
+ 'config/routes.tf.rb.erb' => "#{@app_name}/config/routes.tf.rb",
141
+ 'config/schema.tf.rb.erb' => "#{@app_name}/config/schema.tf.rb",
142
+ 'config/lambda/api.yml.erb' => "#{@app_name}/config/lambda/api.yml",
141
143
  'README.md.erb' => "#{@app_name}/README.md",
142
144
  'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
143
145
  'gitignore.erb' => "#{@app_name}/.gitignore"
@@ -28,6 +28,11 @@ module Belt
28
28
  unless schema_file
29
29
  routes_dir = File.dirname(File.expand_path(routes_file))
30
30
  schema_file = File.join(routes_dir, 'schema.tf.rb')
31
+ # Fall back to infrastructure/ if not found in same directory as routes
32
+ unless File.exist?(schema_file)
33
+ alt = 'infrastructure/schema.tf.rb'
34
+ schema_file = alt if File.exist?(alt)
35
+ end
31
36
  end
32
37
  schema_file
33
38
  end
@@ -25,7 +25,10 @@ module Belt
25
25
 
26
26
  def run
27
27
  routes_file = find_routes_file
28
- abort 'Error: No routes file found. Expected infrastructure/routes.tf.rb' unless routes_file
28
+ unless routes_file
29
+ abort 'Error: No routes file found. ' \
30
+ 'Expected config/routes.tf.rb (or infrastructure/routes.tf.rb)'
31
+ end
29
32
 
30
33
  dsl = load_routes(routes_file)
31
34
  @table_inference = TableInference.new(@options[:tables_file])
@@ -84,8 +87,8 @@ module Belt
84
87
  end
85
88
 
86
89
  def find_routes_file
87
- path = 'infrastructure/routes.tf.rb'
88
- File.exist?(path) ? path : nil
90
+ candidates = ['config/routes.tf.rb', 'infrastructure/routes.tf.rb']
91
+ candidates.find { |f| File.exist?(f) }
89
92
  end
90
93
 
91
94
  def load_routes(file)
@@ -8,7 +8,7 @@ require_relative '../inflector'
8
8
  module Belt
9
9
  module CLI
10
10
  class TablesCommand
11
- SCHEMA_FILE = 'infrastructure/schema.tf.rb'
11
+ SCHEMA_FILE_CANDIDATES = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].freeze
12
12
  MODULE_DIR = 'infrastructure/modules/app'
13
13
 
14
14
  include AppDetection
@@ -33,12 +33,16 @@ module Belt
33
33
  # Automatically sync dynamodb.tf in the app module.
34
34
  # Called by generators after updating schema.tf.rb.
35
35
  def self.sync_all_environments
36
- return unless File.exist?(SCHEMA_FILE)
36
+ return unless schema_file_path
37
37
 
38
38
  # With the module approach, we only need to generate once into modules/app/
39
39
  new(nil, quiet: true).run
40
40
  end
41
41
 
42
+ def self.schema_file_path
43
+ SCHEMA_FILE_CANDIDATES.find { |f| File.exist?(f) }
44
+ end
45
+
42
46
  def initialize(env, quiet: false)
43
47
  @env = env
44
48
  @quiet = quiet
@@ -59,8 +63,12 @@ module Belt
59
63
  private
60
64
 
61
65
  def validate!
62
- unless File.exist?(SCHEMA_FILE)
63
- abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless @quiet
66
+ schema_file = self.class.schema_file_path
67
+ unless schema_file
68
+ unless @quiet
69
+ abort 'Error: No schema.tf.rb found (checked config/ and infrastructure/). ' \
70
+ 'Run `belt generate resource` first.'
71
+ end
64
72
  return
65
73
  end
66
74
  return if Dir.exist?(MODULE_DIR)
@@ -72,10 +80,11 @@ module Belt
72
80
  end
73
81
 
74
82
  def parse_schema
75
- return [] unless File.exist?(SCHEMA_FILE)
83
+ schema_file = self.class.schema_file_path
84
+ return [] unless schema_file && File.exist?(schema_file)
76
85
 
77
86
  parser = SchemaParser.new
78
- schema_content = File.read(SCHEMA_FILE)
87
+ schema_content = File.read(schema_file)
79
88
 
80
89
  # Replace DSL wrapper with direct parser call
81
90
  inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
@@ -83,7 +92,7 @@ module Belt
83
92
 
84
93
  return [] if inner.empty? || inner.match?(/\A\s*\z/m)
85
94
 
86
- parser.instance_eval(inner, SCHEMA_FILE)
95
+ parser.instance_eval(inner, schema_file)
87
96
  parser.models
88
97
  end
89
98
 
@@ -32,8 +32,8 @@ module Belt
32
32
  end
33
33
 
34
34
  def self.read_schema_fields(name)
35
- schema_file = 'infrastructure/schema.tf.rb'
36
- return [] unless File.exist?(schema_file)
35
+ schema_file = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].find { |f| File.exist?(f) }
36
+ return [] unless schema_file
37
37
 
38
38
  content = File.read(schema_file)
39
39
  singular = Belt::Inflector.singularize(name)
data/lib/belt/cli.rb CHANGED
@@ -16,6 +16,7 @@ require_relative 'cli/terraform_command'
16
16
  require_relative 'cli/deploy_command'
17
17
  require_relative 'cli/server_command'
18
18
  require_relative 'cli/routes_command'
19
+ require_relative 'cli/lambda_config_command'
19
20
  require_relative 'cli/tasks_command'
20
21
  require_relative 'cli/console_command'
21
22
 
@@ -26,6 +27,7 @@ module Belt
26
27
  %w[generate g] => Belt::CLI::GenerateCommand,
27
28
  %w[destroy d] => Belt::CLI::DestroyCommand,
28
29
  'routes' => Belt::CLI::RoutesCommand,
30
+ 'lambda-config' => Belt::CLI::LambdaConfigCommand,
29
31
  %w[console c] => Belt::CLI::ConsoleCommand,
30
32
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
31
33
  'setup' => Belt::CLI::SetupCommand,
@@ -56,7 +58,7 @@ module Belt
56
58
  # belt destroy --help → DestroyCommand help
57
59
  # belt destroy (no args) → terraform destroy (needs env)
58
60
  return DestroyCommand.run(args) if args.include?('--help') || args.include?('-h')
59
- elsif DestroyCommand::GENERATORS.include?(args.first)
61
+ elsif DestroyCommand::GENERATORS.include?(args.first) || GeneratorRegistry.generator_names.include?(args.first)
60
62
  return DestroyCommand.run(args)
61
63
  end
62
64
  end
@@ -101,6 +103,7 @@ module Belt
101
103
  deploy frontend <env> Build and deploy frontend to AWS
102
104
  frontend env <env> Write frontend/.env from terraform outputs
103
105
  routes [-g PATTERN] [-f json] Show route definitions
106
+ lambda-config [-e ENV] [-f json|terraform] Show merged lambda configuration
104
107
 
105
108
  console Start an interactive console (IRB)
106
109
  c Alias for console
data/lib/belt/root.rb CHANGED
@@ -9,9 +9,33 @@ module Belt
9
9
  @root = path
10
10
  end
11
11
 
12
+ # Resolves the path to routes.tf.rb, checking config/ first then infrastructure/ (legacy).
13
+ def self.routes_file
14
+ candidates = [
15
+ File.join(root, 'config/routes.tf.rb'),
16
+ File.join(root, 'infrastructure/routes.tf.rb')
17
+ ]
18
+ candidates.find { |f| File.exist?(f) }
19
+ end
20
+
21
+ # Resolves the path to schema.tf.rb, checking config/ first then infrastructure/ (legacy).
22
+ def self.schema_file
23
+ candidates = [
24
+ File.join(root, 'config/schema.tf.rb'),
25
+ File.join(root, 'infrastructure/schema.tf.rb')
26
+ ]
27
+ candidates.find { |f| File.exist?(f) }
28
+ end
29
+
30
+ # Resolves the lambda config directory.
31
+ def self.lambda_config_dir
32
+ File.join(root, 'config/lambda')
33
+ end
34
+
12
35
  def self.detect_root
13
36
  dir = Dir.pwd
14
37
  loop do
38
+ return dir if File.exist?(File.join(dir, 'config/routes.tf.rb'))
15
39
  return dir if File.exist?(File.join(dir, 'infrastructure/routes.tf.rb'))
16
40
 
17
41
  parent = File.dirname(dir)
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.1.12'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -13,7 +13,7 @@ terraform {
13
13
  }
14
14
  conveyor-belt = {
15
15
  source = "stowzilla/conveyor-belt"
16
- version = "~> 0.0.1"
16
+ version = "~> 0.0.2"
17
17
  }
18
18
  }
19
19
  }
@@ -21,7 +21,7 @@ terraform {
21
21
  resource "conveyor_belt" "main" {
22
22
  provider = conveyor-belt
23
23
 
24
- source = "${path.module}/../../routes.tf.rb"
24
+ source = "${path.module}/../../config/routes.tf.rb"
25
25
  app_name = var.app_name
26
26
  lambda_source_dir = "${path.module}/../../../lambda"
27
27
  lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
@@ -30,13 +30,12 @@ resource "conveyor_belt" "main" {
30
30
 
31
31
  custom_domain_name = local.dns_enabled ? local.api_domain : ""
32
32
 
33
- # Per-lambda configuration: env vars, timeouts, memory
34
- lambda_config = {
35
- api = {
36
- env_vars = {
37
- WELCOME_TITLE = "Welcome to ${var.app_name}"
38
- WELCOME_SUBTITLE = "API Gateway → Lambda → DynamoDB — all connected."
39
- }
40
- }
41
- }
33
+ # Per-lambda configuration from config/lambda/*.yml (database.yml style)
34
+ lambda_config_dir = "${path.module}/../../config/lambda"
35
+
36
+ # Dynamic value references — YAML env_vars use ref(name) to pull these in
37
+ lambda_env_refs = var.lambda_env_refs
38
+
39
+ # Terraform overrides for values that need interpolation (${var.app_name}, module outputs, etc.)
40
+ lambda_config = var.lambda_config
42
41
  }
@@ -25,3 +25,15 @@ variable "frontend_urls" {
25
25
  type = list(string)
26
26
  default = []
27
27
  }
28
+
29
+ variable "lambda_env_refs" {
30
+ description = "Map of reference names to dynamic Terraform values. YAML env_vars use ref(name) to resolve these."
31
+ type = map(string)
32
+ default = {}
33
+ }
34
+
35
+ variable "lambda_config" {
36
+ description = "Additional lambda configuration overrides (for dynamic Terraform values)"
37
+ type = any
38
+ default = {}
39
+ }
@@ -22,9 +22,12 @@ This file explains the project structure, tooling, and conventions for AI agents
22
22
  │ │ └── concerns/ # Shared model concerns
23
23
  │ ├── lib/routes/ # Route manifests (auto-generated)
24
24
  │ └── Gemfile # Lambda-specific dependencies
25
- ├── infrastructure/
25
+ ├── config/
26
26
  │ ├── routes.tf.rb # API routes (Conveyor Belt DSL)
27
27
  │ ├── schema.tf.rb # Model schema definitions
28
+ │ └── lambda/ # Per-lambda config (like database.yml)
29
+ │ └── api.yml # Lambda timeout, memory, env vars
30
+ ├── infrastructure/
28
31
  │ └── <env>/ # Per-environment Terraform configs
29
32
  ├── Gemfile # Project-level dependencies
30
33
  └── AGENTS.md # This file
@@ -50,7 +53,7 @@ belt output <env> # terraform output
50
53
 
51
54
  ## How Routing Works
52
55
 
53
- 1. `infrastructure/routes.tf.rb` defines routes using a DSL:
56
+ 1. `config/routes.tf.rb` defines routes using a DSL:
54
57
  ```ruby
55
58
  Belt.application.routes.draw do
56
59
  namespace :<%= @app_name %> do
@@ -5,9 +5,12 @@ A serverless application built with [Belt](https://github.com/stowzilla/belt) an
5
5
  ## Structure
6
6
 
7
7
  ```
8
- ├── infrastructure/
8
+ ├── config/
9
9
  │ ├── routes.tf.rb # API route definitions
10
- └── schema.tf.rb # Model schema definitions
10
+ ├── schema.tf.rb # Model schema definitions
11
+ │ └── lambda/ # Per-lambda config (timeout, memory, env vars)
12
+ │ └── api.yml
13
+ ├── infrastructure/ # Terraform environments
11
14
  └── lambda/
12
15
  ├── api.rb # Lambda entry point
13
16
  ├── controllers/<%= @app_name %>/ # Controllers
@@ -0,0 +1,32 @@
1
+ # Lambda configuration for the api function.
2
+ # Works like Rails' database.yml — define defaults and override per environment.
3
+ #
4
+ # Keys:
5
+ # timeout Lambda timeout in seconds (default: 30)
6
+ # memory_size Lambda memory in MB (default: 256)
7
+ # env_vars Environment variables (static values or ref() for Terraform values)
8
+ # dynamodb_tables DynamoDB table access (by name — ARNs built by convention)
9
+ # s3_buckets S3 bucket access declarations
10
+ # sns_triggers SNS topic triggers
11
+ # sqs_triggers SQS queue triggers
12
+ #
13
+ # ref(name) references are resolved from lambda_env_refs in Terraform.
14
+ # DynamoDB tables use shorthand (table: [permissions]) or expanded form.
15
+ #
16
+ # Usage:
17
+ # belt lambda-config -e prod # View merged config for prod
18
+ # belt lambda-config -f terraform # Output as Terraform-ready lambda_config
19
+
20
+ default: &default
21
+ timeout: 30
22
+ memory_size: 256
23
+ env_vars:
24
+ WELCOME_TITLE: "Welcome to <%= @app_name %>"
25
+ WELCOME_SUBTITLE: "API Gateway → Lambda → DynamoDB — all connected."
26
+
27
+ dev:
28
+ <<: *default
29
+
30
+ prod:
31
+ <<: *default
32
+ memory_size: 512
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.12
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -82,6 +82,8 @@ files:
82
82
  - lib/belt/cli/frontend_env_map.rb
83
83
  - lib/belt/cli/frontend_setup_command.rb
84
84
  - lib/belt/cli/generate_command.rb
85
+ - lib/belt/cli/generator_registry.rb
86
+ - lib/belt/cli/lambda_config_command.rb
85
87
  - lib/belt/cli/new_command.rb
86
88
  - lib/belt/cli/routes_command.rb
87
89
  - lib/belt/cli/routes_command/route_inference.rb
@@ -133,9 +135,10 @@ files:
133
135
  - lib/templates/new_app/Gemfile.erb
134
136
  - lib/templates/new_app/README.md.erb
135
137
  - lib/templates/new_app/Rakefile.erb
138
+ - lib/templates/new_app/config/lambda/api.yml.erb
139
+ - lib/templates/new_app/config/routes.tf.rb.erb
140
+ - lib/templates/new_app/config/schema.tf.rb.erb
136
141
  - lib/templates/new_app/gitignore.erb
137
- - lib/templates/new_app/infrastructure/routes.tf.rb.erb
138
- - lib/templates/new_app/infrastructure/schema.tf.rb.erb
139
142
  - lib/templates/new_app/lambda/api.rb.erb
140
143
  - lib/templates/new_app/lambda/config/environment.rb.erb
141
144
  - lib/templates/new_app/lambda/controllers/application_controller.rb.erb