belt 0.2.11 → 0.2.13

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/lib/belt/cli/app_detection.rb +19 -5
  4. data/lib/belt/cli/contracts_command.rb +142 -0
  5. data/lib/belt/cli/destroy_command.rb +74 -0
  6. data/lib/belt/cli/frontend_command.rb +23 -0
  7. data/lib/belt/cli/generate_command.rb +11 -9
  8. data/lib/belt/cli/logs_command.rb +634 -0
  9. data/lib/belt/cli/new_command.rb +2 -2
  10. data/lib/belt/cli/routes_command/schema_loader.rb +17 -7
  11. data/lib/belt/cli/routes_command.rb +7 -3
  12. data/lib/belt/cli/setup_command.rb +1 -1
  13. data/lib/belt/cli/views_command.rb +96 -11
  14. data/lib/belt/cli.rb +6 -0
  15. data/lib/belt/root.rb +12 -3
  16. data/lib/belt/route_dsl.rb +9 -6
  17. data/lib/belt/version.rb +1 -1
  18. data/lib/belt.rb +1 -0
  19. data/lib/templates/frontend_infra/frontend.tf.erb +2 -1
  20. data/lib/templates/generate/controller.rb.erb +13 -18
  21. data/lib/templates/generate/model.rb.erb +1 -14
  22. data/lib/templates/module/frontend.tf.erb +2 -1
  23. data/lib/templates/module/main.tf.erb +1 -1
  24. data/lib/templates/new_app/AGENTS.md.erb +4 -4
  25. data/lib/templates/new_app/Gemfile.erb +0 -2
  26. data/lib/templates/new_app/README.md.erb +2 -2
  27. data/lib/templates/new_app/gitignore.erb +3 -0
  28. data/lib/templates/new_app/lambda/api.rb.erb +0 -6
  29. data/lib/templates/new_app/lambda/config/environment.rb.erb +0 -6
  30. data/lib/templates/new_app/lambda/models/application_record.rb.erb +0 -2
  31. data/lib/templates/plugin/AGENTS.md.erb +1 -1
  32. data/lib/templates/plugin/README.md.erb +1 -1
  33. metadata +11 -3
  34. /data/lib/templates/new_app/config/{schema.tf.rb.erb → contracts.rb.erb} +0 -0
  35. /data/lib/templates/new_app/config/{routes.tf.rb.erb → routes.rb.erb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43ae84461595be08ca6caada3afd148ccfa0db94bfc2e2bd864a9b466d41f087
4
- data.tar.gz: 1f33826b8c7a7b068458db8ca38aa9910079e16f04c3c5c727550f084ed85075
3
+ metadata.gz: 43bc6e6cb774005faa3bab6b5133403255636327e3dd017ad4acad8fd9a2c884
4
+ data.tar.gz: fe11f89e1efa821750dea2953c70bbc21fe24bea394d418c2b870a2804adf331
5
5
  SHA512:
6
- metadata.gz: 81532edca53a084bdd6aabf5e7027fd1653ec0fc4c592b0bdb60374283c7c59512a2217405d3a19f32a975bcbcbbebdf9f9a97d952a25b1567e05af8e2cb879d
7
- data.tar.gz: d5ff609d6526f8cc59964df6da091a43150eca9d1c6f625dee27b070db0c56bd90ef58ef11c42f57e27259f169c477a4c1c548f9ca0f4c4f916c8bea540763bb
6
+ metadata.gz: d5c4900624a6435b178455b5aa7e6f34c08d7c2e75e2d1ec3c1b69e717a5964a390ba59a4ff8ccad1be797d29273bddc4354c235f83070d00bb7517d60bcb38e
7
+ data.tar.gz: a47bc3063743a278e4241faf65c510ede23ec922c167469f2654627f3079bb558893194f717979002354e07153e41cee257785e8b0f7dea187fc391fb351e5f3
data/CHANGELOG.md CHANGED
@@ -1,6 +1,47 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.2.12
4
+
5
+ ### Rename: `routes.tf.rb` → `routes.rb`, `schema.tf.rb` → `contracts.rb`
6
+
7
+ The `.tf.rb` extension was a vestige of when these files produced HCL output — that hasn't been the case for a while. New apps now get clean, Rails-familiar names:
8
+
9
+ - `config/routes.rb` — API route definitions
10
+ - `config/contracts.rb` — API request/response contracts
11
+
12
+ **Backward compatible:** All detection helpers (`Belt.root`, `Belt.routes_file`, `Belt.contracts_file`, `find_routes_file_path`, `find_contracts_file_path`) check new names first, then fall back to `*.tf.rb` and `infrastructure/` paths. Existing apps continue working without changes.
13
+
14
+ ### New command: `belt contracts`
15
+
16
+ Dedicated CLI command for inspecting API contracts, separate from `belt routes`:
17
+
18
+ ```bash
19
+ belt contracts # Human-readable table of request/response models
20
+ belt contracts -f json # JSON output (for tooling/CI)
21
+ belt contracts -g post # Filter by pattern
22
+ belt contracts --file path.rb # Explicit file override
23
+ ```
24
+
25
+ Previously, contracts were only accessible as a side-effect of `belt routes -f json --schema <file>`. Now they have their own first-class command. `belt routes -f json` continues to include models for backward compatibility.
26
+
27
+ ### Other changes
28
+
29
+ - `Belt.schema_file` is now an alias for `Belt.contracts_file`
30
+ - `find_schema_file_path` is now an alias for `find_contracts_file_path`
31
+ - Module template updated: `source` points to `config/routes.rb`
32
+ - All scaffold/generator help text and templates reference new filenames
33
+
34
+ ## 0.2.13
35
+
36
+ ### Template cleanup
37
+
38
+ - **Model template**: single-line `attr_accessor` instead of one per attribute; removed redundant `to_h` (ActiveItem::Base already provides it)
39
+ - **Controller template**: uses implicit response pattern (instance variable assigns) instead of explicit `success_response` calls; `response_status :created` for create actions, `head :no_content` for destroy
40
+ - **Gemfile template**: removed `activeitem` and `lambda_loadout` (already belt gem dependencies)
41
+ - **gitignore template**: excludes `lambda/lib/routes/` (generated artifact from `belt routes`)
42
+ - **Removed `require 'activeitem'`** from api.rb, environment.rb, and application_record.rb templates (belt requires it transitively)
43
+ - **Removed `ActiveItem.configure` boilerplate** from api.rb and environment.rb templates (activeitem 0.0.13+ defaults `table_prefix` and `environment` from ENV vars)
44
+ - Bumped activeitem dependency to `>= 0.0.13`
4
45
 
5
46
  ## 0.2.11
6
47
 
@@ -40,17 +40,31 @@ module Belt
40
40
  name.to_s.downcase.tr('_', '-')
41
41
  end
42
42
 
43
- # Finds routes.tf.rb checking config/ first, then infrastructure/ (legacy).
43
+ # Finds routes file checking config/routes.rb first, then legacy paths.
44
44
  def find_routes_file_path
45
- candidates = ['config/routes.tf.rb', 'infrastructure/routes.tf.rb']
45
+ candidates = [
46
+ 'config/routes.rb',
47
+ 'config/routes.tf.rb',
48
+ 'infrastructure/routes.tf.rb'
49
+ ]
46
50
  candidates.find { |f| File.exist?(f) }
47
51
  end
48
52
 
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']
53
+ # Finds contracts file checking config/contracts.rb first, then legacy paths.
54
+ def find_contracts_file_path
55
+ candidates = [
56
+ 'config/contracts.rb',
57
+ 'config/contracts.tf.rb',
58
+ 'config/schema.tf.rb',
59
+ 'infrastructure/schema.tf.rb'
60
+ ]
52
61
  candidates.find { |f| File.exist?(f) }
53
62
  end
63
+
64
+ # Legacy alias for backward compatibility
65
+ def find_schema_file_path
66
+ find_contracts_file_path
67
+ end
54
68
  end
55
69
  end
56
70
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+ require_relative '../route_dsl'
6
+ require_relative 'routes_command/schema_loader'
7
+
8
+ module Belt
9
+ module CLI
10
+ class ContractsCommand
11
+ include RoutesCommand::SchemaLoader
12
+
13
+ def self.run(args)
14
+ new(args).run
15
+ end
16
+
17
+ def initialize(args)
18
+ @options = {}
19
+ parse_options(args)
20
+ end
21
+
22
+ def run
23
+ contracts_file = find_contracts_file
24
+ unless contracts_file
25
+ abort 'Error: No contracts file found. ' \
26
+ 'Expected config/contracts.rb (or config/contracts.tf.rb, config/schema.tf.rb)'
27
+ end
28
+
29
+ models = load_contracts(contracts_file)
30
+
31
+ if @options[:format] == 'json'
32
+ output_json(models)
33
+ else
34
+ output_concise(models)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def parse_options(args)
41
+ OptionParser.new do |opts|
42
+ opts.banner = 'Usage: belt contracts [options]'
43
+
44
+ opts.on('-f', '--format FORMAT', 'Output format: concise (default), json') do |format|
45
+ @options[:format] = format
46
+ end
47
+
48
+ opts.on('-g', '--grep PATTERN', 'Filter contracts matching pattern') do |pattern|
49
+ @options[:grep] = pattern
50
+ end
51
+
52
+ opts.on('--file FILE', 'Path to contracts file (overrides auto-detection)') do |file|
53
+ @options[:contracts_file] = file
54
+ end
55
+
56
+ opts.on('-h', '--help', 'Show this help') do
57
+ puts opts
58
+ exit
59
+ end
60
+ end.parse!(args)
61
+ end
62
+
63
+ def find_contracts_file
64
+ if @options[:contracts_file]
65
+ file = @options[:contracts_file]
66
+ return file if File.exist?(file)
67
+
68
+ abort "Error: Specified contracts file not found: #{file}"
69
+ end
70
+
71
+ candidates = [
72
+ 'config/contracts.rb',
73
+ 'config/contracts.tf.rb',
74
+ 'config/schema.tf.rb',
75
+ 'infrastructure/schema.tf.rb'
76
+ ]
77
+ candidates.find { |f| File.exist?(f) }
78
+ end
79
+
80
+ def load_contracts(file)
81
+ Belt.instance_variable_set(:@application, nil)
82
+ begin
83
+ eval(File.read(file), binding, file) # rubocop:disable Security/Eval
84
+ rescue StandardError => e
85
+ abort "Error: Failed to load contracts file #{file}: #{e.message}"
86
+ end
87
+
88
+ schema = Belt.application.schema.to_h
89
+ models = build_models_from_schema(schema)
90
+ models = apply_grep(models) if @options[:grep]
91
+ models
92
+ end
93
+
94
+ def apply_grep(models)
95
+ pattern = Regexp.new(@options[:grep], Regexp::IGNORECASE)
96
+ models.select do |m|
97
+ m[:name].match?(pattern) ||
98
+ m[:kind].match?(pattern) ||
99
+ m[:description].match?(pattern)
100
+ end
101
+ end
102
+
103
+ def output_json(models)
104
+ puts JSON.pretty_generate(models: models)
105
+ end
106
+
107
+ def output_concise(models)
108
+ return puts('No contracts defined.') if models.empty?
109
+
110
+ requests = models.select { |m| m[:kind] == 'request' }
111
+ responses = models.select { |m| m[:kind] == 'response' }
112
+
113
+ if requests.any?
114
+ puts 'REQUEST MODELS'
115
+ puts '-' * 60
116
+ requests.each { |m| print_model(m) }
117
+ puts
118
+ end
119
+
120
+ if responses.any?
121
+ puts 'RESPONSE MODELS'
122
+ puts '-' * 60
123
+ responses.each { |m| print_model(m) }
124
+ end
125
+
126
+ puts "\n#{models.length} contract(s) total (#{requests.length} request, #{responses.length} response)"
127
+ end
128
+
129
+ def print_model(model)
130
+ required = model[:required] || []
131
+ props = (model[:properties] || {}).map do |name, meta|
132
+ type = meta['type'] || meta[:type] || 'string'
133
+ req_marker = required.include?(name.to_s) ? ' *' : ''
134
+ "#{name}:#{type}#{req_marker}"
135
+ end
136
+
137
+ puts " #{model[:name]} (#{model[:kind]})"
138
+ puts " #{props.join(', ')}" if props.any?
139
+ end
140
+ end
141
+ end
142
+ end
@@ -258,6 +258,9 @@ module Belt
258
258
  end
259
259
  end
260
260
 
261
+ # Empty S3 buckets before destroy — terraform can't delete non-empty buckets
262
+ empty_s3_buckets_in_state
263
+
261
264
  # Run destroy with auto-approve (user already confirmed)
262
265
  unless system('terraform', 'destroy', '-auto-approve')
263
266
  puts "\n✗ terraform destroy failed."
@@ -269,6 +272,77 @@ module Belt
269
272
  puts ' ✓ Infrastructure destroyed.'
270
273
  end
271
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
344
+ end
345
+
272
346
  def destroy_frontend
273
347
  dir = 'frontend'
274
348
  if Dir.exist?(dir)
@@ -59,6 +59,7 @@ module Belt
59
59
 
60
60
  install_dependencies(dest_dir)
61
61
  setup_frontend_infra_for_existing_environments
62
+ generate_views_for_existing_resources
62
63
 
63
64
  return if @quiet || !@announce
64
65
 
@@ -104,6 +105,28 @@ module Belt
104
105
  puts " create #{frontend_tf}" unless @quiet
105
106
  end
106
107
 
108
+ def generate_views_for_existing_resources
109
+ routes_file = find_routes_file_path
110
+ return unless routes_file && File.exist?(routes_file)
111
+
112
+ resources = extract_resources_from_routes(routes_file)
113
+ return if resources.empty?
114
+
115
+ puts "\n Detected existing resources: #{resources.join(', ')}" unless @quiet
116
+ puts ' Generating views...' unless @quiet
117
+
118
+ require_relative 'views_command'
119
+ resources.each do |resource_name|
120
+ fields = ViewsCommand.read_schema_fields(resource_name)
121
+ ViewsCommand.new(resource_name, fields, force: true).generate
122
+ end
123
+ end
124
+
125
+ def extract_resources_from_routes(routes_file)
126
+ content = File.read(routes_file)
127
+ content.scan(/resources\s+:(\w+)/).flatten.uniq
128
+ end
129
+
107
130
  def copy_template(src_dir, dest_dir)
108
131
  Dir.glob("#{src_dir}/**/*", File::FNM_DOTMATCH).each do |src|
109
132
  next if File.directory?(src)
@@ -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
@@ -478,14 +478,16 @@ module Belt
478
478
  puts " update #{schema_file}"
479
479
  end
480
480
 
481
- # Map generator field types to OpenAPI schema DSL types
481
+ # Map generator field types to schema DSL types.
482
+ # Preserves :text, :date, :datetime so views can render appropriate form inputs.
482
483
  def schema_type_for(field_type)
483
484
  case field_type.to_s
484
- when 'text' then 'string'
485
+ when 'text' then 'text'
485
486
  when 'integer' then 'integer'
486
487
  when 'float' then 'number'
487
488
  when 'boolean' then 'boolean'
488
- when 'date', 'datetime' then 'string'
489
+ when 'date' then 'date'
490
+ when 'datetime' then 'datetime'
489
491
  else 'string'
490
492
  end
491
493
  end
@@ -505,7 +507,7 @@ module Belt
505
507
  return unless Dir.exist?('frontend/src')
506
508
  return if @skip_views
507
509
 
508
- Belt::CLI::ViewsCommand.new(@name, @fields).generate
510
+ Belt::CLI::ViewsCommand.new(@name, @fields, force: @force).generate
509
511
  end
510
512
  end
511
513
  end