belt 0.2.10 → 0.2.12

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/README.md +169 -0
  4. data/lib/belt/action_router.rb +32 -4
  5. data/lib/belt/cli/app_detection.rb +19 -5
  6. data/lib/belt/cli/contracts_command.rb +142 -0
  7. data/lib/belt/cli/destroy_command.rb +193 -10
  8. data/lib/belt/cli/environment_command.rb +32 -0
  9. data/lib/belt/cli/generate_command.rb +5 -5
  10. data/lib/belt/cli/new_command.rb +2 -2
  11. data/lib/belt/cli/plugin_command.rb +221 -0
  12. data/lib/belt/cli/routes_command/schema_loader.rb +17 -7
  13. data/lib/belt/cli/routes_command.rb +7 -3
  14. data/lib/belt/cli/setup_command.rb +1 -1
  15. data/lib/belt/cli/views_command.rb +7 -2
  16. data/lib/belt/cli.rb +7 -0
  17. data/lib/belt/helpers/cors_origin.rb +19 -2
  18. data/lib/belt/helpers/response.rb +13 -2
  19. data/lib/belt/lambda_handler.rb +14 -5
  20. data/lib/belt/root.rb +12 -3
  21. data/lib/belt/route_dsl.rb +2 -2
  22. data/lib/belt/version.rb +1 -1
  23. data/lib/templates/environment/backend.tf.erb +1 -2
  24. data/lib/templates/frontend_infra/frontend.tf.erb +2 -1
  25. data/lib/templates/module/frontend.tf.erb +2 -1
  26. data/lib/templates/module/main.tf.erb +1 -1
  27. data/lib/templates/new_app/AGENTS.md.erb +4 -4
  28. data/lib/templates/new_app/README.md.erb +2 -2
  29. data/lib/templates/plugin/AGENTS.md.erb +135 -0
  30. data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
  31. data/lib/templates/plugin/Gemfile.erb +11 -0
  32. data/lib/templates/plugin/LICENSE.erb +21 -0
  33. data/lib/templates/plugin/README.md.erb +63 -0
  34. data/lib/templates/plugin/Rakefile.erb +7 -0
  35. data/lib/templates/plugin/gemspec.erb +26 -0
  36. data/lib/templates/plugin/gitignore.erb +11 -0
  37. data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
  38. data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
  39. data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
  40. data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
  41. data/lib/templates/plugin/lib/entry.rb.erb +3 -0
  42. data/lib/templates/plugin/rspec.erb +3 -0
  43. data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
  44. data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
  45. metadata +21 -3
  46. /data/lib/templates/new_app/config/{schema.tf.rb.erb → contracts.rb.erb} +0 -0
  47. /data/lib/templates/new_app/config/{routes.tf.rb.erb → routes.rb.erb} +0 -0
@@ -44,10 +44,11 @@ module Belt
44
44
  when 'environment'
45
45
  name = args.shift
46
46
  if name.nil? || name.empty?
47
- puts 'Usage: belt destroy environment <name>'
47
+ puts 'Usage: belt destroy environment <name> [--force] [--skip-terraform]'
48
48
  exit 1
49
49
  end
50
- new(generator, name, []).destroy
50
+ flags = parse_environment_flags(args)
51
+ new(generator, name, [], **flags).destroy
51
52
  when 'frontend'
52
53
  new(generator, nil, []).destroy
53
54
  when 'views'
@@ -67,6 +68,19 @@ module Belt
67
68
  end
68
69
  end
69
70
 
71
+ def self.parse_environment_flags(args)
72
+ flags = { force: false, skip_terraform: false }
73
+ args.each do |arg|
74
+ case arg
75
+ when '--force', '-f'
76
+ flags[:force] = true
77
+ when '--skip-terraform'
78
+ flags[:skip_terraform] = true
79
+ end
80
+ end
81
+ flags
82
+ end
83
+
70
84
  def self.parse_field(arg)
71
85
  name, type = arg.split(':', 2)
72
86
  { name: name, type: type || 'string' }
@@ -84,26 +98,37 @@ module Belt
84
98
  resource Alias for scaffold
85
99
  model Remove an ActiveItem model
86
100
  controller Remove a controller
87
- environment Remove a deployment environment directory
101
+ environment Remove a deployment environment and tear down infrastructure
88
102
  frontend Remove the frontend/ directory
89
103
  views Remove React pages for a resource
90
104
 
105
+ Environment options:
106
+ --force, -f Skip all prompts (CI mode)
107
+ --skip-terraform Delete local files without running terraform destroy
108
+
91
109
  Examples:
92
110
  belt d scaffold post title:string body:text status:string
93
111
  belt d model user
94
112
  belt d controller comments
95
113
  belt d environment staging
114
+ belt d environment dev --skip-terraform
115
+ belt d environment dev --force
96
116
  belt d frontend
97
117
  belt d views post
98
118
 
99
119
  ⚠ This is destructive. Files will be permanently deleted.
120
+
121
+ For environments: if terraform state is detected, you'll be prompted to run
122
+ `terraform destroy` first to tear down cloud resources before removing files.
100
123
  HELP
101
124
  end
102
125
 
103
- def initialize(generator, name, fields)
126
+ def initialize(generator, name, fields, force: false, skip_terraform: false)
104
127
  @generator = generator
105
128
  @name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
106
129
  @fields = fields
130
+ @force = force
131
+ @skip_terraform = skip_terraform
107
132
  @app_name = detect_namespace
108
133
  @singular_name = @name ? Belt::Inflector.singularize(@name) : nil
109
134
  @resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
@@ -149,15 +174,173 @@ module Belt
149
174
 
150
175
  def destroy_environment
151
176
  dir = "infrastructure/#{@name}"
152
- if Dir.exist?(dir)
153
- FileUtils.rm_rf(dir)
154
- @removed << dir
155
- puts " remove #{dir}/"
156
- puts "\n✓ Environment '#{@name}' destroyed!"
157
- else
177
+
178
+ unless Dir.exist?(dir)
158
179
  puts "✗ Environment '#{@name}' not found at #{dir}/"
159
180
  exit 1
160
181
  end
182
+
183
+ # Check if terraform state exists (infra may still be live)
184
+ if !@skip_terraform && terraform_state_exists?(dir)
185
+ puts "⚠ Environment '#{@name}' appears to have active infrastructure."
186
+ puts " Terraform state was found — resources may still be running.\n\n"
187
+
188
+ if @force
189
+ puts ' --force passed, skipping terraform destroy.'
190
+ else
191
+ puts ' Options:'
192
+ puts ' 1) Run `terraform destroy` to tear down infrastructure first (recommended)'
193
+ puts ' 2) Skip terraform and just delete the local files (--skip-terraform)'
194
+ puts " 3) Cancel\n\n"
195
+
196
+ print " Run terraform destroy for '#{@name}'? [y/N/skip] "
197
+ response = $stdin.gets&.strip&.downcase
198
+
199
+ case response
200
+ when 'y', 'yes'
201
+ run_terraform_destroy(dir)
202
+ when 'skip', 's'
203
+ puts ' Skipping terraform destroy.'
204
+ else
205
+ puts 'Cancelled.'
206
+ exit 0
207
+ end
208
+ end
209
+ end
210
+
211
+ # Final confirmation before deleting files
212
+ unless @force
213
+ print "\nPermanently delete #{dir}/? [y/N] "
214
+ response = $stdin.gets&.strip&.downcase
215
+ unless response&.start_with?('y')
216
+ puts 'Cancelled.'
217
+ exit 0
218
+ end
219
+ end
220
+
221
+ FileUtils.rm_rf(dir)
222
+ @removed << dir
223
+ puts " remove #{dir}/"
224
+ puts "\n✓ Environment '#{@name}' destroyed!"
225
+ end
226
+
227
+ def terraform_state_exists?(dir)
228
+ # Check for local .terraform directory (initialized state)
229
+ return true if Dir.exist?(File.join(dir, '.terraform'))
230
+
231
+ # Check if backend.tf exists (remote state configured)
232
+ backend_file = File.join(dir, 'backend.tf')
233
+ return false unless File.exist?(backend_file)
234
+
235
+ # Try to query remote state — if terraform is initialized and state exists,
236
+ # the environment likely has live resources
237
+ Dir.chdir(dir) do
238
+ # Quick check: does `terraform show` return anything?
239
+ output = `terraform show -no-color 2>&1`
240
+ Process.last_status.success? && !output.strip.empty? && !output.include?('No state')
241
+ end
242
+ rescue StandardError
243
+ # If we can't determine state, assume it might exist and warn
244
+ true
245
+ end
246
+
247
+ def run_terraform_destroy(dir)
248
+ puts "\n━━━ terraform destroy (#{@name}) ━━━"
249
+
250
+ Dir.chdir(dir) do
251
+ # Initialize if needed
252
+ unless Dir.exist?('.terraform')
253
+ puts ' Initializing terraform...'
254
+ unless system('terraform', 'init', '-input=false')
255
+ puts "\n✗ terraform init failed. You may need to destroy manually:"
256
+ puts " cd #{dir} && terraform init && terraform destroy"
257
+ exit 1
258
+ end
259
+ end
260
+
261
+ # Empty S3 buckets before destroy — terraform can't delete non-empty buckets
262
+ empty_s3_buckets_in_state
263
+
264
+ # Run destroy with auto-approve (user already confirmed)
265
+ unless system('terraform', 'destroy', '-auto-approve')
266
+ puts "\n✗ terraform destroy failed."
267
+ puts ' Infrastructure may still be running. Fix and retry, or use --skip-terraform.'
268
+ exit 1
269
+ end
270
+ end
271
+
272
+ puts ' ✓ Infrastructure destroyed.'
273
+ end
274
+
275
+ def empty_s3_buckets_in_state
276
+ output = `terraform state list 2>/dev/null`
277
+ return unless Process.last_status.success?
278
+
279
+ bucket_resources = output.lines.map(&:strip).grep(/\Aaws_s3_bucket\./)
280
+ return if bucket_resources.empty?
281
+
282
+ bucket_resources.each do |resource|
283
+ bucket_name = resolve_bucket_name(resource)
284
+ next unless bucket_name
285
+
286
+ empty_s3_bucket(bucket_name)
287
+ end
288
+ end
289
+
290
+ def resolve_bucket_name(resource)
291
+ output = `terraform state show '#{resource}' 2>/dev/null`
292
+ return nil unless Process.last_status.success?
293
+
294
+ match = output.match(/^\s*bucket\s*=\s*"([^"]+)"/)
295
+ match&.[](1)
296
+ end
297
+
298
+ def empty_s3_bucket(bucket_name)
299
+ # Check if bucket exists
300
+ `aws s3api head-bucket --bucket '#{bucket_name}' 2>&1`
301
+ return unless Process.last_status.success?
302
+
303
+ puts " Emptying S3 bucket: #{bucket_name}"
304
+
305
+ # Delete all object versions (handles versioned buckets)
306
+ `aws s3 rm 's3://#{bucket_name}' --recursive 2>/dev/null`
307
+
308
+ # Also delete versioned objects and delete markers
309
+ delete_all_versions(bucket_name)
310
+ end
311
+
312
+ def delete_all_versions(bucket_name)
313
+ loop do
314
+ output = `aws s3api list-object-versions --bucket '#{bucket_name}' --max-items 1000 2>/dev/null`
315
+ break unless Process.last_status.success?
316
+
317
+ begin
318
+ data = JSON.parse(output)
319
+ rescue JSON::ParserError
320
+ break
321
+ end
322
+
323
+ versions = (data['Versions'] || []) + (data['DeleteMarkers'] || [])
324
+ break if versions.empty?
325
+
326
+ # Build delete objects payload
327
+ objects = versions.map do |v|
328
+ { 'Key' => v['Key'], 'VersionId' => v['VersionId'] }
329
+ end
330
+
331
+ delete_payload = JSON.generate({ 'Objects' => objects, 'Quiet' => true })
332
+
333
+ # Use a temp file for the payload since it can be large
334
+ require 'tempfile'
335
+ Tempfile.create(['delete-objects', '.json']) do |f|
336
+ f.write(delete_payload)
337
+ f.flush
338
+ `aws s3api delete-objects --bucket '#{bucket_name}' --delete 'file://#{f.path}' 2>/dev/null`
339
+ end
340
+
341
+ # If there's no next token, we're done
342
+ break unless data['NextToken'] || data['IsTruncated']
343
+ end
161
344
  end
162
345
 
163
346
  def destroy_frontend
@@ -32,6 +32,7 @@ module Belt
32
32
  @domain = domain
33
33
  @quiet = quiet
34
34
  @announce = announce
35
+ @state_bucket = resolve_state_bucket
35
36
  end
36
37
 
37
38
  def generate
@@ -80,6 +81,37 @@ module Belt
80
81
  content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
81
82
  File.write(dest_path, content)
82
83
  end
84
+
85
+ # Resolve the state bucket name to use in backend.tf.
86
+ # Priority: existing sibling backend.tf → AWS account ID → bare placeholder.
87
+ def resolve_state_bucket
88
+ bucket_from_sibling || bucket_from_aws || 'belt-terraform-state'
89
+ end
90
+
91
+ def bucket_from_sibling
92
+ Dir.glob('infrastructure/*/backend.tf').each do |f|
93
+ match = File.read(f).match(/bucket\s*=\s*"([^"]+)"/)
94
+ next unless match
95
+ # Skip the bare placeholder — it means state wasn't set up yet
96
+ return match[1] unless match[1] == 'belt-terraform-state'
97
+ end
98
+ nil
99
+ end
100
+
101
+ def bucket_from_aws
102
+ require 'open3'
103
+ output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
104
+ return nil unless status.success?
105
+
106
+ data = begin
107
+ JSON.parse(output)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ return nil unless data&.dig('Account')
112
+
113
+ "belt-terraform-state-#{data['Account']}"
114
+ end
83
115
  end
84
116
  end
85
117
  end
@@ -38,8 +38,8 @@ module Belt
38
38
  Creates:
39
39
  lambda/models/<name>.rb Model with validations and fields
40
40
  lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
41
- config/routes.tf.rb Route entry added
42
- config/schema.tf.rb API response contract added
41
+ config/routes.rb Route entry added
42
+ config/contracts.rb API response contract added
43
43
  lambda/lib/routes/<app>_routes.rb Route manifest updated
44
44
  infrastructure/modules/app/dynamodb.tf DynamoDB table generated
45
45
  frontend/src/pages/<names>/ React pages (if frontend exists)
@@ -60,7 +60,7 @@ module Belt
60
60
  notes: <<~NOTES
61
61
  Creates:
62
62
  lambda/models/<name>.rb Model class inheriting from ApplicationRecord
63
- config/schema.tf.rb API response contract added
63
+ config/contracts.rb API response contract added
64
64
  infrastructure/modules/app/dynamodb.tf DynamoDB table generated
65
65
  NOTES
66
66
  },
@@ -333,8 +333,8 @@ module Belt
333
333
  puts "\nFiles created/updated:"
334
334
  puts " lambda/models/#{@singular_name}.rb"
335
335
  puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
336
- puts " #{find_routes_file_path || 'config/routes.tf.rb'} (updated)"
337
- puts " #{find_schema_file_path || 'config/schema.tf.rb'} (updated)"
336
+ puts " #{find_routes_file_path || 'config/routes.rb'} (updated)"
337
+ puts " #{find_contracts_file_path || 'config/contracts.rb'} (updated)"
338
338
  puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
339
339
  puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
340
340
  end
@@ -167,8 +167,8 @@ module Belt
167
167
  'lambda/controllers/application_controller.rb.erb' =>
168
168
  "#{@app_name}/lambda/controllers/api/application_controller.rb",
169
169
  'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
170
- 'config/routes.tf.rb.erb' => "#{@app_name}/config/routes.tf.rb",
171
- 'config/schema.tf.rb.erb' => "#{@app_name}/config/schema.tf.rb",
170
+ 'config/routes.rb.erb' => "#{@app_name}/config/routes.rb",
171
+ 'config/contracts.rb.erb' => "#{@app_name}/config/contracts.rb",
172
172
  'config/lambda/api.yml.erb' => "#{@app_name}/config/lambda/api.yml",
173
173
  'README.md.erb' => "#{@app_name}/README.md",
174
174
  'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+
6
+ module Belt
7
+ module CLI
8
+ # Scaffold a new Belt plugin gem (similar to `rails plugin new`).
9
+ #
10
+ # Usage:
11
+ # belt plugin new messaging
12
+ # belt plugin new belt-pay --path ~/code
13
+ #
14
+ # Creates a gem that registers with Belt's GeneratorRegistry via:
15
+ # lib/belt/generators/<name>_generator.rb
16
+ class PluginCommand
17
+ TEMPLATE_DIR = File.expand_path('../../templates/plugin', __dir__)
18
+
19
+ def self.run(args)
20
+ subcommand = args.shift
21
+
22
+ case subcommand
23
+ when 'new'
24
+ new(args).generate
25
+ when nil, '--help', '-h', 'help'
26
+ print_help
27
+ else
28
+ puts "Unknown plugin subcommand: #{subcommand}"
29
+ puts ''
30
+ print_help
31
+ exit 1
32
+ end
33
+ end
34
+
35
+ def self.print_help
36
+ puts <<~HELP
37
+ Manage Belt plugin gems.
38
+
39
+ Usage:
40
+ belt plugin new <name> [options]
41
+
42
+ Arguments:
43
+ name Plugin name (e.g. messaging, pay, or belt-messaging)
44
+
45
+ Options:
46
+ --path DIR Parent directory for the gem (default: current directory)
47
+ --summary TEXT Short gem summary for the gemspec
48
+ --force Overwrite files if the directory already exists
49
+ -h, --help Show this help
50
+
51
+ Examples:
52
+ belt plugin new messaging
53
+ belt plugin new pay --path ~/Code
54
+ belt plugin new belt-notifications --summary "Push notifications for Belt apps"
55
+
56
+ What you get:
57
+ A standalone gem (belt-<name>) with:
58
+ - Module under Belt::<Name>
59
+ - Generator at lib/belt/generators/<name>_generator.rb
60
+ - Configuration stub, version, RSpec, README, AGENTS.md
61
+ - Hooked into `belt generate <name>` / `belt destroy <name>` once
62
+ the gem is in an app's Gemfile
63
+
64
+ Reference plugins:
65
+ belt-messaging — SMS via AWS End User Messaging
66
+ belt-pay — Stripe payments and subscriptions
67
+
68
+ See the Belt README (Plugins / Contributing) and AGENTS.md for the full guide.
69
+ HELP
70
+ end
71
+
72
+ def initialize(args)
73
+ @raw_name = nil
74
+ @path = Dir.pwd
75
+ @summary = nil
76
+ @force = false
77
+
78
+ i = 0
79
+ while i < args.length
80
+ arg = args[i]
81
+ case arg
82
+ when '--path'
83
+ i += 1
84
+ @path = args[i] || Dir.pwd
85
+ when /^--path=/
86
+ @path = arg.split('=', 2).last
87
+ when '--summary'
88
+ i += 1
89
+ @summary = args[i]
90
+ when /^--summary=/
91
+ @summary = arg.split('=', 2).last
92
+ when '--force'
93
+ @force = true
94
+ when '--help', '-h'
95
+ self.class.print_help
96
+ exit 0
97
+ else
98
+ if arg.start_with?('-')
99
+ puts "Unknown option: #{arg}"
100
+ exit 1
101
+ end
102
+ @raw_name ||= arg
103
+ end
104
+ i += 1
105
+ end
106
+ end
107
+
108
+ def generate
109
+ if @raw_name.nil? || @raw_name.strip.empty?
110
+ puts 'Usage: belt plugin new <name> [options]'
111
+ puts "Run 'belt plugin --help' for more information."
112
+ exit 1
113
+ end
114
+
115
+ normalize_names!
116
+
117
+ if Dir.exist?(@gem_dir) && !@force
118
+ puts "✗ Directory already exists: #{@gem_dir}"
119
+ puts ' Use --force to overwrite, or choose a different name/path.'
120
+ exit 1
121
+ end
122
+
123
+ FileUtils.mkdir_p(@gem_dir)
124
+ write_files
125
+ print_success
126
+ end
127
+
128
+ private
129
+
130
+ def normalize_names!
131
+ # Accept "messaging", "belt-messaging", "Belt::Messaging"
132
+ name = @raw_name.to_s.strip
133
+ name = name.sub(/\ABelt::/i, '')
134
+ name = name.gsub('::', '-')
135
+ name = name.sub(/\Abelt[-_]/i, '')
136
+ name = name.gsub(/[^a-z0-9_-]/i, '_').downcase.tr('_', '-')
137
+ name = name.gsub(/-+/, '-').gsub(/\A-|-\z/, '')
138
+
139
+ if name.empty?
140
+ puts "✗ Invalid plugin name: #{@raw_name.inspect}"
141
+ exit 1
142
+ end
143
+
144
+ @plugin_name = name # messaging
145
+ @gem_name = "belt-#{name}" # belt-messaging
146
+ @module_name = name.split('-').map(&:capitalize).join # Messaging
147
+ @constant_path = "Belt::#{@module_name}" # Belt::Messaging
148
+ @generator_class = "#{@module_name}Generator"
149
+ @summary ||= "#{@module_name} plugin for Belt applications"
150
+ @gem_dir = File.expand_path(File.join(@path, @gem_name))
151
+ end
152
+
153
+ def write_files
154
+ files.each do |relative, content|
155
+ full = File.join(@gem_dir, relative)
156
+ FileUtils.mkdir_p(File.dirname(full))
157
+ File.write(full, content)
158
+ puts " create #{File.join(@gem_name, relative)}"
159
+ end
160
+ end
161
+
162
+ def files
163
+ gen = "lib/belt/generators/#{@plugin_name.tr('-', '_')}_generator.rb"
164
+ {
165
+ "#{@gem_name}.gemspec" => render('gemspec.erb'),
166
+ 'Gemfile' => render('Gemfile.erb'),
167
+ 'Rakefile' => render('Rakefile.erb'),
168
+ 'README.md' => render('README.md.erb'),
169
+ 'AGENTS.md' => render('AGENTS.md.erb'),
170
+ 'CHANGELOG.md' => render('CHANGELOG.md.erb'),
171
+ 'LICENSE' => render('LICENSE.erb'),
172
+ '.gitignore' => render('gitignore.erb'),
173
+ '.rspec' => render('rspec.erb'),
174
+ "lib/#{@gem_name}.rb" => render('lib/entry.rb.erb'),
175
+ "lib/belt/#{@plugin_name.tr('-', '_')}.rb" => render('lib/belt/module.rb.erb'),
176
+ "lib/belt/#{@plugin_name.tr('-', '_')}/version.rb" => render('lib/belt/module/version.rb.erb'),
177
+ "lib/belt/#{@plugin_name.tr('-', '_')}/configuration.rb" => render('lib/belt/module/configuration.rb.erb'),
178
+ gen => render('lib/belt/generators/generator.rb.erb'),
179
+ "lib/belt/#{@plugin_name.tr('-', '_')}/templates/.gitkeep" => '',
180
+ 'spec/spec_helper.rb' => render('spec/spec_helper.rb.erb'),
181
+ "spec/belt/#{@plugin_name.tr('-', '_')}/configuration_spec.rb" => render('spec/configuration_spec.rb.erb')
182
+ }
183
+ end
184
+
185
+ def render(template_name)
186
+ path = File.join(TEMPLATE_DIR, template_name)
187
+ raise "Missing plugin template: #{path}" unless File.exist?(path)
188
+
189
+ erb = ERB.new(File.read(path), trim_mode: '-')
190
+ # Expose locals used in templates
191
+ gem_name = @gem_name
192
+ plugin_name = @plugin_name
193
+ module_name = @module_name
194
+ constant_path = @constant_path
195
+ generator_class = @generator_class
196
+ summary = @summary
197
+ underscored = @plugin_name.tr('-', '_')
198
+ erb.result(binding)
199
+ end
200
+
201
+ def print_success
202
+ puts ''
203
+ puts "✓ Created plugin gem #{@gem_name}"
204
+ puts ''
205
+ puts 'Next steps:'
206
+ puts " 1. cd #{@gem_dir}"
207
+ puts ' 2. bundle install'
208
+ puts ' 3. Implement your library under lib/belt/'
209
+ puts " 4. Flesh out lib/belt/generators/#{@plugin_name.tr('-', '_')}_generator.rb"
210
+ puts ' (templates go in lib/belt/<name>/templates/)'
211
+ puts ' 5. In a Belt app Gemfile:'
212
+ puts " gem \"#{@gem_name}\", path: \"#{@gem_dir}\""
213
+ puts ' 6. bundle install && belt generate --help'
214
+ puts " → should list \"#{@plugin_name}\" under Gem Generators"
215
+ puts " 7. belt generate #{@plugin_name}"
216
+ puts ''
217
+ puts 'Reference implementations: belt-messaging, belt-pay'
218
+ end
219
+ end
220
+ end
221
+ end
@@ -8,14 +8,14 @@ module Belt
8
8
  private
9
9
 
10
10
  def load_schema_models(routes_file)
11
- schema_file = resolve_schema_file(routes_file)
11
+ schema_file = resolve_contracts_file(routes_file)
12
12
  return [] unless schema_file && File.exist?(schema_file)
13
13
 
14
14
  Belt.instance_variable_set(:@application, nil)
15
15
  begin
16
16
  eval(File.read(schema_file), binding, schema_file) # rubocop:disable Security/Eval
17
17
  rescue StandardError => e
18
- warn "Warning: Failed to load schema file #{schema_file}: #{e.message}"
18
+ warn "Warning: Failed to load contracts file #{schema_file}: #{e.message}"
19
19
  return []
20
20
  end
21
21
 
@@ -23,15 +23,25 @@ module Belt
23
23
  build_models_from_schema(schema)
24
24
  end
25
25
 
26
- def resolve_schema_file(routes_file)
26
+ def resolve_contracts_file(routes_file)
27
27
  schema_file = @options[:schema_file]
28
28
  unless schema_file
29
29
  routes_dir = File.dirname(File.expand_path(routes_file))
30
- schema_file = File.join(routes_dir, 'schema.tf.rb')
30
+ # Check new convention first, then legacy names
31
+ candidates = [
32
+ File.join(routes_dir, 'contracts.rb'),
33
+ File.join(routes_dir, 'contracts.tf.rb'),
34
+ File.join(routes_dir, 'schema.tf.rb')
35
+ ]
36
+ schema_file = candidates.find { |f| File.exist?(f) }
37
+
31
38
  # 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)
39
+ unless schema_file
40
+ legacy_candidates = [
41
+ 'infrastructure/contracts.rb',
42
+ 'infrastructure/schema.tf.rb'
43
+ ]
44
+ schema_file = legacy_candidates.find { |f| File.exist?(f) }
35
45
  end
36
46
  end
37
47
  schema_file
@@ -27,7 +27,7 @@ module Belt
27
27
  routes_file = find_routes_file
28
28
  unless routes_file
29
29
  abort 'Error: No routes file found. ' \
30
- 'Expected config/routes.tf.rb (or infrastructure/routes.tf.rb)'
30
+ 'Expected config/routes.rb (or config/routes.tf.rb, infrastructure/routes.tf.rb)'
31
31
  end
32
32
 
33
33
  dsl = load_routes(routes_file)
@@ -71,7 +71,7 @@ module Belt
71
71
  @options[:output_dir] = dir
72
72
  end
73
73
 
74
- opts.on('--schema FILE', 'Path to schema.tf.rb for model definitions') do |file|
74
+ opts.on('--schema FILE', 'Path to contracts.rb for model definitions') do |file|
75
75
  @options[:schema_file] = file
76
76
  end
77
77
 
@@ -87,7 +87,11 @@ module Belt
87
87
  end
88
88
 
89
89
  def find_routes_file
90
- candidates = ['config/routes.tf.rb', 'infrastructure/routes.tf.rb']
90
+ candidates = [
91
+ 'config/routes.rb',
92
+ 'config/routes.tf.rb',
93
+ 'infrastructure/routes.tf.rb'
94
+ ]
91
95
  candidates.find { |f| File.exist?(f) }
92
96
  end
93
97
 
@@ -32,7 +32,7 @@ module Belt
32
32
  puts 'Usage: belt setup <state|tables|frontend> [options]'
33
33
  puts "\nSubcommands:"
34
34
  puts ' state Set up S3 bucket for Terraform state'
35
- puts ' tables Generate DynamoDB table definitions from schema.tf.rb'
35
+ puts ' tables Generate DynamoDB table definitions from contracts.rb'
36
36
  puts ' frontend Generate S3 + CloudFront infrastructure for frontend hosting'
37
37
  exit 1
38
38
  end
@@ -25,14 +25,19 @@ module Belt
25
25
  { name: n, type: t || 'string' }
26
26
  end
27
27
 
28
- # If no fields provided, try to read from schema.tf.rb
28
+ # If no fields provided, try to read from contracts.rb
29
29
  fields = read_schema_fields(name) if fields.empty?
30
30
 
31
31
  new(name, fields).generate
32
32
  end
33
33
 
34
34
  def self.read_schema_fields(name)
35
- schema_file = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].find { |f| File.exist?(f) }
35
+ schema_file = [
36
+ 'config/contracts.rb',
37
+ 'config/contracts.tf.rb',
38
+ 'config/schema.tf.rb',
39
+ 'infrastructure/schema.tf.rb'
40
+ ].find { |f| File.exist?(f) }
36
41
  return [] unless schema_file
37
42
 
38
43
  content = File.read(schema_file)